Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
363c743
fix(extension-arktype-json): tolerate driver-pre-parsed jsonb in decode
jkomyno May 4, 2026
d3ddcca
fix(extension-arktype-json): register runtime codec metadata for cast…
jkomyno May 4, 2026
15ba130
fix(sql-runtime): preserve RUNTIME.JSON_SCHEMA_VALIDATION_FAILED thro…
jkomyno May 4, 2026
0cdb6a2
docs(adr-208, no-emit): mark parameterized FieldOutputType as not-yet…
jkomyno May 4, 2026
95548d1
chore(extension-arktype-json): pin arktype to a tilde range and docum…
jkomyno May 4, 2026
37fe383
fix(extension-arktype-json): hard-fail on rehydrated-expression mismatch
jkomyno May 4, 2026
812a182
refactor(extension-arktype-json): decouple emit codec stub from SQL f…
jkomyno May 4, 2026
38f4b96
fix(sql-runtime): preserve RUNTIME.JSON_SCHEMA_VALIDATION_FAILED thro…
jkomyno May 4, 2026
782ce8d
test(e2e): exercise arktype-json column round-trip end-to-end
jkomyno May 4, 2026
c6d5ac7
docs(extension-arktype-json): clarify when expression-divergence chec…
jkomyno May 4, 2026
99ff583
feat(framework, sql-runtime): add encodeIsParamsIndependent descripto…
jkomyno May 4, 2026
62a1c3d
fix(extension-arktype-json): drop encode-side schema validation; tole…
jkomyno May 4, 2026
b0935eb
docs(adr-208): align stale claims with shipped behavior
jkomyno May 4, 2026
192a4ed
chore(extension-pgvector): declare encodeIsParamsIndependent on vecto…
jkomyno May 4, 2026
96b668e
fix(e2e): drop the validate-on-read e2e test; keep happy-path round-trip
jkomyno May 4, 2026
6a2e33b
fix(sql-runtime): pass RUNTIME.{ENCODE,DECODE}_FAILED envelopes throu…
jkomyno May 4, 2026
89e3484
docs(e2e): fix arktype-json round-trip comment
jkomyno May 4, 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
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@ The same `vector(1536)` participates in four code paths. Each reads a different

### 2. No-emit type resolution

