Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4310a6a
docs(created-updated-at-authoring): add timestamp authoring plan
jkomyno May 4, 2026
682714e
feat(sql): add updatedAt timestamp authoring
jkomyno May 4, 2026
ece4e18
fix(sql): address updatedAt review findings
jkomyno May 5, 2026
40462aa
refactor(sql): collapse executionDefault into executionDefaults
jkomyno May 5, 2026
0d45548
refactor(psl): consolidate @updatedAt validation paths and improve di…
jkomyno May 5, 2026
89e15f5
feat(runtime): validate mutation default generators at execution cont…
jkomyno May 5, 2026
40d67af
refactor(sql): centralize timestampNow descriptor and runtime wiring …
jkomyno May 5, 2026
146242c
fix(orm-client): apply @updatedAt to ORM updates and stabilize across…
jkomyno May 5, 2026
14ff454
docs(plan): record Milestone 4 — ORM update wiring and across-rows st…
jkomyno May 5, 2026
f25ab85
docs(plan): pivot timestamp authoring to PSL field-preset surface
jkomyno May 6, 2026
ac8b147
feat(psl): add field-preset dispatch path with temporal namespace
jkomyno May 6, 2026
2f5c88e
feat(psl,authoring): add preset misuse diagnostics and registry colli…
jkomyno May 6, 2026
91178d7
docs(plan): correct RD11 — namespaced-call arg-error rename deferred
jkomyno May 6, 2026
f3b748d
feat(targets): re-namespace timestamp presets under field.temporal
jkomyno May 6, 2026
b9a2caf
feat(psl)!: remove @updatedAt attribute, add migration hint
jkomyno May 6, 2026
889ba98
docs(authoring): update READMEs for temporal preset surface
jkomyno May 6, 2026
6ec547a
refactor(sql,family): share temporal.* preset registration across tar…
jkomyno May 6, 2026
fa2c8d2
refactor(sql,runtime): drop applyOnEmptyUpdate opt-in
jkomyno May 6, 2026
2a01ae5
refactor(sql,authoring): tighten nullable+executionDefaults rejection
jkomyno May 6, 2026
93be243
docs(plan): record YAGNI cuts and temporal preset consolidation
jkomyno May 6, 2026
13e24d0
Merge branch 'main' into feat/created-updated-at-authoring
jkomyno May 7, 2026
94a7ee2
fix(contract,authoring): validate resolved literal defaults before ca…
jkomyno May 7, 2026
c835e8f
refactor(family-sql): isolate runtime generator from control-plane mo…
jkomyno May 7, 2026
dbdb336
test(orm-client): assert Date identity for stableAcrossRows
jkomyno May 7, 2026
58d7f5a
feat(sql): support id-less sql models
jkomyno May 5, 2026
014ea5c
fix(psl): tighten @id and @@id identity validation (todos 067, 068, 0…
jkomyno May 5, 2026
a44bf5a
refactor(psl): collapse primary-key reconciliation and document ORM P…
jkomyno May 5, 2026
d54fcc1
chore(psl): unify entityLabel parameter and hoist model-attribute lab…
jkomyno May 5, 2026
405ea06
fix(psl): reject @@unique and @@index with duplicate fields
jkomyno May 5, 2026
6cba724
fix(ci): stabilize idless model checks
jkomyno May 7, 2026
ba6b20c
Merge remote-tracking branch 'origin/main' into feat/idless-models
jkomyno May 8, 2026
f8dd2df
docs(sql): clarify id-less ORM primary-key fallback
jkomyno May 8, 2026
116bc52
fix(sql): centralize storage constraint validation
jkomyno May 8, 2026
c1d6b15
chore(projects): close out id-less-sql-models
jkomyno May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/architecture docs/subsystems/3. Query Lanes.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,10 @@ const plan = o.user()

The ORM is optional and layered on top of the DSL.

### Implicit invariant: ORM ops assume a primary key

`storage.tables.<t>.primaryKey` is optional in the SQL contract — id-less SQL tables are emittable from PSL (`model X { ... }` without `@id`/`@@id`) and from introspection. ORM read/write operations (`findUnique`, `update(where)`, `delete(where)`) assume a single canonical identity to address rows; they have no fallback for id-less models. The current ORM lane has no code referring to `model.id` directly, so id-less models do not break composition today, but adding any ORM op that depends on a primary key against an id-less model will fail at runtime. The long-term fix is a per-model capability (e.g. `sql.ormRequiresPrimaryKey`) gated at composition time; until then, treat id-less models as DSL-only.
Comment thread
jkomyno marked this conversation as resolved.
Outdated
Comment thread
jkomyno marked this conversation as resolved.
Outdated

### Shared Collection interface across families

The ORM's `Collection` class with fluent chaining is a shared architectural pattern that works across database families — not just SQL. A comparative analysis with the MongoDB ORM (see [10. MongoDB Family](10.%20MongoDB%20Family.md)) revealed that the consumer-facing surface is fundamentally the same:
Expand Down
4 changes: 2 additions & 2 deletions examples/prisma-next-demo/prisma/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const contract = defineContract(
fields: {
id: field.id.uuidv4(),
email: field.text(),
createdAt: field.createdAt(),
createdAt: field.temporal.createdAt(),
kind: field.namedType(types.user_type),
address: field.json().optional(),
},
Expand All @@ -40,7 +40,7 @@ export const contract = defineContract(
id: field.id.uuidv4(),
title: field.text(),
userId: field.uuid(),
createdAt: field.createdAt(),
createdAt: field.temporal.createdAt(),
embedding: field.namedType(types.Embedding1536).optional(),
},
});
Expand Down
4 changes: 2 additions & 2 deletions examples/react-router-demo/prisma/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const contract = defineContract(
fields: {
id: field.id.uuidv4(),
email: field.text(),
createdAt: field.createdAt(),
createdAt: field.temporal.createdAt(),
},
});

