diff --git a/.agents/rules/jsdoc-line-width.mdc b/.agents/rules/jsdoc-line-width.mdc new file mode 100644 index 0000000000..95c1c831f1 --- /dev/null +++ b/.agents/rules/jsdoc-line-width.mdc @@ -0,0 +1,24 @@ +--- +description: Wrap JSDoc / block-comment prose at the same line width as the biome formatter (currently 100). Markdown is exempt — see the markdown-no-artificial-line-wraps skill. +alwaysApply: false +--- + +# JSDoc / block-comment prose — wrap at the biome line width + +When adding or editing **prose** in `/** ... */` or long `//` documentation: + +1. **Wrap at biome's `lineWidth`** — Wrap prose at the line width configured in `biome.jsonc` (`formatter.lineWidth`, currently **100**). Source of truth: read `biome.jsonc` rather than hard-coding the number; if it changes, this rule changes with it. + +2. **No fixed-column wraps below the biome width** — Do not break mid-sentence to stay under ~72 or ~80 characters. Editors soft-wrap; arbitrary narrow wraps make diffs noisy and conflict-prone. + +3. **Bullet lists and tagged blocks** + - **Bullets** (`*` / `-`): keep each bullet's prose on one line up to the biome width before wrapping; wrap continuation lines aligned with the bullet text. + - **Tagged blocks** (`@param`, `@returns`, `@example`): follow normal JSDoc conventions; the same line-width rule applies to the sentence text. + +4. **Orphaned blocks** — A doc comment must document the declaration that immediately follows it (after optional blank lines only before `import` / file headers). Do not leave a standalone `/** ... */` between imports and the next export with no attached symbol; merge into the relevant declaration's doc or delete redundancy. + +5. **Markdown is exempt** — Do not apply this rule to `.md` files (READMEs, ADRs, PR bodies, rulecards, and any other Markdown). Markdown prose stays on one continuous line per paragraph; see `.agents/skills/markdown-no-artificial-line-wraps/SKILL.md`. + +6. **Rationale** — Wrapping at the same width that the formatter uses keeps doc-comment prose visually consistent with surrounding code, avoids both "one giant line" and "narrow 1990s terminal" extremes, and produces stable diffs because the wrap width is anchored to the formatter rather than picked per-author. + +Related: `.agents/skills/markdown-no-artificial-line-wraps/SKILL.md` (Markdown files). diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md index e4bbd80000..0d8673648d 100644 --- a/.cursor/rules/README.md +++ b/.cursor/rules/README.md @@ -75,6 +75,7 @@ Thresholds are defined in `.cursor/rules-footprint.config.json`. ## TypeScript & Typing - `.cursor/rules/typescript-patterns.mdc` — TS patterns index (short) +- `.agents/rules/jsdoc-no-artificial-line-wraps.mdc` — JSDoc prose: no manual ~80-column wraps; avoid orphaned doc blocks - `.cursor/rules/generic-parameters.mdc` — Generic parameter defaults - `.cursor/rules/interface-factory-pattern.mdc` — Interface-based design + factories - `.cursor/rules/type-predicates.mdc` — Replace blind casts with type predicates diff --git a/.vscode/settings.json b/.vscode/settings.json index fad3077d66..19178fb0c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "cSpell.words": ["codegen", "Lowerer", "pgvector"], + "cSpell.words": ["arktype", "codegen", "Lowerer", "pgvector"], "editor.defaultFormatter": "biomejs.biome", "typescript.tsdk": "node_modules/typescript/lib", "[typescript]": { diff --git a/docs/README.md b/docs/README.md index 0993afb03b..cf8707f0c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ This directory contains the primary documentation for the repository. - [Glossary](./glossary.md) — user-facing terminology (source of truth for naming) - [Commands](./commands/README.md) — command docs and entry points - [Reference docs](./reference/) — conventions and patterns used across the codebase +- [Codec authoring guide](./reference/codec-authoring-guide.md) — class-based codecs (`CodecImpl`, `CodecDescriptorImpl`) and column helpers - [CLI Style Guide](./CLI%20Style%20Guide.md) — CLI UX conventions ## Working with AI agents diff --git a/docs/Testing Guide.md b/docs/Testing Guide.md index 16fc7be8f6..64e94aaec4 100644 --- a/docs/Testing Guide.md +++ b/docs/Testing Guide.md @@ -297,7 +297,7 @@ function createStubAdapter(): Adapter, Lowere target: 'postgres', targetFamily: 'sql', capabilities: {}, - codecs: createCodecRegistry(), + codecs: emptyCodecRegistry(), }, lower: () => ({ sql: '', params: [] }), }; @@ -330,10 +330,7 @@ Low-level helper tests (for IR builders, planners, CLI glue, etc.) should prove ### File Organization -**Unit tests:** `src/**/*.test.ts` (alongside source code) -**Integration tests:** `test/**/*.integration.test.ts` or `src/**/*.integration.test.ts` -**Type tests:** `src/**/*.test-d.ts` (type-level tests using `expectTypeOf`) -**E2E tests:** `test/e2e/framework/test/**/*.test.ts` +**Unit tests:** `src/**/*.test.ts` (alongside source code) **Integration tests:** `test/**/*.integration.test.ts` or `src/**/*.integration.test.ts` **Type tests:** `src/**/*.test-d.ts` (type-level tests using `expectTypeOf`) **E2E tests:** `test/e2e/framework/test/**/*.test.ts` ### Test File Structure @@ -969,9 +966,7 @@ it('reads user', async () => { ### 6. Use Appropriate Test Level -**Unit test:** Test a single function in isolation -**Integration test:** Test multiple components working together -**E2E test:** Test complete execution path to database and back +**Unit test:** Test a single function in isolation **Integration test:** Test multiple components working together **E2E test:** Test complete execution path to database and back **When in doubt:** Start with a unit test. If you need to test interactions, create an integration test. If you need to test the complete flow, create an E2E test. diff --git a/docs/architecture docs/adrs/ADR 152 - Execution Plane Descriptors and Instances.md b/docs/architecture docs/adrs/ADR 152 - Execution Plane Descriptors and Instances.md index 4eaf5b5c49..dd72479d3c 100644 --- a/docs/architecture docs/adrs/ADR 152 - Execution Plane Descriptors and Instances.md +++ b/docs/architecture docs/adrs/ADR 152 - Execution Plane Descriptors and Instances.md @@ -164,9 +164,8 @@ The SQL family extends base execution-plane descriptors with `SqlStaticContribut ```ts interface SqlStaticContributions { - codecs(): CodecRegistry + codecs(): ReadonlyArray operationSignatures(): ReadonlyArray - parameterizedCodecs(): ReadonlyArray } ``` diff --git a/docs/architecture docs/adrs/ADR 180 - Dot-path field accessor.md b/docs/architecture docs/adrs/ADR 180 - Dot-path field accessor.md index dbb3e24985..e241844810 100644 --- a/docs/architecture docs/adrs/ADR 180 - Dot-path field accessor.md +++ b/docs/architecture docs/adrs/ADR 180 - Dot-path field accessor.md @@ -1,6 +1,6 @@ # ADR 180 — Dot-path field accessor -> **Implementation update (Mongo query builder unification).** The consolidated `FieldAccessor` shipped in `@prisma-next/mongo-query-builder` replaced the earlier `FieldProxy` and `FilterProxy` types — filter and update operators now hang off a single accessor, used by both read callbacks (`match`, `addFields`, `project`, `group`) and write callbacks (`updateMany`, `findOneAndUpdate`, etc.). Type-safe dot-path validation for the callable form `f("address.city")` was implemented in [TML-2281](https://linear.app/prisma-company/issue/TML-2281): a second generic `N extends NestedDocShape` threads the contract's model + value-object structure through the pipeline, paths are constrained by `ValidPaths`, the resolved leaf's codec drives the returned `Expression`, and non-leaf paths surface a reduced `ObjectExpression` operator surface. Additive pipeline stages preserve `N`; replacement stages (`project`, `group`, `replaceRoot`, …) reset it, disabling the callable form downstream. For paths that are intentionally outside the typed model (canonically, migration authoring where a backfill writes a field that is not yet in the contract), `f.rawPath("path")` is the sanctioned escape hatch — it returns a `LeafExpression` with the verbatim path and the full leaf operator surface. The method is named `rawPath` rather than `raw` so the escape hatch does not shadow a legitimate top-level `raw` field on a user model (the callable fallback `f("raw")` is disabled downstream of replacement stages, which would otherwise leave such a field inaccessible). +> **Implementation update (Mongo query builder unification).** The consolidated `FieldAccessor` shipped in `@prisma-next/mongo-query-builder` replaced the earlier `FieldProxy` and `FilterProxy` types — filter and update operators now hang off a single accessor, used by both read callbacks (`match`, `addFields`, `project`, `group`) and write callbacks (`updateMany`, `findOneAndUpdate`, etc.). Type-safe dot-path validation for the callable form `f("address.city")` added a second generic `N extends NestedDocShape` that threads the contract's model + value-object structure through the pipeline, constrains paths by `ValidPaths`, drives the returned `Expression` from the resolved leaf's codec, and surfaces a reduced `ObjectExpression` operator surface for non-leaf paths. Additive pipeline stages preserve `N`; replacement stages (`project`, `group`, `replaceRoot`, …) reset it, disabling the callable form downstream. For paths that are intentionally outside the typed model (canonically, migration authoring where a backfill writes a field that is not yet in the contract), `f.rawPath("path")` is the sanctioned escape hatch — it returns a `LeafExpression` with the verbatim path and the full leaf operator surface. The method is named `rawPath` rather than `raw` so the escape hatch does not shadow a legitimate top-level `raw` field on a user model (the callable fallback `f("raw")` is disabled downstream of replacement stages, which would otherwise leave such a field inaccessible). ## At a glance diff --git a/docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md b/docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md index 216cc23ae9..401a1e4a0e 100644 --- a/docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md +++ b/docs/architecture docs/adrs/ADR 184 - Codec-owned value serialization.md @@ -1,11 +1,13 @@ # ADR 184 — Codec-owned value serialization +> **Retrospective note.** This ADR's examples use the `defineCodec({...})` factory. That factory was the canonical codec-author surface at the time; it was later retired in favor of class-based authoring: concrete codecs extend `CodecImpl`, descriptors extend `CodecDescriptorImpl`, and per-codec column helpers tie helpers to descriptors with `satisfies`. The ADR's *decision* — that codecs own both wire and JSON-safe representations through `encode` / `decode` + `encodeJson` / `decodeJson` — is unchanged; only the authoring shape has moved on. See [ADR 208 — Higher-order codecs for parameterized types](ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) and the [Codec authoring guide](../../reference/codec-authoring-guide.md) for the current shape. + ## At a glance A column with `codecId: "pg/timestamptz@1"` has a default value of `new Date('2024-01-15')` — a JavaScript `Date`. This value has to survive a round-trip through `contract.json`, but `Date` has no JSON representation. The codec handles it: ```ts -const pgTimestamptzCodec = codec({ +const pgTimestamptzCodec = defineCodec({ typeId: 'pg/timestamptz@1', targetTypes: ['timestamptz'], traits: ['equality', 'order'], @@ -42,7 +44,7 @@ The resulting contract JSON is plain — no tags, no wrappers: The consumer reads `"2024-01-15T00:00:00.000Z"`, looks up `pg/timestamptz@1`, calls `decodeJson(...)`, gets a `Date` object. -Every codec has `encodeJson` and `decodeJson`. For JSON-safe types (strings, numbers, booleans, null), they are identity functions — the `codec()` factory provides these defaults. Only codecs for types that JSON can't represent (`Date`, binary data, etc.) override them. +Every codec has `encodeJson` and `decodeJson`. For JSON-safe types (strings, numbers, booleans, null), they are identity functions — the `defineCodec()` factory provides these defaults. Only codecs for types that JSON can't represent (`Date`, binary data, etc.) override them. The same typed value crosses other boundaries too. The migration planner renders it into DDL (`DEFAULT '2024-01-15T00:00:00.000Z'`). The PSL printer renders it into schema source (`@default("2024-01-15T00:00:00.000Z")`). Migration operations carry it in `ops.json`. These are the same problem for different media, but they live at different layers: @@ -119,7 +121,7 @@ Both SQL and Mongo families define structurally identical codec interfaces (`Cod `encodeJson` and `decodeJson` are required on the `Codec` interface, not optional. Any type that can appear in the contract may need a literal value serialized for it (column defaults, discriminator values, type parameters, migration temporary defaults). Making the methods required eliminates null checks at every dispatch site. -For JSON-safe types (strings, numbers, booleans, null), the methods are identity functions. The `codec()` factory provides these defaults when not explicitly supplied, so codecs for JSON-safe types need no additional boilerplate. +For JSON-safe types (strings, numbers, booleans, null), the methods are identity functions. The `defineCodec()` factory provides these defaults when not explicitly supplied, so codecs for JSON-safe types need no additional boilerplate. ### Contract loading integrates decoding diff --git a/docs/architecture docs/adrs/ADR 186 - Codec-dispatched type rendering.md b/docs/architecture docs/adrs/ADR 186 - Codec-dispatched type rendering.md index 5b2eff7f02..c0c51237ec 100644 --- a/docs/architecture docs/adrs/ADR 186 - Codec-dispatched type rendering.md +++ b/docs/architecture docs/adrs/ADR 186 - Codec-dispatched type rendering.md @@ -1,5 +1,7 @@ # ADR 186 — Codec-dispatched type rendering +> **Retrospective note.** This ADR introduced the `renderOutputType` slot on the codec record (and shows `defineCodec({...})` examples). Both the codec authoring shape and the home of `renderOutputType` have since moved on: [ADR 208](ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) relocated `renderOutputType` to the unified `CodecDescriptor`, and the `defineCodec({...})` factory was retired in favor of class-based descriptors (`CodecDescriptorImpl`) and codecs (`CodecImpl`). The decision this ADR records — that the codec is the dispatch authority for type rendering — is unchanged; only the slot's home and the authoring syntax have moved. See [Codec authoring guide](../../reference/codec-authoring-guide.md). + ## At a glance A `vector(1536)` column should produce `Vector<1536>` as its output type. A `jsonb(schema)` column should produce `{ name: string }`. Today, resolving a field's output type requires dispatching through `CodecTypes[codecId]['output']` or `parameterizedOutput` — a hoop that varies depending on whether the codec is parameterized. After this change, every field's output type is resolved once and stamped into a dedicated map in `contract.d.ts`: @@ -124,7 +126,7 @@ Non-parameterized codecs don't need to implement it. The common case is zero cod Here's what the JSONB codec looks like with `renderOutputType`: ```ts -const pgJsonbCodec = codec({ +const pgJsonbCodec = defineCodec({ typeId: 'pg/jsonb@1', targetTypes: ['jsonb'], encode: (value): string => JSON.stringify(value), @@ -210,7 +212,7 @@ Rejected because phantom types are gross, and mixing type-only metadata into the ### Make `renderOutputType` required with a default -Like `encodeJson`/`decodeJson` on [ADR 184](ADR%20184%20-%20Codec-owned%20value%20serialization.md), make it required with a default provided by the `codec()` factory. +Like `encodeJson`/`decodeJson` on [ADR 184](ADR%20184%20-%20Codec-owned%20value%20serialization.md), make it required with a default provided by the `defineCodec()` factory. Deferred. Most codecs don't parameterize their output type — the default (`CodecTypes[codecId]['output']`) handles them. Optional with a well-defined fallback is cleaner for now. diff --git a/docs/architecture docs/adrs/ADR 202 - Codec trait system.md b/docs/architecture docs/adrs/ADR 202 - Codec trait system.md index 8d7e7dfdd2..516fdf78b4 100644 --- a/docs/architecture docs/adrs/ADR 202 - Codec trait system.md +++ b/docs/architecture docs/adrs/ADR 202 - Codec trait system.md @@ -1,5 +1,7 @@ # ADR 202 — Codec trait system +> **Retrospective note.** This ADR's examples show codec definitions via `defineCodec({...})`. That factory was retired in favor of class-based descriptors (`CodecDescriptorImpl`); traits are now declared as `override readonly traits = [...] as const` on the descriptor class. The `'json-validator'` trait this ADR introduced was also retired — JSON-Schema validation now lives uniformly inside the resolved codec's `decode` body rather than behind a parallel `JsonSchemaValidatorRegistry`. The trait system itself, the operator-gating semantics, and the canonical traits set are all unchanged. See [ADR 208](ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) and the [Codec authoring guide](../../reference/codec-authoring-guide.md). + ## Context Data types in the system are identified by codec IDs (e.g., `pg/int4@1`, `pg/text@1`, `pg/vector@1`). Query surfaces need to know which operators and functions are valid for a given data type — for example, ordering a `jsonb` column with `lt` or applying `sum` to a `text` column are not meaningful SQL. Today there is no generic mechanism to express these semantic constraints. @@ -63,7 +65,7 @@ Traits are declared at codec registration time. Core SQL codecs and adapter code ```ts // sql-codecs.ts -const sqlIntCodec = codec({ +const sqlIntCodec = defineCodec({ typeId: SQL_INT_CODEC_ID, targetTypes: ['int'], traits: ['equality', 'order', 'numeric'], @@ -74,7 +76,7 @@ const sqlIntCodec = codec({ ```ts // postgres adapter codecs -const pgTextCodec = codec({ +const pgTextCodec = defineCodec({ typeId: 'pg/text@1', targetTypes: ['text'], traits: ['equality', 'order', 'textual'], @@ -82,7 +84,7 @@ const pgTextCodec = codec({ decode: (wire) => wire, }); -const pgBoolCodec = codec({ +const pgBoolCodec = defineCodec({ typeId: 'pg/bool@1', targetTypes: ['bool'], traits: ['equality', 'boolean'], @@ -90,7 +92,7 @@ const pgBoolCodec = codec({ decode: (wire) => wire, }); -const pgJsonbCodec = codec({ +const pgJsonbCodec = defineCodec({ typeId: 'pg/jsonb@1', targetTypes: ['jsonb'], traits: ['equality'], // equality only; not order-comparable @@ -103,7 +105,7 @@ Extension codecs declare traits the same way: ```ts // pgvector codec — vectors have equality but are not order-comparable or numeric -const pgVectorCodec = codec({ +const pgVectorCodec = defineCodec({ typeId: 'pg/vector@1', targetTypes: ['vector'], traits: ['equality'], diff --git a/docs/architecture docs/adrs/ADR 204 - Single-Path Async Codec Runtime.md b/docs/architecture docs/adrs/ADR 204 - Single-Path Async Codec Runtime.md index be8128d409..1e0c2e682b 100644 --- a/docs/architecture docs/adrs/ADR 204 - Single-Path Async Codec Runtime.md +++ b/docs/architecture docs/adrs/ADR 204 - Single-Path Async Codec Runtime.md @@ -1,5 +1,7 @@ # ADR 204 — Single-Path Async Codec Runtime +> **Retrospective note.** This ADR documents the single-path async codec runtime through the `defineCodec({...})` / `mongoCodec({...})` factories of the time. The `defineCodec({...})` factory was later retired in favor of class-based codecs extending `CodecImpl` with `async encode` / `async decode` methods. The decision this ADR records — that `encode` and `decode` are uniformly Promise-returning at the public boundary, and the runtime always awaits — is unchanged. The sync-lifting that the factory used to perform is now a property of the abstract base class's method signatures: subclasses author `async` methods directly, and synchronous bodies are returned as resolved promises by the engine without an explicit lift step. See [ADR 208](ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) and the [Codec authoring guide](../../reference/codec-authoring-guide.md). + ## Context Codecs are pure value transformers that bridge contract-typed JS values and a target's wire types: SQL parameter bytes, Postgres OID-tagged literals, MongoDB BSON shapes, and so on. Until this ADR, every codec method ran on the call stack of the query that invoked it. Rows were assembled from plain values; `encodeParams` and `decodeRow` were synchronous loops; build-time helpers (`encodeJson`, `decodeJson`, `renderOutputType`) were synchronous as well. This was a clean, fast shape for the common case (in-process scalar transforms) but blocked a small but real class of codecs that need asynchronous work: KMS-resolved encryption keys, externally-resolved secrets, deferred reference lookups, secret rotation. @@ -18,13 +20,13 @@ That direction was rejected during architectural review. The salient critique: - **The cost lived in the wrong place.** A property of two codec methods became a property of the whole interface, the ORM type system, and every codec author's mental model. - **There was nowhere to walk back to.** Once `runtime: 'async'` and conditional return types are public, removing them is a breaking change. The optimization (sync fast-path) and the public surface were tangled. - **The sync/async split was the wrong seam.** The actual structural seam is between **query-time methods** (per-row, IO-relevant: `encode`, `decode`) and **build-time methods** (per-contract-load, sync: `encodeJson`, `decodeJson`, `renderOutputType`). -- **Cross-family portability was harder, not easier.** Mongo and SQL would each need to interpret the marker; a single `codec({...})` value couldn't be reused without re-typing. +- **Cross-family portability was harder, not easier.** Mongo and SQL would each need to interpret the marker; a single `defineCodec({...})` value couldn't be reused without re-typing. This ADR replaces that direction with a **single-path** design that localizes the cost of supporting both sync and async authoring to one place — the runtime's two codec invocation loops — and leaves the public interface uniform, the ORM type surfaces single-ended, and the build-time path synchronous. ## Decision -The runtime treats codec query-time methods as **uniformly Promise-returning** at the public interface and **always awaits** them. Codec authors may write either sync or async functions; the `codec()` factory transparently lifts sync ones to Promise-returning methods. Build-time methods stay synchronous. +The runtime treats codec query-time methods as **uniformly Promise-returning** at the public interface and **always awaits** them. Codec authors may write either sync or async functions; the `defineCodec()` factory transparently lifts sync ones to Promise-returning methods. Build-time methods stay synchronous. Concretely: @@ -36,7 +38,7 @@ Concretely: - `renderOutputType?(typeParams): string | undefined` — build-time, optional, synchronous. - **No** `runtime` / `kind` / equivalent marker. **No** `TRuntime` generic. **No** conditional return types. -2. **Single factory.** `codec()` (in `relational-core`) and its cross-family analog `mongoCodec()` (in `mongo-codec`) accept `encode` and `decode` in either sync or async form. Sync functions are wrapped to return `Promise.resolve(...)`; async functions pass through unchanged. The constructed `Codec` value always exposes Promise-returning query-time methods, regardless of how it was authored. `encode` may be omitted (identity default); `decode` is required. +2. **Single factory.** `defineCodec()` (in `relational-core`) and its cross-family analog `mongoCodec()` (in `mongo-codec`) accept `encode` and `decode` in either sync or async form. Sync functions are wrapped to return `Promise.resolve(...)`; async functions pass through unchanged. The constructed `Codec` value always exposes Promise-returning query-time methods, regardless of how it was authored. `encode` may be omitted (identity default); `decode` is required. 3. **Runtime always awaits.** - `encodeParams` is `async` and dispatches all parameter codec calls concurrently via `Promise.all`. @@ -48,7 +50,7 @@ Concretely: 5. **ORM client type surfaces are uniform.** `DefaultModelRow` / `InferRootRow` field types are plain `T`. Write surfaces (`MutationUpdateInput`, `CreateInput`, `UniqueConstraintCriterion`, `ShorthandWhereFilter`, `DefaultModelInputRow`) accept plain `T`. Read and write share **one** field type-map. -6. **Cross-family portability.** The `Codec` interface in SQL (`framework-components` / `relational-core`) and Mongo (`mongo-codec`) is structurally identical: same generics, same Promise-returning query-time methods, same synchronous build-time methods. A single `codec({...})` value is structurally usable in both runtimes. +6. **Cross-family portability.** The `Codec` interface in SQL (`framework-components` / `relational-core`) and Mongo (`mongo-codec`) is structurally identical: same generics, same Promise-returning query-time methods, same synchronous build-time methods. A single `defineCodec({...})` value is structurally usable in both runtimes. ## Architecture @@ -78,13 +80,13 @@ interface Codec< The interface uses the same four generics across SQL and Mongo (`Id`, `TTraits`, `TWire`, `TInput`). `decode` returns `Promise`: a codec's decoded value is the same JS-side type its `encode` accepts, so a single round-trip type variable `TInput` is sufficient. There is no `TOutput`. There is no `TRuntime`. There is no `kind` discriminant. There is no conditional return type. -### `codec()` / `mongoCodec()` factories +### `defineCodec()` / `mongoCodec()` factories The factory is the **only** place where author-side sync/async authoring is observable. It accepts each query-time method in either form and constructs a `Codec` whose query-time methods always return Promises: ```ts // Sync authoring — works exactly the same end-to-end. -const textCodec = codec({ +const textCodec = defineCodec({ typeId: 'pg/text@1', targetTypes: ['text'], encode: (v: string) => v, @@ -94,7 +96,7 @@ const textCodec = codec({ }); // Async authoring — same factory, same shape. -const secretCodec = codec({ +const secretCodec = defineCodec({ typeId: 'pg/secret@1', targetTypes: ['text'], encode: async (v: string) => encrypt(v, await getKey()), @@ -141,12 +143,12 @@ async function decodeField(wire, codec, schema) { Mongo gets the same `Codec` shape and the same encode-side always-await pattern: - `MongoCodec` is structurally identical to the SQL `Codec` (same four generics, same Promise-returning query-time methods, same synchronous build-time methods). -- `mongoCodec()` is the cross-family analog of `codec()` and lifts sync authoring identically. +- `mongoCodec()` is the cross-family analog of `defineCodec()` and lifts sync authoring identically. - `resolveValue` is `async` and dispatches codec-encoded leaves concurrently via `Promise.all` when a value tree carries multiple of them. - `MongoAdapter.lower()` is `async`; `MongoAdapter` in `mongo-lowering` reflects this. - `MongoRuntime.execute()` awaits `adapter.lower(plan)` before issuing the wire command. -A single `codec({...})` module is structurally usable in both SQL and Mongo runtimes; a portability test exercises this. +A single `defineCodec({...})` module is structurally usable in both SQL and Mongo runtimes; a portability test exercises this. ### Error envelopes (unchanged) @@ -160,14 +162,14 @@ Encode and decode failures continue to be wrapped in the standard envelope shape A synchronous fast-path for sustained-throughput workloads is a future, **additive**, opt-in change — not a constraint on the public interface today. The walk-back path is: -- A new `codecSync()` factory (in addition to, not replacing, `codec()`) that constructs a codec whose query-time methods are typed as synchronous returns at the public boundary. +- A new `codecSync()` factory (in addition to, not replacing, `defineCodec()`) that constructs a codec whose query-time methods are typed as synchronous returns at the public boundary. - Predicates `isSyncEncoder(codec)` / `isSyncDecoder(codec)` that the runtime can call to take a faster, fully-sync encode/decode path that skips the `Promise.all` allocation and microtask hop. - The runtime gains a sync fast-path arm; the existing async path remains, unchanged, for codecs that opt in to async or that don't opt in to sync. For this opt-in to land cleanly, the design today must **not** introduce any of the following walk-back constraints: 1. A sync/async marker on the public `Codec` interface (no `runtime`, `kind`, or equivalent field). -2. Multiple factory variants (`codecSync` / `codecAsync`) — there is **one** factory, `codec()`, today; `codecSync()` is the future additive opt-in. +2. Multiple factory variants (`codecSync` / `codecAsync`) — there is **one** factory, `defineCodec()`, today; `codecSync()` is the future additive opt-in. 3. Exported sync-vs-async predicates. 4. Conditional return types tied to async-ness on the public interface. 5. A `TRuntime` generic on `Codec`. @@ -201,7 +203,7 @@ Codec authors write either sync or async query-time functions, with no annotatio ### Cleartext leakage via error envelopes -The expanded encrypting-codec surface means encode/decode failures can carry plaintext into `error.message`, `details.errors`, or other envelope fields. The redaction policy (cause routing, bounded `wirePreview`, validator-message redaction trigger) is preserved unchanged from the prior runtime, but trait-gated redaction (e.g. omitting `wirePreview` and scrubbing `cause` for codecs that carry the `secret` trait) is **not** spelled in this ADR. Tracked as a follow-up; see [TML-2329](https://linear.app/prisma-company/issue/TML-2329). The existing `it.skip` in the JSON-Schema validation tests pins the redaction-trigger seam. +The expanded encrypting-codec surface means encode/decode failures can carry plaintext into `error.message`, `details.errors`, or other envelope fields. The redaction policy (cause routing, bounded `wirePreview`, validator-message redaction trigger) is preserved unchanged from the prior runtime, but trait-gated redaction (e.g. omitting `wirePreview` and scrubbing `cause` for codecs that carry the `secret` trait) is **not** spelled in this ADR. Tracked as a follow-up; see [framework-gaps G9](../../reference/framework-gaps.md#g9--trait-gated-redaction-in-error-envelopes-cleartext-leakage-policy). The existing `it.skip` in the JSON-Schema validation tests pins the redaction-trigger seam. ### `Promise.all` codec dispatch is unbounded @@ -209,16 +211,16 @@ The runtime encodes parameters and decodes cells via `Promise.all`. For sync-lif This is **acceptable but not safe at scale**. The standard-library `Promise.all` also propagates the first rejection without cancelling already-dispatched bodies; their results are discarded but the IO still runs to completion. Mitigations are intentionally deferred: -- **Concurrency control (rate limit, batched dispatch).** Tracked under [TML-2330](https://linear.app/prisma-company/issue/TML-2330) and [framework-gaps G4](../../reference/framework-gaps.md#g4--per-cell-promiseall-codec-dispatch-is-unbounded-for-network-backed-codecs). The likely landing is a per-codec-or-per-trait dispatcher that bounds in-flight requests and lifts the public `Promise.all` shape into a configurable scheduler. +- **Concurrency control (rate limit, batched dispatch).** Tracked under [framework-gaps G4](../../reference/framework-gaps.md#g4--per-cell-promiseall-codec-dispatch-is-unbounded-for-network-backed-codecs). The likely landing is a per-codec-or-per-trait dispatcher that bounds in-flight requests and lifts the public `Promise.all` shape into a configurable scheduler. - **`AbortSignal` plumbing.** **Resolved by [ADR 207 — Codec call context: per-query `AbortSignal` and column metadata](./ADR%20207%20-%20Codec%20call%20context%20per-query%20AbortSignal%20and%20column%20metadata.md).** The codec dispatch surface now threads a per-execute `CodecCallContext` (`{ signal? }` at the framework level; `SqlCodecCallContext extends CodecCallContext { column? }` at the SQL layer) to every `codec.encode` / `codec.decode` call, and `runtime.execute(plan, { signal })` surfaces `RUNTIME.ABORTED` with cooperative cancellation. None of the seven walk-back constraints below are reintroduced. The single-path always-await design does not preclude either mitigation; both can land additively without changing the codec author surface. ## Cross-family scope notes -Mongo decode is **in place** as of TML-2324: the Mongo runtime walks a structural `MongoResultShape` attached to `MongoQueryPlan` / `MongoExecutionPlan` (see `packages/2-mongo-family/4-query/query-ast/src/result-shape.ts`), dispatches leaf decodes via `Promise.all` per row, and maps failures to `RUNTIME.DECODE_FAILED` with `{ collection, path, codec, wirePreview }`. Lanes populate `resultShape` for flat typed reads; raw commands omit it so rows pass through unchanged. SQL continues to use `meta.annotations` / `meta.projectionTypes`; Mongo does not use those fields for decode. +Mongo decode is **in place**: the Mongo runtime walks a structural `MongoResultShape` attached to `MongoQueryPlan` / `MongoExecutionPlan` (see `packages/2-mongo-family/4-query/query-ast/src/result-shape.ts`), dispatches leaf decodes via `Promise.all` per row, and maps failures to `RUNTIME.DECODE_FAILED` with `{ collection, path, codec, wirePreview }`. Lanes populate `resultShape` for flat typed reads; raw commands omit it so rows pass through unchanged. SQL continues to use `meta.annotations` / `meta.projectionTypes`; Mongo does not use those fields for decode. -Mongo's codec registry is now aggregated by the framework's execution-stack composition machinery (matching the SQL pattern that has been in place since ADR 152): component descriptors declare codecs on `ComponentMetadata.types.codecTypes.codecInstances`, and `createMongoExecutionContext({ contract, stack })` walks the stack to fold them into a single `MongoCodecRegistry` exposed on `context.codecs`. `MongoRuntimeOptions` no longer accepts a `codecs` field — users do not construct or thread a registry. See `packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts` for the Mongo-side implementation and `packages/2-sql/5-runtime/src/sql-context.ts` for the SQL counterpart. +Mongo's codec registry is now aggregated by the framework's execution-stack composition machinery (matching the SQL pattern that has been in place since ADR 152): component descriptors declare codecs on `ComponentMetadata.types.codecTypes.codecDescriptors`, and `createMongoExecutionContext({ contract, stack })` walks the stack to fold them into a single `MongoCodecRegistry` exposed on `context.codecs`. `MongoRuntimeOptions` no longer accepts a `codecs` field — users do not construct or thread a registry. See `packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts` for the Mongo-side implementation and `packages/2-sql/5-runtime/src/sql-context.ts` for the SQL counterpart. The earlier "Mongo decode out of scope" note below is **historical** — it described the state before this work landed. @@ -233,7 +235,7 @@ Concretely, in this ADR’s original encode-focused slice: - **In scope (Mongo):** the encode-side runtime invocation pattern. `resolveValue`, `MongoAdapter.lower()`, and `MongoRuntime.execute()` are reshaped to async + `Promise.all` for consistency with SQL. - **Decode (Mongo), follow-up to this ADR:** row decoding now uses structural `MongoResultShape` and `decodeMongoRow` in `@prisma-next/mongo-runtime` (TML-2324); JSON-Schema validation on decoded cells remains SQL-only for now. -- **In scope (cross-family):** the structural identity of `Codec` and `MongoCodec`, and the structural reusability of a single `codec({...})` module across both runtimes' encode paths. +- **In scope (cross-family):** the structural identity of `Codec` and `MongoCodec`, and the structural reusability of a single `defineCodec({...})` module across both runtimes' encode paths. ## References diff --git a/docs/architecture docs/adrs/ADR 204 - Single-tier runtime.md b/docs/architecture docs/adrs/ADR 204 - Single-tier runtime.md index b44361713c..51e6e7181b 100644 --- a/docs/architecture docs/adrs/ADR 204 - Single-tier runtime.md +++ b/docs/architecture docs/adrs/ADR 204 - Single-tier runtime.md @@ -84,7 +84,7 @@ In practice the two tiers carried very little independent value: - The middleware orchestration loop existed twice — once in `runtime-executor` for the cross-family path, once in each family for SQL's `beforeCompile` chain. Drift between them was a recurring review concern. - Plumbing a generic middleware to observe both SQL and Mongo required threading the same context shape through both tiers in each family. -The cross-family runtime unification project ([TML-2242](https://linear.app/prisma-company/issue/TML-2242/)) introduced three primitives — `QueryPlan` / `ExecutionPlan` markers, the abstract `RuntimeCore` class, and the `runWithMiddleware` helper — that, together, leave the inner kernel with no responsibilities the abstract base cannot own. At that point the composition tier becomes pure forwarding and is worth removing. +The cross-family runtime unification project introduced three primitives — `QueryPlan` / `ExecutionPlan` markers, the abstract `RuntimeCore` class, and the `runWithMiddleware` helper — that, together, leave the inner kernel with no responsibilities the abstract base cannot own. At that point the composition tier becomes pure forwarding and is worth removing. ## Rationale diff --git a/docs/architecture docs/adrs/ADR 205 - SQL cast emission is adapter policy.md b/docs/architecture docs/adrs/ADR 205 - SQL cast emission is adapter policy.md index c61d06880f..7560df1b4a 100644 --- a/docs/architecture docs/adrs/ADR 205 - SQL cast emission is adapter policy.md +++ b/docs/architecture docs/adrs/ADR 205 - SQL cast emission is adapter policy.md @@ -1,5 +1,7 @@ # ADR 205 — Postgres cast emission is adapter policy, codec metadata stays descriptive +> **Retrospective note.** This ADR's examples use the `defineCodec({...})` factory. That factory was retired in favor of class-based descriptors (`CodecDescriptorImpl`) and codecs (`CodecImpl`) as described in [ADR 208](ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). The decision this ADR records — that the SQL renderer reads `meta.db.sql..nativeType` as descriptive metadata and the adapter applies cast policy — is unchanged. `meta` is declared on the descriptor class today (`readonly meta = { db: { sql: { postgres: { nativeType: 'vector' } } } } as const;`). See [ADR 208](ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) and the [Codec authoring guide](../../reference/codec-authoring-guide.md). + ## TL;DR The Postgres SQL renderer sometimes has to suffix a parameter with `::` (e.g. `$1::vector`) so Postgres can resolve the parameter's type. Today it picks which casts to emit by hardcoding three codec IDs in the renderer — including a codec ID owned by an extension package, which inverts the dependency between core adapter and extension. We're moving the decision out of the renderer and onto the **adapter as policy**: the renderer reads each codec's existing `nativeType` field and casts only when the type isn't in an adapter-local "infers cleanly" allow-list. Codec authors set no new flags; extensions just work. @@ -90,7 +92,7 @@ Anything outside the set — including `json`, `jsonb`, all extension types, all The pgvector extension declares its codec the same way it does today, with no new fields: ```ts -const pgVectorCodec = codec({ +const pgVectorCodec = defineCodec({ typeId: 'pg/vector@1', meta: { db: { sql: { postgres: { nativeType: 'vector' } } } }, // … encode/decode … @@ -113,7 +115,7 @@ function renderTypedParam(index, codecId, codecLookup) { A user who registers a PostGIS codec gets correct emission with zero adapter change: ```ts -const geographyCodec = codec({ +const geographyCodec = defineCodec({ typeId: 'app/geography@1', meta: { db: { sql: { postgres: { nativeType: 'geography' } } } }, // … diff --git a/docs/architecture docs/adrs/ADR 207 - Codec call context per-query AbortSignal and column metadata.md b/docs/architecture docs/adrs/ADR 207 - Codec call context per-query AbortSignal and column metadata.md index 22c87be8d8..f9efd20871 100644 --- a/docs/architecture docs/adrs/ADR 207 - Codec call context per-query AbortSignal and column metadata.md +++ b/docs/architecture docs/adrs/ADR 207 - Codec call context per-query AbortSignal and column metadata.md @@ -4,33 +4,56 @@ Accepted. Apr 30, 2026. -Codec query-time methods (`encode`, `decode`) gain an optional second argument: a per-call context object carrying `signal` (framework-level) and family-specific metadata such as SQL's `column` (family-level). The runtime's `execute(plan, { signal })` builds one context per call and threads it to every codec invocation, so codec authors can forward cancellation to their underlying SDK and identify the column at decode time. Resolves the `AbortSignal` half of [ADR 204](./ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md) §"Risks & mitigations" and the decode-side column-identity gap. The concurrency-cap half of ADR 204 §"Risks" stays open, tracked under [TML-2330](https://linear.app/prisma-company/issue/TML-2330). +Codec query-time methods (`encode`, `decode`) gain an optional second argument: a per-call context object carrying `signal` (framework-level) and family-specific metadata such as SQL's `column` (family-level). The runtime's `execute(plan, { signal })` builds one context per call and threads it to every codec invocation, so codec authors can forward cancellation to their underlying SDK and identify the column at decode time. Resolves the `AbortSignal` half of [ADR 204](./ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md) §"Risks & mitigations" and the decode-side column-identity gap. The concurrency-cap half of ADR 204 §"Risks" stays open; see [framework-gaps G4](../../reference/framework-gaps.md#g4--per-cell-promiseall-codec-dispatch-is-unbounded-for-network-backed-codecs). ## Grounding example A codec author shipping an encrypted-JSON column type with cancellation and column-aware envelopes: ```ts -import { codec } from '@prisma-next/sql-relational-core/ast'; +import { + CodecDescriptorImpl, + CodecImpl, + voidParamsSchema, + type CodecInstanceContext, +} from '@prisma-next/framework-components/codec'; +import type { JsonValue } from '@prisma-next/contract/types'; +import type { SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; import { encryptClient } from './encrypt-client'; -const encryptedJson = codec({ - typeId: 'encrypted/json@1', - targetTypes: ['jsonb'], - - // Single-arg authors continue to compile and run unchanged. - // Two-arg authors observe the per-call context. - encode: async (value: JsonValue, ctx) => { - return encryptClient.encrypt(value, { signal: ctx?.signal }); - }, - decode: async (wire: string, ctx) => { - const plain = await encryptClient.decrypt(wire, { signal: ctx?.signal }); +class EncryptedJsonCodec extends CodecImpl< + 'encrypted/json@1', + readonly [], + string, + { readonly value: JsonValue; readonly column?: { table: string; name: string } } +> { + override readonly id = 'encrypted/json@1'; + override readonly traits = [] as const; + + override async encode(value: { value: JsonValue }, ctx: SqlCodecCallContext): Promise { + return encryptClient.encrypt(value.value, { signal: ctx.signal }); + } + override async decode( + wire: string, + ctx: SqlCodecCallContext, + ): Promise<{ value: JsonValue; column?: { table: string; name: string } }> { + const plain = await encryptClient.decrypt(wire, { signal: ctx.signal }); // SQL family ctx: column is { table, name } when the cell resolves to // a single underlying column, undefined for aggregates / computed // expressions / include aliases. - return { value: plain, column: ctx?.column }; - }, -}); + return { value: plain, column: ctx.column }; + } + encodeJson(v: { value: JsonValue }): JsonValue { return v.value; } + decodeJson(j: JsonValue): { value: JsonValue } { return { value: j }; } +} + +class EncryptedJsonDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'encrypted/json@1'; + override readonly traits = [] as const; + override readonly targetTypes = ['jsonb'] as const; + override readonly paramsSchema = voidParamsSchema; + override readonly factory = () => (_ctx: CodecInstanceContext) => new EncryptedJsonCodec(); +} ``` The call site supplies the signal once; the runtime forwards the same `AbortSignal` reference to every codec call inside that one query: @@ -176,7 +199,7 @@ export function runtimeAborted( - Cells that don't resolve to a single underlying column (aggregate aliases, computed projections, include-aggregate fields) get `column: undefined`. Codec authors that need column identity must handle the undefined case explicitly; the runtime never silently defaults it. - When the runtime cannot project `column` for a cell, the per-cell ctx **drops the field entirely** rather than passing the row-level context through unchanged — preventing a previously-populated `rowCtx.column` from leaking into unrelated cells when callers reuse a context object. -Encode-side `ctx.column` is intentionally always undefined. The same encode site encodes parameters for predicates, expressions, and aggregations whose column identity is ambiguous. Encode-time column context is the middleware's domain — a middleware that walks a plan can attach richer column metadata to outbound parameters before encode begins (tracked under [TML-2359](https://linear.app/prisma-company/issue/TML-2359)). +Encode-side `ctx.column` is intentionally always undefined. The same encode site encodes parameters for predicates, expressions, and aggregations whose column identity is ambiguous. Encode-time column context is the middleware's domain — a middleware that walks a plan can attach richer column metadata to outbound parameters before encode begins (documented as still-open encode-side enrichment under [framework-gaps G1](../../reference/framework-gaps.md#g1--codecs-receive-no-per-call-column-metadata)). ## What this enables @@ -187,7 +210,7 @@ Encode-side `ctx.column` is intentionally always undefined. The same encode site ## Non-goals (deliberate) -- **No concurrency cap, no rate limit, no batched dispatch.** A query that touches N rows × M codec'd cells still issues N × M concurrent codec calls. Bounding fan-out is tracked under [TML-2330](https://linear.app/prisma-company/issue/TML-2330). The codec author surface is forward-compatible — a future `bulkEncode` / `bulkDecode` slot lands additively. +- **No concurrency cap, no rate limit, no batched dispatch.** A query that touches N rows × M codec'd cells still issues N × M concurrent codec calls. Bounding fan-out is tracked under [framework-gaps G4](../../reference/framework-gaps.md#g4--per-cell-promiseall-codec-dispatch-is-unbounded-for-network-backed-codecs). The codec author surface is forward-compatible — a future `bulkEncode` / `bulkDecode` slot lands additively. - **No driver-level statement cancellation.** When the runtime returns `RUNTIME.ABORTED` mid-stream, the underlying database driver's in-flight statement is not cancelled at the wire level. Iterator-close cleanup runs (cursor released, connection returned to pool); the server may keep producing rows for a moment after the runtime stops consuming. Wiring `signal` into Postgres `pg_cancel_backend` / Mongo `killCursors` is separate work. - **No transaction-scoped abort composition.** Each `runtime.execute` builds its own per-execute context; the transaction wrapper doesn't compose deadline / cancel-on-rollback signals automatically. Callers compose their own controllers if they need transaction-scoped timeouts. - **No structured cancellation reason taxonomy.** `cause` carries `signal.reason` verbatim; no normalization, no enrichment. @@ -230,7 +253,7 @@ We considered having the runtime forcibly terminate codec bodies on abort, by pa ### Encode-side `ctx.column` -We considered populating `ctx.column` for encode calls too. Rejected: the same encode site encodes parameters for predicates, expressions, and aggregations whose column identity is ambiguous. Encode-time column context is the middleware's domain — a middleware can walk the plan and attach richer column metadata to outbound parameters before encode begins. Tracked under [TML-2359](https://linear.app/prisma-company/issue/TML-2359). +We considered populating `ctx.column` for encode calls too. Rejected: the same encode site encodes parameters for predicates, expressions, and aggregations whose column identity is ambiguous. Encode-time column context is the middleware's domain — a middleware can walk the plan and attach richer column metadata to outbound parameters before encode begins. See [framework-gaps G1](../../reference/framework-gaps.md#g1--codecs-receive-no-per-call-column-metadata). ### Eager driver-level cancellation @@ -258,8 +281,8 @@ The required second-arg shape on the `Codec` interface doesn't tie context to as - [ADR 204 — Single-Path Async Codec Runtime](./ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md). Establishes the always-await codec runtime model and names `AbortSignal` plumbing as a known gap; this ADR resolves that half. - [ADR 027 — Error Envelope Stable Codes](./ADR%20027%20-%20Error%20Envelope%20Stable%20Codes.md). Defines the envelope shape (`code`, `details`, `cause`) used by `RUNTIME.ABORTED`. -- [TML-2330](https://linear.app/prisma-company/issue/TML-2330). Tracking ticket for this ADR's implementation; concurrency cap / bulk-codec dispatch stays open under the same ticket. -- [TML-2359](https://linear.app/prisma-company/issue/TML-2359). Encode-side richer column metadata via middleware (out of scope for this ADR). +- [framework-gaps G4](../../reference/framework-gaps.md#g4--per-cell-promiseall-codec-dispatch-is-unbounded-for-network-backed-codecs) — concurrency cap / bulk-codec dispatch (still open alongside this ADR's `AbortSignal` work). +- [framework-gaps G1](../../reference/framework-gaps.md#g1--codecs-receive-no-per-call-column-metadata) — encode-side richer column metadata via middleware (out of scope for this ADR). - [WHATWG `AbortSignal` / `AbortController](https://dom.spec.whatwg.org/#interface-abortsignal)`. The cancellation primitive used end-to-end. -Implementation lives across `framework-components` (`codec-types.ts`, `runtime-error.ts`, `race-against-abort.ts`, `runtime-core.ts`, `runtime-middleware.ts`), `relational-core/src/ast/codec-types.ts`, the SQL runtime's encode/decode/streaming paths, and the Mongo adapter / runtime threading. Behavioural and type-level test pins sit alongside each subject file under `test/`. End-to-end abort coverage against real drivers lives at `test/integration/test/sql-builder/execution-abort.test.ts` and `test/integration/test/mongo/execution-abort.test.ts`. Full implementation history is in PR [#400](https://github.com/prisma/prisma-next/pull/400) under [TML-2330](https://linear.app/prisma-company/issue/TML-2330). \ No newline at end of file +Implementation lives across `framework-components` (`codec-types.ts`, `runtime-error.ts`, `race-against-abort.ts`, `runtime-core.ts`, `runtime-middleware.ts`), `relational-core/src/ast/codec-types.ts`, the SQL runtime's encode/decode/streaming paths, and the Mongo adapter / runtime threading. Behavioural and type-level test pins sit alongside each subject file under `test/`. End-to-end abort coverage against real drivers lives at `test/integration/test/sql-builder/execution-abort.test.ts` and `test/integration/test/mongo/execution-abort.test.ts`. Full implementation history is in PR [#400](https://github.com/prisma/prisma-next/pull/400). \ No newline at end of file diff --git a/docs/architecture docs/adrs/ADR 208 - Higher-order codecs for parameterized types.md b/docs/architecture docs/adrs/ADR 208 - Higher-order codecs for parameterized types.md index eaa0e18308..4d040db1bb 100644 --- a/docs/architecture docs/adrs/ADR 208 - Higher-order codecs for parameterized types.md +++ b/docs/architecture docs/adrs/ADR 208 - Higher-order codecs for parameterized types.md @@ -30,59 +30,101 @@ Before this ADR, each parameterized codec encoded its parameter relationship in ## Decision -Every codec is described by a single descriptor type: +Every codec is described by a single descriptor type. The consumer surface is the `CodecDescriptor

` interface; codec authors extend the abstract class `CodecDescriptorImpl

`: ```ts +// Consumer surface (interface, in @prisma-next/framework-components/codec) export interface CodecDescriptor

{ readonly codecId: string; readonly traits: readonly CodecTrait[]; readonly targetTypes: readonly string[]; readonly meta?: CodecMeta; readonly paramsSchema: StandardSchemaV1

; + readonly isParameterized: boolean; readonly renderOutputType?: (params: P) => string | undefined; readonly factory: (params: P) => (ctx: CodecInstanceContext) => Codec; } + +// Codec-author surface (abstract class — what codec authors `extends`) +export abstract class CodecDescriptorImpl

implements CodecDescriptor

{ + abstract readonly codecId: string; + abstract readonly traits: readonly CodecTrait[]; + abstract readonly targetTypes: readonly string[]; + readonly meta?: CodecMeta; + abstract readonly paramsSchema: StandardSchemaV1

; + readonly isParameterized: boolean; // derived from `paramsSchema !== voidParamsSchema` + renderOutputType?(params: P): string | undefined; + abstract factory(params: P): (ctx: CodecInstanceContext) => Codec; +} ``` -A **parameterized codec** is a curried factory plus the descriptor that registers it. The `vector(N)` codec authors as: +A **parameterized codec** is three artifacts: the codec class (extending `CodecImpl`), the descriptor class (extending `CodecDescriptorImpl

`), and a per-codec column helper that calls `descriptor.factory(...)` directly so TypeScript binds the method-level generic at the call site. The `vector(N)` codec authors as: ```ts -// Pack-author surface — what users import and call. -function vector(length: N): ColumnTypeDescriptor & { - readonly typeParams: { readonly length: N }; -} { - return { codecId: 'pg/vector@1', nativeType: 'vector', typeParams: { length } }; +// 1. Codec class — `encode`/`decode` (and JSON variants where applicable). +class VectorCodec extends CodecImpl< + 'pg/vector@1', readonly ['equality'], string, Vector +> { + constructor(descriptor: PgVectorDescriptor, readonly dimension: N) { + super(descriptor); + } + async encode(value: Vector) { return `[${value.join(',')}]`; } + async decode(wire: string) { return parseVector(wire) as Vector; } } -// Framework-registration surface — what the descriptor map consumes. -const pgVectorCodec: CodecDescriptor<{ readonly length: number }> = { - codecId: 'pg/vector@1', - traits: ['equality'], - targetTypes: ['vector'], - paramsSchema: type({ length: 'number > 0' }), - renderOutputType: ({ length }) => `Vector<${length}>`, - factory: (_params) => (_ctx) => sharedVectorCodec, -}; +// 2. Descriptor class — codec id, metadata, factory. +class PgVectorDescriptor extends CodecDescriptorImpl<{ readonly length: number }> { + override readonly codecId = 'pg/vector@1' as const; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['vector'] as const; + override readonly paramsSchema = type({ length: 'number > 0' }); + override renderOutputType({ length }: { length: number }) { return `Vector<${length}>`; } + override factory( + params: { readonly length: N }, + ): (ctx: CodecInstanceContext) => VectorCodec { + return (ctx) => new VectorCodec(this, params.length); + } +} + +export const pgVectorDescriptor = new PgVectorDescriptor(); + +// 3. Per-codec column helper — direct invocation of `descriptor.factory(...)` +// preserves `` at the call site through TypeScript's variance rules. +export const vector = (length: N) => + column(pgVectorDescriptor.factory({ length }), pgVectorDescriptor.codecId, { length }); +vector satisfies ColumnHelperFor; ``` -The descriptor registers the codec id with the framework and carries the codec-id-keyed metadata the framework consults without the runtime instance in scope: traits and target types for trait gating; `paramsSchema` for JSON-boundary validation; `renderOutputType` for `contract.d.ts`; the curried `factory` for runtime materialization. +The descriptor registers the codec id with the framework and carries the codec-id-keyed metadata the framework consults without the runtime instance in scope: traits and target types for trait gating; `paramsSchema` for JSON-boundary validation; `renderOutputType` for `contract.d.ts`; the curried `factory` for runtime materialization. The `satisfies ColumnHelperFor` clause ties the helper to its descriptor at compile time, catching wiring mistakes (wrong `codecId`, wrong factory wired in, mismatched typeParams shape). -**Non-parameterized codecs are the degenerate case.** A non-parameterized codec uses `P = void` and a constant factory that returns the same shared `Codec` instance for every column: +**Non-parameterized codecs are the degenerate case.** A non-parameterized codec uses `P = void` and a constant factory that returns the same shared codec instance for every column: ```ts -const sharedTextCodec: Codec = { id: 'pg/text@1', /* … */ }; - -const pgTextCodec: CodecDescriptor = { - codecId: 'pg/text@1', - traits: ['equality', 'order', 'textual'], - targetTypes: ['text'], - paramsSchema: voidParamsSchema, - factory: () => () => sharedTextCodec, -}; +class PgTextCodec extends CodecImpl<'pg/text@1', readonly ['equality', 'order', 'textual'], string, string> { + async encode(value: string) { return value; } + async decode(wire: string) { return wire; } +} + +class PgTextDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'pg/text@1' as const; + override readonly traits = ['equality', 'order', 'textual'] as const; + override readonly targetTypes = ['text'] as const; + override readonly paramsSchema = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgTextCodec { + const shared = new PgTextCodec(this); + return () => shared; + } +} + +export const pgTextDescriptor = new PgTextDescriptor(); +export const text = () => column(pgTextDescriptor.factory(), pgTextDescriptor.codecId, undefined); +text satisfies ColumnHelperFor; ``` Whether a codec id "is parameterized" stops being a registration-time distinction; it's a property of `P` on the descriptor. The descriptor map indexes every descriptor by `codecId`; both `descriptorFor(codecId)` (codec-id-keyed metadata reads) and `forColumn(table, column)` (column-aware dispatch reads) resolve through the same map without branching. +> **Authoring guide.** The class-form authoring pattern (descriptor class + codec class + per-codec helper, tied by `satisfies`), the variance rationale, and the three case studies that pin the design (non-parameterized, parameterized with literal preservation, parameterized with arktype schema) live in [`docs/reference/codec-authoring-guide.md`](../../reference/codec-authoring-guide.md). + `CodecInstanceContext` is a small framework-supplied input the curried factory closes over. The base shape is family-agnostic; SQL-family extensions augment it with domain-shaped column-set metadata. ```ts @@ -123,7 +165,7 @@ For columns that reference a named storage type via `typeRef` (rather than carry ### 4. Runtime materialization and dispatch -When `contract.json` loads, `sql-runtime` builds a **descriptor map** keyed by `codecId`. Parameterized descriptors land directly; non-parameterized codecs registered through the legacy `codecs:` slot are auto-lifted into `CodecDescriptor` via `synthesizeNonParameterizedDescriptor(codec)` — a synthesis bridge that wraps an existing async-shaped `Codec` (per [ADR 204 — Single-Path Async Codec Runtime](ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md)) into a descriptor whose constant factory returns the same codec instance for every column. The map exposes two read APIs: +When `contract.json` loads, `sql-runtime` builds a **descriptor map** keyed by `codecId`. Every contributor (target, adapter, extension pack) ships native `CodecDescriptor`s through the unified `codecs:` slot — both parameterized and non-parameterized descriptors live side-by-side in one array. The framework registers them directly; no synthesis or auto-lifting happens at runtime. The map exposes two read APIs: - **`descriptorFor(codecId)`** — codec-id-keyed metadata reads (consumed by trait gating, startup validation, the emit path's `renderOutputType` lookup). Non-branching for parameterized vs. non-parameterized. - **`forColumn(table, column)`** — column-aware dispatch reads (consumed by encode and decode). Returns the per-instance parameterized codec for parameterized columns, the cached singleton for non-parameterized columns. Pre-built once at context construction by walking `storage.tables[].columns[]`: @@ -132,7 +174,7 @@ When `contract.json` loads, `sql-runtime` builds a **descriptor map** keyed by ` 3. For inline-`typeParams` columns, validate via `descriptor.paramsSchema['~standard'].validate(typeParams)` and call `descriptor.factory(validatedParams)({ name: '', usedAt: [{ table, column }] })` once. 4. For non-parameterized columns, call `descriptor.factory(undefined)(ctx)` once and cache the resulting `Codec` by codec id (the constant-factory contract guarantees the result is shared across columns). -JSON-with-schema validation lives **inside the resolved codec's `decode` body** rather than in a parallel validator registry. The per-library extension's factory rehydrates the schema at materialization time and closes over it; `decode(wire)` parses then validates, throwing a uniform `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED` on rejection. +JSON-with-schema validation lives **inside the resolved codec's `decode` body** rather than in a parallel validator registry. The per-library extension's factory rehydrates the schema at materialization time and closes over it; `decode(wire)` parses then validates, throwing a uniform `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED` on rejection (which the runtime decode wrapper surfaces as `RUNTIME.DECODE_FAILED` with the original error reachable on `cause`). ## Why this shape @@ -160,9 +202,8 @@ 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. -- **Heterogeneous-`P` registry boundary.** `descriptorFor(codecId): CodecDescriptor

` 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 `` 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` 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. +- **Heterogeneous-`P` registry boundary.** `descriptorFor(codecId): CodecDescriptor` 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 `unknown` at the boundary; consumers narrow per codec id at the call site (where the descriptor's `paramsSchema` validates JSON-sourced params before the factory ever sees them, so the runtime narrow is safe). A typed-dispatch / sealed-visitor refactor would eliminate the boundary widening but is not in scope. +- **`forCodecId` retained only for non-parameterized codec ids.** Encode-side dispatch consults `forColumn(table, column)` for column-bound `ParamRef`s — `ParamRef.refs: { table; column }` is plumbed through every column-bound construction site, and a builder-pipeline validator pass (`validateParamRefRefs`) rejects refs-less `ParamRef`s targeting parameterized codec ids before encode runs. `forCodecId(codecId)` survives only as the refs-less fallback for non-parameterized codec ids, where it returns the shared singleton (a function that's logically a no-op for parameterized codec ids — those always reach `forColumn` first). ### Per-library JSON extensions @@ -174,7 +215,7 @@ The postgres adapter retains only the non-parameterized raw-JSON / raw-JSONB cod **Type-level brand or `OutputType` HKT field on the codec.** The codec carries an `OutputType: CodecOutputTypeFn` field, and `FieldOutputType` consults `Apply`. Rejected because the same information already lives in the factory function's TypeScript return type — encoding it twice and synchronizing the two encodings via `renderOutputType` is exactly the drift `function-is-signature` is meant to prevent. -**Optional `init(params, instance)` hook on the codec.** Codec carries `init?` separately from a factory; runtime calls `init` per `storage.types` instance for stateful codecs. Rejected because the higher-order factory IS what `init` was — the same signature, the same lifecycle, the same purpose. One artifact, not two. (The legacy `init?` slot on `CodecParamsDescriptor` and the SQL `Codec` extension persists as an adapter-level surface during the transition; retirement tracked under TML-2357.) +**Optional `init(params, instance)` hook on the codec.** Codec carries `init?` separately from a factory; runtime calls `init` per `storage.types` instance for stateful codecs. Rejected because the higher-order factory IS what `init` was — the same signature, the same lifecycle, the same purpose. One artifact, not two. The legacy `init?` slot on the SQL `Codec` extension was retired alongside the unified-descriptor migration. **A shared `columnFor(codec)(params)` helper.** A single `columnFor` helper turns any codec into a column-descriptor factory, type-discriminated on whether the codec is parameterized. Rejected because each pack ships a typed factory directly — `columnFor` would add no type information and would add an indirection at the call site. @@ -182,13 +223,13 @@ The postgres adapter retains only the non-parameterized raw-JSON / raw-JSONB cod ## Supersedes -The transitional `paramsSchema?` and `init?` fields on the SQL `Codec` extension and the `renderOutputType?` field on the SQL `Codec` and Mongo `MongoCodec` extensions (introduced by [ADR 186](ADR%20186%20-%20Codec-dispatched%20type%20rendering.md)). All three migrate to `CodecDescriptor`. Pack-author column-descriptor factories (`vector(N)`, `charColumn(N)`, `numericColumn(p, s)`, …) are reshaped to return `ColumnTypeDescriptor & { type?: (ctx) => Codec<…> }` for codecs that need no-emit type-level access — the user-call site (`field.column(vector(1536))`) is unchanged. +The transitional `paramsSchema?` and `init?` fields on the SQL `Codec` extension and the `renderOutputType?` field on the SQL `Codec` and Mongo `MongoCodec` extensions (introduced by [ADR 186](ADR%20186%20-%20Codec-dispatched%20type%20rendering.md)). All three migrated to `CodecDescriptor`. Pack-author column-descriptor factories (`vector(N)`, `charColumn(N)`, `numericColumn(p, s)`, …) are reshaped to return `ColumnTypeDescriptor & { type?: (ctx) => Codec<…> }` for codecs that need no-emit type-level access — the user-call site (`field.column(vector(1536))`) is unchanged. -The intermediate `CodecParamsDescriptor

` type at the adapter compile-time boundary persists as a legacy surface during the registration-side migration; retirement tracked under TML-2357 (see "Future work" below). Phase B of this ADR's landing migrated the runtime-side descriptor (in `@prisma-next/sql-runtime`) to the unified shape; the adapter-level `CodecParamsDescriptor` retires alongside the single registration slot. +The legacy `defineCodec({...})` factory and the family-side `mkCodec({...})` instance constructor were the previous canonical author surface; they have been retired in favor of class-based codecs and descriptors (`CodecImpl`, `CodecDescriptorImpl`, per-codec column helpers, `satisfies`) as described above. The earlier ADRs that show `defineCodec({...})` examples ([ADR 184](ADR%20184%20-%20Codec-owned%20value%20serialization.md), [ADR 186](ADR%20186%20-%20Codec-dispatched%20type%20rendering.md), [ADR 202](ADR%20202%20-%20Codec%20trait%20system.md), [ADR 204](ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md), [ADR 205](ADR%20205%20-%20SQL%20cast%20emission%20is%20adapter%20policy.md)) are accurate as historical records of those decisions, but the authoring shape they show is no longer current — see the retrospective notes at the top of each. ## 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`). +- **Parameterized columns (no-emit and emit).** `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. ## References @@ -199,16 +240,11 @@ The intermediate `CodecParamsDescriptor

` type at the adapter compile-time bou - [ADR 184 — Codec-owned value serialization](ADR%20184%20-%20Codec-owned%20value%20serialization.md). Established the pattern of codecs owning their representations. - [ADR 171 — Parameterized native types in contracts](ADR%20171%20-%20Parameterized%20native%20types%20in%20contracts.md). Established `typeParams` on storage columns. - [ADR 168 — Postgres JSON and JSONB typed columns](ADR%20168%20-%20Postgres%20JSON%20and%20JSONB%20typed%20columns.md). Introduced typed JSON columns with Standard Schema. Per-library extensions (`@prisma-next/extension-arktype-json`) now own the typed JSON column shape. -- [ADR 202 — Codec trait system](ADR%20202%20-%20Codec%20trait%20system.md). The trait system gating per-instance helper extraction (the `'json-validator'` trait gates the JSON-schema validator registry's `validate` extraction; the registry itself is unused by arktype-json, which validates inside its `decode` body). +- [ADR 202 — Codec trait system](ADR%20202%20-%20Codec%20trait%20system.md). The trait system. The `'json-validator'` trait was a transitional gate for the now-deleted `JsonSchemaValidatorRegistry`; both the trait and the registry were retired — JSON-Schema validation lives uniformly inside the resolved codec's `decode` body. ## Future work -- **TML-2357 — registration-side migration of the unified `CodecDescriptor`.** This ADR's landing covered the read-surface unification (`descriptorFor` non-branching across parameterized vs. non-parameterized codec ids) and the parameterized-descriptor migration for the codecs main shipped at the time (pgvector, postgres json/jsonb). The remaining work tracked under TML-2357: - - **T3.5.2** — narrow the runtime `Codec` instance to drop codec-id-keyed metadata (`id`, `traits`, `targetTypes`, `meta`). The descriptor is the long-term home for those fields; the runtime instance retains them today for the legacy registry's sake. - - **T3.5.3** — migrate every non-parameterized codec contributor to ship a `CodecDescriptor` directly (~50 codecs across postgres / sqlite / sql-family / mongo). The synthesis bridge auto-lifts the legacy `codecs:` slot today; T3.5.3 retires the bridge. - - **T3.5.4** — collapse the parallel registration slots into one (`codecs:` retires; `parameterizedCodecs:` retires; contributors ship one descriptor list). - - **T3.5.9 / T3.5.10 / T3.5.11** — thread `ParamRef.refs: { table; column }` through the SQL builder's column-bound construction sites so encode-side dispatch resolves through `forColumn` (the AC-5-deferred encode fallback retires; the `forCodecId` fallback retires for parameterized codec ids). - - **T3.5.12** — delete the `JsonSchemaValidatorRegistry` infrastructure (validation moves into the resolved codec's `decode` body uniformly; the `'json-validator'` trait becomes vestigial or persists only as a structural marker). - - **Emit-path consultation through `descriptorFor`.** The framework emitter currently consults a single codec-id-keyed `CodecLookup` for `renderOutputType`. Routing the emit path through the descriptor map directly retires the per-library "emit-only Codec shim" pattern (currently shipped by `@prisma-next/extension-arktype-json`). -- **Mongo control-plane parameterized-codecs slot.** The Mongo control descriptor doesn't carry the slot today; Mongo demos don't use vectors, so the gap is authoring-time only. A future migration aligns Mongo with the SQL family's slot shape. -- **Future schema libraries.** zod, valibot, etc. ship as parallel per-library extensions when each has a clean serialize / rehydrate story. The arktype-json package is the structural template. +- **`pgEnumCodec` factory audit.** The current factory is a placeholder (enum values aren't parameterized in the curried-factory sense). A separate ticket reshapes it. +- **Mongo registration migration + Mongo runtime `forColumn`.** Separate follow-up for the Mongo family. Mongo demos don't use parameterized codecs today, so the gap is authoring-time only. +- **Mongo control-plane unified `codecs:` registration surface.** Aligns Mongo with the SQL family's single-slot shape — separate ticket. +- **Future schema libraries.** zod, valibot, etc. ship as parallel per-library extensions when each library has a clean serialize / rehydrate story. The arktype-json package is the structural template. diff --git a/docs/architecture docs/adrs/ADR 209 - Mongo result-shape as a structural plan field.md b/docs/architecture docs/adrs/ADR 209 - Mongo result-shape as a structural plan field.md index b231518d99..dfa27e1298 100644 --- a/docs/architecture docs/adrs/ADR 209 - Mongo result-shape as a structural plan field.md +++ b/docs/architecture docs/adrs/ADR 209 - Mongo result-shape as a structural plan field.md @@ -150,7 +150,7 @@ The envelope code, fields, and `cause` chain are unchanged from ADR 027. | **Lanes** (ORM, query-builder typed-read terminals) | Produce the `resultShape` from the contract. Identity-stage pipelines (`$match`, `$sort`, `$limit`, `$skip`, `$sample`) preserve the source shape; shape-rewriting stages (`$project`, `$group`, `$addFields`, `$unwind`, `$replaceRoot`) currently emit `kind: 'unknown'` (per-stage value-level reification is mechanical follow-up work — see *Consequences*). Raw commands omit `resultShape` entirely. | | **Runtime** (`MongoRuntimeImpl.execute`) | Reads `exec.resultShape` and decodes per-row when present. Walks the shape and the row in lockstep, dispatches all leaf decodes through one `Promise.all`, wraps failures in the envelope above. Sources the `collection` name from `exec.command` (post-lowering authoritative) so middleware that rewrites collection names is reflected in error envelopes. | | **Adapter** (`MongoAdapter.lower`) | Untouched. Lowering is about producing the wire command; the result shape passes through unchanged. | -| **Codec registry** (`MongoCodecLookup`) | Resolves `codecId` → `MongoCodec`. Aggregated by the framework's execution-stack composition machinery: each component descriptor (target, adapter, extension packs) declares its codecs via `ComponentMetadata.types.codecTypes.codecInstances`; `createMongoExecutionContext({ contract, stack })` walks `[stack.target, stack.adapter, ...stack.extensionPacks]` and folds the declarations into a single registry. The runtime sees a read-only `MongoCodecLookup` (`get` / `has`); `register` stays internal to the aggregator. **Users never construct a `MongoCodecRegistry` themselves** — they compose a stack and a context, and codec aggregation falls out. | +| **Codec registry** (`MongoCodecLookup`) | Resolves `codecId` → `MongoCodec`. Aggregated by the framework's execution-stack composition machinery: each component descriptor (target, adapter, extension packs) declares its codecs via `ComponentMetadata.types.codecTypes.codecDescriptors`; `createMongoExecutionContext({ contract, stack })` walks `[stack.target, stack.adapter, ...stack.extensionPacks]` and folds the declarations into a single registry. The runtime sees a read-only `MongoCodecLookup` (`get` / `has`); `register` stays internal to the aggregator. **Users never construct a `MongoCodecRegistry` themselves** — they compose a stack and a context, and codec aggregation falls out. | | **Driver** | Untouched. Continues to surface BSON-shaped wire values. | ## Consequences @@ -199,7 +199,7 @@ We didn't, deliberately. SQL's `meta.annotations.codecs` is in production and wo An earlier draft of this work added a required `codecs: MongoCodecRegistry` field to `MongoRuntimeOptions` and asked the user — or the `mongo()` extension — to construct one and pass the same instance to both adapter and runtime. It worked, but it leaked an internal coordination problem onto every call site: the same-instance invariant was enforced by the user remembering to do it correctly. -The framework already had the right model for this. SQL's runtime composes from a stack of component descriptors (`SqlExecutionStack`), each of which declares its codec contributions on `ComponentMetadata.types.codecTypes.codecInstances`; `createExecutionContext({ contract, stack })` walks the stack and aggregates the contributions into a single registry. The Mongo runtime simply hadn't joined that model. This ADR's design plugs Mongo in: descriptors declare `codecInstances`, `createMongoExecutionContext` aggregates them, the runtime takes a context whole. `MongoCodecRegistry` doesn't appear in any user-facing surface; the read-only `MongoCodecLookup` is what the runtime uses internally. +The framework already had the right model for this. SQL's runtime composes from a stack of component descriptors (`SqlExecutionStack`), each of which declares its codec contributions on `ComponentMetadata.types.codecTypes.codecDescriptors`; `createExecutionContext({ contract, stack })` walks the stack and aggregates the contributions into a single registry. The Mongo runtime simply hadn't joined that model. This ADR's design plugs Mongo in: descriptors declare `codecDescriptors`, `createMongoExecutionContext` aggregates them, the runtime takes a context whole. `MongoCodecRegistry` doesn't appear in any user-facing surface; the read-only `MongoCodecLookup` is what the runtime uses internally. This is the same shape SQL already uses, applied to Mongo — not a new pattern. diff --git a/docs/architecture docs/subsystems/2. Contract Emitter & Types.md b/docs/architecture docs/subsystems/2. Contract Emitter & Types.md index 0d90287247..0d35f730db 100644 --- a/docs/architecture docs/subsystems/2. Contract Emitter & Types.md +++ b/docs/architecture docs/subsystems/2. Contract Emitter & Types.md @@ -261,7 +261,7 @@ Notes: ### Parameterized codecs and the unified descriptor -Parameterized codecs (e.g. `pg/vector@1`, `arktype/json@1`) ship a `CodecDescriptor

` with `paramsSchema: StandardSchemaV1

`, optional `renderOutputType: (params: P) => string`, and a curried `factory: (params: P) => (ctx: CodecInstanceContext) => Codec`. The descriptor unifies what previous iterations split across the codec object's optional `paramsSchema?` / `init?` / `renderOutputType?` slots and per-codec hand-rolled column-helper factories. Non-parameterized codecs are the degenerate `P = void` case and auto-lift via `synthesizeNonParameterizedDescriptor(codec)` from the legacy `codecs:` slot, so the descriptor map is the single read source for codec-id-keyed metadata across both shapes. See [ADR 208 — Higher-order codecs for parameterized types](../adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). +Parameterized codecs (e.g. `pg/vector@1`, `arktype/json@1`) ship a `CodecDescriptor

` with `paramsSchema: StandardSchemaV1

`, optional `renderOutputType: (params: P) => string`, and a curried `factory: (params: P) => (ctx: CodecInstanceContext) => Codec`. The descriptor unifies what previous iterations split across the codec object's optional `paramsSchema?` / `init?` / `renderOutputType?` slots and per-codec hand-rolled column-helper factories. Non-parameterized codecs are the degenerate `P = void` case authored as `class extends CodecDescriptorImpl` with a constant factory that returns the same shared codec instance for every column (see [ADR 208](../adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) for the unified authoring pattern). The descriptor map is the single read source for codec-id-keyed metadata across both shapes. The emit path consults `renderOutputType` via the framework's `CodecLookup`. Inline `field.type.typeParams` always wins when present; the framework only consults the resolver as a fallback for `typeRef`-backed columns whose inline `typeParams` is absent. The family-specific emitter (e.g. SQL) implements `EmissionSpi.resolveFieldTypeParams(modelName, fieldName, model, contract)` to walk `storage.fields → storage.tables → storage.types` and return the named instance's `typeParams`, so typeRef-backed columns render with the same fidelity as inline-`typeParams` columns. Mongo and other families that don't use named storage types simply don't implement the optional hook. diff --git a/docs/reference/codec-authoring-guide.md b/docs/reference/codec-authoring-guide.md new file mode 100644 index 0000000000..6663967911 --- /dev/null +++ b/docs/reference/codec-authoring-guide.md @@ -0,0 +1,261 @@ +# Codec authoring guide + +This guide describes the canonical authoring shape for codecs in Prisma Next: **class-based codecs and descriptors** (`CodecImpl`, `CodecDescriptorImpl`), per-codec column helpers, and `satisfies` for compile-time wiring. The design rationale and the broader codec model live in [ADR 208 — Higher-order codecs for parameterized types](../architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md); this document is the practical "how to write a codec" reference for contributors. + +## At a glance + +A codec is **three artifacts**: + +1. A **codec class** that extends `CodecImpl` and implements `encode` / `decode` (and `encodeJson` / `decodeJson` when the wire is not already JSON-safe). +2. A **descriptor class** that extends `CodecDescriptorImpl

` 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` clause that ties it to its descriptor at compile time. + +The framework imports live at `@prisma-next/framework-components/codec`: + +- `CodecImpl` — abstract codec base class. +- `CodecDescriptorImpl

` — abstract descriptor base class. +- `ColumnHelperFor` / `ColumnHelperForStrict` — `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). +- `Codec<...>`, `CodecDescriptor

`, `AnyCodecDescriptor` — consumer-facing interfaces (consumers depend on these; authors extend the `*Impl` classes). + +## Three case studies + +The same three artifacts express the full spectrum: non-parameterized, parameterized with literal preservation, and parameterized with a typed schema. + +### Case 1 — Non-parameterized codec (`pg/text@1`) + +```ts +import { + type CodecCallContext, + type CodecInstanceContext, + CodecImpl, + CodecDescriptorImpl, + type ColumnHelperFor, + column, + voidParamsSchema, +} from '@prisma-next/framework-components/codec'; + +class PgTextCodec extends CodecImpl< + 'pg/text@1', + readonly ['equality', 'order', 'textual'], + string, + string +> { + async encode(value: string, _ctx: CodecCallContext) { return value; } + async decode(wire: string, _ctx: CodecCallContext) { return wire; } +} + +class PgTextDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'pg/text@1' as const; + override readonly traits = ['equality', 'order', 'textual'] as const; + override readonly targetTypes = ['text'] as const; + override readonly paramsSchema = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgTextCodec { + const shared = new PgTextCodec(this); + return () => shared; + } +} + +export const pgTextDescriptor = new PgTextDescriptor(); + +export const text = () => + column(pgTextDescriptor.factory(), pgTextDescriptor.codecId, undefined, 'text'); +text satisfies ColumnHelperFor; +``` + +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. + +### Case 2 — Parameterized codec with literal preservation (`pg/vector@1`) + +```ts +import { type } from 'arktype'; + +class VectorCodec extends CodecImpl< + 'pg/vector@1', + readonly ['equality'], + string, + Vector +> { + constructor(descriptor: PgVectorDescriptor, readonly dimension: N) { + super(descriptor); + } + async encode(value: Vector, _ctx: CodecCallContext) { + return `[${value.join(',')}]`; + } + async decode(wire: string, _ctx: CodecCallContext) { + return parseVector(wire) as Vector; + } +} + +class PgVectorDescriptor extends CodecDescriptorImpl<{ readonly length: number }> { + override readonly codecId = 'pg/vector@1' as const; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['vector'] as const; + override readonly paramsSchema = type({ length: 'number > 0' }); + override renderOutputType({ length }: { length: number }) { return `Vector<${length}>`; } + override factory( + params: { readonly length: N }, + ): (ctx: CodecInstanceContext) => VectorCodec { + return (ctx) => new VectorCodec(this, params.length); + } +} + +export const pgVectorDescriptor = new PgVectorDescriptor(); + +export const vector = (length: N) => + column( + pgVectorDescriptor.factory({ length }), + pgVectorDescriptor.codecId, + { length }, + 'vector', + ); +vector satisfies ColumnHelperFor; +``` + +The class-level params type is `{ readonly length: number }` (widest bound). The **method-level generic** `` on `factory` is what preserves the literal at the call site: when `vector(1536)` calls `pgVectorDescriptor.factory({ length: 1536 })` *directly*, TypeScript binds `N=1536`. The literal flows through `column(...)`'s generics into the column spec, into the contract type, and into `contract.d.ts`. + +This is the **load-bearing variance pattern**: method generics on the descriptor's factory are preserved by direct invocation inside the per-codec helper, not by structural extraction at a polymorphic helper. A polymorphic `column(descriptor, params)` helper that tried to extract `R` from the descriptor's `factory` would lose the literal — TypeScript instantiates method generics to their constraint at every form of structural extraction (structural match, indexed access, `Parameters` / `ReturnType`, etc.). + +### Case 3 — Parameterized codec with typed schema (`arktype/json@1`) + +The schema's TypeScript-level inferred type `S['infer']` is only available at the column-author site (where the user passes their typed schema), not at the descriptor's factory site (where only the serialized IR is available). This drives a slightly richer shape than Case 2: + +```ts +import { type } from 'arktype'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +class ArktypeJsonCodecClass extends CodecImpl< + 'arktype/json@1', + readonly ['equality'], + string, + TInferred +> { + constructor( + descriptor: ArktypeJsonDescriptor, + private readonly schema: ArktypeSchemaLike, + ) { super(descriptor); } + async encode(value: TInferred, _ctx: CodecCallContext) { + return serializeToJsonSafe(this.schema, value).wire; + } + async decode(wire: string, _ctx: CodecCallContext) { + return validateSchema(this.schema, JSON.parse(wire)); + } +} + +class ArktypeJsonDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'arktype/json@1' as const; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['jsonb'] as const; + override readonly paramsSchema = type({ + expression: 'string', + jsonIr: 'object', + }) satisfies StandardSchemaV1; + override renderOutputType(params: ArktypeJsonTypeParams) { return params.expression; } + override factory( + params: ArktypeJsonTypeParams, + ): (ctx: CodecInstanceContext) => ArktypeJsonCodecClass { + const schema = rehydrateSchema(params.jsonIr); + return () => new ArktypeJsonCodecClass(this, schema); + } +} + +export const arktypeJsonDescriptor = new ArktypeJsonDescriptor(); + +export function arktypeJsonColumn>( + schema: S, +): ColumnSpec, ArktypeJsonTypeParams> { + // Eager serialization captures `expression` (emit-path) and `jsonIr` (runtime rehydration) at the column-author site. + const params: ArktypeJsonTypeParams = { expression: schema.expression, jsonIr: schema.json }; + return column( + (_ctx) => new ArktypeJsonCodecClass(arktypeJsonDescriptor, schema), + arktypeJsonDescriptor.codecId, + params, + 'jsonb', + ); +} +arktypeJsonColumn satisfies ColumnHelperFor; +``` + +Two things to note: + +1. The descriptor's factory return is `ArktypeJsonCodecClass` (the descriptor only sees IR — `S` is erased). The runtime path through `descriptor.factory(params)` always exists (e.g. for `validateContract` re-materialization); it just loses the typed inferred shape. +2. The column helper bypasses `descriptor.factory(...)` and constructs the typed codec directly so `S['infer']` flows through the column spec into the contract type. It satisfies `ColumnHelperFor` (coarse) but not `ColumnHelperForStrict` — the descriptor's factory return is `ArktypeJsonCodecClass` while the helper produces `ArktypeJsonCodecClass`, and `Codec`'s `TInput` is invariant. Negative type tests cover the literal-preservation property the strict variant would otherwise enforce. + +JSON-Schema validation lives **inside `decode`**: the rehydrated schema is closure-captured by the codec instance, and `decode` calls into it synchronously. There is no parallel validator registry — the framework deleted `JsonSchemaValidatorRegistry` when unified descriptors and inline decode validation replaced the parallel registry. + +## `satisfies` discipline + +The framework exports two helper-shape constraints: + +- `ColumnHelperFor` — checks the helper returns a `ColumnSpec` whose typeParams shape matches `Parameters[0]`. Catches wiring the wrong descriptor's factory in by typeParams shape; doesn't catch literal-preservation violations (those are covered by negative type tests). +- `ColumnHelperForStrict` — also checks the helper's promised codec type matches `ReturnType`. Use this when the codec's resolved type is well-defined (most cases). The strict form fails for helpers like `arktypeJsonColumn` whose typed return is more specific than the descriptor's factory return; in that case use the coarse form and rely on `expectTypeOf` tests for the literal-preservation property. + +Both are exported from `@prisma-next/framework-components/codec`. + +## Aliases + +Aliasing a codec under a new id (e.g. Postgres's `pgCharDescriptor` aliasing the SQL-base `sqlCharDescriptor`) is a **descriptor-level** operation, not an instance-level one. There is no `aliasCodec` helper: aliases are expressed as plain class inheritance from the base descriptor with the alias's metadata overridden. + +```ts +// SQL base — relational-core/src/ast/sql-codecs.ts +class SqlCharDescriptor extends CodecDescriptorImpl { /* … */ } +export const sqlCharDescriptor = new SqlCharDescriptor(); + +// Postgres alias — target-postgres/src/core/codecs.ts +class PgCharDescriptor extends SqlCharDescriptor { + override readonly codecId = 'pg/char@1'; + override readonly targetTypes = ['char'] as const; +} +export const pgCharDescriptor = new PgCharDescriptor(); +``` + +Inherited overrides do the heavy lifting: the alias inherits `paramsSchema`, `traits`, and `factory` from the base. Because `CodecImpl.id` proxies through `this.descriptor.codecId`, instances produced by `pgCharDescriptor.factory(params)(ctx)` automatically report the alias's id without prototype-stripping (the legacy `{ ...base, id }` spread pattern lost the prototype on class-instance bases — descriptor-class inheritance never spreads, so the bug is structurally avoided). + +See [packages/3-targets/3-targets/postgres/src/core/codecs.ts](../../packages/3-targets/3-targets/postgres/src/core/codecs.ts) (`pgCharDescriptor`, `pgVarcharDescriptor`) for the canonical pattern. + +## Heterogeneous storage at the runtime layer + +The framework's descriptor registry is keyed by `codecId: string` and stores type-erased descriptor instances. The canonical erasure type is `AnyCodecDescriptor` (defined in `packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts`): + +```ts +interface CodecDescriptorRegistry { + descriptorFor(codecId: string): CodecDescriptor | undefined; + values(): IterableIterator>; + byTargetType(targetType: string): readonly CodecDescriptor[]; +} +``` + +Registries are built from flat descriptor lists (see `buildCodecDescriptorRegistry` in `@prisma-next/sql-relational-core`); there is no imperative `register` on the public surface. + +`CodecDescriptor

` is invariant in `P` (the `factory` and `renderOutputType` slots use `P` contravariantly), so `CodecDescriptor` is **not** assignable from concrete `CodecDescriptor` subclasses — the `` shape would force `as` casts at every register/retrieve boundary. `AnyCodecDescriptor` is the only erasure form that admits cast-free heterogeneous storage. + +Per-codec helpers don't pass through the registry — they're imported directly by extension authors and column-defining sites. The registry exists for runtime lookup (by codec id string), where types are already erased. + +## Why classes work for this design + +The class hierarchy isn't load-bearing for variance preservation (per-codec helpers' direct calls do that work). It's load-bearing for **structure**: + +1. **Codec instance ↔ descriptor reference is structural.** The abstract `CodecImpl` constructor takes a `descriptor: AnyCodecDescriptor`; concrete codec subclasses pass it via `super(descriptor)`. `codec.id` proxies through this reference. Aliases work for free: an alias descriptor produces a codec whose `descriptor` points to the alias, so `codec.id` reports the alias's `codecId` automatically. +2. **Subclass-based authoring is uniform across the codec spectrum.** Non-parameterized, parameterized, schema-typed, alias — all four shapes are expressed as `class X extends CodecDescriptorImpl<...>` with overrides on the abstract members. The variance behavior is identical across all four: the per-codec helper handles literal preservation via direct calls; the descriptor class declares the shape. + +## Reference implementations in the repo + +- **Non-parameterized base codecs** (text, int, float, bool, etc.): `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts`. +- **Postgres adapter codecs and aliases**: `packages/3-targets/3-targets/postgres/src/core/codecs.ts`. +- **SQLite adapter codecs**: `packages/3-targets/3-targets/sqlite/src/core/codecs.ts`. +- **Parameterized codec with literal preservation** (pgvector): `packages/3-extensions/pgvector/src/core/codecs.ts`. +- **Parameterized codec with typed schema** (arktype-json): `packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts`. + +## Pitfalls + +- **`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`, 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`) and project with `R extends Codec ? 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`). + +## See also + +- [ADR 208 — Higher-order codecs for parameterized types](../architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) — design rationale and how the codec composes across authoring, emit, and runtime dispatch. +- [ADR 204 — Single-Path Async Codec Runtime](../architecture%20docs/adrs/ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md) — `encode` / `decode` are uniformly Promise-returning at the public boundary. +- [ADR 207 — Codec call context](../architecture%20docs/adrs/ADR%20207%20-%20Codec%20call%20context%20per-query%20AbortSignal%20and%20column%20metadata.md) — the `CodecCallContext` (per-call signal + family-extended column metadata) threaded into every encode/decode invocation. diff --git a/docs/reference/community-generator-migration-analysis.md b/docs/reference/community-generator-migration-analysis.md index c9ec5fa637..1c88ca3660 100644 --- a/docs/reference/community-generator-migration-analysis.md +++ b/docs/reference/community-generator-migration-analysis.md @@ -186,7 +186,7 @@ Users who want to query through Kysely or Drizzle are opting out of Prisma Next' Already addressed: - **Plain TS interfaces / types**: `contract.d.ts` provides zero-dependency typed model definitions. -- **Typed JSON fields** (`prisma-json-types-generator`): Prisma Next has a more capable built-in solution. The `jsonb(schema)` authoring helper accepts any Standard Schema value (arktype, zod, etc.), extracts its JSON Schema into `typeParams.schemaJson`, and uses it for both compile-time typing (emitted into `contract.d.ts` as a concrete type expression) and runtime validation (compiled via Ajv into validators on `ExecutionContext.jsonSchemaValidators`). Strictly more capable than the overlay approach. +- **Typed JSON fields** (`prisma-json-types-generator`): Prisma Next has a more capable built-in solution. Library-bound JSON codecs (e.g. `@prisma-next/extension-arktype-json`) accept a typed schema (arktype, zod, etc.), serialize the schema's IR into the contract, and validate inline inside the resolved codec's `decode` body — used for both compile-time typing (emitted into `contract.d.ts` as a concrete type expression) and runtime validation. Strictly more capable than the overlay approach. - **Repository / custom models** (`prisma-custom-models-generator`): The ORM client's `Collection` subclassing is this pattern done properly — custom collections add domain methods that compose with all built-in query methods and propagate through includes. No scaffolding generator needed. - **Class-based DTOs** (`prisma-class-generator`): Low relevance in Prisma Next's functional/interface-oriented design. diff --git a/docs/reference/framework-gaps.md b/docs/reference/framework-gaps.md index 8292fd73b5..4f5260e14a 100644 --- a/docs/reference/framework-gaps.md +++ b/docs/reference/framework-gaps.md @@ -72,7 +72,7 @@ The JSDoc on `createEncryptionBinding` calls this out explicitly: 1. Pass `(table, column, typeParams)` (or a richer `EncodeContext` object) to `codec.encode` / `codec.decode`. **Drawback**: hot-path overhead on every encode for a need most codecs don't have; the column-aware codec class is narrow. 2. **(Recommended)** Allow extensions to register one codec instance per `StorageTypeInstance` — parameterized codec instantiation. The codec graph already knows which column it serves at construction time; `encode` stays pure. Keeps the codec interface unchanged for the codecs that don't need column identity. -(2) is the natural fit for the existing `parameterizedCodecs` slot (`[reference/cipherstash/stack/packages/stack/src/prisma/exports/runtime.ts (L78–L83)](../../../reference/cipherstash/stack/packages/stack/src/prisma/exports/runtime.ts)` — the integration already declares a `paramsSchema` for the storage codec but the runtime never actually instantiates per-instance codecs from it). The slot exists; honoring it closes the gap. +(2) is the natural fit for the unified `CodecDescriptor` factory (`[reference/cipherstash/stack/packages/stack/src/prisma/exports/runtime.ts (L78–L83)](../../../reference/cipherstash/stack/packages/stack/src/prisma/exports/runtime.ts)` — the integration already declares a `paramsSchema` for the storage codec but the runtime never actually instantiates per-instance codecs from it). The descriptor's curried `factory(params)(ctx)` signature exists for exactly this use case; honoring it closes the gap. Tracked upstream as part of [TML-2330](https://linear.app/prisma-company/issue/TML-2330) (column-context plumbing). diff --git a/docs/reference/typescript-patterns.md b/docs/reference/typescript-patterns.md index 97c8511233..46de87ed73 100644 --- a/docs/reference/typescript-patterns.md +++ b/docs/reference/typescript-patterns.md @@ -92,32 +92,26 @@ This aligns with the "Types-Only Emission" principle and allows for better abstr **✅ CORRECT: Export interface and factory function** ```typescript -export interface CodecRegistry { - register(codec: Codec): void; - get(id: string): Codec | undefined; +export interface ColumnRegistry { + register(column: Column): void; + get(id: string): Column | undefined; has(id: string): boolean; // ... other methods } -export function createCodecRegistry(): CodecRegistry { - return new CodecRegistryImpl(); // Private implementation class -} - -// Private implementation - not exported -class CodecRegistryImpl implements CodecRegistry { - private codecs = new Map>(); - - register(codec: Codec): void { - this.codecs.set(codec.id, codec); - } - - get(id: string): Codec | undefined { - return this.codecs.get(id); - } - - has(id: string): boolean { - return this.codecs.has(id); - } +export function createColumnRegistry(): ColumnRegistry { + const columns = new Map(); + return { + register(column) { + columns.set(column.id, column); + }, + get(id) { + return columns.get(id); + }, + has(id) { + return columns.has(id); + }, + }; } ``` @@ -125,16 +119,14 @@ class CodecRegistryImpl implements CodecRegistry { ```typescript // Don't do this - exposes implementation details -export class CodecRegistry { - private codecs = new Map>(); +export class ColumnRegistry { + private columns = new Map(); // ... } ``` ### Examples in Codebase -- `CodecRegistry` → `createCodecRegistry()` -- `CodecDefBuilder` → `defineCodecs()` - `Runtime` → `createRuntime()` - `PostgresDriver` → `postgresRuntimeDriverDescriptor.create()` + `connect(binding)` - `PostgresAdapter` → `createPostgresAdapter()` @@ -310,7 +302,7 @@ Use `Record` for empty object types without index signatures: **❌ WRONG: `{}` can be problematic** ```typescript -type CodecDefBuilder< +type CodecMap< ScalarNames extends { readonly [K in keyof ScalarNames]: Codec } = {} > = { // ... @@ -320,7 +312,7 @@ type CodecDefBuilder< **✅ CORRECT: `Record` is explicit empty type** ```typescript -type CodecDefBuilder< +type CodecMap< ScalarNames extends { readonly [K in keyof ScalarNames]: Codec } = Record > = { // ... @@ -335,7 +327,7 @@ When extracting literal types from codecs, use mapped types that extract keys (w ```typescript // Extract the Id type from a Codec by using the key in a mapped type -type ExtractDataTypes< +type ExtractCodecIds< ScalarNames extends { readonly [K in keyof ScalarNames]: Codec } > = { readonly [K in keyof ScalarNames]: ScalarNames[K] extends Codec @@ -606,16 +598,16 @@ It's easy to add unnecessary type casts (`as unknown as T`) or optional chaining ```typescript // Codec accepts string | Date, but we cast Date to string -const codec = codecDefinitions['timestamp']?.codec; -const encoded = codec.encode!(date as unknown as string); // Unnecessary cast! +const c = codecLookup.get('pg/timestamptz@1'); +const encoded = c.encode(date as unknown as string); // Unnecessary cast! ``` **✅ CORRECT: Check the actual type signature first** ```typescript -// Codec interface: encode?(value: string | Date): string -const codec = codecDefinitions['timestamp'].codec; -const encoded = codec.encode!(date); // Date is already accepted! +// Codec interface: encode(value: string | Date): Promise +const c = codecLookup.get('pg/timestamptz@1'); +const encoded = await c.encode(date); // Date is already accepted! ``` **When to use type casts:** @@ -628,23 +620,20 @@ const encoded = codec.encode!(date); // Date is already accepted! **❌ WRONG: Using optional chaining when values are guaranteed to exist** ```typescript -// codecDefinitions['timestamp'] is guaranteed to exist in tests -const codec = codecDefinitions['timestamp']?.codec as - | { encode: (value: string | Date) => string } +// codecLookup.get('pg/timestamptz@1') is guaranteed to return a codec in tests +const c = codecLookup.get('pg/timestamptz@1') as + | { encode: (value: string | Date) => Promise } | undefined; -if (!codec) { +if (!c) { throw new Error('codec not found'); } ``` -**✅ CORRECT: Use dot notation when values are guaranteed** +**✅ CORRECT: Use a non-null assertion (or assert) when values are guaranteed** ```typescript -// In test context, codecDefinitions['timestamp'] is guaranteed to exist -const codec = codecDefinitions['timestamp'].codec as { - encode: (value: string | Date) => string; - decode: (wire: string | Date) => string; -}; +// In test context, the codec lookup always has the timestamp codec registered +const c = codecLookup.get('pg/timestamptz@1')!; ``` **When to use optional chaining:** @@ -657,18 +646,15 @@ const codec = codecDefinitions['timestamp'].codec as { **❌ WRONG: Adding `| undefined` to type assertions when values are guaranteed** ```typescript -const codec = codecDefinitions['timestamp']?.codec as - | { encode: (value: string | Date) => string } +const c = codecLookup.get('pg/timestamptz@1') as + | { encode: (value: string | Date) => Promise } | undefined; // Unnecessary - value is guaranteed to exist ``` **✅ CORRECT: Only include `| undefined` if the value might actually be undefined** ```typescript -const codec = codecDefinitions['timestamp'].codec as { - encode: (value: string | Date) => string; - decode: (wire: string | Date) => string; -}; +const c = codecLookup.get('pg/timestamptz@1')!; ``` ### Best Practices @@ -744,8 +730,7 @@ const _plan = sql({ contract, adapter }) type Row = ResultType; // _plan indicates it's intentionally unused ``` -**Biome Configuration:** -The `noUnusedVariables` rule is configured to ignore variables starting with `_` via the `ignorePattern: '^_'` option. +**Biome Configuration:** The `noUnusedVariables` rule is configured to ignore variables starting with `_` via the `ignorePattern: '^_'` option. ### Empty Object Types diff --git a/examples/prisma-next-demo/test/no-emit-typed-flow.test-d.ts b/examples/prisma-next-demo/test/no-emit-typed-flow.test-d.ts new file mode 100644 index 0000000000..e9e58741a4 --- /dev/null +++ b/examples/prisma-next-demo/test/no-emit-typed-flow.test-d.ts @@ -0,0 +1,51 @@ +/** + * End-to-end no-emit authoring chain type tests. + * + * The no-emit chain is the strongest evidence the typed flow works as designed: authoring a contract via the TS callback surface (`defineContract` + `field.*` builders) produces a contract whose model + field types flow through to the SQL builder lane, so that: + * + * - `field.id.uuidv4()` resolves to a string-shaped field; + * - `fns.eq(f.id, '')` typechecks at the where clause; + * - `fns.eq(f.id, 1234)` fails to typecheck (id is a string, not a number). + * + * The contract is the demo's own no-emit `contract` value (typed via `defineContract` callback inference, not via `contract.d.ts`). + */ + +import { expectTypeOf, test } from 'vitest'; +import type { contract } from '../prisma/contract'; +import { sql } from '../src/prisma-no-emit/context'; + +test('field.id.uuidv4() produces a string-typed id field on User', () => { + type UserStorageFields = (typeof contract.models)['User']['storage']['fields']; + expectTypeOf().toHaveProperty('id'); + type IdField = UserStorageFields['id']; + expectTypeOf().toHaveProperty('column'); + // Assert the column resolves to a string at the typed-leaf boundary, not just "has a column property". Without this assertion the test would pass for any field whose IR carries a `column` slot, defeating the no-emit-chain evidence claim. Use `toExtend` rather than `toEqualTypeOf` because the storage field type narrows the column to `string` at the leaf — a stricter codec type that resolves to a string-extending shape is acceptable. + expectTypeOf().toExtend(); + // Confirm the column is *not* `never` (which is the failure mode if the codec lookup mis-resolves and the type system widens to bottom). + expectTypeOf().not.toBeNever(); +}); + +test('fns.eq(f.id, "") typechecks on the User table', () => { + const plan = sql.user + .select('id', 'email') + .where((f, fns) => fns.eq(f.id, 'b3a1f8e0-1234-4f5a-9876-abcdef012345')) + .limit(1) + .build(); + expectTypeOf(plan).not.toBeNever(); +}); + +test('fns.eq(f.id, 1234) fails to typecheck — id is a string, not a number', () => { + sql.user + .select('id', 'email') + // @ts-expect-error -- id is string-typed; comparing to a number violates the typed flow + .where((f, fns) => fns.eq(f.id, 1234)) + .limit(1) + .build(); +}); + +test('authoring chain preserves model + field types end-to-end', () => { + expectTypeOf().toExtend<'User' | 'Post'>(); + type PostStorageFields = (typeof contract.models)['Post']['storage']['fields']; + expectTypeOf().toHaveProperty('title'); + expectTypeOf().toHaveProperty('userId'); +}); diff --git a/examples/prisma-next-demo/test/runtime.offline.integration.test.ts b/examples/prisma-next-demo/test/runtime.offline.integration.test.ts index 06c7283352..2b90002b49 100644 --- a/examples/prisma-next-demo/test/runtime.offline.integration.test.ts +++ b/examples/prisma-next-demo/test/runtime.offline.integration.test.ts @@ -24,10 +24,16 @@ describe('static context (no runtime)', () => { expect(ast.limit).toBe(1); expect(ast.projection).toHaveLength(2); expect(ast.projection[0]).toEqual( - ProjectionItem.of('id', IdentifierRef.of('id'), 'sql/char@1'), + ProjectionItem.of('id', IdentifierRef.of('id'), 'sql/char@1', { + table: 'user', + column: 'id', + }), ); expect(ast.projection[1]).toEqual( - ProjectionItem.of('email', IdentifierRef.of('email'), 'pg/text@1'), + ProjectionItem.of('email', IdentifierRef.of('email'), 'pg/text@1', { + table: 'user', + column: 'email', + }), ); }); diff --git a/packages/1-framework/0-foundation/contract/package.json b/packages/1-framework/0-foundation/contract/package.json index c491e9f15d..cd2756310d 100644 --- a/packages/1-framework/0-foundation/contract/package.json +++ b/packages/1-framework/0-foundation/contract/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@prisma-next/utils": "workspace:*", + "@standard-schema/spec": "^1.1.0", "arktype": "catalog:" }, "devDependencies": { diff --git a/packages/1-framework/1-core/framework-components/README.md b/packages/1-framework/1-core/framework-components/README.md index 7c112b1c1a..daf335da26 100644 --- a/packages/1-framework/1-core/framework-components/README.md +++ b/packages/1-framework/1-core/framework-components/README.md @@ -1,8 +1,6 @@ # @prisma-next/framework-components -> **Internal package.** This package is an implementation detail of [`prisma-next`](https://www.npmjs.com/package/prisma-next) -> and is published only to support its runtime. Its API is unstable and may change -> without notice. Do not depend on this package directly; install `prisma-next` instead. +> **Internal package.** This package is an implementation detail of [`prisma-next`](https://www.npmjs.com/package/prisma-next) and is published only to support its runtime. Its API is unstable and may change without notice. Do not depend on this package directly; install `prisma-next` instead. Framework component types, authoring logic, control stack assembly, and emission SPI for Prisma Next. @@ -31,7 +29,7 @@ import { RuntimeCore, runWithMiddleware, type RuntimeMiddleware } from '@prisma- The base `Codec` interface lands on the seam between **query-time** methods (per-row, IO-relevant) and **build-time** methods (per-contract-load): -- Query-time: `encode(value): Promise` and `decode(wire): Promise` are required and **Promise-returning at the public boundary**, regardless of whether the codec body is synchronous or asynchronous. Family factories (`codec()` for SQL, `mongoCodec()` for Mongo) accept either sync or async author functions and lift sync ones to Promise-shaped methods, so authors write whichever shape is natural per method without annotations. +- Query-time: `encode(value): Promise` and `decode(wire): Promise` are required and **Promise-returning at the public boundary**. Codec authors extend `CodecImpl` (per [ADR 208 — Higher-order codecs for parameterized types](../../../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md)); a logically synchronous body still has to return a `Promise`-compatible value (mark the method `async`, or return `Promise.resolve(...)` explicitly). The runtime always awaits the result. - Build-time: `encodeJson`, `decodeJson`, and the optional `renderOutputType` are **synchronous** so `validateContract` and client construction stay synchronous. There is no `runtime` / `kind` / equivalent async marker on the interface and no `TRuntime` generic. The runtime always awaits the query-time methods. See [ADR 204 — Single-Path Async Codec Runtime](../../../../../docs/architecture%20docs/adrs/ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md) for the full design. @@ -58,27 +56,28 @@ Codec authors who write `(value) => …` continue to compile via TypeScript's bi Family layers extend the context where they have a per-call concept that doesn't generalise. SQL declares `SqlCodecCallContext extends CodecCallContext { column?: SqlColumnRef }` (see `@prisma-next/sql-relational-core`); Mongo continues to use the framework type directly. Codec authors that take a `(value, ctx)` author signature can forward `ctx.signal` to network SDKs: ```ts -// Sketch — actual factory lives in a family package (codec() / mongoCodec()). -encode: async (v: string, ctx) => - kms.encrypt({ plaintext: v }, { signal: ctx?.signal }); +// Sketch — codec authors extend `CodecImpl`; class methods receive `(value, ctx)`. +async encode(v: string, ctx: CodecCallContext): Promise { + return kms.encrypt({ plaintext: v }, { signal: ctx.signal }); +} ``` Aborts surface to the caller as `RUNTIME.ABORTED` with `details.phase ∈ { 'encode', 'decode', 'stream' }`. Codec bodies that ignore the signal complete in the background (cooperative cancellation). The `runtimeAborted(phase, cause?)` envelope helper and the `raceAgainstAbort(work, signal, phase)` race helper are exported from `@prisma-next/framework-components/runtime`. See [ADR 207 — Codec call context: per-query `AbortSignal` and column metadata](../../../../docs/architecture%20docs/adrs/ADR%20207%20-%20Codec%20call%20context%20per-query%20AbortSignal%20and%20column%20metadata.md) for the full design. -## Higher-order codecs (`CodecDescriptor`, `CodecInstanceContext`, `synthesizeNonParameterizedDescriptor`) +## Higher-order codecs (`CodecDescriptor`, `CodecInstanceContext`) -Codec metadata, parameterized-codec registration, and runtime materialization live on a unified `CodecDescriptor

`: +Codec metadata, parameterized-codec registration, and runtime materialization live on a unified `CodecDescriptor

` — the only registration shape framework consumers see: ```ts import type { CodecDescriptor, CodecInstanceContext } from '@prisma-next/framework-components/codec'; -import { synthesizeNonParameterizedDescriptor, voidParamsSchema } from '@prisma-next/framework-components/codec'; +import { voidParamsSchema } from '@prisma-next/framework-components/codec'; ``` -- `CodecDescriptor

` carries `codecId`, `traits`, `targetTypes`, `meta`, `paramsSchema: StandardSchemaV1

`, optional `renderOutputType`, and a curried `factory: (P) => (CodecInstanceContext) => Codec`. Non-parameterized codecs use `P = void` and a constant factory; parameterized codecs use a non-empty `P` (e.g. `{ length: number }` for pgvector). +- `CodecDescriptor

` carries `codecId`, `traits`, `targetTypes`, `meta`, `paramsSchema: StandardSchemaV1

`, optional `renderOutputType`, and a curried `factory: (P) => (CodecInstanceContext) => Codec`. Non-parameterized codecs use `P = void` (with the framework-supplied `voidParamsSchema`) and a constant factory; parameterized codecs use a non-empty `P` (e.g. `{ length: number }` for pgvector). - `CodecInstanceContext` (family-agnostic, `{ name }` only) is supplied by the runtime when materializing a per-instance codec. Pack authors close over it inside the factory; they never construct it. This is the **per-materialization** context, sibling to the **per-call** `CodecCallContext` documented above. Family-specific extensions augment it — the SQL family ships `SqlCodecInstanceContext extends CodecInstanceContext` in `@prisma-next/sql-relational-core/ast`, adding `usedAt: ReadonlyArray<{ table; column }>` for SQL-domain codecs that need column-set metadata. -- `synthesizeNonParameterizedDescriptor(codec)` wraps an existing `Codec` (with its async `encode`/`decode` per ADR 204) into a `CodecDescriptor` whose constant factory returns the same shared codec instance for every column. The synthesis bridge keeps non-parameterized codec contributors on the legacy `codecs:` slot while the unified descriptor map remains the single read source for codec-id-keyed metadata. +- Contributors expose their descriptors through `ComponentMetadata.types.codecTypes.codecDescriptors` and the unified `codecs: () => ReadonlyArray` slot. `extractCodecLookup` reads `targetTypes` / `meta` / `renderOutputType` directly off the descriptors — there is no parameterized vs. non-parameterized split and no synthesis bridge. `paramsSchema` is typed as Standard Schema (`StandardSchemaV1

`), not arktype-specific. arktype `Type`s satisfy the shape via their `~standard` getter, so existing arktype-typed descriptors satisfy the new shape transparently while `framework-components` itself takes no dependency on arktype. diff --git a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts index 84f01f2f54..68e31a0af5 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts @@ -1,4 +1,5 @@ -import type { CodecLookup } from '../shared/codec-types'; +import type { Codec } from '../shared/codec'; +import type { CodecLookup, CodecMeta } from '../shared/codec-types'; import type { AuthoringContributions, AuthoringFieldNamespace, @@ -270,27 +271,65 @@ export function assembleControlMutationDefaults( } export function extractCodecLookup( - descriptors: ReadonlyArray>, + descriptors: ReadonlyArray>, ): CodecLookup { - const byId = new Map(); + const byId = new Map(); + const targetTypesById = new Map(); + const metaById = new Map(); + const renderersById = new Map) => string | undefined>(); const owners = new Map(); for (const descriptor of descriptors) { - const codecInstances = descriptor.types?.codecTypes?.codecInstances; - if (!codecInstances) continue; - const descriptorId = descriptor.id ?? ''; - for (const codec of codecInstances) { + const codecTypes = descriptor.types?.codecTypes; + const descriptorId = descriptor.id; + // Descriptor-side metadata is the single source of truth for `targetTypes` / `meta` / `renderOutputType`. Every contributor ships a `codecDescriptors` list on `types.codecTypes`. + for (const codecDescriptor of codecTypes?.codecDescriptors ?? []) { assertUniqueCodecOwner({ - codecId: codec.id, + codecId: codecDescriptor.codecId, owners, descriptorId, - entityLabel: 'codec instance', - entityOwnershipLabel: 'codec instance provider', + entityLabel: 'codec descriptor', + entityOwnershipLabel: 'codec descriptor provider', }); - owners.set(codec.id, descriptorId); - byId.set(codec.id, codec); + owners.set(codecDescriptor.codecId, descriptorId); + if (Array.isArray(codecDescriptor.targetTypes)) { + targetTypesById.set(codecDescriptor.codecId, codecDescriptor.targetTypes); + } + if (codecDescriptor.meta !== undefined) { + metaById.set(codecDescriptor.codecId, codecDescriptor.meta); + } + if (typeof codecDescriptor.renderOutputType === 'function') { + renderersById.set(codecDescriptor.codecId, codecDescriptor.renderOutputType); + } + // Materialize a representative `Codec` instance for `byId.get()` so consumers reading the lookup's instance side (e.g. SQL renderer's cast-policy lookup, or the contract emitter's literal-default `encodeJson` resolver) keep finding the codec. + // + // Two cohorts: + // - Non-parameterized descriptors: factory must succeed; any throw is a real bug and we let it propagate (no silent try/catch). + // - Parameterized descriptors: try with empty params. Many parameterized codecs treat params as advisory (e.g. `pg/timestamptz@1` whose precision is rendered into the `nativeType` only and never read by the runtime codec), so an empty-params construction yields a usable representative for id-keyed lookups (e.g. emit-time literal-default encoding). Codecs whose factory genuinely requires params (e.g. `pg/vector@1` threading `length` into the runtime codec) will throw; for those, per-column instances are materialized at runtime by `buildContractCodecRegistry` and the id-keyed lookup miss is correct (the column-aware path resolves them). + if (!byId.has(codecDescriptor.codecId)) { + if (codecDescriptor.isParameterized) { + try { + const representative = codecDescriptor.factory({} as never)({ + name: ``, + } as Parameters>[0]); + byId.set(codecDescriptor.codecId, representative); + } catch { + // Factory requires concrete params; skip representative materialization. Per-column instances are built at runtime; id-keyed lookup miss is the correct outcome here. + } + } else { + const representative = codecDescriptor.factory(undefined as never)({ + name: ``, + } as Parameters>[0]); + byId.set(codecDescriptor.codecId, representative); + } + } } } - return { get: (id) => byId.get(id) }; + return { + get: (id) => byId.get(id), + targetTypesFor: (id) => targetTypesById.get(id), + metaFor: (id) => metaById.get(id), + renderOutputTypeFor: (id, params) => renderersById.get(id)?.(params), + }; } export function validateScalarTypeCodecIds( diff --git a/packages/1-framework/1-core/framework-components/src/exports/codec.ts b/packages/1-framework/1-core/framework-components/src/exports/codec.ts index c1e3c139b8..c51eb187c6 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/codec.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/codec.ts @@ -1,14 +1,27 @@ +/** + * Codec model: interfaces (consumer surface) plus abstract `Impl` classes (codec-author surface) plus the column packager. + * + * Consumers depend on the interfaces: {@link Codec}, {@link CodecDescriptor}, {@link AnyCodecDescriptor}, {@link ColumnSpec}, {@link ColumnTypeDescriptor}. + * + * Codec authors `extend` the abstract bases: {@link CodecImpl} and {@link CodecDescriptorImpl}. They write a per-codec column helper that calls `descriptor.factory(...)` directly and tie the helper to its descriptor with `satisfies ColumnHelperFor` (or `ColumnHelperForStrict`). + */ + +export type { Codec } from '../shared/codec'; +export { CodecImpl } from '../shared/codec'; +export type { AnyCodecDescriptor, CodecDescriptor } from '../shared/codec-descriptor'; +export { CodecDescriptorImpl } from '../shared/codec-descriptor'; export type { - Codec, CodecCallContext, - CodecDescriptor, CodecInstanceContext, CodecLookup, CodecMeta, CodecTrait, } from '../shared/codec-types'; -export { - emptyCodecLookup, - synthesizeNonParameterizedDescriptor, - voidParamsSchema, -} from '../shared/codec-types'; +export { emptyCodecLookup, voidParamsSchema } from '../shared/codec-types'; +export type { + ColumnHelperFor, + ColumnHelperForStrict, + ColumnSpec, + ColumnTypeDescriptor, +} from '../shared/column-spec'; +export { column } from '../shared/column-spec'; diff --git a/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts b/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts new file mode 100644 index 0000000000..ffd61fab25 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts @@ -0,0 +1,87 @@ +/** + * Codec descriptor interface (consumer surface) and abstract `CodecDescriptorImpl` base (codec-author surface). + * + * Consumers depend on the {@link CodecDescriptor} interface — it is the codec-id-keyed source of truth for static metadata (`traits`, `targetTypes`, `meta`) and registration concerns (`paramsSchema`; optional `renderOutputType`). The runtime `Codec` instance returned by `factory(params)(ctx)` carries only the conversion behavior. + * + * Codec authors `extend` the {@link CodecDescriptorImpl} abstract class to declare their codec id, traits, target types, params schema, the `factory(params)` that materializes a typed `Codec<...>`, and (optionally) a `renderOutputType(params)` for the emit path. + * + * The factory's method-level generic is the load-bearing piece for literal preservation: per-codec column helpers invoke `descriptor.factory(...)` *directly*, and the direct call binds the generic at its call site. Type extraction (`ReturnType`, structural matching) widens method generics to their constraint — that's why the column-helper surface is per-codec, not polymorphic. + */ + +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { Codec } from './codec'; +import { + type CodecInstanceContext, + type CodecMeta, + type CodecTrait, + voidParamsSchema, +} from './codec-types'; + +/** + * Unified codec descriptor. Every codec in the framework registers through this shape — non-parameterized codecs use `P = void` and a constant factory that returns the same shared codec instance for every column; parameterized codecs use a non-empty `P` and a curried higher-order factory that returns a per-instance codec. + * + * The descriptor is the codec-id-keyed source of truth for static metadata (`traits`, `targetTypes`, `meta`) and registration concerns (`paramsSchema` for JSON-boundary validation; optional `renderOutputType` for the `contract.d.ts` emit path). The runtime `Codec` instance returned by `factory(params)(ctx)` carries only the conversion behavior. + * + * Whether a codec id "is parameterized" stops being a registration-time distinction — it's a property of `P` on the descriptor. The descriptor map indexes every descriptor by `codecId`; both `descriptorFor(codecId)` and `forColumn(table, column)` resolve through the same map without branching on parameterization. + * + * @template P - The shape of the params accepted by the factory (`void` for non-parameterized codecs; a record like `{ length: number }` for parameterized codecs). + * + * Codec-registry-unification project § Decision. + */ +export interface CodecDescriptor

{ + /** The codec ID this descriptor applies to (e.g. `pg/vector@1`, `pg/text@1`). */ + readonly codecId: string; + /** Semantic traits for operator gating (e.g. equality, order, numeric). */ + readonly traits: readonly CodecTrait[]; + /** Database-native type names this codec handles (e.g. `['timestamptz']`). */ + readonly targetTypes: readonly string[]; + /** Optional family-specific metadata (e.g. SQL-side `db.sql.postgres.nativeType`). */ + readonly meta?: CodecMeta; + /** Standard Schema validator for the factory's params. Validates JSON-sourced params at the contract boundary (PSL → IR; `contract.json` → runtime). For non-parameterized codecs (`P = void`), the schema validates `void`/`undefined` — the framework supplies no params at the call boundary. */ + readonly paramsSchema: StandardSchemaV1

; + /** Whether this descriptor is parameterized — i.e. its `paramsSchema` is something other than the singleton `voidParamsSchema`. Consumers that need to gate column-aware dispatch (e.g. the `validateParamRefRefs` AST-builder pass) read this directly rather than threading a free-floating `(codecId) => boolean` callback. */ + readonly isParameterized: boolean; + /** Emit-path string renderer for `contract.d.ts`. Returns the TypeScript output type expression for given params (e.g. `Vector<1536>`). Optional; absent renderers cause the emitter to fall back to the codec's base output type. Non-parameterized codecs typically omit it. */ + readonly renderOutputType?: (params: P) => string | undefined; + /** The curried higher-order codec. For non-parameterized codecs, the factory is constant — every call returns the same shared codec instance. For parameterized codecs, the factory is called once per `storage.types` instance (or once per inline-`typeParams` column), with `ctx` carrying the column set the resulting codec serves. */ + readonly factory: (params: P) => (ctx: CodecInstanceContext) => Codec; +} + +/** + * Variance-erased {@link CodecDescriptor} alias. `CodecDescriptor

` is invariant in `P` (the `factory` and `renderOutputType` slots use `P` contravariantly), so `CodecDescriptor

` does not extend `CodecDescriptor` for specific `P`. Heterogeneous descriptor collections — e.g. `SqlStaticContributions.codecs:` returning a list that mixes parameterized and non-parameterized descriptors — type against this alias and narrow per codec id at the consumer. + * + * Codec-registry-unification spec § Decision: every codec resolves through one descriptor map; reads are non-branching. + */ +// biome-ignore lint/suspicious/noExplicitAny: variance erasure for heterogeneous descriptor collections +export type AnyCodecDescriptor = CodecDescriptor; + +/** + * Abstract base class for concrete codec descriptors. + * + * Codec authors extend this class with their typed `TParams` and declare `codecId`, `traits`, `targetTypes`, `paramsSchema`, the curried `factory(params)`, and (optionally) `renderOutputType`. + * + * Implements the {@link CodecDescriptor} interface so a concrete subclass instance is directly usable wherever the framework expects a `CodecDescriptor

`. + */ +export abstract class CodecDescriptorImpl implements CodecDescriptor { + abstract readonly codecId: string; + abstract readonly traits: readonly CodecTrait[]; + abstract readonly targetTypes: readonly string[]; + readonly meta?: CodecMeta; + + abstract readonly paramsSchema: StandardSchemaV1; + + /** Boolean derived from `paramsSchema`: `true` whenever the schema is not the singleton `voidParamsSchema`. The framework registry's `validateParamRefRefs` pass reads this through `descriptorFor(codecId).isParameterized` to gate column-ref enforcement. */ + get isParameterized(): boolean { + return this.paramsSchema !== voidParamsSchema; + } + + /** Optional emit-path string renderer for `contract.d.ts`. Returns the TypeScript output type expression for the given params (e.g. `Vector<1536>`). Non-parameterized codecs typically omit it. */ + renderOutputType?(params: TParams): string | undefined; + + /** + * Materialize a curried codec factory for the given params. Concrete subclasses override with a typed return type (e.g. `factory(params: { length: N }): (ctx) => VectorCodec`); per-codec helpers read the typed return at the *direct* call site, which is what preserves method-level generics. Type extraction (e.g. `ReturnType`) widens method generics to their constraint — that's why the column-helper surface is per-codec, not polymorphic. + */ + abstract factory( + params: TParams, + ): (ctx: CodecInstanceContext) => Codec; +} diff --git a/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts b/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts index e21bbd9605..8995fee1d5 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/codec-types.ts @@ -1,212 +1,65 @@ -import type { JsonValue } from '@prisma-next/contract/types'; import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { Codec } from './codec'; -export type CodecTrait = - | 'equality' - | 'order' - | 'boolean' - | 'numeric' - | 'textual' - /** - * The codec carries a per-instance `validate(value: unknown) => - * JsonSchemaValidationResult` function on the resolved codec object that - * the framework's `JsonSchemaValidatorRegistry` consults at runtime. The - * trait gates the `extractValidator` cast from structurally-typed - * `unknown` to a typed validator view. - * - * Retirement target. The unified `CodecDescriptor` model moves - * validation into the resolved codec's `decode` body; the parallel - * `JsonSchemaValidatorRegistry` (and this trait alongside it) retires - * under TML-2357 (T3.5.12). Per-library JSON extensions like - * `@prisma-next/extension-arktype-json` already follow the new pattern. - */ - | 'json-validator'; +export type CodecTrait = 'equality' | 'order' | 'boolean' | 'numeric' | 'textual'; /** - * Per-call context the runtime threads to every `codec.encode` / - * `codec.decode` invocation for a single `runtime.execute()` call. + * Per-call context the runtime threads to every `codec.encode` / `codec.decode` invocation for a single `runtime.execute()` call. * * The framework-level shape is family-agnostic and carries one field: * - * - `signal?: AbortSignal` — per-query cancellation. The runtime returns - * a `RUNTIME.ABORTED` envelope when the signal aborts; codec authors - * who forward `signal` to their underlying SDK get true cancellation - * of in-flight network calls. + * - `signal?: AbortSignal` — per-query cancellation. The runtime returns a `RUNTIME.ABORTED` envelope when the signal aborts; codec authors who forward `signal` to their underlying SDK get true cancellation of in-flight network calls. * - * Family layers extend this base with their own shape-of-call metadata: - * the SQL family adds `column?: SqlColumnRef` via `SqlCodecCallContext` - * (see `@prisma-next/sql-relational-core`). Mongo currently uses this - * framework type unchanged. Column metadata is intentionally **not** on - * the framework type — it is a SQL-family concept rooted in SQL's - * `(table, column)` addressing model and would not generalise to other - * families. + * Family layers extend this base with their own shape-of-call metadata: the SQL family adds `column?: SqlColumnRef` via `SqlCodecCallContext` (see `@prisma-next/sql-relational-core`). Mongo currently uses this framework type unchanged. Column metadata is intentionally **not** on the framework type — it is a SQL-family concept rooted in SQL's `(table, column)` addressing model and would not generalise to other families. * - * The interface is named explicitly (not inlined) so future framework - * fields and family extensions can land additively without breaking - * codec author signatures. + * The interface is named explicitly (not inlined) so future framework fields and family extensions can land additively without breaking codec author signatures. */ export interface CodecCallContext { readonly signal?: AbortSignal; } /** - * A codec is the contract between an application value and its on-wire and - * on-contract-disk representations. + * Codec-id-keyed read surface threaded into emit and authoring paths. * - * The author's mental model is two JS-side types — `TInput` (the - * application JS type) and `TWire` (the database driver wire format) — - * plus `JsonValue` for build-time contract artifacts. The codec translates - * `TInput` to `TWire` on writes and back on reads, and to/from `JsonValue` - * during contract emission and loading. - * - * Three representations participate: - * - **Input** (`TInput`): the JS type at the application boundary. - * - **Wire** (`TWire`): the format exchanged with the database driver. - * - **JSON** (`JsonValue`): a JSON-safe form used in contract artifacts. - * - * Codec methods split into two groups: - * - * - **Query-time** methods (`encode`, `decode`) run per row/parameter at the - * IO boundary; they are required and Promise-returning. The per-family - * codec factory accepts sync or async author functions and lifts sync - * ones to Promise-shaped methods automatically. - * - **Build-time** methods (`encodeJson`, `decodeJson`, `renderOutputType`) - * run when the contract is serialized, loaded, or when client types are - * emitted. They stay synchronous so contract validation and client - * construction are synchronous. - * - * Target-family codec interfaces extend this base with target-shaped - * metadata. + * - `get(id)` returns the runtime {@link Codec} instance for the codec id (used by `validateContract` for `decodeJson` of literal column defaults). + * - `targetTypesFor(id)` exposes the codec-id-keyed `targetTypes` metadata the runtime instance no longer carries (TML-2357). Returns the same array `CodecDescriptor.targetTypes` would; for Mongo (whose registration doesn't yet resolve through the unified descriptor map — TML-2324) the family-side assembly populates this directly from the contributor's codec metadata. + * - `metaFor(id)` exposes the codec-id-keyed `meta` (e.g. SQL-side `db.sql.postgres.nativeType`) the runtime instance no longer carries. + * - `renderOutputTypeFor(id, params)` exposes the codec-id-keyed `renderOutputType` renderer the runtime instance no longer carries. Returns `undefined` when the codec doesn't render a custom type or when the codec id is unknown. */ -export interface Codec< - Id extends string = string, - TTraits extends readonly CodecTrait[] = readonly CodecTrait[], - TWire = unknown, - TInput = unknown, -> { - /** Unique codec identifier in `namespace/name@version` format (e.g. `pg/timestamptz@1`). */ - readonly id: Id; - /** Database-native type names this codec handles (e.g. `['timestamptz']`). */ - readonly targetTypes: readonly string[]; - /** Semantic traits for operator gating (e.g. equality, order, numeric). */ - readonly traits?: TTraits; - /** Converts a JS value to the wire format expected by the database driver. Always Promise-returning at the boundary. The {@link CodecCallContext} is supplied by the runtime on every call (allocated once per `runtime.execute()`); family layers may narrow the ctx to extend it (e.g. SQL adds `column`). Author-side single-arg `(value) => …` functions remain legal via TypeScript's bivariance for trailing parameters. */ - encode(value: TInput, ctx: CodecCallContext): Promise; - /** Converts a wire value from the database driver into the JS application type. Always Promise-returning at the boundary. The {@link CodecCallContext} is supplied by the runtime on every call (allocated once per `runtime.execute()`); family layers may narrow the ctx to extend it (e.g. SQL adds `column`). Author-side single-arg `(wire) => …` functions remain legal via TypeScript's bivariance for trailing parameters. */ - decode(wire: TWire, ctx: CodecCallContext): Promise; - /** Converts a JS value to a JSON-safe representation for contract serialization. Synchronous; called during contract emission. */ - encodeJson(value: TInput): JsonValue; - /** Converts a JSON representation back to the JS input type. Synchronous; called during contract loading via `validateContract`. */ - decodeJson(json: JsonValue): TInput; - /** Produces the TypeScript output type expression for a field given its `typeParams`. Synchronous; used during contract.d.ts emission. */ - renderOutputType?(typeParams: Record): string | undefined; -} - export interface CodecLookup { get(id: string): Codec | undefined; + targetTypesFor(id: string): readonly string[] | undefined; + metaFor(id: string): CodecMeta | undefined; + renderOutputTypeFor(id: string, params: Record): string | undefined; } export const emptyCodecLookup: CodecLookup = { get: () => undefined, + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }; /** - * Family-agnostic per-instance context supplied by the framework when - * applying a higher-order codec factory. Allows stateful codecs (e.g. - * column-scoped encryption) to derive per-instance state from the - * materialization site. + * Family-agnostic per-instance context supplied by the framework when applying a higher-order codec factory. Allows stateful codecs (e.g. column-scoped encryption) to derive per-instance state from the materialization site. * - * - `name` — the family-agnostic instance identity. For SQL, the runtime - * populates this as the `storage.types` instance name (e.g. - * `Embedding1536`) for typeRef-shaped columns, the synthesized - * anonymous instance name (``) for inline- - * `typeParams` columns, or a shared sentinel (``) - * for non-parameterized codec ids. Other families pick the analogous - * identity for their materialization sites. + * - `name` — the family-agnostic instance identity. For SQL, the runtime populates this as the `storage.types` instance name (e.g. `Embedding1536`) for typeRef-shaped columns, the synthesized anonymous instance name (``) for inline-`typeParams` columns, or a shared sentinel (``) for non-parameterized codec ids. Other families pick the analogous identity for their materialization sites. * - * Family-specific extensions (e.g. {@link import('@prisma-next/sql-relational-core/ast').SqlCodecInstanceContext} - * in the SQL layer) augment this base with domain-shaped column-set - * metadata. Codec authors target the base when they don't read family- - * specific metadata; they target the family extension when they do. + * Family-specific extensions (e.g. {@link import('@prisma-next/sql-relational-core/ast').SqlCodecInstanceContext} in the SQL layer) augment this base with domain-shaped column-set metadata. Codec authors target the base when they don't read family-specific metadata; they target the family extension when they do. */ export interface CodecInstanceContext { readonly name: string; } /** - * Family-agnostic codec metadata. Family-specific extensions augment the - * base `db..` block with native-type information; the base - * shape is an empty object so non-relational codecs can carry no metadata. + * Family-agnostic codec metadata. Family-specific extensions augment the base `db..` block with native-type information; the base shape is an empty object so non-relational codecs can carry no metadata. */ export interface CodecMeta { readonly db?: Record; } /** - * Unified codec descriptor. Every codec in the framework registers through - * this shape — non-parameterized codecs use `P = void` and a constant - * factory that returns the same shared codec instance for every column; - * parameterized codecs use a non-empty `P` and a curried higher-order - * factory that returns a per-instance codec. - * - * The descriptor is the codec-id-keyed source of truth for static metadata - * (`traits`, `targetTypes`, `meta`) and registration concerns - * (`paramsSchema` for JSON-boundary validation; optional `renderOutputType` - * for the `contract.d.ts` emit path). The runtime `Codec` instance returned - * by `factory(params)(ctx)` carries only the conversion behavior. - * - * Whether a codec id "is parameterized" stops being a registration-time - * distinction — it's a property of `P` on the descriptor. The descriptor - * map indexes every descriptor by `codecId`; both `descriptorFor(codecId)` - * and `forColumn(table, column)` resolve through the same map without - * branching on parameterization. - * - * @template P - The shape of the params accepted by the factory (`void` for - * non-parameterized codecs; a record like `{ length: number }` for - * parameterized codecs). - * - * Codec-registry-unification project § Decision. - */ -export interface CodecDescriptor

{ - /** The codec ID this descriptor applies to (e.g. `pg/vector@1`, `pg/text@1`). */ - readonly codecId: string; - /** Semantic traits for operator gating (e.g. equality, order, numeric). */ - readonly traits: readonly CodecTrait[]; - /** Database-native type names this codec handles (e.g. `['timestamptz']`). */ - readonly targetTypes: readonly string[]; - /** Optional family-specific metadata (e.g. SQL-side `db.sql.postgres.nativeType`). */ - readonly meta?: CodecMeta; - /** - * Standard Schema validator for the factory's params. Validates JSON- - * sourced params at the contract boundary (PSL → IR; `contract.json` → - * runtime). For non-parameterized codecs (`P = void`), the schema - * validates `void`/`undefined` — the framework supplies no params at the - * call boundary. - */ - readonly paramsSchema: StandardSchemaV1

; - /** - * Emit-path string renderer for `contract.d.ts`. Returns the TypeScript - * output type expression for given params (e.g. `Vector<1536>`). - * Optional; absent renderers cause the emitter to fall back to the - * codec's base output type. Non-parameterized codecs typically omit it. - */ - readonly renderOutputType?: (params: P) => string | undefined; - /** - * The curried higher-order codec. For non-parameterized codecs, the - * factory is constant — every call returns the same shared codec - * instance. For parameterized codecs, the factory is called once per - * `storage.types` instance (or once per inline-`typeParams` column), - * with `ctx` carrying the column set the resulting codec serves. - */ - readonly factory: (params: P) => (ctx: CodecInstanceContext) => Codec; -} - -/** - * Standard Schema validator for `void` params. Accepts only `undefined` - * (or absent input); rejects any other value so a contract that tries to - * thread `typeParams` through a non-parameterized codec id fails fast at - * the JSON boundary instead of silently coercing the value away. Used by - * the framework-supplied non-parameterized descriptor synthesizer. + * Standard Schema validator for `void` params. Accepts only `undefined` (or absent input); rejects any other value so a contract that tries to thread `typeParams` through a non-parameterized codec id fails fast at the JSON boundary instead of silently coercing the value away. Used by the framework-supplied non-parameterized descriptor synthesizer. */ export const voidParamsSchema: StandardSchemaV1 = { '~standard': { @@ -224,38 +77,3 @@ export const voidParamsSchema: StandardSchemaV1 = { }, }, }; - -/** - * Synthesize a `CodecDescriptor` for a non-parameterized codec - * runtime instance. The factory is constant — every call returns the same - * shared codec instance — so columns sharing this codec id share one - * resolved codec. - * - * Codec-registry-unification spec § Decision (Case T — non-parameterized - * text codec). This is the bridge while non-parameterized codec - * contributors still register through the legacy `codecs:` slot; once they - * migrate to ship descriptors directly (TML-2357 T3.5.3), this synthesis - * steps aside. - */ -export function synthesizeNonParameterizedDescriptor(codec: Codec): CodecDescriptor { - // The descriptor's `factory: (params: void) => (ctx: CodecInstanceContext) - // => Codec` is a constant for non-parameterized codecs — `params` is - // never read and the returned ctx-applier always yields the same shared - // codec. We rely on the descriptor's typed `factory` slot to infer the - // signatures rather than naming `void` locally (biome's - // `noConfusingVoidType` flags `void` outside return positions). - const sharedFactory = () => () => codec; - // Family-extended codecs (SQL `Codec`) carry an optional `meta` field - // that the base interface doesn't declare. Read it through a structural - // narrow so the synthesizer forwards it to the descriptor without losing - // type safety on the base shape. - const codecMeta = (codec as { readonly meta?: CodecMeta }).meta; - return { - codecId: codec.id, - traits: codec.traits ?? [], - targetTypes: codec.targetTypes, - paramsSchema: voidParamsSchema, - factory: sharedFactory, - ...(codecMeta !== undefined ? { meta: codecMeta } : {}), - }; -} diff --git a/packages/1-framework/1-core/framework-components/src/shared/codec.ts b/packages/1-framework/1-core/framework-components/src/shared/codec.ts new file mode 100644 index 0000000000..83b0af172e --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/codec.ts @@ -0,0 +1,80 @@ +/** + * Codec interface (consumer surface) and abstract `CodecImpl` base (codec-author surface). + * + * Consumers depend on the {@link Codec} interface — it describes the runtime instance returned by a descriptor's curried factory and is what the framework threads through emit, validate, and execute paths. + * + * Codec authors `extend` the {@link CodecImpl} abstract class to declare a typed runtime codec instance. The class carries a variance-erased descriptor reference (`CodecDescriptor`); `id` proxies through the descriptor so one source of truth governs both metadata reads and aliasing semantics (alias subclasses inherit the descriptor's id automatically). + * + * Class generic shape: `Id`, `TTraits`, `TWire`, `TInput`. Method generics on the codec subclass's own surface (e.g. arktype-json's schema generic, pgvector's dimension generic) flow through the subclass's constructor and propagate via the descriptor's typed `factory(params)` return at *direct* call sites. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; +import type { CodecDescriptor } from './codec-descriptor'; +import type { CodecCallContext, CodecTrait } from './codec-types'; + +/** + * A codec is the contract between an application value and its on-wire and on-contract-disk representations. + * + * The author's mental model is two JS-side types — `TInput` (the application JS type) and `TWire` (the database driver wire format) — plus `JsonValue` for build-time contract artifacts. The codec translates `TInput` to `TWire` on writes and back on reads, and to/from `JsonValue` during contract emission and loading. + * + * Three representations participate: + * - **Input** (`TInput`): the JS type at the application boundary. + * - **Wire** (`TWire`): the format exchanged with the database driver. + * - **JSON** (`JsonValue`): a JSON-safe form used in contract artifacts. + * + * The runtime instance carries only its `id` (the descriptor's `codecId`, set by the factory) and the four conversion methods. Static metadata (`traits`, `targetTypes`, `meta`) and the build-time `renderOutputType` renderer live on the {@link CodecDescriptor} keyed by `codecId` — the read-surface single source of truth. Consumers that need them resolve through `descriptorFor(codecId)`. + * + * Codec methods split into two groups: + * + * - **Query-time** methods (`encode`, `decode`) run per row/parameter at the IO boundary; they are required and Promise-returning. The per-family codec factory accepts sync or async author functions and lifts sync ones to Promise-shaped methods automatically. + * - **Build-time** methods (`encodeJson`, `decodeJson`) run when the contract is serialized or loaded. They stay synchronous so contract validation and client construction are synchronous. + * + * Target-family codec interfaces extend this base; family-specific concerns (e.g. the SQL `column?` per-call context) layer on through the `CodecCallContext` extension pattern. + */ +export interface Codec< + Id extends string = string, + TTraits extends readonly CodecTrait[] = readonly CodecTrait[], + TWire = unknown, + TInput = unknown, +> { + /** Unique codec identifier in `namespace/name@version` format (e.g. `pg/timestamptz@1`). The factory sets this to the descriptor's `codecId`; consumers use it as a back-reference for descriptor lookups and for decode-error diagnostics. */ + readonly id: Id; + /** Phantom carrier for the `TTraits` generic; type-only, undefined at runtime. Runtime traits live on {@link CodecDescriptor.traits}. Implemented as a string-key phantom (`__codecTraits`) rather than `unique symbol` so bundlers that split `.d.ts` chunks do not strand symbol identity on chunk-private paths (the same `TS2742` family that the public re-export of `CodecTypes` works around). */ + readonly __codecTraits?: TTraits; + /** Converts a JS value to the wire format expected by the database driver. Always Promise-returning at the boundary. The {@link CodecCallContext} is supplied by the runtime on every call (allocated once per `runtime.execute()`); family layers may narrow the ctx to extend it (e.g. SQL adds `column`). Author-side single-arg `(value) => …` functions remain legal via TypeScript's bivariance for trailing parameters. */ + encode(value: TInput, ctx: CodecCallContext): Promise; + /** Converts a wire value from the database driver into the JS application type. Always Promise-returning at the boundary. The {@link CodecCallContext} is supplied by the runtime on every call (allocated once per `runtime.execute()`); family layers may narrow the ctx to extend it (e.g. SQL adds `column`). Author-side single-arg `(wire) => …` functions remain legal via TypeScript's bivariance for trailing parameters. */ + decode(wire: TWire, ctx: CodecCallContext): Promise; + /** Converts a JS value to a JSON-safe representation for contract serialization. Synchronous; called during contract emission. */ + encodeJson(value: TInput): JsonValue; + /** Converts a JSON representation back to the JS input type. Synchronous; called during contract loading via `validateContract`. */ + decodeJson(json: JsonValue): TInput; +} + +/** + * Abstract base class for concrete codec implementations. + * + * Codec authors extend this class with their typed `Id`, `TTraits`, `TWire`, `TInput` and override `encode`/`decode` (and optionally `encodeJson`/`decodeJson`). The runtime instance carries only its `id` (proxied through the descriptor so alias subclasses inherit the descriptor's id automatically) and the conversion methods — static metadata lives on the {@link CodecDescriptor}. + */ +export abstract class CodecImpl< + Id extends string = string, + TTraits extends readonly CodecTrait[] = readonly CodecTrait[], + TWire = unknown, + TInput = unknown, +> implements Codec +{ + /** + * Variance-erased descriptor reference. Concrete codec subclasses receive the typed descriptor in their own constructors and forward it via `super(descriptor)`; the variance erasure lives at this base because the abstract surface can't carry the concrete `TParams`. + */ + // biome-ignore lint/suspicious/noExplicitAny: variance-erased descriptor reference; subclasses retain typed access via their own state + constructor(public readonly descriptor: CodecDescriptor) {} + + get id(): Id { + return this.descriptor.codecId as Id; + } + + abstract encode(value: TInput, ctx: CodecCallContext): Promise; + abstract decode(wire: TWire, ctx: CodecCallContext): Promise; + abstract encodeJson(value: TInput): JsonValue; + abstract decodeJson(json: JsonValue): TInput; +} diff --git a/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts b/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts new file mode 100644 index 0000000000..4245c729d8 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/shared/column-spec.ts @@ -0,0 +1,83 @@ +/** + * `column()` packager + `ColumnSpec` shape + `ColumnHelperFor` variants for tying per-codec column helpers to their descriptor. + * + * `ColumnSpec` extends {@link ColumnTypeDescriptor} so it remains a drop-in for contract authoring sites that consume `ColumnTypeDescriptor` shapes — both types live at the framework-components layer so the `extends` clause is real (no structural mirror). + * + * `column()` is a trivial, non-polymorphic packager. Generic over `R` (the codec instance type returned by the descriptor's curried factory) and `P` (the typeParams record). The framework does NOT try to infer `R` and `P` from a descriptor — that path is the variance trap. Per-codec helpers absorb the descriptor relationship instead and tie themselves to their descriptor via `satisfies ColumnHelperFor` or `satisfies ColumnHelperForStrict`. + */ + +import type { CodecDescriptor } from './codec-descriptor'; +import type { CodecInstanceContext } from './codec-types'; + +/** + * Authored column-type descriptor — the data shape an authoring site (PSL or TypeScript builders) attaches to a column to identify its codec and its native database type. + * + * Lives at the framework-components layer alongside the codec types so codec-author packages (e.g. column-spec / `column()` packagers) can extend it directly without crossing layer boundaries. + * + * @template TCodecId Narrowed codec id literal for sites that thread a specific codec id through the type system. + */ +export type ColumnTypeDescriptor = { + readonly codecId: TCodecId; + readonly nativeType: string; + readonly typeParams?: Record | undefined; + readonly typeRef?: string; +}; + +/** + * Column spec carrying the codec factory closure alongside the {@link ColumnTypeDescriptor} fields. Codec authors return a `ColumnSpec` from per-codec column helpers; the runtime materializes the codec instance by calling `codecFactory(ctx)` once it knows the column's `CodecInstanceContext`. + * + * Extends {@link ColumnTypeDescriptor} so `ColumnSpec` instances flow directly into contract-authoring sites that consume the descriptor shape — no structural mirroring required. + */ +export interface ColumnSpec | undefined> + extends ColumnTypeDescriptor { + readonly codecFactory: (ctx: CodecInstanceContext) => R; + readonly typeParams: P; +} + +/** + * Trivial column packager. Per-codec helpers call this directly with the result of `descriptor.factory(params)` — direct method invocation binds the descriptor's method-level generic at the call site and the literal flows through `R`. + * + * `nativeType` is the column's database-native type spelling — the value the postgres adapter's migration planner, the SQL renderer's cast policy, and the contract's `meta.db...nativeType` slot read. Per-codec helpers pass the literal native-type string for their codec (e.g. `'text'`, `'int4'`, `'character varying'`); for codecs whose native-type spelling depends on parameters (none today; reserved for future shapes), the helper computes the rendered string before calling `column`. The framework does not derive the value from `codecId` — that mapping is target-specific and lives at the helper. + */ +export function column | undefined>( + codecFactory: (ctx: CodecInstanceContext) => R, + codecId: string, + typeParams: P, + nativeType: string, +): ColumnSpec { + return { + codecFactory, + codecId, + typeParams, + nativeType, + }; +} + +/** + * Coarse `satisfies` shape — checks the helper's typeParams record matches the descriptor's factory params. Catches "wrong typeParams shape" wiring mistakes; does NOT catch "wrong descriptor's factory" mistakes (the codec slot is left as `unknown`). + * + * Use when the codec's `ReturnType` is unstable (e.g. heavily overloaded factories where extraction widens too much). + */ +// biome-ignore lint/suspicious/noExplicitAny: variance erasure — `CodecDescriptor

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

` is invariant in P, so concrete subclasses do not extend `CodecDescriptor`; matches the existing `AnyCodecDescriptor` pattern +export type ColumnHelperForStrict> = ( + // biome-ignore lint/suspicious/noExplicitAny: helper signature is the verification subject; satisfies clauses can't narrow this without circular inference + ...args: any[] +) => ColumnSpec>, ColumnHelperParams>; + +/** + * Coerce a descriptor's `factory` first parameter into the typeParams shape `ColumnSpec` accepts. Non-parameterized descriptors (factory with no params, or `params: void`) collapse to `undefined`; parameterized descriptors keep the params record shape. + */ +// biome-ignore lint/suspicious/noExplicitAny: variance erasure — see above +type ColumnHelperParams> = + Parameters[0] extends Record + ? Parameters[0] + : undefined; diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-components.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-components.ts index 2be70985d5..1b72bf5382 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-components.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-components.ts @@ -1,4 +1,4 @@ -import type { Codec } from './codec-types'; +import type { AnyCodecDescriptor } from './codec-descriptor'; import type { AuthoringContributions } from './framework-authoring'; import type { ControlMutationDefaults } from './mutation-default-types'; import type { TypesImportSpec } from './types-import-spec'; @@ -13,10 +13,7 @@ export interface ComponentMetadata { /** * Capabilities this component provides. * - * For adapters, capabilities must be declared on the adapter descriptor (so they are emitted into - * the contract) and also exposed in runtime adapter code (e.g. `adapter.profile.capabilities`); - * keep these declarations in sync. Targets are identifiers/descriptors and typically do not - * declare capabilities. + * For adapters, capabilities must be declared on the adapter descriptor (so they are emitted into the contract) and also exposed in runtime adapter code (e.g. `adapter.profile.capabilities`); keep these declarations in sync. Targets are identifiers/descriptors and typically do not declare capabilities. */ readonly capabilities?: Record; @@ -24,29 +21,25 @@ export interface ComponentMetadata { readonly types?: { readonly codecTypes?: { /** - * Base codec types import spec. - * Optional: adapters typically provide this, extensions usually don't. + * Base codec types import spec. Optional: adapters typically provide this, extensions usually don't. */ readonly import?: TypesImportSpec; /** * Additional type-only imports for parameterized codec branded types. * - * These imports are included in generated `contract.d.ts` but are NOT treated as - * codec type maps (i.e., they should not be intersected into `export type CodecTypes = ...`). + * These imports are included in generated `contract.d.ts` but are NOT treated as codec type maps (i.e., they should not be intersected into `export type CodecTypes = ...`). * * Example: `Vector` for pgvector codecs that emit `Vector<1536>` */ readonly typeImports?: ReadonlyArray; /** - * Optional control-plane hooks keyed by codecId. - * Used by family-specific planners/verifiers to handle storage types. + * Optional control-plane hooks keyed by codecId. Used by family-specific planners/verifiers to handle storage types. */ readonly controlPlaneHooks?: Record; /** - * Codec instances contributed by this component. - * Used to build a CodecLookup for codec-dispatched type rendering during emission. + * Codec descriptors contributed by this component. Source of truth for codec-id-keyed metadata (`traits`, `targetTypes`, `meta`, `renderOutputType`) consumed by `extractCodecLookup`, and used to materialize representative `Codec` instances for codec-dispatched type rendering during emission. */ - readonly codecInstances?: ReadonlyArray; + readonly codecDescriptors?: ReadonlyArray; }; readonly operationTypes?: { readonly import: TypesImportSpec }; readonly queryOperationTypes?: { readonly import: TypesImportSpec }; @@ -61,21 +54,17 @@ export interface ComponentMetadata { /** * Optional pure-data authoring contributions exposed by this component. * - * These contributions are safe to include on pack refs and descriptors because - * they contain only declarative metadata. Higher-level authoring packages may - * project them into concrete helper functions for TS-first workflows. + * These contributions are safe to include on pack refs and descriptors because they contain only declarative metadata. Higher-level authoring packages may project them into concrete helper functions for TS-first workflows. */ readonly authoring?: AuthoringContributions; /** - * Scalar type name to codec ID mapping contributed by this component. - * Assembled by `createControlStack` with duplicate detection. + * Scalar type name to codec ID mapping contributed by this component. Assembled by `createControlStack` with duplicate detection. */ readonly scalarTypeDescriptors?: ReadonlyMap; /** - * Mutation default function handlers and generator descriptors contributed - * by this component. Assembled by `createControlStack` with duplicate detection. + * Mutation default function handlers and generator descriptors contributed by this component. Assembled by `createControlStack` with duplicate detection. */ readonly controlMutationDefaults?: ControlMutationDefaults; } @@ -83,13 +72,9 @@ export interface ComponentMetadata { /** * Base descriptor for any framework component. * - * All component descriptors share these fundamental properties that identify - * the component and provide its metadata. This interface is extended by - * specific descriptor types (FamilyDescriptor, TargetDescriptor, etc.). + * All component descriptors share these fundamental properties that identify the component and provide its metadata. This interface is extended by specific descriptor types (FamilyDescriptor, TargetDescriptor, etc.). * - * @template Kind - Discriminator literal identifying the component type. - * Built-in kinds are 'family', 'target', 'adapter', 'driver', 'extension', - * but the type accepts any string to allow ecosystem extensions. + * @template Kind - Discriminator literal identifying the component type. Built-in kinds are 'family', 'target', 'adapter', 'driver', 'extension', but the type accepts any string to allow ecosystem extensions. * * @example * ```ts @@ -163,14 +148,12 @@ export function checkContractComponentRequirements( /** * Descriptor for a family component. * - * A "family" represents a category of data sources with shared semantics - * (e.g., SQL databases, document stores). Families define: + * A "family" represents a category of data sources with shared semantics (e.g., SQL databases, document stores). Families define: * - Query semantics and operations (SELECT, INSERT, find, aggregate, etc.) * - Contract structure (tables vs collections, columns vs fields) * - Type system and codecs * - * Families are the top-level grouping. Each family contains multiple targets - * (e.g., SQL family contains Postgres, MySQL, SQLite targets). + * Families are the top-level grouping. Each family contains multiple targets (e.g., SQL family contains Postgres, MySQL, SQLite targets). * * Extended by plane-specific descriptors: * - `ControlFamilyDescriptor` - adds `emission` for CLI/tooling operations @@ -195,13 +178,11 @@ export interface FamilyDescriptor extends ComponentDes /** * Descriptor for a target component. * - * A "target" represents a specific database or data store within a family - * (e.g., Postgres, MySQL, MongoDB). Targets define: + * A "target" represents a specific database or data store within a family (e.g., Postgres, MySQL, MongoDB). Targets define: * - Native type mappings (e.g., Postgres int4 → TypeScript number) * - Target-specific capabilities (e.g., RETURNING, LATERAL joins) * - * Targets are bound to a family and provide the target-specific implementation - * details that adapters and drivers use. + * Targets are bound to a family and provide the target-specific implementation details that adapters and drivers use. * * Extended by plane-specific descriptors: * - `ControlTargetDescriptor` - adds optional `migrations` capability @@ -229,8 +210,7 @@ export interface TargetDescriptor extends ComponentMetadata { @@ -274,14 +254,12 @@ export type DriverPackRef< /** * Descriptor for an adapter component. * - * An "adapter" provides the protocol and dialect implementation for a target. - * Adapters handle: + * An "adapter" provides the protocol and dialect implementation for a target. Adapters handle: * - SQL/query generation (lowering AST to target-specific syntax) * - Codec registration (encoding/decoding between JS and wire types) * - Type mappings and coercions * - * Adapters are bound to a specific family+target combination and work with - * any compatible driver for that target. + * Adapters are bound to a specific family+target combination and work with any compatible driver for that target. * * Extended by plane-specific descriptors: * - `ControlAdapterDescriptor` - control-plane factory @@ -311,16 +289,13 @@ export interface AdapterDescriptor { type Signal = NonNullable; @@ -36,8 +37,7 @@ test('encode/decode call sites accept an explicit ctx (signal optional inside th c.encode(v, ctx); const decodeWithCtx = (c: StringCodec, w: string, ctx: CodecCallContext): Promise => c.decode(w, ctx); - // An empty ctx is legal — `signal` is the only field today and is optional - // inside the context shape. + // An empty ctx is legal — `signal` is the only field today and is optional inside the context shape. const encodeWithEmptyCtx = (c: StringCodec, v: string): Promise => c.encode(v, {}); const decodeWithEmptyCtx = (c: StringCodec, w: string): Promise => c.decode(w, {}); void encodeWithCtx; @@ -46,9 +46,7 @@ test('encode/decode call sites accept an explicit ctx (signal optional inside th void decodeWithEmptyCtx; }); -// ADR 204 walk-back constraints — pinned here so future refactors cannot -// reintroduce a `TRuntime` generic, a discriminator field, conditional -// return types, or other shape complications on the public Codec. +// ADR 204 walk-back constraints — pinned here so future refactors cannot reintroduce a `TRuntime` generic, a discriminator field, conditional return types, or other shape complications on the public Codec. test('Codec carries no `runtime` or `kind` discriminator field', () => { type CodecKeys = keyof Codec; @@ -57,8 +55,7 @@ test('Codec carries no `runtime` or `kind` discriminator field', () => { }); test('Codec has exactly four type parameters (Id, TTraits, TWire, TInput) — no TRuntime', () => { - // If a fifth `TRuntime` generic were added before TWire/TInput, this - // call shape would either fail or produce an unrelated codec type. + // If a fifth `TRuntime` generic were added before TWire/TInput, this call shape would either fail or produce an unrelated codec type. type FourGenericCodec = Codec<'demo/four@1', readonly [], number, string>; expectTypeOf[0]>().toEqualTypeOf(); expectTypeOf>().toExtend>(); diff --git a/packages/1-framework/1-core/framework-components/test/codec-types.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/codec-types.types.test-d.ts index 7bf4cdfeaf..7b8f037c17 100644 --- a/packages/1-framework/1-core/framework-components/test/codec-types.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/codec-types.types.test-d.ts @@ -1,6 +1,7 @@ import type { JsonValue } from '@prisma-next/contract/types'; import { expectTypeOf, test } from 'vitest'; -import type { Codec, CodecTrait } from '../src/shared/codec-types'; +import type { Codec } from '../src/shared/codec'; +import type { CodecTrait } from '../src/shared/codec-types'; test('encode is required and Promise-returning', () => { expectTypeOf().toHaveProperty('encode'); @@ -31,29 +32,27 @@ test('decodeJson is required and synchronous', () => { expectTypeOf().not.toExtend>(); }); -test('renderOutputType is optional and synchronous', () => { - type Render = NonNullable; - expectTypeOf().toBeFunction(); - expectTypeOf>().toEqualTypeOf(); - // optional on the interface - type IsOptional = undefined extends Codec['renderOutputType'] ? true : false; - expectTypeOf().toEqualTypeOf(); -}); - -test('Codec carries no async marker (no runtime/kind/TRuntime fields)', () => { - type CodecKeys = keyof Codec; +test('Codec instance carries only id + the four conversion methods (plus phantom)', () => { + // The runtime instance is narrowed to id + behavior (TML-2357); codec-id-keyed static metadata (`traits`, `targetTypes`, `meta`, `renderOutputType`) lives on `CodecDescriptor` keyed by codecId. The `__codecTraits` slot is a type-only phantom carrier (always `undefined` at runtime) and double-underscored to signal that it is not part of the consumer-facing API surface. + type CodecStringKeys = Extract; const expectedKeys = [ 'id', - 'targetTypes', - 'traits', 'encode', 'decode', 'encodeJson', 'decodeJson', - 'renderOutputType', + '__codecTraits', ] as const; type ExpectedKeys = (typeof expectedKeys)[number]; - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +}); + +test('Codec instance does not carry traits / targetTypes / meta / renderOutputType', () => { + type C = Codec; + expectTypeOf().not.toHaveProperty('traits'); + expectTypeOf().not.toHaveProperty('targetTypes'); + expectTypeOf().not.toHaveProperty('meta'); + expectTypeOf().not.toHaveProperty('renderOutputType'); }); test('Codec carries four generics: encode TInput → TWire, decode TWire → TInput', () => { diff --git a/packages/1-framework/1-core/framework-components/test/codec.test.ts b/packages/1-framework/1-core/framework-components/test/codec.test.ts new file mode 100644 index 0000000000..20a9908811 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/codec.test.ts @@ -0,0 +1,129 @@ +/** + * Framework-components-level runtime tests for the `CodecImpl.id` proxy through `descriptor.codecId`. + * + * The class-based codec hierarchy declares `Codec.id` on the abstract `CodecImpl` base as a getter that returns `this.descriptor.codecId`. Type-level assertions don't exercise the getter, so a regression where someone wires `id` to a hardcoded literal or forgets to pass `super(descriptor)` would slip through type checks — these runtime round-trip tests catch that. + * + * The proxy is also the load-bearing aliasing mechanism: an alias-style descriptor that overrides `codecId` produces codec instances whose `id` reads the alias's id (per spec § Class hierarchy aliasing). The third test below specifically exercises this regression vector. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { test } from 'vitest'; +import { + type CodecCallContext, + type CodecDescriptor, + CodecDescriptorImpl, + CodecImpl, + type CodecInstanceContext, + type CodecTrait, + voidParamsSchema, +} from '../src/exports/codec'; + +class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], number, number> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +class Int4FixtureDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'demo/int4@1' as const; + override readonly traits: readonly CodecTrait[] = ['equality']; + override readonly targetTypes: readonly string[] = ['int4']; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => Int4FixtureCodec { + return () => new Int4FixtureCodec(this); + } +} + +const int4FixtureDescriptor = new Int4FixtureDescriptor(); + +type VectorParams = { readonly length: number }; +const vectorFixtureParamsSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'demo', + validate: (input) => ({ value: input as VectorParams }), + }, +}; + +class VectorFixtureCodec extends CodecImpl< + 'demo/vector@1', + readonly ['equality'], + string, + number[] +> { + constructor( + descriptor: CodecDescriptor, + public readonly dimension: N, + ) { + super(descriptor); + } + async encode(value: number[], _ctx: CodecCallContext): Promise { + return `[${value.join(',')}]`; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire.slice(1, -1).split(',').map(Number); + } + encodeJson(value: number[]): JsonValue { + return value; + } + decodeJson(json: JsonValue): number[] { + return json as number[]; + } +} + +class VectorFixtureDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'demo/vector@1' as const; + override readonly traits: readonly CodecTrait[] = ['equality']; + override readonly targetTypes: readonly string[] = ['vector']; + override readonly paramsSchema = vectorFixtureParamsSchema; + override factory(params: { + readonly length: N; + }): (ctx: CodecInstanceContext) => VectorFixtureCodec { + return () => new VectorFixtureCodec(this, params.length); + } +} + +const vectorFixtureDescriptor = new VectorFixtureDescriptor(); + +const stubCtx = {} as CodecInstanceContext; + +test('CodecImpl.id proxies through descriptor.codecId (non-parameterized)', ({ expect }) => { + const codec = int4FixtureDescriptor.factory()(stubCtx); + expect(codec.id).toBe(int4FixtureDescriptor.codecId); + expect(codec.id).toBe('demo/int4@1'); +}); + +test('CodecImpl.id proxies through descriptor.codecId (parameterized)', ({ expect }) => { + const codec = vectorFixtureDescriptor.factory({ length: 1536 })(stubCtx); + expect(codec.id).toBe(vectorFixtureDescriptor.codecId); + expect(codec.id).toBe('demo/vector@1'); +}); + +test('alias descriptor produces codec whose id reads the alias codecId', ({ expect }) => { + // Spec § Class hierarchy aliasing: an alias descriptor instantiates the same concrete codec class (`Int4FixtureCodec`) but passes itself as the descriptor reference. `CodecImpl.id` proxies through `this.descriptor.codecId`, so the runtime id reads the alias's id even though the codec class hardcodes `'demo/int4@1'` in its type-level `Id` parameter. This test pins that regression vector — a future change that pinned `id` to the codec class's `Id` type literal would silently break aliasing. + // + // The alias extends `CodecDescriptorImpl` directly (not `Int4FixtureDescriptor`) because `Int4FixtureDescriptor.codecId` is narrowed to the literal `'demo/int4@1'`; subclasses can't override it with a different literal under TypeScript's structural overrides. + class AliasedInt4Descriptor extends CodecDescriptorImpl { + override readonly codecId = 'demo/aliased-int@1' as const; + override readonly traits: readonly CodecTrait[] = ['equality']; + override readonly targetTypes: readonly string[] = ['int4']; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => Int4FixtureCodec { + return () => new Int4FixtureCodec(this); + } + } + const aliased = new AliasedInt4Descriptor(); + const codec = aliased.factory()(stubCtx); + expect(codec.id).toBe('demo/aliased-int@1'); + expect(codec.id).not.toBe(int4FixtureDescriptor.codecId); +}); diff --git a/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts new file mode 100644 index 0000000000..3efc444b0a --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts @@ -0,0 +1,213 @@ +/** + * Framework-level type tests for the codec abstract class hierarchy + `column()` packager + `ColumnHelperFor` shapes. + * + * Uses inline fixture descriptors so the test is framework-internal (no cross-package deps). Negative tests assert the variance discipline: literal preservation through per-codec helpers' direct calls; satisfies shape catches typeParams-shape and codec-wiring mistakes. + * + * Refs: TML-2357. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { expectTypeOf, test } from 'vitest'; +import { + type AnyCodecDescriptor, + type Codec, + type CodecCallContext, + type CodecDescriptor, + CodecDescriptorImpl, + CodecImpl, + type CodecInstanceContext, + type CodecTrait, + type ColumnHelperFor, + type ColumnHelperForStrict, + type ColumnSpec, + column, + voidParamsSchema, +} from '../src/exports/codec'; + +class Int4FixtureCodec extends CodecImpl<'demo/int4@1', readonly ['equality'], number, number> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +class Int4FixtureDescriptor extends CodecDescriptorImpl implements CodecDescriptor { + override readonly codecId = 'demo/int4@1' as const; + override readonly traits: readonly CodecTrait[] = ['equality']; + override readonly targetTypes: readonly string[] = ['int4']; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => Int4FixtureCodec { + return () => new Int4FixtureCodec(this); + } +} + +const int4FixtureDescriptor = new Int4FixtureDescriptor(); + +const int4Fixture = () => + column(int4FixtureDescriptor.factory(), int4FixtureDescriptor.codecId, undefined, 'int4'); + +int4Fixture satisfies ColumnHelperFor; +int4Fixture satisfies ColumnHelperForStrict; + +type VectorParams = { readonly length: number }; +const vectorFixtureParamsSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'demo', + validate: (input) => ({ value: input as VectorParams }), + }, +}; + +class VectorFixtureCodec extends CodecImpl< + 'demo/vector@1', + readonly ['equality'], + string, + number[] +> { + constructor( + descriptor: CodecDescriptor, + public readonly dimension: N, + ) { + super(descriptor); + } + async encode(value: number[], _ctx: CodecCallContext): Promise { + return `[${value.join(',')}]`; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire.slice(1, -1).split(',').map(Number); + } + encodeJson(value: number[]): JsonValue { + return value; + } + decodeJson(json: JsonValue): number[] { + return json as number[]; + } +} + +class VectorFixtureDescriptor + extends CodecDescriptorImpl + implements CodecDescriptor +{ + override readonly codecId = 'demo/vector@1' as const; + override readonly traits: readonly CodecTrait[] = ['equality']; + override readonly targetTypes: readonly string[] = ['vector']; + override readonly paramsSchema = vectorFixtureParamsSchema; + override factory(params: { + readonly length: N; + }): (ctx: CodecInstanceContext) => VectorFixtureCodec { + return () => new VectorFixtureCodec(this, params.length); + } +} + +const vectorFixtureDescriptor = new VectorFixtureDescriptor(); + +const vectorFixture = (length: N) => + column( + vectorFixtureDescriptor.factory({ length }), + vectorFixtureDescriptor.codecId, + { length }, + 'vector', + ); + +vectorFixture satisfies ColumnHelperFor; +vectorFixture satisfies ColumnHelperForStrict; + +test('descriptor factory call preserves method-level generic literal', () => { + const factory = vectorFixtureDescriptor.factory({ length: 1536 }); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => VectorFixtureCodec<1536>>(); +}); + +test('per-codec helper preserves literal through column packager', () => { + const col = vectorFixture(1536); + expectTypeOf(col.codecFactory).toEqualTypeOf< + (ctx: CodecInstanceContext) => VectorFixtureCodec<1536> + >(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ length: 1536 }>(); +}); + +test('non-parameterized helper packages void typeParams', () => { + const col = int4Fixture(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => Int4FixtureCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf(); +}); + +test('ResolvedCodec extracts the typed codec from a column spec', () => { + type ResolvedCodec = + C extends ColumnSpec + ? R + : C extends { codecFactory: (ctx: CodecInstanceContext) => infer R } + ? R + : never; + + type EmbeddingResolved = ResolvedCodec>>; + expectTypeOf().toEqualTypeOf>(); +}); + +test('ColumnInputType extracts the codec TInput', () => { + type ResolvedCodec = C extends { codecFactory: (ctx: CodecInstanceContext) => infer R } + ? R + : never; + type ColumnInputType = + ResolvedCodec extends Codec ? T : never; + + expectTypeOf>>>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); +}); + +test('coarse satisfies catches wrong typeParams shape', () => { + const brokenTypeParamsHelper = (length: N) => + column( + vectorFixtureDescriptor.factory({ length }), + vectorFixtureDescriptor.codecId, + { wrongKey: length }, + 'vector', + ); + // @ts-expect-error -- typeParams shape doesn't satisfy ColumnHelperFor (missing `length`) + brokenTypeParamsHelper satisfies ColumnHelperFor; + // @ts-expect-error -- strict shape catches the same mismatch + brokenTypeParamsHelper satisfies ColumnHelperForStrict; +}); + +test('strict satisfies catches wrong codec wired in', () => { + // A helper that wires the int4 fixture's factory into VectorFixtureDescriptor's codec id slot. Coarse satisfies passes (typeParams shape is correct); strict satisfies fails because the codec types differ. + const wrongCodecHelper = (length: N) => + column(int4FixtureDescriptor.factory(), vectorFixtureDescriptor.codecId, { length }, 'vector'); + wrongCodecHelper satisfies ColumnHelperFor; + // @ts-expect-error -- codec is Int4FixtureCodec, not VectorFixtureCodec + wrongCodecHelper satisfies ColumnHelperForStrict; +}); + +test('column packs the helper-supplied nativeType (non-parameterized)', () => { + const col = int4Fixture(); + expectTypeOf(col.nativeType).toEqualTypeOf(); + expectTypeOf(col.codecId).toEqualTypeOf(); + // Runtime confirms the helper's nativeType reaches the spec, distinct from codecId. + if (col.nativeType !== 'int4' || col.codecId !== 'demo/int4@1') { + throw new Error(`nativeType / codecId mismatch: ${col.nativeType} / ${col.codecId}`); + } +}); + +test('column packs the helper-supplied nativeType (parameterized)', () => { + const col = vectorFixture(1536); + expectTypeOf(col.nativeType).toEqualTypeOf(); + if (col.nativeType !== 'vector' || col.codecId !== 'demo/vector@1') { + throw new Error(`nativeType / codecId mismatch: ${col.nativeType} / ${col.codecId}`); + } +}); + +test('AnyCodecDescriptor stores parameterized + non-parameterized descriptors without casts', () => { + // Heterogeneous storage uses `AnyCodecDescriptor` (variance-erased `CodecDescriptor`). `CodecDescriptor

` is invariant in `P`, so concrete subclasses are NOT assignable to `CodecDescriptor` — an `as` cast at the storage boundary would mask the variance violation. The `AnyCodecDescriptor` form removes the cast and the assignments typecheck directly because `CodecDescriptorImpl` is structurally compatible with `CodecDescriptor` regardless of `TParams`. + const reg = new Map(); + reg.set(int4FixtureDescriptor.codecId, int4FixtureDescriptor); + reg.set(vectorFixtureDescriptor.codecId, vectorFixtureDescriptor); + expectTypeOf().toMatchTypeOf>(); +}); diff --git a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts index 078bbad668..12431cba3f 100644 --- a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts +++ b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts @@ -1,4 +1,5 @@ import type { JsonValue } from '@prisma-next/contract/types'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { describe, expect, it } from 'vitest'; import type { CreateControlStackInput } from '../src/control/control-stack'; import { @@ -13,6 +14,8 @@ import { extractQueryOperationTypeImports, validateScalarTypeCodecIds, } from '../src/control/control-stack'; +import type { Codec } from '../src/shared/codec'; +import type { AnyCodecDescriptor } from '../src/shared/codec-descriptor'; import type { CodecLookup } from '../src/shared/codec-types'; import type { ComponentDescriptor } from '../src/shared/framework-components'; @@ -192,12 +195,7 @@ describe('assembleAuthoringContributions', () => { }); it('rejects malformed descriptor values during merge instead of recursing into primitives', () => { - // A descriptor missing `output` fails the canonical leaf guard but is - // a plain object, so the walker would historically recurse INTO it - // and, on the second registration of the same path, try to walk - // through the inner `'fieldPreset'` string of the `kind` property — - // either silently mangling state or infinite-looping. The walker - // now rejects the malformed value with a clear path-aware error. + // A descriptor missing `output` fails the canonical leaf guard but is a plain object, so the walker would historically recurse INTO it and, on the second registration of the same path, try to walk through the inner `'fieldPreset'` string of the `kind` property — either silently mangling state or infinite-looping. The walker now rejects the malformed value with a clear path-aware error. expect(() => assembleAuthoringContributions([ createDescriptor({ @@ -253,26 +251,39 @@ describe('extractCodecLookup', () => { const stubCodec = (id: string) => ({ id, - targetTypes: [], - decode: (w: unknown) => w, + encode: async (v: unknown) => v, + decode: async (v: unknown) => v, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, - }) as unknown as import('../src/shared/codec-types').Codec; + }) as unknown as Codec; + + const stubDescriptor = (id: string): AnyCodecDescriptor => ({ + codecId: id, + traits: [], + targetTypes: [], + paramsSchema: { + '~standard': { + version: 1, + vendor: 'test', + validate: () => ({ value: undefined }), + }, + } as unknown as StandardSchemaV1, + isParameterized: false, + factory: () => () => stubCodec(id), + }); - it('builds a lookup from codec instances across descriptors', () => { - const codec1 = stubCodec('a@1'); - const codec2 = stubCodec('b@1'); + it('builds a lookup from codec descriptors across components', () => { const lookup = extractCodecLookup([ - { id: 'desc-1', types: { codecTypes: { codecInstances: [codec1] } } }, - { id: 'desc-2', types: { codecTypes: { codecInstances: [codec2] } } }, + { id: 'desc-1', types: { codecTypes: { codecDescriptors: [stubDescriptor('a@1')] } } }, + { id: 'desc-2', types: { codecTypes: { codecDescriptors: [stubDescriptor('b@1')] } } }, ]); - expect(lookup.get('a@1')).toBe(codec1); - expect(lookup.get('b@1')).toBe(codec2); + expect(lookup.get('a@1')?.id).toBe('a@1'); + expect(lookup.get('b@1')?.id).toBe('b@1'); }); it('returns undefined for unknown codec ids', () => { const lookup = extractCodecLookup([ - { id: 'desc', types: { codecTypes: { codecInstances: [stubCodec('a@1')] } } }, + { id: 'desc', types: { codecTypes: { codecDescriptors: [stubDescriptor('a@1')] } } }, ]); expect(lookup.get('z@1')).toBeUndefined(); }); @@ -280,10 +291,10 @@ describe('extractCodecLookup', () => { it('throws on duplicate codec ids from different descriptors', () => { expect(() => extractCodecLookup([ - { id: 'desc-1', types: { codecTypes: { codecInstances: [stubCodec('a@1')] } } }, - { id: 'desc-2', types: { codecTypes: { codecInstances: [stubCodec('a@1')] } } }, + { id: 'desc-1', types: { codecTypes: { codecDescriptors: [stubDescriptor('a@1')] } } }, + { id: 'desc-2', types: { codecTypes: { codecDescriptors: [stubDescriptor('a@1')] } } }, ]), - ).toThrow(/Duplicate codec instance for codecId "a@1"/); + ).toThrow(/Duplicate codec descriptor for codecId "a@1"/); }); }); @@ -513,7 +524,12 @@ describe('createControlStack', () => { describe('validateScalarTypeCodecIds', () => { it('returns errors for unregistered codec IDs', () => { const descriptors = new Map([['String', 'missing/codec@1']]); - const lookup = { get: () => undefined }; + const lookup: CodecLookup = { + get: () => undefined, + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, + }; const errors = validateScalarTypeCodecIds(descriptors, lookup); expect(errors).toHaveLength(1); expect(errors[0]).toMatch(/Scalar type "String" references codec "missing\/codec@1"/); @@ -526,13 +542,15 @@ describe('validateScalarTypeCodecIds', () => { id === 'test/text@1' ? { id, - targetTypes: ['text'], encode: async (v: unknown) => v, decode: async (v: unknown) => v, encodeJson: (v: unknown) => v as JsonValue, decodeJson: (v: JsonValue) => v, } : undefined, + targetTypesFor: (id: string) => (id === 'test/text@1' ? ['text'] : undefined), + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }; const errors = validateScalarTypeCodecIds(descriptors, lookup); expect(errors).toEqual([]); diff --git a/packages/1-framework/2-authoring/contract/README.md b/packages/1-framework/2-authoring/contract/README.md index 74d38e30cb..017349b113 100644 --- a/packages/1-framework/2-authoring/contract/README.md +++ b/packages/1-framework/2-authoring/contract/README.md @@ -10,11 +10,12 @@ This package holds the small, target-neutral descriptor vocabulary shared by Pri ## Responsibilities -- **Column descriptors**: `ColumnTypeDescriptor` captures a codec ID, native type, and optional `typeParams` / `typeRef` - **Index descriptors**: `IndexDef` captures index column lists plus optional `name`, `using`, and `config` - **Foreign key defaults**: `ForeignKeyDefaultsState` captures default FK materialization choices shared by authoring surfaces - **Shared authoring vocabulary**: Gives target-family packages such as `@prisma-next/sql-contract-ts` a target-neutral descriptor layer +`ColumnTypeDescriptor` lives in `@prisma-next/framework-components/codec` alongside the codec types. + ## Package Status This package is the extracted shared descriptor layer from the contract authoring split. The current SQL TypeScript authoring implementation lives in `@prisma-next/sql-contract-ts`. @@ -31,7 +32,6 @@ This package is the extracted shared descriptor layer from the contract authorin ## Exports -- `ColumnTypeDescriptor` - `IndexDef` - `ForeignKeyDefaultsState` diff --git a/packages/1-framework/2-authoring/contract/src/descriptors.ts b/packages/1-framework/2-authoring/contract/src/descriptors.ts index 9f7d624484..e4bf7a5fee 100644 --- a/packages/1-framework/2-authoring/contract/src/descriptors.ts +++ b/packages/1-framework/2-authoring/contract/src/descriptors.ts @@ -1,10 +1,3 @@ -export type ColumnTypeDescriptor = { - readonly codecId: TCodecId; - readonly nativeType: string; - readonly typeParams?: Record; - readonly typeRef?: string; -}; - export interface IndexDef { readonly columns: readonly string[]; readonly name?: string; diff --git a/packages/1-framework/2-authoring/contract/src/index.ts b/packages/1-framework/2-authoring/contract/src/index.ts index 6c4a286b56..70d0f9d51b 100644 --- a/packages/1-framework/2-authoring/contract/src/index.ts +++ b/packages/1-framework/2-authoring/contract/src/index.ts @@ -1,5 +1 @@ -export type { - ColumnTypeDescriptor, - ForeignKeyDefaultsState, - IndexDef, -} from './descriptors'; +export type { ForeignKeyDefaultsState, IndexDef } from './descriptors'; diff --git a/packages/1-framework/2-authoring/contract/test/descriptors.test.ts b/packages/1-framework/2-authoring/contract/test/descriptors.test.ts index 80a181d265..8acae65af8 100644 --- a/packages/1-framework/2-authoring/contract/test/descriptors.test.ts +++ b/packages/1-framework/2-authoring/contract/test/descriptors.test.ts @@ -1,19 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { ColumnTypeDescriptor, ForeignKeyDefaultsState, IndexDef } from '../src'; +import type { ForeignKeyDefaultsState, IndexDef } from '../src'; describe('descriptor exports', () => { - it('keeps column descriptors as plain data', () => { - const descriptor: ColumnTypeDescriptor = { - codecId: 'pg/text@1', - nativeType: 'text', - }; - - expect(descriptor).toEqual({ - codecId: 'pg/text@1', - nativeType: 'text', - }); - }); - it('keeps foreign key defaults as plain data', () => { const defaults: ForeignKeyDefaultsState = { constraint: true, diff --git a/packages/1-framework/2-authoring/ids/package.json b/packages/1-framework/2-authoring/ids/package.json index 801400c4ac..dfefefa153 100644 --- a/packages/1-framework/2-authoring/ids/package.json +++ b/packages/1-framework/2-authoring/ids/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@prisma-next/contract": "workspace:*", - "@prisma-next/contract-authoring": "workspace:*", + "@prisma-next/framework-components": "workspace:*", "@prisma-next/utils": "workspace:*", "uniku": "^0.0.12" }, diff --git a/packages/1-framework/2-authoring/ids/src/index.ts b/packages/1-framework/2-authoring/ids/src/index.ts index 3957f7142e..74e8f3ff9f 100644 --- a/packages/1-framework/2-authoring/ids/src/index.ts +++ b/packages/1-framework/2-authoring/ids/src/index.ts @@ -1,5 +1,5 @@ import type { ExecutionMutationDefaultValue } from '@prisma-next/contract/types'; -import type { ColumnTypeDescriptor } from '@prisma-next/contract-authoring'; +import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import { ifDefined } from '@prisma-next/utils/defined'; import { type BuiltinGeneratorId, builtinGeneratorIds } from './generator-ids'; import type { IdGeneratorOptionsById } from './generators'; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/contract-enrichment.ts b/packages/1-framework/3-tooling/cli/src/control-api/contract-enrichment.ts index c0c689efc2..86505b881f 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/contract-enrichment.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/contract-enrichment.ts @@ -73,7 +73,11 @@ function extractExtensionPackMeta( } if (types) { if (types.codecTypes) { - const { controlPlaneHooks: _, codecInstances: _ci, ...cleanedCodecTypes } = types.codecTypes; + const { + controlPlaneHooks: _, + codecDescriptors: _cd, + ...cleanedCodecTypes + } = types.codecTypes; base['types'] = { ...types, codecTypes: cleanedCodecTypes }; } else { base['types'] = types; @@ -83,9 +87,7 @@ function extractExtensionPackMeta( } /** - * Enriches a raw contract with framework-derived metadata: - * capabilities from all component descriptors and extension pack metadata - * from extension descriptors. Produces deterministically sorted output. + * Enriches a raw contract with framework-derived metadata: capabilities from all component descriptors and extension pack metadata from extension descriptors. Produces deterministically sorted output. */ export function enrichContract( ir: Contract, diff --git a/packages/1-framework/3-tooling/cli/test/config-types.test.ts b/packages/1-framework/3-tooling/cli/test/config-types.test.ts index 4f1432a955..2f8114d293 100644 --- a/packages/1-framework/3-tooling/cli/test/config-types.test.ts +++ b/packages/1-framework/3-tooling/cli/test/config-types.test.ts @@ -201,7 +201,12 @@ describe('defineConfig', () => { composedExtensionPacks: [], scalarTypeDescriptors: new Map(), authoringContributions: { field: {}, type: {} }, - codecLookup: { get: () => undefined }, + codecLookup: { + get: () => undefined, + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, + }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), generatorDescriptors: [] }, resolvedInputs: [], }); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/contract-enrichment.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/contract-enrichment.test.ts index 7914ae1325..ca7b0381fe 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/contract-enrichment.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/contract-enrichment.test.ts @@ -180,21 +180,11 @@ describe('enrichContract', () => { }); }); - it('strips controlPlaneHooks and codecInstances from extension pack metadata', () => { + it('strips controlPlaneHooks from extension pack metadata', () => { const extension = makeExtension({ types: { codecTypes: { controlPlaneHooks: { 'pg/vector@1': { expandNativeType: () => 'vector' } }, - codecInstances: [ - { - id: 'pg/vector@1', - targetTypes: ['vector'], - encode: async (v: unknown) => v, - decode: async (v: unknown) => v, - encodeJson: () => null, - decodeJson: () => null, - }, - ], import: { package: '@ext/pgvector', named: 'PgvectorCodecTypes', @@ -210,7 +200,6 @@ describe('enrichContract', () => { const codecTypes = types['codecTypes'] as Record; expect(codecTypes).not.toHaveProperty('controlPlaneHooks'); - expect(codecTypes).not.toHaveProperty('codecInstances'); expect(codecTypes['import']).toBeDefined(); }); diff --git a/packages/1-framework/3-tooling/emitter/src/domain-type-generation.ts b/packages/1-framework/3-tooling/emitter/src/domain-type-generation.ts index 322354c31d..05935ed183 100644 --- a/packages/1-framework/3-tooling/emitter/src/domain-type-generation.ts +++ b/packages/1-framework/3-tooling/emitter/src/domain-type-generation.ts @@ -226,16 +226,9 @@ function applyModifiers(base: string, field: ContractField): string { } /** - * Per-family resolver for typeParams that don't live inline on the - * framework-domain `ContractField`. SQL columns authored via a named - * `storage.types` entry carry their `typeRef` on the storage column - * (family-specific) rather than on the framework's domain field; the - * per-family emitter walks `storage.types[ref].typeParams` here so the - * framework emit path can render the parameterized output type. + * Per-family resolver for typeParams that don't live inline on the framework-domain `ContractField`. SQL columns authored via a named `storage.types` entry carry their `typeRef` on the storage column (family-specific) rather than on the framework's domain field; the per-family emitter walks `storage.types[ref].typeParams` here so the framework emit path can render the parameterized output type. * - * Returns `undefined` when the field has no resolvable typeParams (i.e. - * the column isn't parameterized, isn't a `typeRef`, or the family - * doesn't support named storage types). + * Returns `undefined` when the field has no resolvable typeParams (i.e. the column isn't parameterized, isn't a `typeRef`, or the family doesn't support named storage types). */ export type FieldTypeParamsResolver = ( modelName: string, @@ -256,12 +249,9 @@ export function resolveFieldType( type.typeParams && Object.keys(type.typeParams).length > 0 ? type.typeParams : undefined; const effectiveTypeParams = inlineTypeParams ?? resolvedTypeParams; if (codecLookup && effectiveTypeParams && Object.keys(effectiveTypeParams).length > 0) { - const codec = codecLookup.get(type.codecId); - if (codec?.renderOutputType) { - const rendered = codec.renderOutputType(effectiveTypeParams); - if (rendered && isSafeTypeExpression(rendered)) { - outputResolved = rendered; - } + const rendered = codecLookup.renderOutputTypeFor(type.codecId, effectiveTypeParams); + if (rendered && isSafeTypeExpression(rendered)) { + outputResolved = rendered; } } const codecAccessor = `CodecTypes[${serializeValue(type.codecId)}]`; diff --git a/packages/1-framework/3-tooling/emitter/test/domain-type-generation.test.ts b/packages/1-framework/3-tooling/emitter/test/domain-type-generation.test.ts index 0fef92da91..7cb1a93211 100644 --- a/packages/1-framework/3-tooling/emitter/test/domain-type-generation.test.ts +++ b/packages/1-framework/3-tooling/emitter/test/domain-type-generation.test.ts @@ -79,16 +79,13 @@ describe('serializeValue', () => { }); describe('injection safety', () => { - // Lock the escape behavior so attacker-controlled (or merely weird) strings - // in a schema.prisma cannot break out of the emitted single-quoted literal - // and inject arbitrary TypeScript into contract.d.ts. + // Lock the escape behavior so attacker-controlled (or merely weird) strings in a schema.prisma cannot break out of the emitted single-quoted literal and inject arbitrary TypeScript into contract.d.ts. it('escapes a string attempting to terminate the literal', () => { const injected = "x'; export let foo = 'bar"; const serialized = serializeValue(injected); expect(serialized).toBe("'x\\'; export let foo = \\'bar'"); - // The serialized form is a single valid string literal: exactly two - // outer single quotes, and every inner single quote is backslash-escaped. + // The serialized form is a single valid string literal: exactly two outer single quotes, and every inner single quote is backslash-escaped. expect(serialized.match(/(? { }); it('passes through control characters and line separators as raw bytes', () => { - // U+2028/U+2029 are JavaScript line terminators in legacy parsers. - // The current emitter does not escape them but they cannot break the - // single-quoted literal since they are not \' or \\. Pin the behavior. + // U+2028/U+2029 are JavaScript line terminators in legacy parsers. The current emitter does not escape them but they cannot break the single-quoted literal since they are not \' or \\. Pin the behavior. expect(serializeValue('a\u2028b')).toBe("'a\u2028b'"); expect(serializeValue('a\u2029b')).toBe("'a\u2029b'"); expect(serializeValue('a\nb')).toBe("'a\nb'"); @@ -765,18 +760,28 @@ describe('generateValueObjectTypeAliases', () => { }); }); -function stubCodec(overrides: Partial & { id: string }): Codec { +type CodecStub = Codec & { + readonly targetTypes?: readonly string[]; + readonly renderOutputType?: (params: Record) => string | undefined; +}; + +function stubCodec(overrides: Partial & { id: string }): CodecStub { return { targetTypes: [], decode: (w: unknown) => w, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, ...overrides, - } as unknown as Codec; + } as unknown as CodecStub; } -function stubCodecLookup(codecs: Record): CodecLookup { - return { get: (id) => codecs[id] }; +function stubCodecLookup(codecs: Record): CodecLookup { + return { + get: (id) => codecs[id], + targetTypesFor: (id) => codecs[id]?.targetTypes, + metaFor: () => undefined, + renderOutputTypeFor: (id, params) => codecs[id]?.renderOutputType?.(params), + }; } describe('generateFieldResolvedType', () => { @@ -997,12 +1002,7 @@ describe('resolveFieldType', () => { }); describe('generateBothFieldTypesMaps with resolveFieldTypeParams', () => { - // Phase A: SQL `typeRef`-shaped columns carry their `typeParams` on a named - // `storage.types[ref]` entry rather than inline on the framework's domain - // `ContractField`. The framework emit path consults a per-family resolver - // (`EmissionSpi.resolveFieldTypeParams`) to recover those typeParams so the - // codec's `renderOutputType` runs and the parameterized output type is - // emitted instead of the generic `CodecTypes[...]['output']` fallback. + // SQL `typeRef`-shaped columns carry their `typeParams` on a named `storage.types[ref]` entry rather than inline on the framework's domain `ContractField`. The framework emit path consults a per-family resolver (`EmissionSpi.resolveFieldTypeParams`) to recover those typeParams so the codec's `renderOutputType` runs and the parameterized output type is emitted instead of the generic `CodecTypes[...]['output']` fallback. it('uses resolved typeParams from the family resolver when domain field has none', () => { const lookup = stubCodecLookup({ diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/README.md b/packages/2-mongo-family/1-foundation/mongo-codec/README.md index c1529dce47..366bc3315c 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/README.md +++ b/packages/2-mongo-family/1-foundation/mongo-codec/README.md @@ -4,10 +4,10 @@ Codec interface and registry for MongoDB value serialization. ## Responsibilities -- **Codec interface**: `MongoCodec` — declares how a JS value translates to and from the BSON-shaped wire format the Mongo driver exchanges, plus the JSON-safe form stored in contract artifacts. Carries trait annotations (`equality`, `order`, `boolean`, `numeric`, `textual`, `vector`) for operator gating. Same four generics as the framework `Codec` base. +- **Codec interface**: `MongoCodec` — declares how a JS value translates to and from the BSON-shaped wire format the Mongo driver exchanges, plus the JSON-safe form stored in contract artifacts. Same four generics as the framework `Codec` base; the codec instance carries only `id` plus the four conversion methods. Trait annotations (`equality`, `order`, `boolean`, `numeric`, `textual`, `vector`) for operator gating live on the unified `CodecDescriptor` (see [ADR 208](../../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md)). - **Codec factory**: `mongoCodec()` — creates frozen codec instances from a config object. Both `encode` and `decode` are required so `TInput` and `TWire` are always covered by an explicit author function — the factory installs no identity fallback. `encode` and `decode` may be authored as sync or async functions and are lifted to Promise-returning query-time methods automatically. Build-time methods (`encodeJson`, `decodeJson`) are synchronous and default to identity when omitted. -- **Codec registry**: `MongoCodecRegistry` and `createMongoCodecRegistry()` — a map-based container that stores and retrieves codecs by ID, with duplicate-ID protection -- **Type-level helpers**: `MongoCodecInput` and `MongoCodecTraits` for extracting the input JS type and traits from codec types +- **Codec registry**: `MongoCodecRegistry` and `newMongoCodecRegistry()` — a map-based container that stores and retrieves codecs by ID, with duplicate-ID protection +- **Type-level helper**: `MongoCodecInput` for extracting the JS application type from a codec type. Trait metadata lives on the unified `CodecDescriptor` (see [ADR 208](../../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md)). ## Examples @@ -15,7 +15,6 @@ Codec interface and registry for MongoDB value serialization. // Sync authoring: const intCodec = mongoCodec({ typeId: 'mongo/int@1', - targetTypes: ['int'], encode: (v: number) => v, decode: (w: number) => w, encodeJson: (v: number) => v, @@ -25,7 +24,6 @@ const intCodec = mongoCodec({ // Async authoring (e.g. KMS-backed encryption): same factory, same shape. const secretCodec = mongoCodec({ typeId: 'mongo/secret@1', - targetTypes: ['string'], encode: async (v: string) => encrypt(v, await getKey()), decode: async (w: string) => decrypt(w, await getKey()), encodeJson: (v: string) => v, @@ -41,7 +39,6 @@ Codecs receive a second `ctx` options argument; you may ignore it. The Mongo run // Forward ctx.signal to a network SDK so aborted queries stop the round-trip. const kmsSecretCodec = mongoCodec({ typeId: 'mongo/kms-secret@1', - targetTypes: ['string'], encode: async (v: string, ctx) => kms.encrypt({ plaintext: v }, { signal: ctx?.signal }), decode: async (w: string, ctx) => diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/src/codec-registry.ts b/packages/2-mongo-family/1-foundation/mongo-codec/src/codec-registry.ts index dc37e65505..315e1cb6e9 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/src/codec-registry.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/src/codec-registry.ts @@ -8,33 +8,23 @@ export interface MongoCodecRegistry { values(): IterableIterator>; } -class MongoCodecRegistryImpl implements MongoCodecRegistry { - readonly #byId = new Map>(); - - get(id: string): MongoCodec | undefined { - return this.#byId.get(id); - } - - has(id: string): boolean { - return this.#byId.has(id); - } - - register(codec: MongoCodec): void { - if (this.#byId.has(codec.id)) { - throw new Error(`Codec with ID '${codec.id}' is already registered`); - } - this.#byId.set(codec.id, codec); - } - - *[Symbol.iterator](): Iterator> { - yield* this.#byId.values(); - } - - values(): IterableIterator> { - return this.#byId.values(); - } -} - -export function createMongoCodecRegistry(): MongoCodecRegistry { - return new MongoCodecRegistryImpl(); +/** + * Create a new Mongo codec registry. Inline object literal — no class implementation; the registry is just a private `Map` with the documented surface methods. + */ +export function newMongoCodecRegistry(): MongoCodecRegistry { + const byId = new Map>(); + return { + get: (id) => byId.get(id), + has: (id) => byId.has(id), + register: (codec) => { + if (byId.has(codec.id)) { + throw new Error(`Codec with ID '${codec.id}' is already registered`); + } + byId.set(codec.id, codec); + }, + values: () => byId.values(), + [Symbol.iterator]: function* () { + yield* byId.values(); + }, + }; } diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts b/packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts index a6502b8abd..74c73c98be 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/src/codecs.ts @@ -4,33 +4,23 @@ import type { CodecCallContext, CodecTrait, } from '@prisma-next/framework-components/codec'; -import { ifDefined } from '@prisma-next/utils/defined'; export type MongoCodecTrait = CodecTrait; /** - * A codec for the Mongo target. Translates between an application value - * and the BSON-shaped wire form the Mongo driver exchanges, and between - * an application value and the JSON form stored in contract artifacts. + * A codec for the Mongo target. Translates between an application value and the BSON-shaped wire form the Mongo driver exchanges, and between an application value and the JSON form stored in contract artifacts. * - * Same shape as the framework codec base — see `Codec` in - * `@prisma-next/framework-components/codec` for the contract. The alias - * exists so Mongo-specific metadata can be added here in future without - * touching the framework base. + * Same shape as the framework codec base — see `Codec` in `@prisma-next/framework-components/codec` for the contract. Codec-id-keyed static metadata (`traits`, `targetTypes`, `renderOutputType`) lives on the unified {@link import('@prisma-next/framework-components/codec').CodecDescriptor}; Mongo's full migration to descriptor-side registration is tracked under TML-2324. */ -export type MongoCodec< +export interface MongoCodec< Id extends string = string, TTraits extends readonly MongoCodecTrait[] = readonly MongoCodecTrait[], TWire = unknown, TInput = unknown, -> = BaseCodec; +> extends BaseCodec {} /** - * Conditional bundle for `encodeJson`/`decodeJson`: when `TInput` is - * structurally assignable to `JsonValue` the identity defaults are - * sound and both fields are optional; otherwise both fields are - * required so an author cannot silently produce a non-JSON-safe - * contract artifact. + * Conditional bundle for `encodeJson`/`decodeJson`: when `TInput` is structurally assignable to `JsonValue` the identity defaults are sound and both fields are optional; otherwise both fields are required so an author cannot silently produce a non-JSON-safe contract artifact. */ type JsonRoundTripConfig = [TInput] extends [JsonValue] ? { @@ -45,17 +35,11 @@ type JsonRoundTripConfig = [TInput] extends [JsonValue] /** * Construct a Mongo codec from author functions. * - * Author `encode` and `decode` as sync or async functions; the factory - * produces a {@link MongoCodec} whose query-time methods follow the - * boundary contract documented on the framework {@link BaseCodec}. - * Authors receive a second `ctx` options argument carrying the per-call - * context; ignore it if you don't need it. + * Author `encode` and `decode` as sync or async functions; the factory produces a {@link MongoCodec} whose query-time methods follow the boundary contract documented on the framework {@link BaseCodec}. Authors receive a second `ctx` options argument carrying the per-call context; ignore it if you don't need it. * - * Both `encode` and `decode` are required so `TInput` and `TWire` are - * always covered by an explicit author function — the factory installs - * no identity fallback. `encodeJson` and `decodeJson` default to identity - * **only when `TInput` is assignable to `JsonValue`**; otherwise both are - * required so the contract artifact stays JSON-safe. + * Both `encode` and `decode` are required so `TInput` and `TWire` are always covered by an explicit author function — the factory installs no identity fallback. `encodeJson` and `decodeJson` default to identity **only when `TInput` is assignable to `JsonValue`**; otherwise both are required so the contract artifact stays JSON-safe. + * + * Codec-id-keyed static metadata (`traits`, `targetTypes`, `renderOutputType`) lives on the unified `CodecDescriptor` rather than on the codec instance itself (TML-2357). */ export function mongoCodec< Id extends string, @@ -65,20 +49,12 @@ export function mongoCodec< >( config: { typeId: Id; - targetTypes: readonly string[]; - traits?: TTraits; encode: (value: TInput, ctx: CodecCallContext) => TWire | Promise; decode: (wire: TWire, ctx: CodecCallContext) => TInput | Promise; - renderOutputType?: (typeParams: Record) => string | undefined; } & JsonRoundTripConfig, ): MongoCodec { const identity = (v: unknown) => v; - // The runtime allocates one `CodecCallContext` per `runtime.execute()` - // call (no caller-supplied `signal` produces `{}` instead of `undefined`) - // and threads it as a non-optional reference to every codec call. The - // author surface keeps the second parameter optional so single-arg - // `(value) => …` authors continue to satisfy the signature via - // TypeScript's bivariance for trailing parameters. + // The runtime allocates one `CodecCallContext` per `runtime.execute()` call (no caller-supplied `signal` produces `{}` instead of `undefined`) and threads it as a non-optional reference to every codec call. The author surface keeps the second parameter optional so single-arg `(value) => …` authors continue to satisfy the signature via TypeScript's bivariance for trailing parameters. const userEncode = config.encode; const userDecode = config.decode; const widenedConfig = config as { @@ -87,12 +63,6 @@ export function mongoCodec< }; return { id: config.typeId, - targetTypes: config.targetTypes, - ...ifDefined( - 'traits', - config.traits ? (Object.freeze([...config.traits]) as TTraits) : undefined, - ), - ...ifDefined('renderOutputType', config.renderOutputType), encode: (value, ctx) => { try { return Promise.resolve(userEncode(value, ctx)); @@ -115,6 +85,3 @@ export function mongoCodec< /** Extract the JS application type carried by a Mongo codec — used both as `encode` input and as `decode` output. */ export type MongoCodecInput = T extends MongoCodec ? TInput : never; - -export type MongoCodecTraits = - T extends MongoCodec ? TTraits[number] & MongoCodecTrait : never; diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/src/exports/index.ts b/packages/2-mongo-family/1-foundation/mongo-codec/src/exports/index.ts index e6127e158a..c482948050 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/src/exports/index.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/src/exports/index.ts @@ -1,9 +1,4 @@ export type { MongoCodecRegistry } from '../codec-registry'; -export { createMongoCodecRegistry } from '../codec-registry'; -export type { - MongoCodec, - MongoCodecInput, - MongoCodecTrait, - MongoCodecTraits, -} from '../codecs'; +export { newMongoCodecRegistry } from '../codec-registry'; +export type { MongoCodec, MongoCodecInput, MongoCodecTrait } from '../codecs'; export { mongoCodec } from '../codecs'; diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test-d.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test-d.ts index 7d0833f8f2..a8d5fe825c 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test-d.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test-d.ts @@ -11,7 +11,6 @@ test('Mongo uses the framework CodecCallContext directly (signal-only, no `colum test('mongoCodec() accepts a `(value, ctx)` encode author', () => { const c = mongoCodec({ typeId: 'demo/ctx-encode@1', - targetTypes: ['string'], encode: (value: string, _ctx?: CodecCallContext) => value, decode: (wire: string) => wire, }); @@ -22,7 +21,6 @@ test('mongoCodec() accepts a `(value, ctx)` encode author', () => { test('mongoCodec() accepts a `(value, ctx)` decode author', () => { const c = mongoCodec({ typeId: 'demo/ctx-decode@1', - targetTypes: ['string'], encode: (value: string) => value, decode: (wire: string, _ctx?: CodecCallContext) => wire, }); @@ -32,7 +30,6 @@ test('mongoCodec() accepts a `(value, ctx)` decode author', () => { test('mongoCodec() accepts a single-arg `(value)` encode author and exposes a Promise method', () => { const c = mongoCodec({ typeId: 'demo/single-encode@1', - targetTypes: ['string'], encode: (value: string) => value, decode: (wire: string) => wire, }); @@ -42,7 +39,6 @@ test('mongoCodec() accepts a single-arg `(value)` encode author and exposes a Pr test('MongoCodec.encode and MongoCodec.decode require a ctx argument', () => { const c = mongoCodec({ typeId: 'demo/require-ctx@1', - targetTypes: ['string'], encode: (value: string) => value, decode: (wire: string) => wire, }); diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test.ts index 115a1b5080..b5dd8e05dc 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs-ctx.test.ts @@ -6,7 +6,6 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { it('lifts a single-arg `(value)` author unchanged (back-compat)', async () => { const c = mongoCodec({ typeId: 'demo/single-arg-encode@1', - targetTypes: ['string'], encode: (value: string) => value.toUpperCase(), decode: (wire: string) => wire, }); @@ -17,7 +16,6 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { let observed: CodecCallContext | undefined; const c = mongoCodec({ typeId: 'demo/ctx-encode@1', - targetTypes: ['string'], encode: (value: string, ctx?: CodecCallContext) => { observed = ctx; return value; @@ -35,7 +33,6 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { let observed: CodecCallContext | undefined; const c = mongoCodec({ typeId: 'demo/ctx-decode@1', - targetTypes: ['string'], encode: (value: string) => value, decode: (wire: string, ctx?: CodecCallContext) => { observed = ctx; @@ -53,7 +50,6 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { let observedSignal: AbortSignal | undefined; const c = mongoCodec({ typeId: 'demo/identity@1', - targetTypes: ['string'], encode: (value: string, ctx?: CodecCallContext) => { observedSignal = ctx?.signal; return value; @@ -69,7 +65,6 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { let observed: unknown = 'sentinel'; const c = mongoCodec({ typeId: 'demo/empty-ctx@1', - targetTypes: ['string'], encode: (value: string, ctx?: CodecCallContext) => { observed = ctx; return value; @@ -84,7 +79,6 @@ describe('mongoCodec() factory — CodecCallContext arity', () => { it('async ctx-bearing encode resolves with the produced value', async () => { const c = mongoCodec({ typeId: 'demo/async-ctx@1', - targetTypes: ['string'], encode: async (value: string, _ctx?: CodecCallContext) => `enc:${value}`, decode: (wire: string) => wire, }); diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test-d.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test-d.ts index e7c2a9e739..fbdf37cb27 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test-d.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test-d.ts @@ -1,77 +1,19 @@ import type { Codec as BaseCodec } from '@prisma-next/framework-components/codec'; import { expectTypeOf, test } from 'vitest'; -import type { MongoCodec, MongoCodecInput, MongoCodecTraits } from '../src/codecs'; +import type { MongoCodec, MongoCodecInput } from '../src/codecs'; import { mongoCodec } from '../src/codecs'; -const equalityOnlyCodec = mongoCodec({ - typeId: 'test/equality@1', - targetTypes: ['string'], - traits: ['equality'], - decode: (w: string) => w, - encode: (v: string) => v, -}); - -const multiTraitCodec = mongoCodec({ - typeId: 'test/multi@1', - targetTypes: ['string'], - traits: ['equality', 'order', 'textual'], - decode: (w: string) => w, - encode: (v: string) => v, -}); - -const vectorCodec = mongoCodec({ - typeId: 'test/vector@1', - targetTypes: ['vector'], - traits: ['equality', 'numeric'], - decode: (w: readonly number[]) => w, - encode: (v: readonly number[]) => v, -}); - -test('MongoCodecTraits extracts single trait', () => { - expectTypeOf>().toEqualTypeOf<'equality'>(); -}); - -test('MongoCodecTraits extracts multiple traits as union', () => { - expectTypeOf>().toEqualTypeOf< - 'equality' | 'order' | 'textual' - >(); -}); - -test('MongoCodecTraits extracts multiple traits from vector codec', () => { - expectTypeOf>().toEqualTypeOf<'equality' | 'numeric'>(); -}); - -const traitlessCodec = mongoCodec({ - typeId: 'test/traitless@1', - targetTypes: ['blob'], - decode: (w: Buffer) => w, - encode: (v: Buffer) => v, - // Buffer is not assignable to JsonValue, so encodeJson/decodeJson are - // required (the conditional default identity is unsafe here). - encodeJson: (v: Buffer) => v.toString('base64'), - decodeJson: (j) => Buffer.from(j as string, 'base64'), -}); - -test('MongoCodecTraits is never for codec without traits', () => { - expectTypeOf>().toEqualTypeOf(); -}); - -// MongoCodec is a structural alias of `BaseCodec` — same four generics in -// the same order. Confirm the alias remains identical at the type level so -// authors can hold a `BaseCodec` reference where a `MongoCodec` is expected. -test('MongoCodec is structurally identical to BaseCodec (4 generics, same order)', () => { - expectTypeOf>().toEqualTypeOf< +// MongoCodec extends `BaseCodec` and carries the same four generics in the same order. Trait/targetType/renderOutputType metadata moved to the unified `CodecDescriptor` (TML-2357); the codec instance is now a pure conversion record. +test('MongoCodec is assignable to BaseCodec (4 generics, same order)', () => { + expectTypeOf>().toExtend< BaseCodec<'id/x@1', readonly ['equality'], number, string> >(); }); -// `MongoCodecInput` surfaces the JS application type of a Mongo codec -// — used both as `encode`'s input and as `decode`'s output, since the codec -// translates one JS application type to/from one wire format. +// `MongoCodecInput` surfaces the JS application type of a Mongo codec — used both as `encode`'s input and as `decode`'s output, since the codec translates one JS application type to/from one wire format. test('MongoCodecInput extracts the JS application type used for both write input and read output', () => { const text = mongoCodec({ typeId: 'demo/text@1', - targetTypes: ['string'], encode: (value: string) => value, decode: (wire: string) => wire, }); diff --git a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test.ts b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test.ts index c608f59f78..96badcc894 100644 --- a/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test.ts +++ b/packages/2-mongo-family/1-foundation/mongo-codec/test/codecs.test.ts @@ -1,20 +1,17 @@ import type { JsonValue } from '@prisma-next/contract/types'; import { describe, expect, it } from 'vitest'; -import { createMongoCodecRegistry } from '../src/codec-registry'; +import { newMongoCodecRegistry } from '../src/codec-registry'; import { type MongoCodec, mongoCodec } from '../src/codecs'; describe('mongoCodec()', () => { it('creates a codec with the given config', async () => { const codec = mongoCodec({ typeId: 'test/string@1', - targetTypes: ['string'], decode: (wire: string) => wire, encode: (value: string) => value, }); expect(codec.id).toBe('test/string@1'); - expect(codec.targetTypes).toEqual(['string']); - expect(codec.traits).toBeUndefined(); expect(await codec.decode('hello', {})).toBe('hello'); expect(await codec.encode('hello', {})).toBe('hello'); }); @@ -22,7 +19,6 @@ describe('mongoCodec()', () => { it('creates a codec with encode and decode', async () => { const codec = mongoCodec({ typeId: 'test/upper@1', - targetTypes: ['text'], decode: (wire: string) => wire.toUpperCase(), encode: (value: string) => value.toLowerCase(), }); @@ -34,7 +30,6 @@ describe('mongoCodec()', () => { it('lifts sync author functions to Promise-returning methods', () => { const codec = mongoCodec({ typeId: 'test/sync@1', - targetTypes: ['string'], decode: (wire: string) => wire, encode: (value: string) => value, }); @@ -48,7 +43,6 @@ describe('mongoCodec()', () => { it('accepts async author functions and uses them directly', async () => { const codec = mongoCodec({ typeId: 'test/async@1', - targetTypes: ['string'], decode: async (wire: string) => `decoded:${wire}`, encode: async (value: string) => `encoded:${value}`, }); @@ -62,14 +56,13 @@ describe('MongoCodecRegistry', () => { function makeCodec(id: string): MongoCodec { return mongoCodec({ typeId: id, - targetTypes: ['test'], decode: (wire: JsonValue) => wire, encode: (value: JsonValue) => value, }); } it('registers and retrieves a codec by id', () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); const codec = makeCodec('test/a@1'); registry.register(codec); @@ -77,12 +70,12 @@ describe('MongoCodecRegistry', () => { }); it('returns undefined for unregistered id', () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); expect(registry.get('nonexistent')).toBeUndefined(); }); it('has() returns true for registered, false for unregistered', () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); const codec = makeCodec('test/b@1'); registry.register(codec); @@ -91,7 +84,7 @@ describe('MongoCodecRegistry', () => { }); it('throws on duplicate registration', () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); const codec = makeCodec('test/dup@1'); registry.register(codec); @@ -101,7 +94,7 @@ describe('MongoCodecRegistry', () => { }); it('iterates over registered codecs', () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); const a = makeCodec('test/x@1'); const b = makeCodec('test/y@1'); registry.register(a); @@ -114,7 +107,7 @@ describe('MongoCodecRegistry', () => { }); it('values() returns an iterable of codecs', () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); const a = makeCodec('test/v@1'); registry.register(a); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/derive-json-schema.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/derive-json-schema.ts index a94922f5d3..4878c30193 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/derive-json-schema.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/derive-json-schema.ts @@ -6,8 +6,7 @@ function resolveBsonType( codecId: string, codecLookup: CodecLookup | undefined, ): string | undefined { - const codec = codecLookup?.get(codecId); - return codec?.targetTypes[0]; + return codecLookup?.targetTypesFor(codecId)?.[0]; } function fieldToBsonSchema( diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts index 135966238c..0b772c6af2 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts @@ -3,26 +3,30 @@ import type { CodecLookup } from '@prisma-next/framework-components/codec'; import { describe, expect, it } from 'vitest'; import { deriveJsonSchema, derivePolymorphicJsonSchema } from '../src/derive-json-schema'; +const mongoTargetTypes: Record = { + 'mongo/string@1': ['string'], + 'mongo/int32@1': ['int'], + 'mongo/bool@1': ['bool'], + 'mongo/date@1': ['date'], + 'mongo/objectId@1': ['objectId'], + 'mongo/double@1': ['double'], +}; + const mongoCodecLookup: CodecLookup = { get(id: string) { - const codecs: Record = { - 'mongo/string@1': { id: 'mongo/string@1', targetTypes: ['string'] }, - 'mongo/int32@1': { id: 'mongo/int32@1', targetTypes: ['int'] }, - 'mongo/bool@1': { id: 'mongo/bool@1', targetTypes: ['bool'] }, - 'mongo/date@1': { id: 'mongo/date@1', targetTypes: ['date'] }, - 'mongo/objectId@1': { id: 'mongo/objectId@1', targetTypes: ['objectId'] }, - 'mongo/double@1': { id: 'mongo/double@1', targetTypes: ['double'] }, - }; - const entry = codecs[id]; - if (!entry) return undefined; + const targetTypes = mongoTargetTypes[id]; + if (!targetTypes) return undefined; return { - ...entry, + id, encode: async (v: unknown) => v, decode: async (w: unknown) => w, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, } as ReturnType; }, + targetTypesFor: (id: string) => mongoTargetTypes[id], + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }; function scalarField(codecId: string, nullable = false): ContractField { diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 7e4ceffce0..222d47e1f3 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -12,27 +12,30 @@ const mongoScalarTypeDescriptors: ReadonlyMap = new Map([ ['Float', 'mongo/double@1'], ]); +const mongoTargetTypes: Record = { + 'mongo/string@1': ['string'], + 'mongo/int32@1': ['int'], + 'mongo/bool@1': ['bool'], + 'mongo/date@1': ['date'], + 'mongo/objectId@1': ['objectId'], + 'mongo/double@1': ['double'], +}; + const mongoCodecLookup: CodecLookup = { get(id: string) { - const types: Record = { - 'mongo/string@1': ['string'], - 'mongo/int32@1': ['int'], - 'mongo/bool@1': ['bool'], - 'mongo/date@1': ['date'], - 'mongo/objectId@1': ['objectId'], - 'mongo/double@1': ['double'], - }; - const targetTypes = types[id]; + const targetTypes = mongoTargetTypes[id]; if (!targetTypes) return undefined; return { id, - targetTypes, encode: async (v: unknown) => v, decode: async (w: unknown) => w, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, } as ReturnType; }, + targetTypesFor: (id: string) => mongoTargetTypes[id], + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }; function interpret(schema: string) { @@ -575,7 +578,7 @@ describe('interpretPslDocumentToMongoContract — polymorphism', () => { expect(conflict?.span?.end.offset).toBeGreaterThan(conflict?.span?.start.offset ?? 0); }); - it('emits PSL_INDEX_FIELD_NOT_FOUND when a variant indexes a base-inherited field (AC-M3-07)', () => { + it('emits PSL_INDEX_FIELD_NOT_FOUND when a variant indexes a base-inherited field', () => { const result = interpret(` model Task { id ObjectId @id @map("_id") diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts index 5c30e772a9..53152a8ece 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts @@ -16,27 +16,30 @@ const mongoScalarTypeDescriptors: ReadonlyMap = new Map([ ['Float', 'mongo/double@1'], ]); +const mongoTargetTypes: Record = { + 'mongo/string@1': ['string'], + 'mongo/int32@1': ['int'], + 'mongo/bool@1': ['bool'], + 'mongo/date@1': ['date'], + 'mongo/objectId@1': ['objectId'], + 'mongo/double@1': ['double'], +}; + const mongoCodecLookup: CodecLookup = { get(id: string) { - const types: Record = { - 'mongo/string@1': ['string'], - 'mongo/int32@1': ['int'], - 'mongo/bool@1': ['bool'], - 'mongo/date@1': ['date'], - 'mongo/objectId@1': ['objectId'], - 'mongo/double@1': ['double'], - }; - const targetTypes = types[id]; + const targetTypes = mongoTargetTypes[id]; if (!targetTypes) return undefined; return { id, - targetTypes, encode: async (v: unknown) => v, decode: async (w: unknown) => w, encodeJson: (v: unknown) => v, decodeJson: (j: unknown) => j, } as ReturnType; }, + targetTypesFor: (id: string) => mongoTargetTypes[id], + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }; interface MongoModel { diff --git a/packages/2-mongo-family/7-runtime/README.md b/packages/2-mongo-family/7-runtime/README.md index 691dce8ecc..ce84858b78 100644 --- a/packages/2-mongo-family/7-runtime/README.md +++ b/packages/2-mongo-family/7-runtime/README.md @@ -42,7 +42,7 @@ const runtime = createMongoRuntime({ }); ``` -Custom or third-party codecs (encryption, vendor scalars) are contributed via an extension-pack descriptor whose `codecInstances` list includes them; `createMongoExecutionContext` folds them into the same registry. Duplicate codec ids across contributors throw `RUNTIME.DUPLICATE_CODEC` at composition time. +Custom or third-party codecs (encryption, vendor scalars) are contributed via an extension-pack descriptor whose `codecs` slot returns the codec descriptors; `createMongoExecutionContext` folds them into the same registry. Duplicate codec ids across contributors throw `RUNTIME.DUPLICATE_CODEC` at composition time. ## Responsibilities diff --git a/packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts b/packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts index 9debda1bbd..36615cc2c4 100644 --- a/packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts +++ b/packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts @@ -12,16 +12,13 @@ import { } from '@prisma-next/framework-components/execution'; import { runtimeError } from '@prisma-next/framework-components/runtime'; import type { MongoCodec } from '@prisma-next/mongo-codec'; -import { createMongoCodecRegistry, type MongoCodecRegistry } from '@prisma-next/mongo-codec'; +import { type MongoCodecRegistry, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import type { MongoAdapter } from '@prisma-next/mongo-lowering'; /** * Mongo-specific static contributions a runtime descriptor declares. * - * Mirrors `SqlStaticContributions` in shape: a `codecs()` getter that yields - * a `MongoCodecRegistry` populated with this contributor's codecs. The - * registry is then walked by `createMongoExecutionContext` and folded into - * the single per-execution registry the runtime reads from at decode time. + * Mirrors `SqlStaticContributions` in shape: a `codecs()` getter that yields a `MongoCodecRegistry` populated with this contributor's codecs. The registry is then walked by `createMongoExecutionContext` and folded into the single per-execution registry the runtime reads from at decode time. */ export interface MongoStaticContributions { readonly codecs: () => MongoCodecRegistry; @@ -59,9 +56,7 @@ export interface MongoRuntimeExtensionDescriptor { readonly target: MongoRuntimeTargetDescriptor; @@ -102,10 +97,7 @@ export function createMongoExecutionStack(op /** * Read-only view of the codec registry exposed on `MongoExecutionContext`. * - * Hides `register()` and the iterator from public surface — users do not - * mutate the per-execution codec registry. Internal aggregation in - * `createMongoExecutionContext` keeps using the full `MongoCodecRegistry` - * (it needs `register()`). + * Hides `register()` and the iterator from public surface — users do not mutate the per-execution codec registry. Internal aggregation in `createMongoExecutionContext` keeps using the full `MongoCodecRegistry` (it needs `register()`). */ export interface MongoCodecLookup { get(id: string): MongoCodec | undefined; @@ -115,14 +107,9 @@ export interface MongoCodecLookup { /** * Per-execution context aggregated from a `MongoExecutionStack`. * - * Carries the user's contract, a read-only lookup over the codec registry - * composed from every stack contributor, and a back-reference to the stack - * itself so the runtime can reach the adapter without users threading it - * explicitly. + * Carries the user's contract, a read-only lookup over the codec registry composed from every stack contributor, and a back-reference to the stack itself so the runtime can reach the adapter without users threading it explicitly. * - * Mirrors SQL's `ExecutionContext` in role; Mongo's flavour is leaner - * because there are no parameterised codecs, JSON-schema validators, or - * mutation-default generators in scope yet. + * Mirrors SQL's `ExecutionContext` in role; Mongo's flavour is leaner because there are no parameterised codecs, JSON-schema validators, or mutation-default generators in scope yet. */ export interface MongoExecutionContext { readonly contract: unknown; @@ -134,7 +121,7 @@ export function createMongoExecutionContext( readonly contract: unknown; readonly stack: MongoExecutionStack; }): MongoExecutionContext { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); const owners = new Map(); const contributors: ReadonlyArray = [ diff --git a/packages/2-mongo-family/7-runtime/test/codecs/decoding.test.ts b/packages/2-mongo-family/7-runtime/test/codecs/decoding.test.ts index db86f25eb1..d73b38a513 100644 --- a/packages/2-mongo-family/7-runtime/test/codecs/decoding.test.ts +++ b/packages/2-mongo-family/7-runtime/test/codecs/decoding.test.ts @@ -1,8 +1,8 @@ import { isRuntimeError } from '@prisma-next/framework-components/runtime'; import { - createMongoCodecRegistry, type MongoCodecRegistry, mongoCodec, + newMongoCodecRegistry, } from '@prisma-next/mongo-codec'; import type { MongoFieldShape, MongoResultShape } from '@prisma-next/mongo-query-ast/execution'; import { ObjectId } from 'mongodb'; @@ -24,11 +24,10 @@ function deferred(): { } function registryWithDefaults(): MongoCodecRegistry { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'mongo/string@1', - targetTypes: ['string'], encode: (v: string) => v, decode: (w: string) => w, }), @@ -36,7 +35,6 @@ function registryWithDefaults(): MongoCodecRegistry { registry.register( mongoCodec({ typeId: 'mongo/objectId@1', - targetTypes: ['objectId'], encode: (v: string) => new ObjectId(v), decode: (w: { toHexString: () => string }) => w.toHexString(), }), @@ -68,7 +66,6 @@ describe('decodeMongoRow', () => { registry.register( mongoCodec({ typeId: 'test/spy@1', - targetTypes: ['x'], encode: (v: string) => v, decode: decodeSpy, }), @@ -128,7 +125,6 @@ describe('decodeMongoRow', () => { registry.register( mongoCodec({ typeId: 'throws-on-b@1', - targetTypes: ['string'], encode: (v: string) => v, decode: (w: string) => { if (w === 'bad') throw new Error('boom'); @@ -183,8 +179,7 @@ describe('decodeMongoRow', () => { }, }, }; - // A driver row where `addr` came back as an array (e.g. an unexpanded - // `$lookup` pre-`$unwind`) is yielded verbatim rather than walked into. + // A driver row where `addr` came back as an array (e.g. an unexpanded `$lookup` pre-`$unwind`) is yielded verbatim rather than walked into. const arrayRow = { addr: [{ city: 'X' }] }; const arrayOut = await decodeMongoRow(arrayRow, shape, registry, 'c'); expect(arrayOut).toEqual({ addr: [{ city: 'X' }] }); @@ -250,17 +245,13 @@ describe('decodeMongoRow', () => { }); it('coerces non-Error throw values into the wrapper message', async () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'throws-string@1', - targetTypes: ['x'], encode: (v: string) => v, decode: () => { - // Codec authors throwing a non-Error happens — the wrapper has - // to render something for the message. The cast is a deliberate - // exercise of `wrapDecodeFailure`'s `error instanceof Error` - // false-branch (pure type-system: `throw` accepts `unknown`). + // Codec authors throwing a non-Error happens — the wrapper has to render something for the message. The cast is a deliberate exercise of `wrapDecodeFailure`'s `error instanceof Error` false-branch (pure type-system: `throw` accepts `unknown`). throw 'string-error' as unknown as Error; }, }), @@ -282,11 +273,10 @@ describe('decodeMongoRow', () => { }); it('serialises non-string wire values for wirePreview when decode throws', async () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'throws@1', - targetTypes: ['x'], encode: (v: string) => v, decode: () => { throw new Error('boom'); @@ -311,11 +301,10 @@ describe('decodeMongoRow', () => { }); it('truncates long string wirePreviews to 100 chars with an ellipsis', async () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'throws@1', - targetTypes: ['x'], encode: (v: string) => v, decode: () => { throw new Error('boom'); @@ -341,9 +330,7 @@ describe('decodeMongoRow', () => { }); it('passes through row fields the shape does not describe', async () => { - // Polymorphic variants and sidecar fields the contract does not enumerate - // round-trip verbatim. The shape is a partial lane-vouched description; - // drop semantics belongs to projection, not to structural decode. + // Polymorphic variants and sidecar fields the contract does not enumerate round-trip verbatim. The shape is a partial lane-vouched description; drop semantics belongs to projection, not to structural decode. const registry = registryWithDefaults(); const shape: MongoResultShape = { kind: 'document', @@ -363,11 +350,7 @@ describe('decodeMongoRow', () => { }); it('passes through subdocument keys the nested document shape does not describe', async () => { - // The pass-through invariant is structurally additive at every depth, not - // just the top level. A nested `kind: 'document'` slot decodes the keys - // its `fields` enumerates and round-trips the rest. ADR 209 promises - // future lane work threading concrete value-object subtrees is purely - // additive, which requires this. + // The pass-through invariant is structurally additive at every depth, not just the top level. A nested `kind: 'document'` slot decodes the keys its `fields` enumerates and round-trips the rest. ADR 209 promises future lane work threading concrete value-object subtrees is purely additive, which requires this. const registry = registryWithDefaults(); const shape: MongoResultShape = { kind: 'document', @@ -407,7 +390,7 @@ describe('decodeMongoRow', () => { }); it('passes through when registry has no entry for codecId', async () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); const shape: MongoResultShape = { kind: 'document', fields: { @@ -420,11 +403,10 @@ describe('decodeMongoRow', () => { }); it('wraps codec errors in RUNTIME.DECODE_FAILED with details and cause', async () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'throws@1', - targetTypes: ['x'], encode: (v: string) => v, decode: () => { throw new Error('inner'); @@ -461,11 +443,10 @@ describe('decodeMongoRow', () => { const dA = deferred(); const dB = deferred(); const callOrder: string[] = []; - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'slow-a@1', - targetTypes: ['x'], encode: (v: string) => v, decode: (w: string) => { callOrder.push('a-start'); @@ -476,7 +457,6 @@ describe('decodeMongoRow', () => { registry.register( mongoCodec({ typeId: 'slow-b@1', - targetTypes: ['x'], encode: (v: string) => v, decode: (w: string) => { callOrder.push('b-start'); diff --git a/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts b/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts index 1cfe605cba..96d9304d75 100644 --- a/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts +++ b/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts @@ -30,8 +30,7 @@ describe('Mongo runtime decode integration', () => { embeddings: vec, }); - // User-facing path: build the plan through the query-builder so the - // Row type is contract-derived (no explicit annotation on execute). + // User-facing path: build the plan through the query-builder so the Row type is contract-derived (no explicit annotation on execute). const plan = mongoQuery({ contractJson: contract }) .from('users') .match((f) => @@ -52,16 +51,10 @@ describe('Mongo runtime decode integration', () => { it('decode failure surfaces RUNTIME.DECODE_FAILED with details and cause', async () => { await withMongod(async (ctx) => { - // The synthetic codec id (`test/throws-on-decode@1`) is not a contract - // field codec, so this test legitimately needs a stub `resultShape` — - // the user-facing `mongoQuery(...)` path can't construct a shape - // referencing a codec the contract doesn't declare. The tradeoff is - // intentional: this test exercises the failure-envelope plumbing, not - // the lane-population path. The other two integration tests in this - // file go through the query-builder. + // The synthetic codec id (`test/throws-on-decode@1`) is not a contract field codec, so this test legitimately needs a stub `resultShape` — the user-facing `mongoQuery(...)` path can't construct a shape referencing a codec the contract doesn't declare. The tradeoff is intentional: this test exercises the failure-envelope plumbing, not the lane-population path. The other two integration tests in this file go through the + // query-builder. const failing = mongoCodec({ typeId: 'test/throws-on-decode@1', - targetTypes: ['any'], encode: (v: string) => v, decode: () => { throw new Error('decode explosion'); @@ -120,13 +113,8 @@ describe('Mongo runtime decode integration', () => { it('unknown shape slot leaves driver value for that field intact', async () => { await withMongod(async (ctx) => { - // No contract-modelled lane currently emits `kind: 'unknown'` for a - // sibling-of-leaf slot — lanes either emit a fully-described - // document shape or omit `resultShape` entirely. This test - // exercises the runtime's unknown-slot behavior with a hand-rolled - // shape; the match filter still goes through `MongoParamRef` for - // the encode-side codec round-trip (no `as unknown as MongoValue` - // cast — `MongoParamRef` is a member of `MongoValue`). + // No contract-modelled lane currently emits `kind: 'unknown'` for a sibling-of-leaf slot — lanes either emit a fully-described document shape or omit `resultShape` entirely. This test exercises the runtime's unknown-slot behavior with a hand-rolled shape; the match filter still goes through `MongoParamRef` for the encode-side codec round-trip (no `as unknown as MongoValue` cast — `MongoParamRef` is a member of + // `MongoValue`). const nested = { city: 'Paris' }; const insert = await ctx.client .db(ctx.dbName) diff --git a/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts b/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts index c218fd129a..5b77726124 100644 --- a/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts +++ b/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts @@ -1,6 +1,6 @@ import mongoRuntimeAdapter from '@prisma-next/adapter-mongo/runtime'; import { isRuntimeError } from '@prisma-next/framework-components/runtime'; -import { createMongoCodecRegistry, mongoCodec } from '@prisma-next/mongo-codec'; +import { mongoCodec, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import mongoRuntimeTarget from '@prisma-next/target-mongo/runtime'; import { describe, expect, it } from 'vitest'; import { @@ -38,7 +38,7 @@ describe('createMongoExecutionStack', () => { familyId: 'mongo', targetId: 'mongo', version: '0.0.1', - codecs: () => createMongoCodecRegistry(), + codecs: () => newMongoCodecRegistry(), create: () => ({ familyId: 'mongo', targetId: 'mongo' }), }; const stack = createMongoExecutionStack({ @@ -65,7 +65,6 @@ describe('createMongoExecutionContext', () => { it('folds extension-pack codec contributions into the same registry', () => { const customCodec = mongoCodec({ typeId: 'test/custom@1', - targetTypes: ['custom'], decode: (wire: string) => `decoded:${wire}`, encode: (value: string) => value, }); @@ -76,7 +75,7 @@ describe('createMongoExecutionContext', () => { targetId: 'mongo', version: '0.0.1', codecs: () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(customCodec); return registry; }, @@ -95,7 +94,6 @@ describe('createMongoExecutionContext', () => { it('throws RUNTIME.DUPLICATE_CODEC when two contributors declare the same codec id', () => { const conflictingCodec = mongoCodec({ typeId: 'mongo/string@1', - targetTypes: ['string'], decode: (wire: string) => wire, encode: (value: string) => value, }); @@ -106,7 +104,7 @@ describe('createMongoExecutionContext', () => { targetId: 'mongo', version: '0.0.1', codecs: () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(conflictingCodec); return registry; }, @@ -151,9 +149,9 @@ describe('createMongoExecutionContext', () => { }); describe('runtime adapter descriptor', () => { - it('declares the seven standard Mongo codec instances on codecInstances', () => { - const codecInstances = mongoRuntimeAdapter.types?.codecTypes?.codecInstances ?? []; - expect(codecInstances.map((c) => c.id).sort()).toEqual([...STANDARD_CODEC_IDS].sort()); + it('declares the seven standard Mongo codec descriptors on codecDescriptors', () => { + const codecDescriptors = mongoRuntimeAdapter.types?.codecTypes?.codecDescriptors ?? []; + expect(codecDescriptors.map((d) => d.codecId).sort()).toEqual([...STANDARD_CODEC_IDS].sort()); }); it('create(stack) returns an instance whose lower() delegates to the standard adapter', async () => { diff --git a/packages/2-mongo-family/7-runtime/test/mongo-middleware.test.ts b/packages/2-mongo-family/7-runtime/test/mongo-middleware.test.ts index d2ef547100..61a875ce8c 100644 --- a/packages/2-mongo-family/7-runtime/test/mongo-middleware.test.ts +++ b/packages/2-mongo-family/7-runtime/test/mongo-middleware.test.ts @@ -1,5 +1,5 @@ import type { PlanMeta } from '@prisma-next/contract/types'; -import { createMongoCodecRegistry, type MongoCodecRegistry } from '@prisma-next/mongo-codec'; +import { type MongoCodecRegistry, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import type { MongoAdapter, MongoDriver } from '@prisma-next/mongo-lowering'; import type { MongoQueryPlan } from '@prisma-next/mongo-query-ast/execution'; import { describe, expect, it, vi } from 'vitest'; @@ -14,7 +14,7 @@ import type { MongoMiddleware } from '../src/mongo-middleware'; import { createMongoRuntime } from '../src/mongo-runtime'; function makeContext(adapter: MongoAdapter): MongoExecutionContext { - const codecs: MongoCodecRegistry = createMongoCodecRegistry(); + const codecs: MongoCodecRegistry = newMongoCodecRegistry(); const adapterInstance: MongoRuntimeAdapterInstance<'mongo'> = { familyId: 'mongo', targetId: 'mongo', @@ -26,7 +26,7 @@ function makeContext(adapter: MongoAdapter): MongoExecutionContext { familyId: 'mongo', targetId: 'mongo', version: '0.0.1', - codecs: () => createMongoCodecRegistry(), + codecs: () => newMongoCodecRegistry(), create: () => ({ familyId: 'mongo', targetId: 'mongo' }), }; const adapterDescriptor: MongoRuntimeAdapterDescriptor<'mongo'> = { @@ -35,7 +35,7 @@ function makeContext(adapter: MongoAdapter): MongoExecutionContext { familyId: 'mongo', targetId: 'mongo', version: '0.0.1', - codecs: () => createMongoCodecRegistry(), + codecs: () => newMongoCodecRegistry(), create: () => adapterInstance, }; const stack: MongoExecutionStack<'mongo'> = { @@ -378,9 +378,7 @@ describe('MongoRuntime middleware compatibility validation', () => { }); it('rejects a SQL middleware with a clear error', () => { - // Intentionally misconfigured to verify the runtime rejects mismatched familyId. - // The static type narrows familyId to 'mongo' | undefined, so we cast to bypass - // the type check and exercise the runtime path. + // Intentionally misconfigured to verify the runtime rejects mismatched familyId. The static type narrows familyId to 'mongo' | undefined, so we cast to bypass the type check and exercise the runtime path. const middleware = { name: 'sql-lints', familyId: 'sql' as const, diff --git a/packages/2-mongo-family/7-runtime/test/mongo-runtime-abort.test.ts b/packages/2-mongo-family/7-runtime/test/mongo-runtime-abort.test.ts index 56d80f9fd6..ece457690d 100644 --- a/packages/2-mongo-family/7-runtime/test/mongo-runtime-abort.test.ts +++ b/packages/2-mongo-family/7-runtime/test/mongo-runtime-abort.test.ts @@ -1,6 +1,6 @@ import type { PlanMeta } from '@prisma-next/contract/types'; import type { CodecCallContext } from '@prisma-next/framework-components/codec'; -import { createMongoCodecRegistry, type MongoCodecRegistry } from '@prisma-next/mongo-codec'; +import { type MongoCodecRegistry, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import type { MongoAdapter, MongoDriver } from '@prisma-next/mongo-lowering'; import type { MongoQueryPlan } from '@prisma-next/mongo-query-ast/execution'; import { describe, expect, it, vi } from 'vitest'; @@ -52,7 +52,7 @@ function recordingAdapter(): RecordingAdapter { } function makeContext(adapter: MongoAdapter): MongoExecutionContext { - const codecs: MongoCodecRegistry = createMongoCodecRegistry(); + const codecs: MongoCodecRegistry = newMongoCodecRegistry(); const adapterInstance: MongoRuntimeAdapterInstance<'mongo'> = { familyId: 'mongo', targetId: 'mongo', @@ -64,7 +64,7 @@ function makeContext(adapter: MongoAdapter): MongoExecutionContext { familyId: 'mongo', targetId: 'mongo', version: '0.0.1', - codecs: () => createMongoCodecRegistry(), + codecs: () => newMongoCodecRegistry(), create: () => ({ familyId: 'mongo', targetId: 'mongo' }), }; const adapterDescriptor: MongoRuntimeAdapterDescriptor<'mongo'> = { @@ -73,7 +73,7 @@ function makeContext(adapter: MongoAdapter): MongoExecutionContext { familyId: 'mongo', targetId: 'mongo', version: '0.0.1', - codecs: () => createMongoCodecRegistry(), + codecs: () => newMongoCodecRegistry(), create: () => adapterInstance, }; const stack: MongoExecutionStack<'mongo'> = { diff --git a/packages/2-sql/2-authoring/contract-psl/src/provider.ts b/packages/2-sql/2-authoring/contract-psl/src/provider.ts index b6234adf56..c1353007c1 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/provider.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/provider.ts @@ -20,9 +20,7 @@ function buildColumnDescriptorMap( ): ReadonlyMap { const result = new Map(); for (const [typeName, codecId] of scalarTypeDescriptors) { - const codec = codecLookup.get(codecId); - if (!codec) continue; - const nativeType = codec.targetTypes[0]; + const nativeType = codecLookup.targetTypesFor(codecId)?.[0]; if (nativeType === undefined) continue; result.set(typeName, { codecId, nativeType }); } diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index 97c8fa8a2e..65ccb71885 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -41,7 +41,7 @@ export type ColumnDescriptor = { readonly codecId: string; readonly nativeType: string; readonly typeRef?: string; - readonly typeParams?: Record; + readonly typeParams?: Record | undefined; }; export function toNamedTypeFieldDescriptor( @@ -72,16 +72,9 @@ export function getAuthoringTypeConstructor( } /** - * Walks `authoringContributions.field` segment-by-segment and returns the - * field-preset descriptor at the resolved path, or `undefined` if no descriptor - * is registered. + * Walks `authoringContributions.field` segment-by-segment and returns the field-preset descriptor at the resolved path, or `undefined` if no descriptor is registered. * - * Symmetric with `getAuthoringTypeConstructor`. Field presets are strictly - * richer than type constructors — they can contribute `default` / - * `executionDefaults` / `id` / `unique` / `nullable` in addition to the - * `codecId` / `nativeType` / `typeParams` triple. PSL resolution tries field - * presets first, then falls back to type constructors on miss (see - * `resolveFieldTypeDescriptor`). + * Symmetric with `getAuthoringTypeConstructor`. Field presets are strictly richer than type constructors — they can contribute `default` / `executionDefaults` / `id` / `unique` / `nullable` in addition to the `codecId` / `nativeType` / `typeParams` triple. PSL resolution tries field presets first, then falls back to type constructors on miss (see `resolveFieldTypeDescriptor`). */ export function getAuthoringFieldPreset( contributions: AuthoringContributions | undefined, @@ -100,9 +93,7 @@ export function getAuthoringFieldPreset( } /** - * Returns the namespace prefix of `attributeName` if it references an - * unrecognized extension namespace, otherwise `undefined`. A namespace is - * considered recognized when it is: + * Returns the namespace prefix of `attributeName` if it references an unrecognized extension namespace, otherwise `undefined`. A namespace is considered recognized when it is: * * - `db` (native-type spec, always allowed), * - the active family id (e.g. `sql`), @@ -110,10 +101,7 @@ export function getAuthoringFieldPreset( * - a registered field-preset namespace (e.g. `temporal`), * - present in `composedExtensions`. * - * Family/target/field-preset namespaces are exempted so that e.g. `@sql.foo` - * surfaces as PSL_UNSUPPORTED_*_ATTRIBUTE (the attribute isn't defined) - * rather than PSL_EXTENSION_NAMESPACE_NOT_COMPOSED (the namespace is already - * composed). + * Family/target/field-preset namespaces are exempted so that e.g. `@sql.foo` surfaces as PSL_UNSUPPORTED_*_ATTRIBUTE (the attribute isn't defined) rather than PSL_EXTENSION_NAMESPACE_NOT_COMPOSED (the namespace is already composed). */ export function checkUncomposedNamespace( attributeName: string, @@ -142,12 +130,9 @@ export function checkUncomposedNamespace( } /** - * Pushes the canonical `PSL_EXTENSION_NAMESPACE_NOT_COMPOSED` diagnostic for a - * subject (attribute, model attribute, or type constructor) that references an - * extension namespace which is not composed in the current contract. + * Pushes the canonical `PSL_EXTENSION_NAMESPACE_NOT_COMPOSED` diagnostic for a subject (attribute, model attribute, or type constructor) that references an extension namespace which is not composed in the current contract. * - * The `data` payload carries the missing namespace so machine consumers - * (agents, IDE extensions, CLI auto-fix) don't have to parse the prose. + * The `data` payload carries the missing namespace so machine consumers (agents, IDE extensions, CLI auto-fix) don't have to parse the prose. */ export function reportUncomposedNamespace(input: { readonly subjectLabel: string; @@ -166,10 +151,7 @@ export function reportUncomposedNamespace(input: { } /** - * Pushes the canonical `PSL_UNKNOWN_FIELD_PRESET` diagnostic when a typoed - * preset name is referenced inside a registered field-preset namespace. The - * `data` payload exposes the namespace and full helper path so machine - * consumers (agents, IDE extensions) don't have to parse the prose. + * Pushes the canonical `PSL_UNKNOWN_FIELD_PRESET` diagnostic when a typoed preset name is referenced inside a registered field-preset namespace. The `data` payload exposes the namespace and full helper path so machine consumers (agents, IDE extensions) don't have to parse the prose. */ export function reportUnknownFieldPreset(input: { readonly entityLabel: string; @@ -292,16 +274,9 @@ export function resolvePslTypeConstructorDescriptor(input: { } /** - * Instantiates a field-preset call against its descriptor, coercing PSL AST - * arguments into the descriptor's typed argument shape and returning the - * preset's full set of contract contributions. + * Instantiates a field-preset call against its descriptor, coercing PSL AST arguments into the descriptor's typed argument shape and returning the preset's full set of contract contributions. * - * Symmetric with `instantiatePslTypeConstructor` but richer: a field preset - * can contribute `default`, `executionDefaults`, `id`, `unique`, and - * `nullable` in addition to the storage-type triple. PSL → typed-args - * coercion happens here (via `mapPslHelperArgs`) so that - * `instantiateAuthoringFieldPreset` itself stays typed-input-only and TS - * keeps its zero-runtime-validation cost. + * Symmetric with `instantiatePslTypeConstructor` but richer: a field preset can contribute `default`, `executionDefaults`, `id`, `unique`, and `nullable` in addition to the storage-type triple. PSL → typed-args coercion happens here (via `mapPslHelperArgs`) so that `instantiateAuthoringFieldPreset` itself stays typed-input-only and TS keeps its zero-runtime-validation cost. */ export function instantiatePslFieldPreset(input: { readonly call: PslTypeConstructorCall; @@ -365,10 +340,7 @@ export function instantiatePslFieldPreset(input: { } /** - * Contract contributions a field preset adds beyond the bare storage-type - * triple. Set when a field is resolved through the field-preset dispatch - * path; absent when resolved through the type-constructor path or as a - * scalar/enum/named-type lookup. + * Contract contributions a field preset adds beyond the bare storage-type triple. Set when a field is resolved through the field-preset dispatch path; absent when resolved through the type-constructor path or as a scalar/enum/named-type lookup. */ export type FieldPresetContributions = { readonly nullable: boolean; @@ -400,9 +372,7 @@ export function resolveFieldTypeDescriptor(input: { readonly entityLabel: string; }): ResolveFieldTypeResult { if (input.field.typeConstructor) { - // Field presets carry richer semantics than type constructors, so a field - // preset match is the complete answer. Shared composition rejects exact - // cross-registry collisions before PSL resolution can observe them. + // Field presets carry richer semantics than type constructors, so a field preset match is the complete answer. Shared composition rejects exact cross-registry collisions before PSL resolution can observe them. const presetDescriptor = getAuthoringFieldPreset( input.authoringContributions, input.field.typeConstructor.path, @@ -778,11 +748,7 @@ export function lowerDefaultForField(input: { return {}; } - // Preset-only generators (e.g. `timestampNow`) co-register their codec - // through the preset descriptor, so they don't carry an - // `applicableCodecIds` list. Such a generator surfacing on the - // `@default(...)` lowering path is itself the bug — emit a diagnostic - // pointing the user at the correct authoring surface. + // Preset-only generators (e.g. `timestampNow`) co-register their codec through the preset descriptor, so they don't carry an `applicableCodecIds` list. Such a generator surfacing on the `@default(...)` lowering path is itself the bug — emit a diagnostic pointing the user at the correct authoring surface. if (generatorDescriptor.applicableCodecIds === undefined) { input.diagnostics.push({ code: 'PSL_INVALID_DEFAULT_APPLICABILITY', diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index 7c95c135d6..3b7c8f7400 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -96,10 +96,9 @@ export const pgvectorExtensionPack: ExtensionPackRef<'sql', 'postgres'> = { version: '1.2.3-test', }; -/** Controlled test-only descriptor — intentionally uses pg/vector@1 with maximum: 2000 rather - * than importing the real pgvector pack, so interpreter unit tests stay layer-isolated. - * Real-pack parity is covered by - * `test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts`. */ +/** + * Controlled test-only descriptor — intentionally uses pg/vector@1 with maximum: 2000 rather than importing the real pgvector pack, so interpreter unit tests stay layer-isolated. Real-pack parity is covered by `test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts`. + */ export const pgvectorAuthoringContributions = { field: {}, type: { @@ -178,10 +177,12 @@ const targetTypesByCodecId: Record = { export const postgresCodecLookup: CodecLookup = { get: (id: string) => { - const targetTypes = targetTypesByCodecId[id]; - if (!targetTypes) return undefined; - return { id, targetTypes } as ReturnType; + if (!targetTypesByCodecId[id]) return undefined; + return { id } as ReturnType; }, + targetTypesFor: (id: string) => targetTypesByCodecId[id], + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }; export function createPostgresTestContext( diff --git a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts index cd1579c3ab..3fdcd2f934 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts @@ -541,6 +541,12 @@ model Document { codecLookup: { get: (id: string) => id === 'pg/int4@1' ? createPostgresTestContext().codecLookup.get(id) : undefined, + targetTypesFor: (id: string) => + id === 'pg/int4@1' + ? createPostgresTestContext().codecLookup.targetTypesFor(id) + : undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index a1d730ff07..4c39899f06 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -1,5 +1,5 @@ -import type { ColumnTypeDescriptor } from '@prisma-next/contract-authoring'; import type { AuthoringContributions } from '@prisma-next/framework-components/authoring'; +import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, FamilyPackRef, @@ -433,9 +433,7 @@ describe('TS and PSL authoring parity', () => { expect(tsLines).toBeLessThanOrEqual(Math.ceil(pslLines * 1.6)); }); - // The shared timestamp parity schema both SQL targets exercise. Lives at - // module scope so the per-target tests below read as data + a single - // call into `expectTimestampParity`. + // The shared timestamp parity schema both SQL targets exercise. Lives at module scope so the per-target tests below read as data + a single call into `expectTimestampParity`. const timestampParityPslSchema = `model User { id Int @id email String diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts index 49f2daca36..8b6fc1bdc4 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts @@ -1,8 +1,6 @@ import type { ColumnDefault, ExecutionMutationDefaultPhases } from '@prisma-next/contract/types'; -import type { - ColumnTypeDescriptor, - ForeignKeyDefaultsState, -} from '@prisma-next/contract-authoring'; +import type { ForeignKeyDefaultsState } from '@prisma-next/contract-authoring'; +import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import type { ReferentialAction, StorageTypeInstance } from '@prisma-next/sql-contract/types'; diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts index 00a402e089..732068680e 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts @@ -5,13 +5,10 @@ import type { ExecutionMutationDefaultValue, } from '@prisma-next/contract/types'; import { isColumnDefault } from '@prisma-next/contract/types'; -import type { - ColumnTypeDescriptor, - ForeignKeyDefaultsState, -} from '@prisma-next/contract-authoring'; +import type { ForeignKeyDefaultsState } from '@prisma-next/contract-authoring'; import type { AuthoringFieldPresetDescriptor } from '@prisma-next/framework-components/authoring'; import { instantiateAuthoringFieldPreset } from '@prisma-next/framework-components/authoring'; -import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import type { CodecLookup, ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, FamilyPackRef, @@ -145,10 +142,7 @@ function toColumnDefault(value: ColumnDefaultLiteralInputValue | ColumnDefault): return { kind: 'literal', value }; } -// Chaining methods use `as unknown as ` because TypeScript cannot -// narrow generic conditional return types through object spread. The runtime values -// are correct — the casts bridge the gap between the spread result and the -// compile-time conditional type that encodes the state transition. +// Chaining methods use `as unknown as ` because TypeScript cannot narrow generic conditional return types through object spread. The runtime values are correct — the casts bridge the gap between the spread result and the compile-time conditional type that encodes the state transition. export class ScalarFieldBuilder { declare readonly __state: State; @@ -1030,9 +1024,7 @@ export class ContractModelBuilder< ): [ValidateSqlStageSpec] extends [never] ? ContractModelBuilder : ContractModelBuilder { - // Conditional return type cannot be verified by the implementation; the - // runtime value is always a valid ContractModelBuilder regardless of the - // validation outcome (validation is type-level only). + // Conditional return type cannot be verified by the implementation; the runtime value is always a valid ContractModelBuilder regardless of the validation outcome (validation is type-level only). return new ContractModelBuilder(this.stageOne, this.attributesFactory, specOrFactory) as never; } diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts index 31f419ac78..830f0aa97c 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-lowering.ts @@ -1,4 +1,4 @@ -import type { ColumnTypeDescriptor } from '@prisma-next/contract-authoring'; +import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import type { StorageTypeInstance } from '@prisma-next/sql-contract/types'; import type { ContractDefinition, diff --git a/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts b/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts index 352befee3c..17788b9108 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/config-types.test.ts @@ -11,7 +11,12 @@ const stubContext: ContractSourceContext = { composedExtensionPacks: [], scalarTypeDescriptors: new Map(), authoringContributions: { field: {}, type: {} }, - codecLookup: { get: () => undefined }, + codecLookup: { + get: () => undefined, + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, + }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), generatorDescriptors: [] }, resolvedInputs: [], }; diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts index 71869db06e..750ce26f2f 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.contract-definition.test.ts @@ -185,8 +185,6 @@ describe('shared contract definition lowering', () => { return { id, - targetTypes: ['timestamptz'], - traits: ['equality', 'order'] as const, encode: async (value: unknown) => value, decode: async (wire: unknown) => wire, encodeJson: (value: unknown) => @@ -194,6 +192,9 @@ describe('shared contract definition lowering', () => { decodeJson: (json: unknown) => new Date(json as string), }; }, + targetTypesFor: (id) => (id === 'pg/timestamptz@1' ? ['timestamptz'] : undefined), + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }; const contract = buildSqlContractFromDefinition( diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts index b1b329b7fa..9990c3994f 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.value-objects.test.ts @@ -30,8 +30,6 @@ describe('value objects in contract definition builder', () => { return { id, - targetTypes: ['jsonb'], - traits: ['equality'] as const, encode: async (value: unknown) => value, decode: async (wire: unknown) => wire, encodeJson: (value: unknown) => { @@ -47,6 +45,9 @@ describe('value objects in contract definition builder', () => { decodeJson: (json: unknown) => json, }; }, + targetTypesFor: (id) => (id === 'pg/jsonb@1' ? ['jsonb'] : undefined), + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }; const contract = buildSqlContractFromDefinition( diff --git a/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts b/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts index aefc7e1b6f..48ce931619 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/helpers/column-descriptor.ts @@ -1,4 +1,4 @@ -import type { ColumnTypeDescriptor } from '@prisma-next/contract-authoring'; +import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; export function columnDescriptor( codecId: string, diff --git a/packages/2-sql/3-tooling/emitter/test/emitter-hook.typeref-resolver.test.ts b/packages/2-sql/3-tooling/emitter/test/emitter-hook.typeref-resolver.test.ts index 579c17b55a..9696bc0593 100644 --- a/packages/2-sql/3-tooling/emitter/test/emitter-hook.typeref-resolver.test.ts +++ b/packages/2-sql/3-tooling/emitter/test/emitter-hook.typeref-resolver.test.ts @@ -4,12 +4,7 @@ import type { CodecLookup } from '@prisma-next/framework-components/codec'; import { describe, expect, it } from 'vitest'; import { sqlEmission } from '../src/index'; -// Phase A integration test (F01 from Phase A review): exercise the real -// SQL emitter walk end-to-end for the typeRef-resolver path. Confirms that -// `sqlEmission.resolveFieldTypeParams` walks `storage.fields → storage.tables[t] -// .columns[c] → storage.types[ref].typeParams` and that the framework emit -// path (`generateContractDts`) consults the resolver via the -// `EmissionSpi.resolveFieldTypeParams` hook plumbed in TA.1-TA.2. +// Integration test for the typeRef-resolver path: exercise the real SQL emitter walk end-to-end. Confirms that `sqlEmission.resolveFieldTypeParams` walks `storage.fields → storage.tables[t].columns[c] → storage.types[ref].typeParams` and that the framework emit path (`generateContractDts`) consults the resolver via the `EmissionSpi.resolveFieldTypeParams` hook. function createContract(overrides: Partial): Contract { return { @@ -29,31 +24,25 @@ function createContract(overrides: Partial): Contract { const testHashes = { storageHash: 'sha256:test', profileHash: 'sha256:test' }; function vectorCodecLookup(): CodecLookup { + const vectorCodec = { + id: 'pg/vector@1', + encode: async (v: unknown) => v, + decode: async (w: unknown) => w, + encodeJson: (v: unknown) => v as never, + decodeJson: (j: unknown) => j as never, + } as ReturnType; return { - get: (id) => - id === 'pg/vector@1' - ? ({ - id: 'pg/vector@1', - targetTypes: ['vector'], - renderOutputType: (params) => `Vector<${params['length']}>`, - encode: async (v: unknown) => v, - decode: async (w: unknown) => w, - encodeJson: (v: unknown) => v as never, - decodeJson: (j: unknown) => j as never, - // The framework `Codec` shape narrows `traits` etc.; the - // structural narrow here is enough for the emit-path test. - } as unknown as ReturnType) - : undefined, + get: (id) => (id === 'pg/vector@1' ? vectorCodec : undefined), + targetTypesFor: (id) => (id === 'pg/vector@1' ? ['vector'] : undefined), + metaFor: () => undefined, + renderOutputTypeFor: (id, params) => + id === 'pg/vector@1' ? `Vector<${params['length']}>` : undefined, }; } describe('sqlEmission.resolveFieldTypeParams (integration via generateContractDts)', () => { it('renders typeRef-shaped parameterized columns via the codec descriptor', () => { - // Two columns share a named storage.types entry. The SQL emitter's - // resolveFieldTypeParams walk finds `Embedding1536`'s typeParams via - // `storage.fields[embedding].column → storage.tables.post.columns - // .embedding.typeRef → storage.types.Embedding1536.typeParams`, then - // the framework emit path renders the codec's output expression. + // Two columns share a named storage.types entry. The SQL emitter's resolveFieldTypeParams walk finds `Embedding1536`'s typeParams via `storage.fields[embedding].column → storage.tables.post.columns .embedding.typeRef → storage.types.Embedding1536.typeParams`, then the framework emit path renders the codec's output expression. const contract = createContract({ models: { Post: { @@ -117,8 +106,7 @@ describe('sqlEmission.resolveFieldTypeParams (integration via generateContractDt }); it('inline column typeParams continue to win over the resolver', () => { - // Inline `field.type.typeParams` takes precedence: even though the - // SQL resolver could find `Embedding1536`, the inline 768 wins. + // Inline `field.type.typeParams` takes precedence: even though the SQL resolver could find `Embedding1536`, the inline 768 wins. const contract = createContract({ models: { Post: { diff --git a/packages/2-sql/4-lanes/relational-core/README.md b/packages/2-sql/4-lanes/relational-core/README.md index 8e637c4a95..3212ce653f 100644 --- a/packages/2-sql/4-lanes/relational-core/README.md +++ b/packages/2-sql/4-lanes/relational-core/README.md @@ -91,66 +91,64 @@ flowchart TD - Defines `SqlQueryPlan` interface for SQL query plans produced by lanes before lowering - Per [ADR 205](../../../../docs/architecture%20docs/adrs/ADR%20205%20-%20Execution%20metadata%20lives%20on%20AST.md), codec IDs travel on `ProjectionItem.codecId` (output) and `ParamRef.codecId` (parameters) on the AST itself, not on plan-level descriptor lists -### Codec Factory (`ast/codec-types.ts` via `exports/ast.ts`) +### Codec authoring (class form) -- `codec({...})` is the SQL-side factory for constructing `Codec` values. It accepts `encode` and `decode` author functions in **either sync or async form** with no annotations; the constructed codec exposes Promise-returning query-time methods regardless of which form was used. Both `encode` and `decode` are required so `TInput` and `TWire` are always covered by an explicit author function — the factory installs no identity fallback. -- Build-time methods (`encodeJson`, `decodeJson`, `renderOutputType?`) are synchronous and pass through unchanged. -- This is the only public entry point for SQL codecs. There is no separate `codecSync` / `codecAsync` factory and no per-codec async marker on the resulting value. +Codec authors extend the framework `CodecImpl` (and pair the codec with a `CodecDescriptorImpl` registration) per [ADR 208 — Higher-order codecs for parameterized types](../../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). Each codec class declares the four conversion methods (`encode`, `decode`, `encodeJson`, `decodeJson`); the descriptor class declares static metadata (`codecId`, `traits`, `targetTypes`, `meta`, `paramsSchema`, optional `renderOutputType`) and a `factory` returning a per-instance codec. + +- Query-time methods (`encode` / `decode`) are typed as `Promise<…>`-returning at the public boundary; sync method bodies are accepted via TypeScript bivariance and the runtime always awaits. +- Build-time methods (`encodeJson` / `decodeJson` / `renderOutputType?`) stay synchronous so contract validation and client construction stay synchronous. +- Both `encode` and `decode` must be implemented — there is no identity-default fallback. ```ts -// Sync authoring: -const textCodec = codec({ - typeId: 'pg/text@1', - targetTypes: ['text'], - encode: (v: string) => v, - decode: (w: string) => w, - encodeJson: (v: string) => v, - decodeJson: (j: string) => j as string, -}); - -// Async authoring (e.g. KMS-backed encryption): same factory, same shape. -const secretCodec = codec({ - typeId: 'pg/secret@1', - targetTypes: ['text'], - encode: async (v: string) => encrypt(v, await getKey()), - decode: async (w: string) => decrypt(w, await getKey()), - encodeJson: (v: string) => v, - decodeJson: (j: string) => j as string, -}); +// Non-parameterized codec (P = void) — see packages/3-targets/3-targets/postgres/src/core/codecs.ts +// for the production form. +import { + CodecDescriptorImpl, + CodecImpl, + voidParamsSchema, + type CodecCallContext, + type CodecInstanceContext, +} from '@prisma-next/framework-components/codec'; + +class PgTextCodec extends CodecImpl<'pg/text@1', readonly ['equality'], string, string> { + override readonly id = 'pg/text@1'; + override readonly traits = ['equality'] as const; + encode(v: string, _ctx: CodecCallContext): Promise { return Promise.resolve(v); } + decode(w: string, _ctx: CodecCallContext): Promise { return Promise.resolve(w); } + encodeJson(v: string) { return v; } + decodeJson(j: unknown) { return j as string; } +} + +class PgTextDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'pg/text@1'; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['text'] as const; + override readonly paramsSchema = voidParamsSchema; + override readonly factory = () => (_ctx: CodecInstanceContext) => new PgTextCodec(); +} ``` #### Codec call context (`ctx`) -Codecs receive a second `ctx` options argument; you may ignore it. The runtime allocates one `SqlCodecCallContext` per `runtime.execute(plan, { signal })` call and threads the same reference to every codec dispatch site as a non-optional argument — when no `signal` is supplied the runtime still threads an empty `{}`, never `undefined`. The internal `Codec` interface declares the parameter as required (`encode(value, ctx: SqlCodecCallContext)` / `decode(wire, ctx: SqlCodecCallContext)`); single-arg author functions `(value) => …` continue to compile via TypeScript's bivariance for trailing parameters, so codec ergonomics are unchanged. +Codecs receive a second `ctx` options argument; you may ignore it. The runtime allocates one `SqlCodecCallContext` per `runtime.execute(plan, { signal })` call and threads the same reference to every codec dispatch site as a non-optional argument — when no `signal` is supplied the runtime still threads an empty `{}`, never `undefined`. The internal `Codec` interface declares the parameter as required (`encode(value, ctx: SqlCodecCallContext)` / `decode(wire, ctx: SqlCodecCallContext)`); single-arg method bodies continue to compile via TypeScript's bivariance for trailing parameters, so codec ergonomics are unchanged. - **`ctx.signal`** — the same `AbortSignal` reference at every codec call in one execute. Forward it to network SDKs so aborted queries stop talking to the underlying service. - **`ctx.column`** (decode-side only) — `{ table, name }` for cells the runtime can resolve to a single column ref; `undefined` for aggregate aliases, computed projections, and other unresolvable cells. Encode-side `ctx.column` is always `undefined` (encode-time column enrichment is the middleware's domain). ```ts // Forward ctx.signal to a network call so aborted queries stop the SDK. -const kmsSecretCodec = codec({ - typeId: 'pg/kms-secret@1', - targetTypes: ['text'], - encode: async (v: string, ctx) => - kms.encrypt({ plaintext: v }, { signal: ctx?.signal }), - decode: async (w: string, ctx) => - kms.decrypt({ ciphertext: w }, { signal: ctx?.signal }), - encodeJson: (v: string) => v, - decodeJson: (j: string) => j as string, -}); - -// Use ctx.column on decode to construct an envelope value carrying (table, name). -const envelopeCodec = codec({ - typeId: 'pg/envelope@1', - targetTypes: ['text'], - encode: async (v: string) => v, - decode: async (w: string, ctx) => ({ - value: w, - column: ctx?.column, - }), - encodeJson: (v: string) => v, - decodeJson: (j: string) => j as string, -}); +class PgKmsSecretCodec extends CodecImpl<'pg/kms-secret@1', readonly [], string, string> { + override readonly id = 'pg/kms-secret@1'; + override readonly traits = [] as const; + override async encode(v: string, ctx: SqlCodecCallContext): Promise { + return kms.encrypt({ plaintext: v }, { signal: ctx.signal }); + } + override async decode(w: string, ctx: SqlCodecCallContext): Promise { + return kms.decrypt({ ciphertext: w }, { signal: ctx.signal }); + } + encodeJson(v: string) { return v; } + decodeJson(j: unknown) { return j as string; } +} ``` Codec bodies that ignore `ctx.signal` complete in the background (cooperative cancellation); aborts still surface to the caller as `RUNTIME.ABORTED` with `details.phase ∈ { 'encode', 'decode', 'stream' }`. diff --git a/packages/2-sql/4-lanes/relational-core/package.json b/packages/2-sql/4-lanes/relational-core/package.json index 52e0012b65..edef45be19 100644 --- a/packages/2-sql/4-lanes/relational-core/package.json +++ b/packages/2-sql/4-lanes/relational-core/package.json @@ -22,6 +22,7 @@ "@prisma-next/sql-contract": "workspace:*", "@prisma-next/sql-operations": "workspace:*", "@prisma-next/utils": "workspace:*", + "@standard-schema/spec": "^1.1.0", "arktype": "catalog:", "ts-toolbelt": "^9.6.0" }, @@ -41,6 +42,7 @@ "exports": { ".": "./dist/index.mjs", "./ast": "./dist/exports/ast.mjs", + "./codec-descriptor-registry": "./dist/exports/codec-descriptor-registry.mjs", "./errors": "./dist/exports/errors.mjs", "./expression": "./dist/exports/expression.mjs", "./plan": "./dist/exports/plan.mjs", diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/adapter-types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/adapter-types.ts index 3702d3ef72..34b75e748a 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/adapter-types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/adapter-types.ts @@ -1,5 +1,4 @@ import type { ContractMarkerRecord } from '@prisma-next/contract/types'; -import type { CodecRegistry } from './codec-types'; import type { LoweredStatement } from './types'; export type AdapterTarget = string; @@ -14,22 +13,11 @@ export interface AdapterProfile { readonly target: TTarget; readonly capabilities: Record; /** - * Returns the adapter's default codec registry. - * The registry contains codecs provided by the adapter for converting - * between wire types and JavaScript types. - */ - codecs(): CodecRegistry; - /** - * Returns the SQL statement to read the contract marker from the database. - * Each adapter provides target-specific SQL (e.g. schema-qualified table names, - * parameter placeholder style). + * Returns the SQL statement to read the contract marker from the database. Each adapter provides target-specific SQL (e.g. schema-qualified table names, parameter placeholder style). */ readMarkerStatement(): MarkerStatement; /** - * Parses a row returned by the adapter's `readMarkerStatement()` into a - * `ContractMarkerRecord`. Each adapter is responsible for any - * target-specific decoding before delegating to the shared row schema. - * Throws on shape violation. + * Parses a row returned by the adapter's `readMarkerStatement()` into a `ContractMarkerRecord`. Each adapter is responsible for any target-specific decoding before delegating to the shared row schema. Throws on shape violation. */ parseMarkerRow(row: unknown): ContractMarkerRecord; } @@ -45,11 +33,7 @@ export type Lowerer TBody; /** - * Lowers a query AST into a target-specific executable body (typically - * `LoweredStatement` for SQL adapters). The `lower` method returns the body - * directly; per-statement metadata, when needed, lives on the body itself - * (e.g. `LoweredStatement.annotations`). Adapter-level metadata such as the - * profile id is reachable via `profile.id` for callers that genuinely need it. + * Lowers a query AST into a target-specific executable body (typically `LoweredStatement` for SQL adapters). The `lower` method returns the body directly; per-statement metadata, when needed, lives on the body itself (e.g. `LoweredStatement.annotations`). Adapter-level metadata such as the profile id is reachable via `profile.id` for callers that genuinely need it. */ export interface Adapter { readonly profile: AdapterProfile; diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts index 1fe9ca87f8..f9331100bb 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts @@ -1,28 +1,21 @@ -import type { JsonValue } from '@prisma-next/contract/types'; import type { Codec as BaseCodec, CodecCallContext, + CodecDescriptor, CodecInstanceContext, CodecTrait, } from '@prisma-next/framework-components/codec'; -import { ifDefined } from '@prisma-next/utils/defined'; -import type { Type } from 'arktype'; -import type { O } from 'ts-toolbelt'; -export type { CodecCallContext, CodecTrait } from '@prisma-next/framework-components/codec'; +export type { + CodecCallContext, + CodecDescriptor, + CodecTrait, +} from '@prisma-next/framework-components/codec'; /** - * SQL-family addressing of a single column. The decode site populates a - * `SqlColumnRef` whenever it can resolve the cell to a single underlying - * `(table, column)` (the typical case for projected columns from a - * single-table source); cells the runtime cannot resolve (aggregate - * aliases, include aggregate fields, computed projections without a - * simple ref) get `column = undefined`. + * SQL-family addressing of a single column. The decode site populates a `SqlColumnRef` whenever it can resolve the cell to a single underlying `(table, column)` (the typical case for projected columns from a single-table source); cells the runtime cannot resolve (aggregate aliases, include aggregate fields, computed projections without a simple ref) get `column = undefined`. * - * The shape is a structural projection of the runtime's `ColumnRef` so - * the SQL decode site can reuse the resolution it already performs for - * `RUNTIME.DECODE_FAILED` envelope construction without allocating - * twice per cell. + * The shape is a structural projection of the runtime's `ColumnRef` so the SQL decode site can reuse the resolution it already performs for `RUNTIME.DECODE_FAILED` envelope construction without allocating twice per cell. */ export interface SqlColumnRef { readonly table: string; @@ -30,92 +23,29 @@ export interface SqlColumnRef { } /** - * SQL-family per-call context. Extends the framework {@link CodecCallContext} - * (which carries `signal` only) with `column?: SqlColumnRef`, populated - * on **decode** call sites that can resolve a single underlying column - * ref. Encode call sites currently leave `column` undefined (encode-time - * column context is the middleware's domain). + * SQL-family per-call context. Extends the framework {@link CodecCallContext} (which carries `signal` only) with `column?: SqlColumnRef`, populated on **decode** call sites that can resolve a single underlying column ref. Encode call sites currently leave `column` undefined (encode-time column context is the middleware's domain). * - * SQL codec authors writing a `(value, ctx)` author function for the SQL - * `codec()` factory observe this type. The framework codec dispatch - * surface (and Mongo) sees only the base `CodecCallContext`. + * SQL codec authors writing codec methods observe this type via {@link SqlCodec}. The framework codec dispatch surface (and Mongo) sees only the base `CodecCallContext`. */ export interface SqlCodecCallContext extends CodecCallContext { readonly column?: SqlColumnRef; } /** - * SQL-family per-instance context. Extends the framework - * {@link CodecInstanceContext} (`name` only) with `usedAt`, the set of - * `(table, column)` pairs the resolved codec serves. + * SQL-family per-instance context. Extends the framework {@link CodecInstanceContext} (`name` only) with `usedAt`, the set of `(table, column)` pairs the resolved codec serves. * - * - For `typeRef` columns sharing one named `storage.types` instance, the - * array lists every referencing column — a column-scoped stateful codec - * (e.g. encryption) can derive aggregated per-instance state across all - * the columns sharing the named instance. - * - For inline-`typeParams` columns, the array has exactly one entry — - * the column that owns the inline params. - * - For shared non-parameterized codecs, the array carries one - * representative entry (the column that triggered materialization); - * the codec is shared across every column with that codec id, so the - * `usedAt` is informational only. + * - For `typeRef` columns sharing one named `storage.types` instance, the array lists every referencing column — a column-scoped stateful codec (e.g. encryption) can derive aggregated per-instance state across all the columns sharing the named instance. + * - For inline-`typeParams` columns, the array has exactly one entry — the column that owns the inline params. + * - For shared non-parameterized codecs, the array carries one representative entry (the column that triggered materialization); the codec is shared across every column with that codec id, so the `usedAt` is informational only. * - * SQL extensions consuming `usedAt` (e.g. column-scoped state derivation) - * type their factory parameter as `SqlCodecInstanceContext`. Extensions - * that don't read `usedAt` type their factory parameter as the - * family-agnostic {@link CodecInstanceContext} — a `SqlCodecInstanceContext` - * is structurally assignable to the base. + * SQL extensions consuming `usedAt` (e.g. column-scoped state derivation) type their factory parameter as `SqlCodecInstanceContext`. Extensions that don't read `usedAt` type their factory parameter as the family-agnostic {@link CodecInstanceContext} — a `SqlCodecInstanceContext` is structurally assignable to the base. */ export interface SqlCodecInstanceContext extends CodecInstanceContext { readonly usedAt: ReadonlyArray<{ readonly table: string; readonly column: string }>; } /** - * Legacy adapter-level descriptor for parameterized codecs that require - * type-parameter validation at compile time. The runtime descriptor - * (`RuntimeParameterizedCodecDescriptor` in `@prisma-next/sql-runtime`) - * has migrated to the unified `CodecDescriptor

` shape with - * `factory: (P) => (CodecInstanceContext) => Codec`; this descriptor stays only because - * the SQL `Adapter.parameterizedCodecs()` surface still returns - * `CodecParamsDescriptor[]` (compile-time typeParams validation only, - * not runtime materialization). - * - * Retirement is tracked under TML-2357 T3.5.4 (single registration slot) - * — the adapter-level `parameterizedCodecs()` collapses into the unified - * runtime descriptor map once contributors migrate fully. - * - * @template TParams - The shape of the type parameters (e.g., `{ length: number }`) - * @template THelper - The type returned by the optional `init` hook - */ -export interface CodecParamsDescriptor, THelper = unknown> { - /** The codec ID this descriptor applies to (e.g., 'pg/vector@1') */ - readonly codecId: string; - - /** - * Arktype schema for validating typeParams. - * Used to validate both storage.types entries and inline column typeParams. - */ - readonly paramsSchema: Type; - - /** - * Optional init hook called during runtime context creation. - * Receives validated params and returns a helper object to be stored in context.types. - * If not provided, the validated params are stored directly. - * - * Predecessor pattern. The runtime descriptor's curried - * `factory: (P) => (CodecInstanceContext) => Codec` subsumes this hook — per-instance - * state lives on the resolved codec rather than in a parallel - * `TypeHelperRegistry` entry. Retirement tracked under TML-2357 T3.5.2 - * (narrow runtime `Codec` interface) and T3.5.4 (single registration - * slot). Adapter-level callers reading codec-self-carried `init` should - * migrate to the runtime descriptor map's factory instead. - */ - readonly init?: (params: TParams) => THelper; -} - -/** - * Codec metadata for database-specific type information. - * Used for schema introspection and verification. + * Codec metadata for database-specific type information. Used for schema introspection and verification. */ export interface CodecMeta { readonly db?: { @@ -128,503 +58,85 @@ export interface CodecMeta { } /** - * SQL codec — extends the framework codec base with SQL-specific metadata: - * driver-native type info (`meta.db.sql..nativeType`) and an - * optional parameterized-codec descriptor (`paramsSchema` + `init`) for - * codecs that require type-parameter validation (e.g. `pg/vector@1`). + * SQL codec — extends the framework codec base by narrowing the per-call context to the SQL-family {@link SqlCodecCallContext} (adds `column?: SqlColumnRef`). TypeScript treats method-syntax declarations bivariantly, so the SQL narrowing is structurally compatible with the framework {@link BaseCodec} super-interface. * - * `encode` and `decode` are redeclared here to narrow the per-call - * context to the SQL-family {@link SqlCodecCallContext} (adds - * `column?: SqlColumnRef`). TypeScript treats method-syntax declarations - * bivariantly, so the SQL narrowing is structurally compatible with the - * framework {@link BaseCodec} super-interface. + * Codec-id-keyed static metadata (`traits`, `targetTypes`, `meta`, `paramsSchema`, `renderOutputType`) lives on the unified {@link import('@prisma-next/framework-components/codec').CodecDescriptor} — the codec instance itself only carries `id` plus the four conversion methods. * - * Note: `paramsSchema` and `init` here are the legacy adapter-level slots - * mirrored from {@link CodecParamsDescriptor}. The runtime materialization - * path uses `RuntimeParameterizedCodecDescriptor` (in - * `@prisma-next/sql-runtime`) via the unified `CodecDescriptor

` shape; - * codec-self-carried `paramsSchema`/`init` retire under TML-2357 (T3.5.2 - * narrows the runtime `Codec` interface; T3.5.4 collapses the parallel - * registration slots). - * - * See `Codec` in `@prisma-next/framework-components/codec` for the codec - * contract that this interface extends. + * See `Codec` in `@prisma-next/framework-components/codec` for the codec contract that this interface extends. */ export interface Codec< Id extends string = string, TTraits extends readonly CodecTrait[] = readonly CodecTrait[], TWire = unknown, TInput = unknown, - TParams = Record, - THelper = unknown, > extends BaseCodec { encode(value: TInput, ctx: SqlCodecCallContext): Promise; decode(wire: TWire, ctx: SqlCodecCallContext): Promise; - readonly meta?: CodecMeta; - readonly paramsSchema?: Type; - /** - * Predecessor init hook. Retirement tracked under TML-2357 (T3.5.2 / - * T3.5.4); the unified runtime descriptor's - * `factory: (P) => (CodecInstanceContext) => Codec` is the replacement. - */ - readonly init?: (params: TParams) => THelper; } /** * Contract-bound codec registry. * - * The dispatch interface for encode/decode at runtime: built once at - * `ExecutionContext` construction time by walking the contract's - * `storage.tables[].columns[]` and resolving each column to either a per- - * instance parameterized codec (via `descriptor.factory(typeParams)(ctx)`) - * or the shared codec instance from the legacy `CodecRegistry` (for non- - * parameterized codecs). The dispatch path calls - * `forColumn(table, column).encode/decode(...)` and doesn't know whether - * the codec is parameterized. - * - * `forCodecId(codecId)` is a fallback for sites that don't carry the - * `(table, column)` ref through to the encode/decode call site — - * primarily the param-encoding path, where `ParamRef.refs` is not - * populated by the SQL builder today (every `ParamRef` carries `codecId` - * but not the column it relates to). For the parameterized codecs shipped - * at Phase B, encode is per-instance-stateless (pgvector formats - * `[v1,v2,v3]` regardless of length; JSON's `encode` is `JSON.stringify` - * regardless of schema), so a codec-id-keyed lookup yields a structurally - * equivalent encoder; the fallback is the bridge that lets the legacy - * `codecs:` registration retire from the dispatch path while staying as - * the codec-id-only source for now. + * The dispatch interface for encode/decode at runtime: built once at `ExecutionContext` construction time by walking the contract's `storage.tables[].columns[]` and resolving each column through its descriptor's factory (per-instance for parameterized columns; the cached shared codec for non-parameterized columns). The dispatch path calls `forColumn(table, column).encode/decode(...)` and doesn't know whether the codec + * is parameterized. * - * The encode-side fallback is the AC-5-deferred carve-out documented in - * the codec-registry-unification spec § Non-functional constraints. - * TML-2357 retires the fallback by threading `ParamRef.refs` through - * column-bound construction sites. + * `forCodecId(codecId)` is the refs-less fallback. Every column-bound `ParamRef` carries `refs: { table; column }` and the builder-pipeline `validateParamRefRefs` pass enforces refs on every parameterized `ParamRef` before encode runs, so this fallback is only reached for non-parameterized codec ids. */ export interface ContractCodecRegistry { /** - * Resolve the codec for `(table, column)`. Returns the per-instance - * parameterized codec for parameterized columns, the shared codec for - * non-parameterized columns, or `undefined` if the column is unknown - * or the codec isn't registered. + * Resolve the codec for `(table, column)`. Returns the per-instance parameterized codec for parameterized columns, the shared codec for non-parameterized columns, or `undefined` if the column is unknown or the codec isn't registered. */ forColumn(table: string, column: string): Codec | undefined; /** - * Resolve a codec by id. Returns the same codec instance the legacy - * `CodecRegistry.get(codecId)` would return — for non-parameterized - * codecs that's the shared instance; for parameterized codecs that's - * a representative resolved instance. Used by sites that don't carry - * `(table, column)` through to the encode/decode call site (the AC-5 - * carve-out path). + * Resolve a codec by id. For non-parameterized codecs this returns the canonical shared instance materialized once at context construction; for parameterized codecs it returns the column-resolved instance when a single column declares the codec id, or the `factory(undefined)` representative when the descriptor's factory is parameter-tolerant. Used by refs-less call sites; the validator pass guarantees the call site's + * `codecId` is non-parameterized (or parameter-tolerant) at this boundary. */ forCodecId(codecId: string): Codec | undefined; } /** - * Registry interface for codecs organized by ID and by contract scalar type. - * - * The registry allows looking up codecs by their namespaced ID or by the - * contract scalar types they handle. Multiple codecs may handle the same - * scalar type; ordering in byScalar reflects preference (adapter first, - * then packs, then app overrides). + * Variance-erased descriptor type used for heterogeneous storage in collection containers and on the unified contributor `codecs:` slot. The descriptor's `factory` and `renderOutputType` are contravariant in `P`, so descriptors with different params shapes are not in a subtype relationship; collecting them into one container needs an explicit variance erasure rather than `CodecDescriptor` (which is the + * narrowest, not the widest, of the family). */ -export interface CodecRegistry { - get(id: string): Codec | undefined; - has(id: string): boolean; - getByScalar(scalar: string): readonly Codec[]; - getDefaultCodec(scalar: string): Codec | undefined; - register(codec: Codec): void; - /** Returns true if the codec with this ID has the given trait. */ - hasTrait(codecId: string, trait: CodecTrait): boolean; - /** Returns all traits for a codec, or an empty array if not found. */ - traitsOf(codecId: string): readonly CodecTrait[]; - [Symbol.iterator](): Iterator>; - values(): IterableIterator>; -} - -/** - * Implementation of CodecRegistry. - */ -class CodecRegistryImpl implements CodecRegistry { - private readonly _byId = new Map>(); - private readonly _byScalar = new Map[]>(); - - /** - * Map-like interface for codec lookup by ID. - * Example: registry.get('pg/text@1') - */ - get(id: string): Codec | undefined { - return this._byId.get(id); - } - - /** - * Check if a codec with the given ID is registered. - */ - has(id: string): boolean { - return this._byId.has(id); - } - - /** - * Get all codecs that handle a given scalar type. - * Returns an empty frozen array if no codecs are found. - * Example: registry.getByScalar('text') → [codec1, codec2, ...] - */ - getByScalar(scalar: string): readonly Codec[] { - return this._byScalar.get(scalar) ?? Object.freeze([]); - } - - /** - * Get the default codec for a scalar type (first registered codec). - * Returns undefined if no codec handles this scalar type. - */ - getDefaultCodec(scalar: string): Codec | undefined { - const _codecs = this._byScalar.get(scalar); - return _codecs?.[0]; - } - - /** - * Register a codec in the registry. - * Throws an error if a codec with the same ID is already registered. - * - * @param codec - The codec to register - * @throws Error if a codec with the same ID already exists - */ - register(codec: Codec): void { - if (this._byId.has(codec.id)) { - throw new Error(`Codec with ID '${codec.id}' is already registered`); - } +// biome-ignore lint/suspicious/noExplicitAny: descriptor variance erasure — `P` is contravariant on the factory and renderOutputType slots, so heterogeneous descriptor storage cannot use `unknown`. +export type AnyCodecDescriptor = CodecDescriptor; - this._byId.set(codec.id, codec); +type DescriptorResolvedCodec = + D extends CodecDescriptor ? ReturnType> : never; - // Update byScalar mapping - for (const scalarType of codec.targetTypes) { - const existing = this._byScalar.get(scalarType); - if (existing) { - existing.push(codec); - } else { - this._byScalar.set(scalarType, [codec]); - } - } - } +export type DescriptorCodecId = D extends AnyCodecDescriptor ? D['codecId'] : never; - hasTrait(codecId: string, trait: CodecTrait): boolean { - const codec = this._byId.get(codecId); - return codec?.traits?.includes(trait) ?? false; - } - - traitsOf(codecId: string): readonly CodecTrait[] { - return this._byId.get(codecId)?.traits ?? []; - } - - /** - * Returns an iterator over all registered codecs. - * Useful for iterating through codecs from another registry. - */ - *[Symbol.iterator](): Iterator> { - for (const codec of this._byId.values()) { - yield codec; - } - } - - /** - * Returns an iterable of all registered codecs. - */ - values(): IterableIterator> { - return this._byId.values(); - } -} - -/** - * Conditional bundle for `encodeJson`/`decodeJson`: when `TInput` is - * structurally assignable to `JsonValue` the identity defaults are - * sound and both fields are optional; otherwise both fields are - * required so an author cannot silently produce a non-JSON-safe - * contract artifact. - */ -type JsonRoundTripConfig = [TInput] extends [JsonValue] - ? { - encodeJson?: (value: TInput) => JsonValue; - decodeJson?: (json: JsonValue) => TInput; - } - : { - encodeJson: (value: TInput) => JsonValue; - decodeJson: (json: JsonValue) => TInput; - }; +export type DescriptorCodecInput = + DescriptorResolvedCodec extends BaseCodec + ? In + : never; /** - * Construct a SQL codec from author functions and optional metadata. - * - * Author `encode` and `decode` as sync or async functions; the factory - * produces a {@link Codec} whose query-time methods follow the boundary - * contract documented on `Codec`. Authors receive a second `ctx` options - * argument carrying the SQL-family per-call context; ignore it if you - * don't need it. + * Resolve the trait union for a descriptor `D`. * - * Both `encode` and `decode` are required so `TInput` and `TWire` are - * always covered by an explicit author function — the factory installs - * no identity fallback. `encodeJson` and `decodeJson` default to identity - * **only when `TInput` is assignable to `JsonValue`**; otherwise both are - * required so the contract artifact stays JSON-safe. + * Reads `traits` directly off the descriptor — concrete descriptor classes declare `override readonly traits = [...] as const`, which preserves the literal trait tuple at the descriptor type. Reading from the resolved codec instance (`CodecImpl<…, TTraits, …>`) would lose the literal because `Codec` carries `TTraits` only on its optional phantom slot (`readonly __codecTraits?: TTraits`); codecs extending `CodecImpl` + * have no required structural site that pins `TTraits`, so a descriptor-keyed extractor reading from the codec instance would widen to the broad `CodecTrait` union. */ -export function codec< - Id extends string, - const TTraits extends readonly CodecTrait[] = readonly [], - TWire = unknown, - TInput = unknown, - TParams = Record, - THelper = unknown, ->( - config: { - typeId: Id; - targetTypes: readonly string[]; - encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; - decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; - meta?: CodecMeta; - paramsSchema?: Type; - init?: (params: TParams) => THelper; - traits?: TTraits; - renderOutputType?: (typeParams: Record) => string | undefined; - } & JsonRoundTripConfig, -): Codec { - const identity = (v: unknown) => v; - // The runtime allocates one `SqlCodecCallContext` per `runtime.execute()` - // call (no caller-supplied `signal` produces `{}` instead of `undefined`) - // and threads it as a non-optional reference to every codec call. The - // author surface keeps the second parameter optional so single-arg - // `(value) => …` authors continue to satisfy the signature via - // TypeScript's bivariance for trailing parameters. - const userEncode = config.encode; - const userDecode = config.decode; - // The conditional JsonRoundTripConfig narrows TInput|JsonValue at the - // boundary; widen back to the generic shape inside the factory body. - const widenedConfig = config as { - encodeJson?: (value: TInput) => JsonValue; - decodeJson?: (json: JsonValue) => TInput; - }; - return { - id: config.typeId, - targetTypes: config.targetTypes, - ...ifDefined('meta', config.meta), - ...ifDefined('paramsSchema', config.paramsSchema), - ...ifDefined('init', config.init), - ...ifDefined( - 'traits', - config.traits ? (Object.freeze([...config.traits]) as TTraits) : undefined, - ), - ...ifDefined('renderOutputType', config.renderOutputType), - encode: (value, ctx) => { - try { - return Promise.resolve(userEncode(value, ctx)); - } catch (error) { - return Promise.reject(error); - } - }, - decode: (wire, ctx) => { - try { - return Promise.resolve(userDecode(wire, ctx)); - } catch (error) { - return Promise.reject(error); - } - }, - encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, - decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, - }; +export type DescriptorCodecTraits = D extends { + readonly traits: infer TTraits extends readonly CodecTrait[]; } + ? TTraits[number] & CodecTrait + : never; /** - * Type helpers to extract codec types. - */ -export type CodecId = - T extends Codec ? Id : T extends { readonly id: infer Id } ? Id : never; -export type CodecInput = - T extends Codec ? In : never; -export type CodecTraits = - T extends Codec ? TTraits[number] & CodecTrait : never; - -/** - * Type helper to extract codec types from builder instance. + * Project a record of {@link AnyCodecDescriptor}s keyed by scalar name onto the codec-id-keyed `CodecTypes` shape consumed by emit and no-emit type pipelines (`{ readonly [codecId]: { input; output; traits } }`). + * + * Canonical extractor for the descriptor-keyed type pipeline; the legacy instance-keyed extractor and its `mkCodec`-bound builder retired alongside the carrier deletion. */ export type ExtractCodecTypes< - ScalarNames extends { readonly [K in keyof ScalarNames]: Codec } = Record, + ScalarNames extends { + readonly [K in keyof ScalarNames]: AnyCodecDescriptor; + } = Record, > = { - readonly [K in keyof ScalarNames as ScalarNames[K] extends Codec ? Id : never]: { - readonly input: CodecInput; - readonly output: CodecInput; - readonly traits: CodecTraits; + readonly [K in keyof ScalarNames as DescriptorCodecId]: { + readonly input: DescriptorCodecInput; + readonly output: DescriptorCodecInput; + readonly traits: DescriptorCodecTraits; }; }; - -/** - * Type helper to extract data type IDs from builder instance. - * Uses ExtractCodecTypes which preserves literal types as keys. - * Since ExtractCodecTypes> has exactly one key (the Id), - * we extract it by creating a mapped type that uses the Id as both key and value, - * then extract the value type. This preserves literal types. - */ -export type ExtractDataTypes< - ScalarNames extends { readonly [K in keyof ScalarNames]: Codec }, -> = { - readonly [K in keyof ScalarNames]: { - readonly [Id in keyof ExtractCodecTypes>]: Id; - }[keyof ExtractCodecTypes>]; -}; - -/** - * Builder interface for declaring codecs. - */ -export interface CodecDefBuilder< - ScalarNames extends { readonly [K in keyof ScalarNames]: Codec } = Record, -> { - readonly CodecTypes: ExtractCodecTypes; - - add>( - scalarName: ScalarName, - codecImpl: CodecImpl, - ): CodecDefBuilder< - O.Overwrite> & Record - >; - - readonly codecDefinitions: { - readonly [K in keyof ScalarNames]: { - readonly typeId: ScalarNames[K] extends Codec ? Id : never; - readonly scalar: K; - readonly codec: ScalarNames[K]; - readonly input: CodecInput; - readonly output: CodecInput; - readonly jsType: CodecInput; - }; - }; - - readonly dataTypes: { - readonly [K in keyof ScalarNames]: { - readonly [Id in keyof ExtractCodecTypes>]: Id; - }[keyof ExtractCodecTypes>]; - }; -} - -/** - * Implementation of CodecDefBuilder. - */ -class CodecDefBuilderImpl< - ScalarNames extends { readonly [K in keyof ScalarNames]: Codec } = Record, -> implements CodecDefBuilder -{ - private readonly _codecs: ScalarNames; - - public readonly CodecTypes: ExtractCodecTypes; - public readonly dataTypes: { - readonly [K in keyof ScalarNames]: { - readonly [Id in keyof ExtractCodecTypes>]: Id; - }[keyof ExtractCodecTypes>]; - }; - - constructor(codecs: ScalarNames) { - this._codecs = codecs; - - // Populate CodecTypes from codecs - const codecTypes: Record< - string, - { readonly input: unknown; readonly output: unknown; readonly traits: unknown } - > = {}; - for (const [, codecImpl] of Object.entries(this._codecs)) { - const codecImplTyped = codecImpl as Codec; - codecTypes[codecImplTyped.id] = { - input: undefined as unknown as CodecInput, - output: undefined as unknown as CodecInput, - traits: undefined as unknown as CodecTraits, - }; - } - this.CodecTypes = codecTypes as ExtractCodecTypes; - - // Populate dataTypes from codecs - extract id property from each codec - // Build object preserving keys from ScalarNames - // Type assertion is safe because we know ScalarNames structure matches the return type - // biome-ignore lint/suspicious/noExplicitAny: dynamic codec mapping requires any - const dataTypes = {} as any; - for (const key in this._codecs) { - if (Object.hasOwn(this._codecs, key)) { - const codec = this._codecs[key] as Codec; - dataTypes[key] = codec.id; - } - } - this.dataTypes = dataTypes as { - readonly [K in keyof ScalarNames]: { - readonly [Id in keyof ExtractCodecTypes>]: Id; - }[keyof ExtractCodecTypes>]; - }; - } - - add>( - scalarName: ScalarName, - codecImpl: CodecImpl, - ): CodecDefBuilder< - O.Overwrite> & Record - > { - return new CodecDefBuilderImpl({ - ...this._codecs, - [scalarName]: codecImpl, - } as O.Overwrite> & Record); - } - - /** - * Derive codecDefinitions structure. - */ - get codecDefinitions(): { - readonly [K in keyof ScalarNames]: { - readonly typeId: ScalarNames[K] extends Codec ? Id : never; - readonly scalar: K; - readonly codec: ScalarNames[K]; - readonly input: CodecInput; - readonly output: CodecInput; - readonly jsType: CodecInput; - }; - } { - const result: Record< - string, - { - typeId: string; - scalar: string; - codec: Codec; - input: unknown; - output: unknown; - jsType: unknown; - } - > = {}; - - for (const [scalarName, codecImpl] of Object.entries(this._codecs)) { - const codec = codecImpl as Codec; - result[scalarName] = { - typeId: codec.id, - scalar: scalarName, - codec: codec, - input: undefined as unknown as CodecInput, - output: undefined as unknown as CodecInput, - jsType: undefined as unknown as CodecInput, - }; - } - - return result as { - readonly [K in keyof ScalarNames]: { - readonly typeId: ScalarNames[K] extends Codec ? Id : never; - readonly scalar: K; - readonly codec: ScalarNames[K]; - readonly input: CodecInput; - readonly output: CodecInput; - readonly jsType: CodecInput; - }; - }; - } -} - -/** - * Create a new codec registry. - */ -export function createCodecRegistry(): CodecRegistry { - return new CodecRegistryImpl(); -} - -/** - * Create a new codec definition builder. - */ -export function defineCodecs(): CodecDefBuilder> { - return new CodecDefBuilderImpl({}); -} diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts new file mode 100644 index 0000000000..a276338058 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codec-helpers.ts @@ -0,0 +1,79 @@ +/** + * Shared encode/decode/render constants and codec id literals for the six SQL base codecs (`sql/char@1`, `sql/varchar@1`, `sql/int@1`, `sql/float@1`, `sql/text@1`, `sql/timestamp@1`). + * + * The codec implementations live in `sql-codecs.ts` (TML-2357). This module retains only the conversion helpers + emit-path renderers the codec methods compose with — keeping a single source of truth for non-trivial conversions while the codec methods provide the framework-required `Promise<…>` boundary. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; + +export const SQL_CHAR_CODEC_ID = 'sql/char@1' as const; +export const SQL_VARCHAR_CODEC_ID = 'sql/varchar@1' as const; +export const SQL_INT_CODEC_ID = 'sql/int@1' as const; +export const SQL_FLOAT_CODEC_ID = 'sql/float@1' as const; +export const SQL_TEXT_CODEC_ID = 'sql/text@1' as const; +export const SQL_TIMESTAMP_CODEC_ID = 'sql/timestamp@1' as const; + +export const sqlCharEncode = (value: string): string => value; +export const sqlCharDecode = (wire: string): string => wire.trimEnd(); +export const sqlCharRenderOutputType = (typeParams: { readonly length?: number }) => { + const length = typeParams.length; + if (length === undefined) return undefined; + if (typeof length !== 'number' || !Number.isFinite(length) || !Number.isInteger(length)) { + throw new Error( + `renderOutputType: expected integer "length" in typeParams for Char, got ${String(length)}`, + ); + } + return `Char<${length}>`; +}; + +export const sqlVarcharEncode = (value: string): string => value; +export const sqlVarcharDecode = (wire: string): string => wire; +export const sqlVarcharRenderOutputType = (typeParams: { readonly length?: number }) => { + const length = typeParams.length; + if (length === undefined) return undefined; + if (typeof length !== 'number' || !Number.isFinite(length) || !Number.isInteger(length)) { + throw new Error( + `renderOutputType: expected integer "length" in typeParams for Varchar, got ${String(length)}`, + ); + } + return `Varchar<${length}>`; +}; + +export const sqlIntEncode = (value: number): number => value; +export const sqlIntDecode = (wire: number): number => wire; + +export const sqlFloatEncode = (value: number): number => value; +export const sqlFloatDecode = (wire: number): number => wire; + +export const sqlTextEncode = (value: string): string => value; +export const sqlTextDecode = (wire: string): string => wire; + +export const sqlTimestampEncode = (value: Date): Date => value; +export const sqlTimestampDecode = (wire: Date): Date => wire; +export const sqlTimestampEncodeJson = (value: Date): JsonValue => value.toISOString(); +export const sqlTimestampDecodeJson = (json: JsonValue): Date => { + if (typeof json !== 'string') { + throw new Error(`Expected ISO date string for sql/timestamp@1, got ${typeof json}`); + } + const date = new Date(json); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid ISO date string for sql/timestamp@1: ${json}`); + } + return date; +}; +export const sqlTimestampRenderOutputType = (typeParams: { readonly precision?: number }) => { + const precision = typeParams.precision; + if (precision === undefined) { + return 'Timestamp'; + } + if ( + typeof precision !== 'number' || + !Number.isFinite(precision) || + !Number.isInteger(precision) + ) { + throw new Error( + `renderOutputType: expected integer "precision" in typeParams for Timestamp, got ${String(precision)}`, + ); + } + return `Timestamp<${precision}>`; +}; diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts index bbf92600c3..dbcf1125b1 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts @@ -1,159 +1,302 @@ +/** + * The six SQL base codecs (TML-2357). + * + * Each codec ships as three artifacts: + * + * 1. A `SqlXCodec` class extending {@link CodecImpl} that wraps the module-level encode/decode constants exported from `sql-codec-helpers.ts` (the single source of truth for runtime behaviour). 2. A `SqlXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, and (where applicable) the emit-path `renderOutputType`. 3. A per-codec column helper (`sqlXColumn`) + * that calls `descriptor.factory(...)` directly and packages the result into a {@link ColumnSpec} via the framework {@link column} packager. The helper is tied to its descriptor with `satisfies ColumnHelperFor`. + * + * After TML-2357 this file is the canonical source of SQL base codec metadata and runtime behaviour — the legacy `mkCodec` / `defineCodec` carriers retired with the deletion sweep. + */ + import type { JsonValue } from '@prisma-next/contract/types'; +import { + type CodecCallContext, + CodecDescriptorImpl, + CodecImpl, + type CodecInstanceContext, + type ColumnHelperFor, + type ColumnHelperForStrict, + column, + voidParamsSchema, +} from '@prisma-next/framework-components/codec'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; -import { codec, defineCodecs } from './codec-types'; +import { + SQL_CHAR_CODEC_ID, + SQL_FLOAT_CODEC_ID, + SQL_INT_CODEC_ID, + SQL_TEXT_CODEC_ID, + SQL_TIMESTAMP_CODEC_ID, + SQL_VARCHAR_CODEC_ID, + sqlCharDecode, + sqlCharEncode, + sqlCharRenderOutputType, + sqlFloatDecode, + sqlFloatEncode, + sqlIntDecode, + sqlIntEncode, + sqlTextDecode, + sqlTextEncode, + sqlTimestampDecode, + sqlTimestampDecodeJson, + sqlTimestampEncode, + sqlTimestampEncodeJson, + sqlTimestampRenderOutputType, + sqlVarcharDecode, + sqlVarcharEncode, + sqlVarcharRenderOutputType, +} from './sql-codec-helpers'; -export const SQL_CHAR_CODEC_ID = 'sql/char@1' as const; -export const SQL_VARCHAR_CODEC_ID = 'sql/varchar@1' as const; -export const SQL_INT_CODEC_ID = 'sql/int@1' as const; -export const SQL_FLOAT_CODEC_ID = 'sql/float@1' as const; -export const SQL_TEXT_CODEC_ID = 'sql/text@1' as const; -export const SQL_TIMESTAMP_CODEC_ID = 'sql/timestamp@1' as const; +type LengthParams = { readonly length?: number }; +type PrecisionParams = { readonly precision?: number }; const lengthParamsSchema = arktype({ - length: 'number.integer > 0', -}); + 'length?': 'number.integer > 0', +}) satisfies StandardSchemaV1; const precisionParamsSchema = arktype({ 'precision?': 'number.integer >= 0 & number.integer <= 6', -}); - -type LengthTypeHelper = { - readonly kind: 'fixed' | 'variable'; - readonly maxLength: number; -}; - -function createLengthTypeHelper( - kind: LengthTypeHelper['kind'], -): (params: Record) => LengthTypeHelper { - return (params) => ({ - kind, - maxLength: params['length'] as number, - }); +}) satisfies StandardSchemaV1; + +export class SqlTextCodec extends CodecImpl< + typeof SQL_TEXT_CODEC_ID, + readonly ['equality', 'order', 'textual'], + string, + string +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return sqlTextEncode(value); + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return sqlTextDecode(wire); + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} + +export class SqlTextDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQL_TEXT_CODEC_ID; + override readonly traits = ['equality', 'order', 'textual'] as const; + override readonly targetTypes = ['text'] as const; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqlTextCodec { + return () => new SqlTextCodec(this); + } +} + +export const sqlTextDescriptor = new SqlTextDescriptor(); + +export const sqlTextColumn = () => + column(sqlTextDescriptor.factory(), sqlTextDescriptor.codecId, undefined, 'text'); + +sqlTextColumn satisfies ColumnHelperFor; +sqlTextColumn satisfies ColumnHelperForStrict; + +export class SqlIntCodec extends CodecImpl< + typeof SQL_INT_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number, + number +> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return sqlIntEncode(value); + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return sqlIntDecode(wire); + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +export class SqlIntDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQL_INT_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['int'] as const; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqlIntCodec { + return () => new SqlIntCodec(this); + } } -const sqlCharCodec = codec< +export const sqlIntDescriptor = new SqlIntDescriptor(); + +export const sqlIntColumn = () => + column(sqlIntDescriptor.factory(), sqlIntDescriptor.codecId, undefined, 'int'); + +sqlIntColumn satisfies ColumnHelperFor; +sqlIntColumn satisfies ColumnHelperForStrict; + +export class SqlFloatCodec extends CodecImpl< + typeof SQL_FLOAT_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number, + number +> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return sqlFloatEncode(value); + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return sqlFloatDecode(wire); + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +export class SqlFloatDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQL_FLOAT_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['float'] as const; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqlFloatCodec { + return () => new SqlFloatCodec(this); + } +} + +export const sqlFloatDescriptor = new SqlFloatDescriptor(); + +export const sqlFloatColumn = () => + column(sqlFloatDescriptor.factory(), sqlFloatDescriptor.codecId, undefined, 'float'); + +sqlFloatColumn satisfies ColumnHelperFor; +sqlFloatColumn satisfies ColumnHelperForStrict; + +export class SqlCharCodec extends CodecImpl< typeof SQL_CHAR_CODEC_ID, readonly ['equality', 'order', 'textual'], string, string ->({ - typeId: SQL_CHAR_CODEC_ID, - targetTypes: ['char'], - traits: ['equality', 'order', 'textual'], - encode: (value: string): string => value, - decode: (wire: string): string => wire.trimEnd(), - paramsSchema: lengthParamsSchema, - init: createLengthTypeHelper('fixed'), - renderOutputType: (typeParams) => { - const length = typeParams['length']; - if (length === undefined) return undefined; - if (typeof length !== 'number' || !Number.isFinite(length) || !Number.isInteger(length)) { - throw new Error( - `renderOutputType: expected integer "length" in typeParams for Char, got ${String(length)}`, - ); - } - return `Char<${length}>`; - }, -}); - -const sqlVarcharCodec = codec< +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return sqlCharEncode(value); + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return sqlCharDecode(wire); + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} + +export class SqlCharDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQL_CHAR_CODEC_ID; + override readonly traits = ['equality', 'order', 'textual'] as const; + override readonly targetTypes = ['char'] as const; + override readonly paramsSchema: StandardSchemaV1 = lengthParamsSchema; + override renderOutputType(params: LengthParams): string | undefined { + return sqlCharRenderOutputType(params); + } + override factory(_params: LengthParams): (ctx: CodecInstanceContext) => SqlCharCodec { + return () => new SqlCharCodec(this); + } +} + +export const sqlCharDescriptor = new SqlCharDescriptor(); + +export const sqlCharColumn = (params: LengthParams = {}) => + column(sqlCharDescriptor.factory(params), sqlCharDescriptor.codecId, params, 'char'); + +sqlCharColumn satisfies ColumnHelperFor; +sqlCharColumn satisfies ColumnHelperForStrict; + +export class SqlVarcharCodec extends CodecImpl< typeof SQL_VARCHAR_CODEC_ID, readonly ['equality', 'order', 'textual'], string, string ->({ - typeId: SQL_VARCHAR_CODEC_ID, - targetTypes: ['varchar'], - traits: ['equality', 'order', 'textual'], - encode: (value: string): string => value, - decode: (wire: string): string => wire, - paramsSchema: lengthParamsSchema, - init: createLengthTypeHelper('variable'), - renderOutputType: (typeParams) => { - const length = typeParams['length']; - if (length === undefined) return undefined; - if (typeof length !== 'number' || !Number.isFinite(length) || !Number.isInteger(length)) { - throw new Error( - `renderOutputType: expected integer "length" in typeParams for Varchar, got ${String(length)}`, - ); - } - return `Varchar<${length}>`; - }, -}); - -const sqlIntCodec = codec({ - typeId: SQL_INT_CODEC_ID, - targetTypes: ['int'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number): number => value, - decode: (wire: number): number => wire, -}); - -const sqlFloatCodec = codec({ - typeId: SQL_FLOAT_CODEC_ID, - targetTypes: ['float'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number): number => value, - decode: (wire: number): number => wire, -}); - -const sqlTextCodec = codec({ - typeId: SQL_TEXT_CODEC_ID, - targetTypes: ['text'], - traits: ['equality', 'order', 'textual'], - encode: (value: string): string => value, - decode: (wire: string): string => wire, -}); - -const sqlTimestampCodec = codec< +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return sqlVarcharEncode(value); + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return sqlVarcharDecode(wire); + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} + +export class SqlVarcharDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQL_VARCHAR_CODEC_ID; + override readonly traits = ['equality', 'order', 'textual'] as const; + override readonly targetTypes = ['varchar'] as const; + override readonly paramsSchema: StandardSchemaV1 = lengthParamsSchema; + override renderOutputType(params: LengthParams): string | undefined { + return sqlVarcharRenderOutputType(params); + } + override factory(_params: LengthParams): (ctx: CodecInstanceContext) => SqlVarcharCodec { + return () => new SqlVarcharCodec(this); + } +} + +export const sqlVarcharDescriptor = new SqlVarcharDescriptor(); + +export const sqlVarcharColumn = (params: LengthParams = {}) => + column(sqlVarcharDescriptor.factory(params), sqlVarcharDescriptor.codecId, params, 'varchar'); + +sqlVarcharColumn satisfies ColumnHelperFor; +sqlVarcharColumn satisfies ColumnHelperForStrict; + +export class SqlTimestampCodec extends CodecImpl< typeof SQL_TIMESTAMP_CODEC_ID, readonly ['equality', 'order'], Date, Date ->({ - typeId: SQL_TIMESTAMP_CODEC_ID, - targetTypes: ['timestamp'], - traits: ['equality', 'order'], - encode: (value: Date): Date => value, - decode: (wire: Date): Date => wire, - encodeJson: (value: Date): JsonValue => value.toISOString(), - decodeJson: (json: JsonValue): Date => { - if (typeof json !== 'string') { - throw new Error(`Expected ISO date string for sql/timestamp@1, got ${typeof json}`); - } - const date = new Date(json); - if (Number.isNaN(date.getTime())) { - throw new Error(`Invalid ISO date string for sql/timestamp@1: ${json}`); - } - return date; - }, - paramsSchema: precisionParamsSchema, - renderOutputType: (typeParams) => { - const precision = typeParams['precision']; - if (precision === undefined) { - return 'Timestamp'; - } - if ( - typeof precision !== 'number' || - !Number.isFinite(precision) || - !Number.isInteger(precision) - ) { - throw new Error( - `renderOutputType: expected integer "precision" in typeParams for Timestamp, got ${String(precision)}`, - ); - } - return `Timestamp<${precision}>`; - }, -}); - -const codecs = defineCodecs() - .add('char', sqlCharCodec) - .add('varchar', sqlVarcharCodec) - .add('int', sqlIntCodec) - .add('float', sqlFloatCodec) - .add('text', sqlTextCodec) - .add('timestamp', sqlTimestampCodec); - -export const sqlCodecDefinitions = codecs.codecDefinitions; -export const sqlDataTypes = codecs.dataTypes; -export type SqlCodecTypes = typeof codecs.CodecTypes; +> { + async encode(value: Date, _ctx: CodecCallContext): Promise { + return sqlTimestampEncode(value); + } + async decode(wire: Date, _ctx: CodecCallContext): Promise { + return sqlTimestampDecode(wire); + } + encodeJson(value: Date): JsonValue { + return sqlTimestampEncodeJson(value); + } + decodeJson(json: JsonValue): Date { + return sqlTimestampDecodeJson(json); + } +} + +export class SqlTimestampDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQL_TIMESTAMP_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['timestamp'] as const; + override readonly paramsSchema: StandardSchemaV1 = precisionParamsSchema; + override renderOutputType(params: PrecisionParams): string | undefined { + return sqlTimestampRenderOutputType(params); + } + override factory(_params: PrecisionParams): (ctx: CodecInstanceContext) => SqlTimestampCodec { + return () => new SqlTimestampCodec(this); + } +} + +export const sqlTimestampDescriptor = new SqlTimestampDescriptor(); + +export const sqlTimestampColumn = (params: PrecisionParams = {}) => + column( + sqlTimestampDescriptor.factory(params), + sqlTimestampDescriptor.codecId, + params, + 'timestamp', + ); + +sqlTimestampColumn satisfies ColumnHelperFor; +sqlTimestampColumn satisfies ColumnHelperForStrict; diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/types.ts index 08ca9c127e..d0c134c3d3 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/types.ts @@ -156,7 +156,7 @@ function rewriteProjectionItem(item: ProjectionItem, rewriter: AstRewriter): Pro ? rewriter.literal(item.expr) : item.expr : item.expr.rewrite(rewriter); - return new ProjectionItem(item.alias, rewrittenExpr, item.codecId); + return new ProjectionItem(item.alias, rewrittenExpr, item.codecId, item.refs); } function rewriteInsertValue(value: InsertValue, rewriter: AstRewriter): InsertValue { @@ -320,9 +320,7 @@ export class DerivedTableSource extends FromSource { return new DerivedTableSource(alias, query); } - // Intentionally does not call rewriter.tableSource — derived tables are rewritten - // via their inner query, not intercepted at the FromSource level. A future - // fromSource?(source: AnyFromSource) callback would be needed for that. + // Intentionally does not call rewriter.tableSource — derived tables are rewritten via their inner query, not intercepted at the FromSource level. A future fromSource?(source: AnyFromSource) callback would be needed for that. override rewrite(rewriter: AstRewriter): AnyFromSource { return new DerivedTableSource(this.alias, this.query.rewrite(rewriter)); } @@ -392,23 +390,37 @@ export class IdentifierRef extends Expression { } } +/** + * Column ref carried by a {@link ParamRef} that was constructed at a column-bound site (e.g. an INSERT/UPDATE column assignment, a `WHERE column = $param` comparison). The encode-side dispatch path uses `refs` to call `contractCodecs.forColumn(refs.table, refs.column)`, which selects the per-instance parameterized codec for the column — required when a parameterized codec id is shared by multiple columns with distinct + * typeParams (e.g. `vector(1024)` vs. `vector(1536)`). + * + * `refs` may legitimately be `undefined` for `ParamRef`s constructed without a column context — the validator pass (`validateParamRefRefs`) treats refs-less ParamRefs as a hard error only when their codec id is parameterized. + */ +export interface ParamRefBindingRefs { + readonly table: string; + readonly column: string; +} + export class ParamRef extends Expression { readonly kind = 'param-ref' as const; readonly value: unknown; readonly name: string | undefined; readonly codecId: string | undefined; + readonly refs: ParamRefBindingRefs | undefined; constructor( value: unknown, options?: { name?: string; codecId?: string; + refs?: ParamRefBindingRefs; }, ) { super(); this.value = value; this.name = options?.name; this.codecId = options?.codecId; + this.refs = options?.refs ? Object.freeze({ ...options.refs }) : undefined; this.freeze(); } @@ -417,6 +429,7 @@ export class ParamRef extends Expression { options?: { name?: string; codecId?: string; + refs?: ParamRefBindingRefs; }, ): ParamRef { return new ParamRef(value, options); @@ -1069,21 +1082,32 @@ export class ProjectionItem extends AstNode { readonly alias: string; readonly expr: ProjectionExpr; readonly codecId: string | undefined; + /** + * Column-bound metadata for the projection. Populated by builder paths that promote a top-level field shortcut (`select('email', ...)`) into a bare `IdentifierRef` AST while still knowing the originating `(table, column)`. Decode-side dispatch consults `refs` to call `forColumn(table, column)` so parameterized codec ids resolve to the per-instance codec — required when multiple columns share a codec id with distinct + * typeParams (e.g. `varchar(36)` vs. `varchar(255)`). Stays `undefined` for `column-ref` projections (the AST already carries the binding) and for non-column-bound projections (computed expressions, subqueries, raw aliases). + */ + readonly refs: ParamRefBindingRefs | undefined; - constructor(alias: string, expr: ProjectionExpr, codecId?: string) { + constructor(alias: string, expr: ProjectionExpr, codecId?: string, refs?: ParamRefBindingRefs) { super(); this.alias = alias; this.expr = expr; this.codecId = codecId; + this.refs = refs ? Object.freeze({ ...refs }) : undefined; this.freeze(); } - static of(alias: string, expr: ProjectionExpr, codecId?: string): ProjectionItem { - return new ProjectionItem(alias, expr, codecId); + static of( + alias: string, + expr: ProjectionExpr, + codecId?: string, + refs?: ParamRefBindingRefs, + ): ProjectionItem { + return new ProjectionItem(alias, expr, codecId, refs); } withCodecId(codecId: string | undefined): ProjectionItem { - return new ProjectionItem(this.alias, this.expr, codecId); + return new ProjectionItem(this.alias, this.expr, codecId, this.refs); } } @@ -1241,6 +1265,7 @@ export class SelectAst extends QueryAst { : projection.expr : projection.expr.rewrite(rewriter), projection.codecId, + projection.refs, ), ), where: this.where?.rewrite(rewriter), diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/validate-param-refs.ts b/packages/2-sql/4-lanes/relational-core/src/ast/validate-param-refs.ts new file mode 100644 index 0000000000..661ca7dd0d --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/src/ast/validate-param-refs.ts @@ -0,0 +1,39 @@ +/** + * Builder-pipeline validator pass: every {@link ParamRef} whose `codecId` resolves to a *parameterized* descriptor must carry `refs: { table, column }` so encode-side dispatch can call `contractCodecs.forColumn(table, column)`. Refs-less parameterized `ParamRef`s are a hard error — the codec-id-keyed `forCodecId` fallback cannot disambiguate per-instance codecs (e.g. `vector(1024)` vs. `vector(1536)`), so the dispatch + * path must reject them at validation time rather than silently bind to the wrong instance. + * + * Non-parameterized codec ids (the `voidParamsSchema` case) are always dispatch-safe via codec id alone, so refs-less ParamRefs for those ids are accepted unchanged. + * + * The pass runs post-build / pre-execute — the natural location is the SQL runtime's `lower()` step, between any `beforeCompile` rewrites and `encodeParams`. See AC-5 in the codec-registry-unification spec. + */ + +import { runtimeError } from '@prisma-next/framework-components/runtime'; +import type { CodecDescriptorRegistry } from '../query-lane-context'; +import type { AnyQueryAst, ParamRef } from './types'; +import { collectOrderedParamRefs } from './util'; + +/** + * Validate that every parameterized-codec `ParamRef` in `plan` carries `refs`. Throws `RUNTIME.PARAM_REF_REFS_REQUIRED` (a runtime envelope) naming the codec id and the binding label when the invariant is violated. Returns the plan unchanged on success — callers that prefer a side-effecting assertion can ignore the return value. + * + * The `registry` is consulted via `descriptorFor(codecId).isParameterized` — `true` whenever the descriptor's `paramsSchema` is not the singleton `voidParamsSchema`. + */ +export function validateParamRefRefs(plan: AnyQueryAst, registry: CodecDescriptorRegistry): void { + for (const ref of collectOrderedParamRefs(plan)) { + diagnoseRef(ref, registry); + } +} + +function diagnoseRef(ref: ParamRef, registry: CodecDescriptorRegistry): void { + if (!ref.codecId) return; + const descriptor = registry.descriptorFor(ref.codecId); + if (descriptor === undefined) return; + if (!descriptor.isParameterized) return; + if (ref.refs) return; + + const label = ref.name ?? ''; + throw runtimeError( + 'RUNTIME.PARAM_REF_REFS_REQUIRED', + `ParamRef '${label}' for parameterized codec '${ref.codecId}' is missing column refs; column-aware dispatch requires { table, column } at the binding site.`, + { codecId: ref.codecId, paramName: ref.name }, + ); +} diff --git a/packages/2-sql/4-lanes/relational-core/src/codec-descriptor-registry.ts b/packages/2-sql/4-lanes/relational-core/src/codec-descriptor-registry.ts new file mode 100644 index 0000000000..a709df7053 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/src/codec-descriptor-registry.ts @@ -0,0 +1,52 @@ +import type { CodecDescriptor } from '@prisma-next/framework-components/codec'; +import type { AnyCodecDescriptor } from './ast/codec-types'; +import type { CodecDescriptorRegistry } from './query-lane-context'; + +/** + * Build a {@link CodecDescriptorRegistry} from a flat descriptor list. + * + * Used by: + * - Each codec-shipping package's `core/registry.ts` to expose a package-scoped registry as the public consumer surface (replacing raw descriptor-array exports). See ADR 208. + * - The runtime's `buildExecutionContext` to construct the contract-bound combined registry from every contributor's `codecs:` slot. + * + * The descriptor map is heterogeneous in `P` — each codec id has its own params shape. The public {@link CodecDescriptorRegistry} interface widens to `CodecDescriptor` and consumers narrow per codec id at the call site (the descriptor's `paramsSchema` validates JSON-sourced params before the factory ever sees them, so the runtime narrow is safe). The cast at registration goes through `unknown` because + * `CodecDescriptor

` is invariant in `P` (the `factory` and `renderOutputType` slots use `P` contravariantly). + */ +export function buildCodecDescriptorRegistry( + allDescriptors: ReadonlyArray, +): CodecDescriptorRegistry { + type AnyDescriptor = CodecDescriptor; + const byId = new Map(); + const byTargetType = new Map>(); + + for (const descriptor of allDescriptors) { + if (byId.has(descriptor.codecId)) { + throw new Error( + `Duplicate codec descriptor id: '${descriptor.codecId}' — registered twice during registry construction. ` + + 'Each codecId must be contributed by exactly one component (target / adapter / extension pack).', + ); + } + const widened = descriptor as unknown as AnyDescriptor; + byId.set(descriptor.codecId, widened); + for (const targetType of descriptor.targetTypes) { + const list = byTargetType.get(targetType); + if (list) { + list.push(widened); + } else { + byTargetType.set(targetType, [widened]); + } + } + } + + return { + descriptorFor(codecId: string): AnyDescriptor | undefined { + return byId.get(codecId); + }, + *values(): IterableIterator { + yield* byId.values(); + }, + byTargetType(targetType: string): readonly AnyDescriptor[] { + return byTargetType.get(targetType) ?? Object.freeze([]); + }, + }; +} diff --git a/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts b/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts index 6e42f1bfcc..0905c4be02 100644 --- a/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts +++ b/packages/2-sql/4-lanes/relational-core/src/exports/ast.ts @@ -1,6 +1,8 @@ export * from '../ast/adapter-types'; export * from '../ast/codec-types'; export * from '../ast/driver-types'; +export * from '../ast/sql-codec-helpers'; export * from '../ast/sql-codecs'; export * from '../ast/types'; export * from '../ast/util'; +export * from '../ast/validate-param-refs'; diff --git a/packages/2-sql/4-lanes/relational-core/src/exports/codec-descriptor-registry.ts b/packages/2-sql/4-lanes/relational-core/src/exports/codec-descriptor-registry.ts new file mode 100644 index 0000000000..d170e18341 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/src/exports/codec-descriptor-registry.ts @@ -0,0 +1 @@ +export * from '../codec-descriptor-registry'; diff --git a/packages/2-sql/4-lanes/relational-core/src/expression.ts b/packages/2-sql/4-lanes/relational-core/src/expression.ts index afd0a96430..eaf52c6e01 100644 --- a/packages/2-sql/4-lanes/relational-core/src/expression.ts +++ b/packages/2-sql/4-lanes/relational-core/src/expression.ts @@ -7,10 +7,7 @@ import { OperationExpr, ParamRef } from './ast/types'; export type ScopeField = { codecId: string; nullable: boolean }; /** - * A typed SQL expression. Identity is carried by the `returnType` descriptor - * (inherited from `QueryOperationReturn` and narrowed to `T`) — distinct `T` - * makes distinct Expression types structurally. `buildAst()` materialises the - * underlying AST node. + * A typed SQL expression. Identity is carried by the `returnType` descriptor (inherited from `QueryOperationReturn` and narrowed to `T`) — distinct `T` makes distinct Expression types structurally. `buildAst()` materialises the underlying AST node. */ export type Expression = QueryOperationReturn & { readonly returnType: T; @@ -34,9 +31,9 @@ type NullSuffix = N extends true ? null : never; * An expression or literal value targeting a specific codec. * * Accepts any of: - * - An `Expression` whose codec matches exactly - * - A raw JS value of the codec's `input` type - * - `null` when `Nullable` is true + * - An `Expression` whose codec matches exactly + * - A raw JS value of the codec's `input` type + * - `null` when `Nullable` is true */ export type CodecExpression< CodecId extends string, @@ -48,11 +45,9 @@ export type CodecExpression< | NullSuffix; /** - * An expression or literal value targeting any codec whose trait set contains - * all the required traits. + * An expression or literal value targeting any codec whose trait set contains all the required traits. * - * Resolves the trait set to the union of matching codec identities via - * `CodecIdsWithTrait`, then reuses `CodecExpression` for the codec-id form. + * Resolves the trait set to the union of matching codec identities via `CodecIdsWithTrait`, then reuses `CodecExpression` for the codec-id form. */ export type TraitExpression< Traits extends readonly string[], @@ -63,16 +58,42 @@ export type TraitExpression< /** * Resolve a raw value or an Expression into an AST expression node. * - * When `value` is an Expression (duck-typed by its `buildAst` method), the AST - * it wraps is returned. Otherwise the value is embedded as a ParamRef tagged - * with `codecId` (if given). Pass `codecId` to encode the literal with a - * specific codec — most operations do. + * When `value` is an Expression (duck-typed by its `buildAst` method), the AST it wraps is returned. Otherwise the value is embedded as a ParamRef tagged with `codecId` (if given) and optionally `refs: { table, column }` (if the caller knows the column-bound site). + * + * For parameterized codec ids (e.g. `pg/vector@1`), encode-side dispatch requires `refs` to select the per-instance codec — so operation implementations that compare a column to a user-supplied value should derive `refs` from the column-bound side and pass it down. Non-parameterized codec ids (e.g. `pg/int4@1`) tolerate refs-less ParamRefs; the validator pass enforces refs only for parameterized ids. */ -export function toExpr(value: unknown, codecId?: string): AstExpression { +export function toExpr( + value: unknown, + codecId?: string, + refs?: { table: string; column: string }, +): AstExpression { if (isExpressionLike(value)) { return value.buildAst(); } - return ParamRef.of(value, codecId ? { codecId } : undefined); + if (codecId === undefined && refs === undefined) return ParamRef.of(value); + return ParamRef.of(value, { + ...(codecId !== undefined ? { codecId } : {}), + ...(refs !== undefined ? { refs } : {}), + }); +} + +/** + * Derive `(table, column)` refs from an expression-like value when it carries column-bound metadata. Returns `undefined` for non-column-bound expressions and for raw scalar values. + * + * Two sources are consulted, in order: 1. An optional `refs` slot on the `Expression` wrapper (the SQL builder's `ExpressionImpl` records `(table, column)` for top-level fields whose AST is `IdentifierRef` — the AST stays bare to preserve SQL rendering, the metadata lives on the wrapper). 2. The wrapped AST when it's already a `ColumnRef` (the namespaced field-proxy form, or operation impls passing column-bound exprs + * directly). + * + * Operation implementations call this on the column-bound side of a comparison and forward the refs to {@link toExpr} on the user-value side, so the resulting `ParamRef` carries the table+column required by encode-side `forColumn` dispatch. + */ +export function refsOf(value: unknown): { table: string; column: string } | undefined { + if (!isExpressionLike(value)) return undefined; + const wrapperRefs = (value as { refs?: { table: string; column: string } }).refs; + if (wrapperRefs) return { table: wrapperRefs.table, column: wrapperRefs.column }; + const ast = value.buildAst(); + if (ast.kind === 'column-ref') { + return { table: ast.table, column: ast.column }; + } + return undefined; } function isExpressionLike(value: unknown): value is Expression { @@ -87,9 +108,7 @@ function isExpressionLike(value: unknown): value is Expression { export interface BuildOperationSpec { readonly method: string; /** - * The operation's arguments. The first element is the self argument (the - * value the operation is being applied to); the rest are the remaining - * user-supplied arguments. + * The operation's arguments. The first element is the self argument (the value the operation is being applied to); the rest are the remaining user-supplied arguments. */ readonly args: readonly [AstExpression, ...AstExpression[]]; readonly returns: R & ParamSpec; @@ -97,9 +116,7 @@ export interface BuildOperationSpec { } /** - * Construct an OperationExpr AST node and wrap it as a typed Expression. - * Operation implementations use this to turn their user-facing arguments into - * the AST node the compilation pipeline eventually lowers to SQL. + * Construct an OperationExpr AST node and wrap it as a typed Expression. Operation implementations use this to turn their user-facing arguments into the AST node the compilation pipeline eventually lowers to SQL. */ export function buildOperation(spec: BuildOperationSpec): Expression { const [self, ...rest] = spec.args; diff --git a/packages/2-sql/4-lanes/relational-core/src/query-lane-context.ts b/packages/2-sql/4-lanes/relational-core/src/query-lane-context.ts index 6974a5ceb3..de7a013d67 100644 --- a/packages/2-sql/4-lanes/relational-core/src/query-lane-context.ts +++ b/packages/2-sql/4-lanes/relational-core/src/query-lane-context.ts @@ -2,83 +2,31 @@ import type { Contract } from '@prisma-next/contract/types'; import type { CodecDescriptor } from '@prisma-next/framework-components/codec'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { SqlOperationRegistry } from '@prisma-next/sql-operations'; -import type { CodecRegistry, ContractCodecRegistry } from './ast/codec-types'; +import type { ContractCodecRegistry } from './ast/codec-types'; /** - * Codec-id-keyed accessor for descriptor metadata. The unified read API - * for codec-id-keyed metadata (`traits`, `targetTypes`, `meta`) — non- - * branching for parameterized vs. non-parameterized codecs since every - * codec ships as (or is synthesized into) a `CodecDescriptor`. - * - * See codec-registry-unification spec § Decision and AC-3. + * Codec-id-keyed accessor for descriptor metadata. The unified read API for codec-id-keyed metadata (`traits`, `targetTypes`, `meta`) — non-branching for parameterized vs. non-parameterized codecs. Every codec ships natively as a `CodecDescriptor` through the unified `codecs:` contributor slot (see ADR 208). */ export interface CodecDescriptorRegistry { /** - * Descriptors carry distinct param shapes per codec id; the registry is - * heterogeneous and the consumer narrows per codec. + * Descriptors carry distinct param shapes per codec id; the registry is heterogeneous and the consumer narrows per codec. */ descriptorFor(codecId: string): CodecDescriptor | undefined; /** - * All registered descriptors. Used by `validateCodecRegistryCompleteness` - * and other startup-time consumers that enumerate descriptors. + * All registered descriptors. Used by `validateCodecRegistryCompleteness` and other startup-time consumers that enumerate descriptors. */ values(): IterableIterator>; /** - * Descriptors indexed by `targetTypes[i]` (each scalar type the codec - * advertises). Multiple descriptors may map to the same scalar type; - * ordering reflects registration order. + * Descriptors indexed by `targetTypes[i]` (each scalar type the codec advertises). Multiple descriptors may map to the same scalar type; ordering reflects registration order. */ byTargetType(targetType: string): readonly CodecDescriptor[]; } /** - * Registry of initialized type helpers from storage.types. - * Each key is a type name from storage.types, and the value is: - * - The result of the codec's init hook (if provided), or - * - The full StorageTypeInstance metadata (codecId, nativeType, typeParams) if no init hook + * Registry of initialized type helpers from storage.types. Each key is a type name from storage.types, and the value is the resolved codec materialized once for that named instance via `descriptor.factory(typeParams)(ctx)` (or the raw `StorageTypeInstance` metadata for codec ids whose descriptor isn't registered). */ export type TypeHelperRegistry = Record; -// ============================================================================= -// JSON Schema Validation Types -// ============================================================================= - -/** - * A single validation error from JSON Schema validation. - */ -export interface JsonSchemaValidationError { - readonly path: string; - readonly message: string; - readonly keyword: string; -} - -/** - * Result of a JSON Schema validation. - */ -export type JsonSchemaValidationResult = - | { readonly valid: true } - | { readonly valid: false; readonly errors: ReadonlyArray }; - -/** - * A compiled JSON Schema validate function. - * Returns a structured result indicating whether the value conforms to the schema. - */ -export type JsonSchemaValidateFn = (value: unknown) => JsonSchemaValidationResult; - -/** - * Registry of compiled JSON Schema validators for columns with typed JSON/JSONB. - * - * Built during context creation by scanning the contract for columns whose codec - * descriptor provides an `init` hook that returns a `{ validate }` helper. - * Keys are `"table.column"` (e.g., `"user.metadata"`). - */ -export interface JsonSchemaValidatorRegistry { - /** Get the compiled validator for a column. Key format: "table.column". */ - get(key: string): JsonSchemaValidateFn | undefined; - /** Number of registered validators. */ - readonly size: number; -} - export type MutationDefaultsOp = 'create' | 'update'; export type AppliedMutationDefault = { @@ -91,14 +39,8 @@ export type MutationDefaultsOptions = { readonly table: string; readonly values: Record; /** - * Per-ORM-operation cache for generators that declare - * `stability: 'query'`. The caller passes the same `Map` across every - * `applyMutationDefaults` invocation in one bulk operation; the - * framework keys by `generatorId` so the same value is reused across - * all rows and columns. Generators with `stability: 'row'` use a - * fresh per-call cache the framework manages internally; generators - * with `stability: 'field'` skip caching entirely. Omit to make every - * call independent (degrades `'query'` to per-call behavior). + * Per-ORM-operation cache for generators that declare `stability: 'query'`. The caller passes the same `Map` across every `applyMutationDefaults` invocation in one bulk operation; the framework keys by `generatorId` so the same value is reused across all rows and columns. Generators with `stability: 'row'` use a fresh per-call cache the framework manages internally; generators with `stability: 'field'` skip caching + * entirely. Omit to make every call independent (degrades `'query'` to per-call behavior). */ readonly defaultValueCache?: Map; }; @@ -106,50 +48,26 @@ export type MutationDefaultsOptions = { /** * Minimal context interface for SQL query lanes. * - * Lanes only need contract, operations, and codecs to build typed ASTs and attach - * operation builders. This interface explicitly excludes runtime concerns like - * adapters, connection management, and transaction state. + * Lanes only need contract, operations, and codecs to build typed ASTs and attach operation builders. This interface explicitly excludes runtime concerns like adapters, connection management, and transaction state. */ export interface ExecutionContext = Contract> { readonly contract: TContract; /** - * Codec registry indexed by codec id. Source of shared, non-parameterized - * codec instances; also used as the codec-id-only fallback at the - * `forCodecId` boundary while AC-5's `ParamRef.refs` plumbing remains - * deferred (TML-2357). - */ - readonly codecs: CodecRegistry; - /** - * Contract-bound codec registry built once at context-construction time - * by walking the contract's columns and resolving each to its per- - * instance codec (parameterized columns) or the shared codec from the - * legacy registry (non-parameterized columns). The dispatch path - * (`encodeParam` / `decodeRow`) consults `forColumn(table, column)` - * when the call site has the ref, falling back to `forCodecId(codecId)` - * otherwise. Codec-registry-unification spec § AC-4. + * Contract-bound codec registry built once at context-construction time by walking the contract's columns and resolving each through its descriptor's factory. The dispatch path (`encodeParam` / `decodeRow`) consults `forColumn(table, column)` for column-bound call sites; `forCodecId(codecId)` is the refs-less fallback, permitted only for non-parameterized codec ids (the builder-pipeline validator pass enforces refs on + * every parameterized `ParamRef`). Pre-populated with one canonical instance per non-parameterized descriptor so `forCodecId` covers refs-less codec ids that no contract column declares. */ readonly contractCodecs: ContractCodecRegistry; /** - * Codec-id-keyed descriptor map. Single source of truth for codec-id- - * keyed metadata (`traits`, `targetTypes`, `meta`) — every codec, - * parameterized or not, resolves through this map without branching. - * Codec-registry-unification spec § AC-3. + * Codec-id-keyed descriptor map. Single source of truth for codec-id-keyed metadata (`traits`, `targetTypes`, `meta`) — every codec, parameterized or not, resolves through this map without branching. */ readonly codecDescriptors: CodecDescriptorRegistry; readonly queryOperations: SqlOperationRegistry; /** - * Type helper registry for parameterized types. - * Schema builders expose these helpers via schema.types. + * Type helper registry for parameterized types. Schema builders expose these helpers via schema.types. */ readonly types: TypeHelperRegistry; /** - * Compiled JSON Schema validators for typed JSON/JSONB columns. - * Present only when the contract declares columns with JSON Schema typeParams. - */ - readonly jsonSchemaValidators?: JsonSchemaValidatorRegistry; - /** - * Applies execution-time mutation defaults for the given table. - * Returns the applied defaults (caller-provided values always win). + * Applies execution-time mutation defaults for the given table. Returns the applied defaults (caller-provided values always win). */ applyMutationDefaults(options: MutationDefaultsOptions): ReadonlyArray; } diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory-ctx.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory-ctx.test.ts index 6ef4ac6172..eff7a87a9c 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory-ctx.test.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory-ctx.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { SqlCodecCallContext } from '../../src/ast/codec-types'; -import { codec } from '../../src/ast/codec-types'; +import { defineTestCodec } from './test-codec'; -describe('codec() factory — SqlCodecCallContext arity', () => { +describe('defineTestCodec() factory — SqlCodecCallContext arity', () => { it('lifts a single-arg `(value)` author unchanged (back-compat)', async () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/single-arg-encode@1', - targetTypes: ['text'], encode: (value: string) => value.toUpperCase(), decode: (wire: string) => wire, }); @@ -15,9 +14,8 @@ describe('codec() factory — SqlCodecCallContext arity', () => { it('forwards ctx (signal + column) to a `(value, ctx)` encode author', async () => { let observed: SqlCodecCallContext | undefined; - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/ctx-encode@1', - targetTypes: ['text'], encode: (value: string, ctx?: SqlCodecCallContext) => { observed = ctx; return value; @@ -37,9 +35,8 @@ describe('codec() factory — SqlCodecCallContext arity', () => { it('forwards ctx (signal + column) to a `(value, ctx)` decode author', async () => { let observed: SqlCodecCallContext | undefined; - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/ctx-decode@1', - targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string, ctx?: SqlCodecCallContext) => { observed = ctx; @@ -59,9 +56,8 @@ describe('codec() factory — SqlCodecCallContext arity', () => { it('preserves AbortSignal identity through the lifted method', async () => { let observedSignal: AbortSignal | undefined; - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/identity@1', - targetTypes: ['text'], encode: (value: string, ctx?: SqlCodecCallContext) => { observedSignal = ctx?.signal; return value; @@ -75,9 +71,8 @@ describe('codec() factory — SqlCodecCallContext arity', () => { it('forwards an empty ctx (no signal, no column) as-is to a ctx-bearing author', async () => { let observed: unknown = 'sentinel'; - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/empty-ctx@1', - targetTypes: ['text'], encode: (value: string, ctx?: SqlCodecCallContext) => { observed = ctx; return value; @@ -90,9 +85,8 @@ describe('codec() factory — SqlCodecCallContext arity', () => { }); it('async ctx-bearing encode resolves with the produced value', async () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/async-ctx@1', - targetTypes: ['text'], encode: async (value: string, _ctx?: SqlCodecCallContext) => `enc:${value}`, decode: (wire: string) => wire, }); @@ -101,9 +95,8 @@ describe('codec() factory — SqlCodecCallContext arity', () => { it('a column-aware decode author observes ctx.column shape `{ table, name }`', async () => { let observedColumn: SqlCodecCallContext['column']; - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/column-aware@1', - targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string, ctx?: SqlCodecCallContext) => { observedColumn = ctx?.column; diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory-ctx.types.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory-ctx.types.test-d.ts index 2296d69335..562ae643d6 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory-ctx.types.test-d.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory-ctx.types.test-d.ts @@ -1,7 +1,7 @@ import type { CodecCallContext } from '@prisma-next/framework-components/codec'; import { expectTypeOf, test } from 'vitest'; import type { Codec, SqlCodecCallContext, SqlColumnRef } from '../../src/ast/codec-types'; -import { codec } from '../../src/ast/codec-types'; +import { defineTestCodec } from './test-codec'; test('SqlColumnRef shape is `{ table, name }`', () => { expectTypeOf().toEqualTypeOf<{ @@ -30,9 +30,8 @@ test('SQL Codec.encode/decode narrow ctx to SqlCodecCallContext (non-optional at }); test('factory accepts a `(value, ctx: SqlCodecCallContext)` encode author', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/ctx-encode@1', - targetTypes: ['text'], encode: (value: string, _ctx?: SqlCodecCallContext) => value, decode: (wire: string) => wire, }); @@ -41,9 +40,8 @@ test('factory accepts a `(value, ctx: SqlCodecCallContext)` encode author', () = }); test('factory accepts a `(value, ctx: SqlCodecCallContext)` decode author', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/ctx-decode@1', - targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string, _ctx?: SqlCodecCallContext) => wire, }); @@ -52,9 +50,8 @@ test('factory accepts a `(value, ctx: SqlCodecCallContext)` decode author', () = }); test('factory accepts a single-arg `(value)` encode author and exposes a Promise method', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/single-encode@1', - targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string) => wire, }); @@ -62,9 +59,8 @@ test('factory accepts a single-arg `(value)` encode author and exposes a Promise }); test('factory lifts an async ctx-bearing encode into a Promise method', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/async-ctx-encode@1', - targetTypes: ['text'], encode: async (value: string, _ctx?: SqlCodecCallContext) => value, decode: (wire: string) => wire, }); @@ -72,9 +68,8 @@ test('factory lifts an async ctx-bearing encode into a Promise method', () => { }); test('Codec.encode and Codec.decode require a ctx argument', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/require-ctx@1', - targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string) => wire, }); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory.test.ts index 8b985aa418..5e7881cac9 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory.test.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { codec } from '../../src/ast/codec-types'; +import { defineTestCodec } from './test-codec'; -describe('codec() factory — query-time methods are Promise-returning', () => { +describe('defineTestCodec — query-time methods are Promise-returning', () => { it('lifts a sync encode into a Promise-returning method', async () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/sync-encode@1', - targetTypes: ['text'], encode: (value: string) => value.toUpperCase(), decode: (wire: string) => wire, }); @@ -16,9 +15,8 @@ describe('codec() factory — query-time methods are Promise-returning', () => { }); it('lifts a sync decode into a Promise-returning method', async () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/sync-decode@1', - targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string) => wire.toLowerCase(), }); @@ -29,9 +27,8 @@ describe('codec() factory — query-time methods are Promise-returning', () => { }); it('accepts an async encode and produces a Promise-returning method', async () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/async-encode@1', - targetTypes: ['text'], encode: async (value: string) => value.toUpperCase(), decode: (wire: string) => wire, }); @@ -42,9 +39,8 @@ describe('codec() factory — query-time methods are Promise-returning', () => { }); it('accepts an async decode and produces a Promise-returning method', async () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/async-decode@1', - targetTypes: ['text'], encode: (value: string) => value, decode: async (wire: string) => wire.toLowerCase(), }); @@ -55,9 +51,8 @@ describe('codec() factory — query-time methods are Promise-returning', () => { }); it('accepts a mix of sync encode + async decode', async () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/mixed-a@1', - targetTypes: ['text'], encode: (value: string) => value, decode: async (wire: string) => wire.toUpperCase(), }); @@ -69,9 +64,8 @@ describe('codec() factory — query-time methods are Promise-returning', () => { }); it('accepts a mix of async encode + sync decode', async () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/mixed-b@1', - targetTypes: ['text'], encode: async (value: string) => value.toUpperCase(), decode: (wire: string) => wire, }); @@ -83,9 +77,8 @@ describe('codec() factory — query-time methods are Promise-returning', () => { }); it('passes encodeJson and decodeJson through as synchronous methods', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/json-passthrough@1', - targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string) => wire, encodeJson: (value: string) => value.toUpperCase(), @@ -100,29 +93,5 @@ describe('codec() factory — query-time methods are Promise-returning', () => { expect(decodedJson).not.toBeInstanceOf(Promise); }); - it('preserves renderOutputType as synchronous when provided', () => { - const c = codec({ - typeId: 'demo/render@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - renderOutputType: (params) => `Demo<${String(params['size'] ?? 'unknown')}>`, - }); - - expect(c.renderOutputType).toBeDefined(); - const rendered = c.renderOutputType!({ size: 8 }); - expect(rendered).toBe('Demo<8>'); - expect(rendered).not.toBeInstanceOf(Promise); - }); - - it('omits renderOutputType when not provided', () => { - const c = codec({ - typeId: 'demo/no-render@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - expect(c.renderOutputType).toBeUndefined(); - }); + // `renderOutputType` is a `CodecDescriptor`-side concern (TML-2357) — the legacy `defineTestCodec()` factory accepts the field for back-compat with existing call sites but the produced codec instance no longer carries it. The descriptor side is exercised by `sql-codecs.test.ts`. }); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory.types.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory.types.test-d.ts index ef2e2a5821..dc19ce2c45 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory.types.test-d.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/codec-factory.types.test-d.ts @@ -1,10 +1,9 @@ import { expectTypeOf, test } from 'vitest'; -import { codec } from '../../src/ast/codec-types'; +import { defineTestCodec } from './test-codec'; test('factory accepts sync encode and decode and produces Promise-returning methods', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/sync@1', - targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string) => wire, }); @@ -16,9 +15,8 @@ test('factory accepts sync encode and decode and produces Promise-returning meth }); test('factory accepts async encode and decode', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/async@1', - targetTypes: ['text'], encode: async (value: string) => value, decode: async (wire: string) => wire, }); @@ -28,9 +26,8 @@ test('factory accepts async encode and decode', () => { }); test('factory accepts mixed sync encode + async decode', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/mixed-a@1', - targetTypes: ['text'], encode: (value: string) => value, decode: async (wire: string) => wire, }); @@ -40,9 +37,8 @@ test('factory accepts mixed sync encode + async decode', () => { }); test('factory accepts mixed async encode + sync decode', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/mixed-b@1', - targetTypes: ['text'], encode: async (value: string) => value, decode: (wire: string) => wire, }); @@ -52,14 +48,17 @@ test('factory accepts mixed async encode + sync decode', () => { }); test('factory rejects an omitted encode — the property is required', () => { - // @ts-expect-error encode is required at the codec() factory call site; the factory installs no identity fallback. - codec({ typeId: 'demo/no-encode@1', targetTypes: ['text'], decode: (wire: string) => wire }); + // @ts-expect-error encode is required at the defineTestCodec() factory call site; the factory installs no identity fallback. + defineTestCodec({ + typeId: 'demo/no-encode@1', + targetTypes: ['text'], + decode: (wire: string) => wire, + }); }); test('factory passes encodeJson and decodeJson through as synchronous', () => { - const c = codec({ + const c = defineTestCodec({ typeId: 'demo/json@1', - targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string) => wire, encodeJson: (value: string) => value, diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/codec-types.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/codec-types.test.ts deleted file mode 100644 index 03ffb47492..0000000000 --- a/packages/2-sql/4-lanes/relational-core/test/ast/codec-types.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -import type { Type } from 'arktype'; -import { describe, expect, it } from 'vitest'; -import { codec, createCodecRegistry, defineCodecs } from '../../src/ast/codec-types'; - -describe('codec factory', () => { - it('creates codec with id, targetTypes, encode, and decode', async () => { - const testCodec = codec({ - typeId: 'test/type@1', - targetTypes: ['text'], - encode: (value: string) => value.toUpperCase(), - decode: (wire: string) => wire.toLowerCase(), - }); - - expect({ - id: testCodec.id, - targetTypes: testCodec.targetTypes, - hasEncode: testCodec.encode !== undefined, - hasDecode: testCodec.decode !== undefined, - encodeResult: await testCodec.encode('hello', {}), - decodeResult: await testCodec.decode('WORLD', {}), - }).toMatchObject({ - id: 'test/type@1', - targetTypes: ['text'], - hasEncode: true, - hasDecode: true, - encodeResult: 'HELLO', - decodeResult: 'world', - }); - }); - - it('creates codec with multiple target types', () => { - const testCodec = codec({ - typeId: 'test/multi@1', - targetTypes: ['int4', 'int8'], - encode: (value: number) => value.toString(), - decode: (wire: string) => Number.parseInt(wire, 10), - }); - - expect(testCodec.targetTypes).toEqual(['int4', 'int8']); - }); - - it('creates codec with meta property', () => { - const testCodec = codec({ - typeId: 'test/with-meta@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - meta: { db: { sql: { postgres: { nativeType: 'text' } } } }, - }); - - expect(testCodec.meta).toEqual({ db: { sql: { postgres: { nativeType: 'text' } } } }); - }); - - it('uses identity fallback for encodeJson/decodeJson when not provided', () => { - const testCodec = codec({ - typeId: 'test/identity@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - expect(testCodec.encodeJson('hello')).toBe('hello'); - expect(testCodec.decodeJson('world')).toBe('world'); - }); - - it.each([ - { - label: 'without meta', - config: {}, - check: (testCodec: unknown) => { - expect((testCodec as { readonly meta?: unknown }).meta).toBeUndefined(); - }, - }, - { - label: 'with paramsSchema', - config: { - paramsSchema: {} as unknown as Type<{ readonly precision: number }>, - }, - check: (testCodec: unknown) => { - expect((testCodec as { readonly paramsSchema?: unknown }).paramsSchema).toBeDefined(); - }, - }, - { - label: 'with init', - config: { - init: (params: { precision: number }) => ({ normalized: params.precision }), - }, - check: (testCodec: unknown) => { - const codecWithInit = testCodec as { - readonly init?: (params: { readonly precision: number }) => { - readonly normalized: number; - }; - }; - expect(codecWithInit.init).toBeDefined(); - expect(codecWithInit.init?.({ precision: 12 })).toEqual({ normalized: 12 }); - }, - }, - ])('creates codec $label', ({ config, check }) => { - const testCodec = codec({ - typeId: 'test/optional@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - ...config, - }); - - check(testCodec); - }); -}); - -describe('CodecRegistry', () => { - it('returns undefined for unregistered codec', () => { - const registry = createCodecRegistry(); - expect(registry.get('unknown/id@1')).toBeUndefined(); - }); - - it('returns false for unregistered codec has check', () => { - const registry = createCodecRegistry(); - expect(registry.has('unknown/id@1')).toBe(false); - }); - - it('registers and retrieves codec by id', () => { - const registry = createCodecRegistry(); - const testCodec = codec({ - typeId: 'test/type@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - registry.register(testCodec); - expect({ - has: registry.has('test/type@1'), - get: registry.get('test/type@1'), - }).toMatchObject({ - has: true, - get: testCodec, - }); - }); - - it('throws error when registering duplicate codec id', () => { - const registry = createCodecRegistry(); - const codec1 = codec({ - typeId: 'test/type@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const codec2 = codec({ - typeId: 'test/type@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - registry.register(codec1); - expect(() => { - registry.register(codec2); - }).toThrow("Codec with ID 'test/type@1' is already registered"); - }); - - it('returns empty array for unknown scalar type', () => { - const registry = createCodecRegistry(); - const result = registry.getByScalar('unknown'); - expect(result).toEqual([]); - expect(Object.isFrozen(result)).toBe(true); - }); - - it('returns codecs by scalar type', () => { - const registry = createCodecRegistry(); - const codec1 = codec({ - typeId: 'test/type1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const codec2 = codec({ - typeId: 'test/type2@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - registry.register(codec1); - registry.register(codec2); - - const codecs = registry.getByScalar('text'); - expect({ - length: codecs.length, - containsCodec1: codecs.includes(codec1), - containsCodec2: codecs.includes(codec2), - }).toMatchObject({ - length: 2, - containsCodec1: true, - containsCodec2: true, - }); - }); - - it('returns undefined for default codec when no codecs exist', () => { - const registry = createCodecRegistry(); - expect(registry.getDefaultCodec('unknown')).toBeUndefined(); - }); - - it('returns first codec as default for scalar type', () => { - const registry = createCodecRegistry(); - const codec1 = codec({ - typeId: 'test/type1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const codec2 = codec({ - typeId: 'test/type2@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - registry.register(codec1); - registry.register(codec2); - - expect(registry.getDefaultCodec('text')).toBe(codec1); - }); - - it('handles codec with multiple target types', () => { - const registry = createCodecRegistry(); - const multiCodec = codec({ - typeId: 'test/multi@1', - targetTypes: ['int4', 'int8'], - encode: (value: number) => value, - decode: (wire: number) => wire, - }); - - registry.register(multiCodec); - - expect({ - int4Contains: registry.getByScalar('int4').includes(multiCodec), - int8Contains: registry.getByScalar('int8').includes(multiCodec), - int4Default: registry.getDefaultCodec('int4'), - int8Default: registry.getDefaultCodec('int8'), - }).toMatchObject({ - int4Contains: true, - int8Contains: true, - int4Default: multiCodec, - int8Default: multiCodec, - }); - }); - - it('iterates over all registered codecs', () => { - const registry = createCodecRegistry(); - const codec1 = codec({ - typeId: 'test/type1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const codec2 = codec({ - typeId: 'test/type2@1', - targetTypes: ['int4'], - encode: (value: number) => value, - decode: (wire: number) => wire, - }); - - registry.register(codec1); - registry.register(codec2); - - const codecs = Array.from(registry); - expect({ - length: codecs.length, - containsCodec1: codecs.includes(codec1), - containsCodec2: codecs.includes(codec2), - }).toMatchObject({ - length: 2, - containsCodec1: true, - containsCodec2: true, - }); - }); - - it('returns values iterator', () => { - const registry = createCodecRegistry(); - const codec1 = codec({ - typeId: 'test/type1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const codec2 = codec({ - typeId: 'test/type2@1', - targetTypes: ['int4'], - encode: (value: number) => value, - decode: (wire: number) => wire, - }); - - registry.register(codec1); - registry.register(codec2); - - const codecs = Array.from(registry.values()); - expect({ - length: codecs.length, - containsCodec1: codecs.includes(codec1), - containsCodec2: codecs.includes(codec2), - }).toMatchObject({ - length: 2, - containsCodec1: true, - containsCodec2: true, - }); - }); - - it('is iterable via Symbol.iterator', () => { - const registry = createCodecRegistry(); - const codec1 = codec({ - typeId: 'test/iter1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - registry.register(codec1); - - const codecs = [...registry]; - expect(codecs).toEqual([codec1]); - }); - - it('handles codec registration with existing scalar type array', () => { - const registry = createCodecRegistry(); - const codec1 = codec({ - typeId: 'test/type1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const codec2 = codec({ - typeId: 'test/type2@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - registry.register(codec1); - registry.register(codec2); - - const codecs = registry.getByScalar('text'); - expect({ - length: codecs.length, - first: codecs[0], - second: codecs[1], - }).toMatchObject({ - length: 2, - first: codec1, - second: codec2, - }); - }); -}); - -describe('CodecDefBuilder', () => { - it('creates empty builder', () => { - const builder = defineCodecs(); - expect({ - codecTypes: builder.CodecTypes, - dataTypes: builder.dataTypes, - codecDefinitions: builder.codecDefinitions, - }).toMatchObject({ - codecTypes: {}, - dataTypes: {}, - codecDefinitions: {}, - }); - }); - - it('adds codec to builder', () => { - const codec1 = codec({ - typeId: 'test/type1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const builder = defineCodecs().add('text', codec1); - expect({ - hasCodecTypes: builder.CodecTypes !== undefined, - hasDataTypes: builder.dataTypes !== undefined, - hasCodecDefinitions: builder.codecDefinitions !== undefined, - hasTextDef: builder.codecDefinitions.text !== undefined, - typeId: builder.codecDefinitions.text.typeId, - scalar: builder.codecDefinitions.text.scalar, - codec: builder.codecDefinitions.text.codec, - dataType: builder.dataTypes.text, - }).toMatchObject({ - hasCodecTypes: true, - hasDataTypes: true, - hasCodecDefinitions: true, - hasTextDef: true, - typeId: 'test/type1@1', - scalar: 'text', - codec: codec1, - dataType: 'test/type1@1', - }); - }); - - it('adds multiple codecs to builder', () => { - const codec1 = codec({ - typeId: 'test/type1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const codec2 = codec({ - typeId: 'test/type2@1', - targetTypes: ['int4'], - encode: (value: number) => value, - decode: (wire: number) => wire, - }); - - const builder = defineCodecs().add('text', codec1).add('int4', codec2); - expect({ - hasTextDef: builder.codecDefinitions.text !== undefined, - hasInt4Def: builder.codecDefinitions.int4 !== undefined, - textTypeId: builder.codecDefinitions.text.typeId, - int4TypeId: builder.codecDefinitions.int4.typeId, - textDataType: builder.dataTypes.text, - int4DataType: builder.dataTypes.int4, - }).toMatchObject({ - hasTextDef: true, - hasInt4Def: true, - textTypeId: 'test/type1@1', - int4TypeId: 'test/type2@1', - textDataType: 'test/type1@1', - int4DataType: 'test/type2@1', - }); - }); - - it('overwrites codec when adding with same scalar name', () => { - const codec1 = codec({ - typeId: 'test/type1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const codec2 = codec({ - typeId: 'test/type2@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const builder = defineCodecs().add('text', codec1).add('text', codec2); - expect({ - typeId: builder.codecDefinitions.text.typeId, - codec: builder.codecDefinitions.text.codec, - dataType: builder.dataTypes.text, - }).toMatchObject({ - typeId: 'test/type2@1', - codec: codec2, - dataType: 'test/type2@1', - }); - }); - - it('populates CodecTypes correctly', () => { - const codec1 = codec({ - typeId: 'test/type1@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - const builder = defineCodecs().add('text', codec1); - expect(builder.CodecTypes).toBeDefined(); - expect('test/type1@1' in builder.CodecTypes).toBe(true); - }); -}); - -describe('codec traits', () => { - it('codec() factory produces codec with traits', () => { - const testCodec = codec({ - typeId: 'test/numeric@1', - targetTypes: ['int4'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number) => value, - decode: (wire: number) => wire, - }); - - expect(testCodec.traits).toEqual(['equality', 'order', 'numeric']); - }); - - it('codec() factory omits traits when not provided', () => { - const testCodec = codec({ - typeId: 'test/bare@1', - targetTypes: ['text'], - encode: (value: string) => value, - decode: (wire: string) => wire, - }); - - expect(testCodec.traits).toBeUndefined(); - }); - - it('hasTrait returns true for declared trait', () => { - const registry = createCodecRegistry(); - registry.register( - codec({ - typeId: 'test/num@1', - targetTypes: ['int'], - traits: ['equality', 'order', 'numeric'], - encode: (v: number) => v, - decode: (w: number) => w, - }), - ); - - expect(registry.hasTrait('test/num@1', 'numeric')).toBe(true); - expect(registry.hasTrait('test/num@1', 'equality')).toBe(true); - expect(registry.hasTrait('test/num@1', 'order')).toBe(true); - }); - - it('hasTrait returns false for undeclared trait', () => { - const registry = createCodecRegistry(); - registry.register( - codec({ - typeId: 'test/bool@1', - targetTypes: ['bool'], - traits: ['equality', 'boolean'], - encode: (v: boolean) => v, - decode: (w: boolean) => w, - }), - ); - - expect(registry.hasTrait('test/bool@1', 'numeric')).toBe(false); - expect(registry.hasTrait('test/bool@1', 'order')).toBe(false); - expect(registry.hasTrait('test/bool@1', 'textual')).toBe(false); - }); - - it('hasTrait returns false for unknown codec', () => { - const registry = createCodecRegistry(); - expect(registry.hasTrait('unknown/id@1', 'equality')).toBe(false); - }); - - it('hasTrait returns false for codec without traits', () => { - const registry = createCodecRegistry(); - registry.register( - codec({ - typeId: 'test/bare@1', - targetTypes: ['text'], - encode: (v: string) => v, - decode: (w: string) => w, - }), - ); - - expect(registry.hasTrait('test/bare@1', 'equality')).toBe(false); - }); - - it('traitsOf returns declared traits', () => { - const registry = createCodecRegistry(); - registry.register( - codec({ - typeId: 'test/text@1', - targetTypes: ['text'], - traits: ['equality', 'order', 'textual'], - encode: (v: string) => v, - decode: (w: string) => w, - }), - ); - - expect(registry.traitsOf('test/text@1')).toEqual(['equality', 'order', 'textual']); - }); - - it('traitsOf returns empty array for unknown codec', () => { - const registry = createCodecRegistry(); - expect(registry.traitsOf('unknown/id@1')).toEqual([]); - }); - - it('traitsOf returns empty array for codec without traits', () => { - const registry = createCodecRegistry(); - registry.register( - codec({ - typeId: 'test/bare@1', - targetTypes: ['text'], - encode: (v: string) => v, - decode: (w: string) => w, - }), - ); - - expect(registry.traitsOf('test/bare@1')).toEqual([]); - }); -}); - -describe('SqlCodecTypes with traits', () => { - it('SqlCodecTypes carries narrow traits for codecs without init', async () => { - type SqlCTypes = import('../../src/ast/sql-codecs').SqlCodecTypes; - - // Int (no init/paramsSchema): TTraits inferred as literal tuple - true satisfies 'numeric' extends SqlCTypes['sql/int@1']['traits'] ? true : false; - true satisfies 'equality' extends SqlCTypes['sql/int@1']['traits'] ? true : false; - true satisfies 'order' extends SqlCTypes['sql/int@1']['traits'] ? true : false; - false satisfies 'textual' extends SqlCTypes['sql/int@1']['traits'] ? true : false; - false satisfies 'boolean' extends SqlCTypes['sql/int@1']['traits'] ? true : false; - - // Float (no init/paramsSchema): same narrow traits - true satisfies 'numeric' extends SqlCTypes['sql/float@1']['traits'] ? true : false; - false satisfies 'textual' extends SqlCTypes['sql/float@1']['traits'] ? true : false; - }); -}); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/refs-propagation.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/refs-propagation.test.ts new file mode 100644 index 0000000000..a42f05cba0 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/refs-propagation.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + AndExpr, + BinaryExpr, + ColumnRef, + ParamRef, + ProjectionItem, + SelectAst, + TableSource, +} from '../../src/exports/ast'; +import { shiftParamRef } from './test-helpers'; + +const userEmailRefs = { table: 'user', column: 'email' } as const; + +function selectWithEmailFilter(ref: ParamRef): SelectAst { + return SelectAst.from(TableSource.named('user')) + .withProjection([ + ProjectionItem.of('email', ColumnRef.of('user', 'email'), 'sql/varchar@1', userEmailRefs), + ]) + .withWhere(AndExpr.of([BinaryExpr.eq(ColumnRef.of('user', 'email'), ref)])); +} + +describe('ParamRef refs — AST rewriter propagation', () => { + it('ParamRef.rewrite with no paramRef rewriter returns the same instance (refs preserved)', () => { + const original = ParamRef.of('a@b.com', { + name: 'p1', + codecId: 'sql/varchar@1', + refs: userEmailRefs, + }); + const rewritten = original.rewrite({}); + expect(rewritten).toBe(original); + }); + + it('SelectAst.rewrite with an identity paramRef rewriter preserves refs on every ParamRef', () => { + const ref = ParamRef.of('a@b.com', { + name: 'p1', + codecId: 'sql/varchar@1', + refs: userEmailRefs, + }); + const ast = selectWithEmailFilter(ref); + + const rewritten = ast.rewrite({ paramRef: (p) => p }); + + const rewrittenWhere = rewritten.where as AndExpr; + const eq = rewrittenWhere.exprs[0] as BinaryExpr; + const right = eq.right as ParamRef; + expect(right.refs).toEqual(userEmailRefs); + expect(right.codecId).toBe('sql/varchar@1'); + }); + + it('SelectAst.rewrite with a non-identity paramRef rewriter that propagates refs preserves them', () => { + const ref = ParamRef.of(7, { + name: 'p1', + codecId: 'sql/int@1', + refs: { table: 'user', column: 'age' }, + }); + const ast = SelectAst.from(TableSource.named('user')) + .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) + .withWhere(AndExpr.of([BinaryExpr.eq(ColumnRef.of('user', 'age'), ref)])); + + const rewritten = ast.rewrite({ paramRef: shiftParamRef(10) }); + + const where = rewritten.where as AndExpr; + const eq = where.exprs[0] as BinaryExpr; + const right = eq.right as ParamRef; + expect(right.value).toBe(17); + expect(right.refs).toEqual({ table: 'user', column: 'age' }); + }); + + it('SelectAst.rewrite preserves ProjectionItem refs through rewriteProjectionItem', () => { + const ref = ParamRef.of('a@b.com', { + name: 'p1', + codecId: 'sql/varchar@1', + refs: userEmailRefs, + }); + const ast = selectWithEmailFilter(ref); + + const rewritten = ast.rewrite({ + columnRef: (c) => (c.table === 'user' ? ColumnRef.of('member', c.column) : c), + }); + + const projection = rewritten.projection[0]; + expect(projection?.refs).toEqual(userEmailRefs); + expect(projection?.codecId).toBe('sql/varchar@1'); + }); + + it('ProjectionItem.withCodecId preserves refs', () => { + const item = ProjectionItem.of( + 'email', + ColumnRef.of('user', 'email'), + 'sql/varchar@1', + userEmailRefs, + ); + const updated = item.withCodecId('sql/varchar@2'); + expect(updated.codecId).toBe('sql/varchar@2'); + expect(updated.refs).toEqual(userEmailRefs); + }); +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-helpers.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-helpers.test.ts new file mode 100644 index 0000000000..8daf804512 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codec-helpers.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; +import type { AnyCodecDescriptor } from '../../src/ast/codec-types'; +import { + SQL_CHAR_CODEC_ID, + SQL_FLOAT_CODEC_ID, + SQL_INT_CODEC_ID, + SQL_TEXT_CODEC_ID, + SQL_TIMESTAMP_CODEC_ID, + SQL_VARCHAR_CODEC_ID, +} from '../../src/ast/sql-codec-helpers'; +import { + sqlCharDescriptor, + sqlFloatDescriptor, + sqlIntDescriptor, + sqlTextDescriptor, + sqlTimestampDescriptor, + sqlVarcharDescriptor, +} from '../../src/ast/sql-codecs'; + +const descriptorsByScalar = { + char: sqlCharDescriptor, + varchar: sqlVarcharDescriptor, + int: sqlIntDescriptor, + float: sqlFloatDescriptor, + text: sqlTextDescriptor, + timestamp: sqlTimestampDescriptor, +} as const satisfies Record; + +describe('sql-codec-helpers', () => { + it('exports expected codec IDs', () => { + expect({ + char: SQL_CHAR_CODEC_ID, + varchar: SQL_VARCHAR_CODEC_ID, + int: SQL_INT_CODEC_ID, + float: SQL_FLOAT_CODEC_ID, + text: SQL_TEXT_CODEC_ID, + timestamp: SQL_TIMESTAMP_CODEC_ID, + }).toEqual({ + char: 'sql/char@1', + varchar: 'sql/varchar@1', + int: 'sql/int@1', + float: 'sql/float@1', + text: 'sql/text@1', + timestamp: 'sql/timestamp@1', + }); + }); + + const codecDefinitionCases: ReadonlyArray<{ + scalar: keyof typeof descriptorsByScalar; + id: string; + targetTypes: readonly string[]; + hasParamsSchema: boolean; + }> = [ + { scalar: 'char', id: SQL_CHAR_CODEC_ID, targetTypes: ['char'], hasParamsSchema: true }, + { + scalar: 'varchar', + id: SQL_VARCHAR_CODEC_ID, + targetTypes: ['varchar'], + hasParamsSchema: true, + }, + { scalar: 'int', id: SQL_INT_CODEC_ID, targetTypes: ['int'], hasParamsSchema: true }, + { scalar: 'float', id: SQL_FLOAT_CODEC_ID, targetTypes: ['float'], hasParamsSchema: true }, + { scalar: 'text', id: SQL_TEXT_CODEC_ID, targetTypes: ['text'], hasParamsSchema: true }, + { + scalar: 'timestamp', + id: SQL_TIMESTAMP_CODEC_ID, + targetTypes: ['timestamp'], + hasParamsSchema: true, + }, + ]; + + it.each(codecDefinitionCases)('defines descriptor for $scalar', ({ + scalar, + id, + targetTypes, + hasParamsSchema, + }) => { + const descriptor = descriptorsByScalar[scalar]; + expect(descriptor.codecId).toBe(id); + expect(descriptor.targetTypes).toEqual(targetTypes); + expect(descriptor.paramsSchema !== undefined).toBe(hasParamsSchema); + }); + + const codecRoundTripCases: ReadonlyArray<{ + scalar: keyof typeof descriptorsByScalar; + input: string | number; + expectedEncoded: string | number; + expectedDecoded: string | number; + }> = [ + { scalar: 'char', input: 'A', expectedEncoded: 'A', expectedDecoded: 'A' }, + { scalar: 'varchar', input: 'hello', expectedEncoded: 'hello', expectedDecoded: 'hello' }, + { scalar: 'int', input: 42, expectedEncoded: 42, expectedDecoded: 42 }, + { scalar: 'float', input: 3.14, expectedEncoded: 3.14, expectedDecoded: 3.14 }, + { + scalar: 'text', + input: 'portable text', + expectedEncoded: 'portable text', + expectedDecoded: 'portable text', + }, + ]; + + it.each(codecRoundTripCases)('encodes and decodes $scalar values', async ({ + scalar, + input, + expectedEncoded, + expectedDecoded, + }) => { + const descriptor = descriptorsByScalar[scalar] as AnyCodecDescriptor; + const codec = descriptor.factory(undefined as never)({ name: 'test' }); + expect(await codec.encode(input, {})).toBe(expectedEncoded); + expect(await codec.decode(input, {})).toBe(expectedDecoded); + }); + + it('trims trailing spaces when decoding char values', async () => { + const codec = sqlCharDescriptor.factory({})({ name: 'test' }); + expect(await codec.decode('user_001 ', {})).toBe('user_001'); + expect(await codec.decode('user_001', {})).toBe('user_001'); + }); + + it('round-trips Date values for timestamp codecs', async () => { + const codec = sqlTimestampDescriptor.factory({})({ name: 'test' }); + const instant = new Date('2024-01-15T10:30:00Z'); + expect(await codec.encode(instant, {})).toBe(instant); + expect(await codec.decode(instant, {})).toBe(instant); + }); + + it('serializes timestamps to ISO strings for the JSON contract', () => { + const codec = sqlTimestampDescriptor.factory({})({ name: 'test' }); + const instant = new Date('2024-01-15T10:30:00Z'); + expect(codec.encodeJson(instant)).toBe('2024-01-15T10:30:00.000Z'); + expect(codec.decodeJson('2024-01-15T10:30:00.000Z')).toEqual(instant); + }); + + it('throws on invalid JSON input for timestamp codecs', () => { + const codec = sqlTimestampDescriptor.factory({})({ name: 'test' }); + expect(() => codec.decodeJson(42)).toThrow(/Expected ISO date string/); + expect(() => codec.decodeJson('not-a-date')).toThrow(/Invalid ISO date string/); + }); + + describe('renderOutputType', () => { + it('sql/char@1 renders Char', () => { + expect(sqlCharDescriptor.renderOutputType?.({ length: 36 })).toBe('Char<36>'); + }); + + it('sql/char@1 returns undefined when length absent', () => { + expect(sqlCharDescriptor.renderOutputType?.({})).toBeUndefined(); + }); + + it('sql/char@1 throws on invalid length type', () => { + expect(() => + sqlCharDescriptor.renderOutputType?.({ length: 'bad' as unknown as number }), + ).toThrow(/expected integer "length"/); + }); + + it('sql/varchar@1 renders Varchar', () => { + expect(sqlVarcharDescriptor.renderOutputType?.({ length: 255 })).toBe('Varchar<255>'); + }); + + it('sql/varchar@1 returns undefined when length absent', () => { + expect(sqlVarcharDescriptor.renderOutputType?.({})).toBeUndefined(); + }); + + it('sql/varchar@1 throws on invalid length type', () => { + expect(() => + sqlVarcharDescriptor.renderOutputType?.({ length: 'bad' as unknown as number }), + ).toThrow(/expected integer "length"/); + }); + + it('sql/timestamp@1 renders Timestamp

with precision', () => { + expect(sqlTimestampDescriptor.renderOutputType?.({ precision: 3 })).toBe('Timestamp<3>'); + }); + + it('sql/timestamp@1 renders bare Timestamp when precision absent', () => { + expect(sqlTimestampDescriptor.renderOutputType?.({})).toBe('Timestamp'); + }); + + it('sql/timestamp@1 throws on invalid precision type', () => { + expect(() => + sqlTimestampDescriptor.renderOutputType?.({ + precision: 'bad' as unknown as number, + }), + ).toThrow(/expected integer "precision"/); + }); + + it('sql/int@1 has no renderOutputType', () => { + expect(sqlIntDescriptor.renderOutputType).toBeUndefined(); + }); + + it('sql/float@1 has no renderOutputType', () => { + expect(sqlFloatDescriptor.renderOutputType).toBeUndefined(); + }); + + it('sql/text@1 has no renderOutputType', () => { + expect(sqlTextDescriptor.renderOutputType).toBeUndefined(); + }); + }); +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.test.ts index c211c6069b..0a071fd619 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.test.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest'; -import type { SqlCodecCallContext } from '../../src/ast/codec-types'; import { SQL_CHAR_CODEC_ID, SQL_FLOAT_CODEC_ID, @@ -7,267 +6,245 @@ import { SQL_TEXT_CODEC_ID, SQL_TIMESTAMP_CODEC_ID, SQL_VARCHAR_CODEC_ID, - sqlCodecDefinitions, - sqlDataTypes, +} from '../../src/ast/sql-codec-helpers'; +import { + sqlCharColumn, + sqlCharDescriptor, + sqlFloatColumn, + sqlFloatDescriptor, + sqlIntColumn, + sqlIntDescriptor, + sqlTextColumn, + sqlTextDescriptor, + sqlTimestampColumn, + sqlTimestampDescriptor, + sqlVarcharColumn, + sqlVarcharDescriptor, } from '../../src/ast/sql-codecs'; +const instanceCtx = { name: '' }; +const callCtx = {}; + describe('sql-codecs', () => { - it('exports expected codec IDs', () => { - expect({ - char: SQL_CHAR_CODEC_ID, - varchar: SQL_VARCHAR_CODEC_ID, - int: SQL_INT_CODEC_ID, - float: SQL_FLOAT_CODEC_ID, - text: SQL_TEXT_CODEC_ID, - timestamp: SQL_TIMESTAMP_CODEC_ID, - }).toEqual({ - char: 'sql/char@1', - varchar: 'sql/varchar@1', - int: 'sql/int@1', - float: 'sql/float@1', - text: 'sql/text@1', - timestamp: 'sql/timestamp@1', + describe('sql/text@1', () => { + const codec = sqlTextDescriptor.factory()(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(SQL_TEXT_CODEC_ID); }); - }); - const codecDefinitionCases: ReadonlyArray<{ - scalar: keyof typeof sqlCodecDefinitions; - id: string; - targetTypes: readonly string[]; - hasParamsSchema: boolean; - hasInit: boolean; - }> = [ - { - scalar: 'char', - id: SQL_CHAR_CODEC_ID, - targetTypes: ['char'], - hasParamsSchema: true, - hasInit: true, - }, - { - scalar: 'varchar', - id: SQL_VARCHAR_CODEC_ID, - targetTypes: ['varchar'], - hasParamsSchema: true, - hasInit: true, - }, - { - scalar: 'int', - id: SQL_INT_CODEC_ID, - targetTypes: ['int'], - hasParamsSchema: false, - hasInit: false, - }, - { - scalar: 'float', - id: SQL_FLOAT_CODEC_ID, - targetTypes: ['float'], - hasParamsSchema: false, - hasInit: false, - }, - { - scalar: 'text', - id: SQL_TEXT_CODEC_ID, - targetTypes: ['text'], - hasParamsSchema: false, - hasInit: false, - }, - { - scalar: 'timestamp', - id: SQL_TIMESTAMP_CODEC_ID, - targetTypes: ['timestamp'], - hasParamsSchema: true, - hasInit: false, - }, - ]; - - it.each(codecDefinitionCases)('defines codec for $scalar', ({ - scalar, - id, - targetTypes, - hasParamsSchema, - hasInit, - }) => { - const definition = sqlCodecDefinitions[scalar]; - expect(definition.typeId).toBe(id); - expect(definition.scalar).toBe(scalar); - expect(definition.codec.targetTypes).toEqual(targetTypes); - expect(definition.codec.paramsSchema !== undefined).toBe(hasParamsSchema); - expect(definition.codec.init !== undefined).toBe(hasInit); - }); + it('encodes and decodes string values', async () => { + expect(await codec.encode('hello', callCtx)).toBe('hello'); + expect(await codec.decode('hello', callCtx)).toBe('hello'); + }); - it('exports data types mapped to codec IDs', () => { - expect(sqlDataTypes).toEqual({ - char: SQL_CHAR_CODEC_ID, - varchar: SQL_VARCHAR_CODEC_ID, - int: SQL_INT_CODEC_ID, - float: SQL_FLOAT_CODEC_ID, - text: SQL_TEXT_CODEC_ID, - timestamp: SQL_TIMESTAMP_CODEC_ID, + it('round-trips through JSON identity', () => { + expect(codec.encodeJson('hello')).toBe('hello'); + expect(codec.decodeJson('hello')).toBe('hello'); }); }); - const codecRoundTripCases: ReadonlyArray<{ - scalar: keyof typeof sqlCodecDefinitions; - input: string | number; - expectedEncoded: string | number; - expectedDecoded: string | number; - }> = [ - { - scalar: 'char', - input: 'A', - expectedEncoded: 'A', - expectedDecoded: 'A', - }, - { - scalar: 'varchar', - input: 'hello', - expectedEncoded: 'hello', - expectedDecoded: 'hello', - }, - { - scalar: 'int', - input: 42, - expectedEncoded: 42, - expectedDecoded: 42, - }, - { - scalar: 'float', - input: 3.14, - expectedEncoded: 3.14, - expectedDecoded: 3.14, - }, - { - scalar: 'text', - input: 'portable text', - expectedEncoded: 'portable text', - expectedDecoded: 'portable text', - }, - ]; - - it.each(codecRoundTripCases)('encodes and decodes $scalar values', async ({ - scalar, - input, - expectedEncoded, - expectedDecoded, - }) => { - const codec = sqlCodecDefinitions[scalar].codec as { - encode: (value: unknown, ctx: SqlCodecCallContext) => Promise; - decode: (wire: unknown, ctx: SqlCodecCallContext) => Promise; - }; - - expect(await codec.encode(input, {})).toBe(expectedEncoded); - expect(await codec.decode(input, {})).toBe(expectedDecoded); - }); + describe('sql/int@1', () => { + const codec = sqlIntDescriptor.factory()(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(SQL_INT_CODEC_ID); + }); - it('trims trailing spaces when decoding char values', async () => { - const charCodec = sqlCodecDefinitions.char.codec as { - decode: (wire: string, ctx: SqlCodecCallContext) => Promise; - }; + it('encodes and decodes number values', async () => { + expect(await codec.encode(42, callCtx)).toBe(42); + expect(await codec.decode(42, callCtx)).toBe(42); + }); - expect(await charCodec.decode('user_001 ', {})).toBe('user_001'); - expect(await charCodec.decode('user_001', {})).toBe('user_001'); + it('round-trips through JSON identity', () => { + expect(codec.encodeJson(42)).toBe(42); + expect(codec.decodeJson(42)).toBe(42); + }); }); - it('initializes helpers for length-parameterized codecs', () => { - const charCodec = sqlCodecDefinitions.char.codec; - const varcharCodec = sqlCodecDefinitions.varchar.codec; + describe('sql/float@1', () => { + const codec = sqlFloatDescriptor.factory()(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(SQL_FLOAT_CODEC_ID); + }); - expect(charCodec.init?.({ length: 5 })).toEqual({ - kind: 'fixed', - maxLength: 5, + it('encodes and decodes number values', async () => { + expect(await codec.encode(3.14, callCtx)).toBe(3.14); + expect(await codec.decode(3.14, callCtx)).toBe(3.14); }); - expect(varcharCodec.init?.({ length: 255 })).toEqual({ - kind: 'variable', - maxLength: 255, + + it('round-trips through JSON identity', () => { + expect(codec.encodeJson(3.14)).toBe(3.14); + expect(codec.decodeJson(3.14)).toBe(3.14); }); }); - it('round-trips Date values for timestamp codecs', async () => { - const timestampCodec = sqlCodecDefinitions.timestamp.codec as { - encode: (value: Date, ctx: SqlCodecCallContext) => Promise; - decode: (wire: Date, ctx: SqlCodecCallContext) => Promise; - }; + describe('sql/char@1', () => { + const codec = sqlCharDescriptor.factory({ length: 8 })(instanceCtx); + + it('id proxies through the descriptor (independent of params)', () => { + expect(codec.id).toBe(SQL_CHAR_CODEC_ID); + }); - const instant = new Date('2024-01-15T10:30:00Z'); + it('encodes string values verbatim', async () => { + expect(await codec.encode('user_001', callCtx)).toBe('user_001'); + }); - expect(await timestampCodec.encode(instant, {})).toBe(instant); - expect(await timestampCodec.decode(instant, {})).toBe(instant); - }); + it('trims trailing spaces on decode', async () => { + expect(await codec.decode('user_001 ', callCtx)).toBe('user_001'); + expect(await codec.decode('user_001', callCtx)).toBe('user_001'); + }); - it('serializes timestamps to ISO strings for the JSON contract', () => { - const timestampCodec = sqlCodecDefinitions.timestamp.codec; + it('round-trips through JSON identity', () => { + expect(codec.encodeJson('user_001')).toBe('user_001'); + expect(codec.decodeJson('user_001')).toBe('user_001'); + }); - const instant = new Date('2024-01-15T10:30:00Z'); + it('renderOutputType returns Char', () => { + expect(sqlCharDescriptor.renderOutputType?.({ length: 36 })).toBe('Char<36>'); + }); - expect(timestampCodec.encodeJson(instant)).toBe('2024-01-15T10:30:00.000Z'); - expect(timestampCodec.decodeJson('2024-01-15T10:30:00.000Z')).toEqual(instant); + it('renderOutputType returns undefined when length absent', () => { + expect(sqlCharDescriptor.renderOutputType?.({})).toBeUndefined(); + }); }); - it('throws on invalid JSON input for timestamp codecs', () => { - const timestampCodec = sqlCodecDefinitions.timestamp.codec; + describe('sql/varchar@1', () => { + const codec = sqlVarcharDescriptor.factory({ length: 255 })(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(SQL_VARCHAR_CODEC_ID); + }); - expect(() => timestampCodec.decodeJson(42)).toThrow(/Expected ISO date string/); - expect(() => timestampCodec.decodeJson('not-a-date')).toThrow(/Invalid ISO date string/); + it('encodes and decodes string values verbatim', async () => { + expect(await codec.encode('hello', callCtx)).toBe('hello'); + expect(await codec.decode('hello', callCtx)).toBe('hello'); + }); + + it('round-trips through JSON identity', () => { + expect(codec.encodeJson('hello')).toBe('hello'); + expect(codec.decodeJson('hello')).toBe('hello'); + }); + + it('renderOutputType returns Varchar', () => { + expect(sqlVarcharDescriptor.renderOutputType?.({ length: 255 })).toBe('Varchar<255>'); + }); + + it('renderOutputType returns undefined when length absent', () => { + expect(sqlVarcharDescriptor.renderOutputType?.({})).toBeUndefined(); + }); }); - describe('renderOutputType', () => { - it('sql/char@1 renders Char', () => { - expect(sqlCodecDefinitions.char.codec.renderOutputType!({ length: 36 })).toBe('Char<36>'); + describe('sql/timestamp@1', () => { + const codec = sqlTimestampDescriptor.factory({ precision: 3 })(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(SQL_TIMESTAMP_CODEC_ID); + }); + + it('round-trips Date values', async () => { + const instant = new Date('2024-01-15T10:30:00Z'); + expect(await codec.encode(instant, callCtx)).toBe(instant); + expect(await codec.decode(instant, callCtx)).toBe(instant); }); - it('sql/char@1 returns undefined when length absent', () => { - expect(sqlCodecDefinitions.char.codec.renderOutputType!({})).toBeUndefined(); + it('serializes Date to ISO 8601 string for JSON', () => { + const instant = new Date('2024-01-15T10:30:00Z'); + expect(codec.encodeJson(instant)).toBe('2024-01-15T10:30:00.000Z'); + expect(codec.decodeJson('2024-01-15T10:30:00.000Z')).toEqual(instant); }); - it('sql/char@1 throws on invalid length type', () => { - expect(() => sqlCodecDefinitions.char.codec.renderOutputType!({ length: 'bad' })).toThrow( - /expected integer "length"/, - ); + it('throws on invalid JSON input', () => { + expect(() => codec.decodeJson(42)).toThrow(/Expected ISO date string/); + expect(() => codec.decodeJson('not-a-date')).toThrow(/Invalid ISO date string/); }); - it('sql/varchar@1 renders Varchar', () => { - expect(sqlCodecDefinitions.varchar.codec.renderOutputType!({ length: 255 })).toBe( - 'Varchar<255>', - ); + it('renderOutputType returns Timestamp', () => { + expect(sqlTimestampDescriptor.renderOutputType?.({ precision: 3 })).toBe('Timestamp<3>'); }); - it('sql/varchar@1 returns undefined when length absent', () => { - expect(sqlCodecDefinitions.varchar.codec.renderOutputType!({})).toBeUndefined(); + it('renderOutputType returns bare Timestamp when precision absent', () => { + expect(sqlTimestampDescriptor.renderOutputType?.({})).toBe('Timestamp'); }); + }); - it('sql/varchar@1 throws on invalid length type', () => { - expect(() => sqlCodecDefinitions.varchar.codec.renderOutputType!({ length: 'bad' })).toThrow( - /expected integer "length"/, - ); + describe('column helpers', () => { + it('sqlTextColumn produces a ColumnSpec with text nativeType and no typeParams', () => { + const spec = sqlTextColumn(); + expect(spec.codecId).toBe(SQL_TEXT_CODEC_ID); + expect(spec.nativeType).toBe('text'); + expect(spec.typeParams).toBeUndefined(); }); - it('sql/timestamp@1 renders Timestamp

with precision', () => { - expect(sqlCodecDefinitions.timestamp.codec.renderOutputType!({ precision: 3 })).toBe( - 'Timestamp<3>', - ); + it('sqlIntColumn produces a ColumnSpec with int nativeType', () => { + const spec = sqlIntColumn(); + expect(spec.codecId).toBe(SQL_INT_CODEC_ID); + expect(spec.nativeType).toBe('int'); }); - it('sql/timestamp@1 renders bare Timestamp when precision absent', () => { - expect(sqlCodecDefinitions.timestamp.codec.renderOutputType!({})).toBe('Timestamp'); + it('sqlFloatColumn produces a ColumnSpec with float nativeType', () => { + const spec = sqlFloatColumn(); + expect(spec.codecId).toBe(SQL_FLOAT_CODEC_ID); + expect(spec.nativeType).toBe('float'); }); - it('sql/timestamp@1 throws on invalid precision type', () => { - expect(() => - sqlCodecDefinitions.timestamp.codec.renderOutputType!({ precision: 'bad' }), - ).toThrow(/expected integer "precision"/); + it('sqlCharColumn defaults typeParams to {} when invoked without arguments', () => { + const spec = sqlCharColumn(); + expect(spec.codecId).toBe(SQL_CHAR_CODEC_ID); + expect(spec.nativeType).toBe('char'); + expect(spec.typeParams).toEqual({}); }); - it('sql/int@1 has no renderOutputType', () => { - expect(sqlCodecDefinitions.int.codec.renderOutputType).toBeUndefined(); + it('sqlCharColumn carries the explicit length param', () => { + const spec = sqlCharColumn({ length: 16 }); + expect(spec.typeParams).toEqual({ length: 16 }); }); - it('sql/float@1 has no renderOutputType', () => { - expect(sqlCodecDefinitions.float.codec.renderOutputType).toBeUndefined(); + it('sqlVarcharColumn defaults typeParams to {} when invoked without arguments', () => { + const spec = sqlVarcharColumn(); + expect(spec.typeParams).toEqual({}); }); - it('sql/text@1 has no renderOutputType', () => { - expect(sqlCodecDefinitions.text.codec.renderOutputType).toBeUndefined(); + it('sqlVarcharColumn carries the explicit length param', () => { + const spec = sqlVarcharColumn({ length: 64 }); + expect(spec.typeParams).toEqual({ length: 64 }); + }); + + it('sqlTimestampColumn defaults typeParams to {} when invoked without arguments', () => { + const spec = sqlTimestampColumn(); + expect(spec.typeParams).toEqual({}); + }); + + it('sqlTimestampColumn carries the explicit precision param', () => { + const spec = sqlTimestampColumn({ precision: 6 }); + expect(spec.typeParams).toEqual({ precision: 6 }); + }); + }); + + describe('descriptor metadata', () => { + it('codec ids match the SQL_*_CODEC_ID constants', () => { + expect(sqlTextDescriptor.codecId).toBe(SQL_TEXT_CODEC_ID); + expect(sqlIntDescriptor.codecId).toBe(SQL_INT_CODEC_ID); + expect(sqlFloatDescriptor.codecId).toBe(SQL_FLOAT_CODEC_ID); + expect(sqlCharDescriptor.codecId).toBe(SQL_CHAR_CODEC_ID); + expect(sqlVarcharDescriptor.codecId).toBe(SQL_VARCHAR_CODEC_ID); + expect(sqlTimestampDescriptor.codecId).toBe(SQL_TIMESTAMP_CODEC_ID); + }); + + it('exposes traits and targetTypes for each codec', () => { + expect(sqlTextDescriptor.traits).toEqual(['equality', 'order', 'textual']); + expect(sqlTextDescriptor.targetTypes).toEqual(['text']); + + expect(sqlIntDescriptor.traits).toEqual(['equality', 'order', 'numeric']); + expect(sqlIntDescriptor.targetTypes).toEqual(['int']); + + expect(sqlFloatDescriptor.traits).toEqual(['equality', 'order', 'numeric']); + expect(sqlFloatDescriptor.targetTypes).toEqual(['float']); + + expect(sqlCharDescriptor.targetTypes).toEqual(['char']); + expect(sqlVarcharDescriptor.targetTypes).toEqual(['varchar']); + expect(sqlTimestampDescriptor.targetTypes).toEqual(['timestamp']); }); }); }); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.types.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.types.test-d.ts new file mode 100644 index 0000000000..76a93e38c8 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.types.test-d.ts @@ -0,0 +1,104 @@ +/** + * Type tests for the SQL base codecs (TML-2357). + * + * Mirrors the framework-level pattern from `packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts`. Verifies that: + * + * - `SqlXDescriptor.factory(...)` preserves the typed return at the direct call site; + * - the per-codec column helper threads that typed return through the `column()` packager into the `ColumnSpec` shape; + * - `satisfies ColumnHelperFor` (and the strict variant where applicable) ties the helper to its descriptor. + * + * Coverage selection: one void-param codec (`text`), one length-param codec (`char`), one precision-param codec (`timestamp`). The framework type tests already exercise the variance discipline at the abstract-class level. + */ + +import { + type CodecInstanceContext, + type ColumnHelperFor, + type ColumnHelperForStrict, + column, +} from '@prisma-next/framework-components/codec'; +import { expectTypeOf, test } from 'vitest'; +import { + type SqlCharCodec, + type SqlCharDescriptor, + type SqlTextCodec, + type SqlTextDescriptor, + type SqlTimestampCodec, + type SqlTimestampDescriptor, + sqlCharColumn, + sqlCharDescriptor, + sqlTextColumn, + sqlTextDescriptor, + sqlTimestampColumn, + sqlTimestampDescriptor, +} from '../../src/ast/sql-codecs'; + +test('sqlText: descriptor.factory() returns typed (ctx) => SqlTextCodec', () => { + const factory = sqlTextDescriptor.factory(); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqlTextCodec>(); +}); + +test('sqlText: column helper preserves typed codecFactory + undefined typeParams', () => { + const col = sqlTextColumn(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqlTextCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf(); +}); + +test('sqlChar: descriptor.factory(params) returns typed (ctx) => SqlCharCodec', () => { + const factory = sqlCharDescriptor.factory({ length: 36 }); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqlCharCodec>(); +}); + +test('sqlChar: column helper preserves typed codecFactory + length params', () => { + const col = sqlCharColumn({ length: 36 }); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqlCharCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ readonly length?: number }>(); +}); + +test('sqlChar: column helper accepts no-args call (default params)', () => { + const col = sqlCharColumn(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqlCharCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ readonly length?: number }>(); +}); + +test('sqlTimestamp: descriptor.factory(params) returns typed (ctx) => SqlTimestampCodec', () => { + const factory = sqlTimestampDescriptor.factory({ precision: 3 }); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqlTimestampCodec>(); +}); + +test('sqlTimestamp: column helper preserves typed codecFactory + precision params', () => { + const col = sqlTimestampColumn({ precision: 3 }); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqlTimestampCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ readonly precision?: number }>(); +}); + +sqlTextColumn satisfies ColumnHelperFor; +sqlTextColumn satisfies ColumnHelperForStrict; + +sqlCharColumn satisfies ColumnHelperFor; +sqlCharColumn satisfies ColumnHelperForStrict; + +sqlTimestampColumn satisfies ColumnHelperFor; +sqlTimestampColumn satisfies ColumnHelperForStrict; + +test('coarse satisfies catches wrong typeParams shape on sqlCharColumn', () => { + const brokenHelper = (length: number) => + column( + sqlCharDescriptor.factory({ length }), + sqlCharDescriptor.codecId, + { wrongKey: length }, + 'char', + ); + // @ts-expect-error -- typeParams shape doesn't satisfy ColumnHelperFor (missing `length`) + brokenHelper satisfies ColumnHelperFor; + // @ts-expect-error -- strict shape catches the same mismatch + brokenHelper satisfies ColumnHelperForStrict; +}); + +test('strict satisfies catches wrong codec wired in', () => { + // Wire the text descriptor's factory into the char descriptor's slot. Coarse satisfies passes (`undefined` is the typeParams shape mismatch — sqlText's params resolve to `undefined` while sqlChar expects `{ readonly length?: number }`), so this exercises both axes; we assert the strict failure for the codec mismatch. + const wrongCodecHelper = (length: number) => + column(sqlTextDescriptor.factory(), sqlCharDescriptor.codecId, { length }, 'char'); + wrongCodecHelper satisfies ColumnHelperFor; + // @ts-expect-error -- codec is SqlTextCodec, not SqlCharCodec + wrongCodecHelper satisfies ColumnHelperForStrict; +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts b/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts new file mode 100644 index 0000000000..7ac22927e8 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/test-codec.ts @@ -0,0 +1,58 @@ +/** + * Test-only helper that constructs a SQL-family `Codec` instance from author-side encode/decode functions. Replaces the legacy public `mkCodec()` factory (deleted under TML-2357); tests that need a stub codec for behavioural assertions instantiate one through this helper rather than going through `descriptor.factory(...)`. + */ +import type { JsonValue } from '@prisma-next/contract/types'; +import type { CodecTrait } from '@prisma-next/framework-components/codec'; +import type { Codec, SqlCodecCallContext } from '../../src/ast/codec-types'; + +type JsonRoundTripConfig = [TInput] extends [JsonValue] + ? { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + } + : { + encodeJson: (value: TInput) => JsonValue; + decodeJson: (json: JsonValue) => TInput; + }; + +export function defineTestCodec< + Id extends string, + const TTraits extends readonly CodecTrait[] = readonly [], + TWire = unknown, + TInput = unknown, +>( + config: { + typeId: Id; + targetTypes?: readonly string[]; + encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; + decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + traits?: TTraits; + } & JsonRoundTripConfig, +): Codec { + const identity = (v: unknown) => v; + const userEncode = config.encode; + const userDecode = config.decode; + const widenedConfig = config as { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + }; + return { + id: config.typeId, + encode: (value, ctx) => { + try { + return Promise.resolve(userEncode(value, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + decode: (wire, ctx) => { + try { + return Promise.resolve(userDecode(wire, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, + decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + } as Codec; +} diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/test-helpers.ts b/packages/2-sql/4-lanes/relational-core/test/ast/test-helpers.ts index 00e0a6aaff..dc47ffa45f 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/test-helpers.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/test-helpers.ts @@ -29,6 +29,7 @@ export function shiftParamRef(delta: number): (expr: ParamRef) => ParamRef { ParamRef.of(typeof expr.value === 'number' ? expr.value + delta : expr.value, { ...ifDefined('name', expr.name), ...ifDefined('codecId', expr.codecId), + ...ifDefined('refs', expr.refs), }); } diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/validate-param-refs.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/validate-param-refs.test.ts new file mode 100644 index 0000000000..4f34f4ce9e --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/ast/validate-param-refs.test.ts @@ -0,0 +1,102 @@ +import type { CodecDescriptor } from '@prisma-next/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { + AndExpr, + BinaryExpr, + ColumnRef, + ParamRef, + ProjectionItem, + SelectAst, + TableSource, +} from '../../src/ast/types'; +import { validateParamRefRefs } from '../../src/ast/validate-param-refs'; +import type { CodecDescriptorRegistry } from '../../src/query-lane-context'; + +const PARAMETERIZED_IDS = new Set(['sql/varchar@1', 'pgvector/vector@1']); + +const stubDescriptor = (codecId: string): CodecDescriptor => + ({ + codecId, + isParameterized: PARAMETERIZED_IDS.has(codecId), + }) as unknown as CodecDescriptor; + +const registry: CodecDescriptorRegistry = { + descriptorFor: (codecId) => stubDescriptor(codecId), + *values() { + for (const id of PARAMETERIZED_IDS) yield stubDescriptor(id); + }, + byTargetType: () => Object.freeze([]), +}; + +function selectWithWhere(...where: Parameters[0]): SelectAst { + return SelectAst.from(TableSource.named('user')) + .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) + .withWhere(AndExpr.of(where)); +} + +describe('validateParamRefRefs', () => { + it('passes when refs are present on a parameterized-codec ParamRef', () => { + const ref = ParamRef.of('a@b.com', { + name: 'p1', + codecId: 'sql/varchar@1', + refs: { table: 'user', column: 'email' }, + }); + const ast = selectWithWhere(BinaryExpr.eq(ColumnRef.of('user', 'email'), ref)); + + expect(() => validateParamRefRefs(ast, registry)).not.toThrow(); + }); + + it('passes when codecId is a non-parameterized id and refs are absent', () => { + const ref = ParamRef.of(42, { name: 'p1', codecId: 'sql/int@1' }); + const ast = selectWithWhere(BinaryExpr.eq(ColumnRef.of('user', 'age'), ref)); + + expect(() => validateParamRefRefs(ast, registry)).not.toThrow(); + }); + + it('passes when codecId is undefined (untyped ParamRef)', () => { + const ref = ParamRef.of('whatever'); + const ast = selectWithWhere(BinaryExpr.eq(ColumnRef.of('user', 'name'), ref)); + + expect(() => validateParamRefRefs(ast, registry)).not.toThrow(); + }); + + it('throws RUNTIME.PARAM_REF_REFS_REQUIRED when a parameterized-codec ParamRef lacks refs', () => { + const ref = ParamRef.of('hello', { name: 'p1', codecId: 'sql/varchar@1' }); + const ast = selectWithWhere(BinaryExpr.eq(ColumnRef.of('user', 'email'), ref)); + + expect(() => validateParamRefRefs(ast, registry)).toThrowError(/sql\/varchar@1/); + try { + validateParamRefRefs(ast, registry); + } catch (err) { + const error = err as { + code: string; + message: string; + details?: { codecId?: string; paramName?: string }; + }; + expect(error.code).toBe('RUNTIME.PARAM_REF_REFS_REQUIRED'); + expect(error.message).toContain("ParamRef 'p1'"); + expect(error.message).toContain('sql/varchar@1'); + expect(error.details?.codecId).toBe('sql/varchar@1'); + expect(error.details?.paramName).toBe('p1'); + } + }); + + it("uses '' label when ParamRef has no name", () => { + const ref = ParamRef.of([1, 2], { codecId: 'pgvector/vector@1' }); + const ast = selectWithWhere(BinaryExpr.eq(ColumnRef.of('post', 'embedding'), ref)); + + expect(() => validateParamRefRefs(ast, registry)).toThrowError(//); + }); + + it('passes when codecId is unknown to the registry (descriptorFor returns undefined)', () => { + const sparseRegistry: CodecDescriptorRegistry = { + descriptorFor: () => undefined, + *values() {}, + byTargetType: () => Object.freeze([]), + }; + const ref = ParamRef.of('hello', { name: 'p1', codecId: 'unknown/codec@1' }); + const ast = selectWithWhere(BinaryExpr.eq(ColumnRef.of('user', 'email'), ref)); + + expect(() => validateParamRefRefs(ast, sparseRegistry)).not.toThrow(); + }); +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/codec-descriptor-registry.test.ts b/packages/2-sql/4-lanes/relational-core/test/codec-descriptor-registry.test.ts new file mode 100644 index 0000000000..af37414345 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/codec-descriptor-registry.test.ts @@ -0,0 +1,63 @@ +import type { CodecDescriptor } from '@prisma-next/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import type { AnyCodecDescriptor } from '../src/ast/codec-types'; +import { buildCodecDescriptorRegistry } from '../src/codec-descriptor-registry'; + +const stub = (codecId: string, targetTypes: readonly string[]): AnyCodecDescriptor => + ({ + codecId, + traits: [], + targetTypes, + isParameterized: false, + paramsSchema: undefined, + factory: () => () => ({ id: codecId }) as never, + }) as unknown as AnyCodecDescriptor; + +describe('buildCodecDescriptorRegistry', () => { + it('descriptorFor returns the registered descriptor by codec id', () => { + const a = stub('lib/a@1', ['ta']); + const b = stub('lib/b@1', ['tb']); + const registry = buildCodecDescriptorRegistry([a, b]); + + expect(registry.descriptorFor('lib/a@1')).toBe(a as unknown as CodecDescriptor); + expect(registry.descriptorFor('lib/b@1')).toBe(b as unknown as CodecDescriptor); + }); + + it('descriptorFor returns undefined for an unknown codec id', () => { + const registry = buildCodecDescriptorRegistry([stub('lib/a@1', ['ta'])]); + expect(registry.descriptorFor('lib/missing@1')).toBeUndefined(); + }); + + it('values() yields all registered descriptors in registration order', () => { + const a = stub('lib/a@1', ['ta']); + const b = stub('lib/b@1', ['tb']); + const c = stub('lib/c@1', ['tc']); + const registry = buildCodecDescriptorRegistry([a, b, c]); + + expect([...registry.values()]).toEqual([a, b, c]); + }); + + it('byTargetType groups descriptors that advertise the same target type', () => { + const a = stub('lib/a@1', ['shared']); + const b = stub('lib/b@1', ['shared', 'extra']); + const registry = buildCodecDescriptorRegistry([a, b]); + + expect(registry.byTargetType('shared')).toEqual([a, b]); + expect(registry.byTargetType('extra')).toEqual([b]); + }); + + it('byTargetType returns an empty frozen array for an unknown target type', () => { + const registry = buildCodecDescriptorRegistry([stub('lib/a@1', ['ta'])]); + const result = registry.byTargetType('unknown'); + expect(result).toEqual([]); + expect(Object.isFrozen(result)).toBe(true); + }); + + it('throws when a codec id is registered twice', () => { + const a = stub('lib/dup@1', ['ta']); + const a2 = stub('lib/dup@1', ['tb']); + expect(() => buildCodecDescriptorRegistry([a, a2])).toThrowError( + /Duplicate codec descriptor id: 'lib\/dup@1'/, + ); + }); +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/expression.test.ts b/packages/2-sql/4-lanes/relational-core/test/expression.test.ts index 7110c03a22..4b24311f49 100644 --- a/packages/2-sql/4-lanes/relational-core/test/expression.test.ts +++ b/packages/2-sql/4-lanes/relational-core/test/expression.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { ColumnRef, LiteralExpr, OperationExpr, ParamRef } from '../src/ast/types'; -import { buildOperation, type Expression, toExpr } from '../src/expression'; +import { ColumnRef, IdentifierRef, LiteralExpr, OperationExpr, ParamRef } from '../src/ast/types'; +import { buildOperation, type Expression, refsOf, toExpr } from '../src/expression'; const infixLowering = { targetFamily: 'sql', @@ -50,6 +50,51 @@ describe('toExpr', () => { expect(result).toBeInstanceOf(ParamRef); expect((result as ParamRef).value).toBe(value); }); + + it('threads refs onto the resulting ParamRef when provided', () => { + const result = toExpr('alice@example.com', 'sql/varchar@1', { + table: 'user', + column: 'email', + }); + expect(result).toBeInstanceOf(ParamRef); + const ref = result as ParamRef; + expect(ref.codecId).toBe('sql/varchar@1'); + expect(ref.refs).toEqual({ table: 'user', column: 'email' }); + }); +}); + +describe('refsOf', () => { + it('reads refs from a ColumnRef AST', () => { + const expr: Expression<{ codecId: 'pg/text@1'; nullable: false }> = { + returnType: { codecId: 'pg/text@1', nullable: false }, + buildAst: () => ColumnRef.of('user', 'email'), + }; + expect(refsOf(expr)).toEqual({ table: 'user', column: 'email' }); + }); + + it('reads refs from an Expression wrapper that carries refs metadata directly', () => { + const expr: Expression<{ codecId: 'pg/text@1'; nullable: false }> & { + refs: { table: string; column: string }; + } = { + returnType: { codecId: 'pg/text@1', nullable: false }, + buildAst: () => IdentifierRef.of('email'), + refs: { table: 'user', column: 'email' }, + }; + expect(refsOf(expr)).toEqual({ table: 'user', column: 'email' }); + }); + + it('returns undefined for an Expression backed by a non-column AST and no refs metadata', () => { + const expr: Expression<{ codecId: 'pg/text@1'; nullable: false }> = { + returnType: { codecId: 'pg/text@1', nullable: false }, + buildAst: () => LiteralExpr.of('foo'), + }; + expect(refsOf(expr)).toBeUndefined(); + }); + + it('returns undefined for raw values', () => { + expect(refsOf('plain string')).toBeUndefined(); + expect(refsOf(42)).toBeUndefined(); + }); }); describe('buildOperation', () => { diff --git a/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts b/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts new file mode 100644 index 0000000000..fe939eebeb --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts @@ -0,0 +1,112 @@ +/** + * Constructive type tests at the descriptor round-trip layer (TML-2357). + * + * The descriptor surface is the canonical path from a `CodecDescriptor

` instance to its resolved `Codec` shape. These tests pin that round-trip end-to-end at the SQL base codec layer: given a concrete descriptor, the resolved codec exactly equals `Codec` with the literal Id, the readonly trait tuple preserved, and Wire / Input fixed. + * + * Coverage: + * - one non-parameterized SQL base codec (`sqlInt`) + * - one parameterized SQL base codec (`sqlVarchar`) + * - one inline "extension" descriptor exercising a custom Wire/Input pair via {@link CodecDescriptorImpl}; this stands in for the extension-codec axis covered concretely by `pgVector` in the per-target descriptor flow tests (T0.D.2). + * + * Negative coverage uses `// @ts-expect-error` so a regression in the round-trip mapping (e.g. trait widening, Wire/Input drift, codec id mismatch) breaks the test type-check rather than passing silently. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; +import { + type Codec, + type CodecCallContext, + CodecDescriptorImpl, + CodecImpl, + type CodecInstanceContext, + type CodecTrait, + voidParamsSchema, +} from '@prisma-next/framework-components/codec'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { expectTypeOf, test } from 'vitest'; +import type { + SqlIntCodec, + SqlVarcharCodec, + sqlIntDescriptor, + sqlVarcharDescriptor, +} from '../src/ast/sql-codecs'; + +type ResolvedCodec = D extends { + factory: (...args: never[]) => (ctx: CodecInstanceContext) => infer R; +} + ? R + : never; + +test('non-parameterized SQL base codec — sqlInt round-trips to typed Codec', () => { + type Resolved = ResolvedCodec; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toExtend< + Codec<'sql/int@1', readonly ['equality', 'order', 'numeric'], number, number> + >(); +}); + +test('parameterized SQL base codec — sqlVarchar round-trips to typed Codec', () => { + type Resolved = ResolvedCodec; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toExtend< + Codec<'sql/varchar@1', readonly ['equality', 'order', 'textual'], string, string> + >(); +}); + +class TestVectorCodec extends CodecImpl<'test/vector@1', readonly ['equality'], string, number[]> { + async encode(value: number[], _ctx: CodecCallContext): Promise { + return `[${value.join(',')}]`; + } + async decode(_wire: string, _ctx: CodecCallContext): Promise { + return []; + } + encodeJson(value: number[]): JsonValue { + return value; + } + decodeJson(json: JsonValue): number[] { + return json as number[]; + } +} + +class TestVectorDescriptor extends CodecDescriptorImpl { + override readonly codecId = 'test/vector@1' as const; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['vector'] as const; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => TestVectorCodec { + return () => new TestVectorCodec(this); + } +} + +const testVectorDescriptor = new TestVectorDescriptor(); + +test('extension-style descriptor round-trips with custom Wire/Input', () => { + type Resolved = ResolvedCodec; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toExtend< + Codec<'test/vector@1', readonly ['equality'], string, number[]> + >(); +}); + +test('wrong codec id breaks the round-trip equality', () => { + type Resolved = ResolvedCodec; + expectTypeOf().toEqualTypeOf<'sql/int@1'>(); + // @ts-expect-error -- resolved codec id is `sql/int@1`, not `sql/varchar@1` + expectTypeOf().toEqualTypeOf<'sql/varchar@1'>(); +}); + +test('wrong wire type breaks the round-trip equality', () => { + type ExtractWire = + C extends Codec ? W : never; + type ResolvedWire = ExtractWire>; + expectTypeOf().toEqualTypeOf(); + // @ts-expect-error -- sqlInt wire is `number`, not `string` + expectTypeOf().toEqualTypeOf(); +}); + +test('widened trait union breaks the round-trip equality', () => { + // Read traits off the descriptor — the codec instance carries them on an optional phantom slot which is not always preserved by inference. + type DescTraits = (typeof sqlIntDescriptor)['traits']; + expectTypeOf().toEqualTypeOf(); + // @ts-expect-error -- sqlInt traits include `numeric`, not just `['equality']` + expectTypeOf().toEqualTypeOf(); +}); diff --git a/packages/2-sql/4-lanes/relational-core/test/utils.ts b/packages/2-sql/4-lanes/relational-core/test/utils.ts index a7ca656611..c924853c95 100644 --- a/packages/2-sql/4-lanes/relational-core/test/utils.ts +++ b/packages/2-sql/4-lanes/relational-core/test/utils.ts @@ -2,12 +2,10 @@ import type { Contract } from '@prisma-next/contract/types'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { createSqlOperationRegistry } from '@prisma-next/sql-operations'; import type { Adapter, LoweredStatement, SelectAst } from '../src/exports/ast'; -import { createCodecRegistry } from '../src/exports/ast'; import type { ExecutionContext } from '../src/exports/query-lane-context'; /** - * Creates a stub adapter for testing. - * This helper DRYs up the common pattern of adapter creation in tests. + * Creates a stub adapter for testing. This helper DRYs up the common pattern of adapter creation in tests. */ export function createStubAdapter(): Adapter, LoweredStatement> { return { @@ -15,9 +13,6 @@ export function createStubAdapter(): Adapter, Lo id: 'stub-profile', target: 'postgres', capabilities: {}, - codecs() { - return createCodecRegistry(); - }, readMarkerStatement: () => ({ sql: '', params: [] }), parseMarkerRow: () => { throw new Error('stub adapter does not implement parseMarkerRow'); @@ -31,23 +26,18 @@ export function createStubAdapter(): Adapter, Lo } /** - * Creates an ExecutionContext for testing. - * This helper DRYs up the common pattern of context creation in tests. - * Note: This creates an ExecutionContext, so it doesn't include an adapter. + * Creates an ExecutionContext for testing. This helper DRYs up the common pattern of context creation in tests. Note: This creates an ExecutionContext, so it doesn't include an adapter. * * @param contract - The SQL contract */ export function createTestContext>( contract: TContract, ): ExecutionContext { - const codecRegistry = createCodecRegistry(); - return { contract, - codecs: codecRegistry, contractCodecs: { forColumn: () => undefined, - forCodecId: (id) => codecRegistry.get(id), + forCodecId: () => undefined, }, codecDescriptors: { descriptorFor: () => undefined, diff --git a/packages/2-sql/4-lanes/relational-core/tsdown.config.ts b/packages/2-sql/4-lanes/relational-core/tsdown.config.ts index 6a4a984e27..5ab29c76ef 100644 --- a/packages/2-sql/4-lanes/relational-core/tsdown.config.ts +++ b/packages/2-sql/4-lanes/relational-core/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'src/exports/types.ts', 'src/exports/errors.ts', 'src/exports/ast.ts', + 'src/exports/codec-descriptor-registry.ts', 'src/exports/expression.ts', 'src/exports/plan.ts', 'src/exports/query-lane-context.ts', diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts index 8f993ff888..f23e6d00a4 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts @@ -110,6 +110,20 @@ export function combineWhereExprs(exprs: readonly AstExpression[]): AstExpressio return AndExpr.of(exprs); } +/** + * Same uniqueness rule as the field-proxy's `findUniqueNamespaceFor`: when exactly one namespace owns a top-level field, the binding is unambiguous. Used by `select('col', ...)` to attach `refs` metadata to the resulting `ProjectionItem` while keeping the AST as `IdentifierRef` (so SQL renders unchanged). + */ +function findUniqueNamespaceFor(scope: Scope, fieldName: string): string | undefined { + let found: string | undefined; + for (const [namespace, fields] of Object.entries(scope.namespaces)) { + if (Object.hasOwn(fields, fieldName)) { + if (found !== undefined) return undefined; + found = namespace; + } + } + return found; +} + export function buildSelectAst(state: BuilderState): SelectAst { const where = combineWhereExprs(state.where); return new SelectAst({ @@ -225,7 +239,9 @@ export function resolveSelectArgs( for (const colName of args as string[]) { const field = scope.topLevel[colName]; if (!field) throw new Error(`Column "${colName}" not found in scope`); - projections.push(ProjectionItem.of(colName, IdentifierRef.of(colName), field.codecId)); + const namespace = findUniqueNamespaceFor(scope, colName); + const refs = namespace ? { table: namespace, column: colName } : undefined; + projections.push(ProjectionItem.of(colName, IdentifierRef.of(colName), field.codecId, refs)); newRowFields[colName] = field; } return { projections, newRowFields }; diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/expression-impl.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/expression-impl.ts index 16d1b8cedb..da0e8fbbfb 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/expression-impl.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/expression-impl.ts @@ -3,17 +3,23 @@ import type { Expression } from '@prisma-next/sql-relational-core/expression'; import type { ScopeField } from '../scope'; /** - * Runtime wrapper around a relational-core AST expression node. - * Carries ScopeField metadata (codecId, nullable) so aggregate-like - * combinators can propagate the input codec onto their result. + * Runtime wrapper around a relational-core AST expression node. Carries ScopeField metadata (codecId, nullable) so aggregate-like combinators can propagate the input codec onto their result. + * + * `refs` records the column-bound binding (`{ table, column }`) when known — the field-proxy populates it for both the namespaced form (`f.user.email` → `ColumnRef`) and the top-level shortcut (`f.email` → `IdentifierRef` + refs metadata). Encode-side dispatch and the `validateParamRefRefs` pass read it via `refsOf(expression)`. */ export class ExpressionImpl implements Expression { private readonly ast: AstExpression; readonly returnType: T; + readonly refs: { readonly table: string; readonly column: string } | undefined; - constructor(ast: AstExpression, returnType: T) { + constructor( + ast: AstExpression, + returnType: T, + refs?: { readonly table: string; readonly column: string }, + ) { this.ast = ast; this.returnType = returnType; + this.refs = refs; } buildAst(): AstExpression { diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/field-proxy.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/field-proxy.ts index bb13c5b3b9..fc1825f4b5 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/field-proxy.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/field-proxy.ts @@ -3,12 +3,31 @@ import type { FieldProxy } from '../expression'; import type { Scope, ScopeTable } from '../scope'; import { ExpressionImpl } from './expression-impl'; +/** + * For a top-level field name, find the namespace (table alias) that contributed it. When exactly one namespace owns the field, the top-level binding is unambiguously column-bound and we record that `(table, column)` pair on the `ExpressionImpl` so encode-side dispatch (`forColumn`) and the `validateParamRefRefs` pass can find it. The AST stays as `IdentifierRef` to preserve SQL rendering — adapters render top-level + * identifiers without an explicit table qualifier — so this change is metadata-only and produces no SQL drift. + */ +function findUniqueNamespaceFor(scope: Scope, fieldName: string): string | undefined { + let found: string | undefined; + for (const [namespace, fields] of Object.entries(scope.namespaces)) { + if (Object.hasOwn(fields, fieldName)) { + if (found !== undefined) return undefined; + found = namespace; + } + } + return found; +} + export function createFieldProxy(scope: S): FieldProxy { return new Proxy({} as FieldProxy, { get(_target, prop: string) { if (Object.hasOwn(scope.topLevel, prop)) { const topField = scope.topLevel[prop]; - if (topField) return new ExpressionImpl(IdentifierRef.of(prop), topField); + if (topField) { + const namespace = findUniqueNamespaceFor(scope, prop); + const refs = namespace ? { table: namespace, column: prop } : undefined; + return new ExpressionImpl(IdentifierRef.of(prop), topField, refs); + } } if (Object.hasOwn(scope.namespaces, prop)) { diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts index b1ab908354..4768d516e2 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts @@ -12,7 +12,7 @@ import { OrExpr, SubqueryExpr, } from '@prisma-next/sql-relational-core/ast'; -import { toExpr } from '@prisma-next/sql-relational-core/expression'; +import { refsOf, toExpr } from '@prisma-next/sql-relational-core/expression'; import type { AggregateFunctions, AggregateOnlyFunctions, @@ -26,8 +26,7 @@ import type { QueryContext, ScopeField, Subquery } from '../scope'; import { ExpressionImpl } from './expression-impl'; type CodecTypes = Record; -// Runtime-level ExprOrVal — accepts any codec, any nullability. Concrete codec -// typing lives on the public BuiltinFunctions surface in `../expression`. +// Runtime-level ExprOrVal — accepts any codec, any nullability. Concrete codec typing lives on the public BuiltinFunctions surface in `../expression`. type ExprOrVal = CodecExpression< CodecId, N, @@ -39,16 +38,44 @@ const BOOL_FIELD: BooleanCodecType = { codecId: 'pg/bool@1', nullable: false }; const resolve = toExpr; /** - * Resolves an Expression via `buildAst()`, or wraps a raw value as a - * `LiteralExpr` — an SQL literal inlined into the query text, not a bound - * parameter. + * Resolve a binary-comparison operand into an AST expression, threading the column-bound side's `codecId` + `refs` to the raw-value side. * - * Used for `and` / `or` operands. The usual operand is an `Expression` - * (e.g. the result of `fns.eq`), which this function passes through by calling - * `buildAst()`. The only time the raw-value branch fires is when the caller - * writes `fns.and(true, x)` or similar — inlining `TRUE`/`FALSE` literals - * lets the SQL planner statically simplify `TRUE AND x` to `x`, which it - * cannot do for an opaque `ParamRef`. + * For `fns.eq(f.email, 'alice@example.com')`, `f.email` is the column-bound expression carrying a `ColumnRef` AST and a `returnType.codecId` (`pg/varchar@1`); the raw string operand has no codec context. By deriving the codec context from the column-bound side and forwarding it via `toExpr(value, codecId, refs)`, the resulting `ParamRef` carries the column refs that encode-side `forColumn` dispatch needs (and that the + * validator pass requires for parameterized codec ids like `pg/varchar@1` with a length parameter). + */ +function resolveOperand( + operand: ExprOrVal, + otherCodecId?: string, + otherRefs?: { table: string; column: string }, +): AstExpression { + if (isExpressionLike(operand)) return operand.buildAst(); + return toExpr(operand, otherCodecId, otherRefs); +} + +function isExpressionLike( + value: unknown, +): value is { buildAst: () => AstExpression; returnType?: { codecId: string } } { + return ( + typeof value === 'object' && + value !== null && + 'buildAst' in value && + typeof (value as { buildAst: unknown }).buildAst === 'function' + ); +} + +function operandCodecId(operand: ExprOrVal): string | undefined { + if (!isExpressionLike(operand)) return undefined; + return (operand as { returnType?: { codecId: string } }).returnType?.codecId; +} + +function operandRefs(operand: ExprOrVal): { table: string; column: string } | undefined { + return refsOf(operand); +} + +/** + * Resolves an Expression via `buildAst()`, or wraps a raw value as a `LiteralExpr` — an SQL literal inlined into the query text, not a bound parameter. + * + * Used for `and` / `or` operands. The usual operand is an `Expression` (e.g. the result of `fns.eq`), which this function passes through by calling `buildAst()`. The only time the raw-value branch fires is when the caller writes `fns.and(true, x)` or similar — inlining `TRUE`/`FALSE` literals lets the SQL planner statically simplify `TRUE AND x` to `x`, which it cannot do for an opaque `ParamRef`. */ function toLiteralExpr(value: unknown): AstExpression { if ( @@ -66,20 +93,34 @@ function boolExpr(astNode: AstExpression): ExpressionImpl { return new ExpressionImpl(astNode, BOOL_FIELD); } +function binaryWithSharedCodec( + a: ExprOrVal, + b: ExprOrVal, + build: (left: AstExpression, right: AstExpression) => AstExpression, +): AstExpression { + const aCodecId = operandCodecId(a); + const bCodecId = operandCodecId(b); + const aRefs = operandRefs(a); + const bRefs = operandRefs(b); + const left = resolveOperand(a, bCodecId, bRefs); + const right = resolveOperand(b, aCodecId, aRefs); + return build(left, right); +} + function eq(a: ExprOrVal, b: ExprOrVal): ExpressionImpl { if (b === null) return boolExpr(NullCheckExpr.isNull(resolve(a))); if (a === null) return boolExpr(NullCheckExpr.isNull(resolve(b))); - return boolExpr(new BinaryExpr('eq', resolve(a), resolve(b))); + return boolExpr(binaryWithSharedCodec(a, b, (l, r) => new BinaryExpr('eq', l, r))); } function ne(a: ExprOrVal, b: ExprOrVal): ExpressionImpl { if (b === null) return boolExpr(NullCheckExpr.isNotNull(resolve(a))); if (a === null) return boolExpr(NullCheckExpr.isNotNull(resolve(b))); - return boolExpr(new BinaryExpr('neq', resolve(a), resolve(b))); + return boolExpr(binaryWithSharedCodec(a, b, (l, r) => new BinaryExpr('neq', l, r))); } function comparison(a: ExprOrVal, b: ExprOrVal, op: BinaryOp): ExpressionImpl { - return boolExpr(new BinaryExpr(op, resolve(a), resolve(b))); + return boolExpr(binaryWithSharedCodec(a, b, (l, r) => new BinaryExpr(op, l, r))); } function inOrNotIn( @@ -88,10 +129,12 @@ function inOrNotIn( op: 'in' | 'notIn', ): ExpressionImpl { const left = expr.buildAst(); + const leftCodecId = expr.returnType.codecId; + const leftRefs = refsOf(expr); const binaryFn = op === 'in' ? BinaryExpr.in : BinaryExpr.notIn; if (Array.isArray(valuesOrSubquery)) { - const refs = valuesOrSubquery.map((v) => resolve(v)); + const refs = valuesOrSubquery.map((v) => resolveOperand(v, leftCodecId, leftRefs)); return boolExpr(binaryFn(left, ListExpression.of(refs))); } return boolExpr(binaryFn(left, SubqueryExpr.of(valuesOrSubquery.buildAst()))); diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts index e11954d942..5a2b2b8fa2 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts @@ -41,11 +41,19 @@ function buildParamValues( const params: Record = {}; for (const [col, value] of Object.entries(values)) { const column = table.columns[col]; - params[col] = ParamRef.of(value, column ? { codecId: column.codecId } : undefined); + params[col] = ParamRef.of( + value, + column ? { codecId: column.codecId, refs: { table: tableName, column: col } } : undefined, + ); } for (const def of ctx.applyMutationDefaults({ op, table: tableName, values })) { const column = table.columns[def.column]; - params[def.column] = ParamRef.of(def.value, column ? { codecId: column.codecId } : undefined); + params[def.column] = ParamRef.of( + def.value, + column + ? { codecId: column.codecId, refs: { table: tableName, column: def.column } } + : undefined, + ); } return params; } diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/field-proxy.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/field-proxy.test.ts index eee800f369..7e3150b791 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/field-proxy.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/field-proxy.test.ts @@ -45,4 +45,25 @@ describe('createFieldProxy', () => { const proxy = createFieldProxy(usersScope); expect((proxy as Record)['nonexistent']).toBeUndefined(); }); + + it('attaches refs metadata for top-level fields backed by a unique namespace', () => { + const proxy = createFieldProxy(usersScope); + const idExpr = proxy.id as ExpressionImpl; + + expect(idExpr.refs).toEqual({ table: 'users', column: 'id' }); + }); + + it('omits refs for top-level fields when multiple namespaces own the field', () => { + const ambiguousScope = { + topLevel: { name: { codecId: 'pg/text@1', nullable: false } }, + namespaces: { + users: { name: { codecId: 'pg/text@1', nullable: false } }, + members: { name: { codecId: 'pg/text@1', nullable: false } }, + }, + } as const; + const proxy = createFieldProxy(ambiguousScope); + const nameExpr = proxy.name as ExpressionImpl; + + expect(nameExpr.refs).toBeUndefined(); + }); }); diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts index 0ee9fd1f52..83eb27d8d8 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts @@ -154,6 +154,45 @@ describe('createFunctions', () => { }); }); + describe('refs propagation', () => { + it('eq(field, value) propagates refs from the column-bound left side onto the ParamRef', () => { + const result = fns.eq(f().email, 'alice@example.com'); + const ast = result.buildAst() as BinaryExpr; + const right = ast.right as ParamRef; + + expect(right).toBeInstanceOf(ParamRef); + expect(right.refs).toEqual({ table: 'users', column: 'email' }); + }); + + it('eq(value, field) propagates refs from the column-bound right side onto the ParamRef', () => { + const result = fns.eq('alice@example.com', f().email); + const ast = result.buildAst() as BinaryExpr; + const left = ast.left as ParamRef; + + expect(left).toBeInstanceOf(ParamRef); + expect(left.refs).toEqual({ table: 'users', column: 'email' }); + }); + + it('comparison operators propagate refs onto value-side ParamRefs', () => { + const result = fns.gt(f().id, 5); + const ast = result.buildAst() as BinaryExpr; + const right = ast.right as ParamRef; + + expect(right.refs).toEqual({ table: 'users', column: 'id' }); + }); + + it('in() propagates refs onto every value ParamRef in the list', () => { + const result = fns.in(f().email, ['a@x', 'b@x', 'c@x']); + const ast = result.buildAst() as BinaryExpr; + const list = ast.right as ListExpression; + + for (const value of list.values) { + expect(value).toBeInstanceOf(ParamRef); + expect((value as ParamRef).refs).toEqual({ table: 'users', column: 'email' }); + } + }); + }); + describe('in / notIn', () => { it('in with array produces BinaryExpr with ListExpression of ParamRefs', () => { const result = fns.in(f().id, [1, 2, 3]); diff --git a/packages/2-sql/5-runtime/src/codecs/alias-resolver.ts b/packages/2-sql/5-runtime/src/codecs/alias-resolver.ts new file mode 100644 index 0000000000..bb8b411cf5 --- /dev/null +++ b/packages/2-sql/5-runtime/src/codecs/alias-resolver.ts @@ -0,0 +1,34 @@ +import type { AnyFromSource, AnyQueryAst } from '@prisma-next/sql-relational-core/ast'; + +/** + * Build a map from query-local table aliases to their underlying source table names. + * + * Self-joins like `db.sql.post.as('p1').innerJoin(db.sql.post.as('p2'), …)` produce `ColumnRef`s whose `table` is the alias (`p1`, `p2`) — the SQL renderer needs the alias for `SELECT p1.id, …`. Codec dispatch keys `byColumn` by the underlying source table, so aliases must be resolved back to the source name for `forColumn(...)` to hit. Tables that already use their canonical name (no alias) are also entered so a single + * lookup works for both shapes. + */ +function buildAliasMap(ast: AnyQueryAst): ReadonlyMap { + const aliases = new Map(); + const recordSource = (source: AnyFromSource): void => { + if (source.kind === 'table-source') { + const key = source.alias ?? source.name; + aliases.set(key, source.name); + } else { + aliases.set(source.alias, source.alias); + } + }; + if (ast.kind === 'select') { + recordSource(ast.from); + for (const join of ast.joins ?? []) { + recordSource(join.source); + } + } else { + recordSource(ast.table); + } + return aliases; +} + +export function makeAliasResolver(ast: AnyQueryAst | undefined): (alias: string) => string { + if (!ast) return (alias) => alias; + const map = buildAliasMap(ast); + return (alias) => map.get(alias) ?? alias; +} diff --git a/packages/2-sql/5-runtime/src/codecs/decoding.ts b/packages/2-sql/5-runtime/src/codecs/decoding.ts index f6c186bd4b..b0e0c74b46 100644 --- a/packages/2-sql/5-runtime/src/codecs/decoding.ts +++ b/packages/2-sql/5-runtime/src/codecs/decoding.ts @@ -7,14 +7,12 @@ import { import type { AnyQueryAst, Codec, - CodecRegistry, ContractCodecRegistry, ProjectionItem, SqlCodecCallContext, } from '@prisma-next/sql-relational-core/ast'; import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; -import type { JsonSchemaValidatorRegistry } from '@prisma-next/sql-relational-core/query-lane-context'; -import { validateJsonValue } from './json-schema-validation'; +import { makeAliasResolver } from './alias-resolver'; type ColumnRef = { table: string; column: string }; @@ -44,40 +42,39 @@ function projectionListFromAst(ast: AnyQueryAst): ReadonlyArray /** * Resolve the per-cell codec for a projection item. * - * Phase B: when a `(table, column)` ref is available for the projection, - * prefer `contractCodecs.forColumn(table, column)` — that's the per- - * instance resolved codec materialized from the codec descriptor's - * factory at context-construction time (carries any per-instance state - * such as the compiled JSON-Schema validator). When the projection - * resolves to a non-`column-ref` expression (computed projections, raw - * SQL aliases) but still carries a codec id (ADR 205 stamps every - * `ProjectionItem` with the producer's codec id), fall back to the - * codec-id-keyed `forCodecId(codecId)` lookup, which itself falls back - * to the legacy `CodecRegistry` for codec ids the contract walk - * couldn't resolve. + * When a `(table, column)` ref is available — either implicit on a `column-ref` expression or carried explicitly via `item.refs` for column-bound non-`column-ref` projections — prefer `contractCodecs.forColumn(table, column)`: that returns the per-instance codec materialized from the descriptor's factory for that column, encoding any per-instance state (typeParams like vector length, schema validators, etc.). * - * Codec-registry-unification spec § AC-4. + * The wrong-instance risk for parameterized codecs is closed off structurally: + * + * 1. `buildContractCodecRegistry` pre-populates `byCodecId` with one canonical instance per non-parameterized descriptor; parameterized descriptors are intentionally absent. 2. `forCodecId` rejects ambiguous parameterized fallbacks (`ambiguousCodecIds`). 3. The non-ambiguous parameterized case stores the column-correct per-instance codec under `byCodecId`, so the fall-through still resolves to the right instance. + * + * The `forCodecId` fallback otherwise covers projections that are *not* column-bound (computed projections, raw SQL aliases) but still carry a `codecId` (ADR 205 stamps every `ProjectionItem` with the producer's codec id). + * + * Codec-registry-unification spec § AC-4 / AC-5. */ function resolveProjectionCodec( item: ProjectionItem, - registry: CodecRegistry, contractCodecs: ContractCodecRegistry | undefined, + aliasResolver: (alias: string) => string, ): Codec | undefined { - if (item.expr.kind === 'column-ref' && contractCodecs) { - const byColumn = contractCodecs.forColumn(item.expr.table, item.expr.column); - if (byColumn) return byColumn; + if (contractCodecs) { + if (item.expr.kind === 'column-ref') { + const byColumn = contractCodecs.forColumn(aliasResolver(item.expr.table), item.expr.column); + // Only honour `byColumn` when its codec id agrees with `item.codecId`. They can legitimately disagree when an `OperationExpr`-shaped projection carries a single inner column-ref but transforms the value's codec (e.g. `cosineDistance(col, x)` projects `pg/float8@1` while the inner column-ref points at a `pg/vector@1` column). + if (byColumn && (item.codecId === undefined || byColumn.id === item.codecId)) return byColumn; + } else if (item.refs) { + const byColumn = contractCodecs.forColumn(aliasResolver(item.refs.table), item.refs.column); + if (byColumn && (item.codecId === undefined || byColumn.id === item.codecId)) return byColumn; + } } if (item.codecId) { - const fromContract = contractCodecs?.forCodecId(item.codecId); - if (fromContract) return fromContract; - return registry.get(item.codecId); + return contractCodecs?.forCodecId(item.codecId); } return undefined; } function buildDecodeContext( plan: SqlExecutionPlan, - registry: CodecRegistry, contractCodecs: ContractCodecRegistry | undefined, ): DecodeContext { if (!isAstBackedPlan(plan)) { @@ -103,17 +100,26 @@ function buildDecodeContext( const codecs = new Map(); const columnRefs = new Map(); const includeAliases = new Set(); + const aliasResolver = makeAliasResolver(plan.ast); for (const item of projection) { aliases.push(item.alias); - const codec = resolveProjectionCodec(item, registry, contractCodecs); + const codec = resolveProjectionCodec(item, contractCodecs, aliasResolver); if (codec) { codecs.set(item.alias, codec); } if (item.expr.kind === 'column-ref') { - columnRefs.set(item.alias, { table: item.expr.table, column: item.expr.column }); + columnRefs.set(item.alias, { + table: aliasResolver(item.expr.table), + column: item.expr.column, + }); + } else if (item.refs) { + columnRefs.set(item.alias, { + table: aliasResolver(item.refs.table), + column: item.refs.column, + }); } else if (item.expr.kind === 'subquery' || item.expr.kind === 'json-array-agg') { includeAliases.add(item.alias); } @@ -131,10 +137,6 @@ function previewWireValue(wireValue: unknown): string { return String(wireValue).substring(0, WIRE_PREVIEW_LIMIT); } -function isJsonSchemaValidationError(error: unknown): boolean { - return isRuntimeError(error) && error.code === 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED'; -} - function wrapDecodeFailure( error: unknown, alias: string, @@ -197,25 +199,16 @@ function decodeIncludeAggregate(alias: string, wireValue: unknown): unknown { } /** - * Decodes a single field. Single-armed: every cell takes the same path — - * `codec.decode → await → JSON-Schema validate → return plain value` — so - * sync- and async-authored codecs are indistinguishable to callers. + * Decodes a single field. Single-armed: every cell takes the same path — `codec.decode → await → return plain value` — so sync- and async-authored codecs are indistinguishable to callers. JSON-Schema validation, when required, lives inside the resolved codec's `decode` body (e.g. `arktype-json` validates against its rehydrated schema and throws `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED` from `decode` directly); there is + * no separate validator-registry pass. * - * The row-level `rowCtx` is repackaged into a per-cell - * `SqlCodecCallContext` whose `column = { table, name }` is a structural - * projection of the per-cell `ColumnRef = { table, column }` resolved from - * the AST-backed `DecodeContext` (the same resolution `wrapDecodeFailure` - * uses for envelope construction — one resolution per cell, two consumers). - * Cells the runtime cannot resolve to a single underlying column (aggregate - * aliases, computed projections without a simple ref) get - * `column: undefined`, matching the spec contract that the runtime never - * silently defaults this field. + * The row-level `rowCtx` is repackaged into a per-cell `SqlCodecCallContext` whose `column = { table, name }` is a structural projection of the per-cell `ColumnRef = { table, column }` resolved from the AST-backed `DecodeContext` (the same resolution `wrapDecodeFailure` uses for envelope construction — one resolution per cell, two consumers). Cells the runtime cannot resolve to a single underlying column (aggregate + * aliases, computed projections without a simple ref) get `column: undefined`, matching the spec contract that the runtime never silently defaults this field. */ async function decodeField( alias: string, wireValue: unknown, decodeCtx: DecodeContext, - jsonValidators: JsonSchemaValidatorRegistry | undefined, rowCtx: SqlCodecCallContext, ): Promise { if (wireValue === null) { @@ -229,15 +222,8 @@ async function decodeField( const ref = decodeCtx.columnRefs.get(alias); - // Per-cell ctx: the cell-level `column` is a `SqlColumnRef = { table, name }` - // projection of the resolved `ColumnRef = { table, column }` (same - // resolution `wrapDecodeFailure` uses below — no double work). Cells the - // runtime cannot resolve (aggregate aliases, computed projections without - // a simple ref) drop the `column` field entirely — explicitly cleared so - // a previously-populated `rowCtx.column` cannot leak through to unrelated - // cells. Destructuring (rather than `column: undefined`) is required - // because `SqlCodecCallContext.column` is declared `column?: SqlColumnRef` - // under `exactOptionalPropertyTypes`. + // Per-cell ctx: the cell-level `column` is a `SqlColumnRef = { table, name }` projection of the resolved `ColumnRef = { table, column }` (same resolution `wrapDecodeFailure` uses below — no double work). Cells the runtime cannot resolve (aggregate aliases, computed projections without a simple ref) drop the `column` field entirely — explicitly cleared so a previously-populated `rowCtx.column` cannot leak through to + // unrelated cells. Destructuring (rather than `column: undefined`) is required because `SqlCodecCallContext.column` is declared `column?: SqlColumnRef` under `exactOptionalPropertyTypes`. let cellCtx: SqlCodecCallContext; if (ref) { cellCtx = { ...rowCtx, column: { table: ref.table, name: ref.column } }; @@ -246,55 +232,36 @@ async function decodeField( cellCtx = rowCtxWithoutColumn; } - let decoded: unknown; try { - decoded = await codec.decode(wireValue, cellCtx); + return await codec.decode(wireValue, cellCtx); } catch (error) { - wrapDecodeFailure(error, alias, ref, codec, wireValue); - } - - if (jsonValidators && ref) { - try { - validateJsonValue(jsonValidators, ref.table, ref.column, decoded, 'decode', codec.id); - } catch (error) { - if (isJsonSchemaValidationError(error)) throw error; - wrapDecodeFailure(error, alias, ref, codec, wireValue); + // Codec-authored runtime envelopes (e.g. `RUNTIME.DECODE_FAILED` thrown from inside the codec body, or `RUNTIME.ABORTED` raised via `CodecCallContext.signal` per ADR 207) carry their own per-codec context — wrapping them again would erase that context and coerce the abort intent into a generic decode failure. Pass them through unchanged; only foreign errors get the `wrapDecodeFailure` envelope. + if (isRuntimeError(error)) { + throw error; } + wrapDecodeFailure(error, alias, ref, codec, wireValue); } - - return decoded; } /** - * Decodes a row by dispatching all per-cell codec calls concurrently via - * `Promise.all`. Each cell follows the single-armed `decodeField` path. - * Failures are wrapped in `RUNTIME.DECODE_FAILED` with `{ table, column, - * codec }` (or `{ alias, codec }` when no column ref is resolvable) and the - * original error attached on `cause`. + * Decodes a row by dispatching all per-cell codec calls concurrently via `Promise.all`. Each cell follows the single-armed `decodeField` path. Failures are wrapped in `RUNTIME.DECODE_FAILED` with `{ table, column, codec }` (or `{ alias, codec }` when no column ref is resolvable) and the original error attached on `cause`. * * When `rowCtx.signal` is provided: * - * - **Already-aborted at entry** short-circuits with `RUNTIME.ABORTED` - * (`{ phase: 'decode' }`) before any `codec.decode` call is made. - * - **Mid-flight aborts** race the per-cell `Promise.all` against the - * signal so the runtime returns promptly even when codec bodies ignore - * it. In-flight bodies that ignore the signal complete in the - * background (cooperative cancellation). - * - Existing `RUNTIME.DECODE_FAILED` envelopes from codec bodies pass - * through unchanged (no double wrap). + * - **Already-aborted at entry** short-circuits with `RUNTIME.ABORTED` (`{ phase: 'decode' }`) before any `codec.decode` call is made. + * - **Mid-flight aborts** race the per-cell `Promise.all` against the signal so the runtime returns promptly even when codec bodies ignore it. In-flight bodies that ignore the signal complete in the background (cooperative cancellation). + * - Existing `RUNTIME.DECODE_FAILED` envelopes from codec bodies pass through unchanged (no double wrap). */ export async function decodeRow( row: Record, plan: SqlExecutionPlan, - registry: CodecRegistry, - jsonValidators: JsonSchemaValidatorRegistry | undefined, rowCtx: SqlCodecCallContext, contractCodecs?: ContractCodecRegistry, ): Promise> { checkAborted(rowCtx, 'decode'); const signal = rowCtx.signal; - const decodeCtx = buildDecodeContext(plan, registry, contractCodecs); + const decodeCtx = buildDecodeContext(plan, contractCodecs); const aliases = decodeCtx.aliases ?? Object.keys(row); @@ -323,13 +290,12 @@ export async function decodeRow( continue; } - tasks.push(decodeField(alias, wireValue, decodeCtx, jsonValidators, rowCtx)); + tasks.push(decodeField(alias, wireValue, decodeCtx, rowCtx)); } const settled = await raceAgainstAbort(Promise.all(tasks), signal, 'decode'); - // Include aggregates are decoded synchronously after concurrent codec - // dispatch settles, so any decode failures upstream propagate first. + // Include aggregates are decoded synchronously after concurrent codec dispatch settles, so any decode failures upstream propagate first. for (const entry of includeIndices) { settled[entry.index] = decodeIncludeAggregate(entry.alias, entry.value); } diff --git a/packages/2-sql/5-runtime/src/codecs/encoding.ts b/packages/2-sql/5-runtime/src/codecs/encoding.ts index dd27d54743..f927897976 100644 --- a/packages/2-sql/5-runtime/src/codecs/encoding.ts +++ b/packages/2-sql/5-runtime/src/codecs/encoding.ts @@ -5,51 +5,54 @@ import { } from '@prisma-next/framework-components/runtime'; import { type Codec, - type CodecRegistry, type ContractCodecRegistry, collectOrderedParamRefs, + type ParamRefBindingRefs, type SqlCodecCallContext, } from '@prisma-next/sql-relational-core/ast'; import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; +import { makeAliasResolver } from './alias-resolver'; interface ParamMetadata { readonly codecId: string | undefined; readonly name: string | undefined; + readonly refs: ParamRefBindingRefs | undefined; } -const NO_METADATA: ParamMetadata = Object.freeze({ codecId: undefined, name: undefined }); +const NO_METADATA: ParamMetadata = Object.freeze({ + codecId: undefined, + name: undefined, + refs: undefined, +}); /** * Resolve the codec for an outgoing param. * - * Phase B (and AC-5-deferred carve-out): `ParamRef` does not carry a - * `(table, column)` ref today — every `ParamRef` carries `codecId` but - * not the column it relates to. Encode-side dispatch therefore consults - * `contractCodecs.forCodecId(codecId)` (which itself prefers the - * contract-walk-derived shared codec, falling back to the legacy - * `CodecRegistry.get` for parameterized codec ids whose contracts don't - * have a column the walk could resolve through). + * Column-aware dispatch: when `metadata.refs` is populated by a column-bound construction site, prefer `contractCodecs.forColumn(refs.table, refs.column)` — that returns the per-instance codec the contract walk materialized for the `(table, column)` pair, encoding the column's typeParams (e.g. `vector(1024)` vs. `vector(1536)`). * - * For the parameterized codecs shipped at Phase B (pgvector, postgres - * json/jsonb), encode is per-instance-stateless w.r.t. params: - * - pgvector formats `[v1,v2,...]` regardless of declared length; - * - postgres json/jsonb encode is `JSON.stringify` regardless of schema. + * On a column-lookup miss the resolver falls through to `forCodecId`. The wrong-instance risk for parameterized codecs is closed off structurally: * - * So the codec-id-keyed lookup yields a structurally equivalent encoder - * even when the resolved per-instance codec carries extra state (e.g. a - * compiled JSON-Schema validator used only by `decode`). TML-2357 retires - * the fallback by threading `ParamRef.refs` through column-bound - * construction sites. + * 1. `buildContractCodecRegistry` pre-populates `byCodecId` with one canonical instance per non-parameterized descriptor; parameterized descriptors are intentionally absent from this pre-population. 2. `forCodecId` rejects ambiguous parameterized fallbacks (`ambiguousCodecIds`) — if the contract walk resolved more than one distinct instance under a single parameterized id, the call throws rather than binding to + * whichever landed first. 3. For the non-ambiguous parameterized case (a single column with that id), `byCodecId` stores the column-correct per-instance codec, so the fall-through still resolves to the right instance. + * + * Refs-less fallback: ParamRefs constructed outside a column-bound site (literals, transient builder state) carry a non-parameterized `codecId` whose dispatch is ambiguity-free. The validator pass (`validateParamRefRefs`) already enforced refs on every parameterized ParamRef before encode runs. */ function resolveParamCodec( metadata: ParamMetadata, - registry: CodecRegistry, contractCodecs: ContractCodecRegistry | undefined, + aliasResolver: (alias: string) => string, ): Codec | undefined { if (!metadata.codecId) return undefined; - const fromContract = contractCodecs?.forCodecId(metadata.codecId); - if (fromContract) return fromContract; - return registry.get(metadata.codecId); + if (metadata.refs && contractCodecs) { + const byColumn = contractCodecs.forColumn( + aliasResolver(metadata.refs.table), + metadata.refs.column, + ); + // Only honour `byColumn` when its codec id agrees with the `ParamRef`'s declared `codecId`. They can legitimately disagree when a heuristic (e.g. the ORM's `refsFromLeft`) lifts column refs out of an `OperationExpr` that changed the codec id — e.g. `cosineDistance(p.embedding, x).lt(1)` carries `refs={post,embedding}` (a vector column) but the comparison side's codec is `pg/float8@1`. Trusting `byColumn` blindly would dispatch the float + // literal through the vector codec. + if (byColumn && byColumn.id === metadata.codecId) return byColumn; + } + return contractCodecs?.forCodecId(metadata.codecId); } function paramLabel(metadata: ParamMetadata, paramIndex: number): string { @@ -74,34 +77,28 @@ function wrapEncodeFailure( } /** - * Encodes a single parameter through its codec. Always awaits codec.encode so - * a Promise can never leak into the driver, even if a sync-authored codec is - * lifted to async by the codec() factory. Failures are wrapped in - * `RUNTIME.ENCODE_FAILED` with `{ label, codec, paramIndex }` and the original - * error attached on `cause`. + * Encodes a single parameter through its codec. Always awaits codec.encode so a Promise can never leak into the driver, even if a sync-authored codec is lifted to async by the codec factory. Failures are wrapped in `RUNTIME.ENCODE_FAILED` with `{ label, codec, paramIndex }` and the original error attached on `cause`. * - * `ctx` is forwarded verbatim to `codec.encode` so codec authors who opt - * into the `(value, ctx)` arity see the same `SqlCodecCallContext` the - * runtime built for the surrounding `runtime.execute()` call. The ctx is - * always present; its `signal` field may be `undefined`. Encode call - * sites do not populate `ctx.column` — encode-time column context is the - * middleware's domain. + * `ctx` is forwarded verbatim to `codec.encode` so codec authors who opt into the `(value, ctx)` arity see the same `SqlCodecCallContext` the runtime built for the surrounding `runtime.execute()` call. The ctx is always present; its `signal` field may be `undefined`. Encode call sites do not populate `ctx.column` — encode-time column context is the middleware's domain. */ export async function encodeParam( value: unknown, - paramRef: { readonly codecId?: string; readonly name?: string }, + paramRef: { + readonly codecId?: string; + readonly name?: string; + readonly refs?: ParamRefBindingRefs; + }, paramIndex: number, - registry: CodecRegistry, ctx: SqlCodecCallContext, contractCodecs?: ContractCodecRegistry, ): Promise { return encodeParamValue( value, - { codecId: paramRef.codecId, name: paramRef.name }, + { codecId: paramRef.codecId, name: paramRef.name, refs: paramRef.refs }, paramIndex, - registry, ctx, contractCodecs, + (alias) => alias, ); } @@ -109,15 +106,15 @@ async function encodeParamValue( value: unknown, metadata: ParamMetadata, paramIndex: number, - registry: CodecRegistry, ctx: SqlCodecCallContext, contractCodecs: ContractCodecRegistry | undefined, + aliasResolver: (alias: string) => string, ): Promise { if (value === null || value === undefined) { return null; } - const codec = resolveParamCodec(metadata, registry, contractCodecs); + const codec = resolveParamCodec(metadata, contractCodecs, aliasResolver); if (!codec) { return value; } @@ -130,27 +127,16 @@ async function encodeParamValue( } /** - * Encodes all parameters concurrently via `Promise.all`. Per parameter, sync- - * and async-authored codecs share the same path: `codec.encode → await → - * return`. Param-level failures are wrapped in `RUNTIME.ENCODE_FAILED`. + * Encodes all parameters concurrently via `Promise.all`. Per parameter, sync-and async-authored codecs share the same path: `codec.encode → await → return`. Param-level failures are wrapped in `RUNTIME.ENCODE_FAILED`. * * When `ctx.signal` is provided: * - * - **Already-aborted at entry** short-circuits with `RUNTIME.ABORTED` - * (`{ phase: 'encode' }`) before any `codec.encode` call is made — codecs - * can pin this with a per-call counter that stays at zero. - * - **Mid-flight abort** races the per-param `Promise.all` against - * `abortable(ctx.signal)`. The runtime returns `RUNTIME.ABORTED` promptly - * even if codec bodies ignore the signal; the in-flight bodies are - * abandoned and run to completion in the background (cooperative - * cancellation, see ADR 204). - * - Existing `RUNTIME.ENCODE_FAILED` envelopes that surface from a codec - * body before the runtime observes the abort pass through unchanged - * (no double wrap). + * - **Already-aborted at entry** short-circuits with `RUNTIME.ABORTED` (`{ phase: 'encode' }`) before any `codec.encode` call is made — codecs can pin this with a per-call counter that stays at zero. + * - **Mid-flight abort** races the per-param `Promise.all` against `abortable(ctx.signal)`. The runtime returns `RUNTIME.ABORTED` promptly even if codec bodies ignore the signal; the in-flight bodies are abandoned and run to completion in the background (cooperative cancellation, see ADR 204). + * - Existing `RUNTIME.ENCODE_FAILED` envelopes that surface from a codec body before the runtime observes the abort pass through unchanged (no double wrap). */ export async function encodeParams( plan: SqlExecutionPlan, - registry: CodecRegistry, ctx: SqlCodecCallContext, contractCodecs?: ContractCodecRegistry, ): Promise { @@ -169,20 +155,22 @@ export async function encodeParams( for (let i = 0; i < paramCount && i < refs.length; i++) { const ref = refs[i]; if (ref) { - metadata[i] = { codecId: ref.codecId, name: ref.name }; + metadata[i] = { codecId: ref.codecId, name: ref.name, refs: ref.refs }; } } } + const aliasResolver = makeAliasResolver(plan.ast); + const tasks: Promise[] = new Array(paramCount); for (let i = 0; i < paramCount; i++) { tasks[i] = encodeParamValue( plan.params[i], metadata[i] ?? NO_METADATA, i, - registry, ctx, contractCodecs, + aliasResolver, ); } diff --git a/packages/2-sql/5-runtime/src/codecs/json-schema-validation.ts b/packages/2-sql/5-runtime/src/codecs/json-schema-validation.ts deleted file mode 100644 index 506226bfd1..0000000000 --- a/packages/2-sql/5-runtime/src/codecs/json-schema-validation.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { runtimeError } from '@prisma-next/framework-components/runtime'; -import type { - JsonSchemaValidationError, - JsonSchemaValidatorRegistry, -} from '@prisma-next/sql-relational-core/query-lane-context'; - -/** - * Validates a JSON value against its column's JSON Schema, if a validator exists. - * - * Throws `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED` on validation failure. - * No-ops if no validator is registered for the column. - */ -export function validateJsonValue( - registry: JsonSchemaValidatorRegistry, - table: string, - column: string, - value: unknown, - direction: 'encode' | 'decode', - codecId?: string, -): void { - const key = `${table}.${column}`; - const validate = registry.get(key); - if (!validate) return; - - const result = validate(value); - if (result.valid) return; - - throw createJsonSchemaValidationError(table, column, direction, result.errors, codecId); -} - -function createJsonSchemaValidationError( - table: string, - column: string, - direction: 'encode' | 'decode', - errors: ReadonlyArray, - codecId?: string, -): Error { - const summary = formatErrorSummary(errors); - return runtimeError( - 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', - `JSON schema validation failed for column '${table}.${column}' (${direction}): ${summary}`, - { - table, - column, - codecId, - direction, - errors: [...errors], - }, - ); -} - -function formatErrorSummary(errors: ReadonlyArray): string { - if (errors.length === 0) return 'unknown validation error'; - if (errors.length === 1) { - const err = errors[0] as JsonSchemaValidationError; - return err.path === '/' ? err.message : `${err.path}: ${err.message}`; - } - return errors - .map((err) => (err.path === '/' ? err.message : `${err.path}: ${err.message}`)) - .join('; '); -} diff --git a/packages/2-sql/5-runtime/src/codecs/validation.ts b/packages/2-sql/5-runtime/src/codecs/validation.ts index 3d3f52a64b..9a8b0d426c 100644 --- a/packages/2-sql/5-runtime/src/codecs/validation.ts +++ b/packages/2-sql/5-runtime/src/codecs/validation.ts @@ -1,7 +1,6 @@ import type { Contract } from '@prisma-next/contract/types'; import { runtimeError } from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; -import type { CodecRegistry } from '@prisma-next/sql-relational-core/ast'; import type { CodecDescriptorRegistry } from '@prisma-next/sql-relational-core/query-lane-context'; export function extractCodecIds(contract: Contract): Set { @@ -31,33 +30,15 @@ function extractCodecIdsFromColumns(contract: Contract): Map registry.descriptorFor(id) !== undefined }; -} - -function isDescriptorRegistry( - registry: CodecRegistry | CodecDescriptorRegistry, -): registry is CodecDescriptorRegistry { - return 'descriptorFor' in registry; -} - export function validateContractCodecMappings( - registry: CodecRegistry | CodecDescriptorRegistry, + registry: CodecDescriptorRegistry, contract: Contract, ): void { - const lookup: CodecLookupForValidation = isDescriptorRegistry(registry) - ? adaptDescriptorRegistry(registry) - : registry; - const codecIds = extractCodecIdsFromColumns(contract); const invalidCodecs: Array<{ table: string; column: string; codecId: string }> = []; for (const [key, codecId] of codecIds.entries()) { - if (!lookup.has(codecId)) { + if (registry.descriptorFor(codecId) === undefined) { const parts = key.split('.'); const table = parts[0] ?? ''; const column = parts[1] ?? ''; @@ -80,7 +61,7 @@ export function validateContractCodecMappings( } export function validateCodecRegistryCompleteness( - registry: CodecRegistry | CodecDescriptorRegistry, + registry: CodecDescriptorRegistry, contract: Contract, ): void { validateContractCodecMappings(registry, contract); diff --git a/packages/2-sql/5-runtime/src/middleware/budgets.ts b/packages/2-sql/5-runtime/src/middleware/budgets.ts index d5812d5db0..cc46e275dc 100644 --- a/packages/2-sql/5-runtime/src/middleware/budgets.ts +++ b/packages/2-sql/5-runtime/src/middleware/budgets.ts @@ -25,14 +25,17 @@ function hasAggregateWithoutGroupBy(ast: SelectAst): boolean { return ast.projection.some((item) => item.expr.kind === 'aggregate'); } -function primaryTableFromAst(ast: SelectAst): string | undefined { +function primaryTableFromAst(ast: SelectAst): string { switch (ast.from.kind) { case 'table-source': return ast.from.name; case 'derived-table-source': return ast.from.alias; + // v8 ignore next 4 default: - return undefined; + throw new Error( + `Unsupported source kind: ${(ast.from satisfies never as { kind: string }).kind}`, + ); } } @@ -41,17 +44,12 @@ function estimateRowsFromAst( tableRows: Record, defaultTableRows: number, hasAggregateWithoutGroup: boolean, -): number | null { +): number { if (hasAggregateWithoutGroup) { return 1; } - const table = primaryTableFromAst(ast); - if (!table) { - return null; - } - - const tableEstimate = tableRows[table] ?? defaultTableRows; + const tableEstimate = tableRows[primaryTableFromAst(ast)] ?? defaultTableRows; if (ast.limit !== undefined) { return Math.min(ast.limit, tableEstimate); @@ -136,31 +134,19 @@ export function budgets(options?: BudgetsOptions): SqlMiddleware { const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict'; if (isUnbounded) { - if (estimated !== null && estimated >= maxRows) { - emitBudgetViolation( - runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', { - source: 'ast', - estimatedRows: estimated, - maxRows, - }), - shouldBlock, - ctx, - ); - return; - } - + const details = + estimated >= maxRows + ? { source: 'ast', estimatedRows: estimated, maxRows } + : { source: 'ast', maxRows }; emitBudgetViolation( - runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', { - source: 'ast', - maxRows, - }), + runtimeError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', details), shouldBlock, ctx, ); return; } - if (estimated !== null && estimated > maxRows) { + if (estimated > maxRows) { emitBudgetViolation( runtimeError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', { source: 'ast', diff --git a/packages/2-sql/5-runtime/src/sql-context.ts b/packages/2-sql/5-runtime/src/sql-context.ts index 2c0337bfa7..5b5a0541ba 100644 --- a/packages/2-sql/5-runtime/src/sql-context.ts +++ b/packages/2-sql/5-runtime/src/sql-context.ts @@ -1,6 +1,5 @@ import type { Contract, ExecutionMutationDefaultValue } from '@prisma-next/contract/types'; -import type { CodecDescriptor } from '@prisma-next/framework-components/codec'; -import { synthesizeNonParameterizedDescriptor } from '@prisma-next/framework-components/codec'; +import type { AnyCodecDescriptor, CodecDescriptor } from '@prisma-next/framework-components/codec'; import type { ComponentDescriptor } from '@prisma-next/framework-components/components'; import { checkContractComponentRequirements } from '@prisma-next/framework-components/components'; import { @@ -25,19 +24,16 @@ import type { Adapter, AnyQueryAst, Codec, - CodecRegistry, ContractCodecRegistry, LoweredStatement, SqlCodecInstanceContext, SqlDriver, } from '@prisma-next/sql-relational-core/ast'; -import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; +import { buildCodecDescriptorRegistry } from '@prisma-next/sql-relational-core/codec-descriptor-registry'; import type { AppliedMutationDefault, CodecDescriptorRegistry, ExecutionContext, - JsonSchemaValidateFn, - JsonSchemaValidatorRegistry, MutationDefaultsOptions, TypeHelperRegistry, } from '@prisma-next/sql-relational-core/query-lane-context'; @@ -45,20 +41,17 @@ import type { /** * Runtime parameterized codec descriptor. * - * The unified `CodecDescriptor

` shape applied to parameterized codecs - * — `paramsSchema: StandardSchemaV1

` for JSON-boundary validation, - * `factory: (P) => (CodecInstanceContext) => Codec` for the curried higher-order codec. - * The factory is called once per `storage.types` instance (or once per - * inline-`typeParams` column); per-instance state lives in the closure. + * The unified `CodecDescriptor

` shape applied to parameterized codecs — `paramsSchema: StandardSchemaV1

` for JSON-boundary validation, `factory: (P) => (CodecInstanceContext) => Codec` for the curried higher-order codec. The factory is called once per `storage.types` instance (or once per inline-`typeParams` column); per-instance state lives in the closure. * * Codec-registry-unification spec § Decision. */ export type RuntimeParameterizedCodecDescriptor

> = CodecDescriptor

; +/** + * Contributor protocol for SQL components (target, adapter, extension pack). The unified `codecs:` slot returns the full {@link CodecDescriptor} list — non-parameterized and parameterized descriptors live side-by-side in the same array. The framework dispatches every codec id through the unified descriptor map without branching on parameterization. + */ export interface SqlStaticContributions { - readonly codecs: () => CodecRegistry; - // biome-ignore lint/suspicious/noExplicitAny: needed for covariance with concrete descriptor types - readonly parameterizedCodecs: () => ReadonlyArray>; + readonly codecs: () => ReadonlyArray; readonly queryOperations?: () => ReadonlyArray; readonly mutationDefaultGenerators?: () => ReadonlyArray; } @@ -66,16 +59,9 @@ export interface SqlStaticContributions { /** * Scope across which a generator's value is constant. * - * - `'field'` — one value per defaulting site (one column, one row). - * Cache strategy: no cache; call per defaulting site. Right for - * per-row identifiers (UUIDs, CUIDs, ULIDs, nanoid, ksuid). - * - `'row'` — one value across all defaulting sites of one row of one - * operation. Cache strategy: per-call cache keyed by `generatorId`. - * Right for correlation ids stamped into multiple columns of one row. - * - `'query'` — one value across all rows and columns of one ORM - * operation. Cache strategy: caller-provided cache keyed by - * `generatorId`. Right for `timestampNow` (a single timestamp per - * bulk insert/update). + * - `'field'` — one value per defaulting site (one column, one row). Cache strategy: no cache; call per defaulting site. Right for per-row identifiers (UUIDs, CUIDs, ULIDs, nanoid, ksuid). + * - `'row'` — one value across all defaulting sites of one row of one operation. Cache strategy: per-call cache keyed by `generatorId`. Right for correlation ids stamped into multiple columns of one row. + * - `'query'` — one value across all rows and columns of one ORM operation. Cache strategy: caller-provided cache keyed by `generatorId`. Right for `timestampNow` (a single timestamp per bulk insert/update). */ export type GeneratorStability = 'field' | 'row' | 'query'; @@ -83,10 +69,7 @@ export interface RuntimeMutationDefaultGenerator { readonly id: string; readonly generate: (params?: Record) => unknown; /** - * Scope across which the generator's value is constant. The framework - * derives the cache strategy from this declaration; generator authors - * never need to know about cache keys. See `GeneratorStability` for - * the per-value semantics. + * Scope across which the generator's value is constant. The framework derives the cache strategy from this declaration; generator authors never need to know about cache keys. See `GeneratorStability` for the per-value semantics. */ readonly stability: GeneratorStability; } @@ -149,10 +132,7 @@ export type SqlRuntimeAdapterInstance = Runti Adapter, LoweredStatement>; /** - * NOTE: Binding type is intentionally erased to unknown at this shared runtime layer. - * Target clients (for example `postgres()`) validate and construct the concrete binding - * before calling `driver.connect(binding)`, which keeps runtime behavior safe today. - * A future follow-up can preserve TBinding through stack/context generics end-to-end. + * NOTE: Binding type is intentionally erased to unknown at this shared runtime layer. Target clients (for example `postgres()`) validate and construct the concrete binding before calling `driver.connect(binding)`, which keeps runtime behavior safe today. A future follow-up can preserve TBinding through stack/context generics end-to-end. */ export type SqlRuntimeDriverInstance = RuntimeDriverInstance< 'sql', @@ -176,7 +156,7 @@ export function createSqlExecutionStack(options: { }); } -export type { ExecutionContext, JsonSchemaValidatorRegistry, TypeHelperRegistry }; +export type { ExecutionContext, TypeHelperRegistry }; export function assertExecutionStackContractRequirements( contract: Contract, @@ -230,15 +210,15 @@ export function assertExecutionStackContractRequirements( function validateTypeParams( typeParams: Record, - codecDescriptor: RuntimeParameterizedCodecDescriptor, + descriptor: RuntimeParameterizedCodecDescriptor, context: { typeName?: string; tableName?: string; columnName?: string }, ): Record { - const result = codecDescriptor.paramsSchema['~standard'].validate(typeParams); + const result = descriptor.paramsSchema['~standard'].validate(typeParams); if (result instanceof Promise) { throw runtimeError( 'RUNTIME.TYPE_PARAMS_INVALID', - `paramsSchema for codec '${codecDescriptor.codecId}' returned a Promise; runtime validation requires a synchronous Standard Schema validator.`, - { ...context, codecId: codecDescriptor.codecId, typeParams }, + `paramsSchema for codec '${descriptor.codecId}' returned a Promise; runtime validation requires a synchronous Standard Schema validator.`, + { ...context, codecId: descriptor.codecId, typeParams }, ); } if (result.issues) { @@ -248,93 +228,49 @@ function validateTypeParams( : `column '${context.tableName}.${context.columnName}'`; throw runtimeError( 'RUNTIME.TYPE_PARAMS_INVALID', - `Invalid typeParams for ${locationInfo} (codecId: ${codecDescriptor.codecId}): ${messages}`, - { ...context, codecId: codecDescriptor.codecId, typeParams }, + `Invalid typeParams for ${locationInfo} (codecId: ${descriptor.codecId}): ${messages}`, + { ...context, codecId: descriptor.codecId, typeParams }, ); } return result.value as Record; } -function collectParameterizedCodecDescriptors( - contributors: ReadonlyArray, -): Map { - const descriptors = new Map(); +/** + * Collect every {@link CodecDescriptor} contributed by the SQL stack and partition into "parameterized" vs "non-parameterized" via the descriptor's own {@link CodecDescriptorImpl.isParameterized} getter. The getter is the canonical discriminator — a `paramsSchema` identity check would misroute any descriptor that doesn't reuse the exact `voidParamsSchema` singleton (e.g. a non-parameterized codec authoring its own no-op schema). + * + * The unified descriptor list collapses the legacy split (a separate slot used to register parameterized codecs) — every codec id resolves through the same map (codec-registry-unification spec § Decision). + */ +function collectCodecDescriptors(contributors: ReadonlyArray): { + readonly all: ReadonlyArray; + readonly parameterized: Map; +} { + const all: AnyCodecDescriptor[] = []; + const parameterized = new Map(); + const seen = new Set(); for (const contributor of contributors) { - for (const descriptor of contributor.parameterizedCodecs()) { - if (descriptors.has(descriptor.codecId)) { + for (const descriptor of contributor.codecs()) { + if (seen.has(descriptor.codecId)) { throw runtimeError( - 'RUNTIME.DUPLICATE_PARAMETERIZED_CODEC', - `Duplicate parameterized codec descriptor for codecId '${descriptor.codecId}'.`, + 'RUNTIME.DUPLICATE_CODEC', + `Duplicate codec descriptor for codecId '${descriptor.codecId}'.`, { codecId: descriptor.codecId }, ); } - descriptors.set(descriptor.codecId, descriptor); - } - } - - return descriptors; -} - -/** - * Build the unified descriptor map. Combines parameterized descriptors - * (which already ship as `CodecDescriptor`s) with synthesized descriptors - * for non-parameterized codecs registered through the legacy `codecs:` - * slot. Codec ids that ship a parameterized descriptor take precedence — - * even when the legacy registry registers a representative codec under - * the same id, the parameterized descriptor is the authoritative source. - * - * Codec-registry-unification spec § Decision: every codec resolves - * through one descriptor map; reads are non-branching. - */ -function buildCodecDescriptorRegistry( - codecRegistry: CodecRegistry, - parameterizedDescriptors: Map, -): CodecDescriptorRegistry { - type AnyDescriptor = CodecDescriptor; - const byId = new Map(); - const byTargetType = new Map>(); - - function registerInIndices(descriptor: AnyDescriptor): void { - byId.set(descriptor.codecId, descriptor); - for (const targetType of descriptor.targetTypes) { - const list = byTargetType.get(targetType); - if (list) { - list.push(descriptor); - } else { - byTargetType.set(targetType, [descriptor]); + seen.add(descriptor.codecId); + all.push(descriptor); + + if (descriptor.isParameterized) { + // Cast widens the descriptor's heterogeneous `P` to the runtime alias surface; consumers narrow per codec id at the dispatch site, where the descriptor's own `paramsSchema` validates JSON-sourced params before the factory ever sees them. + parameterized.set( + descriptor.codecId, + descriptor as unknown as RuntimeParameterizedCodecDescriptor, + ); } } } - // The descriptor map is heterogeneous in `P` — each codec id has its own - // params shape. The public `CodecDescriptorRegistry` interface widens to - // `CodecDescriptor` and consumers narrow per codec id at the - // call site (the descriptor's `paramsSchema` validates JSON-sourced - // params before the factory ever sees them, so the runtime narrow is - // safe). The cast at registration goes through `unknown` because - // `CodecDescriptor

` is invariant in `P` (the `factory` and - // `renderOutputType` slots use `P` contravariantly). - for (const descriptor of parameterizedDescriptors.values()) { - registerInIndices(descriptor as unknown as AnyDescriptor); - } - - for (const codec of codecRegistry.values()) { - if (byId.has(codec.id)) continue; - registerInIndices(synthesizeNonParameterizedDescriptor(codec) as unknown as AnyDescriptor); - } - - return { - descriptorFor(codecId: string): AnyDescriptor | undefined { - return byId.get(codecId); - }, - *values(): IterableIterator { - yield* byId.values(); - }, - byTargetType(targetType: string): readonly AnyDescriptor[] { - return byTargetType.get(targetType) ?? Object.freeze([]); - }, - }; + return { all, parameterized }; } function collectTypeRefSites( @@ -373,8 +309,7 @@ function initializeTypeHelpers( const descriptor = codecDescriptors.get(typeInstance.codecId); if (!descriptor) { - // No parameterized descriptor for this codec id — store the raw - // type instance for callers that need typeParams metadata. + // No parameterized descriptor for this codec id — store the raw type instance for callers that need typeParams metadata. helpers[typeName] = typeInstance; continue; } @@ -407,31 +342,6 @@ function validateColumnTypeParams( } } -/** - * View of a codec that exposes a per-instance JSON-schema `validate` - * function. Codecs declare this contract by including the - * `'json-validator'` `CodecTrait` in their `traits` array; the trait is - * the gate that lets `extractValidator` resolve from structurally-typed - * `unknown` to this typed view. - */ -type JsonValidatorCodec = { - readonly traits?: ReadonlyArray; - readonly validate: JsonSchemaValidateFn; -}; - -function hasJsonValidatorTrait(candidate: unknown): candidate is JsonValidatorCodec { - if (candidate === null || typeof candidate !== 'object') return false; - const traits = (candidate as { readonly traits?: unknown }).traits; - if (!Array.isArray(traits)) return false; - if (!traits.includes('json-validator')) return false; - const validate = (candidate as { readonly validate?: unknown }).validate; - return typeof validate === 'function'; -} - -function extractValidator(candidate: unknown): JsonSchemaValidateFn | undefined { - return hasJsonValidatorTrait(candidate) ? candidate.validate : undefined; -} - function isResolvedCodec(candidate: unknown): candidate is Codec { return ( candidate !== null && @@ -442,52 +352,59 @@ function isResolvedCodec(candidate: unknown): candidate is Codec { } /** - * Walk the contract's `storage.tables[].columns[]` and resolve each - * column to a `Codec` through the unified descriptor map. Per-instance - * behavior: + * Walk the contract's `storage.tables[].columns[]` and resolve each column to a `Codec` through the unified descriptor map. Per-instance behavior: * - * - **typeRef columns**: reuse the resolved codec materialized once by - * `initializeTypeHelpers` for the `storage.types` entry. Multiple - * columns sharing one typeRef share one codec instance. - * - **inline-typeParams columns**: call `descriptor.factory(typeParams) - * (ctx)` once per column (per-column anonymous instance). - * - **non-parameterized columns**: call `descriptor.factory()(ctx)` - * once. The synthesized descriptor's factory is constant — every call - * returns the same shared codec instance — so columns sharing a non- - * parameterized codec id share one resolved codec without explicit - * caching. + * - **typeRef columns**: reuse the resolved codec materialized once by `initializeTypeHelpers` for the `storage.types` entry. Multiple columns sharing one typeRef share one codec instance. + * - **inline-typeParams columns**: call `descriptor.factory(typeParams) (ctx)` once per column (per-column anonymous instance). + * - **non-parameterized columns**: call `descriptor.factory()(ctx)` once. The synthesized descriptor's factory is constant — every call returns the same shared codec instance — so columns sharing a non-parameterized codec id share one resolved codec without explicit caching. * - * Combines what `initializeTypeHelpers` (named-instance walk) and the - * old `buildJsonSchemaValidatorRegistry` (per-column walk) used to do - * separately: one walk over all columns, one resolved codec per column, - * one trait-gated validator extraction per column. The result drives - * both the dispatch registry (`ContractCodecRegistry.forColumn`) and the - * validator registry. - * - * Codec-registry-unification spec § AC-4: every column resolves through - * one descriptor map without branching on parameterization. + * Codec-registry-unification spec § AC-4: every column resolves through one descriptor map without branching on parameterization. JSON-Schema validation, when required, lives inside the resolved codec's `decode` body (see `arktype-json`'s `ArktypeJsonCodecClass`); the framework no longer maintains a parallel validator registry. */ function buildContractCodecRegistry( contract: Contract, codecDescriptors: CodecDescriptorRegistry, - legacyCodecRegistry: CodecRegistry, types: TypeHelperRegistry, parameterizedDescriptors: Map, -): { - readonly registry: ContractCodecRegistry; - readonly jsonValidators: JsonSchemaValidatorRegistry | undefined; -} { +): ContractCodecRegistry { const byColumn = new Map(); const byCodecId = new Map(); - // Codec ids whose `byCodecId` entry is ambiguous — multiple distinct - // resolved instances landed under the same parameterized codec id (e.g. - // `Vector<1024>` and `Vector<1536>` both registering under - // `pg/vector@1`). The encode-side `forCodecId` fallback rejects these - // ids so a DSL-param without a column ref cannot silently bind to the - // wrong instance. Retires when AC-5's `ParamRef.refs` plumbing lands - // (TML-2357). + // Codec ids whose `byCodecId` entry is ambiguous — multiple distinct resolved instances landed under the same parameterized codec id (e.g. `Vector<1024>` and `Vector<1536>` both registering under `pg/vector@1`). The refs-less `forCodecId` fallback rejects these ids so a DSL-param without a column ref cannot silently bind to the wrong instance. The validator pass enforces refs on every parameterized `ParamRef`, so this + // branch is reachable only as a defensive guard for non-parameterized columns whose `byCodecId` entry is unique by construction. const ambiguousCodecIds = new Set(); - const validators = new Map(); + + // Pre-populate `byCodecId` with non-parameterized descriptor instances. Refs-less encode/decode call sites (computed projections without a column ref, transient builder ParamRefs) resolve through `forCodecId(id)` and need a representative instance for codec ids that no contract column declares. Non-parameterized descriptors' factories are constant — every call yields the same shared codec — so a single materialization + // is correct. + for (const descriptor of codecDescriptors.values()) { + if (descriptor.isParameterized) continue; + const ctx: SqlCodecInstanceContext = { + name: ``, + usedAt: [], + }; + const voidFactory = descriptor.factory as unknown as ( + params: undefined, + ) => (ctx: SqlCodecInstanceContext) => Codec; + byCodecId.set(descriptor.codecId, voidFactory(undefined)(ctx)); + } + + // Representative instances for parameterized descriptors whose factory tolerates `factory(undefined)` (e.g. pgvector — the factory ignores its params and returns the same shared codec). Used as the last-resort fallback in `forCodecId` for refs-less call sites whose codec id has no contract column the walk could resolve through (e.g. `cosineSimilarity(col, [literal])` builds an inline `ParamRef` without column refs). + // Stored separately so column-bound walk results don't trip the ambiguity check, and consulted only when `byCodecId` has no column-bound entry. Descriptors whose factory needs real params (arktype-json) raise and are skipped — the per-column dispatch path materializes those lazily when refs are populated. + const parameterizedRepresentatives = new Map(); + for (const descriptor of codecDescriptors.values()) { + if (!descriptor.isParameterized) continue; + const ctx: SqlCodecInstanceContext = { + name: ``, + usedAt: [], + }; + // Call `factory` *as a method on the descriptor* — `descriptor.factory(undefined)` — rather than detaching it into a local. Several descriptors implement `factory` as a class method whose body returns an arrow that captures `this` (`return () => new SomeCodec(this);`), and detaching loses that binding so the codec ends up with an `undefined` descriptor and `codec.id` throws. + const factory = descriptor.factory.bind(descriptor) as unknown as ( + params: unknown, + ) => (ctx: SqlCodecInstanceContext) => Codec; + try { + parameterizedRepresentatives.set(descriptor.codecId, factory(undefined)(ctx)); + } catch { + // Parameterized descriptor whose factory requires real params; refs-less fallback for this codec id is unavailable. + } + } for (const [tableName, table] of Object.entries(contract.storage.tables)) { for (const [columnName, column] of Object.entries(table.columns)) { @@ -500,10 +417,7 @@ function buildContractCodecRegistry( const isParameterized = parameterizedDescriptors.has(column.codecId); if (column.typeRef) { - // The named instance was already materialized once by - // `initializeTypeHelpers`; reuse it so multiple columns sharing - // the same typeRef share one codec instance (and any per- - // instance helper state on it). + // The named instance was already materialized once by `initializeTypeHelpers`; reuse it so multiple columns sharing the same typeRef share one codec instance (and any per-instance helper state on it). const helper = types[column.typeRef]; if (isResolvedCodec(helper)) { resolvedCodec = helper; @@ -516,60 +430,30 @@ function buildContractCodecRegistry( columnName, }); const ctx: SqlCodecInstanceContext = { - name: ``, + name: ``, usedAt: [{ table: tableName, column: columnName }], }; resolvedCodec = parameterizedDescriptor.factory(validatedParams)(ctx); } } else if (!isParameterized) { - // Non-parameterized column. Cache the resolved codec by codec - // id — the synthesized descriptor's factory is constant for - // non-parameterized codecs, so columns sharing this codec id - // share one resolved instance. - let cached = byCodecId.get(column.codecId); - if (!cached) { - const ctx: SqlCodecInstanceContext = { - name: ``, - usedAt: [{ table: tableName, column: columnName }], - }; - // `synthesizeNonParameterizedDescriptor` produces a - // `CodecDescriptor` whose factory ignores its params - // and ctx; the runtime's `void` value is `undefined`. The - // structural cast goes through `unknown` to satisfy the - // heterogeneous-`P` registry boundary (the factory's - // declared `P` is `any` here; the consumer narrows per - // codec id). The cast narrows the descriptor's - // family-agnostic `CodecInstanceContext` slot to the SQL - // `SqlCodecInstanceContext` we pass at this call site — - // function-argument contravariance makes the narrow safe - // (a callee that accepts the base will also accept the - // SQL extension). Per spec § Non-functional constraints. - const voidFactory = descriptor.factory as unknown as ( - params: undefined, - ) => (ctx: SqlCodecInstanceContext) => Codec; - cached = voidFactory(undefined)(ctx); - byCodecId.set(column.codecId, cached); - } - resolvedCodec = cached; + // Non-parameterized column: materialize a fresh codec instance per `forColumn(table, column)` entry with a column-specific `SqlCodecInstanceContext`. The pre-populated `byCodecId` representative (built with the synthetic `` context and empty `usedAt`) is reserved for `forCodecId()` refs-less fallbacks; reusing it for column-bound dispatch would erase per-column diagnostics for any descriptor whose factory reads `CodecInstanceContext`. + const ctx: SqlCodecInstanceContext = { + name: ``, + usedAt: [{ table: tableName, column: columnName }], + }; + // The descriptor's `P` is `void` for non-parameterized codecs; the runtime's `void` value is `undefined`. The cast narrows the descriptor's family-agnostic `CodecInstanceContext` slot to the SQL `SqlCodecInstanceContext` we pass at this call site — function-argument contravariance makes the narrow safe. + // `bind` preserves the `this`-on-descriptor invariant — several descriptors implement `factory` as a class method whose body returns an arrow that captures `this`; detaching loses the binding and produces a codec whose `descriptor` is `undefined`. + const voidFactory = descriptor.factory.bind(descriptor) as unknown as ( + params: undefined, + ) => (ctx: SqlCodecInstanceContext) => Codec; + resolvedCodec = voidFactory(undefined)(ctx); } - // else: parameterized codec id with no typeRef and no typeParams - // — this is the legitimate "undimensioned" form for codecs that - // ship a no-params column variant alongside a parameterized one - // (e.g. pgvector's `vectorColumn` vs. `vector(N)`). Leave - // `resolvedCodec` undefined; encode/decode for this column flows - // through `forCodecId` (the AC-5-deferred carve-out documented - // in `relational-core/src/ast/codec-types.ts`). The fallback - // works for these cases because their wire format is - // params-independent (vector formats `[v1,v2,...]` regardless - // of declared length). + // else: parameterized codec id with no typeRef and no typeParams — this is the legitimate "undimensioned" form for codecs that ship a no-params column variant alongside a parameterized one (e.g. pgvector's `vectorColumn` vs. `vector(N)`). Leave `resolvedCodec` undefined; encode/decode for this column flows through `forCodecId`. The fallback works for these cases because their wire format is params-independent (vector + // formats `[v1,v2,...]` regardless of declared length). } if (resolvedCodec) { byColumn.set(columnKey, resolvedCodec); - const validate = extractValidator(resolvedCodec); - if (validate) { - validators.set(columnKey, validate); - } const existing = byCodecId.get(column.codecId); if (existing === undefined) { byCodecId.set(column.codecId, resolvedCodec); @@ -585,20 +469,10 @@ function buildContractCodecRegistry( return byColumn.get(`${table}.${column}`); }, forCodecId(codecId) { - // Codec-id-only fallback for sites without a column ref (encode- - // side DSL params whose `ParamRef.refs` isn't populated). Prefer - // the contract-walk-derived shared codec; fall back to the legacy - // `codecRegistry.get` for parameterized codec ids whose contracts - // don't have a typeRef/typeParams column the walk could resolve - // through. The legacy fallback retires once `ParamRef.refs` is - // threaded everywhere (TML-2357). + // Codec-id-only fallback for refs-less sites. The validator pass (`validateParamRefRefs`) enforces refs on every parameterized `ParamRef` before encode, so this path is only legitimately reachable for non-parameterized codec ids. The map is pre-populated with every non-parameterized descriptor's canonical instance and overlaid with column-resolved instances from the contract walk; parameterized codec ids without a + // typeRef/typeParams-bound column never reach this map. // - // Reject ambiguous parameterized fallbacks: if the contract walk - // resolved more than one distinct codec instance under this id - // (e.g. multiple vector dimensions, multiple arktype-json - // schemas), the codec-id-keyed lookup cannot honor the call site - // — fail fast rather than bind to whichever instance happened to - // land first. + // Reject ambiguous parameterized fallbacks: if the contract walk resolved more than one distinct codec instance under this id (e.g. multiple vector dimensions, multiple arktype-json schemas), the codec-id-keyed lookup cannot honor the call site — fail fast rather than bind to whichever instance happened to land first. if (ambiguousCodecIds.has(codecId)) { throw runtimeError( 'RUNTIME.TYPE_PARAMS_INVALID', @@ -606,19 +480,11 @@ function buildContractCodecRegistry( { codecId }, ); } - return byCodecId.get(codecId) ?? legacyCodecRegistry.get(codecId); + return byCodecId.get(codecId) ?? parameterizedRepresentatives.get(codecId); }, }; - const jsonValidators: JsonSchemaValidatorRegistry | undefined = - validators.size > 0 - ? { - get: (key: string) => validators.get(key), - size: validators.size, - } - : undefined; - - return { registry, jsonValidators }; + return registry; } function assertMutationDefaultGeneratorsAvailable( @@ -714,8 +580,7 @@ function applyMutationDefaults( const applied: AppliedMutationDefault[] = []; const appliedColumns = new Set(); - // Fresh per-call cache for `stability: 'row'` generators — they share - // across columns of a single row but regenerate on the next call. + // Fresh per-call cache for `stability: 'row'` generators — they share across columns of a single row but regenerate on the next call. const rowCache = new Map(); for (const mutationDefault of defaults) { @@ -729,8 +594,7 @@ function applyMutationDefaults( continue; } - // RD2: empty update payloads skip onUpdate defaults — no write means - // no `@updatedAt` advance. + // RD2: empty update payloads skip onUpdate defaults — no write means no `@updatedAt` advance. if (isEmptyUpdate) { continue; } @@ -803,19 +667,14 @@ export function createExecutionContext< assertExecutionStackContractRequirements(contract, stack); - const codecRegistry = createCodecRegistry(); - const contributors: Array> = [ stack.target, stack.adapter, ...stack.extensionPacks, ]; - for (const contributor of contributors) { - for (const c of contributor.codecs().values()) { - codecRegistry.register(c); - } - } + const { all: allCodecDescriptors, parameterized: parameterizedCodecDescriptors } = + collectCodecDescriptors(contributors); const queryOperationRegistry = createSqlOperationRegistry(); for (const contributor of contributors) { @@ -824,11 +683,7 @@ export function createExecutionContext< } } - const parameterizedCodecDescriptors = collectParameterizedCodecDescriptors(contributors); - const codecDescriptors = buildCodecDescriptorRegistry( - codecRegistry, - parameterizedCodecDescriptors, - ); + const codecDescriptors = buildCodecDescriptorRegistry(allCodecDescriptors); const mutationDefaultGeneratorRegistry = collectMutationDefaultGenerators(contributors); assertMutationDefaultGeneratorsAvailable(contract, mutationDefaultGeneratorRegistry); @@ -838,23 +693,19 @@ export function createExecutionContext< const types = initializeTypeHelpers(contract.storage, parameterizedCodecDescriptors); - const { registry: contractCodecs, jsonValidators: jsonSchemaValidators } = - buildContractCodecRegistry( - contract, - codecDescriptors, - codecRegistry, - types, - parameterizedCodecDescriptors, - ); + const contractCodecs = buildContractCodecRegistry( + contract, + codecDescriptors, + types, + parameterizedCodecDescriptors, + ); return { contract, - codecs: codecRegistry, contractCodecs, codecDescriptors, queryOperations: queryOperationRegistry, types, - ...(jsonSchemaValidators ? { jsonSchemaValidators } : {}), applyMutationDefaults: (options) => applyMutationDefaults(contract, mutationDefaultGeneratorRegistry, options), }; diff --git a/packages/2-sql/5-runtime/src/sql-runtime.ts b/packages/2-sql/5-runtime/src/sql-runtime.ts index a6e331b9d4..8ccfb0693b 100644 --- a/packages/2-sql/5-runtime/src/sql-runtime.ts +++ b/packages/2-sql/5-runtime/src/sql-runtime.ts @@ -17,7 +17,6 @@ import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { Adapter, AnyQueryAst, - CodecRegistry, ContractCodecRegistry, LoweredStatement, SqlCodecCallContext, @@ -25,11 +24,9 @@ import type { SqlQueryable, SqlTransaction, } from '@prisma-next/sql-relational-core/ast'; +import { validateParamRefRefs } from '@prisma-next/sql-relational-core/ast'; import type { SqlExecutionPlan, SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; -import type { - CodecDescriptorRegistry, - JsonSchemaValidatorRegistry, -} from '@prisma-next/sql-relational-core/query-lane-context'; +import type { CodecDescriptorRegistry } from '@prisma-next/sql-relational-core/query-lane-context'; import type { RuntimeScope } from '@prisma-next/sql-relational-core/types'; import { ifDefined } from '@prisma-next/utils/defined'; import { decodeRow } from './codecs/decoding'; @@ -93,25 +90,15 @@ export interface Runtime extends RuntimeQueryable { export interface RuntimeConnection extends RuntimeQueryable { transaction(): Promise; /** - * Returns the connection to the pool for reuse. Only call this when the - * connection is known to be in a clean state. If a transaction - * commit/rollback failed or the connection is otherwise suspect, call - * `destroy(reason)` instead. + * Returns the connection to the pool for reuse. Only call this when the connection is known to be in a clean state. If a transaction commit/rollback failed or the connection is otherwise suspect, call `destroy(reason)` instead. */ release(): Promise; /** - * Evicts the connection so it is never reused. Call this when the - * connection may be in an indeterminate state (e.g. a failed rollback - * leaving an open transaction, or a broken socket). + * Evicts the connection so it is never reused. Call this when the connection may be in an indeterminate state (e.g. a failed rollback leaving an open transaction, or a broken socket). * - * If teardown fails the error is propagated and the connection remains - * retryable, so the caller can decide whether to swallow the failure or - * retry cleanup. Calling destroy() or release() more than once after a - * successful teardown is caller error. + * If teardown fails the error is propagated and the connection remains retryable, so the caller can decide whether to swallow the failure or retry cleanup. Calling destroy() or release() more than once after a successful teardown is caller error. * - * `reason` is advisory context only. It may be surfaced to driver-level - * observability hooks (e.g. pg-pool's `'release'` event) but does not - * influence eviction behavior and is not rethrown. + * `reason` is advisory context only. It may be surfaced to driver-level observability hooks (e.g. pg-pool's `'release'` event) but does not influence eviction behavior and is not rethrown. */ destroy(reason?: unknown): Promise; } @@ -133,6 +120,10 @@ function isExecutionPlan(plan: SqlExecutionPlan | SqlQueryPlan): plan is SqlExec return 'sql' in plan; } +// v8 ignore next 2 +const noopLogSink = (): void => {}; +const noopLog: Log = { info: noopLogSink, warn: noopLogSink, error: noopLogSink }; + class SqlRuntimeImpl = Contract> extends RuntimeCore implements Runtime @@ -141,10 +132,8 @@ class SqlRuntimeImpl = Contract, LoweredStatement>; private readonly driver: SqlDriver; private readonly familyAdapter: RuntimeFamilyAdapter>; - private readonly codecRegistry: CodecRegistry; private readonly contractCodecs: ContractCodecRegistry; private readonly codecDescriptors: CodecDescriptorRegistry; - private readonly jsonSchemaValidators: JsonSchemaValidatorRegistry | undefined; private readonly sqlCtx: SqlMiddlewareContext; private readonly verify: RuntimeVerifyOptions; private codecRegistryValidated: boolean; @@ -165,13 +154,8 @@ class SqlRuntimeImpl = Contract Date.now(), - log: log ?? { - info: () => {}, - warn: () => {}, - error: () => {}, - }, - // ctx is only invoked by runWithMiddleware with execs this runtime lowered; - // the framework parameter type is the cross-family base. + log: log ?? noopLog, + // ctx is only invoked by runWithMiddleware with execs this runtime lowered; the framework parameter type is the cross-family base. contentHash: (exec) => computeSqlContentHash(exec as SqlExecutionPlan), }; @@ -181,10 +165,8 @@ class SqlRuntimeImpl = Contract = Contract { + validateParamRefRefs(plan.ast, this.codecDescriptors); const lowered = lowerSqlPlan(this.adapter, this.contract, plan); return Object.freeze({ ...lowered, - params: await encodeParams(lowered, this.codecRegistry, ctx, this.contractCodecs), + params: await encodeParams(lowered, ctx, this.contractCodecs), }); } /** - * Default driver invocation. Production execution paths override the - * queryable target (e.g. transaction or connection) by going through - * `executeAgainstQueryable`; this implementation supports any caller of - * `super.execute(plan)` and the abstract-base contract. + * Default driver invocation required by the abstract `RuntimeCore` contract. Every production path overrides `execute()` and routes through `executeAgainstQueryable`, so this hook is defensive only — subclasses that delegate back to `super.execute()` would land here. */ + // v8 ignore next 6 protected override runDriver(exec: SqlExecutionPlan): AsyncIterable> { return this.driver.execute>({ sql: exec.sql, @@ -234,13 +209,8 @@ class SqlRuntimeImpl = Contract { const rewrittenDraft = await runBeforeCompileChain( @@ -269,23 +239,24 @@ class SqlRuntimeImpl = Contract { checkAborted(codecCtx, 'stream'); - const exec: SqlExecutionPlan = isExecutionPlan(plan) - ? Object.freeze({ - ...plan, - params: await encodeParams(plan, self.codecRegistry, codecCtx, self.contractCodecs), - }) - : await self.lower(await self.runBeforeCompile(plan), codecCtx); + let exec: SqlExecutionPlan; + if (isExecutionPlan(plan)) { + if (plan.ast) { + validateParamRefRefs(plan.ast, self.codecDescriptors); + } + exec = Object.freeze({ + ...plan, + params: await encodeParams(plan, codecCtx, self.contractCodecs), + }); + } else { + exec = await self.lower(await self.runBeforeCompile(plan), codecCtx); + } self.familyAdapter.validatePlan(exec, self.contract); self._telemetry = null; @@ -317,11 +288,7 @@ class SqlRuntimeImpl = Contract = Contract = Contract { - if (this.verify.mode === 'always') { - this.verified = false; - } - - if (this.verified) { - return; - } - const readStatement = this.familyAdapter.markerReader.readMarkerStatement(); const result = await this.driver.query(readStatement.sql, readStatement.params); @@ -536,11 +486,7 @@ export async function withTransaction( const destroyConnection = async (reason: unknown): Promise => { if (connectionDisposed) return; connectionDisposed = true; - // SqlConnection.destroy() propagates teardown errors so callers can - // decide what to do with them. Here, we're already about to throw a - // more informative error describing why we're evicting the connection - // (rollback/commit failure), so swallowing the teardown error is the - // right call — surfacing it would mask the original cause. + // SqlConnection.destroy() propagates teardown errors so callers can decide what to do with them. Here, we're already about to throw a more informative error describing why we're evicting the connection (rollback/commit failure), so swallowing the teardown error is the right call — surfacing it would mask the original cause. await connection.destroy(reason).catch(() => undefined); }; @@ -569,17 +515,9 @@ export async function withTransaction( try { await transaction.commit(); } catch (commitError) { - // After a failed COMMIT the server-side transaction may be: (a) already - // committed (error on response path), (b) already rolled back (deferred - // constraint / serialization failure), or (c) still open (COMMIT never - // reached the server). Attempt a best-effort rollback to cover (c) and - // confirm the protocol is healthy. + // After a failed COMMIT the server-side transaction may be: (a) already committed (error on response path), (b) already rolled back (deferred constraint / serialization failure), or (c) still open (COMMIT never reached the server). Attempt a best-effort rollback to cover (c) and confirm the protocol is healthy. // - // If rollback succeeds, the server is definitely no longer in a - // transaction (no-op in (a)/(b), real cleanup in (c)) and we've just - // proved the connection round-trips correctly — it's safe to return - // to the pool. If rollback fails, the connection state is ambiguous - // (broken socket, protocol desync, etc.) and we must destroy it. + // If rollback succeeds, the server is definitely no longer in a transaction (no-op in (a)/(b), real cleanup in (c)) and we've just proved the connection round-trips correctly — it's safe to return to the pool. If rollback fails, the connection state is ambiguous (broken socket, protocol desync, etc.) and we must destroy it. try { await transaction.rollback(); } catch { diff --git a/packages/2-sql/5-runtime/test/alias-resolver.test.ts b/packages/2-sql/5-runtime/test/alias-resolver.test.ts new file mode 100644 index 0000000000..450babefa8 --- /dev/null +++ b/packages/2-sql/5-runtime/test/alias-resolver.test.ts @@ -0,0 +1,65 @@ +import { + BinaryExpr, + ColumnRef, + DeleteAst, + DerivedTableSource, + EqColJoinOn, + JoinAst, + ParamRef, + ProjectionItem, + SelectAst, + TableSource, +} from '@prisma-next/sql-relational-core/ast'; +import { describe, expect, it } from 'vitest'; +import { makeAliasResolver } from '../src/codecs/alias-resolver'; + +describe('makeAliasResolver', () => { + it('returns identity when ast is undefined', () => { + const resolver = makeAliasResolver(undefined); + expect(resolver('post')).toBe('post'); + expect(resolver('p1')).toBe('p1'); + }); + + it('maps table aliases to source names for SELECT', () => { + const ast = SelectAst.from(TableSource.named('post', 'p1')).withProjection([ + ProjectionItem.of('id', ColumnRef.of('p1', 'id')), + ]); + const resolver = makeAliasResolver(ast); + expect(resolver('p1')).toBe('post'); + expect(resolver('post')).toBe('post'); + expect(resolver('unknown')).toBe('unknown'); + }); + + it('records sources from join clauses (self-join aliases)', () => { + const ast = SelectAst.from(TableSource.named('post', 'p1')) + .withJoins([ + JoinAst.inner( + TableSource.named('post', 'p2'), + EqColJoinOn.of(ColumnRef.of('p1', 'id'), ColumnRef.of('p2', 'id')), + ), + ]) + .withProjection([ProjectionItem.of('id', ColumnRef.of('p1', 'id'))]); + const resolver = makeAliasResolver(ast); + expect(resolver('p1')).toBe('post'); + expect(resolver('p2')).toBe('post'); + }); + + it('records derived table sources by their alias', () => { + const inner = SelectAst.from(TableSource.named('post')).withProjection([ + ProjectionItem.of('id', ColumnRef.of('post', 'id')), + ]); + const ast = SelectAst.from(DerivedTableSource.as('p', inner)).withProjection([ + ProjectionItem.of('id', ColumnRef.of('p', 'id')), + ]); + const resolver = makeAliasResolver(ast); + expect(resolver('p')).toBe('p'); + }); + + it('records the target table for mutation ASTs (DELETE)', () => { + const ast = DeleteAst.from(TableSource.named('post', 'p1')).withWhere( + BinaryExpr.eq(ColumnRef.of('p1', 'id'), ParamRef.of(1, { codecId: 'pg/int4@1' })), + ); + const resolver = makeAliasResolver(ast); + expect(resolver('p1')).toBe('post'); + }); +}); diff --git a/packages/2-sql/5-runtime/test/before-compile-chain.test.ts b/packages/2-sql/5-runtime/test/before-compile-chain.test.ts index e0ac3f07ee..27a7ab0229 100644 --- a/packages/2-sql/5-runtime/test/before-compile-chain.test.ts +++ b/packages/2-sql/5-runtime/test/before-compile-chain.test.ts @@ -4,8 +4,6 @@ import { AndExpr, BinaryExpr, ColumnRef, - codec, - createCodecRegistry, LiteralExpr, ParamRef, ProjectionItem, @@ -21,6 +19,8 @@ import type { SqlMiddleware, SqlMiddlewareContext, } from '../src/middleware/sql-middleware'; +import { defineTestCodec } from './test-codec'; +import { buildTestContractCodecs } from './utils'; function createContext(): SqlMiddlewareContext & { log: { debug: ReturnType }; @@ -342,15 +342,14 @@ describe('runBeforeCompileChain', () => { it( 'beforeCompile alias-swap rewrites the AST and the decoder reads from it', async () => { - const decoderRegistry = createCodecRegistry(); - decoderRegistry.register( - codec({ + const decoderRegistry = [ + defineTestCodec({ typeId: 'pg/int4@1', targetTypes: ['int4'], encode: (v: number) => v, decode: (w: number) => w + 100, }), - ); + ]; const initialAst = SelectAst.from(TableSource.named('users')).withProjection([ ProjectionItem.of('id', ColumnRef.of('users', 'id'), 'pg/int4@1'), @@ -384,7 +383,12 @@ describe('runBeforeCompileChain', () => { ast: result.ast, meta: result.meta, }; - const row = await decodeRow({ user_id: 7 }, plan, decoderRegistry, undefined, {}); + const row = await decodeRow( + { user_id: 7 }, + plan, + {}, + buildTestContractCodecs(decoderRegistry), + ); expect(row).toEqual({ user_id: 107 }); }, timeouts.default, @@ -395,15 +399,14 @@ describe('runBeforeCompileChain', () => { async () => { const { InsertAst } = await import('@prisma-next/sql-relational-core/ast'); - const decoderRegistry = createCodecRegistry(); - decoderRegistry.register( - codec({ + const decoderRegistry = [ + defineTestCodec({ typeId: 'pg/int4@1', targetTypes: ['int4'], encode: (v: number) => v, decode: (w: number) => w + 100, }), - ); + ]; const insert = InsertAst.into(TableSource.named('users')) .withRows([{ id: ParamRef.of(1, { name: 'id', codecId: 'pg/int4@1' }) }]) @@ -416,7 +419,7 @@ describe('runBeforeCompileChain', () => { ast: insert, meta, }; - const row = await decodeRow({ id: 7 }, plan, decoderRegistry, undefined, {}); + const row = await decodeRow({ id: 7 }, plan, {}, buildTestContractCodecs(decoderRegistry)); expect(row).toEqual({ id: 107 }); }, timeouts.default, diff --git a/packages/2-sql/5-runtime/test/budgets.test.ts b/packages/2-sql/5-runtime/test/budgets.test.ts index 96501ab0d3..ea8acc9e50 100644 --- a/packages/2-sql/5-runtime/test/budgets.test.ts +++ b/packages/2-sql/5-runtime/test/budgets.test.ts @@ -5,6 +5,7 @@ import { AggregateExpr, ColumnRef, DeleteAst, + DerivedTableSource, ProjectionItem, SelectAst, TableSource, @@ -313,5 +314,46 @@ describe('budgets middleware', () => { }, timeouts.default, ); + + it( + 'flags unbounded SELECTs even when the estimate is below the row budget', + async () => { + const ast = SelectAst.from(userTable).withProjection([ProjectionItem.of('id', idCol)]); + const plan = createPlan({ ast }); + const mw = budgets({ maxRows: 100_000, defaultTableRows: 100 }); + const ctx = createMiddlewareContext(); + + await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({ + code: 'BUDGET.ROWS_EXCEEDED', + details: { source: 'ast', maxRows: 100_000 }, + }); + }, + timeouts.default, + ); + + it( + 'reads the alias from a derived table source when estimating rows', + async () => { + const inner = SelectAst.from(userTable) + .withProjection([ProjectionItem.of('id', idCol)]) + .withLimit(1); + const ast = SelectAst.from(DerivedTableSource.as('top_users', inner)) + .withProjection([ProjectionItem.of('id', ColumnRef.of('top_users', 'id'))]) + .withLimit(10); + const plan = createPlan({ ast }); + const mw = budgets({ + maxRows: 4, + defaultTableRows: 10_000, + tableRows: { top_users: 5 }, + }); + const ctx = createMiddlewareContext(); + + await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({ + code: 'BUDGET.ROWS_EXCEEDED', + details: expect.objectContaining({ source: 'ast', estimatedRows: 5 }), + }); + }, + timeouts.default, + ); }); }); diff --git a/packages/2-sql/5-runtime/test/codec-async.test.ts b/packages/2-sql/5-runtime/test/codec-async.test.ts index 65e3eaf1cf..7f10862852 100644 --- a/packages/2-sql/5-runtime/test/codec-async.test.ts +++ b/packages/2-sql/5-runtime/test/codec-async.test.ts @@ -1,32 +1,27 @@ import type { JsonValue } from '@prisma-next/contract/types'; import { coreHash } from '@prisma-next/contract/types'; -import type { Codec, CodecRegistry } from '@prisma-next/sql-relational-core/ast'; +import { runtimeError } from '@prisma-next/framework-components/runtime'; +import type { Codec } from '@prisma-next/sql-relational-core/ast'; import { AndExpr, type AnyExpression, BinaryExpr, ColumnRef, - codec, - createCodecRegistry, ParamRef, ProjectionItem, SelectAst, TableSource, } from '@prisma-next/sql-relational-core/ast'; import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; -import type { - JsonSchemaValidateFn, - JsonSchemaValidatorRegistry, -} from '@prisma-next/sql-relational-core/query-lane-context'; import { timeouts } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { decodeRow } from '../src/codecs/decoding'; import { encodeParams } from '../src/codecs/encoding'; import { createAsyncSecretCodec, decryptSecret, encryptSecret } from './seeded-secret-codec'; +import { defineTestCodec } from './test-codec'; +import { buildTestContractCodecs } from './utils'; -// ============================================================================= -// Shared helpers — AST-backed plans (ADR 205) -// ============================================================================= +// ============================================================================= Shared helpers — AST-backed plans (ADR 205) ============================================================================= interface ParamSpec { readonly value: unknown; @@ -110,9 +105,7 @@ function deferred(): { return { promise, resolve, reject }; } -// ============================================================================= -// encodeParams: concurrent dispatch + envelope (AST-backed plans) -// ============================================================================= +// ============================================================================= encodeParams: concurrent dispatch + envelope (AST-backed plans) ============================================================================= describe('encodeParams — async, concurrent dispatch', () => { it('dispatches mixed sync/async parameter codecs concurrently via Promise.all', async () => { @@ -120,9 +113,8 @@ describe('encodeParams — async, concurrent dispatch', () => { const dB = deferred(); const callOrder: string[] = []; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/async-a@1', targetTypes: ['text'], encode: (value: string) => { @@ -131,9 +123,7 @@ describe('encodeParams — async, concurrent dispatch', () => { }, decode: (wire: string) => wire, }), - ); - registry.register( - codec({ + defineTestCodec({ typeId: 'test/async-b@1', targetTypes: ['text'], encode: (value: string) => { @@ -142,9 +132,7 @@ describe('encodeParams — async, concurrent dispatch', () => { }, decode: (wire: string) => wire, }), - ); - registry.register( - codec({ + defineTestCodec({ typeId: 'test/sync@1', targetTypes: ['int4'], encode: (value: number) => { @@ -153,7 +141,7 @@ describe('encodeParams — async, concurrent dispatch', () => { }, decode: (wire: number) => wire, }), - ); + ]; const plan = buildAstPlan({ params: [ @@ -163,7 +151,7 @@ describe('encodeParams — async, concurrent dispatch', () => { ], }); - const promise = encodeParams(plan, registry, {}); + const promise = encodeParams(plan, {}, buildTestContractCodecs(registry)); expect(callOrder).toEqual(['encode-a-start', 'encode-b-start', 'encode-sync']); @@ -175,21 +163,20 @@ describe('encodeParams — async, concurrent dispatch', () => { }); it('always awaits codec.encode (no Promise leaks into the driver)', async () => { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/async@1', targetTypes: ['text'], encode: async (value: string) => `wire:${value}`, decode: async (wire: string) => wire, }), - ); + ]; const plan = buildAstPlan({ params: [{ value: 'hello', codecId: 'test/async@1' }], }); - const result = await encodeParams(plan, registry, {}); + const result = await encodeParams(plan, {}, buildTestContractCodecs(registry)); const first = result[0]; expect(typeof (first as { then?: unknown } | null | undefined)?.then).toBe('undefined'); expect(first).toBe('wire:hello'); @@ -197,9 +184,8 @@ describe('encodeParams — async, concurrent dispatch', () => { it('wraps encode failures in RUNTIME.ENCODE_FAILED with { label, codec, paramIndex } and cause', async () => { const cause = new Error('boom'); - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/explody@1', targetTypes: ['text'], encode: () => { @@ -207,13 +193,13 @@ describe('encodeParams — async, concurrent dispatch', () => { }, decode: (wire: string) => wire, }), - ); + ]; const plan = buildAstPlan({ params: [{ value: 'bad', codecId: 'test/explody@1', name: 'pname' }], }); - await expect(encodeParams(plan, registry, {})).rejects.toMatchObject({ + await expect(encodeParams(plan, {}, buildTestContractCodecs(registry))).rejects.toMatchObject({ code: 'RUNTIME.ENCODE_FAILED', category: 'RUNTIME', severity: 'error', @@ -227,9 +213,8 @@ describe('encodeParams — async, concurrent dispatch', () => { }); it('uses param[] label when ParamRef has no name', async () => { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/explody@1', targetTypes: ['text'], encode: () => { @@ -237,22 +222,21 @@ describe('encodeParams — async, concurrent dispatch', () => { }, decode: (wire: string) => wire, }), - ); + ]; const plan = buildAstPlan({ params: [{ value: 'x', codecId: 'test/explody@1' }], }); - await expect(encodeParams(plan, registry, {})).rejects.toMatchObject({ + await expect(encodeParams(plan, {}, buildTestContractCodecs(registry))).rejects.toMatchObject({ code: 'RUNTIME.ENCODE_FAILED', details: { label: 'param[0]' }, }); }); it('returns null for null/undefined parameter values without invoking the codec', async () => { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/sync@1', targetTypes: ['text'], encode: () => { @@ -260,7 +244,7 @@ describe('encodeParams — async, concurrent dispatch', () => { }, decode: (wire: string) => wire, }), - ); + ]; const plan = buildAstPlan({ params: [ @@ -269,23 +253,22 @@ describe('encodeParams — async, concurrent dispatch', () => { ], }); - const result = await encodeParams(plan, registry, {}); + const result = await encodeParams(plan, {}, buildTestContractCodecs(registry)); expect([...result]).toEqual([null, null]); }); it('passes through values when no codec is registered for the ParamRef.codecId', async () => { - const registry = createCodecRegistry(); + const registry: ReadonlyArray> = []; const plan = buildAstPlan({ params: [{ value: 'raw', codecId: 'test/missing@1' }], }); - const result = await encodeParams(plan, registry, {}); + const result = await encodeParams(plan, {}, buildTestContractCodecs(registry)); expect([...result]).toEqual(['raw']); }); it('passes parameters through unchanged for raw plans (no AST, no codec encoding)', async () => { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/should-not-run@1', targetTypes: ['text'], encode: () => { @@ -293,23 +276,22 @@ describe('encodeParams — async, concurrent dispatch', () => { }, decode: (wire: string) => wire, }), - ); + ]; const plan = buildRawPlan(['Alice', 42]); - const result = await encodeParams(plan, registry, {}); + const result = await encodeParams(plan, {}, buildTestContractCodecs(registry)); expect([...result]).toEqual(['Alice', 42]); }); it('encodes a fully-typed AST-backed plan without throwing', async () => { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/passthrough@1', targetTypes: ['text'], encode: (value: string) => `wire:${value}`, decode: (wire: string) => wire, }), - ); + ]; const plan = buildAstPlan({ params: [ @@ -318,50 +300,21 @@ describe('encodeParams — async, concurrent dispatch', () => { ], }); - const encoded = await encodeParams(plan, registry, {}); + const encoded = await encodeParams(plan, {}, buildTestContractCodecs(registry)); expect([...encoded]).toEqual(['wire:x', 'wire:y']); }); }); -// ============================================================================= -// decodeRow / decodeField: concurrent per-cell + envelope + JSON validation -// ============================================================================= +// ============================================================================= decodeRow / decodeField: concurrent per-cell + envelope + JSON validation ============================================================================= describe('decodeRow — async, concurrent per-cell dispatch', () => { - function buildJsonbRegistry(): CodecRegistry { - const registry = createCodecRegistry(); - registry.register( - codec<'pg/jsonb@1', readonly [], string, JsonValue>({ - typeId: 'pg/jsonb@1', - targetTypes: ['jsonb'], - encode: (v: JsonValue) => JSON.stringify(v), - decode: (w: string) => (typeof w === 'string' ? JSON.parse(w) : w) as JsonValue, - }), - ); - return registry; - } - - function buildValidator( - valid: (value: unknown) => boolean, - message = 'invalid', - ): JsonSchemaValidateFn { - return (value: unknown) => { - if (valid(value)) return { valid: true }; - return { - valid: false, - errors: [{ path: '/', message, keyword: 'custom' }], - }; - }; - } - it('dispatches per-cell decoders concurrently via Promise.all', async () => { const dA = deferred(); const dB = deferred(); const callOrder: string[] = []; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/slow-a@1', targetTypes: ['text'], encode: (v: string) => v, @@ -370,9 +323,7 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { return dA.promise.then((suffix) => `${w}:${suffix}`); }, }), - ); - registry.register( - codec({ + defineTestCodec({ typeId: 'test/slow-b@1', targetTypes: ['text'], encode: (v: string) => v, @@ -381,9 +332,7 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { return dB.promise.then((suffix) => `${w}:${suffix}`); }, }), - ); - registry.register( - codec({ + defineTestCodec({ typeId: 'test/sync@1', targetTypes: ['int4'], encode: (v: number) => v, @@ -392,7 +341,7 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { return w * 2; }, }), - ); + ]; const plan = buildAstPlan({ projections: [ @@ -403,7 +352,7 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { }); const row = { a: 'A', b: 'B', n: 21 }; - const promise = decodeRow(row, plan, registry, undefined, {}); + const promise = decodeRow(row, plan, {}, buildTestContractCodecs(registry)); expect(callOrder).toEqual(['decode-a-start', 'decode-b-start', 'decode-sync']); @@ -415,75 +364,80 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { }); it('always awaits codec.decode and yields plain values (no Promise leaks)', async () => { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/async@1', targetTypes: ['text'], encode: (v: string) => v, decode: async (w: string) => `decoded:${w}`, }), - ); + ]; const plan = buildAstPlan({ projections: [{ alias: 'name', codecId: 'test/async@1' }], }); - const result = await decodeRow({ name: 'alice' }, plan, registry, undefined, {}); + const result = await decodeRow({ name: 'alice' }, plan, {}, buildTestContractCodecs(registry)); expect(typeof (result['name'] as { then?: unknown } | null)?.then).toBe('undefined'); expect(result['name']).toBe('decoded:alice'); }); - it('runs JSON-Schema validation against the resolved (awaited) decoded value', async () => { - const registry = buildJsonbRegistry(); - const validators: JsonSchemaValidatorRegistry = { - get: (key) => - key === 'user.metadata' - ? buildValidator( - (v) => typeof v === 'object' && v !== null && 'name' in (v as object), - "must have required property 'name'", - ) - : undefined, - size: 1, - }; + it('codec.decode that throws JSON-Schema validation envelope passes the runtime envelope through unchanged', async () => { + // Validation lives in the resolved codec's `decode` body (e.g. arktype-json validates against its rehydrated schema and throws `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED` directly). The runtime no longer maintains a parallel validator registry. Codec-authored runtime envelopes (DECODE_FAILED, ABORTED, JSON_SCHEMA_VALIDATION_FAILED, …) carry their own per-codec context, so `decodeField` rethrows them unchanged instead of coercing them into a fresh `RUNTIME.DECODE_FAILED` (which would erase the original code and details). + const registry = [ + defineTestCodec<'pg/inline-validating-json@1', readonly [], string, JsonValue>({ + typeId: 'pg/inline-validating-json@1', + targetTypes: ['jsonb'], + encode: (v: JsonValue) => JSON.stringify(v), + decode: async (w: string) => { + const parsed = JSON.parse(w) as Record; + if (!('name' in parsed)) { + throw runtimeError( + 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', + "JSON validation failed: must have required property 'name'", + { issues: "must have required property 'name'" }, + ); + } + return parsed as JsonValue; + }, + }), + ]; const plan = buildAstPlan({ projections: [ { alias: 'metadata', - codecId: 'pg/jsonb@1', + codecId: 'pg/inline-validating-json@1', column: { table: 'user', column: 'metadata' }, }, ], }); - const result = await decodeRow( + const ok = await decodeRow( { metadata: '{"name":"alice"}' }, plan, - registry, - validators, {}, + buildTestContractCodecs(registry), ); - expect(result['metadata']).toEqual({ name: 'alice' }); + expect(ok['metadata']).toEqual({ name: 'alice' }); - await expect( - decodeRow({ metadata: '{"age":30}' }, plan, registry, validators, {}), - ).rejects.toMatchObject({ - code: 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', - details: { - table: 'user', - column: 'metadata', - direction: 'decode', - codecId: 'pg/jsonb@1', - }, - }); + const rejection = (await decodeRow( + { metadata: '{"age":30}' }, + plan, + {}, + buildTestContractCodecs(registry), + ).catch((e: unknown) => e)) as Error & { + code?: string; + details?: { issues?: unknown }; + }; + expect(rejection.code).toBe('RUNTIME.JSON_SCHEMA_VALIDATION_FAILED'); + expect(rejection.details?.issues).toBe("must have required property 'name'"); }); it('wraps decode failures in RUNTIME.DECODE_FAILED with { table, column, codec } and cause', async () => { const cause = new Error('boom'); - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/explody@1', targetTypes: ['text'], encode: (v: string) => v, @@ -491,7 +445,7 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { throw cause; }, }), - ); + ]; const plan = buildAstPlan({ projections: [ @@ -504,7 +458,7 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { }); await expect( - decodeRow({ explody: 'wire' }, plan, registry, undefined, {}), + decodeRow({ explody: 'wire' }, plan, {}, buildTestContractCodecs(registry)), ).rejects.toMatchObject({ code: 'RUNTIME.DECODE_FAILED', category: 'RUNTIME', @@ -519,9 +473,8 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { }); it('passes wire values through for raw plans (no AST, no codec decoding)', async () => { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/should-not-run@1', targetTypes: ['text'], encode: (v: string) => v, @@ -529,20 +482,27 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { throw new Error('raw plans must skip codec decoding'); }, }), - ); + ]; const plan = buildRawPlan(); - const result = await decodeRow({ id: 1, email: 'a@b.com' }, plan, registry, undefined, {}); + const result = await decodeRow( + { id: 1, email: 'a@b.com' }, + plan, + {}, + buildTestContractCodecs(registry), + ); expect(result).toEqual({ id: 1, email: 'a@b.com' }); }); it('throws RUNTIME.DECODE_FAILED for AST-backed plan when row is missing a projection alias', async () => { - const registry = createCodecRegistry(); + const registry: ReadonlyArray> = []; const plan = buildAstPlan({ projections: [{ alias: 'id' }, { alias: 'email' }], }); - await expect(decodeRow({ id: 1 }, plan, registry, undefined, {})).rejects.toMatchObject({ + await expect( + decodeRow({ id: 1 }, plan, {}, buildTestContractCodecs(registry)), + ).rejects.toMatchObject({ code: 'RUNTIME.DECODE_FAILED', details: { alias: 'email', @@ -553,12 +513,8 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { }); it('preserves wire null for AST-backed plans (distinct from missing alias)', async () => { - const registry = createCodecRegistry(); - const plan = buildAstPlan({ - projections: [{ alias: 'id', codecId: 'test/should-not-run@1' }], - }); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/should-not-run@1', targetTypes: ['text'], encode: (v: string) => v, @@ -566,40 +522,40 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { throw new Error('codec must not be invoked for null wire values'); }, }), - ); + ]; + const plan = buildAstPlan({ + projections: [{ alias: 'id', codecId: 'test/should-not-run@1' }], + }); - const result = await decodeRow({ id: null }, plan, registry, undefined, {}); + const result = await decodeRow({ id: null }, plan, {}, buildTestContractCodecs(registry)); expect(result).toEqual({ id: null }); }); it('decodeField is single-armed: same path for sync and async codec authors', async () => { - const registry = createCodecRegistry(); const buildCodec = ( id: string, encode: (value: string) => string, decode: (wire: string) => string | Promise, ): Codec => - codec({ + defineTestCodec({ typeId: id, targetTypes: ['text'], encode, decode, }); - registry.register( + const registry = [ buildCodec( 'sync@1', (v) => v, (w) => `sync:${String(w)}`, ), - ); - registry.register( buildCodec( 'async@1', (v) => v, async (w) => `async:${String(w)}`, ), - ); + ]; const plan = buildAstPlan({ projections: [ @@ -608,14 +564,17 @@ describe('decodeRow — async, concurrent per-cell dispatch', () => { ], }); - const result = await decodeRow({ syncCol: 'a', asyncCol: 'b' }, plan, registry, undefined, {}); + const result = await decodeRow( + { syncCol: 'a', asyncCol: 'b' }, + plan, + {}, + buildTestContractCodecs(registry), + ); expect(result).toEqual({ syncCol: 'sync:a', asyncCol: 'async:b' }); }); }); -// ============================================================================= -// seeded-secret-codec — realistic crypto roundtrip + envelopes -// ============================================================================= +// ============================================================================= seeded-secret-codec — realistic crypto roundtrip + envelopes ============================================================================= describe('seeded-secret-codec — realistic crypto path against the runtime', () => { const seed = 'codec-async-test-seed'; @@ -624,14 +583,13 @@ describe('seeded-secret-codec — realistic crypto path against the runtime', () 'encodeParams encrypts plaintext via async codec.encode (no Promise leaks)', { timeout: timeouts.databaseOperation }, async () => { - const registry = createCodecRegistry(); - registry.register(createAsyncSecretCodec({ typeId: 'pg/secret@1', seed })); + const registry = [createAsyncSecretCodec({ typeId: 'pg/secret@1', seed })]; const plan = buildAstPlan({ params: [{ value: 'Alice', codecId: 'pg/secret@1', name: 'secret' }], }); - const result = await encodeParams(plan, registry, {}); + const result = await encodeParams(plan, {}, buildTestContractCodecs(registry)); const wire = result[0]; expect(typeof wire).toBe('string'); expect(wire).not.toBe('Alice'); @@ -643,8 +601,7 @@ describe('seeded-secret-codec — realistic crypto path against the runtime', () 'decodeRow decrypts ciphertext via async codec.decode and yields plain values', { timeout: timeouts.databaseOperation }, async () => { - const registry = createCodecRegistry(); - registry.register(createAsyncSecretCodec({ typeId: 'pg/secret@1', seed })); + const registry = [createAsyncSecretCodec({ typeId: 'pg/secret@1', seed })]; const wire = await encryptSecret('top-secret', seed); const plan = buildAstPlan({ @@ -657,14 +614,13 @@ describe('seeded-secret-codec — realistic crypto path against the runtime', () ], }); - const result = await decodeRow({ secret: wire }, plan, registry, undefined, {}); + const result = await decodeRow({ secret: wire }, plan, {}, buildTestContractCodecs(registry)); expect(result['secret']).toBe('top-secret'); }, ); it('decode failures from async crypto are wrapped in RUNTIME.DECODE_FAILED with cause', async () => { - const registry = createCodecRegistry(); - registry.register(createAsyncSecretCodec({ typeId: 'pg/secret@1', seed })); + const registry = [createAsyncSecretCodec({ typeId: 'pg/secret@1', seed })]; const plan = buildAstPlan({ projections: [ @@ -679,9 +635,8 @@ describe('seeded-secret-codec — realistic crypto path against the runtime', () const rejection = await decodeRow( { secret: 'bad-payload' }, plan, - registry, - undefined, {}, + buildTestContractCodecs(registry), ).catch((e: unknown) => e); expect(rejection).toBeInstanceOf(Error); const err = rejection as Error & { diff --git a/packages/2-sql/5-runtime/test/codec-decode-ctx.test.ts b/packages/2-sql/5-runtime/test/codec-decode-ctx.test.ts index dadb20fdca..6f3547da7c 100644 --- a/packages/2-sql/5-runtime/test/codec-decode-ctx.test.ts +++ b/packages/2-sql/5-runtime/test/codec-decode-ctx.test.ts @@ -2,8 +2,6 @@ import { coreHash } from '@prisma-next/contract/types'; import { AggregateExpr, ColumnRef, - codec, - createCodecRegistry, LiteralExpr, ProjectionItem, SelectAst, @@ -13,6 +11,8 @@ import { import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; import { describe, expect, it } from 'vitest'; import { decodeRow } from '../src/codecs/decoding'; +import { defineTestCodec } from './test-codec'; +import { buildTestContractCodecs } from './utils'; const TEST_HASH = coreHash('sha256:test'); @@ -56,9 +56,8 @@ function deferred(): { describe('decodeRow — SqlCodecCallContext threading', () => { it('forwards a per-cell ctx whose signal is the same instance as the row-level ctx (signal identity preserved)', async () => { const observed: AbortSignal[] = []; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/observe@1', targetTypes: ['text'], encode: (v: string) => v, @@ -67,7 +66,7 @@ describe('decodeRow — SqlCodecCallContext threading', () => { return w; }, }), - ); + ]; const p = buildPlan([ columnProjection('a', 'users', 'a', 'test/observe@1'), @@ -76,7 +75,7 @@ describe('decodeRow — SqlCodecCallContext threading', () => { const controller = new AbortController(); const rowCtx: SqlCodecCallContext = { signal: controller.signal }; - await decodeRow({ a: 'A', b: 'B' }, p, registry, undefined, rowCtx); + await decodeRow({ a: 'A', b: 'B' }, p, rowCtx, buildTestContractCodecs(registry)); expect(observed).toHaveLength(2); expect(observed[0]).toBe(controller.signal); @@ -85,9 +84,8 @@ describe('decodeRow — SqlCodecCallContext threading', () => { it('populates ctx.column = { table, name } for cells whose ColumnRef resolves', async () => { const observed: { alias: string; column: SqlCodecCallContext['column'] }[] = []; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/observe-col@1', targetTypes: ['text'], encode: (v: string) => v, @@ -96,16 +94,19 @@ describe('decodeRow — SqlCodecCallContext threading', () => { return w; }, }), - ); + ]; const p = buildPlan([ columnProjection('email', 'users', 'email', 'test/observe-col@1'), columnProjection('total', 'orders', 'total', 'test/observe-col@1'), ]); - await decodeRow({ email: 'email', total: 'total' }, p, registry, undefined, { - signal: new AbortController().signal, - }); + await decodeRow( + { email: 'email', total: 'total' }, + p, + { signal: new AbortController().signal }, + buildTestContractCodecs(registry), + ); expect(observed).toEqual([ { alias: 'email', column: { table: 'users', name: 'email' } }, @@ -115,9 +116,8 @@ describe('decodeRow — SqlCodecCallContext threading', () => { it('populates ctx.column when the projection points at a different table.column than the alias', async () => { let observed: SqlCodecCallContext | undefined; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/observe-projection@1', targetTypes: ['text'], encode: (v: string) => v, @@ -126,24 +126,26 @@ describe('decodeRow — SqlCodecCallContext threading', () => { return w; }, }), - ); + ]; const p = buildPlan([ columnProjection('secret', 'user', 'secret', 'test/observe-projection@1'), ]); - await decodeRow({ secret: 'wire' }, p, registry, undefined, { - signal: new AbortController().signal, - }); + await decodeRow( + { secret: 'wire' }, + p, + { signal: new AbortController().signal }, + buildTestContractCodecs(registry), + ); expect(observed?.column).toEqual({ table: 'user', name: 'secret' }); }); it('leaves ctx.column undefined for cells the runtime cannot resolve to a single (table, name) — aggregate projection', async () => { let observed: SqlCodecCallContext | undefined; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/observe-undef@1', targetTypes: ['text'], encode: (v: string) => v, @@ -152,23 +154,20 @@ describe('decodeRow — SqlCodecCallContext threading', () => { return w; }, }), - ); + ]; const p = buildPlan([ - // Aggregate (count) projections are not single-column refs, so the - // runtime cannot project a `{ table, name }` for them. + // Aggregate (count) projections are not single-column refs, so the runtime cannot project a `{ table, name }` for them. ProjectionItem.of('agg', AggregateExpr.count(), 'test/observe-undef@1'), ]); - // Seed the row ctx with a stale `column` to confirm unresolved cells - // explicitly clear inherited `column` rather than passing `rowCtx` - // through unchanged. + // Seed the row ctx with a stale `column` to confirm unresolved cells explicitly clear inherited `column` rather than passing `rowCtx` through unchanged. const rowCtx: SqlCodecCallContext = { signal: new AbortController().signal, column: { table: 'stale', name: 'stale' }, }; - await decodeRow({ agg: '1' }, p, registry, undefined, rowCtx); + await decodeRow({ agg: '1' }, p, rowCtx, buildTestContractCodecs(registry)); expect(observed).toBeDefined(); expect(observed?.column).toBeUndefined(); @@ -176,9 +175,8 @@ describe('decodeRow — SqlCodecCallContext threading', () => { it('leaves ctx.column undefined for non-column-ref projections (computed expression)', async () => { let observed: SqlCodecCallContext | undefined; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/observe-no-ref@1', targetTypes: ['text'], encode: (v: string) => v, @@ -187,7 +185,7 @@ describe('decodeRow — SqlCodecCallContext threading', () => { return w; }, }), - ); + ]; const p = buildPlan([ ProjectionItem.of('computed', LiteralExpr.of(1), 'test/observe-no-ref@1'), @@ -198,7 +196,7 @@ describe('decodeRow — SqlCodecCallContext threading', () => { column: { table: 'stale', name: 'stale' }, }; - await decodeRow({ computed: 'wire' }, p, registry, undefined, rowCtx); + await decodeRow({ computed: 'wire' }, p, rowCtx, buildTestContractCodecs(registry)); expect(observed).toBeDefined(); expect(observed?.column).toBeUndefined(); @@ -207,9 +205,8 @@ describe('decodeRow — SqlCodecCallContext threading', () => { it('1-arg codec authors observe no behavioral change when ctx is the default empty ctx', async () => { let invoked = 0; let receivedWire: unknown; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/single-arg-author@1', targetTypes: ['text'], encode: (v: string) => v, @@ -219,11 +216,11 @@ describe('decodeRow — SqlCodecCallContext threading', () => { return w; }, }), - ); + ]; const p = buildPlan([columnProjection('x', 'users', 'x', 'test/single-arg-author@1')]); - const result = await decodeRow({ x: 'wire' }, p, registry, undefined, {}); + const result = await decodeRow({ x: 'wire' }, p, {}, buildTestContractCodecs(registry)); expect(result).toEqual({ x: 'wire' }); expect(invoked).toBe(1); expect(receivedWire).toBe('wire'); @@ -231,9 +228,8 @@ describe('decodeRow — SqlCodecCallContext threading', () => { it('already-aborted signal at entry short-circuits before any codec call', async () => { let callCount = 0; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/counter@1', targetTypes: ['text'], encode: (v: string) => v, @@ -242,7 +238,7 @@ describe('decodeRow — SqlCodecCallContext threading', () => { return w; }, }), - ); + ]; const p = buildPlan([ columnProjection('a', 'users', 'a', 'test/counter@1'), @@ -254,7 +250,12 @@ describe('decodeRow — SqlCodecCallContext threading', () => { controller.abort(reason); await expect( - decodeRow({ a: '1', b: '2' }, p, registry, undefined, { signal: controller.signal }), + decodeRow( + { a: '1', b: '2' }, + p, + { signal: controller.signal }, + buildTestContractCodecs(registry), + ), ).rejects.toMatchObject({ code: 'RUNTIME.ABORTED', details: { phase: 'decode' }, @@ -265,23 +266,25 @@ describe('decodeRow — SqlCodecCallContext threading', () => { it('mid-decode abort surfaces RUNTIME.ABORTED { phase: decode } via abortable race', async () => { const release = deferred(); - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/blocking@1', targetTypes: ['text'], encode: (v: string) => v, decode: (w: string) => release.promise.then((suffix) => `${w}:${suffix}`), }), - ); + ]; const p = buildPlan([columnProjection('x', 'users', 'x', 'test/blocking@1')]); const controller = new AbortController(); const reason = new Error('mid-decode abort'); - const promise = decodeRow({ x: 'wire' }, p, registry, undefined, { - signal: controller.signal, - }); + const promise = decodeRow( + { x: 'wire' }, + p, + { signal: controller.signal }, + buildTestContractCodecs(registry), + ); queueMicrotask(() => controller.abort(reason)); @@ -294,11 +297,10 @@ describe('decodeRow — SqlCodecCallContext threading', () => { release.resolve('done'); }); - it('passes through RUNTIME.DECODE_FAILED unchanged when the codec body throws (no double-wrap, AC-ERR4)', async () => { + it('passes through RUNTIME.DECODE_FAILED unchanged when the codec body throws (no double-wrap)', async () => { const cause = new Error('decode boom'); - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/explody@1', targetTypes: ['text'], encode: (v: string) => v, @@ -306,12 +308,17 @@ describe('decodeRow — SqlCodecCallContext threading', () => { throw cause; }, }), - ); + ]; const p = buildPlan([columnProjection('x', 'users', 'x', 'test/explody@1')]); await expect( - decodeRow({ x: 'wire' }, p, registry, undefined, { signal: new AbortController().signal }), + decodeRow( + { x: 'wire' }, + p, + { signal: new AbortController().signal }, + buildTestContractCodecs(registry), + ), ).rejects.toMatchObject({ code: 'RUNTIME.DECODE_FAILED', cause, @@ -319,15 +326,10 @@ describe('decodeRow — SqlCodecCallContext threading', () => { }); it('reuses the existing per-cell ColumnRef resolution: the column passed to the codec matches the table/name used by RUNTIME.DECODE_FAILED for the same cell', async () => { - // The codec records the column it observes via ctx; the same plan - // exercises the failure path by throwing on a different cell. The - // observed `ctx.column` for the success cell must match the - // `{ table, column }` shape the runtime would have constructed for - // the error envelope (proving the resolution is shared, not duplicated). + // The codec records the column it observes via ctx; the same plan exercises the failure path by throwing on a different cell. The observed `ctx.column` for the success cell must match the `{ table, column }` shape the runtime would have constructed for the error envelope (proving the resolution is shared, not duplicated). const observedColumns: SqlCodecCallContext['column'][] = []; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/recorder@1', targetTypes: ['text'], encode: (v: string) => v, @@ -336,17 +338,18 @@ describe('decodeRow — SqlCodecCallContext threading', () => { return w; }, }), - ); + ]; const p = buildPlan([columnProjection('email', 'users', 'email', 'test/recorder@1')]); - await decodeRow({ email: 'wire' }, p, registry, undefined, { - signal: new AbortController().signal, - }); + await decodeRow( + { email: 'wire' }, + p, + { signal: new AbortController().signal }, + buildTestContractCodecs(registry), + ); - // SqlColumnRef shape `{ table, name }` projected from the ColumnRef - // shape `{ table, column }` the resolver returns — same source, one - // resolution per cell. + // SqlColumnRef shape `{ table, name }` projected from the ColumnRef shape `{ table, column }` the resolver returns — same source, one resolution per cell. expect(observedColumns).toEqual([{ table: 'users', name: 'email' }]); }); }); diff --git a/packages/2-sql/5-runtime/test/codec-encode-ctx.test.ts b/packages/2-sql/5-runtime/test/codec-encode-ctx.test.ts index 14bb7ee496..e1fbed76e6 100644 --- a/packages/2-sql/5-runtime/test/codec-encode-ctx.test.ts +++ b/packages/2-sql/5-runtime/test/codec-encode-ctx.test.ts @@ -3,9 +3,8 @@ import { AndExpr, type AnyExpression, BinaryExpr, + type Codec, ColumnRef, - codec, - createCodecRegistry, ParamRef, SelectAst, type SqlCodecCallContext, @@ -14,6 +13,8 @@ import { import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; import { describe, expect, it } from 'vitest'; import { encodeParam, encodeParams } from '../src/codecs/encoding'; +import { defineTestCodec } from './test-codec'; +import { buildTestContractCodecs } from './utils'; const TEST_HASH = coreHash('sha256:test'); @@ -69,9 +70,8 @@ function deferred(): { describe('encodeParams — SqlCodecCallContext threading', () => { it('forwards the same ctx instance to every per-param codec.encode', async () => { const observed: (SqlCodecCallContext | undefined)[] = []; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/observe@1', targetTypes: ['text'], encode: (value: string, ctx?: SqlCodecCallContext) => { @@ -80,7 +80,7 @@ describe('encodeParams — SqlCodecCallContext threading', () => { }, decode: (wire: string) => wire, }), - ); + ]; const p = buildPlan([ { value: 'a', codecId: 'test/observe@1', name: 'p0' }, @@ -90,7 +90,7 @@ describe('encodeParams — SqlCodecCallContext threading', () => { const controller = new AbortController(); const ctx: SqlCodecCallContext = { signal: controller.signal }; - await encodeParams(p, registry, ctx); + await encodeParams(p, ctx, buildTestContractCodecs(registry)); expect(observed).toHaveLength(3); for (const seen of observed) { @@ -100,9 +100,8 @@ describe('encodeParams — SqlCodecCallContext threading', () => { it('leaves ctx.column undefined on encode call sites (encode-time column-context is the middleware domain)', async () => { let observed: SqlCodecCallContext | undefined; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/observe-column@1', targetTypes: ['text'], encode: (value: string, ctx?: SqlCodecCallContext) => { @@ -111,38 +110,43 @@ describe('encodeParams — SqlCodecCallContext threading', () => { }, decode: (wire: string) => wire, }), - ); + ]; const p = buildPlan([{ value: 'x', codecId: 'test/observe-column@1' }]); - await encodeParams(p, registry, { signal: new AbortController().signal }); + await encodeParams( + p, + { signal: new AbortController().signal }, + buildTestContractCodecs(registry), + ); expect(observed?.column).toBeUndefined(); }); it('regression — omitting ctx is bit-for-bit identical to today (no-ctx case)', async () => { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/passthrough@1', targetTypes: ['text'], encode: (value: string) => `wire:${value}`, decode: (wire: string) => wire, }), - ); + ]; const p = buildPlan([ { value: 'x', codecId: 'test/passthrough@1' }, { value: 'y', codecId: 'test/passthrough@1' }, ]); - expect(await encodeParams(p, registry, {})).toEqual(['wire:x', 'wire:y']); + expect(await encodeParams(p, {}, buildTestContractCodecs(registry))).toEqual([ + 'wire:x', + 'wire:y', + ]); }); it('already-aborted signal at entry short-circuits before any codec call', async () => { let callCount = 0; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/counter@1', targetTypes: ['text'], encode: (value: string) => { @@ -151,7 +155,7 @@ describe('encodeParams — SqlCodecCallContext threading', () => { }, decode: (wire: string) => wire, }), - ); + ]; const p = buildPlan([ { value: 'a', codecId: 'test/counter@1' }, @@ -162,7 +166,9 @@ describe('encodeParams — SqlCodecCallContext threading', () => { const reason = new Error('user cancelled'); controller.abort(reason); - await expect(encodeParams(p, registry, { signal: controller.signal })).rejects.toMatchObject({ + await expect( + encodeParams(p, { signal: controller.signal }, buildTestContractCodecs(registry)), + ).rejects.toMatchObject({ code: 'RUNTIME.ABORTED', details: { phase: 'encode' }, cause: reason, @@ -171,13 +177,13 @@ describe('encodeParams — SqlCodecCallContext threading', () => { }); it('already-aborted signal short-circuits even for empty param lists', async () => { - const registry = createCodecRegistry(); + const registry: ReadonlyArray> = []; const controller = new AbortController(); const reason = new Error('encode short-circuit'); controller.abort(reason); await expect( - encodeParams(buildPlan([]), registry, { signal: controller.signal }), + encodeParams(buildPlan([]), { signal: controller.signal }, buildTestContractCodecs(registry)), ).rejects.toMatchObject({ code: 'RUNTIME.ABORTED', details: { phase: 'encode' }, @@ -187,21 +193,24 @@ describe('encodeParams — SqlCodecCallContext threading', () => { it('mid-encode abort surfaces RUNTIME.ABORTED { phase: encode } via abortable race', async () => { const release = deferred(); - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/blocking@1', targetTypes: ['text'], encode: (value: string) => release.promise.then((suffix) => `${value}:${suffix}`), decode: (wire: string) => wire, }), - ); + ]; const p = buildPlan([{ value: 'v', codecId: 'test/blocking@1' }]); const controller = new AbortController(); const reason = new Error('mid-encode abort'); - const promise = encodeParams(p, registry, { signal: controller.signal }); + const promise = encodeParams( + p, + { signal: controller.signal }, + buildTestContractCodecs(registry), + ); // Let the codec start, then abort. queueMicrotask(() => controller.abort(reason)); @@ -212,16 +221,14 @@ describe('encodeParams — SqlCodecCallContext threading', () => { cause: reason, }); - // Release the in-flight body so the test can clean up; cooperative - // cancellation lets it complete in the background without leaks. + // Release the in-flight body so the test can clean up; cooperative cancellation lets it complete in the background without leaks. release.resolve('done'); }); it('passes through RUNTIME.ENCODE_FAILED when the codec body throws before the runtime sees the abort (no double-wrap)', async () => { const cause = new Error('codec specific failure'); - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/explody@1', targetTypes: ['text'], encode: () => { @@ -229,12 +236,12 @@ describe('encodeParams — SqlCodecCallContext threading', () => { }, decode: (wire: string) => wire, }), - ); + ]; const p = buildPlan([{ value: 'x', codecId: 'test/explody@1' }]); await expect( - encodeParams(p, registry, { signal: new AbortController().signal }), + encodeParams(p, { signal: new AbortController().signal }, buildTestContractCodecs(registry)), ).rejects.toMatchObject({ code: 'RUNTIME.ENCODE_FAILED', cause, @@ -245,9 +252,8 @@ describe('encodeParams — SqlCodecCallContext threading', () => { describe('encodeParam — ctx forwarded to codec.encode', () => { it('forwards ctx (signal) to a codec body that accepts (value, ctx)', async () => { let observedSignal: AbortSignal | undefined; - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/single-cell@1', targetTypes: ['text'], encode: (value: string, ctx?: SqlCodecCallContext) => { @@ -256,20 +262,23 @@ describe('encodeParam — ctx forwarded to codec.encode', () => { }, decode: (wire: string) => wire, }), - ); + ]; const controller = new AbortController(); - await encodeParam('x', { codecId: 'test/single-cell@1' }, 0, registry, { - signal: controller.signal, - }); + await encodeParam( + 'x', + { codecId: 'test/single-cell@1' }, + 0, + { signal: controller.signal }, + buildTestContractCodecs(registry), + ); expect(observedSignal).toBe(controller.signal); }); it('null/undefined values still bypass the codec when ctx is provided', async () => { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry = [ + defineTestCodec({ typeId: 'test/never@1', targetTypes: ['text'], encode: () => { @@ -277,14 +286,20 @@ describe('encodeParam — ctx forwarded to codec.encode', () => { }, decode: (wire: string) => wire, }), - ); + ]; const ctx: SqlCodecCallContext = { signal: new AbortController().signal }; await expect( - encodeParam(null, { codecId: 'test/never@1' }, 0, registry, ctx), + encodeParam(null, { codecId: 'test/never@1' }, 0, ctx, buildTestContractCodecs(registry)), ).resolves.toBeNull(); await expect( - encodeParam(undefined, { codecId: 'test/never@1' }, 0, registry, ctx), + encodeParam( + undefined, + { codecId: 'test/never@1' }, + 0, + ctx, + buildTestContractCodecs(registry), + ), ).resolves.toBeNull(); }); }); diff --git a/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts b/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts index 11e46e9b01..b64d0d5c3a 100644 --- a/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts +++ b/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts @@ -1,72 +1,61 @@ import type { Contract } from '@prisma-next/contract/types'; import { coreHash, profileHash } from '@prisma-next/contract/types'; -import type { CodecInstanceContext } from '@prisma-next/framework-components/codec'; +import type { + CodecDescriptor, + CodecInstanceContext, +} from '@prisma-next/framework-components/codec'; +import { voidParamsSchema } from '@prisma-next/framework-components/codec'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { Codec } from '@prisma-next/sql-relational-core/ast'; -import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; import { ifDefined } from '@prisma-next/utils/defined'; import { describe, expect, it } from 'vitest'; import type { RuntimeParameterizedCodecDescriptor, SqlRuntimeExtensionDescriptor, } from '../src/sql-context'; +import { defineTestCodec } from './test-codec'; import { createStubAdapter, createTestContext } from './utils'; -// Phase B of the codec-registry-unification project introduces two -// runtime registries: -// -// - `ContractCodecRegistry` (`context.contractCodecs`): per-column -// resolved-codec dispatch with `forColumn(table, column)` and a codec- -// id-keyed fallback `forCodecId(codecId)` (the AC-5-deferred carve-out -// for sites without a column ref). -// - `CodecDescriptorRegistry` (`context.codecDescriptors`): codec-id- -// keyed metadata read with `descriptorFor(codecId)` — non-branching for -// parameterized vs. non-parameterized codecs (every non-parameterized -// codec is auto-lifted into a synthesized `CodecDescriptor`). +// The codec-registry layer exposes two runtime registries: // -// See spec § Decision and AC-3, AC-4. +// - `ContractCodecRegistry` (`context.contractCodecs`): per-column resolved-codec dispatch with `forColumn(table, column)` and a codec-id-keyed fallback `forCodecId(codecId)` for sites without a column ref. +// - `CodecDescriptorRegistry` (`context.codecDescriptors`): codec-id-keyed metadata read with `descriptorFor(codecId)` — non-branching for parameterized vs. non-parameterized codecs (every non-parameterized codec is auto-lifted into a synthesized `CodecDescriptor`). function makeVectorCodec(meta?: Record): Codec { - const baseCodec = codec({ + const baseCodec = defineTestCodec({ typeId: 'pg/vector@1', - targetTypes: ['vector'], encode: (v: number[]) => v, decode: (w: number[]) => w, }); if (!meta) return baseCodec; - // SQL-side `Codec` declares `meta?: CodecMeta`; cast to the - // non-undefined branch under `exactOptionalPropertyTypes`. - return { ...baseCodec, meta: meta as NonNullable }; + // The narrow `Codec` shape is conversion-only (TML-2357); the `meta` sentinel here is test-side bookkeeping that downstream assertions read off the exact instance handed back by the factory. + return { ...baseCodec, meta } as unknown as Codec; } function createVectorExtensionDescriptor(): SqlRuntimeExtensionDescriptor<'postgres'> { - // The factory returns a per-instance codec whose `meta.length` carries - // the parameter — so tests can observe per-instance differentiation. + // The factory returns a per-instance codec whose `meta.length` carries the parameter — so tests can observe per-instance differentiation. const factory: (params: { length: number }) => (ctx: CodecInstanceContext) => Codec = (params) => (_ctx) => makeVectorCodec({ length: params.length }); - const parameterizedCodecs: RuntimeParameterizedCodecDescriptor<{ length: number }>[] = [ - { - codecId: 'pg/vector@1', - traits: ['equality'], - targetTypes: ['vector'], - paramsSchema: { - '~standard': { - version: 1, - vendor: 'test', - validate: (value) => ({ value: value as { length: number } }), - }, + const vectorDescriptor: RuntimeParameterizedCodecDescriptor<{ length: number }> = { + codecId: 'pg/vector@1', + traits: ['equality'], + targetTypes: ['vector'], + paramsSchema: { + '~standard': { + version: 1, + vendor: 'test', + validate: (value) => ({ value: value as { length: number } }), }, - factory, }, - ]; + isParameterized: true, + factory, + }; - // The legacy `codecs:` registration carries a representative codec used - // as the codec-id fallback. Production parameterized descriptors ship - // the same shape today. - const registry = createCodecRegistry(); - registry.register(makeVectorCodec()); + const descriptors: ReadonlyArray = [ + vectorDescriptor as unknown as CodecDescriptor, + ]; return { kind: 'extension' as const, @@ -74,8 +63,7 @@ function createVectorExtensionDescriptor(): SqlRuntimeExtensionDescriptor<'postg version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => registry, - parameterizedCodecs: () => parameterizedCodecs, + codecs: () => descriptors, create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -83,17 +71,22 @@ function createVectorExtensionDescriptor(): SqlRuntimeExtensionDescriptor<'postg } function createNonParameterizedExtensionDescriptor(): SqlRuntimeExtensionDescriptor<'postgres'> { - const registry = createCodecRegistry(); - // Custom codec id avoids colliding with the default test target - // descriptor's pre-registered codecs (`pg/text@1`, etc.). - registry.register( - codec({ - typeId: 'test/scalar@1', - targetTypes: ['scalar'], - encode: (v: string) => v, - decode: (w: string) => w, - }), - ); + // Custom codec id avoids colliding with the default test target descriptor's pre-registered codecs (`pg/text@1`, etc.). + const scalarCodec = defineTestCodec({ + typeId: 'test/scalar@1', + targetTypes: ['scalar'], + encode: (v: string) => v, + decode: (w: string) => w, + }); + + const scalarDescriptor: CodecDescriptor = { + codecId: 'test/scalar@1', + traits: [], + targetTypes: ['scalar'], + paramsSchema: voidParamsSchema, + isParameterized: false, + factory: () => () => scalarCodec, + }; return { kind: 'extension' as const, @@ -101,8 +94,7 @@ function createNonParameterizedExtensionDescriptor(): SqlRuntimeExtensionDescrip version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => registry, - parameterizedCodecs: () => [], + codecs: () => [scalarDescriptor], create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -174,9 +166,7 @@ describe('ContractCodecRegistry', () => { const resolved = context.contractCodecs.forColumn('Doc', 'embedding'); expect(resolved).toBeDefined(); - // The per-instance codec carries the column's `length` on its meta — - // confirms the dispatch path resolves through `factory(typeParams) - // (ctx)`, not the codec-id-keyed fallback. + // The per-instance codec carries the column's `length` on its meta — confirms the dispatch path resolves through `factory(typeParams) (ctx)`, not the codec-id-keyed fallback. expect((resolved as Codec & { meta: { length: number } }).meta.length).toBe(768); }); @@ -240,8 +230,7 @@ describe('ContractCodecRegistry', () => { expect(primaryCodec).toBeDefined(); expect(secondaryCodec).toBeDefined(); - // Non-parameterized codec ids share one codec instance across every - // column with that id. + // Non-parameterized codec ids share one codec instance across every column with that id. expect(primaryCodec).toBe(secondaryCodec); expect(primaryCodec?.id).toBe('test/scalar@1'); }); @@ -273,9 +262,6 @@ describe('ContractCodecRegistry', () => { const codecById = context.contractCodecs.forCodecId('test/scalar@1'); expect(codecById).toBeDefined(); expect(codecById?.id).toBe('test/scalar@1'); - // The codec-id fallback returns the same instance the legacy - // CodecRegistry.get(id) returns for non-parameterized codecs. - expect(codecById).toBe(context.codecs.get('test/scalar@1')); }); it('forCodecId returns undefined for an unknown codec id', () => { @@ -331,8 +317,7 @@ describe('CodecDescriptorRegistry', () => { }); it('descriptorFor reads use the same call shape for parameterized and non-parameterized codec ids', () => { - // The defining property of the unified descriptor map: callers don't - // need to know whether a codec id is parameterized to read its traits. + // The defining property of the unified descriptor map: callers don't need to know whether a codec id is parameterized to read its traits. const contract = createTestContract({ Doc: { embedding: { @@ -358,8 +343,7 @@ describe('CodecDescriptorRegistry', () => { context.codecDescriptors.descriptorFor(codecId)?.traits ?? []; expect(traitsByCodecId('pg/vector@1')).toEqual(['equality']); - // Synthesized descriptors carry empty traits if the codec didn't - // declare any. + // Synthesized descriptors carry empty traits if the codec didn't declare any. expect(traitsByCodecId('test/scalar@1')).toEqual([]); }); diff --git a/packages/2-sql/5-runtime/test/decode-error-passthrough.test.ts b/packages/2-sql/5-runtime/test/decode-error-passthrough.test.ts new file mode 100644 index 0000000000..328424b3b2 --- /dev/null +++ b/packages/2-sql/5-runtime/test/decode-error-passthrough.test.ts @@ -0,0 +1,100 @@ +import { coreHash } from '@prisma-next/contract/types'; +import { runtimeAborted, runtimeError } from '@prisma-next/framework-components/runtime'; +import { + ColumnRef, + ProjectionItem, + SelectAst, + TableSource, +} from '@prisma-next/sql-relational-core/ast'; +import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; +import { describe, expect, it } from 'vitest'; +import { decodeRow } from '../src/codecs/decoding'; +import { defineTestCodec } from './test-codec'; +import { buildTestContractCodecs } from './utils'; + +const TEST_HASH = coreHash('sha256:test'); + +function buildPlan(): SqlExecutionPlan { + const ast = SelectAst.from(TableSource.named('users')).withProjection([ + ProjectionItem.of('value', ColumnRef.of('users', 'value'), 'test/passthrough@1'), + ]); + return { + sql: 'select value from users', + params: [], + ast, + meta: { + target: 'postgres', + storageHash: TEST_HASH, + lane: 'dsl', + }, + }; +} + +describe('decodeRow — runtime-envelope passthrough', () => { + it('rethrows codec-authored RUNTIME.DECODE_FAILED without wrapping', async () => { + const original = runtimeError('RUNTIME.DECODE_FAILED', 'codec-authored failure', { + table: 'users', + column: 'value', + codec: 'test/passthrough@1', + detail: 'codec-specific', + }); + const registry = [ + defineTestCodec({ + typeId: 'test/passthrough@1', + targetTypes: ['text'], + encode: (v: string) => v, + decode: () => { + throw original; + }, + }), + ]; + + await expect( + decodeRow({ value: 'wire' }, buildPlan(), {}, buildTestContractCodecs(registry)), + ).rejects.toBe(original); + }); + + it('rethrows codec-authored RUNTIME.ABORTED without wrapping', async () => { + const original = runtimeAborted('decode', new Error('codec aborted')); + const registry = [ + defineTestCodec({ + typeId: 'test/passthrough@1', + targetTypes: ['text'], + encode: (v: string) => v, + decode: () => { + throw original; + }, + }), + ]; + + await expect( + decodeRow({ value: 'wire' }, buildPlan(), {}, buildTestContractCodecs(registry)), + ).rejects.toBe(original); + }); + + it('wraps a foreign Error into RUNTIME.DECODE_FAILED with the original on cause', async () => { + const original = new Error('boom'); + const registry = [ + defineTestCodec({ + typeId: 'test/passthrough@1', + targetTypes: ['text'], + encode: (v: string) => v, + decode: () => { + throw original; + }, + }), + ]; + + await expect( + decodeRow({ value: 'wire' }, buildPlan(), {}, buildTestContractCodecs(registry)), + ).rejects.toMatchObject({ + code: 'RUNTIME.DECODE_FAILED', + cause: original, + details: expect.objectContaining({ + table: 'users', + column: 'value', + codec: 'test/passthrough@1', + }), + }); + }); +}); diff --git a/packages/2-sql/5-runtime/test/encoding-dispatch.test.ts b/packages/2-sql/5-runtime/test/encoding-dispatch.test.ts new file mode 100644 index 0000000000..6c4529d473 --- /dev/null +++ b/packages/2-sql/5-runtime/test/encoding-dispatch.test.ts @@ -0,0 +1,213 @@ +import type { + Codec, + ContractCodecRegistry, + SqlCodecCallContext, +} from '@prisma-next/sql-relational-core/ast'; +import { describe, expect, it } from 'vitest'; +import { encodeParam } from '../src/codecs/encoding'; +import { defineTestCodec } from './test-codec'; + +/** + * Encode-side dispatch (AC-5): + * + * `encodeParam` consults `paramRef.refs` and resolves through `contractCodecs.forColumn(refs.table, refs.column)` when present. The codec-id-keyed fallback (`forCodecId`) is reserved for refs-less non-parameterized codec ids — parameterized codec ids reaching encode without refs are caught by `validateParamRefRefs` upstream. + */ +describe('encodeParam — column-aware dispatch', () => { + it('resolves the per-instance parameterized codec via forColumn when paramRef.refs is populated', async () => { + const codec1024 = defineTestCodec({ + typeId: 'pgvector/vector@1', + encode: (v: number[]) => `enc1024:${v.join(',')}`, + decode: (wire: string) => wire.split(',').map(Number), + }); + const codec1536 = defineTestCodec({ + typeId: 'pgvector/vector@1', + encode: (v: number[]) => `enc1536:${v.join(',')}`, + decode: (wire: string) => wire.split(',').map(Number), + }); + + const calls: Array<['forColumn', string, string] | ['forCodecId', string]> = []; + const contractCodecs: ContractCodecRegistry = { + forColumn: (table, column) => { + calls.push(['forColumn', table, column]); + if (table === 'Doc' && column === 'embedding') return codec1024; + if (table === 'Page' && column === 'embedding') return codec1536; + return undefined; + }, + forCodecId: (codecId) => { + calls.push(['forCodecId', codecId]); + return undefined; + }, + }; + + const ctx: SqlCodecCallContext = { signal: new AbortController().signal }; + + const wireDoc = await encodeParam( + [0.1, 0.2, 0.3], + { + codecId: 'pgvector/vector@1', + name: 'p0', + refs: { table: 'Doc', column: 'embedding' }, + }, + 0, + ctx, + contractCodecs, + ); + + expect(wireDoc).toBe('enc1024:0.1,0.2,0.3'); + expect(calls).toEqual([['forColumn', 'Doc', 'embedding']]); + + const wirePage = await encodeParam( + [0.4, 0.5], + { + codecId: 'pgvector/vector@1', + name: 'p0', + refs: { table: 'Page', column: 'embedding' }, + }, + 0, + ctx, + contractCodecs, + ); + + expect(wirePage).toBe('enc1536:0.4,0.5'); + expect(calls).toEqual([ + ['forColumn', 'Doc', 'embedding'], + ['forColumn', 'Page', 'embedding'], + ]); + }); + + it('falls through to forCodecId only when refs are absent (refs-less non-parameterized path)', async () => { + const scalarCodec = defineTestCodec({ + typeId: 'test/scalar@1', + encode: (v: string) => `enc:${v}`, + decode: (wire: string) => wire, + }); + + const calls: Array<['forColumn', string, string] | ['forCodecId', string]> = []; + const contractCodecs: ContractCodecRegistry = { + forColumn: (table, column) => { + calls.push(['forColumn', table, column]); + return undefined; + }, + forCodecId: (codecId) => { + calls.push(['forCodecId', codecId]); + return scalarCodec; + }, + }; + + const ctx: SqlCodecCallContext = { signal: new AbortController().signal }; + + const wire = await encodeParam( + 'hello', + { codecId: 'test/scalar@1', name: 'p0' }, + 0, + ctx, + contractCodecs, + ); + + expect(wire).toBe('enc:hello'); + expect(calls).toEqual([['forCodecId', 'test/scalar@1']]); + }); + + it('prefers forColumn when refs are present even if forCodecId would also resolve', async () => { + const columnCodec = defineTestCodec({ + typeId: 'pgvector/vector@1', + encode: (v: number[]) => `column:${v.join(',')}`, + decode: (w: string) => w.split(',').map(Number), + }); + const fallbackCodec = defineTestCodec({ + typeId: 'pgvector/vector@1', + encode: (v: number[]) => `fallback:${v.join(',')}`, + decode: (w: string) => w.split(',').map(Number), + }); + + const contractCodecs: ContractCodecRegistry = { + forColumn: () => columnCodec, + forCodecId: () => fallbackCodec, + }; + + const ctx: SqlCodecCallContext = { signal: new AbortController().signal }; + + const wire = await encodeParam( + [0.1], + { + codecId: 'pgvector/vector@1', + name: 'p0', + refs: { table: 'Doc', column: 'embedding' }, + }, + 0, + ctx, + contractCodecs, + ); + + expect(wire).toBe('column:0.1'); + }); + + it('falls back to forCodecId when forColumn returns a codec whose id disagrees with paramRef.codecId', async () => { + // Reproduces the regression behind `cosineDistance(col, x).lt(1)`: the ORM lifts a column ref out of an `OperationExpr` that changed the codec id, leaving a `ParamRef` whose `refs` point at a vector column but whose declared `codecId` is `pg/float8@1`. Encoding the float literal through the vector codec (because `forColumn` agreed with the refs but not the codec id) produced "Vector value must be an array of numbers". + const vectorCodec = defineTestCodec({ + typeId: 'pgvector/vector@1', + encode: (v: number[]) => `vec:${v.join(',')}`, + decode: (w: string) => w.split(',').map(Number), + }); + const float8Codec = defineTestCodec({ + typeId: 'pg/float8@1', + encode: (v: number) => `f8:${v}`, + decode: (w: string) => Number(w), + }); + + const contractCodecs: ContractCodecRegistry = { + forColumn: () => vectorCodec, + forCodecId: () => float8Codec, + }; + + const ctx: SqlCodecCallContext = { signal: new AbortController().signal }; + + const wire = await encodeParam( + 1, + { + codecId: 'pg/float8@1', + name: 'p0', + refs: { table: 'post', column: 'embedding' }, + }, + 0, + ctx, + contractCodecs, + ); + + expect(wire).toBe('f8:1'); + }); + + it('null/undefined values bypass codec dispatch entirely', async () => { + let invoked = false; + const codec: Codec = defineTestCodec({ + typeId: 'pgvector/vector@1', + encode: (v: number[]) => { + invoked = true; + return v; + }, + decode: (w: number[]) => w, + }); + + const contractCodecs: ContractCodecRegistry = { + forColumn: () => codec, + forCodecId: () => codec, + }; + + const ctx: SqlCodecCallContext = { signal: new AbortController().signal }; + + const result = await encodeParam( + null, + { + codecId: 'pgvector/vector@1', + name: 'p0', + refs: { table: 'Doc', column: 'embedding' }, + }, + 0, + ctx, + contractCodecs, + ); + + expect(result).toBeNull(); + expect(invoked).toBe(false); + }); +}); diff --git a/packages/2-sql/5-runtime/test/execution-stack.test.ts b/packages/2-sql/5-runtime/test/execution-stack.test.ts index b41c392315..6d1a22b729 100644 --- a/packages/2-sql/5-runtime/test/execution-stack.test.ts +++ b/packages/2-sql/5-runtime/test/execution-stack.test.ts @@ -1,5 +1,5 @@ import { createExecutionStack } from '@prisma-next/framework-components/execution'; -import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; +import type { Codec } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { createExecutionContext, createSqlExecutionStack } from '../src/exports'; import type { @@ -8,18 +8,18 @@ import type { SqlRuntimeExtensionDescriptor, SqlRuntimeTargetDescriptor, } from '../src/sql-context'; -import { createTestContract } from './utils'; +import { defineTestCodec } from './test-codec'; +import { createTestContract, descriptorsFromCodecs } from './utils'; function createStubAdapterDescriptor(): SqlRuntimeAdapterDescriptor<'postgres'> { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry: ReadonlyArray> = [ + defineTestCodec({ typeId: 'pg/text@1', targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string) => wire, }), - ); + ]; return { kind: 'adapter', @@ -27,8 +27,7 @@ function createStubAdapterDescriptor(): SqlRuntimeAdapterDescriptor<'postgres'> version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => registry, - parameterizedCodecs: () => [], + codecs: () => descriptorsFromCodecs(registry), create() { return Object.assign( { familyId: 'sql' as const, targetId: 'postgres' as const }, @@ -37,7 +36,6 @@ function createStubAdapterDescriptor(): SqlRuntimeAdapterDescriptor<'postgres'> id: 'test-profile', target: 'postgres', capabilities: {}, - codecs: () => registry, readMarkerStatement: () => ({ sql: '', params: [] }), parseMarkerRow: () => { throw new Error('stub adapter does not implement parseMarkerRow'); @@ -59,8 +57,7 @@ function createStubTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -68,15 +65,14 @@ function createStubTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { } function createStubExtensionDescriptor(): SqlRuntimeExtensionDescriptor<'postgres'> { - const registry = createCodecRegistry(); - registry.register( - codec({ + const registry: ReadonlyArray> = [ + defineTestCodec({ typeId: 'pg/uuid@1', targetTypes: ['uuid'], encode: (value: string) => value, decode: (wire: string) => wire, }), - ); + ]; const operations = [ { @@ -91,9 +87,8 @@ function createStubExtensionDescriptor(): SqlRuntimeExtensionDescriptor<'postgre version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => registry, + codecs: () => descriptorsFromCodecs(registry), queryOperations: () => operations, - parameterizedCodecs: () => [], create() { return { familyId: 'sql' as const, @@ -129,8 +124,8 @@ describe('createExecutionStack', () => { }) as ExecutionContext; expect(context.contract).toBe(contract); - expect(context.codecs.get('pg/text@1')).toBeDefined(); - expect(context.codecs.get('pg/uuid@1')).toBeDefined(); + expect(context.codecDescriptors.descriptorFor('pg/text@1')).toBeDefined(); + expect(context.codecDescriptors.descriptorFor('pg/uuid@1')).toBeDefined(); expect(context.queryOperations.entries()['example']).toBeDefined(); expect(context.types).toEqual({}); }); diff --git a/packages/2-sql/5-runtime/test/intercept-decoding.test.ts b/packages/2-sql/5-runtime/test/intercept-decoding.test.ts index 6004486ed6..f73f95ce03 100644 --- a/packages/2-sql/5-runtime/test/intercept-decoding.test.ts +++ b/packages/2-sql/5-runtime/test/intercept-decoding.test.ts @@ -7,15 +7,9 @@ import { type RuntimeExtensionInstance, } from '@prisma-next/framework-components/execution'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; -import type { - CodecRegistry, - SqlDriver, - SqlExecuteRequest, -} from '@prisma-next/sql-relational-core/ast'; +import type { Codec, SqlDriver, SqlExecuteRequest } from '@prisma-next/sql-relational-core/ast'; import { ColumnRef, - codec, - createCodecRegistry, ProjectionItem, SelectAst, TableSource, @@ -31,16 +25,13 @@ import type { } from '../src/sql-context'; import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime } from '../src/sql-runtime'; +import { defineTestCodec } from './test-codec'; +import { descriptorsFromCodecs } from './utils'; /** - * Documents the contract: when a `SqlMiddleware.intercept` hook short-circuits - * execution and returns raw rows, those rows go through the SQL runtime's - * normal codec decode pass — exactly as if they had come from the driver. + * Documents the contract: when a `SqlMiddleware.intercept` hook short-circuits execution and returns raw rows, those rows go through the SQL runtime's normal codec decode pass — exactly as if they had come from the driver. * - * The cache middleware (TML-2143 M3) relies on this: it stores raw (undecoded) - * rows on first execution, then returns them from `intercept` on subsequent - * executions. Decoding happens once per row consumption regardless of whether - * the row originated from the driver or the cache. + * The cache middleware (TML-2143 M3) relies on this: it stores raw (undecoded) rows on first execution, then returns them from `intercept` on subsequent executions. Decoding happens once per row consumption regardless of whether the row originated from the driver or the cache. */ const testContract: Contract = { @@ -56,33 +47,27 @@ const testContract: Contract = { }; /** - * A JSON codec that takes wire-format strings and decodes them into parsed - * objects. Used to demonstrate that intercepted rows containing JSON-encoded - * values come back to the consumer as parsed objects. + * A JSON codec that takes wire-format strings and decodes them into parsed objects. Used to demonstrate that intercepted rows containing JSON-encoded values come back to the consumer as parsed objects. */ -function createJsonCodecRegistry(): CodecRegistry { - const registry = createCodecRegistry(); - registry.register( - codec({ +function createJsonCodecs(): ReadonlyArray> { + return [ + defineTestCodec({ typeId: 'pg/jsonb@1', targetTypes: ['jsonb'], encode: (value: string | JsonValue): string => JSON.stringify(value), decode: (wire: string | JsonValue): JsonValue => typeof wire === 'string' ? (JSON.parse(wire) as JsonValue) : wire, }), - ); - registry.register( - codec({ + defineTestCodec({ typeId: 'pg/int4@1', targetTypes: ['int4'], encode: (v: number) => v, decode: (w: number) => w, }), - ); - return registry; + ]; } -function createStubAdapter(codecs: CodecRegistry) { +function createStubAdapter(codecs: ReadonlyArray>) { return { familyId: 'sql' as const, targetId: 'postgres' as const, @@ -109,8 +94,7 @@ function createStubAdapter(codecs: CodecRegistry) { function createMockDriver(): SqlDriver { const rootExecute = vi.fn().mockImplementation(async function* (_request: SqlExecuteRequest) { - // Default driver path; real test cases below either intercept (skipping - // this) or assert it was called. + // Default driver path; real test cases below either intercept (skipping this) or assert it was called. yield {} as Record; }); @@ -132,8 +116,7 @@ function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -143,15 +126,14 @@ function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { function createTestAdapterDescriptor( adapter: ReturnType, ): SqlRuntimeAdapterDescriptor<'postgres'> { - const codecRegistry = adapter.profile.codecs(); + const descriptors = descriptorsFromCodecs(adapter.profile.codecs()); return { kind: 'adapter', id: 'test-adapter', version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => codecRegistry, - parameterizedCodecs: () => [], + codecs: () => descriptors, create() { return Object.assign( { familyId: 'sql' as const, targetId: 'postgres' as const }, @@ -162,7 +144,7 @@ function createTestAdapterDescriptor( } function createTestSetup(middleware: readonly SqlMiddleware[]) { - const codecs = createJsonCodecRegistry(); + const codecs = createJsonCodecs(); const adapter = createStubAdapter(codecs); const driver = createMockDriver(); @@ -200,10 +182,7 @@ function createTestSetup(middleware: readonly SqlMiddleware[]) { } /** - * Builds an execution plan whose AST projection maps the named alias to the - * JSON codec via `ProjectionItem.codecId`, so any row yielded for this plan - * (driver or intercepted) is decoded through the JSON codec before reaching - * the consumer. + * Builds an execution plan whose AST projection maps the named alias to the JSON codec via `ProjectionItem.codecId`, so any row yielded for this plan (driver or intercepted) is decoded through the JSON codec before reaching the consumer. */ function createJsonProjectionPlan(alias: string): SqlExecutionPlan { const ast = SelectAst.from(TableSource.named('users')).withProjection([ @@ -230,8 +209,7 @@ describe('intercepted rows go through codec decoding', () => { name: 'mock-cache', familyId: 'sql', async intercept() { - // Raw wire row, as the driver would have produced it: a string - // containing JSON-encoded data. + // Raw wire row, as the driver would have produced it: a string containing JSON-encoded data. return { rows: [{ profile: wireValue }] }; }, }; @@ -241,8 +219,7 @@ describe('intercepted rows go through codec decoding', () => { const out = await runtime.execute(plan).toArray(); - // The consumer must see the *decoded* value (a parsed object), not the - // raw wire string. + // The consumer must see the *decoded* value (a parsed object), not the raw wire string. expect(out).toEqual([{ profile: { name: 'Alice', tags: ['admin', 'staff'] } }]); expect(driver.execute).not.toHaveBeenCalled(); }); diff --git a/packages/2-sql/5-runtime/test/json-schema-validation.test.ts b/packages/2-sql/5-runtime/test/json-schema-validation.test.ts deleted file mode 100644 index 6f1a6b5aac..0000000000 --- a/packages/2-sql/5-runtime/test/json-schema-validation.test.ts +++ /dev/null @@ -1,601 +0,0 @@ -import type { Contract, JsonValue } from '@prisma-next/contract/types'; -import { coreHash, profileHash } from '@prisma-next/contract/types'; -import type { SqlStorage, StorageTypeInstance } from '@prisma-next/sql-contract/types'; -import type { Codec, CodecRegistry } from '@prisma-next/sql-relational-core/ast'; -import { - BinaryExpr, - ColumnRef, - codec, - createCodecRegistry, - ParamRef, - ProjectionItem, - SelectAst, - TableSource, -} from '@prisma-next/sql-relational-core/ast'; -import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; -import type { - JsonSchemaValidateFn, - JsonSchemaValidatorRegistry, -} from '@prisma-next/sql-relational-core/query-lane-context'; -import { ifDefined } from '@prisma-next/utils/defined'; -import { type as arktype } from 'arktype'; -import { describe, expect, it } from 'vitest'; -import { decodeRow } from '../src/codecs/decoding'; -import { encodeParam, encodeParams } from '../src/codecs/encoding'; -import type { - RuntimeParameterizedCodecDescriptor, - SqlRuntimeExtensionDescriptor, -} from '../src/sql-context'; -import { createStubAdapter, createTestContext } from './utils'; - -// ============================================================================= -// Shared test helpers -// ============================================================================= - -function createStubValidator(schema: Record): JsonSchemaValidateFn { - return (value: unknown) => { - if (schema['type'] === 'object' && typeof value === 'object' && value !== null) { - const required = (schema['required'] ?? []) as string[]; - const obj = value as Record; - for (const prop of required) { - if (!(prop in obj)) { - return { - valid: false, - errors: [ - { - path: '/', - message: `must have required property '${prop}'`, - keyword: 'required', - }, - ], - }; - } - } - return { valid: true }; - } - if (schema['type'] === 'object' && (typeof value !== 'object' || value === null)) { - return { - valid: false, - errors: [{ path: '/', message: 'must be object', keyword: 'type' }], - }; - } - return { valid: true }; - }; -} - -const metadataSchema: Record = { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], -}; - -const userMetadataSchema: Record = { - type: 'object', - properties: { userName: { type: 'string' } }, - required: ['userName'], -}; - -const postMetadataSchema: Record = { - type: 'object', - properties: { postTitle: { type: 'string' } }, - required: ['postTitle'], -}; - -function createMetadataValidatorRegistry(): JsonSchemaValidatorRegistry { - const validators = new Map(); - validators.set('user.metadata', createStubValidator(metadataSchema)); - return { get: (key) => validators.get(key), size: validators.size }; -} - -function createJoinMetadataValidatorRegistry(): JsonSchemaValidatorRegistry { - const validators = new Map(); - validators.set('user.metadata', createStubValidator(userMetadataSchema)); - validators.set('post.metadata', createStubValidator(postMetadataSchema)); - return { get: (key) => validators.get(key), size: validators.size }; -} - -function jsonbCodec(typeId: Id, targetType: string) { - return codec({ - typeId, - targetTypes: [targetType], - encode: (v: JsonValue) => JSON.stringify(v), - decode: (w: string) => (typeof w === 'string' ? JSON.parse(w) : w) as JsonValue, - }); -} - -function createTestCodecRegistry(): CodecRegistry { - const registry = createCodecRegistry(); - registry.register(jsonbCodec('pg/jsonb@1', 'jsonb')); - registry.register(jsonbCodec('pg/json@1', 'json')); - registry.register( - codec({ - typeId: 'pg/int4@1', - targetTypes: ['int4'], - encode: (v: number) => v, - decode: (w: number) => w, - }), - ); - return registry; -} - -function createJsonSchemaContract( - options?: Partial<{ - types: Record; - tableColumns: Record< - string, - { - nativeType: string; - codecId: string; - nullable: boolean; - typeParams?: Record; - typeRef?: string; - } - >; - }>, -): Contract { - return { - targetFamily: 'sql', - target: 'postgres', - profileHash: profileHash('sha256:test'), - models: {}, - roots: {}, - storage: { - storageHash: coreHash('sha256:test'), - tables: { - user: { - columns: options?.tableColumns ?? { - id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, - metadata: { - nativeType: 'jsonb', - codecId: 'pg/jsonb@1', - nullable: true, - typeParams: { schema: metadataSchema }, - }, - }, - primaryKey: { columns: ['id'] }, - uniques: [], - indexes: [], - foreignKeys: [], - }, - }, - ...ifDefined('types', options?.types), - }, - extensionPacks: {}, - capabilities: {}, - meta: {}, - }; -} - -const jsonTypeParamsSchema = arktype({ - schema: 'object', - 'type?': 'string', -}); - -function createJsonbExtensionDescriptor(): SqlRuntimeExtensionDescriptor<'postgres'> { - // Phase B note: JSON-validator codec descriptors compile their schema - // in the factory and attach `validate` as per-instance state on the - // resolved codec. The framework's `JsonSchemaValidatorRegistry` builder - // extracts `validate` via the `'json-validator'` `CodecTrait` gate. - const baseJson = jsonbCodec('pg/json@1', 'json'); - const baseJsonb = jsonbCodec('pg/jsonb@1', 'jsonb'); - - const buildJsonDescriptor = ( - codecId: 'pg/json@1' | 'pg/jsonb@1', - base: typeof baseJson | typeof baseJsonb, - targetType: 'json' | 'jsonb', - ): RuntimeParameterizedCodecDescriptor> => ({ - codecId, - traits: ['json-validator'], - targetTypes: [targetType], - paramsSchema: jsonTypeParamsSchema, - factory: (params: Record) => { - const validate = createStubValidator(params['schema'] as Record); - const baseTraits: ReadonlyArray = base.traits ?? []; - const traitsArr = Array.from(new Set([...baseTraits, 'json-validator'])); - const traits = Object.freeze(traitsArr) as NonNullable; - const resolved: Codec & { readonly validate: typeof validate } = { - ...base, - traits, - validate, - }; - return () => resolved; - }, - }); - - const parameterizedCodecs: RuntimeParameterizedCodecDescriptor>[] = [ - buildJsonDescriptor('pg/json@1', baseJson, 'json'), - buildJsonDescriptor('pg/jsonb@1', baseJsonb, 'jsonb'), - ]; - - const registry = createCodecRegistry(); - registry.register(baseJson); - registry.register(baseJsonb); - - return { - kind: 'extension' as const, - id: 'json-validation', - version: '0.0.1', - familyId: 'sql' as const, - targetId: 'postgres' as const, - codecs: () => registry, - parameterizedCodecs: () => parameterizedCodecs, - create() { - return { familyId: 'sql' as const, targetId: 'postgres' as const }; - }, - }; -} - -function createTestPlan(overrides?: Partial): SqlExecutionPlan { - return { - sql: 'SELECT 1', - params: [], - meta: { - target: 'postgres', - storageHash: 'sha256:test', - lane: 'dsl', - }, - ...overrides, - }; -} - -function projectionAst(items: ReadonlyArray): SelectAst { - return SelectAst.from(TableSource.named('user')).withProjection(items); -} - -// ============================================================================= -// Tests: Validator Registry via createExecutionContext -// ============================================================================= - -describe('JSON Schema validator registry', () => { - describe('context creation', () => { - it('builds validator registry for contract with JSON columns that have schemas', () => { - const contract = createJsonSchemaContract(); - const context = createTestContext(contract, createStubAdapter(), { - extensionPacks: [createJsonbExtensionDescriptor()], - }); - - expect(context.jsonSchemaValidators).toBeDefined(); - expect(context.jsonSchemaValidators!.size).toBe(1); - expect(context.jsonSchemaValidators!.get('user.metadata')).toBeDefined(); - }); - - it('omits validator registry when no JSON columns have schemas', () => { - const contract = createJsonSchemaContract({ - tableColumns: { - id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, - }, - }); - const context = createTestContext(contract, createStubAdapter(), { - extensionPacks: [createJsonbExtensionDescriptor()], - }); - - expect(context.jsonSchemaValidators).toBeUndefined(); - }); - - it('builds validators for columns with typeRef', () => { - const contract = createJsonSchemaContract({ - types: { - ProfileJson: { - codecId: 'pg/jsonb@1', - nativeType: 'jsonb', - typeParams: { - schema: { - type: 'object', - properties: { displayName: { type: 'string' } }, - required: ['displayName'], - }, - }, - }, - }, - tableColumns: { - id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false }, - profile: { - nativeType: 'jsonb', - codecId: 'pg/jsonb@1', - nullable: true, - typeRef: 'ProfileJson', - }, - }, - }); - const context = createTestContext(contract, createStubAdapter(), { - extensionPacks: [createJsonbExtensionDescriptor()], - }); - - expect(context.jsonSchemaValidators).toBeDefined(); - expect(context.jsonSchemaValidators!.get('user.profile')).toBeDefined(); - }); - - it('omits validator registry when no descriptor declares the json-validator trait', () => { - const baseJsonb = jsonbCodec('pg/jsonb@1', 'jsonb'); - const registry = createCodecRegistry(); - registry.register(baseJsonb); - - // The factory returns the legacy codec without attaching `validate` - // and without declaring the `'json-validator'` trait — so the - // framework's validator registry builder finds no per-column - // validators to extract. - const noValidatorExtension: SqlRuntimeExtensionDescriptor<'postgres'> = { - kind: 'extension' as const, - id: 'json-no-validator', - version: '0.0.1', - familyId: 'sql' as const, - targetId: 'postgres' as const, - codecs: () => registry, - parameterizedCodecs: () => [ - { - codecId: 'pg/jsonb@1', - traits: [], - targetTypes: ['jsonb'], - paramsSchema: jsonTypeParamsSchema, - factory: (_params) => () => baseJsonb, - }, - ], - create() { - return { familyId: 'sql' as const, targetId: 'postgres' as const }; - }, - }; - - const contract = createJsonSchemaContract(); - const context = createTestContext(contract, createStubAdapter(), { - extensionPacks: [noValidatorExtension], - }); - - expect(context.jsonSchemaValidators).toBeUndefined(); - }); - }); -}); - -// ============================================================================= -// Tests: Encoding validation -// ============================================================================= - -describe('JSON Schema encoding validation', () => { - const codecRegistry = createTestCodecRegistry(); - - it('encodes JSON values via codec', async () => { - const ref = ParamRef.of({ name: 'Alice' }, { codecId: 'pg/jsonb@1' }); - const plan = createTestPlan({ - params: [ref.value], - ast: SelectAst.from(TableSource.named('user')).withWhere( - BinaryExpr.eq(ColumnRef.of('user', 'metadata'), ref), - ), - }); - - const result = await encodeParams(plan, codecRegistry, {}); - expect(result[0]).toBe('{"name":"Alice"}'); - }); - - it('returns null for null values', async () => { - const result = await encodeParam(null, { codecId: 'pg/jsonb@1' }, 0, codecRegistry, {}); - expect(result).toBeNull(); - }); - - it('encodes when descriptor has name', async () => { - const result = await encodeParam( - { age: 30 }, - { name: 'metadata', codecId: 'pg/jsonb@1' }, - 0, - codecRegistry, - {}, - ); - expect(result).toBe('{"age":30}'); - }); -}); - -// ============================================================================= -// Tests: Decoding validation -// ============================================================================= - -describe('JSON Schema decoding validation', () => { - const codecRegistry = createTestCodecRegistry(); - - it('passes valid decoded JSON values', async () => { - const plan = createTestPlan({ - ast: projectionAst([ - ProjectionItem.of('metadata', ColumnRef.of('user', 'metadata'), 'pg/jsonb@1'), - ]), - }); - - const row = { metadata: '{"name":"Alice"}' }; - const result = await decodeRow(row, plan, codecRegistry, createMetadataValidatorRegistry(), {}); - expect(result['metadata']).toEqual({ name: 'Alice' }); - }); - - it('throws RUNTIME.JSON_SCHEMA_VALIDATION_FAILED for invalid decoded values', async () => { - const plan = createTestPlan({ - ast: projectionAst([ - ProjectionItem.of('metadata', ColumnRef.of('user', 'metadata'), 'pg/jsonb@1'), - ]), - }); - - const row = { metadata: '{"age":30}' }; - await expect( - decodeRow(row, plan, codecRegistry, createMetadataValidatorRegistry(), {}), - ).rejects.toMatchObject({ - code: 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', - category: 'RUNTIME', - severity: 'error', - details: { - table: 'user', - column: 'metadata', - direction: 'decode', - codecId: 'pg/jsonb@1', - }, - }); - }); - - it('validates aliased projection columns using projection mapping', async () => { - const plan = createTestPlan({ - ast: projectionAst([ - ProjectionItem.of('userMeta', ColumnRef.of('user', 'metadata'), 'pg/jsonb@1'), - ]), - }); - - const row = { userMeta: '{"age":30}' }; - await expect( - decodeRow(row, plan, codecRegistry, createMetadataValidatorRegistry(), {}), - ).rejects.toMatchObject({ - code: 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', - details: { - table: 'user', - column: 'metadata', - direction: 'decode', - }, - }); - }); - - it('resolves join aliases with duplicate column names using projection mapping', async () => { - const plan = createTestPlan({ - ast: projectionAst([ - ProjectionItem.of('userMeta', ColumnRef.of('user', 'metadata'), 'pg/jsonb@1'), - ProjectionItem.of('postMeta', ColumnRef.of('post', 'metadata'), 'pg/jsonb@1'), - ]), - }); - - const row = { - userMeta: '{"userName":"Alice"}', - postMeta: '{"userName":"Alice"}', - }; - await expect( - decodeRow(row, plan, codecRegistry, createJoinMetadataValidatorRegistry(), {}), - ).rejects.toMatchObject({ - code: 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', - details: { - table: 'post', - column: 'metadata', - direction: 'decode', - }, - }); - }); - - it('skips validation when column ref cannot be resolved', async () => { - // Non-ColumnRef projection expression — no { table, column } available, so - // validation is skipped (decoded value still flows through). - const plan = createTestPlan({ - ast: projectionAst([ProjectionItem.of('data', ParamRef.of(null), 'pg/jsonb@1')]), - }); - - const row = { data: '{"bad":"data"}' }; - const result = await decodeRow(row, plan, codecRegistry, createMetadataValidatorRegistry(), {}); - expect(result['data']).toEqual({ bad: 'data' }); - }); - - it('skips validation for null wire values', async () => { - const plan = createTestPlan({ - ast: projectionAst([ - ProjectionItem.of('metadata', ColumnRef.of('user', 'metadata'), 'pg/jsonb@1'), - ]), - }); - - const row = { metadata: null }; - const result = await decodeRow(row, plan, codecRegistry, createMetadataValidatorRegistry(), {}); - expect(result['metadata']).toBeNull(); - }); - - it('skips validation when no registry is provided', async () => { - const plan = createTestPlan({ - ast: projectionAst([ - ProjectionItem.of('metadata', ColumnRef.of('user', 'metadata'), 'pg/jsonb@1'), - ]), - }); - - const row = { metadata: '{"bad":"data"}' }; - const result = await decodeRow(row, plan, codecRegistry, undefined, {}); - expect(result['metadata']).toEqual({ bad: 'data' }); - }); - - it('decodes non-JSON columns without validation', async () => { - const plan = createTestPlan({ - ast: projectionAst([ - ProjectionItem.of('id', ColumnRef.of('user', 'id'), 'pg/int4@1'), - ProjectionItem.of('metadata', ColumnRef.of('user', 'metadata'), 'pg/jsonb@1'), - ]), - }); - - const row = { id: 42, metadata: '{"name":"Alice"}' }; - const result = await decodeRow(row, plan, codecRegistry, createMetadataValidatorRegistry(), {}); - expect(result['id']).toBe(42); - expect(result['metadata']).toEqual({ name: 'Alice' }); - }); - - it('runs JSON schema validation against the resolved value of an async decoder', async () => { - const asyncRegistry = createCodecRegistry(); - asyncRegistry.register( - codec<'pg/async-jsonb@1', readonly [], string, JsonValue>({ - typeId: 'pg/async-jsonb@1', - targetTypes: ['jsonb'], - encode: async (v: JsonValue) => JSON.stringify(v), - decode: async (w: string) => (typeof w === 'string' ? JSON.parse(w) : w) as JsonValue, - }), - ); - - const plan = createTestPlan({ - ast: projectionAst([ - ProjectionItem.of('metadata', ColumnRef.of('user', 'metadata'), 'pg/async-jsonb@1'), - ]), - }); - - const row = { metadata: '{"age":30}' }; - await expect( - decodeRow(row, plan, asyncRegistry, createMetadataValidatorRegistry(), {}), - ).rejects.toMatchObject({ - code: 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', - category: 'RUNTIME', - severity: 'error', - details: { - table: 'user', - column: 'metadata', - direction: 'decode', - codecId: 'pg/async-jsonb@1', - }, - }); - }); - - // --------------------------------------------------------------------------- - // Codec-authored error.message redaction — DEFERRED follow-up. - // - // Asserts that codec-authored error messages are not embedded into the - // RUNTIME.DECODE_FAILED envelope (`err.message` / `err.details`), while the - // original error remains reachable via `err.cause`. - // - // The current `wrapDecodeFailure` (packages/2-sql/5-runtime/src/codecs/ - // decoding.ts) embeds `error.message` into the envelope's wrapped message. - // Implementing the redaction trigger is independent of the async-codec - // boundary work and is tracked separately (see ADR 204 §"Risks & - // mitigations" — validator-message redaction). The assertion is preserved - // here as an `it.skip` so it can be activated when redaction policy lands. - // --------------------------------------------------------------------------- - it.skip('does not leak codec-authored error.message into the DECODE_FAILED envelope', async () => { - const leakyPlaintext = 'super-secret-plaintext-value'; - const leakyCodec = codec({ - typeId: 'pg/leaky@1', - targetTypes: ['text'], - encode: (v: string) => v, - decode: async (_w: string) => { - throw new Error(`decrypt failed for value=${leakyPlaintext}`); - }, - }); - const registry = createCodecRegistry(); - registry.register(leakyCodec); - - const plan = createTestPlan({ - ast: projectionAst([ - ProjectionItem.of('secret', ColumnRef.of('user', 'secret'), 'pg/leaky@1'), - ]), - }); - - const row = { secret: 'wire-bytes' }; - const rejection = await decodeRow(row, plan, registry, undefined, {}).catch((e: unknown) => e); - - const err = rejection as Error & { - code?: string; - details?: Record; - cause?: unknown; - }; - expect(err).toBeInstanceOf(Error); - expect(err.code).toBe('RUNTIME.DECODE_FAILED'); - expect(err.message).not.toContain(leakyPlaintext); - expect(JSON.stringify(err.details ?? {})).not.toContain(leakyPlaintext); - expect((err.cause as Error | undefined)?.message).toContain(leakyPlaintext); - }); -}); diff --git a/packages/2-sql/5-runtime/test/lints.test.ts b/packages/2-sql/5-runtime/test/lints.test.ts index dce38a74e2..d7b0331c07 100644 --- a/packages/2-sql/5-runtime/test/lints.test.ts +++ b/packages/2-sql/5-runtime/test/lints.test.ts @@ -158,4 +158,120 @@ describe('lints middleware', () => { }, timeouts.default, ); + + it( + 'honors configured severity overrides for every AST-level lint code', + async () => { + const cases = [ + { + code: 'LINT.DELETE_WITHOUT_WHERE', + plan: () => createPlan({ ast: DeleteAst.from(userTable) }), + severities: { deleteWithoutWhere: 'warn' as const }, + }, + { + code: 'LINT.UPDATE_WITHOUT_WHERE', + plan: () => + createPlan({ + ast: UpdateAst.table(userTable).withSet({ + email: ParamRef.of('x', { name: 'email', codecId: 'pg/text@1' }), + }), + }), + severities: { updateWithoutWhere: 'warn' as const }, + }, + { + code: 'LINT.NO_LIMIT', + plan: () => + createPlan({ + ast: SelectAst.from(userTable).withProjection([ProjectionItem.of('id', idCol)]), + }), + severities: { noLimit: 'error' as const }, + }, + { + code: 'LINT.SELECT_STAR', + plan: () => + createPlan({ + ast: SelectAst.from(userTable) + .withProjection([ProjectionItem.of('id', idCol)]) + .withLimit(1) + .withSelectAllIntent({ table: 'user' }), + }), + severities: { selectStar: 'error' as const }, + }, + ]; + + for (const { code, plan, severities } of cases) { + const mw = lints({ severities }); + const ctx = createMiddlewareContext(); + const wantsError = Object.values(severities)[0] === 'error'; + const promise = mw.beforeExecute?.(plan(), ctx); + if (wantsError) { + await expect(promise).rejects.toMatchObject({ code }); + } else { + await promise; + expect(ctx.log.warn).toHaveBeenCalledWith(expect.objectContaining({ code })); + } + } + }, + timeouts.default, + ); + + it( + 'returns undefined severity for codes outside the configured map', + async () => { + const ast = SelectAst.from(userTable) + .withProjection([ProjectionItem.of('id', idCol)]) + .withLimit(1) + .withSelectAllIntent({ table: 'user' }); + const mw = lints({ severities: { deleteWithoutWhere: 'warn' } }); + const ctx = createMiddlewareContext(); + + await mw.beforeExecute?.(createPlan({ ast }), ctx); + expect(ctx.log.warn).toHaveBeenCalledWith( + expect.objectContaining({ code: 'LINT.SELECT_STAR' }), + ); + }, + timeouts.default, + ); + + it( + 'falls back to raw guardrail evaluation when ast is missing (default fallback: raw)', + async () => { + const plan = createPlan({ sql: 'SELECT * FROM "user"' }); + const mw = lints(); + const ctx = createMiddlewareContext(); + + await expect(mw.beforeExecute?.(plan, ctx)).rejects.toMatchObject({ + code: 'LINT.SELECT_STAR', + }); + }, + timeouts.default, + ); + + it( + 'warns from raw fallback when severity override downgrades a default-error code', + async () => { + const plan = createPlan({ sql: 'SELECT * FROM "user"' }); + const mw = lints({ severities: { selectStar: 'warn' } }); + const ctx = createMiddlewareContext(); + + await mw.beforeExecute?.(plan, ctx); + expect(ctx.log.warn).toHaveBeenCalledWith( + expect.objectContaining({ code: 'LINT.SELECT_STAR' }), + ); + }, + timeouts.default, + ); + + it( + 'skips raw fallback evaluation when fallbackWhenAstMissing is set to skip', + async () => { + const plan = createPlan({ sql: 'SELECT * FROM "user"' }); + const mw = lints({ fallbackWhenAstMissing: 'skip' }); + const ctx = createMiddlewareContext(); + + await mw.beforeExecute?.(plan, ctx); + expect(ctx.log.warn).not.toHaveBeenCalled(); + }, + timeouts.default, + ); }); diff --git a/packages/2-sql/5-runtime/test/marker-verification.test.ts b/packages/2-sql/5-runtime/test/marker-verification.test.ts new file mode 100644 index 0000000000..3e012c5fb7 --- /dev/null +++ b/packages/2-sql/5-runtime/test/marker-verification.test.ts @@ -0,0 +1,288 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { coreHash, profileHash } from '@prisma-next/contract/types'; +import { + type ExecutionStackInstance, + instantiateExecutionStack, + type RuntimeDriverInstance, + type RuntimeExtensionInstance, +} from '@prisma-next/framework-components/execution'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { Codec, SqlDriver, SqlExecuteRequest } from '@prisma-next/sql-relational-core/ast'; +import { SelectAst, TableSource } from '@prisma-next/sql-relational-core/ast'; +import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; +import { describe, expect, it, vi } from 'vitest'; +import { parseContractMarkerRow } from '../src/marker'; +import type { + SqlRuntimeAdapterDescriptor, + SqlRuntimeAdapterInstance, + SqlRuntimeTargetDescriptor, +} from '../src/sql-context'; +import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; +import { createRuntime } from '../src/sql-runtime'; +import { defineTestCodec } from './test-codec'; +import { descriptorsFromCodecs } from './utils'; + +/** + * Pins the per-error-class branches of `verifyMarker` in `sql-runtime.ts`: missing marker (with `requireMarker: true`), missing marker tolerated (with `requireMarker: false`), and profile-hash mismatch. Storage-hash mismatch is covered by `marker-vs-intercept-ordering.test.ts`. + */ + +const testContract: Contract = { + targetFamily: 'sql', + target: 'postgres', + profileHash: profileHash('sha256:test-profile'), + models: {}, + roots: {}, + storage: { storageHash: coreHash('sha256:test'), tables: {} }, + extensionPacks: {}, + capabilities: {}, + meta: {}, +}; + +function createCodecs(): ReadonlyArray> { + return [ + defineTestCodec({ + typeId: 'pg/int4@1', + targetTypes: ['int4'], + encode: (v: number) => v, + decode: (w: number) => w, + }), + ]; +} + +function createStubAdapter(codecs: ReadonlyArray>) { + return { + familyId: 'sql' as const, + targetId: 'postgres' as const, + profile: { + id: 'test-profile', + target: 'postgres', + capabilities: {}, + codecs() { + return codecs; + }, + readMarkerStatement() { + return { sql: 'select * from prisma_contract.marker', params: [1] }; + }, + parseMarkerRow: parseContractMarkerRow, + }, + lower(ast: SelectAst) { + return Object.freeze({ sql: JSON.stringify(ast), params: [] }); + }, + }; +} + +interface MarkerRow { + readonly core_hash: string; + readonly profile_hash: string; + readonly contract_json: null; + readonly canonical_version: number; + readonly updated_at: Date; + readonly app_tag: null; + readonly meta: null; + readonly invariants: readonly never[]; +} + +function createDriver(rows: readonly MarkerRow[]): SqlDriver { + const query = vi.fn().mockResolvedValue({ rows, rowCount: rows.length }); + const execute = vi.fn().mockImplementation(async function* (_request: SqlExecuteRequest) { + yield {} as Record; + }); + return { + execute, + query, + connect: vi.fn().mockImplementation(async (_binding?: undefined) => undefined), + acquireConnection: vi.fn().mockRejectedValue(new Error('not used in this test')), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function createTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { + return { + kind: 'target', + id: 'postgres', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => [], + create() { + return { familyId: 'sql' as const, targetId: 'postgres' as const }; + }, + }; +} + +function createAdapterDescriptor( + adapter: ReturnType, +): SqlRuntimeAdapterDescriptor<'postgres'> { + const descriptors = descriptorsFromCodecs(adapter.profile.codecs()); + return { + kind: 'adapter', + id: 'test-adapter', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => descriptors, + create() { + return Object.assign( + { familyId: 'sql' as const, targetId: 'postgres' as const }, + adapter, + ) as SqlRuntimeAdapterInstance<'postgres'>; + }, + }; +} + +function createSetup(driver: SqlDriver, requireMarker: boolean) { + const codecs = createCodecs(); + const adapter = createStubAdapter(codecs); + const target = createTargetDescriptor(); + const adapterDesc = createAdapterDescriptor(adapter); + const stack = createSqlExecutionStack({ + target, + adapter: adapterDesc, + extensionPacks: [], + }); + type SqlTestStackInstance = ExecutionStackInstance< + 'sql', + 'postgres', + SqlRuntimeAdapterInstance<'postgres'>, + RuntimeDriverInstance<'sql', 'postgres'>, + RuntimeExtensionInstance<'sql', 'postgres'> + >; + const stackInstance = instantiateExecutionStack(stack) as SqlTestStackInstance; + const context = createExecutionContext({ + contract: testContract, + stack: { target, adapter: adapterDesc, extensionPacks: [] }, + }); + return createRuntime({ + stackInstance, + context, + driver, + verify: { mode: 'always', requireMarker }, + }); +} + +function createPlan(): SqlExecutionPlan { + const ast = SelectAst.from(TableSource.named('users')); + return { + sql: 'select * from users', + params: [], + ast, + meta: { + target: testContract.target, + targetFamily: testContract.targetFamily, + storageHash: testContract.storage.storageHash, + lane: 'raw', + }, + }; +} + +function createStartupSetup(driver: SqlDriver) { + const codecs = createCodecs(); + const adapter = createStubAdapter(codecs); + const target = createTargetDescriptor(); + const adapterDesc = createAdapterDescriptor(adapter); + const stack = createSqlExecutionStack({ + target, + adapter: adapterDesc, + extensionPacks: [], + }); + type SqlTestStackInstance = ExecutionStackInstance< + 'sql', + 'postgres', + SqlRuntimeAdapterInstance<'postgres'>, + RuntimeDriverInstance<'sql', 'postgres'>, + RuntimeExtensionInstance<'sql', 'postgres'> + >; + const stackInstance = instantiateExecutionStack(stack) as SqlTestStackInstance; + const context = createExecutionContext({ + contract: testContract, + stack: { target, adapter: adapterDesc, extensionPacks: [] }, + }); + return createRuntime({ + stackInstance, + context, + driver, + verify: { mode: 'startup', requireMarker: false }, + }); +} + +describe('verifyMarker', () => { + it('throws CONTRACT.MARKER_MISSING when no marker row exists and requireMarker is true', async () => { + const runtime = createSetup(createDriver([]), true); + + await expect(runtime.execute(createPlan()).toArray()).rejects.toMatchObject({ + code: 'CONTRACT.MARKER_MISSING', + category: 'CONTRACT', + }); + }); + + it('runs verification on first execute when mode is "startup"', async () => { + const driver = createDriver([ + { + core_hash: 'sha256:test', + profile_hash: 'sha256:test-profile', + contract_json: null, + canonical_version: 1, + updated_at: new Date('2026-01-01T00:00:00Z'), + app_tag: null, + meta: null, + invariants: [], + }, + ]); + const runtime = createStartupSetup(driver); + + await runtime.execute(createPlan()).toArray(); + await runtime.execute(createPlan()).toArray(); + + expect(driver.query).toHaveBeenCalledTimes(1); + }); + + it('skips verification when no marker row exists and requireMarker is false', async () => { + const runtime = createSetup(createDriver([]), false); + + const rows = await runtime.execute(createPlan()).toArray(); + expect(rows).toBeDefined(); + }); + + it('passes verification when marker matches contract storage and profile hash', async () => { + const driver = createDriver([ + { + core_hash: 'sha256:test', + profile_hash: 'sha256:test-profile', + contract_json: null, + canonical_version: 1, + updated_at: new Date('2026-01-01T00:00:00Z'), + app_tag: null, + meta: null, + invariants: [], + }, + ]); + const runtime = createSetup(driver, true); + + const rows = await runtime.execute(createPlan()).toArray(); + expect(rows).toBeDefined(); + }); + + it('throws CONTRACT.MARKER_MISMATCH when the database profile hash differs from the contract', async () => { + const driver = createDriver([ + { + core_hash: 'sha256:test', + profile_hash: 'sha256:other-profile', + contract_json: null, + canonical_version: 1, + updated_at: new Date('2026-01-01T00:00:00Z'), + app_tag: null, + meta: null, + invariants: [], + }, + ]); + const runtime = createSetup(driver, true); + + await expect(runtime.execute(createPlan()).toArray()).rejects.toMatchObject({ + code: 'CONTRACT.MARKER_MISMATCH', + details: expect.objectContaining({ + expectedProfile: 'sha256:test-profile', + actualProfile: 'sha256:other-profile', + }), + }); + }); +}); diff --git a/packages/2-sql/5-runtime/test/marker-vs-intercept-ordering.test.ts b/packages/2-sql/5-runtime/test/marker-vs-intercept-ordering.test.ts index fe63f35ab7..c00635d2dd 100644 --- a/packages/2-sql/5-runtime/test/marker-vs-intercept-ordering.test.ts +++ b/packages/2-sql/5-runtime/test/marker-vs-intercept-ordering.test.ts @@ -7,17 +7,8 @@ import { type RuntimeExtensionInstance, } from '@prisma-next/framework-components/execution'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; -import type { - CodecRegistry, - SqlDriver, - SqlExecuteRequest, -} from '@prisma-next/sql-relational-core/ast'; -import { - codec, - createCodecRegistry, - SelectAst, - TableSource, -} from '@prisma-next/sql-relational-core/ast'; +import type { Codec, SqlDriver, SqlExecuteRequest } from '@prisma-next/sql-relational-core/ast'; +import { SelectAst, TableSource } from '@prisma-next/sql-relational-core/ast'; import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; import { describe, expect, it, vi } from 'vitest'; import { parseContractMarkerRow } from '../src/marker'; @@ -29,15 +20,13 @@ import type { } from '../src/sql-context'; import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime } from '../src/sql-runtime'; +import { defineTestCodec } from './test-codec'; +import { descriptorsFromCodecs } from './utils'; /** - * Pins the ordering invariant from spec AC L239: marker verification runs - * upstream of `runWithMiddleware`, so a hash-mismatched query throws - * `CONTRACT.MARKER_MISMATCH` before any `intercept` hook can answer it. + * Pins the ordering invariant from spec AC L239: marker verification runs upstream of `runWithMiddleware`, so a hash-mismatched query throws `CONTRACT.MARKER_MISMATCH` before any `intercept` hook can answer it. * - * If a future refactor moves marker verification into the orchestrator, - * this test fails — surfacing the regression that would otherwise let a - * cache hit serve stale-schema results. + * If a future refactor moves marker verification into the orchestrator, this test fails — surfacing the regression that would otherwise let a cache hit serve stale-schema results. */ const testContract: Contract = { @@ -52,20 +41,18 @@ const testContract: Contract = { meta: {}, }; -function createCodecs(): CodecRegistry { - const registry = createCodecRegistry(); - registry.register( - codec({ +function createCodecs(): ReadonlyArray> { + return [ + defineTestCodec({ typeId: 'pg/int4@1', targetTypes: ['int4'], encode: (v: number) => v, decode: (w: number) => w, }), - ); - return registry; + ]; } -function createStubAdapter(codecs: CodecRegistry) { +function createStubAdapter(codecs: ReadonlyArray>) { return { familyId: 'sql' as const, targetId: 'postgres' as const, @@ -91,9 +78,7 @@ function createStubAdapter(codecs: CodecRegistry) { } function createStaleMarkerDriver(): SqlDriver { - // Driver returns a marker row with a `core_hash` that does not match the - // contract's `storage.storageHash`, simulating a database whose schema is - // out of date relative to the running runtime. + // Driver returns a marker row with a `core_hash` that does not match the contract's `storage.storageHash`, simulating a database whose schema is out of date relative to the running runtime. const query = vi.fn().mockResolvedValue({ rows: [ { @@ -130,8 +115,7 @@ function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -141,15 +125,14 @@ function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { function createTestAdapterDescriptor( adapter: ReturnType, ): SqlRuntimeAdapterDescriptor<'postgres'> { - const codecRegistry = adapter.profile.codecs(); + const descriptors = descriptorsFromCodecs(adapter.profile.codecs()); return { kind: 'adapter', id: 'test-adapter', version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => codecRegistry, - parameterizedCodecs: () => [], + codecs: () => descriptors, create() { return Object.assign( { familyId: 'sql' as const, targetId: 'postgres' as const }, diff --git a/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts b/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts index e41ff7432d..75aa78a9eb 100644 --- a/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts +++ b/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts @@ -1,6 +1,5 @@ import { type Contract, coreHash, executionHash, profileHash } from '@prisma-next/contract/types'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; -import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { createExecutionContext, @@ -55,8 +54,7 @@ describe('composed runtime mutation default generators', () => { version: '0.0.1', familyId: 'sql', targetId: 'postgres', - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], mutationDefaultGenerators: () => [ { id: 'slugid', @@ -98,8 +96,7 @@ describe('composed runtime mutation default generators', () => { version: '0.0.1', familyId: 'sql', targetId: 'postgres', - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], mutationDefaultGenerators: () => [ { id: 'slugid', @@ -145,8 +142,7 @@ describe('composed runtime mutation default generators', () => { version: '0.0.1', familyId: 'sql', targetId: 'postgres', - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], mutationDefaultGenerators: () => [ { id: 'duplicate', generate: () => 'first', stability: 'field' }, ], @@ -160,8 +156,7 @@ describe('composed runtime mutation default generators', () => { version: '0.0.1', familyId: 'sql', targetId: 'postgres', - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], mutationDefaultGenerators: () => [ { id: 'duplicate', generate: () => 'second', stability: 'field' }, ], diff --git a/packages/2-sql/5-runtime/test/parameterized-types.test.ts b/packages/2-sql/5-runtime/test/parameterized-types.test.ts index 4c1e894b33..ca413c89fd 100644 --- a/packages/2-sql/5-runtime/test/parameterized-types.test.ts +++ b/packages/2-sql/5-runtime/test/parameterized-types.test.ts @@ -1,9 +1,11 @@ import type { Contract } from '@prisma-next/contract/types'; import { coreHash, profileHash } from '@prisma-next/contract/types'; -import type { CodecInstanceContext } from '@prisma-next/framework-components/codec'; +import type { + CodecDescriptor, + CodecInstanceContext, +} from '@prisma-next/framework-components/codec'; import type { SqlStorage, StorageTypeInstance } from '@prisma-next/sql-contract/types'; import type { Codec, SqlCodecInstanceContext } from '@prisma-next/sql-relational-core/ast'; -import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Type } from 'arktype'; import { type as arktype } from 'arktype'; @@ -12,25 +14,22 @@ import type { RuntimeParameterizedCodecDescriptor, SqlRuntimeExtensionDescriptor, } from '../src/sql-context'; +import { defineTestCodec } from './test-codec'; import { createStubAdapter, createTestContext } from './utils'; function vectorCodecInstance(meta?: Record): Codec { - const baseCodec = codec({ + const baseCodec = defineTestCodec({ typeId: 'pg/vector@1', - targetTypes: ['vector'], encode: (v: number[]) => v, decode: (w: number[]) => w, }); if (!meta) return baseCodec; - // Attach SQL-side `meta` on the resolved codec; the SQL `Codec` shape - // declares `meta?: CodecMeta` so spreading the optional onto the base - // requires conditional inclusion under `exactOptionalPropertyTypes`. - return { ...baseCodec, meta: meta as NonNullable }; + // The narrow `Codec` shape is conversion-only (TML-2357). The `meta` here is a test-side sentinel attached to the codec object so a downstream assertion can verify that the runtime materialization path threads the *exact same instance* the factory returned (via `toBe(taggedCodec)`); it is intentionally not part of the codec's declared shape. Cast through `unknown` to keep the augmentation visible to the assertion + // without re-introducing a `meta` slot. + return { ...baseCodec, meta } as unknown as Codec; } -// ============================================================================= -// Test helpers -// ============================================================================= +// ============================================================================= Test helpers ============================================================================= function createParamTypesTestContract( options?: Partial<{ @@ -74,9 +73,7 @@ function createParamTypesTestContract( }; } -// ============================================================================= -// Tests: Parameterized type validation -// ============================================================================= +// ============================================================================= Tests: Parameterized type validation ============================================================================= describe('parameterized types', () => { describe('storage.types validation', () => { @@ -121,27 +118,24 @@ describe('parameterized types', () => { paramsSchema?: Type<{ length: number }>; }): SqlRuntimeExtensionDescriptor<'postgres'> { const sharedCodec = vectorCodecInstance(); - const parameterizedCodecs: RuntimeParameterizedCodecDescriptor<{ length: number }>[] = [ + const parameterizedDescriptors: RuntimeParameterizedCodecDescriptor<{ length: number }>[] = [ { codecId: 'pg/vector@1', traits: [], targetTypes: ['vector'], paramsSchema: options?.paramsSchema ?? vectorParamsSchema, + isParameterized: true, factory: (_params) => (_ctx) => sharedCodec, }, ]; - const registry = createCodecRegistry(); - registry.register(sharedCodec); - return { kind: 'extension' as const, id: 'pgvector', version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => registry, - parameterizedCodecs: () => parameterizedCodecs, + codecs: () => parameterizedDescriptors as unknown as ReadonlyArray, create() { return { familyId: 'sql' as const, @@ -230,12 +224,7 @@ describe('parameterized types', () => { }); }); - // Phase B note: `init` was the predecessor hook returning a helper. The - // unified descriptor uses `factory: (P) => (CodecInstanceContext) => Codec`; per-instance - // state lives in the resolved codec returned by the factory. The - // `TypeHelperRegistry` (`context.types`) carries the resolved codec for - // every typed instance — or, for codec ids without a parameterized - // descriptor, the raw `StorageTypeInstance` for typeParams metadata. + // The unified descriptor uses `factory: (P) => (CodecInstanceContext) => Codec`; per-instance state lives in the resolved codec returned by the factory. The `TypeHelperRegistry` (`context.types`) carries the resolved codec for every typed instance — or, for codec ids without a parameterized descriptor, the raw `StorageTypeInstance` for typeParams metadata. describe('factory for type helpers', () => { function createPgVectorExt(opts?: { paramsSchema?: Type<{ length: number }>; @@ -248,25 +237,23 @@ describe('parameterized types', () => { length: 'number', }); const factory = opts?.factory ?? ((_params: { length: number }) => () => sharedCodec); - const parameterizedCodecs: RuntimeParameterizedCodecDescriptor<{ length: number }>[] = [ + const parameterizedDescriptors: RuntimeParameterizedCodecDescriptor<{ length: number }>[] = [ { codecId: 'pg/vector@1', traits: [], targetTypes: ['vector'], paramsSchema, + isParameterized: true, factory, }, ]; - const registry = createCodecRegistry(); - registry.register(sharedCodec); return { kind: 'extension' as const, id: 'pgvector', version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => registry, - parameterizedCodecs: () => parameterizedCodecs, + codecs: () => parameterizedDescriptors as unknown as ReadonlyArray, create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -293,18 +280,20 @@ describe('parameterized types', () => { }); expect(context.types['Vector1536']).toBe(taggedCodec); + // The factory returned the `taggedCodec` instance with its test-side `meta` sentinel; the runtime materialization path preserves the exact instance, so the sentinel is still there. expect( - (context.types['Vector1536'] as Codec & { meta: { dimensions: number } }).meta?.dimensions, + ( + context.types['Vector1536'] as unknown as { + meta?: { dimensions?: number }; + } + ).meta?.dimensions, ).toBe(1536); }); it('threads ctx (name + usedAt) through to the factory', () => { const observedCtxs: SqlCodecInstanceContext[] = []; const sharedCodec = vectorCodecInstance(); - // SQL extensions that read `usedAt` author against the SQL-extended - // ctx; the descriptor's factory slot is family-agnostic, so we cast - // through the base. This mirrors what production SQL extensions do - // (see `pgvector/src/exports/runtime.ts`'s family-agnostic cast). + // SQL extensions that read `usedAt` author against the SQL-extended ctx; the descriptor's factory slot is family-agnostic, so we cast through the base. This mirrors what production SQL extensions do (see `pgvector/src/exports/runtime.ts`'s family-agnostic cast). const sqlFactory = (_params: { length: number }) => (ctx: SqlCodecInstanceContext) => { observedCtxs.push(ctx); return sharedCodec; @@ -337,15 +326,14 @@ describe('parameterized types', () => { extensionPacks: [extensionDescriptor], }); - expect(observedCtxs[0]?.name).toBe('Vector1536'); - expect(observedCtxs[0]?.usedAt).toEqual([{ table: 'test', column: 'embedding' }]); + // Skip the representative-materialization call sql-context.ts makes per descriptor for the legacy `forCodecId` fallback (named `` with empty `usedAt`); locate the per-column factory call by the type-instance ctx the test contract declares. + const perColumnCtx = observedCtxs.find((ctx) => ctx.name === 'Vector1536'); + expect(perColumnCtx?.name).toBe('Vector1536'); + expect(perColumnCtx?.usedAt).toEqual([{ table: 'test', column: 'embedding' }]); }); it('stores full type instance for codec ids without a parameterized descriptor', () => { - // No extension contributes a parameterized descriptor for - // `pg/vector@1`. The named instance can't be materialized; the - // helper falls back to the raw type instance for callers that need - // typeParams metadata. + // No extension contributes a parameterized descriptor for `pg/vector@1`. The named instance can't be materialized; the helper falls back to the raw type instance for callers that need typeParams metadata. const contract = createParamTypesTestContract({ types: { Vector1536: { @@ -369,25 +357,23 @@ describe('parameterized types', () => { describe('column typeParams validation', () => { function createBasicVectorExt(): SqlRuntimeExtensionDescriptor<'postgres'> { const sharedCodec = vectorCodecInstance(); - const parameterizedCodecs: RuntimeParameterizedCodecDescriptor<{ length: number }>[] = [ + const parameterizedDescriptors: RuntimeParameterizedCodecDescriptor<{ length: number }>[] = [ { codecId: 'pg/vector@1', traits: [], targetTypes: ['vector'], paramsSchema: arktype({ length: 'number' }), + isParameterized: true, factory: (_params) => () => sharedCodec, }, ]; - const registry = createCodecRegistry(); - registry.register(sharedCodec); return { kind: 'extension' as const, id: 'pgvector', version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => registry, - parameterizedCodecs: () => parameterizedCodecs, + codecs: () => parameterizedDescriptors as unknown as ReadonlyArray, create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -450,22 +436,24 @@ describe('parameterized types', () => { }); describe('duplicate codec descriptor detection', () => { - it('throws RUNTIME.DUPLICATE_PARAMETERIZED_CODEC when multiple extensions provide same codecId', () => { + it('throws RUNTIME.DUPLICATE_CODEC when multiple extensions provide same codecId', () => { const vectorParamsSchema = arktype({ length: 'number', }); function createVectorExtension(id: string): SqlRuntimeExtensionDescriptor<'postgres'> { const sharedCodec = vectorCodecInstance(); - const parameterizedCodecs: RuntimeParameterizedCodecDescriptor<{ length: number }>[] = [ - { - codecId: 'pg/vector@1', - traits: [], - targetTypes: ['vector'], - paramsSchema: vectorParamsSchema, - factory: (_params) => () => sharedCodec, - }, - ]; + const parameterizedDescriptors: RuntimeParameterizedCodecDescriptor<{ length: number }>[] = + [ + { + codecId: 'pg/vector@1', + traits: [], + targetTypes: ['vector'], + paramsSchema: vectorParamsSchema, + isParameterized: true, + factory: (_params) => () => sharedCodec, + }, + ]; return { kind: 'extension' as const, @@ -473,8 +461,7 @@ describe('parameterized types', () => { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => parameterizedCodecs, + codecs: () => parameterizedDescriptors as unknown as ReadonlyArray, create() { return { familyId: 'sql' as const, @@ -492,7 +479,7 @@ describe('parameterized types', () => { }), ).toThrow( expect.objectContaining({ - code: 'RUNTIME.DUPLICATE_PARAMETERIZED_CODEC', + code: 'RUNTIME.DUPLICATE_CODEC', category: 'RUNTIME', severity: 'error', details: { diff --git a/packages/2-sql/5-runtime/test/runtime-ctx-passthrough.test.ts b/packages/2-sql/5-runtime/test/runtime-ctx-passthrough.test.ts new file mode 100644 index 0000000000..61cff78776 --- /dev/null +++ b/packages/2-sql/5-runtime/test/runtime-ctx-passthrough.test.ts @@ -0,0 +1,167 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { coreHash, profileHash } from '@prisma-next/contract/types'; +import { + type ExecutionStackInstance, + instantiateExecutionStack, + type RuntimeDriverInstance, + type RuntimeExtensionInstance, +} from '@prisma-next/framework-components/execution'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { Codec, SqlDriver, SqlExecuteRequest } from '@prisma-next/sql-relational-core/ast'; +import { SelectAst, TableSource } from '@prisma-next/sql-relational-core/ast'; +import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; +import { describe, expect, it, vi } from 'vitest'; +import { parseContractMarkerRow } from '../src/marker'; +import type { SqlMiddleware } from '../src/middleware/sql-middleware'; +import type { + SqlRuntimeAdapterDescriptor, + SqlRuntimeAdapterInstance, + SqlRuntimeTargetDescriptor, +} from '../src/sql-context'; +import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; +import { createRuntime } from '../src/sql-runtime'; +import { defineTestCodec } from './test-codec'; +import { descriptorsFromCodecs } from './utils'; + +/** + * Pins that the SQL runtime's middleware ctx exposes a working `now()` clock and `contentHash()` plan hasher even when no `log` was supplied to `createRuntime` (default noop log path). + */ + +const testContract: Contract = { + targetFamily: 'sql', + target: 'postgres', + profileHash: profileHash('sha256:test'), + models: {}, + roots: {}, + storage: { storageHash: coreHash('sha256:test'), tables: {} }, + extensionPacks: {}, + capabilities: {}, + meta: {}, +}; + +function createCodecs(): ReadonlyArray> { + return [ + defineTestCodec({ + typeId: 'pg/int4@1', + targetTypes: ['int4'], + encode: (v: number) => v, + decode: (w: number) => w, + }), + ]; +} + +function createStubAdapter() { + const codecs = createCodecs(); + return { + familyId: 'sql' as const, + targetId: 'postgres' as const, + profile: { + id: 'test-profile', + target: 'postgres', + capabilities: {}, + codecs() { + return codecs; + }, + readMarkerStatement() { + return { sql: 'select 1', params: [] }; + }, + parseMarkerRow: parseContractMarkerRow, + }, + lower(ast: SelectAst) { + return Object.freeze({ sql: JSON.stringify(ast), params: [] }); + }, + }; +} + +function createDriver(): SqlDriver { + return { + execute: vi.fn().mockImplementation(async function* (_request: SqlExecuteRequest) { + yield {} as Record; + }), + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connect: vi.fn().mockImplementation(async (_binding?: undefined) => undefined), + acquireConnection: vi.fn().mockRejectedValue(new Error('not used')), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('SQL middleware context surface', () => { + it('exposes now() and contentHash() to middleware on a runtime with the default noop log', async () => { + const adapter = createStubAdapter(); + const target: SqlRuntimeTargetDescriptor<'postgres'> = { + kind: 'target', + id: 'postgres', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => [], + create() { + return { familyId: 'sql' as const, targetId: 'postgres' as const }; + }, + }; + const adapterDesc: SqlRuntimeAdapterDescriptor<'postgres'> = { + kind: 'adapter', + id: 'test-adapter', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => descriptorsFromCodecs(adapter.profile.codecs()), + create() { + return Object.assign( + { familyId: 'sql' as const, targetId: 'postgres' as const }, + adapter, + ) as SqlRuntimeAdapterInstance<'postgres'>; + }, + }; + const stack = createSqlExecutionStack({ target, adapter: adapterDesc, extensionPacks: [] }); + type SqlTestStackInstance = ExecutionStackInstance< + 'sql', + 'postgres', + SqlRuntimeAdapterInstance<'postgres'>, + RuntimeDriverInstance<'sql', 'postgres'>, + RuntimeExtensionInstance<'sql', 'postgres'> + >; + const stackInstance = instantiateExecutionStack(stack) as SqlTestStackInstance; + const context = createExecutionContext({ + contract: testContract, + stack: { target, adapter: adapterDesc, extensionPacks: [] }, + }); + + let observedNow: number | undefined; + let observedHash: string | undefined; + const probe: SqlMiddleware = { + name: 'probe', + familyId: 'sql' as const, + async beforeExecute(plan, ctx) { + observedNow = ctx.now(); + observedHash = await ctx.contentHash(plan as unknown as SqlExecutionPlan); + }, + }; + + const runtime = createRuntime({ + stackInstance, + context, + driver: createDriver(), + verify: { mode: 'onFirstUse', requireMarker: false }, + middleware: [probe], + }); + + const ast = SelectAst.from(TableSource.named('users')); + const plan: SqlExecutionPlan = { + sql: 'select * from users', + params: [], + ast, + meta: { + target: testContract.target, + targetFamily: testContract.targetFamily, + storageHash: testContract.storage.storageHash, + lane: 'raw', + }, + }; + + await runtime.execute(plan).toArray(); + + expect(observedNow).toBeTypeOf('number'); + expect(observedHash).toBeTypeOf('string'); + }); +}); diff --git a/packages/2-sql/5-runtime/test/seeded-secret-codec.ts b/packages/2-sql/5-runtime/test/seeded-secret-codec.ts index 38563f4b2d..9427e1c493 100644 --- a/packages/2-sql/5-runtime/test/seeded-secret-codec.ts +++ b/packages/2-sql/5-runtime/test/seeded-secret-codec.ts @@ -1,20 +1,11 @@ -// ============================================================================ -// TEST-ONLY FIXTURE — do not copy into production code. +// ============================================================================ TEST-ONLY FIXTURE — do not copy into production code. // -// This helper exists so the async-codec tests exercise the crypto shape of -// a real encrypted column. It uses AES-GCM with a random 12-byte IV per -// encryption, stored as `iv:ciphertext`, which is adequate for a test -// fixture but is not a production-grade codec: -// * the AES key is deterministically derived from a short string seed; -// * there is no key rotation, key identifier, associated-data binding, -// or authenticated envelope versioning. -// A production codec must source keys from a KMS, bind AAD, and carry -// version/rotation metadata. Treat this file strictly as test plumbing. +// This helper exists so the async-codec tests exercise the crypto shape of a real encrypted column. It uses AES-GCM with a random 12-byte IV per encryption, stored as `iv:ciphertext`, which is adequate for a test fixture but is not a production-grade codec: * the AES key is deterministically derived from a short string seed; * there is no key rotation, key identifier, associated-data binding, or authenticated envelope +// versioning. A production codec must source keys from a KMS, bind AAD, and carry version/rotation metadata. Treat this file strictly as test plumbing. // -// Guard against accidental production use. -// ============================================================================ +// Guard against accidental production use. ============================================================================ -import { codec } from '@prisma-next/sql-relational-core/ast'; +import { defineTestCodec } from './test-codec'; if (typeof process !== 'undefined' && process.env?.['NODE_ENV'] === 'production') { throw new Error('seeded-secret-codec is a test fixture and must not be loaded in production'); @@ -78,10 +69,7 @@ export async function decryptSecret(wire: string, seed: string): Promise } /** - * Build a `Codec` whose query-time `encode` / `decode` are async crypto - * operations. Authors pass the underlying async functions directly to - * `codec({...})`; the single-path runtime always awaits them, so the - * codec needs no async marker. + * Build a `Codec` whose query-time `encode` / `decode` are async crypto operations. Authors pass the underlying async functions directly to `defineTestCodec({...})`; the single-path runtime always awaits them, so the codec needs no async marker. */ export function createAsyncSecretCodec({ seed, @@ -92,7 +80,7 @@ export function createAsyncSecretCodec({ typeId?: string; targetTypes?: readonly string[]; }) { - return codec({ + return defineTestCodec({ typeId, targetTypes, encode: (value: string) => encryptSecret(value, seed), diff --git a/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts new file mode 100644 index 0000000000..5eeb7c988b --- /dev/null +++ b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts @@ -0,0 +1,179 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { coreHash, profileHash } from '@prisma-next/contract/types'; +import type { CodecDescriptor } from '@prisma-next/framework-components/codec'; +import { voidParamsSchema } from '@prisma-next/framework-components/codec'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { Codec, SqlCodecInstanceContext } from '@prisma-next/sql-relational-core/ast'; +import { describe, expect, it } from 'vitest'; +import type { SqlRuntimeExtensionDescriptor } from '../src/sql-context'; +import { createStubAdapter, createTestContext } from './utils'; + +/** + * `forColumn(table, column)` dispatch must materialize a fresh codec instance with a column-specific `SqlCodecInstanceContext`. The pre-populated `byCodecId` representative is reserved for `forCodecId` refs-less fallbacks; reusing it for column-bound dispatch erases the per-column context any descriptor whose factory reads `CodecInstanceContext` (for diagnostics, telemetry, or per-column behaviour) would expect. + */ +describe('buildContractCodecRegistry — per-column codec instance context', () => { + function createCtxCapturingExtension(captures: SqlCodecInstanceContext[]): { + descriptor: SqlRuntimeExtensionDescriptor<'postgres'>; + instances: Array<{ ctx: SqlCodecInstanceContext; codec: Codec }>; + } { + const instances: Array<{ ctx: SqlCodecInstanceContext; codec: Codec }> = []; + const codecDescriptor: CodecDescriptor = { + codecId: 'test/captures-ctx@1', + traits: [], + targetTypes: ['captures'], + paramsSchema: voidParamsSchema, + isParameterized: false, + // Family-agnostic descriptor slot; SQL-side test consumer reads `usedAt` so the factory parameter is typed as the SQL-extended context. The cast through `unknown` mirrors what production SQL extensions do (see pgvector's family-agnostic factory cast). + factory: ((_params: undefined) => (ctx: SqlCodecInstanceContext) => { + captures.push(ctx); + const codec: Codec = { + id: 'test/captures-ctx@1', + encode: (v: unknown) => Promise.resolve(v), + decode: (w: unknown) => Promise.resolve(w), + encodeJson: (v) => v as never, + decodeJson: (j) => j as never, + }; + instances.push({ ctx, codec }); + return codec; + }) as unknown as CodecDescriptor['factory'], + }; + + return { + descriptor: { + kind: 'extension' as const, + id: 'test-captures-ctx', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => [codecDescriptor], + create() { + return { familyId: 'sql' as const, targetId: 'postgres' as const }; + }, + }, + instances, + }; + } + + function contractWith( + columns: Record, + ): Contract { + const tables: SqlStorage['tables'] = {}; + for (const [tableName, columnSpec] of Object.entries(columns)) { + tables[tableName] = { + columns: { + field: { + nativeType: columnSpec.nativeType, + codecId: columnSpec.codecId, + nullable: false, + }, + }, + primaryKey: { columns: ['field'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + } + + return { + targetFamily: 'sql', + target: 'postgres', + profileHash: profileHash('sha256:test'), + models: {}, + roots: {}, + storage: { storageHash: coreHash('sha256:test'), tables }, + extensionPacks: {}, + capabilities: {}, + meta: {}, + }; + } + + it('materializes a fresh per-column codec instance with `` context for forColumn dispatch', () => { + const captures: SqlCodecInstanceContext[] = []; + const { descriptor, instances } = createCtxCapturingExtension(captures); + + const contract = contractWith({ + users: { codecId: 'test/captures-ctx@1', nativeType: 'captures' }, + }); + + const context = createTestContext(contract, createStubAdapter(), { + extensionPacks: [descriptor], + }); + + const columnInstance = context.contractCodecs.forColumn('users', 'field'); + expect(columnInstance).toBeDefined(); + + const columnCtx = instances.find(({ codec }) => codec === columnInstance)?.ctx; + expect(columnCtx).toBeDefined(); + expect(columnCtx?.name).toBe(''); + expect(columnCtx?.usedAt).toEqual([{ table: 'users', column: 'field' }]); + }); + + it('preserves the representative `` context for forCodecId fallback', () => { + const captures: SqlCodecInstanceContext[] = []; + const { descriptor, instances } = createCtxCapturingExtension(captures); + + // Contract with no column referencing the descriptor — `forCodecId` resolves through the pre-populated representative only. + const contract = contractWith({}); + + const context = createTestContext(contract, createStubAdapter(), { + extensionPacks: [descriptor], + }); + + const codecIdInstance = context.contractCodecs.forCodecId('test/captures-ctx@1'); + expect(codecIdInstance).toBeDefined(); + + const sharedCtx = instances.find(({ codec }) => codec === codecIdInstance)?.ctx; + expect(sharedCtx).toBeDefined(); + expect(sharedCtx?.name).toBe(''); + expect(sharedCtx?.usedAt).toEqual([]); + }); + + it('materializes distinct instances for distinct columns sharing the same codec id', () => { + const captures: SqlCodecInstanceContext[] = []; + const { descriptor } = createCtxCapturingExtension(captures); + + const contract = contractWith({ + users: { codecId: 'test/captures-ctx@1', nativeType: 'captures' }, + orders: { codecId: 'test/captures-ctx@1', nativeType: 'captures' }, + }); + + const context = createTestContext(contract, createStubAdapter(), { + extensionPacks: [descriptor], + }); + + const usersInstance = context.contractCodecs.forColumn('users', 'field'); + const ordersInstance = context.contractCodecs.forColumn('orders', 'field'); + + expect(usersInstance).toBeDefined(); + expect(ordersInstance).toBeDefined(); + expect(usersInstance).not.toBe(ordersInstance); + + const usersCtx = captures.find((ctx) => ctx.name === ''); + const ordersCtx = captures.find((ctx) => ctx.name === ''); + expect(usersCtx).toBeDefined(); + expect(ordersCtx).toBeDefined(); + expect(usersCtx?.usedAt).toEqual([{ table: 'users', column: 'field' }]); + expect(ordersCtx?.usedAt).toEqual([{ table: 'orders', column: 'field' }]); + }); + + it('does not reuse the representative instance for forColumn dispatch', () => { + const captures: SqlCodecInstanceContext[] = []; + const { descriptor } = createCtxCapturingExtension(captures); + + const contract = contractWith({ + users: { codecId: 'test/captures-ctx@1', nativeType: 'captures' }, + }); + + const context = createTestContext(contract, createStubAdapter(), { + extensionPacks: [descriptor], + }); + + const columnInstance = context.contractCodecs.forColumn('users', 'field'); + const codecIdInstance = context.contractCodecs.forCodecId('test/captures-ctx@1'); + + // The representative is preserved as the `forCodecId` fallback even after a column resolved through `forColumn` — the two paths must surface distinct instances built with distinct ctxs. + expect(columnInstance).toBeDefined(); + expect(codecIdInstance).toBeDefined(); + expect(columnInstance).not.toBe(codecIdInstance); + }); +}); diff --git a/packages/2-sql/5-runtime/test/sql-context.test.ts b/packages/2-sql/5-runtime/test/sql-context.test.ts index a249d9db94..4abfe584c6 100644 --- a/packages/2-sql/5-runtime/test/sql-context.test.ts +++ b/packages/2-sql/5-runtime/test/sql-context.test.ts @@ -1,7 +1,7 @@ import { type Contract, coreHash, executionHash, profileHash } from '@prisma-next/contract/types'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { SqlOperationDescriptor } from '@prisma-next/sql-operations'; -import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; +import type { Codec } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { createExecutionContext, @@ -9,10 +9,12 @@ import { type SqlRuntimeExtensionDescriptor, type SqlRuntimeTargetDescriptor, } from '../src/sql-context'; +import { defineTestCodec } from './test-codec'; import { createStubAdapter, createTestAdapterDescriptor, createTestTargetDescriptor, + descriptorsFromCodecs, } from './utils'; const testContract: Contract = { @@ -33,20 +35,16 @@ function createTestExtensionDescriptor(options?: { }): SqlRuntimeExtensionDescriptor<'postgres'> { const { hasCodecs = false, hasOperations = false } = options ?? {}; - const codecRegistry = hasCodecs - ? (() => { - const registry = createCodecRegistry(); - registry.register( - codec({ - typeId: 'test/ext@1', - targetTypes: ['ext'], - encode: (v: string) => v, - decode: (w: string) => w, - }), - ); - return registry; - })() - : createCodecRegistry(); + const codecRegistry: ReadonlyArray> = hasCodecs + ? [ + defineTestCodec({ + typeId: 'test/ext@1', + targetTypes: ['ext'], + encode: (v: string) => v, + decode: (w: string) => w, + }), + ] + : []; const operationsArray: ReadonlyArray = hasOperations ? [ @@ -64,9 +62,8 @@ function createTestExtensionDescriptor(options?: { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => codecRegistry, + codecs: () => descriptorsFromCodecs(codecRegistry), queryOperations: () => operationsArray, - parameterizedCodecs: () => [], create() { return { familyId: 'sql' as const, @@ -94,7 +91,7 @@ describe('createExecutionContext', () => { }); expect(context.contract).toBe(testContract); - expect(context.codecs.has('pg/int4@1')).toBe(true); + expect(context.codecDescriptors.descriptorFor('pg/int4@1')).toBeDefined(); expect(context.queryOperations).toBeDefined(); }); @@ -104,8 +101,8 @@ describe('createExecutionContext', () => { stack: createStack({ extensionPacks: [] }), }); - expect(context.codecs.has('pg/int4@1')).toBe(true); - expect(context.codecs.has('test/ext@1')).toBe(false); + expect(context.codecDescriptors.descriptorFor('pg/int4@1')).toBeDefined(); + expect(context.codecDescriptors.descriptorFor('test/ext@1')).toBeUndefined(); }); it('registers extension codecs from descriptors', () => { @@ -116,8 +113,8 @@ describe('createExecutionContext', () => { }), }); - expect(context.codecs.has('pg/int4@1')).toBe(true); - expect(context.codecs.has('test/ext@1')).toBe(true); + expect(context.codecDescriptors.descriptorFor('pg/int4@1')).toBeDefined(); + expect(context.codecDescriptors.descriptorFor('test/ext@1')).toBeDefined(); }); it('registers extension operations from descriptors', () => { @@ -140,22 +137,21 @@ describe('createExecutionContext', () => { }), }); - expect(context.codecs.has('pg/int4@1')).toBe(true); - expect(context.codecs.has('test/ext@1')).toBe(false); + expect(context.codecDescriptors.descriptorFor('pg/int4@1')).toBeDefined(); + expect(context.codecDescriptors.descriptorFor('test/ext@1')).toBeUndefined(); }); }); describe('comprehensive descriptor-based derivation', () => { it('includes all expected codec IDs and operations from target, adapter, and extensions', () => { - const targetCodecRegistry = createCodecRegistry(); - targetCodecRegistry.register( - codec({ + const targetCodecRegistry: ReadonlyArray> = [ + defineTestCodec({ typeId: 'target/special@1', targetTypes: ['special'], encode: (v: string) => v, decode: (w: string) => w, }), - ); + ]; const targetOps: SqlOperationDescriptor[] = [ { @@ -171,9 +167,8 @@ describe('comprehensive descriptor-based derivation', () => { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => targetCodecRegistry, + codecs: () => descriptorsFromCodecs(targetCodecRegistry), queryOperations: () => targetOps, - parameterizedCodecs: () => [], create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -187,9 +182,9 @@ describe('comprehensive descriptor-based derivation', () => { const context = createExecutionContext({ contract: testContract, stack }); - expect(context.codecs.has('target/special@1')).toBe(true); - expect(context.codecs.has('pg/int4@1')).toBe(true); - expect(context.codecs.has('test/ext@1')).toBe(true); + expect(context.codecDescriptors.descriptorFor('target/special@1')).toBeDefined(); + expect(context.codecDescriptors.descriptorFor('pg/int4@1')).toBeDefined(); + expect(context.codecDescriptors.descriptorFor('test/ext@1')).toBeDefined(); const entries = context.queryOperations.entries(); expect(entries['targetOp']).toBeDefined(); @@ -519,8 +514,7 @@ describe('applyMutationDefaults', () => { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], mutationDefaultGenerators: () => [ { id: 'counter', @@ -610,8 +604,7 @@ describe('applyMutationDefaults', () => { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], mutationDefaultGenerators: () => [ { id: 'correlationId', @@ -693,8 +686,7 @@ describe('applyMutationDefaults', () => { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], mutationDefaultGenerators: () => [ { id: 'perFieldCounter', diff --git a/packages/2-sql/5-runtime/test/sql-family-adapter.test.ts b/packages/2-sql/5-runtime/test/sql-family-adapter.test.ts index fb0dc9b8b1..8e5d2541c5 100644 --- a/packages/2-sql/5-runtime/test/sql-family-adapter.test.ts +++ b/packages/2-sql/5-runtime/test/sql-family-adapter.test.ts @@ -23,9 +23,6 @@ const testProfile: AdapterProfile = { id: 'test/default@1', target: 'postgres', capabilities: {}, - codecs: () => { - throw new Error('not needed in test'); - }, readMarkerStatement: () => ({ sql: 'SELECT core_hash, profile_hash FROM prisma_contract.marker WHERE id = $1', params: [1], diff --git a/packages/2-sql/5-runtime/test/sql-runtime-abort.test.ts b/packages/2-sql/5-runtime/test/sql-runtime-abort.test.ts index 0d2c8e5ad0..5c299e837c 100644 --- a/packages/2-sql/5-runtime/test/sql-runtime-abort.test.ts +++ b/packages/2-sql/5-runtime/test/sql-runtime-abort.test.ts @@ -9,15 +9,12 @@ import { import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { Codec, - CodecRegistry, SqlCodecCallContext, SqlDriver, SqlExecuteRequest, } from '@prisma-next/sql-relational-core/ast'; import { ColumnRef, - codec, - createCodecRegistry, ProjectionItem, SelectAst, TableSource, @@ -32,6 +29,8 @@ import type { } from '../src/sql-context'; import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime } from '../src/sql-runtime'; +import { defineTestCodec } from './test-codec'; +import { descriptorsFromCodecs } from './utils'; const testContract: Contract = { targetFamily: 'sql', @@ -59,10 +58,8 @@ function deferred(): { return { promise, resolve, reject }; } -function createStubCodecs(extras: readonly Codec[] = []): CodecRegistry { - const registry = createCodecRegistry(); - for (const c of extras) registry.register(c); - return registry; +function createStubCodecs(extras: readonly Codec[] = []): ReadonlyArray> { + return [...extras]; } interface DriverOptions { @@ -105,13 +102,11 @@ function createStubAdapter(extraCodecs: readonly Codec[] = []) { return { familyId: 'sql' as const, targetId: 'postgres' as const, + __codecs: codecs, profile: { id: 'test-profile', target: 'postgres', capabilities: {}, - codecs() { - return codecs; - }, readMarkerStatement() { return { sql: 'select 1', params: [] }; }, @@ -133,22 +128,20 @@ function createTestSetup(extras: readonly Codec[] = [], driverOptions?: version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, }; - const codecRegistry = adapter.profile.codecs(); + const codecRegistry = adapter.__codecs; const adapterDescriptor: SqlRuntimeAdapterDescriptor<'postgres'> = { kind: 'adapter', id: 'test-adapter', version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => codecRegistry, - parameterizedCodecs: () => [], + codecs: () => descriptorsFromCodecs(codecRegistry), create() { return Object.assign( { familyId: 'sql' as const, targetId: 'postgres' as const }, @@ -308,14 +301,12 @@ describe('SqlRuntimeImpl.execute({ signal }) — abort semantics', () => { const blockingDecodeStarted = deferred(); const codecAbortObserved = deferred(); - const observingCodec = codec({ + const observingCodec = defineTestCodec({ typeId: 'test/observe-signal@1', targetTypes: ['text'], encode: (v: string) => v, decode: async (w: string, ctx?: SqlCodecCallContext) => { - // Mimic an SDK that registers an abort listener on the supplied - // signal. The runtime threads the same AbortSignal into every codec - // call; codec authors who forward it observe true cancellation. + // Mimic an SDK that registers an abort listener on the supplied signal. The runtime threads the same AbortSignal into every codec call; codec authors who forward it observe true cancellation. await new Promise((_resolve, reject) => { if (ctx?.signal) { ctx.signal.addEventListener('abort', () => { @@ -362,7 +353,7 @@ describe('SqlRuntimeImpl.execute({ signal }) — abort semantics', () => { it('codec ignoring ctx.signal does not block runtime — RUNTIME.ABORTED still surfaces (cooperative cancellation)', async () => { const decodeStarted = deferred(); const release = deferred(); - const ignoringCodec = codec({ + const ignoringCodec = defineTestCodec({ typeId: 'test/ignore-signal@1', targetTypes: ['text'], encode: (v: string) => v, @@ -391,10 +382,7 @@ describe('SqlRuntimeImpl.execute({ signal }) — abort semantics', () => { const reason = new Error('runtime aborted while codec body still running'); const collector = runtime.execute(plan, { signal: controller.signal }).toArray(); - // Wait until the decode body has actually started (we're now mid-decode); - // then abort. The race in raceAgainstAbort surfaces RUNTIME.ABORTED with - // phase: 'decode', even though the codec body is still running and does - // not honour the signal. + // Wait until the decode body has actually started (we're now mid-decode); then abort. The race in raceAgainstAbort surfaces RUNTIME.ABORTED with phase: 'decode', even though the codec body is still running and does not honour the signal. await decodeStarted.promise; controller.abort(reason); diff --git a/packages/2-sql/5-runtime/test/sql-runtime.test.ts b/packages/2-sql/5-runtime/test/sql-runtime.test.ts index 44e92b2e32..5f8d6b4a75 100644 --- a/packages/2-sql/5-runtime/test/sql-runtime.test.ts +++ b/packages/2-sql/5-runtime/test/sql-runtime.test.ts @@ -7,17 +7,10 @@ import { type RuntimeExtensionInstance, } from '@prisma-next/framework-components/execution'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; -import type { - Codec, - CodecRegistry, - SqlDriver, - SqlExecuteRequest, -} from '@prisma-next/sql-relational-core/ast'; +import type { Codec, SqlDriver, SqlExecuteRequest } from '@prisma-next/sql-relational-core/ast'; import { BinaryExpr, ColumnRef, - codec, - createCodecRegistry, LiteralExpr, ParamRef, ProjectionItem, @@ -37,6 +30,8 @@ import type { import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime, withTransaction } from '../src/sql-runtime'; import { createAsyncSecretCodec, decryptSecret } from './seeded-secret-codec'; +import { defineTestCodec } from './test-codec'; +import { descriptorsFromCodecs } from './utils'; const runtimeSecretSeed = 'sql-runtime-secret'; @@ -65,20 +60,18 @@ interface DriverMockSpies { type MockSqlDriver = SqlDriver & { __spies: DriverMockSpies }; -function createStubCodecs(extraCodecs: readonly Codec[] = []): CodecRegistry { - const registry = createCodecRegistry(); - registry.register( - codec({ +function createStubCodecs( + extraCodecs: readonly Codec[] = [], +): ReadonlyArray> { + return [ + defineTestCodec({ typeId: 'pg/int4@1', targetTypes: ['int4'], encode: (v: number) => v, decode: (w: number) => w, }), - ); - for (const extraCodec of extraCodecs) { - registry.register(extraCodec); - } - return registry; + ...extraCodecs, + ]; } function createStubAdapter(extraCodecs: readonly Codec[] = []) { @@ -86,13 +79,11 @@ function createStubAdapter(extraCodecs: readonly Codec[] = []) { return { familyId: 'sql' as const, targetId: 'postgres' as const, + __codecs: codecs, profile: { id: 'test-profile', target: 'postgres', capabilities: {}, - codecs() { - return codecs; - }, readMarkerStatement() { return { sql: 'select core_hash, profile_hash, contract_json, canonical_version, updated_at, app_tag, meta, invariants from prisma_contract.marker where id = $1', @@ -171,8 +162,7 @@ function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -182,15 +172,14 @@ function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { function createTestAdapterDescriptor( adapter: ReturnType, ): SqlRuntimeAdapterDescriptor<'postgres'> { - const codecRegistry = adapter.profile.codecs(); + const codecRegistry = adapter.__codecs; return { kind: 'adapter', id: 'test-adapter', version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => codecRegistry, - parameterizedCodecs: () => [], + codecs: () => descriptorsFromCodecs(codecRegistry), create() { return Object.assign( { familyId: 'sql' as const, targetId: 'postgres' as const }, @@ -406,8 +395,7 @@ describe('createRuntime', () => { it('rejects a Mongo middleware with a clear error', () => { const { stackInstance, context, driver } = createTestSetup(); - // Simulate a caller bypassing the SqlMiddleware type constraint (e.g. dynamically-loaded - // middleware). Static typing already rejects familyId: 'mongo'; this tests the runtime guard. + // Simulate a caller bypassing the SqlMiddleware type constraint (e.g. dynamically-loaded middleware). Static typing already rejects familyId: 'mongo'; this tests the runtime guard. const mongoMiddleware = { name: 'mongo-mw', familyId: 'mongo' } as unknown as SqlMiddleware; expect(() => createRuntime({ @@ -617,7 +605,7 @@ describe('createRuntime', () => { ); it('wraps async parameter encoding failures before the driver runs', async () => { - const failingCodec = codec({ + const failingCodec = defineTestCodec({ typeId: 'test/failing-secret@1', targetTypes: ['text'], encode: async (_value: string) => { @@ -746,9 +734,7 @@ describe('withTransaction', () => { expect(driver.__spies.transactionCommit).toHaveBeenCalledOnce(); expect(driver.__spies.transactionRollback).toHaveBeenCalledOnce(); - // A successful rollback after a failed commit means the server is no - // longer in a transaction and the connection round-tripped cleanly, so - // it is safe to return to the pool rather than evict it. + // A successful rollback after a failed commit means the server is no longer in a transaction and the connection round-tripped cleanly, so it is safe to return to the pool rather than evict it. expect(driver.__spies.connectionRelease).toHaveBeenCalledOnce(); expect(driver.__spies.connectionDestroy).not.toHaveBeenCalled(); }); diff --git a/packages/2-sql/5-runtime/test/test-codec.ts b/packages/2-sql/5-runtime/test/test-codec.ts new file mode 100644 index 0000000000..4185017321 --- /dev/null +++ b/packages/2-sql/5-runtime/test/test-codec.ts @@ -0,0 +1,60 @@ +/** + * Test-only helper that constructs a SQL-family `Codec` instance from author-side encode/decode functions. Replaces the legacy public `mkCodec()` factory (deleted under TML-2357); tests that need a stub codec for behavioural assertions instantiate one through this helper rather than going through `descriptor.factory(...)`. + * + * The body is identical in spirit to the retired `mkCodec`: promise-lift sync author functions onto the framework-required `Promise<…>` boundary, default `encodeJson`/`decodeJson` to identity when `TInput` is JSON-safe, fail loudly otherwise. + */ +import type { JsonValue } from '@prisma-next/contract/types'; +import type { CodecTrait } from '@prisma-next/framework-components/codec'; +import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; + +type JsonRoundTripConfig = [TInput] extends [JsonValue] + ? { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + } + : { + encodeJson: (value: TInput) => JsonValue; + decodeJson: (json: JsonValue) => TInput; + }; + +export function defineTestCodec< + Id extends string, + const TTraits extends readonly CodecTrait[] = readonly [], + TWire = unknown, + TInput = unknown, +>( + config: { + typeId: Id; + targetTypes?: readonly string[]; + encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; + decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + traits?: TTraits; + } & JsonRoundTripConfig, +): Codec { + const identity = (v: unknown) => v; + const userEncode = config.encode; + const userDecode = config.decode; + const widenedConfig = config as { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + }; + return { + id: config.typeId, + encode: (value, ctx) => { + try { + return Promise.resolve(userEncode(value, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + decode: (wire, ctx) => { + try { + return Promise.resolve(userDecode(wire, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, + decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + } as Codec; +} diff --git a/packages/2-sql/5-runtime/test/utils.ts b/packages/2-sql/5-runtime/test/utils.ts index c4a1322113..2c7f3ae581 100644 --- a/packages/2-sql/5-runtime/test/utils.ts +++ b/packages/2-sql/5-runtime/test/utils.ts @@ -1,5 +1,11 @@ import type { Contract } from '@prisma-next/contract/types'; import { coreHash, profileHash } from '@prisma-next/contract/types'; +import type { + CodecDescriptor, + CodecMeta, + CodecTrait, +} from '@prisma-next/framework-components/codec'; +import { voidParamsSchema } from '@prisma-next/framework-components/codec'; import { instantiateExecutionStack, type RuntimeDriverDescriptor, @@ -8,8 +14,13 @@ import type { ResultType } from '@prisma-next/framework-components/runtime'; import { builtinGeneratorIds } from '@prisma-next/ids'; import { generateId } from '@prisma-next/ids/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; -import type { Adapter, LoweredStatement, SelectAst } from '@prisma-next/sql-relational-core/ast'; -import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; +import type { + Adapter, + Codec, + ContractCodecRegistry, + LoweredStatement, + SelectAst, +} from '@prisma-next/sql-relational-core/ast'; import type { SqlExecutionPlan, SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import { collectAsync, drainAsyncIterable } from '@prisma-next/test-utils'; import type { Client } from 'pg'; @@ -31,6 +42,7 @@ import type { SqlRuntimeExtensionDescriptor, SqlRuntimeTargetDescriptor, } from '../src/sql-context'; +import { defineTestCodec } from './test-codec'; function createTestMutationDefaultGenerators() { return builtinGeneratorIds.map((id) => ({ @@ -41,9 +53,7 @@ function createTestMutationDefaultGenerators() { } /** - * Executes a plan and collects all results into an array. - * This helper DRYs up the common pattern of executing plans in tests. - * The return type is inferred from the plan's type parameter. + * Executes a plan and collects all results into an array. This helper DRYs up the common pattern of executing plans in tests. The return type is inferred from the plan's type parameter. */ export async function executePlanAndCollect< P extends SqlExecutionPlan> | SqlQueryPlan>, @@ -53,8 +63,7 @@ export async function executePlanAndCollect< } /** - * Drains a plan execution, consuming all results without collecting them. - * Useful for testing side effects without memory overhead. + * Drains a plan execution, consuming all results without collecting them. Useful for testing side effects without memory overhead. */ export async function drainPlanExecution( runtime: ReturnType, @@ -76,8 +85,7 @@ export async function executeStatement(client: Client, statement: SqlStatement): } /** - * Sets up database schema and data, then writes the contract marker. - * This helper DRYs up the common pattern of database setup in tests. + * Sets up database schema and data, then writes the contract marker. This helper DRYs up the common pattern of database setup in tests. */ export async function setupTestDatabase( client: Client, @@ -101,8 +109,7 @@ export async function setupTestDatabase( } /** - * Writes a contract marker to the database. - * This helper DRYs up the common pattern of writing contract markers in tests. + * Writes a contract marker to the database. This helper DRYs up the common pattern of writing contract markers in tests. */ export async function writeTestContractMarker( client: Client, @@ -118,22 +125,62 @@ export async function writeTestContractMarker( } /** - * Creates a test adapter descriptor from a raw adapter. - * Wraps the adapter in an SqlRuntimeAdapterDescriptor with static contributions - * derived from the adapter's codec registry. + * Creates a test adapter descriptor from a raw adapter. Wraps the adapter in an SqlRuntimeAdapterDescriptor with static contributions derived from the adapter's codec registry. + */ +/** + * Build a {@link ContractCodecRegistry} from a codec array for tests that exercise `encodeParam(s)` / `decodeRow` in isolation. The production runtime builds `ContractCodecRegistry` from contract walk + descriptor list and never goes through this helper; tests use it to wire a hand-built codec set into the surface those functions consume in production. + */ +export function buildTestContractCodecs( + codecs: ReadonlyArray>, +): ContractCodecRegistry { + const byId = new Map>(); + for (const codec of codecs) { + byId.set(codec.id, codec); + } + return { + forColumn: () => undefined, + forCodecId: (codecId) => byId.get(codecId), + }; +} + +/** + * Synthesize `CodecDescriptor`s from a codec array of non-parameterized codec instances. Test-only: the production synthesis bridge was retired under TML-2357. Lets the existing `createTestAdapterDescriptor` pattern keep wrapping a stub `Adapter` (whose `__codecs` slot still exposes the codec set) into the descriptor-list shape that `SqlStaticContributions.codecs:` now expects. The `Codec` instances carry + * `traits`/`targetTypes`/`meta` via the SQL family extension; the structural narrow reads those fields directly. */ +export function descriptorsFromCodecs( + codecs: ReadonlyArray>, +): ReadonlyArray { + const descriptors: CodecDescriptor[] = []; + for (const instance of codecs) { + const legacy = instance as { + readonly traits?: readonly CodecTrait[]; + readonly targetTypes?: readonly string[]; + readonly meta?: CodecMeta; + }; + descriptors.push({ + codecId: instance.id, + traits: legacy.traits ?? [], + targetTypes: legacy.targetTypes ?? [], + paramsSchema: voidParamsSchema, + isParameterized: false, + factory: () => () => instance, + ...(legacy.meta !== undefined ? { meta: legacy.meta } : {}), + }); + } + return descriptors; +} + export function createTestAdapterDescriptor( - adapter: Adapter, LoweredStatement>, + adapter: StubAdapter, ): SqlRuntimeAdapterDescriptor<'postgres'> { - const codecRegistry = adapter.profile.codecs(); + const descriptors = descriptorsFromCodecs(adapter.__codecs); return { kind: 'adapter' as const, id: 'test-adapter', version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => codecRegistry, - parameterizedCodecs: () => [], + codecs: () => descriptors, mutationDefaultGenerators: createTestMutationDefaultGenerators, create(_stack): SqlRuntimeAdapterInstance<'postgres'> { return Object.assign({ familyId: 'sql' as const, targetId: 'postgres' as const }, adapter); @@ -151,8 +198,7 @@ export function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgr version: '0.0.1', familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], create() { return { familyId: 'sql' as const, targetId: 'postgres' as const }; }, @@ -160,15 +206,13 @@ export function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgr } /** - * Creates an ExecutionContext for testing. - * This helper DRYs up the common pattern of context creation in tests. + * Creates an ExecutionContext for testing. This helper DRYs up the common pattern of context creation in tests. * - * Accepts a raw adapter and optional extension descriptors, wrapping the - * adapter in a descriptor internally for descriptor-first context creation. + * Accepts a raw adapter and optional extension descriptors, wrapping the adapter in a descriptor internally for descriptor-first context creation. */ export function createTestContext>( contract: TContract, - adapter: Adapter, LoweredStatement>, + adapter: StubAdapter, options?: { extensionPacks?: ReadonlyArray>; }, @@ -203,60 +247,52 @@ export function createTestStackInstance(options?: { } /** - * Creates a stub adapter for testing. - * This helper DRYs up the common pattern of adapter creation in tests. - * - * The stub adapter includes simple codecs for common test types (pg/int4@1, pg/text@1, pg/timestamptz@1) - * to enable type inference in tests without requiring the postgres adapter package. + * Stub-adapter type augments the public {@link Adapter} surface with a `__codecs` slot that exposes the test stub's runtime codec set to descriptor-shaping helpers (`createTestAdapterDescriptor`). Production adapters do not declare this slot — runtime codecs flow through the descriptor list from `SqlRuntimeAdapterDescriptor.codecs()` — so the augmentation is intentionally test-only. */ -export function createStubAdapter(): Adapter, LoweredStatement> { - const codecRegistry = createCodecRegistry(); +export type StubAdapter = Adapter, LoweredStatement> & { + readonly __codecs: ReadonlyArray>; +}; - // Register stub codecs for common test types - // These match the codec IDs used in test contracts (pg/int4@1, pg/text@1, pg/timestamptz@1) - // but don't require importing from the postgres adapter package - codecRegistry.register( - codec({ +/** + * Creates a stub adapter for testing. This helper DRYs up the common pattern of adapter creation in tests. + * + * The stub adapter includes simple codecs for common test types (pg/int4@1, pg/text@1, pg/timestamptz@1) to enable type inference in tests without requiring the postgres adapter package. + */ +export function createStubAdapter(): StubAdapter { + // Stub codecs for common test types — match the codec IDs used in test contracts (pg/int4@1, pg/text@1, pg/timestamptz@1) without importing from the postgres adapter package. + const codecs: ReadonlyArray> = [ + defineTestCodec({ typeId: 'pg/int4@1', targetTypes: ['int4'], encode: (value: number) => value, decode: (wire: number) => wire, }), - ); - - codecRegistry.register( - codec({ + defineTestCodec({ typeId: 'pg/text@1', targetTypes: ['text'], encode: (value: string) => value, decode: (wire: string) => wire, }), - ); - - codecRegistry.register( - codec({ + defineTestCodec({ typeId: 'pg/timestamptz@1', targetTypes: ['timestamptz'], encode: (value: Date) => value, decode: (wire: Date) => wire, - // Date is not assignable to JsonValue, so the JSON round-trip pair - // must be supplied explicitly. + // Date is not assignable to JsonValue, so the JSON round-trip pair must be supplied explicitly. encodeJson: (value: Date) => value.toISOString(), decodeJson: (json) => { if (typeof json !== 'string') throw new Error('expected ISO date string'); return new Date(json); }, }), - ); + ]; return { + __codecs: codecs, profile: { id: 'stub-profile', target: 'postgres', capabilities: {}, - codecs() { - return codecRegistry; - }, readMarkerStatement() { return { sql: 'select core_hash, profile_hash, contract_json, canonical_version, updated_at, app_tag, meta, invariants from prisma_contract.marker where id = $1', diff --git a/packages/3-extensions/arktype-json/README.md b/packages/3-extensions/arktype-json/README.md index 56ef1ba171..7e00988ad3 100644 --- a/packages/3-extensions/arktype-json/README.md +++ b/packages/3-extensions/arktype-json/README.md @@ -1,33 +1,19 @@ # `@prisma-next/extension-arktype-json` -Per-library JSON-with-schema column factory for Prisma Next, built on -[arktype](https://arktype.io). Ships the `arktypeJson(schema)` column-author -helper and the `arktype/json@1` codec descriptor. +Per-library JSON-with-schema column factory for Prisma Next, built on [arktype](https://arktype.io). Ships the `arktypeJson(schema)` column-author helper and the `arktype/json@1` codec descriptor. ## What it does -Given an arktype `Type`, `arktypeJson(schema)` produces a column descriptor -that: +Given an arktype `Type`, `arktypeJson(schema)` produces a column descriptor that: - Stores values as `jsonb` on Postgres. -- Eagerly serializes `schema.expression` (TypeScript-source-like rendering) - and `schema.json` (arktype's internal IR) into `typeParams`. The IR is the - lossless rehydration source; the expression is the emit-path renderer's - input. -- At runtime, the framework's unified codec descriptor map rehydrates the - schema via `ark.schema(typeParams.jsonIr)` and returns a `Codec` whose - `decode` validates wire payloads via the rehydrated schema. Validation - failures throw `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED`. -- The emitter renders the column's TS type as the schema's `expression` - (e.g. `{ name: string; price: number }`). +- Eagerly serializes `schema.expression` (TypeScript-source-like rendering) and `schema.json` (arktype's internal IR) into `typeParams`. The IR is the lossless rehydration source; the expression is the emit-path renderer's input. +- At runtime, the framework's unified codec descriptor map rehydrates the schema via `ark.schema(typeParams.jsonIr)` and returns a `Codec` whose `decode` validates wire payloads via the rehydrated schema. Validation failures throw `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED`. +- The emitter renders the column's TS type as the schema's `expression` (e.g. `{ name: string; price: number }`). ## Why a per-library extension -The unified `CodecDescriptor` model routes JSON-with-schema through per- -library extension packages: arktype-json now, future zod / valibot -extensions when each has a clean serialize / rehydrate story. The Postgres -adapter retains only the storage-level `jsonColumn` / `jsonbColumn` -descriptors (untyped raw JSON). See [ADR 208 — Higher-order codecs for parameterized types](../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). +The unified `CodecDescriptor` model routes JSON-with-schema through per-library extension packages: arktype-json now, future zod / valibot extensions when each has a clean serialize / rehydrate story. The Postgres adapter retains only the storage-level `jsonColumn` / `jsonbColumn` descriptors (untyped raw JSON). See [ADR 208 — Higher-order codecs for parameterized types](../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). ## Usage @@ -51,14 +37,11 @@ const contract = defineContract({ /* ... */ }, ({ field, model }) => ({ })); ``` -In the emitted `contract.d.ts`, `Product.spec` resolves to -`{ name: string; price: number; description?: string }` — the schema's -expression renders directly into the field type. +In the emitted `contract.d.ts`, `Product.spec` resolves to `{ name: string; price: number; description?: string }` — the schema's expression renders directly into the field type. ## Pack registration -Add the runtime descriptor to your runtime stack and the control descriptor -to your `prisma-next.config.ts` `extensionPacks`: +Add the runtime descriptor to your runtime stack and the control descriptor to your `prisma-next.config.ts` `extensionPacks`: ```ts import arktypeJsonPack from '@prisma-next/extension-arktype-json/pack'; @@ -80,12 +63,6 @@ const stack = createSqlExecutionStack({ ## Notes -- The codec is library-bound (`arktype/json@1`), not target-bound. Other - schema libraries ship as parallel extensions (`zod/json@1`, - `valibot/json@1`) when their serialize/rehydrate stories materialize. -- `decode` validates internally and throws on rejection; the framework's - `JsonSchemaValidatorRegistry` is not consulted for arktype-json columns - (no `'json-validator'` trait + per-instance `validate` extraction). The - one-path "validate inside `decode`" matches the spec's Case J pinning. -- For untyped raw JSON columns, use `jsonColumn` / `jsonbColumn` from - `@prisma-next/adapter-postgres/column-types` instead. +- The codec is library-bound (`arktype/json@1`), not target-bound. Other schema libraries ship as parallel extensions (`zod/json@1`, `valibot/json@1`) when their serialize/rehydrate stories materialize. +- `decode` validates internally and throws on rejection. JSON-Schema validation lives uniformly inside the resolved codec's `decode` body; the framework no longer maintains a parallel validator registry. Validation rejections surface as `RUNTIME.DECODE_FAILED` envelopes with the original `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED` attached on `cause`. +- For untyped raw JSON columns, use `jsonColumn` / `jsonbColumn` from `@prisma-next/adapter-postgres/column-types` instead. diff --git a/packages/3-extensions/arktype-json/package.json b/packages/3-extensions/arktype-json/package.json index 7722ad09be..b51e285093 100644 --- a/packages/3-extensions/arktype-json/package.json +++ b/packages/3-extensions/arktype-json/package.json @@ -21,6 +21,7 @@ "@prisma-next/framework-components": "workspace:*", "@prisma-next/sql-relational-core": "workspace:*", "@prisma-next/sql-runtime": "workspace:*", + "@standard-schema/spec": "^1.1.0", "arktype": "^2.1.29" }, "devDependencies": { diff --git a/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts b/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts index d4ba961950..3dc6141ad9 100644 --- a/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts +++ b/packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts @@ -1,395 +1,167 @@ /** - * Single source of truth for the arktype-json `arktype/json@1` codec. + * Arktype-json codec (TML-2357). * - * Ships the per-library JSON-with-schema column factory (`arktypeJson`) and - * the framework-registration descriptor (`arktypeJsonCodec`). The two - * surfaces share one serialize/rehydrate pipeline keyed on arktype's - * internal IR. + * Spec § Case 3: method-level generic over `S extends Type`. The schema's TypeScript-level inferred type `S['infer']` is only available at the column-author site (where the user passes their typed schema), not at the descriptor's factory site (where only the serialized IR is available). This drives the shape: * - * **Serialization** (column-author site, eager): + * 1. {@link ArktypeJsonCodecClass} extends {@link CodecImpl} and is generic over `TInferred` — the application-level JS type the schema validates to. The constructor takes both the descriptor (for `id` proxy) and the rehydrated arktype `Type` (closure-captured so encode/decode/encodeJson/decodeJson can validate through it). 2. {@link ArktypeJsonDescriptor} extends {@link CodecDescriptorImpl} over {@link + * ArktypeJsonTypeParams}. Factory rehydrates the schema from `params.jsonIr` and returns `(ctx) => new ArktypeJsonCodecClass(this, schema)` — `S` is erased to `unknown` because the descriptor only sees IR. The runtime path through `descriptor.factory(params)` always exists (e.g. for `validateContract` re-materialization); it just loses the typed inferred shape. 3. {@link arktypeJsonColumn} is the column-author + * surface with the method-level generic over `S extends Type`. It bypasses `descriptor.factory` because `S` is only available here, instead constructing the typed codec directly so `S['infer']` flows through `codecFactory`'s return into the column site's resolved output type. Eager serialization at this call site captures `expression` (for the emit-path renderer) and `jsonIr` (for runtime rehydration). * - * - `expression`: `schema.expression` — arktype's TypeScript-source-like - * rendering used by the emit-path `renderOutputType` to produce the - * column's TS type in `contract.d.ts`. - * - `jsonIr`: `schema.json` — arktype's internal IR. Lossless; the - * rehydration source consumed by `ark.schema(jsonIr)` at runtime. - * - * The pair is sufficient: `expression` round-trips with the rehydrated - * schema (`ark.schema(jsonIr).expression === expression`) so the emit-path - * output is stable across serialize/rehydrate. - * - * **Rehydration** (runtime, on factory invocation): `ark.schema(typeParams.jsonIr)` - * returns a callable `Type`-like with `~standard`. The returned codec's - * `decode` body validates wire payloads through the rehydrated schema and - * throws `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED` on rejection — no separate - * validator-registry consultation. - * - * See the codec-registry-unification spec § Case J (JSON-with-schema). + * `satisfies ColumnHelperFor` (coarse) is applied — the typeParams shape is verified. `ColumnHelperForStrict` is intentionally skipped: the descriptor's factory return is `ArktypeJsonCodecClass` while the helper produces `ArktypeJsonCodecClass`, and `Codec`'s `TInput` is invariant (used contravariantly in `encode`, covariantly in `decode`/`encodeJson`/`decodeJson`). Strict + * assignment fails by design; the explicit `expectTypeOf` tests in `test/arktype-json-codec.types.test-d.ts` cover the literal-preservation property the strict variant would otherwise enforce. */ import type { JsonValue } from '@prisma-next/contract/types'; -import type { ColumnTypeDescriptor } from '@prisma-next/contract-authoring'; -import type { - Codec, - CodecDescriptor, - CodecInstanceContext, +import { + type AnyCodecDescriptor, + type CodecCallContext, + CodecDescriptorImpl, + CodecImpl, + type CodecInstanceContext, + type ColumnHelperFor, + type ColumnSpec, + column, } from '@prisma-next/framework-components/codec'; import { runtimeError } from '@prisma-next/framework-components/runtime'; -import { codec } from '@prisma-next/sql-relational-core/ast'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { ArkErrors, ark, type Type, type } from 'arktype'; -// ── Constants ──────────────────────────────────────────────────────────── - /** Codec id for arktype-backed JSON columns. Library-bound, not target-bound. */ export const ARKTYPE_JSON_CODEC_ID = 'arktype/json@1' as const; /** Native storage type backing the codec. JSONB on Postgres; binary, indexable. */ export const ARKTYPE_JSON_NATIVE_TYPE = 'jsonb' as const; -// ── typeParams shape ───────────────────────────────────────────────────── - /** - * Eagerly serialized typeParams for the arktype-json column. Carried in - * the contract IR; the runtime descriptor's factory rehydrates `jsonIr` - * and the emitter consumes `expression`. + * Eagerly serialized typeParams for the arktype-json column. Carried in the contract IR; the runtime descriptor's factory rehydrates `jsonIr` and the emitter consumes `expression`. */ export type ArktypeJsonTypeParams = { /** - * Arktype's TypeScript-source-like rendering of the schema. Read by - * `renderOutputType` to emit the column's TS type into `contract.d.ts`. - * Stable across the serialize/rehydrate cycle: the rehydrated schema's - * `expression` matches the source schema's. + * Arktype's TypeScript-source-like rendering of the schema. Read by `renderOutputType` to emit the column's TS type into `contract.d.ts`. Stable across the serialize/rehydrate cycle: the rehydrated schema's `expression` matches the source schema's. */ readonly expression: string; /** - * Arktype's internal IR for the schema. Lossless; the rehydration - * source. Schema-shape — `ark.schema(jsonIr)` reconstructs a callable - * `Type`-like structurally identical to the original `type(definition)` - * output. + * Arktype's internal IR for the schema. Lossless; the rehydration source. Schema-shape — `ark.schema(jsonIr)` reconstructs a callable `Type`-like structurally identical to the original `type(definition)` output. */ readonly jsonIr: object; }; -// ── Curried higher-order codec factory ─────────────────────────────────── - -/** - * Codec instance returned by `arktypeJson(schema)(ctx)` and by - * `arktypeJsonCodec.factory(typeParams)(ctx)`. The `TInferred` slot - * carries the arktype schema's inferred output type. - */ -export type ArktypeJsonCodec = Codec< - typeof ARKTYPE_JSON_CODEC_ID, - readonly ['equality'], - string, - TInferred ->; - -/** - * Structural narrow of arktype's `Type` — the surface our codec depends - * on: a callable validator that returns `inferOut | ArkErrors`, plus the - * `expression` string for emit-path rendering. - * - * Avoids depending on the precise generics of arktype's `Type` so - * schemas built in any scope (the default `Ark` from `type(...)` AND the - * minimal scope from `ark.schema(...)`) satisfy the same contract. - */ type ArktypeSchemaLike = ((value: unknown) => unknown) & { readonly expression: string; }; -/** - * Type predicate for `ArktypeSchemaLike`. Lets the column-author - * factory narrow `unknown` schemas to the structural shape the codec - * depends on after the explicit field guards run, so the descriptor - * builder doesn't fall back to a `as unknown as` cast. - */ function isArktypeSchemaLike(value: unknown): value is ArktypeSchemaLike { if (typeof value !== 'function') return false; const expression = (value as { readonly expression?: unknown }).expression; return typeof expression === 'string'; } -/** - * Build the curried factory for a rehydrated arktype schema. The factory's - * returned codec carries the schema in its closure; `decode` validates - * wire payloads via `schema(parsed)`, throwing - * `RUNTIME.JSON_SCHEMA_VALIDATION_FAILED` on rejection. - * - * Encode is `JSON.stringify` — the schema validates the input shape only - * at the read boundary (decode), matching the JSON-validator philosophy: - * the payload may have been written by any source (this writer, a - * previous version of the schema, a manual SQL `INSERT`); validate when - * reading, not when writing. - * - * Author bodies are sync; main's `codec({...})` factory promise-lifts - * `encode`/`decode` into the framework-required `Promise<…>` boundary - * shape (per ADR 204). - */ -function arktypeJsonCodecForSchema( - schema: ArktypeSchemaLike, -): (ctx: CodecInstanceContext) => ArktypeJsonCodec { - // Shared schema check used by both `decode` (wire → JS) and - // `decodeJson` (JsonValue → JS). Either entry point must reject - // payloads that don't match the schema; without the shared validator, - // any caller that hands parsed JSON straight to the codec would bypass - // schema enforcement and return unchecked data. - function validateSchema(value: unknown): TInferred { - const result = schema(value); - if (result instanceof ArkErrors) { - throw runtimeError( - 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', - `arktype-json schema validation failed (decode): ${result.summary}`, - { codecId: ARKTYPE_JSON_CODEC_ID, issues: result.summary }, - ); - } - // arktype's call-result is `inferOut | ArkErrors`; the ArkErrors - // branch is excluded above. The cast threads the caller-supplied - // generic onto the structurally-typed validation output. - return result as TInferred; - } - - // Derive both `encode` (wire string) and `encodeJson` (JsonValue) - // outputs from the same `JSON.stringify` → `JSON.parse` round-trip, - // then validate the normalized payload through the schema. Without - // this normalization, a non-JSON-safe runtime value (e.g. a class - // instance, a function field on a narrowed type) could slip through - // `encodeJson` unchanged while `encode` silently dropped or - // transformed it — producing wire payloads the codec's own decode - // path would later reject. The serialize/parse round-trip also - // produces the JSON-safe shape required by the contract IR's - // `JsonValue` surface, so `encodeJson` no longer needs a blind cast. - function serializeToJsonSafe(value: TInferred): { wire: string; json: JsonValue } { - // `JSON.stringify` returns `string | undefined` — `undefined` - // happens when the input is `undefined` itself or contains only - // unserializable values (functions, symbols). Reject explicitly so - // the caller sees the schema-failure code rather than a downstream - // `JSON.parse(undefined)` SyntaxError. - const wire: string | undefined = JSON.stringify(value); - if (typeof wire !== 'string') { - throw runtimeError( - 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', - `arktype-json value is not representable as JSON (codecId: ${ARKTYPE_JSON_CODEC_ID})`, - { codecId: ARKTYPE_JSON_CODEC_ID }, - ); - } - const json = JSON.parse(wire) as JsonValue; - // Validate the normalized payload — the round-trip strips - // class-prototype shape and arktype-narrowed fields, and the - // schema must still accept the result. Run validation and discard - // its return value (we keep `json` as the JsonValue, not the - // schema's `inferOut` which already matches `TInferred`). - validateSchema(json); - return { wire, json }; +function validateSchema(schema: ArktypeSchemaLike, value: unknown): TInferred { + const result = schema(value); + if (result instanceof ArkErrors) { + throw runtimeError( + 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', + `arktype-json schema validation failed (decode): ${result.summary}`, + { codecId: ARKTYPE_JSON_CODEC_ID, issues: result.summary }, + ); } - - return (_ctx) => - codec({ - typeId: ARKTYPE_JSON_CODEC_ID, - targetTypes: [ARKTYPE_JSON_NATIVE_TYPE], - traits: ['equality'] as const, - encode: (value: TInferred): string => serializeToJsonSafe(value).wire, - decode: (wire: string): TInferred => validateSchema(JSON.parse(wire)), - encodeJson: (value: TInferred): JsonValue => serializeToJsonSafe(value).json, - decodeJson: (json: JsonValue) => validateSchema(json), - }) as ArktypeJsonCodec; + return result as TInferred; } -// ── Column-author surface ──────────────────────────────────────────────── - -/** - * Curried column-author factory for arktype-validated JSON columns. - * - * Usage: - * - * ```ts - * import { type } from 'arktype'; - * import { arktypeJson } from '@prisma-next/extension-arktype-json/column-types'; - * - * const ProductSchema = type({ name: 'string', price: 'number' }); - * - * const Product = { - * columns: { - * id: textCodec, - * settings: arktypeJson(ProductSchema), - * // ^? ColumnTypeDescriptor with type :: (ctx) => Codec<…, { name: string; price: number }> - * }, - * }; - * ``` - * - * The schema's inferred output flows through `S['infer']` so the no-emit - * `FieldOutputType` resolver produces the precise TS type at the column - * site. Eager serialization at this call site captures `expression` (for - * the emit-path renderer) and `jsonIr` (for runtime rehydration). - * - * @throws {Error} if the schema doesn't expose `expression` and `json` - * fields (i.e. is not an arktype `Type`). The factory validates the - * schema shape at the call site so configuration errors surface during - * contract authoring, not at runtime. - */ -export function arktypeJson>( - schema: S, -): ColumnTypeDescriptor & { - readonly codecId: typeof ARKTYPE_JSON_CODEC_ID; - readonly nativeType: typeof ARKTYPE_JSON_NATIVE_TYPE; - readonly typeParams: ArktypeJsonTypeParams; - readonly type: (ctx: CodecInstanceContext) => ArktypeJsonCodec; -} { - // Reject non-callable / non-arktype-shaped lookalikes before any - // property reads. An object shaped like `{ expression, json }` would - // otherwise pass the field checks and only explode on the first - // `decode`/`decodeJson` call, defeating the early authoring-time - // guard this factory provides. The `isArktypeSchemaLike` predicate - // narrows `schema` so the descriptor builder hands the typed shape - // straight to the curried factory — no `as unknown as` cast. - if (!isArktypeSchemaLike(schema)) { - throw new Error( - typeof schema !== 'function' - ? 'arktypeJson(schema) expects a callable arktype Type.' - : 'arktypeJson(schema) expects an arktype Type (missing `expression: string`).', +function serializeToJsonSafe( + schema: ArktypeSchemaLike, + value: TInferred, +): { wire: string; json: JsonValue } { + const wire: string | undefined = JSON.stringify(value); + if (typeof wire !== 'string') { + throw runtimeError( + 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', + `arktype-json value is not representable as JSON (codecId: ${ARKTYPE_JSON_CODEC_ID})`, + { codecId: ARKTYPE_JSON_CODEC_ID }, ); } - const jsonIr: unknown = (schema as { readonly json?: unknown }).json; - if (jsonIr === null || typeof jsonIr !== 'object') { - throw new Error('arktypeJson(schema) expects an arktype Type (missing `json` IR).'); - } - return { - codecId: ARKTYPE_JSON_CODEC_ID, - nativeType: ARKTYPE_JSON_NATIVE_TYPE, - typeParams: { expression: schema.expression, jsonIr }, - type: arktypeJsonCodecForSchema(schema), - } as const; + const json = JSON.parse(wire) as JsonValue; + validateSchema(schema, json); + return { wire, json }; } -// ── Framework-registration descriptor ──────────────────────────────────── - -/** - * Standard Schema validator for the descriptor's typeParams. Asserts the - * shape `{ expression: string; jsonIr: object }` at the contract IR - * boundary; deeper IR-shape validation happens implicitly when - * `ark.schema(jsonIr)` reparses (corrupt IR throws there). - * - * Eats its own dog food: the validator is itself an arktype schema. - */ -const arktypeJsonParamsSchema = type({ - expression: 'string', - jsonIr: 'object', -}); - -/** - * Rehydrate an arktype schema from the serialized IR. Throws a clean - * error if the IR is corrupt — the "corruption-of-contract.json" case. - */ function rehydrateSchema(jsonIr: object): ArktypeSchemaLike { + let rehydrated: unknown; try { - return ark.schema(jsonIr) as unknown as ArktypeSchemaLike; + rehydrated = ark.schema(jsonIr); } catch (error) { throw runtimeError( 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', + /* c8 ignore next — the `String(error)` fallback covers throws of non-Error values; arktype only throws Error subclasses, so this branch is defensive only. */ `Failed to rehydrate arktype schema from contract IR: ${error instanceof Error ? error.message : String(error)}`, { codecId: ARKTYPE_JSON_CODEC_ID, jsonIr }, ); } + /* c8 ignore start — defensive: ark.schema either throws (handled above) or returns a callable Type with `expression: string`. The structural guard is kept so a future ark internal change can't silently slip a non-callable past us. */ + if (!isArktypeSchemaLike(rehydrated)) { + throw runtimeError( + 'RUNTIME.JSON_SCHEMA_VALIDATION_FAILED', + `Rehydrated arktype schema does not have the expected callable + 'expression: string' shape (codecId: ${ARKTYPE_JSON_CODEC_ID})`, + { codecId: ARKTYPE_JSON_CODEC_ID, jsonIr }, + ); + } + /* c8 ignore stop */ + return rehydrated; } -/** - * Render the emit-path TS type for an arktype-json column. Reads the - * eagerly-extracted `expression` directly — the round-trip stability - * guarantee (rehydrated schema's `expression` matches the source's) - * means the rendered output is consistent across serialize/rehydrate. - */ function renderArktypeJsonOutputType(params: ArktypeJsonTypeParams): string { const expression = params.expression.trim(); return expression.length > 0 ? expression : 'unknown'; } -/** - * Build a permissive `renderOutputType` that accepts the framework's - * generic typeParams shape and dispatches to the type-narrow renderer - * once the input is structurally an `ArktypeJsonTypeParams`. - */ -function renderArktypeJsonOutputTypeFromUnknownParams( - typeParams: Record, -): string | undefined { - const expression = typeParams['expression']; - const jsonIr = typeParams['jsonIr']; - if (typeof expression !== 'string' || jsonIr === null || typeof jsonIr !== 'object') { - return undefined; - } - return renderArktypeJsonOutputType({ expression, jsonIr }); -} - -/** - * Emit-only `Codec` instance for `arktype/json@1`. Threaded through the - * pack-meta's `codecInstances` array so the emitter's `CodecLookup` can - * find a `renderOutputType` for the codec id (the emitter consults the - * codec-id-keyed `CodecLookup` at the framework boundary; the unified - * descriptor's `renderOutputType` is the long-term home for the renderer - * but the emit-path glue still routes through `CodecLookup`). - * - * All conversion methods are sentinels that throw if invoked — runtime - * materialization always goes through `arktypeJsonCodec.factory`'s - * curried `(params) => (ctx) => Codec`, never through this instance. - * `encodeJson`/`decodeJson` throw alongside `encode`/`decode` so a - * mistaken contract-load that resolved to this stub fails fast at the - * JSON boundary instead of silently returning unvalidated payloads. A - * future cleanup could route the emit path through the descriptor map - * directly and retire this shim. - */ -const ARKTYPE_JSON_RUNTIME_DISPATCH_ERROR = - 'arktype-json codec instances must be materialized via the descriptor factory; this is an emit-only stub'; - -export const arktypeJsonEmitCodec: Codec< +export class ArktypeJsonCodecClass extends CodecImpl< typeof ARKTYPE_JSON_CODEC_ID, readonly ['equality'], string, - unknown -> = { - id: ARKTYPE_JSON_CODEC_ID, - targetTypes: [ARKTYPE_JSON_NATIVE_TYPE], - traits: ['equality'] as const, - encode: () => Promise.reject(new Error(ARKTYPE_JSON_RUNTIME_DISPATCH_ERROR)), - decode: () => Promise.reject(new Error(ARKTYPE_JSON_RUNTIME_DISPATCH_ERROR)), - encodeJson: () => { - throw new Error(ARKTYPE_JSON_RUNTIME_DISPATCH_ERROR); - }, - decodeJson: () => { - throw new Error(ARKTYPE_JSON_RUNTIME_DISPATCH_ERROR); - }, - renderOutputType: renderArktypeJsonOutputTypeFromUnknownParams, -}; + TInferred +> { + constructor( + descriptor: ArktypeJsonDescriptor, + private readonly schema: ArktypeSchemaLike, + ) { + super(descriptor); + } -/** - * Framework-registration descriptor for the arktype-json codec. Registered - * through the SQL runtime's `parameterizedCodecs:` slot. `sql-runtime`'s - * `initializeTypeHelpers` (and per-column walk in - * `buildContractCodecRegistry`) calls `arktypeJsonCodec.factory(typeParams) - * (ctx)` once per `storage.types` instance (or once per inline-typeParams - * column) to materialize the resolved codec carrying the rehydrated - * schema. - * - * Per Phase B of codec-registry-unification, `descriptorFor('arktype/json@1')` - * returns this descriptor and its `traits`/`targetTypes` are the codec-id- - * keyed source of truth — no parallel placeholder on the legacy `codecs:` - * slot is needed (the runtime descriptor ships `codecs: () => createCodecRegistry()` - * — empty). - */ -export const arktypeJsonCodec: CodecDescriptor = { - codecId: ARKTYPE_JSON_CODEC_ID, - traits: ['equality'] as const, - targetTypes: [ARKTYPE_JSON_NATIVE_TYPE] as const, - paramsSchema: arktypeJsonParamsSchema, - renderOutputType: renderArktypeJsonOutputType, - factory: (params) => { + async encode(value: TInferred, _ctx: CodecCallContext): Promise { + return serializeToJsonSafe(this.schema, value).wire; + } + + async decode(wire: string, _ctx: CodecCallContext): Promise { + return validateSchema(this.schema, JSON.parse(wire)); + } + + encodeJson(value: TInferred): JsonValue { + return serializeToJsonSafe(this.schema, value).json; + } + + decodeJson(json: JsonValue): TInferred { + return validateSchema(this.schema, json); + } +} + +const arktypeJsonParamsSchema = type({ + expression: 'string', + jsonIr: 'object', +}) satisfies StandardSchemaV1; + +export class ArktypeJsonDescriptor extends CodecDescriptorImpl { + override readonly codecId = ARKTYPE_JSON_CODEC_ID; + override readonly traits = ['equality'] as const; + override readonly targetTypes = [ARKTYPE_JSON_NATIVE_TYPE] as const; + override readonly paramsSchema: StandardSchemaV1 = arktypeJsonParamsSchema; + override renderOutputType(params: ArktypeJsonTypeParams): string { + return renderArktypeJsonOutputType(params); + } + override factory( + params: ArktypeJsonTypeParams, + ): (ctx: CodecInstanceContext) => ArktypeJsonCodecClass { const schema = rehydrateSchema(params.jsonIr); /* c8 ignore start — defensive parity check; not exercised by typical contracts */ - // The rehydrated schema's `expression` should match the serialized - // one; diverging means contract.json was hand-edited out from under - // the emit-path renderer. Surface as a soft warning at materialization - // time so the caller knows their emit output may not match the - // runtime schema. The runtime keeps using the schema rehydrated from - // `jsonIr` — that's the lossless source — so the worst case is an - // emit-vs-runtime divergence at a single column, not a runtime - // failure. const rehydratedExpression = (schema as { readonly expression?: unknown }).expression; if (typeof rehydratedExpression === 'string' && rehydratedExpression !== params.expression) { console.warn( @@ -397,6 +169,49 @@ export const arktypeJsonCodec: CodecDescriptor = { ); } /* c8 ignore stop */ - return arktypeJsonCodecForSchema(schema); - }, -}; + return () => new ArktypeJsonCodecClass(this, schema); + } +} + +export const arktypeJsonDescriptor = new ArktypeJsonDescriptor(); + +/** + * Per-codec column helper for `arktype/json@1`. Method-level generic over `S extends Type` so the column site preserves the schema's inferred TS type in the resolved codec (`ArktypeJsonCodecClass`). Bypasses `descriptor.factory` because `S` is only available at the column-author site; constructs the typed codec directly with the closure-captured schema. + * + * Eager serialization at this call site captures `expression` (for the emit-path renderer) and `jsonIr` (for runtime rehydration via the descriptor's factory). + * + * @throws {Error} if the schema doesn't expose `expression` and `json` fields (i.e. is not an arktype `Type`). Validates the schema shape at the call site so configuration errors surface during contract authoring, not at runtime. + */ +export function arktypeJsonColumn>( + schema: S, +): ColumnSpec, ArktypeJsonTypeParams> { + if (!isArktypeSchemaLike(schema)) { + throw new Error( + typeof schema !== 'function' + ? 'arktypeJsonColumn(schema) expects a callable arktype Type.' + : 'arktypeJsonColumn(schema) expects an arktype Type (missing `expression: string`).', + ); + } + const jsonIr: unknown = (schema as { readonly json?: unknown }).json; + if (jsonIr === null || typeof jsonIr !== 'object') { + throw new Error('arktypeJsonColumn(schema) expects an arktype Type (missing `json` IR).'); + } + const params: ArktypeJsonTypeParams = { expression: schema.expression, jsonIr }; + return column( + (_ctx: CodecInstanceContext) => + new ArktypeJsonCodecClass(arktypeJsonDescriptor, schema), + arktypeJsonDescriptor.codecId, + params, + ARKTYPE_JSON_NATIVE_TYPE, + ); +} + +arktypeJsonColumn satisfies ColumnHelperFor; +// Note: `ColumnHelperForStrict` is intentionally not applied — `Codec` is invariant in `TInput` (encode contravariant, decode covariant), so `ArktypeJsonCodecClass` is not assignable to `ArktypeJsonCodecClass` (the descriptor.factory return). `expectTypeOf` tests cover the literal-preservation property strict satisfies would otherwise enforce. + +/** + * Codec instance returned by `arktypeJsonColumn(schema).codecFactory(ctx)` and by `arktypeJsonDescriptor.factory(typeParams)(ctx)`. The `TInferred` slot carries the arktype schema's inferred output type at the column-author site; descriptor-side factories erase to `unknown`. + */ +export type ArktypeJsonCodec = ArktypeJsonCodecClass; + +export const codecDescriptors: readonly AnyCodecDescriptor[] = [arktypeJsonDescriptor]; diff --git a/packages/3-extensions/arktype-json/src/core/pack-meta.ts b/packages/3-extensions/arktype-json/src/core/pack-meta.ts index 942853f4f3..721fbd0dce 100644 --- a/packages/3-extensions/arktype-json/src/core/pack-meta.ts +++ b/packages/3-extensions/arktype-json/src/core/pack-meta.ts @@ -1,24 +1,14 @@ /** * arktype-json pack metadata. * - * The pack metadata is the framework-composition entry point: control- - * stack assembly reads `types.codecTypes.import` to thread the type-side - * imports into emitted `contract.d.ts`, and `types.storage` declares the - * codec id's storage backing (`jsonb` on Postgres). + * The pack metadata is the framework-composition entry point: control-stack assembly reads `types.codecTypes.import` to thread the type-side imports into emitted `contract.d.ts`, and `types.storage` declares the codec id's storage backing (`jsonb` on Postgres). * - * Per Phase B of codec-registry-unification, runtime materialization - * flows through the unified descriptor map (`arktypeJsonCodec` - * parameterized descriptor), not through the legacy runtime codec - * lookup. This metadata still carries an emit-only `Codec` instance - * (`arktypeJsonEmitCodec`) under `codecInstances` so the framework - * emitter's codec-id-keyed `CodecLookup` can resolve `renderOutputType` - * at emit time — that shim retires when the emit path consults the - * descriptor map directly (TML-2357). Control-stack consumers read - * codec metadata from `descriptorFor('arktype/json@1')`. + * Per TML-2357 runtime materialization flows through the unified descriptor map (`arktypeJsonDescriptor`) and the emit path consults `descriptorFor('arktype/json@1').renderOutputType` directly — no per-library "emit-only Codec" stub. */ import type { CodecTypes } from '../types/codec-types'; -import { ARKTYPE_JSON_CODEC_ID, arktypeJsonEmitCodec } from './arktype-json-codec'; +import { ARKTYPE_JSON_CODEC_ID } from './arktype-json-codec'; +import { arktypeJsonCodecRegistry } from './registry'; const arktypeJsonPackMetaBase = { kind: 'extension', @@ -29,13 +19,7 @@ const arktypeJsonPackMetaBase = { capabilities: {}, types: { codecTypes: { - // The emitter's `CodecLookup` is the codec-id-keyed source of - // truth for `renderOutputType` at the framework emit-path - // boundary. We thread an emit-only `Codec` instance carrying the - // `renderOutputType` here so the lookup resolves; runtime - // materialization goes through the unified descriptor's - // `factory: (P) => (CodecInstanceContext) => Codec`, never through this shim. - codecInstances: [arktypeJsonEmitCodec], + codecDescriptors: Array.from(arktypeJsonCodecRegistry.values()), import: { package: '@prisma-next/extension-arktype-json/codec-types', named: 'CodecTypes', @@ -54,9 +38,7 @@ const arktypeJsonPackMetaBase = { } as const; /** - * Public pack metadata. The phantom `__codecTypes` field threads the - * codec-types map's literal type into the pack ref so contract-builder - * generics can pick it up; it is never accessed at runtime. + * Public pack metadata. The phantom `__codecTypes` field threads the codec-types map's literal type into the pack ref so contract-builder generics can pick it up; it is never accessed at runtime. */ export const arktypeJsonPackMeta: typeof arktypeJsonPackMetaBase & { readonly __codecTypes?: CodecTypes; diff --git a/packages/3-extensions/arktype-json/src/core/registry.ts b/packages/3-extensions/arktype-json/src/core/registry.ts new file mode 100644 index 0000000000..9ef8bb36b7 --- /dev/null +++ b/packages/3-extensions/arktype-json/src/core/registry.ts @@ -0,0 +1,11 @@ +import { buildCodecDescriptorRegistry } from '@prisma-next/sql-relational-core/codec-descriptor-registry'; +import type { CodecDescriptorRegistry } from '@prisma-next/sql-relational-core/query-lane-context'; +import { codecDescriptors } from './arktype-json-codec'; + +/** + * Registry of every codec descriptor shipped by `@prisma-next/extension-arktype-json`. + * + * Public consumer surface for the arktype-json codec set. Currently a single entry (`arktype/json@1`); the registry shape stays consistent with the other codec-shipping packages so consumers don't need to special-case extensions. See ADR 208. + */ +export const arktypeJsonCodecRegistry: CodecDescriptorRegistry = + buildCodecDescriptorRegistry(codecDescriptors); diff --git a/packages/3-extensions/arktype-json/src/exports/codecs.ts b/packages/3-extensions/arktype-json/src/exports/codecs.ts index 5ab7e8518b..9401a97d91 100644 --- a/packages/3-extensions/arktype-json/src/exports/codecs.ts +++ b/packages/3-extensions/arktype-json/src/exports/codecs.ts @@ -1,6 +1,11 @@ -export type { ArktypeJsonCodec, ArktypeJsonTypeParams } from '../core/arktype-json-codec'; +export type { + ArktypeJsonCodec, + ArktypeJsonDescriptor, + ArktypeJsonTypeParams, +} from '../core/arktype-json-codec'; export { ARKTYPE_JSON_CODEC_ID, ARKTYPE_JSON_NATIVE_TYPE, - arktypeJsonCodec, + arktypeJsonColumn, } from '../core/arktype-json-codec'; +export { arktypeJsonCodecRegistry } from '../core/registry'; diff --git a/packages/3-extensions/arktype-json/src/exports/column-types.ts b/packages/3-extensions/arktype-json/src/exports/column-types.ts index ca4c1b8217..e34429aa3d 100644 --- a/packages/3-extensions/arktype-json/src/exports/column-types.ts +++ b/packages/3-extensions/arktype-json/src/exports/column-types.ts @@ -1,2 +1,2 @@ export type { ArktypeJsonCodec, ArktypeJsonTypeParams } from '../core/arktype-json-codec'; -export { arktypeJson } from '../core/arktype-json-codec'; +export { arktypeJsonColumn as arktypeJson } from '../core/arktype-json-codec'; diff --git a/packages/3-extensions/arktype-json/src/exports/runtime.ts b/packages/3-extensions/arktype-json/src/exports/runtime.ts index a8d571b818..48c069719e 100644 --- a/packages/3-extensions/arktype-json/src/exports/runtime.ts +++ b/packages/3-extensions/arktype-json/src/exports/runtime.ts @@ -1,28 +1,14 @@ /** * Runtime-plane extension descriptor for arktype-json. * - * Registers `arktypeJsonCodec` (the parameterized codec descriptor) - * through the SQL runtime's `parameterizedCodecs:` slot. Per Phase B of - * codec-registry-unification, the legacy `codecs:` slot returns an empty - * registry — the unified descriptor map subsumes the codec-id-keyed - * metadata reads that the legacy slot used to back, and the runtime - * dispatch (`forColumn`) materializes the per-instance codec from the - * descriptor's factory. + * Registers `arktypeJsonCodec` (the unified `CodecDescriptor`) through the SQL runtime's `codecs:` slot. Per TML-2357 the dedicated parameterized-codec slot retired — the unified descriptor map dispatches every codec id, parameterized or not. * - * Lives at the runtime-plane entrypoint so `src/core/**` stays free of - * runtime-plane imports (per `.cursor/rules/multi-plane-entrypoints.mdc`). + * Lives at the runtime-plane entrypoint so `src/core/**` stays free of runtime-plane imports (per `.cursor/rules/multi-plane-entrypoints.mdc`). */ -import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; import type { SqlRuntimeExtensionDescriptor } from '@prisma-next/sql-runtime'; -import { arktypeJsonCodec } from '../core/arktype-json-codec'; import { arktypeJsonPackMeta } from '../core/pack-meta'; - -function createArktypeJsonCodecRegistry() { - // arktype-json ships only the parameterized descriptor; the legacy - // `codecs:` slot has nothing to register. - return createCodecRegistry(); -} +import { arktypeJsonCodecRegistry } from '../core/registry'; export const arktypeJsonRuntimeDescriptor: SqlRuntimeExtensionDescriptor<'postgres'> = { kind: 'extension' as const, @@ -30,8 +16,7 @@ export const arktypeJsonRuntimeDescriptor: SqlRuntimeExtensionDescriptor<'postgr version: arktypeJsonPackMeta.version, familyId: 'sql' as const, targetId: 'postgres' as const, - codecs: createArktypeJsonCodecRegistry, - parameterizedCodecs: () => [arktypeJsonCodec], + codecs: () => Array.from(arktypeJsonCodecRegistry.values()), create() { return { familyId: 'sql' as const, diff --git a/packages/3-extensions/arktype-json/test/arktype-json-codec.test-d.ts b/packages/3-extensions/arktype-json/test/arktype-json-codec.test-d.ts deleted file mode 100644 index 2663911f17..0000000000 --- a/packages/3-extensions/arktype-json/test/arktype-json-codec.test-d.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { - Codec, - CodecDescriptor, - CodecInstanceContext, -} from '@prisma-next/framework-components/codec'; -import { type } from 'arktype'; -import { type ArktypeJsonTypeParams, arktypeJson } from '../src/core/arktype-json-codec'; -import type { arktypeJsonCodec } from '../src/exports/codecs'; - -type IsEqual = - (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; -type Assert<_T extends true> = never; - -// AC-7: arktypeJson(schema).type is (ctx) => Codec<…, S['infer']> -const productSchema = type({ - name: 'string', - price: 'number', -}); - -const product = arktypeJson(productSchema); -type ProductType = typeof product.type; - -export type _Arktype_TakesCtx = Assert[0], CodecInstanceContext>>; - -type ProductResolved = ReturnType; -type ProductJs = - ProductResolved extends Codec<'arktype/json@1', readonly ['equality'], string, infer Js> - ? Js - : never; - -// The schema's inferred output flows into the codec's TInferred slot — -// narrowed fields, optional markers, literal types all preserved. -type ProductInfer = typeof productSchema.infer; -export type _ProductJs_MatchesInfer = Assert>; - -// Different schemas produce distinct TInferred — no conflation through -// the codec id. -const auditSchema = type({ - actor: "'system' | 'user' | 'admin'", - at: 'number', -}); -const audit = arktypeJson(auditSchema); -type AuditType = typeof audit.type; -type AuditResolved = ReturnType; -type AuditJs = - AuditResolved extends Codec<'arktype/json@1', readonly ['equality'], string, infer Js> - ? Js - : never; -type AuditInfer = typeof auditSchema.infer; -export type _AuditJs_MatchesInfer = Assert>; -export type _AuditJs_NotConflatedWithProduct = Assert, false>>; - -// arktypeJsonCodec descriptor's typeParams shape is -// `ArktypeJsonTypeParams`. -type DescriptorP = typeof arktypeJsonCodec extends CodecDescriptor ? P : never; -export type _DescriptorParams = Assert>; diff --git a/packages/3-extensions/arktype-json/test/arktype-json-codec.test.ts b/packages/3-extensions/arktype-json/test/arktype-json-codec.test.ts index 13617fcd88..6788bf6a54 100644 --- a/packages/3-extensions/arktype-json/test/arktype-json-codec.test.ts +++ b/packages/3-extensions/arktype-json/test/arktype-json-codec.test.ts @@ -1,23 +1,25 @@ +/** + * Runtime tests for the arktype-json codec (TML-2357). Canonical test suite for arktype-json codec behavior after the legacy `arktypeJson(schema)` form retired. + * + * Coverage: + * + * - the column-author helper produces a working codec whose `id` proxies through the descriptor's `codecId`. + * - the descriptor's factory rehydrates the schema and returns a working codec for runtime materialization paths. + * - encode/decode round-trip including encodeJson/decodeJson agreement on the JSON-safe normalized payload. + * - schema validation rejects malformed payloads at decode and non-JSON-safe runtime values at encode. + */ + import type { CodecInstanceContext } from '@prisma-next/framework-components/codec'; import type { SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; -import { type Type, type } from 'arktype'; +import { type } from 'arktype'; import { describe, expect, it } from 'vitest'; import { ARKTYPE_JSON_CODEC_ID, - ARKTYPE_JSON_NATIVE_TYPE, - arktypeJson, - arktypeJsonCodec, - arktypeJsonEmitCodec, + arktypeJsonColumn, + arktypeJsonDescriptor, } from '../src/core/arktype-json-codec'; -import { arktypeJsonPackMeta } from '../src/core/pack-meta'; - -const SYNTH_CTX: CodecInstanceContext = { - name: '', -}; -// Per-call context the runtime threads to every `codec.encode` / -// `codec.decode`. The test doesn't exercise abort or column metadata — -// an empty `{}` is the canonical no-signal SQL call ctx. +const SYNTH_CTX: CodecInstanceContext = { name: '' }; const CALL_CTX: SqlCodecCallContext = {}; const productSchema = type({ @@ -26,139 +28,75 @@ const productSchema = type({ 'description?': 'string', }); -describe('arktypeJson(schema)', () => { - it('returns a column descriptor with codecId and nativeType', () => { - const descriptor = arktypeJson(productSchema); - expect(descriptor.codecId).toBe(ARKTYPE_JSON_CODEC_ID); - expect(descriptor.nativeType).toBe(ARKTYPE_JSON_NATIVE_TYPE); +describe('arktypeJsonColumn(schema)', () => { + it('returns a ColumnSpec with codecId, nativeType, typeParams', () => { + const col = arktypeJsonColumn(productSchema); + expect(col.codecId).toBe(ARKTYPE_JSON_CODEC_ID); + expect(col.nativeType).toBe('jsonb'); + expect(col.typeParams.expression).toBe(productSchema.expression); + expect(col.typeParams.jsonIr).toEqual(productSchema.json); }); - it('eagerly extracts expression and jsonIr into typeParams', () => { - const descriptor = arktypeJson(productSchema); - expect(typeof descriptor.typeParams.expression).toBe('string'); - expect(descriptor.typeParams.expression).toBe(productSchema.expression); - expect(typeof descriptor.typeParams.jsonIr).toBe('object'); - // The IR is the rehydration source — structurally identical to - // `schema.json` so consumers reading the IR get arktype's lossless - // wire format. - expect(descriptor.typeParams.jsonIr).toEqual(productSchema.json); - }); - - it('exposes a curried (ctx) => Codec factory in the type slot', () => { - const descriptor = arktypeJson(productSchema); - const codec = descriptor.type(SYNTH_CTX); + it('codecFactory(ctx) materializes a working codec', async () => { + const col = arktypeJsonColumn(productSchema); + const codec = col.codecFactory(SYNTH_CTX); expect(codec.id).toBe(ARKTYPE_JSON_CODEC_ID); - expect(codec.targetTypes).toEqual(['jsonb']); - expect(codec.traits).toEqual(['equality']); - }); - it('rejects non-callable schema lookalikes at the call site', () => { - // The runtime check enforces the column-author surface accepts - // callable arktype `Type`s only — a plain object lookalike (with - // `expression` and `json` fields shaped right but not callable) - // would have passed the field checks and only blown up at the - // first `decode`/`decodeJson`. Reject early instead. - const notASchema = { foo: 'bar' } as unknown as Type; - expect(() => arktypeJson(notASchema)).toThrow(/callable arktype Type/); - }); - - it('rejects callable schemas missing the `expression` field', () => { - // A callable that doesn't carry arktype's `expression` getter is - // not an arktype `Type` — the column descriptor relies on - // `expression` for emit-path rendering. - const callableWithoutExpression = (() => undefined) as unknown as Type; - expect(() => arktypeJson(callableWithoutExpression)).toThrow(/missing `expression: string`/); + const value = { name: 'Widget', price: 9.99 }; + const wire = await codec.encode(value, CALL_CTX); + expect(typeof wire).toBe('string'); + const decoded = await codec.decode(wire, CALL_CTX); + expect(decoded).toEqual(value); }); - it('rejects callable schemas with non-object json IR', () => { - // A callable that carries `expression` but lacks the `json` IR - // can't be rehydrated at runtime; reject at authoring time. - const malformedSchema = Object.assign(() => undefined, { - expression: 'string', - json: 'not-an-object', - }) as unknown as Type; - expect(() => arktypeJson(malformedSchema)).toThrow(/missing `json` IR/); + it('decode rejects payloads that fail schema validation', async () => { + const col = arktypeJsonColumn(productSchema); + const codec = col.codecFactory(SYNTH_CTX); + const wire = JSON.stringify({ name: 'Widget' }); + await expect(codec.decode(wire, CALL_CTX)).rejects.toThrow(/schema validation failed/); }); -}); -describe('arktypeJson encode/decode (Promise-lifted async surface)', () => { - it('encode round-trips through JSON.stringify (Promise-returning)', async () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - const encoded = await codec.encode({ name: 'Widget', price: 10 }, CALL_CTX); - expect(encoded).toBe('{"name":"Widget","price":10}'); + it('encodeJson / decodeJson round-trip through schema', () => { + const col = arktypeJsonColumn(productSchema); + const codec = col.codecFactory(SYNTH_CTX); + const value = { name: 'Widget', price: 9.99, description: 'A widget' }; + const json = codec.encodeJson(value); + const decoded = codec.decodeJson(json); + expect(decoded).toEqual(value); }); - it('decode validates the wire payload against the schema', async () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - const wire = JSON.stringify({ name: 'Widget', price: 10 }); - expect(await codec.decode(wire, CALL_CTX)).toEqual({ name: 'Widget', price: 10 }); + it('rejects non-callable schema lookalikes at the call site', () => { + const notASchema = { foo: 'bar' }; + // @ts-expect-error -- deliberately malformed input for the call-site guard + expect(() => arktypeJsonColumn(notASchema)).toThrow(/callable arktype Type/); }); - it('decode rejects payloads missing required fields', async () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - const wire = JSON.stringify({ name: 'Widget' }); - await expect(codec.decode(wire, CALL_CTX)).rejects.toThrow( - /JSON_SCHEMA_VALIDATION_FAILED|price/, + it('rejects callable values that are missing `expression: string`', () => { + const callableWithoutExpression = (v: unknown) => v; + expect(() => arktypeJsonColumn(callableWithoutExpression as never)).toThrow( + /missing `expression: string`/, ); }); - it('decode rejects payloads with type-mismatched fields', async () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - const wire = JSON.stringify({ name: 'Widget', price: 'not-a-number' }); - await expect(codec.decode(wire, CALL_CTX)).rejects.toThrow(/price/); - }); - - it('decode accepts payloads with optional fields present', async () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - const wire = JSON.stringify({ name: 'Widget', price: 10, description: 'A widget' }); - expect(await codec.decode(wire, CALL_CTX)).toEqual({ - name: 'Widget', - price: 10, - description: 'A widget', + it('rejects callable schemas that are missing the `json` IR', () => { + const fakeSchema = Object.assign((v: unknown) => v, { + expression: 'unknown', + json: 'not-an-object', }); + expect(() => arktypeJsonColumn(fakeSchema as never)).toThrow(/missing `json` IR/); }); }); -describe('arktypeJson roundtrip', () => { - it('encode → decode preserves the value structurally', async () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - const original = { name: 'Widget', price: 10, description: 'A widget' }; - const encoded = await codec.encode(original, CALL_CTX); - const decoded = await codec.decode(encoded as string, CALL_CTX); - expect(decoded).toEqual(original); - }); - - it('encodeJson / decodeJson round-trip JsonValue payloads', () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - const original = { name: 'Widget', price: 10 }; - const json = codec.encodeJson(original); - const restored = codec.decodeJson(json); - expect(restored).toEqual(original); - }); - - // The codec derives both `encode` (wire string) and `encodeJson` - // (JsonValue) outputs from the same `JSON.stringify` → `JSON.parse` - // round-trip, then runs the schema on the normalized payload. Without - // this unification, `encodeJson` would emit non-JSON-safe values - // unchanged while `encode` silently dropped or transformed them, - // producing wire payloads the codec'\''s own decode path would later - // reject. The next two tests pin both halves of that contract. +describe('arktypeJsonColumn encode/encodeJson agreement', () => { it('encode and encodeJson agree on the normalized payload', async () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); + const codec = arktypeJsonColumn(productSchema).codecFactory(SYNTH_CTX); const original = { name: 'Widget', price: 10, description: 'desc' }; const wire = await codec.encode(original, CALL_CTX); const json = codec.encodeJson(original); expect(wire).toBe(JSON.stringify(json)); }); - it('encode rejects non-JSON-safe runtime values via the shared validator', async () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - // Class instance with extra prototype-only methods. `JSON.stringify` - // strips those so the wire payload normalizes to `{ name, price }`, - // but the schema must still accept the normalized shape — this case - // does. The check ensures the unification path runs without - // throwing for legitimate JSON-safe payloads even when the runtime - // type isn'\''t a plain object. + it('encode strips class prototypes via the JSON.stringify round-trip', async () => { class Widget { constructor( public name: string, @@ -168,22 +106,15 @@ describe('arktypeJson roundtrip', () => { return `${this.name}@${this.price}`; } } + const codec = arktypeJsonColumn(productSchema).codecFactory(SYNTH_CTX); const widget = new Widget('Widget', 10); const wire = await codec.encode(widget, CALL_CTX); expect(wire).toBe('{"name":"Widget","price":10}'); }); it('encode rejects values that are not representable as JSON', async () => { - // A schema whose inferred type accepts a function field would never - // be authored intentionally — but the type system can'\''t catch that - // for `unknown`-typed schemas at runtime. Cast through the typed - // surface to model the case where a runtime value is structurally - // unserializable; both encode paths must reject. const anySchema = type('object'); - const codec = arktypeJson(anySchema).type(SYNTH_CTX); - // `JSON.stringify(undefined)` returns `undefined`, not a string; the - // serializeToJsonSafe guard rejects this with a clear schema-failure - // code rather than a downstream `JSON.parse(undefined)` SyntaxError. + const codec = arktypeJsonColumn(anySchema).codecFactory(SYNTH_CTX); await expect(codec.encode(undefined as never, CALL_CTX)).rejects.toThrow( /not representable as JSON|JSON_SCHEMA_VALIDATION_FAILED/, ); @@ -191,221 +122,51 @@ describe('arktypeJson roundtrip', () => { /not representable as JSON|JSON_SCHEMA_VALIDATION_FAILED/, ); }); -}); - -describe('arktypeJsonCodec descriptor', () => { - it('has the right codecId, traits, and targetTypes', () => { - expect(arktypeJsonCodec.codecId).toBe(ARKTYPE_JSON_CODEC_ID); - expect(arktypeJsonCodec.traits).toEqual(['equality']); - expect(arktypeJsonCodec.targetTypes).toEqual(['jsonb']); - }); - - it("renderOutputType returns the schema's TS-source expression", () => { - expect( - arktypeJsonCodec.renderOutputType?.({ - expression: '{ name: string, price: number }', - jsonIr: {}, - }), - ).toBe('{ name: string, price: number }'); - }); - - it("renderOutputType falls back to 'unknown' for empty expressions", () => { - expect( - arktypeJsonCodec.renderOutputType?.({ - expression: ' ', - jsonIr: {}, - }), - ).toBe('unknown'); - }); - - it('paramsSchema validates well-formed typeParams', () => { - const validation = arktypeJsonCodec.paramsSchema['~standard'].validate({ - expression: '{ name: string }', - jsonIr: { domain: 'object' }, - }); - expect(validation).not.toBeInstanceOf(Promise); - if (!(validation instanceof Promise)) { - expect(validation.issues).toBeUndefined(); - } - }); - - it('paramsSchema rejects malformed typeParams', () => { - const validation = arktypeJsonCodec.paramsSchema['~standard'].validate({ - expression: 42, - jsonIr: { domain: 'object' }, - }); - expect(validation).not.toBeInstanceOf(Promise); - if (!(validation instanceof Promise)) { - expect(validation.issues).toBeDefined(); - } - }); - - it('factory rehydrates the schema from typeParams.jsonIr and validates', async () => { - const descriptor = arktypeJson(productSchema); - const codec = arktypeJsonCodec.factory(descriptor.typeParams)(SYNTH_CTX); - expect(codec.id).toBe(ARKTYPE_JSON_CODEC_ID); - - const validWire = JSON.stringify({ name: 'Widget', price: 10 }); - expect(await codec.decode(validWire, CALL_CTX)).toEqual({ name: 'Widget', price: 10 }); - - const invalidWire = JSON.stringify({ name: 'Widget' }); - await expect(codec.decode(invalidWire, CALL_CTX)).rejects.toThrow(); - }); - it('factory throws on corrupt jsonIr', () => { - expect(() => - arktypeJsonCodec.factory({ - expression: 'corrupt', - jsonIr: { broken: true }, - })(SYNTH_CTX), - ).toThrow(/Failed to rehydrate arktype schema/); + it('decode rejects payloads with type-mismatched fields', async () => { + const codec = arktypeJsonColumn(productSchema).codecFactory(SYNTH_CTX); + const wire = JSON.stringify({ name: 'Widget', price: 'not-a-number' }); + await expect(codec.decode(wire, CALL_CTX)).rejects.toThrow(/price/); }); }); -describe('serialize/rehydrate roundtrip', () => { - it("rehydrated schema's behavior matches the source", async () => { - // The rehydration round-trip is the load-bearing guarantee for the - // emit-vs-runtime parity check: the rehydrated schema validates the - // same payloads as the source (semantic identity, even if the - // expression diverges across arktype versions). The descriptor's - // factory carries a defensive console.warn for expression - // divergence; we only assert on the validation side here. - const descriptor = arktypeJson(productSchema); - const reCodec = arktypeJsonCodec.factory(descriptor.typeParams)(SYNTH_CTX); - const sourceCodec = descriptor.type(SYNTH_CTX); - - const valid = { name: 'X', price: 1 }; - const validWire = JSON.stringify(valid); - expect(await reCodec.decode(validWire, CALL_CTX)).toEqual( - await sourceCodec.decode(validWire, CALL_CTX), - ); +describe('arktypeJsonDescriptor.factory(params)', () => { + it('rehydrates the schema from typeParams.jsonIr and produces a working codec', async () => { + const col = arktypeJsonColumn(productSchema); + const factory = arktypeJsonDescriptor.factory(col.typeParams); + const codec = factory(SYNTH_CTX); + expect(codec.id).toBe(ARKTYPE_JSON_CODEC_ID); - const invalid = { name: 'X' }; - const invalidWire = JSON.stringify(invalid); - await expect(reCodec.decode(invalidWire, CALL_CTX)).rejects.toThrow(); - await expect(sourceCodec.decode(invalidWire, CALL_CTX)).rejects.toThrow(); + const value = { name: 'Widget', price: 9.99 }; + const wire = await codec.encode(value, CALL_CTX); + const decoded = await codec.decode(wire, CALL_CTX); + expect(decoded).toEqual(value); }); - it('preserves narrowed fields (literal unions) across rehydrate', async () => { - const auditSchema = type({ - actor: "'system' | 'user' | 'admin'", - at: 'number', - }); - const descriptor = arktypeJson(auditSchema); - const reCodec = arktypeJsonCodec.factory(descriptor.typeParams)(SYNTH_CTX); - - expect(await reCodec.decode(JSON.stringify({ actor: 'system', at: 1 }), CALL_CTX)).toEqual({ - actor: 'system', - at: 1, - }); - await expect( - reCodec.decode(JSON.stringify({ actor: 'stranger', at: 1 }), CALL_CTX), - ).rejects.toThrow(/actor/); + it('descriptor metadata: traits, targetTypes', () => { + expect(arktypeJsonDescriptor.codecId).toBe(ARKTYPE_JSON_CODEC_ID); + expect(arktypeJsonDescriptor.traits).toEqual(['equality']); + expect(arktypeJsonDescriptor.targetTypes).toEqual(['jsonb']); }); -}); -describe('decodeJson schema enforcement', () => { - // `decode` (wire string → JS) and `decodeJson` (JsonValue → JS) must - // both run the schema. Without enforcement on `decodeJson`, any - // adapter/runtime path that hands parsed JSON straight to the codec - // would bypass schema validation and return unchecked data. - it('decodeJson runs the schema against typed JsonValue payloads', () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - expect(codec.decodeJson({ name: 'Widget', price: 10 })).toEqual({ - name: 'Widget', - price: 10, - }); + it('renderOutputType returns the eager-extracted expression', () => { + const col = arktypeJsonColumn(productSchema); + const rendered = arktypeJsonDescriptor.renderOutputType(col.typeParams); + expect(rendered).toBe(productSchema.expression); }); - it('decodeJson throws when payload misses required fields', () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - expect(() => codec.decodeJson({ name: 'Widget' })).toThrow( - /JSON_SCHEMA_VALIDATION_FAILED|price/, + it("renderOutputType falls back to 'unknown' when the expression is whitespace-only", () => { + expect(arktypeJsonDescriptor.renderOutputType({ expression: ' ', jsonIr: {} })).toBe( + 'unknown', ); }); - it('decodeJson throws when payload has type-mismatched fields', () => { - const codec = arktypeJson(productSchema).type(SYNTH_CTX); - expect(() => codec.decodeJson({ name: 'Widget', price: 'not-a-number' })).toThrow(/price/); - }); -}); - -describe('arktypeJsonEmitCodec (emit-only shim)', () => { - // The emit-only codec carries `renderOutputType` so the framework - // emitter's `CodecLookup` can resolve the column's TS type at emit - // time. encode/decode are sentinels that throw if invoked — runtime - // materialization always goes through the descriptor's factory. - it('exposes the codec id and native type', () => { - expect(arktypeJsonEmitCodec.id).toBe(ARKTYPE_JSON_CODEC_ID); - expect(arktypeJsonEmitCodec.targetTypes).toEqual([ARKTYPE_JSON_NATIVE_TYPE]); - expect(arktypeJsonEmitCodec.traits).toEqual(['equality']); - }); - - it('renderOutputType returns expression for well-formed typeParams', () => { - expect( - arktypeJsonEmitCodec.renderOutputType?.({ - expression: '{ name: string }', - jsonIr: { domain: 'object' }, + it('throws on corrupt jsonIr at factory time', () => { + expect(() => + arktypeJsonDescriptor.factory({ + expression: 'string', + jsonIr: { not: 'a-valid-arktype-ir' }, }), - ).toBe('{ name: string }'); - }); - - it('renderOutputType returns undefined for malformed typeParams', () => { - expect(arktypeJsonEmitCodec.renderOutputType?.({ expression: 42, jsonIr: {} })).toBeUndefined(); - expect( - arktypeJsonEmitCodec.renderOutputType?.({ expression: 'x', jsonIr: null }), - ).toBeUndefined(); - expect( - arktypeJsonEmitCodec.renderOutputType?.({ expression: 'x', jsonIr: 'str' }), - ).toBeUndefined(); - }); - - it('encode/decode reject because runtime materialization goes through the descriptor', async () => { - await expect(arktypeJsonEmitCodec.encode('value', CALL_CTX)).rejects.toThrow(/emit-only/); - await expect(arktypeJsonEmitCodec.decode('wire', CALL_CTX)).rejects.toThrow(/emit-only/); - }); - - it('encodeJson/decodeJson throw because runtime materialization goes through the descriptor', () => { - // Mirrors `encode`/`decode`: a contract-load path that resolved to - // this emit-only stub must fail fast at the JSON boundary instead - // of silently returning unvalidated payloads. - expect(() => arktypeJsonEmitCodec.encodeJson('payload')).toThrow(/emit-only/); - expect(() => arktypeJsonEmitCodec.decodeJson({ a: 1 })).toThrow(/emit-only/); - }); -}); - -describe('arktypeJsonPackMeta', () => { - // Pack metadata threads the emit-only `Codec` instance into the - // codec-id-keyed `CodecLookup` and declares the storage backing - // (`jsonb` on Postgres). Asserting the structure protects the - // framework-composition entry point. - it('declares kind, id, family, and target', () => { - expect(arktypeJsonPackMeta.kind).toBe('extension'); - expect(arktypeJsonPackMeta.id).toBe('arktype-json'); - expect(arktypeJsonPackMeta.familyId).toBe('sql'); - expect(arktypeJsonPackMeta.targetId).toBe('postgres'); - }); - - it('threads arktypeJsonEmitCodec into codecInstances for emit-path lookup', () => { - expect(arktypeJsonPackMeta.types.codecTypes.codecInstances).toContain(arktypeJsonEmitCodec); - }); - - it('declares jsonb storage backing for the codec id', () => { - expect(arktypeJsonPackMeta.types.storage).toEqual([ - { - typeId: ARKTYPE_JSON_CODEC_ID, - familyId: 'sql', - targetId: 'postgres', - nativeType: 'jsonb', - }, - ]); - }); - - it('declares the type-side import spec', () => { - expect(arktypeJsonPackMeta.types.codecTypes.import).toEqual({ - package: '@prisma-next/extension-arktype-json/codec-types', - named: 'CodecTypes', - alias: 'ArktypeJsonTypes', - }); + ).toThrow(/Failed to rehydrate arktype schema from contract IR/); }); }); diff --git a/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts b/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts new file mode 100644 index 0000000000..7419679a27 --- /dev/null +++ b/packages/3-extensions/arktype-json/test/arktype-json-codec.types.test-d.ts @@ -0,0 +1,112 @@ +/** + * Type tests for the arktype-json codec (TML-2357). + * + * Spec § Case 3: method-level generic over `S extends Type`. Coverage focuses on the literal-preservation property — `S['infer']` flows from the column-author site through `arktypeJsonColumn` into the resolved codec's `TInput` slot. Exercises that: + * + * - the helper's return-type `codecFactory` slot carries `ArktypeJsonCodecClass`, with the schema's TS-level inferred shape preserved. + * - the column spec's `nativeType` is the bare `'jsonb'` literal and `codecId` is `'arktype/json@1'`. + * - `ColumnInputType` extraction recovers the schema's inferred shape. + * - the descriptor's factory returns the erased `ArktypeJsonCodecClass` form (since `S` is unavailable at descriptor-factory time; only the IR is). + * - `satisfies ColumnHelperFor` (coarse) succeeds; `ColumnHelperForStrict` is intentionally not applied because `Codec` is invariant in `TInput` (see codec-class.ts comment). + * + * Negative tests cover the `ColumnHelperFor` typeParams-shape check. + */ + +import { + type Codec, + type CodecInstanceContext, + type CodecTrait, + type ColumnHelperFor, + type ColumnSpec, + column, +} from '@prisma-next/framework-components/codec'; +import { type } from 'arktype'; +import { expectTypeOf, test } from 'vitest'; +import { + type ArktypeJsonCodecClass, + type ArktypeJsonDescriptor, + type ArktypeJsonTypeParams, + arktypeJsonColumn, + arktypeJsonDescriptor, +} from '../src/core/arktype-json-codec'; + +test('arktypeJsonColumn: schema infer preserved through codecFactory return', () => { + const ProductSchema = type({ name: 'string', price: 'number' }); + const col = arktypeJsonColumn(ProductSchema); + expectTypeOf(col.codecFactory).toEqualTypeOf< + (ctx: CodecInstanceContext) => ArktypeJsonCodecClass<{ name: string; price: number }> + >(); +}); + +test('arktypeJsonColumn: typeParams shape is ArktypeJsonTypeParams', () => { + const ProductSchema = type({ name: 'string', price: 'number' }); + const col = arktypeJsonColumn(ProductSchema); + expectTypeOf(col.typeParams).toEqualTypeOf(); +}); + +test('arktypeJsonColumn: bare nativeType "jsonb" + codecId literal', () => { + const ProductSchema = type({ name: 'string', price: 'number' }); + const col = arktypeJsonColumn(ProductSchema); + expectTypeOf(col.nativeType).toEqualTypeOf(); + expectTypeOf(col.codecId).toEqualTypeOf(); + if (col.nativeType !== 'jsonb' || col.codecId !== 'arktype/json@1') { + throw new Error(`nativeType / codecId mismatch: ${col.nativeType} / ${col.codecId}`); + } +}); + +test('ColumnInputType extracts the schema-inferred TS type', () => { + type ResolvedCodec = C extends { codecFactory: (ctx: CodecInstanceContext) => infer R } + ? R + : never; + type ColumnInputType = + ResolvedCodec extends Codec ? T : never; + + const ProductSchema = type({ name: 'string', price: 'number' }); + expectTypeOf< + ColumnInputType>> + >().toEqualTypeOf<{ name: string; price: number }>(); +}); + +test('arktypeJsonDescriptor: factory(params) returns erased ArktypeJsonCodecClass', () => { + const factory = arktypeJsonDescriptor.factory({ expression: 'string', jsonIr: {} }); + expectTypeOf(factory).toEqualTypeOf< + (ctx: CodecInstanceContext) => ArktypeJsonCodecClass + >(); +}); + +arktypeJsonColumn satisfies ColumnHelperFor; + +test('coarse satisfies catches wrong typeParams shape on arktypeJsonColumn', () => { + const brokenHelper = (_schema: unknown) => + column( + (_ctx: CodecInstanceContext) => + new (class FakeCodec { + readonly id = 'arktype/json@1' as const; + encode(_v: unknown, _c: unknown): Promise { + return Promise.resolve(''); + } + decode(_w: string, _c: unknown): Promise { + return Promise.resolve(undefined); + } + encodeJson(_v: unknown): unknown { + return null; + } + decodeJson(_j: unknown): unknown { + return undefined; + } + })(), + arktypeJsonDescriptor.codecId, + { wrongKey: 'oops' }, + 'jsonb', + ); + // @ts-expect-error -- typeParams shape doesn't satisfy ArktypeJsonTypeParams (missing `expression`/`jsonIr`) + brokenHelper satisfies ColumnHelperFor; +}); + +test('arktypeJsonColumn: result is ColumnSpec with typed codecFactory', () => { + const ProductSchema = type({ name: 'string', price: 'number' }); + const col = arktypeJsonColumn(ProductSchema); + expectTypeOf(col).toExtend< + ColumnSpec, ArktypeJsonTypeParams> + >(); +}); diff --git a/packages/3-extensions/arktype-json/test/extension-descriptors.test.ts b/packages/3-extensions/arktype-json/test/extension-descriptors.test.ts index 07ea531e64..0c7db1f055 100644 --- a/packages/3-extensions/arktype-json/test/extension-descriptors.test.ts +++ b/packages/3-extensions/arktype-json/test/extension-descriptors.test.ts @@ -1,14 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { ARKTYPE_JSON_CODEC_ID, arktypeJsonCodec } from '../src/core/arktype-json-codec'; +import { ARKTYPE_JSON_CODEC_ID, arktypeJsonDescriptor } from '../src/core/arktype-json-codec'; import { arktypeJsonExtensionDescriptor } from '../src/exports/control'; import { arktypeJsonRuntimeDescriptor } from '../src/exports/runtime'; describe('arktypeJsonRuntimeDescriptor', () => { - // The runtime descriptor is the SQL runtime's entry point for - // arktype-json. It registers `arktypeJsonCodec` through the - // `parameterizedCodecs:` slot and ships an empty legacy `codecs:` - // registry — Phase B of codec-registry-unification: arktype-json's - // codec metadata flows through the unified descriptor map only. + // The runtime descriptor is the SQL runtime's entry point for arktype-json. The contributor protocol is unified: every codec — parameterized or not — flows through the single `codecs:` slot returning a `CodecDescriptor` list. arktype-json contributes exactly one descriptor: `arktypeJsonDescriptor`. it('declares family, target, and version aligned with pack-meta', () => { expect(arktypeJsonRuntimeDescriptor.familyId).toBe('sql'); expect(arktypeJsonRuntimeDescriptor.targetId).toBe('postgres'); @@ -16,14 +12,11 @@ describe('arktypeJsonRuntimeDescriptor', () => { expect(arktypeJsonRuntimeDescriptor.id).toBe('arktype-json'); }); - it('exposes the parameterized codec descriptor through parameterizedCodecs()', () => { - expect(arktypeJsonRuntimeDescriptor.parameterizedCodecs()).toEqual([arktypeJsonCodec]); - }); - - it('returns an empty legacy codec registry from codecs()', () => { - const registry = arktypeJsonRuntimeDescriptor.codecs(); - expect(registry.has(ARKTYPE_JSON_CODEC_ID)).toBe(false); - expect([...registry]).toEqual([]); + it('contributes the arktype-json descriptor through the unified codecs slot', () => { + // The contributor reads from the descriptor registry. The single entry is the canonical `arktypeJsonDescriptor`. + const descriptors = arktypeJsonRuntimeDescriptor.codecs(); + expect(descriptors).toEqual([arktypeJsonDescriptor]); + expect(descriptors[0]?.codecId).toBe(ARKTYPE_JSON_CODEC_ID); }); it('create() returns an instance tagged with the family/target', () => { @@ -34,10 +27,7 @@ describe('arktypeJsonRuntimeDescriptor', () => { }); describe('arktypeJsonExtensionDescriptor (control)', () => { - // The control descriptor wires the migration-plane hooks into the SQL - // family's control stack. arktype-json's `expandNativeType` is an - // identity (`jsonb` is dimension-free) and there's no - // `databaseDependencies` (`jsonb` is built into Postgres). + // The control descriptor wires the migration-plane hooks into the SQL family's control stack. arktype-json's `expandNativeType` is an identity (`jsonb` is dimension-free) and there's no `databaseDependencies` (`jsonb` is built into Postgres). it('declares family, target, and version aligned with pack-meta', () => { expect(arktypeJsonExtensionDescriptor.familyId).toBe('sql'); expect(arktypeJsonExtensionDescriptor.targetId).toBe('postgres'); diff --git a/packages/3-extensions/pgvector/package.json b/packages/3-extensions/pgvector/package.json index 52f37623b9..62c24c856e 100644 --- a/packages/3-extensions/pgvector/package.json +++ b/packages/3-extensions/pgvector/package.json @@ -23,6 +23,7 @@ "@prisma-next/sql-relational-core": "workspace:*", "@prisma-next/sql-runtime": "workspace:*", "@prisma-next/sql-schema-ir": "workspace:*", + "@standard-schema/spec": "^1.1.0", "arktype": "^2.0.0" }, "devDependencies": { diff --git a/packages/3-extensions/pgvector/src/core/codecs.ts b/packages/3-extensions/pgvector/src/core/codecs.ts index eb5c026b08..63129dd87c 100644 --- a/packages/3-extensions/pgvector/src/core/codecs.ts +++ b/packages/3-extensions/pgvector/src/core/codecs.ts @@ -1,77 +1,145 @@ /** - * Vector codec implementation for pgvector extension. + * pgvector extension codec. * - * Provides encoding/decoding for the `vector` PostgreSQL type. - * Wire format is a string like `[1,2,3]` (PostgreSQL vector text format). + * Mirrors the patterns in `postgres/codecs-class.ts` and `sqlite/codecs-class.ts` for the single `pg/vector@1` codec. Three artifacts: + * + * 1. `PgVectorCodec` extends {@link CodecImpl} with the runtime encode/decode/encodeJson/decodeJson conversions inline. Conversions are simple enough (PostgreSQL `[1,2,3]` text format) that no shared helper module is warranted; the class body is the source of truth. + * 2. `PgVectorDescriptor` extends {@link CodecDescriptorImpl} with the codec id, traits, target types, params schema (`{ length: number }`, validated against {@link VECTOR_MAX_DIM}), `meta` (postgres `nativeType: 'vector'`), and the emit-path `renderOutputType` producing `Vector<${length}>`. + * 3. `pgVectorColumn(length)` per-codec column helper invoking `descriptor.factory({ length })` directly + passing the bare `nativeType: 'vector'`. The family-layer {@link expandNativeType} hook renders the parameterized form (`vector(1536)`) at emit/verify time from `nativeType` + `typeParams`. + * + * `length` threads into the runtime codec via the constructor so encode/decode/encodeJson/decodeJson enforce the declared dimension at every ingress path. Without this, `vector(3)` and `vector(1536)` would produce codecs with identical behaviour and a dimension-mismatched value would round-trip undetected. */ -import { codec, defineCodecs } from '@prisma-next/sql-relational-core/ast'; - -const pgVectorCodec = codec({ - typeId: 'pg/vector@1', - targetTypes: ['vector'], - traits: ['equality'], - renderOutputType: (typeParams) => { - const length = typeParams['length']; - if (length === undefined) return undefined; - if (typeof length !== 'number' || !Number.isFinite(length) || !Number.isInteger(length)) { - throw new Error( - `renderOutputType: expected positive integer "length" in typeParams for Vector, got ${String(length)}`, - ); - } - return `Vector<${length}>`; - }, - encode: (value: number[]): string => { - // Validate that value is an array of numbers +import type { JsonValue } from '@prisma-next/contract/types'; +import { + type AnyCodecDescriptor, + type CodecCallContext, + CodecDescriptorImpl, + CodecImpl, + type CodecInstanceContext, + type ColumnHelperFor, + type ColumnHelperForStrict, + column, +} from '@prisma-next/framework-components/codec'; +import type { ExtractCodecTypes } from '@prisma-next/sql-relational-core/ast'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { type as arktype } from 'arktype'; +import { VECTOR_CODEC_ID, VECTOR_MAX_DIM } from './constants'; + +type VectorParams = { readonly length: number }; + +const vectorParamsSchema = arktype({ + length: 'number', +}).narrow((params, ctx) => { + const { length } = params; + if (!Number.isInteger(length)) { + return ctx.mustBe('an integer'); + } + if (length < 1 || length > VECTOR_MAX_DIM) { + return ctx.mustBe(`in the range [1, ${VECTOR_MAX_DIM}]`); + } + return true; +}) satisfies StandardSchemaV1; + +const PG_VECTOR_META = { db: { sql: { postgres: { nativeType: 'vector' } } } } as const; + +export class PgVectorCodec extends CodecImpl< + typeof VECTOR_CODEC_ID, + readonly ['equality'], + string, + number[] +> { + readonly length: number | undefined; + + constructor(descriptor: AnyCodecDescriptor, length: number | undefined) { + super(descriptor); + this.length = length; + } + + assertVector(value: unknown): asserts value is number[] { if (!Array.isArray(value)) { throw new Error('Vector value must be an array of numbers'); } if (!value.every((v) => typeof v === 'number')) { throw new Error('Vector value must contain only numbers'); } - // Format as PostgreSQL vector text format: [1,2,3] - // PostgreSQL's pg library requires the vector format string + if (this.length !== undefined && value.length !== this.length) { + throw new Error(`Vector length mismatch: expected ${this.length}, got ${value.length}`); + } + } + + async encode(value: number[], _ctx: CodecCallContext): Promise { + this.assertVector(value); return `[${value.join(',')}]`; - }, - decode: (wire: string): number[] => { - // Handle string format from PostgreSQL: [1,2,3] + } + + async decode(wire: string, _ctx: CodecCallContext): Promise { if (typeof wire !== 'string') { throw new Error('Vector wire value must be a string'); } - // Parse PostgreSQL vector format: [1,2,3] if (!wire.startsWith('[') || !wire.endsWith(']')) { throw new Error(`Invalid vector format: expected "[...]", got "${wire}"`); } const content = wire.slice(1, -1).trim(); - if (content === '') { - return []; - } - const values = content.split(',').map((v) => { - const num = Number.parseFloat(v.trim()); - if (Number.isNaN(num)) { - throw new Error(`Invalid vector value: "${v}" is not a number`); - } - return num; - }); - return values; - }, - meta: { - db: { - sql: { - postgres: { - nativeType: 'vector', - }, - }, - }, - }, -}); - -// Build codec definitions using the builder DSL -const codecs = defineCodecs().add('vector', pgVectorCodec); - -// Export derived structures directly from codecs builder -export const codecDefinitions = codecs.codecDefinitions; -export const dataTypes = codecs.dataTypes; - -// Export types derived from codecs builder -export type CodecTypes = typeof codecs.CodecTypes; + const parsed = + content === '' + ? [] + : content.split(',').map((v) => { + const num = Number.parseFloat(v.trim()); + if (Number.isNaN(num)) { + throw new Error(`Invalid vector value: "${v}" is not a number`); + } + return num; + }); + this.assertVector(parsed); + return parsed; + } + + encodeJson(value: number[]): JsonValue { + this.assertVector(value); + return value; + } + + decodeJson(json: JsonValue): number[] { + this.assertVector(json); + return json; + } +} + +export class PgVectorDescriptor extends CodecDescriptorImpl { + override readonly codecId = VECTOR_CODEC_ID; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['vector'] as const; + override readonly meta = PG_VECTOR_META; + override readonly paramsSchema: StandardSchemaV1 = vectorParamsSchema; + override renderOutputType(params: VectorParams): string { + return `Vector<${params.length}>`; + } + /** + * The runtime calls `factory(undefined)(ctx)` to materialize a representative codec for parameterized descriptors that ship a no-params column variant (here, `vectorColumn` vs `vector(N)`). The runtime cast widens `params` to `unknown`, so guarding with an optional read keeps the typed call site (`factory({ length })`) strict while still producing a length-agnostic codec for representative use. Encode/decode for an undimensioned column run through this representative; the wire format `[v1,v2,...]` is dimension-independent. + */ + override factory(params: VectorParams): (ctx: CodecInstanceContext) => PgVectorCodec { + return () => new PgVectorCodec(this, (params as VectorParams | undefined)?.length); + } +} + +export const pgVectorDescriptor = new PgVectorDescriptor(); + +/** + * Per-codec column helper for `pg/vector@1`. Generic over `N extends number` so the column site preserves the dimension literal in `typeParams` (e.g. `pgVectorColumn(1536)` packs `typeParams: { length: 1536 }`). + * + * Passes the bare `nativeType: 'vector'`; the family-layer `expandNativeType` hook renders the parameterized form (`vector(1536)`) at emit/verify time from `nativeType` + `typeParams`. + */ +export const pgVectorColumn = (length: N) => + column(pgVectorDescriptor.factory({ length }), pgVectorDescriptor.codecId, { length }, 'vector'); + +pgVectorColumn satisfies ColumnHelperFor; +pgVectorColumn satisfies ColumnHelperForStrict; + +const codecDescriptorMap = { + vector: pgVectorDescriptor, +} as const; + +export type CodecTypes = ExtractCodecTypes; + +export const codecDescriptors: readonly AnyCodecDescriptor[] = Object.values(codecDescriptorMap); diff --git a/packages/3-extensions/pgvector/src/core/descriptor-meta.ts b/packages/3-extensions/pgvector/src/core/descriptor-meta.ts index bc98b7ec65..1884b11f7d 100644 --- a/packages/3-extensions/pgvector/src/core/descriptor-meta.ts +++ b/packages/3-extensions/pgvector/src/core/descriptor-meta.ts @@ -3,11 +3,12 @@ import { buildOperation, type CodecExpression, type Expression, + refsOf, toExpr, } from '@prisma-next/sql-relational-core/expression'; import type { CodecTypes } from '../types/codec-types'; import { pgvectorAuthoringTypes } from './authoring'; -import { codecDefinitions } from './codecs'; +import { pgvectorCodecRegistry } from './registry'; const pgvectorTypeId = 'pg/vector@1' as const; @@ -23,17 +24,19 @@ export function pgvectorQueryOperations< impl: ( self: CodecExpression<'pg/vector@1', boolean, CT>, other: CodecExpression<'pg/vector@1', boolean, CT>, - ): Expression<{ codecId: 'pg/float8@1'; nullable: false }> => - buildOperation({ + ): Expression<{ codecId: 'pg/float8@1'; nullable: false }> => { + const selfRefs = refsOf(self); + return buildOperation({ method: 'cosineDistance', - args: [toExpr(self, pgvectorTypeId), toExpr(other, pgvectorTypeId)], + args: [toExpr(self, pgvectorTypeId, selfRefs), toExpr(other, pgvectorTypeId, selfRefs)], returns: { codecId: 'pg/float8@1', nullable: false }, lowering: { targetFamily: 'sql', strategy: 'function', template: '{{self}} <=> {{arg0}}', }, - }), + }); + }, }, { method: 'cosineSimilarity', @@ -41,17 +44,19 @@ export function pgvectorQueryOperations< impl: ( self: CodecExpression<'pg/vector@1', boolean, CT>, other: CodecExpression<'pg/vector@1', boolean, CT>, - ): Expression<{ codecId: 'pg/float8@1'; nullable: false }> => - buildOperation({ + ): Expression<{ codecId: 'pg/float8@1'; nullable: false }> => { + const selfRefs = refsOf(self); + return buildOperation({ method: 'cosineSimilarity', - args: [toExpr(self, pgvectorTypeId), toExpr(other, pgvectorTypeId)], + args: [toExpr(self, pgvectorTypeId, selfRefs), toExpr(other, pgvectorTypeId, selfRefs)], returns: { codecId: 'pg/float8@1', nullable: false }, lowering: { targetFamily: 'sql', strategy: 'function', template: '1 - ({{self}} <=> {{arg0}})', }, - }), + }); + }, }, ]; } @@ -72,7 +77,7 @@ const pgvectorPackMetaBase = { }, types: { codecTypes: { - codecInstances: Object.values(codecDefinitions).map((def) => def.codec), + codecDescriptors: Array.from(pgvectorCodecRegistry.values()), import: { package: '@prisma-next/extension-pgvector/codec-types', named: 'CodecTypes', diff --git a/packages/3-extensions/pgvector/src/core/registry.ts b/packages/3-extensions/pgvector/src/core/registry.ts new file mode 100644 index 0000000000..7f558f701f --- /dev/null +++ b/packages/3-extensions/pgvector/src/core/registry.ts @@ -0,0 +1,11 @@ +import { buildCodecDescriptorRegistry } from '@prisma-next/sql-relational-core/codec-descriptor-registry'; +import type { CodecDescriptorRegistry } from '@prisma-next/sql-relational-core/query-lane-context'; +import { codecDescriptors } from './codecs'; + +/** + * Registry of every codec descriptor shipped by `@prisma-next/extension-pgvector`. + * + * Public consumer surface for the pgvector codec set. Currently a single entry (`pg/vector@1`); the registry shape stays consistent with the other codec-shipping packages so consumers don't need to special-case extensions. See ADR 208. + */ +export const pgvectorCodecRegistry: CodecDescriptorRegistry = + buildCodecDescriptorRegistry(codecDescriptors); diff --git a/packages/3-extensions/pgvector/src/exports/column-types.ts b/packages/3-extensions/pgvector/src/exports/column-types.ts index 0fd1880cdd..a289b5d496 100644 --- a/packages/3-extensions/pgvector/src/exports/column-types.ts +++ b/packages/3-extensions/pgvector/src/exports/column-types.ts @@ -1,16 +1,14 @@ /** * Column type descriptors for pgvector extension. * - * These descriptors provide both codecId and nativeType for use in contract authoring. - * They are derived from the same source of truth as codec definitions and manifests. + * These descriptors provide both codecId and nativeType for use in contract authoring. They are derived from the same source of truth as codec definitions and manifests. */ -import type { ColumnTypeDescriptor } from '@prisma-next/contract-authoring'; +import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import { VECTOR_CODEC_ID, VECTOR_MAX_DIM } from '../core/constants'; /** - * Static vector column descriptor without dimension. - * Use `vector(N)` for dimensioned vectors that produce `vector(N)` DDL. + * Static vector column descriptor without dimension. Use `vector(N)` for dimensioned vectors that produce `vector(N)` DDL. */ export const vectorColumn = { codecId: VECTOR_CODEC_ID, @@ -25,7 +23,6 @@ export const vectorColumn = { * .column('embedding', { type: vector(1536), nullable: false }) * // Produces: nativeType: 'vector', typeParams: { length: 1536 } * ``` - * * @param length - The dimension of the vector (e.g., 1536 for OpenAI embeddings) * @returns A column type descriptor with `typeParams.length` set * @throws {RangeError} If length is not an integer in the range [1, VECTOR_MAX_DIM] diff --git a/packages/3-extensions/pgvector/src/exports/runtime.ts b/packages/3-extensions/pgvector/src/exports/runtime.ts index 5d125bb098..cf25a5eb5c 100644 --- a/packages/3-extensions/pgvector/src/exports/runtime.ts +++ b/packages/3-extensions/pgvector/src/exports/runtime.ts @@ -1,63 +1,6 @@ -import type { Codec, CodecInstanceContext } from '@prisma-next/framework-components/codec'; -import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; -import type { - RuntimeParameterizedCodecDescriptor, - SqlRuntimeExtensionDescriptor, -} from '@prisma-next/sql-runtime'; -import { type as arktype } from 'arktype'; -import { codecDefinitions } from '../core/codecs'; -import { VECTOR_CODEC_ID, VECTOR_MAX_DIM } from '../core/constants'; +import type { SqlRuntimeExtensionDescriptor } from '@prisma-next/sql-runtime'; import { pgvectorPackMeta, pgvectorQueryOperations } from '../core/descriptor-meta'; - -const vectorParamsSchema = arktype({ - length: 'number', -}).narrow((params, ctx) => { - const { length } = params; - if (!Number.isInteger(length)) { - return ctx.mustBe('an integer'); - } - if (length < 1 || length > VECTOR_MAX_DIM) { - return ctx.mustBe(`in the range [1, ${VECTOR_MAX_DIM}]`); - } - return true; -}); - -// pgvector's encode is parameter-independent (the wire format `[v1,v2,...]` -// doesn't care about declared length), so the resolved codec for every -// `(length)` instance is the same shared codec object today. The factory -// returns it directly; `ctx` is unused. When a future refactor wants per- -// instance state (e.g. capping wire length to declared dimension), the -// closure over `params` is the place to add it. -// -// The factory parameter types as the family-agnostic -// `CodecInstanceContext` because pgvector doesn't read the SQL-specific -// `usedAt` field. The SQL runtime materializer passes a -// `SqlCodecInstanceContext`; family-agnostic factories are structurally -// compatible with the SQL extended type. -const sharedVectorCodec: Codec = codecDefinitions.vector.codec; -const vectorFactory = (_params: { readonly length: number }) => (_ctx: CodecInstanceContext) => - sharedVectorCodec; - -const parameterizedCodecDescriptors = [ - { - codecId: VECTOR_CODEC_ID, - traits: ['equality'] as const, - targetTypes: ['vector'] as const, - paramsSchema: vectorParamsSchema, - renderOutputType: (params: { readonly length: number }) => `Vector<${params.length}>`, - factory: vectorFactory, - }, -] as const satisfies ReadonlyArray< - RuntimeParameterizedCodecDescriptor<{ readonly length: number }> ->; - -function createPgvectorCodecRegistry() { - const registry = createCodecRegistry(); - for (const def of Object.values(codecDefinitions)) { - registry.register(def.codec); - } - return registry; -} +import { pgvectorCodecRegistry } from '../core/registry'; const pgvectorRuntimeDescriptor: SqlRuntimeExtensionDescriptor<'postgres'> = { kind: 'extension' as const, @@ -65,20 +8,14 @@ const pgvectorRuntimeDescriptor: SqlRuntimeExtensionDescriptor<'postgres'> = { version: pgvectorPackMeta.version, familyId: 'sql' as const, targetId: 'postgres' as const, - // Mirror `pgvectorPackMeta.types.codecTypes.codecInstances` here so that - // runtime-plane assemblers driven by `extractCodecLookup` (which reads - // `descriptor.types?.codecTypes?.codecInstances`) discover `pg/vector@1`. - // Without this, the Postgres adapter's runtime-plane codec lookup misses - // the vector codec and `$N::vector` would silently disappear once the - // renderer switches to lookup-driven cast policy. + // Expose the unified descriptor list so `extractCodecLookup` reads `targetTypes` / `meta` / `renderOutputType` directly off the descriptors and materializes the representative `Codec` for the SQL renderer's cast-policy lookup. types: { codecTypes: { - codecInstances: Object.values(codecDefinitions).map((def) => def.codec), + codecDescriptors: Array.from(pgvectorCodecRegistry.values()), }, }, - codecs: createPgvectorCodecRegistry, + codecs: () => Array.from(pgvectorCodecRegistry.values()), queryOperations: () => pgvectorQueryOperations(), - parameterizedCodecs: () => parameterizedCodecDescriptors, create() { return { familyId: 'sql' as const, @@ -87,4 +24,5 @@ const pgvectorRuntimeDescriptor: SqlRuntimeExtensionDescriptor<'postgres'> = { }, }; +export { pgvectorCodecRegistry }; export default pgvectorRuntimeDescriptor; diff --git a/packages/3-extensions/pgvector/test/codec-render-output-type.test.ts b/packages/3-extensions/pgvector/test/codec-render-output-type.test.ts index 6d6ebc4c79..6e746b8cb2 100644 --- a/packages/3-extensions/pgvector/test/codec-render-output-type.test.ts +++ b/packages/3-extensions/pgvector/test/codec-render-output-type.test.ts @@ -1,24 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { codecDefinitions } from '../src/core/codecs'; +import { pgVectorDescriptor } from '../src/core/codecs'; describe('pgvector codec renderOutputType', () => { - const codec = codecDefinitions['vector'].codec; + const renderOutputType = pgVectorDescriptor.renderOutputType as + | ((typeParams: Record) => string | undefined) + | undefined; + + // The descriptor's `renderOutputType` runs *after* `paramsSchema` validation so it can assume a well-formed `length`. Negative-shape inputs (missing / NaN / non-integer) are rejected upstream by `paramsSchema` and never reach this renderer. it('renders Vector when length is present', () => { - expect(codec.renderOutputType!({ length: 1536 })).toBe('Vector<1536>'); + expect(renderOutputType?.({ length: 1536 })).toBe('Vector<1536>'); }); it('renders Vector with small dimension', () => { - expect(codec.renderOutputType!({ length: 3 })).toBe('Vector<3>'); - }); - - it('returns undefined when length is absent', () => { - expect(codec.renderOutputType!({})).toBeUndefined(); - }); - - it('throws on NaN length', () => { - expect(() => codec.renderOutputType!({ length: Number.NaN })).toThrow( - /expected positive integer "length"/, - ); + expect(renderOutputType?.({ length: 3 })).toBe('Vector<3>'); }); }); diff --git a/packages/3-extensions/pgvector/test/codecs-class.types.test-d.ts b/packages/3-extensions/pgvector/test/codecs-class.types.test-d.ts new file mode 100644 index 0000000000..599fada18f --- /dev/null +++ b/packages/3-extensions/pgvector/test/codecs-class.types.test-d.ts @@ -0,0 +1,65 @@ +/** + * Type tests for the pgvector codec (TML-2357). + * + * Mirrors `packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts`. + * + * Coverage selection: + * + * - literal preservation through `descriptor.factory({ length })` — `N` flows from the call site into the helper's `typeParams`. + * - column helper preserves the typed `codecFactory` and the `{ length: N }` typeParams literal. + * - positive `satisfies ColumnHelperFor` and `ColumnHelperForStrict`. + * - one negative `// @ts-expect-error` for a wrong-shape malformed helper. + */ + +import { + type CodecInstanceContext, + type ColumnHelperFor, + type ColumnHelperForStrict, + column, +} from '@prisma-next/framework-components/codec'; +import { expectTypeOf, test } from 'vitest'; +import { + type PgVectorCodec, + type PgVectorDescriptor, + pgVectorColumn, + pgVectorDescriptor, +} from '../src/core/codecs'; + +test('pgVector: descriptor.factory(params) returns typed (ctx) => PgVectorCodec', () => { + const factory = pgVectorDescriptor.factory({ length: 1536 }); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgVectorCodec>(); +}); + +test('pgVector: column helper preserves typed codecFactory + length literal', () => { + const col = pgVectorColumn(1536); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgVectorCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ length: 1536 }>(); +}); + +test('pgVector: column helper carries bare nativeType (family layer expands at emit/verify)', () => { + const col = pgVectorColumn(1536); + expectTypeOf(col.nativeType).toEqualTypeOf(); + if (col.nativeType !== 'vector' || col.codecId !== 'pg/vector@1') { + throw new Error(`nativeType / codecId mismatch: ${col.nativeType} / ${col.codecId}`); + } + if (col.typeParams.length !== 1536) { + throw new Error(`length literal not preserved: ${col.typeParams.length}`); + } +}); + +pgVectorColumn satisfies ColumnHelperFor; +pgVectorColumn satisfies ColumnHelperForStrict; + +test('coarse satisfies catches wrong typeParams shape on pgVectorColumn', () => { + const brokenHelper = (length: number) => + column( + pgVectorDescriptor.factory({ length }), + pgVectorDescriptor.codecId, + { wrongKey: length }, + 'vector', + ); + // @ts-expect-error -- typeParams shape doesn't satisfy ColumnHelperFor + brokenHelper satisfies ColumnHelperFor; + // @ts-expect-error -- strict shape catches the same mismatch + brokenHelper satisfies ColumnHelperForStrict; +}); diff --git a/packages/3-extensions/pgvector/test/codecs.test.ts b/packages/3-extensions/pgvector/test/codecs.test.ts index 5b4c626f81..511fc83780 100644 --- a/packages/3-extensions/pgvector/test/codecs.test.ts +++ b/packages/3-extensions/pgvector/test/codecs.test.ts @@ -1,37 +1,36 @@ +import type { JsonValue } from '@prisma-next/contract/types'; import { timeouts } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; -import { codecDefinitions } from '../src/core/codecs'; - -// The pgvector codec authors `encode`/`decode` synchronously, but the -// `codec()` factory in `relational-core` lifts both methods to -// `Promise`-returning at the boundary. The tests below cast through the -// Promise-returning shape and `await` every call so unit-level coverage -// stays aligned with the codec contract: -// `Codec` — encode/decode return Promise. +import { pgVectorColumn, pgVectorDescriptor } from '../src/core/codecs'; +import { VECTOR_CODEC_ID, VECTOR_MAX_DIM } from '../src/core/constants'; + +// The pgvector codec authors `encode`/`decode` synchronously; codecs route through `Promise`-returning methods at the boundary. The tests below cast through the Promise-returning shape and `await` every call so unit-level coverage stays aligned with the codec contract: `Codec` — encode/decode return Promise. type AsyncVectorCodec = { readonly encode: (value: number[]) => Promise; readonly decode: (wire: string) => Promise; + readonly encodeJson: (value: number[]) => JsonValue; + readonly decodeJson: (json: JsonValue) => number[]; }; -function asAsyncCodec(): AsyncVectorCodec { - return codecDefinitions.vector.codec as unknown as AsyncVectorCodec; +function asAsyncCodec(length: number): AsyncVectorCodec { + // pgvector's runtime codec enforces the declared dimension; tests instantiate the codec at the dimension matching their value array. + return pgVectorDescriptor.factory({ length })({ + name: 'test', + }) as unknown as AsyncVectorCodec; } describe('pgvector codecs', () => { it( 'has vector codec registered', () => { - const vectorDef = codecDefinitions.vector; - expect(vectorDef).toBeDefined(); - expect(vectorDef.typeId).toBe('pg/vector@1'); - expect(vectorDef.codec.targetTypes).toEqual(['vector']); + expect(pgVectorDescriptor.codecId).toBe('pg/vector@1'); + expect(pgVectorDescriptor.targetTypes).toEqual(['vector']); }, timeouts.default, ); it('encodes number array to PostgreSQL vector format', async () => { - const vectorCodec = asAsyncCodec(); - + const vectorCodec = asAsyncCodec(4); const value = [0.1, 0.2, 0.3, 0.4]; const encoded = await vectorCodec.encode(value); expect(encoded).toBe('[0.1,0.2,0.3,0.4]'); @@ -39,16 +38,14 @@ describe('pgvector codecs', () => { }); it('decodes PostgreSQL vector format string', async () => { - const vectorCodec = asAsyncCodec(); - + const vectorCodec = asAsyncCodec(4); const wire = '[0.1,0.2,0.3,0.4]'; const decoded = await vectorCodec.decode(wire); expect(decoded).toEqual([0.1, 0.2, 0.3, 0.4]); }); it('round-trip encode/decode preserves values', async () => { - const vectorCodec = asAsyncCodec(); - + const vectorCodec = asAsyncCodec(5); const original = [0.1, 0.2, 0.3, 0.4, 0.5]; const encoded = await vectorCodec.encode(original); expect(typeof encoded).toBe('string'); @@ -58,8 +55,7 @@ describe('pgvector codecs', () => { }); it('handles empty vector', async () => { - const vectorCodec = asAsyncCodec(); - + const vectorCodec = asAsyncCodec(0); const original: number[] = []; const encoded = await vectorCodec.encode(original); expect(encoded).toBe('[]'); @@ -68,34 +64,144 @@ describe('pgvector codecs', () => { }); it('rejects when encoding non-array', async () => { - const vectorCodec = asAsyncCodec(); - + const vectorCodec = asAsyncCodec(4); await expect(vectorCodec.encode('not an array' as unknown as number[])).rejects.toThrow( 'Vector value must be an array of numbers', ); }); it('rejects when encoding array with non-numbers', async () => { - const vectorCodec = asAsyncCodec(); - + const vectorCodec = asAsyncCodec(3); await expect(vectorCodec.encode([1, 2, 'three'] as unknown as number[])).rejects.toThrow( 'Vector value must contain only numbers', ); }); it('rejects when decoding invalid string format', async () => { - const vectorCodec = asAsyncCodec(); - + const vectorCodec = asAsyncCodec(4); await expect(vectorCodec.decode('not a vector format')).rejects.toThrow( 'Invalid vector format: expected "[...]", got "not a vector format"', ); }); it('rejects when decoding non-string', async () => { - const vectorCodec = asAsyncCodec(); - + const vectorCodec = asAsyncCodec(4); await expect(vectorCodec.decode(123 as unknown as string)).rejects.toThrow( 'Vector wire value must be a string', ); }); + + it('rejects encoding when value length mismatches declared dimension', async () => { + const vectorCodec = asAsyncCodec(3); + await expect(vectorCodec.encode([1, 2])).rejects.toThrow( + 'Vector length mismatch: expected 3, got 2', + ); + await expect(vectorCodec.encode([1, 2, 3, 4])).rejects.toThrow( + 'Vector length mismatch: expected 3, got 4', + ); + }); + + it('rejects decoding when wire length mismatches declared dimension', async () => { + const vectorCodec = asAsyncCodec(3); + await expect(vectorCodec.decode('[1,2]')).rejects.toThrow( + 'Vector length mismatch: expected 3, got 2', + ); + }); + + // The runtime materializes a representative codec for parameterized descriptors + // via `factory(undefined)(ctx)` so undimensioned `vectorColumn` columns (no + // `typeParams.length`) still resolve through codec encode/decode. This guards + // against silently regressing back to passing arrays through node-postgres, + // which formats them as PG array literals (`{"0.1","0.2"}`) rejected by the + // `vector` type. + it('factory(undefined) yields a length-agnostic codec (representative for undimensioned columns)', async () => { + const factory = pgVectorDescriptor.factory as unknown as ( + params: undefined, + ) => (ctx: { name: string }) => AsyncVectorCodec; + const codec = factory(undefined)({ name: 'representative' }); + expect(await codec.encode([0.1, 0.2, 0.3])).toBe('[0.1,0.2,0.3]'); + expect(await codec.encode([1, 2, 3, 4, 5])).toBe('[1,2,3,4,5]'); + expect(await codec.decode('[0.4,0.5]')).toEqual([0.4, 0.5]); + }); + + it('rejects decoding when the wire payload contains a non-number token', async () => { + const vectorCodec = asAsyncCodec(3); + await expect(vectorCodec.decode('[1,foo,3]')).rejects.toThrow( + /Invalid vector value: "foo" is not a number/, + ); + }); + + describe('encodeJson / decodeJson', () => { + it('returns the value array unchanged on encodeJson when length matches', () => { + const codec = asAsyncCodec(3); + const value = [0.1, 0.2, 0.3]; + const encoded = codec.encodeJson(value); + expect(encoded).toEqual(value); + }); + + it('round-trips through decodeJson back to the same array', () => { + const codec = asAsyncCodec(3); + const json = codec.encodeJson([0.1, 0.2, 0.3]); + expect(codec.decodeJson(json)).toEqual([0.1, 0.2, 0.3]); + }); + + it('rejects encodeJson when the value is not an array', () => { + const codec = asAsyncCodec(3); + expect(() => codec.encodeJson('nope' as unknown as number[])).toThrow( + 'Vector value must be an array of numbers', + ); + }); + + it('rejects decodeJson when the JSON payload is not a number array of the declared length', () => { + const codec = asAsyncCodec(3); + expect(() => codec.decodeJson([1, 2] as unknown as JsonValue)).toThrow( + /Vector length mismatch/, + ); + expect(() => codec.decodeJson([1, 'two', 3] as unknown as JsonValue)).toThrow( + 'Vector value must contain only numbers', + ); + }); + }); + + describe('pgVectorColumn helper', () => { + it('produces a ColumnSpec with the codec id, vector nativeType, and length typeParams', () => { + const spec = pgVectorColumn(1536); + expect(spec.codecId).toBe(VECTOR_CODEC_ID); + expect(spec.nativeType).toBe('vector'); + expect(spec.typeParams).toEqual({ length: 1536 }); + }); + + it('produces a codec factory that materializes a working PgVectorCodec', async () => { + const spec = pgVectorColumn(3); + const codec = spec.codecFactory({ + name: 'embedding', + }) as unknown as AsyncVectorCodec; + expect(await codec.encode([0.1, 0.2, 0.3])).toBe('[0.1,0.2,0.3]'); + }); + }); + + describe('paramsSchema', () => { + const validate = (params: unknown) => + pgVectorDescriptor.paramsSchema['~standard'].validate(params); + + it('accepts a positive integer length within the allowed range', () => { + const result = validate({ length: 1536 }); + expect('issues' in result ? result.issues : null).toBeFalsy(); + }); + + it('rejects non-integer length values', () => { + const result = validate({ length: 1.5 }); + expect('issues' in result && result.issues).toBeTruthy(); + }); + + it('rejects length values below 1', () => { + const result = validate({ length: 0 }); + expect('issues' in result && result.issues).toBeTruthy(); + }); + + it('rejects length values above VECTOR_MAX_DIM', () => { + const result = validate({ length: VECTOR_MAX_DIM + 1 }); + expect('issues' in result && result.issues).toBeTruthy(); + }); + }); }); diff --git a/packages/3-extensions/pgvector/test/operations.test.ts b/packages/3-extensions/pgvector/test/operations.test.ts index e03ff2a905..958075b450 100644 --- a/packages/3-extensions/pgvector/test/operations.test.ts +++ b/packages/3-extensions/pgvector/test/operations.test.ts @@ -1,5 +1,5 @@ import { createSqlOperationRegistry } from '@prisma-next/sql-operations'; -import { createCodecRegistry, OperationExpr, ParamRef } from '@prisma-next/sql-relational-core/ast'; +import { OperationExpr, ParamRef } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import pgvectorDescriptor from '../src/exports/runtime'; @@ -12,13 +12,14 @@ describe('pgvector operations', () => { expect(pgvectorDescriptor.version).toBe('0.0.1'); }); - it('descriptor provides codec registry with vector codec', () => { - const codecs = pgvectorDescriptor.codecs(); - expect(codecs).toBeDefined(); + it('descriptor contributes the pg/vector@1 codec descriptor', () => { + const descriptors = pgvectorDescriptor.codecs(); + expect(descriptors).toBeDefined(); + expect(descriptors.length).toBe(1); - const vectorCodec = codecs.get('pg/vector@1'); - expect(vectorCodec).toBeDefined(); - expect(vectorCodec?.id).toBe('pg/vector@1'); + const vectorDescriptor = descriptors.find((d) => d.codecId === 'pg/vector@1'); + expect(vectorDescriptor).toBeDefined(); + expect(vectorDescriptor?.codecId).toBe('pg/vector@1'); }); it('descriptor provides query operations whose impls build AST with lowering', () => { @@ -68,18 +69,13 @@ describe('pgvector operations', () => { expect(entries['cosineSimilarity']).toBeDefined(); }); - it('codecs can be registered in codec registry', () => { - const descriptorCodecs = pgvectorDescriptor.codecs(); - expect(descriptorCodecs).toBeDefined(); + it('descriptor materializes a runtime codec when its factory is called', () => { + const descriptors = pgvectorDescriptor.codecs(); + const vectorDescriptor = descriptors.find((d) => d.codecId === 'pg/vector@1'); + expect(vectorDescriptor).toBeDefined(); - const registry = createCodecRegistry(); - for (const codec of descriptorCodecs.values()) { - registry.register(codec); - } - - const vectorCodec = registry.get('pg/vector@1'); - expect(vectorCodec).toBeDefined(); - expect(vectorCodec?.id).toBe('pg/vector@1'); + const codec = vectorDescriptor!.factory({ length: 3 })({ name: '' }); + expect(codec.id).toBe('pg/vector@1'); }); it('instance is minimal (identity only)', () => { diff --git a/packages/3-extensions/pgvector/test/typed-descriptor-flow.test-d.ts b/packages/3-extensions/pgvector/test/typed-descriptor-flow.test-d.ts new file mode 100644 index 0000000000..f21098a4af --- /dev/null +++ b/packages/3-extensions/pgvector/test/typed-descriptor-flow.test-d.ts @@ -0,0 +1,45 @@ +/** + * Constructive type tests for the pgvector per-extension descriptor record layer (TML-2357). Mirrors the per-target tests in postgres / sqlite. + */ + +import type { AnyCodecDescriptor, CodecTrait } from '@prisma-next/framework-components/codec'; +import { expectTypeOf, test } from 'vitest'; +import { codecDescriptors, type PgVectorDescriptor, pgVectorDescriptor } from '../src/core/codecs'; +import type { CodecTypes } from '../src/exports/codec-types'; + +test('codecDescriptors narrows to readonly AnyCodecDescriptor[]', () => { + expectTypeOf(codecDescriptors).toEqualTypeOf(); +}); + +test('list entries extend AnyCodecDescriptor', () => { + expectTypeOf<(typeof codecDescriptors)[number]>().toExtend(); +}); + +test('pgVectorDescriptor.traits is the readonly literal tuple', () => { + type Traits = PgVectorDescriptor['traits']; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toExtend(); +}); + +test('pgVectorDescriptor.codecId is the literal `pg/vector@1`', () => { + expectTypeOf(pgVectorDescriptor.codecId).toEqualTypeOf<'pg/vector@1'>(); +}); + +test('CodecTypes is keyed by codec id and exposes input/output/traits', () => { + expectTypeOf().toExtend<{ + readonly input: number[]; + readonly output: number[]; + readonly traits: 'equality'; + }>(); +}); + +test('widened trait shape on pgVector fails the equality check', () => { + type Traits = PgVectorDescriptor['traits']; + // @ts-expect-error -- traits literal tuple is preserved, not widened to CodecTrait[] + expectTypeOf().toEqualTypeOf(); +}); + +test('non-existent codec id is absent from CodecTypes', () => { + // @ts-expect-error -- `pg/nonexistent@1` is not a registered codec id + type _Missing = CodecTypes['pg/nonexistent@1']; +}); diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts b/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts index 7b35aa2808..7558d94d3f 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts @@ -51,7 +51,11 @@ function toParamAssignments( if (!codecId) { throw new Error(`Unknown column "${column}" in table "${tableName}"`); } - assignments[column] = ParamRef.of(value, { name: column, codecId }); + assignments[column] = ParamRef.of(value, { + name: column, + codecId, + refs: { table: tableName, column }, + }); } return { assignments }; @@ -97,7 +101,11 @@ function normalizeInsertRows( if (!codecId) { throw new Error(`Unknown column "${column}" in table "${tableName}"`); } - normalizedRow[column] = ParamRef.of(row[column], { name: column, codecId }); + normalizedRow[column] = ParamRef.of(row[column], { + name: column, + codecId, + refs: { table: tableName, column }, + }); continue; } normalizedRow[column] = new DefaultValueExpr(); @@ -143,11 +151,7 @@ function stripUndefinedValues(row: Record): Record[], ): ReadonlyArray[]> { diff --git a/packages/3-extensions/sql-orm-client/src/types.ts b/packages/3-extensions/sql-orm-client/src/types.ts index 5c0e553b14..4ca7d4002c 100644 --- a/packages/3-extensions/sql-orm-client/src/types.ts +++ b/packages/3-extensions/sql-orm-client/src/types.ts @@ -19,12 +19,9 @@ import { import type { Expression } from '@prisma-next/sql-relational-core/expression'; import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; import type { ComputeColumnJsType, RuntimeScope } from '@prisma-next/sql-relational-core/types'; +import { ifDefined } from '@prisma-next/utils/defined'; import type { RowSelection } from './collection-internal-types'; -// --------------------------------------------------------------------------- -// Comparison / Filter / Order / Include -// --------------------------------------------------------------------------- - export type AggregateFn = 'count' | 'sum' | 'avg' | 'min' | 'max'; export interface IncludeScalar extends RowSelection { @@ -64,10 +61,6 @@ export interface IncludeExpr { readonly combine: Readonly> | undefined; } -// --------------------------------------------------------------------------- -// CollectionState — plain data, no query builder types -// --------------------------------------------------------------------------- - export interface CollectionState { readonly filters: readonly AnyExpression[]; readonly includes: readonly IncludeExpr[]; @@ -112,10 +105,6 @@ export type DefaultCollectionTypeState = { readonly variantName: undefined; }; -// --------------------------------------------------------------------------- -// CollectionContext — bundles lane context + runtime -// --------------------------------------------------------------------------- - export interface RuntimeConnection extends RuntimeScope { release?(): Promise; transaction?(): Promise; @@ -136,10 +125,6 @@ export interface CollectionContext> { readonly context: ExecutionContext; } -// --------------------------------------------------------------------------- -// ModelAccessor — type-safe proxy for where() callbacks -// --------------------------------------------------------------------------- - export type ComparisonMethodFns = { eq(value: T): AnyExpression; neq(value: T): AnyExpression; @@ -157,8 +142,7 @@ export type ComparisonMethodFns = { }; /** - * Trait-gated comparison methods. Only methods whose required traits are - * all present in `Traits` are included. + * Trait-gated comparison methods. Only methods whose required traits are all present in `Traits` are included. * * - `traits: []` → always available (isNull, isNotNull) */ @@ -168,10 +152,6 @@ export type ComparisonMethods = { : never]: ComparisonMethodFns[K]; }; -// --------------------------------------------------------------------------- -// Extension operation result — returned by calling an extension method -// --------------------------------------------------------------------------- - type QueryOperationReturnTraits< Returns, TCodecTypes extends Record, @@ -209,9 +189,7 @@ type IsBooleanReturn> = Ret : false; /** - * Extract the `{codecId, nullable}` spec carried inside an `Expression`. - * Used to recover the op's return spec from its impl signature so the - * pre-existing `QueryOperationReturn*` helpers can consume it unchanged. + * Extract the `{codecId, nullable}` spec carried inside an `Expression`. Used to recover the op's return spec from its impl signature so the pre-existing `QueryOperationReturn*` helpers can consume it unchanged. */ type SpecOf = E extends Expression ? T : never; @@ -220,14 +198,8 @@ type ImplReturnSpec = Impl extends (...args: never[]) => infer Ret ? SpecO /** * Builds the ORM column-method signature for an operation. * - * - User args: drops the impl's first parameter (the column is bound at access - * time) and forwards the rest unchanged. Each remaining arg keeps its - * authored `CodecExpression` / `TraitExpression` shape — so callers can pass - * a raw JS value, another column handle (which itself implements - * `Expression`), or `null` when nullable. - * - Return: predicate ops (boolean-traited return) yield `AnyExpression`; - * non-predicate ops yield `ComparisonMethods` of the return - * codec. + * - User args: drops the impl's first parameter (the column is bound at access time) and forwards the rest unchanged. Each remaining arg keeps its authored `CodecExpression` / `TraitExpression` shape — so callers can pass a raw JS value, another column handle (which itself implements `Expression`), or `null` when nullable. + * - Return: predicate ops (boolean-traited return) yield `AnyExpression`; non-predicate ops yield `ComparisonMethods` of the return codec. */ type QueryOperationMethod> = Op extends { readonly impl: (...args: never[]) => unknown; @@ -247,9 +219,7 @@ type QueryOperationMethod> = Op : never; /** - * Tests whether an operation's `self` dispatch hint reaches a field with the - * given codec identity. Codec hints match by identity; trait hints match when - * every required trait is present in the field codec's trait set. + * Tests whether an operation's `self` dispatch hint reaches a field with the given codec identity. Codec hints match by identity; trait hints match when every required trait is present in the field codec's trait set. */ type OpMatchesField> = Op extends { readonly self: infer Self; @@ -285,21 +255,43 @@ type FieldOperations< : unknown : unknown; -// --------------------------------------------------------------------------- -// COMPARISON_METHODS_META — single source of truth for traits + factories -// --------------------------------------------------------------------------- +/** + * Resolve the unique column ref carried by `left` so its surrounding `ParamRef` can dispatch through `forColumn`. The previous implementation only matched bare `column-ref` expressions, which lost refs the moment the expression got wrapped (`upper(f.id)`, `BinaryExpr`, function-call expressions, etc.) — and column-aware dispatch (AC-5) silently degraded for any predicate that touched a computed column. + * + * Walking via `collectColumnRefs()` and accepting only a single unambiguous ref gives `eq(upper(f.id), value)` and friends correct dispatch without inventing refs for ambiguous shapes (e.g. `eq(concat(f.firstName, f.lastName), value)` returns `undefined` and falls through to the codec-id path, which is correct for non-parameterized comparisons). + */ +function refsFromLeft(left: AnyExpression): { table: string; column: string } | undefined { + if (left.kind === 'column-ref') { + return { table: left.table, column: left.column }; + } + const columnRefs = left.collectColumnRefs(); + if (columnRefs.length !== 1) return undefined; + const single = columnRefs[0]; + if (!single) return undefined; + return { table: single.table, column: single.column }; +} -function param(codecId: string | undefined, value: unknown): ParamRef { - return codecId ? ParamRef.of(value, { codecId }) : ParamRef.of(value); +function param( + codecId: string | undefined, + value: unknown, + refs: { table: string; column: string } | undefined, +): ParamRef { + if (codecId === undefined && refs === undefined) return ParamRef.of(value); + return ParamRef.of(value, { + ...ifDefined('codecId', codecId), + ...ifDefined('refs', refs), + }); } -function paramList(codecId: string | undefined, values: readonly unknown[]): ListExpression { - return ListExpression.of(values.map((value) => param(codecId, value))); +function paramList( + codecId: string | undefined, + values: readonly unknown[], + refs: { table: string; column: string } | undefined, +): ListExpression { + return ListExpression.of(values.map((value) => param(codecId, value, refs))); } -// never[] is intentional: factories have heterogeneous signatures (value: unknown, -// values: readonly unknown[], pattern: string, etc.) but are only called through -// the typed ComparisonMethodFns interface, never through this type directly. +// never[] is intentional: factories have heterogeneous signatures (value: unknown, values: readonly unknown[], pattern: string, etc.) but are only called through the typed ComparisonMethodFns interface, never through this type directly. type MethodFactory = ( left: AnyExpression, codecId: string | undefined, @@ -312,12 +304,16 @@ type ComparisonMethodMeta = { function scalarComparisonMethod(op: BinaryOp) { return ((left, codecId) => (value: unknown) => - new BinaryExpr(op, left, param(codecId, value))) satisfies MethodFactory; + new BinaryExpr(op, left, param(codecId, value, refsFromLeft(left)))) satisfies MethodFactory; } function listComparisonMethod(op: BinaryOp) { return ((left, codecId) => (values: readonly unknown[]) => - new BinaryExpr(op, left, paramList(codecId, values))) satisfies MethodFactory; + new BinaryExpr( + op, + left, + paramList(codecId, values, refsFromLeft(left)), + )) satisfies MethodFactory; } /** @@ -422,18 +418,10 @@ export type ModelAccessor< ModelName extends string, > = ScalarModelAccessor & RelationModelAccessor; -// --------------------------------------------------------------------------- -// DefaultModelRow — all scalar fields with JS types -// --------------------------------------------------------------------------- - export type DefaultModelRow, ModelName extends string> = { [K in keyof FieldsOf & string]: FieldJsType; }; -// --------------------------------------------------------------------------- -// InferRootRow — discriminated union for polymorphic base models -// --------------------------------------------------------------------------- - type Simplify = { [K in keyof T]: T[K] } & {}; type VariantRow, ModelName extends string> = ModelDef< @@ -554,10 +542,6 @@ export type ShorthandWhereFilter< | undefined; }>; -// --------------------------------------------------------------------------- -// Helpers for extracting fields / types from the contract -// --------------------------------------------------------------------------- - type ModelsOf> = TContract['models'] extends Record ? TContract['models'] : Record; @@ -671,10 +655,6 @@ type FieldStorageColumn< FieldName extends string, > = ResolvedStorageColumn; -// --------------------------------------------------------------------------- -// Field trait resolution from contract CodecTypes -// --------------------------------------------------------------------------- - type FieldCodecId< TContract extends Contract, ModelName extends string, @@ -795,10 +775,6 @@ export type CreateInput, ModelName extend > & RelationMutationFields; -// --------------------------------------------------------------------------- -// Polymorphic write gating -// --------------------------------------------------------------------------- - type IsPolymorphicBase, ModelName extends string> = ModelDef< TContract, ModelName @@ -1070,10 +1046,6 @@ export type MutationUpdateInput< ModelName extends string, > = Partial> & RelationMutationFields; -// --------------------------------------------------------------------------- -// Relation helpers -// --------------------------------------------------------------------------- - type ModelRelations, ModelName extends string> = ModelDef< TContract, ModelName diff --git a/packages/3-extensions/sql-orm-client/src/where-binding.ts b/packages/3-extensions/sql-orm-client/src/where-binding.ts index fdb2c2df5a..c026beb4e2 100644 --- a/packages/3-extensions/sql-orm-client/src/where-binding.ts +++ b/packages/3-extensions/sql-orm-client/src/where-binding.ts @@ -122,7 +122,10 @@ function createParamRef( if (!codecId) { throw new Error(`Unknown column "${columnRef.column}" in table "${columnRef.table}"`); } - return ParamRef.of(value, { codecId }); + return ParamRef.of(value, { + codecId, + refs: { table: columnRef.table, column: columnRef.column }, + }); } function createExpressionBinder(contract: Contract): ExpressionRewriter { @@ -174,6 +177,7 @@ function bindSelectAst(contract: Contract, ast: SelectAst): SelectAs projection.alias, bindProjectionExpr(contract, projection.expr), projection.codecId, + projection.refs, ), ), where: ast.where ? bindWhereExprNode(contract, ast.where) : undefined, diff --git a/packages/3-extensions/sql-orm-client/test/codec-async.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/codec-async.types.test-d.ts index 2a7eb44cf9..4268841827 100644 --- a/packages/3-extensions/sql-orm-client/test/codec-async.types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/codec-async.types.test-d.ts @@ -1,24 +1,13 @@ /** * Type-level coverage for the ORM-client async-codec boundary. * - * These assertions encode the invariant that ORM-client read and write - * surfaces always present plain `T` values to consumers — never `Promise` - * or `T | Promise` — even though codec query-time methods (`encode` / - * `decode`) are Promise-returning at the boundary. The Promise lift lives - * inside `sql-runtime`'s decode-once-per-row contract; the orm-client - * itself never adds (or removes) a Promise wrapper, so the type-level - * surfaces here stay plain by construction. + * These assertions encode the invariant that ORM-client read and write surfaces always present plain `T` values to consumers — never `Promise` or `T | Promise` — even though codec query-time methods (`encode` / `decode`) are Promise-returning at the boundary. The Promise lift lives inside `sql-runtime`'s decode-once-per-row contract; the orm-client itself never adds (or removes) a Promise wrapper, so the + * type-level surfaces here stay plain by construction. * * Coverage: - * - **Row shape**: `DefaultModelRow` / `InferRootRow` carry plain `T` for - * both `.first()` and `for await` consumption paths. - * - **Write surfaces**: `CreateInput`, `MutationUpdateInput`, - * `UniqueConstraintCriterion`, and `ShorthandWhereFilter` carry plain - * `T` for field positions. - * - **Negative tests**: no `Promise` form leaks into a row-shape - * position (read or write). The ORM client uses one field type-map - * (rooted in `DefaultModelRow`); there is no read/write split for codec - * output types. + * - **Row shape**: `DefaultModelRow` / `InferRootRow` carry plain `T` for both `.first()` and `for await` consumption paths. + * - **Write surfaces**: `CreateInput`, `MutationUpdateInput`, `UniqueConstraintCriterion`, and `ShorthandWhereFilter` carry plain `T` for field positions. + * - **Negative tests**: no `Promise` form leaks into a row-shape position (read or write). The ORM client uses one field type-map (rooted in `DefaultModelRow`); there is no read/write split for codec output types. */ import { expectTypeOf, test } from 'vitest'; @@ -33,15 +22,9 @@ import type { } from '../src/types'; import type { Contract } from './fixtures/generated/contract'; -// `User.address` is jsonb-backed (a value object whose fields and the field -// itself flow through the `pg/jsonb@1` codec); `User.invitedById` is int4 and -// nullable. Both codecs are lifted to async dispatch by `codec()` regardless -// of whether the author wrote sync or async functions, so these columns are -// representative of "async codec columns" at the runtime boundary. +// `User.address` is jsonb-backed (a value object whose fields and the field itself flow through the `pg/jsonb@1` codec); `User.invitedById` is int4 and nullable. Both codecs are lifted to async dispatch at the codec boundary regardless of whether the author wrote sync or async functions, so these columns are representative of "async codec columns" at the runtime boundary. // -// See `packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts` -// (`codec()` factory) — every author function is wrapped in `async (x) => -// fn(x)`, which means every column is an "async codec" at the runtime layer. +// See `packages/1-framework/1-core/framework-components/src/shared/codec.ts` (`Codec` interface — `encode`/`decode` return `Promise<…>`) — every author function is wrapped at the class boundary, which means every column is an "async codec" at the runtime layer. type AddressShape = { readonly street: string; @@ -58,10 +41,6 @@ type UserUnique = UniqueConstraintCriterion; type UserWhere = ShorthandWhereFilter; type UserInferRoot = InferRootRow; -// --------------------------------------------------------------------------- -// Read surfaces — DefaultModelRow / InferRootRow row fields are plain T -// --------------------------------------------------------------------------- - test('DefaultModelRow exposes plain `string` for pg/text@1 columns', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -118,10 +97,6 @@ test('Collection.all().firstOrThrow() resolves to a plain row (no Promise on expectTypeOf>().toEqualTypeOf(); }); -// --------------------------------------------------------------------------- -// Write surfaces accept plain T -// --------------------------------------------------------------------------- - test('CreateInput accepts plain `string` for pg/text@1 fields', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -140,10 +115,7 @@ test('MutationUpdateInput accepts plain `string` for pg/text@1 fields', () => { }); test('UniqueConstraintCriterion variants carry plain T for unique columns', () => { - // User has unique(id) [PK] and unique(email). - // Use toExtend to keep the assertion robust against minor representation - // differences (e.g. readonly modifiers / discriminated arrangement) while - // still pinning the field types to plain T. + // User has unique(id) [PK] and unique(email). Use toExtend to keep the assertion robust against minor representation differences (e.g. readonly modifiers / discriminated arrangement) while still pinning the field types to plain T. expectTypeOf().toExtend<{ readonly id: number } | { readonly email: string }>(); expectTypeOf<{ readonly id: number }>().toExtend(); expectTypeOf<{ readonly email: string }>().toExtend(); @@ -155,10 +127,6 @@ test('ShorthandWhereFilter accepts plain T (or null/undefined) for filterable fi expectTypeOf().toEqualTypeOf(); }); -// --------------------------------------------------------------------------- -// Negative tests — no Promise leak; one shared field type-map -// --------------------------------------------------------------------------- - test('no DefaultModelRow field position resolves to a Promise', () => { expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); @@ -189,12 +157,7 @@ test('no ShorthandWhereFilter field position resolves to a Promise', () => { expectTypeOf>>().toEqualTypeOf(); }); -// One field type-map shared by read and write surfaces: `CreateInput` and -// `MutationUpdateInput` are both derived from `DefaultModelRow`, which means -// the field-type source of truth is identical for reads and writes. The -// assertions below pin the field types to a single shape so that any future -// drift (e.g. introducing a `DefaultModelInputRow` with `Promise` shapes) -// would break this test. +// One field type-map shared by read and write surfaces: `CreateInput` and `MutationUpdateInput` are both derived from `DefaultModelRow`, which means the field-type source of truth is identical for reads and writes. The assertions below pin the field types to a single shape so that any future drift (e.g. introducing a `DefaultModelInputRow` with `Promise` shapes) would break this test. test('CreateInput field types match DefaultModelRow field types (one type-map)', () => { expectTypeOf>().toEqualTypeOf(); diff --git a/packages/3-extensions/sql-orm-client/test/filters.test.ts b/packages/3-extensions/sql-orm-client/test/filters.test.ts index 9cdfbcc048..5b8735e007 100644 --- a/packages/3-extensions/sql-orm-client/test/filters.test.ts +++ b/packages/3-extensions/sql-orm-client/test/filters.test.ts @@ -24,7 +24,7 @@ describe('filters', () => { { columns: Record } | undefined >; const codecId = tables[table]?.columns[column]?.codecId; - return codecId ? ParamRef.of(value, { codecId }) : ParamRef.of(value); + return codecId ? ParamRef.of(value, { codecId, refs: { table, column } }) : ParamRef.of(value); } it('and(), or(), not(), and all() use rich where objects', () => { @@ -129,10 +129,7 @@ describe('filters', () => { }); it('shorthandToWhereExpr() rejects equality-shorthand on a field without the equality trait', () => { - // Drop the descriptor's `traits` to model a codec that doesn't - // advertise equality (the descriptor-based trait gate replaces the - // legacy `codecs.traitsOf(codecId)` read; both branches — descriptor - // present without trait, descriptor missing entirely — must error). + // Drop the descriptor's `traits` to model a codec that doesn't advertise equality (the descriptor-based trait gate replaces the legacy `codecs.traitsOf(codecId)` read; both branches — descriptor present without trait, descriptor missing entirely — must error). const stubbedContext = { ...context, codecDescriptors: { @@ -159,18 +156,14 @@ describe('filters', () => { }); it('shorthandToWhereExpr() rejects equality-shorthand on a non-scalar field type', () => { - // When `fieldType?.kind !== 'scalar'` (e.g. the field doesn't have a - // codec id resolvable from a scalar type), the trait array is empty - // and the filter throws — this models a relation-shorthand attempt - // through the scalar code path. + // When `fieldType?.kind !== 'scalar'` (e.g. the field doesn't have a codec id resolvable from a scalar type), the trait array is empty and the filter throws — this models a relation-shorthand attempt through the scalar code path. expect(() => shorthandToWhereExpr(context, 'User', { posts: 'oops' } as never)).toThrow( /does not support equality comparisons/, ); }); it('shorthandToWhereExpr() rejects equality-shorthand when no descriptor is registered for the codec', () => { - // `descriptorFor` returns `undefined` — the trait array short- - // circuits to `[]` and `equality` is missing. + // `descriptorFor` returns `undefined` — the trait array short-circuits to `[]` and `equality` is missing. const stubbedContext = { ...context, codecDescriptors: { diff --git a/packages/3-extensions/sql-orm-client/test/integration/codec-async.test.ts b/packages/3-extensions/sql-orm-client/test/integration/codec-async.test.ts index a28998d9b7..496b3e3854 100644 --- a/packages/3-extensions/sql-orm-client/test/integration/codec-async.test.ts +++ b/packages/3-extensions/sql-orm-client/test/integration/codec-async.test.ts @@ -1,26 +1,14 @@ /** * End-to-end ORM-client coverage for the async-codec read/write boundary. * - * `Post.embedding` flows through the `pg/vector@1` codec from - * `@prisma-next/extension-pgvector`. The pgvector codec's `encode` and - * `decode` are authored synchronously, but the `codec()` factory in - * `relational-core` lifts them to Promise-returning at the boundary, so this - * column exercises the runtime's async dispatch path on every read and write. + * `Post.embedding` flows through the `pg/vector@1` codec from `@prisma-next/extension-pgvector`. The pgvector codec's `encode` and `decode` are authored synchronously, but the codec base in `framework-components` lifts them to Promise-returning at the boundary, so this column exercises the runtime's async dispatch path on every read and write. * - * `User.address` flows through `pg/jsonb@1` (a built-in `adapter-postgres` - * codec, also lifted to async by the same factory) backed by the `Address` - * value object. Adding a second codec with a different wire shape gives us - * "mixed sync/async codec columns" in a single integration run. + * `User.address` flows through `pg/jsonb@1` (a built-in `adapter-postgres` codec, also lifted to async by the same boundary) backed by the `Address` value object. Adding a second codec with a different wire shape gives us "mixed sync/async codec columns" in a single integration run. * * The tests below verify: * - * - **Read paths**: `.first()` and `for await (const row of c.all())` yield - * rows whose codec-decoded fields are plain `T` (not `Promise`) and - * whose values round-trip through the runtime decode boundary. - * - **Write paths**: `create()` and `update()` accept plain `T` for - * async-codec columns, run the value through the runtime's async encode - * path, and persist the wire format the codec produced (not a stringified - * Promise). + * - **Read paths**: `.first()` and `for await (const row of c.all())` yield rows whose codec-decoded fields are plain `T` (not `Promise`) and whose values round-trip through the runtime decode boundary. + * - **Write paths**: `create()` and `update()` accept plain `T` for async-codec columns, run the value through the runtime's async encode path, and persist the wire format the codec produced (not a stringified Promise). */ import { describe, expect, it } from 'vitest'; diff --git a/packages/3-extensions/sql-orm-client/test/integration/create.test.ts b/packages/3-extensions/sql-orm-client/test/integration/create.test.ts index 4b58247bd6..2ee01faabf 100644 --- a/packages/3-extensions/sql-orm-client/test/integration/create.test.ts +++ b/packages/3-extensions/sql-orm-client/test/integration/create.test.ts @@ -21,25 +21,37 @@ function expectInsertBatchAst( ): asserts ast is InsertAst { expect(ast).toBeInstanceOf(InsertAst); + const usersRefs = (column: string) => ({ table: 'users', column }); expect((ast as InsertAst).rows).toEqual([ { - id: ParamRef.of(rows[0]!.id, { name: 'id', codecId: 'pg/int4@1' }), - name: ParamRef.of(rows[0]!.name, { name: 'name', codecId: 'pg/text@1' }), + id: ParamRef.of(rows[0]!.id, { name: 'id', codecId: 'pg/int4@1', refs: usersRefs('id') }), + name: ParamRef.of(rows[0]!.name, { + name: 'name', + codecId: 'pg/text@1', + refs: usersRefs('name'), + }), email: ParamRef.of(rows[0]!.email, { name: 'email', codecId: 'pg/text@1', + refs: usersRefs('email'), }), invited_by_id: ParamRef.of(rows[0]!.invitedById ?? null, { name: 'invited_by_id', codecId: 'pg/int4@1', + refs: usersRefs('invited_by_id'), }), }, { - id: ParamRef.of(rows[1]!.id, { name: 'id', codecId: 'pg/int4@1' }), - name: ParamRef.of(rows[1]!.name, { name: 'name', codecId: 'pg/text@1' }), + id: ParamRef.of(rows[1]!.id, { name: 'id', codecId: 'pg/int4@1', refs: usersRefs('id') }), + name: ParamRef.of(rows[1]!.name, { + name: 'name', + codecId: 'pg/text@1', + refs: usersRefs('name'), + }), email: ParamRef.of(rows[1]!.email, { name: 'email', codecId: 'pg/text@1', + refs: usersRefs('email'), }), invited_by_id: rows[1]!.invitedById === undefined @@ -47,6 +59,7 @@ function expectInsertBatchAst( : ParamRef.of(rows[1]!.invitedById, { name: 'invited_by_id', codecId: 'pg/int4@1', + refs: usersRefs('invited_by_id'), }), }, ]); diff --git a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts index 5965a5d733..378e3d8e13 100644 --- a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts +++ b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts @@ -1,12 +1,9 @@ -import type { JsonValue } from '@prisma-next/contract/types'; import { createSqlOperationRegistry } from '@prisma-next/sql-operations'; -import type { CodecRegistry, CodecTrait } from '@prisma-next/sql-relational-core/ast'; +import type { CodecTrait } from '@prisma-next/sql-relational-core/ast'; import { AndExpr, BinaryExpr, ColumnRef, - codec, - createCodecRegistry, ExistsExpr, ListExpression, NotExpr, @@ -31,7 +28,7 @@ describe('createModelAccessor', () => { { columns: Record } | undefined >; const codecId = tables[table]?.columns[column]?.codecId; - return codecId ? ParamRef.of(value, { codecId }) : ParamRef.of(value); + return codecId ? ParamRef.of(value, { codecId, refs: { table, column } }) : ParamRef.of(value); } function expectBinaryParam( @@ -46,22 +43,6 @@ describe('createModelAccessor', () => { ); } - function makeRegistry(entries: Record): CodecRegistry { - const registry = createCodecRegistry(); - for (const [id, traits] of Object.entries(entries)) { - registry.register( - codec({ - typeId: id, - targetTypes: [], - traits, - encode: (v: JsonValue) => v, - decode: (v: JsonValue) => v, - }), - ); - } - return registry; - } - function makeDescriptors( entries: Record, ): typeof context.codecDescriptors { @@ -79,8 +60,8 @@ describe('createModelAccessor', () => { validate: (_value: unknown) => ({ value: undefined }), }, }, - // The trait-gating tests don't materialize codecs; the - // factory is shape-only and never invoked. + isParameterized: false, + // The trait-gating tests don't materialize codecs; the factory is shape-only and never invoked. factory: () => () => { throw new Error('test descriptor factory not exercised'); }, @@ -138,8 +119,7 @@ describe('createModelAccessor', () => { string, unknown >; - // Non-predicate return → ComparisonMethods wrapper; the underlying AST is - // behind the comparison methods. Invoke a comparison to observe it. + // Non-predicate return → ComparisonMethods wrapper; the underlying AST is behind the comparison methods. Invoke a comparison to observe it. const gt = (result['gt'] as (value: number) => BinaryExpr)(0.5); expect(gt).toBeInstanceOf(BinaryExpr); const opExpr = gt.left as OperationExpr; @@ -152,10 +132,7 @@ describe('createModelAccessor', () => { }); it('cosineDistance accepts another vector column and produces a ColumnRef on arg0 (cross-column composition)', () => { - // Cross-column composition: the second argument is another column handle - // (an Expression with buildAst → ColumnRef), not a raw JS value. - // The factory must detect it as an Expression and emit a ColumnRef, not a - // ParamRef wrapping the accessor object. + // Cross-column composition: the second argument is another column handle (an Expression with buildAst → ColumnRef), not a raw JS value. The factory must detect it as an Expression and emit a ColumnRef, not a ParamRef wrapping the accessor object. const post = createModelAccessor(context, 'Post'); const otherPost = createModelAccessor(context, 'Post'); @@ -263,14 +240,12 @@ describe('createModelAccessor', () => { const user = createModelAccessor(context, 'User'); expect((user as Record)[Symbol.iterator]).toBeUndefined(); - // Unknown fields in a shorthand predicate are surfaced loudly — silent - // skip would drop user intent (a typo'd filter would match every row). + // Unknown fields in a shorthand predicate are surfaced loudly — silent skip would drop user intent (a typo'd filter would match every row). expect(() => user['posts']!.some({ unknown: 'value' })).toThrow( /Shorthand filter on "Post\.unknown": field is not defined on the model/, ); - // Undefined values are skipped before the field lookup, so a shorthand - // with an unknown field and undefined value is a no-op. + // Undefined values are skipped before the field lookup, so a shorthand with an unknown field and undefined value is a no-op. const someUndefined = user['posts']!.some({ unknown: undefined }) as ExistsExpr; expect(someUndefined.subquery.where).toEqual( BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')), @@ -413,11 +388,7 @@ describe('createModelAccessor', () => { }, }; - // Contract claims the User model lives in `users_storage`, but - // storage.tables has no entry for it. The Proxy returns undefined for - // fields whose column cannot be resolved, matching plain JS object - // semantics. Downstream consumers (or TypeScript at compile time) are - // responsible for noticing the missing column. + // Contract claims the User model lives in `users_storage`, but storage.tables has no entry for it. The Proxy returns undefined for fields whose column cannot be resolved, matching plain JS object semantics. Downstream consumers (or TypeScript at compile time) are responsible for noticing the missing column. const accessor = createModelAccessor( { ...context, contract: storageFallbackContract } as never, 'User', @@ -489,9 +460,8 @@ describe('createModelAccessor', () => { describe('runtime trait-gating', () => { it('only creates equality methods when codec has equality trait', () => { - const codecs = makeRegistry({ 'pg/int4@1': ['equality'] }); const codecDescriptors = makeDescriptors({ 'pg/int4@1': ['equality'] }); - const accessor = createModelAccessor({ ...context, codecs, codecDescriptors }, 'Post'); + const accessor = createModelAccessor({ ...context, codecDescriptors }, 'Post'); const field = accessor['id'] as unknown as Record; expect(typeof field['eq']).toBe('function'); @@ -511,13 +481,10 @@ describe('createModelAccessor', () => { }); it('creates all methods when codec has all relevant traits', () => { - const codecs = makeRegistry({ - 'pg/text@1': ['equality', 'order', 'textual'], - }); const codecDescriptors = makeDescriptors({ 'pg/text@1': ['equality', 'order', 'textual'], }); - const accessor = createModelAccessor({ ...context, codecs, codecDescriptors }, 'User'); + const accessor = createModelAccessor({ ...context, codecDescriptors }, 'User'); const field = accessor['name'] as unknown as Record; for (const method of [ @@ -540,9 +507,8 @@ describe('createModelAccessor', () => { }); it('throws when relation shorthand filter targets a field without equality trait', () => { - const codecs = makeRegistry({ 'pg/int4@1': ['order'] }); const codecDescriptors = makeDescriptors({ 'pg/int4@1': ['order'] }); - const accessor = createModelAccessor({ ...context, codecs, codecDescriptors }, 'Post'); + const accessor = createModelAccessor({ ...context, codecDescriptors }, 'Post'); expect(() => accessor['comments']!.some({ postId: 42 })).toThrow( /does not support equality comparisons/, @@ -598,7 +564,12 @@ describe('createModelAccessor', () => { const opExpr = binary.left as unknown as OperationExpr; expect(opExpr.method).toBe('cosineDistance'); expect(opExpr.self).toEqual(ColumnRef.of('posts', 'embedding')); - expect(opExpr.args[0]).toEqual(ParamRef.of([1, 2, 3], { codecId: 'pg/vector@1' })); + expect(opExpr.args[0]).toEqual( + ParamRef.of([1, 2, 3], { + codecId: 'pg/vector@1', + refs: { table: 'posts', column: 'embedding' }, + }), + ); }); it('cosineDistance().asc() produces OrderByItem', () => { @@ -628,10 +599,9 @@ describe('createModelAccessor', () => { 'pg/int4@1': ['equality'], 'pg/bool@1': ['equality', 'boolean'], }; - const codecs = makeRegistry(traitsByCodec); const codecDescriptors = makeDescriptors(traitsByCodec); - const ctx = { ...context, queryOperations, codecs, codecDescriptors }; + const ctx = { ...context, queryOperations, codecDescriptors }; const user = createModelAccessor(ctx, 'User'); const post = createModelAccessor(ctx, 'Post'); diff --git a/packages/3-extensions/sql-orm-client/test/query-plan-meta.test.ts b/packages/3-extensions/sql-orm-client/test/query-plan-meta.test.ts index b08b53d402..fee9f88c7b 100644 --- a/packages/3-extensions/sql-orm-client/test/query-plan-meta.test.ts +++ b/packages/3-extensions/sql-orm-client/test/query-plan-meta.test.ts @@ -33,6 +33,14 @@ describe('query plan meta', () => { }); }); + it('omits profileHash from plan meta when the contract carries none', () => { + const { profileHash: _omit, ...rest } = baseContract; + const noProfileContract = rest as typeof baseContract; + const meta = buildOrmPlanMeta(noProfileContract); + expect(meta).not.toHaveProperty('profileHash'); + expect(meta).toMatchObject({ lane: 'orm-client' }); + }); + it('produces a plan whose meta carries no execution-metadata sidecars', () => { const ast = SelectAst.from(TableSource.named('users')) .withProjection([ diff --git a/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts b/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts index 05893f0695..58deaf2d49 100644 --- a/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts +++ b/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts @@ -1,18 +1,23 @@ import { + BinaryExpr, + ColumnRef, type DeleteAst, type DoUpdateSetConflictAction, type InsertAst, ParamRef, + ParamRef as ParamRefClass, type UpdateAst, } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { compileDeleteCount, + compileDeleteReturning, compileInsertCount, compileInsertCountSplit, compileInsertReturning, compileInsertReturningSplit, compileUpdateCount, + compileUpdateReturning, compileUpsertReturning, } from '../src/query-plan'; import { withReturningCapability } from './collection-fixtures'; @@ -34,6 +39,7 @@ function usersColParam( return ParamRef.of(value, { name: column, codecId: columnMeta?.codecId ?? 'unknown', + refs: { table: 'users', column }, }); } @@ -288,4 +294,93 @@ describe('query plan mutations', () => { expect((deletePlan.ast as DeleteAst).where).toBeUndefined(); expect(deletePlan.params).toEqual([]); }); + + describe('split helpers reject empty rows', () => { + it('compileInsertReturningSplit() rejects an empty rows array', () => { + const contract = withReturningCapability(getTestContract()); + expect(() => compileInsertReturningSplit(contract, 'users', [], undefined)).toThrowError( + /at least one row/, + ); + }); + + it('compileInsertCountSplit() rejects an empty rows array', () => { + const contract = getTestContract(); + expect(() => compileInsertCountSplit(contract, 'users', [])).toThrowError(/at least one row/); + }); + }); + + describe('UPDATE / DELETE WHERE preservation', () => { + function eqOnUserId(value: number) { + return BinaryExpr.eq( + ColumnRef.of('users', 'id'), + ParamRefClass.of(value, { + name: 'id', + codecId: 'pg/int4@1', + refs: { table: 'users', column: 'id' }, + }), + ); + } + + it('compileUpdateReturning() preserves WHERE when filters are present', () => { + const contract = withReturningCapability(getTestContract()); + const plan = compileUpdateReturning( + contract, + 'users', + { name: 'Alice' }, + [eqOnUserId(7)], + undefined, + ); + expect(plan.ast.kind).toBe('update'); + expect((plan.ast as UpdateAst).where).toBeDefined(); + expect(plan.params).toEqual(['Alice', 7]); + }); + + it('compileUpdateCount() preserves WHERE when filters are present', () => { + const contract = getTestContract(); + const plan = compileUpdateCount(contract, 'users', { name: 'Bob' }, [eqOnUserId(9)]); + expect((plan.ast as UpdateAst).where).toBeDefined(); + expect(plan.params).toEqual(['Bob', 9]); + }); + + it('compileDeleteReturning() preserves WHERE when filters are present and omits when empty', () => { + const contract = withReturningCapability(getTestContract()); + const planWithWhere = compileDeleteReturning(contract, 'users', [eqOnUserId(3)], undefined); + expect((planWithWhere.ast as DeleteAst).where).toBeDefined(); + expect(planWithWhere.params).toEqual([3]); + + const planNoWhere = compileDeleteReturning(contract, 'users', [], undefined); + expect((planNoWhere.ast as DeleteAst).where).toBeUndefined(); + expect(planNoWhere.params).toEqual([]); + }); + }); + + describe('table/column resolution errors', () => { + it('compileUpdateCount() rejects an unknown table', () => { + const contract = getTestContract(); + expect(() => compileUpdateCount(contract, 'missing_table', { name: 'X' }, [])).toThrowError( + /Unknown table "missing_table"/, + ); + }); + + it('compileUpdateCount() rejects an unknown column for the table', () => { + const contract = getTestContract(); + expect(() => + compileUpdateCount(contract, 'users', { not_a_real_column: 'X' }, []), + ).toThrowError(/Unknown column "not_a_real_column" in table "users"/); + }); + + it('compileInsertCount() rejects an unknown table', () => { + const contract = getTestContract(); + expect(() => compileInsertCount(contract, 'missing_table', [{ id: 1 }])).toThrowError( + /Unknown table "missing_table"/, + ); + }); + + it('compileInsertCount() rejects an unknown column on an insert row', () => { + const contract = getTestContract(); + expect(() => + compileInsertCount(contract, 'users', [{ id: 1, not_a_real_column: 'X' }]), + ).toThrowError(/Unknown column "not_a_real_column" in table "users"/); + }); + }); }); diff --git a/packages/3-extensions/sql-orm-client/test/repository.test.ts b/packages/3-extensions/sql-orm-client/test/repository.test.ts index eeecb0edc4..7fc512ce41 100644 --- a/packages/3-extensions/sql-orm-client/test/repository.test.ts +++ b/packages/3-extensions/sql-orm-client/test/repository.test.ts @@ -36,7 +36,13 @@ describe('Collection construction', () => { const scoped = collection.popular(); expect(scoped.state.filters).toHaveLength(1); expect(scoped.state.filters[0]).toEqual( - BinaryExpr.gt(ColumnRef.of('posts', 'views'), ParamRef.of(1000, { codecId: 'pg/int4@1' })), + BinaryExpr.gt( + ColumnRef.of('posts', 'views'), + ParamRef.of(1000, { + codecId: 'pg/int4@1', + refs: { table: 'posts', column: 'views' }, + }), + ), ); }); }); diff --git a/packages/3-extensions/sql-orm-client/test/rich-filters-and-where.test.ts b/packages/3-extensions/sql-orm-client/test/rich-filters-and-where.test.ts index f3df5da1f8..3d48c74350 100644 --- a/packages/3-extensions/sql-orm-client/test/rich-filters-and-where.test.ts +++ b/packages/3-extensions/sql-orm-client/test/rich-filters-and-where.test.ts @@ -42,7 +42,10 @@ describe('SQL ORM rich AST filters', () => { expect(nameFilter).toMatchObject({ op: 'eq', left: ColumnRef.of('users', 'name'), - right: ParamRef.of('Alice', { codecId: 'pg/text@1' }), + right: ParamRef.of('Alice', { + codecId: 'pg/text@1', + refs: { table: 'users', column: 'name' }, + }), }); expect(postsFilter?.kind).toBe('exists'); diff --git a/packages/3-extensions/sql-orm-client/test/test-codec.ts b/packages/3-extensions/sql-orm-client/test/test-codec.ts new file mode 100644 index 0000000000..3b8e5c00af --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/test-codec.ts @@ -0,0 +1,58 @@ +/** + * Test-only helper that constructs a SQL-family `Codec` instance from author-side encode/decode functions. Replaces the legacy public `mkCodec()` factory (deleted under TML-2357); tests that need a stub codec for behavioural assertions instantiate one through this helper rather than going through `descriptor.factory(...)`. + */ +import type { JsonValue } from '@prisma-next/contract/types'; +import type { CodecTrait } from '@prisma-next/framework-components/codec'; +import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; + +type JsonRoundTripConfig = [TInput] extends [JsonValue] + ? { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + } + : { + encodeJson: (value: TInput) => JsonValue; + decodeJson: (json: JsonValue) => TInput; + }; + +export function defineTestCodec< + Id extends string, + const TTraits extends readonly CodecTrait[] = readonly [], + TWire = unknown, + TInput = unknown, +>( + config: { + typeId: Id; + targetTypes?: readonly string[]; + encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; + decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + traits?: TTraits; + } & JsonRoundTripConfig, +): Codec { + const identity = (v: unknown) => v; + const userEncode = config.encode; + const userDecode = config.decode; + const widenedConfig = config as { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + }; + return { + id: config.typeId, + encode: (value, ctx) => { + try { + return Promise.resolve(userEncode(value, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + decode: (wire, ctx) => { + try { + return Promise.resolve(userDecode(wire, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, + decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + } as Codec; +} diff --git a/packages/3-mongo-target/1-mongo-target/src/exports/runtime.ts b/packages/3-mongo-target/1-mongo-target/src/exports/runtime.ts index 13fff755c7..cda35a174b 100644 --- a/packages/3-mongo-target/1-mongo-target/src/exports/runtime.ts +++ b/packages/3-mongo-target/1-mongo-target/src/exports/runtime.ts @@ -2,18 +2,13 @@ import type { RuntimeTargetDescriptor, RuntimeTargetInstance, } from '@prisma-next/framework-components/execution'; -import { createMongoCodecRegistry, type MongoCodecRegistry } from '@prisma-next/mongo-codec'; +import { type MongoCodecRegistry, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import { mongoTargetDescriptorMeta } from '../core/descriptor-meta'; export interface MongoRuntimeTargetInstance extends RuntimeTargetInstance<'mongo', 'mongo'> {} /** - * Target-mongo deliberately does NOT import `MongoRuntimeTargetDescriptor` - * from `@prisma-next/mongo-runtime`. The target package is a control-plane - * residence and must not pull the Mongo execution-plane package into its - * dependency closure. The runtime descriptor here is shaped to satisfy the - * framework's `RuntimeTargetDescriptor` plus the structural - * `MongoStaticContributions` (`codecs`) that `@prisma-next/mongo-runtime` + * Target-mongo deliberately does NOT import `MongoRuntimeTargetDescriptor` from `@prisma-next/mongo-runtime`. The target package is a control-plane residence and must not pull the Mongo execution-plane package into its dependency closure. The runtime descriptor here is shaped to satisfy the framework's `RuntimeTargetDescriptor` plus the structural `MongoStaticContributions` (`codecs`) that `@prisma-next/mongo-runtime` * consumers narrow to at composition time. */ const mongoRuntimeTargetDescriptor: RuntimeTargetDescriptor< @@ -24,9 +19,8 @@ const mongoRuntimeTargetDescriptor: RuntimeTargetDescriptor< readonly codecs: () => MongoCodecRegistry; } = { ...mongoTargetDescriptorMeta, - // The target descriptor itself contributes no codecs — the standard set - // lives on the adapter descriptor (see `@prisma-next/adapter-mongo/runtime`). - codecs: () => createMongoCodecRegistry(), + // The target descriptor itself contributes no codecs — the standard set lives on the adapter descriptor (see `@prisma-next/adapter-mongo/runtime`). + codecs: () => newMongoCodecRegistry(), create(): MongoRuntimeTargetInstance { return { familyId: 'mongo', diff --git a/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts b/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts index 68932213d9..cd1cf674ad 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/core/codecs.ts @@ -1,7 +1,10 @@ +import type { CodecDescriptor, CodecTrait } from '@prisma-next/framework-components/codec'; +import { voidParamsSchema } from '@prisma-next/framework-components/codec'; import { - createMongoCodecRegistry, + type MongoCodec, type MongoCodecRegistry, mongoCodec, + newMongoCodecRegistry, } from '@prisma-next/mongo-codec'; import { ObjectId } from 'mongodb'; import { @@ -16,48 +19,36 @@ import { export const mongoObjectIdCodec = mongoCodec({ typeId: MONGO_OBJECTID_CODEC_ID, - targetTypes: ['objectId'], - traits: ['equality'], decode: (wire: ObjectId) => wire.toHexString(), encode: (value: string) => new ObjectId(value), }); export const mongoStringCodec = mongoCodec({ typeId: MONGO_STRING_CODEC_ID, - targetTypes: ['string'], - traits: ['equality', 'order', 'textual'], decode: (wire: string) => wire, encode: (value: string) => value, }); export const mongoDoubleCodec = mongoCodec({ typeId: MONGO_DOUBLE_CODEC_ID, - targetTypes: ['double'], - traits: ['equality', 'order', 'numeric'], decode: (wire: number) => wire, encode: (value: number) => value, }); export const mongoInt32Codec = mongoCodec({ typeId: MONGO_INT32_CODEC_ID, - targetTypes: ['int'], - traits: ['equality', 'order', 'numeric'], decode: (wire: number) => wire, encode: (value: number) => value, }); export const mongoBooleanCodec = mongoCodec({ typeId: MONGO_BOOLEAN_CODEC_ID, - targetTypes: ['bool'], - traits: ['equality', 'boolean'], decode: (wire: boolean) => wire, encode: (value: boolean) => value, }); export const mongoDateCodec = mongoCodec({ typeId: MONGO_DATE_CODEC_ID, - targetTypes: ['date'], - traits: ['equality', 'order'], decode: (wire: Date) => wire, encode: (value: Date) => value, encodeJson: (value: Date) => value.toISOString(), @@ -69,25 +60,14 @@ export const mongoDateCodec = mongoCodec({ export const mongoVectorCodec = mongoCodec({ typeId: MONGO_VECTOR_CODEC_ID, - targetTypes: ['vector'], - traits: ['equality'], decode: (wire: readonly number[]) => wire, encode: (value: readonly number[]) => value, - renderOutputType: (typeParams) => { - const length = typeParams['length']; - if (length === undefined) return undefined; - if (typeof length !== 'number' || !Number.isFinite(length) || !Number.isInteger(length)) { - throw new Error('renderOutputType: expected positive integer "length" for Vector'); - } - return `Vector<${length}>`; - }, }); /** * The canonical set of Mongo wire-type codecs. * - * Single source of truth for both control- and runtime-plane adapter - * descriptors. Don't duplicate this list — import it. + * Single source of truth for both control- and runtime-plane adapter descriptors. Don't duplicate this list — import it. */ export const mongoStandardCodecs = [ mongoObjectIdCodec, @@ -100,17 +80,87 @@ export const mongoStandardCodecs = [ ] as const; /** - * Build a {@link MongoCodecRegistry} preloaded with the standard Mongo - * wire-type codecs. + * Build a {@link CodecDescriptor} for a Mongo wire-type codec. + * + * Wraps an existing {@link MongoCodec} instance into a descriptor whose factory hands out the same shared codec. Mongo's full migration to descriptor-first authoring is tracked under TML-2324; for now the descriptor view is composed from the existing `mongoCodec()` outputs. + */ +function descriptorFor( + codec: MongoCodec, + metadata: { + readonly traits: readonly CodecTrait[]; + readonly targetTypes: readonly string[]; + readonly renderOutputType?: (typeParams: Record) => string | undefined; + }, +): CodecDescriptor { + // The descriptor's `P` is structurally `Record` for codecs that take params (Mongo `vector`); non-parameterized codecs ignore the slot. Cast through `unknown` to fit the `CodecDescriptor` slot's `(params: P) => …` typing without leaking a per-codec `P` into the heterogeneous descriptor list. + const renderOutputType = metadata.renderOutputType as + | CodecDescriptor['renderOutputType'] + | undefined; + return { + codecId: codec.id, + traits: metadata.traits, + targetTypes: metadata.targetTypes, + paramsSchema: voidParamsSchema as CodecDescriptor['paramsSchema'], + isParameterized: false, + factory: (() => () => codec) as CodecDescriptor['factory'], + ...(renderOutputType !== undefined ? { renderOutputType } : {}), + }; +} + +const renderVectorOutputType = (typeParams: Record): string | undefined => { + const length = typeParams['length']; + if (length === undefined) return undefined; + if ( + typeof length !== 'number' || + !Number.isFinite(length) || + !Number.isInteger(length) || + length <= 0 + ) { + throw new Error('renderOutputType: expected positive integer "length" for Vector'); + } + return `Vector<${length}>`; +}; + +/** + * Mongo wire-type codec descriptors. Static metadata for `traits`, `targetTypes`, and `renderOutputType` lives here (the descriptor shape) — `MongoCodec` itself is narrow and only carries the four conversion methods (TML-2357). + */ +export const mongoCodecDescriptors: ReadonlyArray = [ + descriptorFor(mongoObjectIdCodec, { traits: ['equality'], targetTypes: ['objectId'] }), + descriptorFor(mongoStringCodec, { + traits: ['equality', 'order', 'textual'], + targetTypes: ['string'], + }), + descriptorFor(mongoDoubleCodec, { + traits: ['equality', 'order', 'numeric'], + targetTypes: ['double'], + }), + descriptorFor(mongoInt32Codec, { + traits: ['equality', 'order', 'numeric'], + targetTypes: ['int'], + }), + descriptorFor(mongoBooleanCodec, { traits: ['equality', 'boolean'], targetTypes: ['bool'] }), + descriptorFor(mongoDateCodec, { traits: ['equality', 'order'], targetTypes: ['date'] }), + descriptorFor(mongoVectorCodec, { + traits: ['equality'], + targetTypes: ['vector'], + renderOutputType: renderVectorOutputType, + }), +]; + +/** + * Lookup descriptor metadata by codec id — used by tests and for descriptor-side reads of static metadata. + */ +export function mongoDescriptorById(codecId: string): CodecDescriptor | undefined { + return mongoCodecDescriptors.find((d) => d.codecId === codecId); +} + +/** + * Build a {@link MongoCodecRegistry} preloaded with the standard Mongo wire-type codecs. * - * Single point of truth for adapter-side codec construction: used by the - * legacy synchronous `createMongoAdapter()` factory and by the runtime - * adapter descriptor's `codecs()` getter. Userland code obtains a registry - * via the framework's execution-stack composition (see - * `createMongoExecutionContext`) instead of calling this directly. + * Single point of truth for adapter-side codec construction: used by the legacy synchronous `createMongoAdapter()` factory and by the runtime adapter descriptor's `codecs()` getter. Userland code obtains a registry via the framework's execution-stack composition (see `createMongoExecutionContext`) instead of calling this directly. */ export function buildStandardCodecRegistry(): MongoCodecRegistry { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); for (const codec of mongoStandardCodecs) { registry.register(codec); } diff --git a/packages/3-mongo-target/2-mongo-adapter/src/core/introspect-schema.ts b/packages/3-mongo-target/2-mongo-adapter/src/core/introspect-schema.ts index 2c5a04baf8..f42aa50754 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/core/introspect-schema.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/core/introspect-schema.ts @@ -18,7 +18,12 @@ function parseIndexKeys(keySpec: Record): MongoIndexKey[] { return keys; } -function isDefaultIdIndex(doc: Document): boolean { +/** + * Exported for unit tests to exercise the defensive `!key` guard; not part of + * the public API. Callers in this package use it via the `introspectSchema` + * pipeline only. + */ +export function isDefaultIdIndex(doc: Document): boolean { const key = doc['key'] as Record | undefined; if (!key) return false; const entries = Object.entries(key); diff --git a/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts b/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts index 074a4fc9a6..89aa442f1a 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts @@ -8,7 +8,7 @@ export { } from '../core/mongo-control-driver'; export { createMongoRunnerDeps, extractDb } from '../core/runner-deps'; -import { mongoStandardCodecs } from '../core/codecs'; +import { mongoCodecDescriptors } from '../core/codecs'; const mongoAdapterDescriptor: ControlAdapterDescriptor<'mongo', 'mongo'> = { kind: 'adapter', @@ -26,7 +26,7 @@ const mongoAdapterDescriptor: ControlAdapterDescriptor<'mongo', 'mongo'> = { ]), types: { codecTypes: { - codecInstances: [...mongoStandardCodecs], + codecDescriptors: mongoCodecDescriptors, import: { package: '@prisma-next/adapter-mongo/codec-types', named: 'CodecTypes', diff --git a/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts b/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts index 86970a38f9..16f3caf007 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts @@ -5,21 +5,12 @@ import type { } from '@prisma-next/framework-components/execution'; import type { MongoCodecRegistry } from '@prisma-next/mongo-codec'; import type { MongoAdapter } from '@prisma-next/mongo-lowering'; -import { buildStandardCodecRegistry, mongoStandardCodecs } from '../core/codecs'; +import { buildStandardCodecRegistry, mongoCodecDescriptors } from '../core/codecs'; import { createMongoAdapter } from '../mongo-adapter'; /** - * adapter-mongo deliberately does NOT import the - * `MongoRuntimeAdapterDescriptor` type alias from - * `@prisma-next/mongo-runtime`. The adapter package is downstream of the - * Mongo runtime package only conceptually; introducing a hard import would - * create a workspace dependency cycle (`mongo-runtime` consumes the runtime - * descriptor's `create(stack)` factory; `adapter-mongo` would then need - * `mongo-runtime` to type the descriptor). The descriptor is shaped to - * satisfy the framework's `RuntimeAdapterDescriptor` plus the structural - * `MongoStaticContributions` (`codecs()`) that `@prisma-next/mongo-runtime` - * narrows to at composition time. This mirrors the `target-postgres` ↔ - * `sql-runtime` decoupling pattern. + * adapter-mongo deliberately does NOT import the `MongoRuntimeAdapterDescriptor` type alias from `@prisma-next/mongo-runtime`. The adapter package is downstream of the Mongo runtime package only conceptually; introducing a hard import would create a workspace dependency cycle (`mongo-runtime` consumes the runtime descriptor's `create(stack)` factory; `adapter-mongo` would then need `mongo-runtime` to type the + * descriptor). The descriptor is shaped to satisfy the framework's `RuntimeAdapterDescriptor` plus the structural `MongoStaticContributions` (`codecs()`) that `@prisma-next/mongo-runtime` narrows to at composition time. This mirrors the `target-postgres` ↔ `sql-runtime` decoupling pattern. */ interface MongoRuntimeAdapterInstance @@ -40,7 +31,7 @@ const mongoRuntimeAdapterDescriptor: RuntimeAdapterDescriptor< version: '0.0.1', types: { codecTypes: { - codecInstances: [...mongoStandardCodecs], + codecDescriptors: mongoCodecDescriptors, }, }, codecs: buildStandardCodecRegistry, diff --git a/packages/3-mongo-target/2-mongo-adapter/test/codecs.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/codecs.test.ts index 0df250cafd..f2470a682b 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/codecs.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/codecs.test.ts @@ -5,6 +5,7 @@ import { MONGO_DOUBLE_CODEC_ID, MONGO_VECTOR_CODEC_ID } from '../src/core/codec- import { mongoBooleanCodec, mongoDateCodec, + mongoDescriptorById, mongoDoubleCodec, mongoInt32Codec, mongoObjectIdCodec, @@ -72,33 +73,45 @@ describe('mongoDateCodec', () => { }); }); -describe('codec traits', () => { +describe('codec traits (descriptor-side)', () => { it('objectId has equality trait', () => { - expect(mongoObjectIdCodec.traits).toEqual(['equality']); + expect(mongoDescriptorById(mongoObjectIdCodec.id)?.traits).toEqual(['equality']); }); it('string has equality, order, textual traits', () => { - expect(mongoStringCodec.traits).toEqual(['equality', 'order', 'textual']); + expect(mongoDescriptorById(mongoStringCodec.id)?.traits).toEqual([ + 'equality', + 'order', + 'textual', + ]); }); it('int32 has equality, order, numeric traits', () => { - expect(mongoInt32Codec.traits).toEqual(['equality', 'order', 'numeric']); + expect(mongoDescriptorById(mongoInt32Codec.id)?.traits).toEqual([ + 'equality', + 'order', + 'numeric', + ]); }); it('double has equality, order, numeric traits', () => { - expect(mongoDoubleCodec.traits).toEqual(['equality', 'order', 'numeric']); + expect(mongoDescriptorById(mongoDoubleCodec.id)?.traits).toEqual([ + 'equality', + 'order', + 'numeric', + ]); }); it('boolean has equality, boolean traits', () => { - expect(mongoBooleanCodec.traits).toEqual(['equality', 'boolean']); + expect(mongoDescriptorById(mongoBooleanCodec.id)?.traits).toEqual(['equality', 'boolean']); }); it('date has equality, order traits', () => { - expect(mongoDateCodec.traits).toEqual(['equality', 'order']); + expect(mongoDescriptorById(mongoDateCodec.id)?.traits).toEqual(['equality', 'order']); }); it('vector has equality trait', () => { - expect(mongoVectorCodec.traits).toEqual(['equality']); + expect(mongoDescriptorById(mongoVectorCodec.id)?.traits).toEqual(['equality']); }); }); @@ -143,29 +156,65 @@ describe('mongoDateCodec', () => { }); }); -describe('mongoVectorCodec.renderOutputType', () => { +describe('mongo vector descriptor renderOutputType', () => { + // The descriptor list is heterogeneous (`CodecDescriptor` with default `P = void`); the per-codec `P` for vector is `Record` — narrow back here to invoke the renderer with concrete typeParams. + const renderVector = mongoDescriptorById(mongoVectorCodec.id)?.renderOutputType as + | ((typeParams: Record) => string | undefined) + | undefined; + it('renders Vector when length is present', () => { - expect(mongoVectorCodec.renderOutputType!({ length: 1536 })).toBe('Vector<1536>'); + expect(renderVector?.({ length: 1536 })).toBe('Vector<1536>'); }); it('renders Vector with small dimension', () => { - expect(mongoVectorCodec.renderOutputType!({ length: 3 })).toBe('Vector<3>'); + expect(renderVector?.({ length: 3 })).toBe('Vector<3>'); }); it('returns undefined when length is absent', () => { - expect(mongoVectorCodec.renderOutputType!({})).toBeUndefined(); + expect(renderVector?.({})).toBeUndefined(); }); it('throws on NaN length', () => { - expect(() => mongoVectorCodec.renderOutputType!({ length: Number.NaN })).toThrow( + expect(() => renderVector?.({ length: Number.NaN })).toThrow( /expected positive integer "length"/, ); }); it('throws on non-integer length', () => { - expect(() => mongoVectorCodec.renderOutputType!({ length: 3.5 })).toThrow( - /expected positive integer "length"/, - ); + expect(() => renderVector?.({ length: 3.5 })).toThrow(/expected positive integer "length"/); + }); + + it('throws on zero length', () => { + expect(() => renderVector?.({ length: 0 })).toThrow(/expected positive integer "length"/); + }); + + it('throws on negative length', () => { + expect(() => renderVector?.({ length: -1 })).toThrow(/expected positive integer "length"/); + }); +}); + +describe('mongo descriptor factory', () => { + it('descriptor.factory()(ctx) returns the underlying MongoCodec', () => { + const descriptor = mongoDescriptorById(mongoStringCodec.id); + expect(descriptor).toBeDefined(); + if (!descriptor) return; + const make = (descriptor.factory as () => () => unknown)(); + const codec = make(); + expect(codec).toBe(mongoStringCodec); + }); + + it('every standard mongo codec descriptor materializes its codec via factory', () => { + for (const descriptor of [ + mongoDescriptorById(mongoObjectIdCodec.id), + mongoDescriptorById(mongoVectorCodec.id), + mongoDescriptorById(mongoDateCodec.id), + ]) { + expect(descriptor).toBeDefined(); + if (!descriptor) continue; + const make = (descriptor.factory as () => () => unknown)(); + expect(typeof make).toBe('function'); + expect(make()).toBeDefined(); + } }); }); @@ -176,8 +225,7 @@ describe('vector operation descriptors (production-defined)', () => { }); it('mongoVectorNearOperation.impl returns undefined as a placeholder', () => { - // Mongo does not yet lower the vector `near` operation; the impl is a - // placeholder so the descriptor satisfies the shared shape. + // Mongo does not yet lower the vector `near` operation; the impl is a placeholder so the descriptor satisfies the shared shape. expect((mongoVectorNearOperation.impl as () => unknown)()).toBeUndefined(); }); diff --git a/packages/3-mongo-target/2-mongo-adapter/test/introspect-schema.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/introspect-schema.test.ts index 84a4bfcfe7..fab069db79 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/introspect-schema.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/introspect-schema.test.ts @@ -9,7 +9,7 @@ import { import { type Db, MongoClient } from 'mongodb'; import { MongoMemoryReplSet } from 'mongodb-memory-server'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { introspectSchema } from '../src/core/introspect-schema'; +import { introspectSchema, isDefaultIdIndex } from '../src/core/introspect-schema'; let replSet: MongoMemoryReplSet; let client: MongoClient; @@ -246,4 +246,133 @@ describe('introspectSchema', () => { } }); }); + + describe('defensive parsing paths (synthesized inputs)', () => { + type IndexDoc = Record; + type CollectionInfo = Record; + + function makeDb( + collections: ReadonlyArray<{ + readonly info: CollectionInfo; + readonly indexes: readonly IndexDoc[]; + }>, + ): Db { + const fake = { + listCollections: () => ({ + toArray: async () => collections.map((c) => c.info), + }), + collection: (name: string) => ({ + listIndexes: () => ({ + toArray: async () => { + const found = collections.find( + (c) => (c.info['name'] as string | undefined) === name, + ); + return found ? [...found.indexes] : []; + }, + }), + }), + }; + return fake as unknown as Db; + } + + it('isDefaultIdIndex returns false when the doc lacks a `key` field', () => { + expect(isDefaultIdIndex({ name: 'no_key' })).toBe(false); + }); + + it('isDefaultIdIndex returns true for the canonical _id_ index spec', () => { + expect(isDefaultIdIndex({ key: { _id: 1 } })).toBe(true); + }); + + it('treats a non-jsonSchema validator as no validator', async () => { + const fakeDb = makeDb([ + { + info: { + name: 'audit', + options: { validator: { $expr: { $gt: ['$amount', 0] } } }, + }, + indexes: [], + }, + ]); + const ir = await introspectSchema(fakeDb); + expect(ir.collection('audit')!.validator).toBeUndefined(); + }); + + it('falls back to validation defaults when level/action are absent', async () => { + const fakeDb = makeDb([ + { + info: { + name: 'products', + options: { validator: { $jsonSchema: { bsonType: 'object' } } }, + }, + indexes: [], + }, + ]); + const ir = await introspectSchema(fakeDb); + const validator = ir.collection('products')!.validator!; + expect(validator.validationLevel).toBe('strict'); + expect(validator.validationAction).toBe('error'); + }); + + it('returns no collection options when the info has no options bag', async () => { + const fakeDb = makeDb([{ info: { name: 'plain' }, indexes: [] }]); + const ir = await introspectSchema(fakeDb); + expect(ir.collection('plain')!.options).toBeUndefined(); + }); + + it('forwards timeseries options when present', async () => { + const fakeDb = makeDb([ + { + info: { + name: 'metrics', + options: { + timeseries: { timeField: 'ts', metaField: 'tags', granularity: 'minutes' }, + }, + }, + indexes: [], + }, + ]); + const ir = await introspectSchema(fakeDb); + expect(ir.collection('metrics')!.options!.timeseries).toEqual({ + timeField: 'ts', + metaField: 'tags', + granularity: 'minutes', + }); + }); + + it('forwards collation, changeStreamPreAndPostImages, and clusteredIndex options when present', async () => { + const fakeDb = makeDb([ + { + info: { + name: 'configured', + options: { + collation: { locale: 'en_US' }, + changeStreamPreAndPostImages: { enabled: true }, + clusteredIndex: { name: 'ix' }, + }, + }, + indexes: [], + }, + ]); + const ir = await introspectSchema(fakeDb); + const opts = ir.collection('configured')!.options!; + expect(opts.collation).toEqual({ locale: 'en_US' }); + expect(opts.changeStreamPreAndPostImages).toEqual({ enabled: true }); + expect(opts.clusteredIndex).toEqual({ name: 'ix' }); + }); + + it('keeps capped options without `max` when max is omitted', async () => { + const fakeDb = makeDb([ + { + info: { + name: 'capped_no_max', + options: { capped: true, size: 4096 }, + }, + indexes: [], + }, + ]); + const ir = await introspectSchema(fakeDb); + const capped = ir.collection('capped_no_max')!.options!.capped; + expect(capped).toEqual({ size: 4096 }); + }); + }); }); diff --git a/packages/3-mongo-target/2-mongo-adapter/test/lowering.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/lowering.test.ts index 8bd6ecc784..17dada7658 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/lowering.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/lowering.test.ts @@ -1,5 +1,5 @@ import type { CodecCallContext } from '@prisma-next/framework-components/codec'; -import { createMongoCodecRegistry, mongoCodec } from '@prisma-next/mongo-codec'; +import { mongoCodec, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import { MongoAddFieldsStage, MongoAggAccumulator, @@ -51,9 +51,8 @@ import { MongoParamRef } from '@prisma-next/mongo-value'; import { describe, expect, it } from 'vitest'; import { lowerAggExpr, lowerFilter, lowerPipeline, lowerStage } from '../src/lowering'; -// Default fixtures: tests that don't exercise codecs use an empty registry -// and an empty ctx. Tests that need codec encoding shadow `registry` locally. -const registry = createMongoCodecRegistry(); +// Default fixtures: tests that don't exercise codecs use an empty registry and an empty ctx. Tests that need codec encoding shadow `registry` locally. +const registry = newMongoCodecRegistry(); const ctx: CodecCallContext = {}; describe('lowerFilter', () => { @@ -149,11 +148,10 @@ describe('lowerFilter', () => { }); it('encodes MongoParamRef field-filter values via the codec registry when provided', async () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/uppercase@1', - targetTypes: ['string'], decode: (wire: string) => wire, encode: (value: string) => value.toUpperCase(), }), @@ -166,11 +164,10 @@ describe('lowerFilter', () => { }); it('forwards the codec registry through composite filters', async () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/uppercase@1', - targetTypes: ['string'], decode: (wire: string) => wire, encode: (value: string) => value.toUpperCase(), }), @@ -192,11 +189,10 @@ describe('lowerFilter', () => { }); it('passes the registry through $match in a pipeline', async () => { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/uppercase@1', - targetTypes: ['string'], decode: (wire: string) => wire, encode: (value: string) => value.toUpperCase(), }), diff --git a/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter-ctx.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter-ctx.test.ts index 286cfa9152..c4da3b9cf1 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter-ctx.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter-ctx.test.ts @@ -1,5 +1,5 @@ import type { CodecCallContext } from '@prisma-next/framework-components/codec'; -import { createMongoCodecRegistry, mongoCodec } from '@prisma-next/mongo-codec'; +import { mongoCodec, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import { AggregateCommand, DeleteOneCommand, @@ -22,11 +22,10 @@ const baseMeta = { }; function recordingRegistry(observed: (CodecCallContext | undefined)[]) { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/recorder@1', - targetTypes: ['string'], decode: (w: string) => w, encode: (v: string, ctx?: CodecCallContext) => { observed.push(ctx); @@ -193,11 +192,10 @@ describe('MongoAdapter — CodecCallContext threading', () => { let callCount = 0; const adapter = _unstable_createMongoAdapterWithCodecs( (() => { - const reg = createMongoCodecRegistry(); + const reg = newMongoCodecRegistry(); reg.register( mongoCodec({ typeId: 'test/counter@1', - targetTypes: ['string'], decode: (w: string) => w, encode: (v: string) => { callCount += 1; diff --git a/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter.test.ts index 9a1bcc34ad..3391bf4ae8 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/mongo-adapter.test.ts @@ -1,4 +1,4 @@ -import { createMongoCodecRegistry, mongoCodec } from '@prisma-next/mongo-codec'; +import { mongoCodec, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import type { MongoAdapter } from '@prisma-next/mongo-lowering'; import type { AnyMongoCommand } from '@prisma-next/mongo-query-ast/execution'; import { @@ -379,13 +379,12 @@ describe('MongoAdapter', () => { describe('MongoAdapter with codec registry', () => { const uppercaseCodec = mongoCodec({ typeId: 'test/uppercase@1', - targetTypes: ['string'], decode: (wire: string) => wire.toLowerCase(), encode: (value: string) => value.toUpperCase(), }); function registryWithUppercase() { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(uppercaseCodec); return registry; } @@ -457,10 +456,7 @@ describe('MongoAdapter with codec registry', () => { }); }); -// Regression: createMongoAdapter() must remain synchronous. Even though -// the adapter's `lower()` method is async, the construction path stays -// sync so that `mongo({...})` clients can be instantiated without -// `await`. +// Regression: createMongoAdapter() must remain synchronous. Even though the adapter's `lower()` method is async, the construction path stays sync so that `mongo({...})` clients can be instantiated without `await`. describe('createMongoAdapter (sync construction regression)', () => { it('returns a non-Promise adapter at runtime', () => { const adapter = createMongoAdapter(); @@ -470,9 +466,7 @@ describe('createMongoAdapter (sync construction regression)', () => { }); it('binds to a synchronous MongoAdapter type at the call site', () => { - // Compile-time guard: createMongoAdapter must return MongoAdapter directly, - // never a Promise. If it ever becomes Promise-returning, this fails to - // compile (caught by the test-file typecheck pass). + // Compile-time guard: createMongoAdapter must return MongoAdapter directly, never a Promise. If it ever becomes Promise-returning, this fails to compile (caught by the test-file typecheck pass). expectTypeOf>().toEqualTypeOf(); expectTypeOf>().not.toEqualTypeOf< Promise diff --git a/packages/3-mongo-target/2-mongo-adapter/test/resolve-value-ctx.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/resolve-value-ctx.test.ts index 5966b8a87e..29a190d8c0 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/resolve-value-ctx.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/resolve-value-ctx.test.ts @@ -1,5 +1,5 @@ import type { CodecCallContext } from '@prisma-next/framework-components/codec'; -import { createMongoCodecRegistry, mongoCodec } from '@prisma-next/mongo-codec'; +import { mongoCodec, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import { MongoParamRef } from '@prisma-next/mongo-value'; import { describe, expect, it } from 'vitest'; import { resolveValue } from '../src/resolve-value'; @@ -21,11 +21,10 @@ function deferred(): { describe('resolveValue — CodecCallContext threading', () => { it('forwards the same ctx instance to every codec.encode (root-level leaf)', async () => { const observed: (CodecCallContext | undefined)[] = []; - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/observe@1', - targetTypes: ['string'], decode: (w: string) => w, encode: (v: string, ctx?: CodecCallContext) => { observed.push(ctx); @@ -42,11 +41,10 @@ describe('resolveValue — CodecCallContext threading', () => { it('preserves ctx identity across nested object/array branches (recursive walk)', async () => { const observed: (CodecCallContext | undefined)[] = []; - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/observe-recursive@1', - targetTypes: ['string'], decode: (w: string) => w, encode: (v: string, ctx?: CodecCallContext) => { observed.push(ctx); @@ -78,11 +76,10 @@ describe('resolveValue — CodecCallContext threading', () => { it('1-arg codec authors observe no behavioral change when the ctx has no signal', async () => { let invoked = 0; let receivedValue: unknown; - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/single-arg-author@1', - targetTypes: ['string'], decode: (w: string) => w, encode: (v: string) => { invoked += 1; @@ -104,11 +101,10 @@ describe('resolveValue — CodecCallContext threading', () => { it('already-aborted signal at entry short-circuits before any codec.encode call', async () => { let callCount = 0; - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/counter@1', - targetTypes: ['string'], decode: (w: string) => w, encode: (v: string) => { callCount += 1; @@ -136,11 +132,10 @@ describe('resolveValue — CodecCallContext threading', () => { it('mid-encode abort surfaces RUNTIME.ABORTED { phase: encode } via the framework race helper', async () => { const release = deferred(); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/blocking@1', - targetTypes: ['string'], decode: (w: string) => w, encode: (v: string) => release.promise.then((suffix) => `${v}:${suffix}`), }), @@ -165,13 +160,12 @@ describe('resolveValue — CodecCallContext threading', () => { release.resolve('done'); }); - it('passes RUNTIME.ENCODE_FAILED from a codec body through unchanged when the body throws before the runtime sees the abort (AC-ERR4)', async () => { + it('passes RUNTIME.ENCODE_FAILED from a codec body through unchanged when the body throws before the runtime sees the abort', async () => { const cause = new Error('codec specific failure'); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/explody@1', - targetTypes: ['string'], decode: (w: string) => w, encode: () => { throw cause; @@ -191,11 +185,10 @@ describe('resolveValue — CodecCallContext threading', () => { it('races each per-level Promise.all against the signal — abort wins even when sibling leaves block forever', async () => { const blockingLeaf = deferred(); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/level-blocker@1', - targetTypes: ['string'], decode: (w: string) => w, encode: (v: string) => blockingLeaf.promise.then((s) => `${v}:${s}`), }), @@ -204,8 +197,7 @@ describe('resolveValue — CodecCallContext threading', () => { const controller = new AbortController(); const reason = new Error('level race wins'); - // Top-level object with two leaves at the same level — Promise.all races - // against the abort signal at this level. + // Top-level object with two leaves at the same level — Promise.all races against the abort signal at this level. const doc = { a: new MongoParamRef('a', { codecId: 'test/level-blocker@1' }), b: new MongoParamRef('b', { codecId: 'test/level-blocker@1' }), @@ -225,11 +217,10 @@ describe('resolveValue — CodecCallContext threading', () => { it('races inside arrays too — abort wins even when array leaves block forever', async () => { const blockingLeaf = deferred(); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register( mongoCodec({ typeId: 'test/array-blocker@1', - targetTypes: ['string'], decode: (w: string) => w, encode: (v: string) => blockingLeaf.promise.then((s) => `${v}:${s}`), }), diff --git a/packages/3-mongo-target/2-mongo-adapter/test/resolve-value.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/resolve-value.test.ts index 2346cbfa0f..e1fecb5d23 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/resolve-value.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/resolve-value.test.ts @@ -1,4 +1,4 @@ -import { createMongoCodecRegistry, mongoCodec } from '@prisma-next/mongo-codec'; +import { mongoCodec, newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import { MongoParamRef } from '@prisma-next/mongo-value'; import { describe, expect, it } from 'vitest'; import { resolveValue } from '../src/resolve-value'; @@ -11,19 +11,18 @@ interface RuntimeErrorShape extends Error { const uppercaseCodec = mongoCodec({ typeId: 'test/uppercase@1', - targetTypes: ['string'], decode: (wire: string) => wire.toLowerCase(), encode: (value: string) => value.toUpperCase(), }); function testRegistry() { - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(uppercaseCodec); return registry; } function emptyRegistry() { - return createMongoCodecRegistry(); + return newMongoCodecRegistry(); } const noCtx = {} as const; @@ -103,7 +102,6 @@ describe('resolveValue', () => { const asyncACodec = mongoCodec({ typeId: 'test/async-a@1', - targetTypes: ['string'], decode: (wire: string) => wire, encode: (value: string) => { callOrder.push('encode-a-start'); @@ -112,7 +110,6 @@ describe('resolveValue', () => { }); const asyncBCodec = mongoCodec({ typeId: 'test/async-b@1', - targetTypes: ['string'], decode: (wire: string) => wire, encode: (value: string) => { callOrder.push('encode-b-start'); @@ -120,7 +117,7 @@ describe('resolveValue', () => { }, }); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(asyncACodec); registry.register(asyncBCodec); @@ -131,8 +128,7 @@ describe('resolveValue', () => { const resultPromise = resolveValue(doc, registry, noCtx); - // Both encode functions must have started before either resolves — - // i.e. dispatch is concurrent, not sequential. + // Both encode functions must have started before either resolves — i.e. dispatch is concurrent, not sequential. await new Promise((r) => setImmediate(r)); expect(callOrder).toEqual(['encode-a-start', 'encode-b-start']); @@ -151,7 +147,6 @@ describe('resolveValue', () => { const codec = mongoCodec({ typeId: 'test/seq@1', - targetTypes: ['string'], decode: (w: string) => w, encode: async (value: string) => { callOrder.push(`start:${value}`); @@ -160,7 +155,7 @@ describe('resolveValue', () => { }, }); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(codec); const arr = [ @@ -192,13 +187,12 @@ describe('resolveValue', () => { it('wraps codec.encode failures in RUNTIME.ENCODE_FAILED with cause and codec id', async () => { const failingCodec = mongoCodec({ typeId: 'test/failing@1', - targetTypes: ['string'], decode: (w: string) => w, encode: async (_v: string) => { throw new Error('kms-key-resolution-failed'); }, }); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(failingCodec); const ref = new MongoParamRef('plaintext', { codecId: 'test/failing@1' }); @@ -217,13 +211,12 @@ describe('resolveValue', () => { it('uses MongoParamRef.name as the envelope label when available', async () => { const failingCodec = mongoCodec({ typeId: 'test/failing@1', - targetTypes: ['string'], decode: (w: string) => w, encode: async (_v: string) => { throw new Error('boom'); }, }); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(failingCodec); const ref = new MongoParamRef('plaintext', { @@ -241,13 +234,12 @@ describe('resolveValue', () => { it('falls back to codec id as the envelope label when MongoParamRef has no name', async () => { const failingCodec = mongoCodec({ typeId: 'test/failing@1', - targetTypes: ['string'], decode: (w: string) => w, encode: async (_v: string) => { throw new Error('boom'); }, }); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(failingCodec); const ref = new MongoParamRef('plaintext', { codecId: 'test/failing@1' }); @@ -261,7 +253,6 @@ describe('resolveValue', () => { it('preserves an existing RUNTIME.ENCODE_FAILED envelope without re-wrapping', async () => { const innerCodec = mongoCodec({ typeId: 'test/already-wrapped@1', - targetTypes: ['string'], decode: (w: string) => w, encode: async (_v: string) => { const err = new Error('original') as RuntimeErrorShape; @@ -269,7 +260,7 @@ describe('resolveValue', () => { throw err; }, }); - const registry = createMongoCodecRegistry(); + const registry = newMongoCodecRegistry(); registry.register(innerCodec); const ref = new MongoParamRef('x', { codecId: 'test/already-wrapped@1' }); diff --git a/packages/3-mongo-target/2-mongo-adapter/test/runner-deps.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/runner-deps.test.ts new file mode 100644 index 0000000000..d3b3bb67ac --- /dev/null +++ b/packages/3-mongo-target/2-mongo-adapter/test/runner-deps.test.ts @@ -0,0 +1,18 @@ +import type { ControlDriverInstance } from '@prisma-next/framework-components/control'; +import { describe, expect, it } from 'vitest'; +import { extractDb } from '../src/core/runner-deps'; + +describe('extractDb', () => { + it('returns the db reference attached to the mongo control driver', () => { + const fakeDb = { __id: 'fake-db' } as unknown; + const driver = { db: fakeDb } as unknown as ControlDriverInstance<'mongo', 'mongo'>; + expect(extractDb(driver)).toBe(fakeDb); + }); + + it("throws when the mongo control driver doesn't expose a db property", () => { + const driver = {} as unknown as ControlDriverInstance<'mongo', 'mongo'>; + expect(() => extractDb(driver)).toThrowError( + /Mongo control driver does not expose a db property/, + ); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/package.json b/packages/3-targets/3-targets/postgres/package.json index a2b054b97f..346d9b412d 100644 --- a/packages/3-targets/3-targets/postgres/package.json +++ b/packages/3-targets/3-targets/postgres/package.json @@ -29,6 +29,7 @@ "@prisma-next/sql-relational-core": "workspace:*", "@prisma-next/sql-schema-ir": "workspace:*", "@prisma-next/utils": "workspace:*", + "@standard-schema/spec": "^1.1.0", "arktype": "^2.0.0", "pathe": "^2.0.3" }, diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts new file mode 100644 index 0000000000..34bb22285f --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts @@ -0,0 +1,135 @@ +/** + * Shared encode/decode/render constants for the Postgres target codecs. + * + * The codec implementations live in `codecs.ts` (TML-2357). This file retains the conversion helpers + emit-path type renderers that the codec methods compose with — keeping a single source of truth for non-trivial conversions while the codec methods provide the framework-required `Promise<…>` boundary. + * + * Trivial identity passthroughs are inlined directly in the codec methods; only conversions with shape (custom JSON round-trip, decode normalisation, parameterised renderers) live here. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; + +export function renderLength( + typeName: string, + typeParams: Record, +): string | undefined { + const length = typeParams['length']; + if (length === undefined) { + return undefined; + } + if (typeof length !== 'number' || !Number.isFinite(length) || !Number.isInteger(length)) { + throw new Error( + `renderOutputType: expected integer "length" in typeParams for ${typeName}, got ${String(length)}`, + ); + } + return `${typeName}<${length}>`; +} + +export function renderPrecision(typeName: string, typeParams: Record): string { + const precision = typeParams['precision']; + if (precision === undefined) { + return typeName; + } + if ( + typeof precision !== 'number' || + !Number.isFinite(precision) || + !Number.isInteger(precision) + ) { + throw new Error( + `renderOutputType: expected integer "precision" in typeParams for ${typeName}, got ${String(precision)}`, + ); + } + return `${typeName}<${precision}>`; +} + +export const pgNumericDecode = (wire: string | number): string => { + if (typeof wire === 'number') return String(wire); + return wire; +}; + +export const pgNumericRenderOutputType = (typeParams: { + readonly precision: number; + readonly scale?: number; +}): string | undefined => { + const precision = typeParams.precision; + if (precision === undefined) return undefined; + if ( + typeof precision !== 'number' || + !Number.isFinite(precision) || + !Number.isInteger(precision) + ) { + throw new Error( + `renderOutputType: expected integer "precision" in typeParams for Numeric, got ${String(precision)}`, + ); + } + const scale = typeParams.scale; + if (scale === undefined) return `Numeric<${precision}>`; + if (typeof scale !== 'number' || !Number.isFinite(scale) || !Number.isInteger(scale)) { + throw new Error( + `renderOutputType: expected integer "scale" in typeParams for Numeric, got ${String(scale)}`, + ); + } + return `Numeric<${precision}, ${scale}>`; +}; + +// ISO 8601 UTC: `YYYY-MM-DDTHH:MM:SS[.mmm…]Z`. Trailing `Z` is required; fractional seconds are optional. Other `Date`-parseable formats (`January 15, 2024`, `01/15/2024`, etc.) are intentionally rejected because those formats are implementation-defined and not the documented contract for `pg/timestamp@1` / `pg/timestamptz@1`. +const ISO_8601_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/; + +export const pgTimestampEncodeJson = (value: Date): JsonValue => value.toISOString(); +export const pgTimestampDecodeJson = (json: JsonValue): Date => { + if (typeof json !== 'string') { + throw new Error(`Expected ISO date string for pg/timestamp@1, got ${typeof json}`); + } + if (!ISO_8601_UTC.test(json)) { + throw new Error(`Invalid ISO date string for pg/timestamp@1: ${json}`); + } + const date = new Date(json); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid ISO date string for pg/timestamp@1: ${json}`); + } + return date; +}; + +export const pgTimestamptzEncodeJson = (value: Date): JsonValue => value.toISOString(); +export const pgTimestamptzDecodeJson = (json: JsonValue): Date => { + if (typeof json !== 'string') { + throw new Error(`Expected ISO date string for pg/timestamptz@1, got ${typeof json}`); + } + if (!ISO_8601_UTC.test(json)) { + throw new Error(`Invalid ISO date string for pg/timestamptz@1: ${json}`); + } + const date = new Date(json); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid ISO date string for pg/timestamptz@1: ${json}`); + } + return date; +}; + +export const pgIntervalDecode = (wire: string | Record): string => { + if (typeof wire === 'string') return wire; + return JSON.stringify(wire); +}; + +export const pgEnumRenderOutputType = (typeParams: { + readonly values?: readonly unknown[]; +}): string => { + const values = typeParams.values; + if (!Array.isArray(values)) { + throw new Error( + `renderOutputType: expected array "values" in typeParams for enum, got ${typeof values}`, + ); + } + if (!values.every((v): v is string => typeof v === 'string')) { + throw new Error(`renderOutputType: expected string[] "values" in typeParams for enum`); + } + return values + .map((value) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`) + .join(' | '); +}; + +export const pgJsonEncode = (value: string | JsonValue): string => JSON.stringify(value); +export const pgJsonDecode = (wire: string | JsonValue): JsonValue => + typeof wire === 'string' ? JSON.parse(wire) : wire; + +export const pgJsonbEncode = (value: string | JsonValue): string => JSON.stringify(value); +export const pgJsonbDecode = (wire: string | JsonValue): JsonValue => + typeof wire === 'string' ? JSON.parse(wire) : wire; diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-type-map.ts b/packages/3-targets/3-targets/postgres/src/core/codec-type-map.ts new file mode 100644 index 0000000000..7e83dbfdd8 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/codec-type-map.ts @@ -0,0 +1,81 @@ +/** + * Internal codec descriptor map and `CodecTypes` materialisation for the Postgres target. + * + * Why this lives in `core/` even though the public origin of `CodecTypes` is `exports/codec-types.ts`: + * + * - The descriptor map (`codecDescriptorMap`) and the `Resolve` helper are implementation detail; they shouldn't appear on the public package surface. + * - The `CodecTypes` *materialisation* (the `Resolve<...>` application) must still happen at the public boundary so tsdown's DTS bundler resolves consumer-side `pack.d.mts` references via the public entry point rather than a hash-named internal chunk (the `TS2742` family). `exports/codec-types.ts` re-exports `CodecTypes` from here as a type alias, which preserves the materialisation site at the public surface. + */ + +import type { ExtractCodecTypes } from '@prisma-next/sql-relational-core/ast'; +import { + sqlCharDescriptor, + sqlFloatDescriptor, + sqlIntDescriptor, + sqlTextDescriptor, + sqlTimestampDescriptor, + sqlVarcharDescriptor, +} from '@prisma-next/sql-relational-core/ast'; +import { + pgBitDescriptor, + pgBoolDescriptor, + pgByteaDescriptor, + pgCharDescriptor, + pgEnumDescriptor, + pgFloat4Descriptor, + pgFloat8Descriptor, + pgFloatDescriptor, + pgInt2Descriptor, + pgInt4Descriptor, + pgInt8Descriptor, + pgIntDescriptor, + pgIntervalDescriptor, + pgJsonbDescriptor, + pgJsonDescriptor, + pgNumericDescriptor, + pgTextDescriptor, + pgTimeDescriptor, + pgTimestampDescriptor, + pgTimestamptzDescriptor, + pgTimetzDescriptor, + pgVarbitDescriptor, + pgVarcharDescriptor, +} from './codecs'; + +export const codecDescriptorMap = { + char: sqlCharDescriptor, + varchar: sqlVarcharDescriptor, + int: sqlIntDescriptor, + float: sqlFloatDescriptor, + 'sql-text': sqlTextDescriptor, + 'sql-timestamp': sqlTimestampDescriptor, + text: pgTextDescriptor, + character: pgCharDescriptor, + 'character varying': pgVarcharDescriptor, + integer: pgIntDescriptor, + 'double precision': pgFloatDescriptor, + int4: pgInt4Descriptor, + int2: pgInt2Descriptor, + int8: pgInt8Descriptor, + float4: pgFloat4Descriptor, + float8: pgFloat8Descriptor, + numeric: pgNumericDescriptor, + timestamp: pgTimestampDescriptor, + timestamptz: pgTimestamptzDescriptor, + time: pgTimeDescriptor, + timetz: pgTimetzDescriptor, + bool: pgBoolDescriptor, + bit: pgBitDescriptor, + 'bit varying': pgVarbitDescriptor, + bytea: pgByteaDescriptor, + interval: pgIntervalDescriptor, + enum: pgEnumDescriptor, + json: pgJsonDescriptor, + jsonb: pgJsonbDescriptor, +} as const; + +export type Resolve = { readonly [K in keyof T]: { readonly [P in keyof T[K]]: T[K][P] } }; + +export type CodecDescriptorMap = typeof codecDescriptorMap; + +export type ExtractedCodecTypes = ExtractCodecTypes; diff --git a/packages/3-targets/3-targets/postgres/src/core/codecs.ts b/packages/3-targets/3-targets/postgres/src/core/codecs.ts index d2a634d657..6fda34d78d 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codecs.ts @@ -1,21 +1,59 @@ /** - * Unified codec definitions for Postgres adapter. + * Native Postgres target codecs (TML-2357). Mirrors the SQL base codec form in `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts`. * - * This file contains a single source of truth for all codec information: - * - Scalar names - * - Type IDs - * - Codec implementations (runtime) - * - Type information (compile-time) + * Each codec ships as three artifacts: * - * This structure is used both at runtime (to populate the registry) and - * at compile time (to derive CodecTypes). + * 1. A `PgXCodec` class extending {@link CodecImpl} that wraps the module-level encode/decode/encodeJson/decodeJson constants exported from `codec-helpers.ts` (the single source of truth for non-trivial runtime conversions; trivial identity passthroughs are inlined). 2. A `PgXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, params schema, meta, and (where applicable) + * the emit-path `renderOutputType`. 3. A per-codec column helper (`pgXColumn`) that calls `descriptor.factory(...)` directly and packages the result into a {@link ColumnSpec} via the framework {@link column} packager. The helper is tied to its descriptor with `satisfies ColumnHelperFor` (and `ColumnHelperForStrict` where the resolved codec type is well-defined). + * + * After TML-2357 this is the canonical source of Postgres codec metadata and runtime behaviour — the legacy `mkCodec` / `defineCodec` carriers (and the parallel `byScalar`/`codecDescriptorDefinitions`/ `codecDescriptorList` collection exports) retired with the deletion sweep. + * + * Audit (parameterized codecs): every parameterized codec in this file is **parameter-stateless** — the params (`length`, `precision`, `precision`+`scale`, `values`) only inform the emit-path `renderOutputType` renderer or stay as JSON metadata. None of the runtime encode/decode/encodeJson/decodeJson conversions thread params into their behavior, so each `factory(_params)` returns a fresh codec constructed solely from + * `this` (the descriptor). */ import type { JsonValue } from '@prisma-next/contract/types'; -import type { Codec, CodecMeta, CodecTrait } from '@prisma-next/sql-relational-core/ast'; -import { codec, defineCodecs, sqlCodecDefinitions } from '@prisma-next/sql-relational-core/ast'; -import { ifDefined } from '@prisma-next/utils/defined'; +import { + type AnyCodecDescriptor, + type CodecCallContext, + CodecDescriptorImpl, + CodecImpl, + type CodecInstanceContext, + type ColumnHelperFor, + type ColumnHelperForStrict, + column, + voidParamsSchema, +} from '@prisma-next/framework-components/codec'; +import { + SqlCharCodec, + SqlFloatCodec, + SqlIntCodec, + SqlVarcharCodec, + sqlCharDescriptor, + sqlFloatDescriptor, + sqlIntDescriptor, + sqlTextDescriptor, + sqlTimestampDescriptor, + sqlVarcharDescriptor, +} from '@prisma-next/sql-relational-core/ast'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { type as arktype } from 'arktype'; +import { + pgEnumRenderOutputType, + pgIntervalDecode, + pgJsonbDecode, + pgJsonbEncode, + pgJsonDecode, + pgJsonEncode, + pgNumericDecode, + pgNumericRenderOutputType, + pgTimestampDecodeJson, + pgTimestampEncodeJson, + pgTimestamptzDecodeJson, + pgTimestamptzEncodeJson, + renderLength, + renderPrecision, +} from './codec-helpers'; import { PG_BIT_CODEC_ID, PG_BOOL_CODEC_ID, @@ -42,481 +80,642 @@ import { PG_VARCHAR_CODEC_ID, } from './codec-ids'; +type LengthParams = { readonly length?: number }; +type PrecisionParams = { readonly precision?: number }; +type NumericParams = { readonly precision: number; readonly scale?: number }; +type EnumParams = { readonly values?: readonly string[] }; + const lengthParamsSchema = arktype({ - length: 'number.integer > 0', -}); + 'length?': 'number.integer > 0', +}) satisfies StandardSchemaV1; const numericParamsSchema = arktype({ precision: 'number.integer > 0 & number.integer <= 1000', 'scale?': 'number.integer >= 0', -}); +}) satisfies StandardSchemaV1; const precisionParamsSchema = arktype({ 'precision?': 'number.integer >= 0 & number.integer <= 6', -}); +}) satisfies StandardSchemaV1; -function renderLength(typeName: string, typeParams: Record): string | undefined { - const length = typeParams['length']; - if (length === undefined) { - return undefined; - } - if (typeof length !== 'number' || !Number.isFinite(length) || !Number.isInteger(length)) { - throw new Error( - `renderOutputType: expected integer "length" in typeParams for ${typeName}, got ${String(length)}`, - ); - } - return `${typeName}<${length}>`; -} - -function renderPrecision(typeName: string, typeParams: Record): string { - const precision = typeParams['precision']; - if (precision === undefined) { - return typeName; - } - if ( - typeof precision !== 'number' || - !Number.isFinite(precision) || - !Number.isInteger(precision) - ) { - throw new Error( - `renderOutputType: expected integer "precision" in typeParams for ${typeName}, got ${String(precision)}`, - ); - } - return `${typeName}<${precision}>`; -} - -// Phase C: postgres' raw json/jsonb codecs no longer carry a -// `renderOutputType` slot — the schema-typed JSON surface that drove -// `typeParams: { schemaJson, type? }` retired in favor of the per-library -// extension package (`@prisma-next/extension-arktype-json`). Untyped -// json/jsonb columns have no typeParams; the framework emit path falls -// through to the generic `CodecTypes['pg/jsonb@1']['output']` accessor -// (which resolves to `JsonValue` via the codec-types map). - -function aliasCodec< - Id extends string, - TTraits extends readonly CodecTrait[], - TWire, - TJs, - TParams, - THelper, ->( - base: Codec, - options: { - readonly typeId: Id; - readonly targetTypes: readonly string[]; - readonly meta?: CodecMeta; - }, -): Codec { - return { - id: options.typeId, - targetTypes: options.targetTypes, - ...ifDefined('meta', options.meta), - ...ifDefined('paramsSchema', base.paramsSchema), - ...ifDefined('init', base.init), - ...ifDefined('encode', base.encode), - ...ifDefined('traits', base.traits), - ...ifDefined('renderOutputType', base.renderOutputType), - decode: base.decode, - encodeJson: base.encodeJson, - decodeJson: base.decodeJson, - } as Codec; -} - -const sqlCharCodec = sqlCodecDefinitions.char.codec; -const sqlVarcharCodec = sqlCodecDefinitions.varchar.codec; -const sqlIntCodec = sqlCodecDefinitions.int.codec; -const sqlFloatCodec = sqlCodecDefinitions.float.codec; -const sqlTextCodec = sqlCodecDefinitions.text.codec; -const sqlTimestampCodec = sqlCodecDefinitions.timestamp.codec; - -// Create individual codec instances -const pgTextCodec = codec({ - typeId: PG_TEXT_CODEC_ID, - targetTypes: ['text'], - traits: ['equality', 'order', 'textual'], - encode: (value: string): string => value, - decode: (wire: string): string => wire, - meta: { - db: { - sql: { - postgres: { - nativeType: 'text', - }, - }, - }, - }, -}); +const PG_TEXT_META = { db: { sql: { postgres: { nativeType: 'text' } } } } as const; +const PG_INT4_META = { db: { sql: { postgres: { nativeType: 'integer' } } } } as const; +const PG_INT2_META = { db: { sql: { postgres: { nativeType: 'smallint' } } } } as const; +const PG_INT8_META = { db: { sql: { postgres: { nativeType: 'bigint' } } } } as const; +const PG_FLOAT4_META = { db: { sql: { postgres: { nativeType: 'real' } } } } as const; +const PG_FLOAT8_META = { db: { sql: { postgres: { nativeType: 'double precision' } } } } as const; +const PG_NUMERIC_META = { db: { sql: { postgres: { nativeType: 'numeric' } } } } as const; +const PG_TIMESTAMP_META = { + db: { sql: { postgres: { nativeType: 'timestamp without time zone' } } }, +} as const; +const PG_TIMESTAMPTZ_META = { + db: { sql: { postgres: { nativeType: 'timestamp with time zone' } } }, +} as const; +const PG_TIME_META = { db: { sql: { postgres: { nativeType: 'time' } } } } as const; +const PG_TIMETZ_META = { db: { sql: { postgres: { nativeType: 'timetz' } } } } as const; +const PG_BOOL_META = { db: { sql: { postgres: { nativeType: 'boolean' } } } } as const; +const PG_BIT_META = { db: { sql: { postgres: { nativeType: 'bit' } } } } as const; +const PG_VARBIT_META = { db: { sql: { postgres: { nativeType: 'bit varying' } } } } as const; +const PG_BYTEA_META = { db: { sql: { postgres: { nativeType: 'bytea' } } } } as const; +const PG_INTERVAL_META = { db: { sql: { postgres: { nativeType: 'interval' } } } } as const; +const PG_JSON_META = { db: { sql: { postgres: { nativeType: 'json' } } } } as const; +const PG_JSONB_META = { db: { sql: { postgres: { nativeType: 'jsonb' } } } } as const; -const pgCharCodec = aliasCodec(sqlCharCodec, { - typeId: PG_CHAR_CODEC_ID, - targetTypes: ['character'], - meta: { - db: { - sql: { - postgres: { - nativeType: 'character', - }, - }, - }, - }, -}); +export class PgTextCodec extends CodecImpl< + typeof PG_TEXT_CODEC_ID, + readonly ['equality', 'order', 'textual'], + string, + string +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} -const pgVarcharCodec = aliasCodec(sqlVarcharCodec, { - typeId: PG_VARCHAR_CODEC_ID, - targetTypes: ['character varying'], - meta: { - db: { - sql: { - postgres: { - nativeType: 'character varying', - }, - }, - }, - }, -}); +export class PgTextDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_TEXT_CODEC_ID; + override readonly traits = ['equality', 'order', 'textual'] as const; + override readonly targetTypes = ['text'] as const; + override readonly meta = PG_TEXT_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgTextCodec { + return () => new PgTextCodec(this); + } +} -const pgIntCodec = aliasCodec(sqlIntCodec, { - typeId: PG_INT_CODEC_ID, - targetTypes: ['int4'], - meta: { - db: { - sql: { - postgres: { - nativeType: 'integer', - }, - }, - }, - }, -}); +export const pgTextDescriptor = new PgTextDescriptor(); -const pgFloatCodec = aliasCodec(sqlFloatCodec, { - typeId: PG_FLOAT_CODEC_ID, - targetTypes: ['float8'], - meta: { - db: { - sql: { - postgres: { - nativeType: 'double precision', - }, - }, - }, - }, -}); +export const pgTextColumn = () => + column(pgTextDescriptor.factory(), pgTextDescriptor.codecId, undefined, 'text'); -const pgInt4Codec = codec({ - typeId: PG_INT4_CODEC_ID, - targetTypes: ['int4'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number): number => value, - decode: (wire: number): number => wire, - meta: { - db: { - sql: { - postgres: { - nativeType: 'integer', - }, - }, - }, - }, -}); +pgTextColumn satisfies ColumnHelperFor; +pgTextColumn satisfies ColumnHelperForStrict; + +export class PgInt4Codec extends CodecImpl< + typeof PG_INT4_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number, + number +> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +export class PgInt4Descriptor extends CodecDescriptorImpl { + override readonly codecId = PG_INT4_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['int4'] as const; + override readonly meta = PG_INT4_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgInt4Codec { + return () => new PgInt4Codec(this); + } +} + +export const pgInt4Descriptor = new PgInt4Descriptor(); + +export const pgInt4Column = () => + column(pgInt4Descriptor.factory(), pgInt4Descriptor.codecId, undefined, 'int4'); + +pgInt4Column satisfies ColumnHelperFor; +pgInt4Column satisfies ColumnHelperForStrict; + +export class PgInt2Codec extends CodecImpl< + typeof PG_INT2_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number, + number +> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +export class PgInt2Descriptor extends CodecDescriptorImpl { + override readonly codecId = PG_INT2_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['int2'] as const; + override readonly meta = PG_INT2_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgInt2Codec { + return () => new PgInt2Codec(this); + } +} + +export const pgInt2Descriptor = new PgInt2Descriptor(); + +export const pgInt2Column = () => + column(pgInt2Descriptor.factory(), pgInt2Descriptor.codecId, undefined, 'int2'); + +pgInt2Column satisfies ColumnHelperFor; +pgInt2Column satisfies ColumnHelperForStrict; + +export class PgInt8Codec extends CodecImpl< + typeof PG_INT8_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number, + number +> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +export class PgInt8Descriptor extends CodecDescriptorImpl { + override readonly codecId = PG_INT8_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['int8'] as const; + override readonly meta = PG_INT8_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgInt8Codec { + return () => new PgInt8Codec(this); + } +} + +export const pgInt8Descriptor = new PgInt8Descriptor(); + +export const pgInt8Column = () => + column(pgInt8Descriptor.factory(), pgInt8Descriptor.codecId, undefined, 'int8'); + +pgInt8Column satisfies ColumnHelperFor; +pgInt8Column satisfies ColumnHelperForStrict; + +export class PgFloat4Codec extends CodecImpl< + typeof PG_FLOAT4_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number, + number +> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +export class PgFloat4Descriptor extends CodecDescriptorImpl { + override readonly codecId = PG_FLOAT4_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['float4'] as const; + override readonly meta = PG_FLOAT4_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgFloat4Codec { + return () => new PgFloat4Codec(this); + } +} + +export const pgFloat4Descriptor = new PgFloat4Descriptor(); -const pgNumericCodec = codec< +export const pgFloat4Column = () => + column(pgFloat4Descriptor.factory(), pgFloat4Descriptor.codecId, undefined, 'float4'); + +pgFloat4Column satisfies ColumnHelperFor; +pgFloat4Column satisfies ColumnHelperForStrict; + +export class PgFloat8Codec extends CodecImpl< + typeof PG_FLOAT8_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number, + number +> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +export class PgFloat8Descriptor extends CodecDescriptorImpl { + override readonly codecId = PG_FLOAT8_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['float8'] as const; + override readonly meta = PG_FLOAT8_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgFloat8Codec { + return () => new PgFloat8Codec(this); + } +} + +export const pgFloat8Descriptor = new PgFloat8Descriptor(); + +export const pgFloat8Column = () => + column(pgFloat8Descriptor.factory(), pgFloat8Descriptor.codecId, undefined, 'float8'); + +pgFloat8Column satisfies ColumnHelperFor; +pgFloat8Column satisfies ColumnHelperForStrict; + +export class PgBoolCodec extends CodecImpl< + typeof PG_BOOL_CODEC_ID, + readonly ['equality', 'boolean'], + boolean, + boolean +> { + async encode(value: boolean, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: boolean, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: boolean): JsonValue { + return value; + } + decodeJson(json: JsonValue): boolean { + return json as boolean; + } +} + +export class PgBoolDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_BOOL_CODEC_ID; + override readonly traits = ['equality', 'boolean'] as const; + override readonly targetTypes = ['bool'] as const; + override readonly meta = PG_BOOL_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgBoolCodec { + return () => new PgBoolCodec(this); + } +} + +export const pgBoolDescriptor = new PgBoolDescriptor(); + +export const pgBoolColumn = () => + column(pgBoolDescriptor.factory(), pgBoolDescriptor.codecId, undefined, 'bool'); + +pgBoolColumn satisfies ColumnHelperFor; +pgBoolColumn satisfies ColumnHelperForStrict; + +export class PgNumericCodec extends CodecImpl< typeof PG_NUMERIC_CODEC_ID, readonly ['equality', 'order', 'numeric'], - string, + string | number, string ->({ - typeId: PG_NUMERIC_CODEC_ID, - targetTypes: ['numeric', 'decimal'], - traits: ['equality', 'order', 'numeric'], - encode: (value: string): string => value, - decode: (wire: string | number): string => { - if (typeof wire === 'number') return String(wire); - return wire; - }, - paramsSchema: numericParamsSchema, - renderOutputType: (typeParams) => { - const precision = typeParams['precision']; - if (precision === undefined) return undefined; - if ( - typeof precision !== 'number' || - !Number.isFinite(precision) || - !Number.isInteger(precision) - ) { - throw new Error( - `renderOutputType: expected integer "precision" in typeParams for Numeric, got ${String(precision)}`, - ); - } - const scale = typeParams['scale']; - return typeof scale === 'number' ? `Numeric<${precision}, ${scale}>` : `Numeric<${precision}>`; - }, - meta: { - db: { - sql: { - postgres: { - nativeType: 'numeric', - }, - }, - }, - }, -}); +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: string | number, _ctx: CodecCallContext): Promise { + return pgNumericDecode(wire); + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} -const pgInt2Codec = codec({ - typeId: PG_INT2_CODEC_ID, - targetTypes: ['int2'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number): number => value, - decode: (wire: number): number => wire, - meta: { - db: { - sql: { - postgres: { - nativeType: 'smallint', - }, - }, - }, - }, -}); +export class PgNumericDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_NUMERIC_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['numeric', 'decimal'] as const; + override readonly meta = PG_NUMERIC_META; + override readonly paramsSchema = numericParamsSchema satisfies StandardSchemaV1; + override renderOutputType(params: NumericParams): string | undefined { + return pgNumericRenderOutputType(params); + } + override factory(_params: NumericParams): (ctx: CodecInstanceContext) => PgNumericCodec { + return () => new PgNumericCodec(this); + } +} -const pgInt8Codec = codec({ - typeId: PG_INT8_CODEC_ID, - targetTypes: ['int8'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number): number => value, - decode: (wire: number): number => wire, - meta: { - db: { - sql: { - postgres: { - nativeType: 'bigint', - }, - }, - }, - }, -}); +export const pgNumericDescriptor = new PgNumericDescriptor(); -const pgFloat4Codec = codec({ - typeId: PG_FLOAT4_CODEC_ID, - targetTypes: ['float4'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number): number => value, - decode: (wire: number): number => wire, - meta: { - db: { - sql: { - postgres: { - nativeType: 'real', - }, - }, - }, - }, -}); +export const pgNumericColumn = (params: NumericParams) => + column(pgNumericDescriptor.factory(params), pgNumericDescriptor.codecId, params, 'numeric'); -const pgFloat8Codec = codec({ - typeId: PG_FLOAT8_CODEC_ID, - targetTypes: ['float8'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number): number => value, - decode: (wire: number): number => wire, - meta: { - db: { - sql: { - postgres: { - nativeType: 'double precision', - }, - }, - }, - }, -}); +pgNumericColumn satisfies ColumnHelperFor; +pgNumericColumn satisfies ColumnHelperForStrict; -const pgTimestampCodec = codec< +export class PgTimestampCodec extends CodecImpl< typeof PG_TIMESTAMP_CODEC_ID, readonly ['equality', 'order'], Date, Date ->({ - typeId: PG_TIMESTAMP_CODEC_ID, - targetTypes: ['timestamp'], - traits: ['equality', 'order'], - encode: (value: Date): Date => value, - decode: (wire: Date): Date => wire, - encodeJson: (value: Date) => value.toISOString(), - decodeJson: (json) => { - if (typeof json !== 'string') { - throw new Error(`Expected ISO date string for pg/timestamp@1, got ${typeof json}`); - } - const date = new Date(json); - if (Number.isNaN(date.getTime())) { - throw new Error(`Invalid ISO date string for pg/timestamp@1: ${json}`); - } - return date; - }, - paramsSchema: precisionParamsSchema, - renderOutputType: (typeParams) => renderPrecision('Timestamp', typeParams), - meta: { - db: { - sql: { - postgres: { - nativeType: 'timestamp without time zone', - }, - }, - }, - }, -}); +> { + async encode(value: Date, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: Date, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: Date): JsonValue { + return pgTimestampEncodeJson(value); + } + decodeJson(json: JsonValue): Date { + return pgTimestampDecodeJson(json); + } +} + +export class PgTimestampDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_TIMESTAMP_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['timestamp'] as const; + override readonly meta = PG_TIMESTAMP_META; + override readonly paramsSchema = + precisionParamsSchema satisfies StandardSchemaV1; + override renderOutputType(params: PrecisionParams): string | undefined { + return renderPrecision('Timestamp', params as Record); + } + override factory(_params: PrecisionParams): (ctx: CodecInstanceContext) => PgTimestampCodec { + return () => new PgTimestampCodec(this); + } +} + +export const pgTimestampDescriptor = new PgTimestampDescriptor(); -const pgTimestamptzCodec = codec< +export const pgTimestampColumn = (params: PrecisionParams = {}) => + column(pgTimestampDescriptor.factory(params), pgTimestampDescriptor.codecId, params, 'timestamp'); + +pgTimestampColumn satisfies ColumnHelperFor; +pgTimestampColumn satisfies ColumnHelperForStrict; + +export class PgTimestamptzCodec extends CodecImpl< typeof PG_TIMESTAMPTZ_CODEC_ID, readonly ['equality', 'order'], Date, Date ->({ - typeId: PG_TIMESTAMPTZ_CODEC_ID, - targetTypes: ['timestamptz'], - traits: ['equality', 'order'], - encode: (value: Date): Date => value, - decode: (wire: Date): Date => wire, - encodeJson: (value: Date) => value.toISOString(), - decodeJson: (json) => { - if (typeof json !== 'string') { - throw new Error(`Expected ISO date string for pg/timestamptz@1, got ${typeof json}`); - } - const date = new Date(json); - if (Number.isNaN(date.getTime())) { - throw new Error(`Invalid ISO date string for pg/timestamptz@1: ${json}`); - } - return date; - }, - paramsSchema: precisionParamsSchema, - renderOutputType: (typeParams) => renderPrecision('Timestamptz', typeParams), - meta: { - db: { - sql: { - postgres: { - nativeType: 'timestamp with time zone', - }, - }, - }, - }, -}); +> { + async encode(value: Date, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: Date, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: Date): JsonValue { + return pgTimestamptzEncodeJson(value); + } + decodeJson(json: JsonValue): Date { + return pgTimestamptzDecodeJson(json); + } +} -const pgTimeCodec = codec({ - typeId: PG_TIME_CODEC_ID, - targetTypes: ['time'], - traits: ['equality', 'order'], - encode: (value: string): string => value, - decode: (wire: string): string => wire, - paramsSchema: precisionParamsSchema, - renderOutputType: (typeParams) => renderPrecision('Time', typeParams), - meta: { - db: { - sql: { - postgres: { - nativeType: 'time', - }, - }, - }, - }, -}); +export class PgTimestamptzDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_TIMESTAMPTZ_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['timestamptz'] as const; + override readonly meta = PG_TIMESTAMPTZ_META; + override readonly paramsSchema = + precisionParamsSchema satisfies StandardSchemaV1; + override renderOutputType(params: PrecisionParams): string | undefined { + return renderPrecision('Timestamptz', params as Record); + } + override factory(_params: PrecisionParams): (ctx: CodecInstanceContext) => PgTimestamptzCodec { + return () => new PgTimestamptzCodec(this); + } +} + +export const pgTimestamptzDescriptor = new PgTimestamptzDescriptor(); + +export const pgTimestamptzColumn = (params: PrecisionParams = {}) => + column( + pgTimestamptzDescriptor.factory(params), + pgTimestamptzDescriptor.codecId, + params, + 'timestamptz', + ); + +pgTimestamptzColumn satisfies ColumnHelperFor; +pgTimestamptzColumn satisfies ColumnHelperForStrict; -const pgTimetzCodec = codec< +export class PgTimeCodec extends CodecImpl< + typeof PG_TIME_CODEC_ID, + readonly ['equality', 'order'], + string, + string +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} + +export class PgTimeDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_TIME_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['time'] as const; + override readonly meta = PG_TIME_META; + override readonly paramsSchema = + precisionParamsSchema satisfies StandardSchemaV1; + override renderOutputType(params: PrecisionParams): string | undefined { + return renderPrecision('Time', params as Record); + } + override factory(_params: PrecisionParams): (ctx: CodecInstanceContext) => PgTimeCodec { + return () => new PgTimeCodec(this); + } +} + +export const pgTimeDescriptor = new PgTimeDescriptor(); + +export const pgTimeColumn = (params: PrecisionParams = {}) => + column(pgTimeDescriptor.factory(params), pgTimeDescriptor.codecId, params, 'time'); + +pgTimeColumn satisfies ColumnHelperFor; +pgTimeColumn satisfies ColumnHelperForStrict; + +export class PgTimetzCodec extends CodecImpl< typeof PG_TIMETZ_CODEC_ID, readonly ['equality', 'order'], string, string ->({ - typeId: PG_TIMETZ_CODEC_ID, - targetTypes: ['timetz'], - traits: ['equality', 'order'], - encode: (value: string): string => value, - decode: (wire: string): string => wire, - paramsSchema: precisionParamsSchema, - renderOutputType: (typeParams) => renderPrecision('Timetz', typeParams), - meta: { - db: { - sql: { - postgres: { - nativeType: 'timetz', - }, - }, - }, - }, -}); +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} -const pgBoolCodec = codec({ - typeId: PG_BOOL_CODEC_ID, - targetTypes: ['bool'], - traits: ['equality', 'boolean'], - encode: (value: boolean): boolean => value, - decode: (wire: boolean): boolean => wire, - meta: { - db: { - sql: { - postgres: { - nativeType: 'boolean', - }, - }, - }, - }, -}); +export class PgTimetzDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_TIMETZ_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['timetz'] as const; + override readonly meta = PG_TIMETZ_META; + override readonly paramsSchema = + precisionParamsSchema satisfies StandardSchemaV1; + override renderOutputType(params: PrecisionParams): string | undefined { + return renderPrecision('Timetz', params as Record); + } + override factory(_params: PrecisionParams): (ctx: CodecInstanceContext) => PgTimetzCodec { + return () => new PgTimetzCodec(this); + } +} -const pgBitCodec = codec({ - typeId: PG_BIT_CODEC_ID, - targetTypes: ['bit'], - traits: ['equality', 'order'], - encode: (value: string): string => value, - decode: (wire: string): string => wire, - paramsSchema: lengthParamsSchema, - renderOutputType: (typeParams) => renderLength('Bit', typeParams), - meta: { - db: { - sql: { - postgres: { - nativeType: 'bit', - }, - }, - }, - }, -}); +export const pgTimetzDescriptor = new PgTimetzDescriptor(); + +export const pgTimetzColumn = (params: PrecisionParams = {}) => + column(pgTimetzDescriptor.factory(params), pgTimetzDescriptor.codecId, params, 'timetz'); -const pgVarbitCodec = codec< +pgTimetzColumn satisfies ColumnHelperFor; +pgTimetzColumn satisfies ColumnHelperForStrict; + +export class PgBitCodec extends CodecImpl< + typeof PG_BIT_CODEC_ID, + readonly ['equality', 'order'], + string, + string +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} + +export class PgBitDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_BIT_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['bit'] as const; + override readonly meta = PG_BIT_META; + override readonly paramsSchema = lengthParamsSchema satisfies StandardSchemaV1; + override renderOutputType(params: LengthParams): string | undefined { + return renderLength('Bit', params as Record); + } + override factory(_params: LengthParams): (ctx: CodecInstanceContext) => PgBitCodec { + return () => new PgBitCodec(this); + } +} + +export const pgBitDescriptor = new PgBitDescriptor(); + +export const pgBitColumn = (params: LengthParams = {}) => + column(pgBitDescriptor.factory(params), pgBitDescriptor.codecId, params, 'bit'); + +pgBitColumn satisfies ColumnHelperFor; +pgBitColumn satisfies ColumnHelperForStrict; + +export class PgVarbitCodec extends CodecImpl< typeof PG_VARBIT_CODEC_ID, readonly ['equality', 'order'], string, string ->({ - typeId: PG_VARBIT_CODEC_ID, - targetTypes: ['bit varying'], - traits: ['equality', 'order'], - encode: (value: string): string => value, - decode: (wire: string): string => wire, - paramsSchema: lengthParamsSchema, - renderOutputType: (typeParams) => renderLength('VarBit', typeParams), - meta: { - db: { - sql: { - postgres: { - nativeType: 'bit varying', - }, - }, - }, - }, -}); +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} + +export class PgVarbitDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_VARBIT_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['bit varying'] as const; + override readonly meta = PG_VARBIT_META; + override readonly paramsSchema = lengthParamsSchema satisfies StandardSchemaV1; + override renderOutputType(params: LengthParams): string | undefined { + return renderLength('VarBit', params as Record); + } + override factory(_params: LengthParams): (ctx: CodecInstanceContext) => PgVarbitCodec { + return () => new PgVarbitCodec(this); + } +} + +export const pgVarbitDescriptor = new PgVarbitDescriptor(); -const pgByteaCodec = codec({ - typeId: PG_BYTEA_CODEC_ID, - targetTypes: ['bytea'], - traits: ['equality'], - encode: (value: Uint8Array): Uint8Array => value, - decode: (wire: Uint8Array): Uint8Array => - // Postgres node drivers commonly return Buffer instances (which extend - // Uint8Array) — normalize to a plain Uint8Array view so engine-agnostic - // consumers don't accidentally observe Buffer-specific APIs. - wire instanceof Uint8Array && wire.constructor === Uint8Array +export const pgVarbitColumn = (params: LengthParams = {}) => + column(pgVarbitDescriptor.factory(params), pgVarbitDescriptor.codecId, params, 'bit varying'); + +pgVarbitColumn satisfies ColumnHelperFor; +pgVarbitColumn satisfies ColumnHelperForStrict; + +export class PgByteaCodec extends CodecImpl< + typeof PG_BYTEA_CODEC_ID, + readonly ['equality'], + Uint8Array, + Uint8Array +> { + async encode(value: Uint8Array, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: Uint8Array, _ctx: CodecCallContext): Promise { + // Postgres node drivers commonly return Buffer instances (which extend Uint8Array) — normalize to a plain Uint8Array view so engine-agnostic consumers don't accidentally observe Buffer-specific APIs. + return wire instanceof Uint8Array && wire.constructor === Uint8Array ? wire - : new Uint8Array(wire.buffer, wire.byteOffset, wire.byteLength), - encodeJson: (value: Uint8Array): string => Buffer.from(value).toString('base64'), - decodeJson: (json): Uint8Array => { + : new Uint8Array(wire.buffer, wire.byteOffset, wire.byteLength); + } + encodeJson(value: Uint8Array): JsonValue { + return Buffer.from(value).toString('base64'); + } + decodeJson(json: JsonValue): Uint8Array { if (typeof json !== 'string') { throw new Error(`Expected base64 string for pg/bytea@1, got ${typeof json}`); } @@ -525,134 +724,316 @@ const pgByteaCodec = codec({ throw new Error(`Invalid base64 string for pg/bytea@1 (length: ${json.length})`); } return new Uint8Array(decoded); - }, - meta: { - db: { - sql: { - postgres: { - nativeType: 'bytea', - }, - }, - }, - }, -}); + } +} -const pgEnumCodec = codec({ - typeId: PG_ENUM_CODEC_ID, - targetTypes: ['enum'], - traits: ['equality', 'order'], - encode: (value: string): string => value, - decode: (wire: string): string => wire, - renderOutputType: (typeParams) => { - const values = typeParams['values']; - if (!Array.isArray(values)) { - throw new Error( - `renderOutputType: expected array "values" in typeParams for enum, got ${typeof values}`, - ); - } - return values - .map((value) => `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`) - .join(' | '); - }, -}); +export class PgByteaDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_BYTEA_CODEC_ID; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['bytea'] as const; + override readonly meta = PG_BYTEA_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgByteaCodec { + return () => new PgByteaCodec(this); + } +} + +export const pgByteaDescriptor = new PgByteaDescriptor(); + +export const pgByteaColumn = () => + column(pgByteaDescriptor.factory(), pgByteaDescriptor.codecId, undefined, 'bytea'); + +pgByteaColumn satisfies ColumnHelperFor; +pgByteaColumn satisfies ColumnHelperForStrict; -const pgIntervalCodec = codec< +export class PgIntervalCodec extends CodecImpl< typeof PG_INTERVAL_CODEC_ID, readonly ['equality', 'order'], string | Record, string ->({ - typeId: PG_INTERVAL_CODEC_ID, - targetTypes: ['interval'], - traits: ['equality', 'order'], - encode: (value: string): string => value, - decode: (wire: string | Record): string => { - if (typeof wire === 'string') return wire; - return JSON.stringify(wire); - }, - paramsSchema: precisionParamsSchema, - renderOutputType: (typeParams) => renderPrecision('Interval', typeParams), - meta: { - db: { - sql: { - postgres: { - nativeType: 'interval', - }, - }, - }, - }, -}); +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: string | Record, _ctx: CodecCallContext): Promise { + return pgIntervalDecode(wire); + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} -const pgJsonCodec = codec({ - typeId: PG_JSON_CODEC_ID, - targetTypes: ['json'], - traits: [], - encode: (value: string | JsonValue): string => JSON.stringify(value), - decode: (wire: string | JsonValue): JsonValue => - typeof wire === 'string' ? JSON.parse(wire) : wire, - meta: { - db: { - sql: { - postgres: { - nativeType: 'json', - }, - }, - }, - }, -}); +export class PgIntervalDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_INTERVAL_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['interval'] as const; + override readonly meta = PG_INTERVAL_META; + override readonly paramsSchema = + precisionParamsSchema satisfies StandardSchemaV1; + override renderOutputType(params: PrecisionParams): string | undefined { + return renderPrecision('Interval', params as Record); + } + override factory(_params: PrecisionParams): (ctx: CodecInstanceContext) => PgIntervalCodec { + return () => new PgIntervalCodec(this); + } +} + +export const pgIntervalDescriptor = new PgIntervalDescriptor(); + +export const pgIntervalColumn = (params: PrecisionParams = {}) => + column(pgIntervalDescriptor.factory(params), pgIntervalDescriptor.codecId, params, 'interval'); -const pgJsonbCodec = codec({ - typeId: PG_JSONB_CODEC_ID, - targetTypes: ['jsonb'], - traits: ['equality'], - encode: (value: string | JsonValue): string => JSON.stringify(value), - decode: (wire: string | JsonValue): JsonValue => - typeof wire === 'string' ? JSON.parse(wire) : wire, - meta: { - db: { - sql: { - postgres: { - nativeType: 'jsonb', - }, - }, - }, - }, +pgIntervalColumn satisfies ColumnHelperFor; +pgIntervalColumn satisfies ColumnHelperForStrict; + +const enumParamsSchema = arktype({ + 'values?': 'string[]', }); -// Build codec definitions using the builder DSL -const codecs = defineCodecs() - .add('char', sqlCharCodec) - .add('varchar', sqlVarcharCodec) - .add('int', sqlIntCodec) - .add('float', sqlFloatCodec) - .add('sql-text', sqlTextCodec) - .add('sql-timestamp', sqlTimestampCodec) - .add('text', pgTextCodec) - .add('character', pgCharCodec) - .add('character varying', pgVarcharCodec) - .add('integer', pgIntCodec) - .add('double precision', pgFloatCodec) - .add('int4', pgInt4Codec) - .add('int2', pgInt2Codec) - .add('int8', pgInt8Codec) - .add('float4', pgFloat4Codec) - .add('float8', pgFloat8Codec) - .add('numeric', pgNumericCodec) - .add('timestamp', pgTimestampCodec) - .add('timestamptz', pgTimestamptzCodec) - .add('time', pgTimeCodec) - .add('timetz', pgTimetzCodec) - .add('bool', pgBoolCodec) - .add('bit', pgBitCodec) - .add('bit varying', pgVarbitCodec) - .add('bytea', pgByteaCodec) - .add('interval', pgIntervalCodec) - .add('enum', pgEnumCodec) - .add('json', pgJsonCodec) - .add('jsonb', pgJsonbCodec); - -// Export derived structures directly from codecs builder -export const codecDefinitions = codecs.codecDefinitions; -export const dataTypes = codecs.dataTypes; - -export type CodecTypes = typeof codecs.CodecTypes; +export class PgEnumCodec extends CodecImpl< + typeof PG_ENUM_CODEC_ID, + readonly ['equality', 'order'], + string, + string +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} + +export class PgEnumDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_ENUM_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['enum'] as const; + override readonly paramsSchema = enumParamsSchema satisfies StandardSchemaV1; + override renderOutputType(params: EnumParams): string | undefined { + return pgEnumRenderOutputType(params); + } + override factory(_params: EnumParams): (ctx: CodecInstanceContext) => PgEnumCodec { + return () => new PgEnumCodec(this); + } +} + +export const pgEnumDescriptor = new PgEnumDescriptor(); + +export const pgEnumColumn = (params: EnumParams = {}) => + column(pgEnumDescriptor.factory(params), pgEnumDescriptor.codecId, params, 'enum'); + +pgEnumColumn satisfies ColumnHelperFor; +pgEnumColumn satisfies ColumnHelperForStrict; + +export class PgJsonCodec extends CodecImpl< + typeof PG_JSON_CODEC_ID, + readonly [], + string | JsonValue, + JsonValue +> { + async encode(value: JsonValue, _ctx: CodecCallContext): Promise { + return pgJsonEncode(value); + } + async decode(wire: string | JsonValue, _ctx: CodecCallContext): Promise { + return pgJsonDecode(wire); + } + encodeJson(value: JsonValue): JsonValue { + return value; + } + decodeJson(json: JsonValue): JsonValue { + return json; + } +} + +export class PgJsonDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_JSON_CODEC_ID; + override readonly traits = [] as const; + override readonly targetTypes = ['json'] as const; + override readonly meta = PG_JSON_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgJsonCodec { + return () => new PgJsonCodec(this); + } +} + +export const pgJsonDescriptor = new PgJsonDescriptor(); + +export const pgJsonColumn = () => + column(pgJsonDescriptor.factory(), pgJsonDescriptor.codecId, undefined, 'json'); + +pgJsonColumn satisfies ColumnHelperFor; +pgJsonColumn satisfies ColumnHelperForStrict; + +export class PgJsonbCodec extends CodecImpl< + typeof PG_JSONB_CODEC_ID, + readonly ['equality'], + string | JsonValue, + JsonValue +> { + async encode(value: JsonValue, _ctx: CodecCallContext): Promise { + return pgJsonbEncode(value); + } + async decode(wire: string | JsonValue, _ctx: CodecCallContext): Promise { + return pgJsonbDecode(wire); + } + encodeJson(value: JsonValue): JsonValue { + return value; + } + decodeJson(json: JsonValue): JsonValue { + return json; + } +} + +export class PgJsonbDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_JSONB_CODEC_ID; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['jsonb'] as const; + override readonly meta = PG_JSONB_META; + override readonly paramsSchema: StandardSchemaV1 = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => PgJsonbCodec { + return () => new PgJsonbCodec(this); + } +} + +export const pgJsonbDescriptor = new PgJsonbDescriptor(); + +export const pgJsonbColumn = () => + column(pgJsonbDescriptor.factory(), pgJsonbDescriptor.codecId, undefined, 'jsonb'); + +pgJsonbColumn satisfies ColumnHelperFor; +pgJsonbColumn satisfies ColumnHelperForStrict; + +// `meta`. The factories instantiate the SQL-base codec class (`SqlCharCodec` etc.) passing `this` (the pg-alias descriptor) so `codec.id` resolves to the pg-alias codec id via `CodecImpl`'s `descriptor.codecId` proxy. --------------------------------------------------------------------------- + +const PG_CHAR_META = { db: { sql: { postgres: { nativeType: 'character' } } } } as const; +const PG_VARCHAR_META = { + db: { sql: { postgres: { nativeType: 'character varying' } } }, +} as const; +const PG_INT_META = { db: { sql: { postgres: { nativeType: 'integer' } } } } as const; +const PG_FLOAT_META = { db: { sql: { postgres: { nativeType: 'double precision' } } } } as const; + +export class PgCharDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_CHAR_CODEC_ID; + override readonly targetTypes = ['character'] as const; + override readonly meta = PG_CHAR_META; + override readonly traits = sqlCharDescriptor.traits; + override readonly paramsSchema = sqlCharDescriptor.paramsSchema; + override renderOutputType(params: LengthParams): string | undefined { + return sqlCharDescriptor.renderOutputType(params); + } + override factory(_params: LengthParams): (ctx: CodecInstanceContext) => SqlCharCodec { + return () => new SqlCharCodec(this); + } +} + +export const pgCharDescriptor = new PgCharDescriptor(); + +export const pgCharColumn = (params: LengthParams = {}) => + column(pgCharDescriptor.factory(params), pgCharDescriptor.codecId, params, 'character'); + +pgCharColumn satisfies ColumnHelperFor; + +export class PgVarcharDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_VARCHAR_CODEC_ID; + override readonly targetTypes = ['character varying'] as const; + override readonly meta = PG_VARCHAR_META; + override readonly traits = sqlVarcharDescriptor.traits; + override readonly paramsSchema = sqlVarcharDescriptor.paramsSchema; + override renderOutputType(params: LengthParams): string | undefined { + return sqlVarcharDescriptor.renderOutputType(params); + } + override factory(_params: LengthParams): (ctx: CodecInstanceContext) => SqlVarcharCodec { + return () => new SqlVarcharCodec(this); + } +} + +export const pgVarcharDescriptor = new PgVarcharDescriptor(); + +export const pgVarcharColumn = (params: LengthParams = {}) => + column( + pgVarcharDescriptor.factory(params), + pgVarcharDescriptor.codecId, + params, + 'character varying', + ); + +pgVarcharColumn satisfies ColumnHelperFor; + +export class PgIntDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_INT_CODEC_ID; + override readonly targetTypes = ['int4'] as const; + override readonly meta = PG_INT_META; + override readonly traits = sqlIntDescriptor.traits; + override readonly paramsSchema = sqlIntDescriptor.paramsSchema; + override factory(): (ctx: CodecInstanceContext) => SqlIntCodec { + return () => new SqlIntCodec(this); + } +} + +export const pgIntDescriptor = new PgIntDescriptor(); + +export const pgIntColumn = () => + column(pgIntDescriptor.factory(), pgIntDescriptor.codecId, undefined, 'int4'); + +pgIntColumn satisfies ColumnHelperFor; + +export class PgFloatDescriptor extends CodecDescriptorImpl { + override readonly codecId = PG_FLOAT_CODEC_ID; + override readonly targetTypes = ['float8'] as const; + override readonly meta = PG_FLOAT_META; + override readonly traits = sqlFloatDescriptor.traits; + override readonly paramsSchema = sqlFloatDescriptor.paramsSchema; + override factory(): (ctx: CodecInstanceContext) => SqlFloatCodec { + return () => new SqlFloatCodec(this); + } +} + +export const pgFloatDescriptor = new PgFloatDescriptor(); + +export const pgFloatColumn = () => + column(pgFloatDescriptor.factory(), pgFloatDescriptor.codecId, undefined, 'float8'); + +pgFloatColumn satisfies ColumnHelperFor; + +// `ExtractCodecTypes` to derive `CodecTypes`. --------------------------------------------------------------------------- + +export const codecDescriptors: readonly AnyCodecDescriptor[] = [ + sqlCharDescriptor, + sqlVarcharDescriptor, + sqlIntDescriptor, + sqlFloatDescriptor, + sqlTextDescriptor, + sqlTimestampDescriptor, + pgTextDescriptor, + pgCharDescriptor, + pgVarcharDescriptor, + pgIntDescriptor, + pgFloatDescriptor, + pgInt4Descriptor, + pgInt2Descriptor, + pgInt8Descriptor, + pgFloat4Descriptor, + pgFloat8Descriptor, + pgNumericDescriptor, + pgTimestampDescriptor, + pgTimestamptzDescriptor, + pgTimeDescriptor, + pgTimetzDescriptor, + pgBoolDescriptor, + pgBitDescriptor, + pgVarbitDescriptor, + pgByteaDescriptor, + pgIntervalDescriptor, + pgEnumDescriptor, + pgJsonDescriptor, + pgJsonbDescriptor, +]; diff --git a/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts b/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts index c9946c72d7..55813ed5e6 100644 --- a/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts +++ b/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts @@ -1,5 +1,5 @@ +import type { CodecTypes } from '../exports/codec-types'; import { postgresAuthoringFieldPresets, postgresAuthoringTypes } from './authoring'; -import type { CodecTypes } from './codecs'; const postgresTargetDescriptorMetaBase = { kind: 'target', diff --git a/packages/3-targets/3-targets/postgres/src/core/registry.ts b/packages/3-targets/3-targets/postgres/src/core/registry.ts new file mode 100644 index 0000000000..49be5a03e5 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/registry.ts @@ -0,0 +1,11 @@ +import { buildCodecDescriptorRegistry } from '@prisma-next/sql-relational-core/codec-descriptor-registry'; +import type { CodecDescriptorRegistry } from '@prisma-next/sql-relational-core/query-lane-context'; +import { codecDescriptors } from './codecs'; + +/** + * Registry of every codec descriptor shipped by `@prisma-next/target-postgres`. + * + * Public consumer surface for the postgres codec set: the postgres adapter and any other consumer that needs to enumerate or look up a postgres codec by id consumes this rather than the raw descriptor array. See ADR 208. + */ +export const postgresCodecRegistry: CodecDescriptorRegistry = + buildCodecDescriptorRegistry(codecDescriptors); diff --git a/packages/3-targets/3-targets/postgres/src/exports/codec-types.ts b/packages/3-targets/3-targets/postgres/src/exports/codec-types.ts index 4748b52649..4107bbd5a4 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/codec-types.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/codec-types.ts @@ -1,26 +1,17 @@ /** * Codec type definitions for the Postgres target. * - * This file exports type-only definitions for codec input/output types. - * These types are imported by generated `contract.d.ts` files for compile-time - * type inference. + * This file is the public origin of `CodecTypes`. The `Resolve<...>` materialisation happens here (rather than in `core/codec-type-map.ts`) so the tsdown DTS bundler resolves consumer-side `.d.mts` references via this public entry point rather than a hash-named internal chunk (the `TS2742` family). * - * Lives in `target-postgres` because codec types describe the target's value - * space - both the control adapter (introspection / schema verification) and - * the runtime adapter (encode/decode) share the same definitions, and the - * target package is the natural home that both adapters depend on. - * - * Runtime codec implementations are provided by the runtime adapter's - * codec registry, which is built from `core/codecs.ts`. + * Lives in `target-postgres` because codec types describe the target's value space — both the control adapter (introspection / schema verification) and the runtime adapter (encode/decode) share the same definitions, and the target package is the natural home that both adapters depend on. */ import type { JsonValue } from '@prisma-next/contract/types'; -import type { CodecTypes as CoreCodecTypes } from '../core/codecs'; +import type { ExtractedCodecTypes, Resolve } from '../core/codec-type-map'; -export type CodecTypes = CoreCodecTypes; +export type CodecTypes = Resolve; export type { JsonValue }; -export { dataTypes } from '../core/codecs'; type Branded> = T & { readonly [K in keyof Shape]: Shape[K]; diff --git a/packages/3-targets/3-targets/postgres/src/exports/codecs.ts b/packages/3-targets/3-targets/postgres/src/exports/codecs.ts index 0ffa4d1c97..af5a0e99c6 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/codecs.ts @@ -1,2 +1,49 @@ -export type { CodecTypes } from '../core/codecs'; -export { codecDefinitions, dataTypes } from '../core/codecs'; +export type { + PgBitDescriptor, + PgBoolDescriptor, + PgCharDescriptor, + PgEnumDescriptor, + PgFloat4Descriptor, + PgFloat8Descriptor, + PgFloatDescriptor, + PgInt2Descriptor, + PgInt4Descriptor, + PgInt8Descriptor, + PgIntDescriptor, + PgIntervalDescriptor, + PgJsonbDescriptor, + PgJsonDescriptor, + PgNumericDescriptor, + PgTextDescriptor, + PgTimeDescriptor, + PgTimestampDescriptor, + PgTimestamptzDescriptor, + PgTimetzDescriptor, + PgVarbitDescriptor, + PgVarcharDescriptor, +} from '../core/codecs'; +export { + pgBitColumn, + pgBoolColumn, + pgCharColumn, + pgEnumColumn, + pgFloat4Column, + pgFloat8Column, + pgFloatColumn, + pgInt2Column, + pgInt4Column, + pgInt8Column, + pgIntColumn, + pgIntervalColumn, + pgJsonbColumn, + pgJsonColumn, + pgNumericColumn, + pgTextColumn, + pgTimeColumn, + pgTimestampColumn, + pgTimestamptzColumn, + pgTimetzColumn, + pgVarbitColumn, + pgVarcharColumn, +} from '../core/codecs'; +export { postgresCodecRegistry } from '../core/registry'; diff --git a/packages/3-targets/3-targets/postgres/src/exports/runtime.ts b/packages/3-targets/3-targets/postgres/src/exports/runtime.ts index 9d773438e6..1d131a20ce 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/runtime.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/runtime.ts @@ -1,32 +1,27 @@ +import type { AnyCodecDescriptor } from '@prisma-next/framework-components/codec'; import type { RuntimeTargetDescriptor, RuntimeTargetInstance, } from '@prisma-next/framework-components/execution'; -import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; export interface PostgresRuntimeTargetInstance extends RuntimeTargetInstance<'sql', 'postgres'> {} /** - * Target-postgres deliberately does NOT import `SqlRuntimeTargetDescriptor` - * from `@prisma-next/sql-runtime`. The target package is a control-plane - * residence and must not pull the SQL execution-plane package into its - * dependency closure. The runtime descriptor here is shaped to satisfy the - * framework's `RuntimeTargetDescriptor` plus the structural - * `SqlStaticContributions` (`codecs`, `parameterizedCodecs`) that + * Target-postgres deliberately does NOT import `SqlRuntimeTargetDescriptor` from `@prisma-next/sql-runtime`. The target package is a control-plane residence and must not pull the SQL execution-plane package into its dependency closure. The runtime descriptor here is shaped to satisfy the framework's `RuntimeTargetDescriptor` plus the structural `SqlStaticContributions` (`codecs:` returning a descriptor list) that * `@prisma-next/sql-runtime` consumers narrow to at composition time. + * + * The target itself contributes no codecs — postgres-specific codecs ship from the postgres adapter and from extension packs (pgvector, arktype-json, etc.). */ const postgresRuntimeTargetDescriptor: RuntimeTargetDescriptor< 'sql', 'postgres', PostgresRuntimeTargetInstance > & { - readonly codecs: () => ReturnType; - readonly parameterizedCodecs: () => readonly never[]; + readonly codecs: () => readonly AnyCodecDescriptor[]; } = { ...postgresTargetDescriptorMeta, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], create(): PostgresRuntimeTargetInstance { return { familyId: 'sql', diff --git a/packages/3-targets/3-targets/postgres/test/codec-render-output-type.test.ts b/packages/3-targets/3-targets/postgres/test/codec-render-output-type.test.ts index 13ea721cc8..9f676cb3e2 100644 --- a/packages/3-targets/3-targets/postgres/test/codec-render-output-type.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codec-render-output-type.test.ts @@ -1,191 +1,207 @@ +import type { AnyCodecDescriptor } from '@prisma-next/framework-components/codec'; +import { sqlCharDescriptor, sqlVarcharDescriptor } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; -import { codecDefinitions } from '../src/core/codecs'; +import { + pgBitDescriptor, + pgBoolDescriptor, + pgCharDescriptor, + pgEnumDescriptor, + pgInt4Descriptor, + pgIntervalDescriptor, + pgJsonbDescriptor, + pgJsonDescriptor, + pgNumericDescriptor, + pgTextDescriptor, + pgTimeDescriptor, + pgTimestampDescriptor, + pgTimestamptzDescriptor, + pgTimetzDescriptor, + pgVarbitDescriptor, + pgVarcharDescriptor, +} from '../src/core/codecs'; + +// `renderOutputType` is a `CodecDescriptor`-side concern after the SQL `Codec` narrow (TML-2357). Tests read the renderer from the descriptor directly. +function rendererFor( + descriptor: AnyCodecDescriptor, +): ((typeParams: Record) => string | undefined) | undefined { + return descriptor.renderOutputType as + | ((typeParams: Record) => string | undefined) + | undefined; +} describe('codec renderOutputType', () => { describe('pg/char@1', () => { - const codec = codecDefinitions['character'].codec; + const renderer = rendererFor(pgCharDescriptor); it('renders Char when length is present', () => { - expect(codec.renderOutputType!({ length: 36 })).toBe('Char<36>'); + expect(renderer?.({ length: 36 })).toBe('Char<36>'); }); it('returns undefined when length is absent', () => { - expect(codec.renderOutputType!({})).toBeUndefined(); + expect(renderer?.({})).toBeUndefined(); }); it('throws on invalid length type', () => { - expect(() => codec.renderOutputType!({ length: 'bad' })).toThrow(/expected integer "length"/); + expect(() => renderer?.({ length: 'bad' })).toThrow(/expected integer "length"/); }); }); describe('pg/varchar@1', () => { - const codec = codecDefinitions['character varying'].codec; + const renderer = rendererFor(pgVarcharDescriptor); it('renders Varchar', () => { - expect(codec.renderOutputType!({ length: 255 })).toBe('Varchar<255>'); + expect(renderer?.({ length: 255 })).toBe('Varchar<255>'); }); it('returns undefined when length is absent', () => { - expect(codec.renderOutputType!({})).toBeUndefined(); + expect(renderer?.({})).toBeUndefined(); }); it('throws on invalid length type', () => { - expect(() => codec.renderOutputType!({ length: 'bad' })).toThrow(/expected integer "length"/); + expect(() => renderer?.({ length: 'bad' })).toThrow(/expected integer "length"/); }); }); describe('sql/char@1', () => { - const codec = codecDefinitions['char'].codec; + const renderer = rendererFor(sqlCharDescriptor); it('renders Char', () => { - expect(codec.renderOutputType!({ length: 36 })).toBe('Char<36>'); + expect(renderer?.({ length: 36 })).toBe('Char<36>'); }); }); describe('sql/varchar@1', () => { - const codec = codecDefinitions['varchar'].codec; + const renderer = rendererFor(sqlVarcharDescriptor); it('renders Varchar', () => { - expect(codec.renderOutputType!({ length: 100 })).toBe('Varchar<100>'); + expect(renderer?.({ length: 100 })).toBe('Varchar<100>'); }); }); describe('pg/numeric@1', () => { - const codec = codecDefinitions['numeric'].codec; + const renderer = rendererFor(pgNumericDescriptor); it('renders Numeric when both precision and scale are present', () => { - expect(codec.renderOutputType!({ precision: 10, scale: 2 })).toBe('Numeric<10, 2>'); + expect(renderer?.({ precision: 10, scale: 2 })).toBe('Numeric<10, 2>'); }); it('renders Numeric

when only precision is present', () => { - expect(codec.renderOutputType!({ precision: 10 })).toBe('Numeric<10>'); + expect(renderer?.({ precision: 10 })).toBe('Numeric<10>'); }); it('returns undefined when precision is absent', () => { - expect(codec.renderOutputType!({})).toBeUndefined(); + expect(renderer?.({})).toBeUndefined(); }); }); describe('pg/bit@1', () => { - const codec = codecDefinitions['bit'].codec; + const renderer = rendererFor(pgBitDescriptor); it('renders Bit', () => { - expect(codec.renderOutputType!({ length: 8 })).toBe('Bit<8>'); + expect(renderer?.({ length: 8 })).toBe('Bit<8>'); }); it('returns undefined when length is absent', () => { - expect(codec.renderOutputType!({})).toBeUndefined(); + expect(renderer?.({})).toBeUndefined(); }); }); describe('pg/varbit@1', () => { - const codec = codecDefinitions['bit varying'].codec; + const renderer = rendererFor(pgVarbitDescriptor); it('renders VarBit', () => { - expect(codec.renderOutputType!({ length: 16 })).toBe('VarBit<16>'); + expect(renderer?.({ length: 16 })).toBe('VarBit<16>'); }); }); describe('pg/timestamp@1', () => { - const codec = codecDefinitions['timestamp'].codec; + const renderer = rendererFor(pgTimestampDescriptor); it('renders Timestamp

when precision is present', () => { - expect(codec.renderOutputType!({ precision: 3 })).toBe('Timestamp<3>'); + expect(renderer?.({ precision: 3 })).toBe('Timestamp<3>'); }); it('renders Timestamp when precision is missing', () => { - expect(codec.renderOutputType!({})).toBe('Timestamp'); + expect(renderer?.({})).toBe('Timestamp'); }); }); describe('pg/timestamptz@1', () => { - const codec = codecDefinitions['timestamptz'].codec; + const renderer = rendererFor(pgTimestamptzDescriptor); it('renders Timestamptz

', () => { - expect(codec.renderOutputType!({ precision: 6 })).toBe('Timestamptz<6>'); + expect(renderer?.({ precision: 6 })).toBe('Timestamptz<6>'); }); it('renders Timestamptz when precision is missing', () => { - expect(codec.renderOutputType!({})).toBe('Timestamptz'); + expect(renderer?.({})).toBe('Timestamptz'); }); }); describe('pg/time@1', () => { - const codec = codecDefinitions['time'].codec; + const renderer = rendererFor(pgTimeDescriptor); it('renders Time

', () => { - expect(codec.renderOutputType!({ precision: 0 })).toBe('Time<0>'); + expect(renderer?.({ precision: 0 })).toBe('Time<0>'); }); }); describe('pg/timetz@1', () => { - const codec = codecDefinitions['timetz'].codec; + const renderer = rendererFor(pgTimetzDescriptor); it('renders Timetz

', () => { - expect(codec.renderOutputType!({ precision: 3 })).toBe('Timetz<3>'); + expect(renderer?.({ precision: 3 })).toBe('Timetz<3>'); }); }); describe('pg/interval@1', () => { - const codec = codecDefinitions['interval'].codec; + const renderer = rendererFor(pgIntervalDescriptor); it('renders Interval

', () => { - expect(codec.renderOutputType!({ precision: 3 })).toBe('Interval<3>'); + expect(renderer?.({ precision: 3 })).toBe('Interval<3>'); }); }); describe('pg/enum@1', () => { - const codec = codecDefinitions['enum'].codec; + const renderer = rendererFor(pgEnumDescriptor); it('renders literal union from values', () => { - expect(codec.renderOutputType!({ values: ['USER', 'ADMIN'] })).toBe("'USER' | 'ADMIN'"); + expect(renderer?.({ values: ['USER', 'ADMIN'] })).toBe("'USER' | 'ADMIN'"); }); it('escapes backslashes before single quotes', () => { - expect(codec.renderOutputType!({ values: ["it's", 'back\\slash'] })).toBe( - "'it\\'s' | 'back\\\\slash'", - ); + expect(renderer?.({ values: ["it's", 'back\\slash'] })).toBe("'it\\'s' | 'back\\\\slash'"); }); it('throws when values is missing', () => { - expect(() => codec.renderOutputType!({})).toThrow(/expected array "values"/); + expect(() => renderer?.({})).toThrow(/expected array "values"/); }); }); - // Phase C: pg/json@1 and pg/jsonb@1 no longer carry renderOutputType. - // The schema-typed JSON column surface that drove typeParams.schemaJson - // / typeParams.type retired in favor of the per-library extension - // (`@prisma-next/extension-arktype-json`). Untyped raw json/jsonb - // columns have no typeParams; the framework emit path falls through to - // the generic CodecTypes accessor. + // Phase C: pg/json@1 and pg/jsonb@1 no longer carry renderOutputType. The schema-typed JSON column surface that drove typeParams.schemaJson / typeParams.type retired in favor of the per-library extension (`@prisma-next/extension-arktype-json`). Untyped raw json/jsonb columns have no typeParams; the framework emit path falls through to the generic CodecTypes accessor. describe('pg/jsonb@1', () => { it('has no renderOutputType (raw JSONB)', () => { - const codec = codecDefinitions['jsonb'].codec; - expect(codec.renderOutputType).toBeUndefined(); + expect(rendererFor(pgJsonbDescriptor)).toBeUndefined(); }); }); describe('pg/json@1', () => { it('has no renderOutputType (raw JSON)', () => { - const codec = codecDefinitions['json'].codec; - expect(codec.renderOutputType).toBeUndefined(); + expect(rendererFor(pgJsonDescriptor)).toBeUndefined(); }); }); describe('non-parameterized codecs', () => { it('pg/int4@1 has no renderOutputType', () => { - const codec = codecDefinitions['int4'].codec; - expect(codec.renderOutputType).toBeUndefined(); + expect(rendererFor(pgInt4Descriptor)).toBeUndefined(); }); it('pg/text@1 has no renderOutputType', () => { - const codec = codecDefinitions['text'].codec; - expect(codec.renderOutputType).toBeUndefined(); + expect(rendererFor(pgTextDescriptor)).toBeUndefined(); }); it('pg/bool@1 has no renderOutputType', () => { - const codec = codecDefinitions['bool'].codec; - expect(codec.renderOutputType).toBeUndefined(); + expect(rendererFor(pgBoolDescriptor)).toBeUndefined(); }); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts new file mode 100644 index 0000000000..9e82d4e121 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from 'vitest'; +import { + PG_BIT_CODEC_ID, + PG_BOOL_CODEC_ID, + PG_ENUM_CODEC_ID, + PG_FLOAT4_CODEC_ID, + PG_FLOAT8_CODEC_ID, + PG_INT2_CODEC_ID, + PG_INT4_CODEC_ID, + PG_INT8_CODEC_ID, + PG_INTERVAL_CODEC_ID, + PG_JSON_CODEC_ID, + PG_JSONB_CODEC_ID, + PG_NUMERIC_CODEC_ID, + PG_TEXT_CODEC_ID, + PG_TIME_CODEC_ID, + PG_TIMESTAMP_CODEC_ID, + PG_TIMESTAMPTZ_CODEC_ID, + PG_TIMETZ_CODEC_ID, + PG_VARBIT_CODEC_ID, +} from '../src/core/codec-ids'; +import { + pgBitDescriptor, + pgBoolDescriptor, + pgEnumDescriptor, + pgFloat4Descriptor, + pgFloat8Descriptor, + pgInt2Descriptor, + pgInt4Descriptor, + pgInt8Descriptor, + pgIntervalDescriptor, + pgJsonbDescriptor, + pgJsonDescriptor, + pgNumericDescriptor, + pgTextDescriptor, + pgTimeDescriptor, + pgTimestampDescriptor, + pgTimestamptzDescriptor, + pgTimetzDescriptor, + pgVarbitDescriptor, +} from '../src/core/codecs'; + +const instanceCtx = { name: '' }; +const callCtx = {}; + +describe('codecs-class', () => { + describe('pg/text@1', () => { + const codec = pgTextDescriptor.factory()(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_TEXT_CODEC_ID); + }); + + it('encodes and decodes string values verbatim', async () => { + expect(await codec.encode('hello', callCtx)).toBe('hello'); + expect(await codec.decode('hello', callCtx)).toBe('hello'); + }); + + it('round-trips through JSON identity', () => { + expect(codec.encodeJson('hello')).toBe('hello'); + expect(codec.decodeJson('hello')).toBe('hello'); + }); + }); + + describe('pg/int4@1', () => { + const codec = pgInt4Descriptor.factory()(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_INT4_CODEC_ID); + }); + it('encodes and decodes number values verbatim', async () => { + expect(await codec.encode(42, callCtx)).toBe(42); + expect(await codec.decode(42, callCtx)).toBe(42); + }); + }); + + describe('pg/int2@1', () => { + const codec = pgInt2Descriptor.factory()(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_INT2_CODEC_ID); + }); + it('encodes and decodes number values verbatim', async () => { + expect(await codec.encode(7, callCtx)).toBe(7); + expect(await codec.decode(7, callCtx)).toBe(7); + }); + }); + + describe('pg/int8@1', () => { + const codec = pgInt8Descriptor.factory()(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_INT8_CODEC_ID); + }); + it('encodes and decodes number values verbatim', async () => { + expect(await codec.encode(9_999_999_999, callCtx)).toBe(9_999_999_999); + expect(await codec.decode(9_999_999_999, callCtx)).toBe(9_999_999_999); + }); + }); + + describe('pg/float4@1', () => { + const codec = pgFloat4Descriptor.factory()(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_FLOAT4_CODEC_ID); + }); + it('encodes and decodes number values verbatim', async () => { + expect(await codec.encode(3.14, callCtx)).toBe(3.14); + expect(await codec.decode(3.14, callCtx)).toBe(3.14); + }); + }); + + describe('pg/float8@1', () => { + const codec = pgFloat8Descriptor.factory()(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_FLOAT8_CODEC_ID); + }); + it('encodes and decodes number values verbatim', async () => { + expect(await codec.encode(Math.E, callCtx)).toBe(Math.E); + expect(await codec.decode(Math.E, callCtx)).toBe(Math.E); + }); + }); + + describe('pg/bool@1', () => { + const codec = pgBoolDescriptor.factory()(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_BOOL_CODEC_ID); + }); + it('encodes and decodes boolean values verbatim', async () => { + expect(await codec.encode(true, callCtx)).toBe(true); + expect(await codec.decode(false, callCtx)).toBe(false); + }); + }); + + describe('pg/numeric@1', () => { + const codec = pgNumericDescriptor.factory({ precision: 10, scale: 2 })(instanceCtx); + + it('id proxies through the descriptor (independent of params)', () => { + expect(codec.id).toBe(PG_NUMERIC_CODEC_ID); + }); + + it('encodes string verbatim', async () => { + expect(await codec.encode('123.45', callCtx)).toBe('123.45'); + }); + + it('decodes string verbatim and coerces number to string', async () => { + expect(await codec.decode('123.45', callCtx)).toBe('123.45'); + expect(await codec.decode(123 as unknown as string, callCtx)).toBe('123'); + }); + + it('renderOutputType returns Numeric', () => { + expect(pgNumericDescriptor.renderOutputType?.({ precision: 10, scale: 2 })).toBe( + 'Numeric<10, 2>', + ); + }); + + it('renderOutputType returns Numeric when scale absent', () => { + expect(pgNumericDescriptor.renderOutputType?.({ precision: 10 })).toBe('Numeric<10>'); + }); + }); + + describe('pg/timestamp@1', () => { + const codec = pgTimestampDescriptor.factory({ precision: 3 })(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_TIMESTAMP_CODEC_ID); + }); + + it('round-trips Date values', async () => { + const instant = new Date('2024-01-15T10:30:00Z'); + expect(await codec.encode(instant, callCtx)).toBe(instant); + expect(await codec.decode(instant, callCtx)).toBe(instant); + }); + + it('serializes Date to ISO 8601 string for JSON', () => { + const instant = new Date('2024-01-15T10:30:00Z'); + expect(codec.encodeJson(instant)).toBe('2024-01-15T10:30:00.000Z'); + expect(codec.decodeJson('2024-01-15T10:30:00.000Z')).toEqual(instant); + }); + + it('throws on invalid JSON input', () => { + expect(() => codec.decodeJson(42)).toThrow(/Expected ISO date string/); + expect(() => codec.decodeJson('not-a-date')).toThrow(/Invalid ISO date string/); + }); + + it('renderOutputType returns Timestamp', () => { + expect(pgTimestampDescriptor.renderOutputType?.({ precision: 3 })).toBe('Timestamp<3>'); + }); + + it('renderOutputType returns bare Timestamp when precision absent', () => { + expect(pgTimestampDescriptor.renderOutputType?.({})).toBe('Timestamp'); + }); + }); + + describe('pg/timestamptz@1', () => { + const codec = pgTimestamptzDescriptor.factory({ precision: 6 })(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_TIMESTAMPTZ_CODEC_ID); + }); + + it('round-trips Date values', async () => { + const instant = new Date('2024-01-15T10:30:00Z'); + expect(await codec.encode(instant, callCtx)).toBe(instant); + expect(await codec.decode(instant, callCtx)).toBe(instant); + }); + + it('round-trips through JSON via ISO 8601', () => { + const instant = new Date('2024-01-15T10:30:00Z'); + expect(codec.encodeJson(instant)).toBe('2024-01-15T10:30:00.000Z'); + expect(codec.decodeJson('2024-01-15T10:30:00.000Z')).toEqual(instant); + }); + + it('throws on invalid JSON input with pg/timestamptz@1 label', () => { + expect(() => codec.decodeJson(42)).toThrow(/pg\/timestamptz@1/); + }); + }); + + describe('pg/time@1', () => { + const codec = pgTimeDescriptor.factory({ precision: 2 })(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_TIME_CODEC_ID); + }); + it('round-trips strings verbatim', async () => { + expect(await codec.encode('10:30:00', callCtx)).toBe('10:30:00'); + expect(await codec.decode('10:30:00', callCtx)).toBe('10:30:00'); + }); + it('renderOutputType formats Time', () => { + expect(pgTimeDescriptor.renderOutputType?.({ precision: 2 })).toBe('Time<2>'); + }); + }); + + describe('pg/timetz@1', () => { + const codec = pgTimetzDescriptor.factory({})(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_TIMETZ_CODEC_ID); + }); + it('round-trips strings verbatim', async () => { + expect(await codec.encode('10:30:00+00', callCtx)).toBe('10:30:00+00'); + expect(await codec.decode('10:30:00+00', callCtx)).toBe('10:30:00+00'); + }); + }); + + describe('pg/bit@1', () => { + const codec = pgBitDescriptor.factory({ length: 8 })(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_BIT_CODEC_ID); + }); + it('round-trips bit strings verbatim', async () => { + expect(await codec.encode('10101010', callCtx)).toBe('10101010'); + expect(await codec.decode('10101010', callCtx)).toBe('10101010'); + }); + it('renderOutputType returns Bit', () => { + expect(pgBitDescriptor.renderOutputType?.({ length: 8 })).toBe('Bit<8>'); + }); + it('renderOutputType returns undefined when length absent', () => { + expect(pgBitDescriptor.renderOutputType?.({})).toBeUndefined(); + }); + }); + + describe('pg/varbit@1', () => { + const codec = pgVarbitDescriptor.factory({ length: 16 })(instanceCtx); + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_VARBIT_CODEC_ID); + }); + it('round-trips bit strings verbatim', async () => { + expect(await codec.encode('1010', callCtx)).toBe('1010'); + expect(await codec.decode('1010', callCtx)).toBe('1010'); + }); + it('renderOutputType returns VarBit', () => { + expect(pgVarbitDescriptor.renderOutputType?.({ length: 16 })).toBe('VarBit<16>'); + }); + }); + + describe('pg/interval@1', () => { + const codec = pgIntervalDescriptor.factory({})(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_INTERVAL_CODEC_ID); + }); + + it('encodes string verbatim', async () => { + expect(await codec.encode('1 day', callCtx)).toBe('1 day'); + }); + + it('decodes string verbatim', async () => { + expect(await codec.decode('1 day', callCtx)).toBe('1 day'); + }); + + it('decodes object form to JSON string', async () => { + expect(await codec.decode({ days: 1 } as unknown as string, callCtx)).toBe('{"days":1}'); + }); + }); + + describe('pg/enum@1', () => { + const codec = pgEnumDescriptor.factory({ values: ['red', 'green', 'blue'] })(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_ENUM_CODEC_ID); + }); + + it('round-trips string variants verbatim', async () => { + expect(await codec.encode('red', callCtx)).toBe('red'); + expect(await codec.decode('green', callCtx)).toBe('green'); + }); + + it("renderOutputType returns 'a' | 'b' | 'c' literal union", () => { + expect(pgEnumDescriptor.renderOutputType?.({ values: ['red', 'green', 'blue'] })).toBe( + "'red' | 'green' | 'blue'", + ); + }); + }); + + describe('pg/json@1', () => { + const codec = pgJsonDescriptor.factory()(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_JSON_CODEC_ID); + }); + + it('encodes JsonValue to JSON string', async () => { + expect(await codec.encode({ key: 'value' }, callCtx)).toBe('{"key":"value"}'); + }); + + it('decodes JSON string to value', async () => { + expect(await codec.decode('{"key":"value"}', callCtx)).toEqual({ key: 'value' }); + }); + + it('decode passes through already-decoded values', async () => { + expect(await codec.decode({ key: 'value' }, callCtx)).toEqual({ key: 'value' }); + }); + }); + + describe('pg/jsonb@1', () => { + const codec = pgJsonbDescriptor.factory()(instanceCtx); + + it('id proxies through the descriptor', () => { + expect(codec.id).toBe(PG_JSONB_CODEC_ID); + }); + + it('encodes JsonValue to JSON string', async () => { + expect(await codec.encode([1, 2, 3], callCtx)).toBe('[1,2,3]'); + }); + + it('decodes JSON string to value', async () => { + expect(await codec.decode('[1,2,3]', callCtx)).toEqual([1, 2, 3]); + }); + + it('decode passes through already-decoded values', async () => { + expect(await codec.decode([1, 2, 3], callCtx)).toEqual([1, 2, 3]); + }); + }); + + describe('descriptor metadata', () => { + it('codec ids match the PG_*_CODEC_ID constants', () => { + expect(pgTextDescriptor.codecId).toBe(PG_TEXT_CODEC_ID); + expect(pgInt4Descriptor.codecId).toBe(PG_INT4_CODEC_ID); + expect(pgInt2Descriptor.codecId).toBe(PG_INT2_CODEC_ID); + expect(pgInt8Descriptor.codecId).toBe(PG_INT8_CODEC_ID); + expect(pgFloat4Descriptor.codecId).toBe(PG_FLOAT4_CODEC_ID); + expect(pgFloat8Descriptor.codecId).toBe(PG_FLOAT8_CODEC_ID); + expect(pgBoolDescriptor.codecId).toBe(PG_BOOL_CODEC_ID); + expect(pgNumericDescriptor.codecId).toBe(PG_NUMERIC_CODEC_ID); + expect(pgTimestampDescriptor.codecId).toBe(PG_TIMESTAMP_CODEC_ID); + expect(pgTimestamptzDescriptor.codecId).toBe(PG_TIMESTAMPTZ_CODEC_ID); + expect(pgTimeDescriptor.codecId).toBe(PG_TIME_CODEC_ID); + expect(pgTimetzDescriptor.codecId).toBe(PG_TIMETZ_CODEC_ID); + expect(pgBitDescriptor.codecId).toBe(PG_BIT_CODEC_ID); + expect(pgVarbitDescriptor.codecId).toBe(PG_VARBIT_CODEC_ID); + expect(pgIntervalDescriptor.codecId).toBe(PG_INTERVAL_CODEC_ID); + expect(pgEnumDescriptor.codecId).toBe(PG_ENUM_CODEC_ID); + expect(pgJsonDescriptor.codecId).toBe(PG_JSON_CODEC_ID); + expect(pgJsonbDescriptor.codecId).toBe(PG_JSONB_CODEC_ID); + }); + + it('exposes nativeType meta keyed under db.sql.postgres', () => { + expect(pgTextDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('text'); + expect(pgInt4Descriptor.meta?.db?.sql?.postgres?.nativeType).toBe('integer'); + expect(pgInt2Descriptor.meta?.db?.sql?.postgres?.nativeType).toBe('smallint'); + expect(pgInt8Descriptor.meta?.db?.sql?.postgres?.nativeType).toBe('bigint'); + expect(pgFloat4Descriptor.meta?.db?.sql?.postgres?.nativeType).toBe('real'); + expect(pgFloat8Descriptor.meta?.db?.sql?.postgres?.nativeType).toBe('double precision'); + expect(pgBoolDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('boolean'); + expect(pgNumericDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('numeric'); + expect(pgTimestampDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe( + 'timestamp without time zone', + ); + expect(pgTimestamptzDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe( + 'timestamp with time zone', + ); + expect(pgTimeDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('time'); + expect(pgTimetzDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('timetz'); + expect(pgBitDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('bit'); + expect(pgVarbitDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('bit varying'); + expect(pgIntervalDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('interval'); + expect(pgJsonDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('json'); + expect(pgJsonbDescriptor.meta?.db?.sql?.postgres?.nativeType).toBe('jsonb'); + }); + + it('exposes traits and targetTypes for each codec', () => { + expect(pgTextDescriptor.traits).toEqual(['equality', 'order', 'textual']); + expect(pgInt4Descriptor.traits).toEqual(['equality', 'order', 'numeric']); + expect(pgBoolDescriptor.traits).toEqual(['equality', 'boolean']); + expect(pgJsonDescriptor.traits).toEqual([]); + expect(pgJsonbDescriptor.traits).toEqual(['equality']); + + expect(pgTextDescriptor.targetTypes).toEqual(['text']); + expect(pgNumericDescriptor.targetTypes).toEqual(['numeric', 'decimal']); + expect(pgBitDescriptor.targetTypes).toEqual(['bit']); + expect(pgVarbitDescriptor.targetTypes).toEqual(['bit varying']); + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts b/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts new file mode 100644 index 0000000000..7eeb91f8af --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts @@ -0,0 +1,119 @@ +/** + * Type tests for the Postgres target codecs. + * + * Mirrors `packages/2-sql/4-lanes/relational-core/test/ast/sql-codecs.types.test-d.ts`. + * + * Representative codecs only — the framework-level type discipline is exercised in `framework-components/test/codec.types.test-d.ts`. Per-target coverage focuses on: + * + * - one void-param codec (`pgInt4`) + * - one length-param codec (`pgBit`) + * - one precision-param codec (`pgTimestamptz`) + * - one composite-param codec (`pgNumeric` precision + scale) + * - positive `satisfies ColumnHelperFor` and `ColumnHelperForStrict` on each + * - one negative `// @ts-expect-error` for a wrong-shape malformed helper + */ + +import { + type CodecInstanceContext, + type ColumnHelperFor, + type ColumnHelperForStrict, + column, +} from '@prisma-next/framework-components/codec'; +import { expectTypeOf, test } from 'vitest'; +import { + type PgBitCodec, + type PgBitDescriptor, + type PgInt4Codec, + type PgInt4Descriptor, + type PgNumericCodec, + type PgNumericDescriptor, + type PgTimestamptzCodec, + type PgTimestamptzDescriptor, + pgBitColumn, + pgBitDescriptor, + pgInt4Column, + pgInt4Descriptor, + pgNumericColumn, + pgNumericDescriptor, + pgTimestamptzColumn, + pgTimestamptzDescriptor, +} from '../src/core/codecs'; + +test('pgInt4: descriptor.factory() returns typed (ctx) => PgInt4Codec', () => { + const factory = pgInt4Descriptor.factory(); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgInt4Codec>(); +}); + +test('pgInt4: column helper preserves typed codecFactory + undefined typeParams', () => { + const col = pgInt4Column(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgInt4Codec>(); + expectTypeOf(col.typeParams).toEqualTypeOf(); +}); + +test('pgBit: descriptor.factory(params) returns typed (ctx) => PgBitCodec', () => { + const factory = pgBitDescriptor.factory({ length: 8 }); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgBitCodec>(); +}); + +test('pgBit: column helper preserves typed codecFactory + length params', () => { + const col = pgBitColumn({ length: 8 }); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgBitCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ readonly length?: number }>(); +}); + +test('pgBit: column helper accepts no-args call (default params)', () => { + const col = pgBitColumn(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgBitCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ readonly length?: number }>(); +}); + +test('pgTimestamptz: descriptor.factory(params) returns typed (ctx) => PgTimestamptzCodec', () => { + const factory = pgTimestamptzDescriptor.factory({ precision: 3 }); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgTimestamptzCodec>(); +}); + +test('pgTimestamptz: column helper preserves typed codecFactory + precision params', () => { + const col = pgTimestamptzColumn({ precision: 3 }); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgTimestamptzCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ readonly precision?: number }>(); +}); + +test('pgNumeric: descriptor.factory(params) returns typed (ctx) => PgNumericCodec', () => { + const factory = pgNumericDescriptor.factory({ precision: 10, scale: 2 }); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgNumericCodec>(); +}); + +test('pgNumeric: column helper preserves typed codecFactory + composite params', () => { + const col = pgNumericColumn({ precision: 10, scale: 2 }); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => PgNumericCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf<{ + readonly precision: number; + readonly scale?: number; + }>(); +}); + +pgInt4Column satisfies ColumnHelperFor; +pgInt4Column satisfies ColumnHelperForStrict; + +pgBitColumn satisfies ColumnHelperFor; +pgBitColumn satisfies ColumnHelperForStrict; + +pgTimestamptzColumn satisfies ColumnHelperFor; +pgTimestamptzColumn satisfies ColumnHelperForStrict; + +pgNumericColumn satisfies ColumnHelperFor; +pgNumericColumn satisfies ColumnHelperForStrict; + +test('coarse satisfies catches wrong typeParams shape on pgBitColumn', () => { + const brokenHelper = (length: number) => + column( + pgBitDescriptor.factory({ length }), + pgBitDescriptor.codecId, + { wrongKey: length }, + 'bit', + ); + // @ts-expect-error -- typeParams shape doesn't satisfy ColumnHelperFor + brokenHelper satisfies ColumnHelperFor; + // @ts-expect-error -- strict shape catches the same mismatch + brokenHelper satisfies ColumnHelperForStrict; +}); diff --git a/packages/3-targets/3-targets/postgres/test/codecs.test.ts b/packages/3-targets/3-targets/postgres/test/codecs.test.ts index ada60c6919..141cd056d6 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs.test.ts @@ -1,10 +1,88 @@ -import type { SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; +import type { + AnyCodecDescriptor, + CodecInstanceContext, +} from '@prisma-next/framework-components/codec'; +import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; +import { + sqlCharDescriptor, + sqlFloatDescriptor, + sqlIntDescriptor, + sqlTextDescriptor, + sqlTimestampDescriptor, + sqlVarcharDescriptor, +} from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; -import { codecDefinitions } from '../src/core/codecs'; +import { + pgBitDescriptor, + pgBoolDescriptor, + pgByteaDescriptor, + pgCharDescriptor, + pgEnumDescriptor, + pgFloat4Descriptor, + pgFloat8Descriptor, + pgFloatDescriptor, + pgInt2Descriptor, + pgInt4Descriptor, + pgInt8Descriptor, + pgIntDescriptor, + pgIntervalDescriptor, + pgJsonbDescriptor, + pgJsonDescriptor, + pgNumericDescriptor, + pgTextDescriptor, + pgTimeDescriptor, + pgTimestampDescriptor, + pgTimestamptzDescriptor, + pgTimetzDescriptor, + pgVarbitDescriptor, + pgVarcharDescriptor, +} from '../src/core/codecs'; + +const SYNTH_CTX: CodecInstanceContext = { name: 'test' }; + +const descriptorByScalar = { + char: sqlCharDescriptor, + varchar: sqlVarcharDescriptor, + int: sqlIntDescriptor, + float: sqlFloatDescriptor, + 'sql-text': sqlTextDescriptor, + 'sql-timestamp': sqlTimestampDescriptor, + text: pgTextDescriptor, + character: pgCharDescriptor, + 'character varying': pgVarcharDescriptor, + integer: pgIntDescriptor, + 'double precision': pgFloatDescriptor, + int4: pgInt4Descriptor, + int2: pgInt2Descriptor, + int8: pgInt8Descriptor, + float4: pgFloat4Descriptor, + float8: pgFloat8Descriptor, + numeric: pgNumericDescriptor, + timestamp: pgTimestampDescriptor, + timestamptz: pgTimestamptzDescriptor, + time: pgTimeDescriptor, + timetz: pgTimetzDescriptor, + bool: pgBoolDescriptor, + bit: pgBitDescriptor, + 'bit varying': pgVarbitDescriptor, + bytea: pgByteaDescriptor, + interval: pgIntervalDescriptor, + enum: pgEnumDescriptor, + json: pgJsonDescriptor, + jsonb: pgJsonbDescriptor, +} as const satisfies Record; + +type ScalarName = keyof typeof descriptorByScalar; + +function codecForScalar(scalar: ScalarName): Codec { + const descriptor = descriptorByScalar[scalar]; + // Codec runtime is per-instance-stateless for every codec under test; pass `undefined as never` so parameterized descriptors (e.g. char, numeric) accept a missing params record without bypassing the descriptor's `factory(params)` contract at the type level. + return descriptor.factory(undefined as never)(SYNTH_CTX); +} describe('adapter-postgres codecs', () => { it('exports expected codec scalars', () => { - expect(Object.keys(codecDefinitions).sort()).toEqual([ + expect(Object.keys(descriptorByScalar).sort()).toEqual([ 'bit', 'bit varying', 'bool', @@ -38,7 +116,7 @@ describe('adapter-postgres codecs', () => { }); describe('timestamp codec', () => { - const timestampCodec = codecDefinitions.timestamp.codec as { + const timestampCodec = codecForScalar('timestamp') as { encode: (value: Date, ctx: SqlCodecCallContext) => Promise; decode: (wire: Date, ctx: SqlCodecCallContext) => Promise; }; @@ -55,7 +133,7 @@ describe('adapter-postgres codecs', () => { }); describe('sql-timestamp codec', () => { - const timestampCodec = codecDefinitions['sql-timestamp'].codec as { + const timestampCodec = codecForScalar('sql-timestamp') as { encode: (value: Date, ctx: SqlCodecCallContext) => Promise; decode: (wire: Date, ctx: SqlCodecCallContext) => Promise; }; @@ -68,7 +146,7 @@ describe('adapter-postgres codecs', () => { }); describe('timestamptz codec', () => { - const timestamptzCodec = codecDefinitions.timestamptz.codec as { + const timestamptzCodec = codecForScalar('timestamptz') as { encode: (value: Date, ctx: SqlCodecCallContext) => Promise; decode: (wire: Date, ctx: SqlCodecCallContext) => Promise; }; @@ -81,7 +159,7 @@ describe('adapter-postgres codecs', () => { }); describe('json codec', () => { - const jsonCodec = codecDefinitions.json.codec as { + const jsonCodec = codecForScalar('json') as { encode: (value: unknown, ctx: SqlCodecCallContext) => Promise; decode: (wire: string | unknown, ctx: SqlCodecCallContext) => Promise; }; @@ -102,7 +180,7 @@ describe('adapter-postgres codecs', () => { }); describe('jsonb codec', () => { - const jsonbCodec = codecDefinitions.jsonb.codec as { + const jsonbCodec = codecForScalar('jsonb') as { encode: (value: unknown, ctx: SqlCodecCallContext) => Promise; decode: (wire: string | unknown, ctx: SqlCodecCallContext) => Promise; }; @@ -128,7 +206,7 @@ describe('adapter-postgres codecs', () => { { scalar: 'text', value: 'hello world' }, { scalar: 'enum', value: 'ADMIN' }, ] as const)('keeps $scalar values unchanged', async ({ scalar, value }) => { - const codec = codecDefinitions[scalar].codec as { + const codec = codecForScalar(scalar) as { encode: (input: string, ctx: SqlCodecCallContext) => Promise; decode: (input: string, ctx: SqlCodecCallContext) => Promise; }; @@ -143,7 +221,7 @@ describe('adapter-postgres codecs', () => { { scalar: 'float4', value: 3.14 }, { scalar: 'float8', value: Math.E }, ] as const)('keeps $scalar values unchanged', async ({ scalar, value }) => { - const codec = codecDefinitions[scalar].codec as { + const codec = codecForScalar(scalar) as { encode: (input: number, ctx: SqlCodecCallContext) => Promise; decode: (input: number, ctx: SqlCodecCallContext) => Promise; }; @@ -152,7 +230,7 @@ describe('adapter-postgres codecs', () => { }); it('keeps boolean values unchanged', async () => { - const boolCodec = codecDefinitions.bool.codec as { + const boolCodec = codecForScalar('bool') as { encode: (input: boolean, ctx: SqlCodecCallContext) => Promise; decode: (input: boolean, ctx: SqlCodecCallContext) => Promise; }; @@ -162,139 +240,112 @@ describe('adapter-postgres codecs', () => { }); describe('character codec', () => { - const charCodec = codecDefinitions.character.codec as { + const charCodec = codecForScalar('character') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; decode: (wire: string, ctx: SqlCodecCallContext) => Promise; }; it('encodes string as-is', async () => { - const value = 'A'; - const encoded = await charCodec.encode(value, {}); - expect(encoded).toBe(value); + expect(await charCodec.encode('A', {})).toBe('A'); }); it('decodes string as-is', async () => { - const value = 'Z'; - const decoded = await charCodec.decode(value, {}); - expect(decoded).toBe(value); + expect(await charCodec.decode('Z', {})).toBe('Z'); }); }); describe('character varying codec', () => { - const varcharCodec = codecDefinitions['character varying'].codec as { + const varcharCodec = codecForScalar('character varying') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; decode: (wire: string, ctx: SqlCodecCallContext) => Promise; }; it('encodes string as-is', async () => { - const value = 'hello'; - const encoded = await varcharCodec.encode(value, {}); - expect(encoded).toBe(value); + expect(await varcharCodec.encode('hello', {})).toBe('hello'); }); it('decodes string as-is', async () => { - const value = 'world'; - const decoded = await varcharCodec.decode(value, {}); - expect(decoded).toBe(value); + expect(await varcharCodec.decode('world', {})).toBe('world'); }); }); describe('numeric codec', () => { - const numericCodec = codecDefinitions.numeric.codec as { + const numericCodec = codecForScalar('numeric') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; decode: (wire: string | number, ctx: SqlCodecCallContext) => Promise; }; it('encodes string as-is', async () => { - const value = '123.45'; - const encoded = await numericCodec.encode(value, {}); - expect(encoded).toBe(value); + expect(await numericCodec.encode('123.45', {})).toBe('123.45'); }); it('decodes number to string', async () => { - const decoded = await numericCodec.decode(42, {}); - expect(decoded).toBe('42'); + expect(await numericCodec.decode(42, {})).toBe('42'); }); }); describe('time codec', () => { - const timeCodec = codecDefinitions.time.codec as { + const timeCodec = codecForScalar('time') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; decode: (wire: string, ctx: SqlCodecCallContext) => Promise; }; it('encodes string as-is', async () => { - const value = '12:34:56'; - const encoded = await timeCodec.encode(value, {}); - expect(encoded).toBe(value); + expect(await timeCodec.encode('12:34:56', {})).toBe('12:34:56'); }); it('decodes string as-is', async () => { - const value = '23:59:59'; - const decoded = await timeCodec.decode(value, {}); - expect(decoded).toBe(value); + expect(await timeCodec.decode('23:59:59', {})).toBe('23:59:59'); }); }); describe('timetz codec', () => { - const timetzCodec = codecDefinitions.timetz.codec as { + const timetzCodec = codecForScalar('timetz') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; decode: (wire: string, ctx: SqlCodecCallContext) => Promise; }; it('encodes string as-is', async () => { - const value = '12:34:56+02'; - const encoded = await timetzCodec.encode(value, {}); - expect(encoded).toBe(value); + expect(await timetzCodec.encode('12:34:56+02', {})).toBe('12:34:56+02'); }); it('decodes string as-is', async () => { - const value = '23:59:59-05'; - const decoded = await timetzCodec.decode(value, {}); - expect(decoded).toBe(value); + expect(await timetzCodec.decode('23:59:59-05', {})).toBe('23:59:59-05'); }); }); describe('bit codec', () => { - const bitCodec = codecDefinitions.bit.codec as { + const bitCodec = codecForScalar('bit') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; decode: (wire: string, ctx: SqlCodecCallContext) => Promise; }; it('encodes string as-is', async () => { - const value = '1010'; - const encoded = await bitCodec.encode(value, {}); - expect(encoded).toBe(value); + expect(await bitCodec.encode('1010', {})).toBe('1010'); }); it('decodes string as-is', async () => { - const value = '0101'; - const decoded = await bitCodec.decode(value, {}); - expect(decoded).toBe(value); + expect(await bitCodec.decode('0101', {})).toBe('0101'); }); }); describe('bit varying codec', () => { - const varbitCodec = codecDefinitions['bit varying'].codec as { + const varbitCodec = codecForScalar('bit varying') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; decode: (wire: string, ctx: SqlCodecCallContext) => Promise; }; it('encodes string as-is', async () => { - const value = '11110000'; - const encoded = await varbitCodec.encode(value, {}); - expect(encoded).toBe(value); + expect(await varbitCodec.encode('11110000', {})).toBe('11110000'); }); it('decodes string as-is', async () => { - const value = '00001111'; - const decoded = await varbitCodec.decode(value, {}); - expect(decoded).toBe(value); + expect(await varbitCodec.decode('00001111', {})).toBe('00001111'); }); }); describe('bytea codec', () => { - const byteaCodec = codecDefinitions.bytea.codec as { + const byteaCodec = codecForScalar('bytea') as { encode: (value: Uint8Array, ctx: SqlCodecCallContext) => Promise; decode: (wire: Uint8Array, ctx: SqlCodecCallContext) => Promise; encodeJson: (value: Uint8Array) => unknown; @@ -347,17 +398,14 @@ describe('adapter-postgres codecs', () => { }); it('throws on invalid base64 characters in decodeJson', () => { - // The bytea codec must reject malformed base64 rather than silently - // skipping invalid characters and producing arbitrary bytes — see - // https://github.com/prisma/prisma-next/pull/428. + // The bytea codec must reject malformed base64 rather than silently skipping invalid characters and producing arbitrary bytes — see https://github.com/prisma/prisma-next/pull/428. expect(() => byteaCodec.decodeJson('!!!not base64!!!')).toThrow( /Invalid base64 string for pg\/bytea@1/, ); }); it('throws on base64 with stray whitespace in decodeJson', () => { - // Whitespace decodes to valid bytes via Buffer.from, but the round-trip - // comparison rejects non-canonical input. + // Whitespace decodes to valid bytes via Buffer.from, but the round-trip comparison rejects non-canonical input. expect(() => byteaCodec.decodeJson('SGVs bG8=')).toThrow( /Invalid base64 string for pg\/bytea@1/, ); @@ -365,21 +413,17 @@ describe('adapter-postgres codecs', () => { }); describe('interval codec', () => { - const intervalCodec = codecDefinitions.interval.codec as { + const intervalCodec = codecForScalar('interval') as { encode: (value: string, ctx: SqlCodecCallContext) => Promise; decode: (wire: string | Record, ctx: SqlCodecCallContext) => Promise; }; it('encodes string as-is', async () => { - const value = '1 day'; - const encoded = await intervalCodec.encode(value, {}); - expect(encoded).toBe(value); + expect(await intervalCodec.encode('1 day', {})).toBe('1 day'); }); it('decodes string as-is', async () => { - const value = '2 hours'; - const decoded = await intervalCodec.decode(value, {}); - expect(decoded).toBe(value); + expect(await intervalCodec.decode('2 hours', {})).toBe('2 hours'); }); it('serializes object wire values to JSON strings', async () => { @@ -390,7 +434,7 @@ describe('adapter-postgres codecs', () => { describe('metadata and params schema', () => { const postgresNativeTypeCases: ReadonlyArray<{ - scalar: keyof typeof codecDefinitions; + scalar: ScalarName; nativeType: string; }> = [ { scalar: 'character', nativeType: 'character' }, @@ -406,72 +450,44 @@ describe('adapter-postgres codecs', () => { scalar, nativeType, }) => { - const codec = codecDefinitions[scalar].codec as { - meta?: { db?: { sql?: { postgres?: { nativeType?: string } } } }; - }; - expect(codec.meta?.db?.sql?.postgres?.nativeType).toBe(nativeType); + const meta = descriptorByScalar[scalar].meta as + | { db?: { sql?: { postgres?: { nativeType?: string } } } } + | undefined; + expect(meta?.db?.sql?.postgres?.nativeType).toBe(nativeType); }); const paramsSchemaPresenceCases: ReadonlyArray<{ - scalar: keyof typeof codecDefinitions; - hasParamsSchema: boolean; - }> = [ - { scalar: 'character', hasParamsSchema: true }, - { scalar: 'character varying', hasParamsSchema: true }, - { scalar: 'numeric', hasParamsSchema: true }, - { scalar: 'sql-timestamp', hasParamsSchema: true }, - { scalar: 'timestamp', hasParamsSchema: true }, - { scalar: 'timestamptz', hasParamsSchema: true }, - { scalar: 'time', hasParamsSchema: true }, - { scalar: 'timetz', hasParamsSchema: true }, - { scalar: 'bit', hasParamsSchema: true }, - { scalar: 'bit varying', hasParamsSchema: true }, - { scalar: 'interval', hasParamsSchema: true }, - { scalar: 'sql-text', hasParamsSchema: false }, - { scalar: 'text', hasParamsSchema: false }, - { scalar: 'enum', hasParamsSchema: false }, - { scalar: 'bool', hasParamsSchema: false }, - { scalar: 'int4', hasParamsSchema: false }, - ]; - - it.each(paramsSchemaPresenceCases)('tracks params schema presence for $scalar', ({ - scalar, - hasParamsSchema, - }) => { - const codec = codecDefinitions[scalar].codec as { - paramsSchema?: unknown; - }; - expect(codec.paramsSchema !== undefined).toBe(hasParamsSchema); - }); - - const initHookCases: ReadonlyArray<{ - scalar: keyof typeof codecDefinitions; - hasInit: boolean; - expected: { kind: 'fixed' | 'variable'; maxLength: number } | undefined; + scalar: ScalarName; }> = [ - { scalar: 'character', hasInit: true, expected: { kind: 'fixed', maxLength: 12 } }, - { scalar: 'character varying', hasInit: true, expected: { kind: 'variable', maxLength: 64 } }, - { scalar: 'numeric', hasInit: false, expected: undefined }, + { scalar: 'character' }, + { scalar: 'character varying' }, + { scalar: 'numeric' }, + { scalar: 'sql-timestamp' }, + { scalar: 'timestamp' }, + { scalar: 'timestamptz' }, + { scalar: 'time' }, + { scalar: 'timetz' }, + { scalar: 'bit' }, + { scalar: 'bit varying' }, + { scalar: 'interval' }, + { scalar: 'sql-text' }, + { scalar: 'text' }, + { scalar: 'enum' }, + { scalar: 'bool' }, + { scalar: 'int4' }, ]; - it.each(initHookCases)('tracks init hook presence for $scalar', ({ + it.each(paramsSchemaPresenceCases)('descriptor for $scalar carries a paramsSchema', ({ scalar, - hasInit, - expected, }) => { - const codec = codecDefinitions[scalar].codec as { - init?: (params: { length: number }) => unknown; - }; - expect(codec.init !== undefined).toBe(hasInit); - if (expected) { - expect(codec.init?.({ length: expected.maxLength })).toEqual(expected); - } + // Descriptors always carry `paramsSchema` (every codec has one, be it `voidParamsSchema` for non-parameterized codecs or a codec-specific schema). The parameterization split remains observable through the descriptor's typed paramsSchema shape; the runtime presence check below holds for every codec. + expect(descriptorByScalar[scalar].paramsSchema).toBeDefined(); }); }); describe('encodeJson / decodeJson', () => { describe('pg/timestamptz@1', () => { - const codec = codecDefinitions.timestamptz.codec; + const codec = codecForScalar('timestamptz'); it('encodes Date to ISO string', () => { expect(codec.encodeJson(new Date('2024-01-15T00:00:00.000Z'))).toBe( @@ -480,7 +496,7 @@ describe('adapter-postgres codecs', () => { }); it('decodes ISO string to Date', () => { - const result = codec.decodeJson('2024-01-15T00:00:00.000Z'); + const result = codec.decodeJson('2024-01-15T00:00:00.000Z') as Date; expect(result).toBeInstanceOf(Date); expect(result).toEqual(new Date('2024-01-15T00:00:00.000Z')); }); @@ -504,7 +520,7 @@ describe('adapter-postgres codecs', () => { }); describe('pg/timestamp@1', () => { - const codec = codecDefinitions.timestamp.codec; + const codec = codecForScalar('timestamp'); it('encodes Date to ISO string', () => { expect(codec.encodeJson(new Date('2024-01-15T00:00:00.000Z'))).toBe( @@ -513,7 +529,7 @@ describe('adapter-postgres codecs', () => { }); it('decodes ISO string to Date', () => { - const result = codec.decodeJson('2024-01-15T00:00:00.000Z'); + const result = codec.decodeJson('2024-01-15T00:00:00.000Z') as Date; expect(result).toBeInstanceOf(Date); expect(result).toEqual(new Date('2024-01-15T00:00:00.000Z')); }); @@ -531,25 +547,25 @@ describe('adapter-postgres codecs', () => { describe('identity codecs', () => { it('pg/int4@1 round-trips numbers', () => { - const codec = codecDefinitions.int4.codec; + const codec = codecForScalar('int4'); expect(codec.encodeJson(42)).toBe(42); expect(codec.decodeJson(42)).toBe(42); }); it('pg/text@1 round-trips strings', () => { - const codec = codecDefinitions.text.codec; + const codec = codecForScalar('text'); expect(codec.encodeJson('hello')).toBe('hello'); expect(codec.decodeJson('hello')).toBe('hello'); }); it('pg/bool@1 round-trips booleans', () => { - const codec = codecDefinitions.bool.codec; + const codec = codecForScalar('bool'); expect(codec.encodeJson(true)).toBe(true); expect(codec.decodeJson(false)).toBe(false); }); it('pg/int8@1 round-trips numbers (identity)', () => { - const codec = codecDefinitions.int8.codec; + const codec = codecForScalar('int8'); expect(codec.encodeJson(9001)).toBe(9001); expect(codec.decodeJson(9001)).toBe(9001); }); @@ -557,7 +573,7 @@ describe('adapter-postgres codecs', () => { }); describe('numeric codec decode', () => { - const numericCodec = codecDefinitions.numeric.codec as { + const numericCodec = codecForScalar('numeric') as { decode: (wire: string | number, ctx: SqlCodecCallContext) => Promise; }; diff --git a/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts b/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts new file mode 100644 index 0000000000..0e40843d41 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts @@ -0,0 +1,72 @@ +/** + * Constructive type tests for the postgres per-target descriptor record layer. + * + * Coverage: + * - the internal descriptor list (`codecDescriptors`) narrows to `readonly AnyCodecDescriptor[]`, so heterogeneous descriptor storage works without per-codec branching; + * - trait literals survive on each descriptor class — {@link DescriptorCodecTraits} reads `traits` directly off the descriptor, so the literal tuple shape (`readonly ['equality', 'order', 'numeric']`) is preserved rather than widened to `readonly CodecTrait[]`; + * - the resolved `CodecTypes` projection contains the codec-id keys consumers reference at the no-emit authoring chain. + * + * Negative coverage (`// @ts-expect-error`) proves that a regression in trait preservation or a missing codec id breaks the test compile. + */ + +import type { AnyCodecDescriptor, CodecTrait } from '@prisma-next/framework-components/codec'; +import { expectTypeOf, test } from 'vitest'; +import { + codecDescriptors, + type PgInt4Descriptor, + type PgNumericDescriptor, + pgInt4Descriptor, + pgNumericDescriptor, +} from '../src/core/codecs'; +import type { CodecTypes } from '../src/exports/codec-types'; + +test('codecDescriptors narrows to readonly AnyCodecDescriptor[]', () => { + expectTypeOf(codecDescriptors).toEqualTypeOf(); +}); + +test('list entries extend AnyCodecDescriptor', () => { + expectTypeOf<(typeof codecDescriptors)[number]>().toExtend(); +}); + +test('pgInt4Descriptor.traits is a readonly literal tuple, not widened', () => { + type Traits = PgInt4Descriptor['traits']; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toExtend(); +}); + +test('pgNumericDescriptor.traits preserves the same literal tuple', () => { + type Traits = PgNumericDescriptor['traits']; + expectTypeOf().toEqualTypeOf(); +}); + +test('pgInt4Descriptor.codecId is the literal `pg/int4@1`', () => { + expectTypeOf(pgInt4Descriptor.codecId).toEqualTypeOf<'pg/int4@1'>(); +}); + +test('pgNumericDescriptor.codecId is the literal `pg/numeric@1`', () => { + expectTypeOf(pgNumericDescriptor.codecId).toEqualTypeOf<'pg/numeric@1'>(); +}); + +test('CodecTypes is keyed by codec id and exposes input/output/traits', () => { + expectTypeOf().toExtend<{ + readonly input: number; + readonly output: number; + readonly traits: 'equality' | 'order' | 'numeric'; + }>(); + + expectTypeOf().toExtend<{ + readonly input: string; + readonly output: string; + }>(); +}); + +test('widened trait shape on pgInt4 fails the equality check', () => { + type Traits = PgInt4Descriptor['traits']; + // @ts-expect-error -- traits literal tuple is preserved, not widened to CodecTrait[] + expectTypeOf().toEqualTypeOf(); +}); + +test('non-existent codec id is absent from CodecTypes', () => { + // @ts-expect-error -- `pg/nonexistent@1` is not a registered codec id + type _Missing = CodecTypes['pg/nonexistent@1']; +}); diff --git a/packages/3-targets/3-targets/sqlite/src/core/codec-helpers.ts b/packages/3-targets/3-targets/sqlite/src/core/codec-helpers.ts new file mode 100644 index 0000000000..5295af3f2f --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/codec-helpers.ts @@ -0,0 +1,11 @@ +/** + * Local `JsonValue` alias for the SQLite target. Codec implementations live in `codecs.ts` (TML-2357); this module retains only the JSON-shape alias the surrounding adapter and tests still import. + */ + +export type JsonValue = + | string + | number + | boolean + | null + | { readonly [key: string]: JsonValue } + | readonly JsonValue[]; diff --git a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts index d7a0881b69..748d5dfed4 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/codecs.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/codecs.ts @@ -1,4 +1,34 @@ -import { codec, defineCodecs, sqlCodecDefinitions } from '@prisma-next/sql-relational-core/ast'; +/** + * Native SQLite target codecs (TML-2357). Mirrors the Postgres codec class form in `packages/3-targets/3-targets/postgres/src/core/codecs.ts`. + * + * Each codec ships as three artifacts: + * + * 1. A `SqliteXCodec` class extending {@link CodecImpl} that wraps the encode/decode/encodeJson/decodeJson conversions inline. SQLite's runtime conversions are simple enough that there is no shared helper module; the class bodies are the single source of truth. 2. A `SqliteXDescriptor` class extending {@link CodecDescriptorImpl} declaring the codec id, traits, target types, and params schema. SQLite codecs do not carry + * `meta` (no per-target native-type meta today) and are all non-parameterized. 3. A per-codec column helper (`sqliteXColumn`) that calls `descriptor.factory()` directly and packages the result into a {@link ColumnSpec} via the framework {@link column} packager. The helper is tied to its descriptor with `satisfies ColumnHelperFor` + `ColumnHelperForStrict` (every SQLite codec's resolved type is well-defined). + * + * After TML-2357 this is the canonical source of SQLite codec metadata and runtime behaviour — the legacy `mkCodec` / `defineCodec` carriers (and the parallel `byScalar` / `codecDescriptorDefinitions` collection exports) retired with the deletion sweep. + * + * Audit: every SQLite codec is non-parameterized and parameter-stateless; `factory()` takes no params (`P = void`) and returns a fresh codec constructed solely from `this`. + */ + +import type { JsonValue } from '@prisma-next/contract/types'; +import { + type AnyCodecDescriptor, + type CodecCallContext, + CodecDescriptorImpl, + CodecImpl, + type CodecInstanceContext, + type ColumnHelperFor, + type ColumnHelperForStrict, + column, + voidParamsSchema, +} from '@prisma-next/framework-components/codec'; +import { + sqlCharDescriptor, + sqlFloatDescriptor, + sqlIntDescriptor, + sqlVarcharDescriptor, +} from '@prisma-next/sql-relational-core/ast'; import { SQLITE_BIGINT_CODEC_ID, SQLITE_BLOB_CODEC_ID, @@ -9,111 +39,299 @@ import { SQLITE_TEXT_CODEC_ID, } from './codec-ids'; -const sqlCharCodec = sqlCodecDefinitions.char.codec; -const sqlVarcharCodec = sqlCodecDefinitions.varchar.codec; -const sqlIntCodec = sqlCodecDefinitions.int.codec; -const sqlFloatCodec = sqlCodecDefinitions.float.codec; - -export type JsonValue = - | string - | number - | boolean - | null - | { readonly [key: string]: JsonValue } - | readonly JsonValue[]; - -const sqliteTextCodec = codec({ - typeId: SQLITE_TEXT_CODEC_ID, - targetTypes: ['text'], - traits: ['equality', 'order', 'textual'], - encode: (value: string): string => value, - decode: (wire: string): string => wire, -}); - -const sqliteIntegerCodec = codec({ - typeId: SQLITE_INTEGER_CODEC_ID, - targetTypes: ['integer'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number): number => value, - decode: (wire: number): number => wire, -}); - -const sqliteRealCodec = codec({ - typeId: SQLITE_REAL_CODEC_ID, - targetTypes: ['real'], - traits: ['equality', 'order', 'numeric'], - encode: (value: number): number => value, - decode: (wire: number): number => wire, -}); - -const sqliteBlobCodec = codec({ - typeId: SQLITE_BLOB_CODEC_ID, - targetTypes: ['blob'], - traits: ['equality'], - encode: (value: Uint8Array): Uint8Array => value, - decode: (wire: Uint8Array): Uint8Array => wire, - encodeJson: (value: Uint8Array): string => Buffer.from(value).toString('base64'), - decodeJson: (json: JsonValue): Uint8Array => { +export class SqliteTextCodec extends CodecImpl< + typeof SQLITE_TEXT_CODEC_ID, + readonly ['equality', 'order', 'textual'], + string, + string +> { + async encode(value: string, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: string): JsonValue { + return value; + } + decodeJson(json: JsonValue): string { + return json as string; + } +} + +export class SqliteTextDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQLITE_TEXT_CODEC_ID; + override readonly traits = ['equality', 'order', 'textual'] as const; + override readonly targetTypes = ['text'] as const; + override readonly paramsSchema = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqliteTextCodec { + return () => new SqliteTextCodec(this); + } +} + +export const sqliteTextDescriptor = new SqliteTextDescriptor(); + +export const sqliteTextColumn = () => + column(sqliteTextDescriptor.factory(), sqliteTextDescriptor.codecId, undefined, 'text'); + +sqliteTextColumn satisfies ColumnHelperFor; +sqliteTextColumn satisfies ColumnHelperForStrict; + +export class SqliteIntegerCodec extends CodecImpl< + typeof SQLITE_INTEGER_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number, + number +> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +export class SqliteIntegerDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQLITE_INTEGER_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['integer'] as const; + override readonly paramsSchema = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqliteIntegerCodec { + return () => new SqliteIntegerCodec(this); + } +} + +export const sqliteIntegerDescriptor = new SqliteIntegerDescriptor(); + +export const sqliteIntegerColumn = () => + column(sqliteIntegerDescriptor.factory(), sqliteIntegerDescriptor.codecId, undefined, 'integer'); + +sqliteIntegerColumn satisfies ColumnHelperFor; +sqliteIntegerColumn satisfies ColumnHelperForStrict; + +export class SqliteRealCodec extends CodecImpl< + typeof SQLITE_REAL_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number, + number +> { + async encode(value: number, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: number): JsonValue { + return value; + } + decodeJson(json: JsonValue): number { + return json as number; + } +} + +export class SqliteRealDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQLITE_REAL_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['real'] as const; + override readonly paramsSchema = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqliteRealCodec { + return () => new SqliteRealCodec(this); + } +} + +export const sqliteRealDescriptor = new SqliteRealDescriptor(); + +export const sqliteRealColumn = () => + column(sqliteRealDescriptor.factory(), sqliteRealDescriptor.codecId, undefined, 'real'); + +sqliteRealColumn satisfies ColumnHelperFor; +sqliteRealColumn satisfies ColumnHelperForStrict; + +export class SqliteBlobCodec extends CodecImpl< + typeof SQLITE_BLOB_CODEC_ID, + readonly ['equality'], + Uint8Array, + Uint8Array +> { + async encode(value: Uint8Array, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: Uint8Array, _ctx: CodecCallContext): Promise { + return wire; + } + encodeJson(value: Uint8Array): JsonValue { + return Buffer.from(value).toString('base64'); + } + decodeJson(json: JsonValue): Uint8Array { if (typeof json !== 'string') { throw new TypeError('sqlite/blob@1 contract value must be a base64 string'); } return new Uint8Array(Buffer.from(json, 'base64')); - }, -}); - -const sqliteDatetimeCodec = codec({ - typeId: SQLITE_DATETIME_CODEC_ID, - targetTypes: ['text'], - traits: ['equality', 'order'], - encode: (value: Date): string => value.toISOString(), - decode: (wire: string): Date => new Date(wire), - encodeJson: (value: Date): string => value.toISOString(), - decodeJson: (json: JsonValue): Date => { + } +} + +export class SqliteBlobDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQLITE_BLOB_CODEC_ID; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['blob'] as const; + override readonly paramsSchema = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqliteBlobCodec { + return () => new SqliteBlobCodec(this); + } +} + +export const sqliteBlobDescriptor = new SqliteBlobDescriptor(); + +export const sqliteBlobColumn = () => + column(sqliteBlobDescriptor.factory(), sqliteBlobDescriptor.codecId, undefined, 'blob'); + +sqliteBlobColumn satisfies ColumnHelperFor; +sqliteBlobColumn satisfies ColumnHelperForStrict; + +export class SqliteDatetimeCodec extends CodecImpl< + typeof SQLITE_DATETIME_CODEC_ID, + readonly ['equality', 'order'], + string, + Date +> { + // Reject `Invalid Date` (NaN-time) at every decode ingress so consumers never receive a Date object whose downstream operations silently produce NaN. Mirrors the stricter ISO-8601 validation on the postgres timestamp helpers. + private parseDate(value: string): Date { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new TypeError(`sqlite/datetime@1 value must be a valid ISO-8601 string: ${value}`); + } + return date; + } + async encode(value: Date, _ctx: CodecCallContext): Promise { + return value.toISOString(); + } + async decode(wire: string, _ctx: CodecCallContext): Promise { + return this.parseDate(wire); + } + encodeJson(value: Date): JsonValue { + return value.toISOString(); + } + decodeJson(json: JsonValue): Date { if (typeof json !== 'string') { throw new TypeError('sqlite/datetime@1 contract value must be an ISO-8601 string'); } - return new Date(json); - }, -}); - -const sqliteJsonCodec = codec({ - typeId: SQLITE_JSON_CODEC_ID, - targetTypes: ['text'], - traits: ['equality'], - encode: (value: JsonValue): string => JSON.stringify(value), - decode: (wire: string | JsonValue): JsonValue => - typeof wire === 'string' ? (JSON.parse(wire) as JsonValue) : wire, -}); - -const sqliteBigintCodec = codec({ - typeId: SQLITE_BIGINT_CODEC_ID, - targetTypes: ['integer'], - traits: ['equality', 'order', 'numeric'], - encode: (value: bigint): number | bigint => value, - decode: (wire: number | bigint): bigint => BigInt(wire), - encodeJson: (value: bigint): string => value.toString(), - decodeJson: (json: JsonValue): bigint => { + return this.parseDate(json); + } +} + +export class SqliteDatetimeDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQLITE_DATETIME_CODEC_ID; + override readonly traits = ['equality', 'order'] as const; + override readonly targetTypes = ['text'] as const; + override readonly paramsSchema = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqliteDatetimeCodec { + return () => new SqliteDatetimeCodec(this); + } +} + +export const sqliteDatetimeDescriptor = new SqliteDatetimeDescriptor(); + +export const sqliteDatetimeColumn = () => + column(sqliteDatetimeDescriptor.factory(), sqliteDatetimeDescriptor.codecId, undefined, 'text'); + +sqliteDatetimeColumn satisfies ColumnHelperFor; +sqliteDatetimeColumn satisfies ColumnHelperForStrict; + +export class SqliteJsonCodec extends CodecImpl< + typeof SQLITE_JSON_CODEC_ID, + readonly ['equality'], + string | JsonValue, + JsonValue +> { + async encode(value: JsonValue, _ctx: CodecCallContext): Promise { + return JSON.stringify(value); + } + async decode(wire: string | JsonValue, _ctx: CodecCallContext): Promise { + return typeof wire === 'string' ? (JSON.parse(wire) as JsonValue) : wire; + } + encodeJson(value: JsonValue): JsonValue { + return value; + } + decodeJson(json: JsonValue): JsonValue { + return json; + } +} + +export class SqliteJsonDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQLITE_JSON_CODEC_ID; + override readonly traits = ['equality'] as const; + override readonly targetTypes = ['text'] as const; + override readonly paramsSchema = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqliteJsonCodec { + return () => new SqliteJsonCodec(this); + } +} + +export const sqliteJsonDescriptor = new SqliteJsonDescriptor(); + +export const sqliteJsonColumn = () => + column(sqliteJsonDescriptor.factory(), sqliteJsonDescriptor.codecId, undefined, 'text'); + +sqliteJsonColumn satisfies ColumnHelperFor; +sqliteJsonColumn satisfies ColumnHelperForStrict; + +export class SqliteBigintCodec extends CodecImpl< + typeof SQLITE_BIGINT_CODEC_ID, + readonly ['equality', 'order', 'numeric'], + number | bigint, + bigint +> { + async encode(value: bigint, _ctx: CodecCallContext): Promise { + return value; + } + async decode(wire: number | bigint, _ctx: CodecCallContext): Promise { + return BigInt(wire); + } + encodeJson(value: bigint): JsonValue { + return value.toString(); + } + decodeJson(json: JsonValue): bigint { if (typeof json !== 'string' && typeof json !== 'number') { throw new TypeError('sqlite/bigint@1 contract value must be a string or number'); } return BigInt(json); - }, -}); - -const codecs = defineCodecs() - .add('char', sqlCharCodec) - .add('varchar', sqlVarcharCodec) - .add('int', sqlIntCodec) - .add('float', sqlFloatCodec) - .add('text', sqliteTextCodec) - .add('integer', sqliteIntegerCodec) - .add('real', sqliteRealCodec) - .add('blob', sqliteBlobCodec) - .add('datetime', sqliteDatetimeCodec) - .add('json', sqliteJsonCodec) - .add('bigint', sqliteBigintCodec); - -export const codecDefinitions = codecs.codecDefinitions; -export const dataTypes = codecs.dataTypes; - -export type CodecTypes = typeof codecs.CodecTypes; + } +} + +export class SqliteBigintDescriptor extends CodecDescriptorImpl { + override readonly codecId = SQLITE_BIGINT_CODEC_ID; + override readonly traits = ['equality', 'order', 'numeric'] as const; + override readonly targetTypes = ['integer'] as const; + override readonly paramsSchema = voidParamsSchema; + override factory(): (ctx: CodecInstanceContext) => SqliteBigintCodec { + return () => new SqliteBigintCodec(this); + } +} + +export const sqliteBigintDescriptor = new SqliteBigintDescriptor(); + +export const sqliteBigintColumn = () => + column(sqliteBigintDescriptor.factory(), sqliteBigintDescriptor.codecId, undefined, 'integer'); + +sqliteBigintColumn satisfies ColumnHelperFor; +sqliteBigintColumn satisfies ColumnHelperForStrict; + +export const codecDescriptors: readonly AnyCodecDescriptor[] = [ + sqlCharDescriptor, + sqlVarcharDescriptor, + sqlIntDescriptor, + sqlFloatDescriptor, + sqliteTextDescriptor, + sqliteIntegerDescriptor, + sqliteRealDescriptor, + sqliteBlobDescriptor, + sqliteDatetimeDescriptor, + sqliteJsonDescriptor, + sqliteBigintDescriptor, +]; diff --git a/packages/3-targets/3-targets/sqlite/src/core/descriptor-meta.ts b/packages/3-targets/3-targets/sqlite/src/core/descriptor-meta.ts index 9cb243ed63..c29e052ab4 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/descriptor-meta.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/descriptor-meta.ts @@ -1,5 +1,5 @@ +import type { CodecTypes } from '../exports/codec-types'; import { sqliteAuthoringFieldPresets } from './authoring'; -import type { CodecTypes } from './codecs'; const sqliteTargetDescriptorMetaBase = { kind: 'target', diff --git a/packages/3-targets/3-targets/sqlite/src/core/registry.ts b/packages/3-targets/3-targets/sqlite/src/core/registry.ts new file mode 100644 index 0000000000..5c04217921 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/src/core/registry.ts @@ -0,0 +1,11 @@ +import { buildCodecDescriptorRegistry } from '@prisma-next/sql-relational-core/codec-descriptor-registry'; +import type { CodecDescriptorRegistry } from '@prisma-next/sql-relational-core/query-lane-context'; +import { codecDescriptors } from './codecs'; + +/** + * Registry of every codec descriptor shipped by `@prisma-next/target-sqlite`. + * + * Public consumer surface for the sqlite codec set: the sqlite adapter and any other consumer that needs to enumerate or look up a sqlite codec by id consumes this rather than the raw descriptor array. See ADR 208. + */ +export const sqliteCodecRegistry: CodecDescriptorRegistry = + buildCodecDescriptorRegistry(codecDescriptors); diff --git a/packages/3-targets/3-targets/sqlite/src/core/runtime-target.ts b/packages/3-targets/3-targets/sqlite/src/core/runtime-target.ts index 9235f324e1..5bf5fb0efc 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/runtime-target.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/runtime-target.ts @@ -1,5 +1,4 @@ import type { RuntimeTargetInstance } from '@prisma-next/framework-components/execution'; -import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; import type { SqlRuntimeTargetDescriptor } from '@prisma-next/sql-runtime'; import { sqliteTargetDescriptorMeta } from './descriptor-meta'; @@ -10,8 +9,7 @@ const sqliteRuntimeTargetDescriptor: SqlRuntimeTargetDescriptor< SqliteRuntimeTargetInstance > = { ...sqliteTargetDescriptorMeta, - codecs: () => createCodecRegistry(), - parameterizedCodecs: () => [], + codecs: () => [], create(): SqliteRuntimeTargetInstance { return { familyId: 'sql', diff --git a/packages/3-targets/3-targets/sqlite/src/exports/codec-types.ts b/packages/3-targets/3-targets/sqlite/src/exports/codec-types.ts index eb6f22e860..4852f0ff05 100644 --- a/packages/3-targets/3-targets/sqlite/src/exports/codec-types.ts +++ b/packages/3-targets/3-targets/sqlite/src/exports/codec-types.ts @@ -1,6 +1,43 @@ -import type { CodecTypes as CoreCodecTypes, JsonValue } from '../core/codecs'; +/** + * Codec type definitions for the SQLite target. + * + * Defining `CodecTypes` here (rather than re-exporting from `core/codecs`) keeps the tsdown DTS bundler from emitting a private chunk path in downstream `.d.mts` files: consumers see `CodecTypes` resolved via this public entry point rather than via a hash-named internal chunk (TML-2357). + */ -export type CodecTypes = CoreCodecTypes; +import type { ExtractCodecTypes } from '@prisma-next/sql-relational-core/ast'; +import { + sqlCharDescriptor, + sqlFloatDescriptor, + sqlIntDescriptor, + sqlVarcharDescriptor, +} from '@prisma-next/sql-relational-core/ast'; +import type { JsonValue } from '../core/codec-helpers'; +import { + sqliteBigintDescriptor, + sqliteBlobDescriptor, + sqliteDatetimeDescriptor, + sqliteIntegerDescriptor, + sqliteJsonDescriptor, + sqliteRealDescriptor, + sqliteTextDescriptor, +} from '../core/codecs'; + +const codecDescriptorMap = { + char: sqlCharDescriptor, + varchar: sqlVarcharDescriptor, + int: sqlIntDescriptor, + float: sqlFloatDescriptor, + text: sqliteTextDescriptor, + integer: sqliteIntegerDescriptor, + real: sqliteRealDescriptor, + blob: sqliteBlobDescriptor, + datetime: sqliteDatetimeDescriptor, + json: sqliteJsonDescriptor, + bigint: sqliteBigintDescriptor, +} as const; + +type Resolve = { readonly [K in keyof T]: { readonly [P in keyof T[K]]: T[K][P] } }; + +export type CodecTypes = Resolve>; export type { JsonValue }; -export { dataTypes } from '../core/codecs'; diff --git a/packages/3-targets/3-targets/sqlite/src/exports/codecs.ts b/packages/3-targets/3-targets/sqlite/src/exports/codecs.ts index 6db4a688a5..c52e19928e 100644 --- a/packages/3-targets/3-targets/sqlite/src/exports/codecs.ts +++ b/packages/3-targets/3-targets/sqlite/src/exports/codecs.ts @@ -1 +1,20 @@ -export { type CodecTypes, codecDefinitions, dataTypes, type JsonValue } from '../core/codecs'; +export type { JsonValue } from '../core/codec-helpers'; +export type { + SqliteBigintDescriptor, + SqliteBlobDescriptor, + SqliteDatetimeDescriptor, + SqliteIntegerDescriptor, + SqliteJsonDescriptor, + SqliteRealDescriptor, + SqliteTextDescriptor, +} from '../core/codecs'; +export { + sqliteBigintColumn, + sqliteBlobColumn, + sqliteDatetimeColumn, + sqliteIntegerColumn, + sqliteJsonColumn, + sqliteRealColumn, + sqliteTextColumn, +} from '../core/codecs'; +export { sqliteCodecRegistry } from '../core/registry'; diff --git a/packages/3-targets/3-targets/sqlite/test/codecs-class.types.test-d.ts b/packages/3-targets/3-targets/sqlite/test/codecs-class.types.test-d.ts new file mode 100644 index 0000000000..61b92915e6 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/codecs-class.types.test-d.ts @@ -0,0 +1,88 @@ +/** + * Type tests for the SQLite target codecs. + * + * Mirrors `packages/3-targets/3-targets/postgres/test/codecs-class.types.test-d.ts`. + * + * Coverage selection: every SQLite codec is non-parameterized, so the tests focus on representative codecs that exercise distinct input/wire types — a numeric (`integer`), a typed `Date` mapping (`datetime`, wire `string` ≠ input `Date`), a binary mapping (`blob`, wire `Uint8Array`), and a bigint mapping (`bigint`, wire `number | bigint` ≠ input `bigint`). The framework-level type discipline is exercised in `framework-components/test/codec.types.test-d.ts`. + */ + +import { + type CodecInstanceContext, + type ColumnHelperFor, + type ColumnHelperForStrict, + column, +} from '@prisma-next/framework-components/codec'; +import { expectTypeOf, test } from 'vitest'; +import { + type SqliteBigintCodec, + type SqliteBigintDescriptor, + type SqliteBlobCodec, + type SqliteBlobDescriptor, + type SqliteDatetimeCodec, + type SqliteDatetimeDescriptor, + type SqliteIntegerCodec, + type SqliteIntegerDescriptor, + sqliteBigintColumn, + sqliteBigintDescriptor, + sqliteBlobColumn, + sqliteBlobDescriptor, + sqliteDatetimeColumn, + sqliteDatetimeDescriptor, + sqliteIntegerColumn, + sqliteIntegerDescriptor, +} from '../src/core/codecs'; + +test('sqliteInteger: descriptor.factory() returns typed (ctx) => SqliteIntegerCodec', () => { + const factory = sqliteIntegerDescriptor.factory(); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqliteIntegerCodec>(); +}); + +test('sqliteInteger: column helper preserves typed codecFactory + undefined typeParams', () => { + const col = sqliteIntegerColumn(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqliteIntegerCodec>(); + expectTypeOf(col.typeParams).toEqualTypeOf(); +}); + +test('sqliteDatetime: column preserves the wire-string / input-Date split', () => { + const factory = sqliteDatetimeDescriptor.factory(); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqliteDatetimeCodec>(); + const col = sqliteDatetimeColumn(); + expectTypeOf(col.codecFactory).toEqualTypeOf< + (ctx: CodecInstanceContext) => SqliteDatetimeCodec + >(); +}); + +test('sqliteBlob: column preserves Uint8Array codec type', () => { + const factory = sqliteBlobDescriptor.factory(); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqliteBlobCodec>(); + const col = sqliteBlobColumn(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqliteBlobCodec>(); +}); + +test('sqliteBigint: column preserves the (number|bigint) wire / bigint input split', () => { + const factory = sqliteBigintDescriptor.factory(); + expectTypeOf(factory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqliteBigintCodec>(); + const col = sqliteBigintColumn(); + expectTypeOf(col.codecFactory).toEqualTypeOf<(ctx: CodecInstanceContext) => SqliteBigintCodec>(); +}); + +sqliteIntegerColumn satisfies ColumnHelperFor; +sqliteIntegerColumn satisfies ColumnHelperForStrict; + +sqliteDatetimeColumn satisfies ColumnHelperFor; +sqliteDatetimeColumn satisfies ColumnHelperForStrict; + +sqliteBlobColumn satisfies ColumnHelperFor; +sqliteBlobColumn satisfies ColumnHelperForStrict; + +sqliteBigintColumn satisfies ColumnHelperFor; +sqliteBigintColumn satisfies ColumnHelperForStrict; + +test('strict satisfies catches wrong codec wired in', () => { + // Wire the integer descriptor's factory into the bigint descriptor's slot. Coarse satisfies passes (both have `void` typeParams); strict satisfies fails because the codec types differ (SqliteIntegerCodec ≠ SqliteBigintCodec). + const wrongCodecHelper = () => + column(sqliteIntegerDescriptor.factory(), sqliteBigintDescriptor.codecId, undefined, 'integer'); + wrongCodecHelper satisfies ColumnHelperFor; + // @ts-expect-error -- codec is SqliteIntegerCodec, not SqliteBigintCodec + wrongCodecHelper satisfies ColumnHelperForStrict; +}); diff --git a/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts b/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts new file mode 100644 index 0000000000..c38cd001c6 --- /dev/null +++ b/packages/3-targets/3-targets/sqlite/test/typed-descriptor-flow.test-d.ts @@ -0,0 +1,64 @@ +/** + * Constructive type tests for the sqlite per-target descriptor record layer (TML-2357). Mirrors the postgres test (`packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts`). + */ + +import type { AnyCodecDescriptor, CodecTrait } from '@prisma-next/framework-components/codec'; +import { expectTypeOf, test } from 'vitest'; +import { + codecDescriptors, + type SqliteDatetimeDescriptor, + type SqliteIntegerDescriptor, + sqliteDatetimeDescriptor, + sqliteIntegerDescriptor, +} from '../src/core/codecs'; +import type { CodecTypes } from '../src/exports/codec-types'; + +test('codecDescriptors narrows to readonly AnyCodecDescriptor[]', () => { + expectTypeOf(codecDescriptors).toEqualTypeOf(); +}); + +test('list entries extend AnyCodecDescriptor', () => { + expectTypeOf<(typeof codecDescriptors)[number]>().toExtend(); +}); + +test('sqliteIntegerDescriptor.traits is a readonly literal tuple, not widened', () => { + type Traits = SqliteIntegerDescriptor['traits']; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toExtend(); +}); + +test('sqliteDatetimeDescriptor.traits preserves its literal tuple', () => { + type Traits = SqliteDatetimeDescriptor['traits']; + expectTypeOf().toEqualTypeOf(); +}); + +test('sqliteIntegerDescriptor.codecId is the literal `sqlite/integer@1`', () => { + expectTypeOf(sqliteIntegerDescriptor.codecId).toEqualTypeOf<'sqlite/integer@1'>(); +}); + +test('sqliteDatetimeDescriptor.codecId is the literal `sqlite/datetime@1`', () => { + expectTypeOf(sqliteDatetimeDescriptor.codecId).toEqualTypeOf<'sqlite/datetime@1'>(); +}); + +test('CodecTypes is keyed by codec id and exposes input/output/traits', () => { + expectTypeOf().toExtend<{ + readonly input: number; + readonly output: number; + readonly traits: 'equality' | 'order' | 'numeric'; + }>(); + + expectTypeOf().toExtend<{ + readonly input: Date; + }>(); +}); + +test('widened trait shape on sqliteInteger fails the equality check', () => { + type Traits = SqliteIntegerDescriptor['traits']; + // @ts-expect-error -- traits literal tuple is preserved, not widened to CodecTrait[] + expectTypeOf().toEqualTypeOf(); +}); + +test('non-existent codec id is absent from CodecTypes', () => { + // @ts-expect-error -- `sqlite/nonexistent@1` is not a registered codec id + type _Missing = CodecTypes['sqlite/nonexistent@1']; +}); diff --git a/packages/3-targets/6-adapters/postgres/README.md b/packages/3-targets/6-adapters/postgres/README.md index 8e0f12744d..a10f81691d 100644 --- a/packages/3-targets/6-adapters/postgres/README.md +++ b/packages/3-targets/6-adapters/postgres/README.md @@ -133,11 +133,7 @@ flowchart TD - Exports raw JSON helpers: - `jsonColumn`, `jsonbColumn` — untyped raw JSON / JSONB column descriptors - - For schema-typed JSON columns, use the per-library extension package - (`@prisma-next/extension-arktype-json` for arktype). The - schema-accepting `json(schema)` / `jsonb(schema)` overloads - previously shipped here retired in Phase C of the - codec-registry-unification project. + - For schema-typed JSON columns, use the per-library extension package (`@prisma-next/extension-arktype-json` for arktype). The schema-accepting `json(schema)` / `jsonb(schema)` overloads previously shipped here retired in Phase C of the codec-registry-unification project. ## Dependencies @@ -300,18 +296,14 @@ table('event', (t) => ### Typed fallback behavior -- For schema-typed columns, use a per-library extension package - (e.g. `@prisma-next/extension-arktype-json`). The emit-path renderer - reads the schema's `expression` from typeParams and produces a concrete - TS type in `contract.d.ts`. -- For untyped columns (`jsonColumn`, `jsonbColumn`), the emitted type - falls back to `JsonValue`. +- For schema-typed columns, use a per-library extension package (e.g. `@prisma-next/extension-arktype-json`). The emit-path renderer reads the schema's `expression` from typeParams and produces a concrete TS type in `contract.d.ts`. +- For untyped columns (`jsonColumn`, `jsonbColumn`), the emitted type falls back to `JsonValue`. - Runtime values still encode/decode as JSON-compatible values. ## Exports - `./adapter`: Adapter implementation (`createPostgresAdapter`) -- `./codec-types`: PostgreSQL codec types (`CodecTypes`, `JsonValue`, `dataTypes`) +- `./codec-types`: PostgreSQL codec types (`CodecTypes`, `JsonValue`) - `./column-types`: Column type descriptors and authoring helpers (`jsonColumn`, `jsonbColumn`, `enumType`, `enumColumn`, `textColumn`, `int4Column`, etc.) - `./types`: PostgreSQL-specific types - `./control`: Control-plane entry point (adapter descriptor) diff --git a/packages/3-targets/6-adapters/postgres/src/core/adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/adapter.ts index 966c36413d..77931295dd 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/adapter.ts @@ -1,15 +1,11 @@ import type { CodecLookup } from '@prisma-next/framework-components/codec'; -import { - type Adapter, - type AdapterProfile, - type AnyQueryAst, - type CodecParamsDescriptor, - createCodecRegistry, - type LowererContext, +import type { + Adapter, + AdapterProfile, + AnyQueryAst, + LowererContext, } from '@prisma-next/sql-relational-core/ast'; import { parseContractMarkerRow } from '@prisma-next/sql-runtime'; -import { codecDefinitions } from '@prisma-next/target-postgres/codecs'; -import { ifDefined } from '@prisma-next/utils/defined'; import { createPostgresBuiltinCodecLookup } from './codec-lookup'; import { renderLoweredSql } from './sql-renderer'; import type { PostgresAdapterOptions, PostgresContract, PostgresLoweredStatement } from './types'; @@ -29,38 +25,14 @@ const defaultCapabilities = Object.freeze({ }, }); -type AdapterCodec = (typeof codecDefinitions)[keyof typeof codecDefinitions]['codec']; -type ParameterizedCodec = AdapterCodec & { - readonly paramsSchema: NonNullable; -}; - -const parameterizedCodecs: ReadonlyArray = Object.values(codecDefinitions) - .map((definition) => definition.codec) - .filter((codec): codec is ParameterizedCodec => codec.paramsSchema !== undefined) - .map((codec) => - Object.freeze({ - codecId: codec.id, - paramsSchema: codec.paramsSchema, - ...ifDefined('init', codec.init), - }), - ); - class PostgresAdapterImpl implements Adapter { - // These fields make the adapter instance structurally compatible with - // RuntimeAdapterInstance<'sql', 'postgres'> without introducing a runtime-plane dependency. + // These fields make the adapter instance structurally compatible with RuntimeAdapterInstance<'sql', 'postgres'> without introducing a runtime-plane dependency. readonly familyId = 'sql' as const; readonly targetId = 'postgres' as const; readonly profile: AdapterProfile<'postgres'>; - private readonly codecRegistry = (() => { - const registry = createCodecRegistry(); - for (const definition of Object.values(codecDefinitions)) { - registry.register(definition.codec); - } - return registry; - })(); private readonly codecLookup: CodecLookup; constructor(options?: PostgresAdapterOptions) { @@ -69,21 +41,15 @@ class PostgresAdapterImpl id: options?.profileId ?? 'postgres/default@1', target: 'postgres', capabilities: defaultCapabilities, - codecs: () => this.codecRegistry, readMarkerStatement: () => ({ sql: 'select core_hash, profile_hash, contract_json, canonical_version, updated_at, app_tag, meta, invariants from prisma_contract.marker where id = $1', params: [1], }), - // Postgres' driver hydrates `text[]` columns as native JS arrays, so - // the row is already in the shape the shared parser expects. + // Postgres' driver hydrates `text[]` columns as native JS arrays, so the row is already in the shape the shared parser expects. parseMarkerRow: (row: unknown) => parseContractMarkerRow(row), }); } - parameterizedCodecs(): ReadonlyArray { - return parameterizedCodecs; - } - lower(ast: AnyQueryAst, context: LowererContext): PostgresLoweredStatement { return renderLoweredSql(ast, context.contract, this.codecLookup); } diff --git a/packages/3-targets/6-adapters/postgres/src/core/codec-lookup.ts b/packages/3-targets/6-adapters/postgres/src/core/codec-lookup.ts index 518f4ee447..76b50baa96 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/codec-lookup.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/codec-lookup.ts @@ -1,24 +1,19 @@ -import type { Codec, CodecLookup } from '@prisma-next/framework-components/codec'; -import { codecDefinitions } from '@prisma-next/target-postgres/codecs'; +import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; +import { postgresCodecRegistry } from '@prisma-next/target-postgres/codecs'; /** - * Build a {@link CodecLookup} populated with the Postgres-builtin codec - * definitions only. + * Build a {@link CodecLookup} populated with the Postgres-builtin codec definitions only. * - * This is the default lookup used by `createPostgresAdapter()` and - * `new PostgresControlAdapter()` when called without a stack-derived lookup - * (e.g. from tests, or one-off scripts that don't compose a full stack). + * This is the default lookup used by `createPostgresAdapter()` and `new PostgresControlAdapter()` when called without a stack-derived lookup (e.g. from tests, or one-off scripts that don't compose a full stack). * - * Extension codecs (e.g. `pg/vector@1` from `@prisma-next/extension-pgvector`) - * are intentionally NOT included here: a bare adapter cannot see extensions. - * Stack-composed paths (`SqlControlAdapterDescriptor.create(stack)` / - * `SqlRuntimeAdapterDescriptor.create(stack)`) supply the broader, - * extension-inclusive lookup at construction time. + * Extension codecs (e.g. `pg/vector@1` from `@prisma-next/extension-pgvector`) are intentionally NOT included here: a bare adapter cannot see extensions. Stack-composed paths (`SqlControlAdapterDescriptor.create(stack)` / `SqlRuntimeAdapterDescriptor.create(stack)`) supply the broader, extension-inclusive lookup at construction time. */ export function createPostgresBuiltinCodecLookup(): CodecLookup { - const byId = new Map(); - for (const definition of Object.values(codecDefinitions)) { - byId.set(definition.codec.id, definition.codec); - } - return { get: (id) => byId.get(id) }; + return extractCodecLookup([ + { + id: 'postgres-builtin-codecs', + types: { codecTypes: { codecDescriptors: Array.from(postgresCodecRegistry.values()) } }, + }, + ]); } diff --git a/packages/3-targets/6-adapters/postgres/src/core/descriptor-meta.ts b/packages/3-targets/6-adapters/postgres/src/core/descriptor-meta.ts index e3a40ffbda..f4bc4b89a8 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/descriptor-meta.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/descriptor-meta.ts @@ -4,6 +4,7 @@ import { buildOperation, type CodecExpression, type Expression, + refsOf, type TraitExpression, toExpr, } from '@prisma-next/sql-relational-core/expression'; @@ -38,12 +39,10 @@ import { SQL_TIMESTAMP_CODEC_ID, SQL_VARCHAR_CODEC_ID, } from '@prisma-next/target-postgres/codec-ids'; -import { codecDefinitions } from '@prisma-next/target-postgres/codecs'; +import { postgresCodecRegistry } from '@prisma-next/target-postgres/codecs'; import { pgEnumControlHooks } from './enum-control-hooks'; -// ============================================================================ -// Helper functions for reducing boilerplate -// ============================================================================ +// ============================================================================ Helper functions for reducing boilerplate ============================================================================ /** Creates a type import spec for codec types */ const codecTypeImport = (named: string) => @@ -132,9 +131,7 @@ const precisionHooks: CodecControlHooks = { expandNativeType: expandPrecision }; const numericHooks: CodecControlHooks = { expandNativeType: expandNumeric }; const identityHooks: CodecControlHooks = { expandNativeType: ({ nativeType }) => nativeType }; -// ============================================================================ -// Descriptor metadata -// ============================================================================ +// ============================================================================ Descriptor metadata ============================================================================ type CodecTypesBase = Record; @@ -148,13 +145,15 @@ export function postgresQueryOperations< impl: ( self: TraitExpression, pattern: CodecExpression<'pg/text@1', false, CT>, - ): Expression<{ codecId: 'pg/bool@1'; nullable: false }> => - buildOperation({ + ): Expression<{ codecId: 'pg/bool@1'; nullable: false }> => { + const selfRefs = refsOf(self); + return buildOperation({ method: 'ilike', - args: [toExpr(self), toExpr(pattern, PG_TEXT_CODEC_ID)], + args: [toExpr(self), toExpr(pattern, PG_TEXT_CODEC_ID, selfRefs)], returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, lowering: { targetFamily: 'sql', strategy: 'infix', template: '{{self}} ILIKE {{arg0}}' }, - }), + }); + }, }, ]; } @@ -181,7 +180,7 @@ export const postgresAdapterDescriptorMeta = { }, types: { codecTypes: { - codecInstances: Object.values(codecDefinitions).map((def) => def.codec), + codecDescriptors: Array.from(postgresCodecRegistry.values()), import: { package: '@prisma-next/target-postgres/codec-types', named: 'CodecTypes', diff --git a/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts b/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts index dd72e9db6c..9a2496cf46 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts @@ -22,7 +22,6 @@ import { type ParamRef, type ProjectionItem, type SelectAst, - type Codec as SqlCodec, type SubqueryExpr, type UpdateAst, } from '@prisma-next/sql-relational-core/ast'; @@ -30,21 +29,11 @@ import { escapeLiteral, quoteIdentifier } from '@prisma-next/target-postgres/sql import type { PostgresContract } from './types'; /** - * Postgres native types whose unknown-OID parameter inference is reliable in - * arbitrary expression positions. Parameters bound to a codec whose - * `meta.db.sql.postgres.nativeType` falls in this set are emitted as plain - * `$N`; everything else (including `json`, `jsonb`, extension types like - * `vector`, and unknown user types) is emitted as `$N::` so the - * planner picks an unambiguous overload. + * Postgres native types whose unknown-OID parameter inference is reliable in arbitrary expression positions. Parameters bound to a codec whose `meta.db.sql.postgres.nativeType` falls in this set are emitted as plain `$N`; everything else (including `json`, `jsonb`, extension types like `vector`, and unknown user types) is emitted as `$N::` so the planner picks an unambiguous overload. * - * `json` / `jsonb` are intentionally excluded despite being Postgres builtins: - * their operator overloads make context inference unreliable in expression - * positions (e.g. `$1 -> 'key'` is ambiguous between the two). + * `json` / `jsonb` are intentionally excluded despite being Postgres builtins: their operator overloads make context inference unreliable in expression positions (e.g. `$1 -> 'key'` is ambiguous between the two). * - * Spellings match the on-disk `meta.db.sql.postgres.nativeType` values in - * `@prisma-next/target-postgres`'s codec definitions, not the `udt_name` - * abbreviations that ADR 205 used as illustrative shorthand. The lookup-based - * cast policy compares against these strings directly. + * Spellings match the on-disk `meta.db.sql.postgres.nativeType` values in `@prisma-next/target-postgres`'s codec definitions, not the `udt_name` abbreviations that ADR 205 used as illustrative shorthand. The lookup-based cast policy compares against these strings directly. */ const POSTGRES_INFERRABLE_NATIVE_TYPES: ReadonlySet = new Set([ // Numeric @@ -80,12 +69,7 @@ function renderTypedParam( if (codecId === undefined) { return `$${index}`; } - // SQL codecs extend the framework `Codec` base with an optional - // `meta: CodecMeta`; the framework `CodecLookup.get` returns the base type, - // so we narrow to `SqlCodec` to read `meta`. Every codec actually - // registered into a SQL codec lookup conforms to `SqlCodec`. - const codec = codecLookup.get(codecId) as SqlCodec | undefined; - if (codec === undefined) { + if (codecLookup.get(codecId) === undefined) { throw new Error( `Postgres lowering: ParamRef carries codecId "${codecId}" but the ` + 'assembled codec lookup has no entry for it. This usually indicates ' + @@ -95,18 +79,24 @@ function renderTypedParam( "if it's a builtin.", ); } - const nativeType = codec.meta?.db?.sql?.postgres?.nativeType; - if (nativeType !== undefined && !POSTGRES_INFERRABLE_NATIVE_TYPES.has(nativeType)) { + // The framework `CodecLookup.metaFor` returns the family-agnostic `CodecMeta` whose `db` is `Record`. The SQL family populates a narrower shape with `db.sql..nativeType: string`; navigate that path defensively and string-check the leaf. + const meta = codecLookup.metaFor(codecId); + const dbRecord = meta?.db; + const sqlBlock = isRecord(dbRecord) ? dbRecord['sql'] : undefined; + const dialectBlock = isRecord(sqlBlock) ? sqlBlock['postgres'] : undefined; + const nativeType = isRecord(dialectBlock) ? dialectBlock['nativeType'] : undefined; + if (typeof nativeType === 'string' && !POSTGRES_INFERRABLE_NATIVE_TYPES.has(nativeType)) { return `$${index}::${nativeType}`; } return `$${index}`; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + /** - * Per-render carrier threaded through every helper. Bundles the param-index - * map (for `$N` numbering) and the assembled-stack `codecLookup` (for - * cast policy at the `renderTypedParam` chokepoint). Carrying both on a - * single value keeps helper signatures stable. + * Per-render carrier threaded through every helper. Bundles the param-index map (for `$N` numbering) and the assembled-stack `codecLookup` (for cast policy at the `renderTypedParam` chokepoint). Carrying both on a single value keeps helper signatures stable. */ interface ParamIndexMap { readonly indexMap: Map; @@ -116,9 +106,7 @@ interface ParamIndexMap { /** * Render a SQL query AST to a Postgres-flavored `{ sql, params }` payload. * - * Shared between the runtime (`PostgresAdapterImpl.lower`) and control - * (`PostgresControlAdapter.lower`) entrypoints so emit-time and run-time - * paths produce byte-identical output for the same AST. + * Shared between the runtime (`PostgresAdapterImpl.lower`) and control (`PostgresControlAdapter.lower`) entrypoints so emit-time and run-time paths produce byte-identical output for the same AST. */ export function renderLoweredSql( ast: AnyQueryAst, @@ -309,15 +297,9 @@ function renderNullCheck( } /** - * Atomic expression kinds whose rendered SQL is already self-delimited - * (a column reference, parameter, literal, function call, aggregate, etc.) - * and therefore does not need surrounding parentheses when used as the - * left operand of a postfix predicate like `IS NULL` or `IS NOT NULL`, - * or as either operand of a binary infix operator. + * Atomic expression kinds whose rendered SQL is already self-delimited (a column reference, parameter, literal, function call, aggregate, etc.) and therefore does not need surrounding parentheses when used as the left operand of a postfix predicate like `IS NULL` or `IS NOT NULL`, or as either operand of a binary infix operator. * - * Anything not in this set is treated as composite (binary, AND/OR/NOT, - * EXISTS, nested IS NULL, subqueries, operation templates) and gets - * wrapped to preserve grouping. + * Anything not in this set is treated as composite (binary, AND/OR/NOT, EXISTS, nested IS NULL, subqueries, operation templates) and gets wrapped to preserve grouping. */ function isAtomicExpressionKind(kind: AnyExpression['kind']): boolean { switch (kind) { @@ -571,12 +553,7 @@ function renderOperation( return renderExpr(arg, contract, pim); }); - // Resolve `{{self}}` and `{{argN}}` from the original template in a single - // pass. Doing this with sequential `String.prototype.replace` calls is - // unsafe: a substituted fragment can itself contain text that matches a - // later token (e.g. an arg literal containing the substring `{{arg1}}`), - // and the next iteration would corrupt it. A single regex callback never - // re-scans already-substituted output. + // Resolve `{{self}}` and `{{argN}}` from the original template in a single pass. Doing this with sequential `String.prototype.replace` calls is unsafe: a substituted fragment can itself contain text that matches a later token (e.g. an arg literal containing the substring `{{arg1}}`), and the next iteration would corrupt it. A single regex callback never re-scans already-substituted output. return expr.lowering.template.replace( /\{\{self\}\}|\{\{arg(\d+)\}\}/g, (token, argIndex: string | undefined) => { diff --git a/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts b/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts index df84322fac..81e847efae 100644 --- a/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts +++ b/packages/3-targets/6-adapters/postgres/src/exports/column-types.ts @@ -1,11 +1,10 @@ /** * Column type descriptors for Postgres adapter. * - * These descriptors provide both codecId and nativeType for use in contract authoring. - * They are derived from the same source of truth as codec definitions and manifests. + * These descriptors provide both codecId and nativeType for use in contract authoring. They are derived from the same source of truth as codec definitions and manifests. */ -import type { ColumnTypeDescriptor } from '@prisma-next/contract-authoring'; +import type { ColumnTypeDescriptor } from '@prisma-next/framework-components/codec'; import type { StorageTypeInstance } from '@prisma-next/sql-contract/types'; import { PG_BIT_CODEC_ID, @@ -152,10 +151,7 @@ export function varbitColumn(length: number): ColumnTypeDescriptor & { /** * Postgres `bytea` column descriptor — variable-length binary string. * - * Round-trips as `Uint8Array` on the JS side. The pg wire-protocol text - * encoding (`\x` followed by hex-encoded bytes, canonical for Postgres ≥ 9.0) - * and binary encoding are both handled by the underlying driver; the codec - * only normalizes the JS-side representation to a plain `Uint8Array` view. + * Round-trips as `Uint8Array` on the JS side. The pg wire-protocol text encoding (`\x` followed by hex-encoded bytes, canonical for Postgres ≥ 9.0) and binary encoding are both handled by the underlying driver; the codec only normalizes the JS-side representation to a plain `Uint8Array` view. */ export const byteaColumn = { codecId: PG_BYTEA_CODEC_ID, @@ -175,11 +171,7 @@ export function intervalColumn(precision?: number): ColumnTypeDescriptor & { /** * Postgres `json` column descriptor — untyped raw JSON. * - * For schema-typed JSON columns, use the per-library extension package - * (`@prisma-next/extension-arktype-json` ships `arktypeJson(schema)` for - * arktype). The schema-accepting `json(schema)` / `jsonb(schema)` - * overloads previously shipped from this module retired in Phase C of - * the codec-registry-unification project — see spec § AC-7. + * For schema-typed JSON columns, use the per-library extension package (`@prisma-next/extension-arktype-json` ships `arktypeJson(schema)` for arktype). The schema-accepting `json(schema)` / `jsonb(schema)` overloads previously shipped from this module retired in Phase C of the codec-registry-unification project — see spec § AC-7. */ export const jsonColumn = { codecId: PG_JSON_CODEC_ID, @@ -187,8 +179,7 @@ export const jsonColumn = { } as const satisfies ColumnTypeDescriptor; /** - * Postgres `jsonb` column descriptor — untyped raw JSONB. Same retirement - * note as {@link jsonColumn}. + * Postgres `jsonb` column descriptor — untyped raw JSONB. Same retirement note as {@link jsonColumn}. */ export const jsonbColumn = { codecId: PG_JSONB_CODEC_ID, diff --git a/packages/3-targets/6-adapters/postgres/src/exports/runtime.ts b/packages/3-targets/6-adapters/postgres/src/exports/runtime.ts index b70f345acc..3a879b8bbd 100644 --- a/packages/3-targets/6-adapters/postgres/src/exports/runtime.ts +++ b/packages/3-targets/6-adapters/postgres/src/exports/runtime.ts @@ -4,13 +4,9 @@ import { extractCodecLookup } from '@prisma-next/framework-components/control'; import type { RuntimeAdapterInstance } from '@prisma-next/framework-components/execution'; import { builtinGeneratorIds } from '@prisma-next/ids'; import { generateId } from '@prisma-next/ids/runtime'; -import type { Adapter, AnyQueryAst, CodecRegistry } from '@prisma-next/sql-relational-core/ast'; -import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; -import type { - RuntimeParameterizedCodecDescriptor, - SqlRuntimeAdapterDescriptor, -} from '@prisma-next/sql-runtime'; -import { codecDefinitions } from '@prisma-next/target-postgres/codecs'; +import type { Adapter, AnyQueryAst } from '@prisma-next/sql-relational-core/ast'; +import type { SqlRuntimeAdapterDescriptor } from '@prisma-next/sql-runtime'; +import { postgresCodecRegistry } from '@prisma-next/target-postgres/codecs'; import { createPostgresAdapter } from '../core/adapter'; import { postgresAdapterDescriptorMeta, postgresQueryOperations } from '../core/descriptor-meta'; import type { PostgresContract, PostgresLoweredStatement } from '../core/types'; @@ -19,14 +15,6 @@ export interface SqlRuntimeAdapter extends RuntimeAdapterInstance<'sql', 'postgres'>, Adapter {} -function createPostgresCodecRegistry(): CodecRegistry { - const registry = createCodecRegistry(); - for (const definition of Object.values(codecDefinitions)) { - registry.register(definition.codec); - } - return registry; -} - function createPostgresMutationDefaultGenerators() { return [ ...builtinGeneratorIds.map((id) => ({ @@ -41,34 +29,15 @@ function createPostgresMutationDefaultGenerators() { ]; } -/** - * Phase C of codec-registry-unification: the postgres adapter retains - * only static raw-JSON / raw-JSONB column descriptors. Schema-typed JSON - * columns ship from per-library extension packages now — - * `@prisma-next/extension-arktype-json` for arktype, future zod / valibot - * extensions when each lands. The previously-shipped - * `parameterizedCodecDescriptors` for `pg/json@1` / `pg/jsonb@1` retired - * with the schema-typed surface; the unified descriptor map auto-lifts - * the raw json/jsonb codecs from `codecs:` via the synthesis bridge for - * codec-id-keyed metadata reads. - */ -const parameterizedCodecDescriptors: ReadonlyArray = []; - const postgresRuntimeAdapterDescriptor: SqlRuntimeAdapterDescriptor<'postgres', SqlRuntimeAdapter> = { ...postgresAdapterDescriptorMeta, - codecs: createPostgresCodecRegistry, - parameterizedCodecs: () => parameterizedCodecDescriptors, + codecs: () => Array.from(postgresCodecRegistry.values()), queryOperations: () => postgresQueryOperations(), mutationDefaultGenerators: createPostgresMutationDefaultGenerators, create(stack): SqlRuntimeAdapter { - // The runtime `ExecutionStack` does not (yet) carry a pre-assembled - // `codecLookup` field the way the control `ControlStack` does, so we - // derive an equivalent lookup here from the stack's component metadata - // (target + adapter + extension packs) using the same assembly helper - // that `createControlStack` uses. This keeps the renderer fed with the - // same codec set on both planes — including extension-contributed - // codecs like `pg/vector@1` from `@prisma-next/extension-pgvector`. + // The runtime `ExecutionStack` does not (yet) carry a pre-assembled `codecLookup` field the way the control `ControlStack` does, so we derive an equivalent lookup here from the stack's component metadata (target + adapter + extension packs) using the same assembly helper that `createControlStack` uses. This keeps the renderer fed with the same codec set on both planes — including extension-contributed codecs like + // `pg/vector@1` from `@prisma-next/extension-pgvector`. const codecLookup = extractCodecLookup([ stack.target, stack.adapter, diff --git a/packages/3-targets/6-adapters/postgres/test/adapter.test.ts b/packages/3-targets/6-adapters/postgres/test/adapter.test.ts index 3343dc2d19..b0be7be6b5 100644 --- a/packages/3-targets/6-adapters/postgres/test/adapter.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/adapter.test.ts @@ -24,6 +24,7 @@ import { TableSource, UpdateAst, } from '@prisma-next/sql-relational-core/ast'; +import { timeouts } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; import { createPostgresAdapter } from '../src/core/adapter'; import type { PostgresContract } from '../src/core/types'; @@ -286,16 +287,12 @@ describe('Postgres adapter', () => { expect(sql).toContain('WHERE FALSE'); }); - it('exposes profile metadata: codecs registry, capabilities, and readMarkerStatement', () => { + it('exposes profile metadata: capabilities and readMarkerStatement', () => { expect(adapter.profile.target).toBe('postgres'); expect(adapter.profile.id).toBe('postgres/default@1'); expect(adapter.profile.capabilities['postgres']).toMatchObject({ lateral: true }); expect(adapter.profile.capabilities['sql']).toMatchObject({ returning: true }); - const codecs = adapter.profile.codecs(); - expect(codecs.get('pg/text@1')).toBeDefined(); - expect(codecs.has('pg/jsonb@1')).toBe(true); - const marker = adapter.profile.readMarkerStatement(); expect(marker.sql).toContain('from prisma_contract.marker'); expect(marker.params).toEqual([1]); @@ -306,15 +303,22 @@ describe('Postgres adapter', () => { expect(customAdapter.profile.id).toBe('postgres/custom@9'); }); - it('reports parameterized codec descriptors with paramsSchema for every entry', () => { - const descriptors = adapter.parameterizedCodecs(); - expect(descriptors.length).toBeGreaterThan(0); - const ids = descriptors.map((d) => d.codecId); - expect(ids).toEqual(expect.arrayContaining(['pg/numeric@1', 'pg/timestamptz@1'])); - for (const descriptor of descriptors) { - expect(descriptor.paramsSchema).toBeDefined(); - } - }); + it( + 'contributes parameterized codec descriptors through the unified codecs slot', + async () => { + // The contributor protocol is unified: every codec descriptor (parameterized or not) flows through the runtime descriptor's `codecs:` slot. The adapter class itself no longer carries a dedicated parameterized-codec accessor — descriptor metadata lives on the runtime descriptor exported by the package. + // Uses `timeouts.coldTransformImport` because the dynamic `await import('../src/exports/runtime')` triggers vitest's first-pass transform of the runtime module graph (control stack, codec descriptors, descriptor-meta), which can exceed the default 200ms hook timeout on cold CI workers. + const runtimeMod = await import('../src/exports/runtime'); + const descriptors = runtimeMod.default.codecs(); + expect(descriptors.length).toBeGreaterThan(0); + const ids = descriptors.map((d: { codecId: string }) => d.codecId); + expect(ids).toEqual(expect.arrayContaining(['pg/numeric@1', 'pg/timestamptz@1'])); + for (const descriptor of descriptors) { + expect(descriptor.paramsSchema).toBeDefined(); + } + }, + timeouts.coldTransformImport, + ); it('renders DO UPDATE SET with param-ref values and UPDATE SET with column-ref values', () => { const insertWithParamUpdate = InsertAst.into(TableSource.named('user')) @@ -377,15 +381,10 @@ describe('Postgres adapter', () => { }); it('renders multi-row DEFAULT VALUES inserts as `(DEFAULT, …), (DEFAULT, …)` over the contract column order', () => { - // Phase C deleted the schema-typed JSON tests that incidentally - // covered `renderInsert`'s multi-row default-values branch (lines - // walking `defaultColumns` and emitting `(DEFAULT, …)` per row). - // Pin the multi-row default-values shape here so the function- - // coverage % stays above the 95% threshold. + // Phase C deleted the schema-typed JSON tests that incidentally covered `renderInsert`'s multi-row default-values branch (lines walking `defaultColumns` and emitting `(DEFAULT, …)` per row). Pin the multi-row default-values shape here so the function-coverage % stays above the 95% threshold. const ast = InsertAst.into(TableSource.named('user')).withRows([{}, {}]); const sql = adapter.lower(ast, { contract, params: [] }).sql; - // Column order matches the contract storage column order; every - // value is `DEFAULT` per row. + // Column order matches the contract storage column order; every value is `DEFAULT` per row. expect(sql).toMatch(/^INSERT INTO "user" \("[^"]+"(, "[^"]+")*\) VALUES /); expect(sql).toContain(' VALUES (DEFAULT, '); // Two rows of defaults, separated by `, `. @@ -393,10 +392,7 @@ describe('Postgres adapter', () => { }); it('renders BinaryExpr.in over a non-empty ListExpression as `IN ($1, $2, …)`', () => { - // The empty-list branch of `renderListLiteral` is covered by the - // existing distinct/exists/null-check test. Pin the non-empty list - // shape so `.values.map(...)` (param-ref + literal arms) stays - // covered after the Phase C test deletions. + // The empty-list branch of `renderListLiteral` is covered by the existing distinct/exists/null-check test. Pin the non-empty list shape so `.values.map(...)` (param-ref + literal arms) stays covered after the Phase C test deletions. const ast = SelectAst.from(TableSource.named('user')) .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) .withWhere( @@ -414,12 +410,7 @@ describe('Postgres adapter', () => { }); it("readMarkerStatement's parseMarkerRow round-trips a contract marker row payload", () => { - // `parseMarkerRow` (an arrow on `adapter.profile`) is invoked at - // contract-load time by the runtime's startup verify path. A focused - // unit test pins the function-coverage entry without needing an - // end-to-end Postgres driver — the parser itself lives in - // `@prisma-next/sql-runtime` and is fully covered there; we only - // need to verify the adapter-side wrapper forwards correctly. + // `parseMarkerRow` (an arrow on `adapter.profile`) is invoked at contract-load time by the runtime's startup verify path. A focused unit test pins the function-coverage entry without needing an end-to-end Postgres driver — the parser itself lives in `@prisma-next/sql-runtime` and is fully covered there; we only need to verify the adapter-side wrapper forwards correctly. const markerRow = { core_hash: 'sha256:test-core', profile_hash: 'sha256:test-profile', diff --git a/packages/3-targets/6-adapters/postgres/test/descriptor-parity.test.ts b/packages/3-targets/6-adapters/postgres/test/descriptor-parity.test.ts deleted file mode 100644 index 915dca6cfa..0000000000 --- a/packages/3-targets/6-adapters/postgres/test/descriptor-parity.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ExecutionStack } from '@prisma-next/framework-components/execution'; -import { describe, expect, it } from 'vitest'; -import postgresRuntimeAdapterDescriptor from '../src/exports/runtime'; - -describe('adapter descriptor / instance codec parity', () => { - it('descriptor codecs() matches adapter instance profile.codecs() codec IDs', () => { - const descriptorCodecIds = new Set( - [...postgresRuntimeAdapterDescriptor.codecs().values()].map((c) => c.id), - ); - - // The adapter reads stack metadata to derive a `codecLookup` for the - // renderer; minimal stub that satisfies that path. Codec contents come - // from the adapter descriptor itself, so the inner `target` need only - // expose its `id` for `extractCodecLookup`'s ownership tracking. - const stack = { - target: { id: 'postgres' }, - adapter: postgresRuntimeAdapterDescriptor, - extensionPacks: [], - } as unknown as ExecutionStack<'sql', 'postgres'>; - const adapterInstance = postgresRuntimeAdapterDescriptor.create(stack); - const instanceCodecIds = new Set( - [...adapterInstance.profile.codecs().values()].map((c) => c.id), - ); - - expect(descriptorCodecIds.size).toBeGreaterThan(0); - expect(descriptorCodecIds).toEqual(instanceCodecIds); - }); -}); diff --git a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts index 7c1d78583f..6cedda452f 100644 --- a/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/sql-renderer.cast-policy.test.ts @@ -1,19 +1,48 @@ import type { Codec, CodecLookup } from '@prisma-next/framework-components/codec'; +import { voidParamsSchema } from '@prisma-next/framework-components/codec'; import type { RuntimeExtensionDescriptor } from '@prisma-next/framework-components/execution'; import { validateContract } from '@prisma-next/sql-contract/validate'; import { BinaryExpr, ColumnRef, - codec, ParamRef, ProjectionItem, SelectAst, + type Codec as SqlCodec, TableSource, } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { renderLoweredSql } from '../src/core/sql-renderer'; import type { PostgresContract } from '../src/core/types'; import { createComposedPostgresAdapter } from './helpers/composed-adapter'; +import { defineTestCodec } from './test-codec'; + +const emptyLookup: CodecLookup = { + get: () => undefined, + targetTypesFor: () => undefined, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, +}; + +// `Codec`-side static metadata (`targetTypes` / `meta` / `renderOutputType`) retired with the SQL `Codec` narrow (TML-2357); these tests supply the metadata side-by-side with the codec instance to build the `CodecLookup` directly. +interface CodecMetadata { + readonly targetTypes?: readonly string[]; + readonly meta?: { + readonly db?: { readonly sql?: { readonly postgres?: { readonly nativeType?: string } } }; + }; + readonly renderOutputType?: (params: Record) => string | undefined; +} + +function lookupOf( + byId: Record, +): CodecLookup { + return { + get: (id) => byId[id]?.codec as Codec | undefined, + targetTypesFor: (id) => byId[id]?.metadata?.targetTypes, + metaFor: (id) => byId[id]?.metadata?.meta, + renderOutputTypeFor: (id, params) => byId[id]?.metadata?.renderOutputType?.(params), + }; +} const baseContract = validateContract( { @@ -43,7 +72,7 @@ const baseContract = validateContract( }, models: {}, }, - { get: () => undefined }, + emptyLookup, ); function selectWithParam(column: string, codecId: string | undefined, value: unknown) { @@ -58,16 +87,20 @@ function selectWithParam(column: string, codecId: string | undefined, value: unk describe('renderLoweredSql cast policy', () => { it('emits $N:: when the codec nativeType is outside the inferrable set', () => { - const fooCodec: Codec = codec({ + const fooCodec: Codec = defineTestCodec({ typeId: 'app/test-foo@1', - targetTypes: ['foo'], encode: (value: string): string => value, decode: (wire: string): string => wire, - meta: { db: { sql: { postgres: { nativeType: 'foo' } } } }, }); - const lookup: CodecLookup = { - get: (id) => (id === 'app/test-foo@1' ? fooCodec : undefined), - }; + const lookup = lookupOf({ + 'app/test-foo@1': { + codec: fooCodec, + metadata: { + targetTypes: ['foo'], + meta: { db: { sql: { postgres: { nativeType: 'foo' } } } }, + }, + }, + }); const ast = selectWithParam('tag', 'app/test-foo@1', 'tagged'); const lowered = renderLoweredSql(ast, baseContract, lookup); @@ -76,16 +109,20 @@ describe('renderLoweredSql cast policy', () => { }); it('emits plain $N when the codec nativeType is inferrable', () => { - const integerCodec: Codec = codec({ + const integerCodec: Codec = defineTestCodec({ typeId: 'pg/int4@1', - targetTypes: ['int4'], encode: (value: number): number => value, decode: (wire: number): number => wire, - meta: { db: { sql: { postgres: { nativeType: 'integer' } } } }, }); - const lookup: CodecLookup = { - get: (id) => (id === 'pg/int4@1' ? integerCodec : undefined), - }; + const lookup = lookupOf({ + 'pg/int4@1': { + codec: integerCodec, + metadata: { + targetTypes: ['int4'], + meta: { db: { sql: { postgres: { nativeType: 'integer' } } } }, + }, + }, + }); const ast = selectWithParam('score', 'pg/int4@1', 1); const lowered = renderLoweredSql(ast, baseContract, lookup); @@ -94,15 +131,17 @@ describe('renderLoweredSql cast policy', () => { }); it('emits plain $N when the codec carries no nativeType metadata', () => { - const enumCodec: Codec = codec({ + const enumCodec: Codec = defineTestCodec({ typeId: 'pg/enum@1', - targetTypes: ['enum'], encode: (value: string): string => value, decode: (wire: string): string => wire, }); - const lookup: CodecLookup = { - get: (id) => (id === 'pg/enum@1' ? enumCodec : undefined), - }; + const lookup = lookupOf({ + 'pg/enum@1': { + codec: enumCodec, + metadata: { targetTypes: ['enum'] }, + }, + }); const ast = selectWithParam('note', 'pg/enum@1', 'urgent'); const lowered = renderLoweredSql(ast, baseContract, lookup); @@ -111,13 +150,8 @@ describe('renderLoweredSql cast policy', () => { }); it('throws a clear error when the codec lookup has no entry for the codecId', () => { - // A `codecId` on a `ParamRef` that resolves to no codec in the assembled - // lookup is a stack-configuration failure, not a fallback opportunity: - // it almost always means an extension pack is missing from the runtime - // stack. Surface it loudly at lower-time so callers fix the configuration - // rather than silently emitting an uncast `$N` or guessing from contract - // storage. (See ADR 205 § "Adapters built without a stack".) - const lookup: CodecLookup = { get: () => undefined }; + // A `codecId` on a `ParamRef` that resolves to no codec in the assembled lookup is a stack-configuration failure, not a fallback opportunity: it almost always means an extension pack is missing from the runtime stack. Surface it loudly at lower-time so callers fix the configuration rather than silently emitting an uncast `$N` or guessing from contract storage. (See ADR 205 § "Adapters built without a stack".) + const lookup = emptyLookup; const ast = selectWithParam('tag', 'app/test-foo@1', 'tagged'); @@ -125,7 +159,7 @@ describe('renderLoweredSql cast policy', () => { }); it('throws even when no contract column references the codecId', () => { - const lookup: CodecLookup = { get: () => undefined }; + const lookup = emptyLookup; const ast = SelectAst.from(TableSource.named('user')) .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) @@ -142,7 +176,7 @@ describe('renderLoweredSql cast policy', () => { }); it('emits plain $N when the param ref carries no codecId', () => { - const lookup: CodecLookup = { get: () => undefined }; + const lookup = emptyLookup; const ast = selectWithParam('id', undefined, 1); const lowered = renderLoweredSql(ast, baseContract, lookup); @@ -153,14 +187,23 @@ describe('renderLoweredSql cast policy', () => { describe('renderLoweredSql cast policy via stack-derived lookup', () => { it('emits the extension-codec cast when the codec is contributed via stack.extensionPacks', () => { - const geographyCodec: Codec = codec({ + const geographyCodec: Codec = defineTestCodec({ typeId: 'app/geography@1', - targetTypes: ['geography'], encode: (value: string): string => value, decode: (wire: string): string => wire, - meta: { db: { sql: { postgres: { nativeType: 'geography' } } } }, }); + // Codec-side static metadata (`targetTypes` / `meta`) lives on the codec descriptor (TML-2357); contributors expose it via `types.codecTypes.codecDescriptors`. + const geographyDescriptor = { + codecId: 'app/geography@1', + traits: [], + targetTypes: ['geography'], + meta: { db: { sql: { postgres: { nativeType: 'geography' } } } }, + paramsSchema: voidParamsSchema, + isParameterized: false, + factory: () => () => geographyCodec, + } as const; + const geographyExtension: RuntimeExtensionDescriptor<'sql', 'postgres'> = { kind: 'extension', id: 'app-geography', @@ -169,7 +212,7 @@ describe('renderLoweredSql cast policy via stack-derived lookup', () => { targetId: 'postgres', types: { codecTypes: { - codecInstances: [geographyCodec], + codecDescriptors: [geographyDescriptor], }, }, create() { @@ -187,10 +230,7 @@ describe('renderLoweredSql cast policy via stack-derived lookup', () => { }); it('emits $1::vector when pgvector is installed via stack.extensionPacks', async () => { - // Smoke test for the M2 wiring fix: `pgvectorRuntimeDescriptor` exposes - // its codec instances via `types.codecTypes.codecInstances`, so the - // adapter's runtime-plane lookup picks up `pg/vector@1` and the renderer - // emits the cast. Without the wiring fix this regresses to `$1`. + // Smoke test for the M2 wiring fix: `pgvectorRuntimeDescriptor` exposes its codecs via `types.codecTypes.codecDescriptors`, so the adapter's runtime-plane lookup picks up `pg/vector@1` and the renderer emits the cast. Without the wiring fix this regresses to `$1`. const pgvectorRuntime = (await import('@prisma-next/extension-pgvector/runtime')).default; const adapter = createComposedPostgresAdapter({ extensionPacks: [pgvectorRuntime] }); @@ -220,7 +260,7 @@ describe('renderLoweredSql cast policy via stack-derived lookup', () => { }, models: {}, }, - { get: () => undefined }, + emptyLookup, ); const ast = selectWithParam('vec', 'pg/vector@1', [1, 2, 3]); diff --git a/packages/3-targets/6-adapters/postgres/test/test-codec.ts b/packages/3-targets/6-adapters/postgres/test/test-codec.ts new file mode 100644 index 0000000000..3b8e5c00af --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/test-codec.ts @@ -0,0 +1,58 @@ +/** + * Test-only helper that constructs a SQL-family `Codec` instance from author-side encode/decode functions. Replaces the legacy public `mkCodec()` factory (deleted under TML-2357); tests that need a stub codec for behavioural assertions instantiate one through this helper rather than going through `descriptor.factory(...)`. + */ +import type { JsonValue } from '@prisma-next/contract/types'; +import type { CodecTrait } from '@prisma-next/framework-components/codec'; +import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; + +type JsonRoundTripConfig = [TInput] extends [JsonValue] + ? { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + } + : { + encodeJson: (value: TInput) => JsonValue; + decodeJson: (json: JsonValue) => TInput; + }; + +export function defineTestCodec< + Id extends string, + const TTraits extends readonly CodecTrait[] = readonly [], + TWire = unknown, + TInput = unknown, +>( + config: { + typeId: Id; + targetTypes?: readonly string[]; + encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; + decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + traits?: TTraits; + } & JsonRoundTripConfig, +): Codec { + const identity = (v: unknown) => v; + const userEncode = config.encode; + const userDecode = config.decode; + const widenedConfig = config as { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + }; + return { + id: config.typeId, + encode: (value, ctx) => { + try { + return Promise.resolve(userEncode(value, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + decode: (wire, ctx) => { + try { + return Promise.resolve(userDecode(wire, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, + decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + } as Codec; +} diff --git a/packages/3-targets/6-adapters/sqlite/README.md b/packages/3-targets/6-adapters/sqlite/README.md index 0bed7c60f2..c045965a32 100644 --- a/packages/3-targets/6-adapters/sqlite/README.md +++ b/packages/3-targets/6-adapters/sqlite/README.md @@ -187,7 +187,7 @@ DELETE FROM "user" WHERE "user"."id" = ? RETURNING "user"."id", "user"."email" ## Exports -- `./codec-types`: SQLite codec types (`CodecTypes`, `JsonValue`, `dataTypes`) +- `./codec-types`: SQLite codec types (`CodecTypes`, `JsonValue`) - `./column-types`: Column type descriptors (`textColumn`, `integerColumn`, `realColumn`, `blobColumn`, `datetimeColumn`, `jsonColumn`, `bigintColumn`) - `./types`: SQLite-specific types - `./control`: Control-plane entry point (stubbed for future migration support) diff --git a/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts b/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts index d2aebf0f88..3c9e3e5d3c 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts @@ -1,34 +1,31 @@ -import { - type Adapter, - type AdapterProfile, - type AggregateExpr, - type AnyExpression, - type AnyFromSource, - type AnyQueryAst, - type BinaryExpr, - type CodecParamsDescriptor, - type ColumnRef, - createCodecRegistry, - type DeleteAst, - type InsertAst, - type InsertValue, - type JoinAst, - type JoinOnExpr, - type JsonArrayAggExpr, - type JsonObjectExpr, - type ListExpression, - type LiteralExpr, - type LowererContext, - type NullCheckExpr, - type OperationExpr, - type OrderByItem, - type ProjectionItem, - type SelectAst, - type SubqueryExpr, - type UpdateAst, +import type { + Adapter, + AdapterProfile, + AggregateExpr, + AnyExpression, + AnyFromSource, + AnyQueryAst, + BinaryExpr, + ColumnRef, + DeleteAst, + InsertAst, + InsertValue, + JoinAst, + JoinOnExpr, + JsonArrayAggExpr, + JsonObjectExpr, + ListExpression, + LiteralExpr, + LowererContext, + NullCheckExpr, + OperationExpr, + OrderByItem, + ProjectionItem, + SelectAst, + SubqueryExpr, + UpdateAst, } from '@prisma-next/sql-relational-core/ast'; import { parseContractMarkerRow } from '@prisma-next/sql-runtime'; -import { codecDefinitions } from '@prisma-next/target-sqlite/codecs'; import { escapeLiteral, quoteIdentifier } from '@prisma-next/target-sqlite/sql-utils'; import type { SqliteAdapterOptions, SqliteContract, SqliteLoweredStatement } from './types'; @@ -48,27 +45,17 @@ class SqliteAdapterImpl implements Adapter; - private readonly codecRegistry = (() => { - const registry = createCodecRegistry(); - for (const definition of Object.values(codecDefinitions)) { - registry.register(definition.codec); - } - return registry; - })(); constructor(options?: SqliteAdapterOptions) { this.profile = Object.freeze({ id: options?.profileId ?? 'sqlite/default@1', target: 'sqlite', capabilities: defaultCapabilities, - codecs: () => this.codecRegistry, readMarkerStatement: () => ({ sql: 'select core_hash, profile_hash, contract_json, canonical_version, updated_at, app_tag, meta, invariants from _prisma_marker where id = ?', params: [1], }), - // SQLite stores arrays as JSON-encoded TEXT (no native array type), - // so the driver returns `invariants` as a string. Decode before - // delegating to the shared row schema, which expects `string[]`. + // SQLite stores arrays as JSON-encoded TEXT (no native array type), so the driver returns `invariants` as a string. Decode before delegating to the shared row schema, which expects `string[]`. parseMarkerRow: (row: unknown) => { const raw = row as Record; const invariants = @@ -80,10 +67,6 @@ class SqliteAdapterImpl implements Adapter { - return []; - } - lower(ast: AnyQueryAst, context: LowererContext): SqliteLoweredStatement { return renderLoweredSql(ast, context.contract); } @@ -92,9 +75,7 @@ class SqliteAdapterImpl implements Adapter & ReturnType; -function createSqliteCodecRegistry(): CodecRegistry { - const registry = createCodecRegistry(); - for (const definition of Object.values(codecDefinitions)) { - registry.register(definition.codec); - } - return registry; -} - function createSqliteMutationDefaultGenerators() { return [ ...builtinGeneratorIds.map((id) => ({ @@ -40,8 +30,7 @@ const sqliteRuntimeAdapterDescriptor: SqlRuntimeAdapterDescriptor< SqliteRuntimeAdapterInstance > = { ...sqliteAdapterDescriptorMeta, - codecs: createSqliteCodecRegistry, - parameterizedCodecs: () => [], + codecs: () => Array.from(sqliteCodecRegistry.values()), mutationDefaultGenerators: createSqliteMutationDefaultGenerators, create(_stack): SqliteRuntimeAdapterInstance { return createSqliteAdapter(); diff --git a/packages/3-targets/6-adapters/sqlite/src/exports/codec-types.ts b/packages/3-targets/6-adapters/sqlite/src/exports/codec-types.ts index 69aea71bfb..5afaab1595 100644 --- a/packages/3-targets/6-adapters/sqlite/src/exports/codec-types.ts +++ b/packages/3-targets/6-adapters/sqlite/src/exports/codec-types.ts @@ -1,8 +1,2 @@ -// Facade over `@prisma-next/target-sqlite/codec-types` so downstream consumers -// (demo, e2e tests, generated contract `.d.ts`) can keep importing from -// `@prisma-next/adapter-sqlite/codec-types` after codecs moved target-side. -export { - type CodecTypes, - dataTypes, - type JsonValue, -} from '@prisma-next/target-sqlite/codec-types'; +// Facade over `@prisma-next/target-sqlite/codec-types` so downstream consumers (demo, e2e tests, generated contract `.d.ts`) can keep importing from `@prisma-next/adapter-sqlite/codec-types` after codecs moved target-side. +export type { CodecTypes, JsonValue } from '@prisma-next/target-sqlite/codec-types'; diff --git a/packages/3-targets/6-adapters/sqlite/test/codecs.test.ts b/packages/3-targets/6-adapters/sqlite/test/codecs.test.ts index 9500b70e5d..351c0dbbc5 100644 --- a/packages/3-targets/6-adapters/sqlite/test/codecs.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/codecs.test.ts @@ -1,3 +1,14 @@ +import type { + AnyCodecDescriptor, + Codec, + CodecInstanceContext, +} from '@prisma-next/framework-components/codec'; +import { + SQL_CHAR_CODEC_ID, + SQL_FLOAT_CODEC_ID, + SQL_INT_CODEC_ID, + SQL_VARCHAR_CODEC_ID, +} from '@prisma-next/sql-relational-core/ast'; import { SQLITE_BIGINT_CODEC_ID, SQLITE_BLOB_CODEC_ID, @@ -7,55 +18,85 @@ import { SQLITE_REAL_CODEC_ID, SQLITE_TEXT_CODEC_ID, } from '@prisma-next/target-sqlite/codec-ids'; -import { codecDefinitions } from '@prisma-next/target-sqlite/codecs'; +import { sqliteCodecRegistry } from '@prisma-next/target-sqlite/codecs'; import { describe, expect, it } from 'vitest'; +const SYNTH_CTX: CodecInstanceContext = { name: 'test' }; + +const codecIdByScalar = { + text: SQLITE_TEXT_CODEC_ID, + integer: SQLITE_INTEGER_CODEC_ID, + real: SQLITE_REAL_CODEC_ID, + blob: SQLITE_BLOB_CODEC_ID, + datetime: SQLITE_DATETIME_CODEC_ID, + json: SQLITE_JSON_CODEC_ID, + bigint: SQLITE_BIGINT_CODEC_ID, + // SQL base codecs are also registered via the contributor's `codecs:` slot, so the package-scoped registry resolves them. + char: SQL_CHAR_CODEC_ID, + varchar: SQL_VARCHAR_CODEC_ID, + int: SQL_INT_CODEC_ID, + float: SQL_FLOAT_CODEC_ID, +} as const; + +type ScalarName = keyof typeof codecIdByScalar; + +function codecForScalar(scalar: ScalarName): Codec { + const codecId = codecIdByScalar[scalar]; + const descriptor = sqliteCodecRegistry.descriptorFor(codecId); + if (!descriptor) { + throw new Error(`No descriptor registered for codec id ${codecId}`); + } + // Codec runtime is per-instance-stateless for every codec under test; pass `undefined as never` to satisfy parameterized descriptors (SQL char/varchar/int/float carry typed param shapes). + const factory = (descriptor as AnyCodecDescriptor).factory(undefined as never); + return factory(SYNTH_CTX) as Codec; +} + describe('SQLite codecs', () => { describe('text codec', () => { - const codec = codecDefinitions.text.codec; + const codec = codecForScalar('text'); it('has correct id', () => { expect(codec.id).toBe(SQLITE_TEXT_CODEC_ID); }); it('round-trips strings', async () => { - expect(await codec.decode(await codec.encode!('hello', {}), {})).toBe('hello'); + expect(await codec.decode(await codec.encode('hello', {}), {})).toBe('hello'); }); it('handles empty string', async () => { - expect(await codec.decode(await codec.encode!('', {}), {})).toBe(''); + expect(await codec.decode(await codec.encode('', {}), {})).toBe(''); }); }); describe('integer codec', () => { - const codec = codecDefinitions.integer.codec; + const codec = codecForScalar('integer'); it('has correct id', () => { expect(codec.id).toBe(SQLITE_INTEGER_CODEC_ID); }); it('round-trips numbers', async () => { - expect(await codec.decode(await codec.encode!(42, {}), {})).toBe(42); - expect(await codec.decode(await codec.encode!(0, {}), {})).toBe(0); - expect(await codec.decode(await codec.encode!(-1, {}), {})).toBe(-1); + expect(await codec.decode(await codec.encode(42, {}), {})).toBe(42); + expect(await codec.decode(await codec.encode(0, {}), {})).toBe(0); + expect(await codec.decode(await codec.encode(-1, {}), {})).toBe(-1); }); }); describe('real codec', () => { - const codec = codecDefinitions.real.codec; + const codec = codecForScalar('real'); it('has correct id', () => { expect(codec.id).toBe(SQLITE_REAL_CODEC_ID); }); it('round-trips floats', async () => { - expect(await codec.decode(await codec.encode!(3.14, {}), {})).toBeCloseTo(3.14); - expect(await codec.decode(await codec.encode!(0.0, {}), {})).toBe(0); + expect(await codec.decode(await codec.encode(3.14, {}), {})).toBeCloseTo(3.14); + expect(await codec.decode(await codec.encode(0.0, {}), {})).toBe(0); }); }); describe('blob codec', () => { - const codec = codecDefinitions.blob.codec; + const codec = codecForScalar('blob'); it('has correct id', () => { expect(codec.id).toBe(SQLITE_BLOB_CODEC_ID); @@ -63,17 +104,17 @@ describe('SQLite codecs', () => { it('round-trips Uint8Array', async () => { const input = new Uint8Array([1, 2, 3, 4]); - expect(await codec.decode(await codec.encode!(input, {}), {})).toEqual(input); + expect(await codec.decode(await codec.encode(input, {}), {})).toEqual(input); }); it('handles empty Uint8Array', async () => { const input = new Uint8Array([]); - expect(await codec.decode(await codec.encode!(input, {}), {})).toEqual(input); + expect(await codec.decode(await codec.encode(input, {}), {})).toEqual(input); }); }); describe('datetime codec', () => { - const codec = codecDefinitions.datetime.codec; + const codec = codecForScalar('datetime'); it('has correct id', () => { expect(codec.id).toBe(SQLITE_DATETIME_CODEC_ID); @@ -81,30 +122,30 @@ describe('SQLite codecs', () => { it('encodes Date to ISO8601 string', async () => { const date = new Date('2024-01-15T10:30:00.000Z'); - expect(await codec.encode!(date, {})).toBe('2024-01-15T10:30:00.000Z'); + expect(await codec.encode(date, {})).toBe('2024-01-15T10:30:00.000Z'); }); it('decodes ISO8601 string to Date', async () => { - const result = await codec.decode('2024-01-15T10:30:00.000Z', {}); + const result = (await codec.decode('2024-01-15T10:30:00.000Z', {})) as Date; expect(result).toBeInstanceOf(Date); expect(result.toISOString()).toBe('2024-01-15T10:30:00.000Z'); }); it('round-trips dates', async () => { const date = new Date('2024-06-15T23:59:59.999Z'); - const wire = await codec.encode!(date, {}); - const decoded = await codec.decode(wire, {}); + const wire = await codec.encode(date, {}); + const decoded = (await codec.decode(wire, {})) as Date; expect(decoded.getTime()).toBe(date.getTime()); }); it('handles date without timezone (treated as UTC by Date constructor)', async () => { - const result = await codec.decode('2024-01-15T10:30:00', {}); + const result = (await codec.decode('2024-01-15T10:30:00', {})) as Date; expect(result).toBeInstanceOf(Date); }); }); describe('json codec', () => { - const codec = codecDefinitions.json.codec; + const codec = codecForScalar('json'); it('has correct id', () => { expect(codec.id).toBe(SQLITE_JSON_CODEC_ID); @@ -112,7 +153,7 @@ describe('SQLite codecs', () => { it('encodes object to JSON string', async () => { const value = { name: 'alice', age: 30 }; - expect(await codec.encode!(value, {})).toBe('{"name":"alice","age":30}'); + expect(await codec.encode(value, {})).toBe('{"name":"alice","age":30}'); }); it('decodes JSON string to object', async () => { @@ -121,16 +162,16 @@ describe('SQLite codecs', () => { it('round-trips nested objects', async () => { const value = { a: { b: { c: [1, 2, 3] } } }; - expect(await codec.decode(await codec.encode!(value, {}), {})).toEqual(value); + expect(await codec.decode(await codec.encode(value, {}), {})).toEqual(value); }); it('round-trips arrays', async () => { const value = [1, 'two', true, null]; - expect(await codec.decode(await codec.encode!(value, {}), {})).toEqual(value); + expect(await codec.decode(await codec.encode(value, {}), {})).toEqual(value); }); it('round-trips null', async () => { - expect(await codec.decode(await codec.encode!(null, {}), {})).toBeNull(); + expect(await codec.decode(await codec.encode(null, {}), {})).toBeNull(); }); it('handles already-parsed objects from wire', async () => { @@ -141,14 +182,14 @@ describe('SQLite codecs', () => { }); describe('bigint codec', () => { - const codec = codecDefinitions.bigint.codec; + const codec = codecForScalar('bigint'); it('has correct id', () => { expect(codec.id).toBe(SQLITE_BIGINT_CODEC_ID); }); it('encodes bigint', async () => { - expect(await codec.encode!(42n, {})).toBe(42n); + expect(await codec.encode(42n, {})).toBe(42n); }); it('decodes number to bigint', async () => { @@ -161,13 +202,13 @@ describe('SQLite codecs', () => { it('handles large integers', async () => { const large = 9007199254740993n; - expect(await codec.decode(await codec.encode!(large, {}), {})).toBe(large); + expect(await codec.decode(await codec.encode(large, {}), {})).toBe(large); }); }); describe('codec definitions structure', () => { it('has all expected codecs', () => { - const keys = Object.keys(codecDefinitions); + const keys = Object.keys(codecIdByScalar); expect(keys).toContain('text'); expect(keys).toContain('integer'); expect(keys).toContain('real'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e41a3781cb..93bac5ea3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -787,6 +787,9 @@ importers: '@prisma-next/utils': specifier: workspace:* version: link:../utils + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 arktype: specifier: 'catalog:' version: 2.1.29 @@ -977,9 +980,9 @@ importers: '@prisma-next/contract': specifier: workspace:* version: link:../../0-foundation/contract - '@prisma-next/contract-authoring': + '@prisma-next/framework-components': specifier: workspace:* - version: link:../contract + version: link:../../1-core/framework-components '@prisma-next/utils': specifier: workspace:* version: link:../../0-foundation/utils @@ -2189,6 +2192,9 @@ importers: '@prisma-next/utils': specifier: workspace:* version: link:../../../1-framework/0-foundation/utils + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 arktype: specifier: 'catalog:' version: 2.1.29 @@ -2424,6 +2430,9 @@ importers: '@prisma-next/sql-runtime': specifier: workspace:* version: link:../../2-sql/5-runtime + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 arktype: specifier: ^2.1.29 version: 2.1.29 @@ -2596,6 +2605,9 @@ importers: '@prisma-next/sql-schema-ir': specifier: workspace:* version: link:../../2-sql/1-core/schema-ir + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 arktype: specifier: ^2.0.0 version: 2.1.29 @@ -3065,6 +3077,9 @@ importers: '@prisma-next/utils': specifier: workspace:* version: link:../../../1-framework/0-foundation/utils + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 arktype: specifier: ^2.0.0 version: 2.1.29 diff --git a/projects/codec-registration-completion/plan.md b/projects/codec-registration-completion/plan.md new file mode 100644 index 0000000000..a68c9d1dfe --- /dev/null +++ b/projects/codec-registration-completion/plan.md @@ -0,0 +1,293 @@ +# Plan — Codec registration completion (TML-2357) + +> Milestones for the [spec](spec.md). Each milestone is one cohesive change that ends in a green-gates checkpoint and a pause-for-review with the user. If milestone diffs grow large enough, milestone boundaries (or Phase boundaries inside a milestone) become PR boundaries. + +## Status + +| Milestone | State | +|---|---| +| **M0** — Class-based codec migration (per-codec helpers + `CodecImpl`/`CodecDescriptorImpl` + Strength 3 deletion) | **Active.** Phase A **LANDED** (commits `0515acd1f`, `625c59020`, `ea67cc5bc`, `588cf319e`, `89d27c25a`). Phase B1 starts next. | +| **M1** — Narrow runtime `Codec` instance + descriptor-keyed metadata reads | **LANDED** (commits `3c9338fef`, `1be7564c4`). Re-verify post-M0. | +| **M2** — Native descriptor migration (interface form), bridge deletion, `aliasDescriptor`, `arktypeJsonEmitCodec` deletion | **Mostly LANDED** (R1–R3, T2.1–T2.6, Phase A, Phase B). M2 R4 (legacy-API deletion) was rolled back — its intent is absorbed into M0 Phase C. The remaining M2 surface (R4 retry) is **subsumed** by M0; no separate M2 R4 retry milestone. The function-form `aliasDescriptor` is **already deleted** (commit `89d27c25a`); class-based aliases are the only form going forward. | +| **M3** — `ParamRef.refs` plumbing + encode-side `forColumn` + `forCodecId` retirement | Pending; runs after M0. | +| **M4** — `JsonSchemaValidatorRegistry` deletion + `'json-validator'` trait retirement | Pending; runs after M0. | + +`AC-7` (validation gates) is checked at the end of every milestone. + +### Why M0 was redesigned (Pattern E) + +M2 R4 attempted to delete the parallel typed-instance carriers (`mkCodec`-produced instances kept alive solely to drive `CodecTypes`/`TypeMaps` derivation). It rolled back because the typed-flow chain through the existing interface-form `CodecDescriptor` couldn't preserve the codec generics through TypeScript's variance rules. Three design iterations followed: + +1. **Shape A vs Shape B spike** (interface form, parameterized `CodecDescriptor` vs intersection at `defineCodec`'s return) — partially solved the deletion problem but landed in a place that conflicted with the goal "the descriptor's factory IS the type-level source of truth." Documented in `wip/m0-shape-spike.md`. +2. **Mode C goal spec** — `factory-defined-codec-types.spec.md` framed the design goal independent of the implementation: the descriptor's factory is the single type-level source of truth for codec types; column helpers are derivative. +3. **Class-based design + Pattern E spike** — `class-based-codec-design.spec.md` proposed an abstract-class hierarchy. A TypeScript playground proof (`wip/m0-class-variance-proof.md`) falsified the polymorphic-column-helper approach and surfaced **Pattern E** (per-codec helpers + `satisfies`). The spike on `spike/class-based-codecs` validated all six AC-CB-* end-to-end. + +Pattern E is the locked design. M0 below describes how to migrate the codebase to it, which absorbs both the M2 R4 deletion intent and the typed-flow precondition `typed-codec-flow.spec.md` was authored around. **`typed-codec-flow.spec.md` is superseded** by `class-based-codec-design.spec.md` + `factory-defined-codec-types.spec.md`; it survives in the project as historical context for the rollback. + +## Validation gates (every milestone) + +All must be green before declaring a milestone done: + +- `pnpm typecheck` +- `pnpm lint:deps` +- `pnpm test:packages` +- `pnpm test:e2e` (postgres real-DB) +- `pnpm build` +- `pnpm fixtures:check` (all fixture pairs byte-identical against `origin/main` baseline) + +## Milestone M0 — Pattern E migration + +**Goal**: Replace the interface-form `Codec`/`CodecDescriptor` with the class-based hierarchy + per-codec helper functions. Migrate every codec in the SQL families. Delete every legacy carrier (`mkCodec`, `defineCodec`, `defineCodecGroup`, `defineCodecBundle`, `CodecDefBuilder*`, `byScalar` maps, `ExtractCodecTypes` instance-keyed, `aliasDescriptor` function form if replaced by class extension, etc.). Add negative type tests proving the typed `Codec` flow runs end-to-end through descriptor classes. + +**Spec ACs addressed**: AC-0 (typed flow), AC-1 (every codec ships as a `CodecDescriptor`), AC-4 (alias by descriptor extension), and partial AC-3 (instance narrowed — already landed in M1; M0 confirms the class form preserves the narrow shape). + +**Specs**: [`specs/class-based-codec-design.spec.md`](specs/class-based-codec-design.spec.md) (implementation-approach), [`specs/factory-defined-codec-types.spec.md`](specs/factory-defined-codec-types.spec.md) (goal). Absorbs [TML-2393](https://linear.app/prisma-company/issue/TML-2393). + +### Phase A — Framework class hierarchy + per-codec helper machinery — **LANDED** + +The framework-level scaffolding shipped across five commits. The shape that landed differs in important ways from the original spike sketch — review feedback drove a consolidation. Final state: + +- `packages/1-framework/1-core/framework-components/src/shared/codec.ts` — `interface Codec` (canonical consumer surface) + `abstract class CodecImpl<...> implements Codec<...>` (codec-author base). Class constructor takes `descriptor: CodecDescriptor` (variance-erased); `id` getter proxies through `descriptor.codecId`; `encode` / `decode` / `encodeJson` / `decodeJson` are abstract. Runtime instance does **not** carry `traits` (the `Codec` interface declares only a phantom `[codecTraitsPhantom]?: TTraits` carrier; consumers needing the runtime trait set read `codec.descriptor.traits`). +- `packages/1-framework/1-core/framework-components/src/shared/codec-descriptor.ts` — `interface CodecDescriptor` (canonical consumer surface) + `abstract class CodecDescriptorImpl implements CodecDescriptor` (codec-author base) + `AnyCodecDescriptor` variance-erased alias. The function-form `aliasDescriptor` is **deleted** (the spread had a prototype-stripping bug; aliases are now class-based). +- `packages/1-framework/1-core/framework-components/src/shared/column-spec.ts` — `ColumnTypeDescriptor` (relocated from `@prisma-next/contract-authoring` to layer 1 alongside the codec types — codec base types are essential framework concepts and shouldn't sit at layer 2) + `interface ColumnSpec extends ColumnTypeDescriptor` (real `extends`, no structural mirror) + the `column(codecFactory, codecId, typeParams)` packager + `ColumnHelperFor` / `ColumnHelperForStrict` shapes. +- `packages/1-framework/1-core/framework-components/src/shared/codec-types.ts` — reduced to support types only: `CodecTrait`, `CodecCallContext`, `CodecInstanceContext`, `CodecMeta`, `CodecLookup`, `voidParamsSchema`, `emptyCodecLookup`. +- `packages/1-framework/1-core/framework-components/src/exports/codec.ts` — single consolidated barrel. The `class-based-codec` subpath barrel is deleted. +- `packages/1-framework/1-core/framework-components/test/codec.types.test-d.ts` — framework-level type tests using inline fixtures; covers literal preservation through direct `descriptor.factory(...)` calls, `column()` packaging, `ResolvedCodec` / `ColumnInputType` extraction, `ColumnHelperFor` / `ColumnHelperForStrict` `satisfies` discipline (positive + negative), and heterogeneous-storage variance erasure. + +The reviewer-driven changes from the spike sketch: +- **Naming**: `Codec` and `CodecDescriptor` stayed as the interface names (consumer surface); the abstract classes use `Impl` suffix (`CodecImpl`, `CodecDescriptorImpl`) matching existing repo convention (`SelectQueryImpl`, `MongoDriverImpl`, etc.). No name collisions; consumers depend on the interface, authors extend the class. +- **`ColumnTypeDescriptor` moved** from contract-authoring (layer 2) to framework-components (layer 1). All ten cross-package import sites updated (`contract-ts`, `contract-psl`, `pgvector`, `arktype-json`, `postgres` adapter, `ids`, `test/utils`, etc.). `ColumnSpec` now `extends ColumnTypeDescriptor` directly — no structural mirror. +- **`aliasDescriptor` deleted** from the framework. Independent reviewer caught a prototype-stripping bug in the spread (`{ ...baseCodec, id }` strips inherited methods on class-based codecs). Postgres's four use sites (`pgCharDescriptor`, `pgVarcharDescriptor`, `pgIntDescriptor`, `pgFloatDescriptor`) were rewritten as inline `class extends CodecDescriptorImpl

` declarations using a small file-local `aliasCodec()` helper that derives codec instances via `Object.create(Object.getPrototypeOf(baseCodec))` + `Object.assign` + `Object.defineProperty(_, 'id', ...)` — works for both plain-object base codecs (today) and class-instance bases (post-Phase B). + +### Phase B — Per-codec migration + +PR boundaries by package. Each sub-phase ends with a green checkpoint. + +#### B1. sql-relational-core base codecs (+ defensive Postgres legacy-alias rework) + +**Expanded scope**: B1 also rewrites the four legacy `pgXxxCodec` *codec instance* aliases at `packages/3-targets/3-targets/postgres/src/core/codecs.ts:135-153` (`{ ...sqlCharCodec, id: PG_CHAR_CODEC_ID }` etc.) so they survive the SQL base codec migration. Today's pattern works because base codecs are plain-object `mkCodec` outputs; once SQL bases produce `CodecImpl` subclass instances, the spread silently strips prototype methods. Eliminating the transitional bug window. + +5. **T0.B1.1 — Audit base codecs.** ~6 codecs in `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts` (char, varchar, int, float, text, timestamp). Each has: + - Today: `defineCodec({...})` producing an interface-form descriptor + `mkCodec({...})` producing a plain-object `Codec` instance. + - Class-based target: `class XCodec extends CodecImpl<...>` (concrete codec class) + `class XDescriptor extends CodecDescriptorImpl<...>` (concrete descriptor class) + `xColumn = (...) => column(xDescriptor.factory(...), xDescriptor.codecId, typeParams)` (per-codec column helper) + `xColumn satisfies ColumnHelperFor` (or `ColumnHelperForStrict` when the codec's resolved type is well-defined). + +6. **T0.B1.2 — Reshape one codec end-to-end (text).** Confirms the migration pattern works against real consumers (sql-relational-core has internal callers). Type tests + runtime tests + green gates. + +7. **T0.B1.3 — Reshape the remaining base codecs.** char, varchar, int, float, timestamp. Each gets the same quadruple (codec class, descriptor class, per-codec column helper, `satisfies` clause). Existing `defineCodec(...)` and `mkCodec(...)` exports stay until Phase C — coexistence keeps the build green. + +8. **T0.B1.4 — Defensive rework: Postgres legacy `pgXxxCodec` instances.** Replace the four `{ ...sqlXxxCodec, id }` patterns in `packages/3-targets/3-targets/postgres/src/core/codecs.ts:135-153` (`pgCharCodec`, `pgVarcharCodec`, `pgIntCodec`, `pgFloatCodec`) with calls to the existing file-local `aliasCodec()` helper (introduced for the descriptor-level aliases in commit `89d27c25a`). After this task, *no* code in the codebase relies on object-spread codec aliasing. Phase C deletes these legacy `Codec` instances entirely; in the interim, the prototype-preserving derivation keeps SQLite's direct `sqlCharCodec` consumers and Postgres's spread-aliased consumers both correct. SQLite's `sqlCharCodec` etc. usage in `packages/3-targets/3-targets/sqlite/src/core/codecs.ts` is direct (no spread); confirmed safe without changes for B1 — gets handled in Phase C alongside the SQL base codec deletions. + +9. **T0.B1.5 — B1 validation checkpoint.** Postgres + SQLite + relational-core pass `pnpm typecheck && pnpm test`; repo-wide `pnpm lint:deps` and `pnpm test:packages` green. + +#### B2. postgres target codecs + +10. **T0.B2.1 — Audit postgres codecs.** ~18 remaining codecs in `packages/3-targets/3-targets/postgres/src/core/codecs.ts` (text, int4, int2, int8, float4, float8, numeric, bool, enum, json, jsonb, uuid, bytea, timestamptz, timestamp, date, time). The four char/varchar/int/float aliases already migrated to class form in B1's prerequisite (commit `89d27c25a`). Each remaining codec has: + - Today: `mkCodec({...})` instance + `defineCodec({...})` descriptor in parallel; `byScalar` and `dataTypes` parallel exports keyed by scalar. + - Class-based target: `class PgXCodec extends CodecImpl<...>` + `class PgXDescriptor extends CodecDescriptorImpl<...>` + per-codec column helper + `satisfies ColumnHelperFor`. + +11. **T0.B2.2 — Reshape one postgres codec end-to-end (int4).** Same pattern as B1.2. + +12. **T0.B2.3 — Reshape the remaining postgres codecs.** Batched into reasonable commit chunks (numeric/scalar codecs together, JSON together, etc.). + +13. **T0.B2.4 — B2 validation checkpoint.** + +#### B3. sqlite target codecs + +13. **T0.B3.1 — Reshape sqlite codecs.** ~10 codecs in `packages/3-targets/3-targets/sqlite/src/core/codecs.ts`. Same pattern as B2. + +14. **T0.B3.2 — B3 validation checkpoint.** + +#### B4. Extension codecs + +15. **T0.B4.1 — Reshape pgvector.** `packages/3-extensions/pgvector/src/core/codecs.ts`. Already has the class form on `spike/class-based-codecs`; lift the pattern (without the `*CB` suffix). + +16. **T0.B4.2 — Reshape arktype-json.** `packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts`. Method-level generic over `S extends Type` per the spec § Case 3. + +17. **T0.B4.3 — Reshape any other extension codecs** (cipherstash etc., as they exist). + +18. **T0.B4.4 — B4 validation checkpoint.** + +#### B5. Adapter / contributor wiring + +19. **T0.B5.1 — Update postgres adapter** `packages/3-targets/6-adapters/postgres/src/core/adapter.ts`. Today consumes `Object.values(byScalar)` to register codecs. Migrate to consume the new descriptor classes through the unified `codecs:` slot. The descriptors-by-codec-id map gets populated from the class-form descriptor list. + +20. **T0.B5.2 — Update sqlite adapter** analogously. + +21. **T0.B5.3 — Update extension contributor wiring.** pgvector / arktype-json contributor packs ship the class-form descriptors through the unified `codecs:` slot. + +22. **T0.B5.4 — B5 validation checkpoint.** + +### Phase C — Strength 3 forcing-function deletion + +PR boundary. The deletion is the proof that the Pattern E migration is complete: zero legacy callers remain. + +23. **T0.C.1 — Delete `mkCodec` (and rename to `buildSqlCodec` if any internal carryover survives).** Audit grep `mkCodec`; sites should be zero in production after Phase B. + +24. **T0.C.2 — Delete `defineCodec`** (`packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts:587-693`). Zero callers after Phase B. + +25. **T0.C.3 — Delete `defineCodecGroup`** factory function and its export. + +26. **T0.C.4 — Delete `defineCodecBundle`** factory function and its export. (If renamed during Phase B, delete the renamed form.) + +27. **T0.C.5 — Delete `CodecDefBuilder` / `CodecDefBuilderImpl`**. + +28. **T0.C.6 — Delete `ExtractCodecTypes` (instance-keyed, line 292)**. Rename `ExtractDescriptorCodecTypes` → `ExtractCodecTypes` (canonical now). Confirm the contract-level `ExtractCodecTypes` in `packages/2-sql/1-core/contract/src/types.ts:239` is untouched (different file, different role; preserved). + +29. **T0.C.7 — Delete `byScalar` and `dataTypes` from target / extension packages.** Sites: + - `packages/3-targets/3-targets/postgres/src/core/codecs.ts:570` + - `packages/3-targets/3-targets/sqlite/src/core/codecs.ts:120` + - `packages/3-extensions/pgvector/src/core/codecs.ts:73` + - `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts:198` (`sqlCodecDefinitions`) + - Delete the dual-shape parallel exports `codecDescriptorDefinitions` (postgres/sqlite/pgvector). + - Delete `byScalar` getter from `CodecDescriptorBuilder` if no other consumers. + +30. **T0.C.8 — Delete the legacy `pgXxxCodec` / `sqlXxxCodec` `Codec` instances.** After all consumers consume codecs through `descriptor.factory(...)(ctx)`, the legacy plain-object `Codec` instance exports (`sqlCharCodec`, `sqlIntCodec`, `pgCharCodec` (post-B1 prototype-preserving form), etc.) delete. SQLite's direct consumption pattern updates to consume through descriptors at this point. **Note**: the function-form `aliasDescriptor` was already deleted (commit `89d27c25a`); no separate task needed. The interface form of `Codec` / `CodecDescriptor` is preserved (it's the canonical consumer surface; the abstract classes implement it). + +31. **T0.C.9 — Migrate any remaining test consumers** of legacy carriers. Sites that call `byScalar.timestamp.codec.encode(...)` migrate to `pgTimestampDescriptor.factory(undefined)({}).encode(...)` or use a small per-test helper. Already audited under M2 R4: ~50 test sites across postgres/sqlite/pgvector test fixtures + adapter test fixtures. + +32. **T0.C.10 — Mark `typed-codec-flow.spec.md` as superseded** (already done — links point to `class-based-codec-design.spec.md` + `factory-defined-codec-types.spec.md`). + +### Phase D — Constructive type tests + closing-grep + validation + +PR boundary with Phase C if diff is small enough; otherwise its own. + +35. **T0.D.1 — Negative type tests at the descriptor round-trip layer.** `packages/2-sql/4-lanes/relational-core/test/typed-codec-flow.test-d.ts` — assertions like `expectTypeOf>().toEqualTypeOf>()`. + +36. **T0.D.2 — Negative type tests at the per-target descriptor record layer.** `packages/3-targets/3-targets/postgres/test/typed-descriptor-flow.test-d.ts` (and analogous in sqlite/pgvector). Assert each descriptor record entry's full type. + +37. **T0.D.3 — Negative type tests at the no-emit authoring chain.** `examples/prisma-next-demo/test/no-emit-typed-flow.test-d.ts`. `field.uuidv4()` returns typed field spec; query expression typechecks; `fns.eq(f.id, 1234: number)` fails. + +38. **T0.D.4 — Closing-grep verification.** Zero hits across `packages/ test/ examples/ docs/` (excluding `projects/**` and `wip/**`) for: `mkCodec`, `defineCodec\(`, `defineCodecGroup`, `defineCodecBundle`, `CodecDefBuilder`, `CodecDefBuilderImpl`, `ExtractDescriptorCodecTypes`, `byScalar`, `dataTypes` (the target-codec export — disambiguate by context), `sqlCodecDefinitions`, `codecDescriptorDefinitions`. The deletion is the forcing function. + +39. **T0.D.5 — `pnpm fixtures:check`.** Confirm zero drift across all fixture pairs. + +40. **T0.D.6 — Full validation checkpoint** (`pnpm typecheck`, `pnpm lint:deps`, `pnpm test:packages`, `pnpm test:e2e`, `pnpm build`, `pnpm fixtures:check`) and **pause for review**. + +### Risks + +- **Per-codec helper boilerplate.** ~40 helpers across the codebase. Mitigated by `defineSimpleCodec` shorthand per spec § Risks for cases that don't need class state. +- **Adapter-level descriptor consumption.** Phase B5 changes the adapter registration loops. The contributor protocol's unified `codecs:` slot must accept class-form descriptors; verify the slot's shape on first attempt; if a friction surfaces, lift the change into the contributor protocol. +- **Test fixture diff volume.** ~50 test sites migrating from `byScalar.X.codec.encode(...)` to typed factory calls. Mechanical but bounded. +- **`override` keyword discipline.** `noImplicitOverride` requires `override` on every concrete-subclass member touching an inherited member; codec authors must remember. Mechanical but catches mistakes. +- **`CodecDescriptorImpl.factory` return-type widening.** The abstract base declares `factory` to return `(ctx) => Codec`. Concrete subclasses returning a typed `Codec` are subtype-assignable, but consumer-side type extraction (`ReturnType>`) reads the abstract base's widened return. Per-codec column helpers preserve precise types via *direct* invocation at the call site (the load-bearing variance discipline of Pattern E); the type-test fixtures verify this. Phase B may surface ergonomic friction worth revisiting; if so, an alternative is making the abstract `factory` generic over the concrete codec type. Defer until first encountered. +- **Phase ordering.** Phase A → B1 → B2 → B3 → B4 → B5 → C → D. Phase A is purely additive (LANDED). Each Phase B sub-phase keeps the build green (legacy interface form survives alongside until Phase C). Phase C is the deletion sweep that exposes any latent dependency. + +### Estimated diff + +| Phase | Production files | Test files | LoC scope | +|---|---|---|---| +| A | ~6 (framework class hierarchy + barrel + ColumnTypeDescriptor relocation + Postgres descriptor-level alias rework) | ~1 | ~600 (LANDED) | +| B1 | ~3 (relational-core base codecs) + 1 (Postgres legacy `pgXxxCodec` defensive rework) | ~3 | ~500 | +| B2 | ~3 | ~5 | ~1500 | +| B3 | ~3 | ~3 | ~600 | +| B4 | ~4 | ~4 | ~500 | +| B5 | ~5 (adapters + contributor wiring) | ~5 | ~400 | +| C | ~15 (deletions + barrel reconciliation) | ~50 (test migration) | ~1500 | +| D | ~3 new test-d files | ~5 | ~500 | +| **Total** | **~40 production files** | **~75 test files** | **~6100 LoC** | + +Comparable to (or larger than) the original combined M0+M2 estimate. Six PR boundaries (A, B1, B2, B3, B4, B5+C, D) keep individual reviews tractable. + +## Milestone M1 — Narrow runtime `Codec` instance + descriptor-keyed metadata reads + +**Status: LANDED** (commits `3c9338fef`, `1be7564c4`). + +Re-verify post-M0: + +- The class-based `Codec` abstract class declares only `id` getter (proxied through descriptor) + the four conversion methods. +- No instance-level `traits` / `targetTypes` / `meta` / `renderOutputType` slots. +- All consumer sites read static metadata from descriptors. + +If post-M0 any regressions surface, file a follow-up. No re-implementation expected. + +## Milestone M2 — Native descriptor migration + +**Status: ABSORBED into M0.** + +M2 R1–R3 landed (interface-form descriptor migration, synthesis bridge deletion, `aliasCodec` retirement, `arktypeJsonEmitCodec` deletion, narrowed `Codec` shape). M2 R4 (legacy-API deletion) was rolled back. M0's Phase B + Phase C absorb M2 R4's intent — the migration to the class form IS the native descriptor migration; the deletion of `mkCodec` / `defineCodec` / `byScalar` IS what M2 R4 was attempting. There is no separate M2 R4 retry milestone. + +## Milestone M3 — `ParamRef.refs` plumbing + encode-side `forColumn` + `forCodecId` retirement + +**Goal**: Every `ParamRef` constructed at a column-bound site carries `refs: { table, column }`. A builder-pipeline validator pass enforces refs-required for parameterized codec ids. Encode-side dispatch goes through `forColumn(refs.table, refs.column)`. The `forCodecId` fallback retires for parameterized codec ids. + +**Spec ACs addressed**: AC-5. + +### Tasks + +1. **T3.1 — Audit refs-less encode-side call sites.** Grep for every `ParamRef.of(...)` and `new ParamRef(...)` in production. For each, determine: column-bound (refs available)? targets a parameterized codec id today? Sites identified on `origin/main`: + - `packages/2-sql/4-lanes/relational-core/src/expression.ts:75` + - `packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts:43,47` + - `packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts:50,96` + - `packages/3-extensions/sql-orm-client/src/where-binding.ts:125` + - `packages/3-extensions/sql-orm-client/src/types.ts:293` +2. **T3.2 — Extend `ParamRef` AST node.** Add `refs?: { table: string; column: string }`. +3. **T3.3 — Add a builder-pipeline validator pass.** `validateParamRefRefs(plan, descriptorMap)` walks expressions, identifies `ParamRef`s whose `codecId` is parameterized (`descriptorFor(codecId).paramsSchema` validates non-`void`), asserts `refs !== undefined`. Refs-less parameterized-codec-id `ParamRef`s throw a clear diagnostic naming the codec id and the binding site. +4. **T3.4 — Populate refs at every column-bound site** identified in T3.1. +5. **T3.5 — Encode-side dispatch via `forColumn`.** `encodeParam` in `packages/2-sql/5-runtime/src/codecs/encoding.ts` consults `paramRef.refs` and resolves through `contractCodecs.forColumn(refs.table, refs.column)` when present. Falls back to `descriptorFor(codecId).factory(undefined)(syntheticInstanceCtx)` for non-parameterized codec ids without refs. The `forCodecId` path retires for parameterized codec ids. +6. **T3.6 — Tests.** Validator-pass unit test (refs-less parameterized codec ParamRef → throw). Encode-side dispatch integration test (vector encode goes through `forColumn`, not `forCodecId`). Refs propagation tests for each migrated site. +7. **T3.7 — Validation checkpoint** and **pause for review**. + +### Risks + +- Refs propagation surface area: the 5 enumerated sites may not be exhaustive; the audit catches more. +- AST rewriters (`Expression.rewrite`) construct new `ParamRef` instances; preserve refs across rewrites. +- Validator-pass ergonomics: refs-less parameterized-codec-id `ParamRef`s exist transiently in the AST; the pass must run before encode. + +### Estimated diff + +~10 production files + ~5 test files. + +## Milestone M4 — `JsonSchemaValidatorRegistry` deletion + trait retirement + +**Goal**: JSON-Schema validation lives in the resolved codec's `decode` body (already the case for `arktypeJsonCodec`). The `JsonSchemaValidatorRegistry`, `buildJsonSchemaValidatorRegistry`, the `jsonSchemaValidators?` slot on `ExecutionContext`, and `packages/2-sql/5-runtime/src/codecs/json-schema-validation.ts` all delete. The `'json-validator'` `CodecTrait` retires if no consumer remains. + +**Spec ACs addressed**: AC-6. + +### Tasks + +1. **T4.1 — Audit `'json-validator'` trait consumers.** Grep `'json-validator'` and `extractValidator`. +2. **T4.2 — Verify arktype-json's inline validation path is the only producer of validator state.** +3. **T4.3 — Delete `JsonSchemaValidatorRegistry`** from `packages/2-sql/4-lanes/relational-core/src/query-lane-context.ts`. Delete `buildJsonSchemaValidatorRegistry`. +4. **T4.4 — Delete the `jsonSchemaValidators?` slot** on `ExecutionContext`. +5. **T4.5 — Delete `packages/2-sql/5-runtime/src/codecs/json-schema-validation.ts`** and any callers. +6. **T4.6 — Retire the `'json-validator'` `CodecTrait`** if T4.1 found no consumers. +7. **T4.7 — Tests.** Update / delete `packages/2-sql/5-runtime/test/json-schema-validation.test.ts`. Real-DB e2e: arktype-json roundtrip. +8. **T4.8 — Validation checkpoint** and **pause for review**. + +### Risks + +- Hidden consumers of the validator registry; the grep audit is the safety net. +- Decode-error diagnostic regression — verify the inline path's error envelope (`RUNTIME.JSON_SCHEMA_VALIDATION_FAILED`) carries equivalent information. + +### Estimated diff + +~5 production files + ~5 test files. + +## Project-wide close-out + +Done after M4 lands cleanly. Per [drive-project-workflow](../../.cursor/rules/drive-project-workflow.mdc): + +1. **Migrate long-lived docs into `docs/`.** ADR 208 was authored by the parent project to describe the unified model; verify it accurately reflects the post-TML-2357 state under Pattern E (class-based descriptors, per-codec helpers, no `defineCodec`, no `forCodecId` fallback for parameterized codec ids, no emit-shim, no `CodecParamsDescriptor`, narrow runtime `Codec`). Update if needed. +2. **Strip repo-wide references to `projects/codec-registration-completion/**`** (replace with ADR 208 / canonical `docs/` links or remove). +3. **Delete `projects/codec-registration-completion/`** in the close-out commit. +4. **Linear**: TML-2357 auto-closes when the PR(s) merge (issue id in branch name + PR title). + +## Open items (deferred) + +- **`pgEnumCodec` factory audit** — placeholder factory; documented in ADR 208 § Future work; separate ticket. +- **Mongo registration migration + Mongo runtime `forColumn`** — TML-2324. +- **Mongo control-plane `parameterizedCodecs:` slot** — separate ticket; Mongo demos don't use parameterized codecs, so the gap is authoring-time only. +- **Future per-library JSON extensions (zod, valibot)** — not blocked by this work. +- **`pnpm test:packages` parallel-execution flake** — workspace-parallel test runs intermittently fail in `@prisma-next/sql-orm-client` / `@prisma-next/cli` / `@prisma-next/adapter-postgres`; isolated re-runs pass. Filed as [TML-2402](https://linear.app/prisma-company/issue/TML-2402). Pre-existing; not in scope for TML-2357. Worked around during this project by re-running affected packages in isolation to confirm the failure pattern. +- **Turbo cache-keying gap on transitive AST/type changes** — observed twice (M3 R1, M4 R1): downstream consumers' build caches did not invalidate after a structurally compatible AST change in `@prisma-next/sql-relational-core`, requiring `pnpm build --force`. Filed as [TML-2403](https://linear.app/prisma-company/issue/TML-2403). + +## Close-out outcome (post-orchestrator interrupt) + +The original plan above (steps 1–4 of the project-wide close-out) was executed with one deviation. Step 3 ("delete `projects/codec-registration-completion/`") was reverted mid-close-out by an orchestrator directive: the project directory is retained in-tree as a historical record rather than removed. + +What landed on the close-out branch: + +- **Step 1 — doc migration**: ADR 208 rewritten to describe Pattern E (class-form `CodecImpl` + `CodecDescriptorImpl` + per-codec column helper) as the canonical authoring shape; new contributor reference at [`docs/reference/codec-authoring-guide.md`](../../docs/reference/codec-authoring-guide.md); retrospective notes added to ADRs 184/186/202/204/205 explaining that their `defineCodec({...})` examples reflect the prior surface and pointing to ADR 208 + the authoring guide for current practice. +- **Step 2 — reference cleanup (revised)**: project-internal milestone/phase markers (`(TML-2357 M0 Phase B5/C)` etc.) in code comments and docblocks were replaced with the durable Linear ticket id `(TML-2357)`. References that point into this directory remain only inside the directory itself (self-references in `plan.md` and `specs/`); no external file links into `projects/codec-registration-completion/**`. +- **Step 3 — directory deletion**: **NOT executed.** A deletion commit (`aacf58dccf7347d460ae01f02db1b4d2a8d23300`) was created and immediately reverted by `5b0113a5afbdf93a5c03ed6b70c3546aa367d657`. The five tracked spec/plan files in this directory were restored from the prior commit. The gitignored `reviews/` contents (review artifacts created during execution; never tracked by git) are not recoverable; per the original close-out triage they were classified as transient review artifacts. +- **Step 4 — Linear**: TML-2357 will auto-close on PR merge via branch name + PR title link. Two follow-up tickets filed during close-out triage: [TML-2402](https://linear.app/prisma-company/issue/TML-2402) (parallel-execution flake, P3) and [TML-2403](https://linear.app/prisma-company/issue/TML-2403) (turbo cache-keying gap, P4). Both are pre-existing/operational, not regressions introduced by this project. diff --git a/projects/codec-registration-completion/spec.md b/projects/codec-registration-completion/spec.md new file mode 100644 index 0000000000..857454ef23 --- /dev/null +++ b/projects/codec-registration-completion/spec.md @@ -0,0 +1,213 @@ +# Spec — Codec registration completion (TML-2357) + +> Follow-up to [codec-registry-unification](https://linear.app/prisma-company/issue/TML-2229) (merged to main; [ADR 208](../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md)). Completes the registration-side migration the parent work deliberately deferred. + +## Decision + +Foundational precondition surfaced during M2 R4: `defineCodec`'s declared return type drops the codec generics its `spec` argument inferred, so per-target descriptor records carry `CodecDescriptor` and the typed `Codec` flow into no-emit authoring (`field.uuidv4()`) and emit-path `contract.d.ts` `TypeMaps` derivation collapses. Without fixing this, AC-1 / AC-2 / AC-3 / AC-7 below cannot land cleanly. **See [`specs/typed-codec-flow.spec.md`](specs/typed-codec-flow.spec.md) for the full statement; AC-0 below refers to it.** + +Every codec contributor in the framework ships native `CodecDescriptor`s. The synthesis bridge that auto-lifts legacy `Codec` instances at context-construction (`synthesizeNonParameterizedDescriptor`) deletes; the `parameterizedCodecs:` registration slot deletes; the legacy SQL adapter `CodecParamsDescriptor` shape deletes; the `JsonSchemaValidatorRegistry` workaround deletes. The runtime `Codec` instance type narrows to a back-referenced behavior shape — it keeps `id` (the descriptor's `codecId`, set by the factory) and the four conversion methods (`encode`, `decode`, `encodeJson`, `decodeJson`); it loses the codec-id-keyed static metadata (`traits`, `targetTypes`, `meta`) and the build-time `renderOutputType?` shim, all of which live only on the descriptor. The emit path consults `descriptorFor(codecId).renderOutputType` directly; no per-library "emit-only Codec" stub is needed (today's `arktypeJsonEmitCodec` retires). + +`ParamRef` gains a structural invariant: every `ParamRef` whose `codecId` resolves to a parameterized descriptor (`P` non-`void`) must carry `refs: { table, column }`. Refs are populated from every column-bound construction site in the SQL builder and the ORM client. Encode-side dispatch goes `forColumn(refs.table, refs.column)` for column-bound params and `descriptor.factory(undefined)(syntheticInstanceCtx)` for non-parameterized refs-less params. The legacy `forCodecId` fallback (the AC-5-deferred carve-out parent project left in place) retires for parameterized codec ids. + +The postgres target's `aliasCodec` helper retires in favor of `aliasDescriptor(base, { codecId, targetTypes, meta })`, which composes at the descriptor level. The alias's factory delegates to the base descriptor's factory and rewrites the resolved codec's `id`. + +After this work, the codec registration model is uniform: one descriptor per codec id, one registration slot, no parameterized/non-parameterized branching at any read or registration site, no per-codec emit-shim or runtime-fallback workarounds. + +## Why + +Parent project `codec-registry-unification` (TML-2229; merged to main as commits `3d650b312` … `3194eb81d` plus the post-merge Phase E refactors `6cbfaa5a1` and `977ae8fbf`) shipped the read-surface unification — `descriptorFor(codecId)` and `forColumn(table, column)` resolve through one descriptor map without branching on parameterization. It explicitly deferred the registration-side migration to TML-2357 and lists six concrete defects that survived its merge: + +1. **Parameterized codec ids without column refs depend on `forCodecId` fallback.** The encode-side path resolves through `forCodecId(codecId)` when the call site doesn't carry a column ref. For non-parameterized codec ids this is harmless (the codec is a singleton). For parameterized codec ids it works by coincidence — the descriptor's factory is reachable through the synthesis bridge, but the encode wire format must be parameter-independent for the result to be correct. Today only pgvector hits this path, and its wire format happens to be length-independent. Any future parameterized codec whose encode depends on its parameters would silently produce malformed wire values. Comments in `relational-core/ast/codec-types.ts` and `sql-runtime/codecs/encoding.ts` already mark this as a TML-2357 retirement target. + +2. **Codec-id-keyed metadata lives in two places.** The descriptor is the source of truth at the read surface (after the parent work), but the runtime `Codec` instance still carries `id`, `targetTypes`, `traits`, and the `renderOutputType?` build-time hook. Several consumer sites still read these fields off the resolved codec instead of consulting the descriptor. The duality reintroduces the very drift the parent set out to retire. + +3. **`arktypeJsonEmitCodec` is a workaround for the emit-path.** A `Codec` instance registered through the legacy `codecs:` slot purely so the emit-path renderer can find its `renderOutputType` via `forCodecId('arktype/json@1')`. The instance's `encode`/`decode` reject at runtime — they're stubs. The shim exists because the emit path consults the codec registry (not the descriptor map) for `renderOutputType`. Same shape as point 1, different reason: a placeholder codec on the legacy slot to serve a single read site. + +4. **The `parameterizedCodecs:` adapter slot is parallel to `codecs:`.** Contributors ship through both slots — non-parameterized through `codecs:`, parameterized through `parameterizedCodecs:`. Both flow into the unified descriptor map at context-construction. The slot duality is mechanical-but-real: every contributor declares a `parameterizedCodecs(): []` even when they ship none. + +5. **`CodecParamsDescriptor` is the legacy compile-time shape** that the SQL `Adapter.parameterizedCodecs()` surface still returns. The runtime `RuntimeParameterizedCodecDescriptor` already migrated to the unified `CodecDescriptor

` shape; the adapter shape lags. The codec-types.ts comment block in relational-core explicitly tracks this as TML-2357 T3.5.4. + +6. **`JsonSchemaValidatorRegistry` is a vestige.** Per-instance JSON-Schema validator state lives in a parallel data structure rather than on the resolved codec's `decode` body. Parent's Phase C ships `arktypeJsonCodec` with inline validation (its `factory` builds the validator inside the closure and references it from `decode`) — the precedent for the future deletion. The `'json-validator'` `CodecTrait` is explicitly tagged "Retirement target" under TML-2357 in `framework-components/src/shared/codec-types.ts`. + +These six defects share a root cause: the registration model was only partially unified. The descriptor map is the read-surface source of truth; the registration model still ships codecs through a parallel slot, an instance-level metadata duplicate, an emit-path shim, a legacy adapter-level descriptor shape, and a parallel per-instance-state registry. Closing the loop requires every contributor to ship descriptors directly through one slot, every consumer to read static metadata from descriptors, the emit path to consult `descriptorFor(codecId).renderOutputType` directly, and per-instance state to live where the descriptor's factory puts it. + +## Glossary + +| Term | Meaning | +|---|---| +| **CodecDescriptor** | The registration record (defined by parent project). One per codec id. Carries `codecId`, `traits`, `targetTypes`, `meta`, `paramsSchema`, optional `renderOutputType`, and `factory`. | +| **Codec (runtime instance)** | The behavior-bearing object returned by `descriptor.factory(params)(ctx)`. After this work, carries only `id` (back-reference to descriptor's `codecId`) and the four conversion methods. `encode` and `decode` remain `Promise`-returning per [ADR 204](../../docs/architecture%20docs/adrs/ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md); they take a per-call `CodecCallContext` (signal, family-extended with `column?` for SQL). | +| **`CodecInstanceContext`** | Family-agnostic per-instance context the framework supplies to `descriptor.factory(params)(ctx)`. Carries `name` (the materialization-site identity). Family-specific extensions (e.g. `SqlCodecInstanceContext`) augment with column-set metadata (`usedAt`). | +| **`CodecCallContext`** | Family-agnostic per-call context the runtime supplies on every `encode`/`decode` invocation. Carries `signal?` for cancellation. SQL-family extension `SqlCodecCallContext` adds `column?: SqlColumnRef`. Out of scope to reshape here. | +| **Synthesis bridge** | `synthesizeNonParameterizedDescriptor(codec)` — the helper parent project introduced so legacy non-parameterized codecs auto-lift into descriptors at context-construction. Deletes when every contributor ships descriptors natively. | +| **`parameterizedCodecs:` slot** | The contributor slot parent left in place alongside the legacy `codecs:` slot for parameterized descriptors. Deletes when both shapes consolidate under a single `codecs: () => ReadonlyArray` slot. | +| **`CodecParamsDescriptor`** | The legacy SQL adapter parameterized-descriptor shape (`paramsSchema` + optional `init` hook). Adapter-level only; the runtime descriptor migrated. Deletes alongside the `parameterizedCodecs:` slot. | +| **`forCodecId` fallback** | The encode/decode dispatch fallback on `ContractCodecRegistry` for sites that don't carry a column ref. Retires for parameterized codec ids once `ParamRef.refs` is plumbed. | +| **`arktypeJsonEmitCodec`** | The placeholder `Codec` instance arktype-json registers on the legacy `codecs:` slot purely so emit-path `renderOutputType` lookup via `forCodecId('arktype/json@1')` works. Deletes after emit consults `descriptorFor(codecId).renderOutputType` directly. | +| **`JsonSchemaValidatorRegistry`** | The parallel data structure that today holds per-column JSON-Schema validators. Deletes; validation moves into the resolved codec's `decode` body (matching the arktype-json pattern parent's Phase C established). | +| **`aliasCodec`** | The postgres-target helper that composes a derived codec from a base codec by copying its `encode` / `decode` / `traits` and overlaying a new `id` / `targetTypes` / `meta`. Replaced by `aliasDescriptor`, which composes at the descriptor level. | +| **`ParamRef.refs`** | New optional field on the `ParamRef` AST node carrying `{ table, column }` for column-bound DSL params. Required (validator-pass enforced) whenever the param's codec id is parameterized. | + +## Cases that pin the design + +These three cases drive the structural decisions; if any can't be expressed cleanly under the unified registration shape, the design is wrong. + +### Case T — Text codec migration (non-parameterized, native registration) + +`pg/text@1` ships from the postgres target package as a `CodecDescriptor` directly: no `codec({ ... })` factory consumed by `synthesizeNonParameterizedDescriptor`, just a hand-rolled descriptor whose factory closes over a shared `pgTextCodec` instance. The descriptor's `paramsSchema` is `voidParamsSchema`; its `traits` are `['equality', 'order', 'textual']`; its `meta` carries `db.sql.postgres.nativeType: 'text'`. The legacy `codecs:` slot in the postgres runtime adapter (currently lists `pgTextCodec` and ~21 siblings) deletes; everything ships through the unified `codecs: () => ReadonlyArray` slot. + +What this case pins: + +- Every non-parameterized codec contributor ships a descriptor through the unified `codecs:` slot — no synthesis happens at runtime. +- The contributor authoring surface is uniform: a `defineCodecDescriptor` helper (or inline construction) for both parameterized and non-parameterized codecs. +- The narrowed runtime `Codec` instance still carries `id` (the descriptor's `codecId`); the descriptor's `factory` is responsible for setting it. This keeps decode-error messages and dispatch-site debugging unchanged. + +### Case V — Vector codec encode (parameterized; refs always required) + +A SQL builder constructs a query with `vectorCol.eq([1.2, 3.4, ...])` against `Document.embedding` (a `vector(1536)` column). The encode-side path constructs a `ParamRef` for the `[1.2, 3.4, ...]` value with `codecId: 'pg/vector@1'`. Because `pg/vector@1` is parameterized, the AST validator pass enforces `refs: { table: 'Document', column: 'embedding' }`; the SQL builder populates them from the `vectorCol` reference. At encode time, dispatch goes `forColumn('Document', 'embedding')`, which resolves through the per-instance `pg/vector@1` codec materialized for that column's `typeParams: { length: 1536 }`. The `forCodecId` fallback is never consulted for `pg/vector@1`. + +What this case pins: + +- `ParamRef.refs` is the primary encode-side dispatch input for column-bound params. +- The validator-pass invariant — `descriptor.paramsSchema is non-void → refs !== undefined` — is enforced before encode runs, so the builder fails fast if a column-bound site forgot to plumb refs. +- The encode-side `forCodecId` fallback survives only for non-parameterized codec ids (where `descriptor.factory(undefined)(syntheticInstanceCtx)` returns the shared singleton). For parameterized codec ids without refs, the validator refuses to admit the AST. + +### Case J — JSON-with-schema decode (parameterized; validator inline; emit via descriptor) + +A row containing `{ active: true }` arrives over the wire for a `Product.metadata` column typed as `arktypeJson(ProductSchema)`. The decode path calls `forColumn('Product', 'metadata').decode(wire, callCtx)` and the resolved codec's `decode` body parses the JSON, runs the arktype validator, and returns the typed value. There is no `JsonSchemaValidatorRegistry` lookup; the validator was compiled inside the descriptor's `factory({ expression, jsonIr })` closure and is referenced from `decode` directly. + +When the emitter runs `pnpm emit`, it walks the contract's models, looks up `arktype/json@1` via `descriptorFor('arktype/json@1')`, and calls `descriptor.renderOutputType(typeParams)` to produce the TypeScript output type. No `arktypeJsonEmitCodec` shim on the legacy `codecs:` slot is consulted. + +What this case pins: + +- Per-instance state (compiled validators, derived keys, etc.) lives on the resolved codec where the descriptor's factory put it. No parallel registry. +- The emit path's `renderOutputType` consultation routes through `descriptorFor(codecId)`, not through the codec registry. The per-library emit shim retires. +- The `'json-validator'` `CodecTrait` retires once no consumer reads it — its only purpose was to gate the structural cast through which `JsonSchemaValidatorRegistry` was consulted. The trait is already explicitly tagged "Retirement target" in framework-components. +- The descriptor's `paramsSchema` runs validation at the JSON boundary (`contract.json` → runtime), guaranteeing the params the factory closes over are well-formed before any decode path runs. + +## Acceptance criteria + +### AC-0. Typed `Codec` flow through `CodecDescriptor` (precondition) + +- `defineCodec({...})` returns a descriptor type that preserves the codec generics inferred from its `spec` argument (`Id`, `TTraits`, `TWire`, `TInput`, `TParams`). +- Per-target descriptor records (`PgDescriptors`, `SqliteDescriptors`, `PgvectorDescriptors`, `SqlDescriptors`, `ArktypeJsonDescriptors`) carry each entry's full descriptor type by inference. +- The no-emit authoring chain types end-to-end: `field.uuidv4()` returns a typed field spec; `defineContract({...}, ...)` produces a typed contract; `sqlBuilder({context})`-produced query expressions type-check correctly-typed parameters and reject incorrectly-typed ones. +- Emit-path `contract.d.ts` `TypeMaps` projection has correct per-codec-id `{input, output, traits}` shapes; `pnpm fixtures:check` passes. +- **Forcing-function deletion.** Every parallel typed-instance carrier deleted within M0 (closing-grep zero across `packages/ test/ examples/ docs/` for: `mkCodec`, `defineCodecGroup`, `defineCodecBundle`, `CodecDefBuilder`, `CodecDefBuilderImpl`, `ExtractDescriptorCodecTypes` (renamed to `ExtractCodecTypes`), `byScalar`, `dataTypes`, `sqlCodecDefinitions`, `codecDescriptorDefinitions`). Absorbs [TML-2393](https://linear.app/prisma-company/issue/TML-2393) (the `byScalar` antipattern cleanup). +- Negative type tests assert the typed-flow chain at `defineCodec` round-trip, per-target descriptor record entries, and the no-emit authoring chain. + +Full statement: [`specs/typed-codec-flow.spec.md`](specs/typed-codec-flow.spec.md). AC-0 must land before AC-1, AC-2, AC-3, AC-7 can complete. + +### AC-1. Every codec ships as a `CodecDescriptor` + +- Every codec in the SQL families (postgres target/adapter, sqlite target/adapter, sql-relational-core base codecs, pgvector extension, arktype-json extension) ships as a `CodecDescriptor` through the unified `codecs:` slot. +- The synthesis bridge `synthesizeNonParameterizedDescriptor` is unused in production code; the function and its export delete. +- `arktypeJsonEmitCodec` and the `pack-meta.ts` `codecInstances: [arktypeJsonEmitCodec]` registration delete. + +**Excluded**: Mongo codec migration. Folded into [TML-2324](https://linear.app/prisma-company/issue/TML-2324) (Mongo runtime `forColumn` plumbing). + +### AC-2. Single registration slot + +- `SqlStaticContributions.codecs` returns `ReadonlyArray` (not `Codec`); the legacy `codecs: () => CodecRegistry` shape gone from the contributor protocol. +- `parameterizedCodecs:` slot deleted from `SqlStaticContributions`, `Adapter`, `RuntimeAdapter`, `RuntimeTarget`, `ControlAdapter`, every contributor's runtime/control descriptors, and `cli/src/control-api/contract-enrichment.ts`'s destructure. +- `CodecParamsDescriptor` deletes from `@prisma-next/sql-relational-core`; the adapter-level `parameterizedCodecs():` collapses into the unified `codecs():` slot. + +### AC-3. Runtime `Codec` instance narrowed + +- The `Codec` interface in `@prisma-next/framework-components/codec` declares only `id` and the four conversion methods (`encode`, `decode`, `encodeJson`, `decodeJson`). **(M1)** +- `traits`, `targetTypes`, `meta`, and `renderOutputType?` are removed from the base interface in M1, and from every family-specific extension (SQL `Codec`, Mongo `MongoCodec`) in M2 alongside the synthesis-bridge deletion. The two-stage shape is intentional: M1 narrows the *framework* surface and migrates every framework-side consumer; family extensions retain optional transitional fields through M1 so the synthesis bridge (`synthesizeNonParameterizedDescriptor`) and `aliasCodec` keep working until M2 deletes both alongside the per-library descriptor migration. **(M1 framework / M2 family extensions)** +- `encode`/`decode` retain their async signature with `CodecCallContext` per ADR 204; this work doesn't reshape the call surface. +- Every consumer of the removed fields migrates to read them from `descriptorFor(codecId)`. Concrete sites (verified by grep on the post-merge baseline): + - `packages/1-framework/1-core/framework-components/src/control/control-stack.ts` — `codec.id` reads stay; any `targetTypes`/`traits` consultation routes through descriptors. + - `packages/2-sql/2-authoring/contract-psl/src/provider.ts` — `descriptorFor(codecId).targetTypes[0]`. + - `packages/2-mongo-family/2-authoring/contract-psl/src/derive-json-schema.ts` — analogous. + - `packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts` — `CodecRegistryImpl.register`, `hasTrait`, `traitsOf`, `defineCodecs` builder. + - `packages/2-sql/5-runtime/src/codecs/decoding.ts` — `codec.id` reads stay; any read of `codec.traits` / `codec.targetTypes` rewires to descriptor reads. + - `packages/3-targets/3-targets/postgres/src/core/codecs.ts` — `aliasCodec` deletes (replaced by `aliasDescriptor`). + - `packages/3-targets/6-adapters/postgres/src/core/{adapter,descriptor-meta}.ts` — `Object.values(codecDefinitions)` mappings consult descriptors. + - The emit path's `renderOutputType` consultation routes through `descriptorFor(codecId).renderOutputType` (retiring the per-library emit shim). + - `packages/2-mongo-family/1-foundation/mongo-codec/src/codec-registry.ts` — `codec.id`-keyed registration stays; Mongo's full migration is TML-2324. + +### AC-4. `aliasDescriptor` replaces `aliasCodec` + +- `aliasDescriptor(base: CodecDescriptor

, overrides: { codecId, targetTypes, meta? }): CodecDescriptor

` exists in the postgres target package (or as a shared helper in framework-components if Mongo would benefit later). +- The alias's `factory` delegates to `base.factory`, producing a new resolved codec whose `id` matches the alias's `codecId` and whose behavior is the base's behavior. +- Every `aliasCodec(...)` call site in `packages/3-targets/3-targets/postgres/src/core/codecs.ts` migrates to `aliasDescriptor(...)`. + +### AC-5. `ParamRef.refs` plumbed and validator-pass enforced + +- `ParamRef` carries an optional `refs?: { table: string; column: string }` field on the AST node. +- A builder-pipeline validator pass enforces: if the descriptor map indicates `codecId` is parameterized (i.e. `descriptorFor(codecId).paramsSchema` validates a non-`void` shape), `refs` MUST be present. Refs-less parameterized-codec-id `ParamRef`s throw a clear diagnostic naming the codec id and the binding site at build time. +- Refs are populated at every column-bound construction site: + - `packages/2-sql/4-lanes/relational-core/src/expression.ts:75` (the `toExpr` helper threads refs when the column is known) + - `packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts:43,47` (INSERT VALUES / UPDATE SET binding) + - `packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts:50,96` (ORM mutation binding) + - `packages/3-extensions/sql-orm-client/src/where-binding.ts:125` (ORM WHERE binding) + - `packages/3-extensions/sql-orm-client/src/types.ts:293` (ORM param descriptor construction) + - Any other production site found by grep before implementation starts. +- Encode-side dispatch consults `paramRef.refs` when present and resolves through `forColumn(refs.table, refs.column)`. The `forCodecId` fallback survives only for non-parameterized codec ids; the validator-pass invariant guarantees this is the only case it can hit. + +### AC-6. `JsonSchemaValidatorRegistry` deleted; validation inline + +- `JsonSchemaValidatorRegistry` and `buildJsonSchemaValidatorRegistry` deleted from `@prisma-next/sql-relational-core` and `@prisma-next/sql-runtime`. +- The `jsonSchemaValidators?` slot on `ExecutionContext` deleted. +- Every JSON-with-schema codec (`arktype/json@1` is the only production case today) bakes validation into the resolved codec's `decode` body — already the case for `arktypeJsonCodec` per parent's Phase C; this AC formalizes it as the only path. +- The `'json-validator'` `CodecTrait` deletes if no consumer remains; persists as a structural marker only if a consumer still requires it (audit before deletion). +- `packages/2-sql/5-runtime/src/codecs/json-schema-validation.ts` deletes. + +### AC-7. Validation gates green + +- `pnpm typecheck`, `pnpm lint:deps`, `pnpm test:packages`, `pnpm test:e2e`, `pnpm build` all green. +- Demo emit byte-identical against the post-merge `origin/main` baseline. This work is registration + AST + runtime; emit-path output is unchanged. +- Real-Postgres e2e tests pass for vector encode/decode (parameterized; refs path), JSON-with-schema encode/decode (parameterized; inline-validator path), and non-parameterized columns. + +## Non-goals + +- **Mongo registration migration.** Folded into [TML-2324](https://linear.app/prisma-company/issue/TML-2324). The Mongo runtime's wire-dispatch path differs from SQL's; reshaping it just for codec-registration symmetry would conflict with TML-2324. +- **Mongo runtime `forColumn` plumbing.** TML-2324's scope. +- **Renaming `Codec`** — the type name stays; only the field set narrows. +- **Reshaping the async codec runtime** ([ADR 204](../../docs/architecture%20docs/adrs/ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md)) or `CodecCallContext` ([ADR 207 — codec call context](../../docs/architecture%20docs/adrs/ADR%20207%20-%20Codec%20call%20context%20per-query%20AbortSignal%20and%20column%20metadata.md)) — both are baselines this work composes with. +- **Other codec interface fields** (`bulkEncode`, `preferParam`, redaction traits) — out of scope. +- **`pgEnumCodec` placeholder factory audit** (referenced in ADR 208 § Future work) — separate ticket. +- **Mongo control-plane parameterized-codecs slot** — separate ticket. + +## Non-functional constraints + +- **Zero new type casts** in production code. The descriptor migration unifies what the legacy registration was special-casing; if the consolidation requires a cast, the type design is wrong. +- **No backward-compat shims**: the synthesis bridge, `parameterizedCodecs:` slot, legacy `codecs: () => CodecRegistry` shape, `CodecParamsDescriptor`, `arktypeJsonEmitCodec`, and `JsonSchemaValidatorRegistry` all delete in this work; contributors that ship through them must migrate. +- **No `any`, no `@ts-expect-error` outside negative type tests, no `@ts-nocheck`, no biome suppressions.** +- **Demo emit byte-identical against `origin/main`.** Parent's Phase A fixed the typeRef emit-path bug; subsequent work must not regress. +- **Layering**: `CodecDescriptor` and `aliasDescriptor` (if shared) live in `framework-components`. Family-specific descriptors live in their target/adapter/extension packages. `pnpm lint:deps` passes throughout. + +## Project base + +Branched from `origin/main` (currently at `1d8b70943`, post-TML-2229 merge). Single branch (`tml-2357-codec-registration-model-complete-the-unified`); milestones land as separate commits with a pause-for-review checkpoint between each. If the diff per milestone is small enough, all milestones land in a single PR; otherwise the milestone boundaries become PR boundaries (the Linear ticket pre-suggests a 4-PR split if needed). + +## Outcomes + +- One descriptor per codec id; one registration slot; no parameterized/non-parameterized branching anywhere in the registration or read paths. +- The `forCodecId` fallback for parameterized codec ids retires; encode-side dispatch is column-aware end-to-end. +- Runtime `Codec` instance narrowed to behavior + back-reference; static metadata and emit-path renderer live only on the descriptor. +- `CodecParamsDescriptor`, `arktypeJsonEmitCodec`, and `JsonSchemaValidatorRegistry` deleted; legacy adapter shape, per-library emit shim, and per-instance validator-state workarounds retire for good. +- The codec model documented in [ADR 208](../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md) matches the implementation byte-for-byte: the registration shape, the read shape, the emit shape, and the per-instance lifecycle all describe the same artifact. + +## Forward-looking work captured but out of scope + +- **TML-2324** — Mongo runtime `forColumn` plumbing (and Mongo codec registration migration, folded in). +- **Future per-library JSON extensions** (zod, valibot) when each has a clean serialize/rehydrate story. +- **`pgEnumCodec` placeholder factory audit** — its factory is a placeholder (enum values aren't parameterized in the curried-factory sense). Documented in ADR 208 § Future work; separate ticket. +- **Mongo control-plane `parameterizedCodecs:` slot** — Mongo's control descriptor doesn't carry the slot today; the Mongo `vector(N)` factory is exported and tested but cannot register through control until the slot lands. Mongo demos don't use parameterized codecs, so the gap is authoring-time only. + +## References + +- [ADR 208 — Higher-order codecs for parameterized types](../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). The codec model this work completes the registration side of. +- [ADR 204 — Single-Path Async Codec Runtime](../../docs/architecture%20docs/adrs/ADR%20204%20-%20Single-Path%20Async%20Codec%20Runtime.md). Establishes the async `Codec` baseline this work composes with. +- [ADR 207 — Codec call context per-query AbortSignal and column metadata](../../docs/architecture%20docs/adrs/ADR%20207%20-%20Codec%20call%20context%20per-query%20AbortSignal%20and%20column%20metadata.md). Establishes `CodecCallContext` and the family-specific extension pattern this work preserves. +- [TML-2229](https://linear.app/prisma-company/issue/TML-2229). The parent project (merged to main) — read-surface unification (descriptor map, `descriptorFor`, `forColumn`, synthesis bridge) and per-library JSON extension scaffold. +- [TML-2324](https://linear.app/prisma-company/issue/TML-2324). Mongo runtime `forColumn` plumbing (parallel work; absorbs Mongo codec registration migration). +- [TML-2357](https://linear.app/prisma-company/issue/TML-2357). This project's Linear ticket. + +## Open questions + +None remaining for spec-time. Implementation-time questions documented in `plan.md` per milestone. diff --git a/projects/codec-registration-completion/specs/class-based-codec-design.spec.md b/projects/codec-registration-completion/specs/class-based-codec-design.spec.md new file mode 100644 index 0000000000..a4857185e0 --- /dev/null +++ b/projects/codec-registration-completion/specs/class-based-codec-design.spec.md @@ -0,0 +1,622 @@ +# Class-based codec design (Mode C, Approach 2) + +## Status + +**Implementation-approach spec** for the [Mode C goal](factory-defined-codec-types.spec.md). Describes a specific implementation pattern where `CodecDescriptor` and `Codec` are abstract base classes that codec authors extend, and the type-flow surface is a per-codec helper function tied to its descriptor by `satisfies`. + +This spec describes the **target design** of the spike. The spike itself is exploratory — small scratch-branch reshape of pgvector + a representative postgres codec, demonstrating AC-1 through AC-6 from the goal spec without touching the rest of the codebase. Scope is in [Spike scope](#spike-scope) below. + +> **Empirical foundation.** An earlier draft of this spec proposed a polymorphic `column(descriptor, params)` helper using structural matching (`{ factory(params: P): R }`) to preserve method-level generics. A TypeScript playground proof falsified that approach: TS instantiates method generics to their constraint at every form of structural extraction (structural match, indexed access, `Parameters`/`ReturnType`, etc.). The current design avoids that path entirely. See [`wip/m0-class-variance-proof.md`](../../../wip/m0-class-variance-proof.md) for the proof and the rejected alternatives. + +## Decision + +A codec is **two paired classes plus one per-codec column helper function**, tied together by `satisfies`: + +- **`CodecDescriptor`** — abstract base class. Codec authors extend it to declare a codec's identity (`codecId`, `traits`, `targetTypes`), validate its parameters (`paramsSchema`), produce its codec instance from params (`factory()`), and render its TS output type for the emit path (`renderOutputType()`). +- **`Codec`** — abstract base class. Codec authors extend it to implement `encode`/`decode` (and JSON variants where applicable). The instance retains a reference to its descriptor; metadata reads (`id`, `traits`) proxy through the descriptor for one source of truth. +- **Per-codec column helper** — a hand-written function (e.g. `vector(length)`, `arktypeJson(schema)`) generic over the same shape as its descriptor's `factory`. The helper invokes `descriptor.factory(...)` **directly** (not via structural extraction). The direct invocation is what preserves method-level generics — TS binds `` to the literal at the call site because the call is a direct method call, not a function-type extraction. A `satisfies ColumnHelperFor` clause ties the helper to its descriptor at compile time, catching wiring mistakes (wrong `codecId`, wrong factory wired in, mismatched typeParams shape). + +The framework provides a trivial `column()` packager that constructs the column-spec record from `(codec, codecId, typeParams)`. It is **not** generic over descriptors — that path was the variance trap. Per-codec helpers absorb the descriptor relationship instead, with `satisfies` enforcing it. + +This is the implementation pattern the goal spec ([`factory-defined-codec-types.spec.md`](factory-defined-codec-types.spec.md)) calls for — factory-as-source-of-truth, expressed through the class hierarchy plus per-codec helpers. + +## Class hierarchy + +### `CodecDescriptor` + +Lives in `@prisma-next/framework-components/codec` (replacing today's `CodecDescriptor` interface). + +```typescript +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { CodecInstanceContext } from './codec-instance-context'; +import { Codec } from './codec'; + +export abstract class CodecDescriptor { + abstract readonly codecId: string; + abstract readonly traits: readonly CodecTrait[]; + abstract readonly targetTypes: readonly string[]; + readonly meta?: CodecMeta; + + /** + * Standard Schema validator for the descriptor's params. Validates the + * params shape at the JSON boundary (contract-load time, PSL parsing). + * The factory's typed input is the type-level constraint; this schema + * is its runtime counterpart. + */ + abstract readonly paramsSchema: StandardSchemaV1; + + /** + * Render the TypeScript output type as a source string for the emit + * path. Optional; non-parameterized codecs and codecs whose output + * type is fixed (e.g. `number`, `string`) return undefined and the + * emitter falls through to the codec's base output type. + */ + renderOutputType?(params: TParams): string | undefined; + + /** + * Materialize a runtime codec instance for the given params. The + * factory's TS-level typed return determines the codec instance type + * for type-level consumers — but only at *direct* call sites + * (per-codec helpers, framework runtime). It does NOT survive + * structural extraction; that's why the column-helper surface is + * per-codec, not polymorphic. + * + * Concrete subclasses override this method with a typed return type + * (e.g. `factory(params: { length: N }): (ctx) => VectorCodec`). + * Direct callers (per-codec helpers) read the typed return; the + * runtime registry sees only the abstract base's signature. + */ + abstract factory(params: TParams): (ctx: CodecInstanceContext) => Codec; +} +``` + +### `Codec` + +Lives in `@prisma-next/framework-components/codec` (replacing today's `Codec` interface). + +```typescript +import type { CodecDescriptor } from './codec-descriptor'; + +export abstract class Codec< + Id extends string, + TTraits extends readonly CodecTrait[], + TWire, + TInput, +> { + constructor(public readonly descriptor: CodecDescriptor) {} + + /** Codec id, proxied from the descriptor. One source of truth. */ + get id(): Id { + return this.descriptor.codecId as Id; + } + + /** Codec traits, proxied from the descriptor. */ + get traits(): TTraits { + return this.descriptor.traits as TTraits; + } + + abstract encode(value: TInput, ctx: SqlCodecCallContext): Promise; + abstract decode(wire: TWire, ctx: SqlCodecCallContext): Promise; + + encodeJson?(value: TInput): JsonValue; + decodeJson?(json: JsonValue): TInput; +} +``` + +The codec instance retaining a reference to its descriptor solves the aliasing concern raised during goal-spec discussion: aliased codecs (if kept at all) point their codec instances at the alias descriptor, and `codec.id` reads the alias's `codecId`. No instance-level `id` field to keep in sync. + +### Concrete codec author pattern + +Authoring a codec is **three artifacts**: the descriptor class, the codec instance class, and the per-codec column helper function. Three illustrative examples spanning the case spectrum. + +#### Non-parameterized codec (Case 1) + +```typescript +class PgInt4Codec extends Codec<'pg/int4@1', readonly ['equality', 'order', 'numeric'], number, number> { + async encode(value: number): Promise { + return value; + } + async decode(wire: number): Promise { + return wire; + } +} + +class PgInt4Descriptor extends CodecDescriptor { + readonly codecId = 'pg/int4@1' as const; + readonly traits = ['equality', 'order', 'numeric'] as const; + readonly targetTypes = ['int4']; + readonly paramsSchema = voidParamsSchema; + + factory(): (ctx: CodecInstanceContext) => PgInt4Codec { + return (ctx) => new PgInt4Codec(this); + } +} + +export const pgInt4Descriptor = new PgInt4Descriptor(); + +export const int4 = () => column( + pgInt4Descriptor.factory(), + pgInt4Descriptor.codecId, + undefined, +); +int4 satisfies ColumnHelperFor; +``` + +The factory has no method-level generic — non-parameterized codecs return the same `PgInt4Codec` for every call. The `int4()` helper is a thin wrapper packaging the codec factory + metadata into a column spec. + +#### Parameterized codec with literal preservation (Case 2) + +```typescript +class VectorCodec extends Codec<'pg/vector@1', readonly ['equality'], string, Vector> { + constructor(descriptor: CodecDescriptor<{ readonly length: N }>, public readonly dimension: N) { + super(descriptor); + } + async encode(value: Vector): Promise { + return `[${value.join(',')}]`; + } + async decode(wire: string): Promise> { + return parsed as Vector; + } +} + +class PgVectorDescriptor extends CodecDescriptor<{ readonly length: number }> { + readonly codecId = 'pg/vector@1' as const; + readonly traits = ['equality'] as const; + readonly targetTypes = ['vector']; + readonly paramsSchema = vectorParamsSchema; + + factory( + params: { readonly length: N }, + ): (ctx: CodecInstanceContext) => VectorCodec { + return (ctx) => new VectorCodec(this, params.length); + } + + renderOutputType(params: { readonly length: number }): string { + return `Vector<${params.length}>`; + } +} + +export const pgVectorDescriptor = new PgVectorDescriptor(); + +export const vector = (length: N) => column( + pgVectorDescriptor.factory({ length }), + pgVectorDescriptor.codecId, + { length }, +); +vector satisfies ColumnHelperFor; +``` + +The class-level params type is `{ readonly length: number }` (widest bound). The **method-level generic** `` on `factory` is what preserves the literal at call sites: when `vector(1536)` calls `pgVectorDescriptor.factory({ length: 1536 })` *directly*, TS binds `N=1536` from the call site. The `vector` helper's own generic `(length: N)` captures the literal one level further out, and the literal flows through the column spec into the contract type. + +This is the core variance pattern of the class-based design: method generics on the descriptor's factory are preserved by **direct invocation inside the per-codec helper**, not by extraction at a polymorphic helper. + +#### Parameterized codec with arktype schema (Case 3) + +```typescript +class ArktypeJsonCodec> extends Codec< + 'arktype/json@1', + readonly ['equality'], + string, + S['infer'] +> { + constructor( + descriptor: CodecDescriptor<{ readonly schema: S }>, + private readonly schema: S, + ) { + super(descriptor); + } + async encode(value: S['infer']): Promise { + return JSON.stringify(value); + } + async decode(wire: string): Promise { + const raw = JSON.parse(wire); + const result = this.schema(raw); + if (result instanceof type.errors) { + throw new Error(`...`); + } + return result; + } +} + +class ArktypeJsonDescriptor extends CodecDescriptor<{ readonly schema: Type }> { + readonly codecId = 'arktype/json@1' as const; + readonly traits = ['equality'] as const; + readonly targetTypes = ['jsonb']; + readonly paramsSchema = arktypeJsonParamsSchema; + + factory>( + params: { readonly schema: S }, + ): (ctx: CodecInstanceContext) => ArktypeJsonCodec { + return (ctx) => new ArktypeJsonCodec(this, params.schema); + } + + renderOutputType(params: { readonly schema: { expression: string } }): string { + return params.schema.expression; + } +} + +export const arktypeJsonDescriptor = new ArktypeJsonDescriptor(); + +export const arktypeJson = >(schema: S) => column( + arktypeJsonDescriptor.factory({ schema }), + arktypeJsonDescriptor.codecId, + { schema }, +); +arktypeJson satisfies ColumnHelperFor; +``` + +Same pattern as `vector`: method-level generic on the descriptor's factory; the per-codec helper's own generic captures the schema's specific type and threads it through the direct call. + +## Column type-flow surface + +The framework exposes one trivial `column()` packager. Per-codec helpers compose with it. + +### Framework `column()` packager + +Lives in `@prisma-next/framework-components/codec` (or alongside `ColumnTypeDescriptor`). + +```typescript +type ColumnSpec = ColumnTypeDescriptor & { + readonly codecFactory: (ctx: CodecInstanceContext) => R; + readonly codecId: string; + readonly typeParams: P; +}; + +export function column( + codecFactory: (ctx: CodecInstanceContext) => R, + codecId: string, + typeParams: P, +): ColumnSpec { + return { codecFactory, codecId, typeParams /* + ColumnTypeDescriptor fields */ }; +} +``` + +Generic over `R` (the codec instance type) and `P` (the typeParams object). The framework does **not** try to infer `R` and `P` from a descriptor — that's the per-codec helper's job. This is intentional: the polymorphic version was the variance trap. + +### Per-codec helper pattern + +Each codec ships its own column helper. The helper: +1. Is generic over the same shape as its descriptor's `factory` method generic. +2. Calls `descriptor.factory({...})` **directly** — not via structural extraction. +3. Packages the result with `column(codecFactory, codecId, typeParams)`. +4. Asserts conformance with `satisfies ColumnHelperFor`. + +```typescript +export const vector = (length: N) => column( + pgVectorDescriptor.factory({ length }), + pgVectorDescriptor.codecId, + { length }, +); +vector satisfies ColumnHelperFor; +``` + +Direct invocation `pgVectorDescriptor.factory({ length })` is the load-bearing piece. TypeScript binds `` to the literal from the call site at this point — the same way `vectorDescriptor.factory({ length: 1536 })` binds `N=1536` in any direct method call. The literal flows through `column(...)`'s `R` and `P` generics into the column spec. + +### `satisfies ColumnHelperFor` discipline + +The framework exports two `ColumnHelperFor` shapes; codec authors pick the one appropriate to their helper. + +#### Coarse — checks typeParams shape only + +```typescript +export type ColumnHelperFor> = ( + ...args: any[] +) => ColumnSpec[0]>; +``` + +Catches: +- Wrong typeParams shape (e.g. helper packaging `{ wrongKey: ... }` when descriptor's factory takes `{ length: ... }`). + +Does **not** catch: +- Wrong codec instance type (the helper could wire in a different descriptor's factory and pass the coarse check). + +Use when the codec doesn't have a stable `ReturnType` that's worth checking (e.g. heavily overloaded factories). + +#### Strict — also checks codec base type + +```typescript +export type ColumnHelperForStrict> = ( + ...args: any[] +) => ColumnSpec, Parameters[0]>; +``` + +Catches: +- Coarse case + wrong codec instance type (e.g. helper invoking `arktypeJsonDescriptor.factory(...)` while declaring as `ColumnHelperForStrict`). + +Does **not** catch: +- Literal-level mismatches between helper's promised codec type and descriptor's factory's typed return. This is fine — `ReturnType` widens method generics to their constraint; the satisfies check is for sanity, and literal preservation comes from the direct invocation, not the satisfies clause. + +Use as the default. The widened `ReturnType` is sufficient because it catches the most common wiring mistake (wrong descriptor) without false positives on literal preservation. + +### Type extraction at consumer sites + +Consumers of a column spec project the codec type via simple type-level extraction: + +```typescript +const embeddingColumn = vector(1536); +// ^? ColumnSpec, { length: 1536 }> + +type ResolvedCodec = C extends ColumnSpec ? R : never; +type EmbeddingCodec = ResolvedCodec; +// ^? VectorCodec<1536> +``` + +Because the literal was bound at the per-codec helper's call site (not extracted from the descriptor), `R` flows through `column(...)`'s `R` generic carrying the literal. `ResolvedCodec` extracts it cleanly via `infer R` — no method generic to widen. + +For `FieldOutputType` (consumed by `contract.d.ts` no-emit definitions): + +```typescript +type ColumnInputType = ResolvedCodec extends Codec ? T : never; +type EmbeddingInput = ColumnInputType; +// ^? Vector<1536> + +const settingsColumn = arktypeJson(productSchema); +type SettingsInput = ColumnInputType; +// ^? typeof productSchema['infer'] +``` + +## Heterogeneous storage at the runtime layer + +The framework's descriptor registry is keyed by `codecId: string` and stores type-erased descriptor instances. Per Q-3c (spike-resolved), the canonical erasure type is `AnyCodecDescriptor` (a `CodecDescriptor` alias defined in `framework-components/shared/codec-descriptor.ts` with the `biome-ignore` comment naming the variance rationale): + +```typescript +import type { AnyCodecDescriptor } from '@prisma-next/framework-components/codec'; + +class CodecDescriptorRegistry { + private readonly descriptors = new Map(); + + register(descriptor: AnyCodecDescriptor): void { + this.descriptors.set(descriptor.codecId, descriptor); + } + + descriptorFor(codecId: string): AnyCodecDescriptor | undefined { + return this.descriptors.get(codecId); + } +} +``` + +`CodecDescriptor

` is invariant in `P` (per Q-3c: `factory` and `renderOutputType` use `P` contravariantly), so `CodecDescriptor` is **not** assignable from concrete subclasses' `CodecDescriptor` — the `` shape would force `as` casts at every register / retrieve boundary, violating AC-CB-5 below. `AnyCodecDescriptor` is the only erasure form that admits cast-free heterogeneous storage. Runtime consumers of the registry call `descriptor.factory(validatedParams)(ctx)` to materialize codec instances; the abstract `factory()` signature (returning `Codec`) is sufficient. No type information is needed at the runtime layer. + +Per-codec helpers don't pass through the registry — they're imported directly by extension authors and column-defining sites. The registry exists for runtime lookup (by `codecId` string), where types are already erased. + +## Why classes work for this design + +The class hierarchy isn't load-bearing for variance preservation (per-codec helpers' direct calls do that work). It's load-bearing for **structure**: declaring the descriptor + codec pair with one inheritable identity, holding the descriptor reference in the codec instance, and giving aliases a natural extension shape. + +Two specific reasons the class form is preferable to a record-based descriptor: + +### 1. Codec instance ↔ descriptor reference is structural + +The abstract `Codec` constructor takes a `descriptor: CodecDescriptor`; concrete codec subclasses pass it via `super(descriptor)`. `codec.id` and `codec.traits` proxy through this reference. Aliases work for free: an alias descriptor produces a codec instance whose `descriptor` points to the alias, so `codec.id` reports the alias's `codecId` automatically. + +The record-based equivalent requires every codec author to thread the descriptor reference through an object-literal constructor parameter. Workable, but error-prone — and identical structure has to be repeated at every codec definition site. + +### 2. Subclass-based authoring is uniform across the codec spectrum + +Non-parameterized, parameterized, schema-typed, alias — all four shapes are expressed as `class X extends CodecDescriptor<...>` with overrides on the abstract members. The variance behavior is identical across all four: the per-codec helper handles literal preservation via direct calls; the descriptor class declares the shape. + +The record-based equivalent has subtly different mechanics for each case (records vs records-of-functions vs branded literal types vs spread-and-override aliases). Authoring overhead scales worse. + +## Acceptance criteria + +The goal spec's AC-1 through AC-7 apply unchanged. This implementation spec adds class-based-design-specific ACs. + +### AC-CB-1. Class hierarchy declarations + +- `CodecDescriptor` is an exported abstract base class from `@prisma-next/framework-components/codec`. +- `Codec` is an exported abstract base class from the same package. +- Both replace today's interface-shaped declarations. +- Legacy interfaces (if they survive at all) are kept only as deprecated aliases for type-only consumption during the transition; deletion is acceptable per AC-7 (validation gates green). + +### AC-CB-2. Per-codec helper preserves method generics through direct invocation + +For each parameterized codec demonstrated in the spike: +- `descriptor.factory(specificParams)` types as `(ctx) => SpecificCodec` at any direct call site (verified at HEAD; this is baseline TS behavior, not novel). +- The per-codec helper (e.g. `vector(1536)`) returns a column spec whose `codecFactory` types as `(ctx) => SpecificCodec` with literals preserved. +- `ResolvedCodec` projects to `SpecificCodec` with literals preserved. + +**Verification.** Negative type tests in `*.test-d.ts` files for at least: +- `pgVectorDescriptor.factory({ length: 1536 })` → `(ctx) => VectorCodec<1536>` (baseline confirmation). +- `vector(1536)` → `ColumnSpec, { length: 1536 }>`. +- `arktypeJsonDescriptor.factory({ schema: testSchema })` → `(ctx) => ArktypeJsonCodec` (baseline). +- `arktypeJson(testSchema)` → `ColumnSpec, ...>`. +- Negative test: `ResolvedCodec` is NOT assignable to `VectorCodec<999>`. + +### AC-CB-3. Per-codec helper conforms via `satisfies` + +For each per-codec helper in the spike: +- The helper has a `satisfies ColumnHelperFor` (or `ColumnHelperForStrict`) clause referencing its descriptor's class. +- A negative type test demonstrates that a malformed helper (wrong typeParams shape, or wrong descriptor's factory wired in for the strict form) fails to satisfy the clause — verified via `// @ts-expect-error` directive. + +### AC-CB-4. Codec instance descriptor reference + +- Every concrete `Codec` subclass in the spike receives a `descriptor` constructor argument and passes it to the abstract base's constructor. +- `codec.id` and `codec.traits` proxy through `this.descriptor.codecId` / `this.descriptor.traits` (no instance-level fields). +- A round-trip test confirms: `pgVectorDescriptor.factory(params)(ctx).id === pgVectorDescriptor.codecId`. + +### AC-CB-5. Heterogeneous registry stores type-erased descriptors + +- The registry signature uses `AnyCodecDescriptor` (the `CodecDescriptor` alias defined in `framework-components/shared/codec-descriptor.ts`) per Q-3c. **Do not** use `CodecDescriptor` — it is not assignable from concrete `CodecDescriptor` subclasses because `CodecDescriptor

` is invariant in `P`. +- A test demonstrates: registering concrete descriptors, retrieving by codec id, calling `descriptor.factory(params)(ctx)` to materialize codec instances. **No `as` casts at the registry's storage / retrieval boundary.** If a test uses `as CodecDescriptor` (or any equivalent), that's a violation of this AC and a signal that `AnyCodecDescriptor` should be used instead. + +### AC-CB-6. Spike scope demonstrated end-to-end + +- The spike scratch branch demonstrates the full data flow for at least one parameterized codec: + 1. Codec author writes `PgVectorDescriptor`, `VectorCodec`, and `vector(N)` helper. + 2. Column author calls `vector(1536)` and gets back `ColumnSpec, { length: 1536 }>`. + 3. Contract definition aggregates the column spec; `typeof contract` carries the typed codec. + 4. A no-emit consumer (test fixture mimicking `FieldOutputType`) projects the typed codec from the contract type and resolves to `Vector<1536>`. +- The spike does **not** reshape the runtime contributor protocol, the contributor-pack registration flow, or the contract-load-time materialization machinery beyond what's needed for the demo. + +## Open questions to resolve in the spike + +These questions don't block the spike from starting; they get answered as part of the spike's findings. + +### Q-1. Class generic on `Codec` vs phantom types + +The current design parameterizes `Codec` positionally with concrete-instance-level types. An alternative: `Codec>` where `Id`, `TTraits`, `TWire`, `TInput` are derived from the descriptor type. Trade-off: tighter coupling but fewer type parameters at codec subclass declaration sites. + +The spike picks one. Recommendation pending: probably the positional form (current design) for clarity; the descriptor-derived form may be useful as a convention. + +### Q-2. Where do `column()` and `ColumnHelperFor` live? + +**Resolved by spike** ([`wip/class-based-codec-spike.md`](../../../wip/class-based-codec-spike.md) § Q-A): **layer 1 (`framework-components`), structurally compatible with `ColumnTypeDescriptor`**. + +Importing `ColumnTypeDescriptor` from `@prisma-next/contract-authoring` (layer 2) into `framework-components` (layer 1) would violate layering and trip `pnpm lint:deps`. Resolution: inline a structural mirror (`ColumnTypeDescriptorShape`) inside `column-spec.ts` and expose a type-level sanity check (`_ColumnSpecIsColumnTypeDescriptorCompatible`) verifying `ColumnSpec` remains assignable to `ColumnTypeDescriptor` at consumer sites without an explicit `extends`. If `column()` later moves to a layer-2+ package, this becomes a real `extends`. + +### Q-3. `paramsSchema` in the abstract class — required or optional? + +The current declaration has it `abstract readonly paramsSchema: StandardSchemaV1`. For non-parameterized codecs (`TParams = void`), authors write `readonly paramsSchema = voidParamsSchema`. Acceptable; the alternative is making it optional and providing a default. The spike picks one. + +### Q-3b. typeParams readonness convention + +**Resolved by spike** ([`wip/class-based-codec-spike.md`](../../../wip/class-based-codec-spike.md) § Q-B): **non-readonly typeParams literal in helpers; readonly in the descriptor's factory params type.** + +The descriptor declares `factory(params: { readonly length: N })`; the per-codec helper writes `column(... , { length })` (non-readonly literal). TS treats them as bidirectionally assignable in property-position matches, so the asymmetry is harmless. We do **not** force `Readonly

` at the `ColumnSpec` boundary — leaving the helper's literal non-readonly keeps the consumer-facing type inspection (`embeddingColumn.typeParams.length`) from being needlessly ceremonious. + +### Q-3c. `Codec` constructor argument variance + +**Resolved by spike** ([`wip/class-based-codec-spike.md`](../../../wip/class-based-codec-spike.md) § Q-C): **`CodecDescriptor` (with biome-ignore) is canonical.** + +`CodecDescriptor

` is invariant in `P` (the `factory` and `renderOutputType` slots use `P` contravariantly), so concrete subclasses do not extend `CodecDescriptor`. The codebase's prevailing convention is to type variance-erased descriptor parameters as `CodecDescriptor` with a `// biome-ignore lint/suspicious/noExplicitAny: variance erasure …` comment (matches the existing `AnyCodecDescriptor` alias in `codec-types.ts`). The class-based design follows the same convention everywhere a heterogeneous-storage or variance-erased boundary surfaces (the abstract `Codec` constructor's descriptor parameter, `ColumnHelperFor>`, registry storage type, etc.). Concrete codec subclasses retain typed access through their own state (the descriptor reference is typed at the subclass's `super(descriptor)` site). + +### Q-4. Does aliasing keep its first-class form? + +Per the goal spec's non-goals, deletion is acceptable. If kept, the natural class-based pattern is class extension: + +```typescript +class PgCharDescriptor extends SqlCharDescriptor { + readonly codecId = 'pg/char@1' as const; + readonly targetTypes = ['character']; +} +``` + +The codec instance produced by `pgCharDescriptor.factory()` returns a `SqlCharCodec` whose `descriptor` reference points to the `pgCharDescriptor` instance — `codec.id` reports `'pg/char@1'` automatically. The per-codec helper is similarly aliased: `pgChar = (length) => column(pgCharDescriptor.factory({length}), pgCharDescriptor.codecId, {length})`. + +The spike includes one alias example to verify this works. + +### Q-5. JSON validators registry retirement + +The goal spec preserves `paramsSchema`; today there's also a `JsonSchemaValidatorRegistry` (per ADR 208's per-library JSON design). The class-based design's natural shape: validation lives inside the codec instance's `decode` body (already the case for `arktypeJson` per ADR 208). The registry retirement is tracked under TML-2357 M4 and is independent of this spike. + +### Q-5b. `override` keyword discipline + +**Resolved by spike** ([`wip/class-based-codec-spike.md`](../../../wip/class-based-codec-spike.md) § Q-D): authors must write `override` on every concrete-subclass member that overrides an abstract or default member of the base class. + +The workspace's `noImplicitOverride` setting requires this for `factory`, `meta`, `renderOutputType`, and any other inherited member touched in a subclass. TS catches missing-`override` mistakes, which is the point — but it's worth flagging in author docs that `override factory(...)` (not `factory(...)`) is the correct shape. + +### Q-5c. Where do cross-codec / heterogeneous-registry tests live? + +**Resolved by spike** ([`wip/class-based-codec-spike.md`](../../../wip/class-based-codec-spike.md) § Q-E): **`packages/0-config/test-utils` (or a dedicated test fixture package), not the codec packages themselves.** + +The spike's heterogeneous-registry test wanted access to both `pgVectorDescriptor` (extension) and `pgInt4Descriptor` (target). Pulling cross-extension devDeps into pgvector or postgres tests felt heavy; the spike worked around it by defining a tiny inline non-parameterized codec inside the pgvector test. For full M0, the registry / cross-codec integration tests should live in a fixture package that has clean access to multiple descriptors without forcing each codec package to devDep its peers. + +### Q-6. Async constructors for codec instances? + +Some codec instances might need async setup (e.g. an encryption codec deriving keys at materialization time). Today's `factory(params)(ctx) => Codec` returns a sync `Codec`. The class form: codec instance constructors are sync in TS; async setup would require `factory` to return `Promise` or for the codec itself to expose an `async ready()` method. + +Out of scope for the spike; the spike codecs are all sync-constructible. + +## Spike scope + +The spike's deliverable is a scratch branch (off the current project branch's `efc0a988c` or its successor), demonstrating the class-based design end-to-end for **one parameterized codec** plus **one non-parameterized codec** plus **per-codec helpers + `satisfies` clauses**. + +### What the spike implements + +In a scratch branch, no production-quality migration: + +1. **`framework-components/src/shared/codec-descriptor.ts`** — new. Abstract `CodecDescriptor` class. +2. **`framework-components/src/shared/codec.ts`** — new. Abstract `Codec` class. +3. **`framework-components/src/shared/column.ts`** — new (or in another package as Q-2 decides). Trivial `column(codecFactory, codecId, typeParams)` packager. `ColumnHelperFor` and `ColumnHelperForStrict` shape exports. +4. **`extension-pgvector/src/core/codecs.ts`** — reshape pgvector's `PgVectorDescriptor` and `VectorCodec` into class form. Add the `vector(N)` per-codec helper with `satisfies ColumnHelperForStrict`. Keep one example of the legacy descriptor form alongside if helpful for diffing. +5. **`target-postgres/src/core/codecs.ts`** — reshape one non-parameterized codec (e.g. `pgInt4`) into class form. Add the `int4()` per-codec helper. +6. **`extension-pgvector/test/spike-class-based.types.test-d.ts`** (new) — negative type tests covering AC-CB-2 and AC-CB-3: + - `vector(1536)` → `ColumnSpec, { length: 1536 }>` + - `ResolvedCodec` → `VectorCodec<1536>` (and NOT `VectorCodec<999>`) + - `vector satisfies ColumnHelperForStrict` ✅ + - Malformed helper variants fail `satisfies` (with `// @ts-expect-error` directives) +7. **`extension-pgvector/test/spike-class-based.test.ts`** (new) — runtime test covering AC-CB-4: codec instance's `descriptor` reference; codecId proxying; encode/decode round-trip on a sample vector. +8. **A fixture demo** under `examples/` or in tests showing the full flow for one column. + +### What the spike does NOT do + +- Migrate other codecs (postgres, sqlite, sql-family, mongo). These are post-spike implementation work. +- Touch the contributor protocol or the contributor-pack registration flow. +- Change `contract.d.ts` emission. The spike demonstrates the no-emit type derivation; emit-path verification is a post-spike concern. +- Update consumers (sql-builder, sql-orm-client, contract-ts). The spike only proves the class-hierarchy + per-codec helper shape works. +- Resolve TML-2393's `byScalar` cleanup. That's part of M0 of the parent project's existing scope. + +### Spike deliverables + +- Scratch branch `spike/class-based-codecs` (off the project branch). +- Spike report at `wip/class-based-codec-spike.md` summarizing findings, including: + - Did AC-CB-1 through AC-CB-6 pass? + - Did the per-codec helper + `satisfies` discipline preserve literals end-to-end as the playground proof predicted? + - What unexpected friction surfaced? + - What's the projected diff cost of full M0 implementation under this design (per-codec helper authoring overhead, satisfies discipline, etc.)? + - Recommendation: proceed with class-based + per-codec-helper or fall back to functional? + +The spike's report informs the next decision: whether to commit to the class-based approach for the project or refine further. + +## Risks + +### Per-codec helper boilerplate + +Each parameterized codec ships a small per-codec helper function (~5 lines). For ~22 codecs in postgres + ~10 in sqlite + a few in extensions, that's ~40 helpers. Modest but real. Mitigation: codec authors who don't need ergonomic surface tweaks can use a single-line passthrough form; only authors needing custom surfaces (defaults, derived params, positional vs object-arg) write more. + +### `satisfies` not catching literal-level mismatches + +`ColumnHelperForStrict` checks the helper's return is `ColumnSpec, ...>`. `ReturnType` widens method generics, so a helper that accidentally widens its own generic (e.g. `(length: N)` becoming `(length: number)`) still satisfies the clause — but the column spec loses the literal. Mitigation: the `*.test-d.ts` negative tests in AC-CB-2 cover this; a helper that widens fails the literal-preservation test. + +### Codec instance class proliferation + +Today's codecs are object literals; the class form requires a class declaration per codec. For postgres alone, ~22 codec class declarations + ~22 descriptor class declarations + ~22 helper functions = ~66 codec-related artifacts. Not technically problematic but visually heavier than today's object-literal codecs. + +Mitigation: a `defineSimpleCodec` helper that produces a concrete codec class from `{ encode, decode }` functions. Authors who don't need class-level state (the common case) write the helper-based form; only stateful codecs (e.g. arktype-json with its schema) write full class declarations. + +### `super()` discipline in the codec abstract base + +Codec subclasses must call `super(descriptor)` in their constructors. If an author forgets, TypeScript catches it (the abstract `Codec`'s constructor parameter is required). But it's one more thing to remember. Mitigation: `defineSimpleCodec` handles the `super()` call. + +### Async / sync codec divergence + +Per ADR 204, codec encode/decode are async. Codec instance construction is sync (TS class constructor limitation). For codecs that need async setup, the class form requires a `static async create()` factory pattern or an async `ready()` method. None of today's codecs need this; flagged as a future consideration. + +### Performance of class instantiation per column + +The current factory pattern returns a shared codec instance for non-parameterized codecs — same instance for every column. The class-based design keeps this property: `factory()(ctx) => new PgInt4Codec(this)` could be optimized to return a cached singleton: + +```typescript +class PgInt4Descriptor extends CodecDescriptor { + private cachedCodec?: PgInt4Codec; + factory(): (ctx) => PgInt4Codec { + return (ctx) => { + this.cachedCodec ??= new PgInt4Codec(this); + return this.cachedCodec; + }; + } +} +``` + +For parameterized codecs, the per-column instance is the design — each column gets a codec instance closing over its specific params. No regression vs. today. + +## Non-goals + +- **Polymorphic column helper.** Falsified by the playground proof. Out of scope. +- **Functional approach (Approach 1).** Out of scope. If the class-based spike fails, the functional fallback re-enters consideration. +- **Full codec migration across the codebase.** The spike reshapes one or two codecs only; full migration is post-spike implementation work. +- **Contributor protocol changes.** The spike doesn't touch how codecs register with the framework; it only shows that the class form satisfies the existing protocol's shape requirements. +- **`Codec.id` field elimination across the codebase.** The codec instance's `id` field becomes a getter proxying to the descriptor; consumers that today read `codec.id` continue to work without change. Whether to delete the field entirely (forcing all consumers through `codec.descriptor.codecId`) is a separate cleanup. +- **`paramsSchema`'s relationship to the factory's TS input type.** Could in principle be derived (the schema's parsed output type assignable to factory's input type); the spike treats them as separate artifacts that authors keep aligned, with a separate ticket / cleanup if mechanical derivation is desirable later. + +## References + +- [`factory-defined-codec-types.spec.md`](factory-defined-codec-types.spec.md). The goal spec this implementation approach satisfies. +- [`typed-codec-flow.spec.md`](typed-codec-flow.spec.md). The M0 sub-spec under the parent project; subsumed by the goal spec. +- [Parent spec `spec.md`](../spec.md). The `codec-registration-completion` canonical project spec. +- [ADR 208 — Higher-order codecs for parameterized types](../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). The ADR partially superseded by the goal spec. +- [`wip/m0-class-variance-proof.md`](../../../wip/m0-class-variance-proof.md). The TS playground proof that falsified the polymorphic-column-helper approach and informed the per-codec-helper design. +- [`wip/codec-class-variance-proof/`](../../../wip/codec-class-variance-proof/). The proof's supporting playground files (gitignored). +- [`wip/class-based-codec-spike.md`](../../../wip/class-based-codec-spike.md). The Pattern E spike report (six ACs validated end-to-end on the `spike/class-based-codecs` branch). Captured TS error messages from negative tests, friction items, and resolved spec questions (Q-A..E). +- [`wip/unattended-decisions.md` Decision #11](../../../wip/unattended-decisions.md). The variance failure that surfaced this design space. +- `wip/m0-shape-spike.md`. Shape A vs Shape B (functional Mode B) findings. diff --git a/projects/codec-registration-completion/specs/factory-defined-codec-types.spec.md b/projects/codec-registration-completion/specs/factory-defined-codec-types.spec.md new file mode 100644 index 0000000000..ab6b293530 --- /dev/null +++ b/projects/codec-registration-completion/specs/factory-defined-codec-types.spec.md @@ -0,0 +1,263 @@ +# Factory-defined codec types (Mode C) + +## Status + +**Goal-level spec.** Describes the design target only — the *what* and *why*. Implementation approaches (functional `defineCodec`-style, abstract-class-based, or other) are deliberately out of scope; each is its own follow-up spec + spike. + +This spec is written during the [TML-2357](https://linear.app/prisma-company/issue/TML-2357) `codec-registration-completion` project but represents a **substantive design pivot** that supersedes part of [ADR 208](../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). Adoption requires either re-scoping the parent project around this goal or splitting it into a dedicated project; that scoping decision is itself out of scope here. + +## Decision + +The `CodecDescriptor`'s factory function is the **single type-level source of truth** for the codec instance's output type. The factory's input-parameters type, applied at the type level, determines the resulting `Codec` instance's type parameters (`Id`, `TTraits`, `TWire`, `TInput`, etc.). + +A codec is one artifact with two roles bundled together by its factory: +- **Runtime role**: `factory(params)(ctx)` materializes the runtime `Codec` instance. +- **Type-level role**: applying the factory's *type* with column-specific params yields the codec's TypeScript type at consumer sites. + +Both roles are projections of the same function. The factory is not declarative metadata coordinated with separate type-level rendering machinery — it *is* the type-level rendering machinery. + +Consumers that need a codec's type for a specific column (no-emit `FieldOutputType`, `sql-builder`'s parameter-typing, the ORM's row-shape derivation) read it from **the column spec on the contract**, which the column helper populated at authoring time via descriptor-factory application. The descriptor itself is never queried by these consumers; the column spec carries the typed codec (or enough information to project it) from authoring time onward. + +Codec authors maintain exactly one type-level surface: the descriptor's factory function (or its class-based equivalent). No parallel `OutputType` HKT field. No hand-written codec-id-keyed type rows. No column-helper `type` slot maintained alongside the factory. The framework's emitted `contract.d.ts` carries codec type rows because emit serializes the type-level result into a static artifact — but those rows are *generated* from the descriptors, not maintained in parallel. + +## Data flow + +The architecture has one source artifact (the descriptor's factory) and four lifecycle phases that consult it. Every phase consults it at most once; nothing is computed twice or maintained in parallel. + +``` +codec author site descriptor.factory(params) → (ctx) → Codec<...> + ↓ one type-level surface + ↓ +column author site column(descriptor, params) + │ helper applies descriptor.factory at the type level + │ helper stores typed result on the column spec + ↓ +contract definition contract carries column specs with their typed codecs + │ flows through the rest of the type system + ↓ +no-emit consumers FieldOutputType / sql-builder / ORM read the typed + codec from the column spec; descriptor not consulted + +emit emitter walks descriptors at emit time, evaluates the + factory at the type level, serializes into contract.d.ts + (verified byte-equivalent to the no-emit type per AC-6) + +runtime descriptorFor(codecId): AnyCodecDescriptor (type-erased) + materialization via descriptor.factory(params)(ctx) +``` + +Three patterns fall out of this flow: + +1. **Source-of-truth singularity.** The descriptor's factory is the *only* artifact a codec author writes that carries type information. `paramsSchema` and `renderOutputType` are runtime artifacts (validators, string renderers); they don't carry codec types. Column helpers don't hand-roll typed returns; they delegate to the descriptor's factory at the type level. + +2. **Two-layer storage separation.** The descriptor is stored twice with different access shapes: + - **At the runtime layer**, the framework's heterogeneous registry indexes by `codecId: string` and returns `AnyCodecDescriptor` (variance-erased, correctly so — the runtime needs no types). + - **At the type-level layer**, the column spec on the contract is the only access path. It was populated at column-author time by applying the descriptor's factory at the type level; consumers read from there. + + These two paths never cross. Type-level consumers don't query the runtime registry; runtime consumers don't query the contract type. + +3. **Emit as serialization, not as parallel.** The emitter walks descriptors, evaluates the factory at the type level (within the type-checker), and serializes the result into `contract.d.ts`. This is descriptor-derived; AC-6 verifies byte-equivalence with the no-emit type to pin the agreement. + +The implementation question of *what* the column spec carries — a resolved codec instance, an unapplied factory thunk, a phantom-type marker, or another shape — is deliberately left to the per-approach implementation specs. The AC-5 verification points pin the type-level outcome regardless of representation. + +## Why + +ADR 208 diagnosed the problem correctly (line 145): + +> Both problems share a root cause: the type-level facts about a parameterized column lived in three places (the column-helper factory, the codec record, the renderer) with no single source of truth. + +ADR 208's prescribed cure was column-helper-first: the column helper (`vector(N)`, `arktypeJson(schema)`) carries the typed factory; the descriptor is auxiliary. Three reasons that cure isn't right: + +1. **It picks the wrong actor as the source of truth.** The column helper is a per-codec construction site for column descriptors. The codec is what actually owns the type-level transformation from inputs to outputs. Making the column helper authoritative pushes the codec's own type-level information to a layer above the codec — every parameterized codec must hand-roll a column helper that mirrors the runtime factory's types, and the two must be kept in sync. The codec descriptor's factory becomes an under-typed twin of the column helper. + +2. **It doesn't actually unify; it just relocates the multiplicity.** ADR 208 still ships separate `paramsSchema`, `renderOutputType`, and (in arktype-json's case) the column descriptor's `type` slot. The user-facing API is one function call, but the internal architecture remains: column helper + codec descriptor + renderer + Standard Schema validator + (sometimes) the column `type` slot. Five artifacts that must agree per parameterized codec. + +3. **It wasn't fully wired up.** At HEAD, `FieldOutputType` reads from `CodecTypesFromDefinition[codecId]['output']` — a static codec-id-keyed lookup, indexed by codec id, not by params. `vector(1536)` resolves to `number[]`, not `Vector<1536>`. The `type` slot mechanism described in ADR 208 § 2 exists only on `arktypeJson` and has no consumer. The unification is documented, not implemented. + +This spec relocates the source of truth to where the codec actually owns it: **the codec descriptor's factory function**. Column helpers become derivatives — and likely collapsable into a single generic helper parameterised by the descriptor. + +## Cases that pin the design + +A correct implementation of this spec must accommodate every case below. Each case anchors specific acceptance criteria. + +### Case 1 — Non-parameterized codec (degenerate case) + +`pgInt4Descriptor` is non-parameterized: its factory takes `void`, its codec instance type is fixed at `Codec<'pg/int4@1', readonly ['equality','order','numeric'], number, number>`. + +Applying the factory's type (`factory(undefined)`) at the type level produces the fixed codec instance type. This case must continue to work without ceremony — the degenerate case is the common case. + +### Case 2 — Parameterized codec with literal preservation + +`pgVectorDescriptor`'s factory is `(params: { length: N }) => (ctx) => Codec<'pg/vector@1', readonly ['equality'], string, Vector>` (or equivalent expressed through the chosen implementation approach). + +Applying with `params: { length: 1536 }` (literal) yields `Codec<..., Vector<1536>>` (literal preserved). Applying with `params: { length: number }` (widened) yields `Codec<..., Vector>` (acceptable widened form). + +This is the case that fails today: `vector(1536)` resolves to `number[]`, not `Vector<1536>`. + +### Case 3 — Parameterized codec with arktype schema + +`arktypeJsonDescriptor`'s factory is `>(params: { schema: S }) => (ctx) => Codec<'arktype/json@1', readonly ['equality'], string, S['infer']>`. + +Applying with a specific schema yields a codec typed by that schema's inferred output. This is the load case for *non-numeric* literal preservation — the typed shape is a derived TypeScript type rather than a literal value. + +### Case 4 — Heterogeneous descriptor storage (runtime-only; type-erased) + +The framework stores all registered descriptors in a runtime registry keyed by codec id (`descriptorFor(codecId): AnyCodecDescriptor`, `forColumn(table, column): Codec` per ADR 208). The registry's role is runtime materialization: encode/decode dispatch, contract-load-time codec instantiation, validation. **It needs no type information at all.** Consumers querying the registry receive variance-erased `AnyCodecDescriptor` and that is correct: the registry is a `Record` indexed by codec id, and TypeScript variance correctly erases the per-descriptor factory generics at this boundary. + +Type-level access does not flow through the registry. It flows through the **column spec on the contract**, populated by column helpers at column-author time via descriptor-factory application. There is no `Descriptors` projection, no per-pack typed map consulted at the type level, and no direct-reference-to-descriptor pattern in framework code (direct references are fine in tests; framework code cannot reach into specific codec implementations). + +The implementation accommodates this two-layer separation: heterogeneous, type-erased registry at the runtime layer; column-spec-on-contract carrying typed codec information at the type-level layer. + +### Case 5 — `FieldOutputType` derivation (column-spec-driven) + +For a column declared as `column(pgVectorDescriptor, { length: 1536 })` (or via a thin per-codec wrapper like `vector(1536)` that delegates to `column`), the column helper applies the descriptor's factory at the type level with the column's params and stores the result on the column spec. The no-emit `FieldOutputType` resolver reads the typed codec from the column spec and projects its `TInput` (or equivalently, the resolved codec's `decode` return type). + +There is no descriptor lookup at the resolver site; the column has what it needs. This replaces the static `CodecTypesFromDefinition[codecId]['output']` lookup HEAD uses today. + +### Case 6 — Column helper collapse + +A column helper today (e.g. `vector(N)`, `arktypeJson(schema)`) ships per-codec, with a hand-rolled typed return. Under this spec, a generic helper exists that works for any descriptor: + +```typescript +function column>( + descriptor: D, + params: P, +): ColumnTypeDescriptor & { readonly codecId: D['codecId']; readonly typeParams: P } +``` + +— or the equivalent expressed through the chosen implementation approach. Per-codec column helpers become trivial wrappers (`vector = (length) => column(pgVectorDescriptor, { length })`) and may be eliminable entirely. + +### Case 7 — Emit-path rendering + +The framework emitter walks each column and renders its TypeScript type into `contract.d.ts`. At emit time, TS-type-level resolution is not available — the emitter operates on contract IR data. The descriptor must therefore expose a runtime-callable rendering function (today's `renderOutputType: (params) => string`). Under this spec, the emit-path renderer is **the single legitimate type-level/runtime parallel** in the design — and it remains as a separate slot on the descriptor because the emit pass cannot inspect TS types at runtime. + +The acceptance criterion is that this renderer's output **agrees with the no-emit type** at every column. Tests pin this agreement (the contract.d.ts emitted for `vector(1536)` is `Vector<1536>`; the no-emit `FieldOutputType` for the same column is also `Vector<1536>`). + +### Case 8 — Validators (`paramsSchema`, JSON-schema validation) + +The descriptor's `paramsSchema` validates the params at the JSON boundary (contract-load time) — runtime concern, no type-level role beyond constraining the factory's input type. JSON-schema validation per ADR 208 lives inside the resolved codec's `decode` body. Both stay where ADR 208 placed them. + +## Acceptance criteria + +A correct implementation satisfies every AC below. Implementation-approach-specific ACs (e.g. "the abstract class is named `CodecDescriptor`") belong in the per-approach spike spec, not here. + +### AC-1. Factory's typed shape preserved + +The `CodecDescriptor`'s factory function (or equivalent in the chosen implementation) preserves its full TypeScript signature: input-parameter types, output-codec types, and any per-codec generic parameters (e.g. `` for `vector`). The mechanism that constructs descriptors (`defineCodec`, an abstract base class, or other) does not strip generics from the factory's declared signature. + +**Verification.** Constructive type test: a parameterized descriptor's `descriptor.factory<{ length: 1536 }>` resolves at the type level to a function returning `Codec<..., Vector<1536>>`. + +### AC-2. No parallel hand-maintained type mechanism + +Codec authors write exactly one type-level surface per codec: the descriptor's factory function (or its class-based equivalent). No parallel `OutputType` HKT field, no hand-written codec-id-keyed type rows alongside the descriptor, no column-helper `type` slot operating outside this flow. + +Type derivations needed by the framework compute *from* the descriptor's factory: +- **No-emit path**: column helpers apply the factory at the type level at column-author time; the result lives on the column spec; consumers read it from there. +- **Emit path**: the emitter walks descriptors at emit time, evaluates the factory at the type level, and serializes the result into `contract.d.ts`'s codec type rows (today's `CodecTypes` / `TypeMaps`). The emitted rows are not a parallel surface — they are the descriptor-derived serialization. AC-6 covers byte-equivalence with the no-emit type. + +**Verification.** Audit: every parameterized codec author writes one type-level artifact (the factory). Removing the source-level `CodecTypesFromDefinition` (or successor) does not break the no-emit type derivation — that path runs through column specs. + +### AC-3. Literal preservation in the no-emit path + +`vector(1536)`'s declared column resolves to `Vector<1536>` at the type level (literal preserved). `arktypeJson(productSchema)`'s declared column resolves to the schema's inferred output. Tests cover both cases. + +**Verification.** `*.test-d.ts` constructive tests on `examples/prisma-next-demo` or an equivalent no-emit fixture. Both positive (correct types compile) and negative (wrong types fail) cases. + +### AC-4. Column helpers are derivatives or eliminated + +Per-codec column helpers (`vector(N)`, `arktypeJson(schema)`, `charColumn(N)`, …) either: +- Collapse into a single generic `column(descriptor, params)` helper, or +- Persist as thin wrappers that call the generic helper, contributing no type-level information beyond what the descriptor already provides. + +The codec descriptor's authoring site is the only place where per-codec type-level facts are encoded. + +**Verification.** No production column helper declares its return type via hand-rolled typed factory return. All column helpers are either trivial calls into a generic helper or eliminated entirely. + +### AC-5. Type-level access via column spec, not via descriptor map + +The type-level entry point for a codec's type is the **column spec on the contract**. The column helper, at column-author time, applies the descriptor's factory at the type level with the column's params and stores the result on the column spec. Consumers (no-emit `FieldOutputType`, sql-builder parameter typing, ORM row derivation) read the typed codec from the column spec; they do not consult any descriptor map at the type level. + +Runtime descriptor storage remains heterogeneous (registered through the unified `codecs:` slot, indexed by `codecId: string`, returns `AnyCodecDescriptor`). The runtime path is variance-erased and consumes the descriptor for materialization (`descriptor.factory(params)(ctx)`); no type information is required at the runtime layer. + +There is no `Descriptors` projection, no per-pack typed map consulted at the type level, and no direct-reference-to-descriptor pattern in framework code. The column spec is the only type-level path; it carries everything consumers need. + +**Verification.** Constructive type tests: +- At the column-author site: `column(pgVectorDescriptor, { length: 1536 })`'s return type carries the typed codec instance (or equivalent typed surface — a factory thunk whose return type resolves to the typed codec also satisfies the AC). +- At the contract-type level: walking from `typeof contract` through `models[name].fields[name]` to the column spec recovers the typed codec for that field. +- At the consumer level: `FieldOutputType` resolves to `Vector<1536>` for a column declared as `column(pgVectorDescriptor, { length: 1536 })`. + +### AC-6. Emit-path renderer agrees with no-emit type + +For every parameterized codec and every distinct param shape, the emitted `contract.d.ts` type and the no-emit `FieldOutputType` type are byte-equivalent (after canonicalization). The descriptor's emit-path renderer remains as the only descriptor slot that runtime-renders strings; its output is verified to agree with the type-level derivation. + +**Verification.** A test fixture with `vector(1536)`, `arktypeJson(productSchema)`, and a non-parameterized column. Assert that `contract.d.ts`'s emitted type for each column is byte-equivalent to the no-emit `FieldOutputType` for the same column. + +### AC-7. Validation gates green + +- `pnpm typecheck`, `pnpm lint:deps`, `pnpm fixtures:check`, `pnpm test:packages`, `pnpm test:e2e`, `pnpm build` all green at every commit boundary. +- No new type casts in production code. No `any`. No `@ts-expect-error` outside negative type tests. No `@ts-nocheck`. No biome suppressions. +- Demo emit byte-identical against the post-implementation baseline. + +## Non-goals + +- **Implementation approach.** Whether the descriptor is a function-returned object (today's `defineCodec`), an abstract base class (the next spike), a frozen interface with constructor pattern, or another shape — out of scope for this spec. Each approach gets its own spike spec under `projects/codec-registration-completion/specs/`. + +- **Mode A retrofit.** Wiring up the column-`type`-slot mechanism per ADR 208's intent. This spec supersedes that direction. + +- **`renderOutputType` removal / TS-compiler-API emit.** Eliminating the descriptor's emit-path renderer (e.g. by using TypeScript's compiler API to generate `contract.d.ts` from descriptor types) is not in scope. The renderer stays as one acknowledged runtime-side artifact; codec authors writing it is overhead, not damage. AC-6's byte-equivalence verification covers the practical risk. + +- **Codec reuse via descriptor inheritance / aliasing / sharing across targets.** Codecs are thin and re-declared at each authoring site. Shared logic lives in utility functions (`encodeVectorWire(value)`, `decodeVectorWire(wire)`); shared *descriptors* are pointless. The class-based implementation approach (next spike) may use class inheritance internally as an organizational pattern, but this is not load-bearing for the design — every codec id has exactly one descriptor declared at one site. + +- **`aliasDescriptor` as a first-class abstraction.** If kept at all, the implementation is a trivial runtime spread + `codecId` rewrite (or, under a class-based approach, a class-inheritance pattern that proxies `codecId` through a descriptor reference on the codec instance). Either way, aliasing is not a load-bearing case for this spec; deletion is acceptable. + +- **`paramsSchema` relocation or removal.** Stays on the descriptor as the runtime params validator (also consumed by PSL for incoming codec parameter validation per ADR 208). Its role is unchanged under this spec. + +- **Renaming.** Whether `CodecDescriptor` becomes `Codec`, `defineCodec` becomes `defineCodecDescriptor`, etc. — orthogonal naming concerns, decided per implementation approach. + +- **Mongo type flow.** Mongo's wire-dispatch path is reshaped under [TML-2324](https://linear.app/prisma-company/issue/TML-2324). This spec applies to the SQL family at minimum; Mongo alignment is a separate concern. + +- **Migration sequencing.** Whether this lands as a single squashed PR, a milestone sequence in the existing `codec-registration-completion` project, or a new project entirely — scoping decision, made when an implementation approach is selected. + +## Implementation approaches under consideration + +(Documenting briefly here for orientation; each approach gets its own follow-up spec.) + +### Approach 1 — Functional with full factory-generic preservation + +Today's `defineCodec(spec)` keeps its functional shape but its declared return preserves the factory's full generic signature. Consumers extract via structural inference: `D extends { factory: (...) => (...) => infer C } ? C : never`. Heterogeneous storage stays in `Record` at the runtime layer; type-level consumers access via typed maps. + +Pros: minimal authoring-site change for codec authors. Maps onto the existing codec ecosystem with the smallest reshape. Cons: TS variance challenges at the storage layer; structural inference patterns are verbose; the M2 R4 attempt already failed at this approach's variance boundary (see `wip/unattended-decisions.md` Decision #11). + +### Approach 2 — Abstract-class-based (the user's preferred experiment) + +`CodecDescriptor` is an abstract base class. Codec authors extend it and override `id`, `traits`, `targetTypes`, `paramsSchema`, `renderOutputType`, and `factory()`. The factory's return is a `Codec` instance — itself an abstract base class authors extend per codec. The class hierarchy expresses parameterization through TypeScript's natural class generics (`abstract class CodecDescriptor>`), which TS variance handles more cleanly than function-return inference. + +Pros: TypeScript's class-generic mechanics align well with the parameterization story; the relationship between descriptor and instance is explicit in the type system; column-helper collapse is mechanical (a generic helper accepts a descriptor class and instantiates per params). Cons: invasive reshape of every codec contributor; class-based authoring may feel heavier than functional; integration with existing functional patterns (Standard Schema, contributor protocol) requires care. + +### Approach 3 — Other + +Open. Examples: branded types + factory-applied projection types; generic descriptor types that defer instantiation until column-binding; or hybrids of approaches 1 and 2. + +The class-based approach (Approach 2) is the next planned spike. + +## Relationship to in-flight work + +- **Parent project [`codec-registration-completion`](../spec.md).** Currently scoped to typed-flow fix (M0 per [`typed-codec-flow.spec.md`](typed-codec-flow.spec.md)) plus runtime/registration migrations (M1–M4). This spec is a generalization that subsumes M0 and re-scopes M1–M4. Adoption requires either re-scoping the project around this spec or splitting into a new project. Decision deferred to post-spike. + +- **[ADR 208](../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md).** Decision § paragraph picking the column helper as the type-level surface is partially superseded; the ADR 208 alternatives-considered HKT rejection (line 175) survives but is supplemented by this spec's factory-generic-preservation approach (a third option not weighed in ADR 208). + +- **[TML-2229](https://linear.app/prisma-company/issue/TML-2229).** The codec-registry-unification project that introduced `CodecDescriptor`. The unification is preserved; the type-level layering is restructured. + +- **[TML-2393](https://linear.app/prisma-company/issue/TML-2393).** The `byScalar` antipattern cleanup ticket. Absorbed into the parent project's M0 forcing-function deletion. Likely still applies in spirit under this spec — `byScalar`'s codec-instance map is exactly the kind of parallel structure this spec eliminates. + +- **[TML-2324](https://linear.app/prisma-company/issue/TML-2324).** Mongo cross-family work. Out of scope for this spec. + +## References + +- [ADR 208 — Higher-order codecs for parameterized types](../../../docs/architecture%20docs/adrs/ADR%20208%20-%20Higher-order%20codecs%20for%20parameterized%20types.md). The ADR this spec partially supersedes. +- [ADR 206 — Operations as TypeScript functions](../../../docs/architecture%20docs/adrs/ADR%20206%20-%20Operations%20as%20TypeScript%20functions.md). The "function is the signature" precedent. This spec applies the principle at the codec-descriptor layer; ADR 208 applied it at the column-helper layer. +- [ADR 184 — Codec-owned value serialization](../../../docs/architecture%20docs/adrs/ADR%20184%20-%20Codec-owned%20value%20serialization.md). The pattern of codecs owning their representations; this spec extends it to "codecs own their type-level representation too." +- [Parent spec `spec.md`](../spec.md) — `codec-registration-completion` canonical spec. +- [`typed-codec-flow.spec.md`](typed-codec-flow.spec.md) — M0 spec under the parent project; subsumed by this goal. +- [`wip/unattended-decisions.md` Decision #11](../../../wip/unattended-decisions.md) — the variance failure that exposed the multiple-actors complexity this spec addresses. +- `wip/m0-shape-spike.md` — Shape A vs Shape B spike under the parent project's M0 (Mode B); informs the type-level variance considerations relevant to Approach 1 here. diff --git a/projects/codec-registration-completion/specs/typed-codec-flow.spec.md b/projects/codec-registration-completion/specs/typed-codec-flow.spec.md new file mode 100644 index 0000000000..43132c38d7 --- /dev/null +++ b/projects/codec-registration-completion/specs/typed-codec-flow.spec.md @@ -0,0 +1,207 @@ +# Spec — Typed `Codec` flow through `CodecDescriptor` (TML-2357 prerequisite) + +> **Status: SUPERSEDED.** This spec was authored around the interface-form `defineCodec` redesign (Shape A vs Shape B). The Pattern E spike on `spike/class-based-codecs` proved a different design — class-based descriptors + per-codec helpers — better satisfies the same problem. The current source of truth is [`class-based-codec-design.spec.md`](class-based-codec-design.spec.md) (implementation) and [`factory-defined-codec-types.spec.md`](factory-defined-codec-types.spec.md) (goal). This file survives as historical context for the M2 R4 rollback and the design iteration that followed; do not implement against it. + +> **Pivotal precondition for the rest of TML-2357.** Surfaced during M2 R4 (see [`wip/unattended-decisions.md` Decision #11](../../../wip/unattended-decisions.md)) when deleting the legacy `mkCodec`-call-result typed instances broke `` derivation downstream. The bug is a TML-2229 regression that should have been caught before merge. **No part of TML-2357's downstream codec migration (AC-1, AC-2, AC-3, AC-7) can land cleanly without this fix first.** + +## Decision + +`defineCodec({...})` MUST return a descriptor type that preserves the four codec generics its `spec` argument inferred (`Id`, `TTraits`, `TWire`, `TInput`) in addition to `TParams`. The factory's return type IS the resolved codec's type; consumers of a descriptor record (e.g. `PgDescriptors`, `SqlDescriptors`, `PgvectorDescriptors`) recover the typed `Codec` directly from the descriptor's type — no `mkCodec`-call-result instance kept alive in parallel as a "type carrier." + +After this fix, the typed flow runs end-to-end through descriptors only: + +``` +defineCodec({...}) ── author site, codec generics inferred + → PgDescriptors / SqlDescriptors / ... ── per-package typed descriptor records + → defineContract({target, family, packs}, ..) ── contract-ts builder reads typed records + → field.uuidv4() / field.text() / ... ── typed field specs + → typeof contract ── carries per-column codec types + → sqlBuilder({context}) ── propagates types to query expressions + → sql.user.where((f, fns) => fns.eq(f.id, x)) ── x must be string for a uuid column +``` + +In the emit path (`pnpm emit`), the same descriptor records drive `contract.d.ts` text generation; the typed `CodecTypes`/`TypeMaps` entries in the emitted file are derived from the descriptor types. Both paths converge on descriptor-resident typed factories as the single source of truth for the typed-codec flow. + +## Why + +The codec-registry-unification work ([TML-2229](https://linear.app/prisma-company/issue/TML-2229)) introduced `defineCodec` / `CodecDescriptor` as the unified registration shape but kept `mkCodec`-produced typed `Codec` instances alive in parallel, accidentally serving as the source of typed flow into `CodecTypes`/`TypeMaps`. This conflated two concerns: + +1. **Codec registration** — a runtime/contributor-protocol concern, solved by descriptors flowing through the unified `codecs:` slot. +2. **Typed `Codec` flow into user code** — a TypeScript-type concern. Only relevant in (i) the no-emit authoring path (`field.uuidv4()` typing) and (ii) emit-path `contract.d.ts` generation (typed `CodecTypes` entries). Solved accidentally by keeping typed `mkCodec`-result instances alive. + +When TML-2357 M2 R4 attempted to delete the legacy typed instances (the `mkCodec` factory and its callers), the type flow collapsed because: + +- `defineCodec` declares its return as `CodecDescriptor` (see [`packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts:587-593`](../../../packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts) at HEAD), dropping the `Id`, `TTraits`, `TWire`, `TInput` parameters its `spec` argument inferred. +- Per-target descriptor records (`PgDescriptors`, `SqliteDescriptors`, `PgvectorDescriptors`) carry `CodecDescriptor` per descriptor, not the typed shape. +- `ReturnType>` collapses to base `Codec` because `D extends CodecDescriptor` widens `D` to the unparameterized interface declaration before extraction. + +Concrete failure shapes observed in M2 R4 ([`wip/unattended-decisions.md` Decision #11](../../../wip/unattended-decisions.md)): + +- `sql-builder/test/playground/select.test-d.ts`: `SqlQueryPlan` constraint `{table: never; ...}` failures because ``-parameterized types lost per-codec-id specificity. +- `sql-builder/test/runtime/builders.test.ts`: `CodecExpression<"pg/int4@1", boolean, ...>` — `boolean` (default fallback) where `number` (`TypeMaps['pg/int4@1'].input`) was required. +- `sqlite/test/codecs.test.ts`: `result` typed as `unknown` instead of `Date`. + +The variance failure was diagnosed as a real TS-type design problem, not an implementation bug. Two M2 R4 implementer subagents crashed iterating on workarounds; the orchestrator rolled back the entire commit chain to the M2 R3 HEAD (`a29e06245`). + +This bug should have been caught and fixed in TML-2229. TML-2357 was scoped as "downstream codec migration"; it cannot complete its scope without first landing the type-flow fix. + +## Cases that pin the design + +These four cases anchor the acceptance criteria. If any can't be expressed cleanly under the typed-flow design, the design is wrong. + +### Case U — Non-parameterized codec, no-emit query author types end-to-end + +**Setup.** `pgUuidv4Descriptor` is defined in `packages/3-targets/3-targets/postgres/src/core/codecs.ts` via `defineCodec({factory: () => () => buildSqlCodec({typeId: 'pg/uuid@1', encode: (s: string) => uuidParse(s), decode: (b: Buffer) => uuidStringify(b), traits: ['equality']})})`. A no-emit author writes `field.id.uuidv4()` in `prisma/contract.ts`. + +**Expected behavior.** `sql.user.where((f, fns) => fns.eq(f.id, '550e8400-e29b-41d4-a716-446655440000'))` typechecks. Substituting `1234` (number) fails to compile with a clear TS error pointing at the codec mismatch. + +**What this pins.** +- `defineCodec`'s return preserves all five codec generics inferred from `spec`. +- `PgDescriptors['uuidv4']`'s type is rich enough that downstream `field.uuidv4()` returns a typed field spec carrying `Codec<'pg/uuid@1', readonly ['equality'], Buffer, string>`. +- The contract-ts `field` proxy propagates the typed shape through `defineContract({...}, ({field}) => ...)`. + +### Case V — Parameterized codec (vector), typed flow under params validation + +**Setup.** `pgVectorDescriptor` is defined with `paramsSchema: lengthSchema`, `factory: ({length}) => (ctx) => buildSqlCodec({typeId: 'pg/vector@1', encode: (arr: number[]) => packVector(arr, length), decode: (b: Buffer) => unpackVector(b, length), traits: []})`. A no-emit author writes `type.pgvector.Vector(1536)`, then a column references it. + +**Expected behavior.** The column's effective codec type is `Codec<'pg/vector@1', readonly [], Buffer, number[]>`. Query expressions like `vectorCol.eq([1.2, 3.4, ...])` typecheck; passing `'string'` fails. + +**What this pins.** +- Parameterized descriptors preserve typed codec generics through `paramsSchema` validation. +- The typed codec at the materialization site (`descriptor.factory(params)(ctx)`) carries the same generics as the factory's declaration. + +### Case E — Emit-path `contract.d.ts` typed `TypeMaps` derivation + +**Setup.** `pnpm emit` runs against `examples/prisma-next-demo`. The emitter walks the descriptors registered through the unified `codecs:` slot, derives per-codec-id `{input, output, traits}` shapes, and writes them into `contract.d.ts`'s `TypeMaps` projection. + +**Expected behavior.** Generated `TypeMaps` projection has correct per-codec-id shapes: +```typescript +type TypeMaps = { + 'pg/int4@1': { input: number; output: number; traits: ... }; + 'pg/uuid@1': { input: string; output: string; traits: 'equality' }; + 'pg/vector@1':{ input: number[]; output: number[]; traits: ... }; + // ... +}; +``` + +`pnpm fixtures:check` passes across all fixture pairs. + +**What this pins.** +- The emit-path `TypeMaps` derivation reads from typed descriptors (no shadow `mkCodec`-result instance map needed). +- Demo emit output is byte-identical to the post-TML-2229 baseline. + +### Case D — Heterogeneous descriptor storage at the registration boundary + +**Setup.** Postgres adapter's contributor protocol returns `codecs: () => ReadonlyArray` (per AC-2 of the parent spec). `Object.values(pgDescriptors)` flattens the typed record into a generic descriptor array for runtime registration. + +**Expected behavior.** The runtime registration path (`@prisma-next/sql-runtime` → `CodecLookup.descriptorFor(codecId)`) accepts the heterogeneous array. The framework treats each entry as base `CodecDescriptor` (or `AnyCodecDescriptor`); typed-codec generics are not required at runtime — they served their purpose at the no-emit authoring boundary and at emit time. + +**What this pins.** +- The variance erasure point is bounded to the *registration boundary* — type-flow ergonomics survive at the descriptor-record-of-typed-descriptors level (where it matters); the framework runtime continues to consume codecs as black boxes (where it shouldn't matter). + +## Acceptance criteria + +### AC-0.1. `defineCodec` preserves typed factory return + +`defineCodec({...})`'s return type carries enough generic information for the typed `Codec` to flow into per-package descriptor records. Two equivalent implementation shapes admissible (decision deferred to plan-time): + +- **Shape A** (parameterize `CodecDescriptor`): `CodecDescriptor`. Verbose but explicit. +- **Shape B** (intersection return): `CodecDescriptor & { codecId: Id; traits: TTraits; factory: (p: TParams) => (ctx: SqlCodecInstanceContext) => Codec }`. Less invasive at the interface level; consumers extract structurally. + +The key constraint: extracting the typed `Codec` from the descriptor's type must NOT route through `D extends CodecDescriptor` (which discards generics). Either the interface carries them as type parameters (Shape A), or the `defineCodec` return intersects in the typed factory (Shape B). + +**Verification.** A negative type test in `packages/2-sql/4-lanes/relational-core/test/`: +```typescript +const d = defineCodec({factory: () => () => buildSqlCodec({typeId: 'pg/int4@1' as const, encode: (n: number) => Buffer.from([n]), decode: (b: Buffer) => b[0]!})}); +type C = ResolvedCodec; // however we name the extractor +expectTypeOf().toEqualTypeOf>(); +``` + +### AC-0.2. Per-target descriptor records carry typed shape + +Per-package descriptor records preserve each entry's full descriptor type by inference: +- `packages/3-targets/3-targets/postgres/src/core/codecs.ts` — `PgDescriptors`. +- `packages/3-targets/3-targets/sqlite/src/core/codecs.ts` — `SqliteDescriptors`. +- `packages/3-extensions/pgvector/src/core/codecs.ts` — `PgvectorDescriptors`. +- `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts` — `SqlDescriptors`. +- `packages/3-extensions/arktype-json/src/core/arktype-json-codec.ts` — `ArktypeJsonDescriptors`. + +`PgDescriptors['uuidv4']` is *not* `CodecDescriptor`; it carries the descriptor's full inferred shape from which `Codec<'pg/uuid@1', readonly [...], Buffer, string>` is recoverable. + +**Verification.** Negative type tests in each package's `test/` directory. + +### AC-0.3. No-emit authoring chain types end-to-end + +The no-emit authoring chain (using `examples/prisma-next-demo/prisma/contract.ts` + `prisma-no-emit/context.ts` as the reference shape) types every step: + +- `field.uuidv4()` returns a field spec whose codec generic is `Codec<'pg/uuid@1', ..., Buffer, string>`. +- `defineContract({target: postgresPack, family: sqlFamily, extensionPacks: {pgvector}}, ...)` produces a contract type carrying per-column codec types (e.g. `User.fields.id` → `pg/uuid@1` codec; `Post.fields.embedding` → `pg/vector@1` codec). +- `sqlBuilder({context})`-produced query expressions accept correctly-typed parameters and reject incorrectly-typed ones. + +**Verification.** A `*.test-d.ts` constructive test in the no-emit chain. Both positive (correct types compile) and negative (wrong types fail) cases. + +### AC-0.4. Emit-path `contract.d.ts` typed `TypeMaps` derivation works + +The emitter, given a descriptor record, produces a `contract.d.ts` whose `TypeMaps` projection has correct per-codec-id `{input, output, traits}` shapes. Generated text matches the post-TML-2229 baseline byte-for-byte. + +**Verification.** `pnpm fixtures:check` passes across all fixture pairs. + +### AC-0.5. Forcing function — every parallel typed-instance path deleted + +This is the AC that *proves* AC-0.1–AC-0.4 hold in fact and not just nominally. Without an explicit deletion of every parallel typed-instance carrier, M0 could ship while the typed flow is still being load-borne by the legacy instance map and we'd never know. The only way to verify the typed flow runs through descriptors is to delete every other path that could be carrying it and confirm the chain still works. + +**Required deletions inside M0:** + +1. **`mkCodec` public export** from `@prisma-next/sql-relational-core/ast`. The factory may persist as an *internal* helper inside the package (rename to `buildSqlCodec` to make the role explicit; it's the codec-instance builder called inside `defineCodec` factory closures). External callers must reach typed codecs via descriptors. + +2. **`CodecDefBuilder` and `CodecDefBuilderImpl`** from `packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts`. The instance-keyed builder has no remaining role; descriptor-keyed `CodecDescriptorBuilder` (line 709-) absorbs every consumer. Delete the legacy `defineCodecGroup()` factory function exported in parallel. + +3. **`ExtractCodecTypes` (relational-core, line 292)** — the instance-keyed projection. The descriptor-keyed `ExtractDescriptorCodecTypes` (line 688) takes its place. **Rename `ExtractDescriptorCodecTypes` → `ExtractCodecTypes`** so there's one canonical name for the projection. Note: the contract-level `ExtractCodecTypes` at `packages/2-sql/1-core/contract/src/types.ts:239` is a different type with a different role and stays as-is. + +4. **`byScalar` and `dataTypes` exports** from target/extension packages (`packages/3-targets/3-targets/postgres/src/core/codecs.ts:570`, `packages/3-targets/3-targets/sqlite/src/core/codecs.ts:120`, `packages/3-extensions/pgvector/src/core/codecs.ts:73`, `packages/2-sql/4-lanes/relational-core/src/ast/sql-codecs.ts:198`'s `sqlCodecDefinitions`). Migrate adapter consumers (`adapter-postgres/src/core/adapter.ts:42`, `adapter-sqlite/src/core/adapter.ts:53`) to receive codecs through the unified `codecs:` contributor protocol slot — not by importing data structures from the target package. Migrate test consumers (`target-postgres/test/codecs.test.ts`, `target-sqlite/test/codecs.test.ts`, `pgvector/test/codecs.test.ts`) to construct codec instances at the test site via `descriptor.factory(undefined)(ctx)`. **This work absorbs [TML-2393](https://linear.app/prisma-company/issue/TML-2393)** (filed earlier as a follow-up; now consolidated into M0 because the deletion is the forcing function for AC-0). + +5. **Legacy renamed factories** `defineCodecGroup` and `defineCodecBundle` — redundant once `CodecDefBuilder` is deleted and `CodecDescriptorBuilder` is the only builder. + +**Closing-grep set.** After M0 lands, every symbol below MUST return zero hits across `packages/`, `test/`, `examples/`, `docs/`: + +- `mkCodec` (factory; `buildSqlCodec` is the internal-only replacement and is fine) +- `defineCodecGroup` +- `defineCodecBundle` +- `CodecDefBuilder` +- `CodecDefBuilderImpl` +- `ExtractDescriptorCodecTypes` (renamed to `ExtractCodecTypes`) +- `byScalar` +- `dataTypes` (the target-codecs export, distinct from any unrelated framework concept) +- `sqlCodecDefinitions` +- `codecDescriptorDefinitions` (the dual-shape parallel export retired alongside `byScalar`) + +The contract-level `ExtractCodecTypes` in `sql/1-core/contract/src/types.ts` is a different identifier in a different file and stays. + +**Why this is the forcing function.** With every parallel typed-instance carrier deleted, the only remaining type-flow path is descriptor → `descriptor.factory` → typed `Codec`. If `defineCodec` doesn't preserve generics, every codec round-trip test (`postgres/test/codecs.test.ts` calls `byScalar.timestamp.codec.encode(value, ctx)` today; after migration, `pgTimestampDescriptor.factory(undefined)({}).encode(value, ctx)`) fails to compile because `value` types as `unknown`. Mechanical, observable, impossible to bypass. + +**Verification.** +- Closing-grep returns zero for every symbol in the set. +- Every gate in AC-0.6 green. +- Negative type tests in AC-0.1, AC-0.2, AC-0.3 prove the typed flow is intact at the unit-test level. + +### AC-0.6. Validation gates green throughout + +- `pnpm typecheck`, `pnpm lint:deps`, `pnpm fixtures:check`, `pnpm test:packages`, `pnpm test:e2e`, `pnpm build` all green at every commit boundary. +- No new type casts in production code. No `any`. No `@ts-expect-error` outside negative type tests. No `@ts-nocheck`. No biome suppressions. +- Demo emit byte-identical against the post-TML-2229 baseline (`origin/main`). + +## Non-goals + +- **Heterogeneous descriptor storage ergonomics.** Whether `AnyCodecDescriptor` becomes 5-arg (under Shape A) or stays 1-arg (under Shape B) is the implementation choice. Both shapes are admissible for AC-0.1. + +- **Mongo type flow.** Mongo doesn't use this descriptor record pattern at HEAD; its wire-dispatch path is reshaped under [TML-2324](https://linear.app/prisma-company/issue/TML-2324). Out of scope. + +- **Renaming `defineCodec` / `CodecDescriptor`.** Names stay; only the type signatures change. (`mkCodec` *is* renamed to `buildSqlCodec` per AC-0.5, but only because it's transitioning from public to internal.) + +## References + +- [TML-2229](https://linear.app/prisma-company/issue/TML-2229) — codec-registry-unification (parent project where this regression was introduced). +- Parent spec: [`spec.md`](../spec.md) — TML-2357 canonical spec; this sub-spec is a precondition for AC-1 / AC-2 / AC-3 / AC-7. +- [`wip/unattended-decisions.md` Decision #11](../../../wip/unattended-decisions.md) — diagnosis of the M2 R4 type-system failure that surfaced this problem. +- [`packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts:587-593`](../../../packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts) — current `defineCodec` declaration, where the variance erasure happens. +- [`examples/prisma-next-demo/prisma/contract.ts`](../../../examples/prisma-next-demo/prisma/contract.ts), [`examples/prisma-next-demo/src/prisma-no-emit/context.ts`](../../../examples/prisma-next-demo/src/prisma-no-emit/context.ts) — reference no-emit authoring chain. diff --git a/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts b/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts index e01f307766..495dd30873 100644 --- a/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts +++ b/test/integration/test/authoring/parity/ts-psl-parity.real-packs.test.ts @@ -26,8 +26,8 @@ const stack = createControlStack({ function buildColumnDescriptorMap() { const result = new Map(); for (const [typeName, codecId] of stack.scalarTypeDescriptors) { - const codec = stack.codecLookup.get(codecId); - const nativeType = codec?.targetTypes[0] ?? codecId; + const targetTypes = stack.codecLookup.targetTypesFor(codecId); + const nativeType = targetTypes?.[0] ?? codecId; result.set(typeName, { codecId, nativeType }); } return result; diff --git a/test/integration/test/contract-builder.test.ts b/test/integration/test/contract-builder.test.ts index 18e6fa7835..2245c0850a 100644 --- a/test/integration/test/contract-builder.test.ts +++ b/test/integration/test/contract-builder.test.ts @@ -270,7 +270,7 @@ describe('builder integration', () => { expectTypeOf(builderContract.models.User.fields).toHaveProperty('createdAt'); }); - it('supports type option with dataTypes constants', () => { + it('supports type option with column-type constants', () => { const contract = defineContract({ family: sqlFamilyPack, target: postgresPack, @@ -295,8 +295,7 @@ describe('builder integration', () => { }); it('accepts any codecId format in descriptor (validation happens at runtime)', () => { - // Column descriptors accept any codecId format - validation happens at runtime - // when the contract is used, not at build time + // Column descriptors accept any codecId format - validation happens at runtime when the contract is used, not at build time const contract = defineContract({ family: sqlFamilyPack, target: postgresPack, @@ -431,12 +430,7 @@ describe('builder integration', () => { }); }); - // TODO: The following 4 validation tests tested legacy chain builder validation logic - // (parentTable/childTable/through matching). In the new DSL, these constraints are - // enforced structurally by rel.belongsTo/hasMany/manyToMany and cannot be violated. - // Equivalent DSL validation tests exist in contract-builder.dsl.test.ts - // (e.g., "rejects belongsTo relations whose field arity does not match the target", - // "rejects hasMany relations whose child fields do not match the parent identity arity", - // "rejects many-to-many relations whose through mappings do not match anchor arity"). + // TODO: The following 4 validation tests tested legacy chain builder validation logic (parentTable/childTable/through matching). In the new DSL, these constraints are enforced structurally by rel.belongsTo/hasMany/manyToMany and cannot be violated. Equivalent DSL validation tests exist in contract-builder.dsl.test.ts (e.g., "rejects belongsTo relations whose field arity does not match the target", "rejects hasMany + // relations whose child fields do not match the parent identity arity", "rejects many-to-many relations whose through mappings do not match anchor arity"). }); }); diff --git a/test/integration/test/cross-package/cross-family-codec.test.ts b/test/integration/test/cross-package/cross-family-codec.test.ts index 07664a41c1..ef8c6b3c0f 100644 --- a/test/integration/test/cross-package/cross-family-codec.test.ts +++ b/test/integration/test/cross-package/cross-family-codec.test.ts @@ -1,40 +1,33 @@ -import { createMongoCodecRegistry } from '@prisma-next/mongo-codec'; +import { newMongoCodecRegistry } from '@prisma-next/mongo-codec'; import { MongoParamRef } from '@prisma-next/mongo-value'; -import { codec, createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { resolveValue } from '../../../../packages/3-mongo-target/2-mongo-adapter/src/resolve-value'; +import { defineTestCodec } from './test-codec'; // T4.1 — cross-family codec parity proof // -// A single `codec({...})` value (the unified factory entry point in -// relational-core, mirrored by `mongoCodec` post-m4) is registered in both a -// SQL `CodecRegistry` and a Mongo `MongoCodecRegistry`. Encoding the same -// input value through each registry must produce identical wire output. For -// the SQL fixture the same codec also round-trips via `decode`, demonstrating -// that one codec definition can serve both directional boundaries. +// A single codec instance (constructed via the test-only `defineTestCodec` helper, which mirrors the shape of an author-side codec definition) is used directly on the SQL side and registered in a Mongo `MongoCodecRegistry`. Encoding the same input value through each path must produce identical wire output. For the SQL fixture the same codec also round-trips via `decode`, demonstrating that one codec definition can +// serve both directional boundaries. describe('cross-family codec parity (T4.1)', () => { - // A single codec instance — registered in both SQL and Mongo registries. - const objectIdLikeCodec = codec({ + // A single codec instance — used on the SQL side directly and registered in the Mongo registry. + const objectIdLikeCodec = defineTestCodec({ typeId: 'shared/object-id-like@1', targetTypes: ['objectIdLike'], encode: (value: string) => `wire:${value}`, decode: (wire: string) => wire.replace(/^wire:/, ''), }); - it('produces identical wire output through both family registries', async () => { - const sqlRegistry = createCodecRegistry(); - sqlRegistry.register(objectIdLikeCodec); - const mongoRegistry = createMongoCodecRegistry(); + it('produces identical wire output through both family code paths', async () => { + const mongoRegistry = newMongoCodecRegistry(); mongoRegistry.register(objectIdLikeCodec); - const sqlCodec = sqlRegistry.get('shared/object-id-like@1'); const mongoCodecLookup = mongoRegistry.get('shared/object-id-like@1'); - if (!sqlCodec || !mongoCodecLookup) { - throw new Error('codec not registered in one of the family registries'); + if (!mongoCodecLookup) { + throw new Error('codec not registered in mongo registry'); } - const sqlWire = await sqlCodec.encode('abc-123', {}); + const sqlWire = await objectIdLikeCodec.encode('abc-123', {}); const mongoWire = await mongoCodecLookup.encode('abc-123', {}); expect(sqlWire).toBe('wire:abc-123'); @@ -43,15 +36,10 @@ describe('cross-family codec parity (T4.1)', () => { }); it('encoding through Mongo resolveValue matches SQL codec.encode result', async () => { - const sqlRegistry = createCodecRegistry(); - sqlRegistry.register(objectIdLikeCodec); - const mongoRegistry = createMongoCodecRegistry(); + const mongoRegistry = newMongoCodecRegistry(); mongoRegistry.register(objectIdLikeCodec); - const sqlCodec = sqlRegistry.get('shared/object-id-like@1'); - if (!sqlCodec) throw new Error('SQL codec missing'); - - const sqlWire = await sqlCodec.encode('abc-123', {}); + const sqlWire = await objectIdLikeCodec.encode('abc-123', {}); const mongoWire = await resolveValue( new MongoParamRef('abc-123', { codecId: 'shared/object-id-like@1' }), mongoRegistry, @@ -63,16 +51,10 @@ describe('cross-family codec parity (T4.1)', () => { }); it('round-trips: SQL decode is the inverse of SQL encode', async () => { - const sqlRegistry = createCodecRegistry(); - sqlRegistry.register(objectIdLikeCodec); - - const sqlCodec = sqlRegistry.get('shared/object-id-like@1'); - if (!sqlCodec) throw new Error('SQL codec missing'); - - const wire = await sqlCodec.encode('abc-123', {}); + const wire = await objectIdLikeCodec.encode('abc-123', {}); expect(wire).toBe('wire:abc-123'); - const decoded = await sqlCodec.decode(wire, {}); + const decoded = await objectIdLikeCodec.decode(wire, {}); expect(decoded).toBe('abc-123'); }); }); diff --git a/test/integration/test/cross-package/test-codec.ts b/test/integration/test/cross-package/test-codec.ts new file mode 100644 index 0000000000..3b8e5c00af --- /dev/null +++ b/test/integration/test/cross-package/test-codec.ts @@ -0,0 +1,58 @@ +/** + * Test-only helper that constructs a SQL-family `Codec` instance from author-side encode/decode functions. Replaces the legacy public `mkCodec()` factory (deleted under TML-2357); tests that need a stub codec for behavioural assertions instantiate one through this helper rather than going through `descriptor.factory(...)`. + */ +import type { JsonValue } from '@prisma-next/contract/types'; +import type { CodecTrait } from '@prisma-next/framework-components/codec'; +import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'; + +type JsonRoundTripConfig = [TInput] extends [JsonValue] + ? { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + } + : { + encodeJson: (value: TInput) => JsonValue; + decodeJson: (json: JsonValue) => TInput; + }; + +export function defineTestCodec< + Id extends string, + const TTraits extends readonly CodecTrait[] = readonly [], + TWire = unknown, + TInput = unknown, +>( + config: { + typeId: Id; + targetTypes?: readonly string[]; + encode: (value: TInput, ctx: SqlCodecCallContext) => TWire | Promise; + decode: (wire: TWire, ctx: SqlCodecCallContext) => TInput | Promise; + traits?: TTraits; + } & JsonRoundTripConfig, +): Codec { + const identity = (v: unknown) => v; + const userEncode = config.encode; + const userDecode = config.decode; + const widenedConfig = config as { + encodeJson?: (value: TInput) => JsonValue; + decodeJson?: (json: JsonValue) => TInput; + }; + return { + id: config.typeId, + encode: (value, ctx) => { + try { + return Promise.resolve(userEncode(value, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + decode: (wire, ctx) => { + try { + return Promise.resolve(userDecode(wire, ctx)); + } catch (error) { + return Promise.reject(error); + } + }, + encodeJson: (widenedConfig.encodeJson ?? identity) as (value: TInput) => JsonValue, + decodeJson: (widenedConfig.decodeJson ?? identity) as (json: JsonValue) => TInput, + } as Codec; +} diff --git a/test/integration/test/mongo/migration-psl-authoring.test.ts b/test/integration/test/mongo/migration-psl-authoring.test.ts index 44554b1b06..d26ae3fad1 100644 --- a/test/integration/test/mongo/migration-psl-authoring.test.ts +++ b/test/integration/test/mongo/migration-psl-authoring.test.ts @@ -45,13 +45,18 @@ const mongoCodecLookup: CodecLookup = { if (!bsonType) return undefined; return { id, - targetTypes: [bsonType], encode: async (v: unknown) => v, decode: async (v: unknown) => v, encodeJson: (v: unknown) => v as JsonValue, decodeJson: (v: JsonValue) => v, }; }, + targetTypesFor(id: string) { + const bsonType = bsonTypesByCodecId[id]; + return bsonType ? [bsonType] : undefined; + }, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, }; function pslToContract(schema: string): MongoContract { diff --git a/test/integration/test/pgvector.test.ts b/test/integration/test/pgvector.test.ts index 672d221533..07d1673a85 100644 --- a/test/integration/test/pgvector.test.ts +++ b/test/integration/test/pgvector.test.ts @@ -3,7 +3,6 @@ import { extractCodecTypeImports, extractOperationTypeImports, } from '@prisma-next/family-sql/test-utils'; -import { createCodecRegistry } from '@prisma-next/sql-relational-core/ast'; import { describe, expect, it } from 'vitest'; import { getSqlDescriptorBundle, pgvectorExtensionDescriptor } from '../utils/framework-components'; @@ -52,14 +51,14 @@ describe('pgvector extension pack integration', () => { }); }); - it('descriptor provides codecs', () => { - const codecs = pgvector.codecs(); - expect(codecs).toBeDefined(); + it('descriptor contributes the pg/vector@1 codec descriptor', () => { + const descriptors = pgvector.codecs(); + expect(descriptors).toBeDefined(); - const vectorCodec = codecs.get('pg/vector@1'); - expect(vectorCodec).toBeDefined(); - expect(vectorCodec?.id).toBe('pg/vector@1'); - expect(vectorCodec?.targetTypes).toEqual(['vector']); + const vectorDescriptor = descriptors.find((d) => d.codecId === 'pg/vector@1'); + expect(vectorDescriptor).toBeDefined(); + expect(vectorDescriptor?.codecId).toBe('pg/vector@1'); + expect(vectorDescriptor?.targetTypes).toEqual(['vector']); }); it('descriptor provides query operations', () => { @@ -74,17 +73,12 @@ describe('pgvector extension pack integration', () => { expect(cosineSimilarityOp).toBeDefined(); }); - it('codecs can be registered in registry', { timeout: 1_000 }, () => { - const codecs = pgvector.codecs(); - expect(codecs).toBeDefined(); + it('descriptor materializes a runtime codec when factory is called', { timeout: 1_000 }, () => { + const descriptors = pgvector.codecs(); + const vectorDescriptor = descriptors.find((d) => d.codecId === 'pg/vector@1'); + expect(vectorDescriptor).toBeDefined(); - const registry = createCodecRegistry(); - for (const codec of codecs.values()) { - registry.register(codec); - } - - const vectorCodec = registry.get('pg/vector@1'); - expect(vectorCodec).toBeDefined(); - expect(vectorCodec?.id).toBe('pg/vector@1'); + const codec = vectorDescriptor!.factory({ length: 3 })({ name: '' }); + expect(codec.id).toBe('pg/vector@1'); }); }); diff --git a/test/utils/README.md b/test/utils/README.md index dd21cfdf60..2cb0d80552 100644 --- a/test/utils/README.md +++ b/test/utils/README.md @@ -108,7 +108,7 @@ const contract = defineContract({ }); ``` -**Note**: These descriptors are dependency-free and match the `ColumnTypeDescriptor` shape from `@prisma-next/contract-authoring`, but are defined locally to keep test-utils dependency-free. +**Note**: The descriptor shape mirrors `ColumnTypeDescriptor` from `@prisma-next/framework-components/codec` but is defined locally to keep `test-utils` dependency-free (avoids a turbo build cycle through packages that devDepend on it). ### Operation Descriptors diff --git a/test/utils/src/column-descriptors.ts b/test/utils/src/column-descriptors.ts index 96a178a7e4..addff7c6b0 100644 --- a/test/utils/src/column-descriptors.ts +++ b/test/utils/src/column-descriptors.ts @@ -2,11 +2,9 @@ * Adapter-agnostic column type descriptors for test fixtures. * * These descriptors match common PostgreSQL types but don't depend on - * @prisma-next/adapter-postgres or any target-specific packages. - * Use these in test fixtures to avoid adapter/target dependencies. + * @prisma-next/adapter-postgres or any target-specific packages. Use these in test fixtures to avoid adapter/target dependencies. * - * The shape matches `ColumnTypeDescriptor` from `@prisma-next/contract-authoring` - * but is defined locally to keep test-utils dependency-free. + * The shape matches `ColumnTypeDescriptor` from `@prisma-next/framework-components/codec` but is defined locally to keep test-utils dependency-free (`framework-components` transitively depends on packages that devDepend on test-utils, so a runtime dep here would create a turbo build cycle). */ type ColumnTypeDescriptor = { readonly codecId: string;