Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a393c9e
feat(framework): codecs own the PSL form of their literals
wmadden-electric Sep 16, 2026
1b0b7b8
docs(projects): slice B dispatch plan
wmadden-electric Sep 16, 2026
0b053dd
feat(postgres): Postgres and SQL base codecs own their PSL literal form
wmadden-electric Sep 16, 2026
daf2ffb
fix(postgres): float codecs write a non-finite value as the text Post…
wmadden-electric Sep 16, 2026
b33cfec
feat(codecs): every codec in the repository owns its PSL literal form
wmadden-electric Sep 16, 2026
ac39eb8
feat(contract-psl): the interpreter reads literal defaults through th…
wmadden-electric Sep 16, 2026
d1dddad
test(contract-psl): pin the PSL_INVALID_DEFAULT_LITERAL span to the @…
wmadden-electric Sep 16, 2026
7aa9a72
fix(postgres): pg/numeric@1 reads a string literal holding a decimal
wmadden-electric Sep 16, 2026
277783f
feat(contract-prisma7): the Prisma 7 source reads literal defaults th…
wmadden-electric Sep 16, 2026
e9c734d
feat(psl-infer): the infer printer prints literal defaults through th…
wmadden-electric Sep 16, 2026
ecd4de1
test(family-sql): name the string escaping case after the rule it checks
wmadden-electric Sep 16, 2026
2a05bc2
test(integration): journeys prove codec-owned PSL literal defaults en…
wmadden-electric Sep 16, 2026
da9340e
fix(language-server): the literal @default arm offers true and false …
wmadden-electric Sep 16, 2026
f8191a7
docs: codecs own the PSL form of their literals
wmadden-electric Sep 16, 2026
226e0e7
docs(adr-184): state the deferred DDL half inline and the decode erro…
wmadden-electric Sep 16, 2026
430bffc
docs(projects): slice B retro
wmadden-electric Sep 16, 2026
2e77566
test: read the literal-defaults table by its verbatim name
wmadden-electric Sep 17, 2026
30d77fb
fix(relational-core): sql/float@1 refuses a non-finite PSL value with…
wmadden-electric Sep 17, 2026
97a9a0c
docs(upgrade-instructions): extension fragment for the required codec…
wmadden-electric Sep 17, 2026
49b4bf7
docs(projects): slice B retro adds the CI-only checks the local gates…
wmadden-electric Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/architecture docs/ADR-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ This document provides a comprehensive index of all Architectural Decision Recor
| 157 | Execution enums | Defines execution-plane enum behavior derived from explicit storage enforcement; builds on ADR 155 and ADR 156 | [ADR 157 - Execution enums.md](adrs/ADR%20157%20-%20Execution%20enums.md) |
| 158 | Execution mutation defaults | Defines execution-plane mutation defaults (`execution.mutations.defaults`) and a section-owned hashing model to avoid marker churn | [ADR 158 - Execution mutation defaults.md](adrs/ADR%20158%20-%20Execution%20mutation%20defaults.md) |
| 168 | Postgres JSON and JSONB typed columns | Adds first-class PostgreSQL `json`/`jsonb` codec and column support with Standard Schema-based typed emission in `contract.d.ts` | [ADR 168 - Postgres JSON and JSONB typed columns.md](adrs/ADR%20168%20-%20Postgres%20JSON%20and%20JSONB%20typed%20columns.md) |
| 184 | Codec-owned value serialization | Codecs own every value boundary: `encodeJson`/`decodeJson` for contract JSON and `encodePsl`/`decodePsl` for PSL literals are required `Codec` members dispatched by codec id; DDL methods stay future work | [ADR 184 - Codec-owned value serialization.md](adrs/ADR%20184%20-%20Codec-owned%20value%20serialization.md) |
| 186 | Codec-dispatched type rendering | Codecs own TypeScript type rendering via `renderOutputType` and `FieldOutputTypes`; removes `EmissionSpi.generateModelsType?` override and legacy renderer infrastructure | [ADR 186 - Codec-dispatched type rendering.md](adrs/ADR%20186%20-%20Codec-dispatched%20type%20rendering.md) |
| 169 | Declared applicability for mutation default generators | Records the decision to validate generator/column compatibility via contributor-declared applicability and to assemble generator implementations via composed registries | [ADR 169 - Declared applicability for mutation default generators.md](adrs/ADR%20169%20-%20Declared%20applicability%20for%20mutation%20default%20generators.md) |
| 160 | Plan grouping keys for multi-statement orchestration | Adds `meta.groupingKey` to correlate multiple statement executions that serve one higher-level operation | [ADR 160 - Plan grouping keys for multi-statement orchestration.md](adrs/ADR%20160%20-%20Plan%20grouping%20keys%20for%20multi-statement%20orchestration.md) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,61 @@ ADR 167 proposed a standalone `DefaultLiteralCodec` interface, parallel to `Code

Rejected because this isn't a separate kind of codec — it's an extension of codec responsibilities. The codec already owns the type; value serialization is part of what owning a type means.

## Amendment — PSL literal methods live on `Codec` (2026-09-16)

`encodePsl` and `decodePsl` are required members of the `Codec` interface and abstract members of `CodecImpl`, next to `encode`, `decode`, `encodeJson`, and `decodeJson`. Every codec states the PSL literal that denotes its values; there is no default on the base class.

```ts
/** A PSL scalar literal as its content, with the fence removed and escapes resolved. */
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;
}

