Skip to content
Merged
166 changes: 103 additions & 63 deletions packages/3-extensions/supabase/scripts/generate-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
namespacePslExtensionBlocks,
type PslDocumentAst,
type PslExtensionBlock,
type PslField,
type PslModel,
type PslNamedTypeDeclaration,
type PslNamespace,
Expand Down Expand Up @@ -281,68 +282,104 @@ function rewriteFieldTypeNames(
});
}

function namedTypeSignature(declaration: PslNamedTypeDeclaration): string {
return JSON.stringify({
baseType: declaration.baseType,
typeConstructor: declaration.typeConstructor,
attributes: declaration.attributes,
});
/**
* Curated storage-type aliases, keyed by the type as `contract infer` writes
* it. Hand-authored in the pack's first contract (commit 7a9426e2,
* "using named types for the uuid/timestamptz column types") and preserved
* here so `contract:generate` reproduces them instead of inlining every
* column's full type.
*
* The alias is chosen by how the type is written, never by what the column
* means: a new Supabase release that adds any `character varying(255)` column
* will have it named `Parent`, whether or not that reads correctly. Check the
* names after refreshing the fixture.
*/
const NAMED_TYPE_ALIASES: Readonly<Record<string, string>> = {
Inet: 'IpAddress',
Json: 'Payload',
SmallInt: 'EmailChangeConfirmStatus',
Timestamp: 'CreatedAt',
Uuid: 'Id',
'VarChar(40)': 'Hash',
'VarChar(64)': 'IpAddress2',
'VarChar(100)': 'Name',
'VarChar(255)': 'Parent',
};

/** The type as `printPsl` would write it, e.g. `Uuid` or `VarChar(255)`. */
function printedFieldType(field: PslField): string {
const { typeConstructor } = field;
if (!typeConstructor) return field.typeName;
const path = typeConstructor.path.join('.');
if (typeConstructor.args.length === 0) return path;
const args = typeConstructor.args.map((arg) =>
arg.kind === 'positional' ? arg.value : `${arg.name}: ${arg.value}`,
);
return `${path}(${args.join(', ')})`;
}

/**
* `auth` and `storage` are inferred independently, so each seeds its own
* named-type registry from its own columns — the same underlying `Uuid`
* storage type can come out as `Id` in one schema and `Owner` in the other. Groups every declaration by structural signature (ignoring name),
* keeps one canonical declaration per signature (the first-seen — `auth`'s
* declarations are passed first), and returns the old-name -> canonical-name
* map for every non-canonical name so callers fold it into the global
* field-`typeName` rewrite alongside the model-rename maps.
* Rewrites every scalar field whose printed type has an alias to reference
* that alias, and records which aliases were used so only those are declared.
* `modelNames` keeps a relation field out of the lookup: its type name is the
* target model's name, which could one day collide with an alias name.
*/
function canonicalizeNamedTypes(
declarationLists: readonly (readonly PslNamedTypeDeclaration[])[],
): {
readonly declarations: readonly PslNamedTypeDeclaration[];
readonly renameMap: ReadonlyMap<string, string>;
} {
const bySignature = new Map<string, PslNamedTypeDeclaration[]>();
for (const list of declarationLists) {
for (const declaration of list) {
const signature = namedTypeSignature(declaration);
const group = bySignature.get(signature);
if (group) {
group.push(declaration);
} else {
bySignature.set(signature, [declaration]);
function applyNamedTypeAliases(
namespace: PslNamespace,
modelNames: ReadonlySet<string>,
used: Set<string>,
): PslNamespace {
let changed = false;
const models = namespace.models.map((model) => {
const fields = model.fields.map((field) => {
if (
field.typeNamespaceId !== undefined ||
field.typeContractSpaceId !== undefined ||
modelNames.has(field.typeName)
) {
return field;
}
}
}
const alias = NAMED_TYPE_ALIASES[printedFieldType(field)];
if (alias === undefined) return field;
changed = true;
used.add(alias);
const { typeConstructor: _replacedByAlias, ...rest } = field;
return { ...rest, typeName: alias };
});
return { ...model, fields };
});

const declarations: PslNamedTypeDeclaration[] = [];
const renameMap = new Map<string, string>();
for (const group of bySignature.values()) {
const [canonical] = group;
if (!canonical) continue;
declarations.push(canonical);
for (const declaration of group) {
if (declaration.name !== canonical.name) {
renameMap.set(declaration.name, canonical.name);
}
}
}
declarations.sort((a, b) => a.name.localeCompare(b.name));
if (!changed) return namespace;

return { declarations, renameMap };
return makePslNamespace({
kind: 'namespace',
name: namespace.name,
entries: makePslNamespaceEntries(
models,
namespace.compositeTypes,
namespacePslExtensionBlocks(namespace),
),
span: namespace.span,
});
}

interface InferredSchema {
readonly namespace: PslNamespace;
readonly types: readonly PslNamedTypeDeclaration[];
function namedTypeDeclarations(used: ReadonlySet<string>): readonly PslNamedTypeDeclaration[] {
return Object.entries(NAMED_TYPE_ALIASES)
.filter(([, alias]) => used.has(alias))
.map(([baseType, name]) => ({
kind: 'namedType' as const,
name,
baseType,
attributes: [],
span: SYNTHETIC_SPAN,
}))
.sort((a, b) => a.name.localeCompare(b.name));
}

async function introspectSchema(
driver: Awaited<ReturnType<typeof postgresDriverDescriptor.create>>,
schemaName: string,
): Promise<InferredSchema> {
): Promise<PslNamespace> {
const controlStack = createControlStack({
family: sqlFamilyDescriptor,
target: postgresTargetDescriptor,
Expand Down Expand Up @@ -370,8 +407,7 @@ async function introspectSchema(

// `@@rls` is emitted natively by `inferPslContract` from each table node's
// `rlsEnabled` — no out-of-band appender needed.
const defaultsFixed = applyDefaultOmissions(namespace, DEFAULT_OMISSIONS[schemaName] ?? {});
return { namespace: defaultsFixed, types: ast.types?.declarations ?? [] };
return applyDefaultOmissions(namespace, DEFAULT_OMISSIONS[schemaName] ?? {});
}

async function main(): Promise<void> {
Expand All @@ -393,8 +429,8 @@ async function main(): Promise<void> {
}

const driver = await postgresDriverDescriptor.create(connectionString);
let auth: InferredSchema;
let storage: InferredSchema;
let auth: PslNamespace;
let storage: PslNamespace;
try {
auth = await introspectSchema(driver, 'auth');
storage = await introspectSchema(driver, 'storage');
Expand All @@ -403,31 +439,35 @@ async function main(): Promise<void> {
if (database) await database.close();
}

const authRenamed = renameModels(auth.namespace, MODEL_RENAMES['auth'] ?? {});
const storageRenamed = renameModels(storage.namespace, MODEL_RENAMES['storage'] ?? {});
const { declarations: canonicalTypes, renameMap: typeRenameMap } = canonicalizeNamedTypes([
auth.types,
storage.types,
]);
const authRenamed = renameModels(auth, MODEL_RENAMES['auth'] ?? {});
const storageRenamed = renameModels(storage, MODEL_RENAMES['storage'] ?? {});

const globalRenameMap = new Map<string, string>([
...authRenamed.renameMap,
...storageRenamed.renameMap,
...typeRenameMap,
]);

const renamedNamespaces = [authRenamed.namespace, storageRenamed.namespace].map((namespace) =>
rewriteFieldTypeNames(namespace, globalRenameMap),
);
const modelNames = new Set(
renamedNamespaces.flatMap((namespace) => namespace.models.map((model) => model.name)),
);
const usedAliases = new Set<string>();
const namespaces = [
roleNamespace(),
rewriteFieldTypeNames(authRenamed.namespace, globalRenameMap),
rewriteFieldTypeNames(storageRenamed.namespace, globalRenameMap),
...renamedNamespaces.map((namespace) =>
applyNamedTypeAliases(namespace, modelNames, usedAliases),
),
];
const declarations = namedTypeDeclarations(usedAliases);

const merged: PslDocumentAst = {
kind: 'document',
sourceId: 'supabase-reference',
namespaces,
...(canonicalTypes.length > 0
? { types: { kind: 'types', declarations: canonicalTypes, span: SYNTHETIC_SPAN } }
...(declarations.length > 0
? { types: { kind: 'types', declarations, span: SYNTHETIC_SPAN } }
: {}),
span: SYNTHETIC_SPAN,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# Contract fidelity notes

The shipped contract (`contract.prisma` → emitted `contract.json` / `contract.d.ts`) is **generated, not hand-authored**: `pnpm contract:generate` restores the reference fixture ([`test/fixtures/supabase-reference/`](../../test/fixtures/supabase-reference/)) into a fresh PGlite database, introspects the `auth` and `storage` schemas, infers PSL per schema, assembles the `auth`/`storage` `namespace` blocks plus a `namespace unbound { }` block carrying the three `role` blocks (from `src/contract/roles.ts`'s `SupabaseRole.values`), and emits. Rerunning the generator today does not reproduce the committed file: its output inlines the hand-authored `types {}` alias block, declares 43 `@@check` constraints the committed contract omits, writes `DateTime` columns as `Timestamptz` (same codec), and prints one enum default as a literal instead of `dbgenerated(...)`; reconciling the committed contract with the generator is tracked separately. `contract.prisma` is fully self-describing — nothing is injected outside of PSL text during emit.
The shipped contract (`contract.prisma` → emitted `contract.json` / `contract.d.ts`) is **generated, not hand-authored**: `pnpm contract:generate` restores the reference fixture ([`test/fixtures/supabase-reference/`](../../test/fixtures/supabase-reference/)) into a fresh PGlite database, introspects the `auth` and `storage` schemas, infers PSL per schema, assembles the `types { }` alias block, the `auth`/`storage` `namespace` blocks and a `namespace unbound { }` block carrying the three `role` blocks (from `src/contract/roles.ts`'s `SupabaseRole.values`), and emits. Rerunning it reproduces the committed file exactly — a second run leaves `git status` clean — so the three files here are the generator's own output and nothing else. `contract.prisma` is fully self-describing: nothing is injected outside of PSL text during emit.

**Reference version:** supabase/postgres:17.6.1.106 (PostgreSQL 17.6), gotrue v2.188.1, storage-api v1.54.1, captured 2026-07-12 with supabase CLI 2.95.4. Supabase-internal schema drifts across platform upgrades; refresh by re-capturing the fixture from a newer stack and rerunning `contract:generate`.

## The safety asymmetry this file relies on

Everything the pack declares is `control: 'external'`. Under `external`, `db verify` **fails on a declared shape the live database lacks** and **tolerates everything live that the contract does not declare** (extra schemas, tables, columns, indexes, defaults). So *under-declaring is safe and wrong-declaring is not* — every entry below is an omission, never an approximation. The round-trip test (`test/reference-fixture-verify.integration.test.ts`) pins that the shipped contract verifies clean against the restored reference, with the undeclared schemas (`realtime`, `vault`, …) present.

The 43 `CHECK` constraints are a newly declared surface, read from the one pinned reference build below. They were previously a tolerated live extra; now they are a declared shape, so a consumer whose Supabase build declares a different constraint set fails verify and cannot repair it, because no plan may emit DDL against an `external` table. That is the same bet the pack already makes on tables, native enums and indexes, and it is the reason the reference version is pinned and the fixture refresh is a deliberate step.

## What the contract deliberately does not declare

The machine-readable version of the default list lives in `scripts/generate-contract.ts` (`DEFAULT_OMISSIONS`) with the full reasoning; this is the audit summary.

**Columns:** none. Every live column of every declared table is declared, including the nullable `text[]` columns `storage.buckets.allowed_mime_types` and `storage.objects.path_tokens` (`String[]?`). `path_tokens` is `GENERATED ALWAYS`, so it is declared but not user-writable.

**Column defaults (1):** `auth.users.phone`. Its live `DEFAULT NULL` on a nullable column is a no-op (the same as no default at all), but the raw-default parser round-trips it as an explicit `@default(null)`, which the interpreter rejects (`PSL_INVALID_DEFAULT_VALUE` — `null` is not a value literal). Dropping the default changes nothing observable: the column type is declared in full, it is still nullable, and it still has no enforced default. Every other live default is declared, including the `'{}'::text[]` list defaults on `auth.custom_oauth_providers` (`@default([])`) and the jsonb `dbgenerated(...)` defaults — `db verify`'s permanent-drift disagreement on those is fixed generically, at the postgres target's `SchemaIR` construction, so it needs no authoring-side omission.
**Column defaults (1):** `auth.users.phone`. Its live `DEFAULT NULL` on a nullable column is a no-op (the same as no default at all), but the raw-default parser round-trips it as an explicit `@default(null)`, which the interpreter rejects (`PSL_INVALID_DEFAULT_VALUE` — `null` is not a value literal). Dropping the default changes nothing observable: the column type is declared in full, it is still nullable, and it still has no enforced default. Every other live default is declared, including the `'{}'::text[]` list defaults on `auth.custom_oauth_providers` (`@default([])`) and the jsonb `dbgenerated(...)` defaults — `db verify`'s permanent-drift disagreement on those is fixed generically, at the postgres target's `SchemaIR` construction, so it needs no authoring-side omission. The six native-enum columns whose live default is a cast (`'STANDARD'::storage.buckettype` and friends) are declared as member literals — `@default("STANDARD")` — because `contract infer` reads the member out of the cast; the emitted default is `{ kind: 'literal' }` rather than `{ kind: 'function' }`, and it verifies clean against the same live default.

**Indexes:**

Expand All @@ -24,6 +26,12 @@ The machine-readable version of the default list lives in `scripts/generate-cont

**Generated columns** (`auth.users.confirmed_at`, `auth.identities.email`, `storage.objects.path_tokens`): declared as ordinary columns. Introspection reports them identically on the authored and live sides, so verify is clean; the contract does not record the generation expression.

**Check constraints:** all 43 live `CHECK` constraints on a declared table are declared — see "What is complete". The four `text[]` columns additionally carry `@noCheck(elementNotNull)`, which `contract infer` writes for any list column with no live check at the derived wire name; the committed contract reproduces the generator's output, so it carries the waiver too. The waiver's only effect on the emitted artefact is the `"noCheck": ["elementNotNull"]` key, which feeds the storage hash. It changes nothing about what `db verify` demands: the pack's `defaultControlPolicy: 'external'` already runs `stripDerivedChecksFromNonManagedTables` over every table before emit, so a derived check never reaches the contract whether the waiver is written or not.

## The named types are chosen by how a type is written

`contract.prisma` opens with a `types { }` block of nine curated aliases (`Id = Uuid`, `Parent = VarChar(255)`, `Payload = Json`, and so on), and 85 columns reference an alias instead of writing their type out. The aliases are hand-picked names, but the generator applies them mechanically, matching on how a type is written rather than on what a column means: the table lives in `scripts/generate-contract.ts` (`NAMED_TYPE_ALIASES`) and maps a printed type such as `VarChar(255)` to a name such as `Parent`. A new Supabase release that adds any `character varying(255)` column therefore picks up the name `Parent` automatically, whether or not that reads correctly. After refreshing the fixture, read the new columns' alias names and rename or add aliases if one of them no longer makes sense.

## What is complete

Every `auth` (23) and `storage` (10) table of the reference version, all 10 native enum types, and the three platform roles. Schemas the pack does not own (`realtime`, `vault`, `pgsodium`, `extensions`, `graphql*`, `net`, `supabase_functions`, `_realtime`) are deliberately undeclared: they belong to Supabase subsystems and Postgres extensions this pack does not model, and under `external` control an undeclared live schema is a tolerated extra (the safety asymmetry above), so declaring them would add surface without changing what verifies.
Every `auth` (23) and `storage` (10) table of the reference version, all 10 native enum types, the three platform roles, and all 43 `CHECK` constraints the reference declares on a table this pack owns. (The fixture has 45 `CHECK` constraints; the other two are on `_realtime.tenants` and `realtime.subscription`, in schemas the pack does not declare.) Schemas the pack does not own (`realtime`, `vault`, `pgsodium`, `extensions`, `graphql*`, `net`, `supabase_functions`, `_realtime`) are deliberately undeclared: they belong to Supabase subsystems and Postgres extensions this pack does not model, and under `external` control an undeclared live schema is a tolerated extra (the safety asymmetry above), so declaring them would add surface without changing what verifies.
Loading
Loading