diff --git a/packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts b/packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts index ff39369ae2..eb5197b822 100644 --- a/packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts +++ b/packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts @@ -167,4 +167,25 @@ describe('SqlColumnIR', () => { expect(column.resolvedDefault).toEqual({ kind: 'literal', value: 'x' }); }); }); + + describe('identity columns (introspected, no raw default)', () => { + it('yields a default child node from resolvedDefault alone, with no raw default', () => { + // A `GENERATED ... AS IDENTITY` column has no `column_default` at + // all — the postgres control adapter sets `resolvedDefault` directly + // to `autoincrement()` without a raw expression to parse, so + // `children()` must still produce a default node without a `default` + // (raw) field. + const column = new SqlColumnIR({ + name: 'id', + nativeType: 'int4', + nullable: false, + resolvedDefault: { kind: 'function', expression: 'autoincrement()' }, + }); + expect(column.children()).toEqual([ + new SqlColumnDefaultIR({ + resolved: { kind: 'function', expression: 'autoincrement()' }, + }), + ]); + }); + }); }); diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index dec19f4012..5b0f0bffaf 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -79,7 +79,7 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; -import { getAttribute, mapFieldNamesToColumns } from './psl-attribute-parsing'; +import { getAttribute, getNamedArgument, mapFieldNamesToColumns } from './psl-attribute-parsing'; import type { ColumnDescriptor } from './psl-column-resolution'; import { checkUncomposedNamespace, @@ -103,7 +103,7 @@ import { interpretRelationAttribute, type ModelBackrelationCandidate, normalizeReferentialAction, - validateNavigationListFieldAttributes, + validateBackrelationFieldAttributes, } from './psl-relation-resolution'; import { baseModelSpec, @@ -668,6 +668,21 @@ interface BuildModelNodeResult { readonly modelAttributeEntities: Readonly>>>; } +/** + * The owning side of a relation is the one that declares `fields`/`references` on its + * `@relation` attribute — those name the FK columns. A singular model-typed field whose + * `@relation` carries only a name (or nothing at all) is the back side: infer prints exactly + * that shape for a 1:1 back-relation whenever the FK needs disambiguating (two FKs between the + * same table pair, or a self-referencing unique FK). Checking for the attribute's mere presence + * would misclassify that back side as the owning side. + */ +function relationAttributeDeclaresOwningSide(relationAttribute: ResolvedAttribute): boolean { + return ( + getNamedArgument(relationAttribute, 'fields') !== undefined || + getNamedArgument(relationAttribute, 'references') !== undefined + ); +} + function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult { const { model, mapping, sourceId, diagnostics } = input; const tableName = mapping.tableName; @@ -726,10 +741,20 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult const resultBackrelationCandidates: ModelBackrelationCandidate[] = []; for (const field of Object.values(model.fields)) { - if (!field.list || !input.modelNames.has(field.typeName)) { + if (!input.modelNames.has(field.typeName)) { + continue; + } + const relationAttribute = getAttribute(field.attributes, 'relation'); + if ( + !field.list && + relationAttribute && + relationAttributeDeclaresOwningSide(relationAttribute) + ) { + // The owning side of the relation: it declares fields/references and is + // lowered separately below, by the `relationAttributes` FK-building loop. continue; } - const attributesValid = validateNavigationListFieldAttributes({ + const attributesValid = validateBackrelationFieldAttributes({ modelName: model.name, field, sourceId, @@ -739,7 +764,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult familyId: input.familyId, targetId: input.targetId, }); - const relationAttribute = getAttribute(field.attributes, 'relation'); let relationName: string | undefined; if (relationAttribute) { const parsedRelation = interpretRelationAttribute({ @@ -786,6 +810,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult tableName, field, targetModelName: field.typeName, + isList: field.list, ...ifDefined('relationName', relationName), }); } @@ -1095,6 +1120,13 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult continue; } + if (!relationAttributeDeclaresOwningSide(relationAttribute.relation)) { + // A singular model-typed field whose `@relation` carries only a name (or nothing) is the + // back side of a 1:1 relation, already lowered above via backrelationCandidates. It is + // not the owning side, so it has no fields/references to validate here. + continue; + } + // Cross-contract-space relation: the target model lives in a different contract space // identified by `typeContractSpaceId` (e.g. `supabase:auth.User`). if (fieldTypeContractSpaceId !== undefined) { diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index 27edf7f47b..9d79121faf 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -697,7 +697,11 @@ export function resolveFieldTypeDescriptor(input: { * Declarative specification for @db.* native type attributes. * * Argument kinds: - * - `noArgs`: No arguments accepted; `codecId: null` means inherit from baseDescriptor. + * - `noArgs`: No arguments accepted; `codecId: null` means resolve at runtime + * from the target-contributed `scalarTypeDescriptors` map keyed by the + * attribute's own name (e.g. `"db.Date"`), falling back to `baseDescriptor` + * (the base PSL type's own codec) when the target contributes nothing + * under that key — see `resolveDbNativeTypeAttribute`. * - `optionalLength`: Zero or one positional integer (minimum 1), stored as `{ length }`. * - `optionalPrecision`: Zero or one positional integer (minimum 0), stored as `{ precision }`. * - `optionalNumeric`: Zero, one, or two positional integers (precision + scale). @@ -783,6 +787,18 @@ export function resolveDbNativeTypeAttribute(input: { readonly attribute: ResolvedAttribute; readonly baseType: string; readonly baseDescriptor: ColumnDescriptor; + /** + * Target-contributed descriptor lookup, keyed by PSL scalar type name + * (e.g. `"DateTime"`) everywhere else it's used — but also consulted here + * by the full `@db.*` attribute name (e.g. `"db.Date"`) as a `noArgs` + * spec's codec-id source. A `noArgs` spec with `codecId: null` can't + * simply inherit the base type's own descriptor (that's `baseDescriptor`, + * already the wrong codec for e.g. `@db.Date` vs its `DateTime` base); the + * target contributes the attribute-specific codec id under its own key in + * the same map instead, keeping this family module free of any target's + * concrete codec id. + */ + readonly scalarTypeDescriptors: ReadonlyMap; readonly diagnostics: ContractSourceDiagnostic[]; readonly sourceId: string; readonly entityLabel: string; @@ -818,7 +834,10 @@ export function resolveDbNativeTypeAttribute(input: { }); } return { - codecId: spec.codecId ?? input.baseDescriptor.codecId, + codecId: + spec.codecId ?? + input.scalarTypeDescriptors.get(input.attribute.name)?.codecId ?? + input.baseDescriptor.codecId, nativeType: spec.nativeType, }; } diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts index 6513bf81fd..a2ee5b1381 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts @@ -347,6 +347,13 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv if (field.typeContractSpaceId !== undefined && relationAttribute) { continue; } + // A model-typed, non-list field with no `@relation` is the back side of a + // 1:1 relation — the owning side always carries `@relation(fields: [...], + // references: [...])`. It is lowered separately, via the interpreter's + // backrelation-candidate matching, not as a scalar column here. + if (isModelField) { + continue; + } const isValueObjectField = compositeTypeNames.has(field.typeName); const isListField = field.list; diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-named-type-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-named-type-resolution.ts index 0336026c89..a79cf03ea7 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-named-type-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-named-type-resolution.ts @@ -210,6 +210,7 @@ export function resolveNamedTypeDeclarations(input: ResolveNamedTypeDeclarations attribute: dbNativeTypeAttribute, baseType, baseDescriptor, + scalarTypeDescriptors: input.scalarTypeDescriptors, diagnostics: input.diagnostics, sourceId: input.sourceId, entityLabel: `Named type "${declaration.name}"`, diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index 70154c9189..3783a14f4d 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -62,6 +62,8 @@ export type ModelBackrelationCandidate = { readonly tableName: string; readonly field: FieldSymbol; readonly targetModelName: string; + /** Whether the PSL field itself is list-typed (`Target[]`) rather than singular (`Target?`). A singular candidate is the back side of a 1:1 relation and can never be many-to-many. */ + readonly isList: boolean; readonly relationName?: string; }; @@ -453,35 +455,39 @@ export function applyBackrelationCandidates(input: { : [...pairMatches]; if (matches.length === 0) { - const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({ - candidate, - fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel, - modelIdColumns: input.modelIdColumns, - }); - const junctionPair = junctionPairs[0]; - if (junctionPairs.length === 1 && junctionPair) { - relationsForModel(input.modelRelations, candidate.modelName).push( - manyToManyRelationNode(candidate, junctionPair), - ); - continue; - } - if (junctionPairs.length > 1) { - input.diagnostics.push({ - code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', - message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add @relation(name: "...") (or @relation("...")) to the list field and the junction FK-side relation pointing back at "${candidate.modelName}" to disambiguate.`, - sourceId: input.sourceId, - span: candidate.field.span, + // A singular candidate is the back side of a 1:1 — many-to-many junction + // matching only makes sense for a list-typed backrelation. + if (candidate.isList) { + const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({ + candidate, + fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel, + modelIdColumns: input.modelIdColumns, }); - continue; - } - const nearMiss = nearMisses[0]; - if (nearMiss) { - input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId)); - continue; + const junctionPair = junctionPairs[0]; + if (junctionPairs.length === 1 && junctionPair) { + relationsForModel(input.modelRelations, candidate.modelName).push( + manyToManyRelationNode(candidate, junctionPair), + ); + continue; + } + if (junctionPairs.length > 1) { + input.diagnostics.push({ + code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', + message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add @relation(name: "...") (or @relation("...")) to the list field and the junction FK-side relation pointing back at "${candidate.modelName}" to disambiguate.`, + sourceId: input.sourceId, + span: candidate.field.span, + }); + continue; + } + const nearMiss = nearMisses[0]; + if (nearMiss) { + input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId)); + continue; + } } input.diagnostics.push({ code: 'PSL_ORPHANED_BACKRELATION_LIST', - message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(fields: [...], references: [...]) on the FK-side relation or use an explicit join model for many-to-many.`, + message: `Backrelation field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(fields: [...], references: [...]) on the FK-side relation${candidate.isList ? ' or use an explicit join model for many-to-many' : ''}.`, sourceId: input.sourceId, span: candidate.field.span, }); @@ -490,7 +496,7 @@ export function applyBackrelationCandidates(input: { if (matches.length > 1) { input.diagnostics.push({ code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', - message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation(name: "...") (or @relation("...")) to both sides to disambiguate.`, + message: `Backrelation field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation(name: "...") (or @relation("...")) to both sides to disambiguate.`, sourceId: input.sourceId, span: candidate.field.span, }); @@ -506,7 +512,7 @@ export function applyBackrelationCandidates(input: { toModel: matched.declaringModelName, toTable: matched.declaringTableName, ...ifDefined('toNamespaceId', matched.declaringNamespaceId), - cardinality: '1:N', + cardinality: candidate.isList ? '1:N' : '1:1', on: { parentTable: candidate.tableName, parentColumns: matched.referencedColumns, @@ -517,7 +523,7 @@ export function applyBackrelationCandidates(input: { } } -export function validateNavigationListFieldAttributes(input: { +export function validateBackrelationFieldAttributes(input: { readonly modelName: string; readonly field: FieldSymbol; readonly sourceId: string; diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index cbfc785628..573459c16e 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -270,6 +270,10 @@ export const postgresScalarTypeDescriptors = new Map([ ['DateTime', { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz' }], ['Json', { codecId: 'pg/jsonb@1', nativeType: 'jsonb' }], ['Bytes', { codecId: 'pg/bytea@1', nativeType: 'bytea' }], + // Keyed by the full `@db.*` attribute name, not a PSL base type — see the + // matching entry (and its comment) in the real target's + // `postgresScalarTypeDescriptors`, `control-mutation-defaults.ts`. + ['db.Date', { codecId: 'pg/date@1', nativeType: 'date' }], ] as const); export function buildSymbolTableInput( diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index 17efb8092a..a4f2641dd9 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -75,6 +75,105 @@ model Post { }); }); + it('accepts a bare model-typed optional field with no @relation as the 1:1 back side', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id + profile Profile? +} + +model Profile { + id Int @id + userId Int @unique + user User @relation(fields: [userId], references: [id]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ ...baseInput, ...document }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const models = modelsOf(result.value) as Record< + string, + { relations?: Record } + >; + expect(models['User']?.relations).toMatchObject({ + profile: { + to: crossRef('Profile', 'public'), + cardinality: '1:1', + on: { + localFields: ['id'], + targetFields: ['userId'], + }, + }, + }); + expect(models['Profile']?.relations).toMatchObject({ + user: { + to: crossRef('User', 'public'), + cardinality: 'N:1', + on: { + localFields: ['userId'], + targetFields: ['id'], + }, + }, + }); + }); + + it('reports an orphaned 1:1 backrelation candidate when no FK points back at it', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id + profile Profile? +} + +model Profile { + id Int @id +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ ...baseInput, ...document }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_ORPHANED_BACKRELATION_LIST', + message: expect.stringContaining('User.profile'), + }), + ]), + ); + }); + + it('still rejects a field whose type is neither a model, enum, composite, nor scalar', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id + nonsense Nonsense +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ ...baseInput, ...document }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_UNSUPPORTED_FIELD_TYPE', + message: expect.stringContaining('User.nonsense'), + }), + ]), + ); + }); + it('matches named backrelations using positional and named relation forms', () => { const document = symbolTableInputFromParseArgs({ schema: `model User { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts index 84be831a99..c3a158b60c 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts @@ -70,7 +70,7 @@ model Event { nativeType: 'time', typeParams: { precision: 3 }, }, - PublishDay: { codecId: 'pg/timestamptz@1', nativeType: 'date' }, + PublishDay: { codecId: 'pg/date@1', nativeType: 'date' }, Payload: { codecId: 'pg/json@1', nativeType: 'json' }, Amount: { codecId: 'pg/numeric@1', @@ -105,7 +105,7 @@ model Event { typeRef: 'HappenedAt', }, publishDay: { - codecId: 'pg/timestamptz@1', + codecId: 'pg/date@1', nativeType: 'date', nullable: false, typeRef: 'PublishDay', diff --git a/packages/2-sql/9-family/package.json b/packages/2-sql/9-family/package.json index eb038eba0c..2b04a66e8c 100644 --- a/packages/2-sql/9-family/package.json +++ b/packages/2-sql/9-family/package.json @@ -27,7 +27,8 @@ "@prisma-next/sql-runtime": "workspace:0.15.0", "@prisma-next/sql-schema-ir": "workspace:0.15.0", "@prisma-next/utils": "workspace:0.15.0", - "arktype": "^2.2.2" + "arktype": "^2.2.2", + "pluralize": "^8.0.0" }, "devDependencies": { "@prisma-next/driver-postgres": "workspace:0.15.0", @@ -37,6 +38,7 @@ "@prisma-next/test-utils": "workspace:0.15.0", "@prisma-next/tsconfig": "workspace:0.15.0", "@prisma-next/tsdown": "workspace:0.15.0", + "@types/pluralize": "^0.0.33", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts index 183b206377..a4e5afcda7 100644 --- a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts +++ b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts @@ -56,6 +56,25 @@ export type NativeTypeExpander = (input: { */ export type DefaultRenderer = (def: ColumnDefault, column: StorageColumn) => string; +/** + * Target-specific callback that normalizes a contract-declared `ColumnDefault` + * into the same resolved shape introspection would parse from the live + * database, so `resolvedDefault` compares correctly on both sides. + * + * The contract's raw default and the introspected raw default can describe + * the same value in different shapes (e.g. a `dbgenerated("'{}'::jsonb")` + * function-call default versus a literal Postgres parses from the live + * column). Without normalizing the contract side too, `resolvedDefaultsEqual` + * compares `kind` before content and reports drift forever even when the + * database matches exactly. This follows the same IoC pattern as + * `NativeTypeExpander`/`DefaultRenderer`: the target provides its own + * resolution (reusing whatever raw-default parser its introspection side + * already uses) when calling `contractToSchemaIR`, keeping the family layer + * target-agnostic. Omitted entirely, the contract's raw default is the + * resolved default unchanged (today's behavior). + */ +export type DefaultResolver = (def: ColumnDefault, resolvedNativeType: string) => ColumnDefault; + /** * Target-supplied callback that resolves a contract namespace to the live * database schema its enums are stored under. @@ -77,6 +96,7 @@ function convertColumn( storageTypes: ResolvedStorageTypes, expandNativeType: NativeTypeExpander | undefined, renderDefault: DefaultRenderer | undefined, + resolveDefault: DefaultResolver | undefined, ): SqlColumnIRInput { // Resolve `typeRef` so columns that delegate their `nativeType`/`codecId`/ // `typeParams` to a named `storage.types` entry expand the same way as @@ -107,6 +127,11 @@ function convertColumn( // agree on as the comparable "expanded" type. const nativeType = baseNativeType; const resolvedNativeType = column.many ? `${baseNativeType}[]` : baseNativeType; + const rawColumnDefault = column.default ?? undefined; + const resolvedColumnDefault = + rawColumnDefault !== undefined && resolveDefault + ? resolveDefault(rawColumnDefault, resolvedNativeType) + : rawColumnDefault; return { name, nativeType, @@ -117,11 +142,14 @@ function convertColumn( column.default != null && renderDefault ? renderDefault(column.default, column) : undefined, ), // Contract-derived columns are resolved by construction: the computed - // full native type doubles as the resolved value, and the contract's - // structured default is the resolved default (the introspected side - // stamps its normalizer's parse of the raw expression). + // full native type doubles as the resolved value. The contract's raw + // structured default becomes the resolved default after passing through + // the target's `resolveDefault` hook (when supplied), so a default the + // target's introspection side would normalize differently (e.g. a + // `dbgenerated(...)` function call that is actually a literal) compares + // equal instead of drifting on `kind` alone. resolvedNativeType, - ...ifDefined('resolvedDefault', column.default ?? undefined), + ...ifDefined('resolvedDefault', resolvedColumnDefault), // The column's codec identity, carried the same way the query AST // carries `CodecRef` (TML-2456) — the migration planner's op-builders // resolve DDL rendering from this at plan time (Decision 5), instead of @@ -339,6 +367,7 @@ function convertTable( storageTypes: ResolvedStorageTypes, expandNativeType: NativeTypeExpander | undefined, renderDefault: DefaultRenderer | undefined, + resolveDefault: DefaultResolver | undefined, storage: SqlStorage, ): SqlTableIR { const columns: Record = {}; @@ -349,6 +378,7 @@ function convertTable( storageTypes, expandNativeType, renderDefault, + resolveDefault, ); } @@ -444,6 +474,7 @@ export interface ContractToSchemaIROptions { readonly annotationNamespace: string; readonly expandNativeType?: NativeTypeExpander; readonly renderDefault?: DefaultRenderer; + readonly resolveDefault?: DefaultResolver; /** * Target-supplied resolver mapping a namespace to the live database schema * its enums are stored under. When provided (Postgres), namespace-scoped @@ -504,6 +535,7 @@ export function contractNamespaceToSchemaIR( storageTypes, options.expandNativeType, options.renderDefault, + options.resolveDefault, storage, ); } @@ -540,6 +572,7 @@ export function contractToSchemaIR( storageTypes, options.expandNativeType, options.renderDefault, + options.resolveDefault, storage, ); } diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts index a1e3ee8c6b..40f5eee5ba 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts @@ -1,3 +1,5 @@ +import pluralizeLib from 'pluralize'; + const PSL_RESERVED_WORDS = new Set(['model', 'enum', 'types', 'type', 'generator', 'datasource']); const IDENTIFIER_PART_PATTERN = /[A-Za-z0-9]+/g; @@ -143,19 +145,7 @@ export function toEnumMemberName(value: string): string { } export function pluralize(word: string): string { - if ( - word.endsWith('s') || - word.endsWith('x') || - word.endsWith('z') || - word.endsWith('ch') || - word.endsWith('sh') - ) { - return `${word}es`; - } - if (word.endsWith('y') && !/[aeiou]y$/i.test(word)) { - return `${word.slice(0, -1)}ies`; - } - return `${word}s`; + return pluralizeLib.plural(word); } export function deriveRelationFieldName( diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index 883a6ce59d..3e4d938647 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -21,6 +21,7 @@ export type { export type { ContractToSchemaIROptions, DefaultRenderer, + DefaultResolver, EnumNamespaceSchemaResolver, NativeTypeExpander, } from '../core/migrations/contract-to-schema-ir'; diff --git a/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts b/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts index 0745ae38b9..79737eb47a 100644 --- a/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts +++ b/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts @@ -1182,6 +1182,38 @@ describe('contractToSchemaIR — resolved leaf values', () => { expect(columns['plain']!.resolvedDefault).toBeUndefined(); }); + it('passes the raw default through resolveDefault when supplied', () => { + // Without a target-supplied `resolveDefault`, the contract's raw + // default becomes the resolved default unchanged — this is what a + // target that never normalizes (e.g. one with no literal-vs-function + // ambiguity in its default syntax) gets by omitting the hook. Proves + // the hook actually runs, and runs with the resolved (`[]`-suffixed for + // arrays) native type, not the base one. + const storage = unboundStorage('sha256:test' as StorageHashBase, { + T: table({ + columns: { + tags: col({ + nativeType: 'text', + many: true, + default: { kind: 'function', expression: "'{}'::text[]" }, + }), + }, + }), + }); + + const result = contractToSchemaIR(wrap(storage), { + renderDefault: testRenderer, + resolveDefault: (def, resolvedNativeType) => + def.kind === 'function' && resolvedNativeType === 'text[]' + ? { kind: 'literal', value: [] } + : def, + }); + expect(result.tables['T']!.columns['tags']!.resolvedDefault).toEqual({ + kind: 'literal', + value: [], + }); + }); + it('check nodes carry the value-set resolved permittedValues', () => { const valueSetName = 'T_status_values'; const ns = createTestSqlNamespace({ diff --git a/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts index f4deefdf92..bb8b2e77d3 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts @@ -142,6 +142,21 @@ describe('pluralize', () => { expect(pluralize('day')).toBe('days'); expect(pluralize('key')).toBe('keys'); }); + + it('leaves already-plural words unchanged', () => { + expect(pluralize('sessions')).toBe('sessions'); + expect(pluralize('identities')).toBe('identities'); + expect(pluralize('mfaAmrClaims')).toBe('mfaAmrClaims'); + }); + + it('pluralizes singular words that end in s', () => { + expect(pluralize('status')).toBe('statuses'); + expect(pluralize('bus')).toBe('buses'); + }); + + it('pluralizes class', () => { + expect(pluralize('class')).toBe('classes'); + }); }); describe('deriveRelationFieldName', () => { @@ -178,6 +193,10 @@ describe('deriveBackRelationFieldName', () => { it('singularizes for 1:1', () => { expect(deriveBackRelationFieldName('Profile', true)).toBe('profile'); }); + + it('does not double-pluralize an already-plural model name for 1:N', () => { + expect(deriveBackRelationFieldName('Sessions', false)).toBe('sessions'); + }); }); describe('toNamedTypeName', () => { diff --git a/packages/3-extensions/supabase/scripts/generate-contract.ts b/packages/3-extensions/supabase/scripts/generate-contract.ts index 3895109c98..d492f211ff 100644 --- a/packages/3-extensions/supabase/scripts/generate-contract.ts +++ b/packages/3-extensions/supabase/scripts/generate-contract.ts @@ -86,60 +86,12 @@ const COLUMN_OMISSIONS: Readonly>>> = { auth: { users: ['phone'], - custom_oauth_providers: [ - 'acceptable_client_ids', - 'scopes', - 'attribute_mapping', - 'authorization_params', - ], - webauthn_credentials: ['transports'], - }, - storage: { - iceberg_namespaces: ['metadata'], }, }; -// --- Index omissions (declarative, index-name keyed) --------------------- -// -// Fidelity notes: -// - auth.one_time_tokens_relates_to_hash_idx / _token_hash_hash_idx: both -// `USING hash` indexes. The inferrer now carries a non-default index -// access method through as `@@index(..., type: "hash")`, but `hash` is -// not a registered index type on this pack's stack (`IndexTypeRegistry` -// — see `packages/2-sql/1-core/contract/src/index-types.ts` — is an -// opt-in extensibility point a target or extension pack populates, e.g. -// `paradedbIndexTypes` for `bm25`; the postgres target itself registers -// none). `contract emit` rejects an unregistered type, so these two -// indexes are omitted rather than declared. Under `external` control an -// undeclared live index is a suppressed extra, so omission is -// verify-safe. Registering `hash` as a built-in postgres index type -// would let both come back declared; out of scope here. -// This mechanism also serves as the escape hatch for whatever the *next* -// unrepresentable index turns out to be, following the same declarative -// pattern as the column/default omissions above. -const INDEX_OMISSIONS: Readonly> = { - auth: ['one_time_tokens_relates_to_hash_idx', 'one_time_tokens_token_hash_hash_idx'], -}; - /** * PSL attribute argument values arrive as raw source text; a string-typed * argument (e.g. `@@map("users")`) is a JSON string literal, so JSON.parse @@ -157,113 +109,6 @@ function parseJsonStringLiteral(raw: string): string { return value; } -function indexOmissionNameOf(attribute: PslModel['attributes'][number]): string | undefined { - const mapArg = attribute.args.find((arg) => arg.kind === 'named' && arg.name === 'map'); - if (!mapArg) return undefined; - return parseJsonStringLiteral(mapArg.value); -} - -/** Drops `@@index`/`@@unique` model attributes named in `omittedNames`. */ -function applyIndexOmissions( - namespace: PslNamespace, - omittedNames: readonly string[], -): PslNamespace { - if (omittedNames.length === 0) return namespace; - let changed = false; - const models = namespace.models.map((model) => { - const attributes = model.attributes.filter((attribute) => { - if ( - attribute.target !== 'model' || - (attribute.name !== 'index' && attribute.name !== 'unique') - ) { - return true; - } - const name = indexOmissionNameOf(attribute); - const omit = name !== undefined && omittedNames.includes(name); - if (omit) changed = true; - return !omit; - }); - return attributes.length === model.attributes.length ? model : { ...model, attributes }; - }); - - if (!changed) return namespace; - - return makePslNamespace({ - kind: 'namespace', - name: namespace.name, - entries: makePslNamespaceEntries( - models, - namespace.compositeTypes, - namespacePslExtensionBlocks(namespace), - ), - span: namespace.span, - }); -} - -// --- Back-relation field-name corrections (declarative, field-name keyed) -- -// -// Fidelity note: -// - The framework inferrer's `pluralize()` -// (packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts) -// unconditionally appends `es` to a name that already ends in `s`, `x`, -// `z`, `ch`, or `sh`. Supabase's `auth`/`storage` table names are already -// plural (e.g. `sessions`, `identities`), so the back-relation field -// derived from them comes out double-pluralized (`sessionses`, -// `identitieses`). A general inflection fix is deferred to a follow-up -// ticket; this table corrects the known-affected names here so the pack -// does not ship double-plural public relation names. -const DOUBLE_PLURALIZED_FIELD_NAMES: ReadonlySet = new Set([ - 'icebergNamespaceses', - 'icebergTableses', - 'identitieses', - 'mfaAmrClaimses', - 'mfaChallengeses', - 'mfaFactorses', - 'oauthAuthorizationses', - 'oauthConsentses', - 'objectses', - 'oneTimeTokenses', - 'refreshTokenses', - 's3MultipartUploadsPartses', - 's3MultipartUploadses', - 'samlProviderses', - 'samlRelayStateses', - 'sessionses', - 'ssoDomainses', - 'vectorIndexeses', - 'webauthnChallengeses', - 'webauthnCredentialses', -]); - -/** Strips the erroneous trailing `es` from double-pluralized back-relation field names. */ -function applyDoublePluralizationFix(namespace: PslNamespace): PslNamespace { - let changed = false; - const models = namespace.models.map((model) => { - let modelChanged = false; - const fields = model.fields.map((field) => { - if (!DOUBLE_PLURALIZED_FIELD_NAMES.has(field.name)) return field; - modelChanged = true; - return { ...field, name: field.name.slice(0, -2) }; - }); - if (!modelChanged) return model; - changed = true; - return { ...model, fields }; - }); - - if (!changed) return namespace; - - return makePslNamespace({ - kind: 'namespace', - name: namespace.name, - entries: makePslNamespaceEntries( - models, - namespace.compositeTypes, - namespacePslExtensionBlocks(namespace), - ), - span: namespace.span, - }); -} - // --- Model renames (legacy names referenced by examples + cross-space FKs) - const MODEL_RENAMES: Readonly>>> = { @@ -623,10 +468,8 @@ async function introspectSchema( ); const defaultsFixed = applyDefaultOmissions(namespace, DEFAULT_OMISSIONS[schemaName] ?? {}); - const indexesFixed = applyIndexOmissions(defaultsFixed, INDEX_OMISSIONS[schemaName] ?? []); - const rlsFixed = applyRlsEnablement(indexesFixed, rlsEnabledTables); - const pluralizationFixed = applyDoublePluralizationFix(rlsFixed); - return { namespace: pluralizationFixed, types: ast.types?.declarations ?? [] }; + const rlsFixed = applyRlsEnablement(defaultsFixed, rlsEnabledTables); + return { namespace: rlsFixed, types: ast.types?.declarations ?? [] }; } async function main(): Promise { diff --git a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md index 17237d91f4..6ae7e50724 100644 --- a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md +++ b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md @@ -10,7 +10,7 @@ Everything the pack declares is `control: 'external'`. Under `external`, `db ver ## What the contract deliberately does not declare -Machine-readable versions of these lists live in `scripts/generate-contract.ts` (`COLUMN_OMISSIONS` / `DEFAULT_OMISSIONS` / `INDEX_OMISSIONS`), each with the full reasoning; this is the audit summary. +Machine-readable versions of these lists live in `scripts/generate-contract.ts` (`COLUMN_OMISSIONS` / `DEFAULT_OMISSIONS`), each with the full reasoning; this is the audit summary. **Columns (2):** @@ -19,12 +19,12 @@ Machine-readable versions of these lists live in `scripts/generate-contract.ts` | `storage.buckets.allowed_mime_types` | `text[]` nullable | PSL has no nullable-list syntax (`String[]?` is invalid) | | `storage.objects.path_tokens` | `text[]` nullable | Same; also `GENERATED ALWAYS`, so not user-writable regardless | -**Column defaults (7):** `auth.users.phone` (`DEFAULT NULL` no-op), `auth.custom_oauth_providers.acceptable_client_ids`/`scopes` (list defaults have no PSL execution-default form), and `auth.custom_oauth_providers.attribute_mapping`/`authorization_params`, `auth.webauthn_credentials.transports`, `storage.iceberg_namespaces.metadata` (JSON-literal defaults resolve to different shapes on the authored vs introspected side). Column types are declared in full; only the `@default` is dropped. +**Column defaults (3):** `auth.users.phone` (`DEFAULT NULL` is a no-op, but round-trips through the raw-default parser as an explicit `@default(null)`, which the interpreter rejects); `auth.custom_oauth_providers.acceptable_client_ids` and `.scopes` (both `text[]` with `DEFAULT '{}'::text[]`, printed as `@default(dbgenerated("'{}'::text[]"))` — the interpreter rejects any function-kind default on a list field, and a `dbgenerated(...)` default is always function-kind at authoring time). Column type is declared in full for all three; only the `@default` is dropped. The jsonb `dbgenerated(...)` defaults that used to widen this list (TML-3037) are declared again — `db verify`'s permanent-drift disagreement is fixed generically, at the postgres target's `SchemaIR` construction, so it needs no authoring-side omission. **Indexes:** - The reference's 8 partial unique indexes (`WHERE`-predicated, on `auth.users` token columns, `auth.mfa_factors`, `storage.buckets_analytics`) are not declared — the inferrer never promotes an index-level unique into `@@unique`, and a predicate-less declaration would misdeclare. -- `auth.one_time_tokens`' two `USING hash` indexes are not declared — `hash` is not a registered index type on this stack (`IndexTypeRegistry` is pack-populated; the postgres target registers none). +- `auth.one_time_tokens`' two `USING hash` indexes are declared (`@@index(..., type: "hash")`) — the postgres target registers `hash` as a built-in index type (TML-3037). - 16 foreign keys whose source columns have **no live backing index** are declared with `@relation(..., index: false)` — real Supabase does not index those FK columns, and the default FK-derived index expectation would otherwise fail verify. (This PSL argument and the inferrer support for it shipped with this contract.) **Generated columns** (`auth.users.confirmed_at`, `auth.identities.email`): 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. diff --git a/packages/3-extensions/supabase/src/contract/contract.d.ts b/packages/3-extensions/supabase/src/contract/contract.d.ts index d59dd5cb0e..a08ad591fc 100644 --- a/packages/3-extensions/supabase/src/contract/contract.d.ts +++ b/packages/3-extensions/supabase/src/contract/contract.d.ts @@ -30,7 +30,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:4fd3b6b5481531b6fc23e6f3a97061908a8efaab460a56e3c334ef2483c9dfdb'>; + StorageHashBase<'sha256:1856d476008cdfac9fd383f74be8015f6cb313515966c3c6cba4067f5bc15858'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; @@ -1750,11 +1750,19 @@ type ContractBase = Omit< readonly nativeType: 'text'; readonly codecId: 'pg/text@1'; readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/text@1', readonly []>; + }; }; readonly scopes: { readonly nativeType: 'text'; readonly codecId: 'pg/text@1'; readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/text@1', readonly []>; + }; }; readonly pkce_enabled: { readonly nativeType: 'bool'; @@ -1769,11 +1777,19 @@ type ContractBase = Omit< readonly nativeType: 'jsonb'; readonly codecId: 'pg/jsonb@1'; readonly nullable: false; + readonly default: { + readonly kind: 'function'; + readonly expression: "'{}'::jsonb"; + }; }; readonly authorization_params: { readonly nativeType: 'jsonb'; readonly codecId: 'pg/jsonb@1'; readonly nullable: false; + readonly default: { + readonly kind: 'function'; + readonly expression: "'{}'::jsonb"; + }; }; readonly enabled: { readonly nativeType: 'bool'; @@ -2764,7 +2780,18 @@ type ContractBase = Omit< readonly name: 'one_time_tokens_pkey'; }; uniques: readonly []; - indexes: readonly []; + indexes: readonly [ + { + readonly columns: readonly ['relates_to']; + readonly name: 'one_time_tokens_relates_to_hash_idx'; + readonly type: 'hash'; + }, + { + readonly columns: readonly ['token_hash']; + readonly name: 'one_time_tokens_token_hash_hash_idx'; + readonly type: 'hash'; + }, + ]; foreignKeys: readonly [ { readonly source: { @@ -3669,6 +3696,10 @@ type ContractBase = Omit< readonly nativeType: 'jsonb'; readonly codecId: 'pg/jsonb@1'; readonly nullable: false; + readonly default: { + readonly kind: 'function'; + readonly expression: "'[]'::jsonb"; + }; }; readonly backup_eligible: { readonly nativeType: 'bool'; @@ -4012,6 +4043,10 @@ type ContractBase = Omit< readonly nativeType: 'jsonb'; readonly codecId: 'pg/jsonb@1'; readonly nullable: false; + readonly default: { + readonly kind: 'function'; + readonly expression: "'{}'::jsonb"; + }; }; readonly catalog_id: { readonly nativeType: 'uuid'; diff --git a/packages/3-extensions/supabase/src/contract/contract.json b/packages/3-extensions/supabase/src/contract/contract.json index 688c608204..37fd77ebab 100644 --- a/packages/3-extensions/supabase/src/contract/contract.json +++ b/packages/3-extensions/supabase/src/contract/contract.json @@ -4832,17 +4832,29 @@ "columns": { "acceptable_client_ids": { "codecId": "pg/text@1", + "default": { + "kind": "literal", + "value": [] + }, "many": true, "nativeType": "text", "nullable": false }, "attribute_mapping": { "codecId": "pg/jsonb@1", + "default": { + "expression": "'{}'::jsonb", + "kind": "function" + }, "nativeType": "jsonb", "nullable": false }, "authorization_params": { "codecId": "pg/jsonb@1", + "default": { + "expression": "'{}'::jsonb", + "kind": "function" + }, "nativeType": "jsonb", "nullable": false }, @@ -4949,6 +4961,10 @@ }, "scopes": { "codecId": "pg/text@1", + "default": { + "kind": "literal", + "value": [] + }, "many": true, "nativeType": "text", "nullable": false @@ -6139,7 +6155,22 @@ } } ], - "indexes": [], + "indexes": [ + { + "columns": [ + "relates_to" + ], + "name": "one_time_tokens_relates_to_hash_idx", + "type": "hash" + }, + { + "columns": [ + "token_hash" + ], + "name": "one_time_tokens_token_hash_hash_idx", + "type": "hash" + } + ], "primaryKey": { "columns": [ "id" @@ -7200,6 +7231,10 @@ }, "transports": { "codecId": "pg/jsonb@1", + "default": { + "expression": "'[]'::jsonb", + "kind": "function" + }, "nativeType": "jsonb", "nullable": false }, @@ -7664,6 +7699,10 @@ }, "metadata": { "codecId": "pg/jsonb@1", + "default": { + "expression": "'{}'::jsonb", + "kind": "function" + }, "nativeType": "jsonb", "nullable": false }, @@ -8303,7 +8342,7 @@ "kind": "postgres-schema" } }, - "storageHash": "sha256:4fd3b6b5481531b6fc23e6f3a97061908a8efaab460a56e3c334ef2483c9dfdb", + "storageHash": "sha256:1856d476008cdfac9fd383f74be8015f6cb313515966c3c6cba4067f5bc15858", "types": { "CreatedAt": { "codecId": "pg/timestamp@1", diff --git a/packages/3-extensions/supabase/src/contract/contract.prisma b/packages/3-extensions/supabase/src/contract/contract.prisma index 928a2d47f0..4700938040 100644 --- a/packages/3-extensions/supabase/src/contract/contract.prisma +++ b/packages/3-extensions/supabase/src/contract/contract.prisma @@ -154,11 +154,11 @@ namespace auth { name String clientId String @map("client_id") clientSecret String @map("client_secret") - acceptableClientIds String[] @map("acceptable_client_ids") - scopes String[] + acceptableClientIds String[] @default([]) @map("acceptable_client_ids") + scopes String[] @default([]) pkceEnabled Boolean @default(true) @map("pkce_enabled") - attributeMapping Json @map("attribute_mapping") - authorizationParams Json @map("authorization_params") + attributeMapping Json @default(dbgenerated("'{}'::jsonb")) @map("attribute_mapping") + authorizationParams Json @default(dbgenerated("'{}'::jsonb")) @map("authorization_params") enabled Boolean @default(true) emailOptional Boolean @default(false) @map("email_optional") issuer String? @@ -331,6 +331,8 @@ namespace auth { updatedAt CreatedAt @default(now()) @map("updated_at") user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "one_time_tokens_user_id_fkey", index: false) + @@index([relatesTo], map: "one_time_tokens_relates_to_hash_idx", type: "hash") + @@index([tokenHash], map: "one_time_tokens_token_hash_hash_idx", type: "hash") @@rls @@map("one_time_tokens") } @@ -449,7 +451,7 @@ namespace auth { attestationType String @default("") @map("attestation_type") aaguid Id? signCount BigInt @default(0) @map("sign_count") - transports Json + transports Json @default(dbgenerated("'[]'::jsonb")) backupEligible Boolean @default(false) @map("backup_eligible") backedUp Boolean @default(false) @map("backed_up") friendlyName String @default("") @map("friendly_name") @@ -557,7 +559,7 @@ namespace storage { name String createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @map("updated_at") - metadata Json + metadata Json @default(dbgenerated("'{}'::jsonb")) catalogId Id @map("catalog_id") icebergTables IcebergTables[] catalog BucketsAnalytics @relation(fields: [catalogId], references: [id], onDelete: Cascade, map: "iceberg_namespaces_catalog_id_fkey", index: false) diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts index 05f95c37bb..a35691ce36 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts @@ -47,7 +47,7 @@ export const pgNumericDecode = (wire: string | number): string => { }; export const pgNumericRenderOutputType = (typeParams: { - readonly precision: number; + readonly precision?: number; readonly scale?: number; }): string | undefined => { const precision = typeParams.precision; @@ -106,6 +106,55 @@ export const pgTimestamptzDecodeJson = (json: JsonValue): Date => { return date; }; +const ISO_8601_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +function formatDateOnly(year: number, month: number, day: number): string { + return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** + * A Postgres `date` has no time-of-day or timezone component, so `pg/date@1` + * canonicalizes its JS-level value as a `Date` at UTC midnight + * (`Date.UTC(y, m, d)`), independent of the process's local timezone. + * + * `pgDateEncode` reads the calendar date via UTC getters (matching that + * canonical form) and formats it as `YYYY-MM-DD` directly, bypassing the pg + * driver's own `Date` serialization (`dateToString`), which reads *local* + * getters and would shift the calendar day near midnight in negative-UTC-offset + * environments. + */ +export const pgDateEncode = (value: Date): string => + formatDateOnly(value.getUTCFullYear(), value.getUTCMonth() + 1, value.getUTCDate()); + +/** + * Normalizes the pg driver's already-parsed `Date` for a `date` column into + * the canonical UTC-midnight form. The driver (via `postgres-date`) builds + * that `Date` at *local* midnight from the wire text; reading it back with the + * same (local) getters recovers the exact calendar date the driver parsed, + * and reconstructing via `Date.UTC` makes the result's instant independent of + * the process's timezone. + */ +export const pgDateDecode = (wire: Date): Date => + new Date(Date.UTC(wire.getFullYear(), wire.getMonth(), wire.getDate())); + +export const pgDateEncodeJson = (value: Date): JsonValue => pgDateEncode(value); + +export const pgDateDecodeJson = (json: JsonValue): Date => { + if (typeof json !== 'string') { + throw new Error(`Expected date string for pg/date@1, got ${typeof json}`); + } + const match = ISO_8601_DATE.exec(json); + if (!match) { + throw new Error(`Invalid date string for pg/date@1: ${json}`); + } + const [, yearText, monthText, dayText] = match; + const date = new Date(Date.UTC(Number(yearText), Number(monthText) - 1, Number(dayText))); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date string for pg/date@1: ${json}`); + } + return date; +}; + export const pgIntervalDecode = (wire: string | Record): string => { if (typeof wire === 'string') return wire; return JSON.stringify(wire); diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-ids.ts b/packages/3-targets/3-targets/postgres/src/core/codec-ids.ts index 4082e69ede..73f2b47244 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-ids.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-ids.ts @@ -22,6 +22,7 @@ export const PG_NUMERIC_CODEC_ID = 'pg/numeric@1' as const; export const PG_BOOL_CODEC_ID = 'pg/bool@1' as const; export const PG_BIT_CODEC_ID = 'pg/bit@1' as const; export const PG_VARBIT_CODEC_ID = 'pg/varbit@1' as const; +export const PG_DATE_CODEC_ID = 'pg/date@1' as const; export const PG_TIMESTAMP_CODEC_ID = 'pg/timestamp@1' as const; export const PG_TIMESTAMPTZ_CODEC_ID = 'pg/timestamptz@1' as const; export const PG_TIME_CODEC_ID = 'pg/time@1' as const; diff --git a/packages/3-targets/3-targets/postgres/src/core/codecs.ts b/packages/3-targets/3-targets/postgres/src/core/codecs.ts index e345be7a58..fc69160ff6 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codecs.ts @@ -45,6 +45,10 @@ import { type as arktype } from 'arktype'; import { pgByteaDecodeJson, pgByteaEncodeJson, + pgDateDecode, + pgDateDecodeJson, + pgDateEncode, + pgDateEncodeJson, pgIntervalDecode, pgJsonbDecode, pgJsonbEncode, @@ -64,6 +68,7 @@ import { PG_BOOL_CODEC_ID, PG_BYTEA_CODEC_ID, PG_CHAR_CODEC_ID, + PG_DATE_CODEC_ID, PG_ENUM_CODEC_ID, PG_FLOAT_CODEC_ID, PG_FLOAT4_CODEC_ID, @@ -92,14 +97,14 @@ import { PostgresNativeEnum } from './postgres-native-enum'; type LengthParams = { readonly length?: number }; type PrecisionParams = { readonly precision?: number }; -type NumericParams = { readonly precision: number; readonly scale?: number }; +type NumericParams = { readonly precision?: number; readonly scale?: number }; const lengthParamsSchema = arktype({ 'length?': 'number.integer > 0', }) satisfies StandardSchemaV1; const numericParamsSchema = arktype({ - precision: 'number.integer > 0 & number.integer <= 1000', + 'precision?': 'number.integer > 0 & number.integer <= 1000', 'scale?': 'number.integer >= 0', }) satisfies StandardSchemaV1; @@ -115,6 +120,7 @@ const PG_INT8_META = { db: { sql: { postgres: { nativeType: 'bigint' } } } } as const PG_FLOAT4_META = { db: { sql: { postgres: { nativeType: 'real' } } } } as const; const PG_FLOAT8_META = { db: { sql: { postgres: { nativeType: 'double precision' } } } } as const; const PG_NUMERIC_META = { db: { sql: { postgres: { nativeType: 'numeric' } } } } as const; +const PG_DATE_META = { db: { sql: { postgres: { nativeType: 'date' } } } } as const; const PG_TIMESTAMP_META = { db: { sql: { postgres: { nativeType: 'timestamp without time zone' } } }, } as const; @@ -648,12 +654,57 @@ export class PgNumericDescriptor extends CodecDescriptorImpl { export const pgNumericDescriptor = new PgNumericDescriptor(); -export const pgNumericColumn = (params: NumericParams) => +export const pgNumericColumn = (params: NumericParams = {}) => column(pgNumericDescriptor.factory(params), pgNumericDescriptor.codecId, params, 'numeric'); pgNumericColumn satisfies ColumnHelperFor; pgNumericColumn satisfies ColumnHelperForStrict; +/** + * A Postgres `date` has no time-of-day or timezone component. This codec + * canonicalizes its JS-level value as a `Date` at UTC midnight, so its + * round-trip is independent of the process's local timezone — see + * `pgDateEncode`/`pgDateDecode` in `codec-helpers.ts`. + */ +export class PgDateCodec extends CodecImpl< + typeof PG_DATE_CODEC_ID, + readonly ['equality', 'order'], + Date | string, + Date +> { + async encode(value: Date, _ctx: CodecCallContext): Promise { + return pgDateEncode(value); + } + async decode(wire: Date, _ctx: CodecCallContext): Promise { + return pgDateDecode(wire); + } + encodeJson(value: Date): JsonValue { + return pgDateEncodeJson(value); + } + decodeJson(json: JsonValue): Date { + return pgDateDecodeJson(json); + } +} + +export class PgDateDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_DATE_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['date'] as const; + override readonly meta = PG_DATE_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgDateCodec { + return () => new PgDateCodec(this); + } +} + +export const pgDateDescriptor = new PgDateDescriptor(); + +export const pgDateColumn = () => + column(pgDateDescriptor.factory(), pgDateDescriptor.codecId, undefined, 'date'); + +pgDateColumn satisfies ColumnHelperFor; +pgDateColumn satisfies ColumnHelperForStrict; + export class PgTimestampCodec extends CodecImpl< typeof PG_TIMESTAMP_CODEC_ID, readonly ['equality', 'order'], @@ -1285,6 +1336,7 @@ export const codecDescriptors: readonly AnyCodecDescriptor[] = [ pgFloat4Descriptor, pgFloat8Descriptor, pgNumericDescriptor, + pgDateDescriptor, pgTimestampDescriptor, pgTimestamptzDescriptor, pgTimeDescriptor, diff --git a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts index 54dbd1f419..c650d5cd24 100644 --- a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts @@ -249,3 +249,25 @@ export function parsePostgresDefault( return { kind: 'function', expression: trimmed }; } + +/** + * Normalizes a contract-declared default through {@link parsePostgresDefault} + * — the same parser introspection uses — so a function-shaped default the + * parser recognizes as a literal (e.g. `dbgenerated("'{}'::jsonb")`) + * resolves to the same `resolvedDefault` shape a live introspected column + * would produce. Compensates once, at `SchemaIR` construction of the + * expected (contract-derived) side (`contractToSchemaIR`'s `resolveDefault` + * hook), instead of at every site that later compares the two sides. A + * literal default, or a function form the parser doesn't recognize, passes + * through unchanged; `nextval(...)` normalizes to `autoincrement()` on both + * sides, matching a `serial`/identity column's introspected counterpart. + */ +export function postgresResolveDefault( + def: ColumnDefault, + resolvedNativeType: string, +): ColumnDefault { + if (def.kind !== 'function') { + return def; + } + return parsePostgresDefault(def.expression, resolvedNativeType) ?? def; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts b/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts index 57fab1cd51..25cf104a57 100644 --- a/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts +++ b/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts @@ -9,12 +9,14 @@ import { } from './authoring'; import { postgresQualifyColumnType } from './codecs'; import { postgresTargetDescriptorMetaRuntime } from './descriptor-meta-runtime'; +import { postgresIndexTypes } from './index-types'; import { DEFAULT_NAMESPACE_ID } from './namespace-ids'; import { postgresCreateNamespace } from './postgres-schema'; const postgresTargetDescriptorMetaBase = { ...postgresTargetDescriptorMetaRuntime, defaultNamespaceId: DEFAULT_NAMESPACE_ID, + indexTypes: postgresIndexTypes, authoring: { type: postgresAuthoringTypes, field: postgresAuthoringFieldPresets, diff --git a/packages/3-targets/3-targets/postgres/src/core/index-types.ts b/packages/3-targets/3-targets/postgres/src/core/index-types.ts new file mode 100644 index 0000000000..0fc5c2ab20 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/index-types.ts @@ -0,0 +1,15 @@ +import { defineIndexTypes } from '@prisma-next/sql-contract/index-types'; +import { type } from 'arktype'; + +// Postgres's built-in index access methods (`CREATE INDEX ... USING `). +// Per-method option validation (e.g. `gin` operator classes) is out of scope; +// every method accepts any options object until a later slice narrows it. +export const postgresIndexTypes = defineIndexTypes() + .add('btree', { options: type('object') }) + .add('hash', { options: type('object') }) + .add('gin', { options: type('object') }) + .add('gist', { options: type('object') }) + .add('spgist', { options: type('object') }) + .add('brin', { options: type('object') }); + +export type IndexTypes = typeof postgresIndexTypes.IndexTypes; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts index d72cc02343..2cbc673e51 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts @@ -10,6 +10,7 @@ import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; +import { postgresResolveDefault } from '../default-normalizer'; import type { PostgresContract } from '../postgres-schema'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; import { PostgresNamespaceSchemaNode } from '../schema-ir/postgres-namespace-schema-node'; @@ -135,6 +136,7 @@ export function diffPostgresSchema(input: { const fullExpected = contractToPostgresDatabaseSchemaNode(postgresContract, { annotationNamespace: 'pg', ...ifDefined('expandNativeType', expandNativeType), + resolveDefault: postgresResolveDefault, }); const expected = pruneTableLessNamespaces(fullExpected); const relationalOwned = ownedSchemaNames(expected); @@ -230,6 +232,7 @@ export function buildPostgresPlanDiff(input: { const projectionOptions = { annotationNamespace: 'pg', ...ifDefined('expandNativeType', expandNativeType), + resolveDefault: postgresResolveDefault, }; const fullExpected = contractToPostgresDatabaseSchemaNode(postgresContract, projectionOptions); const expected = pruneTableLessNamespaces(fullExpected); diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts index a68d0cd9fc..ea32983830 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts @@ -162,6 +162,15 @@ type ForeignKeyResolution = { readonly extraRelationsByTable: ReadonlyMap; /** Synthetic field-name maps for cross-space-referenced pack tables, merged into `fieldNamesByTable`. */ readonly crossSpaceFieldNamesByTable: ReadonlyMap; + /** Dangling foreign keys dropped per host table, kept so the model can explain the drop. */ + readonly danglingForeignKeysByTable: ReadonlyMap; +}; + +/** A foreign key dropped because its target lives outside the introspected scope. */ +type DanglingForeignKeyInfo = { + readonly columns: readonly string[]; + readonly referencedSchema: string | undefined; + readonly referencedTable: string; }; /** @@ -198,6 +207,7 @@ function resolveForeignKeys( const resultTables: Record = {}; const extraRelationsByTable = new Map(); const crossSpaceFieldNamesByTable = new Map(); + const danglingForeignKeysByTable = new Map(); for (const [tableName, table] of Object.entries(tables)) { const keptForeignKeys: SqlForeignKeyIR[] = []; @@ -248,9 +258,22 @@ function resolveForeignKeys( // Not a pack-owned coordinate: keep the foreign key if the referenced // table survived introspection (local), otherwise drop it while keeping - // the scalar column (dangling). + // the scalar column (dangling) — recording it so the model can explain + // the drop instead of the relation vanishing without a trace. if (tables[fk.referencedTable] !== undefined) { keptForeignKeys.push(fk); + } else { + const dangling: DanglingForeignKeyInfo = { + columns: fk.columns, + referencedSchema: fk.referencedSchema, + referencedTable: fk.referencedTable, + }; + const existingDangling = danglingForeignKeysByTable.get(tableName); + if (existingDangling) { + existingDangling.push(dangling); + } else { + danglingForeignKeysByTable.set(tableName, [dangling]); + } } } @@ -260,7 +283,12 @@ function resolveForeignKeys( : new SqlTableIR({ ...table, foreignKeys: keptForeignKeys }); } - return { tables: resultTables, extraRelationsByTable, crossSpaceFieldNamesByTable }; + return { + tables: resultTables, + extraRelationsByTable, + crossSpaceFieldNamesByTable, + danglingForeignKeysByTable, + }; } /** @@ -405,6 +433,7 @@ export function inferPostgresPslContract( tables: resolvedTables, extraRelationsByTable, crossSpaceFieldNamesByTable, + danglingForeignKeysByTable, } = resolveForeignKeys(tables, owners); const schemaIR = new SqlSchemaIR({ tables: resolvedTables }); @@ -437,6 +466,7 @@ export function inferPostgresPslContract( { extraRelationsByTable, crossSpaceFieldNamesByTable, + danglingForeignKeysByTable, }, wrapNamespaceName, ); @@ -447,12 +477,13 @@ export function buildPslDocumentAst( options: PslPrinterOptions, foreignKeyExtras: Pick< ForeignKeyResolution, - 'extraRelationsByTable' | 'crossSpaceFieldNamesByTable' + 'extraRelationsByTable' | 'crossSpaceFieldNamesByTable' | 'danglingForeignKeysByTable' >, namespaceName?: string, ): PslDocumentAst { const { typeMap, defaultMapping, parseRawDefault: rawDefaultParser } = options; - const { extraRelationsByTable, crossSpaceFieldNamesByTable } = foreignKeyExtras; + const { extraRelationsByTable, crossSpaceFieldNamesByTable, danglingForeignKeysByTable } = + foreignKeyExtras; const modelNames = buildTopLevelNameMap( Object.keys(schemaIR.tables), @@ -508,6 +539,7 @@ export function buildPslDocumentAst( ...(relationsByTable.get(table.name) ?? []), ...(extraRelationsByTable.get(table.name) ?? []), ], + danglingForeignKeysByTable.get(table.name) ?? [], ), ); } @@ -642,6 +674,7 @@ function buildModel( defaultMapping: DefaultMappingOptions | undefined, rawDefaultParser: PslPrinterOptions['parseRawDefault'], relationFields: readonly RelationField[], + danglingForeignKeys: readonly DanglingForeignKeyInfo[], ): PslModel { const { name: modelName, map: mapName } = toModelName(table.name); const fieldNameMap = fieldNamesByTable.get(table.name); @@ -719,13 +752,23 @@ function buildModel( modelAttributes.push(buildMapAttribute('model', mapName)); } - // Surface introspection advisory: tables without a primary key cannot serve - // as the right-hand side of a `findUnique`-style query downstream, so the - // user should add an `@id`. This warning is part of the emitted SQL output - // and is asserted byte-for-byte, so keep the exact wording. - const comment = table.primaryKey - ? undefined - : '// WARNING: This table has no primary key in the database'; + // Surface introspection advisories the user would otherwise have no way to + // discover from the emitted PSL alone. Both warnings are part of the + // emitted SQL output and are asserted byte-for-byte, so keep the exact + // wording; a table hitting both is combined onto the single comment line + // `PslModel.comment` supports. + const warnings: string[] = []; + if (!table.primaryKey) { + // Tables without a primary key cannot serve as the right-hand side of a + // `findUnique`-style query downstream, so the user should add an `@id`. + warnings.push('This table has no primary key in the database'); + } + if (danglingForeignKeys.length > 0) { + warnings.push( + buildDanglingForeignKeyWarning(danglingForeignKeys, fieldNamesByTable, table.name), + ); + } + const comment = warnings.length > 0 ? `// WARNING: ${warnings.join(' ')}` : undefined; return { kind: 'model', @@ -737,6 +780,37 @@ function buildModel( }; } +/** + * Explains a foreign key `resolveForeignKeys` dropped as dangling: the + * database enforces it, but its target lives outside the introspected + * schema, so infer has no model to point a relation at. Without this, the + * relation just vanishes from the emitted PSL with no trace. Matches the + * missing-primary-key warning's voice — one line, stating the fact and the + * fix — since a schema outside the introspected scope is typically an + * extension pack (e.g. Supabase's `auth`) that isn't configured yet. + */ +function buildDanglingForeignKeyWarning( + danglingForeignKeys: readonly DanglingForeignKeyInfo[], + fieldNamesByTable: ReadonlyMap, + tableName: string, +): string { + const descriptions = danglingForeignKeys.map((fk) => { + const fieldNames = fk.columns.map((columnName) => + resolveColumnFieldName(fieldNamesByTable, tableName, columnName), + ); + const target = + fk.referencedSchema !== undefined + ? `${fk.referencedSchema}.${fk.referencedTable}` + : fk.referencedTable; + return `"${fieldNames.join(', ')}" -> "${target}"`; + }); + return ( + `Foreign key ${descriptions.join(', ')} exists in the database, but its target schema is ` + + 'outside the introspected scope, so no relation field was generated. If the target schema ' + + 'is described by an extension pack, add it to extensionPacks and re-run infer.' + ); +} + function buildScalarField( column: SqlColumnIR, table: SqlTableIR, @@ -799,7 +873,45 @@ function buildScalarField( attributes.push(buildSimpleConstraintFieldAttribute('id', singlePkConstraintName)); } - if (column.default !== undefined) { + if ( + column.default === undefined && + column.resolvedDefault?.kind === 'function' && + column.resolvedDefault.expression === 'autoincrement()' + ) { + // A `GENERATED ... AS IDENTITY` column (either variant) reports no + // `column_default` at all, so it never reaches the raw-default path + // below — the postgres control adapter is the only place that resolves + // an identity column, stamping `resolvedDefault` straight to + // `autoincrement()` since there is no raw expression to parse. That is + // the only introspected shape which carries a `resolvedDefault` with no + // raw `default`, so this check identifies it without a dedicated + // `identity` field on the column IR. The contract's vocabulary already + // means "the database generates this value" for `autoincrement()` — the + // same thing both identity variants and `serial` mean — so print it + // directly rather than modelling the ALWAYS/BY-DEFAULT distinction. + attributes.push(parseDefaultAttributeString('@default(autoincrement())')); + } else if (column.many === true && column.resolvedDefault?.kind === 'literal') { + // Postgres reports a list column's default as raw SQL text (e.g. + // `'{}'::text[]`), which the family-generic raw-default parser doesn't + // recognize as an array literal and falls back to printing + // `dbgenerated(...)` — a shape the interpreter always rejects on a list + // column, because `dbgenerated` only ever lowers to a storage-level + // function default and lists accept only literal defaults. The postgres + // control adapter already resolved the same raw text to a structured + // literal at introspection time (`resolvedDefault`), so print PSL's own + // literal-list syntax from that instead of re-deriving it from the raw + // string. PSL literal-list elements are string/number/boolean only; if + // the resolved value holds anything else (e.g. a nested object, or a + // null element), it has no literal-list spelling, so the default is + // omitted rather than printed as `dbgenerated` — an omitted default + // becomes a live-only "extra" under verify, not a false mismatch. + const formatted = Array.isArray(column.resolvedDefault.value) + ? formatPslListLiteralValue(column.resolvedDefault.value) + : undefined; + if (formatted !== undefined) { + attributes.push(parseDefaultAttributeString(`@default(${formatted})`)); + } + } else if (column.default !== undefined) { const parsed = parseColumnDefault(column.default, column.nativeType, rawDefaultParser); if (parsed) { const result = mapDefault(parsed, defaultMapping); @@ -989,6 +1101,27 @@ function escapePslString(value: string): string { .replace(/\r/g, '\\r'); } +/** + * Formats a resolved literal-default array as PSL literal-list syntax + * (`[1, 2, 3]`, `["a", "b"]`, `[]`). PSL's list-literal grammar only accepts + * string/number/boolean elements, so any other element (e.g. `null`, a + * nested array/object) makes the value unrepresentable and this returns + * `undefined`. + */ +function formatPslListLiteralValue(elements: readonly unknown[]): string | undefined { + const parts: string[] = []; + for (const element of elements) { + if (typeof element === 'string') { + parts.push(`"${escapePslString(element)}"`); + } else if (typeof element === 'number' || typeof element === 'boolean') { + parts.push(String(element)); + } else { + return undefined; + } + } + return `[${parts.join(', ')}]`; +} + /** * Resolves a `SqlColumnIR.default` value into a normalized {@link ColumnDefault}. * diff --git a/packages/3-targets/3-targets/postgres/src/exports/codecs.ts b/packages/3-targets/3-targets/postgres/src/exports/codecs.ts index 61e714d099..afb5d2a9d8 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/codecs.ts @@ -2,6 +2,7 @@ export type { PgBitDescriptor, PgBoolDescriptor, PgCharDescriptor, + PgDateDescriptor, PgEnumCodec, PgEnumDescriptor, PgFloat4Descriptor, @@ -29,6 +30,7 @@ export { pgBitColumn, pgBoolColumn, pgCharColumn, + pgDateColumn, pgEnumDescriptor, pgFloat4Column, pgFloat8Column, diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index 30b50043a5..78d4c60316 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -9,6 +9,7 @@ import type { import type { StorageColumn } from '@prisma-next/sql-contract/types'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; +import { postgresResolveDefault } from '../core/default-normalizer'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; import { contractToPostgresDatabaseSchemaNode } from '../core/migrations/contract-to-postgres-database-schema-node'; import { diffPostgresSchema } from '../core/migrations/diff-database-schema'; @@ -64,6 +65,7 @@ const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresP annotationNamespace: 'pg', ...ifDefined('expandNativeType', expander), renderDefault: postgresRenderDefault, + resolveDefault: postgresResolveDefault, }); }, }, diff --git a/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts index 480093cfcc..40e9e548c5 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts @@ -1 +1 @@ -export { parsePostgresDefault } from '../core/default-normalizer'; +export { parsePostgresDefault, postgresResolveDefault } from '../core/default-normalizer'; diff --git a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts index 3694487039..b0f64a9737 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { PG_BIT_CODEC_ID, PG_BOOL_CODEC_ID, + PG_DATE_CODEC_ID, PG_FLOAT4_CODEC_ID, PG_FLOAT8_CODEC_ID, PG_INET_CODEC_ID, @@ -23,6 +24,7 @@ import { import { pgBitDescriptor, pgBoolDescriptor, + pgDateDescriptor, pgFloat4Descriptor, pgFloat8Descriptor, pgInetDescriptor, @@ -157,6 +159,76 @@ describe('codecs-class', () => { }); }); + describe('pg/numeric@1 with no typeParams (unbounded numeric / bare Decimal)', () => { + const codec = pgNumericDescriptor.factory({})(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_NUMERIC_CODEC_ID); + }); + + it('encodes and decodes strings verbatim with no precision/scale supplied', async () => { + expect(await codec.encode('123.45', callCtx)).toBe('123.45'); + expect(await codec.decode('123.45', callCtx)).toBe('123.45'); + }); + + it('renderOutputType returns undefined when precision is absent', () => { + expect(pgNumericDescriptor.renderOutputType?.({})).toBeUndefined(); + }); + }); + + describe('pg/date@1', () => { + const codec = pgDateDescriptor.factory()(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_DATE_CODEC_ID); + }); + + it('decode normalizes a local-midnight Date into canonical UTC midnight', async () => { + // Simulates what the pg driver hands the codec for a `date` column: a + // `Date` built at *local* midnight (postgres-date's `getDate`), e.g. + // `new Date(2024, 0, 15)`. Regardless of the process's timezone, decode + // must recover the same calendar date at UTC midnight. + const localMidnight = new Date(2024, 0, 15); + const decoded = await codec.decode(localMidnight, callCtx); + expect(decoded.getTime()).toBe(Date.UTC(2024, 0, 15)); + }); + + it('encode formats the UTC calendar date as YYYY-MM-DD, independent of local getters', async () => { + const utcMidnight = new Date(Date.UTC(2024, 0, 15)); + expect(await codec.encode(utcMidnight, callCtx)).toBe('2024-01-15'); + }); + + it('round-trips a calendar date through encode -> decode unchanged', async () => { + const original = new Date(Date.UTC(2024, 0, 15)); + const wireText = await codec.encode(original, callCtx); + // The driver would parse `wireText` back into a Date; decode + // canonicalizes whatever it receives to the same UTC-midnight instant. + const roundTripped = await codec.decode(new Date(2024, 0, 15), callCtx); + expect(wireText).toBe('2024-01-15'); + expect(roundTripped.getTime()).toBe(original.getTime()); + }); + + it('encodeJson/decodeJson round-trip the YYYY-MM-DD representation', () => { + const instant = new Date(Date.UTC(2024, 0, 15)); + expect(codec.encodeJson(instant)).toBe('2024-01-15'); + expect(codec.decodeJson('2024-01-15')).toEqual(instant); + }); + + it('throws on invalid JSON input', () => { + expect(() => codec.decodeJson(42)).toThrow(/Expected date string for pg\/date@1/); + expect(() => codec.decodeJson('not-a-date')).toThrow(/Invalid date string for pg\/date@1/); + expect(() => codec.decodeJson('2024-01-15T10:30:00Z')).toThrow( + /Invalid date string for pg\/date@1/, + ); + }); + + it('exposes equality-order traits and the date target/native types', () => { + expect(pgDateDescriptor.traits).toEqual(['equality', 'order']); + expect(pgDateDescriptor.targetTypes).toEqual(['date']); + expect(pgDateDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('date'); + }); + }); + describe('pg/timestamp@1', () => { const codec = pgTimestampDescriptor.factory({ precision: 3 })(instanceCtx); diff --git a/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts b/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts index 7eeb91f8af..aa12a65a27 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts @@ -87,7 +87,16 @@ test('pgNumeric: column helper preserves typed codecFactory + composite params', const col = pgNumericColumn({ precision: 10, scale: 2 }); expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgNumericCodec>(); expectTypeOf(col.typeParams).toEqualTypeOf<{ - readonly precision: number; + readonly precision?: number; + readonly scale?: number; + }>(); +}); + +test('pgNumeric: column helper accepts no-args call (default params)', () => { + const col = pgNumericColumn(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgNumericCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ + readonly precision?: number; readonly scale?: number; }>(); }); diff --git a/packages/3-targets/3-targets/postgres/test/codecs-runtime-and-helpers.test.ts b/packages/3-targets/3-targets/postgres/test/codecs-runtime-and-helpers.test.ts index 4d8f284d5f..e14e85b3ca 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs-runtime-and-helpers.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs-runtime-and-helpers.test.ts @@ -5,6 +5,7 @@ import { PG_BOOL_CODEC_ID, PG_BYTEA_CODEC_ID, PG_CHAR_CODEC_ID, + PG_DATE_CODEC_ID, PG_ENUM_CODEC_ID, PG_FLOAT_CODEC_ID, PG_FLOAT4_CODEC_ID, @@ -34,6 +35,7 @@ import { pgByteaDescriptor, pgCharColumn, pgCharDescriptor, + pgDateColumn, pgEnumDescriptor, pgFloat4Column, pgFloat8Column, @@ -348,7 +350,7 @@ describe('column helpers', () => { expect(spec.codecFactory(instanceCtx).id).toBe(PG_JSONB_CODEC_ID); }); - it('pgNumericColumn packages a ColumnSpec for pg/numeric@1 (params required)', () => { + it('pgNumericColumn packages a ColumnSpec for pg/numeric@1', () => { const spec = pgNumericColumn({ precision: 10, scale: 2 }); expect(spec.codecId).toBe(PG_NUMERIC_CODEC_ID); expect(spec.nativeType).toBe('numeric'); @@ -356,6 +358,18 @@ describe('column helpers', () => { expect(spec.codecFactory(instanceCtx).id).toBe(PG_NUMERIC_CODEC_ID); }); + it('pgNumericColumn defaults typeParams to {} when called with no args (unbounded numeric / bare Decimal)', () => { + const spec = pgNumericColumn(); + expect(spec.typeParams).toEqual({}); + }); + + it('pgDateColumn packages a ColumnSpec for pg/date@1', () => { + const spec = pgDateColumn(); + expect(spec.codecId).toBe(PG_DATE_CODEC_ID); + expect(spec.nativeType).toBe('date'); + expect(spec.codecFactory(instanceCtx).id).toBe(PG_DATE_CODEC_ID); + }); + it('pgTextColumn packages a ColumnSpec for pg/text@1', () => { const spec = pgTextColumn(); expect(spec.codecId).toBe(PG_TEXT_CODEC_ID); diff --git a/packages/3-targets/3-targets/postgres/test/codecs.test.ts b/packages/3-targets/3-targets/postgres/test/codecs.test.ts index b866d80cbd..4ac5c2aa83 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs.test.ts @@ -17,6 +17,7 @@ import { pgBoolDescriptor, pgByteaDescriptor, pgCharDescriptor, + pgDateDescriptor, pgFloat4Descriptor, pgFloat8Descriptor, pgFloatDescriptor, @@ -60,6 +61,7 @@ const descriptorByScalar = { float4: pgFloat4Descriptor, float8: pgFloat8Descriptor, numeric: pgNumericDescriptor, + date: pgDateDescriptor, timestamp: pgTimestampDescriptor, timestamptz: pgTimestamptzDescriptor, time: pgTimeDescriptor, @@ -93,6 +95,7 @@ describe('adapter-postgres codecs', () => { 'char', 'character', 'character varying', + 'date', 'double precision', 'float', 'float4', @@ -289,6 +292,22 @@ describe('adapter-postgres codecs', () => { }); }); + describe('date codec', () => { + const dateCodec = codecForScalar('date') as { + encode: (value: Date, ctx: SqlCodecCallContext) => Promise; + decode: (wire: Date, ctx: SqlCodecCallContext) => Promise; + }; + + it('encodes a Date as its UTC calendar date, not the pg driver Date auto-conversion', async () => { + expect(await dateCodec.encode(new Date(Date.UTC(2024, 0, 15)), {})).toBe('2024-01-15'); + }); + + it('decodes the driver local-midnight Date to the equivalent UTC-midnight instant', async () => { + const decoded = await dateCodec.decode(new Date(2024, 0, 15), {}); + expect(decoded.getTime()).toBe(Date.UTC(2024, 0, 15)); + }); + }); + describe('time codec', () => { const timeCodec = codecForScalar('time') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; @@ -603,6 +622,13 @@ describe('adapter-postgres codecs', () => { }); }); + describe('pg/date@1 registry resolution', () => { + it('resolves pgDateDescriptor by codec id from the registry', () => { + const resolved = postgresCodecRegistry.descriptorFor('pg/date@1'); + expect(resolved).toBe(pgDateDescriptor); + }); + }); + describe('numeric codec decode', () => { const numericCodec = codecForScalar('numeric') as { decode: (wire: string | number, ctx: SqlCodecCallContext) => Promise; diff --git a/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts b/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts index 6ed0b9f30f..9fcd8ce5e5 100644 --- a/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts +++ b/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parsePostgresDefault } from '../src/core/default-normalizer'; +import { parsePostgresDefault, postgresResolveDefault } from '../src/core/default-normalizer'; describe('parsePostgresDefault array literals', () => { it('parses an empty array body', () => { @@ -346,3 +346,63 @@ describe('parsePostgresDefault unparseable expressions', () => { }); }); }); + +describe('postgresResolveDefault', () => { + // The contract-derived (expected) side's `resolveDefault` hook, called at + // `SchemaIR` construction so the expected side normalizes a `dbgenerated` + // literal-shaped function default the same way introspection already + // does. Reintroducing a bare "keep the contract default unchanged" here + // would reproduce the class of bug this fixes: `db verify` reporting + // permanent drift for a jsonb/text[] literal default that matches the + // live database exactly. + + it('a literal default passes through unchanged', () => { + const literal = { kind: 'literal' as const, value: 'draft' }; + expect(postgresResolveDefault(literal, 'text')).toEqual(literal); + }); + + it('resolves a dbgenerated jsonb literal to a literal object, matching introspection', () => { + const result = postgresResolveDefault({ kind: 'function', expression: "'{}'::jsonb" }, 'jsonb'); + expect(result).toEqual({ kind: 'literal', value: {} }); + }); + + it('resolves a dbgenerated text[] literal to a literal array, matching introspection', () => { + const result = postgresResolveDefault( + { kind: 'function', expression: "'{}'::text[]" }, + 'text[]', + ); + expect(result).toEqual({ kind: 'literal', value: [] }); + }); + + it('normalizes a dbgenerated nextval(...) to autoincrement(), matching a serial/identity column', () => { + const result = postgresResolveDefault( + { kind: 'function', expression: "nextval('my_seq'::regclass)" }, + 'int4', + ); + expect(result).toEqual({ kind: 'function', expression: 'autoincrement()' }); + }); + + it('keeps gen_random_uuid() a function, unresolved', () => { + const expression = 'gen_random_uuid()'; + expect(postgresResolveDefault({ kind: 'function', expression }, 'uuid')).toEqual({ + kind: 'function', + expression, + }); + }); + + it('keeps a now()-plus-interval expression a function, unresolved', () => { + const expression = "(now() + '00:03:00'::interval)"; + expect(postgresResolveDefault({ kind: 'function', expression }, 'timestamptz')).toEqual({ + kind: 'function', + expression, + }); + }); + + it('keeps an enum-cast literal a function (unqualified cast type defeats the string-literal pattern)', () => { + const expression = "'confidential'::auth.oauth_client_type"; + expect(postgresResolveDefault({ kind: 'function', expression }, 'oauth_client_type')).toEqual({ + kind: 'function', + expression, + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/index-types.test.ts b/packages/3-targets/3-targets/postgres/test/index-types.test.ts new file mode 100644 index 0000000000..bef86496f5 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/index-types.test.ts @@ -0,0 +1,116 @@ +/** + * Postgres index-type registration (TML-3037). + * + * `contract infer` prints `@@index(..., type: "gin"/"hash")` for a non-default + * access method, but the postgres target registered zero index types, so + * `validateIndexTypes` rejected every non-btree index at emit. These tests + * prove: (1) the registry itself carries the six Postgres built-in access + * methods with permissive options, and (2) a real PSL interpret → build pass + * accepts a `gin`/`hash` index end-to-end while still rejecting a bogus type + * — registering real methods must not disable the check. + */ +import { assembleAuthoringContributions } from '@prisma-next/framework-components/control'; +import { buildSymbolTable } from '@prisma-next/psl-parser'; +import { parse } from '@prisma-next/psl-parser/syntax'; +import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl'; +import { type } from 'arktype'; +import { describe, expect, it } from 'vitest'; +import { + postgresAuthoringEntityTypes, + postgresAuthoringPslBlockDescriptors, +} from '../src/core/authoring'; +import { postgresTargetDescriptorMeta } from '../src/core/descriptor-meta'; +import { postgresIndexTypes } from '../src/core/index-types'; +import { type PostgresSchema, postgresCreateNamespace } from '../src/core/postgres-schema'; + +const assembled = assembleAuthoringContributions([ + { + authoring: { + entityTypes: postgresAuthoringEntityTypes, + pslBlockDescriptors: postgresAuthoringPslBlockDescriptors, + }, + }, +]); + +const scalarTypeDescriptors = new Map([ + ['Int', { codecId: 'pg/int4@1', nativeType: 'int4' }], +]); + +function interpret(source: string) { + const { document, sourceFile } = parse(source); + const { table: symbolTable } = buildSymbolTable({ + document, + sourceFile, + scalarTypes: [...scalarTypeDescriptors.keys()], + pslBlockDescriptors: assembled.pslBlockDescriptors, + }); + return interpretPslDocumentToSqlContract({ + symbolTable, + sourceFile, + sourceId: 'schema.prisma', + capabilities: {}, + target: postgresTargetDescriptorMeta, + scalarTypeDescriptors, + authoringContributions: assembled, + composedExtensionContracts: new Map(), + createNamespace: postgresCreateNamespace, + }); +} + +function modelWithIndexType(indexType: string): string { + return ` +model Widgets { + id Int @id + code Int + @@index([code], type: "${indexType}") +} +`; +} + +describe('postgresIndexTypes', () => { + it('registers the six Postgres built-in access methods', () => { + expect(postgresIndexTypes.entries.map((e) => e.type)).toEqual([ + 'btree', + 'hash', + 'gin', + 'gist', + 'spgist', + 'brin', + ]); + }); + + it('accepts an arbitrary options object for every registered method (permissive; per-method validation is a later slice)', () => { + for (const entry of postgresIndexTypes.entries) { + const result = entry.options({ anything: 'goes' }); + expect(result instanceof type.errors).toBe(false); + } + }); +}); + +describe('postgresTargetDescriptorMeta', () => { + it('declares its index types via postgresIndexTypes', () => { + expect(postgresTargetDescriptorMeta.indexTypes).toBe(postgresIndexTypes); + }); +}); + +describe('contract build registers postgres index types end-to-end', () => { + it('accepts @@index(..., type: "gin")', () => { + const result = interpret(modelWithIndexType('gin')); + expect(result.ok).toBe(true); + if (!result.ok) return; + const ns = result.value.storage.namespaces['public'] as PostgresSchema; + expect(ns.table['widgets']?.indexes.map((idx) => idx.type)).toEqual(['gin']); + }); + + it('accepts @@index(..., type: "hash")', () => { + const result = interpret(modelWithIndexType('hash')); + expect(result.ok).toBe(true); + if (!result.ok) return; + const ns = result.value.storage.namespaces['public'] as PostgresSchema; + expect(ns.table['widgets']?.indexes.map((idx) => idx.type)).toEqual(['hash']); + }); + + it('still rejects a bogus, unregistered index type — registering real methods does not disable the check', () => { + expect(() => interpret(modelWithIndexType('bogus'))).toThrow(/unregistered index type "bogus"/); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts index 42fba2202e..7c2737f908 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts @@ -300,6 +300,10 @@ describe('inferPostgresPslContract — described-contract omission', () => { expect(relationField?.typeName).toBe('AuthUser'); expect(relationField?.typeNamespaceId).toBe('auth'); expect(relationField?.typeContractSpaceId).toBe('supabase'); + // A resolved cross-space FK is not dangling — it must not carry the + // dangling-FK warning comment the "target neither in the tree nor owned" + // case gets. + expect(profileModel?.comment).toBeUndefined(); const printed = printPsl(ast); expect(printed).toContain('supabase:auth.AuthUser'); @@ -395,7 +399,7 @@ describe('inferPostgresPslContract — described-contract omission', () => { ).toThrow(/owns storage coordinate "auth\.users" but declares no domain model/); }); - it('drops a genuinely dangling FK (target neither in the tree nor owned by any described contract), keeping the scalar column', () => { + it('drops a genuinely dangling FK (target neither in the tree nor owned by any described contract), keeping the scalar column and explaining the drop with a comment', () => { const database = tree({ public: namespaceNode('public', { posts: new PostgresTableSchemaNode({ @@ -406,7 +410,12 @@ describe('inferPostgresPslContract — described-contract omission', () => { }, primaryKey: { columns: ['id'] }, foreignKeys: [ - { columns: ['ownerId'], referencedTable: 'owners', referencedColumns: ['id'] }, + { + columns: ['ownerId'], + referencedTable: 'owners', + referencedSchema: 'secure', + referencedColumns: ['id'], + }, ], uniques: [], indexes: [], @@ -426,6 +435,12 @@ describe('inferPostgresPslContract — described-contract omission', () => { expect(postsModel?.fields.some((f) => f.attributes.some((a) => a.name === 'relation'))).toBe( false, ); + expect(postsModel?.comment).toBe( + '// WARNING: Foreign key "ownerId" -> "secure.owners" exists in the database, but its ' + + 'target schema is outside the introspected scope, so no relation field was generated. ' + + 'If the target schema is described by an extension pack, add it to extensionPacks and ' + + 're-run infer.', + ); }); it('keeps a legitimate FK to a surviving same-named table when a different namespace omits that name', () => { @@ -461,6 +476,8 @@ describe('inferPostgresPslContract — described-contract omission', () => { expect(postsModel?.fields.some((f) => f.attributes.some((a) => a.name === 'relation'))).toBe( true, ); + // A local FK that resolved to a real relation is not dangling — no warning comment. + expect(postsModel?.comment).toBeUndefined(); }); it('omits a described-contract-claimed table before the cross-schema duplicate-name check', () => { diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts index f56e28aaee..abece4849d 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts @@ -142,6 +142,47 @@ describe('inferPostgresPslContract', () => { expect(arg && arg.kind === 'positional' ? arg.value : '').toContain('now()'); }); + it('produces a @default(autoincrement()) attribute for an identity column (GENERATED ... AS IDENTITY)', () => { + // Both `GENERATED ALWAYS AS IDENTITY` and `GENERATED BY DEFAULT AS + // IDENTITY` report no `column_default` at all — the postgres control + // adapter stamps `resolvedDefault` straight to `autoincrement()` with no + // raw expression (PSL has no syntax to distinguish the two variants), + // and infer must print the same `@default(autoincrement())` either way. + const schemaIR = ir({ + tables: { + session: { + name: 'session', + columns: { + id: { + name: 'id', + nativeType: 'int4', + nullable: false, + resolvedDefault: { kind: 'function', expression: 'autoincrement()' }, + }, + note: { name: 'note', nativeType: 'text', nullable: true }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }); + + const ast = sqlSchemaIrToPslAst(schemaIR); + const model = flatPslModels(ast)[0]; + const idField = model?.fields.find((f) => f.name === 'id'); + const defaultAttr = idField?.attributes.find((a) => a.name === 'default'); + expect(defaultAttr).toBeDefined(); + const arg = defaultAttr?.args[0]; + expect(arg && arg.kind === 'positional' ? arg.value : '').toBe('autoincrement()'); + + // A plain column with neither a raw default nor a resolvedDefault gets + // no @default attribute at all. + const noteField = model?.fields.find((f) => f.name === 'note'); + expect(noteField?.attributes.some((a) => a.name === 'default')).toBe(false); + }); + it('attaches a "no primary key" warning comment for tables without a primary key', () => { const schemaIR = ir({ tables: { diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts index fefde282cb..55e8e0b0ad 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts @@ -42,6 +42,7 @@ function printWithEnums( const ast = buildPslDocumentAst(new SqlSchemaIR({ tables }), options, { extraRelationsByTable: new Map(), crossSpaceFieldNamesByTable: new Map(), + danglingForeignKeysByTable: new Map(), }); return printPsl(ast, { pslBlockDescriptors: postgresAuthoringPslBlockDescriptors }); } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts index a6ad10b39f..17be7839cc 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts @@ -487,8 +487,8 @@ describe('printPsl', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma-next contract emit\`. model Parent { - id Int @id - childs Child[] + id Int @id + children Child[] @@map("parent") } diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 67df835947..5739699855 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -1,4 +1,8 @@ -import type { ContractMarkerRecord, LedgerEntryRecord } from '@prisma-next/contract/types'; +import type { + ColumnDefault, + ContractMarkerRecord, + LedgerEntryRecord, +} from '@prisma-next/contract/types'; import { parseMarkerRowSafely, rethrowMarkerReadError, @@ -712,6 +716,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { numeric_scale: number | null; column_default: string | null; formatted_type: string | null; + attidentity: string; }>( `SELECT c.table_name, @@ -723,7 +728,8 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { numeric_precision, numeric_scale, column_default, - format_type(a.atttypid, a.atttypmod) AS formatted_type + format_type(a.atttypid, a.atttypmod) AS formatted_type, + a.attidentity FROM information_schema.columns c JOIN pg_catalog.pg_class cl ON cl.relname = c.table_name @@ -1007,6 +1013,21 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { // normalizes them itself. const resolvedNativeType = `${normalizeSchemaNativeType(nativeType)}${many ? '[]' : ''}`; const rawDefault = colRow.column_default ?? undefined; + // `GENERATED ALWAYS AS IDENTITY` ('a') and `GENERATED BY DEFAULT AS + // IDENTITY` ('d') both report a NULL column_default — Postgres tracks + // generation via attidentity, not a default expression — so neither + // variant is visible in `rawDefault` at all. The contract has no + // syntax to distinguish the two, so both resolve directly to the same + // `autoincrement()` a `serial` column's `nextval(...)` default + // already maps to. This is the only place identity is recognized — + // there is no `SqlColumnIR.identity` field; every consumer compares + // `resolvedDefault` instead. + const isIdentityColumn = colRow.attidentity === 'a' || colRow.attidentity === 'd'; + const resolvedDefault: ColumnDefault | undefined = isIdentityColumn + ? { kind: 'function', expression: 'autoincrement()' } + : rawDefault !== undefined + ? parsePostgresDefault(rawDefault, resolvedNativeType) + : undefined; columns[colRow.column_name] = { name: colRow.column_name, nativeType, @@ -1014,12 +1035,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { ...ifDefined('default', rawDefault), ...ifDefined('many', many), resolvedNativeType, - ...ifDefined( - 'resolvedDefault', - rawDefault !== undefined - ? parsePostgresDefault(rawDefault, resolvedNativeType) - : undefined, - ), + ...ifDefined('resolvedDefault', resolvedDefault), }; } diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts index 47bcb62986..fabbb9f4f7 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts @@ -163,6 +163,14 @@ const postgresScalarTypeDescriptors = new Map([ ['DateTime', 'pg/timestamptz@1'], ['Json', 'pg/jsonb@1'], ['Bytes', 'pg/bytea@1'], + // Keyed by the full `@db.*` attribute name, not a PSL base type — every + // other entry above answers "what's the codec for a bare `DateTime` + // field", but `@db.Date`'s own codec (`pg/date@1`) is deliberately + // different from its `DateTime` base's (`pg/timestamptz@1`), so it can't + // reuse that lookup. contract-psl's `resolveDbNativeTypeAttribute` + // consults this same map by attribute name for a `noArgs` spec whose + // `codecId` is `null`, keeping the concrete `pg/date@1` id out of 2-sql. + ['db.Date', 'pg/date@1'], ]); export function createPostgresDefaultFunctionRegistry(): ReadonlyMap< diff --git a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts index eaf186b8a5..7618b15eeb 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts @@ -146,6 +146,56 @@ describe('createPostgresDefaultFunctionRegistry', () => { expect(result).toMatchObject({ ok: false }); }); + describe('dbgenerated keeps the raw expression verbatim, never resolving it', () => { + // `lowerDbgenerated` no longer resolves the raw SQL text at all — a + // literal-shaped expression (e.g. `'{}'::jsonb`) is recognized and + // normalized once, at SchemaIR construction on the expected side (see + // `contractToSchemaIR`'s target-supplied `resolveDefault` hook), not + // here. Adopting the normalizer's rewrites here would also discard the + // user's original expression for cases like `nextval('my_seq')`, whose + // DDL must keep referencing the named sequence. + const handler = createPostgresDefaultFunctionRegistry().get('dbgenerated')!; + + function lower(expression: string) { + return handler.lower({ + call: makeCall('dbgenerated', { expression }), + context: stubContext, + }); + } + + it('keeps a jsonb literal expression as a function, unresolved', () => { + const expression = "'{}'::jsonb"; + expect(lower(expression)).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'function', expression } }, + }); + }); + + it('keeps a text[] literal expression as a function, unresolved', () => { + const expression = "'{}'::text[]"; + expect(lower(expression)).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'function', expression } }, + }); + }); + + it('keeps gen_random_uuid() a function', () => { + const expression = 'gen_random_uuid()'; + expect(lower(expression)).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'function', expression } }, + }); + }); + + it("keeps nextval(...) a function, unchanged (doesn't adopt the normalizer's autoincrement() rewrite)", () => { + const expression = "nextval('seq'::regclass)"; + expect(lower(expression)).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'function', expression } }, + }); + }); + }); + it('lowers uuid(4) explicitly to uuidv4 execution generator', () => { const handler = registry.get('uuid')!; const result = handler.lower({ diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/identity-column-introspection.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/identity-column-introspection.integration.test.ts new file mode 100644 index 0000000000..198fda91ec --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/identity-column-introspection.integration.test.ts @@ -0,0 +1,103 @@ +/** + * Integration test: introspection of `GENERATED ... AS IDENTITY` columns. + * + * An identity column reports no `column_default` at all — Postgres tracks + * generation via `pg_attribute.attidentity`, not a default expression. The + * columns query selects `attidentity` and, when it reports either identity + * variant, the control adapter stamps `resolvedDefault` straight to + * `autoincrement()` — the same value a contract's `@default(autoincrement())` + * resolves to. Without this, `db verify` would see the contract-derived side + * declare a default the introspected side never reports, and flag every + * identity column drifted forever (TML-3037). + */ +import { PostgresDatabaseSchemaNode } from '@prisma-next/target-postgres/types'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + createDriver, + createTestDatabase, + familyInstance, + type PostgresControlDriver, + resetDatabase, + testTimeout, +} from './fixtures/runner-fixtures'; + +describe.sequential('identity column introspection', () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, testTimeout); + + afterAll(async () => { + if (database) await database.close(); + }, testTimeout); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + }, testTimeout); + + afterEach(async () => { + if (driver) { + await driver.close(); + driver = undefined; + } + }, testTimeout); + + it('GENERATED ALWAYS AS IDENTITY -> no raw default, resolvedDefault:autoincrement()', { + timeout: testTimeout, + }, async () => { + await driver!.query( + 'CREATE TABLE identity_test (id int4 GENERATED ALWAYS AS IDENTITY PRIMARY KEY)', + ); + + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['identity_test']?.columns['id']; + expect(col?.default).toBeUndefined(); + expect(col).toMatchObject({ + resolvedDefault: { kind: 'function', expression: 'autoincrement()' }, + }); + }); + + it('GENERATED BY DEFAULT AS IDENTITY -> no raw default, resolvedDefault:autoincrement()', { + timeout: testTimeout, + }, async () => { + await driver!.query( + 'CREATE TABLE identity_test (id int4 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY)', + ); + + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['identity_test']?.columns['id']; + expect(col?.default).toBeUndefined(); + expect(col).toMatchObject({ + resolvedDefault: { kind: 'function', expression: 'autoincrement()' }, + }); + }); + + it('serial column (control) -> resolvedDefault:autoincrement() via nextval()', { + timeout: testTimeout, + }, async () => { + await driver!.query('CREATE TABLE identity_test (id serial PRIMARY KEY)'); + + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['identity_test']?.columns['id']; + expect(col?.default).toMatch(/^nextval\(/); + expect(col?.resolvedDefault).toEqual({ kind: 'function', expression: 'autoincrement()' }); + }); + + it('plain int column (control) -> no default at all', { + timeout: testTimeout, + }, async () => { + await driver!.query('CREATE TABLE identity_test (id int4 PRIMARY KEY, note int4)'); + + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['identity_test']?.columns['note']; + expect(col?.default).toBeUndefined(); + expect(col?.resolvedDefault).toBeUndefined(); + }); +}); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts index 99b9ba0394..20b232b0ea 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts @@ -195,4 +195,37 @@ describe('renderDefaultLiteral', () => { const result = renderDefaultLiteral({ key: 'val' }); expect(result).toBe(`'{"key":"val"}'`); }); + + it('renders an empty array literal for a list column', () => { + const result = renderDefaultLiteral([], col({ nativeType: 'text', many: true })); + expect(result).toBe("'{}'"); + }); + + it('renders a populated array literal for a list column', () => { + const result = renderDefaultLiteral(['a', 'b'], col({ nativeType: 'text', many: true })); + expect(result).toBe(`ARRAY['a', 'b']`); + }); + + it('renders a mixed-type array literal element-by-element', () => { + const result = renderDefaultLiteral([1, true, null], col({ nativeType: 'int4', many: true })); + expect(result).toBe('ARRAY[1, true, NULL]'); + }); +}); + +describe('buildColumnDefaultSql with a list column', () => { + it('renders DEFAULT with an empty array literal', () => { + const result = buildColumnDefaultSql( + { kind: 'literal', value: [] }, + col({ nativeType: 'text', many: true }), + ); + expect(result).toBe("DEFAULT '{}'"); + }); + + it('renders DEFAULT with a populated array literal', () => { + const result = buildColumnDefaultSql( + { kind: 'literal', value: ['a', 'b'] }, + col({ nativeType: 'text', many: true }), + ); + expect(result).toBe(`DEFAULT ARRAY['a', 'b']`); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8d2708541..b15e0b03fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,7 +117,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) apps/lsp-playground: dependencies: @@ -284,7 +284,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/bundle-size: dependencies: @@ -372,7 +372,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) wrangler: specifier: 4.91.0 version: 4.91.0(@cloudflare/workers-types@4.20260515.1) @@ -427,7 +427,7 @@ importers: version: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/mongo-demo: dependencies: @@ -521,7 +521,7 @@ importers: version: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/multi-extension-monorepo: dependencies: @@ -573,7 +573,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/paradedb-demo: dependencies: @@ -655,7 +655,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/prisma-next-cloudflare-worker: dependencies: @@ -731,7 +731,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) wrangler: specifier: 4.91.0 version: 4.91.0(@cloudflare/workers-types@4.20260515.1) @@ -867,7 +867,7 @@ importers: version: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/prisma-next-demo-sqlite: dependencies: @@ -1046,7 +1046,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/react-router-demo: dependencies: @@ -1152,7 +1152,7 @@ importers: version: 6.1.1(typescript@5.9.3)(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/retail-store: dependencies: @@ -1282,7 +1282,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/supabase: dependencies: @@ -1355,7 +1355,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/0-config/tsconfig: {} @@ -1403,7 +1403,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/0-foundation/utils: devDependencies: @@ -1421,7 +1421,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/config: dependencies: @@ -1455,7 +1455,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/errors: dependencies: @@ -1480,7 +1480,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/framework-components: dependencies: @@ -1517,7 +1517,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/operations: devDependencies: @@ -1538,7 +1538,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/ts-render: devDependencies: @@ -1556,7 +1556,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/2-authoring/contract: dependencies: @@ -1581,7 +1581,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/2-authoring/ids: dependencies: @@ -1612,7 +1612,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/2-authoring/psl-parser: dependencies: @@ -1643,7 +1643,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/2-authoring/psl-printer: dependencies: @@ -1677,7 +1677,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/cli: dependencies: @@ -1795,7 +1795,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/cli-telemetry: dependencies: @@ -1838,7 +1838,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/config-loader: dependencies: @@ -1881,7 +1881,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/emitter: dependencies: @@ -1927,7 +1927,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/language-server: dependencies: @@ -1979,7 +1979,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/migration: dependencies: @@ -2019,7 +2019,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/prisma-next: dependencies: @@ -2144,7 +2144,7 @@ importers: version: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/1-foundation/mongo-codec: dependencies: @@ -2175,7 +2175,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/1-foundation/mongo-contract: dependencies: @@ -2215,7 +2215,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/1-foundation/mongo-value: dependencies: @@ -2237,7 +2237,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/2-authoring/contract-psl: dependencies: @@ -2289,7 +2289,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/2-authoring/contract-ts: dependencies: @@ -2335,7 +2335,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/3-tooling/emitter: dependencies: @@ -2372,7 +2372,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/3-tooling/mongo-schema-ir: dependencies: @@ -2406,7 +2406,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/4-query/query-ast: dependencies: @@ -2443,7 +2443,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/5-query-builders/orm: dependencies: @@ -2504,7 +2504,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/5-query-builders/query-builder: dependencies: @@ -2538,7 +2538,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/6-transport/mongo-lowering: dependencies: @@ -2569,7 +2569,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/6-transport/mongo-wire: dependencies: @@ -2597,7 +2597,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/7-runtime: dependencies: @@ -2667,7 +2667,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/9-family: dependencies: @@ -2734,7 +2734,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/1-core/contract: dependencies: @@ -2771,7 +2771,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/1-core/errors: devDependencies: @@ -2792,7 +2792,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/1-core/operations: dependencies: @@ -2826,7 +2826,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/1-core/schema-ir: dependencies: @@ -2857,7 +2857,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/2-authoring/contract-psl: dependencies: @@ -2909,7 +2909,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/2-authoring/contract-ts: dependencies: @@ -2964,7 +2964,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/3-tooling/emitter: dependencies: @@ -3001,7 +3001,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/4-lanes/query-builder: devDependencies: @@ -3028,7 +3028,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/4-lanes/relational-core: dependencies: @@ -3080,7 +3080,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/4-lanes/sql-builder: dependencies: @@ -3132,7 +3132,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/5-runtime: dependencies: @@ -3187,7 +3187,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/9-family: dependencies: @@ -3233,6 +3233,9 @@ importers: arktype: specifier: ^2.2.2 version: 2.2.3 + pluralize: + specifier: ^8.0.0 + version: 8.0.0 devDependencies: '@prisma-next/driver-postgres': specifier: workspace:0.15.0 @@ -3255,6 +3258,9 @@ importers: '@prisma-next/tsdown': specifier: workspace:0.15.0 version: link:../../0-config/tsdown + '@types/pluralize': + specifier: ^0.0.33 + version: 0.0.33 tsdown: specifier: 'catalog:' version: 0.22.3(tsx@4.22.5)(typescript@5.9.3) @@ -3263,7 +3269,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/arktype-json: dependencies: @@ -3312,7 +3318,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/middleware-cache: dependencies: @@ -3337,7 +3343,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/mongo: dependencies: @@ -3419,7 +3425,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/paradedb: dependencies: @@ -3486,7 +3492,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/pgvector: dependencies: @@ -3562,7 +3568,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/postgis: dependencies: @@ -3638,7 +3644,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/postgres: dependencies: @@ -3726,7 +3732,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/sql-orm-client: dependencies: @@ -3802,7 +3808,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/sqlite: dependencies: @@ -3875,7 +3881,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/supabase: dependencies: @@ -3984,7 +3990,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-mongo-target/1-mongo-target: dependencies: @@ -4075,7 +4081,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-mongo-target/2-mongo-adapter: dependencies: @@ -4166,7 +4172,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-mongo-target/3-mongo-driver: dependencies: @@ -4209,7 +4215,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/3-targets/postgres: dependencies: @@ -4288,7 +4294,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/3-targets/sqlite: dependencies: @@ -4355,7 +4361,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/6-adapters/postgres: dependencies: @@ -4440,7 +4446,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/6-adapters/sqlite: dependencies: @@ -4525,7 +4531,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/7-drivers/postgres: dependencies: @@ -4589,7 +4595,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/7-drivers/sqlite: dependencies: @@ -4635,7 +4641,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) test/e2e/framework: dependencies: @@ -4741,7 +4747,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) test/integration: dependencies: @@ -4940,7 +4946,7 @@ importers: version: vite@8.0.9(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) test/integration/test/fixtures/cli/cli-e2e-test-app: dependencies: @@ -5115,7 +5121,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages: @@ -7918,6 +7924,9 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/pluralize@0.0.33': + resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -9743,6 +9752,10 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -13209,6 +13222,8 @@ snapshots: pg-protocol: 1.15.0 pg-types: 2.2.0 + '@types/pluralize@0.0.33': {} + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 @@ -13252,7 +13267,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + vitest: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) '@vitest/expect@4.1.10': dependencies: @@ -15105,6 +15120,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pluralize@8.0.0: {} + postcss@8.4.31: dependencies: nanoid: 3.3.15 @@ -16126,36 +16143,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)): - dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.3 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.9.4 - '@vitest/coverage-v8': 4.1.6(vitest@4.1.10) - jsdom: 29.1.1(@noble/hashes@2.2.0) - transitivePeerDependencies: - - msw - - vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)): + vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) diff --git a/projects/infer-emit-roundtrip/plan.md b/projects/infer-emit-roundtrip/plan.md new file mode 100644 index 0000000000..1a23590a18 --- /dev/null +++ b/projects/infer-emit-roundtrip/plan.md @@ -0,0 +1,109 @@ +# Slice plan — TML-3037 + +Spec: [`spec.md`](./spec.md). One PR on `tml-3037-contract-infer-output-round-trips-through-contract-emit`. + +## Sequencing + +D1 builds the instrument and must land first — it's what converts eight code-readings into eight +reproductions. Everything between D2 and D8 is a fix whose acceptance is "the instrument stops +failing for this defect." D9 removes the workarounds and runs the full gate set. + +Dispatches are **serialized**, not parallel: they share one git index, and a concurrent +`git restore --staged` from one implementer can clear another's staging. Per +`drive/calibration/model-tier.md` and the operator's standing preference, implementers run on +Sonnet; the review pass runs on Opus. + +| # | Outcome | Surface | Tier | +|---|---|---|---| +| D1 | The instrument exists and **fails**, reproducing each defect with its real error | `test/integration/test/cli-journeys/` | Sonnet | +| D2 | `pluralize` is correct for plural *and* singular-`s` inputs | `packages/2-sql/9-family` | Sonnet | +| D3 | The interpreter accepts the 1:1 back-relation and list storage defaults infer prints | `packages/2-sql/2-authoring/contract-psl` | Sonnet | +| D4 | The postgres codec set covers unbounded `numeric` and `date` | `packages/3-targets/3-targets/postgres` | Sonnet | +| D5 | Postgres registers its built-in index types; dangling FK drops are visible | `packages/3-targets/3-targets/postgres` | Sonnet | +| D6 | Identity columns round-trip as `autoincrement()` on both sides | `2-sql/1-core/schema-ir` + postgres adapter | Sonnet | +| D7 | Authoring and introspection agree on a JSON literal default | `packages/3-targets/3-targets/postgres` | Sonnet | +| D8 | The pack's workarounds are gone and its contract regenerates unchanged | `packages/3-extensions/supabase` | Sonnet | +| D9 | Full gate set green; upgrade instructions recorded | repo-wide | Sonnet | + +## The rule that governs every dispatch + +**Reproduce before you fix.** D1 produces a written record of which defect throws which error. +A fix dispatch that cannot make its defect fail first has not found the defect — it must stop and +report, not fix on the strength of a code reading. This is the operator's explicit bar and it is +why D1 is not merged into D2. + +Threaded failure modes (from `drive/calibration/failure-modes.md`): + +- **F15** — a behavioural AC verified by code-reading instead of a populated fixture. The whole + slice exists because eight defects passed code review; do not repeat the method that missed them. +- **F13** — a regression test that doesn't discriminate. Each assertion must fail if its defect is + reintroduced. A journey that passes for the wrong reason is worse than no journey. +- **F5** — destructive git operations are forbidden without orchestrator approval. No + `git stash`, no `git restore --staged`, no force-push, no branch deletion. Non-negotiable. +- **F14** — dispatch gates must mirror CI. `pnpm typecheck` + vitest pass with unused imports on + disk; only `pnpm lint` catches them. +- **F24** — a stale `dist` makes a red gate look like a broken base. Rebuild the touched package + before believing a downstream red. +- **F25** — "pre-existing failure on main" is false by policy here. Treat a red as this slice's + regression. + +## Dispatch boundaries + +**D1 — the instrument.** A journey with a fixture schema carrying: already-plural table names; a +1:1 FK on a unique column; a 1:N FK; `GENERATED ALWAYS AS IDENTITY`; `GENERATED BY DEFAULT AS +IDENTITY`; a `serial`; an unbounded `numeric`; a `numeric(10,2)`; a `date`; `text[]` with +`DEFAULT '{}'::text[]`; `jsonb` with `DEFAULT '{}'::jsonb`; a GIN index; a `USING hash` index; an +FK pointing out of scope. Driven infer → emit → `db verify --schema-only`. + +Its deliverable is **the failure record**, not a green test: a table of defect → command → verbatim +error. Expect it to fail. If a defect from the spec does *not* reproduce, say so plainly — that +finding outranks the spec, and it changes what we tell the reporter. + +Plus a runtime integration test that builds an `ExecutionContext` and reads a `date` through +`.include()` — the CLI path cannot observe the numeric-connect or date-decode defects. + +**D2 — pluralize.** Acceptance table is in the spec and comes from TML-3024. Prefer a small +maintained inflection library; the curated-set fallback is pre-authorized if `lint:deps` objects. +Don't stall on the choice. + +**D3 — interpreter.** Two shapes infer legitimately prints. For the 1:1 back side, lower to +`cardinality: '1:1'` — the contract already has it. Do not make infer emit lists instead; that +discards real information. For list defaults, reject only genuine `executionDefaults.onCreate`; +permit a storage-level function default. + +**D4 — codecs.** `precision` optional on `NumericParams`, matching sibling `PrecisionParams`. Do +not loosen `assertColumnCodecIntegrity` — it is working as designed. **Test the base scalar**: a +bare `amount Decimal` field, not `@db.Numeric()` — D1 proved the crash arrives via +`['Decimal', 'pg/numeric@1']` on the base-scalar path, so an attribute-only test passes while the +defect ships. See the spec's corrected defect 5. Then `pg/date@1`, with `@db.Date` pointing at it; +`mongo/date@1` is the shape precedent. Breaking: needs an upgrade entry (D9 records it, this +dispatch flags it). + +**D5 — index types + dangling FK.** The target registers `btree`, `hash`, `gin`, `gist`, +`spgist`, `brin`. Permissive options schemas; per-method validation is out of scope. Then the +dangling-FK comment, matching the missing-PK comment precedent already in the file. + +**D6 — identity.** The largest and the one most likely to grow. Symmetric fix: `SqlColumnIR` +gains identity, the query selects `attidentity`, infer emits `@default(autoincrement())`, and the +normalizer resolves a live identity column to `autoincrement()` so verify compares equal. +**Stop condition:** if verify still drifts after the symmetric fix, stop and report — do not chase +DDL fidelity inside the loop. That's a re-spec, per the spec's pinned boundary. + +**D7 — jsonb defaults.** The authoring side normalizes to match introspection, reusing +`parsePostgresDefault` rather than copying it. `dbgenerated("gen_random_uuid()")` must stay a +function — the discriminating test for that goes in the same dispatch. Fixtures will move; read +the diff, don't blanket-regenerate. + +**D8 — delete the workarounds.** `DOUBLE_PLURALIZED_FIELD_NAMES` and `INDEX_OMISSIONS` gone; +`DEFAULT_OMISSIONS` reduced to what's genuinely unrepresentable with the reason stated. Regenerate +the pack contract via `pnpm --filter @prisma-next/extension-supabase run contract:generate` and +read the diff. A workaround that survives a landed fix means the fix didn't work — report that +rather than keeping the workaround. + +**D9 — close.** Upgrade instructions for `pg/date@1`. Full gate set per the spec's DoD. + +## Not in the dispatch loop + +Design is settled in the spec. If a dispatch wants to renegotiate identity-as-`autoincrement()`, +or the target-registers-its-own-index-types call, it stops and reports — those are re-specs, not +in-loop decisions. diff --git a/projects/infer-emit-roundtrip/reproduction.md b/projects/infer-emit-roundtrip/reproduction.md new file mode 100644 index 0000000000..dd691bc162 --- /dev/null +++ b/projects/infer-emit-roundtrip/reproduction.md @@ -0,0 +1,351 @@ +# Reproduction record — TML-3037 dispatch D1 + +Every claim below comes from a run executed against the instrument on this branch. Nothing here is +inferred from reading code. + +**Instruments:** + +- `test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts` — 8 `it()`s (IF.01–IF.08), + run with `pnpm --filter @prisma-next/integration-tests test:journeys test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts`. + Result: **8 failed (8)**. +- `test/integration/test/infer-roundtrip-runtime.integration.test.ts` — 2 `it()`s (RT.01–RT.02), + run with `pnpm --filter @prisma-next/integration-tests test test/infer-roundtrip-runtime.integration.test.ts`. + Result: **2 failed (2)**. + +Both files assert the **fixed** behaviour, so every one of the 10 failures is a live reproduction. +All 8+2 fail today; each should flip to green as its fix lands. + +## The inferred PSL the whole record is read from + +This is the verbatim `contract.prisma` `contract infer` produces from the fixture schema. Seven of +the nine findings are visible in it directly. + +```prisma +// use prisma-next +// Contract inferred from the live database schema. Edit as needed, then run `prisma-next contract emit`. + +types { + BirthDate = DateTime @db.Date + PreciseBalance = Decimal @db.Numeric(10, 2) +} + +model Users { + id Int @id(map: "users_pkey") + email String + balance Decimal? + preciseBalance PreciseBalance? @map("precise_balance") + birthDate BirthDate? @map("birth_date") + tags String[] @default(dbgenerated("'{}'::text[]")) + metadata Json @default(dbgenerated("'{}'::jsonb")) + identities Identities? + sessionses Sessions[] + + @@index([metadata], map: "users_metadata_gin_idx", type: "gin") + @@map("users") +} + +model Identities { + id Int @id(map: "identities_pkey") @default(autoincrement()) + userId Int @unique(map: "identities_user_id_key") @map("user_id") + provider String + user Users @relation(fields: [userId], references: [id], map: "identities_user_id_fkey") + + @@index([provider], map: "identities_provider_hash_idx", type: "hash") + @@map("identities") +} + +model Sessions { + id Int @id(map: "sessions_pkey") + userId Int @map("user_id") + ownerRef Int? @map("owner_ref") + user Users @relation(fields: [userId], references: [id], map: "sessions_user_id_fkey", index: false) + + @@map("sessions") +} +``` + +## The record + +| # | Defect (spec §) | Reproduces? | Surfaced by | Test | +|---|---|---|---|---| +| 1 | Back-relation names double-pluralize | Yes | `contract infer` output | IF.01 | +| 2 | Infer prints a 1:1 back-relation emit can't parse | Yes | `contract emit` | IF.02 | +| 3 | Identity columns lose their default | Yes | `contract infer` output | IF.06 | +| 4 | Non-btree indexes can never emit | Yes — **both** `gin` and `hash` | `contract emit` | IF.04 | +| 5 | Unbounded `numeric` crashes at connect | Yes — **but not by the mechanism the spec names** | `createExecutionContext` | RT.01 | +| 6 | No `pg/date` codec exists | Yes | `.include()` at runtime | RT.02 | +| 7 | Array columns can't keep their default | Yes | `contract emit` | IF.03 | +| 8 | jsonb defaults report drift forever | Yes | `db verify --schema-only` | IF.07 | +| — | Dangling FKs drop silently | Yes | `contract infer` output | IF.05 | +| — | (whole-slice outcome) | Round trip fails at emit | `contract emit` | IF.08 | + +--- + +### 1. Back-relation names double-pluralize — REPRODUCES + +**Command:** `contract infer` (exit 0). The defect is in its output, not its exit code. + +**Verbatim** — the `Users` field pointing at the already-plural `sessions` table: + +``` + sessionses Sessions[] +``` + +`IF.01` asserts `/\bsessions\s+Sessions\[\]/` and fails. + +**Caveat on the fixture, worth knowing for D2's acceptance:** only the **to-many** side pluralizes. +My `identities` table has a UNIQUE FK, so it lands on the 1:1 side and prints `identities Identities?` +— `pluralize()` is never called for it. The spec's §"The instrument" list and defect-1 acceptance table +name `identities` as a double-plural case, and `generate-contract.ts`'s `DOUBLE_PLURALIZED_FIELD_NAMES` +carries `identitieses`; both are right about real Supabase (where `auth.identities` is 1:N from +`auth.users`) but do not describe this fixture. `sessionses` is the reproduction here. This is not a +spec error — just don't expect `identitieses` from this instrument. + +### 2. Infer prints a 1:1 back-relation emit can't parse — REPRODUCES + +**Command:** `contract emit`, exit **1**. (IF.02 first repairs the two unrelated emit-blockers — the +list default and the non-btree index types — so this error stands alone.) + +**Verbatim:** + +``` +■ ✖ Failed to resolve contract source (PN-RUN-3000) +│ Why: PSL to SQL contract interpretation failed +│ Fix: Fix contract source diagnostics and return ok(Contract). +│ Issues (showing 1 of 1): +│ - [PSL_UNSUPPORTED_FIELD_TYPE] Field "Users.identities" type "Identities" is not supported in SQL PSL provider v1 (./contract.prisma:17:3) +``` + +Exactly as the spec describes: the uniqueness detection is right (`identities Identities?` is +correctly the 1:1 back side), and the field falls through to scalar resolution because the interpreter +only collects back-relation candidates `if (field.list)`. + +### 3. Identity columns lose their default — REPRODUCES (both variants); `serial` is unaffected + +**Command:** `contract infer` (exit 0). Visible in its output. + +**Verbatim** — `users.id` is `GENERATED ALWAYS AS IDENTITY`, `sessions.id` is `GENERATED BY DEFAULT AS IDENTITY`: + +``` +model Users { + id Int @id(map: "users_pkey") +... +model Sessions { + id Int @id(map: "sessions_pkey") +``` + +Neither carries `@default(autoincrement())`. The `serial` control column does, confirming the spec's +account that `serial` works only because it sets a real `nextval(...)` default: + +``` +model Identities { + id Int @id(map: "identities_pkey") @default(autoincrement()) +``` + +IF.06 asserts all three and fails on the two identity columns while the `serial` assertion passes — +so it discriminates, rather than passing or failing wholesale. + +### 4. Non-btree indexes can never emit — REPRODUCES for `gin` **and** `hash` + +**Command:** `contract emit`, exit **1**. + +**Verbatim** (`gin`, the first index validated): + +``` +■ ✖ Failed to resolve contract source (PN-RUN-3000) +│ Why: Namespace "public" table "users" index on columns [metadata] uses unregistered index type "gin" +│ Fix: Ensure contract.source.load resolves to ok(Contract) or returns structured diagnostics. +``` + +Validation stops at the first offender, so I confirmed `hash` separately by removing only the `gin` +argument and re-emitting: + +``` +│ Why: Namespace "public" table "identities" index on columns [provider] uses unregistered index type "hash" +``` + +Both reproduce. That temporary probe was removed; IF.04 covers the pair through the `gin` error. + +### 5. Unbounded `numeric` crashes at connect — REPRODUCES, but **not via `@db.Numeric`** + +**Command:** `contract infer` → `contract emit` (both exit **0**), then `createExecutionContext`. + +**Verbatim:** + +``` +RuntimeError { + "message": "Column 'amount_probe.amount' uses parameterized codec 'pg/numeric@1' but no typeParams are supplied. Provide typeParams on the column, or use a typeRef pointing at a storage.types entry that carries them.", + "code": "RUNTIME.CODEC_PARAMETERIZATION_MISMATCH", + "category": "RUNTIME", + "severity": "error", + "details": { + "actual": "no typeParams", + "codecId": "pg/numeric@1", + "column": "amount", + "expected": "parameterized", + "table": "amount_probe", + }, +} +``` + +**Where the spec is imprecise — D4 should read this before starting.** The spec frames this as an +attribute problem ("`@db.Numeric` with no args"), but infer never prints `@db.Numeric` for an +unbounded `numeric` column. It prints a bare `Decimal`: + +``` + balance Decimal? +``` + +and only the *bounded* `numeric(10,2)` gets an attribute, via a named type: + +``` +types { + PreciseBalance = Decimal @db.Numeric(10, 2) +} +``` + +So the crash arrives through the **base-scalar** path — `postgresScalarTypeDescriptors` maps +`'Decimal' → 'pg/numeric@1'` (`packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts`), +producing a `pg/numeric@1` ref with no `typeParams` at all. Every `Decimal` field on postgres reaches +this, `@db.Numeric` or not. + +This does not change the chosen fix — making `precision` optional on `NumericParams` / +`numericParamsSchema` still resolves it, and the "not infer-specific, needs an authoring-surface test" +note still holds. It changes the **blast radius**: the affected surface is bare `Decimal`, which is +wider than the spec's framing suggests. D4's authoring-surface test should cover bare `Decimal`, not +just `@db.Numeric` with no args. + +### 6. No `pg/date` codec exists — REPRODUCES + +**Command:** `contract infer` → `contract emit` (both exit 0), then a real `ExecutionContext` + +`PostgresRuntimeImpl`; top-level `.select()` first, then `.include()`. + +Top-level `select('id', 'notedOn')` succeeds and returns a `Date` — matching the spec's account that +`decode()` is a passthrough over an already-parsed value. `.include()` on the same column throws: + +``` +RuntimeError { + "message": "Failed to decode column record.noted_on with codec 'pg/timestamptz@1': Invalid ISO date string for pg/timestamptz@1: 2024-01-15", + "cause": Error { + "message": "Invalid ISO date string for pg/timestamptz@1: 2024-01-15", + }, + "code": "RUNTIME.DECODE_FAILED", + "category": "RUNTIME", + "severity": "error", + "details": { + "codec": "pg/timestamptz@1", + "column": "noted_on", + "table": "record", + }, +} +``` + +The error names `pg/timestamptz@1` on a `date` column, confirming the spec's mechanism exactly: +`@db.Date` carries `codecId: null` ("inherit from base"), `DateTime` on postgres is `pg/timestamptz@1`, +and `decodeJson`'s ISO-timestamp regex rejects a bare `YYYY-MM-DD`. + +**One note for D4 on the assertion, not on the defect:** the top-level `select()` half asserts +`toBeInstanceOf(Date)`, not an exact instant. The driver builds a `date` column's `Date` at *local* +midnight, so the instant is environment-timezone-dependent (it came back `2024-01-14T23:00:00.000Z` +on this machine, in CEST). Once `pg/date@1` lands and owns the conversion, that assertion can and +should tighten to the exact value. + +### 7. Array columns can't keep their default — REPRODUCES + +**Command:** `contract emit`, exit **1**. + +**Verbatim:** + +``` +■ ✖ Failed to resolve contract source (PN-RUN-3000) +│ Why: PSL to SQL contract interpretation failed +│ Fix: Fix contract source diagnostics and return ok(Contract). +│ Issues (showing 1 of 1): +│ - [PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED] Field "Users.tags" is a list and cannot use an execution default ("dbgenerated("'{}'::text[]")"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default. (./contract.prisma:15:34) +``` + +### 8. jsonb defaults report drift forever — REPRODUCES + +**Command:** `contract infer` → (repair the three unrelated emit-blockers) → `contract emit` (exit 0) +→ `db verify --schema-only`, exit **1**. The database is the one the contract was inferred *from*, +and nothing changed in between. + +**Verbatim:** + +``` +│ Schema issues: +│ ✖ mismatch: database/public/users/column:metadata/default +│ +│ ✖ Database schema does not satisfy contract (1 failure) (PN-SCHEMA-0001) +``` + +**A reduction artifact D7 should know about.** My first cut of IF.07 repaired the non-btree indexes by +stripping only the `type:` argument. That produced two *extra* verify mismatches that are not among the +eight: + +``` +│ ✖ mismatch: database/public/identities/index:provider +│ ✖ mismatch: database/public/users/index:metadata +``` + +Those are declared-btree-vs-live-gin/hash — an artifact of the repair, not a defect. IF.07 now drops +the two `@@index` attributes entirely, so they become undeclared "extras" that non-strict schema-only +verify tolerates, and the jsonb mismatch stands alone. Worth remembering if a later dispatch reduces +this PSL for its own purposes. + +### Dangling FKs drop silently — REPRODUCES + +**Command:** `contract infer` (exit 0). + +`sessions.owner_ref` references `secure.owners`, outside the introspected `public` schema. The scalar +column survives, as the spec says it should: + +``` +model Sessions { + id Int @id(map: "sessions_pkey") + userId Int @map("user_id") + ownerRef Int? @map("owner_ref") + user Users @relation(fields: [userId], references: [id], map: "sessions_user_id_fkey", index: false) + + @@map("sessions") +} +``` + +There is **no comment** anywhere on or above `model Sessions` explaining the dropped relation. IF.05 +asserts both halves — the surviving column (passes) and the comment (fails) — so it discriminates. + +The precedent the spec points at is real and works: `infer-psl-contract.ts` emits +`// WARNING: This table has no primary key in the database` above a PK-less model, and the printer +renders `model.comment` immediately above the `model` line +(`packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts`). + +### Whole-slice outcome — the round trip fails at emit + +**Command:** `contract infer` (exit 0) → `contract emit` on the **unmodified** inferred PSL, exit **1**. + +**Verbatim:** + +``` +■ ✖ Failed to resolve contract source (PN-RUN-3000) +│ Why: PSL to SQL contract interpretation failed +│ Fix: Fix contract source diagnostics and return ok(Contract). +│ Issues (showing 2 of 2): +│ - [PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED] Field "Users.tags" is a list and cannot use an execution default ("dbgenerated("'{}'::text[]")"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default. (./contract.prisma:15:34) +│ - [PSL_UNSUPPORTED_FIELD_TYPE] Field "Users.identities" type "Identities" is not supported in SQL PSL provider v1 (./contract.prisma:17:3) +``` + +`db verify --schema-only` is never reached. IF.08 is the slice's headline acceptance: it goes green +only when every fix has landed. + +## Not a defect: an install-state trap + +The runtime test first failed with: + +``` +Error: Cannot find package '@prisma-next/sql-schema-ir/naming' imported from .../packages/2-sql/1-core/contract/dist/foreign-key-materialization.mjs +``` + +This is the `workspace-package-not-found-run-pnpm-install` rule's case, not a code bug: the +`@prisma-next/sql-schema-ir` symlink was missing from `packages/2-sql/1-core/contract/node_modules/@prisma-next/`. +`pnpm install` fixed it and left `pnpm-lock.yaml` unchanged. If a later dispatch sees this, install — +don't debug it. diff --git a/projects/infer-emit-roundtrip/spec.md b/projects/infer-emit-roundtrip/spec.md new file mode 100644 index 0000000000..400801c9ee --- /dev/null +++ b/projects/infer-emit-roundtrip/spec.md @@ -0,0 +1,360 @@ +# Slice spec — `contract infer` output round-trips through `contract emit` + +**Linear:** [TML-3037](https://linear.app/prisma-company/issue/TML-3037/contract-infer-output-round-trips-through-contract-emit) · closes [TML-3024](https://linear.app/prisma-company/issue/TML-3024/contract-infer-correct-back-relation-field-pluralization-dont-double) +**Branch:** `tml-3037-contract-infer-output-round-trips-through-contract-emit` (parent: `main`) +**Shape:** orphan slice, one PR. + +## Outcome + +`contract infer` run against a realistic Postgres database produces a `contract.prisma` that +emits cleanly and verifies clean against the database it was read from — with no hand-editing +and no post-processing script. + +That property is the whole slice. Eight defects currently break it; each is an instance of the +same class, and one instrument proves all of them. + +## Why this is one slice, not eight + +Every defect below is the same failure: infer writes PSL that emit rejects, or that verify then +reports as drift. They share one instrument (the round-trip journey), one acceptance bar, and one +piece of evidence that they're worth fixing at all. Split across eight PRs, each reviewer sees a +one-line change with no way to judge whether it's the right one-line change. Together, the diff +tells a single story and the pack script shrinking is the visible proof. + +## Why now + +An external user sent in a 260-line `fix-inferred-contract.ts` that post-processes every +`contract infer` run before their contract will emit. Auditing it claim-by-claim produced the +eight defects below. + +The finding that motivates the slice is not in their script. It's that +`packages/3-extensions/supabase/scripts/generate-contract.ts` — ours — is independently the same +script. Same double-plural rename table, same default omissions, same index omissions, each with +a comment explaining which part of our own pipeline rejects our own output. We shipped the +workaround and left the defect, and the next user through the door paid for it. + +**No test anywhere does introspect → infer → emit.** That's why all eight shipped. + +Audit board: https://claude.ai/code/artifact/fa624452-ab58-4450-8888-600b9f681a53 + +## The instrument + +The harness already exists. `test/integration/test/cli-journeys/contract-infer-workflow.e2e.test.ts` +already runs infer → emit → verify, using `runContractInfer` / `runContractEmit` / `runDbVerify` +from `journey-test-helpers`. Its schema is two columns: + +```sql +CREATE TABLE "user" (id int4 PRIMARY KEY, email text NOT NULL); +``` + +The gap is the schema, not the harness. + +**Two instruments, because the CLI journey can't observe runtime decode:** + +1. **`cli-journeys/infer-roundtrip-fidelity.e2e.test.ts`** — a purpose-built schema exercising + every defect, driven `contract infer` → `contract emit` → `db verify --schema-only`. + Acceptance: infer exits 0, emit exits 0, verify reports no drift. +2. **An integration test that builds an `ExecutionContext` against the emitted contract and reads + a row through `.include()`** — the only way to observe the numeric-connect and date-decode + defects, which the CLI path never touches. + +**The certainty rule (operator-set):** each defect is reproduced by the instrument *before* its +fix lands. A defect the instrument cannot reproduce does not get "fixed" on the strength of a +code reading, and does not appear as fact in the report we hand back. + +The fixture schema must carry, at minimum: already-plural table names; a 1:1 FK on a unique +column; a 1:N FK; a `GENERATED ALWAYS AS IDENTITY` column; a `GENERATED BY DEFAULT AS IDENTITY` +column; a `serial` column (to prove we don't regress it); an unbounded `numeric`; a +`numeric(10,2)`; a `date`; a `text[]` with `DEFAULT '{}'::text[]`; a `jsonb` with +`DEFAULT '{}'::jsonb`; a GIN index; a `USING hash` index; and an FK pointing out of the +introspected scope. + +## The eight defects and the chosen fix for each + +### 1. Back-relation names double-pluralize + +`pluralize()` appends `es` to any word ending in `s`/`x`/`z`/`ch`/`sh`, so an already-plural +table name doubles: `sessions` → `sessionses`. + +**Fix:** real inflection, per TML-3024. A naive "ends in `s` → skip" rule is wrong and would emit +`statuss`. Property to satisfy: `pluralize` is idempotent for regular plurals *and* correct for +singular-but-`s`-ending words. + +| Input | Output | +|---|---| +| `sessions`, `identities`, `mfaAmrClaims` | unchanged | +| `status` | `statuses` | +| `address` | `addresses` | +| `class` | `classes` | +| `bus` | `buses` | +| `post`, `user` | `posts`, `users` | + +Prefer a small maintained inflection library over a hand-rolled table; fall back to a curated +irregular/uncountable set if a dependency can't clear `pnpm lint:deps`. `packages/2-sql/9-family` +is the consumer. + +### 2. Infer prints a 1:1 back-relation emit can't parse + +Infer emits a bare `profile Profile?` with no `@relation` for a 1:1 back side. The uniqueness +detection producing it is **correct**. The interpreter collects back-relation candidates only +`if (field.list)`, so the singular optional field falls through to scalar resolution and dies +with `PSL_UNSUPPORTED_FIELD_TYPE`. + +**Fix:** teach the interpreter the shape infer already prints — a model-typed, non-list field +with no `@relation` is the 1:1 back side, lowering to `cardinality: '1:1'`. The contract format +already supports `'1:1'` and the TS DSL already produces it; only the PSL interpreter has no path +for it. Do **not** "fix" this by making infer emit lists — that discards real 1:1 information. + +The infer-side snapshot asserting this shape stays; it stops being a snapshot of PSL our own +emitter rejects. + +### 3. Identity columns lose their default + +`GENERATED ALWAYS AS IDENTITY` infers as bare `seq Int`. The columns query joins `pg_attribute` +but never selects `attidentity`; an identity column's `column_default` is NULL, so nothing is +read. `serial` works only because it sets a real `nextval(...)` default that +`raw-default-parser` already maps to `autoincrement()`. + +**Fix, and its deliberate boundary.** Naively emitting `@default(autoincrement())` for an +identity column trades an emit break for a *verify* break: the contract would carry a function +default while the live column reports none, and `resolvedDefaultsEqual` would report drift +forever. + +So the fix is symmetric — identity is recognized as `autoincrement()` on **both** sides: + +- `SqlColumnIR` gains an identity field (it has none today; this is new IR surface). +- The columns query selects `attidentity` and threads it onto the IR. +- Infer emits `@default(autoincrement())` for an identity column. +- The postgres default-normalizer resolves a live identity column's default to + `autoincrement()`, so verify compares equal. + +**Deliberate limitation:** at the contract's altitude, `autoincrement()` means "the database +generates this value." Identity and `serial` are both that, and PSL has no syntax to distinguish +them — so this slice maps identity onto `autoincrement()` rather than modelling +`GENERATED ALWAYS` vs `GENERATED BY DEFAULT`. Consequence: a *fresh* `db init` from such a +contract creates a `serial` column, not an identity one. That is a pre-existing gap (identity has +never been authorable) and stays out of scope. File a follow-up for authoring identity DDL. + +### 4. Non-btree indexes can never emit + +Infer prints `@@index(…, type: "gin")`; the postgres target registers **zero** index types, so +emit throws `unregistered index type "gin"`. `IndexTypeRegistry` is an opt-in extensibility point +— ParadeDB registers `bm25`, and postgres registers nothing. + +**Fix:** the postgres target registers the access methods Postgres ships: `btree`, `hash`, `gin`, +`gist`, `spgist`, `brin`. This is the target declaring its own built-ins, which is what the +registry's opt-in design intends — ParadeDB's `bm25` remains an extension contributing a type it +owns. + +Options schemas start permissive; per-method option validation is not this slice's problem. + +### 5. The `Decimal` scalar is unusable on Postgres + +> **Corrected after D1 reproduced it.** This spec originally described the defect as "unbounded +> `numeric` from infer, via `@db.Numeric` with no args." That mechanism is wrong and the blast +> radius is much wider. Keeping the original framing here would have pointed D4's test at a +> surface the defect doesn't live on. + +`NumericParams.precision` is required while every sibling temporal codec's param is optional. +`renderOutputType`, the `expandNumeric` DDL hook, and the PSL attribute parser all already handle +a missing precision. Only the arktype schema disagrees, and it throws +`RUNTIME.CODEC_PARAMETERIZATION_MISMATCH` when the app builds its `ExecutionContext`. + +**The real reach.** The crash arrives through the **base-scalar** path, not an attribute. +`control-mutation-defaults.ts:162` maps `['Decimal', 'pg/numeric@1']`, so a PSL field declared +`amount Decimal` produces a codec ref with no `typeParams` at all and crashes at connect. Infer +never prints `@db.Numeric` with no arguments — an unbounded `numeric` column comes out as a bare +`Decimal?`, and only a *bounded* `numeric(10,2)` gets an attribute. So this is not an infer defect +that happens to hit `Decimal`; it is that **`Decimal` has never worked on Postgres**, and infer is +simply the first thing that generates one. + +D1's evidence that it went unnoticed this long: there is not a single `Decimal` field in any +`.prisma` file in this repository. The path has never been exercised. + +**Fix:** make `precision` optional on `NumericParams` and `numericParamsSchema`, matching the +sibling `PrecisionParams` pattern. Do not loosen `assertColumnCodecIntegrity` — that check is +working as designed. + +**D4's test must target the base scalar.** A bare `amount Decimal` field authored in PSL, taken +through emit to a live `ExecutionContext`. A test that only covers `@db.Numeric()` would pass +while the defect ships. + +### 6. No `pg/date` codec exists + +`@db.Date` carries `codecId: null` ("inherit from base"), and `DateTime` on postgres is +`pg/timestamptz@1`. Top-level rows survive because `decode()` is a passthrough over an +already-parsed `Date`; `.include()` goes through `json_agg` → `decodeJson()`, which regex-checks +for a full ISO timestamp and rejects `"2024-01-15"`. + +**Fix:** add a real `pg/date@1` codec and point `@db.Date` at it, so the alias has something +correct to inherit. Mongo's `mongo/date@1` is the shape precedent. `decodeJson` accepts +`YYYY-MM-DD`; infer maps a bare `date` column to it. + +**Breaking-change note:** this changes the emitted `codecId` for existing `@db.Date` columns, +which changes the contract hash and therefore signed markers. It needs an entry under +`skills/upgrade/prisma-next-upgrade/upgrades/` — see the `record-upgrade-instructions` skill. +Flag at PR-open; do not skip. + +### 7. Array columns can't keep their default + +> **Remedy corrected after review.** The analysis below was right; the fix it prescribed was +> wrong, and shipping it would have traded this defect for a worse one. Recorded rather than +> quietly amended, because the mistake is instructive. + +`DEFAULT '{}'::text[]` → `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`. The check conflates two +different concepts: `dbgenerated` lowers to a **storage-level** SQL default — the same shape as +`now()` — not a per-element execution-time generator. That much is true. + +**The prescribed fix was to relax the check** to reject only `executionDefaults.onCreate`, so a +storage-level function default on a list would pass. That is unsound. It admits +`tags DateTime[] @default(now())`, whose DDL Postgres refuses — the default expression's type +doesn't match the array column — moving the error from authoring time (clear) to DDL-apply time +(ugly). Nothing in the authoring layer can tell whether a function returns an array, so "permit +storage function defaults on lists" cannot be made safe here. + +The tell was visible in the implementation: it needed a `PSL_LIST_AUTOINCREMENT_UNSUPPORTED` +special case to plug one instance of exactly that hole. + +**The actual fix is defect 8's.** Once the authoring side resolves literal defaults through +`parsePostgresDefault`, `dbgenerated("'{}'::text[]")` resolves to `kind: 'literal'` and never +reaches the list check at all. The check stays as it was, rejecting function defaults on lists, +and the motivating case works anyway. + +So this defect has no fix of its own — it is a duplicate of defect 8 wearing different clothes, +and the round-trip journey proves the array default survives without the check being touched. + +**Known residual:** a genuine array-returning function default (`dbgenerated("ARRAY[gen_random_uuid()]")`) +is still rejected. That is the pre-existing behaviour, so leaving it is not a regression. Worth a +follow-up, not worth widening the check for. + +> **"No fix of its own" was also wrong.** Defect 8 ended up fixed at compare time — the +> `resolveDefault` hook that normalizes a contract-declared default only runs when building the +> expected-side `SchemaIR` for verify, after emit has already accepted the field. It never runs +> during authoring, so `dbgenerated("'{}'::text[]")` on a list column still lowers to +> `kind: 'function'` at emit time and still hits `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`. Defect +> 7 stayed reproducible after defect 8 landed. +> +> The actual fix: PSL already has literal list syntax (`@default([...])`), and the interpreter +> already lowers it to `kind: 'literal'` without ever reaching the list check. Infer just never +> printed it — for any raw default it couldn't otherwise map, it fell back to `dbgenerated( text>)`. The postgres control adapter already resolves a list column's raw default to a +> structured literal at introspection time (`resolvedDefault`, the same mechanism defect 3 uses +> for identity columns), so infer now prints PSL literal-list syntax from that resolved value +> instead of the raw-text fallback. No interpreter change, no framework change: the fix lives +> entirely in `infer-psl-contract.ts`'s `buildScalarField`. + +### 8. A `dbgenerated` literal default reports drift forever + +> **Widened after D6 exposed a second instance.** This was originally written as a jsonb defect. +> It is not: `dbgenerated("'{}'::text[]")` drifts identically. The defect is a **class** — any +> literal default the two sides resolve differently — and it gets fixed as a class, not +> instance-by-instance. + +Emit keeps `@default(dbgenerated("'{}'::jsonb"))` as `kind: 'function'`; introspection parses the +same literal to `kind: 'literal'`. `resolvedDefaultsEqual` compares `kind` first and returns false +before reading content — so `db verify` flags every such column `not-equal` permanently, even when +the database matches exactly. + +**Known instances:** `'{}'::jsonb` (D1 reproduced it) and `'{}'::text[]` (D6 reproduced it; D1 +could not, because emit failed before verify ever ran). Assume there are others — +`parsePostgresDefault` recognizes a range of literal forms, and every form it parses is a +candidate. Enumerate them from that function rather than from this list. + +**Fix:** the two paths agree on one resolved shape for a literal default. The authoring path is +the lossy one — it keeps the user's raw text without recognizing that `'{}'::jsonb` *is* a literal +— so the authoring side normalizes to match introspection by **reusing `parsePostgresDefault`**, +not by copying it. Reuse is what makes this a class fix: a form the function learns to parse later +is then automatically consistent on both sides. + +Guard against the obvious over-reach: `dbgenerated("gen_random_uuid()")` and +`dbgenerated("(now() + '00:03:00'::interval)")` are genuinely functions and must stay functions. + +### Also in scope: dangling FKs drop silently + +Infer correctly drops an FK whose target is outside the introspected scope — that branch is +tested and there's no unresolvable reference. But it emits no comment and no warning, so a user +loses every `auth.users` relationship with zero indication. + +**Fix:** emit an explanatory comment on the model, matching the precedent already in +`infer-psl-contract.ts` for tables with no primary key. + +### Also in scope: delete the workarounds + +Each fix removes the matching entry from `generate-contract.ts` and the pack's `contract.prisma` +is regenerated. What survives must survive for a stated reason. The script shrinking is the +measure of done — if a fix lands and its workaround stays, the fix didn't work. + +## Contract-impact + +- `SqlColumnIR` gains an identity field (`packages/2-sql/1-core/schema-ir`). New IR surface; + every constructor/factory site adapts. +- A new codec id `pg/date@1` joins the postgres codec registry. Emitted contracts for `@db.Date` + columns change `codecId`, changing the contract hash. +- Postgres registers built-in index types, so `@@index(type:)` values that previously failed + validation now persist into `contract.json`. +- No change to `Contract` envelope shape or cardinality vocabulary — `'1:1'` already exists. + +## Adapter-impact + +- **postgres adapter** (`packages/3-targets/6-adapters/postgres`): columns introspection query + selects `attidentity`; default-normalizer resolves identity → `autoincrement()`. +- **postgres target** (`packages/3-targets/3-targets/postgres`): new codec, index-type + registration, infer changes. +- **sqlite / mongo:** untouched. The `pluralize` fix lives in `packages/2-sql/9-family` and is + shared by every SQL target; its behaviour change is name-only and covered by the acceptance + table above. + +## ADR pointer + +No new ADR. Two decisions are worth recording in the PR body rather than an ADR, because both +apply an existing decision rather than making a new one: + +- **The target registers its own built-in index types.** This is `IndexTypeRegistry` used as + designed, not a redefinition of it. +- **Identity maps onto `autoincrement()`.** Applies the contract's existing "database generates + this" vocabulary; does not introduce a competing concept. + +If review disagrees that identity-as-`autoincrement()` is settled, that's an ADR and a re-spec — +not a thing to negotiate inside the dispatch loop. + +## Out of scope + +- **NUL bytes where `@` should be.** Reported by the external user; could not be reproduced. Our + generated `contract.prisma` has 603 `@` bytes and zero NULs, no `.prisma` file in the repo + contains a NUL, the sigil is a hardcoded literal never computed, and the printer is + byte-identical to the version they run. Needs `xxd` of a fresh infer from the reporter before + it's worth any time. +- **PSL `constraint: false`.** PSL cannot author an FK-less relation; the knob is TS-DSL-only. + Real gap, different shape. +- **Authoring `GENERATED ALWAYS AS IDENTITY` in DDL.** See defect 3's boundary. +- **Per-index-method option validation** (e.g. `gin` operator classes). Registration only. +- **`@default(null)` on a nullable column** and **nullable list columns** — two further + round-trip gaps documented in `generate-contract.ts`'s own comments. Real, but each needs its + own design; file separately. + +## Slice DoD + +- [ ] `cli-journeys/infer-roundtrip-fidelity.e2e.test.ts` exists, covers the fixture schema above, + and passes: infer → emit → `db verify --schema-only` clean. +- [ ] A runtime integration test builds an `ExecutionContext` against the emitted contract and + reads a `date` column through `.include()`. +- [ ] Each of the eight was demonstrated failing against the instrument before its fix. +- [ ] `generate-contract.ts`: `DOUBLE_PLURALIZED_FIELD_NAMES` deleted; `INDEX_OMISSIONS` deleted; + `DEFAULT_OMISSIONS` reduced to only what remains genuinely unrepresentable, each with its + reason. The pack's `contract.prisma` regenerated and the diff reviewed. +- [ ] Upgrade instructions recorded for the `pg/date@1` codec change. +- [ ] TML-3024 closed by this PR. +- [ ] Full gate set green: `pnpm build`, `pnpm typecheck`, the Lint job (incl. `lint:deps`, + `lint:casts`), `pnpm fixtures:check`, `test:packages`, `test:integration`, `test:e2e`. + +## Risks + +- **Identity is the biggest item** and the one most likely to grow. Its boundary is pinned above. + If the round-trip test shows verify still drifting after the symmetric fix, stop and re-spec + rather than chasing DDL fidelity inside the dispatch loop. +- **The jsonb normalization fix changes an authoring-side resolved shape**, which will move + emitted fixtures. `pnpm fixtures:check` is the tell; the diff needs reading, not blanket + regeneration. +- **The `pg/date@1` change is breaking.** Missing the upgrade instructions is the failure mode. +- **`pluralize` may want a dependency.** If `lint:deps` or layering rejects it, the curated-set + fallback is pre-authorized — don't stall on it. diff --git a/scripts/lint-framework-vocabulary.config.json b/scripts/lint-framework-vocabulary.config.json index fc80d4808c..24cf00b30b 100644 --- a/scripts/lint-framework-vocabulary.config.json +++ b/scripts/lint-framework-vocabulary.config.json @@ -32,6 +32,11 @@ ], "allow": ["SymbolTable"], "threshold": 836 + }, + { + "path": "packages/2-sql", + "forbidden": ["pg/"], + "threshold": 61 } ] } diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.15-to-0.16/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.15-to-0.16/instructions.md index 4e9e7a8861..e26392f21a 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.15-to-0.16/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.15-to-0.16/instructions.md @@ -18,8 +18,71 @@ changes: contains: - "extension-supabase/test/utils" anyMatch: true + - id: pg-date-codec-for-db-date + summary: | + Postgres `@db.Date` columns are now backed by a dedicated `pg/date@1` codec, rather than inheriting `pg/timestamptz@1` (which `@db.Date` previously had no codec of its own and fell back to). The next `contract emit` of any existing `@db.Date` column in an extension pack's contract picks up the new `codecId` automatically — no `.prisma` source change needed — which changes that column's entry in `contract.json`/`contract.d.ts` and the contract's `storageHash`. This is a fix, not a regression: a `@db.Date` column read through `.include()` previously crashed decoding a bare `YYYY-MM-DD` value through the timestamptz decoder (`json_agg` -> `decodeJson`); that now works. No extension code needs to change. If your extension pins or compares a contract's `storageHash` (e.g. in a fixture snapshot or a published pack's own upgrade check), re-generate it after upgrading. + detection: + glob: "**/*.prisma" + contains: + - "@db.Date" + anyMatch: true + - id: identity-columns-need-explicit-default-under-strict-verify + summary: | + `contract infer` now emits `@default(autoincrement())` for a Postgres `GENERATED ALWAYS AS IDENTITY` / `GENERATED BY DEFAULT AS IDENTITY` column (previously it emitted a bare column with no default, since Postgres reports no `column_default` for an identity column). Symmetrically, `db verify` introspecting a live identity column now resolves its default to `autoincrement()` too (previously it resolved to nothing). This only changes `db verify --strict` — without `--strict`, an undeclared live default is tolerated either way (and it is fully tolerated regardless of strictness under `control: 'external'`, the posture most extension packs declare). If your pack's own tests run `db verify --strict` against a table with an identity column whose contract does not declare `@default(autoincrement())`, verify now reports that default as an unexpected extra. Re-run `contract infer` for the affected table, or add `@default(autoincrement())` by hand. + - id: pluralize-back-relation-names-no-longer-double-pluralize + summary: | + `contract infer`'s back-relation field name generation used a hand-rolled pluralization rule that appended `es` to any table name already ending in `s`/`x`/`z`/`ch`/`sh`, doubling an already-plural table name (`sessions` -> `sessionses`). `contract infer` now uses real inflection (the `pluralize` library) and produces the correct name (`sessions` stays `sessions`; a genuinely singular `status` still becomes `statuses`). This only affects a future `contract infer` run — an already-generated `.prisma` file is untouched, so nothing breaks until you next re-run infer for your pack. If you do re-run `contract infer` against a schema with an already-plural table name, diff the regenerated `.prisma` file for any back-relation field whose name changed — that's a public field name your pack's consumers access via `.include()`/`.select()`/the generated TypeScript types, so a rename is a breaking change to your pack's own published surface, to be versioned and documented like any other. --- + +