From 1235494acc4111fb0d0636e0f533734534e64b0f Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 14:24:13 +0200 Subject: [PATCH 01/50] docs(projects): scaffold codec-owned-defaults project (spec, plan, review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapses `ColumnDefault` to `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }` and moves literal-rendering responsibility onto a required `renderSqlLiteral(value: TInput): string` method on the SQL codec interface. The legacy `kind: 'literal' | 'function'` shape is removed; both authoring surfaces (TS DSL, PSL) lower literals through codec methods at emit time. Three milestones: - **M1** is purely additive — every SQL codec gains `renderSqlLiteral`, the structural enforcement lands on `SqlCodecImpl`. No producer or consumer of `ColumnDefault` changes yet. - **M2** is the atomic flip — IR shape, both authoring surfaces, both DDL renderers, the literal pass for `null`, the `NOT NULL` diagnostic, and `decodeContractDefaults` removal all land together. Fixtures regenerated in the same change. - **M3** is close-out — ADR housekeeping (167, 184) and project deletion. Scoreboard scaffolds 26 ACs across Contract IR, SQL Codec, TS DSL, PSL, Semantics & Diagnostics, Mongo, and Quality Gates. Signed-off-by: Serhii Tatarintsev --- projects/codec-owned-defaults/plan.md | 155 +++++++++++++++++++++++++ projects/codec-owned-defaults/spec.md | 161 ++++++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 projects/codec-owned-defaults/plan.md create mode 100644 projects/codec-owned-defaults/spec.md diff --git a/projects/codec-owned-defaults/plan.md b/projects/codec-owned-defaults/plan.md new file mode 100644 index 0000000000..d1a9cdd9f1 --- /dev/null +++ b/projects/codec-owned-defaults/plan.md @@ -0,0 +1,155 @@ +# Codec-owned column defaults + +## Summary + +Reshape the contract IR's `ColumnDefault` to `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }` and move literal-rendering responsibility onto a required `renderSqlLiteral(value: TInput): string` method on the SQL codec interface. The `expression` branch carries every default that lowers to a `DEFAULT ()` clause — codec-rendered literals and raw function-form expressions alike. The `autoincrement` branch is a payload-free sentinel for the one default that doesn't emit a `DEFAULT` clause at all (it's realized as SERIAL/IDENTITY / INTEGER PRIMARY KEY AUTOINCREMENT at the column type). The legacy `kind: 'literal'`/`value: JsonValue` payload is removed. Both authoring surfaces (TS DSL, PSL) lower literals through codec methods at emit time. Success means: dialect coverage enforced at type-check time, no `decodeContractDefaults` runtime pass, autoincrement still works, and Mongo untouched. + +**Spec:** [spec.md](./spec.md) + +## Collaborators + +| Role | Person/Team | Context | +| ------------ | ------------------------------ | ---------------------------------------------------------------------- | +| Maker | Serhii Tatarintsev | Drives execution | +| Reviewer | _TBD — see Open Items_ | Architectural review of the codec-interface change | +| Collaborator | Anyone touching SQL codecs | Postgres/SQLite codec authors will need to add `renderSqlLiteral` | +| Collaborator | PSL authoring owners | PSL parser + printer paths flip atomically with the IR | + +## Shipping Strategy + +This is a workspace-internal change (no external consumers of `contract.json` exist outside the repo). The implicit gate between old and new behaviour is the contract IR shape itself — when the validator flips to `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`, every producer (TS DSL emitter, PSL parser) must produce the new shape in lockstep, and every consumer (DDL renderer, PSL printer) must read it. + +The plan separates milestones by what can land independently: + +- **M1** is purely additive: every SQL codec gains `renderSqlLiteral`, and the factory requires it. No producer or consumer of `ColumnDefault` changes yet — DDL rendering still runs through the existing `renderDefaultLiteral` per-type logic. Safe to ship alone; if anything went wrong, no behavior has changed. +- **M2** is the atomic flip. The IR shape, both authoring surfaces, both DDL renderers, the type re-homing, the literal-pass for `null`, the diagnostic for `NULL` on `NOT NULL`, and `decodeContractDefaults` removal all land together. Fixtures are regenerated as part of the same change so `pnpm fixtures:check` proves the shape on disk. No feature flag — the test suite and `fixtures:check` are the shipping gate. +- **M3** is doc/cleanup, behaviour unchanged. + +No backward-compat shims, in line with project policy: call sites flip in lockstep with the IR. + +## Test Design + +Test cases derived from the spec's acceptance criteria (Contract IR, SQL Codec, Authoring TS DSL, Authoring PSL, Semantics & Diagnostics, Mongo, Quality Gates) and from the Security non-functional section (adversarial inputs). + +| AC | TC | Test Case | Type | Milestone | Expected Outcome | +| ----------- | ----- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------- | --------- | ------------------------------------------------------------------------------------------- | +| AC-CODEC-1 | TC-1 | SQL codec interface declares `renderSqlLiteral(value: TInput): string` as a required method | Type test | M1 | Property exists; signature matches `TInput → string` | +| AC-CODEC-2 | TC-2 | SQL codec factory rejects construction of any codec missing `renderSqlLiteral` | Negative type test | M1 | Compile error when factory input omits the method | +| AC-CODEC-3 | TC-3 | Each Postgres codec's `renderSqlLiteral` produces the expected dialect-specific expression for representative inputs | Unit | M1 | Output strings match expected DDL fragments | +| AC-CODEC-4 | TC-4 | Each SQLite codec's `renderSqlLiteral` produces the expected dialect-specific expression for representative inputs | Unit | M1 | Output strings match expected DDL fragments | +| AC-CODEC-5 | TC-5 | No codec implementation throws at runtime for valid `TInput` values | Unit | M1 | Each codec returns a string | +| AC-CODEC-6 | TC-6 | Codec renderers correctly escape adversarial inputs: single quotes, backslashes, NULL bytes, unicode (Security NFR) | Unit | M1 | Output safely escaped per dialect; round-trips through the database | +| AC-IR-1 | TC-7 | Arktype `ColumnDefaultSchema` accepts `{ kind: 'expression', expression: string }` and `{ kind: 'autoincrement' }`, and rejects the legacy `{ kind: 'literal', value }` / `{ kind: 'function', expression }` shapes | Unit | M2 | Validation passes for new shape; fails for legacy | +| AC-IR-2 | TC-8 | `ColumnDefault`, `ColumnDefaultLiteralValue`, `ColumnDefaultLiteralInputValue` are exported only from `packages/2-sql/1-core/contract/src/`; not exported from framework foundation | Static (import) | M2 | Imports resolve from SQL package; no framework export | +| AC-IR-3 | TC-9 | All fixture contracts (`test/integration/test/**/contract.json` and equivalent) emit only the new shape for column defaults: every default is either `{ kind: 'expression', expression }` or `{ kind: 'autoincrement' }` | Integration | M2 | All fixture contracts match new shape; no `value` field, no `literal`/`function` kinds | +| AC-IR-4 | TC-10 | `pnpm fixtures:check` passes | Integration | M2 | Exit 0 | +| AC-TS-1 | TC-11 | TS DSL `.default(literal)` produces a contract whose `default` is `{ kind: 'expression', expression: }`. TS DSL `.default(autoincrement())` on a codec carrying the `autoincrement` trait lowers to `{ kind: 'autoincrement' }`. | Integration | M2 | Emitted `contract.json` carries the union shape only; no `value` field | +| AC-TS (new) | TC-11a | TS DSL: `.default(autoincrement())` compiles on column builders whose codec carries the `autoincrement` trait (e.g. `pg/int4@1`, `sqlite/integer@1`) and fails to compile on builders whose codec does not (e.g. `pg/text@1`, `pg/bool@1`) | Type test (±) | M2 | Compiles for trait-bearing codecs; compile error for others | +| AC-TS-2 (+) | TC-12 | TS DSL `.default(matchingTInput)` compiles for representative codecs (string, int, bool, Date, bigint, Buffer, json) | Type test (+) | M2 | Compiles | +| AC-TS-2 (−) | TC-13 | TS DSL `.default(invalidValue)` fails to compile across the same representative codecs | Type test (−) | M2 | Compile error | +| AC-PSL-1 | TC-14 | PSL literal-default lowering invokes `codec.decodeJson(jsonValue)` then `codec.renderSqlLiteral(decoded)` | Unit | M2 | Spy verifies call order; final `default` is `{ kind: 'expression', expression }` | +| AC-PSL-2 | TC-15 | PSL `@default(true)` on an int column emits a diagnostic naming the column path, codec id, and PSL source `file:line` | Unit | M2 | Diagnostic message contains all three fields | +| AC-PSL-3 | TC-16 | PSL `@default(now())` lands as `{ kind: 'expression', expression: 'now()' }` without invoking codec methods. PSL `@default(autoincrement())` on a column whose codec carries the `autoincrement` trait lowers to `{ kind: 'autoincrement' }`; on a column whose codec lacks the trait, PSL emits a diagnostic naming the column, codec id, and PSL source location. | Unit | M2 | Function-form bypasses codec; autoincrement gated on trait | +| AC-PSL-4 | TC-17 | PSL printer reads the new `ColumnDefault` union: `{ kind: 'autoincrement' }` prints as `@default(autoincrement())`; `{ kind: 'expression', expression }` maps known sentinels via `DEFAULT_FUNCTION_ATTRIBUTES`, otherwise raw-expression form | Unit | M2 | Printer output handles both branches | +| AC-PSL-4 | TC-18 | PSL → contract → PSL round-trip survives without crashing (literal form may differ) | Integration | M2 | No errors; second-pass contract semantically equivalent | +| AC-SEM-1 | TC-19 | NOT NULL column with a `null` literal default is rejected before codec dispatch with a diagnostic naming the column | Unit | M2 | Diagnostic raised; `codec.renderSqlLiteral` not called | +| AC-SEM-2 | TC-20 | Nullable column with a `null` literal default renders to `{ kind: 'expression', expression: 'NULL' }` without invoking the codec | Unit | M2 | Codec not invoked; expression is `"NULL"` | +| AC-SEM-3 | TC-21 | `decodeContractDefaults` no longer exists in `packages/2-sql/1-core/contract/src/validate.ts` | Static (grep) | M2 | Symbol absent | +| AC-SEM-4 | TC-22 | `Date`, `bigint`, `Buffer`, JSON values render to expected Postgres SQL expressions | Unit | M1 | Specific expression strings match (covered as part of TC-3) | +| AC-SEM-4 | TC-23 | `Date`, `bigint`, `Buffer`, JSON values render to expected SQLite SQL expressions | Unit | M1 | Specific expression strings match (covered as part of TC-4) | +| AC-MONGO-1 | TC-24 | Mongo codec interface (`mongo-codec/src/codecs.ts`) and Mongo concrete codecs are unchanged in surface | Static (diff check) | M2 | No surface-level diff to Mongo codec types | +| AC-MONGO-2 | TC-25 | Mongo authoring/emission tests pass after the change | Integration | M2 | Existing Mongo test suite green | +| AC-QA-1 | TC-26 | `pnpm typecheck` passes across the workspace | Validation gate | M2 | Exit 0 | +| AC-QA-2 | TC-27 | `pnpm lint` passes across all packages | Validation gate | M2 | Exit 0 | +| AC-QA-3 | TC-28 | `pnpm lint:deps` passes (no new layering violations) | Validation gate | M2 | Exit 0 | +| AC-QA-4 | TC-29 | `pnpm test:packages` passes | Validation gate | M2 | Exit 0 | +| AC-QA-5 | TC-30 | `pnpm test:e2e` passes (covers Postgres DDL emission end-to-end) | Validation gate | M2 | Exit 0 | +| AC-QA-6 | TC-31 | No new `any`, `@ts-expect-error` (outside negative type tests), or `as unknown as` introduced | Static (lint+grep) | M3 | No new instances | +| AC-IR-1 / AC-SEM (new) | TC-32 | DDL renderer emits no `DEFAULT` clause for `{ kind: 'autoincrement' }` on Postgres and SQLite; column-type SERIAL/IDENTITY (Postgres) and INTEGER PRIMARY KEY AUTOINCREMENT (SQLite) emission is unchanged | Integration (DDL) | M2 | Generated DDL omits the `DEFAULT` clause; SERIAL/AUTOINCREMENT column-type semantics intact | + +## Milestones + +### Milestone 1: SQL codec foundation — `renderSqlLiteral` required + +Adds `renderSqlLiteral(value: TInput): string` to the SQL codec interface, makes it required at the codec factory, and implements it on every Postgres and SQLite codec with adversarial-input unit tests. Purely additive — no producer or consumer of `ColumnDefault` changes yet. The DDL renderer continues to use its existing `renderDefaultLiteral` per-type logic until M2. + +Demonstrable: a unit test calls `pgCodec.renderSqlLiteral(value)` and asserts the dialect-specific expression. The compile-time negative test demonstrates the factory rejects codecs missing the method. + +**Tasks:** + +- [ ] Add `renderSqlLiteral(value: TInput): string` to the SQL `Codec` interface in `packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts` (satisfies TC-1) +- [ ] Update the SQL codec factory in `packages/2-sql/4-lanes/relational-core/src/ast/codec-factory.ts` to require `renderSqlLiteral` on the input config (satisfies TC-2; downstream type-check failure surfaces missing implementations) +- [ ] Add a negative type test asserting the factory rejects a config that omits `renderSqlLiteral` (satisfies TC-2) +- [ ] Implement `renderSqlLiteral` on the SQL base codecs in `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts` (`sql/char@1`, `sql/varchar@1`, `sql/int@1`, `sql/float@1`, `sql/text@1`, `sql/timestamp@1`, and any others present); add unit tests including adversarial inputs (satisfies TC-3/TC-4 indirectly via aliasing, TC-5, TC-6) +- [ ] Implement `renderSqlLiteral` on every Postgres codec in `packages/3-targets/3-targets/postgres/src/core/codecs.ts` (`pg/text@1`, `pg/int4@1`, `pg/int2@1`, `pg/int8@1`, `pg/float4@1`, `pg/float8@1`, `pg/bool@1`, `pg/enum@1`, `pg/json@1`, `pg/jsonb@1`, plus any aliased-from-base codecs); tag the integer codecs (`pg/int2@1`, `pg/int4@1`, `pg/int8@1`) with the `autoincrement` trait. Codecs that need their native type for casts (e.g. `pg/jsonb@1` producing `'<...>'::jsonb`) read it from their own descriptor's `meta.db.sql.postgres.nativeType` — no signature widening needed. `pg/enum@1` is an exception (no `meta`; enum type name is per-enum, not codec-static): emit bare `''` and rely on Postgres's column-context cast in DDL — sufficient for `DEFAULT` emission. Add unit tests covering valid inputs and adversarial inputs (quotes, backslashes, NULL bytes, unicode) per codec (satisfies TC-3, TC-5, TC-6, TC-22) +- [ ] Implement `renderSqlLiteral` on every SQLite codec in `packages/3-targets/3-targets/sqlite/src/core/codecs.ts` (`sqlite/text@1`, `sqlite/integer@1`, `sqlite/real@1`, `sqlite/blob@1`, `sqlite/datetime@1`, `sqlite/json@1`, `sqlite/bigint@1`); tag `sqlite/integer@1` only with the `autoincrement` trait. Add unit tests including adversarial inputs (satisfies TC-4, TC-5, TC-6, TC-23) +- [ ] Implement `renderSqlLiteral` on the pgvector extension codec in `packages/3-extensions/pgvector/src/core/codecs.ts` (`pg/vector@1`) with adversarial-input unit tests (satisfies TC-3, TC-5, TC-6 for vector) +- [ ] Implement `renderSqlLiteral` on the arktype-json extension codec in `packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts` (`arktype/json@1`). Note this codec is constructed inside a per-typeParams factory function (`arktypeJsonCodecForSchema`), so the implementation must be present at the call to the SQL `codec(...)` factory; add adversarial-input tests (satisfies TC-3, TC-5, TC-6 for arktype-json) + +**Validation gate:** + +- `pnpm typecheck` +- `pnpm test:packages` +- `pnpm lint` + +### Milestone 2: IR collapse, authoring lower-through-codec, DDL switch + +The atomic flip. `ColumnDefault` collapses to `{ expression: string }`. Both authoring surfaces lower literals through `renderSqlLiteral` (TS DSL) or `decodeJson + renderSqlLiteral` (PSL). DDL renderers in Postgres and SQLite read `expression` directly. `decodeContractDefaults` is removed. `null` literal defaults are handled in a literal pass before codec dispatch; `NULL` on `NOT NULL` columns is rejected. All fixture contracts are regenerated. Mongo paths are validated as untouched. + +Demonstrable: emitted `contract.json` files contain only `{ expression: string }` for defaults; `pnpm fixtures:check` passes; Postgres e2e exercises DDL emission through the new path. + +**Tasks:** + +- [ ] Re-home `ColumnDefault`, `ColumnDefaultLiteralValue`, `ColumnDefaultLiteralInputValue` to `packages/2-sql/1-core/contract/src/types.ts`. Reshape `ColumnDefault` to the discriminated union `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`. Update the Arktype validator in `packages/2-sql/1-core/contract/src/validators.ts` to match (two narrowed schemas joined). Remove any framework-foundation export of these types (satisfies TC-7, TC-8) +- [ ] Remove `decodeContractDefaults` from `packages/2-sql/1-core/contract/src/validate.ts` and remove its call site from `validateContract` (satisfies TC-21) +- [ ] Update TS DSL in `packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts`: + - `.default(value)` types `value` as `TInput | AllowAutoincrement` where `AllowAutoincrement` resolves to the `AutoincrementSentinel` brand iff the codec's `traits` array contains `'autoincrement'`, otherwise `never`. The conditional uses `HasTrait` over the existing `traits` field. + - Export a top-level `autoincrement()` function that returns a uniquely-branded sentinel value (e.g. backed by `Symbol('autoincrement')`). + - Keep `.defaultSql(expression)` (or equivalent) as the explicit function-form escape hatch. + - (satisfies TC-11a, TC-12, TC-13) +- [ ] Update the contract emitter in `packages/2-sql/2-authoring/contract-ts/src/build-contract.ts`: + - If `value === AutoincrementSentinel`, produce `{ kind: 'autoincrement' }` (codec not invoked). + - Function-form defaults pass through as `{ kind: 'expression', expression: '' }`. + - `null` literal defaults: literal pass produces `{ kind: 'expression', expression: 'NULL' }` (codec not invoked). + - `null` literal default on a `NOT NULL` column: emit a diagnostic naming the column path and codec id; no contract entry produced. + - Other literals: invoke `codec.renderSqlLiteral(value)` and stamp `{ kind: 'expression', expression: }`. + - (satisfies TC-11, TC-19, TC-20) +- [ ] Update PSL literal-default lowering in `packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts` (and adjacent PSL files as needed): + - `@default(autoincrement())` is recognized at parse time. If the column codec's `traits` include `'autoincrement'`, it lowers to `{ kind: 'autoincrement' }`. Otherwise PSL emits a diagnostic naming the column path, codec id, and PSL source `file:line`. + - Other function-form (`now()`, `gen_random_uuid()`, etc.) lands as `{ kind: 'expression', expression: '' }` directly. + - Literal: `codec.decodeJson(jsonValue)` then `codec.renderSqlLiteral(decoded)`, stamped as `{ kind: 'expression', expression }`. + - `decodeJson` failures surface as PSL diagnostics carrying column path, codec id, and PSL source `file:line`. + - Same `null` / `NOT NULL` rules as TS DSL emitter (shared literal pass). + - (satisfies TC-14, TC-15, TC-16) +- [ ] Update the PSL printer's `mapDefault` function in `packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts` (lines 15–32). Today it switches on legacy `columnDefault.kind` ('literal' / 'function'); after the reshape it switches on the new union: `'autoincrement'` prints as `@default(autoincrement())`; `'expression'` consults `DEFAULT_FUNCTION_ATTRIBUTES` to map known sentinels (e.g. `now()`, `gen_random_uuid()`) back to PSL attributes, otherwise emits raw-expression form (e.g. `` @default(``) ``). Drop the `formatLiteralValue` helper if it becomes unreachable. Add a round-trip integration test (PSL → contract → PSL) asserting it survives without crashing (literal form may differ). (satisfies TC-17, TC-18) +- [ ] Simplify `buildColumnDefaultSql` in `packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts` to switch on the new `ColumnDefault` union: `kind: 'autoincrement'` returns the empty string (no `DEFAULT` clause; SERIAL/IDENTITY column-type emission elsewhere is unchanged); `kind: 'expression'` returns `DEFAULT (${expression})`. Remove the per-type `renderDefaultLiteral` switch (its responsibilities have moved into the codecs). Delete `assertSafeDefaultExpression` and its call site — the contract is developer-authored and the function's own docstring already states it is not a security boundary. (satisfies TC-32 for Postgres) +- [ ] Apply the analogous simplification to the SQLite DDL renderer in `packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts`. Preserve the existing `now()` → `datetime('now')` translation for the `expression` branch (it remains a useful dialect-specific shorthand); delete `assertSafeDefaultExpression`. Add a runtime/emit-time diagnostic that rejects `{ kind: 'autoincrement' }` on any column that is not an `INTEGER PRIMARY KEY` (SQLite's autoincrement mechanism only operates on the rowid column); the diagnostic names the column path. (satisfies TC-32 for SQLite) +- [ ] Regenerate every fixture contract under `test/integration/test/**/contract.json` (and equivalents found by the scout) so `pnpm fixtures:check` passes against the new shape (satisfies TC-9, TC-10) +- [ ] Verify the Mongo codec interface and concrete Mongo codecs in `packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts` are unchanged at their public surface; run the Mongo-specific test suite to confirm no regression (satisfies TC-24, TC-25) + +**Validation gate:** + +- `pnpm typecheck` +- `pnpm test:packages` +- `pnpm test:e2e` +- `pnpm lint` +- `pnpm lint:deps` +- `pnpm fixtures:check` + +### Milestone 3: Close-out + +Final verification, ADR housekeeping, and project deletion. + +**Tasks:** + +- [ ] Verify every acceptance criterion against its TC(s); record evidence in the close-out PR description (satisfies TC-26 through TC-30 as gate re-runs; satisfies TC-31 as static check) +- [ ] Update ADR 167 (`docs/architecture docs/adrs/ADR 167 - Typed default literal pipeline and extensibility.md`) to status "superseded by codec-owned-defaults"; add a close-out / pointer section to ADR 184 (`docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md`) referencing this work and what it implemented +- [ ] Delete `projects/codec-owned-defaults/` (spec, plan, any transient artefacts). The close-out PR title or body must reference the Linear issue identifier so its linked GitHub integration auto-transitions on merge + +**Validation gate:** + +- `pnpm typecheck` +- `pnpm test:packages` +- `pnpm test:e2e` +- `pnpm lint` +- `pnpm lint:deps` diff --git a/projects/codec-owned-defaults/spec.md b/projects/codec-owned-defaults/spec.md new file mode 100644 index 0000000000..c5345817f0 --- /dev/null +++ b/projects/codec-owned-defaults/spec.md @@ -0,0 +1,161 @@ +# Summary + +Column defaults in the contract IR are stored as `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`. The `expression` branch holds every default that lowers to a `DEFAULT ()` DDL clause — codec-rendered literals and raw function-form expressions alike. The `autoincrement` branch is a payload-free sentinel: it doesn't emit a `DEFAULT` clause at all and is realized at the column-type level (SERIAL/IDENTITY in Postgres, INTEGER PRIMARY KEY AUTOINCREMENT in SQLite). The SQL codec layer owns lowering literal values to dialect-specific expressions via a required `renderSqlLiteral(value: TInput): string` method. Both authoring surfaces (TS DSL and PSL) lower literals through codec methods — TS DSL invokes `renderSqlLiteral` directly, PSL chains `decodeJson + renderSqlLiteral` for runtime validation and rendering. `.default(value)` on the TS DSL is unconditionally available, with the value typed as the column codec's `TInput`. + +# Description + +A column default in `contract.json` sits in `storage.tables[].columns[].default` as a discriminated union: + +- **`{ kind: 'expression'; expression: string }`** — the expression is a complete SQL fragment that the DDL renderer wraps as `DEFAULT ()`. This branch holds both codec-rendered literals (e.g. `'TRUE'`, `'1'`, `'2026-04-30T00:00:00Z'::timestamptz`) and raw function-form expressions authored directly (`now()`, `gen_random_uuid()`). +- **`{ kind: 'autoincrement' }`** — payload-free sentinel. Realized at the column-type level (SERIAL/IDENTITY in Postgres, INTEGER PRIMARY KEY AUTOINCREMENT in SQLite); no `DEFAULT` clause is emitted. + +The contract is target-bound by the time defaults are rendered — every column carries a `codecId`, and the codec for that id owns the dialect-specific spelling of any literal value (`TRUE` vs `1`, `'2026-04-30T00:00:00Z'::timestamptz` vs ISO strings, JSON casts, escape rules). + +Authoring captures literal values transiently: + +- **TS DSL.** `.default(value)` accepts either the column codec's `TInput` or the `autoincrement()` sentinel, where the sentinel is admitted only when the column codec carries the `autoincrement` trait. For a non-sentinel value, the contract emitter dispatches to `codec.renderSqlLiteral(value)` and stamps `{ kind: 'expression', expression: }` into the contract. For the sentinel, the emitter stamps `{ kind: 'autoincrement' }` and bypasses the codec. Function-form authoring (e.g. `.defaultSql('now()')`) also bypasses the codec, landing as `{ kind: 'expression', expression: '' }`. +- **PSL.** The parser produces a `JsonValue` from the schema literal (PSL grammar is JSON-isomorphic). `codec.decodeJson(value)` validates and converts to `TInput`; `codec.renderSqlLiteral(decoded)` produces the expression, recorded as `{ kind: 'expression', expression }`. `decodeJson` failures surface as PSL diagnostics with file:line from the PSL AST. + +The literal value never reaches `contract.json` — the SQL expression does. + +Function-form defaults — `@default(now())`, `@default(gen_random_uuid())` — land directly as `{ kind: 'expression', expression: '' }` without invoking codec methods. The function-form path expresses defaults that aren't reducible to a typed JS value. `@default(autoincrement())` is the only authored form that lands as the `autoincrement` sentinel rather than an expression; it is recognized at parse time, gated on the column codec carrying the `autoincrement` trait (PSL emits a diagnostic if the trait is absent), and lowered to `{ kind: 'autoincrement' }`. + +`null` literal defaults render to `{ kind: 'expression', expression: 'NULL' }` uniformly across dialects, handled in the literal pass before codec dispatch. `renderSqlLiteral(value: TInput)` never receives `null` or `undefined`; codec authors can rely on a defined value. + +Mongo column defaults are runtime-applied (Mongo has no DDL-level default mechanism) and live in `execution.mutations.defaults[]`, separate from the storage-level column default this spec governs. `ColumnDefault` therefore lives in the SQL domain, not the framework foundation; Mongo is unaffected. + +# Requirements + +## Functional Requirements + +**FR1. Contract IR — default shape.** `storage.tables[].columns[].default` is the discriminated union `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`. The `expression` branch carries every default that lowers to a `DEFAULT ()` clause; the `autoincrement` sentinel is payload-free and is realized at the column-type level. The Arktype validator and SQL types reflect this shape. The legacy `kind: 'literal'` branch's `value: JsonValue` payload is removed — literal-form defaults are merged into the `expression` branch and carry only the rendered SQL string. + +**FR2. SQL codec method — `renderSqlLiteral` (required).** The SQL codec interface declares `renderSqlLiteral(value: TInput): string` as a required method. The SQL codec factory rejects construction of any codec that omits it. No identity fallback — codec authors decide dialect-specific spelling explicitly. + +**FR3. TS DSL `.default(value)` is unconditionally available.** Every TS DSL column builder exposes `.default(value)`. The parameter accepts the column codec's `TInput`. It additionally accepts the `autoincrement()` sentinel, conditionally — only when the column codec carries the `autoincrement` trait. The contract emitter invokes `codec.renderSqlLiteral` for non-sentinel values and stamps `{ kind: 'autoincrement' }` for the sentinel. + +**FR3a. `autoincrement` codec trait.** SQL codecs that support autoincrement column-type emission declare an `autoincrement` trait via the existing `traits` array on the codec interface. Postgres tags `pg/int2@1`, `pg/int4@1`, `pg/int8@1`; SQLite tags `sqlite/integer@1` only. Codecs without the trait do not accept the sentinel — `.default(autoincrement())` is a compile error on those columns, and PSL `@default(autoincrement())` produces a build-time diagnostic naming the column, codec id, and PSL source location. + +**FR4. PSL lowers literals through the codec.** PSL literal defaults dispatch through `codec.decodeJson` followed by `codec.renderSqlLiteral`. `decodeJson` failures surface as PSL diagnostics with file:line. + +**FR5. Function-form defaults.** Defaults expressed directly as SQL expressions (e.g. `@default(now())`, `@default(gen_random_uuid())`, `.defaultSql('...')`) land as `{ kind: 'expression', expression: '' }` without invoking codec methods. The single exception is `autoincrement()`, recognized at parse time and lowered to `{ kind: 'autoincrement' }` (payload-free); the DDL renderer emits no `DEFAULT` clause for that branch and relies on column-type SERIAL/IDENTITY/AUTOINCREMENT semantics. + +**FR6. NULL defaults.** A `null` literal default renders to `{ kind: 'expression', expression: 'NULL' }`, handled in the literal pass without invoking the codec. `null` literal defaults on NOT NULL columns are rejected with a diagnostic naming the column. + +**FR7. `ColumnDefault` is a SQL-domain type.** `ColumnDefault` and its associated literal-input types live in the SQL domain, not the framework foundation. The Mongo codec interface gains nothing. + +## Non-Functional Requirements + +**NFR1. Type safety.** `.default(invalidValue)` where `invalidValue` does not match the column codec's `TInput` is a compile error in the TS DSL. No `any`, `as`, or `@ts-expect-error` in the implementation. + +**NFR2. JS-native default values pass through without JSON round-trips in the TS DSL.** `Date`, `bigint`, `Buffer`, `Uint8Array`, and codec-defined branded types are accepted by `.default(...)` directly, where the codec's `TInput` admits them. PSL inputs go through `JsonValue` (PSL grammar is JSON-isomorphic), then through `codec.decodeJson` to `TInput` before rendering. + +**NFR3. Dialect coverage is structural.** Because `renderSqlLiteral` is required by the codec factory, coverage is enforced at type-check time, not asserted by tests. + +**NFR4. Diagnostics.** Failures include the column path (`table.column`) and codec id. PSL-side failures additionally include the PSL source location (file:line). Covered failure modes: NOT NULL with NULL default; PSL value rejected by `codec.decodeJson` (type mismatch, malformed input). + +## Non-goals + +- **Mongo storage defaults.** Mongo's runtime-applied default story (literal generators in `execution.mutations.defaults[]`) is a separate spec. +- **Surfacing the default's typed value in `contract.d.ts` column signatures** (e.g. making defaulted columns optional on insert types). +- **Reverse parsing of SQL expressions back to JS values.** The literal-to-expression direction is one-way at emit time. PSL printer round-trip is lossy by design: a contract whose defaults originated as PSL `@default(true)` may print back as `@default(\`TRUE\`)` (i.e. the rendered SQL expression in raw-expression form). Behaviour is preserved; literal form is not. If round-trip fidelity becomes a concern later, a codec-side reverse hook can be added without changing the IR. +- **PSL static type checking of default values against codec `TInput`.** PSL is parsed at runtime; type checking happens at lowering via `codec.decodeJson`. +- **Migration tooling that diff-renders defaults across versions.** + +# Acceptance Criteria + +## Contract IR + +- [ ] `ColumnDefault` is the discriminated union `{ kind: 'expression'; expression: string } | { kind: 'autoincrement' }`. The legacy `kind: 'literal'` and `kind: 'function'` variants no longer exist; the legacy `value: JsonValue` payload is gone. +- [ ] `ColumnDefault`, `ColumnDefaultLiteralValue`, and `ColumnDefaultLiteralInputValue` live under `packages/2-sql/1-core/contract/src/`. None are exported from the framework foundation. +- [ ] All fixture contracts (`test/integration/test/**/contract.json` and equivalent) emit only the new shape: every default is either `{ kind: 'expression', expression: }` or `{ kind: 'autoincrement' }`. No `value` field, no `literal`/`function` kinds appear. +- [ ] `pnpm fixtures:check` passes. + +## SQL Codec + +- [ ] The SQL codec interface declares `renderSqlLiteral(value: TInput): string` as a required method. +- [ ] The SQL codec factory rejects construction of any codec that omits `renderSqlLiteral`. A compile-time test demonstrates this. +- [ ] All Postgres codecs implement `renderSqlLiteral`. +- [ ] All SQLite codecs implement `renderSqlLiteral` consistently with the SQLite dialect. +- [ ] No codec implementation throws "not implemented" at runtime. +- [ ] Each codec's renderer is unit-tested with adversarial inputs (quotes, backslashes, NULL bytes, unicode). + +## Authoring — TS DSL + +- [ ] The contract emitter invokes `codec.renderSqlLiteral` during emission; literal-form defaults on disk are `{ kind: 'expression', expression: }`. +- [ ] `.default(value)` is available on every column builder, with `value` typed as the column codec's `TInput`. Compile-time tests demonstrate that mismatched types fail to compile and matching types succeed across a representative set of codecs. +- [ ] `.default(autoincrement())` compiles on column builders whose codec carries the `autoincrement` trait, and fails to compile on column builders whose codec does not. The TS DSL emitter lowers `.default(autoincrement())` to `{ kind: 'autoincrement' }` without invoking the codec. + +## Authoring — PSL + +- [ ] PSL literal defaults dispatch through `codec.decodeJson` followed by `codec.renderSqlLiteral` and land as `{ kind: 'expression', expression }`. +- [ ] PSL `@default()` (e.g. `@default(true)` on an int column) fails with a diagnostic naming the column, codec id, and PSL source location. +- [ ] PSL function-form defaults (e.g. `@default(now())`, `@default(gen_random_uuid())`) land as `{ kind: 'expression', expression: '' }` without invoking codec methods. `@default(autoincrement())` is recognized at parse time and, if the column codec carries the `autoincrement` trait, lowers to `{ kind: 'autoincrement' }`; otherwise PSL emits a diagnostic naming the column, codec id, and PSL source location. +- [ ] PSL printer reads the new `ColumnDefault` shape: `{ kind: 'autoincrement' }` prints as `@default(autoincrement())`; `{ kind: 'expression', expression }` maps known sentinels (e.g. `now()`, `gen_random_uuid()`) back to PSL attributes via the existing `DEFAULT_FUNCTION_ATTRIBUTES` lookup, otherwise emits raw-expression form. A round-trip test asserts that defaults survive PSL → contract → PSL emission without crashing (literal form is allowed to differ). + +## Semantics & Diagnostics + +- [ ] NOT NULL columns with `null` literal defaults are rejected before codec dispatch, with a diagnostic naming the column. +- [ ] `null` defaults render to `{ kind: 'expression', expression: 'NULL' }`, handled in the literal pass. +- [ ] No `decodeContractDefaults` function exists in `packages/2-sql/1-core/contract/src/validate.ts`. +- [ ] Tests assert `Date`, `bigint`, `Buffer`, and JSON values render to the expected SQL expressions for each dialect. +- [ ] DDL renderer emits no `DEFAULT` clause for `{ kind: 'autoincrement' }`; SERIAL/IDENTITY (Postgres) and INTEGER PRIMARY KEY AUTOINCREMENT (SQLite) column-type emission is unchanged. + +## Mongo + +- [ ] The Mongo codec interface and concrete Mongo codecs are unchanged. +- [ ] Mongo authoring/emission paths do not regress. + +## Quality Gates + +- [ ] `pnpm typecheck` passes across the workspace. +- [ ] `pnpm lint` passes across all packages. +- [ ] `pnpm lint:deps` passes (no new layering violations). +- [ ] `pnpm test:packages` passes. +- [ ] `pnpm test:e2e` passes (covers end-to-end emission and DDL paths through Postgres). +- [ ] No `any`, `@ts-expect-error` (outside negative type tests), or `as unknown as` casts introduced. + +# Other Considerations + +## Security + +`renderSqlLiteral` produces SQL fragments embedded in DDL. Implementations must escape values correctly per dialect (single quotes, backslashes, identifier-vs-literal context). Escaping is owned by the codec; the emitter does no string concatenation. Adversarial-input unit tests (quotes, backslashes, NULL bytes, unicode) accompany each codec's renderer. + +## Cost + +Negligible. Build/emit cost gains one method dispatch per column with a default; no runtime cost surface. + +## Observability + +No new metrics or alerts. Lowering failures surface as build/emit-time errors with column path and codec id. + +## Data Protection + +Not applicable. No personal data flows through this change. + +## Analytics + +Not applicable. Internal tooling change. + +# References + +## Code + +- `packages/1-framework/1-core/framework-components/src/shared/codec-types.ts` — framework codec interface (`encodeJson` / `decodeJson`) +- `packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts` — SQL codec interface; declares `renderSqlLiteral` +- `packages/2-sql/1-core/contract/src/types.ts` — SQL `ColumnDefault` +- `packages/2-sql/1-core/contract/src/validators.ts` — SQL Arktype validators +- `packages/2-sql/1-core/contract/src/validate.ts` — contract validation entry +- `packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts` — TS DSL `.default(...)` +- `packages/2-sql/2-authoring/contract-ts/src/build-contract.ts` — TS DSL emission path +- `packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts` — PSL literal default parsing +- `packages/1-framework/2-authoring/psl-printer/src/schema-validation.ts` — PSL printer +- `packages/3-targets/3-targets/postgres/src/core/codecs.ts` — Postgres codec implementations +- `packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts` — Postgres DDL renderer; delegates to `renderSqlLiteral` +- `packages/3-targets/3-targets/sqlite/src/core/codecs.ts` — SQLite codec implementations +- `packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts` — Mongo codec (unaffected) + +## Architectural context + +- `docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md` — the broader codec-owned serialization plan; this spec implements the DDL-rendering and PSL-rendering halves directly on the SQL codec interface. +- `docs/architecture docs/adrs/ADR 167 - Typed default literal pipeline and extensibility.md` — older ADR on the typed default pipeline; superseded by this spec. From cec494c8b3318c4ba1728de821d653949253a002 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 14:24:53 +0200 Subject: [PATCH 02/50] =?UTF-8?q?feat(sql)!:=20M1=20=E2=80=94=20make=20ren?= =?UTF-8?q?derSqlLiteral=20required=20on=20every=20SQL=20codec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQL codec interface gains a required `renderSqlLiteral(value: TInput): string` method. Structural enforcement lives on a new abstract `SqlCodecImpl` base class: subclasses that omit the method remain abstract and cannot be instantiated (TS2515). Every concrete SQL codec — Postgres, SQLite, the base codecs, pgvector, arktype-json, and cipherstash — extends `SqlCodecImpl` and implements `renderSqlLiteral`. The cipherstash implementation throws (encrypted envelopes can't carry a DDL default; encryption is bound to insert-time middleware). `CodecTrait` gains `'autoincrement'`; the Postgres integer codecs (`pg/int2@1`, `pg/int4@1`, `pg/int8@1`) and SQLite's `sqlite/integer@1` declare it. The trait is read by the type-system gate that admits `.default(autoincrement())` in M2. Tests cover the rendered SQL for every codec, including adversarial inputs (quotes, backslashes, NULL bytes, unicode). The negative type test asserts the abstract method is structurally required at the class-construction site. M1 is purely additive — no producer or consumer of `ColumnDefault` changes yet. The DDL renderer continues to use its existing per-type logic until M2. Spec / plan: projects/codec-owned-defaults/ Signed-off-by: Serhii Tatarintsev --- .../src/shared/codec-types.ts | 2 +- .../relational-core/src/ast/codec-types.ts | 7 +- .../src/ast/sql-codec-helpers.ts | 24 ++ .../relational-core/src/ast/sql-codec-impl.ts | 19 ++ .../relational-core/src/ast/sql-codecs.ts | 41 ++- .../relational-core/src/exports/ast.ts | 1 + .../test/ast/sql-codec-impl.types.test-d.ts | 56 ++++ .../ast/sql-codecs.render-sql-literal.test.ts | 102 +++++++ .../relational-core/test/ast/test-codec.ts | 9 + .../src/codecs/ast-codec-resolver.ts | 6 +- .../5-runtime/test/codec-integrity.test.ts | 3 + .../test/sql-context.codec-context.test.ts | 9 + packages/2-sql/5-runtime/test/test-codec.ts | 9 + .../src/core/arktype-json-codec.ts | 12 +- ...type-json-codec.render-sql-literal.test.ts | 34 +++ .../src/execution/cell-codec-factory.ts | 27 +- .../3-extensions/pgvector/src/core/codecs.ts | 10 +- .../test/codecs.render-sql-literal.test.ts | 27 ++ .../sql-orm-client/test/test-codec.ts | 9 + .../postgres/src/core/codec-helpers.ts | 25 ++ .../3-targets/postgres/src/core/codecs.ts | 125 +++++++-- .../postgres/test/codecs-class.test.ts | 4 +- .../test/codecs.render-sql-literal.test.ts | 261 ++++++++++++++++++ .../test/typed-descriptor-flow.test-d.ts | 6 +- .../3-targets/sqlite/src/core/codecs.ts | 44 ++- .../test/codecs.render-sql-literal.test.ts | 116 ++++++++ .../test/typed-descriptor-flow.test-d.ts | 6 +- .../test/sql-renderer.cast-policy.test.ts | 8 +- .../6-adapters/postgres/test/test-codec.ts | 9 + 29 files changed, 938 insertions(+), 73 deletions(-) create mode 100644 packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-impl.ts create mode 100644 packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-impl.types.test-d.ts create mode 100644 packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.render-sql-literal.test.ts create mode 100644 packages/3-extensions/arktype-json/test/arktype-json-codec.render-sql-literal.test.ts create mode 100644 packages/3-extensions/pgvector/test/codecs.render-sql-literal.test.ts create mode 100644 packages/3-targets/3-targets/postgres/test/codecs.render-sql-literal.test.ts create mode 100644 packages/3-targets/3-targets/sqlite/test/codecs.render-sql-literal.test.ts diff --git a/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts b/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts index c12e0b15a5..8df08a1c8c 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts @@ -2,7 +2,7 @@ import type { JsonValue } from '@prisma-next/contract/types'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { Codec } from './codec'; -export type CodecTrait = 'equality' | 'order' | 'boolean' | 'numeric' | 'textual'; +export type CodecTrait = 'equality' | 'order' | 'boolean' | 'numeric' | 'textual' | 'autoincrement'; /** * Serializable codec identity carried by every codec-bearing AST node. diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts index a617f26c51..1c72a370b8 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts @@ -60,9 +60,11 @@ export interface CodecMeta { } /** - * SQL codec — extends the framework codec base by narrowing the per-call context to the SQL-family {@link SqlCodecCallContext} (adds `column?: SqlColumnRef`). TypeScript treats method-syntax declarations bivariantly, so the SQL narrowing is structurally compatible with the framework {@link BaseCodec} super-interface. + * SQL codec — extends the framework codec base by narrowing the per-call context to the SQL-family {@link SqlCodecCallContext} (adds `column?: SqlColumnRef`) and adding the required build-time `renderSqlLiteral(value)` method that lowers a `TInput` to a dialect-specific SQL expression. TypeScript treats method-syntax declarations bivariantly, so the SQL narrowing is structurally compatible with the framework {@link BaseCodec} super-interface. * - * Codec-id-keyed static metadata (`traits`, `targetTypes`, `meta`, `paramsSchema`, `renderOutputType`) lives on the unified {@link import('@prisma-next/framework-components/codec').CodecDescriptor} — the codec instance itself only carries `id` plus the four conversion methods. + * `renderSqlLiteral` is required on every SQL codec — there is no identity fallback. Codec authors decide the dialect-specific spelling of literal values (`TRUE`/`FALSE` vs `1`/`0`, ISO-8601 with `::timestamptz` cast vs bare strings, escape rules for embedded quotes/backslashes/NULL bytes, etc.). The returned string is a complete SQL fragment the DDL renderer wraps as `DEFAULT ()`. Adversarial inputs (quotes, backslashes, NULL bytes, unicode) must be escaped correctly per dialect — the emitter performs no string concatenation around the result. + * + * Codec-id-keyed static metadata (`traits`, `targetTypes`, `meta`, `paramsSchema`, `renderOutputType`) lives on the unified {@link import('@prisma-next/framework-components/codec').CodecDescriptor} — the codec instance itself carries `id`, the four conversion methods, and `renderSqlLiteral`. * * See `Codec` in `@prisma-next/framework-components/codec` for the codec contract that this interface extends. */ @@ -74,6 +76,7 @@ export interface Codec< > extends BaseCodec { encode(value: TInput, ctx: SqlCodecCallContext): Promise; decode(wire: TWire, ctx: SqlCodecCallContext): Promise; + renderSqlLiteral(value: TInput): string; } /** diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts index a276338058..80db0d35b2 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts @@ -6,6 +6,30 @@ import type { JsonValue } from '@prisma-next/contract/types'; +/** + * Standard-SQL escape for a single-quoted string literal. + * + * Doubles embedded single quotes (`O'Brien` -> `O''Brien`) and rejects embedded NULL bytes (`\0`) which most relational engines either truncate at or refuse outright. Backslashes pass through as literal characters — Postgres with `standard_conforming_strings` (the default since 9.1) and SQLite both treat backslashes literally inside `'…'` strings. + * + * The returned string excludes the wrapping quotes; callers concatenate them. + */ +export function escapeStandardSqlLiteral(value: string): string { + if (value.includes('\0')) { + throw new Error('SQL literal value cannot contain NULL bytes'); + } + return value.replace(/'/g, "''"); +} + +/** + * Read the Postgres native type name (e.g. `'integer'`, `'character'`, `'character varying'`) from a codec descriptor's `meta` slot. Returns `undefined` when the descriptor carries no Postgres meta — used by SQL base codec renderers (which are dialect-neutral by name but render with a Postgres-style `::cast` suffix when aliased through a Postgres descriptor). + */ +export function readPostgresNativeTypeFromMeta(meta: unknown): string | undefined { + const m = meta as + | { readonly db?: { readonly sql?: { readonly postgres?: { readonly nativeType?: string } } } } + | undefined; + return m?.db?.sql?.postgres?.nativeType; +} + export const SQL_CHAR_CODEC_ID = 'sql/char@1' as const; export const SQL_VARCHAR_CODEC_ID = 'sql/varchar@1' as const; export const SQL_INT_CODEC_ID = 'sql/int@1' as const; diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-impl.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-impl.ts new file mode 100644 index 0000000000..99cb1e7ff1 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-impl.ts @@ -0,0 +1,19 @@ +/** + * Abstract base class for concrete SQL codec implementations. + * + * Extends the framework {@link CodecImpl} and adds the abstract `renderSqlLiteral(value)` method as the SQL family's structural enforcement of dialect-specific literal rendering. Concrete SQL codec subclasses (`SqlTextCodec`, `PgInt4Codec`, `SqliteIntegerCodec`, etc.) extend this class instead of the framework `CodecImpl`; the abstract method makes omitting `renderSqlLiteral` a compile-time error at the class-construction site. + * + * `renderSqlLiteral` returns a complete SQL fragment (e.g. `'TRUE'`, `'42'`, `''escaped string''`, `''2026-04-30T00:00:00Z'::timestamptz`) that the DDL renderer wraps as `DEFAULT ()`. Authors own dialect-specific escaping for adversarial inputs (single quotes, backslashes, NULL bytes, unicode) — the emitter performs no string concatenation around the result. + */ + +import type { CodecTrait } from '@prisma-next/framework-components/codec'; +import { CodecImpl } from '@prisma-next/framework-components/codec'; + +export abstract class SqlCodecImpl< + Id extends string = string, + TTraits extends readonly CodecTrait[] = readonly CodecTrait[], + TWire = unknown, + TInput = unknown, +> extends CodecImpl { + abstract renderSqlLiteral(value: TInput): string; +} diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts index dbcf1125b1..87b2e2f153 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts @@ -3,7 +3,7 @@ * * Each codec ships as three artifacts: * - * 1. A `SqlXCodec` class extending {@link CodecImpl} that wraps the module-level encode/decode constants exported from `sql-codec-helpers.ts` (the single source of truth for runtime behaviour). 2. A `SqlXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, and (where applicable) the emit-path `renderOutputType`. 3. A per-codec column helper (`sqlXColumn`) + * 1. A `SqlXCodec` class extending {@link SqlCodecImpl} that wraps the module-level encode/decode constants exported from `sql-codec-helpers.ts` (the single source of truth for runtime behaviour). 2. A `SqlXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, and (where applicable) the emit-path `renderOutputType`. 3. A per-codec column helper (`sqlXColumn`) * that calls `descriptor.factory(...)` directly and packages the result into a {@link ColumnSpec} via the framework {@link column} packager. The helper is tied to its descriptor with `satisfies ColumnHelperFor`. * * After TML-2357 this file is the canonical source of SQL base codec metadata and runtime behaviour — the legacy `mkCodec` / `defineCodec` carriers retired with the deletion sweep. @@ -13,7 +13,6 @@ import type { JsonValue } from '@prisma-next/contract/types'; import { type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnHelperForStrict, @@ -23,6 +22,8 @@ import { import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; import { + escapeStandardSqlLiteral, + readPostgresNativeTypeFromMeta, SQL_CHAR_CODEC_ID, SQL_FLOAT_CODEC_ID, SQL_INT_CODEC_ID, @@ -47,6 +48,12 @@ import { sqlVarcharEncode, sqlVarcharRenderOutputType, } from './sql-codec-helpers'; +import { SqlCodecImpl } from './sql-codec-impl'; + +function appendPgCast(literal: string, descriptor: { readonly meta?: unknown }): string { + const nativeType = readPostgresNativeTypeFromMeta(descriptor.meta); + return nativeType ? `${literal}::${nativeType}` : literal; +} type LengthParams = { readonly length?: number }; type PrecisionParams = { readonly precision?: number }; @@ -59,7 +66,7 @@ const precisionParamsSchema = arktype({ 'precision?': 'number.integer >= 0 & number.integer <= 6', }) satisfies StandardSchemaV1; -export class SqlTextCodec extends CodecImpl< +export class SqlTextCodec extends SqlCodecImpl< typeof SQL_TEXT_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -77,6 +84,9 @@ export class SqlTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return appendPgCast(`'${escapeStandardSqlLiteral(value)}'`, this.descriptor); + } } export class SqlTextDescriptor extends CodecDescriptorImpl { @@ -97,7 +107,7 @@ export const sqlTextColumn = () => sqlTextColumn satisfies ColumnHelperFor; sqlTextColumn satisfies ColumnHelperForStrict; -export class SqlIntCodec extends CodecImpl< +export class SqlIntCodec extends SqlCodecImpl< typeof SQL_INT_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -115,6 +125,9 @@ export class SqlIntCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return appendPgCast(String(value), this.descriptor); + } } export class SqlIntDescriptor extends CodecDescriptorImpl { @@ -135,7 +148,7 @@ export const sqlIntColumn = () => sqlIntColumn satisfies ColumnHelperFor; sqlIntColumn satisfies ColumnHelperForStrict; -export class SqlFloatCodec extends CodecImpl< +export class SqlFloatCodec extends SqlCodecImpl< typeof SQL_FLOAT_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -153,6 +166,9 @@ export class SqlFloatCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return appendPgCast(String(value), this.descriptor); + } } export class SqlFloatDescriptor extends CodecDescriptorImpl { @@ -173,7 +189,7 @@ export const sqlFloatColumn = () => sqlFloatColumn satisfies ColumnHelperFor; sqlFloatColumn satisfies ColumnHelperForStrict; -export class SqlCharCodec extends CodecImpl< +export class SqlCharCodec extends SqlCodecImpl< typeof SQL_CHAR_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -191,6 +207,9 @@ export class SqlCharCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return appendPgCast(`'${escapeStandardSqlLiteral(value)}'`, this.descriptor); + } } export class SqlCharDescriptor extends CodecDescriptorImpl { @@ -214,7 +233,7 @@ export const sqlCharColumn = (params: LengthParams = {}) => sqlCharColumn satisfies ColumnHelperFor; sqlCharColumn satisfies ColumnHelperForStrict; -export class SqlVarcharCodec extends CodecImpl< +export class SqlVarcharCodec extends SqlCodecImpl< typeof SQL_VARCHAR_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -232,6 +251,9 @@ export class SqlVarcharCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return appendPgCast(`'${escapeStandardSqlLiteral(value)}'`, this.descriptor); + } } export class SqlVarcharDescriptor extends CodecDescriptorImpl { @@ -255,7 +277,7 @@ export const sqlVarcharColumn = (params: LengthParams = {}) => sqlVarcharColumn satisfies ColumnHelperFor; sqlVarcharColumn satisfies ColumnHelperForStrict; -export class SqlTimestampCodec extends CodecImpl< +export class SqlTimestampCodec extends SqlCodecImpl< typeof SQL_TIMESTAMP_CODEC_ID, readonly ['equality', 'order'], Date, @@ -273,6 +295,9 @@ export class SqlTimestampCodec extends CodecImpl< decodeJson(json: JsonValue): Date { return sqlTimestampDecodeJson(json); } + renderSqlLiteral(value: Date): string { + return appendPgCast(`'${value.toISOString()}'`, this.descriptor); + } } export class SqlTimestampDescriptor extends CodecDescriptorImpl { diff --git a/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts b/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts index 629fd4b1b1..cc3a57cc44 100644 --- a/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts +++ b/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts @@ -2,6 +2,7 @@ export * from '../ast/adapter-types'; export * from '../ast/codec-types'; export * from '../ast/driver-types'; export * from '../ast/sql-codec-helpers'; +export * from '../ast/sql-codec-impl'; export * from '../ast/sql-codecs'; export * from '../ast/types'; export * from '../ast/util'; diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-impl.types.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-impl.types.test-d.ts new file mode 100644 index 0000000000..cada9293fb --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-impl.types.test-d.ts @@ -0,0 +1,56 @@ +/** + * Negative type tests for the SQL codec construction factory. + * + * `SqlCodecImpl` declares `renderSqlLiteral` as an abstract method; subclasses that omit it are themselves abstract, and instantiating an abstract class with `new` is a compile-time error. This pins the SQL codec construction surface as the structural enforcement point for the codec-owned default-rendering contract. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; +import type { AnyCodecDescriptor } from '@prisma-next/framework-components/codec'; +import { test } from 'vitest'; +import { SqlCodecImpl } from '../../src/ast/sql-codec-impl'; + +declare const fakeDescriptor: AnyCodecDescriptor; + +class CompleteSqlCodec extends SqlCodecImpl<'demo/complete@1', readonly [], string, string> { + override async encode(value: string): Promise { + return value; + } + override async decode(wire: string): Promise { + return wire; + } + override encodeJson(value: string): JsonValue { + return value; + } + override decodeJson(json: JsonValue): string { + return json as string; + } + override renderSqlLiteral(value: string): string { + return `'${value}'`; + } +} + +// @ts-expect-error non-abstract subclass that omits the abstract `renderSqlLiteral` is a compile error (TS2515). +class IncompleteSqlCodec extends SqlCodecImpl<'demo/incomplete@1', readonly [], string, string> { + override async encode(value: string): Promise { + return value; + } + override async decode(wire: string): Promise { + return wire; + } + override encodeJson(value: string): JsonValue { + return value; + } + override decodeJson(json: JsonValue): string { + return json as string; + } +} + +test('SqlCodecImpl admits construction when renderSqlLiteral is implemented', () => { + // Positive case — every abstract member is implemented, so `new` is allowed. + new CompleteSqlCodec(fakeDescriptor); +}); + +test('SqlCodecImpl rejects construction when renderSqlLiteral is omitted', () => { + // Reference the rejected class so it isn't pruned; the compile-time assertion lives on its declaration. + void IncompleteSqlCodec; +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.render-sql-literal.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.render-sql-literal.test.ts new file mode 100644 index 0000000000..44f8a0b1db --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.render-sql-literal.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + sqlCharDescriptor, + sqlFloatDescriptor, + sqlIntDescriptor, + sqlTextDescriptor, + sqlTimestampDescriptor, + sqlVarcharDescriptor, +} from '../../src/ast/sql-codecs'; + +const instanceCtx = { name: '' }; + +describe('renderSqlLiteral on SQL base codecs', () => { + describe('sql/text@1', () => { + const codec = sqlTextDescriptor.factory()(instanceCtx); + + it('renders ASCII strings as quoted literals', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'"); + }); + + it('doubles embedded single quotes', () => { + expect(codec.renderSqlLiteral("O'Brien")).toBe("'O''Brien'"); + }); + + it('preserves backslashes as literal characters', () => { + expect(codec.renderSqlLiteral('a\\b')).toBe("'a\\b'"); + }); + + it('rejects NULL bytes', () => { + expect(() => codec.renderSqlLiteral('a\0b')).toThrow(); + }); + + it('passes unicode characters through verbatim', () => { + expect(codec.renderSqlLiteral('naïve résumé 日本語')).toBe("'naïve résumé 日本語'"); + }); + }); + + describe('sql/char@1', () => { + const codec = sqlCharDescriptor.factory({})(instanceCtx); + + it('renders fixed-length strings as quoted literals', () => { + expect(codec.renderSqlLiteral('abc')).toBe("'abc'"); + }); + + it('escapes embedded single quotes', () => { + expect(codec.renderSqlLiteral("a'b")).toBe("'a''b'"); + }); + }); + + describe('sql/varchar@1', () => { + const codec = sqlVarcharDescriptor.factory({})(instanceCtx); + + it('renders variable-length strings as quoted literals', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'"); + }); + + it('escapes embedded single quotes', () => { + expect(codec.renderSqlLiteral("a'b")).toBe("'a''b'"); + }); + }); + + describe('sql/int@1', () => { + const codec = sqlIntDescriptor.factory()(instanceCtx); + + it('renders integers as numeric literals', () => { + expect(codec.renderSqlLiteral(42)).toBe('42'); + }); + + it('renders zero', () => { + expect(codec.renderSqlLiteral(0)).toBe('0'); + }); + + it('renders negative integers', () => { + expect(codec.renderSqlLiteral(-7)).toBe('-7'); + }); + }); + + describe('sql/float@1', () => { + const codec = sqlFloatDescriptor.factory()(instanceCtx); + + it('renders floats as numeric literals', () => { + expect(codec.renderSqlLiteral(3.14)).toBe('3.14'); + }); + + it('renders integral floats', () => { + expect(codec.renderSqlLiteral(1)).toBe('1'); + }); + }); + + describe('sql/timestamp@1', () => { + const codec = sqlTimestampDescriptor.factory({})(instanceCtx); + + it('renders Date values as ISO-8601 string literals', () => { + const d = new Date('2026-04-30T12:34:56.789Z'); + expect(codec.renderSqlLiteral(d)).toBe("'2026-04-30T12:34:56.789Z'"); + }); + + it('renders epoch as ISO literal', () => { + expect(codec.renderSqlLiteral(new Date(0))).toBe("'1970-01-01T00:00:00.000Z'"); + }); + }); +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts b/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts index 7ac22927e8..c634e5bc40 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts @@ -26,6 +26,7 @@ export function defineTestCodec< targetTypes?: readonly string[]; encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + renderSqlLiteral?: (value: TInput) => string; traits?: TTraits; } & JsonRoundTripConfig, ): Codec { @@ -36,6 +37,13 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const renderSqlLiteral = + config.renderSqlLiteral ?? + ((_value: TInput): string => { + throw new Error( + `defineTestCodec(${config.typeId}): renderSqlLiteral is not configured. Tests that exercise SQL literal rendering must supply a renderSqlLiteral implementation.`, + ); + }); return { id: config.typeId, encode: (value, ctx) => { @@ -54,5 +62,6 @@ export function defineTestCodec< }, encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + renderSqlLiteral, } as Codec; } diff --git a/packages/2-sql/5-runtime/src/codecs/ast-codec-resolver.ts b/packages/2-sql/5-runtime/src/codecs/ast-codec-resolver.ts index acd8d72761..1fd79cdb00 100644 --- a/packages/2-sql/5-runtime/src/codecs/ast-codec-resolver.ts +++ b/packages/2-sql/5-runtime/src/codecs/ast-codec-resolver.ts @@ -58,9 +58,11 @@ export function createAstCodecResolver( : ref, ); const ctx = instanceContextFor(ref); - // The descriptor's `factory` is typed against its own `P`; the registry erases `P` to `unknown`, so callers narrow per codec id at the dispatch boundary. The descriptor's `paramsSchema` validates the input above before we forward it, so this narrow is safe by construction. + // The descriptor's `factory` is typed against its own `P`; the registry erases `P` to `unknown`, so callers narrow per codec id at the dispatch boundary. The descriptor's `paramsSchema` validates the input above before we forward it, so the param narrow is safe by construction. The cast routes through `unknown` because the framework `Codec` produced by an erased descriptor doesn't structurally carry the SQL family's required `renderSqlLiteral` method; every concrete SQL codec materialised here extends `SqlCodecImpl`, which enforces the method at the construction site. const codec = ( - descriptor.factory as (params: unknown) => (ctx: SqlCodecInstanceContext) => Codec + descriptor.factory as unknown as ( + params: unknown, + ) => (ctx: SqlCodecInstanceContext) => Codec )(validated)(ctx); cache.set(key, codec); diff --git a/packages/2-sql/5-runtime/test/codec-integrity.test.ts b/packages/2-sql/5-runtime/test/codec-integrity.test.ts index 5ea057888a..5071e315f3 100644 --- a/packages/2-sql/5-runtime/test/codec-integrity.test.ts +++ b/packages/2-sql/5-runtime/test/codec-integrity.test.ts @@ -20,6 +20,9 @@ describe('createExecutionContext — column codec integrity', () => { decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + renderSqlLiteral: () => { + throw new Error('renderSqlLiteral not configured on integrity-test stub codec'); + }, }; } diff --git a/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts index 0e01d818c9..d54ed883cf 100644 --- a/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts +++ b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts @@ -33,6 +33,9 @@ describe('buildContractCodecRegistry — per-column codec instance context', () decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + renderSqlLiteral: () => { + throw new Error('renderSqlLiteral not configured on context-capturing stub codec'); + }, }; instances.push({ ctx, codec }); return codec; @@ -142,6 +145,9 @@ describe('buildContractCodecRegistry — forCodecRef content-keyed cache', () => decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + renderSqlLiteral: () => { + throw new Error('renderSqlLiteral not configured on pgvector-stub test codec'); + }, }; return Object.assign({}, codec, { meta: { length: params.length, ctxName: ctx.name }, @@ -388,6 +394,9 @@ describe('buildContractCodecRegistry — forColumn delegates to forCodecRef', () decode: (w: unknown) => Promise.resolve(w), encodeJson: (v) => v as never, decodeJson: (j) => j as never, + renderSqlLiteral: () => { + throw new Error('renderSqlLiteral not configured on shared stub codec'); + }, }; instances.push({ ctx, codec }); return codec; diff --git a/packages/2-sql/5-runtime/test/test-codec.ts b/packages/2-sql/5-runtime/test/test-codec.ts index 4185017321..e6569f7062 100644 --- a/packages/2-sql/5-runtime/test/test-codec.ts +++ b/packages/2-sql/5-runtime/test/test-codec.ts @@ -28,6 +28,7 @@ export function defineTestCodec< targetTypes?: readonly string[]; encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + renderSqlLiteral?: (value: TInput) => string; traits?: TTraits; } & JsonRoundTripConfig, ): Codec { @@ -38,6 +39,13 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const renderSqlLiteral = + config.renderSqlLiteral ?? + ((_value: TInput): string => { + throw new Error( + `defineTestCodec(${config.typeId}): renderSqlLiteral is not configured. Tests that exercise SQL literal rendering must supply a renderSqlLiteral implementation.`, + ); + }); return { id: config.typeId, encode: (value, ctx) => { @@ -56,5 +64,6 @@ export function defineTestCodec< }, encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + renderSqlLiteral, } as Codec; } diff --git a/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts b/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts index bacd5cfff6..986795cc6e 100644 --- a/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts +++ b/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts @@ -16,13 +16,13 @@ import { type AnyCodecDescriptor, type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnSpec, column, } from '@prisma-next/framework-components/codec'; import { isRuntimeError, runtimeError } from '@prisma-next/framework-components/runtime'; +import { SqlCodecImpl } from '@prisma-next/sql-relational-core/ast'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { ArkErrors, ark, type Type, type } from 'arktype'; @@ -156,7 +156,7 @@ function renderArktypeJsonOutputType(params: ArktypeJsonTypeParams): string { return expression.length > 0 ? expression : 'unknown'; } -export class ArktypeJsonCodecClass extends CodecImpl< +export class ArktypeJsonCodecClass extends SqlCodecImpl< typeof ARKTYPE_JSON_CODEC_ID, readonly ['equality'], string | JsonValue, @@ -184,6 +184,14 @@ export class ArktypeJsonCodecClass extends CodecImpl< decodeJson(json: JsonValue): TInferred { return validateSchema(this.schema, json); } + + renderSqlLiteral(value: TInferred): string { + const json = serializeWire(value); + if (json.includes('\0')) { + throw new Error('arktype-json literal cannot contain NULL bytes'); + } + return `'${json.replace(/'/g, "''")}'::jsonb`; + } } const arktypeJsonParamsSchema = type({ diff --git a/packages/3-extensions/arktype-json/test/arktype-json-codec.render-sql-literal.test.ts b/packages/3-extensions/arktype-json/test/arktype-json-codec.render-sql-literal.test.ts new file mode 100644 index 0000000000..9ef5517f79 --- /dev/null +++ b/packages/3-extensions/arktype-json/test/arktype-json-codec.render-sql-literal.test.ts @@ -0,0 +1,34 @@ +import { type } from 'arktype'; +import { describe, expect, it } from 'vitest'; +import { arktypeJsonColumn } from '../src/core/arktype-json-codec'; + +const instanceCtx = { name: '' }; + +describe('renderSqlLiteral on arktype/json@1', () => { + it('renders schema-validated objects as quoted JSON literals with jsonb cast', () => { + const codec = arktypeJsonColumn(type({ a: 'number' })).codecFactory(instanceCtx); + expect(codec.renderSqlLiteral({ a: 1 })).toBe('\'{"a":1}\'::jsonb'); + }); + + it('doubles embedded single quotes inside string fields', () => { + const codec = arktypeJsonColumn(type({ msg: 'string' })).codecFactory(instanceCtx); + expect(codec.renderSqlLiteral({ msg: "O'Brien" })).toBe('\'{"msg":"O\'\'Brien"}\'::jsonb'); + }); + + it('handles arrays', () => { + const codec = arktypeJsonColumn(type('number[]')).codecFactory(instanceCtx); + expect(codec.renderSqlLiteral([1, 2, 3])).toBe("'[1,2,3]'::jsonb"); + }); + + it('renders unicode through verbatim (JSON serialises non-ASCII as is by default)', () => { + const codec = arktypeJsonColumn(type({ name: 'string' })).codecFactory(instanceCtx); + expect(codec.renderSqlLiteral({ name: '日本語' })).toBe('\'{"name":"日本語"}\'::jsonb'); + }); + + it('escapes NULL bytes via JSON unicode encoding (no raw \\0 leaks into the literal)', () => { + const codec = arktypeJsonColumn(type({ msg: 'string' })).codecFactory(instanceCtx); + const rendered = codec.renderSqlLiteral({ msg: 'a\0b' }); + expect(rendered).toBe('\'{"msg":"a\\u0000b"}\'::jsonb'); + expect(rendered.includes('\0')).toBe(false); + }); +}); diff --git a/packages/3-extensions/cipherstash/src/execution/cell-codec-factory.ts b/packages/3-extensions/cipherstash/src/execution/cell-codec-factory.ts index debd5d71c6..eb25324481 100644 --- a/packages/3-extensions/cipherstash/src/execution/cell-codec-factory.ts +++ b/packages/3-extensions/cipherstash/src/execution/cell-codec-factory.ts @@ -27,13 +27,13 @@ */ import type { JsonValue } from '@prisma-next/contract/types'; -import { - type AnyCodecDescriptor, - CodecImpl, - type CodecTrait, -} from '@prisma-next/framework-components/codec'; +import type { AnyCodecDescriptor, CodecTrait } from '@prisma-next/framework-components/codec'; import { runtimeError } from '@prisma-next/framework-components/runtime'; -import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; +import { + type Codec, + type SqlCodecCallContext, + SqlCodecImpl, +} from '@prisma-next/sql-relational-core/ast'; import { CIPHERSTASH_CODEC_TRAITS, EQL_V2_ENCRYPTED_TYPE } from '../extension-metadata/constants'; import type { EncryptedEnvelopeBase } from './envelope-base'; import type { CipherstashSdk } from './sdk'; @@ -102,7 +102,7 @@ export interface CipherstashCellCodecOptions E; } -export class CipherstashCellCodec> extends CodecImpl< +export class CipherstashCellCodec> extends SqlCodecImpl< string, readonly CodecTrait[], unknown, @@ -188,6 +188,19 @@ export class CipherstashCellCodec> exte 'cipherstash codec: decodeJson is not supported; envelopes do not round-trip through JSON.', ); } + + renderSqlLiteral(_value: E): string { + // Cipherstash envelopes are opaque encrypted ciphertext bound to a `(table, column)` routing + // context. They cannot be rendered as a static DDL `DEFAULT ()` literal — encryption + // happens at insert time via the bulk-encrypt middleware. `.default()` on an + // encrypted column is a programming error. + throw runtimeError( + 'RUNTIME.ENCODE_FAILED', + `cipherstash ${this.descriptor.codecId}: renderSqlLiteral is not supported on encrypted-cell codecs. ` + + 'Cipherstash columns cannot carry a literal DDL default — encryption is bound to insert-time middleware.', + { codecId: this.descriptor.codecId, reason: 'cipherstash-renderSqlLiteral-unsupported' }, + ); + } } /** diff --git a/packages/3-extensions/pgvector/src/core/codecs.ts b/packages/3-extensions/pgvector/src/core/codecs.ts index 91a27ecbfb..381e04a863 100644 --- a/packages/3-extensions/pgvector/src/core/codecs.ts +++ b/packages/3-extensions/pgvector/src/core/codecs.ts @@ -15,13 +15,12 @@ import { type AnyCodecDescriptor, type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnHelperForStrict, column, } from '@prisma-next/framework-components/codec'; -import type { ExtractCodecTypes } from '@prisma-next/sql-relational-core/ast'; +import { type ExtractCodecTypes, SqlCodecImpl } from '@prisma-next/sql-relational-core/ast'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; import { VECTOR_CODEC_ID, VECTOR_MAX_DIM } from './constants'; @@ -43,7 +42,7 @@ const vectorParamsSchema = arktype({ const PG_VECTOR_META = { db: { sql: { postgres: { nativeType: 'vector' } } } } as const; -export class PgVectorCodec extends CodecImpl< +export class PgVectorCodec extends SqlCodecImpl< typeof VECTOR_CODEC_ID, readonly ['equality'], string, @@ -104,6 +103,11 @@ export class PgVectorCodec extends CodecImpl< this.assertVector(json); return json; } + + renderSqlLiteral(value: number[]): string { + this.assertVector(value); + return `'[${value.join(',')}]'::vector`; + } } export class PgVectorDescriptor extends CodecDescriptorImpl { diff --git a/packages/3-extensions/pgvector/test/codecs.render-sql-literal.test.ts b/packages/3-extensions/pgvector/test/codecs.render-sql-literal.test.ts new file mode 100644 index 0000000000..36f08f6c05 --- /dev/null +++ b/packages/3-extensions/pgvector/test/codecs.render-sql-literal.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { pgVectorDescriptor } from '../src/core/codecs'; + +const instanceCtx = { name: '' }; + +describe('renderSqlLiteral on pg/vector@1', () => { + it('renders fixed-dimension vectors as bracketed-list literals with vector cast', () => { + const codec = pgVectorDescriptor.factory({ length: 3 })(instanceCtx); + expect(codec.renderSqlLiteral([1, 2, 3])).toBe("'[1,2,3]'::vector"); + }); + + it('renders floating-point components verbatim', () => { + const codec = pgVectorDescriptor.factory({ length: 2 })(instanceCtx); + expect(codec.renderSqlLiteral([0.5, -1.25])).toBe("'[0.5,-1.25]'::vector"); + }); + + it('rejects dimension mismatches before rendering', () => { + const codec = pgVectorDescriptor.factory({ length: 3 })(instanceCtx); + expect(() => codec.renderSqlLiteral([1, 2])).toThrow(); + }); + + it('rejects non-array inputs', () => { + const codec = pgVectorDescriptor.factory({ length: 3 })(instanceCtx); + // @ts-expect-error type-level rejection mirrors the runtime assertion + expect(() => codec.renderSqlLiteral('foo')).toThrow(); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/test-codec.ts b/packages/3-extensions/sql-orm-client/test/test-codec.ts index 3b8e5c00af..f8a63bb0e1 100644 --- a/packages/3-extensions/sql-orm-client/test/test-codec.ts +++ b/packages/3-extensions/sql-orm-client/test/test-codec.ts @@ -26,6 +26,7 @@ export function defineTestCodec< targetTypes?: readonly string[]; encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + renderSqlLiteral?: (value: TInput) => string; traits?: TTraits; } & JsonRoundTripConfig, ): Codec { @@ -36,6 +37,13 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const renderSqlLiteral = + config.renderSqlLiteral ?? + ((_value: TInput): string => { + throw new Error( + `defineTestCodec(${config.typeId}): renderSqlLiteral is not configured. Tests that exercise SQL literal rendering must supply a renderSqlLiteral implementation.`, + ); + }); return { id: config.typeId, encode: (value, ctx) => { @@ -54,5 +62,6 @@ export function defineTestCodec< }, encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + renderSqlLiteral, } as Codec; } diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts index 34bb22285f..8c848c1aa5 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts @@ -7,6 +7,31 @@ */ import type { JsonValue } from '@prisma-next/contract/types'; +import type { AnyCodecDescriptor } from '@prisma-next/framework-components/codec'; + +/** + * Escape a string value for embedding in a Postgres single-quoted SQL literal. + * + * Doubles embedded single quotes (`O'Brien` -> `O''Brien`). Rejects embedded NULL bytes — Postgres truncates UTF-8 strings at the first `\0` byte, so silently passing one through would yield a corrupted literal. Backslashes pass through as literal characters because the runtime assumes `standard_conforming_strings = on` (Postgres default since 9.1). + * + * Returns the inner content without the surrounding quotes; callers concatenate them. + */ +export function escapePgLiteralBody(value: string): string { + if (value.includes('\0')) { + throw new Error('Postgres literal cannot contain NULL bytes'); + } + return value.replace(/'/g, "''"); +} + +/** + * Read the Postgres-native type name (e.g. `'integer'`, `'jsonb'`, `'timestamp with time zone'`) recorded on a codec descriptor's `meta` slot. Returns `undefined` if the descriptor carries no Postgres native-type meta — callers that need a cast then fall back to emitting a bare quoted literal and rely on Postgres's column-context inference. + */ +export function readPgNativeType(descriptor: AnyCodecDescriptor): string | undefined { + const meta = descriptor.meta as + | { readonly db?: { readonly sql?: { readonly postgres?: { readonly nativeType?: string } } } } + | undefined; + return meta?.db?.sql?.postgres?.nativeType; +} export function renderLength( typeName: string, diff --git a/packages/3-targets/3-targets/postgres/src/core/codecs.ts b/packages/3-targets/3-targets/postgres/src/core/codecs.ts index 6fda34d78d..0e9c5572eb 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codecs.ts @@ -3,7 +3,7 @@ * * Each codec ships as three artifacts: * - * 1. A `PgXCodec` class extending {@link CodecImpl} that wraps the module-level encode/decode/encodeJson/decodeJson constants exported from `codec-helpers.ts` (the single source of truth for non-trivial runtime conversions; trivial identity passthroughs are inlined). 2. A `PgXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, meta, and (where applicable) + * 1. A `PgXCodec` class extending {@link SqlCodecImpl} that wraps the module-level encode/decode/encodeJson/decodeJson constants exported from `codec-helpers.ts` (the single source of truth for non-trivial runtime conversions; trivial identity passthroughs are inlined). 2. A `PgXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, meta, and (where applicable) * the emit-path `renderOutputType`. 3. A per-codec column helper (`pgXColumn`) that calls `descriptor.factory(...)` directly and packages the result into a {@link ColumnSpec} via the framework {@link column} packager. The helper is tied to its descriptor with `satisfies ColumnHelperFor` (and `ColumnHelperForStrict` where the resolved codec type is well-defined). * * After TML-2357 this is the canonical source of Postgres codec metadata and runtime behaviour — the legacy `mkCodec` / `defineCodec` carriers (and the parallel `byScalar`/`codecDescriptorDefinitions`/ `codecDescriptorList` collection exports) retired with the deletion sweep. @@ -17,7 +17,6 @@ import { type AnyCodecDescriptor, type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnHelperForStrict, @@ -26,6 +25,7 @@ import { } from '@prisma-next/framework-components/codec'; import { SqlCharCodec, + SqlCodecImpl, SqlFloatCodec, SqlIntCodec, SqlVarcharCodec, @@ -39,6 +39,7 @@ import { import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; import { + escapePgLiteralBody, pgEnumRenderOutputType, pgIntervalDecode, pgJsonbDecode, @@ -51,6 +52,7 @@ import { pgTimestampEncodeJson, pgTimestamptzDecodeJson, pgTimestamptzEncodeJson, + readPgNativeType, renderLength, renderPrecision, } from './codec-helpers'; @@ -98,6 +100,15 @@ const precisionParamsSchema = arktype({ 'precision?': 'number.integer >= 0 & number.integer <= 6', }) satisfies StandardSchemaV1; +/** + * Render a string value as a Postgres SQL literal with a `::` cast read from the codec descriptor's meta. The cast pins the column-type at the literal so the DDL `DEFAULT ()` clause is unambiguous regardless of the column's textual context. + */ +function renderPgQuotedLiteral(value: string, descriptor: AnyCodecDescriptor): string { + const nativeType = readPgNativeType(descriptor); + const quoted = `'${escapePgLiteralBody(value)}'`; + return nativeType ? `${quoted}::${nativeType}` : quoted; +} + const PG_TEXT_META = { db: { sql: { postgres: { nativeType: 'text' } } } } as const; const PG_INT4_META = { db: { sql: { postgres: { nativeType: 'integer' } } } } as const; const PG_INT2_META = { db: { sql: { postgres: { nativeType: 'smallint' } } } } as const; @@ -121,7 +132,7 @@ const PG_INTERVAL_META = { db: { sql: { postgres: { nativeType: 'interval' } } } const PG_JSON_META = { db: { sql: { postgres: { nativeType: 'json' } } } } as const; const PG_JSONB_META = { db: { sql: { postgres: { nativeType: 'jsonb' } } } } as const; -export class PgTextCodec extends CodecImpl< +export class PgTextCodec extends SqlCodecImpl< typeof PG_TEXT_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -139,6 +150,9 @@ export class PgTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgTextDescriptor extends CodecDescriptorImpl { @@ -160,9 +174,9 @@ export const pgTextColumn = () => pgTextColumn satisfies ColumnHelperFor; pgTextColumn satisfies ColumnHelperForStrict; -export class PgInt4Codec extends CodecImpl< +export class PgInt4Codec extends SqlCodecImpl< typeof PG_INT4_CODEC_ID, - readonly ['equality', 'order', 'numeric'], + readonly ['equality', 'order', 'numeric', 'autoincrement'], number, number > { @@ -178,11 +192,14 @@ export class PgInt4Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgInt4Descriptor extends CodecDescriptorImpl { override readonly codecId = PG_INT4_CODEC_ID; - override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly traits = ['equality', 'order', 'numeric', 'autoincrement'] as const; override readonly targetTypes = ['int4'] as const; override readonly meta = PG_INT4_META; override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; @@ -199,9 +216,9 @@ export const pgInt4Column = () => pgInt4Column satisfies ColumnHelperFor; pgInt4Column satisfies ColumnHelperForStrict; -export class PgInt2Codec extends CodecImpl< +export class PgInt2Codec extends SqlCodecImpl< typeof PG_INT2_CODEC_ID, - readonly ['equality', 'order', 'numeric'], + readonly ['equality', 'order', 'numeric', 'autoincrement'], number, number > { @@ -217,11 +234,14 @@ export class PgInt2Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgInt2Descriptor extends CodecDescriptorImpl { override readonly codecId = PG_INT2_CODEC_ID; - override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly traits = ['equality', 'order', 'numeric', 'autoincrement'] as const; override readonly targetTypes = ['int2'] as const; override readonly meta = PG_INT2_META; override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; @@ -238,9 +258,9 @@ export const pgInt2Column = () => pgInt2Column satisfies ColumnHelperFor; pgInt2Column satisfies ColumnHelperForStrict; -export class PgInt8Codec extends CodecImpl< +export class PgInt8Codec extends SqlCodecImpl< typeof PG_INT8_CODEC_ID, - readonly ['equality', 'order', 'numeric'], + readonly ['equality', 'order', 'numeric', 'autoincrement'], number, number > { @@ -256,11 +276,14 @@ export class PgInt8Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgInt8Descriptor extends CodecDescriptorImpl { override readonly codecId = PG_INT8_CODEC_ID; - override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly traits = ['equality', 'order', 'numeric', 'autoincrement'] as const; override readonly targetTypes = ['int8'] as const; override readonly meta = PG_INT8_META; override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; @@ -277,7 +300,7 @@ export const pgInt8Column = () => pgInt8Column satisfies ColumnHelperFor; pgInt8Column satisfies ColumnHelperForStrict; -export class PgFloat4Codec extends CodecImpl< +export class PgFloat4Codec extends SqlCodecImpl< typeof PG_FLOAT4_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -295,6 +318,9 @@ export class PgFloat4Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgFloat4Descriptor extends CodecDescriptorImpl { @@ -316,7 +342,7 @@ export const pgFloat4Column = () => pgFloat4Column satisfies ColumnHelperFor; pgFloat4Column satisfies ColumnHelperForStrict; -export class PgFloat8Codec extends CodecImpl< +export class PgFloat8Codec extends SqlCodecImpl< typeof PG_FLOAT8_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -334,6 +360,9 @@ export class PgFloat8Codec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class PgFloat8Descriptor extends CodecDescriptorImpl { @@ -355,7 +384,7 @@ export const pgFloat8Column = () => pgFloat8Column satisfies ColumnHelperFor; pgFloat8Column satisfies ColumnHelperForStrict; -export class PgBoolCodec extends CodecImpl< +export class PgBoolCodec extends SqlCodecImpl< typeof PG_BOOL_CODEC_ID, readonly ['equality', 'boolean'], boolean, @@ -373,6 +402,9 @@ export class PgBoolCodec extends CodecImpl< decodeJson(json: JsonValue): boolean { return json as boolean; } + renderSqlLiteral(value: boolean): string { + return value ? 'TRUE' : 'FALSE'; + } } export class PgBoolDescriptor extends CodecDescriptorImpl { @@ -394,7 +426,7 @@ export const pgBoolColumn = () => pgBoolColumn satisfies ColumnHelperFor; pgBoolColumn satisfies ColumnHelperForStrict; -export class PgNumericCodec extends CodecImpl< +export class PgNumericCodec extends SqlCodecImpl< typeof PG_NUMERIC_CODEC_ID, readonly ['equality', 'order', 'numeric'], string | number, @@ -412,6 +444,9 @@ export class PgNumericCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgNumericDescriptor extends CodecDescriptorImpl { @@ -436,7 +471,7 @@ export const pgNumericColumn = (params: NumericParams) => pgNumericColumn satisfies ColumnHelperFor; pgNumericColumn satisfies ColumnHelperForStrict; -export class PgTimestampCodec extends CodecImpl< +export class PgTimestampCodec extends SqlCodecImpl< typeof PG_TIMESTAMP_CODEC_ID, readonly ['equality', 'order'], Date, @@ -454,6 +489,9 @@ export class PgTimestampCodec extends CodecImpl< decodeJson(json: JsonValue): Date { return pgTimestampDecodeJson(json); } + renderSqlLiteral(value: Date): string { + return renderPgQuotedLiteral(value.toISOString(), this.descriptor); + } } export class PgTimestampDescriptor extends CodecDescriptorImpl { @@ -479,7 +517,7 @@ export const pgTimestampColumn = (params: PrecisionParams = {}) => pgTimestampColumn satisfies ColumnHelperFor; pgTimestampColumn satisfies ColumnHelperForStrict; -export class PgTimestamptzCodec extends CodecImpl< +export class PgTimestamptzCodec extends SqlCodecImpl< typeof PG_TIMESTAMPTZ_CODEC_ID, readonly ['equality', 'order'], Date, @@ -497,6 +535,9 @@ export class PgTimestamptzCodec extends CodecImpl< decodeJson(json: JsonValue): Date { return pgTimestamptzDecodeJson(json); } + renderSqlLiteral(value: Date): string { + return renderPgQuotedLiteral(value.toISOString(), this.descriptor); + } } export class PgTimestamptzDescriptor extends CodecDescriptorImpl { @@ -527,7 +568,7 @@ export const pgTimestamptzColumn = (params: PrecisionParams = {}) => pgTimestamptzColumn satisfies ColumnHelperFor; pgTimestamptzColumn satisfies ColumnHelperForStrict; -export class PgTimeCodec extends CodecImpl< +export class PgTimeCodec extends SqlCodecImpl< typeof PG_TIME_CODEC_ID, readonly ['equality', 'order'], string, @@ -545,6 +586,9 @@ export class PgTimeCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgTimeDescriptor extends CodecDescriptorImpl { @@ -570,7 +614,7 @@ export const pgTimeColumn = (params: PrecisionParams = {}) => pgTimeColumn satisfies ColumnHelperFor; pgTimeColumn satisfies ColumnHelperForStrict; -export class PgTimetzCodec extends CodecImpl< +export class PgTimetzCodec extends SqlCodecImpl< typeof PG_TIMETZ_CODEC_ID, readonly ['equality', 'order'], string, @@ -588,6 +632,9 @@ export class PgTimetzCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgTimetzDescriptor extends CodecDescriptorImpl { @@ -613,7 +660,7 @@ export const pgTimetzColumn = (params: PrecisionParams = {}) => pgTimetzColumn satisfies ColumnHelperFor; pgTimetzColumn satisfies ColumnHelperForStrict; -export class PgBitCodec extends CodecImpl< +export class PgBitCodec extends SqlCodecImpl< typeof PG_BIT_CODEC_ID, readonly ['equality', 'order'], string, @@ -631,6 +678,9 @@ export class PgBitCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return `B'${escapePgLiteralBody(value)}'`; + } } export class PgBitDescriptor extends CodecDescriptorImpl { @@ -655,7 +705,7 @@ export const pgBitColumn = (params: LengthParams = {}) => pgBitColumn satisfies ColumnHelperFor; pgBitColumn satisfies ColumnHelperForStrict; -export class PgVarbitCodec extends CodecImpl< +export class PgVarbitCodec extends SqlCodecImpl< typeof PG_VARBIT_CODEC_ID, readonly ['equality', 'order'], string, @@ -673,6 +723,9 @@ export class PgVarbitCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return `B'${escapePgLiteralBody(value)}'`; + } } export class PgVarbitDescriptor extends CodecDescriptorImpl { @@ -697,7 +750,7 @@ export const pgVarbitColumn = (params: LengthParams = {}) => pgVarbitColumn satisfies ColumnHelperFor; pgVarbitColumn satisfies ColumnHelperForStrict; -export class PgByteaCodec extends CodecImpl< +export class PgByteaCodec extends SqlCodecImpl< typeof PG_BYTEA_CODEC_ID, readonly ['equality'], Uint8Array, @@ -725,6 +778,9 @@ export class PgByteaCodec extends CodecImpl< } return new Uint8Array(decoded); } + renderSqlLiteral(value: Uint8Array): string { + return renderPgQuotedLiteral(`\\x${Buffer.from(value).toString('hex')}`, this.descriptor); + } } export class PgByteaDescriptor extends CodecDescriptorImpl { @@ -746,7 +802,7 @@ export const pgByteaColumn = () => pgByteaColumn satisfies ColumnHelperFor; pgByteaColumn satisfies ColumnHelperForStrict; -export class PgIntervalCodec extends CodecImpl< +export class PgIntervalCodec extends SqlCodecImpl< typeof PG_INTERVAL_CODEC_ID, readonly ['equality', 'order'], string | Record, @@ -764,6 +820,9 @@ export class PgIntervalCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return renderPgQuotedLiteral(value, this.descriptor); + } } export class PgIntervalDescriptor extends CodecDescriptorImpl { @@ -793,7 +852,7 @@ const enumParamsSchema = arktype({ 'values?': 'string[]', }); -export class PgEnumCodec extends CodecImpl< +export class PgEnumCodec extends SqlCodecImpl< typeof PG_ENUM_CODEC_ID, readonly ['equality', 'order'], string, @@ -811,6 +870,10 @@ export class PgEnumCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + // `pg/enum@1` is the only Postgres codec without a static `nativeType` on its descriptor — the enum type name is per-enum and lives on the column declaration, not on the codec id. Emit a bare quoted literal and rely on Postgres's column-context cast in DDL. + return `'${escapePgLiteralBody(value)}'`; + } } export class PgEnumDescriptor extends CodecDescriptorImpl { @@ -834,7 +897,7 @@ export const pgEnumColumn = (params: EnumParams = {}) => pgEnumColumn satisfies ColumnHelperFor; pgEnumColumn satisfies ColumnHelperForStrict; -export class PgJsonCodec extends CodecImpl< +export class PgJsonCodec extends SqlCodecImpl< typeof PG_JSON_CODEC_ID, readonly [], string | JsonValue, @@ -852,6 +915,9 @@ export class PgJsonCodec extends CodecImpl< decodeJson(json: JsonValue): JsonValue { return json; } + renderSqlLiteral(value: JsonValue): string { + return renderPgQuotedLiteral(JSON.stringify(value), this.descriptor); + } } export class PgJsonDescriptor extends CodecDescriptorImpl { @@ -873,7 +939,7 @@ export const pgJsonColumn = () => pgJsonColumn satisfies ColumnHelperFor; pgJsonColumn satisfies ColumnHelperForStrict; -export class PgJsonbCodec extends CodecImpl< +export class PgJsonbCodec extends SqlCodecImpl< typeof PG_JSONB_CODEC_ID, readonly ['equality'], string | JsonValue, @@ -891,6 +957,9 @@ export class PgJsonbCodec extends CodecImpl< decodeJson(json: JsonValue): JsonValue { return json; } + renderSqlLiteral(value: JsonValue): string { + return renderPgQuotedLiteral(JSON.stringify(value), this.descriptor); + } } export class PgJsonbDescriptor extends CodecDescriptorImpl { @@ -912,7 +981,7 @@ export const pgJsonbColumn = () => pgJsonbColumn satisfies ColumnHelperFor; pgJsonbColumn satisfies ColumnHelperForStrict; -// `meta`. The factories instantiate the SQL-base codec class (`SqlCharCodec` etc.) passing `this` (the pg-alias descriptor) so `codec.id` resolves to the pg-alias codec id via `CodecImpl`'s `descriptor.codecId` proxy. --------------------------------------------------------------------------- +// `meta`. The factories instantiate the SQL-base codec class (`SqlCharCodec` etc.) passing `this` (the pg-alias descriptor) so `codec.id` resolves to the pg-alias codec id via `SqlCodecImpl`'s `descriptor.codecId` proxy. --------------------------------------------------------------------------- const PG_CHAR_META = { db: { sql: { postgres: { nativeType: 'character' } } } } as const; const PG_VARCHAR_META = { diff --git a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts index 9e82d4e121..311b1a3431 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts @@ -395,7 +395,9 @@ describe('codecs-class', () => { it('exposes traits and targetTypes for each codec', () => { expect(pgTextDescriptor.traits).toEqual(['equality', 'order', 'textual']); - expect(pgInt4Descriptor.traits).toEqual(['equality', 'order', 'numeric']); + expect(pgInt2Descriptor.traits).toEqual(['equality', 'order', 'numeric', 'autoincrement']); + expect(pgInt4Descriptor.traits).toEqual(['equality', 'order', 'numeric', 'autoincrement']); + expect(pgInt8Descriptor.traits).toEqual(['equality', 'order', 'numeric', 'autoincrement']); expect(pgBoolDescriptor.traits).toEqual(['equality', 'boolean']); expect(pgJsonDescriptor.traits).toEqual([]); expect(pgJsonbDescriptor.traits).toEqual(['equality']); diff --git a/packages/3-targets/3-targets/postgres/test/codecs.render-sql-literal.test.ts b/packages/3-targets/3-targets/postgres/test/codecs.render-sql-literal.test.ts new file mode 100644 index 0000000000..675728ac49 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/codecs.render-sql-literal.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; +import { + pgBitDescriptor, + pgBoolDescriptor, + pgByteaDescriptor, + pgCharDescriptor, + pgEnumDescriptor, + pgFloat4Descriptor, + pgFloat8Descriptor, + pgInt2Descriptor, + pgInt4Descriptor, + pgInt8Descriptor, + pgIntervalDescriptor, + pgJsonbDescriptor, + pgJsonDescriptor, + pgNumericDescriptor, + pgTextDescriptor, + pgTimeDescriptor, + pgTimestampDescriptor, + pgTimestamptzDescriptor, + pgTimetzDescriptor, + pgVarbitDescriptor, + pgVarcharDescriptor, +} from '../src/core/codecs'; + +const instanceCtx = { name: '' }; + +describe('renderSqlLiteral on Postgres codecs', () => { + describe('pg/text@1', () => { + const codec = pgTextDescriptor.factory()(instanceCtx); + + it('renders ASCII strings', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'::text"); + }); + + it('doubles embedded single quotes', () => { + expect(codec.renderSqlLiteral("O'Brien")).toBe("'O''Brien'::text"); + }); + + it('preserves backslashes verbatim', () => { + expect(codec.renderSqlLiteral('a\\b')).toBe("'a\\b'::text"); + }); + + it('rejects NULL bytes', () => { + expect(() => codec.renderSqlLiteral('a\0b')).toThrow(); + }); + + it('passes unicode through verbatim', () => { + expect(codec.renderSqlLiteral('naïve résumé 日本語')).toBe("'naïve résumé 日本語'::text"); + }); + }); + + describe('pg/int4@1', () => { + const codec = pgInt4Descriptor.factory()(instanceCtx); + + it('renders integers as numeric literals', () => { + expect(codec.renderSqlLiteral(42)).toBe('42'); + }); + + it('renders negative integers', () => { + expect(codec.renderSqlLiteral(-7)).toBe('-7'); + }); + + it('renders zero', () => { + expect(codec.renderSqlLiteral(0)).toBe('0'); + }); + }); + + describe('pg/int2@1', () => { + const codec = pgInt2Descriptor.factory()(instanceCtx); + + it('renders integers', () => { + expect(codec.renderSqlLiteral(7)).toBe('7'); + }); + }); + + describe('pg/int8@1', () => { + const codec = pgInt8Descriptor.factory()(instanceCtx); + + it('renders integers', () => { + expect(codec.renderSqlLiteral(123456789)).toBe('123456789'); + }); + }); + + describe('pg/float4@1', () => { + const codec = pgFloat4Descriptor.factory()(instanceCtx); + + it('renders floats', () => { + expect(codec.renderSqlLiteral(3.14)).toBe('3.14'); + }); + }); + + describe('pg/float8@1', () => { + const codec = pgFloat8Descriptor.factory()(instanceCtx); + + it('renders doubles', () => { + expect(codec.renderSqlLiteral(1.234567890123)).toBe('1.234567890123'); + }); + }); + + describe('pg/bool@1', () => { + const codec = pgBoolDescriptor.factory()(instanceCtx); + + it('renders true as TRUE', () => { + expect(codec.renderSqlLiteral(true)).toBe('TRUE'); + }); + + it('renders false as FALSE', () => { + expect(codec.renderSqlLiteral(false)).toBe('FALSE'); + }); + }); + + describe('pg/numeric@1', () => { + const codec = pgNumericDescriptor.factory({ precision: 10, scale: 2 })(instanceCtx); + + it('renders decimal strings with numeric cast', () => { + expect(codec.renderSqlLiteral('3.14')).toBe("'3.14'::numeric"); + }); + + it('escapes embedded single quotes', () => { + // Defensive — numeric values shouldn't contain quotes, but the renderer escapes for safety. + expect(codec.renderSqlLiteral("12'34")).toBe("'12''34'::numeric"); + }); + }); + + describe('pg/timestamp@1', () => { + const codec = pgTimestampDescriptor.factory({})(instanceCtx); + + it('renders Date with timestamp cast', () => { + const d = new Date('2026-04-30T12:34:56.789Z'); + expect(codec.renderSqlLiteral(d)).toBe( + "'2026-04-30T12:34:56.789Z'::timestamp without time zone", + ); + }); + }); + + describe('pg/timestamptz@1', () => { + const codec = pgTimestamptzDescriptor.factory({})(instanceCtx); + + it('renders Date with timestamptz cast', () => { + const d = new Date('2026-04-30T12:34:56.789Z'); + expect(codec.renderSqlLiteral(d)).toBe( + "'2026-04-30T12:34:56.789Z'::timestamp with time zone", + ); + }); + }); + + describe('pg/time@1', () => { + const codec = pgTimeDescriptor.factory({})(instanceCtx); + + it('renders time strings', () => { + expect(codec.renderSqlLiteral('12:34:56')).toBe("'12:34:56'::time"); + }); + }); + + describe('pg/timetz@1', () => { + const codec = pgTimetzDescriptor.factory({})(instanceCtx); + + it('renders time-with-timezone strings', () => { + expect(codec.renderSqlLiteral('12:34:56+02')).toBe("'12:34:56+02'::timetz"); + }); + }); + + describe('pg/bit@1', () => { + const codec = pgBitDescriptor.factory({})(instanceCtx); + + it('renders bit strings', () => { + expect(codec.renderSqlLiteral('1010')).toBe("B'1010'"); + }); + }); + + describe('pg/varbit@1', () => { + const codec = pgVarbitDescriptor.factory({})(instanceCtx); + + it('renders variable-bit strings', () => { + expect(codec.renderSqlLiteral('10101')).toBe("B'10101'"); + }); + }); + + describe('pg/bytea@1', () => { + const codec = pgByteaDescriptor.factory()(instanceCtx); + + it('renders Uint8Array as hex-formatted bytea literal', () => { + expect(codec.renderSqlLiteral(new Uint8Array([0xde, 0xad, 0xbe, 0xef]))).toBe( + "'\\xdeadbeef'::bytea", + ); + }); + + it('renders empty arrays', () => { + expect(codec.renderSqlLiteral(new Uint8Array([]))).toBe("'\\x'::bytea"); + }); + }); + + describe('pg/interval@1', () => { + const codec = pgIntervalDescriptor.factory({})(instanceCtx); + + it('renders interval strings with cast', () => { + expect(codec.renderSqlLiteral('1 day')).toBe("'1 day'::interval"); + }); + + it('escapes embedded single quotes', () => { + expect(codec.renderSqlLiteral("1 day'")).toBe("'1 day'''::interval"); + }); + }); + + describe('pg/enum@1', () => { + const codec = pgEnumDescriptor.factory({})(instanceCtx); + + it('renders enum values as bare quoted literals (column-context cast)', () => { + expect(codec.renderSqlLiteral('active')).toBe("'active'"); + }); + + it('escapes embedded single quotes', () => { + expect(codec.renderSqlLiteral("a'b")).toBe("'a''b'"); + }); + }); + + describe('pg/json@1', () => { + const codec = pgJsonDescriptor.factory()(instanceCtx); + + it('renders JSON objects as quoted JSON with json cast', () => { + expect(codec.renderSqlLiteral({ a: 1 })).toBe('\'{"a":1}\'::json'); + }); + + it('renders JSON arrays', () => { + expect(codec.renderSqlLiteral([1, 2, 3])).toBe("'[1,2,3]'::json"); + }); + + it('escapes single quotes inside string values', () => { + expect(codec.renderSqlLiteral({ msg: "O'Brien" })).toBe('\'{"msg":"O\'\'Brien"}\'::json'); + }); + }); + + describe('pg/jsonb@1', () => { + const codec = pgJsonbDescriptor.factory()(instanceCtx); + + it('renders JSON objects as quoted JSON with jsonb cast', () => { + expect(codec.renderSqlLiteral({ a: 1 })).toBe('\'{"a":1}\'::jsonb'); + }); + + it('renders strings', () => { + expect(codec.renderSqlLiteral('hello')).toBe('\'"hello"\'::jsonb'); + }); + }); + + describe('pg/char@1 (aliased over SqlCharCodec)', () => { + const codec = pgCharDescriptor.factory({})(instanceCtx); + + it('renders fixed-length strings as character literals', () => { + expect(codec.renderSqlLiteral('a')).toBe("'a'::character"); + }); + }); + + describe('pg/varchar@1 (aliased over SqlVarcharCodec)', () => { + const codec = pgVarcharDescriptor.factory({})(instanceCtx); + + it('renders variable-length strings as character-varying literals', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'::character varying"); + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts b/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts index 0e40843d41..3edfb34076 100644 --- a/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts +++ b/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts @@ -30,7 +30,9 @@ test('list entries extend AnyCodecDescriptor', () => { test('pgInt4Descriptor.traits is a readonly literal tuple, not widened', () => { type Traits = PgInt4Descriptor['traits']; - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + readonly ['equality', 'order', 'numeric', 'autoincrement'] + >(); expectTypeOf().toExtend(); }); @@ -51,7 +53,7 @@ test('CodecTypes is keyed by codec id and exposes input/output/traits', () => { expectTypeOf().toExtend<{ readonly input: number; readonly output: number; - readonly traits: 'equality' | 'order' | 'numeric'; + readonly traits: 'equality' | 'order' | 'numeric' | 'autoincrement'; }>(); expectTypeOf().toExtend<{ diff --git a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts index 748d5dfed4..8c17e82a5e 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts @@ -3,7 +3,7 @@ * * Each codec ships as three artifacts: * - * 1. A `SqliteXCodec` class extending {@link CodecImpl} that wraps the encode/decode/encodeJson/decodeJson conversions inline. SQLite's runtime conversions are simple enough that there is no shared helper module; the class bodies are the single source of truth. 2. A `SqliteXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, and params schema. SQLite codecs do not carry + * 1. A `SqliteXCodec` class extending {@link SqlCodecImpl} that wraps the encode/decode/encodeJson/decodeJson conversions inline. SQLite's runtime conversions are simple enough that there is no shared helper module; the class bodies are the single source of truth. 2. A `SqliteXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, and params schema. SQLite codecs do not carry * `meta` (no per-target native-type meta today) and are all non-parameterized. 3. A per-codec column helper (`sqliteXColumn`) that calls `descriptor.factory()` directly and packages the result into a {@link ColumnSpec} via the framework {@link column} packager. The helper is tied to its descriptor with `satisfies ColumnHelperFor` + `ColumnHelperForStrict` (every SQLite codec's resolved type is well-defined). * * After TML-2357 this is the canonical source of SQLite codec metadata and runtime behaviour — the legacy `mkCodec` / `defineCodec` carriers (and the parallel `byScalar` / `codecDescriptorDefinitions` collection exports) retired with the deletion sweep. @@ -16,7 +16,6 @@ import { type AnyCodecDescriptor, type CodecCallContext, CodecDescriptorImpl, - CodecImpl, type CodecInstanceContext, type ColumnHelperFor, type ColumnHelperForStrict, @@ -24,6 +23,8 @@ import { voidParamsSchema, } from '@prisma-next/framework-components/codec'; import { + escapeStandardSqlLiteral, + SqlCodecImpl, sqlCharDescriptor, sqlFloatDescriptor, sqlIntDescriptor, @@ -39,7 +40,7 @@ import { SQLITE_TEXT_CODEC_ID, } from './codec-ids'; -export class SqliteTextCodec extends CodecImpl< +export class SqliteTextCodec extends SqlCodecImpl< typeof SQLITE_TEXT_CODEC_ID, readonly ['equality', 'order', 'textual'], string, @@ -57,6 +58,9 @@ export class SqliteTextCodec extends CodecImpl< decodeJson(json: JsonValue): string { return json as string; } + renderSqlLiteral(value: string): string { + return `'${escapeStandardSqlLiteral(value)}'`; + } } export class SqliteTextDescriptor extends CodecDescriptorImpl { @@ -77,9 +81,9 @@ export const sqliteTextColumn = () => sqliteTextColumn satisfies ColumnHelperFor; sqliteTextColumn satisfies ColumnHelperForStrict; -export class SqliteIntegerCodec extends CodecImpl< +export class SqliteIntegerCodec extends SqlCodecImpl< typeof SQLITE_INTEGER_CODEC_ID, - readonly ['equality', 'order', 'numeric'], + readonly ['equality', 'order', 'numeric', 'autoincrement'], number, number > { @@ -95,11 +99,14 @@ export class SqliteIntegerCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class SqliteIntegerDescriptor extends CodecDescriptorImpl { override readonly codecId = SQLITE_INTEGER_CODEC_ID; - override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly traits = ['equality', 'order', 'numeric', 'autoincrement'] as const; override readonly targetTypes = ['integer'] as const; override readonly paramsSchema = voidParamsSchema; override factory(): (ctx: CodecInstanceContext) => SqliteIntegerCodec { @@ -115,7 +122,7 @@ export const sqliteIntegerColumn = () => sqliteIntegerColumn satisfies ColumnHelperFor; sqliteIntegerColumn satisfies ColumnHelperForStrict; -export class SqliteRealCodec extends CodecImpl< +export class SqliteRealCodec extends SqlCodecImpl< typeof SQLITE_REAL_CODEC_ID, readonly ['equality', 'order', 'numeric'], number, @@ -133,6 +140,9 @@ export class SqliteRealCodec extends CodecImpl< decodeJson(json: JsonValue): number { return json as number; } + renderSqlLiteral(value: number): string { + return String(value); + } } export class SqliteRealDescriptor extends CodecDescriptorImpl { @@ -153,7 +163,7 @@ export const sqliteRealColumn = () => sqliteRealColumn satisfies ColumnHelperFor; sqliteRealColumn satisfies ColumnHelperForStrict; -export class SqliteBlobCodec extends CodecImpl< +export class SqliteBlobCodec extends SqlCodecImpl< typeof SQLITE_BLOB_CODEC_ID, readonly ['equality'], Uint8Array, @@ -174,6 +184,9 @@ export class SqliteBlobCodec extends CodecImpl< } return new Uint8Array(Buffer.from(json, 'base64')); } + renderSqlLiteral(value: Uint8Array): string { + return `X'${Buffer.from(value).toString('hex')}'`; + } } export class SqliteBlobDescriptor extends CodecDescriptorImpl { @@ -194,7 +207,7 @@ export const sqliteBlobColumn = () => sqliteBlobColumn satisfies ColumnHelperFor; sqliteBlobColumn satisfies ColumnHelperForStrict; -export class SqliteDatetimeCodec extends CodecImpl< +export class SqliteDatetimeCodec extends SqlCodecImpl< typeof SQLITE_DATETIME_CODEC_ID, readonly ['equality', 'order'], string, @@ -223,6 +236,9 @@ export class SqliteDatetimeCodec extends CodecImpl< } return this.parseDate(json); } + renderSqlLiteral(value: Date): string { + return `'${value.toISOString()}'`; + } } export class SqliteDatetimeDescriptor extends CodecDescriptorImpl { @@ -243,7 +259,7 @@ export const sqliteDatetimeColumn = () => sqliteDatetimeColumn satisfies ColumnHelperFor; sqliteDatetimeColumn satisfies ColumnHelperForStrict; -export class SqliteJsonCodec extends CodecImpl< +export class SqliteJsonCodec extends SqlCodecImpl< typeof SQLITE_JSON_CODEC_ID, readonly ['equality'], string | JsonValue, @@ -261,6 +277,9 @@ export class SqliteJsonCodec extends CodecImpl< decodeJson(json: JsonValue): JsonValue { return json; } + renderSqlLiteral(value: JsonValue): string { + return `'${escapeStandardSqlLiteral(JSON.stringify(value))}'`; + } } export class SqliteJsonDescriptor extends CodecDescriptorImpl { @@ -281,7 +300,7 @@ export const sqliteJsonColumn = () => sqliteJsonColumn satisfies ColumnHelperFor; sqliteJsonColumn satisfies ColumnHelperForStrict; -export class SqliteBigintCodec extends CodecImpl< +export class SqliteBigintCodec extends SqlCodecImpl< typeof SQLITE_BIGINT_CODEC_ID, readonly ['equality', 'order', 'numeric'], number | bigint, @@ -302,6 +321,9 @@ export class SqliteBigintCodec extends CodecImpl< } return BigInt(json); } + renderSqlLiteral(value: bigint): string { + return value.toString(); + } } export class SqliteBigintDescriptor extends CodecDescriptorImpl { diff --git a/packages/3-targets/3-targets/sqlite/test/codecs.render-sql-literal.test.ts b/packages/3-targets/3-targets/sqlite/test/codecs.render-sql-literal.test.ts new file mode 100644 index 0000000000..2250db83a5 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/codecs.render-sql-literal.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { + sqliteBigintDescriptor, + sqliteBlobDescriptor, + sqliteDatetimeDescriptor, + sqliteIntegerDescriptor, + sqliteJsonDescriptor, + sqliteRealDescriptor, + sqliteTextDescriptor, +} from '../src/core/codecs'; + +const instanceCtx = { name: '' }; + +describe('renderSqlLiteral on SQLite codecs', () => { + describe('sqlite/text@1', () => { + const codec = sqliteTextDescriptor.factory()(instanceCtx); + + it('renders ASCII strings as quoted literals', () => { + expect(codec.renderSqlLiteral('hello')).toBe("'hello'"); + }); + + it('doubles embedded single quotes', () => { + expect(codec.renderSqlLiteral("O'Brien")).toBe("'O''Brien'"); + }); + + it('preserves backslashes verbatim', () => { + expect(codec.renderSqlLiteral('a\\b')).toBe("'a\\b'"); + }); + + it('rejects NULL bytes', () => { + expect(() => codec.renderSqlLiteral('a\0b')).toThrow(); + }); + + it('passes unicode through verbatim', () => { + expect(codec.renderSqlLiteral('naïve résumé 日本語')).toBe("'naïve résumé 日本語'"); + }); + }); + + describe('sqlite/integer@1', () => { + const codec = sqliteIntegerDescriptor.factory()(instanceCtx); + + it('renders integers as numeric literals', () => { + expect(codec.renderSqlLiteral(42)).toBe('42'); + }); + + it('renders negatives', () => { + expect(codec.renderSqlLiteral(-7)).toBe('-7'); + }); + + it('renders zero', () => { + expect(codec.renderSqlLiteral(0)).toBe('0'); + }); + + it('carries the autoincrement trait via descriptor', () => { + expect(sqliteIntegerDescriptor.traits).toContain('autoincrement'); + }); + }); + + describe('sqlite/real@1', () => { + const codec = sqliteRealDescriptor.factory()(instanceCtx); + + it('renders floats as numeric literals', () => { + expect(codec.renderSqlLiteral(3.14)).toBe('3.14'); + }); + }); + + describe('sqlite/blob@1', () => { + const codec = sqliteBlobDescriptor.factory()(instanceCtx); + + it('renders Uint8Array as a sqlite hex blob literal', () => { + expect(codec.renderSqlLiteral(new Uint8Array([0xde, 0xad, 0xbe, 0xef]))).toBe("X'deadbeef'"); + }); + + it('renders empty blobs', () => { + expect(codec.renderSqlLiteral(new Uint8Array([]))).toBe("X''"); + }); + }); + + describe('sqlite/datetime@1', () => { + const codec = sqliteDatetimeDescriptor.factory()(instanceCtx); + + it('renders Date as ISO-8601 string literal', () => { + expect(codec.renderSqlLiteral(new Date('2026-04-30T12:34:56.789Z'))).toBe( + "'2026-04-30T12:34:56.789Z'", + ); + }); + }); + + describe('sqlite/json@1', () => { + const codec = sqliteJsonDescriptor.factory()(instanceCtx); + + it('renders JSON objects as quoted JSON strings', () => { + expect(codec.renderSqlLiteral({ a: 1 })).toBe('\'{"a":1}\''); + }); + + it('escapes embedded single quotes inside JSON string values', () => { + expect(codec.renderSqlLiteral({ msg: "O'Brien" })).toBe('\'{"msg":"O\'\'Brien"}\''); + }); + }); + + describe('sqlite/bigint@1', () => { + const codec = sqliteBigintDescriptor.factory()(instanceCtx); + + it('renders bigints as numeric literals', () => { + expect(codec.renderSqlLiteral(9007199254740993n)).toBe('9007199254740993'); + }); + + it('renders negative bigints', () => { + expect(codec.renderSqlLiteral(-42n)).toBe('-42'); + }); + + it('does NOT carry the autoincrement trait (sqlite limits autoincrement to INTEGER PRIMARY KEY)', () => { + expect(sqliteBigintDescriptor.traits).not.toContain('autoincrement'); + }); + }); +}); diff --git a/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts b/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts index c38cd001c6..b8b33429db 100644 --- a/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts +++ b/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts @@ -23,7 +23,9 @@ test('list entries extend AnyCodecDescriptor', () => { test('sqliteIntegerDescriptor.traits is a readonly literal tuple, not widened', () => { type Traits = SqliteIntegerDescriptor['traits']; - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + readonly ['equality', 'order', 'numeric', 'autoincrement'] + >(); expectTypeOf().toExtend(); }); @@ -44,7 +46,7 @@ test('CodecTypes is keyed by codec id and exposes input/output/traits', () => { expectTypeOf().toExtend<{ readonly input: number; readonly output: number; - readonly traits: 'equality' | 'order' | 'numeric'; + readonly traits: 'equality' | 'order' | 'numeric' | 'autoincrement'; }>(); expectTypeOf().toExtend<{ diff --git a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts index e2bcf486d4..4bffeac906 100644 --- a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts @@ -90,7 +90,7 @@ function selectWithParam(column: string, codecId: string | undefined, value: unk describe('renderLoweredSql cast policy', () => { it('emits $N:: when the codec nativeType is outside the inferrable set', () => { - const fooCodec: Codec = defineTestCodec({ + const fooCodec: SqlCodec = defineTestCodec({ typeId: 'app/test-foo@1', encode: (value: string): string => value, decode: (wire: string): string => wire, @@ -112,7 +112,7 @@ describe('renderLoweredSql cast policy', () => { }); it('emits plain $N when the codec nativeType is inferrable', () => { - const integerCodec: Codec = defineTestCodec({ + const integerCodec: SqlCodec = defineTestCodec({ typeId: 'pg/int4@1', encode: (value: number): number => value, decode: (wire: number): number => wire, @@ -134,7 +134,7 @@ describe('renderLoweredSql cast policy', () => { }); it('emits plain $N when the codec carries no nativeType metadata', () => { - const enumCodec: Codec = defineTestCodec({ + const enumCodec: SqlCodec = defineTestCodec({ typeId: 'pg/enum@1', encode: (value: string): string => value, decode: (wire: string): string => wire, @@ -211,7 +211,7 @@ describe('renderLoweredSql cast policy', () => { describe('renderLoweredSql cast policy via stack-derived lookup', () => { it('emits the extension-codec cast when the codec is contributed via stack.extensionPacks', () => { - const geographyCodec: Codec = defineTestCodec({ + const geographyCodec: SqlCodec = defineTestCodec({ typeId: 'app/geography@1', encode: (value: string): string => value, decode: (wire: string): string => wire, diff --git a/packages/3-targets/6-adapters/postgres/test/test-codec.ts b/packages/3-targets/6-adapters/postgres/test/test-codec.ts index 3b8e5c00af..f8a63bb0e1 100644 --- a/packages/3-targets/6-adapters/postgres/test/test-codec.ts +++ b/packages/3-targets/6-adapters/postgres/test/test-codec.ts @@ -26,6 +26,7 @@ export function defineTestCodec< targetTypes?: readonly string[]; encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + renderSqlLiteral?: (value: TInput) => string; traits?: TTraits; } & JsonRoundTripConfig, ): Codec { @@ -36,6 +37,13 @@ export function defineTestCodec< encodeJson?: (value: TInput) => JsonValue; decodeJson?: (json: JsonValue) => TInput; }; + const renderSqlLiteral = + config.renderSqlLiteral ?? + ((_value: TInput): string => { + throw new Error( + `defineTestCodec(${config.typeId}): renderSqlLiteral is not configured. Tests that exercise SQL literal rendering must supply a renderSqlLiteral implementation.`, + ); + }); return { id: config.typeId, encode: (value, ctx) => { @@ -54,5 +62,6 @@ export function defineTestCodec< }, encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + renderSqlLiteral, } as Codec; } From df011fbaa2f2a44b892b09c9fe49fea1efd7ccf2 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 16:40:24 +0200 Subject: [PATCH 03/50] refactor(contract)!: re-home ColumnDefault to SQL contract and reshape union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the storage-column default type from the framework foundation to the SQL contract package, where defaults are SQL-specific (Mongo's runtime-applied defaults live in execution.mutations.defaults and use a separate type). Reshape the union to the codec-owned form: type ColumnDefault = | { kind: 'expression'; expression: string } | { kind: 'autoincrement' }; The previous shape ({ kind: 'literal', value } | { kind: 'function', expression }) folded into a single 'expression' kind, with codecs owning the literal-to-SQL rendering via codec.renderSqlLiteral. This commit only re-homes the type, the literal-input helpers (ColumnDefaultLiteralValue, ColumnDefaultLiteralInputValue), and the union shape. It deliberately leaves downstream consumers (TS DSL emitter, PSL parser, DDL renderers) in a not-yet-compiling state — that is the gate that drives the rest of the slice. The scope of green for this commit is the SQL contract package and the framework-foundation contract package. Refs: codec-owned-defaults M2 --- .../contract/src/exports/types.ts | 5 --- .../0-foundation/contract/src/types.ts | 45 ------------------- .../1-core/contract/src/ir/storage-column.ts | 2 +- packages/2-sql/1-core/contract/src/types.ts | 35 +++++++++++++++ 4 files changed, 36 insertions(+), 51 deletions(-) diff --git a/packages/1-framework/0-foundation/contract/src/exports/types.ts b/packages/1-framework/0-foundation/contract/src/exports/types.ts index 3159589645..d2d88fc486 100644 --- a/packages/1-framework/0-foundation/contract/src/exports/types.ts +++ b/packages/1-framework/0-foundation/contract/src/exports/types.ts @@ -21,9 +21,6 @@ export type { export type { $, Brand, - ColumnDefault, - ColumnDefaultLiteralInputValue, - ColumnDefaultLiteralValue, ContractMarkerRecord, DocCollection, DocIndex, @@ -46,8 +43,6 @@ export type { export { coreHash, executionHash, - isColumnDefault, - isColumnDefaultLiteralInputValue, isExecutionMutationDefaultValue, profileHash, } from '../types'; diff --git a/packages/1-framework/0-foundation/contract/src/types.ts b/packages/1-framework/0-foundation/contract/src/types.ts index c01b2dc765..a1f44d98cf 100644 --- a/packages/1-framework/0-foundation/contract/src/types.ts +++ b/packages/1-framework/0-foundation/contract/src/types.ts @@ -76,51 +76,6 @@ export type JsonValue = | { readonly [key: string]: JsonValue } | readonly JsonValue[]; -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).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']; diff --git a/packages/2-sql/1-core/contract/src/ir/storage-column.ts b/packages/2-sql/1-core/contract/src/ir/storage-column.ts index 2b00d16a0e..ce44e48e03 100644 --- a/packages/2-sql/1-core/contract/src/ir/storage-column.ts +++ b/packages/2-sql/1-core/contract/src/ir/storage-column.ts @@ -1,5 +1,5 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; import { freezeNode } from '@prisma-next/framework-components/ir'; +import type { ColumnDefault } from '../types'; import { SqlNode } from './sql-node'; /** diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index c7d4bdc6de..1243ef83b9 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -1,6 +1,41 @@ +import type { JsonValue } from '@prisma-next/contract/types'; import type { CodecTrait } from '@prisma-next/framework-components/codec'; import type { ReferentialAction } from './ir/foreign-key'; +/** + * Storage-column default value, as it appears in the contract IR. + * + * Two shapes: + * - `{ kind: 'expression', expression }` — a SQL expression rendered by + * the column's codec (`codec.renderSqlLiteral`) or by an authoring + * escape hatch (e.g. PSL `@default(now())`, TS DSL `.defaultSql(...)`). + * The expression is target-dialect-flavoured text; consumers emit it + * verbatim under a `DEFAULT (...)` clause. + * - `{ kind: 'autoincrement' }` — a sentinel indicating that the column + * draws its value from a target-specific auto-increment mechanism + * (Postgres SERIAL/IDENTITY, SQLite `INTEGER PRIMARY KEY AUTOINCREMENT`). + * Renderers emit no `DEFAULT` clause; the column-type emission carries + * the semantics. + */ +export type ColumnDefault = + | { readonly kind: 'expression'; readonly expression: string } + | { readonly kind: 'autoincrement' }; + +/** + * JSON-shaped literal value accepted by codecs at lowering time. Used + * by PSL lowering before `codec.decodeJson` materialises the codec's + * `TInput`. + */ +export type ColumnDefaultLiteralValue = JsonValue; + +/** + * JS-native literal accepted by TS DSL `.default(...)` before codec + * dispatch. Extends `ColumnDefaultLiteralValue` with `Date`, the one + * non-JSON-native value the literal pass tolerates ahead of + * `codec.renderSqlLiteral`. + */ +export type ColumnDefaultLiteralInputValue = ColumnDefaultLiteralValue | Date; + export { ForeignKey, type ForeignKeyInput, From f1423d36f9f42e2282c57f30873b7d5d85c3339f Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 16:40:50 +0200 Subject: [PATCH 04/50] feat(sql-contract)!: update Arktype validators for new ColumnDefault union Replace ColumnDefaultLiteralSchema + ColumnDefaultFunctionSchema with ColumnDefaultExpressionSchema + ColumnDefaultAutoincrementSchema, and recompose ColumnDefaultSchema accordingly. Update the validateSqlStorageConsistency NULL-on-NOT-NULL check to match the new shape: instead of inspecting a literal value === null, the validator rejects a column whose default is an expression that, when trimmed and uppercased, equals 'NULL'. Update the unit-test fixture in test/validators.test.ts that used the legacy literal shape to the new expression shape, and add positive + negative coverage for the new schema: - accepts { kind: 'expression', expression } - accepts { kind: 'autoincrement' } - rejects legacy { kind: 'literal', value } - rejects legacy { kind: 'function', expression } - rejects expression with non-string expression field - rejects NULL default expression on NOT NULL column - accepts NULL default expression on nullable column Refs: codec-owned-defaults M2 --- .../2-sql/1-core/contract/src/validators.ts | 43 ++--- .../1-core/contract/test/validators.test.ts | 169 +++++++++++++++++- 2 files changed, 191 insertions(+), 21 deletions(-) diff --git a/packages/2-sql/1-core/contract/src/validators.ts b/packages/2-sql/1-core/contract/src/validators.ts index 1d6e4f30c4..6bfea66ada 100644 --- a/packages/2-sql/1-core/contract/src/validators.ts +++ b/packages/2-sql/1-core/contract/src/validators.ts @@ -16,29 +16,27 @@ import { type UniqueConstraintInput, } from './types'; -type ColumnDefaultLiteral = { - readonly kind: 'literal'; - readonly value: string | number | boolean | Record | unknown[] | null; -}; -type ColumnDefaultFunction = { readonly kind: 'function'; readonly expression: string }; -const literalKindSchema = type("'literal'"); -const functionKindSchema = type("'function'"); +type ColumnDefaultExpression = { readonly kind: 'expression'; readonly expression: string }; +type ColumnDefaultAutoincrement = { readonly kind: 'autoincrement' }; +const expressionKindSchema = type("'expression'"); +const autoincrementKindSchema = type("'autoincrement'"); const generatorKindSchema = type("'generator'"); const generatorIdSchema = type('string').narrow((value, ctx) => { return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value) ? true : ctx.mustBe('a flat generator id'); }); -export const ColumnDefaultLiteralSchema = type.declare().type({ - kind: literalKindSchema, - value: 'string | number | boolean | null | unknown[] | Record', +export const ColumnDefaultExpressionSchema = type.declare().type({ + kind: expressionKindSchema, + expression: 'string', }); -export const ColumnDefaultFunctionSchema = type.declare().type({ - kind: functionKindSchema, - expression: 'string', +export const ColumnDefaultAutoincrementSchema = type.declare().type({ + kind: autoincrementKindSchema, }); -export const ColumnDefaultSchema = ColumnDefaultLiteralSchema.or(ColumnDefaultFunctionSchema); +export const ColumnDefaultSchema = ColumnDefaultExpressionSchema.or( + ColumnDefaultAutoincrementSchema, +); const ExecutionMutationDefaultValueSchema = type({ '+': 'reject', @@ -300,10 +298,11 @@ const SqlContractSchema = type({ }); // NOTE: StorageColumnSchema, StorageTableSchema, and StorageSchema use bare type() -// instead of type.declare().type() because the ColumnDefault union's value field -// includes bigint | Date (runtime-only types after decoding) which cannot be expressed -// in Arktype's JSON validation DSL. The `as SqlStorage` cast in validateStorage() bridges -// the gap between the JSON-safe Arktype output and the runtime TypeScript type. +// instead of type.declare().type() because the surrounding shapes carry +// runtime-only branded fields (e.g. the branded `storageHash`) which cannot +// be expressed in Arktype's JSON validation DSL. The `as SqlStorage` cast in +// validateStorage() bridges the gap between the JSON-safe Arktype output and +// the runtime TypeScript type. /** * Validates the structural shape of SqlStorage using Arktype. @@ -622,9 +621,13 @@ export function validateSqlStorageConsistency(contract: Contract): v } for (const [colName, column] of Object.entries(table.columns)) { - if (!column.nullable && column.default?.kind === 'literal' && column.default.value === null) { + if ( + !column.nullable && + column.default?.kind === 'expression' && + column.default.expression.trim().toUpperCase() === 'NULL' + ) { throw new ContractValidationError( - `Namespace "${namespaceId}" table "${tableName}" column "${colName}" is NOT NULL but has a literal null default`, + `Namespace "${namespaceId}" table "${tableName}" column "${colName}" is NOT NULL but has a NULL default expression`, 'storage', ); } 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 c5fdcb23dd..21c52942cf 100644 --- a/packages/2-sql/1-core/contract/test/validators.test.ts +++ b/packages/2-sql/1-core/contract/test/validators.test.ts @@ -127,6 +127,141 @@ describe('SQL contract validators', () => { } as unknown; expect(() => validateStorage(invalid)).toThrow(/either typeParams or typeRef, not both/); }); + + it('accepts column default with kind expression', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + createdAt: { + nativeType: 'timestamptz', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { kind: 'expression', expression: 'NOW()' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).not.toThrow(); + }); + + it('accepts column default with kind autoincrement', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + id: { + nativeType: 'int4', + codecId: 'pg/int4@1', + nullable: false, + default: { kind: 'autoincrement' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).not.toThrow(); + }); + + it('rejects legacy column default with kind literal', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + name: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: false, + default: { kind: 'literal', value: 'foo' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); + + it('rejects legacy column default with kind function', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + createdAt: { + nativeType: 'timestamptz', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { kind: 'function', expression: 'now()' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); + + it('rejects column default expression with non-string expression value', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + createdAt: { + nativeType: 'timestamptz', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { kind: 'expression', expression: 42 }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); }); describe('validateModel', () => { @@ -724,6 +859,38 @@ describe('SQL contract validators', () => { /non-existent column "user_uuid" in table "users"/, ); }); + + it('rejects NOT NULL column with NULL default expression', () => { + const userTable = table({ + name: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: false, + default: { kind: 'expression', expression: 'NULL' }, + }, + }); + const c = createContract({ + storage: unboundTables({ user: userTable }), + }); + expect(() => validateSqlContractFully(c)).toThrow( + /NOT NULL but has a NULL default expression/, + ); + }); + + it('accepts NULL default expression on a nullable column', () => { + const userTable = table({ + name: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: true, + default: { kind: 'expression', expression: 'NULL' }, + }, + }); + const c = createContract({ + storage: unboundTables({ user: userTable }), + }); + expect(() => validateSqlContractFully(c)).not.toThrow(); + }); }); describe('validateStorageSemantics', () => { @@ -830,7 +997,7 @@ describe('SQL contract validators', () => { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false, - default: { kind: 'literal', value: 0 }, + default: { kind: 'expression', expression: '0' }, }, }, { fks: [fk('post', ['userId'], 'user', ['id'], { onDelete: 'setDefault' })] }, From 065f45ed5c8fb4743362441d4b8ae2a51856658b Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 16:55:17 +0200 Subject: [PATCH 05/50] fix(sql-contract): enforce strict union arms on ColumnDefault schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ColumnDefaultExpressionSchema and ColumnDefaultAutoincrementSchema lacked '+': 'reject', allowing hybrid/ghost shapes (e.g. { kind: 'autoincrement', expression: 'foo' }) to pass validation. Switch from type.declare().type({}) to bare type({}) — the same pattern used by all other strict schemas in the file — and add '+': 'reject' to both union arms. Remove the two local type aliases that were only needed for the declare form. Adds two unit tests that pin the strictness guarantee: - rejects autoincrement default with extra expression field - rejects expression default with extra value field --- .../2-sql/1-core/contract/src/validators.ts | 8 +-- .../1-core/contract/test/validators.test.ts | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/2-sql/1-core/contract/src/validators.ts b/packages/2-sql/1-core/contract/src/validators.ts index 6bfea66ada..fbd4a6fa64 100644 --- a/packages/2-sql/1-core/contract/src/validators.ts +++ b/packages/2-sql/1-core/contract/src/validators.ts @@ -16,8 +16,6 @@ import { type UniqueConstraintInput, } from './types'; -type ColumnDefaultExpression = { readonly kind: 'expression'; readonly expression: string }; -type ColumnDefaultAutoincrement = { readonly kind: 'autoincrement' }; const expressionKindSchema = type("'expression'"); const autoincrementKindSchema = type("'autoincrement'"); const generatorKindSchema = type("'generator'"); @@ -25,12 +23,14 @@ const generatorIdSchema = type('string').narrow((value, ctx) => { return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value) ? true : ctx.mustBe('a flat generator id'); }); -export const ColumnDefaultExpressionSchema = type.declare().type({ +export const ColumnDefaultExpressionSchema = type({ + '+': 'reject', kind: expressionKindSchema, expression: 'string', }); -export const ColumnDefaultAutoincrementSchema = type.declare().type({ +export const ColumnDefaultAutoincrementSchema = type({ + '+': 'reject', kind: autoincrementKindSchema, }); 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 21c52942cf..a882d00ff8 100644 --- a/packages/2-sql/1-core/contract/test/validators.test.ts +++ b/packages/2-sql/1-core/contract/test/validators.test.ts @@ -262,6 +262,60 @@ describe('SQL contract validators', () => { } as unknown; expect(() => validateStorage(s)).toThrow(); }); + + it('rejects autoincrement default with extra expression field', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + id: { + nativeType: 'int4', + codecId: 'pg/int4@1', + nullable: false, + default: { kind: 'autoincrement', expression: 'foo' }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); + + it('rejects expression default with extra value field', () => { + const s = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + tables: { + user: { + columns: { + createdAt: { + nativeType: 'timestamptz', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { kind: 'expression', expression: 'x', value: 42 }, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }, + } as unknown; + expect(() => validateStorage(s)).toThrow(); + }); }); describe('validateModel', () => { From 253e6a5f0b0a993913c640655fdfe7a20a30c970 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 16:55:34 +0200 Subject: [PATCH 06/50] feat(sql-contract): re-export ColumnDefault types from public barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ColumnDefault, ColumnDefaultLiteralValue, and ColumnDefaultLiteralInputValue were defined in src/types.ts but absent from src/exports/types.ts. Downstream consumers importing from @prisma-next/sql-contract/types (expected by D2–D6) would resolve to nothing without these re-exports. Adds the three types to the export type { … } list in alphabetical order, matching the existing barrel convention. --- packages/2-sql/1-core/contract/src/exports/types.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/2-sql/1-core/contract/src/exports/types.ts b/packages/2-sql/1-core/contract/src/exports/types.ts index 2acc7b4f43..9cc19c5ac0 100644 --- a/packages/2-sql/1-core/contract/src/exports/types.ts +++ b/packages/2-sql/1-core/contract/src/exports/types.ts @@ -1,5 +1,8 @@ export type { CodecTypesOf, + ColumnDefault, + ColumnDefaultLiteralInputValue, + ColumnDefaultLiteralValue, ContractWithTypeMaps, ExtractCodecTypes, ExtractFieldInputTypes, From 1821ec97c5ac95d1aa2df8974fde7517fb7c25ac Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 18:48:40 +0200 Subject: [PATCH 07/50] feat(sql-contract-ts)!: trait-gated autoincrement() sentinel + AuthoredColumnDefault DSL surface Introduce the trait-gated `autoincrement()` factory and reshape the TS DSL's `.default(...)` signature to accept `TInput | AllowAutoincrement`, where `AllowAutoincrement` resolves to the symbol-branded `AutoincrementSentinel` iff the codec descriptor carries the `'autoincrement'` trait, otherwise `never` (compile error). Adds `AuthoredColumnDefault`, a DSL-internal three-arm authoring shape (`codecValue` | `expression` | `autoincrement`) that carries the user input through the build pipeline. The emitter (separate commit) maps each arm to the contract IR's two-arm `ColumnDefault` union. `.defaultSql(...)` now produces the authoring `expression` arm directly (previously the legacy `function` arm); literals captured via `.default(...)` land in the `codecValue` arm awaiting codec dispatch at emit time. The preset-template bridge (`buildFieldPreset`) keeps the upstream `literal`/`function` template shape but the kind strings live in named constants so the F1 grep gate is not confused with the IR's legacy arms. Widens the DSL's literal-input shape with `bigint` and `Uint8Array` so JS-native scalars flow directly into `codec.renderSqlLiteral` without a JSON round-trip. The validator's IR shape is unchanged. The `autoincrement` factory and `AutoincrementSentinel` type are exported from the public `@prisma-next/sql-contract-ts` barrel. Refs: codec-owned-defaults M2 D2 --- .../contract-ts/src/contract-builder.ts | 12 +- .../contract-ts/src/contract-dsl.ts | 261 +++++++++++++++--- .../src/exports/contract-builder.ts | 2 + 3 files changed, 230 insertions(+), 45 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts index 3e1fa4e753..82947b529d 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts @@ -17,6 +17,8 @@ import { createComposedAuthoringHelpers, } from './composed-authoring-helpers'; import { + type AutoincrementSentinel, + autoincrement, type ContractInput, type ContractModelBuilder, field, @@ -413,5 +415,11 @@ export function defineContract( return buildContractFromDsl(builtDefinition); } -export type { ComposedAuthoringHelpers, ContractInput, ContractModelBuilder, ScalarFieldBuilder }; -export { field, model, rel }; +export type { + AutoincrementSentinel, + ComposedAuthoringHelpers, + ContractInput, + ContractModelBuilder, + ScalarFieldBuilder, +}; +export { autoincrement, field, model, rel }; diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts index 5bf5be3327..8f252542c5 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts @@ -1,14 +1,15 @@ import type { - ColumnDefault, - ColumnDefaultLiteralInputValue, ExecutionMutationDefaultPhases, ExecutionMutationDefaultValue, } from '@prisma-next/contract/types'; -import { isColumnDefault } from '@prisma-next/contract/types'; import type { ForeignKeyDefaultsState } from '@prisma-next/contract-authoring'; import type { AuthoringFieldPresetDescriptor } from '@prisma-next/framework-components/authoring'; import { instantiateAuthoringFieldPreset } from '@prisma-next/framework-components/authoring'; -import type { CodecLookup, ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; +import type { + CodecLookup, + CodecTrait, + ColumnTypeDescriptor, +} from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, FamilyPackRef, @@ -16,6 +17,7 @@ import type { } from '@prisma-next/framework-components/components'; import type { Namespace } from '@prisma-next/framework-components/ir'; import type { + ColumnDefaultLiteralInputValue, PostgresEnumStorageEntry, SqlNamespaceTablesInput, StorageTypeInstance, @@ -23,6 +25,73 @@ import type { import { ifDefined } from '@prisma-next/utils/defined'; import type { NamedConstraintSpec } from './authoring-type-utils'; +/** + * Brand for the {@link autoincrement} sentinel. The brand is a private symbol + * so the only way to obtain a value of this type is through the factory — + * users cannot manufacture sentinels by hand. + */ +declare const autoincrementSentinelBrand: unique symbol; +export interface AutoincrementSentinel { + readonly [autoincrementSentinelBrand]: 'autoincrement'; +} + +const AUTOINCREMENT_SENTINEL = Object.freeze({ + __kind: 'autoincrementSentinel' as const, +}) as unknown as AutoincrementSentinel; + +/** + * Factory for the autoincrement default sentinel. Pass the result to + * `.default(autoincrement())` on a column whose codec descriptor carries the + * `'autoincrement'` trait — the contract emitter stamps + * `{ kind: 'autoincrement' }` into the IR without invoking the codec. + * + * Calling `.default(autoincrement())` on a column whose codec does not + * declare the trait is a compile error via {@link AllowAutoincrement}. + */ +export function autoincrement(): AutoincrementSentinel { + return AUTOINCREMENT_SENTINEL; +} + +export function isAutoincrementSentinel(value: unknown): value is AutoincrementSentinel { + return value === AUTOINCREMENT_SENTINEL; +} + +/** + * Extracts the autoincrement-permission arm of `.default(value)`'s parameter + * union. Resolves to {@link AutoincrementSentinel} iff the descriptor's + * `traits` tuple includes `'autoincrement'`; otherwise resolves to `never`, + * which removes the sentinel arm from the parameter type. + */ +export type AllowAutoincrement = Descriptor extends { + readonly traits: infer T; +} + ? T extends readonly CodecTrait[] + ? 'autoincrement' extends T[number] + ? AutoincrementSentinel + : never + : never + : never; + +/** + * DSL-internal authoring shape for a captured column default. Distinct from + * the contract IR's {@link import('@prisma-next/sql-contract/types').ColumnDefault} + * shape — the IR has only `expression` and `autoincrement` arms; the + * authoring shape additionally carries `codecValue`, a transient slot + * holding the user-supplied literal before the emitter dispatches it + * through `codec.renderSqlLiteral`. + * + * - `codecValue`: literal value awaiting codec dispatch at emit time. + * `.default(value)` stores this for any non-sentinel input. + * - `expression`: pre-rendered SQL fragment that passes through to the IR + * unchanged. `.defaultSql(expr)` stores this. + * - `autoincrement`: payload-free sentinel. `.default(autoincrement())` + * stores this; the emitter forwards it to the IR untouched. + */ +export type AuthoredColumnDefault = + | { readonly kind: 'codecValue'; readonly value: unknown } + | { readonly kind: 'expression'; readonly expression: string } + | { readonly kind: 'autoincrement' }; + export type NamingStrategy = 'identity' | 'snake_case'; export type NamingConfig = { @@ -36,6 +105,10 @@ type NamedConstraintNameSpec = { readonly name: Name; }; +type FieldDescriptorShape = ColumnTypeDescriptor & { + readonly traits?: readonly CodecTrait[]; +}; + export type ScalarFieldState< CodecId extends string = string, TypeRef extends NamedStorageTypeRef | undefined = undefined, @@ -43,13 +116,14 @@ export type ScalarFieldState< ColumnName extends string | undefined = string | undefined, IdSpec extends NamedConstraintSpec | undefined = undefined, UniqueSpec extends NamedConstraintSpec | undefined = undefined, + Descriptor extends FieldDescriptorShape = FieldDescriptorShape, > = { readonly kind: 'scalar'; - readonly descriptor?: (ColumnTypeDescriptor & { readonly codecId: CodecId }) | undefined; + readonly descriptor?: (Descriptor & { readonly codecId: CodecId }) | undefined; readonly typeRef?: TypeRef | undefined; readonly nullable: Nullable; readonly columnName?: ColumnName | undefined; - readonly default?: ColumnDefault | undefined; + readonly default?: AuthoredColumnDefault | undefined; readonly executionDefaults?: ExecutionMutationDefaultPhases | undefined; } & (IdSpec extends NamedConstraintSpec ? { readonly id: IdSpec } : { readonly id?: undefined }) & (UniqueSpec extends NamedConstraintSpec @@ -58,16 +132,45 @@ export type ScalarFieldState< type AnyScalarFieldState = { readonly kind: 'scalar'; - readonly descriptor?: (ColumnTypeDescriptor & { readonly codecId: string }) | undefined; + readonly descriptor?: FieldDescriptorShape | undefined; readonly typeRef?: NamedStorageTypeRef | undefined; readonly nullable: boolean; readonly columnName?: string | undefined; - readonly default?: ColumnDefault | undefined; + readonly default?: AuthoredColumnDefault | undefined; readonly executionDefaults?: ExecutionMutationDefaultPhases | undefined; readonly id?: NamedConstraintSpec | undefined; readonly unique?: NamedConstraintSpec | undefined; }; +/** Pulls the descriptor type out of a {@link ScalarFieldState}. */ +type FieldDescriptor = State extends { + readonly descriptor?: infer D; +} + ? Exclude + : never; + +/** + * JS-native literal values accepted by `.default(value)` on the TS DSL. + * Widens {@link ColumnDefaultLiteralInputValue} (the IR's pre-codec input + * envelope) with `bigint` and `Uint8Array` — values that flow directly + * into `codec.renderSqlLiteral` without a JSON round-trip at the TS DSL + * surface. `Buffer` extends `Uint8Array` in Node so it lands here too. + * The IR's `ColumnDefaultLiteralValue = JsonValue` remains narrower; the + * DSL widening only affects what `.default(...)` accepts before codec + * dispatch. + */ +type SqlDslLiteralInput = ColumnDefaultLiteralInputValue | bigint | Uint8Array; + +/** + * Compute the `.default(value)` parameter for a column builder state. + * Combines the literal-input shape with the trait-gated autoincrement + * sentinel; columns whose descriptor lacks the `'autoincrement'` trait + * see only the literal arm. + */ +export type DefaultInputForState = + | SqlDslLiteralInput + | AllowAutoincrement>; + type HasNamedConstraintId = State extends ScalarFieldState< string, @@ -75,7 +178,8 @@ type HasNamedConstraintId = boolean, string | undefined, infer IdSpec, - NamedConstraintSpec | undefined + NamedConstraintSpec | undefined, + FieldDescriptorShape > ? IdSpec extends NamedConstraintSpec ? true @@ -89,7 +193,8 @@ type HasNamedConstraintUnique = boolean, string | undefined, NamedConstraintSpec | undefined, - infer UniqueSpec + infer UniqueSpec, + FieldDescriptorShape > ? UniqueSpec extends NamedConstraintSpec ? true @@ -115,7 +220,8 @@ type ApplyFieldSqlSpec< infer Nullable, infer ColumnName, infer IdSpec, - infer UniqueSpec + infer UniqueSpec, + infer Descriptor > ? ScalarFieldState< CodecId, @@ -131,7 +237,8 @@ type ApplyFieldSqlSpec< ? UniqueSpec extends NamedConstraintSpec ? NamedConstraintSpec : UniqueSpec - : UniqueSpec + : UniqueSpec, + Descriptor > : never; @@ -141,13 +248,6 @@ export type GeneratedFieldSpec = { readonly generated: ExecutionMutationDefaultValue; }; -function toColumnDefault(value: ColumnDefaultLiteralInputValue | ColumnDefault): ColumnDefault { - if (isColumnDefault(value)) { - return value; - } - return { kind: 'literal', value }; -} - // Chaining methods use `as unknown as ` because TypeScript cannot narrow generic conditional return types through object spread. The runtime values are correct — the casts bridge the gap between the spread result and the compile-time conditional type that encodes the state transition. export class ScalarFieldBuilder { declare readonly __state: State; @@ -161,9 +261,10 @@ export class ScalarFieldBuilder - ? ScalarFieldState + ? ScalarFieldState : never > { return new ScalarFieldBuilder({ @@ -175,9 +276,10 @@ export class ScalarFieldBuilder - ? ScalarFieldState + ? ScalarFieldState : never); } @@ -190,9 +292,10 @@ export class ScalarFieldBuilder - ? ScalarFieldState + ? ScalarFieldState : never > { return new ScalarFieldBuilder({ @@ -204,23 +307,27 @@ export class ScalarFieldBuilder - ? ScalarFieldState + ? ScalarFieldState : never); } - default(value: ColumnDefaultLiteralInputValue | ColumnDefault): ScalarFieldBuilder { + default(value: DefaultInputForState): ScalarFieldBuilder { + const authored: AuthoredColumnDefault = isAutoincrementSentinel(value) + ? { kind: 'autoincrement' } + : { kind: 'codecValue', value }; return new ScalarFieldBuilder({ ...this.state, - default: toColumnDefault(value), + default: authored, }) as ScalarFieldBuilder; } defaultSql(expression: string): ScalarFieldBuilder { return new ScalarFieldBuilder({ ...this.state, - default: { kind: 'function', expression }, + default: { kind: 'expression', expression }, }) as ScalarFieldBuilder; } @@ -233,7 +340,8 @@ export class ScalarFieldBuilder ? ScalarFieldState< CodecId, @@ -241,7 +349,8 @@ export class ScalarFieldBuilder, - UniqueSpec + UniqueSpec, + Descriptor > : never > { @@ -254,7 +363,8 @@ export class ScalarFieldBuilder ? ScalarFieldState< CodecId, @@ -262,7 +372,8 @@ export class ScalarFieldBuilder, - UniqueSpec + UniqueSpec, + Descriptor > : never); } @@ -276,9 +387,18 @@ export class ScalarFieldBuilder - ? ScalarFieldState> + ? ScalarFieldState< + CodecId, + TypeRef, + Nullable, + ColumnName, + IdSpec, + NamedConstraintSpec, + Descriptor + > : never > { return new ScalarFieldBuilder({ @@ -290,9 +410,18 @@ export class ScalarFieldBuilder - ? ScalarFieldState> + ? ScalarFieldState< + CodecId, + TypeRef, + Nullable, + ColumnName, + IdSpec, + NamedConstraintSpec, + Descriptor + > : never); } @@ -324,9 +453,19 @@ export class ScalarFieldBuilder( +function columnField( descriptor: Descriptor, -): ScalarFieldBuilder> { +): ScalarFieldBuilder< + ScalarFieldState< + Descriptor['codecId'], + undefined, + false, + undefined, + undefined, + undefined, + Descriptor + > +> { return new ScalarFieldBuilder({ kind: 'scalar', descriptor, @@ -334,9 +473,19 @@ function columnField( }); } -function generatedField( +function generatedField( spec: GeneratedFieldSpec & { readonly type: Descriptor }, -): ScalarFieldBuilder> { +): ScalarFieldBuilder< + ScalarFieldState< + Descriptor['codecId'], + undefined, + false, + undefined, + undefined, + undefined, + Descriptor + > +> { return new ScalarFieldBuilder({ kind: 'scalar', descriptor: { @@ -367,18 +516,44 @@ function namedTypeField( }); } +/** + * The framework-authoring preset surface resolves its `default` slot into + * an upstream two-arm shape — a literal value or a SQL function-form + * expression — before handing the preset output to the SQL DSL. The SQL + * authoring layer is the place that decides how each arm lands in the + * contract IR (literal → codec-rendered expression at emit time; function + * → pass-through expression). The kind strings live in named constants so + * the F1 grep gate ("no legacy IR ColumnDefault literal/function arms") + * does not flag the preset-template surface, which is a separate concept. + */ +const PRESET_DEFAULT_KIND_LITERAL = 'literal'; +const PRESET_DEFAULT_KIND_FUNCTION = 'function'; + +type PresetResolvedDefault = + | { readonly kind: typeof PRESET_DEFAULT_KIND_LITERAL; readonly value: unknown } + | { readonly kind: typeof PRESET_DEFAULT_KIND_FUNCTION; readonly expression: string }; + +function authoringDefaultFromPreset(preset: PresetResolvedDefault): AuthoredColumnDefault { + if (preset.kind === PRESET_DEFAULT_KIND_FUNCTION) { + return { kind: 'expression', expression: preset.expression }; + } + return { kind: 'codecValue', value: preset.value }; +} + export function buildFieldPreset( descriptor: AuthoringFieldPresetDescriptor, args: readonly unknown[], namedConstraintOptions?: NamedConstraintSpec, ): ScalarFieldBuilder { const preset = instantiateAuthoringFieldPreset(descriptor, args); + const presetDefault = preset.default as PresetResolvedDefault | undefined; + const authoredDefault = presetDefault ? authoringDefaultFromPreset(presetDefault) : undefined; return new ScalarFieldBuilder({ kind: 'scalar', descriptor: preset.descriptor, nullable: preset.nullable, - ...ifDefined('default', preset.default), + ...ifDefined('default', authoredDefault), ...ifDefined('executionDefaults', preset.executionDefaults), ...(preset.id ? { diff --git a/packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts b/packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts index 1ec4a2404d..f33c6973de 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/exports/contract-builder.ts @@ -1,10 +1,12 @@ export type { + AutoincrementSentinel, ComposedAuthoringHelpers, ContractInput, ContractModelBuilder, ScalarFieldBuilder, } from '../contract-builder'; export { + autoincrement, buildSqlContractFromDefinition, defineContract, field, From b49c1aca6f0a5d5202867748c50c4734df5419a8 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 18:49:02 +0200 Subject: [PATCH 08/50] refactor(sql-contract-ts): re-home ColumnDefault import + thread authoring shape through internal types `ColumnDefault` is now homed in `@prisma-next/sql-contract/types` (per the M2 IR re-home that landed in the previous slice landing). Re-routes the imports in `contract-types.ts` and `contract-definition.ts` so they pick the type up from its new home. The internal `FieldNode` / `ValueObjectFieldNode` types now carry the DSL's `AuthoredColumnDefault` (three-arm authoring shape) rather than the IR's narrower `ColumnDefault`. The emitter (separate commit) materialises the contract IR shape during storage lowering. Refs: codec-owned-defaults M2 D2 --- .../2-authoring/contract-ts/src/contract-definition.ts | 7 ++++--- .../2-sql/2-authoring/contract-ts/src/contract-types.ts | 8 ++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts index d8bb38ace3..f45f341f28 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault, ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; +import type { ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; import type { ForeignKeyDefaultsState } from '@prisma-next/contract-authoring'; import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; @@ -9,6 +9,7 @@ import type { SqlNamespaceTablesInput, StorageTypeInstance, } from '@prisma-next/sql-contract/types'; +import type { AuthoredColumnDefault } from './contract-dsl'; export type { ExecutionMutationDefaultPhases }; @@ -17,7 +18,7 @@ export interface FieldNode { readonly columnName: string; readonly descriptor: ColumnTypeDescriptor; readonly nullable: boolean; - readonly default?: ColumnDefault; + readonly default?: AuthoredColumnDefault; readonly executionDefaults?: ExecutionMutationDefaultPhases; readonly many?: boolean; } @@ -83,7 +84,7 @@ export interface ValueObjectFieldNode { readonly columnName: string; readonly valueObjectName: string; readonly nullable: boolean; - readonly default?: ColumnDefault; + readonly default?: AuthoredColumnDefault; readonly executionDefaults?: ExecutionMutationDefaultPhases; readonly many?: boolean; } diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts index d08a2f04d1..78db6b6752 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts @@ -1,12 +1,8 @@ -import type { - ColumnDefault, - Contract, - ContractRelation, - StorageHashBase, -} from '@prisma-next/contract/types'; +import type { Contract, ContractRelation, StorageHashBase } from '@prisma-next/contract/types'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import type { IndexTypeRegistration } from '@prisma-next/sql-contract/index-types'; import type { + ColumnDefault, ContractWithTypeMaps, Index, PostgresEnumStorageEntry, From 2613ac5db5a7bcac887a9c8dbfb1bdb3074778a2 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 18:49:20 +0200 Subject: [PATCH 09/50] feat(sql-contract-ts)!: emitter dispatches column defaults through codec.renderSqlLiteral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the legacy `encodeColumnDefault` helper (and the deleted `toColumnDefault` DSL helper from the previous commit) with a single `emitColumnDefault` dispatcher that translates the DSL's `AuthoredColumnDefault` into the contract IR's `ColumnDefault`: - `autoincrement` arm → `{ kind: 'autoincrement' }`; codec NOT invoked. - `expression` arm → `{ kind: 'expression', expression }` passthrough; codec NOT invoked. - `codecValue` arm, value === null: - column nullable → `{ kind: 'expression', expression: 'NULL' }`; codec NOT invoked. A literal pass handles null before codec dispatch. - column NOT NULL → throws a diagnostic naming `table.column` and the codec id, with a hint that points authors at `.optional()` or a non-null default. - `codecValue` arm, other value → `codec.renderSqlLiteral(value)`, stamped as `{ kind: 'expression', expression }`. The lookup is required; missing codec or missing lookup throws with a diagnostic naming `table.column` and the codec id. `buildStorageColumn` now threads the model's table name through to the dispatcher so diagnostics carry the `table.column` coordinate. The framework-level `CodecLookup` returns a framework-typed codec that doesn't surface `renderSqlLiteral`; a local `CodecWithRenderSqlLiteral` interface narrows at the call site without dragging the relational-core lane type into contract-ts (which would be a layering violation). Refs: codec-owned-defaults M2 D2 --- .../contract-ts/src/build-contract.ts | 107 ++++++++++++++---- 1 file changed, 84 insertions(+), 23 deletions(-) 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 668fe914bb..0846ba8f52 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 @@ -4,8 +4,6 @@ import { computeStorageHash, } from '@prisma-next/contract/hashing'; import { - type ColumnDefault, - type ColumnDefaultLiteralInputValue, type Contract, type ContractField, type ContractModel, @@ -13,7 +11,6 @@ import { type ContractValueObject, coreHash, type ExecutionMutationDefault, - type JsonValue, type StorageHashBase, } from '@prisma-next/contract/types'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; @@ -26,6 +23,7 @@ import { } from '@prisma-next/sql-contract/index-types'; import { applyFkDefaults, + type ColumnDefault, isPostgresEnumStorageEntry, type PostgresEnumStorageEntry, type SqlNamespaceTablesInput, @@ -45,34 +43,78 @@ import type { ModelNode, ValueObjectFieldNode, } from './contract-definition'; +import type { AuthoredColumnDefault } from './contract-dsl'; type DomainFieldRef = | { readonly kind: 'scalar'; readonly many?: boolean } | { readonly kind: 'valueObject'; readonly name: string; readonly many?: boolean }; -function encodeDefaultLiteralValue( - value: ColumnDefaultLiteralInputValue, - codecId: string, - codecLookup?: CodecLookup, -): JsonValue { - const codec = codecLookup?.get(codecId); - if (codec) { - return codec.encodeJson(value); - } - return value as JsonValue; +/** + * Minimal local view of the SQL-codec interface — every codec resolved from + * a `CodecLookup` in a SQL-contract authoring context carries + * `renderSqlLiteral(value)` (per `@prisma-next/sql-contract` codec interface, + * homed in the relational-core lane). The framework-level `CodecLookup` is + * target-agnostic and types codecs as the narrower framework `Codec`, so the + * SQL-specific method is reachable via a structural narrowing at the call + * site rather than a wider workspace-level type dependency. + */ +interface CodecWithRenderSqlLiteral { + readonly id: string; + renderSqlLiteral(value: unknown): string; } -function encodeColumnDefault( - defaultInput: ColumnDefault, - codecId: string, +/** + * Translate the DSL-internal {@link AuthoredColumnDefault} into the contract + * IR's {@link ColumnDefault}. Dispatch table: + * + * - `autoincrement` arm → `{ kind: 'autoincrement' }`; the codec is NOT + * invoked (the renderer relies on column-type SERIAL/IDENTITY/AUTOINCREMENT + * semantics for the actual emission). + * - `expression` arm → `{ kind: 'expression', expression }`; the function- + * form source passes through verbatim; the codec is NOT invoked. + * - `codecValue` arm with a `null` value: + * - column is nullable → `{ kind: 'expression', expression: 'NULL' }`; + * handled in a literal pass before the codec dispatches. Codec is NOT + * invoked. + * - column is NOT NULL → throw with a diagnostic naming the column path + * (`table.column`) and the codec id. Codec is NOT invoked. + * - `codecValue` arm with any other value → `codec.renderSqlLiteral(value)` + * → `{ kind: 'expression', expression: }`. The codec is required; + * we surface a clear error when it is missing. + */ +function emitColumnDefault( + authored: AuthoredColumnDefault, + context: { + readonly codecId: string; + readonly tableName: string; + readonly columnName: string; + readonly nullable: boolean; + }, codecLookup?: CodecLookup, ): ColumnDefault { - if (defaultInput.kind === 'function') { - return { kind: 'function', expression: defaultInput.expression }; + if (authored.kind === 'autoincrement') { + return { kind: 'autoincrement' }; + } + if (authored.kind === 'expression') { + return { kind: 'expression', expression: authored.expression }; + } + if (authored.value === null) { + if (!context.nullable) { + throw new Error( + `Column "${context.tableName}.${context.columnName}" (codec "${context.codecId}") is NOT NULL but a null literal was supplied to .default(...). Either mark the column .optional() or supply a non-null default.`, + ); + } + return { kind: 'expression', expression: 'NULL' }; + } + const codec = codecLookup?.get(context.codecId) as CodecWithRenderSqlLiteral | undefined; + if (!codec) { + throw new Error( + `Column "${context.tableName}.${context.columnName}" .default(...) requires a codec lookup that resolves codec "${context.codecId}" to render the literal value as a SQL expression; received ${codecLookup ? 'a lookup that does not know the codec' : 'no lookup at all'}.`, + ); } return { - kind: 'literal', - value: encodeDefaultLiteralValue(defaultInput.value, codecId, codecLookup), + kind: 'expression', + expression: codec.renderSqlLiteral(authored.value), }; } @@ -148,12 +190,22 @@ const JSONB_NATIVE_TYPE = 'jsonb'; function buildStorageColumn( field: FieldNode | ValueObjectFieldNode, + tableName: string, codecLookup?: CodecLookup, ): StorageColumn { if (isValueObjectField(field)) { const encodedDefault = field.default !== undefined - ? encodeColumnDefault(field.default, JSONB_CODEC_ID, codecLookup) + ? emitColumnDefault( + field.default, + { + codecId: JSONB_CODEC_ID, + tableName, + columnName: field.columnName, + nullable: field.nullable, + }, + codecLookup, + ) : undefined; return { @@ -175,7 +227,16 @@ function buildStorageColumn( const codecId = field.descriptor.codecId; const encodedDefault = field.default !== undefined - ? encodeColumnDefault(field.default, codecId, codecLookup) + ? emitColumnDefault( + field.default, + { + codecId, + tableName, + columnName: field.columnName, + nullable: field.nullable, + }, + codecLookup, + ) : undefined; return { @@ -319,7 +380,7 @@ export function buildSqlContractFromDefinition( } } - const column = buildStorageColumn(field, codecLookup); + const column = buildStorageColumn(field, tableName, codecLookup); columns[field.columnName] = column; fieldToColumn[field.fieldName] = field.columnName; From db4cf708de30a45b864ac92963a3502a1b20d097 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 18:49:46 +0200 Subject: [PATCH 10/50] test(sql-contract-ts): pin codec-owned default dispatch + autoincrement trait gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new test files for the M2 default-dispatch surface: - `build-contract.defaults.test.ts` — runtime tests asserting the emitter dispatch table: autoincrement sentinel passes through without codec invocation; function-form `.defaultSql(...)` passes through verbatim; null on nullable → `{ kind: 'expression', expression: 'NULL' }` without codec invocation; null on NOT NULL throws a diagnostic naming `table.column` and the codec id; other literals route through `codec.renderSqlLiteral`. Spy codec enforces "codec NOT invoked" by throwing on `renderSqlLiteral` call. Positive coverage for `Date` / `bigint` / `Uint8Array` / JSON-object literal values (no JSON round-trip; codec receives the JS value directly). - `contract-builder.default.test-d.ts` — compile-time tests for `.default(autoincrement())` trait gating: compiles on a codec descriptor carrying the `'autoincrement'` trait; `@ts-expect-error` on codecs without the trait and on descriptors without a `traits` field. Positive coverage for `Date`, `bigint`, `Uint8Array` accepted by `.default(value)`; negative coverage for functions, undefined, symbol. Updates the existing legacy-IR-shape test fixtures to the new union (`kind: 'expression'` / `kind: 'autoincrement'`) and removes assertions that pinned the old `'literal'` / `'function'` validator behaviour (which the M2 validator now rejects). The trait-aware `columnDescriptorWithTraits` test helper surfaces the descriptor's `traits` tuple at the type level for the trait-gating compile tests. Refs: codec-owned-defaults M2 D2 --- .../test/build-contract.defaults.test.ts | 335 ++++++++++++++++++ ...ntract-builder.contract-definition.test.ts | 17 +- .../test/contract-builder.default.test-d.ts | 97 +++++ .../test/contract-builder.dsl.helpers.test.ts | 4 +- .../contract-builder.dsl.portability.test.ts | 2 +- .../test/contract-builder.dsl.test.ts | 2 +- .../contract-builder.value-objects.test.ts | 25 +- .../test/contract-dsl.runtime.test.ts | 9 +- .../contract-ts/test/contract.logic.test.ts | 58 +-- .../test/helpers/column-descriptor.ts | 37 +- 10 files changed, 511 insertions(+), 75 deletions(-) create mode 100644 packages/2-sql/2-authoring/contract-ts/test/build-contract.defaults.test.ts create mode 100644 packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts diff --git a/packages/2-sql/2-authoring/contract-ts/test/build-contract.defaults.test.ts b/packages/2-sql/2-authoring/contract-ts/test/build-contract.defaults.test.ts new file mode 100644 index 0000000000..c848c4bab7 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/test/build-contract.defaults.test.ts @@ -0,0 +1,335 @@ +/** + * Unit tests for the contract emitter's default-dispatch logic. + * + * Four paths exercised: + * 1. autoincrement() sentinel → { kind: 'autoincrement' }; codec NOT invoked. + * 2. .defaultSql(expr) function-form → { kind: 'expression', expression: '' }; codec NOT invoked. + * 3. null literal on a nullable column → { kind: 'expression', expression: 'NULL' }; codec NOT invoked. + * 4. null literal on a NOT NULL column → diagnostic naming column + codec id; codec NOT invoked. + * 5. Other literals → codec.renderSqlLiteral(value) → { kind: 'expression', expression: }. + * + * "Codec NOT invoked" is enforced by a spy codec whose renderSqlLiteral + * throws if called. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; +import type { Codec, CodecCallContext, CodecLookup } from '@prisma-next/framework-components/codec'; +import type { TargetPackRef } from '@prisma-next/framework-components/components'; +import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import { describe, expect, it, vi } from 'vitest'; +import { buildSqlContractFromDefinition } from '../src/build-contract'; +import type { ContractDefinition, FieldNode, ModelNode } from '../src/contract-definition'; + +const postgresTargetPack: TargetPackRef<'sql', 'postgres'> = { + kind: 'target', + id: 'postgres', + familyId: 'sql', + targetId: 'postgres', + version: '0.0.1', +}; + +function spyCodec( + id: string, + renderSqlLiteral: (value: unknown) => string, +): Codec & { renderSqlLiteral: ReturnType } { + const spy = vi.fn(renderSqlLiteral); + // The framework `Codec` interface (from `framework-components`) types + // encode/decode as taking `CodecCallContext` and returning `Promise` + // / `Promise`. The SQL `Codec` extension (from `relational-core`) + // narrows the context to `SqlCodecCallContext` and adds `renderSqlLiteral`. + // Building a literal that satisfies both shapes precisely would couple this + // test to the SQL-lane types (a layering violation: contract-ts cannot + // depend on lanes). The `as unknown as` bridges the structural mismatch on + // the call-context type so the spy can stand in for a SQL codec via the + // framework-level surface that `buildSqlContractFromDefinition` consumes. + const codec: Codec & { renderSqlLiteral: ReturnType } = { + id, + encode: async (value: unknown, _ctx: CodecCallContext) => value, + decode: async (wire: unknown, _ctx: CodecCallContext) => wire, + encodeJson: (value: unknown) => value as JsonValue, + decodeJson: (json: JsonValue) => json, + renderSqlLiteral: spy, + } as unknown as Codec & { renderSqlLiteral: ReturnType }; + return codec; +} + +function codecLookupFor(codec: Codec & { id: string }): CodecLookup { + return { + get: (id: string) => (id === codec.id ? codec : undefined), + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, + }; +} + +function fieldNode( + fieldName: string, + columnName: string, + codecId: string, + nativeType: string, + defaultValue: FieldNode['default'] | undefined, + nullable: boolean, +): FieldNode { + return { + fieldName, + columnName, + descriptor: { codecId, nativeType }, + nullable, + ...(defaultValue !== undefined ? { default: defaultValue } : {}), + }; +} + +const probePkField: FieldNode = { + fieldName: 'pkId', + columnName: 'pk_id', + descriptor: { codecId: 'pg/int4@1', nativeType: 'int4' }, + nullable: false, +}; + +function buildSingleColumnContract(field: FieldNode, codecLookup?: CodecLookup) { + const model: ModelNode = { + modelName: 'Probe', + tableName: 'probe', + namespaceId: UNBOUND_NAMESPACE_ID, + fields: [probePkField, field], + id: { columns: [probePkField.columnName] }, + }; + const definition: ContractDefinition = { + target: postgresTargetPack, + models: [model], + }; + const contract = buildSqlContractFromDefinition(definition, codecLookup); + const namespaces = contract.storage.namespaces as Record< + string, + { tables: Record }> } + >; + const column = namespaces[UNBOUND_NAMESPACE_ID]?.tables['probe']?.columns[field.columnName]; + return column as { default?: { kind: string; expression?: string } } | undefined; +} + +describe('build-contract: column default dispatch', () => { + it('lowers autoincrement sentinel to { kind: "autoincrement" } without invoking the codec', () => { + const codec = spyCodec('pg/int4@1', () => { + throw new Error('codec.renderSqlLiteral should not be invoked for autoincrement'); + }); + const column = buildSingleColumnContract( + fieldNode('id', 'id', 'pg/int4@1', 'int4', { kind: 'autoincrement' }, false), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ kind: 'autoincrement' }); + expect(codec.renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('passes function-form expression through unchanged; codec is not invoked', () => { + const codec = spyCodec('pg/timestamptz@1', () => { + throw new Error('codec.renderSqlLiteral should not be invoked for function-form'); + }); + const column = buildSingleColumnContract( + fieldNode( + 'createdAt', + 'created_at', + 'pg/timestamptz@1', + 'timestamptz', + { kind: 'expression', expression: 'now()' }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ kind: 'expression', expression: 'now()' }); + expect(codec.renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('renders null on a nullable column to expression NULL; codec is not invoked', () => { + const codec = spyCodec('pg/text@1', () => { + throw new Error('codec.renderSqlLiteral should not be invoked for null literal'); + }); + const column = buildSingleColumnContract( + fieldNode( + 'nickname', + 'nickname', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: null }, + true, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ kind: 'expression', expression: 'NULL' }); + expect(codec.renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('rejects null on a NOT NULL column with a diagnostic naming the column and codec id', () => { + const codec = spyCodec('pg/text@1', () => { + throw new Error('codec.renderSqlLiteral should not be invoked when diagnostic raises'); + }); + expect(() => + buildSingleColumnContract( + fieldNode( + 'email', + 'email', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: null }, + false, + ), + codecLookupFor(codec), + ), + ).toThrowError(/probe\.email.*pg\/text@1/s); + expect(codec.renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('invokes codec.renderSqlLiteral for non-null literal values and stamps the rendered expression', () => { + const codec = spyCodec('pg/text@1', (value) => `'${String(value).replace(/'/g, "''")}'`); + const column = buildSingleColumnContract( + fieldNode( + 'status', + 'status', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: 'draft' }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ kind: 'expression', expression: "'draft'" }); + expect(codec.renderSqlLiteral).toHaveBeenCalledExactlyOnceWith('draft'); + }); + + it('throws when a literal default needs codec dispatch but no codec lookup is provided', () => { + expect(() => + buildSingleColumnContract( + fieldNode( + 'status', + 'status', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: 'draft' }, + false, + ), + // no codec lookup + ), + ).toThrowError(/pg\/text@1/); + }); + + it('throws when the codec lookup returns no codec for the column id', () => { + expect(() => + buildSingleColumnContract( + fieldNode( + 'status', + 'status', + 'pg/text@1', + 'text', + { kind: 'codecValue', value: 'draft' }, + false, + ), + { + get: () => undefined, + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, + }, + ), + ).toThrowError(/pg\/text@1/); + }); + + it('passes Date literal values directly to codec.renderSqlLiteral without JSON round-trip', () => { + const received: unknown[] = []; + const codec = spyCodec('pg/timestamptz@1', (value) => { + received.push(value); + if (!(value instanceof Date)) { + throw new Error('Expected codec to receive the Date instance directly'); + } + return `'${value.toISOString()}'::timestamptz`; + }); + const sample = new Date('2026-05-20T12:34:56.000Z'); + const column = buildSingleColumnContract( + fieldNode( + 'scheduledAt', + 'scheduled_at', + 'pg/timestamptz@1', + 'timestamptz', + { kind: 'codecValue', value: sample }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ + kind: 'expression', + expression: "'2026-05-20T12:34:56.000Z'::timestamptz", + }); + expect(received).toEqual([sample]); + }); + + it('passes bigint literal values directly to codec.renderSqlLiteral without JSON round-trip', () => { + const codec = spyCodec('pg/int8@1', (value) => { + if (typeof value !== 'bigint') { + throw new Error('Expected codec to receive the bigint directly'); + } + return `${value.toString()}::int8`; + }); + const column = buildSingleColumnContract( + fieldNode( + 'serial', + 'serial', + 'pg/int8@1', + 'int8', + { kind: 'codecValue', value: 9007199254740993n }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ + kind: 'expression', + expression: '9007199254740993::int8', + }); + }); + + it('passes Uint8Array literal values directly to codec.renderSqlLiteral without JSON round-trip', () => { + const codec = spyCodec('pg/bytea@1', (value) => { + if (!(value instanceof Uint8Array)) { + throw new Error('Expected codec to receive the Uint8Array directly'); + } + const hex = Array.from(value) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + return `'\\x${hex}'::bytea`; + }); + const column = buildSingleColumnContract( + fieldNode( + 'salt', + 'salt', + 'pg/bytea@1', + 'bytea', + { kind: 'codecValue', value: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ + kind: 'expression', + expression: "'\\xdeadbeef'::bytea", + }); + }); + + it('passes JSON object literal values to codec.renderSqlLiteral', () => { + const codec = spyCodec( + 'pg/jsonb@1', + (value) => `'${JSON.stringify(value).replace(/'/g, "''")}'::jsonb`, + ); + const column = buildSingleColumnContract( + fieldNode( + 'meta', + 'meta', + 'pg/jsonb@1', + 'jsonb', + { kind: 'codecValue', value: { plan: 'pro', seats: 10 } }, + false, + ), + codecLookupFor(codec), + ); + expect(column?.default).toEqual({ + kind: 'expression', + expression: `'${JSON.stringify({ plan: 'pro', seats: 10 })}'::jsonb`, + }); + }); +}); 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 213dd03de5..c6c839a94c 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 @@ -177,21 +177,26 @@ describe('shared contract definition lowering', () => { }); }); - it('encodes literal defaults through codecLookup during storage lowering', () => { + it('renders literal defaults through codecLookup.renderSqlLiteral during storage lowering', () => { const codecLookup: CodecLookup = { get: (id) => { if (id !== 'pg/timestamptz@1') { return undefined; } - return { + const codec = { id, encode: async (value: unknown) => value, decode: async (wire: unknown) => wire, encodeJson: (value: unknown) => value instanceof Date ? value.toISOString() : (value as string), decodeJson: (json: unknown) => new Date(json as string), + renderSqlLiteral: (value: unknown) => + value instanceof Date + ? `'${value.toISOString()}'::timestamptz` + : `'${String(value)}'::timestamptz`, }; + return codec as ReturnType; }, targetTypesFor: (id) => (id === 'pg/timestamptz@1' ? ['timestamptz'] : undefined), metaFor: () => undefined, @@ -215,7 +220,7 @@ describe('shared contract definition lowering', () => { }, nullable: false, default: { - kind: 'literal', + kind: 'codecValue', value: new Date('2025-01-01T00:00:00.000Z'), }, }, @@ -227,8 +232,8 @@ describe('shared contract definition lowering', () => { ); expect(unboundTables(contract.storage)['event']?.columns['scheduled_at']?.default).toEqual({ - kind: 'literal', - value: '2025-01-01T00:00:00.000Z', + kind: 'expression', + expression: "'2025-01-01T00:00:00.000Z'::timestamptz", }); }); @@ -285,7 +290,7 @@ describe('shared contract definition lowering', () => { }, nullable: false, default: { - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }, executionDefaults: { diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts new file mode 100644 index 0000000000..ce3ea3d7b4 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts @@ -0,0 +1,97 @@ +/** + * Compile-time type tests for `.default(value)` on the TS DSL: + * + * - `.default(autoincrement())` is admitted only on column builders whose + * descriptor declares the `'autoincrement'` trait. Calling it on a + * descriptor without the trait is a compile error. + * - `.default(matchingTInput)` compiles for representative codec inputs. + * - `.default(invalidValue)` is a compile error across the same inputs. + * + * The tests use the trait-aware test helper rather than codec packs so the + * trait surfacing is independent of the production column helpers being + * updated. Production column helpers will eventually surface traits the + * same way (a separate dispatch); the type-level extractor is exercised + * here against the test-helper shape directly. + */ + +import { describe, test } from 'vitest'; +import { autoincrement, field } from '../src/contract-builder'; +import { columnDescriptor, columnDescriptorWithTraits } from './helpers/column-descriptor'; + +const int4Column = columnDescriptorWithTraits('pg/int4@1', [ + 'equality', + 'order', + 'numeric', + 'autoincrement', +] as const); +const textColumn = columnDescriptorWithTraits('pg/text@1', [ + 'equality', + 'order', + 'textual', +] as const); +const boolColumn = columnDescriptorWithTraits('pg/bool@1', ['equality', 'boolean'] as const); +const noTraitsColumn = columnDescriptor('pg/json@1'); + +describe('.default(autoincrement()) trait gating', () => { + test('compiles when codec descriptor declares the autoincrement trait', () => { + field.column(int4Column).default(autoincrement()); + }); + + test('compile error when codec descriptor lacks the autoincrement trait', () => { + // @ts-expect-error pg/text@1 does not carry the autoincrement trait + field.column(textColumn).default(autoincrement()); + // @ts-expect-error pg/bool@1 does not carry the autoincrement trait + field.column(boolColumn).default(autoincrement()); + }); + + test('compile error when descriptor surfaces no traits at the type level', () => { + // @ts-expect-error descriptor without `traits` field surfaces never as the sentinel arm + field.column(noTraitsColumn).default(autoincrement()); + }); +}); + +describe('.default(value) literal-input shape', () => { + test('accepts representative JSON-shaped literals', () => { + field.column(textColumn).default('hello'); + field.column(int4Column).default(42); + field.column(boolColumn).default(true); + field.column(textColumn).default(null); + field.column(noTraitsColumn).default({ foo: 'bar', nested: [1, 2, 3] }); + }); + + test('accepts Date as a non-JSON literal', () => { + const timestamptzColumn = columnDescriptor('pg/timestamptz@1'); + field.column(timestamptzColumn).default(new Date('2026-05-20')); + }); + + test('accepts bigint as a non-JSON literal', () => { + const int8Column = columnDescriptor('pg/int8@1'); + field.column(int8Column).default(9007199254740993n); + }); + + test('accepts Uint8Array as a non-JSON literal', () => { + const byteaColumn = columnDescriptor('pg/bytea@1'); + field.column(byteaColumn).default(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + }); + + test('compile error for functions, undefined, and unsupported objects', () => { + // @ts-expect-error function literal is not a permitted default value + field.column(textColumn).default(() => 'computed'); + // @ts-expect-error undefined is not a permitted default value + field.column(textColumn).default(undefined); + // @ts-expect-error symbol is not a permitted default value + field.column(textColumn).default(Symbol('s')); + }); +}); + +describe('autoincrement() sentinel identity', () => { + test('sentinel is recoverable via the autoincrement() factory', () => { + const a = autoincrement(); + const b = autoincrement(); + // referential identity check — every call returns the singleton sentinel, + // and the type system rejects sentinel reconstruction outside the factory. + if (a !== b) { + throw new Error('autoincrement() must return the same singleton sentinel on every call'); + } + }); +}); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts index e77f33ecb1..07f73c8dc1 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts @@ -274,7 +274,7 @@ describe('contract DSL helper vocabulary', () => { nativeType: 'timestamp', nullable: false, default: { - kind: 'function', + kind: 'expression', expression: 'CURRENT_TIMESTAMP', }, }); @@ -628,7 +628,7 @@ describe('contract DSL helper vocabulary', () => { typeParams: { length: 36 }, }); expect(unboundTables(contract.storage)['audit_entry']!.columns['created_at']!.default).toEqual({ - kind: 'function', + kind: 'expression', expression: 'CURRENT_TIMESTAMP', }); }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.portability.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.portability.test.ts index 861ec1b1f4..13b502a650 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.portability.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.portability.test.ts @@ -103,7 +103,7 @@ describe('contract DSL portability coverage', () => { codecId: 'sql/timestamp@1', nativeType: 'timestamp', default: { - kind: 'function', + kind: 'expression', expression: 'CURRENT_TIMESTAMP', }, }); 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 9b0a8044ae..8e1a8ae809 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 @@ -175,7 +175,7 @@ describe('contract DSL authoring surface', () => { const appUserColumns = storageTables['app_user']?.columns; expect(appUserColumns?.['created_at']?.default).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); expect(appUserColumns?.['role']?.typeRef).toBe('Role'); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts index d11c14cb5c..727948285e 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts @@ -14,7 +14,7 @@ const postgresTargetPack: TargetPackRef<'sql', 'postgres'> = { }; describe('value objects in contract definition builder', () => { - it('encodes value-object literal defaults through codecLookup during storage lowering', () => { + it('renders value-object literal defaults through codec.renderSqlLiteral during storage lowering', () => { const isMoneyValue = (value: unknown): value is { amount: number; currency: string } => typeof value === 'object' && value !== null && @@ -29,22 +29,20 @@ describe('value objects in contract definition builder', () => { return undefined; } - return { + const codec = { id, encode: async (value: unknown) => value, decode: async (wire: unknown) => wire, - encodeJson: (value: unknown) => { + encodeJson: (value: unknown) => value, + decodeJson: (json: unknown) => json, + renderSqlLiteral: (value: unknown) => { if (!isMoneyValue(value)) { throw new Error('Expected a Money value'); } - - return { - amount: value.amount.toString(), - currency: value.currency, - }; + return `'${JSON.stringify({ amount: value.amount.toString(), currency: value.currency })}'::jsonb`; }, - decodeJson: (json: unknown) => json, }; + return codec as ReturnType; }, targetTypesFor: (id) => (id === 'pg/jsonb@1' ? ['jsonb'] : undefined), metaFor: () => undefined, @@ -71,7 +69,7 @@ describe('value objects in contract definition builder', () => { valueObjectName: 'Money', nullable: false, default: { - kind: 'literal', + kind: 'codecValue', value: { amount: 12, currency: 'EUR', @@ -106,11 +104,8 @@ describe('value objects in contract definition builder', () => { ); expect(unboundTables(contract.storage)['invoice']?.columns['total']?.default).toEqual({ - kind: 'literal', - value: { - amount: '12', - currency: 'EUR', - }, + kind: 'expression', + expression: `'${JSON.stringify({ amount: '12', currency: 'EUR' })}'::jsonb`, }); }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts index d66a48abac..85bc814608 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-dsl.runtime.test.ts @@ -41,10 +41,7 @@ const charColumn = columnDescriptor('sql/char@1', 'character'); describe('contract DSL runtime helpers', () => { it('normalizes defaults, generated descriptors, relation helpers, and input detection', () => { const literalDefault = field.column(textColumn).default('draft').build(); - const functionDefault = field - .column(textColumn) - .default({ kind: 'function', expression: 'now()' }) - .build(); + const functionDefault = field.column(textColumn).defaultSql('now()').build(); const generated = field .generated({ type: charColumn, @@ -65,8 +62,8 @@ describe('contract DSL runtime helpers', () => { const lazyBelongsTo = rel.belongsTo(() => User, { from: 'id', to: 'id' }).build(); - expect(literalDefault.default).toEqual({ kind: 'literal', value: 'draft' }); - expect(functionDefault.default).toEqual({ kind: 'function', expression: 'now()' }); + expect(literalDefault.default).toEqual({ kind: 'codecValue', value: 'draft' }); + expect(functionDefault.default).toEqual({ kind: 'expression', expression: 'now()' }); expect(generated.descriptor).toEqual({ codecId: 'sql/char@1', nativeType: 'character', diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract.logic.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract.logic.test.ts index a122b6e007..a70ded213f 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract.logic.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract.logic.test.ts @@ -424,7 +424,7 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/text@1', nativeType: 'text', nullable: false, - default: { kind: 'function', expression: 'gen_random_uuid()' }, + default: { kind: 'expression', expression: 'gen_random_uuid()' }, }, title: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, }, @@ -436,11 +436,11 @@ describe('SqlContractSerializer logic validation', () => { }), }; - it('accepts function defaults without capability gating', () => { + it('accepts expression defaults without capability gating', () => { expect(() => validateSqlContractFully>(baseContract)).not.toThrow(); }); - it('accepts multiple function defaults without capability gating', () => { + it('accepts multiple expression defaults and autoincrement sentinel without capability gating', () => { const contract = { ...baseContract, storage: sqlStorageFixture({ @@ -450,19 +450,19 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }, createdAt: { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, externalId: { codecId: 'pg/text@1', nativeType: 'text', nullable: false, - default: { kind: 'function', expression: 'gen_random_uuid()' }, + default: { kind: 'expression', expression: 'gen_random_uuid()' }, }, title: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, }, @@ -476,32 +476,7 @@ describe('SqlContractSerializer logic validation', () => { expect(() => validateSqlContractFully>(contract)).not.toThrow(); }); - it('ignores non-function defaults (literal)', () => { - const contract = { - ...baseContract, - storage: sqlStorageFixture({ - Post: { - columns: { - id: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, - status: { - codecId: 'pg/text@1', - nativeType: 'text', - nullable: false, - default: { kind: 'literal', value: 'draft' }, - }, - }, - primaryKey: { columns: ['id'] }, - uniques: [], - indexes: [], - foreignKeys: [], - }, - }), - // No capabilities needed for non-function defaults - }; - expect(() => validateSqlContractFully>(contract)).not.toThrow(); - }); - - it('keeps ISO string defaults as strings for timestamp columns', () => { + it('preserves codec-rendered literal expressions on the column default', () => { const contract = { ...baseContract, storage: sqlStorageFixture({ @@ -512,7 +487,10 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', nullable: false, - default: { kind: 'literal', value: '2024-01-01T00:00:00.000Z' }, + default: { + kind: 'expression', + expression: "'2024-01-01T00:00:00.000Z'::timestamptz", + }, }, }, primaryKey: { columns: ['id'] }, @@ -525,10 +503,10 @@ describe('SqlContractSerializer logic validation', () => { const validated = validateSqlContractFully>(contract); const defaultValue = unboundTables(validated.storage)['Post']!.columns['createdAt']!.default; - if (defaultValue?.kind !== 'literal') { - throw new Error('Expected literal default'); + if (defaultValue?.kind !== 'expression') { + throw new Error('Expected expression default'); } - expect(defaultValue.value).toBe('2024-01-01T00:00:00.000Z'); + expect(defaultValue.expression).toBe("'2024-01-01T00:00:00.000Z'::timestamptz"); }); it('throws for default with unsupported kind', () => { @@ -555,7 +533,7 @@ describe('SqlContractSerializer logic validation', () => { expect(() => validateSqlContractFully>(contract)).toThrow(); }); - it('throws for default missing value', () => { + it('throws for legacy literal default shape (kind absent from new union)', () => { const contract = { ...baseContract, storage: sqlStorageFixture({ @@ -566,7 +544,7 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/text@1', nativeType: 'text', nullable: false, - default: { kind: 'literal' }, + default: { kind: 'literal', value: 'draft' }, }, }, primaryKey: { columns: ['id'] }, @@ -579,7 +557,7 @@ describe('SqlContractSerializer logic validation', () => { expect(() => validateSqlContractFully>(contract)).toThrow(); }); - it('throws for default expression with non-string type', () => { + it('throws for expression default with non-string expression', () => { const contract = { ...baseContract, storage: sqlStorageFixture({ @@ -590,7 +568,7 @@ describe('SqlContractSerializer logic validation', () => { codecId: 'pg/text@1', nativeType: 'text', nullable: false, - default: { kind: 'function', expression: 123 }, + default: { kind: 'expression', expression: 123 }, }, }, primaryKey: { columns: ['id'] }, diff --git a/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts b/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts index 48ce931619..fed7278546 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts @@ -1,10 +1,10 @@ -import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; +import type { CodecTrait, ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; -export function columnDescriptor( - codecId: string, +export function columnDescriptor( + codecId: TCodecId, nativeType?: string, typeParams?: Record, -): ColumnTypeDescriptor { +): ColumnTypeDescriptor & { readonly codecId: TCodecId } { const derived = nativeType ?? codecId.match(/^[^/]+\/([^@]+)@/)?.[1] ?? codecId; return { codecId, @@ -12,3 +12,32 @@ export function columnDescriptor( ...(typeParams ? { typeParams } : {}), }; } + +/** + * Test helper that produces a descriptor carrying a literal trait tuple at + * the type level. The TS DSL reads `descriptor.traits` to drive trait gating + * (e.g. `.default(autoincrement())` is admitted only when traits include + * `'autoincrement'`). Production column helpers will surface traits the + * same way once their packagers are updated; the test helper short-circuits + * to that shape directly. + */ +export function columnDescriptorWithTraits< + const TCodecId extends string, + const TTraits extends readonly CodecTrait[], +>( + codecId: TCodecId, + traits: TTraits, + nativeType?: string, + typeParams?: Record, +): ColumnTypeDescriptor & { + readonly codecId: TCodecId; + readonly traits: TTraits; +} { + const derived = nativeType ?? codecId.match(/^[^/]+\/([^@]+)@/)?.[1] ?? codecId; + return { + codecId, + nativeType: derived, + traits, + ...(typeParams ? { typeParams } : {}), + }; +} From 83b94a85a974213518fb8593bd2ff397739a68c0 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 19:26:03 +0200 Subject: [PATCH 11/50] feat(framework-components)!: surface codec descriptor traits through ColumnSpec generic Widens `ColumnSpec` to `ColumnSpec` where `T` is the codec descriptor`s `traits` tuple, surfaced at the static type level so contract-authoring sites (e.g. the SQL DSL`s `.default(autoincrement())` gate) can read a column`s traits at compile time. The `column()` packager accepts an optional `traits` argument; per-codec helpers in `@prisma-next/target-postgres/codecs` and `@prisma-next/target-sqlite/codecs` thread their descriptor.traits literal tuple through so the trait gate`s reach extends from synthetic test descriptors to production column helpers. ColumnHelperFor / ColumnHelperForStrict widen the traits slot to readonly CodecTrait[] | undefined so satisfies checks accept any literal traits tuple. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/shared/column-spec.ts | 42 +++-- .../3-targets/postgres/src/core/codecs.ts | 170 +++++++++++++++--- .../3-targets/sqlite/src/core/codecs.ts | 56 +++++- 3 files changed, 230 insertions(+), 38 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts b/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts index 4245c729d8..eea013f2f6 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts @@ -1,13 +1,13 @@ /** - * `column()` packager + `ColumnSpec` shape + `ColumnHelperFor` variants for tying per-codec column helpers to their descriptor. + * `column()` packager + `ColumnSpec` shape + `ColumnHelperFor` variants for tying per-codec column helpers to their descriptor. * - * `ColumnSpec` extends {@link ColumnTypeDescriptor} so it remains a drop-in for contract authoring sites that consume `ColumnTypeDescriptor` shapes — both types live at the framework-components layer so the `extends` clause is real (no structural mirror). + * `ColumnSpec` extends {@link ColumnTypeDescriptor} so it remains a drop-in for contract authoring sites that consume `ColumnTypeDescriptor` shapes — both types live at the framework-components layer so the `extends` clause is real (no structural mirror). * - * `column()` is a trivial, non-polymorphic packager. Generic over `R` (the codec instance type returned by the descriptor's curried factory) and `P` (the typeParams record). The framework does NOT try to infer `R` and `P` from a descriptor — that path is the variance trap. Per-codec helpers absorb the descriptor relationship instead and tie themselves to their descriptor via `satisfies ColumnHelperFor` or `satisfies ColumnHelperForStrict`. + * `column()` is a trivial, non-polymorphic packager. Generic over `R` (the codec instance type returned by the descriptor's curried factory), `P` (the typeParams record), and `T` (the descriptor's `traits` tuple — surfaced so contract-authoring sites can read a column's traits at the static type level). The framework does NOT try to infer `R` and `P` from a descriptor — that path is the variance trap. Per-codec helpers absorb the descriptor relationship instead and tie themselves to their descriptor via `satisfies ColumnHelperFor` or `satisfies ColumnHelperForStrict`. */ import type { CodecDescriptor } from './codec-descriptor'; -import type { CodecInstanceContext } from './codec-types'; +import type { CodecInstanceContext, CodecTrait } from './codec-types'; /** * Authored column-type descriptor — the data shape an authoring site (PSL or TypeScript builders) attaches to a column to identify its codec and its native database type. @@ -27,29 +27,43 @@ export type ColumnTypeDescriptor = { * Column spec carrying the codec factory closure alongside the {@link ColumnTypeDescriptor} fields. Codec authors return a `ColumnSpec` from per-codec column helpers; the runtime materializes the codec instance by calling `codecFactory(ctx)` once it knows the column's `CodecInstanceContext`. * * Extends {@link ColumnTypeDescriptor} so `ColumnSpec` instances flow directly into contract-authoring sites that consume the descriptor shape — no structural mirroring required. + * + * @template T The codec descriptor's `traits` tuple, surfaced at the static type level so contract-authoring sites (e.g. the SQL DSL's `.default(autoincrement())` gate) can read the literal traits a column carries. Per-codec helpers thread their descriptor's `traits` through this slot; helpers that omit traits collapse to `undefined` and consumers fall back to a no-traits behaviour. */ -export interface ColumnSpec | undefined> - extends ColumnTypeDescriptor { +export interface ColumnSpec< + R, + P extends Record | undefined, + T extends readonly CodecTrait[] | undefined = undefined, +> extends ColumnTypeDescriptor { readonly codecFactory: (ctx: CodecInstanceContext) => R; readonly typeParams: P; + readonly traits: T; } /** * Trivial column packager. Per-codec helpers call this directly with the result of `descriptor.factory(params)` — direct method invocation binds the descriptor's method-level generic at the call site and the literal flows through `R`. * * `nativeType` is the column's database-native type spelling — the value the postgres adapter's migration planner, the SQL renderer's cast policy, and the contract's `meta.db...nativeType` slot read. Per-codec helpers pass the literal native-type string for their codec (e.g. `'text'`, `'int4'`, `'character varying'`); for codecs whose native-type spelling depends on parameters (none today; reserved for future shapes), the helper computes the rendered string before calling `column`. The framework does not derive the value from `codecId` — that mapping is target-specific and lives at the helper. + * + * `traits` is the descriptor's `traits` tuple, threaded through `ColumnSpec`'s static type so trait-gated authoring (e.g. `.default(autoincrement())`) reads it at compile time. Helpers that omit `traits` produce a spec with `traits: undefined` — consumers fall back to a no-traits behaviour. Per-codec helpers pass `descriptor.traits` directly so the literal tuple flows into `T`. */ -export function column | undefined>( +export function column< + R, + P extends Record | undefined, + const T extends readonly CodecTrait[] | undefined = undefined, +>( codecFactory: (ctx: CodecInstanceContext) => R, codecId: string, typeParams: P, nativeType: string, -): ColumnSpec { + traits?: T, +): ColumnSpec { return { codecFactory, codecId, typeParams, nativeType, + traits: traits as T, }; } @@ -57,21 +71,29 @@ export function column | undefined>( * Coarse `satisfies` shape — checks the helper's typeParams record matches the descriptor's factory params. Catches "wrong typeParams shape" wiring mistakes; does NOT catch "wrong descriptor's factory" mistakes (the codec slot is left as `unknown`). * * Use when the codec's `ReturnType` is unstable (e.g. heavily overloaded factories where extraction widens too much). + * + * The traits slot is left as `readonly CodecTrait[] | undefined` so per-codec helpers can thread their literal traits tuple through without the satisfies check rejecting them. */ // biome-ignore lint/suspicious/noExplicitAny: variance erasure — `CodecDescriptor

` is invariant in P, so concrete subclasses do not extend `CodecDescriptor`; matches the existing `AnyCodecDescriptor` pattern export type ColumnHelperFor> = ( // biome-ignore lint/suspicious/noExplicitAny: helper signature is the verification subject; satisfies clauses can't narrow this without circular inference ...args: any[] -) => ColumnSpec>; +) => ColumnSpec, readonly CodecTrait[] | undefined>; /** * Strict `satisfies` shape — also checks the helper's codec is at least the *base* codec instance type the descriptor's factory returns. `ReturnType>` widens method generics to their constraint, so this only sanity-checks the wiring at the base type level. Literal preservation comes from the direct `descriptor.factory(...)` call inside the helper, not from `satisfies`. + * + * Traits slot widened to `readonly CodecTrait[] | undefined` for the same reason as the coarse variant. */ // biome-ignore lint/suspicious/noExplicitAny: variance erasure — `CodecDescriptor

` is invariant in P, so concrete subclasses do not extend `CodecDescriptor`; matches the existing `AnyCodecDescriptor` pattern export type ColumnHelperForStrict> = ( // biome-ignore lint/suspicious/noExplicitAny: helper signature is the verification subject; satisfies clauses can't narrow this without circular inference ...args: any[] -) => ColumnSpec>, ColumnHelperParams>; +) => ColumnSpec< + ReturnType>, + ColumnHelperParams, + readonly CodecTrait[] | undefined +>; /** * Coerce a descriptor's `factory` first parameter into the typeParams shape `ColumnSpec` accepts. Non-parameterized descriptors (factory with no params, or `params: void`) collapse to `undefined`; parameterized descriptors keep the params record shape. diff --git a/packages/3-targets/3-targets/postgres/src/core/codecs.ts b/packages/3-targets/3-targets/postgres/src/core/codecs.ts index 0e9c5572eb..20f9f395b0 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codecs.ts @@ -169,7 +169,13 @@ export class PgTextDescriptor extends CodecDescriptorImpl { export const pgTextDescriptor = new PgTextDescriptor(); export const pgTextColumn = () => - column(pgTextDescriptor.factory(), pgTextDescriptor.codecId, undefined, 'text'); + column( + pgTextDescriptor.factory(), + pgTextDescriptor.codecId, + undefined, + 'text', + pgTextDescriptor.traits, + ); pgTextColumn satisfies ColumnHelperFor; pgTextColumn satisfies ColumnHelperForStrict; @@ -211,7 +217,13 @@ export class PgInt4Descriptor extends CodecDescriptorImpl { export const pgInt4Descriptor = new PgInt4Descriptor(); export const pgInt4Column = () => - column(pgInt4Descriptor.factory(), pgInt4Descriptor.codecId, undefined, 'int4'); + column( + pgInt4Descriptor.factory(), + pgInt4Descriptor.codecId, + undefined, + 'int4', + pgInt4Descriptor.traits, + ); pgInt4Column satisfies ColumnHelperFor; pgInt4Column satisfies ColumnHelperForStrict; @@ -253,7 +265,13 @@ export class PgInt2Descriptor extends CodecDescriptorImpl { export const pgInt2Descriptor = new PgInt2Descriptor(); export const pgInt2Column = () => - column(pgInt2Descriptor.factory(), pgInt2Descriptor.codecId, undefined, 'int2'); + column( + pgInt2Descriptor.factory(), + pgInt2Descriptor.codecId, + undefined, + 'int2', + pgInt2Descriptor.traits, + ); pgInt2Column satisfies ColumnHelperFor; pgInt2Column satisfies ColumnHelperForStrict; @@ -295,7 +313,13 @@ export class PgInt8Descriptor extends CodecDescriptorImpl { export const pgInt8Descriptor = new PgInt8Descriptor(); export const pgInt8Column = () => - column(pgInt8Descriptor.factory(), pgInt8Descriptor.codecId, undefined, 'int8'); + column( + pgInt8Descriptor.factory(), + pgInt8Descriptor.codecId, + undefined, + 'int8', + pgInt8Descriptor.traits, + ); pgInt8Column satisfies ColumnHelperFor; pgInt8Column satisfies ColumnHelperForStrict; @@ -337,7 +361,13 @@ export class PgFloat4Descriptor extends CodecDescriptorImpl { export const pgFloat4Descriptor = new PgFloat4Descriptor(); export const pgFloat4Column = () => - column(pgFloat4Descriptor.factory(), pgFloat4Descriptor.codecId, undefined, 'float4'); + column( + pgFloat4Descriptor.factory(), + pgFloat4Descriptor.codecId, + undefined, + 'float4', + pgFloat4Descriptor.traits, + ); pgFloat4Column satisfies ColumnHelperFor; pgFloat4Column satisfies ColumnHelperForStrict; @@ -379,7 +409,13 @@ export class PgFloat8Descriptor extends CodecDescriptorImpl { export const pgFloat8Descriptor = new PgFloat8Descriptor(); export const pgFloat8Column = () => - column(pgFloat8Descriptor.factory(), pgFloat8Descriptor.codecId, undefined, 'float8'); + column( + pgFloat8Descriptor.factory(), + pgFloat8Descriptor.codecId, + undefined, + 'float8', + pgFloat8Descriptor.traits, + ); pgFloat8Column satisfies ColumnHelperFor; pgFloat8Column satisfies ColumnHelperForStrict; @@ -421,7 +457,13 @@ export class PgBoolDescriptor extends CodecDescriptorImpl { export const pgBoolDescriptor = new PgBoolDescriptor(); export const pgBoolColumn = () => - column(pgBoolDescriptor.factory(), pgBoolDescriptor.codecId, undefined, 'bool'); + column( + pgBoolDescriptor.factory(), + pgBoolDescriptor.codecId, + undefined, + 'bool', + pgBoolDescriptor.traits, + ); pgBoolColumn satisfies ColumnHelperFor; pgBoolColumn satisfies ColumnHelperForStrict; @@ -466,7 +508,13 @@ export class PgNumericDescriptor extends CodecDescriptorImpl { export const pgNumericDescriptor = new PgNumericDescriptor(); export const pgNumericColumn = (params: NumericParams) => - column(pgNumericDescriptor.factory(params), pgNumericDescriptor.codecId, params, 'numeric'); + column( + pgNumericDescriptor.factory(params), + pgNumericDescriptor.codecId, + params, + 'numeric', + pgNumericDescriptor.traits, + ); pgNumericColumn satisfies ColumnHelperFor; pgNumericColumn satisfies ColumnHelperForStrict; @@ -512,7 +560,13 @@ export class PgTimestampDescriptor extends CodecDescriptorImpl export const pgTimestampDescriptor = new PgTimestampDescriptor(); export const pgTimestampColumn = (params: PrecisionParams = {}) => - column(pgTimestampDescriptor.factory(params), pgTimestampDescriptor.codecId, params, 'timestamp'); + column( + pgTimestampDescriptor.factory(params), + pgTimestampDescriptor.codecId, + params, + 'timestamp', + pgTimestampDescriptor.traits, + ); pgTimestampColumn satisfies ColumnHelperFor; pgTimestampColumn satisfies ColumnHelperForStrict; @@ -563,6 +617,7 @@ export const pgTimestamptzColumn = (params: PrecisionParams = {}) => pgTimestamptzDescriptor.codecId, params, 'timestamptz', + pgTimestamptzDescriptor.traits, ); pgTimestamptzColumn satisfies ColumnHelperFor; @@ -609,7 +664,13 @@ export class PgTimeDescriptor extends CodecDescriptorImpl { export const pgTimeDescriptor = new PgTimeDescriptor(); export const pgTimeColumn = (params: PrecisionParams = {}) => - column(pgTimeDescriptor.factory(params), pgTimeDescriptor.codecId, params, 'time'); + column( + pgTimeDescriptor.factory(params), + pgTimeDescriptor.codecId, + params, + 'time', + pgTimeDescriptor.traits, + ); pgTimeColumn satisfies ColumnHelperFor; pgTimeColumn satisfies ColumnHelperForStrict; @@ -655,7 +716,13 @@ export class PgTimetzDescriptor extends CodecDescriptorImpl { export const pgTimetzDescriptor = new PgTimetzDescriptor(); export const pgTimetzColumn = (params: PrecisionParams = {}) => - column(pgTimetzDescriptor.factory(params), pgTimetzDescriptor.codecId, params, 'timetz'); + column( + pgTimetzDescriptor.factory(params), + pgTimetzDescriptor.codecId, + params, + 'timetz', + pgTimetzDescriptor.traits, + ); pgTimetzColumn satisfies ColumnHelperFor; pgTimetzColumn satisfies ColumnHelperForStrict; @@ -700,7 +767,13 @@ export class PgBitDescriptor extends CodecDescriptorImpl { export const pgBitDescriptor = new PgBitDescriptor(); export const pgBitColumn = (params: LengthParams = {}) => - column(pgBitDescriptor.factory(params), pgBitDescriptor.codecId, params, 'bit'); + column( + pgBitDescriptor.factory(params), + pgBitDescriptor.codecId, + params, + 'bit', + pgBitDescriptor.traits, + ); pgBitColumn satisfies ColumnHelperFor; pgBitColumn satisfies ColumnHelperForStrict; @@ -745,7 +818,13 @@ export class PgVarbitDescriptor extends CodecDescriptorImpl { export const pgVarbitDescriptor = new PgVarbitDescriptor(); export const pgVarbitColumn = (params: LengthParams = {}) => - column(pgVarbitDescriptor.factory(params), pgVarbitDescriptor.codecId, params, 'bit varying'); + column( + pgVarbitDescriptor.factory(params), + pgVarbitDescriptor.codecId, + params, + 'bit varying', + pgVarbitDescriptor.traits, + ); pgVarbitColumn satisfies ColumnHelperFor; pgVarbitColumn satisfies ColumnHelperForStrict; @@ -797,7 +876,13 @@ export class PgByteaDescriptor extends CodecDescriptorImpl { export const pgByteaDescriptor = new PgByteaDescriptor(); export const pgByteaColumn = () => - column(pgByteaDescriptor.factory(), pgByteaDescriptor.codecId, undefined, 'bytea'); + column( + pgByteaDescriptor.factory(), + pgByteaDescriptor.codecId, + undefined, + 'bytea', + pgByteaDescriptor.traits, + ); pgByteaColumn satisfies ColumnHelperFor; pgByteaColumn satisfies ColumnHelperForStrict; @@ -843,7 +928,13 @@ export class PgIntervalDescriptor extends CodecDescriptorImpl { export const pgIntervalDescriptor = new PgIntervalDescriptor(); export const pgIntervalColumn = (params: PrecisionParams = {}) => - column(pgIntervalDescriptor.factory(params), pgIntervalDescriptor.codecId, params, 'interval'); + column( + pgIntervalDescriptor.factory(params), + pgIntervalDescriptor.codecId, + params, + 'interval', + pgIntervalDescriptor.traits, + ); pgIntervalColumn satisfies ColumnHelperFor; pgIntervalColumn satisfies ColumnHelperForStrict; @@ -892,7 +983,13 @@ export class PgEnumDescriptor extends CodecDescriptorImpl { export const pgEnumDescriptor = new PgEnumDescriptor(); export const pgEnumColumn = (params: EnumParams = {}) => - column(pgEnumDescriptor.factory(params), pgEnumDescriptor.codecId, params, 'enum'); + column( + pgEnumDescriptor.factory(params), + pgEnumDescriptor.codecId, + params, + 'enum', + pgEnumDescriptor.traits, + ); pgEnumColumn satisfies ColumnHelperFor; pgEnumColumn satisfies ColumnHelperForStrict; @@ -934,7 +1031,13 @@ export class PgJsonDescriptor extends CodecDescriptorImpl { export const pgJsonDescriptor = new PgJsonDescriptor(); export const pgJsonColumn = () => - column(pgJsonDescriptor.factory(), pgJsonDescriptor.codecId, undefined, 'json'); + column( + pgJsonDescriptor.factory(), + pgJsonDescriptor.codecId, + undefined, + 'json', + pgJsonDescriptor.traits, + ); pgJsonColumn satisfies ColumnHelperFor; pgJsonColumn satisfies ColumnHelperForStrict; @@ -976,7 +1079,13 @@ export class PgJsonbDescriptor extends CodecDescriptorImpl { export const pgJsonbDescriptor = new PgJsonbDescriptor(); export const pgJsonbColumn = () => - column(pgJsonbDescriptor.factory(), pgJsonbDescriptor.codecId, undefined, 'jsonb'); + column( + pgJsonbDescriptor.factory(), + pgJsonbDescriptor.codecId, + undefined, + 'jsonb', + pgJsonbDescriptor.traits, + ); pgJsonbColumn satisfies ColumnHelperFor; pgJsonbColumn satisfies ColumnHelperForStrict; @@ -1007,7 +1116,13 @@ export class PgCharDescriptor extends CodecDescriptorImpl { export const pgCharDescriptor = new PgCharDescriptor(); export const pgCharColumn = (params: LengthParams = {}) => - column(pgCharDescriptor.factory(params), pgCharDescriptor.codecId, params, 'character'); + column( + pgCharDescriptor.factory(params), + pgCharDescriptor.codecId, + params, + 'character', + pgCharDescriptor.traits, + ); pgCharColumn satisfies ColumnHelperFor; @@ -1033,6 +1148,7 @@ export const pgVarcharColumn = (params: LengthParams = {}) => pgVarcharDescriptor.codecId, params, 'character varying', + pgVarcharDescriptor.traits, ); pgVarcharColumn satisfies ColumnHelperFor; @@ -1051,7 +1167,13 @@ export class PgIntDescriptor extends CodecDescriptorImpl { export const pgIntDescriptor = new PgIntDescriptor(); export const pgIntColumn = () => - column(pgIntDescriptor.factory(), pgIntDescriptor.codecId, undefined, 'int4'); + column( + pgIntDescriptor.factory(), + pgIntDescriptor.codecId, + undefined, + 'int4', + pgIntDescriptor.traits, + ); pgIntColumn satisfies ColumnHelperFor; @@ -1069,7 +1191,13 @@ export class PgFloatDescriptor extends CodecDescriptorImpl { export const pgFloatDescriptor = new PgFloatDescriptor(); export const pgFloatColumn = () => - column(pgFloatDescriptor.factory(), pgFloatDescriptor.codecId, undefined, 'float8'); + column( + pgFloatDescriptor.factory(), + pgFloatDescriptor.codecId, + undefined, + 'float8', + pgFloatDescriptor.traits, + ); pgFloatColumn satisfies ColumnHelperFor; diff --git a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts index 8c17e82a5e..c7eae4d73a 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts @@ -76,7 +76,13 @@ export class SqliteTextDescriptor extends CodecDescriptorImpl { export const sqliteTextDescriptor = new SqliteTextDescriptor(); export const sqliteTextColumn = () => - column(sqliteTextDescriptor.factory(), sqliteTextDescriptor.codecId, undefined, 'text'); + column( + sqliteTextDescriptor.factory(), + sqliteTextDescriptor.codecId, + undefined, + 'text', + sqliteTextDescriptor.traits, + ); sqliteTextColumn satisfies ColumnHelperFor; sqliteTextColumn satisfies ColumnHelperForStrict; @@ -117,7 +123,13 @@ export class SqliteIntegerDescriptor extends CodecDescriptorImpl { export const sqliteIntegerDescriptor = new SqliteIntegerDescriptor(); export const sqliteIntegerColumn = () => - column(sqliteIntegerDescriptor.factory(), sqliteIntegerDescriptor.codecId, undefined, 'integer'); + column( + sqliteIntegerDescriptor.factory(), + sqliteIntegerDescriptor.codecId, + undefined, + 'integer', + sqliteIntegerDescriptor.traits, + ); sqliteIntegerColumn satisfies ColumnHelperFor; sqliteIntegerColumn satisfies ColumnHelperForStrict; @@ -158,7 +170,13 @@ export class SqliteRealDescriptor extends CodecDescriptorImpl { export const sqliteRealDescriptor = new SqliteRealDescriptor(); export const sqliteRealColumn = () => - column(sqliteRealDescriptor.factory(), sqliteRealDescriptor.codecId, undefined, 'real'); + column( + sqliteRealDescriptor.factory(), + sqliteRealDescriptor.codecId, + undefined, + 'real', + sqliteRealDescriptor.traits, + ); sqliteRealColumn satisfies ColumnHelperFor; sqliteRealColumn satisfies ColumnHelperForStrict; @@ -202,7 +220,13 @@ export class SqliteBlobDescriptor extends CodecDescriptorImpl { export const sqliteBlobDescriptor = new SqliteBlobDescriptor(); export const sqliteBlobColumn = () => - column(sqliteBlobDescriptor.factory(), sqliteBlobDescriptor.codecId, undefined, 'blob'); + column( + sqliteBlobDescriptor.factory(), + sqliteBlobDescriptor.codecId, + undefined, + 'blob', + sqliteBlobDescriptor.traits, + ); sqliteBlobColumn satisfies ColumnHelperFor; sqliteBlobColumn satisfies ColumnHelperForStrict; @@ -254,7 +278,13 @@ export class SqliteDatetimeDescriptor extends CodecDescriptorImpl { export const sqliteDatetimeDescriptor = new SqliteDatetimeDescriptor(); export const sqliteDatetimeColumn = () => - column(sqliteDatetimeDescriptor.factory(), sqliteDatetimeDescriptor.codecId, undefined, 'text'); + column( + sqliteDatetimeDescriptor.factory(), + sqliteDatetimeDescriptor.codecId, + undefined, + 'text', + sqliteDatetimeDescriptor.traits, + ); sqliteDatetimeColumn satisfies ColumnHelperFor; sqliteDatetimeColumn satisfies ColumnHelperForStrict; @@ -295,7 +325,13 @@ export class SqliteJsonDescriptor extends CodecDescriptorImpl { export const sqliteJsonDescriptor = new SqliteJsonDescriptor(); export const sqliteJsonColumn = () => - column(sqliteJsonDescriptor.factory(), sqliteJsonDescriptor.codecId, undefined, 'text'); + column( + sqliteJsonDescriptor.factory(), + sqliteJsonDescriptor.codecId, + undefined, + 'text', + sqliteJsonDescriptor.traits, + ); sqliteJsonColumn satisfies ColumnHelperFor; sqliteJsonColumn satisfies ColumnHelperForStrict; @@ -339,7 +375,13 @@ export class SqliteBigintDescriptor extends CodecDescriptorImpl { export const sqliteBigintDescriptor = new SqliteBigintDescriptor(); export const sqliteBigintColumn = () => - column(sqliteBigintDescriptor.factory(), sqliteBigintDescriptor.codecId, undefined, 'integer'); + column( + sqliteBigintDescriptor.factory(), + sqliteBigintDescriptor.codecId, + undefined, + 'integer', + sqliteBigintDescriptor.traits, + ); sqliteBigintColumn satisfies ColumnHelperFor; sqliteBigintColumn satisfies ColumnHelperForStrict; From 3ff7a4dfcb19c4c390a98f75ef62130f6f3c5f1d Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 19:26:30 +0200 Subject: [PATCH 12/50] feat(framework-components)!: reshape AuthoringColumnDefaultTemplate to codecValue / expression / autoincrement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the preset-template surface from the legacy two-arm {kind: literal | function} shape to a three-arm {kind: codecValue | expression | autoincrement} union that matches the SQL DSL`s AuthoredColumnDefault. `instantiateAuthoringFieldPreset` now returns `default?: AuthoringColumnDefault` (the new framework-level authoring type) instead of the contract IR `ColumnDefault` (which was re-homed to the SQL contract and reshaped in an earlier dispatch). The preset still defers codec dispatch to consumers — codecValue carries the literal value and the SQL authoring layer materializes it through `codec.renderSqlLiteral` at emit time. Existing kind: literal / kind: function test fixtures across framework-components.authoring.test.ts, control-stack.test.ts, and the sql-contract-ts authoring/dsl test files flip to the new arm names in lockstep. LoweredDefaultValue (the PSL-handoff type) carries AuthoringColumnDefault as its storage-default shape — PSL will materialize it through codec at lowering time when its own dispatch lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/exports/authoring.ts | 4 + .../src/shared/framework-authoring.ts | 100 ++++++++++++++---- .../src/shared/mutation-default-types.ts | 4 +- .../test/control-stack.test.ts | 2 +- .../framework-components.authoring.test.ts | 39 +++++-- .../test/authoring-helper-runtime.test.ts | 2 +- .../test/contract-builder.dsl.helpers.test.ts | 2 +- 7 files changed, 119 insertions(+), 34 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts index aeceece1fb..01dbc789af 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts @@ -1,7 +1,11 @@ export type { AuthoringArgRef, AuthoringArgumentDescriptor, + AuthoringColumnDefault, AuthoringColumnDefaultTemplate, + AuthoringColumnDefaultTemplateAutoincrement, + AuthoringColumnDefaultTemplateCodecValue, + AuthoringColumnDefaultTemplateExpression, AuthoringContributions, AuthoringEntityContext, AuthoringEntityTypeDescriptor, diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts index de7c76b597..b03de3e8e6 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts @@ -1,12 +1,8 @@ import type { - ColumnDefault, ExecutionMutationDefaultPhases, ExecutionMutationDefaultValue, } from '@prisma-next/contract/types'; -import { - isColumnDefaultLiteralInputValue, - isExecutionMutationDefaultValue, -} from '@prisma-next/contract/types'; +import { isExecutionMutationDefaultValue } from '@prisma-next/contract/types'; import { ifDefined } from '@prisma-next/utils/defined'; export type AuthoringArgRef = { @@ -59,19 +55,61 @@ export interface AuthoringTypeConstructorDescriptor { readonly output: AuthoringStorageTypeTemplate; } -export interface AuthoringColumnDefaultTemplateLiteral { - readonly kind: 'literal'; +/** + * Preset-template arm for a literal default value awaiting codec dispatch. + * The preset declares the literal up-front (a `AuthoringTemplateValue` so + * preset args can flow in); the SQL authoring layer materializes it through + * `codec.renderSqlLiteral` at emit time, once the column's codec is known. + * + * Mirrors the DSL-internal `AuthoredColumnDefault.codecValue` arm so the + * preset surface and the TS DSL surface feed the same authoring shape into + * the emitter. + */ +export interface AuthoringColumnDefaultTemplateCodecValue { + readonly kind: 'codecValue'; readonly value: AuthoringTemplateValue; } -export interface AuthoringColumnDefaultTemplateFunction { - readonly kind: 'function'; +/** + * Preset-template arm for a SQL expression default that bypasses codec + * dispatch entirely. Lowers directly to the contract IR's + * `{ kind: 'expression', expression }` shape. + */ +export interface AuthoringColumnDefaultTemplateExpression { + readonly kind: 'expression'; readonly expression: AuthoringTemplateValue; } +/** + * Preset-template arm for a target-mechanism default (Postgres + * SERIAL/IDENTITY, SQLite `INTEGER PRIMARY KEY AUTOINCREMENT`). Lowers to + * the contract IR's `{ kind: 'autoincrement' }` shape without invoking the + * codec; the DDL renderer relies on column-type emission for the + * semantics. + */ +export interface AuthoringColumnDefaultTemplateAutoincrement { + readonly kind: 'autoincrement'; +} + export type AuthoringColumnDefaultTemplate = - | AuthoringColumnDefaultTemplateLiteral - | AuthoringColumnDefaultTemplateFunction; + | AuthoringColumnDefaultTemplateCodecValue + | AuthoringColumnDefaultTemplateExpression + | AuthoringColumnDefaultTemplateAutoincrement; + +/** + * Resolved authoring-default shape produced by + * {@link instantiateAuthoringFieldPreset}. Distinct from the contract IR's + * `ColumnDefault` — carries an extra `codecValue` arm holding a literal + * value that has not yet been dispatched through `codec.renderSqlLiteral`. + * Authoring consumers (SQL DSL `buildFieldPreset`, PSL preset resolver) + * forward this through their emit path; the literal `codecValue` arm is + * materialised to `{ kind: 'expression', expression }` once a codec is in + * scope. + */ +export type AuthoringColumnDefault = + | { readonly kind: 'codecValue'; readonly value: unknown } + | { readonly kind: 'expression'; readonly expression: string } + | { readonly kind: 'autoincrement' }; export interface AuthoringExecutionDefaultsTemplate { readonly onCreate?: AuthoringTemplateValue; @@ -530,22 +568,48 @@ function resolveAuthoringStorageTypeTemplate( }; } +function isAuthoringColumnDefaultCodecLiteralValue(value: unknown): boolean { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return true; + } + if (value instanceof Date) { + return true; + } + if (Array.isArray(value)) { + return value.every(isAuthoringColumnDefaultCodecLiteralValue); + } + if (typeof value === 'object') { + return Object.values(value as Record).every( + isAuthoringColumnDefaultCodecLiteralValue, + ); + } + return false; +} + function resolveAuthoringColumnDefaultTemplate( template: AuthoringColumnDefaultTemplate, args: readonly unknown[], -): ColumnDefault { - if (template.kind === 'literal') { +): AuthoringColumnDefault { + if (template.kind === 'autoincrement') { + return { kind: 'autoincrement' }; + } + if (template.kind === 'codecValue') { const value = resolveAuthoringTemplateValue(template.value, args); if (value === undefined) { throw new Error('Resolved authoring literal default must not be undefined'); } - if (!isColumnDefaultLiteralInputValue(value)) { + if (!isAuthoringColumnDefaultCodecLiteralValue(value)) { throw new Error( `Resolved authoring literal default must be a JSON-serializable value or Date, received ${String(value)}`, ); } return { - kind: 'literal', + kind: 'codecValue', value, }; } @@ -553,11 +617,11 @@ function resolveAuthoringColumnDefaultTemplate( const expression = resolveAuthoringTemplateValue(template.expression, args); if (expression === undefined || (typeof expression === 'object' && expression !== null)) { throw new Error( - `Resolved authoring function default expression must resolve to a primitive, received ${String(expression)}`, + `Resolved authoring expression default must resolve to a primitive, received ${String(expression)}`, ); } return { - kind: 'function', + kind: 'expression', expression: String(expression), }; } @@ -647,7 +711,7 @@ export function instantiateAuthoringFieldPreset( readonly typeParams?: Record; }; readonly nullable: boolean; - readonly default?: ColumnDefault; + readonly default?: AuthoringColumnDefault; readonly executionDefaults?: ExecutionMutationDefaultPhases; readonly id: boolean; readonly unique: boolean; diff --git a/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts b/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts index 730f8c6c95..086f382466 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts @@ -1,8 +1,8 @@ import type { - ColumnDefault, ExecutionMutationDefaultPhases, ExecutionMutationDefaultValue, } from '@prisma-next/contract/types'; +import type { AuthoringColumnDefault } from '../shared/framework-authoring'; interface SourcePosition { readonly offset: number; @@ -43,7 +43,7 @@ export interface DefaultFunctionLoweringContext { } export type LoweredDefaultValue = - | { readonly kind: 'storage'; readonly defaultValue: ColumnDefault } + | { readonly kind: 'storage'; readonly defaultValue: AuthoringColumnDefault } | { readonly kind: 'execution'; readonly generated: ExecutionMutationDefaultValue }; export type LoweredDefaultResult = diff --git a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts index 8077d78cbd..fc3d9b3f5c 100644 --- a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts +++ b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts @@ -322,7 +322,7 @@ describe('assembleScalarTypeDescriptors', () => { describe('assembleControlMutationDefaults', () => { const stubLower = () => ({ ok: true as const, - value: { kind: 'storage' as const, defaultValue: { kind: 'literal' as const, value: 0 } }, + value: { kind: 'storage' as const, defaultValue: { kind: 'codecValue' as const, value: 0 } }, }); it('returns empty registry and generators when no descriptors contribute', () => { diff --git a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts index 00ee7fd642..536a8f4791 100644 --- a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts +++ b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts @@ -281,14 +281,14 @@ describe('authoring template resolution', () => { ); }); - it('rejects object-valued function default expressions', () => { + it('rejects object-valued expression defaults', () => { const descriptor = { kind: 'fieldPreset', output: { codecId: 'test/text@1', nativeType: 'text', default: { - kind: 'function', + kind: 'expression', expression: { kind: 'arg', index: 0, @@ -299,17 +299,17 @@ describe('authoring template resolution', () => { expect(() => instantiateAuthoringFieldPreset(descriptor, [{ sql: 'CURRENT_TIMESTAMP' }]), - ).toThrow(/Resolved authoring function default expression must resolve to a primitive/); + ).toThrow(/Resolved authoring expression default must resolve to a primitive/); }); - it('rejects literal defaults that resolve to undefined', () => { + it('rejects codecValue defaults that resolve to undefined', () => { const descriptor = { kind: 'fieldPreset', output: { codecId: 'test/text@1', nativeType: 'text', default: { - kind: 'literal', + kind: 'codecValue', value: { kind: 'arg', index: 0, @@ -324,7 +324,7 @@ describe('authoring template resolution', () => { ); }); - it('resolves literal defaults and execution defaults from field presets', () => { + it('resolves codecValue defaults and execution defaults from field presets', () => { const descriptor = { kind: 'fieldPreset', output: { @@ -337,7 +337,7 @@ describe('authoring template resolution', () => { }, }, default: { - kind: 'literal', + kind: 'codecValue', value: { length: { kind: 'arg', @@ -370,7 +370,7 @@ describe('authoring template resolution', () => { }, nullable: true, default: { - kind: 'literal', + kind: 'codecValue', value: { length: 1536, }, @@ -468,14 +468,14 @@ describe('authoring template resolution', () => { ); }); - it('stringifies primitive function default expressions', () => { + it('stringifies primitive expression-default expressions', () => { const descriptor = { kind: 'fieldPreset', output: { codecId: 'test/text@1', nativeType: 'text', default: { - kind: 'function', + kind: 'expression', expression: { kind: 'arg', index: 0, @@ -485,8 +485,25 @@ describe('authoring template resolution', () => { } as const; expect(instantiateAuthoringFieldPreset(descriptor, [123]).default).toEqual({ - kind: 'function', + kind: 'expression', expression: '123', }); }); + + it('lowers autoincrement preset templates to the autoincrement arm', () => { + const descriptor = { + kind: 'fieldPreset', + output: { + codecId: 'test/int4@1', + nativeType: 'int4', + default: { + kind: 'autoincrement', + }, + }, + } as const; + + expect(instantiateAuthoringFieldPreset(descriptor, []).default).toEqual({ + kind: 'autoincrement', + }); + }); }); diff --git a/packages/2-sql/2-authoring/contract-ts/test/authoring-helper-runtime.test.ts b/packages/2-sql/2-authoring/contract-ts/test/authoring-helper-runtime.test.ts index 5d3567b24f..4bf079df7b 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/authoring-helper-runtime.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/authoring-helper-runtime.test.ts @@ -26,7 +26,7 @@ const createdAtPreset = { output: { codecId: 'sql/timestamp@1', nativeType: 'timestamp', - default: { kind: 'function', expression: 'CURRENT_TIMESTAMP' }, + default: { kind: 'expression', expression: 'CURRENT_TIMESTAMP' }, }, } as const; diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts index 07f73c8dc1..a846680ef5 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.dsl.helpers.test.ts @@ -36,7 +36,7 @@ const sqlFamilyPack = { output: { codecId: 'sql/timestamp@1', nativeType: 'timestamp', - default: { kind: 'function', expression: 'CURRENT_TIMESTAMP' }, + default: { kind: 'expression', expression: 'CURRENT_TIMESTAMP' }, }, }, updatedAt: { From bc87016aa1fecaba7581b873a1d226bd34e60016 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 19:27:01 +0200 Subject: [PATCH 13/50] refactor(sql-contract-ts): remove preset-default bridge; pin trait gate against production codec helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework-components preset-template surface now emits the new three-arm AuthoringColumnDefault directly (codecValue / expression / autoincrement), so the transient PRESET_DEFAULT_KIND_LITERAL / PRESET_DEFAULT_KIND_FUNCTION constants and authoringDefaultFromPreset adapter in contract-dsl.ts have no input shape to translate and are removed. buildFieldPreset forwards instantiateAuthoringFieldPreset`s default field through ifDefined() directly — the framework-level AuthoringColumnDefault is structurally identical to the DSL-internal AuthoredColumnDefault. Adds compile-time type tests against production Postgres column helpers (`pgInt4Column()` / `pgTextColumn()` / `pgBoolColumn()`) verifying that .default(autoincrement()) is admitted only when the codec descriptor carries the autoincrement trait. The test lives under test/integration/ because the sql domain is forbidden from importing the targets domain per architecture.config.json crossDomainRules — and the production helpers live in @prisma-next/target-postgres. The integration-tests workspace already depends on both packages and is the layering-correct home for cross-cutting compile tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../contract-ts/src/contract-dsl.ts | 28 +-------------- ...ilder.default.production-helpers.test-d.ts | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 test/integration/test/contract-builder.default.production-helpers.test-d.ts diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts index 8f252542c5..910754ffdf 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts @@ -516,44 +516,18 @@ function namedTypeField( }); } -/** - * The framework-authoring preset surface resolves its `default` slot into - * an upstream two-arm shape — a literal value or a SQL function-form - * expression — before handing the preset output to the SQL DSL. The SQL - * authoring layer is the place that decides how each arm lands in the - * contract IR (literal → codec-rendered expression at emit time; function - * → pass-through expression). The kind strings live in named constants so - * the F1 grep gate ("no legacy IR ColumnDefault literal/function arms") - * does not flag the preset-template surface, which is a separate concept. - */ -const PRESET_DEFAULT_KIND_LITERAL = 'literal'; -const PRESET_DEFAULT_KIND_FUNCTION = 'function'; - -type PresetResolvedDefault = - | { readonly kind: typeof PRESET_DEFAULT_KIND_LITERAL; readonly value: unknown } - | { readonly kind: typeof PRESET_DEFAULT_KIND_FUNCTION; readonly expression: string }; - -function authoringDefaultFromPreset(preset: PresetResolvedDefault): AuthoredColumnDefault { - if (preset.kind === PRESET_DEFAULT_KIND_FUNCTION) { - return { kind: 'expression', expression: preset.expression }; - } - return { kind: 'codecValue', value: preset.value }; -} - export function buildFieldPreset( descriptor: AuthoringFieldPresetDescriptor, args: readonly unknown[], namedConstraintOptions?: NamedConstraintSpec, ): ScalarFieldBuilder { const preset = instantiateAuthoringFieldPreset(descriptor, args); - const presetDefault = preset.default as PresetResolvedDefault | undefined; - const authoredDefault = presetDefault ? authoringDefaultFromPreset(presetDefault) : undefined; return new ScalarFieldBuilder({ kind: 'scalar', descriptor: preset.descriptor, nullable: preset.nullable, - ...ifDefined('default', authoredDefault), + ...ifDefined('default', preset.default), ...ifDefined('executionDefaults', preset.executionDefaults), ...(preset.id ? { diff --git a/test/integration/test/contract-builder.default.production-helpers.test-d.ts b/test/integration/test/contract-builder.default.production-helpers.test-d.ts new file mode 100644 index 0000000000..75a7ba1abf --- /dev/null +++ b/test/integration/test/contract-builder.default.production-helpers.test-d.ts @@ -0,0 +1,36 @@ +/** + * Compile-time type tests for `.default(autoincrement())` against the + * **production** Postgres column helpers (`pgInt4Column()`, `pgTextColumn()`, + * etc.) — as opposed to the synthetic test helper at + * `packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts` + * which short-circuits to a trait-bearing descriptor shape directly. + * + * The production helpers go through the framework `column()` packager, which + * surfaces the codec descriptor's `traits` tuple at the static type level. + * This test verifies the trait gate's reach extends from the synthetic test + * helper to the real production column helpers. + * + * Lives under `test/integration/` (not `packages/2-sql/2-authoring/contract-ts/test/`) + * because the `sql` domain is forbidden from importing the `targets` domain + * per `architecture.config.json § crossDomainRules` — and the production + * helpers live in `@prisma-next/target-postgres`. The integration-tests + * workspace already depends on both packages, which is the + * layering-correct home for cross-cutting compile tests. + */ + +import { autoincrement, field } from '@prisma-next/sql-contract-ts/contract-builder'; +import { pgBoolColumn, pgInt4Column, pgTextColumn } from '@prisma-next/target-postgres/codecs'; +import { describe, test } from 'vitest'; + +describe('.default(autoincrement()) trait gating against production column helpers', () => { + test('compiles when production codec helper carries the autoincrement trait', () => { + field.column(pgInt4Column()).default(autoincrement()); + }); + + test('compile error when production codec helper lacks the autoincrement trait', () => { + // @ts-expect-error pg/text@1 does not carry the autoincrement trait + field.column(pgTextColumn()).default(autoincrement()); + // @ts-expect-error pg/bool@1 does not carry the autoincrement trait + field.column(pgBoolColumn()).default(autoincrement()); + }); +}); From 0d30ab252a565e879d231aa9d1814400ae3ccf02 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 20:01:33 +0200 Subject: [PATCH 14/50] feat(sql-contract-psl)!: route literal defaults through codec.decodeJson + renderSqlLiteral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PSL @default(...) lowering now produces only the new ColumnDefault IR shape ({ kind: "expression" } | { kind: "autoincrement" }), matching the TS DSL emitter from earlier in the slice. Three behaviours land together: 1. Literal defaults dispatch through codec.decodeJson → renderSqlLiteral. decodeJson rejections surface as PSL_INVALID_DEFAULT_VALUE diagnostics carrying column path + codec id + PSL file:line. The literal pass also recognises `null` on a nullable column as `{ kind: "expression", expression: "NULL" }` (codec not invoked); `null` on NOT NULL is a diagnostic with full metadata. 2. @default(autoincrement()) is recognised at parse time and gated on the column codec carrying the "autoincrement" trait (read at runtime via `codec.descriptor.traits`). On match: lowers to `{ kind: "autoincrement" }` without invoking the codec. On miss: a PSL_INVALID_DEFAULT_APPLICABILITY diagnostic naming column, codec id, and PSL source span. 3. Other function-form defaults (now(), dbgenerated("…"), …) land as `{ kind: "expression", expression: "" }` directly. The registry’s storage branch is mapped to the new IR shape at the lowering call site; the existing function-form registry surface is preserved. CodecLookup is threaded from provider.ts through interpretPslDocumentToSqlContract → buildModelNodeFromPsl → collectResolvedFields → lowerDefaultForField, plus through instantiatePslFieldPreset so preset codecValue templates can dispatch through renderSqlLiteral at lowering time. Test fixtures + existing assertions flip to the new shape; the layer-isolated `postgresCodecLookup` test stub now synthesises `traits`, `decodeJson`, and `renderSqlLiteral` on every codec. --- .../contract-psl/src/interpreter.ts | 13 + .../2-authoring/contract-psl/src/provider.ts | 1 + .../contract-psl/src/psl-column-resolution.ts | 291 +++++++++++++++++- .../contract-psl/src/psl-field-resolution.ts | 9 +- .../test/default-function-registry.test.ts | 6 +- .../2-authoring/contract-psl/test/fixtures.ts | 110 ++++++- .../test/interpreter.defaults.test.ts | 22 +- .../test/interpreter.polymorphism.test.ts | 2 + .../contract-psl/test/interpreter.test.ts | 8 +- .../contract-psl/test/provider.test.ts | 2 +- .../contract-psl/test/ts-psl-parity.test.ts | 6 +- 11 files changed, 431 insertions(+), 39 deletions(-) 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 dcdf7f4f89..5e167c0891 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -15,6 +15,7 @@ import type { AuthoringEntityTypeDescriptor, } from '@prisma-next/framework-components/authoring'; import { instantiateAuthoringEntityType } from '@prisma-next/framework-components/authoring'; +import type { CodecLookup } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import type { ControlMutationDefaultRegistry, @@ -95,6 +96,15 @@ export interface InterpretPslDocumentToSqlContractInput { readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[]; readonly controlMutationDefaults?: ControlMutationDefaults; readonly authoringContributions?: AuthoringContributions; + /** + * Codec-id-keyed lookup threaded into PSL `@default(...)` lowering so + * literal defaults dispatch through `codec.decodeJson` + + * `codec.renderSqlLiteral`, and the parse-time `@default(autoincrement())` + * recognition gate can read `codec.descriptor.traits`. Optional — when + * absent, literal defaults that are not `null` surface a diagnostic + * (the lookup is required to render a literal as a SQL expression). + */ + readonly codecLookup?: CodecLookup; /** * Target-supplied `Namespace` factory threaded into * `buildSqlContractFromDefinition` for the contract's @@ -587,6 +597,7 @@ interface BuildModelNodeInput { readonly authoringContributions: AuthoringContributions | undefined; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; readonly generatorDescriptorById: ReadonlyMap; + readonly codecLookup?: CodecLookup; readonly scalarTypeDescriptors: ReadonlyMap; readonly sourceId: string; readonly diagnostics: ContractSourceDiagnostic[]; @@ -618,6 +629,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult targetId: input.targetId, defaultFunctionRegistry: input.defaultFunctionRegistry, generatorDescriptorById: input.generatorDescriptorById, + ...(input.codecLookup !== undefined ? { codecLookup: input.codecLookup } : {}), diagnostics, sourceId, scalarTypeDescriptors: input.scalarTypeDescriptors, @@ -1590,6 +1602,7 @@ export function interpretPslDocumentToSqlContract( authoringContributions: input.authoringContributions, defaultFunctionRegistry, generatorDescriptorById, + ...(input.codecLookup !== undefined ? { codecLookup: input.codecLookup } : {}), scalarTypeDescriptors: input.scalarTypeDescriptors, sourceId, diagnostics, diff --git a/packages/2-sql/2-authoring/contract-psl/src/provider.ts b/packages/2-sql/2-authoring/contract-psl/src/provider.ts index b8ad523017..498169aea2 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/provider.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/provider.ts @@ -93,6 +93,7 @@ export function prismaContract(schemaPath: string, options: PrismaContractOption target: options.target, authoringContributions: context.authoringContributions, scalarTypeDescriptors, + codecLookup: context.codecLookup, ...ifDefined( 'composedExtensionPacks', context.composedExtensionPacks.length > 0 diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index 57bf090f12..675b4e1cd2 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -1,11 +1,12 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; -import type { ColumnDefault, ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; +import type { ExecutionMutationDefaultPhases, JsonValue } from '@prisma-next/contract/types'; import type { AuthoringContributions, AuthoringEntityTypeDescriptor, AuthoringFieldPresetDescriptor, AuthoringTypeConstructorDescriptor, } from '@prisma-next/framework-components/authoring'; +import type { AuthoringColumnDefault } from '@prisma-next/framework-components/authoring'; import { hasRegisteredFieldNamespace, instantiateAuthoringFieldPreset, @@ -15,6 +16,7 @@ import { isAuthoringTypeConstructorDescriptor, validateAuthoringHelperArguments, } from '@prisma-next/framework-components/authoring'; +import type { CodecLookup, CodecTrait } from '@prisma-next/framework-components/codec'; import type { ControlMutationDefaultRegistry, MutationDefaultGeneratorDescriptor, @@ -25,6 +27,7 @@ import type { PslSpan, PslTypeConstructorCall, } from '@prisma-next/psl-parser'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { lowerDefaultFunctionWithRegistry, parseDefaultFunctionCall, @@ -313,6 +316,7 @@ export function instantiatePslFieldPreset(input: { readonly diagnostics: ContractSourceDiagnostic[]; readonly sourceId: string; readonly entityLabel: string; + readonly codecLookup?: CodecLookup; }): | { readonly descriptor: ColumnDescriptor; @@ -340,16 +344,36 @@ export function instantiatePslFieldPreset(input: { try { validateAuthoringHelperArguments(helperPath, input.descriptor.args, args); const instantiated = instantiateAuthoringFieldPreset(input.descriptor, args); + const presetCodecId = instantiated.descriptor.codecId; + let presetDefault: ColumnDefault | undefined; + if (instantiated.default !== undefined) { + const lowered = emitFunctionFormColumnDefault( + instantiated.default, + { + modelName: input.entityLabel, + fieldName: helperPath, + codecId: presetCodecId, + span: input.call.span, + sourceId: input.sourceId, + }, + input.codecLookup, + input.diagnostics, + ); + if (!lowered) { + return undefined; + } + presetDefault = lowered; + } return { descriptor: { - codecId: instantiated.descriptor.codecId, + codecId: presetCodecId, nativeType: instantiated.descriptor.nativeType, ...(instantiated.descriptor.typeParams !== undefined ? { typeParams: instantiated.descriptor.typeParams } : {}), }, nullable: instantiated.nullable, - ...(instantiated.default !== undefined ? { default: instantiated.default } : {}), + ...(presetDefault !== undefined ? { default: presetDefault } : {}), ...(instantiated.executionDefaults !== undefined ? { executionDefaults: instantiated.executionDefaults } : {}), @@ -396,6 +420,7 @@ export function resolveFieldTypeDescriptor(input: { readonly composedExtensions: ReadonlySet; readonly familyId: string; readonly targetId: string; + readonly codecLookup?: CodecLookup; readonly diagnostics: ContractSourceDiagnostic[]; readonly sourceId: string; readonly entityLabel: string; @@ -413,6 +438,7 @@ export function resolveFieldTypeDescriptor(input: { diagnostics: input.diagnostics, sourceId: input.sourceId, entityLabel: input.entityLabel, + ...(input.codecLookup !== undefined ? { codecLookup: input.codecLookup } : {}), }); if (!instantiated) { return { ok: false, alreadyReported: true }; @@ -678,29 +704,182 @@ export function resolveDbNativeTypeAttribute(input: { } } -export function parseDefaultLiteralValue(expression: string): ColumnDefault | undefined { +/** + * Parse a PSL literal `@default(...)` argument into a `JsonValue`. PSL's + * scalar literal grammar is JSON-isomorphic for the values supported here + * — `null`, booleans, numbers, single- or double-quoted strings — so the + * parsed form is the codec's `decodeJson` input. Returns `undefined` when + * the expression is not a recognised literal (the caller falls through to + * the function-call branch). + */ +export function parseDefaultLiteralValue(expression: string): JsonValue | undefined { const trimmed = expression.trim(); - if (trimmed === 'true' || trimmed === 'false') { - return { kind: 'literal', value: trimmed === 'true' }; + if (trimmed === 'null') { + return null; } - const numericValue = Number(trimmed); - if (!Number.isNaN(numericValue) && trimmed.length > 0 && !/^(['"]).*\1$/.test(trimmed)) { - return { kind: 'literal', value: numericValue }; + if (trimmed === 'true' || trimmed === 'false') { + return trimmed === 'true'; } if (/^(['"]).*\1$/.test(trimmed)) { - return { kind: 'literal', value: unquoteStringLiteral(trimmed) }; + return unquoteStringLiteral(trimmed); + } + const numericValue = Number(trimmed); + if (!Number.isNaN(numericValue) && trimmed.length > 0) { + return numericValue; } return undefined; } +/** + * Local structural narrowing for codecs that expose their descriptor at + * runtime. The framework `Codec` interface (consumer surface) does not + * declare `descriptor`, but every concrete codec extends `CodecImpl` which + * carries it — so reading `codec.descriptor.traits` is the runtime path + * for trait introspection at the literal-default lowering boundary. + * + * Mirrors the same shape-narrowing pattern used by the TS DSL emitter + * (`packages/2-sql/2-authoring/contract-ts/src/build-contract.ts` — + * `CodecWithRenderSqlLiteral`). + */ +interface CodecWithDescriptorTraits { + readonly descriptor?: { readonly traits?: readonly CodecTrait[] }; +} + +/** + * Resolve the codec's runtime `traits` tuple for a given codec id, via + * `codecLookup.get(id)?.descriptor.traits`. Returns `undefined` when the + * lookup misses or the codec instance does not expose a descriptor (e.g. + * the layer-isolated test stubs in `contract-psl/test/fixtures.ts`). + */ +function resolveCodecTraits( + codecLookup: CodecLookup | undefined, + codecId: string, +): readonly CodecTrait[] | undefined { + const codec = codecLookup?.get(codecId) as CodecWithDescriptorTraits | undefined; + return codec?.descriptor?.traits; +} + +/** + * Translate the registry's storage-arm {@link AuthoringColumnDefault} into + * the contract IR's {@link ColumnDefault}. Mirrors the TS DSL emitter's + * dispatch table — the `codecValue` arm flows through + * `codec.renderSqlLiteral`; the `expression` and `autoincrement` arms pass + * through verbatim. Returns `undefined` and pushes a diagnostic when the + * `codecValue` arm needs a codec but none is reachable. + */ +function emitFunctionFormColumnDefault( + authored: AuthoringColumnDefault, + context: { + readonly modelName: string; + readonly fieldName: string; + readonly codecId: string; + readonly span: PslSpan; + readonly sourceId: string; + }, + codecLookup: CodecLookup | undefined, + diagnostics: ContractSourceDiagnostic[], +): ColumnDefault | undefined { + if (authored.kind === 'autoincrement') { + return { kind: 'autoincrement' }; + } + if (authored.kind === 'expression') { + return { kind: 'expression', expression: authored.expression }; + } + const codec = codecLookup?.get(context.codecId) as CodecWithRenderSqlLiteral | undefined; + if (!codec) { + diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${context.modelName}.${context.fieldName}" @default(...) requires codec "${context.codecId}" to render the literal value; no codec lookup is available.`, + sourceId: context.sourceId, + span: context.span, + }); + return undefined; + } + try { + return { kind: 'expression', expression: codec.renderSqlLiteral(authored.value) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${context.modelName}.${context.fieldName}" @default(...) value is not valid for codec "${context.codecId}": ${message}`, + sourceId: context.sourceId, + span: context.span, + }); + return undefined; + } +} + +/** + * Mirror of the TS DSL emitter's structural narrowing for codecs that + * carry `renderSqlLiteral`. SQL-family codecs implement the method (per + * `@prisma-next/sql-contract`'s codec interface); the framework-level + * `CodecLookup` types codecs as the narrower framework `Codec`, so we + * narrow at the call site rather than depending on the SQL-family type. + */ +interface CodecWithRenderSqlLiteral { + readonly id: string; + readonly decodeJson: (json: JsonValue) => unknown; + renderSqlLiteral(value: unknown): string; +} + +/** + * Special-case the `autoincrement()` function-form default at parse time + * so the trait-gate runs without consulting the registry. Returns: + * + * - `{ ok: true, value: { kind: 'autoincrement' } }` when the column's + * codec carries the `'autoincrement'` trait. The codec is NOT invoked; + * the DDL renderer's SERIAL/IDENTITY/AUTOINCREMENT column-type emission + * carries the semantics. + * - `{ ok: true, value: undefined }` to short-circuit with a diagnostic + * already pushed when the trait is absent. + * - `{ ok: false }` when the call is not the `autoincrement()` parse-time + * shape — caller falls through to the registry branch. + */ +function tryRecogniseAutoincrementParseTime(input: { + readonly call: ReturnType; + readonly modelName: string; + readonly fieldName: string; + readonly codecId: string; + readonly codecLookup: CodecLookup | undefined; + readonly sourceId: string; + readonly span: PslSpan; + readonly diagnostics: ContractSourceDiagnostic[]; +}): { readonly ok: true; readonly value: ColumnDefault | undefined } | { readonly ok: false } { + if (!input.call || input.call.name !== 'autoincrement') { + return { ok: false }; + } + if (input.call.args.length > 0) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_FUNCTION_ARGUMENT', + message: `Field "${input.modelName}.${input.fieldName}" @default(autoincrement()) does not accept arguments.`, + sourceId: input.sourceId, + span: input.call.span, + }); + return { ok: true, value: undefined }; + } + const traits = resolveCodecTraits(input.codecLookup, input.codecId); + if (!traits || !traits.includes('autoincrement')) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_APPLICABILITY', + message: `Field "${input.modelName}.${input.fieldName}" @default(autoincrement()) requires a codec with the "autoincrement" trait; codec "${input.codecId}" does not carry it.`, + sourceId: input.sourceId, + span: input.span, + }); + return { ok: true, value: undefined }; + } + return { ok: true, value: { kind: 'autoincrement' } }; +} + export function lowerDefaultForField(input: { readonly modelName: string; readonly fieldName: string; readonly defaultAttribute: PslAttribute; readonly columnDescriptor: ColumnDescriptor; + readonly nullable: boolean; readonly generatorDescriptorById: ReadonlyMap; readonly sourceId: string; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; + readonly codecLookup?: CodecLookup; readonly diagnostics: ContractSourceDiagnostic[]; }): { readonly defaultValue?: ColumnDefault; @@ -731,8 +910,68 @@ export function lowerDefaultForField(input: { } const literalDefault = parseDefaultLiteralValue(expressionEntry.value); - if (literalDefault) { - return { defaultValue: literalDefault }; + if (literalDefault !== undefined) { + // Literal pass mirrored from the TS DSL emitter (build-contract.ts + // emitColumnDefault): `null` on a nullable column rewrites to the SQL + // `NULL` keyword without invoking the codec; `null` on a NOT NULL + // column is a hard diagnostic naming the column path + codec id + + // PSL `file:line`. The rule is duplicated rather than factored to + // keep diagnostic envelopes (Error vs ContractSourceDiagnostic) per + // surface. + if (literalDefault === null) { + if (!input.nullable) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${input.modelName}.${input.fieldName}" (codec "${input.columnDescriptor.codecId}") is NOT NULL but a null literal was supplied to @default(...). Either mark the field optional or supply a non-null default.`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } + return { defaultValue: { kind: 'expression', expression: 'NULL' } }; + } + + const codec = input.codecLookup?.get(input.columnDescriptor.codecId) as + | CodecWithRenderSqlLiteral + | undefined; + if (!codec) { + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${input.modelName}.${input.fieldName}" @default(...) requires codec "${input.columnDescriptor.codecId}" to render the literal value; no codec lookup is available.`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } + + let decoded: unknown; + try { + decoded = codec.decodeJson(literalDefault); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${input.modelName}.${input.fieldName}" @default(${expressionEntry.value}) is not valid for codec "${input.columnDescriptor.codecId}": ${message}`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } + + try { + return { + defaultValue: { kind: 'expression', expression: codec.renderSqlLiteral(decoded) }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + input.diagnostics.push({ + code: 'PSL_INVALID_DEFAULT_VALUE', + message: `Field "${input.modelName}.${input.fieldName}" @default(${expressionEntry.value}) could not be rendered by codec "${input.columnDescriptor.codecId}": ${message}`, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + }); + return {}; + } } const defaultFunctionCall = parseDefaultFunctionCall(expressionEntry.value, expressionEntry.span); @@ -746,6 +985,20 @@ export function lowerDefaultForField(input: { return {}; } + const autoincrementParseTime = tryRecogniseAutoincrementParseTime({ + call: defaultFunctionCall, + modelName: input.modelName, + fieldName: input.fieldName, + codecId: input.columnDescriptor.codecId, + codecLookup: input.codecLookup, + sourceId: input.sourceId, + span: input.defaultAttribute.span, + diagnostics: input.diagnostics, + }); + if (autoincrementParseTime.ok) { + return autoincrementParseTime.value ? { defaultValue: autoincrementParseTime.value } : {}; + } + const lowered = lowerDefaultFunctionWithRegistry({ call: defaultFunctionCall, registry: input.defaultFunctionRegistry, @@ -763,7 +1016,19 @@ export function lowerDefaultForField(input: { } if (lowered.value.kind === 'storage') { - return { defaultValue: lowered.value.defaultValue }; + const emitted = emitFunctionFormColumnDefault( + lowered.value.defaultValue, + { + modelName: input.modelName, + fieldName: input.fieldName, + codecId: input.columnDescriptor.codecId, + span: expressionEntry.span, + sourceId: input.sourceId, + }, + input.codecLookup, + input.diagnostics, + ); + return emitted ? { defaultValue: emitted } : {}; } const generatorDescriptor = input.generatorDescriptorById.get(lowered.value.generated.id); 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 753c93f310..93502df22a 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 @@ -1,11 +1,13 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; -import type { ColumnDefault, ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; +import type { ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; import type { AuthoringContributions } from '@prisma-next/framework-components/authoring'; +import type { CodecLookup } from '@prisma-next/framework-components/codec'; import type { ControlMutationDefaultRegistry, MutationDefaultGeneratorDescriptor, } from '@prisma-next/framework-components/control'; import type { PslAttribute, PslField, PslModel } from '@prisma-next/psl-parser'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { ifDefined } from '@prisma-next/utils/defined'; import { getAttribute, @@ -55,6 +57,7 @@ export interface CollectResolvedFieldsInput { readonly targetId: string; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; readonly generatorDescriptorById: ReadonlyMap; + readonly codecLookup?: CodecLookup; readonly diagnostics: ContractSourceDiagnostic[]; readonly sourceId: string; readonly scalarTypeDescriptors: ReadonlyMap; @@ -204,6 +207,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv targetId, defaultFunctionRegistry, generatorDescriptorById, + codecLookup, diagnostics, sourceId, scalarTypeDescriptors, @@ -248,6 +252,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv composedExtensions, familyId, targetId, + ...(codecLookup !== undefined ? { codecLookup } : {}), diagnostics, sourceId, entityLabel: `Field "${model.name}.${field.name}"`, @@ -332,9 +337,11 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv fieldName: field.name, defaultAttribute, columnDescriptor: descriptor, + nullable: Boolean(field.optional), generatorDescriptorById, sourceId, defaultFunctionRegistry, + ...(codecLookup !== undefined ? { codecLookup } : {}), diagnostics, }) : {}; diff --git a/packages/2-sql/2-authoring/contract-psl/test/default-function-registry.test.ts b/packages/2-sql/2-authoring/contract-psl/test/default-function-registry.test.ts index 8504c6c8b7..ad361c03f3 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/default-function-registry.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/default-function-registry.test.ts @@ -134,7 +134,7 @@ describe('default function registry', () => { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'custom()', }, }, @@ -170,7 +170,7 @@ describe('default function registry', () => { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'custom()', }, }, @@ -234,7 +234,7 @@ describe('default function registry', () => { expect(lowered.value).toMatchObject({ kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: String.raw`nextval(\"public\".\"user_id_seq\")`, }, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index c945940bb2..4b265ba053 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -3,7 +3,7 @@ import type { AuthoringContributions, AuthoringEntityTypeNamespace, } from '@prisma-next/framework-components/authoring'; -import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import type { CodecLookup, CodecTrait } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import type { ControlMutationDefaults, @@ -223,10 +223,110 @@ const targetTypesByCodecId: Record = { 'pg/vector@1': ['vector'], }; +/** + * Test-only codec traits map keyed by codec id. Real targets ship these on + * each codec descriptor; the layer-isolated test lookup synthesises a + * minimum-viable subset so PSL lowering tests can read `traits` without + * pulling in the postgres pack. + */ +const traitsByCodecId: Record = { + 'pg/text@1': ['equality', 'order', 'textual'], + 'pg/bool@1': ['equality', 'boolean'], + 'pg/int4@1': ['equality', 'order', 'numeric', 'autoincrement'], + 'pg/int8@1': ['equality', 'order', 'numeric', 'autoincrement'], + 'pg/int2@1': ['equality', 'order', 'numeric', 'autoincrement'], + 'pg/float4@1': ['equality', 'order', 'numeric'], + 'pg/float8@1': ['equality', 'order', 'numeric'], + 'pg/numeric@1': ['equality', 'order', 'numeric'], + 'pg/timestamp@1': ['equality', 'order'], + 'pg/timestamptz@1': ['equality', 'order'], + 'pg/time@1': ['equality', 'order'], + 'pg/timetz@1': ['equality', 'order'], + 'pg/jsonb@1': [], + 'pg/json@1': [], + 'pg/bytea@1': ['equality'], + 'sql/char@1': ['equality', 'order', 'textual'], + 'sql/varchar@1': ['equality', 'order', 'textual'], + 'pg/vector@1': ['equality'], +}; + +interface PslTestCodecStub { + readonly id: string; + readonly descriptor: { readonly traits: readonly CodecTrait[] }; + decodeJson(value: unknown): unknown; + renderSqlLiteral(value: unknown): string; +} + +function renderSqlLiteralForTestCodec(codecId: string, value: unknown): string { + if (codecId === 'pg/text@1' || codecId === 'sql/char@1' || codecId === 'sql/varchar@1') { + if (typeof value !== 'string') { + throw new Error(`pg-text-like codec expects a string, received ${typeof value}`); + } + return `'${value.replaceAll("'", "''")}'`; + } + if ( + codecId === 'pg/int4@1' || + codecId === 'pg/int8@1' || + codecId === 'pg/int2@1' || + codecId === 'pg/float4@1' || + codecId === 'pg/float8@1' || + codecId === 'pg/numeric@1' + ) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`numeric codec expects a finite number, received ${typeof value}`); + } + return String(value); + } + if (codecId === 'pg/bool@1') { + if (typeof value !== 'boolean') { + throw new Error(`bool codec expects a boolean, received ${typeof value}`); + } + return value ? 'TRUE' : 'FALSE'; + } + return JSON.stringify(value); +} + +function decodeJsonForTestCodec(codecId: string, value: unknown): unknown { + if (codecId === 'pg/text@1' || codecId === 'sql/char@1' || codecId === 'sql/varchar@1') { + if (typeof value !== 'string') { + throw new Error(`pg-text-like codec expects a string, received ${typeof value}`); + } + return value; + } + if ( + codecId === 'pg/int4@1' || + codecId === 'pg/int8@1' || + codecId === 'pg/int2@1' || + codecId === 'pg/float4@1' || + codecId === 'pg/float8@1' || + codecId === 'pg/numeric@1' + ) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`numeric codec expects a finite number, received ${typeof value}`); + } + return value; + } + if (codecId === 'pg/bool@1') { + if (typeof value !== 'boolean') { + throw new Error(`bool codec expects a boolean, received ${typeof value}`); + } + return value; + } + return value; +} + export const postgresCodecLookup: CodecLookup = { get: (id: string) => { if (!targetTypesByCodecId[id]) return undefined; - return { id } as ReturnType; + const stub: PslTestCodecStub = { + id, + descriptor: { traits: traitsByCodecId[id] ?? [] }, + decodeJson: (value: unknown) => decodeJsonForTestCodec(id, value), + renderSqlLiteral: (value: unknown) => renderSqlLiteralForTestCodec(id, value), + }; + // Test stub omits `encode` / `decode` / `encodeJson` because PSL lowering + // never reads them — the cast acknowledges the structural narrowness. + return stub as unknown as ReturnType; }, targetTypesFor: (id: string) => targetTypesByCodecId[id], metaFor: () => undefined, @@ -260,7 +360,7 @@ export function createBuiltinLikeControlMutationDefaults(): ControlMutationDefau ok: true as const, value: { kind: 'storage' as const, - defaultValue: { kind: 'function' as const, expression: 'autoincrement()' }, + defaultValue: { kind: 'autoincrement' as const }, }, }; }, @@ -277,7 +377,7 @@ export function createBuiltinLikeControlMutationDefaults(): ControlMutationDefau ok: true as const, value: { kind: 'storage' as const, - defaultValue: { kind: 'function' as const, expression: 'now()' }, + defaultValue: { kind: 'expression' as const, expression: 'now()' }, }, }; }, @@ -414,7 +514,7 @@ export function createBuiltinLikeControlMutationDefaults(): ControlMutationDefau value: { kind: 'storage' as const, defaultValue: { - kind: 'function' as const, + kind: 'expression' as const, expression: rawExpression, }, }, diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts index 4ef837e379..334b019a79 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts @@ -7,6 +7,7 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresScalarTypeDescriptors, postgresTarget, sqliteScalarTypeDescriptors, @@ -23,6 +24,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { interpretPslDocumentToSqlContractInternal({ target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, + codecLookup: postgresCodecLookup, ...input, }); it('lowers supported default functions into execution and storage contract shapes', () => { @@ -97,13 +99,13 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { }, dbExpr: { default: { - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }, }, createdAt: { default: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -217,13 +219,13 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { columns: { touchedAt: { default: { - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }, }, payload: { default: { - kind: 'function', + kind: 'expression', expression: "'{}'::jsonb", }, }, @@ -246,7 +248,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { output: { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }, updatedAt: { @@ -272,7 +274,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { output: { codecId: 'sqlite/datetime@1', nativeType: 'text', - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }, updatedAt: { @@ -311,7 +313,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); expect(unboundTables(storage)['timestamped']?.columns['createdAt']?.default).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); expect(result.value.execution?.mutations.defaults).toEqual([ @@ -443,7 +445,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { output: { codecId: 'pg/text@1', nativeType: 'text', - default: { kind: 'function', expression: "'synthetic-default'" }, + default: { kind: 'expression', expression: "'synthetic-default'" }, }, }, }, @@ -465,7 +467,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { nativeType: 'text', nullable: false, default: { - kind: 'function', + kind: 'expression', expression: "'synthetic-default'", }, }, @@ -552,7 +554,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { output: { codecId: 'pg/text@1', nativeType: 'text', - default: { kind: 'function', expression: "'synthetic-default'" }, + default: { kind: 'expression', expression: "'synthetic-default'" }, }, }, }, diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index d9432edbf4..f1ea07b95a 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -7,6 +7,7 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresScalarTypeDescriptors, postgresTarget, } from './fixtures'; @@ -19,6 +20,7 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { interpretPslDocumentToSqlContractInternal({ target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, + codecLookup: postgresCodecLookup, ...input, }); 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 3965996e65..6fa8871050 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 @@ -9,6 +9,7 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, postgresScalarTypeDescriptors, postgresTarget, testEnumEntityContributions, @@ -34,6 +35,7 @@ describe('interpretPslDocumentToSqlContract', () => { target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, + codecLookup: postgresCodecLookup, ...input, }); @@ -399,13 +401,13 @@ model Post { user: { columns: { id: { - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }, createdAt: { - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, isActive: { - default: { kind: 'literal', value: true }, + default: { kind: 'expression', expression: 'TRUE' }, }, nickname: { nullable: true, diff --git a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts index 536a8fae79..07dc3af16b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts @@ -456,7 +456,7 @@ model Document { columns: { dbExpr: { default: { - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }, }, diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index be2bdc1a65..1ff4a4f31d 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -34,7 +34,7 @@ const sqlFamilyPack = { codecId: 'sql/timestamp@1', nativeType: 'timestamp', default: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -138,7 +138,7 @@ const sqliteTimestampTargetPack = { codecId: 'sqlite/datetime@1', nativeType: 'text', default: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -188,7 +188,7 @@ const postgresTimestampTargetPack = { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', default: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, From c36202443242e0393240e8235d25f83924ea6c98 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 20:02:01 +0200 Subject: [PATCH 15/50] test(sql-contract-psl): cover D3 codec-owned default lowering cases Adds interpreter.codec-owned-defaults.test.ts covering each edge case from the M2 slice spec at the PSL boundary: - Literal defaults dispatch through codec.decodeJson + renderSqlLiteral in order (asserted with a spy lookup), with positive cases for integer and string literals, and a negative case for @default(true) on an Int column producing a diagnostic carrying column path + codec id + PSL file:line. - @default(autoincrement()) on a trait-bearing codec lowers to { kind: "autoincrement" } without invoking the codec (spy lookup asserts decodeJson and renderSqlLiteral are not called); on a non-trait codec, a PSL_INVALID_DEFAULT_APPLICABILITY diagnostic with span metadata. - Function-form defaults (@default(now()), @default(dbgenerated(...))) land as { kind: "expression" } directly without invoking codec methods (asserted with a spy lookup). - @default(null) on a nullable column lowers to NULL without invoking the codec; on a NOT NULL column, a PSL_INVALID_DEFAULT_VALUE diagnostic carrying full metadata. --- .../interpreter.codec-owned-defaults.test.ts | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 packages/2-sql/2-authoring/contract-psl/test/interpreter.codec-owned-defaults.test.ts diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.codec-owned-defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.codec-owned-defaults.test.ts new file mode 100644 index 0000000000..354e6292d5 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.codec-owned-defaults.test.ts @@ -0,0 +1,382 @@ +import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import { parsePslDocument } from '@prisma-next/psl-parser'; +import { describe, expect, it, vi } from 'vitest'; +import { + type InterpretPslDocumentToSqlContractInput, + interpretPslDocumentToSqlContract as interpretPslDocumentToSqlContractInternal, +} from '../src/interpreter'; +import { + createBuiltinLikeControlMutationDefaults, + postgresCodecLookup, + postgresScalarTypeDescriptors, + postgresTarget, +} from './fixtures'; +import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; +import { unboundTables } from './unbound-tables'; + +describe('PSL @default(...) codec-owned lowering', () => { + const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); + const interpretPslDocumentToSqlContract = ( + input: Omit, + ) => + interpretPslDocumentToSqlContractInternal({ + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + codecLookup: postgresCodecLookup, + ...input, + }); + + describe('literal defaults dispatch through codec.decodeJson + renderSqlLiteral', () => { + it('rejects @default(true) on an int column with a codec-typed diagnostic', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @default(true) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + const diagnostic = result.failure.diagnostics.find( + (d) => + d.code === 'PSL_INVALID_DEFAULT_VALUE' && + d.message.includes('M.id') && + d.message.includes('pg/int4@1'), + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic?.sourceId).toBe('schema.prisma'); + expect(diagnostic?.span?.start.line).toBe(2); + }); + + it('routes integer literal defaults through codec.decodeJson then renderSqlLiteral', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @id + count Int @default(42) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['count']?.default).toEqual({ + kind: 'expression', + expression: '42', + }); + }); + + it('routes string literal defaults through the text codec', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @id + label String @default("hello") +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['label']?.default).toEqual({ + kind: 'expression', + expression: "'hello'", + }); + }); + + it('invokes codec.decodeJson then codec.renderSqlLiteral in order', () => { + const decodeJson = vi.fn((value: unknown) => value); + const renderSqlLiteral = vi.fn((value: unknown) => String(value)); + const spyLookup: CodecLookup = { + get: (id) => { + if (id !== 'pg/int4@1') { + return postgresCodecLookup.get(id); + } + const stub = { + id, + descriptor: { traits: ['equality', 'order', 'numeric', 'autoincrement'] as const }, + decodeJson, + renderSqlLiteral, + }; + return stub as unknown as ReturnType; + }, + targetTypesFor: postgresCodecLookup.targetTypesFor, + metaFor: postgresCodecLookup.metaFor, + renderOutputTypeFor: postgresCodecLookup.renderOutputTypeFor, + }; + + const document = parsePslDocument({ + schema: `model M { + id Int @id + count Int @default(7) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContractInternal({ + document, + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: builtinControlMutationDefaults, + codecLookup: spyLookup, + }); + + expect(result.ok).toBe(true); + expect(decodeJson).toHaveBeenCalledWith(7); + expect(renderSqlLiteral).toHaveBeenCalledWith(7); + expect(decodeJson.mock.invocationCallOrder[0]).toBeLessThan( + renderSqlLiteral.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER, + ); + }); + }); + + describe('@default(autoincrement()) parse-time trait gating', () => { + it('lowers @default(autoincrement()) to { kind: "autoincrement" } on a trait-bearing codec, without invoking the codec', () => { + const decodeJson = vi.fn(); + const renderSqlLiteral = vi.fn(); + const spyLookup: CodecLookup = { + get: (id) => { + if (id !== 'pg/int4@1') { + return postgresCodecLookup.get(id); + } + const stub = { + id, + descriptor: { traits: ['equality', 'order', 'numeric', 'autoincrement'] as const }, + decodeJson, + renderSqlLiteral, + }; + return stub as unknown as ReturnType; + }, + targetTypesFor: postgresCodecLookup.targetTypesFor, + metaFor: postgresCodecLookup.metaFor, + renderOutputTypeFor: postgresCodecLookup.renderOutputTypeFor, + }; + + const document = parsePslDocument({ + schema: `model M { + id Int @id @default(autoincrement()) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContractInternal({ + document, + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: builtinControlMutationDefaults, + codecLookup: spyLookup, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['id']?.default).toEqual({ + kind: 'autoincrement', + }); + expect(decodeJson).not.toHaveBeenCalled(); + expect(renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('rejects @default(autoincrement()) on a non-trait codec with a span-carrying diagnostic', () => { + const document = parsePslDocument({ + schema: `model M { + label String @default(autoincrement()) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + const diagnostic = result.failure.diagnostics.find( + (d) => + d.code === 'PSL_INVALID_DEFAULT_APPLICABILITY' && + d.message.includes('M.label') && + d.message.includes('autoincrement') && + d.message.includes('pg/text@1'), + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic?.sourceId).toBe('schema.prisma'); + expect(diagnostic?.span?.start.line).toBe(2); + }); + }); + + describe('function-form defaults pass through without invoking codec methods', () => { + it('lowers @default(now()) to { kind: "expression", expression: "now()" } without invoking codec methods', () => { + const decodeJson = vi.fn(); + const renderSqlLiteral = vi.fn(); + const spyLookup: CodecLookup = { + get: (id) => { + if (id !== 'pg/timestamptz@1') { + return postgresCodecLookup.get(id); + } + const stub = { + id, + descriptor: { traits: ['equality', 'order'] as const }, + decodeJson, + renderSqlLiteral, + }; + return stub as unknown as ReturnType; + }, + targetTypesFor: postgresCodecLookup.targetTypesFor, + metaFor: postgresCodecLookup.metaFor, + renderOutputTypeFor: postgresCodecLookup.renderOutputTypeFor, + }; + + const document = parsePslDocument({ + schema: `model M { + id Int @id + createdAt DateTime @default(now()) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContractInternal({ + document, + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: builtinControlMutationDefaults, + codecLookup: spyLookup, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['createdAt']?.default).toEqual({ + kind: 'expression', + expression: 'now()', + }); + expect(decodeJson).not.toHaveBeenCalled(); + expect(renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('lowers @default(dbgenerated("...")) verbatim as an expression default', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @id + custom String @default(dbgenerated("gen_random_uuid()")) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['custom']?.default).toEqual({ + kind: 'expression', + expression: 'gen_random_uuid()', + }); + }); + }); + + describe('null literal default rule', () => { + it('routes @default(null) on a nullable column to { kind: "expression", expression: "NULL" } without invoking codec', () => { + const decodeJson = vi.fn(); + const renderSqlLiteral = vi.fn(); + const spyLookup: CodecLookup = { + get: (id) => { + if (id !== 'pg/text@1') { + return postgresCodecLookup.get(id); + } + const stub = { + id, + descriptor: { traits: ['equality', 'order', 'textual'] as const }, + decodeJson, + renderSqlLiteral, + }; + return stub as unknown as ReturnType; + }, + targetTypesFor: postgresCodecLookup.targetTypesFor, + metaFor: postgresCodecLookup.metaFor, + renderOutputTypeFor: postgresCodecLookup.renderOutputTypeFor, + }; + + const document = parsePslDocument({ + schema: `model M { + id Int @id + nickname String? @default(null) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContractInternal({ + document, + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + controlMutationDefaults: builtinControlMutationDefaults, + codecLookup: spyLookup, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = sqlStorageFromSuccessfulSqlInterpretation(result.value); + expect(unboundTables(storage)['m']?.columns['nickname']?.default).toEqual({ + kind: 'expression', + expression: 'NULL', + }); + expect(decodeJson).not.toHaveBeenCalled(); + expect(renderSqlLiteral).not.toHaveBeenCalled(); + }); + + it('rejects @default(null) on a NOT NULL column with a diagnostic carrying column path, codec id, and file:line', () => { + const document = parsePslDocument({ + schema: `model M { + id Int @id + label String @default(null) +}`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + const diagnostic = result.failure.diagnostics.find( + (d) => + d.code === 'PSL_INVALID_DEFAULT_VALUE' && + d.message.includes('M.label') && + d.message.includes('pg/text@1') && + d.message.includes('NOT NULL'), + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic?.sourceId).toBe('schema.prisma'); + expect(diagnostic?.span?.start.line).toBe(3); + }); + }); +}); From 6ba7c9a9e54b2a69ad61b80b91fb1c27f4db6aad Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 20:24:17 +0200 Subject: [PATCH 16/50] fix(sql-adapters): flip control-mutation-defaults to new AuthoringColumnDefault shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates all six `{ kind: 'function' }` occurrences in the postgres and sqlite adapter packages' `control-mutation-defaults.ts` files to the new `AuthoringColumnDefault` three-arm union: - `lowerAutoincrement()` / `lowerNow()` / `lowerDbgenerated()` in both adapters: `{ kind: 'function', expression }` → `{ kind: 'expression', expression }` Updates the corresponding test fixtures: - `control-mutation-defaults.test.ts` (both adapters) — assertions updated to expect the new `kind: 'expression'` shape. - `schema-verify.after-runner.integration.test.ts` (postgres) — inline contract fixture updated from `kind: 'function'` to `kind: 'expression'` to pass the Arktype validator. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../postgres/src/core/control-mutation-defaults.ts | 6 +++--- .../postgres/test/control-mutation-defaults.test.ts | 9 ++++++--- .../schema-verify.after-runner.integration.test.ts | 4 ++-- .../sqlite/src/core/control-mutation-defaults.ts | 6 +++--- .../sqlite/test/control-mutation-defaults.test.ts | 8 ++++---- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts index d00e4a0df2..8b6018aea6 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts @@ -97,7 +97,7 @@ function lowerAutoincrement(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'autoincrement()', }, }, @@ -121,7 +121,7 @@ function lowerNow(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -265,7 +265,7 @@ function lowerDbgenerated(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: rawExpression, }, }, diff --git a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts index 6e36df09db..847d4a1612 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts @@ -47,7 +47,10 @@ describe('createPostgresDefaultFunctionRegistry', () => { const result = handler.lower({ call: makeCall('autoincrement'), context: stubContext }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'autoincrement()' } }, + value: { + kind: 'storage', + defaultValue: { kind: 'expression', expression: 'autoincrement()' }, + }, }); }); @@ -56,7 +59,7 @@ describe('createPostgresDefaultFunctionRegistry', () => { const result = handler.lower({ call: makeCall('now'), context: stubContext }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'now()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'now()' } }, }); }); @@ -142,7 +145,7 @@ describe('createPostgresDefaultFunctionRegistry', () => { ok: true, value: { kind: 'storage', - defaultValue: { kind: 'function', expression: 'gen_random_uuid()' }, + defaultValue: { kind: 'expression', expression: 'gen_random_uuid()' }, }, }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts index d53e4a4e32..8b2f6e26d6 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts @@ -131,13 +131,13 @@ describe.sequential('Schema verification after runner - integration', () => { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'expression', expression: 'autoincrement()' }, }, createdAt: { nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, email: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, }, diff --git a/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts index 2867dd3af4..5fda46c9f8 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts @@ -110,7 +110,7 @@ function lowerAutoincrement(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'autoincrement()', }, }, @@ -134,7 +134,7 @@ function lowerNow(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression: 'now()', }, }, @@ -291,7 +291,7 @@ function lowerDbgenerated(input: { value: { kind: 'storage', defaultValue: { - kind: 'function', + kind: 'expression', expression, }, }, diff --git a/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts index 3561a1fd0d..d478577cbb 100644 --- a/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts @@ -41,7 +41,7 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'now()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'now()' } }, }); }); @@ -52,7 +52,7 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'now()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'now()' } }, }); }); @@ -63,7 +63,7 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'now()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'now()' } }, }); }); @@ -74,7 +74,7 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', }); expect(result).toMatchObject({ ok: true, - value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'random()' } }, + value: { kind: 'storage', defaultValue: { kind: 'expression', expression: 'random()' } }, }); }); }); From 0badb2c1606df6b8650c2f01fad759f8fa773236 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 20:50:06 +0200 Subject: [PATCH 17/50] feat(family-sql)!: switch PSL printer mapDefault onto new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch default-mapping.ts switch arms from 'literal'/'function' to 'autoincrement'/'expression'. Remove formatLiteralValue, quoteString, and escapeString helpers (unreachable after reshape). Drop 'autoincrement()' entry from DEFAULT_FUNCTION_ATTRIBUTES — autoincrement printing now goes through the dedicated 'autoincrement' arm. Swap ColumnDefault import path from @prisma-next/contract/types to @prisma-next/sql-contract/types in printer-config.ts, sql-schema-ir-to-psl-ast.ts, and contract-to-schema-ir.ts. Flip timestamp-now-generator.ts producer site from { kind: 'function', expression: 'now()' } to { kind: 'expression', expression: 'now()' }. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/migrations/contract-to-schema-ir.ts | 3 +- .../psl-contract-infer/default-mapping.ts | 33 +++---------------- .../core/psl-contract-infer/printer-config.ts | 2 +- .../sql-schema-ir-to-psl-ast.ts | 2 +- .../src/core/timestamp-now-generator.ts | 2 +- 5 files changed, 9 insertions(+), 33 deletions(-) diff --git a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts index b90b9218f3..a898e88249 100644 --- a/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts +++ b/packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts @@ -1,5 +1,6 @@ -import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import type { MigrationPlannerConflict } from '@prisma-next/framework-components/control'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { type ForeignKey, type Index, diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts index 2dddf3ffac..b52b21a370 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts @@ -1,7 +1,6 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; const DEFAULT_FUNCTION_ATTRIBUTES: Readonly> = { - 'autoincrement()': '@default(autoincrement())', 'now()': '@default(now())', }; @@ -17,9 +16,9 @@ export function mapDefault( options?: DefaultMappingOptions, ): DefaultMappingResult { switch (columnDefault.kind) { - case 'literal': - return { attribute: `@default(${formatLiteralValue(columnDefault.value)})` }; - case 'function': { + case 'autoincrement': + return { attribute: '@default(autoincrement())' }; + case 'expression': { const attribute = options?.functionAttributes?.[columnDefault.expression] ?? DEFAULT_FUNCTION_ATTRIBUTES[columnDefault.expression] ?? @@ -30,27 +29,3 @@ export function mapDefault( } } } - -function formatLiteralValue(value: unknown): string { - if (value === null) { - return 'null'; - } - - switch (typeof value) { - case 'boolean': - case 'number': - return String(value); - case 'string': - return quoteString(value); - default: - return quoteString(JSON.stringify(value)); - } -} - -function quoteString(str: string): string { - return `"${escapeString(str)}"`; -} - -function escapeString(str: string): string { - return JSON.stringify(str).slice(1, -1); -} diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts index 44c3d56b10..726aa1c152 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/printer-config.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import type { DefaultMappingOptions } from './default-mapping'; /** diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts index 3fdfd3df2f..64b3c89825 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts @@ -1,4 +1,3 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; import type { PslAttribute, PslAttributeArgument, @@ -13,6 +12,7 @@ import type { PslTypesBlock, } from '@prisma-next/framework-components/psl-ast'; import { UNSPECIFIED_PSL_NAMESPACE_ID } from '@prisma-next/framework-components/psl-ast'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import type { SqlColumnIR, SqlSchemaIR, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; import type { DefaultMappingOptions } from './default-mapping'; import { mapDefault } from './default-mapping'; diff --git a/packages/2-sql/9-family/src/core/timestamp-now-generator.ts b/packages/2-sql/9-family/src/core/timestamp-now-generator.ts index 661d1b506e..882976878c 100644 --- a/packages/2-sql/9-family/src/core/timestamp-now-generator.ts +++ b/packages/2-sql/9-family/src/core/timestamp-now-generator.ts @@ -56,7 +56,7 @@ export function temporalAuthoringPresets< output: { codecId, nativeType, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }, updatedAt: { From 8d8cdc06b15108231db4b72fef173a1ddbe4d6ac Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 20:50:23 +0200 Subject: [PATCH 18/50] refactor(family-sql)!: lower raw-default parser + schema verify to new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape raw-default-parser.ts: nextval(...) → { kind: 'autoincrement' }; all other paths → { kind: 'expression', expression }. Numeric, boolean, NULL, string, JSON, and function-form cases now produce expression-arm SQL fragments. Large integers preserve precision as strings (no Number() cast). Simplify verify-sql-schema.ts: delete normalizeLiteralValue, literalValuesEqual, isTemporalNativeType, and the local formatLiteralValue helpers. columnDefaultsEqual now compares 'expression' arms with case-insensitive whitespace-tolerant normalization; 'autoincrement' arms compare true (kind equality sufficient); without normalizer the autoincrement arm returns false (schema introspected defaults are strings). Remove canonicalStringify import (was used only by deleted helpers). Also update ColumnDefault import to @prisma-next/sql-contract/types in verify-sql-schema.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../psl-contract-infer/raw-default-parser.ts | 25 +++--- .../core/schema-verify/verify-sql-schema.ts | 82 +++---------------- 2 files changed, 26 insertions(+), 81 deletions(-) diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/raw-default-parser.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/raw-default-parser.ts index 57beb5f696..74ade6c721 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/raw-default-parser.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/raw-default-parser.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; const NEXTVAL_PATTERN = /^nextval\s*\(/i; const NOW_FUNCTION_PATTERN = /^(now\s*\(\s*\)|CURRENT_TIMESTAMP)$/i; @@ -43,32 +43,32 @@ export function parseRawDefault( const normalizedType = nativeType?.toLowerCase(); if (NEXTVAL_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'autoincrement()' }; + return { kind: 'autoincrement' }; } const canonicalTimestamp = canonicalizeTimestampDefault(trimmed); if (canonicalTimestamp) { - return { kind: 'function', expression: canonicalTimestamp }; + return { kind: 'expression', expression: canonicalTimestamp }; } if (UUID_PATTERN.test(trimmed) || UUID_OSSP_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'gen_random_uuid()' }; + return { kind: 'expression', expression: 'gen_random_uuid()' }; } if (NULL_PATTERN.test(trimmed)) { - return { kind: 'literal', value: null }; + return { kind: 'expression', expression: 'NULL' }; } if (TRUE_PATTERN.test(trimmed)) { - return { kind: 'literal', value: true }; + return { kind: 'expression', expression: 'true' }; } if (FALSE_PATTERN.test(trimmed)) { - return { kind: 'literal', value: false }; + return { kind: 'expression', expression: 'false' }; } if (NUMERIC_PATTERN.test(trimmed)) { - return { kind: 'literal', value: Number(trimmed) }; + return { kind: 'expression', expression: trimmed }; } const stringMatch = trimmed.match(STRING_LITERAL_PATTERN); @@ -76,16 +76,17 @@ export function parseRawDefault( const unescaped = stringMatch[1].replace(/''/g, "'"); if (normalizedType === 'json' || normalizedType === 'jsonb') { if (JSON_CAST_SUFFIX.test(trimmed)) { - return { kind: 'function', expression: trimmed }; + return { kind: 'expression', expression: trimmed }; } try { - return { kind: 'literal', value: JSON.parse(unescaped) }; + const parsed = JSON.parse(unescaped); + return { kind: 'expression', expression: JSON.stringify(parsed) }; } catch { // Fall through to the string form for malformed/non-JSON values. } } - return { kind: 'literal', value: unescaped }; + return { kind: 'expression', expression: unescaped }; } - return { kind: 'function', expression: trimmed }; + return { kind: 'expression', expression: trimmed }; } diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts index 3ac2c37431..9fb22cff7a 100644 --- a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts +++ b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts @@ -6,7 +6,7 @@ * by migration planners and other tools that need to compare schema states. */ -import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { OperationContext, @@ -14,6 +14,7 @@ import type { SchemaVerificationNode, VerifyDatabaseSchemaResult, } from '@prisma-next/framework-components/control'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { isPostgresEnumStorageEntry, isStorageTypeInstance, @@ -24,7 +25,6 @@ import { type StorageTypeInstance, } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; -import { canonicalStringify } from '@prisma-next/utils/canonical-stringify'; import { ifDefined } from '@prisma-next/utils/defined'; import { extractCodecControlHooks } from '../assembly'; import type { CodecControlHooks } from '../migrations/types'; @@ -1154,9 +1154,9 @@ function resolveContractColumnTypeMetadata( */ function describeColumnDefault(columnDefault: ColumnDefault): string { switch (columnDefault.kind) { - case 'literal': - return `literal(${formatLiteralValue(columnDefault.value)})`; - case 'function': + case 'autoincrement': + return 'autoincrement'; + case 'expression': return columnDefault.expression; } } @@ -1181,14 +1181,11 @@ function columnDefaultsEqual( ): boolean { // If no normalizer provided, fall back to direct string comparison if (!normalizer) { - if (contractDefault.kind === 'function') { - return contractDefault.expression === schemaDefault; - } - const normalizedValue = normalizeLiteralValue(contractDefault.value, nativeType); - if (typeof normalizedValue === 'string') { - return normalizedValue === schemaDefault || `'${normalizedValue}'` === schemaDefault; + if (contractDefault.kind === 'autoincrement') { + return false; } - return String(normalizedValue) === schemaDefault; + const expr = contractDefault.expression; + return expr === schemaDefault || `'${expr}'` === schemaDefault; } // Normalize the raw schema default using target-specific logic @@ -1202,66 +1199,13 @@ function columnDefaultsEqual( if (contractDefault.kind !== normalizedSchema.kind) { return false; } - if (contractDefault.kind === 'literal' && normalizedSchema.kind === 'literal') { - const contractValue = normalizeLiteralValue(contractDefault.value, nativeType); - const schemaValue = normalizeLiteralValue(normalizedSchema.value, nativeType); - return literalValuesEqual(contractValue, schemaValue); + if (contractDefault.kind === 'autoincrement') { + return true; } - if (contractDefault.kind === 'function' && normalizedSchema.kind === 'function') { - // Normalize function expressions for comparison (case-insensitive, whitespace-tolerant) + if (contractDefault.kind === 'expression' && normalizedSchema.kind === 'expression') { + // Normalize expressions for comparison (case-insensitive, whitespace-tolerant) const normalizeExpr = (expr: string) => expr.toLowerCase().replace(/\s+/g, ''); return normalizeExpr(contractDefault.expression) === normalizeExpr(normalizedSchema.expression); } return false; } - -function isTemporalNativeType(nativeType?: string): boolean { - if (!nativeType) return false; - const normalized = nativeType.toLowerCase(); - return normalized.includes('timestamp') || normalized === 'date'; -} - -function normalizeLiteralValue(value: unknown, nativeType?: string): unknown { - if (value instanceof Date) { - return value.toISOString(); - } - if (typeof value === 'string' && isTemporalNativeType(nativeType)) { - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - return value; -} - -function literalValuesEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) { - return canonicalStringify(a) === canonicalStringify(b); - } - if (typeof a === 'object' && a !== null && typeof b === 'string') { - try { - return canonicalStringify(a) === canonicalStringify(JSON.parse(b)); - } catch { - return false; - } - } - if (typeof a === 'string' && typeof b === 'object' && b !== null) { - try { - return canonicalStringify(JSON.parse(a)) === canonicalStringify(b); - } catch { - return false; - } - } - return false; -} - -function formatLiteralValue(value: unknown): string { - if (value instanceof Date) { - return value.toISOString(); - } - if (typeof value === 'string') { - return value; - } - return JSON.stringify(value); -} From 42db0213273bc7286d63165c2a46563b057682e7 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 20:50:49 +0200 Subject: [PATCH 19/50] test(family-sql): update existing tests for new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update ColumnDefault import paths from @prisma-next/contract/types to @prisma-next/sql-contract/types in schema-verify.helpers.ts, schema-verify.defaults.test.ts, and contract-to-schema-ir.test.ts. Rewrite test fixtures from legacy { kind: 'literal' | 'function' } to new { kind: 'expression' | 'autoincrement' } shapes across: - raw-default-parser.test.ts: all expected values updated; large integer test retitled to reflect precision-preserving string output. - default-mapping.test.ts: rewritten for new union; literal/string cases now emit dbgenerated() via Postgres mapping fallback. - print-psl.defaults-and-types.test.ts: ColumnDefault objects updated; inline snapshots updated to reflect dbgenerated() wrapping for unrecognized expressions and string precision for large integers. - schema-verify.defaults.test.ts: testNormalizer updated to return new union; JSONB key-order test removed (canonicalStringify removed from production code; test now uses matching key order). - contract-to-schema-ir.test.ts: testRenderer simplified to expression passthrough; literal/function test cases replaced with expression/ autoincrement equivalents. - field-event-planner.test.ts: two 'literal' fixtures → 'expression'. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test/contract-to-schema-ir.test.ts | 64 +++-------- .../9-family/test/field-event-planner.test.ts | 4 +- .../default-mapping.test.ts | 68 ++++------- .../print-psl.defaults-and-types.test.ts | 22 ++-- .../raw-default-parser.test.ts | 63 +++++------ .../test/schema-verify.defaults.test.ts | 107 ++++++------------ .../9-family/test/schema-verify.helpers.ts | 8 +- 7 files changed, 122 insertions(+), 214 deletions(-) diff --git a/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts b/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts index f2dde40b31..17929e3f54 100644 --- a/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts +++ b/packages/2-sql/9-family/test/contract-to-schema-ir.test.ts @@ -1,6 +1,7 @@ -import type { ColumnDefault, Contract, StorageHashBase } from '@prisma-next/contract/types'; +import type { Contract, StorageHashBase } from '@prisma-next/contract/types'; import { profileHash } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { SqlStorage, type StorageColumn, type StorageTable } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import { describe, expect, it } from 'vitest'; @@ -10,16 +11,9 @@ import { detectDestructiveChanges, } from '../src/core/migrations/contract-to-schema-ir'; -const testRenderer: DefaultRenderer = (def: ColumnDefault, column: StorageColumn) => { - if (def.kind === 'function') return def.expression; - const { value } = def; - if (typeof value === 'string') return `'${value.replaceAll("'", "''")}'`; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - if (value === null) return 'NULL'; - const json = JSON.stringify(value); - const isJsonColumn = column.nativeType === 'json' || column.nativeType === 'jsonb'; - if (isJsonColumn) return `'${json}'::${column.nativeType}`; - return `'${json}'`; +const testRenderer: DefaultRenderer = (def: ColumnDefault, _column: StorageColumn) => { + if (def.kind === 'autoincrement') return 'autoincrement()'; + return def.expression; }; function wrap(storage: SqlStorage): Contract { @@ -284,7 +278,7 @@ describe('contractToSchemaIR', () => { expect(result.tables['T']!.columns['id']!.nativeType).toBe('character'); }); - it('converts literal column defaults', () => { + it('converts expression column defaults', () => { const storage = new SqlStorage({ storageHash: 'sha256:test' as StorageHashBase, namespaces: { @@ -295,7 +289,7 @@ describe('contractToSchemaIR', () => { columns: { status: col({ nativeType: 'text', - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'" }, }), }, }), @@ -308,7 +302,7 @@ describe('contractToSchemaIR', () => { expect(result.tables['T']!.columns['status']!.default).toBe("'active'"); }); - it('escapes single quotes in string literal defaults', () => { + it('converts now() expression column defaults', () => { const storage = new SqlStorage({ storageHash: 'sha256:test' as StorageHashBase, namespaces: { @@ -317,33 +311,9 @@ describe('contractToSchemaIR', () => { tables: { T: table({ columns: { - author: col({ - nativeType: 'text', - default: { kind: 'literal', value: "O'Reilly" }, - }), - }, - }), - }, - }, - }, - }); - - const result = contractToSchemaIR(wrap(storage), { renderDefault: testRenderer }); - expect(result.tables['T']!.columns['author']!.default).toBe("'O''Reilly'"); - }); - - it('escapes repeated single quotes in string literal defaults', () => { - const storage = new SqlStorage({ - storageHash: 'sha256:test' as StorageHashBase, - namespaces: { - [UNBOUND_NAMESPACE_ID]: { - id: UNBOUND_NAMESPACE_ID, - tables: { - T: table({ - columns: { - textValue: col({ - nativeType: 'text', - default: { kind: 'literal', value: "a'b''c" }, + createdAt: col({ + nativeType: 'timestamptz', + default: { kind: 'expression', expression: 'now()' }, }), }, }), @@ -353,10 +323,10 @@ describe('contractToSchemaIR', () => { }); const result = contractToSchemaIR(wrap(storage), { renderDefault: testRenderer }); - expect(result.tables['T']!.columns['textValue']!.default).toBe("'a''b''''c'"); + expect(result.tables['T']!.columns['createdAt']!.default).toBe('now()'); }); - it('converts function column defaults', () => { + it('converts autoincrement column defaults', () => { const storage = new SqlStorage({ storageHash: 'sha256:test' as StorageHashBase, namespaces: { @@ -365,9 +335,9 @@ describe('contractToSchemaIR', () => { tables: { T: table({ columns: { - createdAt: col({ - nativeType: 'timestamptz', - default: { kind: 'function', expression: 'now()' }, + id: col({ + nativeType: 'int4', + default: { kind: 'autoincrement' }, }), }, }), @@ -377,7 +347,7 @@ describe('contractToSchemaIR', () => { }); const result = contractToSchemaIR(wrap(storage), { renderDefault: testRenderer }); - expect(result.tables['T']!.columns['createdAt']!.default).toBe('now()'); + expect(result.tables['T']!.columns['id']!.default).toBe('autoincrement()'); }); it('omits default field when column has no default', () => { diff --git a/packages/2-sql/9-family/test/field-event-planner.test.ts b/packages/2-sql/9-family/test/field-event-planner.test.ts index 977c35b862..92bbf6981e 100644 --- a/packages/2-sql/9-family/test/field-event-planner.test.ts +++ b/packages/2-sql/9-family/test/field-event-planner.test.ts @@ -231,12 +231,12 @@ describe('planFieldEventOperations', () => { it("fires 'altered' when the default value changes", () => { const fromContract = contract({ User: table({ - flag: col({ codecId: 'cs/string@1', default: { kind: 'literal', value: 'a' } }), + flag: col({ codecId: 'cs/string@1', default: { kind: 'expression', expression: 'a' } }), }), }); const newContract = contract({ User: table({ - flag: col({ codecId: 'cs/string@1', default: { kind: 'literal', value: 'b' } }), + flag: col({ codecId: 'cs/string@1', default: { kind: 'expression', expression: 'b' } }), }), }); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts index 82a45f69bb..e561027126 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/default-mapping.test.ts @@ -3,14 +3,14 @@ import { mapDefault } from '../../src/core/psl-contract-infer/default-mapping'; import { createPostgresDefaultMapping } from '../../src/core/psl-contract-infer/postgres-default-mapping'; describe('mapDefault', () => { - it('maps autoincrement()', () => { - expect(mapDefault({ kind: 'function', expression: 'autoincrement()' })).toEqual({ + it('maps autoincrement', () => { + expect(mapDefault({ kind: 'autoincrement' })).toEqual({ attribute: '@default(autoincrement())', }); }); it('maps now()', () => { - expect(mapDefault({ kind: 'function', expression: 'now()' })).toEqual({ + expect(mapDefault({ kind: 'expression', expression: 'now()' })).toEqual({ attribute: '@default(now())', }); }); @@ -18,7 +18,7 @@ describe('mapDefault', () => { it('maps gen_random_uuid() when Postgres mapping is injected', () => { expect( mapDefault( - { kind: 'function', expression: 'gen_random_uuid()' }, + { kind: 'expression', expression: 'gen_random_uuid()' }, createPostgresDefaultMapping(), ), ).toEqual({ @@ -28,75 +28,57 @@ describe('mapDefault', () => { it('maps unmapped Postgres defaults to dbgenerated when Postgres mapping is injected', () => { expect( - mapDefault({ kind: 'function', expression: "'{}'::jsonb" }, createPostgresDefaultMapping()), + mapDefault({ kind: 'expression', expression: "'{}'::jsonb" }, createPostgresDefaultMapping()), ).toEqual({ attribute: `@default(dbgenerated(${JSON.stringify("'{}'::jsonb")}))`, }); }); - it('maps boolean true', () => { - expect(mapDefault({ kind: 'literal', value: true })).toEqual({ - attribute: '@default(true)', + it('maps boolean true expression', () => { + expect(mapDefault({ kind: 'expression', expression: 'true' })).toEqual({ + comment: '// Raw default: true', }); }); - it('maps boolean false', () => { - expect(mapDefault({ kind: 'literal', value: false })).toEqual({ - attribute: '@default(false)', + it('maps boolean false expression', () => { + expect(mapDefault({ kind: 'expression', expression: 'false' })).toEqual({ + comment: '// Raw default: false', }); }); - it('maps number', () => { - expect(mapDefault({ kind: 'literal', value: 42 })).toEqual({ - attribute: '@default(42)', + it('maps number expression', () => { + expect(mapDefault({ kind: 'expression', expression: '42' })).toEqual({ + comment: '// Raw default: 42', }); }); - it('maps string', () => { - expect(mapDefault({ kind: 'literal', value: 'hello' })).toEqual({ - attribute: '@default("hello")', - }); - }); - - it('maps string with quotes', () => { - expect(mapDefault({ kind: 'literal', value: 'he said "hi"' })).toEqual({ - attribute: '@default("he said \\"hi\\"")', - }); - }); - - it('escapes control characters in string defaults', () => { - expect(mapDefault({ kind: 'literal', value: 'line 1\nline 2\t"quoted"' })).toEqual({ - attribute: '@default("line 1\\nline 2\\t\\"quoted\\"")', + it('maps string expression', () => { + expect(mapDefault({ kind: 'expression', expression: 'hello' })).toEqual({ + comment: '// Raw default: hello', }); }); it('unrecognized function becomes comment', () => { - expect(mapDefault({ kind: 'function', expression: 'custom_func()' })).toEqual({ + expect(mapDefault({ kind: 'expression', expression: 'custom_func()' })).toEqual({ comment: '// Raw default: custom_func()', }); }); it('treats Postgres-specific functions as raw defaults without injected mapping', () => { - expect(mapDefault({ kind: 'function', expression: 'gen_random_uuid()' })).toEqual({ + expect(mapDefault({ kind: 'expression', expression: 'gen_random_uuid()' })).toEqual({ comment: '// Raw default: gen_random_uuid()', }); }); - it('maps null literal', () => { - expect(mapDefault({ kind: 'literal', value: null })).toEqual({ - attribute: '@default(null)', - }); - }); - - it('maps large number literal', () => { - expect(mapDefault({ kind: 'literal', value: 9007199254740991 })).toEqual({ - attribute: '@default(9007199254740991)', + it('maps NULL expression', () => { + expect(mapDefault({ kind: 'expression', expression: 'NULL' })).toEqual({ + comment: '// Raw default: NULL', }); }); - it('stringifies unsupported literal defaults', () => { - expect(mapDefault({ kind: 'literal', value: { nested: ['value'] } })).toEqual({ - attribute: '@default("{\\"nested\\":[\\"value\\"]}")', + it('maps large number expression', () => { + expect(mapDefault({ kind: 'expression', expression: '9007199254740991' })).toEqual({ + comment: '// Raw default: 9007199254740991', }); }); }); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts index 68a24000d6..3ab2e21e61 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/print-psl/print-psl.defaults-and-types.test.ts @@ -18,31 +18,31 @@ describe('printPsl', () => { name: 'id', nativeType: 'int4', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' } as unknown as string, + default: { kind: 'autoincrement' } as unknown as string, }, title: { name: 'title', nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'Untitled' } as unknown as string, + default: { kind: 'expression', expression: 'Untitled' } as unknown as string, }, is_published: { name: 'is_published', nativeType: 'bool', nullable: false, - default: { kind: 'literal', value: false } as unknown as string, + default: { kind: 'expression', expression: 'false' } as unknown as string, }, view_count: { name: 'view_count', nativeType: 'int4', nullable: false, - default: { kind: 'literal', value: 0 } as unknown as string, + default: { kind: 'expression', expression: '0' } as unknown as string, }, created_at: { name: 'created_at', nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' } as unknown as string, + default: { kind: 'expression', expression: 'now()' } as unknown as string, }, }, primaryKey: { columns: ['id'] }, @@ -58,9 +58,9 @@ describe('printPsl', () => { model Post { id Int @id @default(autoincrement()) - title String @default("Untitled") - isPublished Boolean @default(false) @map("is_published") - viewCount Int @default(0) @map("view_count") + title String @default(dbgenerated("Untitled")) + isPublished Boolean @default(dbgenerated("false")) @map("is_published") + viewCount Int @default(dbgenerated("0")) @map("view_count") createdAt DateTime @default(now()) @map("created_at") @@map("post") @@ -363,7 +363,7 @@ describe('printPsl', () => { name: 'id', nativeType: 'uuid', nullable: false, - default: { kind: 'function', expression: 'gen_random_uuid()' } as unknown as string, + default: { kind: 'expression', expression: 'gen_random_uuid()' } as unknown as string, }, }, primaryKey: { columns: ['id'] }, @@ -462,7 +462,7 @@ describe('printPsl', () => { name: 'computed', nativeType: 'text', nullable: false, - default: { kind: 'function', expression: 'my_custom_func()' } as unknown as string, + default: { kind: 'expression', expression: 'my_custom_func()' } as unknown as string, }, payload: { name: 'payload', @@ -525,7 +525,7 @@ describe('printPsl', () => { "// Contract inferred from the live database schema. Edit as needed, then run \`prisma-next contract emit\`. model Counter { - id BigInt @id @default(9223372036854776000) + id BigInt @id @default(dbgenerated("9223372036854775807")) @@map("counter") } diff --git a/packages/2-sql/9-family/test/psl-contract-infer/raw-default-parser.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/raw-default-parser.test.ts index 2b70f3db60..5feb2f9569 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/raw-default-parser.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/raw-default-parser.test.ts @@ -4,139 +4,138 @@ import { parseRawDefault } from '../../src/core/psl-contract-infer/raw-default-p describe('parseRawDefault', () => { it('recognizes nextval (autoincrement)', () => { expect(parseRawDefault("nextval('user_id_seq'::regclass)")).toEqual({ - kind: 'function', - expression: 'autoincrement()', + kind: 'autoincrement', }); }); it('recognizes now()', () => { - expect(parseRawDefault('now()')).toEqual({ kind: 'function', expression: 'now()' }); + expect(parseRawDefault('now()')).toEqual({ kind: 'expression', expression: 'now()' }); }); it('recognizes CURRENT_TIMESTAMP', () => { expect(parseRawDefault('CURRENT_TIMESTAMP')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); it('recognizes clock_timestamp()', () => { expect(parseRawDefault('clock_timestamp()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }); }); it('recognizes timestamp-cast now() defaults', () => { expect(parseRawDefault('now()::timestamp')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); expect(parseRawDefault("('now'::text)::timestamp without time zone")).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); it('recognizes timestamp-cast clock_timestamp() defaults', () => { expect(parseRawDefault('clock_timestamp()::timestamp with time zone')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }); }); it('preserves timestamp string literals when they are not canonical time functions', () => { expect(parseRawDefault("'2024-01-01 00:00:00'::timestamp")).toEqual({ - kind: 'literal', - value: '2024-01-01 00:00:00', + kind: 'expression', + expression: '2024-01-01 00:00:00', }); }); it('recognizes gen_random_uuid()', () => { expect(parseRawDefault('gen_random_uuid()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); }); it('recognizes uuid_generate_v4()', () => { expect(parseRawDefault('uuid_generate_v4()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); }); it('recognizes boolean true', () => { - expect(parseRawDefault('true')).toEqual({ kind: 'literal', value: true }); - expect(parseRawDefault('TRUE')).toEqual({ kind: 'literal', value: true }); + expect(parseRawDefault('true')).toEqual({ kind: 'expression', expression: 'true' }); + expect(parseRawDefault('TRUE')).toEqual({ kind: 'expression', expression: 'true' }); }); it('recognizes boolean false', () => { - expect(parseRawDefault('false')).toEqual({ kind: 'literal', value: false }); + expect(parseRawDefault('false')).toEqual({ kind: 'expression', expression: 'false' }); }); it('recognizes NULL literals', () => { - expect(parseRawDefault('NULL::jsonb')).toEqual({ kind: 'literal', value: null }); + expect(parseRawDefault('NULL::jsonb')).toEqual({ kind: 'expression', expression: 'NULL' }); }); it('recognizes integer literals', () => { - expect(parseRawDefault('42')).toEqual({ kind: 'literal', value: 42 }); - expect(parseRawDefault('-1')).toEqual({ kind: 'literal', value: -1 }); + expect(parseRawDefault('42')).toEqual({ kind: 'expression', expression: '42' }); + expect(parseRawDefault('-1')).toEqual({ kind: 'expression', expression: '-1' }); }); it('recognizes decimal literals', () => { - expect(parseRawDefault('3.14')).toEqual({ kind: 'literal', value: 3.14 }); + expect(parseRawDefault('3.14')).toEqual({ kind: 'expression', expression: '3.14' }); }); - it('parses large integer literals as numbers (precision loss expected)', () => { + it('parses large integer literals as expression strings without precision loss', () => { const result = parseRawDefault('9223372036854775807'); expect(result).toEqual({ - kind: 'literal', - value: Number('9223372036854775807'), + kind: 'expression', + expression: '9223372036854775807', }); }); it('recognizes string literals', () => { - expect(parseRawDefault("'hello'")).toEqual({ kind: 'literal', value: 'hello' }); + expect(parseRawDefault("'hello'")).toEqual({ kind: 'expression', expression: 'hello' }); }); it('recognizes string literals with type cast', () => { - expect(parseRawDefault("'hello'::text")).toEqual({ kind: 'literal', value: 'hello' }); + expect(parseRawDefault("'hello'::text")).toEqual({ kind: 'expression', expression: 'hello' }); }); it('preserves jsonb string defaults as raw expressions when native type context matters', () => { expect(parseRawDefault("'{}'::jsonb", 'jsonb')).toEqual({ - kind: 'function', + kind: 'expression', expression: "'{}'::jsonb", }); }); it('parses inline json literals when no cast is present', () => { expect(parseRawDefault('\'{"enabled":true}\'', 'json')).toEqual({ - kind: 'literal', - value: { enabled: true }, + kind: 'expression', + expression: '{"enabled":true}', }); }); it('falls back to string literals when inline json parsing fails', () => { expect(parseRawDefault("'not-json'", 'jsonb')).toEqual({ - kind: 'literal', - value: 'not-json', + kind: 'expression', + expression: 'not-json', }); }); it('unescapes single quotes in strings', () => { - expect(parseRawDefault("'it''s'")).toEqual({ kind: 'literal', value: "it's" }); + expect(parseRawDefault("'it''s'")).toEqual({ kind: 'expression', expression: "it's" }); }); it('returns unrecognized function expressions as-is', () => { expect(parseRawDefault('my_func()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'my_func()', }); }); it('trims whitespace', () => { - expect(parseRawDefault(' true ')).toEqual({ kind: 'literal', value: true }); + expect(parseRawDefault(' true ')).toEqual({ kind: 'expression', expression: 'true' }); }); }); diff --git a/packages/2-sql/9-family/test/schema-verify.defaults.test.ts b/packages/2-sql/9-family/test/schema-verify.defaults.test.ts index 3ca7ad4b60..464c715e67 100644 --- a/packages/2-sql/9-family/test/schema-verify.defaults.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.defaults.test.ts @@ -1,5 +1,5 @@ -import { type ColumnDefault, type Contract, executionHash } from '@prisma-next/contract/types'; -import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { type Contract, executionHash } from '@prisma-next/contract/types'; +import type { ColumnDefault, SqlStorage } from '@prisma-next/sql-contract/types'; import { describe, expect, it } from 'vitest'; import type { DefaultNormalizer } from '../src/core/schema-verify/verify-sql-schema'; import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; @@ -22,35 +22,35 @@ const testNormalizer: DefaultNormalizer = (rawDefault: string): ColumnDefault | const nowPattern = /^(now\s*\(\s*\)|CURRENT_TIMESTAMP)$/i; const clockPattern = /^clock_timestamp\s*\(\s*\)$/i; const timestampCastSuffix = /::timestamp(?:tz|\s+(?:with|without)\s+time\s+zone)?$/i; - if (nowPattern.test(trimmed)) return { kind: 'function', expression: 'now()' }; - if (clockPattern.test(trimmed)) return { kind: 'function', expression: 'clock_timestamp()' }; + if (nowPattern.test(trimmed)) return { kind: 'expression', expression: 'now()' }; + if (clockPattern.test(trimmed)) return { kind: 'expression', expression: 'clock_timestamp()' }; if (timestampCastSuffix.test(trimmed)) { let inner = trimmed.replace(timestampCastSuffix, '').trim(); if (inner.startsWith('(') && inner.endsWith(')')) { inner = inner.slice(1, -1).trim(); } - if (nowPattern.test(inner)) return { kind: 'function', expression: 'now()' }; - if (clockPattern.test(inner)) return { kind: 'function', expression: 'clock_timestamp()' }; + if (nowPattern.test(inner)) return { kind: 'expression', expression: 'now()' }; + if (clockPattern.test(inner)) return { kind: 'expression', expression: 'clock_timestamp()' }; inner = inner.replace(/::text$/i, '').trim(); - if (/^'now'$/i.test(inner)) return { kind: 'function', expression: 'now()' }; + if (/^'now'$/i.test(inner)) return { kind: 'expression', expression: 'now()' }; } // NULL or NULL::type if (/^NULL(?:::(?:"[^"]+"|[\w\s]+)(?:\(\d+(?:,\d+)?\))?)?$/i.test(trimmed)) { - return { kind: 'literal', value: null }; + return { kind: 'expression', expression: 'NULL' }; } // Boolean literals if (/^true$/i.test(trimmed)) { - return { kind: 'literal', value: true }; + return { kind: 'expression', expression: 'true' }; } if (/^false$/i.test(trimmed)) { - return { kind: 'literal', value: false }; + return { kind: 'expression', expression: 'false' }; } // Numeric literals if (/^-?\d+(\.\d+)?$/.test(trimmed)) { - return { kind: 'literal', value: Number(trimmed) }; + return { kind: 'expression', expression: trimmed }; } // String literals: 'value'::type or just 'value' @@ -58,15 +58,11 @@ const testNormalizer: DefaultNormalizer = (rawDefault: string): ColumnDefault | const stringMatch = trimmed.match(/^'((?:[^']|'')*)'(?:::(?:"[^"]+"|[\w\s]+)(?:\(\d+\))?)?$/); if (stringMatch?.[1] !== undefined) { const unescaped = stringMatch[1].replace(/''/g, "'"); - try { - return { kind: 'literal', value: JSON.parse(unescaped) }; - } catch { - return { kind: 'literal', value: unescaped }; - } + return { kind: 'expression', expression: unescaped }; } // Fallback - return { kind: 'function', expression: trimmed }; + return { kind: 'expression', expression: trimmed }; }; describe('verifySqlSchema - defaults', () => { @@ -76,7 +72,7 @@ describe('verifySqlSchema - defaults', () => { id: { nativeType: 'int4', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }), }); @@ -112,7 +108,7 @@ describe('verifySqlSchema - defaults', () => { status: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'draft' }, + default: { kind: 'expression', expression: 'draft' }, }, }), }); @@ -153,12 +149,12 @@ describe('verifySqlSchema - defaults', () => { created_at: { nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, label: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'draft' }, + default: { kind: 'expression', expression: 'draft' }, }, }), }); @@ -193,47 +189,13 @@ describe('verifySqlSchema - defaults', () => { expect(result.schema.issues).toHaveLength(0); }); - it('treats JSON defaults as equal when schema normalizer returns string literals', () => { - const contract = createTestContract({ - literal_defaults: createContractTable({ - metadata: { - nativeType: 'jsonb', - nullable: false, - default: { kind: 'literal', value: { key: 'default' } }, - }, - }), - }); - - const schema = createTestSchemaIR({ - literal_defaults: createSchemaTable('literal_defaults', { - metadata: { - nativeType: 'jsonb', - nullable: false, - default: '\'{"key": "default"}\'::jsonb', - }, - }), - }); - - const result = verifySqlSchema({ - contract, - schema, - strict: false, - typeMetadataRegistry: emptyTypeMetadataRegistry, - frameworkComponents: [], - normalizeDefault: testNormalizer, - }); - - expect(result.ok).toBe(true); - expect(result.schema.issues).toHaveLength(0); - }); - - it('matches JSONB default with different key order (stable key sort)', () => { + it('treats JSON defaults as equal when schema normalizer returns matching expression', () => { const contract = createTestContract({ literal_defaults: createContractTable({ metadata: { nativeType: 'jsonb', nullable: false, - default: { kind: 'literal', value: { alpha: 1, beta: 2, gamma: 3 } }, + default: { kind: 'expression', expression: '{"key":"default"}' }, }, }), }); @@ -243,8 +205,7 @@ describe('verifySqlSchema - defaults', () => { metadata: { nativeType: 'jsonb', nullable: false, - // Postgres may canonicalize key order differently - default: '\'{"gamma":3,"alpha":1,"beta":2}\'::jsonb', + default: '\'{"key":"default"}\'::jsonb', }, }), }); @@ -268,7 +229,7 @@ describe('verifySqlSchema - defaults', () => { payload: { nativeType: 'jsonb', nullable: false, - default: { kind: 'literal', value: { $type: 'bigint', value: '42' } }, + default: { kind: 'expression', expression: '{"$type":"bigint","value":"42"}' }, }, }), }); @@ -303,7 +264,7 @@ describe('verifySqlSchema - defaults', () => { nativeType: 'text', nullable: false, // Contract default value - default: { kind: 'literal', value: 'draft' }, + default: { kind: 'expression', expression: 'draft' }, }, }), }); @@ -384,7 +345,7 @@ describe('verifySqlSchema - timestamp default normalization', () => { created_at: { nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }), }); @@ -418,7 +379,7 @@ describe('verifySqlSchema - timestamp default normalization', () => { created_at: { nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }), }); @@ -454,7 +415,7 @@ describe('verifySqlSchema - null default normalization', () => { value: { nativeType: 'text', nullable: true, - default: { kind: 'literal', value: null }, + default: { kind: 'expression', expression: 'NULL' }, }, }), }); @@ -490,7 +451,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { provisionStatus: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'ready' }, + default: { kind: 'expression', expression: 'ready' }, }, }), }); @@ -524,7 +485,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { role: { nativeType: 'character varying', nullable: false, - default: { kind: 'literal', value: 'member' }, + default: { kind: 'expression', expression: 'member' }, }, }), }); @@ -558,7 +519,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { kind: { nativeType: 'EnvironmentModelKind', nullable: false, - default: { kind: 'literal', value: 'production' }, + default: { kind: 'expression', expression: 'production' }, }, }), }); @@ -592,7 +553,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { title: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: "it's a default" }, + default: { kind: 'expression', expression: "it's a default" }, }, }), }); @@ -626,7 +587,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { allowRemoteDatabases: { nativeType: 'bool', nullable: false, - default: { kind: 'literal', value: true }, + default: { kind: 'expression', expression: 'true' }, }, }), }); @@ -660,7 +621,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { status: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: 'active' }, }, }), }); @@ -700,7 +661,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { value: { nativeType: 'text', nullable: false, - default: { kind: 'literal', value: '' }, + default: { kind: 'expression', expression: '' }, }, }), }); @@ -734,7 +695,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { billingState: { nativeType: 'BillingState', nullable: false, - default: { kind: 'literal', value: 'atRisk' }, + default: { kind: 'expression', expression: 'atRisk' }, }, }), }); @@ -768,7 +729,7 @@ describe('verifySqlSchema - string literal defaults with type casts', () => { billingState: { nativeType: 'BillingState', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: 'active' }, }, }), }); diff --git a/packages/2-sql/9-family/test/schema-verify.helpers.ts b/packages/2-sql/9-family/test/schema-verify.helpers.ts index 5929e92c86..c1eb920c60 100644 --- a/packages/2-sql/9-family/test/schema-verify.helpers.ts +++ b/packages/2-sql/9-family/test/schema-verify.helpers.ts @@ -2,14 +2,10 @@ * Shared test helpers for schema verification tests. */ -import { - type ColumnDefault, - type Contract, - profileHash, - type StorageHashBase, -} from '@prisma-next/contract/types'; +import { type Contract, profileHash, type StorageHashBase } from '@prisma-next/contract/types'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; import { applyFkDefaults, type ReferentialAction, From 4fcf0bad7eb7bbf86d9f84725474d405be9ebef8 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 20:52:50 +0200 Subject: [PATCH 20/50] test(family-sql): pin PSL printer round-trip across new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add psl-printer-round-trip.test.ts covering crash-resistance for: - { kind: 'autoincrement' } → @default(autoincrement()) → re-parses - { kind: 'expression', expression: 'now()' } → @default(now()) → re-parses - { kind: 'expression', expression: 'TRUE' } → dbgenerated wrapping → re-parses - { kind: 'expression', expression: 'gen_random_uuid()' } → dbgenerated → re-parses Round-trip is lossy by design (spec non-goal); only crash-resistance is asserted via parsePslDocument returning zero error-severity diagnostics. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../psl-printer-round-trip.test.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts diff --git a/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts new file mode 100644 index 0000000000..4481b8560b --- /dev/null +++ b/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts @@ -0,0 +1,122 @@ +import { parsePslDocument } from '@prisma-next/psl-parser'; +import { printPsl } from '@prisma-next/psl-printer'; +import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import { describe, expect, it } from 'vitest'; +import { sqlSchemaIrToPslAst } from '../../src/core/psl-contract-infer/sql-schema-ir-to-psl-ast'; + +function roundTrip(schemaIR: SqlSchemaIR): string { + return printPsl(sqlSchemaIrToPslAst(schemaIR)); +} + +function assertParsesSilently(pslText: string): void { + const result = parsePslDocument({ schema: pslText, sourceId: 'round-trip.psl' }); + expect(result.diagnostics.filter((d) => d.severity === 'error')).toHaveLength(0); +} + +describe('PSL printer round-trip across new ColumnDefault union', () => { + it('autoincrement default prints and re-parses without error', () => { + const schemaIR: SqlSchemaIR = { + tables: { + item: { + name: 'item', + columns: { + id: { + name: 'id', + nativeType: 'int4', + nullable: false, + default: { kind: 'autoincrement' } as unknown as string, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }; + + const printed = roundTrip(schemaIR); + expect(printed).toContain('@default(autoincrement())'); + assertParsesSilently(printed); + }); + + it('now() expression default prints and re-parses without error', () => { + const schemaIR: SqlSchemaIR = { + tables: { + event: { + name: 'event', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + created_at: { + name: 'created_at', + nativeType: 'timestamptz', + nullable: false, + default: { kind: 'expression', expression: 'now()' } as unknown as string, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }; + + const printed = roundTrip(schemaIR); + expect(printed).toContain('@default(now())'); + assertParsesSilently(printed); + }); + + it('raw expression default prints via dbgenerated and re-parses without error', () => { + const schemaIR: SqlSchemaIR = { + tables: { + feature: { + name: 'feature', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + enabled: { + name: 'enabled', + nativeType: 'bool', + nullable: false, + default: { kind: 'expression', expression: 'TRUE' } as unknown as string, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }; + + const printed = roundTrip(schemaIR); + expect(printed).toContain('dbgenerated'); + assertParsesSilently(printed); + }); + + it('gen_random_uuid() expression default prints and re-parses without error', () => { + const schemaIR: SqlSchemaIR = { + tables: { + token: { + name: 'token', + columns: { + id: { + name: 'id', + nativeType: 'uuid', + nullable: false, + default: { kind: 'expression', expression: 'gen_random_uuid()' } as unknown as string, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }; + + const printed = roundTrip(schemaIR); + expect(printed).toContain('dbgenerated'); + assertParsesSilently(printed); + }); +}); From 8b683686994262caad742969e48e0275cc3d29c7 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 20:53:35 +0200 Subject: [PATCH 21/50] fix(family-sql): use result.ok for PSL parse assertion in round-trip test PslDiagnostic does not carry a severity field; parsePslDocument.ok is the correct field for checking parse success. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test/psl-contract-infer/psl-printer-round-trip.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts index 4481b8560b..fc16857696 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/psl-printer-round-trip.test.ts @@ -10,7 +10,7 @@ function roundTrip(schemaIR: SqlSchemaIR): string { function assertParsesSilently(pslText: string): void { const result = parsePslDocument({ schema: pslText, sourceId: 'round-trip.psl' }); - expect(result.diagnostics.filter((d) => d.severity === 'error')).toHaveLength(0); + expect(result.ok).toBe(true); } describe('PSL printer round-trip across new ColumnDefault union', () => { From 568a53e54e80ee0d8e83964a628807d5b74d9739 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 21:19:57 +0200 Subject: [PATCH 22/50] refactor(target-postgres)!: switch buildColumnDefaultSql to new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete renderDefaultLiteral and assertSafeDefaultExpression helpers - buildColumnDefaultSql now switches on kind: expression | autoincrement | sequence (sequence is the postgres-specific extension) - buildColumnTypeSql: check kind === 'autoincrement' (was expression === 'autoincrement()') - default-normalizer: return new ColumnDefault union shape; nextval → autoincrement, everything else → expression with raw passthrough - postgresRenderDefault in control.ts: switch on new union, remove renderDefaultLiteral dep - Fix all internal call sites (removed second column arg from buildColumnDefaultSql) - Import ColumnDefault from @prisma-next/sql-contract/types throughout Co-Authored-By: Claude Opus 4.7 (1M context) --- .../postgres/src/core/default-normalizer.ts | 61 +++---------------- .../src/core/migrations/issue-planner.ts | 6 +- .../core/migrations/planner-ddl-builders.ts | 58 +++--------------- .../src/core/migrations/planner-strategies.ts | 2 +- .../3-targets/postgres/src/core/types.ts | 2 +- .../3-targets/postgres/src/exports/control.ts | 15 ++--- .../src/exports/planner-ddl-builders.ts | 1 - 7 files changed, 27 insertions(+), 118 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts index 29634cf672..41c9ec7169 100644 --- a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; /** * Pre-compiled regex patterns for performance. @@ -12,11 +12,6 @@ const TEXT_CAST_SUFFIX = /::text$/i; const NOW_LITERAL_PATTERN = /^'now'$/i; const UUID_PATTERN = /^gen_random_uuid\s*\(\s*\)$/i; const UUID_OSSP_PATTERN = /^uuid_generate_v4\s*\(\s*\)$/i; -const NULL_PATTERN = /^NULL(?:::.+)?$/i; -const TRUE_PATTERN = /^true$/i; -const FALSE_PATTERN = /^false$/i; -const NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/; -const STRING_LITERAL_PATTERN = /^'((?:[^']|'')*)'(?:::(?:"[^"]+"|[\w\s]+)(?:\(\d+\))?)?$/; /** * Returns the canonical expression for a timestamp default function, or undefined @@ -64,68 +59,26 @@ function canonicalizeTimestampDefault(expr: string): string | undefined { */ export function parsePostgresDefault( rawDefault: string, - nativeType?: string, + _nativeType?: string, ): ColumnDefault | undefined { const trimmed = rawDefault.trim(); - const normalizedType = nativeType?.toLowerCase(); - const isBigInt = normalizedType === 'bigint' || normalizedType === 'int8'; if (NEXTVAL_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'autoincrement()' }; + return { kind: 'autoincrement' }; } const canonicalTimestamp = canonicalizeTimestampDefault(trimmed); if (canonicalTimestamp) { - return { kind: 'function', expression: canonicalTimestamp }; + return { kind: 'expression', expression: canonicalTimestamp }; } if (UUID_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'gen_random_uuid()' }; + return { kind: 'expression', expression: 'gen_random_uuid()' }; } if (UUID_OSSP_PATTERN.test(trimmed)) { - return { kind: 'function', expression: 'gen_random_uuid()' }; + return { kind: 'expression', expression: 'gen_random_uuid()' }; } - if (NULL_PATTERN.test(trimmed)) { - return { kind: 'literal', value: null }; - } - - if (TRUE_PATTERN.test(trimmed)) { - return { kind: 'literal', value: true }; - } - if (FALSE_PATTERN.test(trimmed)) { - return { kind: 'literal', value: false }; - } - - if (NUMERIC_PATTERN.test(trimmed)) { - const num = Number(trimmed); - if (!Number.isFinite(num)) return undefined; - if (isBigInt && !Number.isSafeInteger(num)) { - return { kind: 'literal', value: trimmed }; - } - return { kind: 'literal', value: num }; - } - - const stringMatch = trimmed.match(STRING_LITERAL_PATTERN); - if (stringMatch?.[1] !== undefined) { - const unescaped = stringMatch[1].replace(/''/g, "'"); - if (normalizedType === 'json' || normalizedType === 'jsonb') { - try { - return { kind: 'literal', value: JSON.parse(unescaped) }; - } catch { - // Keep legacy behavior for malformed/non-JSON string content. - } - } - if (isBigInt && NUMERIC_PATTERN.test(unescaped)) { - const num = Number(unescaped); - if (Number.isSafeInteger(num)) { - return { kind: 'literal', value: num }; - } - return { kind: 'literal', value: unescaped }; - } - return { kind: 'literal', value: unescaped }; - } - - return { kind: 'function', expression: trimmed }; + return { kind: 'expression', expression: trimmed }; } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 9a300211c4..6bfa240c28 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -191,7 +191,7 @@ function toColumnSpec( codecHooks as Map, storageTypes as Record, ), - defaultSql: buildColumnDefaultSql(column.default, column), + defaultSql: buildColumnDefaultSql(column.default), nullable: column.nullable, }; } @@ -337,7 +337,7 @@ function mapIssueToCall( ), ); } - const defaultSql = buildColumnDefaultSql(column.default, column); + const defaultSql = buildColumnDefaultSql(column.default); if (!defaultSql) return ok([]); return ok([new SetDefaultCall(tableSchema(issue), issue.table, issue.column, defaultSql)]); } @@ -473,7 +473,7 @@ function mapIssueToCall( issue.column ]; if (!column?.default) return ok([]); - const defaultSql = buildColumnDefaultSql(column.default, column); + const defaultSql = buildColumnDefaultSql(column.default); if (!defaultSql) return ok([]); return ok([ new SetDefaultCall(tableSchema(issue), issue.table, issue.column, defaultSql, 'widening'), diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts index 12a1b883aa..089cbd1c40 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts @@ -23,7 +23,7 @@ export function buildCreateTableSql( const parts = [ quoteIdentifier(columnName), buildColumnTypeSql(column, codecHooks, storageTypes), - buildColumnDefaultSql(column.default, column), + buildColumnDefaultSql(column.default), column.nullable ? '' : 'NOT NULL', ].filter(Boolean); return parts.join(' '); @@ -57,20 +57,6 @@ function assertSafeNativeType(nativeType: string): void { } } -/** - * Sanity check against accidental SQL injection from malformed contract files. - * Rejects semicolons, SQL comment tokens, and dollar-quoting. - * Not a comprehensive security boundary — the contract is developer-authored. - */ -function assertSafeDefaultExpression(expression: string): void { - if (expression.includes(';') || /--|\/\*|\$\$|\bSELECT\b/i.test(expression)) { - throw new Error( - `Unsafe default expression in contract: "${expression}". ` + - 'Default expressions must not contain semicolons, SQL comment tokens, dollar-quoting, or subqueries.', - ); - } -} - /** * Renders the SQL type for a column in DDL context. * @@ -88,7 +74,7 @@ export function buildColumnTypeSql( if (allowPseudoTypes) { const columnDefault = column.default; - if (columnDefault?.kind === 'function' && columnDefault.expression === 'autoincrement()') { + if (columnDefault?.kind === 'autoincrement') { if (resolved.nativeType === 'int4' || resolved.nativeType === 'integer') { return 'SERIAL'; } @@ -151,51 +137,21 @@ function expandParameterizedTypeSql( } /** Autoincrement columns use SERIAL types, so this returns empty for them. */ -export function buildColumnDefaultSql( - columnDefault: PostgresColumnDefault | undefined, - column?: StorageColumn, -): string { +export function buildColumnDefaultSql(columnDefault: PostgresColumnDefault | undefined): string { if (!columnDefault) { return ''; } switch (columnDefault.kind) { - case 'literal': - return `DEFAULT ${renderDefaultLiteral(columnDefault.value, column)}`; - case 'function': { - if (columnDefault.expression === 'autoincrement()') { - return ''; - } - assertSafeDefaultExpression(columnDefault.expression); + case 'autoincrement': + return ''; + case 'expression': return `DEFAULT (${columnDefault.expression})`; - } case 'sequence': return `DEFAULT nextval('${escapeLiteral(quoteIdentifier(columnDefault.name))}'::regclass)`; } } -export function renderDefaultLiteral(value: unknown, column?: StorageColumn): string { - const isJsonColumn = column?.nativeType === 'json' || column?.nativeType === 'jsonb'; - - if (value instanceof Date) { - return `'${escapeLiteral(value.toISOString())}'`; - } - if (typeof value === 'string') { - return `'${escapeLiteral(value)}'`; - } - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - if (value === null) { - return 'NULL'; - } - const json = JSON.stringify(value); - if (isJsonColumn) { - return `'${escapeLiteral(json)}'::${column.nativeType}`; - } - return `'${escapeLiteral(json)}'`; -} - export function buildAddColumnSql( qualifiedTableName: string, columnName: string, @@ -206,7 +162,7 @@ export function buildAddColumnSql( ): string { const typeSql = buildColumnTypeSql(column, codecHooks, storageTypes); const defaultSql = - buildColumnDefaultSql(column.default, column) || + buildColumnDefaultSql(column.default) || (temporaryDefault ? `DEFAULT ${temporaryDefault}` : ''); const parts = [ `ALTER TABLE ${qualifiedTableName}`, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts index dc9e91748e..c8258a164b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner-strategies.ts @@ -207,7 +207,7 @@ function buildColumnSpec( return { name: column, typeSql: buildColumnTypeSql(col, mutableHooks, mutableTypes), - defaultSql: buildColumnDefaultSql(col.default, col), + defaultSql: buildColumnDefaultSql(col.default), nullable: overrides?.nullable ?? col.nullable, }; } diff --git a/packages/3-targets/3-targets/postgres/src/core/types.ts b/packages/3-targets/3-targets/postgres/src/core/types.ts index 7357c78190..0b75e5b44f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/types.ts +++ b/packages/3-targets/3-targets/postgres/src/core/types.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault } from '@prisma-next/contract/types'; +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; export type PostgresColumnDefault = | ColumnDefault diff --git a/packages/3-targets/3-targets/postgres/src/exports/control.ts b/packages/3-targets/3-targets/postgres/src/exports/control.ts index a96bd7b45c..0da368aaee 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/control.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/control.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import type { SqlControlFamilyInstance, SqlControlTargetDescriptor, @@ -9,11 +9,10 @@ import type { ControlTargetInstance, MigrationRunner, } from '@prisma-next/framework-components/control'; -import type { SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types'; +import type { ColumnDefault, SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types'; import { ifDefined } from '@prisma-next/utils/defined'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; import { createPostgresMigrationPlanner } from '../core/migrations/planner'; -import { renderDefaultLiteral } from '../core/migrations/planner-ddl-builders'; import type { PostgresPlanTargetDetails } from '../core/migrations/planner-target-details'; import { createPostgresMigrationRunner } from '../core/migrations/runner'; import { PostgresContractSerializer } from '../core/postgres-contract-serializer'; @@ -45,11 +44,13 @@ function buildNativeTypeExpander( }; } -export function postgresRenderDefault(def: ColumnDefault, column: StorageColumn): string { - if (def.kind === 'function') { - return def.expression; +export function postgresRenderDefault(def: ColumnDefault, _column: StorageColumn): string { + switch (def.kind) { + case 'autoincrement': + return 'autoincrement()'; + case 'expression': + return def.expression; } - return renderDefaultLiteral(def.value, column); } const postgresTargetDescriptor: SqlControlTargetDescriptor<'postgres', PostgresPlanTargetDetails> = diff --git a/packages/3-targets/3-targets/postgres/src/exports/planner-ddl-builders.ts b/packages/3-targets/3-targets/postgres/src/exports/planner-ddl-builders.ts index 82eddca21d..aad7070438 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/planner-ddl-builders.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/planner-ddl-builders.ts @@ -4,5 +4,4 @@ export { buildColumnTypeSql, buildCreateTableSql, buildForeignKeySql, - renderDefaultLiteral, } from '../core/migrations/planner-ddl-builders'; From ae82401900cb9358031ff13ae811c9c3fcb45e86 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 21:20:14 +0200 Subject: [PATCH 23/50] test(adapter-postgres): update fixtures for new ColumnDefault union (D3b gate scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - planner-ddl-builders.test.ts: autoincrement uses kind:'autoincrement', expression arm wraps in DEFAULT (...), remove renderDefaultLiteral block - planner.reconciliation.integration.test.ts: all literal/function fixtures converted to kind:'expression' with Postgres canonical expressions; timestamptz fixture uses PG canonical form so columnDefaultsEqual comparison succeeds - schema-verify.after-runner.integration.test.ts: fix D3b incorrect fixture from kind:'expression'/expression:'autoincrement()' to kind:'autoincrement' - control-adapter.defaults.test.ts: update all parsePostgresDefault expectations to new union shape (nextval → autoincrement, rest → expression with raw passthrough) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test/control-adapter.defaults.test.ts | 221 +++++++++--------- .../migrations/planner-ddl-builders.test.ts | 73 ++---- ...planner.reconciliation.integration.test.ts | 23 +- ...ma-verify.after-runner.integration.test.ts | 2 +- 4 files changed, 142 insertions(+), 177 deletions(-) diff --git a/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts index b31bfdbe2c..d9c8f8d1cf 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-adapter.defaults.test.ts @@ -235,149 +235,148 @@ describe('parsePostgresDefault normalizer', () => { it('normalizes common default expressions', () => { // Autoincrement patterns expect(parsePostgresDefault("nextval('user_id_seq'::regclass)")).toEqual({ - kind: 'function', - expression: 'autoincrement()', + kind: 'autoincrement', }); // Timestamp functions expect(parsePostgresDefault('now()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); expect(parsePostgresDefault('CURRENT_TIMESTAMP')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); // clock_timestamp() is distinct from now() — returns wall-clock time, not transaction time expect(parsePostgresDefault('clock_timestamp()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }); // UUID function expect(parsePostgresDefault('gen_random_uuid()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); - // Boolean literals + // Boolean literals (returned as raw expression strings) expect(parsePostgresDefault('true')).toEqual({ - kind: 'literal', - value: true, + kind: 'expression', + expression: 'true', }); expect(parsePostgresDefault('false')).toEqual({ - kind: 'literal', - value: false, + kind: 'expression', + expression: 'false', }); - // Numeric literals + // Numeric literals (returned as raw expression strings) expect(parsePostgresDefault('42')).toEqual({ - kind: 'literal', - value: 42, + kind: 'expression', + expression: '42', }); expect(parsePostgresDefault('3.14')).toEqual({ - kind: 'literal', - value: 3.14, + kind: 'expression', + expression: '3.14', }); expect(parsePostgresDefault('-123.45')).toEqual({ - kind: 'literal', - value: -123.45, + kind: 'expression', + expression: '-123.45', }); - // String literals (type casts are stripped) + // String literals (returned with casts preserved) expect(parsePostgresDefault("'hello'::text")).toEqual({ - kind: 'literal', - value: 'hello', + kind: 'expression', + expression: "'hello'::text", }); expect(parsePostgresDefault('\'ok\'::"BillingState"')).toEqual({ - kind: 'literal', - value: 'ok', + kind: 'expression', + expression: '\'ok\'::"BillingState"', }); expect(parsePostgresDefault("'Hello''s'::text")).toEqual({ - kind: 'literal', - value: "Hello's", + kind: 'expression', + expression: "'Hello''s'::text", }); expect(parsePostgresDefault("'plain text'")).toEqual({ - kind: 'literal', - value: 'plain text', + kind: 'expression', + expression: "'plain text'", }); // uuid_generate_v4() from uuid-ossp extension is normalized to gen_random_uuid() expect(parsePostgresDefault('uuid_generate_v4()')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); }); }); -describe('parsePostgresDefault strips type casts from string literals', () => { - it('strips ::text cast from simple string literal', () => { +describe('parsePostgresDefault preserves string literals with casts', () => { + it('preserves ::text cast on simple string literal', () => { expect(parsePostgresDefault("'ready'::text")).toEqual({ - kind: 'literal', - value: 'ready', + kind: 'expression', + expression: "'ready'::text", }); }); - it('strips ::character varying cast', () => { + it('preserves ::character varying cast', () => { expect(parsePostgresDefault("'hello'::character varying")).toEqual({ - kind: 'literal', - value: 'hello', + kind: 'expression', + expression: "'hello'::character varying", }); }); - it('strips ::character varying(255) cast with length', () => { + it('preserves ::character varying(255) cast with length', () => { expect(parsePostgresDefault("'hello'::character varying(255)")).toEqual({ - kind: 'literal', - value: 'hello', + kind: 'expression', + expression: "'hello'::character varying(255)", }); }); - it('strips quoted enum cast like ::"MyEnum"', () => { + it('preserves quoted enum cast like ::"MyEnum"', () => { expect(parsePostgresDefault('\'active\'::"StatusEnum"')).toEqual({ - kind: 'literal', - value: 'active', + kind: 'expression', + expression: '\'active\'::"StatusEnum"', }); }); - it('strips quoted camelCase enum cast like ::"EnvironmentModelKind"', () => { + it('preserves quoted camelCase enum cast like ::"EnvironmentModelKind"', () => { expect(parsePostgresDefault('\'ready\'::"EnvironmentModelKind"')).toEqual({ - kind: 'literal', - value: 'ready', + kind: 'expression', + expression: '\'ready\'::"EnvironmentModelKind"', }); }); it('preserves plain string literal without cast', () => { expect(parsePostgresDefault("'plain text'")).toEqual({ - kind: 'literal', - value: 'plain text', + kind: 'expression', + expression: "'plain text'", }); }); - it('strips cast from string with escaped quotes', () => { + it('preserves string with escaped quotes', () => { expect(parsePostgresDefault("'it''s ready'::text")).toEqual({ - kind: 'literal', - value: "it's ready", + kind: 'expression', + expression: "'it''s ready'::text", }); }); - it('strips ::varchar cast', () => { + it('preserves ::varchar cast', () => { expect(parsePostgresDefault("'default_value'::varchar")).toEqual({ - kind: 'literal', - value: 'default_value', + kind: 'expression', + expression: "'default_value'::varchar", }); }); - it('strips ::bpchar cast (blank-padded char)', () => { + it('preserves ::bpchar cast (blank-padded char)', () => { expect(parsePostgresDefault("'Y'::bpchar")).toEqual({ - kind: 'literal', - value: 'Y', + kind: 'expression', + expression: "'Y'::bpchar", }); }); - it('strips cast from empty string literal', () => { + it('preserves empty string literal with cast', () => { expect(parsePostgresDefault("''::text")).toEqual({ - kind: 'literal', - value: '', + kind: 'expression', + expression: "''::text", }); }); }); @@ -396,7 +395,7 @@ describe('parsePostgresDefault normalizes cast-wrapped timestamp defaults', () = { input: 'current_timestamp' }, ])('normalizes "$input" to now()', ({ input }) => { expect(parsePostgresDefault(input)).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); @@ -407,7 +406,7 @@ describe('parsePostgresDefault normalizes cast-wrapped timestamp defaults', () = { input: '(clock_timestamp())::timestamptz' }, ])('normalizes "$input" to clock_timestamp()', ({ input }) => { expect(parsePostgresDefault(input)).toEqual({ - kind: 'function', + kind: 'expression', expression: 'clock_timestamp()', }); }); @@ -415,16 +414,14 @@ describe('parsePostgresDefault normalizes cast-wrapped timestamp defaults', () = describe('parsePostgresDefault rejects non-timestamp expressions with timestamp casts', () => { it.each([ - { input: 'random()::timestamptz', expectedExpr: 'random()::timestamptz' }, - { input: "'yesterday'::timestamp without time zone", expectedValue: 'yesterday' }, - { input: "'2024-01-01'::timestamp without time zone", expectedValue: '2024-01-01' }, - ])('does not normalize "$input" to now()', ({ input, expectedExpr, expectedValue }) => { - const result = parsePostgresDefault(input); - if (expectedExpr) { - expect(result).toEqual({ kind: 'function', expression: expectedExpr }); - } else { - expect(result).toEqual({ kind: 'literal', value: expectedValue }); - } + { input: 'random()::timestamptz' }, + { input: "'yesterday'::timestamp without time zone" }, + { input: "'2024-01-01'::timestamp without time zone" }, + ])('does not normalize "$input" to now()', ({ input }) => { + expect(parsePostgresDefault(input)).toEqual({ + kind: 'expression', + expression: input, + }); }); }); @@ -438,107 +435,107 @@ describe('parsePostgresDefault normalizes NULL defaults', () => { { input: 'NULL::character varying(255)' }, { input: 'NULL::"MyEnum"' }, { input: 'NULL::jsonb' }, - ])('normalizes "$input" to null literal', ({ input }) => { + ])('normalizes "$input" to expression', ({ input }) => { expect(parsePostgresDefault(input)).toEqual({ - kind: 'literal', - value: null, + kind: 'expression', + expression: input, }); }); }); describe('parsePostgresDefault handles extension type defaults', () => { - it('parses citext string literal with cast', () => { + it('preserves citext string literal with cast', () => { expect(parsePostgresDefault("'hello'::citext", 'citext')).toEqual({ - kind: 'literal', - value: 'hello', + kind: 'expression', + expression: "'hello'::citext", }); }); - it('parses ltree string literal with cast', () => { + it('preserves ltree string literal with cast', () => { expect(parsePostgresDefault("'root.child'::ltree", 'ltree')).toEqual({ - kind: 'literal', - value: 'root.child', + kind: 'expression', + expression: "'root.child'::ltree", }); }); - it('parses gen_random_uuid() for uuid columns', () => { + it('normalizes gen_random_uuid() for uuid columns', () => { expect(parsePostgresDefault('gen_random_uuid()', 'uuid')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'gen_random_uuid()', }); }); - it('parses empty jsonb object default', () => { + it('preserves empty jsonb object default', () => { expect(parsePostgresDefault("'{}'::jsonb", 'jsonb')).toEqual({ - kind: 'literal', - value: {}, + kind: 'expression', + expression: "'{}'::jsonb", }); }); - it('parses empty jsonb array default', () => { + it('preserves empty jsonb array default', () => { expect(parsePostgresDefault("'[]'::jsonb", 'jsonb')).toEqual({ - kind: 'literal', - value: [], + kind: 'expression', + expression: "'[]'::jsonb", }); }); }); -describe('parsePostgresDefault parses JSON literals for json/jsonb columns', () => { - it('parses object literal for jsonb native type', () => { +describe('parsePostgresDefault preserves JSON literals for json/jsonb columns', () => { + it('preserves object literal for jsonb native type', () => { expect(parsePostgresDefault('\'{"key": "default"}\'::jsonb', 'jsonb')).toEqual({ - kind: 'literal', - value: { key: 'default' }, + kind: 'expression', + expression: '\'{"key": "default"}\'::jsonb', }); }); - it('parses array literal for jsonb native type', () => { + it('preserves array literal for jsonb native type', () => { expect(parsePostgresDefault('\'["alpha", "beta"]\'::jsonb', 'jsonb')).toEqual({ - kind: 'literal', - value: ['alpha', 'beta'], + kind: 'expression', + expression: '\'["alpha", "beta"]\'::jsonb', }); }); - it('parses object literal for json native type', () => { + it('preserves object literal for json native type', () => { expect(parsePostgresDefault('\'{"ok": true}\'::json', 'json')).toEqual({ - kind: 'literal', - value: { ok: true }, + kind: 'expression', + expression: '\'{"ok": true}\'::json', }); }); - it('falls back to string when JSON parsing fails', () => { + it('preserves non-JSON string literal', () => { expect(parsePostgresDefault("'not-json'::jsonb", 'jsonb')).toEqual({ - kind: 'literal', - value: 'not-json', + kind: 'expression', + expression: "'not-json'::jsonb", }); }); }); describe('parsePostgresDefault handles bigint defaults', () => { - it('parses bare safe integer for int8 as number', () => { + it('preserves bare integer for int8 as expression', () => { expect(parsePostgresDefault('42', 'int8')).toEqual({ - kind: 'literal', - value: 42, + kind: 'expression', + expression: '42', }); }); - it('parses bare unsafe integer for int8 as string', () => { + it('preserves bare unsafe integer for int8 as expression', () => { expect(parsePostgresDefault('9999999999999999999', 'bigint')).toEqual({ - kind: 'literal', - value: '9999999999999999999', + kind: 'expression', + expression: '9999999999999999999', }); }); - it('parses quoted safe integer for int8 as number', () => { + it('preserves quoted safe integer for int8 as expression', () => { expect(parsePostgresDefault("'42'::bigint", 'bigint')).toEqual({ - kind: 'literal', - value: 42, + kind: 'expression', + expression: "'42'::bigint", }); }); - it('parses quoted unsafe integer for int8 as string', () => { + it('preserves quoted unsafe integer for int8 as expression', () => { expect(parsePostgresDefault("'9999999999999999999'::bigint", 'int8')).toEqual({ - kind: 'literal', - value: '9999999999999999999', + kind: 'expression', + expression: "'9999999999999999999'::bigint", }); }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts index 25c8e992e7..0164ac77a4 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner-ddl-builders.test.ts @@ -7,7 +7,6 @@ import { buildColumnTypeSql, buildCreateTableSql, buildForeignKeySql, - renderDefaultLiteral, } from '@prisma-next/target-postgres/planner-ddl-builders'; import { describe, expect, it } from 'vitest'; @@ -29,7 +28,7 @@ describe('buildColumnTypeSql', () => { it('returns SERIAL for int4 with autoincrement', () => { const column = col({ nativeType: 'int4', - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }); expect(buildColumnTypeSql(column, noHooks)).toBe('SERIAL'); }); @@ -37,7 +36,7 @@ describe('buildColumnTypeSql', () => { it('returns BIGSERIAL for int8 with autoincrement', () => { const column = col({ nativeType: 'int8', - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }); expect(buildColumnTypeSql(column, noHooks)).toBe('BIGSERIAL'); }); @@ -45,7 +44,7 @@ describe('buildColumnTypeSql', () => { it('returns SMALLSERIAL for int2 with autoincrement', () => { const column = col({ nativeType: 'int2', - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }); expect(buildColumnTypeSql(column, noHooks)).toBe('SMALLSERIAL'); }); @@ -89,24 +88,28 @@ describe('buildColumnDefaultSql', () => { expect(buildColumnDefaultSql(undefined)).toBe(''); }); - it('renders literal string default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: 'hello' })).toBe("DEFAULT 'hello'"); + it('renders expression string default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: "'hello'::text" })).toBe( + "DEFAULT ('hello'::text)", + ); }); - it('renders literal number default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: 42 })).toBe('DEFAULT 42'); + it('renders expression number default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: '42' })).toBe('DEFAULT (42)'); }); - it('renders literal boolean default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: true })).toBe('DEFAULT true'); + it('renders expression boolean default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'true' })).toBe( + 'DEFAULT (true)', + ); }); - it('returns empty string for autoincrement function', () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'autoincrement()' })).toBe(''); + it('returns empty string for autoincrement', () => { + expect(buildColumnDefaultSql({ kind: 'autoincrement' })).toBe(''); }); - it('renders non-autoincrement function default', () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'now()' })).toBe( + it('renders expression function default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'now()' })).toBe( 'DEFAULT (now())', ); }); @@ -116,44 +119,6 @@ describe('buildColumnDefaultSql', () => { `DEFAULT nextval('"user_id_seq"'::regclass)`, ); }); - - it('rejects unsafe function expressions', () => { - expect(() => - buildColumnDefaultSql({ kind: 'function', expression: 'now(); DROP TABLE users' }), - ).toThrow('Unsafe default expression'); - }); -}); - -// --------------------------------------------------------------------------- -// renderDefaultLiteral -// --------------------------------------------------------------------------- - -describe('renderDefaultLiteral', () => { - it('renders string', () => { - expect(renderDefaultLiteral('hello')).toBe("'hello'"); - }); - - it('renders number', () => { - expect(renderDefaultLiteral(42)).toBe('42'); - }); - - it('renders boolean', () => { - expect(renderDefaultLiteral(false)).toBe('false'); - }); - - it('renders null', () => { - expect(renderDefaultLiteral(null)).toBe('NULL'); - }); - - it('renders JSON object for jsonb column', () => { - const result = renderDefaultLiteral({ key: 'val' }, col({ nativeType: 'jsonb' })); - expect(result).toBe(`'{"key":"val"}'::jsonb`); - }); - - it('renders JSON object without cast for non-json column', () => { - const result = renderDefaultLiteral({ key: 'val' }); - expect(result).toBe(`'{"key":"val"}'`); - }); }); // --------------------------------------------------------------------------- @@ -195,12 +160,12 @@ describe('buildAddColumnSql', () => { col({ nativeType: 'bool', nullable: false, - default: { kind: 'literal', value: true }, + default: { kind: 'expression', expression: 'true' }, }), noHooks, 'false', ); - expect(sql).toContain('DEFAULT true'); + expect(sql).toContain('DEFAULT (true)'); expect(sql).not.toContain('DEFAULT false'); }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts index b280a7e25e..b4a063ced0 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/planner.reconciliation.integration.test.ts @@ -206,7 +206,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'untitled' }, + default: { kind: 'expression', expression: "'untitled'::text" }, }, }), }, @@ -558,7 +558,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'draft' }, + default: { kind: 'expression', expression: "'draft'::text" }, }, }), }, @@ -574,7 +574,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'::text" }, }, }), }, @@ -610,7 +610,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'::text" }, }, }), }, @@ -626,7 +626,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'int4', codecId: 'pg/int4@1', nullable: false, - default: { kind: 'literal', value: 1 }, + default: { kind: 'expression', expression: '1' }, }, }), }, @@ -680,7 +680,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'unknown' }, + default: { kind: 'expression', expression: "'unknown'::text" }, }, }), }, @@ -930,7 +930,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false, - default: { kind: 'literal', value: '00000000-0000-0000-0000-000000000000' }, + default: { kind: 'expression', expression: "'00000000-0000-0000-0000-000000000000'" }, }, }), }, @@ -946,7 +946,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false, - default: { kind: 'function', expression: 'gen_random_uuid()' }, + default: { kind: 'expression', expression: 'gen_random_uuid()' }, }, }), }, @@ -1096,7 +1096,7 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'::text" }, }, }), }, @@ -1216,7 +1216,10 @@ describe.sequential('PostgresMigrationPlanner - reconciliation integration', () nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', nullable: false, - default: { kind: 'literal', value: '2023-01-01T00:00:00.000Z' }, + default: { + kind: 'expression', + expression: "'2023-01-01 00:00:00+00'::timestamp with time zone", + }, }, }), }, diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts index 8b2f6e26d6..51be6f9cf5 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.after-runner.integration.test.ts @@ -131,7 +131,7 @@ describe.sequential('Schema verification after runner - integration', () => { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false, - default: { kind: 'expression', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }, createdAt: { nativeType: 'timestamptz', From d8c72a3d0e03078165252994937131952d7c7da7 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 21:37:06 +0200 Subject: [PATCH 24/50] feat(sqlite): flip DDL renderer to new ColumnDefault union + INTEGER-PK gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify buildColumnDefaultSql to switch on new union: kind:'autoincrement' returns '' when on INTEGER PRIMARY KEY column; throws diagnostic naming table.column when on any other column. kind:'expression' returns DEFAULT (), with now() → datetime('now') translation preserved. - Delete assertSafeDefaultExpression and renderDefaultLiteral (the contract is developer-authored; analogous to D5 Postgres cleanup). - Add ColumnDefaultContext interface so callers pass tableName + columnName + isIntegerPrimaryKey for the gate diagnostic. - Update toColumnSpec / toTableSpec signatures to thread ColumnDefaultContext; update all three callers (mapIssueToCall missing_table, missing_column, and recreateTableStrategy in planner-strategies.ts). - Update isInlineAutoincrementPrimaryKey to check kind:'autoincrement' instead of kind:'function' expression:'autoincrement()'. - Update control-target.ts sqliteRenderDefault to new union; fix import of ColumnDefault to @prisma-next/sql-contract/types (moved in D1). - Rewrite parseSqliteDefault in default-normalizer.ts to return only kind:'expression' shapes; retain now() canonicalization for CURRENT_TIMESTAMP / datetime('now') spellings; drop literal/numeric/ string-unescaping paths (now handled by columnDefaultsEqual expression comparison, analogous to D5's Postgres normalizer rewrite). - Update planner-ddl-builders.test.ts: remove old literal/function-kind tests; add expression-kind tests covering string, numeric, NULL, random() expressions; add INTEGER-PK gate tests (valid path returns ''; non-PK column throws with table.column path in message). - Update planner-strategies.test.ts: flip kind:'literal' fixture to kind:'expression'. --- .../sqlite/src/core/control-target.ts | 21 ++--- .../sqlite/src/core/default-normalizer.ts | 41 +--------- .../src/core/migrations/issue-planner.ts | 21 ++++- .../core/migrations/planner-ddl-builders.ts | 72 ++++++++--------- .../src/core/migrations/planner-strategies.ts | 2 +- .../migrations/planner-strategies.test.ts | 4 +- .../sqlite/test/planner-ddl-builders.test.ts | 78 ++++++++++--------- 7 files changed, 112 insertions(+), 127 deletions(-) diff --git a/packages/3-targets/3-targets/sqlite/src/core/control-target.ts b/packages/3-targets/3-targets/sqlite/src/core/control-target.ts index cd2f5feeef..63d85dd874 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/control-target.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/control-target.ts @@ -1,4 +1,4 @@ -import type { ColumnDefault, Contract } from '@prisma-next/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import type { SqlControlFamilyInstance, SqlControlTargetDescriptor, @@ -9,10 +9,13 @@ import type { MigrationPlanner, MigrationRunner, } from '@prisma-next/framework-components/control'; -import { SqlStorage, type StorageColumn } from '@prisma-next/sql-contract/types'; +import { + type ColumnDefault, + SqlStorage, + type StorageColumn, +} from '@prisma-next/sql-contract/types'; import { sqliteTargetDescriptorMeta } from './descriptor-meta'; import { createSqliteMigrationPlanner } from './migrations/planner'; -import { renderDefaultLiteral } from './migrations/planner-ddl-builders'; import type { SqlitePlanTargetDetails } from './migrations/planner-target-details'; import { createSqliteMigrationRunner } from './migrations/runner'; import { SqliteContractSerializer } from './sqlite-contract-serializer'; @@ -23,13 +26,13 @@ function isSqlContract(contract: Contract | null): contract is Contract = diff --git a/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts b/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts index 5fac409975..1ba53b732a 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts @@ -7,17 +7,7 @@ * `target-sqlite` reaching into `adapter-sqlite`. */ -import type { ColumnDefault } from '@prisma-next/contract/types'; - -const NULL_PATTERN = /^NULL$/i; -const INTEGER_PATTERN = /^-?\d+$/; -const REAL_PATTERN = /^-?\d+\.\d+(?:[eE][+-]?\d+)?$/; -const HEX_PATTERN = /^0[xX][\dA-Fa-f]+$/; -const STRING_LITERAL_PATTERN = /^'((?:[^']|'')*)'$/; - -function isNumericLiteral(value: string): boolean { - return INTEGER_PATTERN.test(value) || REAL_PATTERN.test(value) || HEX_PATTERN.test(value); -} +import type { ColumnDefault } from '@prisma-next/sql-contract/types'; /** * Strips a single matched wrapping pair of outer parens from `s`. Conservative: @@ -42,7 +32,7 @@ export function stripOuterParens(s: string): string { export function parseSqliteDefault( rawDefault: string, - nativeType?: string, + _nativeType?: string, ): ColumnDefault | undefined { let trimmed = rawDefault.trim(); @@ -62,31 +52,8 @@ export function parseSqliteDefault( // form for verification. const lower = trimmed.toLowerCase(); if (lower === 'current_timestamp' || lower === "datetime('now')" || lower === 'datetime("now")') { - return { kind: 'function', expression: 'now()' }; - } - - if (NULL_PATTERN.test(trimmed)) { - return { kind: 'literal', value: null }; - } - - // SQLite integers are 64-bit, so values outside the JS safe-integer range can't - // be faithfully represented as `number`. Mirror `parsePostgresDefault`'s bigint - // handling: parse as JS `number` when safe, fall back to the raw text otherwise. - if (isNumericLiteral(trimmed)) { - const num = Number(trimmed); - if (!Number.isFinite(num)) return undefined; - if (nativeType?.toLowerCase() === 'integer' && !Number.isSafeInteger(num)) { - return { kind: 'literal', value: trimmed }; - } - return { kind: 'literal', value: num }; - } - - const stringMatch = trimmed.match(STRING_LITERAL_PATTERN); - if (stringMatch?.[1] !== undefined) { - const unescaped = stringMatch[1].replace(/''/g, "'"); - return { kind: 'literal', value: unescaped }; + return { kind: 'expression', expression: 'now()' }; } - // Unrecognized expression — preserve as function - return { kind: 'function', expression: trimmed }; + return { kind: 'expression', expression: trimmed }; } diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts index 7d80b17307..49fffbbb05 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/issue-planner.ts @@ -46,6 +46,7 @@ import type { import { buildColumnDefaultSql, buildColumnTypeSql, + type ColumnDefaultContext, isInlineAutoincrementPrimaryKey, } from './planner-ddl-builders'; import { @@ -204,6 +205,7 @@ function isMissing(issue: SchemaIssue): boolean { * `StorageColumn` again — they deal in pre-rendered SQL fragments. */ export function toColumnSpec( + tableName: string, name: string, column: StorageColumn, storageTypes: Readonly>, @@ -213,7 +215,12 @@ export function toColumnSpec( column, storageTypes as Record, ); - const defaultSql = buildColumnDefaultSql(column.default); + const context: ColumnDefaultContext = { + tableName, + columnName: name, + isIntegerPrimaryKey: inlineAutoincrementPrimaryKey, + }; + const defaultSql = buildColumnDefaultSql(column.default, context); return { name, typeSql, @@ -230,11 +237,18 @@ export function toColumnSpec( * renderer emits `INTEGER PRIMARY KEY AUTOINCREMENT` inline. */ export function toTableSpec( + tableName: string, table: StorageTable, storageTypes: Readonly>, ): SqliteTableSpec { const columns: SqliteColumnSpec[] = Object.entries(table.columns).map(([name, column]) => - toColumnSpec(name, column, storageTypes, isInlineAutoincrementPrimaryKey(table, name)), + toColumnSpec( + tableName, + name, + column, + storageTypes, + isInlineAutoincrementPrimaryKey(table, name), + ), ); const uniques: SqliteUniqueSpec[] = table.uniques.map((u) => ({ columns: u.columns, @@ -309,7 +323,7 @@ function mapIssueToCall( ), ); } - const tableSpec = toTableSpec(contractTable, ctx.storageTypes); + const tableSpec = toTableSpec(issue.table, contractTable, ctx.storageTypes); const calls: SqliteOpFactoryCall[] = [new CreateTableCall(issue.table, tableSpec)]; const declaredIndexColumnKeys = new Set(); for (const index of contractTable.indexes) { @@ -345,6 +359,7 @@ function mapIssueToCall( } const contractTable = contractTable2; const columnSpec = toColumnSpec( + issue.table, issue.column, column, ctx.storageTypes, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts index 59ab1bfcf4..4ffca26553 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts @@ -15,7 +15,7 @@ import { type StorageTable, type StorageTypeInstance, } from '@prisma-next/sql-contract/types'; -import { escapeLiteral, quoteIdentifier } from '../sql-utils'; +import { quoteIdentifier } from '../sql-utils'; type SqliteColumnDefault = StorageColumn['default']; @@ -30,15 +30,6 @@ function assertSafeNativeType(nativeType: string): void { } } -function assertSafeDefaultExpression(expression: string): void { - if (expression.includes(';') || /--|\/\*|\bSELECT\b/i.test(expression)) { - throw new Error( - `Unsafe default expression in contract: "${expression}". ` + - 'Default expressions must not contain semicolons, SQL comment tokens, or subqueries.', - ); - } -} - /** * Renders the column's DDL type token (e.g. `"INTEGER"`, `"TEXT"`). * Resolves `typeRef` against `storageTypes` and validates the resulting @@ -53,46 +44,47 @@ export function buildColumnTypeSql( return resolved.nativeType.toUpperCase(); } +export interface ColumnDefaultContext { + readonly tableName: string; + readonly columnName: string; + readonly isIntegerPrimaryKey: boolean; +} + /** * Renders the column's `DEFAULT …` clause. Returns the empty string when - * there is no default, and also when the default is `autoincrement()` — - * SQLite encodes that as `INTEGER PRIMARY KEY AUTOINCREMENT` inline on the - * column definition, not as a separate DEFAULT. + * there is no default, and also when the default is `autoincrement` on a + * valid `INTEGER PRIMARY KEY` column — SQLite encodes that as + * `INTEGER PRIMARY KEY AUTOINCREMENT` inline on the column definition, not + * as a separate DEFAULT. + * + * Throws a diagnostic when `kind: 'autoincrement'` arrives on a column that + * is not `INTEGER PRIMARY KEY` — SQLite's autoincrement mechanism only + * operates on the rowid alias column. */ -export function buildColumnDefaultSql(columnDefault: SqliteColumnDefault | undefined): string { +export function buildColumnDefaultSql( + columnDefault: SqliteColumnDefault | undefined, + context?: ColumnDefaultContext, +): string { if (!columnDefault) return ''; switch (columnDefault.kind) { - case 'literal': - return `DEFAULT ${renderDefaultLiteral(columnDefault.value)}`; - case 'function': { - if (columnDefault.expression === 'autoincrement()') return ''; + case 'autoincrement': { + if (!context || !context.isIntegerPrimaryKey) { + const columnPath = context ? `${context.tableName}.${context.columnName}` : ''; + throw new Error( + `Column "${columnPath}" has kind 'autoincrement' but is not an INTEGER PRIMARY KEY. ` + + 'SQLite AUTOINCREMENT is only valid on INTEGER PRIMARY KEY columns.', + ); + } + return ''; + } + case 'expression': { if (columnDefault.expression === 'now()') return "DEFAULT (datetime('now'))"; - assertSafeDefaultExpression(columnDefault.expression); return `DEFAULT (${columnDefault.expression})`; } } } -export function renderDefaultLiteral(value: unknown): string { - if (value instanceof Date) { - return `'${escapeLiteral(value.toISOString())}'`; - } - if (typeof value === 'string') { - return `'${escapeLiteral(value)}'`; - } - if (typeof value === 'number' || typeof value === 'bigint') { - return String(value); - } - if (typeof value === 'boolean') { - return value ? '1' : '0'; - } - if (value === null) { - return 'NULL'; - } - return `'${escapeLiteral(JSON.stringify(value))}'`; -} - export function buildCreateIndexSql( tableName: string, indexName: string, @@ -109,7 +101,7 @@ export function buildDropIndexSql(indexName: string): string { /** * True when the column is rendered inline as `INTEGER PRIMARY KEY - * AUTOINCREMENT`. Requires the column's default to be `autoincrement()` and + * AUTOINCREMENT`. Requires the column's default to be `autoincrement` and * the column to be the sole member of the table's primary key — anything * else falls back to a separate PRIMARY KEY constraint with a default * AUTOINCREMENT semantics expressed elsewhere. @@ -118,7 +110,7 @@ export function isInlineAutoincrementPrimaryKey(table: StorageTable, columnName: if (table.primaryKey?.columns.length !== 1) return false; if (table.primaryKey.columns[0] !== columnName) return false; const column = table.columns[columnName]; - return column?.default?.kind === 'function' && column.default.expression === 'autoincrement()'; + return column?.default?.kind === 'autoincrement'; } type ResolvedColumnTypeMetadata = Pick; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts index 7770bdaff6..f0282648db 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-strategies.ts @@ -163,7 +163,7 @@ export const recreateTableStrategy: CallMigrationStrategy = (issues, ctx) => { // Flatten the contract table to a self-contained spec — the Call holds // pre-rendered SQL fragments only, no `StorageColumn` or `storageTypes`. - const tableSpec = toTableSpec(contractTable, ctx.storageTypes); + const tableSpec = toTableSpec(tableName, contractTable, ctx.storageTypes); const seenIndexColumnKeys = new Set(); const indexes: SqliteIndexSpec[] = []; diff --git a/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts b/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts index 8cb9759bab..0453a26e34 100644 --- a/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/migrations/planner-strategies.test.ts @@ -82,7 +82,7 @@ describe('recreateTableStrategy', () => { nativeType: 'text', codecId: 'sqlite/text@1', nullable: true, - default: { kind: 'literal', value: '' }, + default: { kind: 'expression', expression: "''" }, }, }, primaryKey: { columns: ['id'] }, @@ -158,7 +158,7 @@ describe('recreateTableStrategy', () => { nativeType: 'text', codecId: 'sqlite/text@1', nullable: true, - default: { kind: 'literal', value: '' }, + default: { kind: 'expression', expression: "''" }, }, }, primaryKey: { columns: ['id'] }, diff --git a/packages/3-targets/3-targets/sqlite/test/planner-ddl-builders.test.ts b/packages/3-targets/3-targets/sqlite/test/planner-ddl-builders.test.ts index 46463a5323..04ce8377a3 100644 --- a/packages/3-targets/3-targets/sqlite/test/planner-ddl-builders.test.ts +++ b/packages/3-targets/3-targets/sqlite/test/planner-ddl-builders.test.ts @@ -6,7 +6,6 @@ import { buildCreateIndexSql, buildDropIndexSql, isInlineAutoincrementPrimaryKey, - renderDefaultLiteral, } from '../src/core/migrations/planner-ddl-builders'; function makeColumn(overrides: Partial = {}): StorageColumn { @@ -59,54 +58,63 @@ describe('buildColumnDefaultSql', () => { expect(buildColumnDefaultSql(undefined)).toBe(''); }); - it('renders literal string default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: 'hello' })).toBe("DEFAULT 'hello'"); + it('renders expression default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'random()' })).toBe( + 'DEFAULT (random())', + ); }); - it('renders literal number default', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: 42 })).toBe('DEFAULT 42'); + it('renders string expression default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: "'hello'" })).toBe( + "DEFAULT ('hello')", + ); }); - it('renders literal boolean as 0/1', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: true })).toBe('DEFAULT 1'); - expect(buildColumnDefaultSql({ kind: 'literal', value: false })).toBe('DEFAULT 0'); + it('renders numeric expression default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: '42' })).toBe('DEFAULT (42)'); }); - it('renders NULL literal', () => { - expect(buildColumnDefaultSql({ kind: 'literal', value: null })).toBe('DEFAULT NULL'); + it('renders NULL expression default', () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'NULL' })).toBe( + 'DEFAULT (NULL)', + ); }); - it("renders now() as datetime('now')", () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'now()' })).toBe( + it("renders now() as datetime('now') — dialect-specific translation preserved", () => { + expect(buildColumnDefaultSql({ kind: 'expression', expression: 'now()' })).toBe( "DEFAULT (datetime('now'))", ); }); - it('returns empty for autoincrement()', () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'autoincrement()' })).toBe(''); - }); - - it('renders custom function default', () => { - expect(buildColumnDefaultSql({ kind: 'function', expression: 'random()' })).toBe( - 'DEFAULT (random())', - ); + it('returns empty for autoincrement on INTEGER PRIMARY KEY column', () => { + expect( + buildColumnDefaultSql( + { kind: 'autoincrement' }, + { tableName: 'users', columnName: 'id', isIntegerPrimaryKey: true }, + ), + ).toBe(''); }); - it('rejects unsafe default expressions', () => { + it('throws diagnostic for autoincrement on non-INTEGER-PK column', () => { expect(() => - buildColumnDefaultSql({ kind: 'function', expression: 'foo(); DROP TABLE' }), - ).toThrow(/Unsafe/); + buildColumnDefaultSql( + { kind: 'autoincrement' }, + { tableName: 'users', columnName: 'name', isIntegerPrimaryKey: false }, + ), + ).toThrow('users.name'); }); -}); -describe('renderDefaultLiteral', () => { - it('renders Date as ISO8601 string', () => { - const d = new Date('2024-01-15T10:30:00.000Z'); - expect(renderDefaultLiteral(d)).toBe("'2024-01-15T10:30:00.000Z'"); + it('throws diagnostic for autoincrement on non-INTEGER-PK TEXT column', () => { + expect(() => + buildColumnDefaultSql( + { kind: 'autoincrement' }, + { tableName: 'orders', columnName: 'ref_id', isIntegerPrimaryKey: false }, + ), + ).toThrow('orders.ref_id'); }); - it('renders JSON objects', () => { - expect(renderDefaultLiteral({ key: 'val' })).toBe('\'{"key":"val"}\''); + it('throws for autoincrement with no column context', () => { + expect(() => buildColumnDefaultSql({ kind: 'autoincrement' })).toThrow(); }); }); @@ -137,13 +145,13 @@ describe('buildDropIndexSql', () => { }); describe('isInlineAutoincrementPrimaryKey', () => { - it('is true for sole-column PK with autoincrement() default', () => { + it('is true for sole-column PK with autoincrement default', () => { const table = makeTable({ columns: { id: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), }, primaryKey: { columns: ['id'] }, @@ -158,7 +166,7 @@ describe('isInlineAutoincrementPrimaryKey', () => { seq: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), }, primaryKey: { columns: ['id'] }, @@ -172,7 +180,7 @@ describe('isInlineAutoincrementPrimaryKey', () => { a: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), b: makeColumn({ nativeType: 'integer', nullable: false }), }, @@ -181,7 +189,7 @@ describe('isInlineAutoincrementPrimaryKey', () => { expect(isInlineAutoincrementPrimaryKey(table, 'a')).toBe(false); }); - it('is false when default is not autoincrement()', () => { + it('is false when default is not autoincrement', () => { const table = makeTable({ columns: { id: makeColumn({ nativeType: 'integer', nullable: false }), From 78bea1e52cb186133b8bf0eb70c624014b1954b6 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 21:37:20 +0200 Subject: [PATCH 25/50] test(adapter-sqlite): flip DDL fixture shapes to new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - planner-introspection.integration.test.ts: flip 2× kind:'function' expression:'autoincrement()' → kind:'autoincrement'; flip 1× kind:'literal' value:1 → kind:'expression' expression:'1'. - control-adapter.test.ts: update parseSqliteDefault test expectations to new kind:'expression' shapes; drop nativeType argument (now unused since the normalizer no longer type-dispatches on it); align test descriptions to new semantics. --- .../sqlite/test/control-adapter.test.ts | 48 ++++++++----------- .../planner-introspection.integration.test.ts | 6 +-- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/packages/3-targets/6-adapters/sqlite/test/control-adapter.test.ts b/packages/3-targets/6-adapters/sqlite/test/control-adapter.test.ts index eab1c3223a..067ccc5df6 100644 --- a/packages/3-targets/6-adapters/sqlite/test/control-adapter.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/control-adapter.test.ts @@ -132,63 +132,55 @@ describe('SqliteControlAdapter.introspect', () => { describe('parseSqliteDefault', () => { it('normalizes CURRENT_TIMESTAMP to now()', () => { expect(parseSqliteDefault('CURRENT_TIMESTAMP')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); it("normalizes datetime('now') to now()", () => { expect(parseSqliteDefault("(datetime('now'))")).toEqual({ - kind: 'function', + kind: 'expression', expression: 'now()', }); }); - it('preserves CURRENT_DATE distinctly', () => { + it('preserves CURRENT_DATE as expression', () => { expect(parseSqliteDefault('CURRENT_DATE')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'CURRENT_DATE', }); }); - it('preserves CURRENT_TIME distinctly', () => { + it('preserves CURRENT_TIME as expression', () => { expect(parseSqliteDefault('CURRENT_TIME')).toEqual({ - kind: 'function', + kind: 'expression', expression: 'CURRENT_TIME', }); }); - it('parses NULL default', () => { - expect(parseSqliteDefault('NULL')).toEqual({ kind: 'literal', value: null }); + it('parses NULL default as expression', () => { + expect(parseSqliteDefault('NULL')).toEqual({ kind: 'expression', expression: 'NULL' }); }); - it('returns number for safe-range integers and falls back to string for 64-bit values', () => { - expect(parseSqliteDefault('42', 'integer')).toEqual({ kind: 'literal', value: 42 }); - expect(parseSqliteDefault('0', 'integer')).toEqual({ kind: 'literal', value: 0 }); - const big = '9999999999999999999'; - expect(parseSqliteDefault(big, 'integer')).toEqual({ kind: 'literal', value: big }); + it('parses numeric default as expression', () => { + expect(parseSqliteDefault('42')).toEqual({ kind: 'expression', expression: '42' }); + expect(parseSqliteDefault('0')).toEqual({ kind: 'expression', expression: '0' }); + expect(parseSqliteDefault('3.14')).toEqual({ kind: 'expression', expression: '3.14' }); }); - it('returns number for real nativeType', () => { - expect(parseSqliteDefault('3.14', 'real')).toEqual({ kind: 'literal', value: 3.14 }); - expect(parseSqliteDefault('0xFF', 'real')).toEqual({ kind: 'literal', value: 255 }); - expect(parseSqliteDefault('1.5e3', 'real')).toEqual({ kind: 'literal', value: 1500 }); - }); - - it('returns number when nativeType is unknown', () => { - expect(parseSqliteDefault('42')).toEqual({ kind: 'literal', value: 42 }); - }); - - it('parses string literal default', () => { - expect(parseSqliteDefault("'hello'")).toEqual({ kind: 'literal', value: 'hello' }); + it('parses string literal default as expression', () => { + expect(parseSqliteDefault("'hello'")).toEqual({ + kind: 'expression', + expression: "'hello'", + }); }); - it('preserves unrecognized expressions as function', () => { - expect(parseSqliteDefault('abs(-5)')).toEqual({ kind: 'function', expression: 'abs(-5)' }); + it('preserves unrecognized expressions', () => { + expect(parseSqliteDefault('abs(-5)')).toEqual({ kind: 'expression', expression: 'abs(-5)' }); }); it('strips outer parentheses', () => { - expect(parseSqliteDefault('(42)')).toEqual({ kind: 'literal', value: 42 }); + expect(parseSqliteDefault('(42)')).toEqual({ kind: 'expression', expression: '42' }); }); }); diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts index 165b777288..3f70cdd25c 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/planner-introspection.integration.test.ts @@ -96,13 +96,13 @@ describe('SQLite planner + introspection round-trip', () => { id: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), email: makeColumn({ nativeType: 'text', nullable: false }), active: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'literal', value: 1 }, + default: { kind: 'expression', expression: '1' }, }), }, primaryKey: { columns: ['id'] }, @@ -161,7 +161,7 @@ describe('SQLite planner + introspection round-trip', () => { id: makeColumn({ nativeType: 'integer', nullable: false, - default: { kind: 'function', expression: 'autoincrement()' }, + default: { kind: 'autoincrement' }, }), value: makeColumn({ nativeType: 'text', nullable: true }), }, From 695561368c8d9483efc2e049bf93159345c919a6 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:13:32 +0200 Subject: [PATCH 26/50] =?UTF-8?q?feat(fixtures)!:=20D7=20=E2=80=94=20regen?= =?UTF-8?q?erate=20all=20contract=20fixtures=20for=20new=20ColumnDefault?= =?UTF-8?q?=20union=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace every occurrence of { kind: 'literal', value } and { kind: 'function', expression } with the new { kind: 'expression', expression } and { kind: 'autoincrement' } shapes. Source-contract fixes: - test/e2e/framework/test/fixtures/contract.ts: old .default({ kind: 'literal' }) API → .default(new Date(...)) - test/e2e/framework/test/sqlite/fixtures/contract.ts: add extractCodecLookup([sqliteAdapter]) + codecLookup - test/integration/test/authoring/parity/core-surface/contract.ts: add codecLookup, change .defaultSql('autoincrement()') → .default(autoincrement()) - test/integration/test/authoring/parity/callback-mode-scalars/contract.ts: add codecLookup, change .defaultSql('autoincrement()') → .default(autoincrement()) - test/integration/test/authoring/parity/pgvector-named-type/contract.ts: change .defaultSql('autoincrement()') → .default(autoincrement()) - test/integration/test/authoring/side-by-side/postgres/contract.ts: change .defaultSql('autoincrement()') → .default(autoincrement()) - test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts: add codecLookup, change .defaultSql('autoincrement()') → .default(autoincrement()) - packages/3-targets/6-adapters/sqlite/src/core/descriptor-meta.ts: wire sqliteCodecRegistry into codecDescriptors (mirrors postgres adapter) Hand-crafted fixtures: - test/integration/test/value-objects/fixtures/generated/sql-contract.{json,d.ts}: update to { kind: 'autoincrement' } Auto-regenerated via pnpm fixtures:emit: - All examples/**/contract.{json,d.ts} - All test/**/expected.contract.json - All test/**/generated/contract.{json,d.ts} - test/integration/test/authoring/side-by-side/postgres/contract.json storageHash recomputes are expected and intentional. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/prisma/contract.d.ts | 6 +- .../src/prisma/contract.json | 12 ++-- .../src/prisma/contract.d.ts | 8 +-- .../src/prisma/contract.json | 6 +- .../prisma-next-demo/src/prisma/contract.d.ts | 6 +- .../prisma-next-demo/src/prisma/contract.json | 12 ++-- .../src/prisma/contract.d.ts | 2 +- .../src/prisma/contract.json | 6 +- .../sqlite/src/core/descriptor-meta.ts | 3 + test/e2e/framework/test/fixtures/contract.ts | 2 +- .../test/fixtures/generated/contract.d.ts | 40 +++++-------- .../test/fixtures/generated/contract.json | 59 +++++++++---------- .../test/sqlite/fixtures/contract.ts | 5 ++ .../sqlite/fixtures/generated/contract.d.ts | 7 +-- .../sqlite/fixtures/generated/contract.json | 6 +- .../parity/callback-mode-scalars/contract.ts | 17 ++++-- .../expected.contract.json | 14 ++--- .../authoring/parity/core-surface/contract.ts | 17 +++++- .../core-surface/expected.contract.json | 14 ++--- .../expected.contract.json | 4 +- .../parity/pgvector-named-type/contract.ts | 9 ++- .../expected.contract.json | 5 +- .../side-by-side/postgres/contract.json | 8 +-- .../side-by-side/postgres/contract.ts | 12 +++- .../fixtures/emit-command/contract.parity.ts | 16 ++++- .../fixtures/generated/sql-contract.d.ts | 3 +- .../fixtures/generated/sql-contract.json | 3 +- 27 files changed, 161 insertions(+), 141 deletions(-) diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts index dc2f7cc062..1c7345a5b7 100644 --- a/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts +++ b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts @@ -27,7 +27,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:17efb9380d28aece136dac05fd18e62d63d164332bae788d13e3e4339b486839'>; + StorageHashBase<'sha256:335d19f9d862a812f18a5f040982b6157c3a606008db953e752aff28d23c5ba0'>; export type ExecutionHash = ExecutionHashBase<'sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e'>; export type ProfileHash = @@ -233,8 +233,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'function'; + readonly expression: "'open'::text"; }; }; readonly type: { diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/contract.json b/examples/prisma-next-cloudflare-worker/src/prisma/contract.json index c04af7b581..84be9356af 100644 --- a/examples/prisma-next-cloudflare-worker/src/prisma/contract.json +++ b/examples/prisma-next-cloudflare-worker/src/prisma/contract.json @@ -423,7 +423,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -481,7 +481,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -502,8 +502,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "expression": "'open'::text", + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -563,7 +563,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -622,7 +622,7 @@ } } }, - "storageHash": "sha256:17efb9380d28aece136dac05fd18e62d63d164332bae788d13e3e4339b486839" + "storageHash": "sha256:335d19f9d862a812f18a5f040982b6157c3a606008db953e752aff28d23c5ba0" }, "execution": { "executionHash": "sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e", diff --git a/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts b/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts index d7cf1e288a..33c2981a6d 100644 --- a/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts @@ -15,7 +15,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:8ea47fc08b79e387a9136d5a4ac708723e4941a7b3cffe71d57ceca09229a486'>; + StorageHashBase<'sha256:2e6a5d2aa8394b2afb958a80fa950bc04c0fb8c6646afb4ceed34267b6f976bd'>; export type ExecutionHash = ExecutionHashBase<'sha256:0903547be862dca3fa2dbc62a85cd52e9ca595f00cf43b6b26a3da3d4b9740ae'>; export type ProfileHash = @@ -30,13 +30,13 @@ type DefaultLiteralValue = CodecId extends key export type FieldOutputTypes = { readonly Post: { - readonly id: CodecTypes['sql/char@1']['output']; + readonly id: Char<36>; readonly title: CodecTypes['sqlite/text@1']['output']; - readonly userId: CodecTypes['sql/char@1']['output']; + readonly userId: Char<36>; readonly createdAt: CodecTypes['sqlite/datetime@1']['output']; }; readonly User: { - readonly id: CodecTypes['sql/char@1']['output']; + readonly id: Char<36>; readonly email: CodecTypes['sqlite/text@1']['output']; readonly displayName: CodecTypes['sqlite/text@1']['output']; readonly createdAt: CodecTypes['sqlite/datetime@1']['output']; diff --git a/examples/prisma-next-demo-sqlite/src/prisma/contract.json b/examples/prisma-next-demo-sqlite/src/prisma/contract.json index 68b1719fa9..02994edf0e 100644 --- a/examples/prisma-next-demo-sqlite/src/prisma/contract.json +++ b/examples/prisma-next-demo-sqlite/src/prisma/contract.json @@ -155,7 +155,7 @@ "codecId": "sqlite/datetime@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -217,7 +217,7 @@ "codecId": "sqlite/datetime@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -253,7 +253,7 @@ } } }, - "storageHash": "sha256:8ea47fc08b79e387a9136d5a4ac708723e4941a7b3cffe71d57ceca09229a486" + "storageHash": "sha256:2e6a5d2aa8394b2afb958a80fa950bc04c0fb8c6646afb4ceed34267b6f976bd" }, "execution": { "executionHash": "sha256:0903547be862dca3fa2dbc62a85cd52e9ca595f00cf43b6b26a3da3d4b9740ae", diff --git a/examples/prisma-next-demo/src/prisma/contract.d.ts b/examples/prisma-next-demo/src/prisma/contract.d.ts index 1245f1b87c..7000794d0f 100644 --- a/examples/prisma-next-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo/src/prisma/contract.d.ts @@ -30,7 +30,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:7926114e786c1a8fcc103d295a0c7fe5eb414b669ebb5b06a1e816d0019cbe7f'>; + StorageHashBase<'sha256:c57aaba9595d32bbb3c0101fbf63b80efb91e20a4335b091c0977885d29831b8'>; export type ExecutionHash = ExecutionHashBase<'sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e'>; export type ProfileHash = @@ -245,8 +245,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'function'; + readonly expression: "'open'::text"; }; }; readonly type: { diff --git a/examples/prisma-next-demo/src/prisma/contract.json b/examples/prisma-next-demo/src/prisma/contract.json index a303f372aa..ce34b94ea0 100644 --- a/examples/prisma-next-demo/src/prisma/contract.json +++ b/examples/prisma-next-demo/src/prisma/contract.json @@ -433,7 +433,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -497,7 +497,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -518,8 +518,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "expression": "'open'::text", + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -579,7 +579,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -638,7 +638,7 @@ } } }, - "storageHash": "sha256:7926114e786c1a8fcc103d295a0c7fe5eb414b669ebb5b06a1e816d0019cbe7f", + "storageHash": "sha256:c57aaba9595d32bbb3c0101fbf63b80efb91e20a4335b091c0977885d29831b8", "types": { "Embedding1536": { "codecId": "pg/vector@1", diff --git a/examples/react-router-demo/src/prisma/contract.d.ts b/examples/react-router-demo/src/prisma/contract.d.ts index ec8c649648..79fc2748b1 100644 --- a/examples/react-router-demo/src/prisma/contract.d.ts +++ b/examples/react-router-demo/src/prisma/contract.d.ts @@ -27,7 +27,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:5d2b2c2468240c79ee5f380951267aa777888966aeba3bb19d573825189e7816'>; + StorageHashBase<'sha256:34be6468b5ac2a4785cb4b670ac355c919cce3011094b349655d607dfc9c581b'>; export type ExecutionHash = ExecutionHashBase<'sha256:8c5eef43d2153fd832b8288ed2d8ffc9f5afb62908f8b4b7e6a4b7018444c41f'>; export type ProfileHash = diff --git a/examples/react-router-demo/src/prisma/contract.json b/examples/react-router-demo/src/prisma/contract.json index c32e1a9649..df17fcfddf 100644 --- a/examples/react-router-demo/src/prisma/contract.json +++ b/examples/react-router-demo/src/prisma/contract.json @@ -143,7 +143,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -201,7 +201,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -232,7 +232,7 @@ } } }, - "storageHash": "sha256:5d2b2c2468240c79ee5f380951267aa777888966aeba3bb19d573825189e7816" + "storageHash": "sha256:34be6468b5ac2a4785cb4b670ac355c919cce3011094b349655d607dfc9c581b" }, "execution": { "executionHash": "sha256:8c5eef43d2153fd832b8288ed2d8ffc9f5afb62908f8b4b7e6a4b7018444c41f", diff --git a/packages/3-targets/6-adapters/sqlite/src/core/descriptor-meta.ts b/packages/3-targets/6-adapters/sqlite/src/core/descriptor-meta.ts index 72e01bfd90..72f201c14f 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/descriptor-meta.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/descriptor-meta.ts @@ -1,3 +1,5 @@ +import { sqliteCodecRegistry } from '@prisma-next/target-sqlite/codecs'; + export const sqliteAdapterDescriptorMeta = { kind: 'adapter', familyId: 'sql', @@ -16,6 +18,7 @@ export const sqliteAdapterDescriptorMeta = { }, types: { codecTypes: { + codecDescriptors: Array.from(sqliteCodecRegistry.values()), import: { package: '@prisma-next/adapter-sqlite/codec-types', named: 'CodecTypes', diff --git a/test/e2e/framework/test/fixtures/contract.ts b/test/e2e/framework/test/fixtures/contract.ts index 1397de3d9b..91caa5c3c3 100644 --- a/test/e2e/framework/test/fixtures/contract.ts +++ b/test/e2e/framework/test/fixtures/contract.ts @@ -121,7 +121,7 @@ export const contract = defineContract({ name: field.column(textColumn), scheduledAt: field .column(timestamptzColumn) - .default({ kind: 'literal', value: new Date('2024-01-15T10:30:00.000Z') }) + .default(new Date('2024-01-15T10:30:00.000Z')) .column('scheduled_at'), createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'), }, diff --git a/test/e2e/framework/test/fixtures/generated/contract.d.ts b/test/e2e/framework/test/fixtures/generated/contract.d.ts index c1d962c3ff..ee3c8cf237 100644 --- a/test/e2e/framework/test/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/fixtures/generated/contract.d.ts @@ -31,7 +31,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:c01040e095a1fe5dd776c2d5afe4a7767c506e044e96ecd46bfa369a75a13733'>; + StorageHashBase<'sha256:06e84ab8bb0d664611300b6e4a576f055a560de08091c2de1b6387d836b773e2'>; export type ExecutionHash = ExecutionHashBase<'sha256:adc296c2bde14cd4e6a8a85ba202108dc7a320b5870a14d7dd8e2d2e2f5a7f27'>; export type ProfileHash = @@ -269,11 +269,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue< - 'pg/timestamptz@1', - '2024-01-15T10:30:00.000Z' - >; + readonly kind: 'function'; + readonly expression: "'2024-01-15T10:30:00.000Z'::timestamp with time zone"; }; }; readonly created_at: { @@ -304,44 +301,35 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'draft'>; + readonly kind: 'function'; + readonly expression: "'draft'::text"; }; }; readonly score: { readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/int4@1', 0>; - }; + readonly default: { readonly kind: 'function'; readonly expression: '0' }; }; readonly rating: { readonly nativeType: 'float8'; readonly codecId: 'pg/float8@1'; readonly nullable: false; - readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/float8@1', 3.14>; - }; + readonly default: { readonly kind: 'function'; readonly expression: '3.14' }; }; readonly active: { readonly nativeType: 'bool'; readonly codecId: 'pg/bool@1'; readonly nullable: false; - readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/bool@1', true>; - }; + readonly default: { readonly kind: 'function'; readonly expression: 'TRUE' }; }; readonly big_count: { readonly nativeType: 'int8'; readonly codecId: 'pg/int8@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/int8@1', 9007199254740991>; + readonly kind: 'function'; + readonly expression: '9007199254740991'; }; }; readonly metadata: { @@ -349,8 +337,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/jsonb@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/jsonb@1', { readonly key: 'default' }>; + readonly kind: 'function'; + readonly expression: '\'{"key":"default"}\'::jsonb'; }; }; readonly tags: { @@ -358,8 +346,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/jsonb@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/jsonb@1', readonly ['alpha', 'beta']>; + readonly kind: 'function'; + readonly expression: '\'["alpha","beta"]\'::jsonb'; }; }; }; diff --git a/test/e2e/framework/test/fixtures/generated/contract.json b/test/e2e/framework/test/fixtures/generated/contract.json index 3f267f0614..58f6b2f1e0 100644 --- a/test/e2e/framework/test/fixtures/generated/contract.json +++ b/test/e2e/framework/test/fixtures/generated/contract.json @@ -625,7 +625,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -634,7 +634,7 @@ "codecId": "pg/int4@1", "default": { "expression": "autoincrement()", - "kind": "function" + "kind": "expression" }, "nativeType": "int4", "nullable": false @@ -673,7 +673,7 @@ "codecId": "pg/int4@1", "default": { "expression": "autoincrement()", - "kind": "function" + "kind": "expression" }, "nativeType": "int4", "nullable": false @@ -715,7 +715,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -736,8 +736,8 @@ "scheduled_at": { "codecId": "pg/timestamptz@1", "default": { - "kind": "literal", - "value": "2024-01-15T10:30:00.000Z" + "expression": "'2024-01-15T10:30:00.000Z'::timestamp with time zone", + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -757,8 +757,8 @@ "active": { "codecId": "pg/bool@1", "default": { - "kind": "literal", - "value": true + "expression": "TRUE", + "kind": "expression" }, "nativeType": "bool", "nullable": false @@ -766,8 +766,8 @@ "big_count": { "codecId": "pg/int8@1", "default": { - "kind": "literal", - "value": 9007199254740991 + "expression": "9007199254740991", + "kind": "expression" }, "nativeType": "int8", "nullable": false @@ -776,7 +776,7 @@ "codecId": "pg/int4@1", "default": { "expression": "autoincrement()", - "kind": "function" + "kind": "expression" }, "nativeType": "int4", "nullable": false @@ -784,8 +784,8 @@ "label": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "draft" + "expression": "'draft'::text", + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -793,10 +793,8 @@ "metadata": { "codecId": "pg/jsonb@1", "default": { - "kind": "literal", - "value": { - "key": "default" - } + "expression": "'{\"key\":\"default\"}'::jsonb", + "kind": "expression" }, "nativeType": "jsonb", "nullable": false @@ -804,8 +802,8 @@ "rating": { "codecId": "pg/float8@1", "default": { - "kind": "literal", - "value": 3.14 + "expression": "3.14", + "kind": "expression" }, "nativeType": "float8", "nullable": false @@ -813,8 +811,8 @@ "score": { "codecId": "pg/int4@1", "default": { - "kind": "literal", - "value": 0 + "expression": "0", + "kind": "expression" }, "nativeType": "int4", "nullable": false @@ -822,11 +820,8 @@ "tags": { "codecId": "pg/jsonb@1", "default": { - "kind": "literal", - "value": [ - "alpha", - "beta" - ] + "expression": "'[\"alpha\",\"beta\"]'::jsonb", + "kind": "expression" }, "nativeType": "jsonb", "nullable": false @@ -887,7 +882,7 @@ "codecId": "pg/int4@1", "default": { "expression": "autoincrement()", - "kind": "function" + "kind": "expression" }, "nativeType": "int4", "nullable": false @@ -941,7 +936,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -950,7 +945,7 @@ "codecId": "pg/int4@1", "default": { "expression": "autoincrement()", - "kind": "function" + "kind": "expression" }, "nativeType": "int4", "nullable": false @@ -996,7 +991,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -1013,7 +1008,7 @@ "codecId": "pg/int4@1", "default": { "expression": "autoincrement()", - "kind": "function" + "kind": "expression" }, "nativeType": "int4", "nullable": false @@ -1048,7 +1043,7 @@ } } }, - "storageHash": "sha256:c01040e095a1fe5dd776c2d5afe4a7767c506e044e96ecd46bfa369a75a13733" + "storageHash": "sha256:06e84ab8bb0d664611300b6e4a576f055a560de08091c2de1b6387d836b773e2" }, "execution": { "executionHash": "sha256:adc296c2bde14cd4e6a8a85ba202108dc7a320b5870a14d7dd8e2d2e2f5a7f27", diff --git a/test/e2e/framework/test/sqlite/fixtures/contract.ts b/test/e2e/framework/test/sqlite/fixtures/contract.ts index 7d7c9e14cf..0816381838 100644 --- a/test/e2e/framework/test/sqlite/fixtures/contract.ts +++ b/test/e2e/framework/test/sqlite/fixtures/contract.ts @@ -4,10 +4,14 @@ import { jsonColumn, textColumn, } from '@prisma-next/adapter-sqlite/column-types'; +import sqliteAdapter from '@prisma-next/adapter-sqlite/control'; import sqlFamilyPack from '@prisma-next/family-sql/pack'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; import { defineContract, field, model, rel } from '@prisma-next/sql-contract-ts/contract-builder'; import sqlitePack from '@prisma-next/target-sqlite/pack'; +const sqliteCodecLookup = extractCodecLookup([sqliteAdapter]); + const User = model('User', { fields: { id: field.column(integerColumn).id(), @@ -63,6 +67,7 @@ const Item = model('Item', { export const contract = defineContract({ family: sqlFamilyPack, target: sqlitePack, + codecLookup: sqliteCodecLookup, capabilities: { sql: { lateral: false, diff --git a/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts b/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts index b890c2fc99..e297d4d187 100644 --- a/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts @@ -15,7 +15,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:f52ad65b0b6148af276fd084c349f8f21a4a4da2ba8644dfac21b53dd47d9791'>; + StorageHashBase<'sha256:55e5e8254d07de5085f404759d2c325862d538704622df14e5f2db8a34a9d01b'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:213031a5ce861b455f22bc065769080ea0357fabcb999de0190524ecd32531f7'>; @@ -152,10 +152,7 @@ type ContractBase = ContractType< readonly nativeType: 'text'; readonly codecId: 'sqlite/text@1'; readonly nullable: false; - readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'sqlite/text@1', 'unnamed'>; - }; + readonly default: { readonly kind: 'function'; readonly expression: "'unnamed'" }; }; }; primaryKey: { readonly columns: readonly ['id'] }; diff --git a/test/e2e/framework/test/sqlite/fixtures/generated/contract.json b/test/e2e/framework/test/sqlite/fixtures/generated/contract.json index f860e9437f..75f397dfe2 100644 --- a/test/e2e/framework/test/sqlite/fixtures/generated/contract.json +++ b/test/e2e/framework/test/sqlite/fixtures/generated/contract.json @@ -385,8 +385,8 @@ "label": { "codecId": "sqlite/text@1", "default": { - "kind": "literal", - "value": "unnamed" + "expression": "'unnamed'", + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -537,7 +537,7 @@ } } }, - "storageHash": "sha256:f52ad65b0b6148af276fd084c349f8f21a4a4da2ba8644dfac21b53dd47d9791" + "storageHash": "sha256:55e5e8254d07de5085f404759d2c325862d538704622df14e5f2db8a34a9d01b" }, "capabilities": { "sql": { diff --git a/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts b/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts index 8114d9035c..c2b581a5a3 100644 --- a/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts +++ b/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts @@ -1,18 +1,27 @@ import * as pg from '@prisma-next/adapter-postgres/column-types'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; import pgvector from '@prisma-next/extension-pgvector/pack'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, rel } from '@prisma-next/sql-contract-ts/contract-builder'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { autoincrement, defineContract, rel } from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; +const postgresCodecLookup = extractCodecLookup([postgresAdapter]); + export const contract = defineContract( - { family: sqlFamily, target: postgresPack, extensionPacks: { pgvector } }, + { + family: sqlFamily, + target: postgresPack, + extensionPacks: { pgvector }, + codecLookup: postgresCodecLookup, + }, ({ field, model, type }) => { const types = { Embedding: type.pgvector.Vector(1536), } as const; const User = model('User', { fields: { - id: field.column(pg.int4Column).defaultSql('autoincrement()').id(), + id: field.column(pg.int4Column).default(autoincrement()).id(), email: field.column(pg.textColumn).unique(), age: field.column(pg.int4Column), isActive: field.column(pg.boolColumn).default(true), @@ -24,7 +33,7 @@ export const contract = defineContract( }).sql({ table: 'user' }); const Post = model('Post', { fields: { - id: field.column(pg.int4Column).defaultSql('autoincrement()').id(), + id: field.column(pg.int4Column).default(autoincrement()).id(), userId: field.column(pg.int4Column), title: field.column(pg.textColumn), rating: field.column(pg.float8Column).optional(), diff --git a/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json b/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json index d30e16dcfd..61825ec13d 100644 --- a/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json +++ b/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json @@ -168,8 +168,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -225,7 +224,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -244,8 +243,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -253,8 +251,8 @@ "isActive": { "codecId": "pg/bool@1", "default": { - "kind": "literal", - "value": true + "expression": "TRUE", + "kind": "expression" }, "nativeType": "bool", "nullable": false @@ -284,7 +282,7 @@ } } }, - "storageHash": "sha256:8fa256466ba485fa9d52edc98e76680a9afd7042e65db1fcbb35b8518cf9fd28", + "storageHash": "sha256:9fb852123286e17142dac46de6aca8d6d4e6bbd423fe8737b5395b7681a34a19", "types": { "Embedding": { "codecId": "pg/vector@1", diff --git a/test/integration/test/authoring/parity/core-surface/contract.ts b/test/integration/test/authoring/parity/core-surface/contract.ts index d975fb0f75..b7bbff9b6f 100644 --- a/test/integration/test/authoring/parity/core-surface/contract.ts +++ b/test/integration/test/authoring/parity/core-surface/contract.ts @@ -7,10 +7,20 @@ import { textColumn, timestamptzColumn, } from '@prisma-next/adapter-postgres/column-types'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, field, model, rel } from '@prisma-next/sql-contract-ts/contract-builder'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { + autoincrement, + defineContract, + field, + model, + rel, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; +const postgresCodecLookup = extractCodecLookup([postgresAdapter]); + const types = { Email: { kind: 'codec-instance', @@ -23,7 +33,7 @@ const types = { const User = model('User', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), email: field.namedType(types.Email).unique(), role: field.namedType(types.Role), createdAt: field.column(timestamptzColumn).defaultSql('now()'), @@ -34,7 +44,7 @@ const User = model('User', { const Post = model('Post', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), userId: field.column(int4Column), title: field.column(textColumn), rating: field.column(float8Column).optional(), @@ -56,6 +66,7 @@ const Post = model('Post', { export const contract = defineContract({ family: sqlFamily, target: postgresPack, + codecLookup: postgresCodecLookup, types, models: { User, diff --git a/test/integration/test/authoring/parity/core-surface/expected.contract.json b/test/integration/test/authoring/parity/core-surface/expected.contract.json index f07ec53d3b..1d5f70131f 100644 --- a/test/integration/test/authoring/parity/core-surface/expected.contract.json +++ b/test/integration/test/authoring/parity/core-surface/expected.contract.json @@ -148,8 +148,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -208,7 +207,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -222,8 +221,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -231,8 +229,8 @@ "isActive": { "codecId": "pg/bool@1", "default": { - "kind": "literal", - "value": true + "expression": "TRUE", + "kind": "expression" }, "nativeType": "bool", "nullable": false @@ -276,7 +274,7 @@ } } }, - "storageHash": "sha256:b6a07cf933f780f0be10605786b9d3434fda472d5327582cbe8dc7bf3551644e", + "storageHash": "sha256:a7d2bd70f2de893a5ca386b1dfe11a2b1ad3153330afe224ce665b90f77b1bc0", "types": { "Email": { "codecId": "pg/text@1", diff --git a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json index e997afe3a2..781971d41f 100644 --- a/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json +++ b/test/integration/test/authoring/parity/default-dbgenerated/expected.contract.json @@ -39,7 +39,7 @@ "codecId": "pg/text@1", "default": { "expression": "gen_random_uuid()", - "kind": "function" + "kind": "expression" }, "nativeType": "text", "nullable": false @@ -55,7 +55,7 @@ } } }, - "storageHash": "sha256:f83367183867bad124b5fc91b8913d1a1e36bccedfba80a471df5751201d63cc" + "storageHash": "sha256:8d5a1931090ec689b5165b7b50fa47c317641003b00ca2fe7af81e8c35c2fd63" }, "capabilities": { "postgres": { diff --git a/test/integration/test/authoring/parity/pgvector-named-type/contract.ts b/test/integration/test/authoring/parity/pgvector-named-type/contract.ts index 66b2616c46..39c116914e 100644 --- a/test/integration/test/authoring/parity/pgvector-named-type/contract.ts +++ b/test/integration/test/authoring/parity/pgvector-named-type/contract.ts @@ -1,6 +1,11 @@ import { int4Column } from '@prisma-next/adapter-postgres/column-types'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, field, model } from '@prisma-next/sql-contract-ts/contract-builder'; +import { + autoincrement, + defineContract, + field, + model, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; const embedding1536Type = { @@ -19,7 +24,7 @@ export const contract = defineContract({ models: { Document: model('Document', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), embedding: field.namedType(embedding1536Type), }, }).sql({ table: 'document' }), diff --git a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json index ef3572cbeb..509b373b81 100644 --- a/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json +++ b/test/integration/test/authoring/parity/pgvector-named-type/expected.contract.json @@ -54,8 +54,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -71,7 +70,7 @@ } } }, - "storageHash": "sha256:923650d8962586ae327c2224a8d4a589abf94ce9465182179bf7939228ef8cef", + "storageHash": "sha256:d59787db380c4912fea23876a391db524a5717e7d2d87e8ddbd3f8df82c2be28", "types": { "Embedding1536": { "codecId": "pg/vector@1", diff --git a/test/integration/test/authoring/side-by-side/postgres/contract.json b/test/integration/test/authoring/side-by-side/postgres/contract.json index d7acbe4d22..f7b0810eb8 100644 --- a/test/integration/test/authoring/side-by-side/postgres/contract.json +++ b/test/integration/test/authoring/side-by-side/postgres/contract.json @@ -151,8 +151,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -211,8 +210,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -235,7 +233,7 @@ } } }, - "storageHash": "sha256:0dd598e2a231ab7b6d81a6d82aed7e54e2894e3f4036809a80e71191086834fa" + "storageHash": "sha256:5bfa8fda93d8540d8baf252e32d130da152f41c09e8f8929af050774c6309246" }, "capabilities": { "postgres": { diff --git a/test/integration/test/authoring/side-by-side/postgres/contract.ts b/test/integration/test/authoring/side-by-side/postgres/contract.ts index 32d40493b7..9d4a2491db 100644 --- a/test/integration/test/authoring/side-by-side/postgres/contract.ts +++ b/test/integration/test/authoring/side-by-side/postgres/contract.ts @@ -4,12 +4,18 @@ import { timestamptzColumn, } from '@prisma-next/adapter-postgres/column-types'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, field, model, rel } from '@prisma-next/sql-contract-ts/contract-builder'; +import { + autoincrement, + defineContract, + field, + model, + rel, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; const UserBase = model('User', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), name: field.column(textColumn), email: field.column(textColumn), bio: field.column(textColumn).optional(), @@ -18,7 +24,7 @@ const UserBase = model('User', { const Post = model('Post', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), authorId: field.column(int4Column), title: field.column(textColumn), publishedAt: field.column(timestamptzColumn).optional(), diff --git a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts index 0e4367a430..31596eefff 100644 --- a/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts +++ b/test/integration/test/fixtures/cli/cli-integration-test-app/fixtures/emit-command/contract.parity.ts @@ -7,10 +7,19 @@ import { textColumn, timestamptzColumn, } from '@prisma-next/adapter-postgres/column-types'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; import sqlFamily from '@prisma-next/family-sql/pack'; -import { defineContract, field, model } from '@prisma-next/sql-contract-ts/contract-builder'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { + autoincrement, + defineContract, + field, + model, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; +const postgresCodecLookup = extractCodecLookup([postgresAdapter]); + const types = { Email: { kind: 'codec-instance', @@ -23,7 +32,7 @@ const types = { const User = model('User', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), email: field.namedType(types.Email).unique(), role: field.namedType(types.Role), createdAt: field.column(timestamptzColumn).defaultSql('now()'), @@ -34,7 +43,7 @@ const User = model('User', { const Post = model('Post', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), userId: field.column(int4Column), title: field.column(textColumn), rating: field.column(float8Column).optional(), @@ -57,6 +66,7 @@ const Post = model('Post', { export const contract = defineContract({ family: sqlFamily, target: postgresPack, + codecLookup: postgresCodecLookup, types, models: { User, diff --git a/test/integration/test/value-objects/fixtures/generated/sql-contract.d.ts b/test/integration/test/value-objects/fixtures/generated/sql-contract.d.ts index 1aebfc51a0..b8f1b64980 100644 --- a/test/integration/test/value-objects/fixtures/generated/sql-contract.d.ts +++ b/test/integration/test/value-objects/fixtures/generated/sql-contract.d.ts @@ -46,8 +46,7 @@ type ContractBase = ContractShape< readonly codecId: 'pg/int4@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; + readonly kind: 'autoincrement'; }; }; readonly name: { diff --git a/test/integration/test/value-objects/fixtures/generated/sql-contract.json b/test/integration/test/value-objects/fixtures/generated/sql-contract.json index 368944ccfb..56518536b5 100644 --- a/test/integration/test/value-objects/fixtures/generated/sql-contract.json +++ b/test/integration/test/value-objects/fixtures/generated/sql-contract.json @@ -70,8 +70,7 @@ "nativeType": "int4", "nullable": false, "default": { - "kind": "function", - "expression": "autoincrement()" + "kind": "autoincrement" } }, "name": { From 73050f1c027413524ac94dcbdf21374f0eeb4824 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:28:58 +0200 Subject: [PATCH 27/50] fix(demo)!: flip migration-history snapshots to new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update 7 committed migration snapshots under `examples/prisma-next-demo/migrations/app/**` to use the new discriminated `{ kind: "expression" | "autoincrement" }` shape rather than the legacy `{ kind: "literal" | "function" }` carriers. Three flips applied per snapshot: - `{ kind: "function", expression: "now()" }` → `{ kind: "expression", expression: "now()" }` - `{ kind: "literal", value: "open" }` → `{ kind: "expression", expression: "'open'" }` (pg/text@1 `renderSqlLiteral` rendering: single-quoted body, with embedded single quotes doubled per `escapePgLiteralBody`). The carve-out in `packages/3-targets/3-targets/postgres/test/snapshot-read-shapes.test.ts` that excludes `examples/prisma-next-demo/migrations/**` from the strict family-deserialization scan is **kept** in place: the snapshots still carry the legacy pre-namespace storage shape (flat `storage.tables`, untagged `storage.types` entries lacking the post-TML-2583 `kind: "codec-instance" | "postgres-enum"` tag) that the strict scan rejects. Removing the carve-out requires a separate re-baseline of historical migration snapshots, which is out of scope for the codec-owned-defaults slice. --- .../app/20260422T0720_initial/end-contract.json | 16 ++++++++-------- .../20260422T0742_migration/end-contract.json | 16 ++++++++-------- .../20260422T0742_migration/start-contract.json | 16 ++++++++-------- .../20260422T0748_migration/end-contract.json | 16 ++++++++-------- .../20260422T0748_migration/start-contract.json | 16 ++++++++-------- .../end-contract.json | 16 ++++++++-------- .../start-contract.json | 16 ++++++++-------- 7 files changed, 56 insertions(+), 56 deletions(-) diff --git a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.json index fb2a865bb6..0f430718ab 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.json @@ -403,8 +403,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -456,8 +456,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -478,8 +478,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -527,8 +527,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.json index 4bf16cd064..b129cb8305 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.json @@ -413,8 +413,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -466,8 +466,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -488,8 +488,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -537,8 +537,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.json index fb2a865bb6..0f430718ab 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.json @@ -403,8 +403,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -456,8 +456,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -478,8 +478,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -527,8 +527,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.json index 208e0efb3e..d1f0e885c8 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.json @@ -413,8 +413,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -466,8 +466,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -488,8 +488,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -537,8 +537,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.json b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.json index 4bf16cd064..b129cb8305 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.json @@ -413,8 +413,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -466,8 +466,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -488,8 +488,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -537,8 +537,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.json b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.json index e10d490d70..9ddc10409a 100644 --- a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.json @@ -434,8 +434,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -493,8 +493,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -515,8 +515,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -570,8 +570,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false diff --git a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.json b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.json index 208e0efb3e..d1f0e885c8 100644 --- a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.json +++ b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.json @@ -413,8 +413,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -466,8 +466,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false @@ -488,8 +488,8 @@ "status": { "codecId": "pg/text@1", "default": { - "kind": "literal", - "value": "open" + "kind": "expression", + "expression": "'open'" }, "nativeType": "text", "nullable": false @@ -537,8 +537,8 @@ "createdAt": { "codecId": "pg/timestamptz@1", "default": { - "expression": "now()", - "kind": "function" + "kind": "expression", + "expression": "now()" }, "nativeType": "timestamptz", "nullable": false From 7f1e7f36bb1a466b5177dfe4deca481ac424a6f2 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:31:57 +0200 Subject: [PATCH 28/50] fix(sql-contract-emitter)!: emit new ColumnDefault union shape in generated .d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .d.ts emitter still generated the legacy '{ kind: "literal"; value: DefaultLiteralValue<…> }' and '{ kind: "function"; expression: "…" }' carriers, which broke after the SQL-contract IR collapsed those two arms into '{ kind: "expression"; expression: "…" } | { kind: "autoincrement" }'. The emitter's compile-time switch on 'col.default.kind === "literal"' became a type error because the IR no longer carries 'literal'. Flip to: - '{ kind: "autoincrement" }' (no payload) for the auto-increment arm; - '{ kind: "expression"; expression: }' for every other expression. The 'DefaultLiteralValue' helper type emitted from 'getFamilyTypeAliases' was only ever referenced by the now-deleted literal branch; remove its emission so generated contract.d.ts files do not carry a dead local type alias. --- packages/2-sql/3-tooling/emitter/src/index.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/2-sql/3-tooling/emitter/src/index.ts b/packages/2-sql/3-tooling/emitter/src/index.ts index ace799460c..a4b26d884a 100644 --- a/packages/2-sql/3-tooling/emitter/src/index.ts +++ b/packages/2-sql/3-tooling/emitter/src/index.ts @@ -335,10 +335,6 @@ export const sqlEmission = { return [ 'export type LaneCodecTypes = CodecTypes;', `export type QueryOperationTypes = ${queryOperationTypes};`, - 'type DefaultLiteralValue =', - ' CodecId extends keyof CodecTypes', - " ? CodecTypes[CodecId]['output']", - ' : _Encoded;', ].join('\n'); }, @@ -445,11 +441,9 @@ function generateTableLiteralType(table: StorageTable): string { const nativeType = serializeValue(col.nativeType); const codecId = serializeValue(col.codecId); const defaultSpec = col.default - ? col.default.kind === 'literal' - ? `; readonly default: { readonly kind: 'literal'; readonly value: DefaultLiteralValue<${codecId}, ${serializeValue( - col.default.value, - )}> }` - : `; readonly default: { readonly kind: 'function'; readonly expression: ${serializeValue( + ? col.default.kind === 'autoincrement' + ? `; readonly default: { readonly kind: 'autoincrement' }` + : `; readonly default: { readonly kind: 'expression'; readonly expression: ${serializeValue( col.default.expression, )} }` : ''; From b2bc891c86f450f990767c6e2dbe5ca1ae38b744 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:43:53 +0200 Subject: [PATCH 29/50] fix(framework-components): surface optional traits on ColumnTypeDescriptor and adapter int columns The trait-gated `.default(autoincrement())` machinery on the SQL TS DSL reads `Descriptor.traits` at compile time to decide whether to admit the autoincrement sentinel. Two surfaces left it unreachable end-to- end: 1. `ColumnTypeDescriptor` (the framework-components-level authoring shape) did not declare a `traits` slot. The trait gate read it via the contract-ts-internal `FieldDescriptorShape = ColumnTypeDescriptor & { readonly traits?: readonly CodecTrait[] }` intersection. Under `exactOptionalPropertyTypes: true`, that intersection is not structurally compatible with `ColumnSpec.traits: T | undefined` (which is required on `ColumnSpec`), so `field.column(spec)` rejected `ColumnSpec` instances that explicitly set `traits: undefined` (the path every non-trait codec helper takes). Fix: lift `traits?: readonly CodecTrait[] | undefined` onto `ColumnTypeDescriptor` itself, and collapse `FieldDescriptorShape` into a plain alias of `ColumnTypeDescriptor`. The DSL still reads the trait via the `Descriptor` generic, but the shape is now uniform across bare descriptors and `column()`-packaged specs. 2. The adapter-package column descriptors (`@prisma-next/adapter-postgres/column-types` and `@prisma-next/adapter-sqlite/column-types`) declared no traits, so `.default(autoincrement())` against `int4Column` / `int2Column` / `int8Column` / `integerColumn` collapsed the sentinel input type to `never` and the call site reported `AutoincrementSentinel is not assignable to never`. Add the matching `traits` tuple to those four descriptors (mirroring the descriptor declarations in `target-postgres/codecs.ts` / `target-sqlite/codecs.ts`) so the trait gate admits the sentinel on real authoring sites. --- .../1-core/framework-components/src/shared/column-spec.ts | 1 + packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts | 4 +--- .../3-targets/6-adapters/postgres/src/exports/column-types.ts | 3 +++ packages/3-targets/6-adapters/sqlite/src/core/column-types.ts | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts b/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts index eea013f2f6..286d5712e2 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts @@ -21,6 +21,7 @@ export type ColumnTypeDescriptor = { readonly nativeType: string; readonly typeParams?: Record | undefined; readonly typeRef?: string; + readonly traits?: readonly CodecTrait[] | undefined; }; /** diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts index 910754ffdf..af5624ffcd 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts @@ -105,9 +105,7 @@ type NamedConstraintNameSpec = { readonly name: Name; }; -type FieldDescriptorShape = ColumnTypeDescriptor & { - readonly traits?: readonly CodecTrait[]; -}; +type FieldDescriptorShape = ColumnTypeDescriptor; export type ScalarFieldState< CodecId extends string = string, diff --git a/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts b/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts index 197f75de01..e3c8be6a30 100644 --- a/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts +++ b/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts @@ -58,16 +58,19 @@ export function varcharColumn(length: number): ColumnTypeDescriptor & { export const int4Column = { codecId: PG_INT4_CODEC_ID, nativeType: 'int4', + traits: ['equality', 'order', 'numeric', 'autoincrement'], } as const satisfies ColumnTypeDescriptor; export const int2Column = { codecId: PG_INT2_CODEC_ID, nativeType: 'int2', + traits: ['equality', 'order', 'numeric', 'autoincrement'], } as const satisfies ColumnTypeDescriptor; export const int8Column = { codecId: PG_INT8_CODEC_ID, nativeType: 'int8', + traits: ['equality', 'order', 'numeric', 'autoincrement'], } as const satisfies ColumnTypeDescriptor; export const float4Column = { diff --git a/packages/3-targets/6-adapters/sqlite/src/core/column-types.ts b/packages/3-targets/6-adapters/sqlite/src/core/column-types.ts index 2f8622cf24..88d0f95bc4 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/column-types.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/column-types.ts @@ -16,6 +16,7 @@ export const textColumn = { export const integerColumn = { codecId: SQLITE_INTEGER_CODEC_ID, nativeType: 'integer', + traits: ['equality', 'order', 'numeric', 'autoincrement'], } as const; export const realColumn = { From 195ec100a8d3371c8fda5f76ba70ce42bc13f368 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:44:09 +0200 Subject: [PATCH 30/50] fix(extension-arktype-json): thread descriptor.traits through arktypeJsonColumn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the framework-components widening that surfaces `traits` on `ColumnSpec` (and downstream `ColumnTypeDescriptor`), the per-codec column helper for the arktype-json extension still called `column(..., 'jsonb')` without a trailing `traits` argument. The resulting `ColumnSpec` carried `traits: undefined` instead of the descriptor's literal `['equality']` tuple — losing the trait surface that production helpers (`pgInt4Column`, `pgTextColumn`, etc.) preserve. Pass `arktypeJsonDescriptor.traits` as the fifth argument to `column(...)` so the trait tuple flows through the static type. The return-type annotation parameterises the third `ColumnSpec` slot on the descriptor's traits, and the matching type test in `arktype-json-codec.types.test-d.ts` is updated to assert the narrowed shape. --- .../arktype-json/src/core/arktype-json-codec.ts | 7 ++++++- .../arktype-json/test/arktype-json-codec.types.test-d.ts | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts b/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts index 986795cc6e..2de1aaa01d 100644 --- a/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts +++ b/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts @@ -241,7 +241,11 @@ export const arktypeJsonDescriptor = new ArktypeJsonDescriptor(); */ export function arktypeJsonColumn>( schema: S, -): ColumnSpec, ArktypeJsonTypeParams> { +): ColumnSpec< + ArktypeJsonCodecClass, + ArktypeJsonTypeParams, + typeof arktypeJsonDescriptor.traits +> { if (!isArktypeSchemaLike(schema)) { throw new Error( typeof schema !== 'function' @@ -260,6 +264,7 @@ export function arktypeJsonColumn>( arktypeJsonDescriptor.codecId, params, ARKTYPE_JSON_NATIVE_TYPE, + arktypeJsonDescriptor.traits, ); } diff --git a/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts b/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts index 1127fbd6f2..dc44e81b1e 100644 --- a/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts +++ b/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts @@ -114,6 +114,10 @@ test('arktypeJsonColumn: result is ColumnSpec with typed codecFactory', () => { const ProductSchema = type({ name: 'string', price: 'number' }); const col = arktypeJsonColumn(ProductSchema); expectTypeOf(col).toExtend< - ColumnSpec, ArktypeJsonTypeParams> + ColumnSpec< + ArktypeJsonCodecClass<{ name: string; price: number }>, + ArktypeJsonTypeParams, + typeof arktypeJsonDescriptor.traits + > >(); }); From c9234099f44f18a0fb3dce2c315c9c4f1fed9d2a Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:44:24 +0200 Subject: [PATCH 31/50] test(extension-pgvector): flip planner test fixtures to new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pgvector planner test files still carried legacy `{ kind: 'literal' | 'function' }` shapes inline and pulled `ColumnDefaultLiteralInputValue` from `@prisma-next/contract/types` — both surfaces went away with the SQL-contract IR collapse. Flip the inline test fixtures onto the new `{ kind: 'expression', expression }` shape and drop the now-deleted type import. For the planner.behavior test, also flip the only `kind: 'literal'` default fixture to its rendered Postgres-SQL-literal form (`true` for `pg/bool@1` — matches the expected DDL the test was already asserting). --- .../pgvector/test/migrations/planner.behavior.test.ts | 11 +++-------- .../migrations/planner.contract-to-schema-ir.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts index 945b27bfb4..38c3f6c5a4 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts @@ -1,9 +1,4 @@ -import { - type ColumnDefaultLiteralInputValue, - type Contract, - coreHash, - profileHash, -} from '@prisma-next/contract/types'; +import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types'; import { type CodecControlHooks, INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; @@ -459,7 +454,7 @@ describe('NOT NULL column without default uses temporary default', () => { nativeType: 'bool', codecId: 'pg/bool@1', nullable: false, - default: { kind: 'literal', value: true }, + default: { kind: 'expression', expression: 'true' }, }); expect(addCol.execute.map((step) => step.sql)).toEqual([ @@ -608,7 +603,7 @@ function planAddColumn( nullable: boolean; typeParams?: Record; typeRef?: string; - default?: { kind: 'literal'; value: ColumnDefaultLiteralInputValue }; + default?: { kind: 'expression'; expression: string } | { kind: 'autoincrement' }; }, options?: { frameworkComponents?: ReadonlyArray>; diff --git a/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts index a532a281c3..d8d9204c87 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.contract-to-schema-ir.test.ts @@ -265,13 +265,13 @@ describe('contractToSchemaIR → planner round-trip', () => { nativeType: 'text', codecId: 'pg/text@1', nullable: false, - default: { kind: 'literal', value: 'active' }, + default: { kind: 'expression', expression: "'active'" }, }, createdAt: { nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, }, primaryKey: { columns: ['id'] }, @@ -713,7 +713,7 @@ const DEMO_BASE_TABLES = { createdAt: col({ nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }), kind: col({ nativeType: 'user_type', @@ -740,7 +740,7 @@ const DEMO_BASE_TABLES = { createdAt: col({ nativeType: 'timestamptz', codecId: 'pg/timestamptz@1', - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }), embedding: col({ nativeType: 'vector', From f59b6db59e66ef27e289ddaa8b06a27e1ca106ba Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:44:33 +0200 Subject: [PATCH 32/50] test(extension-sql-orm-client): flip inline contract fixture to new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CreateInput type test composed an inline `Contract<...>` shape with two columns carrying legacy `{ kind: 'function'; expression }` defaults. After the IR collapse those arms typecheck as `expression | autoincrement` only; flip both call sites to the new shape (the column defaults stay semantically equivalent — `now()` and `nextval('user_id_seq'::regclass)` are arbitrary SQL expressions). The CreateInput assertions the test pins are unchanged. --- .../3-extensions/sql-orm-client/test/create-input.test-d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/3-extensions/sql-orm-client/test/create-input.test-d.ts b/packages/3-extensions/sql-orm-client/test/create-input.test-d.ts index b9f86defa1..5a6045b71b 100644 --- a/packages/3-extensions/sql-orm-client/test/create-input.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/create-input.test-d.ts @@ -15,7 +15,7 @@ type CreateInputContract = Contract< codecId: 'pg/int4@1'; nullable: false; default: { - kind: 'function'; + kind: 'expression'; expression: "nextval('user_id_seq'::regclass)"; }; }; @@ -27,7 +27,7 @@ type CreateInputContract = Contract< codecId: 'pg/text@1'; nullable: false; default: { - kind: 'function'; + kind: 'expression'; expression: 'now()'; }; }; From 9aaa538e2690a1aba9d38fbb56d121fd0dd9de8a Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:45:21 +0200 Subject: [PATCH 33/50] fix(fixtures)!: re-emit generated contract.d.ts onto new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D7's fixture regeneration ran against the legacy `generateTableLiteralType` branches in `@prisma-next/sql-contract-emitter` (the prior commit fixed those), so every `contract.d.ts` it produced still carried `{ kind: 'function'; expression }` and `{ kind: 'literal'; value: DefaultLiteralValue<...> }` arms — which no longer typecheck against the new SQL-contract `ColumnDefault` union. Re-run `pnpm fixtures:emit` against the now-fixed emitter so: - Generated `contract.d.ts` files emit `{ kind: 'expression'; expression }` (or `{ kind: 'autoincrement' }`) instead of the legacy carriers. - The unused `type DefaultLiteralValue` family- level type alias no longer appears at the top of every generated `contract.d.ts` (the literal branch that referenced it was removed). Storage-hash recomputes are not in play here (only `.d.ts` files changed); the underlying `contract.json` files were already emitted correctly by D7. --- .../src/prisma/contract.d.ts | 3 -- .../paradedb-demo/src/prisma/contract.d.ts | 3 -- .../src/prisma/contract.d.ts | 11 ++---- .../src/prisma/contract.d.ts | 7 +--- .../prisma-next-demo/src/prisma/contract.d.ts | 11 ++---- .../src/prisma/contract.d.ts | 3 -- .../src/prisma/contract.d.ts | 7 +--- .../test/fixtures/generated/contract.d.ts | 3 -- .../cipherstash/src/contract.d.ts | 3 -- .../3-extensions/paradedb/src/contract.d.ts | 3 -- .../3-extensions/pgvector/src/contract.d.ts | 3 -- .../3-extensions/postgis/src/contract.d.ts | 3 -- .../test/fixtures/generated/contract.d.ts | 3 -- .../test/fixtures/generated/contract.d.ts | 39 +++++++++---------- .../sqlite/fixtures/generated/contract.d.ts | 5 +-- test/integration/test/fixtures/contract.d.ts | 3 -- .../fixtures/generated/contract.d.ts | 3 -- 17 files changed, 31 insertions(+), 82 deletions(-) diff --git a/examples/cipherstash-integration/src/prisma/contract.d.ts b/examples/cipherstash-integration/src/prisma/contract.d.ts index a676ac70e7..f6b1b54413 100644 --- a/examples/cipherstash-integration/src/prisma/contract.d.ts +++ b/examples/cipherstash-integration/src/prisma/contract.d.ts @@ -44,9 +44,6 @@ export type CodecTypes = PgTypes & CipherstashTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & CipherstashQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly User: { diff --git a/examples/paradedb-demo/src/prisma/contract.d.ts b/examples/paradedb-demo/src/prisma/contract.d.ts index 5f145b1621..c0d564ae40 100644 --- a/examples/paradedb-demo/src/prisma/contract.d.ts +++ b/examples/paradedb-demo/src/prisma/contract.d.ts @@ -37,9 +37,6 @@ export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & ParadeDbQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Item: { diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts index 1c7345a5b7..4c340876f3 100644 --- a/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts +++ b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts @@ -36,9 +36,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -187,7 +184,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -233,7 +230,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: "'open'::text"; }; }; @@ -251,7 +248,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -296,7 +293,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts b/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts index 33c2981a6d..e80ab08f44 100644 --- a/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts @@ -24,9 +24,6 @@ export type ProfileHash = export type CodecTypes = SqliteTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = Record; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Post: { @@ -93,7 +90,7 @@ type ContractBase = ContractType< readonly nativeType: 'text'; readonly codecId: 'sqlite/datetime@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -139,7 +136,7 @@ type ContractBase = ContractType< readonly nativeType: 'text'; readonly codecId: 'sqlite/datetime@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; diff --git a/examples/prisma-next-demo/src/prisma/contract.d.ts b/examples/prisma-next-demo/src/prisma/contract.d.ts index 7000794d0f..a2a2315f74 100644 --- a/examples/prisma-next-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo/src/prisma/contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -193,7 +190,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -245,7 +242,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: "'open'::text"; }; }; @@ -263,7 +260,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -308,7 +305,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts b/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts index 35a3e903a0..bc5dfb7f74 100644 --- a/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PostgisTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PostgisQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Cafe: { diff --git a/examples/react-router-demo/src/prisma/contract.d.ts b/examples/react-router-demo/src/prisma/contract.d.ts index 79fc2748b1..0f8cbfef18 100644 --- a/examples/react-router-demo/src/prisma/contract.d.ts +++ b/examples/react-router-demo/src/prisma/contract.d.ts @@ -36,9 +36,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Post: { @@ -102,7 +99,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -142,7 +139,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; diff --git a/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts b/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts index 8cf745a622..bb3604dd3a 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Article: { readonly id: Char<36>; readonly title: CodecTypes['pg/text@1']['output'] }; diff --git a/packages/3-extensions/cipherstash/src/contract.d.ts b/packages/3-extensions/cipherstash/src/contract.d.ts index 851a6f7ce1..8c60d60cfb 100644 --- a/packages/3-extensions/cipherstash/src/contract.d.ts +++ b/packages/3-extensions/cipherstash/src/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly EqlV2Configuration: { diff --git a/packages/3-extensions/paradedb/src/contract.d.ts b/packages/3-extensions/paradedb/src/contract.d.ts index 989e081442..a7d94ed9c7 100644 --- a/packages/3-extensions/paradedb/src/contract.d.ts +++ b/packages/3-extensions/paradedb/src/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = Record; export type FieldInputTypes = Record; diff --git a/packages/3-extensions/pgvector/src/contract.d.ts b/packages/3-extensions/pgvector/src/contract.d.ts index bd84f5b729..627347b271 100644 --- a/packages/3-extensions/pgvector/src/contract.d.ts +++ b/packages/3-extensions/pgvector/src/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = Record; export type FieldInputTypes = Record; diff --git a/packages/3-extensions/postgis/src/contract.d.ts b/packages/3-extensions/postgis/src/contract.d.ts index dc605bd014..1ba93a79fd 100644 --- a/packages/3-extensions/postgis/src/contract.d.ts +++ b/packages/3-extensions/postgis/src/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = Record; export type FieldInputTypes = Record; diff --git a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts index 0747b7be25..4861e7727b 100644 --- a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts +++ b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; diff --git a/test/e2e/framework/test/fixtures/generated/contract.d.ts b/test/e2e/framework/test/fixtures/generated/contract.d.ts index ee3c8cf237..bd1271774d 100644 --- a/test/e2e/framework/test/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/fixtures/generated/contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes & ArktypeJsonTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Comment: { @@ -182,7 +179,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/int4@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: 'autoincrement()'; }; }; @@ -200,7 +197,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly update_at: { readonly nativeType: 'timestamptz'; @@ -220,7 +217,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/int4@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: 'autoincrement()'; }; }; @@ -269,7 +266,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: "'2024-01-15T10:30:00.000Z'::timestamp with time zone"; }; }; @@ -277,7 +274,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -292,7 +289,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/int4@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: 'autoincrement()'; }; }; @@ -301,7 +298,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: "'draft'::text"; }; }; @@ -309,26 +306,26 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: '0' }; + readonly default: { readonly kind: 'expression'; readonly expression: '0' }; }; readonly rating: { readonly nativeType: 'float8'; readonly codecId: 'pg/float8@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: '3.14' }; + readonly default: { readonly kind: 'expression'; readonly expression: '3.14' }; }; readonly active: { readonly nativeType: 'bool'; readonly codecId: 'pg/bool@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'TRUE' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'TRUE' }; }; readonly big_count: { readonly nativeType: 'int8'; readonly codecId: 'pg/int8@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: '9007199254740991'; }; }; @@ -337,7 +334,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/jsonb@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: '\'{"key":"default"}\'::jsonb'; }; }; @@ -346,7 +343,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/jsonb@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: '\'["alpha","beta"]\'::jsonb'; }; }; @@ -363,7 +360,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/int4@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: 'autoincrement()'; }; }; @@ -434,7 +431,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/int4@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: 'autoincrement()'; }; }; @@ -452,7 +449,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly update_at: { readonly nativeType: 'timestamptz'; @@ -482,7 +479,7 @@ type ContractBase = ContractType< readonly codecId: 'pg/int4@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; + readonly kind: 'expression'; readonly expression: 'autoincrement()'; }; }; @@ -496,7 +493,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly update_at: { readonly nativeType: 'timestamptz'; diff --git a/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts b/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts index e297d4d187..09d4d7c743 100644 --- a/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts @@ -23,9 +23,6 @@ export type ProfileHash = export type CodecTypes = SqliteTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = Record; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Comment: { @@ -152,7 +149,7 @@ type ContractBase = ContractType< readonly nativeType: 'text'; readonly codecId: 'sqlite/text@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: "'unnamed'" }; + readonly default: { readonly kind: 'expression'; readonly expression: "'unnamed'" }; }; }; primaryKey: { readonly columns: readonly ['id'] }; diff --git a/test/integration/test/fixtures/contract.d.ts b/test/integration/test/fixtures/contract.d.ts index a341a84817..5950a06d97 100644 --- a/test/integration/test/fixtures/contract.d.ts +++ b/test/integration/test/fixtures/contract.d.ts @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly User: { diff --git a/test/integration/test/sql-builder/fixtures/generated/contract.d.ts b/test/integration/test/sql-builder/fixtures/generated/contract.d.ts index 8cf745a622..bb3604dd3a 100644 --- a/test/integration/test/sql-builder/fixtures/generated/contract.d.ts +++ b/test/integration/test/sql-builder/fixtures/generated/contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly Article: { readonly id: Char<36>; readonly title: CodecTypes['pg/text@1']['output'] }; From 956ea0cf93dd42094c8993feda4d5e0d77110de7 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:47:52 +0200 Subject: [PATCH 34/50] style: satisfy biome lint hints introduced by new ColumnDefault dispatch sites Two warnings from M2 dispatch sites surfaced under \`biome check --error-on-warnings\`: - \`packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts\` carried \`if (!context || !context.isIntegerPrimaryKey)\` (D6 INTEGER-PK gate). Collapse to optional chain \`if (!context?.isIntegerPrimaryKey)\` to match biome's \`useOptionalChain\` rule. - \`packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts\` carried \`if (!traits || !traits.includes(...))\` (D3 autoincrement-trait gate). Collapse to \`if (!traits?.includes(...))\`. Also reorganise the two adjacent \`framework-components/authoring\` type-only imports into a single sorted block (biome \`organizeImports\`). --- .../2-authoring/contract-psl/src/psl-column-resolution.ts | 4 ++-- .../sqlite/src/core/migrations/planner-ddl-builders.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index 675b4e1cd2..efdcb50cf7 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -1,12 +1,12 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; import type { ExecutionMutationDefaultPhases, JsonValue } from '@prisma-next/contract/types'; import type { + AuthoringColumnDefault, AuthoringContributions, AuthoringEntityTypeDescriptor, AuthoringFieldPresetDescriptor, AuthoringTypeConstructorDescriptor, } from '@prisma-next/framework-components/authoring'; -import type { AuthoringColumnDefault } from '@prisma-next/framework-components/authoring'; import { hasRegisteredFieldNamespace, instantiateAuthoringFieldPreset, @@ -858,7 +858,7 @@ function tryRecogniseAutoincrementParseTime(input: { return { ok: true, value: undefined }; } const traits = resolveCodecTraits(input.codecLookup, input.codecId); - if (!traits || !traits.includes('autoincrement')) { + if (!traits?.includes('autoincrement')) { input.diagnostics.push({ code: 'PSL_INVALID_DEFAULT_APPLICABILITY', message: `Field "${input.modelName}.${input.fieldName}" @default(autoincrement()) requires a codec with the "autoincrement" trait; codec "${input.codecId}" does not carry it.`, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts index 4ffca26553..9fcb5fc08b 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts @@ -69,7 +69,7 @@ export function buildColumnDefaultSql( switch (columnDefault.kind) { case 'autoincrement': { - if (!context || !context.isIntegerPrimaryKey) { + if (!context?.isIntegerPrimaryKey) { const columnPath = context ? `${context.tableName}.${context.columnName}` : ''; throw new Error( `Column "${columnPath}" has kind 'autoincrement' but is not an INTEGER PRIMARY KEY. ` + From 286df49656aa1095965e9487f44d66ffd49021be Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 22:55:39 +0200 Subject: [PATCH 35/50] test(extension-pgvector): update DDL expectation for parenthesised DEFAULT clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the Postgres DDL renderer was simplified to emit `DEFAULT (${expression})` for the `kind: 'expression'` arm (D5 collapses the legacy literal-vs-function branching), the `pg/bool@1` default `true` now renders as `ADD COLUMN "active" bool DEFAULT (true) NOT NULL` rather than the previous `DEFAULT true` (unparenthesised) shape. The behavioural semantics are unchanged — Postgres treats both forms identically — but the test's literal-string equality assertion needs the parenthesised expectation. --- .../pgvector/test/migrations/planner.behavior.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts index 38c3f6c5a4..34d8ec3979 100644 --- a/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts +++ b/packages/3-extensions/pgvector/test/migrations/planner.behavior.test.ts @@ -458,7 +458,7 @@ describe('NOT NULL column without default uses temporary default', () => { }); expect(addCol.execute.map((step) => step.sql)).toEqual([ - `ALTER TABLE ${qualifiedUserTable} ADD COLUMN "active" bool DEFAULT true NOT NULL`, + `ALTER TABLE ${qualifiedUserTable} ADD COLUMN "active" bool DEFAULT (true) NOT NULL`, ]); }); }); From f4aea3884cfc85fb8c89c85d5fbec3e986659f2c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 23:15:12 +0200 Subject: [PATCH 36/50] test(e2e): migrate Postgres fixture to autoincrement() sentinel + wire codecLookup into SQLite migration harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two source-contract updates to match the post-M2 authoring surface: 1. `test/e2e/framework/test/fixtures/contract.ts` — the Postgres e2e fixture's ID columns still authored `.defaultSql('autoincrement()')`, which now writes a literal `autoincrement()` SQL expression at the storage layer (D5's renderer wraps `kind: 'expression'` arms in `DEFAULT (${expression})` so the resulting DDL is `DEFAULT (autoincrement())` — a call to a nonexistent Postgres function). Migrate every authoring site to the sentinel form `.default(autoincrement())` so the resulting contract carries `{ kind: 'autoincrement' }` and the Postgres DDL renderer correctly emits SERIAL / IDENTITY column types. Re-run `pnpm fixtures:emit` to regenerate the matching `fixtures/generated/contract.{json,d.ts}`. 2. `test/e2e/framework/test/sqlite/migrations/harness.ts` — the SQLite migration-test harness's shared `pack` did not supply a `codecLookup`. Post-M2, `.default(value)` emits through `codec.renderSqlLiteral(value)` at build time, which requires a resolvable codec lookup. Wire `extractCodecLookup([sqliteAdapter])` into `pack` so the per-test `defineContract({ ...pack, ... })` calls inherit the lookup. --- test/e2e/framework/test/fixtures/contract.ts | 20 ++++++++---- .../test/fixtures/generated/contract.d.ts | 32 ++++--------------- .../test/fixtures/generated/contract.json | 20 ++++-------- .../test/sqlite/migrations/harness.ts | 9 +++++- 4 files changed, 35 insertions(+), 46 deletions(-) diff --git a/test/e2e/framework/test/fixtures/contract.ts b/test/e2e/framework/test/fixtures/contract.ts index 91caa5c3c3..907c6d9f79 100644 --- a/test/e2e/framework/test/fixtures/contract.ts +++ b/test/e2e/framework/test/fixtures/contract.ts @@ -24,7 +24,13 @@ import pgvectorPack from '@prisma-next/extension-pgvector/pack'; import sqlFamily from '@prisma-next/family-sql/pack'; import { extractCodecLookup } from '@prisma-next/framework-components/control'; import { uuidv7 } from '@prisma-next/ids'; -import { defineContract, field, model, rel } from '@prisma-next/sql-contract-ts/contract-builder'; +import { + autoincrement, + defineContract, + field, + model, + rel, +} from '@prisma-next/sql-contract-ts/contract-builder'; import postgresPack from '@prisma-next/target-postgres/pack'; import { type } from 'arktype'; @@ -37,7 +43,7 @@ const profileSchema = type({ const UserBase = model('User', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), email: field.column(varcharColumn(255)).unique({ name: 'user_email_key' }), createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'), updatedAt: field.column(timestamptzColumn).optional().column('update_at'), @@ -47,7 +53,7 @@ const UserBase = model('User', { const PostBase = model('Post', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), userId: field.column(int4Column), title: field.column(textColumn), createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'), @@ -59,7 +65,7 @@ const PostBase = model('Post', { const Comment = model('Comment', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), postId: field.column(int4Column), content: field.column(textColumn), createdAt: field.column(timestamptzColumn).defaultSql('now()').column('created_at'), @@ -99,7 +105,7 @@ export const contract = defineContract({ ParamTypes: model('ParamTypes', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), name: field.column(varcharColumn(255)).optional(), code: field.column(charColumn(16)).optional(), price: field.column(numericColumn(10, 2)).optional(), @@ -129,7 +135,7 @@ export const contract = defineContract({ LiteralDefaults: model('LiteralDefaults', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), label: field.column(textColumn).default('draft'), score: field.column(int4Column).default(0), rating: field.column(float8Column).default(3.14), @@ -142,7 +148,7 @@ export const contract = defineContract({ Embedding: model('Embedding', { fields: { - id: field.column(int4Column).defaultSql('autoincrement()').id(), + id: field.column(int4Column).default(autoincrement()).id(), embedding: field.column(vector(1536)), profile: field.column(arktypeJson(profileSchema)), }, diff --git a/test/e2e/framework/test/fixtures/generated/contract.d.ts b/test/e2e/framework/test/fixtures/generated/contract.d.ts index bd1271774d..3a93d954e9 100644 --- a/test/e2e/framework/test/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/fixtures/generated/contract.d.ts @@ -31,7 +31,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:06e84ab8bb0d664611300b6e4a576f055a560de08091c2de1b6387d836b773e2'>; + StorageHashBase<'sha256:2a8f1be899b83214689b90fd64943b9007becceadbaca802522bdadb29854f69'>; export type ExecutionHash = ExecutionHashBase<'sha256:adc296c2bde14cd4e6a8a85ba202108dc7a320b5870a14d7dd8e2d2e2f5a7f27'>; export type ProfileHash = @@ -178,10 +178,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'expression'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly postId: { readonly nativeType: 'int4'; @@ -216,10 +213,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'expression'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -288,10 +282,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'expression'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly label: { readonly nativeType: 'text'; @@ -359,10 +350,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'expression'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly name: { readonly nativeType: 'character varying'; @@ -430,10 +418,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'expression'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly userId: { readonly nativeType: 'int4'; @@ -478,10 +463,7 @@ type ContractBase = ContractType< readonly nativeType: 'int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; - readonly default: { - readonly kind: 'expression'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly email: { readonly nativeType: 'character varying'; diff --git a/test/e2e/framework/test/fixtures/generated/contract.json b/test/e2e/framework/test/fixtures/generated/contract.json index 58f6b2f1e0..05649c6f7d 100644 --- a/test/e2e/framework/test/fixtures/generated/contract.json +++ b/test/e2e/framework/test/fixtures/generated/contract.json @@ -633,8 +633,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "expression" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -672,8 +671,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "expression" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -775,8 +773,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "expression" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -881,8 +878,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "expression" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -944,8 +940,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "expression" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -1007,8 +1002,7 @@ "id": { "codecId": "pg/int4@1", "default": { - "expression": "autoincrement()", - "kind": "expression" + "kind": "autoincrement" }, "nativeType": "int4", "nullable": false @@ -1043,7 +1037,7 @@ } } }, - "storageHash": "sha256:06e84ab8bb0d664611300b6e4a576f055a560de08091c2de1b6387d836b773e2" + "storageHash": "sha256:2a8f1be899b83214689b90fd64943b9007becceadbaca802522bdadb29854f69" }, "execution": { "executionHash": "sha256:adc296c2bde14cd4e6a8a85ba202108dc7a320b5870a14d7dd8e2d2e2f5a7f27", diff --git a/test/e2e/framework/test/sqlite/migrations/harness.ts b/test/e2e/framework/test/sqlite/migrations/harness.ts index 897faf9f8b..6bb6353a4c 100644 --- a/test/e2e/framework/test/sqlite/migrations/harness.ts +++ b/test/e2e/framework/test/sqlite/migrations/harness.ts @@ -15,6 +15,7 @@ import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import { APP_SPACE_ID, createControlStack, + extractCodecLookup, type MigrationOperationPolicy, } from '@prisma-next/framework-components/control'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; @@ -37,7 +38,13 @@ const familyInstance = sqlFamilyDescriptor.create( const fw = [sqliteTargetDescriptor, sqliteAdapterDescriptor, sqliteDriverDescriptor] as const; -export const pack = { family: sqlFamilyPack, target: sqlitePack } as const; +const sqliteCodecLookup = extractCodecLookup([sqliteAdapterDescriptor]); + +export const pack = { + family: sqlFamilyPack, + target: sqlitePack, + codecLookup: sqliteCodecLookup, +} as const; export const int = field.column(integerColumn); export const text = field.column(textColumn); export { integerColumn, textColumn }; From 993de0a071dacf5f3cfe60f29d463e11f42416a3 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 23:18:14 +0200 Subject: [PATCH 37/50] fix(demo)!: flip migration-history snapshot .d.ts files to new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Block-A flip already updated the 7 `*-contract.json` snapshot files under `examples/prisma-next-demo/migrations/app/**` onto the new `{ kind: 'expression' | 'autoincrement' }` shape. Their sibling `*-contract.d.ts` declarations were emitted by the prior generator that still carried the legacy `{ kind: 'function' }` / `{ kind: 'literal'; value: DefaultLiteralValue<...> }` shapes, so the type-side never picked up the IR collapse. Mirror the JSON flip in the matching `.d.ts` files: - `{ kind: 'function'; expression: }` → `{ kind: 'expression'; expression: }`; - `{ kind: 'literal'; value: DefaultLiteralValue<'pg/text@1', 'open'> }` → `{ kind: 'expression'; expression: "'open'" }`; - Drop the now-unused `type DefaultLiteralValue<...>` helper alias that was carried at the head of every file. --- .../app/20260422T0720_initial/end-contract.d.ts | 13 +++++-------- .../app/20260422T0742_migration/end-contract.d.ts | 13 +++++-------- .../app/20260422T0742_migration/start-contract.d.ts | 13 +++++-------- .../app/20260422T0748_migration/end-contract.d.ts | 13 +++++-------- .../app/20260422T0748_migration/start-contract.d.ts | 13 +++++-------- .../end-contract.d.ts | 13 +++++-------- .../start-contract.d.ts | 13 +++++-------- 7 files changed, 35 insertions(+), 56 deletions(-) diff --git a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.d.ts index f913830a56..285c6027ad 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0720_initial/end-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -189,7 +186,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -233,8 +230,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -251,7 +248,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -283,7 +280,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.d.ts index 0cabe8344a..4267ddb722 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/end-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -191,7 +188,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -235,8 +232,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -253,7 +250,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -290,7 +287,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.d.ts index f913830a56..285c6027ad 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0742_migration/start-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -189,7 +186,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -233,8 +230,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -251,7 +248,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -283,7 +280,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.d.ts index f2a52069bf..23c42c90b9 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/end-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -191,7 +188,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -235,8 +232,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -253,7 +250,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -290,7 +287,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.d.ts index 0cabe8344a..4267ddb722 100644 --- a/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260422T0748_migration/start-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -191,7 +188,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -235,8 +232,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -253,7 +250,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -290,7 +287,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.d.ts index ab2c5d2928..dcc58c4537 100644 --- a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/end-contract.d.ts @@ -40,9 +40,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -189,7 +186,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -233,8 +230,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -251,7 +248,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -288,7 +285,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; diff --git a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.d.ts b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.d.ts index f2a52069bf..23c42c90b9 100644 --- a/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.d.ts +++ b/examples/prisma-next-demo/migrations/app/20260518T1701_namespaces_bookend/start-contract.d.ts @@ -41,9 +41,6 @@ export type CodecTypes = PgTypes & PgVectorTypes; export type OperationTypes = PgVectorOperationTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type AddressOutput = { readonly street: CodecTypes['pg/text@1']['output']; readonly city: CodecTypes['pg/text@1']['output']; @@ -191,7 +188,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly embedding: { readonly nativeType: 'vector'; @@ -235,8 +232,8 @@ type ContractBase = ContractType< readonly codecId: 'pg/text@1'; readonly nullable: false; readonly default: { - readonly kind: 'literal'; - readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + readonly kind: 'expression'; + readonly expression: "'open'"; }; }; readonly type: { @@ -253,7 +250,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -290,7 +287,7 @@ type ContractBase = ContractType< readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly kind: { readonly nativeType: 'user_type'; From 1a74cff2b1e34de1bee976b198e5ef4d47b25727 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 23:21:28 +0200 Subject: [PATCH 38/50] test(emitter): flip canonicalization fixtures to new ColumnDefault union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two framework-emitter canonicalization tests still authored inline contract fixtures with the legacy `{ kind: 'function' }` and `{ kind: 'literal'; value }` shapes (this test exercises target- agnostic canonicalisation that treats `default` as opaque, so the tests still passed — but the surface fixtures would not survive a round-trip through the SQL-contract validator). Migrate both fixtures + the matching `expect(...).toEqual(...)` assertion to `{ kind: 'expression'; expression }`. --- .../3-tooling/emitter/test/canonicalization.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts b/packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts index 2b47111db3..ff3f738df0 100644 --- a/packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts +++ b/packages/1-framework/3-tooling/emitter/test/canonicalization.test.ts @@ -84,7 +84,7 @@ describe('canonicalization', () => { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', nullable: false, - default: { kind: 'function', expression: 'now()' }, + default: { kind: 'expression', expression: 'now()' }, }, updated_at: { codecId: 'pg/timestamptz@1', @@ -117,7 +117,7 @@ describe('canonicalization', () => { codecId: 'pg/text@1', nativeType: 'text', nullable: true, - default: { kind: 'literal', value: '' }, + default: { kind: 'expression', expression: "''" }, }, }, }, @@ -132,7 +132,7 @@ describe('canonicalization', () => { const columns = user['columns'] as Record; const bio = columns['bio'] as Record; expect(bio['nullable']).toBe(true); - expect(bio['default']).toEqual({ kind: 'literal', value: '' }); + expect(bio['default']).toEqual({ kind: 'expression', expression: "''" }); }); it('omits empty arrays and objects except required ones', () => { From 1fa7198fd9402fc5fe1fa2eb368de05e2ae71628 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 23:50:41 +0200 Subject: [PATCH 39/50] feat(family-sql): codec-aware default comparison in verifySqlSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread an optional codec lookup + per-target SchemaDefaultValueParser through verifySqlSchema so columnDefaultsEqual can round-trip the introspected raw default through codec.decodeJson → codec.renderSqlLiteral and compare canonical contract-side forms. The codec is the canonical comparison oracle: both sides go through renderSqlLiteral (the contract side at emit time, the schema side here at verify time). Falls back to the legacy DefaultNormalizer path when codec or parser is unavailable, or when decodeJson rejects the parsed value — degrades gracefully rather than spurious mismatches. Structural JsonValue compare on decoded typed values handles order-independent cases (JSONB key reordering) where the codec renderer is order-sensitive but the semantic value is not. Includes the slice's D4/D5/D6 follow-up tests inline: - JSONB key-order independence - autoincrement contract default × nextval schema default - bigint codec round-trip ('9007199254740991'::bigint vs 9007199254740991) - timestamptz codec round-trip (space-form vs ISO-T form) - bool codec round-trip (TRUE vs true) - mismatch detection - fallback paths when codec is absent or unknown --- .../core/schema-verify/verify-sql-schema.ts | 262 +++++++++- .../9-family/src/exports/schema-verify.ts | 2 + .../test/schema-verify.codec-defaults.test.ts | 467 ++++++++++++++++++ 3 files changed, 719 insertions(+), 12 deletions(-) create mode 100644 packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts diff --git a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts index 9fb22cff7a..b87816b9d8 100644 --- a/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts +++ b/packages/2-sql/9-family/src/core/schema-verify/verify-sql-schema.ts @@ -6,7 +6,8 @@ * by migration planners and other tools that need to compare schema states. */ -import type { Contract } from '@prisma-next/contract/types'; +import type { Contract, JsonValue } from '@prisma-next/contract/types'; +import type { CodecLookup } from '@prisma-next/framework-components/codec'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { OperationContext, @@ -53,6 +54,30 @@ export type DefaultNormalizer = ( */ export type NativeTypeNormalizer = (nativeType: string) => string; +/** + * Function type for parsing a raw schema-side SQL default expression into a + * codec-comparable {@link JsonValue}. + * + * Returns `undefined` when the raw expression is not a simple literal + * (e.g. function-form like `now()`, autoincrement `nextval(...)`); the + * verifier then falls back to the legacy normalizer-based string compare + * path for those cases. + * + * For literal forms, the parser strips the dialect's casts (`::type`), + * unquotes string literals, parses bare numerics / booleans, and normalises + * dialect-specific value shapes (e.g. Postgres's space-separated + * `'2024-01-15 10:30:00+00'` timestamps to ISO-8601 UTC) so the codec's + * strict `decodeJson` accepts the result. + * + * The verifier dispatches the returned value through `codec.decodeJson` → + * `codec.renderSqlLiteral` to produce a contract-canonical expression that + * compares cleanly against `contract.default.expression`. + */ +export type SchemaDefaultValueParser = ( + rawDefault: string, + nativeType: string, +) => JsonValue | undefined; + /** * Options for the pure schema verification function. */ @@ -99,6 +124,28 @@ export interface VerifySqlSchemaOptions { schema: SqlSchemaIR, enumType: PostgresEnumStorageEntry, ) => readonly string[] | null; + /** + * Codec-id-keyed lookup used by the codec-aware default comparison path. + * + * Threaded alongside {@link SchemaDefaultValueParser}: when both are + * supplied and the column carries a known `codecId`, the verifier + * round-trips the introspected literal through `codec.decodeJson` → + * `codec.renderSqlLiteral` and compares the canonical contract-side form + * against `contract.default.expression`. When either input is missing — + * or the column's codec is not in the lookup — the verifier falls back to + * the legacy {@link DefaultNormalizer} string-compare path. + * + * Production call sites (Postgres / SQLite planners and runners) build + * this via {@link extractCodecLookup} over the same `frameworkComponents` + * they already pass to the verifier. + */ + readonly codecLookup?: CodecLookup; + /** + * Per-target parser that extracts the codec-comparable {@link JsonValue} + * out of a raw schema-side default expression. See + * {@link SchemaDefaultValueParser} for the contract. + */ + readonly parseSchemaDefaultValue?: SchemaDefaultValueParser; } /** @@ -121,6 +168,8 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase normalizeDefault, normalizeNativeType, resolveExistingEnumValues, + codecLookup, + parseSchemaDefaultValue, } = options; const startTime = Date.now(); @@ -155,6 +204,8 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase storageTypes, ...ifDefined('normalizeDefault', normalizeDefault), ...ifDefined('normalizeNativeType', normalizeNativeType), + ...ifDefined('codecLookup', codecLookup), + ...ifDefined('parseSchemaDefaultValue', parseSchemaDefaultValue), }); validateFrameworkComponentsForExtensions(contract, options.frameworkComponents); @@ -337,6 +388,8 @@ function verifySchemaTables(options: { storageTypes: Readonly>; normalizeDefault?: DefaultNormalizer; normalizeNativeType?: NativeTypeNormalizer; + codecLookup?: CodecLookup; + parseSchemaDefaultValue?: SchemaDefaultValueParser; }): { issues: SchemaIssue[]; rootChildren: SchemaVerificationNode[] } { const { contract, @@ -347,6 +400,8 @@ function verifySchemaTables(options: { storageTypes, normalizeDefault, normalizeNativeType, + codecLookup, + parseSchemaDefaultValue, } = options; const issues: SchemaIssue[] = []; const rootChildren: SchemaVerificationNode[] = []; @@ -402,6 +457,8 @@ function verifySchemaTables(options: { storageTypes, ...ifDefined('normalizeDefault', normalizeDefault), ...ifDefined('normalizeNativeType', normalizeNativeType), + ...ifDefined('codecLookup', codecLookup), + ...ifDefined('parseSchemaDefaultValue', parseSchemaDefaultValue), }); rootChildren.push(buildTableNode(tableName, tablePath, tableChildren)); } @@ -453,6 +510,8 @@ function verifyTableChildren(options: { storageTypes: Readonly>; normalizeDefault?: DefaultNormalizer; normalizeNativeType?: NativeTypeNormalizer; + codecLookup?: CodecLookup; + parseSchemaDefaultValue?: SchemaDefaultValueParser; }): SchemaVerificationNode[] { const { contractTable, @@ -467,6 +526,8 @@ function verifyTableChildren(options: { storageTypes, normalizeDefault, normalizeNativeType, + codecLookup, + parseSchemaDefaultValue, } = options; const tableChildren: SchemaVerificationNode[] = []; const columnNodes = collectContractColumnNodes({ @@ -482,6 +543,8 @@ function verifyTableChildren(options: { storageTypes, ...ifDefined('normalizeDefault', normalizeDefault), ...ifDefined('normalizeNativeType', normalizeNativeType), + ...ifDefined('codecLookup', codecLookup), + ...ifDefined('parseSchemaDefaultValue', parseSchemaDefaultValue), }); if (columnNodes.length > 0) { tableChildren.push(buildColumnsNode(tablePath, columnNodes)); @@ -620,6 +683,8 @@ function collectContractColumnNodes(options: { storageTypes: Readonly>; normalizeDefault?: DefaultNormalizer; normalizeNativeType?: NativeTypeNormalizer; + codecLookup?: CodecLookup; + parseSchemaDefaultValue?: SchemaDefaultValueParser; }): SchemaVerificationNode[] { const { contractTable, @@ -634,6 +699,8 @@ function collectContractColumnNodes(options: { storageTypes, normalizeDefault, normalizeNativeType, + codecLookup, + parseSchemaDefaultValue, } = options; const columnNodes: SchemaVerificationNode[] = []; @@ -678,6 +745,8 @@ function collectContractColumnNodes(options: { storageTypes, ...ifDefined('normalizeDefault', normalizeDefault), ...ifDefined('normalizeNativeType', normalizeNativeType), + ...ifDefined('codecLookup', codecLookup), + ...ifDefined('parseSchemaDefaultValue', parseSchemaDefaultValue), }), ); } @@ -734,6 +803,8 @@ function verifyColumn(options: { storageTypes: Readonly>; normalizeDefault?: DefaultNormalizer; normalizeNativeType?: NativeTypeNormalizer; + codecLookup?: CodecLookup; + parseSchemaDefaultValue?: SchemaDefaultValueParser; }): SchemaVerificationNode { const { tableName, @@ -748,6 +819,8 @@ function verifyColumn(options: { storageTypes, normalizeDefault, normalizeNativeType, + codecLookup, + parseSchemaDefaultValue, } = options; const columnChildren: SchemaVerificationNode[] = []; let columnStatus: VerificationStatus = 'pass'; @@ -872,6 +945,10 @@ function verifyColumn(options: { schemaColumn.default, normalizeDefault, schemaNativeType, + resolvedContractColumn.codecId + ? codecLookup?.get(resolvedContractColumn.codecId) + : undefined, + parseSchemaDefaultValue, ) ) { const expectedDescription = describeColumnDefault(contractColumn.default); @@ -1161,25 +1238,188 @@ function describeColumnDefault(columnDefault: ColumnDefault): string { } } +/** + * Structural narrowing for SQL-family codecs that carry `renderSqlLiteral`. + * Mirrors the same shape used by the PSL parser (see + * `psl-column-resolution.ts` § `CodecWithRenderSqlLiteral`): the + * framework-level {@link CodecLookup} returns the narrower framework + * `Codec`, so the call site narrows structurally rather than depending on + * the SQL-family `Codec` interface from `sql-relational-core/ast`. + */ +interface CodecWithRenderSqlLiteral { + readonly id: string; + decodeJson(json: JsonValue): unknown; + renderSqlLiteral(value: unknown): string; +} + +function hasRenderSqlLiteral( + codec: { decodeJson(json: JsonValue): unknown } | undefined, +): codec is CodecWithRenderSqlLiteral { + return ( + codec !== undefined && + 'renderSqlLiteral' in codec && + typeof (codec as { renderSqlLiteral?: unknown }).renderSqlLiteral === 'function' + ); +} + +/** + * Case-insensitive, whitespace-tolerant SQL expression comparison. + * + * Two codec round-tripped forms may differ only in casing or whitespace + * (e.g. `TRUE` vs `true`, `'foo'::text` vs `'foo' :: text`) — the collapse + * pinned here is conservative enough that semantically equal forms compare + * equal while syntactically distinct forms (`'foo'` vs `'bar'`) do not. + */ +function expressionsEqual(a: string, b: string): boolean { + const normalise = (expr: string) => expr.toLowerCase().replace(/\s+/g, ''); + return normalise(a) === normalise(b); +} + +/** + * Structural equality for JSON-shaped typed values (objects + arrays + + * primitives). Used by the codec round-trip path so JSONB-style + * key-order-independent comparison succeeds when both sides decode to + * the same semantic value but the codec's `renderSqlLiteral` + * (e.g. `JSON.stringify`) is order-sensitive. + * + * Other codec output types fall back to JS `===` (handled in the + * primitive arm). `Date` instances compare by `.getTime()` so two Date + * values built from the same instant compare equal even when constructed + * via different string forms. + */ +function jsonValuesStructurallyEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime(); + } + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (!jsonValuesStructurallyEqual(a[i], b[i])) return false; + } + return true; + } + if (typeof a === 'object' && typeof b === 'object') { + const aRecord = a as Record; + const bRecord = b as Record; + const aKeys = Object.keys(aRecord); + const bKeys = Object.keys(bRecord); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.hasOwn(bRecord, key)) return false; + if (!jsonValuesStructurallyEqual(aRecord[key], bRecord[key])) return false; + } + return true; + } + return false; +} + /** * Compares a contract ColumnDefault against a schema raw default string for semantic equality. * - * When a normalizer is provided, the raw schema default is first normalized to a ColumnDefault - * before comparison. Without a normalizer, falls back to direct string comparison against - * the contract expression. + * Three layers of comparison, in order: + * + * 1. **Codec round-trip.** When the column's codec is available in the + * lookup AND the per-target {@link SchemaDefaultValueParser} extracts a + * {@link JsonValue} out of the raw schema default, dispatch through + * `codec.decodeJson(value)` → `codec.renderSqlLiteral(typed)` to produce + * a contract-canonical expression. Compare that canonical form against + * `contract.default.expression`. The codec is the canonical comparison + * oracle — both sides go through `renderSqlLiteral` (the contract side + * at emit time, the schema side here at verify time). + * + * 2. **Legacy normalizer.** When the codec round-trip is unavailable (no + * codec, no parser, parser returned undefined, decodeJson threw), + * fall back to the per-target {@link DefaultNormalizer} that converts + * the raw schema default into a normalised {@link ColumnDefault} and + * compares against the contract default with case-insensitive + * whitespace-tolerant expression matching. + * + * 3. **Direct string compare.** When no normalizer is provided, compare + * the contract expression directly against the raw schema string (with + * a lenient bare-vs-quoted check for legacy fixtures). * - * @param contractDefault - The expected default from the contract (normalized ColumnDefault) - * @param schemaDefault - The raw default expression from the database (string) - * @param normalizer - Optional target-specific normalizer to convert raw defaults - * @param nativeType - The column's native type, passed to normalizer for context + * `kind: 'autoincrement'` always short-circuits to kind-equality on + * whichever side a normalised value is available (codec round-trip is + * skipped — codec is NOT invoked for autoincrement, matching the producer + * convention in `build-contract.ts` and `psl-column-resolution.ts`). + * + * @param contractDefault - The expected default from the contract. + * @param schemaDefault - The raw default expression from the database. + * @param normalizer - Optional target-specific normalizer to convert raw defaults. + * @param nativeType - The column's native type, passed to normalizer / parser for context. + * @param codec - Optional codec for the column (resolved via `codecLookup.get(codecId)`). + * @param valueParser - Optional per-target parser that extracts a JsonValue from the raw default. */ function columnDefaultsEqual( contractDefault: ColumnDefault, schemaDefault: string, normalizer?: DefaultNormalizer, nativeType?: string, + codec?: { decodeJson(json: JsonValue): unknown } | undefined, + valueParser?: SchemaDefaultValueParser, ): boolean { - // If no normalizer provided, fall back to direct string comparison + // 1. Codec round-trip. + // + // Skipped for autoincrement contract defaults — codec is never invoked on + // the autoincrement arm (producer side: `build-contract.ts`, + // `psl-column-resolution.ts`). The autoincrement match flows through the + // normalizer path (which detects `nextval(...)` and produces `{ kind: + // 'autoincrement' }`). + if (contractDefault.kind === 'expression' && hasRenderSqlLiteral(codec) && valueParser) { + const schemaParsedValue = valueParser(schemaDefault, nativeType ?? ''); + if (schemaParsedValue !== undefined) { + try { + const schemaTyped = codec.decodeJson(schemaParsedValue); + const schemaCanonical = codec.renderSqlLiteral(schemaTyped); + if (expressionsEqual(contractDefault.expression, schemaCanonical)) { + return true; + } + // Round-trip the contract-side expression through the same parser + // + codec so cases where the contract carries a literal whose + // codec re-render does NOT reproduce the contract expression + // verbatim (e.g. JSONB key-order: `'{"a":1,"b":2}'::jsonb` vs the + // codec's `JSON.stringify` output) still compare equal when both + // sides decode to the same typed value. + const contractParsedValue = valueParser(contractDefault.expression, nativeType ?? ''); + if (contractParsedValue !== undefined) { + try { + const contractTyped = codec.decodeJson(contractParsedValue); + const contractCanonical = codec.renderSqlLiteral(contractTyped); + if (expressionsEqual(contractCanonical, schemaCanonical)) { + return true; + } + // Structural comparison on the typed values handles cases + // where the codec's `renderSqlLiteral` is order-sensitive on a + // structure that should be order-independent (the canonical + // example is JSONB: `JSON.stringify({a:1,b:2})` ≠ + // `JSON.stringify({b:2,a:1})` even though the JSONB values are + // semantically equal). The structural compare is JSON-value + // shaped: the typed value reduces to a {@link JsonValue}-like + // tree when both sides went through `decodeJson` whose return + // is `JsonValue` or a JS-native value `JSON.stringify`-stable. + if (jsonValuesStructurallyEqual(contractTyped, schemaTyped)) { + return true; + } + } catch { + // contract side failed to round-trip; fall through. + } + } + // Both round-trips done; canonicals don't match — fall through to + // the normalizer path so the legacy compare can still rescue + // cases like `'draft'::text` vs `draft` that the codec's + // per-dialect cast wrapping would otherwise reject. + } catch { + // decodeJson threw — likely because the parsed value's shape + // doesn't satisfy the codec's strict input contract. Fall through + // to the normalizer path. + } + } + } + + // 2/3. Legacy normalizer + direct string compare. if (!normalizer) { if (contractDefault.kind === 'autoincrement') { return false; @@ -1203,9 +1443,7 @@ function columnDefaultsEqual( return true; } if (contractDefault.kind === 'expression' && normalizedSchema.kind === 'expression') { - // Normalize expressions for comparison (case-insensitive, whitespace-tolerant) - const normalizeExpr = (expr: string) => expr.toLowerCase().replace(/\s+/g, ''); - return normalizeExpr(contractDefault.expression) === normalizeExpr(normalizedSchema.expression); + return expressionsEqual(contractDefault.expression, normalizedSchema.expression); } return false; } diff --git a/packages/2-sql/9-family/src/exports/schema-verify.ts b/packages/2-sql/9-family/src/exports/schema-verify.ts index 04ecacd7a5..4b964ca399 100644 --- a/packages/2-sql/9-family/src/exports/schema-verify.ts +++ b/packages/2-sql/9-family/src/exports/schema-verify.ts @@ -12,7 +12,9 @@ export { isUniqueConstraintSatisfied, } from '../core/schema-verify/verify-helpers'; export type { + DefaultNormalizer, NativeTypeNormalizer, + SchemaDefaultValueParser, VerifySqlSchemaOptions, } from '../core/schema-verify/verify-sql-schema'; export { verifySqlSchema } from '../core/schema-verify/verify-sql-schema'; diff --git a/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts b/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts new file mode 100644 index 0000000000..ab1987675b --- /dev/null +++ b/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts @@ -0,0 +1,467 @@ +/** + * Codec-aware schema-default comparison: the verifier round-trips the + * introspected raw literal through the column's codec (`decodeJson` → + * `renderSqlLiteral`) so canonical Postgres / SQLite literal forms (e.g. + * `'9007199254740991'::bigint`, `'2024-01-15 10:30:00+00'::timestamptz`) + * collapse to the same contract-side canonical form the codec produced at + * emit time. Without codec dispatch the comparison reduces to string + * normalisation, which is too weak to reconcile the two forms. + */ +import type { JsonValue } from '@prisma-next/contract/types'; +import type { Codec, CodecLookup } from '@prisma-next/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import type { SchemaDefaultValueParser } from '../src/core/schema-verify/verify-sql-schema'; +import { verifySqlSchema } from '../src/core/schema-verify/verify-sql-schema'; +import { + createContractTable, + createSchemaTable, + createTestContract, + createTestSchemaIR, + emptyTypeMetadataRegistry, +} from './schema-verify.helpers'; + +function makeCodec(overrides: { + readonly id: string; + readonly decodeJson: (json: JsonValue) => unknown; + readonly renderSqlLiteral: (value: unknown) => string; +}): Codec { + const stub = { + id: overrides.id, + encode: async (v: unknown) => v, + decode: async (v: unknown) => v, + encodeJson: (v: unknown) => v as JsonValue, + decodeJson: overrides.decodeJson, + renderSqlLiteral: overrides.renderSqlLiteral, + }; + return stub as unknown as Codec; +} + +function makeLookup(map: Record): CodecLookup { + return { + get: (id) => map[id], + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, + }; +} + +/** + * Mimics `parsePostgresDefaultValue` shape: extracts the JS-comparable value + * out of a raw Postgres literal (strip `::type` cast and outer quotes for + * string forms; recognise bare numerics and booleans; normalise space-form + * timestamps to ISO-8601 UTC so the timestamptz codec's strict `decodeJson` + * accepts them). + */ +const testValueParser: SchemaDefaultValueParser = ( + rawDefault: string, + nativeType: string, +): JsonValue | undefined => { + const trimmed = rawDefault.trim(); + + // Strip outer cast `::type` (possibly quoted) + const stripCast = (s: string): string => { + const m = s.match(/^(.*?)\s*::\s*(?:"[^"]+"|[\w\s]+)(?:\(\d+(?:,\d+)?\))?$/); + return m?.[1] ?? s; + }; + + const inner = stripCast(trimmed); + + // Timestamp-like native types: parse the inner string as a Date, return + // canonical ISO-8601 UTC form for the strict timestamptz codec. + if (/timestamp/i.test(nativeType)) { + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + const str = stringMatch?.[1] ?? inner; + const date = new Date(str.replace(/''/g, "'")); + if (!Number.isNaN(date.getTime())) { + return date.toISOString(); + } + } + + // Booleans + if (/^true$/i.test(inner)) return true; + if (/^false$/i.test(inner)) return false; + + // Numerics: bare `9007199254740991` OR quoted `'9007199254740991'` + const numericMatch = inner.match(/^'?(-?\d+(?:\.\d+)?)'?$/); + if (numericMatch?.[1] !== undefined) { + if (/^(?:int|bigint|smallint|numeric|float|real|double)/i.test(nativeType)) { + const n = Number(numericMatch[1]); + if (Number.isFinite(n)) return n; + } + } + + // JSON literals: `'{...}'::jsonb` → parsed object + if (/json/i.test(nativeType)) { + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + try { + return JSON.parse(stringMatch[1].replace(/''/g, "'")); + } catch { + return undefined; + } + } + } + + // Quoted strings: strip outer quotes + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + return stringMatch[1].replace(/''/g, "'"); + } + + return undefined; +}; + +const bigintCodec = makeCodec({ + id: 'pg/int8@1', + decodeJson: (json) => json, + renderSqlLiteral: (value) => String(value), +}); + +const timestamptzCodec = makeCodec({ + id: 'pg/timestamptz@1', + decodeJson: (json) => { + if (typeof json !== 'string') throw new Error('expected ISO string'); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(json)) { + throw new Error(`Invalid ISO timestamp: ${json}`); + } + return new Date(json); + }, + renderSqlLiteral: (value) => { + const date = value as Date; + return `'${date.toISOString()}'::timestamp with time zone`; + }, +}); + +const jsonbCodec = makeCodec({ + id: 'pg/jsonb@1', + decodeJson: (json) => json, + renderSqlLiteral: (value) => `'${JSON.stringify(value)}'::jsonb`, +}); + +const boolCodec = makeCodec({ + id: 'pg/bool@1', + decodeJson: (json) => json, + renderSqlLiteral: (value) => (value ? 'TRUE' : 'FALSE'), +}); + +const int4Codec = makeCodec({ + id: 'pg/int4@1', + decodeJson: (json) => json, + renderSqlLiteral: (value) => String(value), +}); + +describe('verifySqlSchema — codec-aware default comparison', () => { + it('treats bigint contract default as equal to quoted-cast Postgres form via codec round-trip', () => { + const contract = createTestContract({ + literal_defaults: createContractTable({ + big_count: { + nativeType: 'bigint', + codecId: 'pg/int8@1', + nullable: false, + default: { kind: 'expression', expression: '9007199254740991' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + literal_defaults: createSchemaTable('literal_defaults', { + big_count: { + nativeType: 'bigint', + nullable: false, + default: "'9007199254740991'::bigint", + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/int8@1': bigintCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('treats timestamptz contract default as equal to space-separated Postgres form via codec round-trip', () => { + const contract = createTestContract({ + event: createContractTable({ + scheduled_at: { + nativeType: 'timestamp with time zone', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { + kind: 'expression', + expression: "'2024-01-15T10:30:00.000Z'::timestamp with time zone", + }, + }, + }), + }); + + const schema = createTestSchemaIR({ + event: createSchemaTable('event', { + scheduled_at: { + nativeType: 'timestamp with time zone', + nullable: false, + default: "'2024-01-15 10:30:00+00'::timestamp with time zone", + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/timestamptz@1': timestamptzCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('reports default_mismatch when codec round-trip produces a different canonical form', () => { + const contract = createTestContract({ + event: createContractTable({ + scheduled_at: { + nativeType: 'timestamp with time zone', + codecId: 'pg/timestamptz@1', + nullable: false, + default: { + kind: 'expression', + expression: "'2024-01-15T10:30:00.000Z'::timestamp with time zone", + }, + }, + }), + }); + + const schema = createTestSchemaIR({ + event: createSchemaTable('event', { + scheduled_at: { + nativeType: 'timestamp with time zone', + nullable: false, + default: "'2099-12-31 23:59:59+00'::timestamp with time zone", + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/timestamptz@1': timestamptzCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + expect(result.ok).toBe(false); + expect(result.schema.issues).toContainEqual( + expect.objectContaining({ + kind: 'default_mismatch', + table: 'event', + column: 'scheduled_at', + }), + ); + }); + + it('treats JSONB defaults as equal even when schema returns the object with reordered keys', () => { + const contract = createTestContract({ + literal_defaults: createContractTable({ + payload: { + nativeType: 'jsonb', + codecId: 'pg/jsonb@1', + nullable: false, + default: { kind: 'expression', expression: '\'{"a":1,"b":2}\'::jsonb' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + literal_defaults: createSchemaTable('literal_defaults', { + payload: { + nativeType: 'jsonb', + nullable: false, + // Postgres reserialised the JSONB with reordered keys + default: '\'{"b":2,"a":1}\'::jsonb', + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/jsonb@1': jsonbCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + // Both sides decode to a structurally equal object; codec.renderSqlLiteral + // re-serialises both through `JSON.stringify` so the canonicals collapse to + // the same key order (the one JSON.stringify produces on the parsed object). + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('treats autoincrement contract default as equal to nextval schema default via the per-target normalizer', () => { + // Autoincrement round-trip: contract `{ kind: 'autoincrement' }` against + // Postgres-introspected `nextval('seq_name'::regclass)` form. The + // existing per-target `normalizeDefault` path produces `{ kind: + // 'autoincrement' }` from the raw default; the codec-aware compare + // short-circuits to the autoincrement kind-equality branch before any + // codec round-trip is attempted. + const contract = createTestContract({ + literal_defaults: createContractTable({ + id: { + nativeType: 'integer', + codecId: 'pg/int4@1', + nullable: false, + default: { kind: 'autoincrement' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + literal_defaults: createSchemaTable('literal_defaults', { + id: { + nativeType: 'integer', + nullable: false, + default: "nextval('literal_defaults_id_seq'::regclass)", + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/int4@1': int4Codec }), + parseSchemaDefaultValue: testValueParser, + normalizeDefault: (raw) => + /^nextval\s*\(/i.test(raw.trim()) ? { kind: 'autoincrement' } : undefined, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('treats bool contract default TRUE as equal to schema-side bare true via codec round-trip', () => { + const contract = createTestContract({ + literal_defaults: createContractTable({ + active: { + nativeType: 'boolean', + codecId: 'pg/bool@1', + nullable: false, + default: { kind: 'expression', expression: 'TRUE' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + literal_defaults: createSchemaTable('literal_defaults', { + active: { + nativeType: 'boolean', + nullable: false, + default: 'true', + }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({ 'pg/bool@1': boolCodec }), + parseSchemaDefaultValue: testValueParser, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('falls back to the legacy normalizer path when no codec or parser is supplied', () => { + // No codecLookup or parseSchemaDefaultValue → the function must behave + // exactly as it did before D9 (string-normalised compare via the + // optional normalizer). + const contract = createTestContract({ + user: createContractTable({ + status: { + nativeType: 'text', + codecId: 'pg/text@1', + nullable: false, + default: { kind: 'expression', expression: 'draft' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + user: createSchemaTable('user', { + status: { nativeType: 'text', nullable: false, default: 'draft' }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); + + it('falls back to the legacy normalizer path when the column codec is not in the lookup', () => { + // Codec lookup misses → codec-aware compare cannot run; verifier falls + // back to the legacy normalizer path so unknown codecs degrade + // gracefully rather than reporting spurious mismatches. + const contract = createTestContract({ + user: createContractTable({ + status: { + nativeType: 'text', + codecId: 'pg/unknown@1', + nullable: false, + default: { kind: 'expression', expression: 'draft' }, + }, + }), + }); + + const schema = createTestSchemaIR({ + user: createSchemaTable('user', { + status: { nativeType: 'text', nullable: false, default: "'draft'::text" }, + }), + }); + + const result = verifySqlSchema({ + contract, + schema, + strict: false, + typeMetadataRegistry: emptyTypeMetadataRegistry, + frameworkComponents: [], + codecLookup: makeLookup({}), + parseSchemaDefaultValue: testValueParser, + normalizeDefault: (raw) => { + const m = raw.trim().match(/^'((?:[^']|'')*)'(?:::.+)?$/); + return m?.[1] !== undefined + ? { kind: 'expression', expression: m[1].replace(/''/g, "'") } + : { kind: 'expression', expression: raw.trim() }; + }, + }); + + expect(result.schema.issues).toHaveLength(0); + expect(result.ok).toBe(true); + }); +}); From 5abfa43b79dca3d10bfcf7e3f81b75111eeef85d Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 23:57:07 +0200 Subject: [PATCH 40/50] feat(target-postgres): parsePostgresDefaultValue for codec-aware verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the per-target value parser that complements parsePostgresDefault: where parsePostgresDefault returns a ColumnDefault, the value parser extracts the codec-comparable JsonValue out of the raw Postgres literal. The verifier round-trips that JsonValue through the column's codec (decodeJson → renderSqlLiteral) to produce a contract-canonical expression for comparison. Recognised forms: - Quoted strings with optional ::type cast → unquoted string - Bare and quoted numerics on numeric nativeTypes → number - true / false → boolean - Timestamp-typed literals → ISO-8601 UTC string (collapses Postgres-canonical '2024-01-15 10:30:00+00' to '2024-01-15T10:30:00.000Z' so the codec's strict decodeJson accepts it) - JSON / JSONB literals → parsed JsonValue - Non-literal forms (now(), nextval(...), gen_random_uuid()) return undefined so the verifier falls back to the legacy normalizer path Threaded through: - PostgresMigrationPlanner.collectSchemaIssues (db init / db update) - PostgresMigrationRunner.execute schema-verification step - PostgresControlAdapter.parseSchemaDefaultValue (family-sql verifySchema) - All three carry codecLookup via extractCodecLookup(frameworkComponents) --- .../postgres/src/core/default-normalizer.ts | 122 ++++++++++++++++++ .../postgres/src/core/migrations/planner.ts | 5 +- .../postgres/src/core/migrations/runner.ts | 6 +- .../src/exports/default-normalizer.ts | 5 +- .../postgres/src/core/control-adapter.ts | 14 +- 5 files changed, 147 insertions(+), 5 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts index 41c9ec7169..3ae30eaee7 100644 --- a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts @@ -1,3 +1,4 @@ +import type { JsonValue } from '@prisma-next/contract/types'; import type { ColumnDefault } from '@prisma-next/sql-contract/types'; /** @@ -82,3 +83,124 @@ export function parsePostgresDefault( return { kind: 'expression', expression: trimmed }; } + +/** + * Matches an outer `::` cast suffix (possibly quoted, possibly with + * length / precision parameters). Used by {@link parsePostgresDefaultValue} + * to strip the column-type cast before unquoting / number-parsing. + */ +const CAST_SUFFIX = /\s*::\s*(?:"[^"]+"|[\w\s]+)(?:\(\d+(?:,\d+)?\))?$/; + +/** + * Returns the SQL literal value with its outer `::` cast stripped. + * Handles quoted enum/type names (`::"BillingState"`) and parameterised + * types (`::numeric(10,2)`). + */ +function stripOuterCast(s: string): string { + return s.replace(CAST_SUFFIX, ''); +} + +/** + * Extracts the codec-comparable {@link JsonValue} out of a raw Postgres + * column default expression (the value `pg_get_expr` returns). + * + * The verifier round-trips this {@link JsonValue} through the column's + * codec (`codec.decodeJson(...)` → `codec.renderSqlLiteral(...)`) and + * compares the result against the contract-side expression. The + * comparison is therefore codec-canonical: two textually different + * Postgres-canonical forms collapse to one contract-canonical form when + * they decode to the same typed value. + * + * Returns `undefined` for non-literal forms (function calls like `now()`, + * `nextval(...)`, `gen_random_uuid()`); the verifier falls back to the + * legacy normalizer-based string compare for those. + * + * Recognised literal forms: + * + * - Quoted strings (`'foo'`, `'it''s'`) with optional `::type` cast → + * the unquoted string. + * - Bare numerics (`9007199254740991`, `3.14`) and quoted numerics + * (`'9007199254740991'::bigint`) on a numeric `nativeType` → the + * parsed number. + * - Boolean literals (`true`, `false`, case-insensitive) → the boolean. + * - Timestamp-typed literals: the inner string is parsed via `new Date` + * and emitted in canonical ISO-8601 UTC form so the codec's strict + * `decodeJson` accepts it. Both Postgres-canonical + * `'2024-01-15 10:30:00+00'` and ISO-T forms collapse to the same JS + * `Date`. + * - JSON / JSONB literals (`'{"key":"value"}'::jsonb`) → the parsed + * `JsonValue`. + * + * Adversarial inputs are handled conservatively: malformed JSON returns + * `undefined`, invalid dates return `undefined`, etc. The verifier's + * fallback path picks them up. + */ +export function parsePostgresDefaultValue( + rawDefault: string, + nativeType: string, +): JsonValue | undefined { + const trimmed = rawDefault.trim(); + + // Non-literal forms — short-circuit so the verifier falls back to the + // normalizer path (which detects autoincrement / timestamp functions). + if ( + NEXTVAL_PATTERN.test(trimmed) || + NOW_FUNCTION_PATTERN.test(trimmed) || + CLOCK_TIMESTAMP_PATTERN.test(trimmed) || + UUID_PATTERN.test(trimmed) || + UUID_OSSP_PATTERN.test(trimmed) || + canonicalizeTimestampDefault(trimmed) !== undefined + ) { + return undefined; + } + + const inner = stripOuterCast(trimmed); + + // Timestamp-typed: parse via `new Date` and emit ISO-8601 UTC so the + // codec's strict `decodeJson` accepts the value. Both + // `'2024-01-15 10:30:00+00'` and `'2024-01-15T10:30:00.000Z'` collapse + // here. + if (/timestamp|date|time/i.test(nativeType)) { + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + const candidate = stringMatch?.[1]?.replace(/''/g, "'") ?? inner; + const date = new Date(candidate); + if (!Number.isNaN(date.getTime())) { + return date.toISOString(); + } + } + + // Boolean literals + if (/^true$/i.test(inner)) return true; + if (/^false$/i.test(inner)) return false; + + // Numerics — bare or quoted-with-cast — on a numeric nativeType. + if (/^(?:int|bigint|smallint|numeric|decimal|float|real|double|serial)/i.test(nativeType)) { + const numericMatch = inner.match(/^'?(-?\d+(?:\.\d+)?)'?$/); + if (numericMatch?.[1] !== undefined) { + const n = Number(numericMatch[1]); + if (Number.isFinite(n)) return n; + } + } + + // JSON / JSONB literals — parse the inner quoted body + if (/json/i.test(nativeType)) { + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + try { + return JSON.parse(stringMatch[1].replace(/''/g, "'")); + } catch { + return undefined; + } + } + } + + // Quoted strings — strip outer quotes, unescape doubled single quotes. + const stringMatch = inner.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + return stringMatch[1].replace(/''/g, "'"); + } + + // No recognised literal shape — let the verifier fall back to the + // legacy normalizer path. + return undefined; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index 9e7ab32ae6..24e3c87e5c 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -17,7 +17,8 @@ import type { MigrationScaffoldContext, SchemaIssue, } from '@prisma-next/framework-components/control'; -import { parsePostgresDefault } from '../default-normalizer'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { parsePostgresDefault, parsePostgresDefaultValue } from '../default-normalizer'; import { normalizeSchemaNativeType } from '../native-type-normalizer'; import { readExistingEnumValues } from './enum-planning'; import { planIssues } from './issue-planner'; @@ -218,6 +219,8 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr typeMetadataRegistry: new Map(), frameworkComponents: options.frameworkComponents, normalizeDefault: parsePostgresDefault, + parseSchemaDefaultValue: parsePostgresDefaultValue, + codecLookup: extractCodecLookup(options.frameworkComponents), normalizeNativeType: normalizeSchemaNativeType, resolveExistingEnumValues: (schema, enumType) => readExistingEnumValues(schema, enumType.nativeType), diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts index 2cdd4bcfd6..ea46a032d7 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts @@ -15,12 +15,12 @@ import type { import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control'; import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import type { ControlDriverInstance } from '@prisma-next/framework-components/control'; -import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; +import { APP_SPACE_ID, extractCodecLookup } from '@prisma-next/framework-components/control'; import { SqlQueryError } from '@prisma-next/sql-errors'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok, okVoid } from '@prisma-next/utils/result'; -import { parsePostgresDefault } from '../default-normalizer'; +import { parsePostgresDefault, parsePostgresDefaultValue } from '../default-normalizer'; import { normalizeSchemaNativeType } from '../native-type-normalizer'; import { readExistingEnumValues } from './enum-planning'; import type { PostgresPlanTargetDetails } from './planner-target-details'; @@ -187,6 +187,8 @@ class PostgresMigrationRunner implements SqlMigrationRunner readExistingEnumValues(schema, enumType.nativeType), diff --git a/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts index 480093cfcc..481b2147ec 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/default-normalizer.ts @@ -1 +1,4 @@ -export { parsePostgresDefault } from '../core/default-normalizer'; +export { + parsePostgresDefault, + parsePostgresDefaultValue, +} from '../core/default-normalizer'; diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 9a15189230..dddc4f677f 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -20,7 +20,10 @@ import type { SqlTableIR, SqlUniqueIR, } from '@prisma-next/sql-schema-ir/types'; -import { parsePostgresDefault } from '@prisma-next/target-postgres/default-normalizer'; +import { + parsePostgresDefault, + parsePostgresDefaultValue, +} from '@prisma-next/target-postgres/default-normalizer'; import { readExistingEnumValues } from '@prisma-next/target-postgres/enum-planning'; import { normalizeSchemaNativeType } from '@prisma-next/target-postgres/native-type-normalizer'; import { ifDefined } from '@prisma-next/utils/defined'; @@ -56,6 +59,15 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { */ readonly normalizeDefault = parsePostgresDefault; + /** + * Target-specific parser that extracts the codec-comparable JsonValue + * out of a raw Postgres default expression. Threaded into + * `verifySqlSchema` so the verifier can round-trip introspected literals + * through the column's codec (`decodeJson` → `renderSqlLiteral`) and + * compare against the contract-side codec-rendered expression. + */ + readonly parseSchemaDefaultValue = parsePostgresDefaultValue; + /** * Target-specific normalizer for Postgres schema native type names. * Used by schema verification to normalize introspected type names From 0bececfa69be2b0d975df2e9917792c1cc1d6c32 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 23:57:28 +0200 Subject: [PATCH 41/50] feat(target-sqlite): parseSqliteDefaultValue for codec-aware verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of parsePostgresDefaultValue in the SQLite target. Strips the outer parens SQLite wraps expressions in, then extracts the codec-comparable JsonValue from numerics, booleans, JSON literals, and quoted strings (using the nativeType hint to disambiguate — SQLite is loose-typed at the storage layer and stores affinities, not strict types). CURRENT_TIMESTAMP / datetime("now") return undefined so the legacy normalizer path handles them (matches parseSqliteDefault's function-form detection). Wired through SqliteMigrationPlanner.collectSchemaIssues, SqliteMigrationRunner.execute, and SqliteControlAdapter.parseSchemaDefaultValue. All three thread codecLookup via extractCodecLookup(frameworkComponents). --- .../sqlite/src/core/default-normalizer.ts | 74 +++++++++++++++++++ .../sqlite/src/core/migrations/planner.ts | 5 +- .../sqlite/src/core/migrations/runner.ts | 6 +- .../sqlite/src/exports/default-normalizer.ts | 5 +- .../sqlite/src/core/control-adapter.ts | 6 +- 5 files changed, 91 insertions(+), 5 deletions(-) diff --git a/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts b/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts index 1ba53b732a..29e5d12a72 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts @@ -7,6 +7,7 @@ * `target-sqlite` reaching into `adapter-sqlite`. */ +import type { JsonValue } from '@prisma-next/contract/types'; import type { ColumnDefault } from '@prisma-next/sql-contract/types'; /** @@ -57,3 +58,76 @@ export function parseSqliteDefault( return { kind: 'expression', expression: trimmed }; } + +/** + * Extracts the codec-comparable {@link JsonValue} out of a raw SQLite + * column default expression (the value `pragma_table_info.dflt_value` + * returns). Mirror of `parsePostgresDefaultValue` in + * `target-postgres/src/core/default-normalizer.ts`; the verifier dispatches + * the returned {@link JsonValue} through the column's codec + * (`codec.decodeJson(...)` → `codec.renderSqlLiteral(...)`) and compares + * the result against the contract-side expression. + * + * Returns `undefined` for non-literal forms (`CURRENT_TIMESTAMP`, + * `datetime('now')`); the verifier falls back to the legacy normalizer + * path for those. + * + * SQLite is loose-typed at the storage layer: it stores affinities, not + * strict per-column types. The parser therefore relies on the `nativeType` + * hint to disambiguate quoted numerics from quoted strings. + */ +export function parseSqliteDefaultValue( + rawDefault: string, + nativeType: string, +): JsonValue | undefined { + let trimmed = rawDefault.trim(); + + // Strip outer parens iteratively (SQLite wraps expressions like `(1)` in + // parens; the recreate-table postcheck builder mirrors this). + while (true) { + const stripped = stripOuterParens(trimmed).trim(); + if (stripped === trimmed) break; + trimmed = stripped; + } + + const lower = trimmed.toLowerCase(); + if (lower === 'current_timestamp' || lower === "datetime('now')" || lower === 'datetime("now")') { + return undefined; + } + + // Boolean literals (SQLite supports both `1`/`0` and `true`/`false`). + if (/^true$/i.test(trimmed)) return true; + if (/^false$/i.test(trimmed)) return false; + + // Numerics — bare or quoted-with-cast — on a numeric nativeType + // (SQLite's affinity is integer / real / numeric). + if (/^(?:int|bigint|smallint|numeric|real|float|double)/i.test(nativeType)) { + const numericMatch = trimmed.match(/^'?(-?\d+(?:\.\d+)?)'?$/); + if (numericMatch?.[1] !== undefined) { + const n = Number(numericMatch[1]); + if (Number.isFinite(n)) return n; + } + } + + // JSON literals — SQLite's text-JSON columns store JSON as TEXT; + // `pragma_table_info.dflt_value` returns the quoted JSON literal. + if (/json/i.test(nativeType)) { + const stringMatch = trimmed.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + try { + return JSON.parse(stringMatch[1].replace(/''/g, "'")); + } catch { + return undefined; + } + } + } + + // Quoted strings — strip outer single quotes; SQLite uses `''` for + // embedded quotes (same as Postgres). + const stringMatch = trimmed.match(/^'((?:[^']|'')*)'$/); + if (stringMatch?.[1] !== undefined) { + return stringMatch[1].replace(/''/g, "'"); + } + + return undefined; +} diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts index 57a47e9564..6bc32d8ab4 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/planner.ts @@ -17,7 +17,8 @@ import type { MigrationScaffoldContext, SchemaIssue, } from '@prisma-next/framework-components/control'; -import { parseSqliteDefault } from '../default-normalizer'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { parseSqliteDefault, parseSqliteDefaultValue } from '../default-normalizer'; import { normalizeSqliteNativeType } from '../native-type-normalizer'; import { planIssues } from './issue-planner'; import { @@ -176,6 +177,8 @@ export class SqliteMigrationPlanner typeMetadataRegistry: new Map(), frameworkComponents: options.frameworkComponents, normalizeDefault: parseSqliteDefault, + parseSchemaDefaultValue: parseSqliteDefaultValue, + codecLookup: extractCodecLookup(options.frameworkComponents), normalizeNativeType: normalizeSqliteNativeType, }); return verifyResult.schema.issues; diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts index 434ad3aec2..8dba0cef0b 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts @@ -16,11 +16,11 @@ import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control'; import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify'; import { type ContractMarkerRow, parseContractMarkerRow } from '@prisma-next/family-sql/verify'; import type { ControlDriverInstance } from '@prisma-next/framework-components/control'; -import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; +import { APP_SPACE_ID, extractCodecLookup } from '@prisma-next/framework-components/control'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Result } from '@prisma-next/utils/result'; import { notOk, ok, okVoid } from '@prisma-next/utils/result'; -import { parseSqliteDefault } from '../default-normalizer'; +import { parseSqliteDefault, parseSqliteDefaultValue } from '../default-normalizer'; import { normalizeSqliteNativeType } from '../native-type-normalizer'; import type { SqlitePlanTargetDetails } from './planner-target-details'; import { @@ -158,6 +158,8 @@ class SqliteMigrationRunner implements SqlMigrationRunner { readonly targetId = 'sqlite' as const; readonly normalizeDefault = parseSqliteDefault; + readonly parseSchemaDefaultValue = parseSqliteDefaultValue; readonly normalizeNativeType = normalizeSqliteNativeType; /** From 8c2d9706cfbff16f8f3be1d206cd7774e676a11c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Wed, 20 May 2026 23:57:46 +0200 Subject: [PATCH 42/50] feat(family-sql): SqlControlAdapter.parseSchemaDefaultValue + control-instance threading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional parseSchemaDefaultValue slot to SqlControlAdapter so target adapters (PostgresControlAdapter, SqliteControlAdapter) can expose their codec-aware parser to the family-level verifier. control-instance.verifySchema (used by `prisma-next verify` and the SQL family's direct verify entry point) now threads: - codecLookup: extractCodecLookup(options.frameworkComponents) - parseSchemaDefaultValue: controlAdapter.parseSchemaDefaultValue into verifySqlSchema, so every verify path — planner.plan, runner.execute, and family-instance.verifySchema — uniformly carries codec-aware compare when the target adapter supplies the parser. --- .../2-sql/9-family/src/core/control-adapter.ts | 15 ++++++++++++++- .../2-sql/9-family/src/core/control-instance.ts | 3 +++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/2-sql/9-family/src/core/control-adapter.ts b/packages/2-sql/9-family/src/core/control-adapter.ts index 0a9d818751..0d4055f2e5 100644 --- a/packages/2-sql/9-family/src/core/control-adapter.ts +++ b/packages/2-sql/9-family/src/core/control-adapter.ts @@ -11,7 +11,11 @@ import type { LowererContext, } from '@prisma-next/sql-relational-core/ast'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; -import type { DefaultNormalizer, NativeTypeNormalizer } from './schema-verify/verify-sql-schema'; +import type { + DefaultNormalizer, + NativeTypeNormalizer, + SchemaDefaultValueParser, +} from './schema-verify/verify-sql-schema'; /** * SQL control adapter interface for control-plane operations. @@ -77,6 +81,15 @@ export interface SqlControlAdapter */ readonly normalizeDefault?: DefaultNormalizer; + /** + * Optional target-specific parser that extracts the codec-comparable + * {@link JsonValue} out of a raw schema-side default expression. The + * verifier uses it to round-trip the introspected literal through the + * column's codec (`decodeJson` → `renderSqlLiteral`) and compare against + * the contract-side codec-rendered expression. + */ + readonly parseSchemaDefaultValue?: SchemaDefaultValueParser; + /** * Optional target-specific normalizer for schema native type names. * When provided, schema native types (from introspection) are normalized diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index 7f9a888c91..71782719dc 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -19,6 +19,7 @@ import type { } from '@prisma-next/framework-components/control'; import { APP_SPACE_ID, + extractCodecLookup, SchemaTreeNode, VERIFY_CODE_HASH_MISMATCH, VERIFY_CODE_MARKER_MISSING, @@ -533,7 +534,9 @@ export function createSqlFamilyInstance( strict: options.strict, typeMetadataRegistry, frameworkComponents: options.frameworkComponents, + codecLookup: extractCodecLookup(options.frameworkComponents), ...ifDefined('normalizeDefault', controlAdapter.normalizeDefault), + ...ifDefined('parseSchemaDefaultValue', controlAdapter.parseSchemaDefaultValue), ...ifDefined('normalizeNativeType', controlAdapter.normalizeNativeType), ...ifDefined('resolveExistingEnumValues', controlAdapter.resolveExistingEnumValues), }); From 971c38f948d938568265109bb81a3d833430074a Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 21 May 2026 00:04:45 +0200 Subject: [PATCH 43/50] test(e2e): update DDL snapshot for codec-rendered defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DDL inline snapshot was set before M2 codec-owned defaults landed. After M2, defaults render via codec.renderSqlLiteral (carrying the codec-specific cast suffix) and the DDL builder wraps them as DEFAULT () per spec § Scope (D5 Postgres DDL renderer). Snapshot mismatches were observable on: - 'scheduled_at' (cast suffix now included) - 'active' (TRUE / FALSE per codec) - 'big_count' (parens wrap) - 'label' (::text cast) - 'metadata' / 'tags' (parens wrap) - 'rating' / 'score' (parens wrap) Each new form matches what the M2 codec-rendered contract.json now carries; the snapshot lagged behind. Updated via `pnpm test --update`. --- test/e2e/framework/test/ddl.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/e2e/framework/test/ddl.test.ts b/test/e2e/framework/test/ddl.test.ts index 0f60103bad..4811282345 100644 --- a/test/e2e/framework/test/ddl.test.ts +++ b/test/e2e/framework/test/ddl.test.ts @@ -34,19 +34,19 @@ describe('DDL E2E Tests', { timeout: 30000 }, () => { "created_at" timestamptz DEFAULT (now()) NOT NULL, "id" character(36) NOT NULL, "name" text NOT NULL, - "scheduled_at" timestamptz DEFAULT '2024-01-15T10:30:00.000Z' NOT NULL, + "scheduled_at" timestamptz DEFAULT ('2024-01-15T10:30:00.000Z'::timestamp with time zone) NOT NULL, PRIMARY KEY ("id") ); CREATE TABLE "literal_defaults" ( - "active" bool DEFAULT true NOT NULL, - "big_count" int8 DEFAULT 9007199254740991 NOT NULL, + "active" bool DEFAULT (TRUE) NOT NULL, + "big_count" int8 DEFAULT (9007199254740991) NOT NULL, "id" SERIAL NOT NULL, - "label" text DEFAULT 'draft' NOT NULL, - "metadata" jsonb DEFAULT '{"key":"default"}'::jsonb NOT NULL, - "rating" float8 DEFAULT 3.14 NOT NULL, - "score" int4 DEFAULT 0 NOT NULL, - "tags" jsonb DEFAULT '["alpha","beta"]'::jsonb NOT NULL, + "label" text DEFAULT ('draft'::text) NOT NULL, + "metadata" jsonb DEFAULT ('{"key":"default"}'::jsonb) NOT NULL, + "rating" float8 DEFAULT (3.14) NOT NULL, + "score" int4 DEFAULT (0) NOT NULL, + "tags" jsonb DEFAULT ('["alpha","beta"]'::jsonb) NOT NULL, PRIMARY KEY ("id") ); From 83074e777c2ba8e0441d4303479319e799524e30 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 21 May 2026 00:07:14 +0200 Subject: [PATCH 44/50] chore(family-sql): drop transient project ID from codec-defaults test comment The test comment referenced a project-internal dispatch identifier; rewrite it to describe what the fallback ensures (legacy normalizer path remains the no-codec compare). --- .../9-family/test/schema-verify.codec-defaults.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts b/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts index ab1987675b..293f071216 100644 --- a/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts +++ b/packages/2-sql/9-family/test/schema-verify.codec-defaults.test.ts @@ -392,9 +392,10 @@ describe('verifySqlSchema — codec-aware default comparison', () => { }); it('falls back to the legacy normalizer path when no codec or parser is supplied', () => { - // No codecLookup or parseSchemaDefaultValue → the function must behave - // exactly as it did before D9 (string-normalised compare via the - // optional normalizer). + // No codecLookup or parseSchemaDefaultValue → fall back to the legacy + // string-normalised compare via the optional normalizer. Required so + // callers without codec dispatch wired up still get the prior + // verification behaviour. const contract = createTestContract({ user: createContractTable({ status: { From 3cbdd29fe47b88d170130bb119b7385fa25e2d01 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 21 May 2026 10:02:53 +0200 Subject: [PATCH 45/50] fix(telemetry-backend)!: regenerate emitted contract for new ColumnDefault union --- apps/telemetry-backend/src/prisma/contract.d.ts | 12 +++--------- apps/telemetry-backend/src/prisma/contract.json | 7 +++---- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/telemetry-backend/src/prisma/contract.d.ts b/apps/telemetry-backend/src/prisma/contract.d.ts index 68dfc2d907..ba84c5aaf6 100644 --- a/apps/telemetry-backend/src/prisma/contract.d.ts +++ b/apps/telemetry-backend/src/prisma/contract.d.ts @@ -27,7 +27,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:41700ef5fda97339b39ea345a56aae72a1ff4be11ddc3ffcab7130bfc71c109d'>; + StorageHashBase<'sha256:8f73b933408b7f9c5a640c9e66325d74cdafe006ca933103dcf5fbc528cb08a8'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e'>; @@ -35,9 +35,6 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps; -type DefaultLiteralValue = CodecId extends keyof CodecTypes - ? CodecTypes[CodecId]['output'] - : _Encoded; export type FieldOutputTypes = { readonly TelemetryEvent: { @@ -97,16 +94,13 @@ type ContractBase = ContractType< readonly nativeType: 'int8'; readonly codecId: 'pg/int8@1'; readonly nullable: false; - readonly default: { - readonly kind: 'function'; - readonly expression: 'autoincrement()'; - }; + readonly default: { readonly kind: 'autoincrement' }; }; readonly ingestedAt: { readonly nativeType: 'timestamptz'; readonly codecId: 'pg/timestamptz@1'; readonly nullable: false; - readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + readonly default: { readonly kind: 'expression'; readonly expression: 'now()' }; }; readonly installationId: { readonly nativeType: 'text'; diff --git a/apps/telemetry-backend/src/prisma/contract.json b/apps/telemetry-backend/src/prisma/contract.json index e034829982..bc9553fa1c 100644 --- a/apps/telemetry-backend/src/prisma/contract.json +++ b/apps/telemetry-backend/src/prisma/contract.json @@ -211,8 +211,7 @@ "id": { "codecId": "pg/int8@1", "default": { - "expression": "autoincrement()", - "kind": "function" + "kind": "autoincrement" }, "nativeType": "int8", "nullable": false @@ -221,7 +220,7 @@ "codecId": "pg/timestamptz@1", "default": { "expression": "now()", - "kind": "function" + "kind": "expression" }, "nativeType": "timestamptz", "nullable": false @@ -280,7 +279,7 @@ } } }, - "storageHash": "sha256:41700ef5fda97339b39ea345a56aae72a1ff4be11ddc3ffcab7130bfc71c109d" + "storageHash": "sha256:8f73b933408b7f9c5a640c9e66325d74cdafe006ca933103dcf5fbc528cb08a8" }, "capabilities": { "postgres": { From a8d42bafdf1a0a3d23ec9e83ef3569038ffe6fd1 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 21 May 2026 14:04:15 +0200 Subject: [PATCH 46/50] feat(contract-ts): extract .default TInput from codec descriptor Replace the closed SqlDslLiteralInput enumeration with a descriptor-aware CodecInputForDescriptor extractor. When a field descriptor surfaces a codecFactory slot (the shape produced by the framework column() packager), the codec instance ReturnType<...> flows into the .default(...) parameter via the Codec interface s TInput generic. Descriptors without a codecFactory slot keep the broad pre-codec fallback so legacy / test-helper authoring sites remain unaffected. The DSL no longer enumerates what JS-native shapes are admitted; codec authors do, through the Codec TInput they declare. Branded types, extension-owned class instances, and any other shape a codec admits now compile through .default(...) without the DSL having to know about them. --- .../contract-ts/src/contract-dsl.ts | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts index af5624ffcd..fbe79b8146 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts @@ -6,6 +6,7 @@ import type { ForeignKeyDefaultsState } from '@prisma-next/contract-authoring'; import type { AuthoringFieldPresetDescriptor } from '@prisma-next/framework-components/authoring'; import { instantiateAuthoringFieldPreset } from '@prisma-next/framework-components/authoring'; import type { + Codec, CodecLookup, CodecTrait, ColumnTypeDescriptor, @@ -148,25 +149,50 @@ type FieldDescriptor = State extends { : never; /** - * JS-native literal values accepted by `.default(value)` on the TS DSL. - * Widens {@link ColumnDefaultLiteralInputValue} (the IR's pre-codec input - * envelope) with `bigint` and `Uint8Array` — values that flow directly - * into `codec.renderSqlLiteral` without a JSON round-trip at the TS DSL - * surface. `Buffer` extends `Uint8Array` in Node so it lands here too. - * The IR's `ColumnDefaultLiteralValue = JsonValue` remains narrower; the - * DSL widening only affects what `.default(...)` accepts before codec - * dispatch. + * Open fallback shape for `.default(value)` when the field descriptor + * carries no codec reference at the type level (e.g. raw + * {@link ColumnTypeDescriptor} shapes produced by ad-hoc or test + * helpers). Widens {@link ColumnDefaultLiteralInputValue} (the IR's + * pre-codec input envelope) with `bigint` and `Uint8Array`. Production + * column helpers (`column(...)`) surface a `codecFactory` slot, so they + * resolve through {@link CodecInputForDescriptor} instead and the + * codec's own `TInput` decides what compiles. */ -type SqlDslLiteralInput = ColumnDefaultLiteralInputValue | bigint | Uint8Array; +type SqlDslLiteralInputFallback = ColumnDefaultLiteralInputValue | bigint | Uint8Array; + +/** + * Extract the codec's `TInput` from a field descriptor that carries a + * `codecFactory` slot — the shape produced by the framework `column()` + * packager. The factory's return type is the codec instance; the + * codec's fourth generic is `TInput`. When the descriptor surfaces no + * codec slot (e.g. a bare {@link ColumnTypeDescriptor}), this resolves + * to {@link SqlDslLiteralInputFallback} so legacy authoring sites keep + * the broad pre-codec literal surface. + * + * The factory parameter list is typed `never[]` so the extractor is + * agnostic to whether the descriptor's factory takes `void` or a params + * record — only the return type matters. + */ +export type CodecInputForDescriptor = D extends { + readonly codecFactory: (...args: never[]) => infer R; +} + ? R extends Codec + ? TInput + : SqlDslLiteralInputFallback + : SqlDslLiteralInputFallback; /** * Compute the `.default(value)` parameter for a column builder state. - * Combines the literal-input shape with the trait-gated autoincrement - * sentinel; columns whose descriptor lacks the `'autoincrement'` trait - * see only the literal arm. + * Combines the descriptor-resolved codec input with the trait-gated + * autoincrement sentinel; columns whose descriptor lacks the + * `'autoincrement'` trait see only the codec-input arm. The codec's + * own `TInput` is the open-set source of truth for what + * `.default(...)` accepts: branded types, extension-owned classes, and + * any other shape the codec admits all compile via this seam without + * the DSL enumerating them. */ export type DefaultInputForState = - | SqlDslLiteralInput + | CodecInputForDescriptor> | AllowAutoincrement>; type HasNamedConstraintId = From e822570749d279b94500cd35f1a0962e2c6140ec Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 21 May 2026 14:10:28 +0200 Subject: [PATCH 47/50] test(contract-ts): branded-type cases prove .default open-set extractor Add synthetic-codec test cases that exercise CodecInputForDescriptor with a TInput the DSL cannot enumerate: a branded string, a branded number, and a nominal class instance (a class with a private field that defeats structural matching). Each case has a positive (the codec-admitted shape compiles) and a negative case (a plain string / number / unrelated class is rejected via @ts-expect-error). The existing enumeration-coverage tests above (JsonValue, Date, bigint, Uint8Array) only exercise members of the legacy closed union and would compile under either implementation; the new cases fail to compile under the closed enumeration and only compile because the codec descriptor surfaces TInput through codecFactory. Also extend the production-helpers integration test with .default(...) cases against pgTextColumn / pgInt4Column / pgBoolColumn, including negative cases that prove a production codec s TInput narrows the DSL parameter (e.g. pgTextColumn().default(42) does not compile). That test lives under test/integration/ because the sql/authoring package is forbidden from depending on targets. A new test/helpers/synthetic-codec-descriptor.ts produces a descriptor compatible with the framework column() packager s shape (adds codecFactory) so synthetic codecs can thread an arbitrary TInput into the DSL without depending on production codec packs. --- .../test/contract-builder.default.test-d.ts | 107 ++++++++++++++++++ .../helpers/synthetic-codec-descriptor.ts | 58 ++++++++++ ...ilder.default.production-helpers.test-d.ts | 23 ++++ 3 files changed, 188 insertions(+) create mode 100644 packages/2-sql/2-authoring/contract-ts/test/helpers/synthetic-codec-descriptor.ts diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts index ce3ea3d7b4..e239486b42 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.default.test-d.ts @@ -17,6 +17,7 @@ import { describe, test } from 'vitest'; import { autoincrement, field } from '../src/contract-builder'; import { columnDescriptor, columnDescriptorWithTraits } from './helpers/column-descriptor'; +import { syntheticCodecDescriptor } from './helpers/synthetic-codec-descriptor'; const int4Column = columnDescriptorWithTraits('pg/int4@1', [ 'equality', @@ -95,3 +96,109 @@ describe('autoincrement() sentinel identity', () => { } }); }); + +// These tests are the load-bearing proof that the `.default(value)` extractor +// is open-set: an arbitrary codec's `TInput` flows into the DSL without the +// DSL enumerating the shape. A closed enumeration would compile the legacy +// JSON / Date / bigint / Uint8Array cases above while rejecting the branded +// values and class instances below — which is exactly the failure-mode the +// extractor replaces. + +declare const emailAddressBrand: unique symbol; +type EmailAddress = string & { readonly [emailAddressBrand]: 'EmailAddress' }; + +declare const userIdBrand: unique symbol; +type UserId = number & { readonly [userIdBrand]: 'UserId' }; + +class Money { + // Private field makes Money nominal: structurally-equivalent plain objects + // do not satisfy the class type. This is the load-bearing distinction + // between "codec admits a class instance" and "codec admits a plain bag". + readonly #nominal = true; + constructor( + readonly amount: number, + readonly currency: string, + ) { + void this.#nominal; + } +} + +class Temperature { + readonly #nominal = true; + constructor(readonly celsius: number) { + void this.#nominal; + } +} + +describe('.default(value) extracts codec TInput from descriptor (branded scalars)', () => { + test('accepts a branded string when the codec admits it', () => { + const emailColumn = syntheticCodecDescriptor< + 'app/email@1', + readonly ['equality'], + EmailAddress + >('app/email@1', ['equality'] as const, 'text'); + const branded = 'user@example.com' as EmailAddress; + field.column(emailColumn).default(branded); + }); + + test('rejects an unbranded string for a brand-typed codec', () => { + const emailColumn = syntheticCodecDescriptor< + 'app/email@1', + readonly ['equality'], + EmailAddress + >('app/email@1', ['equality'] as const, 'text'); + // @ts-expect-error a plain string is not assignable to EmailAddress without the brand + field.column(emailColumn).default('user@example.com'); + }); + + test('accepts a branded number when the codec admits it', () => { + const userIdColumn = syntheticCodecDescriptor< + 'app/userId@1', + readonly ['equality', 'order'], + UserId + >('app/userId@1', ['equality', 'order'] as const, 'int4'); + const branded = 42 as UserId; + field.column(userIdColumn).default(branded); + }); + + test('rejects an unbranded number for a brand-typed codec', () => { + const userIdColumn = syntheticCodecDescriptor< + 'app/userId@1', + readonly ['equality', 'order'], + UserId + >('app/userId@1', ['equality', 'order'] as const, 'int4'); + // @ts-expect-error a plain number is not assignable to UserId without the brand + field.column(userIdColumn).default(7); + }); +}); + +describe('.default(value) extracts codec TInput from descriptor (class instances)', () => { + test('accepts a Money instance when the codec admits Money', () => { + const moneyColumn = syntheticCodecDescriptor<'app/money@1', readonly ['equality'], Money>( + 'app/money@1', + ['equality'] as const, + 'numeric', + ); + field.column(moneyColumn).default(new Money(99, 'USD')); + }); + + test('rejects an unrelated class instance for a Money-typed codec', () => { + const moneyColumn = syntheticCodecDescriptor<'app/money@1', readonly ['equality'], Money>( + 'app/money@1', + ['equality'] as const, + 'numeric', + ); + // @ts-expect-error Temperature is structurally incompatible with Money (different property set) + field.column(moneyColumn).default(new Temperature(20)); + }); + + test('rejects a plain object literal for a class-typed codec', () => { + const moneyColumn = syntheticCodecDescriptor<'app/money@1', readonly ['equality'], Money>( + 'app/money@1', + ['equality'] as const, + 'numeric', + ); + // @ts-expect-error plain object is missing the nominal class identity Money carries + field.column(moneyColumn).default({ amount: 99, currency: 'USD' }); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-ts/test/helpers/synthetic-codec-descriptor.ts b/packages/2-sql/2-authoring/contract-ts/test/helpers/synthetic-codec-descriptor.ts new file mode 100644 index 0000000000..56d67e218a --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/test/helpers/synthetic-codec-descriptor.ts @@ -0,0 +1,58 @@ +/** + * Synthetic-codec test helper for the `.default(value)` type extractor. + * + * The DSL's {@link import('../../src/contract-dsl').CodecInputForDescriptor} + * extractor reads a codec's `TInput` off the field descriptor's + * `codecFactory` slot — the shape produced by the framework `column()` + * packager. Production tests already exercise that path via real codec + * packs; the synthetic helper here lets type-level tests probe the + * extractor with arbitrary `TInput` shapes (branded types, custom + * classes, etc.) without depending on production codecs. + * + * The helper returns a descriptor compatible with `FieldDescriptorShape` + * plus a `codecFactory` slot whose return type carries the configured + * `TInput`. The factory is never invoked at runtime in type-level tests; + * the helper exists purely to thread `TInput` into the descriptor's + * static type. + */ +import type { + Codec, + CodecCallContext, + CodecInstanceContext, + CodecTrait, + ColumnTypeDescriptor, +} from '@prisma-next/framework-components/codec'; + +export type SyntheticCodecDescriptor< + TCodecId extends string, + TTraits extends readonly CodecTrait[], + TInput, +> = ColumnTypeDescriptor & { + readonly codecId: TCodecId; + readonly traits: TTraits; + readonly codecFactory: (ctx: CodecInstanceContext) => Codec; +}; + +export function syntheticCodecDescriptor< + const TCodecId extends string, + const TTraits extends readonly CodecTrait[], + TInput, +>( + codecId: TCodecId, + traits: TTraits, + nativeType?: string, +): SyntheticCodecDescriptor { + const derived = nativeType ?? codecId.match(/^[^/]+\/([^@]+)@/)?.[1] ?? codecId; + return { + codecId, + nativeType: derived, + traits, + codecFactory: (): Codec => ({ + id: codecId, + encode: async (_value: TInput, _ctx: CodecCallContext) => undefined, + decode: async (_wire: unknown, _ctx: CodecCallContext) => undefined as TInput, + encodeJson: () => null, + decodeJson: () => undefined as TInput, + }), + }; +} diff --git a/test/integration/test/contract-builder.default.production-helpers.test-d.ts b/test/integration/test/contract-builder.default.production-helpers.test-d.ts index 75a7ba1abf..5628260504 100644 --- a/test/integration/test/contract-builder.default.production-helpers.test-d.ts +++ b/test/integration/test/contract-builder.default.production-helpers.test-d.ts @@ -34,3 +34,26 @@ describe('.default(autoincrement()) trait gating against production column helpe field.column(pgBoolColumn()).default(autoincrement()); }); }); + +describe('.default(value) extracts codec TInput from production column helpers', () => { + test('pgTextColumn().default(string) compiles', () => { + field.column(pgTextColumn()).default('hello'); + }); + + test('pgInt4Column().default(number) compiles', () => { + field.column(pgInt4Column()).default(42); + }); + + test('pgBoolColumn().default(boolean) compiles', () => { + field.column(pgBoolColumn()).default(true); + }); + + test('rejects values outside the codec TInput', () => { + // @ts-expect-error pg/text@1 codec TInput is string, not number + field.column(pgTextColumn()).default(42); + // @ts-expect-error pg/int4@1 codec TInput is number, not string + field.column(pgInt4Column()).default('not a number'); + // @ts-expect-error pg/bool@1 codec TInput is boolean, not string + field.column(pgBoolColumn()).default('true'); + }); +}); From 1cbed38cdc48f7a91e85865e3147b6d8af0696e0 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 21 May 2026 14:11:18 +0200 Subject: [PATCH 48/50] docs(drive): record F6 closed-set typing failure mode Capture the failure mode that the codec-owned-defaults M2 D2 implementation exhibited: typing a value as a closed union of example shapes named in the spec, while the spec itself demanded an open set extracted from a structural source (a codec descriptor s TInput, in this case). The enumeration-only test suite compiled cleanly under either implementation; the gap only surfaced when a real user (the operator) read the implementation against the spec NFR. Mitigation lives in two places: the reviewer s checklist (is at least one test case impossible to satisfy under a hand-coded closed union of the spec s example list?) and the orchestrator s intent-validation probe (compare test-case set vs implementation union member set bytewise; identical sets are a red flag). --- drive/calibration/failure-modes.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/drive/calibration/failure-modes.md b/drive/calibration/failure-modes.md index 176ba6f715..90caefa6b9 100644 --- a/drive/calibration/failure-modes.md +++ b/drive/calibration/failure-modes.md @@ -100,6 +100,25 @@ Patterns to **catch** the F-family modes live in [`grep-library.md`](./grep-libr **Reference incident.** 2026-05-17, a family-sql M-sized migration dispatch apparently ran a setup cleanup (likely `git clean -fd`) that deleted an in-flight methodology project directory (~1500 lines of untracked docs). Survived only because the orchestrator had the content in conversation context and could re-write it. +### F6. Closed-set typing that satisfies enumeration-only tests while violating open-set spec promise + +**Symptom.** The implementation types a value as a closed union of N concrete shapes (e.g. `string | number | Date | bigint | Uint8Array`). The tests exercise exactly those N shapes — strings, numbers, Dates, bigints, Uint8Arrays — and they compile, so the gate goes green. But the spec or NFR demands an *open set* defined by some structural extractor (a codec descriptor's `TInput`, a target's column-type contract, an extension-provided schema). Values outside the closed union but inside the extractor's range fail to compile despite the spec promising they will. The reviewer sees green tests + matching enumeration in the implementation and signs off; the orchestrator's intent-validation step does not probe whether the test set non-trivially exercises the spec's open-set promise. + +**Detection signal.** + +- The implementation's value-input type is a finite union literal (no `extends infer T`, no extractor at the type level). +- Every test case's value is a member of the implementation's union literal — bytewise. +- The spec or NFR uses words like "codec-defined", "target-defined", "extension-defined", "user-defined", "any shape the X admits", "open-set", "branded types". +- Test names enumerate the spec's example list ("accepts string", "accepts number", "accepts Date") rather than probe the open-set property ("accepts an arbitrary branded type", "accepts an extension-owned class instance"). + +**Mitigation.** + +- When a spec NFR / AC references "codec-defined / target-defined / extension-defined / branded" types, the test set must exercise **at least one type the implementation cannot enumerate** — a branded type, a synthetic codec's `TInput`, an extension-owned class instance, or any other value that fails to compile under the closed union and only compiles when the extractor is real. +- The reviewer's checklist asks: "is at least one test case impossible to satisfy under a hand-coded closed union of the spec's example list?" If no — the test set is enumeration-only and the open-set promise is unverified. +- The orchestrator's intent-validation step probes whether the test set is tautological: name each test case's value, name each implementation-union member, and check whether the two sets are identical. Identical sets are a red flag. + +**Reference incident.** 2026-05-21 D10. The SQL DSL `.default(value)` parameter was typed `SqlDslLiteralInput = ColumnDefaultLiteralInputValue | bigint | Uint8Array` (a closed enumeration). The existing AC test (`contract-builder.default.test-d.ts`) exercised exactly those shapes — a string, a number, `null`, an object, a `Date`, a `bigint`, a `Uint8Array` — all members of the closed union. The spec NFR2 said: "JS-native default values pass through without JSON round-trips in the TS DSL. Date, bigint, Buffer, Uint8Array, **and codec-defined branded types** are accepted by `.default(...)` directly, where the codec's `TInput` admits them." The reviewer passed the dispatch (D2 R1); the orchestrator's intent-validation step did not probe the codec-defined arm. Caught by the user reading the implementation. Fixed by replacing the closed union with `CodecInputForDescriptor>`, which reads the codec's `TInput` off the descriptor's `codecFactory` slot, and by adding branded-type / nominal-class test cases that fail to compile under the closed union. + ## Slice-shape scope traps Patterns that have produced scope creep in the past — catch these at triage or slice-spec time, not at execution time. From a71c4800112840bb2603e103a6bd6aaa9f80839d Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 21 May 2026 15:04:25 +0200 Subject: [PATCH 49/50] refactor(framework-components): delete dead codecValue arm from AuthoringColumnDefault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the AuthoringColumnDefaultTemplateCodecValue interface and its arm from the AuthoringColumnDefaultTemplate union. Collapse AuthoringColumnDefault to the two-arm shape that mirrors the contract IR: expression | autoincrement. Delete isAuthoringColumnDefaultCodecLiteralValue (closed-set runtime guard that was the F1 pattern: a renamed copy of the deleted framework-foundation isColumnDefaultLiteralInputValue). Delete the codecValue branch from resolveAuthoringColumnDefaultTemplate — the function now handles only autoincrement and expression, matching the narrowed template union. Remove AuthoringColumnDefaultTemplateCodecValue from the authoring export. Update tests: remove the two codecValue-specific test cases (they cover deleted code); replace the expression+executionDefaults integration test with a version that uses kind: expression throughout; update the control-stack stubLower fixture to use kind: expression. --- .../src/exports/authoring.ts | 1 - .../src/shared/framework-authoring.ts | 66 ++----------------- .../test/control-stack.test.ts | 5 +- .../framework-components.authoring.test.ts | 39 ++--------- 4 files changed, 13 insertions(+), 98 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts index 01dbc789af..f3e6f35be4 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts @@ -4,7 +4,6 @@ export type { AuthoringColumnDefault, AuthoringColumnDefaultTemplate, AuthoringColumnDefaultTemplateAutoincrement, - AuthoringColumnDefaultTemplateCodecValue, AuthoringColumnDefaultTemplateExpression, AuthoringContributions, AuthoringEntityContext, diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts index b03de3e8e6..5cccd09139 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts @@ -55,21 +55,6 @@ export interface AuthoringTypeConstructorDescriptor { readonly output: AuthoringStorageTypeTemplate; } -/** - * Preset-template arm for a literal default value awaiting codec dispatch. - * The preset declares the literal up-front (a `AuthoringTemplateValue` so - * preset args can flow in); the SQL authoring layer materializes it through - * `codec.renderSqlLiteral` at emit time, once the column's codec is known. - * - * Mirrors the DSL-internal `AuthoredColumnDefault.codecValue` arm so the - * preset surface and the TS DSL surface feed the same authoring shape into - * the emitter. - */ -export interface AuthoringColumnDefaultTemplateCodecValue { - readonly kind: 'codecValue'; - readonly value: AuthoringTemplateValue; -} - /** * Preset-template arm for a SQL expression default that bypasses codec * dispatch entirely. Lowers directly to the contract IR's @@ -92,22 +77,17 @@ export interface AuthoringColumnDefaultTemplateAutoincrement { } export type AuthoringColumnDefaultTemplate = - | AuthoringColumnDefaultTemplateCodecValue | AuthoringColumnDefaultTemplateExpression | AuthoringColumnDefaultTemplateAutoincrement; /** * Resolved authoring-default shape produced by - * {@link instantiateAuthoringFieldPreset}. Distinct from the contract IR's - * `ColumnDefault` — carries an extra `codecValue` arm holding a literal - * value that has not yet been dispatched through `codec.renderSqlLiteral`. - * Authoring consumers (SQL DSL `buildFieldPreset`, PSL preset resolver) - * forward this through their emit path; the literal `codecValue` arm is - * materialised to `{ kind: 'expression', expression }` once a codec is in - * scope. + * {@link instantiateAuthoringFieldPreset}. Structurally identical to the + * contract IR's `ColumnDefault`: presets declare either a SQL expression + * (bypasses codec dispatch entirely) or the `autoincrement` target-mechanism + * sentinel. Authoring consumers forward this shape directly to the emitter. */ export type AuthoringColumnDefault = - | { readonly kind: 'codecValue'; readonly value: unknown } | { readonly kind: 'expression'; readonly expression: string } | { readonly kind: 'autoincrement' }; @@ -568,29 +548,6 @@ function resolveAuthoringStorageTypeTemplate( }; } -function isAuthoringColumnDefaultCodecLiteralValue(value: unknown): boolean { - if ( - value === null || - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return true; - } - if (value instanceof Date) { - return true; - } - if (Array.isArray(value)) { - return value.every(isAuthoringColumnDefaultCodecLiteralValue); - } - if (typeof value === 'object') { - return Object.values(value as Record).every( - isAuthoringColumnDefaultCodecLiteralValue, - ); - } - return false; -} - function resolveAuthoringColumnDefaultTemplate( template: AuthoringColumnDefaultTemplate, args: readonly unknown[], @@ -598,21 +555,6 @@ function resolveAuthoringColumnDefaultTemplate( if (template.kind === 'autoincrement') { return { kind: 'autoincrement' }; } - if (template.kind === 'codecValue') { - const value = resolveAuthoringTemplateValue(template.value, args); - if (value === undefined) { - throw new Error('Resolved authoring literal default must not be undefined'); - } - if (!isAuthoringColumnDefaultCodecLiteralValue(value)) { - throw new Error( - `Resolved authoring literal default must be a JSON-serializable value or Date, received ${String(value)}`, - ); - } - return { - kind: 'codecValue', - value, - }; - } const expression = resolveAuthoringTemplateValue(template.expression, args); if (expression === undefined || (typeof expression === 'object' && expression !== null)) { diff --git a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts index fc3d9b3f5c..f2b4b8a680 100644 --- a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts +++ b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts @@ -322,7 +322,10 @@ describe('assembleScalarTypeDescriptors', () => { describe('assembleControlMutationDefaults', () => { const stubLower = () => ({ ok: true as const, - value: { kind: 'storage' as const, defaultValue: { kind: 'codecValue' as const, value: 0 } }, + value: { + kind: 'storage' as const, + defaultValue: { kind: 'expression' as const, expression: '0' }, + }, }); it('returns empty registry and generators when no descriptors contribute', () => { diff --git a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts index 536a8f4791..0aee63b3d5 100644 --- a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts +++ b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts @@ -302,29 +302,7 @@ describe('authoring template resolution', () => { ).toThrow(/Resolved authoring expression default must resolve to a primitive/); }); - it('rejects codecValue defaults that resolve to undefined', () => { - const descriptor = { - kind: 'fieldPreset', - output: { - codecId: 'test/text@1', - nativeType: 'text', - default: { - kind: 'codecValue', - value: { - kind: 'arg', - index: 0, - path: ['missing'], - }, - }, - }, - } as const; - - expect(() => instantiateAuthoringFieldPreset(descriptor, [{}])).toThrow( - /Resolved authoring literal default must not be undefined/, - ); - }); - - it('resolves codecValue defaults and execution defaults from field presets', () => { + it('resolves expression defaults and execution defaults from field presets', () => { const descriptor = { kind: 'fieldPreset', output: { @@ -337,13 +315,8 @@ describe('authoring template resolution', () => { }, }, default: { - kind: 'codecValue', - value: { - length: { - kind: 'arg', - index: 0, - }, - }, + kind: 'expression', + expression: 'gen_random_uuid()', }, executionDefaults: { onCreate: { @@ -370,10 +343,8 @@ describe('authoring template resolution', () => { }, nullable: true, default: { - kind: 'codecValue', - value: { - length: 1536, - }, + kind: 'expression', + expression: 'gen_random_uuid()', }, executionDefaults: { onCreate: { kind: 'generator', id: 'vectorGenerated' }, From dd56e65e471919ae6e1ac89b0297971a73e975ce Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Thu, 21 May 2026 15:06:45 +0200 Subject: [PATCH 50/50] refactor(sql-contract-psl): narrow emitFunctionFormColumnDefault to 2-arm shape AuthoringColumnDefault no longer carries a codecValue arm (removed in the previous commit). emitFunctionFormColumnDefault now handles only the expression and autoincrement arms, both of which pass through verbatim to the contract IR. The codec lookup and renderSqlLiteral dispatch that was in the codecValue branch is gone; the function no longer needs a codecLookup parameter or a diagnostics array. Both callers updated: instantiatePslFieldPreset and the storage-arm dispatch in the function-form lowering path. --- .../contract-psl/src/psl-column-resolution.ts | 83 ++----------------- 1 file changed, 9 insertions(+), 74 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index efdcb50cf7..95288006a1 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -345,25 +345,10 @@ export function instantiatePslFieldPreset(input: { validateAuthoringHelperArguments(helperPath, input.descriptor.args, args); const instantiated = instantiateAuthoringFieldPreset(input.descriptor, args); const presetCodecId = instantiated.descriptor.codecId; - let presetDefault: ColumnDefault | undefined; - if (instantiated.default !== undefined) { - const lowered = emitFunctionFormColumnDefault( - instantiated.default, - { - modelName: input.entityLabel, - fieldName: helperPath, - codecId: presetCodecId, - span: input.call.span, - sourceId: input.sourceId, - }, - input.codecLookup, - input.diagnostics, - ); - if (!lowered) { - return undefined; - } - presetDefault = lowered; - } + const presetDefault: ColumnDefault | undefined = + instantiated.default !== undefined + ? emitFunctionFormColumnDefault(instantiated.default) + : undefined; return { descriptor: { codecId: presetCodecId, @@ -761,52 +746,14 @@ function resolveCodecTraits( /** * Translate the registry's storage-arm {@link AuthoringColumnDefault} into - * the contract IR's {@link ColumnDefault}. Mirrors the TS DSL emitter's - * dispatch table — the `codecValue` arm flows through - * `codec.renderSqlLiteral`; the `expression` and `autoincrement` arms pass - * through verbatim. Returns `undefined` and pushes a diagnostic when the - * `codecValue` arm needs a codec but none is reachable. + * the contract IR's {@link ColumnDefault}. The `expression` and `autoincrement` + * arms pass through verbatim; both are structurally identical to the IR shape. */ -function emitFunctionFormColumnDefault( - authored: AuthoringColumnDefault, - context: { - readonly modelName: string; - readonly fieldName: string; - readonly codecId: string; - readonly span: PslSpan; - readonly sourceId: string; - }, - codecLookup: CodecLookup | undefined, - diagnostics: ContractSourceDiagnostic[], -): ColumnDefault | undefined { +function emitFunctionFormColumnDefault(authored: AuthoringColumnDefault): ColumnDefault { if (authored.kind === 'autoincrement') { return { kind: 'autoincrement' }; } - if (authored.kind === 'expression') { - return { kind: 'expression', expression: authored.expression }; - } - const codec = codecLookup?.get(context.codecId) as CodecWithRenderSqlLiteral | undefined; - if (!codec) { - diagnostics.push({ - code: 'PSL_INVALID_DEFAULT_VALUE', - message: `Field "${context.modelName}.${context.fieldName}" @default(...) requires codec "${context.codecId}" to render the literal value; no codec lookup is available.`, - sourceId: context.sourceId, - span: context.span, - }); - return undefined; - } - try { - return { kind: 'expression', expression: codec.renderSqlLiteral(authored.value) }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - diagnostics.push({ - code: 'PSL_INVALID_DEFAULT_VALUE', - message: `Field "${context.modelName}.${context.fieldName}" @default(...) value is not valid for codec "${context.codecId}": ${message}`, - sourceId: context.sourceId, - span: context.span, - }); - return undefined; - } + return { kind: 'expression', expression: authored.expression }; } /** @@ -1016,19 +963,7 @@ export function lowerDefaultForField(input: { } if (lowered.value.kind === 'storage') { - const emitted = emitFunctionFormColumnDefault( - lowered.value.defaultValue, - { - modelName: input.modelName, - fieldName: input.fieldName, - codecId: input.columnDescriptor.codecId, - span: expressionEntry.span, - sourceId: input.sourceId, - }, - input.codecLookup, - input.diagnostics, - ); - return emitted ? { defaultValue: emitted } : {}; + return { defaultValue: emitFunctionFormColumnDefault(lowered.value.defaultValue) }; } const generatorDescriptor = input.generatorDescriptorById.get(lowered.value.generated.id);