interface Codec<Id, TTraits, TWire, TInput> {
// ... encode, decode, encodeJson, decodeJson
encodePsl(value: TInput): PslLiteral;
decodePsl(literal: PslLiteral): TInput;
}
```

The codec never sees the fence, and a number's digits reach it verbatim: a big integer or a decimal is never converted to a JavaScript number before the codec reads it. The PSL interpreter passes each `@default` literal to the column codec's `decodePsl` and stores the result through `encodeJson`; a literal the codec refuses is the diagnostic `PSL_INVALID_DEFAULT_LITERAL`, carrying the codec's message. The `contract infer` printer calls `encodePsl` on the value `decodeJson` read from the contract and writes the literal with the fence and escapes added.

The `PslLiteralCodec` interface sketched above is not a separate entity and never was: it is the consumer's view of the same codec, the dependency inversion the "Single interface with all boundaries" alternative describes. That alternative is therefore no longer rejected for PSL. It stays rejected for DDL: `encodeDdl` and `decodeDdl` are not built. Until they are, a raw SQL default carries any value the JSON and PSL forms cannot express, and the Prisma 7 contract source keeps turning `Bytes` and `DateTime` literal defaults into raw SQL expressions, because verification cannot yet compare those as typed values.

### The rule for a codec's PSL form

One rule, applied to every codec, keyed on the JSON form `encodeJson` produces:

- A JSON string is a string literal holding that string: `{ kind: 'string', text }`.
- A JSON number is a number literal with no exponent: `{ kind: 'number', text }`, the exact decimal text of the value.
- A JSON boolean is a boolean literal.
- A JSON object, array, or null is a string literal holding the JSON text: `{ kind: 'string', text: JSON.stringify(json) }`, which `decodePsl` parses and hands to `decodeJson`. The JSON codecs, `arktype/json@1`, `pg/vector@1`, and the Mongo vector codec take this form.

The shared pairs in `@internal/framework-components/codec` implement the rule: `encodeStringPsl`/`decodeStringPsl`, `encodeNumberPsl`/`decodeNumberPsl`, `decodeWholeNumberPsl`, `encodeFloatPsl`/`decodeFloatPsl`, `encodeBooleanPsl`/`decodeBooleanPsl`, and `encodeJsonTextPsl`/`decodeJsonTextPsl`. Every decode error has one shape, `<codecId> reads <what it reads>; got <the literal it got>` (for example `pg/int4@1 reads a whole number literal; got a number 1.5`, `pg/float8@1 reads a number literal or "NaN", "Infinity", "-Infinity"; got a boolean true`, `pg/jsonb@1 reads a string literal holding JSON text; got a number 1`), so the interpreter's diagnostic names the codec.

```ts
class PgTextCodec extends CodecImpl<'pg/text@1', readonly ['equality', 'order', 'textual'], string, string> {
// ... encode, decode, encodeJson, decodeJson
encodePsl(value: string): PslLiteral {
return encodeStringPsl(value);
}
decodePsl(literal: PslLiteral): string {
return decodeStringPsl(this.id, literal);
}
}
```

The named exceptions, each the way PSL is already written:

- `pg/float4@1` and `pg/float8@1` write `NaN`, `Infinity`, and `-Infinity` as the quoted strings `"NaN"`, `"Infinity"`, `"-Infinity"`, because PSL has no number token for them, and read both that string and the number token the tokenizer produces for the bare text. Their JSON form and their wire form carry those three values as that text; finite values stay numbers.
- `pg/int8@1`, `pg/unboundedint@1`, and `pg/numeric@1` read the digits from `text` and print them as text, so every digit of a big integer or a decimal survives. `pg/numeric@1` canonicalises leading zeros and the sign of zero (`007` → `7`, `-0` → `0`, `-0.00` → `0.00`; trailing zeros are kept), writes the three special values as quoted strings, and also reads a quoted decimal string.
- The integer codecs (`pg/int4@1`, `pg/int2@1`, `pg/int8@1`, `pg/int8number@1`, `pg/unboundedint@1`, `sql/int@1`, `sqlite/integer@1`, `sqlite/bigint@1`, `sqlite/bigintnumber@1`, `mongo/int32@1`) reject a fraction: `pg/int4@1 reads a whole number literal; got a number 1.5`.
- `sql/float@1` and `sqlite/real@1` refuse non-finite values in PSL as they do in JSON.
- A codec whose JSON form is a string but whose value is not (`pg/bytea@1` and `sqlite/blob@1` as base64 or hex text, `pg/geometry@1` as HEXEWKB, `pg/interval@1` as an ISO duration, the Temporal codecs, and the `Date` codecs) uses the string rule and carries `encodeJson`/`decodeJson` through it: `encodePsl` writes `encodeJson(value)` as the string, `decodePsl` returns `decodeJson(text)`.
- The Mongo `mongoCodec({...})` factory takes `encodePsl` and `decodePsl` as required config members, so every Mongo codec declares its PSL form explicitly too.

## Supersedes

- **ADR 167 v2** (deferred codec-keyed `DefaultLiteralCodec` SPI) — this ADR generalizes and implements the concept. The v1 hardcoded pipeline is replaced.
Expand Down
37 changes: 35 additions & 2 deletions docs/reference/codec-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This guide describes the canonical authoring shape for codecs in Prisma 8: **cla

A codec is **three artifacts**:

1. A **codec class** that extends `CodecImpl<Id, TTraits, TWire, TInput>` and implements all four conversion methods: `encode`, `decode`, `encodeJson`, and `decodeJson`.
1. A **codec class** that extends `CodecImpl<Id, TTraits, TWire, TInput>` and implements all six conversion methods: `encode` and `decode` for the driver wire form, `encodeJson` and `decodeJson` for the JSON form stored in contract artifacts, and `encodePsl` and `decodePsl` for the PSL literal that denotes a value in schema source (`@default(...)`). The PSL pair is synchronous and required; the shared pairs in `@internal/framework-components/codec` (`encodeStringPsl`/`decodeStringPsl`, `encodeNumberPsl`/`decodeNumberPsl`, `decodeWholeNumberPsl`, `encodeFloatPsl`/`decodeFloatPsl`, `encodeBooleanPsl`/`decodeBooleanPsl`, `encodeJsonTextPsl`/`decodeJsonTextPsl`) cover the common shapes, and [ADR 184](../architecture%20docs/adrs/ADR%20184%20-%20Codec-owned%20value%20serialization.md) records the rule for which shape a codec takes.
2. A **descriptor class** that extends `CodecDescriptorImpl<P>` for a target-neutral codec, or the target-owned `PostgresCodecDescriptor<P>` / `SqliteCodecDescriptor<P>` for a target-bound SQL codec, and declares the codec id, traits, target types, params schema, and the curried factory that materializes codec instances.
3. A **per-codec column helper function** that calls `descriptor.factory(...)` directly and packages the result into a `ColumnSpec` via the framework-supplied `column(...)` packager. The helper carries a `satisfies ColumnHelperFor<D>` clause that ties it to its descriptor at compile time.

Expand All @@ -17,6 +17,8 @@ The framework imports live at `@internal/framework-components/codec`:
- `ColumnHelperFor<D>` / `ColumnHelperForStrict<D>` — `satisfies` shapes for per-codec helpers.
- `column(codecFactory, codecId, typeParams, nativeType)` — column-spec packager (`nativeType` is the database spelling for migrations and contract meta).
- `voidParamsSchema` — Standard Schema validator for `P = void` (non-parameterized codecs).
- `PslLiteral` — the `{ kind: 'string' | 'number' | 'boolean', text }` shape `encodePsl` returns and `decodePsl` receives, with the fence removed and escapes resolved.
- `encodeStringPsl`, `decodeStringPsl`, `encodeNumberPsl`, `decodeNumberPsl`, `decodeWholeNumberPsl`, `encodeFloatPsl`, `decodeFloatPsl`, `encodeBooleanPsl`, `decodeBooleanPsl`, `encodeJsonTextPsl`, `decodeJsonTextPsl` — the shared PSL pairs; each decode helper takes the codec id so its error names the codec.
- `Codec<...>`, `CodecDescriptor<P>`, `AnyCodecDescriptor` — consumer-facing interfaces (consumers depend on these; target-neutral authors extend the `*Impl` classes, while target-bound SQL authors use target-owned bases).

SQL codecs use the same framework `CodecImpl` base. Their `encodeJson` and `decodeJson` methods define the codec's JSON-safe contract representation; `decode` remains responsible for the driver's ordinary column wire value. Keep that representation stable and mutually consistent, and keep `decodeJson` compatible with the values the current SQL JSON renderer returns for the codec. This distinction matters for types such as PostgreSQL `bytea` and extension-defined types whose values inside database-produced JSON may differ from their normal driver representation.
Expand Down Expand Up @@ -56,6 +58,9 @@ import {
CodecImpl,
type ColumnHelperFor,
column,
decodeStringPsl,
encodeStringPsl,
type PslLiteral,
voidParamsSchema,
} from '@internal/framework-components/codec';
import type { ProjectionExpr } from '@internal/sql-relational-core/ast';
Expand All @@ -76,6 +81,8 @@ class PgTextCodec extends CodecImpl<
}
return json;
}
encodePsl(value: string): PslLiteral { return encodeStringPsl(value); }
decodePsl(literal: PslLiteral): string { return decodeStringPsl(this.id, literal); }
}

class PgTextDescriptor extends PostgresCodecDescriptor<void> {
Expand Down Expand Up @@ -104,6 +111,32 @@ text satisfies ColumnHelperFor<PgTextDescriptor>;

The factory is **constant**: every call returns the same shared codec instance. The runtime relies on this contract — non-parameterized columns sharing a codec id share one resolved codec without explicit caching.

The PSL pair is the string rule: a `pg/text@1` value is a JSON string, so it is written as a string literal (`@default("hello")`) and read back from one. `decodeStringPsl(this.id, literal)` throws `pg/text@1 reads a string literal; got a number 5` for any other literal kind, and the PSL interpreter turns that into `PSL_INVALID_DEFAULT_LITERAL` at the attribute.

#### A JSON-valued codec (`pg/jsonb@1`)

A codec whose JSON form is an object, array, or null takes the JSON-text rule: the PSL literal is a string holding the JSON text (`@default("{\"a\":1}")`, `@default("[1, 2]")`, `@default("null")`), which `decodePsl` parses and hands to `decodeJson`.

```ts
import {
decodeJsonTextPsl,
encodeJsonTextPsl,
} from '@internal/framework-components/codec';

