diff --git a/docs/architecture docs/subsystems/3. Query Lanes.md b/docs/architecture docs/subsystems/3. Query Lanes.md index ec47f39589..86777c2027 100644 --- a/docs/architecture docs/subsystems/3. Query Lanes.md +++ b/docs/architecture docs/subsystems/3. Query Lanes.md @@ -340,7 +340,11 @@ const plan = o.user() - **DML operations** via INSERT, UPDATE, DELETE statements with model-to-column mapping - **One call → one statement** remains the rule -The ORM is optional and layered on top of the DSL. +The ORM is optional and layered on top of the SQL DSL query-builder lane (`db.sql` / `sql()`). + +### Id-less tables and primary-key fallback + +`storage.tables..primaryKey` is optional in the SQL contract. Id-less SQL tables are emittable from PSL (`model X { ... }` without `@id`/`@@id`) and from introspection. This is not a general restriction on ORM collection operations: predicate-based reads and writes such as `where(...).first()`, `where(...).update(...)`, and `where(...).delete()` can target id-less tables because the caller provides the row-matching predicate explicitly. The SQL DSL query-builder lane (`db.sql` / `sql()`) is also safe for the same reason: callers state the table, predicates, and projection directly. The unsafe case is primary-key fallback behavior, such as mutation reloads, default upsert conflict columns, count helpers that currently select the primary-key column before mutating, or future `findUnique`-style APIs. Those helpers must either require an explicit unique predicate or be gated by a per-model capability. ### Shared Collection interface across families diff --git a/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts index 1f735fadf9..b4304d81cf 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/client.test.ts @@ -17,7 +17,7 @@ import type { EmissionSpi } from '@prisma-next/framework-components/emission'; import { timeouts } from '@prisma-next/test-utils'; import { ifDefined } from '@prisma-next/utils/defined'; import { notOk, ok } from '@prisma-next/utils/result'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@prisma-next/emitter', () => ({ emit: vi.fn( @@ -36,6 +36,20 @@ import type { ControlProgressEvent } from '../../src/control-api/types'; const mockEmit = vi.mocked(emitFn); +function createMockEmitResult(): EmitResult { + return { + storageHash: 'test-core-hash', + profileHash: 'test-profile-hash', + contractJson: '{"test": true}', + contractDts: 'export interface Contract {}', + }; +} + +beforeEach(() => { + mockEmit.mockReset(); + mockEmit.mockResolvedValue(createMockEmitResult()); +}); + function createSourceProvider( load: ContractSourceProvider['load'] = async () => ok({ test: true } as unknown as Contract), inputs?: readonly string[], diff --git a/packages/1-framework/3-tooling/cli/vitest.config.ts b/packages/1-framework/3-tooling/cli/vitest.config.ts index 0e02747099..afaa9b107e 100644 --- a/packages/1-framework/3-tooling/cli/vitest.config.ts +++ b/packages/1-framework/3-tooling/cli/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ pool: 'forks', maxWorkers: 1, isolate: false, + fileParallelism: false, testTimeout: timeouts.vitestPackageDefault, hookTimeout: timeouts.vitestPackageDefault, setupFiles: ['./test/setup.ts'], diff --git a/packages/1-framework/3-tooling/emitter/test/emitter.roundtrip.test.ts b/packages/1-framework/3-tooling/emitter/test/emitter.roundtrip.test.ts index 30ee6fdad1..4bc10abd51 100644 --- a/packages/1-framework/3-tooling/emitter/test/emitter.roundtrip.test.ts +++ b/packages/1-framework/3-tooling/emitter/test/emitter.roundtrip.test.ts @@ -171,70 +171,74 @@ describe('emitter round-trip', () => { timeouts.typeScriptCompilation, ); - it('round-trip with nullable fields', async () => { - const ir = createTestContract({ - storage: { - tables: { - user: { - columns: { - id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, - email: { codecId: 'pg/text@1', nativeType: 'text', nullable: true }, - name: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, + it( + 'round-trip with nullable fields', + async () => { + const ir = createTestContract({ + storage: { + tables: { + user: { + columns: { + id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, + email: { codecId: 'pg/text@1', nativeType: 'text', nullable: true }, + name: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], }, - primaryKey: { columns: ['id'] }, - uniques: [], - indexes: [], - foreignKeys: [], }, }, - }, - extensionPacks: { - postgres: { version: '0.0.1' }, - pg: {}, - }, - }); + extensionPacks: { + postgres: { version: '0.0.1' }, + pg: {}, + }, + }); - const codecTypeImports: TypesImportSpec[] = []; - const operationTypeImports: TypesImportSpec[] = []; - const extensionIds = ['postgres', 'pg']; - const options: EmitStackInput = { - codecTypeImports, - operationTypeImports, - extensionIds, - }; + const codecTypeImports: TypesImportSpec[] = []; + const operationTypeImports: TypesImportSpec[] = []; + const extensionIds = ['postgres', 'pg']; + const options: EmitStackInput = { + codecTypeImports, + operationTypeImports, + extensionIds, + }; - const result1 = await emit(ir, options, mockSqlHook); - const contractJson1 = JSON.parse(result1.contractJson) as Record; + const result1 = await emit(ir, options, mockSqlHook); + const contractJson1 = JSON.parse(result1.contractJson) as Record; - const ir2 = createTestContract({ - targetFamily: contractJson1['targetFamily'] as string, - target: contractJson1['target'] as string, - roots: contractJson1['roots'] as Record, - models: contractJson1['models'] as Record, - storage: contractJson1['storage'] as Record, - extensionPacks: contractJson1['extensionPacks'] as Record, - capabilities: - (contractJson1['capabilities'] as Record>) || {}, - meta: (contractJson1['meta'] as Record) || {}, - }); + const ir2 = createTestContract({ + targetFamily: contractJson1['targetFamily'] as string, + target: contractJson1['target'] as string, + roots: contractJson1['roots'] as Record, + models: contractJson1['models'] as Record, + storage: contractJson1['storage'] as Record, + extensionPacks: contractJson1['extensionPacks'] as Record, + capabilities: + (contractJson1['capabilities'] as Record>) || {}, + meta: (contractJson1['meta'] as Record) || {}, + }); - const result2 = await emit(ir2, options, mockSqlHook); + const result2 = await emit(ir2, options, mockSqlHook); - expect(result1.contractJson).toBe(result2.contractJson); - expect(result1.storageHash).toBe(result2.storageHash); + expect(result1.contractJson).toBe(result2.contractJson); + expect(result1.storageHash).toBe(result2.storageHash); - const parsed2 = JSON.parse(result2.contractJson) as Record; - const storage = parsed2['storage'] as Record; - const tables = storage['tables'] as Record; - const user = tables['user'] as Record; - const columns = user['columns'] as Record; - const id = columns['id'] as Record; - const email = columns['email'] as Record; - const name = columns['name'] as Record; - expect(id['nullable']).toBe(false); - expect(email['nullable']).toBe(true); - expect(name['nullable']).toBe(false); - }); + const parsed2 = JSON.parse(result2.contractJson) as Record; + const storage = parsed2['storage'] as Record; + const tables = storage['tables'] as Record; + const user = tables['user'] as Record; + const columns = user['columns'] as Record; + const id = columns['id'] as Record; + const email = columns['email'] as Record; + const name = columns['name'] as Record; + expect(id['nullable']).toBe(false); + expect(email['nullable']).toBe(true); + expect(name['nullable']).toBe(false); + }, + timeouts.typeScriptCompilation, + ); it('round-trip with capabilities', async () => { const ir = createTestContract({ diff --git a/packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts b/packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts index 48c74092b9..e1ee7dccfd 100644 --- a/packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts +++ b/packages/1-framework/3-tooling/vite-plugin-contract-emit/src/plugin.ts @@ -124,6 +124,11 @@ export function prismaVitePlugin( log(` → ${result.files.json}`, 'debug'); log(` → ${result.files.dts}`, 'debug'); + if (server) { + server.moduleGraph.onFileChange(result.files.json); + server.moduleGraph.onFileChange(result.files.dts); + } + if (server && !hasQueuedEmit) { server.ws.send({ type: 'full-reload' }); } else if (hasQueuedEmit) { diff --git a/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts b/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts index 49d2d77987..3cbf6f20ba 100644 --- a/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts +++ b/packages/1-framework/3-tooling/vite-plugin-contract-emit/test/plugin.test.ts @@ -135,6 +135,7 @@ function createMockServer() { ssrLoadModule: vi.fn().mockResolvedValue({}), moduleGraph: { getModuleById: vi.fn().mockReturnValue(null), + onFileChange: vi.fn(), }, }; } @@ -415,6 +416,22 @@ describe('prismaVitePlugin', () => { ); }); + it('invalidates emitted artifacts after successful emit', async () => { + const plugin = prismaVitePlugin('prisma-next.config.ts', { logLevel: 'silent' }); + const mockServer = createMockServer(); + + const configResolved = plugin.configResolved as unknown as (config: { root: string }) => void; + configResolved({ root: '/project' }); + + const configureServer = plugin.configureServer as unknown as ( + server: ReturnType, + ) => Promise; + await configureServer(mockServer); + + expect(mockServer.moduleGraph.onFileChange).toHaveBeenCalledWith('/out/contract.json'); + expect(mockServer.moduleGraph.onFileChange).toHaveBeenCalledWith('/out/contract.d.ts'); + }); + it('loads config once on startup before the initial emit', async () => { const plugin = prismaVitePlugin('prisma-next.config.ts', { logLevel: 'silent' }); const mockServer = createMockServer(); diff --git a/packages/2-sql/1-core/contract/src/validators.ts b/packages/2-sql/1-core/contract/src/validators.ts index d64a6f307e..02787daac1 100644 --- a/packages/2-sql/1-core/contract/src/validators.ts +++ b/packages/2-sql/1-core/contract/src/validators.ts @@ -139,6 +139,17 @@ function isPlainRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function findDuplicateValue(values: readonly string[]): string | undefined { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + return value; + } + seen.add(value); + } + return undefined; +} + function isContractFieldType(value: unknown): boolean { if (!isPlainRecord(value)) return false; const kind = value['kind']; @@ -293,6 +304,8 @@ export function validateSqlContract>(value: unkno * Currently checks: * - duplicate named primary key / unique / index / foreign key objects within a table * - duplicate unique, index, or foreign key declarations within a table + * - duplicate columns within primary key / unique / index definitions + * - nullable columns in primary key definitions * - `setNull` referential action on a non-nullable FK column (would fail at runtime) * - `setDefault` referential action on a non-nullable FK column without a DEFAULT (would fail at runtime) */ @@ -325,8 +338,33 @@ export function validateStorageSemantics(storage: SqlStorage): string[] { } } + if (table.primaryKey) { + const duplicateColumn = findDuplicateValue(table.primaryKey.columns); + if (duplicateColumn !== undefined) { + errors.push( + `Table "${tableName}": primary key contains duplicate column "${duplicateColumn}"`, + ); + } + + for (const columnName of table.primaryKey.columns) { + const column = table.columns[columnName]; + if (column?.nullable === true) { + errors.push( + `Table "${tableName}": primary key column "${columnName}" is nullable; primary key columns must be NOT NULL`, + ); + } + } + } + const seenUniqueDefinitions = new Set(); for (const unique of table.uniques) { + const duplicateColumn = findDuplicateValue(unique.columns); + if (duplicateColumn !== undefined) { + errors.push( + `Table "${tableName}": unique constraint contains duplicate column "${duplicateColumn}"`, + ); + } + const signature = JSON.stringify({ columns: unique.columns }); if (seenUniqueDefinitions.has(signature)) { errors.push( @@ -339,6 +377,11 @@ export function validateStorageSemantics(storage: SqlStorage): string[] { const seenIndexDefinitions = new Set(); for (const index of table.indexes) { + const duplicateColumn = findDuplicateValue(index.columns); + if (duplicateColumn !== undefined) { + errors.push(`Table "${tableName}": index contains duplicate column "${duplicateColumn}"`); + } + const signature = JSON.stringify({ columns: index.columns, using: index.using ?? null, diff --git a/packages/2-sql/1-core/contract/test/validators.test.ts b/packages/2-sql/1-core/contract/test/validators.test.ts index 365716cd5f..88235f1581 100644 --- a/packages/2-sql/1-core/contract/test/validators.test.ts +++ b/packages/2-sql/1-core/contract/test/validators.test.ts @@ -681,6 +681,57 @@ describe('SQL contract validators', () => { expect(errors[1]).toContain('duplicate index definition'); }); + it('rejects duplicate columns inside key, unique, and index definitions', () => { + const s = createContract({ + storage: { + tables: { + user: table( + { + id: col('int4', 'pg/int4@1'), + email: col('text', 'pg/text@1'), + }, + { + pk: pk('id', 'id'), + uniques: [unique('email', 'email')], + indexes: [index('email', 'email')], + }, + ), + }, + }, + }).storage; + + const errors = validateStorageSemantics(s); + expect(errors).toHaveLength(3); + expect(errors[0]).toContain('primary key'); + expect(errors[0]).toContain('duplicate column "id"'); + expect(errors[1]).toContain('unique constraint'); + expect(errors[1]).toContain('duplicate column "email"'); + expect(errors[2]).toContain('index'); + expect(errors[2]).toContain('duplicate column "email"'); + }); + + it('rejects nullable primary-key columns', () => { + const s = createContract({ + storage: { + tables: { + user: table( + { + id: col('int4', 'pg/int4@1', true), + }, + { + pk: pk('id'), + }, + ), + }, + }, + }).storage; + + const errors = validateStorageSemantics(s); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('primary key column "id"'); + expect(errors[0]).toContain('NOT NULL'); + }); + it('rejects duplicate foreign key definitions within the same table', () => { const s = createContract({ storage: { 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 daebe4d990..a82eda1984 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -35,11 +35,13 @@ import { type ForeignKeyNode, type IndexNode, type ModelNode, + type PrimaryKeyNode, type UniqueConstraintNode, } from '@prisma-next/sql-contract-ts/contract-builder'; import { ifDefined } from '@prisma-next/utils/defined'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; import { + findDuplicateFieldName, getAttribute, getPositionalArgument, mapFieldNamesToColumns, @@ -464,18 +466,24 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult scalarTypeDescriptors: input.scalarTypeDescriptors, }); - const primaryKeyFields = resolvedFields.filter((field) => field.isId); - const primaryKeyColumns = primaryKeyFields.map((field) => field.columnName); - const primaryKeyName = primaryKeyFields.length === 1 ? primaryKeyFields[0]?.idName : undefined; - const isVariantModel = model.attributes.some((attr) => attr.name === 'base'); - if (primaryKeyColumns.length === 0 && !isVariantModel) { + const inlineIdFields = resolvedFields.filter((field) => field.isId); + if (inlineIdFields.length > 1) { diagnostics.push({ - code: 'PSL_MISSING_PRIMARY_KEY', - message: `Model "${model.name}" must declare at least one @id field for SQL provider`, + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `Model "${model.name}" cannot declare inline @id on multiple fields; use model-level @@id([...]) for composite identity`, sourceId, span: model.span, }); } + const singleInlineIdField = inlineIdFields.length === 1 ? inlineIdFields[0] : undefined; + let primaryKey: PrimaryKeyNode | undefined = singleInlineIdField + ? { + columns: [singleInlineIdField.columnName], + ...ifDefined('name', singleInlineIdField.idName), + } + : undefined; + const hasInlinePrimaryKey = primaryKey !== undefined; + let blockPrimaryKeyDeclared = false; const resultBackrelationCandidates: ModelBackrelationCandidate[] = []; for (const field of model.fields) { @@ -562,17 +570,107 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') { continue; } + const attributeLabel = `Model "${model.name}" @@${modelAttribute.name}`; + if (modelAttribute.name === 'id') { + if (blockPrimaryKeyDeclared) { + diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `Model "${model.name}" declares @@id more than once`, + sourceId, + span: modelAttribute.span, + }); + continue; + } + if (hasInlinePrimaryKey) { + diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `Model "${model.name}" cannot declare both field-level @id and model-level @@id`, + sourceId, + span: modelAttribute.span, + }); + blockPrimaryKeyDeclared = true; + continue; + } + const fieldNames = parseAttributeFieldList({ + attribute: modelAttribute, + sourceId, + diagnostics, + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + entityLabel: attributeLabel, + }); + if (!fieldNames) { + continue; + } + const duplicateFieldName = findDuplicateFieldName(fieldNames); + if (duplicateFieldName !== undefined) { + diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `${attributeLabel} list contains duplicate field "${duplicateFieldName}"`, + sourceId, + span: modelAttribute.span, + }); + continue; + } + const nullableFieldName = fieldNames.find( + (name) => model.fields.find((f) => f.name === name)?.optional === true, + ); + if (nullableFieldName !== undefined) { + diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `${attributeLabel} cannot include optional field "${nullableFieldName}"; primary key columns must be NOT NULL`, + sourceId, + span: modelAttribute.span, + }); + continue; + } + const columnNames = mapFieldNamesToColumns({ + modelName: model.name, + fieldNames, + mapping, + sourceId, + diagnostics, + span: modelAttribute.span, + entityLabel: attributeLabel, + }); + if (!columnNames) { + continue; + } + const constraintName = parseConstraintMapArgument({ + attribute: modelAttribute, + sourceId, + diagnostics, + entityLabel: attributeLabel, + span: modelAttribute.span, + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + }); + primaryKey = { + columns: columnNames, + ...ifDefined('name', constraintName), + }; + blockPrimaryKeyDeclared = true; + continue; + } if (modelAttribute.name === 'unique' || modelAttribute.name === 'index') { const fieldNames = parseAttributeFieldList({ attribute: modelAttribute, sourceId, diagnostics, code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - messagePrefix: `Model "${model.name}" @@${modelAttribute.name}`, + entityLabel: attributeLabel, }); if (!fieldNames) { continue; } + const duplicateFieldName = findDuplicateFieldName(fieldNames); + if (duplicateFieldName !== undefined) { + diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `${attributeLabel} list contains duplicate field "${duplicateFieldName}"`, + sourceId, + span: modelAttribute.span, + }); + continue; + } const columnNames = mapFieldNamesToColumns({ modelName: model.name, fieldNames, @@ -580,7 +678,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult sourceId, diagnostics, span: modelAttribute.span, - contextLabel: `Model "${model.name}" @@${modelAttribute.name}`, + entityLabel: attributeLabel, }); if (!columnNames) { continue; @@ -589,7 +687,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult attribute: modelAttribute, sourceId, diagnostics, - entityLabel: `Model "${model.name}" @@${modelAttribute.name}`, + entityLabel: attributeLabel, span: modelAttribute.span, code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', }); @@ -687,7 +785,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult sourceId, diagnostics, span: relationAttribute.relation.span, - contextLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`, + entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`, }); if (!localColumns) { continue; @@ -699,7 +797,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult sourceId, diagnostics, span: relationAttribute.relation.span, - contextLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`, + entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`, }); if (!referencedColumns) { continue; @@ -773,14 +871,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult ...ifDefined('default', resolvedField.defaultValue), ...ifDefined('executionDefaults', resolvedField.executionDefaults), })), - ...(primaryKeyColumns.length > 0 - ? { - id: { - columns: primaryKeyColumns, - ...ifDefined('name', primaryKeyName), - }, - } - : {}), + ...ifDefined('id', primaryKey), ...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}), ...(indexNodes.length > 0 ? { indexes: indexNodes } : {}), ...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}), diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts index 07451d7ea4..69dd905a52 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts @@ -251,13 +251,13 @@ export function parseAttributeFieldList(input: { readonly sourceId: string; readonly diagnostics: ContractSourceDiagnostic[]; readonly code: string; - readonly messagePrefix: string; + readonly entityLabel: string; }): readonly string[] | undefined { const raw = getNamedArgument(input.attribute, 'fields') ?? getPositionalArgument(input.attribute); if (!raw) { input.diagnostics.push({ code: input.code, - message: `${input.messagePrefix} requires fields list argument`, + message: `${input.entityLabel} requires fields list argument`, sourceId: input.sourceId, span: input.attribute.span, }); @@ -267,7 +267,7 @@ export function parseAttributeFieldList(input: { if (!fields || fields.length === 0) { input.diagnostics.push({ code: input.code, - message: `${input.messagePrefix} requires bracketed field list argument`, + message: `${input.entityLabel} requires bracketed field list argument`, sourceId: input.sourceId, span: input.attribute.span, }); @@ -276,6 +276,15 @@ export function parseAttributeFieldList(input: { return fields; } +export function findDuplicateFieldName(fieldNames: readonly string[]): string | undefined { + const seen = new Set(); + for (const name of fieldNames) { + if (seen.has(name)) return name; + seen.add(name); + } + return undefined; +} + export function mapFieldNamesToColumns(input: { readonly modelName: string; readonly fieldNames: readonly string[]; @@ -283,7 +292,7 @@ export function mapFieldNamesToColumns(input: { readonly sourceId: string; readonly diagnostics: ContractSourceDiagnostic[]; readonly span: PslSpan; - readonly contextLabel: string; + readonly entityLabel: string; }): readonly string[] | undefined { const columns: string[] = []; for (const fieldName of input.fieldNames) { @@ -291,7 +300,7 @@ export function mapFieldNamesToColumns(input: { if (!columnName) { input.diagnostics.push({ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: `${input.contextLabel} references unknown field "${input.modelName}.${fieldName}"`, + message: `${input.entityLabel} references unknown field "${input.modelName}.${fieldName}"`, sourceId: input.sourceId, span: input.span, }); 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 b1f846c1a8..753c93f310 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 @@ -366,6 +366,16 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv sourceId, diagnostics, }); + let isIdField = Boolean(idAttribute); + if (idAttribute && field.optional) { + diagnostics.push({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: `Field "${model.name}.${field.name}" @id cannot be optional; primary key columns must be NOT NULL`, + sourceId, + span: idAttribute.span, + }); + isIdField = false; + } // Field presets contribute their own default / executionDefaults / id / // unique. They take precedence over attribute-derived contributions for @@ -394,7 +404,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv descriptor, ...ifDefined('defaultValue', fieldDefaultValue), ...ifDefined('executionDefaults', fieldExecutionDefaults), - isId: Boolean(idAttribute) || Boolean(presetContributions?.id), + isId: isIdField || Boolean(presetContributions?.id), isUnique: Boolean(uniqueAttribute) || Boolean(presetContributions?.unique), ...ifDefined('idName', idName), ...ifDefined('uniqueName', uniqueName), diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index 39d92ec723..c9ae75c773 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -125,7 +125,6 @@ model User { expect(result.failure.diagnostics.map((diagnostic) => diagnostic.code)).toEqual( expect.arrayContaining([ 'PSL_UNSUPPORTED_NAMED_TYPE_BASE', - 'PSL_MISSING_PRIMARY_KEY', 'PSL_UNSUPPORTED_FIELD_TYPE', 'PSL_INVALID_RELATION_TARGET', ]), @@ -695,4 +694,324 @@ model User { ]), ); }); + + it('rejects @@id with no field list argument', () => { + const document = parsePslDocument({ + schema: `model Thing { + email String + @@id() +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: expect.stringContaining('Model "Thing" @@id requires fields list argument'), + }), + ]), + ); + }); + + it('rejects @@id with empty bracketed field list', () => { + const document = parsePslDocument({ + schema: `model Thing { + email String + @@id([]) +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: expect.stringContaining('@@id requires bracketed field list argument'), + }), + ]), + ); + }); + + it('rejects @@id referencing an unknown field', () => { + const document = parsePslDocument({ + schema: `model Thing { + email String + @@id([nope]) +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: expect.stringContaining( + 'Model "Thing" @@id references unknown field "Thing.nope"', + ), + }), + ]), + ); + }); + + it('rejects inline @id together with @@id', () => { + const document = parsePslDocument({ + schema: `model Thing { + email String @id + @@id([email]) +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: 'Model "Thing" cannot declare both field-level @id and model-level @@id', + }), + ]), + ); + }); + + it('rejects @@id with non-quoted map argument', () => { + const document = parsePslDocument({ + schema: `model Thing { + email String + @@id([email], map: not_a_string) +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: expect.stringContaining('@@id map argument must be a quoted string literal'), + }), + ]), + ); + }); + + it('rejects two @@id declarations on the same model', () => { + const document = parsePslDocument({ + schema: `model Thing { + email String + token String + @@id([email]) + @@id([token]) +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: 'Model "Thing" declares @@id more than once', + }), + ]), + ); + }); + + it('rejects @@id with duplicate fields in the list', () => { + const document = parsePslDocument({ + schema: `model Thing { + email String + @@id([email, email]) +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: 'Model "Thing" @@id list contains duplicate field "email"', + }), + ]), + ); + }); + + it('rejects inline @id on an optional field', () => { + const document = parsePslDocument({ + schema: `model Thing { + email String? @id +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: + 'Field "Thing.email" @id cannot be optional; primary key columns must be NOT NULL', + }), + ]), + ); + }); + + it('rejects @@id including an optional field', () => { + const document = parsePslDocument({ + schema: `model Thing { + email String? + @@id([email]) +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: + 'Model "Thing" @@id cannot include optional field "email"; primary key columns must be NOT NULL', + }), + ]), + ); + }); + + it('rejects inline @id on multiple fields', () => { + const document = parsePslDocument({ + schema: `model Thing { + a Int @id + b Int @id +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: + 'Model "Thing" cannot declare inline @id on multiple fields; use model-level @@id([...]) for composite identity', + }), + ]), + ); + }); + + it('rejects @@unique with duplicate fields in the list', () => { + const document = parsePslDocument({ + schema: `model Thing { + id Int @id + email String + @@unique([email, email]) +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: 'Model "Thing" @@unique list contains duplicate field "email"', + }), + ]), + ); + }); + + it('rejects @@index with duplicate fields in the list', () => { + const document = parsePslDocument({ + schema: `model Thing { + id Int @id + email String + @@index([email, email]) +} +`, + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', + message: 'Model "Thing" @@index list contains duplicate field "email"', + }), + ]), + ); + }); }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts index 608b7fe373..0f3397dc4d 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts @@ -1,4 +1,5 @@ import { parsePslDocument } from '@prisma-next/psl-parser'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { describe, expect, it } from 'vitest'; import { type InterpretPslDocumentToSqlContractInput, @@ -190,6 +191,114 @@ model Comment { }); }); + it('emits sql model with no @id and no @@id', () => { + const document = parsePslDocument({ + schema: `model IdlessThing { + email String @unique + token String +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.storage).toMatchObject({ + tables: { + idlessThing: { + columns: { + email: { codecId: 'pg/text@1', nativeType: 'text' }, + token: { codecId: 'pg/text@1', nativeType: 'text' }, + }, + uniques: [{ columns: ['email'] }], + }, + }, + }); + // `toMatchObject` with `primaryKey: undefined` requires the key to be + // present — assert absence directly via a narrowed accessor instead. + const storage = result.value.storage as SqlStorage; + expect(storage.tables['idlessThing']?.primaryKey).toBeUndefined(); + expect(result.value.models).toMatchObject({ + IdlessThing: { + storage: { + table: 'idlessThing', + fields: { + email: { column: 'email' }, + token: { column: 'token' }, + }, + }, + }, + }); + }); + + it('emits composite model id as primary key', () => { + const document = parsePslDocument({ + schema: `model CompositeThing { + email String + token String + + @@id([email, token]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.storage).toMatchObject({ + tables: { + compositeThing: { + primaryKey: { columns: ['email', 'token'] }, + }, + }, + }); + }); + + it('emits mapped composite model id name and columns', () => { + const document = parsePslDocument({ + schema: `model CompositeThing { + email String @map("email_address") + token String @map("api_token") + + @@id([email, token], map: "composite_thing_pkey") + @@map("composite_thing") +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.storage).toMatchObject({ + tables: { + composite_thing: { + primaryKey: { + columns: ['email_address', 'api_token'], + name: 'composite_thing_pkey', + }, + }, + }, + }); + }); + it('maps enums, named types, defaults, indexes, and foreign keys', () => { const document = parsePslDocument({ schema: `types { @@ -354,4 +463,67 @@ model Member { }, }); }); + + // Round-trip companion to packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.core.test.ts + // The PSL strings below are copied verbatim from the printer's snapshots so + // a drift on either side breaks one of the two suites. Spec: id-less SQL + // tables and composite-PK tables emitted by introspection must round-trip + // through the SQL PSL interpreter. + describe('round-trips printer output', () => { + it('accepts the printer output for an id-less table', () => { + const printed = `// Contract inferred from the live database schema. Edit as needed, then run \`prisma-next contract emit\`. + +// WARNING: This table has no primary key in the database +model AuditLog { + event String + timestamp DateTime + + @@map("audit_log") +} +`; + const document = parsePslDocument({ schema: printed, sourceId: 'schema.prisma' }); + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const storage = result.value.storage as SqlStorage; + expect(storage.tables['audit_log']?.primaryKey).toBeUndefined(); + expect(result.value.models).toMatchObject({ + AuditLog: { storage: { table: 'audit_log' } }, + }); + }); + + it('accepts the printer output for a composite-PK table', () => { + const printed = `// Contract inferred from the live database schema. Edit as needed, then run \`prisma-next contract emit\`. + +model OrderItem { + orderId Int @map("order_id") + productId Int @map("product_id") + quantity Int + + @@id([orderId, productId], map: "order_item_pkey") + @@map("order_item") +} +`; + const document = parsePslDocument({ schema: printed, sourceId: 'schema.prisma' }); + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.storage).toMatchObject({ + tables: { + order_item: { + primaryKey: { + columns: ['order_id', 'product_id'], + name: 'order_item_pkey', + }, + }, + }, + }); + }); + }); }); diff --git a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index c54f9efea9..9d8e7adb05 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts @@ -239,20 +239,6 @@ export function buildSqlContractFromDefinition( } } - if (semanticModel.id) { - const fieldsByColumnName = new Map( - semanticModel.fields.map((field) => [field.columnName, field]), - ); - for (const columnName of semanticModel.id.columns) { - const field = fieldsByColumnName.get(columnName); - if (field?.nullable) { - throw new Error( - `Model "${semanticModel.modelName}" uses nullable field "${field.fieldName}" in its identity.`, - ); - } - } - } - const foreignKeys = (semanticModel.foreignKeys ?? []).map((fk) => { const targetModel = assertKnownTargetModel( modelsByName, diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts index 02e693f1f7..71869db06e 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts @@ -356,6 +356,8 @@ describe('shared contract definition lowering', () => { }, ], }), - ).toThrow('Model "User" uses nullable field "id" in its identity.'); + ).toThrow( + /Contract semantic validation failed:.*primary key column "id".*primary key columns must be NOT NULL/, + ); }); }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts index 90d89294d7..a57817bf54 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.test.ts @@ -450,6 +450,52 @@ describe('contract DSL authoring surface', () => { }); }); + it('rejects duplicate identity columns from .attributes(...) through storage semantics', () => { + const User = model('User', { + fields: { + id: field.column(textColumn), + }, + }) + .attributes(({ fields, constraints }) => ({ + id: constraints.id([fields.id, fields.id]), + })) + .sql({ table: 'app_user' }); + + expect(() => + defineTestContract({ + models: { + User, + }, + }), + ).toThrow(/Contract semantic validation failed:.*primary key.*duplicate column "id"/); + }); + + it('rejects duplicate unique and index columns through storage semantics', () => { + const User = model('User', { + fields: { + id: field.column(textColumn), + email: field.column(textColumn), + }, + }) + .attributes(({ fields, constraints }) => ({ + uniques: [constraints.unique([fields.email, fields.email])], + })) + .sql(({ cols, constraints }) => ({ + table: 'app_user', + indexes: [constraints.index([cols.email, cols.email])], + })); + + expect(() => + defineTestContract({ + models: { + User, + }, + }), + ).toThrow( + /Contract semantic validation failed:.*unique constraint.*duplicate column "email".*index.*duplicate column "email"/, + ); + }); + it.each([ { label: 'hasMany', diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index d0db6a81d1..653251e959 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -77,10 +77,6 @@ export const sqlEmission = { const table: StorageTable | undefined = storage.tables[tableName]; assertDefined(table, `Model "${modelName}" references non-existent table "${tableName}"`); - if (!table.primaryKey) { - throw new Error(`Model "${modelName}" table "${tableName}" is missing a primary key`); - } - const columnNames = new Set(Object.keys(table.columns)); const storageFields = model.storage.fields; if (!storageFields || Object.keys(storageFields).length === 0) { diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.structure.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.structure.test.ts index 07f7dae8b3..479c649662 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.structure.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.structure.test.ts @@ -190,18 +190,20 @@ describe('sql-target-family-hook', () => { }).toThrow('references non-existent table'); }); - it('validates structure with model table missing primary key', () => { + it('validates structure with model table without primary key', () => { const ir = createContract({ models: { User: { - storage: { table: 'user', fields: {} }, + storage: { table: 'user', fields: { email: { column: 'email' } } }, relations: {}, }, }, storage: { tables: { user: { - columns: {}, + columns: { + email: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + }, uniques: [], indexes: [], foreignKeys: [], @@ -212,7 +214,7 @@ describe('sql-target-family-hook', () => { expect(() => { sqlEmission.validateStructure(ir); - }).toThrow('is missing a primary key'); + }).not.toThrow(); }); it('validates structure with model field referencing non-existent column', () => { diff --git a/projects/created-updated-at-authoring/plan.md b/projects/created-updated-at-authoring/plan.md new file mode 100644 index 0000000000..ff6e3b41ee --- /dev/null +++ b/projects/created-updated-at-authoring/plan.md @@ -0,0 +1,170 @@ +# Created/Updated Timestamp Authoring Plan + +## Summary + +Add Prisma-style timestamp defaults across Postgres and SQLite via a **PSL field-preset registry path** that mirrors the existing TypeScript field-preset path. Success means PSL `temporal.createdAt()` / `temporal.updatedAt()` and TS `field.temporal.createdAt()` / `field.temporal.updatedAt()` lower to the existing SQL storage-default and execution-mutation-default IR with target-owned timestamp generators — and the `@updatedAt` attribute that landed earlier on this branch is removed in favor of the preset path. + +**Spec:** `projects/created-updated-at-authoring/spec.md` + +## What Changed (and Why) + +The first three milestones (now consolidated into "Inherited Foundation" below) delivered `@updatedAt` as a Prisma-flavored PSL **attribute** with codec-applicability validation (`packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts:222`). wmadden flagged that the right shape — already used in PSL for `pgvector.Vector(1536)` — is a **namespaced field-position call** where the codec is implicit in the preset. That eliminates the need to validate where attributes belong on a per-codec basis. + +The contract IR, the `timestampNow` generator, the runtime application path, and the ORM-client cross-rows stability work all carry through unchanged. **What changes is the user-facing PSL surface and the TS preset namespace.** + +## Collaborators + +| Role | Person/Team | Context | +| ------------ | ----------------------------------- | ------------------------------------------------------------------------ | +| Maker | jkomyno | Implements the PSL field-preset dispatch path and the registry move | +| Reviewer | wmadden | Originator of the field-preset feedback; signs off on PSL surface shape | +| Reviewer | SQL authoring/runtime reviewer | Reviews PSL lowering parity, TS namespace move, runtime non-regression | +| Collaborator | Target adapters owner | Confirms Postgres + SQLite preset re-registration and codec assignments | + +## Inherited Foundation + +Branch `feat/created-updated-at-authoring` already contains the IR plumbing, runtime, adapters, ORM-client wiring, and cross-rows stability work that this pivot builds on. None of it changes. The relevant pre-pivot commits and the load-bearing pieces: + +- **IR & runtime.** `ExecutionMutationDefault` with `onCreate`/`onUpdate` phases; `MutationDefaultGeneratorDescriptor` with `applicableCodecIds` and `buildPhases`; runtime `applyMutationDefaults` with empty-update skip and explicit-value-skip; per-target Postgres + SQLite mutation-default generator descriptors. +- **Family-shared timestamp generator.** `timestampNowControlDescriptor` and `timestampNowRuntimeGenerator` (`packages/2-sql/9-family/src/core/timestamp-now-generator.ts`), with `stableAcrossRows: true` for cross-row stability in bulk inserts. +- **ORM-client wiring.** `Collection.update*`, `Collection.upsert`, and `updateFirstGraph` nested update branch all invoke `applyMutationDefaults({ op: 'update' })`. `Collection.createAll` allocates one `acrossRowsCache` per bulk insert. Bulk-update path also uses `acrossRowsCache` for `op: 'update'`. +- **Pre-pivot commits.** `9b27e0847` (migration ref routing), `4310a6a70` (initial spec/plan), `682714eee` (initial `@updatedAt` attribute), `ece4e185c` (review fixes), `40462aa91` (executionDefault → executionDefaults rename), `0d45548da` (validation consolidation), `89e15f591` (mutation-default validation at context creation), `40d67af63` (timestampNow centralization), `146242c1a` (ORM update wiring + acrossRowsCache), `14ff4548d` (Milestone 4 docs). + +The pivot supersedes the user-facing PSL/TS surface from `682714eee`+ (the attribute-based `@updatedAt`, flat `field.createdAt()` / `field.updatedAt()`); everything else carries through untouched. + +## Test Design + +| AC | TC | Test Case | Type | Expected Outcome | +| --- | --- | --- | --- | --- | +| AC1 | TC1 | PSL `createdAt temporal.createdAt()` lowers to storage default `now()` | Unit | Storage column has `default: { kind: 'function', expression: 'now()' }`; no execution mutation default emitted | +| AC1 | TC2 | PSL `createdAt DateTime @default(now())` continues to lower identically | Unit | Same contract shape as TC1 | +| AC2 | TC3 | PSL `updatedAt temporal.updatedAt()` lowers to mutation default with `onCreate` + `onUpdate` | Unit | Execution defaults entry references `timestampNow` for both phases; storage column is non-null timestamp | +| AC3 | TC4 | TS `field.temporal.createdAt()` / `field.temporal.updatedAt()` produce contracts byte-equivalent to their PSL counterparts | Unit | After deterministic sorting by a single shared comparator helper, storage tables and execution defaults are identical for Postgres and SQLite | +| AC4 | TC5a | PSL `@updatedAt` produces `PSL_UNKNOWN_ATTRIBUTE` with a targeted hint pointing at `temporal.updatedAt()` | Unit | Stable diagnostic code, span on the offending attribute, message includes literal `temporal.updatedAt()` suggestion, no mention of "timestamp-compatible" | +| AC4 | TC5b | PSL `f temporal.updatedAt() @updatedAt` (already-migrated field with stale attribute) | Unit | Hint is suppressed; only the bare `PSL_UNKNOWN_ATTRIBUTE` diagnostic fires | +| AC5a | TC6a | PSL `temporal.updatedAt(123)` | Unit | `PSL_INVALID_ATTRIBUTE_ARGUMENT` with span on the offending argument (shared code with type-constructor arg errors; honest rename deferred — see RD11) | +| AC5b | TC6b | PSL `temporal.foo()` | Unit | `PSL_UNKNOWN_FIELD_PRESET` with span on the preset name | +| AC5c | TC6c | PSL `weather.updatedAt()` (unknown namespace) | Unit | `PSL_EXTENSION_NAMESPACE_NOT_COMPOSED` | +| AC5d | TC6d | PSL `updatedAt temporal.updatedAt() @default(now())` | Unit | `PSL_PRESET_AND_DEFAULT_CONFLICT` (new code, distinct from `PSL_INVALID_DEFAULT_APPLICABILITY`) | +| AC5e | TC6e | PSL `updatedAt temporal.updatedAt()?` | Unit | `PSL_PRESET_NOT_OPTIONAL` (new code) | +| AC5f | TC6f | PSL `updatedAt temporal.updatedAt()[]` | Unit | `PSL_PRESET_NOT_LIST` (new code) | +| AC5g | TC6g | PSL `f DateTime @default(temporal.updatedAt())` | Unit | `PSL_INVALID_DEFAULT_EXPRESSION` (or closest existing stable code) | +| AC5h | TC6h | PSL `id temporal.updatedAt() @id` | Unit | Hard error, stable code; preset's id semantics conflict with bare `@id` | +| AC5i | TC6i | PSL `f temporal.updatedAt() temporal.createdAt()` | Unit | Parse-time or resolution-time error (regression test for double preset on one field) | +| AC6 | TC7 | PSL field-preset dispatch is generic: a synthetic `testns.exampleField()` registered in test fixtures resolves through the same path | Unit | Synthetic preset emits the descriptor's contract contributions; no `temporal`-specific code path is required | +| AC6a | TC7a | Compose-time collision check across registries: same path registered in both `authoringContributions.field` and `authoringContributions.type` | Unit | Composition throws a deterministic error naming the colliding path; PSL resolution is never reached | +| AC6a | TC7b | Compose-time within-registry duplicate-name guard regression: same path registered twice within the field registry | Unit | Existing `composeFieldNamespace` duplicate-name guard fires | +| AC7 | TC8 | SQLite PSL `temporal.updatedAt()` + TS `field.temporal.updatedAt()` parity | Unit | SQLite-native codecs/defaults, byte-equivalent contracts | +| AC8 | TC9 | Examples and templates use the preset surface | Manual | `examples/**/contract.{ts,prisma}` and the CLI init template emit the preset surface, build cleanly | +| AC9 | TC10 | Docs reference the new vocabulary in one canonical location | Manual | `docs/products/psl/README.md` is the canonical reference; package READMEs link to it with short summaries | + +**CI gates (not ACs).** + +- `pnpm -F @prisma-next/sql-runtime test` — inherited runtime tests (cross-rows stability, empty-update skip, explicit-value-skip, validation at context creation) continue to pass. +- `pnpm -F @prisma-next/sql-orm-client test` — inherited ORM-client tests (`create` / `createAll` / `updateAll` / `updateCount` / `upsert` / nested `updateFirstGraph`) continue to pass, including bulk-update cross-row-stability for `op: 'update'`. + +## Milestone: Pivot to Field-Preset PSL Surface + +Add a generic field-preset dispatch path to PSL, re-namespace the existing `createdAt`/`updatedAt` presets under `temporal.*`, delete the `@updatedAt` attribute path, update fixtures/examples/docs, and close out the project. Single milestone because the dispatch mechanism, the registry move, and the attribute deletion are tightly coupled — the dispatch is dead code without the registry move; the attribute deletion can't safely land before the preset surface exists. + +### Phase A: PSL field-preset dispatch path (mechanism) + +Add the dispatch *mechanism* as a generic, registry-driven walker. No user-visible surface change yet; existing `field.createdAt()` / `field.updatedAt()` flat-named registrations continue to work for TS via this same path. + +- [ ] Add `getAuthoringFieldPreset(contributions, path)` symmetric with `getAuthoringTypeConstructor` in `packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts:54-67`. Walk `contributions?.field` segment-by-segment; return the descriptor or `undefined`. Satisfies TC1, TC3. +- [ ] Add `resolvePslFieldPresetDescriptor` and `instantiatePslFieldPreset` analogous to the existing type-constructor pair (lines 129, 187 of the same file). Reuse `instantiateAuthoringFieldPreset` from `framework-components` for argument binding and contribution resolution. Satisfies TC1, TC3. +- [ ] **Design note: PSL → `instantiateAuthoringFieldPreset` argument coercion.** TS feeds the function typed args; PSL feeds it AST nodes. Add an arg-coercion step in `instantiatePslFieldPreset` that converts AST nodes to the descriptor's declared shape (`number`, `string`, `boolean`, `object`-with-typed-properties); mismatches produce `PSL_INVALID_NAMESPACED_CALL_ARGUMENT`. `instantiateAuthoringFieldPreset` itself stays typed-input-only — TS keeps its zero-runtime-validation cost. Today's `temporal.X()` are arity-zero so the coercion path is exercised only by AC6's synthetic preset; if/when arity-non-zero family presets land in PSL (RD10 follow-up), expand the synthetic to exercise typed args. Satisfies TC1, TC3, TC7. +- [ ] Wire the dispatch into the field-type resolution path in `psl-field-resolution.ts`: field-preset walker runs first; on miss, fall back to the existing type-constructor walker. Satisfies TC1, TC3, TC8. +- [ ] Add a compose-time collision check that rejects any path appearing in both `authoringContributions.field` and `authoringContributions.type` (extend the existing duplicate-name guard in `composeFieldNamespace`, `composed-authoring-helpers.ts:158`). The error message names the colliding path. Satisfies TC7a, TC7b. +- [ ] Extend the namespace gate (`checkUncomposedNamespace`, `psl-column-resolution.ts:84-103`) to exempt `temporal` alongside `db`, `familyId`, and `targetId`. Document the rationale inline (forward-compat with the JS/TS Temporal API). Satisfies TC1, TC3, TC6c, TC8. +- [x] **Diagnostic code work:** + - **Rename `PSL_INVALID_TYPE_CONSTRUCTOR_ARITY` → `PSL_INVALID_NAMESPACED_CALL_ARITY`.** Discovered during Phase A: that code name doesn't exist. Type-constructor arity errors emit `PSL_INVALID_ATTRIBUTE_ARGUMENT`; field-preset arity errors do the same. Honest rename is a wider refactor (touches genuine attribute-arg uses too) and deferred — see RD11. Field-preset arity errors use `PSL_INVALID_ATTRIBUTE_ARGUMENT` for now. + - **Added codes**: `PSL_UNKNOWN_FIELD_PRESET`, `PSL_PRESET_AND_DEFAULT_CONFLICT`, `PSL_PRESET_AND_ID_CONFLICT`, `PSL_PRESET_NOT_OPTIONAL`, `PSL_PRESET_NOT_LIST`. (`PSL_PRESET_AND_UPDATED_AT_CONFLICT` was added in Phase A and removed in Phase C alongside the `@updatedAt` attribute path.) + - **Do not** reuse `PSL_INVALID_DEFAULT_APPLICABILITY` for the preset+`@default` collision — the actual error is "duplicate default," not "not applicable here." Added `PSL_PRESET_AND_DEFAULT_CONFLICT` and used it. Satisfies TC6a–TC6f. +- [ ] Add edge-case validation in the preset resolver: + - Optional preset (`temporal.updatedAt()?`) → `PSL_PRESET_NOT_OPTIONAL`. + - List preset (`temporal.updatedAt()[]`) → `PSL_PRESET_NOT_LIST`. + - Preset + `@id` → hard error. + - Preset + `@default(...)` → `PSL_PRESET_AND_DEFAULT_CONFLICT`. + - Preset call inside `@default(...)` arg position → `PSL_INVALID_DEFAULT_EXPRESSION` (existing code). + - Same field declaring two preset calls → parse-time or resolution-time error. Satisfies TC6d–TC6i. +- [ ] Add a synthetic test fixture (`testns.exampleField()`) in `packages/2-sql/2-authoring/contract-psl/test/fixtures.ts` to prove the dispatch path is generic, not `temporal`-specific. Satisfies TC7. + +### Phase B: Re-namespace presets under `temporal.*` + +Move the registrations from flat names to the `temporal.*` namespace. Both PSL and TS now consume the same namespaced registry entries. + +- [ ] Re-register presets in Postgres at `target.authoring.field.temporal.createdAt` and `target.authoring.field.temporal.updatedAt` (`packages/3-targets/3-targets/postgres/src/core/authoring.ts:92-119`). Delete the flat-named registrations. Satisfies TC1, TC3, TC4. +- [ ] Same for SQLite (`packages/3-targets/3-targets/sqlite/src/core/authoring.ts`). Satisfies TC8. +- [ ] **Consolidation:** the `temporal.{createdAt,updatedAt}` registrations are produced by a single shared helper `temporalAuthoringPresets({ codecId, nativeType })` exported from `@prisma-next/family-sql/control` (`packages/2-sql/9-family/src/core/timestamp-now-generator.ts`). Postgres and SQLite each pass their own `codecId` / `nativeType` pair; the helper owns the rest of the descriptor. This makes byte-equivalence between targets structural and prevents per-target drift if a third SQL target lands. + +### Phase C: Remove the `@updatedAt` attribute path + +Delete the attribute-based path that's now superseded by the preset surface, plus add the targeted migration hint. + +- [ ] Delete the `@updatedAt` attribute path: + - Remove `'updatedAt'` from `BUILTIN_FIELD_ATTRIBUTE_NAMES` (`psl-field-resolution.ts:64-71`). + - Delete `reportInvalidUpdatedAt` (lines 143–157), `rejectUpdatedAtOnNonScalar` (lines 159–170), and `lowerUpdatedAtAttribute` (lines 172–241). + - Delete the `getAttribute(field.attributes, 'updatedAt')` branch in `collectResolvedFields` (lines 265–305). + - Delete the `@updatedAt` lowering merge in lines 364–376 and 418–428. +- [ ] Add a targeted migration hint to the unknown-attribute diagnostic: a hardcoded `if (name === 'updatedAt')` branch in the diagnostic emitter that appends "Use `temporal.updatedAt()` instead." to the message. The diagnostic code stays `PSL_UNKNOWN_ATTRIBUTE`; only the message text changes. **Suppression:** if the field already declares any `temporal.*` preset, the hint is omitted (don't tell users to do what they already did). Tests cover both presence (TC5a) and suppression (TC5b). +- [ ] Diagnostic-code inventory: for each diagnostic code emitted from the deleted attribute path (`PSL_INVALID_ATTRIBUTE_ARGUMENT`, `PSL_INVALID_DEFAULT_APPLICABILITY`, plus any branch-local codes introduced for `@updatedAt`), run `grep -rn 'CODE_NAME' --include="*.ts" | grep -v node_modules | grep -v dist`. Zero references after deletion → delete the code's constant; non-zero → leave it alone. Paper analysis expects no orphans; the inventory verifies rather than assumes. Mechanical: no judgment calls. + +### Phase D: Update fixtures, examples, templates, and docs + +- [ ] Update PSL test fixtures and tests: + - `packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts` — replace the attribute-based `@updatedAt` cases (lines 228–377) with preset-based `temporal.updatedAt()` cases. Keep at least one negative test for `@updatedAt` producing `PSL_UNKNOWN_ATTRIBUTE` with the targeted hint, plus one for hint suppression. + - `packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts` (lines 156–207, 246–251, 432–467) — port to `temporal.updatedAt()` ↔ `field.temporal.updatedAt()`. Use a single shared deterministic-sort comparator helper for both targets. Satisfies TC3, TC4, TC5a, TC5b, TC8. +- [ ] Update TS test fixtures: + - `packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts:32-47, 230-231, 289-332, 602` — switch to `field.temporal.createdAt()` / `field.temporal.updatedAt()`. + - `packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts:242-369` — same. Satisfies TC4. +- [ ] Update examples and templates: + - `examples/react-router-demo/prisma/contract.ts` lines 24, 33 (TS) and `contract.prisma` line 4 (PSL). + - `examples/prisma-next-demo/prisma/contract.ts` lines 32, 43. + - `packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts` lines 116, 125, 164, 177. Satisfies TC9. +- [ ] Update documentation in **one canonical location**: + - `docs/products/psl/README.md` becomes the canonical reference. Lists `temporal.createdAt()`, `temporal.updatedAt()`, `@default(now())`. Documents the namespace exemption, the field-preset dispatch path, and the `@updatedAt` removal with migration hint. + - `packages/2-sql/2-authoring/contract-psl/README.md` lines 18, 69–70 — replace attribute documentation with a short summary linking to the canonical doc. + - `packages/2-sql/2-authoring/contract-ts/README.md` lines 19, 112, 139–140, 197–198 and `API.md` lines 20–21 — short summary linking to the canonical doc; reference `field.temporal.*`. Satisfies TC10. + +### Phase E: Close-out + +- [ ] Verify all 9 acceptance criteria with focused commands listed in the validation gate. +- [ ] Strip repo-wide references to `projects/created-updated-at-authoring/**` (search + remove from any committed READMEs, contributor docs, or PR templates that reference this transient path) and delete the project directory. + +### Validation gate + +- `pnpm -F @prisma-next/sql-contract-psl test` +- `pnpm -F @prisma-next/sql-contract-ts test` +- `pnpm -F @prisma-next/framework-components test` +- `pnpm -F @prisma-next/sql-runtime test` +- `pnpm -F @prisma-next/sql-orm-client test` +- `pnpm -F @prisma-next/adapter-postgres test` +- `pnpm -F @prisma-next/adapter-sqlite test` +- `pnpm test:packages` +- `pnpm lint:deps` +- `pnpm build` + +## Resolved Decisions + +- The internal timestamp generator ID is `timestampNow`. (Inherited.) +- Empty update payloads skip all `onUpdate` execution defaults. (Inherited.) +- Cross-row stability uses `RuntimeMutationDefaultGenerator.stableAcrossRows` + `MutationDefaultsOptions.acrossRowsCache`. (Inherited.) +- The PSL `@updatedAt` attribute is **removed**, not retained for back-compat. The unknown-attribute diagnostic gains a targeted hint pointing at `temporal.updatedAt()` whenever it fires for `@updatedAt`. The hint is a **hardcoded `if`-branch** in the diagnostic emitter — no extensible map. The hint is suppressed when the field already declares any `temporal.*` preset. +- The TS surface re-namespaces from `field.{createdAt,updatedAt}` to `field.temporal.{createdAt,updatedAt}`. PSL and TS share **leaf names**, not full paths — this is parallel-leaf-naming, not symmetric path-naming. The cost (one extra TS nesting level) is paid for registry-path parity (single shared comparator helper, eliminated drift class). +- `@default(now())` is **kept** as a parallel, equivalent way to express create-time timestamps. `temporal.createdAt()` is added for symmetry only. +- A `temporal` namespace exemption is added to the PSL namespace gate (alongside `db`, `familyId`, `targetId`) to mark it as a curated SQL-family-shared namespace. The name is chosen for forward-compatibility with the JS/TS Temporal API. +- The PSL field-preset dispatch path is built **generic from day one** (a `getAuthoringFieldPreset` walker), with AC6 explicitly testing genericness via a synthetic test-only preset. +- Field presets resolve **before** type constructors at runtime; presets carry richer semantics (storage + execution defaults + id/unique flags + native type) so the more complete answer wins on ambiguity. Backed by a compose-time collision check that rejects any path appearing in both registries — runtime collisions are made structurally impossible. +- Family-level ID presets (`id.uuidv7`, etc.) are **not** exposed in PSL during this project. The dispatch path supports them, but PSL exposure is a focused follow-up project (~1 day after the dispatch path lands) that can address its own design questions (namespace exemption for `id`, flat-name PSL syntax for bare `uuidv7()`). +- Diagnostic codes touched by the attribute path get a mechanical inventory at the end of Phase C, not aggressive cleanup. Codes used solely by the deleted code path are removed; codes still referenced by the preset path or other attributes stay. Expected outcome: no orphans (`PSL_INVALID_ATTRIBUTE_ARGUMENT` and `PSL_INVALID_DEFAULT_APPLICABILITY` both remain in use), but the inventory verifies rather than assumes. +- Diagnostic codes for namespaced-call arg errors: the planned rename `PSL_INVALID_TYPE_CONSTRUCTOR_ARITY` → `PSL_INVALID_NAMESPACED_CALL_ARITY` was based on a wrong assumption (that code doesn't exist). Reality: type-constructor and field-preset arg errors both emit `PSL_INVALID_ATTRIBUTE_ARGUMENT`. The honest rename to a `PSL_INVALID_NAMESPACED_CALL_ARGUMENT` code would also touch genuine attribute-arg errors and is deferred to a follow-up. New codes that *were* added in this project: `PSL_UNKNOWN_FIELD_PRESET`, `PSL_PRESET_AND_DEFAULT_CONFLICT`, `PSL_PRESET_AND_ID_CONFLICT`, `PSL_PRESET_AND_UPDATED_AT_CONFLICT` (transient — removed in Phase C), `PSL_PRESET_NOT_OPTIONAL`, `PSL_PRESET_NOT_LIST`. Reusing the misleading `PSL_INVALID_DEFAULT_APPLICABILITY` for the preset+`@default` collision is rejected — it's exactly the accumulated-debt pattern the original `@updatedAt`-applicability check exemplified. +- PSL → `instantiateAuthoringFieldPreset` argument coercion runs in the PSL-side `instantiatePslFieldPreset`, not inside the framework-components function. TS keeps its zero-runtime-validation cost; PSL pays the coercion cost only for namespaced-call args. +- Edge cases for preset usage (optional, list, preset+`@id`, preset+`@default`, preset-in-default-arg, double preset on one field) all produce hard errors with stable codes. The preset is a complete field-type declaration; combinations that contradict that are rejected at PSL resolution time. +- **`temporal.updatedAt()` semantics: "last modified time", not "last update time".** The preset registers **both** `onCreate` and `onUpdate` to `timestampNow` and the column is **non-null**. On insert, `updatedAt` equals `createdAt`; on update, it advances. PSL rejects `temporal.updatedAt()?` with `PSL_PRESET_NOT_OPTIONAL`; TS rejects nullable + any `executionDefaults` at contract-build time (`build-contract.ts`). **Rationale:** mirrors Prisma 6 / Rails / Django conventions and preserves the `updatedAt >= createdAt` invariant so `ORDER BY updated_at DESC` works for fresh records too. **Trade-off considered and rejected:** an alternative semantic ("`updatedAt` = last *update*, NULL until first update, nullable column") is more semantically pure but diverges from Prisma 6 and forces query-side `COALESCE(updated_at, created_at)` for activity-sort use cases. The contract IR + runtime can support this alternative shape if a future preset (e.g. `temporal.lastModifiedAt()` with `onUpdate`-only and nullable column) is added, but the corresponding `nullable + onUpdate-only` allowance is **not** speculatively built into `build-contract.ts` — that PR will introduce it alongside a real production user, not as a hook. +- **YAGNI cuts at PR-close.** Forward-compat hooks for hypothetical future generators were removed from the runtime and contract-builder before merge: (1) the `applyOnEmptyUpdate` opt-in on `RuntimeMutationDefaultGenerator` (only ever exercised by a test of itself; empty-update skip is now unconditional in `applyMutationDefaults`), and (2) the `nullable + onUpdate-only` allowance in `build-contract.ts` (dead code w.r.t. `temporal.updatedAt()`; the check is now "nullable + any executionDefaults = error"). Both can be re-added when a real generator/preset needs them, with a test exercising real production code instead of test-only code. +- **AC9 / FR10 canonical-doc clause overtaken by upstream deletion.** Spec FR10 + AC9 named `docs/products/psl/README.md` as the canonical PSL doc carrying the temporal-preset writeup. After this branch was opened, that file was deleted on `main` in commit `b5c3381da` ("docs(planning): elevate PSL authoring to a top-level May workstream") as "significantly stale, unreferenced, and actively misleading." On merge from main, the deletion is accepted: `docs/products/psl/README.md` is gone. The temporal-preset writeup survives in the package READMEs (`packages/2-sql/2-authoring/contract-psl/README.md`, `packages/2-sql/2-authoring/contract-ts/{README.md,API.md}`), which no longer link to the deleted canonical doc. A fresh canonical PSL doc is owned by the May WS5 (PSL authoring) workstream and is out of scope here. + +## Open Questions + +_All questions raised during planning have been resolved. Future questions discovered during implementation should be tracked here and reviewed at milestone gates._ diff --git a/projects/created-updated-at-authoring/spec.md b/projects/created-updated-at-authoring/spec.md new file mode 100644 index 0000000000..1ac5218c95 --- /dev/null +++ b/projects/created-updated-at-authoring/spec.md @@ -0,0 +1,233 @@ +# Summary + +Add first-class create/update timestamp defaults to the SQL authoring surfaces via a **field-preset registry path in PSL**, mirroring the existing TypeScript field-preset mechanism. SQL PSL exposes `temporal.createdAt()` and `temporal.updatedAt()` as namespaced field-type expressions (analogous to `pgvector.Vector(1536)`); SQL TypeScript exposes `field.temporal.createdAt()` and `field.temporal.updatedAt()` from the same registry. The Prisma-flavored `@updatedAt` attribute path that landed earlier on this branch is removed in favor of the preset path. The contract IR, runtime, and adapter wiring are unchanged. + +# Context + +## Vocabulary + +Two registry concepts appear throughout this document: + +- **Field preset.** A registered field-type call expression that contributes any combination of `default` (storage default), `executionDefaults` (mutation defaults — `onCreate`/`onUpdate`), `id` (id-flag), `unique` (unique-flag), and a codec/native-type pair. Today resolved by `instantiateAuthoringFieldPreset` in `framework-authoring.ts:382-409`. +- **Type constructor.** A registered field-type call expression that contributes only `codecId` / `nativeType` / `typeParams`. Used for things like `pgvector.Vector(1536)` and scalar aliases. Today resolved by `instantiatePslTypeConstructor` in `psl-column-resolution.ts:129`. + +Field presets are strictly richer; type constructors are a subset of what a preset can express. + +## At a glance + +Prisma users expect timestamp fields to be declarative, not hand-written at every mutation call site. Prisma Next now expresses this via a typed preset call — the preset names its own codec, so there is no opportunity for the surface to combine the helper with a non-timestamp scalar. + +```prisma +// schema.prisma +model User { + id Int @id @default(autoincrement()) + email String + createdAt temporal.createdAt() + updatedAt temporal.updatedAt() +} +``` + +```ts +// schema.ts +const User = model('User', { + fields: { + email: field.text(), + createdAt: field.temporal.createdAt(), + updatedAt: field.temporal.updatedAt(), + }, +}); +``` + +`temporal.createdAt()` lowers to the same storage column default as `@default(now())`, so the database owns the create-time value. `temporal.updatedAt()` lowers to `contract.execution.mutations.defaults` with both `onCreate` and `onUpdate`, so Prisma Next fills the timestamp on insert and on non-empty update payloads when the caller does not provide an explicit value. `@default(now())` continues to work as a back-compat alternative to `temporal.createdAt()`. + +## Problem + +The first three milestones of this project shipped `@updatedAt` as a Prisma-flavored PSL attribute, validated by a codec-applicability check (`packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts:222`): + +```ts +if (!generatorDescriptor.applicableCodecIds.includes(input.descriptor.codecId)) { + // emit "@updatedAt requires a timestamp-compatible field, received codecId X" +} +``` + +That check exists because `@updatedAt` is **type-blind**: it is syntactically legal on `String`, `Int`, or any other scalar, and only the codec-applicability lookup rejects it. Every PSL attribute that wraps a generator is condemned to invent a similar guard, and each such guard couples PSL to specific codec IDs from each target adapter. + +wmadden flagged that the right shape — already used in PSL for `pgvector.Vector(1536)` — is a **namespaced field-position call**, where the call carries the codec inside its `output` descriptor. There is no opportunity for the user to pair the helper with the wrong scalar, because the preset *is* the scalar declaration. Validation of "is this codec a timestamp" downgrades from a user-error diagnostic to a registry-coherence assertion run once at composition time. + +The TypeScript authoring surface already has the registry shape this pivot needs: + +- `AuthoringFieldPresetDescriptor` carries `output` (codecId/nativeType), `executionDefaults`, `default`, etc. (`packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts:74-141`). +- Postgres and SQLite each register `createdAt` and `updatedAt` field presets at `target.authoring.field.{createdAt,updatedAt}` with executionDefaults wired to `timestampNow` (`packages/3-targets/3-targets/postgres/src/core/authoring.ts:92-119`, equivalent SQLite file). +- TS composition turns those into callable `field.createdAt()` / `field.updatedAt()` helpers (`packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts:187-198`). + +The missing piece is **PSL never reads `authoringContributions.field`**. The PSL interpreter walks `authoringContributions.type` for type-constructor calls (`getAuthoringTypeConstructor`, `packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts:54-67`) but has no symmetric `getAuthoringFieldPreset` walker. As a result, the only authoring surface available in PSL today for execution-default fields is the attribute path, with all the codec-validation costs that implies. + +## Approach + +Make PSL learn the field-preset registry the same way it already learns the type-constructor registry, then re-route timestamp authoring through it. + +1. **Add a PSL field-preset dispatch path.** When the parser encounters a namespaced call expression in field-type position (`temporal.updatedAt()`), the interpreter resolves it against `authoringContributions.field` first; if no field preset matches, fall back to the existing type-constructor resolution. Resolution returns the same `default` / `executionDefaults` / `id` / `unique` contributions that TS already produces via `instantiateAuthoringFieldPreset`. + +2. **Re-namespace the existing presets under `temporal`.** Move `createdAt` / `updatedAt` from `target.authoring.field.{createdAt,updatedAt}` to `target.authoring.field.temporal.{createdAt,updatedAt}`. PSL invokes them as `temporal.updatedAt()`; TS invokes them as `field.temporal.updatedAt()`. Both consume the same registry entry, so byte-equivalence between PSL and TS is preserved by construction. The name `temporal` is chosen for forward-compatibility with the JS/TS [Temporal API](https://tc39.es/proposal-temporal/docs/), which Prisma Next's date codecs are expected to migrate toward; `temporal.*` is therefore the natural namespace for any future date/time helpers (`temporal.now()`, `temporal.duration()`, etc.). + +3. **Add a `temporal` namespace exemption** to PSL's namespace gate (`checkUncomposedNamespace`, `psl-column-resolution.ts:84-103`), alongside the existing `db` / `familyId` / `targetId` exemptions. `temporal` is a curated SQL-family-shared namespace; it does not require explicit composition by the user. + +4. **Remove the `@updatedAt` attribute path.** Drop `'updatedAt'` from `BUILTIN_FIELD_ATTRIBUTE_NAMES`; delete `lowerUpdatedAtAttribute`, `reportInvalidUpdatedAt`, `rejectUpdatedAtOnNonScalar`, and the relation-field updatedAt rejection branch in `collectResolvedFields`. With the attribute name removed, `@updatedAt` produces a `PSL_UNKNOWN_ATTRIBUTE` diagnostic enhanced with a targeted migration hint pointing at `temporal.updatedAt()`. This is non-breaking because the attribute was added on this same branch and has never shipped. + +5. **Keep `@default(now())` as the create-time storage-default path.** `temporal.createdAt()` is added for symmetry, but the project does not deprecate `@default(now())` — it remains the spelling that mirrors Prisma 6 PSL. + +6. **Do not change the contract IR, the runtime, the adapter mutation-default generators, or the ORM-client cross-row stability work.** `ExecutionMutationDefault`, `MutationDefaultGeneratorDescriptor`, `RuntimeMutationDefaultGenerator.stableAcrossRows`, and `MutationDefaultsOptions.acrossRowsCache` survive unchanged from the prior milestones. + +The net effect: the PSL parser already tolerates `temporal.updatedAt()` (the type-constructor regex matches dotted identifiers and paren-call arguments — `packages/1-framework/2-authoring/psl-parser/src/parser.ts:482-552`). The pivot adds a second consumer of the AST node it produces, plus the namespace exemption and registry move, plus deletion of the attribute path. No PSL grammar or AST changes are required. + +# Requirements + +## Functional Requirements + +- **FR1.** PSL accepts `temporal.createdAt()` and `temporal.updatedAt()` as field-type expressions on `model` field declarations for SQL targets (Postgres and SQLite). +- **FR2.** `temporal.createdAt()` lowers to a storage column default equivalent to `@default(now())` (`{ kind: 'function', expression: 'now()' }`) and emits no execution mutation default. +- **FR3.** `temporal.updatedAt()` lowers to an execution mutation default with both `onCreate` and `onUpdate` set to the target-provided `timestampNow` generator, and to a non-null timestamp storage column. +- **FR4.** SQL TypeScript authoring exposes `field.temporal.createdAt()` and `field.temporal.updatedAt()` from the same registry entries the PSL surface consumes; equivalent PSL and TS models emit byte-equivalent contracts after deterministic sorting (verified via a single shared comparator helper used by both Postgres and SQLite parity tests). +- **FR5.** PSL no longer recognizes `@updatedAt` as a known attribute. References to it produce the standard `PSL_UNSUPPORTED_FIELD_ATTRIBUTE` diagnostic, enhanced with a targeted migration hint ("Use `temporal.updatedAt()` as a field-preset call instead."). The hint is implemented as a hardcoded branch in `getRemovedAttributeHint()` — not an extensible map. The hint is suppressed when the field already declares any `temporal.*` preset, to avoid telling users to do what they already did. *(Note: an earlier draft of this requirement called for `PSL_UNKNOWN_ATTRIBUTE`; that code name doesn't exist — the actual code is `PSL_UNSUPPORTED_FIELD_ATTRIBUTE`. Phase C uses the existing code with the targeted hint appended to its message.)* +- **FR6.** PSL gains a generic field-preset dispatch path that walks `authoringContributions.field` symmetrically with the existing `authoringContributions.type` walker. The path is registry-driven, not hardcoded to `temporal.*`. At runtime, field-preset resolution runs first; if no field preset matches the path, resolution falls back to the type-constructor walker. +- **FR6a.** Composition-time collision check: when authoring contributions are composed, the framework rejects any path that appears in both `authoringContributions.field` and `authoringContributions.type` with a clear "ambiguous registry path" error. This makes runtime collisions structurally impossible and surfaces registry-author mistakes at registration time, not at field-resolution time. +- **FR7.** PSL invalid-usage diagnostics for `temporal.*` calls (and any field-preset call) derive from preset-instantiation rules and PSL surface rules only; there is no codec-applicability diagnostic on the authoring path. The complete set, with implemented diagnostic codes: + - **Wrong arity / wrong-typed arg** (`temporal.updatedAt(123)`) → `PSL_INVALID_ATTRIBUTE_ARGUMENT`. This is the same code type-constructor arity errors use today; the name is historically misleading (it names the attribute case, but covers any namespaced-call arg failure). Renaming to a `PSL_INVALID_NAMESPACED_CALL_ARGUMENT` code would also touch genuine attribute-arg errors and is deferred to a follow-up so this project's scope stays focused. + - **Unknown preset name within a known curated namespace** (`temporal.foo()`) → `PSL_UNKNOWN_FIELD_PRESET` (new code). Curated namespaces are reserved for field presets, so a miss is a typo, not a request to look elsewhere. + - **Unknown namespace, not extension-composed** (`weather.updatedAt()`) → existing `PSL_EXTENSION_NAMESPACE_NOT_COMPOSED`. + - **Preset call combined with `@default(...)` on the same field** (`updatedAt temporal.updatedAt() @default(now())`) → `PSL_PRESET_AND_DEFAULT_CONFLICT` (new code, separate from `PSL_INVALID_DEFAULT_APPLICABILITY` because the actual error is duplicate-default, not applicability). + - **Preset call on an optional field** (`updatedAt temporal.updatedAt()?`) → `PSL_PRESET_NOT_OPTIONAL` (new code). The preset's whole point is that the system owns the value; optional contradicts that. + - **Preset call on a list field** (`updatedAt temporal.updatedAt()[]`) → `PSL_PRESET_NOT_LIST` (new code). + - **Preset call combined with `@id` when the preset doesn't itself contribute id semantics** (`id temporal.updatedAt() @id`) → `PSL_PRESET_AND_ID_CONFLICT` (new code). Presets express id semantics via the descriptor's `id` flag. + - **Preset call combined with `@updatedAt`** → after Phase C, `@updatedAt` is no longer a known attribute, so this case produces `PSL_UNSUPPORTED_FIELD_ATTRIBUTE` (the standard unknown-attribute diagnostic). The migration hint is *suppressed* when the field already declares a `temporal.*` preset, so the user isn't told to do what they just did. + - **Preset call inside `@default(...)` argument position** (`f DateTime @default(temporal.updatedAt())`) → existing `PSL_INVALID_DEFAULT_EXPRESSION` or equivalent. Not yet covered by a focused test; either parser-rejected or default-function-registry-rejected today. + - **Same field declaring two preset calls** (`f temporal.updatedAt() temporal.createdAt()`) → likely already a parse error; covered by an existing parser regression test. +- **FR8.** `@default(now())` continues to work as a create-time storage default on `DateTime` columns, with semantics unchanged. +- **FR9.** Runtime mutation default generators continue to fill omitted `updatedAt` on create and non-empty update; explicit user values still win; empty update payloads still skip all `onUpdate` execution defaults; bulk inserts share one timestamp across rows via `acrossRowsCache`. (Inherited from the prior milestones; see "Inherited Foundation" in `plan.md`.) +- **FR10.** Documentation reflects the new preset-based PSL vocabulary in **one canonical location** (`docs/products/psl/README.md`), with package READMEs (`contract-psl/README.md`, `contract-ts/{README.md,API.md}`) carrying short summaries and links to the canonical doc. + +## Non-Functional Requirements + +- **NFR1.** No new contract IR concept. `ExecutionMutationDefault`, `MutationDefaultGeneratorDescriptor` (with `applicableCodecIds`), and the runtime application code from the inherited foundation survive unchanged. +- **NFR2.** PSL and TS authoring for the same model and target emit byte-equivalent storage and execution sections after deterministic sorting. +- **NFR3.** No codec-type-aware validation on the authoring path. `applicableCodecIds` continues to exist on `MutationDefaultGeneratorDescriptor` but is enforced as a registry-coherence assertion at composition time, not as a user-facing diagnostic. +- **NFR4.** Existing generated ID helpers (`uuidv4`, `uuidv7`, `nanoid`, `cuid2`, `ulid`, `ksuid`) keep their current TS behavior. Once PSL gains the field-preset dispatch path, these become invokable in PSL too (e.g. `id.uuidv7()`), but exposing them is out of scope for this project — the dispatch path must support them without exposing them by default. +- **NFR5.** The `temporal` namespace exemption is the only new exemption; the existing namespace gate continues to require explicit extension composition for non-curated namespaces (`pgvector.*`, etc.). + +## Non-goals + +- Validating which attributes belong to a given codec type — eliminated by construction with the field-preset path. +- Maintaining `@updatedAt` attribute back-compat. The attribute was added on this branch and has never been released; removal is a clean deletion. +- Adding triggers, database-side `updatedAt` magic, or any non-application-side timestamp source. +- Changing the runtime, the ORM-client, the adapter mutation-default generators, or any IR shape (`ExecutionMutationDefault`, `applicableCodecIds`, `stableAcrossRows`, `acrossRowsCache`). +- Inferring timestamp semantics from field names during introspection. +- Exposing the family-level ID helpers (`id.uuidv7`, etc.) as PSL syntax. The dispatch path supports it; the registry does not enable it in this project. +- Changing Prisma 6 ORM compatibility beyond the explicit authoring surfaces described here. `@default(now())` remains the spelling that mirrors Prisma 6. + +# Acceptance Criteria + +- [ ] **AC1.** A PSL model with `createdAt temporal.createdAt()` emits a non-null timestamp column with `default: { kind: 'function', expression: 'now()' }` and no execution mutation default. The equivalent `createdAt DateTime @default(now())` PSL emits the same contract shape. Covers FR2, FR8, NFR2. +- [ ] **AC2.** A PSL model with `updatedAt temporal.updatedAt()` emits a non-null timestamp column and one execution mutation default entry with both `onCreate` and `onUpdate` referencing the `timestampNow` generator. Covers FR1, FR3, NFR1. +- [ ] **AC3.** The equivalent SQL TypeScript model using `field.temporal.createdAt()` and `field.temporal.updatedAt()` emits the same contract shape as the PSL model for Postgres and SQLite. The comparison uses one shared deterministic-sort comparator helper used by both targets' parity tests. Covers FR4, NFR2. +- [x] **AC4.** PSL `@updatedAt` produces `PSL_UNSUPPORTED_FIELD_ATTRIBUTE` enhanced with a targeted migration hint pointing at `temporal.updatedAt()`. The diagnostic message includes the literal preset spelling so users can copy-paste the fix. The diagnostic carries no codec-applicability messaging. The `BUILTIN_FIELD_ATTRIBUTE_NAMES` set no longer contains `'updatedAt'`. **The hint is suppressed when the field already declares any `temporal.*` preset.** Covers FR5. +- [x] **AC5a.** `temporal.updatedAt(123)` produces `PSL_INVALID_ATTRIBUTE_ARGUMENT` with a span on the offending argument. (Code name is historically misleading but matches what type-constructor arity errors emit today; honest rename deferred — see FR7.) Covers FR7 (arity). +- [x] **AC5b.** `temporal.foo()` produces `PSL_UNKNOWN_FIELD_PRESET` with a span on the preset name and a message including the namespace and full path. Covers FR7 (unknown preset). +- [ ] **AC5c.** `weather.updatedAt()` (unknown namespace, not extension-composed) produces `PSL_EXTENSION_NAMESPACE_NOT_COMPOSED`. (Already worked via the existing namespace gate; no focused test added in Phase A.) Covers FR7 (unknown namespace). +- [x] **AC5d.** `updatedAt temporal.updatedAt() @default(now())` produces `PSL_PRESET_AND_DEFAULT_CONFLICT` (new code, distinct from `PSL_INVALID_DEFAULT_APPLICABILITY`). Covers FR7 (double default). +- [x] **AC5e.** `updatedAt temporal.updatedAt()?` produces `PSL_PRESET_NOT_OPTIONAL`. Covers FR7 (optional). +- [ ] **AC5f.** `updatedAt temporal.updatedAt()[]` produces `PSL_PRESET_NOT_LIST`. (Code emits the diagnostic when the parser reaches that branch; verify in Phase B once real `temporal.*` presets are registered. Likely a parser-level reject in practice.) Covers FR7 (list). +- [ ] **AC5g.** `f DateTime @default(temporal.updatedAt())` produces `PSL_INVALID_DEFAULT_EXPRESSION` (or the existing closest stable code). (Untested — exercises the default-function-registry path, not the field-preset dispatch path.) Covers FR7 (preset in default-arg position). +- [x] **AC5h.** `id temporal.updatedAt() @id` produces `PSL_PRESET_AND_ID_CONFLICT` (new code). Covers FR7 (preset + `@id`). +- [ ] **AC5i.** `f temporal.updatedAt() temporal.createdAt()` produces a parse-time or resolution-time error. (Likely already a parse error; verify in Phase B.) Covers FR7 (double preset on one field). +- [x] **AC5j.** ~~`f temporal.updatedAt() @updatedAt` produces `PSL_PRESET_AND_UPDATED_AT_CONFLICT`.~~ **Superseded.** Phase C removed the `@updatedAt` attribute path; the half-migrated case now produces `PSL_UNSUPPORTED_FIELD_ATTRIBUTE` with the migration hint *suppressed* (see AC4) so users who already migrated aren't told to do what they just did. +- [ ] **AC6.** The PSL field-preset dispatch path is generic. A test fixture registers a synthetic field preset under a custom namespace (e.g. `testns.exampleField()`) and confirms PSL resolves it through the same path used by `temporal.*`. Covers FR6. +- [ ] **AC6a.** Composition rejects collisions across registries: a test fixture that registers the same path in both `authoringContributions.field` and `authoringContributions.type` triggers a deterministic error at composition time, not at PSL resolution. The error names the colliding path. A second test fixture registers the same path twice within the field registry and confirms the existing duplicate-name guard still fires (regression). Covers FR6a. +- [ ] **AC7.** SQLite PSL and TypeScript authoring accept the same `temporal.createdAt()` / `temporal.updatedAt()` model as Postgres, emit SQLite-native timestamp codecs/defaults, and the SQL PSL provider remains target-generic. Covers FR1, FR3, FR4, NFR2. +- [ ] **AC8.** Examples and templates updated: + - `examples/react-router-demo/prisma/contract.{ts,prisma}` uses the preset surface. + - `examples/prisma-next-demo/prisma/contract.ts` uses `field.temporal.*`. + - `packages/1-framework/3-tooling/cli/src/commands/init/templates/code-templates.ts` PSL/TS templates use the new preset surface for `updatedAt`. +- [ ] **AC9.** Documentation reflects the preset surface in one canonical location: + - `docs/products/psl/README.md` — canonical reference; lists `temporal.createdAt()`, `temporal.updatedAt()`, and `@default(now())`. Documents the namespace exemption and the field-preset dispatch path. + - `packages/2-sql/2-authoring/contract-psl/README.md` — short summary linking to the canonical doc. + - `packages/2-sql/2-authoring/contract-ts/{README.md,API.md}` — short summary linking to the canonical doc; references `field.temporal.createdAt()` / `field.temporal.updatedAt()` and notes the `@updatedAt` attribute removal. Covers FR10. + +**CI gates (not ACs).** The inherited runtime/ORM-client tests in `packages/2-sql/5-runtime/test/sql-context.test.ts` and `packages/3-extensions/sql-orm-client/test/collection-mutation-defaults.test.ts` continue to pass without behavior changes, including bulk-update cross-row-stability behavior (`acrossRowsCache` for `op: 'update'`). These are CI gates because the pivot does not modify runtime semantics; verifying non-regression is a continuous condition, not a single milestone deliverable. + +# Other Considerations + +## Security + +This feature does not introduce a new data access path. The timestamp generator (unchanged from the inherited foundation) uses local process time and does not read environment variables or database credentials. + +## Cost + +No meaningful runtime cost. The runtime change in the inherited foundation already scans mutation defaults per table; this project leaves the runtime untouched. + +PSL parse cost is negligibly higher: each namespaced field-type call now consults two registries (field presets first, type constructors second). Both are in-memory map lookups. + +## Observability + +No new telemetry. Existing runtime errors for missing mutation default generators continue to fire if a contract references the timestamp generator without a runtime component that provides it. + +The deletion of `@updatedAt` produces a `PSL_UNKNOWN_ATTRIBUTE` diagnostic with a targeted "Use `temporal.updatedAt()` instead" hint — clear, actionable, and self-documenting for users porting Prisma 6 schemas. + +## Data Protection + +Created and updated timestamps are metadata and may still be user-associated data in application contexts. This project does not change retention, masking, or export behavior. + +## Analytics + +No product analytics required. + +## Migration / Compatibility + +- The `@updatedAt` PSL attribute landed on this branch (commit `682714ee`) and is not yet released. Removing it is a clean deletion with no external consumers. Users copying `@updatedAt` from Prisma 6 docs/schemas will see a targeted `PSL_UNKNOWN_ATTRIBUTE` diagnostic suggesting the `temporal.updatedAt()` preset spelling. +- The TS surface `field.createdAt()` / `field.updatedAt()` also landed on this branch and is removed in favor of `field.temporal.createdAt()` / `field.temporal.updatedAt()`. No external consumers. +- Existing test fixtures and example apps on `feat/created-updated-at-authoring` need to be updated to the preset surface (covered by AC8). +- `@default(now())` is unchanged and continues to be supported indefinitely. + +# References + +## Implementation touchpoints + +- PSL parser (already supports namespaced calls): `packages/1-framework/2-authoring/psl-parser/src/parser.ts:482-552`. +- PSL type-constructor walker (model for the new field-preset walker): `packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts:54-67` (`getAuthoringTypeConstructor`). +- PSL namespace gate (where the `temporal` exemption goes): `packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts:84-103` (`checkUncomposedNamespace`). +- PSL `@updatedAt` attribute path to delete: `packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts` lines 64–71 (BUILTIN_FIELD_ATTRIBUTE_NAMES), 143–241 (`reportInvalidUpdatedAt`, `rejectUpdatedAtOnNonScalar`, `lowerUpdatedAtAttribute`), 265–305 (relation-field rejection), 364–376, 418–428 (lowering merge). +- TS composition (already walks `field` namespace): `packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts:187-198`. +- TS field-preset descriptor: `packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts:74-141`, `382-409` (`instantiateAuthoringFieldPreset`). +- Postgres preset registration to re-namespace: `packages/3-targets/3-targets/postgres/src/core/authoring.ts:92-119`. +- SQLite preset registration to re-namespace: `packages/3-targets/3-targets/sqlite/src/core/authoring.ts`. +- Family-level shared timestamp generator (unchanged): `packages/2-sql/9-family/src/core/timestamp-now-generator.ts`. + +## Reference patterns + +- `pgvector.Vector(N)` namespaced field-type call: `packages/1-framework/2-authoring/psl-parser/test/parser.test.ts:118-119, 156, 192-193`. +- pgvector authoring registration: `packages/3-extensions/pgvector/src/core/authoring.ts`. +- Family-level nested field presets (already register `id.uuidv7`, `id.uuidv4`, etc.): `packages/2-sql/9-family/src/core/authoring-field-presets.ts:36-210`. + +## Spec / plan history + +- Original spec milestones (attribute-based `@updatedAt`): commits `4310a6a7`, `682714ee`, `ece4e185`. +- ORM client wiring + cross-rows stability: commits `146242c1`, `14ff4548`. +- Plan: `projects/created-updated-at-authoring/plan.md`. + +# Resolved Decisions + +- **RD1.** The internal timestamp generator ID remains `timestampNow`. (Inherited.) +- **RD2.** Empty update payloads skip all `onUpdate` execution defaults. (Inherited.) +- **RD3.** Bulk-insert timestamp stability is encoded as `RuntimeMutationDefaultGenerator.stableAcrossRows` + `MutationDefaultsOptions.acrossRowsCache`. (Inherited.) +- **RD4.** The PSL `@updatedAt` attribute is **removed**, not retained for back-compat. The pivot is the whole point of this project; keeping the attribute would re-introduce the codec-validation problem the project exists to solve. To soften the migration, `PSL_UNKNOWN_ATTRIBUTE` is enhanced with a targeted hint suggesting `temporal.updatedAt()` whenever the unknown attribute is `@updatedAt`. The hint is implemented as a hardcoded `if`-branch in the diagnostic emitter — no extensible map. The hint is suppressed when the field already declares any `temporal.*` preset, so users who already migrated do not get told to do what they already did. +- **RD5.** The TS surface re-namespaces from `field.{createdAt,updatedAt}` to `field.temporal.{createdAt,updatedAt}`. PSL and TS share **leaf names** (`createdAt`, `updatedAt`) — they do not share full paths. PSL invokes them in field-type position (`temporal.updatedAt()`); TS invokes them under the `field` root (`field.temporal.updatedAt()`). This is parallel-leaf-naming, not symmetric path-naming. The cost: TS gains one nesting level (`field.createdAt()` → `field.temporal.createdAt()`). The benefit: registry-path parity makes fixture-comparator helpers trivial to write — both surfaces consume the exact same `target.authoring.field.temporal.{createdAt,updatedAt}` registry entry, eliminating an entire class of drift bugs between PSL and TS authoring. +- **RD6.** `@default(now())` is **kept** as a parallel, equivalent way to express create-time timestamps. `temporal.createdAt()` is added for symmetry, not as a replacement. Users who want Prisma-6-shaped PSL keep `@default(now())`; users who want full preset symmetry use `temporal.createdAt()`. +- **RD7.** PSL adds a `temporal` exemption to the namespace gate, alongside `db` / `familyId` / `targetId`. The exemption marks `temporal` as a curated SQL-family-shared namespace, not an opt-in extension. The name is chosen for forward-compatibility with the JS/TS Temporal API (date codecs are expected to migrate to Temporal-backed representations). +- **RD8.** The PSL field-preset dispatch path is built **generic from day one**: a `getAuthoringFieldPreset` walker symmetric with the existing `getAuthoringTypeConstructor` walker. AC6 explicitly tests genericness via a synthetic test-only preset, so the path doesn't decay into a `temporal`-specific shortcut. +- **RD9.** Field presets resolve **before** type constructors at runtime: `getAuthoringFieldPreset` runs first, with a fallback to `getAuthoringTypeConstructor` on miss. Rationale: presets carry richer semantics (storage default + execution defaults + id/unique flags + native type) than type constructors (`codecId`/`nativeType`/`typeParams` only), so when ambiguous, the more complete answer wins. Belt-and-suspenders: a compose-time collision check (FR6a, AC6a) makes runtime collisions structurally impossible by rejecting any path registered in both `authoringContributions.field` and `authoringContributions.type` — same pattern `composeFieldNamespace` already uses for within-registry duplicates (`composed-authoring-helpers.ts:158`). +- **RD10.** Family-level ID presets (`id.uuidv7`, `id.uuidv4`, `id.ulid`, `id.nanoid`, `id.cuid2`, `id.ksuid`, plus their flat aliases `field.uuid`, `field.ulid`, etc.) are **not** exposed in PSL during this project, even though M5's dispatch path will technically support them. Rationale: project-scoping discipline (this project is timestamp authoring, not generic PSL preset exposure); the flat-name PSL syntax question deserves its own focused discussion (bare `uuidv7()` collides with the namespace gate's no-dot assumption); and the follow-up cost after the dispatch path lands is small (~1 day: add `id` to the namespace exemption, write PSL tests, update docs). A follow-up issue should be filed once the pivot lands. +- **RD11.** Diagnostic codes for namespaced-call arg errors. The original draft of this RD called for renaming `PSL_INVALID_TYPE_CONSTRUCTOR_ARITY` → `PSL_INVALID_NAMESPACED_CALL_ARITY`. **Reality discovered during Phase A**: that code name doesn't exist. Type-constructor arity errors today emit `PSL_INVALID_ATTRIBUTE_ARGUMENT` (a code shared with genuine attribute-arg errors like `@id(badarg)`). A faithful rename to `PSL_INVALID_NAMESPACED_CALL_ARGUMENT` would also need to disambiguate from attribute uses, which is a wider refactor than this project warrants. **Decision**: field-preset arity/arg errors emit the same `PSL_INVALID_ATTRIBUTE_ARGUMENT` as type-constructor arity errors do today, accepting the historical name. The honest rename is deferred to a follow-up project. New codes that *were* added in this project: `PSL_UNKNOWN_FIELD_PRESET`, `PSL_PRESET_AND_DEFAULT_CONFLICT`, `PSL_PRESET_AND_ID_CONFLICT`, `PSL_PRESET_AND_UPDATED_AT_CONFLICT` (transient — disappears in Phase C with the `@updatedAt` attribute path), `PSL_PRESET_NOT_OPTIONAL`, `PSL_PRESET_NOT_LIST`. The double-default conflict for preset + `@default` deliberately uses `PSL_PRESET_AND_DEFAULT_CONFLICT` rather than reusing `PSL_INVALID_DEFAULT_APPLICABILITY` — the actual error is "duplicate default," not "not applicable here." Reusing the misleading code is exactly the accumulated-debt pattern the original `@updatedAt`-applicability check exemplified; we don't repeat it. +- **RD12.** PSL → `instantiateAuthoringFieldPreset` argument coercion. The TS authoring surface feeds `instantiateAuthoringFieldPreset` arguments that have already passed through TypeScript's static typing. PSL feeds it arguments produced by the parser as untyped AST nodes. To reconcile, the PSL field-preset resolver runs an **arg-coercion step** before invoking `instantiateAuthoringFieldPreset`: each AST argument is coerced to the descriptor's declared shape (`number`, `string`, `boolean`, `object`-with-typed-properties), with mismatches producing a stable `PSL_INVALID_NAMESPACED_CALL_ARGUMENT` diagnostic. The function itself remains typed-input-only — TS keeps its zero-runtime-validation cost. The synthetic preset in AC6 uses arity-zero, so today's tests don't bite this path; a follow-up arity-non-zero synthetic preset should be added once the pattern stabilizes (tracked as an in-implementation discovery, not a project AC). + +# Open Questions + +_All questions raised during planning have been resolved. Future questions discovered during implementation will be tracked in `plan.md` and reviewed at milestone gates._