Expand All @@ -30,7 +30,7 @@ export const contract = defineContract(
id: field.id.uuidv4(),
title: field.text(),
userId: field.uuid(),
createdAt: field.createdAt(),
createdAt: field.temporal.createdAt(),
},
});

Expand Down
10 changes: 9 additions & 1 deletion packages/1-framework/0-foundation/contract/src/exports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type {
DocIndex,
ExecutionHashBase,
ExecutionMutationDefault,
ExecutionMutationDefaultPhases,
ExecutionMutationDefaultValue,
ExecutionSection,
Expr,
Expand All @@ -42,4 +43,11 @@ export type {
StorageBase,
StorageHashBase,
} from '../types';
export { coreHash, executionHash, profileHash } from '../types';
export {
coreHash,
executionHash,
isColumnDefault,
isColumnDefaultLiteralInputValue,
isExecutionMutationDefaultValue,
profileHash,
} from '../types';
62 changes: 62 additions & 0 deletions packages/1-framework/0-foundation/contract/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,25 +80,87 @@ export type ColumnDefaultLiteralValue = JsonValue;

export type ColumnDefaultLiteralInputValue = ColumnDefaultLiteralValue | Date;

/**
* Runtime predicate for `ColumnDefaultLiteralInputValue`. Authoring layers
* resolve template values from caller-supplied args (typed `unknown` at the
* boundary) and need to validate before constructing a `ColumnDefault`.
* Accepts JSON primitives, plain arrays/objects of JSON values, and `Date`
* instances. Rejects functions, class instances (other than `Date`),
* `undefined`, `bigint`, `symbol`, and arrays/objects containing those.
*/
export function isColumnDefaultLiteralInputValue(
value: unknown,
): value is ColumnDefaultLiteralInputValue {
if (value === null) return true;
const t = typeof value;
if (t === 'string' || t === 'number' || t === 'boolean') return true;
if (value instanceof Date) return true;
if (Array.isArray(value)) return value.every(isColumnDefaultLiteralInputValue);
if (t === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
return Object.values(value as Record<string, unknown>).every(isColumnDefaultLiteralInputValue);
}
return false;
}

export type ColumnDefault =
| {
readonly kind: 'literal';
readonly value: ColumnDefaultLiteralInputValue;
}
| { readonly kind: 'function'; readonly expression: string };

export function isColumnDefault(value: unknown): value is ColumnDefault {
if (typeof value !== 'object' || value === null) return false;
const kind = (value as { kind?: unknown }).kind;
if (kind === 'literal') {
return 'value' in value;
}
if (kind === 'function') {
return typeof (value as { expression?: unknown }).expression === 'string';
}
return false;
}

export type ExecutionMutationDefaultValue = {
readonly kind: 'generator';
readonly id: GeneratedValueSpec['id'];
readonly params?: Record<string, unknown>;
};

export function isExecutionMutationDefaultValue(
value: unknown,
): value is ExecutionMutationDefaultValue {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as {
kind?: unknown;
id?: unknown;
params?: unknown;
};
if (candidate.kind !== 'generator') return false;
if (typeof candidate.id !== 'string') return false;
if (
candidate.params !== undefined &&
(typeof candidate.params !== 'object' ||
candidate.params === null ||
Array.isArray(candidate.params))
) {
return false;
}
return true;
}

export type ExecutionMutationDefault = {
readonly ref: { readonly table: string; readonly column: string };
readonly onCreate?: ExecutionMutationDefaultValue;
readonly onUpdate?: ExecutionMutationDefaultValue;
};

/**
* `ExecutionMutationDefault` minus its `ref` — the per-field phases value
* authoring layers attach to a column before the column ref is known.
*/
export type ExecutionMutationDefaultPhases = Omit<ExecutionMutationDefault, 'ref'>;

export type ExecutionSection<THash extends string = string> = {
readonly executionHash: ExecutionHashBase<THash>;
readonly mutations: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,4 @@ export type {
SourceDiagnostic,
SourceSpan,
} from '../shared/mutation-default-types';
export { TIMESTAMP_NOW_GENERATOR_ID } from '../shared/mutation-default-types';
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import type {
ColumnDefault,
ExecutionMutationDefaultPhases,
ExecutionMutationDefaultValue,
} from '@prisma-next/contract/types';
import {
isColumnDefaultLiteralInputValue,
isExecutionMutationDefaultValue,
} from '@prisma-next/contract/types';
import { ifDefined } from '@prisma-next/utils/defined';

export type AuthoringArgRef = {
Expand Down Expand Up @@ -63,10 +72,15 @@ export type AuthoringColumnDefaultTemplate =
| AuthoringColumnDefaultTemplateLiteral
| AuthoringColumnDefaultTemplateFunction;

export interface AuthoringExecutionDefaultsTemplate {
readonly onCreate?: AuthoringTemplateValue;
readonly onUpdate?: AuthoringTemplateValue;
}

export interface AuthoringFieldPresetOutput extends AuthoringStorageTypeTemplate {
readonly nullable?: boolean;
readonly default?: AuthoringColumnDefaultTemplate;
readonly executionDefault?: AuthoringTemplateValue;
readonly executionDefaults?: AuthoringExecutionDefaultsTemplate;
readonly id?: boolean;
readonly unique?: boolean;
}
Expand Down Expand Up @@ -295,20 +309,17 @@ function resolveAuthoringStorageTypeTemplate(
function resolveAuthoringColumnDefaultTemplate(
template: AuthoringColumnDefaultTemplate,
args: readonly unknown[],
):
| {
readonly kind: 'literal';
readonly value: unknown;
}
| {
readonly kind: 'function';
readonly expression: string;
} {
): ColumnDefault {
if (template.kind === 'literal') {
const value = resolveAuthoringTemplateValue(template.value, args);
if (value === undefined) {
throw new Error('Resolved authoring literal default must not be undefined');
}
if (!isColumnDefaultLiteralInputValue(value)) {
throw new Error(
`Resolved authoring literal default must be a JSON-serializable value or Date, received ${String(value)}`,
);
}
return {
kind: 'literal',
value,
Expand All @@ -327,6 +338,40 @@ function resolveAuthoringColumnDefaultTemplate(
};
}

function resolveExecutionMutationDefaultPhase(
phase: 'onCreate' | 'onUpdate',
template: AuthoringTemplateValue,
args: readonly unknown[],
): ExecutionMutationDefaultValue {
const value = resolveAuthoringTemplateValue(template, args);
if (!isExecutionMutationDefaultValue(value)) {
throw new Error(
`Authoring preset executionDefaults.${phase} did not resolve to a valid generator descriptor (kind: 'generator', id: string).`,
);
}
return value;
}

function resolveAuthoringExecutionDefaultsTemplate(
template: AuthoringExecutionDefaultsTemplate,
args: readonly unknown[],
): ExecutionMutationDefaultPhases {
return {
...ifDefined(
'onCreate',
template.onCreate !== undefined
? resolveExecutionMutationDefaultPhase('onCreate', template.onCreate, args)
: undefined,
),
...ifDefined(
'onUpdate',
template.onUpdate !== undefined
? resolveExecutionMutationDefaultPhase('onUpdate', template.onUpdate, args)
: undefined,
),
};
}

export function instantiateAuthoringTypeConstructor(
descriptor: AuthoringTypeConstructorDescriptor,
args: readonly unknown[],
Expand All @@ -348,16 +393,8 @@ export function instantiateAuthoringFieldPreset(
readonly typeParams?: Record<string, unknown>;
};
readonly nullable: boolean;
readonly default?:
| {
readonly kind: 'literal';
readonly value: unknown;
}
| {
readonly kind: 'function';
readonly expression: string;
};
readonly executionDefault?: unknown;
readonly default?: ColumnDefault;
readonly executionDefaults?: ExecutionMutationDefaultPhases;
readonly id: boolean;
readonly unique: boolean;
} {
Expand All @@ -371,9 +408,9 @@ export function instantiateAuthoringFieldPreset(
: undefined,
),
...ifDefined(
'executionDefault',
descriptor.output.executionDefault !== undefined
? resolveAuthoringTemplateValue(descriptor.output.executionDefault, args)
'executionDefaults',
descriptor.output.executionDefaults !== undefined
? resolveAuthoringExecutionDefaultsTemplate(descriptor.output.executionDefaults, args)
: undefined,
),
id: descriptor.output.id ?? false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import type { ColumnDefault, ExecutionMutationDefaultValue } from '@prisma-next/contract/types';
import type {
ColumnDefault,
ExecutionMutationDefaultPhases,
ExecutionMutationDefaultValue,
} from '@prisma-next/contract/types';

/**
* Canonical id for the wall-clock-now mutation default generator.
*
* Authoring surfaces (PSL `temporal.updatedAt()`, TS `field.temporal.updatedAt()`), control
* descriptors, and runtime generators all reference this id. Centralized
* here so a future rename or alias is a single edit.
*/
export const TIMESTAMP_NOW_GENERATOR_ID = 'timestampNow' as const;

interface SourcePosition {
readonly offset: number;
Expand Down Expand Up @@ -71,6 +84,13 @@ export interface MutationDefaultGeneratorDescriptor {
readonly typeParams?: Record<string, unknown>;
}
| undefined;
/**
* Construct the `onCreate`/`onUpdate` phases value owned by this
* generator. Authoring layers (PSL `temporal.updatedAt()`, TS field presets) call
* this instead of building the literal inline so PSL/TS-authored
* contracts stay byte-equivalent for any future params-bearing generator.
*/
readonly buildPhases?: (args?: Record<string, unknown>) => ExecutionMutationDefaultPhases;
}

export interface ControlMutationDefaultEntry {
Expand Down
Loading
Loading