class PgJsonbCodec extends CodecImpl<'pg/jsonb@1', readonly ['equality'], string | JsonValue, JsonValue> {
async encode(value: JsonValue, _ctx: CodecCallContext) { return JSON.stringify(value); }
async decode(wire: string | JsonValue, _ctx: CodecCallContext) {
return typeof wire === 'string' ? (JSON.parse(wire) as JsonValue) : wire;
}
encodeJson(value: JsonValue) { return value; }
decodeJson(json: JsonValue) { return json; }
encodePsl(value: JsonValue): PslLiteral { return encodeJsonTextPsl(value); }
decodePsl(literal: PslLiteral): JsonValue { return decodeJsonTextPsl(this.id, literal); }
}
```

A codec whose value is not a string but whose JSON form is one (`pg/bytea@1` as base64, the Temporal codecs) also writes a string literal, carrying `encodeJson`/`decodeJson` through it: `encodeStringPsl(this.encodeJson(value))` and `this.decodeJson(decodeStringPsl(this.id, literal))`.

### Case 2 — Parameterized codec with literal preservation (`pg/vector@1`)

```ts
Expand Down Expand Up @@ -475,7 +508,7 @@ The class hierarchy isn't load-bearing for variance preservation (per-codec help
- **`override` discipline.** With `noImplicitOverride`, every concrete-subclass member that touches an inherited member must carry `override`. Forgetting it surfaces as a typecheck error.
- **Don't widen the factory return at the descriptor.** Concrete descriptors should declare their factory's typed return (`(ctx) => VectorCodec<N>`, not `(ctx) => Codec<...>`). The widened return loses literal preservation at consumer sites.
- **Don't extract codec types via `Parameters` / `ReturnType` of the descriptor's `factory`.** TypeScript widens method generics to their constraint in those forms. Use the per-codec helper's typed return (`ColumnSpec<R, P>`) and project with `R extends Codec<any, any, any, infer T> ? T : never`.
- **Don't reach through the codec instance for metadata.** The runtime `Codec` instance is narrow (id + four conversion methods). Read traits / target types / meta from `descriptor` (e.g. `context.codecDescriptors.descriptorFor(codecId).traits`).
- **Don't reach through the codec instance for metadata.** The runtime `Codec` instance is narrow (id + six conversion methods). Read traits / target types / meta from `descriptor` (e.g. `context.codecDescriptors.descriptorFor(codecId).traits`).

