diff --git a/.changeset/prisma-next-0-14.md b/.changeset/prisma-next-0-14.md new file mode 100644 index 000000000..f1ad6d5a5 --- /dev/null +++ b/.changeset/prisma-next-0-14.md @@ -0,0 +1,12 @@ +--- +'@cipherstash/prisma-next': minor +--- + +Upgrade to Prisma Next 0.14.0 (from 0.8.0). Every `@prisma-next/*` dependency is now pinned at 0.14.0; consuming apps must run Prisma Next 0.14 to use this release. + +Highlights of the upgrade: + +- The extension contract space is re-emitted in the 0.14 canonical shape: storage is namespace-enveloped (`storage.namespaces.public.entries.table`), the domain plane replaces flat `models`, and the baseline EQL-install migration is re-pinned to the new storage hash. The vendored EQL bundle SQL is unchanged byte-for-byte. +- `deriveStackSchemas` reads the namespace-enveloped contract shape emitted by Prisma Next 0.10+. +- The bulk-encrypt middleware accepts the widened insert/update AST value unions introduced through 0.9–0.11. +- README examples use the namespace-qualified ORM accessors (`db.orm.public.User`) required since Prisma Next 0.14. diff --git a/docs/superpowers/plans/2026-07-08-eql-v3-prisma-next.md b/docs/superpowers/plans/2026-07-08-eql-v3-prisma-next.md new file mode 100644 index 000000000..11f2be297 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-eql-v3-prisma-next.md @@ -0,0 +1,2935 @@ +# EQL v3 Prisma-next Implementation Plan (design A — per-domain constructors) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `@cipherstash/prisma-next` author EQL v3 columns through **concrete per-domain constructors** that map 1:1 to the `public.*` Postgres domains, each carrying a static codec id + concrete `public.*` native type + `{ castAs, capabilities }` typeParams — no boolean-option surface, no runtime domain-resolution stage — wired end-to-end through a self-contained `src/v3/` namespace, with legacy `*V2` aliases preserved. + +**Architecture:** v3 is a **parallel, self-contained namespace** — never v3 branches grafted onto v2 code. Capability is encoded by constructor identity: the constructor you choose *is* the capability set, and it emits its concrete descriptor statically (`(codec id) ⟺ (public.* domain) ⟺ capabilities` is bijective — nothing is resolved). The **domains / native types live in `public`** (`public.text_eq`, `public.integer_ord`, `public.boolean`, `public.bigint_ord`); the **operator functions live in `eql_v3`** (`eql_v3.eq`, `eql_v3.gt`, `eql_v3.contains`, `eql_v3.ord_term`). These two schemas must never be conflated. The constructor set, codec ids, capabilities, cast kinds, and native types are all **derived from the imported `DOMAIN_REGISTRY`** in `@cipherstash/stack/eql/v3` — there is no hand-maintained local table. Concrete per-domain types are threaded end-to-end (literal `codecId` / `nativeType` / `capabilities`; see Task 3's `V3ColumnDescriptor` and the `.test-d.ts`). **v2 and v3 are separate entry points** (decision 1b): v3 is its own extension factory with its own distinct extension id, keeping the same `cipherstash*` method names, and is **never co-registered** with v2 in one client (the flat `OperationRegistry` disallows the duplicate-method override). A client is v2 or v3; mixed v2+v3 columns in one client are unsupported this release. + +**Tech Stack:** TypeScript, `@cipherstash/stack` (`/eql/v3` catalog + a v3 `EncryptionV3` client), `@cipherstash/eql@3.0.0-alpha.3` (`/sql` → `readInstallSql` / `releaseManifest`), `@prisma-next/*` framework **pinned at 0.14.0** (see the "Prisma Next 0.14 ground truth" notes below — this plan was originally drafted against 0.8.0 and has been re-aligned), `arktype`, `vitest` (`vitest run`), `fast-check ^4.8.0` (added here), `biome` (lint), `tsup` (build), `postgres` (live tests). + +## Global Constraints + +- **Schema split (hard — the #1 thing to get right).** Two Postgres schemas are in play and must not be conflated: + - **Domains / native types → `public`.** `public.text_eq`, `public.integer_ord`, `public.boolean`, `public.bigint_ord`, … A column's `getEqlType()` returns this `public.*` string; it is the column's `nativeType`. The stack type pins it as `` `public.${string}` ``. + - **Operator functions → `eql_v3`.** `eql_v3.eq(...)`, `eql_v3.gt(...)`, `eql_v3.contains(...)`, `eql_v3.ord_term(...)`. This is the SQL the operators lower to. Do NOT "correct" these to `public.*`. +- **Namespace separation (hard).** All v3 implementation lives under `packages/prisma-next/src/v3/` plus `packages/prisma-next/src/extension-metadata/constants-v3.ts`. v3 modules MUST NOT import v2 codec/wire (`encodeEqlV2EncryptedWire`), the v2 bulk-encrypt middleware, the v2 codec-runtime, or the v2 SDK adapter. The only permitted shared primitives are the version-neutral value-envelope classes (`EncryptedEnvelopeBase`, `EncryptedString`, `EncryptedDate`, `EncryptedBoolean`, `EncryptedBigInt`) + the routing-key stamping utilities and framework authoring/trait infrastructure. No v2 module gains an `if (isV3)` wire-format branch. +- **Source of truth = the imported `DOMAIN_REGISTRY`.** Derive domain names, `castAs`, capabilities, indexes, native types, and codec ids from `@cipherstash/stack/eql/v3` by iterating `DOMAIN_REGISTRY` (keys are bare domain names → factory). Do NOT hand-maintain a local domain table. The stack v3 layer defines NO codec-id strings; `codecId` is purely a prisma-next concept. +- **Codec id scheme.** Concrete per-domain id = `` `cipherstash/eql-v3/${bareDomain}@1` `` where `bareDomain` is the domain with the `public.` prefix stripped (e.g. `public.text_search` → `cipherstash/eql-v3/text_search@1`). Strip `public.` (never `eql_v3.`). The `eql-v3` token is a logical version tag, not a schema. +- **castAs set.** `'string' | 'number' | 'bigint' | 'boolean' | 'date' | 'timestamp'`. `bigint` domains (`public.bigint*`) carry JS `bigint` plaintext. +- **No id reuse.** v3 ids MUST NOT reuse v2 ids (`cipherstash/string@1`, …). The v3 and v2 id sets MUST be disjoint (pinned by a test). +- **Legacy aliases.** `*V2` constructors keep the existing v2 codec ids and `eql_v2_encrypted` native type — unchanged behavior. The v2 constructors that used unqualified names (`EncryptedString`, `EncryptedDouble`, `EncryptedBigInt`, `EncryptedDate`, `EncryptedBoolean`, `EncryptedJson`) are renamed to their `*V2` forms; the unqualified names are taken over by the v3 surface (except `EncryptedString`/`EncryptedJson`, which have no v3 constructor — text v3 uses `EncryptedText*`, and JSON has no v3 domain). +- **Contract representation.** Each v3 column carries `codecId` (concrete per-domain id), `nativeType` (concrete `public.*` domain), `typeParams: { castAs, capabilities }`. There is NO redundant `eqlType` field — `nativeType` is the single source. +- **`*_ord_ore` not exposed.** The registry defines `*_ord_ore` domains; prisma-next surfaces only the `_ord` order domains. `_ord_ore` gets no constructor. (The catalog META/codec-id set may still include them so runtime decode/derivation is robust; only the *authoring constructor set* excludes them.) +- **No searchable JSON v3 domain, no `EncryptedJson` v3 constructor.** BigInt IS a first-class v3 family (`public.bigint` / `bigint_eq` / `bigint_ord`). +- **Wire format.** v3 columns are PG domains over `jsonb`; encode = `JSON.stringify`, decode = `JSON.parse` (object passthrough). Never the v2 composite literal. +- **Operators.** v3 lowers to the two-arg `eql_v3.*(...::jsonb)` functions, byte-for-byte matching the canonical Drizzle branch. No built-in framework equality traits; CipherStash operators stay in the `cipherstash:*` trait namespace. +- **Migrations.** v3 install SQL is sourced from `@cipherstash/eql/sql` (`readInstallSql()` / `releaseManifest.eqlVersion`, `@cipherstash/eql@3.0.0-alpha.3`), NOT a hand-vendored FFI fixture. Invariant `cipherstash:install-eql-v3-bundle-v1`; directory `20260601T0100_install_eql_v3_bundle` (sorts after the v2 `20260601T0000_install_eql_bundle`). v3 columns emit NO `add_search_config` / `remove_search_config`. +- **Commands.** Test: `pnpm --filter @cipherstash/prisma-next test`. Build: `pnpm --filter @cipherstash/prisma-next build`. Lint: `pnpm --filter @cipherstash/prisma-next lint`. If a task touches `@cipherstash/stack` public exports, also run `pnpm --filter @cipherstash/stack test`. Every task ends green on `test`. + +## Ground-truth notes (verified against the worktree `.worktrees/feat/eql-v3-supabase-adapter`) + +1. **`DOMAIN_REGISTRY` / `factoryForDomain` / `stripDomainSchema` are NOT yet re-exported** from the `@cipherstash/stack/eql/v3` barrel (`packages/stack/src/eql/v3/index.ts`); they live in `domain-registry.ts` as internal. Task 1 Step 1 adds them to the barrel (touches stack public exports → run stack tests). +2. **`EncryptedBigInt`, `EncryptedString`, `EncryptedDate`, `EncryptedBoolean`, `EncryptedDouble` value envelopes already exist** in `packages/prisma-next/src/execution/envelope-*.ts` (each with its own `typeName`). **`EncryptedNumber` does NOT exist and must be created** (a sibling of `EncryptedDouble`). `EncryptedBigInt` is REUSED (not recreated) and surfaced through the v3 barrel. +3. **`stampRoutingKeysFromAst` is module-private** in `src/middleware/bulk-encrypt.ts`. Task 5 exports it (a version-neutral primitive). +4. **`CipherstashSdk`** (`src/execution/sdk.ts`): `decrypt(args) => Promise`, `bulkEncrypt({ routingKey, values, signal? }) => Promise>`, `bulkDecrypt({ routingKey, ciphertexts, signal? }) => Promise>`; `CipherstashRoutingKey { readonly table: string; readonly column: string }`. +5. **`encryptedTable(name, columns)`** (stack `eql/v3/table.ts`) returns `EncryptedTable & T`; instance members: `tableName`, `columnBuilders`, `build() => { tableName, columns }`, `buildColumnKeyMap()`. `AnyV3Table = EncryptedTable`. +6. **`@cipherstash/eql/sql`** exports `readInstallSql(): string` and `releaseManifest` (`.eqlVersion`). `installEqlV3IfNeeded(sql)` lives in `packages/stack/__tests__/helpers/eql-v3.ts` (advisory lock `3_733_003`; staleness via `eql_v3.version()`). +7. **The Drizzle reference operator/gating shapes** live on branch `eql-v3-drizzle-concrete-types` (not the working tree). Their shapes are reproduced inline in the relevant tasks. Operator SQL is proven live in `packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts` and `schema-v3-pg.test.ts`. +8. **Operator method names + the flat registry (resolved: decision 1b).** The framework `OperationRegistry` is a flat, method-name-keyed map that disallows override, so two descriptors both defining `cipherstashEq` collide at registration regardless of extension id. Resolution: v2 and v3 are **separate entry points** — v3 registers its own extension descriptor (its own distinct `id`/`version`, same `cipherstash*` method names) and is **never co-registered** with the v2 descriptor. There is no single-shared-gated-descriptor and no in-one-client mixing. This is not a fallback; it is the design. + +## Prisma Next 0.14 ground truth (re-alignment, 2026-07-09) + +This plan was drafted while `packages/prisma-next` pinned `@prisma-next/*` at **0.8.0**. The package has since been upgraded to **0.14.0** (branch `chore/prisma-next-0.14-upgrade`, one commit per minor). Everything below is verified against that branch and **overrides** any 0.8-era shape the original draft assumed. Tasks whose steps this affects carry an inline `> **0.14:**` note. + +1. **Contract JSON shape (affects every contract fixture and every contract walker).** `contract.json` is namespace-enveloped at both planes: + - Storage: `storage.namespaces..entries.table.` (the `entries.` envelope landed in 0.13). There is **no** flat `storage.tables` — the structural validator rejects it. + - Domain: `domain.namespaces..models` replaces flat `models`; `roots` entries are `{ model, namespace }` objects. + - The Postgres default namespace is **`public`** (`kind: 'postgres-schema'`) since 0.12; the emitted extension contract carries the sole namespace `public` (0.14 dropped the spurious empty `__unbound__` slot, TML-2916). + - Reference implementation: the v2 `deriveStackSchemas` (`src/stack/derive-schemas.ts`) already walks `storage.namespaces..tables`-era → 0.14 `entries`-shaped views on the branch; `deriveStackSchemasV3` must walk the same envelope (Task 7 has been updated accordingly). +2. **Handcrafted contract fixtures** (`test/operator-lowering.helpers.ts`, `test/helpers.test.ts`) are validated with `validateSqlContractFully(value)` — single argument, imported from `@prisma-next/sql-contract/validators` (`@prisma-next/sql-contract/validate` and the two-arg `validateContract` are gone). Fixtures carry `storage.namespaces.__unbound__ = { id, entries: { table: {...} } }` and `domain: { namespaces: { __unbound__: { models: {} } } }`. Mirror this shape for `contractV3` (Task 6). +3. **Lowered params are structured.** `adapter.lower(...)` emits `LoweredParam` entries (`{ kind: 'literal', value } | { kind: 'bind', name }`), not raw values. The v2 helpers export `literalParamValue(param)` for unwrapping — reuse it in v3 lowering tests that assert on `lowered.params`. +4. **PSL interpreter harness.** `interpretPslDocumentToSqlContract` requires the target ref to carry `defaultNamespaceId` (`'public'`) and requires `composedExtensionContracts: ReadonlyMap` (the composed cipherstash contract must be in the map or interpretation fails with `PSL_UNKNOWN_CONTRACT_SPACE`). Interpreted tables land under the `public` namespace at `storage.namespaces.public.entries.table`. The `psl-interpretation*.test.ts` harnesses (`interpret()`, `asStorage()`) are already 0.14-shaped on the branch — new v3 cases extend them, don't fork them. +5. **Contract-space emit / re-pin loop (Task 8's backbone).** The self-emit (`pnpm exec tsx migrations//migration.ts`) writes **only** `ops.json` + `migration.json`. It does not write `end-contract.json`, does not print a contract hash, and does not touch `migrations/refs/head.json`. The full maintainer loop, exercised four times on the upgrade branch, is: + 1. `pnpm exec prisma-next contract emit` (from `packages/prisma-next/`) → rewrites `src/contract.{json,d.ts}`; the new hash is `src/contract.json` → `storage.storageHash`. + 2. Copy `src/contract.json` → `migrations//end-contract.json` and `src/contract.d.ts` → `migrations//end-contract.d.ts`. + 3. Hand-edit the migration's `describe()` hash literal(s). + 4. Self-emit: `pnpm exec tsx migrations//migration.ts` → regenerates `ops.json` + `migration.json` (0.12+ manifests carry no `labels`/`hints` and no inlined `fromContract`/`toContract`). + 5. Hand-edit `migrations/refs/head.json` (`{ hash, invariants }`). + - Current head after the 0.14 upgrade: `hash: sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7`, `invariants: ["cipherstash:install-eql-bundle-v1"]`. The v2 baseline's `describe()` is `{ from: null, to: }`. +6. **Descriptor self-consistency requires the family canonicalization hooks.** `assertDescriptorSelfConsistency` must be passed `sqlContractCanonicalizationHooks.shouldPreserveEmpty` / `.sortStorage` (from `@prisma-next/sql-contract/canonicalization-hooks`) or the recomputed hash will not match the emitted one. `test/descriptor.test.ts` already does this on the branch; Task 8's "descriptor test passes with the second migration wired" step inherits it. +7. **Codec control hooks carry a namespace coordinate.** `FieldEventContext` has a required `namespaceId` (plus optional `priorTable`/`newTable`), and `planFieldEventOperations` walks `storage.namespaces.` — relevant only to v2 hooks (v3 registers none), but any new test that invokes a hook directly must pass `namespaceId`. +8. **Plan ops can lower lazily.** `OpFactoryCall.toOp()` may return a `Promise`; tests await `Promise.all(ops.map((c) => c.toOp()))` (see `test/cipherstash-codec.test.ts` on the branch). +9. **Middleware context requires `planExecutionId`.** `SqlMiddlewareContext` (via `RuntimeMiddlewareContext`) has a required `planExecutionId: string`; the v2 test `createCtx()` fixtures already stamp one — the extracted shared helper (Task 5) inherits it. +10. **`createRuntime` is removed** from `@prisma-next/sql-runtime` (construct `new PostgresRuntimeImpl(...)` from `@prisma-next/postgres/runtime`), the bare migration op factories are methods on the `Migration` base class (`this.createTable({...})` etc.) — but **`rawSql` survives as a free function** in 0.14 and the v2 baseline still uses it; Task 8's migration module is unaffected. ORM access is namespace-qualified (`db.orm.public.User`) — relevant to any live-suite example code. + +## File Structure + +New / modified files, by responsibility: + +``` +packages/stack/ + src/eql/v3/index.ts (MODIFY) re-export DOMAIN_REGISTRY, factoryForDomain, stripDomainSchema, V3ColumnFactory +packages/prisma-next/ + src/ + extension-metadata/ + constants-v3.ts (NEW) v3 codec ids (derived closed union), traits map, invariants, migration name + v3/ + catalog.ts (NEW) derive domain meta + codec ids + native-type→factory map from DOMAIN_REGISTRY + envelope-number.ts (NEW) EncryptedNumber envelope (sibling of EncryptedDouble) + wire-v3.ts (NEW) v3ToDriver / v3FromDriver (plain JSONB) + codec-runtime-v3.ts (NEW) v3 cell codec (JSONB), per-domain factories, metadata + bulk-encrypt-v3.ts (NEW) bulkEncryptMiddlewareV3(sdk): JSONB payloads, neutral routing-key stamp + operators-v3.ts (NEW) cipherstashV3QueryOperations(), EncryptionOperatorError, capability gating + derive-schemas-v3.ts (NEW) deriveStackSchemasV3(contractJson) -> AnyV3Table[] + sdk-adapter-v3.ts (NEW) createCipherstashV3Sdk(EncryptionV3 client, v3 schemas) + runtime-v3.ts (NEW) createCipherstashV3RuntimeDescriptor({ sdk }) + from-stack-v3-validate.ts (NEW) assertV3SchemasAgree (exact domain identity) + barrel.ts (NEW) v3 user-facing value types (reuse neutral classes + EncryptedNumber) + contract-authoring.ts (MODIFY) v3 concrete per-domain constructors (derived) + *V2 aliases + exports/column-types.ts (MODIFY) TS factories mirror the PSL constructors + *V2 + middleware/bulk-encrypt.ts (MODIFY) export stampRoutingKeysFromAst as neutral util + stack/from-stack.ts (MODIFY) dispatch v2/v3, build Encryption + EncryptionV3 clients + migration/ + eql-bundle-v3.ts (NEW) re-export readInstallSql / releaseManifest from @cipherstash/eql/sql + migrations/20260601T0100_install_eql_v3_bundle/ + migration.ts (NEW) rawSql install op, invariant cipherstash:install-eql-v3-bundle-v1 + migration.json / ops.json (GENERATED by the migration.ts self-emit) + end-contract.{json,d.ts} (COPIED from src/contract.{json,d.ts} after `prisma-next contract emit`) + exports/control.ts (MODIFY) add the v3 baseline to the migrations array + exports/runtime.ts (MODIFY) export v3 runtime surface + exports/stack.ts (MODIFY) export v3 derivation/adapter + test/ + v3/ (new suites — see tasks) + live/helpers/live-gate.ts, eql-v3.ts (NEW) mirror stack live helpers + package.json (MODIFY) add fast-check + @cipherstash/eql devDeps; ./v3 subpath exports +.changeset/eql-v3-prisma-next.md (NEW) +``` + +--- + +### Task 1: Export `DOMAIN_REGISTRY` from stack + derive the v3 catalog (meta + codec ids) + +Re-export the registry from the stack v3 barrel, then derive every v3 domain's `{ nativeType, bareDomain, castAs, capabilities, indexes }` and its concrete codec id by iterating `DOMAIN_REGISTRY`. This is the single runtime source of truth; every later task reads from it. + +**Files:** +- Modify: `packages/stack/src/eql/v3/index.ts` +- Create: `packages/prisma-next/src/v3/catalog.ts` +- Test: `packages/prisma-next/test/v3/catalog.test.ts` + +**Interfaces:** +- Consumes: `@cipherstash/stack/eql/v3` — after Step 1: `DOMAIN_REGISTRY` (`Record AnyEncryptedV3Column>`), `stripDomainSchema(eqlType): string`, `type V3ColumnFactory`; already public: `type QueryCapabilities`, `type AnyEncryptedV3Column`, `type EqlTypeForColumn`. Each column exposes `getEqlType(): \`public.${string}\``, `getQueryCapabilities(): QueryCapabilities`, `build(): { cast_as: string; indexes: Record }`. +- Produces: + - `type V3CastAs = 'string' | 'number' | 'bigint' | 'boolean' | 'date' | 'timestamp'` + - `interface V3DomainMeta { readonly nativeType: \`public.${string}\`; readonly bareDomain: string; readonly castAs: V3CastAs; readonly capabilities: QueryCapabilities; readonly indexes: Readonly> }` + - `function toV3CodecId(bareDomain: string): string` → `` `cipherstash/eql-v3/${bareDomain}@1` `` + - `const V3_DOMAIN_META_BY_CODEC_ID: ReadonlyMap` (all registry domains, keyed by concrete codec id) + - `const V3_CODEC_IDS: readonly string[]` + - `const V3_FACTORY_BY_NATIVE_TYPE: ReadonlyMap AnyEncryptedV3Column>` (keyed by `public.*` native type) + - `function isOrdOreDomain(bareDomain: string): boolean` + - `const EXPOSED_DOMAIN_ENTRIES: ReadonlyArray` (registry minus `*_ord_ore`) + - `function envelopeTypeNameForCastAs(castAs: V3CastAs): 'EncryptedString' | 'EncryptedNumber' | 'EncryptedBigInt' | 'EncryptedDate' | 'EncryptedBoolean'` + +- [ ] **Step 1: Re-export the registry from the stack v3 barrel** + +In `packages/stack/src/eql/v3/index.ts`, add: + +```ts +export { + DOMAIN_REGISTRY, + factoryForDomain, + stripDomainSchema, + type V3ColumnFactory, +} from './domain-registry' +``` + +Run: `pnpm --filter @cipherstash/stack build && pnpm --filter @cipherstash/stack test` +Expected: PASS (pure additive export; no behavior change). + +- [ ] **Step 2: Write the failing test** + +Create `packages/prisma-next/test/v3/catalog.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { + EXPOSED_DOMAIN_ENTRIES, + envelopeTypeNameForCastAs, + isOrdOreDomain, + toV3CodecId, + V3_CODEC_IDS, + V3_DOMAIN_META_BY_CODEC_ID, + V3_FACTORY_BY_NATIVE_TYPE, +} from '../../src/v3/catalog' + +describe('v3 catalog derivation', () => { + it('derives a concrete codec id per domain by stripping the public. prefix', () => { + expect(toV3CodecId('text_search')).toBe('cipherstash/eql-v3/text_search@1') + expect(toV3CodecId('boolean')).toBe('cipherstash/eql-v3/boolean@1') + }) + + it('records the public.* native type (never eql_v3.*) for text_search with full caps', () => { + const meta = V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/text_search@1') + expect(meta?.nativeType).toBe('public.text_search') + expect(meta?.nativeType.startsWith('public.')).toBe(true) + expect(meta?.castAs).toBe('string') + expect(meta?.capabilities).toEqual({ equality: true, orderAndRange: true, freeTextSearch: true }) + expect(Object.keys(meta?.indexes ?? {}).sort()).toEqual(['match', 'ore', 'unique']) + }) + + it('marks public.boolean as storage-only (no indexes, all capabilities false)', () => { + const meta = V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/boolean@1') + expect(meta?.nativeType).toBe('public.boolean') + expect(meta?.castAs).toBe('boolean') + expect(meta?.capabilities).toEqual({ equality: false, orderAndRange: false, freeTextSearch: false }) + expect(Object.keys(meta?.indexes ?? {})).toEqual([]) + }) + + it('exposes bigint as a first-class family with bigint castAs', () => { + expect(V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/bigint@1')?.castAs).toBe('bigint') + expect(V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/bigint_ord@1')?.nativeType).toBe('public.bigint_ord') + }) + + it('derives timestamp castAs distinctly from date', () => { + expect(V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/timestamp@1')?.castAs).toBe('timestamp') + expect(V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/date@1')?.castAs).toBe('date') + }) + + it('maps native type back to its concrete factory', () => { + const factory = V3_FACTORY_BY_NATIVE_TYPE.get('public.integer_ord') + expect(factory).toBeDefined() + expect(factory?.('score').getEqlType()).toBe('public.integer_ord') + }) + + it('maps castAs to the envelope type name (bigint distinct from number)', () => { + expect(envelopeTypeNameForCastAs('string')).toBe('EncryptedString') + expect(envelopeTypeNameForCastAs('number')).toBe('EncryptedNumber') + expect(envelopeTypeNameForCastAs('bigint')).toBe('EncryptedBigInt') + expect(envelopeTypeNameForCastAs('date')).toBe('EncryptedDate') + expect(envelopeTypeNameForCastAs('timestamp')).toBe('EncryptedDate') + expect(envelopeTypeNameForCastAs('boolean')).toBe('EncryptedBoolean') + }) + + it('includes *_ord_ore in the full meta but excludes it from the exposed entries', () => { + expect(V3_DOMAIN_META_BY_CODEC_ID.has('cipherstash/eql-v3/integer_ord_ore@1')).toBe(true) + expect(isOrdOreDomain('integer_ord_ore')).toBe(true) + const exposedBare = new Set(EXPOSED_DOMAIN_ENTRIES.map(([, m]) => m.bareDomain)) + expect([...exposedBare].some((b) => b.endsWith('_ord_ore'))).toBe(false) + expect(exposedBare.has('integer_ord')).toBe(true) + expect(exposedBare.has('bigint')).toBe(true) + }) + + it('every codec id is v3-namespaced and unique', () => { + expect(new Set(V3_CODEC_IDS).size).toBe(V3_CODEC_IDS.length) + for (const id of V3_CODEC_IDS) expect(id.startsWith('cipherstash/eql-v3/')).toBe(true) + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/catalog` +Expected: FAIL — `Cannot find module '../../src/v3/catalog'`. + +- [ ] **Step 4: Write minimal implementation** + +Create `packages/prisma-next/src/v3/catalog.ts`: + +```ts +/** + * v3 domain catalog — derived (never hand-maintained) from the imported + * `DOMAIN_REGISTRY`. For each bare domain name we probe its factory and read + * `getEqlType()` (the `public.*` native type), `getQueryCapabilities()`, and + * `build()` (`cast_as` + `indexes`) off the instance. Codec id is + * `cipherstash/eql-v3/${bareDomain}@1` — the `public.` prefix is stripped, and + * the `eql-v3` token is a logical version tag, NOT the operator schema. + */ +import { + type AnyEncryptedV3Column, + DOMAIN_REGISTRY, + type QueryCapabilities, + type V3ColumnFactory, +} from '@cipherstash/stack/eql/v3' + +export type V3CastAs = 'string' | 'number' | 'bigint' | 'boolean' | 'date' | 'timestamp' + +export interface V3DomainMeta { + readonly nativeType: `public.${string}` + readonly bareDomain: string + readonly castAs: V3CastAs + readonly capabilities: QueryCapabilities + readonly indexes: Readonly> +} + +export function toV3CodecId(bareDomain: string): string { + return `cipherstash/eql-v3/${bareDomain}@1` +} + +const PROBE = '__probe__' + +function metaFor(bareDomain: string, factory: V3ColumnFactory): V3DomainMeta { + const column = factory(PROBE) + const built = column.build() + return { + nativeType: column.getEqlType(), + bareDomain, + castAs: built.cast_as as V3CastAs, + capabilities: column.getQueryCapabilities(), + indexes: built.indexes, + } +} + +const registryEntries: ReadonlyArray = + Object.entries(DOMAIN_REGISTRY) + +const metaEntries: Array = registryEntries.map( + ([bareDomain, factory]) => [toV3CodecId(bareDomain), metaFor(bareDomain, factory)] as const, +) + +export const V3_DOMAIN_META_BY_CODEC_ID: ReadonlyMap = new Map(metaEntries) + +export const V3_CODEC_IDS: readonly string[] = metaEntries.map(([id]) => id) + +export const V3_FACTORY_BY_NATIVE_TYPE: ReadonlyMap< + string, + (name: string) => AnyEncryptedV3Column +> = new Map(registryEntries.map(([, factory]) => [factory(PROBE).getEqlType(), factory] as const)) + +export function isOrdOreDomain(bareDomain: string): boolean { + return bareDomain.endsWith('_ord_ore') +} + +export const EXPOSED_DOMAIN_ENTRIES: ReadonlyArray = + metaEntries.filter(([, meta]) => !isOrdOreDomain(meta.bareDomain)) + +export function envelopeTypeNameForCastAs( + castAs: V3CastAs, +): 'EncryptedString' | 'EncryptedNumber' | 'EncryptedBigInt' | 'EncryptedDate' | 'EncryptedBoolean' { + switch (castAs) { + case 'string': + return 'EncryptedString' + case 'number': + return 'EncryptedNumber' + case 'bigint': + return 'EncryptedBigInt' + case 'date': + case 'timestamp': + return 'EncryptedDate' + case 'boolean': + return 'EncryptedBoolean' + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/catalog` +Expected: PASS (9 tests). + +- [ ] **Step 6: Commit** + +```bash +git add packages/stack/src/eql/v3/index.ts packages/prisma-next/src/v3/catalog.ts packages/prisma-next/test/v3/catalog.test.ts +git commit -m "feat(prisma-next): derive v3 domain catalog + codec ids from stack DOMAIN_REGISTRY" +``` + +--- + +### Task 2: `constants-v3.ts` — codec-id closed union, traits, invariants, drift test + +Produce the generated closed union (`CIPHERSTASH_V3_CODEC_IDS` → `CipherstashV3CodecId` → set + guard), the per-capability trait mapping, the FFI-catalog invariant drift test, and the migration name/invariant constants. Mirrors the v2 `constants.ts` shape. There are NO family ids and NO resolver in design A. + +**Files:** +- Create: `packages/prisma-next/src/extension-metadata/constants-v3.ts` +- Test: `packages/prisma-next/test/v3/constants-v3.test.ts`, `packages/prisma-next/test/v3/catalog-invariants.test.ts` + +**Interfaces:** +- Consumes: Task 1 `V3_CODEC_IDS`, `V3_DOMAIN_META_BY_CODEC_ID`, `EXPOSED_DOMAIN_ENTRIES`; `@cipherstash/stack/eql/v3` types `EqlTypeForColumn`, `AnyEncryptedV3Column`, `QueryCapabilities`; existing v2 `CIPHERSTASH_CODEC_IDS`, `CIPHERSTASH_TRAIT_EQUALITY`, `CIPHERSTASH_TRAIT_ORDER_AND_RANGE`, `CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH` (`./constants`). +- Produces: + - `type CipherstashV3CodecId` (compile-time closed union, one id per catalog domain) + - `const CIPHERSTASH_V3_CODEC_IDS: readonly CipherstashV3CodecId[]` + - `const CIPHERSTASH_V3_CODEC_ID_SET: ReadonlySet` + - `function isCipherstashV3CodecId(id: string): id is CipherstashV3CodecId` + - re-exported trait constants; `function v3TraitsForCapabilities(caps: QueryCapabilities): readonly string[]` + - `const CIPHERSTASH_V3_BASELINE_MIGRATION_NAME = '20260601T0100_install_eql_v3_bundle'` + - `const CIPHERSTASH_V3_INVARIANTS = { installBundle: 'cipherstash:install-eql-v3-bundle-v1' } as const` + - `const CIPHERSTASH_V3_SPACE_ID` / `const CIPHERSTASH_V3_EXTENSION_VERSION` — v3's own distinct extension identity (decision 1b), asserted ≠ the v2 id/version. + +- [ ] **Step 1: Write the failing test** + +Create `packages/prisma-next/test/v3/constants-v3.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { CIPHERSTASH_CODEC_IDS } from '../../src/extension-metadata/constants' +import { V3_CODEC_IDS } from '../../src/v3/catalog' +import { + CIPHERSTASH_V3_BASELINE_MIGRATION_NAME, + CIPHERSTASH_V3_CODEC_ID_SET, + CIPHERSTASH_V3_CODEC_IDS, + CIPHERSTASH_V3_INVARIANTS, + isCipherstashV3CodecId, + v3TraitsForCapabilities, +} from '../../src/extension-metadata/constants-v3' + +describe('constants-v3', () => { + it('the PINNED codec-id tuple equals the registry-derived set (drift guard, runtime side)', () => { + // The compile-time `satisfies` + `Exclude` guards catch type drift; this + // catches derivation drift (e.g. the registry iteration silently dropping + // or reordering a domain). Both directions must hold. + expect(new Set(CIPHERSTASH_V3_CODEC_IDS)).toEqual(new Set(V3_CODEC_IDS)) + expect(CIPHERSTASH_V3_CODEC_IDS.length).toBe(V3_CODEC_IDS.length) + expect(new Set(CIPHERSTASH_V3_CODEC_IDS).size).toBe(CIPHERSTASH_V3_CODEC_IDS.length) + }) + + it('v3 and v2 codec-id sets are disjoint', () => { + const v2 = new Set(CIPHERSTASH_CODEC_IDS) + for (const id of CIPHERSTASH_V3_CODEC_IDS) expect(v2.has(id)).toBe(false) + }) + + it('guard narrows only v3 ids', () => { + expect(isCipherstashV3CodecId('cipherstash/eql-v3/text_eq@1')).toBe(true) + expect(isCipherstashV3CodecId('cipherstash/string@1')).toBe(false) + expect(CIPHERSTASH_V3_CODEC_ID_SET.has('cipherstash/eql-v3/text_eq@1')).toBe(true) + }) + + it('derives cipherstash-namespaced traits from capabilities', () => { + expect( + v3TraitsForCapabilities({ equality: true, orderAndRange: true, freeTextSearch: true }).sort(), + ).toEqual(['cipherstash:equality', 'cipherstash:free-text-search', 'cipherstash:order-and-range']) + expect(v3TraitsForCapabilities({ equality: false, orderAndRange: false, freeTextSearch: false })).toEqual([]) + }) + + it('pins the migration name + invariant', () => { + expect(CIPHERSTASH_V3_BASELINE_MIGRATION_NAME).toBe('20260601T0100_install_eql_v3_bundle') + expect(CIPHERSTASH_V3_INVARIANTS.installBundle).toBe('cipherstash:install-eql-v3-bundle-v1') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/constants-v3` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `packages/prisma-next/src/extension-metadata/constants-v3.ts`: + +```ts +/** + * v3 codec ids, traits, and migration invariants. Parallel to the v2 + * `constants.ts`; imports NO v2 codec/wire code. Codec ids are the generated + * closed union derived from the imported catalog: `(codec id) ⟺ (public.* + * domain)` is bijective, so the id set IS the catalog. + */ +import type { + AnyEncryptedV3Column, + EqlTypeForColumn, + QueryCapabilities, +} from '@cipherstash/stack/eql/v3' +import { V3_CODEC_IDS } from '../v3/catalog' +import { + CIPHERSTASH_TRAIT_EQUALITY, + CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH, + CIPHERSTASH_TRAIT_ORDER_AND_RANGE, +} from './constants' + +export { + CIPHERSTASH_TRAIT_EQUALITY, + CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH, + CIPHERSTASH_TRAIT_ORDER_AND_RANGE, +} + +/** Strip the `public.` schema qualifier at the type level. */ +type StripSchema = T extends `public.${infer D}` ? D : never + +/** Compile-time closed union: one concrete codec id per catalog domain. A + * catalog change that adds/drops a domain surfaces as a type error at the + * `satisfies` line below. */ +export type CipherstashV3CodecId = + `cipherstash/eql-v3/${StripSchema>}@1` + +/** + * PINNED literal tuple — the compile-time source of truth for the v3 codec-id + * set. Hand-listed, one entry per catalog domain (INCLUDING the `*_ord_ore` + * variants that authoring does not expose but the codec/derivation layer still + * decodes). This is deliberately NOT `V3_CODEC_IDS as readonly CipherstashV3CodecId[]` + * — that is a masking cast that asserts the derived array IS the union and so + * catches NO drift. Here, catalog drift fails to COMPILE in both directions: + * - `satisfies readonly CipherstashV3CodecId[]` fails if a listed id is no + * longer a catalog domain (a domain removed / renamed). + * - `_NoUnpinnedDomain` (below) fails if the catalog gained a domain not + * pinned here. + * The Step-1 runtime test additionally asserts this equals the registry-derived + * `V3_CODEC_IDS` (guarding the derivation itself). + */ +export const CIPHERSTASH_V3_CODEC_IDS = [ + 'cipherstash/eql-v3/integer@1', + 'cipherstash/eql-v3/integer_eq@1', + 'cipherstash/eql-v3/integer_ord_ore@1', + 'cipherstash/eql-v3/integer_ord@1', + 'cipherstash/eql-v3/smallint@1', + 'cipherstash/eql-v3/smallint_eq@1', + 'cipherstash/eql-v3/smallint_ord_ore@1', + 'cipherstash/eql-v3/smallint_ord@1', + 'cipherstash/eql-v3/bigint@1', + 'cipherstash/eql-v3/bigint_eq@1', + 'cipherstash/eql-v3/bigint_ord_ore@1', + 'cipherstash/eql-v3/bigint_ord@1', + 'cipherstash/eql-v3/date@1', + 'cipherstash/eql-v3/date_eq@1', + 'cipherstash/eql-v3/date_ord_ore@1', + 'cipherstash/eql-v3/date_ord@1', + 'cipherstash/eql-v3/timestamp@1', + 'cipherstash/eql-v3/timestamp_eq@1', + 'cipherstash/eql-v3/timestamp_ord_ore@1', + 'cipherstash/eql-v3/timestamp_ord@1', + 'cipherstash/eql-v3/numeric@1', + 'cipherstash/eql-v3/numeric_eq@1', + 'cipherstash/eql-v3/numeric_ord_ore@1', + 'cipherstash/eql-v3/numeric_ord@1', + 'cipherstash/eql-v3/text@1', + 'cipherstash/eql-v3/text_eq@1', + 'cipherstash/eql-v3/text_match@1', + 'cipherstash/eql-v3/text_ord_ore@1', + 'cipherstash/eql-v3/text_ord@1', + 'cipherstash/eql-v3/text_search@1', + 'cipherstash/eql-v3/boolean@1', + 'cipherstash/eql-v3/real@1', + 'cipherstash/eql-v3/real_eq@1', + 'cipherstash/eql-v3/real_ord_ore@1', + 'cipherstash/eql-v3/real_ord@1', + 'cipherstash/eql-v3/double@1', + 'cipherstash/eql-v3/double_eq@1', + 'cipherstash/eql-v3/double_ord_ore@1', + 'cipherstash/eql-v3/double_ord@1', +] as const satisfies readonly CipherstashV3CodecId[] + +// Bidirectional drift guard — the direction `satisfies` cannot catch. If the +// catalog gains a domain that is not pinned above, `Exclude<...>` is non-`never` +// and this assignment fails to compile with the offending id in the error. +type _PinnedId = (typeof CIPHERSTASH_V3_CODEC_IDS)[number] +type _NoUnpinnedDomain = [Exclude] extends [never] + ? true + : ['catalog gained an unpinned codec id', Exclude] +const _driftGuard: _NoUnpinnedDomain = true +void _driftGuard + +export const CIPHERSTASH_V3_CODEC_ID_SET: ReadonlySet = new Set(CIPHERSTASH_V3_CODEC_IDS) + +export function isCipherstashV3CodecId(id: string): id is CipherstashV3CodecId { + return CIPHERSTASH_V3_CODEC_ID_SET.has(id) +} + +export function v3TraitsForCapabilities(caps: QueryCapabilities): readonly string[] { + const traits: string[] = [] + if (caps.equality) traits.push(CIPHERSTASH_TRAIT_EQUALITY) + if (caps.orderAndRange) traits.push(CIPHERSTASH_TRAIT_ORDER_AND_RANGE) + if (caps.freeTextSearch) traits.push(CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH) + return traits +} + +export const CIPHERSTASH_V3_BASELINE_MIGRATION_NAME = '20260601T0100_install_eql_v3_bundle' + +export const CIPHERSTASH_V3_INVARIANTS = { + installBundle: 'cipherstash:install-eql-v3-bundle-v1', +} as const + +// v3's OWN extension identity (decision 1b) — DISTINCT from the v2 +// CIPHERSTASH_SPACE_ID / CIPHERSTASH_EXTENSION_VERSION. v3 and v2 are separate +// entry points, never co-registered; the distinct id keeps the two extension +// descriptors cleanly separate. Confirm the exact SPACE_ID shape the framework +// expects against the v2 `constants.ts` (e.g. a namespaced string / UUID) and +// mirror it under the `eql-v3` namespace. +export const CIPHERSTASH_V3_SPACE_ID = 'cipherstash/eql-v3' as const +export const CIPHERSTASH_V3_EXTENSION_VERSION = 1 as const +``` + +Add a test asserting the v3 extension id/version are **not equal** to the v2 `CIPHERSTASH_SPACE_ID` / `CIPHERSTASH_EXTENSION_VERSION` (imported from `../../src/extension-metadata/constants`), so the separation can never silently regress. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/constants-v3` +Expected: PASS (4 tests). + +- [ ] **Step 5: Add the catalog-invariant drift test** + +Create `packages/prisma-next/test/v3/catalog-invariants.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { DOMAIN_REGISTRY } from '@cipherstash/stack/eql/v3' +import { EXPOSED_DOMAIN_ENTRIES, V3_DOMAIN_META_BY_CODEC_ID } from '../../src/v3/catalog' + +describe('FFI catalog invariants (non-structural — pinned)', () => { + const bareDomains = Object.keys(DOMAIN_REGISTRY) + + it('exposes no searchable JSON v3 domain', () => { + expect(bareDomains.some((d) => d.includes('json'))).toBe(false) + }) + + it('public.boolean is storage-only (advertises no equality, no indexes)', () => { + const bool = V3_DOMAIN_META_BY_CODEC_ID.get('cipherstash/eql-v3/boolean@1') + expect(bool?.nativeType).toBe('public.boolean') + expect(bool?.capabilities.equality).toBe(false) + expect(Object.keys(bool?.indexes ?? {})).toEqual([]) + // there is only one boolean domain (no boolean_eq) + expect(bareDomains.filter((d) => d.startsWith('boolean'))).toEqual(['boolean']) + }) + + it('the BigInt family IS exposed (base/_eq/_ord), *OrdOre is not', () => { + const exposedBare = EXPOSED_DOMAIN_ENTRIES.map(([, m]) => m.bareDomain) + expect(exposedBare).toEqual(expect.arrayContaining(['bigint', 'bigint_eq', 'bigint_ord'])) + expect(exposedBare).not.toContain('bigint_ord_ore') + expect(exposedBare.some((b) => b.endsWith('_ord_ore'))).toBe(false) + }) + + it('native types are public.* while operator SQL is eql_v3.* (schema split not collapsed)', () => { + for (const [, meta] of V3_DOMAIN_META_BY_CODEC_ID) { + expect(meta.nativeType.startsWith('public.')).toBe(true) + expect(meta.nativeType.startsWith('eql_v3.')).toBe(false) + } + }) +}) +``` + +- [ ] **Step 6: Run and commit** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/constants-v3 v3/catalog-invariants` +Expected: PASS. + +```bash +git add packages/prisma-next/src/extension-metadata/constants-v3.ts packages/prisma-next/test/v3/constants-v3.test.ts packages/prisma-next/test/v3/catalog-invariants.test.ts +git commit -m "feat(prisma-next): v3 codec-id closed union, traits, invariants, drift test" +``` + +--- + +### Task 3: Authoring surface — derived per-domain constructors + `*V2` aliases + +Rewrite `contract-authoring.ts` (PSL) and `exports/column-types.ts` (TS factories) so every exposed v3 domain gets one concrete constructor (static codec id + `public.*` native type + `{ castAs, capabilities }` typeParams, NO options), derived from `EXPOSED_DOMAIN_ENTRIES`. Rename the six pre-existing v2 constructors to their `*V2` forms (keeping v2 codec ids + `eql_v2_encrypted`). The PSL constructor name and the TS factory name are the mechanical `Encrypted` / `encrypted` transform of the bare domain. + +**Files:** +- Modify: `packages/prisma-next/src/contract-authoring.ts` +- Modify: `packages/prisma-next/src/exports/column-types.ts` +- Test: `packages/prisma-next/test/authoring.test.ts` (add/retarget v3 + V2 cases), `packages/prisma-next/test/column-types.test.ts` (add v3 + V2 cases) + +**Interfaces:** +- Consumes: Task 1 `EXPOSED_DOMAIN_ENTRIES`, `V3DomainMeta`; existing v2 `CIPHERSTASH_STRING_CODEC_ID`, `CIPHERSTASH_DOUBLE_CODEC_ID`, `CIPHERSTASH_BIGINT_CODEC_ID`, `CIPHERSTASH_DATE_CODEC_ID`, `CIPHERSTASH_BOOLEAN_CODEC_ID`, `CIPHERSTASH_JSON_CODEC_ID`, `EQL_V2_ENCRYPTED_TYPE` (`./extension-metadata/constants`); framework `AuthoringTypeNamespace` (`@prisma-next/framework-components/authoring`). +- Produces: + - `function v3PascalName(bareDomain: string): string` / `function v3CamelName(bareDomain: string): string` (shared name transform; exported from `contract-authoring.ts` for reuse by `column-types.ts` and tests). + - PSL: `cipherstashAuthoringTypes.cipherstash` carrying one constructor per exposed domain (`EncryptedText`, `EncryptedTextEq`, `EncryptedTextOrd`, `EncryptedTextMatch`, `EncryptedTextSearch`, `EncryptedInteger`/`Eq`/`Ord`, `EncryptedSmallint`/`Eq`/`Ord`, `EncryptedBigInt`/`Eq`/`Ord`, `EncryptedNumeric`/`Eq`/`Ord`, `EncryptedReal`/`Eq`/`Ord`, `EncryptedDouble`/`Eq`/`Ord`, `EncryptedDate`/`Eq`/`Ord`, `EncryptedTimestamp`/`Eq`/`Ord`, `EncryptedBoolean`) plus v2 aliases `EncryptedStringV2`, `EncryptedDoubleV2`, `EncryptedBigIntV2`, `EncryptedDateV2`, `EncryptedBooleanV2`, `EncryptedJsonV2`. + - TS: one factory per exposed domain (`encryptedTextSearch`, `encryptedIntegerOrd`, `encryptedBigIntOrd`, `encryptedBoolean`, …) returning `{ codecId, nativeType, typeParams: { castAs, capabilities } }`, plus `encryptedStringV2`, `encryptedDoubleV2`, `encryptedBigIntV2`, `encryptedDateV2`, `encryptedBooleanV2`, `encryptedJsonV2`. + +- [ ] **Step 0: Confirm the framework accepts static (non-arg) typeParams** + +The v2 constructors use `{ kind: 'arg', ... }` typeParam nodes because they take options. v3 constructors take no options, so their `typeParams` are static literal values (`castAs: 'string'`, `capabilities: { equality: true, ... }`). Confirm the framework serializes static literal typeParam values: + +Run: `sed -n '1,80p' packages/prisma-next/node_modules/@prisma-next/framework-components/dist/authoring.d.ts 2>/dev/null || grep -rn "typeParams" packages/prisma-next/src/contract-authoring.ts` +Expected: `typeParams` values are `unknown`/JSON — static literals are allowed. If the framework requires `{ kind: 'arg' | 'literal', ... }` node wrappers, wrap each static value as `{ kind: 'literal', value }` in Step 2 (adjust both the PSL emit and the Step-1 test expectation together). Record the confirmed shape in a comment atop `contract-authoring.ts`. + +- [ ] **Step 1: Write the failing test (PSL authoring outputs, derived 1:1)** + +Replace the v2 authoring assertions in `packages/prisma-next/test/authoring.test.ts` with the v3 + V2 surface: + +```ts +import { EXPOSED_DOMAIN_ENTRIES } from '../src/v3/catalog' +import { cipherstashAuthoringTypes, v3PascalName } from '../src/contract-authoring' + +describe('cipherstash v3 authoring (concrete per-domain, static descriptors)', () => { + const ns = cipherstashAuthoringTypes.cipherstash as Record }> + + it('emits exactly one constructor per exposed domain, matching the catalog values', () => { + for (const [codecId, meta] of EXPOSED_DOMAIN_ENTRIES) { + const name = v3PascalName(meta.bareDomain) + const ctor = ns[name] + expect(ctor, `missing constructor ${name}`).toBeDefined() + expect(ctor.output).toMatchObject({ + codecId, + nativeType: meta.nativeType, + typeParams: { castAs: meta.castAs, capabilities: meta.capabilities }, + }) + } + }) + + it('EncryptedTextSearch → public.text_search with full capabilities', () => { + expect(ns.EncryptedTextSearch.output).toMatchObject({ + codecId: 'cipherstash/eql-v3/text_search@1', + nativeType: 'public.text_search', + typeParams: { + castAs: 'string', + capabilities: { equality: true, orderAndRange: true, freeTextSearch: true }, + }, + }) + }) + + it('EncryptedBoolean → storage-only public.boolean (no equality constructor exists)', () => { + expect(ns.EncryptedBoolean.output).toMatchObject({ + codecId: 'cipherstash/eql-v3/boolean@1', + nativeType: 'public.boolean', + typeParams: { castAs: 'boolean', capabilities: { equality: false, orderAndRange: false, freeTextSearch: false } }, + }) + expect(ns.EncryptedBooleanEq).toBeUndefined() + }) + + it('BigInt family is present (public.bigint*, cast_as bigint); no *OrdOre, no v3 Json/String', () => { + expect(ns.EncryptedBigInt.output).toMatchObject({ nativeType: 'public.bigint', typeParams: { castAs: 'bigint' } }) + expect(ns.EncryptedBigIntEq.output).toMatchObject({ nativeType: 'public.bigint_eq' }) + expect(ns.EncryptedBigIntOrd.output).toMatchObject({ nativeType: 'public.bigint_ord' }) + expect(ns.EncryptedBigIntOrdOre).toBeUndefined() + expect(ns.EncryptedIntegerOrdOre).toBeUndefined() + expect(ns.EncryptedTextOrdOre).toBeUndefined() + expect(ns.EncryptedJson).toBeUndefined() // no v3 searchable JSON + expect(ns.EncryptedString).toBeUndefined() // v3 text uses EncryptedText* + }) + + it('*V2 aliases keep v2 codec ids + eql_v2_encrypted', () => { + expect(ns.EncryptedStringV2.output).toMatchObject({ codecId: 'cipherstash/string@1', nativeType: 'eql_v2_encrypted' }) + expect(ns.EncryptedJsonV2.output).toMatchObject({ codecId: 'cipherstash/json@1', nativeType: 'eql_v2_encrypted' }) + expect(ns.EncryptedBigIntV2.output).toMatchObject({ codecId: 'cipherstash/bigint@1', nativeType: 'eql_v2_encrypted' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @cipherstash/prisma-next test -- authoring` +Expected: FAIL — `v3PascalName` / constructors undefined. + +- [ ] **Step 3: Write the PSL implementation (derived)** + +Replace `packages/prisma-next/src/contract-authoring.ts` with: + +```ts +import type { AuthoringTypeNamespace } from '@prisma-next/framework-components/authoring' +import { + CIPHERSTASH_BIGINT_CODEC_ID, + CIPHERSTASH_BOOLEAN_CODEC_ID, + CIPHERSTASH_DATE_CODEC_ID, + CIPHERSTASH_DOUBLE_CODEC_ID, + CIPHERSTASH_JSON_CODEC_ID, + CIPHERSTASH_STRING_CODEC_ID, + EQL_V2_ENCRYPTED_TYPE, +} from './extension-metadata/constants' +import { EXPOSED_DOMAIN_ENTRIES, type V3DomainMeta } from './v3/catalog' + +// The stem of a bare domain maps to a JS-friendly display label; the suffix +// after the first `_` names the capability tier. `bigint` deliberately renders +// as `BigInt` (capital I) while `smallint` stays `Smallint` — this table is the +// single source for both PSL (`Encrypted…`) and TS (`encrypted…`) names. +const STEM_LABEL: Record = { + text: 'Text', + integer: 'Integer', + smallint: 'Smallint', + bigint: 'BigInt', + numeric: 'Numeric', + real: 'Real', + double: 'Double', + date: 'Date', + timestamp: 'Timestamp', + boolean: 'Boolean', +} +const SUFFIX_LABEL: Record = { + '': '', + eq: 'Eq', + ord: 'Ord', + match: 'Match', + search: 'Search', +} + +function coreName(bareDomain: string): string { + const i = bareDomain.indexOf('_') + const stem = i === -1 ? bareDomain : bareDomain.slice(0, i) + const suffix = i === -1 ? '' : bareDomain.slice(i + 1) + const stemLabel = STEM_LABEL[stem] + const suffixLabel = SUFFIX_LABEL[suffix] + if (stemLabel === undefined || suffixLabel === undefined) { + throw new Error( + `contract-authoring: cannot name exposed domain "${bareDomain}" — extend STEM_LABEL/SUFFIX_LABEL.`, + ) + } + return `${stemLabel}${suffixLabel}` +} + +/** `text_search` → `EncryptedTextSearch`, `bigint_ord` → `EncryptedBigIntOrd`. */ +export function v3PascalName(bareDomain: string): string { + return `Encrypted${coreName(bareDomain)}` +} +/** `text_search` → `encryptedTextSearch`, `bigint_ord` → `encryptedBigIntOrd`. */ +export function v3CamelName(bareDomain: string): string { + return `encrypted${coreName(bareDomain)}` +} + +function v3Constructor(codecId: string, meta: V3DomainMeta) { + return { + kind: 'typeConstructor', + args: [], + output: { + codecId, + nativeType: meta.nativeType, + // Static, not `{ kind: 'arg' }`: there are no options — the constructor + // IS the capability set. (If the framework requires literal-node + // wrappers, wrap these two values per Step 0.) + typeParams: { castAs: meta.castAs, capabilities: meta.capabilities }, + }, + } as const +} + +// --- v2 legacy aliases (verbatim old v2 outputs, renamed to *V2) --- +const v2StringOptions = { + kind: 'object', + name: 'options', + optional: true, + properties: { + equality: { kind: 'boolean', optional: true }, + freeTextSearch: { kind: 'boolean', optional: true }, + orderAndRange: { kind: 'boolean', optional: true }, + }, +} as const +const argTrue = (path: string) => ({ kind: 'arg', index: 0, path: [path], default: true }) as const + +const v2String = { + kind: 'typeConstructor', + args: [v2StringOptions], + output: { + codecId: CIPHERSTASH_STRING_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + typeParams: { + equality: argTrue('equality'), + freeTextSearch: argTrue('freeTextSearch'), + orderAndRange: argTrue('orderAndRange'), + }, + }, +} as const + +const v2Scalar = (codecId: string) => + ({ + kind: 'typeConstructor', + args: [ + { + kind: 'object', + name: 'options', + optional: true, + properties: { + equality: { kind: 'boolean', optional: true }, + orderAndRange: { kind: 'boolean', optional: true }, + }, + }, + ], + output: { + codecId, + nativeType: EQL_V2_ENCRYPTED_TYPE, + typeParams: { equality: argTrue('equality'), orderAndRange: argTrue('orderAndRange') }, + }, + }) as const + +const v2Boolean = { + kind: 'typeConstructor', + args: [ + { kind: 'object', name: 'options', optional: true, properties: { equality: { kind: 'boolean', optional: true } } }, + ], + output: { + codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + typeParams: { equality: argTrue('equality') }, + }, +} as const + +const v2Json = { + kind: 'typeConstructor', + args: [ + { + kind: 'object', + name: 'options', + optional: true, + properties: { searchableJson: { kind: 'boolean', optional: true } }, + }, + ], + output: { + codecId: CIPHERSTASH_JSON_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + typeParams: { searchableJson: argTrue('searchableJson') }, + }, +} as const + +const v3Constructors: Record = Object.fromEntries( + EXPOSED_DOMAIN_ENTRIES.map(([codecId, meta]) => [v3PascalName(meta.bareDomain), v3Constructor(codecId, meta)]), +) + +export const cipherstashAuthoringTypes = { + cipherstash: { + ...v3Constructors, + // v2 legacy aliases + EncryptedStringV2: v2String, + EncryptedDoubleV2: v2Scalar(CIPHERSTASH_DOUBLE_CODEC_ID), + EncryptedBigIntV2: v2Scalar(CIPHERSTASH_BIGINT_CODEC_ID), + EncryptedDateV2: v2Scalar(CIPHERSTASH_DATE_CODEC_ID), + EncryptedBooleanV2: v2Boolean, + EncryptedJsonV2: v2Json, + }, +} as unknown as AuthoringTypeNamespace +``` + +> Dynamic construction loses the literal object type that `as const satisfies AuthoringTypeNamespace` gave the old file; the `as unknown as AuthoringTypeNamespace` cast is deliberate, and the 1:1 test in Step 1 is what enforces the shape at runtime. + +- [ ] **Step 4: Run the PSL authoring test** + +Run: `pnpm --filter @cipherstash/prisma-next test -- authoring` +Expected: PASS for the v3 + V2 cases. + +- [ ] **Step 5: Retarget pre-existing v2 assertions in `psl-interpretation*.test.ts`** + +Any assertion expecting the unqualified `EncryptedString`/`EncryptedDouble`/`EncryptedBigInt`/`EncryptedDate`/`EncryptedBoolean`/`EncryptedJson` to lower to `cipherstash/*@1` + `eql_v2_encrypted` must be retargeted to the `*V2` name (or moved to a v3 expectation where the unqualified name is now a v3 domain). + +> **0.14:** extend the existing `interpret()` / `asStorage()` harnesses in `psl-interpretation*.test.ts` — they already carry the 0.14 requirements (target ref with `defaultNamespaceId: 'public'`, the `composedExtensionContracts` map keyed by the cipherstash space id, and namespace-envelope reads via `entries.table`). Interpreted tables land under the `public` namespace (ground-truth note 4). Do not fork a pre-0.13 harness shape for the v3 cases. + +Run: `pnpm --filter @cipherstash/prisma-next test -- psl-interpretation` +Expected: the failing assertions name the exact lines; update each to `EncryptedStringV2` / `EncryptedDoubleV2` / etc. + +- [ ] **Step 6: Write the failing TS-factory test** + +Add to `packages/prisma-next/test/column-types.test.ts`: + +```ts +import * as columnTypes from '../src/exports/column-types' +import { EXPOSED_DOMAIN_ENTRIES } from '../src/v3/catalog' +import { v3CamelName } from '../src/contract-authoring' + +describe('v3 TS factories', () => { + it('one factory per exposed domain, returning the concrete descriptor', () => { + for (const [codecId, meta] of EXPOSED_DOMAIN_ENTRIES) { + const fn = (columnTypes as Record unknown>)[v3CamelName(meta.bareDomain)] + expect(fn, `missing factory ${v3CamelName(meta.bareDomain)}`).toBeTypeOf('function') + expect(fn()).toEqual({ + codecId, + nativeType: meta.nativeType, + typeParams: { castAs: meta.castAs, capabilities: meta.capabilities }, + }) + } + }) + + it('encryptedBigIntOrd() emits public.bigint_ord with bigint castAs', () => { + expect(columnTypes.encryptedBigIntOrd()).toMatchObject({ + codecId: 'cipherstash/eql-v3/bigint_ord@1', + nativeType: 'public.bigint_ord', + typeParams: { castAs: 'bigint' }, + }) + }) + + it('encryptedStringV2() keeps the v2 codec id', () => { + expect(columnTypes.encryptedStringV2()).toEqual({ + codecId: 'cipherstash/string@1', + nativeType: 'eql_v2_encrypted', + typeParams: { equality: true, freeTextSearch: true, orderAndRange: true }, + }) + }) +}) +``` + +- [ ] **Step 7: Implement the TS factories** + +In `packages/prisma-next/src/exports/column-types.ts`, derive a descriptor record and expose one named factory per exposed domain plus the `*V2` aliases. Add the imports and this block; keep the existing option/descriptor interfaces for the `*V2` return shapes. + +```ts +import { + type AnyEncryptedV3Column, + type PlaintextKind, + stripDomainSchema, + types, +} from '@cipherstash/stack/eql/v3' + +type StripPublic = T extends `public.${infer D}` ? D : never + +/** + * Literal-preserving authoring descriptor, generic over the concrete stack + * column `C`. `codecId`, `nativeType`, and `capabilities` are the domain's + * LITERAL types (not `string` / `unknown`), so two domains' factories are + * mutually non-assignable — this is the type spine (design "Type Discipline"). + * `castAs` is the one field the stack widens (`build(): ColumnSchema`), which is + * harmless: `nativeType` already discriminates every domain. Pinned by the + * `column-types.test-d.ts` type-level tests (Task: type-level). + */ +export interface V3ColumnDescriptor { + readonly codecId: `cipherstash/eql-v3/${StripPublic>}@1` + readonly nativeType: ReturnType + readonly typeParams: { + readonly castAs: PlaintextKind + readonly capabilities: ReturnType + } +} + +/** + * Build one authoring factory from a concrete stack `types.*` factory. VALUES + * are read from the column (`getEqlType` / `getQueryCapabilities` / `build`) — + * nothing is hardcoded — while the literal TYPES flow from `C`. The factory set + * is a typed 1:1 mirror of the exposed catalog domains, which is how "derive + * from the catalog" and "distinct static type per domain" coexist: TypeScript + * cannot hand distinct static types to values pulled from a runtime record, so + * each factory names its stack factory. Drift is caught by the Step-1 1:1 test + * (exposed factory set === registry minus `*_ord_ore`) and Task 2's drift test. + */ +function v3Authored( + make: (name: string) => C, +): () => V3ColumnDescriptor { + const probe = make('__probe__') + const nativeType = probe.getEqlType() + const descriptor = { + codecId: `cipherstash/eql-v3/${stripDomainSchema(nativeType)}@1`, + nativeType, + typeParams: { + castAs: probe.build().cast_as as PlaintextKind, + capabilities: probe.getQueryCapabilities(), + }, + } as V3ColumnDescriptor + return () => descriptor +} + +// Each factory has a DISTINCT return type (V3ColumnDescriptor); +// `encryptedBigIntOrd()` is not assignable to `encryptedText()` and vice-versa. +// Note the stack factory names: bigint is `types.Bigint*` (not `BigInt`). +// text family +export const encryptedText = v3Authored(types.Text) +export const encryptedTextEq = v3Authored(types.TextEq) +export const encryptedTextOrd = v3Authored(types.TextOrd) +export const encryptedTextMatch = v3Authored(types.TextMatch) +export const encryptedTextSearch = v3Authored(types.TextSearch) +// integer +export const encryptedInteger = v3Authored(types.Integer) +export const encryptedIntegerEq = v3Authored(types.IntegerEq) +export const encryptedIntegerOrd = v3Authored(types.IntegerOrd) +// smallint +export const encryptedSmallint = v3Authored(types.Smallint) +export const encryptedSmallintEq = v3Authored(types.SmallintEq) +export const encryptedSmallintOrd = v3Authored(types.SmallintOrd) +// bigint +export const encryptedBigInt = v3Authored(types.Bigint) +export const encryptedBigIntEq = v3Authored(types.BigintEq) +export const encryptedBigIntOrd = v3Authored(types.BigintOrd) +// numeric +export const encryptedNumeric = v3Authored(types.Numeric) +export const encryptedNumericEq = v3Authored(types.NumericEq) +export const encryptedNumericOrd = v3Authored(types.NumericOrd) +// real +export const encryptedReal = v3Authored(types.Real) +export const encryptedRealEq = v3Authored(types.RealEq) +export const encryptedRealOrd = v3Authored(types.RealOrd) +// double +export const encryptedDouble = v3Authored(types.Double) +export const encryptedDoubleEq = v3Authored(types.DoubleEq) +export const encryptedDoubleOrd = v3Authored(types.DoubleOrd) +// date +export const encryptedDate = v3Authored(types.Date) +export const encryptedDateEq = v3Authored(types.DateEq) +export const encryptedDateOrd = v3Authored(types.DateOrd) +// timestamp +export const encryptedTimestamp = v3Authored(types.Timestamp) +export const encryptedTimestampEq = v3Authored(types.TimestampEq) +export const encryptedTimestampOrd = v3Authored(types.TimestampOrd) +// boolean (storage-only) +export const encryptedBoolean = v3Authored(types.Boolean) +``` + +> Note: `V3ColumnDescriptor` widens to the framework's `ColumnTypeDescriptor` (whose `codecId` is `string`) at the framework authoring boundary — that widening is fine at the edge; prisma-next's own factories and the `.test-d.ts` retain the literals. If `PlaintextKind` is not re-exported from `@cipherstash/stack/eql/v3`, add it to that barrel (it is the `build().cast_as` union) rather than falling back to `string`. + +Add the MANDATORY type-level test (design "Type Discipline" — a runtime suite does not protect the spine). Create `packages/prisma-next/test/v3/column-types.test-d.ts`: + +```ts +import { expectTypeOf } from 'vitest' +import type { QueryCapabilities } from '@cipherstash/stack/eql/v3' +import { + encryptedBigIntOrd, + encryptedText, + encryptedTextEq, +} from '../../src/exports/column-types' + +// 1. Factories are mutually non-assignable — distinct type per domain. +expectTypeOf(encryptedTextEq()).not.toMatchTypeOf(encryptedText()) +expectTypeOf(encryptedBigIntOrd()).not.toMatchTypeOf(encryptedText()) +expectTypeOf(encryptedText()).not.toMatchTypeOf(encryptedBigIntOrd()) + +// 2. codecId is the literal union member, not `string`. +expectTypeOf(encryptedTextEq().codecId).toEqualTypeOf<'cipherstash/eql-v3/text_eq@1'>() +expectTypeOf(encryptedBigIntOrd().codecId).toEqualTypeOf<'cipherstash/eql-v3/bigint_ord@1'>() + +// 3. nativeType is the `public.*` literal, not `string`. +expectTypeOf(encryptedTextEq().nativeType).toEqualTypeOf<'public.text_eq'>() +expectTypeOf(encryptedBigIntOrd().nativeType).toEqualTypeOf<'public.bigint_ord'>() + +// 4. capabilities is (at least) the concrete QueryCapabilities, never `unknown`. +// (If the stack types each domain's capabilities as literal booleans, this +// also narrows further — the point is it is NOT `unknown`.) +expectTypeOf(encryptedTextEq().typeParams.capabilities).toMatchTypeOf() +expectTypeOf(encryptedTextEq().typeParams.capabilities).not.toBeUnknown() +``` + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/column-types.test-d` (vitest typecheck) → PASS. + +Rename the six existing factories to their `*V2` names, keeping their previous bodies verbatim (they still return `{ codecId: CIPHERSTASH_*_CODEC_ID, nativeType: EQL_V2_ENCRYPTED_TYPE, typeParams: {...} }`): + +```ts +export function encryptedStringV2(options: EncryptedStringOptions = {}) { /* previous encryptedString body */ } +export function encryptedDoubleV2(options: EncryptedNumericOptions = {}) { /* previous encryptedDouble body */ } +export function encryptedBigIntV2(options: EncryptedNumericOptions = {}) { /* previous encryptedBigInt body */ } +export function encryptedDateV2(options: EncryptedDateOptions = {}) { /* previous encryptedDate body */ } +export function encryptedBooleanV2(options: EncryptedBooleanOptions = {}) { /* previous encryptedBoolean body */ } +export function encryptedJsonV2(options: EncryptedJsonOptions = {}) { /* previous encryptedJson body */ } +``` + +If any barrel (`exports/index.ts`) re-exported the old `encryptedString`/`encryptedDouble`/… by name, update those re-exports to the `*V2` names (and add the new v3 names). Run the build to surface any dangling re-export. + +- [ ] **Step 8: Run and commit** + +Run: `pnpm --filter @cipherstash/prisma-next test -- authoring psl-interpretation column-types && pnpm --filter @cipherstash/prisma-next build` +Expected: PASS + build clean. + +```bash +git add packages/prisma-next/src/contract-authoring.ts packages/prisma-next/src/exports/column-types.ts packages/prisma-next/test/authoring.test.ts packages/prisma-next/test/column-types.test.ts packages/prisma-next/test/psl-interpretation*.test.ts +git commit -m "feat(prisma-next): derived v3 per-domain constructors + V2 aliases" +``` + +--- + +### Task 4: Runtime codecs — `EncryptedNumber` + plain-JSONB v3 cell codec + +Create the `EncryptedNumber` envelope (a **sibling** of `EncryptedDouble`, distinct `typeName`), the plain-JSONB `v3ToDriver`/`v3FromDriver`, the SDK-bound v3 cell codec + per-domain descriptors (native type = the concrete `public.*` domain), and the v3 barrel that surfaces the neutral value classes (`EncryptedString`, `EncryptedDate`, `EncryptedBoolean`, the existing `EncryptedBigInt`) plus `EncryptedNumber`. v3 codecs depend only on the neutral `EncryptedEnvelopeBase`; they never import the v2 composite/wire codec. + +**Files:** +- Create: `packages/prisma-next/src/v3/envelope-number.ts`, `packages/prisma-next/src/v3/wire-v3.ts`, `packages/prisma-next/src/v3/codec-runtime-v3.ts`, `packages/prisma-next/src/v3/barrel.ts` +- Test: `packages/prisma-next/test/v3/envelope-number.test.ts`, `packages/prisma-next/test/v3/wire-v3.test.ts`, `packages/prisma-next/test/v3/codec-runtime-v3.test.ts` + +**Interfaces:** +- Consumes: `EncryptedEnvelopeBase`, `EncryptedEnvelopeFromInternalArgs`, `EncryptedEnvelopeHandle` (`../execution/envelope-base`); neutral value classes `EncryptedString` (`../execution/envelope-string`), `EncryptedDate` (`../execution/envelope-date`), `EncryptedBoolean` (`../execution/envelope-boolean`), `EncryptedBigInt` (`../execution/envelope-bigint`); Task 1 `V3_DOMAIN_META_BY_CODEC_ID`, `V3DomainMeta`, `V3CastAs`, `envelopeTypeNameForCastAs`; Task 2 `CipherstashV3CodecId`, `v3TraitsForCapabilities`; `CipherstashSdk` (`../execution/sdk`); framework codec infra. +- Produces: + - `class EncryptedNumber extends EncryptedEnvelopeBase` (`typeName = 'EncryptedNumber'`, `static from`, `static fromInternal`). + - `function v3ToDriver(value: unknown): string | null`; `function v3FromDriver(value: string | object | null | undefined): T`. + - `class CipherstashV3CellCodec`; `function createV3CodecDescriptors(sdk): ReadonlyArray>` (one per catalog domain; `nativeType`/`targetTypes` = the concrete `public.*` domain; traits from capabilities). + - `barrel.ts` re-exporting `EncryptedNumber`, `EncryptedString`, `EncryptedDate`, `EncryptedBoolean`, `EncryptedBigInt`. + +- [ ] **Step 1: Write the failing test for `EncryptedNumber`** + +Create `packages/prisma-next/test/v3/envelope-number.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { EncryptedDouble } from '../../src/execution/envelope-double' +import { EncryptedNumber } from '../../src/v3/envelope-number' + +describe('EncryptedNumber', () => { + it('is a distinct sibling of EncryptedDouble (no cross-instanceof leak)', () => { + const n = EncryptedNumber.from(42) + expect(n).toBeInstanceOf(EncryptedNumber) + expect(n instanceof EncryptedDouble).toBe(false) + expect(EncryptedDouble.from(42) instanceof EncryptedNumber).toBe(false) + }) + it('renders the $encryptedNumber placeholder marker (distinct typeName)', () => { + expect(JSON.stringify(EncryptedNumber.from(7))).toBe('{"$encryptedNumber":""}') + }) + it('redacts and round-trips plaintext via decrypt() fast path', async () => { + const n = EncryptedNumber.from(3.14) + expect(String(n)).toBe('[REDACTED]') + expect(await n.decrypt()).toBe(3.14) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/envelope-number` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `EncryptedNumber`** + +Create `packages/prisma-next/src/v3/envelope-number.ts` (mirror `execution/envelope-double.ts` byte-for-byte, changing only the class name + `typeName`): + +```ts +import { + EncryptedEnvelopeBase, + type EncryptedEnvelopeFromInternalArgs, + type EncryptedEnvelopeHandle, +} from '../execution/envelope-base' + +export type EncryptedNumberHandle = EncryptedEnvelopeHandle +export type EncryptedNumberFromInternalArgs = EncryptedEnvelopeFromInternalArgs + +/** + * v3 number envelope. A SIBLING of `EncryptedDouble` — NOT a subclass or alias. + * Its distinct `typeName` drives the `$encryptedNumber` marker; keeping the + * class separate prevents `value instanceof EncryptedNumber` from matching a v2 + * `EncryptedDouble` (a cross-version leak). Integer/smallint/numeric/real/double + * v3 domains all render as `EncryptedNumber`. + */ +export class EncryptedNumber extends EncryptedEnvelopeBase { + protected override get typeName(): string { + return 'EncryptedNumber' + } + + static from(plaintext: number): EncryptedNumber { + return new EncryptedNumber({ plaintext, ciphertext: undefined, table: undefined, column: undefined, sdk: undefined }) + } + + static fromInternal(args: EncryptedNumberFromInternalArgs): EncryptedNumber { + return new EncryptedNumber({ + plaintext: undefined, + ciphertext: args.ciphertext, + table: args.table, + column: args.column, + sdk: args.sdk, + }) + } +} +``` + +> Confirm the `typeName` accessor visibility + the `from`/`fromInternal` argument object against the actual `envelope-double.ts` before finalizing; match it exactly. + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/envelope-number` +Expected: PASS (3 tests). + +- [ ] **Step 5: Write + implement `wire-v3.ts` (TDD)** + +Create `packages/prisma-next/test/v3/wire-v3.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { v3FromDriver, v3ToDriver } from '../../src/v3/wire-v3' + +describe('v3 wire (plain JSONB)', () => { + it('serialises to JSON text, never the v2 composite literal', () => { + expect(v3ToDriver({ a: 1 })).toBe('{"a":1}') + expect(v3ToDriver({ a: 1 })).not.toMatch(/^\(/) + }) + it('null/undefined -> null', () => { + expect(v3ToDriver(null)).toBeNull() + expect(v3ToDriver(undefined)).toBeNull() + }) + it('bigintSafeReplacer: a stray bigint in a malformed envelope serialises to its decimal string, not a throw', () => { + // A well-formed envelope never carries a bigint; this pins the defensive + // guard so the codec never throws "Do not know how to serialize a BigInt". + expect(v3ToDriver({ c: 10n })).toBe('{"c":"10"}') + expect(() => v3ToDriver({ c: 9007199254740993n })).not.toThrow() + }) + it('fromDriver parses text, passes objects through, preserves null/undefined', () => { + expect(v3FromDriver('{"a":1}')).toEqual({ a: 1 }) + expect(v3FromDriver({ a: 1 } as object)).toEqual({ a: 1 }) + expect(v3FromDriver(null)).toBeNull() + expect(v3FromDriver(undefined)).toBeUndefined() + }) +}) +``` + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/wire-v3` → FAIL. Then create `packages/prisma-next/src/v3/wire-v3.ts`: + +```ts +/** + * v3 columns are `CREATE DOMAIN ... AS jsonb`, so they serialise as plain jsonb + * — distinct from v2's composite-literal wire. + * + * NOTE: this is a thin re-export of the SHARED, framework-neutral codec lifted + * to `@cipherstash/stack/eql/v3` (`v3ToDriver` / `v3FromDriver` / + * `bigintSafeReplacer`) — see the shared-primitive-lift task. It is reproduced + * here only to show the exact behaviour under test; the implementation must + * `import { v3ToDriver, v3FromDriver } from '@cipherstash/stack/eql/v3'` rather + * than fork a second copy. + */ + +// `bigintSafeReplacer` is LOAD-BEARING and must not be dropped: a well-formed +// envelope never carries a bigint (bigint plaintext is encrypted to ciphertext +// strings first), but a malformed envelope with a stray bigint would otherwise +// throw `TypeError: Do not know how to serialize a BigInt`. Dropping it is a +// regression (ref `eql/v3/drizzle/codec.ts:18,28`). +function bigintSafeReplacer(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? value.toString() : value +} + +export function v3ToDriver(value: unknown): string | null { + if (value === null || value === undefined) return null + return JSON.stringify(value, bigintSafeReplacer) +} + +export function v3FromDriver(value: string | object | null | undefined): T { + if (value === null || value === undefined) return value as T + if (typeof value === 'object') return value as T + return JSON.parse(value) as T +} +``` + +Run again → PASS. + +- [ ] **Step 6: Write + implement the v3 cell codec + descriptors (TDD)** + +Create `packages/prisma-next/test/v3/codec-runtime-v3.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest' +import type { CipherstashSdk } from '../../src/execution/sdk' +import { createV3CodecDescriptors } from '../../src/v3/codec-runtime-v3' + +const emptySdk = (): CipherstashSdk => ({ + decrypt: vi.fn(), + bulkEncrypt: vi.fn(), + bulkDecrypt: vi.fn(), +}) + +describe('createV3CodecDescriptors', () => { + it('emits one descriptor per catalog domain; native type = the public.* domain', () => { + const ds = createV3CodecDescriptors(emptySdk()) + const textSearch = ds.find((d) => d.codecId === 'cipherstash/eql-v3/text_search@1') + expect(textSearch).toBeDefined() + expect(textSearch?.targetTypes).toEqual(['public.text_search']) + expect(textSearch?.meta).toEqual({ db: { sql: { postgres: { nativeType: 'public.text_search' } } } }) + expect(textSearch?.renderOutputType?.({} as never)).toBe('EncryptedString') + }) + + it('storage-only public.boolean declares no traits; text_search declares all three', () => { + const ds = createV3CodecDescriptors(emptySdk()) + expect(ds.find((d) => d.codecId === 'cipherstash/eql-v3/boolean@1')?.traits).toEqual([]) + expect([...(ds.find((d) => d.codecId === 'cipherstash/eql-v3/text_search@1')?.traits ?? [])].sort()).toEqual([ + 'cipherstash:equality', + 'cipherstash:free-text-search', + 'cipherstash:order-and-range', + ]) + }) + + it('number domains render EncryptedNumber; bigint domains render EncryptedBigInt', () => { + const ds = createV3CodecDescriptors(emptySdk()) + expect(ds.find((d) => d.codecId === 'cipherstash/eql-v3/integer_ord@1')?.renderOutputType?.({} as never)).toBe( + 'EncryptedNumber', + ) + expect(ds.find((d) => d.codecId === 'cipherstash/eql-v3/bigint_ord@1')?.renderOutputType?.({} as never)).toBe( + 'EncryptedBigInt', + ) + }) + + it('encode serialises an envelope\'s ciphertext to plain JSONB text', async () => { + const ds = createV3CodecDescriptors(emptySdk()) + const desc = ds.find((d) => d.codecId === 'cipherstash/eql-v3/text_eq@1')! + const codec = desc.factory({} as never)({} as never) + // `encode` operates on an ENVELOPE (with `.expose()`), not a plain object. + // A set ciphertext serialises via v3ToDriver to JSONB text. + const envelope = { expose: () => ({ ciphertext: { c: 'abc' } }) } + const encoded = await codec.encode(envelope as never, {} as never) + expect(encoded).toBe('{"c":"abc"}') + }) + + it('encode passes a pre-serialised JSONB string through unchanged (middleware two-pass sentinel)', async () => { + const ds = createV3CodecDescriptors(emptySdk()) + const codec = ds.find((d) => d.codecId === 'cipherstash/eql-v3/text_eq@1')!.factory({} as never)({} as never) + expect(await codec.encode('{"c":"enc"}' as never, {} as never)).toBe('{"c":"enc"}') + }) + + it('encode returns the value unchanged when the envelope has no ciphertext yet (pre-encrypt)', async () => { + const ds = createV3CodecDescriptors(emptySdk()) + const codec = ds.find((d) => d.codecId === 'cipherstash/eql-v3/text_eq@1')!.factory({} as never)({} as never) + const preEncrypt = { expose: () => ({ ciphertext: undefined }) } + expect(await codec.encode(preEncrypt as never, {} as never)).toBe(preEncrypt) + }) +}) +``` + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/codec-runtime-v3` → FAIL. Then create `packages/prisma-next/src/v3/codec-runtime-v3.ts`: + +```ts +import type { JsonValue } from '@prisma-next/contract/types' +import { + type AnyCodecDescriptor, + CodecImpl, + type CodecInstanceContext, + type CodecTrait, +} from '@prisma-next/framework-components/codec' +import { runtimeError } from '@prisma-next/framework-components/runtime' +import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast' +import type { RuntimeParameterizedCodecDescriptor } from '@prisma-next/sql-runtime' +import { type as arktype } from 'arktype' +import { type CipherstashV3CodecId, v3TraitsForCapabilities } from '../extension-metadata/constants-v3' +import { EncryptedBigInt } from '../execution/envelope-bigint' +import type { EncryptedEnvelopeBase } from '../execution/envelope-base' +import { EncryptedBoolean } from '../execution/envelope-boolean' +import { EncryptedDate } from '../execution/envelope-date' +import { EncryptedString } from '../execution/envelope-string' +import type { CipherstashSdk } from '../execution/sdk' +import { + envelopeTypeNameForCastAs, + type V3CastAs, + type V3DomainMeta, + V3_DOMAIN_META_BY_CODEC_ID, +} from './catalog' +import { EncryptedNumber } from './envelope-number' +import { v3FromDriver, v3ToDriver } from './wire-v3' + +type FromInternal = (args: { + ciphertext: unknown + table: string + column: string + sdk: CipherstashSdk +}) => EncryptedEnvelopeBase + +function fromInternalForCastAs(castAs: V3CastAs): FromInternal { + switch (castAs) { + case 'string': + return EncryptedString.fromInternal + case 'number': + return EncryptedNumber.fromInternal + case 'bigint': + return EncryptedBigInt.fromInternal + case 'date': + case 'timestamp': + return EncryptedDate.fromInternal + case 'boolean': + return EncryptedBoolean.fromInternal + } +} + +export class CipherstashV3CellCodec> extends CodecImpl< + string, + readonly CodecTrait[], + unknown, + E +> { + readonly #fromInternal: FromInternal + readonly #typeName: string + constructor(descriptor: AnyCodecDescriptor, private readonly sdk: CipherstashSdk, typeName: string, fromInternal: FromInternal) { + super(descriptor) + this.#fromInternal = fromInternal + this.#typeName = typeName + } + + async encode(value: E | string, _ctx: SqlCodecCallContext): Promise { + // Two-pass: the v3 bulk-encrypt middleware replaces the param with the JSONB + // text before the driver reads it. If we still hold an envelope, serialise + // its stamped ciphertext (or return itself as the pre-encrypt sentinel). + if (typeof value === 'string') return value + if (value === null || value === undefined) return value + const handle = (value as E).expose() + if (handle.ciphertext === undefined) return value + return v3ToDriver(handle.ciphertext) + } + + async decode(wire: unknown, ctx: SqlCodecCallContext): Promise { + const column = ctx.column + if (!column) { + throw runtimeError( + 'RUNTIME.DECODE_FAILED', + `cipherstash ${this.descriptor.codecId}: decode requires the projected-column routing context.`, + { codecId: this.descriptor.codecId, reason: 'cipherstash-v3-decode-column-context-missing' }, + ) + } + return this.#fromInternal({ + ciphertext: v3FromDriver(wire as string | object | null | undefined), + table: column.table, + column: column.name, + sdk: this.sdk, + }) as E + } + + encodeJson(_v: E): JsonValue { + const marker = `$${this.#typeName.charAt(0).toLowerCase()}${this.#typeName.slice(1)}` + return { [marker]: '' } as JsonValue + } + decodeJson(): E { + throw new Error('cipherstash v3 codec: decodeJson is not supported') + } +} + +const V3_PARAMS_SCHEMA = arktype({ castAs: 'string', capabilities: 'object' }) + +function makeV3Descriptor( + sdk: CipherstashSdk, + codecId: CipherstashV3CodecId, + meta: V3DomainMeta, +): RuntimeParameterizedCodecDescriptor { + const typeName = envelopeTypeNameForCastAs(meta.castAs) + const descriptorMeta: AnyCodecDescriptor = { + codecId, + traits: v3TraitsForCapabilities(meta.capabilities) as unknown as readonly CodecTrait[], + targetTypes: [meta.nativeType], + meta: { db: { sql: { postgres: { nativeType: meta.nativeType } } } }, + paramsSchema: V3_PARAMS_SCHEMA, + isParameterized: true, + renderOutputType: () => typeName, + factory: () => () => codec, + } + const codec = new CipherstashV3CellCodec(descriptorMeta, sdk, typeName, fromInternalForCastAs(meta.castAs)) as CipherstashV3CellCodec< + EncryptedEnvelopeBase + > & + Codec> + return { + codecId, + traits: descriptorMeta.traits, + targetTypes: descriptorMeta.targetTypes, + meta: descriptorMeta.meta, + paramsSchema: V3_PARAMS_SCHEMA, + isParameterized: true as const, + renderOutputType: () => typeName, + factory: (_p: unknown) => (_ctx: CodecInstanceContext) => codec, + } +} + +export function createV3CodecDescriptors( + sdk: CipherstashSdk, +): ReadonlyArray> { + return [...V3_DOMAIN_META_BY_CODEC_ID.entries()].map(([codecId, meta]) => + makeV3Descriptor(sdk, codecId as CipherstashV3CodecId, meta), + ) +} +``` + +Then create `packages/prisma-next/src/v3/barrel.ts`: + +```ts +/** + * v3 user-facing value types, surfaced through the v3 subtree. Re-exports the + * version-neutral value classes plus the v3-only `EncryptedNumber`. Pulls in NO + * v2 wire/codec code. + */ +export { EncryptedBigInt } from '../execution/envelope-bigint' +export { EncryptedBoolean } from '../execution/envelope-boolean' +export { EncryptedDate } from '../execution/envelope-date' +export { EncryptedString } from '../execution/envelope-string' +export { EncryptedNumber } from './envelope-number' +``` + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/codec-runtime-v3` → PASS. (If arktype's `paramsSchema` type differs from the framework's `~standard` shape, mirror the exact `paramsSchema` object used in v2 `parameterized.ts`. Confirm `CodecImpl` / `AnyCodecDescriptor` / `runtimeError` import paths against the v2 codec-runtime module before finalizing.) + +- [ ] **Step 7: Commit** + +```bash +git add packages/prisma-next/src/v3/envelope-number.ts packages/prisma-next/src/v3/wire-v3.ts packages/prisma-next/src/v3/codec-runtime-v3.ts packages/prisma-next/src/v3/barrel.ts packages/prisma-next/test/v3/envelope-number.test.ts packages/prisma-next/test/v3/wire-v3.test.ts packages/prisma-next/test/v3/codec-runtime-v3.test.ts +git commit -m "feat(prisma-next): v3 EncryptedNumber + plain-JSONB cell codec + per-domain descriptors (public.* native types)" +``` + +--- + +### Task 5: v3 bulk-encrypt middleware (JSONB payloads) + +A separate v3 middleware that filters by the v3 codec-id set, stamps `(table, column)` routing keys via the **neutral** AST utility (reused from the v2 module — not the v2 encode path), groups by routing key, calls `sdk.bulkEncrypt`, and replaces params with **plain JSONB** ciphertext (never the v2 composite). + +**Files:** +- Modify: `packages/prisma-next/src/middleware/bulk-encrypt.ts` (export `stampRoutingKeysFromAst`) +- Create: `packages/prisma-next/src/v3/bulk-encrypt-v3.ts` +- Test: `packages/prisma-next/test/v3/bulk-encrypt-v3.test.ts` + +**Interfaces:** +- Consumes: neutral `stampRoutingKeysFromAst` (now exported from `../middleware/bulk-encrypt`), `setHandleCiphertext`, `EncryptedEnvelopeBase` (`../execution/envelope-base`); `groupByRoutingKey`, `BulkEncryptTarget` (`../execution/routing`); `CipherstashSdk` (`../execution/sdk`); `markBulkEncryptMiddlewareRegistered` (`../execution/middleware-registry`); Task 2 `CIPHERSTASH_V3_CODEC_ID_SET`; Task 4 `v3ToDriver`; abort helpers (`../execution/abort`). +- Produces: `function bulkEncryptMiddlewareV3(sdk: CipherstashSdk): SqlMiddleware` — `name: 'cipherstash.bulk-encrypt-v3'`, `familyId: 'sql'`. + +- [ ] **Step 1: Export the neutral AST-stamping util (refactor, no behavior change)** + +In `packages/prisma-next/src/middleware/bulk-encrypt.ts`, change `function stampRoutingKeysFromAst(...)` to `export function stampRoutingKeysFromAst(...)`. No other change. + +Run: `pnpm --filter @cipherstash/prisma-next test -- bulk-encrypt-middleware` +Expected: PASS (unchanged v2 behavior). + +- [ ] **Step 2: Write the failing test** + +Create `packages/prisma-next/test/v3/bulk-encrypt-v3.test.ts` (reuse the v2 harness; if `buildInsertPlan`/`createCtx`/`createSqlParamRefMutator` are not already exported from a shared helper, extract them into `packages/prisma-next/test/bulk-encrypt-middleware.helpers.ts` as a first sub-step and re-import from the v2 test too — pure refactor, run the v2 suite green afterward; `buildInsertPlan` gains a 3rd arg = the codec id stamped on each `ParamRef.of(value, { codec: { codecId } })`): + +```ts +import { describe, expect, it, vi } from 'vitest' +import type { CipherstashSdk } from '../../src/execution/sdk' +import { EncryptedString } from '../../src/execution/envelope-string' +import { bulkEncryptMiddlewareV3 } from '../../src/v3/bulk-encrypt-v3' +import { buildInsertPlan, createCtx, createSqlParamRefMutator } from '../bulk-encrypt-middleware.helpers' + +const V3_TEXT_SEARCH = 'cipherstash/eql-v3/text_search@1' + +function makeSdk(): CipherstashSdk & { calls: unknown[] } { + const calls: unknown[] = [] + return { + calls, + decrypt: vi.fn(), + bulkDecrypt: vi.fn(), + bulkEncrypt(args) { + calls.push(args) + return Promise.resolve(args.values.map((v) => ({ c: `enc:${String(v)}` }))) + }, + } +} + +describe('bulkEncryptMiddlewareV3', () => { + it('has the v3 identity', () => { + const m = bulkEncryptMiddlewareV3(makeSdk()) + expect(m.name).toBe('cipherstash.bulk-encrypt-v3') + expect(m.familyId).toBe('sql') + }) + + it('bulk-encrypts v3 params and writes plain JSONB (not the v2 composite)', async () => { + const sdk = makeSdk() + const m = bulkEncryptMiddlewareV3(sdk) + const env = EncryptedString.from('alice@example.com') + const plan = buildInsertPlan('user', [{ email: env }], V3_TEXT_SEARCH) + const params = createSqlParamRefMutator(plan) + await m.beforeExecute?.(plan, createCtx(), params) + expect(sdk.calls).toHaveLength(1) + const only = params.currentParams()[0] + expect(only).toBe('{"c":"enc:alice@example.com"}') + expect(only).not.toMatch(/^\(/) // never the composite literal + }) + + it('ignores v2-codec params (jurisdiction is the v3 id set only)', async () => { + const sdk = makeSdk() + const m = bulkEncryptMiddlewareV3(sdk) + const env = EncryptedString.from('x') + const plan = buildInsertPlan('user', [{ email: env }], 'cipherstash/string@1') + const params = createSqlParamRefMutator(plan) + await m.beforeExecute?.(plan, createCtx(), params) + expect(sdk.calls).toHaveLength(0) + }) +}) +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/bulk-encrypt-v3` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `bulk-encrypt-v3.ts`** + +Create `packages/prisma-next/src/v3/bulk-encrypt-v3.ts` (structure mirrors v2 `bulk-encrypt.ts`, but filters on the v3 set and writes `v3ToDriver`): + +```ts +import type { ParamRefHandle, SqlParamRefMutator } from '@prisma-next/sql-relational-core/middleware' +import type { SqlMiddleware } from '@prisma-next/sql-runtime' +import { ifDefined } from '@prisma-next/utils/defined' +import { CIPHERSTASH_V3_CODEC_ID_SET } from '../extension-metadata/constants-v3' +import { checkCipherstashAborted, raceCipherstashAbort } from '../execution/abort' +import { EncryptedEnvelopeBase, setHandleCiphertext } from '../execution/envelope-base' +import { markBulkEncryptMiddlewareRegistered } from '../execution/middleware-registry' +import { type BulkEncryptTarget, groupByRoutingKey } from '../execution/routing' +import type { CipherstashSdk } from '../execution/sdk' +// NEUTRAL util — reused, not the v2 encode path. +import { stampRoutingKeysFromAst } from '../middleware/bulk-encrypt' +import { v3ToDriver } from './wire-v3' + +export function bulkEncryptMiddlewareV3(sdk: CipherstashSdk): SqlMiddleware { + markBulkEncryptMiddlewareRegistered(sdk) + return { + name: 'cipherstash.bulk-encrypt-v3', + familyId: 'sql', + async beforeExecute(plan, ctx, params) { + if (!params) return + stampRoutingKeysFromAst(plan.ast) + const targets = collectV3Targets(params) + if (targets.length === 0) return + for (const [groupKey, group] of groupByRoutingKey(targets)) { + const first = group[0] + if (!first) continue + checkCipherstashAborted(ctx.signal, 'bulk-encrypt') + const ciphertexts = await raceCipherstashAbort( + sdk.bulkEncrypt({ + routingKey: first.routingKey, + values: group.map((t) => t.plaintext), + ...ifDefined('signal', ctx.signal), + }), + ctx.signal, + 'bulk-encrypt', + ) + if (ciphertexts.length !== group.length) { + throw new Error( + `cipherstash v3 bulk-encrypt: SDK returned ${ciphertexts.length} ciphertexts for routing key ${groupKey} but ${group.length} were requested.`, + ) + } + params.replaceValues( + group.map((t, i) => { + const ciphertext = ciphertexts[i] + setHandleCiphertext(t.envelope, ciphertext) + return { ref: t.ref, newValue: v3ToDriver(ciphertext) } + }), + ) + } + }, + } +} + +function collectV3Targets(params: SqlParamRefMutator): BulkEncryptTarget>[] { + const targets: BulkEncryptTarget>[] = [] + for (const entry of params.entries()) { + if (entry.codecId === undefined || !CIPHERSTASH_V3_CODEC_ID_SET.has(entry.codecId)) continue + const value = entry.value + if (!(value instanceof EncryptedEnvelopeBase)) continue + const handle = value.expose() + if (handle.plaintext === undefined) { + throw new Error( + 'cipherstash v3 bulk-encrypt: encountered a write-path envelope with no plaintext. Use `Encrypted*.from(plaintext)`.', + ) + } + if (handle.table === undefined || handle.column === undefined) { + throw new Error( + 'cipherstash v3 bulk-encrypt: envelope reached the bulk-encrypt phase without a (table, column) routing context.', + ) + } + targets.push({ + ref: entry.ref, + plaintext: handle.plaintext, + envelope: value, + routingKey: { table: handle.table, column: handle.column }, + }) + } + return targets +} +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/bulk-encrypt-v3 bulk-encrypt-middleware` +Expected: PASS (v3 suite + unchanged v2 suite). + +- [ ] **Step 6: Commit** + +```bash +git add packages/prisma-next/src/middleware/bulk-encrypt.ts packages/prisma-next/src/v3/bulk-encrypt-v3.ts packages/prisma-next/test/v3/bulk-encrypt-v3.test.ts packages/prisma-next/test/bulk-encrypt-middleware.helpers.ts packages/prisma-next/test/bulk-encrypt-middleware.test.ts +git commit -m "feat(prisma-next): v3 bulk-encrypt middleware with JSONB payloads" +``` + +--- + +### Task 6: v3 operators — `::jsonb` two-arg lowering + capability gating + `EncryptionOperatorError` + +Keep the `cipherstash*` method names but lower v3 columns to the two-arg `eql_v3.*(...::jsonb)` **functions** (in the `eql_v3` schema — the domains they operate on are `public.*`; do NOT conflate), gate by the concrete domain's index set, and throw a real exported `EncryptionOperatorError`. `cipherstashIlike`/`cipherstashNotIlike` lower to `eql_v3.contains` (documented bloom-filter caveat). No built-in equality traits are attached. + +**Files:** +- Create: `packages/prisma-next/src/v3/operators-v3.ts` +- Test: `packages/prisma-next/test/v3/operator-lowering-v3.test.ts`, `packages/prisma-next/test/v3/operator-gating-v3.test.ts`, `packages/prisma-next/test/v3/operator-lowering-v3.helpers.ts` + +**Interfaces:** +- Consumes: framework operator infra (`buildOperation`, `codecOf`, `toExpr`, `ParamRef`, `Expression`, `ScopeField`, `CodecRef`, `SqlOperationDescriptor(s)` — same imports as v2 `execution/operators.ts`); Task 1 `V3_DOMAIN_META_BY_CODEC_ID`, `V3DomainMeta`; Task 2 trait constants; Task 4 `EncryptedNumber` + neutral `EncryptedString`/`EncryptedDate`/`EncryptedBoolean`/`EncryptedBigInt`; `setHandleRoutingKey` (`../execution/envelope-base`). +- Produces: + - `class EncryptionOperatorError extends Error` (name `'EncryptionOperatorError'`) carrying `{ tableName?, columnName?, operator? }`. + - `function cipherstashV3QueryOperations(): SqlOperationDescriptors` with `cipherstashEq`, `cipherstashNe`, `cipherstashInArray`, `cipherstashNotInArray`, `cipherstashGt`, `cipherstashGte`, `cipherstashLt`, `cipherstashLte`, `cipherstashBetween`, `cipherstashNotBetween`, `cipherstashIlike`, `cipherstashNotIlike`. + - Templates: `eql_v3.eq({{self}}, {{arg0}}::jsonb)`, `eql_v3.neq(...)`, `eql_v3.gt/gte/lt/lte(...)`, range = `eql_v3.gte({{self}}, {{arg0}}::jsonb) AND eql_v3.lte({{self}}, {{arg1}}::jsonb)`, `eql_v3.contains({{self}}, {{arg0}}::jsonb)`. + +- [ ] **Step 1: Write the failing lowering test + helpers** + +Create `packages/prisma-next/test/v3/operator-lowering-v3.helpers.ts` mirroring the v2 `operator-lowering.helpers.ts`, but composing the v3 runtime (`createCipherstashV3RuntimeDescriptor({ sdk: emptySdk() })` from Task 7 — until Task 7 lands, compose a minimal adapter directly from `createV3CodecDescriptors` + `cipherstashV3QueryOperations`) and v3 codec ids in `columnAccessorV3` / `contractV3`. Export `callOperator`, `columnAccessorV3`, `contractV3`, `makeV3Adapter`, `selectWithWhere`, `TABLE`, `emptySdk`. + +> **0.14:** the v2 helpers file being mirrored is already 0.14-shaped on the branch — `contractV3` must follow it: `validateSqlContractFully(value)` (single arg, from `@prisma-next/sql-contract/validators`), storage as `namespaces.__unbound__ = { id: '__unbound__', entries: { table: {...} } }`, a `domain: { namespaces: { __unbound__: { models: {} } } }` plane, and per-column `nativeType` set to the concrete `public.*` domain. If any v3 lowering assertion reads `lowered.params`, unwrap the structured `LoweredParam` entries with the exported `literalParamValue` helper (ground-truth notes 2–3). + +Create `packages/prisma-next/test/v3/operator-lowering-v3.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { cipherstashV3QueryOperations } from '../../src/v3/operators-v3' +import { callOperator, columnAccessorV3, contractV3, makeV3Adapter, selectWithWhere, TABLE } from './operator-lowering-v3.helpers' + +// Extract just the operator fragment from the lowered WHERE clause and +// normalise the framework's param placeholders (`$1`/`$2`/…) to `$` so the +// assertion pins the FULL function-call skeleton — parentheses included — +// without coupling to placeholder numbering. A `.toContain('eql_v3.eq(')` +// check (the previous form) does NOT catch the `between` paren bug; these do. +// NOTE TO IMPLEMENTER: once the helper's exact `lowered.sql` format is +// confirmed against the v2 `operator-lowering.helpers.ts`, tighten each +// `whereFragment(...)` assertion to a full-string `.toBe(...)`. +function whereFragment(sql: string): string { + return sql.slice(sql.toUpperCase().indexOf('WHERE ') + 6).replace(/\$\d+/g, '$').trim() +} + +const TEXT = 'cipherstash/eql-v3/text_search@1' +const ORD = 'cipherstash/eql-v3/integer_ord@1' +const lowerOf = (predicate: unknown) => + whereFragment(makeV3Adapter().lower(selectWithWhere(predicate), { contract: contractV3 }).sql) + +describe('v3 operator lowering (::jsonb two-arg eql_v3.* functions)', () => { + const ops = cipherstashV3QueryOperations() + + it.each([ + ['cipherstashEq', ORD, ['x'], 'eql_v3.eq($, $::jsonb)'], + ['cipherstashNe', ORD, ['x'], 'eql_v3.neq($, $::jsonb)'], + ['cipherstashGt', ORD, ['x'], 'eql_v3.gt($, $::jsonb)'], + ['cipherstashGte', ORD, ['x'], 'eql_v3.gte($, $::jsonb)'], + ['cipherstashLt', ORD, ['x'], 'eql_v3.lt($, $::jsonb)'], + ['cipherstashLte', ORD, ['x'], 'eql_v3.lte($, $::jsonb)'], + ['cipherstashIlike', TEXT, ['x'], 'eql_v3.contains($, $::jsonb)'], + ['cipherstashNotIlike', TEXT, ['x'], 'NOT eql_v3.contains($, $::jsonb)'], + ] as const)('%s lowers to the exact ::jsonb skeleton', (method, codecId, args, expected) => { + const predicate = callOperator(ops[method], columnAccessorV3(TABLE, 'c', codecId), ...args) + expect(lowerOf(predicate)).toBe(expected) + }) + + it('cipherstashBetween is a SELF-PARENTHESISED conjunction (composition-safe)', () => { + const predicate = callOperator(ops.cipherstashBetween, columnAccessorV3(TABLE, 'c', ORD), 1, 9) + // The leading/trailing parens are the fix: without them a generic NOT/OR + // over `between` mis-parses. Pin the whole shape. + expect(lowerOf(predicate)).toBe('(eql_v3.gte($, $::jsonb) AND eql_v3.lte($, $::jsonb))') + }) + + it('cipherstashNotBetween wraps the WHOLE conjunction: NOT (gte AND lte)', () => { + const predicate = callOperator(ops.cipherstashNotBetween, columnAccessorV3(TABLE, 'c', ORD), 1, 9) + const frag = lowerOf(predicate) + expect(frag).toBe('NOT (eql_v3.gte($, $::jsonb) AND eql_v3.lte($, $::jsonb))') + // Regression guard on the 9c82f50e degradation `NOT gte AND lte`: + expect(frag).not.toMatch(/NOT eql_v3\.gte/) + }) + + it.each([ + ['cipherstashInArray', '(eql_v3.eq($, $::jsonb) OR eql_v3.eq($, $::jsonb))'], + ['cipherstashNotInArray', 'NOT (eql_v3.eq($, $::jsonb) OR eql_v3.eq($, $::jsonb))'], + ] as const)('%s ORs/NOT-ORs one eq term per value, parenthesised', (method, expected) => { + const predicate = callOperator(ops[method], columnAccessorV3(TABLE, 'c', ORD), ['a', 'b']) + expect(lowerOf(predicate)).toBe(expected) + }) +}) +``` + +- [ ] **Step 2: Write the failing gating test** + +Create `packages/prisma-next/test/v3/operator-gating-v3.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { cipherstashV3QueryOperations, EncryptionOperatorError } from '../../src/v3/operators-v3' +import { callOperator, columnAccessorV3, TABLE } from './operator-lowering-v3.helpers' + +describe('v3 operator capability gating', () => { + it('equality requires unique or ore — storage-only public.text rejects cipherstashEq', () => { + const op = cipherstashV3QueryOperations().cipherstashEq + expect(() => callOperator(op, columnAccessorV3(TABLE, 'note', 'cipherstash/eql-v3/text@1'), 'x')).toThrow( + EncryptionOperatorError, + ) + }) + it('comparison requires ore — text_eq rejects cipherstashGt', () => { + const op = cipherstashV3QueryOperations().cipherstashGt + expect(() => callOperator(op, columnAccessorV3(TABLE, 'email', 'cipherstash/eql-v3/text_eq@1'), 'x')).toThrow(/order\/range/) + }) + it('free-text requires match — text_eq rejects cipherstashIlike', () => { + const op = cipherstashV3QueryOperations().cipherstashIlike + expect(() => callOperator(op, columnAccessorV3(TABLE, 'email', 'cipherstash/eql-v3/text_eq@1'), 'x')).toThrow(/free-text/) + }) + it('storage-only public.boolean rejects every search operator', () => { + const op = cipherstashV3QueryOperations().cipherstashEq + expect(() => callOperator(op, columnAccessorV3(TABLE, 'active', 'cipherstash/eql-v3/boolean@1'), true)).toThrow( + EncryptionOperatorError, + ) + }) +}) +``` + +- [ ] **Step 3: Run to verify both fail** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/operator` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `operators-v3.ts`** + +Create `packages/prisma-next/src/v3/operators-v3.ts` (structure mirrors v2 `execution/operators.ts`, coercing to v3 envelopes, resolving domain meta from the codec id, gating on `meta.indexes`, using `::jsonb` templates): + +```ts +import type { CodecTrait } from '@prisma-next/framework-components/codec' +import type { SqlOperationDescriptor, SqlOperationDescriptors } from '@prisma-next/sql-operations' +import { type AnyExpression, type ColumnRef, type CodecRef, ParamRef } from '@prisma-next/sql-relational-core/ast' +import { buildOperation, codecOf, type Expression, type ScopeField, toExpr } from '@prisma-next/sql-relational-core/expression' +import { + CIPHERSTASH_TRAIT_EQUALITY, + CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH, + CIPHERSTASH_TRAIT_ORDER_AND_RANGE, +} from '../extension-metadata/constants-v3' +import { EncryptedBigInt } from '../execution/envelope-bigint' +import { setHandleRoutingKey } from '../execution/envelope-base' +import { EncryptedBoolean } from '../execution/envelope-boolean' +import { EncryptedDate } from '../execution/envelope-date' +import { EncryptedString } from '../execution/envelope-string' +import { type V3DomainMeta, V3_DOMAIN_META_BY_CODEC_ID } from './catalog' +import { EncryptedNumber } from './envelope-number' + +const PG_BOOL_CODEC_ID = 'pg/bool@1' as const +type PgBoolReturn = { readonly codecId: typeof PG_BOOL_CODEC_ID; readonly nullable: false } + +export class EncryptionOperatorError extends Error { + constructor( + message: string, + public readonly context?: { columnName?: string; tableName?: string; operator?: string }, + ) { + super(message) + this.name = 'EncryptionOperatorError' + } +} + +function coerceV3(meta: V3DomainMeta, value: unknown) { + switch (meta.castAs) { + case 'string': + if (value instanceof EncryptedString) return value + if (typeof value === 'string') return EncryptedString.from(value) + break + case 'number': + if (value instanceof EncryptedNumber) return value + if (typeof value === 'number') return EncryptedNumber.from(value) + break + case 'bigint': + if (value instanceof EncryptedBigInt) return value + if (typeof value === 'bigint') return EncryptedBigInt.from(value) + break + case 'date': + case 'timestamp': + if (value instanceof EncryptedDate) return value + if (value instanceof Date) return EncryptedDate.from(value) + break + case 'boolean': + if (value instanceof EncryptedBoolean) return value + if (typeof value === 'boolean') return EncryptedBoolean.from(value) + break + } + throw new EncryptionOperatorError(`cipherstash v3 operator: value not assignable to castAs "${meta.castAs}".`) +} + +function requireSelfCodec(self: Expression, method: string): CodecRef { + const codec = codecOf(self) + if (codec === undefined) { + throw new EncryptionOperatorError( + `cipherstash ${method}: self expression is missing a CodecRef. Reach the operator through the ORM model accessor.`, + { operator: method }, + ) + } + return codec +} + +function metaFor(codecId: string, method: string): V3DomainMeta { + const meta = V3_DOMAIN_META_BY_CODEC_ID.get(codecId) + if (!meta) { + throw new EncryptionOperatorError(`cipherstash ${method}: column codec id "${codecId}" is not a public.* v3 domain.`, { + operator: method, + }) + } + return meta +} + +function extractColumnRef(selfAst: AnyExpression): ColumnRef | undefined { + if (selfAst.kind === 'column-ref') return selfAst + try { + return selfAst.baseColumnRef() + } catch { + return undefined + } +} + +function asEncryptedParam(selfAst: AnyExpression, selfCodec: CodecRef, meta: V3DomainMeta, value: unknown): ParamRef { + const envelope = coerceV3(meta, value) + const columnRef = extractColumnRef(selfAst) + if (columnRef) setHandleRoutingKey(envelope, columnRef.table, columnRef.column) + return ParamRef.of(envelope, { codec: selfCodec }) +} + +type Gate = (meta: V3DomainMeta) => boolean +// equality is answered via `unique` (HMAC) OR `ore` (equality-via-ORE on a +// pure-order domain); comparison/range/order require `ore`; free-text `match`. +const hasEquality: Gate = (m) => !!(m.indexes.unique || m.indexes.ore) +const hasOre: Gate = (m) => !!m.indexes.ore +const hasMatch: Gate = (m) => !!m.indexes.match + +function gate(meta: V3DomainMeta, ok: Gate, method: string, capability: string): void { + if (!ok(meta)) { + throw new EncryptionOperatorError( + `Operator "${method}" requires ${capability} on this column (domain ${meta.nativeType} does not support it).`, + { operator: method }, + ) + } +} + +function fixedArity(method: string, trait: string, arity: number, template: string, gateFn: Gate, capability: string): SqlOperationDescriptor { + return { + self: { traits: [trait] as unknown as readonly CodecTrait[] }, + impl: (self: Expression, ...userArgs: unknown[]): Expression => { + if (userArgs.length !== arity) { + throw new EncryptionOperatorError(`cipherstash ${method}: expected ${arity} argument(s), got ${userArgs.length}.`, { + operator: method, + }) + } + const selfCodec = requireSelfCodec(self, method) + const meta = metaFor(selfCodec.codecId, method) + gate(meta, gateFn, method, capability) + const selfAst = toExpr(self, selfCodec) + const argRefs = userArgs.map((v) => asEncryptedParam(selfAst, selfCodec, meta, v)) + return buildOperation({ + method, + args: [selfAst, ...argRefs], + returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, + lowering: { targetFamily: 'sql', strategy: 'function', template }, + }) + }, + } +} + +function variableArity(method: string, trait: string, build: (n: number) => string): SqlOperationDescriptor { + return { + self: { traits: [trait] as unknown as readonly CodecTrait[] }, + impl: (self: Expression, values: unknown): Expression => { + if (!Array.isArray(values)) throw new EncryptionOperatorError(`cipherstash ${method}: expected an array.`, { operator: method }) + if (values.length === 0) throw new EncryptionOperatorError(`cipherstash ${method}: empty array is not supported.`, { operator: method }) + const selfCodec = requireSelfCodec(self, method) + const meta = metaFor(selfCodec.codecId, method) + gate(meta, hasEquality, method, 'equality') + const selfAst = toExpr(self, selfCodec) + const argRefs = values.map((v) => asEncryptedParam(selfAst, selfCodec, meta, v)) + return buildOperation({ + method, + args: [selfAst, ...argRefs], + returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, + lowering: { targetFamily: 'sql', strategy: 'function', template: build(values.length) }, + }) + }, + } +} + +const inArrayTemplate = (n: number) => + `(${Array.from({ length: n }, (_, i) => `eql_v3.eq({{self}}, {{arg${i}}}::jsonb)`).join(' OR ')})` +const notInArrayTemplate = (n: number) => `NOT ${inArrayTemplate(n)}` + +export function cipherstashV3QueryOperations(): SqlOperationDescriptors { + return { + cipherstashEq: fixedArity('cipherstashEq', CIPHERSTASH_TRAIT_EQUALITY, 1, 'eql_v3.eq({{self}}, {{arg0}}::jsonb)', hasEquality, 'equality'), + cipherstashNe: fixedArity('cipherstashNe', CIPHERSTASH_TRAIT_EQUALITY, 1, 'eql_v3.neq({{self}}, {{arg0}}::jsonb)', hasEquality, 'equality'), + cipherstashInArray: variableArity('cipherstashInArray', CIPHERSTASH_TRAIT_EQUALITY, inArrayTemplate), + cipherstashNotInArray: variableArity('cipherstashNotInArray', CIPHERSTASH_TRAIT_EQUALITY, notInArrayTemplate), + cipherstashGt: fixedArity('cipherstashGt', CIPHERSTASH_TRAIT_ORDER_AND_RANGE, 1, 'eql_v3.gt({{self}}, {{arg0}}::jsonb)', hasOre, 'order/range'), + cipherstashGte: fixedArity('cipherstashGte', CIPHERSTASH_TRAIT_ORDER_AND_RANGE, 1, 'eql_v3.gte({{self}}, {{arg0}}::jsonb)', hasOre, 'order/range'), + cipherstashLt: fixedArity('cipherstashLt', CIPHERSTASH_TRAIT_ORDER_AND_RANGE, 1, 'eql_v3.lt({{self}}, {{arg0}}::jsonb)', hasOre, 'order/range'), + cipherstashLte: fixedArity('cipherstashLte', CIPHERSTASH_TRAIT_ORDER_AND_RANGE, 1, 'eql_v3.lte({{self}}, {{arg0}}::jsonb)', hasOre, 'order/range'), + // between's template is a SELF-CONTAINED PARENTHESISED conjunction. The + // parens are load-bearing, not cosmetic: Postgres binds NOT tighter than + // AND, so an unparenthesised `gte AND lte` under any generic negation (a + // framework `not(between(...))`, an OR composition, etc.) parses as + // `(NOT gte) AND lte` — rows BELOW the lower bound instead of the range + // complement (the 9c82f50e bug class). Parenthesising here makes every + // composition safe instead of relying on each caller to remember. + cipherstashBetween: fixedArity('cipherstashBetween', CIPHERSTASH_TRAIT_ORDER_AND_RANGE, 2, '(eql_v3.gte({{self}}, {{arg0}}::jsonb) AND eql_v3.lte({{self}}, {{arg1}}::jsonb))', hasOre, 'order/range'), + cipherstashNotBetween: fixedArity('cipherstashNotBetween', CIPHERSTASH_TRAIT_ORDER_AND_RANGE, 2, 'NOT (eql_v3.gte({{self}}, {{arg0}}::jsonb) AND eql_v3.lte({{self}}, {{arg1}}::jsonb))', hasOre, 'order/range'), + // ilike/notIlike lower to eql_v3.contains (bloom-filter token containment, NOT SQL LIKE — carry this caveat into user docs). + cipherstashIlike: fixedArity('cipherstashIlike', CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH, 1, 'eql_v3.contains({{self}}, {{arg0}}::jsonb)', hasMatch, 'free-text search'), + cipherstashNotIlike: fixedArity('cipherstashNotIlike', CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH, 1, 'NOT eql_v3.contains({{self}}, {{arg0}}::jsonb)', hasMatch, 'free-text search'), + } +} +``` + +- [ ] **Step 5: Run to verify both suites pass** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/operator` +Expected: PASS. + +- [ ] **Step 6: Pin the trait-removal regression for v3** + +Add to `packages/prisma-next/test/equality-trait-removal.test.ts` (or a v3 sibling): every descriptor from `createV3CodecDescriptors(emptySdk())` has traits ⊆ `cipherstash:*` and never contains the framework built-in `'equality'`. + +```ts +import { createV3CodecDescriptors } from '../src/v3/codec-runtime-v3' +it('v3 codec descriptors never advertise the framework built-in equality trait', () => { + for (const d of createV3CodecDescriptors({ decrypt: vi.fn(), bulkEncrypt: vi.fn(), bulkDecrypt: vi.fn() })) { + for (const t of d.traits) expect(String(t).startsWith('cipherstash:')).toBe(true) + expect(d.traits).not.toContain('equality') + } +}) +``` + +- [ ] **Step 7: Verify no method-name collision across v2+v3 descriptors** + +Confirm the **v3 descriptor stands alone**: `createCipherstashV3RuntimeDescriptor({ sdk })` (Task 7) registers its `cipherstash*` operations under v3's own distinct extension id and builds cleanly. Do NOT co-register it with the v2 descriptor — decision 1b makes v2 and v3 separate entry points precisely because the flat `OperationRegistry` rejects two descriptors sharing the `cipherstashEq` method name. Add a test asserting a v3-only adapter builds, and (optionally) that attempting to register v2 and v3 descriptors together throws — documenting why they must not be combined. + +- [ ] **Step 8: Commit** + +```bash +git add packages/prisma-next/src/v3/operators-v3.ts packages/prisma-next/test/v3/operator-lowering-v3.test.ts packages/prisma-next/test/v3/operator-lowering-v3.helpers.ts packages/prisma-next/test/v3/operator-gating-v3.test.ts packages/prisma-next/test/equality-trait-removal.test.ts +git commit -m "feat(prisma-next): v3 operators with eql_v3.* ::jsonb lowering, capability gating, EncryptionOperatorError" +``` + +--- + +### Task 7: v3 schema derivation, SDK adapter, runtime descriptor, and the v3 entry point (`cipherstashFromStackV3`) + +Derive `@cipherstash/stack/eql/v3` schemas from v3 contract columns (mapping `nativeType` (`public.*`) → the matching concrete factory, preserving the concrete column type — no widening to `AnyEncryptedV3Column`), adapt the `EncryptionV3` client to the `CipherstashSdk` shape, compose the v3 runtime descriptor (under v3's **own distinct extension id/version**), and add a **v3-only** entry point `cipherstashFromStackV3` that builds a single `EncryptionV3` client. Decision **1b**: v2 and v3 are separate entry points — the v2 `cipherstashFromStack` is left unchanged, there is no two-client dispatch, and a v3 contract carrying a v2 codec id is a hard error. v3 override validation compares exact domain identity (`integer_ord` ≠ `integer_ord_ore`). + +**Files:** +- Create: `packages/prisma-next/src/v3/derive-schemas-v3.ts`, `packages/prisma-next/src/v3/sdk-adapter-v3.ts`, `packages/prisma-next/src/v3/runtime-v3.ts`, `packages/prisma-next/src/v3/from-stack-v3-validate.ts` +- Modify: `packages/prisma-next/src/stack/from-stack.ts`, `packages/prisma-next/src/exports/stack.ts`, `packages/prisma-next/src/exports/runtime.ts` +- Test: `packages/prisma-next/test/v3/derive-schemas-v3.test.ts`, `packages/prisma-next/test/v3/sdk-adapter-v3.test.ts`, `packages/prisma-next/test/v3/from-stack-divergence-v3.test.ts` + +**Interfaces:** +- Consumes: `@cipherstash/stack/eql/v3` — `types`, `encryptedTable`, `AnyV3Table`, `AnyEncryptedV3Column`; Task 1 `V3_FACTORY_BY_NATIVE_TYPE`; Task 2 `isCipherstashV3CodecId`; `EncryptionV3` (from the stack v3 client entry — see Step 4 note); `CipherstashSdk`, `CipherstashRoutingKey` (`../execution/sdk`); Task 4 `createV3CodecDescriptors`; Task 6 `cipherstashV3QueryOperations`; Task 5 `bulkEncryptMiddlewareV3`; existing v2 `deriveStackSchemas`, `createCipherstashSdk`, `createCipherstashRuntimeDescriptor`, `bulkEncryptMiddleware`, `Encryption`. +- Produces: + - `function deriveStackSchemasV3(contractJson): ReadonlyArray` + - `function createCipherstashV3Sdk(client, schemas): CipherstashSdk` + - `function createCipherstashV3RuntimeDescriptor(opts: { sdk: CipherstashSdk }): SqlRuntimeExtensionDescriptor<'postgres'>` + - `function assertV3SchemasAgree(derived: AnyV3Table, override: AnyV3Table): void` + - `function cipherstashFromStackV3(opts: CipherstashFromStackV3Options): Promise` — the v3-only entry point (decision 1b). The v2 `cipherstashFromStack` is **unchanged** and not touched by this task. + - `interface V3ContractShape` (structural view of the contract storage — the 0.14 namespace envelope, mirroring the v2 `ContractStorageView` in `src/stack/derive-schemas.ts`): `{ storage?: { namespaces?: Record }> } }> } }` (exported for reuse by tests/properties). + +- [ ] **Step 1: Write the failing derivation test** + +Create `packages/prisma-next/test/v3/derive-schemas-v3.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { deriveStackSchemasV3 } from '../../src/v3/derive-schemas-v3' + +// 0.14 contract shape: tables live under storage.namespaces..entries.table +// (Postgres default namespace is `public`). +function contract(columns: Record) { + return { + storage: { + namespaces: { public: { entries: { table: { user: { columns } } } } }, + }, + } +} + +describe('deriveStackSchemasV3', () => { + it('derives one v3 EncryptedTable, mapping public.* nativeType -> the concrete factory', () => { + const [table] = deriveStackSchemasV3( + contract({ + email: { codecId: 'cipherstash/eql-v3/text_search@1', nativeType: 'public.text_search' }, + score: { codecId: 'cipherstash/eql-v3/integer_ord@1', nativeType: 'public.integer_ord' }, + balance: { codecId: 'cipherstash/eql-v3/bigint_ord@1', nativeType: 'public.bigint_ord' }, + }), + ) + const built = table!.build() + expect(built.tableName).toBe('user') + expect(Object.keys(built.columns.email!.indexes).sort()).toEqual(['match', 'ore', 'unique']) + expect(Object.keys(built.columns.score!.indexes)).toEqual(['ore']) + expect(built.columns.email!.cast_as).toBe('string') + expect(built.columns.score!.cast_as).toBe('number') + expect(built.columns.balance!.cast_as).toBe('bigint') + }) + + it('skips tables with no v3 columns', () => { + expect( + deriveStackSchemasV3(contract({ x: { codecId: 'cipherstash/string@1', nativeType: 'eql_v2_encrypted' } })), + ).toHaveLength(0) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails, then implement** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/derive-schemas-v3` → FAIL. Create `packages/prisma-next/src/v3/derive-schemas-v3.ts`: + +```ts +import { type AnyEncryptedV3Column, type AnyV3Table, encryptedTable } from '@cipherstash/stack/eql/v3' +import { isCipherstashV3CodecId } from '../extension-metadata/constants-v3' +import { V3_FACTORY_BY_NATIVE_TYPE } from './catalog' + +interface V3TableView { + readonly columns?: Record< + string, + { codecId?: string; nativeType?: string; typeParams?: unknown } + > +} + +// 0.14 storage envelope: namespaces..entries.table. (see the +// "Prisma Next 0.14 ground truth" notes; mirrors the v2 ContractStorageView). +export interface V3ContractShape { + storage?: { + namespaces?: Record } }> + } +} + +export function deriveStackSchemasV3(contractJson: V3ContractShape): ReadonlyArray { + const namespaces = contractJson.storage?.namespaces + if (!namespaces) return [] + const tables: Record = {} + for (const namespace of Object.values(namespaces)) { + Object.assign(tables, namespace.entries?.table) + } + const result: AnyV3Table[] = [] + for (const [tableName, table] of Object.entries(tables)) { + const columns: Record = {} + for (const [columnName, column] of Object.entries(table.columns ?? {})) { + const codecId = column.codecId + if (codecId == null || !isCipherstashV3CodecId(codecId)) continue + const nativeType = column.nativeType + const factory = nativeType ? V3_FACTORY_BY_NATIVE_TYPE.get(nativeType) : undefined + if (!factory) { + throw new Error( + `deriveStackSchemasV3: column "${tableName}"."${columnName}" has v3 codec id "${codecId}" but nativeType "${nativeType}" maps to no eql/v3 factory. Re-emit the contract with a current @cipherstash/stack.`, + ) + } + columns[columnName] = factory(columnName) + } + if (Object.keys(columns).length === 0) continue + result.push(encryptedTable(tableName, columns)) + } + return result +} +``` + +Run again → PASS. + +- [ ] **Step 3: Implement the exact-domain override validator (TDD)** + +Create `packages/prisma-next/test/v3/from-stack-divergence-v3.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +import { assertV3SchemasAgree } from '../../src/v3/from-stack-v3-validate' + +describe('v3 override divergence — same cast_as, different domain', () => { + it('rejects integer_ord vs integer_ord_ore (both cast_as number)', () => { + const derived = encryptedTable('user', { score: types.IntegerOrd('score') }) + const override = encryptedTable('user', { score: types.IntegerOrdOre('score') }) + expect(() => assertV3SchemasAgree(derived, override)).toThrow(/domain/) + }) + it('accepts an exact domain match', () => { + const derived = encryptedTable('user', { score: types.IntegerOrd('score') }) + const override = encryptedTable('user', { score: types.IntegerOrd('score') }) + expect(() => assertV3SchemasAgree(derived, override)).not.toThrow() + }) +}) +``` + +Create `packages/prisma-next/src/v3/from-stack-v3-validate.ts`: + +```ts +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' + +/** v3 override validation compares EXACT domain identity (getEqlType() → + * public.*), not just cast_as + index keys — integer_ord and integer_ord_ore + * share cast_as and index families but are different domains. */ +export function assertV3SchemasAgree(derived: AnyV3Table, override: AnyV3Table): void { + const d = Object.fromEntries(Object.values(derived.columnBuilders).map((c) => [c.getName(), c.getEqlType()])) + const o = Object.fromEntries(Object.values(override.columnBuilders).map((c) => [c.getName(), c.getEqlType()])) + for (const col of new Set([...Object.keys(d), ...Object.keys(o)])) { + if (d[col] !== o[col]) { + throw new Error( + `cipherstashFromStack (v3): schema divergence on column "${derived.tableName}"."${col}". Contract domain "${d[col] ?? '(missing)'}" but override domain "${o[col] ?? '(missing)'}". Fix prisma/schema.prisma and re-emit rather than overriding.`, + ) + } + } +} +``` + +> Confirm the `columnBuilders` property name against `packages/stack/src/eql/v3/table.ts` (`EncryptedTable`); if the concrete-column map is exposed under another name, adjust here and in the SDK adapter (Step 4). + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/from-stack-divergence-v3` → PASS. + +- [ ] **Step 4: Implement the v3 SDK adapter (TDD)** + +Create `packages/prisma-next/test/v3/sdk-adapter-v3.test.ts` asserting `createCipherstashV3Sdk` routes `(table, column)` to the v3 typed objects and calls the client's `bulkEncrypt`, returning a bare array (matching `CipherstashSdk.bulkEncrypt: Promise>`). Then create `packages/prisma-next/src/v3/sdk-adapter-v3.ts`: + +```ts +import type { AnyEncryptedV3Column, AnyV3Table } from '@cipherstash/stack/eql/v3' +import type { CipherstashRoutingKey, CipherstashSdk } from '../execution/sdk' + +/** Minimal structural view of the stack `EncryptionV3` client. Confirm the real + * result shapes against `packages/stack/src/encryption/v3.ts` at implementation + * time; if a method returns a chainable operation, `await` it directly (it is a + * PromiseLike) and keep this adapter's `CipherstashSdk` output shape unchanged. */ +interface V3Client { + bulkEncrypt( + payload: ReadonlyArray<{ plaintext: unknown }>, + opts: { table: AnyV3Table; column: AnyEncryptedV3Column }, + ): PromiseLike<{ data?: Array<{ data: unknown }>; failure?: { message: string } }> + decrypt(encrypted: unknown): PromiseLike<{ data?: unknown; failure?: { message: string } }> + bulkDecrypt( + payloads: ReadonlyArray<{ data: unknown }>, + ): PromiseLike<{ data?: Array<{ data?: unknown; error?: unknown }>; failure?: { message: string } }> +} + +interface V3RegistryEntry { + readonly table: AnyV3Table + readonly columns: ReadonlyMap +} + +export function createCipherstashV3Sdk(client: V3Client, schemas: ReadonlyArray): CipherstashSdk { + const registry = new Map() + for (const table of schemas) { + const columns = new Map() + for (const value of Object.values(table.columnBuilders)) { + columns.set((value as AnyEncryptedV3Column).getName(), value as AnyEncryptedV3Column) + } + registry.set(table.tableName, { table, columns }) + } + + function lookup(rk: CipherstashRoutingKey) { + const entry = registry.get(rk.table) + if (!entry) throw new Error(`cipherstash v3 SDK adapter: routing-key table "${rk.table}" is not in the v3 schemas.`) + const column = entry.columns.get(rk.column) + if (!column) throw new Error(`cipherstash v3 SDK adapter: routing-key column "${rk.column}" is not on v3 table "${rk.table}".`) + return { table: entry.table, column } + } + + return { + async bulkEncrypt({ values, routingKey }) { + const { table, column } = lookup(routingKey) + const result = await client.bulkEncrypt(values.map((plaintext) => ({ plaintext })), { table, column }) + if (result.failure) throw new Error(`cipherstash v3 bulkEncrypt failed: ${result.failure.message}`) + return (result.data ?? []).map((e) => e.data) + }, + async decrypt({ ciphertext }) { + const result = await client.decrypt(ciphertext) + if (result.failure) throw new Error(`cipherstash v3 decrypt failed: ${result.failure.message}`) + return result.data as string + }, + async bulkDecrypt({ ciphertexts }) { + const result = await client.bulkDecrypt(ciphertexts.map((data) => ({ data }))) + if (result.failure) throw new Error(`cipherstash v3 bulkDecrypt failed: ${result.failure.message}`) + return (result.data ?? []).map((entry) => { + if (entry.error !== undefined) throw new Error(`cipherstash v3 bulkDecrypt entry failed: ${String(entry.error)}`) + return entry.data as string + }) + }, + } +} +``` + +- [ ] **Step 5: Implement the v3 runtime descriptor** + +Create `packages/prisma-next/src/v3/runtime-v3.ts`: + +```ts +import type { SqlRuntimeExtensionDescriptor } from '@prisma-next/sql-runtime' +// v3 uses its OWN extension id/version (decision 1b) — NOT the v2 +// CIPHERSTASH_SPACE_ID / CIPHERSTASH_EXTENSION_VERSION. Sharing the v2 id while +// registering the same `cipherstash*` method names is the collision that makes +// v2+v3 co-registration throw; distinct ids keep the two entry points cleanly +// separate. (Method names stay identical — they are never co-registered.) +import { + CIPHERSTASH_V3_EXTENSION_VERSION, + CIPHERSTASH_V3_SPACE_ID, +} from '../extension-metadata/constants-v3' +import type { CipherstashSdk } from '../execution/sdk' +import { createV3CodecDescriptors } from './codec-runtime-v3' +import { cipherstashV3QueryOperations } from './operators-v3' + +export function createCipherstashV3RuntimeDescriptor(opts: { sdk: CipherstashSdk }): SqlRuntimeExtensionDescriptor<'postgres'> { + const descriptors = createV3CodecDescriptors(opts.sdk) + return { + kind: 'extension' as const, + id: CIPHERSTASH_V3_SPACE_ID, + version: CIPHERSTASH_V3_EXTENSION_VERSION, + familyId: 'sql' as const, + targetId: 'postgres' as const, + types: { codecTypes: { codecDescriptors: descriptors } }, + codecs: () => descriptors, + queryOperations: () => cipherstashV3QueryOperations(), + create() { + return { familyId: 'sql' as const, targetId: 'postgres' as const } + }, + } +} +``` + +> Match the exact `SqlRuntimeExtensionDescriptor` field set against the v2 `createCipherstashRuntimeDescriptor` in `runtime.ts`; copy any additional required fields verbatim. + +- [ ] **Step 6: v3 entry point — `cipherstashFromStackV3` (separate from v2, never co-registered)** + +Decision **1b**: v2 and v3 are **separate entry points** and are **never co-registered** in one client. The framework `OperationRegistry` is a flat, method-name-keyed map that disallows override, so pushing both the v2 and v3 runtime descriptors (both defining `cipherstashEq`) into one `extensions[]` collides at registration. Do **NOT** extend `cipherstashFromStack` to build both. Leave the v2 `cipherstashFromStack` **unchanged**, and add a v3-only entry point. A given client is v2 or v3; mixed v2+v3 columns in one client are unsupported this release. + +Create `packages/prisma-next/src/stack/from-stack-v3.ts`: + +```ts +import type { ClientConfig } from '@cipherstash/stack' +import { EncryptionV3 } from '@cipherstash/stack/v3' // confirm the v3 client entry path (Step 8 note) +import { isCipherstashV3CodecId } from '../extension-metadata/constants-v3' +import { bulkEncryptMiddlewareV3 } from '../v3/bulk-encrypt-v3' +import { assertV3SchemasAgree } from '../v3/from-stack-v3-validate' +import { deriveStackSchemasV3 } from '../v3/derive-schemas-v3' +import { createCipherstashV3RuntimeDescriptor } from '../v3/runtime-v3' +import { createCipherstashV3Sdk } from '../v3/sdk-adapter-v3' + +export interface CipherstashFromStackV3Options { + readonly contractJson: unknown + readonly schemasV3?: ReadonlyArray + readonly encryptionConfig?: ClientConfig +} + +/** + * v3-only stack entry point. A v3 client is v3-only: a contract carrying a v2 + * codec id is the WRONG entry point (mixed v2+v3 in one client is unsupported — + * decision 1b), and is a hard error rather than a silently-derived v2 column. + */ +export async function cipherstashFromStackV3( + opts: CipherstashFromStackV3Options, +): Promise { + const foreignV2 = collectV2CodecIds(opts.contractJson) // any codecId that is NOT isCipherstashV3CodecId + if (foreignV2.length > 0) { + throw new Error( + `cipherstashFromStackV3: contract.json contains v2 codec ids [${foreignV2.join(', ')}]. ` + + 'A v3 client is v3-only; use cipherstashFromStack for a v2 contract. Mixed v2+v3 in one client is unsupported.', + ) + } + + const derivedV3 = deriveStackSchemasV3(opts.contractJson) + if (derivedV3.length === 0) { + throw new Error( + 'cipherstashFromStackV3: no v3 cipherstash columns in contract.json. Declare at least one v3 cipherstash.Encrypted*() column and re-emit.', + ) + } + + if (opts.schemasV3) { + const overrideByName = new Map(opts.schemasV3.map((t) => [t.tableName, t])) + for (const dt of derivedV3) { + const ot = overrideByName.get(dt.tableName) + if (ot) assertV3SchemasAgree(dt, ot) // exact domain identity: integer_ord ≠ integer_ord_ore + } + } + + const [f, ...r] = derivedV3 + const clientV3 = await EncryptionV3({ + schemas: [f!, ...r] as never, + ...(opts.encryptionConfig ? { config: opts.encryptionConfig } : {}), + }) + const sdkV3 = createCipherstashV3Sdk(clientV3 as never, derivedV3) + return { + extensions: [createCipherstashV3RuntimeDescriptor({ sdk: sdkV3 })], + middleware: [bulkEncryptMiddlewareV3(sdkV3)], + encryptionClient: clientV3 as never, + } +} + +/** Walk the contract columns and collect every codecId that is not a v3 id. */ +function collectV2CodecIds(contractJson: unknown): string[] { + const ids = new Set() + for (const col of iterateContractColumns(contractJson)) { + if (typeof col.codecId === 'string' && col.codecId.startsWith('cipherstash/') && !isCipherstashV3CodecId(col.codecId)) { + ids.add(col.codecId) + } + } + return [...ids] +} +``` + +(`iterateContractColumns` is the same contract walker `deriveStackSchemasV3` uses.) The v2 `cipherstashFromStack` keeps its signature and body untouched. + +- [ ] **Step 7: Export the v3 surface** + +In `packages/prisma-next/src/exports/runtime.ts` add: + +```ts +export { EncryptedNumber } from '../v3/envelope-number' +export { createCipherstashV3RuntimeDescriptor } from '../v3/runtime-v3' +export { createV3CodecDescriptors } from '../v3/codec-runtime-v3' +export { cipherstashV3QueryOperations, EncryptionOperatorError } from '../v3/operators-v3' +export { bulkEncryptMiddlewareV3 } from '../v3/bulk-encrypt-v3' +export { v3FromDriver, v3ToDriver } from '../v3/wire-v3' +``` + +In `packages/prisma-next/src/exports/stack.ts` add: + +```ts +export { deriveStackSchemasV3 } from '../v3/derive-schemas-v3' +export { createCipherstashV3Sdk } from '../v3/sdk-adapter-v3' +export { assertV3SchemasAgree } from '../v3/from-stack-v3-validate' +``` + +- [ ] **Step 8: Run and commit** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/derive-schemas-v3 v3/sdk-adapter-v3 v3/from-stack-divergence-v3 && pnpm --filter @cipherstash/prisma-next build` +Expected: PASS + build clean. (If the stack v3 client is exported from a subpath other than `@cipherstash/stack/v3`, grep `packages/stack/package.json` `exports` for the `EncryptionV3` entry and adjust the import in Step 6.) + +```bash +git add packages/prisma-next/src/v3/derive-schemas-v3.ts packages/prisma-next/src/v3/sdk-adapter-v3.ts packages/prisma-next/src/v3/runtime-v3.ts packages/prisma-next/src/v3/from-stack-v3-validate.ts packages/prisma-next/src/stack/from-stack.ts packages/prisma-next/src/exports/stack.ts packages/prisma-next/src/exports/runtime.ts packages/prisma-next/test/v3/derive-schemas-v3.test.ts packages/prisma-next/test/v3/sdk-adapter-v3.test.ts packages/prisma-next/test/v3/from-stack-divergence-v3.test.ts +git commit -m "feat(prisma-next): v3 derivation, SDK adapter, runtime descriptor, two-client from-stack dispatch" +``` + +--- + +### Task 8: v3 bundle baseline migration (SQL from `@cipherstash/eql/sql`) + +Add a v3 baseline migration `20260601T0100_install_eql_v3_bundle` with invariant `cipherstash:install-eql-v3-bundle-v1`, whose install SQL is sourced at emit time from `@cipherstash/eql/sql` (`readInstallSql()` / `releaseManifest.eqlVersion`, `@cipherstash/eql@3.0.0-alpha.3` — the same source `packages/stack/scripts/install-eql-v3.ts` / `installEqlV3IfNeeded` use), NOT a hand-vendored FFI fixture. Wire it into the control descriptor after the v2 baseline. v3 columns emit **no** `add_search_config`/`remove_search_config`. + +**Files:** +- Modify: `packages/prisma-next/package.json` (add `@cipherstash/eql` devDependency) +- Create: `packages/prisma-next/src/migration/eql-bundle-v3.ts`, `packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.ts` +- Generated by running `migration.ts`: `migration.json`, `ops.json`, `end-contract.json`, updated `migrations/refs/head.json` +- Modify: `packages/prisma-next/src/exports/control.ts` +- Test: `packages/prisma-next/test/v3/migration-v3.test.ts` + +**Interfaces:** +- Consumes: `@cipherstash/eql/sql` `readInstallSql()`, `releaseManifest`; Task 2 `CIPHERSTASH_V3_INVARIANTS`, `CIPHERSTASH_V3_BASELINE_MIGRATION_NAME`; framework `Migration`, `MigrationCLI`, `rawSql` (`@prisma-next/target-postgres/migration`); `contractSpaceFromJson`. +- Produces: `eql-bundle-v3.ts` (re-export of `readInstallSql` / `releaseManifest`); the migration module + emitted artifacts. + +- [ ] **Step 1: Add the `@cipherstash/eql` devDependency** + +Edit `packages/prisma-next/package.json` — add to `devDependencies`: + +```json +"@cipherstash/eql": "3.0.0-alpha.3", +``` + +Run: `pnpm install` (repo root). Expected: resolves to the version already in the monorepo (stack's devDep). The SQL is baked into `ops.json` at emit time, so `@cipherstash/eql` stays a devDependency (not a runtime dependency of the published package). + +- [ ] **Step 2: Create the bundle re-export** + +Create `packages/prisma-next/src/migration/eql-bundle-v3.ts`: + +```ts +/** + * CipherStash EQL v3 install SQL, sourced from `@cipherstash/eql/sql` — the same + * source the stack install script uses. `readInstallSql()` returns the full + * bundle that creates the `public.*` domains and the `eql_v3.*` operator + * functions; `releaseManifest.eqlVersion` identifies the pinned release. The SQL + * is read at migration EMIT time and baked into `ops.json`. + */ +export { readInstallSql, releaseManifest } from '@cipherstash/eql/sql' +``` + +- [ ] **Step 3: Create the migration module** + +Create `packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.ts` (mirror the v2 `20260601T0000_install_eql_bundle/migration.ts`, but v3 invariant, v3 SQL, and a v3 postcheck — a concrete `public.*` domain + the `eql_v3` operator schema exist, and NO `add_search_config`): + +```ts +#!/usr/bin/env -S node +import { Migration, MigrationCLI, rawSql } from '@prisma-next/target-postgres/migration' +import { CIPHERSTASH_V3_INVARIANTS } from '../../src/extension-metadata/constants-v3' +import { readInstallSql, releaseManifest } from '../../src/migration/eql-bundle-v3' + +const INSTALL_LABEL = `Install EQL v3 bundle (${releaseManifest.eqlVersion}: public.* domains + eql_v3.* functions)` + +export default class M extends Migration { + override describe() { + // The v3 baseline is the SECOND migration in the cipherstash contract + // space: `from` is the v2 baseline's `to` (the current head hash — see + // "Prisma Next 0.14 ground truth" note 5; after the 0.14 upgrade it is + // sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7). + // `to` is the storage hash after this migration (see Step 4). + return { + from: 'sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7', + to: 'sha256:REPLACE_WITH_EMITTED_HASH', + } + } + override get operations() { + return [ + rawSql({ + id: 'cipherstash.install-eql-v3-bundle', + label: INSTALL_LABEL, + operationClass: 'additive', + invariantId: CIPHERSTASH_V3_INVARIANTS.installBundle, + target: { id: 'postgres' }, + precheck: [], + execute: [{ description: INSTALL_LABEL, sql: readInstallSql() }], + postcheck: [ + { + description: 'verify the eql_v3 operator schema exists', + sql: "SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'eql_v3')", + }, + { + description: 'verify the concrete domain public.text_search exists', + sql: "SELECT to_regtype('public.text_search') IS NOT NULL", + }, + { + description: 'verify the eql_v3.eq operator function exists', + sql: "SELECT to_regprocedure('eql_v3.eq(public.eql_encrypted, jsonb)') IS NOT NULL OR to_regproc('eql_v3.eq') IS NOT NULL", + }, + ], + }), + ] + } +} + +MigrationCLI.run(import.meta.url, M) +``` + +> Match the exact `rawSql` field names + the `Migration` subclass shape against the v2 `migration.ts`. In particular confirm whether it uses `get operations()` or `override operations()` and the postcheck object keys; copy the v2 shape verbatim, changing only id/label/invariant/sql/postcheck. +> +> **0.14:** the bare structural op factories (`createTable`, `setNotNull`, …) became protected methods on the `Migration` base class in Prisma Next 0.14, but **`rawSql` survives as a free function** and the v2 baseline still imports it — the import above is correct as written (ground-truth note 10). + +- [ ] **Step 4: Emit the migration artifacts (0.14 loop — self-emit writes `ops.json` + `migration.json` ONLY)** + +> **0.14:** the self-emit does **not** write `end-contract.json`, does **not** print a contract hash, and does **not** touch `migrations/refs/head.json` — all three are manual (ground-truth note 5). The loop below was exercised four times on the upgrade branch. + +First decide the `to` hash — it depends on whether this migration changes the extension's declared contract storage: + +- **If `src/contract.prisma` is unchanged** (the v3 bundle only creates `public.*` domains + `eql_v3.*` functions, and no config table is added to the contract space), the storage hash does not move: `to` equals `from` (the current head hash) and the edge is **invariant-only**. VERIFY the migration graph (`reconstructGraph` / `readMigrationsDir` in `@prisma-next/migration-tools`) accepts a `from === to` edge before committing to this shape; if it rejects self-loop edges, declare the v3 configuration state in `src/contract.prisma` (mirroring how the v2 space declares `eql_v2_configuration`) so the hash genuinely moves. +- **If the contract space gains v3-visible storage** (e.g. a v3 config table), run the full re-pin loop and use the new hash. + +The loop (all from `packages/prisma-next/`): + +1. If the contract changed: `pnpm exec prisma-next contract emit` → rewrites `src/contract.{json,d.ts}`; read the new hash from `src/contract.json` → `storage.storageHash`. +2. Copy `src/contract.json` → `migrations/20260601T0100_install_eql_v3_bundle/end-contract.json` and `src/contract.d.ts` → `.../end-contract.d.ts` (for an invariant-only edge these equal the v2 baseline's end-contract — copy them anyway; every on-disk migration package carries its destination snapshot). +3. Replace `REPLACE_WITH_EMITTED_HASH` in `describe().to` with the hash from (1) (or the `from` hash for an invariant-only edge). +4. Self-emit: `pnpm exec tsx migrations/20260601T0100_install_eql_v3_bundle/migration.ts` → writes `ops.json` + `migration.json`. +5. Hand-edit `migrations/refs/head.json`: set `hash` to the v3 `to` hash and append `cipherstash:install-eql-v3-bundle-v1` to `invariants` (keeping `cipherstash:install-eql-bundle-v1`). + +- [ ] **Step 5: Wire the v3 baseline into control.ts** + +In `packages/prisma-next/src/exports/control.ts`: + +```ts +import v3BaselineMetadata from '../../migrations/20260601T0100_install_eql_v3_bundle/migration.json' with { type: 'json' } +import v3BaselineOps from '../../migrations/20260601T0100_install_eql_v3_bundle/ops.json' with { type: 'json' } +import { CIPHERSTASH_V3_BASELINE_MIGRATION_NAME } from '../extension-metadata/constants-v3' +// ... + migrations: [ + { dirName: CIPHERSTASH_BASELINE_MIGRATION_NAME, metadata: baselineMetadata, ops: baselineOps }, + { dirName: CIPHERSTASH_V3_BASELINE_MIGRATION_NAME, metadata: v3BaselineMetadata, ops: v3BaselineOps }, + ], +``` + +Do **not** register any v3 codec ids in `controlPlaneHooks` — that omission is what guarantees v3 columns emit no `add_search_config`/`remove_search_config`. + +- [ ] **Step 6: Write the migration assertion test** + +Create `packages/prisma-next/test/v3/migration-v3.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import v3Ops from '../../migrations/20260601T0100_install_eql_v3_bundle/ops.json' + +describe('v3 baseline migration', () => { + it('installs under the v3 invariant with an additive rawSql op', () => { + const op = (v3Ops as Array>)[0]! + expect(op.invariantId).toBe('cipherstash:install-eql-v3-bundle-v1') + expect(op.operationClass).toBe('additive') + }) + it('emits no add_search_config / remove_search_config ops', () => { + const json = JSON.stringify(v3Ops) + expect(json).not.toContain('add_search_config') + expect(json).not.toContain('remove_search_config') + }) + it('sorts after the v2 baseline', () => { + expect('20260601T0100_install_eql_v3_bundle' > '20260601T0000_install_eql_bundle').toBe(true) + }) +}) +``` + +- [ ] **Step 7: Run and commit** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/migration-v3 descriptor && pnpm --filter @cipherstash/prisma-next build` +Expected: PASS (including the existing `descriptor.test.ts` self-consistency check with the second migration wired — note it already passes `sqlContractCanonicalizationHooks` to `assertDescriptorSelfConsistency`, required since 0.12, and its "head ref tracks the latest migration's `to`" assertion will now point at the v3 baseline; ground-truth notes 5–6). + +```bash +git add packages/prisma-next/package.json packages/prisma-next/src/migration/eql-bundle-v3.ts packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle packages/prisma-next/migrations/refs/head.json packages/prisma-next/src/exports/control.ts packages/prisma-next/test/v3/migration-v3.test.ts ../../pnpm-lock.yaml +git commit -m "feat(prisma-next): v3 bundle baseline migration from @cipherstash/eql/sql (install-eql-v3-bundle-v1)" +``` + +--- + +### Task 9: Property-based tests (`fast-check`) + +Add `fast-check ^4.8.0` as a prisma-next devDependency and cover the five required properties. (There is no resolver, so there is no resolver-totality property — the constructor↔domain totality property replaces it.) + +**Files:** +- Modify: `packages/prisma-next/package.json` (devDependencies) +- Create: `packages/prisma-next/test/v3/properties.test.ts` + +**Interfaces:** +- Consumes: `fast-check`; Task 4 `v3ToDriver`/`v3FromDriver`, `EncryptedNumber`; Task 1 `V3_DOMAIN_META_BY_CODEC_ID`, `EXPOSED_DOMAIN_ENTRIES`; Task 3 `v3PascalName`; Task 6 `EncryptionOperatorError`; Task 7 `deriveStackSchemasV3`; `cipherstashAuthoringTypes`. + +- [ ] **Step 1: Add the devDependency** + +Edit `packages/prisma-next/package.json` — add to `devDependencies`: + +```json +"fast-check": "^4.8.0", +``` + +Run: `pnpm install` (repo root). Expected: resolves to the monorepo catalog version already present in `packages/drizzle`. + +- [ ] **Step 2: Write the five properties** + +Create `packages/prisma-next/test/v3/properties.test.ts`: + +```ts +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { cipherstashAuthoringTypes, v3PascalName } from '../../src/contract-authoring' +import { EncryptedNumber } from '../../src/v3/envelope-number' +import { v3FromDriver, v3ToDriver } from '../../src/v3/wire-v3' +import { EXPOSED_DOMAIN_ENTRIES, V3_DOMAIN_META_BY_CODEC_ID } from '../../src/v3/catalog' +import { deriveStackSchemasV3 } from '../../src/v3/derive-schemas-v3' + +describe('property: JSONB round-trip', () => { + it('v3FromDriver(v3ToDriver(x)) deep-equals x; null/undefined -> null', () => { + fc.assert( + fc.property(fc.jsonValue(), (x) => { + expect(v3FromDriver(v3ToDriver(x))).toEqual(x) + }), + ) + expect(v3ToDriver(null)).toBeNull() + expect(v3ToDriver(undefined)).toBeNull() + }) +}) + +describe('property: [REDACTED] invariant', () => { + it('serialized envelope never contains the plaintext and always renders the placeholder', () => { + fc.assert( + fc.property(fc.double({ noNaN: true }), (n) => { + const env = EncryptedNumber.from(n) + expect(JSON.stringify(env)).toBe('{"$encryptedNumber":""}') + expect(String(env)).toBe('[REDACTED]') + expect(`${env}`).not.toContain(String(n)) + }), + ) + }) +}) + +describe('property: constructor↔domain totality (public.*)', () => { + const ns = cipherstashAuthoringTypes.cipherstash as Record }> + it('every exposed domain has exactly one constructor whose descriptor equals the catalog values', () => { + fc.assert( + fc.property(fc.constantFrom(...EXPOSED_DOMAIN_ENTRIES), ([codecId, meta]) => { + const ctor = ns[v3PascalName(meta.bareDomain)] + expect(ctor).toBeDefined() + expect(ctor.output.codecId).toBe(codecId) + expect(ctor.output.nativeType).toBe(meta.nativeType) + expect(meta.nativeType.startsWith('public.')).toBe(true) + expect((ctor.output.typeParams as { capabilities: unknown }).capabilities).toEqual(meta.capabilities) + }), + ) + }) + it('no constructor maps off-catalog', () => { + const validNames = new Set(EXPOSED_DOMAIN_ENTRIES.map(([, m]) => v3PascalName(m.bareDomain))) + for (const name of Object.keys(ns)) { + if (name.endsWith('V2')) continue + expect(validNames.has(name), `constructor ${name} is not in the exposed catalog`).toBe(true) + } + }) +}) + +describe('property: operator-gating equivalence', () => { + it('the equality gate allows a domain iff it has a unique or ore index', () => { + fc.assert( + fc.property(fc.constantFrom(...V3_DOMAIN_META_BY_CODEC_ID.keys()), (codecId) => { + const meta = V3_DOMAIN_META_BY_CODEC_ID.get(codecId)! + const gateAllows = !!(meta.indexes.unique || meta.indexes.ore) + const predicate = meta.capabilities.equality || meta.capabilities.orderAndRange + expect(gateAllows).toBe(predicate) + }), + ) + }) +}) + +describe('property: derivation round-trip', () => { + it('arbitrary v3 contract column -> deriveStackSchemasV3 -> derived domain identity == original', () => { + fc.assert( + fc.property(fc.constantFrom(...V3_DOMAIN_META_BY_CODEC_ID.entries()), ([codecId, meta]) => { + const [table] = deriveStackSchemasV3({ + storage: { + namespaces: { + public: { + entries: { + table: { user: { columns: { c: { codecId, nativeType: meta.nativeType } } } }, + }, + }, + }, + }, + }) + const col = Object.values(table!.columnBuilders)[0]! + expect((col as { getEqlType(): string }).getEqlType()).toBe(meta.nativeType) + }), + ) + }) +}) +``` + +- [ ] **Step 3: Run and commit** + +Run: `pnpm --filter @cipherstash/prisma-next test -- v3/properties` +Expected: PASS (5 property blocks). + +```bash +git add packages/prisma-next/package.json packages/prisma-next/test/v3/properties.test.ts ../../pnpm-lock.yaml +git commit -m "test(prisma-next): property-based coverage for v3 wire, constructor totality, gating, derivation" +``` + +--- + +### Task 10: Bundling isolation + live-PG integration suites + +Pin the namespace-separation invariant at the bundle level (v3 entry never pulls the v2 composite codec), and add the seven live-PG suites mirroring the Drizzle branch, installing v3 via `@cipherstash/eql/sql` `readInstallSql()` behind an advisory lock. + +**Files:** +- Modify: `packages/prisma-next/test/bundling-isolation.test.ts` (add v3 assertions) +- Create: `packages/prisma-next/test/live/helpers/live-gate.ts`, `packages/prisma-next/test/live/helpers/eql-v3.ts`, and seven suites under `packages/prisma-next/test/live/` +- Modify: `packages/prisma-next/package.json` (optional `test:live` script) + +**Interfaces:** +- Consumes: `@cipherstash/eql/sql` `readInstallSql` / `releaseManifest`; `postgres`; the v3 runtime/middleware from Tasks 4–7; the stack `EncryptionV3` client. +- Produces: `LIVE_EQL_V3_PG_ENABLED`, `describeLivePg`, `installEqlV3IfNeeded(sql)` local helpers. + +- [ ] **Step 1: Bundling isolation — add v3 forbidden-substring assertions** + +In `packages/prisma-next/test/bundling-isolation.test.ts`, assert that no v3-only chunk reachable from the v3 barrel/runtime re-exports includes the v2 composite encoder (`encodeEqlV2EncryptedWire`), reusing the file's existing `collectGraph`/`readChunk` helpers: + +```ts +it('v3 codec runtime does not pull the v2 composite-literal codec', () => { + const v3Chunks = [...collectGraph('runtime.js').entries()].filter( + ([name]) => name.includes('v3') || name.includes('codec-runtime-v3'), + ) + for (const [, chunk] of v3Chunks) { + expect(chunk.body.includes('encodeEqlV2EncryptedWire')).toBe(false) + } +}) +``` + +> The chunk-name predicate depends on tsup's output; adjust the `.filter` after inspecting `dist/` names. + +Run: `pnpm --filter @cipherstash/prisma-next build && pnpm --filter @cipherstash/prisma-next test -- bundling-isolation` +Expected: PASS. + +- [ ] **Step 2: Create the local live-PG helpers** + +Create `packages/prisma-next/test/live/helpers/live-gate.ts`: + +```ts +import { describe } from 'vitest' + +export const LIVE_CIPHERSTASH_ENABLED = Boolean( + process.env.CS_WORKSPACE_CRN && process.env.CS_CLIENT_ID && process.env.CS_CLIENT_KEY && process.env.CS_CLIENT_ACCESS_KEY, +) +export const LIVE_EQL_V3_PG_ENABLED = Boolean(process.env.DATABASE_URL && LIVE_CIPHERSTASH_ENABLED) +export const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip +``` + +Create `packages/prisma-next/test/live/helpers/eql-v3.ts` (mirror `packages/stack/__tests__/helpers/eql-v3.ts` — advisory lock + `eql_v3.version()` staleness probe + `readInstallSql()`): + +```ts +import { readInstallSql, releaseManifest } from '@cipherstash/eql/sql' +import type postgres from 'postgres' + +const EQL_V3_ADVISORY_LOCK_ID = 3_733_003 + +async function readEqlV3Version(sql: postgres.Sql): Promise { + const [probe] = await sql<{ present: boolean }[]>`SELECT to_regprocedure('eql_v3.version()') IS NOT NULL AS present` + if (!probe?.present) return null + const [row] = await sql<{ version: string }[]>`SELECT eql_v3.version() AS version` + return row?.version ?? null +} + +export async function installEqlV3IfNeeded(sql: postgres.Sql): Promise { + const reserved = await sql.reserve() + try { + await reserved`SELECT pg_advisory_lock(${EQL_V3_ADVISORY_LOCK_ID})` + try { + if ((await readEqlV3Version(reserved)) === releaseManifest.eqlVersion) return + await reserved.unsafe(readInstallSql()) + const installed = await readEqlV3Version(reserved) + if (installed !== releaseManifest.eqlVersion) { + throw new Error(`EQL v3 installation did not yield ${releaseManifest.eqlVersion}, got ${installed ?? 'none'}`) + } + } finally { + await reserved`SELECT pg_advisory_unlock(${EQL_V3_ADVISORY_LOCK_ID})` + } + } finally { + reserved.release() + } +} +``` + +- [ ] **Step 3: Write the seven live suites (self-skipping)** + +Create the following under `packages/prisma-next/test/live/`, each guarded by `describeLivePg`, installing v3 in `beforeAll`, and driving real INSERT/SELECT/decrypt against the `public.*` domains + `eql_v3.*` operators via `cipherstashFromStackV3` (the v3-only entry point — decision 1b) + a real `EncryptionV3` client. Shared skeleton: + +```ts +import { afterAll, beforeAll, expect, it } from 'vitest' +import postgres from 'postgres' +import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from './helpers/live-gate' +import { installEqlV3IfNeeded } from './helpers/eql-v3' + +const sql = LIVE_EQL_V3_PG_ENABLED ? postgres(process.env.DATABASE_URL!, { prepare: false }) : undefined +beforeAll(async () => { if (LIVE_EQL_V3_PG_ENABLED && sql) await installEqlV3IfNeeded(sql) }) +afterAll(async () => { if (sql) await sql.end() }) +``` + +1. `operators-live-pg.test.ts` — per capability tier (`text_search`, `text_eq`, `integer_ord`, `date_ord`, `timestamp_ord`): encrypt → INSERT → query via each `cipherstash*` operator → decrypt; assert the **expected row is selected** (not just an SQL string). +2. `operators-null-live-pg.test.ts` — null operands and null columns round-trip (insert NULL, select, decrypt to null). +3. `boolean-storage-live-pg.test.ts` — `public.boolean` survives INSERT/SELECT/decrypt and every `cipherstash*` search operator throws `EncryptionOperatorError` (storage-only). +4. `bigint-live-pg.test.ts` — `public.bigint` / `bigint_eq` / `bigint_ord` encrypt → INSERT → query (eq / order-range) → decrypt to a JS `bigint`; assert `typeof decrypted === 'bigint'`, lossless value, and ORE ordering over real domains. +5. `migration-apply-live-pg.test.ts` — apply the v3 bundle (`installEqlV3IfNeeded`); assert `to_regtype('public.text_search')`/`public.integer_ord` are non-null, the `eql_v3` schema exists, the invariant `cipherstash:install-eql-v3-bundle-v1` is recorded (via the migration ops fixture or the runner's marker table), and no `add_search_config` was executed. +6. `bulk-encrypt-live-pg.test.ts` — drive the v3 middleware against a **real** `EncryptionV3(...)` client (not a fake) → INSERT; assert the stored cell parses as JSON and decrypts to the original, proving routing-key stamping + JSONB payloads end-to-end. +7. `side-by-side-clients-live-pg.test.ts` — a v2 client (`cipherstashFromStack`, v2-only contract/table) and a v3 client (`cipherstashFromStackV3`, v3-only contract/table) running in the same process against the same database; insert/query/decrypt through each, proving the two entry points coexist without sharing a registry, descriptor, or dispatch. (Mixed v2+v3 columns in ONE client are unsupported per decision 1b — the spec explicitly excludes a mixed-client live test; this replaces the earlier `mixed-v2-v3-live-pg.test.ts` item, which contradicted it.) + +- [ ] **Step 4: Verify self-skip locally, then commit** + +Run (no live env): `pnpm --filter @cipherstash/prisma-next test` +Expected: the seven live suites report `skipped`; every non-live suite passes. + +```bash +git add packages/prisma-next/test/bundling-isolation.test.ts packages/prisma-next/test/live packages/prisma-next/package.json +git commit -m "test(prisma-next): v3 bundling-isolation + live-PG integration suites" +``` + +--- + +### Task 11: Release notes / changeset + +**Files:** +- Create: `.changeset/eql-v3-prisma-next.md` + +- [ ] **Step 1: Write the changeset** + +Create `.changeset/eql-v3-prisma-next.md` (major if the package is treated as stable; the body must call out the new authoring surface regardless — see repo `.changeset/config.json` for the bump policy): + +```md +--- +"@cipherstash/prisma-next": major +--- + +**Breaking:** EQL v3 columns are now authored through **concrete per-domain constructors** — the constructor you choose *is* the capability set. The legacy boolean-option surface (`EncryptedString({ equality, freeTextSearch, orderAndRange })`) is not carried into v3. + +- New per-domain constructors, one per exposed `public.*` domain: + - Text: `EncryptedText` (storage), `EncryptedTextEq`, `EncryptedTextOrd` (eq + order/range), `EncryptedTextMatch` (free-text), `EncryptedTextSearch` (eq + free-text + order/range). + - Scalars (Integer, Smallint, BigInt, Numeric, Real, Double, Date, Timestamp): `Encrypted` (storage), `EncryptedEq`, `EncryptedOrd`. + - `EncryptedBoolean` — storage-only (`public.boolean`); there is no boolean equality constructor. +- **Impossible capability combinations have no constructor** (e.g. text equality + free-text without order/range) — they are unrepresentable, not runtime errors. +- **BigInt is a first-class v3 family** (`EncryptedBigInt` / `EncryptedBigIntEq` / `EncryptedBigIntOrd`, JS `bigint` plaintext, backed by `public.bigint*`). +- **Searchable JSON has no v3 constructor.** Use `EncryptedJsonV2`. +- Use the `*V2` constructors (`EncryptedStringV2`, `EncryptedDoubleV2`, `EncryptedBigIntV2`, `EncryptedDateV2`, `EncryptedBooleanV2`, `EncryptedJsonV2`) to keep EQL v2 columns. +- Query operators (`cipherstashEq`, `cipherstashGt`, …) lower to the two-arg `eql_v3.*(...::jsonb)` functions for v3 columns; `cipherstashIlike` maps to `eql_v3.contains` (bloom-filter token containment, not SQL `LIKE`). The domains are `public.*`; the operator functions live in `eql_v3`. +- A new baseline migration `20260601T0100_install_eql_v3_bundle` (invariant `cipherstash:install-eql-v3-bundle-v1`) installs the `public.*` domains and `eql_v3.*` functions. Regenerate contracts and run migrations after changing constructors. +``` + +- [ ] **Step 2: Full green + commit** + +Run: `pnpm --filter @cipherstash/prisma-next test && pnpm --filter @cipherstash/prisma-next build && pnpm --filter @cipherstash/prisma-next lint` +Expected: all PASS. Because Task 1 touched `@cipherstash/stack` public exports, also run: `pnpm --filter @cipherstash/stack test`. + +```bash +git add .changeset/eql-v3-prisma-next.md +git commit -m "docs(prisma-next): changeset for EQL v3 per-domain authoring surface" +``` + +--- + +## Self-Review + +**1. Spec coverage** — each spec section maps to a task: + +| Spec section | Task(s) | +| --- | --- | +| Goal / Public Authoring Surface (per-domain constructors + `*V2`) | 3 | +| Namespace Separation (dedicated `src/v3/`, separate v2/v3 entry points — decision 1b) | 1–10 (structural); v3 entry point in 7 | +| Source of Truth (import `DOMAIN_REGISTRY`, derive; codec id strips `public.`) | 1, 2 | +| Schema split (native types `public.*`; operator functions `eql_v3.*`) | 1 (meta), 4 (codec native type), 6 (operator templates); pinned by 2 drift test | +| Constructor Inventory (Encrypted prefix, exclude `*OrdOre`, BigInt first-class, no v3 JSON, boolean storage-only) | 3 | +| Capability semantics per domain | 3 (typeParams), 6 (gating) | +| Contract Representation (concrete codec id, `public.*` nativeType, typeParams castAs/capabilities; drop redundant eqlType; generated closed union; disjoint ids) | 2 (union/disjoint), 3 (representation) | +| Runtime Codecs (plain JSONB; EncryptedNumber sibling; EncryptedBigInt distinct envelope; `[REDACTED]`) | 4 | +| Middleware (separate v3, `sdk.bulkEncrypt`, JSONB, neutral routing-key stamp) | 5 | +| Operators (`eql_v3.* ::jsonb` templates; capability gating; `EncryptionOperatorError`; ilike→contains; no built-in equality traits; per-domain traits) | 6 | +| Schema Derivation + Stack Adapter (v3-only `cipherstashFromStackV3`; single `EncryptionV3` client; distinct extension id; v2 contract = hard error; exact-domain override validation) | 7 | +| Migrations (v3 baseline from `@cipherstash/eql/sql`; invariant; no add_search_config) | 8 | +| Testing → Unit/authoring (14 items) | 1,2,3,4,5,6,7,8 (mapping below) | +| Testing → Property-based (5) | 9 | +| Testing → Integration/live-PG (7) | 10 | +| Release Notes / changeset | 11 | + +**14 unit tests → tasks:** (1) authoring static per-domain codec id + `public.*` native type = 3; (2) constructor↔domain 1:1 = 3; (3) `*V2` legacy = 3; (4) unexposed/absent surface (`*OrdOre`, no v3 JSON, boolean storage-only, BigInt present) = 3 + 2 (drift); (5) catalog invariants + `public.*` vs `eql_v3.*` = 2; (6) runtime codec plain JSONB / no composite = 4; (7) middleware `sdk.bulkEncrypt` + JSONB = 5; (8) operator SQL lowering = 6; (9) operator gating = 6; (10) trait removal = 6; (11) derivation exact schemas = 7; (12) same-cast_as different-domain divergence = 7; (13) bundling isolation = 10; (14) codec-id disjointness = 2. **All 14 present.** **5 properties** all in Task 9. **7 live suites** all in Task 10. + +**Removed vs. the old (stale) plan:** +- **Old Task 4** (`resolveV3Domain` pure resolver) and **old Task 5** (`cipherstashV3ContractSource` / `source.load` wrapper) are **deleted** — design A has no resolution stage. The "family codec id / Stage 1 / Stage 2 / boolean-option" model and the pinned boolean/bigint/json error strings are gone. +- Old family constructors + boolean-option authoring replaced by derived per-domain constructors (Task 3). +- Old `eql_v3.*` **native types** corrected to `public.*` throughout (catalog, codec descriptors, derivation, override validation); operator **functions** stay `eql_v3.*`. +- Old hand-vendored FFI install fixture replaced by `@cipherstash/eql/sql` `readInstallSql()` (Task 8). +- Boolean domain corrected from `bool` → `boolean` (`public.boolean`, codec id `cipherstash/eql-v3/boolean@1`). +- BigInt promoted from "throws / unavailable" to a first-class family; `EncryptedBigInt` envelope reused (not recreated). + +**2. Placeholder scan** — no `TBD`/`TODO`/"add error handling"/"similar to Task N" left. Every code step shows real code. The remaining "confirm at implementation time" notes (framework static-typeParams shape in Task 3 Step 0; `envelope-double` shape in Task 4 Step 3; codec import paths in Task 4 Step 6; `columnBuilders` name in Task 7 Step 3; `EncryptionV3` entry path in Task 7 Step 6/8; `rawSql` field names in Task 8 Step 3; tsup chunk names in Task 10 Step 1) are each a concrete verification against a named file with a stated fallback, not a deferred design decision. + +**3. Type consistency** — names verified stable across tasks: +- Task 1 → `V3CastAs`, `V3DomainMeta`, `toV3CodecId`, `V3_DOMAIN_META_BY_CODEC_ID`, `V3_CODEC_IDS`, `V3_FACTORY_BY_NATIVE_TYPE`, `EXPOSED_DOMAIN_ENTRIES`, `isOrdOreDomain`, `envelopeTypeNameForCastAs` — consumed unchanged in 2,3,4,6,7,9. +- Task 2 → `CipherstashV3CodecId`, `CIPHERSTASH_V3_CODEC_IDS`, `CIPHERSTASH_V3_CODEC_ID_SET`, `isCipherstashV3CodecId`, `v3TraitsForCapabilities`, trait constants, `CIPHERSTASH_V3_INVARIANTS`, `CIPHERSTASH_V3_BASELINE_MIGRATION_NAME` — consumed in 3,4,5,6,7,8. +- Task 3 → `v3PascalName` / `v3CamelName` — consumed in 3 tests, 9. +- Task 4 → `EncryptedNumber` (`typeName='EncryptedNumber'`, distinct from `EncryptedDouble`), `v3ToDriver`/`v3FromDriver`, `createV3CodecDescriptors`, `CipherstashV3CellCodec` — consumed in 5,6,7,9,10. +- Task 5 → `bulkEncryptMiddlewareV3`; exported `stampRoutingKeysFromAst` — consumed in 7. +- Task 6 → `cipherstashV3QueryOperations`, `EncryptionOperatorError` — consumed in 7,9,10. +- Task 7 → `deriveStackSchemasV3`, `V3ContractShape`, `createCipherstashV3Sdk`, `createCipherstashV3RuntimeDescriptor`, `assertV3SchemasAgree` — consumed in 9 (derivation), 10 (live). +- `EncryptedBigInt` is REUSED from `../execution/envelope-bigint` in 4 and 6 (never recreated); `EncryptedString`/`EncryptedDate`/`EncryptedBoolean` reused identically. + +No signature drift found. Plan is internally consistent. + +--- + +**Plan complete and saved to `docs/superpowers/plans/2026-07-08-eql-v3-prisma-next.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?** diff --git a/docs/superpowers/specs/2026-07-08-eql-v3-prisma-next-design.md b/docs/superpowers/specs/2026-07-08-eql-v3-prisma-next-design.md new file mode 100644 index 000000000..63d1f5f05 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-eql-v3-prisma-next-design.md @@ -0,0 +1,467 @@ +# EQL v3 Prisma-next Default Design + +Status: proposed +Date: 2026-07-08 +Revised: 2026-07-08 (design A — capabilities encoded in the type system; per-domain constructors; boolean-option resolver removed) +Revised: 2026-07-09 (audit vs `feat/eql-v3-supabase-adapter`: domains are `public.*` / operator functions stay `eql_v3.*`; BigInt is now a first-class v3 family; boolean domain is `public.boolean`; catalog derived from `DOMAIN_REGISTRY`) +Revised: 2026-07-09c (Prisma Next 0.14 alignment: `packages/prisma-next` now pins `@prisma-next/*` at 0.14.0 — contract storage/domain shapes, the emit/re-pin loop, and test-harness requirements changed between 0.8 and 0.14; the operational details live in the companion plan's "Prisma Next 0.14 ground truth" section. Source line references in this spec are 0.8-era anchors and may have drifted by a few lines. The plan's live-suite list also replaces the mixed-client test with side-by-side v2/v3 clients per decision 1b.) +Revised: 2026-07-09b (review remediation: **concrete per-domain types are the spine** — see [Type Discipline]; v2/v3 are **separate entry points**, no in-one-client mixing (bare names are v3, `*V2` is legacy — a breaking major, no transition guard); encrypted ORDER BY (`asc`/`desc` → `eql_v3.ord_term`) in scope; `between` parenthesisation + `bigintSafeReplacer` are correctness invariants; framework-neutral primitives (codec, gate index-sets, fn-schema/op-map/paren-rule) lifted to `eql/v3/`; tests require a plaintext oracle, real properties, full-string operator pins, and mandatory `*.test-d.ts`) +Target package: `@cipherstash/prisma-next` +Reference branches: +- `feat/eql-v3-supabase-adapter` (base) for the current FFI 0.28 concrete domain catalog (`public.*` domains, `eql_v3.*` operator functions), the `DOMAIN_REGISTRY` source of truth, and the canonical v3 adapter shape (Supabase is PostgREST, so its operator model does not port; use it for catalog/derivation/wire-format, not for SQL operator lowering). +- `eql-v3-drizzle-concrete-types` for the canonical **SQL** integration shape, operator lowering (two-arg `eql_v3.*(...::jsonb)`), and plain-jsonb v3 wire format — this is the model prisma-next mirrors. +- Operator SQL is proven live in `packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts` and `schema-v3-pg.test.ts`. + +## Goal + +Change `@cipherstash/prisma-next` so that EQL v3 columns are authored through **concrete per-domain constructors** that map 1:1 to the `eql_v3.*` Postgres domains. Capability is encoded in the type system: the constructor you choose *is* the capability set. There is no boolean-option surface and no runtime domain-resolution stage. + +This is the original intent of the v3 work. EQL v3 makes each capability combination its own Postgres domain (`public.text_eq`, `public.text_search`, …), so capability is a property of the type, not runtime configuration. The authoring surface mirrors that: one constructor per exposed domain, each carrying a static codec id. + +> **Schema note (protect-ffi 0.28).** Two different Postgres schemas are in play and must not be conflated: +> - **Domains / native types live in `public`** — `public.text_eq`, `public.integer_ord`, `public.boolean`, `public.bigint_ord`. The stack catalog pins this at the type level: `eqlType: \`public.${string}\``. This is the column's `nativeType`. +> - **Operator functions live in `eql_v3`** — `eql_v3.eq(...)`, `eql_v3.gt(...)`, `eql_v3.contains(...)`, `eql_v3.ord_term(...)`. This is the SQL the operators lower to. +> +> Wherever this spec refers to a *domain* it is `public.*`; wherever it refers to an *operator function* it is `eql_v3.*`. The `cipherstash/eql-v3/...` codec-id namespace is a logical version tag, unrelated to either Postgres schema. + +EQL v2 remains available only through explicit legacy `*V2` names where a compatibility path is needed. + +### Why not the boolean-option surface + +The v2 authoring surface (`EncryptedString({ equality, freeTextSearch, orderAndRange })`) exists because v2 has a **single** native type (`eql_v2_encrypted`) shared by all codecs, with searchability layered on at migration time via `add_search_config` / `remove_search_config`. In v2, capability is runtime configuration applied to an opaque encrypted blob, and the booleans are the front-end for that config. + +v3 deletes that model: query behavior is encoded by the concrete domain, and v3 columns must **not** emit `add_search_config` / `remove_search_config` (see [Migrations](#migrations)). Carrying the boolean surface into v3 would require a resolution stage to translate booleans into a domain and to reject the impossible combinations at runtime — reintroducing a v2 configuration model on top of a v3 type model, and degrading compile-time guarantees into runtime throws. Design A drops the booleans so that: + +- **Illegal capability combinations are unrepresentable**, not runtime errors. There is no `EncryptedTextEqMatch()` constructor because there is no `public.text_eq_match` domain, so the combination simply cannot be authored. +- **The codec id is static and known at authoring time.** `(codec id) ⟺ (eql_v3.* domain) ⟺ capabilities` is bijective; nothing is resolved. +- **Autocomplete lists the valid domains.** Discoverability comes from the constructor set, not from a checkbox matrix where most combinations throw. + +## Type Discipline (the spine) + +Concrete per-domain types are the **core** of EQL v3 and MUST be preserved end-to-end — from the imported catalog, through authoring and the contract descriptor, into derivation and the stack client. This is the load-bearing invariant of the whole design, not a convenience mirrored from another adapter. A v3 implementation that widens columns to a single `AnyEncryptedV3Column`, or descriptors to `{ codecId: string; capabilities: unknown }`, has discarded the exact thing v3 exists to encode. Every task must be specified so the types flow; "derive it like Drizzle" is not a specification. + +Requirements (each is independently type-tested): + +- **Distinct type per domain.** `encryptedBigIntOrd()` and `encryptedText()` have *different* static types. Two factories for two different domains never share a type. +- **Literal codec id.** A descriptor's `codecId` is a member of the generated literal union `CipherstashV3CodecId` (e.g. `'cipherstash/eql-v3/text_eq@1'`), never `string`. +- **Literal native type.** `nativeType` is the concrete `` `public.${Domain}` `` literal (e.g. `'public.text_eq'`), never widened to `string` or bare `` `public.${string}` ``. +- **Concrete capabilities.** `capabilities` is the domain's literal `QueryCapabilities` (e.g. `{ equality: true; orderAndRange: false; freeTextSearch: false }`), never `unknown` or a widened `QueryCapabilities` that loses the boolean literals. +- **Derivation preserves identity.** `deriveStackSchemasV3` maps each column to its concrete stack factory + column class (`types.TextEq` → `EncryptedTextEqColumn`) and threads that concrete type into `encryptedTableV3`. It MUST NOT widen to `AnyEncryptedV3Column` before building the table — that erases the per-column plaintext / capability inference the stack factories exist to provide. +- **Type-level tests are mandatory** (`*.test-d.ts`), not optional. They pin that the factories are mutually non-assignable, that `codecId` / `nativeType` / `capabilities` are the literal types (not their widenings), and that derivation round-trips the concrete column type. A runtime-only suite does not protect this invariant. + +The stack already carries these types — each `types.*` factory returns a concrete `EncryptedXColumn` with literal `eqlType` / `castAs` / `capabilities`. prisma-next's job is to *thread them through generically*, never to collapse them to a uniform descriptor. + +## Non-Goals + +- Do not add a boolean-capability authoring surface or a domain-resolution stage. Capability is chosen by constructor identity. +- Do not expose `*_ord_ore` variants in this release. The catalog defines them (`EncryptedTextOrdOreColumn`, etc.), but prisma-next surfaces only the `_ord` order domains. `_ord_ore` remains unexposed. +- Do not use `encryptQuery` or v3 term-only operands in Prisma-next. The canonical Drizzle branch uses full encrypted operands and public two-arg SQL functions. +- Do not invent domains that the FFI catalog does not expose. Searchable JSON is not in the current v3 catalog, so there is **no** v3 JSON constructor. (BigInt **is** in the catalog as of protect-ffi 0.28 — `public.bigint` / `bigint_eq` / `bigint_ord` / `bigint_ord_ore` — and is exposed as a first-class v3 family; see [Constructor Inventory](#constructor-inventory).) +- Do not hand-maintain a local copy of the v3 domain catalog. Import it and derive the constructor set, codec ids, capabilities, and cast kinds from it (see [Source of Truth](#source-of-truth)). +- Do not cross the v2 and v3 implementations. v3 is a self-contained namespace; do not branch v2 code paths with `if (isV3)` wire-format switches (see [Namespace Separation](#namespace-separation)). + +## Namespace Separation + +v3 is implemented as a parallel, self-contained namespace, not as v3-aware branches grafted onto the v2 code. This is a hard constraint, not a stylistic preference: the two versions have different wire formats, clients, codecs, migrations, and query lowering, and entangling them is exactly how cross-version leaks (e.g. an `instanceof` dispatch matching the wrong version) and ambiguous contracts arise. + +Rules: + +- **Separate module subtree.** All v3 implementation lives under a dedicated namespace — `src/v3/` for codecs/middleware/operators/adapter and `extension-metadata/constants-v3.ts` for ids/traits/invariants. v3 modules must not import v2 codec, wire (`encodeEqlV2EncryptedWire` / composite-literal), middleware, or adapter modules. +- **Separate everything on the wire/runtime path.** Distinct v3 codecs, v3 bulk-encrypt middleware, v3 operator lowering, v3 SDK adapter, and the v3 `EncryptionV3` client. No v2 function gains a v3 branch. +- **Separate entry points, not a shared dispatch.** v2 and v3 are distinct extension factories with distinct extension ids, registered one-per-client (the framework `OperationRegistry` is a flat method-keyed map that disallows override, so two descriptors sharing `cipherstash*` method names cannot co-register). There is no runtime codec-id-namespace router inside a single client, and mixed v2+v3 columns in one client are not supported this release (see [Schema Derivation](#schema-derivation-and-stack-adapter)). Each version flows through fully isolated code from authoring to client. +- **Permitted shared primitives.** Only genuinely version-neutral, presentation-layer primitives may be shared: the `EncryptedEnvelopeBase` class and framework-level authoring/trait infrastructure. Where a v3 column reuses a user-facing value type, it is surfaced through the v3 barrel and must not pull in any v2 wire/codec code. + +## Source of Truth + +The v3 domain catalog comes from `@cipherstash/stack/eql/v3` after the FFI sync. Prisma-next must **import** this catalog and derive the constructor set, domain names, plaintext cast kinds, capabilities, and codec ids from it. Do not duplicate it as a local table. + +The catalog exports a `types` namespace of per-domain factories (`packages/stack/src/eql/v3/types.ts`), each backed by a concrete column class, plus a `DOMAIN_REGISTRY` (`packages/stack/src/eql/v3/domain-registry.ts`) mapping the **bare** (unqualified) Postgres domain name to its factory: + +``` +types.Text, types.TextEq, types.TextMatch, types.TextOrd, types.TextOrdOre, types.TextSearch +types.Integer, types.IntegerEq, types.IntegerOrd, types.IntegerOrdOre +types.Smallint, types.SmallintEq, types.SmallintOrd, types.SmallintOrdOre +types.Bigint, types.BigintEq, types.BigintOrd, types.BigintOrdOre +types.Numeric, types.NumericEq, types.NumericOrd, types.NumericOrdOre +types.Real, types.RealEq, types.RealOrd, types.RealOrdOre +types.Double, types.DoubleEq, types.DoubleOrd, types.DoubleOrdOre +types.Date, types.DateEq, types.DateOrd, types.DateOrdOre +types.Timestamp, types.TimestampEq, types.TimestampOrd, types.TimestampOrdOre +types.Boolean +``` + +Catalog constraints: + +1. There is no searchable v3 JSON domain. BigInt **is** present (`public.bigint` / `bigint_eq` / `bigint_ord` / `bigint_ord_ore`, `cast_as: 'bigint'`, plaintext = JS `bigint`). +2. `public.boolean` (`types.Boolean`) is storage-only. It does not advertise equality; there is only one boolean domain (no `boolean_eq`). +3. The `*OrdOre` factories exist but are **not** exposed by prisma-next this release (this now includes `types.BigintOrdOre`). + +Reuse technique: import `DOMAIN_REGISTRY` / `factoryForDomain` / `stripDomainSchema` from `@cipherstash/stack/eql/v3` and iterate the registry. Each registry key is the bare domain name; each value is a factory that builds a concrete column whose `getEqlType()` returns the `public.`-qualified type, `getQueryCapabilities()` returns `{ equality, orderAndRange, freeTextSearch }`, and `build()` returns `{ cast_as, indexes }`. Derive the codec id from the bare name (`cipherstash/eql-v3/${bareName}@1`); derive `nativeType` from `getEqlType()` (already `public.*`); use `stripDomainSchema()` when you need the bare name from a `public.`-qualified type. The derivation *is* the catalog; there is no separate local table to pin. Retain one drift test only for the FFI-catalog invariants that are not structural (no searchable JSON domain; `public.boolean` is storage-only; `*OrdOre` intentionally unexposed). + +> Note: `getEqlType()` is metadata only — `build()` emits `cast_as` + `indexes` and never the domain name. `integer_ord` and `integer_ord_ore` build byte-identically; they differ **only** in `getEqlType()` (the `public.*` domain). This is why override/derivation validation must compare exact domain identity, not just `cast_as` + index keys. + +## Public Authoring Surface + +One constructor per exposed v3 domain, mirroring the catalog `types.*` names with an `Encrypted` prefix. Each constructor has a static codec id; the constructor name is the capability. + +### Constructor Inventory + +All constructors below are **new** — none of the v3 constructors reuses a v2 name for v3 behavior. Capability is read from the constructor: + +| Family | Storage-only | `+ equality` | `+ equality + order/range` | `+ free-text` | full | +| --- | --- | --- | --- | --- | --- | +| Text | `EncryptedText` | `EncryptedTextEq` | `EncryptedTextOrd` | `EncryptedTextMatch` | `EncryptedTextSearch` | +| Integer | `EncryptedInteger` | `EncryptedIntegerEq` | `EncryptedIntegerOrd` | — | — | +| Smallint | `EncryptedSmallint` | `EncryptedSmallintEq` | `EncryptedSmallintOrd` | — | — | +| BigInt | `EncryptedBigInt` | `EncryptedBigIntEq` | `EncryptedBigIntOrd` | — | — | +| Numeric | `EncryptedNumeric` | `EncryptedNumericEq` | `EncryptedNumericOrd` | — | — | +| Real | `EncryptedReal` | `EncryptedRealEq` | `EncryptedRealOrd` | — | — | +| Double | `EncryptedDouble` | `EncryptedDoubleEq` | `EncryptedDoubleOrd` | — | — | +| Date | `EncryptedDate` | `EncryptedDateEq` | `EncryptedDateOrd` | — | — | +| Timestamp | `EncryptedTimestamp` | `EncryptedTimestampEq` | `EncryptedTimestampOrd` | — | — | +| Boolean | `EncryptedBoolean` (storage-only) | — | — | — | — | + +Notes: + +- `EncryptedTextOrd` maps to `public.text_ord` (equality + order/range). `EncryptedTextSearch` maps to `public.text_search` (equality + free-text + order/range). `public.text_ord_ore` is not exposed. +- Scalar `*Ord` (including `EncryptedBigIntOrd`) maps to the `public.*_ord` domain (equality + order/range); `*_ord_ore` is not exposed. +- `EncryptedBigInt*` maps to `public.bigint` / `bigint_eq` / `bigint_ord` (`cast_as: 'bigint'`, plaintext = JS `bigint`). `EncryptedBigIntOrdOre` is not exposed. +- `EncryptedBoolean` maps to storage-only `public.boolean`. There is no boolean equality constructor until the FFI exposes a boolean equality domain. +- There is **no** `EncryptedJson` v3 constructor. The name does not exist on the v3 surface; use `EncryptedJsonV2` (see [JSON](#json)). + +The constructor set is **derived from the catalog**, not hand-listed: iterate the exposed `types.*` factories (excluding `*OrdOre`) and generate one authoring constructor per factory. Adding a domain to the catalog adds a constructor automatically; removing one removes it. + +### PSL / TS surface + +```prisma +model User { + email cipherstash.EncryptedTextSearch() // eq + free-text + order/range + name cipherstash.EncryptedTextEq() // equality only + notes cipherstash.EncryptedText() // storage only + age cipherstash.EncryptedIntegerOrd() // eq + order/range + tier cipherstash.EncryptedInteger() // storage only + balance cipherstash.EncryptedBigIntOrd() // eq + order/range (JS bigint) + birthday cipherstash.EncryptedDateEq() // equality only + createdAt cipherstash.EncryptedTimestampOrd() // eq + order/range + active cipherstash.EncryptedBoolean() // storage only +} +``` + +The TS factories mirror the PSL constructors 1:1 (camelCase): + +```ts +encryptedTextSearch() +encryptedTextEq() +encryptedText() +encryptedIntegerOrd() +encryptedInteger() +encryptedBigIntOrd() +encryptedDateEq() +encryptedTimestampOrd() +encryptedBoolean() +``` + +Legacy v2 is explicit and unchanged: + +```prisma +legacyEmail cipherstash.EncryptedStringV2() +legacyData cipherstash.EncryptedJsonV2() +``` + +```ts +encryptedStringV2() +encryptedJsonV2() +``` + +### Capability semantics per domain + +The capability a constructor grants is defined by its domain's `getQueryCapabilities()`, not by any local flag: + +- **Storage-only** (`EncryptedText`, `EncryptedInteger`, …, `EncryptedBoolean`): encrypt/store/decrypt only. Every search operator is rejected at operator time. +- **`*Eq`**: equality (`=`, `IN`) only. +- **`*Ord`**: equality plus comparison / range / ordering (`<`, `>`, `BETWEEN`, `ORDER BY`). +- **`EncryptedTextMatch`**: free-text token containment only. +- **`EncryptedTextSearch`**: equality + free-text + order/range. + +There is no combination to validate at authoring time: unrepresentable combinations have no constructor. The only capability enforcement at runtime is **operator gating** (see [Operators](#operators)) — rejecting an operator a column's domain does not support — which reads the domain's capabilities directly. + +### JSON + +`EncryptedJson` has **no** v3 constructor. The v3 barrel does not export it, so `cipherstash.EncryptedJson()` is an unresolved name at authoring time (PSL / TS "unknown constructor"), not a runtime throw. Documentation and the changeset must direct users to the v2 alias: + +- searchable JSON → `EncryptedJsonV2` / `encryptedJsonV2`. + +BigInt, by contrast, **is** a first-class v3 family as of protect-ffi 0.28: `EncryptedBigInt` / `EncryptedBigIntEq` / `EncryptedBigIntOrd` (`cast_as: 'bigint'`, plaintext = JS `bigint`, backed by `public.bigint` / `bigint_eq` / `bigint_ord`). The legacy `EncryptedBigIntV2` / `encryptedBigIntV2` remain available only for explicit v2 compatibility. + +`EncryptedTimestamp*` maps to `cast_as: "timestamp"` and preserves time of day. `EncryptedDate*` maps to `cast_as: "date"` and reconstructs to `Date`. + +## Contract Representation + +Each v3 column descriptor is **static at authoring** — there is no post-authoring rewrite. The constructor emits its concrete descriptor directly: + +- `codecId`: the concrete per-domain v3 codec id, **derived from the domain**, not a reused v2 codec id. +- `nativeType`: the concrete `public.*` domain (from `getEqlType()`). +- `typeParams`: + - `castAs` + - `capabilities` + +### The codec id is a projection of the domain, not independent data + +In v2, one native type (`eql_v2_encrypted`) is shared by all six codecs, so the codec id is the only discriminator and is genuinely independent data. In v3 the relationship inverts: each domain is its own Postgres domain, so `(codec id) ⟺ (public.* domain)` is bijective. The codec id therefore carries no discriminating information the domain does not already have. Its two remaining jobs are: + +1. **Serialization handle.** The contract is JSON; the domain *type* exists only at compile time. The codec id is the string key that reconstitutes the runtime codec/envelope/trait bundle from persisted JSON. +2. **Version axis.** The `@N` suffix (which the bare domain name lacks) is exactly how v2 and v3 — same logical type, different wire format — stay distinguishable, and how a future wire-format change to a domain would be versioned. + +Consequences for the design: + +- **Derive, do not author.** Codec ids are generated from the catalog as `cipherstash/eql-v3/${bareDomain}@1`, not hand-listed, where `bareDomain` is the `DOMAIN_REGISTRY` key (equivalently `stripDomainSchema(getEqlType())` — the domain with the `public.` prefix removed). Iterate `DOMAIN_REGISTRY` rather than re-deriving. +- **Single source for the type.** `nativeType` is the single source; do not also carry a redundant `typeParams.eqlType`. +- **Type the ids as a generated closed union.** Produce `CIPHERSTASH_V3_CODEC_IDS` (const tuple) → `CipherstashV3CodecId` (union) → set + guard, mirroring `CIPHERSTASH_CODEC_IDS` / `CipherstashCodecId` / `isCipherstashCodecId` (`extension-metadata/constants.ts:88-123`) so every codec-id-keyed dispatch table is compile-time-exhaustive. +- **No id reuse.** v3 must not reuse v2 codec ids such as `cipherstash/string@1`. Tests must assert the v3 and v2 id sets are disjoint. + +Type the descriptor fields with the imported stack types rather than free strings: `nativeType` as `` `public.${string}` `` / `EqlTypeForColumn`, `capabilities` as the exported `QueryCapabilities` (`{ equality, orderAndRange, freeTextSearch }`), `castAs` restricted to `PlaintextKind` (`'string' | 'number' | 'bigint' | 'boolean' | 'date' | 'timestamp'`) / `DateLikeCast`. + +Legacy v2 aliases keep the existing v2 codec ids and `eql_v2_encrypted` native type. + +Place v3 ids/traits/invariants in a dedicated `extension-metadata/constants-v3.ts` (or `v3/` subtree) rather than swelling the v2 `constants.ts`. + +## Runtime Codecs + +V3 columns are PostgreSQL domains over `jsonb`. Runtime encoding and decoding must use plain JSONB, matching `v3ToDriver` / `v3FromDriver` from the Drizzle integration: + +```ts +// `bigintSafeReplacer` is load-bearing and MUST be copied with the codec: a +// well-formed envelope never carries a bigint (bigint plaintext is encrypted to +// ciphertext strings first), but a malformed envelope with a stray bigint would +// otherwise throw `TypeError: Do not know how to serialize a BigInt`. A copy +// that drops it is a regression (see `eql/v3/drizzle/codec.ts:18,28`). +function bigintSafeReplacer(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? value.toString() : value +} + +function v3ToDriver(value: unknown): string | null { + if (value === null || value === undefined) return null + return JSON.stringify(value, bigintSafeReplacer) +} +``` + +Per [Operators](#operators), this codec (`v3ToDriver` / `v3FromDriver` / `bigintSafeReplacer`) is a **shared** framework-neutral primitive lifted to `eql/v3/codec.ts` and imported by both the Drizzle and prisma-next adapters — not re-implemented here. + +v3 codecs live in the v3 subtree and depend only on the neutral `EncryptedEnvelopeBase`; they must not import the v2 composite/wire codec. The v3 codec factory reuses the version-neutral user-facing value classes where the type already matches (surfaced through the v3 barrel) and introduces a number envelope for number-backed v3 domains: + +- text -> `EncryptedString` +- integer, smallint, numeric, real, double -> `EncryptedNumber` +- bigint -> `EncryptedBigInt` (bigint-backed, **not** `EncryptedNumber`) +- date, timestamp -> `EncryptedDate` +- boolean -> `EncryptedBoolean` + +`EncryptedNumber` and `EncryptedDouble` must be **siblings**, each extending `EncryptedEnvelopeBase` with its own distinct `typeName`. Do **not** alias or subclass one from the other: `typeName` drives the `$encryptedX` placeholder marker that `decryptAll` keys on (`envelope-base.ts:87-99`), so aliasing renders the wrong marker, and subclassing makes `value instanceof EncryptedNumber` true for a v2 `EncryptedDouble` — a cross-version leak in any coercer that dispatches on `instanceof` (`execution/operators.ts:202-233`). Each concrete v3 number domain sets `renderOutputType` to `'EncryptedNumber'`. `EncryptedDouble` (the value class) remains available for legacy v2 compatibility only; v3 integer/numeric constructors must not render as `EncryptedDouble`. + +`EncryptedBigInt` is a **separate** value envelope extending `EncryptedEnvelopeBase` with its own `typeName` — the `bigint` `cast_as` domains (`public.bigint*`) carry JS `bigint` plaintext, which is not a `number`, so they must not reuse the `EncryptedNumber` envelope. `bigint` values are lossless across the FFI boundary; the runtime wire codec still `JSON.stringify`s the *ciphertext envelope* (never the raw `bigint`), so JSON's lack of native `bigint` support does not arise on the wire path. + +The runtime codec descriptor should: + +- advertise the concrete domain in `targetTypes` +- use plain JSONB for parameter wire encoding +- never use the v2 composite-literal codec +- emit `[REDACTED]` placeholders through the existing envelope base + +## Middleware + +Add a **separate** v3 bulk-encrypt middleware under the v3 subtree. Do not generalize the v2 middleware with a wire-format branch (see [Namespace Separation](#namespace-separation)). The v3 middleware may reuse the neutral routing-key stamping *utility*, but must not share the v2 encode path. + +Required behavior: + +1. Filter parameters by the v3 codec id set. +2. Stamp `(table, column)` routing keys from INSERT/UPDATE ASTs, reusing the neutral `stampRoutingKeysFromAst` / `setHandleRoutingKey` utility (`bulk-encrypt.ts:80-81, 198-231`) — a version-neutral primitive, not the v2 encode path. +3. For search operands produced by operators, rely on operator-time routing-key stamping (`execution/operators.ts:147-158`). +4. Group by routing key. +5. Call `sdk.bulkEncrypt`, not `sdk.bulkEncryptQuery`. (`bulkEncrypt` is already the established prisma-next path — `bulk-encrypt.ts:121`.) +6. Replace parameter values with plain JSONB encoded ciphertext payloads (not the v2 composite from `encodeEqlV2EncryptedWire`). + +There is no query/storage split in this design. Search operands are full encrypted payloads because the canonical Drizzle branch lowers to public two-arg functions that accept `$::jsonb` full operands. + +## Operators + +Keep the existing cipherstash-prefixed methods: + +- `cipherstashEq`, `cipherstashNe` +- `cipherstashInArray`, `cipherstashNotInArray` +- `cipherstashGt`, `cipherstashGte`, `cipherstashLt`, `cipherstashLte` +- `cipherstashBetween`, `cipherstashNotBetween` +- `cipherstashIlike`, `cipherstashNotIlike` +- `cipherstashAsc`, `cipherstashDesc` — encrypted ordering, lowering to `eql_v3.ord_term({{self}})`, gated on an `ore` index. In scope this release (Drizzle parity: `operators.ts:497-502`). Storage-only / non-`ore` columns reject ordering with `EncryptionOperatorError`. + +For v3 columns, lower to the same SQL shape as the canonical Drizzle branch. The prisma-next lowering mechanism is a template with `{{self}}` / `{{arg0}}` placeholders (`execution/operators.ts:302-326`); the v3 templates must carry the `::jsonb` cast, which the v2 templates do not: + +``` +eql_v3.eq({{self}}, {{arg0}}::jsonb) +eql_v3.neq({{self}}, {{arg0}}::jsonb) +eql_v3.gt({{self}}, {{arg0}}::jsonb) +eql_v3.gte({{self}}, {{arg0}}::jsonb) +eql_v3.lt({{self}}, {{arg0}}::jsonb) +eql_v3.lte({{self}}, {{arg0}}::jsonb) +eql_v3.contains({{self}}, {{arg0}}::jsonb) +eql_v3.ord_term({{self}}) +``` + +**`cipherstashBetween` / `cipherstashNotBetween` parenthesisation is a correctness invariant, not a style choice.** The range template MUST be a single self-contained, parenthesised conjunction: + +``` +(eql_v3.gte({{self}}, {{arg0}}::jsonb) AND eql_v3.lte({{self}}, {{arg1}}::jsonb)) +``` + +Postgres binds `NOT` tighter than `AND`. Emitted bare, `NOT (between)` parses as `(NOT gte) AND lte` — selecting rows *below the lower bound* instead of the complement of the range (this is the exact class of bug fixed in `9c82f50e`; the reference documents it at `sql-dialect.ts:26-37`). `cipherstashNotBetween` then emits `NOT `. A test MUST pin the full parenthesised string for both, and assert `not(between)` wraps the whole conjunction. Emitting the two calls unwrapped is a defect even though each half is individually correct. + +The operator **functions** live in the `eql_v3` schema (unchanged by 0.28) even though the **domains** they operate on are `public.*`; do not "correct" these to `public.*`. This exact two-arg `::jsonb` shape is proven end-to-end against real domains in `packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts` and `schema-v3-pg.test.ts` (e.g. `eql_v3.eq("col", $::jsonb)`, `eql_v3.contains("col", $::jsonb)`, range = `eql_v3.gte(...) AND eql_v3.lte(...)`). The `::jsonb` template change and the middleware wire-format branch (plain JSON, not the v2 composite) must be implemented together — the template consumes exactly what the middleware produces. + +The operand is the **full encrypted envelope** (every index term), not a narrowed query term: the `encryptQuery` scalar-term path is unsupported in protect-ffi 0.28 (`EQL_V3_QUERY_UNSUPPORTED`), and the SQL function chosen — not a `queryType` — selects which term is compared. On a pure-ORE domain (no `unique`/`hm` index) `eql_v3.eq` resolves via `ord_term` (equality-via-ORE); this is why the equality gate accepts `unique` **or** `ore`. `eql_v3.contained_by` also exists as the dual of `contains`, but prisma-next exposes only `contains` (via `cipherstashIlike`) this release. + +`cipherstashIlike` / `cipherstashNotIlike` lower to `eql_v3.contains` (bloom-filter token containment, not SQL `LIKE`). Carry this caveat into user docs so wildcard semantics are not implied. (The Drizzle reference names this operator `contains`; prisma-next keeps its `ilike` method name — an intentional surface divergence.) + +Capability gating must read the domain's built `indexes` (from `column.build().indexes`), not any authoring flag. The gate is a disjunction — any listed index grants the capability: + +- equality (`eq`, `ne`, `IN`, `NOT IN`) requires `unique` **or** `ore` +- comparison / range / ordering (`gt`/`gte`/`lt`/`lte`, `between`/`notBetween`, `asc`/`desc`) requires `ore` +- free-text search (`ilike`) requires `match` +- storage-only domains (no index) reject every search operator + +This gating is the **only** runtime capability enforcement in the design — there is no authoring-time combination validation because impossible combinations have no constructor. + +**Shared, framework-neutral primitives — lift to `packages/stack/src/eql/v3/`, do not re-copy.** Three things the Drizzle adapter currently keeps under its own subtree have **no** Drizzle dependency and are a correctness surface that must not diverge between adapters: + +1. The **wire codec** `v3ToDriver` / `v3FromDriver` **including `bigintSafeReplacer`** (currently `eql/v3/drizzle/codec.ts`, but it imports only `Encrypted` from `@/types`). Move to `eql/v3/codec.ts`; both adapters import it. +2. The **gate index-sets** `EQUALITY_INDEXES = ['unique','ore']`, `ORE_INDEXES = ['ore']`, `MATCH_INDEXES = ['match']` and the `requireIndex` disjunction rule (currently `eql/v3/drizzle/operators.ts:205-221`). These decide which operators a domain may answer — two divergent copies is a correctness bug, not a DRY nit. +3. The **function-schema + op→function-name map + parenthesisation rule**: `EQL_V3_FN_SCHEMA = 'eql_v3'`, the `eq→eq`/`ne→neq`/`gt→gt`/… name map, and the "range is a parenthesised conjunction" rule (currently `eql/v3/drizzle/sql-dialect.ts`). The *fragment builders* can't be shared verbatim (Drizzle emits `sql` fragments; prisma-next emits `{{self}}`/`{{arg0}}` template strings), but the schema, the name map, and the paren rule can and must be. Had they been shared, the between bug above could not exist in two places. + +This expands scope to touch the existing Drizzle adapter (it re-imports from the lifted location). That is intended. + +Add a real exported error class `EncryptionOperatorError` carrying a `context: { columnName?, tableName?, operator? }` (mirroring `eql/v3/drizzle/operators.ts:66-78`). The two adapters keep **separate** `EncryptionOperatorError` classes by deliberate fork (they are independently-versioned entry points — see the "INTENTIONAL FORK" note in the reference); this is the one place duplication is correct. Prisma-next operators currently throw plain `TypeError` / `Error`; "structured like" is not enough — the class must exist and be exported. + +Per-domain trait derivation: each concrete v3 codec descriptor derives its `cipherstash:*` traits from the domain's `QueryCapabilities` keys (`equality → cipherstash:equality`, etc.), a single mapping reused across domains. + +Do not attach built-in framework equality traits that would re-enable plain SQL `=`. CipherStash operators stay in the `cipherstash:*` trait namespace (regression-pinned by `test/equality-trait-removal.test.ts`). + +## Schema Derivation and Stack Adapter + +**v2 and v3 are separate entry points — they are never co-registered in one Prisma-next client.** The framework's `OperationRegistry` is a flat, method-name-keyed map that disallows override; two descriptors both defining `cipherstashEq` collide at registration regardless of extension id. Rather than overload one gated descriptor, v3 ships as its **own** extension factory with its **own** distinct extension id/version, keeping the **same** `cipherstash*` operator method names. A given client is either v2 or v3. + +Consequences: + +- **No single-dispatch-boundary / codec-id-namespace routing inside one client.** There is no shared `deriveStackSchemas` that branches v2 vs v3 at runtime, and no per-client registry that holds both versions. Delete that machinery. +- **Mixed v2 + v3 columns in a single client are not supported this release.** A schema is authored for one version. (A codebase can still run a v2 client and a v3 client side by side; they do not share a registry, descriptor, or dispatch.) +- **`deriveStackSchemasV3(contractJson)` is a v3-only derivation.** It reads the v3 codec ids and builds concrete `@cipherstash/stack/eql/v3` schemas, preserving the concrete column type (see [Type Discipline](#type-discipline-the-spine)): + +```ts +// nativeType 'public.text_eq' -> concrete factory types.TextEq -> EncryptedTextEqColumn +encryptedTableV3(table, { [column]: types.TextEq(column) }) +``` + +It MUST NOT widen the built column to `AnyEncryptedV3Column` before constructing the table. A v3 contract containing a v2 codec id is a hard error (wrong entry point), not a silently-derived v2 column. + +- **v3 client.** `cipherstashFromStackV3` builds a single `EncryptionV3({ schemas })` client from the v3-derived schemas. v3 bulk-encrypt goes through `EncryptionV3(...)`, never the base v2 `bulkEncrypt`. The v3 SDK adapter/registry accepts the v3 concrete columns; the v2 registry (`sdk-adapter.ts:136`) is untouched and unaware of v3. + +Override validation for v3 columns lives in the v3 derivation path and must compare **exact v3 domain identity**, not only `cast_as` and index keys (`from-stack.ts:149-176` cannot distinguish `integer_ord` from `integer_ord_ore` — they have identical `cast_as` and built indexes and differ only in `nativeType`). Two columns with the same `cast_as` but different domains are not equivalent. v2 override validation is unchanged. + +## Migrations + +Add a v3 bundle baseline migration after the existing v2 baseline. Keep the v2 baseline while legacy v2 aliases exist. + +V3 columns must not emit v2 `add_search_config` / `remove_search_config` lifecycle operations. Their query behavior is encoded by the concrete PostgreSQL domain. + +The migration package must include: + +- EQL v3 install SQL sourced from `@cipherstash/eql/sql` (`releaseManifest`, `@cipherstash/eql@3.0.0-alpha.3` — the same source `packages/stack/scripts/install-eql-v3.ts` / `installEqlV3IfNeeded` use), **not** a hand-vendored FFI fixture. This install creates the `public.*` domains and the `eql_v3.*` operator functions. +- new invariant id `cipherstash:install-eql-v3-bundle-v1` +- migration directory `20260601T0100_install_eql_v3_bundle`, which sorts after the v2 install (`20260601T0000_install_eql_bundle` / `cipherstash:install-eql-bundle-v1`) + +## Testing + +Target three levels — unit, property, and integration. + +### Unit / authoring + +Add or update tests in `packages/prisma-next/test` (each maps to an existing file that gains v3 cases unless noted): + +1. Authoring: each v3 constructor emits its concrete per-domain codec id and concrete `public.*` native type, statically — no resolution pass (`authoring.test.ts`). +2. Constructor↔domain 1:1: the generated constructor set exactly matches the exposed catalog `types.*` factories (excluding `*OrdOre`); every constructor's codec id, `nativeType`, and `capabilities` equal the catalog values for that domain (`authoring.test.ts` / `column-types.test.ts`). +3. Legacy: `*V2` constructors emit current v2 codec ids and `eql_v2_encrypted` (`authoring.test.ts` / `psl-interpretation*.test.ts`). +4. Unexposed / absent surface: no `*OrdOre` constructor is exported (including `EncryptedBigIntOrdOre`); no `EncryptedJson` v3 constructor exists (the name is unresolved on the v3 barrel); `EncryptedBoolean` is storage-only and exposes no equality constructor. Conversely, `EncryptedBigInt` / `EncryptedBigIntEq` / `EncryptedBigIntOrd` **are** exported and emit `public.bigint*` with `cast_as: 'bigint'` (`column-types.test.ts`, `psl-interpretation*.test.ts`). +5. Catalog invariants (new): a drift test asserting no searchable JSON v3 domain is exposed, `public.boolean` is storage-only, `*OrdOre` (incl. `bigint_ord_ore`) is excluded from the generated surface, and the BigInt family IS exposed. Assert native types are `public.*` while operator SQL functions are `eql_v3.*` (the schema split is not accidentally collapsed). Structural derivation from the imported `DOMAIN_REGISTRY` covers the rest — no hand-maintained table to pin. +6. Runtime codec: v3 encodes/decodes plain JSONB and never emits v2 composite literals (`codec-runtime.test.ts`, `cipherstash-codec*.test.ts`). +7. Middleware: v3 parameters are bulk-encrypted with `sdk.bulkEncrypt` and JSONB encoded (`bulk-encrypt-middleware.test.ts`). This suite MUST cover the edge cases the implementation itself handles (the Drizzle reference covers all four — `eql/v3/drizzle/operators.test.ts`): (a) a bulk-encrypt response whose length ≠ the operand count is **rejected**, not truncated (a short response would silently widen `inArray` / narrow `notInArray`); (b) partial-failure is wrapped in the structured error with `{ operator, columnName }`; (c) returned terms are positionally aligned index-for-index with the operands; (d) a `null` in a value list is rejected **before** any crypto crossing. +8. Operator SQL lowering: **pin the full lowered string with `.toBe()` for every operator** (`eq`, `ne`, `inArray`, `notInArray`, `gt`, `gte`, `lt`, `lte`, `between`, `notBetween`, `ilike`, `notIlike`, `asc`, `desc`) — not `.toContain('eql_v3.eq(')`, which would not catch the `between` paren bug. Include an explicit test that `cipherstashNotBetween` (and `not(between)`) emits `NOT (eql_v3.gte(...) AND eql_v3.lte(...))` with the conjunction parenthesised (`operator-lowering*.test.ts`). +9. Operator gating: for each domain, the gate allows exactly the operators the domain's built indexes support and rejects the rest with `EncryptionOperatorError`; storage-only domains reject every search operator **and** `asc`/`desc` (`operator-lowering*.test.ts`). +10. Trait: framework built-in equality traits are not reintroduced (`equality-trait-removal.test.ts`). +11. Derivation: v3 contracts derive exact `@cipherstash/stack/eql/v3` concrete schemas (`derive-schemas.test.ts`). +12. Divergence: override schemas with the specific **same-`cast_as`, different-domain** mismatch fail (`from-stack-divergence.test.ts`). +13. Bundling: v3 codecs and bundle assets do not pull the v2 composite codec path unnecessarily (`bundling-isolation.test.ts`). +14. Codec-id disjointness: the v3 codec-id set and the v2 codec-id set are disjoint. + +### Type-level (`*.test-d.ts`) — mandatory + +These enforce [Type Discipline](#type-discipline-the-spine); a runtime-only suite does not. Add `*.test-d.ts` files (as the Drizzle side does — `packages/stack/__tests__/drizzle-v3/types.test-d.ts`): + +15. **Factories are mutually non-assignable.** `encryptedBigIntOrd()` is *not* assignable to the type of `encryptedText()` (and vice-versa); a representative off-diagonal set is checked so an accidental collapse to one shared descriptor type fails to compile. +16. **Descriptor field types are literal, not widened.** For a sample of domains, `codecId` is the literal union member (e.g. `'cipherstash/eql-v3/text_eq@1'`, not `string`), `nativeType` is the `` `public.text_eq` `` literal (not `string`), and `capabilities` is the concrete literal `QueryCapabilities` (booleans preserved, not `unknown`). +17. **Derivation preserves the concrete column type.** `deriveStackSchemasV3` over a known contract yields the concrete `EncryptedTextEqColumn` / `EncryptedBigintOrdColumn` types into `encryptedTableV3`, not `AnyEncryptedV3Column`. + +### Property-based + +`fast-check ^4.8.0` is already in the monorepo (`packages/drizzle/package.json`) but is not yet a prisma-next devDependency — add it. Required properties: + +These must be genuine properties over generated inputs — not `fc.constantFrom(...EXPOSED_DOMAIN_ENTRIES)` table-tests wearing a fast-check costume. Iterating the closed catalog is a table test; keep those as `it.each`, and reserve `fc` for real input spaces: + +- **JSONB round-trip**: over `fc.jsonValue()`, `v3FromDriver(v3ToDriver(x))` deep-equals `x`; `null`/`undefined → null`. +- **BigInt losslessness**: over `fc.bigInt()`, a v3 bigint value survives encode→decode / the codec path and reconstructs to the identical JS `bigint` (this is the property `9c82f50e`'s oracle needed and the current plan lacks). +- **Codec round-trip per `castAs`**: for each `castAs`, an arbitrary plaintext of that kind round-trips through the cell codec to an equal value (the current JSON-only round-trip does not exercise the number/bigint/date/boolean envelopes). +- **`[REDACTED]` invariant**: for arbitrary user values the serialized envelope never contains the plaintext and always renders the placeholder. +- **ORE order-preservation (metamorphic)**: over arbitrary ordered pairs of same-typed plaintexts, the emitted ordering relation agrees with the plaintext comparison — the property that also makes the live oracle rigorous. +- **Operator-gating equivalence**: for arbitrary `(domain, operator)`, the gate allows the operator iff the pure capability predicate holds. +- **Derivation round-trip**: arbitrary v3 contract column → `deriveStackSchemasV3` → derived domain identity equals the original. + +### Integration / live-PG + +prisma-next has no live-PG tests today; add suites mirroring the Drizzle branch, reusing `describeLivePg` / `LIVE_EQL_V3_PG_ENABLED` / `installEqlV3IfNeeded` (self-skip locally, run in CI with `DATABASE_URL` + `CS_*`): + +The live suite MUST assert against a **plaintext-computed oracle**, not hand-picked rows (mirror the Drizzle oracle `eql/v3/drizzle/operators-live-pg.test.ts` — `expectedKeysFor` / `sortedKeysFor` / `comparePlain`, which now includes a `bigint` branch after `9c82f50e`). Asserting "the expected row is selected" over a couple of rows would pass against a `between` that matches everything — the oracle is exactly what caught the paren bug. + +- **operators-live-pg**: per capability tier (per constructor), encrypt → INSERT → query via each `cipherstash*` operator → decrypt against real `public.*` domains; assert the selected key set **equals the oracle's `expectedKeysFor`** over a seeded plaintext population. +- **range boundaries (the paren oracle)**: a `cipherstashBetween` with a single-row-wide window selects exactly that row, and a `cipherstashNotBetween` anchored at the **lower** bound excludes it (and selects the rest) — the specific shape that fails when `NOT (gte AND lte)` degrades to `(NOT gte) AND lte`. +- **ordering live**: `cipherstashAsc` / `cipherstashDesc` over a seeded population return rows in the exact plaintext order (`sortedKeysFor`); a non-`ore` column rejects ordering. +- **operators-null-live-pg**: null operands and null columns round-trip. +- **boolean storage-only live**: `public.boolean` survives INSERT/SELECT/decrypt and rejects every search operator (and ordering). +- **bigint round-trip live**: `public.bigint` / `bigint_eq` / `bigint_ord` encrypt → INSERT → query (eq / order-range) → decrypt to a JS `bigint`, ordering asserted via `comparePlain`'s bigint branch. +- **migration application live**: apply `20260601T0100_install_eql_v3_bundle`; assert the concrete domains exist, invariant `cipherstash:install-eql-v3-bundle-v1` is registered, and v3 columns emit no `add_search_config` / `remove_search_config`. +- **real bulkEncrypt**: middleware → real `EncryptionClient.bulkEncrypt` (not the fake) → INSERT, proving routing-key stamping and JSONB payloads end-to-end. + +(No "mixed v2 + v3 in one client" live test — that mode is unsupported per [Schema Derivation](#schema-derivation-and-stack-adapter). A v2 client and a v3 client running side by side may be exercised separately.) + +### Commands + +```bash +pnpm --filter @cipherstash/prisma-next test +pnpm --filter @cipherstash/prisma-next build +``` + +If implementation touches `@cipherstash/stack` public exports, also run the relevant stack v3 tests. + +## Release Notes + +This is a breaking public behavior change for `@cipherstash/prisma-next`: v3 columns are authored through new per-domain constructors, and the legacy boolean-option surface is not carried into v3. + +Required release metadata: + +- Add a changeset for `@cipherstash/prisma-next`. +- Mark the change as major if the package is considered published/stable. +- If the package is still experimental and versioning policy allows a minor, the changeset body must still call out the new authoring surface explicitly. + +User-facing migration guidance: + +- EQL v3 columns are authored with concrete per-domain constructors — the constructor is the capability. Examples: `EncryptedTextSearch()` (eq + free-text + order/range), `EncryptedTextEq()` (equality), `EncryptedIntegerOrd()` (eq + order/range), `EncryptedText()` / `EncryptedInteger()` (storage-only), `EncryptedBoolean()` (storage-only). +- There is no boolean-option surface (`EncryptedString({ equality, ... })`) in v3. Choose the constructor whose name matches the capability you need. +- Impossible capability combinations have no constructor (e.g. text equality + free-text without order/range) — they are unrepresentable rather than runtime errors. +- Use `*V2` constructors to keep old v2 columns. +- BigInt is a first-class v3 family (`EncryptedBigInt` / `EncryptedBigIntEq` / `EncryptedBigIntOrd`, JS `bigint` plaintext). Searchable JSON has no v3 constructor; use `EncryptedJsonV2`. +- Boolean v3 is storage-only; there is no boolean equality constructor. +- Regenerate contracts and migrations after changing constructors. diff --git a/e2e/tests/prisma-example-readme.e2e.test.ts b/e2e/tests/prisma-example-readme.e2e.test.ts index f43966d55..119422bda 100644 --- a/e2e/tests/prisma-example-readme.e2e.test.ts +++ b/e2e/tests/prisma-example-readme.e2e.test.ts @@ -20,7 +20,13 @@ const authConfigured = (() => { if (process.env.CS_CLIENT_ID && process.env.CS_CLIENT_KEY) return true const home = process.env.HOME if (!home) return false - return existsSync(join(home, '.cipherstash', 'auth.json')) + // `stash auth login` stores either an `auth.json` (legacy PKCE flow) or a + // `device.json` (device-code flow) under the profile directory; both let + // the stack client authenticate without CS_* env vars. + return ( + existsSync(join(home, '.cipherstash', 'auth.json')) || + existsSync(join(home, '.cipherstash', 'device.json')) + ) })() interface StepResult { @@ -62,10 +68,15 @@ function runStep(commandLine: string, timeoutMs: number): StepResult { } } +// `.env` is included so a developer's real credentials file survives the +// walkthrough: the README's `cp .env.example .env` step overwrites it, and +// without the snapshot the teardown would delete it outright. The snapshot +// captures it before the run and the restore puts the original back. const TRANSIENT_PATHS = [ 'migrations/app', 'src/prisma/contract.json', 'src/prisma/contract.d.ts', + '.env', ] as const async function snapshotTransientOutputs(): Promise { @@ -170,10 +181,11 @@ describe.skipIf(!authConfigured)( afterAll(async () => { // Teardown the bundled Postgres container regardless of outcome. runStep('docker compose down -v', 60_000) - // Restore the transient outputs from snapshot so the working tree is clean. + // Restore the transient outputs from snapshot so the working tree is + // clean. `.env` is part of the snapshot: the walkthrough's + // `cp .env.example .env` overwrote it, and the restore brings back the + // developer's original (or removes the copy when none existed before). await restoreTransientOutputs(snapDir) - // Remove the .env we copied in the walkthrough (not tracked anyway). - rmSync(join(EXAMPLE_DIR, '.env'), { force: true }) }, 120_000) // Per-step exit-zero assertion, registered once per non-skipped README line. diff --git a/examples/prisma/.env.example b/examples/prisma/.env.example index 864423351..1180896ad 100644 --- a/examples/prisma/.env.example +++ b/examples/prisma/.env.example @@ -4,8 +4,8 @@ # # Defaults match the bundled `docker-compose.yml`. Run # `docker compose up -d` from this directory to start a Postgres on -# port 5544 with these credentials. -DATABASE_URL=postgres://postgres:postgres@localhost:5544/cipherstash_prisma_example +# port 54338 with these credentials. +DATABASE_URL=postgres://postgres:postgres@localhost:54338/cipherstash_prisma_example # CipherStash workspace credentials — **deployment only**. # diff --git a/examples/prisma/README.md b/examples/prisma/README.md index 4dfdffa77..6977e55d7 100644 --- a/examples/prisma/README.md +++ b/examples/prisma/README.md @@ -10,7 +10,7 @@ A single `User` model with one column per cipherstash codec (string, double, big | Path | Purpose | | -------------------------- | --------------------------------------------------------------------------------------------- | -| `docker-compose.yml` | Local Postgres 16 on port 5544. | +| `docker-compose.yml` | Local Postgres 16 on port 54338. | | `prisma/schema.prisma` | Application schema (one `User` model exercising all six cipherstash codecs). | | `prisma-next.config.ts` | Wires `cipherstash` into `extensionPacks`. | | `src/db.ts` | One-call setup via `cipherstashFromStack({ contractJson })`. | @@ -20,7 +20,7 @@ A single `User` model with one column per cipherstash codec (string, double, big ## Prerequisites -1. **Docker** for the bundled Postgres on port 5544 (or any Postgres 16+). +1. **Docker** for the bundled Postgres on port 54338 (or any Postgres 16+). 2. **A CipherStash workspace** — sign up at [cipherstash.com](https://cipherstash.com), then run `stash auth login` (PKCE; caches credentials in your OS keychain — no `CS_*` env vars needed in local dev). ## Run it @@ -33,7 +33,7 @@ docker compose up -d pnpm install pnpm emit # PSL → contract.{json,d.ts} pnpm migration:plan --name initial -pnpm migration:apply # installs EQL bundle + your app schema in one sweep +pnpm migration:apply # installs EQL bundle + your app schema in one sweep (runs `prisma-next migrate`) pnpm start # runs the demo ``` diff --git a/examples/prisma/docker-compose.yml b/examples/prisma/docker-compose.yml index 49b0dd3b3..491c246b3 100644 --- a/examples/prisma/docker-compose.yml +++ b/examples/prisma/docker-compose.yml @@ -5,9 +5,9 @@ # docker compose down -v # stop + delete volume (fresh state) # # The DATABASE_URL in .env.example matches the values below: -# postgres://postgres:postgres@localhost:5544/cipherstash_prisma_example +# postgres://postgres:postgres@localhost:54338/cipherstash_prisma_example # -# Port 5544 (not 5432) is used to avoid colliding with any host-side +# Port 54338 (not 5432) is used to avoid colliding with any host-side # Postgres / other example containers. services: @@ -20,7 +20,7 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: cipherstash_prisma_example ports: - - "5544:5432" + - "54338:5432" volumes: - cipherstash-prisma-example-pg-data:/var/lib/postgresql/data healthcheck: diff --git a/examples/prisma/migrations/app/20260513T1735_initial/end-contract.d.ts b/examples/prisma/migrations/app/20260513T1735_initial/end-contract.d.ts deleted file mode 100644 index 9bd67b083..000000000 --- a/examples/prisma/migrations/app/20260513T1735_initial/end-contract.d.ts +++ /dev/null @@ -1,533 +0,0 @@ -// ⚠️ GENERATED FILE - DO NOT EDIT -// This file is automatically generated by 'prisma-next contract emit'. -// To regenerate, run: prisma-next contract emit - -import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types' -import type { - Contract as ContractType, - ExecutionHashBase, - ProfileHashBase, - StorageHashBase, -} from '@prisma-next/contract/types' -import type { CodecTypes as CipherstashTypes } from '@prisma-next/extension-cipherstash/codec-types' -import type { QueryOperationTypes as CipherstashQueryOperationTypes } from '@prisma-next/extension-cipherstash/operation-types' -import type { - EncryptedBigInt, - EncryptedBoolean, - EncryptedDate, - EncryptedDouble, - EncryptedJson, - EncryptedString, -} from '@prisma-next/extension-cipherstash/runtime' -import type { - ContractWithTypeMaps, - TypeMaps as TypeMapsType, -} from '@prisma-next/sql-contract/types' -import type { - Bit, - Char, - Interval, - JsonValue, - Numeric, - CodecTypes as PgTypes, - Time, - Timestamp, - Timestamptz, - Timetz, - VarBit, - Varchar, -} from '@prisma-next/target-postgres/codec-types' - -export type StorageHash = - StorageHashBase<'sha256:7475191ce0d78258ce5586265bcdfd12202f5daf90690b902890e58eb7508373'> -export type ExecutionHash = ExecutionHashBase -export type ProfileHash = - ProfileHashBase<'sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e'> - -export type CodecTypes = PgTypes & CipherstashTypes -export type LaneCodecTypes = CodecTypes -export type QueryOperationTypes = PgAdapterQueryOps & - CipherstashQueryOperationTypes -type DefaultLiteralValue< - CodecId extends string, - _Encoded, -> = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded - -export type FieldOutputTypes = { - readonly User: { - readonly id: CodecTypes['pg/text@1']['output'] - readonly email: CodecTypes['cipherstash/string@1']['output'] - readonly salary: CodecTypes['cipherstash/double@1']['output'] - readonly accountId: CodecTypes['cipherstash/bigint@1']['output'] - readonly birthday: CodecTypes['cipherstash/date@1']['output'] - readonly emailVerified: CodecTypes['cipherstash/boolean@1']['output'] - readonly preferences: CodecTypes['cipherstash/json@1']['output'] - } -} -export type FieldInputTypes = { - readonly User: { - readonly id: CodecTypes['pg/text@1']['input'] - readonly email: CodecTypes['cipherstash/string@1']['input'] - readonly salary: CodecTypes['cipherstash/double@1']['input'] - readonly accountId: CodecTypes['cipherstash/bigint@1']['input'] - readonly birthday: CodecTypes['cipherstash/date@1']['input'] - readonly emailVerified: CodecTypes['cipherstash/boolean@1']['input'] - readonly preferences: CodecTypes['cipherstash/json@1']['input'] - } -} -export type TypeMaps = TypeMapsType< - CodecTypes, - QueryOperationTypes, - FieldOutputTypes, - FieldInputTypes -> - -type ContractBase = ContractType< - { - readonly tables: { - readonly users: { - columns: { - readonly id: { - readonly nativeType: 'text' - readonly codecId: 'pg/text@1' - readonly nullable: false - } - readonly email: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/string@1' - readonly nullable: false - readonly typeParams: { - readonly equality: true - readonly freeTextSearch: true - readonly orderAndRange: true - } - } - readonly salary: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/double@1' - readonly nullable: false - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - readonly accountid: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/bigint@1' - readonly nullable: false - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - readonly birthday: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/date@1' - readonly nullable: false - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - readonly emailverified: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/boolean@1' - readonly nullable: false - readonly typeParams: { readonly equality: true } - } - readonly preferences: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/json@1' - readonly nullable: false - readonly typeParams: { readonly searchableJson: true } - } - } - primaryKey: { readonly columns: readonly ['id'] } - uniques: readonly [] - indexes: readonly [] - foreignKeys: readonly [] - } - } - readonly types: Record - readonly storageHash: StorageHash - }, - { - readonly User: { - readonly fields: { - readonly id: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'pg/text@1' - } - } - readonly email: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/string@1' - readonly typeParams: { - readonly equality: true - readonly freeTextSearch: true - readonly orderAndRange: true - } - } - } - readonly salary: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/double@1' - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - } - readonly accountId: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/bigint@1' - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - } - readonly birthday: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/date@1' - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - } - readonly emailVerified: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/boolean@1' - readonly typeParams: { readonly equality: true } - } - } - readonly preferences: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/json@1' - readonly typeParams: { readonly searchableJson: true } - } - } - } - readonly relations: Record - readonly storage: { - readonly table: 'users' - readonly fields: { - readonly id: { readonly column: 'id' } - readonly email: { readonly column: 'email' } - readonly salary: { readonly column: 'salary' } - readonly accountId: { readonly column: 'accountid' } - readonly birthday: { readonly column: 'birthday' } - readonly emailVerified: { readonly column: 'emailverified' } - readonly preferences: { readonly column: 'preferences' } - } - } - } - } -> & { - readonly target: 'postgres' - readonly targetFamily: 'sql' - readonly roots: { readonly users: 'User' } - readonly capabilities: { - readonly postgres: { - readonly jsonAgg: true - readonly lateral: true - readonly limit: true - readonly orderBy: true - readonly returning: true - } - readonly sql: { - readonly defaultInInsert: true - readonly enums: true - readonly returning: true - } - } - readonly extensionPacks: { - readonly cipherstash: { - readonly familyId: 'sql' - readonly id: 'cipherstash' - readonly kind: 'extension' - readonly targetId: 'postgres' - readonly types: { - readonly codecTypes: { - readonly codecInstances: readonly [ - { - readonly descriptor: { - readonly codecId: 'cipherstash/string@1' - readonly factory: unknown - readonly isParameterized: false - readonly meta: { - readonly db: { - readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } - readonly paramsSchema: { - readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly [ - 'cipherstash:equality', - 'cipherstash:free-text-search', - 'cipherstash:order-and-range', - ] - } - }, - { - readonly descriptor: { - readonly codecId: 'cipherstash/double@1' - readonly factory: unknown - readonly isParameterized: false - readonly meta: { - readonly db: { - readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } - readonly paramsSchema: { - readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly [ - 'cipherstash:equality', - 'cipherstash:order-and-range', - ] - } - }, - { - readonly descriptor: { - readonly codecId: 'cipherstash/bigint@1' - readonly factory: unknown - readonly isParameterized: false - readonly meta: { - readonly db: { - readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } - readonly paramsSchema: { - readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly [ - 'cipherstash:equality', - 'cipherstash:order-and-range', - ] - } - }, - { - readonly descriptor: { - readonly codecId: 'cipherstash/date@1' - readonly factory: unknown - readonly isParameterized: false - readonly meta: { - readonly db: { - readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } - readonly paramsSchema: { - readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly [ - 'cipherstash:equality', - 'cipherstash:order-and-range', - ] - } - }, - { - readonly descriptor: { - readonly codecId: 'cipherstash/boolean@1' - readonly factory: unknown - readonly isParameterized: false - readonly meta: { - readonly db: { - readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } - readonly paramsSchema: { - readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly ['cipherstash:equality'] - } - }, - { - readonly descriptor: { - readonly codecId: 'cipherstash/json@1' - readonly factory: unknown - readonly isParameterized: false - readonly meta: { - readonly db: { - readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } - readonly paramsSchema: { - readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly ['cipherstash:searchable-json'] - } - }, - ] - readonly import: { - readonly alias: 'CipherstashTypes' - readonly named: 'CodecTypes' - readonly package: '@prisma-next/extension-cipherstash/codec-types' - } - readonly typeImports: readonly [ - { - readonly alias: 'EncryptedString' - readonly named: 'EncryptedString' - readonly package: '@prisma-next/extension-cipherstash/runtime' - }, - { - readonly alias: 'EncryptedDouble' - readonly named: 'EncryptedDouble' - readonly package: '@prisma-next/extension-cipherstash/runtime' - }, - { - readonly alias: 'EncryptedBigInt' - readonly named: 'EncryptedBigInt' - readonly package: '@prisma-next/extension-cipherstash/runtime' - }, - { - readonly alias: 'EncryptedDate' - readonly named: 'EncryptedDate' - readonly package: '@prisma-next/extension-cipherstash/runtime' - }, - { - readonly alias: 'EncryptedBoolean' - readonly named: 'EncryptedBoolean' - readonly package: '@prisma-next/extension-cipherstash/runtime' - }, - { - readonly alias: 'EncryptedJson' - readonly named: 'EncryptedJson' - readonly package: '@prisma-next/extension-cipherstash/runtime' - }, - ] - } - readonly queryOperationTypes: { - readonly import: { - readonly alias: 'CipherstashQueryOperationTypes' - readonly named: 'QueryOperationTypes' - readonly package: '@prisma-next/extension-cipherstash/operation-types' - } - } - readonly storage: readonly [ - { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/string@1' - }, - { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/double@1' - }, - { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/bigint@1' - }, - { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/date@1' - }, - { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/boolean@1' - }, - { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/json@1' - }, - ] - } - readonly version: '0.0.1' - } - } - readonly meta: {} - - readonly profileHash: ProfileHash -} - -export type Contract = ContractWithTypeMaps - -export type Tables = Contract['storage']['tables'] -export type Models = Contract['models'] diff --git a/examples/prisma/migrations/app/20260513T1735_initial/migration.json b/examples/prisma/migrations/app/20260513T1735_initial/migration.json deleted file mode 100644 index ac2f39c6e..000000000 --- a/examples/prisma/migrations/app/20260513T1735_initial/migration.json +++ /dev/null @@ -1,475 +0,0 @@ -{ - "from": null, - "to": "sha256:7475191ce0d78258ce5586265bcdfd12202f5daf90690b902890e58eb7508373", - "fromContract": null, - "toContract": { - "schemaVersion": "1", - "targetFamily": "sql", - "target": "postgres", - "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", - "roots": { - "users": "User" - }, - "models": { - "User": { - "fields": { - "accountId": { - "nullable": false, - "type": { - "codecId": "cipherstash/bigint@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "orderAndRange": true - } - } - }, - "birthday": { - "nullable": false, - "type": { - "codecId": "cipherstash/date@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "orderAndRange": true - } - } - }, - "email": { - "nullable": false, - "type": { - "codecId": "cipherstash/string@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "freeTextSearch": true, - "orderAndRange": true - } - } - }, - "emailVerified": { - "nullable": false, - "type": { - "codecId": "cipherstash/boolean@1", - "kind": "scalar", - "typeParams": { - "equality": true - } - } - }, - "id": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" - } - }, - "preferences": { - "nullable": false, - "type": { - "codecId": "cipherstash/json@1", - "kind": "scalar", - "typeParams": { - "searchableJson": true - } - } - }, - "salary": { - "nullable": false, - "type": { - "codecId": "cipherstash/double@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "orderAndRange": true - } - } - } - }, - "relations": {}, - "storage": { - "fields": { - "accountId": { - "column": "accountid" - }, - "birthday": { - "column": "birthday" - }, - "email": { - "column": "email" - }, - "emailVerified": { - "column": "emailverified" - }, - "id": { - "column": "id" - }, - "preferences": { - "column": "preferences" - }, - "salary": { - "column": "salary" - } - }, - "table": "users" - } - } - }, - "storage": { - "storageHash": "sha256:7475191ce0d78258ce5586265bcdfd12202f5daf90690b902890e58eb7508373", - "tables": { - "users": { - "columns": { - "accountid": { - "codecId": "cipherstash/bigint@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "orderAndRange": true - } - }, - "birthday": { - "codecId": "cipherstash/date@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "orderAndRange": true - } - }, - "email": { - "codecId": "cipherstash/string@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "freeTextSearch": true, - "orderAndRange": true - } - }, - "emailverified": { - "codecId": "cipherstash/boolean@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true - } - }, - "id": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - }, - "preferences": { - "codecId": "cipherstash/json@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "searchableJson": true - } - }, - "salary": { - "codecId": "cipherstash/double@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "orderAndRange": true - } - } - }, - "foreignKeys": [], - "indexes": [], - "primaryKey": { - "columns": ["id"] - }, - "uniques": [] - } - } - }, - "capabilities": { - "postgres": { - "jsonAgg": true, - "lateral": true, - "limit": true, - "orderBy": true, - "returning": true - }, - "sql": { - "defaultInInsert": true, - "enums": true, - "returning": true - } - }, - "extensionPacks": { - "cipherstash": { - "familyId": "sql", - "id": "cipherstash", - "kind": "extension", - "targetId": "postgres", - "types": { - "codecTypes": { - "codecInstances": [ - { - "descriptor": { - "codecId": "cipherstash/string@1", - "meta": { - "db": { - "sql": { - "postgres": { - "nativeType": "eql_v2_encrypted" - } - } - } - }, - "paramsSchema": { - "~standard": { - "vendor": "cipherstash", - "version": 1 - } - }, - "targetTypes": ["eql_v2_encrypted"], - "traits": [ - "cipherstash:equality", - "cipherstash:free-text-search", - "cipherstash:order-and-range" - ] - } - }, - { - "descriptor": { - "codecId": "cipherstash/double@1", - "meta": { - "db": { - "sql": { - "postgres": { - "nativeType": "eql_v2_encrypted" - } - } - } - }, - "paramsSchema": { - "~standard": { - "vendor": "cipherstash", - "version": 1 - } - }, - "targetTypes": ["eql_v2_encrypted"], - "traits": [ - "cipherstash:equality", - "cipherstash:order-and-range" - ] - } - }, - { - "descriptor": { - "codecId": "cipherstash/bigint@1", - "meta": { - "db": { - "sql": { - "postgres": { - "nativeType": "eql_v2_encrypted" - } - } - } - }, - "paramsSchema": { - "~standard": { - "vendor": "cipherstash", - "version": 1 - } - }, - "targetTypes": ["eql_v2_encrypted"], - "traits": [ - "cipherstash:equality", - "cipherstash:order-and-range" - ] - } - }, - { - "descriptor": { - "codecId": "cipherstash/date@1", - "meta": { - "db": { - "sql": { - "postgres": { - "nativeType": "eql_v2_encrypted" - } - } - } - }, - "paramsSchema": { - "~standard": { - "vendor": "cipherstash", - "version": 1 - } - }, - "targetTypes": ["eql_v2_encrypted"], - "traits": [ - "cipherstash:equality", - "cipherstash:order-and-range" - ] - } - }, - { - "descriptor": { - "codecId": "cipherstash/boolean@1", - "meta": { - "db": { - "sql": { - "postgres": { - "nativeType": "eql_v2_encrypted" - } - } - } - }, - "paramsSchema": { - "~standard": { - "vendor": "cipherstash", - "version": 1 - } - }, - "targetTypes": ["eql_v2_encrypted"], - "traits": ["cipherstash:equality"] - } - }, - { - "descriptor": { - "codecId": "cipherstash/json@1", - "meta": { - "db": { - "sql": { - "postgres": { - "nativeType": "eql_v2_encrypted" - } - } - } - }, - "paramsSchema": { - "~standard": { - "vendor": "cipherstash", - "version": 1 - } - }, - "targetTypes": ["eql_v2_encrypted"], - "traits": ["cipherstash:searchable-json"] - } - } - ], - "import": { - "alias": "CipherstashTypes", - "named": "CodecTypes", - "package": "@prisma-next/extension-cipherstash/codec-types" - }, - "typeImports": [ - { - "alias": "EncryptedString", - "named": "EncryptedString", - "package": "@prisma-next/extension-cipherstash/runtime" - }, - { - "alias": "EncryptedDouble", - "named": "EncryptedDouble", - "package": "@prisma-next/extension-cipherstash/runtime" - }, - { - "alias": "EncryptedBigInt", - "named": "EncryptedBigInt", - "package": "@prisma-next/extension-cipherstash/runtime" - }, - { - "alias": "EncryptedDate", - "named": "EncryptedDate", - "package": "@prisma-next/extension-cipherstash/runtime" - }, - { - "alias": "EncryptedBoolean", - "named": "EncryptedBoolean", - "package": "@prisma-next/extension-cipherstash/runtime" - }, - { - "alias": "EncryptedJson", - "named": "EncryptedJson", - "package": "@prisma-next/extension-cipherstash/runtime" - } - ] - }, - "queryOperationTypes": { - "import": { - "alias": "CipherstashQueryOperationTypes", - "named": "QueryOperationTypes", - "package": "@prisma-next/extension-cipherstash/operation-types" - } - }, - "storage": [ - { - "familyId": "sql", - "nativeType": "eql_v2_encrypted", - "targetId": "postgres", - "typeId": "cipherstash/string@1" - }, - { - "familyId": "sql", - "nativeType": "eql_v2_encrypted", - "targetId": "postgres", - "typeId": "cipherstash/double@1" - }, - { - "familyId": "sql", - "nativeType": "eql_v2_encrypted", - "targetId": "postgres", - "typeId": "cipherstash/bigint@1" - }, - { - "familyId": "sql", - "nativeType": "eql_v2_encrypted", - "targetId": "postgres", - "typeId": "cipherstash/date@1" - }, - { - "familyId": "sql", - "nativeType": "eql_v2_encrypted", - "targetId": "postgres", - "typeId": "cipherstash/boolean@1" - }, - { - "familyId": "sql", - "nativeType": "eql_v2_encrypted", - "targetId": "postgres", - "typeId": "cipherstash/json@1" - } - ] - }, - "version": "0.0.1" - } - }, - "meta": {}, - "_generated": { - "warning": "⚠️ GENERATED FILE - DO NOT EDIT", - "message": "This file is automatically generated by \"prisma-next contract emit\".", - "regenerate": "To regenerate, run: prisma-next contract emit" - } - }, - "hints": { - "used": [], - "applied": [], - "plannerVersion": "2.0.0" - }, - "labels": [], - "createdAt": "2026-05-13T17:35:47.440Z", - "providedInvariants": [ - "cipherstash-codec:users.accountid:add-search-config:ore@v1", - "cipherstash-codec:users.accountid:add-search-config:unique@v1", - "cipherstash-codec:users.birthday:add-search-config:ore@v1", - "cipherstash-codec:users.birthday:add-search-config:unique@v1", - "cipherstash-codec:users.email:add-search-config:match@v1", - "cipherstash-codec:users.email:add-search-config:ore@v1", - "cipherstash-codec:users.email:add-search-config:unique@v1", - "cipherstash-codec:users.emailverified:add-search-config:unique@v1", - "cipherstash-codec:users.preferences:add-search-config:ste_vec@v1", - "cipherstash-codec:users.salary:add-search-config:ore@v1", - "cipherstash-codec:users.salary:add-search-config:unique@v1" - ], - "migrationHash": "sha256:9ea9b8e790665ce11265339be522ed4baba54d446036386c80fa589196d5f645" -} diff --git a/examples/prisma/migrations/app/20260513T1735_initial/migration.ts b/examples/prisma/migrations/app/20260513T1735_initial/migration.ts deleted file mode 100755 index 7d9c9baff..000000000 --- a/examples/prisma/migrations/app/20260513T1735_initial/migration.ts +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env -S node -import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration' -import { - createTable, - Migration, - MigrationCLI, -} from '@prisma-next/target-postgres/migration' - -export default class M extends Migration { - override describe() { - return { - from: null, - to: 'sha256:7475191ce0d78258ce5586265bcdfd12202f5daf90690b902890e58eb7508373', - } - } - - override get operations() { - return [ - createTable( - 'public', - 'users', - [ - { - name: 'accountid', - typeSql: 'eql_v2_encrypted', - defaultSql: '', - nullable: false, - }, - { - name: 'birthday', - typeSql: 'eql_v2_encrypted', - defaultSql: '', - nullable: false, - }, - { - name: 'email', - typeSql: 'eql_v2_encrypted', - defaultSql: '', - nullable: false, - }, - { - name: 'emailverified', - typeSql: 'eql_v2_encrypted', - defaultSql: '', - nullable: false, - }, - { name: 'id', typeSql: 'text', defaultSql: '', nullable: false }, - { - name: 'preferences', - typeSql: 'eql_v2_encrypted', - defaultSql: '', - nullable: false, - }, - { - name: 'salary', - typeSql: 'eql_v2_encrypted', - defaultSql: '', - nullable: false, - }, - ], - { columns: ['id'] }, - ), - cipherstashAddSearchConfig({ - table: 'users', - column: 'accountid', - index: 'unique', - castAs: 'big_int', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'accountid', - index: 'ore', - castAs: 'big_int', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'birthday', - index: 'unique', - castAs: 'date', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'birthday', - index: 'ore', - castAs: 'date', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'email', - index: 'unique', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'email', - index: 'match', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'email', - index: 'ore', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'emailverified', - index: 'unique', - castAs: 'boolean', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'preferences', - index: 'ste_vec', - castAs: 'jsonb', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'salary', - index: 'unique', - castAs: 'double', - }), - cipherstashAddSearchConfig({ - table: 'users', - column: 'salary', - index: 'ore', - castAs: 'double', - }), - ] - } -} - -MigrationCLI.run(import.meta.url, M) diff --git a/examples/prisma/migrations/app/20260709T1034_initial/end-contract.d.ts b/examples/prisma/migrations/app/20260709T1034_initial/end-contract.d.ts new file mode 100644 index 000000000..d6c3d2b94 --- /dev/null +++ b/examples/prisma/migrations/app/20260709T1034_initial/end-contract.d.ts @@ -0,0 +1,513 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { CodecTypes as CipherstashTypes } from '@prisma-next/extension-cipherstash/codec-types'; +import type { QueryOperationTypes as CipherstashQueryOperationTypes } from '@prisma-next/extension-cipherstash/operation-types'; +import type { + EncryptedBigInt, + EncryptedBoolean, + EncryptedDate, + EncryptedDouble, + EncryptedJson, + EncryptedString, +} from '@prisma-next/extension-cipherstash/runtime'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:b8c4febc9397cf1b68293cdb3b2afe9f568db6967f428e3f207e32575a2bc2fa'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes & CipherstashTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps & + CipherstashQueryOperationTypes; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly User: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly email: CodecTypes['cipherstash/string@1']['output']; + readonly salary: CodecTypes['cipherstash/double@1']['output']; + readonly accountId: CodecTypes['cipherstash/bigint@1']['output']; + readonly birthday: CodecTypes['cipherstash/date@1']['output']; + readonly emailVerified: CodecTypes['cipherstash/boolean@1']['output']; + readonly preferences: CodecTypes['cipherstash/json@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly User: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly email: CodecTypes['cipherstash/string@1']['input']; + readonly salary: CodecTypes['cipherstash/double@1']['input']; + readonly accountId: CodecTypes['cipherstash/bigint@1']['input']; + readonly birthday: CodecTypes['cipherstash/date@1']['input']; + readonly emailVerified: CodecTypes['cipherstash/boolean@1']['input']; + readonly preferences: CodecTypes['cipherstash/json@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'sql-namespace'; + readonly entries: { + readonly table: { + readonly users: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly email: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/string@1'; + readonly nullable: false; + readonly typeParams: { + readonly equality: true; + readonly freeTextSearch: true; + readonly orderAndRange: true; + }; + }; + readonly salary: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/double@1'; + readonly nullable: false; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + readonly accountid: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/bigint@1'; + readonly nullable: false; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + readonly birthday: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/date@1'; + readonly nullable: false; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + readonly emailverified: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/boolean@1'; + readonly nullable: false; + readonly typeParams: { readonly equality: true }; + }; + readonly preferences: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/json@1'; + readonly nullable: false; + readonly typeParams: { readonly searchableJson: true }; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly users: { readonly namespace: 'public' & NamespaceId; readonly model: 'User' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly User: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly email: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/string@1'; + readonly typeParams: { + readonly equality: true; + readonly freeTextSearch: true; + readonly orderAndRange: true; + }; + }; + }; + readonly salary: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/double@1'; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + }; + readonly accountId: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/bigint@1'; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + }; + readonly birthday: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/date@1'; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + }; + readonly emailVerified: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/boolean@1'; + readonly typeParams: { readonly equality: true }; + }; + }; + readonly preferences: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/json@1'; + readonly typeParams: { readonly searchableJson: true }; + }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'users'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly email: { readonly column: 'email' }; + readonly salary: { readonly column: 'salary' }; + readonly accountId: { readonly column: 'accountid' }; + readonly birthday: { readonly column: 'birthday' }; + readonly emailVerified: { readonly column: 'emailverified' }; + readonly preferences: { readonly column: 'preferences' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: { + readonly cipherstash: { + readonly familyId: 'sql'; + readonly id: 'cipherstash'; + readonly kind: 'extension'; + readonly targetId: 'postgres'; + readonly types: { + readonly codecTypes: { + readonly codecInstances: readonly [ + { + readonly descriptor: { + readonly codecId: 'cipherstash/string@1'; + readonly factory: unknown; + readonly isParameterized: false; + readonly meta: { + readonly db: { + readonly sql: { + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; + readonly paramsSchema: { + readonly '~standard': { + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly [ + 'cipherstash:equality', + 'cipherstash:free-text-search', + 'cipherstash:order-and-range', + ]; + }; + }, + { + readonly descriptor: { + readonly codecId: 'cipherstash/double@1'; + readonly factory: unknown; + readonly isParameterized: false; + readonly meta: { + readonly db: { + readonly sql: { + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; + readonly paramsSchema: { + readonly '~standard': { + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:equality', 'cipherstash:order-and-range']; + }; + }, + { + readonly descriptor: { + readonly codecId: 'cipherstash/bigint@1'; + readonly factory: unknown; + readonly isParameterized: false; + readonly meta: { + readonly db: { + readonly sql: { + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; + readonly paramsSchema: { + readonly '~standard': { + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:equality', 'cipherstash:order-and-range']; + }; + }, + { + readonly descriptor: { + readonly codecId: 'cipherstash/date@1'; + readonly factory: unknown; + readonly isParameterized: false; + readonly meta: { + readonly db: { + readonly sql: { + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; + readonly paramsSchema: { + readonly '~standard': { + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:equality', 'cipherstash:order-and-range']; + }; + }, + { + readonly descriptor: { + readonly codecId: 'cipherstash/boolean@1'; + readonly factory: unknown; + readonly isParameterized: false; + readonly meta: { + readonly db: { + readonly sql: { + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; + readonly paramsSchema: { + readonly '~standard': { + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:equality']; + }; + }, + { + readonly descriptor: { + readonly codecId: 'cipherstash/json@1'; + readonly factory: unknown; + readonly isParameterized: false; + readonly meta: { + readonly db: { + readonly sql: { + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; + readonly paramsSchema: { + readonly '~standard': { + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:searchable-json']; + }; + }, + ]; + readonly import: { + readonly alias: 'CipherstashTypes'; + readonly named: 'CodecTypes'; + readonly package: '@prisma-next/extension-cipherstash/codec-types'; + }; + readonly typeImports: readonly [ + { + readonly alias: 'EncryptedString'; + readonly named: 'EncryptedString'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; + }, + { + readonly alias: 'EncryptedDouble'; + readonly named: 'EncryptedDouble'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; + }, + { + readonly alias: 'EncryptedBigInt'; + readonly named: 'EncryptedBigInt'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; + }, + { + readonly alias: 'EncryptedDate'; + readonly named: 'EncryptedDate'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; + }, + { + readonly alias: 'EncryptedBoolean'; + readonly named: 'EncryptedBoolean'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; + }, + { + readonly alias: 'EncryptedJson'; + readonly named: 'EncryptedJson'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; + }, + ]; + }; + readonly queryOperationTypes: { + readonly import: { + readonly alias: 'CipherstashQueryOperationTypes'; + readonly named: 'QueryOperationTypes'; + readonly package: '@prisma-next/extension-cipherstash/operation-types'; + }; + }; + readonly storage: readonly [ + { + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/string@1'; + }, + { + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/double@1'; + }, + { + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/bigint@1'; + }, + { + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/date@1'; + }, + { + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/boolean@1'; + }, + { + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/json@1'; + }, + ]; + }; + readonly version: '0.0.1'; + }; + }; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/prisma/migrations/app/20260513T1735_initial/end-contract.json b/examples/prisma/migrations/app/20260709T1034_initial/end-contract.json similarity index 53% rename from examples/prisma/migrations/app/20260513T1735_initial/end-contract.json rename to examples/prisma/migrations/app/20260709T1034_initial/end-contract.json index 98c97b1fa..9a06c3eba 100644 --- a/examples/prisma/migrations/app/20260513T1735_initial/end-contract.json +++ b/examples/prisma/migrations/app/20260709T1034_initial/end-contract.json @@ -2,190 +2,211 @@ "schemaVersion": "1", "targetFamily": "sql", "target": "postgres", - "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", "roots": { - "users": "User" + "users": { + "model": "User", + "namespace": "public" + } }, - "models": { - "User": { - "fields": { - "accountId": { - "nullable": false, - "type": { - "codecId": "cipherstash/bigint@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "orderAndRange": true - } - } - }, - "birthday": { - "nullable": false, - "type": { - "codecId": "cipherstash/date@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "orderAndRange": true - } - } - }, - "email": { - "nullable": false, - "type": { - "codecId": "cipherstash/string@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "freeTextSearch": true, - "orderAndRange": true - } - } - }, - "emailVerified": { - "nullable": false, - "type": { - "codecId": "cipherstash/boolean@1", - "kind": "scalar", - "typeParams": { - "equality": true - } - } - }, - "id": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" - } - }, - "preferences": { - "nullable": false, - "type": { - "codecId": "cipherstash/json@1", - "kind": "scalar", - "typeParams": { - "searchableJson": true - } - } - }, - "salary": { - "nullable": false, - "type": { - "codecId": "cipherstash/double@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "orderAndRange": true + "domain": { + "namespaces": { + "public": { + "models": { + "User": { + "fields": { + "accountId": { + "nullable": false, + "type": { + "codecId": "cipherstash/bigint@1", + "kind": "scalar", + "typeParams": { + "equality": true, + "orderAndRange": true + } + } + }, + "birthday": { + "nullable": false, + "type": { + "codecId": "cipherstash/date@1", + "kind": "scalar", + "typeParams": { + "equality": true, + "orderAndRange": true + } + } + }, + "email": { + "nullable": false, + "type": { + "codecId": "cipherstash/string@1", + "kind": "scalar", + "typeParams": { + "equality": true, + "freeTextSearch": true, + "orderAndRange": true + } + } + }, + "emailVerified": { + "nullable": false, + "type": { + "codecId": "cipherstash/boolean@1", + "kind": "scalar", + "typeParams": { + "equality": true + } + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "preferences": { + "nullable": false, + "type": { + "codecId": "cipherstash/json@1", + "kind": "scalar", + "typeParams": { + "searchableJson": true + } + } + }, + "salary": { + "nullable": false, + "type": { + "codecId": "cipherstash/double@1", + "kind": "scalar", + "typeParams": { + "equality": true, + "orderAndRange": true + } + } + } + }, + "relations": {}, + "storage": { + "fields": { + "accountId": { + "column": "accountid" + }, + "birthday": { + "column": "birthday" + }, + "email": { + "column": "email" + }, + "emailVerified": { + "column": "emailverified" + }, + "id": { + "column": "id" + }, + "preferences": { + "column": "preferences" + }, + "salary": { + "column": "salary" + } + }, + "namespaceId": "public", + "table": "users" } } } - }, - "relations": {}, - "storage": { - "fields": { - "accountId": { - "column": "accountid" - }, - "birthday": { - "column": "birthday" - }, - "email": { - "column": "email" - }, - "emailVerified": { - "column": "emailverified" - }, - "id": { - "column": "id" - }, - "preferences": { - "column": "preferences" - }, - "salary": { - "column": "salary" - } - }, - "table": "users" } } }, "storage": { - "storageHash": "sha256:7475191ce0d78258ce5586265bcdfd12202f5daf90690b902890e58eb7508373", - "tables": { - "users": { - "columns": { - "accountid": { - "codecId": "cipherstash/bigint@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "orderAndRange": true - } - }, - "birthday": { - "codecId": "cipherstash/date@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "orderAndRange": true - } - }, - "email": { - "codecId": "cipherstash/string@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "freeTextSearch": true, - "orderAndRange": true - } - }, - "emailverified": { - "codecId": "cipherstash/boolean@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true - } - }, - "id": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - }, - "preferences": { - "codecId": "cipherstash/json@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "searchableJson": true - } - }, - "salary": { - "codecId": "cipherstash/double@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "orderAndRange": true + "namespaces": { + "public": { + "entries": { + "table": { + "users": { + "columns": { + "accountid": { + "codecId": "cipherstash/bigint@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true, + "orderAndRange": true + } + }, + "birthday": { + "codecId": "cipherstash/date@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true, + "orderAndRange": true + } + }, + "email": { + "codecId": "cipherstash/string@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true, + "freeTextSearch": true, + "orderAndRange": true + } + }, + "emailverified": { + "codecId": "cipherstash/boolean@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true + } + }, + "id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "preferences": { + "codecId": "cipherstash/json@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "searchableJson": true + } + }, + "salary": { + "codecId": "cipherstash/double@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true, + "orderAndRange": true + } + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] } } }, - "foreignKeys": [], - "indexes": [], - "primaryKey": { - "columns": ["id"] - }, - "uniques": [] + "id": "public", + "kind": "postgres-schema" } - } + }, + "storageHash": "sha256:b8c4febc9397cf1b68293cdb3b2afe9f568db6967f428e3f207e32575a2bc2fa" }, "capabilities": { "postgres": { + "distinctOn": true, "jsonAgg": true, "lateral": true, "limit": true, @@ -195,6 +216,7 @@ "sql": { "defaultInInsert": true, "enums": true, + "lateral": true, "returning": true } }, @@ -225,7 +247,9 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], + "targetTypes": [ + "eql_v2_encrypted" + ], "traits": [ "cipherstash:equality", "cipherstash:free-text-search", @@ -251,7 +275,9 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], + "targetTypes": [ + "eql_v2_encrypted" + ], "traits": [ "cipherstash:equality", "cipherstash:order-and-range" @@ -276,7 +302,9 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], + "targetTypes": [ + "eql_v2_encrypted" + ], "traits": [ "cipherstash:equality", "cipherstash:order-and-range" @@ -301,7 +329,9 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], + "targetTypes": [ + "eql_v2_encrypted" + ], "traits": [ "cipherstash:equality", "cipherstash:order-and-range" @@ -326,8 +356,12 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], - "traits": ["cipherstash:equality"] + "targetTypes": [ + "eql_v2_encrypted" + ], + "traits": [ + "cipherstash:equality" + ] } }, { @@ -348,8 +382,12 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], - "traits": ["cipherstash:searchable-json"] + "targetTypes": [ + "eql_v2_encrypted" + ], + "traits": [ + "cipherstash:searchable-json" + ] } } ], @@ -446,4 +484,4 @@ "message": "This file is automatically generated by \"prisma-next contract emit\".", "regenerate": "To regenerate, run: prisma-next contract emit" } -} +} \ No newline at end of file diff --git a/examples/prisma/migrations/app/20260709T1034_initial/migration.json b/examples/prisma/migrations/app/20260709T1034_initial/migration.json new file mode 100644 index 000000000..14b8ed399 --- /dev/null +++ b/examples/prisma/migrations/app/20260709T1034_initial/migration.json @@ -0,0 +1,19 @@ +{ + "from": null, + "to": "sha256:b8c4febc9397cf1b68293cdb3b2afe9f568db6967f428e3f207e32575a2bc2fa", + "providedInvariants": [ + "cipherstash-codec:users.accountid:add-search-config:ore@v1", + "cipherstash-codec:users.accountid:add-search-config:unique@v1", + "cipherstash-codec:users.birthday:add-search-config:ore@v1", + "cipherstash-codec:users.birthday:add-search-config:unique@v1", + "cipherstash-codec:users.email:add-search-config:match@v1", + "cipherstash-codec:users.email:add-search-config:ore@v1", + "cipherstash-codec:users.email:add-search-config:unique@v1", + "cipherstash-codec:users.emailverified:add-search-config:unique@v1", + "cipherstash-codec:users.preferences:add-search-config:ste_vec@v1", + "cipherstash-codec:users.salary:add-search-config:ore@v1", + "cipherstash-codec:users.salary:add-search-config:unique@v1" + ], + "createdAt": "2026-07-09T10:34:08.902Z", + "migrationHash": "sha256:4cce7dab1306568bb00184c15d1b82f6c5a1f61adb6b37a211b917adc4e71200" +} \ No newline at end of file diff --git a/examples/prisma/migrations/app/20260709T1034_initial/migration.ts b/examples/prisma/migrations/app/20260709T1034_initial/migration.ts new file mode 100755 index 000000000..11c035c3a --- /dev/null +++ b/examples/prisma/migrations/app/20260709T1034_initial/migration.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env -S node +import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration'; +import { Migration, MigrationCLI, col, primaryKey } from '@prisma-next/postgres/migration'; + +export default class M extends Migration { + override describe() { + return { + from: null, + to: 'sha256:b8c4febc9397cf1b68293cdb3b2afe9f568db6967f428e3f207e32575a2bc2fa', + }; + } + + override get operations() { + return [ + this.createTable({ + schema: 'public', + table: 'users', + columns: [ + col('accountid', 'eql_v2_encrypted', { + notNull: true, + codecRef: { + codecId: 'cipherstash/bigint@1', + typeParams: { equality: true, orderAndRange: true }, + }, + }), + col('birthday', 'eql_v2_encrypted', { + notNull: true, + codecRef: { + codecId: 'cipherstash/date@1', + typeParams: { equality: true, orderAndRange: true }, + }, + }), + col('email', 'eql_v2_encrypted', { + notNull: true, + codecRef: { + codecId: 'cipherstash/string@1', + typeParams: { equality: true, freeTextSearch: true, orderAndRange: true }, + }, + }), + col('emailverified', 'eql_v2_encrypted', { + notNull: true, + codecRef: { codecId: 'cipherstash/boolean@1', typeParams: { equality: true } }, + }), + col('id', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('preferences', 'eql_v2_encrypted', { + notNull: true, + codecRef: { codecId: 'cipherstash/json@1', typeParams: { searchableJson: true } }, + }), + col('salary', 'eql_v2_encrypted', { + notNull: true, + codecRef: { + codecId: 'cipherstash/double@1', + typeParams: { equality: true, orderAndRange: true }, + }, + }), + ], + constraints: [primaryKey(['id'])], + }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'accountid', + index: 'unique', + castAs: 'big_int', + }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'accountid', + index: 'ore', + castAs: 'big_int', + }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'birthday', + index: 'unique', + castAs: 'date', + }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'birthday', + index: 'ore', + castAs: 'date', + }), + cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'unique' }), + cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'match' }), + cipherstashAddSearchConfig({ table: 'users', column: 'email', index: 'ore' }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'emailverified', + index: 'unique', + castAs: 'boolean', + }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'preferences', + index: 'ste_vec', + castAs: 'jsonb', + }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'salary', + index: 'unique', + castAs: 'double', + }), + cipherstashAddSearchConfig({ + table: 'users', + column: 'salary', + index: 'ore', + castAs: 'double', + }), + ]; + } +} + +MigrationCLI.run(import.meta.url, M); diff --git a/examples/prisma/migrations/app/20260513T1735_initial/ops.json b/examples/prisma/migrations/app/20260709T1034_initial/ops.json similarity index 95% rename from examples/prisma/migrations/app/20260513T1735_initial/ops.json rename to examples/prisma/migrations/app/20260709T1034_initial/ops.json index e985c872c..72baac1a5 100644 --- a/examples/prisma/migrations/app/20260513T1735_initial/ops.json +++ b/examples/prisma/migrations/app/20260709T1034_initial/ops.json @@ -15,19 +15,26 @@ "precheck": [ { "description": "ensure table \"users\" does not exist", - "sql": "SELECT to_regclass('\"public\".\"users\"') IS NULL" + "sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", + "params": [ + "\"public\".\"users\"" + ] } ], "execute": [ { "description": "create table \"users\"", - "sql": "CREATE TABLE \"public\".\"users\" (\n \"accountid\" eql_v2_encrypted NOT NULL,\n \"birthday\" eql_v2_encrypted NOT NULL,\n \"email\" eql_v2_encrypted NOT NULL,\n \"emailverified\" eql_v2_encrypted NOT NULL,\n \"id\" text NOT NULL,\n \"preferences\" eql_v2_encrypted NOT NULL,\n \"salary\" eql_v2_encrypted NOT NULL,\n PRIMARY KEY (\"id\")\n)" + "sql": "CREATE TABLE \"public\".\"users\" (\n \"accountid\" eql_v2_encrypted NOT NULL,\n \"birthday\" eql_v2_encrypted NOT NULL,\n \"email\" eql_v2_encrypted NOT NULL,\n \"emailverified\" eql_v2_encrypted NOT NULL,\n \"id\" text NOT NULL,\n \"preferences\" eql_v2_encrypted NOT NULL,\n \"salary\" eql_v2_encrypted NOT NULL,\n PRIMARY KEY (\"id\")\n)", + "params": [] } ], "postcheck": [ { "description": "verify table \"users\" exists", - "sql": "SELECT to_regclass('\"public\".\"users\"') IS NOT NULL" + "sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", + "params": [ + "\"public\".\"users\"" + ] } ] }, @@ -218,4 +225,4 @@ ], "postcheck": [] } -] +] \ No newline at end of file diff --git a/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/contract.json b/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/contract.json deleted file mode 100644 index 0eed3abc3..000000000 --- a/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/contract.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "_generated": { - "message": "This file is automatically generated by \"prisma-next contract emit\".", - "regenerate": "To regenerate, run: prisma-next contract emit", - "warning": "⚠️ GENERATED FILE - DO NOT EDIT" - }, - "capabilities": { - "postgres": { - "jsonAgg": true, - "lateral": true, - "limit": true, - "orderBy": true, - "returning": true - }, - "sql": { "defaultInInsert": true, "enums": true, "returning": true } - }, - "extensionPacks": {}, - "meta": {}, - "models": { - "EqlV2Configuration": { - "fields": { - "data": { - "nullable": false, - "type": { "codecId": "pg/jsonb@1", "kind": "scalar" } - }, - "id": { - "nullable": false, - "type": { "codecId": "pg/text@1", "kind": "scalar" } - }, - "state": { - "nullable": false, - "type": { "codecId": "pg/text@1", "kind": "scalar" } - } - }, - "relations": {}, - "storage": { - "fields": { - "data": { "column": "data" }, - "id": { "column": "id" }, - "state": { "column": "state" } - }, - "table": "eql_v2_configuration" - } - } - }, - "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", - "roots": { "eql_v2_configuration": "EqlV2Configuration" }, - "schemaVersion": "1", - "storage": { - "storageHash": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", - "tables": { - "eql_v2_configuration": { - "columns": { - "data": { - "codecId": "pg/jsonb@1", - "nativeType": "jsonb", - "nullable": false - }, - "id": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - }, - "state": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - } - }, - "foreignKeys": [], - "indexes": [], - "primaryKey": { "columns": ["id"] }, - "uniques": [] - } - } - }, - "target": "postgres", - "targetFamily": "sql" -} diff --git a/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/migration.json b/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/migration.json index bf79367bf..4bf75c2ac 100644 --- a/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/migration.json +++ b/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/migration.json @@ -1,116 +1,9 @@ { "from": null, - "to": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", - "labels": [], - "providedInvariants": ["cipherstash:install-eql-bundle-v1"], + "to": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7", + "providedInvariants": [ + "cipherstash:install-eql-bundle-v1" + ], "createdAt": "2026-05-09T03:42:56.902Z", - "fromContract": null, - "toContract": { - "schemaVersion": "1", - "targetFamily": "sql", - "target": "postgres", - "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", - "roots": { - "eql_v2_configuration": "EqlV2Configuration" - }, - "models": { - "EqlV2Configuration": { - "fields": { - "data": { - "nullable": false, - "type": { - "codecId": "pg/jsonb@1", - "kind": "scalar" - } - }, - "id": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" - } - }, - "state": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" - } - } - }, - "relations": {}, - "storage": { - "fields": { - "data": { - "column": "data" - }, - "id": { - "column": "id" - }, - "state": { - "column": "state" - } - }, - "table": "eql_v2_configuration" - } - } - }, - "storage": { - "storageHash": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", - "tables": { - "eql_v2_configuration": { - "columns": { - "data": { - "codecId": "pg/jsonb@1", - "nativeType": "jsonb", - "nullable": false - }, - "id": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - }, - "state": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - } - }, - "foreignKeys": [], - "indexes": [], - "primaryKey": { - "columns": ["id"] - }, - "uniques": [] - } - } - }, - "capabilities": { - "postgres": { - "jsonAgg": true, - "lateral": true, - "limit": true, - "orderBy": true, - "returning": true - }, - "sql": { - "defaultInInsert": true, - "enums": true, - "returning": true - } - }, - "extensionPacks": {}, - "meta": {}, - "_generated": { - "warning": "⚠️ GENERATED FILE - DO NOT EDIT", - "message": "This file is automatically generated by \"prisma-next contract emit\".", - "regenerate": "To regenerate, run: prisma-next contract emit" - } - }, - "hints": { - "used": [], - "applied": [], - "plannerVersion": "2.0.0" - }, - "migrationHash": "sha256:76923a92561cdad65c64088ce999bf7afe853b80aac0b787b0d271b0e623abbc" -} + "migrationHash": "sha256:ae84404848d090c24f4094331c14972474a9695ffd45d29a21c5f5a8cfed9df7" +} \ No newline at end of file diff --git a/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/ops.json b/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/ops.json index c124cd04e..47ee0ca65 100644 --- a/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/ops.json +++ b/examples/prisma/migrations/cipherstash/20260601T0000_install_eql_bundle/ops.json @@ -25,4 +25,4 @@ } ] } -] +] \ No newline at end of file diff --git a/examples/prisma/migrations/cipherstash/contract.d.ts b/examples/prisma/migrations/cipherstash/contract.d.ts index e301b06e2..181d93f81 100644 --- a/examples/prisma/migrations/cipherstash/contract.d.ts +++ b/examples/prisma/migrations/cipherstash/contract.d.ts @@ -5,6 +5,6 @@ * alongside `contract.json` and `refs/head.json`. A typed `.d.ts` * rendering pass for extension contracts is tracked separately; * until that ships, consumers should import `contract.json` - * directly with `validateContract<…>(…)`. + * and pass it through the target descriptor’s `contractSerializer`. */ -export {} +export {}; diff --git a/examples/prisma/migrations/cipherstash/contract.json b/examples/prisma/migrations/cipherstash/contract.json index 0eed3abc3..9bab116fb 100644 --- a/examples/prisma/migrations/cipherstash/contract.json +++ b/examples/prisma/migrations/cipherstash/contract.json @@ -1,79 +1 @@ -{ - "_generated": { - "message": "This file is automatically generated by \"prisma-next contract emit\".", - "regenerate": "To regenerate, run: prisma-next contract emit", - "warning": "⚠️ GENERATED FILE - DO NOT EDIT" - }, - "capabilities": { - "postgres": { - "jsonAgg": true, - "lateral": true, - "limit": true, - "orderBy": true, - "returning": true - }, - "sql": { "defaultInInsert": true, "enums": true, "returning": true } - }, - "extensionPacks": {}, - "meta": {}, - "models": { - "EqlV2Configuration": { - "fields": { - "data": { - "nullable": false, - "type": { "codecId": "pg/jsonb@1", "kind": "scalar" } - }, - "id": { - "nullable": false, - "type": { "codecId": "pg/text@1", "kind": "scalar" } - }, - "state": { - "nullable": false, - "type": { "codecId": "pg/text@1", "kind": "scalar" } - } - }, - "relations": {}, - "storage": { - "fields": { - "data": { "column": "data" }, - "id": { "column": "id" }, - "state": { "column": "state" } - }, - "table": "eql_v2_configuration" - } - } - }, - "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", - "roots": { "eql_v2_configuration": "EqlV2Configuration" }, - "schemaVersion": "1", - "storage": { - "storageHash": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", - "tables": { - "eql_v2_configuration": { - "columns": { - "data": { - "codecId": "pg/jsonb@1", - "nativeType": "jsonb", - "nullable": false - }, - "id": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - }, - "state": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - } - }, - "foreignKeys": [], - "indexes": [], - "primaryKey": { "columns": ["id"] }, - "uniques": [] - } - } - }, - "target": "postgres", - "targetFamily": "sql" -} +{"_generated":{"message":"This file is automatically generated by \"prisma-next contract emit\".","regenerate":"To regenerate, run: prisma-next contract emit","warning":"⚠️ GENERATED FILE - DO NOT EDIT"},"capabilities":{"postgres":{"distinctOn":true,"jsonAgg":true,"lateral":true,"limit":true,"orderBy":true,"returning":true},"sql":{"defaultInInsert":true,"enums":true,"lateral":true,"returning":true}},"domain":{"namespaces":{"public":{"models":{"EqlV2Configuration":{"fields":{"data":{"nullable":false,"type":{"codecId":"pg/jsonb@1","kind":"scalar"}},"id":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}},"state":{"nullable":false,"type":{"codecId":"pg/text@1","kind":"scalar"}}},"relations":{},"storage":{"fields":{"data":{"column":"data"},"id":{"column":"id"},"state":{"column":"state"}},"namespaceId":"public","table":"eql_v2_configuration"}}}}}},"extensionPacks":{},"meta":{},"profileHash":"sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb","roots":{"eql_v2_configuration":{"model":"EqlV2Configuration","namespace":"public"}},"schemaVersion":"1","storage":{"namespaces":{"public":{"entries":{"table":{"eql_v2_configuration":{"columns":{"data":{"codecId":"pg/jsonb@1","nativeType":"jsonb","nullable":false},"id":{"codecId":"pg/text@1","nativeType":"text","nullable":false},"state":{"codecId":"pg/text@1","nativeType":"text","nullable":false}},"foreignKeys":[],"indexes":[],"primaryKey":{"columns":["id"]},"uniques":[]}}},"id":"public","kind":"postgres-schema"}},"storageHash":"sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7"},"target":"postgres","targetFamily":"sql"} diff --git a/examples/prisma/migrations/cipherstash/refs/head.json b/examples/prisma/migrations/cipherstash/refs/head.json index 78f580898..d3a5021e4 100644 --- a/examples/prisma/migrations/cipherstash/refs/head.json +++ b/examples/prisma/migrations/cipherstash/refs/head.json @@ -1,4 +1 @@ -{ - "hash": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", - "invariants": ["cipherstash:install-eql-bundle-v1"] -} +{"hash":"sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7","invariants":["cipherstash:install-eql-bundle-v1"]} diff --git a/examples/prisma/package.json b/examples/prisma/package.json index 9560b85b7..7ad4aff1b 100644 --- a/examples/prisma/package.json +++ b/examples/prisma/package.json @@ -7,7 +7,7 @@ "scripts": { "emit": "prisma-next contract emit", "migration:plan": "prisma-next migration plan", - "migration:apply": "prisma-next migration apply", + "migration:apply": "prisma-next migrate --yes", "start": "tsx src/index.ts", "test:e2e": "vitest run --config test/e2e/vitest.config.ts", "typecheck": "tsc --project tsconfig.json --noEmit" @@ -15,21 +15,21 @@ "dependencies": { "@cipherstash/prisma-next": "workspace:*", "@cipherstash/stack": "workspace:*", - "@prisma-next/adapter-postgres": "0.8.0", - "@prisma-next/contract": "0.8.0", - "@prisma-next/driver-postgres": "0.8.0", - "@prisma-next/family-sql": "0.8.0", - "@prisma-next/framework-components": "0.8.0", - "@prisma-next/postgres": "0.8.0", - "@prisma-next/sql-contract": "0.8.0", - "@prisma-next/sql-contract-psl": "0.8.0", - "@prisma-next/sql-orm-client": "0.8.0", - "@prisma-next/sql-runtime": "0.8.0", - "@prisma-next/target-postgres": "0.8.0", + "@prisma-next/adapter-postgres": "0.14.0", + "@prisma-next/contract": "0.14.0", + "@prisma-next/driver-postgres": "0.14.0", + "@prisma-next/family-sql": "0.14.0", + "@prisma-next/framework-components": "0.14.0", + "@prisma-next/postgres": "0.14.0", + "@prisma-next/sql-contract": "0.14.0", + "@prisma-next/sql-contract-psl": "0.14.0", + "@prisma-next/sql-orm-client": "0.14.0", + "@prisma-next/sql-runtime": "0.14.0", + "@prisma-next/target-postgres": "0.14.0", "dotenv": "^17.4.2" }, "devDependencies": { - "@prisma-next/cli": "0.8.0", + "@prisma-next/cli": "0.14.0", "@types/node": "^22.19.19", "pathe": "^2.0.3", "tsx": "catalog:repo", diff --git a/examples/prisma/prisma-next.config.ts b/examples/prisma/prisma-next.config.ts index c08559a80..9cf0597bb 100644 --- a/examples/prisma/prisma-next.config.ts +++ b/examples/prisma/prisma-next.config.ts @@ -6,6 +6,7 @@ import postgresDriver from '@prisma-next/driver-postgres/control' import sql from '@prisma-next/family-sql/control' import { prismaContract } from '@prisma-next/sql-contract-psl/provider' import postgres from '@prisma-next/target-postgres/control' +import postgresPack from '@prisma-next/target-postgres/pack' const databaseUrl = process.env['DATABASE_URL'] @@ -17,7 +18,9 @@ export default defineConfig({ extensionPacks: [cipherstash], contract: prismaContract('./prisma/schema.prisma', { output: 'src/prisma/contract.json', - target: postgres, + // Since 0.14 `prismaContract` takes the target PACK ref (carrying + // `defaultNamespaceId`), not the control descriptor. + target: postgresPack, }), migrations: { dir: 'migrations', diff --git a/examples/prisma/src/index.ts b/examples/prisma/src/index.ts index 64242ec0d..885d3cddf 100644 --- a/examples/prisma/src/index.ts +++ b/examples/prisma/src/index.ts @@ -118,7 +118,7 @@ async function main() { } async function clearUsers(): Promise { - const removed = await db.orm.User.where((u) => u.id.isNotNull()).deleteCount() + const removed = await db.orm.public.User.where((u) => u.id.isNotNull()).deleteCount() if (removed > 0) { console.log(`--- Cleanup ---\nRemoved ${removed} existing user row(s).\n`) } @@ -128,7 +128,7 @@ async function insertUsers(): Promise { console.log('--- Insert (mixed-codec round-trip) ---') await Promise.all( SEED_USERS.map((seed) => - db.orm.User.create({ + db.orm.public.User.create({ id: seed.id, email: EncryptedString.from(seed.email), salary: EncryptedDouble.from(seed.salary), @@ -146,7 +146,7 @@ async function insertUsers(): Promise { async function searchByEq(): Promise { console.log('\n--- cipherstashEq (string equality) ---') - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.email.cipherstashEq('alice@example.com'), ).all() console.log(`Found ${rows.length} row(s) for alice@example.com.`) @@ -158,7 +158,7 @@ async function searchByEq(): Promise { async function searchByIlikeAndDecrypt(): Promise { console.log('\n--- cipherstashIlike (string free-text-search) ---') - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.email.cipherstashIlike('%@example.com'), ).all() console.log(`Found ${rows.length} row(s) matching %@example.com.`) @@ -170,7 +170,7 @@ async function searchByIlikeAndDecrypt(): Promise { async function rangeQueryOnSalary(): Promise { console.log('\n--- cipherstashGt (double order-and-range) ---') - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.salary.cipherstashGt(100_000), ).all() console.log(`Found ${rows.length} user(s) with salary > 100,000.`) @@ -184,7 +184,7 @@ async function betweenQueryOnBirthday(): Promise { console.log('\n--- cipherstashBetween (date order-and-range) ---') const lower = new Date('1985-01-01') const upper = new Date('1995-12-31') - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.birthday.cipherstashBetween(lower, upper), ).all() console.log(`Found ${rows.length} user(s) born between 1985 and 1995.`) @@ -192,7 +192,7 @@ async function betweenQueryOnBirthday(): Promise { async function inArrayQueryOnAccountId(): Promise { console.log('\n--- cipherstashInArray (bigint equality) ---') - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.accountId.cipherstashInArray([100_000_000_001n, 100_000_000_004n]), ).all() console.log( @@ -204,7 +204,7 @@ async function equalityQueryOnEmailVerified(): Promise { console.log('\n--- cipherstashInArray (boolean equality-only) ---') // Booleans surface only the equality-trait operators; a single-element // array is the canonical equality form on non-string codecs. - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.emailVerified.cipherstashInArray([true]), ).all() console.log(`Found ${rows.length} user(s) with emailVerified = true.`) @@ -212,7 +212,7 @@ async function equalityQueryOnEmailVerified(): Promise { async function sortByEmailAsc(): Promise { console.log('\n--- cipherstashAsc (bare-column ORDER BY) ---') - const rows = await db.orm.User.orderBy((u) => cipherstashAsc(u.email)).all() + const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.email)).all() await decryptAll(rows) for (const row of rows) { console.log(` ${row.id}: email=${await row.email.decrypt()}`) diff --git a/examples/prisma/src/prisma/contract.d.ts b/examples/prisma/src/prisma/contract.d.ts index 9bd67b083..d6c3d2b94 100644 --- a/examples/prisma/src/prisma/contract.d.ts +++ b/examples/prisma/src/prisma/contract.d.ts @@ -1,16 +1,9 @@ // ⚠️ GENERATED FILE - DO NOT EDIT // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit - -import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types' -import type { - Contract as ContractType, - ExecutionHashBase, - ProfileHashBase, - StorageHashBase, -} from '@prisma-next/contract/types' -import type { CodecTypes as CipherstashTypes } from '@prisma-next/extension-cipherstash/codec-types' -import type { QueryOperationTypes as CipherstashQueryOperationTypes } from '@prisma-next/extension-cipherstash/operation-types' +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { CodecTypes as CipherstashTypes } from '@prisma-next/extension-cipherstash/codec-types'; +import type { QueryOperationTypes as CipherstashQueryOperationTypes } from '@prisma-next/extension-cipherstash/operation-types'; import type { EncryptedBigInt, EncryptedBoolean, @@ -18,516 +11,503 @@ import type { EncryptedDouble, EncryptedJson, EncryptedString, -} from '@prisma-next/extension-cipherstash/runtime' -import type { - ContractWithTypeMaps, - TypeMaps as TypeMapsType, -} from '@prisma-next/sql-contract/types' +} from '@prisma-next/extension-cipherstash/runtime'; import type { Bit, Char, + CodecTypes as PgTypes, Interval, JsonValue, Numeric, - CodecTypes as PgTypes, Time, Timestamp, Timestamptz, Timetz, VarBit, Varchar, -} from '@prisma-next/target-postgres/codec-types' +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:7475191ce0d78258ce5586265bcdfd12202f5daf90690b902890e58eb7508373'> -export type ExecutionHash = ExecutionHashBase + StorageHashBase<'sha256:b8c4febc9397cf1b68293cdb3b2afe9f568db6967f428e3f207e32575a2bc2fa'>; +export type ExecutionHash = ExecutionHashBase; export type ProfileHash = - ProfileHashBase<'sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e'> + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; -export type CodecTypes = PgTypes & CipherstashTypes -export type LaneCodecTypes = CodecTypes +export type CodecTypes = PgTypes & CipherstashTypes; +export type LaneCodecTypes = CodecTypes; export type QueryOperationTypes = PgAdapterQueryOps & - CipherstashQueryOperationTypes -type DefaultLiteralValue< - CodecId extends string, - _Encoded, -> = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded + CipherstashQueryOperationTypes; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; export type FieldOutputTypes = { - readonly User: { - readonly id: CodecTypes['pg/text@1']['output'] - readonly email: CodecTypes['cipherstash/string@1']['output'] - readonly salary: CodecTypes['cipherstash/double@1']['output'] - readonly accountId: CodecTypes['cipherstash/bigint@1']['output'] - readonly birthday: CodecTypes['cipherstash/date@1']['output'] - readonly emailVerified: CodecTypes['cipherstash/boolean@1']['output'] - readonly preferences: CodecTypes['cipherstash/json@1']['output'] - } -} + readonly public: { + readonly User: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly email: CodecTypes['cipherstash/string@1']['output']; + readonly salary: CodecTypes['cipherstash/double@1']['output']; + readonly accountId: CodecTypes['cipherstash/bigint@1']['output']; + readonly birthday: CodecTypes['cipherstash/date@1']['output']; + readonly emailVerified: CodecTypes['cipherstash/boolean@1']['output']; + readonly preferences: CodecTypes['cipherstash/json@1']['output']; + }; + }; +}; export type FieldInputTypes = { - readonly User: { - readonly id: CodecTypes['pg/text@1']['input'] - readonly email: CodecTypes['cipherstash/string@1']['input'] - readonly salary: CodecTypes['cipherstash/double@1']['input'] - readonly accountId: CodecTypes['cipherstash/bigint@1']['input'] - readonly birthday: CodecTypes['cipherstash/date@1']['input'] - readonly emailVerified: CodecTypes['cipherstash/boolean@1']['input'] - readonly preferences: CodecTypes['cipherstash/json@1']['input'] - } -} + readonly public: { + readonly User: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly email: CodecTypes['cipherstash/string@1']['input']; + readonly salary: CodecTypes['cipherstash/double@1']['input']; + readonly accountId: CodecTypes['cipherstash/bigint@1']['input']; + readonly birthday: CodecTypes['cipherstash/date@1']['input']; + readonly emailVerified: CodecTypes['cipherstash/boolean@1']['input']; + readonly preferences: CodecTypes['cipherstash/json@1']['input']; + }; + }; +}; export type TypeMaps = TypeMapsType< CodecTypes, QueryOperationTypes, FieldOutputTypes, FieldInputTypes -> +>; -type ContractBase = ContractType< - { - readonly tables: { - readonly users: { - columns: { - readonly id: { - readonly nativeType: 'text' - readonly codecId: 'pg/text@1' - readonly nullable: false - } - readonly email: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/string@1' - readonly nullable: false - readonly typeParams: { - readonly equality: true - readonly freeTextSearch: true - readonly orderAndRange: true - } - } - readonly salary: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/double@1' - readonly nullable: false - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - readonly accountid: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/bigint@1' - readonly nullable: false - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - readonly birthday: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/date@1' - readonly nullable: false - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - readonly emailverified: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/boolean@1' - readonly nullable: false - readonly typeParams: { readonly equality: true } - } - readonly preferences: { - readonly nativeType: 'eql_v2_encrypted' - readonly codecId: 'cipherstash/json@1' - readonly nullable: false - readonly typeParams: { readonly searchableJson: true } - } - } - primaryKey: { readonly columns: readonly ['id'] } - uniques: readonly [] - indexes: readonly [] - foreignKeys: readonly [] - } - } - readonly types: Record - readonly storageHash: StorageHash - }, - { - readonly User: { - readonly fields: { - readonly id: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'pg/text@1' - } - } - readonly email: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/string@1' - readonly typeParams: { - readonly equality: true - readonly freeTextSearch: true - readonly orderAndRange: true - } - } - } - readonly salary: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/double@1' - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - } - readonly accountId: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/bigint@1' - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - } - readonly birthday: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/date@1' - readonly typeParams: { - readonly equality: true - readonly orderAndRange: true - } - } - } - readonly emailVerified: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/boolean@1' - readonly typeParams: { readonly equality: true } - } - } - readonly preferences: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'cipherstash/json@1' - readonly typeParams: { readonly searchableJson: true } - } - } - } - readonly relations: Record - readonly storage: { - readonly table: 'users' - readonly fields: { - readonly id: { readonly column: 'id' } - readonly email: { readonly column: 'email' } - readonly salary: { readonly column: 'salary' } - readonly accountId: { readonly column: 'accountid' } - readonly birthday: { readonly column: 'birthday' } - readonly emailVerified: { readonly column: 'emailverified' } - readonly preferences: { readonly column: 'preferences' } - } - } - } - } +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'sql-namespace'; + readonly entries: { + readonly table: { + readonly users: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly email: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/string@1'; + readonly nullable: false; + readonly typeParams: { + readonly equality: true; + readonly freeTextSearch: true; + readonly orderAndRange: true; + }; + }; + readonly salary: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/double@1'; + readonly nullable: false; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + readonly accountid: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/bigint@1'; + readonly nullable: false; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + readonly birthday: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/date@1'; + readonly nullable: false; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + readonly emailverified: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/boolean@1'; + readonly nullable: false; + readonly typeParams: { readonly equality: true }; + }; + readonly preferences: { + readonly nativeType: 'eql_v2_encrypted'; + readonly codecId: 'cipherstash/json@1'; + readonly nullable: false; + readonly typeParams: { readonly searchableJson: true }; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' > & { - readonly target: 'postgres' - readonly targetFamily: 'sql' - readonly roots: { readonly users: 'User' } + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly users: { readonly namespace: 'public' & NamespaceId; readonly model: 'User' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly User: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly email: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/string@1'; + readonly typeParams: { + readonly equality: true; + readonly freeTextSearch: true; + readonly orderAndRange: true; + }; + }; + }; + readonly salary: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/double@1'; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + }; + readonly accountId: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/bigint@1'; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + }; + readonly birthday: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/date@1'; + readonly typeParams: { readonly equality: true; readonly orderAndRange: true }; + }; + }; + readonly emailVerified: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/boolean@1'; + readonly typeParams: { readonly equality: true }; + }; + }; + readonly preferences: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'cipherstash/json@1'; + readonly typeParams: { readonly searchableJson: true }; + }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'users'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly email: { readonly column: 'email' }; + readonly salary: { readonly column: 'salary' }; + readonly accountId: { readonly column: 'accountid' }; + readonly birthday: { readonly column: 'birthday' }; + readonly emailVerified: { readonly column: 'emailverified' }; + readonly preferences: { readonly column: 'preferences' }; + }; + }; + }; + }; + }; + }; + }; readonly capabilities: { readonly postgres: { - readonly jsonAgg: true - readonly lateral: true - readonly limit: true - readonly orderBy: true - readonly returning: true - } + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; readonly sql: { - readonly defaultInInsert: true - readonly enums: true - readonly returning: true - } - } + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; readonly extensionPacks: { readonly cipherstash: { - readonly familyId: 'sql' - readonly id: 'cipherstash' - readonly kind: 'extension' - readonly targetId: 'postgres' + readonly familyId: 'sql'; + readonly id: 'cipherstash'; + readonly kind: 'extension'; + readonly targetId: 'postgres'; readonly types: { readonly codecTypes: { readonly codecInstances: readonly [ { readonly descriptor: { - readonly codecId: 'cipherstash/string@1' - readonly factory: unknown - readonly isParameterized: false + readonly codecId: 'cipherstash/string@1'; + readonly factory: unknown; + readonly isParameterized: false; readonly meta: { readonly db: { readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; readonly paramsSchema: { readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; readonly traits: readonly [ 'cipherstash:equality', 'cipherstash:free-text-search', 'cipherstash:order-and-range', - ] - } + ]; + }; }, { readonly descriptor: { - readonly codecId: 'cipherstash/double@1' - readonly factory: unknown - readonly isParameterized: false + readonly codecId: 'cipherstash/double@1'; + readonly factory: unknown; + readonly isParameterized: false; readonly meta: { readonly db: { readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; readonly paramsSchema: { readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly [ - 'cipherstash:equality', - 'cipherstash:order-and-range', - ] - } + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:equality', 'cipherstash:order-and-range']; + }; }, { readonly descriptor: { - readonly codecId: 'cipherstash/bigint@1' - readonly factory: unknown - readonly isParameterized: false + readonly codecId: 'cipherstash/bigint@1'; + readonly factory: unknown; + readonly isParameterized: false; readonly meta: { readonly db: { readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; readonly paramsSchema: { readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly [ - 'cipherstash:equality', - 'cipherstash:order-and-range', - ] - } + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:equality', 'cipherstash:order-and-range']; + }; }, { readonly descriptor: { - readonly codecId: 'cipherstash/date@1' - readonly factory: unknown - readonly isParameterized: false + readonly codecId: 'cipherstash/date@1'; + readonly factory: unknown; + readonly isParameterized: false; readonly meta: { readonly db: { readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; readonly paramsSchema: { readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly [ - 'cipherstash:equality', - 'cipherstash:order-and-range', - ] - } + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:equality', 'cipherstash:order-and-range']; + }; }, { readonly descriptor: { - readonly codecId: 'cipherstash/boolean@1' - readonly factory: unknown - readonly isParameterized: false + readonly codecId: 'cipherstash/boolean@1'; + readonly factory: unknown; + readonly isParameterized: false; readonly meta: { readonly db: { readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; readonly paramsSchema: { readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly ['cipherstash:equality'] - } + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:equality']; + }; }, { readonly descriptor: { - readonly codecId: 'cipherstash/json@1' - readonly factory: unknown - readonly isParameterized: false + readonly codecId: 'cipherstash/json@1'; + readonly factory: unknown; + readonly isParameterized: false; readonly meta: { readonly db: { readonly sql: { - readonly postgres: { - readonly nativeType: 'eql_v2_encrypted' - } - } - } - } + readonly postgres: { readonly nativeType: 'eql_v2_encrypted' }; + }; + }; + }; readonly paramsSchema: { readonly '~standard': { - readonly validate: unknown - readonly vendor: 'cipherstash' - readonly version: 1 - } - } - readonly renderOutputType: unknown - readonly targetTypes: readonly ['eql_v2_encrypted'] - readonly traits: readonly ['cipherstash:searchable-json'] - } + readonly validate: unknown; + readonly vendor: 'cipherstash'; + readonly version: 1; + }; + }; + readonly renderOutputType: unknown; + readonly targetTypes: readonly ['eql_v2_encrypted']; + readonly traits: readonly ['cipherstash:searchable-json']; + }; }, - ] + ]; readonly import: { - readonly alias: 'CipherstashTypes' - readonly named: 'CodecTypes' - readonly package: '@prisma-next/extension-cipherstash/codec-types' - } + readonly alias: 'CipherstashTypes'; + readonly named: 'CodecTypes'; + readonly package: '@prisma-next/extension-cipherstash/codec-types'; + }; readonly typeImports: readonly [ { - readonly alias: 'EncryptedString' - readonly named: 'EncryptedString' - readonly package: '@prisma-next/extension-cipherstash/runtime' + readonly alias: 'EncryptedString'; + readonly named: 'EncryptedString'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; }, { - readonly alias: 'EncryptedDouble' - readonly named: 'EncryptedDouble' - readonly package: '@prisma-next/extension-cipherstash/runtime' + readonly alias: 'EncryptedDouble'; + readonly named: 'EncryptedDouble'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; }, { - readonly alias: 'EncryptedBigInt' - readonly named: 'EncryptedBigInt' - readonly package: '@prisma-next/extension-cipherstash/runtime' + readonly alias: 'EncryptedBigInt'; + readonly named: 'EncryptedBigInt'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; }, { - readonly alias: 'EncryptedDate' - readonly named: 'EncryptedDate' - readonly package: '@prisma-next/extension-cipherstash/runtime' + readonly alias: 'EncryptedDate'; + readonly named: 'EncryptedDate'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; }, { - readonly alias: 'EncryptedBoolean' - readonly named: 'EncryptedBoolean' - readonly package: '@prisma-next/extension-cipherstash/runtime' + readonly alias: 'EncryptedBoolean'; + readonly named: 'EncryptedBoolean'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; }, { - readonly alias: 'EncryptedJson' - readonly named: 'EncryptedJson' - readonly package: '@prisma-next/extension-cipherstash/runtime' + readonly alias: 'EncryptedJson'; + readonly named: 'EncryptedJson'; + readonly package: '@prisma-next/extension-cipherstash/runtime'; }, - ] - } + ]; + }; readonly queryOperationTypes: { readonly import: { - readonly alias: 'CipherstashQueryOperationTypes' - readonly named: 'QueryOperationTypes' - readonly package: '@prisma-next/extension-cipherstash/operation-types' - } - } + readonly alias: 'CipherstashQueryOperationTypes'; + readonly named: 'QueryOperationTypes'; + readonly package: '@prisma-next/extension-cipherstash/operation-types'; + }; + }; readonly storage: readonly [ { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/string@1' + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/string@1'; }, { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/double@1' + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/double@1'; }, { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/bigint@1' + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/bigint@1'; }, { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/date@1' + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/date@1'; }, { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/boolean@1' + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/boolean@1'; }, { - readonly familyId: 'sql' - readonly nativeType: 'eql_v2_encrypted' - readonly targetId: 'postgres' - readonly typeId: 'cipherstash/json@1' + readonly familyId: 'sql'; + readonly nativeType: 'eql_v2_encrypted'; + readonly targetId: 'postgres'; + readonly typeId: 'cipherstash/json@1'; }, - ] - } - readonly version: '0.0.1' - } - } - readonly meta: {} + ]; + }; + readonly version: '0.0.1'; + }; + }; + readonly meta: {}; - readonly profileHash: ProfileHash -} + readonly profileHash: ProfileHash; +}; -export type Contract = ContractWithTypeMaps +export type Contract = ContractWithTypeMaps; -export type Tables = Contract['storage']['tables'] -export type Models = Contract['models'] +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/prisma/src/prisma/contract.json b/examples/prisma/src/prisma/contract.json index 98c97b1fa..9a06c3eba 100644 --- a/examples/prisma/src/prisma/contract.json +++ b/examples/prisma/src/prisma/contract.json @@ -2,190 +2,211 @@ "schemaVersion": "1", "targetFamily": "sql", "target": "postgres", - "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", "roots": { - "users": "User" + "users": { + "model": "User", + "namespace": "public" + } }, - "models": { - "User": { - "fields": { - "accountId": { - "nullable": false, - "type": { - "codecId": "cipherstash/bigint@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "orderAndRange": true - } - } - }, - "birthday": { - "nullable": false, - "type": { - "codecId": "cipherstash/date@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "orderAndRange": true - } - } - }, - "email": { - "nullable": false, - "type": { - "codecId": "cipherstash/string@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "freeTextSearch": true, - "orderAndRange": true - } - } - }, - "emailVerified": { - "nullable": false, - "type": { - "codecId": "cipherstash/boolean@1", - "kind": "scalar", - "typeParams": { - "equality": true - } - } - }, - "id": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" - } - }, - "preferences": { - "nullable": false, - "type": { - "codecId": "cipherstash/json@1", - "kind": "scalar", - "typeParams": { - "searchableJson": true - } - } - }, - "salary": { - "nullable": false, - "type": { - "codecId": "cipherstash/double@1", - "kind": "scalar", - "typeParams": { - "equality": true, - "orderAndRange": true + "domain": { + "namespaces": { + "public": { + "models": { + "User": { + "fields": { + "accountId": { + "nullable": false, + "type": { + "codecId": "cipherstash/bigint@1", + "kind": "scalar", + "typeParams": { + "equality": true, + "orderAndRange": true + } + } + }, + "birthday": { + "nullable": false, + "type": { + "codecId": "cipherstash/date@1", + "kind": "scalar", + "typeParams": { + "equality": true, + "orderAndRange": true + } + } + }, + "email": { + "nullable": false, + "type": { + "codecId": "cipherstash/string@1", + "kind": "scalar", + "typeParams": { + "equality": true, + "freeTextSearch": true, + "orderAndRange": true + } + } + }, + "emailVerified": { + "nullable": false, + "type": { + "codecId": "cipherstash/boolean@1", + "kind": "scalar", + "typeParams": { + "equality": true + } + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "preferences": { + "nullable": false, + "type": { + "codecId": "cipherstash/json@1", + "kind": "scalar", + "typeParams": { + "searchableJson": true + } + } + }, + "salary": { + "nullable": false, + "type": { + "codecId": "cipherstash/double@1", + "kind": "scalar", + "typeParams": { + "equality": true, + "orderAndRange": true + } + } + } + }, + "relations": {}, + "storage": { + "fields": { + "accountId": { + "column": "accountid" + }, + "birthday": { + "column": "birthday" + }, + "email": { + "column": "email" + }, + "emailVerified": { + "column": "emailverified" + }, + "id": { + "column": "id" + }, + "preferences": { + "column": "preferences" + }, + "salary": { + "column": "salary" + } + }, + "namespaceId": "public", + "table": "users" } } } - }, - "relations": {}, - "storage": { - "fields": { - "accountId": { - "column": "accountid" - }, - "birthday": { - "column": "birthday" - }, - "email": { - "column": "email" - }, - "emailVerified": { - "column": "emailverified" - }, - "id": { - "column": "id" - }, - "preferences": { - "column": "preferences" - }, - "salary": { - "column": "salary" - } - }, - "table": "users" } } }, "storage": { - "storageHash": "sha256:7475191ce0d78258ce5586265bcdfd12202f5daf90690b902890e58eb7508373", - "tables": { - "users": { - "columns": { - "accountid": { - "codecId": "cipherstash/bigint@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "orderAndRange": true - } - }, - "birthday": { - "codecId": "cipherstash/date@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "orderAndRange": true - } - }, - "email": { - "codecId": "cipherstash/string@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "freeTextSearch": true, - "orderAndRange": true - } - }, - "emailverified": { - "codecId": "cipherstash/boolean@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true - } - }, - "id": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - }, - "preferences": { - "codecId": "cipherstash/json@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "searchableJson": true - } - }, - "salary": { - "codecId": "cipherstash/double@1", - "nativeType": "eql_v2_encrypted", - "nullable": false, - "typeParams": { - "equality": true, - "orderAndRange": true + "namespaces": { + "public": { + "entries": { + "table": { + "users": { + "columns": { + "accountid": { + "codecId": "cipherstash/bigint@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true, + "orderAndRange": true + } + }, + "birthday": { + "codecId": "cipherstash/date@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true, + "orderAndRange": true + } + }, + "email": { + "codecId": "cipherstash/string@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true, + "freeTextSearch": true, + "orderAndRange": true + } + }, + "emailverified": { + "codecId": "cipherstash/boolean@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true + } + }, + "id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "preferences": { + "codecId": "cipherstash/json@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "searchableJson": true + } + }, + "salary": { + "codecId": "cipherstash/double@1", + "nativeType": "eql_v2_encrypted", + "nullable": false, + "typeParams": { + "equality": true, + "orderAndRange": true + } + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] } } }, - "foreignKeys": [], - "indexes": [], - "primaryKey": { - "columns": ["id"] - }, - "uniques": [] + "id": "public", + "kind": "postgres-schema" } - } + }, + "storageHash": "sha256:b8c4febc9397cf1b68293cdb3b2afe9f568db6967f428e3f207e32575a2bc2fa" }, "capabilities": { "postgres": { + "distinctOn": true, "jsonAgg": true, "lateral": true, "limit": true, @@ -195,6 +216,7 @@ "sql": { "defaultInInsert": true, "enums": true, + "lateral": true, "returning": true } }, @@ -225,7 +247,9 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], + "targetTypes": [ + "eql_v2_encrypted" + ], "traits": [ "cipherstash:equality", "cipherstash:free-text-search", @@ -251,7 +275,9 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], + "targetTypes": [ + "eql_v2_encrypted" + ], "traits": [ "cipherstash:equality", "cipherstash:order-and-range" @@ -276,7 +302,9 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], + "targetTypes": [ + "eql_v2_encrypted" + ], "traits": [ "cipherstash:equality", "cipherstash:order-and-range" @@ -301,7 +329,9 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], + "targetTypes": [ + "eql_v2_encrypted" + ], "traits": [ "cipherstash:equality", "cipherstash:order-and-range" @@ -326,8 +356,12 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], - "traits": ["cipherstash:equality"] + "targetTypes": [ + "eql_v2_encrypted" + ], + "traits": [ + "cipherstash:equality" + ] } }, { @@ -348,8 +382,12 @@ "version": 1 } }, - "targetTypes": ["eql_v2_encrypted"], - "traits": ["cipherstash:searchable-json"] + "targetTypes": [ + "eql_v2_encrypted" + ], + "traits": [ + "cipherstash:searchable-json" + ] } } ], @@ -446,4 +484,4 @@ "message": "This file is automatically generated by \"prisma-next contract emit\".", "regenerate": "To regenerate, run: prisma-next contract emit" } -} +} \ No newline at end of file diff --git a/examples/prisma/test/e2e/README.md b/examples/prisma/test/e2e/README.md index cce2113bf..96f575a2a 100644 --- a/examples/prisma/test/e2e/README.md +++ b/examples/prisma/test/e2e/README.md @@ -20,7 +20,7 @@ The harness's Vitest global setup (`global-setup.ts`): 1. `docker compose up -d` and waits for `pg_isready`. 2. Sets `DATABASE_URL` to the harness's local Postgres URL. -3. Runs `prisma-next migration apply` against the example app (installs the cipherstash baseline migration + the `users` table). +3. Runs `prisma-next migrate` against the example app (installs the cipherstash baseline migration + the `users` table). 4. Skips cleanly (logging the missing env var) when `CS_WORKSPACE_CRN` / `CS_CLIENT_ID` / `CS_CLIENT_KEY` / `CS_DEFAULT_KEY_ID` are unset, so PRs without secrets configured don't fail the suite. `vitest.config.ts` wires the global setup, scopes the run to `*.e2e.test.ts`, and pins `pool: 'threads'` + `maxWorkers: 1` + `isolate: false` + `fileParallelism: false` so every test file shares one Postgres connection and one CipherStash SDK encryption client (and the SDK isn't asked to run encrypts across files concurrently). Each test file truncates `users` in its `beforeAll` for clean-slate isolation. diff --git a/examples/prisma/test/e2e/bigint.e2e.test.ts b/examples/prisma/test/e2e/bigint.e2e.test.ts index d2ea268ef..8d363c10c 100644 --- a/examples/prisma/test/e2e/bigint.e2e.test.ts +++ b/examples/prisma/test/e2e/bigint.e2e.test.ts @@ -57,11 +57,11 @@ describe('EncryptedBigInt e2e (live PG + EQL + ZeroKMS)', () => { beforeAll(async () => { await ensureConnected() await truncateUsers() - await Promise.all(SEED.map((s) => db.orm.User.create(seedRow(s)))) + await Promise.all(SEED.map((s) => db.orm.public.User.create(seedRow(s)))) }) it('round-trips an EncryptedBigInt through bulkEncrypt + bulkDecrypt', async () => { - const rows = await db.orm.User.all() + const rows = await db.orm.public.User.all() expect(rows).toHaveLength(SEED.length) await decryptAll(rows) const byId = new Map(rows.map((r) => [r.id, r] as const)) @@ -73,7 +73,7 @@ describe('EncryptedBigInt e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashGt filters by encrypted bigint numeric order', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.accountId.cipherstashGt(1_000_000_000_002n), ).all() expect(rows.map((r) => r.id).sort()).toEqual([ @@ -83,7 +83,7 @@ describe('EncryptedBigInt e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashLte includes the equality boundary', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.accountId.cipherstashLte(1_000_000_000_002n), ).all() expect(rows.map((r) => r.id).sort()).toEqual([ @@ -93,7 +93,7 @@ describe('EncryptedBigInt e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashBetween filters by encrypted bigint range', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.accountId.cipherstashBetween( 1_000_000_000_002n, 9_000_000_000_000_000n, @@ -106,7 +106,7 @@ describe('EncryptedBigInt e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashInArray returns rows whose value matches any of the supplied bigints', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.accountId.cipherstashInArray([ 1_000_000_000_001n, BigInt(Number.MAX_SAFE_INTEGER), @@ -119,7 +119,7 @@ describe('EncryptedBigInt e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashAsc orders by bigint value (bare-column ORDER BY)', async () => { - const rows = await db.orm.User.orderBy((u) => + const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.accountId), ).all() expect(rows.map((r) => r.id)).toEqual([ diff --git a/examples/prisma/test/e2e/bool.e2e.test.ts b/examples/prisma/test/e2e/bool.e2e.test.ts index 16602b313..9c7a77e11 100644 --- a/examples/prisma/test/e2e/bool.e2e.test.ts +++ b/examples/prisma/test/e2e/bool.e2e.test.ts @@ -49,11 +49,11 @@ describe('EncryptedBoolean e2e (live PG + EQL + ZeroKMS)', () => { beforeAll(async () => { await ensureConnected() await truncateUsers() - await Promise.all(SEED.map((s) => db.orm.User.create(seedRow(s)))) + await Promise.all(SEED.map((s) => db.orm.public.User.create(seedRow(s)))) }) it('round-trips an EncryptedBoolean through bulkEncrypt + bulkDecrypt', async () => { - const rows = await db.orm.User.all() + const rows = await db.orm.public.User.all() expect(rows).toHaveLength(SEED.length) await decryptAll(rows) const byId = new Map(rows.map((r) => [r.id, r] as const)) @@ -67,21 +67,21 @@ describe('EncryptedBoolean e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashInArray([true]) returns the verified subset', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.emailVerified.cipherstashInArray([true]), ).all() expect(rows.map((r) => r.id).sort()).toEqual(['e2e-bool-0', 'e2e-bool-2']) }) it('cipherstashInArray([false]) returns the unverified subset', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.emailVerified.cipherstashInArray([false]), ).all() expect(rows.map((r) => r.id).sort()).toEqual(['e2e-bool-1', 'e2e-bool-3']) }) it('cipherstashInArray([true, false]) returns the entire population', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.emailVerified.cipherstashInArray([true, false]), ).all() expect(rows.map((r) => r.id).sort()).toEqual([ @@ -93,7 +93,7 @@ describe('EncryptedBoolean e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashNe([true]) excludes the equality match', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.emailVerified.cipherstashNe(true), ).all() expect(rows.map((r) => r.id).sort()).toEqual(['e2e-bool-1', 'e2e-bool-3']) diff --git a/examples/prisma/test/e2e/date.e2e.test.ts b/examples/prisma/test/e2e/date.e2e.test.ts index baee07635..b4aaf5b4e 100644 --- a/examples/prisma/test/e2e/date.e2e.test.ts +++ b/examples/prisma/test/e2e/date.e2e.test.ts @@ -49,11 +49,11 @@ describe('EncryptedDate e2e (live PG + EQL + ZeroKMS)', () => { beforeAll(async () => { await ensureConnected() await truncateUsers() - await Promise.all(SEED.map((s) => db.orm.User.create(seedRow(s)))) + await Promise.all(SEED.map((s) => db.orm.public.User.create(seedRow(s)))) }) it('round-trips an EncryptedDate through bulkEncrypt + bulkDecrypt', async () => { - const rows = await db.orm.User.all() + const rows = await db.orm.public.User.all() expect(rows).toHaveLength(SEED.length) await decryptAll(rows) const byId = new Map(rows.map((r) => [r.id, r] as const)) @@ -72,14 +72,14 @@ describe('EncryptedDate e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashGt filters dates after the cutoff', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.birthday.cipherstashGt(new Date('1995-01-01')), ).all() expect(rows.map((r) => r.id).sort()).toEqual(['e2e-date-2', 'e2e-date-3']) }) it('cipherstashBetween filters a closed date interval', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.birthday.cipherstashBetween( new Date('1985-01-01'), new Date('2005-12-31'), @@ -89,7 +89,7 @@ describe('EncryptedDate e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashAsc orders by calendar date (bare-column ORDER BY)', async () => { - const rows = await db.orm.User.orderBy((u) => + const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.birthday), ).all() expect(rows.map((r) => r.id)).toEqual([ @@ -101,7 +101,7 @@ describe('EncryptedDate e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashDesc reverses the date order', async () => { - const rows = await db.orm.User.orderBy((u) => + const rows = await db.orm.public.User.orderBy((u) => cipherstashDesc(u.birthday), ).all() expect(rows.map((r) => r.id)).toEqual([ diff --git a/examples/prisma/test/e2e/global-setup.ts b/examples/prisma/test/e2e/global-setup.ts index 7d2d68bde..3fb218342 100644 --- a/examples/prisma/test/e2e/global-setup.ts +++ b/examples/prisma/test/e2e/global-setup.ts @@ -27,9 +27,11 @@ */ import { type SpawnSyncReturns, spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' import { fileURLToPath } from 'node:url' import { config as loadDotenv } from 'dotenv' -import { dirname, resolve } from 'pathe' +import { dirname, join, resolve } from 'pathe' const HARNESS_DATABASE_URL = 'postgres://cipherstash:cipherstash@localhost:54329/cipherstash_e2e' @@ -73,11 +75,17 @@ export default async function setup(): Promise<() => Promise> { loadDotenv({ path: resolve(exampleDir, '.env') }) - if (!process.env['CS_WORKSPACE_CRN']) { + // Credentials come from either `.env` (`CS_*` variables) or the local + // CipherStash profile written by `stash auth login` (device-code flow; + // `~/.cipherstash`). The stack client resolves both — this gate only + // exists to fail fast with an actionable message when neither is present. + const hasProfile = existsSync(join(homedir(), '.cipherstash')) + if (!process.env['CS_WORKSPACE_CRN'] && !hasProfile) { throw new Error( - 'cipherstash e2e harness: `CS_WORKSPACE_CRN` is not set. Populate `.env` ' + - '(see `.env.example`) with a ZeroKMS workspace and the three companion ' + - 'credentials before running `pnpm test:e2e`.', + 'cipherstash e2e harness: no CipherStash credentials found. Either ' + + 'populate `.env` (see `.env.example`) with a ZeroKMS workspace and ' + + 'the three companion credentials, or log in once with ' + + '`stash auth login` (device-code flow) before running `pnpm test:e2e`.', ) } @@ -113,7 +121,7 @@ export default async function setup(): Promise<() => Promise> { const apply = spawnSync( 'pnpm', - ['exec', 'prisma-next', 'migration', 'apply'], + ['exec', 'prisma-next', 'migrate', '--yes'], { cwd: exampleDir, stdio: 'pipe', @@ -123,7 +131,7 @@ export default async function setup(): Promise<() => Promise> { ) if (apply.error || apply.signal || apply.status !== 0) { throw new Error( - describeSpawnFailure('`prisma-next migration apply`', apply), + describeSpawnFailure('`prisma-next migrate`', apply), ) } diff --git a/examples/prisma/test/e2e/json.e2e.test.ts b/examples/prisma/test/e2e/json.e2e.test.ts index 2b23d6687..de62144dd 100644 --- a/examples/prisma/test/e2e/json.e2e.test.ts +++ b/examples/prisma/test/e2e/json.e2e.test.ts @@ -82,11 +82,11 @@ describe('EncryptedJson e2e (live PG + EQL + ZeroKMS)', () => { beforeAll(async () => { await ensureConnected() await truncateUsers() - await Promise.all(SEED.map((s) => db.orm.User.create(seedRow(s)))) + await Promise.all(SEED.map((s) => db.orm.public.User.create(seedRow(s)))) }) it('round-trips an EncryptedJson through bulkEncrypt + bulkDecrypt', async () => { - const rows = await db.orm.User.all() + const rows = await db.orm.public.User.all() expect(rows).toHaveLength(SEED.length) await decryptAll(rows) const byId = new Map(rows.map((r) => [r.id, r] as const)) @@ -98,7 +98,7 @@ describe('EncryptedJson e2e (live PG + EQL + ZeroKMS)', () => { }) it.skip('cipherstashJsonbPathExists filters by JSON path (KNOWN LIMITATION: needs client-side selector hashing)', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.preferences.cipherstashJsonbPathExists('$.locale'), ).all() expect(rows.map((r) => r.id).sort()).toEqual(['e2e-json-0', 'e2e-json-1']) @@ -107,10 +107,10 @@ describe('EncryptedJson e2e (live PG + EQL + ZeroKMS)', () => { it('exposes cipherstashJsonbPathQueryFirst as a typed SELECT-expression helper', () => { // Type-level: the helper accepts an `Expression` and // returns an `Expression` typed as `cipherstash/json@1`. Wiring - // it into a `db.sql.users.select(...)` projection exercises the + // it into a `db.sql.public.users.select(...)` projection exercises the // typed surface; the live SQL execution is held back until the // STE-VEC selector hashing gap closes (see file docblock). - const projection = db.sql.users + const projection = db.sql.public.users .select((f) => ({ id: f.id, themeNode: cipherstashJsonbPathQueryFirst(f.preferences, '$.theme'), @@ -120,7 +120,7 @@ describe('EncryptedJson e2e (live PG + EQL + ZeroKMS)', () => { }) it('exposes cipherstashJsonbGet as a typed SELECT-expression helper', () => { - const projection = db.sql.users + const projection = db.sql.public.users .select((f) => ({ id: f.id, themeNode: cipherstashJsonbGet(f.preferences, 'theme'), diff --git a/examples/prisma/test/e2e/mixed.e2e.test.ts b/examples/prisma/test/e2e/mixed.e2e.test.ts index 05d711e73..aa386104b 100644 --- a/examples/prisma/test/e2e/mixed.e2e.test.ts +++ b/examples/prisma/test/e2e/mixed.e2e.test.ts @@ -155,7 +155,7 @@ describe('Mixed-codec e2e (live PG + EQL + ZeroKMS)', () => { }) runtime = (await db.connect({ url })) as { close(): Promise } await truncateUsers() - await Promise.all(SEED.map((s) => db.orm.User.create(seedRow(s)))) + await Promise.all(SEED.map((s) => db.orm.public.User.create(seedRow(s)))) counting.counts.reset() }) @@ -166,7 +166,7 @@ describe('Mixed-codec e2e (live PG + EQL + ZeroKMS)', () => { }) it('executes a four-column WHERE + ordered read end-to-end', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => and( u.email.cipherstashIlike('%@example.com'), u.salary.cipherstashGt(75_000), @@ -185,7 +185,7 @@ describe('Mixed-codec e2e (live PG + EQL + ZeroKMS)', () => { it('groups search-term encrypts: one bulkEncrypt per (table, column)', async () => { counting.counts.reset() - await db.orm.User.where((u) => + await db.orm.public.User.where((u) => and( u.email.cipherstashIlike('%@example.com'), u.salary.cipherstashGt(75_000), @@ -204,7 +204,7 @@ describe('Mixed-codec e2e (live PG + EQL + ZeroKMS)', () => { it('groups result decrypts: one bulkDecrypt per (table, column)', async () => { counting.counts.reset() - const rows = await db.orm.User.all() + const rows = await db.orm.public.User.all() await decryptAll(rows) // Six encrypted columns × N rows ⇒ exactly 6 `bulkDecrypt` calls // (one per `(users, )` group). diff --git a/examples/prisma/test/e2e/num.e2e.test.ts b/examples/prisma/test/e2e/num.e2e.test.ts index 1d989f833..6b22eb7b1 100644 --- a/examples/prisma/test/e2e/num.e2e.test.ts +++ b/examples/prisma/test/e2e/num.e2e.test.ts @@ -56,11 +56,11 @@ describe('EncryptedDouble e2e (live PG + EQL + ZeroKMS)', () => { beforeAll(async () => { await ensureConnected() await truncateUsers() - await Promise.all(SEED.map((s) => db.orm.User.create(seedRow(s)))) + await Promise.all(SEED.map((s) => db.orm.public.User.create(seedRow(s)))) }) it('round-trips an EncryptedDouble through bulkEncrypt + bulkDecrypt', async () => { - const rows = await db.orm.User.all() + const rows = await db.orm.public.User.all() expect(rows).toHaveLength(SEED.length) await decryptAll(rows) const byId = new Map(rows.map((r) => [r.id, r] as const)) @@ -72,14 +72,14 @@ describe('EncryptedDouble e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashGt filters by encrypted IEEE-754 numeric order', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.salary.cipherstashGt(95_000), ).all() expect(rows.map((r) => r.id).sort()).toEqual(['e2e-num-2', 'e2e-num-3']) }) it('cipherstashGte includes the equality boundary', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.salary.cipherstashGte(95_000), ).all() expect(rows.map((r) => r.id).sort()).toEqual([ @@ -90,14 +90,14 @@ describe('EncryptedDouble e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashLt filters strict-less-than', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.salary.cipherstashLt(120_000), ).all() expect(rows.map((r) => r.id).sort()).toEqual(['e2e-num-0', 'e2e-num-1']) }) it('cipherstashLte includes the equality boundary', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.salary.cipherstashLte(120_000), ).all() expect(rows.map((r) => r.id).sort()).toEqual([ @@ -108,14 +108,14 @@ describe('EncryptedDouble e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashBetween bounds inclusively on both sides', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.salary.cipherstashBetween(95_000, 120_000), ).all() expect(rows.map((r) => r.id).sort()).toEqual(['e2e-num-1', 'e2e-num-2']) }) it('cipherstashAsc orders by numeric value via bare-column ORDER BY', async () => { - const rows = await db.orm.User.orderBy((u) => + const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.salary), ).all() expect(rows.map((r) => r.id)).toEqual([ @@ -127,7 +127,7 @@ describe('EncryptedDouble e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashDesc reverses the ascending order', async () => { - const rows = await db.orm.User.orderBy((u) => + const rows = await db.orm.public.User.orderBy((u) => cipherstashDesc(u.salary), ).all() expect(rows.map((r) => r.id)).toEqual([ diff --git a/examples/prisma/test/e2e/str-range.e2e.test.ts b/examples/prisma/test/e2e/str-range.e2e.test.ts index 33b64cdf4..c98bda626 100644 --- a/examples/prisma/test/e2e/str-range.e2e.test.ts +++ b/examples/prisma/test/e2e/str-range.e2e.test.ts @@ -51,11 +51,11 @@ describe('EncryptedString orderAndRange e2e (live PG + EQL + ZeroKMS)', () => { beforeAll(async () => { await ensureConnected() await truncateUsers() - await Promise.all(SEED.map((s) => db.orm.User.create(seedRow(s)))) + await Promise.all(SEED.map((s) => db.orm.public.User.create(seedRow(s)))) }) it('round-trips an EncryptedString through bulkEncrypt + bulkDecrypt', async () => { - const rows = await db.orm.User.all() + const rows = await db.orm.public.User.all() expect(rows).toHaveLength(SEED.length) await decryptAll(rows) const byId = new Map(rows.map((r) => [r.id, r] as const)) @@ -67,14 +67,14 @@ describe('EncryptedString orderAndRange e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashGt filters lexicographically', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.email.cipherstashGt('m'), ).all() expect(rows.map((r) => r.id).sort()).toEqual(['e2e-str-2', 'e2e-str-3']) }) it('cipherstashAsc orders alphabetically (bare-column ORDER BY on string)', async () => { - const rows = await db.orm.User.orderBy((u) => cipherstashAsc(u.email)).all() + const rows = await db.orm.public.User.orderBy((u) => cipherstashAsc(u.email)).all() expect(rows.map((r) => r.id)).toEqual([ 'e2e-str-0', 'e2e-str-1', @@ -84,7 +84,7 @@ describe('EncryptedString orderAndRange e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashDesc reverses the alphabetical order', async () => { - const rows = await db.orm.User.orderBy((u) => + const rows = await db.orm.public.User.orderBy((u) => cipherstashDesc(u.email), ).all() expect(rows.map((r) => r.id)).toEqual([ @@ -96,7 +96,7 @@ describe('EncryptedString orderAndRange e2e (live PG + EQL + ZeroKMS)', () => { }) it('cipherstashIlike coexists with order-and-range on the same column', async () => { - const rows = await db.orm.User.where((u) => + const rows = await db.orm.public.User.where((u) => u.email.cipherstashIlike('%@example.com'), ).all() expect(rows.map((r) => r.id).sort()).toEqual([ diff --git a/packages/prisma-next/README.md b/packages/prisma-next/README.md index ba82820ce..6795240c2 100644 --- a/packages/prisma-next/README.md +++ b/packages/prisma-next/README.md @@ -71,13 +71,13 @@ npx prisma-next migration apply # installs EQL bundle + your schema ```typescript import { EncryptedString, decryptAll } from "@cipherstash/prisma-next/runtime" -await db.orm.User.create({ +await db.orm.public.User.create({ id: "user-0", email: EncryptedString.from("alice@example.com"), // ... }) -const rows = await db.orm.User +const rows = await db.orm.public.User .where((u) => u.email.cipherstashIlike("%@example.com")) .all() diff --git a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.d.ts b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.d.ts index a04e870b3..6443a1f58 100644 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.d.ts +++ b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.d.ts @@ -1,161 +1,175 @@ // ⚠️ GENERATED FILE - DO NOT EDIT // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit - -import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types' -import type { - Contract as ContractType, - ExecutionHashBase, - ProfileHashBase, - StorageHashBase, -} from '@prisma-next/contract/types' -import type { - ContractWithTypeMaps, - TypeMaps as TypeMapsType, -} from '@prisma-next/sql-contract/types' +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; import type { Bit, Char, + CodecTypes as PgTypes, Interval, JsonValue, Numeric, - CodecTypes as PgTypes, Time, Timestamp, Timestamptz, Timetz, VarBit, Varchar, -} from '@prisma-next/target-postgres/codec-types' +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4'> -export type ExecutionHash = ExecutionHashBase + StorageHashBase<'sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7'>; +export type ExecutionHash = ExecutionHashBase; export type ProfileHash = - ProfileHashBase<'sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e'> + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; -export type CodecTypes = PgTypes -export type OperationTypes = Record -export type LaneCodecTypes = CodecTypes -export type QueryOperationTypes = PgAdapterQueryOps -type DefaultLiteralValue< - CodecId extends string, - _Encoded, -> = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; export type FieldOutputTypes = { - readonly EqlV2Configuration: { - readonly id: CodecTypes['pg/text@1']['output'] - readonly state: CodecTypes['pg/text@1']['output'] - readonly data: CodecTypes['pg/jsonb@1']['output'] - } -} + readonly public: { + readonly EqlV2Configuration: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly state: CodecTypes['pg/text@1']['output']; + readonly data: CodecTypes['pg/jsonb@1']['output']; + }; + }; +}; export type FieldInputTypes = { - readonly EqlV2Configuration: { - readonly id: CodecTypes['pg/text@1']['input'] - readonly state: CodecTypes['pg/text@1']['input'] - readonly data: CodecTypes['pg/jsonb@1']['input'] - } -} + readonly public: { + readonly EqlV2Configuration: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly state: CodecTypes['pg/text@1']['input']; + readonly data: CodecTypes['pg/jsonb@1']['input']; + }; + }; +}; export type TypeMaps = TypeMapsType< CodecTypes, - OperationTypes, QueryOperationTypes, FieldOutputTypes, FieldInputTypes -> +>; -type ContractBase = ContractType< - { - readonly tables: { - readonly eql_v2_configuration: { - columns: { - readonly id: { - readonly nativeType: 'text' - readonly codecId: 'pg/text@1' - readonly nullable: false - } - readonly state: { - readonly nativeType: 'text' - readonly codecId: 'pg/text@1' - readonly nullable: false - } - readonly data: { - readonly nativeType: 'jsonb' - readonly codecId: 'pg/jsonb@1' - readonly nullable: false - } - } - primaryKey: { readonly columns: readonly ['id'] } - uniques: readonly [] - indexes: readonly [] - foreignKeys: readonly [] - } - } - readonly types: Record - readonly storageHash: StorageHash - }, - { - readonly EqlV2Configuration: { - readonly fields: { - readonly id: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'pg/text@1' - } - } - readonly state: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'pg/text@1' - } - } - readonly data: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'pg/jsonb@1' - } - } - } - readonly relations: Record - readonly storage: { - readonly table: 'eql_v2_configuration' - readonly fields: { - readonly id: { readonly column: 'id' } - readonly state: { readonly column: 'state' } - readonly data: { readonly column: 'data' } - } - } - } - } +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'sql-namespace'; + readonly entries: { + readonly table: { + readonly eql_v2_configuration: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly state: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly data: { + readonly nativeType: 'jsonb'; + readonly codecId: 'pg/jsonb@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' > & { - readonly target: 'postgres' - readonly targetFamily: 'sql' - readonly roots: { readonly eql_v2_configuration: 'EqlV2Configuration' } + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly eql_v2_configuration: { + readonly namespace: 'public' & NamespaceId; + readonly model: 'EqlV2Configuration'; + }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly EqlV2Configuration: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly state: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly data: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/jsonb@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'eql_v2_configuration'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly state: { readonly column: 'state' }; + readonly data: { readonly column: 'data' }; + }; + }; + }; + }; + }; + }; + }; readonly capabilities: { readonly postgres: { - readonly jsonAgg: true - readonly lateral: true - readonly limit: true - readonly orderBy: true - readonly returning: true - } + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; readonly sql: { - readonly defaultInInsert: true - readonly enums: true - readonly returning: true - } - } - readonly extensionPacks: {} - readonly meta: {} + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly meta: {}; - readonly profileHash: ProfileHash -} + readonly profileHash: ProfileHash; +}; -export type Contract = ContractWithTypeMaps +export type Contract = ContractWithTypeMaps; -export type Tables = Contract['storage']['tables'] -export type Models = Contract['models'] +export type Namespaces = Contract['storage']['namespaces']; diff --git a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.json b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.json index 4b32ae174..8dc56139f 100644 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.json +++ b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.json @@ -2,84 +2,105 @@ "schemaVersion": "1", "targetFamily": "sql", "target": "postgres", - "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", "roots": { - "eql_v2_configuration": "EqlV2Configuration" + "eql_v2_configuration": { + "model": "EqlV2Configuration", + "namespace": "public" + } }, - "models": { - "EqlV2Configuration": { - "fields": { - "data": { - "nullable": false, - "type": { - "codecId": "pg/jsonb@1", - "kind": "scalar" - } - }, - "id": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" - } - }, - "state": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" + "domain": { + "namespaces": { + "public": { + "models": { + "EqlV2Configuration": { + "fields": { + "data": { + "nullable": false, + "type": { + "codecId": "pg/jsonb@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "state": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "data": { + "column": "data" + }, + "id": { + "column": "id" + }, + "state": { + "column": "state" + } + }, + "namespaceId": "public", + "table": "eql_v2_configuration" + } } } - }, - "relations": {}, - "storage": { - "fields": { - "data": { - "column": "data" - }, - "id": { - "column": "id" - }, - "state": { - "column": "state" - } - }, - "table": "eql_v2_configuration" } } }, "storage": { - "storageHash": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", - "tables": { - "eql_v2_configuration": { - "columns": { - "data": { - "codecId": "pg/jsonb@1", - "nativeType": "jsonb", - "nullable": false - }, - "id": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - }, - "state": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false + "namespaces": { + "public": { + "entries": { + "table": { + "eql_v2_configuration": { + "columns": { + "data": { + "codecId": "pg/jsonb@1", + "nativeType": "jsonb", + "nullable": false + }, + "id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "state": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } } }, - "foreignKeys": [], - "indexes": [], - "primaryKey": { - "columns": ["id"] - }, - "uniques": [] + "id": "public", + "kind": "postgres-schema" } - } + }, + "storageHash": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7" }, "capabilities": { "postgres": { + "distinctOn": true, "jsonAgg": true, "lateral": true, "limit": true, @@ -89,6 +110,7 @@ "sql": { "defaultInInsert": true, "enums": true, + "lateral": true, "returning": true } }, @@ -99,4 +121,4 @@ "message": "This file is automatically generated by \"prisma-next contract emit\".", "regenerate": "To regenerate, run: prisma-next contract emit" } -} +} \ No newline at end of file diff --git a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.json b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.json index bf79367bf..4bf75c2ac 100644 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.json +++ b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.json @@ -1,116 +1,9 @@ { "from": null, - "to": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", - "labels": [], - "providedInvariants": ["cipherstash:install-eql-bundle-v1"], + "to": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7", + "providedInvariants": [ + "cipherstash:install-eql-bundle-v1" + ], "createdAt": "2026-05-09T03:42:56.902Z", - "fromContract": null, - "toContract": { - "schemaVersion": "1", - "targetFamily": "sql", - "target": "postgres", - "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", - "roots": { - "eql_v2_configuration": "EqlV2Configuration" - }, - "models": { - "EqlV2Configuration": { - "fields": { - "data": { - "nullable": false, - "type": { - "codecId": "pg/jsonb@1", - "kind": "scalar" - } - }, - "id": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" - } - }, - "state": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" - } - } - }, - "relations": {}, - "storage": { - "fields": { - "data": { - "column": "data" - }, - "id": { - "column": "id" - }, - "state": { - "column": "state" - } - }, - "table": "eql_v2_configuration" - } - } - }, - "storage": { - "storageHash": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", - "tables": { - "eql_v2_configuration": { - "columns": { - "data": { - "codecId": "pg/jsonb@1", - "nativeType": "jsonb", - "nullable": false - }, - "id": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - }, - "state": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - } - }, - "foreignKeys": [], - "indexes": [], - "primaryKey": { - "columns": ["id"] - }, - "uniques": [] - } - } - }, - "capabilities": { - "postgres": { - "jsonAgg": true, - "lateral": true, - "limit": true, - "orderBy": true, - "returning": true - }, - "sql": { - "defaultInInsert": true, - "enums": true, - "returning": true - } - }, - "extensionPacks": {}, - "meta": {}, - "_generated": { - "warning": "⚠️ GENERATED FILE - DO NOT EDIT", - "message": "This file is automatically generated by \"prisma-next contract emit\".", - "regenerate": "To regenerate, run: prisma-next contract emit" - } - }, - "hints": { - "used": [], - "applied": [], - "plannerVersion": "2.0.0" - }, - "migrationHash": "sha256:76923a92561cdad65c64088ce999bf7afe853b80aac0b787b0d271b0e623abbc" -} + "migrationHash": "sha256:ae84404848d090c24f4094331c14972474a9695ffd45d29a21c5f5a8cfed9df7" +} \ No newline at end of file diff --git a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.ts b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.ts index e43c50839..4588bea3d 100755 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.ts +++ b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.ts @@ -36,7 +36,7 @@ export default class M extends Migration { override describe() { return { from: null, - to: 'sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4', + to: 'sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7', } } diff --git a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/ops.json b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/ops.json index c124cd04e..47ee0ca65 100644 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/ops.json +++ b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/ops.json @@ -25,4 +25,4 @@ } ] } -] +] \ No newline at end of file diff --git a/packages/prisma-next/migrations/refs/head.json b/packages/prisma-next/migrations/refs/head.json index 78f580898..e79526b6f 100644 --- a/packages/prisma-next/migrations/refs/head.json +++ b/packages/prisma-next/migrations/refs/head.json @@ -1,4 +1,4 @@ { - "hash": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", + "hash": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7", "invariants": ["cipherstash:install-eql-bundle-v1"] } diff --git a/packages/prisma-next/package.json b/packages/prisma-next/package.json index fb7f55a13..36937fada 100644 --- a/packages/prisma-next/package.json +++ b/packages/prisma-next/package.json @@ -80,27 +80,28 @@ }, "dependencies": { "@cipherstash/stack": "workspace:*", - "@prisma-next/contract": "0.8.0", - "@prisma-next/family-sql": "0.8.0", - "@prisma-next/framework-components": "0.8.0", - "@prisma-next/migration-tools": "0.8.0", - "@prisma-next/sql-contract": "0.8.0", - "@prisma-next/sql-operations": "0.8.0", - "@prisma-next/sql-relational-core": "0.8.0", - "@prisma-next/sql-runtime": "0.8.0", - "@prisma-next/ts-render": "0.8.0", - "@prisma-next/utils": "0.8.0", + "@prisma-next/contract": "0.14.0", + "@prisma-next/family-sql": "0.14.0", + "@prisma-next/framework-components": "0.14.0", + "@prisma-next/migration-tools": "0.14.0", + "@prisma-next/sql-contract": "0.14.0", + "@prisma-next/sql-operations": "0.14.0", + "@prisma-next/sql-relational-core": "0.14.0", + "@prisma-next/sql-runtime": "0.14.0", + "@prisma-next/ts-render": "0.14.0", + "@prisma-next/utils": "0.14.0", "arktype": "^2.1.29" }, "devDependencies": { - "@prisma-next/adapter-postgres": "0.8.0", - "@prisma-next/cli": "0.8.0", - "@prisma-next/driver-postgres": "0.8.0", - "@prisma-next/psl-parser": "0.8.0", - "@prisma-next/sql-contract-psl": "0.8.0", - "@prisma-next/sql-contract-ts": "0.8.0", - "@prisma-next/sql-schema-ir": "0.8.0", - "@prisma-next/target-postgres": "0.8.0", + "@prisma-next/adapter-postgres": "0.14.0", + "@prisma-next/cli": "0.14.0", + "@prisma-next/driver-postgres": "0.14.0", + "@prisma-next/extension-author-tools": "0.14.0", + "@prisma-next/psl-parser": "0.14.0", + "@prisma-next/sql-contract-psl": "0.14.0", + "@prisma-next/sql-contract-ts": "0.14.0", + "@prisma-next/sql-schema-ir": "0.14.0", + "@prisma-next/target-postgres": "0.14.0", "pathe": "^2.0.3", "tsup": "catalog:repo", "typescript": "catalog:repo", diff --git a/packages/prisma-next/src/contract.d.ts b/packages/prisma-next/src/contract.d.ts index a04e870b3..6443a1f58 100644 --- a/packages/prisma-next/src/contract.d.ts +++ b/packages/prisma-next/src/contract.d.ts @@ -1,161 +1,175 @@ // ⚠️ GENERATED FILE - DO NOT EDIT // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit - -import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types' -import type { - Contract as ContractType, - ExecutionHashBase, - ProfileHashBase, - StorageHashBase, -} from '@prisma-next/contract/types' -import type { - ContractWithTypeMaps, - TypeMaps as TypeMapsType, -} from '@prisma-next/sql-contract/types' +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; import type { Bit, Char, + CodecTypes as PgTypes, Interval, JsonValue, Numeric, - CodecTypes as PgTypes, Time, Timestamp, Timestamptz, Timetz, VarBit, Varchar, -} from '@prisma-next/target-postgres/codec-types' +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4'> -export type ExecutionHash = ExecutionHashBase + StorageHashBase<'sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7'>; +export type ExecutionHash = ExecutionHashBase; export type ProfileHash = - ProfileHashBase<'sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e'> + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; -export type CodecTypes = PgTypes -export type OperationTypes = Record -export type LaneCodecTypes = CodecTypes -export type QueryOperationTypes = PgAdapterQueryOps -type DefaultLiteralValue< - CodecId extends string, - _Encoded, -> = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; export type FieldOutputTypes = { - readonly EqlV2Configuration: { - readonly id: CodecTypes['pg/text@1']['output'] - readonly state: CodecTypes['pg/text@1']['output'] - readonly data: CodecTypes['pg/jsonb@1']['output'] - } -} + readonly public: { + readonly EqlV2Configuration: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly state: CodecTypes['pg/text@1']['output']; + readonly data: CodecTypes['pg/jsonb@1']['output']; + }; + }; +}; export type FieldInputTypes = { - readonly EqlV2Configuration: { - readonly id: CodecTypes['pg/text@1']['input'] - readonly state: CodecTypes['pg/text@1']['input'] - readonly data: CodecTypes['pg/jsonb@1']['input'] - } -} + readonly public: { + readonly EqlV2Configuration: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly state: CodecTypes['pg/text@1']['input']; + readonly data: CodecTypes['pg/jsonb@1']['input']; + }; + }; +}; export type TypeMaps = TypeMapsType< CodecTypes, - OperationTypes, QueryOperationTypes, FieldOutputTypes, FieldInputTypes -> +>; -type ContractBase = ContractType< - { - readonly tables: { - readonly eql_v2_configuration: { - columns: { - readonly id: { - readonly nativeType: 'text' - readonly codecId: 'pg/text@1' - readonly nullable: false - } - readonly state: { - readonly nativeType: 'text' - readonly codecId: 'pg/text@1' - readonly nullable: false - } - readonly data: { - readonly nativeType: 'jsonb' - readonly codecId: 'pg/jsonb@1' - readonly nullable: false - } - } - primaryKey: { readonly columns: readonly ['id'] } - uniques: readonly [] - indexes: readonly [] - foreignKeys: readonly [] - } - } - readonly types: Record - readonly storageHash: StorageHash - }, - { - readonly EqlV2Configuration: { - readonly fields: { - readonly id: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'pg/text@1' - } - } - readonly state: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'pg/text@1' - } - } - readonly data: { - readonly nullable: false - readonly type: { - readonly kind: 'scalar' - readonly codecId: 'pg/jsonb@1' - } - } - } - readonly relations: Record - readonly storage: { - readonly table: 'eql_v2_configuration' - readonly fields: { - readonly id: { readonly column: 'id' } - readonly state: { readonly column: 'state' } - readonly data: { readonly column: 'data' } - } - } - } - } +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'sql-namespace'; + readonly entries: { + readonly table: { + readonly eql_v2_configuration: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly state: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly data: { + readonly nativeType: 'jsonb'; + readonly codecId: 'pg/jsonb@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' > & { - readonly target: 'postgres' - readonly targetFamily: 'sql' - readonly roots: { readonly eql_v2_configuration: 'EqlV2Configuration' } + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly eql_v2_configuration: { + readonly namespace: 'public' & NamespaceId; + readonly model: 'EqlV2Configuration'; + }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly EqlV2Configuration: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly state: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly data: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/jsonb@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'eql_v2_configuration'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly state: { readonly column: 'state' }; + readonly data: { readonly column: 'data' }; + }; + }; + }; + }; + }; + }; + }; readonly capabilities: { readonly postgres: { - readonly jsonAgg: true - readonly lateral: true - readonly limit: true - readonly orderBy: true - readonly returning: true - } + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; readonly sql: { - readonly defaultInInsert: true - readonly enums: true - readonly returning: true - } - } - readonly extensionPacks: {} - readonly meta: {} + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly meta: {}; - readonly profileHash: ProfileHash -} + readonly profileHash: ProfileHash; +}; -export type Contract = ContractWithTypeMaps +export type Contract = ContractWithTypeMaps; -export type Tables = Contract['storage']['tables'] -export type Models = Contract['models'] +export type Namespaces = Contract['storage']['namespaces']; diff --git a/packages/prisma-next/src/contract.json b/packages/prisma-next/src/contract.json index 4b32ae174..8dc56139f 100644 --- a/packages/prisma-next/src/contract.json +++ b/packages/prisma-next/src/contract.json @@ -2,84 +2,105 @@ "schemaVersion": "1", "targetFamily": "sql", "target": "postgres", - "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", "roots": { - "eql_v2_configuration": "EqlV2Configuration" + "eql_v2_configuration": { + "model": "EqlV2Configuration", + "namespace": "public" + } }, - "models": { - "EqlV2Configuration": { - "fields": { - "data": { - "nullable": false, - "type": { - "codecId": "pg/jsonb@1", - "kind": "scalar" - } - }, - "id": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" - } - }, - "state": { - "nullable": false, - "type": { - "codecId": "pg/text@1", - "kind": "scalar" + "domain": { + "namespaces": { + "public": { + "models": { + "EqlV2Configuration": { + "fields": { + "data": { + "nullable": false, + "type": { + "codecId": "pg/jsonb@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "state": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "data": { + "column": "data" + }, + "id": { + "column": "id" + }, + "state": { + "column": "state" + } + }, + "namespaceId": "public", + "table": "eql_v2_configuration" + } } } - }, - "relations": {}, - "storage": { - "fields": { - "data": { - "column": "data" - }, - "id": { - "column": "id" - }, - "state": { - "column": "state" - } - }, - "table": "eql_v2_configuration" } } }, "storage": { - "storageHash": "sha256:efa685171bebbb8f078f08d12be3578bb5d96b71669dccc6cc9e4be96af8cdb4", - "tables": { - "eql_v2_configuration": { - "columns": { - "data": { - "codecId": "pg/jsonb@1", - "nativeType": "jsonb", - "nullable": false - }, - "id": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false - }, - "state": { - "codecId": "pg/text@1", - "nativeType": "text", - "nullable": false + "namespaces": { + "public": { + "entries": { + "table": { + "eql_v2_configuration": { + "columns": { + "data": { + "codecId": "pg/jsonb@1", + "nativeType": "jsonb", + "nullable": false + }, + "id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "state": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } } }, - "foreignKeys": [], - "indexes": [], - "primaryKey": { - "columns": ["id"] - }, - "uniques": [] + "id": "public", + "kind": "postgres-schema" } - } + }, + "storageHash": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7" }, "capabilities": { "postgres": { + "distinctOn": true, "jsonAgg": true, "lateral": true, "limit": true, @@ -89,6 +110,7 @@ "sql": { "defaultInInsert": true, "enums": true, + "lateral": true, "returning": true } }, @@ -99,4 +121,4 @@ "message": "This file is automatically generated by \"prisma-next contract emit\".", "regenerate": "To regenerate, run: prisma-next contract emit" } -} +} \ No newline at end of file diff --git a/packages/prisma-next/src/exports/middleware.ts b/packages/prisma-next/src/exports/middleware.ts index 5f7a1c171..20bdfd854 100644 --- a/packages/prisma-next/src/exports/middleware.ts +++ b/packages/prisma-next/src/exports/middleware.ts @@ -6,10 +6,11 @@ * encrypted in batches before encode runs: * * ```ts + * import { PostgresRuntimeImpl } from '@prisma-next/postgres/runtime'; * import { createCipherstashRuntimeDescriptor } from '@prisma-next/extension-cipherstash/runtime'; * import { bulkEncryptMiddleware } from '@prisma-next/extension-cipherstash/middleware'; * - * const runtime = createRuntime({ + * const runtime = new PostgresRuntimeImpl({ * extensionPacks: [createCipherstashRuntimeDescriptor({ sdk })], * middleware: [bulkEncryptMiddleware(sdk)], * }); diff --git a/packages/prisma-next/src/exports/migration.ts b/packages/prisma-next/src/exports/migration.ts index 510bbfe95..cad360528 100644 --- a/packages/prisma-next/src/exports/migration.ts +++ b/packages/prisma-next/src/exports/migration.ts @@ -6,16 +6,20 @@ * to wire EQL search-config rows alongside structural DDL: * * ```ts - * import { Migration, MigrationCLI, createTable } from '@prisma-next/target-postgres/migration'; + * import { Migration, MigrationCLI } from '@prisma-next/target-postgres/migration'; * import { cipherstashAddSearchConfig } from '@cipherstash/prisma-next/migration'; * * export default class M extends Migration { * override get operations() { * return [ - * createTable('public', 'user', [ - * { name: 'email', typeSql: 'eql_v2_encrypted', defaultSql: '', nullable: false }, - * { name: 'id', typeSql: 'text', defaultSql: '', nullable: false }, - * ]), + * this.createTable({ + * schema: 'public', + * table: 'user', + * columns: [ + * { name: 'email', typeSql: 'eql_v2_encrypted', defaultSql: '', nullable: false }, + * { name: 'id', typeSql: 'text', defaultSql: '', nullable: false }, + * ], + * }), * cipherstashAddSearchConfig({ table: 'user', column: 'email', index: 'unique' }), * ]; * } @@ -24,8 +28,10 @@ * MigrationCLI.run(import.meta.url, M); * ``` * - * Identical ergonomics to `createTable` / `setNotNull` etc. from - * `@prisma-next/target-postgres/migration`. The codec lifecycle hook + * Identical ergonomics to the `this.createTable` / `this.setNotNull` + * methods on the `Migration` base class from + * `@prisma-next/target-postgres/migration` (the bare op factory + * functions were removed in Prisma Next 0.14). The codec lifecycle hook * for `Encrypted` columns calls these factories automatically * when planning a contract diff. */ diff --git a/packages/prisma-next/src/exports/runtime.ts b/packages/prisma-next/src/exports/runtime.ts index 928e55e7e..5c3e14982 100644 --- a/packages/prisma-next/src/exports/runtime.ts +++ b/packages/prisma-next/src/exports/runtime.ts @@ -20,8 +20,9 @@ * pgvector's `runtime.ts` precedent. The bulk-encrypt middleware ships * separately at `@prisma-next/extension-cipherstash/middleware` because * `SqlRuntimeExtensionDescriptor` does not own a middleware slot; - * consumers register it via `createRuntime({ middleware: - * [bulkEncryptMiddleware(sdk)] })`. + * consumers register it via `new PostgresRuntimeImpl({ middleware: + * [bulkEncryptMiddleware(sdk)] })` (or their target facade's + * `middleware` option). */ import type { SqlRuntimeExtensionDescriptor } from '@prisma-next/sql-runtime' diff --git a/packages/prisma-next/src/middleware/bulk-encrypt.ts b/packages/prisma-next/src/middleware/bulk-encrypt.ts index 7ee0203ce..23143852f 100644 --- a/packages/prisma-next/src/middleware/bulk-encrypt.ts +++ b/packages/prisma-next/src/middleware/bulk-encrypt.ts @@ -56,11 +56,10 @@ */ import type { + AnyExpression, AnyQueryAst, - ColumnRef, - DefaultValueExpr, InsertAst, - ParamRef, + InsertValue, UpdateAst, } from '@prisma-next/sql-relational-core/ast' import type { @@ -231,7 +230,7 @@ function stampUpdate(ast: UpdateAst): void { } function stampParamRefIfEnvelope( - value: ColumnRef | ParamRef | DefaultValueExpr, + value: AnyExpression | InsertValue, table: string, column: string, ): void { diff --git a/packages/prisma-next/src/stack/derive-schemas.ts b/packages/prisma-next/src/stack/derive-schemas.ts index 8b48d486f..785a05c40 100644 --- a/packages/prisma-next/src/stack/derive-schemas.ts +++ b/packages/prisma-next/src/stack/derive-schemas.ts @@ -6,7 +6,8 @@ * framework knows about — its physical table name (after `@map(...)` * collapsing), its physical column name, its codec id * (`cipherstash/@1`), and its per-flag search-mode `typeParams`. - * `deriveStackSchemas` walks `storage.tables` and returns one + * `deriveStackSchemas` walks the tables of every `storage.namespaces` + * entry and returns one * `EncryptedTable` per table with at least one cipherstash-codec'd * column, ready to pass to `Encryption({ schemas })`. Skipping the * hand-written second declaration removes a runtime-correctness @@ -41,7 +42,16 @@ import { */ export interface ContractStorageView { readonly storage?: { - readonly tables?: Readonly> + readonly namespaces?: Readonly< + Record< + string, + { + readonly entries?: { + readonly table?: Readonly> + } + } + > + > } } @@ -86,8 +96,12 @@ type CipherstashFlag = keyof typeof FLAG_DISPATCH export function deriveStackSchemas( contractJson: ContractStorageView, ): ReadonlyArray> { - const tables = contractJson.storage?.tables - if (!tables) return [] + const namespaces = contractJson.storage?.namespaces + if (!namespaces) return [] + const tables: Record = {} + for (const namespace of Object.values(namespaces)) { + Object.assign(tables, namespace.entries?.table) + } const result: EncryptedTable[] = [] diff --git a/packages/prisma-next/test/abort.test.ts b/packages/prisma-next/test/abort.test.ts index 5e9bcacae..2a40b0097 100644 --- a/packages/prisma-next/test/abort.test.ts +++ b/packages/prisma-next/test/abort.test.ts @@ -127,6 +127,7 @@ function makeMiddlewareCtx( now: () => Date.now(), log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, contentHash: async () => 'mock-hash', + planExecutionId: 'test-plan-execution', ...(signal === undefined ? {} : { signal }), } } diff --git a/packages/prisma-next/test/bulk-encrypt-middleware.test.ts b/packages/prisma-next/test/bulk-encrypt-middleware.test.ts index ad6359322..7a4efc004 100644 --- a/packages/prisma-next/test/bulk-encrypt-middleware.test.ts +++ b/packages/prisma-next/test/bulk-encrypt-middleware.test.ts @@ -69,6 +69,7 @@ function createCtx( error: vi.fn(), }, contentHash: async () => 'mock-hash', + planExecutionId: 'test-plan-execution', ...overrides, } } diff --git a/packages/prisma-next/test/cipherstash-codec-numeric.test.ts b/packages/prisma-next/test/cipherstash-codec-numeric.test.ts index 7a4847aed..195c9265f 100644 --- a/packages/prisma-next/test/cipherstash-codec-numeric.test.ts +++ b/packages/prisma-next/test/cipherstash-codec-numeric.test.ts @@ -31,6 +31,7 @@ describe('cipherstashDoubleCodecHooks — flag → index mapping', () => { next?: Partial | undefined codecId: string }): { + readonly namespaceId: string readonly tableName: string readonly fieldName: string readonly priorField?: StorageColumn @@ -42,6 +43,7 @@ describe('cipherstashDoubleCodecHooks — flag → index mapping', () => { nullable: false, } return { + namespaceId: '__unbound__', tableName: TABLE, fieldName: FIELD, ...(args.prior !== undefined @@ -124,6 +126,7 @@ describe('cipherstashDoubleCodecHooks — flag → index mapping', () => { describe('cipherstashBigIntCodecHooks — cast_as=big_int', () => { it("emits add_search_config(unique) with cast_as='big_int' when equality flips on", () => { const ctxArg = { + namespaceId: '__unbound__', tableName: TABLE, fieldName: FIELD, newField: { diff --git a/packages/prisma-next/test/cipherstash-codec-other-codecs.test.ts b/packages/prisma-next/test/cipherstash-codec-other-codecs.test.ts index 815acde92..103ecc3e5 100644 --- a/packages/prisma-next/test/cipherstash-codec-other-codecs.test.ts +++ b/packages/prisma-next/test/cipherstash-codec-other-codecs.test.ts @@ -32,6 +32,7 @@ const FIELD = 'email' describe('cipherstashDateCodecHooks — cast_as=date', () => { it("emits add_search_config(unique) with cast_as='date' when equality flips on", () => { const ctxArg = { + namespaceId: '__unbound__', tableName: TABLE, fieldName: FIELD, newField: { @@ -55,6 +56,7 @@ describe('cipherstashDateCodecHooks — cast_as=date', () => { describe('cipherstashBooleanCodecHooks — equality-only, cast_as=boolean', () => { it('emits a single add_search_config(unique) with cast_as=boolean when equality flips on', () => { const ctxArg = { + namespaceId: '__unbound__', tableName: TABLE, fieldName: FIELD, newField: { @@ -74,6 +76,7 @@ describe('cipherstashBooleanCodecHooks — equality-only, cast_as=boolean', () = it('does not emit ore ops — booleans have no orderAndRange flag', () => { const ctxArg = { + namespaceId: '__unbound__', tableName: TABLE, fieldName: FIELD, newField: { @@ -94,6 +97,7 @@ describe('cipherstashBooleanCodecHooks — equality-only, cast_as=boolean', () = describe('cipherstashJsonCodecHooks — searchableJson → ste_vec, cast_as=jsonb', () => { it('emits add_search_config(ste_vec) with cast_as=jsonb when searchableJson flips on', () => { const ctxArg = { + namespaceId: '__unbound__', tableName: TABLE, fieldName: FIELD, newField: { @@ -113,6 +117,7 @@ describe('cipherstashJsonCodecHooks — searchableJson → ste_vec, cast_as=json it('emits remove_search_config(ste_vec) on drop when searchableJson was previously enabled', () => { const ctxArg = { + namespaceId: '__unbound__', tableName: TABLE, fieldName: FIELD, priorField: { diff --git a/packages/prisma-next/test/cipherstash-codec-string.test.ts b/packages/prisma-next/test/cipherstash-codec-string.test.ts index ad1eca0b9..52cc22bab 100644 --- a/packages/prisma-next/test/cipherstash-codec-string.test.ts +++ b/packages/prisma-next/test/cipherstash-codec-string.test.ts @@ -40,6 +40,7 @@ function ctx(args: { tableName?: string fieldName?: string }): { + readonly namespaceId: string readonly tableName: string readonly fieldName: string readonly priorField?: StorageColumn @@ -51,6 +52,7 @@ function ctx(args: { nullable: false, } return { + namespaceId: '__unbound__', tableName: args.tableName ?? TABLE, fieldName: args.fieldName ?? FIELD, ...(args.prior !== undefined diff --git a/packages/prisma-next/test/cipherstash-codec.test.ts b/packages/prisma-next/test/cipherstash-codec.test.ts index 1978fe674..07896f6f6 100644 --- a/packages/prisma-next/test/cipherstash-codec.test.ts +++ b/packages/prisma-next/test/cipherstash-codec.test.ts @@ -26,7 +26,11 @@ import { planFieldEventOperations, } from '@prisma-next/family-sql/control' import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components' -import type { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types' +import { + buildSqlNamespace, + SqlStorage, + type StorageTable, +} from '@prisma-next/sql-contract/types' import { ifDefined } from '@prisma-next/utils/defined' import { describe, expect, it } from 'vitest' import cipherstashExtensionDescriptor from '../src/exports/control' @@ -117,11 +121,13 @@ describe('planFieldEventOperations driving the cipherstash hook', () => { target: 'postgres', targetFamily: 'sql', profileHash: profileHash('sha256:test'), - storage: { + storage: new SqlStorage({ storageHash: 'sha256:test' as StorageHashBase, - tables, - }, - models: {}, + namespaces: { + __unbound__: buildSqlNamespace({ id: '__unbound__', entries: { table: tables } }), + }, + }), + domain: { namespaces: { __unbound__: { models: {} } } }, roots: {}, capabilities: {}, extensionPacks: {}, @@ -136,7 +142,7 @@ describe('planFieldEventOperations driving the cipherstash hook', () => { >, ]) - it('inlines per-flag add ops on first emit (priorContract null) when flags are enabled', () => { + it('inlines per-flag add ops on first emit (priorContract null) when flags are enabled', async () => { const ops = planFieldEventOperations({ priorContract: null, newContract: build({ @@ -145,14 +151,15 @@ describe('planFieldEventOperations driving the cipherstash hook', () => { codecHooks, }) expect(ops).toHaveLength(2) - const ids = ops.map((c) => c.toOp().invariantId).sort() + const lowered = await Promise.all(ops.map((c) => c.toOp())) + const ids = lowered.map((op) => op.invariantId).sort() expect(ids).toEqual([ 'cipherstash-codec:User.email:add-search-config:match@v1', 'cipherstash-codec:User.email:add-search-config:unique@v1', ]) }) - it('inlines per-flag remove ops when previously-flagged column is dropped', () => { + it('inlines per-flag remove ops when previously-flagged column is dropped', async () => { const prior = build({ User: userTable({ equality: true, freeTextSearch: true }), }) @@ -165,7 +172,8 @@ describe('planFieldEventOperations driving the cipherstash hook', () => { codecHooks, }) expect(ops).toHaveLength(2) - const ids = ops.map((c) => c.toOp().invariantId).sort() + const lowered = await Promise.all(ops.map((c) => c.toOp())) + const ids = lowered.map((op) => op.invariantId).sort() expect(ids).toEqual([ 'cipherstash-codec:User.email:remove-search-config:match@v1', 'cipherstash-codec:User.email:remove-search-config:unique@v1', diff --git a/packages/prisma-next/test/derive-schemas.test.ts b/packages/prisma-next/test/derive-schemas.test.ts index 89d893fef..4f3dd6409 100644 --- a/packages/prisma-next/test/derive-schemas.test.ts +++ b/packages/prisma-next/test/derive-schemas.test.ts @@ -28,17 +28,26 @@ function makeContract( ) { return { storage: { - tables: Object.fromEntries( - Object.entries(tables).map(([name, cols]) => [ - name, - { - columns: cols as Record< - string, - { codecId: string; typeParams?: Record | null } - >, + namespaces: { + __unbound__: { + entries: { + table: Object.fromEntries( + Object.entries(tables).map(([name, cols]) => [ + name, + { + columns: cols as Record< + string, + { + codecId: string + typeParams?: Record | null + } + >, + }, + ]), + ), }, - ]), - ), + }, + }, }, } } @@ -47,7 +56,12 @@ describe('deriveStackSchemas', () => { it('returns an empty array when contract has no storage tables', () => { expect(deriveStackSchemas({})).toEqual([]) expect(deriveStackSchemas({ storage: {} })).toEqual([]) - expect(deriveStackSchemas({ storage: { tables: {} } })).toEqual([]) + expect(deriveStackSchemas({ storage: { namespaces: {} } })).toEqual([]) + expect( + deriveStackSchemas({ + storage: { namespaces: { __unbound__: { entries: { table: {} } } } }, + }), + ).toEqual([]) }) it('skips tables with no cipherstash columns', () => { diff --git a/packages/prisma-next/test/descriptor.test.ts b/packages/prisma-next/test/descriptor.test.ts index 71eca486a..6956128cd 100644 --- a/packages/prisma-next/test/descriptor.test.ts +++ b/packages/prisma-next/test/descriptor.test.ts @@ -23,6 +23,7 @@ */ import { assertDescriptorSelfConsistency } from '@prisma-next/migration-tools/spaces' +import { sqlContractCanonicalizationHooks } from '@prisma-next/sql-contract/canonicalization-hooks' import { describe, expect, it } from 'vitest' import cipherstashExtensionDescriptor from '../src/exports/control' import { @@ -46,9 +47,18 @@ describe('cipherstash extension descriptor (contract-space package layout)', () it('exposes a contractSpace declaring the eql_v2_configuration table', () => { const space = cipherstashExtensionDescriptor.contractSpace expect(space).toBeDefined() - expect(Object.keys(space!.contractJson.storage.tables)).toEqual([ - EQL_V2_CONFIGURATION_TABLE, - ]) + // Since 0.10 the storage IR is namespace-enveloped (tables under + // `storage.namespaces..entries.table` since 0.13); the + // extension's sole table lives in the target-owned default + // namespace (`public`). + const namespaces = space!.contractJson.storage.namespaces as Record< + string, + { entries?: { table?: Record } } + > + const tables = Object.values(namespaces).flatMap((ns) => + Object.keys(ns.entries?.table ?? {}), + ) + expect(tables).toEqual([EQL_V2_CONFIGURATION_TABLE]) }) it('publishes one baseline migration sourced from the on-disk emit pipeline', () => { @@ -109,6 +119,10 @@ describe('cipherstash extension descriptor (contract-space package layout)', () unknown >, headRefHash: space.headRef.hash, + // The emit pipeline hashes through the SQL family's + // canonicalization hooks; the recompute must use the same ones. + shouldPreserveEmpty: sqlContractCanonicalizationHooks.shouldPreserveEmpty, + sortStorage: sqlContractCanonicalizationHooks.sortStorage, }), ).not.toThrow() }) diff --git a/packages/prisma-next/test/from-stack-divergence.test.ts b/packages/prisma-next/test/from-stack-divergence.test.ts index e19842c0b..42c34316c 100644 --- a/packages/prisma-next/test/from-stack-divergence.test.ts +++ b/packages/prisma-next/test/from-stack-divergence.test.ts @@ -24,16 +24,22 @@ import { cipherstashFromStack } from '../src/stack/from-stack' function makeContract() { return { storage: { - tables: { - users: { - columns: { - email: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - typeParams: { equality: true, freeTextSearch: true }, - }, - verified: { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - typeParams: { equality: true }, + namespaces: { + __unbound__: { + entries: { + table: { + users: { + columns: { + email: { + codecId: CIPHERSTASH_STRING_CODEC_ID, + typeParams: { equality: true, freeTextSearch: true }, + }, + verified: { + codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, + typeParams: { equality: true }, + }, + }, + }, }, }, }, @@ -104,7 +110,13 @@ describe('cipherstashFromStack — divergence check', () => { }) it('throws when the contract has no cipherstash columns and no override is supplied', async () => { - const emptyContract = { storage: { tables: { users: { columns: {} } } } } + const emptyContract = { + storage: { + namespaces: { + __unbound__: { entries: { table: { users: { columns: {} } } } }, + }, + }, + } await expect( cipherstashFromStack({ contractJson: emptyContract }), ).rejects.toThrow(/no cipherstash columns found/) diff --git a/packages/prisma-next/test/helpers.test.ts b/packages/prisma-next/test/helpers.test.ts index a876e2cd1..0ca5aa527 100644 --- a/packages/prisma-next/test/helpers.test.ts +++ b/packages/prisma-next/test/helpers.test.ts @@ -24,12 +24,11 @@ import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime' import type { PostgresContract } from '@prisma-next/adapter-postgres/types' -import { emptyCodecLookup } from '@prisma-next/framework-components/codec' import type { RuntimeExtensionDescriptor, RuntimeTargetDescriptor, } from '@prisma-next/framework-components/execution' -import { validateContract } from '@prisma-next/sql-contract/validate' +import { validateSqlContractFully } from '@prisma-next/sql-contract/validators' import { type AnyExpression, ColumnRef, @@ -71,67 +70,75 @@ function emptySdk(): CipherstashSdk { const TABLE = 'user' -const contract = validateContract( - { - target: 'postgres', - targetFamily: 'sql', - profileHash: 'sha256:cipherstash-helpers-test', - roots: {}, - capabilities: {}, - extensionPacks: {}, - meta: {}, - storage: { - storageHash: 'sha256:cipherstash-helpers-test-storage', - tables: { - [TABLE]: { - columns: { - id: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, - email: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - score: { - codecId: CIPHERSTASH_DOUBLE_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - amount: { - codecId: CIPHERSTASH_BIGINT_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - birthday: { - codecId: CIPHERSTASH_DATE_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - enabled: { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - payload: { - codecId: CIPHERSTASH_JSON_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - plain: { - codecId: 'pg/text@1', - nativeType: 'text', - nullable: false, +const contract = validateSqlContractFully({ + target: 'postgres', + targetFamily: 'sql', + profileHash: 'sha256:cipherstash-helpers-test', + roots: {}, + capabilities: {}, + extensionPacks: {}, + meta: {}, + storage: { + storageHash: 'sha256:cipherstash-helpers-test-storage', + namespaces: { + __unbound__: { + id: '__unbound__', + entries: { + table: { + [TABLE]: { + columns: { + id: { + codecId: 'pg/text@1', + nativeType: 'text', + nullable: false, + }, + email: { + codecId: CIPHERSTASH_STRING_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + score: { + codecId: CIPHERSTASH_DOUBLE_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + amount: { + codecId: CIPHERSTASH_BIGINT_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + birthday: { + codecId: CIPHERSTASH_DATE_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + enabled: { + codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + payload: { + codecId: CIPHERSTASH_JSON_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + plain: { + codecId: 'pg/text@1', + nativeType: 'text', + nullable: false, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], }, }, - uniques: [], - indexes: [], - foreignKeys: [], }, }, }, - models: {}, }, - emptyCodecLookup, -) + domain: { namespaces: { __unbound__: { models: {} } } }, +}) const stubRuntimeTarget: RuntimeTargetDescriptor<'sql', 'postgres'> = { kind: 'target', @@ -279,7 +286,7 @@ describe('cipherstashJsonbPathQueryFirst — AST shape and SQL snapshot', () => expect(lowered.sql).toMatchInlineSnapshot( `"SELECT eql_v2.jsonb_path_query_first("user"."payload", $1) AS "first_email" FROM "user""`, ) - expect(lowered.params).toEqual(['$.user.email']) + expect(lowered.params).toEqual([{ kind: 'literal', value: '$.user.email' }]) }) }) @@ -301,7 +308,7 @@ describe('cipherstashJsonbGet — AST shape and SQL snapshot', () => { expect(lowered.sql).toMatchInlineSnapshot( `"SELECT eql_v2."->"("user"."payload", $1) AS "email_field" FROM "user""`, ) - expect(lowered.params).toEqual(['email']) + expect(lowered.params).toEqual([{ kind: 'literal', value: 'email' }]) }) }) diff --git a/packages/prisma-next/test/operation-types.types.test-d.ts b/packages/prisma-next/test/operation-types.types.test-d.ts index ea9304256..0fae56831 100644 --- a/packages/prisma-next/test/operation-types.types.test-d.ts +++ b/packages/prisma-next/test/operation-types.types.test-d.ts @@ -139,8 +139,8 @@ type _ilike_double_neg = Expect> type _notilike_string_pos = Expect< M<'cipherstashNotIlike', 'cipherstash/string@1'> > -// @ts-expect-error cipherstashNotIlike must not surface on cipherstash/double@1. type _notilike_double_neg = Expect< + // @ts-expect-error cipherstashNotIlike must not surface on cipherstash/double@1. M<'cipherstashNotIlike', 'cipherstash/double@1'> > // @ts-expect-error cipherstashNotIlike must not surface on pg/text@1. @@ -209,8 +209,8 @@ type _between_double_pos = Expect< type _notbetween_date_pos = Expect< M<'cipherstashNotBetween', 'cipherstash/date@1'> > -// @ts-expect-error cipherstashBetween must not surface on cipherstash/boolean@1. type _between_boolean_neg = Expect< + // @ts-expect-error cipherstashBetween must not surface on cipherstash/boolean@1. M<'cipherstashBetween', 'cipherstash/boolean@1'> > // @ts-expect-error cipherstashNotBetween must not surface on pg/text@1. @@ -221,8 +221,8 @@ type _notbetween_text_neg = Expect> type _jpe_json_pos = Expect< M<'cipherstashJsonbPathExists', 'cipherstash/json@1'> > -// @ts-expect-error cipherstashJsonbPathExists must not surface on cipherstash/string@1. type _jpe_string_neg = Expect< + // @ts-expect-error cipherstashJsonbPathExists must not surface on cipherstash/string@1. M<'cipherstashJsonbPathExists', 'cipherstash/string@1'> > // @ts-expect-error cipherstashJsonbPathExists must not surface on pg/text@1. diff --git a/packages/prisma-next/test/operator-lowering-equality.test.ts b/packages/prisma-next/test/operator-lowering-equality.test.ts index da0ed33b8..f74290ffe 100644 --- a/packages/prisma-next/test/operator-lowering-equality.test.ts +++ b/packages/prisma-next/test/operator-lowering-equality.test.ts @@ -25,6 +25,7 @@ import { columnAccessor, contract, getOperator, + literalParamValue, makeAdapter, selectWithWhere, TABLE, @@ -66,7 +67,7 @@ describe('cipherstash operator lowering — cipherstashEq', () => { // `bulk-encrypt.ts:stampRoutingKeysFromAst` does not walk — only // insert/update) still participate in the routing-key grouping. expect(lowered.params).toHaveLength(1) - const envelope = lowered.params[0] + const envelope = literalParamValue(lowered.params[0]) expect(envelope).toBeInstanceOf(EncryptedString) const handle = (envelope as EncryptedString).expose() expect(handle.plaintext).toBe('alice@example.com') @@ -89,7 +90,7 @@ describe('cipherstash operator lowering — cipherstashEq', () => { // The same envelope object flows through; the operator only // augments it with the routing key (write-once-wins semantics — // see `setHandleRoutingKey`). - expect(lowered.params[0]).toBe(userEnvelope) + expect(literalParamValue(lowered.params[0])).toBe(userEnvelope) const handle = userEnvelope.expose() expect(handle.table).toBe(TABLE) expect(handle.column).toBe(COLUMN) @@ -115,7 +116,9 @@ describe('cipherstash operator lowering — equality extensions', () => { `"SELECT "user"."id" AS "id" FROM "user" WHERE NOT eql_v2.eq("user"."email", $1::eql_v2_encrypted)"`, ) expect(lowered.params).toHaveLength(1) - expect(lowered.params[0]).toBeInstanceOf(EncryptedString) + expect(literalParamValue(lowered.params[0])).toBeInstanceOf( + EncryptedString, + ) }) it('lowers cipherstashInArray with a single element to a one-term OR', () => { @@ -164,8 +167,9 @@ describe('cipherstash operator lowering — equality extensions', () => { // Every envelope shares the same `(table, column)` routing key — // the bulk-encrypt grouping invariant for variable-arity ops. for (const param of lowered.params) { - expect(param).toBeInstanceOf(EncryptedString) - const handle = (param as EncryptedString).expose() + const envelope = literalParamValue(param) + expect(envelope).toBeInstanceOf(EncryptedString) + const handle = (envelope as EncryptedString).expose() expect(handle.table).toBe(TABLE) expect(handle.column).toBe(COLUMN) } diff --git a/packages/prisma-next/test/operator-lowering-text-search.test.ts b/packages/prisma-next/test/operator-lowering-text-search.test.ts index bfab62451..2e0d842db 100644 --- a/packages/prisma-next/test/operator-lowering-text-search.test.ts +++ b/packages/prisma-next/test/operator-lowering-text-search.test.ts @@ -22,6 +22,7 @@ import { columnAccessor, contract, getOperator, + literalParamValue, makeAdapter, selectWithWhere, TABLE, @@ -48,7 +49,7 @@ describe('cipherstash operator lowering — cipherstashIlike', () => { const lowered = makeAdapter().lower(ast, { contract }) expect(lowered.params).toHaveLength(1) - const envelope = lowered.params[0] + const envelope = literalParamValue(lowered.params[0]) expect(envelope).toBeInstanceOf(EncryptedString) const handle = (envelope as EncryptedString).expose() expect(handle.plaintext).toBe('%alice%') diff --git a/packages/prisma-next/test/operator-lowering.helpers.ts b/packages/prisma-next/test/operator-lowering.helpers.ts index f4587202c..faa68f762 100644 --- a/packages/prisma-next/test/operator-lowering.helpers.ts +++ b/packages/prisma-next/test/operator-lowering.helpers.ts @@ -25,16 +25,16 @@ import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime' import type { PostgresContract } from '@prisma-next/adapter-postgres/types' -import { emptyCodecLookup } from '@prisma-next/framework-components/codec' import type { RuntimeExtensionDescriptor, RuntimeTargetDescriptor, } from '@prisma-next/framework-components/execution' -import { validateContract } from '@prisma-next/sql-contract/validate' +import { validateSqlContractFully } from '@prisma-next/sql-contract/validators' import type { SqlOperationDescriptor } from '@prisma-next/sql-operations' import { type AnyExpression, ColumnRef, + type LoweredParam, ProjectionItem, SelectAst, TableSource, @@ -67,67 +67,75 @@ export function emptySdk(): CipherstashSdk { export const TABLE = 'user' export const COLUMN = 'email' -export const contract = validateContract( - { - target: 'postgres', - targetFamily: 'sql', - profileHash: 'sha256:cipherstash-operator-lowering-test', - roots: {}, - capabilities: {}, - extensionPacks: {}, - meta: {}, - storage: { - storageHash: 'sha256:cipherstash-operator-lowering-test-storage', - tables: { - [TABLE]: { - columns: { - id: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, - [COLUMN]: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - // Per-codec columns so the trait-dispatched operators - // can be exercised against each column type (the - // postgres renderer reads `nativeType` from the codec - // descriptor at lower time; the column is what gives - // the renderer the codec id to look up). - score: { - codecId: CIPHERSTASH_DOUBLE_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - amount: { - codecId: CIPHERSTASH_BIGINT_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - birthday: { - codecId: CIPHERSTASH_DATE_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - enabled: { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - payload: { - codecId: CIPHERSTASH_JSON_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, +export const contract = validateSqlContractFully({ + target: 'postgres', + targetFamily: 'sql', + profileHash: 'sha256:cipherstash-operator-lowering-test', + roots: {}, + capabilities: {}, + extensionPacks: {}, + meta: {}, + storage: { + storageHash: 'sha256:cipherstash-operator-lowering-test-storage', + namespaces: { + __unbound__: { + id: '__unbound__', + entries: { + table: { + [TABLE]: { + columns: { + id: { + codecId: 'pg/text@1', + nativeType: 'text', + nullable: false, + }, + [COLUMN]: { + codecId: CIPHERSTASH_STRING_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + // Per-codec columns so the trait-dispatched operators + // can be exercised against each column type (the + // postgres renderer reads `nativeType` from the codec + // descriptor at lower time; the column is what gives + // the renderer the codec id to look up). + score: { + codecId: CIPHERSTASH_DOUBLE_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + amount: { + codecId: CIPHERSTASH_BIGINT_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + birthday: { + codecId: CIPHERSTASH_DATE_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + enabled: { + codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + payload: { + codecId: CIPHERSTASH_JSON_CODEC_ID, + nativeType: EQL_V2_ENCRYPTED_TYPE, + nullable: true, + }, + }, + uniques: [], + indexes: [], + foreignKeys: [], }, }, - uniques: [], - indexes: [], - foreignKeys: [], }, }, }, - models: {}, }, - emptyCodecLookup, -) + domain: { namespaces: { __unbound__: { models: {} } } }, +}) // Stub runtime target — the Postgres adapter only consults `familyId` / // `targetId` on the target during `create`. Replicates the helper at @@ -221,3 +229,19 @@ export function selectWithWhere(whereExpr: AnyExpression) { .withProjection([ProjectionItem.of('id', ColumnRef.of(TABLE, 'id'))]) .withWhere(whereExpr) } + +/** + * Unwrap the value carried by a lowered `literal` param. Since 0.9 the + * adapter's `lower` emits structured `LoweredParam` entries + * (`{ kind: 'literal', value } | { kind: 'bind', name }`) instead of raw + * values; the operator-lowering assertions all target the literal + * payload (the cipherstash envelope). + */ +export function literalParamValue(param: LoweredParam): unknown { + if (param.kind !== 'literal') { + throw new Error( + `expected a literal lowered param, got kind '${param.kind}'`, + ) + } + return param.value +} diff --git a/packages/prisma-next/test/operator-lowering.test.ts b/packages/prisma-next/test/operator-lowering.test.ts index 10fd9413b..3ec1038f2 100644 --- a/packages/prisma-next/test/operator-lowering.test.ts +++ b/packages/prisma-next/test/operator-lowering.test.ts @@ -71,6 +71,7 @@ import { contract, emptySdk, getOperator, + literalParamValue, makeAdapter, selectWithWhere, TABLE, @@ -130,7 +131,7 @@ describe('cipherstash operator lowering — per-codec envelope dispatch', () => contract, }) expect(lowered.params).toHaveLength(1) - const envelope = lowered.params[0] + const envelope = literalParamValue(lowered.params[0]) expect(envelope).toBeInstanceOf(EncryptedDouble) }) @@ -144,7 +145,9 @@ describe('cipherstash operator lowering — per-codec envelope dispatch', () => const lowered = makeAdapter().lower(selectWithWhere(predicate), { contract, }) - expect(lowered.params[0]).toBeInstanceOf(EncryptedBigInt) + expect(literalParamValue(lowered.params[0])).toBeInstanceOf( + EncryptedBigInt, + ) }) it('cipherstashGt on a date column wraps the value in EncryptedDate', () => { @@ -157,7 +160,7 @@ describe('cipherstash operator lowering — per-codec envelope dispatch', () => const lowered = makeAdapter().lower(selectWithWhere(predicate), { contract, }) - expect(lowered.params[0]).toBeInstanceOf(EncryptedDate) + expect(literalParamValue(lowered.params[0])).toBeInstanceOf(EncryptedDate) }) it('cipherstashNe on a boolean column wraps the value in EncryptedBoolean', () => { @@ -170,7 +173,9 @@ describe('cipherstash operator lowering — per-codec envelope dispatch', () => const lowered = makeAdapter().lower(selectWithWhere(predicate), { contract, }) - expect(lowered.params[0]).toBeInstanceOf(EncryptedBoolean) + expect(literalParamValue(lowered.params[0])).toBeInstanceOf( + EncryptedBoolean, + ) }) it('cipherstashGt rejects a non-matching plaintext type for the column codec', () => { @@ -202,7 +207,7 @@ describe('cipherstash operator lowering — JSON path predicate', () => { `"SELECT "user"."id" AS "id" FROM "user" WHERE eql_v2.jsonb_path_exists("user"."payload", $1)"`, ) // Path is a plain text bind — no envelope wrapping. - expect(lowered.params).toEqual(['$.k']) + expect(lowered.params.map(literalParamValue)).toEqual(['$.k']) }) it('cipherstashJsonbPathExists rejects non-string path arguments', () => { diff --git a/packages/prisma-next/test/psl-interpretation-numeric.test.ts b/packages/prisma-next/test/psl-interpretation-numeric.test.ts index b24984c75..8769f97ca 100644 --- a/packages/prisma-next/test/psl-interpretation-numeric.test.ts +++ b/packages/prisma-next/test/psl-interpretation-numeric.test.ts @@ -11,6 +11,7 @@ * byte-for-byte (PSL/TS parity). */ +import type { Contract } from '@prisma-next/contract/types' import { parsePslDocument } from '@prisma-next/psl-parser' import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl' import { describe, expect, it } from 'vitest' @@ -21,6 +22,7 @@ const postgresTarget = { kind: 'target' as const, familyId: 'sql' as const, targetId: 'postgres' as const, + defaultNamespaceId: 'public', id: 'postgres', version: '0.0.1', capabilities: {}, @@ -38,6 +40,12 @@ function interpret(schema: string) { target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionPacks: [cipherstashControl.id], + composedExtensionContracts: new Map([ + [ + cipherstashControl.id, + cipherstashControl.contractSpace!.contractJson as unknown as Contract, + ], + ]), authoringContributions: { type: cipherstashPack.authoring.type, field: {} }, }) } @@ -55,7 +63,26 @@ type StorageView = { > readonly types?: Record> } -const asStorage = (storage: unknown): StorageView => storage as StorageView +// Since 0.10 the storage IR is namespace-enveloped: tables live under +// `storage.namespaces.` (the target-owned default namespace — +// `public` for Postgres since 0.12), while `types` stays at the root. +const asStorage = (storage: unknown): StorageView => { + const s = storage as { + readonly namespaces?: Record< + string, + { entries?: { table?: StorageView['tables'] } } + > + readonly types?: StorageView['types'] + } + const tables: StorageView['tables'] = {} + for (const ns of Object.values(s.namespaces ?? {})) { + Object.assign(tables, ns.entries?.table) + } + return { + tables, + ...(s.types !== undefined ? { types: s.types } : {}), + } +} describe('PSL interpretation: cipherstash.EncryptedDouble constructor', () => { it('lowers full args to a column with cipherstash/double@1 codec, eql_v2_encrypted nativeType', () => { diff --git a/packages/prisma-next/test/psl-interpretation-other-types.test.ts b/packages/prisma-next/test/psl-interpretation-other-types.test.ts index ead66a703..f1a7ab212 100644 --- a/packages/prisma-next/test/psl-interpretation-other-types.test.ts +++ b/packages/prisma-next/test/psl-interpretation-other-types.test.ts @@ -12,6 +12,7 @@ * `true` in every case. */ +import type { Contract } from '@prisma-next/contract/types' import { parsePslDocument } from '@prisma-next/psl-parser' import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl' import { describe, expect, it } from 'vitest' @@ -22,6 +23,7 @@ const postgresTarget = { kind: 'target' as const, familyId: 'sql' as const, targetId: 'postgres' as const, + defaultNamespaceId: 'public', id: 'postgres', version: '0.0.1', capabilities: {}, @@ -39,6 +41,12 @@ function interpret(schema: string) { target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionPacks: [cipherstashControl.id], + composedExtensionContracts: new Map([ + [ + cipherstashControl.id, + cipherstashControl.contractSpace!.contractJson as unknown as Contract, + ], + ]), authoringContributions: { type: cipherstashPack.authoring.type, field: {} }, }) } @@ -56,7 +64,26 @@ type StorageView = { > readonly types?: Record> } -const asStorage = (storage: unknown): StorageView => storage as StorageView +// Since 0.10 the storage IR is namespace-enveloped: tables live under +// `storage.namespaces.` (the target-owned default namespace — +// `public` for Postgres since 0.12), while `types` stays at the root. +const asStorage = (storage: unknown): StorageView => { + const s = storage as { + readonly namespaces?: Record< + string, + { entries?: { table?: StorageView['tables'] } } + > + readonly types?: StorageView['types'] + } + const tables: StorageView['tables'] = {} + for (const ns of Object.values(s.namespaces ?? {})) { + Object.assign(tables, ns.entries?.table) + } + return { + tables, + ...(s.types !== undefined ? { types: s.types } : {}), + } +} describe('PSL interpretation: cipherstash.EncryptedDate constructor', () => { it('lowers full args to a column with cipherstash/date@1 codec, eql_v2_encrypted nativeType', () => { diff --git a/packages/prisma-next/test/psl-interpretation.test.ts b/packages/prisma-next/test/psl-interpretation.test.ts index 3f73a0f76..aa4b4e4d9 100644 --- a/packages/prisma-next/test/psl-interpretation.test.ts +++ b/packages/prisma-next/test/psl-interpretation.test.ts @@ -28,6 +28,7 @@ * (`EncryptedDate`, `EncryptedBoolean`, `EncryptedJson`) */ +import type { Contract } from '@prisma-next/contract/types' import { parsePslDocument } from '@prisma-next/psl-parser' import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl' import { describe, expect, it } from 'vitest' @@ -38,6 +39,7 @@ const postgresTarget = { kind: 'target' as const, familyId: 'sql' as const, targetId: 'postgres' as const, + defaultNamespaceId: 'public', id: 'postgres', version: '0.0.1', capabilities: {}, @@ -55,6 +57,12 @@ function interpret(schema: string) { target: postgresTarget, scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionPacks: [cipherstashControl.id], + composedExtensionContracts: new Map([ + [ + cipherstashControl.id, + cipherstashControl.contractSpace!.contractJson as unknown as Contract, + ], + ]), authoringContributions: { type: cipherstashPack.authoring.type, field: {} }, }) } @@ -72,7 +80,26 @@ type StorageView = { > readonly types?: Record> } -const asStorage = (storage: unknown): StorageView => storage as StorageView +// Since 0.10 the storage IR is namespace-enveloped: tables live under +// `storage.namespaces.` (the target-owned default namespace — +// `public` for Postgres since 0.12), while `types` stays at the root. +const asStorage = (storage: unknown): StorageView => { + const s = storage as { + readonly namespaces?: Record< + string, + { entries?: { table?: StorageView['tables'] } } + > + readonly types?: StorageView['types'] + } + const tables: StorageView['tables'] = {} + for (const ns of Object.values(s.namespaces ?? {})) { + Object.assign(tables, ns.entries?.table) + } + return { + tables, + ...(s.types !== undefined ? { types: s.types } : {}), + } +} describe('PSL interpretation: cipherstash.EncryptedString constructor', () => { it('lowers full args to a column with codecId, nativeType, typeParams', () => { @@ -315,8 +342,11 @@ model User { // The named type's storage descriptor and the inline column's // codec/nativeType/typeParams must agree byte-for-byte; the inline // column carries `nullable` (and may carry `default`/etc.) which the - // named-type descriptor does not. + // named-type descriptor does not, while the named-type entry is + // stamped with the `kind` discriminator for the polymorphic + // `storage.types` slot. expect(aliasNamedType).toEqual({ + kind: 'codec-instance', codecId: inlineCol['codecId'], nativeType: inlineCol['nativeType'], typeParams: inlineCol['typeParams'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3023ed143..97b2c71c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,45 +146,45 @@ importers: specifier: workspace:* version: link:../../packages/stack '@prisma-next/adapter-postgres': - specifier: 0.8.0 - version: 0.8.0(typanion@3.14.0) + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@5.9.3) '@prisma-next/contract': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/driver-postgres': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/family-sql': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/framework-components': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/postgres': - specifier: 0.8.0 - version: 0.8.0(typanion@3.14.0) + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@5.9.3) '@prisma-next/sql-contract': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-contract-psl': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-orm-client': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-runtime': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/target-postgres': - specifier: 0.8.0 - version: 0.8.0(typanion@3.14.0) + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@5.9.3) dotenv: specifier: ^17.4.2 version: 17.4.2 devDependencies: '@prisma-next/cli': - specifier: 0.8.0 - version: 0.8.0(typanion@3.14.0) + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@5.9.3) '@types/node': specifier: ^22.19.19 version: 22.19.19 @@ -408,63 +408,66 @@ importers: specifier: workspace:* version: link:../stack '@prisma-next/contract': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/family-sql': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/framework-components': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/migration-tools': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-contract': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-operations': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-relational-core': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-runtime': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/ts-render': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/utils': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) arktype: specifier: ^2.1.29 version: 2.2.0 devDependencies: '@prisma-next/adapter-postgres': - specifier: 0.8.0 - version: 0.8.0(typanion@3.14.0) + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@5.9.3) '@prisma-next/cli': - specifier: 0.8.0 - version: 0.8.0(typanion@3.14.0) + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@5.9.3) '@prisma-next/driver-postgres': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) + '@prisma-next/extension-author-tools': + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/psl-parser': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-contract-psl': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-contract-ts': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/sql-schema-ir': - specifier: 0.8.0 - version: 0.8.0 + specifier: 0.14.0 + version: 0.14.0(typescript@5.9.3) '@prisma-next/target-postgres': - specifier: 0.8.0 - version: 0.8.0(typanion@3.14.0) + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@5.9.3) pathe: specifier: ^2.0.3 version: 2.0.3 @@ -604,7 +607,7 @@ importers: version: 17.4.2 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@types/pg@8.20.0)(gel@2.2.0)(mysql2@3.16.0)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@types/pg@8.20.0)(gel@2.2.0)(mysql2@3.16.0)(pg@8.21.0)(postgres@3.4.9) execa: specifier: ^9.5.2 version: 9.6.1 @@ -1035,18 +1038,10 @@ packages: '@cipherstash/protect-ffi@0.28.0': resolution: {integrity: sha512-R2L/8HwMREkVKlR5KCcuELIWz4QNButSBQzY+nRDHl1PUXjRqWG1h265FkKVtTXKNKUup7rB4mswu+M+t9KF3A==} - '@clack/core@1.3.0': - resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} - engines: {node: '>= 20.12.0'} - '@clack/core@1.3.1': resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} engines: {node: '>= 20.12.0'} - '@clack/prompts@1.3.0': - resolution: {integrity: sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==} - engines: {node: '>= 20.12.0'} - '@clack/prompts@1.4.0': resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} engines: {node: '>= 20.12.0'} @@ -1082,12 +1077,6 @@ packages: react-dom: optional: true - '@dagrejs/dagre@3.0.0': - resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==} - - '@dagrejs/graphlib@4.0.1': - resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} - '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1814,109 +1803,295 @@ packages: '@posthog/types@1.373.5': resolution: {integrity: sha512-K7STCnRG/WBE1q0BwEkIcrJB5OqECaymsQj6Hp4Ntvaek4dqHkZGfp6hxwIPqQPjlOXwidwPLo+XGsn+CoZUyw==} - '@prisma-next/adapter-postgres@0.8.0': - resolution: {integrity: sha512-YLzaPw+DZX9/FdPi3NK8bBEUpATULW+GTkB6v8BIl/upgVp6XkKxJe1rq2JEeDcxYF71JNhI8le7HCrAsfMoEQ==} + '@prisma-next/adapter-postgres@0.14.0': + resolution: {integrity: sha512-c90KFtI74WU+8YH3uhVg1DTmD10T2BpINzzys9F3n1jTM2Am0tXKx53V05Ev+7H1VBrjBoksZPZ92WWxVEXLew==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true + + '@prisma-next/cli-telemetry@0.14.0': + resolution: {integrity: sha512-JaP/+09tIL6zH+5WQS507db2HQ0B47HRR2AZD8VHmsw/tMwBDZqKVdIA5KptPMVSR3pcnXDygGvG7E8N+PSPRg==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/cli@0.8.0': - resolution: {integrity: sha512-3tVU24PDPwQFTEcI4K/RC9eC1aEHsSSNCAcg2/zpl3qH1FcjgRLc7XMkzqWnMH35ifPkPrwxuOjlNXp239643w==} + '@prisma-next/cli@0.14.0': + resolution: {integrity: sha512-wN4mZc0K1m6wB4NXSEdrA2RCfc8zHBs5yJq0G7XoWQ8J8vvX/hR2kkukJv9QfEYFEIMyOT7vjbSBh2vj4NQ56g==} + engines: {node: '>=24'} hasBin: true + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/config@0.8.0': - resolution: {integrity: sha512-RMZk/At00KC6tWrdfLnu2yXcWLjfPljiQgd1Ucr5Xfs2Fw5QA1c0/Rp/nb+2hfnCPLjiZ1Q71cNIzFEb3HNkrQ==} - engines: {node: '>=20'} + '@prisma-next/config@0.14.0': + resolution: {integrity: sha512-7U4Y5GeicvWy0dcg78J8arzqe13r97Zj8yB/e+Uea6M8LZxV2poW/afHlYnU0VXhLTZ4F29CdlWOUmCAKpl9mg==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/contract-authoring@0.8.0': - resolution: {integrity: sha512-LQ4kLO9U/adM9RdtWDOtNlkdq8wzpGs9Yml0yVun0t8zOoULvQkMFKsK1lHOnU9+xwBgHLUTnewU96ll436I9w==} + '@prisma-next/contract-authoring@0.14.0': + resolution: {integrity: sha512-79aLJO04p/XRX2QvIlFMR2ZvbqvI0xFAU5idgYySoevq8bSY9ORGA0XchSbhFl0d6d1VfSEZowRPwzvgl/6lsg==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/contract@0.8.0': - resolution: {integrity: sha512-n9mgNwBX1PSNHOFWXAhFihOv7osD+ZbAFlY5fVVPjOKKXpTcCUi0/Hqo5SIjhz8JKXpsGqz/Eq4X23c9Y3C99g==} - engines: {node: '>=20'} + '@prisma-next/contract@0.14.0': + resolution: {integrity: sha512-HMeduqWGl1NpRyhEvsswF1T8PSBPfbpvMNYMegiYPt2ck5oClhbl0LHkgAIHsOjBg3IJv2VhRqDhQiuRVbkC4A==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/driver-postgres@0.8.0': - resolution: {integrity: sha512-dg5FQ+hFYC3NwyZPoMN6/ppbLdEWnxN+w1z2XkaVSic5VFC/eXRONxMBlE0YNM69/AhXXbavtBmHklFBw0xduQ==} + '@prisma-next/driver-postgres@0.14.0': + resolution: {integrity: sha512-qla7/fAk2YqwomkUPevd9bIqJ5gQZcZTXbEoz8nJaErUunisEosepJOKwlTpquJV2+OAuezODCbrrYtUJ525jg==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/emitter@0.8.0': - resolution: {integrity: sha512-GU/WGpwNejOUauIY0diE3OMYsIfw3SuzH9FpzEri8nl5jvmzjG44Sa6gMEc/g22D3MSD9Ufn2rsLc2K5RmC9wA==} + '@prisma-next/emitter@0.14.0': + resolution: {integrity: sha512-qdyOe11AsXHMpVmPYulA/NjKEn59VkXekGrtg8lB+Pqd+Dg2HwQinaO6G1PXjymRyVgv31UKRyUU7eLhCQaZ0Q==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/errors@0.8.0': - resolution: {integrity: sha512-/ojKRvq4wYuH2ipTla9BeqGKZRFR6VIloxyXU+G2IILhpcobIepYTiOaafVd1ajx8KgR3Ux/rn8oTemIihdceA==} - engines: {node: '>=20'} + '@prisma-next/errors@0.14.0': + resolution: {integrity: sha512-+VlDHQaP3AAs3rYYYSDoG5Ng5Hkyl+F4oAHAvDsvhFMupsGWaBx7esc5MbBHfnjE/5i/cSybR8/efXrIZJI6zA==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/family-sql@0.8.0': - resolution: {integrity: sha512-L2wFXFv2gDFb2E+jm2HIUGSNMNuUwhZRzxDk4YzWEaG4+Xmj1kuv6tdqCLkTzuW9Re1OXMBivqtdIGDS9u4UWA==} + '@prisma-next/extension-author-tools@0.14.0': + resolution: {integrity: sha512-7z9RiduSL6czOnB934pS+n0+PpVfzB7ATDK5HN8yBJKQN45ulNOFbxbnvBNDCPml6qDCFKXN3rtxv4bFVyHhOw==} + engines: {node: '>=24'} + hasBin: true + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/framework-components@0.8.0': - resolution: {integrity: sha512-t0RXECncHX+gby7mCT/WbBXGvayQQ6/JmwmE24dArxFqhj+HqDaTCa6OVPN7MRyuPZ3sAXc3g46EpNsQY3Sx9A==} + '@prisma-next/family-sql@0.14.0': + resolution: {integrity: sha512-Gx4ByuilDiJzwsOkqWE42CSZrhO9/6uFDpyemD185eZsaJGk5tvEpbLy3UuLNx+6VEsvXfPYGLdGUy2LL8e9Pg==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/ids@0.8.0': - resolution: {integrity: sha512-Z8LnSHxtdcX0Fj976zb8dM3PmeRKErWBGUdtExBAgu4aU1So0buG3qOQKkx36Ff1AvD6YhNQFAvTYdSJymPjqg==} + '@prisma-next/framework-components@0.14.0': + resolution: {integrity: sha512-6ZPnesKake4pnRRQZUi67a5qfgs6Pc9cmNN2bAf2bQJYTg3w5b1yOhjg2sFupXT54hq/iE3rXRY5A7ltgSxijA==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/migration-tools@0.8.0': - resolution: {integrity: sha512-MRHV/CcQTHiliG+5jIFgnWrcIC8dfr9/Oh7WgOz9djysNxiytphE0QZAvKwghCC8dCXFTS4giAhimBsNjblv1A==} - engines: {node: '>=20'} + '@prisma-next/ids@0.14.0': + resolution: {integrity: sha512-E+Nhvgmco/Y24wiv/hWVt6wAf1J9sIS5qupoolBeL0V/BA/+zECewg/vCF2dj92hXk0jJ1WphhQDGrvx5XAPbQ==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/operations@0.8.0': - resolution: {integrity: sha512-zT0DFdXYSDfenBzyAZpkoMXRamw6fN9gcRQeVkaq3EhtCMCbIIj+gTMMYIFEKRLgbzqvdkRmEl3IV0ouyvvCyQ==} - engines: {node: '>=20'} + '@prisma-next/migration-tools@0.14.0': + resolution: {integrity: sha512-JdFJPO8qFA3N0lP6g/ZsRz4QGeDV/qQpRUWq+9phoAbL/kBaMha838JG5YUb6h8z3tD4/qg+Qs6ESZPURD4AcQ==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/postgres@0.8.0': - resolution: {integrity: sha512-UrUd7SBxgnCGm41+VbeGuYFkWU5ym8IPRO1Uem+M98w4sYk/EYuJCnF/KDpL2scqy3qg22MMc92j1jiz6d2Anw==} - engines: {node: '>=20'} + '@prisma-next/operations@0.14.0': + resolution: {integrity: sha512-h53frkeEYalkixESJvkCvvuELD5b3YnHdkqkBK1A6XyDoqFusEHpjlP4LMXhpAQlft6QdbgYZfttm9ZXBFBUQw==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/psl-parser@0.8.0': - resolution: {integrity: sha512-cDsVRqq0SZ3W+du1fFaUOe679dV1Zl35C9wmgSLQQ9LYohiEWsTsWfACDO9oOhLUOzeK6iFJb5WMdEgbvf/6Fg==} + '@prisma-next/postgres@0.14.0': + resolution: {integrity: sha512-0TXr3+x3INseRhzPUmMn2t6NUhD3DlDht0MHJ8SBvSpkiGXH237IwID161KtZ7xa6ihTTXdjDfIqgmbDtVxfEQ==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/psl-printer@0.8.0': - resolution: {integrity: sha512-M4jM5ZMVUMYNwmUTnuA3wqtjYVKIRfvh9XA5e26pCPFuBimDtHHN3PdDTciueCTrCqA4/Agl2xRQrACmUPaZ4A==} + '@prisma-next/psl-parser@0.14.0': + resolution: {integrity: sha512-XKumV4Bfy3KHoD3IsWVSDtzFjXKL5zENASbbfIXXpwll3AVgU4rtVKuJIH5fwUOYpcCUdLE8ZaNw6vcwLuse3w==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-builder@0.8.0': - resolution: {integrity: sha512-bCCEL7AkfAiGJUX3457YpRqVhfQbjbFbe2BaLD8JyqQPdwQOq7yHyDQuZwtG36zaPgGK9X7VzrFbNfxA+Q+Oww==} - engines: {node: '>=20'} + '@prisma-next/psl-printer@0.14.0': + resolution: {integrity: sha512-qVTjwpO9uvITSsMg/mtdk0Py7ITsQRpPjmidSi9ByoPE6OGBGF3/BaUbOjRiZ340i39Npg83jT4rLreH1GOFjg==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-contract-emitter@0.8.0': - resolution: {integrity: sha512-7OYXFoRrWFN7qfxGnI1CvqqMMwGGffDeazO2JSzOL2rOR8T/SlqQGg8wXRckR6HNgXQisZKFc8aEPTJcLioJew==} + '@prisma-next/sql-builder@0.14.0': + resolution: {integrity: sha512-11AAASmhf7CZ4M/qQ5JHaHxHl+olBf5TP6jYSKuX8c9COttyp9IN9QRI0+PKR0QSjZgU1VX9WPNRG8hEcmDrZw==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-contract-psl@0.8.0': - resolution: {integrity: sha512-cJFUOyDWwb8FR3YLm1Ye18luofSoVwJiMO1LPmnjAH9W+da8mbkFpH0OuJbppeHAnKkY4lqKj5HPW9Ow0RZ3Jg==} + '@prisma-next/sql-contract-emitter@0.14.0': + resolution: {integrity: sha512-gUgctyFNMKZ94wXNATevMi+FG9R9SK4yYQesYr7/5kZ4ZBbHkaFOFcWwlJZSI+EhCC2J1OgvfaCTtpWpn+VIUw==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-contract-ts@0.8.0': - resolution: {integrity: sha512-sjfYhT7nyuBJVVxAztlnLppcouXms71BreCXOvvuGp5nw4CzMECD12qjRt8BHcNDdpVSSYeUIhd/TIeMD/qtcQ==} + '@prisma-next/sql-contract-psl@0.14.0': + resolution: {integrity: sha512-86Z8MWo0NYhGSX4uOqbvAx46aMOaB2opNyUeFNnntT1br+U48IX2ZPGV62ul6mwCp7DGN7pjvmlBW5OnHXuiyg==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-contract@0.8.0': - resolution: {integrity: sha512-SJbWPz05GlOrV58Dbk5Tfx88cAbgH7nqd1NWQ66yT5E1OSJ4SGwZCyJbtUnbb9HqLqqphYws6vDBmmMy5NQ77w==} + '@prisma-next/sql-contract-ts@0.14.0': + resolution: {integrity: sha512-AMTURIMXCsn+M7oW0QOe/9wS7J+Q1siW7+aihgtmBj88lYav9XHyxJO5AJDu6LSDSctorTzccfsPatBAlcxzKQ==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-errors@0.8.0': - resolution: {integrity: sha512-e7KCFVmHEHOXNHYx6XOr0xW/kTCwo0yeyE/zR2EZVTyNjDRGTCS7FOkqOSVWyPwGmUosDogY51ojChZYfn/fdw==} - engines: {node: '>=20'} + '@prisma-next/sql-contract@0.14.0': + resolution: {integrity: sha512-/Ibvjd41sEgpi4c3YCZvK6vRCucNffsIZ+isIOYoVqXOt74H0llM4Ux4mR0PShNuo0jgG8xOon1ptSQ6/S7c+w==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-operations@0.8.0': - resolution: {integrity: sha512-uBTbNPsCjSMQxBw9ZV0K4egON2Vnp/YUe70dVEw/FPczR/w7/NKqiMhZ74qNpfxtFxpkefJ+nfERkMs3bnm1IQ==} - engines: {node: '>=20'} + '@prisma-next/sql-errors@0.14.0': + resolution: {integrity: sha512-zc5U+SzEUqpjPC69w9JejqAkcRtkZ8BSm1a6l6p4pT4b8dk/iXrfQ96oHoy6SYG/dTbGe47W4gCfX9LCzCdUlA==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-orm-client@0.8.0': - resolution: {integrity: sha512-UOCWxxY6uIj3FRJur7xztp/lz9Tm0RWaTw0CLxV0sFnoJ+QTHvjkTY3nIvgc/vvqsgtb4EpkthDDJAQjzK5VlQ==} - engines: {node: '>=20'} + '@prisma-next/sql-operations@0.14.0': + resolution: {integrity: sha512-0VeVceyh7LAlEGMseFPQrmoBPkNAlBiCOCyWzv1xQjjU0IoPkMlcjFu07wD5aLf+nwBzEUSbdg9EaTdBTYHO9Q==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-relational-core@0.8.0': - resolution: {integrity: sha512-l+hfL3ChYJI1oBgd9dZqUbf+I1dMpUBqlHzZJG1zyXv5wcTlqXbtOk18EoYe+BXp6jnHBwgQDsiBayLvB+ehpQ==} + '@prisma-next/sql-orm-client@0.14.0': + resolution: {integrity: sha512-fXyiR/8r3NqLqUKTxrL0o5KcxHW6r91pD0pU1tmnB6kJD3MS++Fhd2miDcbCn/5O0qf71zfmJDafKU74TOJ3dw==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-runtime@0.8.0': - resolution: {integrity: sha512-YLqJTzxFcpC5y/2ZPfWqvlHgifqJ/ADAXM8o7PHhrEJ5Z9fivppkEfVUlgnRVDjvPGGwCUY3VbK3FqOL/SbmXw==} + '@prisma-next/sql-relational-core@0.14.0': + resolution: {integrity: sha512-7H1HaN31lmGD81C9vz07dq/r5n80yKXfFNpYj5XrKKeVpDZQSb/IXtOVt7LyB+q9QqSUW2jD5RGLuTOhsd2kbw==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/sql-schema-ir@0.8.0': - resolution: {integrity: sha512-8xv+leV0bAK8jaFJN5ngEDUeYZGlvXvP/csiCzz8LHC5M4OTouGpbgyDDXFwC9tJThLaCZUK1zVF4XRYP9hftQ==} - engines: {node: '>=20'} + '@prisma-next/sql-runtime@0.14.0': + resolution: {integrity: sha512-HV8ZAvNuXtFrAHuxs3/ZEz0JapKFayFYDljQeVDFxExSEj79fPtd9CON9sTTV4T/RKII5knlKJRuKtwWH35Znw==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/target-postgres@0.8.0': - resolution: {integrity: sha512-CXkzmyXhtvcfhaBXBDlyb6Aq2wISQuax2pUbK+tq+e9Acz0idDRZk3FtvCkPUkviKv6ZAPA8EpnB9FT3iP3KGw==} + '@prisma-next/sql-schema-ir@0.14.0': + resolution: {integrity: sha512-5buE3jcTz3yyyve4demaACIv52YT09tFPCBEqwmxG1ss8KtybVEgaZg8UUYGmyYbWU0l5RpQ96/vuB5UTFE4nw==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/ts-render@0.8.0': - resolution: {integrity: sha512-IYx4NLsTaJxVlUWI+QQyY8LgLEHUOqqxTOwAlhSyXbzbTXiz89DN/I/swtapH3cl1Tk/PAJG9c5vKMl3lfAcng==} - engines: {node: '>=20'} + '@prisma-next/target-postgres@0.14.0': + resolution: {integrity: sha512-i1H9Jzt7ZGgYQ61rlmalyEPY1RoLFIMk9+qE5WbETjQ8LY9p7iXeTx4bBv25twkYIAHxXtrYxsQ3klUWg0JfVg==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true - '@prisma-next/utils@0.8.0': - resolution: {integrity: sha512-zpSG3L7/Cwtb/WkUZKZCSd4j5gzLCqNTqDb6hO3hcK7Me+blv72cOuyscyUMiYlkHRdLinnSX7MWcAQnsqNONQ==} - engines: {node: '>=20'} + '@prisma-next/ts-render@0.14.0': + resolution: {integrity: sha512-ub25pEfUe2dLpNu/wR0mlBBMZ87eGmyV5D9GS5KmyHkofjJdiE2Y3BpIhHXf7wjiYp35z/gOyQ2vuPFS4xVBgQ==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true + + '@prisma-next/utils@0.14.0': + resolution: {integrity: sha512-YIV6FMdKKsJqLkG2h9tQDzEoeAFjtniNXcB0XnyZyD1tkALodiWsRymuQJBGcFZB7S8icrOvRaqdSUNdrrR6yw==} + engines: {node: '>=24'} + peerDependencies: + typescript: '>=5.9' + peerDependenciesMeta: + typescript: + optional: true '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} @@ -2482,6 +2657,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -3397,11 +3576,17 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + pg-connection-string@2.12.0: resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} - pg-cursor@2.19.0: - resolution: {integrity: sha512-J5cF1MUz7LRJ9emOqF/06QjabMHMZy587rSPF0UuA8rCwKeeYl2co8Pp+6k5UU9YrAYHMzWkLxilfZB0hqsWWw==} + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-cursor@2.21.0: + resolution: {integrity: sha512-IYvk/j+Suhtbo/C3uOf4JLsLK/gWxOTUOmYbDsbKnLaVJDq+KwhwK6ngpRfiCk8eDMS3AmGQABZCv0cREEzHQw==} peerDependencies: pg: ^8 @@ -3414,9 +3599,17 @@ packages: peerDependencies: pg: '>=8.0' + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + pg-protocol@1.13.0: resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} @@ -3439,6 +3632,15 @@ packages: pg-native: optional: true + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} @@ -4372,23 +4574,11 @@ snapshots: '@cipherstash/protect-ffi-linux-x64-musl': 0.28.0 '@cipherstash/protect-ffi-win32-x64-msvc': 0.28.0 - '@clack/core@1.3.0': - dependencies: - fast-wrap-ansi: 0.2.0 - sisteransi: 1.0.5 - '@clack/core@1.3.1': dependencies: fast-wrap-ansi: 0.2.0 sisteransi: 1.0.5 - '@clack/prompts@1.3.0': - dependencies: - '@clack/core': 1.3.0 - fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.0 - sisteransi: 1.0.5 - '@clack/prompts@1.4.0': dependencies: '@clack/core': 1.3.1 @@ -4434,12 +4624,6 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@dagrejs/dagre@3.0.0': - dependencies: - '@dagrejs/graphlib': 4.0.1 - - '@dagrejs/graphlib@4.0.1': {} - '@drizzle-team/brocli@0.10.2': {} '@emnapi/runtime@1.7.1': @@ -4888,41 +5072,57 @@ snapshots: '@posthog/types@1.373.5': {} - '@prisma-next/adapter-postgres@0.8.0(typanion@3.14.0)': - dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/contract-authoring': 0.8.0 - '@prisma-next/family-sql': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/ids': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-contract-psl': 0.8.0 - '@prisma-next/sql-contract-ts': 0.8.0 - '@prisma-next/sql-operations': 0.8.0 - '@prisma-next/sql-relational-core': 0.8.0 - '@prisma-next/sql-runtime': 0.8.0 - '@prisma-next/sql-schema-ir': 0.8.0 - '@prisma-next/target-postgres': 0.8.0(typanion@3.14.0) - '@prisma-next/utils': 0.8.0 + '@prisma-next/adapter-postgres@0.14.0(typanion@3.14.0)(typescript@5.9.3)': + dependencies: + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/contract-authoring': 0.14.0(typescript@5.9.3) + '@prisma-next/errors': 0.14.0(typescript@5.9.3) + '@prisma-next/family-sql': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/ids': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract-psl': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract-ts': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-relational-core': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-runtime': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-schema-ir': 0.14.0(typescript@5.9.3) + '@prisma-next/target-postgres': 0.14.0(typanion@3.14.0)(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - magicast - typanion - '@prisma-next/cli@0.8.0(typanion@3.14.0)': - dependencies: - '@clack/prompts': 1.3.0 - '@dagrejs/dagre': 3.0.0 - '@prisma-next/config': 0.8.0 - '@prisma-next/contract': 0.8.0 - '@prisma-next/emitter': 0.8.0 - '@prisma-next/errors': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/migration-tools': 0.8.0 - '@prisma-next/psl-printer': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/cli-telemetry@0.14.0(typescript@5.9.3)': + dependencies: + '@prisma-next/config': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) + arktype: 2.2.0 + c12: 3.3.4 + pathe: 2.0.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + '@prisma-next/cli@0.14.0(typanion@3.14.0)(typescript@5.9.3)': + dependencies: + '@clack/prompts': 1.4.0 + '@prisma-next/cli-telemetry': 0.14.0(typescript@5.9.3) + '@prisma-next/config': 0.14.0(typescript@5.9.3) + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/emitter': 0.14.0(typescript@5.9.3) + '@prisma-next/errors': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/migration-tools': 0.14.0(typescript@5.9.3) + '@prisma-next/psl-printer': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 c12: 3.3.4 + ci-info: 4.4.0 clipanion: 4.0.0-rc.4(typanion@3.14.0) closest-match: 1.3.3 colorette: 2.0.20 @@ -4934,248 +5134,319 @@ snapshots: string-width: 8.2.1 strip-ansi: 7.2.0 wrap-ansi: 10.0.0 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - magicast - typanion - '@prisma-next/config@0.8.0': + '@prisma-next/config@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/contract-authoring@0.8.0': {} + '@prisma-next/contract-authoring@0.14.0(typescript@5.9.3)': + dependencies: + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/contract@0.8.0': + '@prisma-next/contract@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/utils': 0.8.0 + '@prisma-next/utils': 0.14.0(typescript@5.9.3) '@standard-schema/spec': 1.1.0 arktype: 2.2.0 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/driver-postgres@0.8.0': + '@prisma-next/driver-postgres@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/errors': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-errors': 0.8.0 - '@prisma-next/sql-operations': 0.8.0 - '@prisma-next/sql-relational-core': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/errors': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-errors': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-relational-core': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 - pg: 8.20.0 - pg-cursor: 2.19.0(pg@8.20.0) + pg: 8.21.0 + pg-cursor: 2.21.0(pg@8.21.0) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - pg-native - '@prisma-next/emitter@0.8.0': + '@prisma-next/emitter@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/operations': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/operations': 0.14.0(typescript@5.9.3) + '@prisma-next/ts-render': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 prettier: 3.8.3 + optionalDependencies: + typescript: 5.9.3 + + '@prisma-next/errors@0.14.0(typescript@5.9.3)': + dependencies: + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + '@prisma-next/extension-author-tools@0.14.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/errors@0.8.0': - dependencies: - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/utils': 0.8.0 - - '@prisma-next/family-sql@0.8.0': - dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/emitter': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/migration-tools': 0.8.0 - '@prisma-next/operations': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-contract-emitter': 0.8.0 - '@prisma-next/sql-contract-ts': 0.8.0 - '@prisma-next/sql-operations': 0.8.0 - '@prisma-next/sql-relational-core': 0.8.0 - '@prisma-next/sql-runtime': 0.8.0 - '@prisma-next/sql-schema-ir': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/family-sql@0.14.0(typescript@5.9.3)': + dependencies: + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/emitter': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/migration-tools': 0.14.0(typescript@5.9.3) + '@prisma-next/operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract-emitter': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract-ts': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-relational-core': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-runtime': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-schema-ir': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/framework-components@0.8.0': + '@prisma-next/framework-components@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/operations': 0.8.0 - '@prisma-next/ts-render': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/operations': 0.14.0(typescript@5.9.3) + '@prisma-next/ts-render': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) '@standard-schema/spec': 1.1.0 + arktype: 2.2.0 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/ids@0.8.0': + '@prisma-next/ids@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) uniku: 0.0.12 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/migration-tools@0.8.0': + '@prisma-next/migration-tools@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 pathe: 2.0.3 prettier: 3.8.3 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/operations@0.8.0': {} - - '@prisma-next/postgres@0.8.0(typanion@3.14.0)': - dependencies: - '@prisma-next/adapter-postgres': 0.8.0(typanion@3.14.0) - '@prisma-next/cli': 0.8.0(typanion@3.14.0) - '@prisma-next/config': 0.8.0 - '@prisma-next/contract': 0.8.0 - '@prisma-next/driver-postgres': 0.8.0 - '@prisma-next/family-sql': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/sql-builder': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-contract-psl': 0.8.0 - '@prisma-next/sql-contract-ts': 0.8.0 - '@prisma-next/sql-orm-client': 0.8.0 - '@prisma-next/sql-relational-core': 0.8.0 - '@prisma-next/sql-runtime': 0.8.0 - '@prisma-next/target-postgres': 0.8.0(typanion@3.14.0) - '@prisma-next/utils': 0.8.0 + '@prisma-next/operations@0.14.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@prisma-next/postgres@0.14.0(typanion@3.14.0)(typescript@5.9.3)': + dependencies: + '@prisma-next/adapter-postgres': 0.14.0(typanion@3.14.0)(typescript@5.9.3) + '@prisma-next/cli': 0.14.0(typanion@3.14.0)(typescript@5.9.3) + '@prisma-next/config': 0.14.0(typescript@5.9.3) + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/driver-postgres': 0.14.0(typescript@5.9.3) + '@prisma-next/family-sql': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-builder': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract-psl': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract-ts': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-orm-client': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-relational-core': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-runtime': 0.14.0(typescript@5.9.3) + '@prisma-next/target-postgres': 0.14.0(typanion@3.14.0)(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) pathe: 2.0.3 - pg: 8.20.0 + pg: 8.21.0 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - magicast - pg-native - typanion - '@prisma-next/psl-parser@0.8.0': + '@prisma-next/psl-parser@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/psl-printer@0.8.0': + '@prisma-next/psl-printer@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/framework-components': 0.8.0 + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-builder@0.8.0': + '@prisma-next/sql-builder@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-operations': 0.8.0 - '@prisma-next/sql-relational-core': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-relational-core': 0.14.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-contract-emitter@0.8.0': + '@prisma-next/sql-contract-emitter@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/emitter': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/emitter': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-contract-psl@0.8.0': + '@prisma-next/sql-contract-psl@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/config': 0.8.0 - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/psl-parser': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-contract-ts': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/config': 0.14.0(typescript@5.9.3) + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/psl-parser': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract-ts': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) pathe: 2.0.3 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-contract-ts@0.8.0': + '@prisma-next/sql-contract-ts@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/config': 0.8.0 - '@prisma-next/contract': 0.8.0 - '@prisma-next/contract-authoring': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/config': 0.14.0(typescript@5.9.3) + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/contract-authoring': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 pathe: 2.0.3 ts-toolbelt: 9.6.0 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-contract@0.8.0': + '@prisma-next/sql-contract@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-errors@0.8.0': {} + '@prisma-next/sql-errors@0.14.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-operations@0.8.0': + '@prisma-next/sql-operations@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/operations': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 + '@prisma-next/operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) arktype: 2.2.0 + optionalDependencies: + typescript: 5.9.3 + + '@prisma-next/sql-orm-client@0.14.0(typescript@5.9.3)': + dependencies: + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-errors': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-relational-core': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-runtime': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-orm-client@0.8.0': - dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/operations': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-operations': 0.8.0 - '@prisma-next/sql-relational-core': 0.8.0 - '@prisma-next/sql-runtime': 0.8.0 - '@prisma-next/utils': 0.8.0 - - '@prisma-next/sql-relational-core@0.8.0': - dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/operations': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-operations': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/sql-relational-core@0.14.0(typescript@5.9.3)': + dependencies: + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-operations': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) '@standard-schema/spec': 1.1.0 arktype: 2.2.0 ts-toolbelt: 9.6.0 + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-runtime@0.8.0': + '@prisma-next/sql-runtime@0.14.0(typescript@5.9.3)': dependencies: - '@prisma-next/contract': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/ids': 0.8.0 - '@prisma-next/operations': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-operations': 0.8.0 - '@prisma-next/sql-relational-core': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/ids': 0.14.0(typescript@5.9.3) + '@prisma-next/operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-relational-core': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) arktype: 2.2.0 + optionalDependencies: + typescript: 5.9.3 + + '@prisma-next/sql-schema-ir@0.14.0(typescript@5.9.3)': + dependencies: + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/sql-schema-ir@0.8.0': - dependencies: - '@prisma-next/contract': 0.8.0 - - '@prisma-next/target-postgres@0.8.0(typanion@3.14.0)': - dependencies: - '@prisma-next/cli': 0.8.0(typanion@3.14.0) - '@prisma-next/contract': 0.8.0 - '@prisma-next/errors': 0.8.0 - '@prisma-next/family-sql': 0.8.0 - '@prisma-next/framework-components': 0.8.0 - '@prisma-next/migration-tools': 0.8.0 - '@prisma-next/sql-contract': 0.8.0 - '@prisma-next/sql-errors': 0.8.0 - '@prisma-next/sql-operations': 0.8.0 - '@prisma-next/sql-relational-core': 0.8.0 - '@prisma-next/sql-schema-ir': 0.8.0 - '@prisma-next/ts-render': 0.8.0 - '@prisma-next/utils': 0.8.0 + '@prisma-next/target-postgres@0.14.0(typanion@3.14.0)(typescript@5.9.3)': + dependencies: + '@prisma-next/cli': 0.14.0(typanion@3.14.0)(typescript@5.9.3) + '@prisma-next/contract': 0.14.0(typescript@5.9.3) + '@prisma-next/errors': 0.14.0(typescript@5.9.3) + '@prisma-next/family-sql': 0.14.0(typescript@5.9.3) + '@prisma-next/framework-components': 0.14.0(typescript@5.9.3) + '@prisma-next/migration-tools': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-contract': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-errors': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-operations': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-relational-core': 0.14.0(typescript@5.9.3) + '@prisma-next/sql-schema-ir': 0.14.0(typescript@5.9.3) + '@prisma-next/ts-render': 0.14.0(typescript@5.9.3) + '@prisma-next/utils': 0.14.0(typescript@5.9.3) '@standard-schema/spec': 1.1.0 arktype: 2.2.0 pathe: 2.0.3 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - magicast - typanion - '@prisma-next/ts-render@0.8.0': {} + '@prisma-next/ts-render@0.14.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@prisma-next/utils@0.8.0': {} + '@prisma-next/utils@0.14.0(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -5625,6 +5896,8 @@ snapshots: dependencies: readdirp: 5.0.0 + ci-info@4.4.0: {} + client-only@0.0.1: {} clipanion@4.0.0-rc.4(typanion@3.14.0): @@ -5723,6 +5996,14 @@ snapshots: pg: 8.20.0 postgres: 3.4.9 + drizzle-orm@0.45.2(@types/pg@8.20.0)(gel@2.2.0)(mysql2@3.16.0)(pg@8.21.0)(postgres@3.4.9): + optionalDependencies: + '@types/pg': 8.20.0 + gel: 2.2.0 + mysql2: 3.16.0 + pg: 8.21.0 + postgres: 3.4.9 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6433,11 +6714,16 @@ snapshots: pg-cloudflare@1.3.0: optional: true + pg-cloudflare@1.4.0: + optional: true + pg-connection-string@2.12.0: {} - pg-cursor@2.19.0(pg@8.20.0): + pg-connection-string@2.14.0: {} + + pg-cursor@2.21.0(pg@8.21.0): dependencies: - pg: 8.20.0 + pg: 8.21.0 pg-int8@1.0.1: {} @@ -6449,8 +6735,14 @@ snapshots: dependencies: pg: 8.20.0 + pg-pool@3.14.0(pg@8.21.0): + dependencies: + pg: 8.21.0 + pg-protocol@1.13.0: {} + pg-protocol@1.15.0: {} + pg-types@2.2.0: dependencies: pg-int8: 1.0.1 @@ -6479,6 +6771,16 @@ snapshots: optionalDependencies: pg-cloudflare: 1.3.0 + pg@8.21.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + pgpass@1.0.5: dependencies: split2: 4.2.0