From b401fb11846fa886e02778f19107ac332e43c07b Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 15:53:14 +0200 Subject: [PATCH 1/4] docs(projects): plan the removal of dbgenerated(...) from Prisma 8 Project spec, plan, deferred decisions, and three slice specs: the sql tagged literal for raw SQL defaults (ADR 129), codec-owned PSL literals (the PSL half of ADR 184), and the removal itself with the Supabase contract regeneration and upgrade instruction. Slices A and B are independent and run in parallel; C depends on both. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- projects/remove-dbgenerated/deferred.md | 39 +++ projects/remove-dbgenerated/plan.md | 60 ++++ .../slices/a-sql-default-literal/spec.md | 274 ++++++++++++++++++ .../slices/b-codec-psl-literals/spec.md | 181 ++++++++++++ .../slices/c-remove-dbgenerated/spec.md | 162 +++++++++++ projects/remove-dbgenerated/spec.md | 161 ++++++++++ 6 files changed, 877 insertions(+) create mode 100644 projects/remove-dbgenerated/deferred.md create mode 100644 projects/remove-dbgenerated/plan.md create mode 100644 projects/remove-dbgenerated/slices/a-sql-default-literal/spec.md create mode 100644 projects/remove-dbgenerated/slices/b-codec-psl-literals/spec.md create mode 100644 projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md create mode 100644 projects/remove-dbgenerated/spec.md diff --git a/projects/remove-dbgenerated/deferred.md b/projects/remove-dbgenerated/deferred.md new file mode 100644 index 000000000000..6470c0a1cc26 --- /dev/null +++ b/projects/remove-dbgenerated/deferred.md @@ -0,0 +1,39 @@ +# Deferred decisions and follow-ups + +Items the project deliberately leaves open. Each names the decision, why it is deferred, and what would reopen it. + +## 1. Content-addressed raw SQL defaults + +**Decision open:** whether a raw SQL default should be stored as ADR 129's `ext` envelope (`{ pack, tag, body, bodyHash }`) and compared by hash, the way index expressions, check constraints, and RLS predicates are (ADR 234, ADR 244). + +**Why deferred:** this project keeps `{ kind: 'function', expression }` (spec D1). A column default has no catalog name to carry a hash, so verification would still have to compare the database's own reprint of the expression; the hash would only help contract-to-contract diffing, which the storage hash already covers. That argument was not settled with the operator and is to be decided once everything else in this project has shipped. + +**Reopen when:** slice C is merged. + +## 2. Tagged literals for index expressions, check bodies, and RLS predicates + +**Decision open:** whether `@@index(where: "...")`, `@@check(expression: "...")`, and RLS `using:` / `withCheck:` move from plain strings to tagged literals so ADR 129's "single mechanism" claim becomes true. + +**Why deferred:** out of scope for removing `dbgenerated`. Slice A adds the parser node; only `@default` accepts it in this project. + +**Reopen when:** item 1 is decided, since the answer affects both. + +## 3. `encodeDdl` and `decodeDdl` on codecs + +**Decision open:** building the DDL half of ADR 184 so that migration rendering and verify-side normalisation of literal defaults go through the codec. + +**Why deferred:** no strong reason to do it now. Consequence today: the Prisma 7 source keeps rendering `Bytes` and `DateTime` literal defaults as raw SQL text (`prisma7-binding.ts` `literalDefaultForm` with kind `sqlExpression`), because verify cannot compare those as typed values. + +**Reopen when:** a second consumer needs typed DDL rendering, or the Prisma 7 workaround causes a user-visible defect. + +## 4. Language server, formatter, and highlighting for tagged literals + +**Owner:** Serhii. Brief delivered by slice C at `projects/remove-dbgenerated/editor-tooling-brief.md`. + +## 5. Deleting `.defaultSql()` + +**When:** 8.0.0 GA. Spec D7. + +## 6. Array-returning function defaults on list columns through a named function + +**Status:** pre-existing rejection, unchanged. A tagged literal on a list column is allowed (spec D8), which covers the practical cases. diff --git a/projects/remove-dbgenerated/plan.md b/projects/remove-dbgenerated/plan.md new file mode 100644 index 000000000000..0e387ac14a7d --- /dev/null +++ b/projects/remove-dbgenerated/plan.md @@ -0,0 +1,60 @@ +# Project plan — Remove `dbgenerated(...)` from Prisma 8 + +Spec: [`spec.md`](spec.md). Deferred items: [`deferred.md`](deferred.md). **Linear:** not yet created; add the project and three issues, then fill the IDs below. + +## Slices + +| Slice | Folder | Branch | Depends on | Owner | Linear | +|---|---|---|---|---|---| +| A — The `sql` tagged literal for raw SQL defaults | [`slices/a-sql-default-literal/`](slices/a-sql-default-literal/spec.md) | `remove-dbgenerated-sql-literal` | nothing | agent 1 | TBD | +| B — Codec-owned PSL literals (ADR 184, PSL half) | [`slices/b-codec-psl-literals/`](slices/b-codec-psl-literals/spec.md) | `remove-dbgenerated-codec-psl-literals` | nothing | agent 2 | TBD | +| C — Delete `dbgenerated`, regenerate Supabase, upgrade instruction | [`slices/c-remove-dbgenerated/`](slices/c-remove-dbgenerated/spec.md) | `remove-dbgenerated-delete` | A and B merged | agent 1 | TBD | + +Each slice is one PR against `main`. Slice plans (dispatch decomposition) are written at build time at `slices//plan.md` by the slice's implementer following the slice spec; the specs are complete enough that the plan is a sequencing document, not a design document. + +## Sequencing + +``` +main ──┬── A (parallel) ──┐ + └── B (parallel) ──┴── C +``` + +- A and B start together from the same `main` commit (`f3574a34a7` or later). +- A and B share one file: `packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts`, function `scalarDefaultArms`. A appends a `taggedLiteral(...)` arm after the function arms. B replaces `str(), numLiteral(), bool()` with `literal()`. Whichever merges second rebases and resolves that one function by hand; the result is `[literal(), ...funcArms, taggedLiteral(tags)]` for scalars and `[list(literal()), ...funcArms, taggedLiteral(tags)]` for lists. Both also touch `DefaultArgValue` in the same file, each adding its own union member. +- A and B both add a file under `psl-parser/src/attribute-spec/combinators/` and a member to `ArgTypeKind` in `attribute-spec/types.ts`; distinct names, so the rebase is mechanical. +- No other file is touched by both. If an implementer finds one, they stop and report before editing it. +- C starts only from a `main` that contains both A and B. + +## Coordination between the two agents + +- Agent 2 (slice B) owns the `Codec` interface change and every codec class. Agent 1 (slice A) does not add or change codec members. +- Agent 1 (slice A) owns the tokenizer, parser node, tag registry, TypeScript `sql` tag and helpers, `gen_random_uuid()`, and the SQLite verify-side resolver. Agent 2 does not touch those. +- Interfaces slice C relies on, which A and B must ship exactly as specified: `ControlMutationDefaults.defaultLiteralTagRegistry` (A5), `TaggedLiteralValue` (A4), `Codec.encodePsl` / `Codec.decodePsl` and `PslLiteral` (B1, B2), `mapDefault(columnDefault, { codec })` and `formatPslLiteral` (B6). A change to any of these names or shapes is reported to the orchestrator before it lands. +- Both agents record any question the spec does not answer in their PR body under "Spec gaps" and stop on the halt conditions their spec lists. They do not choose an alternative. + +## Validation gates per slice + +Every slice: `pnpm typecheck` at root, `pnpm test:packages`, `pnpm test:integration`, `pnpm fixtures:check`, `pnpm lint:deps`, `pnpm lint:docs`, plus the grep gates in its Definition of done. Slices B and C also run `pnpm test:e2e`. Slice C also runs the Supabase package tests and the upgrade-instruction validation. + +## Project health checks + +- Opening: this plan. Both slices A and B have specs; C waits. +- After A merges and after B merges: confirm `main` is green and the shared-file rebase (above) produced the specified arm order. +- Before C starts: confirm both specs' Definitions of done held on `main` (re-run the grep gates). +- Before close-out: the project Definition of done in `spec.md`. + +## Retro triggers + +- Any halt condition fired. +- The shared-file rebase needed more than the one function described above. +- A codec's PSL form did not fit the one rule in B3. +- Any spec gap recorded in a PR body. + +## Close-out + +After C merges and the final retro is recorded: + +1. Move `editor-tooling-brief.md` to `docs/design/` (or hand it to Serhii's tracker and delete it, his choice). +2. Move `deferred.md` items 1 to 3 into a Linear issue each or into `docs/TechDebt`-equivalent for this repo; item 5 is already dated. +3. Confirm the three ADR amendments are in the ADR index. +4. Delete `projects/remove-dbgenerated/`. diff --git a/projects/remove-dbgenerated/slices/a-sql-default-literal/spec.md b/projects/remove-dbgenerated/slices/a-sql-default-literal/spec.md new file mode 100644 index 000000000000..dc40e042fc75 --- /dev/null +++ b/projects/remove-dbgenerated/slices/a-sql-default-literal/spec.md @@ -0,0 +1,274 @@ +# Slice A — The `sql` tagged literal for raw SQL defaults + +**Project:** [Remove `dbgenerated`](../../spec.md). **Linear:** not yet created. **Branch:** `remove-dbgenerated-sql-literal` off `main`. **Shape:** one PR. **Runs in parallel with:** [slice B](../b-codec-psl-literals/spec.md). **Touches nothing slice B touches** except the `@default` argument arms in `sql-attribute-specs.ts`, where each slice adds its own arm. + +## Outcome + +A Prisma 8 schema can write a raw SQL column default as a tagged literal, in PSL and in TypeScript, and the result is exactly the contract shape `dbgenerated("...")` produces today. `dbgenerated` still exists at the end of this slice; slice C deletes it. + +After this slice, all of the following are true: + +```prisma +model T { + id String @id @default(sql`gen_random_uuid()`) + expires DateTime @default(sql"(now() + '00:03:00'::interval)") + createdAt DateTime @default(pg.sql`now()`) + tags String[] @default(sql`'{}'::text[]`) + ident String @default(gen_random_uuid()) +} +``` + +```ts +const T = model('T', { + fields: { + id: field.column(textColumn).default(sql`gen_random_uuid()`).id(), + createdAt: field.column(timestamptzTemporalColumn).default(now()), + seq: field.column(int4Column).default(autoincrement()), + ident: field.column(textColumn).default(genRandomUuid()), + }, +}); +``` + +Every one of those emits, migrates onto a dev database, verifies clean, and `contract infer` prints the named-function forms back. + +## Design + +### A1. Tokenizer: the backtick fence + +File: [`packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts`](../../../../packages/1-framework/2-authoring/psl-parser/src/tokenizer.ts). + +- New `TokenKind` member `TemplateLiteral`. +- A `TemplateLiteral` token starts at a backtick and ends at the next backtick that is not escaped. A backtick is escaped when it is preceded by an odd number of consecutive backslashes, the same rule `isTerminatedStringLiteral` applies to quotes. The token may span any number of lines. Its `text` is the source slice including both fences. +- An unterminated template literal produces an `Invalid` token for the rest of the source and the parser reports `PSL_UNTERMINATED_TEMPLATE_LITERAL` at the opening backtick. + +### A2. Parser: the `TaggedLiteral` expression + +Files: `packages/1-framework/2-authoring/psl-parser/src/syntax/` (syntax kinds, green-tree builder, red-tree AST classes; follow the `psl-ast-layers` skill). + +- New `SyntaxKind` member `TaggedLiteral`. +- Grammar: `TaggedLiteral := QualifiedTag Fence`, where `QualifiedTag := Ident ('.' Ident)*` and `Fence := TemplateLiteral | StringLiteral`. No whitespace, newline, or comment may appear between the last identifier of the tag and the fence. `sql \`x\`` with a space is not a tagged literal; the parser reports `PSL_TAGGED_LITERAL_FENCE_EXPECTED` at the identifier. +- The node is an expression and may appear anywhere an expression may appear. Only the `@default` attribute accepts it in this project; every other attribute rejects it through the normal combinator diagnostic ("Expected a string literal" and so on). +- AST class `TaggedLiteralExprAst` with: `tag(): string` (identifiers joined by `.`), `fence(): 'backtick' | 'quote'`, `rawBody(): string` (the text between the fences, escapes not yet resolved), `body(): string | undefined` (the canonical body, `undefined` when canonicalization fails), and the node's span. It joins the `ExpressionAst` union. +- `printSyntax` prints the node's source verbatim. The formatter in `psl-parser/src/format/format.ts` treats the token as opaque: it never re-indents, trims, or rewraps a `TemplateLiteral` token's text. + +### A3. Escapes and canonicalization + +Escape resolution happens before canonicalization and depends on the fence: + +- Backtick fence: `` \` `` becomes a backtick; `\\` becomes one backslash; `\$` becomes `$`. Every other backslash sequence is kept as written, both characters. So a SQL body may contain `E'\n'` unchanged. +- Quote fence: the existing PSL string-literal escape rules apply, exactly as `StringLiteralExprAst.value()` resolves them. Backticks need no escaping inside a quote fence. + +Canonicalization is one shared function used by PSL and TypeScript. New file `packages/1-framework/1-core/framework-components/src/shared/tagged-literal.ts` exporting: + +```ts +export type TaggedLiteralCanonicalization = + | { readonly ok: true; readonly body: string } + | { readonly ok: false; readonly reason: 'interpolation' | 'nul' | 'too-large'; readonly offset: number }; + +export function canonicalizeTaggedLiteralBody(resolved: string): TaggedLiteralCanonicalization; +``` + +Steps, in this order, on the escape-resolved text: + +1. If the text contains `${`, fail with `interpolation` and the offset of the first occurrence. (A body that needs those two characters writes `\${` in a backtick fence, which resolves to `${` before this step only when escaped as `\$` followed by `{`; the check therefore runs on the resolved text and the escaped form passes. Document this in the ADR amendment.) +2. If the text contains a NUL character, fail with `nul`. +3. Replace `\r\n` and lone `\r` with `\n`. +4. If the first line is blank (empty or only spaces and tabs), drop it. +5. If the last line is blank, drop it. +6. Compute the smallest count of leading spaces and tabs over all non-blank lines and remove that many leading characters from every line. Tabs and spaces are counted as characters; there is no tab expansion. +7. Keep internal blank lines as empty lines. +8. Do not add a trailing newline. +9. If the UTF-8 byte length exceeds 65536, fail with `too-large`. + +The canonical body is what the `TaggedLiteralExprAst.body()` returns and what becomes the contract expression. Two literals with different fences and the same canonical body are the same default. + +### A4. Attribute-spec combinator + +File: new `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/tagged-literal.ts`; types in `attribute-spec/types.ts`. + +```ts +export interface TaggedLiteralValue { + readonly tag: string; + readonly body: string; + readonly span: PslSpan; +} + +export function taggedLiteral(tags: readonly string[]): TaggedLiteralArgType; +``` + +- `ArgTypeKind` gains `'taggedLiteral'`. The `label` is `` `tag`...` `` for the first tag, used in "expected one of" messages. +- `parse` casts the argument to `TaggedLiteralExprAst`. If it is not one, the leaf diagnostic is `Expected a tagged literal`. If `tag()` is not in `tags`, the diagnostic is `PSL_UNKNOWN_DEFAULT_LITERAL_TAG` with message `Unknown literal tag "". Known tags: .` If `body()` is undefined, the diagnostic is one of `PSL_TAGGED_LITERAL_INTERPOLATION` (`Tagged literals do not support ${...} interpolation.`), `PSL_TAGGED_LITERAL_NUL` (`Tagged literals must not contain NUL characters.`), `PSL_TAGGED_LITERAL_TOO_LARGE` (`Tagged literal exceeds 65536 bytes.`), each at the literal's span. Otherwise `ok({ tag, body, span })`. + +### A5. The tag registry + +File: [`packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts`](../../../../packages/1-framework/1-core/framework-components/src/shared/mutation-default-types.ts) and the assembly in [`control-stack.ts`](../../../../packages/1-framework/1-core/framework-components/src/control/control-stack.ts). + +```ts +export interface ControlDefaultLiteralTagEntry { + readonly usage: string; // e.g. 'sql`...`' + lower(input: { + readonly literal: { readonly tag: string; readonly body: string; readonly span: PslSpan }; + readonly context: DefaultFunctionContext; // the same context type the function registry receives + }): LoweredDefaultResult; +} + +export type ControlDefaultLiteralTagRegistry = ReadonlyMap; + +export interface ControlMutationDefaults { + readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; + readonly defaultLiteralTagRegistry: ControlDefaultLiteralTagRegistry; // new + readonly generatorDescriptors: ...; // unchanged +} +``` + +- `assembleControlMutationDefaults` merges every contributor's tag registry the same way it merges function registries. Two contributors registering the same tag is an assembly error naming both contributors and the tag. +- Contributor surface: wherever a pack today supplies `defaultFunctionRegistry` (Postgres `exports/control.ts`, SQLite `exports/control.ts`), it also supplies `defaultLiteralTagRegistry`. +- The SQL family exports the shared entry from `packages/2-sql/9-family` (new file `src/core/sql-default-literal-tag.ts`, exported through the family's control export): + +```ts +export function sqlDefaultLiteralTagEntry(usage: string): ControlDefaultLiteralTagEntry; +``` + +Its `lower` runs `checkSqlDefaultBody(body)` (below) and returns `{ ok: true, value: { kind: 'storage', defaultValue: { kind: 'function', expression: body } } }`, or `{ ok: false, diagnostic }` with code `PSL_INVALID_DEFAULT_SQL`. + +- Postgres registers `sql` and `pg.sql`. SQLite registers `sql` and `sqlite.sql`. Both map to `sqlDefaultLiteralTagEntry` with usage `` sql`...` `` and `` pg.sql`...` `` / `` sqlite.sql`...` ``. +- The test fixture registry in [`contract-psl/test/fixtures.ts`](../../../../packages/2-sql/2-authoring/contract-psl/test/fixtures.ts) gains a tag registry with `sql` and `pg.sql`. + +### A6. The body check + +New function in the SQL family, beside the tag entry: + +```ts +/** Returns undefined when the body may be rendered as `DEFAULT ()`, else the reason. */ +export function checkSqlDefaultBody(body: string): string | undefined; +``` + +It rejects a body containing `;`, `--`, `/*`, `$$`, or the whole word `SELECT` (case-insensitive), with the message `Default SQL must not contain semicolons, SQL comment tokens, dollar-quoting, or subqueries.` This is the same rule as `assertSafeDefaultExpression` in the Postgres planner. The Postgres planner keeps its own call. The SQLite planner's `buildColumnDefaultSql` adds the same call before rendering a function-kind default, raising `CONTRACT.DEFAULT_INVALID` as Postgres does. An empty body passes the check. + +### A7. Interpreter wiring + +Files: [`sql-attribute-specs.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts), [`psl-column-resolution.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts), [`psl-field-resolution.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts). + +- `scalarDefaultArms` gains a `taggedLiteral([...registry.defaultLiteralTagRegistry.keys()])` arm, appended after the function arms, for both the list and the non-list case. The enum arms (`enumDefaultArms`) do not gain it: an enum column takes a member name. +- `psl-column-resolution.ts`: when the interpreted value has a `tag` property, look up `registry.defaultLiteralTagRegistry.get(value.tag)` and call `lower`. The entry is guaranteed present because the combinator only accepted registered tags; assert rather than branch. The lowering result is handled exactly like a function-registry result. +- The column-resolution result gains `readonly source: 'literal' | 'function' | 'taggedLiteral' | 'enumMember'`. +- `psl-field-resolution.ts`: the list check that emits `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED` skips a default whose `source` is `'taggedLiteral'`. It still rejects function-registry defaults and execution generators on list columns. + +### A8. Named `gen_random_uuid()` on Postgres + +File: [`postgres/src/core/control-mutation-defaults.ts`](../../../../packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts). + +- Signature `{}`. Registered immediately after `now`. Lowers to `{ kind: 'storage', defaultValue: { kind: 'function', expression: 'gen_random_uuid()' } }`. Usage string `gen_random_uuid()`. +- The registry-order test in [`sql-attribute-specs.test.ts:287`](../../../../packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts) is a family test using the fixture registry; the fixture registry adds `gen_random_uuid` after `now` too, so the expected order becomes `['autoincrement', 'now', 'gen_random_uuid', 'uuid', 'cuid', 'ulid', 'nanoid', 'dbgenerated']`. +- The Postgres infer mapping [`postgres-default-mapping.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts) changes `'gen_random_uuid()'` to print `@default(gen_random_uuid())`. The `dbgenerated` fallback stays until slice C. +- The verifier already normalises the live default to `gen_random_uuid()`; no change there. +- The language-server completion test gains `gen_random_uuid` in the Postgres function list. + +### A9. SQLite verify-side default resolution + +Postgres passes `postgresResolveDefault` into the family's `contract-to-schema-ir` resolver hook so an authored function expression is parsed the same way an introspected one is before comparison. SQLite provides the equivalent: + +- New export `sqliteResolveDefault(columnDefault, column)` in `packages/3-targets/3-targets/sqlite/src/core/default-normalizer.ts`: for a function-kind default, return `parseSqliteDefault(expression)`; otherwise return the default unchanged. +- Wire it where Postgres wires `postgresResolveDefault` (grep `resolveDefault` under `packages/3-targets/3-targets/postgres/src` to find the seam, then mirror it under `sqlite`). +- Effect: a contract with `sql\`CURRENT_TIMESTAMP\`` on SQLite stores and renders `CURRENT_TIMESTAMP` verbatim, and verify compares equal to the database's `CURRENT_TIMESTAMP`, because both sides pass through `parseSqliteDefault`. Nothing stored changes. + +### A10. TypeScript + +Files: [`contract-ts/src/contract-dsl.ts`](../../../../packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts), new `contract-ts/src/sql-default-literal.ts`, new `contract-ts/src/default-functions.ts`, exports in `contract-ts/src/exports/contract-builder.ts`, and the Postgres contract-builder export at `packages/3-targets/3-targets/postgres/src/exports/contract-builder.ts`. + +```ts +// contract-ts/src/sql-default-literal.ts +export function sql(strings: TemplateStringsArray, ...values: readonly never[]): ColumnDefault; +``` + +- Interpolation is a type error through `values: readonly never[]`. At runtime, `values.length > 0` throws a structured error `CONTRACT.DEFAULT_SQL_INTERPOLATION` with message `sql\`...\` does not support interpolation; write the SQL as one literal.` +- The body is `strings.join('')` (the cooked strings, so JavaScript escapes apply), passed through `canonicalizeTaggedLiteralBody`. A canonicalization failure throws `CONTRACT.DEFAULT_INVALID` with the reason. `checkSqlDefaultBody` runs and a failure throws `CONTRACT.DEFAULT_INVALID` with its message. +- Returns `{ kind: 'function', expression: body }`. + +```ts +// contract-ts/src/default-functions.ts +export function now(): ColumnDefault; // { kind: 'function', expression: 'now()' } +export function autoincrement(): ColumnDefault; // { kind: 'function', expression: 'autoincrement()' } +``` + +```ts +// postgres contract-builder export +export function genRandomUuid(): ColumnDefault; // { kind: 'function', expression: 'gen_random_uuid()' } +``` + +- `.default(value)` already accepts a `ColumnDefault`; no builder change is needed for these. +- `.defaultSql(expression)` stays. Its doc comment becomes: `@deprecated Write \`.default(sql\`...\`)\`, or \`.default(now())\` / \`.default(autoincrement())\` / \`.default(genRandomUuid())\` for a named function. Removed in 8.0.0.` The enum-field override that rejects `defaultSql` is unchanged. +- Every `.defaultSql(...)` call in `packages/`, `examples/`, `test/`, and docs is rewritten: `'autoincrement()'` → `.default(autoincrement())`, `'now()'` → `.default(now())`, `'gen_random_uuid()'` → `.default(genRandomUuid())`, `'CURRENT_TIMESTAMP'` → `.default(sql\`CURRENT_TIMESTAMP\`)`. The two DSL tests that test `.defaultSql` itself stay as the deprecated member's tests. The sql-orm-client test helper that maps a string default to `defaultSql` is rewritten to `.default(sql\`...\`)`. + +### A11. Fixtures and examples + +- New PSL/TS parity pair `test/integration/test/authoring/parity/default-sql-literal/` with the four forms from the Outcome section (backtick, quote, `pg.sql`, list column) and the matching TypeScript. The pair must emit identical contracts. +- The existing `default-dbgenerated` parity pair is unchanged in this slice (slice C renames it). +- `examples/prisma-8-demo-sqlite/prisma/contract.ts` uses `.default(now())`. + +### A12. ADR 129 amendment + +Add a section "Amendment — column defaults" to [ADR 129](../../../../docs/architecture%20docs/adrs/ADR%20129%20-%20Template-Tagged%20Literals%20for%20Extensions.md) recording: the two fences and their escape rules; that canonicalization applies to both; the registration rule (the target registers the unprefixed `sql` tag through a family-shared implementation, plus its prefixed alias; every other extension prefixes); that the `TaggedLiteral` node's fields are `tag`, `fence`, `rawBody`, `body`, `span`; that a column default lowers to `{ kind: 'function', expression }` rather than the `ext` envelope, with a pointer to [`deferred.md`](../../deferred.md) item 1; and that only `@default` accepts the node today. Update the ADR index summary line. + +### A13. Docs + +- [`contract-psl/README.md`](../../../../packages/2-sql/2-authoring/contract-psl/README.md): document `sql\`...\``, `sql"..."`, `pg.sql`, `sqlite.sql`, and `gen_random_uuid()` beside `now()` and `autoincrement()`. +- [`contract-ts/README.md`](../../../../packages/2-sql/2-authoring/contract-ts/README.md): replace the `defaultSql` example with `sql`, `now()`, `autoincrement()`, `genRandomUuid()`; note the deprecation. +- [`docs/reference/error-reference.md`](../../../../docs/reference/error-reference.md): add every new diagnostic code from A2, A4, A6, A10. +- `docs/architecture docs/subsystems/9. No-Emit Workflow.md` lines that show `defaultSql` use the new form. + +## Tests (written first; each named test must fail before its implementation lands) + +Tokenizer and parser (`psl-parser/test`): +- backtick fence single line; multi-line; escaped backtick; `\\`; `\$`; unterminated → `PSL_UNTERMINATED_TEMPLATE_LITERAL`; tag with dots; whitespace between tag and fence → `PSL_TAGGED_LITERAL_FENCE_EXPECTED`; quote fence; `printSyntax` round-trips source; formatter leaves a multi-line body byte-identical. + +Canonicalization (`framework-components/test`): +- each of the nine steps with a table of input → output; `${` → `interpolation`; `\${` resolved → passes; NUL; 65537 bytes → `too-large`; 65536 bytes passes. + +Combinator (`psl-parser/test/attribute-spec`): +- known tag ok; unknown tag → `PSL_UNKNOWN_DEFAULT_LITERAL_TAG` listing tags; non-literal argument → `Expected a tagged literal`. + +Registry assembly (`framework-components/test`): +- two contributors, distinct tags, merged; duplicate tag → assembly error naming both. + +Interpreter (`contract-psl/test/interpreter.defaults.test.ts`): +- `@default(sql\`gen_random_uuid()\`)` → `{ kind: 'function', expression: 'gen_random_uuid()' }`; quote fence equal; `pg.sql` equal; `sqlite.sql` with the Postgres fixture registry → unknown tag; body with `;` → `PSL_INVALID_DEFAULT_SQL`; empty body → `{ kind: 'function', expression: '' }` with no diagnostic; list column with `sql\`'{}'::text[]\`` → function default, no diagnostic; list column with `@default(now())` still → `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`; enum column with `sql\`...\`` → rejected by the enum arms. +- `@default(gen_random_uuid())` on Postgres fixture → function default `gen_random_uuid()`. + +Adapters: +- Postgres registry test: `gen_random_uuid` present after `now`; tag registry has `sql` and `pg.sql`. +- SQLite registry test: tag registry has `sql` and `sqlite.sql`; `sql\`CURRENT_TIMESTAMP\`` lowers verbatim (no rewrite). +- SQLite planner: function default renders `DEFAULT ()`; body with `;` → `CONTRACT.DEFAULT_INVALID`. +- SQLite `sqliteResolveDefault`: `CURRENT_TIMESTAMP` and `datetime('now')` both resolve to `now()`; a literal passes through. + +Verify (`9-family/test/schema-verify*`): +- SQLite contract with `sql\`CURRENT_TIMESTAMP\`` against a database column with `DEFAULT CURRENT_TIMESTAMP` → no findings. + +TypeScript (`contract-ts/test`): +- `sql\`x\`` → function default `x`; interpolation is a type error (`test-d`) and a runtime error; multi-line body dedents; `;` → `CONTRACT.DEFAULT_INVALID`; `now()`, `autoincrement()`, `genRandomUuid()` shapes; `.defaultSql` still works. + +Infer (`postgres/test/psl-infer`): +- a column with live default `gen_random_uuid()` prints `@default(gen_random_uuid())`. + +Journeys (`test/integration`, `test/e2e`): +- Parity pair `default-sql-literal` emits identical contracts from PSL and TS. +- Postgres journey: the Outcome schema emits, `db init` succeeds, `db verify --schema-only --strict` reports nothing, `contract infer` prints `@default(gen_random_uuid())` for both uuid columns and `@default(sql\`(now() + '00:03:00'::interval)\`)` for `expires` only after slice C (until then it prints `dbgenerated`; assert the slice-A form for the named function only). +- SQLite e2e: `sql\`CURRENT_TIMESTAMP\`` column migrates and verifies clean. + +## Definition of done + +- All tests above green; `pnpm test:packages`, `pnpm test:integration`, `pnpm fixtures:check`, `pnpm lint:deps`, `pnpm lint:docs`, root typecheck green. +- `git grep -n "defaultSql(" -- packages examples test docs` returns only the DSL definition, its deprecated-member tests, and the deprecation note. +- `git grep -n "NOW_SYNONYMS"` still returns the SQLite adapter (slice C deletes it). +- ADR 129 amendment merged with the PR. +- PR body records that `contract.json` output for every existing fixture is unchanged (`pnpm fixtures:check` proves it). + +## Halt conditions + +- The parser cannot add an expression node without changing how existing attribute arguments parse. Report; do not fork the grammar. +- The family cannot export the tag entry without a layering violation (`pnpm lint:deps`). Report the violation; do not put the entry in the framework. +- The SQLite resolver seam does not exist in the family in a form SQLite can reuse. Report the seam's shape. + +## Repository rules that apply + +`CLAUDE.md`; `.agents/rules/running-tests.mdc`; `.agents/rules/git-staging.mdc`; `.agents/rules/no-bare-casts.mdc`; `.agents/rules/contract-default-values.mdc`; `.agents/rules/omit-should-in-tests.mdc`; the `psl-ast-layers` skill; `.agents/rules/no-backward-compatibility.mdc` except for the `.defaultSql` deprecation the project spec allows. diff --git a/projects/remove-dbgenerated/slices/b-codec-psl-literals/spec.md b/projects/remove-dbgenerated/slices/b-codec-psl-literals/spec.md new file mode 100644 index 000000000000..c7c9e03f2feb --- /dev/null +++ b/projects/remove-dbgenerated/slices/b-codec-psl-literals/spec.md @@ -0,0 +1,181 @@ +# Slice B — Codec-owned PSL literals (the PSL half of ADR 184) + +**Project:** [Remove `dbgenerated`](../../spec.md). **Linear:** not yet created. **Branch:** `remove-dbgenerated-codec-psl-literals` off `main`. **Shape:** one PR. **Runs in parallel with:** [slice A](../a-sql-default-literal/spec.md). **Touches nothing slice A touches** except the `@default` argument arms in `sql-attribute-specs.ts`, where each slice adds its own arm. + +## Outcome + +Every typed literal default is read from PSL and printed back to PSL by the column's codec. There is no type-specific code in the interpreter, the printer, or the Prisma 7 source for numbers, JSON, or anything else. Concretely, after this slice: + +```prisma +model T { + meta Jsonb @default("{}") + items Json @default("[1, 2]") + big BigInt @default(9007199254740993) + price Decimal @default(1.50) + ratio Float @default("NaN") + name String @default("x") + flag Boolean @default(true) + scores Int[] @default([1, 2]) +} +``` + +emits a contract in which `meta` holds the JSON object `{}`, `big` holds every digit, `price` holds `1.50` with its trailing zero, and `contract infer` prints each of them back in exactly that form. + +## Design + +### B1. The `PslLiteral` type + +File: [`packages/1-framework/1-core/framework-components/src/shared/codec-types.ts`](../../../../packages/1-framework/1-core/framework-components/src/shared/codec-types.ts). + +```ts +/** A PSL scalar literal as its content, with the fence removed and escapes resolved. */ +export interface PslLiteral { + readonly kind: 'string' | 'number' | 'boolean'; + /** string: the characters between the quotes with escapes resolved. number: the digits exactly as written. boolean: 'true' or 'false'. */ + readonly text: string; +} +``` + +Nothing converts `text` to a JavaScript number before a codec sees it. + +### B2. The `Codec` interface + +File: [`packages/1-framework/1-core/framework-components/src/shared/codec.ts`](../../../../packages/1-framework/1-core/framework-components/src/shared/codec.ts). + +```ts +export interface Codec<...> { + // existing: encode, decode, encodeJson, decodeJson + /** The PSL literal that denotes this value in schema source. */ + encodePsl(value: TInput): PslLiteral; + /** The value a PSL literal denotes. Throws when the literal is not a value of this type. */ + decodePsl(literal: PslLiteral): TInput; +} + +export abstract class CodecImpl<...> { + abstract encodePsl(value: TInput): PslLiteral; + abstract decodePsl(literal: PslLiteral): TInput; +} +``` + +- Both members are required. There is no base-class default. A codec that omits them fails to compile. +- `decodePsl` throws an ordinary `Error` whose message says what the codec accepts, for example `pg/int4@1 reads a number literal; got a string`. Callers turn the message into a diagnostic. +- Update the file's header comment, which lists the four conversion methods, to list six and say when each pair runs (PSL: schema reading and schema printing). + +### B3. The PSL form of each codec + +One rule, applied to every codec: + +- If `encodeJson(value)` is a JSON string, the PSL form is `{ kind: 'string', text: }`, and `decodePsl` accepts a string literal and returns `decodeJson(text)`. +- If `encodeJson(value)` is a JSON number, the PSL form is `{ kind: 'number', text }` where `text` is the exact decimal text of the value with no exponent, and `decodePsl` accepts a number literal and reads its text without passing through `Number()` unless the codec's own type is a JavaScript number. +- If `encodeJson(value)` is a JSON boolean, the PSL form is `{ kind: 'boolean', text }`, and `decodePsl` accepts a boolean literal. +- If `encodeJson(value)` is a JSON object, array, or null, the PSL form is `{ kind: 'string', text: JSON.stringify(encodeJson(value)) }`, and `decodePsl` accepts a string literal, parses it as JSON, and returns `decodeJson(parsed)`. This covers the JSON codecs, the arktype-json codec, pgvector, and postgis. + +Named exceptions to the rule, each already how PSL is written today: + +- Float codecs (`pg/float4@1`, `pg/float8@1`, and SQLite's real codec): `NaN`, `Infinity`, and `-Infinity` are written as string literals `"NaN"`, `"Infinity"`, `"-Infinity"`, because PSL has no number token for them. `decodePsl` accepts both a number literal and one of those three strings. +- Integer codecs whose JavaScript type is `bigint` or a decimal string (`pg/int8@1`, `pg/numeric@1`, the decimal codecs): `decodePsl` reads the digits from `text` directly; `encodePsl` prints them directly. The number never touches a JavaScript `number`. +- Codecs whose JSON form is a string but whose PSL form must be a number (none known). If one is found, stop and report. + +Per-codec inventory the implementer must complete (grep `extends CodecImpl`; the abstract members make omissions compile errors, so the list is checked by the typecheck): + +- `packages/3-targets/3-targets/postgres/src/core/codecs.ts` (21 classes) +- `packages/3-targets/3-targets/postgres/src/core/temporal-codecs.ts` (4) +- `packages/3-targets/3-targets/postgres/src/core/temporal-string-codecs.ts` (4) +- `packages/3-targets/3-targets/postgres/src/core/date-codecs.ts` (1) +- `packages/3-targets/3-targets/sqlite/src/core/codecs.ts` (8) +- `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts` (5) +- `packages/3-extensions/pgvector/src/core/codecs.ts` (1) +- `packages/3-extensions/postgis/src/core/codecs.ts` (1) +- `packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts` (1) +- `packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts` (all classes; nothing in Mongo authoring calls them yet) +- Any alias or higher-order codec class the typecheck reports. + +Shared helpers are allowed and expected: for instance one `stringPslCodec()` mixin-style pair of functions the identity-string codecs share, one `jsonTextPsl` pair the object-valued codecs share. Put family-shared helpers in `packages/1-framework/1-core/framework-components/src/shared/psl-literal-helpers.ts`. Do not put a default on `CodecImpl`. + +### B4. The `literal()` combinator + +File: new `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/literal.ts`; types in `attribute-spec/types.ts`. + +```ts +export function literal(): LiteralArgType; // parses to PslLiteral +``` + +- `ArgTypeKind` gains `'literal'`. Label `literal`. +- A `StringLiteralExprAst` yields `{ kind: 'string', text: literal.value() }` (escapes resolved by the existing `value()`). A `NumberLiteralExprAst` yields `{ kind: 'number', text: token.text }`. A boolean literal yields `{ kind: 'boolean', text }`. Anything else: `Expected a string, number, or boolean literal`. +- `numLiteral()` stays for its other consumers (the Prisma 7 source uses it for attribute arguments); `@default` no longer uses it. + +### B5. Interpreter + +Files: [`sql-attribute-specs.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts), [`psl-column-resolution.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts), [`number-literal-default.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/number-literal-default.ts). + +- `scalarDefaultArms`: the literal arms `str(), numLiteral(), bool()` become one `literal()` arm; the list case becomes `list(literal())`. The function arms are unchanged. (Slice A appends a tagged-literal arm after the function arms; the two edits do not overlap.) +- `DefaultArgValue` becomes `PslLiteral | PslLiteral[] | TypedFuncCall` (slice A adds its own member). +- `psl-column-resolution.ts`: for a `PslLiteral` or a list of them, look up the column codec with `codecLookup.get(codecId)`. The codec must exist; a missing codec is an `InternalError` (the codec lookup is already required to build the column). Call `codec.decodePsl(literal)` for the value or for each element. A thrown error becomes diagnostic `PSL_INVALID_DEFAULT_LITERAL` at the attribute's span with message `Field ".": @default() is not a value of : `. The decoded value goes into `{ kind: 'literal', value }` exactly where today's value goes; encoding to JSON for the contract happens where it happens today (`encodeColumnDefault` in `contract-ts/src/build-contract.ts` calls `encodeJson`). +- Delete `number-literal-default.ts` and its test. Delete the `numeric` trait check that gated it. Remove `numberLiteralDefault` from `contract-psl`'s `resolution` export; its one external consumer (the Prisma 7 source) is rewritten in B7. +- The enum-member arm is unchanged: members lower to their storage value as today. + +### B6. Printer + +Files: [`9-family/src/core/psl-contract-infer/default-mapping.ts`](../../../../packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts), [`postgres/src/core/psl-infer/psl-literals.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/psl-literals.ts), [`infer-model-blocks.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts), `printer-config.ts`. + +- `mapDefault(columnDefault, options)` gains a required `codec: Codec` in its options for the literal arm. The literal arm prints `formatPslLiteral(codec.encodePsl(codec.decodeJson(value)))`; for a list column, each element, joined as `[a, b]`. The contract holds values in JSON form, so `decodeJson` runs first to get the codec's own type. +- New family function `formatPslLiteral(literal: PslLiteral): string`: `string` → `"` + escaped text + `"` using the existing `escapePslString` rules (moved to the family if it lives in the target today); `number` and `boolean` → `text`. +- Delete `formatLiteralValue`, `quoteString`, `escapeString` from `default-mapping.ts`. +- Delete the per-codec formatter table in `psl-literals.ts` (`PslDefaultValueFormat`, `formatPslValue`, `formatNumber`, `formatFloat`, `formatInteger`, `plainNumeral`, and the table that maps codec IDs to them) and the `printer-config.ts` option that carries it. `infer-model-blocks.ts` passes the column's codec to `mapDefault` instead. +- The list-literal printing path in `infer-model-blocks.ts` (the one that prints `@default([...])` from `resolvedDefault`) uses the same `formatPslLiteral` per element. + +### B7. Prisma 7 source + +Files: [`contract-prisma7/src/defaults.ts`](../../../../packages/2-sql/2-authoring/contract-prisma7/src/defaults.ts), [`contract-prisma7/src/target-binding.ts`](../../../../packages/2-sql/2-authoring/contract-prisma7/src/target-binding.ts), [`postgres/src/core/prisma7-binding.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/prisma7-binding.ts). + +- `Prisma7LiteralDefaultForm` loses the `{ kind: 'json' }` member. `literalDefaultForm` in the Postgres binding no longer returns it for `json`/`jsonb`; those columns take the codec path like every other column. The `sqlExpression` member stays for `bytea` and the temporal types (project spec D11). +- `scalarValue`, `elementValue`, `numberValue`, `rejectedNumberReason`, `WHOLE_NUMBER_SCALARS`, and `WHOLE_NUMBER_TEXT` are replaced by: build a `PslLiteral` from the expression (string → `{ kind: 'string', text: value() }`; number → `{ kind: 'number', text: token.text }`; boolean → boolean), call `codecLookup.get(codecId).decodePsl(literal)`, and turn a thrown error into `PSL.PRISMA7_UNKNOWN_DEFAULT` with message `Field ".": @default() is not a value of : `. The enum-member path (identifier → `enumMembers.get`) is unchanged. The `PRISMA7_JSON_NULL_DEFAULT_UNSUPPORTED` diagnostic stays and fires when the decoded value is `null` on a JSON-typed column. +- The Prisma 7 rule that `Int` and `BigInt` defaults must be whole numbers is now the codec's rule: `pg/int4@1` and `pg/int8@1` `decodePsl` reject `1.5`. The fixture expectations that quote the old message text are updated to the codec's message. + +### B8. ADR 184 amendment + +Add a section "Amendment — PSL literal methods live on `Codec`" to [ADR 184](../../../../docs/architecture%20docs/adrs/ADR%20184%20-%20Codec-owned%20value%20serialization.md): `encodePsl` and `decodePsl` are required members of the `Codec` interface and abstract on `CodecImpl`; the `PslLiteralCodec` interface sketched in the ADR is not a separate entity and never was, it is the consumer's view of the same codec (dependency inversion); the "single interface with all boundaries" alternative is no longer rejected for PSL; the `PslLiteral` shape and the one rule from B3 with its named exceptions; DDL methods remain future work with a pointer to [`deferred.md`](../../deferred.md) item 3. Update the `docs/reference/codec-authoring-guide.md` to list six methods and show a JSON-valued and a string-valued example. Update the ADR index summary line. + +### B9. Docs + +- `contract-psl/README.md`: one paragraph on literal defaults: the written form is whatever the column's codec accepts; JSON columns take a string holding JSON text; numbers are read exactly as written. +- `docs/reference/error-reference.md`: add `PSL_INVALID_DEFAULT_LITERAL`; update the Prisma 7 messages that changed. + +## Tests (written first; each named test must fail before its implementation lands) + +Framework (`framework-components/test`): +- `PslLiteral` type test; `CodecImpl` subclass without the methods fails to compile (`test-d`). + +Per pack, a table test that for every codec the pack registers, `decodePsl(encodePsl(v))` equals `v` for at least one sample value per codec, and `decodePsl` of a wrong-kind literal throws with a message naming the codec. Packs: Postgres target, SQLite target, relational-core, pgvector, postgis, arktype-json, Mongo codec package. + +Interpreter (`contract-psl/test/interpreter.defaults.test.ts`), each case asserting the whole default object: +- `Jsonb @default("{}")` → literal `{}` (object); `Json @default("[1, 2]")` → `[1, 2]`; `Json @default("null")` → literal `null` (allowed in Prisma 8 authoring); `BigInt @default(9007199254740993)` → exact; `Decimal @default(1.50)` → `"1.50"`; `Float @default("NaN")`; `Float @default(1.5)`; `Int @default(1.5)` → `PSL_INVALID_DEFAULT_LITERAL` with the codec's message; `Int @default("1")` → `PSL_INVALID_DEFAULT_LITERAL`; `String @default("a\"b")` → `a"b`; `Boolean @default(true)`; `Int[] @default([1, 2])`; `Int[] @default([1, "x"])` → diagnostic naming the element. +- The existing `preserves raw dbgenerated defaults for timestamp and json columns` test is unchanged (slice C rewrites it). + +Printer (`9-family/test/psl-contract-infer/default-mapping.test.ts`, `postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts`): +- every case above printed back to the same source text; a `pg/float8@1` value `NaN` prints `"NaN"`; a `pg/int8@1` value beyond 2^53 prints every digit; a jsonb object prints `"{\"a\":1}"` with escapes. + +Prisma 7 source (`contract-prisma7/test`): +- existing `defaults` fixture green with updated messages; `jsonLiteral Json @default("{\"a\":1}")` lowers through the codec; `Int @default(1.5)` rejected with the codec's message; `json-null-default` fixture unchanged. + +Journeys: +- `test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts`: the jsonb default case now asserts that emit succeeds without the workaround and that infer prints `@default("{}")`; delete the comment that says it is "left broken". +- New integration test: the Outcome schema emits, `db init` succeeds, `db verify --schema-only --strict` reports nothing, and a row read through the client returns the defaults with their decoded types. + +## Definition of done + +- All tests above green; `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm fixtures:check`, `pnpm lint:deps`, `pnpm lint:docs`, root typecheck green. +- `git grep -n "numberLiteralDefault\|PslDefaultValueFormat\|formatLiteralValue" -- packages` returns nothing. +- `git grep -n "kind: 'json'" -- packages/2-sql/2-authoring/contract-prisma7 packages/3-targets/3-targets/postgres/src/core/prisma7-binding.ts` returns nothing. +- Every existing fixture's `contract.json` is byte-identical (`pnpm fixtures:check`); the JSON form of values does not change. +- ADR 184 amendment and codec guide update merged with the PR. + +## Halt conditions + +- A codec's JSON form is a string but its natural PSL form must be a number, or the reverse. Report the codec; do not add a per-codec branch outside that codec. +- A consumer other than the interpreter, the printer, and the Prisma 7 source depends on the deleted formatter table. Report it. +- The Mongo codec package cannot depend on `PslLiteral` without a layering violation. Report; do not duplicate the type. + +## Repository rules that apply + +`CLAUDE.md`; `.agents/rules/running-tests.mdc`; `.agents/rules/git-staging.mdc`; `.agents/rules/no-bare-casts.mdc`; `.agents/rules/contract-default-values.mdc`; `.agents/rules/storage-type-hooks.mdc`; `.agents/rules/prefer-assertions-over-defensive-checks.mdc`; `.agents/rules/omit-should-in-tests.mdc`; `docs/reference/codec-authoring-guide.md`. diff --git a/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md b/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md new file mode 100644 index 000000000000..15098c7371b3 --- /dev/null +++ b/projects/remove-dbgenerated/slices/c-remove-dbgenerated/spec.md @@ -0,0 +1,162 @@ +# Slice C — Delete `dbgenerated`, regenerate the Supabase contract, ship the upgrade instruction + +**Project:** [Remove `dbgenerated`](../../spec.md). **Linear:** not yet created. **Branch:** `remove-dbgenerated-delete` off `main`. **Shape:** one PR. **Depends on:** [slice A](../a-sql-default-literal/spec.md) and [slice B](../b-codec-psl-literals/spec.md) both merged to `main`. + +## Outcome + +`dbgenerated` is gone from Prisma 8. `@default(dbgenerated("x"))` is a hard error whose message names the replacement. `contract infer` never prints it. The Prisma 7 source reads the Prisma 7 language's `dbgenerated` without it. The shipped Supabase contract carries none of the 21 uses. Users with existing contracts have an upgrade instruction. Serhii has a brief for the editor tools. + +## Design + +### C1. Delete the registry entries + +- Postgres [`control-mutation-defaults.ts`](../../../../packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts): delete `dbgeneratedSig`, `lowerDbgenerated`, and the registry entry. +- SQLite [`control-mutation-defaults.ts`](../../../../packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts): delete `dbgeneratedSig`, `lowerDbgenerated`, `NOW_SYNONYMS`, and the registry entry. The introspection-side rule in `sqlite/src/core/default-normalizer.ts` that reads `CURRENT_TIMESTAMP` and `datetime('now')` as `now()` stays, and its comment is rewritten to say it exists so a named `now()` default verifies against the database's text (slice A's `sqliteResolveDefault` applies it to both sides). +- Test fixture registry [`contract-psl/test/fixtures.ts`](../../../../packages/2-sql/2-authoring/contract-psl/test/fixtures.ts): delete the entry. +- Registry-order test expectation becomes `['autoincrement', 'now', 'gen_random_uuid', 'uuid', 'cuid', 'ulid', 'nanoid']`. +- Language-server completion tests: `dbgenerated` removed from the expected list and snippet tests. +- Adapter registry tests: the `dbgenerated` lowering, empty-argument, and verbatim-preservation cases are deleted; the SQLite `now()` canonicalization suite is deleted. + +### C2. The removed-function message + +File: [`contract-psl/src/default-function-registry.ts`](../../../../packages/2-sql/2-authoring/contract-psl/src/default-function-registry.ts). + +When `lowerDefaultFunctionWithRegistry` (or the `oneOf` failure path in `sql-attribute-specs.ts`, whichever produces `PSL_UNKNOWN_DEFAULT_FUNCTION`) meets a call whose name is exactly `dbgenerated`, the diagnostic message is: + +`Default function "dbgenerated" was removed. Write the SQL as a tagged literal: @default(sql\`\`). Supported functions: .` + +The code stays `PSL_UNKNOWN_DEFAULT_FUNCTION`. The parity fixture `test/integration/test/authoring/diagnostics/invalid-default-arguments/schema.prisma` that used `dbgenerated("")` is rewritten to exercise a different invalid argument (an unknown function name), and a new diagnostics fixture `removed-dbgenerated` asserts the message above. + +### C3. Prisma 7 source + +File: [`contract-prisma7/src/defaults.ts`](../../../../packages/2-sql/2-authoring/contract-prisma7/src/defaults.ts). + +- Remove `dbgenerated` from `FUNCTION_ARGUMENT_KEYS`. +- In `lowerFunction`, before the registry lookup: if `fn === 'dbgenerated'` and there is one positional string argument, return `{ storage: { kind: 'function', expression: }, onCreate: undefined }`. No canonicalization and no body check: the Prisma 7 language had neither, and the planner's DDL-time check still applies. +- If `fn === 'dbgenerated'` with no arguments, return `{ storage: undefined, onCreate: undefined }` on every field: optional, required, or list. Delete the diagnostic that rejected the empty form on required fields. `givesColumnDefault` returns `false` for the empty form, as today. +- If `fn === 'dbgenerated'` with any other argument shape, `PSL.PRISMA7_UNKNOWN_DEFAULT` with `function "dbgenerated()" has an argument this contract source does not read.` (the existing message path). +- Fixtures under `contract-prisma7/test/fixtures/` and `test/integration/test/fixtures/prisma7-source/{reference,supported,supported-verify}/schema.prisma` keep their `dbgenerated("gen_random_uuid()")` uses; they are Prisma 7 language. Add one fixture with the empty form on a required field and assert the column has no default and no diagnostic. +- The package README's list of what the source reads says: `@default(dbgenerated(""))` becomes a raw SQL default; `@default(dbgenerated())` means no column default. + +### C4. Infer prints the new forms + +Files: [`9-family/src/core/psl-contract-infer/default-mapping.ts`](../../../../packages/2-sql/9-family/src/core/psl-contract-infer/default-mapping.ts), [`postgres-default-mapping.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts), [`infer-model-blocks.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-model-blocks.ts), [`infer-psl-contract.ts`](../../../../packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-psl-contract.ts). + +- `mapDefault`'s function arm: named functions print as today; anything else prints `@default(sql\`\`)`, or `@default(sql"")` with PSL string escaping when the expression contains a backtick. This is the family's built-in fallback; the `fallbackFunctionAttribute` option and `DefaultMappingResult`'s `comment` member are deleted. `postgres-default-mapping.ts` keeps only the named-function table (`gen_random_uuid()`) and its `formatDbGeneratedAttribute` is deleted. +- Classification before printing: infer already resolves a column's raw default with the same Postgres parser verify uses (`parsePostgresDefault`, reached through `resolvedDefault`). Two classifications it does not make today are added so the Supabase contract prints literals where it can: + - A default written as `''::` where `` is the column's own type (including `jsonb`, `json`, `text`, and array types) is a literal whose value is `` read through the column's codec. The four Supabase JSON defaults print as `@default("{}")` and `@default("[]")`. + - A default written as `''::.` or `''::` where the enum is a native enum in the introspected schema and the column is typed by it is the enum member ``; infer prints `@default()`. The six Supabase enum casts print this way. The test at `infer-psl-contract.enum-adoption.test.ts` that pins a schema-qualified cast as preserved raw is rewritten to expect the member. +- `@default(autoincrement())` identity handling is unchanged. + +### C5. Regenerate the Supabase contract + +- Run the pack's generation script (`pnpm --filter contract:generate`). The diff of `contract.prisma` is exactly the 21 `dbgenerated` lines: + +| Lines | Before | After | +|---|---|---| +| 88, 158, 445, 459, 544, 571, 587, 657, 675, 697 | `@default(dbgenerated("gen_random_uuid()"))` | `@default(gen_random_uuid())` | +| 167, 168, 576 | `@default(dbgenerated("'{}'::jsonb"))` | `@default("{}")` | +| 466 | `@default(dbgenerated("'[]'::jsonb"))` | `@default("[]")` | +| 118 | `@default(dbgenerated("'confidential'::auth.oauth_client_type"))` | `@default(confidential)` | +| 292 | `@default(dbgenerated("'code'::auth.oauth_response_type"))` | `@default(code)` | +| 293 | `@default(dbgenerated("'pending'::auth.oauth_authorization_status"))` | `@default(pending)` | +| 546 | `@default(dbgenerated("'ANALYTICS'::storage.buckettype"))` | `@default(ANALYTICS)` | +| 561 | `@default(dbgenerated("'VECTOR'::storage.buckettype"))` | `@default(VECTOR)` | +| 627 | `@default(dbgenerated("'STANDARD'::storage.buckettype"))` | `@default(STANDARD)` | +| 296 | `@default(dbgenerated("(now() + '00:03:00'::interval)"))` | `@default(sql\`(now() + '00:03:00'::interval)\`)` | + +Line numbers are as of `main` at 2026-09-16; the implementer re-derives them. Any other change in the diff is a halt condition. + +- The emitted `contract.json` changes only where a literal replaces a function default (the JSON and enum lines). The storage hash moves. `test/reference-fixture-verify.integration.test.ts` must stay green: every new literal must verify equal to the live default. +- `CONTRACT-FIDELITY.md`: rewrite the "Column defaults" paragraph. It currently claims three omitted defaults; `scripts/generate-contract.ts` `DEFAULT_OMISSIONS` holds one (`auth.users.phone`). Say so, and remove the sentence about `dbgenerated` being function-kind on list fields. + +### C6. Fixtures and examples + +- Rename the parity pair `test/integration/test/authoring/parity/default-dbgenerated/` to `default-gen-random-uuid/` with `@default(gen_random_uuid())` and `.default(genRandomUuid())`. +- `test/integration/test/ports/prisma/functional/issues-28591-mapped-enums/_fixture/contract.prisma` line 41: `@default(dbgenerated("'pending'::\"SuggestionStatus\""))` becomes `@default(pending)` (the field is enum-typed) or, if the port requires the cast text, `@default(sql\`'pending'::"SuggestionStatus"\`)`. Choose the member form unless the test breaks; record which. +- `contract-psl/test/interpreter.defaults.test.ts` test `preserves raw dbgenerated defaults for timestamp and json columns` becomes `preserves raw sql defaults for timestamp columns` using `sql\`clock_timestamp()\``; the json column case moves to slice B's literal tests (already there). +- `test/e2e/framework/test/sqlite/migrations/widening.test.ts` comment naming `lowerDbgenerated` is rewritten. +- Every remaining test that constructs `dbgenerated(...)` PSL (see the inventory in the project spec) is rewritten to `sql\`...\`` or to the literal or named form its column allows. + +### C7. Upgrade instruction + +Directory: `upgrade-instructions/pending/remove-dbgenerated/app/instructions.md` and `upgrade-instructions/pending/remove-dbgenerated/extension/instructions.md`, following [`upgrade-instructions/README.md`](../../../../upgrade-instructions/README.md) and the `record-upgrade-instructions` skill. Both audiences are affected: examples changed (`app`) and the Supabase extension changed (`extension`). + +Content, both audiences: + +- Declaration: `@default(dbgenerated("..."))` removed from PSL; `.defaultSql(...)` deprecated in TypeScript. +- Mechanical rewrite table: + +| You wrote | Write instead | +|---|---| +| `@default(dbgenerated("gen_random_uuid()"))` | `@default(gen_random_uuid())` | +| `@default(dbgenerated("now()"))`, `@default(dbgenerated("CURRENT_TIMESTAMP"))` on Postgres | `@default(now())` | +| `@default(dbgenerated("''::jsonb"))` on a JSON column | `@default("")` | +| `@default(dbgenerated("''::"))` on an enum column | `@default()` | +| `@default(dbgenerated(""))` | `@default(sql\`\`)` | +| `.defaultSql('now()')` | `.default(now())` | +| `.defaultSql('autoincrement()')` | `.default(autoincrement())` | +| `.defaultSql('gen_random_uuid()')` | `.default(genRandomUuid())` | +| `.defaultSql('')` | `.default(sql\`\`)` | + +- Detection: `grep -rn "dbgenerated(" prisma/` and `grep -rn "defaultSql(" prisma/`. +- Note that the contract JSON does not change for any of these rewrites except JSON and enum literals, where the storage hash moves and `db verify` still passes because the value is the same. + +### C8. Docs sweep + +Delete or rewrite every mention of `dbgenerated` as an accepted form: + +- `packages/2-sql/2-authoring/contract-psl/README.md` +- `docs/reference/error-reference.md` (the `dbgenerated` examples; add the removed-function message) +- `docs/architecture docs/adrs/ADR 167 - Typed default literal pipeline and extensibility.md`: add a dated note under the superseded banner: the `dbgenerated(...)` stopgap was removed; raw SQL defaults are ADR 129 tagged literals, typed literals go through codecs per ADR 184's amendment. +- `docs/architecture docs/adrs/ADR 248 - PostgreSQL floor lowered to 15.md` line 19: `dbgenerated("gen_random_uuid()")` → `gen_random_uuid()`. +- `scorecard/03-psl-schema-language.md` and `scorecard/15-migrations.md`: update the rows and their status. +- `skills/prisma-8/upgrading/**` and `upgrade-instructions/**` history: leave as is (they describe past versions). +- `projects/infer-emit-roundtrip/`, `projects/port-all-tests/`, `projects/prisma-8-rc1/`: leave as is (project history). +- `CHANGELOG.md`, `docs/releases/`: leave as is. + +### C9. Artefact-format test + +Add an integration test that loads the Supabase pack's `contract.json` as committed on `main` before this slice (copy it into `test/integration/test/fixtures/contract-format/supabase-before-dbgenerated-removal.contract.json`) through `validateSqlContractFully` and asserts it validates. This proves a contract carrying `{ kind: 'function', expression }` defaults from before the change still loads. + +### C10. The editor-tooling brief for Serhii + +File: `projects/remove-dbgenerated/editor-tooling-brief.md`. Contents: + +- What changed in the parser: the `TemplateLiteral` token, the `TaggedLiteral` expression node and its AST class, the `literal()` and `taggedLiteral()` combinators, the tag registry on `ControlMutationDefaults`. +- What the language server must do: tokenise and parse the fence without errors; offer completion for registered tags inside `@default(` alongside the function list; report the new diagnostic codes at their spans; hover on a tagged literal shows the tag's usage string. +- What the formatter must do: leave a `TemplateLiteral` token byte-identical, including its indentation. +- What highlighting should do: inject SQL highlighting inside `sql`, `pg.sql`, and `sqlite.sql` fences (TextMate grammar or equivalent), with the fence delimiters styled as string delimiters. +- Where the tests are: the tokenizer, parser, combinator, and completion tests slices A and C added, so he can extend them. +- What is explicitly not done: everything in the highlighting bullet, hover, and tag completion. + +## Tests (written first; each named test must fail before its implementation lands) + +- Interpreter: `@default(dbgenerated("x"))` → `PSL_UNKNOWN_DEFAULT_FUNCTION` with the C2 message, on the Postgres and SQLite fixture registries. +- Prisma 7 source: `dbgenerated("x")` → function default `x` with no registry entry present; empty `dbgenerated()` on a required field → no default, no diagnostic; on an optional field → same; on a list field → same. +- Infer (`postgres/test/psl-infer`): `'{}'::jsonb` on a jsonb column → `@default("{}")`; `'confidential'::auth.oauth_client_type` on a column of that enum → `@default(confidential)`; `(now() + '00:03:00'::interval)` → `@default(sql\`(now() + '00:03:00'::interval)\`)`; an expression containing a backtick → the quote fence; `gen_random_uuid()` → `@default(gen_random_uuid())`. +- Family `default-mapping` tests: no `comment` result exists (type-level: `DefaultMappingResult` has one member). +- Supabase: `pnpm --filter test` green, including the reference-fixture verify test, against the regenerated contract. +- Artefact-format test (C9). +- Journey `infer-roundtrip-fidelity.e2e.test.ts`: assert the printed schema contains no `dbgenerated`. +- Diagnostics fixture `removed-dbgenerated` (C2). + +## Definition of done + +- `git grep -n dbgenerated -- packages examples test docs skills upgrade-instructions scorecard` returns only: the ADR 167 note; `upgrade-instructions/pending/remove-dbgenerated/**`; `packages/2-sql/2-authoring/contract-prisma7/**` (source, README, fixtures) and `test/integration/test/fixtures/prisma7-source/**`; `CHANGELOG.md`, `docs/releases/**`, and `skills/prisma-8/upgrading/**` history. +- `git grep -n "NOW_SYNONYMS\|lowerDbgenerated\|dbgeneratedSig\|formatDbGeneratedAttribute\|fallbackFunctionAttribute" -- packages` returns nothing. +- All tests above green; `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm fixtures:check` (the Supabase contract and the renamed parity fixture change, nothing else), `pnpm lint:deps`, `pnpm lint:docs`, root typecheck green. +- The upgrade instruction fragments pass the per-PR validation the `record-upgrade-instructions` skill describes. +- The Serhii brief exists and is linked from the PR body. +- ADR 167 note merged with the PR. + +## Halt conditions + +- The regenerated Supabase `contract.prisma` differs anywhere other than the 21 lines in C5. List the extra lines and stop. +- A Supabase literal default does not verify equal to the live default. Name the column and both sides and stop; do not add a normaliser rule. +- A committed fixture on SQLite carries a default only `dbgenerated` could express and none of the new forms can. List it and stop. +- A contract JSON from before the change fails to load (C9). Stop; the project's first cross-cutting requirement is violated. + +## Repository rules that apply + +`CLAUDE.md`; `.agents/rules/running-tests.mdc`; `.agents/rules/git-staging.mdc`; `.agents/rules/no-backward-compatibility.mdc` (no shim that keeps `dbgenerated` parsing); `.agents/rules/doc-maintenance.mdc`; `.agents/rules/fix-the-class-not-the-instance.mdc`; `.agents/rules/cli-test-fixture-cleanup.mdc`; the `record-upgrade-instructions` skill; `drive/calibration/failure-modes.md` F12 (exhaustive doc sweep); `drive/calibration/dod.md` § Project-DoD "Artefact-format changes load the previous format". diff --git a/projects/remove-dbgenerated/spec.md b/projects/remove-dbgenerated/spec.md new file mode 100644 index 000000000000..8d884a5deb2c --- /dev/null +++ b/projects/remove-dbgenerated/spec.md @@ -0,0 +1,161 @@ +# Project spec — Remove `dbgenerated(...)` from Prisma 8 + +**Linear:** not yet created. **Branch:** `remove-dbgenerated-plan` holds these artifacts; each slice gets its own branch off `main`. **Shape:** project, three slices. Slices A and B run in parallel by different agents. Slice C runs after both have merged. + +## Purpose + +`@default(dbgenerated("..."))` lets a schema put arbitrary SQL into a column default as an unnamed string. ADR 167 accepted it as a stopgap while typed default literals were unfinished. It was never meant to ship in Prisma 8. This project removes it and builds the two things it was standing in for: + +1. A designed way to write a raw SQL default: the ADR 129 tagged literal, written `@default(sql\`...\`)` or `@default(sql"...")` in PSL, and `.default(sql\`...\`)` in TypeScript. +2. The codec-owned PSL literal layer that ADR 184 decided and nobody built, so that every typed literal default (JSON, big integers, decimals, timestamps, and so on) is read from PSL and printed back to PSL by the column's codec, with no special cases in the interpreter or the printer. + +When both exist, `dbgenerated` is deleted everywhere, the shipped Supabase contract is regenerated without it, and users get an upgrade instruction. + +## Background the implementer needs + +- **How a default is stored.** A column default in the contract is one of two shapes: `{ kind: 'literal', value }` or `{ kind: 'function', expression }`. The definition is [`packages/1-framework/0-foundation/contract/src/types.ts:128`](../../packages/1-framework/0-foundation/contract/src/types.ts). Nothing in this project changes that type. `dbgenerated("x")` lowers to the function shape with `x` as the expression, and so will the new tagged literal. +- **Who consumes the function shape.** The migration planner writes `DEFAULT ()` into DDL after a keyword check ([Postgres](../../packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts), [SQLite](../../packages/3-targets/3-targets/sqlite/src/core/migrations/planner-ddl-builders.ts)). The verifier compares expressions as lower-cased, whitespace-stripped text ([`resolved-default-equality.ts`](../../packages/2-sql/1-core/schema-ir/src/ir/resolved-default-equality.ts)). On Postgres, the verifier first runs the introspection parser over the authored expression so that `gen_random_uuid()` written by hand and `gen_random_uuid()` read from the catalog compare equal ([`default-normalizer.ts` `postgresResolveDefault`](../../packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts)). SQLite has no such step today. +- **Where `dbgenerated` is registered.** Each target's control-plane default-function registry: [Postgres](../../packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts) and [SQLite](../../packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts). The same registries hold `autoincrement()`, `now()`, and the client-side generators `uuid()`, `cuid()`, `ulid()`, `nanoid()`. +- **Who produces it.** `contract infer` prints it for any Postgres default it cannot express as a literal or a named function ([`postgres-default-mapping.ts`](../../packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-default-mapping.ts)). The TypeScript builder's `.defaultSql("...")` produces the same shape. The Prisma 7 contract source ([`contract-prisma7/src/defaults.ts`](../../packages/2-sql/2-authoring/contract-prisma7/src/defaults.ts)) passes a Prisma 7 `dbgenerated("x")` through the registry, and separately turns `Bytes` and `DateTime` literal defaults into raw SQL text because the verify side cannot yet compare those types as values. +- **Who depends on it.** The shipped Supabase extension contract has 21 uses: 10 `gen_random_uuid()`, 4 JSON casts (`'{}'::jsonb`, `'[]'::jsonb`), 6 enum-value casts (`'confidential'::auth.oauth_client_type` and similar), and 1 expression `(now() + '00:03:00'::interval)`. +- **The prior decision.** The RC1 project recorded on 2026-07-20 that `@dbgenerated()` retires in favour of ADR 129 tagged literals ([`projects/prisma-8-rc1/feature-surface.md`](../prisma-8-rc1/feature-surface.md), item 6). That decision stands. Only the raw SQL syntax part of it is in this project. Mixins and type constructors are not. + +## Decisions + +Each decision is final. An implementer who finds one of them impossible stops and reports; they do not pick an alternative. + +### D1. The contract representation does not change + +A raw SQL default stays `{ kind: 'function', expression }`. A typed literal default stays `{ kind: 'literal', value }` with the value in the codec's JSON form. Every emitted `contract.json` from before this project loads unchanged. Whether a raw SQL default should later become a content-addressed payload (ADR 129's `ext` envelope with a body hash, like index expressions, check constraints, and RLS predicates) is a separate decision, recorded in [`deferred.md`](deferred.md), to be made after this project ships. + +### D2. Raw SQL is written as a tagged literal with two fences + +PSL: `@default(sql\`gen_random_uuid()\`)` and `@default(sql"gen_random_uuid()")`. The backtick fence is the default form. The double-quote fence exists for a body that contains many backticks. Both fences are equivalent after canonicalization. TypeScript: `.default(sql\`gen_random_uuid()\`)` with a `sql` template tag. The full grammar, escaping, canonicalization, and lowering rules are in [slice A](slices/a-sql-default-literal/spec.md). + +### D3. Tags are registered by targets; the unprefixed tag is target-provided + +A tag is a qualified identifier: one or more identifiers joined by dots. The unprefixed tag `sql` is registered by every SQL target (Postgres and SQLite) through a shared implementation the SQL family exports. Each target also registers its own prefixed alias: `pg.sql` on Postgres, `sqlite.sql` on SQLite. A tag is only known when the pack that registers it is part of the contract's stack; an unknown tag is a diagnostic that lists the known tags. Any extension other than the family and the target must use a prefix. This rule is recorded as an amendment to ADR 129. + +### D4. The SQL body is used verbatim + +The canonicalized body is the expression. Nothing rewrites it at authoring time, in the contract, or in DDL. The SQLite adapter's current rewrite of `CURRENT_TIMESTAMP`, `datetime('now')`, and `datetime("now")` to `now()` at authoring time is deleted with `dbgenerated`. An empty body is passed through like any other body; the database reports the error if it cannot accept it. The only authoring-time check on a body is the one the Postgres planner already applies at DDL time, moved earlier so it has a source span: a body containing `;`, `--`, `/*`, `$$`, or the word `SELECT` is rejected. SQLite gets the same check. + +### D5. Verify compares both sides the same way, on every target + +Verification on Postgres already runs the introspection parser over the authored expression before comparing it to the database's. SQLite gains the same step. This changes nothing that is stored or rendered. It only means that a body written one way and read back from the database another way compares equal when the target's parser recognises both. Slice A specifies the SQLite hook. + +### D6. Named storage functions cover the common cases + +Postgres registers `gen_random_uuid()` as a storage default function, after `now()` in registry order. No other named function is added in this project. `now()` and `autoincrement()` already exist on both targets. + +### D7. TypeScript gets the same forms + +The SQL family contract builder exports `sql` (template tag), `now()`, and `autoincrement()`. The Postgres contract builder additionally exports `genRandomUuid()`. All return a function-kind default accepted by `.default()`. `.defaultSql()` stays, marked `@deprecated` with a message naming the replacement, and is deleted at 8.0.0 GA. Every `.defaultSql(...)` call inside this repository is rewritten to the new forms. + +### D8. A tagged literal is allowed on a list column + +The interpreter rejects named function defaults and client-side generator defaults on list columns, because it cannot know whether a function returns an array. That rejection stays. A tagged literal is not subject to it: the author wrote the SQL and takes responsibility for its type. `tags String[] @default(sql\`'{}'::text[]\`)` lowers to a function-kind default on the list column. + +### D9. Codecs own the PSL form of every literal + +`encodePsl` and `decodePsl` become required members of the framework `Codec` interface and abstract members of `CodecImpl`. Every codec class in the repository implements them, including Mongo codecs. The interpreter passes a literal's normalised content and kind to the column codec's `decodePsl` and stores the result through `encodeJson` as today. The printer calls `encodePsl` and writes the result. The numbers-only shortcut in the interpreter, the per-codec formatter table in the Postgres infer printer, and the Prisma 7 source's JSON literal handling are all deleted. The full interface, the rule for what each codec's PSL form is, and the list of codecs are in [slice B](slices/b-codec-psl-literals/spec.md). ADR 184 is amended to say this was always the design and the interface is a consumer interface satisfied by the pack's codecs, not a separate entity. + +### D10. The codec receives normalised content, not source text with fences + +`decodePsl` receives `{ kind, text }` where `kind` is `string`, `number`, or `boolean`, and `text` is the literal's content with the quotes removed and escape sequences resolved for a string, the digits exactly as written for a number, and `true` or `false` for a boolean. The codec never sees the fence. A number is never converted to a JavaScript number before the codec sees it. `encodePsl` returns the same shape and the printer adds quotes and escapes. + +### D11. The DDL side of ADR 184 is out of scope + +`encodeDdl` and `decodeDdl` are not built. The consequence: the Prisma 7 source keeps turning `Bytes` and `DateTime` literal defaults into raw SQL expressions, because verify cannot yet compare those as typed values. That is recorded in [`deferred.md`](deferred.md). + +### D12. `contract infer` prints the new forms and never a gap + +When a Postgres default is a named function, infer prints the named function. When the column codec can read it as a literal, infer prints the literal through `encodePsl`. Otherwise infer prints `@default(sql\`\`)`, switching to the double-quote fence when the expression contains a backtick. Infer never emits a comment in place of a default and never stops on one. The family printer's `// Raw default:` comment fallback is deleted. + +### D13. The Prisma 7 source maps `dbgenerated` directly + +A Prisma 7 `@default(dbgenerated("x"))` lowers directly to `{ kind: 'function', expression: 'x' }` without going through the default-function registry. A Prisma 7 `@default(dbgenerated())` with no argument lowers to no column default at all, on every field, with no diagnostic. The contract then says the column has no default and the migration and runtime systems act on that. The current diagnostic for the empty form on required fields is deleted. + +### D14. Editor tooling is a hand-off + +The language server, the PSL formatter, and syntax highlighting are not extended by this project beyond what keeps them from breaking on the new token. Slice C produces a written brief for Serhii covering what the editor tools must do with tagged literals. + +## Non-goals + +- Mixins, field presets, type aliases, type constructors (the rest of the 2026-07-20 decision). +- Content-addressing raw SQL defaults (deferred, D1). +- Migrating index expressions, check constraint bodies, and RLS predicates from plain strings to tagged literals (deferred). +- `encodeDdl` and `decodeDdl` (deferred, D11). +- New named storage functions beyond `gen_random_uuid()`. +- Any change to Mongo authoring. Mongo codecs implement the new methods; nothing calls them yet. + +## Cross-cutting requirements + +- **No contract format change.** A test in slice C loads the Supabase pack's previous `contract.json` (the one committed before regeneration) through the normal loader and asserts it validates. Contract hashes of unchanged contracts must not move. +- **No rewriting of SQL bodies anywhere** (D4). +- **No backward-compatibility shims** other than the deprecated `.defaultSql()` (D7). `@default(dbgenerated(...))` is a hard error after slice C. +- **Every removed spelling is swept from docs, READMEs, error reference, scorecards, skills, and examples.** The grep in slice C's definition of done is the check. +- **Diagnostics have spans.** Every new diagnostic points at the literal or attribute that caused it. +- **Plain language in every artifact and message.** No invented terms. +- **Tests before implementation** per repository rules. Each slice spec lists the tests that must exist and be red before the change that turns them green. + +## Contract-impact + +Entities affected: `ColumnDefault` (unchanged shape, new producers). `Codec` interface (two new required members, slice B). `ControlMutationDefaults` (a new tag registry beside the function registry, slice A). No migration of stored contracts. + +## Adapter-impact + +- Postgres adapter: registry gains `gen_random_uuid()` and the tag registry; loses `dbgenerated`. +- SQLite adapter: registry gains the tag registry; loses `dbgenerated` and the `NOW_SYNONYMS` rewrite. +- Postgres target: infer prints the new forms; codecs implement PSL methods. +- SQLite target: verify-side default resolution hook; codecs implement PSL methods. +- Mongo: codecs implement PSL methods; nothing else. +- Extensions: pgvector, postgis, arktype-json codecs implement PSL methods. Supabase contract regenerated. + +## ADR pointers + +- ADR 129 — amended in slice A: two fences, tag registration rule (D3), canonicalization applies to both fences, the `TaggedLiteral` node's fields as built. +- ADR 184 — amended in slice B: `encodePsl` and `decodePsl` are required members of `Codec` (D9, D10); the "single interface" alternative is not rejected for PSL; DDL methods remain future work. +- ADR 167 — note added in slice C: the `dbgenerated(...)` stopgap is removed and what replaced it. + +## Definition of done (project) + +- All three slices merged to `main`. +- `git grep -n dbgenerated -- packages examples test docs skills upgrade-instructions scorecard` returns only: the ADR 167 note, the upgrade instruction, the Prisma 7 source's handling of the Prisma 7 language and its fixtures, and CHANGELOG or release-notes history. +- `pnpm test:packages`, `pnpm test:integration`, `pnpm test:e2e`, `pnpm fixtures:check`, `pnpm lint:deps`, `pnpm lint:docs`, and the root typecheck are green on `main`. +- The Supabase reference-fixture verify test is green against the regenerated contract. +- The three ADR amendments are merged. +- The Serhii brief exists at `projects/remove-dbgenerated/editor-tooling-brief.md` and is handed over before close-out moves it to `docs/`. +- Final retro run; `projects/remove-dbgenerated/` deleted at close-out after long-lived content moves to `docs/`. + +## Decision log + +Conclusions from the shaping discussion on 2026-09-16, with reasons, assumptions, and rejected alternatives. + +**Remove it, and replace it with the ADR 129 tagged literal.** Why: the 2026-07-20 team decision already chose this; the brief's Option A (named functions plus typed literals, gaps reported) would leave real defaults such as `(now() + '00:03:00'::interval)` unwritable; Option C (keep it) was never accepted by anyone. Assumes the 2026-07-20 decision stands. Confirmed by the operator. + +**Keep the contract representation.** Why: `dbgenerated` already lowers to the function shape, every consumer works on it, and changing it would touch the planner, verifier, printer, emitter, and Prisma 7 source for no gain in this project. Alternative rejected for now: ADR 129's hashed `ext` payload. Deferred, not rejected. + +**Bare `sql` tag, target-registered.** Why: `pg.sql` is cumbersome; users should write `sql`. The target registers it rather than the family because nobody is certain the tag's behaviour will never vary by target; a shared implementation lives in the family. Alternative rejected: family-registered unprefixed tag. + +**Two fences.** Why: a body with many backticks needs a fence that is not a backtick. Alternative rejected: backtick only (ADR 129 as written). + +**Verbatim bodies.** Why: a person who writes a SQL literal expects that SQL to be used. Alternative rejected: authoring-time rewrites of known synonyms (the SQLite `NOW_SYNONYMS` behaviour). + +**Keep `.defaultSql()` deprecated until GA.** Why: operator decision; it is a small surface and users have contracts using it. This overrides the repository's no-backward-compatibility rule for this one member. + +**Rewrite in-repo `.defaultSql` calls to named helpers where a helper exists.** Why: consistency between PSL and TypeScript; `now()` and `autoincrement()` already exist in PSL, so TypeScript gets the same names. + +**Codec interface, not a separate registry.** Why: the PSL literal form is part of what owning a type means; a separate registry keyed by codec ID would be the same information in a second place. The methods are required with no base-class default so that no codec's PSL form is implicit. + +**Normalised content to the codec, no pre-parsing.** Why: converting a number literal to a JavaScript number before the codec sees it loses precision for big integers and decimals. Alternative rejected: passing source text with fences (the codec has no business with PSL quoting). + +**DDL side out of scope.** Why: no strong reason to do it now; the Prisma 7 source's workaround for bytes and timestamps is contained and recorded. + +**Infer prints, never stops.** Why: infer's job is to describe the database faithfully; a user adopting a database should not be halted on a default they then have to add by hand. Alternative rejected: reporting a gap and stopping. + +**Empty Prisma 7 `dbgenerated()` means no default.** Why: the contract states whether a column has a default; the migration and runtime systems act on that; extra validation in the reader is a special case. Alternative rejected: the current diagnostic on required fields. + +**Tagged literal allowed on list columns.** Why: the list rejection exists because the interpreter cannot know a named function's return type. A tagged literal is the author's own SQL. Assumes the author accepts the database's error if the expression's type is wrong. This is the one decision the operator did not rule on explicitly; it follows from "verbatim". + +**Persona cross-pollination.** Scope was settled under the product lens first (remove, replace, deadline is "before GA", tooling is a hand-off). Shape under the architect lens (two independent pieces meeting at the removal step). Buildability under the principal-engineer lens is in the slice specs. From 259034c27be60d6162b9960d08e47fa3d6b7761c Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 16:32:07 +0200 Subject: [PATCH 2/4] docs(projects): list columns take any storage default; record the verify comparison plan for raw defaults Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- projects/remove-dbgenerated/deferred.md | 13 ++++++++++--- .../slices/a-sql-default-literal/spec.md | 7 +++---- projects/remove-dbgenerated/spec.md | 10 +++++----- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/projects/remove-dbgenerated/deferred.md b/projects/remove-dbgenerated/deferred.md index 6470c0a1cc26..75c9e86256c3 100644 --- a/projects/remove-dbgenerated/deferred.md +++ b/projects/remove-dbgenerated/deferred.md @@ -2,11 +2,18 @@ Items the project deliberately leaves open. Each names the decision, why it is deferred, and what would reopen it. -## 1. Content-addressed raw SQL defaults +## 1. A sound comparison for raw SQL defaults in `db verify` -**Decision open:** whether a raw SQL default should be stored as ADR 129's `ext` envelope (`{ pack, tag, body, bodyHash }`) and compared by hash, the way index expressions, check constraints, and RLS predicates are (ADR 234, ADR 244). +**Problem:** verify compares the expression in the contract with the expression the database reports. Postgres and SQLite report their own reprint of an expression, not the author's text (see ADR 234 for the same problem on RLS predicates). Today both sides are pushed through the target's introspection parser and then compared as lower-cased, whitespace-stripped text. That parser recognises a handful of shapes and passes everything else through verbatim, so any expression Postgres reprints differently from how it was written reports drift forever. -**Why deferred:** this project keeps `{ kind: 'function', expression }` (spec D1). A column default has no catalog name to carry a hash, so verification would still have to compare the database's own reprint of the expression; the hash would only help contract-to-contract diffing, which the storage hash already covers. That argument was not settled with the operator and is to be decided once everything else in this project has shipped. +**How indexes, checks, and RLS policies solve it:** the authored body is hashed and the hash is part of the object's name in the catalog (ADR 234, ADR 243, ADR 244). Verify compares names and never reads the body. A column default has no name in the catalog, so that exact mechanism does not transfer. + +**Candidate plans, to be decided after slice C:** + +1. **Let the database normalise both sides.** Verify already holds a connection. It creates a temporary table with one column carrying the authored default, reads back what the database reprints for it, and compares reprint to reprint. The database is then the only normaliser, which is the same idea as name comparison: never parse SQL in JavaScript. Cost: one round trip per raw default at verify time, and a temporary object on a connection that may be read-only. Recommended candidate. +2. **Record the hash out of band.** Store `SHA-256(canonical body)` in the column's comment (`COMMENT ON COLUMN`, read from `pg_description`; SQLite has no column comments, so it would need another place). Verify compares hashes and never reads the expression. Cost: verify silently trusts the hash if someone edits the default and not the comment, the same trade-off ADR 244 accepts for checks; and SQLite needs a different mechanism. +3. **Wrap every raw default in a named function** (`DEFAULT prisma_default_()`), giving the default a catalog name that carries the hash. Rejected in advance: it creates one database function per default and changes the SQL the user wrote. +4. **Store the default as ADR 129's `ext` envelope with a `bodyHash`.** Changes the contract shape (project spec D1 keeps it). On its own it does not solve verification, since the database still has no name to compare; it only helps contract-to-contract diffing, which the storage hash already covers. Only worth doing together with 1 or 2. **Reopen when:** slice C is merged. diff --git a/projects/remove-dbgenerated/slices/a-sql-default-literal/spec.md b/projects/remove-dbgenerated/slices/a-sql-default-literal/spec.md index dc40e042fc75..607a7122c625 100644 --- a/projects/remove-dbgenerated/slices/a-sql-default-literal/spec.md +++ b/projects/remove-dbgenerated/slices/a-sql-default-literal/spec.md @@ -151,8 +151,7 @@ Files: [`sql-attribute-specs.ts`](../../../../packages/2-sql/2-authoring/contrac - `scalarDefaultArms` gains a `taggedLiteral([...registry.defaultLiteralTagRegistry.keys()])` arm, appended after the function arms, for both the list and the non-list case. The enum arms (`enumDefaultArms`) do not gain it: an enum column takes a member name. - `psl-column-resolution.ts`: when the interpreted value has a `tag` property, look up `registry.defaultLiteralTagRegistry.get(value.tag)` and call `lower`. The entry is guaranteed present because the combinator only accepted registered tags; assert rather than branch. The lowering result is handled exactly like a function-registry result. -- The column-resolution result gains `readonly source: 'literal' | 'function' | 'taggedLiteral' | 'enumMember'`. -- `psl-field-resolution.ts`: the list check that emits `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED` skips a default whose `source` is `'taggedLiteral'`. It still rejects function-registry defaults and execution generators on list columns. +- `psl-field-resolution.ts`: the list check that emits `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED` now tests only `loweredOnCreate` (a client-side generator). The `loweredFunctionDefault` condition is deleted, so named storage functions and tagged literals lower on a list column like on any other column (project spec D8). The message keeps its wording; it only ever fires for generators now. ### A8. Named `gen_random_uuid()` on Postgres @@ -164,7 +163,7 @@ File: [`postgres/src/core/control-mutation-defaults.ts`](../../../../packages/3- - The verifier already normalises the live default to `gen_random_uuid()`; no change there. - The language-server completion test gains `gen_random_uuid` in the Postgres function list. -### A9. SQLite verify-side default resolution +### A9. SQLite verifies defaults exactly the way Postgres does Postgres passes `postgresResolveDefault` into the family's `contract-to-schema-ir` resolver hook so an authored function expression is parsed the same way an introspected one is before comparison. SQLite provides the equivalent: @@ -232,7 +231,7 @@ Registry assembly (`framework-components/test`): - two contributors, distinct tags, merged; duplicate tag → assembly error naming both. Interpreter (`contract-psl/test/interpreter.defaults.test.ts`): -- `@default(sql\`gen_random_uuid()\`)` → `{ kind: 'function', expression: 'gen_random_uuid()' }`; quote fence equal; `pg.sql` equal; `sqlite.sql` with the Postgres fixture registry → unknown tag; body with `;` → `PSL_INVALID_DEFAULT_SQL`; empty body → `{ kind: 'function', expression: '' }` with no diagnostic; list column with `sql\`'{}'::text[]\`` → function default, no diagnostic; list column with `@default(now())` still → `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`; enum column with `sql\`...\`` → rejected by the enum arms. +- `@default(sql\`gen_random_uuid()\`)` → `{ kind: 'function', expression: 'gen_random_uuid()' }`; quote fence equal; `pg.sql` equal; `sqlite.sql` with the Postgres fixture registry → unknown tag; body with `;` → `PSL_INVALID_DEFAULT_SQL`; empty body → `{ kind: 'function', expression: '' }` with no diagnostic; list column with `sql\`'{}'::text[]\`` → function default, no diagnostic; list column with `@default(now())` → function default `now()`, no diagnostic; list column with `@default(uuid())` → `PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED`; enum column with `sql\`...\`` → rejected by the enum arms. - `@default(gen_random_uuid())` on Postgres fixture → function default `gen_random_uuid()`. Adapters: diff --git a/projects/remove-dbgenerated/spec.md b/projects/remove-dbgenerated/spec.md index 8d884a5deb2c..d618f3330c45 100644 --- a/projects/remove-dbgenerated/spec.md +++ b/projects/remove-dbgenerated/spec.md @@ -40,9 +40,9 @@ A tag is a qualified identifier: one or more identifiers joined by dots. The unp The canonicalized body is the expression. Nothing rewrites it at authoring time, in the contract, or in DDL. The SQLite adapter's current rewrite of `CURRENT_TIMESTAMP`, `datetime('now')`, and `datetime("now")` to `now()` at authoring time is deleted with `dbgenerated`. An empty body is passed through like any other body; the database reports the error if it cannot accept it. The only authoring-time check on a body is the one the Postgres planner already applies at DDL time, moved earlier so it has a source span: a body containing `;`, `--`, `/*`, `$$`, or the word `SELECT` is rejected. SQLite gets the same check. -### D5. Verify compares both sides the same way, on every target +### D5. SQLite verifies defaults exactly the way Postgres does -Verification on Postgres already runs the introspection parser over the authored expression before comparing it to the database's. SQLite gains the same step. This changes nothing that is stored or rendered. It only means that a body written one way and read back from the database another way compares equal when the target's parser recognises both. Slice A specifies the SQLite hook. +Postgres runs its introspection parser over the expression in the contract before comparing it to the expression the database reports, so the two are compared in the same form. SQLite does the same with its own parser. Slice A specifies the SQLite hook. A better comparison for raw SQL defaults on every target is a separate decision, recorded in [`deferred.md`](deferred.md) item 1. ### D6. Named storage functions cover the common cases @@ -52,9 +52,9 @@ Postgres registers `gen_random_uuid()` as a storage default function, after `now The SQL family contract builder exports `sql` (template tag), `now()`, and `autoincrement()`. The Postgres contract builder additionally exports `genRandomUuid()`. All return a function-kind default accepted by `.default()`. `.defaultSql()` stays, marked `@deprecated` with a message naming the replacement, and is deleted at 8.0.0 GA. Every `.defaultSql(...)` call inside this repository is rewritten to the new forms. -### D8. A tagged literal is allowed on a list column +### D8. A list column takes any storage default -The interpreter rejects named function defaults and client-side generator defaults on list columns, because it cannot know whether a function returns an array. That rejection stays. A tagged literal is not subject to it: the author wrote the SQL and takes responsibility for its type. `tags String[] @default(sql\`'{}'::text[]\`)` lowers to a function-kind default on the list column. +Nobody can tell from a function's name whether it returns a value of the column's type, for a list column or for any other column. That is the author's responsibility and the database reports the error if it is wrong. So the interpreter's list-column check rejects only client-side generator defaults (`uuid()`, `cuid()`, `ulid()`, `nanoid()`), which generate one value and have no meaning for a list. Named storage functions and tagged literals on a list column lower like on any other column. `tags String[] @default(sql\`'{}'::text[]\`)` and `tags DateTime[] @default(now())` both lower; the second fails when the database refuses the DDL, which is where the error belongs. ### D9. Codecs own the PSL form of every literal @@ -156,6 +156,6 @@ Conclusions from the shaping discussion on 2026-09-16, with reasons, assumptions **Empty Prisma 7 `dbgenerated()` means no default.** Why: the contract states whether a column has a default; the migration and runtime systems act on that; extra validation in the reader is a special case. Alternative rejected: the current diagnostic on required fields. -**Tagged literal allowed on list columns.** Why: the list rejection exists because the interpreter cannot know a named function's return type. A tagged literal is the author's own SQL. Assumes the author accepts the database's error if the expression's type is wrong. This is the one decision the operator did not rule on explicitly; it follows from "verbatim". +**List columns take any storage default.** Why: the interpreter cannot know any function's return type, for lists or for anything else, so refusing storage functions on lists alone was a special case. Only client-side generators are refused on lists, because a generator produces one value. Alternative rejected: the previous rule from the infer round-trip project, which kept the refusal to move a type error from DDL time to authoring time; the operator prefers the database to report it. **Persona cross-pollination.** Scope was settled under the product lens first (remove, replace, deadline is "before GA", tooling is a hand-off). Shape under the architect lens (two independent pieces meeting at the removal step). Buildability under the principal-engineer lens is in the slice specs. From 369cd9a5d4c0df3e3a03462928fa3b0d64c78319 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 16:36:09 +0200 Subject: [PATCH 3/4] docs(projects): no shadow database for default verification; note what ADR 129 does and does not cover Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- projects/remove-dbgenerated/deferred.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/projects/remove-dbgenerated/deferred.md b/projects/remove-dbgenerated/deferred.md index 75c9e86256c3..8bafbdb5f497 100644 --- a/projects/remove-dbgenerated/deferred.md +++ b/projects/remove-dbgenerated/deferred.md @@ -8,12 +8,15 @@ Items the project deliberately leaves open. Each names the decision, why it is d **How indexes, checks, and RLS policies solve it:** the authored body is hashed and the hash is part of the object's name in the catalog (ADR 234, ADR 243, ADR 244). Verify compares names and never reads the body. A column default has no name in the catalog, so that exact mechanism does not transfer. +**What ADR 129 gives and does not give:** the `ext` envelope with `bodyHash` identifies a literal inside the contract, so two contracts can be diffed by hash. It says nothing about comparing a contract to a live database. Storing the default that way (which project spec D1 declines for now) does not answer this question on its own. + +**Rejected by the operator:** creating a temporary table on the target database at verify time so the database reprints the authored default. No shadow-database mechanism of any kind. + **Candidate plans, to be decided after slice C:** -1. **Let the database normalise both sides.** Verify already holds a connection. It creates a temporary table with one column carrying the authored default, reads back what the database reprints for it, and compares reprint to reprint. The database is then the only normaliser, which is the same idea as name comparison: never parse SQL in JavaScript. Cost: one round trip per raw default at verify time, and a temporary object on a connection that may be read-only. Recommended candidate. -2. **Record the hash out of band.** Store `SHA-256(canonical body)` in the column's comment (`COMMENT ON COLUMN`, read from `pg_description`; SQLite has no column comments, so it would need another place). Verify compares hashes and never reads the expression. Cost: verify silently trusts the hash if someone edits the default and not the comment, the same trade-off ADR 244 accepts for checks; and SQLite needs a different mechanism. -3. **Wrap every raw default in a named function** (`DEFAULT prisma_default_()`), giving the default a catalog name that carries the hash. Rejected in advance: it creates one database function per default and changes the SQL the user wrote. -4. **Store the default as ADR 129's `ext` envelope with a `bodyHash`.** Changes the contract shape (project spec D1 keeps it). On its own it does not solve verification, since the database still has no name to compare; it only helps contract-to-contract diffing, which the storage hash already covers. Only worth doing together with 1 or 2. +1. **Record the hash out of band, in the column comment.** The migration that sets a raw default also sets `COMMENT ON COLUMN` to carry `SHA-256(canonical body)` in a fixed form; verify reads it from `pg_description` and compares hashes, never the expression. Same trust model as ADR 244: a default edited by hand under an unchanged comment goes undetected. SQLite has no column comments, so it needs its own carrier, for example a row in the Prisma marker schema keyed by table and column. +2. **Record the hash in the marker schema on every target.** The migration runner writes one row per raw default (table, column, hash) into the marker schema the database already carries; verify compares those rows to the contract. Uniform across targets. Weaker than 1 on Postgres because the hash lives beside the column rather than on it, so a column dropped and recreated by hand leaves a stale row. +3. **Keep text comparison but make the parser complete.** Rejected in advance for the reason ADR 234 and ADR 244 record: matching the database's reprint needs a Postgres grammar in JavaScript. **Reopen when:** slice C is merged. From aa5603e18021a813041e8922c77103d8bdb4778f Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 16 Sep 2026 16:36:33 +0200 Subject: [PATCH 4/4] docs(projects): verification of raw SQL defaults stays as it is Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- projects/remove-dbgenerated/deferred.md | 18 +++--------------- projects/remove-dbgenerated/spec.md | 2 +- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/projects/remove-dbgenerated/deferred.md b/projects/remove-dbgenerated/deferred.md index 8bafbdb5f497..43cca494944e 100644 --- a/projects/remove-dbgenerated/deferred.md +++ b/projects/remove-dbgenerated/deferred.md @@ -2,23 +2,11 @@ Items the project deliberately leaves open. Each names the decision, why it is deferred, and what would reopen it. -## 1. A sound comparison for raw SQL defaults in `db verify` +## 1. Verification of raw SQL defaults stays as it is -**Problem:** verify compares the expression in the contract with the expression the database reports. Postgres and SQLite report their own reprint of an expression, not the author's text (see ADR 234 for the same problem on RLS predicates). Today both sides are pushed through the target's introspection parser and then compared as lower-cased, whitespace-stripped text. That parser recognises a handful of shapes and passes everything else through verbatim, so any expression Postgres reprints differently from how it was written reports drift forever. +**Decision (operator, 2026-09-16):** the existing verification logic is kept. Both sides pass through the target's introspection parser and are compared as normalised text. No hash, no column comment, no marker row, and no temporary table. ADR 129's `bodyHash` identifies a literal inside the contract for diffing two contracts; it is not a verification mechanism and is not adopted here. -**How indexes, checks, and RLS policies solve it:** the authored body is hashed and the hash is part of the object's name in the catalog (ADR 234, ADR 243, ADR 244). Verify compares names and never reads the body. A column default has no name in the catalog, so that exact mechanism does not transfer. - -**What ADR 129 gives and does not give:** the `ext` envelope with `bodyHash` identifies a literal inside the contract, so two contracts can be diffed by hash. It says nothing about comparing a contract to a live database. Storing the default that way (which project spec D1 declines for now) does not answer this question on its own. - -**Rejected by the operator:** creating a temporary table on the target database at verify time so the database reprints the authored default. No shadow-database mechanism of any kind. - -**Candidate plans, to be decided after slice C:** - -1. **Record the hash out of band, in the column comment.** The migration that sets a raw default also sets `COMMENT ON COLUMN` to carry `SHA-256(canonical body)` in a fixed form; verify reads it from `pg_description` and compares hashes, never the expression. Same trust model as ADR 244: a default edited by hand under an unchanged comment goes undetected. SQLite has no column comments, so it needs its own carrier, for example a row in the Prisma marker schema keyed by table and column. -2. **Record the hash in the marker schema on every target.** The migration runner writes one row per raw default (table, column, hash) into the marker schema the database already carries; verify compares those rows to the contract. Uniform across targets. Weaker than 1 on Postgres because the hash lives beside the column rather than on it, so a column dropped and recreated by hand leaves a stale row. -3. **Keep text comparison but make the parser complete.** Rejected in advance for the reason ADR 234 and ADR 244 record: matching the database's reprint needs a Postgres grammar in JavaScript. - -**Reopen when:** slice C is merged. +**Reopen when:** a user reports a raw default that verifies as drift when it is not. Not before. ## 2. Tagged literals for index expressions, check bodies, and RLS predicates diff --git a/projects/remove-dbgenerated/spec.md b/projects/remove-dbgenerated/spec.md index d618f3330c45..8b2760509f71 100644 --- a/projects/remove-dbgenerated/spec.md +++ b/projects/remove-dbgenerated/spec.md @@ -42,7 +42,7 @@ The canonicalized body is the expression. Nothing rewrites it at authoring time, ### D5. SQLite verifies defaults exactly the way Postgres does -Postgres runs its introspection parser over the expression in the contract before comparing it to the expression the database reports, so the two are compared in the same form. SQLite does the same with its own parser. Slice A specifies the SQLite hook. A better comparison for raw SQL defaults on every target is a separate decision, recorded in [`deferred.md`](deferred.md) item 1. +Postgres runs its introspection parser over the expression in the contract before comparing it to the expression the database reports, so the two are compared in the same form. SQLite does the same with its own parser. Slice A specifies the SQLite hook. The comparison itself is not changed in this project; see [`deferred.md`](deferred.md) item 1. ### D6. Named storage functions cover the common cases