## See also

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ The TypeScript contract module imports something outside the contract-source imp

### CONTRACT.SOURCE_DIAGNOSTIC

One finding a contract source reported with a code that is not yet dotted, such as the Prisma 8 PSL interpreter's `PSL_UNSUPPORTED_FIELD_TYPE` or a parser's `PSL_PARSE_ERROR`. This is its only producer case: it exists until those codes convert to dotted ones, and a source code that is already dotted, such as `PSL.PRISMA7_VIEW_UNSUPPORTED`, is reported under its own code instead. Never raised on its own; carried, one per such source diagnostic, in the `diagnostics` list of a `CONTRACT.SOURCE_LOAD_FAILED` error during `contract emit`, printed under it in the terminal and serialized as the envelope's `diagnostics` in JSON. `summary` is `<file>:<line>:<column> <source code>: <message>` (the location is omitted when the source gave none). `where` carries `path` and `line`. Payload: `code` (the source's own diagnostic code). Fix: edit the schema at each location the findings name, then run `prisma contract emit` again.
One finding a contract source reported with a code that is not yet dotted, such as the Prisma 8 PSL interpreter's `PSL_UNSUPPORTED_FIELD_TYPE` or `PSL_INVALID_DEFAULT_LITERAL`, or a parser's `PSL_PARSE_ERROR`. `PSL_INVALID_DEFAULT_LITERAL` is a `@default(...)` literal the column codec does not read; its message is `Field "<Model>.<field>": @default(<literal as written>) is not a value of <codecId>: <the codec's message>` (for example `pg/int4@1 reads a whole number literal; got a number 1.5`), and the fix is to write the literal in the form the codec reads: a whole number on an integer column, a string holding JSON text on a JSON column, a quoted `"NaN"` on a float column. This is its only producer case: it exists until those codes convert to dotted ones, and a source code that is already dotted, such as `PSL.PRISMA7_VIEW_UNSUPPORTED`, is reported under its own code instead. Never raised on its own; carried, one per such source diagnostic, in the `diagnostics` list of a `CONTRACT.SOURCE_LOAD_FAILED` error during `contract emit`, printed under it in the terminal and serialized as the envelope's `diagnostics` in JSON. `summary` is `<file>:<line>:<column> <source code>: <message>` (the location is omitted when the source gave none). `where` carries `path` and `line`. Payload: `code` (the source's own diagnostic code). Fix: edit the schema at each location the findings name, then run `prisma contract emit` again.

