Skip to content

Commit 682714e

Browse files
committed
feat(sql): add updatedAt timestamp authoring
Adds phase-specific execution defaults to SQL TS authoring so generated IDs stay on-create-only while updatedAt can use both create and update phases. Wires Prisma-compatible @updatedat lowering for Postgres and SQLite PSL, plus field.updatedAt() helpers backed by target-owned timestampNow generators. Empty update payloads skip onUpdate defaults so no-op updates do not advance updatedAt.
1 parent 4310a6a commit 682714e

29 files changed

Lines changed: 896 additions & 73 deletions

docs/products/psl/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Scope: **PSL → SQL Contract IR** via `@prisma-next/psl-parser` and `@prisma-ne
77
## What PSL v1 can do today (high level)
88

99
- **Models, enums, and named types** (`types { ... }`) with deterministic parsing and span-aware diagnostics.
10-
- **SQL storage mapping** for a Postgres-first subset: tables, columns, primary keys, uniques, indexes, and foreign keys.
10+
- **SQL storage mapping** for a SQL subset: tables, columns, primary keys, uniques, indexes, and foreign keys.
1111
- **Defaults** for a curated set of TS-aligned functions and literals, lowered into either storage defaults or execution defaults.
1212
- **Extension-pack parity (minimal)**: namespaced constructor expressions such as `pgvector.Vector(...)` when the corresponding pack is composed in config.
1313

@@ -60,6 +60,10 @@ This is a deliberate “strict subset” choice: PSL v1 is bounded by the curren
6060
- Supported defaults are split into:
6161
- **Storage defaults**: literals, `autoincrement()`, `now()`, `dbgenerated("...")`
6262
- **Execution defaults** (mutation-time generators): `uuid()`, `uuid(4)`, `uuid(7)`, `cuid(2)`, `ulid()`, `nanoid()`, `nanoid(n)`
63+
- Timestamp authoring follows Prisma ORM’s shape for supported SQL targets:
64+
- create timestamps use `DateTime @default(now())`, which remains a storage default owned by the database
65+
- update timestamps use no-argument `DateTime @updatedAt`, which lowers to a `timestampNow` execution default on create and non-empty update mutations
66+
- Prisma Next does not add a PSL `@createdAt` alias.
6367
- **`cuid()` (cuid v1) is explicitly unsupported**; diagnostics guide users to `cuid(2)`.
6468
- **`dbgenerated("...")` is string-literal based** and (in v1) preserves the parsed contents as-is (escape sequences are not normalized).
6569

packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,16 @@ export type AuthoringColumnDefaultTemplate =
6363
| AuthoringColumnDefaultTemplateLiteral
6464
| AuthoringColumnDefaultTemplateFunction;
6565

66+
export interface AuthoringExecutionDefaultsTemplate {
67+
readonly onCreate?: AuthoringTemplateValue;
68+
readonly onUpdate?: AuthoringTemplateValue;
69+
}
70+
6671
export interface AuthoringFieldPresetOutput extends AuthoringStorageTypeTemplate {
6772
readonly nullable?: boolean;
6873
readonly default?: AuthoringColumnDefaultTemplate;
6974
readonly executionDefault?: AuthoringTemplateValue;
75+
readonly executionDefaults?: AuthoringExecutionDefaultsTemplate;
7076
readonly id?: boolean;
7177
readonly unique?: boolean;
7278
}
@@ -327,6 +333,29 @@ function resolveAuthoringColumnDefaultTemplate(
327333
};
328334
}
329335

336+
function resolveAuthoringExecutionDefaultsTemplate(
337+
template: AuthoringExecutionDefaultsTemplate,
338+
args: readonly unknown[],
339+
): {
340+
readonly onCreate?: unknown;
341+
readonly onUpdate?: unknown;
342+
} {
343+
return {
344+
...ifDefined(
345+
'onCreate',
346+
template.onCreate !== undefined
347+
? resolveAuthoringTemplateValue(template.onCreate, args)
348+
: undefined,
349+
),
350+
...ifDefined(
351+
'onUpdate',
352+
template.onUpdate !== undefined
353+
? resolveAuthoringTemplateValue(template.onUpdate, args)
354+
: undefined,
355+
),
356+
};
357+
}
358+
330359
export function instantiateAuthoringTypeConstructor(
331360
descriptor: AuthoringTypeConstructorDescriptor,
332361
args: readonly unknown[],
@@ -358,6 +387,10 @@ export function instantiateAuthoringFieldPreset(
358387
readonly expression: string;
359388
};
360389
readonly executionDefault?: unknown;
390+
readonly executionDefaults?: {
391+
readonly onCreate?: unknown;
392+
readonly onUpdate?: unknown;
393+
};
361394
readonly id: boolean;
362395
readonly unique: boolean;
363396
} {
@@ -376,6 +409,12 @@ export function instantiateAuthoringFieldPreset(
376409
? resolveAuthoringTemplateValue(descriptor.output.executionDefault, args)
377410
: undefined,
378411
),
412+
...ifDefined(
413+
'executionDefaults',
414+
descriptor.output.executionDefaults !== undefined
415+
? resolveAuthoringExecutionDefaultsTemplate(descriptor.output.executionDefaults, args)
416+
: undefined,
417+
),
379418
id: descriptor.output.id ?? false,
380419
unique: descriptor.output.unique ?? false,
381420
};

packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,49 @@ describe('authoring template resolution', () => {
345345
});
346346
});
347347

348+
it('resolves phase-specific execution defaults from field presets', () => {
349+
const descriptor = {
350+
kind: 'fieldPreset',
351+
output: {
352+
codecId: 'test/timestamp@1',
353+
nativeType: 'timestamp',
354+
executionDefaults: {
355+
onCreate: {
356+
kind: 'arg',
357+
index: 0,
358+
path: ['create'],
359+
},
360+
onUpdate: {
361+
kind: 'arg',
362+
index: 0,
363+
path: ['update'],
364+
},
365+
},
366+
},
367+
} as const;
368+
369+
expect(
370+
instantiateAuthoringFieldPreset(descriptor, [
371+
{
372+
create: { kind: 'generator', id: 'timestampNow' },
373+
update: { kind: 'generator', id: 'timestampNow' },
374+
},
375+
]),
376+
).toEqual({
377+
descriptor: {
378+
codecId: 'test/timestamp@1',
379+
nativeType: 'timestamp',
380+
},
381+
nullable: false,
382+
executionDefaults: {
383+
onCreate: { kind: 'generator', id: 'timestampNow' },
384+
onUpdate: { kind: 'generator', id: 'timestampNow' },
385+
},
386+
id: false,
387+
unique: false,
388+
});
389+
});
390+
348391
it('stringifies primitive function default expressions', () => {
349392
const descriptor = {
350393
kind: 'fieldPreset',

packages/2-sql/2-authoring/contract-psl/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This keeps core/CLI source-agnostic while giving PSL-first SQL users a one-line
1515

1616
- Interpret `ParsePslDocumentResult` into SQL `Contract`
1717
- Interpret generic PSL attributes into SQL contract semantics (`@id`, `@unique`, `@default`, `@relation`, `@map`, `@@map`)
18+
- Interpret SQL timestamp semantics: `DateTime @default(now())` as a storage default and `DateTime @updatedAt` as an execution mutation default
1819
- Lower shared constructor expressions in both `types {}` blocks and inline field positions (for example `ShortName = sql.String(length: 35)` and `embedding pgvector.Vector(length: 1536)?`)
1920
- Lower supported default functions through composed registry inputs
2021
- Support selected Postgres native-type attributes on named types for brownfield round-trips (`@db.Char`, `@db.VarChar`, `@db.Numeric`, `@db.Uuid`, `@db.SmallInt`, `@db.Real`, `@db.Timestamp`, `@db.Timestamptz`, `@db.Date`, `@db.Time`, `@db.Timetz`, `@db.Json`)
@@ -42,7 +43,7 @@ The **pure interpreter entrypoint** specifically excludes:
4243
- Artifact emission (`contract.json`, `contract.d.ts`) and hashing
4344
- CLI or ControlClient orchestration
4445

45-
Current scope is SQL/Postgres-first: callers pass Postgres-oriented scalar descriptors and target context in v1.
46+
Current scope is SQL target-specific: callers pass scalar descriptors and target context assembled for the active SQL target.
4647

4748
Unsupported PSL constructs in v1 (strict errors):
4849

@@ -63,6 +64,12 @@ Supported `@default(...)` surface in v1 when composed contributors provide handl
6364
- Explicitly unsupported in v1: `cuid()` (diagnostic suggests `cuid(2)`)
6465
- `dbgenerated("...")` preserves the parsed PSL string-literal contents as-is (escaped sequences are not normalized in v1).
6566

67+
Supported timestamp attribute surface:
68+
69+
- `createdAt DateTime @default(now())` lowers to the target storage default and does not create an execution mutation default.
70+
- `updatedAt DateTime @updatedAt` lowers to `timestampNow` on create and on non-empty update mutations. This is application-side because Prisma’s `@updatedAt` semantics are mutation-aware, not a database trigger.
71+
- `@createdAt` is not supported as a PSL alias.
72+
6673
## Public API
6774

6875
- `@prisma-next/sql-contract-psl`

packages/2-sql/2-authoring/contract-psl/src/interpreter.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,19 @@ import {
7777

7878
export interface InterpretPslDocumentToSqlContractInput {
7979
readonly document: ParsePslDocumentResult;
80-
readonly target: TargetPackRef<'sql', 'postgres'>;
80+
readonly target: TargetPackRef<'sql', string>;
8181
readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
8282
readonly composedExtensionPacks?: readonly string[];
83-
readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', 'postgres'>[];
83+
readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
8484
readonly controlMutationDefaults?: ControlMutationDefaults;
8585
readonly authoringContributions?: AuthoringContributions;
8686
}
8787

8888
function buildComposedExtensionPackRefs(
89-
target: TargetPackRef<'sql', 'postgres'>,
89+
target: TargetPackRef<'sql', string>,
9090
extensionIds: readonly string[],
91-
extensionPackRefs: readonly ExtensionPackRef<'sql', 'postgres'>[] = [],
92-
): Record<string, ExtensionPackRef<'sql', 'postgres'>> | undefined {
91+
extensionPackRefs: readonly ExtensionPackRef<'sql', string>[] = [],
92+
): Record<string, ExtensionPackRef<'sql', string>> | undefined {
9393
if (extensionIds.length === 0) {
9494
return undefined;
9595
}
@@ -106,7 +106,7 @@ function buildComposedExtensionPackRefs(
106106
familyId: target.familyId,
107107
targetId: target.targetId,
108108
version: '0.0.1',
109-
} satisfies ExtensionPackRef<'sql', 'postgres'>),
109+
} satisfies ExtensionPackRef<'sql', string>),
110110
]),
111111
);
112112
}
@@ -763,6 +763,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
763763
nullable: resolvedField.field.optional,
764764
...ifDefined('default', resolvedField.defaultValue),
765765
...ifDefined('executionDefault', resolvedField.executionDefault),
766+
...ifDefined('executionDefaults', resolvedField.executionDefaults),
766767
})),
767768
...(primaryKeyColumns.length > 0
768769
? {

packages/2-sql/2-authoring/contract-psl/src/provider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import type { ColumnDescriptor } from './psl-column-resolution';
1010

1111
export interface PrismaContractOptions {
1212
readonly output?: string;
13-
readonly target: TargetPackRef<'sql', 'postgres'>;
14-
readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', 'postgres'>[];
13+
readonly target: TargetPackRef<'sql', string>;
14+
readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
1515
}
1616

1717
function buildColumnDescriptorMap(

packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types';
2-
import type { ColumnDefault, ExecutionMutationDefaultValue } from '@prisma-next/contract/types';
2+
import type {
3+
ColumnDefault,
4+
ExecutionMutationDefault,
5+
ExecutionMutationDefaultValue,
6+
} from '@prisma-next/contract/types';
37
import type { AuthoringContributions } from '@prisma-next/framework-components/authoring';
48
import type {
59
ControlMutationDefaultRegistry,
@@ -27,6 +31,7 @@ export type ResolvedField = {
2731
readonly descriptor: ColumnDescriptor;
2832
readonly defaultValue?: ColumnDefault;
2933
readonly executionDefault?: ExecutionMutationDefaultValue;
34+
readonly executionDefaults?: Omit<ExecutionMutationDefault, 'ref'>;
3035
readonly isId: boolean;
3136
readonly isUnique: boolean;
3237
readonly idName?: string;
@@ -64,6 +69,7 @@ const BUILTIN_FIELD_ATTRIBUTE_NAMES: ReadonlySet<string> = new Set([
6469
'id',
6570
'unique',
6671
'default',
72+
'updatedAt',
6773
'relation',
6874
'map',
6975
]);
@@ -138,6 +144,88 @@ function extractFieldConstraintNames(input: {
138144
return { idAttribute, uniqueAttribute, idName, uniqueName };
139145
}
140146

147+
const TIMESTAMP_NOW_GENERATOR_ID = 'timestampNow';
148+
149+
function reportInvalidUpdatedAt(input: {
150+
readonly model: PslModel;
151+
readonly field: PslField;
152+
readonly attribute: PslAttribute;
153+
readonly diagnostics: ContractSourceDiagnostic[];
154+
readonly sourceId: string;
155+
readonly message: string;
156+
}): void {
157+
input.diagnostics.push({
158+
code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
159+
message: `Field "${input.model.name}.${input.field.name}" @updatedAt ${input.message}`,
160+
sourceId: input.sourceId,
161+
span: input.attribute.span,
162+
});
163+
}
164+
165+
function lowerUpdatedAtAttribute(input: {
166+
readonly model: PslModel;
167+
readonly field: PslField;
168+
readonly attribute: PslAttribute;
169+
readonly descriptor: ColumnDescriptor;
170+
readonly defaultAttribute: PslAttribute | undefined;
171+
readonly generatorDescriptorById: ReadonlyMap<string, MutationDefaultGeneratorDescriptor>;
172+
readonly diagnostics: ContractSourceDiagnostic[];
173+
readonly sourceId: string;
174+
}): Omit<ExecutionMutationDefault, 'ref'> | undefined {
175+
if (input.attribute.args.length > 0) {
176+
reportInvalidUpdatedAt({
177+
...input,
178+
message: 'does not accept arguments. Use @updatedAt.',
179+
});
180+
return undefined;
181+
}
182+
if (input.defaultAttribute) {
183+
reportInvalidUpdatedAt({
184+
...input,
185+
message: 'cannot be combined with @default.',
186+
});
187+
return undefined;
188+
}
189+
if (input.field.optional) {
190+
reportInvalidUpdatedAt({
191+
...input,
192+
message: 'cannot be optional. Remove "?" or remove @updatedAt.',
193+
});
194+
return undefined;
195+
}
196+
if (input.field.list) {
197+
reportInvalidUpdatedAt({
198+
...input,
199+
message: 'cannot be a list field. Use a singular DateTime field.',
200+
});
201+
return undefined;
202+
}
203+
204+
const generatorDescriptor = input.generatorDescriptorById.get(TIMESTAMP_NOW_GENERATOR_ID);
205+
if (!generatorDescriptor) {
206+
input.diagnostics.push({
207+
code: 'PSL_INVALID_DEFAULT_APPLICABILITY',
208+
message: `Field "${input.model.name}.${input.field.name}" @updatedAt requires mutation default generator "${TIMESTAMP_NOW_GENERATOR_ID}".`,
209+
sourceId: input.sourceId,
210+
span: input.attribute.span,
211+
});
212+
return undefined;
213+
}
214+
if (!generatorDescriptor.applicableCodecIds.includes(input.descriptor.codecId)) {
215+
reportInvalidUpdatedAt({
216+
...input,
217+
message: `requires a timestamp-compatible field, received codecId "${input.descriptor.codecId}".`,
218+
});
219+
return undefined;
220+
}
221+
222+
const generated = { kind: 'generator' as const, id: TIMESTAMP_NOW_GENERATOR_ID };
223+
return {
224+
onCreate: generated,
225+
onUpdate: generated,
226+
};
227+
}
228+
141229
export function collectResolvedFields(input: CollectResolvedFieldsInput): ResolvedField[] {
142230
const {
143231
model,
@@ -235,6 +323,19 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
235323
}
236324

237325
const defaultAttribute = getAttribute(field.attributes, 'default');
326+
const updatedAtAttribute = getAttribute(field.attributes, 'updatedAt');
327+
const updatedAtExecutionDefaults = updatedAtAttribute
328+
? lowerUpdatedAtAttribute({
329+
model,
330+
field,
331+
attribute: updatedAtAttribute,
332+
descriptor,
333+
defaultAttribute,
334+
generatorDescriptorById,
335+
diagnostics,
336+
sourceId,
337+
})
338+
: undefined;
238339
const loweredDefault = defaultAttribute
239340
? lowerDefaultForField({
240341
modelName: model.name,
@@ -283,6 +384,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv
283384
descriptor,
284385
...ifDefined('defaultValue', loweredDefault.defaultValue),
285386
...ifDefined('executionDefault', loweredDefault.executionDefault),
387+
...ifDefined('executionDefaults', updatedAtExecutionDefaults),
286388
isId: Boolean(idAttribute),
287389
isUnique: Boolean(uniqueAttribute),
288390
...ifDefined('idName', idName),

0 commit comments

Comments
 (0)