`@prisma-next/sql-contract-ts`'s `FieldOutputType<Definition, Model, Field>` follows `typeRef` through `storage.types`, then synthetically applies `CodecInstanceContext` to the column's `type` slot at the type level and reads the `Js` parameter off the resulting `Codec<…, Js>`. For `vector(1536)`, this produces `Vector<1536>` (literal `N` preserved through curried application). For non-parameterized columns (no `type` slot), it falls back to `CodecTypes[codecId]['output']`. Nullability is reattached uniformly.
> **Status — partial.** As shipped, `@prisma-next/sql-contract-ts`'s `FieldOutputType<Definition, Model, Field>` resolves through `CodecTypesFromDefinition[codecId]['output']` only. Parameterized columns therefore fall back to the codec's base output type in no-emit mode: `vector(1536)` resolves to `number[]` (the codec's base `output`) instead of `Vector<1536>`, and `arktypeJson(schema)` resolves to `unknown` because `@prisma-next/extension-arktype-json/codec-types` declares its `arktype/json@1` entry's `output` as `unknown`. Authors who skip `pnpm emit` get codec-base types, not the parameterized refinements emitted into `contract.d.ts`. Tracked under TML-2357.
>
> The shape described below is the eventual target: the `type: (ctx: CodecInstanceContext) => Codec<…, Js>` slot is already on `ColumnTypeDescriptor` (authoring-time only, never serialized) so `arktypeJson(schema).type` carries the inferred shape at the type level today. What's missing is the `FieldOutputType` resolver wiring that follows `typeRef` and applies the slot to read the `Js` parameter off the resulting codec.

`@prisma-next/sql-contract-ts`'s `FieldOutputType<Definition, Model, Field>` will follow `typeRef` through `storage.types`, then synthetically apply `CodecInstanceContext` to the column's `type` slot at the type level and read the `Js` parameter off the resulting `Codec<…, Js>`. For `vector(1536)`, this will produce `Vector<1536>` (literal `N` preserved through curried application). For non-parameterized columns (no `type` slot), it falls back to `CodecTypes[codecId]['output']` (today's behavior for every column). Nullability is reattached uniformly.

### 3. Emit-path rendering

Expand Down Expand Up @@ -151,7 +155,7 @@ Both problems share a root cause: the type-level facts about a parameterized col
### What works better

- **One artifact per codec.** The pack author writes one curried factory function and one descriptor. The descriptor's `renderOutputType` is the only piece the framework owns separately, and only because the emit path runs without the factory in scope.
- **Type fidelity end-to-end.** `vector(1536)` resolves to `Vector<1536>` at authoring time, in the no-emit path, in the emitted `contract.d.ts`, and at runtime decode. `arktypeJson(ProductSchema)` resolves to the schema's inferred output. Future column-scoped stateful codecs (e.g. encryption) resolve to their declared output even though the wire is ciphertext.
- **Type fidelity through the emit path.** `vector(1536)` resolves to `Vector<1536>` at authoring time and in the emitted `contract.d.ts`; `arktypeJson(ProductSchema)` resolves to the schema's inferred output in `contract.d.ts`. The no-emit path still resolves through the codec's base output type today (see § "No-emit type resolution" status block above) — `Vector<1536>` and the arktype schema's inferred shape land in `contract.d.ts` only after `pnpm emit`. Resolver wiring is tracked under TML-2357. Future column-scoped stateful codecs (e.g. encryption) inherit the same staging.
- **Non-branching descriptor reads.** `descriptorFor('pg/text@1').traits` and `descriptorFor('pg/vector@1').traits` use the same call shape. Non-parameterized codecs are the degenerate `P = void` case; consumers don't ask "is this codec parameterized" before reading metadata. The four sites that previously read traits via `context.codecs.traitsOf(codecId)` migrated to `context.codecDescriptors.descriptorFor(codecId).traits` without behavior change.
- **Framework-components stays library-agnostic.** `paramsSchema: StandardSchemaV1<P>` keeps arktype confined to the codec authors that opt into it; a future extension that prefers zod or valibot satisfies the same descriptor shape without `framework-components` depending on either library.
- **Forward-compat for column-scoped stateful codecs.** Column-scoped encryption and similar codecs author against `(params, ctx)` today using the same surface pack authors already adopted. The contract-load runtime materialization is a documented contract.
Expand All @@ -160,13 +164,13 @@ Both problems share a root cause: the type-level facts about a parameterized col

- **`ColumnTypeDescriptor` grew an authoring-time `type` slot.** The optional `type?: (ctx: CodecInstanceContext) => Codec` field is the price of letting the no-emit resolver read the factory's return type without reaching into the runtime codec registry. The slot is structurally optional, ignored by the IR serializer, and never appears in `contract.json`.
- **Per-library extensions own JSON-with-schema.** A schema-typed JSON column is not a postgres-adapter concept; it's a per-library concept. The cost is one more import for users who want a typed JSON column; the benefit is that each library ships a lossless pipeline rather than a generic Standard-Schema-driven shape that's lossy for narrowed types.
- **Encode-side `forCodecId` legacy fallback (carved out, AC-5-deferred).** `ParamRef` carries `codecId` but not `(table, column)` today, so encode-side dispatch consults `contractCodecs.forCodecId(codecId)` instead of `forColumn`. The fallback works for the parameterized codecs shipped at this ADR's landing because their encode is per-instance-stateless w.r.t. params (pgvector formats `[v1,v2,…]` regardless of declared length; arktype-json's encode is `JSON.stringify`). The carve-out is documented at the registry boundary in `relational-core/src/ast/codec-types.ts:101-129` and retires under TML-2357 once `ParamRef.refs` is threaded through column-bound construction sites.
- **Encode-side `forCodecId` legacy fallback (carved out, AC-5-deferred).** `ParamRef` carries `codecId` but not `(table, column)` today, so encode-side dispatch consults `contractCodecs.forCodecId(codecId)` instead of `forColumn`. The fallback only works when the parameterized codec's encode is per-instance-stateless w.r.t. params: pgvector formats `[v1,v2,…]` regardless of declared length; arktype-json's encode is `JSON.stringify` with **no schema check** (validation runs on decode only — see § Per-library JSON extensions). Codec descriptors that are encode-equivalent across params declare `encodeIsParamsIndependent: true`, which tells the runtime registry not to mark the codec id ambiguous when multiple distinct resolved instances share it (so a contract with two `arktypeJson(...)` columns or two `vector(N)` columns of different lengths can encode through `forCodecId` without rejection). The carve-out is documented at the registry boundary in `relational-core/src/ast/codec-types.ts:101-129` and retires under TML-2357 once `ParamRef.refs` is threaded through column-bound construction sites — at which point `encodeIsParamsIndependent` becomes vestigial.
- **Heterogeneous-`P` registry boundary.** `descriptorFor(codecId): CodecDescriptor<P>` is structurally heterogeneous across codec ids — `P` is `void` for `pg/text@1`, `{ length: number }` for `pg/vector@1`, `{ expression; jsonIr }` for `arktype/json@1`, etc. The registry's interface methods cannot be honestly typed at the registry level without `<any>` at the boundary; consumers narrow per codec id at the call site. A typed-dispatch / sealed-visitor refactor would eliminate the suppressions but is not in scope; the registry interface uses `CodecDescriptor<any>` with documented one-line rationale comments at the four production sites.
- **Emit-only `Codec` shim for per-library extensions.** The framework emitter consults a single codec-id-keyed `CodecLookup` to resolve `renderOutputType`. Per-library extensions whose codec instance is materialized through the descriptor's factory at runtime can't naturally participate in that lookup at emit time. The arktype-json package ships an emit-only `Codec` instance (`arktypeJsonEmitCodec`) carrying just `renderOutputType`; encode/decode are sentinels that throw if invoked. A future cleanup that routes the emit path through `descriptorFor` retires the shim — tracked under TML-2357.

### Per-library JSON extensions

`@prisma-next/extension-arktype-json` ships `arktypeJson(schema)`. The codec id (`arktype/json@1`) is library-bound, not target-bound. The factory eagerly serializes `schema.expression` (TypeScript-source-like rendering) and `schema.json` (arktype's internal IR) into `typeParams` at the column-author site; the descriptor's factory rehydrates via `ark.schema(typeParams.jsonIr)` and validates internally in `decode`. The emit-path renderer reads `expression` directly so `contract.d.ts` carries the schema's source-like rendering with full fidelity.
`@prisma-next/extension-arktype-json` ships `arktypeJson(schema)`. The codec id (`arktype/json@1`) is library-bound, not target-bound. The factory eagerly serializes `schema.expression` (TypeScript-source-like rendering) and `schema.json` (arktype's internal IR) into `typeParams` at the column-author site; the descriptor's factory rehydrates via `ark.schema(typeParams.jsonIr)`. The codec validates against the schema in `decode` (and in `decodeJson` for JsonValue payloads); `encode` is intentionally schema-independent (`JSON.stringify` only) — see the encode-fallback trade-off above. This matches the JSON-validator philosophy: payloads can come from any source (this writer, a previous schema version, a manual SQL `INSERT`), so validate when reading. The emit-path renderer reads `expression` directly so `contract.d.ts` carries the schema's source-like rendering with full fidelity.

The postgres adapter retains only the non-parameterized raw-JSON / raw-JSONB codecs (`pg/json@1`, `pg/jsonb@1`) — schema-typed JSON columns ship from extension packages. Future per-library extensions (`zod/json@1`, `valibot/json@1`) follow the same pattern when each library has a clean serialize / rehydrate story.

Expand All @@ -188,8 +192,8 @@ The intermediate `CodecParamsDescriptor<P>` type at the adapter compile-time bou

## Resolves

- **TML-2229.** `vector(1536)`, `arktypeJson(schema)`, and other parameterized columns resolve correctly in the no-emit path AND through the emit path (typeRef columns included, via `EmissionSpi.resolveFieldTypeParams`).
- **The deferred no-emit fix from [ADR 186](ADR%20186%20-%20Codec-dispatched%20type%20rendering.md).** The `renderOutputType` it introduced moves to its long-term home on the descriptor; the no-emit path now resolves through the factory's return type without consulting it.
- **TML-2229 (emit path).** `vector(1536)`, `arktypeJson(schema)`, and other parameterized columns resolve correctly through the emit path (typeRef columns included, via `EmissionSpi.resolveFieldTypeParams`). The no-emit equivalent — `FieldOutputType` walking `typeRef` and reading the column's authoring-time `type` slot — is **not** yet implemented; tracked under TML-2357 alongside the registration-side migration. See the partial-status block in § "No-emit type resolution".
- **The deferred no-emit `renderOutputType` placement from [ADR 186](ADR%20186%20-%20Codec-dispatched%20type%20rendering.md).** The renderer moves to its long-term home on the descriptor. The no-emit *consumption* of that renderer (resolving through the factory's return type at the type level) is the part that ships under TML-2357.

## References

Expand Down
4 changes: 3 additions & 1 deletion docs/architecture docs/subsystems/9. No-Emit Workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export const contract = defineContract({

#### Parameterized columns: `vector(1536)`, `arktypeJson(schema)`

For parameterized codecs, the column-author factory returns a `ColumnTypeDescriptor` with an authoring-time `type: (ctx: CodecInstanceContext) => Codec<…, S['infer']>` slot. The no-emit `FieldOutputType` resolver synthetically applies `CodecInstanceContext` at the type level and reads the `Js` parameter off the resulting `Codec<…, Js>`. This preserves literal `N` (e.g. `Vector<1536>`) and inferred schema outputs (e.g. arktype's narrowed types) end-to-end without an emit step. At runtime, the same factory the column author wrote is invoked with parameters round-tripped through the contract IR — there is no parallel runtime function and no opportunity for drift between the no-emit type and the runtime instance. See [ADR 208 — Higher-order codecs for parameterized types](../adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md).
> **Status — partial.** Parameterized columns currently resolve to their codec's base output type in no-emit mode (`vector(1536)` → `number[]`, `arktypeJson(schema)` → `unknown`). The `FieldOutputType` resolver wiring that walks `typeRef` and reads the `Js` parameter off the column's authoring-time `type` slot is not yet implemented; tracked under TML-2357. Until it lands, run `pnpm emit` to obtain the parameterized output types in `contract.d.ts`.

For parameterized codecs, the column-author factory returns a `ColumnTypeDescriptor` with an authoring-time `type: (ctx: CodecInstanceContext) => Codec<…, S['infer']>` slot. Once the resolver lands, the no-emit `FieldOutputType` will synthetically apply `CodecInstanceContext` at the type level and read the `Js` parameter off the resulting `Codec<…, Js>`. This preserves literal `N` (e.g. `Vector<1536>`) and inferred schema outputs (e.g. arktype's narrowed types) end-to-end without an emit step. At runtime, the same factory the column author wrote is invoked with parameters round-tripped through the contract IR — there is no parallel runtime function and no opportunity for drift between the no-emit type and the runtime instance. See [ADR 208 — Higher-order codecs for parameterized types](../adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md).

```ts
const contract = defineContract({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,27 @@ export interface CodecDescriptor<P = void> {
* with `ctx` carrying the column set the resulting codec serves.
*/
readonly factory: (params: P) => (ctx: CodecInstanceContext) => Codec;
/**
* Declares that the codec's `encode` produces structurally equivalent
* wire output regardless of `params` — i.e. picking any resolved
* instance of this codec id at the encode-side `forCodecId` lookup
* yields the same wire payload. Optional; defaults to `false`.
*
* When `true`, the runtime registry does NOT mark the codec id as
* ambiguous when multiple distinct resolved instances share it (e.g.
* two `arktypeJson(...)` columns with different schemas). The encode
* dispatch can pick any of the resolved instances safely; decode
* dispatch still uses `forColumn(table, column)` to get the
* instance-specific schema.
*
* This is the AC-5-deferred bridge for parameterized codecs whose
* encode is intrinsically per-call-stateless w.r.t. params (pgvector
* formats `[v1,v2,...]` regardless of dimension; arktype-json's
* encode is `JSON.stringify` with no schema check). Once
* `ParamRef.refs` plumbing lands (TML-2357 § AC-5), encode will use
* `forColumn` directly and this flag becomes vestigial.
*/
readonly encodeIsParamsIndependent?: boolean;
}

/**
Expand Down
16 changes: 16 additions & 0 deletions packages/2-sql/5-runtime/src/codecs/decoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,22 @@ async function decodeField(
try {
decoded = await codec.decode(wireValue, cellCtx);
} catch (error) {
// Pass-through stable runtime envelopes:
//
// - `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED`: per-library JSON-with-
// schema codecs (e.g. `arktype/json@1`) validate inside `decode`
// and throw the stable schema-failure code directly. ADR 208
// promises this code surfaces unchanged.
// - `RUNTIME.DECODE_FAILED`: a codec body that already constructed
// the wrapped envelope itself (carrying its own `details`/`cause`
// contract) must pass through, not be re-wrapped. This matches the
// "no double wrap" guarantee documented on `decodeRow` below.
//
// The post-decode `validateJsonValue` path below has the same
// schema-failure rethrow guard for the legacy validator-registry
// case.
if (isJsonSchemaValidationError(error)) throw error;
if (isRuntimeError(error) && error.code === 'RUNTIME.DECODE_FAILED') throw error;
wrapDecodeFailure(error, alias, ref, codec, wireValue);
}

Expand Down
22 changes: 22 additions & 0 deletions packages/2-sql/5-runtime/src/codecs/encoding.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
checkAborted,
isRuntimeError,
raceAgainstAbort,
runtimeError,
} from '@prisma-next/framework-components/runtime';
Expand Down Expand Up @@ -125,6 +126,27 @@ async function encodeParamValue(
try {
return await codec.encode(value, ctx);
} catch (error) {
// Pass-through stable runtime envelopes:
//
// - `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED`: per-library JSON-with-
// schema codecs validate inside `encode` (ADR 208 § Case J) and
// throw the stable schema-failure code directly. The unified codec
// descriptor model promises this code surfaces unchanged on both
// directions of the wire boundary.
// - `RUNTIME.ENCODE_FAILED`: a codec body that already constructed
// the wrapped envelope itself (carrying its own `details`/`cause`
// contract) must pass through, not be re-wrapped. This matches the
// "no double wrap" guarantee documented on `encodeParams` below.
//
// Anything else flows through `wrapEncodeFailure` to produce a
// canonical `RUNTIME.ENCODE_FAILED` envelope for un-stamped errors.
if (
isRuntimeError(error) &&
(error.code === 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED' ||
error.code === 'RUNTIME.ENCODE_FAILED')
) {
throw error;
}
wrapEncodeFailure(error, metadata, paramIndex, codec.id);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Expand Down
19 changes: 18 additions & 1 deletion packages/2-sql/5-runtime/src/sql-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,24 @@ function buildContractCodecRegistry(
if (existing === undefined) {
byCodecId.set(column.codecId, resolvedCodec);
} else if (existing !== resolvedCodec && parameterizedDescriptors.has(column.codecId)) {
ambiguousCodecIds.add(column.codecId);
// Two distinct resolved instances under the same parameterized
// codec id (e.g. `Vector<1024>` and `Vector<1536>`, or two
// `arktypeJson(...)` columns with different schemas). The
// encode-side `forCodecId` fallback can't honor a column-
// specific call site, so by default we mark the codec id
// ambiguous and reject the fallback.
//
// Opt-out: descriptors that declare `encodeIsParamsIndependent`
// produce wire-identical output across all resolved instances
// (pgvector formats `[v1,v2,…]` regardless of dimension;
// arktype-json's encode is `JSON.stringify` with no schema
// check). For those, picking any resolved instance at the
// encode call site is safe — decode dispatch still uses
// `forColumn` to get the instance-specific schema.
const parameterizedDescriptor = parameterizedDescriptors.get(column.codecId);
if (!parameterizedDescriptor?.encodeIsParamsIndependent) {
ambiguousCodecIds.add(column.codecId);
}
}
}
}
Expand Down
Loading
Loading