### CONTRACT.SOURCE_LOAD_FAILED

Expand Down Expand Up @@ -537,7 +537,7 @@ An attribute Prisma 7 for the target does not have, or one the source does not r

### PSL.PRISMA7_UNKNOWN_DEFAULT

A `@default` value the source cannot read: an unknown function, an enum member on a non-enum field or a non-member, a number with a fraction on an `Int` or `BigInt` field, a malformed JSON or base64 literal, or `dbgenerated()` with no expression on a required field. Use a literal, an enum member, or a supported function. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `<file>:<line>:<column> <message>`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.
A `@default` value the source cannot read: an unknown function, an enum member on a non-enum field or a non-member, a literal the column codec does not read (reported as `Field "<Model>.<field>": @default(<literal>) is not a value of <codecId>: <the codec's message>`, for example a number with a fraction on an `Int` or `BigInt` field, a number on a `String`, `Bytes`, `DateTime`, or `Boolean` field, or a string that is not JSON on a `Json` field), or `dbgenerated()` with no expression on a required field. Use a literal the codec reads, an enum member, or a supported function. Reported by the Prisma 7 contract source (`prisma7Schema`) during `contract emit`, as a finding in the `diagnostics` list of `CONTRACT.SOURCE_LOAD_FAILED`, never on its own. `summary` is `<file>:<line>:<column> <message>`, with only the file when there is no position (the terminal prints the code before it), and `where` carries `path` and, when known, `line`. Payload: none.

### PSL.PRISMA7_UNSUPPORTED_TYPE

Expand Down
Loading
Loading