From 23228849a9e4a4fa070d92b2d64eb2b6ac1ea087 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 13:38:11 +0200 Subject: [PATCH 01/24] =?UTF-8?q?docs(infer-roundtrip):=20shape=20the=20in?= =?UTF-8?q?fer=E2=86=92emit=20round-trip=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec and plan for TML-3037. An external user's post-processing script, audited claim-by-claim, surfaced eight defects — all instances of one class: contract infer writes PSL that contract emit rejects, or that db verify then reports as drift. Our own extension-supabase generate-contract.ts is independently the same workaround script, which is the evidence this is worth fixing at the source rather than papering over per-consumer. No test anywhere does introspect → infer → emit. That is why all eight shipped, and building that instrument is the first dispatch. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- projects/infer-emit-roundtrip/plan.md | 106 ++++++++++ projects/infer-emit-roundtrip/spec.md | 293 ++++++++++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 projects/infer-emit-roundtrip/plan.md create mode 100644 projects/infer-emit-roundtrip/spec.md diff --git a/projects/infer-emit-roundtrip/plan.md b/projects/infer-emit-roundtrip/plan.md new file mode 100644 index 0000000000..0d37c5435c --- /dev/null +++ b/projects/infer-emit-roundtrip/plan.md @@ -0,0 +1,106 @@ +# Slice plan — TML-3037 + +Spec: [`spec.md`](./spec.md). One PR on `tml-3037-contract-infer-output-round-trips-through-contract-emit`. + +## Sequencing + +D1 builds the instrument and must land first — it's what converts eight code-readings into eight +reproductions. Everything between D2 and D8 is a fix whose acceptance is "the instrument stops +failing for this defect." D9 removes the workarounds and runs the full gate set. + +Dispatches are **serialized**, not parallel: they share one git index, and a concurrent +`git restore --staged` from one implementer can clear another's staging. Per +`drive/calibration/model-tier.md` and the operator's standing preference, implementers run on +Sonnet; the review pass runs on Opus. + +| # | Outcome | Surface | Tier | +|---|---|---|---| +| D1 | The instrument exists and **fails**, reproducing each defect with its real error | `test/integration/test/cli-journeys/` | Sonnet | +| D2 | `pluralize` is correct for plural *and* singular-`s` inputs | `packages/2-sql/9-family` | Sonnet | +| D3 | The interpreter accepts the 1:1 back-relation and list storage defaults infer prints | `packages/2-sql/2-authoring/contract-psl` | Sonnet | +| D4 | The postgres codec set covers unbounded `numeric` and `date` | `packages/3-targets/3-targets/postgres` | Sonnet | +| D5 | Postgres registers its built-in index types; dangling FK drops are visible | `packages/3-targets/3-targets/postgres` | Sonnet | +| D6 | Identity columns round-trip as `autoincrement()` on both sides | `2-sql/1-core/schema-ir` + postgres adapter | Sonnet | +| D7 | Authoring and introspection agree on a JSON literal default | `packages/3-targets/3-targets/postgres` | Sonnet | +| D8 | The pack's workarounds are gone and its contract regenerates unchanged | `packages/3-extensions/supabase` | Sonnet | +| D9 | Full gate set green; upgrade instructions recorded | repo-wide | Sonnet | + +## The rule that governs every dispatch + +**Reproduce before you fix.** D1 produces a written record of which defect throws which error. +A fix dispatch that cannot make its defect fail first has not found the defect — it must stop and +report, not fix on the strength of a code reading. This is the operator's explicit bar and it is +why D1 is not merged into D2. + +Threaded failure modes (from `drive/calibration/failure-modes.md`): + +- **F15** — a behavioural AC verified by code-reading instead of a populated fixture. The whole + slice exists because eight defects passed code review; do not repeat the method that missed them. +- **F13** — a regression test that doesn't discriminate. Each assertion must fail if its defect is + reintroduced. A journey that passes for the wrong reason is worse than no journey. +- **F5** — destructive git operations are forbidden without orchestrator approval. No + `git stash`, no `git restore --staged`, no force-push, no branch deletion. Non-negotiable. +- **F14** — dispatch gates must mirror CI. `pnpm typecheck` + vitest pass with unused imports on + disk; only `pnpm lint` catches them. +- **F24** — a stale `dist` makes a red gate look like a broken base. Rebuild the touched package + before believing a downstream red. +- **F25** — "pre-existing failure on main" is false by policy here. Treat a red as this slice's + regression. + +## Dispatch boundaries + +**D1 — the instrument.** A journey with a fixture schema carrying: already-plural table names; a +1:1 FK on a unique column; a 1:N FK; `GENERATED ALWAYS AS IDENTITY`; `GENERATED BY DEFAULT AS +IDENTITY`; a `serial`; an unbounded `numeric`; a `numeric(10,2)`; a `date`; `text[]` with +`DEFAULT '{}'::text[]`; `jsonb` with `DEFAULT '{}'::jsonb`; a GIN index; a `USING hash` index; an +FK pointing out of scope. Driven infer → emit → `db verify --schema-only`. + +Its deliverable is **the failure record**, not a green test: a table of defect → command → verbatim +error. Expect it to fail. If a defect from the spec does *not* reproduce, say so plainly — that +finding outranks the spec, and it changes what we tell the reporter. + +Plus a runtime integration test that builds an `ExecutionContext` and reads a `date` through +`.include()` — the CLI path cannot observe the numeric-connect or date-decode defects. + +**D2 — pluralize.** Acceptance table is in the spec and comes from TML-3024. Prefer a small +maintained inflection library; the curated-set fallback is pre-authorized if `lint:deps` objects. +Don't stall on the choice. + +**D3 — interpreter.** Two shapes infer legitimately prints. For the 1:1 back side, lower to +`cardinality: '1:1'` — the contract already has it. Do not make infer emit lists instead; that +discards real information. For list defaults, reject only genuine `executionDefaults.onCreate`; +permit a storage-level function default. + +**D4 — codecs.** `precision` optional on `NumericParams`, matching sibling `PrecisionParams`. Do +not loosen `assertColumnCodecIntegrity` — it is working as designed. Then `pg/date@1`, with +`@db.Date` pointing at it; `mongo/date@1` is the shape precedent. Breaking: needs an upgrade +entry (D9 records it, this dispatch flags it). + +**D5 — index types + dangling FK.** The target registers `btree`, `hash`, `gin`, `gist`, +`spgist`, `brin`. Permissive options schemas; per-method validation is out of scope. Then the +dangling-FK comment, matching the missing-PK comment precedent already in the file. + +**D6 — identity.** The largest and the one most likely to grow. Symmetric fix: `SqlColumnIR` +gains identity, the query selects `attidentity`, infer emits `@default(autoincrement())`, and the +normalizer resolves a live identity column to `autoincrement()` so verify compares equal. +**Stop condition:** if verify still drifts after the symmetric fix, stop and report — do not chase +DDL fidelity inside the loop. That's a re-spec, per the spec's pinned boundary. + +**D7 — jsonb defaults.** The authoring side normalizes to match introspection, reusing +`parsePostgresDefault` rather than copying it. `dbgenerated("gen_random_uuid()")` must stay a +function — the discriminating test for that goes in the same dispatch. Fixtures will move; read +the diff, don't blanket-regenerate. + +**D8 — delete the workarounds.** `DOUBLE_PLURALIZED_FIELD_NAMES` and `INDEX_OMISSIONS` gone; +`DEFAULT_OMISSIONS` reduced to what's genuinely unrepresentable with the reason stated. Regenerate +the pack contract via `pnpm --filter @prisma-next/extension-supabase run contract:generate` and +read the diff. A workaround that survives a landed fix means the fix didn't work — report that +rather than keeping the workaround. + +**D9 — close.** Upgrade instructions for `pg/date@1`. Full gate set per the spec's DoD. + +## Not in the dispatch loop + +Design is settled in the spec. If a dispatch wants to renegotiate identity-as-`autoincrement()`, +or the target-registers-its-own-index-types call, it stops and reports — those are re-specs, not +in-loop decisions. diff --git a/projects/infer-emit-roundtrip/spec.md b/projects/infer-emit-roundtrip/spec.md new file mode 100644 index 0000000000..5330d2d0d7 --- /dev/null +++ b/projects/infer-emit-roundtrip/spec.md @@ -0,0 +1,293 @@ +# Slice spec — `contract infer` output round-trips through `contract emit` + +**Linear:** [TML-3037](https://linear.app/prisma-company/issue/TML-3037/contract-infer-output-round-trips-through-contract-emit) · closes [TML-3024](https://linear.app/prisma-company/issue/TML-3024/contract-infer-correct-back-relation-field-pluralization-dont-double) +**Branch:** `tml-3037-contract-infer-output-round-trips-through-contract-emit` (parent: `main`) +**Shape:** orphan slice, one PR. + +## Outcome + +`contract infer` run against a realistic Postgres database produces a `contract.prisma` that +emits cleanly and verifies clean against the database it was read from — with no hand-editing +and no post-processing script. + +That property is the whole slice. Eight defects currently break it; each is an instance of the +same class, and one instrument proves all of them. + +## Why this is one slice, not eight + +Every defect below is the same failure: infer writes PSL that emit rejects, or that verify then +reports as drift. They share one instrument (the round-trip journey), one acceptance bar, and one +piece of evidence that they're worth fixing at all. Split across eight PRs, each reviewer sees a +one-line change with no way to judge whether it's the right one-line change. Together, the diff +tells a single story and the pack script shrinking is the visible proof. + +## Why now + +An external user sent in a 260-line `fix-inferred-contract.ts` that post-processes every +`contract infer` run before their contract will emit. Auditing it claim-by-claim produced the +eight defects below. + +The finding that motivates the slice is not in their script. It's that +`packages/3-extensions/supabase/scripts/generate-contract.ts` — ours — is independently the same +script. Same double-plural rename table, same default omissions, same index omissions, each with +a comment explaining which part of our own pipeline rejects our own output. We shipped the +workaround and left the defect, and the next user through the door paid for it. + +**No test anywhere does introspect → infer → emit.** That's why all eight shipped. + +Audit board: https://claude.ai/code/artifact/fa624452-ab58-4450-8888-600b9f681a53 + +## The instrument + +The harness already exists. `test/integration/test/cli-journeys/contract-infer-workflow.e2e.test.ts` +already runs infer → emit → verify, using `runContractInfer` / `runContractEmit` / `runDbVerify` +from `journey-test-helpers`. Its schema is two columns: + +```sql +CREATE TABLE "user" (id int4 PRIMARY KEY, email text NOT NULL); +``` + +The gap is the schema, not the harness. + +**Two instruments, because the CLI journey can't observe runtime decode:** + +1. **`cli-journeys/infer-roundtrip-fidelity.e2e.test.ts`** — a purpose-built schema exercising + every defect, driven `contract infer` → `contract emit` → `db verify --schema-only`. + Acceptance: infer exits 0, emit exits 0, verify reports no drift. +2. **An integration test that builds an `ExecutionContext` against the emitted contract and reads + a row through `.include()`** — the only way to observe the numeric-connect and date-decode + defects, which the CLI path never touches. + +**The certainty rule (operator-set):** each defect is reproduced by the instrument *before* its +fix lands. A defect the instrument cannot reproduce does not get "fixed" on the strength of a +code reading, and does not appear as fact in the report we hand back. + +The fixture schema must carry, at minimum: already-plural table names; a 1:1 FK on a unique +column; a 1:N FK; a `GENERATED ALWAYS AS IDENTITY` column; a `GENERATED BY DEFAULT AS IDENTITY` +column; a `serial` column (to prove we don't regress it); an unbounded `numeric`; a +`numeric(10,2)`; a `date`; a `text[]` with `DEFAULT '{}'::text[]`; a `jsonb` with +`DEFAULT '{}'::jsonb`; a GIN index; a `USING hash` index; and an FK pointing out of the +introspected scope. + +## The eight defects and the chosen fix for each + +### 1. Back-relation names double-pluralize + +`pluralize()` appends `es` to any word ending in `s`/`x`/`z`/`ch`/`sh`, so an already-plural +table name doubles: `sessions` → `sessionses`. + +**Fix:** real inflection, per TML-3024. A naive "ends in `s` → skip" rule is wrong and would emit +`statuss`. Property to satisfy: `pluralize` is idempotent for regular plurals *and* correct for +singular-but-`s`-ending words. + +| Input | Output | +|---|---| +| `sessions`, `identities`, `mfaAmrClaims` | unchanged | +| `status` | `statuses` | +| `address` | `addresses` | +| `class` | `classes` | +| `bus` | `buses` | +| `post`, `user` | `posts`, `users` | + +Prefer a small maintained inflection library over a hand-rolled table; fall back to a curated +irregular/uncountable set if a dependency can't clear `pnpm lint:deps`. `packages/2-sql/9-family` +is the consumer. + +### 2. Infer prints a 1:1 back-relation emit can't parse + +Infer emits a bare `profile Profile?` with no `@relation` for a 1:1 back side. The uniqueness +detection producing it is **correct**. The interpreter collects back-relation candidates only +`if (field.list)`, so the singular optional field falls through to scalar resolution and dies +with `PSL_UNSUPPORTED_FIELD_TYPE`. + +**Fix:** teach the interpreter the shape infer already prints — a model-typed, non-list field +with no `@relation` is the 1:1 back side, lowering to `cardinality: '1:1'`. The contract format +already supports `'1:1'` and the TS DSL already produces it; only the PSL interpreter has no path +for it. Do **not** "fix" this by making infer emit lists — that discards real 1:1 information. + +The infer-side snapshot asserting this shape stays; it stops being a snapshot of PSL our own +emitter rejects. + +### 3. Identity columns lose their default + +`GENERATED ALWAYS AS IDENTITY` infers as bare `seq Int`. The columns query joins `pg_attribute` +but never selects `attidentity`; an identity column's `column_default` is NULL, so nothing is +read. `serial` works only because it sets a real `nextval(...)` default that +`raw-default-parser` already maps to `autoincrement()`. + +**Fix, and its deliberate boundary.** Naively emitting `@default(autoincrement())` for an +identity column trades an emit break for a *verify* break: the contract would carry a function +default while the live column reports none, and `resolvedDefaultsEqual` would report drift +forever. + +So the fix is symmetric — identity is recognized as `autoincrement()` on **both** sides: + +- `SqlColumnIR` gains an identity field (it has none today; this is new IR surface). +- The columns query selects `attidentity` and threads it onto the IR. +- Infer emits `@default(autoincrement())` for an identity column. +- The postgres default-normalizer resolves a live identity column's default to + `autoincrement()`, so verify compares equal. + +**Deliberate limitation:** at the contract's altitude, `autoincrement()` means "the database +generates this value." Identity and `serial` are both that, and PSL has no syntax to distinguish +them — so this slice maps identity onto `autoincrement()` rather than modelling +`GENERATED ALWAYS` vs `GENERATED BY DEFAULT`. Consequence: a *fresh* `db init` from such a +contract creates a `serial` column, not an identity one. That is a pre-existing gap (identity has +never been authorable) and stays out of scope. File a follow-up for authoring identity DDL. + +### 4. Non-btree indexes can never emit + +Infer prints `@@index(…, type: "gin")`; the postgres target registers **zero** index types, so +emit throws `unregistered index type "gin"`. `IndexTypeRegistry` is an opt-in extensibility point +— ParadeDB registers `bm25`, and postgres registers nothing. + +**Fix:** the postgres target registers the access methods Postgres ships: `btree`, `hash`, `gin`, +`gist`, `spgist`, `brin`. This is the target declaring its own built-ins, which is what the +registry's opt-in design intends — ParadeDB's `bm25` remains an extension contributing a type it +owns. + +Options schemas start permissive; per-method option validation is not this slice's problem. + +### 5. Unbounded `numeric` crashes at connect + +`NumericParams.precision` is required while every sibling temporal codec's param is optional. +`renderOutputType`, the `expandNumeric` DDL hook, and the PSL attribute parser all already handle +a missing precision. Only the arktype schema disagrees, and it throws +`RUNTIME.CODEC_PARAMETERIZATION_MISMATCH` when the app builds its `ExecutionContext`. + +**Fix:** make `precision` optional on `NumericParams` and `numericParamsSchema`, matching the +sibling `PrecisionParams` pattern. Do not loosen `assertColumnCodecIntegrity` — that check is +working as designed. Not infer-specific: hand-authored `@db.Numeric` with no args hits the same +crash, so the fix needs a test at the authoring surface too. + +### 6. No `pg/date` codec exists + +`@db.Date` carries `codecId: null` ("inherit from base"), and `DateTime` on postgres is +`pg/timestamptz@1`. Top-level rows survive because `decode()` is a passthrough over an +already-parsed `Date`; `.include()` goes through `json_agg` → `decodeJson()`, which regex-checks +for a full ISO timestamp and rejects `"2024-01-15"`. + +**Fix:** add a real `pg/date@1` codec and point `@db.Date` at it, so the alias has something +correct to inherit. Mongo's `mongo/date@1` is the shape precedent. `decodeJson` accepts +`YYYY-MM-DD`; infer maps a bare `date` column to it. + +**Breaking-change note:** this changes the emitted `codecId` for existing `@db.Date` columns, +which changes the contract hash and therefore signed markers. It needs an entry under +`skills/upgrade/prisma-next-upgrade/upgrades/` — see the `record-upgrade-instructions` skill. +Flag at PR-open; do not skip. + +### 7. Array columns can't keep their default + +`DEFAULT '{}'::text[]` → `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`. The check conflates two +different concepts: `dbgenerated` lowers to a **storage-level** SQL default — the same shape as +`now()` — not a per-element execution-time generator. + +**Fix:** the check rejects only genuine execution defaults (`executionDefaults.onCreate`) on list +fields, and permits a storage-level function default. Nothing downstream objects: +`buildColumnDefaultSql` ignores `column.many` entirely and emits `DEFAULT (…)` already. The +error message stays for the case it was actually written for. + +### 8. jsonb defaults report drift forever + +Emit keeps `@default(dbgenerated("'{}'::jsonb"))` as `kind: 'function'`; introspection parses the +same literal to `kind: 'literal'`. `resolvedDefaultsEqual` compares `kind` first and returns false +before reading content — so `db verify` flags every such column `not-equal` permanently, even when +the database matches exactly. + +**Fix:** the two paths agree on one resolved shape for a JSON literal default. The authoring path +is the one that's lossy — it keeps the user's raw text without recognizing that `'{}'::jsonb` *is* +a literal — so the authoring side normalizes to match introspection, using the same +`parsePostgresDefault` logic rather than a second copy of it. + +Guard against the obvious over-reach: `dbgenerated("gen_random_uuid()")` is genuinely a function +and must stay one. + +### Also in scope: dangling FKs drop silently + +Infer correctly drops an FK whose target is outside the introspected scope — that branch is +tested and there's no unresolvable reference. But it emits no comment and no warning, so a user +loses every `auth.users` relationship with zero indication. + +**Fix:** emit an explanatory comment on the model, matching the precedent already in +`infer-psl-contract.ts` for tables with no primary key. + +### Also in scope: delete the workarounds + +Each fix removes the matching entry from `generate-contract.ts` and the pack's `contract.prisma` +is regenerated. What survives must survive for a stated reason. The script shrinking is the +measure of done — if a fix lands and its workaround stays, the fix didn't work. + +## Contract-impact + +- `SqlColumnIR` gains an identity field (`packages/2-sql/1-core/schema-ir`). New IR surface; + every constructor/factory site adapts. +- A new codec id `pg/date@1` joins the postgres codec registry. Emitted contracts for `@db.Date` + columns change `codecId`, changing the contract hash. +- Postgres registers built-in index types, so `@@index(type:)` values that previously failed + validation now persist into `contract.json`. +- No change to `Contract` envelope shape or cardinality vocabulary — `'1:1'` already exists. + +## Adapter-impact + +- **postgres adapter** (`packages/3-targets/6-adapters/postgres`): columns introspection query + selects `attidentity`; default-normalizer resolves identity → `autoincrement()`. +- **postgres target** (`packages/3-targets/3-targets/postgres`): new codec, index-type + registration, infer changes. +- **sqlite / mongo:** untouched. The `pluralize` fix lives in `packages/2-sql/9-family` and is + shared by every SQL target; its behaviour change is name-only and covered by the acceptance + table above. + +## ADR pointer + +No new ADR. Two decisions are worth recording in the PR body rather than an ADR, because both +apply an existing decision rather than making a new one: + +- **The target registers its own built-in index types.** This is `IndexTypeRegistry` used as + designed, not a redefinition of it. +- **Identity maps onto `autoincrement()`.** Applies the contract's existing "database generates + this" vocabulary; does not introduce a competing concept. + +If review disagrees that identity-as-`autoincrement()` is settled, that's an ADR and a re-spec — +not a thing to negotiate inside the dispatch loop. + +## Out of scope + +- **NUL bytes where `@` should be.** Reported by the external user; could not be reproduced. Our + generated `contract.prisma` has 603 `@` bytes and zero NULs, no `.prisma` file in the repo + contains a NUL, the sigil is a hardcoded literal never computed, and the printer is + byte-identical to the version they run. Needs `xxd` of a fresh infer from the reporter before + it's worth any time. +- **PSL `constraint: false`.** PSL cannot author an FK-less relation; the knob is TS-DSL-only. + Real gap, different shape. +- **Authoring `GENERATED ALWAYS AS IDENTITY` in DDL.** See defect 3's boundary. +- **Per-index-method option validation** (e.g. `gin` operator classes). Registration only. +- **`@default(null)` on a nullable column** and **nullable list columns** — two further + round-trip gaps documented in `generate-contract.ts`'s own comments. Real, but each needs its + own design; file separately. + +## Slice DoD + +- [ ] `cli-journeys/infer-roundtrip-fidelity.e2e.test.ts` exists, covers the fixture schema above, + and passes: infer → emit → `db verify --schema-only` clean. +- [ ] A runtime integration test builds an `ExecutionContext` against the emitted contract and + reads a `date` column through `.include()`. +- [ ] Each of the eight was demonstrated failing against the instrument before its fix. +- [ ] `generate-contract.ts`: `DOUBLE_PLURALIZED_FIELD_NAMES` deleted; `INDEX_OMISSIONS` deleted; + `DEFAULT_OMISSIONS` reduced to only what remains genuinely unrepresentable, each with its + reason. The pack's `contract.prisma` regenerated and the diff reviewed. +- [ ] Upgrade instructions recorded for the `pg/date@1` codec change. +- [ ] TML-3024 closed by this PR. +- [ ] Full gate set green: `pnpm build`, `pnpm typecheck`, the Lint job (incl. `lint:deps`, + `lint:casts`), `pnpm fixtures:check`, `test:packages`, `test:integration`, `test:e2e`. + +## Risks + +- **Identity is the biggest item** and the one most likely to grow. Its boundary is pinned above. + If the round-trip test shows verify still drifting after the symmetric fix, stop and re-spec + rather than chasing DDL fidelity inside the dispatch loop. +- **The jsonb normalization fix changes an authoring-side resolved shape**, which will move + emitted fixtures. `pnpm fixtures:check` is the tell; the diff needs reading, not blanket + regeneration. +- **The `pg/date@1` change is breaking.** Missing the upgrade instructions is the failure mode. +- **`pluralize` may want a dependency.** If `lint:deps` or layering rejects it, the curated-set + fallback is pre-authorized — don't stall on it. From 06a0bf52a5b4daf5706e6c24ec5690365378f89c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 14:22:33 +0200 Subject: [PATCH 02/24] test(infer-roundtrip): reproduce every infer -> emit fidelity defect against a live database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `contract infer` output does not survive `contract emit`, and no test anywhere drives introspect -> infer -> emit, which is why eight defects shipped. This adds the instrument that reproduces all of them, and the failure record it produced. Two files, because the CLI path cannot observe runtime decode: - `cli-journeys/infer-roundtrip-fidelity.e2e.test.ts` drives infer -> emit -> `db verify --schema-only` over a schema carrying already-plural table names, a 1:1 and a 1:N FK, both `GENERATED ... AS IDENTITY` variants plus a `serial` control, bounded and unbounded `numeric`, a `date`, array/jsonb defaults, GIN and `USING hash` indexes, and an FK pointing out of the introspected scope. - `infer-roundtrip-runtime.integration.test.ts` builds a real `ExecutionContext` against an emitted contract and reads a `date` through `.include()` — the only way to see the numeric-connect and date-decode defects. All ten assertions fail today; each asserts the fixed behaviour, so each flips green as its fix lands. Each `it()` isolates one defect, repairing the unrelated emit-blockers so a single run reports every defect independently. `projects/infer-emit-roundtrip/reproduction.md` records, per defect, the command that surfaces it and the verbatim error. Two findings correct the spec: the unbounded-numeric crash arrives via the bare `Decimal` base scalar, not via `@db.Numeric` with no args (infer never prints that attribute), so the affected surface is every `Decimal` field on postgres; and only the to-many side of a relation double-pluralizes, so this fixture reproduces `sessionses` but not `identitieses`. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- projects/infer-emit-roundtrip/reproduction.md | 351 ++++++++++++++++++ .../infer-roundtrip-fidelity.e2e.test.ts | 327 ++++++++++++++++ ...nfer-roundtrip-runtime.integration.test.ts | 212 +++++++++++ 3 files changed, 890 insertions(+) create mode 100644 projects/infer-emit-roundtrip/reproduction.md create mode 100644 test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts create mode 100644 test/integration/test/infer-roundtrip-runtime.integration.test.ts diff --git a/projects/infer-emit-roundtrip/reproduction.md b/projects/infer-emit-roundtrip/reproduction.md new file mode 100644 index 0000000000..dd691bc162 --- /dev/null +++ b/projects/infer-emit-roundtrip/reproduction.md @@ -0,0 +1,351 @@ +# Reproduction record — TML-3037 dispatch D1 + +Every claim below comes from a run executed against the instrument on this branch. Nothing here is +inferred from reading code. + +**Instruments:** + +- `test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts` — 8 `it()`s (IF.01–IF.08), + run with `pnpm --filter @prisma-next/integration-tests test:journeys test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts`. + Result: **8 failed (8)**. +- `test/integration/test/infer-roundtrip-runtime.integration.test.ts` — 2 `it()`s (RT.01–RT.02), + run with `pnpm --filter @prisma-next/integration-tests test test/infer-roundtrip-runtime.integration.test.ts`. + Result: **2 failed (2)**. + +Both files assert the **fixed** behaviour, so every one of the 10 failures is a live reproduction. +All 8+2 fail today; each should flip to green as its fix lands. + +## The inferred PSL the whole record is read from + +This is the verbatim `contract.prisma` `contract infer` produces from the fixture schema. Seven of +the nine findings are visible in it directly. + +```prisma +// use prisma-next +// Contract inferred from the live database schema. Edit as needed, then run `prisma-next contract emit`. + +types { + BirthDate = DateTime @db.Date + PreciseBalance = Decimal @db.Numeric(10, 2) +} + +model Users { + id Int @id(map: "users_pkey") + email String + balance Decimal? + preciseBalance PreciseBalance? @map("precise_balance") + birthDate BirthDate? @map("birth_date") + tags String[] @default(dbgenerated("'{}'::text[]")) + metadata Json @default(dbgenerated("'{}'::jsonb")) + identities Identities? + sessionses Sessions[] + + @@index([metadata], map: "users_metadata_gin_idx", type: "gin") + @@map("users") +} + +model Identities { + id Int @id(map: "identities_pkey") @default(autoincrement()) + userId Int @unique(map: "identities_user_id_key") @map("user_id") + provider String + user Users @relation(fields: [userId], references: [id], map: "identities_user_id_fkey") + + @@index([provider], map: "identities_provider_hash_idx", type: "hash") + @@map("identities") +} + +model Sessions { + id Int @id(map: "sessions_pkey") + userId Int @map("user_id") + ownerRef Int? @map("owner_ref") + user Users @relation(fields: [userId], references: [id], map: "sessions_user_id_fkey", index: false) + + @@map("sessions") +} +``` + +## The record + +| # | Defect (spec §) | Reproduces? | Surfaced by | Test | +|---|---|---|---|---| +| 1 | Back-relation names double-pluralize | Yes | `contract infer` output | IF.01 | +| 2 | Infer prints a 1:1 back-relation emit can't parse | Yes | `contract emit` | IF.02 | +| 3 | Identity columns lose their default | Yes | `contract infer` output | IF.06 | +| 4 | Non-btree indexes can never emit | Yes — **both** `gin` and `hash` | `contract emit` | IF.04 | +| 5 | Unbounded `numeric` crashes at connect | Yes — **but not by the mechanism the spec names** | `createExecutionContext` | RT.01 | +| 6 | No `pg/date` codec exists | Yes | `.include()` at runtime | RT.02 | +| 7 | Array columns can't keep their default | Yes | `contract emit` | IF.03 | +| 8 | jsonb defaults report drift forever | Yes | `db verify --schema-only` | IF.07 | +| — | Dangling FKs drop silently | Yes | `contract infer` output | IF.05 | +| — | (whole-slice outcome) | Round trip fails at emit | `contract emit` | IF.08 | + +--- + +### 1. Back-relation names double-pluralize — REPRODUCES + +**Command:** `contract infer` (exit 0). The defect is in its output, not its exit code. + +**Verbatim** — the `Users` field pointing at the already-plural `sessions` table: + +``` + sessionses Sessions[] +``` + +`IF.01` asserts `/\bsessions\s+Sessions\[\]/` and fails. + +**Caveat on the fixture, worth knowing for D2's acceptance:** only the **to-many** side pluralizes. +My `identities` table has a UNIQUE FK, so it lands on the 1:1 side and prints `identities Identities?` +— `pluralize()` is never called for it. The spec's §"The instrument" list and defect-1 acceptance table +name `identities` as a double-plural case, and `generate-contract.ts`'s `DOUBLE_PLURALIZED_FIELD_NAMES` +carries `identitieses`; both are right about real Supabase (where `auth.identities` is 1:N from +`auth.users`) but do not describe this fixture. `sessionses` is the reproduction here. This is not a +spec error — just don't expect `identitieses` from this instrument. + +### 2. Infer prints a 1:1 back-relation emit can't parse — REPRODUCES + +**Command:** `contract emit`, exit **1**. (IF.02 first repairs the two unrelated emit-blockers — the +list default and the non-btree index types — so this error stands alone.) + +**Verbatim:** + +``` +■ ✖ Failed to resolve contract source (PN-RUN-3000) +│ Why: PSL to SQL contract interpretation failed +│ Fix: Fix contract source diagnostics and return ok(Contract). +│ Issues (showing 1 of 1): +│ - [PSL_UNSUPPORTED_FIELD_TYPE] Field "Users.identities" type "Identities" is not supported in SQL PSL provider v1 (./contract.prisma:17:3) +``` + +Exactly as the spec describes: the uniqueness detection is right (`identities Identities?` is +correctly the 1:1 back side), and the field falls through to scalar resolution because the interpreter +only collects back-relation candidates `if (field.list)`. + +### 3. Identity columns lose their default — REPRODUCES (both variants); `serial` is unaffected + +**Command:** `contract infer` (exit 0). Visible in its output. + +**Verbatim** — `users.id` is `GENERATED ALWAYS AS IDENTITY`, `sessions.id` is `GENERATED BY DEFAULT AS IDENTITY`: + +``` +model Users { + id Int @id(map: "users_pkey") +... +model Sessions { + id Int @id(map: "sessions_pkey") +``` + +Neither carries `@default(autoincrement())`. The `serial` control column does, confirming the spec's +account that `serial` works only because it sets a real `nextval(...)` default: + +``` +model Identities { + id Int @id(map: "identities_pkey") @default(autoincrement()) +``` + +IF.06 asserts all three and fails on the two identity columns while the `serial` assertion passes — +so it discriminates, rather than passing or failing wholesale. + +### 4. Non-btree indexes can never emit — REPRODUCES for `gin` **and** `hash` + +**Command:** `contract emit`, exit **1**. + +**Verbatim** (`gin`, the first index validated): + +``` +■ ✖ Failed to resolve contract source (PN-RUN-3000) +│ Why: Namespace "public" table "users" index on columns [metadata] uses unregistered index type "gin" +│ Fix: Ensure contract.source.load resolves to ok(Contract) or returns structured diagnostics. +``` + +Validation stops at the first offender, so I confirmed `hash` separately by removing only the `gin` +argument and re-emitting: + +``` +│ Why: Namespace "public" table "identities" index on columns [provider] uses unregistered index type "hash" +``` + +Both reproduce. That temporary probe was removed; IF.04 covers the pair through the `gin` error. + +### 5. Unbounded `numeric` crashes at connect — REPRODUCES, but **not via `@db.Numeric`** + +**Command:** `contract infer` → `contract emit` (both exit **0**), then `createExecutionContext`. + +**Verbatim:** + +``` +RuntimeError { + "message": "Column 'amount_probe.amount' uses parameterized codec 'pg/numeric@1' but no typeParams are supplied. Provide typeParams on the column, or use a typeRef pointing at a storage.types entry that carries them.", + "code": "RUNTIME.CODEC_PARAMETERIZATION_MISMATCH", + "category": "RUNTIME", + "severity": "error", + "details": { + "actual": "no typeParams", + "codecId": "pg/numeric@1", + "column": "amount", + "expected": "parameterized", + "table": "amount_probe", + }, +} +``` + +**Where the spec is imprecise — D4 should read this before starting.** The spec frames this as an +attribute problem ("`@db.Numeric` with no args"), but infer never prints `@db.Numeric` for an +unbounded `numeric` column. It prints a bare `Decimal`: + +``` + balance Decimal? +``` + +and only the *bounded* `numeric(10,2)` gets an attribute, via a named type: + +``` +types { + PreciseBalance = Decimal @db.Numeric(10, 2) +} +``` + +So the crash arrives through the **base-scalar** path — `postgresScalarTypeDescriptors` maps +`'Decimal' → 'pg/numeric@1'` (`packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts`), +producing a `pg/numeric@1` ref with no `typeParams` at all. Every `Decimal` field on postgres reaches +this, `@db.Numeric` or not. + +This does not change the chosen fix — making `precision` optional on `NumericParams` / +`numericParamsSchema` still resolves it, and the "not infer-specific, needs an authoring-surface test" +note still holds. It changes the **blast radius**: the affected surface is bare `Decimal`, which is +wider than the spec's framing suggests. D4's authoring-surface test should cover bare `Decimal`, not +just `@db.Numeric` with no args. + +### 6. No `pg/date` codec exists — REPRODUCES + +**Command:** `contract infer` → `contract emit` (both exit 0), then a real `ExecutionContext` + +`PostgresRuntimeImpl`; top-level `.select()` first, then `.include()`. + +Top-level `select('id', 'notedOn')` succeeds and returns a `Date` — matching the spec's account that +`decode()` is a passthrough over an already-parsed value. `.include()` on the same column throws: + +``` +RuntimeError { + "message": "Failed to decode column record.noted_on with codec 'pg/timestamptz@1': Invalid ISO date string for pg/timestamptz@1: 2024-01-15", + "cause": Error { + "message": "Invalid ISO date string for pg/timestamptz@1: 2024-01-15", + }, + "code": "RUNTIME.DECODE_FAILED", + "category": "RUNTIME", + "severity": "error", + "details": { + "codec": "pg/timestamptz@1", + "column": "noted_on", + "table": "record", + }, +} +``` + +The error names `pg/timestamptz@1` on a `date` column, confirming the spec's mechanism exactly: +`@db.Date` carries `codecId: null` ("inherit from base"), `DateTime` on postgres is `pg/timestamptz@1`, +and `decodeJson`'s ISO-timestamp regex rejects a bare `YYYY-MM-DD`. + +**One note for D4 on the assertion, not on the defect:** the top-level `select()` half asserts +`toBeInstanceOf(Date)`, not an exact instant. The driver builds a `date` column's `Date` at *local* +midnight, so the instant is environment-timezone-dependent (it came back `2024-01-14T23:00:00.000Z` +on this machine, in CEST). Once `pg/date@1` lands and owns the conversion, that assertion can and +should tighten to the exact value. + +### 7. Array columns can't keep their default — REPRODUCES + +**Command:** `contract emit`, exit **1**. + +**Verbatim:** + +``` +■ ✖ Failed to resolve contract source (PN-RUN-3000) +│ Why: PSL to SQL contract interpretation failed +│ Fix: Fix contract source diagnostics and return ok(Contract). +│ Issues (showing 1 of 1): +│ - [PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED] Field "Users.tags" is a list and cannot use an execution default ("dbgenerated("'{}'::text[]")"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default. (./contract.prisma:15:34) +``` + +### 8. jsonb defaults report drift forever — REPRODUCES + +**Command:** `contract infer` → (repair the three unrelated emit-blockers) → `contract emit` (exit 0) +→ `db verify --schema-only`, exit **1**. The database is the one the contract was inferred *from*, +and nothing changed in between. + +**Verbatim:** + +``` +│ Schema issues: +│ ✖ mismatch: database/public/users/column:metadata/default +│ +│ ✖ Database schema does not satisfy contract (1 failure) (PN-SCHEMA-0001) +``` + +**A reduction artifact D7 should know about.** My first cut of IF.07 repaired the non-btree indexes by +stripping only the `type:` argument. That produced two *extra* verify mismatches that are not among the +eight: + +``` +│ ✖ mismatch: database/public/identities/index:provider +│ ✖ mismatch: database/public/users/index:metadata +``` + +Those are declared-btree-vs-live-gin/hash — an artifact of the repair, not a defect. IF.07 now drops +the two `@@index` attributes entirely, so they become undeclared "extras" that non-strict schema-only +verify tolerates, and the jsonb mismatch stands alone. Worth remembering if a later dispatch reduces +this PSL for its own purposes. + +### Dangling FKs drop silently — REPRODUCES + +**Command:** `contract infer` (exit 0). + +`sessions.owner_ref` references `secure.owners`, outside the introspected `public` schema. The scalar +column survives, as the spec says it should: + +``` +model Sessions { + id Int @id(map: "sessions_pkey") + userId Int @map("user_id") + ownerRef Int? @map("owner_ref") + user Users @relation(fields: [userId], references: [id], map: "sessions_user_id_fkey", index: false) + + @@map("sessions") +} +``` + +There is **no comment** anywhere on or above `model Sessions` explaining the dropped relation. IF.05 +asserts both halves — the surviving column (passes) and the comment (fails) — so it discriminates. + +The precedent the spec points at is real and works: `infer-psl-contract.ts` emits +`// WARNING: This table has no primary key in the database` above a PK-less model, and the printer +renders `model.comment` immediately above the `model` line +(`packages/1-framework/2-authoring/psl-printer/src/serialize-print-document.ts`). + +### Whole-slice outcome — the round trip fails at emit + +**Command:** `contract infer` (exit 0) → `contract emit` on the **unmodified** inferred PSL, exit **1**. + +**Verbatim:** + +``` +■ ✖ Failed to resolve contract source (PN-RUN-3000) +│ Why: PSL to SQL contract interpretation failed +│ Fix: Fix contract source diagnostics and return ok(Contract). +│ Issues (showing 2 of 2): +│ - [PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED] Field "Users.tags" is a list and cannot use an execution default ("dbgenerated("'{}'::text[]")"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default. (./contract.prisma:15:34) +│ - [PSL_UNSUPPORTED_FIELD_TYPE] Field "Users.identities" type "Identities" is not supported in SQL PSL provider v1 (./contract.prisma:17:3) +``` + +`db verify --schema-only` is never reached. IF.08 is the slice's headline acceptance: it goes green +only when every fix has landed. + +## Not a defect: an install-state trap + +The runtime test first failed with: + +``` +Error: Cannot find package '@prisma-next/sql-schema-ir/naming' imported from .../packages/2-sql/1-core/contract/dist/foreign-key-materialization.mjs +``` + +This is the `workspace-package-not-found-run-pnpm-install` rule's case, not a code bug: the +`@prisma-next/sql-schema-ir` symlink was missing from `packages/2-sql/1-core/contract/node_modules/@prisma-next/`. +`pnpm install` fixed it and left `pnpm-lock.yaml` unchanged. If a later dispatch sees this, install — +don't debug it. diff --git a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts new file mode 100644 index 0000000000..23e708e729 --- /dev/null +++ b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts @@ -0,0 +1,327 @@ +/** + * Infer -> Emit Round-Trip Fidelity (TML-3037, dispatch D1 — the instrument) + * + * `contract-infer-workflow.e2e.test.ts` already proves infer -> emit -> verify + * on a two-column table. This journey runs the same shape against a schema + * that carries the fidelity gaps TML-3037 tracks: an already-plural + * back-relation target, a 1:1 FK, a 1:N FK, both `GENERATED ... AS IDENTITY` + * variants plus a `serial` control column, a GIN index, a `USING hash` index, + * array/jsonb defaults, and a foreign key into a schema outside the + * introspected scope. + * + * Each `it()` isolates one defect so a single run reports every defect + * independently instead of stopping at the first failure. Where an + * unfixed defect would otherwise block `contract emit` before a later + * defect gets a chance to run, the PSL is surgically reduced (fixing + * everything except the one thing under test) so that defect's own error + * surfaces on its own. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { withClient } from '@prisma-next/test-utils'; +import stripAnsi from 'strip-ansi'; +import { describe, expect, it } from 'vitest'; +import { withTempDir } from '../utils/cli-test-helpers'; +import { + type JourneyContext, + runContractEmit, + runContractInfer, + runDbVerify, + setupJourney, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; + +const SEED_SQL = ` + CREATE SCHEMA secure; + CREATE TABLE secure.owners ( + id int4 PRIMARY KEY + ); + + CREATE TABLE users ( + id int4 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text NOT NULL, + balance numeric, + precise_balance numeric(10,2), + birth_date date, + tags text[] NOT NULL DEFAULT '{}'::text[], + metadata jsonb NOT NULL DEFAULT '{}'::jsonb + ); + CREATE INDEX users_metadata_gin_idx ON users USING gin (metadata); + + CREATE TABLE sessions ( + id int4 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id int4 NOT NULL REFERENCES users(id), + owner_ref int4 REFERENCES secure.owners(id) + ); + + CREATE TABLE identities ( + id serial PRIMARY KEY, + user_id int4 NOT NULL UNIQUE REFERENCES users(id), + provider text NOT NULL + ); + CREATE INDEX identities_provider_hash_idx ON identities USING hash (provider); +`; + +function readContractPsl(ctx: JourneyContext): string { + return readFileSync(join(ctx.testDir, 'contract.prisma'), 'utf-8'); +} + +function writeContractPsl(ctx: JourneyContext, psl: string): void { + writeFileSync(join(ctx.testDir, 'contract.prisma'), psl, 'utf-8'); +} + +/** Removes the bare, `@relation`-less 1:1 back-relation field infer prints on `Users`. */ +function fixOneToOneBackRelation(psl: string): string { + return psl.replace(/^\s*identities\s+Identities\?\s*$\n/m, ''); +} + +/** Removes the storage-level list default infer prints on `Users.tags`. */ +function fixListDefault(psl: string): string { + return psl.replace(/\s*@default\(dbgenerated\("'\{\}'::text\[\]"\)\)/, ''); +} + +/** Strips the non-btree `type:` argument infer prints on both `@@index` attributes. */ +function fixIndexTypes(psl: string): string { + return psl.replace(/,\s*type: "(?:gin|hash)"/g, ''); +} + +/** + * Drops the two non-btree `@@index` attributes entirely, rather than just + * their `type:` argument. Stripping only the argument leaves a contract that + * declares those indexes as plain btree, which live verify then reports as a + * type mismatch against the real gin/hash indexes — an artifact of the + * reduction, not one of the eight defects. Dropping the attribute instead + * makes the index an undeclared "extra", which non-strict schema-only verify + * tolerates, isolating whatever defect a test is actually probing. + */ +function dropNonBtreeIndexes(psl: string): string { + return psl.replace(/^\s*@@index\(\[\w+\], map: "[^"]+", type: "(?:gin|hash)"\)\s*$\n/gm, ''); +} + +withTempDir(({ createTempDir }) => { + describe('Journey: Infer -> Emit Round-Trip Fidelity', () => { + const db = useDevDatabase({ + onReady: (cs) => withClient(cs, (client) => client.query(SEED_SQL)), + }); + + it( + 'IF.01: back-relation naming — an already-plural child table does not double-pluralize', + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + + const infer = await runContractInfer(ctx); + expect(infer.exitCode, `IF.01: contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + + const psl = readContractPsl(ctx); + expect( + psl, + 'IF.01: Users back-relation to the plural "sessions" table is "sessions", not "sessionses"', + ).toMatch(/\bsessions\s+Sessions\[\]/); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'IF.02: the interpreter accepts the 1:1 back-relation infer prints for a unique FK', + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + + const infer = await runContractInfer(ctx); + expect(infer.exitCode, `IF.02: contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + + // Isolate the 1:1 back-relation defect: fix the two unrelated + // emit-blockers (list default, non-btree index types) so any failure + // here is attributable to the 1:1 back-relation alone. + const reduced = fixIndexTypes(fixListDefault(readContractPsl(ctx))); + writeContractPsl(ctx, reduced); + + const emit = await runContractEmit(ctx); + expect( + emit.exitCode, + `IF.02: contract emit should accept the bare "identities Identities?" 1:1 back-relation ` + + `field; instead got:\n${stripAnsi(emit.stderr)}\n${stripAnsi(emit.stdout)}`, + ).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'IF.03: the interpreter accepts the storage-level list default infer prints for text[]', + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + + const infer = await runContractInfer(ctx); + expect(infer.exitCode, `IF.03: contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + + // Isolate the list-default defect: fix the two unrelated emit-blockers + // (1:1 back-relation, non-btree index types). + const reduced = fixIndexTypes(fixOneToOneBackRelation(readContractPsl(ctx))); + writeContractPsl(ctx, reduced); + + const emit = await runContractEmit(ctx); + expect( + emit.exitCode, + `IF.03: contract emit should accept @default(dbgenerated("'{}'::text[]")) on Users.tags; ` + + `instead got:\n${stripAnsi(emit.stderr)}\n${stripAnsi(emit.stdout)}`, + ).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'IF.04: postgres registers the gin and hash index types infer already prints', + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + + const infer = await runContractInfer(ctx); + expect(infer.exitCode, `IF.04: contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + + // Isolate the index-type defect: fix the two unrelated emit-blockers + // (1:1 back-relation, list default). + const reduced = fixListDefault(fixOneToOneBackRelation(readContractPsl(ctx))); + writeContractPsl(ctx, reduced); + + const emit = await runContractEmit(ctx); + expect( + emit.exitCode, + `IF.04: contract emit should accept @@index(..., type: "gin"/"hash"); instead got:\n` + + `${stripAnsi(emit.stderr)}\n${stripAnsi(emit.stdout)}`, + ).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'IF.05: a foreign key outside the introspected schema surfaces an explanatory comment', + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + + const infer = await runContractInfer(ctx); + expect(infer.exitCode, `IF.05: contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + + const psl = readContractPsl(ctx); + // sessions.owner_ref points at secure.owners, outside the introspected + // `public` schema — the scalar column must survive... + expect(psl, 'IF.05: dangling FK keeps its scalar column').toContain('ownerRef'); + // ...and the model must explain why the relation is missing, the same + // way infer already documents a table with no primary key. + expect( + psl, + 'IF.05: Sessions documents the dropped cross-schema relation with a comment', + ).toMatch(/\/\/[^\n]*\nmodel Sessions \{/); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'IF.06: identity columns (both GENERATED variants) round-trip as autoincrement(); serial keeps working', + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + + const infer = await runContractInfer(ctx); + expect(infer.exitCode, `IF.06: contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + + const psl = readContractPsl(ctx); + expect(psl, 'IF.06: serial column already carries autoincrement() (control)').toMatch( + /id\s+Int\s+@id\(map: "identities_pkey"\)\s+@default\(autoincrement\(\)\)/, + ); + expect( + psl, + 'IF.06: GENERATED ALWAYS AS IDENTITY (users.id) carries autoincrement()', + ).toMatch(/id\s+Int\s+@id\(map: "users_pkey"\)\s+@default\(autoincrement\(\)\)/); + expect( + psl, + 'IF.06: GENERATED BY DEFAULT AS IDENTITY (sessions.id) carries autoincrement()', + ).toMatch(/id\s+Int\s+@id\(map: "sessions_pkey"\)\s+@default\(autoincrement\(\)\)/); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'IF.07: a jsonb literal default round-trips clean through db verify --schema-only', + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + + const infer = await runContractInfer(ctx); + expect(infer.exitCode, `IF.07: contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + + // Fix the three unrelated emit-blockers so emit succeeds and verify + // can run; the jsonb default on Users.metadata is left untouched. + // The two non-btree indexes are dropped (not just detyped) so they + // become undeclared "extras" that non-strict verify tolerates, + // rather than a declared-btree-vs-live-gin/hash type mismatch that + // would otherwise mask the jsonb-default assertion this test is for. + const reduced = dropNonBtreeIndexes( + fixListDefault(fixOneToOneBackRelation(readContractPsl(ctx))), + ); + writeContractPsl(ctx, reduced); + + const emit = await runContractEmit(ctx); + expect(emit.exitCode, `IF.07: contract emit\n${stripAnsi(emit.stderr)}`).toBe(0); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect( + verify.exitCode, + `IF.07: db verify --schema-only should be clean for Users.metadata; instead got:\n${stripAnsi(verify.stderr)}\n${stripAnsi(verify.stdout)}`, + ).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'IF.08: full round trip — infer -> emit -> db verify --schema-only, no hand-editing', + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + contractMode: 'psl', + }); + + const infer = await runContractInfer(ctx); + expect(infer.exitCode, `IF.08: contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); + + const emit = await runContractEmit(ctx); + expect( + emit.exitCode, + `IF.08: contract emit (unmodified inferred PSL)\n${stripAnsi(emit.stderr)}\n${stripAnsi(emit.stdout)}`, + ).toBe(0); + + const verify = await runDbVerify(ctx, ['--schema-only']); + expect( + verify.exitCode, + `IF.08: db verify --schema-only\n${stripAnsi(verify.stderr)}\n${stripAnsi(verify.stdout)}`, + ).toBe(0); + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/infer-roundtrip-runtime.integration.test.ts b/test/integration/test/infer-roundtrip-runtime.integration.test.ts new file mode 100644 index 0000000000..1afd316f32 --- /dev/null +++ b/test/integration/test/infer-roundtrip-runtime.integration.test.ts @@ -0,0 +1,212 @@ +/** + * Infer -> Emit Round-Trip Fidelity — runtime defects (TML-3037, dispatch D1) + * + * `cli-journeys/infer-roundtrip-fidelity.e2e.test.ts` drives infer -> emit -> + * `db verify --schema-only`, but neither `contract emit` nor `db verify` + * builds an `ExecutionContext` — so two of the slice's eight defects never + * surface on that path: + * + * - An unbounded `numeric` column emits a valid contract, but crashes when + * the app builds its `ExecutionContext` (`RUNTIME.CODEC_PARAMETERIZATION_MISMATCH`). + * - A `date` column decodes fine on a top-level `SELECT` (`decode()` is a + * passthrough over an already-parsed JS `Date`), but `.include()` goes + * through `json_agg` -> `decodeJson()`, which rejects a bare `YYYY-MM-DD` + * string because `@db.Date` currently inherits `DateTime`'s + * `pg/timestamptz@1` codec (no `pg/date@1` exists yet). + * + * Each scenario below infers + emits a real contract from a live database + * (same CLI path the journey test uses), deserializes the emitted + * `contract.json`, and builds a real `ExecutionContext`/runtime against it — + * following the shape of `rls-ts-walking-skeleton.integration.test.ts` + * (`createDevDatabase`, a real driver, no CLI mocking) for the runtime half. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import type { Contract } from '@prisma-next/contract/types'; +import postgresDriver from '@prisma-next/driver-postgres/runtime'; +import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; +import { PostgresRuntimeImpl } from '@prisma-next/postgres/runtime'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { Collection } from '@prisma-next/sql-orm-client'; +import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime'; +import postgresTarget, { PostgresContractSerializer } from '@prisma-next/target-postgres/runtime'; +import { createDevDatabase, timeouts, withClient } from '@prisma-next/test-utils'; +import { Client } from 'pg'; +import stripAnsi from 'strip-ansi'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { withTempDir } from './utils/cli-test-helpers'; +import { + type JourneyContext, + runContractEmit, + runContractInfer, + setupJourney, +} from './utils/journey-test-helpers'; + +function readEmittedContract(ctx: JourneyContext): Contract { + // The db-connected PSL journey config emits `contract.json` at the test + // dir root (not under `ctx.outputDir`) — same path native-enum-adoption's + // `readEmittedContractJson` reads from. + const raw: unknown = JSON.parse(readFileSync(join(ctx.testDir, 'contract.json'), 'utf-8')); + return new PostgresContractSerializer().deserializeContract(raw); +} + +async function inferAndEmit( + connectionString: string, + createTempDir: () => string, +): Promise { + const ctx: JourneyContext = setupJourney({ + connectionString, + createTempDir, + contractMode: 'psl', + }); + const infer = await runContractInfer(ctx); + if (infer.exitCode !== 0) { + throw new Error(`contract infer failed:\n${stripAnsi(infer.stderr)}`); + } + const emit = await runContractEmit(ctx); + if (emit.exitCode !== 0) { + throw new Error(`contract emit failed:\n${stripAnsi(emit.stderr)}\n${stripAnsi(emit.stdout)}`); + } + return ctx; +} + +withTempDir(({ createTempDir }) => { + describe('Runtime: unbounded numeric crashes ExecutionContext construction', () => { + let database: Awaited>; + + beforeAll(async () => { + database = await createDevDatabase(); + await withClient(database.connectionString, (client) => + client.query(` + CREATE TABLE amount_probe ( + id int4 PRIMARY KEY, + amount numeric + ); + `), + ); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'RT.01: an unbounded numeric column emits cleanly and builds an ExecutionContext', + async () => { + const ctx = await inferAndEmit(database.connectionString, createTempDir); + const contract = readEmittedContract(ctx); + + let constructionError: unknown; + try { + createExecutionContext({ + contract, + stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }), + }); + } catch (error) { + constructionError = error; + } + expect( + constructionError, + 'RT.01: createExecutionContext should accept an unbounded numeric column', + ).toBeUndefined(); + }, + timeouts.spinUpPpgDev, + ); + }); + + describe('Runtime: a date column decodes on select() but fails through include()', () => { + let database: Awaited>; + + beforeAll(async () => { + database = await createDevDatabase(); + await withClient(database.connectionString, (client) => + client.query(` + CREATE TABLE owner ( + id int4 PRIMARY KEY + ); + CREATE TABLE record ( + id int4 PRIMARY KEY, + owner_id int4 NOT NULL REFERENCES owner(id), + noted_on date NOT NULL + ); + INSERT INTO owner (id) VALUES (1); + INSERT INTO record (id, owner_id, noted_on) VALUES (1, 1, '2024-01-15'); + `), + ); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'RT.02: top-level select decodes the date; include() decode fails on the same column', + async () => { + const ctx = await inferAndEmit(database.connectionString, createTempDir); + const contract = readEmittedContract(ctx); + + const stack = createSqlExecutionStack({ + target: postgresTarget, + adapter: postgresAdapter, + driver: postgresDriver, + }); + const context = createExecutionContext({ contract, stack }); + const stackInstance = instantiateExecutionStack(stack); + const adapter = stackInstance.adapter; + const driver = stackInstance.driver; + if (!adapter || !driver) { + throw new Error('Adapter or driver descriptor missing from execution stack'); + } + + const client = new Client({ connectionString: database.connectionString }); + await client.connect(); + try { + await driver.connect({ kind: 'pgClient', client }); + const runtime = new PostgresRuntimeImpl({ context, adapter, driver }); + try { + const records = new Collection({ runtime, context }, 'Record', { + namespaceId: 'public', + }); + const [row] = await records.select('id', 'notedOn').all(); + // `decode()` is a passthrough over the driver's already-parsed + // `Date` for a `date` column — this only proves the top-level + // path survives; the driver's local-midnight construction makes + // the exact instant environment-timezone-dependent, so this + // checks shape, not the instant. + expect(row?.notedOn, 'RT.02: top-level select decodes a plain date').toBeInstanceOf( + Date, + ); + + const owners = new Collection({ runtime, context }, 'Owner', { + namespaceId: 'public', + }); + let includeError: unknown; + try { + await owners + .select('id') + // `contract` is a dynamically-introspected `Contract` + // with no literal domain shape (it doesn't exist until this test + // runs infer + emit), so `include`'s relation-name type inference + // has nothing to key off; test files are exempt from the + // no-bare-casts rule. + .include('records' as never, (record) => record.select('notedOn')) + .all(); + } catch (error) { + includeError = error; + } + expect( + includeError, + 'RT.02: include() should decode the same date column without throwing', + ).toBeUndefined(); + } finally { + await runtime.close(); + } + } finally { + await client.end(); + } + }, + timeouts.spinUpPpgDev, + ); + }); +}); From 178f669c85eb9c441574c4bccc8823c78e4893d8 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 14:24:28 +0200 Subject: [PATCH 03/24] =?UTF-8?q?docs(infer-roundtrip):=20correct=20defect?= =?UTF-8?q?=205=20=E2=80=94=20Decimal=20is=20unusable=20on=20postgres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 reproduced the numeric crash and showed the spec had the mechanism wrong. It does not arrive via @db.Numeric with no arguments; infer never prints that. It arrives via the base-scalar path, where control-mutation-defaults maps Decimal to pg/numeric@1 with no typeParams — so a plain `amount Decimal` field crashes at connect regardless of any attribute. This is not an infer defect that happens to touch Decimal. Decimal has never worked on postgres; infer is just the first thing that generates one. No .prisma file in the repo declares a Decimal field, which is why it went unnoticed. The chosen fix is unchanged, but D4 must test the bare scalar — an attribute-only test would pass while the defect ships. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- projects/infer-emit-roundtrip/plan.md | 9 ++++++--- projects/infer-emit-roundtrip/spec.md | 25 ++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/projects/infer-emit-roundtrip/plan.md b/projects/infer-emit-roundtrip/plan.md index 0d37c5435c..1a23590a18 100644 --- a/projects/infer-emit-roundtrip/plan.md +++ b/projects/infer-emit-roundtrip/plan.md @@ -72,9 +72,12 @@ discards real information. For list defaults, reject only genuine `executionDefa permit a storage-level function default. **D4 — codecs.** `precision` optional on `NumericParams`, matching sibling `PrecisionParams`. Do -not loosen `assertColumnCodecIntegrity` — it is working as designed. Then `pg/date@1`, with -`@db.Date` pointing at it; `mongo/date@1` is the shape precedent. Breaking: needs an upgrade -entry (D9 records it, this dispatch flags it). +not loosen `assertColumnCodecIntegrity` — it is working as designed. **Test the base scalar**: a +bare `amount Decimal` field, not `@db.Numeric()` — D1 proved the crash arrives via +`['Decimal', 'pg/numeric@1']` on the base-scalar path, so an attribute-only test passes while the +defect ships. See the spec's corrected defect 5. Then `pg/date@1`, with `@db.Date` pointing at it; +`mongo/date@1` is the shape precedent. Breaking: needs an upgrade entry (D9 records it, this +dispatch flags it). **D5 — index types + dangling FK.** The target registers `btree`, `hash`, `gin`, `gist`, `spgist`, `brin`. Permissive options schemas; per-method validation is out of scope. Then the diff --git a/projects/infer-emit-roundtrip/spec.md b/projects/infer-emit-roundtrip/spec.md index 5330d2d0d7..e6fb0c50ae 100644 --- a/projects/infer-emit-roundtrip/spec.md +++ b/projects/infer-emit-roundtrip/spec.md @@ -148,17 +148,36 @@ owns. Options schemas start permissive; per-method option validation is not this slice's problem. -### 5. Unbounded `numeric` crashes at connect +### 5. The `Decimal` scalar is unusable on Postgres + +> **Corrected after D1 reproduced it.** This spec originally described the defect as "unbounded +> `numeric` from infer, via `@db.Numeric` with no args." That mechanism is wrong and the blast +> radius is much wider. Keeping the original framing here would have pointed D4's test at a +> surface the defect doesn't live on. `NumericParams.precision` is required while every sibling temporal codec's param is optional. `renderOutputType`, the `expandNumeric` DDL hook, and the PSL attribute parser all already handle a missing precision. Only the arktype schema disagrees, and it throws `RUNTIME.CODEC_PARAMETERIZATION_MISMATCH` when the app builds its `ExecutionContext`. +**The real reach.** The crash arrives through the **base-scalar** path, not an attribute. +`control-mutation-defaults.ts:162` maps `['Decimal', 'pg/numeric@1']`, so a PSL field declared +`amount Decimal` produces a codec ref with no `typeParams` at all and crashes at connect. Infer +never prints `@db.Numeric` with no arguments — an unbounded `numeric` column comes out as a bare +`Decimal?`, and only a *bounded* `numeric(10,2)` gets an attribute. So this is not an infer defect +that happens to hit `Decimal`; it is that **`Decimal` has never worked on Postgres**, and infer is +simply the first thing that generates one. + +D1's evidence that it went unnoticed this long: there is not a single `Decimal` field in any +`.prisma` file in this repository. The path has never been exercised. + **Fix:** make `precision` optional on `NumericParams` and `numericParamsSchema`, matching the sibling `PrecisionParams` pattern. Do not loosen `assertColumnCodecIntegrity` — that check is -working as designed. Not infer-specific: hand-authored `@db.Numeric` with no args hits the same -crash, so the fix needs a test at the authoring surface too. +working as designed. + +**D4's test must target the base scalar.** A bare `amount Decimal` field authored in PSL, taken +through emit to a live `ExecutionContext`. A test that only covers `@db.Numeric()` would pass +while the defect ships. ### 6. No `pg/date` codec exists From 88c0974d39dfa5bf225063604ccbdad41b39faba Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 14:34:09 +0200 Subject: [PATCH 04/24] fix(psl-infer): pluralize via the maintained `pluralize` library `pluralize()` appended `es` to any word ending in s/x/z/ch/sh, so an already-plural table name like `sessions` doubled to `sessionses` in inferred back-relation field names. Replaced the hand-rolled suffix rule with the `pluralize` package (real inflection: irregular/uncountable word lists plus ordered regex rules, not a trailing-character heuristic), per TML-3024 and the spec's preference for a maintained library over a hand-rolled table. Verified it satisfies every row of the acceptance table, including the two cases that pull in opposite directions: already plural (sessions, identities, mfaAmrClaims unchanged) and singular-but-s-ending (status -> statuses, bus -> buses, class -> classes). Added `@types/pluralize` as a dev dependency since the library ships untyped. Import as a default import (`import pluralizeLib from 'pluralize'`), not a namespace import: pluralize assigns its whole CJS module.exports in one statement, so cjs-module-lexer-based interop (Node's native ESM loader, used when the CLI runs the built dist/psl-infer.mjs) cannot statically detect `.plural` as a named export and leaves the namespace with only `default`. A default import always resolves to the full module.exports value, so `pluralizeLib.plural` works reliably. Vitest hides this because Vite pre-bundles CJS deps with esbuild, which copies the CJS exports' own properties onto the namespace at runtime; confirmed the failure and the fix against a real Node ESM import, not just the test runner. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/2-sql/9-family/package.json | 4 +- .../psl-contract-infer/name-transforms.ts | 16 +- .../name-transforms.test.ts | 19 ++ pnpm-lock.yaml | 208 +++++++++--------- 4 files changed, 123 insertions(+), 124 deletions(-) diff --git a/packages/2-sql/9-family/package.json b/packages/2-sql/9-family/package.json index eb038eba0c..2b04a66e8c 100644 --- a/packages/2-sql/9-family/package.json +++ b/packages/2-sql/9-family/package.json @@ -27,7 +27,8 @@ "@prisma-next/sql-runtime": "workspace:0.15.0", "@prisma-next/sql-schema-ir": "workspace:0.15.0", "@prisma-next/utils": "workspace:0.15.0", - "arktype": "^2.2.2" + "arktype": "^2.2.2", + "pluralize": "^8.0.0" }, "devDependencies": { "@prisma-next/driver-postgres": "workspace:0.15.0", @@ -37,6 +38,7 @@ "@prisma-next/test-utils": "workspace:0.15.0", "@prisma-next/tsconfig": "workspace:0.15.0", "@prisma-next/tsdown": "workspace:0.15.0", + "@types/pluralize": "^0.0.33", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts index a1e3ee8c6b..40f5eee5ba 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts @@ -1,3 +1,5 @@ +import pluralizeLib from 'pluralize'; + const PSL_RESERVED_WORDS = new Set(['model', 'enum', 'types', 'type', 'generator', 'datasource']); const IDENTIFIER_PART_PATTERN = /[A-Za-z0-9]+/g; @@ -143,19 +145,7 @@ export function toEnumMemberName(value: string): string { } export function pluralize(word: string): string { - if ( - word.endsWith('s') || - word.endsWith('x') || - word.endsWith('z') || - word.endsWith('ch') || - word.endsWith('sh') - ) { - return `${word}es`; - } - if (word.endsWith('y') && !/[aeiou]y$/i.test(word)) { - return `${word.slice(0, -1)}ies`; - } - return `${word}s`; + return pluralizeLib.plural(word); } export function deriveRelationFieldName( diff --git a/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts index f4deefdf92..bb8b2e77d3 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/name-transforms.test.ts @@ -142,6 +142,21 @@ describe('pluralize', () => { expect(pluralize('day')).toBe('days'); expect(pluralize('key')).toBe('keys'); }); + + it('leaves already-plural words unchanged', () => { + expect(pluralize('sessions')).toBe('sessions'); + expect(pluralize('identities')).toBe('identities'); + expect(pluralize('mfaAmrClaims')).toBe('mfaAmrClaims'); + }); + + it('pluralizes singular words that end in s', () => { + expect(pluralize('status')).toBe('statuses'); + expect(pluralize('bus')).toBe('buses'); + }); + + it('pluralizes class', () => { + expect(pluralize('class')).toBe('classes'); + }); }); describe('deriveRelationFieldName', () => { @@ -178,6 +193,10 @@ describe('deriveBackRelationFieldName', () => { it('singularizes for 1:1', () => { expect(deriveBackRelationFieldName('Profile', true)).toBe('profile'); }); + + it('does not double-pluralize an already-plural model name for 1:N', () => { + expect(deriveBackRelationFieldName('Sessions', false)).toBe('sessions'); + }); }); describe('toNamedTypeName', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cd90b5a01..c4a41179a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,7 +117,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) apps/lsp-playground: dependencies: @@ -284,7 +284,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/bundle-size: dependencies: @@ -372,7 +372,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) wrangler: specifier: 4.91.0 version: 4.91.0(@cloudflare/workers-types@4.20260515.1) @@ -427,7 +427,7 @@ importers: version: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/mongo-demo: dependencies: @@ -521,7 +521,7 @@ importers: version: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/multi-extension-monorepo: dependencies: @@ -573,7 +573,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/paradedb-demo: dependencies: @@ -655,7 +655,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/prisma-next-cloudflare-worker: dependencies: @@ -731,7 +731,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) wrangler: specifier: 4.91.0 version: 4.91.0(@cloudflare/workers-types@4.20260515.1) @@ -867,7 +867,7 @@ importers: version: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/prisma-next-demo-sqlite: dependencies: @@ -1046,7 +1046,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/react-router-demo: dependencies: @@ -1152,7 +1152,7 @@ importers: version: 6.1.1(typescript@5.9.3)(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/retail-store: dependencies: @@ -1282,7 +1282,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) examples/supabase: dependencies: @@ -1355,7 +1355,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/0-config/tsconfig: {} @@ -1403,7 +1403,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/0-foundation/utils: devDependencies: @@ -1421,7 +1421,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/config: dependencies: @@ -1455,7 +1455,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/errors: dependencies: @@ -1480,7 +1480,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/framework-components: dependencies: @@ -1517,7 +1517,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/operations: devDependencies: @@ -1538,7 +1538,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/1-core/ts-render: devDependencies: @@ -1556,7 +1556,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/2-authoring/contract: dependencies: @@ -1581,7 +1581,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/2-authoring/ids: dependencies: @@ -1612,7 +1612,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/2-authoring/psl-parser: dependencies: @@ -1643,7 +1643,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/2-authoring/psl-printer: dependencies: @@ -1677,7 +1677,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/cli: dependencies: @@ -1795,7 +1795,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/cli-telemetry: dependencies: @@ -1838,7 +1838,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/config-loader: dependencies: @@ -1881,7 +1881,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/emitter: dependencies: @@ -1927,7 +1927,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/language-server: dependencies: @@ -1979,7 +1979,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/migration: dependencies: @@ -2019,7 +2019,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/1-framework/3-tooling/prisma-next: dependencies: @@ -2144,7 +2144,7 @@ importers: version: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/1-foundation/mongo-codec: dependencies: @@ -2175,7 +2175,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/1-foundation/mongo-contract: dependencies: @@ -2215,7 +2215,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/1-foundation/mongo-value: dependencies: @@ -2237,7 +2237,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/2-authoring/contract-psl: dependencies: @@ -2289,7 +2289,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/2-authoring/contract-ts: dependencies: @@ -2335,7 +2335,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/3-tooling/emitter: dependencies: @@ -2372,7 +2372,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/3-tooling/mongo-schema-ir: dependencies: @@ -2406,7 +2406,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/4-query/query-ast: dependencies: @@ -2443,7 +2443,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/5-query-builders/orm: dependencies: @@ -2504,7 +2504,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/5-query-builders/query-builder: dependencies: @@ -2538,7 +2538,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/6-transport/mongo-lowering: dependencies: @@ -2569,7 +2569,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/6-transport/mongo-wire: dependencies: @@ -2597,7 +2597,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/7-runtime: dependencies: @@ -2667,7 +2667,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-mongo-family/9-family: dependencies: @@ -2734,7 +2734,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/1-core/contract: dependencies: @@ -2771,7 +2771,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/1-core/errors: devDependencies: @@ -2792,7 +2792,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/1-core/operations: dependencies: @@ -2826,7 +2826,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/1-core/schema-ir: dependencies: @@ -2857,7 +2857,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/2-authoring/contract-psl: dependencies: @@ -2909,7 +2909,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/2-authoring/contract-ts: dependencies: @@ -2964,7 +2964,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/3-tooling/emitter: dependencies: @@ -3001,7 +3001,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/4-lanes/query-builder: devDependencies: @@ -3028,7 +3028,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/4-lanes/relational-core: dependencies: @@ -3080,7 +3080,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/4-lanes/sql-builder: dependencies: @@ -3132,7 +3132,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/5-runtime: dependencies: @@ -3187,7 +3187,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/2-sql/9-family: dependencies: @@ -3233,6 +3233,9 @@ importers: arktype: specifier: ^2.2.2 version: 2.2.3 + pluralize: + specifier: ^8.0.0 + version: 8.0.0 devDependencies: '@prisma-next/driver-postgres': specifier: workspace:0.15.0 @@ -3255,6 +3258,9 @@ importers: '@prisma-next/tsdown': specifier: workspace:0.15.0 version: link:../../0-config/tsdown + '@types/pluralize': + specifier: ^0.0.33 + version: 0.0.33 tsdown: specifier: 'catalog:' version: 0.22.3(tsx@4.22.5)(typescript@5.9.3) @@ -3263,7 +3269,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/arktype-json: dependencies: @@ -3312,7 +3318,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/middleware-cache: dependencies: @@ -3337,7 +3343,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/mongo: dependencies: @@ -3419,7 +3425,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/paradedb: dependencies: @@ -3486,7 +3492,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/pgvector: dependencies: @@ -3562,7 +3568,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/postgis: dependencies: @@ -3638,7 +3644,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/postgres: dependencies: @@ -3726,7 +3732,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/sql-orm-client: dependencies: @@ -3802,7 +3808,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/sqlite: dependencies: @@ -3875,7 +3881,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-extensions/supabase: dependencies: @@ -3981,7 +3987,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-mongo-target/1-mongo-target: dependencies: @@ -4072,7 +4078,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-mongo-target/2-mongo-adapter: dependencies: @@ -4163,7 +4169,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-mongo-target/3-mongo-driver: dependencies: @@ -4206,7 +4212,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/3-targets/postgres: dependencies: @@ -4285,7 +4291,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/3-targets/sqlite: dependencies: @@ -4352,7 +4358,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/6-adapters/postgres: dependencies: @@ -4437,7 +4443,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/6-adapters/sqlite: dependencies: @@ -4522,7 +4528,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/7-drivers/postgres: dependencies: @@ -4586,7 +4592,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages/3-targets/7-drivers/sqlite: dependencies: @@ -4632,7 +4638,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) test/e2e/framework: dependencies: @@ -4738,7 +4744,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) test/integration: dependencies: @@ -4937,7 +4943,7 @@ importers: version: vite@8.0.9(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) test/integration/test/fixtures/cli/cli-e2e-test-app: dependencies: @@ -5112,7 +5118,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) packages: @@ -7915,6 +7921,9 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/pluralize@0.0.33': + resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -9740,6 +9749,10 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -13206,6 +13219,8 @@ snapshots: pg-protocol: 1.15.0 pg-types: 2.2.0 + '@types/pluralize@0.0.33': {} + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 @@ -13249,7 +13264,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) + vitest: 4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) '@vitest/expect@4.1.10': dependencies: @@ -15102,6 +15117,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pluralize@8.0.0: {} + postcss@8.4.31: dependencies: nanoid: 3.3.15 @@ -16123,36 +16140,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@types/node@25.9.4)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)): - dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.3 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.9.4 - '@vitest/coverage-v8': 4.1.6(vitest@4.1.10) - jsdom: 29.1.1(@noble/hashes@2.2.0) - transitivePeerDependencies: - - msw - - vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6(vitest@4.1.10))(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)): + vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.8.4)) From ddee7468a3633cda2420add2459dd2470254b0e4 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 15:00:33 +0200 Subject: [PATCH 05/24] fix(contract-psl): accept the 1:1 back-relation and list storage defaults infer prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interpreter only collected back-relation candidates for list-typed fields, so a bare, `@relation`-less model-typed field (the back side of a 1:1) fell through to scalar resolution and died with PSL_UNSUPPORTED_FIELD_TYPE. A model-typed, non-list field with no `@relation` can only be the back side — the owning side always carries `@relation(fields, references)` to declare its FK columns — so this is never ambiguous. ModelBackrelationCandidate now carries `isList`, and applyBackrelationCandidates resolves cardinality to `1:1` for a singular candidate (`1:N` unchanged for list) and skips many-to-many junction matching for singular candidates, since that concept only applies to lists. Separately, the list-default check conflated a storage-level SQL default (`dbgenerated(...)`, `now()`, lowered as `defaultValue.kind: "function"`) with a genuine per-element execution default (`executionDefaults.onCreate`, e.g. `uuid()`), rejecting both on list fields. Only the latter has no list semantics; Postgres accepts `DEFAULT (expression)` on an array column regardless of `many`, and buildColumnDefaultSql already renders it that way. The check now only rejects `executionDefaults.onCreate` on a list field. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-psl/src/interpreter.ts | 14 +- .../contract-psl/src/psl-field-resolution.ts | 13 +- .../src/psl-relation-resolution.ts | 62 +++++---- .../test/interpreter.diagnostics.test.ts | 129 +++++++++++++++--- .../test/interpreter.relations.test.ts | 99 ++++++++++++++ 5 files changed, 262 insertions(+), 55 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 dec19f4012..d3883ccc80 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -103,7 +103,7 @@ import { interpretRelationAttribute, type ModelBackrelationCandidate, normalizeReferentialAction, - validateNavigationListFieldAttributes, + validateBackrelationFieldAttributes, } from './psl-relation-resolution'; import { baseModelSpec, @@ -726,10 +726,16 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult const resultBackrelationCandidates: ModelBackrelationCandidate[] = []; for (const field of Object.values(model.fields)) { - if (!field.list || !input.modelNames.has(field.typeName)) { + if (!input.modelNames.has(field.typeName)) { continue; } - const attributesValid = validateNavigationListFieldAttributes({ + const relationAttribute = getAttribute(field.attributes, 'relation'); + if (!field.list && relationAttribute) { + // The owning side of the relation: it declares fields/references and is + // lowered separately below, by the `relationAttributes` FK-building loop. + continue; + } + const attributesValid = validateBackrelationFieldAttributes({ modelName: model.name, field, sourceId, @@ -739,7 +745,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult familyId: input.familyId, targetId: input.targetId, }); - const relationAttribute = getAttribute(field.attributes, 'relation'); let relationName: string | undefined; if (relationAttribute) { const parsedRelation = interpretRelationAttribute({ @@ -786,6 +791,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult tableName, field, targetModelName: field.typeName, + isList: field.list, ...ifDefined('relationName', relationName), }); } diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts index 6513bf81fd..2cef32427e 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts @@ -347,6 +347,13 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv if (field.typeContractSpaceId !== undefined && relationAttribute) { continue; } + // A model-typed, non-list field with no `@relation` is the back side of a + // 1:1 relation — the owning side always carries `@relation(fields: [...], + // references: [...])`. It is lowered separately, via the interpreter's + // backrelation-candidate matching, not as a scalar column here. + if (isModelField) { + continue; + } const isValueObjectField = compositeTypeNames.has(field.typeName); const isListField = field.list; @@ -481,8 +488,10 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv }) : {}; const loweredOnCreate = loweredDefault.executionDefaults?.onCreate; - const loweredFunctionDefault = loweredDefault.defaultValue?.kind === 'function'; - if (isListField && (loweredOnCreate || loweredFunctionDefault)) { + // Only a genuine per-element execution-time generator has no list semantics. A + // `defaultValue.kind === 'function'` default (`dbgenerated(...)`, `now()`) is a storage-level + // SQL default and is valid on a list column regardless of `many`. + if (isListField && loweredOnCreate) { const defaultExpression = defaultAttribute?.args.find((arg) => arg.kind === 'positional')?.value.trim() ?? 'this function'; diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index 70154c9189..3783a14f4d 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -62,6 +62,8 @@ export type ModelBackrelationCandidate = { readonly tableName: string; readonly field: FieldSymbol; readonly targetModelName: string; + /** Whether the PSL field itself is list-typed (`Target[]`) rather than singular (`Target?`). A singular candidate is the back side of a 1:1 relation and can never be many-to-many. */ + readonly isList: boolean; readonly relationName?: string; }; @@ -453,35 +455,39 @@ export function applyBackrelationCandidates(input: { : [...pairMatches]; if (matches.length === 0) { - const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({ - candidate, - fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel, - modelIdColumns: input.modelIdColumns, - }); - const junctionPair = junctionPairs[0]; - if (junctionPairs.length === 1 && junctionPair) { - relationsForModel(input.modelRelations, candidate.modelName).push( - manyToManyRelationNode(candidate, junctionPair), - ); - continue; - } - if (junctionPairs.length > 1) { - input.diagnostics.push({ - code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', - message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add @relation(name: "...") (or @relation("...")) to the list field and the junction FK-side relation pointing back at "${candidate.modelName}" to disambiguate.`, - sourceId: input.sourceId, - span: candidate.field.span, + // A singular candidate is the back side of a 1:1 — many-to-many junction + // matching only makes sense for a list-typed backrelation. + if (candidate.isList) { + const { pairs: junctionPairs, nearMisses } = findJunctionFkPairs({ + candidate, + fkRelationsByDeclaringModel: input.fkRelationsByDeclaringModel, + modelIdColumns: input.modelIdColumns, }); - continue; - } - const nearMiss = nearMisses[0]; - if (nearMiss) { - input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId)); - continue; + const junctionPair = junctionPairs[0]; + if (junctionPairs.length === 1 && junctionPair) { + relationsForModel(input.modelRelations, candidate.modelName).push( + manyToManyRelationNode(candidate, junctionPair), + ); + continue; + } + if (junctionPairs.length > 1) { + input.diagnostics.push({ + code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', + message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple junction FK pairs for a many-to-many relation. Add @relation(name: "...") (or @relation("...")) to the list field and the junction FK-side relation pointing back at "${candidate.modelName}" to disambiguate.`, + sourceId: input.sourceId, + span: candidate.field.span, + }); + continue; + } + const nearMiss = nearMisses[0]; + if (nearMiss) { + input.diagnostics.push(junctionNearMissDiagnostic(candidate, nearMiss, input.sourceId)); + continue; + } } input.diagnostics.push({ code: 'PSL_ORPHANED_BACKRELATION_LIST', - message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(fields: [...], references: [...]) on the FK-side relation or use an explicit join model for many-to-many.`, + message: `Backrelation field "${candidate.modelName}.${candidate.field.name}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(fields: [...], references: [...]) on the FK-side relation${candidate.isList ? ' or use an explicit join model for many-to-many' : ''}.`, sourceId: input.sourceId, span: candidate.field.span, }); @@ -490,7 +496,7 @@ export function applyBackrelationCandidates(input: { if (matches.length > 1) { input.diagnostics.push({ code: 'PSL_AMBIGUOUS_BACKRELATION_LIST', - message: `Backrelation list field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation(name: "...") (or @relation("...")) to both sides to disambiguate.`, + message: `Backrelation field "${candidate.modelName}.${candidate.field.name}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation(name: "...") (or @relation("...")) to both sides to disambiguate.`, sourceId: input.sourceId, span: candidate.field.span, }); @@ -506,7 +512,7 @@ export function applyBackrelationCandidates(input: { toModel: matched.declaringModelName, toTable: matched.declaringTableName, ...ifDefined('toNamespaceId', matched.declaringNamespaceId), - cardinality: '1:N', + cardinality: candidate.isList ? '1:N' : '1:1', on: { parentTable: candidate.tableName, parentColumns: matched.referencedColumns, @@ -517,7 +523,7 @@ export function applyBackrelationCandidates(input: { } } -export function validateNavigationListFieldAttributes(input: { +export function validateBackrelationFieldAttributes(input: { readonly modelName: string; readonly field: FieldSymbol; readonly sourceId: string; diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index 86f84f85f8..e6f75fbb12 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -1319,47 +1319,134 @@ namespace auth { }); describe('interpretPslDocumentToSqlContract list-field constructs', () => { - it('rejects an execution default now() on a list field', () => { - expectDiagnosticForSchema( - `model Post { + it('accepts a storage-level dbgenerated(...) default on a list field', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { id Int @id - tags String[] @default(now()) + tags String[] @default(dbgenerated("'{}'::text[]")) } `, - { - code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', - message: - 'Field "Post.tags" is a list and cannot use an execution default ("now()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.storage).toMatchObject({ + namespaces: { + public: { + entries: { + table: { + post: { + columns: { + tags: { + many: true, + default: { kind: 'function', expression: "'{}'::text[]" }, + }, + }, + }, + }, + }, + }, }, - ); + }); }); - it('rejects an execution default uuid() on a list field', () => { - expectDiagnosticForSchema( - `model Post { + it('accepts a storage-level now() default on a list field', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { id Int @id - tags String[] @default(uuid()) + tags DateTime[] @default(now()) } `, - { - code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', - message: - 'Field "Post.tags" is a list and cannot use an execution default ("uuid()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.storage).toMatchObject({ + namespaces: { + public: { + entries: { + table: { + post: { + columns: { + tags: { + many: true, + default: { kind: 'function', expression: 'now()' }, + }, + }, + }, + }, + }, + }, }, - ); + }); }); - it('rejects an execution default autoincrement() on a list field', () => { + it('accepts a storage-level autoincrement() default on a list field', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model Post { + id Int @id + tags Int[] @default(autoincrement()) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.storage).toMatchObject({ + namespaces: { + public: { + entries: { + table: { + post: { + columns: { + tags: { + many: true, + default: { kind: 'function', expression: 'autoincrement()' }, + }, + }, + }, + }, + }, + }, + }, + }); + }); + + it('rejects a genuine per-element execution default uuid() on a list field', () => { expectDiagnosticForSchema( `model Post { id Int @id - tags Int[] @default(autoincrement()) + tags String[] @default(uuid()) } `, { code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED', message: - 'Field "Post.tags" is a list and cannot use an execution default ("autoincrement()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', + 'Field "Post.tags" is a list and cannot use an execution default ("uuid()"). Lists have no per-element execution-default semantics; use a literal list @default or remove the default.', }, ); }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index 17efb8092a..a4f2641dd9 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -75,6 +75,105 @@ model Post { }); }); + it('accepts a bare model-typed optional field with no @relation as the 1:1 back side', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id + profile Profile? +} + +model Profile { + id Int @id + userId Int @unique + user User @relation(fields: [userId], references: [id]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ ...baseInput, ...document }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const models = modelsOf(result.value) as Record< + string, + { relations?: Record } + >; + expect(models['User']?.relations).toMatchObject({ + profile: { + to: crossRef('Profile', 'public'), + cardinality: '1:1', + on: { + localFields: ['id'], + targetFields: ['userId'], + }, + }, + }); + expect(models['Profile']?.relations).toMatchObject({ + user: { + to: crossRef('User', 'public'), + cardinality: 'N:1', + on: { + localFields: ['userId'], + targetFields: ['id'], + }, + }, + }); + }); + + it('reports an orphaned 1:1 backrelation candidate when no FK points back at it', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id + profile Profile? +} + +model Profile { + id Int @id +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ ...baseInput, ...document }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_ORPHANED_BACKRELATION_LIST', + message: expect.stringContaining('User.profile'), + }), + ]), + ); + }); + + it('still rejects a field whose type is neither a model, enum, composite, nor scalar', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id + nonsense Nonsense +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ ...baseInput, ...document }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_UNSUPPORTED_FIELD_TYPE', + message: expect.stringContaining('User.nonsense'), + }), + ]), + ); + }); + it('matches named backrelations using positional and named relation forms', () => { const document = symbolTableInputFromParseArgs({ schema: `model User { From c956825414c3fab622b9bf4edb09124a556b9699 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 15:08:33 +0200 Subject: [PATCH 06/24] fix(contract-psl): reject autoincrement() on a list field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permitting storage-level function defaults on lists (the defect-7 fix) also let `Int[] @default(autoincrement())` through, which the postgres DDL builder miscompiles: buildColumnTypeSql keys on the `autoincrement()` sentinel to emit SERIAL and drops the array suffix entirely. The old broad `kind === "function"` rule blocked that shape, but for the wrong reason — it called every storage default an execution default. Restoring it would undo defect 7, so this adds a narrow guard instead: a sequence yields one scalar per row, which an array column cannot be. The check keys on the lowered `autoincrement()` sentinel rather than the authored call name. Every target lowers the call to that exact expression and keys its DDL rendering off it, so rejecting the sentinel keeps the shape out of the DDL builders however it was spelled. Both sides of the line are tested: Int[] @default(autoincrement()) errors, String[] @default(dbgenerated("{}::text[]")) still lowers to a storage function default. Scalar @default(autoincrement()) is untouched. Also regenerates the print-psl inline snapshot invalidated by the pluralize fix: `child` now pluralizes to `children`, the correct irregular plural, where the hand-rolled rule produced `childs`. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-psl/src/psl-field-resolution.ts | 17 ++++++++ .../test/interpreter.diagnostics.test.ts | 40 ++++--------------- .../print-psl/print-psl.relations.test.ts | 4 +- 3 files changed, 27 insertions(+), 34 deletions(-) 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 2cef32427e..2df6cdc856 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 @@ -503,6 +503,23 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv }); continue; } + // `autoincrement()` is a storage default, so the execution-default check above does not + // catch it — but a sequence yields one scalar per row, which an array column cannot be. + // Every target lowers the call to this sentinel expression and keys its DDL rendering off + // it, so rejecting the sentinel here keeps the shape out of the DDL builders entirely. + if ( + isListField && + loweredDefault.defaultValue?.kind === 'function' && + loweredDefault.defaultValue.expression === 'autoincrement()' + ) { + diagnostics.push({ + code: 'PSL_LIST_AUTOINCREMENT_UNSUPPORTED', + message: `Field "${model.name}.${field.name}" is a list and cannot use @default(autoincrement()). autoincrement() draws a single value per row from a database sequence, which is not a list; remove "[]" or the default.`, + sourceId, + span: defaultAttribute?.span ?? field.span, + }); + continue; + } if (field.optional && loweredOnCreate) { const generatorDescription = loweredOnCreate.kind === 'generator' ? `"${loweredOnCreate.id}"` : 'for this field'; diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index e6f75fbb12..aeb8f1b62a 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -1397,43 +1397,19 @@ describe('interpretPslDocumentToSqlContract list-field constructs', () => { }); }); - it('accepts a storage-level autoincrement() default on a list field', () => { - const document = symbolTableInputFromParseArgs({ - schema: `model Post { + it('rejects autoincrement() on a list field', () => { + expectDiagnosticForSchema( + `model Post { id Int @id tags Int[] @default(autoincrement()) } `, - sourceId: 'schema.prisma', - }); - - const result = interpretPslDocumentToSqlContract({ - ...baseInput, - ...document, - controlMutationDefaults: builtinControlMutationDefaults, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.storage).toMatchObject({ - namespaces: { - public: { - entries: { - table: { - post: { - columns: { - tags: { - many: true, - default: { kind: 'function', expression: 'autoincrement()' }, - }, - }, - }, - }, - }, - }, + { + code: 'PSL_LIST_AUTOINCREMENT_UNSUPPORTED', + message: + 'Field "Post.tags" is a list and cannot use @default(autoincrement()). autoincrement() draws a single value per row from a database sequence, which is not a list; remove "[]" or the default.', }, - }); + ); }); it('rejects a genuine per-element execution default uuid() on a list field', () => { diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts index a6ad10b39f..17be7839cc 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.relations.test.ts @@ -487,8 +487,8 @@ describe('printPsl', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma-next contract emit\`. model Parent { - id Int @id - childs Child[] + id Int @id + children Child[] @@map("parent") } From fb6b86f1a5e38c51b11f5bbf295c955686ab8e80 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 15:41:42 +0200 Subject: [PATCH 07/24] fix(target-postgres): postgres codec set covers unbounded numeric and date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NumericParams.precision` was required while every sibling temporal codec (`PrecisionParams`) declares its param optional. A bare `amount Decimal` field — the base-scalar path `control-mutation-defaults.ts` maps every postgres `Decimal` field through, with or without `@db.Numeric` — produced a `pg/numeric@1` ref with no `typeParams` and crashed `createExecutionContext` with `RUNTIME.CODEC_PARAMETERIZATION_MISMATCH`. Make `precision` optional on `NumericParams`/`numericParamsSchema`, matching the sibling pattern; `assertColumnCodecIntegrity` is untouched. A bounded `numeric(10,2)` still carries and enforces its params. No `pg/date@1` codec existed: `@db.Date` inherited `DateTime`s `pg/timestamptz@1`. Top-level `SELECT` survived because `decode()` was a passthrough over the driver-parsed `Date`, but `.include()` (`json_agg` -> `decodeJson()`) rejected a bare `YYYY-MM-DD` string. Add a real `pg/date@1` codec and point `@db.Date` at it. A Postgres `date` has no timezone, so the codec canonicalizes its JS-level value as a `Date` at UTC midnight: `decode()` re-derives the calendar date from the pg drivers local-midnight `Date` via local getters and reconstructs it via `Date.UTC`; `encode()` formats via UTC getters directly to `YYYY-MM-DD`, bypassing the pg drivers own local-getter-based `Date` serialization (which would shift the day near midnight under a negative UTC offset). `decodeJson`/`encodeJson` share the same `YYYY-MM-DD` representation. BREAKING: `@db.Date` columns now emit `codecId: "pg/date@1"` instead of inheriting `"pg/timestamptz@1"`, which changes the contract hash and therefore signed markers for any existing `@db.Date` column. Upgrade instructions are dispatch D9s job, not covered here. RT.01 (unbounded numeric via infer) and RT.02 (date via .include()) in test/integration/test/infer-roundtrip-runtime.integration.test.ts now pass. RT.02s top-level assertion is tightened from toBeInstanceOf(Date) to the exact UTC instant, now that pg/date@1 owns the conversion and the round trip no longer depends on the process timezone. Added RT.03, authoring "amount Decimal" directly (no @db.Numeric, bypassing infer) so the base-scalar fix is proven independently of infers own behavior. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-psl/src/psl-column-resolution.ts | 2 +- .../test/interpreter.types.test.ts | 4 +- .../postgres/src/core/codec-helpers.ts | 51 ++++++++- .../3-targets/postgres/src/core/codec-ids.ts | 1 + .../3-targets/postgres/src/core/codecs.ts | 58 +++++++++- .../3-targets/postgres/src/exports/codecs.ts | 2 + .../postgres/test/codecs-class.test.ts | 72 +++++++++++++ .../test/codecs-class.types.test-d.ts | 11 +- .../test/codecs-runtime-and-helpers.test.ts | 16 ++- .../3-targets/postgres/test/codecs.test.ts | 26 +++++ ...nfer-roundtrip-runtime.integration.test.ts | 102 +++++++++++++++--- 11 files changed, 319 insertions(+), 26 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 27edf7f47b..5bed64de27 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 @@ -763,7 +763,7 @@ export const NATIVE_TYPE_SPECS: Readonly> = { codecId: 'pg/timestamptz@1', nativeType: 'timestamptz', }, - 'db.Date': { args: 'noArgs', baseType: 'DateTime', codecId: null, nativeType: 'date' }, + 'db.Date': { args: 'noArgs', baseType: 'DateTime', codecId: 'pg/date@1', nativeType: 'date' }, 'db.Time': { args: 'optionalPrecision', baseType: 'DateTime', diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts index 84be831a99..c3a158b60c 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts @@ -70,7 +70,7 @@ model Event { nativeType: 'time', typeParams: { precision: 3 }, }, - PublishDay: { codecId: 'pg/timestamptz@1', nativeType: 'date' }, + PublishDay: { codecId: 'pg/date@1', nativeType: 'date' }, Payload: { codecId: 'pg/json@1', nativeType: 'json' }, Amount: { codecId: 'pg/numeric@1', @@ -105,7 +105,7 @@ model Event { typeRef: 'HappenedAt', }, publishDay: { - codecId: 'pg/timestamptz@1', + codecId: 'pg/date@1', nativeType: 'date', nullable: false, typeRef: 'PublishDay', diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts index 05f95c37bb..a35691ce36 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts @@ -47,7 +47,7 @@ export const pgNumericDecode = (wire: string | number): string => { }; export const pgNumericRenderOutputType = (typeParams: { - readonly precision: number; + readonly precision?: number; readonly scale?: number; }): string | undefined => { const precision = typeParams.precision; @@ -106,6 +106,55 @@ export const pgTimestamptzDecodeJson = (json: JsonValue): Date => { return date; }; +const ISO_8601_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +function formatDateOnly(year: number, month: number, day: number): string { + return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** + * A Postgres `date` has no time-of-day or timezone component, so `pg/date@1` + * canonicalizes its JS-level value as a `Date` at UTC midnight + * (`Date.UTC(y, m, d)`), independent of the process's local timezone. + * + * `pgDateEncode` reads the calendar date via UTC getters (matching that + * canonical form) and formats it as `YYYY-MM-DD` directly, bypassing the pg + * driver's own `Date` serialization (`dateToString`), which reads *local* + * getters and would shift the calendar day near midnight in negative-UTC-offset + * environments. + */ +export const pgDateEncode = (value: Date): string => + formatDateOnly(value.getUTCFullYear(), value.getUTCMonth() + 1, value.getUTCDate()); + +/** + * Normalizes the pg driver's already-parsed `Date` for a `date` column into + * the canonical UTC-midnight form. The driver (via `postgres-date`) builds + * that `Date` at *local* midnight from the wire text; reading it back with the + * same (local) getters recovers the exact calendar date the driver parsed, + * and reconstructing via `Date.UTC` makes the result's instant independent of + * the process's timezone. + */ +export const pgDateDecode = (wire: Date): Date => + new Date(Date.UTC(wire.getFullYear(), wire.getMonth(), wire.getDate())); + +export const pgDateEncodeJson = (value: Date): JsonValue => pgDateEncode(value); + +export const pgDateDecodeJson = (json: JsonValue): Date => { + if (typeof json !== 'string') { + throw new Error(`Expected date string for pg/date@1, got ${typeof json}`); + } + const match = ISO_8601_DATE.exec(json); + if (!match) { + throw new Error(`Invalid date string for pg/date@1: ${json}`); + } + const [, yearText, monthText, dayText] = match; + const date = new Date(Date.UTC(Number(yearText), Number(monthText) - 1, Number(dayText))); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date string for pg/date@1: ${json}`); + } + return date; +}; + export const pgIntervalDecode = (wire: string | Record): string => { if (typeof wire === 'string') return wire; return JSON.stringify(wire); diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-ids.ts b/packages/3-targets/3-targets/postgres/src/core/codec-ids.ts index 4082e69ede..73f2b47244 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-ids.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-ids.ts @@ -22,6 +22,7 @@ export const PG_NUMERIC_CODEC_ID = 'pg/numeric@1' as const; export const PG_BOOL_CODEC_ID = 'pg/bool@1' as const; export const PG_BIT_CODEC_ID = 'pg/bit@1' as const; export const PG_VARBIT_CODEC_ID = 'pg/varbit@1' as const; +export const PG_DATE_CODEC_ID = 'pg/date@1' as const; export const PG_TIMESTAMP_CODEC_ID = 'pg/timestamp@1' as const; export const PG_TIMESTAMPTZ_CODEC_ID = 'pg/timestamptz@1' as const; export const PG_TIME_CODEC_ID = 'pg/time@1' as const; diff --git a/packages/3-targets/3-targets/postgres/src/core/codecs.ts b/packages/3-targets/3-targets/postgres/src/core/codecs.ts index e345be7a58..fc69160ff6 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codecs.ts @@ -45,6 +45,10 @@ import { type as arktype } from 'arktype'; import { pgByteaDecodeJson, pgByteaEncodeJson, + pgDateDecode, + pgDateDecodeJson, + pgDateEncode, + pgDateEncodeJson, pgIntervalDecode, pgJsonbDecode, pgJsonbEncode, @@ -64,6 +68,7 @@ import { PG_BOOL_CODEC_ID, PG_BYTEA_CODEC_ID, PG_CHAR_CODEC_ID, + PG_DATE_CODEC_ID, PG_ENUM_CODEC_ID, PG_FLOAT_CODEC_ID, PG_FLOAT4_CODEC_ID, @@ -92,14 +97,14 @@ import { PostgresNativeEnum } from './postgres-native-enum'; type LengthParams = { readonly length?: number }; type PrecisionParams = { readonly precision?: number }; -type NumericParams = { readonly precision: number; readonly scale?: number }; +type NumericParams = { readonly precision?: number; readonly scale?: number }; const lengthParamsSchema = arktype({ 'length?': 'number.integer > 0', }) satisfies StandardSchemaV1; const numericParamsSchema = arktype({ - precision: 'number.integer > 0 & number.integer <= 1000', + 'precision?': 'number.integer > 0 & number.integer <= 1000', 'scale?': 'number.integer >= 0', }) satisfies StandardSchemaV1; @@ -115,6 +120,7 @@ const PG_INT8_META = { db: { sql: { postgres: { nativeType: 'bigint' } } } } as const PG_FLOAT4_META = { db: { sql: { postgres: { nativeType: 'real' } } } } as const; const PG_FLOAT8_META = { db: { sql: { postgres: { nativeType: 'double precision' } } } } as const; const PG_NUMERIC_META = { db: { sql: { postgres: { nativeType: 'numeric' } } } } as const; +const PG_DATE_META = { db: { sql: { postgres: { nativeType: 'date' } } } } as const; const PG_TIMESTAMP_META = { db: { sql: { postgres: { nativeType: 'timestamp without time zone' } } }, } as const; @@ -648,12 +654,57 @@ export class PgNumericDescriptor extends CodecDescriptorImpl { export const pgNumericDescriptor = new PgNumericDescriptor(); -export const pgNumericColumn = (params: NumericParams) => +export const pgNumericColumn = (params: NumericParams = {}) => column(pgNumericDescriptor.factory(params), pgNumericDescriptor.codecId, params, 'numeric'); pgNumericColumn satisfies ColumnHelperFor; pgNumericColumn satisfies ColumnHelperForStrict; +/** + * A Postgres `date` has no time-of-day or timezone component. This codec + * canonicalizes its JS-level value as a `Date` at UTC midnight, so its + * round-trip is independent of the process's local timezone — see + * `pgDateEncode`/`pgDateDecode` in `codec-helpers.ts`. + */ +export class PgDateCodec extends CodecImpl< + typeof PG_DATE_CODEC_ID, + readonly ['equality', 'order'], + Date | string, + Date +> { + async encode(value: Date, _ctx: CodecCallContext): Promise { + return pgDateEncode(value); + } + async decode(wire: Date, _ctx: CodecCallContext): Promise { + return pgDateDecode(wire); + } + encodeJson(value: Date): JsonValue { + return pgDateEncodeJson(value); + } + decodeJson(json: JsonValue): Date { + return pgDateDecodeJson(json); + } +} + +export class PgDateDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_DATE_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['date'] as const; + override readonly meta = PG_DATE_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgDateCodec { + return () => new PgDateCodec(this); + } +} + +export const pgDateDescriptor = new PgDateDescriptor(); + +export const pgDateColumn = () => + column(pgDateDescriptor.factory(), pgDateDescriptor.codecId, undefined, 'date'); + +pgDateColumn satisfies ColumnHelperFor; +pgDateColumn satisfies ColumnHelperForStrict; + export class PgTimestampCodec extends CodecImpl< typeof PG_TIMESTAMP_CODEC_ID, readonly ['equality', 'order'], @@ -1285,6 +1336,7 @@ export const codecDescriptors: readonly AnyCodecDescriptor[] = [ pgFloat4Descriptor, pgFloat8Descriptor, pgNumericDescriptor, + pgDateDescriptor, pgTimestampDescriptor, pgTimestamptzDescriptor, pgTimeDescriptor, diff --git a/packages/3-targets/3-targets/postgres/src/exports/codecs.ts b/packages/3-targets/3-targets/postgres/src/exports/codecs.ts index 61e714d099..afb5d2a9d8 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/codecs.ts @@ -2,6 +2,7 @@ export type { PgBitDescriptor, PgBoolDescriptor, PgCharDescriptor, + PgDateDescriptor, PgEnumCodec, PgEnumDescriptor, PgFloat4Descriptor, @@ -29,6 +30,7 @@ export { pgBitColumn, pgBoolColumn, pgCharColumn, + pgDateColumn, pgEnumDescriptor, pgFloat4Column, pgFloat8Column, diff --git a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts index 3694487039..b0f64a9737 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { PG_BIT_CODEC_ID, PG_BOOL_CODEC_ID, + PG_DATE_CODEC_ID, PG_FLOAT4_CODEC_ID, PG_FLOAT8_CODEC_ID, PG_INET_CODEC_ID, @@ -23,6 +24,7 @@ import { import { pgBitDescriptor, pgBoolDescriptor, + pgDateDescriptor, pgFloat4Descriptor, pgFloat8Descriptor, pgInetDescriptor, @@ -157,6 +159,76 @@ describe('codecs-class', () => { }); }); + describe('pg/numeric@1 with no typeParams (unbounded numeric / bare Decimal)', () => { + const codec = pgNumericDescriptor.factory({})(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_NUMERIC_CODEC_ID); + }); + + it('encodes and decodes strings verbatim with no precision/scale supplied', async () => { + expect(await codec.encode('123.45', callCtx)).toBe('123.45'); + expect(await codec.decode('123.45', callCtx)).toBe('123.45'); + }); + + it('renderOutputType returns undefined when precision is absent', () => { + expect(pgNumericDescriptor.renderOutputType?.({})).toBeUndefined(); + }); + }); + + describe('pg/date@1', () => { + const codec = pgDateDescriptor.factory()(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_DATE_CODEC_ID); + }); + + it('decode normalizes a local-midnight Date into canonical UTC midnight', async () => { + // Simulates what the pg driver hands the codec for a `date` column: a + // `Date` built at *local* midnight (postgres-date's `getDate`), e.g. + // `new Date(2024, 0, 15)`. Regardless of the process's timezone, decode + // must recover the same calendar date at UTC midnight. + const localMidnight = new Date(2024, 0, 15); + const decoded = await codec.decode(localMidnight, callCtx); + expect(decoded.getTime()).toBe(Date.UTC(2024, 0, 15)); + }); + + it('encode formats the UTC calendar date as YYYY-MM-DD, independent of local getters', async () => { + const utcMidnight = new Date(Date.UTC(2024, 0, 15)); + expect(await codec.encode(utcMidnight, callCtx)).toBe('2024-01-15'); + }); + + it('round-trips a calendar date through encode -> decode unchanged', async () => { + const original = new Date(Date.UTC(2024, 0, 15)); + const wireText = await codec.encode(original, callCtx); + // The driver would parse `wireText` back into a Date; decode + // canonicalizes whatever it receives to the same UTC-midnight instant. + const roundTripped = await codec.decode(new Date(2024, 0, 15), callCtx); + expect(wireText).toBe('2024-01-15'); + expect(roundTripped.getTime()).toBe(original.getTime()); + }); + + it('encodeJson/decodeJson round-trip the YYYY-MM-DD representation', () => { + const instant = new Date(Date.UTC(2024, 0, 15)); + expect(codec.encodeJson(instant)).toBe('2024-01-15'); + expect(codec.decodeJson('2024-01-15')).toEqual(instant); + }); + + it('throws on invalid JSON input', () => { + expect(() => codec.decodeJson(42)).toThrow(/Expected date string for pg\/date@1/); + expect(() => codec.decodeJson('not-a-date')).toThrow(/Invalid date string for pg\/date@1/); + expect(() => codec.decodeJson('2024-01-15T10:30:00Z')).toThrow( + /Invalid date string for pg\/date@1/, + ); + }); + + it('exposes equality-order traits and the date target/native types', () => { + expect(pgDateDescriptor.traits).toEqual(['equality', 'order']); + expect(pgDateDescriptor.targetTypes).toEqual(['date']); + expect(pgDateDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('date'); + }); + }); + describe('pg/timestamp@1', () => { const codec = pgTimestampDescriptor.factory({ precision: 3 })(instanceCtx); diff --git a/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts b/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts index 7eeb91f8af..aa12a65a27 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts @@ -87,7 +87,16 @@ test('pgNumeric: column helper preserves typed codecFactory + composite params', const col = pgNumericColumn({ precision: 10, scale: 2 }); expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgNumericCodec>(); expectTypeOf(col.typeParams).toEqualTypeOf<{ - readonly precision: number; + readonly precision?: number; + readonly scale?: number; + }>(); +}); + +test('pgNumeric: column helper accepts no-args call (default params)', () => { + const col = pgNumericColumn(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgNumericCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ + readonly precision?: number; readonly scale?: number; }>(); }); diff --git a/packages/3-targets/3-targets/postgres/test/codecs-runtime-and-helpers.test.ts b/packages/3-targets/3-targets/postgres/test/codecs-runtime-and-helpers.test.ts index 4d8f284d5f..e14e85b3ca 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs-runtime-and-helpers.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs-runtime-and-helpers.test.ts @@ -5,6 +5,7 @@ import { PG_BOOL_CODEC_ID, PG_BYTEA_CODEC_ID, PG_CHAR_CODEC_ID, + PG_DATE_CODEC_ID, PG_ENUM_CODEC_ID, PG_FLOAT_CODEC_ID, PG_FLOAT4_CODEC_ID, @@ -34,6 +35,7 @@ import { pgByteaDescriptor, pgCharColumn, pgCharDescriptor, + pgDateColumn, pgEnumDescriptor, pgFloat4Column, pgFloat8Column, @@ -348,7 +350,7 @@ describe('column helpers', () => { expect(spec.codecFactory(instanceCtx).id).toBe(PG_JSONB_CODEC_ID); }); - it('pgNumericColumn packages a ColumnSpec for pg/numeric@1 (params required)', () => { + it('pgNumericColumn packages a ColumnSpec for pg/numeric@1', () => { const spec = pgNumericColumn({ precision: 10, scale: 2 }); expect(spec.codecId).toBe(PG_NUMERIC_CODEC_ID); expect(spec.nativeType).toBe('numeric'); @@ -356,6 +358,18 @@ describe('column helpers', () => { expect(spec.codecFactory(instanceCtx).id).toBe(PG_NUMERIC_CODEC_ID); }); + it('pgNumericColumn defaults typeParams to {} when called with no args (unbounded numeric / bare Decimal)', () => { + const spec = pgNumericColumn(); + expect(spec.typeParams).toEqual({}); + }); + + it('pgDateColumn packages a ColumnSpec for pg/date@1', () => { + const spec = pgDateColumn(); + expect(spec.codecId).toBe(PG_DATE_CODEC_ID); + expect(spec.nativeType).toBe('date'); + expect(spec.codecFactory(instanceCtx).id).toBe(PG_DATE_CODEC_ID); + }); + it('pgTextColumn packages a ColumnSpec for pg/text@1', () => { const spec = pgTextColumn(); expect(spec.codecId).toBe(PG_TEXT_CODEC_ID); diff --git a/packages/3-targets/3-targets/postgres/test/codecs.test.ts b/packages/3-targets/3-targets/postgres/test/codecs.test.ts index b866d80cbd..4ac5c2aa83 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs.test.ts @@ -17,6 +17,7 @@ import { pgBoolDescriptor, pgByteaDescriptor, pgCharDescriptor, + pgDateDescriptor, pgFloat4Descriptor, pgFloat8Descriptor, pgFloatDescriptor, @@ -60,6 +61,7 @@ const descriptorByScalar = { float4: pgFloat4Descriptor, float8: pgFloat8Descriptor, numeric: pgNumericDescriptor, + date: pgDateDescriptor, timestamp: pgTimestampDescriptor, timestamptz: pgTimestamptzDescriptor, time: pgTimeDescriptor, @@ -93,6 +95,7 @@ describe('adapter-postgres codecs', () => { 'char', 'character', 'character varying', + 'date', 'double precision', 'float', 'float4', @@ -289,6 +292,22 @@ describe('adapter-postgres codecs', () => { }); }); + describe('date codec', () => { + const dateCodec = codecForScalar('date') as { + encode: (value: Date, ctx: SqlCodecCallContext) => Promise; + decode: (wire: Date, ctx: SqlCodecCallContext) => Promise; + }; + + it('encodes a Date as its UTC calendar date, not the pg driver Date auto-conversion', async () => { + expect(await dateCodec.encode(new Date(Date.UTC(2024, 0, 15)), {})).toBe('2024-01-15'); + }); + + it('decodes the driver local-midnight Date to the equivalent UTC-midnight instant', async () => { + const decoded = await dateCodec.decode(new Date(2024, 0, 15), {}); + expect(decoded.getTime()).toBe(Date.UTC(2024, 0, 15)); + }); + }); + describe('time codec', () => { const timeCodec = codecForScalar('time') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; @@ -603,6 +622,13 @@ describe('adapter-postgres codecs', () => { }); }); + describe('pg/date@1 registry resolution', () => { + it('resolves pgDateDescriptor by codec id from the registry', () => { + const resolved = postgresCodecRegistry.descriptorFor('pg/date@1'); + expect(resolved).toBe(pgDateDescriptor); + }); + }); + describe('numeric codec decode', () => { const numericCodec = codecForScalar('numeric') as { decode: (wire: string | number, ctx: SqlCodecCallContext) => Promise; diff --git a/test/integration/test/infer-roundtrip-runtime.integration.test.ts b/test/integration/test/infer-roundtrip-runtime.integration.test.ts index 1afd316f32..1c97d0bcac 100644 --- a/test/integration/test/infer-roundtrip-runtime.integration.test.ts +++ b/test/integration/test/infer-roundtrip-runtime.integration.test.ts @@ -1,18 +1,20 @@ /** - * Infer -> Emit Round-Trip Fidelity — runtime defects (TML-3037, dispatch D1) + * Infer -> Emit Round-Trip Fidelity — runtime defects (TML-3037, dispatches D1/D4) * * `cli-journeys/infer-roundtrip-fidelity.e2e.test.ts` drives infer -> emit -> * `db verify --schema-only`, but neither `contract emit` nor `db verify` * builds an `ExecutionContext` — so two of the slice's eight defects never - * surface on that path: + * surfaced on that path: * - * - An unbounded `numeric` column emits a valid contract, but crashes when - * the app builds its `ExecutionContext` (`RUNTIME.CODEC_PARAMETERIZATION_MISMATCH`). - * - A `date` column decodes fine on a top-level `SELECT` (`decode()` is a - * passthrough over an already-parsed JS `Date`), but `.include()` goes - * through `json_agg` -> `decodeJson()`, which rejects a bare `YYYY-MM-DD` - * string because `@db.Date` currently inherits `DateTime`'s - * `pg/timestamptz@1` codec (no `pg/date@1` exists yet). + * - An unbounded `numeric` column emits a valid contract, but building an + * `ExecutionContext` used to throw `RUNTIME.CODEC_PARAMETERIZATION_MISMATCH` + * (fixed by making `NumericParams.precision` optional — D4). RT.03 targets + * the same base-scalar path directly with a hand-authored `Decimal` field. + * - A `date` column decoded fine on a top-level `SELECT` (`decode()` was a + * passthrough over an already-parsed JS `Date`), but `.include()` went + * through `json_agg` -> `decodeJson()`, which rejected a bare `YYYY-MM-DD` + * string because `@db.Date` inherited `DateTime`'s `pg/timestamptz@1` + * codec (fixed by introducing `pg/date@1` — D4). * * Each scenario below infers + emits a real contract from a live database * (same CLI path the journey test uses), deserializes the emitted @@ -20,7 +22,7 @@ * following the shape of `rls-ts-walking-skeleton.integration.test.ts` * (`createDevDatabase`, a real driver, no CLI mocking) for the runtime half. */ -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import type { Contract } from '@prisma-next/contract/types'; @@ -115,6 +117,73 @@ withTempDir(({ createTempDir }) => { ); }); + describe('Runtime: a hand-authored bare Decimal field builds an ExecutionContext', () => { + let database: Awaited>; + + beforeAll(async () => { + database = await createDevDatabase(); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) await database.close(); + }, timeouts.spinUpPpgDev); + + it( + 'RT.03: "amount Decimal" with no @db.Numeric attribute emits cleanly and builds an ExecutionContext', + async () => { + // Hand-authored, not inferred: `control-mutation-defaults.ts` maps + // the bare `Decimal` scalar straight to `pg/numeric@1` with no + // `typeParams`, independent of any `@db.Numeric` attribute. RT.01 + // reaches this same base-scalar path indirectly (infer prints an + // unbounded `numeric` column as a bare `Decimal?`); this test targets + // it directly so the base-scalar fix can't regress behind an + // infer-only test. + const ctx: JourneyContext = setupJourney({ + connectionString: database.connectionString, + createTempDir, + contractMode: 'psl', + }); + writeFileSync( + join(ctx.testDir, 'contract.prisma'), + [ + '// use prisma-next', + '', + 'model AmountProbe {', + ' id Int @id', + ' amount Decimal', + '', + ' @@map("amount_probe")', + '}', + '', + ].join('\n'), + ); + + const emit = await runContractEmit(ctx); + if (emit.exitCode !== 0) { + throw new Error( + `contract emit failed:\n${stripAnsi(emit.stderr)}\n${stripAnsi(emit.stdout)}`, + ); + } + const contract = readEmittedContract(ctx); + + let constructionError: unknown; + try { + createExecutionContext({ + contract, + stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }), + }); + } catch (error) { + constructionError = error; + } + expect( + constructionError, + 'RT.03: createExecutionContext should accept a bare Decimal field', + ).toBeUndefined(); + }, + timeouts.spinUpPpgDev, + ); + }); + describe('Runtime: a date column decodes on select() but fails through include()', () => { let database: Awaited>; @@ -169,13 +238,12 @@ withTempDir(({ createTempDir }) => { namespaceId: 'public', }); const [row] = await records.select('id', 'notedOn').all(); - // `decode()` is a passthrough over the driver's already-parsed - // `Date` for a `date` column — this only proves the top-level - // path survives; the driver's local-midnight construction makes - // the exact instant environment-timezone-dependent, so this - // checks shape, not the instant. - expect(row?.notedOn, 'RT.02: top-level select decodes a plain date').toBeInstanceOf( - Date, + // `pg/date@1`'s decode() normalizes the driver's local-midnight + // `Date` (built by postgres-date's OID 1082 parser) into the + // equivalent UTC-midnight instant, so the round-trip no longer + // depends on the process's timezone — assert the exact value. + expect(row?.notedOn, 'RT.02: top-level select decodes the exact date').toEqual( + new Date(Date.UTC(2024, 0, 15)), ); const owners = new Collection({ runtime, context }, 'Owner', { From 38c4ab2dcc3eda1bc0bd24ca05751fd501849ac5 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 16:11:39 +0200 Subject: [PATCH 08/24] fix(target-postgres): register built-in index types; explain dropped dangling FKs The postgres target registered zero index types, so validateIndexTypes rejected any @@index(..., type: "gin"/"hash") contract infer produced for a non-default access method. Register the six access methods Postgres ships (btree, hash, gin, gist, spgist, brin) via IndexTypeRegistry, following the same wiring ParadeDB already uses for its own bm25 type. Options schemas stay permissive; per-method option validation is a later slice. btree is Postgres default access method already normalized to `undefined` by introspection, so registering it does not change what gets emitted for an ordinary index. Separately, resolveForeignKeys correctly drops a foreign key whose target is outside the introspected schema, but did so silently: the scalar column survived with no comment and no warning, so a user loses every such relationship (e.g. every auth.users FK in a Supabase database) with no indication. Track dropped FKs per table and emit an explanatory comment on the model, matching the existing missing-primary-key comment mechanism and voice. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- .../postgres/src/core/descriptor-meta.ts | 2 + .../postgres/src/core/index-types.ts | 15 +++ .../src/core/psl-infer/infer-psl-contract.ts | 96 +++++++++++++-- .../postgres/test/index-types.test.ts | 116 ++++++++++++++++++ ...r-psl-contract-described-contracts.test.ts | 21 +++- .../print-psl/print-psl.enums.test.ts | 1 + .../infer-roundtrip-fidelity.e2e.test.ts | 30 ++--- 7 files changed, 246 insertions(+), 35 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/src/core/index-types.ts create mode 100644 packages/3-targets/3-targets/postgres/test/index-types.test.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts b/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts index 57fab1cd51..25cf104a57 100644 --- a/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts +++ b/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts @@ -9,12 +9,14 @@ import { } from './authoring'; import { postgresQualifyColumnType } from './codecs'; import { postgresTargetDescriptorMetaRuntime } from './descriptor-meta-runtime'; +import { postgresIndexTypes } from './index-types'; import { DEFAULT_NAMESPACE_ID } from './namespace-ids'; import { postgresCreateNamespace } from './postgres-schema'; const postgresTargetDescriptorMetaBase = { ...postgresTargetDescriptorMetaRuntime, defaultNamespaceId: DEFAULT_NAMESPACE_ID, + indexTypes: postgresIndexTypes, authoring: { type: postgresAuthoringTypes, field: postgresAuthoringFieldPresets, diff --git a/packages/3-targets/3-targets/postgres/src/core/index-types.ts b/packages/3-targets/3-targets/postgres/src/core/index-types.ts new file mode 100644 index 0000000000..0fc5c2ab20 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/index-types.ts @@ -0,0 +1,15 @@ +import { defineIndexTypes } from '@prisma-next/sql-contract/index-types'; +import { type } from 'arktype'; + +// Postgres's built-in index access methods (`CREATE INDEX ... USING `). +// Per-method option validation (e.g. `gin` operator classes) is out of scope; +// every method accepts any options object until a later slice narrows it. +export const postgresIndexTypes = defineIndexTypes() + .add('btree', { options: type('object') }) + .add('hash', { options: type('object') }) + .add('gin', { options: type('object') }) + .add('gist', { options: type('object') }) + .add('spgist', { options: type('object') }) + .add('brin', { options: type('object') }); + +export type IndexTypes = typeof postgresIndexTypes.IndexTypes; diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts index a68d0cd9fc..535a5f85f8 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts @@ -162,6 +162,15 @@ type ForeignKeyResolution = { readonly extraRelationsByTable: ReadonlyMap; /** Synthetic field-name maps for cross-space-referenced pack tables, merged into `fieldNamesByTable`. */ readonly crossSpaceFieldNamesByTable: ReadonlyMap; + /** Dangling foreign keys dropped per host table, kept so the model can explain the drop. */ + readonly danglingForeignKeysByTable: ReadonlyMap; +}; + +/** A foreign key dropped because its target lives outside the introspected scope. */ +type DanglingForeignKeyInfo = { + readonly columns: readonly string[]; + readonly referencedSchema: string | undefined; + readonly referencedTable: string; }; /** @@ -198,6 +207,7 @@ function resolveForeignKeys( const resultTables: Record = {}; const extraRelationsByTable = new Map(); const crossSpaceFieldNamesByTable = new Map(); + const danglingForeignKeysByTable = new Map(); for (const [tableName, table] of Object.entries(tables)) { const keptForeignKeys: SqlForeignKeyIR[] = []; @@ -248,9 +258,22 @@ function resolveForeignKeys( // Not a pack-owned coordinate: keep the foreign key if the referenced // table survived introspection (local), otherwise drop it while keeping - // the scalar column (dangling). + // the scalar column (dangling) — recording it so the model can explain + // the drop instead of the relation vanishing without a trace. if (tables[fk.referencedTable] !== undefined) { keptForeignKeys.push(fk); + } else { + const dangling: DanglingForeignKeyInfo = { + columns: fk.columns, + referencedSchema: fk.referencedSchema, + referencedTable: fk.referencedTable, + }; + const existingDangling = danglingForeignKeysByTable.get(tableName); + if (existingDangling) { + existingDangling.push(dangling); + } else { + danglingForeignKeysByTable.set(tableName, [dangling]); + } } } @@ -260,7 +283,12 @@ function resolveForeignKeys( : new SqlTableIR({ ...table, foreignKeys: keptForeignKeys }); } - return { tables: resultTables, extraRelationsByTable, crossSpaceFieldNamesByTable }; + return { + tables: resultTables, + extraRelationsByTable, + crossSpaceFieldNamesByTable, + danglingForeignKeysByTable, + }; } /** @@ -405,6 +433,7 @@ export function inferPostgresPslContract( tables: resolvedTables, extraRelationsByTable, crossSpaceFieldNamesByTable, + danglingForeignKeysByTable, } = resolveForeignKeys(tables, owners); const schemaIR = new SqlSchemaIR({ tables: resolvedTables }); @@ -437,6 +466,7 @@ export function inferPostgresPslContract( { extraRelationsByTable, crossSpaceFieldNamesByTable, + danglingForeignKeysByTable, }, wrapNamespaceName, ); @@ -447,12 +477,13 @@ export function buildPslDocumentAst( options: PslPrinterOptions, foreignKeyExtras: Pick< ForeignKeyResolution, - 'extraRelationsByTable' | 'crossSpaceFieldNamesByTable' + 'extraRelationsByTable' | 'crossSpaceFieldNamesByTable' | 'danglingForeignKeysByTable' >, namespaceName?: string, ): PslDocumentAst { const { typeMap, defaultMapping, parseRawDefault: rawDefaultParser } = options; - const { extraRelationsByTable, crossSpaceFieldNamesByTable } = foreignKeyExtras; + const { extraRelationsByTable, crossSpaceFieldNamesByTable, danglingForeignKeysByTable } = + foreignKeyExtras; const modelNames = buildTopLevelNameMap( Object.keys(schemaIR.tables), @@ -508,6 +539,7 @@ export function buildPslDocumentAst( ...(relationsByTable.get(table.name) ?? []), ...(extraRelationsByTable.get(table.name) ?? []), ], + danglingForeignKeysByTable.get(table.name) ?? [], ), ); } @@ -642,6 +674,7 @@ function buildModel( defaultMapping: DefaultMappingOptions | undefined, rawDefaultParser: PslPrinterOptions['parseRawDefault'], relationFields: readonly RelationField[], + danglingForeignKeys: readonly DanglingForeignKeyInfo[], ): PslModel { const { name: modelName, map: mapName } = toModelName(table.name); const fieldNameMap = fieldNamesByTable.get(table.name); @@ -719,13 +752,23 @@ function buildModel( modelAttributes.push(buildMapAttribute('model', mapName)); } - // Surface introspection advisory: tables without a primary key cannot serve - // as the right-hand side of a `findUnique`-style query downstream, so the - // user should add an `@id`. This warning is part of the emitted SQL output - // and is asserted byte-for-byte, so keep the exact wording. - const comment = table.primaryKey - ? undefined - : '// WARNING: This table has no primary key in the database'; + // Surface introspection advisories the user would otherwise have no way to + // discover from the emitted PSL alone. Both warnings are part of the + // emitted SQL output and are asserted byte-for-byte, so keep the exact + // wording; a table hitting both is combined onto the single comment line + // `PslModel.comment` supports. + const warnings: string[] = []; + if (!table.primaryKey) { + // Tables without a primary key cannot serve as the right-hand side of a + // `findUnique`-style query downstream, so the user should add an `@id`. + warnings.push('This table has no primary key in the database'); + } + if (danglingForeignKeys.length > 0) { + warnings.push( + buildDanglingForeignKeyWarning(danglingForeignKeys, fieldNamesByTable, table.name), + ); + } + const comment = warnings.length > 0 ? `// WARNING: ${warnings.join(' ')}` : undefined; return { kind: 'model', @@ -737,6 +780,37 @@ function buildModel( }; } +/** + * Explains a foreign key `resolveForeignKeys` dropped as dangling: the + * database enforces it, but its target lives outside the introspected + * schema, so infer has no model to point a relation at. Without this, the + * relation just vanishes from the emitted PSL with no trace. Matches the + * missing-primary-key warning's voice — one line, stating the fact and the + * fix — since a schema outside the introspected scope is typically an + * extension pack (e.g. Supabase's `auth`) that isn't configured yet. + */ +function buildDanglingForeignKeyWarning( + danglingForeignKeys: readonly DanglingForeignKeyInfo[], + fieldNamesByTable: ReadonlyMap, + tableName: string, +): string { + const descriptions = danglingForeignKeys.map((fk) => { + const fieldNames = fk.columns.map((columnName) => + resolveColumnFieldName(fieldNamesByTable, tableName, columnName), + ); + const target = + fk.referencedSchema !== undefined + ? `${fk.referencedSchema}.${fk.referencedTable}` + : fk.referencedTable; + return `"${fieldNames.join(', ')}" -> "${target}"`; + }); + return ( + `Foreign key ${descriptions.join(', ')} exists in the database, but its target schema is ` + + 'outside the introspected scope, so no relation field was generated. If the target schema ' + + 'is described by an extension pack, add it to extensionPacks and re-run infer.' + ); +} + function buildScalarField( column: SqlColumnIR, table: SqlTableIR, diff --git a/packages/3-targets/3-targets/postgres/test/index-types.test.ts b/packages/3-targets/3-targets/postgres/test/index-types.test.ts new file mode 100644 index 0000000000..3a81661f12 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/index-types.test.ts @@ -0,0 +1,116 @@ +/** + * Postgres index-type registration (TML-3037, dispatch D5). + * + * `contract infer` prints `@@index(..., type: "gin"/"hash")` for a non-default + * access method, but the postgres target registered zero index types, so + * `validateIndexTypes` rejected every non-btree index at emit. These tests + * prove: (1) the registry itself carries the six Postgres built-in access + * methods with permissive options, and (2) a real PSL interpret → build pass + * accepts a `gin`/`hash` index end-to-end while still rejecting a bogus type + * — registering real methods must not disable the check. + */ +import { assembleAuthoringContributions } from '@prisma-next/framework-components/control'; +import { buildSymbolTable } from '@prisma-next/psl-parser'; +import { parse } from '@prisma-next/psl-parser/syntax'; +import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl'; +import { type } from 'arktype'; +import { describe, expect, it } from 'vitest'; +import { + postgresAuthoringEntityTypes, + postgresAuthoringPslBlockDescriptors, +} from '../src/core/authoring'; +import { postgresTargetDescriptorMeta } from '../src/core/descriptor-meta'; +import { postgresIndexTypes } from '../src/core/index-types'; +import { type PostgresSchema, postgresCreateNamespace } from '../src/core/postgres-schema'; + +const assembled = assembleAuthoringContributions([ + { + authoring: { + entityTypes: postgresAuthoringEntityTypes, + pslBlockDescriptors: postgresAuthoringPslBlockDescriptors, + }, + }, +]); + +const scalarTypeDescriptors = new Map([ + ['Int', { codecId: 'pg/int4@1', nativeType: 'int4' }], +]); + +function interpret(source: string) { + const { document, sourceFile } = parse(source); + const { table: symbolTable } = buildSymbolTable({ + document, + sourceFile, + scalarTypes: [...scalarTypeDescriptors.keys()], + pslBlockDescriptors: assembled.pslBlockDescriptors, + }); + return interpretPslDocumentToSqlContract({ + symbolTable, + sourceFile, + sourceId: 'schema.prisma', + capabilities: {}, + target: postgresTargetDescriptorMeta, + scalarTypeDescriptors, + authoringContributions: assembled, + composedExtensionContracts: new Map(), + createNamespace: postgresCreateNamespace, + }); +} + +function modelWithIndexType(indexType: string): string { + return ` +model Widgets { + id Int @id + code Int + @@index([code], type: "${indexType}") +} +`; +} + +describe('postgresIndexTypes', () => { + it('registers the six Postgres built-in access methods', () => { + expect(postgresIndexTypes.entries.map((e) => e.type)).toEqual([ + 'btree', + 'hash', + 'gin', + 'gist', + 'spgist', + 'brin', + ]); + }); + + it('accepts an arbitrary options object for every registered method (permissive; per-method validation is a later slice)', () => { + for (const entry of postgresIndexTypes.entries) { + const result = entry.options({ anything: 'goes' }); + expect(result instanceof type.errors).toBe(false); + } + }); +}); + +describe('postgresTargetDescriptorMeta', () => { + it('declares its index types via postgresIndexTypes', () => { + expect(postgresTargetDescriptorMeta.indexTypes).toBe(postgresIndexTypes); + }); +}); + +describe('contract build registers postgres index types end-to-end', () => { + it('accepts @@index(..., type: "gin")', () => { + const result = interpret(modelWithIndexType('gin')); + expect(result.ok).toBe(true); + if (!result.ok) return; + const ns = result.value.storage.namespaces['public'] as PostgresSchema; + expect(ns.table['widgets']?.indexes.map((idx) => idx.type)).toEqual(['gin']); + }); + + it('accepts @@index(..., type: "hash")', () => { + const result = interpret(modelWithIndexType('hash')); + expect(result.ok).toBe(true); + if (!result.ok) return; + const ns = result.value.storage.namespaces['public'] as PostgresSchema; + expect(ns.table['widgets']?.indexes.map((idx) => idx.type)).toEqual(['hash']); + }); + + it('still rejects a bogus, unregistered index type — registering real methods does not disable the check', () => { + expect(() => interpret(modelWithIndexType('bogus'))).toThrow(/unregistered index type "bogus"/); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts index 42fba2202e..7c2737f908 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract-described-contracts.test.ts @@ -300,6 +300,10 @@ describe('inferPostgresPslContract — described-contract omission', () => { expect(relationField?.typeName).toBe('AuthUser'); expect(relationField?.typeNamespaceId).toBe('auth'); expect(relationField?.typeContractSpaceId).toBe('supabase'); + // A resolved cross-space FK is not dangling — it must not carry the + // dangling-FK warning comment the "target neither in the tree nor owned" + // case gets. + expect(profileModel?.comment).toBeUndefined(); const printed = printPsl(ast); expect(printed).toContain('supabase:auth.AuthUser'); @@ -395,7 +399,7 @@ describe('inferPostgresPslContract — described-contract omission', () => { ).toThrow(/owns storage coordinate "auth\.users" but declares no domain model/); }); - it('drops a genuinely dangling FK (target neither in the tree nor owned by any described contract), keeping the scalar column', () => { + it('drops a genuinely dangling FK (target neither in the tree nor owned by any described contract), keeping the scalar column and explaining the drop with a comment', () => { const database = tree({ public: namespaceNode('public', { posts: new PostgresTableSchemaNode({ @@ -406,7 +410,12 @@ describe('inferPostgresPslContract — described-contract omission', () => { }, primaryKey: { columns: ['id'] }, foreignKeys: [ - { columns: ['ownerId'], referencedTable: 'owners', referencedColumns: ['id'] }, + { + columns: ['ownerId'], + referencedTable: 'owners', + referencedSchema: 'secure', + referencedColumns: ['id'], + }, ], uniques: [], indexes: [], @@ -426,6 +435,12 @@ describe('inferPostgresPslContract — described-contract omission', () => { expect(postsModel?.fields.some((f) => f.attributes.some((a) => a.name === 'relation'))).toBe( false, ); + expect(postsModel?.comment).toBe( + '// WARNING: Foreign key "ownerId" -> "secure.owners" exists in the database, but its ' + + 'target schema is outside the introspected scope, so no relation field was generated. ' + + 'If the target schema is described by an extension pack, add it to extensionPacks and ' + + 're-run infer.', + ); }); it('keeps a legitimate FK to a surviving same-named table when a different namespace omits that name', () => { @@ -461,6 +476,8 @@ describe('inferPostgresPslContract — described-contract omission', () => { expect(postsModel?.fields.some((f) => f.attributes.some((a) => a.name === 'relation'))).toBe( true, ); + // A local FK that resolved to a real relation is not dangling — no warning comment. + expect(postsModel?.comment).toBeUndefined(); }); it('omits a described-contract-claimed table before the cross-schema duplicate-name check', () => { diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts index fefde282cb..55e8e0b0ad 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.enums.test.ts @@ -42,6 +42,7 @@ function printWithEnums( const ast = buildPslDocumentAst(new SqlSchemaIR({ tables }), options, { extraRelationsByTable: new Map(), crossSpaceFieldNamesByTable: new Map(), + danglingForeignKeysByTable: new Map(), }); return printPsl(ast, { pslBlockDescriptors: postgresAuthoringPslBlockDescriptors }); } diff --git a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts index 23e708e729..7c0146c03a 100644 --- a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts +++ b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts @@ -86,19 +86,6 @@ function fixIndexTypes(psl: string): string { return psl.replace(/,\s*type: "(?:gin|hash)"/g, ''); } -/** - * Drops the two non-btree `@@index` attributes entirely, rather than just - * their `type:` argument. Stripping only the argument leaves a contract that - * declares those indexes as plain btree, which live verify then reports as a - * type mismatch against the real gin/hash indexes — an artifact of the - * reduction, not one of the eight defects. Dropping the attribute instead - * makes the index an undeclared "extra", which non-strict schema-only verify - * tolerates, isolating whatever defect a test is actually probing. - */ -function dropNonBtreeIndexes(psl: string): string { - return psl.replace(/^\s*@@index\(\[\w+\], map: "[^"]+", type: "(?:gin|hash)"\)\s*$\n/gm, ''); -} - withTempDir(({ createTempDir }) => { describe('Journey: Infer -> Emit Round-Trip Fidelity', () => { const db = useDevDatabase({ @@ -274,15 +261,14 @@ withTempDir(({ createTempDir }) => { const infer = await runContractInfer(ctx); expect(infer.exitCode, `IF.07: contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); - // Fix the three unrelated emit-blockers so emit succeeds and verify - // can run; the jsonb default on Users.metadata is left untouched. - // The two non-btree indexes are dropped (not just detyped) so they - // become undeclared "extras" that non-strict verify tolerates, - // rather than a declared-btree-vs-live-gin/hash type mismatch that - // would otherwise mask the jsonb-default assertion this test is for. - const reduced = dropNonBtreeIndexes( - fixListDefault(fixOneToOneBackRelation(readContractPsl(ctx))), - ); + // Fix the two unrelated emit-blockers (1:1 back-relation, list + // default) so emit succeeds and verify can run. The gin/hash indexes + // are left exactly as infer printed them — postgres now registers + // those access methods (TML-3037 D5), so they emit and round-trip + // clean against the live gin/hash indexes, proving that fix here + // too. Only the jsonb default on Users.metadata is left broken, which + // is what this test is for. + const reduced = fixListDefault(fixOneToOneBackRelation(readContractPsl(ctx))); writeContractPsl(ctx, reduced); const emit = await runContractEmit(ctx); From 82fcdfacb587c46083aef31f8984eebd0d7c9100 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 16:44:06 +0200 Subject: [PATCH 09/24] fix(sql-schema-ir): identity columns round-trip as autoincrement() on both sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GENERATED ALWAYS AS IDENTITY` and `GENERATED BY DEFAULT AS IDENTITY` columns report no `column_default` at all — Postgres tracks generation via `pg_attribute.attidentity`, not a default expression — so infer emitted a bare column with no default, and even once printed, the introspected side had nothing to compare a `@default(autoincrement())` contract against. Fixed symmetrically, on both sides: - `SqlColumnIR` gains an `identity` boolean (introspection-only; a contract-derived column never sets it, since PSL has no identity vocabulary). - The postgres columns query selects `a.attidentity` and threads it onto the column; the control adapter computes `resolvedDefault` from the identity flag when set, since there is no raw expression to parse. - `parsePostgresDefault` takes an `identity` option that resolves straight to `autoincrement()`, independent of `rawDefault`. - Postgres infer (`buildScalarField`) prints `@default(autoincrement())` for an identity column, the same as it already does for `serial`. Deliberate boundary: at the contract's altitude, `autoincrement()` means "the database generates this value" — identity and `serial` are both that, and PSL has no syntax to distinguish `GENERATED ALWAYS` from `GENERATED BY DEFAULT`. This maps both identity variants onto `autoincrement()` rather than modelling the distinction. Consequence: a fresh `db init` from such a contract creates a `serial` column, not an identity one — a pre-existing gap (identity has never been authorable), unchanged by this fix. Verified against a live database: a new introspection integration test proves both identity variants thread through as `identity: true` with `resolvedDefault: autoincrement()`, that `serial` is unaffected, and that a plain column gets no default. The TML-3037 D1 instrument's IF.06 assertion (both `GENERATED` variants print `@default(autoincrement())`; `serial` keeps working) now passes, and a manual reduction of the same journey (stripping only the two known non-identity default mismatches) shows `db verify --schema-only` reports zero drift for the identity columns. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/schema-ir/src/ir/sql-column-ir.ts | 15 +++ .../schema-ir/test/sql-column-ir.test.ts | 47 ++++++++ .../postgres/src/core/default-normalizer.ts | 18 +++ .../src/core/psl-infer/infer-psl-contract.ts | 10 +- .../src/exports/default-normalizer.ts | 1 + .../postgres/test/default-normalizer.test.ts | 21 ++++ .../test/psl-infer/infer-psl-contract.test.ts | 36 ++++++ .../postgres/src/core/control-adapter.ts | 26 +++-- ...y-column-introspection.integration.test.ts | 107 ++++++++++++++++++ 9 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 packages/3-targets/6-adapters/postgres/test/migrations/identity-column-introspection.integration.test.ts diff --git a/packages/2-sql/1-core/schema-ir/src/ir/sql-column-ir.ts b/packages/2-sql/1-core/schema-ir/src/ir/sql-column-ir.ts index d8d5407a24..c4ee662a25 100644 --- a/packages/2-sql/1-core/schema-ir/src/ir/sql-column-ir.ts +++ b/packages/2-sql/1-core/schema-ir/src/ir/sql-column-ir.ts @@ -73,6 +73,18 @@ export interface SqlColumnIRInput { * end)` rendering exactly. */ readonly codecNamedType?: boolean; + /** + * True when the column is a Postgres `GENERATED ... AS IDENTITY` column + * (either `ALWAYS` or `BY DEFAULT`) — introspection-only. A contract-derived + * column never sets this; the contract has no identity vocabulary, and + * `@default(autoincrement())` alone already conveys "the database generates + * this value" on that side. It exists so introspection can recognize an + * identity column as `autoincrement()` even though the live column reports + * no `column_default` at all — see {@link resolvedDefault}, which the + * postgres control adapter stamps from this flag rather than from a raw + * expression when it's set. + */ + readonly identity?: boolean; } /** @@ -109,6 +121,8 @@ export class SqlColumnIR extends SqlSchemaIRNode implements DiffableNode { declare readonly codecBaseNativeType?: string; /** See {@link SqlColumnIRInput.codecNamedType}. Non-enumerable, same reason as {@link codecRef}. */ declare readonly codecNamedType?: boolean; + /** See {@link SqlColumnIRInput.identity}. */ + declare readonly identity?: boolean; constructor(input: SqlColumnIRInput) { super(); @@ -120,6 +134,7 @@ export class SqlColumnIR extends SqlSchemaIRNode implements DiffableNode { if (input.many !== undefined) this.many = input.many; if (input.resolvedNativeType !== undefined) this.resolvedNativeType = input.resolvedNativeType; if (input.resolvedDefault !== undefined) this.resolvedDefault = input.resolvedDefault; + if (input.identity !== undefined) this.identity = input.identity; defineNonEnumerable(this, 'codecRef', input.codecRef); defineNonEnumerable(this, 'codecBaseNativeType', input.codecBaseNativeType); defineNonEnumerable(this, 'codecNamedType', input.codecNamedType); diff --git a/packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts b/packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts index ff39369ae2..d5f02f61b9 100644 --- a/packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts +++ b/packages/2-sql/1-core/schema-ir/test/sql-column-ir.test.ts @@ -167,4 +167,51 @@ describe('SqlColumnIR', () => { expect(column.resolvedDefault).toEqual({ kind: 'literal', value: 'x' }); }); }); + + describe('identity', () => { + it('is absent by default', () => { + const column = new SqlColumnIR({ name: 'id', nativeType: 'int4', nullable: false }); + expect(column.identity).toBeUndefined(); + }); + + it('carries true when supplied (introspected GENERATED ... AS IDENTITY column)', () => { + const column = new SqlColumnIR({ + name: 'id', + nativeType: 'int4', + nullable: false, + identity: true, + }); + expect(column.identity).toBe(true); + }); + + it('yields a default child node from resolvedDefault alone, with no raw default', () => { + // An identity column has no `column_default` at all — the introspected + // side sets `resolvedDefault` directly (see the postgres control + // adapter), so `children()` must still produce a default node without + // a `default` (raw) field. + const column = new SqlColumnIR({ + name: 'id', + nativeType: 'int4', + nullable: false, + identity: true, + resolvedDefault: { kind: 'function', expression: 'autoincrement()' }, + }); + expect(column.children()).toEqual([ + new SqlColumnDefaultIR({ + resolved: { kind: 'function', expression: 'autoincrement()' }, + }), + ]); + }); + + it('does not participate in isEqualTo (own-attribute comparison)', () => { + const identityColumn = new SqlColumnIR({ + name: 'id', + nativeType: 'int4', + nullable: false, + identity: true, + }); + const plainColumn = new SqlColumnIR({ name: 'id', nativeType: 'int4', nullable: false }); + expect(identityColumn.isEqualTo(plainColumn)).toBe(true); + }); + }); }); diff --git a/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts b/packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts index 54dbd1f419..68bc319154 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 @@ -160,6 +160,19 @@ function parseArrayLiteralBody(body: string): readonly JsonValue[] | undefined { return result; } +export interface ParsePostgresDefaultOptions { + /** + * True when the column is a `GENERATED ... AS IDENTITY` column (either + * variant). An identity column reports no `column_default` at all — Postgres + * tracks generation via `pg_attribute.attidentity`, not a default expression + * — so `rawDefault` carries nothing useful here. Set this to resolve + * straight to `autoincrement()`, the same value a contract's + * `@default(autoincrement())` resolves to, so an identity column compares + * equal against its contract counterpart instead of drifting forever. + */ + readonly identity?: boolean; +} + /** * Parses a raw Postgres column default expression into a normalized ColumnDefault. * This enables semantic comparison between contract defaults and introspected schema defaults. @@ -174,7 +187,12 @@ function parseArrayLiteralBody(body: string): readonly JsonValue[] | undefined { export function parsePostgresDefault( rawDefault: string, nativeType?: string, + options?: ParsePostgresDefaultOptions, ): ColumnDefault | undefined { + if (options?.identity) { + return { kind: 'function', expression: 'autoincrement()' }; + } + const trimmed = rawDefault.trim(); const normalizedType = nativeType?.toLowerCase(); const isBigInt = normalizedType === 'bigint' || normalizedType === 'int8'; diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts index 535a5f85f8..4af6d97779 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts @@ -873,7 +873,15 @@ function buildScalarField( attributes.push(buildSimpleConstraintFieldAttribute('id', singlePkConstraintName)); } - if (column.default !== undefined) { + if (column.identity) { + // A `GENERATED ... AS IDENTITY` column (either variant) reports no + // `column_default` at all, so it never reaches the raw-default path + // below. The contract's vocabulary already means "the database + // generates this value" for `autoincrement()` — the same thing both + // identity variants and `serial` mean — so print it directly rather + // than modelling the ALWAYS/BY-DEFAULT distinction. + attributes.push(parseDefaultAttributeString('@default(autoincrement())')); + } else if (column.default !== undefined) { const parsed = parseColumnDefault(column.default, column.nativeType, rawDefaultParser); if (parsed) { const result = mapDefault(parsed, defaultMapping); 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..1b427831e7 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,2 @@ +export type { ParsePostgresDefaultOptions } from '../core/default-normalizer'; export { parsePostgresDefault } from '../core/default-normalizer'; diff --git a/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts b/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts index 6ed0b9f30f..73ee20e281 100644 --- a/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts +++ b/packages/3-targets/3-targets/postgres/test/default-normalizer.test.ts @@ -117,6 +117,27 @@ describe('parsePostgresDefault sequences', () => { }); }); +describe('parsePostgresDefault identity columns', () => { + it('resolves to autoincrement() when identity is set, regardless of rawDefault', () => { + // An identity column reports no `column_default` at all — the control + // adapter calls this with an empty raw expression and `identity: true`. + expect(parsePostgresDefault('', 'int4', { identity: true })).toEqual({ + kind: 'function', + expression: 'autoincrement()', + }); + }); + + it('ignores identity: false and falls back to normal parsing', () => { + expect( + parsePostgresDefault("nextval('foo_id_seq'::regclass)", 'int4', { identity: false }), + ).toEqual({ kind: 'function', expression: 'autoincrement()' }); + }); + + it('a non-identity call is unaffected by the options parameter existing', () => { + expect(parsePostgresDefault("'hello'", 'text')).toEqual({ kind: 'literal', value: 'hello' }); + }); +}); + describe('parsePostgresDefault timestamps', () => { it('normalizes now()', () => { expect(parsePostgresDefault('now()')).toEqual({ kind: 'function', expression: 'now()' }); diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts index f56e28aaee..af6d1e60a1 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-psl-contract.test.ts @@ -142,6 +142,42 @@ describe('inferPostgresPslContract', () => { expect(arg && arg.kind === 'positional' ? arg.value : '').toContain('now()'); }); + it('produces a @default(autoincrement()) attribute for an identity column (GENERATED ... AS IDENTITY)', () => { + // Both `GENERATED ALWAYS AS IDENTITY` and `GENERATED BY DEFAULT AS + // IDENTITY` report no `column_default` at all — introspection threads + // them onto `SqlColumnIR.identity` (a plain boolean; PSL has no syntax + // to distinguish the two variants), and infer must print the same + // `@default(autoincrement())` either way. + const schemaIR = ir({ + tables: { + session: { + name: 'session', + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false, identity: true }, + note: { name: 'note', nativeType: 'text', nullable: true }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }); + + const ast = sqlSchemaIrToPslAst(schemaIR); + const model = flatPslModels(ast)[0]; + const idField = model?.fields.find((f) => f.name === 'id'); + const defaultAttr = idField?.attributes.find((a) => a.name === 'default'); + expect(defaultAttr).toBeDefined(); + const arg = defaultAttr?.args[0]; + expect(arg && arg.kind === 'positional' ? arg.value : '').toBe('autoincrement()'); + + // A plain column with neither a raw default nor identity gets no + // @default attribute at all. + const noteField = model?.fields.find((f) => f.name === 'note'); + expect(noteField?.attributes.some((a) => a.name === 'default')).toBe(false); + }); + it('attaches a "no primary key" warning comment for tables without a primary key', () => { const schemaIR = ir({ tables: { diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 7a107b898a..7a1af4944a 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 @@ -712,6 +712,7 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { numeric_scale: number | null; column_default: string | null; formatted_type: string | null; + attidentity: string; }>( `SELECT c.table_name, @@ -723,7 +724,8 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { numeric_precision, numeric_scale, column_default, - format_type(a.atttypid, a.atttypmod) AS formatted_type + format_type(a.atttypid, a.atttypmod) AS formatted_type, + a.attidentity FROM information_schema.columns c JOIN pg_catalog.pg_class cl ON cl.relname = c.table_name @@ -988,19 +990,29 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { // normalizes them itself. const resolvedNativeType = `${normalizeSchemaNativeType(nativeType)}${many ? '[]' : ''}`; const rawDefault = colRow.column_default ?? undefined; + // `GENERATED ALWAYS AS IDENTITY` ('a') and `GENERATED BY DEFAULT AS + // IDENTITY` ('d') both report a NULL column_default — Postgres tracks + // generation via attidentity, not a default expression — so neither + // variant is visible in `rawDefault` at all. The contract has no + // syntax to distinguish the two, so both resolve to the same + // `autoincrement()` a `serial` column's `nextval(...)` default + // already maps to. + const identity = + colRow.attidentity === 'a' || colRow.attidentity === 'd' ? true : undefined; + const resolvedDefault = identity + ? parsePostgresDefault(rawDefault ?? '', resolvedNativeType, { identity: true }) + : rawDefault !== undefined + ? parsePostgresDefault(rawDefault, resolvedNativeType) + : undefined; columns[colRow.column_name] = { name: colRow.column_name, nativeType, nullable: colRow.is_nullable === 'YES', ...ifDefined('default', rawDefault), ...ifDefined('many', many), + ...ifDefined('identity', identity), resolvedNativeType, - ...ifDefined( - 'resolvedDefault', - rawDefault !== undefined - ? parsePostgresDefault(rawDefault, resolvedNativeType) - : undefined, - ), + ...ifDefined('resolvedDefault', resolvedDefault), }; } diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/identity-column-introspection.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/identity-column-introspection.integration.test.ts new file mode 100644 index 0000000000..5b5405f470 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/identity-column-introspection.integration.test.ts @@ -0,0 +1,107 @@ +/** + * Integration test: introspection of `GENERATED ... AS IDENTITY` columns. + * + * An identity column reports no `column_default` at all — Postgres tracks + * generation via `pg_attribute.attidentity`, not a default expression — so + * the columns query must select `attidentity` and thread it onto + * `SqlColumnIR.identity`, and `resolvedDefault` must resolve to the same + * `autoincrement()` a contract's `@default(autoincrement())` resolves to. + * Without this, `db verify` would see the contract-derived side declare a + * default the introspected side never reports, and flag every identity + * column drifted forever (TML-3037 D6). + */ +import { PostgresDatabaseSchemaNode } from '@prisma-next/target-postgres/types'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + createDriver, + createTestDatabase, + familyInstance, + type PostgresControlDriver, + resetDatabase, + testTimeout, +} from './fixtures/runner-fixtures'; + +describe.sequential('identity column introspection', () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, testTimeout); + + afterAll(async () => { + if (database) await database.close(); + }, testTimeout); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + }, testTimeout); + + afterEach(async () => { + if (driver) { + await driver.close(); + driver = undefined; + } + }, testTimeout); + + it('GENERATED ALWAYS AS IDENTITY -> identity:true, resolvedDefault:autoincrement()', { + timeout: testTimeout, + }, async () => { + await driver!.query( + 'CREATE TABLE identity_test (id int4 GENERATED ALWAYS AS IDENTITY PRIMARY KEY)', + ); + + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['identity_test']?.columns['id']; + expect(col?.default).toBeUndefined(); + expect(col).toMatchObject({ + identity: true, + resolvedDefault: { kind: 'function', expression: 'autoincrement()' }, + }); + }); + + it('GENERATED BY DEFAULT AS IDENTITY -> identity:true, resolvedDefault:autoincrement()', { + timeout: testTimeout, + }, async () => { + await driver!.query( + 'CREATE TABLE identity_test (id int4 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY)', + ); + + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['identity_test']?.columns['id']; + expect(col?.default).toBeUndefined(); + expect(col).toMatchObject({ + identity: true, + resolvedDefault: { kind: 'function', expression: 'autoincrement()' }, + }); + }); + + it('serial column (control) -> no identity flag, resolvedDefault:autoincrement() via nextval()', { + timeout: testTimeout, + }, async () => { + await driver!.query('CREATE TABLE identity_test (id serial PRIMARY KEY)'); + + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['identity_test']?.columns['id']; + expect(col?.identity).toBeUndefined(); + expect(col?.default).toMatch(/^nextval\(/); + expect(col?.resolvedDefault).toEqual({ kind: 'function', expression: 'autoincrement()' }); + }); + + it('plain int column (control) -> no identity flag, no default', { + timeout: testTimeout, + }, async () => { + await driver!.query('CREATE TABLE identity_test (id int4 PRIMARY KEY, note int4)'); + + const result = await familyInstance.introspect({ driver: driver! }); + PostgresDatabaseSchemaNode.assert(result); + const col = result.namespaces['public']!.tables['identity_test']?.columns['note']; + expect(col?.identity).toBeUndefined(); + expect(col?.default).toBeUndefined(); + expect(col?.resolvedDefault).toBeUndefined(); + }); +}); From 4ff2c863770cb3791f4efd8960a28d7ac088015c Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 16:45:09 +0200 Subject: [PATCH 10/24] docs(infer-roundtrip): defect 8 is a class, not a jsonb bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D6 exposed a second instance: dbgenerated("'{}'::text[]") drifts identically to the jsonb case. D1 could not reproduce it because emit failed before verify ever ran; clearing the emit blockers uncovered it. The defect is any literal default the authoring and introspection paths resolve differently — kind:function on one side, kind:literal on the other, and resolvedDefaultsEqual compares kind first. jsonb and text[] are two instances; enumerate the rest from parsePostgresDefault rather than from a list. The chosen fix already covers the class, because reusing parsePostgresDefault (rather than copying it) means a form it learns to parse later is consistent on both sides automatically. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- projects/infer-emit-roundtrip/spec.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/projects/infer-emit-roundtrip/spec.md b/projects/infer-emit-roundtrip/spec.md index e6fb0c50ae..f1daa70ffc 100644 --- a/projects/infer-emit-roundtrip/spec.md +++ b/projects/infer-emit-roundtrip/spec.md @@ -206,20 +206,31 @@ fields, and permits a storage-level function default. Nothing downstream objects `buildColumnDefaultSql` ignores `column.many` entirely and emits `DEFAULT (…)` already. The error message stays for the case it was actually written for. -### 8. jsonb defaults report drift forever +### 8. A `dbgenerated` literal default reports drift forever + +> **Widened after D6 exposed a second instance.** This was originally written as a jsonb defect. +> It is not: `dbgenerated("'{}'::text[]")` drifts identically. The defect is a **class** — any +> literal default the two sides resolve differently — and it gets fixed as a class, not +> instance-by-instance. Emit keeps `@default(dbgenerated("'{}'::jsonb"))` as `kind: 'function'`; introspection parses the same literal to `kind: 'literal'`. `resolvedDefaultsEqual` compares `kind` first and returns false before reading content — so `db verify` flags every such column `not-equal` permanently, even when the database matches exactly. -**Fix:** the two paths agree on one resolved shape for a JSON literal default. The authoring path -is the one that's lossy — it keeps the user's raw text without recognizing that `'{}'::jsonb` *is* -a literal — so the authoring side normalizes to match introspection, using the same -`parsePostgresDefault` logic rather than a second copy of it. +**Known instances:** `'{}'::jsonb` (D1 reproduced it) and `'{}'::text[]` (D6 reproduced it; D1 +could not, because emit failed before verify ever ran). Assume there are others — +`parsePostgresDefault` recognizes a range of literal forms, and every form it parses is a +candidate. Enumerate them from that function rather than from this list. + +**Fix:** the two paths agree on one resolved shape for a literal default. The authoring path is +the lossy one — it keeps the user's raw text without recognizing that `'{}'::jsonb` *is* a literal +— so the authoring side normalizes to match introspection by **reusing `parsePostgresDefault`**, +not by copying it. Reuse is what makes this a class fix: a form the function learns to parse later +is then automatically consistent on both sides. -Guard against the obvious over-reach: `dbgenerated("gen_random_uuid()")` is genuinely a function -and must stay one. +Guard against the obvious over-reach: `dbgenerated("gen_random_uuid()")` and +`dbgenerated("(now() + '00:03:00'::interval)")` are genuinely functions and must stay functions. ### Also in scope: dangling FKs drop silently From 5f9b5d1fef91bc251dead495b7c2529b49af3701 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 17:14:16 +0200 Subject: [PATCH 11/24] fix(target-postgres): authoring-side dbgenerated resolves literal defaults, not just function text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit kept every `@default(dbgenerated("..."))` as `kind: 'function'`, even when the raw SQL text was actually a literal (`'{}'::jsonb`, `'{}'::text[]`, NULL, true/false, a bare number, a plain string). Introspection parses the same text through `parsePostgresDefault` and gets `kind: 'literal'` for all of those forms. `resolvedDefaultsEqual` compares `kind` before content, so any dbgenerated literal drifted forever in `db verify`, even when the database matched the contract exactly. jsonb and text[] were the two instances TML-3037 caught, but the mismatch is generic to every literal form the normalizer recognizes. Fix the class: `lowerDbgenerated` (adapter-postgres) now resolves the raw text through the same `parsePostgresDefault` introspection already uses, instead of keeping it as opaque text. A form the normalizer does not recognize as a literal falls through to its own function fallback, so genuine functions (`gen_random_uuid()`, `nextval(...)`, arbitrary expressions) are unaffected. `parsePostgresDefault` needs the column's native type to parse array and jsonb literals correctly, so `DefaultFunctionLoweringContext` gains an optional `nativeType` field, populated by the SQL-family authoring layer (`lowerDefaultForField`) with the `[]`-suffixed form for list columns — matching the format introspection's `resolvedNativeType` already uses. Covers every literal form `parsePostgresDefault` handles (array, null, boolean, numeric, string, jsonb-parsed-object, out-of-range bigint) and proves three genuine functions stay functions, including a dbgenerated nextval(...) and an enum-cast default whose qualified type name defeats the string-literal pattern. No fixture moved: the only existing dbgenerated usages in the repo (gen_random_uuid(), enum casts, an interval expression) all already resolve to `kind: 'function'` under the new path, unchanged. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/shared/mutation-default-types.ts | 8 + .../contract-psl/src/psl-column-resolution.ts | 3 + .../src/core/control-mutation-defaults.ts | 13 +- .../test/control-mutation-defaults.test.ts | 150 ++++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) 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 cc05e9104b..0a77760566 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 @@ -28,6 +28,14 @@ export interface DefaultFunctionLoweringContext { readonly modelName: string; readonly fieldName: string; readonly columnCodecId?: string; + /** + * The field's resolved native type, with a trailing `[]` when the field + * is a list column. Matches the `resolvedNativeType` format the target's + * introspection normalizer receives, so a `lower` implementation can + * reuse that same normalizer to resolve a raw default expression instead + * of keeping it as opaque text. + */ + readonly nativeType?: string; } export type LoweredDefaultValue = 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 5bed64de27..7e31c9e77d 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 @@ -924,6 +924,9 @@ export function lowerDefaultForField(input: { modelName: input.modelName, fieldName: input.fieldName, columnCodecId: input.columnDescriptor.codecId, + nativeType: input.isList + ? `${input.columnDescriptor.nativeType}[]` + : input.columnDescriptor.nativeType, }, }); diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts index 47bcb62986..be4b61faca 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 @@ -13,6 +13,7 @@ import { } from '@prisma-next/ids'; import type { FuncCallSig } from '@prisma-next/psl-parser'; import { int, num, oneOf, optional, str } from '@prisma-next/psl-parser'; +import { parsePostgresDefault } from '@prisma-next/target-postgres/default-normalizer'; function invalidArgumentDiagnostic(input: { readonly context: DefaultFunctionLoweringContext; @@ -106,11 +107,21 @@ function lowerDbgenerated(input: { message: 'Default function "dbgenerated" argument cannot be empty.', }); } + // Resolve the raw SQL text through the same normalizer introspection uses, + // so a `dbgenerated(...)` default that is actually a literal (e.g. + // `'{}'::jsonb`) is recognized as one here too. Without this, the two + // sides disagree on `kind` and `resolvedDefaultsEqual` reports drift + // forever even when the database matches the contract exactly. Anything + // the normalizer doesn't recognize as a literal falls through to its own + // `kind: 'function'` fallback, which preserves the raw expression text. return { ok: true, value: { kind: 'storage', - defaultValue: { kind: 'function', expression }, + defaultValue: parsePostgresDefault(expression, input.context.nativeType) ?? { + kind: 'function', + expression, + }, }, }; } diff --git a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts index eaf186b8a5..b5bddaf538 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 @@ -1,3 +1,4 @@ +import { ifDefined } from '@prisma-next/utils/defined'; import { describe, expect, it } from 'vitest'; import { createPostgresDefaultFunctionRegistry, @@ -146,6 +147,155 @@ describe('createPostgresDefaultFunctionRegistry', () => { expect(result).toMatchObject({ ok: false }); }); + describe('dbgenerated resolves literal SQL text to a literal default', () => { + const handler = createPostgresDefaultFunctionRegistry().get('dbgenerated')!; + + function lower(expression: string, nativeType?: string) { + return handler.lower({ + call: makeCall('dbgenerated', { expression }), + context: { ...stubContext, ...ifDefined('nativeType', nativeType) }, + }); + } + + it('resolves an empty jsonb literal to a literal object', () => { + const result = lower("'{}'::jsonb", 'jsonb'); + expect(result).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: {} } }, + }); + }); + + it('resolves a populated jsonb literal to a parsed literal object', () => { + const result = lower(`'{"a":1}'::jsonb`, 'jsonb'); + expect(result).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: { a: 1 } } }, + }); + }); + + it('resolves an empty text[] literal to a literal empty array', () => { + const result = lower("'{}'::text[]", 'text[]'); + expect(result).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: [] } }, + }); + }); + + it('resolves a populated integer[] literal to a literal array of numbers', () => { + const result = lower("'{1,2,3}'::integer[]", 'int4[]'); + expect(result).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: [1, 2, 3] } }, + }); + }); + + it('resolves NULL to a literal null', () => { + const result = lower('NULL'); + expect(result).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: null } }, + }); + }); + + it('resolves true/false to literal booleans', () => { + expect(lower('true')).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: true } }, + }); + expect(lower('false')).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: false } }, + }); + }); + + it('resolves a bare numeric literal to a literal number', () => { + expect(lower('42')).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: 42 } }, + }); + expect(lower('3.14')).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: 3.14 } }, + }); + }); + + it('resolves a plain quoted string to a literal string', () => { + const result = lower("'hello'", 'text'); + expect(result).toMatchObject({ + ok: true, + value: { kind: 'storage', defaultValue: { kind: 'literal', value: 'hello' } }, + }); + }); + + it('keeps an out-of-safe-range bigint literal as its raw text, not a lossy number', () => { + const result = lower("'99999999999999999'::int8", 'int8'); + expect(result).toMatchObject({ + ok: true, + value: { + kind: 'storage', + defaultValue: { kind: 'literal', value: '99999999999999999' }, + }, + }); + }); + }); + + describe('dbgenerated keeps genuine functions as functions (F13: guard against over-reach)', () => { + const handler = createPostgresDefaultFunctionRegistry().get('dbgenerated')!; + + function lower(expression: string, nativeType?: string) { + return handler.lower({ + call: makeCall('dbgenerated', { expression }), + context: { ...stubContext, ...ifDefined('nativeType', nativeType) }, + }); + } + + it('keeps gen_random_uuid() a function', () => { + const result = lower('gen_random_uuid()'); + expect(result).toMatchObject({ + ok: true, + value: { + kind: 'storage', + defaultValue: { kind: 'function', expression: 'gen_random_uuid()' }, + }, + }); + }); + + it('keeps a now()-plus-interval expression a function, unchanged', () => { + const expression = "(now() + '00:03:00'::interval)"; + const result = lower(expression, 'timestamptz'); + expect(result).toMatchObject({ + ok: true, + value: { + kind: 'storage', + defaultValue: { kind: 'function', expression }, + }, + }); + }); + + it('keeps nextval(...) a function (normalized to autoincrement(), not demoted to a literal)', () => { + const result = lower("nextval('seq'::regclass)", 'int4'); + expect(result).toMatchObject({ + ok: true, + value: { + kind: 'storage', + defaultValue: { kind: 'function', expression: 'autoincrement()' }, + }, + }); + }); + + it('keeps an enum-cast literal a function (unqualified cast type defeats the string-literal pattern)', () => { + const expression = "'confidential'::auth.oauth_client_type"; + const result = lower(expression, 'oauth_client_type'); + expect(result).toMatchObject({ + ok: true, + value: { + kind: 'storage', + defaultValue: { kind: 'function', expression }, + }, + }); + }); + }); + it('lowers uuid(4) explicitly to uuidv4 execution generator', () => { const handler = registry.get('uuid')!; const result = handler.lower({ From ca3a0c8b78a1356e4f6ccfde89c28239f3c013a4 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 17:31:19 +0200 Subject: [PATCH 12/24] fix(extension-supabase): delete the fixed-defect workarounds, regenerate contract D2-D7 fixed the eight infer/emit round-trip defects TML-3037 set out to fix. This dispatch is the proof: delete the workarounds from generate-contract.ts and confirm the regenerated contract carries the fix instead of the patch. DOUBLE_PLURALIZED_FIELD_NAMES + applyDoublePluralizationFix: deleted. pluralize() (D2) is now correct for already-plural table names, so the override and the fix cancel out exactly - no back-relation name changed in the regenerated contract.prisma. INDEX_OMISSIONS + applyIndexOmissions + indexOmissionNameOf: deleted. The postgres target now registers hash as a built-in index type (D5), so auth.one_time_tokens' two USING hash indexes come back declared as @@index(..., type: "hash") instead of silently dropped. DEFAULT_OMISSIONS: reduced from 7 entries to 1. The jsonb/array dbgenerated(...) literal-default disagreement (D7) is fixed generically - authoring and introspection now resolve the same literal to the same `kind: 'literal'` shape via parsePostgresDefault - so the six custom_oauth_providers/webauthn_credentials/iceberg_namespaces entries are gone and those columns' defaults are declared again. Only auth.users.phone remains: `DEFAULT NULL` on a nullable column round-trips to an explicit `@default(null)`, which the interpreter rejects (PSL_INVALID_DEFAULT_VALUE) - a different, still-open defect this slice did not fix. COLUMN_OMISSIONS is untouched: nullable text[] columns still have no PSL form. CONTRACT-FIDELITY.md's audit summary is updated to match. Regenerated packages/3-extensions/supabase/src/contract/contract.prisma via `contract:generate`. The diff is exactly the two classes above (jsonb/array defaults restored, two hash indexes declared) plus the resulting contract.json/contract.d.ts changes - nothing else moved. reference-fixture-verify.integration.test.ts passes against the regenerated, workaround-free contract. Refs: TML-3037 Closes: TML-3024 Signed-off-by: willbot Signed-off-by: Will Madden --- .../supabase/scripts/generate-contract.ts | 170 +----------------- .../src/contract/CONTRACT-FIDELITY.md | 6 +- .../supabase/src/contract/contract.d.ts | 39 +++- .../supabase/src/contract/contract.json | 43 ++++- .../supabase/src/contract/contract.prisma | 14 +- 5 files changed, 98 insertions(+), 174 deletions(-) diff --git a/packages/3-extensions/supabase/scripts/generate-contract.ts b/packages/3-extensions/supabase/scripts/generate-contract.ts index 767bc13b8a..c5802085df 100644 --- a/packages/3-extensions/supabase/scripts/generate-contract.ts +++ b/packages/3-extensions/supabase/scripts/generate-contract.ts @@ -79,67 +79,24 @@ const COLUMN_OMISSIONS: Readonly>>> = { auth: { users: ['phone'], - custom_oauth_providers: [ - 'acceptable_client_ids', - 'scopes', - 'attribute_mapping', - 'authorization_params', - ], - webauthn_credentials: ['transports'], - }, - storage: { - iceberg_namespaces: ['metadata'], }, }; -// --- Index omissions (declarative, index-name keyed) --------------------- -// -// Fidelity notes: -// - auth.one_time_tokens_relates_to_hash_idx / _token_hash_hash_idx: both -// `USING hash` indexes. The inferrer now carries a non-default index -// access method through as `@@index(..., type: "hash")`, but `hash` is -// not a registered index type on this pack's stack (`IndexTypeRegistry` -// — see `packages/2-sql/1-core/contract/src/index-types.ts` — is an -// opt-in extensibility point a target or extension pack populates, e.g. -// `paradedbIndexTypes` for `bm25`; the postgres target itself registers -// none). `contract emit` rejects an unregistered type, so these two -// indexes are omitted rather than declared. Under `external` control an -// undeclared live index is a suppressed extra, so omission is -// verify-safe. Registering `hash` as a built-in postgres index type -// would let both come back declared; out of scope here. -// This mechanism also serves as the escape hatch for whatever the *next* -// unrepresentable index turns out to be, following the same declarative -// pattern as the column/default omissions above. -const INDEX_OMISSIONS: Readonly> = { - auth: ['one_time_tokens_relates_to_hash_idx', 'one_time_tokens_token_hash_hash_idx'], -}; - /** * PSL attribute argument values arrive as raw source text; a string-typed * argument (e.g. `@@map("users")`) is a JSON string literal, so JSON.parse @@ -157,113 +114,6 @@ function parseJsonStringLiteral(raw: string): string { return value; } -function indexOmissionNameOf(attribute: PslModel['attributes'][number]): string | undefined { - const mapArg = attribute.args.find((arg) => arg.kind === 'named' && arg.name === 'map'); - if (!mapArg) return undefined; - return parseJsonStringLiteral(mapArg.value); -} - -/** Drops `@@index`/`@@unique` model attributes named in `omittedNames`. */ -function applyIndexOmissions( - namespace: PslNamespace, - omittedNames: readonly string[], -): PslNamespace { - if (omittedNames.length === 0) return namespace; - let changed = false; - const models = namespace.models.map((model) => { - const attributes = model.attributes.filter((attribute) => { - if ( - attribute.target !== 'model' || - (attribute.name !== 'index' && attribute.name !== 'unique') - ) { - return true; - } - const name = indexOmissionNameOf(attribute); - const omit = name !== undefined && omittedNames.includes(name); - if (omit) changed = true; - return !omit; - }); - return attributes.length === model.attributes.length ? model : { ...model, attributes }; - }); - - if (!changed) return namespace; - - return makePslNamespace({ - kind: 'namespace', - name: namespace.name, - entries: makePslNamespaceEntries( - models, - namespace.compositeTypes, - namespacePslExtensionBlocks(namespace), - ), - span: namespace.span, - }); -} - -// --- Back-relation field-name corrections (declarative, field-name keyed) -- -// -// Fidelity note: -// - The framework inferrer's `pluralize()` -// (packages/2-sql/9-family/src/core/psl-contract-infer/name-transforms.ts) -// unconditionally appends `es` to a name that already ends in `s`, `x`, -// `z`, `ch`, or `sh`. Supabase's `auth`/`storage` table names are already -// plural (e.g. `sessions`, `identities`), so the back-relation field -// derived from them comes out double-pluralized (`sessionses`, -// `identitieses`). A general inflection fix is deferred to a follow-up -// ticket; this table corrects the known-affected names here so the pack -// does not ship double-plural public relation names. -const DOUBLE_PLURALIZED_FIELD_NAMES: ReadonlySet = new Set([ - 'icebergNamespaceses', - 'icebergTableses', - 'identitieses', - 'mfaAmrClaimses', - 'mfaChallengeses', - 'mfaFactorses', - 'oauthAuthorizationses', - 'oauthConsentses', - 'objectses', - 'oneTimeTokenses', - 'refreshTokenses', - 's3MultipartUploadsPartses', - 's3MultipartUploadses', - 'samlProviderses', - 'samlRelayStateses', - 'sessionses', - 'ssoDomainses', - 'vectorIndexeses', - 'webauthnChallengeses', - 'webauthnCredentialses', -]); - -/** Strips the erroneous trailing `es` from double-pluralized back-relation field names. */ -function applyDoublePluralizationFix(namespace: PslNamespace): PslNamespace { - let changed = false; - const models = namespace.models.map((model) => { - let modelChanged = false; - const fields = model.fields.map((field) => { - if (!DOUBLE_PLURALIZED_FIELD_NAMES.has(field.name)) return field; - modelChanged = true; - return { ...field, name: field.name.slice(0, -2) }; - }); - if (!modelChanged) return model; - changed = true; - return { ...model, fields }; - }); - - if (!changed) return namespace; - - return makePslNamespace({ - kind: 'namespace', - name: namespace.name, - entries: makePslNamespaceEntries( - models, - namespace.compositeTypes, - namespacePslExtensionBlocks(namespace), - ), - span: namespace.span, - }); -} - // --- Model renames (legacy names referenced by examples + cross-space FKs) - const MODEL_RENAMES: Readonly>>> = { @@ -623,10 +473,8 @@ async function introspectSchema( ); const defaultsFixed = applyDefaultOmissions(namespace, DEFAULT_OMISSIONS[schemaName] ?? {}); - const indexesFixed = applyIndexOmissions(defaultsFixed, INDEX_OMISSIONS[schemaName] ?? []); - const rlsFixed = applyRlsEnablement(indexesFixed, rlsEnabledTables); - const pluralizationFixed = applyDoublePluralizationFix(rlsFixed); - return { namespace: pluralizationFixed, types: ast.types?.declarations ?? [] }; + const rlsFixed = applyRlsEnablement(defaultsFixed, rlsEnabledTables); + return { namespace: rlsFixed, types: ast.types?.declarations ?? [] }; } async function main(): Promise { diff --git a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md index 17237d91f4..49f963eca7 100644 --- a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md +++ b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md @@ -10,7 +10,7 @@ Everything the pack declares is `control: 'external'`. Under `external`, `db ver ## What the contract deliberately does not declare -Machine-readable versions of these lists live in `scripts/generate-contract.ts` (`COLUMN_OMISSIONS` / `DEFAULT_OMISSIONS` / `INDEX_OMISSIONS`), each with the full reasoning; this is the audit summary. +Machine-readable versions of these lists live in `scripts/generate-contract.ts` (`COLUMN_OMISSIONS` / `DEFAULT_OMISSIONS`), each with the full reasoning; this is the audit summary. **Columns (2):** @@ -19,12 +19,12 @@ Machine-readable versions of these lists live in `scripts/generate-contract.ts` | `storage.buckets.allowed_mime_types` | `text[]` nullable | PSL has no nullable-list syntax (`String[]?` is invalid) | | `storage.objects.path_tokens` | `text[]` nullable | Same; also `GENERATED ALWAYS`, so not user-writable regardless | -**Column defaults (7):** `auth.users.phone` (`DEFAULT NULL` no-op), `auth.custom_oauth_providers.acceptable_client_ids`/`scopes` (list defaults have no PSL execution-default form), and `auth.custom_oauth_providers.attribute_mapping`/`authorization_params`, `auth.webauthn_credentials.transports`, `storage.iceberg_namespaces.metadata` (JSON-literal defaults resolve to different shapes on the authored vs introspected side). Column types are declared in full; only the `@default` is dropped. +**Column defaults (1):** `auth.users.phone` (`DEFAULT NULL` is a no-op, but round-trips through the raw-default parser as an explicit `@default(null)`, which the interpreter rejects). Column type is declared in full; only the `@default` is dropped. The jsonb/array `dbgenerated(...)` literal-default disagreement that used to widen this list (TML-3037) is fixed generically — authoring and introspection now resolve such a literal to the same shape. **Indexes:** - The reference's 8 partial unique indexes (`WHERE`-predicated, on `auth.users` token columns, `auth.mfa_factors`, `storage.buckets_analytics`) are not declared — the inferrer never promotes an index-level unique into `@@unique`, and a predicate-less declaration would misdeclare. -- `auth.one_time_tokens`' two `USING hash` indexes are not declared — `hash` is not a registered index type on this stack (`IndexTypeRegistry` is pack-populated; the postgres target registers none). +- `auth.one_time_tokens`' two `USING hash` indexes are declared (`@@index(..., type: "hash")`) — the postgres target registers `hash` as a built-in index type (TML-3037). - 16 foreign keys whose source columns have **no live backing index** are declared with `@relation(..., index: false)` — real Supabase does not index those FK columns, and the default FK-derived index expectation would otherwise fail verify. (This PSL argument and the inferrer support for it shipped with this contract.) **Generated columns** (`auth.users.confirmed_at`, `auth.identities.email`): declared as ordinary columns. Introspection reports them identically on the authored and live sides, so verify is clean; the contract does not record the generation expression. diff --git a/packages/3-extensions/supabase/src/contract/contract.d.ts b/packages/3-extensions/supabase/src/contract/contract.d.ts index d59dd5cb0e..13b339ebb4 100644 --- a/packages/3-extensions/supabase/src/contract/contract.d.ts +++ b/packages/3-extensions/supabase/src/contract/contract.d.ts @@ -30,7 +30,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:4fd3b6b5481531b6fc23e6f3a97061908a8efaab460a56e3c334ef2483c9dfdb'>; + StorageHashBase<'sha256:bf835b04145e86aa7b2128d798656c05beb1f54a3046276e7bc123c59c9cf9fe'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; @@ -1750,11 +1750,19 @@ type ContractBase = Omit< readonly nativeType: 'text'; readonly codecId: 'pg/text@1'; readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/text@1', readonly []>; + }; }; readonly scopes: { readonly nativeType: 'text'; readonly codecId: 'pg/text@1'; readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/text@1', readonly []>; + }; }; readonly pkce_enabled: { readonly nativeType: 'bool'; @@ -1769,11 +1777,19 @@ type ContractBase = Omit< readonly nativeType: 'jsonb'; readonly codecId: 'pg/jsonb@1'; readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/jsonb@1', {}>; + }; }; readonly authorization_params: { readonly nativeType: 'jsonb'; readonly codecId: 'pg/jsonb@1'; readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/jsonb@1', {}>; + }; }; readonly enabled: { readonly nativeType: 'bool'; @@ -2764,7 +2780,18 @@ type ContractBase = Omit< readonly name: 'one_time_tokens_pkey'; }; uniques: readonly []; - indexes: readonly []; + indexes: readonly [ + { + readonly columns: readonly ['relates_to']; + readonly name: 'one_time_tokens_relates_to_hash_idx'; + readonly type: 'hash'; + }, + { + readonly columns: readonly ['token_hash']; + readonly name: 'one_time_tokens_token_hash_hash_idx'; + readonly type: 'hash'; + }, + ]; foreignKeys: readonly [ { readonly source: { @@ -3669,6 +3696,10 @@ type ContractBase = Omit< readonly nativeType: 'jsonb'; readonly codecId: 'pg/jsonb@1'; readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/jsonb@1', readonly []>; + }; }; readonly backup_eligible: { readonly nativeType: 'bool'; @@ -4012,6 +4043,10 @@ type ContractBase = Omit< readonly nativeType: 'jsonb'; readonly codecId: 'pg/jsonb@1'; readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/jsonb@1', {}>; + }; }; readonly catalog_id: { readonly nativeType: 'uuid'; diff --git a/packages/3-extensions/supabase/src/contract/contract.json b/packages/3-extensions/supabase/src/contract/contract.json index 688c608204..2dc3719f8a 100644 --- a/packages/3-extensions/supabase/src/contract/contract.json +++ b/packages/3-extensions/supabase/src/contract/contract.json @@ -4832,17 +4832,29 @@ "columns": { "acceptable_client_ids": { "codecId": "pg/text@1", + "default": { + "kind": "literal", + "value": [] + }, "many": true, "nativeType": "text", "nullable": false }, "attribute_mapping": { "codecId": "pg/jsonb@1", + "default": { + "kind": "literal", + "value": {} + }, "nativeType": "jsonb", "nullable": false }, "authorization_params": { "codecId": "pg/jsonb@1", + "default": { + "kind": "literal", + "value": {} + }, "nativeType": "jsonb", "nullable": false }, @@ -4949,6 +4961,10 @@ }, "scopes": { "codecId": "pg/text@1", + "default": { + "kind": "literal", + "value": [] + }, "many": true, "nativeType": "text", "nullable": false @@ -6139,7 +6155,22 @@ } } ], - "indexes": [], + "indexes": [ + { + "columns": [ + "relates_to" + ], + "name": "one_time_tokens_relates_to_hash_idx", + "type": "hash" + }, + { + "columns": [ + "token_hash" + ], + "name": "one_time_tokens_token_hash_hash_idx", + "type": "hash" + } + ], "primaryKey": { "columns": [ "id" @@ -7200,6 +7231,10 @@ }, "transports": { "codecId": "pg/jsonb@1", + "default": { + "kind": "literal", + "value": [] + }, "nativeType": "jsonb", "nullable": false }, @@ -7664,6 +7699,10 @@ }, "metadata": { "codecId": "pg/jsonb@1", + "default": { + "kind": "literal", + "value": {} + }, "nativeType": "jsonb", "nullable": false }, @@ -8303,7 +8342,7 @@ "kind": "postgres-schema" } }, - "storageHash": "sha256:4fd3b6b5481531b6fc23e6f3a97061908a8efaab460a56e3c334ef2483c9dfdb", + "storageHash": "sha256:bf835b04145e86aa7b2128d798656c05beb1f54a3046276e7bc123c59c9cf9fe", "types": { "CreatedAt": { "codecId": "pg/timestamp@1", diff --git a/packages/3-extensions/supabase/src/contract/contract.prisma b/packages/3-extensions/supabase/src/contract/contract.prisma index 928a2d47f0..2faf026464 100644 --- a/packages/3-extensions/supabase/src/contract/contract.prisma +++ b/packages/3-extensions/supabase/src/contract/contract.prisma @@ -154,11 +154,11 @@ namespace auth { name String clientId String @map("client_id") clientSecret String @map("client_secret") - acceptableClientIds String[] @map("acceptable_client_ids") - scopes String[] + acceptableClientIds String[] @default(dbgenerated("'{}'::text[]")) @map("acceptable_client_ids") + scopes String[] @default(dbgenerated("'{}'::text[]")) pkceEnabled Boolean @default(true) @map("pkce_enabled") - attributeMapping Json @map("attribute_mapping") - authorizationParams Json @map("authorization_params") + attributeMapping Json @default(dbgenerated("'{}'::jsonb")) @map("attribute_mapping") + authorizationParams Json @default(dbgenerated("'{}'::jsonb")) @map("authorization_params") enabled Boolean @default(true) emailOptional Boolean @default(false) @map("email_optional") issuer String? @@ -331,6 +331,8 @@ namespace auth { updatedAt CreatedAt @default(now()) @map("updated_at") user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "one_time_tokens_user_id_fkey", index: false) + @@index([relatesTo], map: "one_time_tokens_relates_to_hash_idx", type: "hash") + @@index([tokenHash], map: "one_time_tokens_token_hash_hash_idx", type: "hash") @@rls @@map("one_time_tokens") } @@ -449,7 +451,7 @@ namespace auth { attestationType String @default("") @map("attestation_type") aaguid Id? signCount BigInt @default(0) @map("sign_count") - transports Json + transports Json @default(dbgenerated("'[]'::jsonb")) backupEligible Boolean @default(false) @map("backup_eligible") backedUp Boolean @default(false) @map("backed_up") friendlyName String @default("") @map("friendly_name") @@ -557,7 +559,7 @@ namespace storage { name String createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @map("updated_at") - metadata Json + metadata Json @default(dbgenerated("'{}'::jsonb")) catalogId Id @map("catalog_id") icebergTables IcebergTables[] catalog BucketsAnalytics @relation(fields: [catalogId], references: [id], onDelete: Cascade, map: "iceberg_namespaces_catalog_id_fkey", index: false) From 3bdc1e0aa402cbbc5405298b3f14eaf78949d7c2 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 16 Jul 2026 17:42:55 +0200 Subject: [PATCH 13/24] docs(upgrade): record TML-3037 breaking changes for 0.15-to-0.16 Four consumer-visible breaking changes from this slice need an upgrade entry, in both the user-skill and the extension-author-skill packages (both substrates hit the same postgres target/interpreter machinery): - pg/date@1: an existing @db.Date column's codecId changes on the next contract emit, changing storageHash, with no source change required. - dbgenerated literal defaults (jsonb/array/null/bool/numeric/string) now resolve to kind: 'literal' on both the authoring and introspection side; an existing dbgenerated(...) literal default's resolved shape changes on the next emit, changing storageHash. - Identity columns (GENERATED ... AS IDENTITY) now resolve to autoincrement() on introspection too; db verify --strict newly reports drift for an identity column whose contract predates this fix and declares no default. Non-strict verify is unaffected (undeclared auxiliary defaults are tolerated below --strict). - pluralize no longer double-pluralizes an already-plural table name in contract infer output; only affects a future infer run, not an already-generated .prisma file, but a consumer who re-infers and gets a renamed back-relation field must update the application/pack code that references it. The extension-author entry additionally explains the packages/3-extensions/supabase diff itself (the D8 workaround-deletion regeneration): it demonstrates the dbgenerated-literal-defaults fix directly, plus two additive/non-breaking changes (postgres index-type registration, the Decimal base-scalar fix) that need no entry. pnpm check:upgrade-coverage passes with these entries committed. Refs: TML-3037 Signed-off-by: willbot Signed-off-by: Will Madden --- .../upgrades/0.15-to-0.16/instructions.md | 46 ++++++++++++++++++- .../upgrades/0.15-to-0.16/instructions.md | 24 +++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.15-to-0.16/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.15-to-0.16/instructions.md index 78ceb551db..4a3a88f565 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.15-to-0.16/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.15-to-0.16/instructions.md @@ -1,9 +1,53 @@ --- from: "0.15" to: "0.16" -changes: [] +changes: + - id: pg-date-codec-for-db-date + summary: | + Postgres `@db.Date` columns are now backed by a dedicated `pg/date@1` codec, rather than inheriting `pg/timestamptz@1` (which `@db.Date` previously had no codec of its own and fell back to). The next `contract emit` of any existing `@db.Date` column in an extension pack's contract picks up the new `codecId` automatically — no `.prisma` source change needed — which changes that column's entry in `contract.json`/`contract.d.ts` and the contract's `storageHash`. This is a fix, not a regression: a `@db.Date` column read through `.include()` previously crashed decoding a bare `YYYY-MM-DD` value through the timestamptz decoder (`json_agg` -> `decodeJson`); that now works. No extension code needs to change. If your extension pins or compares a contract's `storageHash` (e.g. in a fixture snapshot or a published pack's own upgrade check), re-generate it after upgrading. + detection: + glob: "**/*.prisma" + contains: + - "@db.Date" + anyMatch: true + - id: dbgenerated-literal-defaults-resolve-consistently + summary: | + A `@default(dbgenerated("..."))` whose raw SQL text is actually a literal (`'{}'::jsonb`, `'{}'::text[]`, `NULL`, `true`/`false`, a bare number, a plain string) previously always resolved to `kind: 'function'` at emit time, while introspection resolved the identical live default to `kind: 'literal'` — so `db verify` reported permanent drift on such a column even when the database matched exactly. Authoring now resolves through the same parser introspection already used (`parsePostgresDefault`), so both sides agree; a genuine function default (`gen_random_uuid()`, `nextval(...)`, an arbitrary expression) is unaffected and still resolves to `kind: 'function'`. The next `contract emit` of any existing `dbgenerated(...)` literal default in an extension pack's contract picks up the corrected shape automatically — no `.prisma` source change needed — which changes that column's `default` entry in `contract.json` and the contract's `storageHash`. This repo's own `extension-supabase` pack hit exactly this (six columns whose literal defaults were previously omitted as a workaround); see the note below. If your pack's `db verify` was reporting permanent drift on such a column, this fixes it; if you pin or compare a contract's `storageHash`, re-generate it after upgrading. + detection: + glob: "**/*.prisma" + contains: + - "dbgenerated(" + anyMatch: true + - id: identity-columns-need-explicit-default-under-strict-verify + summary: | + `contract infer` now emits `@default(autoincrement())` for a Postgres `GENERATED ALWAYS AS IDENTITY` / `GENERATED BY DEFAULT AS IDENTITY` column (previously it emitted a bare column with no default, since Postgres reports no `column_default` for an identity column). Symmetrically, `db verify` introspecting a live identity column now resolves its default to `autoincrement()` too (previously it resolved to nothing). This only changes `db verify --strict` — without `--strict`, an undeclared live default is tolerated either way (and it is fully tolerated regardless of strictness under `control: 'external'`, the posture most extension packs declare). If your pack's own tests run `db verify --strict` against a table with an identity column whose contract does not declare `@default(autoincrement())`, verify now reports that default as an unexpected extra. Re-run `contract infer` for the affected table, or add `@default(autoincrement())` by hand. + - id: pluralize-back-relation-names-no-longer-double-pluralize + summary: | + `contract infer`'s back-relation field name generation used a hand-rolled pluralization rule that appended `es` to any table name already ending in `s`/`x`/`z`/`ch`/`sh`, doubling an already-plural table name (`sessions` -> `sessionses`). `contract infer` now uses real inflection (the `pluralize` library) and produces the correct name (`sessions` stays `sessions`; a genuinely singular `status` still becomes `statuses`). This only affects a future `contract infer` run — an already-generated `.prisma` file is untouched, so nothing breaks until you next re-run infer for your pack. If you do re-run `contract infer` against a schema with an already-plural table name, diff the regenerated `.prisma` file for any back-relation field whose name changed — that's a public field name your pack's consumers access via `.include()`/`.select()`/the generated TypeScript types, so a rename is a breaking change to your pack's own published surface, to be versioned and documented like any other. --- + +