From 9bbdb0bab1baa08924ec406a2dfc07ff407092be Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 18 Sep 2026 13:10:54 +0200 Subject: [PATCH 1/7] feat(extension-supabase): the contract generator writes the curated named types `contract infer` never produces a `types { }` block, so the generator was dropping the nine hand-authored storage-type aliases (`Id`, `Parent`, `Payload`, ...) and inlining each column full type. That was the main reason rerunning `contract:generate` did not reproduce the committed contract. Declare the aliases in the script, keyed by the type spelling infer produces, rewrite each matching scalar field to reference its alias, and emit a `types` block holding the aliases that were used. The mapping is by type spelling, not by column meaning, so a future Supabase release that adds a `varchar(255)` column will pick up `Parent` automatically and the name needs rechecking. Drop `canonicalizeNamedTypes` and the `InferredSchema` wrapper it needed: they merged named-type registries that `inferPostgresPslContract` never produces. Pin the nine named types and the 43 auth/storage check constraints in contract-completeness. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../supabase/scripts/generate-contract.ts | 162 +++++++++++------- .../test/contract-completeness.test.ts | 35 +++- 2 files changed, 133 insertions(+), 64 deletions(-) diff --git a/packages/3-extensions/supabase/scripts/generate-contract.ts b/packages/3-extensions/supabase/scripts/generate-contract.ts index 846851c0ceb2..f3641720c3c6 100644 --- a/packages/3-extensions/supabase/scripts/generate-contract.ts +++ b/packages/3-extensions/supabase/scripts/generate-contract.ts @@ -28,6 +28,7 @@ import { namespacePslExtensionBlocks, type PslDocumentAst, type PslExtensionBlock, + type PslField, type PslModel, type PslNamedTypeDeclaration, type PslNamespace, @@ -281,68 +282,101 @@ 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 spelling `contract infer` + * produces. 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 type spelling, 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> = { + 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 fieldTypeSpelling(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 type spelling has an alias to reference + * that alias, and records which aliases were used so only those are declared. */ -function canonicalizeNamedTypes( - declarationLists: readonly (readonly PslNamedTypeDeclaration[])[], -): { - readonly declarations: readonly PslNamedTypeDeclaration[]; - readonly renameMap: ReadonlyMap; -} { - const bySignature = new Map(); - 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, used: Set): PslNamespace { + let changed = false; + const models = namespace.models.map((model) => { + const fields = model.fields.map((field) => { + if (field.typeNamespaceId !== undefined || field.typeContractSpaceId !== undefined) { + return field; } - } - } + const alias = NAMED_TYPE_ALIASES[fieldTypeSpelling(field)]; + if (alias === undefined) return field; + changed = true; + used.add(alias); + return { + kind: 'field', + name: field.name, + typeName: alias, + optional: field.optional, + list: field.list, + attributes: field.attributes, + span: field.span, + } satisfies PslField; + }); + return { ...model, fields }; + }); - const declarations: PslNamedTypeDeclaration[] = []; - const renameMap = new Map(); - 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): 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>, schemaName: string, -): Promise { +): Promise { const controlStack = createControlStack({ family: sqlFamilyDescriptor, target: postgresTargetDescriptor, @@ -370,8 +404,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 { @@ -393,8 +426,8 @@ async function main(): Promise { } 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'); @@ -403,31 +436,34 @@ async function main(): Promise { 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([ ...authRenamed.renameMap, ...storageRenamed.renameMap, - ...typeRenameMap, ]); + const usedAliases = new Set(); const namespaces = [ roleNamespace(), - rewriteFieldTypeNames(authRenamed.namespace, globalRenameMap), - rewriteFieldTypeNames(storageRenamed.namespace, globalRenameMap), + applyNamedTypeAliases( + rewriteFieldTypeNames(authRenamed.namespace, globalRenameMap), + usedAliases, + ), + applyNamedTypeAliases( + rewriteFieldTypeNames(storageRenamed.namespace, globalRenameMap), + 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, }; diff --git a/packages/3-extensions/supabase/test/contract-completeness.test.ts b/packages/3-extensions/supabase/test/contract-completeness.test.ts index 0afd98300b4c..d6e00870d53a 100644 --- a/packages/3-extensions/supabase/test/contract-completeness.test.ts +++ b/packages/3-extensions/supabase/test/contract-completeness.test.ts @@ -64,9 +64,28 @@ const AUTH_NATIVE_ENUMS = [ const STORAGE_NATIVE_ENUMS = ['buckettype']; +const NAMED_TYPES = [ + 'CreatedAt', + 'EmailChangeConfirmStatus', + 'Hash', + 'Id', + 'IpAddress', + 'IpAddress2', + 'Name', + 'Parent', + 'Payload', +]; + +/** Every `CHECK` the reference fixture declares on an `auth` or `storage` table. */ +const CHECK_CONSTRAINT_COUNT = 43; + +type ContractJsonTable = { + checks?: readonly { name: string }[]; +}; + type ContractJsonNamespace = { entries: { - table?: Record; + table?: Record; native_enum?: Record; role?: Record; }; @@ -74,6 +93,7 @@ type ContractJsonNamespace = { type ContractJsonStorage = { namespaces: Record; + types?: Record; }; describe('contract completeness — auth/storage table, native enum, and role sets', () => { @@ -102,6 +122,19 @@ describe('contract completeness — auth/storage table, native enum, and role se ); }); + it('declares the nine curated named types', () => { + expect(Object.keys(storage.types ?? {}).sort()).toEqual([...NAMED_TYPES].sort()); + }); + + it('declares every auth/storage check constraint', () => { + const names = [auth, storageNs].flatMap((namespace) => + Object.values(namespace?.entries.table ?? {}).flatMap((table) => + (table.checks ?? []).map((check) => check.name), + ), + ); + expect(names).toHaveLength(CHECK_CONSTRAINT_COUNT); + }); + it('declares the three platform roles under external control', () => { const roles = unboundNs?.entries.role ?? {}; expect(Object.keys(roles).sort()).toEqual([...SupabaseRole.values].sort()); From f8147ebbdf88c4df498d59d73bee1fb987833ad8 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 18 Sep 2026 13:11:10 +0200 Subject: [PATCH 2/7] feat(extension-supabase)!: regenerate the contract from the reference fixture Rerunning `contract:generate` now reproduces the committed contract except for three things the generator gets right and the committed file predates. Adopt all three. Checks: 43 `@@check` constraints. `contract infer` gained check inference after this contract was last generated. The fixture declares 45 CHECK constraints, two of them on tables in schemas the pack does not declare (`_realtime.tenants` and `realtime.subscription`), so 43 is every check on a declared table. Type spelling: 78 columns say `Timestamptz` instead of `DateTime`. Identical in the emitted contract - both produce `pg/timestamptz-temporal@1` over `timestamptz`. Enum defaults: six columns (`auth.oauth_clients.client_type`, `auth.oauth_authorizations.response_type`, `auth.oauth_authorizations.status`, and `type` on `storage.buckets`, `storage.buckets_analytics`, `storage.buckets_vectors`) now carry `@default("confidential")` rather than `@default(dbgenerated("'confidential'::auth.oauth_client_type"))`. This one changes the emitted contract, not only its text: the column default goes from `{ kind: "function", expression }` to `{ kind: "literal", value }`. Both describe the same live default; infer now reads the member out of the cast. The round-trip verify test confirms the pack still verifies clean against the restored reference fixture. The storage hash changes to 43f09411473534105017fa715b8932facbdf79feab1bfc75da681beb87f22cbc. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../supabase/src/contract/contract.d.ts | 26 +- .../supabase/src/contract/contract.json | 228 ++++++++++++- .../supabase/src/contract/contract.prisma | 319 ++++++++++-------- 3 files changed, 409 insertions(+), 164 deletions(-) diff --git a/packages/3-extensions/supabase/src/contract/contract.d.ts b/packages/3-extensions/supabase/src/contract/contract.d.ts index cd06be3be270..a9341c536425 100644 --- a/packages/3-extensions/supabase/src/contract/contract.d.ts +++ b/packages/3-extensions/supabase/src/contract/contract.d.ts @@ -34,7 +34,7 @@ import type { } from '@internal/contract/types'; export type StorageHash = - StorageHashBase<'ede079259d126d9153bcb4fc4aa6781d870a255585524e1e95fae9e5af4eef89'>; + StorageHashBase<'43f09411473534105017fa715b8932facbdf79feab1bfc75da681beb87f22cbc'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; @@ -3212,8 +3212,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'code'::auth.oauth_response_type"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'code'>; }; readonly typeParams: { readonly typeName: 'auth.oauth_response_type' }; }; @@ -3232,8 +3232,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'pending'::auth.oauth_authorization_status"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'pending'>; }; readonly typeParams: { readonly typeName: 'auth.oauth_authorization_status' }; }; @@ -3350,8 +3350,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'confidential'::auth.oauth_client_type"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'confidential'>; }; readonly typeParams: { readonly typeName: 'auth.oauth_client_type' }; }; @@ -4768,8 +4768,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'STANDARD'::storage.buckettype"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'STANDARD'>; }; readonly typeParams: { readonly typeName: 'storage.buckettype' }; }; @@ -4833,8 +4833,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'ANALYTICS'::storage.buckettype"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'ANALYTICS'>; }; readonly typeParams: { readonly typeName: 'storage.buckettype' }; }; @@ -4878,8 +4878,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'VECTOR'::storage.buckettype"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'VECTOR'>; }; readonly typeParams: { readonly typeName: 'storage.buckettype' }; }; diff --git a/packages/3-extensions/supabase/src/contract/contract.json b/packages/3-extensions/supabase/src/contract/contract.json index 9f6dee100c85..e533d3a4aa66 100644 --- a/packages/3-extensions/supabase/src/contract/contract.json +++ b/packages/3-extensions/supabase/src/contract/contract.json @@ -4878,6 +4878,80 @@ "uniques": [] }, "custom_oauth_providers": { + "checks": [ + { + "expression": "((authorization_url IS NULL) OR (authorization_url ~~ 'https://%'::text))", + "name": "custom_oauth_providers_authorization_url_https" + }, + { + "expression": "((authorization_url IS NULL) OR (char_length(authorization_url) <= 2048))", + "name": "custom_oauth_providers_authorization_url_length" + }, + { + "expression": "((char_length(client_id) >= 1) AND (char_length(client_id) <= 512))", + "name": "custom_oauth_providers_client_id_length" + }, + { + "expression": "((discovery_url IS NULL) OR (char_length(discovery_url) <= 2048))", + "name": "custom_oauth_providers_discovery_url_length" + }, + { + "expression": "(identifier ~ '^[a-z0-9][a-z0-9:-]{0,48}[a-z0-9]$'::text)", + "name": "custom_oauth_providers_identifier_format" + }, + { + "expression": "((issuer IS NULL) OR ((char_length(issuer) >= 1) AND (char_length(issuer) <= 2048)))", + "name": "custom_oauth_providers_issuer_length" + }, + { + "expression": "((jwks_uri IS NULL) OR (jwks_uri ~~ 'https://%'::text))", + "name": "custom_oauth_providers_jwks_uri_https" + }, + { + "expression": "((jwks_uri IS NULL) OR (char_length(jwks_uri) <= 2048))", + "name": "custom_oauth_providers_jwks_uri_length" + }, + { + "expression": "((char_length(name) >= 1) AND (char_length(name) <= 100))", + "name": "custom_oauth_providers_name_length" + }, + { + "expression": "((provider_type <> 'oauth2'::text) OR ((authorization_url IS NOT NULL) AND (token_url IS NOT NULL) AND (userinfo_url IS NOT NULL)))", + "name": "custom_oauth_providers_oauth2_requires_endpoints" + }, + { + "expression": "((provider_type <> 'oidc'::text) OR (discovery_url IS NULL) OR (discovery_url ~~ 'https://%'::text))", + "name": "custom_oauth_providers_oidc_discovery_url_https" + }, + { + "expression": "((provider_type <> 'oidc'::text) OR (issuer IS NULL) OR (issuer ~~ 'https://%'::text))", + "name": "custom_oauth_providers_oidc_issuer_https" + }, + { + "expression": "((provider_type <> 'oidc'::text) OR (issuer IS NOT NULL))", + "name": "custom_oauth_providers_oidc_requires_issuer" + }, + { + "expression": "(provider_type = ANY (ARRAY['oauth2'::text, 'oidc'::text]))", + "name": "custom_oauth_providers_provider_type_check" + }, + { + "expression": "((token_url IS NULL) OR (token_url ~~ 'https://%'::text))", + "name": "custom_oauth_providers_token_url_https" + }, + { + "expression": "((token_url IS NULL) OR (char_length(token_url) <= 2048))", + "name": "custom_oauth_providers_token_url_length" + }, + { + "expression": "((userinfo_url IS NULL) OR (userinfo_url ~~ 'https://%'::text))", + "name": "custom_oauth_providers_userinfo_url_https" + }, + { + "expression": "((userinfo_url IS NULL) OR (char_length(userinfo_url) <= 2048))", + "name": "custom_oauth_providers_userinfo_url_length" + } + ], "columns": { "acceptable_client_ids": { "codecId": "pg/text@1", @@ -4887,6 +4961,9 @@ }, "many": true, "nativeType": "text", + "noCheck": [ + "elementNotNull" + ], "nullable": false }, "attribute_mapping": { @@ -5016,6 +5093,9 @@ }, "many": true, "nativeType": "text", + "noCheck": [ + "elementNotNull" + ], "nullable": false }, "skip_nonce_check": { @@ -5681,6 +5761,40 @@ ] }, "oauth_authorizations": { + "checks": [ + { + "expression": "(char_length(authorization_code) <= 255)", + "name": "oauth_authorizations_authorization_code_length" + }, + { + "expression": "(char_length(code_challenge) <= 128)", + "name": "oauth_authorizations_code_challenge_length" + }, + { + "expression": "(expires_at > created_at)", + "name": "oauth_authorizations_expires_at_future" + }, + { + "expression": "(char_length(nonce) <= 255)", + "name": "oauth_authorizations_nonce_length" + }, + { + "expression": "(char_length(redirect_uri) <= 2048)", + "name": "oauth_authorizations_redirect_uri_length" + }, + { + "expression": "(char_length(resource) <= 2048)", + "name": "oauth_authorizations_resource_length" + }, + { + "expression": "(char_length(scope) <= 4096)", + "name": "oauth_authorizations_scope_length" + }, + { + "expression": "(char_length(state) <= 4096)", + "name": "oauth_authorizations_state_length" + } + ], "columns": { "approved_at": { "codecId": "pg/timestamptz-temporal@1", @@ -5764,8 +5878,8 @@ "response_type": { "codecId": "pg/enum@1", "default": { - "expression": "'code'::auth.oauth_response_type", - "kind": "function" + "kind": "literal", + "value": "code" }, "nativeType": "auth.oauth_response_type", "nullable": false, @@ -5792,8 +5906,8 @@ "status": { "codecId": "pg/enum@1", "default": { - "expression": "'pending'::auth.oauth_authorization_status", - "kind": "function" + "kind": "literal", + "value": "pending" }, "nativeType": "auth.oauth_authorization_status", "nullable": false, @@ -5926,6 +6040,24 @@ "uniques": [] }, "oauth_clients": { + "checks": [ + { + "expression": "(char_length(client_name) <= 1024)", + "name": "oauth_clients_client_name_length" + }, + { + "expression": "(char_length(client_uri) <= 2048)", + "name": "oauth_clients_client_uri_length" + }, + { + "expression": "(char_length(logo_uri) <= 2048)", + "name": "oauth_clients_logo_uri_length" + }, + { + "expression": "(token_endpoint_auth_method = ANY (ARRAY['client_secret_basic'::text, 'client_secret_post'::text, 'none'::text]))", + "name": "oauth_clients_token_endpoint_auth_method_check" + } + ], "columns": { "client_name": { "codecId": "pg/text@1", @@ -5940,8 +6072,8 @@ "client_type": { "codecId": "pg/enum@1", "default": { - "expression": "'confidential'::auth.oauth_client_type", - "kind": "function" + "kind": "literal", + "value": "confidential" }, "nativeType": "auth.oauth_client_type", "nullable": false, @@ -6043,6 +6175,20 @@ "uniques": [] }, "oauth_consents": { + "checks": [ + { + "expression": "((revoked_at IS NULL) OR (revoked_at >= granted_at))", + "name": "oauth_consents_revoked_after_granted" + }, + { + "expression": "(char_length(scopes) <= 2048)", + "name": "oauth_consents_scopes_length" + }, + { + "expression": "(char_length(TRIM(BOTH FROM scopes)) > 0)", + "name": "oauth_consents_scopes_not_empty" + } + ], "columns": { "client_id": { "codecId": "pg/uuid@1", @@ -6164,6 +6310,12 @@ ] }, "one_time_tokens": { + "checks": [ + { + "expression": "(char_length(token_hash) > 0)", + "name": "one_time_tokens_token_hash_check" + } + ], "columns": { "created_at": { "codecId": "pg/timestamp-temporal@1", @@ -6408,6 +6560,20 @@ ] }, "saml_providers": { + "checks": [ + { + "expression": "(char_length(entity_id) > 0)", + "name": "entity_id not empty" + }, + { + "expression": "((metadata_url = NULL::text) OR (char_length(metadata_url) > 0))", + "name": "metadata_url not empty" + }, + { + "expression": "(char_length(metadata_xml) > 0)", + "name": "metadata_xml not empty" + } + ], "columns": { "attribute_mapping": { "codecId": "pg/jsonb@1", @@ -6502,6 +6668,12 @@ ] }, "saml_relay_states": { + "checks": [ + { + "expression": "(char_length(request_id) > 0)", + "name": "request_id not empty" + } + ], "columns": { "created_at": { "codecId": "pg/timestamptz-temporal@1", @@ -6636,6 +6808,12 @@ "uniques": [] }, "sessions": { + "checks": [ + { + "expression": "(char_length(scopes) <= 4096)", + "name": "sessions_scopes_length" + } + ], "columns": { "aal": { "codecId": "pg/enum@1", @@ -6806,6 +6984,12 @@ "uniques": [] }, "sso_domains": { + "checks": [ + { + "expression": "(char_length(domain) > 0)", + "name": "domain not empty" + } + ], "columns": { "created_at": { "codecId": "pg/timestamptz-temporal@1", @@ -6878,6 +7062,12 @@ "uniques": [] }, "sso_providers": { + "checks": [ + { + "expression": "((resource_id = NULL::text) OR (char_length(resource_id) > 0))", + "name": "resource_id not empty" + } + ], "columns": { "created_at": { "codecId": "pg/timestamptz-temporal@1", @@ -6930,6 +7120,12 @@ "uniques": [] }, "users": { + "checks": [ + { + "expression": "((email_change_confirm_status >= 0) AND (email_change_confirm_status <= 2))", + "name": "users_email_change_confirm_status_check" + } + ], "columns": { "aud": { "codecId": "sql/varchar@1", @@ -7235,6 +7431,12 @@ ] }, "webauthn_challenges": { + "checks": [ + { + "expression": "(challenge_type = ANY (ARRAY['signup'::text, 'registration'::text, 'authentication'::text]))", + "name": "webauthn_challenges_challenge_type_check" + } + ], "columns": { "challenge_type": { "codecId": "pg/text@1", @@ -7694,8 +7896,8 @@ "type": { "codecId": "pg/enum@1", "default": { - "expression": "'STANDARD'::storage.buckettype", - "kind": "function" + "kind": "literal", + "value": "STANDARD" }, "nativeType": "storage.buckettype", "nullable": false, @@ -7780,8 +7982,8 @@ "type": { "codecId": "pg/enum@1", "default": { - "expression": "'ANALYTICS'::storage.buckettype", - "kind": "function" + "kind": "literal", + "value": "ANALYTICS" }, "nativeType": "storage.buckettype", "nullable": false, @@ -7843,8 +8045,8 @@ "type": { "codecId": "pg/enum@1", "default": { - "expression": "'VECTOR'::storage.buckettype", - "kind": "function" + "kind": "literal", + "value": "VECTOR" }, "nativeType": "storage.buckettype", "nullable": false, @@ -8622,7 +8824,7 @@ "kind": "postgres-schema" } }, - "storageHash": "ede079259d126d9153bcb4fc4aa6781d870a255585524e1e95fae9e5af4eef89", + "storageHash": "43f09411473534105017fa715b8932facbdf79feab1bfc75da681beb87f22cbc", "types": { "CreatedAt": { "codecId": "pg/timestamp-temporal@1", diff --git a/packages/3-extensions/supabase/src/contract/contract.prisma b/packages/3-extensions/supabase/src/contract/contract.prisma index ebc5c2bdc3cd..1aa42b67c552 100644 --- a/packages/3-extensions/supabase/src/contract/contract.prisma +++ b/packages/3-extensions/supabase/src/contract/contract.prisma @@ -15,11 +15,11 @@ types { namespace auth { model AuditLogEntries { - id Id @id(map: "audit_log_entries_pkey") - instanceId Id? @map("instance_id") + id Id @id(map: "audit_log_entries_pkey") + instanceId Id? @map("instance_id") payload Payload? - createdAt DateTime? @map("created_at") - ipAddress IpAddress2 @default("") @map("ip_address") + createdAt Timestamptz? @map("created_at") + ipAddress IpAddress2 @default("") @map("ip_address") @@index([instanceId], map: "audit_logs_instance_id_idx") @@rls @@ -33,34 +33,34 @@ namespace auth { role Parent? email Parent? encryptedPassword Parent? @map("encrypted_password") - emailConfirmedAt DateTime? @map("email_confirmed_at") - invitedAt DateTime? @map("invited_at") + emailConfirmedAt Timestamptz? @map("email_confirmed_at") + invitedAt Timestamptz? @map("invited_at") confirmationToken Parent? @map("confirmation_token") - confirmationSentAt DateTime? @map("confirmation_sent_at") + confirmationSentAt Timestamptz? @map("confirmation_sent_at") recoveryToken Parent? @map("recovery_token") - recoverySentAt DateTime? @map("recovery_sent_at") + recoverySentAt Timestamptz? @map("recovery_sent_at") emailChangeTokenNew Parent? @map("email_change_token_new") emailChange Parent? @map("email_change") - emailChangeSentAt DateTime? @map("email_change_sent_at") - lastSignInAt DateTime? @map("last_sign_in_at") + emailChangeSentAt Timestamptz? @map("email_change_sent_at") + lastSignInAt Timestamptz? @map("last_sign_in_at") rawAppMetaData Jsonb? @map("raw_app_meta_data") rawUserMetaData Jsonb? @map("raw_user_meta_data") isSuperAdmin Boolean? @map("is_super_admin") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") phone String? @unique(map: "users_phone_key") - phoneConfirmedAt DateTime? @map("phone_confirmed_at") + phoneConfirmedAt Timestamptz? @map("phone_confirmed_at") phoneChange String? @default("") @map("phone_change") phoneChangeToken Parent? @default("") @map("phone_change_token") - phoneChangeSentAt DateTime? @map("phone_change_sent_at") - confirmedAt DateTime? @map("confirmed_at") + phoneChangeSentAt Timestamptz? @map("phone_change_sent_at") + confirmedAt Timestamptz? @map("confirmed_at") emailChangeTokenCurrent Parent? @default("") @map("email_change_token_current") emailChangeConfirmStatus EmailChangeConfirmStatus? @default(0) @map("email_change_confirm_status") - bannedUntil DateTime? @map("banned_until") + bannedUntil Timestamptz? @map("banned_until") reauthenticationToken Parent? @default("") @map("reauthentication_token") - reauthenticationSentAt DateTime? @map("reauthentication_sent_at") + reauthenticationSentAt Timestamptz? @map("reauthentication_sent_at") isSsoUser Boolean @default(false) @map("is_sso_user") - deletedAt DateTime? @map("deleted_at") + deletedAt Timestamptz? @map("deleted_at") isAnonymous Boolean @default(false) @map("is_anonymous") identities AuthIdentity[] mfaFactors MfaFactors[] @@ -80,21 +80,22 @@ namespace auth { @@index(expression: "instance_id, lower(email::text)", map: "users_instance_id_email_idx") @@index([instanceId], map: "users_instance_id_idx") @@index([isAnonymous], map: "users_is_anonymous_idx") + @@check(expression: "((email_change_confirm_status >= 0) AND (email_change_confirm_status <= 2))", map: "users_email_change_confirm_status_check") @@rls @@map("users") } model AuthIdentity { - id Id @id(map: "identities_pkey") @default(dbgenerated("gen_random_uuid()")) - providerId String @map("provider_id") - userId Id @map("user_id") - identityData Jsonb @map("identity_data") + id Id @id(map: "identities_pkey") @default(dbgenerated("gen_random_uuid()")) + providerId String @map("provider_id") + userId Id @map("user_id") + identityData Jsonb @map("identity_data") provider String - lastSignInAt DateTime? @map("last_sign_in_at") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + lastSignInAt Timestamptz? @map("last_sign_in_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") email String? - user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "identities_user_id_fkey") + user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "identities_user_id_fkey") @@unique([providerId, provider], map: "identities_provider_id_provider_unique") @@index([email], map: "identities_email_idx") @@ -112,27 +113,31 @@ namespace auth { clientName String? @map("client_name") clientUri String? @map("client_uri") logoUri String? @map("logo_uri") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") - deletedAt DateTime? @map("deleted_at") - clientType pg.enum(OauthClientType) @default(dbgenerated("'confidential'::auth.oauth_client_type")) @map("client_type") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") + deletedAt Timestamptz? @map("deleted_at") + clientType pg.enum(OauthClientType) @default("confidential") @map("client_type") tokenEndpointAuthMethod String @map("token_endpoint_auth_method") oauthAuthorizations OauthAuthorizations[] oauthConsents OauthConsents[] sessions AuthSession[] @@index([deletedAt], map: "oauth_clients_deleted_at_idx") + @@check(expression: "(char_length(client_name) <= 1024)", map: "oauth_clients_client_name_length") + @@check(expression: "(char_length(client_uri) <= 2048)", map: "oauth_clients_client_uri_length") + @@check(expression: "(char_length(logo_uri) <= 2048)", map: "oauth_clients_logo_uri_length") + @@check(expression: "(token_endpoint_auth_method = ANY (ARRAY['client_secret_basic'::text, 'client_secret_post'::text, 'none'::text]))", map: "oauth_clients_token_endpoint_auth_method_check") @@map("oauth_clients") } model AuthSession { id Id @id(map: "sessions_pkey") userId Id @map("user_id") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") factorId Id? @map("factor_id") aal pg.enum(AalLevel)? - notAfter DateTime? @map("not_after") + notAfter Timestamptz? @map("not_after") refreshedAt CreatedAt? @map("refreshed_at") userAgent String? @map("user_agent") ip IpAddress? @@ -150,40 +155,59 @@ namespace auth { @@index([oauthClientId], map: "sessions_oauth_client_id_idx") @@index([userId], map: "sessions_user_id_idx") @@index([userId, createdAt], map: "user_id_created_at_idx") + @@check(expression: "(char_length(scopes) <= 4096)", map: "sessions_scopes_length") @@rls @@map("sessions") } model CustomOauthProviders { - id Id @id(map: "custom_oauth_providers_pkey") @default(dbgenerated("gen_random_uuid()")) - providerType String @map("provider_type") - identifier String @unique(map: "custom_oauth_providers_identifier_key") + id Id @id(map: "custom_oauth_providers_pkey") @default(dbgenerated("gen_random_uuid()")) + providerType String @map("provider_type") + identifier String @unique(map: "custom_oauth_providers_identifier_key") name String - clientId String @map("client_id") - clientSecret String @map("client_secret") - acceptableClientIds String[] @default([]) @map("acceptable_client_ids") - scopes String[] @default([]) - pkceEnabled Boolean @default(true) @map("pkce_enabled") - attributeMapping Jsonb @default(dbgenerated("'{}'::jsonb")) @map("attribute_mapping") - authorizationParams Jsonb @default(dbgenerated("'{}'::jsonb")) @map("authorization_params") - enabled Boolean @default(true) - emailOptional Boolean @default(false) @map("email_optional") + clientId String @map("client_id") + clientSecret String @map("client_secret") + acceptableClientIds String[] @default([]) @noCheck(elementNotNull) @map("acceptable_client_ids") + scopes String[] @default([]) @noCheck(elementNotNull) + pkceEnabled Boolean @default(true) @map("pkce_enabled") + attributeMapping Jsonb @default(dbgenerated("'{}'::jsonb")) @map("attribute_mapping") + authorizationParams Jsonb @default(dbgenerated("'{}'::jsonb")) @map("authorization_params") + enabled Boolean @default(true) + emailOptional Boolean @default(false) @map("email_optional") issuer String? - discoveryUrl String? @map("discovery_url") - skipNonceCheck Boolean @default(false) @map("skip_nonce_check") - cachedDiscovery Jsonb? @map("cached_discovery") - discoveryCachedAt DateTime? @map("discovery_cached_at") - authorizationUrl String? @map("authorization_url") - tokenUrl String? @map("token_url") - userinfoUrl String? @map("userinfo_url") - jwksUri String? @map("jwks_uri") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + discoveryUrl String? @map("discovery_url") + skipNonceCheck Boolean @default(false) @map("skip_nonce_check") + cachedDiscovery Jsonb? @map("cached_discovery") + discoveryCachedAt Timestamptz? @map("discovery_cached_at") + authorizationUrl String? @map("authorization_url") + tokenUrl String? @map("token_url") + userinfoUrl String? @map("userinfo_url") + jwksUri String? @map("jwks_uri") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") @@index([createdAt], map: "custom_oauth_providers_created_at_idx") @@index([enabled], map: "custom_oauth_providers_enabled_idx") @@index([identifier], map: "custom_oauth_providers_identifier_idx") @@index([providerType], map: "custom_oauth_providers_provider_type_idx") + @@check(expression: "((authorization_url IS NULL) OR (authorization_url ~~ 'https://%'::text))", map: "custom_oauth_providers_authorization_url_https") + @@check(expression: "((authorization_url IS NULL) OR (char_length(authorization_url) <= 2048))", map: "custom_oauth_providers_authorization_url_length") + @@check(expression: "((char_length(client_id) >= 1) AND (char_length(client_id) <= 512))", map: "custom_oauth_providers_client_id_length") + @@check(expression: "((discovery_url IS NULL) OR (char_length(discovery_url) <= 2048))", map: "custom_oauth_providers_discovery_url_length") + @@check(expression: "(identifier ~ '^[a-z0-9][a-z0-9:-]{0,48}[a-z0-9]$'::text)", map: "custom_oauth_providers_identifier_format") + @@check(expression: "((issuer IS NULL) OR ((char_length(issuer) >= 1) AND (char_length(issuer) <= 2048)))", map: "custom_oauth_providers_issuer_length") + @@check(expression: "((jwks_uri IS NULL) OR (jwks_uri ~~ 'https://%'::text))", map: "custom_oauth_providers_jwks_uri_https") + @@check(expression: "((jwks_uri IS NULL) OR (char_length(jwks_uri) <= 2048))", map: "custom_oauth_providers_jwks_uri_length") + @@check(expression: "((char_length(name) >= 1) AND (char_length(name) <= 100))", map: "custom_oauth_providers_name_length") + @@check(expression: "((provider_type <> 'oauth2'::text) OR ((authorization_url IS NOT NULL) AND (token_url IS NOT NULL) AND (userinfo_url IS NOT NULL)))", map: "custom_oauth_providers_oauth2_requires_endpoints") + @@check(expression: "((provider_type <> 'oidc'::text) OR (discovery_url IS NULL) OR (discovery_url ~~ 'https://%'::text))", map: "custom_oauth_providers_oidc_discovery_url_https") + @@check(expression: "((provider_type <> 'oidc'::text) OR (issuer IS NULL) OR (issuer ~~ 'https://%'::text))", map: "custom_oauth_providers_oidc_issuer_https") + @@check(expression: "((provider_type <> 'oidc'::text) OR (issuer IS NOT NULL))", map: "custom_oauth_providers_oidc_requires_issuer") + @@check(expression: "(provider_type = ANY (ARRAY['oauth2'::text, 'oidc'::text]))", map: "custom_oauth_providers_provider_type_check") + @@check(expression: "((token_url IS NULL) OR (token_url ~~ 'https://%'::text))", map: "custom_oauth_providers_token_url_https") + @@check(expression: "((token_url IS NULL) OR (char_length(token_url) <= 2048))", map: "custom_oauth_providers_token_url_length") + @@check(expression: "((userinfo_url IS NULL) OR (userinfo_url ~~ 'https://%'::text))", map: "custom_oauth_providers_userinfo_url_https") + @@check(expression: "((userinfo_url IS NULL) OR (char_length(userinfo_url) <= 2048))", map: "custom_oauth_providers_userinfo_url_length") @@map("custom_oauth_providers") } @@ -196,10 +220,10 @@ namespace auth { providerType String @map("provider_type") providerAccessToken String? @map("provider_access_token") providerRefreshToken String? @map("provider_refresh_token") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") authenticationMethod String @map("authentication_method") - authCodeIssuedAt DateTime? @map("auth_code_issued_at") + authCodeIssuedAt Timestamptz? @map("auth_code_issued_at") inviteToken String? @map("invite_token") referrer String? oauthClientStateId Id? @map("oauth_client_state_id") @@ -215,11 +239,11 @@ namespace auth { } model Instances { - id Id @id(map: "instances_pkey") + id Id @id(map: "instances_pkey") uuid Id? - rawBaseConfig String? @map("raw_base_config") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + rawBaseConfig String? @map("raw_base_config") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") @@rls @@map("instances") @@ -228,8 +252,8 @@ namespace auth { model MfaAmrClaims { id Id @id(map: "amr_id_pk") sessionId Id @map("session_id") - createdAt DateTime @map("created_at") - updatedAt DateTime @map("updated_at") + createdAt Timestamptz @map("created_at") + updatedAt Timestamptz @map("updated_at") authenticationMethod String @map("authentication_method") session AuthSession @relation(fields: [sessionId], references: [id], onDelete: Cascade, map: "mfa_amr_claims_session_id_fkey", index: false) @@ -244,11 +268,11 @@ namespace auth { friendlyName String? @map("friendly_name") factorType pg.enum(FactorType) @map("factor_type") status pg.enum(FactorStatus) - createdAt DateTime @map("created_at") - updatedAt DateTime @map("updated_at") + createdAt Timestamptz @map("created_at") + updatedAt Timestamptz @map("updated_at") secret String? phone String? - lastChallengedAt DateTime? @unique(map: "mfa_factors_last_challenged_at_key") @map("last_challenged_at") + lastChallengedAt Timestamptz? @unique(map: "mfa_factors_last_challenged_at_key") @map("last_challenged_at") webAuthnCredential Jsonb? @map("web_authn_credential") webAuthnAaguid Id? @map("web_authn_aaguid") lastWebauthnChallengeData Jsonb? @map("last_webauthn_challenge_data") @@ -264,14 +288,14 @@ namespace auth { } model MfaChallenges { - id Id @id(map: "mfa_challenges_pkey") - factorId Id @map("factor_id") - createdAt DateTime @map("created_at") - verifiedAt DateTime? @map("verified_at") - ipAddress IpAddress @map("ip_address") - otpCode String? @map("otp_code") - webAuthnSessionData Jsonb? @map("web_authn_session_data") - factor MfaFactors @relation(fields: [factorId], references: [id], onDelete: Cascade, map: "mfa_challenges_auth_factor_id_fkey", index: false) + id Id @id(map: "mfa_challenges_pkey") + factorId Id @map("factor_id") + createdAt Timestamptz @map("created_at") + verifiedAt Timestamptz? @map("verified_at") + ipAddress IpAddress @map("ip_address") + otpCode String? @map("otp_code") + webAuthnSessionData Jsonb? @map("web_authn_session_data") + factor MfaFactors @relation(fields: [factorId], references: [id], onDelete: Cascade, map: "mfa_challenges_auth_factor_id_fkey", index: false) @@index([createdAt], map: "mfa_challenge_created_at_idx") @@rls @@ -289,25 +313,33 @@ namespace auth { resource String? codeChallenge String? @map("code_challenge") codeChallengeMethod pg.enum(CodeChallengeMethod)? @map("code_challenge_method") - responseType pg.enum(OauthResponseType) @default(dbgenerated("'code'::auth.oauth_response_type")) @map("response_type") - status pg.enum(OauthAuthorizationStatus) @default(dbgenerated("'pending'::auth.oauth_authorization_status")) + responseType pg.enum(OauthResponseType) @default("code") @map("response_type") + status pg.enum(OauthAuthorizationStatus) @default("pending") authorizationCode String? @unique(map: "oauth_authorizations_authorization_code_key") @map("authorization_code") - createdAt DateTime @default(now()) @map("created_at") - expiresAt DateTime @default(dbgenerated("(now() + '00:03:00'::interval)")) @map("expires_at") - approvedAt DateTime? @map("approved_at") + createdAt Timestamptz @default(now()) @map("created_at") + expiresAt Timestamptz @default(dbgenerated("(now() + '00:03:00'::interval)")) @map("expires_at") + approvedAt Timestamptz? @map("approved_at") nonce String? client OauthClients @relation(fields: [clientId], references: [id], onDelete: Cascade, map: "oauth_authorizations_client_id_fkey", index: false) user AuthUser? @relation(fields: [userId], references: [id], onDelete: Cascade, map: "oauth_authorizations_user_id_fkey", index: false) @@index([expiresAt], map: "oauth_auth_pending_exp_idx", where: "(status = 'pending'::auth.oauth_authorization_status)") + @@check(expression: "(char_length(authorization_code) <= 255)", map: "oauth_authorizations_authorization_code_length") + @@check(expression: "(char_length(code_challenge) <= 128)", map: "oauth_authorizations_code_challenge_length") + @@check(expression: "(expires_at > created_at)", map: "oauth_authorizations_expires_at_future") + @@check(expression: "(char_length(nonce) <= 255)", map: "oauth_authorizations_nonce_length") + @@check(expression: "(char_length(redirect_uri) <= 2048)", map: "oauth_authorizations_redirect_uri_length") + @@check(expression: "(char_length(resource) <= 2048)", map: "oauth_authorizations_resource_length") + @@check(expression: "(char_length(scope) <= 4096)", map: "oauth_authorizations_scope_length") + @@check(expression: "(char_length(state) <= 4096)", map: "oauth_authorizations_state_length") @@map("oauth_authorizations") } model OauthClientStates { - id Id @id(map: "oauth_client_states_pkey") - providerType String @map("provider_type") - codeVerifier String? @map("code_verifier") - createdAt DateTime @map("created_at") + id Id @id(map: "oauth_client_states_pkey") + providerType String @map("provider_type") + codeVerifier String? @map("code_verifier") + createdAt Timestamptz @map("created_at") @@index([createdAt], map: "idx_oauth_client_states_created_at") @@map("oauth_client_states") @@ -318,8 +350,8 @@ namespace auth { userId Id @map("user_id") clientId Id @map("client_id") scopes String - grantedAt DateTime @default(now()) @map("granted_at") - revokedAt DateTime? @map("revoked_at") + grantedAt Timestamptz @default(now()) @map("granted_at") + revokedAt Timestamptz? @map("revoked_at") client OauthClients @relation(fields: [clientId], references: [id], onDelete: Cascade, map: "oauth_consents_client_id_fkey", index: false) user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "oauth_consents_user_id_fkey", index: false) @@ -327,6 +359,9 @@ namespace auth { @@index([clientId], map: "oauth_consents_active_client_idx", where: "(revoked_at IS NULL)") @@index([userId, clientId], map: "oauth_consents_active_user_client_idx", where: "(revoked_at IS NULL)") @@index([userId, grantedAt], map: "oauth_consents_user_order_idx") + @@check(expression: "((revoked_at IS NULL) OR (revoked_at >= granted_at))", map: "oauth_consents_revoked_after_granted") + @@check(expression: "(char_length(scopes) <= 2048)", map: "oauth_consents_scopes_length") + @@check(expression: "(char_length(TRIM(BOTH FROM scopes)) > 0)", map: "oauth_consents_scopes_not_empty") @@map("oauth_consents") } @@ -343,6 +378,7 @@ namespace auth { @@index([relatesTo], map: "one_time_tokens_relates_to_hash_idx", type: "hash") @@index([tokenHash], map: "one_time_tokens_token_hash_hash_idx", type: "hash") @@index([userId, tokenType], map: "one_time_tokens_user_id_token_type_key", unique: true) + @@check(expression: "(char_length(token_hash) > 0)", map: "one_time_tokens_token_hash_check") @@rls @@map("one_time_tokens") } @@ -353,8 +389,8 @@ namespace auth { token Parent? @unique(map: "refresh_tokens_token_unique") userId Parent? @map("user_id") revoked Boolean? - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") parent Parent? sessionId Id? @map("session_id") session AuthSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade, map: "refresh_tokens_session_id_fkey", index: false) @@ -371,8 +407,8 @@ namespace auth { model SsoProviders { id Id @id(map: "sso_providers_pkey") resourceId String? @map("resource_id") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") disabled Boolean? samlProviders SamlProviders[] samlRelayStates SamlRelayStates[] @@ -380,6 +416,7 @@ namespace auth { @@index(expression: "lower(resource_id)", map: "sso_providers_resource_id_idx", unique: true) @@index([resourceId], map: "sso_providers_resource_id_pattern_idx") + @@check(expression: "((resource_id = NULL::text) OR (char_length(resource_id) > 0))", map: "resource_id not empty") @@rls @@map("sso_providers") } @@ -391,12 +428,15 @@ namespace auth { metadataXml String @map("metadata_xml") metadataUrl String? @map("metadata_url") attributeMapping Jsonb? @map("attribute_mapping") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") nameIdFormat String? @map("name_id_format") ssoProvider SsoProviders @relation(fields: [ssoProviderId], references: [id], onDelete: Cascade, map: "saml_providers_sso_provider_id_fkey") @@index([ssoProviderId], map: "saml_providers_sso_provider_id_idx") + @@check(expression: "(char_length(entity_id) > 0)", map: "entity_id not empty") + @@check(expression: "((metadata_url = NULL::text) OR (char_length(metadata_url) > 0))", map: "metadata_url not empty") + @@check(expression: "(char_length(metadata_xml) > 0)", map: "metadata_xml not empty") @@rls @@map("saml_providers") } @@ -407,8 +447,8 @@ namespace auth { requestId String @map("request_id") forEmail String? @map("for_email") redirectTo String? @map("redirect_to") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") flowStateId Id? @map("flow_state_id") flowState FlowState? @relation(fields: [flowStateId], references: [id], onDelete: Cascade, map: "saml_relay_states_flow_state_id_fkey", index: false) ssoProvider SsoProviders @relation(fields: [ssoProviderId], references: [id], onDelete: Cascade, map: "saml_relay_states_sso_provider_id_fkey") @@ -416,6 +456,7 @@ namespace auth { @@index([createdAt], map: "saml_relay_states_created_at_idx") @@index([forEmail], map: "saml_relay_states_for_email_idx") @@index([ssoProviderId], map: "saml_relay_states_sso_provider_id_idx") + @@check(expression: "(char_length(request_id) > 0)", map: "request_id not empty") @@rls @@map("saml_relay_states") } @@ -431,46 +472,48 @@ namespace auth { id Id @id(map: "sso_domains_pkey") ssoProviderId Id @map("sso_provider_id") domain String - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") ssoProvider SsoProviders @relation(fields: [ssoProviderId], references: [id], onDelete: Cascade, map: "sso_domains_sso_provider_id_fkey") @@index(expression: "lower(domain)", map: "sso_domains_domain_idx", unique: true) @@index([ssoProviderId], map: "sso_domains_sso_provider_id_idx") + @@check(expression: "(char_length(domain) > 0)", map: "domain not empty") @@rls @@map("sso_domains") } model WebauthnChallenges { - id Id @id(map: "webauthn_challenges_pkey") @default(dbgenerated("gen_random_uuid()")) - userId Id? @map("user_id") - challengeType String @map("challenge_type") - sessionData Jsonb @map("session_data") - createdAt DateTime @default(now()) @map("created_at") - expiresAt DateTime @map("expires_at") - user AuthUser? @relation(fields: [userId], references: [id], onDelete: Cascade, map: "webauthn_challenges_user_id_fkey") + id Id @id(map: "webauthn_challenges_pkey") @default(dbgenerated("gen_random_uuid()")) + userId Id? @map("user_id") + challengeType String @map("challenge_type") + sessionData Jsonb @map("session_data") + createdAt Timestamptz @default(now()) @map("created_at") + expiresAt Timestamptz @map("expires_at") + user AuthUser? @relation(fields: [userId], references: [id], onDelete: Cascade, map: "webauthn_challenges_user_id_fkey") @@index([expiresAt], map: "webauthn_challenges_expires_at_idx") @@index([userId], map: "webauthn_challenges_user_id_idx") + @@check(expression: "(challenge_type = ANY (ARRAY['signup'::text, 'registration'::text, 'authentication'::text]))", map: "webauthn_challenges_challenge_type_check") @@map("webauthn_challenges") } model WebauthnCredentials { - id Id @id(map: "webauthn_credentials_pkey") @default(dbgenerated("gen_random_uuid()")) - userId Id @map("user_id") - credentialId Bytes @map("credential_id") - publicKey Bytes @map("public_key") - attestationType String @default("") @map("attestation_type") + id Id @id(map: "webauthn_credentials_pkey") @default(dbgenerated("gen_random_uuid()")) + userId Id @map("user_id") + credentialId Bytes @map("credential_id") + publicKey Bytes @map("public_key") + attestationType String @default("") @map("attestation_type") aaguid Id? - signCount BigInt @default(0) @map("sign_count") - transports Jsonb @default(dbgenerated("'[]'::jsonb")) - backupEligible Boolean @default(false) @map("backup_eligible") - backedUp Boolean @default(false) @map("backed_up") - friendlyName String @default("") @map("friendly_name") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") - lastUsedAt DateTime? @map("last_used_at") - user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "webauthn_credentials_user_id_fkey") + signCount BigInt @default(0) @map("sign_count") + transports Jsonb @default(dbgenerated("'[]'::jsonb")) + backupEligible Boolean @default(false) @map("backup_eligible") + backedUp Boolean @default(false) @map("backed_up") + friendlyName String @default("") @map("friendly_name") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") + lastUsedAt Timestamptz? @map("last_used_at") + user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "webauthn_credentials_user_id_fkey") @@index([credentialId], map: "webauthn_credentials_credential_id_key", unique: true) @@index([userId], map: "webauthn_credentials_user_id_idx") @@ -543,11 +586,11 @@ namespace storage { model BucketsAnalytics { id Id @id(map: "buckets_analytics_pkey") @default(dbgenerated("gen_random_uuid()")) name String - _type pg.enum(Buckettype) @default(dbgenerated("'ANALYTICS'::storage.buckettype")) @map("type") + _type pg.enum(Buckettype) @default("ANALYTICS") @map("type") format String @default("ICEBERG") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") - deletedAt DateTime? @map("deleted_at") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") + deletedAt Timestamptz? @map("deleted_at") icebergNamespaces IcebergNamespaces[] icebergTables IcebergTables[] @@ -558,9 +601,9 @@ namespace storage { model BucketsVectors { id String @id(map: "buckets_vectors_pkey") - _type pg.enum(Buckettype) @default(dbgenerated("'VECTOR'::storage.buckettype")) @map("type") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + _type pg.enum(Buckettype) @default("VECTOR") @map("type") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") vectorIndexes VectorIndexes[] @@rls @@ -571,8 +614,8 @@ namespace storage { id Id @id(map: "iceberg_namespaces_pkey") @default(dbgenerated("gen_random_uuid()")) bucketName String @map("bucket_name") name String - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") metadata Jsonb @default(dbgenerated("'{}'::jsonb")) catalogId Id @map("catalog_id") icebergTables IcebergTables[] @@ -589,8 +632,8 @@ namespace storage { bucketName String @map("bucket_name") name String location String - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") remoteTableId String? @map("remote_table_id") shardKey String? @map("shard_key") shardId String? @map("shard_id") @@ -618,14 +661,14 @@ namespace storage { id String @id(map: "buckets_pkey") name String owner Id? - createdAt DateTime? @default(now()) @map("created_at") - updatedAt DateTime? @default(now()) @map("updated_at") + createdAt Timestamptz? @default(now()) @map("created_at") + updatedAt Timestamptz? @default(now()) @map("updated_at") public Boolean? @default(false) avifAutodetection Boolean? @default(false) @map("avif_autodetection") fileSizeLimit BigInt? @map("file_size_limit") allowedMimeTypes String[]? @noCheck(elementNotNull) @map("allowed_mime_types") ownerId String? @map("owner_id") - _type pg.enum(Buckettype) @default(dbgenerated("'STANDARD'::storage.buckettype")) @map("type") + _type pg.enum(Buckettype) @default("STANDARD") @map("type") objects StorageObject[] s3MultipartUploads S3MultipartUploads[] s3MultipartUploadsParts S3MultipartUploadsParts[] @@ -643,7 +686,7 @@ namespace storage { key String version String ownerId String? @map("owner_id") - createdAt DateTime @default(now()) @map("created_at") + createdAt Timestamptz @default(now()) @map("created_at") userMetadata Jsonb? @map("user_metadata") metadata Jsonb? s3MultipartUploadsParts S3MultipartUploadsParts[] @@ -664,7 +707,7 @@ namespace storage { etag String ownerId String? @map("owner_id") version String - createdAt DateTime @default(now()) @map("created_at") + createdAt Timestamptz @default(now()) @map("created_at") bucket StorageBucket @relation(fields: [bucketId], references: [id], map: "s3_multipart_uploads_parts_bucket_id_fkey", index: false) upload S3MultipartUploads @relation(fields: [uploadId], references: [id], onDelete: Cascade, map: "s3_multipart_uploads_parts_upload_id_fkey", index: false) @@ -677,9 +720,9 @@ namespace storage { bucketId String? @map("bucket_id") name String? owner Id? - createdAt DateTime? @default(now()) @map("created_at") - updatedAt DateTime? @default(now()) @map("updated_at") - lastAccessedAt DateTime? @default(now()) @map("last_accessed_at") + createdAt Timestamptz? @default(now()) @map("created_at") + updatedAt Timestamptz? @default(now()) @map("updated_at") + lastAccessedAt Timestamptz? @default(now()) @map("last_accessed_at") metadata Jsonb? pathTokens String[]? @noCheck(elementNotNull) @map("path_tokens") version String? @@ -703,8 +746,8 @@ namespace storage { dimension Int distanceMetric String @map("distance_metric") metadataConfiguration Jsonb? @map("metadata_configuration") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") bucket BucketsVectors @relation(fields: [bucketId], references: [id], map: "vector_indexes_bucket_id_fkey", index: false) @@index([name, bucketId], map: "vector_indexes_name_bucket_id_idx", unique: true) From 7fb5d5e5aa4d7c354e85106eac6507124dc5b807 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 18 Sep 2026 13:13:22 +0200 Subject: [PATCH 3/7] docs(extension-supabase): the fidelity notes match the regenerated contract Replace the drift paragraph: rerunning `contract:generate` now reproduces the committed file, and a second run leaves `git status` clean. Record that the checks are declared rather than omitted (43 of the fixture 45; the other two are in schemas the pack does not declare), that the six native-enum defaults are declared as member literals, that all four `text[]` columns waive the derived element-not-null check because real Supabase has no such constraint, and that the nine curated named types are applied by type spelling rather than by what a column means, so a refreshed fixture can hand a new column an alias name that no longer reads correctly. Add the extension upgrade fragment for the new storage hash. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/contract/CONTRACT-FIDELITY.md | 12 ++++++--- .../extension/instructions.md | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md diff --git a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md index 40f63c7fa6d5..d40fba0ed0ac 100644 --- a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md +++ b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md @@ -1,6 +1,6 @@ # 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`. @@ -14,7 +14,7 @@ The machine-readable version of the default list lives in `scripts/generate-cont **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:** @@ -24,6 +24,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:** none of the live ones. All 43 are declared — see "What is complete". In the other direction, the four `text[]` columns waive the `elementNotNull` check the framework would otherwise derive for a list column (`@noCheck(elementNotNull)`, emitted as `"noCheck": ["elementNotNull"]`): real Supabase has no `array_position(col, NULL) IS NULL` constraint on `auth.custom_oauth_providers.acceptable_client_ids`, `auth.custom_oauth_providers.scopes`, `storage.buckets.allowed_mime_types` or `storage.objects.path_tokens`, so expecting one would fail verify against every real database. + +## The named types are chosen by type spelling + +`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 spelling their type out. The aliases are hand-picked names, but the generator applies them mechanically, by type spelling rather than by 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. diff --git a/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md b/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md new file mode 100644 index 000000000000..9d2e7a3bca44 --- /dev/null +++ b/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md @@ -0,0 +1,25 @@ +--- +changes: + - id: supabase-contract-regenerated-from-the-reference-fixture + summary: The Supabase extension contract is regenerated and now declares 43 check constraints, six native-enum defaults as member literals, and two more list columns that waive the derived element-not-null check, so its storage hash changes; re-sign databases that were signed against the previous Supabase contract. + detection: + glob: "**/package.json" + contains: + - '"@prisma/orm-extension-supabase"' +--- + +## `supabase-contract-regenerated-from-the-reference-fixture` + +The `@prisma/orm-extension-supabase` contract is now exactly what `contract:generate` produces from the reference fixture, which it had drifted away from. The Supabase space's storage hash changes from `ede079259d126d9153bcb4fc4aa6781d870a255585524e1e95fae9e5af4eef89` to `43f09411473534105017fa715b8932facbdf79feab1bfc75da681beb87f22cbc`. + +Four things changed in the contract. + +**43 check constraints are now declared.** Every `CHECK` that real Supabase declares on an `auth` or `storage` table — for example `users_email_change_confirm_status_check` and `one_time_tokens_token_hash_check` — is now part of the contract. They already exist on every Supabase database, so `prisma db verify` passes without a schema change. + +**Six native-enum column defaults are declared as member literals.** `auth.oauth_clients.client_type`, `auth.oauth_authorizations.response_type`, `auth.oauth_authorizations.status`, and the `type` column of `storage.buckets`, `storage.buckets_analytics` and `storage.buckets_vectors` previously carried the raw cast expression as their default, for example `{ "kind": "function", "expression": "'STANDARD'::storage.buckettype" }`. They now carry the enum member itself: `{ "kind": "literal", "value": "STANDARD" }`. This is the same live default read a more precise way, so the live databases need no change; if you read a column's declared default out of the contract, expect a literal rather than an expression. + +**Two list columns waive the derived element-not-null check.** `auth.custom_oauth_providers.acceptable_client_ids` and `auth.custom_oauth_providers.scopes` now carry `"noCheck": ["elementNotNull"]`, matching the two `storage` list columns that already did. Real Supabase has no such constraint on these columns, so the contract no longer expects one. + +**78 timestamp columns are spelled `Timestamptz` instead of `DateTime` in the PSL.** Same codec (`pg/timestamptz-temporal@1`) and same emitted column, so this is a text change only. + +A contract that composes the Supabase space references it by id, so your own `contract.json` and `contract.d.ts` do not change. What changes is the signature: a database that was signed against the previous Supabase contract no longer matches the new hash, so run `prisma db sign` against it after upgrading. If you re-emit your own contract, do that first so the composed space is the new one. From 53f7cdf1203e53d191fda2ffb9c5d62937039d73 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 18 Sep 2026 13:37:59 +0200 Subject: [PATCH 4/7] test(extension-supabase): pin the contract data this PR changes, and harden the alias rewrite contract-completeness asserted a bare count of 43 checks, so it passed if a constraint were renamed or swapped. Compare the 43 names instead, the way every sibling test in the file compares a name set. Add the two contract-data facts nothing in this package pinned: the six native-enum defaults as `{ kind: "literal", value }`, and the four list columns carrying `noCheck: ["elementNotNull"]`. All three assertions were checked by mutation - each goes red when its expected list is perturbed. In the generator, `applyNamedTypeAliases` rebuilt each aliased field from seven named properties, which silently dropped any `PslField` property not listed. Spread the field and remove only the type constructor, so a property added to `PslField` later survives. The alias lookup also fell back to `field.typeName`, which is a relation field shape too, so a future Supabase model named `Id` or `Parent` would have had its relations rewritten into scalars; skip a field whose type name is one of the document models. Neither generator change moves the output: regenerating leaves the contract byte-identical at storage hash 43f09411473534105017fa715b8932facbdf79feab1bfc75da681beb87f22cbc. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../supabase/scripts/generate-contract.ts | 40 +++--- .../test/contract-completeness.test.ts | 117 +++++++++++++++++- 2 files changed, 136 insertions(+), 21 deletions(-) diff --git a/packages/3-extensions/supabase/scripts/generate-contract.ts b/packages/3-extensions/supabase/scripts/generate-contract.ts index f3641720c3c6..e35d81590413 100644 --- a/packages/3-extensions/supabase/scripts/generate-contract.ts +++ b/packages/3-extensions/supabase/scripts/generate-contract.ts @@ -321,27 +321,30 @@ function fieldTypeSpelling(field: PslField): string { /** * Rewrites every scalar field whose type spelling 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 applyNamedTypeAliases(namespace: PslNamespace, used: Set): PslNamespace { +function applyNamedTypeAliases( + namespace: PslNamespace, + modelNames: ReadonlySet, + used: Set, +): PslNamespace { let changed = false; const models = namespace.models.map((model) => { const fields = model.fields.map((field) => { - if (field.typeNamespaceId !== undefined || field.typeContractSpaceId !== undefined) { + if ( + field.typeNamespaceId !== undefined || + field.typeContractSpaceId !== undefined || + modelNames.has(field.typeName) + ) { return field; } const alias = NAMED_TYPE_ALIASES[fieldTypeSpelling(field)]; if (alias === undefined) return field; changed = true; used.add(alias); - return { - kind: 'field', - name: field.name, - typeName: alias, - optional: field.optional, - list: field.list, - attributes: field.attributes, - span: field.span, - } satisfies PslField; + const { typeConstructor: _replacedByAlias, ...rest } = field; + return { ...rest, typeName: alias }; }); return { ...model, fields }; }); @@ -444,16 +447,17 @@ async function main(): Promise { ...storageRenamed.renameMap, ]); + 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(); const namespaces = [ roleNamespace(), - applyNamedTypeAliases( - rewriteFieldTypeNames(authRenamed.namespace, globalRenameMap), - usedAliases, - ), - applyNamedTypeAliases( - rewriteFieldTypeNames(storageRenamed.namespace, globalRenameMap), - usedAliases, + ...renamedNamespaces.map((namespace) => + applyNamedTypeAliases(namespace, modelNames, usedAliases), ), ]; const declarations = namedTypeDeclarations(usedAliases); diff --git a/packages/3-extensions/supabase/test/contract-completeness.test.ts b/packages/3-extensions/supabase/test/contract-completeness.test.ts index d6e00870d53a..90a0e761343a 100644 --- a/packages/3-extensions/supabase/test/contract-completeness.test.ts +++ b/packages/3-extensions/supabase/test/contract-completeness.test.ts @@ -76,11 +76,85 @@ const NAMED_TYPES = [ 'Payload', ]; -/** Every `CHECK` the reference fixture declares on an `auth` or `storage` table. */ -const CHECK_CONSTRAINT_COUNT = 43; +/** + * Every `CHECK` the reference fixture declares on an `auth` or `storage` table + * — its 45 `CONSTRAINT … CHECK` names minus `jwt_secret_or_jwt_jwks_required` + * (`_realtime.tenants`) and `subscription_action_filter_check` + * (`realtime.subscription`), both in schemas this pack does not declare. + */ +const CHECK_CONSTRAINTS = [ + 'custom_oauth_providers_authorization_url_https', + 'custom_oauth_providers_authorization_url_length', + 'custom_oauth_providers_client_id_length', + 'custom_oauth_providers_discovery_url_length', + 'custom_oauth_providers_identifier_format', + 'custom_oauth_providers_issuer_length', + 'custom_oauth_providers_jwks_uri_https', + 'custom_oauth_providers_jwks_uri_length', + 'custom_oauth_providers_name_length', + 'custom_oauth_providers_oauth2_requires_endpoints', + 'custom_oauth_providers_oidc_discovery_url_https', + 'custom_oauth_providers_oidc_issuer_https', + 'custom_oauth_providers_oidc_requires_issuer', + 'custom_oauth_providers_provider_type_check', + 'custom_oauth_providers_token_url_https', + 'custom_oauth_providers_token_url_length', + 'custom_oauth_providers_userinfo_url_https', + 'custom_oauth_providers_userinfo_url_length', + 'domain not empty', + 'entity_id not empty', + 'metadata_url not empty', + 'metadata_xml not empty', + 'oauth_authorizations_authorization_code_length', + 'oauth_authorizations_code_challenge_length', + 'oauth_authorizations_expires_at_future', + 'oauth_authorizations_nonce_length', + 'oauth_authorizations_redirect_uri_length', + 'oauth_authorizations_resource_length', + 'oauth_authorizations_scope_length', + 'oauth_authorizations_state_length', + 'oauth_clients_client_name_length', + 'oauth_clients_client_uri_length', + 'oauth_clients_logo_uri_length', + 'oauth_clients_token_endpoint_auth_method_check', + 'oauth_consents_revoked_after_granted', + 'oauth_consents_scopes_length', + 'oauth_consents_scopes_not_empty', + 'one_time_tokens_token_hash_check', + 'request_id not empty', + 'resource_id not empty', + 'sessions_scopes_length', + 'users_email_change_confirm_status_check', + 'webauthn_challenges_challenge_type_check', +]; + +/** Every native-enum column whose live default is a cast, read as the member. */ +const ENUM_LITERAL_DEFAULTS: Readonly> = { + 'auth.oauth_authorizations.response_type': 'code', + 'auth.oauth_authorizations.status': 'pending', + 'auth.oauth_clients.client_type': 'confidential', + 'storage.buckets.type': 'STANDARD', + 'storage.buckets_analytics.type': 'ANALYTICS', + 'storage.buckets_vectors.type': 'VECTOR', +}; + +/** Every list column that waives the derived element-not-null check. */ +const ELEMENT_NOT_NULL_WAIVERS = [ + 'auth.custom_oauth_providers.acceptable_client_ids', + 'auth.custom_oauth_providers.scopes', + 'storage.buckets.allowed_mime_types', + 'storage.objects.path_tokens', +]; + +type ContractJsonColumn = { + codecId: string; + default?: { kind: string; value?: unknown }; + noCheck?: readonly string[]; +}; type ContractJsonTable = { checks?: readonly { name: string }[]; + columns: Record; }; type ContractJsonNamespace = { @@ -126,13 +200,50 @@ describe('contract completeness — auth/storage table, native enum, and role se expect(Object.keys(storage.types ?? {}).sort()).toEqual([...NAMED_TYPES].sort()); }); + const declaredColumns = (['auth', 'storage'] as const).flatMap((namespaceId) => + Object.entries(storage.namespaces[namespaceId]?.entries.table ?? {}).flatMap( + ([tableName, table]) => + Object.entries(table.columns).map(([columnName, column]) => ({ + path: `${namespaceId}.${tableName}.${columnName}`, + column, + })), + ), + ); + it('declares every auth/storage check constraint', () => { const names = [auth, storageNs].flatMap((namespace) => Object.values(namespace?.entries.table ?? {}).flatMap((table) => (table.checks ?? []).map((check) => check.name), ), ); - expect(names).toHaveLength(CHECK_CONSTRAINT_COUNT); + expect(names.sort()).toEqual([...CHECK_CONSTRAINTS].sort()); + }); + + it('reads every native-enum column default as a member literal', () => { + const declared = Object.fromEntries( + declaredColumns + .filter(({ column }) => column.codecId === 'pg/enum@1' && column.default !== undefined) + .map(({ path, column }) => [path, column.default]), + ); + expect(declared).toEqual( + Object.fromEntries( + Object.entries(ENUM_LITERAL_DEFAULTS).map(([path, value]) => [ + path, + { kind: 'literal', value }, + ]), + ), + ); + }); + + it('waives the derived element-not-null check on every list column', () => { + const declared = Object.fromEntries( + declaredColumns + .filter(({ column }) => column.noCheck !== undefined) + .map(({ path, column }) => [path, column.noCheck]), + ); + expect(declared).toEqual( + Object.fromEntries(ELEMENT_NOT_NULL_WAIVERS.map((path) => [path, ['elementNotNull']])), + ); }); it('declares the three platform roles under external control', () => { From a179024e24d4a5d753c847fc1782427e0ec23ff5 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 18 Sep 2026 13:38:13 +0200 Subject: [PATCH 5/7] docs(extension-supabase): correct why the list columns waive the derived check, and scope the check claim to the reference build The fidelity notes and the upgrade fragment both said the `@noCheck(elementNotNull)` waiver was there because the contract would otherwise expect a constraint real Supabase lacks and fail verify. The previous contract disproves that: those columns carried no waiver and the emitted contract held no checks at all, because `defaultControlPolicy: "external"` runs `stripDerivedChecksFromNonManagedTables` over every table before emit. State the real reason instead - `contract infer` writes the waiver for any list column with no live check at the derived wire name, and the committed contract reproduces the generator output - and say in the fragment that the item moves the storage hash and changes nothing else a consumer can observe. Scope the check-constraint claim. The 43 checks come from one pinned reference build and this PR promotes them from a tolerated live extra to a declared shape, so a consumer on a different Supabase build can now fail verify with no way to repair it under external control. Say so in the fragment rather than promising every Supabase database, and record the newly declared surface in the fidelity notes safety-asymmetry section. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md | 4 +++- .../supabase-contract-regenerated/extension/instructions.md | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md index d40fba0ed0ac..a1df868c22c3 100644 --- a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md +++ b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md @@ -8,6 +8,8 @@ The shipped contract (`contract.prisma` → emitted `contract.json` / `contract. 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. @@ -24,7 +26,7 @@ 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:** none of the live ones. All 43 are declared — see "What is complete". In the other direction, the four `text[]` columns waive the `elementNotNull` check the framework would otherwise derive for a list column (`@noCheck(elementNotNull)`, emitted as `"noCheck": ["elementNotNull"]`): real Supabase has no `array_position(col, NULL) IS NULL` constraint on `auth.custom_oauth_providers.acceptable_client_ids`, `auth.custom_oauth_providers.scopes`, `storage.buckets.allowed_mime_types` or `storage.objects.path_tokens`, so expecting one would fail verify against every real database. +**Check constraints:** none of the live ones. All 43 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 type spelling diff --git a/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md b/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md index 9d2e7a3bca44..12debebbf779 100644 --- a/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md +++ b/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md @@ -1,7 +1,7 @@ --- changes: - id: supabase-contract-regenerated-from-the-reference-fixture - summary: The Supabase extension contract is regenerated and now declares 43 check constraints, six native-enum defaults as member literals, and two more list columns that waive the derived element-not-null check, so its storage hash changes; re-sign databases that were signed against the previous Supabase contract. + summary: The Supabase extension contract is regenerated and now declares the reference build's 43 check constraints, six native-enum defaults as member literals, and an element-not-null waiver on two more list columns, so its storage hash changes; re-sign databases that were signed against the previous Supabase contract, and check your own Supabase build declares the same constraints. detection: glob: "**/package.json" contains: @@ -14,11 +14,11 @@ The `@prisma/orm-extension-supabase` contract is now exactly what `contract:gene Four things changed in the contract. -**43 check constraints are now declared.** Every `CHECK` that real Supabase declares on an `auth` or `storage` table — for example `users_email_change_confirm_status_check` and `one_time_tokens_token_hash_check` — is now part of the contract. They already exist on every Supabase database, so `prisma db verify` passes without a schema change. +**43 check constraints are now declared.** Every `CHECK` that the pack's reference Supabase build declares on an `auth` or `storage` table — for example `users_email_change_confirm_status_check` and `one_time_tokens_token_hash_check` — is now part of the contract. The reference build is supabase/postgres 17.6.1.106 with gotrue 2.188.1 and storage-api 1.54.1. On a database at or near that version the constraints are already present, so `prisma db verify` passes without a schema change. This is the one item that can newly fail for you: the checks used to be a tolerated live extra and are now a declared shape, so if your Supabase build's constraint set differs, verify reports the missing ones. You cannot repair that with a migration, because Prisma emits no DDL against an externally controlled table; report the difference so the pack's reference fixture can be refreshed. **Six native-enum column defaults are declared as member literals.** `auth.oauth_clients.client_type`, `auth.oauth_authorizations.response_type`, `auth.oauth_authorizations.status`, and the `type` column of `storage.buckets`, `storage.buckets_analytics` and `storage.buckets_vectors` previously carried the raw cast expression as their default, for example `{ "kind": "function", "expression": "'STANDARD'::storage.buckettype" }`. They now carry the enum member itself: `{ "kind": "literal", "value": "STANDARD" }`. This is the same live default read a more precise way, so the live databases need no change; if you read a column's declared default out of the contract, expect a literal rather than an expression. -**Two list columns waive the derived element-not-null check.** `auth.custom_oauth_providers.acceptable_client_ids` and `auth.custom_oauth_providers.scopes` now carry `"noCheck": ["elementNotNull"]`, matching the two `storage` list columns that already did. Real Supabase has no such constraint on these columns, so the contract no longer expects one. +**Two list columns carry an element-not-null waiver.** `auth.custom_oauth_providers.acceptable_client_ids` and `auth.custom_oauth_providers.scopes` now carry `"noCheck": ["elementNotNull"]`, matching the two `storage` list columns that already did. `contract infer` writes this for any list column with no live check at the derived name, and the committed contract is the generator's output, so it carries it too. This item moves the storage hash and changes nothing else you can observe: the pack is under `external` control, so a derived check is stripped before emit whether the waiver is written or not, and `db verify` demanded no such constraint before and demands none now. **78 timestamp columns are spelled `Timestamptz` instead of `DateTime` in the PSL.** Same codec (`pg/timestamptz-temporal@1`) and same emitted column, so this is a text change only. From eefce6595236b8b377dc7a31d8c7c1816dc41fab Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 18 Sep 2026 13:55:45 +0200 Subject: [PATCH 6/7] docs(extension-supabase): state plainly that every live check constraint is declared The check-constraints bullet opened "none of the live ones. All 43 are declared", two clauses that contradict each other when read on their own. The "none" was a leftover from when this bullet listed what the contract omits, and it stopped parsing that way once the regenerated contract declared the checks. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md index a1df868c22c3..c252538f3a77 100644 --- a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md +++ b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md @@ -26,7 +26,7 @@ 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:** none of the live ones. All 43 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. +**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 type spelling From bda2e60d54b381178f938018209675d7d05306e2 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 21 Sep 2026 14:32:08 +0200 Subject: [PATCH 7/7] docs(extension-supabase): describe the alias rule without the word spelling The alias table is keyed by the type as `contract infer` writes it, not by what a column represents. Say that in plain words in the generator doc comments, the fidelity notes, and the upgrade instructions, and rename `fieldTypeSpelling` to `printedFieldType` to match. No behaviour change: the regenerated contract is byte-identical. Co-Authored-By: Claude Opus 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../supabase/scripts/generate-contract.ts | 18 +++++++++--------- .../supabase/src/contract/CONTRACT-FIDELITY.md | 4 ++-- .../extension/instructions.md | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/3-extensions/supabase/scripts/generate-contract.ts b/packages/3-extensions/supabase/scripts/generate-contract.ts index e35d81590413..fdaf0e802a46 100644 --- a/packages/3-extensions/supabase/scripts/generate-contract.ts +++ b/packages/3-extensions/supabase/scripts/generate-contract.ts @@ -283,16 +283,16 @@ function rewriteFieldTypeNames( } /** - * Curated storage-type aliases, keyed by the type spelling `contract infer` - * produces. Hand-authored in the pack's first contract (commit 7a9426e2, + * 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 type spelling, 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. + * 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> = { Inet: 'IpAddress', @@ -307,7 +307,7 @@ const NAMED_TYPE_ALIASES: Readonly> = { }; /** The type as `printPsl` would write it, e.g. `Uuid` or `VarChar(255)`. */ -function fieldTypeSpelling(field: PslField): string { +function printedFieldType(field: PslField): string { const { typeConstructor } = field; if (!typeConstructor) return field.typeName; const path = typeConstructor.path.join('.'); @@ -319,7 +319,7 @@ function fieldTypeSpelling(field: PslField): string { } /** - * Rewrites every scalar field whose type spelling has an alias to reference + * 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. @@ -339,7 +339,7 @@ function applyNamedTypeAliases( ) { return field; } - const alias = NAMED_TYPE_ALIASES[fieldTypeSpelling(field)]; + const alias = NAMED_TYPE_ALIASES[printedFieldType(field)]; if (alias === undefined) return field; changed = true; used.add(alias); diff --git a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md index c252538f3a77..b09a662187ae 100644 --- a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md +++ b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md @@ -28,9 +28,9 @@ The machine-readable version of the default list lives in `scripts/generate-cont **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 type spelling +## 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 spelling their type out. The aliases are hand-picked names, but the generator applies them mechanically, by type spelling rather than by 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. +`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 diff --git a/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md b/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md index 12debebbf779..0f42c9c41014 100644 --- a/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md +++ b/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md @@ -20,6 +20,6 @@ Four things changed in the contract. **Two list columns carry an element-not-null waiver.** `auth.custom_oauth_providers.acceptable_client_ids` and `auth.custom_oauth_providers.scopes` now carry `"noCheck": ["elementNotNull"]`, matching the two `storage` list columns that already did. `contract infer` writes this for any list column with no live check at the derived name, and the committed contract is the generator's output, so it carries it too. This item moves the storage hash and changes nothing else you can observe: the pack is under `external` control, so a derived check is stripped before emit whether the waiver is written or not, and `db verify` demanded no such constraint before and demands none now. -**78 timestamp columns are spelled `Timestamptz` instead of `DateTime` in the PSL.** Same codec (`pg/timestamptz-temporal@1`) and same emitted column, so this is a text change only. +**78 timestamp columns are written as `Timestamptz` instead of `DateTime` in the PSL.** Same codec (`pg/timestamptz-temporal@1`) and same emitted column, so this is a text change only. A contract that composes the Supabase space references it by id, so your own `contract.json` and `contract.d.ts` do not change. What changes is the signature: a database that was signed against the previous Supabase contract no longer matches the new hash, so run `prisma db sign` against it after upgrading. If you re-emit your own contract, do that first so the composed space is the new one.