diff --git a/.changeset/prisma-next-v3-only-install.md b/.changeset/prisma-next-v3-only-install.md new file mode 100644 index 000000000..9c9eb7c8e --- /dev/null +++ b/.changeset/prisma-next-v3-only-install.md @@ -0,0 +1,50 @@ +--- +'@cipherstash/prisma-next': minor +--- + +Make `@cipherstash/prisma-next` **EQL v3 only**. The EQL v2 surface is removed +entirely — install path, authoring constructors, runtime codecs, and the v2 +subpath exports. + +**Why:** the v2 and v3 baselines were chained — the v3 migration edge started +from the v2 baseline's `to` state and the head ref required both invariants — so +the only path to head ran the v2 install first. The v2 bundle's install fails on +managed Postgres (e.g. Supabase) where the connecting role is **not a +superuser**, which made the adapter unusable there even for v3-only apps. +Installing only EQL v3 (which applies fine as a non-superuser) fixes this. + +**Breaking — install path:** the EQL v2 baseline migration +(`20260601T0000_install_eql_bundle`) is removed, and the contract now models no +storage (the retired `eql_v2_configuration` table is gone). The v3 baseline +(`20260601T0100_install_eql_v3_bundle`) is re-rooted as the sole invariant-only +**genesis** edge (`from: null`); the head ref requires only +`cipherstash:install-eql-v3-bundle-v1`. `prisma-next migration apply` now +installs EQL v3 exclusively and works on Supabase as a non-superuser. + +**Breaking — API:** the EQL v2 authoring/runtime surface is removed: + +- `cipherstashFromStackV2`, `deriveStackSchemas`, and `createCipherstashSdk` + (from `./stack`) — use `cipherstashFromStack` (v3). +- The `encrypted*V2` TS column factories and the `cipherstash.Encrypted*V2` PSL + constructors (from `./column-types`) — use the v3 domain factories/constructors + (`text`/`textSearch`/`bigIntOrd`/… and `cipherstash.TextSearch()` etc.). +- The v2 runtime codecs, `createCipherstashRuntimeDescriptor`, the `cipherstash*` + query operators/helpers, and the `EncryptedDouble` envelope (from `./runtime`) + — use the v3 runtime (`createCipherstashV3RuntimeDescriptor`, + `bulkEncryptMiddlewareV3`, the `eql*` operators, `EncryptedNumber`). The + version-neutral envelopes (`EncryptedString`/`BigInt`/`Boolean`/`Date`/`Json`) + and `decryptAll` are unchanged. +- The `./middleware` and `./migration` subpath exports are removed (the v2 + bulk-encrypt middleware and call-classes). Use `bulkEncryptMiddlewareV3` from + `./runtime` / `./v3`. + +Apps still on the v2 surface must move to the v3 constructors and regenerate +their contract (`prisma-next contract emit`); there is no supported EQL v2 path +in this package anymore. + +**Also:** the "bulk-encrypt middleware not wired" diagnostic is now raised on the +v3 write path. Encoding an unencrypted value with an SDK that has no +`bulkEncryptMiddlewareV3(sdk)` registered against it fails fast with +`RUNTIME.ENCODE_FAILED` and a copy-pasteable wiring snippet, instead of surfacing +as an opaque pg-level serialise error. (The guard existed on the v2 codec; the v3 +codec had never wired it up.) diff --git a/.changeset/stash-prisma-next-skill-v3-only.md b/.changeset/stash-prisma-next-skill-v3-only.md new file mode 100644 index 000000000..4e2bd8f71 --- /dev/null +++ b/.changeset/stash-prisma-next-skill-v3-only.md @@ -0,0 +1,8 @@ +--- +'stash': patch +--- + +Update the bundled `stash-prisma-next` skill for the EQL v3-only +`@cipherstash/prisma-next`: drop the stale references to the removed EQL v2 +surface (`cipherstashFromStackV2`, the `cipherstash*` operators, the "legacy v2" +subpath note) so the guidance copied into customer repos matches the package. diff --git a/AGENTS.md b/AGENTS.md index 6ce4c707d..62b8299c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ If these variables are missing, tests that require live encryption will fail or - `packages/cli`: The `stash` CLI — auth, init, encryption schema, and database setup (`stash eql install`). Has its own `AGENTS.md`. - `packages/wizard`: AI-powered encryption setup (`@cipherstash/wizard`) - `packages/migrate`: Plaintext-to-encrypted column migration (`@cipherstash/migrate`) — resumable backfill, per-column state -- `packages/prisma-next`: Prisma Next integration (`@cipherstash/prisma-next`) — searchable field-level encryption for Postgres. EQL v2 (`*V2` constructors, `cipherstashFromStack`) and EQL v3 (per-domain constructors, `./v3` entry with `cipherstashFromStackV3`) +- `packages/prisma-next`: Prisma Next integration (`@cipherstash/prisma-next`) — searchable field-level encryption for Postgres. **EQL v3 only**: per-domain constructors (`cipherstash.TextSearch()` / `text()` / `bigIntOrd()` / …) and `cipherstashFromStack` (the `./v3` and `./stack` entries). The EQL v2 surface was removed — the adapter's baseline migration installs the EQL v3 bundle only (works on Supabase as a non-superuser) - `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — EQL v2 (`.`) and EQL v3 (`./v3`). Split out of `@cipherstash/stack`. - `packages/stack-supabase`: Supabase integration (`@cipherstash/stack-supabase`), depends on `@cipherstash/stack` — `encryptedSupabase` (v2) and `encryptedSupabaseV3` (v3). Split out of `@cipherstash/stack`. - `packages/schema`: Schema builder utilities and types (`encryptedTable`, `encryptedColumn`, `encryptedField`) diff --git a/examples/prisma/src/db.ts b/examples/prisma/src/db.ts index e34ba2010..af73e7042 100644 --- a/examples/prisma/src/db.ts +++ b/examples/prisma/src/db.ts @@ -9,10 +9,6 @@ * returns ready-to-spread arrays for `extensions` and `middleware`. * Override `schemasV3` only if you have additional tables the contract * does not model. - * - * A v3 client is v3-only: a contract carrying v2 cipherstash codec ids - * is rejected at setup (use `cipherstashFromStackV2` from - * `@cipherstash/prisma-next/stack` for a v2 contract). */ import 'dotenv/config' diff --git a/packages/prisma-next/DEVELOPING.md b/packages/prisma-next/DEVELOPING.md index efaba11af..8c624efab 100644 --- a/packages/prisma-next/DEVELOPING.md +++ b/packages/prisma-next/DEVELOPING.md @@ -2,333 +2,497 @@ Contributor-facing notes for the cipherstash extension. The user-facing surface lives in [`README.md`](./README.md); this file collects the -internal layout, the substrate architecture, the per-codec wiring -template, and the design choices a contributor needs to know when +internal layout, the substrate architecture, how the v3 domain surface +is derived, and the design choices a contributor needs to know when extending the package. +**This package is EQL v3 only.** Every encrypted column is a concrete +Postgres domain (`public.eql_v3_text_search`, `public.eql_v3_bigint_ord`, +…); the domain you choose *is* the capability set. There is no EQL v2 +surface — no `*V2` constructors, no `eql_v2_encrypted` composite wire, no +per-column search-mode flags. + ## Source layout ```text -packages/3-extensions/cipherstash/ -├── contract.{json,d.ts} emitted contract-space artefacts -├── migrations/cipherstash/ emitted on-disk migrations -├── refs/head.json hand-pinned contract-space head ref -└── src/ - ├── contract/ - │ ├── authoring.ts cipherstash.Encrypted() PSL constructors (six) - │ └── contract.d.ts contract-space declaration - ├── execution/ - │ ├── envelope-base.ts EncryptedEnvelopeBase shared substrate - │ ├── envelope-string.ts EncryptedString (extends base) - │ ├── envelope-double.ts EncryptedDouble - │ ├── envelope-bigint.ts EncryptedBigInt + parseDecryptedValue override - │ ├── envelope-date.ts EncryptedDate + parseDecryptedValue override - │ ├── envelope-boolean.ts EncryptedBoolean - │ ├── envelope-json.ts EncryptedJson - │ ├── cell-codec-factory.ts makeCipherstashCellCodec({...}) factory - │ ├── codec-runtime.ts createCipherstashStringCodec(sdk) string-only constructor - │ ├── parameterized.ts RuntimeParameterizedCodecDescriptor for all six - │ ├── operators.ts 13 predicate operators + asEncryptedParam dispatch - │ ├── helpers.ts 4 free-standing helpers (Asc / Desc / JsonbPath…) - │ ├── decrypt-all.ts opt-in read-side bulk-decrypt walker - │ ├── routing.ts physical-column-name routing-key helpers - │ ├── sdk.ts CipherstashSdk interface (framework-native) - │ └── abort.ts RUNTIME.ABORTED envelope wrappers - ├── extension-metadata/ - │ ├── constants.ts codec ids, EQL native type, CIPHERSTASH_CODEC_IDS tuple, - │ │ isCipherstashCodecId guard, namespaced-trait casts - │ ├── codec-metadata.ts SDK-free codec instances for pack-meta authoring - │ └── descriptor-meta.ts cipherstashPackMeta + authoring + storage + codec instances - ├── middleware/ - │ └── bulk-encrypt.ts bulkEncryptMiddleware(sdk) + stampRoutingKeysFromAst - ├── migration/ - │ ├── codec-hooks-factory.ts makeCipherstashCodecHooks({...}) factory (per codec) - │ ├── cipherstash-codec.ts cipherstashStringCodecHooks string-only hook bundle - │ ├── call-classes.ts CipherstashAddSearchConfigCall / RemoveSearchConfigCall - │ ├── eql-bundle.ts EQL install SQL (vendored byte-for-byte) - │ └── eql-install.generated.ts generated EQL install op definitions - ├── types/ - │ ├── codec-types.ts CipherstashCodecTypes interface (decode return types) - │ └── operation-types.ts QueryOperationTypes augmentation (column-method surface) - └── exports/ - ├── control.ts SqlControlExtensionDescriptor (control-plane entry) - ├── runtime.ts Envelope classes + SDK + codec runtime + decryptAll + - │ 4 free-standing helpers (runtime entry) - ├── middleware.ts bulkEncryptMiddleware (runtime middleware entry) - ├── migration.ts call-classes re-export - ├── pack.ts cipherstashPackMeta default export (TS contract authoring) - ├── column-types.ts 6 TS contract factories (encryptedString / Double / …) - ├── codec-types.ts codec-types augmentation re-export - ├── operation-types.ts operation-types augmentation re-export - └── contract-space-typing.ts helper types for contract-space consumers +packages/prisma-next/ +├── src/ +│ ├── contract.prisma PSL contract source (models NO storage — v3 +│ │ declares none; the bundle creates the domains) +│ ├── contract.{json,d.ts} emitted contract-space artefacts +│ ├── contract-authoring.ts v3 PSL constructors (`cipherstash.TextSearch()` …), +│ │ derived 1:1 from the catalog +│ ├── execution/ version-neutral runtime substrate +│ │ ├── envelope-base.ts EncryptedEnvelopeBase shared superclass +│ │ ├── envelope-{string,bigint, concrete value envelopes (EncryptedNumber lives +│ │ │ boolean,date,json}.ts under src/v3/) +│ │ ├── sdk.ts CipherstashSdk interface (framework-native) +│ │ ├── routing.ts routing-key derivation + stampRoutingKeysFromAst +│ │ ├── middleware-registry.ts per-SDK "middleware wired?" WeakSet +│ │ ├── decrypt-all.ts opt-in read-side bulk-decrypt walker +│ │ └── abort.ts RUNTIME.ABORTED envelope wrappers +│ ├── v3/ the EQL v3 plane +│ │ ├── catalog.ts per-domain metadata, DERIVED from the stack's +│ │ │ DOMAIN_REGISTRY (never hand-maintained) +│ │ ├── envelope-number.ts EncryptedNumber (the numeric-family envelope) +│ │ ├── codec-runtime-v3.ts createV3CodecDescriptors + CipherstashV3CellCodec +│ │ ├── wire-v3.ts plain-JSONB wire (v3ToDriver / v3FromDriver) +│ │ ├── operators-v3.ts the eql* query operators + eqlAsc/eqlDesc +│ │ ├── query-term.ts the query-term seam (mark / collect / route) +│ │ ├── bulk-encrypt-v3.ts bulkEncryptMiddlewareV3(sdk) +│ │ ├── runtime-v3.ts createCipherstashV3RuntimeDescriptor({ sdk }) +│ │ ├── derive-schemas-v3.ts contract.json → EncryptedTable[] for EncryptionV3 +│ │ ├── sdk-adapter-v3.ts EncryptionV3 client → CipherstashSdk adapter +│ │ ├── from-stack-v3-validate.ts assertV3SchemasAgree (override-vs-contract check) +│ │ └── barrel.ts user-facing envelope re-exports +│ ├── stack/ +│ │ └── from-stack-v3.ts cipherstashFromStack({ contractJson }) +│ ├── migration/ +│ │ ├── cipherstash-codec-v3.ts v3 control-plane hooks (identity expandNativeType) +│ │ └── eql-bundle-v3.ts runtime EQL v3 install-SQL injection +│ ├── extension-metadata/ +│ │ ├── constants.ts shared ids: space id, version, cipherstash:* traits +│ │ ├── constants-v3.ts v3 codec-id tuple, invariants, baseline name, guards +│ │ ├── codec-metadata.ts SDK-free v3 codec metadata for pack-meta +│ │ └── descriptor-meta.ts cipherstashPackMeta (authoring + codecInstances + storage) +│ ├── types/ +│ │ ├── codec-types.ts CodecTypes (40 v3 codec-id entries, decode types) +│ │ └── operation-types.ts QueryOperationTypes (the eql* method surface) +│ └── exports/ +│ ├── control.ts SqlControlExtensionDescriptor (control-plane entry) +│ ├── runtime.ts envelopes + SDK + v3 codec runtime + decryptAll + +│ │ bulkEncryptMiddlewareV3 (runtime entry) +│ ├── stack.ts cipherstashFromStack + the v3 stack primitives +│ ├── v3.ts the complete v3 surface in one import +│ ├── pack.ts cipherstashPackMeta default export (TS authoring) +│ ├── column-types.ts the v3 TS contract factories (text / textSearch / …) +│ ├── codec-types.ts codec-types augmentation re-export +│ ├── operation-types.ts operation-types augmentation re-export +│ └── contract-space-typing.ts helper types for contract-space consumers +└── migrations/ + ├── 20260601T0100_install_eql_v3_bundle/ the SOLE migration — invariant-only genesis + │ edge (from: null) installing the EQL v3 bundle + └── refs/head.json hand-pinned head ref (v3 invariant only) ``` -## Substrate architecture +## The v3 domain surface is DERIVED, not hand-wired + +The single most important thing to know: v3 does **not** have a +per-codec wiring template. The entire domain surface — codec ids, native +types, query capabilities, authoring constructors, runtime codecs, +pack-meta instances, storage rows — is derived from one source of truth, +the stack's `DOMAIN_REGISTRY`, through `src/v3/catalog.ts`. + +`catalog.ts` probes each registry factory and reads `getEqlType()` (the +`public.*` native type), `getQueryCapabilities()`, and `build()` +(`cast_as` + `indexes`) off the instance. The codec id is +`cipherstash/eql-v3/${bareDomain}@1` where `bareDomain` is the registry +key VERBATIM (only the `public.` qualifier is stripped) — a mechanical +bijection, never a prettified transform. It exports: + +- `V3_DOMAIN_META_BY_CODEC_ID` — the per-domain metadata map (all 40 + domains, including the authoring-unexposed `*_ord_ore` variants the + codec layer still decodes); +- `EXPOSED_DOMAIN_ENTRIES` — the catalog minus `*_ord_ore` (the domains + that get an authoring constructor); +- `V3_FACTORY_BY_NATIVE_TYPE` / `toV3CodecId` — the `(codec id) ⟺ + (public.* domain)` bijection used by schema derivation; +- `envelopeTypeNameForCastAs` — maps a domain's `cast_as` to the envelope + class name that decodes it (numeric family → `EncryptedNumber`, bigint + → `EncryptedBigInt`, text → `EncryptedString`, …). + +Because everything derives from this, **adding or changing a domain +almost never touches this package** — it flows from a `@cipherstash/stack` +registry change plus a rebuild. Drift is caught by the 1:1 runtime tests +(`test/v3/catalog.test.ts`, `test/column-types.test.ts`, +`test/authoring.test.ts`, `test/v3/constants-v3.test.ts`). + +### Authoring: the constructor IS the capability set + +Each exposed domain gets exactly one **argument-less** constructor: + +- PSL: `cipherstash.TextSearch()` (in `src/contract-authoring.ts`); +- TS: `textSearch()` (in `src/exports/column-types.ts`). + +The name is the mechanical PascalCase/camelCase transform of the bare +domain with its `eql_v3_` prefix stripped (`eql_v3_bigint_ord` → +`BigIntOrd` / `bigIntOrd`); the codec id keeps the registry key verbatim. +There are NO options — the constructor you pick *is* the capability set +(`textSearch` vs `text`, `bigIntOrd` vs `bigIntEq`, …). Both forms lower +to an identical `ColumnTypeDescriptor` with a static +`{ castAs, capabilities }` typeParams block, so PSL- and TS-authored +contracts emit byte-identical `contract.json` (pinned by the parity +tests). -The package centres on a shared substrate that lets every cipherstash codec be one factory call away from the same shape. Three substrate factories carry the load: +## Substrate architecture ### `EncryptedEnvelopeBase` — shared envelope superclass -`src/execution/envelope-base.ts` exports an abstract `EncryptedEnvelopeBase` class that holds the `#`-prefixed `EncryptedHandle` slot and ships the five redaction overrides (`toJSON`, `toString`, `valueOf`, `Symbol.toPrimitive`, `Symbol.for('nodejs.util.inspect.custom')`), `expose()`, `decrypt({ signal? })`, and the post-decrypt plaintext cache. - -Each concrete subclass: - -- Holds nothing of its own beyond a static `from(plaintext: T): Self` and `fromInternal(args): Self`. -- May override `parseDecryptedValue(plaintext: unknown): T` when the SDK round-trips through a JS type that differs from the envelope's `T`. `EncryptedBigInt` overrides this to coerce SDK `number | string` → `bigint`; `EncryptedDate` overrides it to coerce ISO strings → `Date`. - -The base class also stamps the redacted JSON placeholder per subclass (`{ "$encryptedString": "" }` vs `{ "$encryptedBigInt": "" }`) so accidental `JSON.stringify` paths reveal the *type* but not the *value*. - -### `makeCipherstashCellCodec({...})` — runtime cell-codec factory - -`src/execution/cell-codec-factory.ts` exports a single factory that builds a `cipherstash/@1` `CellCodec` given: - -- The `codecId` to register under. -- The envelope subclass's `fromInternal({ ciphertext, table, column, sdk })` constructor — picked up by reference, not by ID. -- The Postgres native type (`eql_v2_encrypted` for every cipherstash codec). -- The static `traits: []` declaration (the wrong-SQL-footgun protection — see [ADR 214](../../../docs/architecture%20docs/adrs/ADR%20214%20-%20Extension%20operator%20surface%20namespaced%20replacement%20operators.md)). - -Per-codec `encode(envelope, ctx)` and `decode(wire, ctx)` bodies are the same shape across all six codecs: encode reads the envelope handle's ciphertext (already populated by the bulk-encrypt middleware) and wraps it in the `eql_v2_encrypted` composite text format; decode constructs the right envelope subclass via the captured `fromInternal` constructor. - -### `makeCipherstashCodecHooks({ flagToIndex, castAs })` — codec lifecycle hook factory - -`src/migration/codec-hooks-factory.ts` exports the factory that builds a `CodecControlHooks` instance given: - -- A `flagToIndex` map from the codec's search-mode flags to EQL search-config index names (e.g. `{ equality: 'unique', orderAndRange: 'ore' }`). -- The EQL `cast_as` value (`'text'`, `'double'`, `'big_int'`, `'date'`, `'boolean'`, `'jsonb'`). - -The factory's returned hook reads `typeParams` off the column (the validated cipherstash search-mode flags) and emits one `cipherstashAddSearchConfig(table, column, index)` op per enabled flag at field-added events, and the corresponding `cipherstashRemoveSearchConfig(...)` at field-dropped events. Flag flips (`true → false` between contract versions) emit a removal at the field-altered event. The framework's destructive-op classification surfaces removals via the standard planner mechanisms — no cipherstash-specific warning policy. - -## Per-codec wiring template - -Adding a new cipherstash codec (e.g. a hypothetical `cipherstashInt` for non-bigint integer support) touches the following files **in this order**. Each step is one or two lines; the substrate factories carry the variable shape. - -1. **Constants** (`src/extension-metadata/constants.ts`). Add the codec id (`'cipherstash/int@1'`), append to the `CIPHERSTASH_CODEC_IDS` stable-order tuple, and the closed-union `CipherstashCodecId` widens automatically. The `isCipherstashCodecId` guard picks up the new entry through the constant tuple. - -2. **Envelope class** (`src/execution/envelope-.ts`). New file extending `EncryptedEnvelopeBase` where `T` is the new codec's JS plaintext type. Add a `parseDecryptedValue` override only if the SDK round-trip introduces a type mismatch (e.g. `EncryptedBigInt`'s `number | string → bigint` coercion). Re-export from `src/exports/runtime.ts`. - -3. **Cell-codec factory call** (`src/execution/parameterized.ts`). One factory invocation: `makeCipherstashCellCodec({ codecId, fromInternal: EncryptedInt.fromInternal, ... })`. The parameterized-descriptor registration in the same file picks it up. - -4. **Codec lifecycle hooks** (`src/migration/codec-hooks-factory.ts` consumer; new constant in the same file or co-located). One factory invocation: `cipherstashIntCodecHooks = makeCipherstashCodecHooks({ flagToIndex: { equality: 'unique', orderAndRange: 'ore' }, castAs: 'int' })`. Add it to the hook export. - -5. **PSL constructor** (`src/contract/authoring.ts`). Add a `cipherstash.EncryptedInt` constructor descriptor mirroring the others. The arktype params schema (validating the codec's search-mode flags) goes alongside. - -6. **TS factory** (`src/exports/column-types.ts`). Add `encryptedInt({...})` mirroring `encryptedBigInt`. Defaults map every search-mode flag to `true`. - -7. **Parameterized codec descriptor** (`src/execution/parameterized.ts`). Add the new codec to `createParameterizedCodecDescriptors(sdk)` so the per-tenant SDK binding reaches it. - -8. **Operator type-visibility** (`src/types/operation-types.ts`). Add the new codec id to whichever `QueryOperationTypes` entries the new codec should surface predicates from. Trait-keyed entries (the multi-codec predicates: `cipherstashEq`, `cipherstashGt`, etc.) pick it up automatically through the `cipherstash:`-namespaced trait dispatch. - -9. **Codec-types augmentation** (`src/types/codec-types.ts`). Add an entry mapping the new codec id to the envelope class's decode-side TypeScript type (used by the framework's decode-result typing). - -10. **Pack-meta authoring** (`src/extension-metadata/descriptor-meta.ts`). Append the new authoring entry + storage entry + codec instance to `cipherstashPackMeta`. - -11. **Parity fixture** (`test/integration/test/authoring/parity/cipherstash-encrypted-/`). New PSL + TS contract pair authoring the same column under the new codec; pinned by the shared parity harness. - -12. **Codec-specific tests** (`test/envelope-.test.ts`, `test/operator-lowering.test.ts` extension). Cover the envelope's redaction overrides + `parseDecryptedValue` if present, and the per-codec predicate lowerings. - -The order is mechanical; the substrate factories are the leverage that makes adding a new codec a ~20-line change across these files. - -## The operator surface — predicate vs helper - -The 17 cipherstash operators decompose along the framework's predicate / non-predicate split per [ADR 214 — Extension operator surface](../../../docs/architecture%20docs/adrs/ADR%20214%20-%20Extension%20operator%20surface%20namespaced%20replacement%20operators.md). +`src/execution/envelope-base.ts` exports an abstract +`EncryptedEnvelopeBase` that holds the `#`-prefixed +`EncryptedHandle` slot and ships the five redaction overrides +(`toJSON`, `toString`, `valueOf`, `Symbol.toPrimitive`, +`Symbol.for('nodejs.util.inspect.custom')`), `expose()`, +`decrypt({ signal? })`, and the post-decrypt plaintext cache. + +The concrete value classes are **version-neutral** — they are the +user-facing input/output types for encrypted columns regardless of the +domain: + +- `EncryptedString`, `EncryptedBigInt`, `EncryptedBoolean`, + `EncryptedDate`, `EncryptedJson` (in `src/execution/envelope-*.ts`); +- `EncryptedNumber` (in `src/v3/envelope-number.ts`) — the numeric + family: every `number`-castAs v3 domain (integer / smallint / numeric / + real / double) decodes to `EncryptedNumber`. + +Each concrete subclass holds nothing of its own beyond a static +`from(plaintext): Self` and `fromInternal(args): Self`, and may override +`parseDecryptedValue(plaintext: unknown): T` when the SDK round-trips +through a JS type that differs from the envelope's `T` (`EncryptedBigInt` +coerces `number | string → bigint`; `EncryptedDate` coerces ISO strings → +`Date`). The base stamps a per-subclass redacted JSON placeholder +(`{ "$encryptedString": "" }` vs `{ "$encryptedNumber": "" }`) +so accidental `JSON.stringify` reveals the *type* but not the *value*. + +### v3 codec runtime — `createV3CodecDescriptors(sdk)` + +`src/v3/codec-runtime-v3.ts` builds one +`RuntimeParameterizedCodecDescriptor` per catalog domain (derived from +`V3_DOMAIN_META_BY_CODEC_ID`, never hand-listed), with three properties +that define the v3 wire: + +- **Wire**: plain JSONB (`src/v3/wire-v3.ts` — `v3ToDriver` / + `v3FromDriver`). v3 columns are Postgres domains over `jsonb`; the + encoded param slot is the serialised EQL payload, not a composite-text + literal. +- **Native type**: each descriptor targets its concrete `public.eql_v3_*` + domain. +- **Traits**: derived per-domain from the catalog's query capabilities + via `v3TraitsForCapabilities` — intrinsic to the domain, not a + per-column option. + +The params shape matches the static `{ castAs, capabilities }` block v3 +authoring emits. `encode` reads the ciphertext off the handle; `decode` +constructs the per-`castAs` envelope via `envelopeTypeNameForCastAs`. + +### v3 control-plane hooks — `cipherstashV3CodecControlHooks` + +`src/migration/cipherstash-codec-v3.ts` registers an identity +`expandNativeType` hook (which strips the `public.` qualifier so DDL and +introspection agree on the bare domain name) and **no `onFieldEvent`**. +The Postgres planner requires an `expandNativeType` hook to *exist* for +any column carrying `typeParams` (and every v3 column carries the static +`{ castAs, capabilities }` block); the absence of `onFieldEvent` is what +guarantees v3 columns emit **no** `add_search_config` / +`remove_search_config` ops — v3 domains carry their own index metadata. + +## The operator surface — the `eql*` vocabulary + +The v3 query operators use an EQL-derived vocabulary and live in +`src/v3/operators-v3.ts`, registered through the framework's +`OperationRegistry` via `cipherstashV3QueryOperations()`. ### Predicate operators — column-method surface -Return `Expression<{codecId: 'pg/bool@1', nullable: ...}>`. Surface as column methods through the operation registry; the model accessor synthesises them onto columns whose codec carries the required `cipherstash:*` trait. - -- **Source**: `src/execution/operators.ts` (one factory per predicate, registered through the framework's `OperationRegistry` SPI). -- **Dispatch**: trait- or codec-id-keyed entries in `QueryOperationTypes` (`src/types/operation-types.ts`). Multi-codec predicates (`cipherstashEq`, `cipherstashGt`, `cipherstashLt`, etc.) key off `cipherstash:equality` / `cipherstash:order-and-range` so a new codec advertising those traits picks up the predicates automatically. -- **Encoded-arg path**: `asEncryptedParam(plaintext, columnRef)` dispatches on the column's codec id to construct the right envelope subclass; the dispatch table is typed `Readonly>` over the closed-union `CipherstashCodecId` so a new codec id without a matching dispatch entry is a TS error. The envelope's handle carries the column's `(table, column)` routing key from the `ParamRef.of({ refs: { table, column } })` call site so the bulk-encrypt middleware can group it correctly. +Return `Expression` and surface as column methods: +`eqlEq` / `eqlNeq` / `eqlIn` / `eqlNotIn` / `eqlGt` / `eqlGte` / `eqlLt` / +`eqlLte` / `eqlBetween` / `eqlNotBetween`, the fuzzy free-text +`eqlMatch` (`eql_v3.contains` — NOT SQL `ILIKE`; may false-positive, so +there is deliberately no negated form), and the JSONB `eqlJsonContains` +(`@>` containment on `eql_v3_json`). + +Type-level visibility is trait-dispatched: each operator's `self` +declares a `cipherstash:v3-*` marker (`src/types/operation-types.ts`), +and each v3 codec entry (`src/types/codec-types.ts`) carries the markers +its capability tier permits. Storage-only domains (e.g. `eql_v3_boolean`, +bare `eql_v3_text`) carry no markers, so they surface no operators — +matching the runtime gate. The markers are TYPE-LEVEL only (no runtime +counterpart). ### Free-standing helpers — non-predicate surface -Return non-boolean shapes: `OrderByItem` for sort, `Expression` for SELECT-expression accessors. - -- **Source**: `src/execution/helpers.ts`. Each helper is a pure function exported from `@cipherstash/prisma-next/runtime`. -- **Dispatch**: none. The helpers are typed at their function-declaration site; there is no registry participation. Calls like `cipherstashAsc(u.salary)` validate the column's codec id at runtime via `getCodecId(col, helperName)` and throw a descriptive `TypeError` on mismatch. -- **AST primitives**: sort helpers return `OrderByItem.asc/desc(col.buildAst())` directly (bare-column form; EQL's native operator overloads on `eql_v2_encrypted` handle the comparison at the Postgres level). JSON helpers construct an `Expression`-shaped `OperationExpr` via `buildOperation({ method, args, returns, lowering })` — the same framework primitive that powers the predicate registrations. -- **No `QueryOperationTypes` entry** — by design. The split is documented in `src/execution/helpers.ts`'s top-of-file docblock and the per-helper JSDoc. - -## Cipherstash-namespaced traits +`eqlAsc` / `eqlDesc` return `OrderByItem` for ORDER BY (via +`eql_v3.ord_term` / `eql_v3.ord_term_ore` by the column's ordering +flavour). They are pure functions with no registry participation. + +### The query-term seam (`src/v3/query-term.ts`) + +A three-module contract that keeps query-term encryption ciphertext-free +without importing the operator registry into the middleware/SDK: + +1. **`operators-v3` MARKS** — each predicate wraps its operand in a + per-`castAs` envelope, stamps the query flavour it needs + (`markV3QueryTerm`), and binds the envelope as a `pg/text@1` param (so + it carries no v3 codec id and stays out of the storage jurisdiction). +2. **`bulk-encrypt-v3` COLLECTS** — the middleware recognises marked + envelopes among the plan params and forwards the envelope itself + through `CipherstashSdk.bulkEncrypt` so the mark survives the SDK + boundary. +3. **`sdk-adapter-v3` ROUTES** — the SDK adapter reads the mark + (`v3QueryTermTypeOf`) and encrypts marked envelopes via the stack + client's `encryptQuery({ queryType })` (a ciphertext-free term), + never the storage `bulkEncrypt` path. The resulting term is written + back as JSONB and is NOT stamped onto the envelope's ciphertext slot + (a query term is not the cell's storage ciphertext). -The cipherstash codecs use `cipherstash:`-prefixed traits exclusively — `cipherstash:equality`, `cipherstash:order-and-range`, `cipherstash:free-text-search`, `cipherstash:searchable-json`. These sit *outside* the framework's closed `CodecTrait` union ([ADR 202](../../../docs/architecture%20docs/adrs/ADR%20202%20-%20Codec%20trait%20system.md)) deliberately: - -- The framework union is closed for the built-in trait set so trait-gated synthesis can reason exhaustively. A cipherstash codec advertising the framework's `equality` trait would mean the built-in `m.col.eq(...)` synthesises on cipherstash columns and lowers to SQL `=` against a randomised EQL ciphertext — the wrong-SQL footgun this design closes. -- Extension traits are open-ended — they're per-extension capability declarations the framework does not need to recognise. The cipherstash operator registry consumes them; the framework's `eq`-synthesis path does not. - -The cast from extension trait names to the framework-internal `CodecTrait` array shape is localised to one site at `src/extension-metadata/constants.ts` with a rationale comment citing the framework type, the model-accessor's `readonly string[]` widening at the dispatch site, and the wrong-SQL-`eq` footgun rationale. This is the only `as unknown as ...` cast in the package; all other type discipline is explicit. - -A pinned regression test at `test/equality-trait-removal.test.ts` asserts every cipherstash codec's `traits` array contains only `cipherstash:`-namespaced strings — catches a regression where someone re-introduces the framework `equality` trait by accident. - -## The `parseDecryptedValue` hook contract - -`EncryptedEnvelopeBase` exposes a protected `parseDecryptedValue(plaintext: unknown): T` hook that subclasses override when the SDK round-trips through a JS type that differs from the envelope's `T`. - -Used by: - -- The single-cell `decrypt({ signal? })` path on the envelope itself. -- The `decryptAll(rows)` walker — every `(sdk, table, column)` group's `bulkDecrypt` returns `ReadonlyArray`; the walker invokes `envelope.parseDecryptedValue(plaintexts[i])` per entry before caching the result on the envelope's handle. - -The hook defaults to an identity cast (`plaintext as T`) so the common-case envelopes (`EncryptedString` for `string`, `EncryptedDouble` for `number`, `EncryptedBoolean` for `boolean`) need no override. - -Subclasses that override: +## Write-path lifecycle — two-pass codec encode + middleware rewrite -- **`EncryptedBigInt`** — the `@cipherstash/stack` SDK's `JsPlaintext` union does not include `bigint`. The example app's SDK adapter converts `bigint → Number` with a `Number.MAX_SAFE_INTEGER` bounds check on the encrypt side; `EncryptedBigInt.parseDecryptedValue` coerces back via `BigInt(plaintext)` and accepts either `number` or `string` per the SDK's polymorphic return shape. -- **`EncryptedDate`** — accepts ISO-8601 strings from the SDK round-trip and returns a `Date` instance. -- **`EncryptedJson`** — defaults to identity (the SDK returns the parsed JSON value as-is). +The SQL family runtime calls `encode` (in `lower`/`encodeParams`) +**before** the `beforeExecute` middleware chain, so the codec cannot read +`handle.ciphertext` during `encode` on the write path — the envelope only +carries plaintext at that point. The package handles this as a deliberate +two-pass design: + +1. **First pass — `CipherstashV3CellCodec#encode`.** With + `handle.ciphertext === undefined` the codec returns the envelope + itself as the pre-encrypt sentinel (a bare string or `null`/`undefined` + passes straight through); the second-pass middleware fills in the + ciphertext. That sentinel is only legitimate if a second pass is + actually coming, so `encode` first consults the per-SDK + `middleware-registry` WeakSet that `bulkEncryptMiddlewareV3(sdk)` + marks its SDK in. An unregistered SDK means the two-pass flow can + never complete, so the codec raises `RUNTIME.ENCODE_FAILED` with a + copy-pasteable wiring snippet rather than letting the envelope reach + the pg driver as an opaque serialise error. The check is memoised per + codec (the registry is add-only), so it costs one WeakSet lookup, not + one per cell. +2. **Second pass — `bulkEncryptMiddlewareV3#beforeExecute`.** Walks the + lowered AST to stamp `(table, column)` routing keys + (`stampRoutingKeysFromAst`, version-neutral, shared from + `src/execution/routing.ts`), collects every v3-codec'd envelope and + every marked query term, groups by `(table, column)`, issues one + `sdk.bulkEncrypt(...)` per group, and calls `params.replaceValues(...)` + with the plain-JSONB wire text. Storage envelopes get their ciphertext + slot stamped (plaintext retained) for follow-on reuse; query terms do + not. + +The read path degenerates to a single pass — when `handle.ciphertext` is +already set (just decoded from a `SELECT`, or carried across queries), +`encode` returns the wire format directly. + +Tests pin both halves and the jurisdiction split: +`test/v3/bulk-encrypt-v3.test.ts` covers the wire-format `replaceValues` +payload, the one-call-per-`(table,column)` grouping, and that non-v3 +params are invisible to the middleware. + +## Wiring it together — the setup entry + +`cipherstashFromStack({ contractJson })` (`src/stack/from-stack-v3.ts`) is +the one-call factory: it derives the v3 encryption schemas from the +contract (`deriveStackSchemasV3` — one `public.eql_v3_*` domain per +column, selected by `nativeType` via `V3_FACTORY_BY_NATIVE_TYPE`), +constructs the `@cipherstash/stack` `EncryptionV3` client, adapts it to +`CipherstashSdk` (`src/v3/sdk-adapter-v3.ts`), and returns ready-to-spread +`extensions` / `middleware` for `postgres({...})`. It rejects a +contract carrying non-v3 cipherstash codec ids — the package is v3 only. + +The runtime extension descriptor +(`createCipherstashV3RuntimeDescriptor({ sdk })`, +`src/v3/runtime-v3.ts`) presents the **pack id** (`CIPHERSTASH_SPACE_ID` = +`'cipherstash'`) so `postgres({ extensions })` accepts contracts +emitted by the cipherstash control descriptor, with v3's own version +(`CIPHERSTASH_V3_EXTENSION_VERSION`). ## Runtime-side gotchas ### Physical column-name routing keys -The framework lowers the user's PSL field names through any `@map(...)` directives before middleware sees `ParamRef`s. The cipherstash bulk-encrypt middleware therefore receives **physical column names** (e.g. `accountid` rather than the PSL `accountId`), and the SDK's `bulkEncrypt(routingKey: { table, column })` round-trip is keyed on the physical name. The example app's SDK adapter at `examples/cipherstash-integration/src/sdk.ts` keeps its `tableRegistry` keyed by physical names to match. - -This is structural — the routing key has to agree between the cipherstash bulk-encrypt middleware (which sees the lowered SQL) and the SDK's per-column EQL index lookup (which reads the schema-time physical name). The decoded envelope's `(table, column)` slot likewise carries the physical name. +The framework lowers PSL field names through `@map(...)` before +middleware sees `ParamRef`s, so the bulk-encrypt middleware and the SDK +adapter both key on **physical** column names (e.g. `accountid`, not the +PSL `accountId`). The SDK adapter's `(table, column)` registry is keyed by +physical name (`getName()`) to match. ### `bigint` SDK boundary -`@cipherstash/stack`'s SDK and ZeroKMS only accept `JsPlaintext = string | number | boolean | object | array` for plaintexts (no `bigint`). For `EncryptedBigInt`: - -- **Encrypt side** (example app's SDK adapter, `examples/cipherstash-integration/src/sdk.ts`): converts `bigint → Number` with an eager `Number.MAX_SAFE_INTEGER` bounds check (throws on overflow). Values beyond the safe-integer range cannot be encrypted today. -- **Decrypt side** (envelope subclass, `src/execution/envelope-bigint.ts`): `parseDecryptedValue` accepts either `number` or `string` from the SDK and coerces back to `bigint` via the `BigInt(plaintext)` constructor. - -This is a known limitation — lifting requires upstream SDK / ZeroKMS work. - -### Polymorphic `CipherstashSdk.decrypt` return type - -`CipherstashSdk.bulkDecrypt(...)` returns `Promise>`. The polymorphic return type is deliberate — the SDK round-trips a heterogeneous mix of plaintext shapes (`string | number | boolean | object | array`) and the example app's adapter mirrors that. - -One small follow-up: the single-cell `CipherstashSdk.decrypt(...)` return type is currently typed `Promise` from the original string-only contract. A widening to `Promise` would match the bulk shape and remove a runtime narrowing cast in `EncryptedEnvelopeBase.decrypt`. Filed as a one-line interface follow-up; tracked under the cipherstash-integration umbrella. - -## Write-path lifecycle — two-pass codec encode + middleware rewrite - -The SQL family runtime calls `lower` (which calls `encodeParams`, which calls each codec's `encode`) **before** running the `beforeExecute` middleware chain. The cipherstash codec therefore *cannot* read `handle.ciphertext` during `encode` on the write path — the bulk-encrypt middleware hasn't run yet and the envelope only carries plaintext at this point. The package handles this as a deliberate two-pass design: - -1. **First pass — `CipherstashCellCodec#encode`** (in `lower`/`encodeParams`). Reached with `handle.ciphertext === undefined`. The codec returns the envelope itself as a sentinel; the SQL runtime treats it as the param's current value but doesn't unwrap it further. If the codec's SDK has no `bulkEncryptMiddleware(sdk)` registered for it (see `src/execution/middleware-registry.ts`), encode throws a `RUNTIME.ENCODE_FAILED` envelope here with a copy-pasteable wiring snippet — that's the loud-failure mode for a misconfigured runtime. - -2. **Second pass — `bulkEncryptMiddleware#beforeExecute`**. Iterates the param entries, collects every cipherstash envelope, groups by `(table, column)`, issues one `sdk.bulkEncrypt(...)` per group, stamps each returned ciphertext onto the envelope's handle (so synchronous `envelope.decrypt()` still works), and calls `params.replaceValues(...)` with the **wire-format string** (the `eql_v2_encrypted` composite-text literal produced by `encodeEqlV2EncryptedWire(ciphertext)`). The driver reads `currentParams()` after the middleware chain, so it sees a string. - -The read path degenerates to a single pass — when `handle.ciphertext` is already set (the envelope was just decoded from a `SELECT`, or it was carried across queries), `encode` returns the wire format directly without ever consulting the middleware. - -Why the codec returns the envelope rather than a placeholder string: the bulk-encrypt middleware needs the envelope instance to read the plaintext slot, look up routing metadata, and stamp the ciphertext back. Returning an opaque placeholder would force a second registry to map param positions to envelopes. - -Why the middleware writes the wire-format string into `params.replaceValues` rather than the envelope: the pg driver only serialises primitives / arrays / Buffers. Handing it the envelope (an `EncryptedEnvelopeBase` instance) would surface an opaque driver error. - -Tests pin both halves: `test/codec-runtime.test.ts` covers the encode-returns-envelope contract and the missing-middleware diagnostic; `test/bulk-encrypt-middleware.test.ts` covers the wire-format `replaceValues` payload. +`@cipherstash/stack`'s SDK and ZeroKMS accept +`JsPlaintext = string | number | boolean | object | array` (no `bigint`). +`EncryptedBigInt` handles this: the encrypt side coerces `bigint → Number` +with a `Number.MAX_SAFE_INTEGER` bounds check; the decrypt side's +`parseDecryptedValue` accepts `number | string` and coerces back via +`BigInt(...)`. Values beyond the safe-integer range cannot be encrypted +today — a known limitation requiring upstream SDK / ZeroKMS work. + +## The migration is an invariant-only genesis edge + +`migrations/20260601T0100_install_eql_v3_bundle/` is the **sole** +migration and the contract space's root: `describe()` returns +`{ from: null, to: }`. The v3 bundle creates the +`public.eql_v3_*` domains, the `eql_v3.*` operator functions, the +`eql_v3.query_*` operand domains, and the `eql_v3_internal` helper schema +— but **no contract-space storage** (no config table), so the contract +models no tables and the storage hash is the empty-storage hash. + +The op is `operationClass: 'data'` (not `additive`): a genesis edge that +moves no contract storage must carry a `data`-class op or the aggregate +integrity checker rejects it. The install SQL is **not baked** into +`ops.json` — the committed op carries `RUNTIME_EQL_SQL_SENTINEL`, and +`control.ts` injects `readInstallSql()` from the installed +`@cipherstash/eql` at descriptor-build time and recomputes the +content-addressed migration hash (`src/migration/eql-bundle-v3.ts`), so +bumping the pinned EQL version needs a dependency bump and rebuild, not a +migration re-emit. + +Authoring loop: `migration.ts` is hand-edited; re-emit `ops.json` / +`migration.json` after edits via +`pnpm exec tsx migrations/20260601T0100_install_eql_v3_bundle/migration.ts`. +The contract-space artefacts (`src/contract.{json,d.ts}`) are re-emitted +via `pnpm exec prisma-next contract emit`; `refs/head.json` is hand-pinned +to the head migration's `to` hash and its invariant. ## Other design choices worth knowing -### Handle storage — SecretBox-style `#` field with redacting overrides +### Handle storage — `#` field with redacting overrides -Every `EncryptedEnvelopeBase` instance holds the `EncryptedHandle` on a single `#`-prefixed class field. The plaintext and ciphertext are reachable through an explicit `envelope.expose()` accessor — that's the deliberate seam for callers who genuinely want the inner values. Every implicit serialization / coercion path (`toJSON`, `toString`, `valueOf`, `Symbol.toPrimitive`, `Symbol.for('nodejs.util.inspect.custom')`) returns a `[REDACTED]` placeholder (or, for `toJSON`, a typed `{ "$encrypted": "" }` placeholder) so accidental `console.log`, `JSON.stringify`, template-literal interpolation, error string construction, and `util.inspect` paths cannot leak plaintext. - -The encapsulation is deliberately not airtight (we do not use a closure-scoped `WeakMap` to hide the storage entirely) — the goal is to make plaintext access **explicit** at the call site, not **impossible**. Callers who need to round-trip envelopes across a network boundary can opt in via `envelope.expose()`. +Every envelope holds its `EncryptedHandle` on a single `#`-prefixed +field. Plaintext/ciphertext are reachable only through an explicit +`envelope.expose()`; every implicit serialization path is redacted. The +encapsulation is deliberately not airtight — the goal is to make plaintext +access **explicit** at the call site, not **impossible**. ### Plaintext is retained post-encrypt -The bulk-encrypt middleware populates the handle's ciphertext slot but does **not** zero the plaintext slot. Zeroing in JS is best-effort (strings are immutable) and the GC-driven lifecycle is sufficient. As a side effect, a write-side envelope's `decrypt()` returns the original plaintext synchronously without an SDK round-trip. - -### Codec is constructed per SDK binding - -The factory `createParameterizedCodecDescriptors(sdk)` is called per tenant — the codec's `decode` body captures the SDK so the read-side envelope can issue `decrypt({ signal? })` against it. This differs from pgvector (whose codec is fully stateless and *can* be a module singleton) but aligns with multi-tenant deployments constructing one extension descriptor per tenant. The seam is tracked at [TML-2388 — Codec-SDK binding refactor](https://linear.app/prisma-company/issue/TML-2388). +The bulk-encrypt middleware populates the handle's ciphertext slot but +does not zero the plaintext slot (zeroing JS strings is best-effort; +GC-driven lifecycle is sufficient). As a side effect a write-side +envelope's `decrypt()` returns the original plaintext synchronously +without an SDK round-trip. -### SDK-free metadata codec for pack-meta +### `CipherstashSdk` is framework-native -`src/extension-metadata/codec-metadata.ts` ships an SDK-free codec used in `cipherstashPackMeta.types.codecTypes.codecInstances`. Pack-meta consumers only read codec metadata (`typeId`, `targetTypes`, `traits`, `renderOutputType`) at contract emit time — they never call `encode`/`decode`. Keeping the metadata codec separate from the SDK-bound runtime codec preserves the control vs runtime split: control-plane consumers (`exports/control.ts`, `exports/pack.ts`) pull this file but never the envelope subclasses, the SDK interface, or the codec runtime. - -### `CipherstashSdk` is framework-native, not the upstream SDK shape - -The interface declares three async methods (`decrypt`, `bulkEncrypt`, `bulkDecrypt`), each accepting an optional `AbortSignal`. The values are typed polymorphically (`unknown` for the bulk paths). This is deliberately smaller than CipherStash's upstream `EncryptionClient` (rich `EncryptOperation` / `LockContext` / lazy-init machinery) so real-world usage wraps the upstream client behind a thin adapter satisfying `CipherstashSdk`. Keeps the framework-side surface free of upstream-specific types. +The interface declares three async methods (`decrypt`, `bulkEncrypt`, +`bulkDecrypt`), each accepting an optional `AbortSignal`, with +polymorphic (`unknown`) value types. It is deliberately smaller than the +upstream `EncryptionV3` client, so real usage wraps that client behind a +thin adapter (`src/v3/sdk-adapter-v3.ts`) and the framework-side surface +stays free of upstream-specific types. ### `decryptAll(rows, opts?)` — opt-in read-side amortisation -The cell codec's `decode` returns envelope subclasses that defer their SDK round-trip until `envelope.decrypt(...)` is awaited; this keeps SELECT plans cheap when consumers only need a subset of encrypted columns or when consumers want to forward envelopes to a downstream service without ever reading the plaintext. - -`decryptAll(rows)` is the read-side amortisation for the case where the consumer DOES want plaintexts: it walks the result-set graph (arrays, plain objects, nested envelopes; cycle-safe; skips already-decrypted envelopes; passes over exotic containers like `Date` / `Map` / `Set` / `Uint8Array`), partitions the discovered envelopes by `(sdk identity, table, column)`, and issues one `bulkDecrypt` SDK call per partition. The resolved plaintexts pass through each envelope's `parseDecryptedValue(...)` hook and cache back onto each envelope's handle so subsequent `envelope.decrypt()` calls return synchronously. Already-decrypted envelopes (write-side envelopes from `Encrypted.from(plaintext)`, or read-side envelopes that already cached a plaintext) are not re-decrypted — a re-run of `decryptAll` over a previously-decrypted result set is a no-op. - -The walker is intentionally narrow: traversing arbitrary graphs (JS-side `Map` / `Set` / `Date` containers) is out of scope and loud-skipped — embedding an envelope inside a `Map` value will not be discovered by the walker. Consumers needing such shapes should call `envelope.decrypt(...)` directly. - -### Control vs runtime tree-shaking architecture - -The package publishes three runtime-relevant subpath entries — `./control` (contract-space authoring + the codec lifecycle hooks), `./runtime` (envelope subclasses + SDK + codec runtime + `decryptAll` + free-standing helpers), and `./middleware` (bulk-encrypt middleware) — and each composes tree-shakably so a consumer pulling `./runtime` does not drag in the EQL bundle SQL or the codec lifecycle hooks (which would defeat the runtime-bundle size budget and leak control-plane behaviour into runtime call paths) and a consumer pulling `./control` does not drag in the runtime envelopes, the SDK interface, the codec runtime, or the bulk-encrypt middleware. - -The split lives in the source layout: `src/exports/control.ts` only imports from `src/contract/`, `src/migration/`, `src/extension-metadata/`, and never from `src/execution/{envelope*, codec-runtime, parameterized, decrypt-all, helpers, operators}` nor from `src/middleware/`. `src/exports/runtime.ts` / `src/exports/middleware.ts` only import from the runtime-side source modules (and the shared `extension-metadata/constants.ts`). The `test/bundling-isolation.test.ts` guard pins this byte-level — asserting the entry `.mjs` files don't carry forbidden symbols and that the transitively-reached chunk-file sets are disjoint modulo the shared `constants-*.mjs` chunk. - -The shared `constants-*.mjs` chunk is structurally permitted to live in both planes — it carries pure literal constants (codec ids, native types, invariant ids, the `CIPHERSTASH_CODEC_IDS` tuple, the `isCipherstashCodecId` guard) and no executable behaviour. - -The cross-package convention (source-level discipline + bundling-isolation test, with rationale and assertion strategies) is documented in the extension-packs reference doc at [Extension-Packs-Naming-and-Layout § Tree-shakability between control and runtime planes](../../../docs/reference/Extension-Packs-Naming-and-Layout.md); this package is the worked example for that section. +`decode` returns envelopes that defer their SDK round-trip until +`.decrypt()` is awaited, keeping SELECT plans cheap. `decryptAll(rows)` +(`src/execution/decrypt-all.ts`) is the read-side amortisation: it walks +the result graph (arrays, objects, nested envelopes; cycle-safe; skips +already-decrypted; passes over exotic containers like `Date` / `Map` / +`Set`), partitions discovered envelopes by `(sdk, table, column)`, and +issues one `bulkDecrypt` per partition. Resolved plaintexts pass through +each envelope's `parseDecryptedValue` and cache back onto the handle. +Version-neutral — it works on any `EncryptedEnvelopeBase` subclass. + +### Cipherstash-namespaced traits + +The extension uses `cipherstash:`-prefixed traits exclusively — the +runtime capability traits (`cipherstash:equality`, +`cipherstash:order-and-range`, `cipherstash:free-text-search`, +`cipherstash:searchable-json`) in `src/extension-metadata/constants.ts`, +and the TYPE-LEVEL `cipherstash:v3-*` dispatch markers in +`src/types/*`. These sit *outside* the framework's closed `CodecTrait` +union deliberately: a cipherstash codec advertising the framework's +`equality` trait would make the built-in `m.col.eq(...)` synthesise on +encrypted columns and lower to SQL `=` against a nondeterministic +ciphertext — the wrong-SQL footgun this namespace closes. The single +cast from the extension namespace into the framework union is localised +and carries a rationale comment; `test/equality-trait-removal.test.ts` +pins that no v3 codec descriptor ever advertises a framework built-in +trait. + +### Control vs runtime tree-shaking + +The package composes tree-shakably along a control/runtime seam: +`./control` (contract-space authoring + the v3 codec lifecycle hook) must +not drag in the runtime envelopes, the SDK, the codec runtime, or the +bulk-encrypt middleware; `./runtime` / `./v3` must not drag in the EQL +install SQL, the baseline migration, or the codec-control hook. The split +lives in the source-import discipline and is pinned byte-level by +`test/bundling-isolation.test.ts` (entry-body forbidden-substring check + +chunk-graph disjointness, modulo pure constants / catalog metadata +chunks). ## Tracked follow-ups -- **Codec-SDK binding refactor.** Pull the per-tenant SDK binding out of the codec factory closure into the descriptor seam so multi-tenant deployments don't re-author the codec per tenant. -- **Polymorphic `CipherstashSdk.decrypt` return type.** One-line interface widening from `Promise` to `Promise` to mirror the bulk shape; removes a narrowing cast in `EncryptedEnvelopeBase.decrypt`. -- **`cipherstashJsonbPathExists` predicate against the live EQL bundle.** The bundle expects a hashed STE-VEC selector computed via the CipherStash SDK's `selector(...)` API; the framework currently binds the JSONpath as a plain `pg/text@1` `ParamRef`. The two SELECT-expression helpers (`cipherstashJsonbPathQueryFirst`, `cipherstashJsonbGet`) work correctly against the same column; the predicate clause returns zero rows. Resolution requires either a client-side path-hashing middleware or an EQL-side plaintext-path overload. +- **`bigint` beyond `Number.MAX_SAFE_INTEGER`.** The `bigint → Number` + coercion at the SDK boundary caps encryptable magnitude; lifting it + requires upstream SDK / ZeroKMS support for a `bigint` plaintext. +- **JSON selector querying.** `eqlJsonContains` covers `@>` containment on + `eql_v3_json`; comparing the value at a specific JSONPath selector is a + separate capability (tracked in GitHub issue #677). ## Behavioural invariants pinned by tests -The following user-facing behaviours are pinned by on-disk tests in `test/` (package-level) and `test/integration/test/authoring/parity/` (cross-package parity harness). This section is the canonical statement of what the package guarantees; if you find yourself loosening one of these, that's the signal to add a regression test alongside. - -### Envelope substrate - -- `EncryptedEnvelopeBase` ships the `#`-prefixed handle slot + five redaction overrides + `expose()` + `decrypt({ signal? })` + `parseDecryptedValue` hook. Every concrete envelope subclass extends it. -- `Encrypted.from(plaintext)` returns a write-side envelope carrying the plaintext on its handle whose ciphertext slot is unfilled until the bulk-encrypt middleware runs. -- `envelope.decrypt({ signal? })` returns plaintext via the SDK's single-cell `decrypt`; `signal` is forwarded by identity (the slot is omitted when `signal` is undefined, preserving `exactOptionalPropertyTypes`). -- After `decryptAll(...)` returns, every touched envelope's `decrypt()` returns the cached plaintext synchronously without consulting the SDK. -- The handle has no public TypeScript surface; pinned per-subclass by `test/envelope-*.test.ts` runtime tests asserting `Object.keys(envelope) === []` and `JSON.stringify(envelope)` produces the documented redacted placeholder. - -### Codec runtime - -- Six codecs (`cipherstash/string@1`, `cipherstash/double@1`, `cipherstash/bigint@1`, `cipherstash/date@1`, `cipherstash/boolean@1`, `cipherstash/json@1`) all registered with target type `eql_v2_encrypted` and `traits: []`. -- `decode(wire, ctx)` builds the right envelope subclass whose handle carries `(table, column)` from `ctx.column`. -- `encode(envelope, ctx)` runs in two-pass mode (see "Write-path lifecycle" above): returns the envelope itself when `handle.ciphertext` is unset (the middleware will rewrite the param slot to the wire-format string), wraps the ciphertext in the `eql_v2_encrypted` composite-text literal when `handle.ciphertext` is set. -- When `handle.ciphertext` is unset AND no `bulkEncryptMiddleware(sdk)` has been constructed against the codec's SDK, encode throws a `RUNTIME.ENCODE_FAILED` envelope with a copy-pasteable wiring snippet. -- `renderOutputType` returns the codec's envelope class name. -- `RuntimeParameterizedCodecDescriptor` per codec, each with its own arktype `paramsSchema` validating that codec's search-mode flags. - -### Bulk-encrypt middleware - -- For N rows × 1 cipherstash column sharing one routing key, exactly one `bulkEncrypt` SDK call per `(table, column)` group. -- For M cipherstash columns across rows, exactly M `bulkEncrypt` calls. -- The middleware writes the encoded **wire-format string** (`encodeEqlV2EncryptedWire(ciphertext)`) into each ParamRef's value slot via `params.replaceValues(...)`. The envelope's ciphertext slot is **also** stamped via `setHandleCiphertext` so any follow-on read off the same envelope skips a re-encrypt round-trip; the plaintext slot is retained. -- Constructing `bulkEncryptMiddleware(sdk)` marks the SDK as registered in the per-process `WeakSet` at `src/execution/middleware-registry.ts`, which the codec's misconfig diagnostic consults. -- `ctx.signal` forwarded by identity to `bulkEncrypt`; cancellation observable downstream. - -### Operator lowering - -- 13 predicate operators (`cipherstashEq` / `Ne` / `InArray` / `NotInArray` / `Ilike` / `NotIlike` / `Gt` / `Gte` / `Lt` / `Lte` / `Between` / `NotBetween` / `JsonbPathExists`) lower to the corresponding `eql_v2.*` function calls. Each is trait- or codec-id-gated. -- 4 free-standing helpers (`cipherstashAsc` / `Desc` / `JsonbPathQueryFirst` / `JsonbGet`) return `OrderByItem` / `Expression`. Sort uses the bare-column form (no `eql_v2.order_by_(col)` wrapping); JSON helpers construct `Expression`-shaped `OperationExpr` via `buildOperation({...})`. -- `m.col.isNull()` / `m.col.isNotNull()` lower to `m.col IS NULL` / `IS NOT NULL` directly via the framework's `NullCheckExpr`; no EQL involvement, no parameter binding. The operator registry is not consulted. -- `m.col.eq(...)` is unreachable on cipherstash columns at the model accessor (compile-time + runtime) — codec declares zero of the framework's built-in traits at all three sites (`codec-runtime.ts` / `codec-metadata.ts` / `parameterized.ts`). Pinned by `test/equality-trait-removal.test.ts`. - -### `decryptAll` walker - -- Walks recursively (objects, arrays, nested envelopes) and decrypts every cipherstash envelope it finds. Skips already-cached envelopes; passes over exotic containers (`Date`, `Map`, `Set`, `Uint8Array`); cycle-safe. -- For K envelopes across distinct routing keys, exactly one `bulkDecrypt` per `(sdk, table, column)` group. -- After return, every touched envelope's `decrypt()` returns the cached plaintext synchronously without consulting the SDK. -- `opts.signal` forwarded by identity to the SDK on every `bulkDecrypt` call. The slot is omitted from the SDK call when `opts.signal` is undefined. - -### Cancellation envelope - -- `RUNTIME.ABORTED` envelope wrapping at every cipherstash-internal phase (`bulk-encrypt`, `decrypt`, `decrypt-all`). Mirrors the framework's `runtimeError(RUNTIME_ABORTED, ...)` envelope shape exactly; only the legal `details.phase` string set widens (the cipherstash phase strings are not added to the framework's `RuntimeAbortedPhase` union). Codec encode/decode are intentionally left unwrapped — the framework's `encodeParams` / `decodeRow` per-cell race already raises `RUNTIME.ABORTED { phase: 'encode' | 'decode' }` per ADR 207. - -### Authoring parity - -- TS contract authoring (`encrypted({...})`) produces a `contract.json` byte-identical to the PSL version (`cipherstash.Encrypted({...})`) for every codec. Pinned by the parity fixtures at `test/integration/test/authoring/parity/cipherstash-encrypted-{string,double,bigint,date,boolean,json}/`. - -### Live e2e umbrella round-trips - -- One `*.e2e.test.ts` per codec under `examples/cipherstash-integration/test/e2e/` exercising the insert → `cipherstash` → optional `cipherstashAsc/Desc` → `decryptAll` round-trip against a live Postgres + EQL + ZeroKMS environment. -- A mixed-codec round-trip exercises four codecs (string + double + bigint + date) in one query, asserting the bulk-encrypt middleware coalesces one SDK call per `(table, column)` group. -- A `*.e2e.json.e2e.test.ts` covers the JSON codec's round-trip and the two SELECT-expression helpers; the `JsonbPathExists` predicate clause is skipped pending the STE-VEC selector hashing follow-up (see Tracked follow-ups). - -### Layering + bundling - -- `pnpm lint:deps` clean for the package's subtree. -- Strict `dbInit` preserved — no `strictVerification: false` anywhere in the cipherstash subtree. -- Tree-shakable control vs runtime / middleware planes pinned by `test/bundling-isolation.test.ts` (entry-body forbidden-substring check + chunk-graph disjointness modulo the shared `constants-*.mjs` chunk). +The following user-facing behaviours are pinned by on-disk tests. This +section is the canonical statement of what the package guarantees; if you +find yourself loosening one, that's the signal to add a regression test +alongside. + +### Contract space & migration + +- The descriptor exposes a contract space that models **no storage**, one + migration (the v3 baseline), an invariant-only genesis edge + (`from: null`), and a head ref requiring only + `cipherstash:install-eql-v3-bundle-v1`. Self-consistency + (`headRef.hash === contract.storageHash`) holds. Pinned by + `test/descriptor.test.ts` and `test/v3/migration-v3.test.ts`. +- The v3 baseline op carries the runtime sentinel (not baked SQL); the + descriptor injects `readInstallSql()` and the injected package survives + the canonical disk writer + integrity-checking reader round-trip. + +### Catalog & authoring + +- Every exposed domain has exactly one argument-less constructor (PSL and + TS) whose descriptor equals the catalog values; the exposed set is + exactly the derived v3 names (no `*OrdOre`, no unqualified `String`). + TS and PSL authoring emit byte-identical `contract.json`. Pinned by + `test/authoring.test.ts`, `test/column-types.test.ts`, + `test/psl-interpretation.test.ts`, `test/v3/properties.test.ts`. +- The pinned v3 codec-id tuple equals the registry-derived set, and the + `isCipherstashV3CodecId` guard narrows only `eql_v3_*`-prefixed ids + (`test/v3/constants-v3.test.ts`, `test/v3/catalog.test.ts`). + +### Codec runtime & operators + +- One v3 codec descriptor per catalog domain, each targeting its concrete + `public.eql_v3_*` native type, wired to the plain-JSONB wire — never a + composite literal. `decode` builds the right envelope via + `envelopeTypeNameForCastAs`. No v3 descriptor advertises a framework + built-in trait (`test/equality-trait-removal.test.ts`, + `test/v3/runtime-v3.test.ts`). +- The `eql*` operators lower to the corresponding `eql_v3.*` functions and + surface only on the domains whose capability tier permits them; the + type-level marker dispatch agrees with the runtime gate. Pinned by + `test/v3/operator-lowering-v3-*.test.ts`, + `test/v3/operator-gating-v3.test.ts`, and the type tests + `test/operation-types.types.test-d.ts`. + +### Envelopes, bulk-encrypt & decryptAll + +- Every envelope ships the redaction overrides + `expose()` + + `decrypt({ signal? })` + `parseDecryptedValue`; `Object.keys(envelope)` + is empty and `JSON.stringify` yields the documented `$encrypted` + placeholder (`test/envelope-*.test.ts`, `test/v3/envelope-number.test.ts`). +- `bulkEncryptMiddlewareV3` issues one `bulkEncrypt` per `(table, column)` + group, writes plain-JSONB wire text, retains plaintext, and ignores + non-v3 params; `ctx.signal` is forwarded by identity + (`test/v3/bulk-encrypt-v3.test.ts`). +- `decryptAll` walks recursively, decrypts one `bulkDecrypt` per + `(sdk, table, column)` group, caches back onto each handle, and forwards + `opts.signal` (`test/decrypt-all.test.ts`). + +### Cancellation + +- `RUNTIME.ABORTED` envelope wrapping at every cipherstash-internal async + phase (`bulk-encrypt`, `decrypt`, `decrypt-all`), reusing the framework + envelope shape with cipherstash-specific `details.phase` strings + (`test/abort.test.ts`; the bulk-encrypt phase in + `test/v3/bulk-encrypt-v3.test.ts`). + +### Layering & bundling + +- `pnpm lint:deps` clean; strict `dbInit` preserved. Tree-shakable + control vs runtime planes pinned byte-level by + `test/bundling-isolation.test.ts`. ## References - [Prisma Next encryption docs](https://cipherstash.com/docs/stack/cipherstash/encryption/prisma-next) — user-facing reference for the extension. - [`@cipherstash/stack`](../stack/README.md) — encryption SDK and schema DSL this package adapts. -- [CipherStash EQL bundle](https://github.com/cipherstash/encrypt-query-language) — the SQL the baseline migration installs. +- [CipherStash EQL](https://github.com/cipherstash/encrypt-query-language) — the SQL the baseline migration installs. diff --git a/packages/prisma-next/README.md b/packages/prisma-next/README.md index faa5a792e..c44541dd1 100644 --- a/packages/prisma-next/README.md +++ b/packages/prisma-next/README.md @@ -10,9 +10,9 @@ Declare encrypted columns directly in `schema.prisma`, and the framework's migra - 🔒 Six encrypted column types — `string`, `double`, `bigint`, `date`, `boolean`, `json` - 🔍 Searchable encryption — equality, free-text search, range, order, JSON path and containment -- 🎯 Type-safe query operators — EQL v3 uses the EQL-derived `eql*` vocabulary (`eqlEq`, `eqlMatch`, `eqlGt`, `eqlAsc`, …); the legacy v2 surface keeps its `cipherstash*` names +- 🎯 Type-safe query operators — the EQL-derived `eql*` vocabulary (`eqlEq`, `eqlMatch`, `eqlGt`, `eqlAsc`, …) - ⚡ Bulk encrypt / bulk decrypt coalescing — one SDK round-trip per `(table, column)` group per query -- 🧩 One-call setup via `cipherstashFromStack({ contractJson })` (v2: `cipherstashFromStackV2`) — no duplicate stack schema to maintain +- 🧩 One-call setup via `cipherstashFromStack({ contractJson })` — no duplicate stack schema to maintain - 🛡️ Plaintext redaction on every implicit serialisation path (`toJSON`, `toString`, `util.inspect`, …) ## Installation @@ -78,7 +78,7 @@ await db.orm.public.User.create({ }) const rows = await db.orm.public.User - .where((u) => u.email.cipherstashIlike("%@example.com")) + .where((u) => u.email.eqlMatch("example.com")) .all() await decryptAll(rows) @@ -91,15 +91,14 @@ See the [full documentation](https://cipherstash.com/docs/stack/cipherstash/encr | Subpath | Purpose | | ---------------- | ------------------------------------------------------------------------------------------------------ | -| `./v3` | The complete EQL v3 surface: `cipherstashFromStack`, the `eql*` query operations, `eqlAsc`/`eqlDesc`, envelopes, middleware, SDK adapter | -| `./stack` | One-call setup against `@cipherstash/stack` (EQL v2): `cipherstashFromStackV2`, `deriveStackSchemas`, `createCipherstashSdk` | -| `./control` | `SqlControlExtensionDescriptor` (contract space + pack meta + codec lifecycle hooks) | -| `./runtime` | Six envelope classes + `CipherstashSdk` + codec runtime + `decryptAll` + four free-standing helpers | -| `./middleware` | `bulkEncryptMiddleware(sdk)` | +| `./v3` | The complete EQL v3 surface: `cipherstashFromStack`, the `eql*` query operations, `eqlAsc`/`eqlDesc`, envelopes, `bulkEncryptMiddlewareV3`, SDK adapter | +| `./stack` | One-call setup against `@cipherstash/stack`: `cipherstashFromStack`, `deriveStackSchemasV3`, `createCipherstashV3Sdk` | +| `./control` | `SqlControlExtensionDescriptor` (contract space + pack meta + v3 codec lifecycle hooks) | +| `./runtime` | Envelope classes + `CipherstashSdk` + v3 codec runtime + `decryptAll` + `bulkEncryptMiddlewareV3` | | `./pack` | `cipherstashPackMeta` for TS contract authoring | -| `./column-types` | Six TS factories: `encryptedString` / `encryptedDouble` / `encryptedBigInt` / `encryptedDate` / `encryptedBoolean` / `encryptedJson` | +| `./column-types` | The v3 domain factories: `text` / `textSearch` / `integerOrd` / `bigIntOrd` / `date` / `boolean` / `json` / … | -`./control`, `./runtime`, and `./middleware` are tree-shakable. `./stack` sits on top of `./runtime` + `./middleware` and additionally pulls in `@cipherstash/stack`; consumers who implement `CipherstashSdk` against a different KMS skip `./stack` and pay no `@cipherstash/stack` bundle cost. +`./control` and `./runtime` are tree-shakable. `./stack` sits on top of `./runtime` and additionally pulls in `@cipherstash/stack`; consumers who implement `CipherstashSdk` against a different KMS skip `./stack` and pay no `@cipherstash/stack` bundle cost. ## Authentication 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 deleted file mode 100644 index 6443a1f58..000000000 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.d.ts +++ /dev/null @@ -1,175 +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 { - 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:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7'>; -export type ExecutionHash = ExecutionHashBase; -export type ProfileHash = - ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; - -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 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 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, - QueryOperationTypes, - FieldOutputTypes, - FieldInputTypes ->; - -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: { - 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 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 meta: {}; - - readonly profileHash: ProfileHash; -}; - -export type Contract = ContractWithTypeMaps; - -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 deleted file mode 100644 index 8dc56139f..000000000 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/end-contract.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "schemaVersion": "1", - "targetFamily": "sql", - "target": "postgres", - "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", - "roots": { - "eql_v2_configuration": { - "model": "EqlV2Configuration", - "namespace": "public" - } - }, - "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" - } - } - } - } - } - }, - "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" - }, - "capabilities": { - "postgres": { - "distinctOn": true, - "jsonAgg": true, - "lateral": true, - "limit": true, - "orderBy": true, - "returning": true - }, - "sql": { - "defaultInInsert": true, - "enums": true, - "lateral": 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" - } -} \ 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 deleted file mode 100644 index 4bf75c2ac..000000000 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "from": null, - "to": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7", - "providedInvariants": [ - "cipherstash:install-eql-bundle-v1" - ], - "createdAt": "2026-05-09T03:42:56.902Z", - "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 deleted file mode 100755 index 4588bea3d..000000000 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/migration.ts +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env -S node -/** - * CipherStash baseline migration — install the vendored EQL bundle. - * - * The contract IR (see `/contract.json`) declares the - * `eql_v2_configuration` table only — that's the single typed object - * today's `SqlStorage` IR can model. The actual database state — the - * `eql_v2` schema, the `eql_v2_configuration_state` enum, the - * `eql_v2_encrypted` composite, the `eql_v2.bloom_filter` / - * `hmac_256` / `blake3` domains, plus the ORE composites — is created - * by the vendored EQL bundle SQL (see `../../src/migration/eql-bundle.ts`, - * which re-exports the bundle from `eql-install.generated.ts` - * byte-for-byte). The bundle also creates the `eql_v2_configuration` - * table itself, so the planner-emitted - * `createTable` op would conflict with the bundle's `CREATE TABLE` - * and is intentionally dropped from this migration's `operations` - * getter. - * - * Authoring loop: this file is hand-edited (see - * `docs/architecture docs/adrs/ADR 212 - Contract spaces.md`'s - * contract-space package layout section). Re-emit `ops.json` / - * `migration.json` after edits via `node migration.ts`. - */ -import { - Migration, - MigrationCLI, - rawSql, -} from '@prisma-next/target-postgres/migration' -import { CIPHERSTASH_INVARIANTS } from '../../src/extension-metadata/constants' -import { EQL_BUNDLE_SQL } from '../../src/migration/eql-bundle' - -const INSTALL_LABEL = - 'Install EQL bundle (functions, operators, casts, op classes, schema, types)' - -export default class M extends Migration { - override describe() { - return { - from: null, - to: 'sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7', - } - } - - override get operations() { - return [ - rawSql({ - id: 'cipherstash.install-eql-bundle', - label: INSTALL_LABEL, - operationClass: 'additive', - invariantId: CIPHERSTASH_INVARIANTS.installBundle, - target: { id: 'postgres' }, - precheck: [], - execute: [{ description: INSTALL_LABEL, sql: EQL_BUNDLE_SQL }], - postcheck: [ - { - description: 'verify "eql_v2" schema exists', - sql: "SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'eql_v2')", - }, - { - // The composite type is created in the `public` schema - // (not `eql_v2`) — by design. Customer data columns - // declared as `eql_v2_encrypted` are pinned to the type's - // OID and must survive a `DROP SCHEMA eql_v2 CASCADE` - // re-install of the bundle without losing the columns. - // Placing the composite outside the `eql_v2` namespace - // decouples the type's lifecycle from the bundle's - // functions / operators / casts. - description: - 'verify "public.eql_v2_encrypted" composite type exists', - sql: "SELECT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = 'public' AND t.typname = 'eql_v2_encrypted')", - }, - ], - }), - ] - } -} - -MigrationCLI.run(import.meta.url, M) diff --git a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/ops.json b/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/ops.json deleted file mode 100644 index 47ee0ca65..000000000 --- a/packages/prisma-next/migrations/20260601T0000_install_eql_bundle/ops.json +++ /dev/null @@ -1,28 +0,0 @@ -[ - { - "id": "cipherstash.install-eql-bundle", - "label": "Install EQL bundle (functions, operators, casts, op classes, schema, types)", - "operationClass": "additive", - "invariantId": "cipherstash:install-eql-bundle-v1", - "target": { - "id": "postgres" - }, - "precheck": [], - "execute": [ - { - "description": "Install EQL bundle (functions, operators, casts, op classes, schema, types)", - "sql": "--! @file schema.sql\n--! @brief EQL v2 schema creation\n--!\n--! Creates the eql_v2 schema which contains all Encrypt Query Language\n--! functions, types, and tables. Drops existing schema if present to\n--! support clean reinstallation.\n--!\n--! @warning DROP SCHEMA CASCADE will remove all objects in the schema\n--! @note All EQL objects (functions, types, tables) reside in eql_v2 schema\n\n--! @brief Drop existing EQL v2 schema\n--! @warning CASCADE will drop all dependent objects\nDROP SCHEMA IF EXISTS eql_v2 CASCADE;\n\n--! @brief Create EQL v2 schema\n--! @note All EQL functions and types will be created in this schema\nCREATE SCHEMA eql_v2;\n\n--! @brief Composite type for encrypted column data\n--!\n--! Core type used for all encrypted columns in EQL. Stores encrypted data as JSONB\n--! with the following structure:\n--! - `c`: ciphertext (base64-encoded encrypted value)\n--! - `i`: index terms (searchable metadata for encrypted searches)\n--! - `k`: key ID (identifier for encryption key)\n--! - `m`: metadata (additional encryption metadata)\n--!\n--! Created in public schema to persist independently of eql_v2 schema lifecycle.\n--! Customer data columns use this type, so it must not be dropped if data exists.\n--!\n--! @note DO NOT DROP this type unless absolutely certain no encrypted data uses it\n--! @see eql_v2.ciphertext\n--! @see eql_v2.meta_data\n--! @see eql_v2.add_column\nDO $$\n BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eql_v2_encrypted') THEN\n CREATE TYPE public.eql_v2_encrypted AS (\n data jsonb\n );\n END IF;\n END\n$$;\n\n\n\n\n\n\n\n\n\n\n--! @brief Bloom filter index term type\n--!\n--! Domain type representing Bloom filter bit arrays stored as smallint arrays.\n--! Used for pattern-match encrypted searches via the 'match' index type.\n--! The filter is stored in the 'bf' field of encrypted data payloads.\n--!\n--! @see eql_v2.add_search_config\n--! @see eql_v2.\"~~\"\n--! @note This is a transient type used only during query execution\nCREATE DOMAIN eql_v2.bloom_filter AS smallint[];\n\n\n\n--! @brief ORE block term type for Order-Revealing Encryption\n--!\n--! Composite type representing a single ORE (Order-Revealing Encryption) block term.\n--! Stores encrypted data as bytea that enables range comparisons without decryption.\n--!\n--! @see eql_v2.ore_block_u64_8_256\n--! @see eql_v2.compare_ore_block_u64_8_256_term\nCREATE TYPE eql_v2.ore_block_u64_8_256_term AS (\n bytes bytea\n);\n\n\n--! @brief ORE block index term type for range queries\n--!\n--! Composite type containing an array of ORE block terms. Used for encrypted\n--! range queries via the 'ore' index type. The array is stored in the 'ob' field\n--! of encrypted data payloads.\n--!\n--! @see eql_v2.add_search_config\n--! @see eql_v2.compare_ore_block_u64_8_256_terms\n--! @note This is a transient type used only during query execution\nCREATE TYPE eql_v2.ore_block_u64_8_256 AS (\n terms eql_v2.ore_block_u64_8_256_term[]\n);\n\n--! @brief HMAC-SHA256 index term type\n--!\n--! Domain type representing HMAC-SHA256 hash values.\n--! Used for exact-match encrypted searches via the 'unique' index type.\n--! The hash is stored in the 'hm' field of encrypted data payloads.\n--!\n--! @see eql_v2.add_search_config\n--! @note This is a transient type used only during query execution\nCREATE DOMAIN eql_v2.hmac_256 AS text;\n\n--! @file src/ste_vec/types.sql\n--! @brief Domain type for individual STE-vec entries\n--!\n--! Defines `eql_v2.ste_vec_entry` as a DOMAIN over `jsonb` constrained to the\n--! shape of a single element inside an `sv` array — a JSON object that\n--! carries at minimum a selector field (`s`). This is the type returned by\n--! the `->` operator on `eql_v2_encrypted` (a single sv element extracted by\n--! selector) and the type accepted by sv-element extractors such as\n--! `eql_v2.ore_cllw(eql_v2.ste_vec_entry)` and\n--! `eql_v2.hmac_256(eql_v2.ste_vec_entry)`.\n--!\n--! Why a separate type. Before #219, the `(eql_v2_encrypted)` overloads of\n--! sv-element extractors read fields like `oc` off the root `data` jsonb,\n--! which is misleading: a root `EncryptedPayload` or `SteVecPayload` (the\n--! shapes that an actual `eql_v2_encrypted` column value carries) never has\n--! `oc` at the root. The previous pattern only worked because the `->`\n--! operator merged ste-vec entry fields into a fake root-shaped payload\n--! before the extractor ran. This domain type makes the distinction\n--! explicit: `eql_v2_encrypted` is the root shape; `eql_v2.ste_vec_entry`\n--! is the per-entry shape; extractors are typed accordingly.\n--!\n--! @note The CHECK constraint reflects the cipherstash-suite emission\n--! contract:\n--! - `s` (selector — column-name HMAC) and `c` (ciphertext) are\n--! emitted on every sv element.\n--! - Each sv element carries **exactly one** of `hm` (HMAC-256, for\n--! hash-equality queries) or `oc` (CLLW ORE, for ordered queries)\n--! — they are mutually exclusive. A given selector / field is\n--! configured for one mode or the other; the crypto layer emits\n--! the corresponding term and only that term.\n--! Other fields (`a` for array marker, etc.) are allowed but not\n--! required.\n--!\n--! @see src/operators/->.sql\n--! @see src/ore_cllw/functions.sql\n--! @see src/hmac_256/functions.sql\nCREATE DOMAIN eql_v2.ste_vec_entry AS jsonb\n CHECK (\n jsonb_typeof(VALUE) = 'object'\n AND VALUE ? 's'\n AND VALUE ? 'c'\n AND (VALUE ? 'hm') <> (VALUE ? 'oc')\n );\n\n\n--! @brief Domain type for an STE-vec containment needle\n--!\n--! `eql_v2.stevec_query` is a query-shaped sv payload: a top-level\n--! `{\"sv\": [...]}` object whose elements carry selector + index\n--! terms but **never** a ciphertext (`c`) field. Containment (`@>`)\n--! against an `eql_v2_encrypted` column is structurally typed\n--! through this domain so the call site reads as \"match against an\n--! sv query\", not \"compare two encrypted values\".\n--!\n--! Compared to `eql_v2.ste_vec_entry` (single sv element with `s`,\n--! `c`, and `hm` XOR `oc`), `stevec_query` is the wrapping\n--! `{\"sv\": [...]}` payload: it forbids `c` on every element but\n--! otherwise keeps the same per-element contract — each element must\n--! carry a selector `s` and exactly one deterministic term (`hm` XOR\n--! `oc`). This mirrors the `SteVecQueryElement` JSON schema and stops\n--! selector-only needles (e.g. `{\"sv\":[{\"s\":\"x\"}]}`) from casting and\n--! then matching every row through the bare `jsonb @>` implementation.\n--! The implementation of `ste_vec_contains` ignores `c` either way,\n--! but typing the needle as `stevec_query` documents the contract at\n--! the API surface.\n--!\n--! @note Constructing a `stevec_query` literal from inline JSON works\n--! via the standard DOMAIN cast:\n--! `'{\"sv\":[{\"s\":\"\",\"hm\":\"\"}]}'::eql_v2.stevec_query`\n--! Casting an `eql_v2_encrypted` value strips `c` fields from\n--! each sv element — see `eql_v2.to_stevec_query`.\n--!\n--! @see eql_v2.to_stevec_query\n--! @see src/operators/@>.sql\nCREATE DOMAIN eql_v2.stevec_query AS jsonb\n CHECK (\n jsonb_typeof(VALUE) = 'object'\n AND VALUE ? 'sv'\n AND jsonb_typeof(VALUE -> 'sv') = 'array'\n -- No element may carry a ciphertext (`c`) — this is a query, not a value.\n AND NOT jsonb_path_exists(VALUE, '$.sv[*] ? (exists(@.c))'::jsonpath)\n -- Every element must carry a selector (`s`) ...\n AND NOT jsonb_path_exists(VALUE, '$.sv[*] ? (!exists(@.s))'::jsonpath)\n -- ... and exactly one deterministic term — `hm` XOR `oc` — matching\n -- the `ste_vec_entry` emission contract and the `SteVecQueryElement`\n -- JSON schema. Rejects selector-only needles that would otherwise\n -- cast and then match every row via the bare `jsonb @>` body.\n AND NOT jsonb_path_exists(VALUE, '$.sv[*] ? (exists(@.hm) && exists(@.oc))'::jsonpath)\n AND NOT jsonb_path_exists(VALUE, '$.sv[*] ? (!exists(@.hm) && !exists(@.oc))'::jsonpath)\n );\n\n\n--! @brief Convert an `eql_v2_encrypted` to a `stevec_query` needle\n--!\n--! Normalises each sv element down to the matching-relevant fields:\n--! `s` (selector) plus exactly one of `hm` / `oc`. Other fields\n--! (`c` ciphertext, `a` array marker, `i`/`v` envelope metadata, anything\n--! else cipherstash-client might emit) are stripped. This is the\n--! canonical needle shape for `@>` containment — matching the contract\n--! that containment compares by selector + deterministic term and\n--! ignores everything else.\n--!\n--! Designed for use as a functional GIN index expression: a single\n--! `GIN (eql_v2.to_stevec_query(col)::jsonb jsonb_path_ops)` index\n--! covers containment queries against any selector (both hm-bearing\n--! and oc-bearing — XOR-aware), and the typed `@>` overloads inline\n--! to a native `jsonb @>` on the same expression so the planner\n--! engages Bitmap Index Scan structurally.\n--!\n--! @param e eql_v2_encrypted Source encrypted payload\n--! @return eql_v2.stevec_query Query-shaped needle, sv elements\n--! normalised to `{s, hm}` or `{s, oc}`.\n--!\n--! @example\n--! -- Functional GIN index — canonical containment recipe\n--! CREATE INDEX ON users USING gin (\n--! eql_v2.to_stevec_query(encrypted_doc)::jsonb jsonb_path_ops\n--! );\n--!\n--! -- Cross-row containment\n--! SELECT a.*\n--! FROM docs a, docs b\n--! WHERE a.encrypted_doc @> b.encrypted_doc::eql_v2.stevec_query\n--! AND b.id = 42;\n--!\n--! @see eql_v2.stevec_query\n--! @see eql_v2.\"@>\"(eql_v2_encrypted, eql_v2.stevec_query)\nCREATE FUNCTION eql_v2.to_stevec_query(e eql_v2_encrypted)\n RETURNS eql_v2.stevec_query\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT jsonb_build_object(\n 'sv',\n coalesce(\n (SELECT jsonb_agg(\n jsonb_strip_nulls(\n jsonb_build_object(\n 's', elem -> 's',\n 'hm', elem -> 'hm',\n 'oc', elem -> 'oc'\n )\n )\n )\n FROM jsonb_array_elements((e).data -> 'sv') AS elem),\n '[]'::jsonb\n )\n )::eql_v2.stevec_query\n$$;\n\nCREATE CAST (eql_v2_encrypted AS eql_v2.stevec_query)\n WITH FUNCTION eql_v2.to_stevec_query\n AS ASSIGNMENT;\n\n--! @file crypto.sql\n--! @brief PostgreSQL pgcrypto extension enablement\n--!\n--! Enables the pgcrypto extension which provides cryptographic functions\n--! used by EQL for hashing and other cryptographic operations.\n--!\n--! Installs pgcrypto into the `extensions` schema (Supabase convention) to\n--! avoid the `extension_in_public` lint. Every EQL function that uses\n--! pgcrypto has `pg_catalog, extensions, public` on its `search_path`, so a\n--! pre-existing install in `public` keeps working — and a pre-existing\n--! install anywhere else will be rejected at install time rather than\n--! failing later inside an encrypted comparison.\n--!\n--! @note pgcrypto provides functions like digest(), hmac(), gen_random_bytes()\n--! @note If pgcrypto is already installed in `public`, EQL works but emits\n--! a NOTICE recommending `ALTER EXTENSION pgcrypto SET SCHEMA extensions`.\n--! @note If pgcrypto is already installed in any other schema, install\n--! fails. Relocate it first with `ALTER EXTENSION pgcrypto SET SCHEMA\n--! extensions` (or move it into `public` if compatibility with other\n--! consumers requires it).\n\n--! @brief Create extensions schema (Supabase convention)\nCREATE SCHEMA IF NOT EXISTS extensions;\n\n--! @brief Enable pgcrypto extension and validate its schema\nDO $$\nDECLARE\n pgcrypto_schema name;\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN\n CREATE EXTENSION pgcrypto WITH SCHEMA extensions;\n END IF;\n\n SELECT n.nspname INTO pgcrypto_schema\n FROM pg_extension e\n JOIN pg_namespace n ON n.oid = e.extnamespace\n WHERE e.extname = 'pgcrypto';\n\n IF pgcrypto_schema = 'extensions' THEN\n -- expected location, nothing to say\n NULL;\n ELSIF pgcrypto_schema = 'public' THEN\n RAISE NOTICE\n 'pgcrypto is installed in the `public` schema. EQL works against this layout, '\n 'but Supabase splinter will flag it as `extension_in_public`. Move it with: '\n 'ALTER EXTENSION pgcrypto SET SCHEMA extensions';\n ELSE\n RAISE EXCEPTION\n 'pgcrypto is installed in schema `%`, which is not on the EQL function search_path '\n '(pg_catalog, extensions, public). EQL cryptographic operations would fail at '\n 'runtime. Relocate the extension before installing EQL: '\n 'ALTER EXTENSION pgcrypto SET SCHEMA extensions',\n pgcrypto_schema;\n END IF;\nEND $$;\n\n--! @brief Extract ciphertext from encrypted JSONB value\n--!\n--! Extracts the ciphertext (c field) from a raw JSONB encrypted value.\n--! The ciphertext is the base64-encoded encrypted data.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return Text Base64-encoded ciphertext string\n--! @throws Exception if 'c' field is not present in JSONB\n--!\n--! @example\n--! -- Extract ciphertext from JSONB literal\n--! SELECT eql_v2.ciphertext('{\"c\":\"AQIDBA==\",\"i\":{\"unique\":\"...\"}}'::jsonb);\n--!\n--! @see eql_v2.ciphertext(eql_v2_encrypted)\n--! @see eql_v2.meta_data\nCREATE FUNCTION eql_v2.ciphertext(val jsonb)\n RETURNS text\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF val ? 'c' THEN\n RETURN val->>'c';\n END IF;\n RAISE 'Expected a ciphertext (c) value in json: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Extract ciphertext from encrypted column value\n--!\n--! Extracts the ciphertext from an encrypted column value. Convenience\n--! overload that unwraps eql_v2_encrypted type and delegates to JSONB version.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return Text Base64-encoded ciphertext string\n--! @throws Exception if encrypted value is malformed\n--!\n--! @example\n--! -- Extract ciphertext from encrypted column\n--! SELECT eql_v2.ciphertext(encrypted_email) FROM users;\n--!\n--! @see eql_v2.ciphertext(jsonb)\n--! @see eql_v2.meta_data\nCREATE FUNCTION eql_v2.ciphertext(val eql_v2_encrypted)\n RETURNS text\n IMMUTABLE STRICT PARALLEL SAFE\n LANGUAGE SQL\nAS $$\n SELECT eql_v2.ciphertext(val.data);\n$$;\n\n--! @brief State transition function for grouped_value aggregate\n--! @internal\n--!\n--! Returns the first non-null value encountered. Used as state function\n--! for the grouped_value aggregate to select first value in each group.\n--!\n--! @param $1 JSONB Accumulated state (first non-null value found)\n--! @param $2 JSONB New value from current row\n--! @return JSONB First non-null value (state or new value)\n--!\n--! @see eql_v2.grouped_value\nCREATE FUNCTION eql_v2._first_grouped_value(jsonb, jsonb)\nRETURNS jsonb\nAS $$\n SELECT COALESCE($1, $2);\n$$ LANGUAGE sql IMMUTABLE;\n\n--! @brief Return first non-null encrypted value in a group\n--!\n--! Aggregate function that returns the first non-null encrypted value\n--! encountered within a GROUP BY clause. Useful for deduplication or\n--! selecting representative values from grouped encrypted data.\n--!\n--! @param input JSONB Encrypted values to aggregate\n--! @return JSONB First non-null encrypted value in group\n--!\n--! @example\n--! -- Get first email per user group\n--! SELECT user_id, eql_v2.grouped_value(encrypted_email)\n--! FROM user_emails\n--! GROUP BY user_id;\n--!\n--! -- Deduplicate encrypted values\n--! SELECT DISTINCT ON (user_id)\n--! user_id,\n--! eql_v2.grouped_value(encrypted_ssn) as primary_ssn\n--! FROM user_records\n--! GROUP BY user_id;\n--!\n--! @see eql_v2._first_grouped_value\nCREATE AGGREGATE eql_v2.grouped_value(jsonb) (\n SFUNC = eql_v2._first_grouped_value,\n STYPE = jsonb\n);\n\n--! @brief Add validation constraint to encrypted column\n--!\n--! Adds a CHECK constraint to ensure column values conform to encrypted data\n--! structure. Constraint uses eql_v2.check_encrypted to validate format.\n--! Called automatically by eql_v2.add_column.\n--!\n--! @param table_name TEXT Name of table containing the column\n--! @param column_name TEXT Name of column to constrain\n--! @return Void\n--!\n--! @example\n--! -- Manually add constraint (normally done by add_column)\n--! SELECT eql_v2.add_encrypted_constraint('users', 'encrypted_email');\n--!\n--! -- Resulting constraint:\n--! -- ALTER TABLE users ADD CONSTRAINT eql_v2_encrypted_check_encrypted_email\n--! -- CHECK (eql_v2.check_encrypted(encrypted_email));\n--!\n--! @see eql_v2.add_column\n--! @see eql_v2.remove_encrypted_constraint\nCREATE FUNCTION eql_v2.add_encrypted_constraint(table_name TEXT, column_name TEXT)\n RETURNS void\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n EXECUTE format('ALTER TABLE %I ADD CONSTRAINT eql_v2_encrypted_constraint_%I_%I CHECK (eql_v2.check_encrypted(%I))', table_name, table_name, column_name, column_name);\n EXCEPTION\n WHEN duplicate_table THEN\n WHEN duplicate_object THEN\n RAISE NOTICE 'Constraint `eql_v2_encrypted_constraint_%_%` already exists, skipping', table_name, column_name;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Remove validation constraint from encrypted column\n--!\n--! Removes the CHECK constraint that validates encrypted data structure.\n--! Called automatically by eql_v2.remove_column. Uses IF EXISTS to avoid\n--! errors if constraint doesn't exist.\n--!\n--! @param table_name TEXT Name of table containing the column\n--! @param column_name TEXT Name of column to unconstrain\n--! @return Void\n--!\n--! @example\n--! -- Manually remove constraint (normally done by remove_column)\n--! SELECT eql_v2.remove_encrypted_constraint('users', 'encrypted_email');\n--!\n--! @see eql_v2.remove_column\n--! @see eql_v2.add_encrypted_constraint\nCREATE FUNCTION eql_v2.remove_encrypted_constraint(table_name TEXT, column_name TEXT)\n RETURNS void\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n\t\tEXECUTE format('ALTER TABLE %I DROP CONSTRAINT IF EXISTS eql_v2_encrypted_constraint_%I_%I', table_name, table_name, column_name);\n\tEND;\n$$ LANGUAGE plpgsql;\n\n--! @brief Extract metadata from encrypted JSONB value\n--!\n--! Extracts index terms (i) and version (v) from a raw JSONB encrypted value.\n--! Returns metadata object containing searchable index terms without ciphertext.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return JSONB Metadata object with 'i' (index terms) and 'v' (version) fields\n--!\n--! @example\n--! -- Extract metadata to inspect index terms\n--! SELECT eql_v2.meta_data('{\"c\":\"...\",\"i\":{\"unique\":\"abc123\"},\"v\":1}'::jsonb);\n--! -- Returns: {\"i\":{\"unique\":\"abc123\"},\"v\":1}\n--!\n--! @see eql_v2.meta_data(eql_v2_encrypted)\n--! @see eql_v2.ciphertext\nCREATE FUNCTION eql_v2.meta_data(val jsonb)\n RETURNS jsonb\n IMMUTABLE STRICT PARALLEL SAFE\n LANGUAGE SQL\nAS $$\n SELECT jsonb_build_object('i', val->'i', 'v', val->'v');\n$$;\n\n--! @brief Extract metadata from encrypted column value\n--!\n--! Extracts index terms and version from an encrypted column value.\n--! Convenience overload that unwraps eql_v2_encrypted type and\n--! delegates to JSONB version.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return JSONB Metadata object with 'i' (index terms) and 'v' (version) fields\n--!\n--! @example\n--! -- Inspect index terms for encrypted column\n--! SELECT user_id, eql_v2.meta_data(encrypted_email) as email_metadata\n--! FROM users;\n--!\n--! @see eql_v2.meta_data(jsonb)\n--! @see eql_v2.ciphertext\nCREATE FUNCTION eql_v2.meta_data(val eql_v2_encrypted)\n RETURNS jsonb\n IMMUTABLE STRICT PARALLEL SAFE\n LANGUAGE SQL\nAS $$\n SELECT eql_v2.meta_data(val.data);\n$$;\n\n-- AUTOMATICALLY GENERATED FILE\n\n--! @file common.sql\n--! @brief Common utility functions\n--!\n--! Provides general-purpose utility functions used across EQL:\n--! - Constant-time bytea comparison for security\n--! - JSONB to bytea array conversion\n--! - Logging helpers for debugging and testing\n\n\n--! @brief Constant-time comparison of bytea values\n--! @internal\n--!\n--! Compares two bytea values in constant time to prevent timing attacks.\n--! Always checks all bytes even after finding differences, maintaining\n--! consistent execution time regardless of where differences occur.\n--!\n--! @param a bytea First value to compare\n--! @param b bytea Second value to compare\n--! @return boolean True if values are equal\n--!\n--! @note Returns false immediately if lengths differ (length is not secret)\n--! @note Used for secure comparison of cryptographic values\nCREATE FUNCTION eql_v2.bytea_eq(a bytea, b bytea) RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n result boolean;\n differing bytea;\nBEGIN\n\n -- Check if the bytea values are the same length\n IF LENGTH(a) != LENGTH(b) THEN\n RETURN false;\n END IF;\n\n -- Compare each byte in the bytea values\n result := true;\n FOR i IN 1..LENGTH(a) LOOP\n IF SUBSTRING(a FROM i FOR 1) != SUBSTRING(b FROM i FOR 1) THEN\n result := result AND false;\n END IF;\n END LOOP;\n\n RETURN result;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Convert JSONB hex array to bytea array\n--! @internal\n--!\n--! Converts a JSONB array of hex-encoded strings into a PostgreSQL bytea array.\n--! Used for deserializing binary data (like ORE terms) from JSONB storage.\n--!\n--! @param jsonb JSONB array of hex-encoded strings\n--! @return bytea[] Array of decoded binary values\n--!\n--! @note Returns NULL if input is JSON null\n--! @note Each array element is hex-decoded to bytea\nCREATE FUNCTION eql_v2.jsonb_array_to_bytea_array(val jsonb)\nRETURNS bytea[]\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n terms_arr bytea[];\nBEGIN\n IF jsonb_typeof(val) = 'null' THEN\n RETURN NULL;\n END IF;\n\n SELECT array_agg(decode(value::text, 'hex')::bytea)\n INTO terms_arr\n FROM jsonb_array_elements_text(val) AS value;\n\n RETURN terms_arr;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Log message for debugging\n--!\n--! Convenience function to emit log messages during testing and debugging.\n--! Uses RAISE NOTICE to output messages to PostgreSQL logs.\n--!\n--! @param text Message to log\n--!\n--! @note Primarily used in tests and development\n--! @see eql_v2.log(text, text) for contextual logging\nCREATE FUNCTION eql_v2.log(s text)\n RETURNS void\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RAISE NOTICE '[LOG] %', s;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Log message with context\n--!\n--! Overload of log function that includes context label for better\n--! log organization during testing.\n--!\n--! @param ctx text Context label (e.g., test name, module name)\n--! @param s text Message to log\n--!\n--! @note Format: \"[LOG] {ctx} {message}\"\n--! @see eql_v2.log(text)\nCREATE FUNCTION eql_v2.log(ctx text, s text)\n RETURNS void\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RAISE NOTICE '[LOG] % %', ctx, s;\nEND;\n$$ LANGUAGE plpgsql;\n\n--! @brief CLLW ORE index term type for STE-vec range queries\n--!\n--! Composite type for CLLW (Copyless Logarithmic Width) Order-Revealing\n--! Encryption. The ciphertext is stored in the `oc` field of encrypted data\n--! payloads (Standard-mode `ste_vec` elements). Used by `eql_v2.compare` and\n--! the range operators (`<`, `<=`, `>`, `>=`) when the payload carries an\n--! `oc` term.\n--!\n--! The wire-format `oc` value is a hex string with a leading domain-tag byte\n--! (`0x00` numeric, `0x01` string) followed by the CLLW ciphertext. The\n--! decoded `bytes` field on this composite carries the full byte string\n--! including the tag — the comparator is variable-length capable, so numeric\n--! and string values within the same column are ordered correctly: the\n--! domain tag separates the two ranges (numeric < string) and the\n--! within-domain comparison falls through to the CLLW per-byte protocol.\n--!\n--! @see eql_v2.add_search_config\n--! @see eql_v2.compare_ore_cllw\n--! @note This is a transient type used only during query execution\nCREATE TYPE eql_v2.ore_cllw AS (\n bytes bytea\n);\n\n--! @brief Extract HMAC-SHA256 index term from JSONB payload\n--!\n--! Extracts the HMAC-SHA256 hash value from the 'hm' field of an encrypted\n--! data payload. Inlinable single-statement SQL — the planner can fold this\n--! into the calling query so functional hash indexes built on\n--! `eql_v2.hmac_256(col)` engage structurally.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return eql_v2.hmac_256 HMAC-SHA256 hash value, or NULL when `hm` is absent\n--!\n--! @note Returns NULL when the payload lacks `hm`. Callers that need to\n--! surface misconfiguration loudly should use\n--! `eql_v2.hash_encrypted` (`GROUP BY` / `DISTINCT` / hash joins)\n--! which raises with a clear message when `hm` is missing.\n--!\n--! @see eql_v2.has_hmac_256\n--! @see eql_v2.compare_hmac_256\n--! @see eql_v2.hash_encrypted\nCREATE FUNCTION eql_v2.hmac_256(val jsonb)\n RETURNS eql_v2.hmac_256\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT (val ->> 'hm')::eql_v2.hmac_256\n$$;\n\n\n--! @brief Check if JSONB payload contains HMAC-SHA256 index term\n--!\n--! Tests whether the encrypted data payload includes an 'hm' field,\n--! indicating an HMAC-SHA256 hash is available for exact-match queries.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return Boolean True if 'hm' field is present and non-null\n--!\n--! @see eql_v2.hmac_256\nCREATE FUNCTION eql_v2.has_hmac_256(val jsonb)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN val ->> 'hm' IS NOT NULL;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Check if encrypted column value contains HMAC-SHA256 index term\n--!\n--! Tests whether an encrypted column value includes an HMAC-SHA256 hash\n--! by checking its underlying JSONB data field.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return Boolean True if HMAC-SHA256 hash is present\n--!\n--! @see eql_v2.has_hmac_256(jsonb)\nCREATE FUNCTION eql_v2.has_hmac_256(val eql_v2_encrypted)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN eql_v2.has_hmac_256(val.data);\n END;\n$$ LANGUAGE plpgsql;\n\n\n\n--! @brief Extract HMAC-SHA256 index term from encrypted column value\n--!\n--! Extracts the HMAC-SHA256 hash from an encrypted column value. Inlinable\n--! single-statement SQL — see the jsonb overload for the rationale.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return eql_v2.hmac_256 HMAC-SHA256 hash value, or NULL when `hm` is absent\n--!\n--! @see eql_v2.hmac_256(jsonb)\nCREATE FUNCTION eql_v2.hmac_256(val eql_v2_encrypted)\n RETURNS eql_v2.hmac_256\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT ((val).data ->> 'hm')::eql_v2.hmac_256\n$$;\n\n\n--! @brief Extract HMAC-SHA256 index term from a ste_vec entry\n--!\n--! Extracts the HMAC from the `hm` field of an `sv` element extracted via\n--! the `->` operator. Inlinable. The recipe for field-level equality on\n--! encrypted JSON is:\n--!\n--! @example\n--! -- Functional hash index\n--! CREATE INDEX ON users USING hash (eql_v2.hmac_256(data -> ''));\n--! -- Bare-form predicate matches via the inlined `=` on ste_vec_entry\n--! SELECT * FROM users WHERE data -> '' = $1::eql_v2.ste_vec_entry;\n--!\n--! @param entry eql_v2.ste_vec_entry STE-vec entry (extracted via `->`)\n--! @return eql_v2.hmac_256 HMAC value, or NULL when `hm` is absent\n--!\n--! @see eql_v2.has_hmac_256\n--! @see src/operators/->.sql\nCREATE FUNCTION eql_v2.hmac_256(entry eql_v2.ste_vec_entry)\n RETURNS eql_v2.hmac_256\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT (entry ->> 'hm')::eql_v2.hmac_256\n$$;\n\n\n--! @brief Check if a ste_vec entry contains an HMAC-SHA256 index term\n--!\n--! @param entry eql_v2.ste_vec_entry STE-vec entry\n--! @return Boolean True if `hm` field is present and non-null\nCREATE FUNCTION eql_v2.has_hmac_256(entry eql_v2.ste_vec_entry)\n RETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT entry ->> 'hm' IS NOT NULL\n$$;\n\n\n\n\n--! @brief Convert JSONB array to ORE block composite type\n--! @internal\n--!\n--! Converts a JSONB array of hex-encoded ORE terms from the CipherStash Proxy\n--! payload into the PostgreSQL composite type used for ORE operations.\n--!\n--! @param val JSONB Array of hex-encoded ORE block terms\n--! @return eql_v2.ore_block_u64_8_256 ORE block composite type, or NULL if input is null\n--!\n--! @see eql_v2.ore_block_u64_8_256(jsonb)\nCREATE FUNCTION eql_v2.jsonb_array_to_ore_block_u64_8_256(val jsonb)\nRETURNS eql_v2.ore_block_u64_8_256\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n terms eql_v2.ore_block_u64_8_256_term[];\nBEGIN\n IF jsonb_typeof(val) = 'null' THEN\n RETURN NULL;\n END IF;\n\n SELECT array_agg(ROW(b)::eql_v2.ore_block_u64_8_256_term)\n INTO terms\n FROM unnest(eql_v2.jsonb_array_to_bytea_array(val)) AS b;\n\n RETURN ROW(terms)::eql_v2.ore_block_u64_8_256;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Extract ORE block index term from JSONB payload\n--!\n--! Extracts the ORE block array from the 'ob' field of an encrypted\n--! data payload. Used internally for range query comparisons.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return eql_v2.ore_block_u64_8_256 ORE block index term\n--! @throws Exception if 'ob' field is missing when ore index is expected\n--!\n--! @see eql_v2.has_ore_block_u64_8_256\n--! @see eql_v2.compare_ore_block_u64_8_256\nCREATE FUNCTION eql_v2.ore_block_u64_8_256(val jsonb)\n RETURNS eql_v2.ore_block_u64_8_256\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF val IS NULL THEN\n RETURN NULL;\n END IF;\n\n IF eql_v2.has_ore_block_u64_8_256(val) THEN\n RETURN eql_v2.jsonb_array_to_ore_block_u64_8_256(val->'ob');\n END IF;\n RAISE 'Expected an ore index (ob) value in json: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Extract ORE block index term from encrypted column value\n--!\n--! Extracts the ORE block from an encrypted column value by accessing\n--! its underlying JSONB data field.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return eql_v2.ore_block_u64_8_256 ORE block index term\n--!\n--! @see eql_v2.ore_block_u64_8_256(jsonb)\nCREATE FUNCTION eql_v2.ore_block_u64_8_256(val eql_v2_encrypted)\n RETURNS eql_v2.ore_block_u64_8_256\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN eql_v2.ore_block_u64_8_256(val.data);\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Check if JSONB payload contains ORE block index term\n--!\n--! Tests whether the encrypted data payload includes an 'ob' field,\n--! indicating an ORE block is available for range queries.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return Boolean True if 'ob' field is present and non-null\n--!\n--! @see eql_v2.ore_block_u64_8_256\nCREATE FUNCTION eql_v2.has_ore_block_u64_8_256(val jsonb)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN val ->> 'ob' IS NOT NULL;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Check if encrypted column value contains ORE block index term\n--!\n--! Tests whether an encrypted column value includes an ORE block\n--! by checking its underlying JSONB data field.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return Boolean True if ORE block is present\n--!\n--! @see eql_v2.has_ore_block_u64_8_256(jsonb)\nCREATE FUNCTION eql_v2.has_ore_block_u64_8_256(val eql_v2_encrypted)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN eql_v2.has_ore_block_u64_8_256(val.data);\n END;\n$$ LANGUAGE plpgsql;\n\n\n\n--! @brief Compare two ORE block terms using cryptographic comparison\n--! @internal\n--!\n--! Performs a three-way comparison (returns -1/0/1) of individual ORE block terms\n--! using the ORE cryptographic protocol. Compares PRP and PRF blocks to determine\n--! ordering without decryption.\n--!\n--! @param a eql_v2.ore_block_u64_8_256_term First ORE term to compare\n--! @param b eql_v2.ore_block_u64_8_256_term Second ORE term to compare\n--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b\n--! @throws Exception if ciphertexts are different lengths\n--!\n--! @note Uses AES-ECB encryption for bit comparisons per ORE protocol\n--! @see eql_v2.compare_ore_block_u64_8_256_terms\nCREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_term(a eql_v2.ore_block_u64_8_256_term, b eql_v2.ore_block_u64_8_256_term)\n RETURNS integer\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n eq boolean := true;\n unequal_block smallint := 0;\n hash_key bytea;\n data_block bytea;\n encrypt_block bytea;\n target_block bytea;\n\n left_block_size CONSTANT smallint := 16;\n right_block_size CONSTANT smallint := 32;\n right_offset CONSTANT smallint := 136; -- 8 * 17\n\n indicator smallint := 0;\n BEGIN\n IF a IS NULL AND b IS NULL THEN\n RETURN 0;\n END IF;\n\n IF a IS NULL THEN\n RETURN -1;\n END IF;\n\n IF b IS NULL THEN\n RETURN 1;\n END IF;\n\n IF bit_length(a.bytes) != bit_length(b.bytes) THEN\n RAISE EXCEPTION 'Ciphertexts are different lengths';\n END IF;\n\n FOR block IN 0..7 LOOP\n -- Compare each PRP (byte from the first 8 bytes) and PRF block (8 byte\n -- chunks of the rest of the value).\n -- NOTE:\n -- * Substr is ordinally indexed (hence 1 and not 0, and 9 and not 8).\n -- * We are not worrying about timing attacks here; don't fret about\n -- the OR or !=.\n IF\n substr(a.bytes, 1 + block, 1) != substr(b.bytes, 1 + block, 1)\n OR substr(a.bytes, 9 + left_block_size * block, left_block_size) != substr(b.bytes, 9 + left_block_size * BLOCK, left_block_size)\n THEN\n -- set the first unequal block we find\n IF eq THEN\n unequal_block := block;\n END IF;\n eq = false;\n END IF;\n END LOOP;\n\n IF eq THEN\n RETURN 0::integer;\n END IF;\n\n -- Hash key is the IV from the right CT of b\n hash_key := substr(b.bytes, right_offset + 1, 16);\n\n -- first right block is at right offset + nonce_size (ordinally indexed)\n target_block := substr(b.bytes, right_offset + 17 + (unequal_block * right_block_size), right_block_size);\n\n data_block := substr(a.bytes, 9 + (left_block_size * unequal_block), left_block_size);\n\n encrypt_block := encrypt(data_block::bytea, hash_key::bytea, 'aes-ecb');\n\n indicator := (\n get_bit(\n encrypt_block,\n 0\n ) + get_bit(target_block, get_byte(a.bytes, unequal_block))) % 2;\n\n IF indicator = 1 THEN\n RETURN 1::integer;\n ELSE\n RETURN -1::integer;\n END IF;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Compare arrays of ORE block terms recursively\n--! @internal\n--!\n--! Recursively compares arrays of ORE block terms element-by-element.\n--! Empty arrays are considered less than non-empty arrays. If the first elements\n--! are equal, recursively compares remaining elements.\n--!\n--! @param a eql_v2.ore_block_u64_8_256_term[] First array of ORE terms\n--! @param b eql_v2.ore_block_u64_8_256_term[] Second array of ORE terms\n--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b, NULL if either array is NULL\n--!\n--! @note Empty arrays sort before non-empty arrays\n--! @see eql_v2.compare_ore_block_u64_8_256_term\nCREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256_term[], b eql_v2.ore_block_u64_8_256_term[])\nRETURNS integer\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n cmp_result integer;\n BEGIN\n\n -- NULLs are NULL\n IF a IS NULL OR b IS NULL THEN\n RETURN NULL;\n END IF;\n\n -- empty a and b\n IF cardinality(a) = 0 AND cardinality(b) = 0 THEN\n RETURN 0;\n END IF;\n\n -- empty a and some b\n IF (cardinality(a) = 0) AND cardinality(b) > 0 THEN\n RETURN -1;\n END IF;\n\n -- some a and empty b\n IF cardinality(a) > 0 AND (cardinality(b) = 0) THEN\n RETURN 1;\n END IF;\n\n cmp_result := eql_v2.compare_ore_block_u64_8_256_term(a[1], b[1]);\n\n IF cmp_result = 0 THEN\n -- Removes the first element in the array, and calls this fn again to compare the next element/s in the array.\n RETURN eql_v2.compare_ore_block_u64_8_256_terms(a[2:array_length(a,1)], b[2:array_length(b,1)]);\n END IF;\n\n RETURN cmp_result;\n END\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Compare ORE block composite types\n--! @internal\n--!\n--! Wrapper function that extracts term arrays from ORE block composite types\n--! and delegates to the array comparison function.\n--!\n--! @param a eql_v2.ore_block_u64_8_256 First ORE block\n--! @param b eql_v2.ore_block_u64_8_256 Second ORE block\n--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b\n--!\n--! @see eql_v2.compare_ore_block_u64_8_256_terms(eql_v2.ore_block_u64_8_256_term[], eql_v2.ore_block_u64_8_256_term[])\nCREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)\nRETURNS integer\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN eql_v2.compare_ore_block_u64_8_256_terms(a.terms, b.terms);\n END\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Extract CLLW ORE index term from a ste_vec entry\n--!\n--! Returns the CLLW ORE ciphertext from the `oc` field of an `sv` element.\n--! `oc` is **only ever present on a `SteVecElement`** in the v2.3 payload\n--! shape — never at the root of an `eql_v2_encrypted` column value — so the\n--! type signature accepts `eql_v2.ste_vec_entry` directly. Callers must\n--! extract first: `eql_v2.ore_cllw(col -> '')`.\n--!\n--! Inlinable single-statement SQL — the planner folds the body into the\n--! calling query so the extractor disappears at planning time. Functional\n--! btree index match on this extractor requires the `eql_v2.ore_cllw_ops`\n--! opclass (installed automatically by the main / protect variants; absent\n--! in the supabase variant).\n--!\n--! **Missing-`oc` semantics**: when the `oc` field is absent, returns a\n--! SQL-level NULL (not a composite with NULL bytes). Btree's standard\n--! NULL handling then filters those rows from range queries: they don't\n--! match `WHERE ore_cllw(col) $1`, they sort at the NULLS LAST end\n--! of `ORDER BY ore_cllw(col)`, and they never reach the comparator.\n--! This avoids the btree FUNCTION 1 contract violation that\n--! `(bytes => NULL)` would otherwise cause (`compare_ore_cllw_term`\n--! must return non-NULL int for non-NULL composite inputs).\n--!\n--! Callers needing a loud RAISE on missing `oc` should check\n--! `eql_v2.has_ore_cllw(entry)` first.\n--!\n--! @param entry eql_v2.ste_vec_entry STE-vec entry (extracted via `->`)\n--! @return eql_v2.ore_cllw Composite carrying the CLLW ciphertext, or\n--! NULL when the `oc` field is absent.\n--!\n--! @see eql_v2.has_ore_cllw\n--! @see eql_v2.compare_ore_cllw_term\n--! @see src/operators/->.sql\nCREATE FUNCTION eql_v2.ore_cllw(entry eql_v2.ste_vec_entry)\n RETURNS eql_v2.ore_cllw\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT CASE WHEN entry ->> 'oc' IS NULL THEN NULL\n ELSE ROW(decode(entry ->> 'oc', 'hex'))::eql_v2.ore_cllw\n END\n$$;\n\n\n--! @brief Extract CLLW ORE index term from raw jsonb (RHS parameter helper)\n--!\n--! Companion overload for `eql_v2.ore_cllw(eql_v2.ste_vec_entry)` that\n--! accepts a raw `jsonb` value. Intended for the right-hand side of\n--! comparisons where the caller binds a literal/parameter jsonb representing\n--! a single ste_vec entry: `... < eql_v2.ore_cllw($1::jsonb)`. The (jsonb)\n--! form skips the domain CHECK constraint so it works for ad-hoc test inputs\n--! and for the GenericComparison case in `eql_v2.compare_ore_cllw_term`.\n--!\n--! Returns SQL-level NULL when the input lacks `oc`, matching the\n--! `(ste_vec_entry)` overload's missing-`oc` semantics so a `WHERE\n--! ore_cllw(col) < ore_cllw($1::jsonb)` with a malformed query needle\n--! evaluates to no rows rather than indexing a NULL-bytes composite.\n--!\n--! @param val jsonb An object carrying an `oc` field\n--! @return eql_v2.ore_cllw Composite carrying the CLLW ciphertext, or\n--! NULL when the `oc` field is absent.\nCREATE FUNCTION eql_v2.ore_cllw(val jsonb)\n RETURNS eql_v2.ore_cllw\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT CASE WHEN val ->> 'oc' IS NULL THEN NULL\n ELSE ROW(decode(val ->> 'oc', 'hex'))::eql_v2.ore_cllw\n END\n$$;\n\n\n--! @brief Check if a ste_vec entry contains a CLLW ORE index term\n--!\n--! Tests whether the entry includes an `oc` field. Inlinable.\n--!\n--! @param entry eql_v2.ste_vec_entry STE-vec entry\n--! @return Boolean True if `oc` field is present and non-null\n--!\n--! @see eql_v2.ore_cllw\nCREATE FUNCTION eql_v2.has_ore_cllw(entry eql_v2.ste_vec_entry)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT entry ->> 'oc' IS NOT NULL\n$$;\n\n\n--! @brief Check if a raw jsonb value contains a CLLW ORE index term\n--!\n--! Companion to `eql_v2.has_ore_cllw(ste_vec_entry)` for raw jsonb inputs.\n--!\n--! @param val jsonb An object that may carry an `oc` field\n--! @return Boolean True if `oc` field is present and non-null\nCREATE FUNCTION eql_v2.has_ore_cllw(val jsonb)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT val ->> 'oc' IS NOT NULL\n$$;\n\n\n--! @brief CLLW per-byte comparison helper\n--! @internal\n--!\n--! Byte-by-byte comparison implementing the CLLW order-revealing protocol.\n--! Used by `eql_v2.compare_ore_cllw_term` for the within-prefix step. The\n--! protocol: identify the index of the first differing byte across both\n--! inputs; if `(y_byte + 1) == x_byte` modulo 256 at that index, then x > y;\n--! otherwise x < y. Equal inputs return 0.\n--!\n--! Inputs MUST be the same length. The caller (`compare_ore_cllw_term`)\n--! guarantees this by passing equal-length prefixes.\n--!\n--! @par Soft constant-time intent\n--! Plpgsql is not a constant-time environment — the interpreter, `SUBSTRING`,\n--! `get_byte`, and the SQL bytea representation all leak timing in ways we\n--! can't control from here. Still, the loop deliberately walks every byte\n--! (no `EXIT` on first difference) and the rotation check uses a bitmask\n--! (`& 255`) instead of `% 256` so that what little timing structure plpgsql\n--! does expose is independent of the position and value of the differing\n--! byte. This is hardening intent, not a guarantee.\n--!\n--! Stays `LANGUAGE plpgsql` — the per-byte loop can't be expressed as a\n--! single inlinable SQL expression. This is the architectural reason ORE\n--! CLLW needs a custom operator class for index match, where OPE does not.\n--!\n--! @param a Bytea First CLLW ciphertext slice\n--! @param b Bytea Second CLLW ciphertext slice\n--! @return Integer -1, 0, or 1\n--! @throws Exception if inputs are different lengths\n--!\n--! @see eql_v2.compare_ore_cllw_term\nCREATE FUNCTION eql_v2.compare_ore_cllw_term_bytes(a bytea, b bytea)\nRETURNS int\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n len_a INT;\n len_b INT;\n i INT;\n first_diff INT := 0;\nBEGIN\n\n len_a := LENGTH(a);\n len_b := LENGTH(b);\n\n IF len_a != len_b THEN\n RAISE EXCEPTION 'ore_cllw index terms are not the same length';\n END IF;\n\n -- Walk every byte, even after a difference is found. Record only the\n -- index of the first difference (1-based; 0 means \"no difference\").\n -- Avoids an early `EXIT` whose presence is itself a timing signal.\n FOR i IN 1..len_a LOOP\n IF first_diff = 0 AND get_byte(a, i - 1) != get_byte(b, i - 1) THEN\n first_diff := i;\n END IF;\n END LOOP;\n\n IF first_diff = 0 THEN\n RETURN 0;\n END IF;\n\n -- Bitmask instead of `% 256` — the modulo's operand is a power of two\n -- so the two are arithmetically equivalent, but `& 255` is a single\n -- machine instruction with no division-related timing variance.\n IF ((get_byte(b, first_diff - 1) + 1) & 255) = get_byte(a, first_diff - 1) THEN\n RETURN 1;\n ELSE\n RETURN -1;\n END IF;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Variable-length CLLW ORE term comparison\n--! @internal\n--!\n--! Three-way comparison of two CLLW ORE ciphertext terms of potentially\n--! different lengths. Compares the shared prefix via the CLLW per-byte\n--! protocol; on equal prefixes, the shorter input sorts first.\n--!\n--! Handles both numeric (Standard-mode 65-byte CLLW outputs from the u64\n--! variant) and string (variable-length CLLW outputs) by virtue of the\n--! domain-tag byte being the first byte of `bytes`. A numeric/string pair\n--! differs at byte 0 (`0x00` vs `0x01`), which the CLLW rule resolves\n--! correctly to numeric < string.\n--!\n--! Stays `LANGUAGE plpgsql` because it dispatches to\n--! `compare_ore_cllw_term_bytes`, which can't be inlined.\n--!\n--! @par Null handling — btree FUNCTION 1 contract\n--! PostgreSQL's btree filters NULL composites at the row level, so this\n--! function should never be called with `a IS NULL` or `b IS NULL` under\n--! normal operation. The leading IS-NULL guard returns NULL defensively\n--! to cover edge cases (e.g., a non-index `ORDER BY` or `WHERE` path\n--! that bypasses the opclass).\n--!\n--! A composite that is non-NULL but whose `bytes` field is NULL is a\n--! contract violation: btree expects FUNCTION 1 to return a non-NULL\n--! integer for non-NULL composite inputs. The extractor overloads of\n--! `eql_v2.ore_cllw` are designed to return SQL NULL (not `ROW(NULL)`)\n--! when the source payload lacks `oc`, so a NULL-bytes composite should\n--! only arise from a hand-crafted literal or a future field addition to\n--! the composite type. Raise loudly to surface the bug instead of\n--! producing silent misordering downstream.\n--!\n--! @param a eql_v2.ore_cllw First term\n--! @param b eql_v2.ore_cllw Second term\n--! @return Integer -1, 0, or 1; NULL if either composite is NULL\n--! @throws Exception if either composite has a NULL `bytes` field\n--!\n--! @see eql_v2.compare_ore_cllw_term_bytes\n--! @see eql_v2.compare_ore_cllw\nCREATE FUNCTION eql_v2.compare_ore_cllw_term(a eql_v2.ore_cllw, b eql_v2.ore_cllw)\nRETURNS int\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n len_a INT;\n len_b INT;\n common_len INT;\n cmp_result INT;\nBEGIN\n -- Composite-level NULL: btree's null-handling layer filters these at\n -- the row level under normal operation. Returning NULL covers\n -- non-index code paths that might still reach here.\n IF a IS NULL OR b IS NULL THEN\n RETURN NULL;\n END IF;\n\n -- Non-NULL composite with NULL bytes is a contract violation: btree's\n -- FUNCTION 1 must return non-NULL int for non-NULL composite inputs.\n -- The extractors return SQL NULL (not ROW(NULL)) on missing `oc`, so\n -- reaching here means a hand-crafted literal or a regression in the\n -- extractor body. Raise loudly rather than silently misorder.\n IF a.bytes IS NULL OR b.bytes IS NULL THEN\n RAISE EXCEPTION 'eql_v2.compare_ore_cllw_term: composite has NULL bytes field — extractor invariant violated. Check that the index expression uses eql_v2.ore_cllw(...) and not a hand-crafted ROW(NULL).';\n END IF;\n\n len_a := LENGTH(a.bytes);\n len_b := LENGTH(b.bytes);\n\n IF len_a = 0 AND len_b = 0 THEN\n RETURN 0;\n ELSIF len_a = 0 THEN\n RETURN -1;\n ELSIF len_b = 0 THEN\n RETURN 1;\n END IF;\n\n IF len_a < len_b THEN\n common_len := len_a;\n ELSE\n common_len := len_b;\n END IF;\n\n cmp_result := eql_v2.compare_ore_cllw_term_bytes(\n SUBSTRING(a.bytes FROM 1 FOR common_len),\n SUBSTRING(b.bytes FROM 1 FOR common_len)\n );\n\n IF cmp_result = -1 THEN\n RETURN -1;\n ELSIF cmp_result = 1 THEN\n RETURN 1;\n END IF;\n\n -- Equal prefixes: shorter sorts first\n IF len_a < len_b THEN\n RETURN -1;\n ELSIF len_a > len_b THEN\n RETURN 1;\n ELSE\n RETURN 0;\n END IF;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n\n--! @brief Convert JSONB to encrypted type\n--!\n--! Wraps a JSONB encrypted payload into the eql_v2_encrypted composite type.\n--! Used internally for type conversions and operator implementations.\n--!\n--! @param jsonb JSONB encrypted payload with structure: {\"c\": \"...\", \"i\": {...}, \"k\": \"...\", \"v\": \"2\"}\n--! @return eql_v2_encrypted Encrypted value wrapped in composite type\n--!\n--! @note This is primarily used for implicit casts in operator expressions\n--! @see eql_v2.to_jsonb\nCREATE FUNCTION eql_v2.to_encrypted(data jsonb)\n RETURNS public.eql_v2_encrypted\n IMMUTABLE STRICT PARALLEL SAFE\n LANGUAGE SQL\nAS $$\n SELECT ROW(data)::public.eql_v2_encrypted;\n$$;\n\n\n--! @brief Implicit cast from JSONB to encrypted type\n--!\n--! Enables PostgreSQL to automatically convert JSONB values to eql_v2_encrypted\n--! in assignment contexts and comparison operations.\n--!\n--! @see eql_v2.to_encrypted(jsonb)\nCREATE CAST (jsonb AS public.eql_v2_encrypted)\n\tWITH FUNCTION eql_v2.to_encrypted(jsonb) AS ASSIGNMENT;\n\n\n--! @brief Convert text to encrypted type\n--!\n--! Parses a text representation of encrypted JSONB payload and wraps it\n--! in the eql_v2_encrypted composite type.\n--!\n--! @param text Text representation of JSONB encrypted payload\n--! @return eql_v2_encrypted Encrypted value wrapped in composite type\n--!\n--! @note Delegates to eql_v2.to_encrypted(jsonb) after parsing text as JSON\n--! @see eql_v2.to_encrypted(jsonb)\nCREATE FUNCTION eql_v2.to_encrypted(data text)\n RETURNS public.eql_v2_encrypted\n IMMUTABLE STRICT PARALLEL SAFE\n LANGUAGE SQL\nAS $$\n SELECT eql_v2.to_encrypted(data::jsonb);\n$$;\n\n\n--! @brief Implicit cast from text to encrypted type\n--!\n--! Enables PostgreSQL to automatically convert text JSON strings to eql_v2_encrypted\n--! in assignment contexts.\n--!\n--! @see eql_v2.to_encrypted(text)\nCREATE CAST (text AS public.eql_v2_encrypted)\n\tWITH FUNCTION eql_v2.to_encrypted(text) AS ASSIGNMENT;\n\n\n\n--! @brief Convert encrypted type to JSONB\n--!\n--! Extracts the underlying JSONB payload from an eql_v2_encrypted composite type.\n--! Useful for debugging or when raw encrypted payload access is needed.\n--!\n--! @param e eql_v2_encrypted Encrypted value to unwrap\n--! @return jsonb Raw JSONB encrypted payload\n--!\n--! @note Returns the raw encrypted structure including ciphertext and index terms\n--! @see eql_v2.to_encrypted(jsonb)\nCREATE FUNCTION eql_v2.to_jsonb(e public.eql_v2_encrypted)\n RETURNS jsonb\n IMMUTABLE STRICT PARALLEL SAFE\n LANGUAGE SQL\nAS $$\n SELECT e.data;\n$$;\n\n--! @brief Implicit cast from encrypted type to JSONB\n--!\n--! Enables PostgreSQL to automatically extract the JSONB payload from\n--! eql_v2_encrypted values in assignment contexts.\n--!\n--! @see eql_v2.to_jsonb(eql_v2_encrypted)\nCREATE CAST (public.eql_v2_encrypted AS jsonb)\n\tWITH FUNCTION eql_v2.to_jsonb(public.eql_v2_encrypted) AS ASSIGNMENT;\n\n\n\n\n\n--! @brief Compare two encrypted values using HMAC-SHA256 index terms\n--!\n--! Performs a three-way comparison (returns -1/0/1) of encrypted values using\n--! their HMAC-SHA256 hash index terms. Used internally by the equality operator (=)\n--! for exact-match queries without decryption.\n--!\n--! @param a eql_v2_encrypted First encrypted value to compare\n--! @param b eql_v2_encrypted Second encrypted value to compare\n--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b\n--!\n--! @note NULL values are sorted before non-NULL values\n--! @note Comparison uses underlying text type ordering of HMAC-SHA256 hashes\n--!\n--! @see eql_v2.hmac_256\n--! @see eql_v2.has_hmac_256\n--! @see eql_v2.\"=\"\nCREATE FUNCTION eql_v2.compare_hmac_256(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS integer\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n a_term eql_v2.hmac_256;\n b_term eql_v2.hmac_256;\n BEGIN\n\n IF a IS NULL AND b IS NULL THEN\n RETURN 0;\n END IF;\n\n IF a IS NULL THEN\n RETURN -1;\n END IF;\n\n IF b IS NULL THEN\n RETURN 1;\n END IF;\n\n IF eql_v2.has_hmac_256(a) THEN\n a_term = eql_v2.hmac_256(a);\n END IF;\n\n IF eql_v2.has_hmac_256(b) THEN\n b_term = eql_v2.hmac_256(b);\n END IF;\n\n IF a_term IS NULL AND b_term IS NULL THEN\n RETURN 0;\n END IF;\n\n IF a_term IS NULL THEN\n RETURN -1;\n END IF;\n\n IF b_term IS NULL THEN\n RETURN 1;\n END IF;\n\n -- Using the underlying text type comparison\n IF a_term = b_term THEN\n RETURN 0;\n END IF;\n\n IF a_term < b_term THEN\n RETURN -1;\n END IF;\n\n IF a_term > b_term THEN\n RETURN 1;\n END IF;\n\n END;\n$$ LANGUAGE plpgsql;\n\n\n\n--! @file src/operators/compare.sql\n--! @brief Three-way ordering on the root `eql_v2_encrypted` type\n--!\n--! Returns `-1` / `0` / `1` for two encrypted column values that carry\n--! Block ORE (`ob`) terms at the root. Used by the btree operator class on\n--! `eql_v2_encrypted` (FUNCTION 1), by the legacy `eql_v2.lt` / `lte` /\n--! `gt` / `gte` helpers, and by `sort_compare`'s `strategy = 'compare'`\n--! fallback path.\n--!\n--! **Strict Block-ORE-only contract.** Root-level `eql_v2_encrypted` values\n--! only carry root-scope ORE terms (`ob`) per the v2.3 payload shape — the\n--! `oc` field (CLLW ORE) is sv-element scope only and never appears on a\n--! root payload. Equality on `eql_v2_encrypted` is hm-only and runs through\n--! the inlined `=` / `<>` operators (post-#193) — it does *not* go through\n--! this function. For sv-element ordering, use the typed\n--! `eql_v2.compare(eql_v2.ste_vec_entry, eql_v2.ste_vec_entry)` overload\n--! (or the `<` / `<=` / `>` / `>=` operators on the same pair).\n--!\n--! @param a eql_v2_encrypted First encrypted value (STRICT — NULL inputs short-circuit to NULL)\n--! @param b eql_v2_encrypted Second encrypted value (STRICT — NULL inputs short-circuit to NULL)\n--! @return integer -1, 0, or 1\n--!\n--! @throws Exception when either value lacks an `ob` (Block ORE) term\n--!\n--! @see eql_v2.compare_ore_block_u64_8_256\n--! @see eql_v2.compare(eql_v2.ste_vec_entry, eql_v2.ste_vec_entry)\n--! @see eql_v2.\"=\" -- hm-only equality, post-#193 inlining\nCREATE FUNCTION eql_v2.compare(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS integer\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n IF eql_v2.has_ore_block_u64_8_256(a) AND eql_v2.has_ore_block_u64_8_256(b) THEN\n RETURN eql_v2.compare_ore_block_u64_8_256(a, b);\n END IF;\n\n RAISE EXCEPTION\n 'eql_v2.compare requires Block ORE (`ob`) on both root operands. For sv-element ordering, extract entries via `col -> ''''` and use eql_v2.compare on the resulting `eql_v2.ste_vec_entry` values (or their `<` / `<=` / `>` / `>=` operators). Equality is hmac-only via the `=` operator — this function is for ordering only.'\n USING ERRCODE = 'feature_not_supported';\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Three-way ordering on `eql_v2.ste_vec_entry`\n--!\n--! CLLW ORE three-way comparator on ste-vec entries. Returns `-1` / `0` /\n--! `1` by extracting the `oc` term from each entry and delegating to\n--! `eql_v2.compare_ore_cllw_term`. Use this when you need an `int` ordering\n--! out of two extracted ste-vec entries — for the boolean-form operators\n--! (`<` / `<=` / `>` / `>=`) on the same pair, see\n--! `src/operators/ste_vec_entry.sql`.\n--!\n--! Note: the caller is responsible for extracting an `eql_v2.ste_vec_entry`\n--! first; the `(eql_v2_encrypted, text)` form would be a natural extension\n--! but is deliberately *not* added here so that callers stay aware of the\n--! two-step shape (extract via `->`, then compare).\n--!\n--! @param a eql_v2.ste_vec_entry First entry\n--! @param b eql_v2.ste_vec_entry Second entry\n--! @return integer -1, 0, or 1\n--!\n--! @throws Exception when either entry lacks an `oc` term\n--!\n--! @see eql_v2.compare_ore_cllw_term\n--! @see src/operators/ste_vec_entry.sql\nCREATE FUNCTION eql_v2.compare(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry)\n RETURNS integer\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n IF NOT (eql_v2.has_ore_cllw(a) AND eql_v2.has_ore_cllw(b)) THEN\n RAISE EXCEPTION\n 'eql_v2.compare(ste_vec_entry, ste_vec_entry) requires `oc` (CLLW ORE) on both entries.'\n USING ERRCODE = 'feature_not_supported';\n END IF;\n\n RETURN eql_v2.compare_ore_cllw_term(eql_v2.ore_cllw(a), eql_v2.ore_cllw(b));\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Equality operator for ORE block types\n--! @internal\n--!\n--! Implements the = operator for direct ORE block comparisons.\n--!\n--! @param a eql_v2.ore_block_u64_8_256 Left operand\n--! @param b eql_v2.ore_block_u64_8_256 Right operand\n--! @return Boolean True if ORE blocks are equal\n--!\n--! @see eql_v2.compare_ore_block_u64_8_256_terms\nCREATE FUNCTION eql_v2.ore_block_u64_8_256_eq(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)\nRETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = 0\n$$;\n\n\n\n--! @brief Not equal operator for ORE block types\n--! @internal\n--!\n--! Implements the <> operator for direct ORE block comparisons.\n--!\n--! @param a eql_v2.ore_block_u64_8_256 Left operand\n--! @param b eql_v2.ore_block_u64_8_256 Right operand\n--! @return Boolean True if ORE blocks are not equal\n--!\n--! @see eql_v2.compare_ore_block_u64_8_256_terms\nCREATE FUNCTION eql_v2.ore_block_u64_8_256_neq(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)\nRETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) <> 0\n$$;\n\n\n\n--! @brief Less than operator for ORE block types\n--! @internal\n--!\n--! Implements the < operator for direct ORE block comparisons.\n--!\n--! @param a eql_v2.ore_block_u64_8_256 Left operand\n--! @param b eql_v2.ore_block_u64_8_256 Right operand\n--! @return Boolean True if left operand is less than right operand\n--!\n--! @see eql_v2.compare_ore_block_u64_8_256_terms\nCREATE FUNCTION eql_v2.ore_block_u64_8_256_lt(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)\nRETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = -1\n$$;\n\n\n\n--! @brief Less than or equal operator for ORE block types\n--! @internal\n--!\n--! Implements the <= operator for direct ORE block comparisons.\n--!\n--! @param a eql_v2.ore_block_u64_8_256 Left operand\n--! @param b eql_v2.ore_block_u64_8_256 Right operand\n--! @return Boolean True if left operand is less than or equal to right operand\n--!\n--! @see eql_v2.compare_ore_block_u64_8_256_terms\nCREATE FUNCTION eql_v2.ore_block_u64_8_256_lte(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)\nRETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) != 1\n$$;\n\n\n\n--! @brief Greater than operator for ORE block types\n--! @internal\n--!\n--! Implements the > operator for direct ORE block comparisons.\n--!\n--! @param a eql_v2.ore_block_u64_8_256 Left operand\n--! @param b eql_v2.ore_block_u64_8_256 Right operand\n--! @return Boolean True if left operand is greater than right operand\n--!\n--! @see eql_v2.compare_ore_block_u64_8_256_terms\nCREATE FUNCTION eql_v2.ore_block_u64_8_256_gt(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)\nRETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = 1\n$$;\n\n\n\n--! @brief Greater than or equal operator for ORE block types\n--! @internal\n--!\n--! Implements the >= operator for direct ORE block comparisons.\n--!\n--! @param a eql_v2.ore_block_u64_8_256 Left operand\n--! @param b eql_v2.ore_block_u64_8_256 Right operand\n--! @return Boolean True if left operand is greater than or equal to right operand\n--!\n--! @see eql_v2.compare_ore_block_u64_8_256_terms\nCREATE FUNCTION eql_v2.ore_block_u64_8_256_gte(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256)\nRETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) != -1\n$$;\n\n\n\n--! @brief = operator for ORE block types\nCREATE OPERATOR = (\n FUNCTION=eql_v2.ore_block_u64_8_256_eq,\n LEFTARG=eql_v2.ore_block_u64_8_256,\n RIGHTARG=eql_v2.ore_block_u64_8_256,\n NEGATOR = <>,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n HASHES,\n MERGES\n);\n\n\n\n--! @brief <> operator for ORE block types\nCREATE OPERATOR <> (\n FUNCTION=eql_v2.ore_block_u64_8_256_neq,\n LEFTARG=eql_v2.ore_block_u64_8_256,\n RIGHTARG=eql_v2.ore_block_u64_8_256,\n NEGATOR = =,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n HASHES,\n MERGES\n);\n\n\n--! @brief > operator for ORE block types\nCREATE OPERATOR > (\n FUNCTION=eql_v2.ore_block_u64_8_256_gt,\n LEFTARG=eql_v2.ore_block_u64_8_256,\n RIGHTARG=eql_v2.ore_block_u64_8_256,\n COMMUTATOR = <,\n NEGATOR = <=,\n RESTRICT = scalargtsel,\n JOIN = scalargtjoinsel\n);\n\n\n\n--! @brief < operator for ORE block types\nCREATE OPERATOR < (\n FUNCTION=eql_v2.ore_block_u64_8_256_lt,\n LEFTARG=eql_v2.ore_block_u64_8_256,\n RIGHTARG=eql_v2.ore_block_u64_8_256,\n COMMUTATOR = >,\n NEGATOR = >=,\n RESTRICT = scalarltsel,\n JOIN = scalarltjoinsel\n);\n\n\n\n--! @brief <= operator for ORE block types\nCREATE OPERATOR <= (\n FUNCTION=eql_v2.ore_block_u64_8_256_lte,\n LEFTARG=eql_v2.ore_block_u64_8_256,\n RIGHTARG=eql_v2.ore_block_u64_8_256,\n COMMUTATOR = >=,\n NEGATOR = >,\n RESTRICT = scalarlesel,\n JOIN = scalarlejoinsel\n);\n\n\n\n--! @brief >= operator for ORE block types\nCREATE OPERATOR >= (\n FUNCTION=eql_v2.ore_block_u64_8_256_gte,\n LEFTARG=eql_v2.ore_block_u64_8_256,\n RIGHTARG=eql_v2.ore_block_u64_8_256,\n COMMUTATOR = <=,\n NEGATOR = <,\n RESTRICT = scalargesel,\n JOIN = scalargejoinsel\n);\n\n\n--! @brief Extract STE vector index from JSONB payload\n--!\n--! Extracts the STE (Searchable Symmetric Encryption) vector from the 'sv' field\n--! of an encrypted data payload. Returns an array of encrypted values used for\n--! containment queries (@>, <@). If no 'sv' field exists, wraps the entire payload\n--! as a single-element array.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return eql_v2_encrypted[] Array of encrypted STE vector elements\n--!\n--! @see eql_v2.ste_vec(eql_v2_encrypted)\n--! @see eql_v2.ste_vec_contains\nCREATE FUNCTION eql_v2.ste_vec(val jsonb)\n RETURNS public.eql_v2_encrypted[]\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n sv jsonb;\n ary public.eql_v2_encrypted[];\n\tBEGIN\n\n IF val ? 'sv' THEN\n sv := val->'sv';\n ELSE\n sv := jsonb_build_array(val);\n END IF;\n\n SELECT array_agg(eql_v2.to_encrypted(elem))\n INTO ary\n FROM jsonb_array_elements(sv) AS elem;\n\n RETURN ary;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Extract STE vector index from encrypted column value\n--!\n--! Extracts the STE vector from an encrypted column value by accessing its\n--! underlying JSONB data field. Used for containment query operations.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return eql_v2_encrypted[] Array of encrypted STE vector elements\n--!\n--! @see eql_v2.ste_vec(jsonb)\nCREATE FUNCTION eql_v2.ste_vec(val eql_v2_encrypted)\n RETURNS public.eql_v2_encrypted[]\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN (SELECT eql_v2.ste_vec(val.data));\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Check if JSONB payload is a single-element STE vector\n--!\n--! Tests whether the encrypted data payload contains an 'sv' field with exactly\n--! one element. Single-element STE vectors can be treated as regular encrypted values.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return Boolean True if 'sv' field exists with exactly one element\n--!\n--! @see eql_v2.to_ste_vec_value\nCREATE FUNCTION eql_v2.is_ste_vec_value(val jsonb)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF val ? 'sv' THEN\n RETURN jsonb_array_length(val->'sv') = 1;\n END IF;\n\n RETURN false;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Check if encrypted column value is a single-element STE vector\n--!\n--! Tests whether an encrypted column value is a single-element STE vector\n--! by checking its underlying JSONB data field.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return Boolean True if value is a single-element STE vector\n--!\n--! @see eql_v2.is_ste_vec_value(jsonb)\nCREATE FUNCTION eql_v2.is_ste_vec_value(val eql_v2_encrypted)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN eql_v2.is_ste_vec_value(val.data);\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Convert single-element STE vector to regular encrypted value\n--!\n--! Extracts the single element from a single-element STE vector and returns it\n--! as a regular encrypted value, preserving metadata. If the input is not a\n--! single-element STE vector, returns it unchanged.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return eql_v2_encrypted Regular encrypted value (unwrapped if single-element STE vector)\n--!\n--! @see eql_v2.is_ste_vec_value\nCREATE FUNCTION eql_v2.to_ste_vec_value(val jsonb)\n RETURNS eql_v2_encrypted\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n meta jsonb;\n sv jsonb;\n\tBEGIN\n\n IF val IS NULL THEN\n RETURN NULL;\n END IF;\n\n IF eql_v2.is_ste_vec_value(val) THEN\n meta := eql_v2.meta_data(val);\n sv := val->'sv';\n sv := sv[0];\n\n RETURN eql_v2.to_encrypted(meta || sv);\n END IF;\n\n RETURN eql_v2.to_encrypted(val);\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Convert single-element STE vector to regular encrypted value (encrypted type)\n--!\n--! Converts an encrypted column value to a regular encrypted value by unwrapping\n--! if it's a single-element STE vector.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return eql_v2_encrypted Regular encrypted value (unwrapped if single-element STE vector)\n--!\n--! @see eql_v2.to_ste_vec_value(jsonb)\nCREATE FUNCTION eql_v2.to_ste_vec_value(val eql_v2_encrypted)\n RETURNS eql_v2_encrypted\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN eql_v2.to_ste_vec_value(val.data);\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Extract selector value from JSONB payload\n--!\n--! Extracts the selector ('s') field from an encrypted data payload.\n--! Selectors are used to match STE vector elements during containment queries.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return Text The selector value\n--! @throws Exception if 's' field is missing\n--!\n--! @see eql_v2.ste_vec_contains\nCREATE FUNCTION eql_v2.selector(val jsonb)\n RETURNS text\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF val IS NULL THEN\n RETURN NULL;\n END IF;\n\n IF val ? 's' THEN\n RETURN val->>'s';\n END IF;\n RAISE 'Expected a selector index (s) value in json: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Extract selector value from encrypted column value\n--! @internal\n--!\n--! Internal convenience: unwraps the encrypted composite and delegates\n--! to `eql_v2.selector(jsonb)`. Exists so the encrypted-selector\n--! overloads of `eql_v2.\"->\"` / `eql_v2.\"->>\"` / `eql_v2.jsonb_path_*`\n--! can dispatch without each having to spell out `(val).data` first.\n--! Not part of the public API — callers should use\n--! `eql_v2.selector(jsonb)` or `eql_v2.selector(eql_v2.ste_vec_entry)`.\n--!\n--! @param eql_v2_encrypted Encrypted column value (single-element form)\n--! @return Text The selector value\n--!\n--! @see eql_v2.selector(jsonb)\n--! @see eql_v2.selector(eql_v2.ste_vec_entry)\nCREATE FUNCTION eql_v2._selector(val eql_v2_encrypted)\n RETURNS text\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN (SELECT eql_v2.selector(val.data));\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Extract selector value from a ste_vec entry\n--!\n--! Direct overload on the domain type. The DOMAIN's CHECK constraint\n--! already guarantees `s` is present, so this is a simple field access.\n--!\n--! @param entry eql_v2.ste_vec_entry STE-vec entry\n--! @return Text The selector value\n--!\n--! @see eql_v2.selector(jsonb)\nCREATE FUNCTION eql_v2.selector(entry eql_v2.ste_vec_entry)\n RETURNS text\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT entry ->> 's'\n$$;\n\n\n\n--! @brief Check if JSONB payload is marked as an STE vector array\n--!\n--! Tests whether the encrypted data payload has the 'a' (array) flag set to true,\n--! indicating it represents an array for STE vector operations.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return Boolean True if 'a' field is present and true\n--!\n--! @see eql_v2.ste_vec\nCREATE FUNCTION eql_v2.is_ste_vec_array(val jsonb)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF val ? 'a' THEN\n RETURN (val->>'a')::boolean;\n END IF;\n\n RETURN false;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Check if encrypted column value is marked as an STE vector array\n--!\n--! Tests whether an encrypted column value has the array flag set by checking\n--! its underlying JSONB data field.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return Boolean True if value is marked as an STE vector array\n--!\n--! @see eql_v2.is_ste_vec_array(jsonb)\nCREATE FUNCTION eql_v2.is_ste_vec_array(val eql_v2_encrypted)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN (SELECT eql_v2.is_ste_vec_array(val.data));\n END;\n$$ LANGUAGE plpgsql;\n\n\n\n--! @brief Extract full encrypted JSONB elements as array\n--!\n--! Extracts all JSONB elements from the STE vector including non-deterministic fields.\n--! Use jsonb_array() instead for GIN indexing and containment queries.\n--!\n--! @param val jsonb containing encrypted EQL payload\n--! @return jsonb[] Array of full JSONB elements\n--!\n--! @see eql_v2.jsonb_array\nCREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val jsonb)\nRETURNS jsonb[]\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT CASE\n WHEN val ? 'sv' THEN\n ARRAY(SELECT elem FROM jsonb_array_elements(val->'sv') AS elem)\n ELSE\n ARRAY[val]\n END;\n$$;\n\n\n--! @brief Extract full encrypted JSONB elements as array from encrypted column\n--!\n--! @param val eql_v2_encrypted Encrypted column value\n--! @return jsonb[] Array of full JSONB elements\n--!\n--! @see eql_v2.jsonb_array_from_array_elements(jsonb)\nCREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val eql_v2_encrypted)\nRETURNS jsonb[]\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT eql_v2.jsonb_array_from_array_elements(val.data);\n$$;\n\n\n--! @brief Extract deterministic fields as array for GIN indexing\n--!\n--! Extracts only deterministic search term fields (`s`, `hm`, `oc`, `op`)\n--! from each STE vector element. Excludes non-deterministic ciphertext for\n--! correct containment comparison using PostgreSQL's native `@>` operator.\n--!\n--! Field set: selector (`s`), HMAC equality (`hm`), ORE CLLW (`oc`,\n--! Standard-mode), OPE CLLW (`op`, Compat-mode). The pre-2.3 fields\n--! (`b3` / `ocf` / `ocv` / `opf` / `opv`) are no longer emitted — see U-004\n--! and U-006 in `docs/upgrading/v2.3.md`.\n--!\n--! @param val jsonb containing encrypted EQL payload\n--! @return jsonb[] Array of JSONB elements with only deterministic fields\n--!\n--! @note Use this for GIN indexes and containment queries\n--! @see eql_v2.jsonb_contains\nCREATE FUNCTION eql_v2.jsonb_array(val jsonb)\nRETURNS jsonb[]\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT ARRAY(\n SELECT jsonb_object_agg(kv.key, kv.value)\n FROM jsonb_array_elements(\n CASE WHEN val ? 'sv' THEN val->'sv' ELSE jsonb_build_array(val) END\n ) AS elem,\n LATERAL jsonb_each(elem) AS kv(key, value)\n WHERE kv.key IN ('s', 'hm', 'oc', 'op')\n GROUP BY elem\n );\n$$;\n\n\n--! @brief Extract deterministic fields as array from encrypted column\n--!\n--! @param val eql_v2_encrypted Encrypted column value\n--! @return jsonb[] Array of JSONB elements with only deterministic fields\n--!\n--! @see eql_v2.jsonb_array(jsonb)\nCREATE FUNCTION eql_v2.jsonb_array(val eql_v2_encrypted)\nRETURNS jsonb[]\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT eql_v2.jsonb_array(val.data);\n$$;\n\n\n--! @brief GIN-indexable JSONB containment check\n--!\n--! Checks if encrypted value 'a' contains all JSONB elements from 'b'.\n--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.\n--!\n--! This function is designed for use with a GIN index on jsonb_array(column).\n--! When combined with such an index, PostgreSQL can efficiently search large tables.\n--!\n--! @param a eql_v2_encrypted Container value (typically a table column)\n--! @param b eql_v2_encrypted Value to search for\n--! @return Boolean True if a contains all elements of b\n--!\n--! @example\n--! -- Create GIN index for efficient containment queries\n--! CREATE INDEX idx ON mytable USING GIN (eql_v2.jsonb_array(encrypted_col));\n--!\n--! -- Query using the helper function\n--! SELECT * FROM mytable WHERE eql_v2.jsonb_contains(encrypted_col, search_value);\n--!\n--! @see eql_v2.jsonb_array\nCREATE FUNCTION eql_v2.jsonb_contains(a eql_v2_encrypted, b eql_v2_encrypted)\nRETURNS boolean\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b);\n$$;\n\n\n--! @brief GIN-indexable JSONB containment check (encrypted, jsonb)\n--!\n--! Checks if encrypted value 'a' contains all JSONB elements from jsonb value 'b'.\n--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.\n--!\n--! @param a eql_v2_encrypted Container value (typically a table column)\n--! @param b jsonb JSONB value to search for\n--! @return Boolean True if a contains all elements of b\n--!\n--! @see eql_v2.jsonb_array\n--! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.jsonb_contains(a eql_v2_encrypted, b jsonb)\nRETURNS boolean\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b);\n$$;\n\n\n--! @brief GIN-indexable JSONB containment check (jsonb, encrypted)\n--!\n--! Checks if jsonb value 'a' contains all JSONB elements from encrypted value 'b'.\n--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.\n--!\n--! @param a jsonb Container JSONB value\n--! @param b eql_v2_encrypted Encrypted value to search for\n--! @return Boolean True if a contains all elements of b\n--!\n--! @see eql_v2.jsonb_array\n--! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.jsonb_contains(a jsonb, b eql_v2_encrypted)\nRETURNS boolean\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b);\n$$;\n\n\n--! @brief GIN-indexable JSONB \"is contained by\" check\n--!\n--! Checks if all JSONB elements from 'a' are contained in 'b'.\n--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.\n--!\n--! @param a eql_v2_encrypted Value to check (typically a table column)\n--! @param b eql_v2_encrypted Container value\n--! @return Boolean True if all elements of a are contained in b\n--!\n--! @see eql_v2.jsonb_array\n--! @see eql_v2.jsonb_contains\nCREATE FUNCTION eql_v2.jsonb_contained_by(a eql_v2_encrypted, b eql_v2_encrypted)\nRETURNS boolean\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b);\n$$;\n\n\n--! @brief GIN-indexable JSONB \"is contained by\" check (encrypted, jsonb)\n--!\n--! Checks if all JSONB elements from encrypted value 'a' are contained in jsonb value 'b'.\n--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.\n--!\n--! @param a eql_v2_encrypted Value to check (typically a table column)\n--! @param b jsonb Container JSONB value\n--! @return Boolean True if all elements of a are contained in b\n--!\n--! @see eql_v2.jsonb_array\n--! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.jsonb_contained_by(a eql_v2_encrypted, b jsonb)\nRETURNS boolean\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b);\n$$;\n\n\n--! @brief GIN-indexable JSONB \"is contained by\" check (jsonb, encrypted)\n--!\n--! Checks if all JSONB elements from jsonb value 'a' are contained in encrypted value 'b'.\n--! Uses jsonb[] arrays internally for native PostgreSQL GIN index support.\n--!\n--! @param a jsonb Value to check\n--! @param b eql_v2_encrypted Container encrypted value\n--! @return Boolean True if all elements of a are contained in b\n--!\n--! @see eql_v2.jsonb_array\n--! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.jsonb_contained_by(a jsonb, b eql_v2_encrypted)\nRETURNS boolean\nIMMUTABLE STRICT PARALLEL SAFE\nLANGUAGE SQL\nAS $$\n SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b);\n$$;\n\n\n--! @brief Check if STE vector array contains a specific encrypted element\n--!\n--! Tests whether any element in the STE vector array 'a' contains the encrypted value 'b'.\n--! Matching requires both the selector and encrypted value to be equal.\n--! Used internally by ste_vec_contains(encrypted, encrypted) for array containment checks.\n--!\n--! @param eql_v2_encrypted[] STE vector array to search within\n--! @param eql_v2_encrypted Encrypted element to search for\n--! @return Boolean True if b is found in any element of a\n--!\n--! @note Compares both selector and encrypted value for match\n--!\n--! @see eql_v2.selector\n--! @see eql_v2.ste_vec_contains(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.ste_vec_contains(a public.eql_v2_encrypted[], b eql_v2_encrypted)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n result boolean;\n _a public.eql_v2_encrypted;\n BEGIN\n\n result := false;\n\n FOR idx IN 1..array_length(a, 1) LOOP\n _a := a[idx];\n -- Element-level match for ste_vec entries.\n --\n -- Per the v2.3 sv-element contract (encoded in\n -- `docs/reference/schema/eql-payload-v2.3.schema.json` and the\n -- `eql_v2.ste_vec_entry` DOMAIN), each entry carries **exactly\n -- one** of:\n -- - `hm` — HMAC-256 for boolean leaves and for the placeholder\n -- entries that represent array / object roots.\n -- - `oc` — CLLW ORE for string and number leaves.\n -- Both terms are deterministic for the same plaintext at the same\n -- selector under the same workspace, so either one serves as the\n -- equality discriminator. A selector configures the leaf's role\n -- (eq / ordered), and the role determines which term is emitted —\n -- two sv entries with the same selector therefore always carry\n -- the same term type.\n --\n -- The selector check is a fast-path gate so we don't compare\n -- terms across mismatched fields. Once selectors match, exactly\n -- one of the two CASE branches fires (XOR contract above).\n --\n -- The `ELSE false` arm covers the malformed case (entry carries\n -- neither term, or only one side has the term for a given role).\n -- That's a data error rather than a normal containment result,\n -- but returning false is safer than raising mid-array-scan.\n result := result OR (\n eql_v2._selector(_a) = eql_v2._selector(b) AND\n CASE\n WHEN eql_v2.has_hmac_256(_a) AND eql_v2.has_hmac_256(b) THEN\n eql_v2.compare_hmac_256(_a, b) = 0\n WHEN eql_v2.has_ore_cllw((_a).data) AND eql_v2.has_ore_cllw((b).data) THEN\n eql_v2.compare_ore_cllw_term(\n eql_v2.ore_cllw((_a).data),\n eql_v2.ore_cllw((b).data)\n ) = 0\n ELSE false\n END\n );\n\n -- Short-circuit once a match is found. Without this we still walk\n -- the rest of the sv array, which on a 100-element document means\n -- 99 wasted selector + extractor calls per row.\n EXIT WHEN result;\n END LOOP;\n\n RETURN result;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Check if encrypted value 'a' contains all elements of encrypted value 'b'\n--!\n--! Performs STE vector containment comparison between two encrypted values.\n--! Returns true if all elements in b's STE vector are found in a's STE vector.\n--! Used internally by the @> containment operator for searchable encryption.\n--!\n--! @param a eql_v2_encrypted First encrypted value (container)\n--! @param b eql_v2_encrypted Second encrypted value (elements to find)\n--! @return Boolean True if all elements of b are contained in a\n--!\n--! @note Empty b is always contained in any a\n--! @note Each element of b must match both selector and value in a\n--!\n--! @see eql_v2.ste_vec\n--! @see eql_v2.ste_vec_contains(eql_v2_encrypted[], eql_v2_encrypted)\n--! @see eql_v2.\"@>\"\nCREATE FUNCTION eql_v2.ste_vec_contains(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n result boolean;\n sv_a public.eql_v2_encrypted[];\n sv_b public.eql_v2_encrypted[];\n _b public.eql_v2_encrypted;\n BEGIN\n\n -- jsonb arrays of ste_vec encrypted values\n sv_a := eql_v2.ste_vec(a);\n sv_b := eql_v2.ste_vec(b);\n\n -- an empty b is always contained in a\n IF array_length(sv_b, 1) IS NULL THEN\n RETURN true;\n END IF;\n\n IF array_length(sv_a, 1) IS NULL THEN\n RETURN false;\n END IF;\n\n result := true;\n\n -- for each element of b check if it is in a\n FOR idx IN 1..array_length(sv_b, 1) LOOP\n _b := sv_b[idx];\n result := result AND eql_v2.ste_vec_contains(sv_a, _b);\n END LOOP;\n\n RETURN result;\n END;\n$$ LANGUAGE plpgsql;\n--! @file config/types.sql\n--! @brief Configuration state type definition\n--!\n--! Defines the ENUM type for tracking encryption configuration lifecycle states.\n--! The configuration table uses this type to manage transitions between states\n--! during setup, activation, and encryption operations.\n--!\n--! @note CREATE TYPE does not support IF NOT EXISTS, so wrapped in DO block\n--! @note Configuration data stored as JSONB directly, not as DOMAIN\n--! @see config/tables.sql\n\n\n--! @brief Configuration lifecycle state\n--!\n--! Defines valid states for encryption configurations in the eql_v2_configuration table.\n--! Configurations transition through these states during setup and activation.\n--!\n--! @note Only one configuration can be in 'active', 'pending', or 'encrypting' state at once\n--! @see config/indexes.sql for uniqueness enforcement\n--! @see config/tables.sql for usage in eql_v2_configuration table\nDO $$\n BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eql_v2_configuration_state') THEN\n CREATE TYPE public.eql_v2_configuration_state AS ENUM ('active', 'inactive', 'encrypting', 'pending');\n END IF;\n END\n$$;\n\n\n--! @file src/ore_cllw/operators.sql\n--! @brief Comparison operators on the `eql_v2.ore_cllw` composite type\n--!\n--! Same-type comparison operators backing the btree operator class on the\n--! composite `eql_v2.ore_cllw` type. Each operator reduces to a single SELECT\n--! over `eql_v2.compare_ore_cllw_term(a, b)`, which is the canonical CLLW\n--! per-byte comparator (`y + 1 == x` mod 256). The operator wrappers are\n--! inlinable `LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE` so the planner can\n--! fold them into the calling query — that's what lets a functional btree\n--! index on `eql_v2.ore_cllw(col)` engage for both `WHERE eql_v2.ore_cllw(col)\n--! < eql_v2.ore_cllw($1)` and `ORDER BY eql_v2.ore_cllw(col)` shapes.\n--!\n--! The inner `eql_v2.compare_ore_cllw_term` is `LANGUAGE plpgsql` (it has a\n--! per-byte loop) and is NOT inlined. That's fine for index *match* (the\n--! planner only needs the outer operator function call to fold so the\n--! predicate's expression tree matches the index's expression tree); only the\n--! per-comparison cost is the plpgsql call overhead. That's the cost the\n--! functional index avoids by walking the btree in order rather than calling\n--! compare on every row.\n--!\n--! @note Deliberately no `HASHES` / `MERGES` flags on the operator\n--! declarations. HASHES requires a registered hash function on the type\n--! (the CLLW protocol gives ordering, not a sensible hashing); MERGES\n--! requires an equivalent merge-joinable operator class on both sides.\n--!\n--! @see src/ore_cllw/operator_class.sql\n--! @see src/ore_cllw/functions.sql\n\n--! @brief Equality operator backing function for `eql_v2.ore_cllw`\n--! @internal\n--!\n--! @param a eql_v2.ore_cllw Left operand\n--! @param b eql_v2.ore_cllw Right operand\n--! @return boolean True if the CLLW terms compare equal\n--!\n--! @see eql_v2.compare_ore_cllw_term\nCREATE FUNCTION eql_v2.ore_cllw_eq(a eql_v2.ore_cllw, b eql_v2.ore_cllw)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_cllw_term(a, b) = 0\n$$;\n\n--! @brief Inequality operator backing function for `eql_v2.ore_cllw`\n--! @internal\n--!\n--! @param a eql_v2.ore_cllw Left operand\n--! @param b eql_v2.ore_cllw Right operand\n--! @return boolean True if the CLLW terms compare unequal\n--!\n--! @see eql_v2.compare_ore_cllw_term\nCREATE FUNCTION eql_v2.ore_cllw_neq(a eql_v2.ore_cllw, b eql_v2.ore_cllw)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_cllw_term(a, b) <> 0\n$$;\n\n--! @brief Less-than operator backing function for `eql_v2.ore_cllw`\n--! @internal\n--!\n--! @param a eql_v2.ore_cllw Left operand\n--! @param b eql_v2.ore_cllw Right operand\n--! @return boolean True if `a` orders before `b`\n--!\n--! @see eql_v2.compare_ore_cllw_term\nCREATE FUNCTION eql_v2.ore_cllw_lt(a eql_v2.ore_cllw, b eql_v2.ore_cllw)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_cllw_term(a, b) = -1\n$$;\n\n--! @brief Less-than-or-equal operator backing function for `eql_v2.ore_cllw`\n--! @internal\n--!\n--! @param a eql_v2.ore_cllw Left operand\n--! @param b eql_v2.ore_cllw Right operand\n--! @return boolean True if `a` orders before or equal to `b`\n--!\n--! @see eql_v2.compare_ore_cllw_term\nCREATE FUNCTION eql_v2.ore_cllw_lte(a eql_v2.ore_cllw, b eql_v2.ore_cllw)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_cllw_term(a, b) <> 1\n$$;\n\n--! @brief Greater-than operator backing function for `eql_v2.ore_cllw`\n--! @internal\n--!\n--! @param a eql_v2.ore_cllw Left operand\n--! @param b eql_v2.ore_cllw Right operand\n--! @return boolean True if `a` orders after `b`\n--!\n--! @see eql_v2.compare_ore_cllw_term\nCREATE FUNCTION eql_v2.ore_cllw_gt(a eql_v2.ore_cllw, b eql_v2.ore_cllw)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_cllw_term(a, b) = 1\n$$;\n\n--! @brief Greater-than-or-equal operator backing function for `eql_v2.ore_cllw`\n--! @internal\n--!\n--! @param a eql_v2.ore_cllw Left operand\n--! @param b eql_v2.ore_cllw Right operand\n--! @return boolean True if `a` orders after or equal to `b`\n--!\n--! @see eql_v2.compare_ore_cllw_term\nCREATE FUNCTION eql_v2.ore_cllw_gte(a eql_v2.ore_cllw, b eql_v2.ore_cllw)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.compare_ore_cllw_term(a, b) <> -1\n$$;\n\n\nCREATE OPERATOR = (\n FUNCTION = eql_v2.ore_cllw_eq,\n LEFTARG = eql_v2.ore_cllw,\n RIGHTARG = eql_v2.ore_cllw,\n COMMUTATOR = =,\n NEGATOR = <>,\n RESTRICT = eqsel,\n JOIN = eqjoinsel\n);\n\nCREATE OPERATOR <> (\n FUNCTION = eql_v2.ore_cllw_neq,\n LEFTARG = eql_v2.ore_cllw,\n RIGHTARG = eql_v2.ore_cllw,\n COMMUTATOR = <>,\n NEGATOR = =,\n RESTRICT = neqsel,\n JOIN = neqjoinsel\n);\n\nCREATE OPERATOR < (\n FUNCTION = eql_v2.ore_cllw_lt,\n LEFTARG = eql_v2.ore_cllw,\n RIGHTARG = eql_v2.ore_cllw,\n COMMUTATOR = >,\n NEGATOR = >=,\n RESTRICT = scalarltsel,\n JOIN = scalarltjoinsel\n);\n\nCREATE OPERATOR <= (\n FUNCTION = eql_v2.ore_cllw_lte,\n LEFTARG = eql_v2.ore_cllw,\n RIGHTARG = eql_v2.ore_cllw,\n COMMUTATOR = >=,\n NEGATOR = >,\n RESTRICT = scalarlesel,\n JOIN = scalarlejoinsel\n);\n\nCREATE OPERATOR > (\n FUNCTION = eql_v2.ore_cllw_gt,\n LEFTARG = eql_v2.ore_cllw,\n RIGHTARG = eql_v2.ore_cllw,\n COMMUTATOR = <,\n NEGATOR = <=,\n RESTRICT = scalargtsel,\n JOIN = scalargtjoinsel\n);\n\nCREATE OPERATOR >= (\n FUNCTION = eql_v2.ore_cllw_gte,\n LEFTARG = eql_v2.ore_cllw,\n RIGHTARG = eql_v2.ore_cllw,\n COMMUTATOR = <=,\n NEGATOR = <,\n RESTRICT = scalargesel,\n JOIN = scalargejoinsel\n);\n\n\n--! @brief Extract Bloom filter index term from JSONB payload\n--!\n--! Extracts the Bloom filter array from the 'bf' field of an encrypted\n--! data payload. Used internally for pattern-match queries (LIKE operator).\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return eql_v2.bloom_filter Bloom filter as smallint array\n--! @throws Exception if 'bf' field is missing when bloom_filter index is expected\n--!\n--! @see eql_v2.has_bloom_filter\n--! @see eql_v2.\"~~\"\nCREATE FUNCTION eql_v2.bloom_filter(val jsonb)\n RETURNS eql_v2.bloom_filter\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF val IS NULL THEN\n RETURN NULL;\n END IF;\n\n IF eql_v2.has_bloom_filter(val) THEN\n RETURN ARRAY(SELECT jsonb_array_elements(val->'bf'))::eql_v2.bloom_filter;\n END IF;\n\n RAISE 'Expected a match index (bf) value in json: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Extract Bloom filter index term from encrypted column value\n--!\n--! Extracts the Bloom filter from an encrypted column value by accessing\n--! its underlying JSONB data field.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return eql_v2.bloom_filter Bloom filter as smallint array\n--!\n--! @see eql_v2.bloom_filter(jsonb)\nCREATE FUNCTION eql_v2.bloom_filter(val eql_v2_encrypted)\n RETURNS eql_v2.bloom_filter\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN (SELECT eql_v2.bloom_filter(val.data));\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Check if JSONB payload contains Bloom filter index term\n--!\n--! Tests whether the encrypted data payload includes a 'bf' field,\n--! indicating a Bloom filter is available for pattern-match queries.\n--!\n--! @param jsonb containing encrypted EQL payload\n--! @return Boolean True if 'bf' field is present and non-null\n--!\n--! @see eql_v2.bloom_filter\nCREATE FUNCTION eql_v2.has_bloom_filter(val jsonb)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN val ->> 'bf' IS NOT NULL;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Check if encrypted column value contains Bloom filter index term\n--!\n--! Tests whether an encrypted column value includes a Bloom filter\n--! by checking its underlying JSONB data field.\n--!\n--! @param eql_v2_encrypted Encrypted column value\n--! @return Boolean True if Bloom filter is present\n--!\n--! @see eql_v2.has_bloom_filter(jsonb)\nCREATE FUNCTION eql_v2.has_bloom_filter(val eql_v2_encrypted)\n RETURNS boolean\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN eql_v2.has_bloom_filter(val.data);\n END;\n$$ LANGUAGE plpgsql;\n\n--! @file src/ste_vec/eq_term.sql\n--! @brief XOR-aware equality term extractor for `eql_v2.ste_vec_entry`\n--!\n--! Returns the bytea representation of whichever deterministic term\n--! the sv entry carries — `hm` (HMAC-256) for bool leaves / array\n--! roots / object roots, or `oc` (CLLW ORE) for string / number\n--! leaves. The two byte distributions are disjoint by construction\n--! (different keys, different protocols), so byte equality on the\n--! coalesce is unambiguous: equal terms imply equal plaintexts under\n--! the same selector, and unequal terms imply different plaintexts\n--! (or different protocols, which can't happen for a single\n--! selector).\n--!\n--! This is the canonical equality extractor used by `=` and `<>` on\n--! `eql_v2.ste_vec_entry` — see `src/operators/ste_vec_entry.sql`.\n--! The recipe for field-level equality on encrypted JSON is:\n--!\n--! @example\n--! -- Functional hash index covers both hm-bearing and oc-bearing selectors\n--! CREATE INDEX ON users USING hash (eql_v2.eq_term(data -> ''));\n--! -- Bare-form predicate matches via the inlined `=` on ste_vec_entry\n--! SELECT * FROM users WHERE data -> '' = $1::eql_v2.ste_vec_entry;\n--!\n--! @param entry eql_v2.ste_vec_entry STE-vec entry (extracted via `->`)\n--! @return bytea Decoded `hm` or `oc` bytes (NULL if entry is NULL).\n--!\n--! @note The XOR contract (each sv entry carries exactly one of `hm`\n--! or `oc` — enforced by the `ste_vec_entry` DOMAIN CHECK) means\n--! the coalesce always picks the one present term.\n--!\n--! @see eql_v2.hmac_256(eql_v2.ste_vec_entry)\n--! @see eql_v2.ore_cllw(eql_v2.ste_vec_entry)\n--! @see src/operators/ste_vec_entry.sql\nCREATE FUNCTION eql_v2.eq_term(entry eql_v2.ste_vec_entry)\n RETURNS bytea\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT decode(coalesce(entry ->> 'hm', entry ->> 'oc'), 'hex')\n$$;\n\n--! @brief Extract ORE index term for ordering encrypted values\n--!\n--! Helper function that extracts the ore_block_u64_8_256 index term from an encrypted value\n--! for use in ORDER BY clauses when comparison operators are not appropriate or available.\n--!\n--! @param eql_v2_encrypted Encrypted value to extract order term from\n--! @return eql_v2.ore_block_u64_8_256 ORE index term for ordering\n--!\n--! @example\n--! -- Order encrypted values without using comparison operators\n--! SELECT * FROM users ORDER BY eql_v2.order_by(encrypted_age);\n--!\n--! @note Requires 'ore' index configuration on the column\n--! @see eql_v2.ore_block_u64_8_256\n--! @see eql_v2.add_search_config\nCREATE FUNCTION eql_v2.order_by(a eql_v2_encrypted)\n RETURNS eql_v2.ore_block_u64_8_256\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN eql_v2.ore_block_u64_8_256(a);\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Fallback literal comparison for encrypted values\n--! @internal\n--!\n--! Compares two encrypted values by their raw JSONB representation when no\n--! suitable index terms are available. This ensures consistent ordering required\n--! for btree correctness and prevents \"lock BufferContent is not held\" errors.\n--!\n--! Used as a last resort fallback in eql_v2.compare() when encrypted values\n--! lack matching index terms (hmac_256, ore).\n--!\n--! @param a eql_v2_encrypted First encrypted value\n--! @param b eql_v2_encrypted Second encrypted value\n--! @return integer -1 if a < b, 0 if a = b, 1 if a > b\n--!\n--! @note This compares the encrypted payloads directly, not the plaintext values\n--! @note Ordering is consistent but not meaningful for range queries\n--! @see eql_v2.compare\nCREATE FUNCTION eql_v2.compare_literal(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS integer\n IMMUTABLE STRICT PARALLEL SAFE\n LANGUAGE SQL\nAS $$\n SELECT CASE\n WHEN a.data < b.data THEN -1\n WHEN a.data > b.data THEN 1\n ELSE 0\n END;\n$$;\n\n\n--! @brief Compare two encrypted values using ORE block index terms\n--!\n--! Performs a three-way comparison (returns -1/0/1) of encrypted values using\n--! their ORE block index terms. Used internally by range operators (<, <=, >, >=)\n--! for order-revealing comparisons without decryption.\n--!\n--! @param a eql_v2_encrypted First encrypted value to compare\n--! @param b eql_v2_encrypted Second encrypted value to compare\n--! @return Integer -1 if a < b, 0 if a = b, 1 if a > b\n--!\n--! @note NULL values are sorted before non-NULL values\n--! @note Uses ORE cryptographic protocol for secure comparisons\n--!\n--! @see eql_v2.ore_block_u64_8_256\n--! @see eql_v2.has_ore_block_u64_8_256\n--! @see eql_v2.\"<\"\n--! @see eql_v2.\">\"\nCREATE FUNCTION eql_v2.compare_ore_block_u64_8_256(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS integer\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n a_term eql_v2.ore_block_u64_8_256;\n b_term eql_v2.ore_block_u64_8_256;\n BEGIN\n\n IF a IS NULL AND b IS NULL THEN\n RETURN 0;\n END IF;\n\n IF a IS NULL THEN\n RETURN -1;\n END IF;\n\n IF b IS NULL THEN\n RETURN 1;\n END IF;\n\n IF eql_v2.has_ore_block_u64_8_256(a) THEN\n a_term := eql_v2.ore_block_u64_8_256(a);\n END IF;\n\n IF eql_v2.has_ore_block_u64_8_256(a) THEN\n b_term := eql_v2.ore_block_u64_8_256(b);\n END IF;\n\n IF a_term IS NULL AND b_term IS NULL THEN\n RETURN 0;\n END IF;\n\n IF a_term IS NULL THEN\n RETURN -1;\n END IF;\n\n IF b_term IS NULL THEN\n RETURN 1;\n END IF;\n\n RETURN eql_v2.compare_ore_block_u64_8_256_terms(a_term.terms, b_term.terms);\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Less-than comparison helper for encrypted values\n--! @internal\n--! @deprecated Slated for removal in EQL 3.0. Use the `<` operator instead.\n--!\n--! Internal helper that delegates to `eql_v2.compare` for less-than\n--! testing. The `<` operator wrappers no longer call this helper — they\n--! inline a direct `ore_block_u64_8_256` comparison instead (see the\n--! inlinable bodies below).\n--!\n--! @warning Behaviour now diverges from the `<` operator: this helper\n--! still walks `eql_v2.compare`'s priority list (ore_block → ore_cllw\n--! → hm), whereas `<` goes straight to `ore_block_u64_8_256` and raises\n--! on missing `ob`. Callers relying on the dispatcher fallback should\n--! migrate to the extractor form: `eql_v2.ore_cllw(col) <\n--! eql_v2.ore_cllw($1::jsonb)`. See U-005.\n--!\n--! @param a eql_v2_encrypted First encrypted value\n--! @param b eql_v2_encrypted Second encrypted value\n--! @return Boolean True if a < b (compare result = -1)\n--!\n--! @see eql_v2.compare\n--! @see eql_v2.\"<\"\nCREATE FUNCTION eql_v2.lt(a eql_v2_encrypted, b eql_v2_encrypted)\nRETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN eql_v2.compare(a, b) = -1;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Less-than operator for encrypted values\n--!\n--! Implements the < operator for comparing two encrypted values via their\n--! `ob` (ore_block_u64_8_256) ORE term. Enables range queries and sorting\n--! without decryption. Requires the column to carry an `ob` term (configured\n--! via the `ore` index in the EQL schema).\n--!\n--! @param a eql_v2_encrypted Left operand\n--! @param b eql_v2_encrypted Right operand\n--! @return Boolean True if a is less than b\n--!\n--! @example\n--! -- Range query on encrypted timestamps\n--! SELECT * FROM events\n--! WHERE encrypted_timestamp < '2024-01-01'::timestamp::text::eql_v2_encrypted;\n--!\n--! -- Compare encrypted numeric columns\n--! SELECT * FROM products WHERE encrypted_price < encrypted_discount_price;\n--!\n--! @see eql_v2.ore_block_u64_8_256\n--! @see eql_v2.add_search_config\n-- Inlinable: `LANGUAGE sql IMMUTABLE` with a single SELECT body and no\n-- `SET` clause. The Postgres planner inlines the body into the calling\n-- query during planning, so `WHERE col < val` reduces to\n-- `WHERE eql_v2.ore_block_u64_8_256(col) < eql_v2.ore_block_u64_8_256(val)`\n-- and matches a functional btree index built on\n-- `eql_v2.ore_block_u64_8_256(col)` (using the DEFAULT\n-- `eql_v2.ore_block_u64_8_256_operator_class`). Bare range queries\n-- (`WHERE col < $1`) engage the functional ORE index on Supabase and any\n-- install that doesn't ship `eql_v2.encrypted_operator_class`.\n--\n-- Behaviour change vs the previous dispatcher-based impl: the old\n-- `eql_v2.\"<\"` walked `eql_v2.compare`, which dispatched through\n-- ore_block / ore_cllw_u64 / ore_cllw_var / ope. Now `<` requires the\n-- column to have `ore_block_u64_8_256` configured (i.e. carry an `ob`\n-- field). Calling `<` on a column with only `ore_cllw_*` or OPE terms\n-- now raises from the `ore_block_u64_8_256(jsonb)` extractor\n-- (`Expected an ore index (ob) value in json: ...`) where it\n-- previously returned a Boolean. Loud failure surfaces config errors\n-- rather than silently producing zero rows — see U-005.\nCREATE FUNCTION eql_v2.\"<\"(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) < eql_v2.ore_block_u64_8_256(b)\n$$;\n\nCREATE OPERATOR <(\n FUNCTION=eql_v2.\"<\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted,\n COMMUTATOR = >,\n NEGATOR = >=,\n RESTRICT = scalarltsel,\n JOIN = scalarltjoinsel\n);\n\n--! @brief Less-than operator for encrypted value and JSONB\n--!\n--! Overload of < operator accepting JSONB on the right side. Reduces to a\n--! direct comparison of the `ob` ORE term on both sides; the jsonb\n--! extractor `eql_v2.ore_block_u64_8_256(jsonb)` reads `b->'ob'` directly.\n--!\n--! @param eql_v2_encrypted Left operand (encrypted value)\n--! @param b JSONB Right operand\n--! @return Boolean True if a < b\n--!\n--! @example\n--! SELECT * FROM events WHERE encrypted_age < '{\"ob\":[...]}'::jsonb;\n--!\n--! @see eql_v2.\"<\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"<\"(a eql_v2_encrypted, b jsonb)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) < eql_v2.ore_block_u64_8_256(b)\n$$;\n\nCREATE OPERATOR <(\n FUNCTION=eql_v2.\"<\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=jsonb,\n COMMUTATOR = >,\n NEGATOR = >=,\n RESTRICT = scalarltsel,\n JOIN = scalarltjoinsel\n);\n\n--! @brief Less-than operator for JSONB and encrypted value\n--!\n--! Overload of < operator accepting JSONB on the left side. Reduces to a\n--! direct comparison of the `ob` ORE term on both sides.\n--!\n--! @param a JSONB Left operand\n--! @param eql_v2_encrypted Right operand (encrypted value)\n--! @return Boolean True if a < b\n--!\n--! @example\n--! SELECT * FROM events WHERE '{\"ob\":[...]}'::jsonb < encrypted_date;\n--!\n--! @see eql_v2.\"<\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"<\"(a jsonb, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) < eql_v2.ore_block_u64_8_256(b)\n$$;\n\n\nCREATE OPERATOR <(\n FUNCTION=eql_v2.\"<\",\n LEFTARG=jsonb,\n RIGHTARG=eql_v2_encrypted,\n COMMUTATOR = >,\n NEGATOR = >=,\n RESTRICT = scalarltsel,\n JOIN = scalarltjoinsel\n);\n\n--! @brief Less-than-or-equal comparison helper for encrypted values\n--! @internal\n--! @deprecated Slated for removal in EQL 3.0. Use the `<=` operator instead.\n--!\n--! Internal helper that delegates to `eql_v2.compare` for `<=` testing.\n--! The `<=` operator wrappers no longer go through this helper — see the\n--! inlinable bodies below.\n--!\n--! @warning Behaviour now diverges from the `<=` operator: this helper\n--! still walks `eql_v2.compare`'s priority list, whereas `<=` goes\n--! straight to `ore_block_u64_8_256` and raises on missing `ob`. See\n--! the matching note on `eql_v2.lt` and U-005 for migration guidance.\n--!\n--! @param a eql_v2_encrypted First encrypted value\n--! @param b eql_v2_encrypted Second encrypted value\n--! @return Boolean True if a <= b (compare result <= 0)\n--!\n--! @see eql_v2.compare\n--! @see eql_v2.\"<=\"\nCREATE FUNCTION eql_v2.lte(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN eql_v2.compare(a, b) <= 0;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Less-than-or-equal operator for encrypted values\n--!\n--! Implements the <= operator for comparing two encrypted values via their\n--! `ob` (ore_block_u64_8_256) ORE term. Requires the column to carry an\n--! `ob` term.\n--!\n--! @param a eql_v2_encrypted Left operand\n--! @param b eql_v2_encrypted Right operand\n--! @return Boolean True if a <= b\n--!\n--! @example\n--! SELECT * FROM users WHERE encrypted_age <= '18'::int::text::eql_v2_encrypted;\n--!\n--! @see eql_v2.ore_block_u64_8_256\n--! @see eql_v2.add_search_config\n-- Inlinable: see `src/operators/<.sql` for the rationale.\nCREATE FUNCTION eql_v2.\"<=\"(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) <= eql_v2.ore_block_u64_8_256(b)\n$$;\n\nCREATE OPERATOR <=(\n FUNCTION = eql_v2.\"<=\",\n LEFTARG = eql_v2_encrypted,\n RIGHTARG = eql_v2_encrypted,\n COMMUTATOR = >=,\n NEGATOR = >,\n RESTRICT = scalarlesel,\n JOIN = scalarlejoinsel\n);\n\n--! @brief <= operator for encrypted value and JSONB\n--! @see eql_v2.\"<=\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"<=\"(a eql_v2_encrypted, b jsonb)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) <= eql_v2.ore_block_u64_8_256(b)\n$$;\n\nCREATE OPERATOR <=(\n FUNCTION = eql_v2.\"<=\",\n LEFTARG = eql_v2_encrypted,\n RIGHTARG = jsonb,\n COMMUTATOR = >=,\n NEGATOR = >,\n RESTRICT = scalarlesel,\n JOIN = scalarlejoinsel\n);\n\n--! @brief <= operator for JSONB and encrypted value\n--! @see eql_v2.\"<=\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"<=\"(a jsonb, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) <= eql_v2.ore_block_u64_8_256(b)\n$$;\n\n\nCREATE OPERATOR <=(\n FUNCTION = eql_v2.\"<=\",\n LEFTARG = jsonb,\n RIGHTARG = eql_v2_encrypted,\n COMMUTATOR = >=,\n NEGATOR = >,\n RESTRICT = scalarlesel,\n JOIN = scalarlejoinsel\n);\n\n--! @brief Equality helper for encrypted values\n--! @internal\n--!\n--! Inlinable SQL helper mirroring the `=` operator's body: reduces to\n--! `hmac_256(a) = hmac_256(b)`. Kept for callers that invoked the\n--! pre-#193 form (`eql_v2.eq`); equivalent to using the `=` operator\n--! directly.\n--!\n--! Equality on `eql_v2_encrypted` is strictly hmac-based (see U-002).\n--! Returns NULL when either side lacks an `hm` term — matching the\n--! `=` operator's behaviour.\n--!\n--! @param a eql_v2_encrypted First encrypted value\n--! @param b eql_v2_encrypted Second encrypted value\n--! @return Boolean True if hmac terms match\n--!\n--! @see eql_v2.\"=\"\n--! @see eql_v2.hmac_256\nCREATE FUNCTION eql_v2.eq(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.hmac_256(a) = eql_v2.hmac_256(b)\n$$;\n\n--! @brief Equality operator for encrypted values\n--!\n--! Implements the = operator for comparing two encrypted values using their\n--! encrypted index terms (hmac_256). Enables WHERE clause comparisons\n--! without decryption.\n--!\n--! @param a eql_v2_encrypted Left operand\n--! @param b eql_v2_encrypted Right operand\n--! @return Boolean True if encrypted values are equal\n--!\n--! @example\n--! -- Compare encrypted columns\n--! SELECT * FROM users WHERE encrypted_email = other_encrypted_email;\n--!\n--! -- Search using encrypted literal\n--! SELECT * FROM users\n--! WHERE encrypted_email = '{\"c\":\"...\",\"i\":{\"unique\":\"...\"}}'::eql_v2_encrypted;\n--!\n--! @see eql_v2.compare\n--! @see eql_v2.add_search_config\n-- Inlinable: `LANGUAGE sql IMMUTABLE` with a single SELECT body and no\n-- `SET` clause. The Postgres planner inlines the body into the calling\n-- query during planning, so `WHERE col = val` reduces to\n-- `WHERE eql_v2.hmac_256(col) = eql_v2.hmac_256(val)` and matches a\n-- functional hash index built on `eql_v2.hmac_256(col)`. Bare equality\n-- queries (including those issued by PostgREST and ORMs that don't\n-- wrap columns themselves) become fast on Supabase and any\n-- --exclude-operator-family install.\n--\n-- Behaviour change vs the previous dispatcher-based impl: the old\n-- `eql_v2.eq` walked `eql_v2.compare`, which fell back to ORE / Blake3 /\n-- literal comparison when HMAC wasn't present. Now `=` requires the\n-- column to have `equality` configured (i.e. carry an `hm` field).\n-- Calling `=` on an ORE-only column will return NULL where it\n-- previously returned a Boolean. This is intentional — it surfaces\n-- config errors loudly. See the predicate/extractor RFC for context.\nCREATE FUNCTION eql_v2.\"=\"(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.hmac_256(a) = eql_v2.hmac_256(b)\n$$;\n\nCREATE OPERATOR = (\n FUNCTION=eql_v2.\"=\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted,\n NEGATOR = <>,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n HASHES,\n MERGES\n);\n\n--! @brief Equality operator for encrypted value and JSONB\n--!\n--! Overload of = operator accepting JSONB on the right side. Automatically\n--! casts JSONB to eql_v2_encrypted for comparison. Useful for comparing\n--! against JSONB literals or columns.\n--!\n--! @param eql_v2_encrypted Left operand (encrypted value)\n--! @param b JSONB Right operand (will be cast to eql_v2_encrypted)\n--! @return Boolean True if values are equal\n--!\n--! @example\n--! -- Compare encrypted column to JSONB literal\n--! SELECT * FROM users\n--! WHERE encrypted_email = '{\"c\":\"...\",\"i\":{\"unique\":\"...\"}}'::jsonb;\n--!\n--! @see eql_v2.\"=\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"=\"(a eql_v2_encrypted, b jsonb)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.hmac_256(a) = eql_v2.hmac_256(b::eql_v2_encrypted)\n$$;\n\nCREATE OPERATOR = (\n FUNCTION=eql_v2.\"=\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=jsonb,\n NEGATOR = <>,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\n--! @brief Equality operator for JSONB and encrypted value\n--!\n--! Overload of = operator accepting JSONB on the left side. Automatically\n--! casts JSONB to eql_v2_encrypted for comparison. Enables commutative\n--! equality comparisons.\n--!\n--! @param a JSONB Left operand (will be cast to eql_v2_encrypted)\n--! @param eql_v2_encrypted Right operand (encrypted value)\n--! @return Boolean True if values are equal\n--!\n--! @example\n--! -- Compare JSONB literal to encrypted column\n--! SELECT * FROM users\n--! WHERE '{\"c\":\"...\",\"i\":{\"unique\":\"...\"}}'::jsonb = encrypted_email;\n--!\n--! @see eql_v2.\"=\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"=\"(a jsonb, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.hmac_256(a::eql_v2_encrypted) = eql_v2.hmac_256(b)\n$$;\n\nCREATE OPERATOR = (\n FUNCTION=eql_v2.\"=\",\n LEFTARG=jsonb,\n RIGHTARG=eql_v2_encrypted,\n NEGATOR = <>,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\n\n--! @brief Greater-than-or-equal comparison helper for encrypted values\n--! @internal\n--! @deprecated Slated for removal in EQL 3.0. Use the `>=` operator instead.\n--!\n--! Internal helper that delegates to `eql_v2.compare` for `>=` testing.\n--! The `>=` operator wrappers no longer go through this helper — see the\n--! inlinable bodies below.\n--!\n--! @warning Behaviour now diverges from the `>=` operator: this helper\n--! still walks `eql_v2.compare`'s priority list, whereas `>=` goes\n--! straight to `ore_block_u64_8_256` and raises on missing `ob`. See\n--! the matching note on `eql_v2.lt` and U-005 for migration guidance.\n--!\n--! @param a eql_v2_encrypted First encrypted value\n--! @param b eql_v2_encrypted Second encrypted value\n--! @return Boolean True if a >= b (compare result >= 0)\n--!\n--! @see eql_v2.compare\n--! @see eql_v2.\">=\"\nCREATE FUNCTION eql_v2.gte(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN eql_v2.compare(a, b) >= 0;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Greater-than-or-equal operator for encrypted values\n--!\n--! Implements the >= operator for comparing two encrypted values via their\n--! `ob` (ore_block_u64_8_256) ORE term. Requires the column to carry an\n--! `ob` term.\n--!\n--! @param a eql_v2_encrypted Left operand\n--! @param b eql_v2_encrypted Right operand\n--! @return Boolean True if a >= b\n--!\n--! @example\n--! SELECT * FROM users WHERE encrypted_age >= '18'::int::text::eql_v2_encrypted;\n--!\n--! @see eql_v2.ore_block_u64_8_256\n--! @see eql_v2.add_search_config\n-- Inlinable: see `src/operators/<.sql` for the rationale.\nCREATE FUNCTION eql_v2.\">=\"(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) >= eql_v2.ore_block_u64_8_256(b)\n$$;\n\n\nCREATE OPERATOR >=(\n FUNCTION = eql_v2.\">=\",\n LEFTARG = eql_v2_encrypted,\n RIGHTARG = eql_v2_encrypted,\n COMMUTATOR = <=,\n NEGATOR = <,\n RESTRICT = scalargesel,\n JOIN = scalargejoinsel\n);\n\n--! @brief >= operator for encrypted value and JSONB\n--! @param a eql_v2_encrypted Left operand (encrypted value)\n--! @param b jsonb Right operand\n--! @return Boolean True if a >= b\n--! @see eql_v2.\">=\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\">=\"(a eql_v2_encrypted, b jsonb)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) >= eql_v2.ore_block_u64_8_256(b)\n$$;\n\nCREATE OPERATOR >=(\n FUNCTION = eql_v2.\">=\",\n LEFTARG = eql_v2_encrypted,\n RIGHTARG=jsonb,\n COMMUTATOR = <=,\n NEGATOR = <,\n RESTRICT = scalargesel,\n JOIN = scalargejoinsel\n);\n\n--! @brief >= operator for JSONB and encrypted value\n--! @param a jsonb Left operand\n--! @param b eql_v2_encrypted Right operand (encrypted value)\n--! @return Boolean True if a >= b\n--! @see eql_v2.\">=\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\">=\"(a jsonb, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) >= eql_v2.ore_block_u64_8_256(b)\n$$;\n\n\nCREATE OPERATOR >=(\n FUNCTION = eql_v2.\">=\",\n LEFTARG = jsonb,\n RIGHTARG =eql_v2_encrypted,\n COMMUTATOR = <=,\n NEGATOR = <,\n RESTRICT = scalargesel,\n JOIN = scalargejoinsel\n);\n\n--! @brief Greater-than comparison helper for encrypted values\n--! @internal\n--! @deprecated Slated for removal in EQL 3.0. Use the `>` operator instead.\n--!\n--! Internal helper that delegates to `eql_v2.compare` for greater-than\n--! testing. The `>` operator wrappers no longer go through this helper —\n--! see the inlinable bodies below.\n--!\n--! @warning Behaviour now diverges from the `>` operator: this helper\n--! still walks `eql_v2.compare`'s priority list, whereas `>` goes\n--! straight to `ore_block_u64_8_256` and raises on missing `ob`. See\n--! the matching note on `eql_v2.lt` and U-005 for migration guidance.\n--!\n--! @param a eql_v2_encrypted First encrypted value\n--! @param b eql_v2_encrypted Second encrypted value\n--! @return Boolean True if a > b (compare result = 1)\n--!\n--! @see eql_v2.compare\n--! @see eql_v2.\">\"\nCREATE FUNCTION eql_v2.gt(a eql_v2_encrypted, b eql_v2_encrypted)\nRETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN eql_v2.compare(a, b) = 1;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Greater-than operator for encrypted values\n--!\n--! Implements the > operator for comparing two encrypted values via their\n--! `ob` (ore_block_u64_8_256) ORE term. Enables range queries and sorting\n--! without decryption. Requires the column to carry an `ob` term.\n--!\n--! @param a eql_v2_encrypted Left operand\n--! @param b eql_v2_encrypted Right operand\n--! @return Boolean True if a is greater than b\n--!\n--! @example\n--! SELECT * FROM events\n--! WHERE encrypted_value > '100'::int::text::eql_v2_encrypted;\n--!\n--! @see eql_v2.ore_block_u64_8_256\n--! @see eql_v2.add_search_config\n-- Inlinable: see `src/operators/<.sql` for the rationale. Predicate\n-- `WHERE col > val` reduces to\n-- `WHERE eql_v2.ore_block_u64_8_256(col) > eql_v2.ore_block_u64_8_256(val)`\n-- and matches a functional ORE index built on the same expression.\n-- Breaking impact: columns with only `ore_cllw_*` or OPE terms now\n-- raise from the `ore_block_u64_8_256(jsonb)` extractor\n-- (`Expected an ore index (ob) value in json: ...`) where they\n-- previously fell through `eql_v2.compare`. See U-005.\nCREATE FUNCTION eql_v2.\">\"(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) > eql_v2.ore_block_u64_8_256(b)\n$$;\n\nCREATE OPERATOR >(\n FUNCTION=eql_v2.\">\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted,\n COMMUTATOR = <,\n NEGATOR = <=,\n RESTRICT = scalargtsel,\n JOIN = scalargtjoinsel\n);\n\n--! @brief > operator for encrypted value and JSONB\n--! @param a eql_v2_encrypted Left operand (encrypted value)\n--! @param b jsonb Right operand\n--! @return Boolean True if a > b\n--! @see eql_v2.\">\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\">\"(a eql_v2_encrypted, b jsonb)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) > eql_v2.ore_block_u64_8_256(b)\n$$;\n\nCREATE OPERATOR >(\n FUNCTION = eql_v2.\">\",\n LEFTARG = eql_v2_encrypted,\n RIGHTARG = jsonb,\n COMMUTATOR = <,\n NEGATOR = <=,\n RESTRICT = scalargtsel,\n JOIN = scalargtjoinsel\n);\n\n--! @brief > operator for JSONB and encrypted value\n--! @param a jsonb Left operand\n--! @param b eql_v2_encrypted Right operand (encrypted value)\n--! @return Boolean True if a > b\n--! @see eql_v2.\">\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\">\"(a jsonb, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_block_u64_8_256(a) > eql_v2.ore_block_u64_8_256(b)\n$$;\n\n\nCREATE OPERATOR >(\n FUNCTION = eql_v2.\">\",\n LEFTARG = jsonb,\n RIGHTARG = eql_v2_encrypted,\n COMMUTATOR = <,\n NEGATOR = <=,\n RESTRICT = scalargtsel,\n JOIN = scalargtjoinsel\n);\n\n--! @brief Compute hash integer for encrypted value\n--!\n--! Produces a 32-bit integer hash suitable for PostgreSQL hash joins, GROUP BY,\n--! DISTINCT, and hash aggregate operations. Used by the `eql_v2_encrypted` hash\n--! operator class (`FUNCTION 1`). Inlinable single-statement SQL — the SQL\n--! function machinery is much cheaper per row than plpgsql, which matters\n--! because HashAggregate / hash-join call this once per input row.\n--!\n--! Returns `hashtext` of the root payload's `hm` term. This is the canonical\n--! bucket for equality groups, since `=` on `eql_v2_encrypted` reduces to\n--! `hmac_256(a) = hmac_256(b)` post-#193.\n--!\n--! @par Contract\n--! Callers using `GROUP BY` / `DISTINCT` / hash joins on `eql_v2_encrypted`\n--! MUST configure the column with a `unique` index so the crypto layer\n--! emits `hm` — `hm` is assumed present. A missing `hm` is a misconfiguration\n--! that surfaces upstream via [U-002](docs/upgrading/v2.3.md#u-002-equality-and-hashing-require-hmac).\n--!\n--! @param val eql_v2_encrypted Encrypted value to hash\n--! @return integer 32-bit hash value derived from `hm`\n--!\n--! @note For grouping a value extracted from an encrypted JSON document, use\n--! the field-level recipe directly: `GROUP BY eql_v2.eq_term(col -> '')`\n--! (covers both hm-bearing and oc-bearing selectors via the XOR-aware\n--! extractor — see `src/ste_vec/eq_term.sql`). That bypasses\n--! `hash_encrypted` entirely.\n--!\n--! @see eql_v2.hmac_256\n--! @see eql_v2.has_hmac_256\n--! @see eql_v2.compare\nCREATE FUNCTION eql_v2.hash_encrypted(val eql_v2_encrypted)\n RETURNS integer\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT pg_catalog.hashtext(eql_v2.hmac_256(val)::text)\n$$;\n\n--! @brief Contains operator for encrypted values (@>)\n--!\n--! Implements the @> (contains) operator for testing if left encrypted value\n--! contains the right encrypted value. Uses ste_vec (secure tree encoding vector)\n--! index terms for containment testing without decryption.\n--!\n--! Primarily used for encrypted array or set containment queries.\n--!\n--! @param a eql_v2_encrypted Left operand (container)\n--! @param b eql_v2_encrypted Right operand (contained value)\n--! @return Boolean True if a contains b\n--!\n--! @example\n--! -- Check if encrypted array contains value\n--! SELECT * FROM documents\n--! WHERE encrypted_tags @> '[\"security\"]'::jsonb::eql_v2_encrypted;\n--!\n--! @note Requires ste_vec index configuration\n--! @see eql_v2.ste_vec_contains\n--! @see eql_v2.add_search_config\n-- Marked IMMUTABLE STRICT PARALLEL SAFE so the planner inlines the body\n-- and a functional GIN index on `eql_v2.ste_vec(col)` can match\n-- `WHERE col @> val`. The previous default-VOLATILE declaration prevented\n-- inlining and forced seq scan even on Supabase installs that have the\n-- ste_vec functional index in place.\nCREATE FUNCTION eql_v2.\"@>\"(a eql_v2_encrypted, b eql_v2_encrypted)\nRETURNS boolean\nLANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ste_vec_contains(a, b)\n$$;\n\nCREATE OPERATOR @>(\n FUNCTION=eql_v2.\"@>\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted\n);\n\n\n--! @brief Contains operator (@>) with an `eql_v2.stevec_query` needle\n--!\n--! Type-safe containment for the recommended recipe: the right-hand\n--! side is an `stevec_query` (sv-shaped payload, no `c` fields). The\n--! body inlines to a native `jsonb @>` over `eql_v2.to_stevec_query(a)::jsonb`,\n--! so the planner can match a functional GIN index built on the same\n--! expression — engaging Bitmap Index Scan for bare-form containment\n--! across both `hm`-bearing and `oc`-bearing selectors with a single\n--! index.\n--!\n--! @param a eql_v2_encrypted Left operand (container)\n--! @param b eql_v2.stevec_query Right operand (query payload)\n--! @return Boolean True if a contains b\n--!\n--! @example\n--! -- Functional GIN index (covers all selectors, hm and oc):\n--! CREATE INDEX ON users USING gin (\n--! eql_v2.to_stevec_query(encrypted_doc)::jsonb jsonb_path_ops\n--! );\n--! -- Bare-form predicate engages the index:\n--! SELECT * FROM users\n--! WHERE encrypted_doc @> '{\"sv\":[{\"s\":\"\",\"hm\":\"\"}]}'::eql_v2.stevec_query;\n--!\n--! @see eql_v2.stevec_query\n--! @see eql_v2.to_stevec_query\nCREATE FUNCTION eql_v2.\"@>\"(a eql_v2_encrypted, b eql_v2.stevec_query)\nRETURNS boolean\nLANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n -- Single-expression body so the planner can inline. The haystack\n -- normalisation happens in `to_stevec_query`; the needle is trusted\n -- to be clean (sv elements of shape `{s, hm-or-oc}` — the documented\n -- stevec_query contract). For untrusted needles, callers should\n -- normalise via the json-shape `{\"sv\":[{\"s\":\"\",\"hm\":\"\"}]}`.\n SELECT eql_v2.to_stevec_query(a)::jsonb @> b::jsonb\n$$;\n\nCREATE OPERATOR @>(\n FUNCTION=eql_v2.\"@>\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2.stevec_query\n);\n\n\n--! @brief Contains operator (@>) with an `eql_v2.ste_vec_entry` needle\n--!\n--! Convenience overload for the common pattern \"does this encrypted\n--! payload include this specific sv entry?\". Wraps the entry into a\n--! single-element sv array (stripping `c`) and reduces to the same\n--! `to_stevec_query(a)::jsonb @> needle::jsonb` form as the\n--! `stevec_query` overload — so it engages the same functional GIN\n--! index. Inlinable.\n--!\n--! @param a eql_v2_encrypted Left operand (container)\n--! @param b eql_v2.ste_vec_entry Right operand (single entry)\n--! @return Boolean True if a contains an sv entry matching `b`\n--!\n--! @example\n--! -- Does this row's encrypted doc contain the same name as this other doc?\n--! SELECT a.* FROM docs a, docs b\n--! WHERE a.doc @> (b.doc -> '');\n--!\n--! @see eql_v2.ste_vec_entry\n--! @see eql_v2.\"@>\"(eql_v2_encrypted, eql_v2.stevec_query)\nCREATE FUNCTION eql_v2.\"@>\"(a eql_v2_encrypted, b eql_v2.ste_vec_entry)\nRETURNS boolean\nLANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.to_stevec_query(a)::jsonb\n @> jsonb_build_object(\n 'sv',\n jsonb_build_array(\n jsonb_strip_nulls(\n jsonb_build_object(\n 's', b -> 's',\n 'hm', b -> 'hm',\n 'oc', b -> 'oc'\n )\n )\n )\n )\n$$;\n\nCREATE OPERATOR @>(\n FUNCTION=eql_v2.\"@>\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2.ste_vec_entry\n);\n\n--! @file config/tables.sql\n--! @brief Encryption configuration storage table\n--!\n--! Defines the main table for storing EQL v2 encryption configurations.\n--! Each row represents a configuration specifying which tables/columns to encrypt\n--! and what index types to use. Configurations progress through lifecycle states.\n--!\n--! @see config/types.sql for state ENUM definition\n--! @see config/indexes.sql for state uniqueness constraints\n--! @see config/constraints.sql for data validation\n\n\n--! @brief Encryption configuration table\n--!\n--! Stores encryption configurations with their state and metadata.\n--! The 'data' JSONB column contains the full configuration structure including\n--! table/column mappings, index types, and casting rules.\n--!\n--! @note Only one configuration can be 'active', 'pending', or 'encrypting' at once\n--! @note 'id' is auto-generated identity column\n--! @note 'state' defaults to 'pending' for new configurations\n--! @note 'data' validated by CHECK constraint (see config/constraints.sql)\nCREATE TABLE IF NOT EXISTS public.eql_v2_configuration\n(\n id bigint GENERATED ALWAYS AS IDENTITY,\n state eql_v2_configuration_state NOT NULL DEFAULT 'pending',\n data jsonb,\n created_at timestamptz not null default current_timestamp,\n PRIMARY KEY(id)\n);\n\n\n--! @brief Initialize default configuration structure\n--! @internal\n--!\n--! Creates a default configuration object if input is NULL. Used internally\n--! by public configuration functions to ensure consistent structure.\n--!\n--! @param config JSONB Existing configuration or NULL\n--! @return JSONB Configuration with default structure (version 1, empty tables)\nCREATE FUNCTION eql_v2.config_default(config jsonb)\n RETURNS jsonb\n IMMUTABLE PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n IF config IS NULL THEN\n SELECT jsonb_build_object('v', 1, 'tables', jsonb_build_object()) INTO config;\n END IF;\n RETURN config;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Add table to configuration if not present\n--! @internal\n--!\n--! Ensures the specified table exists in the configuration structure.\n--! Creates empty table entry if needed. Idempotent operation.\n--!\n--! @param table_name Text Name of table to add\n--! @param config JSONB Configuration object\n--! @return JSONB Updated configuration with table entry\nCREATE FUNCTION eql_v2.config_add_table(table_name text, config jsonb)\n RETURNS jsonb\n IMMUTABLE PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n tbl jsonb;\n BEGIN\n IF NOT config #> array['tables'] ? table_name THEN\n SELECT jsonb_insert(config, array['tables', table_name], jsonb_build_object()) INTO config;\n END IF;\n RETURN config;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Add column to table configuration if not present\n--! @internal\n--!\n--! Ensures the specified column exists in the table's configuration structure.\n--! Creates empty column entry with indexes object if needed. Idempotent operation.\n--!\n--! @param table_name Text Name of parent table\n--! @param column_name Text Name of column to add\n--! @param config JSONB Configuration object\n--! @return JSONB Updated configuration with column entry\nCREATE FUNCTION eql_v2.config_add_column(table_name text, column_name text, config jsonb)\n RETURNS jsonb\n IMMUTABLE PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n col jsonb;\n BEGIN\n IF NOT config #> array['tables', table_name] ? column_name THEN\n SELECT jsonb_build_object('indexes', jsonb_build_object()) into col;\n SELECT jsonb_set(config, array['tables', table_name, column_name], col) INTO config;\n END IF;\n RETURN config;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Set cast type for column in configuration\n--! @internal\n--!\n--! Updates the cast_as field for a column, specifying the PostgreSQL type\n--! that decrypted values should be cast to.\n--!\n--! @param table_name Text Name of parent table\n--! @param column_name Text Name of column\n--! @param cast_as Text PostgreSQL type for casting (e.g., 'text', 'int', 'jsonb')\n--! @param config JSONB Configuration object\n--! @return JSONB Updated configuration with cast_as set\nCREATE FUNCTION eql_v2.config_add_cast(table_name text, column_name text, cast_as text, config jsonb)\n RETURNS jsonb\n IMMUTABLE PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n SELECT jsonb_set(config, array['tables', table_name, column_name, 'cast_as'], to_jsonb(cast_as)) INTO config;\n RETURN config;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Add search index to column configuration\n--! @internal\n--!\n--! Inserts a search index entry (unique, match, ore, ste_vec) with its options\n--! into the column's indexes object.\n--!\n--! @param table_name Text Name of parent table\n--! @param column_name Text Name of column\n--! @param index_name Text Type of index to add\n--! @param opts JSONB Index-specific options\n--! @param config JSONB Configuration object\n--! @return JSONB Updated configuration with index added\nCREATE FUNCTION eql_v2.config_add_index(table_name text, column_name text, index_name text, opts jsonb, config jsonb)\n RETURNS jsonb\n IMMUTABLE PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n SELECT jsonb_insert(config, array['tables', table_name, column_name, 'indexes', index_name], opts) INTO config;\n RETURN config;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Generate default options for match index\n--! @internal\n--!\n--! Returns default configuration for match (LIKE) indexes: k=6, bf=2048,\n--! ngram tokenizer with token_length=3, downcase filter, include_original=true.\n--!\n--! @return JSONB Default match index options\nCREATE FUNCTION eql_v2.config_match_default()\n RETURNS jsonb\nLANGUAGE sql STRICT PARALLEL SAFE\nBEGIN ATOMIC\n SELECT jsonb_build_object(\n 'k', 6,\n 'bf', 2048,\n 'include_original', true,\n 'tokenizer', json_build_object('kind', 'ngram', 'token_length', 3),\n 'token_filters', json_build_array(json_build_object('kind', 'downcase')));\nEND;\n-- AUTOMATICALLY GENERATED FILE\n-- Source is version-template.sql\n\nDROP FUNCTION IF EXISTS eql_v2.version();\n\n--! @file version.sql\n--! @brief EQL version reporting\n--!\n--! This file is auto-generated from version.template during build.\n--! The version string placeholder is replaced with the actual release version.\n\n--! @brief Get EQL library version string\n--!\n--! Returns the version string for the installed EQL library.\n--! This value is set at build time from the project version.\n--!\n--! @return text Version string (e.g., \"2.1.0\" or \"DEV\" for development builds)\n--!\n--! @note Auto-generated during build from version.template\n--!\n--! @example\n--! -- Check installed EQL version\n--! SELECT eql_v2.version();\n--! -- Returns: '2.1.0'\nCREATE FUNCTION eql_v2.version()\n RETURNS text\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT 'eql-2.3.1';\n$$ LANGUAGE SQL;\n\n\n--! @file src/ore_cllw/operator_class.sql\n--! @brief Btree operator class on the `eql_v2.ore_cllw` composite type\n--!\n--! Registers the CLLW per-byte comparison operators as a btree opclass for\n--! the `eql_v2.ore_cllw` composite type. With `DEFAULT FOR TYPE`, a functional\n--! btree index on `eql_v2.ore_cllw(col)` (or any expression returning the\n--! composite) automatically picks up this opclass — no annotation needed at\n--! index creation time.\n--!\n--! Why this matters. After the consolidation in #219, ordered comparison on\n--! sv-element values (via `eql_v2.ore_cllw(value -> ''::text)`)\n--! has correct semantics through the operator backing functions (each\n--! reduces to `compare_ore_cllw_term 0`), but PostgreSQL won't engage\n--! a functional index for `ORDER BY ...` or `WHERE ... < $1` unless the\n--! type has a registered btree opclass that the planner can structurally\n--! match. Without this opclass, `field_order/*` queries on sv-element CLLW\n--! columns fall back to seq scan + Top-N sort (measured 20s+ on 1M rows).\n--! With it, the same queries become Index Scan + LIMIT — milliseconds.\n--!\n--! FUNCTION 1 is the three-way comparator that btree's internal sort uses\n--! (returns -1 / 0 / +1). We point it at `compare_ore_cllw_term` directly:\n--! that's plpgsql by design (the per-byte CLLW protocol needs iteration),\n--! and btree calls it once per index entry pair during build / search —\n--! not per-row in the outer query.\n--!\n--! @note Deliberately no operator family registration beyond the opclass\n--! itself: no cross-type operators on `eql_v2.ore_cllw` × `jsonb`, no\n--! hash support — see operators.sql for the rationale.\n--! @note Excluded from the Supabase build variant (the build glob\n--! `**/*operator_class.sql` strips operator classes for Supabase\n--! compatibility).\n--!\n--! @see src/ore_cllw/operators.sql\n--! @see src/ore_cllw/functions.sql\n\nCREATE OPERATOR FAMILY eql_v2.ore_cllw_ops USING btree;\n\nCREATE OPERATOR CLASS eql_v2.ore_cllw_ops\n DEFAULT FOR TYPE eql_v2.ore_cllw\n USING btree FAMILY eql_v2.ore_cllw_ops AS\n OPERATOR 1 < (eql_v2.ore_cllw, eql_v2.ore_cllw),\n OPERATOR 2 <= (eql_v2.ore_cllw, eql_v2.ore_cllw),\n OPERATOR 3 = (eql_v2.ore_cllw, eql_v2.ore_cllw),\n OPERATOR 4 >= (eql_v2.ore_cllw, eql_v2.ore_cllw),\n OPERATOR 5 > (eql_v2.ore_cllw, eql_v2.ore_cllw),\n FUNCTION 1 eql_v2.compare_ore_cllw_term(eql_v2.ore_cllw, eql_v2.ore_cllw);\n\n\n--! @brief B-tree operator family for ORE block types\n--!\n--! Defines the operator family for creating B-tree indexes on ORE block types.\n--!\n--! @see eql_v2.ore_block_u64_8_256_operator_class\nCREATE OPERATOR FAMILY eql_v2.ore_block_u64_8_256_operator_family USING btree;\n\n--! @brief B-tree operator class for ORE block encrypted values\n--!\n--! Defines the operator class required for creating B-tree indexes on columns\n--! using the ore_block_u64_8_256 type. Enables range queries and ORDER BY on\n--! ORE-encrypted data without decryption.\n--!\n--! Supports operators: <, <=, =, >=, >\n--! Uses comparison function: compare_ore_block_u64_8_256_terms\n--!\n--!\n--! @example\n--! -- Would be used like (if enabled):\n--! CREATE INDEX ON events USING btree (\n--! (encrypted_timestamp::jsonb->'ob')::eql_v2.ore_block_u64_8_256\n--! );\n--!\n--! @see CREATE OPERATOR CLASS in PostgreSQL documentation\n--! @see eql_v2.compare_ore_block_u64_8_256_terms\nCREATE OPERATOR CLASS eql_v2.ore_block_u64_8_256_operator_class DEFAULT FOR TYPE eql_v2.ore_block_u64_8_256 USING btree FAMILY eql_v2.ore_block_u64_8_256_operator_family AS\n OPERATOR 1 <,\n OPERATOR 2 <=,\n OPERATOR 3 =,\n OPERATOR 4 >=,\n OPERATOR 5 >,\n FUNCTION 1 eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256);\n\n--! @brief Cast text to ORE block term\n--! @internal\n--!\n--! Converts text to bytea and wraps in ore_block_u64_8_256_term type.\n--! Used internally for ORE block extraction and manipulation.\n--!\n--! @param t Text Text value to convert\n--! @return eql_v2.ore_block_u64_8_256_term ORE term containing bytea representation\n--!\n--! @see eql_v2.ore_block_u64_8_256_term\nCREATE FUNCTION eql_v2.text_to_ore_block_u64_8_256_term(t text)\n RETURNS eql_v2.ore_block_u64_8_256_term\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nBEGIN ATOMIC\n\tRETURN t::bytea;\nEND;\n\n--! @brief Implicit cast from text to ORE block term\n--!\n--! Defines an implicit cast allowing automatic conversion of text values\n--! to ore_block_u64_8_256_term type for ORE operations.\n--!\n--! @see eql_v2.text_to_ore_block_u64_8_256_term\nCREATE CAST (text AS eql_v2.ore_block_u64_8_256_term)\n\tWITH FUNCTION eql_v2.text_to_ore_block_u64_8_256_term(text) AS IMPLICIT;\n\n--! @brief Pattern matching helper using bloom filters\n--! @internal\n--!\n--! Internal helper for LIKE-style pattern matching on encrypted values.\n--! Uses bloom filter index terms to test substring containment without decryption.\n--! Requires 'match' index configuration on the column.\n--!\n--! Marked IMMUTABLE so the planner inlines the body and a functional index on\n--! `eql_v2.bloom_filter(col)` can match `WHERE eql_v2.like(col, val)`.\n--!\n--! @param a eql_v2_encrypted Haystack (value to search in)\n--! @param b eql_v2_encrypted Needle (pattern to search for)\n--! @return Boolean True if bloom filter of a contains bloom filter of b\n--!\n--! @see eql_v2.\"~~\"\n--! @see eql_v2.bloom_filter\n--! @see eql_v2.add_search_config\nCREATE FUNCTION eql_v2.like(a eql_v2_encrypted, b eql_v2_encrypted)\nRETURNS boolean\nLANGUAGE SQL\nIMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b);\n$$;\n\n--! @brief Case-insensitive pattern matching helper\n--! @internal\n--!\n--! Internal helper for ILIKE-style case-insensitive pattern matching.\n--! Case sensitivity is controlled by index configuration (token_filters with downcase).\n--! This function has same implementation as like() - actual case handling is in index terms.\n--!\n--! @param a eql_v2_encrypted Haystack (value to search in)\n--! @param b eql_v2_encrypted Needle (pattern to search for)\n--! @return Boolean True if bloom filter of a contains bloom filter of b\n--!\n--! @note Case sensitivity depends on match index token_filters configuration\n--! @see eql_v2.\"~~\"\n--! @see eql_v2.add_search_config\nCREATE FUNCTION eql_v2.ilike(a eql_v2_encrypted, b eql_v2_encrypted)\nRETURNS boolean\nLANGUAGE SQL\nIMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b);\n$$;\n\n--! @brief LIKE operator for encrypted values (pattern matching)\n--!\n--! Implements the ~~ (LIKE) operator for substring/pattern matching on encrypted\n--! text using bloom filter index terms. Enables WHERE col LIKE '%pattern%' queries\n--! without decryption. Requires 'match' index configuration on the column.\n--!\n--! Pattern matching uses n-gram tokenization configured in match index. Token length\n--! and filters affect matching behavior.\n--!\n--! @param a eql_v2_encrypted Haystack (encrypted text to search in)\n--! @param b eql_v2_encrypted Needle (encrypted pattern to search for)\n--! @return Boolean True if a contains b as substring\n--!\n--! @example\n--! -- Search for substring in encrypted email\n--! SELECT * FROM users\n--! WHERE encrypted_email ~~ '%@example.com%'::text::eql_v2_encrypted;\n--!\n--! -- Pattern matching on encrypted names\n--! SELECT * FROM customers\n--! WHERE encrypted_name ~~ 'John%'::text::eql_v2_encrypted;\n--!\n--! @brief SQL LIKE operator (~~ operator) for encrypted text pattern matching\n--!\n--! @param a eql_v2_encrypted Left operand (encrypted value)\n--! @param b eql_v2_encrypted Right operand (encrypted pattern)\n--! @return boolean True if pattern matches\n--!\n--! @note Requires match index: eql_v2.add_search_config(table, column, 'match')\n--! @see eql_v2.like\n--! @see eql_v2.add_search_config\n-- Inlinable: delegates to `eql_v2.like` which is itself an inlinable\n-- single-statement SQL function. Two levels of inlining produce\n-- `eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b)`, which matches a\n-- functional GIN index built on `eql_v2.bloom_filter(col)`. PostgREST\n-- and ORM `~~`/`~~*` queries engage the bloom-filter index without\n-- the caller wrapping the column themselves.\nCREATE FUNCTION eql_v2.\"~~\"(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.like(a, b)\n$$;\n\nCREATE OPERATOR ~~(\n FUNCTION=eql_v2.\"~~\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\n--! @brief Case-insensitive LIKE operator (~~*)\n--!\n--! Implements ~~* (ILIKE) operator for case-insensitive pattern matching.\n--! Case handling depends on match index token_filters configuration (use downcase filter).\n--! Same implementation as ~~, with case sensitivity controlled by index configuration.\n--!\n--! @param a eql_v2_encrypted Haystack\n--! @param b eql_v2_encrypted Needle\n--! @return Boolean True if a contains b (case-insensitive)\n--!\n--! @note Configure match index with downcase token filter for case-insensitivity\n--! @see eql_v2.\"~~\"\nCREATE OPERATOR ~~*(\n FUNCTION=eql_v2.\"~~\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\n--! @brief LIKE operator for encrypted value and JSONB\n--!\n--! Overload of ~~ operator accepting JSONB on the right side. Automatically\n--! casts JSONB to eql_v2_encrypted for bloom filter pattern matching.\n--!\n--! @param eql_v2_encrypted Haystack (encrypted value)\n--! @param b JSONB Needle (will be cast to eql_v2_encrypted)\n--! @return Boolean True if a contains b as substring\n--!\n--! @example\n--! SELECT * FROM users WHERE encrypted_email ~~ '%gmail%'::jsonb;\n--!\n--! @see eql_v2.\"~~\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"~~\"(a eql_v2_encrypted, b jsonb)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.like(a, b::eql_v2_encrypted)\n$$;\n\n\nCREATE OPERATOR ~~(\n FUNCTION=eql_v2.\"~~\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=jsonb,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\nCREATE OPERATOR ~~*(\n FUNCTION=eql_v2.\"~~\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=jsonb,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\n--! @brief LIKE operator for JSONB and encrypted value\n--!\n--! Overload of ~~ operator accepting JSONB on the left side. Automatically\n--! casts JSONB to eql_v2_encrypted for bloom filter pattern matching.\n--!\n--! @param a JSONB Haystack (will be cast to eql_v2_encrypted)\n--! @param eql_v2_encrypted Needle (encrypted pattern)\n--! @return Boolean True if a contains b as substring\n--!\n--! @example\n--! SELECT * FROM users WHERE 'test@example.com'::jsonb ~~ encrypted_pattern;\n--!\n--! @see eql_v2.\"~~\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"~~\"(a jsonb, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.like(a::eql_v2_encrypted, b)\n$$;\n\n\nCREATE OPERATOR ~~(\n FUNCTION=eql_v2.\"~~\",\n LEFTARG=jsonb,\n RIGHTARG=eql_v2_encrypted,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\nCREATE OPERATOR ~~*(\n FUNCTION=eql_v2.\"~~\",\n LEFTARG=jsonb,\n RIGHTARG=eql_v2_encrypted,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\n\n-- -----------------------------------------------------------------------------\n\n--! @file src/operators/ste_vec_entry.sql\n--! @brief Comparison operators on `eql_v2.ste_vec_entry`\n--!\n--! Equality (`=`, `<>`) reduces to `eq_term(a) = eq_term(b)` — a bytea\n--! comparison of `coalesce(hm, oc)`. Ordering (`<`, `<=`, `>`, `>=`)\n--! reduces to `ore_cllw(a) ore_cllw(b)`. Each backing function is\n--! inlinable single-statement SQL, so the planner can fold the\n--! operator body into the calling query — `WHERE col -> 'sel' = $1`\n--! and `WHERE col -> 'sel' < $1` therefore match functional indexes\n--! built on `eql_v2.eq_term(col -> 'sel')` /\n--! `eql_v2.ore_cllw(col -> 'sel')` without per-query rewriting.\n--!\n--! XOR contract. Each sv entry carries exactly one of `hm` (bool\n--! leaves, array / object roots) or `oc` (string / number leaves) —\n--! enforced by the `ste_vec_entry` DOMAIN CHECK. Equality coalesces\n--! across both protocols because both are deterministic and the byte\n--! distributions are disjoint; ordering strictly uses `ore_cllw`\n--! (range on hm-only entries is meaningless and produces silent NULL,\n--! which the lint subsystem `src/lint/lints.sql` flags as a\n--! configuration error).\n--!\n--! Same convention as the `eql_v2_encrypted` operators (#193 / #211): the\n--! operator-class function-matching layer is what makes index match work\n--! structurally, the backing functions just need to inline cleanly through\n--! to the extractor calls.\n--!\n--! @see eql_v2.eq_term(eql_v2.ste_vec_entry)\n--! @see eql_v2.ore_cllw(eql_v2.ste_vec_entry)\n--! @see src/operators/=.sql\n--! @see src/operators/<.sql\n\n--! @brief Equality backing function for `eql_v2.ste_vec_entry`\n--! @internal\n--! @param a eql_v2.ste_vec_entry Left operand\n--! @param b eql_v2.ste_vec_entry Right operand\n--! @return boolean True if both entries share the same deterministic\n--! equality term (hm-or-oc, via `eq_term`).\nCREATE FUNCTION eql_v2.eq(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.eq_term(a) = eql_v2.eq_term(b)\n$$;\n\nCREATE OPERATOR = (\n FUNCTION = eql_v2.eq,\n LEFTARG = eql_v2.ste_vec_entry,\n RIGHTARG = eql_v2.ste_vec_entry,\n COMMUTATOR = =,\n NEGATOR = <>,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n HASHES,\n MERGES\n);\n\n\n--! @brief Inequality backing function for `eql_v2.ste_vec_entry`\n--! @internal\n--! @param a eql_v2.ste_vec_entry Left operand\n--! @param b eql_v2.ste_vec_entry Right operand\n--! @return boolean True if the entries' equality terms (hm-or-oc, via\n--! `eq_term`) differ.\nCREATE FUNCTION eql_v2.neq(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.eq_term(a) <> eql_v2.eq_term(b)\n$$;\n\nCREATE OPERATOR <> (\n FUNCTION = eql_v2.neq,\n LEFTARG = eql_v2.ste_vec_entry,\n RIGHTARG = eql_v2.ste_vec_entry,\n COMMUTATOR = <>,\n NEGATOR = =,\n RESTRICT = neqsel,\n JOIN = neqjoinsel\n);\n\n\n--! @brief Less-than backing function for `eql_v2.ste_vec_entry`\n--! @internal\n--! @param a eql_v2.ste_vec_entry Left operand\n--! @param b eql_v2.ste_vec_entry Right operand\n--! @return boolean True if `a`'s CLLW ORE term sorts before `b`'s\nCREATE FUNCTION eql_v2.lt(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_cllw(a) < eql_v2.ore_cllw(b)\n$$;\n\nCREATE OPERATOR < (\n FUNCTION = eql_v2.lt,\n LEFTARG = eql_v2.ste_vec_entry,\n RIGHTARG = eql_v2.ste_vec_entry,\n COMMUTATOR = >,\n NEGATOR = >=,\n RESTRICT = scalarltsel,\n JOIN = scalarltjoinsel\n);\n\n\n--! @brief Less-than-or-equal backing function for `eql_v2.ste_vec_entry`\n--! @internal\n--! @param a eql_v2.ste_vec_entry Left operand\n--! @param b eql_v2.ste_vec_entry Right operand\n--! @return boolean True if `a`'s CLLW ORE term sorts before or equal to `b`'s\nCREATE FUNCTION eql_v2.lte(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_cllw(a) <= eql_v2.ore_cllw(b)\n$$;\n\nCREATE OPERATOR <= (\n FUNCTION = eql_v2.lte,\n LEFTARG = eql_v2.ste_vec_entry,\n RIGHTARG = eql_v2.ste_vec_entry,\n COMMUTATOR = >=,\n NEGATOR = >,\n RESTRICT = scalarlesel,\n JOIN = scalarlejoinsel\n);\n\n\n--! @brief Greater-than backing function for `eql_v2.ste_vec_entry`\n--! @internal\n--! @param a eql_v2.ste_vec_entry Left operand\n--! @param b eql_v2.ste_vec_entry Right operand\n--! @return boolean True if `a`'s CLLW ORE term sorts after `b`'s\nCREATE FUNCTION eql_v2.gt(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_cllw(a) > eql_v2.ore_cllw(b)\n$$;\n\nCREATE OPERATOR > (\n FUNCTION = eql_v2.gt,\n LEFTARG = eql_v2.ste_vec_entry,\n RIGHTARG = eql_v2.ste_vec_entry,\n COMMUTATOR = <,\n NEGATOR = <=,\n RESTRICT = scalargtsel,\n JOIN = scalargtjoinsel\n);\n\n\n--! @brief Greater-than-or-equal backing function for `eql_v2.ste_vec_entry`\n--! @internal\n--! @param a eql_v2.ste_vec_entry Left operand\n--! @param b eql_v2.ste_vec_entry Right operand\n--! @return boolean True if `a`'s CLLW ORE term sorts after or equal to `b`'s\nCREATE FUNCTION eql_v2.gte(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.ore_cllw(a) >= eql_v2.ore_cllw(b)\n$$;\n\nCREATE OPERATOR >= (\n FUNCTION = eql_v2.gte,\n LEFTARG = eql_v2.ste_vec_entry,\n RIGHTARG = eql_v2.ste_vec_entry,\n COMMUTATOR = <=,\n NEGATOR = <,\n RESTRICT = scalargesel,\n JOIN = scalargejoinsel\n);\n\n--! @file operators/sort.sql\n--! @brief Comparison-based sorting functions for encrypted values without operator classes\n--!\n--! Provides O(n log n) quicksort-based sorting using eql_v2.compare() for environments\n--! where btree operator classes are unavailable (e.g., Supabase). This is significantly\n--! faster than the O(n^2) correlated subquery workaround.\n--!\n--! When all input rows share an ORE term (`ob`) the sort path pre-extracts the\n--! ORE order key once per row and compares those keys directly. Rows lacking\n--! an ORE term entirely fall back to `eql_v2.compare()` per pair.\n\n\n--! @internal\n--! @brief Compare pre-extracted ORE order keys with encrypted NULL semantics\n--!\n--! Mirrors eql_v2.compare() for NULL handling, then delegates to the\n--! ore_block_u64_8_256 comparator when both keys are present.\n--!\n--! @param a eql_v2.ore_block_u64_8_256 First order key\n--! @param b eql_v2.ore_block_u64_8_256 Second order key\n--! @return integer -1 if a < b, 0 if a = b, 1 if a > b\nCREATE FUNCTION eql_v2._compare_order_key(\n a eql_v2.ore_block_u64_8_256,\n b eql_v2.ore_block_u64_8_256\n)\nRETURNS integer\nIMMUTABLE PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\nBEGIN\n IF a IS NULL AND b IS NULL THEN\n RETURN 0;\n END IF;\n\n IF a IS NULL THEN\n RETURN -1;\n END IF;\n\n IF b IS NULL THEN\n RETURN 1;\n END IF;\n\n RETURN eql_v2.compare_ore_block_u64_8_256_terms(a, b);\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @internal\n--! @brief Compare two elements from aligned arrays using the selected sort strategy\n--!\n--! @param vals eql_v2_encrypted[] Encrypted values (used when strategy = 'compare')\n--! @param ore_keys eql_v2.ore_block_u64_8_256[] Pre-extracted ORE keys (strategy = 'ore')\n--! @param left_idx integer Index of the left element\n--! @param right_idx integer Index of the right element\n--! @param strategy text One of 'ore' or 'compare'\n--! @return integer -1 if left < right, 0 if equal, 1 if left > right\nCREATE FUNCTION eql_v2._compare_sort_elements(\n vals eql_v2_encrypted[],\n ore_keys eql_v2.ore_block_u64_8_256[],\n left_idx integer,\n right_idx integer,\n strategy text\n)\nRETURNS integer\nIMMUTABLE PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\nBEGIN\n IF strategy = 'ore' THEN\n RETURN eql_v2._compare_order_key(ore_keys[left_idx], ore_keys[right_idx]);\n END IF;\n\n RETURN eql_v2.compare(vals[left_idx], vals[right_idx]);\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @internal\n--! @brief Compare an array element against a captured pivot using the selected strategy\n--!\n--! @param vals eql_v2_encrypted[] Array of encrypted values\n--! @param ore_keys eql_v2.ore_block_u64_8_256[] Array of pre-extracted ORE keys\n--! @param idx integer Index of the element to compare\n--! @param pivot_val eql_v2_encrypted Pivot encrypted value (strategy = 'compare')\n--! @param pivot_ore_key eql_v2.ore_block_u64_8_256 Pivot ORE key (strategy = 'ore')\n--! @param strategy text One of 'ore' or 'compare'\n--! @return integer -1 if element < pivot, 0 if equal, 1 if element > pivot\nCREATE FUNCTION eql_v2._compare_sort_pivot(\n vals eql_v2_encrypted[],\n ore_keys eql_v2.ore_block_u64_8_256[],\n idx integer,\n pivot_val eql_v2_encrypted,\n pivot_ore_key eql_v2.ore_block_u64_8_256,\n strategy text\n)\nRETURNS integer\nIMMUTABLE PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\nBEGIN\n IF strategy = 'ore' THEN\n RETURN eql_v2._compare_order_key(ore_keys[idx], pivot_ore_key);\n END IF;\n\n RETURN eql_v2.compare(vals[idx], pivot_val);\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @internal\n--! @brief In-place insertion sort on parallel id/value/key arrays\n--!\n--! @param ids bigint[] Array of row identifiers (reordered in place)\n--! @param vals eql_v2_encrypted[] Array of encrypted values (reordered in place)\n--! @param ore_keys eql_v2.ore_block_u64_8_256[] Array of pre-extracted ORE keys (reordered in place)\n--! @param lo integer Lower bound index (1-based, inclusive)\n--! @param hi integer Upper bound index (1-based, inclusive)\n--! @param strategy text One of 'ore' or 'compare'\n--! @return ids bigint[] Sorted array of row identifiers\n--! @return vals eql_v2_encrypted[] Sorted array of encrypted values\n--! @return ore_keys eql_v2.ore_block_u64_8_256[] Sorted array of pre-extracted ORE keys\nCREATE FUNCTION eql_v2._insertion_sort(\n INOUT ids bigint[],\n INOUT vals eql_v2_encrypted[],\n INOUT ore_keys eql_v2.ore_block_u64_8_256[],\n lo integer,\n hi integer,\n strategy text\n)\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n i integer;\n j integer;\n key_id bigint;\n key_val eql_v2_encrypted;\n sort_ore_key eql_v2.ore_block_u64_8_256;\nBEGIN\n IF lo >= hi THEN\n RETURN;\n END IF;\n\n FOR i IN lo + 1..hi LOOP\n key_id := ids[i];\n key_val := vals[i];\n sort_ore_key := ore_keys[i];\n j := i - 1;\n\n WHILE j >= lo LOOP\n EXIT WHEN strategy = 'compare'\n AND eql_v2.compare(vals[j], key_val) <= 0;\n EXIT WHEN strategy = 'ore'\n AND eql_v2._compare_order_key(ore_keys[j], sort_ore_key) <= 0;\n\n ids[j + 1] := ids[j];\n vals[j + 1] := vals[j];\n ore_keys[j + 1] := ore_keys[j];\n j := j - 1;\n END LOOP;\n\n ids[j + 1] := key_id;\n vals[j + 1] := key_val;\n ore_keys[j + 1] := sort_ore_key;\n END LOOP;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @internal\n--! @brief In-place quicksort on parallel id/value/key arrays\n--!\n--! Sorts aligned arrays simultaneously using Hoare partition with median-of-three pivot\n--! selection. The median-of-three strategy avoids O(n^2) degradation on already-sorted\n--! input, which is common with sequential test data.\n--!\n--! @param ids bigint[] Array of row identifiers (reordered in place)\n--! @param vals eql_v2_encrypted[] Array of encrypted values to compare (reordered in place)\n--! @param ore_keys eql_v2.ore_block_u64_8_256[] Pre-extracted ORE keys (reordered in place)\n--! @param lo integer Lower bound index (1-based, inclusive)\n--! @param hi integer Upper bound index (1-based, inclusive)\n--! @param strategy text One of 'ore' or 'compare'\n--!\n--! @return ids bigint[] Sorted array of row identifiers\n--! @return vals eql_v2_encrypted[] Sorted array of encrypted values\n--! @return ore_keys eql_v2.ore_block_u64_8_256[] Sorted array of pre-extracted ORE keys\nCREATE FUNCTION eql_v2._quicksort_sorter(\n INOUT ids bigint[],\n INOUT vals eql_v2_encrypted[],\n INOUT ore_keys eql_v2.ore_block_u64_8_256[],\n lo integer,\n hi integer,\n strategy text\n)\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n insertion_threshold CONSTANT integer := 16;\n pivot_val eql_v2_encrypted;\n pivot_ore_key eql_v2.ore_block_u64_8_256;\n mid integer;\n i integer;\n j integer;\n left_hi integer;\n right_lo integer;\n tmp_id bigint;\n tmp_val eql_v2_encrypted;\n tmp_ore_key eql_v2.ore_block_u64_8_256;\nBEGIN\n WHILE lo < hi LOOP\n IF hi - lo <= insertion_threshold THEN\n SELECT q.ids, q.vals, q.ore_keys\n INTO ids, vals, ore_keys\n FROM eql_v2._insertion_sort(ids, vals, ore_keys, lo, hi, strategy) q;\n RETURN;\n END IF;\n\n -- Median-of-three pivot selection: sort lo, mid, hi then use mid as pivot\n mid := lo + (hi - lo) / 2;\n\n IF eql_v2._compare_sort_elements(vals, ore_keys, lo, mid, strategy) > 0 THEN\n tmp_id := ids[lo]; ids[lo] := ids[mid]; ids[mid] := tmp_id;\n tmp_val := vals[lo]; vals[lo] := vals[mid]; vals[mid] := tmp_val;\n tmp_ore_key := ore_keys[lo]; ore_keys[lo] := ore_keys[mid]; ore_keys[mid] := tmp_ore_key;\n END IF;\n IF eql_v2._compare_sort_elements(vals, ore_keys, lo, hi, strategy) > 0 THEN\n tmp_id := ids[lo]; ids[lo] := ids[hi]; ids[hi] := tmp_id;\n tmp_val := vals[lo]; vals[lo] := vals[hi]; vals[hi] := tmp_val;\n tmp_ore_key := ore_keys[lo]; ore_keys[lo] := ore_keys[hi]; ore_keys[hi] := tmp_ore_key;\n END IF;\n IF eql_v2._compare_sort_elements(vals, ore_keys, mid, hi, strategy) > 0 THEN\n tmp_id := ids[mid]; ids[mid] := ids[hi]; ids[hi] := tmp_id;\n tmp_val := vals[mid]; vals[mid] := vals[hi]; vals[hi] := tmp_val;\n tmp_ore_key := ore_keys[mid]; ore_keys[mid] := ore_keys[hi]; ore_keys[hi] := tmp_ore_key;\n END IF;\n\n pivot_val := vals[mid];\n pivot_ore_key := ore_keys[mid];\n i := lo;\n j := hi;\n\n LOOP\n WHILE eql_v2._compare_sort_pivot(\n vals, ore_keys, i,\n pivot_val, pivot_ore_key, strategy\n ) < 0 LOOP\n i := i + 1;\n END LOOP;\n WHILE eql_v2._compare_sort_pivot(\n vals, ore_keys, j,\n pivot_val, pivot_ore_key, strategy\n ) > 0 LOOP\n j := j - 1;\n END LOOP;\n\n EXIT WHEN i >= j;\n\n tmp_id := ids[i]; ids[i] := ids[j]; ids[j] := tmp_id;\n tmp_val := vals[i]; vals[i] := vals[j]; vals[j] := tmp_val;\n tmp_ore_key := ore_keys[i]; ore_keys[i] := ore_keys[j]; ore_keys[j] := tmp_ore_key;\n\n i := i + 1;\n j := j - 1;\n END LOOP;\n\n left_hi := j;\n right_lo := j + 1;\n\n IF left_hi - lo < hi - right_lo THEN\n IF lo < left_hi THEN\n SELECT q.ids, q.vals, q.ore_keys\n INTO ids, vals, ore_keys\n FROM eql_v2._quicksort_sorter(ids, vals, ore_keys, lo, left_hi, strategy) q;\n END IF;\n lo := right_lo;\n ELSE\n IF right_lo < hi THEN\n SELECT q.ids, q.vals, q.ore_keys\n INTO ids, vals, ore_keys\n FROM eql_v2._quicksort_sorter(ids, vals, ore_keys, right_lo, hi, strategy) q;\n END IF;\n hi := left_hi;\n END IF;\n END LOOP;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @internal\n--! @brief Emit aligned arrays as rows in ASC or DESC order\n--!\n--! @param ids bigint[] Array of sorted row identifiers\n--! @param vals eql_v2_encrypted[] Array of sorted encrypted values\n--! @param direction text Sort direction: 'ASC' (default) or 'DESC'\n--! @return TABLE(id bigint, val eql_v2_encrypted) Rows emitted in the requested order\nCREATE FUNCTION eql_v2._emit_sorted_rows(\n ids bigint[],\n vals eql_v2_encrypted[],\n direction text DEFAULT 'ASC'\n)\nRETURNS TABLE(id bigint, val eql_v2_encrypted)\nIMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n n integer;\n i integer;\nBEGIN\n n := coalesce(array_length(ids, 1), 0);\n\n IF upper(direction) = 'DESC' THEN\n FOR i IN REVERSE n..1 LOOP\n id := ids[i];\n val := vals[i];\n RETURN NEXT;\n END LOOP;\n ELSE\n FOR i IN 1..n LOOP\n id := ids[i];\n val := vals[i];\n RETURN NEXT;\n END LOOP;\n END IF;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @internal\n--! @brief Sort encrypted values using precomputed ORE keys when available\n--!\n--! Shared implementation for public sorting entrypoints. The `strategy`\n--! parameter selects the comparison path: `'ore'` uses the aligned `ore_keys`\n--! array; `'compare'` falls back to `eql_v2.compare()` on the encrypted values\n--! directly.\n--!\n--! @param ids bigint[] Row identifiers aligned with `vals`\n--! @param vals eql_v2_encrypted[] Encrypted values to sort\n--! @param ore_keys eql_v2.ore_block_u64_8_256[] Pre-extracted ORE keys (used when strategy = 'ore')\n--! @param direction text Sort direction: 'ASC' (default) or 'DESC'\n--! @param strategy text One of 'ore' or 'compare'\n--! @return TABLE(id bigint, val eql_v2_encrypted) Sorted rows\nCREATE FUNCTION eql_v2._sort_compare_precomputed(\n ids bigint[],\n vals eql_v2_encrypted[],\n ore_keys eql_v2.ore_block_u64_8_256[],\n direction text DEFAULT 'ASC',\n strategy text DEFAULT 'ore'\n)\nRETURNS TABLE(id bigint, val eql_v2_encrypted)\nIMMUTABLE PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n n integer;\n m integer;\n k integer;\n sorted_ids bigint[];\n sorted_vals eql_v2_encrypted[];\n sorted_ore_keys eql_v2.ore_block_u64_8_256[];\nBEGIN\n n := coalesce(array_length(ids, 1), 0);\n m := coalesce(array_length(vals, 1), 0);\n\n IF n <> m THEN\n RAISE EXCEPTION 'ids and vals must have the same length';\n END IF;\n\n IF strategy = 'ore' THEN\n k := coalesce(array_length(ore_keys, 1), 0);\n IF n <> k THEN\n RAISE EXCEPTION 'ids and ore_keys must have the same length when strategy = ''ore''';\n END IF;\n END IF;\n\n IF n = 0 THEN\n RETURN;\n END IF;\n\n IF n = 1 THEN\n id := ids[1];\n val := vals[1];\n RETURN NEXT;\n RETURN;\n END IF;\n\n SELECT q.ids, q.vals, q.ore_keys\n INTO sorted_ids, sorted_vals, sorted_ore_keys\n FROM eql_v2._quicksort_sorter(ids, vals, ore_keys, 1, n, strategy) q;\n\n RETURN QUERY\n SELECT emitted.id, emitted.val\n FROM eql_v2._emit_sorted_rows(sorted_ids, sorted_vals, direction) emitted;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Sort encrypted values using comparison-based quicksort\n--!\n--! Sorts parallel arrays of identifiers and encrypted values using O(n log n)\n--! quicksort with eql_v2.compare(). Returns sorted rows as a table, avoiding\n--! the need for unnest() or other array manipulation by callers.\n--!\n--! When all input rows share an `ore` term the sort uses pre-extracted ORE\n--! keys; otherwise it falls back to `eql_v2.compare()` per pair.\n--!\n--! This function is designed for environments without operator classes (e.g., Supabase)\n--! where direct ORDER BY on encrypted columns is not available.\n--!\n--! @param ids bigint[] Array of row identifiers\n--! @param vals eql_v2_encrypted[] Array of encrypted values (must be same length as ids)\n--! @param direction text Sort direction: 'ASC' (default) or 'DESC'\n--! @return TABLE(id bigint, val eql_v2_encrypted) Sorted rows\n--!\n--! @example\n--! -- Sort all rows from an encrypted table\n--! SELECT * FROM eql_v2.sort_compare(\n--! (SELECT array_agg(id ORDER BY id) FROM ore),\n--! (SELECT array_agg(e ORDER BY id) FROM ore),\n--! 'ASC'\n--! );\n--!\n--! -- Sort with a filter\n--! SELECT * FROM eql_v2.sort_compare(\n--! (SELECT array_agg(id ORDER BY id) FROM ore WHERE id > 42),\n--! (SELECT array_agg(e ORDER BY id) FROM ore WHERE id > 42),\n--! 'DESC'\n--! );\n--!\n--! -- Compose with LIMIT\n--! SELECT * FROM eql_v2.sort_compare(\n--! (SELECT array_agg(id ORDER BY id) FROM ore),\n--! (SELECT array_agg(e ORDER BY id) FROM ore)\n--! ) LIMIT 5;\n--!\n--! @see eql_v2.compare\n--! @see eql_v2.order_by_compare\nCREATE FUNCTION eql_v2.sort_compare(\n ids bigint[],\n vals eql_v2_encrypted[],\n direction text DEFAULT 'ASC'\n)\nRETURNS TABLE(id bigint, val eql_v2_encrypted)\nIMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n n integer;\n sorted_ore_keys eql_v2.ore_block_u64_8_256[];\n i integer;\n use_ore boolean := true;\n strategy text;\nBEGIN\n n := coalesce(array_length(ids, 1), 0);\n\n -- Pre-extract sort keys. ORE wins if every non-NULL row carries `ob`,\n -- otherwise fall back to eql_v2.compare() per pair.\n FOR i IN 1..n LOOP\n IF vals[i] IS NULL THEN\n sorted_ore_keys[i] := NULL;\n ELSE\n IF use_ore THEN\n IF eql_v2.has_ore_block_u64_8_256(vals[i]) THEN\n sorted_ore_keys[i] := eql_v2.order_by(vals[i]);\n ELSE\n use_ore := false;\n END IF;\n END IF;\n\n EXIT WHEN NOT use_ore;\n END IF;\n END LOOP;\n\n IF use_ore THEN\n strategy := 'ore';\n ELSE\n strategy := 'compare';\n END IF;\n\n RETURN QUERY\n SELECT sc.id, sc.val\n FROM eql_v2._sort_compare_precomputed(\n ids, vals, sorted_ore_keys, direction, strategy\n ) sc;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Sort encrypted values from a table using column and table references\n--!\n--! Convenience overload that accepts column names, a table name, and an optional\n--! filter clause instead of pre-aggregated arrays. Internally constructs the\n--! query and delegates to eql_v2.order_by_compare().\n--!\n--! @param id_column text Name of the bigint identifier column\n--! @param val_column text Name of the eql_v2_encrypted value column\n--! @param tbl text Table name (may be schema-qualified)\n--! @param direction text Sort direction: 'ASC' (default) or 'DESC'\n--! @param filter text Optional WHERE clause (without the WHERE keyword)\n--! @return TABLE(id bigint, val eql_v2_encrypted) Sorted rows\n--!\n--! @note The id column must be castable to bigint. Uses dynamic SQL internally.\n--! @warning The filter parameter is executed as dynamic SQL. Use only with trusted input.\n--!\n--! @example\n--! -- Sort all rows ascending (default)\n--! SELECT * FROM eql_v2.sort_compare('id', 'e', 'ore');\n--!\n--! -- Sort descending\n--! SELECT * FROM eql_v2.sort_compare('id', 'e', 'ore', 'DESC');\n--!\n--! -- Sort with a filter\n--! SELECT * FROM eql_v2.sort_compare('id', 'e', 'ore', 'ASC', 'id > 42');\n--!\n--! -- Compose with LIMIT\n--! SELECT * FROM eql_v2.sort_compare('id', 'e', 'ore') LIMIT 10;\n--!\n--! @see eql_v2.sort_compare(bigint[], eql_v2_encrypted[], text)\n--! @see eql_v2.order_by_compare\nCREATE FUNCTION eql_v2.sort_compare(\n id_column text,\n val_column text,\n tbl text,\n direction text DEFAULT 'ASC',\n filter text DEFAULT NULL\n)\nRETURNS TABLE(id bigint, val eql_v2_encrypted)\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n query text;\n resolved_tbl regclass;\nBEGIN\n resolved_tbl := to_regclass(tbl);\n\n IF resolved_tbl IS NULL THEN\n RAISE EXCEPTION 'table \"%\" does not exist', tbl;\n END IF;\n\n query := format('SELECT %I, %I FROM %s', id_column, val_column, resolved_tbl);\n\n IF filter IS NOT NULL THEN\n query := query || ' WHERE ' || filter;\n END IF;\n\n RETURN QUERY\n SELECT sc.id, sc.val\n FROM eql_v2.order_by_compare(query, direction) sc;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Sort encrypted values from a query using comparison-based quicksort\n--!\n--! Convenience wrapper that accepts a SQL query string, executes it, collects the\n--! results, and returns them sorted. For ORE-backed values this pre-extracts the\n--! order key once per row and sorts on that key; other inputs fall back to\n--! eql_v2.compare(). The query must return exactly two columns: a bigint\n--! identifier and an eql_v2_encrypted value.\n--!\n--! @param query text SQL query returning (bigint, eql_v2_encrypted) columns\n--! @param direction text Sort direction: 'ASC' (default) or 'DESC'\n--! @return TABLE(id bigint, val eql_v2_encrypted) Sorted rows\n--!\n--! @note Uses dynamic SQL (EXECUTE) so cannot be IMMUTABLE or PARALLEL SAFE\n--! @warning The query parameter is executed as dynamic SQL. Use only with trusted input.\n--!\n--! @example\n--! -- Sort all rows\n--! SELECT * FROM eql_v2.order_by_compare('SELECT id, e FROM ore');\n--!\n--! -- Sort with WHERE clause\n--! SELECT * FROM eql_v2.order_by_compare(\n--! 'SELECT id, e FROM ore WHERE id > 42',\n--! 'DESC'\n--! );\n--!\n--! @see eql_v2.sort_compare\n--! @see eql_v2.compare\nCREATE FUNCTION eql_v2.order_by_compare(\n query text,\n direction text DEFAULT 'ASC'\n)\nRETURNS TABLE(id bigint, val eql_v2_encrypted)\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n all_ids bigint[];\n all_vals eql_v2_encrypted[];\n all_ore_keys eql_v2.ore_block_u64_8_256[];\n all_have_ore_keys boolean;\n strategy text;\nBEGIN\n -- Pre-extract sort keys. ORE wins if every non-NULL row carries `ob`,\n -- otherwise fall back to eql_v2.compare() per pair.\n EXECUTE format(\n 'WITH input_rows AS (\n SELECT row_number() OVER () AS ord,\n sub.id,\n sub.val,\n CASE\n WHEN sub.val IS NULL THEN NULL\n WHEN eql_v2.has_ore_block_u64_8_256(sub.val) THEN eql_v2.order_by(sub.val)\n ELSE NULL\n END AS ore_key,\n CASE\n WHEN sub.val IS NULL THEN TRUE\n ELSE eql_v2.has_ore_block_u64_8_256(sub.val)\n END AS has_ore_key\n FROM (%s) sub(id, val)\n )\n SELECT array_agg(id ORDER BY ord),\n array_agg(val ORDER BY ord),\n array_agg(ore_key ORDER BY ord),\n coalesce(bool_and(has_ore_key), TRUE)\n FROM input_rows',\n query\n ) INTO all_ids, all_vals, all_ore_keys, all_have_ore_keys;\n\n IF all_ids IS NULL THEN\n RETURN;\n END IF;\n\n IF all_have_ore_keys THEN\n strategy := 'ore';\n ELSE\n strategy := 'compare';\n END IF;\n\n RETURN QUERY\n SELECT sc.id, sc.val\n FROM eql_v2._sort_compare_precomputed(\n all_ids,\n all_vals,\n all_ore_keys,\n direction,\n strategy\n ) sc;\nEND;\n$$ LANGUAGE plpgsql;\n\n--! @file src/operators/operator_class.sql\n--! @brief Btree operator class for the `eql_v2_encrypted` composite type\n--!\n--! `eql_v2_encrypted` is a composite type. PostgreSQL gives every composite\n--! type an implicit row-wise btree comparison (`record_ops`) — but that\n--! compares the raw ciphertext byte-for-byte, so two encryptions of the same\n--! plaintext (same `hm`, different `c`) would sort and group as *distinct*.\n--! `eql_v2.encrypted_operator_class` is registered `DEFAULT ... USING btree`\n--! specifically to override `record_ops` with a comparison that is correct\n--! for encrypted data: `GROUP BY`, `DISTINCT`, `ORDER BY`, sort-merge joins\n--! and `ANALYZE` on a bare `eql_v2_encrypted` column all route through\n--! FUNCTION 1 below.\n--!\n--! @note FUNCTION 1 is `eql_v2.encrypted_btree_compare`, NOT the strict\n--! `eql_v2.compare`. A btree support function must be total and must\n--! never raise — `ANALYZE` calls it to build column statistics on\n--! every encrypted column. `eql_v2.compare` is deliberately strict\n--! (it raises without a Block-ORE `ob` term — see U-005); it backs\n--! the `<` / `>` range operators, not this opclass.\n--!\n--! @note Functional indexes are the canonical recipe for *building* indexes\n--! on encrypted columns (see U-001 and docs/reference/database-indexes.md).\n--! This opclass exists to keep the composite type's built-in\n--! comparison correct — not as an index-building recommendation.\n--!\n--! @see eql_v2.encrypted_hash_operator_class (hash — GROUP BY / hash joins)\n--! @see eql_v2.compare\n\n--------------------\n\n--! @brief Total, non-raising btree comparator for `eql_v2_encrypted`\n--!\n--! Three-way comparison (`-1` / `0` / `1`) used as FUNCTION 1 of\n--! `eql_v2.encrypted_operator_class`. Unlike `eql_v2.compare`, it never\n--! raises: a btree support function is invoked by `ANALYZE`, sort, and\n--! `GROUP BY` on every value, so raising is not an option.\n--!\n--! Comparison priority:\n--! 1. Both operands carry `ob` (Block ORE) — order-preserving comparison\n--! via `eql_v2.compare_ore_block_u64_8_256`.\n--! 2. Both operands carry `hm` (HMAC-256) — a total order on the hmac\n--! bytes. Not order-preserving on plaintext (hmac is not), but\n--! deterministic, total, and `= 0` exactly when the hmac terms match\n--! — consistent with the `=` operator, so `GROUP BY` / `DISTINCT`\n--! deduplicate correctly.\n--! 3. Otherwise — a deterministic order on the raw payload. Reached only\n--! for term-less / mixed payloads; present so the function stays total.\n--!\n--! @param a eql_v2_encrypted First value\n--! @param b eql_v2_encrypted Second value\n--! @return integer -1, 0, or 1\n--!\n--! @internal\n--! @see eql_v2.encrypted_operator_class\n--! @see eql_v2.compare\nCREATE FUNCTION eql_v2.encrypted_btree_compare(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS integer\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n hm_a text;\n hm_b text;\n BEGIN\n -- Block ORE on both sides: order-preserving comparison.\n IF eql_v2.has_ore_block_u64_8_256(a) AND eql_v2.has_ore_block_u64_8_256(b) THEN\n RETURN eql_v2.compare_ore_block_u64_8_256(a, b);\n END IF;\n\n -- HMAC on both sides: total order on the hmac bytes. `= 0` iff the hmac\n -- terms match, consistent with the `=` operator and the hash opclass.\n hm_a := eql_v2.hmac_256(a)::text;\n hm_b := eql_v2.hmac_256(b)::text;\n IF hm_a IS NOT NULL AND hm_b IS NOT NULL THEN\n RETURN CASE\n WHEN hm_a < hm_b THEN -1\n WHEN hm_a > hm_b THEN 1\n ELSE 0\n END;\n END IF;\n\n -- Fallback for term-less / mixed payloads: a deterministic, non-raising\n -- total order on the raw payload. Not a normal column shape — this\n -- branch only keeps the btree FUNCTION 1 contract (total, never raises).\n RETURN CASE\n WHEN (a).data::text < (b).data::text THEN -1\n WHEN (a).data::text > (b).data::text THEN 1\n ELSE 0\n END;\n END;\n$$ LANGUAGE plpgsql;\n\n--------------------\n\nCREATE OPERATOR FAMILY eql_v2.encrypted_operator_family USING btree;\n\nCREATE OPERATOR CLASS eql_v2.encrypted_operator_class DEFAULT FOR TYPE eql_v2_encrypted USING btree FAMILY eql_v2.encrypted_operator_family AS\n OPERATOR 1 <,\n OPERATOR 2 <=,\n OPERATOR 3 =,\n OPERATOR 4 >=,\n OPERATOR 5 >,\n FUNCTION 1 eql_v2.encrypted_btree_compare(a eql_v2_encrypted, b eql_v2_encrypted);\n\n--! @brief PostgreSQL hash operator class for encrypted value hashing\n--!\n--! Defines the hash operator family and operator class required for hash-based\n--! operations on encrypted values. This enables PostgreSQL to use hash strategies for:\n--! - Hash joins (cross-row equality via hash)\n--! - GROUP BY (hash aggregation)\n--! - DISTINCT (hash-based deduplication)\n--! - UNION (hash-based set operations)\n--!\n--! Only the same-type equality operator (eql_v2_encrypted = eql_v2_encrypted) is\n--! registered. Cross-type operators (encrypted/jsonb) are excluded because hash\n--! joins require independent hashing of each side before comparison.\n--!\n--! @note Requires hmac_256 index terms for correct hashing\n--! @see eql_v2.hash_encrypted\n--! @see eql_v2.encrypted_operator_class (btree)\n\nCREATE OPERATOR FAMILY eql_v2.encrypted_hash_operator_family USING hash;\n\nCREATE OPERATOR CLASS eql_v2.encrypted_hash_operator_class\n DEFAULT FOR TYPE eql_v2_encrypted USING hash\n FAMILY eql_v2.encrypted_hash_operator_family AS\n OPERATOR 1 = (eql_v2_encrypted, eql_v2_encrypted),\n FUNCTION 1 eql_v2.hash_encrypted(eql_v2_encrypted);\n\n--! @brief Contained-by operator for encrypted values (<@)\n--!\n--! Implements the <@ (contained-by) operator for testing if left encrypted value\n--! is contained by the right encrypted value. Uses ste_vec (secure tree encoding vector)\n--! index terms for containment testing without decryption. Reverse of @> operator.\n--!\n--! Primarily used for encrypted array or set containment queries.\n--!\n--! @param a eql_v2_encrypted Left operand (contained value)\n--! @param b eql_v2_encrypted Right operand (container)\n--! @return Boolean True if a is contained by b\n--!\n--! @example\n--! -- Check if value is contained in encrypted array\n--! SELECT * FROM documents\n--! WHERE '[\"security\"]'::jsonb::eql_v2_encrypted <@ encrypted_tags;\n--!\n--! @note Requires ste_vec index configuration\n--! @see eql_v2.ste_vec_contains\n--! @see eql_v2.\\\"@>\\\"\n--! @see eql_v2.add_search_config\n\n-- Marked IMMUTABLE STRICT PARALLEL SAFE — see operators/@>.sql for rationale.\nCREATE FUNCTION eql_v2.\"<@\"(a eql_v2_encrypted, b eql_v2_encrypted)\nRETURNS boolean\nLANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n -- Contains with reversed arguments\n SELECT eql_v2.ste_vec_contains(b, a)\n$$;\n\nCREATE OPERATOR <@(\n FUNCTION=eql_v2.\"<@\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted\n);\n\n\n--! @brief Contained-by operator (<@) with an `eql_v2.stevec_query` LHS\n--!\n--! Reverse of `@>(eql_v2_encrypted, eql_v2.stevec_query)`. Mirrors the\n--! typed needle convention: \"is this query payload contained in that\n--! encrypted document?\".\n--!\n--! @param a eql_v2.stevec_query Left operand (query payload)\n--! @param b eql_v2_encrypted Right operand (container)\n--! @return Boolean True if `b` contains `a`\n--! @see eql_v2.\"@>\"(eql_v2_encrypted, eql_v2.stevec_query)\nCREATE FUNCTION eql_v2.\"<@\"(a eql_v2.stevec_query, b eql_v2_encrypted)\nRETURNS boolean\nLANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.\"@>\"(b, a)\n$$;\n\nCREATE OPERATOR <@(\n FUNCTION=eql_v2.\"<@\",\n LEFTARG=eql_v2.stevec_query,\n RIGHTARG=eql_v2_encrypted\n);\n\n\n--! @brief Contained-by operator (<@) with an `eql_v2.ste_vec_entry` LHS\n--!\n--! Reverse of `@>(eql_v2_encrypted, eql_v2.ste_vec_entry)`. Convenience\n--! shape for \"is this entry contained in that encrypted document?\".\n--!\n--! @param a eql_v2.ste_vec_entry Left operand (single entry)\n--! @param b eql_v2_encrypted Right operand (container)\n--! @return Boolean True if `b` contains `a`\n--! @see eql_v2.\"@>\"(eql_v2_encrypted, eql_v2.ste_vec_entry)\nCREATE FUNCTION eql_v2.\"<@\"(a eql_v2.ste_vec_entry, b eql_v2_encrypted)\nRETURNS boolean\nLANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.\"@>\"(b, a)\n$$;\n\nCREATE OPERATOR <@(\n FUNCTION=eql_v2.\"<@\",\n LEFTARG=eql_v2.ste_vec_entry,\n RIGHTARG=eql_v2_encrypted\n);\n\n--! @brief Inequality helper for encrypted values\n--! @internal\n--!\n--! Inlinable SQL helper mirroring the `<>` operator's body: reduces to\n--! `hmac_256(a) <> hmac_256(b)`. Kept for callers that invoked the\n--! pre-#193 form (`eql_v2.neq`); equivalent to using the `<>` operator\n--! directly.\n--!\n--! Inequality on `eql_v2_encrypted` is strictly hmac-based (see U-002).\n--! Returns NULL when either side lacks an `hm` term — matching the\n--! `<>` operator's behaviour.\n--!\n--! @param a eql_v2_encrypted First encrypted value\n--! @param b eql_v2_encrypted Second encrypted value\n--! @return Boolean True if hmac terms differ\n--!\n--! @see eql_v2.\"<>\"\n--! @see eql_v2.hmac_256\nCREATE FUNCTION eql_v2.neq(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.hmac_256(a) <> eql_v2.hmac_256(b)\n$$;\n\n--! @brief Not-equal operator for encrypted values\n--!\n--! Implements the <> (not equal) operator for comparing encrypted values using their\n--! encrypted index terms. Enables WHERE clause inequality comparisons without decryption.\n--!\n--! @param a eql_v2_encrypted Left operand\n--! @param b eql_v2_encrypted Right operand\n--! @return Boolean True if encrypted values are not equal\n--!\n--! @example\n--! -- Find records with non-matching values\n--! SELECT * FROM users\n--! WHERE encrypted_email <> 'admin@example.com'::text::eql_v2_encrypted;\n--!\n--! @see eql_v2.compare\n--! @see eql_v2.\"=\"\n-- Inlinable; mirrors `=` (see operators/=.sql for rationale).\n-- Returns NULL on ORE-only encrypted columns (no `hm` field) instead\n-- of falling back to a slower comparison path; surface the config\n-- error rather than hide it.\nCREATE FUNCTION eql_v2.\"<>\"(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.hmac_256(a) <> eql_v2.hmac_256(b)\n$$;\n\n\nCREATE OPERATOR <> (\n FUNCTION=eql_v2.\"<>\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted,\n NEGATOR = =,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\n--! @brief <> operator for encrypted value and JSONB\n--! @see eql_v2.\"<>\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"<>\"(a eql_v2_encrypted, b jsonb)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.hmac_256(a) <> eql_v2.hmac_256(b::eql_v2_encrypted)\n$$;\n\nCREATE OPERATOR <> (\n FUNCTION=eql_v2.\"<>\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=jsonb,\n NEGATOR = =,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\n--! @brief <> operator for JSONB and encrypted value\n--!\n--! @param jsonb Plain JSONB value\n--! @param eql_v2_encrypted Encrypted value\n--! @return boolean True if values are not equal\n--!\n--! @see eql_v2.\"<>\"(eql_v2_encrypted, eql_v2_encrypted)\nCREATE FUNCTION eql_v2.\"<>\"(a jsonb, b eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.hmac_256(a::eql_v2_encrypted) <> eql_v2.hmac_256(b)\n$$;\n\nCREATE OPERATOR <> (\n FUNCTION=eql_v2.\"<>\",\n LEFTARG=jsonb,\n RIGHTARG=eql_v2_encrypted,\n NEGATOR = =,\n RESTRICT = eqsel,\n JOIN = eqjoinsel,\n MERGES\n);\n\n\n\n\n\n--! @brief JSONB field accessor operator alias (->>)\n--!\n--! Implements the ->> operator as an alias of -> for encrypted JSONB data. This mirrors\n--! PostgreSQL semantics where ->> returns text via implicit casts. The underlying\n--! implementation delegates to eql_v2.\"->\" and allows PostgreSQL to coerce the result.\n--!\n--! Provides two overloads:\n--! - (eql_v2_encrypted, text) - Field name selector\n--! - (eql_v2_encrypted, eql_v2_encrypted) - Encrypted selector\n--!\n--! @see eql_v2.\"->\"\n--! @see eql_v2.selector\n\n--! @brief ->> operator with text selector\n--! @param eql_v2_encrypted Encrypted JSONB data\n--! @param text Field name to extract\n--! @return text Encrypted value at selector, implicitly cast from eql_v2_encrypted\n--! @example\n--! SELECT encrypted_json ->> 'field_name' FROM table;\nCREATE FUNCTION eql_v2.\"->>\"(e eql_v2_encrypted, selector text)\n RETURNS text\nIMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n found eql_v2_encrypted;\n\tBEGIN\n -- found = eql_v2.\"->\"(e, selector);\n -- RETURN eql_v2.ciphertext(found);\n RETURN eql_v2.\"->\"(e, selector);\n END;\n$$ LANGUAGE plpgsql;\n\n\nCREATE OPERATOR ->> (\n FUNCTION=eql_v2.\"->>\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=text\n);\n\n\n\n---------------------------------------------------\n\n--! @brief ->> operator with encrypted selector\n--! @param e eql_v2_encrypted Encrypted JSONB data\n--! @param selector eql_v2_encrypted Encrypted field selector\n--! @return text Encrypted value at selector, implicitly cast from eql_v2_encrypted\n--! @see eql_v2.\"->>\"(eql_v2_encrypted, text)\nCREATE FUNCTION eql_v2.\"->>\"(e eql_v2_encrypted, selector eql_v2_encrypted)\n RETURNS text\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n RETURN eql_v2.\"->>\"(e, eql_v2._selector(selector));\n END;\n$$ LANGUAGE plpgsql;\n\n\nCREATE OPERATOR ->> (\n FUNCTION=eql_v2.\"->>\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted\n);\n\n--! @brief JSONB field accessor operator for encrypted values (->)\n--!\n--! Implements the -> operator to access fields/elements from encrypted JSONB data.\n--! Returns the matching sv entry as `eql_v2.ste_vec_entry` (or NULL on miss).\n--!\n--! Encrypted JSON is represented as an array of sv elements in the\n--! StEVec format. Each element has a selector, ciphertext, and index\n--! terms: `{\"sv\": [{\"c\": \"...\", \"s\": \"...\", \"hm\": \"...\"}, ...]}`.\n--!\n--! Provides three overloads:\n--! - (eql_v2_encrypted, text) - Field name selector\n--! - (eql_v2_encrypted, eql_v2_encrypted) - Encrypted selector\n--! - (eql_v2_encrypted, integer) - Array index selector (0-based)\n--!\n--! All three return `eql_v2.ste_vec_entry` and preserve the source\n--! payload's root `i` / `v` envelope metadata in the returned entry\n--! (the DOMAIN CHECK on `ste_vec_entry` doesn't forbid extra fields).\n--!\n--! @note Operator resolution: Assignment casts are considered (PostgreSQL standard behavior).\n--! To use text selector, parameter may need explicit cast to text.\n--!\n--! @see eql_v2.ste_vec_entry\n--! @see eql_v2.selector\n--! @see eql_v2.\"->>\"\n\n--! @brief -> operator with text selector\n--!\n--! Returns the sv entry whose `s` selector equals @p selector, with\n--! the source payload's `i` / `v` metadata merged in. Selectors are\n--! deterministic per (path, key) within a document, so at most one\n--! entry matches; `jsonb_path_query_first` returns the first match\n--! and stops scanning.\n--!\n--! Inlinable single-statement SQL: the planner folds this body into\n--! the calling query, so `WHERE col -> 'sel' = $1` reduces structurally\n--! to `eql_v2.eq_term(col -> 'sel') = eql_v2.eq_term($1)` and matches\n--! a functional index built on `eql_v2.eq_term(col -> 'sel')`.\n--!\n--! @param e eql_v2_encrypted Encrypted JSONB payload (root)\n--! @param selector text Selector hash (the `s` field value)\n--! @return eql_v2.ste_vec_entry Matching entry merged with root meta,\n--! NULL if no element matches.\n--!\n--! @note The returned entry carries `i` / `v` from the root in addition\n--! to the sv-element fields. This is intentional: per-entry\n--! extractors (`eql_v2.eq_term`, `eql_v2.ore_cllw`, ...) read\n--! only their own fields and ignore `i` / `v`; callers that need\n--! the root envelope (e.g. for decryption) still see it.\n--!\n--! @example\n--! SELECT encrypted_json -> 'field_name' FROM table;\nCREATE FUNCTION eql_v2.\"->\"(e eql_v2_encrypted, selector text)\n RETURNS eql_v2.ste_vec_entry\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT (\n eql_v2.meta_data(e) ||\n jsonb_path_query_first(\n (e).data,\n '$.sv[*] ? (@.s == $sel)'::jsonpath,\n jsonb_build_object('sel', selector)\n )\n )::eql_v2.ste_vec_entry\n$$;\n\n\nCREATE OPERATOR ->(\n FUNCTION=eql_v2.\"->\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=text\n);\n\n---------------------------------------------------\n\n--! @brief -> operator with encrypted selector\n--!\n--! Convenience overload: extracts the selector text from an encrypted\n--! selector payload and delegates to the (text) form. Inlinable.\n--!\n--! @param e eql_v2_encrypted Encrypted JSONB data\n--! @param selector eql_v2_encrypted Encrypted selector payload\n--! @return eql_v2.ste_vec_entry Matching entry, NULL on miss\n--! @see eql_v2.\"->\"(eql_v2_encrypted, text)\nCREATE FUNCTION eql_v2.\"->\"(e eql_v2_encrypted, selector eql_v2_encrypted)\n RETURNS eql_v2.ste_vec_entry\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.\"->\"(e, eql_v2._selector(selector))\n$$;\n\n\n\nCREATE OPERATOR ->(\n FUNCTION=eql_v2.\"->\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=eql_v2_encrypted\n);\n\n\n---------------------------------------------------\n\n--! @brief -> operator with integer array index\n--!\n--! Returns the sv entry at the given (0-based, JSONB-style) array\n--! index, merged with the root payload's `i` / `v` metadata. Returns\n--! NULL when the underlying value isn't an sv-array payload or when\n--! the index is out of bounds.\n--!\n--! @param e eql_v2_encrypted Encrypted sv-array payload\n--! @param selector integer Array index (0-based, JSONB convention)\n--! @return eql_v2.ste_vec_entry Matching entry, NULL on miss\n--! @note Array index is 0-based (JSONB standard) despite PostgreSQL arrays being 1-based\n--! @example\n--! SELECT encrypted_array -> 0 FROM table;\n--! @see eql_v2.is_ste_vec_array\nCREATE FUNCTION eql_v2.\"->\"(e eql_v2_encrypted, selector integer)\n RETURNS eql_v2.ste_vec_entry\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT CASE\n WHEN eql_v2.is_ste_vec_array(e) THEN\n (eql_v2.meta_data(e) || ((e).data -> 'sv' -> selector))::eql_v2.ste_vec_entry\n ELSE NULL\n END\n$$;\n\n\n\n\n\nCREATE OPERATOR ->(\n FUNCTION=eql_v2.\"->\",\n LEFTARG=eql_v2_encrypted,\n RIGHTARG=integer\n);\n\n\n--! @brief EQL lint: detect non-inlinable operator implementation functions\n--!\n--! Returns one row per violation found in the installed EQL surface. The\n--! Postgres planner can only inline a function during index matching when:\n--!\n--! * `LANGUAGE sql` (plpgsql / C / etc. cannot be inlined)\n--! * `IMMUTABLE` or `STABLE` volatility (VOLATILE cannot be inlined into\n--! index expressions)\n--! * No `SET` clauses (e.g. `SET search_path = ...`)\n--! * Not `SECURITY DEFINER`\n--! * Single-statement SELECT body\n--!\n--! @note The single-statement SELECT body condition is **not yet checked** by\n--! this lint. A `LANGUAGE sql` function with a multi-statement body, a CTE,\n--! or any pre-SELECT statement will pass all four implemented checks while\n--! remaining non-inlinable. Implementing the check requires walking `prosrc`\n--! (or `pg_get_functiondef`); tracked as a follow-up to #194.\n--!\n--! Operators on encrypted types (`eql_v2_encrypted`, `eql_v2.bloom_filter`,\n--! `eql_v2.ore_*`, etc.) whose implementation functions fail any of these\n--! rules silently fall back to seq scan when the documented functional\n--! indexes (`eql_v2.hmac_256(col)`, `eql_v2.bloom_filter(col)`,\n--! `eql_v2.ste_vec(col)`) are in place. This lint surfaces every such case.\n--!\n--! Severity:\n--! `error` — fixable, blocks index matching, ship-blocking.\n--! `warning` — likely-fixable, may not block matching but signals intent.\n--! `info` — observational; useful for review, not a defect on its own.\n--!\n--! Categories:\n--! `inlinability_language` — implementation function isn't `LANGUAGE sql`.\n--! `inlinability_volatility` — implementation function is VOLATILE.\n--! `inlinability_set_clause` — implementation function has a `SET` clause.\n--! `inlinability_secdef` — implementation function is `SECURITY DEFINER`.\n--! `inlinability_transitive` — implementation function is itself inlinable\n--! but its body invokes a non-inlinable function\n--! (depth 1; the planner can't peek through\n--! that boundary).\n--!\n--! @example\n--! ```\n--! SELECT severity, category, object_name, message\n--! FROM eql_v2.lints()\n--! WHERE severity = 'error'\n--! ORDER BY category, object_name;\n--! ```\n--!\n--! @return SETOF record (severity text, category text, object_name text, message text)\nCREATE OR REPLACE FUNCTION eql_v2.lints()\nRETURNS TABLE (\n severity text,\n category text,\n object_name text,\n message text\n)\nLANGUAGE sql STABLE\nAS $$\n WITH\n -- All operators where at least one operand involves an EQL type. Limits\n -- the scope of the lint to the operator surface customers actually hit\n -- via SQL (`col = val`, `col LIKE '...'`, `col @> '...'` and friends).\n eql_operators AS (\n SELECT\n op.oid AS oprid,\n op.oprname AS opname,\n op.oprcode AS implfunc,\n op.oprleft::regtype AS lhs,\n op.oprright::regtype AS rhs,\n op.oprcode::regprocedure AS impl_signature\n FROM pg_operator op\n WHERE EXISTS (\n SELECT 1 FROM pg_type t\n WHERE t.oid IN (op.oprleft, op.oprright)\n AND (t.typname LIKE 'eql_v2%'\n OR t.typnamespace = 'eql_v2'::regnamespace)\n )\n ),\n\n -- Cross-join with each operator's implementation function metadata.\n -- One row per operator; columns describe the inlinability of the impl.\n op_impl AS (\n SELECT\n eo.opname,\n eo.lhs,\n eo.rhs,\n eo.impl_signature::text AS impl_signature,\n lang_l.lanname AS lang,\n p.provolatile AS volatility,\n p.proconfig AS config,\n p.prosecdef AS secdef,\n p.prosrc AS body\n FROM eql_operators eo\n JOIN pg_proc p ON p.oid = eo.implfunc\n JOIN pg_language lang_l ON lang_l.oid = p.prolang\n )\n\n -- ┌─────────────────────────────────────────────────────────────────┐\n -- │ Direct inlinability checks: each row examines one operator's │\n -- │ implementation function and emits a violation if any rule is │\n -- │ broken. Multiple violations on the same function become │\n -- │ multiple rows (developers see every reason it doesn't inline). │\n -- └─────────────────────────────────────────────────────────────────┘\n\n SELECT\n 'error' AS severity,\n 'inlinability_language' AS category,\n format('operator %s(%s, %s) -> %s',\n opname, lhs, rhs, impl_signature) AS object_name,\n format(\n 'Operator implementation function is `LANGUAGE %s`; only `LANGUAGE sql` functions can be inlined by the planner. Bare `col %s val` queries fall back to seq scan even when a matching functional index exists.',\n lang, opname) AS message\n FROM op_impl\n WHERE lang <> 'sql'\n\n UNION ALL\n\n SELECT\n 'error',\n 'inlinability_volatility',\n format('operator %s(%s, %s) -> %s', opname, lhs, rhs, impl_signature),\n format(\n 'Operator implementation function is `VOLATILE`. The Postgres planner refuses to inline volatile functions into index expressions, so functional indexes never engage. Mark the function `IMMUTABLE` (or `STABLE` if it depends on session state).',\n opname)\n FROM op_impl\n WHERE volatility = 'v'\n\n UNION ALL\n\n SELECT\n 'error',\n 'inlinability_set_clause',\n format('operator %s(%s, %s) -> %s', opname, lhs, rhs, impl_signature),\n format(\n 'Operator implementation function has a `SET` clause (e.g. `SET search_path = ...`). Per Postgres function-inlining rules, any `SET` clause blocks inlining. Use schema-qualified identifiers in the body and remove the `SET` clause to allow the planner to inline.')\n FROM op_impl\n WHERE config IS NOT NULL\n\n UNION ALL\n\n SELECT\n 'error',\n 'inlinability_secdef',\n format('operator %s(%s, %s) -> %s', opname, lhs, rhs, impl_signature),\n 'Operator implementation function is `SECURITY DEFINER`. Such functions cannot be inlined; remove `SECURITY DEFINER` or use a non-inlinable wrapper layer.'\n FROM op_impl\n WHERE secdef\n\n -- ┌─────────────────────────────────────────────────────────────────┐\n -- │ Transitive inlinability: an operator implementation function │\n -- │ that's itself inlinable can still fail to inline if its body │\n -- │ calls a non-inlinable function. Walk one level via pg_depend. │\n -- │ │\n -- │ Postgres records function-to-function dependencies in │\n -- │ pg_depend with deptype 'n' (normal) when one function references│\n -- │ another in its body — but only at CREATE time and only for │\n -- │ direct calls. This is good enough for v1; deeper transitive │\n -- │ analysis is a follow-up. │\n -- └─────────────────────────────────────────────────────────────────┘\n\n UNION ALL\n\n SELECT\n 'error',\n 'inlinability_transitive',\n format('operator %s(%s, %s) -> %s', oi.opname, oi.lhs, oi.rhs,\n oi.impl_signature),\n format(\n 'Operator implementation function is inlinable but invokes non-inlinable function `%s` (lang=%s, volatility=%s%s). The chain blocks at depth 1: the planner inlines the outer call but cannot reduce the inner call into an index expression.',\n called.proname,\n called_lang.lanname,\n CASE called.provolatile\n WHEN 'i' THEN 'IMMUTABLE'\n WHEN 's' THEN 'STABLE'\n WHEN 'v' THEN 'VOLATILE'\n END,\n CASE WHEN called.proconfig IS NOT NULL\n THEN ', has SET clause'\n ELSE '' END)\n FROM op_impl oi\n -- Only worth the transitive check if the outer function is otherwise\n -- inlinable — otherwise the direct lints above already report it.\n JOIN pg_proc outer_p ON outer_p.oid = oi.impl_signature::regprocedure\n JOIN pg_depend d\n ON d.classid = 'pg_proc'::regclass\n AND d.objid = outer_p.oid\n AND d.refclassid = 'pg_proc'::regclass\n AND d.deptype = 'n'\n JOIN pg_proc called ON called.oid = d.refobjid\n JOIN pg_language called_lang ON called_lang.oid = called.prolang\n WHERE oi.lang = 'sql'\n AND oi.volatility IN ('i', 's')\n AND oi.config IS NULL\n AND NOT oi.secdef\n AND called.oid <> outer_p.oid\n AND (\n called_lang.lanname <> 'sql'\n OR called.provolatile = 'v'\n OR called.proconfig IS NOT NULL\n OR called.prosecdef\n )\n\n ORDER BY 1, 2, 3;\n$$;\n\nCOMMENT ON FUNCTION eql_v2.lints() IS\n 'EQL lint: returns one row per non-inlinable operator implementation. '\n 'Run `SELECT * FROM eql_v2.lints() WHERE severity = ''error''` for a '\n 'CI-gateable check that all operator implementations on EQL types are '\n 'eligible for planner inlining.';\n\n--! @file jsonb/functions.sql\n--! @brief JSONB path query and array manipulation functions for encrypted data\n--!\n--! These functions provide PostgreSQL-compatible operations on encrypted JSONB values\n--! using Structured Transparent Encryption (STE). They support:\n--! - Path-based queries to extract nested encrypted values\n--! - Existence checks for encrypted fields\n--! - Array operations (length, elements extraction)\n--! - Field-level HMAC term extraction for equality / GROUP BY / DISTINCT\n--!\n--! @note STE stores encrypted JSONB as a vector of encrypted elements ('sv') with selectors\n--! @note Functions suppress errors for missing fields, type mismatches (similar to PostgreSQL jsonpath)\n--! @note `selector` parameters in this module are *encrypted-side* selector\n--! hashes — the deterministic hash that the crypto layer (e.g.\n--! `@cipherstash/protect`) emits in the `s` field of each `sv` element\n--! (e.g. `'a7cea93975ed8c01f861ccb6bd082784'`). Plaintext JSONPaths\n--! like `'$.address.city'` are never accepted at runtime; the proxy /\n--! client rewrites them to selector hashes before the query reaches EQL.\n\n\n--! @brief Query encrypted JSONB for elements matching selector\n--!\n--! Searches the Structured Transparent Encryption (STE) vector for elements matching\n--! the given selector path. Returns all matching encrypted elements. If multiple\n--! matches form an array, they are wrapped with array metadata.\n--!\n--! @param jsonb Encrypted JSONB payload containing STE vector ('sv')\n--! @param text Path selector to match against encrypted elements\n--! @return SETOF eql_v2_encrypted Matching encrypted elements (may return multiple rows)\n--!\n--! @note Returns empty set if selector is not found (does not throw exception)\n--! @note Array elements use same selector; multiple matches wrapped with 'a' flag\n--! @note Returns a set containing NULL if val is NULL; returns empty set if no matches found\n--! @see eql_v2.jsonb_path_query_first\n--! @see eql_v2.jsonb_path_exists\nCREATE FUNCTION eql_v2.jsonb_path_query(val jsonb, selector text)\n RETURNS SETOF eql_v2_encrypted\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT\n CASE\n WHEN bool_or(eql_v2.is_ste_vec_array(elem)) THEN\n (eql_v2.meta_data(val) || jsonb_build_object('sv', jsonb_agg(elem), 'a', 1))::eql_v2_encrypted\n ELSE\n (eql_v2.meta_data(val) || (array_agg(elem))[1])::eql_v2_encrypted\n END\n FROM jsonb_array_elements(val -> 'sv') elem\n WHERE elem ->> 's' = selector\n HAVING count(*) > 0\n$$;\n\n\n--! @brief Query encrypted JSONB with encrypted selector\n--!\n--! Overload that accepts encrypted selector and extracts its plaintext value\n--! before delegating to main jsonb_path_query implementation.\n--!\n--! @param val eql_v2_encrypted Encrypted JSONB value to query\n--! @param selector eql_v2_encrypted Encrypted selector to match against\n--! @return SETOF eql_v2_encrypted Matching encrypted elements\n--!\n--! @see eql_v2.jsonb_path_query(jsonb, text)\nCREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector eql_v2_encrypted)\n RETURNS SETOF eql_v2_encrypted\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT * FROM eql_v2.jsonb_path_query((val).data, eql_v2._selector(selector));\n$$;\n\n\n--! @brief Query encrypted JSONB with text selector\n--!\n--! Overload that accepts encrypted JSONB value and text selector,\n--! extracting the JSONB payload before querying.\n--!\n--! @param eql_v2_encrypted Encrypted JSONB value to query\n--! @param text Path selector to match against\n--! @return SETOF eql_v2_encrypted Matching encrypted elements\n--!\n--! @example\n--! -- Query encrypted JSONB for the sv element at a given selector hash\n--! SELECT * FROM eql_v2.jsonb_path_query(encrypted_document, 'a7cea93975ed8c01f861ccb6bd082784');\n--!\n--! @see eql_v2.jsonb_path_query(jsonb, text)\nCREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector text)\n RETURNS SETOF eql_v2_encrypted\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT * FROM eql_v2.jsonb_path_query((val).data, selector);\n$$;\n\n\n------------------------------------------------------------------------------------\n\n\n--! @brief Check if selector path exists in encrypted JSONB\n--!\n--! Tests whether any encrypted elements match the given selector path.\n--! More efficient than jsonb_path_query when only existence check is needed.\n--!\n--! @param jsonb Encrypted JSONB payload to check\n--! @param text Path selector to test\n--! @return boolean True if matching element exists, false otherwise\n--!\n--! @see eql_v2.jsonb_path_query(jsonb, text)\nCREATE FUNCTION eql_v2.jsonb_path_exists(val jsonb, selector text)\n RETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT EXISTS (\n SELECT 1 FROM jsonb_array_elements(val -> 'sv') elem\n WHERE elem ->> 's' = selector\n );\n$$;\n\n\n--! @brief Check existence with encrypted selector\n--!\n--! Overload that accepts encrypted selector and extracts its value\n--! before checking existence.\n--!\n--! @param val eql_v2_encrypted Encrypted JSONB value to check\n--! @param selector eql_v2_encrypted Encrypted selector to test\n--! @return boolean True if path exists\n--!\n--! @see eql_v2.jsonb_path_exists(jsonb, text)\nCREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector eql_v2_encrypted)\n RETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.jsonb_path_exists((val).data, eql_v2._selector(selector));\n$$;\n\n\n--! @brief Check existence with text selector\n--!\n--! Overload that accepts encrypted JSONB value and text selector.\n--!\n--! @param eql_v2_encrypted Encrypted JSONB value to check\n--! @param text Path selector to test\n--! @return boolean True if path exists\n--!\n--! @example\n--! -- Check if the encrypted document has an sv element at a given selector hash\n--! SELECT eql_v2.jsonb_path_exists(encrypted_document, 'a7cea93975ed8c01f861ccb6bd082784');\n--!\n--! @see eql_v2.jsonb_path_exists(jsonb, text)\nCREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector text)\n RETURNS boolean\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.jsonb_path_exists((val).data, selector);\n$$;\n\n\n------------------------------------------------------------------------------------\n\n\n--! @brief Get first element matching selector\n--!\n--! Returns only the first encrypted element matching the selector path,\n--! or NULL if no match found. More efficient than jsonb_path_query when\n--! only one result is needed.\n--!\n--! @param jsonb Encrypted JSONB payload to query\n--! @param text Path selector to match\n--! @return eql_v2_encrypted First matching element or NULL\n--!\n--! @note Uses LIMIT 1 internally for efficiency\n--! @see eql_v2.jsonb_path_query(jsonb, text)\nCREATE FUNCTION eql_v2.jsonb_path_query_first(val jsonb, selector text)\n RETURNS eql_v2_encrypted\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT (eql_v2.meta_data(val) || elem)::eql_v2_encrypted\n FROM jsonb_array_elements(val -> 'sv') elem\n WHERE elem ->> 's' = selector\n LIMIT 1\n$$;\n\n\n--! @brief Get first element with encrypted selector\n--!\n--! Overload that accepts encrypted selector and extracts its value\n--! before querying for first match.\n--!\n--! @param val eql_v2_encrypted Encrypted JSONB value to query\n--! @param selector eql_v2_encrypted Encrypted selector to match\n--! @return eql_v2_encrypted First matching element or NULL\n--!\n--! @see eql_v2.jsonb_path_query_first(jsonb, text)\nCREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector eql_v2_encrypted)\n RETURNS eql_v2_encrypted\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.jsonb_path_query_first((val).data, eql_v2._selector(selector));\n$$;\n\n\n--! @brief Get first element with text selector\n--!\n--! Overload that accepts encrypted JSONB value and text selector.\n--!\n--! @param eql_v2_encrypted Encrypted JSONB value to query\n--! @param text Path selector to match\n--! @return eql_v2_encrypted First matching element or NULL\n--!\n--! @example\n--! -- Get the first matching sv element from an encrypted document\n--! SELECT eql_v2.jsonb_path_query_first(encrypted_document, 'a7cea93975ed8c01f861ccb6bd082784');\n--!\n--! @see eql_v2.jsonb_path_query_first(jsonb, text)\nCREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector text)\n RETURNS eql_v2_encrypted\n LANGUAGE sql\n IMMUTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT eql_v2.jsonb_path_query_first((val).data, selector);\n$$;\n\n\n\n------------------------------------------------------------------------------------\n\n\n--! @brief Get length of encrypted JSONB array\n--!\n--! Returns the number of elements in an encrypted JSONB array by counting\n--! elements in the STE vector ('sv'). The encrypted value must have the\n--! array flag ('a') set to true.\n--!\n--! @param jsonb Encrypted JSONB payload representing an array\n--! @return integer Number of elements in the array\n--! @throws Exception 'cannot get array length of a non-array' if 'a' flag is missing or not true\n--!\n--! @note Array flag 'a' must be present and set to true value\n--! @see eql_v2.jsonb_array_elements\nCREATE FUNCTION eql_v2.jsonb_array_length(val jsonb)\n RETURNS integer\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n sv eql_v2_encrypted[];\n found eql_v2_encrypted[];\n BEGIN\n\n IF val IS NULL THEN\n RETURN NULL;\n END IF;\n\n IF eql_v2.is_ste_vec_array(val) THEN\n sv := eql_v2.ste_vec(val);\n RETURN array_length(sv, 1);\n END IF;\n\n RAISE 'cannot get array length of a non-array';\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Get array length from encrypted type\n--!\n--! Overload that accepts encrypted composite type and extracts the\n--! JSONB payload before computing array length.\n--!\n--! @param eql_v2_encrypted Encrypted array value\n--! @return integer Number of elements in the array\n--! @throws Exception if value is not an array\n--!\n--! @example\n--! -- Get length of encrypted array\n--! SELECT eql_v2.jsonb_array_length(encrypted_tags);\n--!\n--! @see eql_v2.jsonb_array_length(jsonb)\nCREATE FUNCTION eql_v2.jsonb_array_length(val eql_v2_encrypted)\n RETURNS integer\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN (\n SELECT eql_v2.jsonb_array_length(val.data)\n );\n END;\n$$ LANGUAGE plpgsql;\n\n\n\n\n--! @brief Extract elements from encrypted JSONB array\n--!\n--! Returns each element of an encrypted JSONB array as a separate row.\n--! Each element is returned as an eql_v2_encrypted value with metadata\n--! preserved from the parent array.\n--!\n--! @param jsonb Encrypted JSONB payload representing an array\n--! @return SETOF eql_v2_encrypted One row per array element\n--! @throws Exception if value is not an array (missing 'a' flag)\n--!\n--! @note Each element inherits metadata (version, ident) from parent\n--! @see eql_v2.jsonb_array_length\n--! @see eql_v2.jsonb_array_elements_text\nCREATE FUNCTION eql_v2.jsonb_array_elements(val jsonb)\n RETURNS SETOF eql_v2_encrypted\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n sv eql_v2_encrypted[];\n meta jsonb;\n item jsonb;\n BEGIN\n\n IF NOT eql_v2.is_ste_vec_array(val) THEN\n RAISE 'cannot extract elements from non-array';\n END IF;\n\n -- Column identifier and version\n meta := eql_v2.meta_data(val);\n\n sv := eql_v2.ste_vec(val);\n\n FOR idx IN 1..array_length(sv, 1) LOOP\n item = sv[idx];\n RETURN NEXT (meta || item)::eql_v2_encrypted;\n END LOOP;\n\n RETURN;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Extract elements from encrypted array type\n--!\n--! Overload that accepts encrypted composite type and extracts each\n--! array element as a separate row.\n--!\n--! @param eql_v2_encrypted Encrypted array value\n--! @return SETOF eql_v2_encrypted One row per array element\n--! @throws Exception if value is not an array\n--!\n--! @example\n--! -- Expand encrypted array into rows\n--! SELECT * FROM eql_v2.jsonb_array_elements(encrypted_tags);\n--!\n--! @see eql_v2.jsonb_array_elements(jsonb)\nCREATE FUNCTION eql_v2.jsonb_array_elements(val eql_v2_encrypted)\n RETURNS SETOF eql_v2_encrypted\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN QUERY\n SELECT * FROM eql_v2.jsonb_array_elements(val.data);\n END;\n$$ LANGUAGE plpgsql;\n\n\n\n--! @brief Extract encrypted array elements as ciphertext\n--!\n--! Returns each element of an encrypted JSONB array as its raw ciphertext\n--! value (text representation). Unlike jsonb_array_elements, this returns\n--! only the ciphertext 'c' field without metadata.\n--!\n--! @param jsonb Encrypted JSONB payload representing an array\n--! @return SETOF text One ciphertext string per array element\n--! @throws Exception if value is not an array (missing 'a' flag)\n--!\n--! @note Returns ciphertext only, not full encrypted structure\n--! @see eql_v2.jsonb_array_elements\nCREATE FUNCTION eql_v2.jsonb_array_elements_text(val jsonb)\n RETURNS SETOF text\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n sv eql_v2_encrypted[];\n found eql_v2_encrypted[];\n BEGIN\n IF NOT eql_v2.is_ste_vec_array(val) THEN\n RAISE 'cannot extract elements from non-array';\n END IF;\n\n sv := eql_v2.ste_vec(val);\n\n FOR idx IN 1..array_length(sv, 1) LOOP\n RETURN NEXT eql_v2.ciphertext(sv[idx]);\n END LOOP;\n\n RETURN;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Extract array elements as ciphertext from encrypted type\n--!\n--! Overload that accepts encrypted composite type and extracts each\n--! array element's ciphertext as text.\n--!\n--! @param eql_v2_encrypted Encrypted array value\n--! @return SETOF text One ciphertext string per array element\n--! @throws Exception if value is not an array\n--!\n--! @example\n--! -- Get ciphertext of each array element\n--! SELECT * FROM eql_v2.jsonb_array_elements_text(encrypted_tags);\n--!\n--! @see eql_v2.jsonb_array_elements_text(jsonb)\nCREATE FUNCTION eql_v2.jsonb_array_elements_text(val eql_v2_encrypted)\n RETURNS SETOF text\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN QUERY\n SELECT * FROM eql_v2.jsonb_array_elements_text(val.data);\n END;\n$$ LANGUAGE plpgsql;\n\n\n------------------------------------------------------------------------------------\n\n-- `eql_v2.hmac_256_terms(eql_v2_encrypted)` was added under #205 as a\n-- GIN-indexable {s, hm} aggregate. It's been removed: under the XOR\n-- contract each sv element carries exactly one of `hm` (bool leaves,\n-- array / object roots) or `oc` (string / number leaves), and\n-- `hmac_256_terms` filters out everything without `hm` — so containment\n-- queries via this index could never match on string / number selectors.\n-- The canonical XOR-aware replacement is the typed\n-- `@>(eql_v2_encrypted, eql_v2.stevec_query)` overload, which inlines\n-- to `eql_v2.to_stevec_query(col)::jsonb @> needle::jsonb` and engages\n-- a functional GIN on `(eql_v2.to_stevec_query(col)::jsonb) jsonb_path_ops`.\n-- See U-007 / U-008 in `docs/upgrading/v2.3.md`.\n--! @file encryptindex/functions.sql\n--! @brief Configuration lifecycle and column encryption management\n--!\n--! Provides functions for managing encryption configuration transitions:\n--! - Comparing configurations to identify changes\n--! - Identifying columns needing encryption\n--! - Creating and renaming encrypted columns during initial setup\n--! - Tracking encryption progress\n--!\n--! These functions support the workflow of activating a pending configuration\n--! and performing the initial encryption of plaintext columns.\n\n\n--! @brief Compare two configurations and find differences\n--! @internal\n--!\n--! Returns table/column pairs where configuration differs between two configs.\n--! Used to identify which columns need encryption when activating a pending config.\n--!\n--! @param a jsonb First configuration to compare\n--! @param b jsonb Second configuration to compare\n--! @return TABLE(table_name text, column_name text) Columns with differing configuration\n--!\n--! @note Compares configuration structure, not just presence/absence\n--! @see eql_v2.select_pending_columns\nCREATE FUNCTION eql_v2.diff_config(a JSONB, b JSONB)\n\tRETURNS TABLE(table_name TEXT, column_name TEXT)\nIMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n RETURN QUERY\n WITH table_keys AS (\n SELECT jsonb_object_keys(a->'tables') AS key\n UNION\n SELECT jsonb_object_keys(b->'tables') AS key\n ),\n column_keys AS (\n SELECT tk.key AS table_key, jsonb_object_keys(a->'tables'->tk.key) AS column_key\n FROM table_keys tk\n UNION\n SELECT tk.key AS table_key, jsonb_object_keys(b->'tables'->tk.key) AS column_key\n FROM table_keys tk\n )\n SELECT\n ck.table_key AS table_name,\n ck.column_key AS column_name\n FROM\n column_keys ck\n WHERE\n (a->'tables'->ck.table_key->ck.column_key IS DISTINCT FROM b->'tables'->ck.table_key->ck.column_key);\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Get columns with pending configuration changes\n--!\n--! Compares 'pending' and 'active' configurations to identify columns that need\n--! encryption or re-encryption. Returns columns where configuration differs.\n--!\n--! @return TABLE(table_name text, column_name text) Columns needing encryption\n--! @throws Exception if no pending configuration exists\n--!\n--! @note Treats missing active config as empty config\n--! @see eql_v2.diff_config\n--! @see eql_v2.select_target_columns\nCREATE FUNCTION eql_v2.select_pending_columns()\n\tRETURNS TABLE(table_name TEXT, column_name TEXT)\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tDECLARE\n\t\tactive JSONB;\n\t\tpending JSONB;\n\t\tconfig_id BIGINT;\n\tBEGIN\n\t\tSELECT data INTO active FROM eql_v2_configuration WHERE state = 'active';\n\n\t\t-- set default config\n IF active IS NULL THEN\n active := '{}';\n END IF;\n\n\t\tSELECT id, data INTO config_id, pending FROM eql_v2_configuration WHERE state = 'pending';\n\n\t\t-- set default config\n\t\tIF config_id IS NULL THEN\n\t\t\tRAISE EXCEPTION 'No pending configuration exists to encrypt';\n\t\tEND IF;\n\n\t\tRETURN QUERY\n\t\tSELECT d.table_name, d.column_name FROM eql_v2.diff_config(active, pending) as d;\n\tEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Map pending columns to their encrypted target columns\n--!\n--! For each column with pending configuration, identifies the corresponding\n--! encrypted column. During initial encryption, target is '{column_name}_encrypted'.\n--! Returns NULL for target_column if encrypted column doesn't exist yet.\n--!\n--! @return TABLE(table_name text, column_name text, target_column text) Column mappings\n--!\n--! @note Target column is NULL if no column exists matching either 'column_name' or 'column_name_encrypted' with type eql_v2_encrypted\n--! @note The LEFT JOIN checks both original and '_encrypted' suffix variations with type verification\n--! @see eql_v2.select_pending_columns\n--! @see eql_v2.create_encrypted_columns\nCREATE FUNCTION eql_v2.select_target_columns()\n\tRETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT)\n\tSTABLE STRICT PARALLEL SAFE\nAS $$\n SELECT\n c.table_name,\n c.column_name,\n s.column_name as target_column\n FROM\n eql_v2.select_pending_columns() c\n LEFT JOIN information_schema.columns s ON\n s.table_name = c.table_name AND\n (s.column_name = c.column_name OR s.column_name = c.column_name || '_encrypted') AND\n s.udt_name = 'eql_v2_encrypted';\n$$ LANGUAGE sql;\n\n\n--! @brief Check if database is ready for encryption\n--!\n--! Verifies that all columns with pending configuration have corresponding\n--! encrypted target columns created. Returns true if encryption can proceed.\n--!\n--! @return boolean True if all pending columns have target encrypted columns\n--!\n--! @note Returns false if any pending column lacks encrypted column\n--! @see eql_v2.select_target_columns\n--! @see eql_v2.create_encrypted_columns\nCREATE FUNCTION eql_v2.ready_for_encryption()\n\tRETURNS BOOLEAN\n\tSTABLE STRICT PARALLEL SAFE\nAS $$\n\tSELECT EXISTS (\n\t SELECT *\n\t FROM eql_v2.select_target_columns() AS c\n\t WHERE c.target_column IS NOT NULL);\n$$ LANGUAGE sql;\n\n\n--! @brief Create encrypted columns for initial encryption\n--!\n--! For each plaintext column with pending configuration that lacks an encrypted\n--! target column, creates a new column '{column_name}_encrypted' of type\n--! eql_v2_encrypted. This prepares the database schema for initial encryption.\n--!\n--! @return TABLE(table_name text, column_name text) Created encrypted columns\n--!\n--! @warning Executes dynamic DDL (ALTER TABLE ADD COLUMN) - modifies database schema\n--! @note Only creates columns that don't already exist\n--! @see eql_v2.select_target_columns\n--! @see eql_v2.rename_encrypted_columns\nCREATE FUNCTION eql_v2.create_encrypted_columns()\n\tRETURNS TABLE(table_name TEXT, column_name TEXT)\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n FOR table_name, column_name IN\n SELECT c.table_name, (c.column_name || '_encrypted') FROM eql_v2.select_target_columns() AS c WHERE c.target_column IS NULL\n LOOP\n\t\t EXECUTE format('ALTER TABLE %I ADD column %I eql_v2_encrypted;', table_name, column_name);\n RETURN NEXT;\n END LOOP;\n\tEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Finalize initial encryption by renaming columns\n--!\n--! After initial encryption completes, renames columns to complete the transition:\n--! - Plaintext column '{column_name}' → '{column_name}_plaintext'\n--! - Encrypted column '{column_name}_encrypted' → '{column_name}'\n--!\n--! This makes the encrypted column the primary column with the original name.\n--!\n--! @return TABLE(table_name text, column_name text, target_column text) Renamed columns\n--!\n--! @warning Executes dynamic DDL (ALTER TABLE RENAME COLUMN) - modifies database schema\n--! @note Only renames columns where target is '{column_name}_encrypted'\n--! @see eql_v2.create_encrypted_columns\nCREATE FUNCTION eql_v2.rename_encrypted_columns()\n\tRETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT)\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n FOR table_name, column_name, target_column IN\n SELECT * FROM eql_v2.select_target_columns() as c WHERE c.target_column = c.column_name || '_encrypted'\n LOOP\n\t\t EXECUTE format('ALTER TABLE %I RENAME %I TO %I;', table_name, column_name, column_name || '_plaintext');\n\t\t EXECUTE format('ALTER TABLE %I RENAME %I TO %I;', table_name, target_column, column_name);\n RETURN NEXT;\n END LOOP;\n\tEND;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Count rows encrypted with active configuration\n--! @internal\n--!\n--! Counts rows in a table where the encrypted column was encrypted using\n--! the currently active configuration. Used to track encryption progress.\n--!\n--! @param table_name text Name of table to check\n--! @param column_name text Name of encrypted column to check\n--! @return bigint Count of rows encrypted with active configuration\n--!\n--! @note The 'v' field in encrypted payloads stores the payload version (\"2\"), not the configuration ID\n--! @note Configuration tracking mechanism is implementation-specific\nCREATE FUNCTION eql_v2.count_encrypted_with_active_config(table_name TEXT, column_name TEXT)\n RETURNS BIGINT\n SET search_path = pg_catalog, extensions, public\nAS $$\nDECLARE\n result BIGINT;\nBEGIN\n\tEXECUTE format(\n 'SELECT COUNT(%I) FROM %s t WHERE %I->>%L = (SELECT id::TEXT FROM eql_v2_configuration WHERE state = %L)',\n column_name, table_name, column_name, 'v', 'active'\n )\n\tINTO result;\n \tRETURN result;\nEND;\n$$ LANGUAGE plpgsql;\n\n\n\n--! @brief Validate presence of ident field in encrypted payload\n--! @internal\n--!\n--! Checks that the encrypted JSONB payload contains the required 'i' (ident) field.\n--! The ident field tracks which table and column the encrypted value belongs to.\n--!\n--! @param jsonb Encrypted payload to validate\n--! @return Boolean True if 'i' field is present\n--! @throws Exception if 'i' field is missing\n--!\n--! @note Used in CHECK constraints to ensure payload structure\n--! @see eql_v2.check_encrypted\nCREATE FUNCTION eql_v2._encrypted_check_i(val jsonb)\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF val ? 'i' THEN\n RETURN true;\n END IF;\n RAISE 'Encrypted column missing ident (i) field: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Validate table and column fields in ident\n--! @internal\n--!\n--! Checks that the 'i' (ident) field contains both 't' (table) and 'c' (column)\n--! subfields, which identify the origin of the encrypted value.\n--!\n--! @param jsonb Encrypted payload to validate\n--! @return Boolean True if both 't' and 'c' subfields are present\n--! @throws Exception if 't' or 'c' subfields are missing\n--!\n--! @note Used in CHECK constraints to ensure payload structure\n--! @see eql_v2.check_encrypted\nCREATE FUNCTION eql_v2._encrypted_check_i_ct(val jsonb)\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF (val->'i' ?& array['t', 'c']) THEN\n RETURN true;\n END IF;\n RAISE 'Encrypted column ident (i) missing table (t) or column (c) fields: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Validate version field in encrypted payload\n--! @internal\n--!\n--! Checks that the encrypted payload has version field 'v' set to '2',\n--! the current EQL v2 payload version.\n--!\n--! @param jsonb Encrypted payload to validate\n--! @return Boolean True if 'v' field is present and equals '2'\n--! @throws Exception if 'v' field is missing or not '2'\n--!\n--! @note Used in CHECK constraints to ensure payload structure\n--! @see eql_v2.check_encrypted\nCREATE FUNCTION eql_v2._encrypted_check_v(val jsonb)\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF (val ? 'v') THEN\n\n IF val->>'v' <> '2' THEN\n RAISE 'Expected encrypted column version (v) 2';\n RETURN false;\n END IF;\n\n RETURN true;\n END IF;\n RAISE 'Encrypted column missing version (v) field: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Validate ciphertext field in encrypted payload\n--! @internal\n--!\n--! Checks that the encrypted payload carries the required root-level ciphertext\n--! envelope. The v2.3 payload schema admits two mutually exclusive top-level\n--! shapes (`docs/reference/schema/eql-payload-v2.3.schema.json`):\n--!\n--! - `EncryptedPayload` (scalar) — carries `c` at the root.\n--! - `SteVecPayload` (jsonb / structured) — carries `sv` at the root; the\n--! root document ciphertext lives inside `sv[0].c`, so `c` is absent at\n--! the root.\n--!\n--! Either shape satisfies this check. Per-element ciphertext validity on\n--! `sv` entries is enforced separately by the `eql_v2.ste_vec_entry` DOMAIN.\n--!\n--! @param jsonb Encrypted payload to validate\n--! @return Boolean True if either 'c' or 'sv' is present at the root\n--! @throws Exception if neither 'c' nor 'sv' is present\n--!\n--! @note Used in CHECK constraints to ensure payload structure\n--! @see eql_v2.check_encrypted\nCREATE FUNCTION eql_v2._encrypted_check_c(val jsonb)\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF (val ? 'c') OR (val ? 'sv') THEN\n RETURN true;\n END IF;\n RAISE 'Encrypted column missing ciphertext (c) or ste_vec (sv) field: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Validate complete encrypted payload structure\n--!\n--! Comprehensive validation function that checks all required fields in an\n--! encrypted JSONB payload: version ('v'), ciphertext ('c'), ident ('i'),\n--! and ident subfields ('t', 'c').\n--!\n--! This function is used in CHECK constraints to ensure encrypted column\n--! data integrity at the database level.\n--!\n--! @param jsonb Encrypted payload to validate\n--! @return Boolean True if all structure checks pass\n--! @throws Exception if any required field is missing or invalid\n--!\n--! @example\n--! -- Add validation constraint to encrypted column\n--! ALTER TABLE users ADD CONSTRAINT check_email_encrypted\n--! CHECK (eql_v2.check_encrypted(encrypted_email::jsonb));\n--!\n--! @see eql_v2._encrypted_check_v\n--! @see eql_v2._encrypted_check_c\n--! @see eql_v2._encrypted_check_i\n--! @see eql_v2._encrypted_check_i_ct\nCREATE FUNCTION eql_v2.check_encrypted(val jsonb)\n RETURNS BOOLEAN\nLANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nBEGIN ATOMIC\n RETURN (\n eql_v2._encrypted_check_v(val) AND\n eql_v2._encrypted_check_c(val) AND\n eql_v2._encrypted_check_i(val) AND\n eql_v2._encrypted_check_i_ct(val)\n );\nEND;\n\n\n--! @brief Validate encrypted composite type structure\n--!\n--! Validates an eql_v2_encrypted composite type by checking its underlying\n--! JSONB payload. Delegates to eql_v2.check_encrypted(jsonb).\n--!\n--! @param eql_v2_encrypted Encrypted value to validate\n--! @return Boolean True if structure is valid\n--! @throws Exception if any required field is missing or invalid\n--!\n--! @see eql_v2.check_encrypted(jsonb)\nCREATE FUNCTION eql_v2.check_encrypted(val eql_v2_encrypted)\n RETURNS BOOLEAN\nLANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nBEGIN ATOMIC\n RETURN eql_v2.check_encrypted(val.data);\nEND;\n\n\n-- Aggregate functions for ORE\n\n--! @brief State transition function for min aggregate\n--! @internal\n--!\n--! Returns the smaller of two encrypted values for use in MIN aggregate.\n--! Comparison uses ORE index terms without decryption.\n--!\n--! @param a eql_v2_encrypted First encrypted value\n--! @param b eql_v2_encrypted Second encrypted value\n--! @return eql_v2_encrypted The smaller of the two values\n--!\n--! @see eql_v2.min(eql_v2_encrypted)\nCREATE FUNCTION eql_v2.min(a eql_v2_encrypted, b eql_v2_encrypted)\n RETURNS eql_v2_encrypted\nSTRICT\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n IF a < b THEN\n RETURN a;\n ELSE\n RETURN b;\n END IF;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Find minimum encrypted value in a group\n--!\n--! Aggregate function that returns the minimum encrypted value in a group\n--! using ORE index term comparisons without decryption.\n--!\n--! @param input eql_v2_encrypted Encrypted values to aggregate\n--! @return eql_v2_encrypted Minimum value in the group\n--!\n--! @example\n--! -- Find minimum age per department\n--! SELECT department, eql_v2.min(encrypted_age)\n--! FROM employees\n--! GROUP BY department;\n--!\n--! @note Requires 'ore' index configuration on the column\n--! @see eql_v2.min(eql_v2_encrypted, eql_v2_encrypted)\nCREATE AGGREGATE eql_v2.min(eql_v2_encrypted)\n(\n sfunc = eql_v2.min,\n stype = eql_v2_encrypted\n);\n\n\n--! @brief State transition function for max aggregate\n--! @internal\n--!\n--! Returns the larger of two encrypted values for use in MAX aggregate.\n--! Comparison uses ORE index terms without decryption.\n--!\n--! @param a eql_v2_encrypted First encrypted value\n--! @param b eql_v2_encrypted Second encrypted value\n--! @return eql_v2_encrypted The larger of the two values\n--!\n--! @see eql_v2.max(eql_v2_encrypted)\nCREATE FUNCTION eql_v2.max(a eql_v2_encrypted, b eql_v2_encrypted)\nRETURNS eql_v2_encrypted\nSTRICT\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n IF a > b THEN\n RETURN a;\n ELSE\n RETURN b;\n END IF;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Find maximum encrypted value in a group\n--!\n--! Aggregate function that returns the maximum encrypted value in a group\n--! using ORE index term comparisons without decryption.\n--!\n--! @param input eql_v2_encrypted Encrypted values to aggregate\n--! @return eql_v2_encrypted Maximum value in the group\n--!\n--! @example\n--! -- Find maximum salary per department\n--! SELECT department, eql_v2.max(encrypted_salary)\n--! FROM employees\n--! GROUP BY department;\n--!\n--! @note Requires 'ore' index configuration on the column\n--! @see eql_v2.max(eql_v2_encrypted, eql_v2_encrypted)\nCREATE AGGREGATE eql_v2.max(eql_v2_encrypted)\n(\n sfunc = eql_v2.max,\n stype = eql_v2_encrypted\n);\n\n\n--! @file config/indexes.sql\n--! @brief Configuration state uniqueness indexes\n--!\n--! Creates partial unique indexes to enforce that only one configuration\n--! can be in 'active', 'pending', or 'encrypting' state at any time.\n--! Multiple 'inactive' configurations are allowed.\n--!\n--! @note Uses partial indexes (WHERE clauses) for efficiency\n--! @note Prevents conflicting configurations from being active simultaneously\n--! @see config/types.sql for state definitions\n\n\n--! @brief Unique active configuration constraint\n--! @note Only one configuration can be 'active' at once\nCREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'active';\n\n--! @brief Unique pending configuration constraint\n--! @note Only one configuration can be 'pending' at once\nCREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'pending';\n\n--! @brief Unique encrypting configuration constraint\n--! @note Only one configuration can be 'encrypting' at once\nCREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'encrypting';\n\n\n--! @brief Add a search index configuration for an encrypted column\n--!\n--! Configures a searchable encryption index (unique, match, ore, ope, or ste_vec)\n--! on an encrypted column. Creates or updates the pending configuration, then\n--! migrates and activates it unless migrating flag is set.\n--!\n--! @param table_name Text Name of the table containing the column\n--! @param column_name Text Name of the column to configure\n--! @param index_name Text Type of index ('unique', 'match', 'ore', 'ope', 'ste_vec')\n--! @param cast_as Text PostgreSQL type for decrypted values (default: 'text')\n--! @param opts JSONB Index-specific options (default: '{}')\n--! @param migrating Boolean Skip auto-migration if true (default: false)\n--! @return JSONB Updated configuration object\n--! @throws Exception if index already exists for this column\n--! @throws Exception if cast_as is not a valid type\n--!\n--! @example\n--! -- Add unique index for exact-match searches\n--! SELECT eql_v2.add_search_config('users', 'email', 'unique');\n--!\n--! -- Add match index for LIKE searches with custom token length\n--! SELECT eql_v2.add_search_config('posts', 'content', 'match', 'text',\n--! '{\"token_filters\": [{\"kind\": \"downcase\"}], \"tokenizer\": {\"kind\": \"ngram\", \"token_length\": 3}}'\n--! );\n--!\n--! @see eql_v2.add_column\n--! @see eql_v2.remove_search_config\nCREATE FUNCTION eql_v2.add_search_config(table_name text, column_name text, index_name text, cast_as text DEFAULT 'text', opts jsonb DEFAULT '{}', migrating boolean DEFAULT false)\n RETURNS jsonb\n\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n o jsonb;\n _config jsonb;\n BEGIN\n\n -- set the active config\n SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;\n\n -- if index exists\n IF _config #> array['tables', table_name, column_name, 'indexes'] ? index_name THEN\n RAISE EXCEPTION '% index exists for column: % %', index_name, table_name, column_name;\n END IF;\n\n IF NOT cast_as = ANY('{text, int, small_int, big_int, real, double, boolean, date, jsonb, json, float, decimal, timestamp}') THEN\n RAISE EXCEPTION '% is not a valid cast type', cast_as;\n END IF;\n\n -- set default config\n SELECT eql_v2.config_default(_config) INTO _config;\n\n SELECT eql_v2.config_add_table(table_name, _config) INTO _config;\n\n SELECT eql_v2.config_add_column(table_name, column_name, _config) INTO _config;\n\n SELECT eql_v2.config_add_cast(table_name, column_name, cast_as, _config) INTO _config;\n\n -- set default options for index if opts empty\n IF index_name = 'match' AND opts = '{}' THEN\n SELECT eql_v2.config_match_default() INTO opts;\n END IF;\n\n SELECT eql_v2.config_add_index(table_name, column_name, index_name, opts, _config) INTO _config;\n\n -- create a new pending record if we don't have one\n INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)\n ON CONFLICT (state)\n WHERE state = 'pending'\n DO UPDATE\n SET data = _config;\n\n IF NOT migrating THEN\n PERFORM eql_v2.migrate_config();\n PERFORM eql_v2.activate_config();\n END IF;\n\n PERFORM eql_v2.add_encrypted_constraint(table_name, column_name);\n\n -- exeunt\n RETURN _config;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Remove a search index configuration from an encrypted column\n--!\n--! Removes a previously configured search index from an encrypted column.\n--! Updates the pending configuration, then migrates and activates it\n--! unless migrating flag is set.\n--!\n--! @param table_name Text Name of the table containing the column\n--! @param column_name Text Name of the column\n--! @param index_name Text Type of index to remove\n--! @param migrating Boolean Skip auto-migration if true (default: false)\n--! @return JSONB Updated configuration object\n--! @throws Exception if no active or pending configuration exists\n--! @throws Exception if table is not configured\n--! @throws Exception if column is not configured\n--!\n--! @example\n--! -- Remove match index from column\n--! SELECT eql_v2.remove_search_config('posts', 'content', 'match');\n--!\n--! @see eql_v2.add_search_config\n--! @see eql_v2.modify_search_config\nCREATE FUNCTION eql_v2.remove_search_config(table_name text, column_name text, index_name text, migrating boolean DEFAULT false)\n RETURNS jsonb\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n _config jsonb;\n BEGIN\n\n -- set the active config\n SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;\n\n -- if no config\n IF _config IS NULL THEN\n RAISE EXCEPTION 'No active or pending configuration exists';\n END IF;\n\n -- if the table doesn't exist\n IF NOT _config #> array['tables'] ? table_name THEN\n RAISE EXCEPTION 'No configuration exists for table: %', table_name;\n END IF;\n\n -- if the index does not exist\n -- IF NOT _config->key ? index_name THEN\n IF NOT _config #> array['tables', table_name] ? column_name THEN\n RAISE EXCEPTION 'No % index exists for column: % %', index_name, table_name, column_name;\n END IF;\n\n -- create a new pending record if we don't have one\n INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)\n ON CONFLICT (state)\n WHERE state = 'pending'\n DO NOTHING;\n\n -- remove the index\n SELECT _config #- array['tables', table_name, column_name, 'indexes', index_name] INTO _config;\n\n -- update the config and migrate (even if empty)\n UPDATE public.eql_v2_configuration SET data = _config WHERE state = 'pending';\n\n IF NOT migrating THEN\n PERFORM eql_v2.migrate_config();\n PERFORM eql_v2.activate_config();\n END IF;\n\n -- exeunt\n RETURN _config;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Modify a search index configuration for an encrypted column\n--!\n--! Updates an existing search index configuration by removing and re-adding it\n--! with new options. Convenience function that combines remove and add operations.\n--! If index does not exist, it is added.\n--!\n--! @param table_name Text Name of the table containing the column\n--! @param column_name Text Name of the column\n--! @param index_name Text Type of index to modify\n--! @param cast_as Text PostgreSQL type for decrypted values (default: 'text')\n--! @param opts JSONB New index-specific options (default: '{}')\n--! @param migrating Boolean Skip auto-migration if true (default: false)\n--! @return JSONB Updated configuration object\n--!\n--! @example\n--! -- Change match index tokenizer settings\n--! SELECT eql_v2.modify_search_config('posts', 'content', 'match', 'text',\n--! '{\"tokenizer\": {\"kind\": \"ngram\", \"token_length\": 4}}'\n--! );\n--!\n--! @see eql_v2.add_search_config\n--! @see eql_v2.remove_search_config\nCREATE FUNCTION eql_v2.modify_search_config(table_name text, column_name text, index_name text, cast_as text DEFAULT 'text', opts jsonb DEFAULT '{}', migrating boolean DEFAULT false)\n RETURNS jsonb\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n PERFORM eql_v2.remove_search_config(table_name, column_name, index_name, migrating);\n RETURN eql_v2.add_search_config(table_name, column_name, index_name, cast_as, opts, migrating);\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Migrate pending configuration to encrypting state\n--!\n--! Transitions the pending configuration to encrypting state, validating that\n--! all configured columns have encrypted target columns ready. This is part of\n--! the configuration lifecycle: pending → encrypting → active.\n--!\n--! @return Boolean True if migration succeeds\n--! @throws Exception if encryption already in progress\n--! @throws Exception if no pending configuration exists\n--! @throws Exception if configured columns lack encrypted targets\n--!\n--! @example\n--! -- Manually migrate configuration (normally done automatically)\n--! SELECT eql_v2.migrate_config();\n--!\n--! @see eql_v2.activate_config\n--! @see eql_v2.add_column\nCREATE FUNCTION eql_v2.migrate_config()\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n\n IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'encrypting') THEN\n RAISE EXCEPTION 'An encryption is already in progress';\n END IF;\n\n\t\tIF NOT EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'pending') THEN\n\t\t\tRAISE EXCEPTION 'No pending configuration exists to encrypt';\n\t\tEND IF;\n\n IF NOT eql_v2.ready_for_encryption() THEN\n RAISE EXCEPTION 'Some pending columns do not have an encrypted target';\n END IF;\n\n UPDATE public.eql_v2_configuration SET state = 'encrypting' WHERE state = 'pending';\n\t\tRETURN true;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Activate encrypting configuration\n--!\n--! Transitions the encrypting configuration to active state, making it the\n--! current operational configuration. Marks previous active configuration as\n--! inactive. Final step in configuration lifecycle: pending → encrypting → active.\n--!\n--! @return Boolean True if activation succeeds\n--! @throws Exception if no encrypting configuration exists to activate\n--!\n--! @example\n--! -- Manually activate configuration (normally done automatically)\n--! SELECT eql_v2.activate_config();\n--!\n--! @see eql_v2.migrate_config\n--! @see eql_v2.add_column\nCREATE FUNCTION eql_v2.activate_config()\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n\n\t IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'encrypting') THEN\n\t \tUPDATE public.eql_v2_configuration SET state = 'inactive' WHERE state = 'active';\n\t\t\tUPDATE public.eql_v2_configuration SET state = 'active' WHERE state = 'encrypting';\n\t\t\tRETURN true;\n\t\tELSE\n\t\t\tRAISE EXCEPTION 'No encrypting configuration exists to activate';\n\t\tEND IF;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Discard pending configuration\n--!\n--! Deletes the pending configuration without applying changes. Use this to\n--! abandon configuration changes before they are migrated and activated.\n--!\n--! @return Boolean True if discard succeeds\n--! @throws Exception if no pending configuration exists to discard\n--!\n--! @example\n--! -- Discard uncommitted configuration changes\n--! SELECT eql_v2.discard();\n--!\n--! @see eql_v2.add_column\n--! @see eql_v2.add_search_config\nCREATE FUNCTION eql_v2.discard()\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n BEGIN\n IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'pending') THEN\n DELETE FROM public.eql_v2_configuration WHERE state = 'pending';\n RETURN true;\n ELSE\n RAISE EXCEPTION 'No pending configuration exists to discard';\n END IF;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Configure a column for encryption\n--!\n--! Adds a column to the encryption configuration, making it eligible for\n--! encrypted storage and search indexes. Creates or updates pending configuration,\n--! adds encrypted constraint, then migrates and activates unless migrating flag is set.\n--!\n--! @param table_name Text Name of the table containing the column\n--! @param column_name Text Name of the column to encrypt\n--! @param cast_as Text PostgreSQL type to cast decrypted values (default: 'text')\n--! @param migrating Boolean Skip auto-migration if true (default: false)\n--! @return JSONB Updated configuration object\n--! @throws Exception if column already configured for encryption\n--!\n--! @example\n--! -- Configure email column for encryption\n--! SELECT eql_v2.add_column('users', 'email', 'text');\n--!\n--! -- Configure age column with integer casting\n--! SELECT eql_v2.add_column('users', 'age', 'int');\n--!\n--! @see eql_v2.add_search_config\n--! @see eql_v2.remove_column\nCREATE FUNCTION eql_v2.add_column(table_name text, column_name text, cast_as text DEFAULT 'text', migrating boolean DEFAULT false)\n RETURNS jsonb\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n key text;\n _config jsonb;\n BEGIN\n -- set the active config\n SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;\n\n -- set default config\n SELECT eql_v2.config_default(_config) INTO _config;\n\n -- if index exists\n IF _config #> array['tables', table_name] ? column_name THEN\n RAISE EXCEPTION 'Config exists for column: % %', table_name, column_name;\n END IF;\n\n SELECT eql_v2.config_add_table(table_name, _config) INTO _config;\n\n SELECT eql_v2.config_add_column(table_name, column_name, _config) INTO _config;\n\n SELECT eql_v2.config_add_cast(table_name, column_name, cast_as, _config) INTO _config;\n\n -- create a new pending record if we don't have one\n INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)\n ON CONFLICT (state)\n WHERE state = 'pending'\n DO UPDATE\n SET data = _config;\n\n IF NOT migrating THEN\n PERFORM eql_v2.migrate_config();\n PERFORM eql_v2.activate_config();\n END IF;\n\n PERFORM eql_v2.add_encrypted_constraint(table_name, column_name);\n\n -- exeunt\n RETURN _config;\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Remove a column from encryption configuration\n--!\n--! Removes a column from the encryption configuration, including all associated\n--! search indexes. Removes encrypted constraint, updates pending configuration,\n--! then migrates and activates unless migrating flag is set.\n--!\n--! @param table_name Text Name of the table containing the column\n--! @param column_name Text Name of the column to remove\n--! @param migrating Boolean Skip auto-migration if true (default: false)\n--! @return JSONB Updated configuration object\n--! @throws Exception if no active or pending configuration exists\n--! @throws Exception if table is not configured\n--! @throws Exception if column is not configured\n--!\n--! @example\n--! -- Remove email column from encryption\n--! SELECT eql_v2.remove_column('users', 'email');\n--!\n--! @see eql_v2.add_column\n--! @see eql_v2.remove_search_config\nCREATE FUNCTION eql_v2.remove_column(table_name text, column_name text, migrating boolean DEFAULT false)\n RETURNS jsonb\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n key text;\n _config jsonb;\n BEGIN\n -- set the active config\n SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC;\n\n -- if no config\n IF _config IS NULL THEN\n RAISE EXCEPTION 'No active or pending configuration exists';\n END IF;\n\n -- if the table doesn't exist\n IF NOT _config #> array['tables'] ? table_name THEN\n RAISE EXCEPTION 'No configuration exists for table: %', table_name;\n END IF;\n\n -- if the column does not exist\n IF NOT _config #> array['tables', table_name] ? column_name THEN\n RAISE EXCEPTION 'No configuration exists for column: % %', table_name, column_name;\n END IF;\n\n -- create a new pending record if we don't have one\n INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config)\n ON CONFLICT (state)\n WHERE state = 'pending'\n DO NOTHING;\n\n -- remove the column\n SELECT _config #- array['tables', table_name, column_name] INTO _config;\n\n -- if table is now empty, remove the table\n IF _config #> array['tables', table_name] = '{}' THEN\n SELECT _config #- array['tables', table_name] INTO _config;\n END IF;\n\n PERFORM eql_v2.remove_encrypted_constraint(table_name, column_name);\n\n -- update the config (even if empty) and activate\n UPDATE public.eql_v2_configuration SET data = _config WHERE state = 'pending';\n\n IF NOT migrating THEN\n -- For empty configs, skip migration validation and directly activate\n IF _config #> array['tables'] = '{}' THEN\n UPDATE public.eql_v2_configuration SET state = 'inactive' WHERE state = 'active';\n UPDATE public.eql_v2_configuration SET state = 'active' WHERE state = 'pending';\n ELSE\n PERFORM eql_v2.migrate_config();\n PERFORM eql_v2.activate_config();\n END IF;\n END IF;\n\n -- exeunt\n RETURN _config;\n\n END;\n$$ LANGUAGE plpgsql;\n\n--! @brief Reload configuration from CipherStash Proxy\n--!\n--! Placeholder function for reloading configuration from the CipherStash Proxy.\n--! Currently returns NULL without side effects.\n--!\n--! @return Void\n--!\n--! @note This function may be used for configuration synchronization in future versions\nCREATE FUNCTION eql_v2.reload_config()\n RETURNS void\nLANGUAGE sql STRICT PARALLEL SAFE\nBEGIN ATOMIC\n RETURN NULL;\nEND;\n\n--! @brief Query encryption configuration in tabular format\n--!\n--! Returns the active encryption configuration as a table for easier querying\n--! and filtering. Shows all configured tables, columns, cast types, and indexes.\n--!\n--! @return TABLE Contains configuration state, relation name, column name, cast type, and indexes\n--!\n--! @example\n--! -- View all encrypted columns\n--! SELECT * FROM eql_v2.config();\n--!\n--! -- Find all columns with match indexes\n--! SELECT relation, col_name FROM eql_v2.config()\n--! WHERE indexes ? 'match';\n--!\n--! @see eql_v2.add_column\n--! @see eql_v2.add_search_config\nCREATE FUNCTION eql_v2.config() RETURNS TABLE (\n state eql_v2_configuration_state,\n relation text,\n col_name text,\n decrypts_as text,\n indexes jsonb\n)\n SET search_path = pg_catalog, extensions, public\nAS $$\nBEGIN\n RETURN QUERY\n WITH tables AS (\n SELECT cfg.state, tables.key AS table, tables.value AS tbl_config\n FROM public.eql_v2_configuration cfg, jsonb_each(data->'tables') tables\n WHERE cfg.data->>'v' = '1'\n )\n SELECT\n tables.state,\n tables.table,\n column_config.key,\n COALESCE(column_config.value->>'plaintext_type', column_config.value->>'cast_as'),\n column_config.value->'indexes'\n FROM tables, jsonb_each(tables.tbl_config) column_config;\nEND;\n$$ LANGUAGE plpgsql;\n\n--! @file config/constraints.sql\n--! @brief Configuration validation functions and constraints\n--!\n--! Provides CHECK constraint functions to validate encryption configuration structure.\n--! Ensures configurations have required fields (version, tables) and valid values\n--! for index types and cast types before being stored.\n--!\n--! @see config/tables.sql where constraints are applied\n\n\n--! @brief Extract index type names from configuration\n--! @internal\n--!\n--! Helper function that extracts all index type names from the configuration's\n--! 'indexes' sections across all tables and columns.\n--!\n--! @param jsonb Configuration data to extract from\n--! @return SETOF text Index type names (e.g., 'match', 'ore', 'unique', 'ste_vec')\n--!\n--! @note Used by config_check_indexes for validation\n--! @see eql_v2.config_check_indexes\nCREATE FUNCTION eql_v2.config_get_indexes(val jsonb)\n RETURNS SETOF text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\nBEGIN ATOMIC\n\tSELECT jsonb_object_keys(jsonb_path_query(val,'$.tables.*.*.indexes'));\nEND;\n\n\n--! @brief Validate index types in configuration\n--! @internal\n--!\n--! Checks that all index types specified in the configuration are valid.\n--! Valid index types are: match, ore, ope, unique, ste_vec.\n--!\n--! @param jsonb Configuration data to validate\n--! @return boolean True if all index types are valid\n--! @throws Exception if any invalid index type found\n--!\n--! @note Used in CHECK constraint on eql_v2_configuration table\n--! @see eql_v2.config_get_indexes\nCREATE FUNCTION eql_v2.config_check_indexes(val jsonb)\n RETURNS BOOLEAN\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n\n IF (SELECT EXISTS (SELECT eql_v2.config_get_indexes(val))) THEN\n IF (SELECT bool_and(index = ANY('{match, ore, ope, unique, ste_vec}')) FROM eql_v2.config_get_indexes(val) AS index) THEN\n RETURN true;\n END IF;\n RAISE 'Configuration has an invalid index (%). Index should be one of {match, ore, ope, unique, ste_vec}', val;\n END IF;\n RETURN true;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Validate cast types in configuration\n--! @internal\n--!\n--! Checks that all 'cast_as' and 'plaintext_type' types specified in the configuration are valid.\n--! Valid cast types are: text, int, small_int, big_int, real, double, boolean, date, jsonb, json, float, decimal, timestamp.\n--!\n--! @param jsonb Configuration data to validate\n--! @return boolean True if all cast types are valid or no cast types specified\n--! @throws Exception if any invalid cast type found\n--!\n--! @note Used in CHECK constraint on eql_v2_configuration table\n--! @note Empty configurations (no cast_as/plaintext_type fields) are valid\n--! @note Cast type names are EQL's internal representations, not PostgreSQL native types\n--! @note 'plaintext_type' is accepted as a canonical alias for 'cast_as'\nCREATE FUNCTION eql_v2.config_check_cast(val jsonb)\n RETURNS BOOLEAN\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n _valid_types text[] := '{text, int, small_int, big_int, real, double, boolean, date, jsonb, json, float, decimal, timestamp}';\n\tBEGIN\n -- Validate cast_as fields\n IF EXISTS (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.cast_as'))) THEN\n IF NOT (SELECT bool_and(cast_as = ANY(_valid_types))\n FROM (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.cast_as')) AS cast_as) casts) THEN\n RAISE 'Configuration has an invalid cast_as (%). Cast should be one of %', val, _valid_types;\n END IF;\n END IF;\n\n -- Validate plaintext_type fields (canonical alias for cast_as)\n IF EXISTS (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.plaintext_type'))) THEN\n IF NOT (SELECT bool_and(pt = ANY(_valid_types))\n FROM (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.plaintext_type')) AS pt) types) THEN\n RAISE 'Configuration has an invalid plaintext_type (%). Type should be one of %', val, _valid_types;\n END IF;\n END IF;\n\n RETURN true;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Validate tables field presence\n--! @internal\n--!\n--! Ensures the configuration has a 'tables' field, which is required\n--! to specify which database tables contain encrypted columns.\n--!\n--! @param jsonb Configuration data to validate\n--! @return boolean True if 'tables' field exists\n--! @throws Exception if 'tables' field is missing\n--!\n--! @note Used in CHECK constraint on eql_v2_configuration table\nCREATE FUNCTION eql_v2.config_check_tables(val jsonb)\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF (val ? 'tables') THEN\n RETURN true;\n END IF;\n RAISE 'Configuration missing tables (tables) field: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Validate version field presence\n--! @internal\n--!\n--! Ensures the configuration has a 'v' (version) field, which tracks\n--! the configuration format version.\n--!\n--! @param jsonb Configuration data to validate\n--! @return boolean True if 'v' field exists\n--! @throws Exception if 'v' field is missing\n--!\n--! @note Used in CHECK constraint on eql_v2_configuration table\nCREATE FUNCTION eql_v2.config_check_version(val jsonb)\n RETURNS boolean\n SET search_path = pg_catalog, extensions, public\nAS $$\n\tBEGIN\n IF (val ? 'v') THEN\n RETURN true;\n END IF;\n RAISE 'Configuration missing version (v) field: %', val;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Validate ste_vec index mode option\n--! @internal\n--!\n--! Checks that the optional `mode` field on `ste_vec` index configurations is\n--! one of the recognised values. Valid modes are: standard, compat.\n--! Configurations without a `mode` field (the default) pass unconditionally.\n--!\n--! @param jsonb Configuration data to validate\n--! @return boolean True if every ste_vec mode is valid, or none are set\n--! @throws Exception if any ste_vec.mode value is not in the allowed set\n--!\n--! @note Used in CHECK constraint on eql_v2_configuration table\n--! @note Mode is optional — only configurations that set it are validated\nCREATE FUNCTION eql_v2.config_check_ste_vec_mode(val jsonb)\n RETURNS BOOLEAN\n IMMUTABLE STRICT PARALLEL SAFE\n SET search_path = pg_catalog, extensions, public\nAS $$\n DECLARE\n _valid_modes text[] := '{standard, compat}';\n BEGIN\n IF EXISTS (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.indexes.ste_vec.mode'))) THEN\n IF NOT (SELECT bool_and(mode = ANY(_valid_modes))\n FROM (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.indexes.ste_vec.mode')) AS mode) modes) THEN\n RAISE 'Configuration has an invalid ste_vec mode (%). Mode should be one of %', val, _valid_modes;\n END IF;\n END IF;\n RETURN true;\n END;\n$$ LANGUAGE plpgsql;\n\n\n--! @brief Drop existing data validation constraint if present\n--! @note Allows constraint to be recreated during upgrades\nALTER TABLE public.eql_v2_configuration DROP CONSTRAINT IF EXISTS eql_v2_configuration_data_check;\n\n\n--! @brief Comprehensive configuration data validation\n--!\n--! CHECK constraint that validates all aspects of configuration data:\n--! - Version field presence\n--! - Tables field presence\n--! - Valid cast_as types\n--! - Valid index types\n--! - Valid ste_vec mode (when set)\n--!\n--! @note Combines all config_check_* validation functions\n--! @see eql_v2.config_check_version\n--! @see eql_v2.config_check_tables\n--! @see eql_v2.config_check_cast\n--! @see eql_v2.config_check_indexes\n--! @see eql_v2.config_check_ste_vec_mode\nALTER TABLE public.eql_v2_configuration\n ADD CONSTRAINT eql_v2_configuration_data_check CHECK (\n eql_v2.config_check_version(data) AND\n eql_v2.config_check_tables(data) AND\n eql_v2.config_check_cast(data) AND\n eql_v2.config_check_indexes(data) AND\n eql_v2.config_check_ste_vec_mode(data)\n);\n\n\n--! @file pin_search_path.sql\n--! @brief Post-install: pin search_path on every eql_v2.* function\n--!\n--! This file is appended verbatim by `tasks/build.sh` to the end of every\n--! release variant (main, supabase, protect/stack), AFTER all `src/**/*.sql`\n--! files have been concatenated. It lives outside `src/` so it stays out of\n--! the dependency graph entirely — each variant has a different leaf set\n--! (supabase excludes `**/*operator_class.sql`; protect excludes `src/config/*`\n--! and `src/encryptindex/*`), and threading REQUIREs to be ordered last in\n--! every variant simultaneously is fragile.\n--!\n--! Iterates over functions in the `eql_v2` schema and applies a fixed\n--! `search_path` via `ALTER FUNCTION ... SET search_path = ...`. This is the\n--! only way to satisfy Supabase splinter's `function_search_path_mutable`\n--! lint, which checks `pg_proc.proconfig` directly.\n--!\n--! @note A SET clause disables PostgreSQL's SQL-function inlining (see\n--! inline_function() in src/backend/optimizer/util/clauses.c). For most\n--! eql_v2 helpers this is irrelevant. The exceptions are wrappers that\n--! must inline to expose `eql_v2.jsonb_array(col) @> ...` to the planner\n--! so the GIN index on `jsonb_array(e)` can be matched. Those are\n--! deliberately skipped here and allowlisted in `tasks/test/splinter.sh`.\n--!\n--! @see tasks/test/splinter.sh\n--! @see tasks/build.sh\n\nDO $$\nDECLARE\n fn_oid oid;\n inline_critical_oids oid[];\n enc_oid oid;\n jsonb_oid oid;\n text_oid oid;\n entry_oid oid;\nBEGIN\n -- Resolve type oids without depending on caller search_path. The encrypted\n -- composite type is created in `public`; jsonb / text are in `pg_catalog`;\n -- the ste_vec_entry DOMAIN lives in `eql_v2`.\n SELECT t.oid INTO enc_oid\n FROM pg_catalog.pg_type t\n JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace\n WHERE n.nspname = 'public' AND t.typname = 'eql_v2_encrypted';\n\n IF enc_oid IS NULL THEN\n RAISE EXCEPTION 'pin_search_path: type public.eql_v2_encrypted not found — '\n 'this script must run after all EQL src/**/*.sql files have been loaded';\n END IF;\n\n SELECT t.oid INTO jsonb_oid\n FROM pg_catalog.pg_type t\n JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace\n WHERE n.nspname = 'pg_catalog' AND t.typname = 'jsonb';\n\n IF jsonb_oid IS NULL THEN\n RAISE EXCEPTION 'pin_search_path: type pg_catalog.jsonb not found';\n END IF;\n\n SELECT t.oid INTO text_oid\n FROM pg_catalog.pg_type t\n JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace\n WHERE n.nspname = 'pg_catalog' AND t.typname = 'text';\n\n IF text_oid IS NULL THEN\n RAISE EXCEPTION 'pin_search_path: type pg_catalog.text not found';\n END IF;\n\n SELECT t.oid INTO entry_oid\n FROM pg_catalog.pg_type t\n JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace\n WHERE n.nspname = 'eql_v2' AND t.typname = 'ste_vec_entry';\n\n IF entry_oid IS NULL THEN\n RAISE EXCEPTION 'pin_search_path: type eql_v2.ste_vec_entry not found';\n END IF;\n\n -- Wrappers that must remain inlinable for functional-index matching.\n -- Verified empirically: with SET, EXPLAIN drops to Seq Scan; without,\n -- it uses Bitmap Index Scan / Index Scan.\n --\n -- Phase 1 operator inlining (#193): `=`, `<>`, `~~`, `~~*`, `@>`, `<@`\n -- on `eql_v2_encrypted` and the cross-type (encrypted, jsonb) /\n -- (jsonb, encrypted) overloads emitted by ORMs that bind parameters\n -- as jsonb (Drizzle, PostgREST, encryptedSupabase). The implementation\n -- functions reduce to `extractor(a) op extractor(b)` and must inline\n -- to match the documented functional indexes\n -- (`eql_v2.hmac_256(col)`, `eql_v2.bloom_filter(col)`,\n -- `eql_v2.ste_vec(col)`).\n --\n -- For `~~` / `~~*` the planner must inline two layers — the operator\n -- function `eql_v2.\"~~\"` and the helper `eql_v2.like` / `eql_v2.ilike`\n -- — to reach the canonical `eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b)`\n -- form that the documented functional index matches. The helpers are\n -- allowlisted alongside the operator wrappers below; pinning either\n -- layer breaks the chain and reverts to Seq Scan.\n --\n -- Note: pg_proc.proargtypes is an oidvector with 0-based bounds, so we\n -- compare elements individually rather than using array equality (which\n -- requires matching bounds, not just contents).\n SELECT pg_catalog.array_agg(p.oid) INTO inline_critical_oids\n FROM pg_catalog.pg_proc p\n JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'eql_v2'\n AND (\n -- Same-type (encrypted, encrypted) operators that must inline.\n -- `like`/`ilike` are the SQL helpers that `~~`/`~~*` delegate to;\n -- both layers must inline to reach `bloom_filter(a) @> bloom_filter(b)`.\n -- `<`, `<=`, `>`, `>=` inline to `ore_block_u64_8_256(a) op\n -- ore_block_u64_8_256(b)`; they must reach the functional ORE index\n -- expression `eql_v2.ore_block_u64_8_256(col)` for bare range\n -- queries to engage Index Scan.\n (p.pronargs = 2\n AND p.proname IN ('=', '<>', '<', '<=', '>', '>=',\n '~~', '~~*', '@>', '<@',\n 'jsonb_contains', 'jsonb_contained_by',\n 'like', 'ilike')\n AND p.proargtypes[0] = enc_oid AND p.proargtypes[1] = enc_oid)\n -- Cross-type (encrypted, jsonb).\n OR (p.pronargs = 2\n AND p.proname IN ('=', '<>', '<', '<=', '>', '>=',\n '~~', '~~*',\n 'jsonb_contains', 'jsonb_contained_by')\n AND p.proargtypes[0] = enc_oid AND p.proargtypes[1] = jsonb_oid)\n -- Cross-type (jsonb, encrypted).\n OR (p.pronargs = 2\n AND p.proname IN ('=', '<>', '<', '<=', '>', '>=',\n '~~', '~~*',\n 'jsonb_contains', 'jsonb_contained_by')\n AND p.proargtypes[0] = jsonb_oid AND p.proargtypes[1] = enc_oid)\n -- Root-level HMAC extractor (#205): all 1-arg overloads are now\n -- inlinable SQL. Must stay unpinned so the planner can fold extractor\n -- calls inside the inlined equality operator bodies into the calling\n -- query, preserving the functional-index match.\n OR (p.pronargs = 1\n AND p.proname = 'hmac_256'\n AND (p.proargtypes[0] = enc_oid OR p.proargtypes[0] = jsonb_oid))\n -- Field-level JSONB extractors (#205): inlinable SQL replacements for\n -- the previous plpgsql bodies. Inlining lets the planner fold the\n -- `jsonb_array_elements(...) WHERE elem->>'s' = selector` body into\n -- the calling query, eliminating per-row function call overhead on\n -- large ste_vec scans.\n OR (p.pronargs = 2\n AND p.proname IN ('jsonb_path_query',\n 'jsonb_path_query_first',\n 'jsonb_path_exists'))\n -- Inner ORE-block comparison helpers backing the `<`, `<=`, `>`, `>=`\n -- operators on `eql_v2.ore_block_u64_8_256`. The outer operators on\n -- `eql_v2_encrypted` inline to `ore_block(a) ore_block(b)`, and\n -- PG only carries the inlined form through to index matching if the\n -- inner operator function is also inlinable (no SET, IMMUTABLE).\n -- Pinning these would prevent the planner from structurally matching\n -- predicates against a functional `eql_v2.ore_block_u64_8_256(col)`\n -- index. The inner functions are deterministic comparisons of\n -- composite type bytes, declared IMMUTABLE STRICT PARALLEL SAFE.\n OR (p.pronargs = 2\n AND p.proname IN ('ore_block_u64_8_256_eq', 'ore_block_u64_8_256_neq',\n 'ore_block_u64_8_256_lt', 'ore_block_u64_8_256_lte',\n 'ore_block_u64_8_256_gt', 'ore_block_u64_8_256_gte'))\n -- Hash operator class FUNCTION 1: called once per row by HashAggregate,\n -- hash joins, DISTINCT. Inlinable SQL avoids the per-row plpgsql\n -- interpreter overhead — without this, `GROUP BY value` on\n -- `eql_v2_encrypted` at 1M rows degrades super-linearly because the\n -- plpgsql cost compounds with HashAggregate work_mem spillage.\n OR (p.pronargs = 1\n AND p.proname = 'hash_encrypted'\n AND p.proargtypes[0] = enc_oid)\n -- Consolidated ORE-CLLW extractor (U-006). Inlinable SQL — pinning\n -- would silently undo it and prevent the planner from folding\n -- `eql_v2.ore_cllw(col)` calls into the calling query. The\n -- `compare_ore_cllw_term` comparator stays plpgsql by design (per-byte\n -- protocol can't be expressed as a single inlinable SELECT), so it is\n -- NOT on this list. The (jsonb) form is a RHS-parameter helper for\n -- comparisons against literal jsonb; the (eql_v2.ste_vec_entry) form\n -- is the typed extractor for the result of `col -> ''`.\n OR (p.pronargs = 1\n AND p.proname IN ('ore_cllw', 'has_ore_cllw')\n AND (p.proargtypes[0] = jsonb_oid OR p.proargtypes[0] = entry_oid))\n -- Typed HMAC extractor on a ste_vec entry (#219 strict separation).\n -- Same rationale as `ore_cllw(ste_vec_entry)` — must inline so\n -- `eql_v2.hmac_256(col -> 'sel')` folds into the calling query and\n -- matches a functional hash index built on the same expression.\n OR (p.pronargs = 1\n AND p.proname IN ('hmac_256', 'has_hmac_256', 'selector')\n AND p.proargtypes[0] = entry_oid)\n -- `eql_v2.ste_vec_entry × eql_v2.ste_vec_entry` operators (#219).\n -- Inline to `hmac_256(a) = hmac_256(b)` (equality) or\n -- `ore_cllw(a) ore_cllw(b)` (ordering); both chains must remain\n -- unpinned for functional-index match through extractor form.\n OR (p.pronargs = 2\n AND p.proname IN ('=', '<>', '<', '<=', '>', '>=',\n 'eq', 'neq', 'lt', 'lte', 'gt', 'gte')\n AND p.proargtypes[0] = entry_oid AND p.proargtypes[1] = entry_oid)\n -- Inner ORE-CLLW comparison helpers backing the `<`, `<=`, `=`,\n -- `>=`, `>`, `<>` operators on `eql_v2.ore_cllw` (the composite\n -- type, registered via `eql_v2.ore_cllw_ops` opclass — #221). Same\n -- precedent as the `ore_block_u64_8_256_*` helpers above: PG only\n -- carries the inlined operator wrapper through to functional-index\n -- match if the inner backing function is also inlinable. Pinning\n -- these would break the index match for `ORDER BY eql_v2.ore_cllw\n -- (value -> ''::text)` and the matching `WHERE` form.\n OR (p.pronargs = 2\n AND p.proname IN ('ore_cllw_eq', 'ore_cllw_neq',\n 'ore_cllw_lt', 'ore_cllw_lte',\n 'ore_cllw_gt', 'ore_cllw_gte'))\n -- `->` selector lookup: inlinable SQL post the type flip\n -- (returns `eql_v2.ste_vec_entry`). Must stay unpinned so the\n -- planner can fold `col -> ''` into the calling query\n -- — without this, the chained recipe\n -- `WHERE col -> 'sel' = $1::ste_vec_entry` would not match a\n -- functional hash index on `eql_v2.eq_term(col -> 'sel')`.\n OR (p.proname = '->'\n AND p.pronargs = 2\n AND p.proargtypes[0] = enc_oid\n AND (p.proargtypes[1] = text_oid\n OR p.proargtypes[1] = enc_oid\n OR p.proargtypes[1] = (SELECT t.oid FROM pg_catalog.pg_type t\n JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace\n WHERE n.nspname = 'pg_catalog' AND t.typname = 'int4')))\n -- XOR-aware equality term extractor on a ste_vec entry. Must\n -- inline so `eql_v2.eq_term(col -> 'sel')` folds into the\n -- calling query and matches a functional hash index built on\n -- the same expression.\n OR (p.pronargs = 1\n AND p.proname = 'eq_term'\n AND p.proargtypes[0] = entry_oid)\n -- Type-safe `@>` / `<@` overloads with typed needles\n -- (`stevec_query`, `ste_vec_entry`). Inline to the existing\n -- `ste_vec_contains` machinery — must stay unpinned to engage\n -- the GIN index on `eql_v2.ste_vec(col)` structurally for\n -- bare-form containment.\n OR (p.pronargs = 2\n AND p.proname IN ('@>', '<@')\n AND p.proargtypes[0] = enc_oid\n AND (p.proargtypes[1] = entry_oid\n OR p.proargtypes[1] = (SELECT t.oid FROM pg_catalog.pg_type t\n JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace\n WHERE n.nspname = 'eql_v2' AND t.typname = 'stevec_query')))\n OR (p.pronargs = 2\n AND p.proname IN ('@>', '<@')\n AND p.proargtypes[1] = enc_oid\n AND (p.proargtypes[0] = entry_oid\n OR p.proargtypes[0] = (SELECT t.oid FROM pg_catalog.pg_type t\n JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace\n WHERE n.nspname = 'eql_v2' AND t.typname = 'stevec_query')))\n );\n\n FOR fn_oid IN\n SELECT p.oid\n FROM pg_catalog.pg_proc p\n JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'eql_v2'\n -- Only normal functions ('f') and window functions ('w') accept\n -- ALTER FUNCTION ... SET. Aggregates ('a') would be rejected by\n -- ALTER ROUTINE/FUNCTION, and procedures ('p') would need ALTER\n -- PROCEDURE. The 3 affected aggregates (min, max, grouped_value)\n -- are allowlisted in splinter.\n AND p.prokind IN ('f', 'w')\n AND NOT EXISTS (\n SELECT 1 FROM pg_catalog.unnest(coalesce(p.proconfig, '{}'::text[])) c\n WHERE c LIKE 'search_path=%'\n )\n AND NOT (p.oid = ANY (coalesce(inline_critical_oids, '{}'::oid[])))\n LOOP\n -- oid::regprocedure renders as `schema.name(argtype, argtype)` and is a\n -- valid target for ALTER FUNCTION regardless of caller search_path.\n EXECUTE pg_catalog.format(\n 'ALTER FUNCTION %s SET search_path = pg_catalog, extensions, public',\n fn_oid::regprocedure\n );\n END LOOP;\nEND $$;\n" - } - ], - "postcheck": [ - { - "description": "verify \"eql_v2\" schema exists", - "sql": "SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'eql_v2')" - }, - { - "description": "verify \"public.eql_v2_encrypted\" composite type exists", - "sql": "SELECT EXISTS (SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = 'public' AND t.typname = 'eql_v2_encrypted')" - } - ] - } -] \ No newline at end of file diff --git a/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/end-contract.d.ts b/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/end-contract.d.ts index 6443a1f58..22fda37b6 100644 --- a/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/end-contract.d.ts +++ b/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/end-contract.d.ts @@ -30,7 +30,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7'>; + StorageHashBase<'sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; @@ -42,24 +42,8 @@ type DefaultLiteralValue = CodecId extends key ? CodecTypes[CodecId]['output'] : _Encoded; -export type FieldOutputTypes = { - 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 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 FieldOutputTypes = { readonly public: Record }; +export type FieldInputTypes = { readonly public: Record }; export type TypeMaps = TypeMapsType< CodecTypes, QueryOperationTypes, @@ -73,33 +57,7 @@ type ContractBase = Omit< 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 entries: { readonly table: {} }; }; }; readonly storageHash: StorageHash; @@ -108,43 +66,11 @@ type ContractBase = Omit< > & { readonly target: 'postgres'; readonly targetFamily: 'sql'; - readonly roots: { - readonly eql_v2_configuration: { - readonly namespace: 'public' & NamespaceId; - readonly model: 'EqlV2Configuration'; - }; - }; + readonly roots: Record; 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 models: Record; }; }; }; diff --git a/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/end-contract.json b/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/end-contract.json index 8dc56139f..e2cd070fe 100644 --- a/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/end-contract.json +++ b/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/end-contract.json @@ -3,58 +3,11 @@ "targetFamily": "sql", "target": "postgres", "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", - "roots": { - "eql_v2_configuration": { - "model": "EqlV2Configuration", - "namespace": "public" - } - }, + "roots": {}, "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" - } - } - } + "models": {} } } }, @@ -62,41 +15,13 @@ "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": [] - } - } + "table": {} }, "id": "public", "kind": "postgres-schema" } }, - "storageHash": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7" + "storageHash": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e" }, "capabilities": { "postgres": { diff --git a/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.json b/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.json index 32074ecf9..ef2df5616 100644 --- a/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.json +++ b/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.json @@ -1,9 +1,9 @@ { - "from": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7", - "to": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7", + "from": null, + "to": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e", "providedInvariants": [ "cipherstash:install-eql-v3-bundle-v1" ], "createdAt": "2026-07-14T20:10:24.325Z", - "migrationHash": "sha256:55f261c28b9fe8feee21270a652d758a5c90ccbe7aac9cc79ac50662b956160b" + "migrationHash": "sha256:35fc9000fea6e1dc0f1cbf9521344aa6e32f50872a401ceaf1be235d1eb1b315" } \ No newline at end of file diff --git a/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.ts b/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.ts index 16c8e4d9d..ee1e29ad3 100644 --- a/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.ts +++ b/packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.ts @@ -12,12 +12,11 @@ * operator-function schema (`eql_v3.eq`, `eql_v3.ord_term`, …), the * `eql_v3.query_*` operand domains, and the `eql_v3_internal` helper schema. * - * This is the SECOND migration in the cipherstash contract space, and - * it is an **invariant-only edge**: the v3 bundle declares no - * contract-space storage (no config table — unlike the v2 bundle's - * `eql_v2_configuration`), so the storage hash does not move and - * `describe()` returns `from === to === `. The - * edge exists to carry the `cipherstash:install-eql-v3-bundle-v1` + * This is the SOLE migration in the cipherstash contract space (the + * package is EQL v3 only), and it is an **invariant-only genesis edge**: + * the v3 bundle declares no contract-space storage (no config table), + * so `describe()` returns `from: null → to: `. + * The edge exists to carry the `cipherstash:install-eql-v3-bundle-v1` * invariant: the apply-path planner (`findPathWithInvariants`) walks it * when the head ref requires that invariant. * @@ -43,8 +42,8 @@ const INSTALL_LABEL = `Install EQL v3 bundle (eql-${releaseManifest.eqlVersion}: export default class M extends Migration { override describe() { return { - from: 'sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7', - to: 'sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7', + from: null, + to: 'sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e', } } @@ -53,18 +52,17 @@ export default class M extends Migration { rawSql({ id: 'cipherstash.install-eql-v3-bundle', label: INSTALL_LABEL, - // `data`, not `additive`: this edge is a SELF-EDGE (`from === - // to` — the bundle declares no contract-space storage), and the - // aggregate integrity checker (`migration-tools` - // `check-integrity.ts`) rejects a self-edge unless it carries a - // `data`-class op. Along the axis the checker classifies — - // does the op move the modeled contract shape? — `data` is the + // `data`, not `additive`: this genesis edge moves NO contract + // storage (`from: null` → the empty-storage hash — the bundle + // declares no contract-space storage), and the aggregate + // integrity checker (`migration-tools` `check-integrity.ts`) + // rejects a no-storage-movement edge unless it carries a + // `data`-class op. Along the axis the checker classifies — does + // the op move the modeled contract shape? — `data` is the // truthful answer: the bundle creates `public.eql_v3_*` domains // and `eql_v3.*` functions the space contract deliberately does - // not model. (Contrast the v2 bundle op: `additive`, because - // its edge really moves the hash by adding the - // `eql_v2_configuration` table.) The `migrate` policy allows - // all four classes, so apply behaviour is unchanged. + // not model. The `migrate` policy allows all four classes, so + // apply behaviour is unchanged. operationClass: 'data', invariantId: CIPHERSTASH_V3_INVARIANTS.installBundle, target: { id: 'postgres' }, @@ -74,7 +72,8 @@ export default class M extends Migration { // `../../src/migration/eql-bundle-v3.ts` `withRuntimeEqlSql`), so the // ~1.7 MB bundle is NOT baked into `ops.json` and bumping the pinned // `@cipherstash/eql` needs no re-emit. Safe because this is an - // invariant-only self-edge: the SQL never moves the contract hash. + // invariant-only genesis edge: the SQL never moves the contract + // hash (`from: null` → the empty-storage hash, as above). execute: [ { description: INSTALL_LABEL, sql: RUNTIME_EQL_SQL_SENTINEL }, ], diff --git a/packages/prisma-next/migrations/refs/head.json b/packages/prisma-next/migrations/refs/head.json index 9cb960fee..e3eaff20f 100644 --- a/packages/prisma-next/migrations/refs/head.json +++ b/packages/prisma-next/migrations/refs/head.json @@ -1,7 +1,6 @@ { - "hash": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7", + "hash": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e", "invariants": [ - "cipherstash:install-eql-bundle-v1", "cipherstash:install-eql-v3-bundle-v1" ] } diff --git a/packages/prisma-next/package.json b/packages/prisma-next/package.json index 0f4194ede..330069e85 100644 --- a/packages/prisma-next/package.json +++ b/packages/prisma-next/package.json @@ -3,7 +3,7 @@ "version": "1.0.0-rc.3", "license": "MIT", "author": "CipherStash ", - "description": "CipherStash extension for Prisma Next: searchable application-layer field-level encryption for Postgres, with six encrypted column types, 17 query operators, bulk encrypt/decrypt middleware, and a baseline migration that installs the vendored EQL bundle SQL byte-for-byte.", + "description": "CipherStash extension for Prisma Next: searchable application-layer field-level encryption for Postgres (EQL v3), with domain-typed encrypted columns, the eql* query operators, bulk encrypt/decrypt middleware, and a baseline migration that installs the EQL v3 bundle.", "keywords": [ "encrypted", "prisma-next", @@ -36,14 +36,6 @@ "types": "./dist/control.d.ts", "import": "./dist/control.js" }, - "./middleware": { - "types": "./dist/middleware.d.ts", - "import": "./dist/middleware.js" - }, - "./migration": { - "types": "./dist/migration.d.ts", - "import": "./dist/migration.js" - }, "./operation-types": { "types": "./dist/operation-types.d.ts", "import": "./dist/operation-types.js" diff --git a/packages/prisma-next/src/contract-authoring.ts b/packages/prisma-next/src/contract-authoring.ts index 7da4136ff..4f45e5b13 100644 --- a/packages/prisma-next/src/contract-authoring.ts +++ b/packages/prisma-next/src/contract-authoring.ts @@ -28,14 +28,6 @@ * the static `{ castAs, capabilities }` block below lowers to itself — no * `{ kind: 'literal' }` wrapper exists or is needed. * - * ## v2: legacy `*V2` aliases - * - * The six pre-existing v2 constructors survive verbatim under `*V2` names - * (`EncryptedStringV2`, …): same optional-object argument (all search-mode - * flags default to `true` — searchable encryption is the legitimate default; - * users opt out explicitly), same `cipherstash/*@1` codec ids, same - * `eql_v2_encrypted` native type. - * * The same descriptors lower a PSL field-type expression and the matching TS * factory (see `./exports/column-types`) to an identical * `ColumnTypeDescriptor`, so PSL- and TS-authored contracts emit @@ -47,15 +39,6 @@ import type { AuthoringTypeConstructorDescriptor, 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 @@ -112,10 +95,9 @@ function coreName(bareDomain: string): string { * * The `cipherstash.` PSL namespace already disambiguates, so the constructor * names drop the `Encrypted` prefix to line up with the stack / Drizzle - * `types.*` catalog (`types.TextSearch`, `types.BigintOrd`, …). The v2 - * constructors keep their `*V2` names; the runtime envelope classes - * (`EncryptedString`, `EncryptedBoolean`, …) are a separate surface and are - * unchanged. + * `types.*` catalog (`types.TextSearch`, `types.BigintOrd`, …). The runtime + * envelope classes (`EncryptedString`, `EncryptedBoolean`, …) are a separate + * surface and keep their names. */ export function v3PascalName(bareDomain: string): string { return coreName(bareDomain) @@ -172,199 +154,6 @@ function v3Constructor( } } -// --- v2 legacy aliases (verbatim pre-rename v2 outputs, now keyed *V2) --- - -const encryptedStringV2 = { - kind: 'typeConstructor', - args: [ - { - kind: 'object', - name: 'options', - optional: true, - properties: { - equality: { kind: 'boolean', optional: true }, - freeTextSearch: { kind: 'boolean', optional: true }, - orderAndRange: { kind: 'boolean', optional: true }, - }, - }, - ], - output: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - equality: { - kind: 'arg', - index: 0, - path: ['equality'], - default: true, - }, - freeTextSearch: { - kind: 'arg', - index: 0, - path: ['freeTextSearch'], - default: true, - }, - orderAndRange: { - kind: 'arg', - index: 0, - path: ['orderAndRange'], - default: true, - }, - }, - }, -} as const satisfies AuthoringTypeConstructorDescriptor - -const encryptedDoubleV2 = { - kind: 'typeConstructor', - args: [ - { - kind: 'object', - name: 'options', - optional: true, - properties: { - equality: { kind: 'boolean', optional: true }, - orderAndRange: { kind: 'boolean', optional: true }, - }, - }, - ], - output: { - codecId: CIPHERSTASH_DOUBLE_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - equality: { - kind: 'arg', - index: 0, - path: ['equality'], - default: true, - }, - orderAndRange: { - kind: 'arg', - index: 0, - path: ['orderAndRange'], - default: true, - }, - }, - }, -} as const satisfies AuthoringTypeConstructorDescriptor - -const encryptedBigIntV2 = { - kind: 'typeConstructor', - args: [ - { - kind: 'object', - name: 'options', - optional: true, - properties: { - equality: { kind: 'boolean', optional: true }, - orderAndRange: { kind: 'boolean', optional: true }, - }, - }, - ], - output: { - codecId: CIPHERSTASH_BIGINT_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - equality: { - kind: 'arg', - index: 0, - path: ['equality'], - default: true, - }, - orderAndRange: { - kind: 'arg', - index: 0, - path: ['orderAndRange'], - default: true, - }, - }, - }, -} as const satisfies AuthoringTypeConstructorDescriptor - -const encryptedDateV2 = { - kind: 'typeConstructor', - args: [ - { - kind: 'object', - name: 'options', - optional: true, - properties: { - equality: { kind: 'boolean', optional: true }, - orderAndRange: { kind: 'boolean', optional: true }, - }, - }, - ], - output: { - codecId: CIPHERSTASH_DATE_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - equality: { - kind: 'arg', - index: 0, - path: ['equality'], - default: true, - }, - orderAndRange: { - kind: 'arg', - index: 0, - path: ['orderAndRange'], - default: true, - }, - }, - }, -} as const satisfies AuthoringTypeConstructorDescriptor - -const encryptedBooleanV2 = { - 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: { - kind: 'arg', - index: 0, - path: ['equality'], - default: true, - }, - }, - }, -} as const satisfies AuthoringTypeConstructorDescriptor - -const encryptedJsonV2 = { - 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: { - kind: 'arg', - index: 0, - path: ['searchableJson'], - default: true, - }, - }, - }, -} as const satisfies AuthoringTypeConstructorDescriptor - const v3Constructors: Record = Object.fromEntries( EXPOSED_DOMAIN_ENTRIES.map( @@ -380,12 +169,5 @@ const v3Constructors: Record = export const cipherstashAuthoringTypes: AuthoringTypeNamespace = { cipherstash: { ...v3Constructors, - // v2 legacy aliases - EncryptedStringV2: encryptedStringV2, - EncryptedDoubleV2: encryptedDoubleV2, - EncryptedBigIntV2: encryptedBigIntV2, - EncryptedDateV2: encryptedDateV2, - EncryptedBooleanV2: encryptedBooleanV2, - EncryptedJsonV2: encryptedJsonV2, }, } diff --git a/packages/prisma-next/src/contract.d.ts b/packages/prisma-next/src/contract.d.ts index 6443a1f58..22fda37b6 100644 --- a/packages/prisma-next/src/contract.d.ts +++ b/packages/prisma-next/src/contract.d.ts @@ -30,7 +30,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7'>; + StorageHashBase<'sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; @@ -42,24 +42,8 @@ type DefaultLiteralValue = CodecId extends key ? CodecTypes[CodecId]['output'] : _Encoded; -export type FieldOutputTypes = { - 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 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 FieldOutputTypes = { readonly public: Record }; +export type FieldInputTypes = { readonly public: Record }; export type TypeMaps = TypeMapsType< CodecTypes, QueryOperationTypes, @@ -73,33 +57,7 @@ type ContractBase = Omit< 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 entries: { readonly table: {} }; }; }; readonly storageHash: StorageHash; @@ -108,43 +66,11 @@ type ContractBase = Omit< > & { readonly target: 'postgres'; readonly targetFamily: 'sql'; - readonly roots: { - readonly eql_v2_configuration: { - readonly namespace: 'public' & NamespaceId; - readonly model: 'EqlV2Configuration'; - }; - }; + readonly roots: Record; 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 models: Record; }; }; }; diff --git a/packages/prisma-next/src/contract.json b/packages/prisma-next/src/contract.json index 8dc56139f..e2cd070fe 100644 --- a/packages/prisma-next/src/contract.json +++ b/packages/prisma-next/src/contract.json @@ -3,58 +3,11 @@ "targetFamily": "sql", "target": "postgres", "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", - "roots": { - "eql_v2_configuration": { - "model": "EqlV2Configuration", - "namespace": "public" - } - }, + "roots": {}, "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" - } - } - } + "models": {} } } }, @@ -62,41 +15,13 @@ "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": [] - } - } + "table": {} }, "id": "public", "kind": "postgres-schema" } }, - "storageHash": "sha256:1e86a0160ba305fa74516b6d9449218308b258a51a913c1fc907e629f44568a7" + "storageHash": "sha256:efd408cf8924b4d1805bf5acced8898114aa03cd46b465720179c82a4431d51e" }, "capabilities": { "postgres": { diff --git a/packages/prisma-next/src/contract.prisma b/packages/prisma-next/src/contract.prisma index def9808be..a060efbca 100644 --- a/packages/prisma-next/src/contract.prisma +++ b/packages/prisma-next/src/contract.prisma @@ -9,38 +9,21 @@ // The descriptor at `src/exports/control.ts` then wires the emitted JSON // artefacts via JSON-import declarations. // -// ## IR coverage and explicit deferral -// -// CipherStash should declare four kinds of typed objects in its -// contract IR: tables, enums, composite types, and domains. Of these, -// today's `SqlStorage` IR (`@prisma-next/sql-contract/types`) only -// models tables and parameterised type instances (a fit for things -// like pgvector's `vector(N)`, but not yet codec-less composite types, -// standalone enums, or domains). -// -// The contract therefore declares the only IR-representable object -// today (the `eql_v2_configuration` table) using portable scalar -// types (`String` / `Json`). The actual database state — the `eql_v2` -// schema, the typed `eql_v2_configuration_state` enum, the -// `eql_v2_encrypted` composite, the `eql_v2.bloom_filter` / -// `hmac_256` / `blake3` domains, and the various `ore_*` composites — -// is created by the `installEqlBundle` migration op (which carries -// the vendored bundle SQL byte-for-byte; see -// `./src/migration/eql-bundle.ts`). The structural -// `cipherstash:create-*-v1` no-op ops register the invariantIds the -// verifier needs so its `applied_invariants` gate passes. -// -// Once the IR vocabulary expands to first-class composite types, -// standalone enums, and domains, those typed objects shift up into -// `storage.types` and the structural ops gain real verification work -// (precheck SQL probing `pg_type` / `information_schema`). +// ## v3-only: this contract models no storage +// +// This package installs EQL **v3** only. The v3 bundle creates the +// `public.eql_v3_*` storage domains, the `eql_v3.*` operator functions, and the +// `eql_v3_internal` helper schema — but **no contract-space storage** (no config +// table; unlike the retired EQL v2 bundle's `eql_v2_configuration`). The +// `SqlStorage` IR (`@prisma-next/sql-contract/types`) models tables and +// parameterised type instances, and v3 declares neither at the contract level: +// the v3 domains carry their own index metadata, so there is no +// `add_search_config` bookkeeping table to model. +// +// The contract therefore declares **no models**. The database state is created +// entirely by the `installEqlV3Bundle` migration op (the EQL v3 install SQL, +// injected at descriptor-build time from `@cipherstash/eql`; see +// `./src/migration/eql-bundle-v3.ts`). That op is an invariant-only genesis edge +// carrying `cipherstash:install-eql-v3-bundle-v1`. // // @see docs/architecture docs/adrs/ADR 211 - Contract spaces.md - -model EqlV2Configuration { - id String @id - state String - data Json - - @@map("eql_v2_configuration") -} diff --git a/packages/prisma-next/src/execution/cell-codec-factory.ts b/packages/prisma-next/src/execution/cell-codec-factory.ts deleted file mode 100644 index aa1715123..000000000 --- a/packages/prisma-next/src/execution/cell-codec-factory.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * Shared factory for every cipherstash storage codec runtime. - * - * Every cipherstash codec (`cipherstash/string@1`, `cipherstash/double@1`, - * `cipherstash/bigint@1`, `cipherstash/date@1`, - * `cipherstash/boolean@1`, `cipherstash/json@1`) wires the same - * encode/decode body: - * - * - `encode(envelope, ctx)` extracts `handle.ciphertext` and renders - * it as the `eql_v2_encrypted` Postgres composite literal. - * - `decode(wire, ctx)` parses the wire (composite literal or - * pre-parsed `{ data: ... }` row), constructs a fresh envelope via - * the codec's per-type `fromInternal` factory, and stamps the - * `(table, column)` routing context from `ctx.column`. - * - * Only two values vary per codec: - * - * - `codecId` — the `cipherstash/@1` discriminator. - * - `fromInternal` — the per-type envelope factory - * (`EncryptedString.fromInternal`, `EncryptedDouble.fromInternal`, - * etc.). - * - * The factory parallels {@link makeCipherstashCodecHooks} on the - * migration plane (see `../migration/codec-hooks-factory.ts`) — same - * pattern, opposite plane: control plane = lifecycle hooks, runtime - * plane = encode/decode bodies. - */ - -import type { JsonValue } from '@prisma-next/contract/types' -import { - type AnyCodecDescriptor, - CodecImpl, - 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 { - CIPHERSTASH_CODEC_TRAITS, - EQL_V2_ENCRYPTED_TYPE, -} from '../extension-metadata/constants' -import type { EncryptedEnvelopeBase } from './envelope-base' -import { isBulkEncryptMiddlewareRegistered } from './middleware-registry' -import type { CipherstashSdk } from './sdk' - -const CIPHERSTASH_TARGET_TYPES = [EQL_V2_ENCRYPTED_TYPE] as const - -/** - * Encode the SDK ciphertext payload as a Postgres composite literal - * `("...escaped JSON...")`. Embedded `"` are doubled per the composite - * text-format escape rules. Identical across every cipherstash codec — - * the wire format is determined by `eql_v2_encrypted`'s definition - * (`CREATE TYPE eql_v2_encrypted AS (data jsonb)`), not by the codec's - * plaintext type. - */ -export function encodeEqlV2EncryptedWire(payload: unknown): string { - const json = JSON.stringify(payload) - if (json === undefined) { - throw new Error( - 'cipherstash codec: ciphertext payload is not JSON-serializable. ' + - 'The CipherStash SDK must return a JSON-encodable bulk-encrypt result.', - ) - } - const escaped = json.replaceAll('"', '""') - return `("${escaped}")` -} - -/** - * Inverse of {@link encodeEqlV2EncryptedWire}. Postgres returns - * `eql_v2_encrypted` cells in composite text format; some pg clients - * pre-parse composite cells into `{ data: ... }` row objects. Both - * shapes — and `null`/`undefined` passthrough — are accepted. - */ -function decodeEqlV2EncryptedWire(wire: unknown): unknown { - if (wire === null || wire === undefined) return wire - if (typeof wire === 'object') { - if ('data' in wire) { - return (wire as { data: unknown }).data - } - return wire - } - if (typeof wire !== 'string') { - throw new Error( - `cipherstash codec: unexpected wire shape for eql_v2_encrypted: ${typeof wire}`, - ) - } - const trimmed = wire.trim() - if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) { - throw new Error( - `cipherstash codec: expected composite literal "(...)" but got: ${trimmed.slice(0, 40)}`, - ) - } - const inner = trimmed.slice(1, -1) - const unquoted = - inner.startsWith('"') && inner.endsWith('"') - ? inner.slice(1, -1).replaceAll('""', '"') - : inner - return JSON.parse(unquoted) -} - -export interface CipherstashCellCodecOptions< - E extends EncryptedEnvelopeBase, -> { - readonly codecId: string - readonly typeName: string - readonly fromInternal: (args: { - readonly ciphertext: unknown - readonly table: string - readonly column: string - readonly sdk: CipherstashSdk - }) => E -} - -export class CipherstashCellCodec< - E extends EncryptedEnvelopeBase, -> extends CodecImpl { - readonly sdk: CipherstashSdk | undefined - readonly #fromInternal: CipherstashCellCodecOptions['fromInternal'] - readonly #typeName: string - // One-shot cache so the per-encode WeakSet lookup only runs until the - // first time we observe a registered middleware on this codec's SDK. - // WeakSet entries are append-only (the registry never un-registers an - // SDK), so flipping this to true is safe for the rest of the codec's - // lifetime. - #middlewareCheckPassed = false - - constructor( - descriptor: AnyCodecDescriptor, - sdk: CipherstashSdk | undefined, - options: CipherstashCellCodecOptions, - ) { - super(descriptor) - this.sdk = sdk - this.#fromInternal = options.fromInternal - this.#typeName = options.typeName - } - - async encode(value: E, _ctx: SqlCodecCallContext): Promise { - // Two-pass write path. As of `@prisma-next/sql-runtime@0.8`, the - // `beforeExecute` middleware chain fires *before* `encodeParams`: - // `bulkEncryptMiddleware` runs, calls `params.replaceValues(...)` to - // swap each envelope for its wire-format composite-literal string, - // and the encoder then sees that string directly. Pass it through. - // (Prior to 0.8 the order was inverted: encode ran first, returned - // the envelope as a sentinel, then the middleware replaced the param - // before the driver read it. The branches below preserve that older - // contract for non-runtime callers — e.g. the codec unit tests that - // call `encode` directly with an envelope.) - if (typeof value === 'string') { - return value - } - const handle = value.expose() - if (handle.ciphertext === undefined) { - // Misconfig diagnostic: when an SDK-bound codec sees a pre-encrypt - // envelope but no `bulkEncryptMiddleware(sdk)` has been - // constructed against that same SDK, the two-pass flow can never - // complete. Throw at the codec boundary with a copy-pasteable - // wiring snippet rather than letting the envelope reach the pg - // driver and produce an opaque serialise error. - if (!this.#middlewareCheckPassed && this.sdk !== undefined) { - if (!isBulkEncryptMiddlewareRegistered(this.sdk)) { - throw runtimeError( - 'RUNTIME.ENCODE_FAILED', - `cipherstash ${this.descriptor.codecId}: encrypted column value has not been encrypted, ` + - 'and no `bulkEncryptMiddleware(sdk)` has been registered with this SDK. ' + - 'Wire it up alongside the extension descriptor:\n\n' + - ' postgres({\n' + - ' contractJson,\n' + - ' extensions: [createCipherstashRuntimeDescriptor({ sdk })],\n' + - ' middleware: [bulkEncryptMiddleware(sdk)],\n' + - ' });\n\n' + - 'Both must close over the SAME `sdk` reference. See the @cipherstash/prisma-next README for the full wiring example.', - { - codecId: this.descriptor.codecId, - reason: 'cipherstash-bulk-encrypt-middleware-not-registered', - envelopeRouting: { table: handle.table, column: handle.column }, - }, - ) - } - this.#middlewareCheckPassed = true - } - return value - } - return encodeEqlV2EncryptedWire(handle.ciphertext) - } - - async decode(wire: unknown, ctx: SqlCodecCallContext): Promise { - if (this.sdk === undefined) { - throw runtimeError( - 'RUNTIME.DECODE_FAILED', - `cipherstash ${this.descriptor.codecId}: decode invoked on a metadata-only codec instance that has no SDK attached. ` + - 'Build a runtime codec via the parameterized descriptors returned by `createParameterizedCodecDescriptors(sdk)`, ' + - `or construct the codec directly through the matching \`create*Codec(sdk)\` factory (e.g. \`create${this.#typeName}Codec\`) ` + - 'exported from `@cipherstash/prisma-next/runtime`.', - { - codecId: this.descriptor.codecId, - reason: 'cipherstash-sdk-required', - }, - ) - } - const column = ctx.column - if (!column) { - throw runtimeError( - 'RUNTIME.DECODE_FAILED', - `cipherstash ${this.descriptor.codecId}: decode requires the column routing context that the SQL runtime populates ` + - 'for projected columns. The cell being decoded came from an aggregate, computed expression, or other unrouted source. ' + - 'cipherstash codecs need a stable `(table, column)` routing key for envelope construction and bulk-decrypt grouping; ' + - 'project the underlying encrypted column directly instead of through an aggregate.', - { - codecId: this.descriptor.codecId, - reason: 'cipherstash-decode-column-context-missing', - }, - ) - } - return this.#fromInternal({ - ciphertext: decodeEqlV2EncryptedWire(wire), - table: column.table, - column: column.name, - sdk: this.sdk, - }) - } - - encodeJson(_value: E): JsonValue { - const marker = `$${this.#typeName.charAt(0).toLowerCase()}${this.#typeName.slice(1)}` - return { [marker]: '' } as JsonValue - } - - decodeJson(_json: JsonValue): E { - throw new Error( - 'cipherstash codec: decodeJson is not supported; envelopes do not round-trip through JSON.', - ) - } -} - -/** - * Construct an auxiliary descriptor for a cipherstash cell codec. - * - * The framework's `CodecImpl` base class requires a `descriptor` field - * on every codec instance; readers like `codec.id` proxy through - * `descriptor.codecId`. The production lookup path, however, resolves - * cipherstash codecs through the **parameterized** descriptors built - * in `parameterized.ts` — its `factory(params)(ctx)` returns the codec - * instance directly, never going via `codec.descriptor.factory`. - * - * This descriptor therefore needs only to carry **truthful metadata** - * (`codecId`, `traits`, `targetTypes`, `meta`, `renderOutputType`) so - * that any caller reading those fields off the codec sees the right - * values. Its `factory` field is intentionally a throwing stub: if - * anything ever does invoke it, that is a programming error (the call - * site should be going through the parameterized descriptor) and a - * loud failure is preferred to a silent fallback. - * - * The auxiliary cannot be replaced by passing the parameterized - * descriptor through to the codec constructor because the - * parameterized descriptor's `factory` resolves to the codec instance - * itself — constructing the descriptor before the codec, and the - * codec before the descriptor, are mutually circular. - */ -function makeAuxiliaryDescriptor>( - options: CipherstashCellCodecOptions, -): AnyCodecDescriptor { - return { - codecId: options.codecId, - traits: CIPHERSTASH_CODEC_TRAITS[options.codecId] ?? [], - targetTypes: CIPHERSTASH_TARGET_TYPES, - meta: { - db: { sql: { postgres: { nativeType: EQL_V2_ENCRYPTED_TYPE } } }, - }, - paramsSchema: { - '~standard': { - version: 1, - vendor: 'cipherstash', - validate: (value: unknown) => ({ value }), - }, - }, - isParameterized: false, - renderOutputType: () => options.typeName, - factory: () => () => { - throw new Error( - 'cipherstash codec: auxiliary descriptor factory was invoked. ' + - 'This is a programming error — cipherstash codecs are resolved through the ' + - 'parameterized descriptors built in `parameterized.ts`, not through ' + - '`codec.descriptor.factory`. Use `createParameterizedCodecDescriptors(sdk)` ' + - 'to get the production runtime descriptors.', - ) - }, - } -} - -/** - * Construct the runtime codec for a cipherstash cell codec given its - * codec id, the user-facing type name, and the per-type envelope - * `fromInternal` factory. - */ -export function makeCipherstashCellCodec< - E extends EncryptedEnvelopeBase, ->( - sdk: CipherstashSdk, - options: CipherstashCellCodecOptions, -): CipherstashCellCodec & Codec { - return new CipherstashCellCodec( - makeAuxiliaryDescriptor(options), - sdk, - options, - ) -} diff --git a/packages/prisma-next/src/execution/codec-runtime.ts b/packages/prisma-next/src/execution/codec-runtime.ts deleted file mode 100644 index 206aa2286..000000000 --- a/packages/prisma-next/src/execution/codec-runtime.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Cipherstash storage codec runtimes — wrap each `Encrypted*` envelope - * at the SQL codec boundary. - * - * Every cipherstash codec has identical encode/decode bodies (the - * `eql_v2_encrypted` composite-literal wire format is determined by - * the EQL type definition, not by the plaintext type). The shared body - * lives in `./cell-codec-factory.ts`; the per-codec wrappers below - * supply only the per-type discriminators (codec id, user-facing type - * name, envelope `fromInternal` factory) and re-export the codec class - * for backwards compatibility with consumers that imported it directly - * from this module. - * - * Mirrors the `makeCipherstashCodecHooks` pattern on the migration - * plane (see `../migration/codec-hooks-factory.ts`) — same shape, - * opposite plane. - * - * Equality search on cipherstash columns intentionally goes through the - * cipherstash-namespaced operator (`cipherstashEq`); the framework's - * trait-gated built-in `eq` would lower to standard SQL `=` which is - * wrong for EQL ciphers (randomized nonces). Each codec therefore - * declares no traits — see `./cell-codec-factory.ts`. - */ - -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, -} from '../extension-metadata/constants' -import { - CipherstashCellCodec, - makeCipherstashCellCodec, -} from './cell-codec-factory' -import { EncryptedBigInt } from './envelope-bigint' -import { EncryptedBoolean } from './envelope-boolean' -import { EncryptedDate } from './envelope-date' -import { EncryptedDouble } from './envelope-double' -import { EncryptedJson } from './envelope-json' -import { EncryptedString } from './envelope-string' -import type { CipherstashSdk } from './sdk' - -export { CIPHERSTASH_STRING_CODEC_ID } - -/** @deprecated Re-exported for source compatibility; new call sites should use `CipherstashCellCodec`. */ -export type CipherstashStringCodec = CipherstashCellCodec - -export function createCipherstashStringCodec( - sdk: CipherstashSdk, -): CipherstashCellCodec { - return makeCipherstashCellCodec(sdk, { - codecId: CIPHERSTASH_STRING_CODEC_ID, - typeName: 'EncryptedString', - fromInternal: EncryptedString.fromInternal, - }) -} - -export function createCipherstashDoubleCodec( - sdk: CipherstashSdk, -): CipherstashCellCodec { - return makeCipherstashCellCodec(sdk, { - codecId: CIPHERSTASH_DOUBLE_CODEC_ID, - typeName: 'EncryptedDouble', - fromInternal: EncryptedDouble.fromInternal, - }) -} - -export function createCipherstashBigIntCodec( - sdk: CipherstashSdk, -): CipherstashCellCodec { - return makeCipherstashCellCodec(sdk, { - codecId: CIPHERSTASH_BIGINT_CODEC_ID, - typeName: 'EncryptedBigInt', - fromInternal: EncryptedBigInt.fromInternal, - }) -} - -export function createCipherstashDateCodec( - sdk: CipherstashSdk, -): CipherstashCellCodec { - return makeCipherstashCellCodec(sdk, { - codecId: CIPHERSTASH_DATE_CODEC_ID, - typeName: 'EncryptedDate', - fromInternal: EncryptedDate.fromInternal, - }) -} - -export function createCipherstashBooleanCodec( - sdk: CipherstashSdk, -): CipherstashCellCodec { - return makeCipherstashCellCodec(sdk, { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - typeName: 'EncryptedBoolean', - fromInternal: EncryptedBoolean.fromInternal, - }) -} - -export function createCipherstashJsonCodec( - sdk: CipherstashSdk, -): CipherstashCellCodec { - return makeCipherstashCellCodec(sdk, { - codecId: CIPHERSTASH_JSON_CODEC_ID, - typeName: 'EncryptedJson', - fromInternal: EncryptedJson.fromInternal, - }) -} - -export { CipherstashCellCodec } diff --git a/packages/prisma-next/src/execution/envelope-base.ts b/packages/prisma-next/src/execution/envelope-base.ts index 5ff506a7f..0f2eb5466 100644 --- a/packages/prisma-next/src/execution/envelope-base.ts +++ b/packages/prisma-next/src/execution/envelope-base.ts @@ -2,7 +2,7 @@ * Shared abstract base for every cipherstash envelope class. * * Each concrete encrypted-column type (`EncryptedString`, - * `EncryptedDouble`, `EncryptedBigInt`, `EncryptedDate`, + * `EncryptedNumber`, `EncryptedBigInt`, `EncryptedDate`, * `EncryptedBoolean`, `EncryptedJson`) wraps a handle of the same shape * — only the plaintext slot's `T` differs — and shares verbatim: * @@ -70,7 +70,7 @@ const REDACTED = '[REDACTED]' * Placeholder shape returned by `JSON.stringify(envelope)` for every * concrete envelope. The marker key is derived from the subclass's * `typeName` (e.g. `EncryptedString` → `$encryptedString`, - * `EncryptedDouble` → `$encryptedDouble`) so each codec carries a + * `EncryptedNumber` → `$encryptedNumber`) so each codec carries a * distinct, machine-recognisable signature in serialised payloads. * * The four other coercion paths (`toString` / `valueOf` / diff --git a/packages/prisma-next/src/execution/envelope-double.ts b/packages/prisma-next/src/execution/envelope-double.ts deleted file mode 100644 index b03880a0c..000000000 --- a/packages/prisma-next/src/execution/envelope-double.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * `EncryptedDouble` envelope — the user-facing input/output type for - * `cipherstash/double@1` columns. Concrete subclass of - * {@link EncryptedEnvelopeBase} parameterised on `number` (IEEE-754 - * double; EQL `cast_as = 'double'`). Mirrors `EncryptedString` - * byte-for-byte beyond the typed factories and `typeName`. - * - * No `parseDecryptedValue` override is needed: the SDK's polymorphic - * `bulkDecrypt` / single-cell `decrypt` already returns numeric - * plaintexts as `number`; the base's default identity cast suffices. - */ - -import { - EncryptedEnvelopeBase, - type EncryptedEnvelopeFromInternalArgs, - type EncryptedEnvelopeHandle, -} from './envelope-base' - -export type EncryptedDoubleHandle = EncryptedEnvelopeHandle - -export type EncryptedDoubleFromInternalArgs = EncryptedEnvelopeFromInternalArgs - -export class EncryptedDouble extends EncryptedEnvelopeBase { - protected override get typeName(): string { - return 'EncryptedDouble' - } - - /** - * Construct a write-side envelope from a plaintext IEEE-754 number. - * Bulk-encrypt middleware populates the handle's ciphertext slot - * before the codec encodes the envelope to wire format. - */ - static from(plaintext: number): EncryptedDouble { - return new EncryptedDouble({ - plaintext, - ciphertext: undefined, - table: undefined, - column: undefined, - sdk: undefined, - }) - } - - /** - * Construct a read-side envelope from a wire ciphertext + the column - * identity + the SDK used to decrypt the cell. Called from the codec - * `decode` body. - */ - static fromInternal(args: EncryptedDoubleFromInternalArgs): EncryptedDouble { - return new EncryptedDouble({ - plaintext: undefined, - ciphertext: args.ciphertext, - table: args.table, - column: args.column, - sdk: args.sdk, - }) - } -} diff --git a/packages/prisma-next/src/execution/helpers.ts b/packages/prisma-next/src/execution/helpers.ts deleted file mode 100644 index f8c2b0a29..000000000 --- a/packages/prisma-next/src/execution/helpers.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Cipherstash free-standing helpers — the non-predicate side of the - * cipherstash operator surface (see ADR 214). - * - * Predicates (`cipherstashEq`, `cipherstashGt`, …) live in the - * operator registry and surface as column methods through trait- - * dispatched `QueryOperationTypes`. Non-predicates (sort, JSON - * SELECT-expression accessors) cannot share that surface — they - * return `OrderByItem` / column-codec-typed `Expression`, not the - * boolean predicate the registry's where-binding pipeline expects. - * - * The four helpers below are pure functions exported from - * `@prisma-next/extension-cipherstash/runtime`. Each: - * - * - validates the column's codec id is a cipherstash codec the - * helper supports (sort: any of the four - * `cipherstash:order-and-range`-bearing codecs; - * JSON helpers: `cipherstash/json@1` only) - * - constructs the appropriate AST primitive directly: - * sort → `OrderByItem.asc/desc()` - * JSON → `Expression`-shaped `OperationExpr` with the EQL - * function template baked into `lowering.template` - * - throws a descriptive `TypeError` naming the helper and the - * accepted codec ids on a mismatch - * - * # Sort lowering — bare column reference - * - * `cipherstashAsc(col)` lowers to `ORDER BY ASC` with no EQL - * function wrapping. EQL ships native `<` / `>` / `<=` / `>=` operator - * overloads on `eql_v2_encrypted` (see `eql_v2."<"(eql_v2_encrypted, - * eql_v2_encrypted)` and the `CREATE OPERATOR <(LEFTARG=eql_v2_encrypted, - * RIGHTARG=eql_v2_encrypted, …)` definition in the bundled EQL - * install) so Postgres uses the EQL operator family for the sort - * comparison. The wrapped form (`eql_v2.order_by_(col)`) is - * the documented fallback if the bare-column form ever stops working - * against a future EQL bundle. - * - * # JSON helpers — Expression-typed OperationExpr - * - * `cipherstashJsonbPathQueryFirst(col, path)` lowers to - * `eql_v2.jsonb_path_query_first({{self}}, {{arg0}})` - * `cipherstashJsonbGet(col, path)` lowers to - * `eql_v2."->"({{self}}, {{arg0}})` - * - * Both return `eql_v2_encrypted` and so are typed - * `Expression<{codecId: 'cipherstash/json@1', nullable: false}>` — - * the result is itself a JSON-encrypted value usable as the column - * argument to a follow-on JSON helper or predicate. The path is a - * user-authored static literal (a JSONpath expression or a JSON key - * string) and is bound as a `pg/text@1` `ParamRef`. Dynamic - * user-controlled runtime path values are not supported here — paths - * must be statically authored to keep the JSONpath surface free of - * injection-shaped input. - * - * # No registry participation - * - * These are not registered operators. They're called by the user - * directly (e.g. `db.query(...).orderBy([cipherstashAsc(col)])`) and - * are typed at their function-declaration site. There is no - * `QueryOperationTypes` entry and no operator-registry - * descriptor — the helpers do not flow through the column-method - * dispatch that the predicate operators rely on. - */ - -import { - type AnyExpression, - OrderByItem, - ParamRef, -} from '@prisma-next/sql-relational-core/ast' -import { - buildOperation, - type Expression, - type ScopeField, -} from '@prisma-next/sql-relational-core/expression' -import { - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, - CIPHERSTASH_STRING_CODEC_ID, -} from '../extension-metadata/constants' - -/** Cipherstash codec ids that carry the `cipherstash:order-and-range` trait. */ -const ORDER_AND_RANGE_CODEC_IDS = [ - CIPHERSTASH_STRING_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, -] as const - -const ORDER_AND_RANGE_SET: ReadonlySet = new Set( - ORDER_AND_RANGE_CODEC_IDS, -) - -type CipherstashJsonReturn = { - readonly codecId: typeof CIPHERSTASH_JSON_CODEC_ID - readonly nullable: false -} - -function getCodecId(col: Expression, helperName: string): string { - const codecId = col.returnType?.codecId - if (typeof codecId !== 'string') { - throw new TypeError( - `${helperName}: argument is missing a codec id; expected an Expression bound to a cipherstash column.`, - ) - } - return codecId -} - -function describeOrderAndRangeCodecs(): string { - return ORDER_AND_RANGE_CODEC_IDS.join(', ') -} - -/** - * ASC sort over a cipherstash column whose codec carries the - * `cipherstash:order-and-range` trait (string / double / bigint / - * date). Returns an `OrderByItem` carrying the column reference; - * Postgres uses EQL's `<` / `>` operator overloads on - * `eql_v2_encrypted` to compute the sort. - */ -export function cipherstashAsc(col: Expression): OrderByItem { - const codecId = getCodecId(col, 'cipherstashAsc') - if (!ORDER_AND_RANGE_SET.has(codecId)) { - throw new TypeError( - `cipherstashAsc: column codec id "${codecId}" does not support order-and-range sort; ` + - `cipherstashAsc accepts cipherstash columns whose codec id is one of: ${describeOrderAndRangeCodecs()}.`, - ) - } - return OrderByItem.asc(col.buildAst()) -} - -/** - * DESC sort over a cipherstash column whose codec carries the - * `cipherstash:order-and-range` trait. See {@link cipherstashAsc} - * for the lowering rationale. - */ -export function cipherstashDesc(col: Expression): OrderByItem { - const codecId = getCodecId(col, 'cipherstashDesc') - if (!ORDER_AND_RANGE_SET.has(codecId)) { - throw new TypeError( - `cipherstashDesc: column codec id "${codecId}" does not support order-and-range sort; ` + - `cipherstashDesc accepts cipherstash columns whose codec id is one of: ${describeOrderAndRangeCodecs()}.`, - ) - } - return OrderByItem.desc(col.buildAst()) -} - -function requireJsonColumn( - col: Expression, - helperName: string, -): AnyExpression { - const codecId = getCodecId(col, helperName) - if (codecId !== CIPHERSTASH_JSON_CODEC_ID) { - throw new TypeError( - `${helperName}: column codec id "${codecId}" is not "${CIPHERSTASH_JSON_CODEC_ID}"; ` + - `${helperName} only accepts cipherstash JSON columns.`, - ) - } - return col.buildAst() -} - -function requirePathString(path: unknown, helperName: string): string { - if (typeof path !== 'string') { - throw new TypeError( - `${helperName}: expected a string path argument, got ${ - path === null ? 'null' : typeof path - }.`, - ) - } - return path -} - -/** - * Lower to `eql_v2.jsonb_path_query_first({{self}}, {{arg0}})`. The - * column must be `cipherstash/json@1`. The path is a user-authored - * static JSONpath literal; it is bound as a `pg/text@1` `ParamRef`. - * - * The result is `eql_v2_encrypted` and can be passed as the column - * argument to a follow-on cipherstash JSON helper or - * `cipherstashJsonbPathExists` predicate (a column codec is not - * required at the type level for those — the runtime branch checks - * the trait/codec at impl time). - */ -export function cipherstashJsonbPathQueryFirst( - col: Expression, - path: string, -): Expression { - const selfAst = requireJsonColumn(col, 'cipherstashJsonbPathQueryFirst') - const checked = requirePathString(path, 'cipherstashJsonbPathQueryFirst') - return buildOperation({ - method: 'cipherstashJsonbPathQueryFirst', - args: [selfAst, ParamRef.of(checked, { codec: { codecId: 'pg/text@1' } })], - returns: { codecId: CIPHERSTASH_JSON_CODEC_ID, nullable: false }, - lowering: { - targetFamily: 'sql', - strategy: 'function', - template: 'eql_v2.jsonb_path_query_first({{self}}, {{arg0}})', - }, - }) -} - -/** - * Lower to `eql_v2."->"({{self}}, {{arg0}})`. The column must be - * `cipherstash/json@1`. The path is a JSON key string (the right - * argument of the `->` operator); it is bound as a `pg/text@1` - * `ParamRef` against EQL's `(eql_v2_encrypted, text)` overload. - * - * The result is `eql_v2_encrypted`, mirroring - * {@link cipherstashJsonbPathQueryFirst}. - * - * The exported function name preserves the `Get` suffix convention - * (vs the SQL `->` operator) so the JS surface stays identifier- - * friendly; the lowering still emits the quoted operator-as-function - * form. - */ -export function cipherstashJsonbGet( - col: Expression, - path: string, -): Expression { - const selfAst = requireJsonColumn(col, 'cipherstashJsonbGet') - const checked = requirePathString(path, 'cipherstashJsonbGet') - return buildOperation({ - method: 'cipherstashJsonbGet', - args: [selfAst, ParamRef.of(checked, { codec: { codecId: 'pg/text@1' } })], - returns: { codecId: CIPHERSTASH_JSON_CODEC_ID, nullable: false }, - lowering: { - targetFamily: 'sql', - strategy: 'function', - template: 'eql_v2."->"({{self}}, {{arg0}})', - }, - }) -} diff --git a/packages/prisma-next/src/execution/middleware-registry.ts b/packages/prisma-next/src/execution/middleware-registry.ts index 9208a0c4e..0b87a399c 100644 --- a/packages/prisma-next/src/execution/middleware-registry.ts +++ b/packages/prisma-next/src/execution/middleware-registry.ts @@ -1,6 +1,6 @@ /** * Per-process registry of `CipherstashSdk` instances that have been - * wired up via `bulkEncryptMiddleware(sdk)`. The codec's `encode` + * wired up via `bulkEncryptMiddlewareV3(sdk)`. The codec's `encode` * consults this registry to fire a misconfig diagnostic when an * SDK-bound codec sees a pre-encrypt envelope without a corresponding * middleware registration — the failure mode is otherwise an opaque diff --git a/packages/prisma-next/src/execution/operators.ts b/packages/prisma-next/src/execution/operators.ts deleted file mode 100644 index 9932a6445..000000000 --- a/packages/prisma-next/src/execution/operators.ts +++ /dev/null @@ -1,639 +0,0 @@ -/** - * Cipherstash query-operations registry. - * - * `cipherstashEq` and `cipherstashIlike` lower to EQL's encrypted-aware - * comparison functions (`eql_v2.eq`, `eql_v2.ilike`) on - * `cipherstash/string@1`-typed columns: - * - * eql_v2.eq(, ) - * eql_v2.ilike(, ) - * - * Why we diverge from Postgres' native `=` / `ILIKE` operators: EQL - * ciphers contain randomized nonces, so two encrypts of the same - * plaintext do not byte-equal under SQL `=`. EQL's `eql_v2.eq` / - * `eql_v2.ilike` short-circuit through the per-column index - * (`unique` / `match`) emitted by the codec lifecycle hook and produce - * correct results. - * - * **Why cipherstash-namespaced method names (`cipherstashEq`, - * `cipherstashIlike`) rather than reusing the framework`s `eq` / - * `ilike`.** The framework`s `OperationRegistry` is a flat method-keyed - * map and operator overriding is disallowed by project decision. Equally - * importantly, cipherstash`s search operators are semantically distinct - * from the framework built-ins — they take encrypted-aware envelope - * arguments and lower to `eql_v2.eq` / `eql_v2.ilike`, which short- - * circuit through EQL`s per-column index — so they belong under a - * cipherstash-prefixed surface that flags the divergence at the call - * site. The supported user-facing call shape on a cipherstash column is: - * - * model.users.where((u) => u.email.cipherstashEq('alice@example.com')) - * model.users.where((u) => u.email.cipherstashIlike('%alice%')) - * - * The framework`s built-in `email.eq(...)` is **not reachable** on - * cipherstash columns: the cipherstash codec declares no `equality` - * trait (see `codec-runtime.ts` / `codec-metadata.ts` / `parameterized.ts`), - * and the model-accessor synthesis in `sql-orm-client` gates - * `COMPARISON_METHODS_META.eq` on the `equality` trait being present in - * the column codec`s trait set. Calling `email.eq(...)` on a cipherstash - * column is therefore `undefined` — the wrong-SQL footgun (where the - * built-in `eq` would lower to standard SQL `=` against an - * `eql_v2_encrypted` value, silently returning zero rows because EQL - * ciphers contain randomized nonces) is closed at the codec layer, not - * the operator layer. The trait declaration is regression-pinned by - * `test/equality-trait-removal.test.ts`. - * - * The encrypted-arg path: the operator wraps the user-supplied value - * in an `EncryptedString` envelope and stamps the column`s - * `(table, column)` routing context onto the envelope`s handle. The - * bulk-encrypt middleware then groups the envelope alongside - * any others targeting the same `(table, column)` and issues one - * `sdk.bulkEncrypt` per group. The cipherstash codec encodes the - * resulting ciphertext as the wire payload at - * `eql_v2_encrypted` cast time. Stamping at lowering time is the - * load-bearing step — the middleware`s AST walk only handles - * `InsertAst` / `UpdateAst` (see - * `src/middleware/bulk-encrypt.ts:stampRoutingKeysFromAst`); SELECT - * envelopes have to arrive at the middleware already routing-keyed. - * - * Build-time return type is the postgres `pg/bool@1` codec — that`s - * the codec the framework`s predicate machinery looks at via the - * `'boolean'` trait to decide that the operator`s return value is a - * predicate suitable for a WHERE clause (see - * `packages/3-extensions/sql-orm-client/src/model-accessor.ts:172-178`). - * - * **`isNull` / `isNotNull` are NOT registered here.** The framework`s - * always-on `isNull` / `isNotNull` comparison methods construct - * `NullCheckExpr` directly, bypassing - * the operator-registry dispatch, and lower to ` IS [NOT] NULL` - * regardless of codec — pinned by `test/operator-lowering.test.ts`. - */ - -import type { CodecTrait } from '@prisma-next/framework-components/codec' -import type { - SqlOperationDescriptor, - SqlOperationDescriptors, -} from '@prisma-next/sql-operations' -import type { CodecRef } from '@prisma-next/sql-relational-core/ast' -import { - type AnyExpression, - type ColumnRef, - 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_BIGINT_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, - CIPHERSTASH_STRING_CODEC_ID, - CIPHERSTASH_TRAIT_EQUALITY, - CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH, - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - CIPHERSTASH_TRAIT_SEARCHABLE_JSON, - type CipherstashCodecId, - isCipherstashCodecId, -} from '../extension-metadata/constants' -import type { EncryptedEnvelopeBase } from './envelope-base' -import { EncryptedBigInt } from './envelope-bigint' -import { EncryptedBoolean } from './envelope-boolean' -import { EncryptedDate } from './envelope-date' -import { EncryptedDouble } from './envelope-double' -import { EncryptedJson } from './envelope-json' -import { EncryptedString, setHandleRoutingKey } from './envelope-string' - -/** - * Codec ID of the framework's Postgres boolean codec. Referenced as a - * string (rather than imported from `@prisma-next/target-postgres`) - * so cipherstash does not pick up a peer-dep on the target package - * just to identify a return-codec id. Mirrors the same pattern in the - * reference cipherstash integration's `operation-templates.ts:RETURN_BOOL`. - */ -const PG_BOOL_CODEC_ID = 'pg/bool@1' as const - -type PgBoolReturn = { - readonly codecId: typeof PG_BOOL_CODEC_ID - readonly nullable: false -} - -/** - * Convert a user-supplied value (raw plaintext or an existing - * `Encrypted*` envelope) into a `ParamRef` carrying an envelope - * tagged with the column's cipherstash storage codec ref. The - * envelope's handle is stamped with the column's `(table, column)` - * routing context so the bulk-encrypt middleware can group it for - * SELECT-side bulk encryption (the middleware's AST walk only stamps - * for INSERT / UPDATE). - * - * Already-stamped envelopes are preserved write-once-wins per - * `setHandleRoutingKey`'s contract. - * - * The `selfCodec` argument is the full {@link CodecRef} (codecId + - * typeParams) derived from the `self` expression via {@link codecOf}. - * Forwarding the complete ref — not just the codecId — keeps the - * resulting `ParamRef` aligned with the AST-bound codec resolution - * model introduced in TML-2456: `forCodecRef` validates `typeParams` - * against the codec's `paramsSchema`, and parameterized cipherstash - * codecs (`cipherstash/string@1`, `cipherstash/double@1`, ...) - * require their search-index `typeParams` (`equality`, - * `freeTextSearch`, `orderAndRange`) to be present. - */ -function asEncryptedParam( - selfAst: AnyExpression, - selfCodec: CodecRef, - value: unknown, -): ParamRef { - const envelope = coerceToEnvelope(selfCodec.codecId, value) - const columnRef = extractColumnRef(selfAst) - if (columnRef !== undefined) { - setHandleRoutingKey(envelope, columnRef.table, columnRef.column) - } - return ParamRef.of(envelope, { codec: selfCodec }) -} - -/** - * Read the column-bound {@link CodecRef} off the `self` expression. - * Cipherstash predicate operators are reachable only via the ORM's - * model-accessor path, which stamps the column's full CodecRef onto - * the field-proxy's `codec` slot at synthesis time. If the ref is - * missing the operator was reached without a column binding (likely - * a programming error in a custom builder); throw with a stable - * runtime envelope so the failure mode is loud. - */ -function requireSelfCodec( - self: Expression, - publicMethod: string, -): CodecRef { - const codec = codecOf(self) - if (codec === undefined) { - throw new TypeError( - `cipherstash ${publicMethod}: self expression is missing a CodecRef. ` + - 'Cipherstash predicate operators require a column-bound self argument; ' + - 'reach the operator through the ORM model-accessor (e.g. `model.users.where((u) => u.email.cipherstashEq(...))`).', - ) - } - return codec -} - -/** - * Coerce a user-supplied value into the envelope subclass appropriate - * for the column's codec id. Each cipherstash column type has its own - * concrete envelope subclass with a typed `from(plaintext)` factory; - * this dispatcher matches the column codec id to the right subclass - * and wraps the user value, while passing already-constructed - * envelopes through unchanged. The error message lists the expected - * plaintext type per codec so a user passing the wrong shape gets a - * specific diagnostic at the call site. - * - * Dispatch is via a `Record` map so adding - * a new cipherstash codec id (which extends the closed - * {@link CipherstashCodecId} union) becomes a compile-time error - * here until the new branch is wired — closing off the runtime-only - * failure mode the previous if-chain shape tolerated. - */ -type EnvelopeCoercer = (value: unknown) => EncryptedEnvelopeBase - -const ENVELOPE_COERCERS: Readonly> = - { - [CIPHERSTASH_STRING_CODEC_ID]: (value) => { - if (value instanceof EncryptedString) return value - if (typeof value === 'string') return EncryptedString.from(value) - throw envelopeTypeError('EncryptedString', 'string', value) - }, - [CIPHERSTASH_DOUBLE_CODEC_ID]: (value) => { - if (value instanceof EncryptedDouble) return value - if (typeof value === 'number') return EncryptedDouble.from(value) - throw envelopeTypeError('EncryptedDouble', 'number', value) - }, - [CIPHERSTASH_BIGINT_CODEC_ID]: (value) => { - if (value instanceof EncryptedBigInt) return value - if (typeof value === 'bigint') return EncryptedBigInt.from(value) - throw envelopeTypeError('EncryptedBigInt', 'bigint', value) - }, - [CIPHERSTASH_DATE_CODEC_ID]: (value) => { - if (value instanceof EncryptedDate) return value - if (value instanceof Date) return EncryptedDate.from(value) - throw envelopeTypeError('EncryptedDate', 'Date', value) - }, - [CIPHERSTASH_BOOLEAN_CODEC_ID]: (value) => { - if (value instanceof EncryptedBoolean) return value - if (typeof value === 'boolean') return EncryptedBoolean.from(value) - throw envelopeTypeError('EncryptedBoolean', 'boolean', value) - }, - [CIPHERSTASH_JSON_CODEC_ID]: (value) => { - if (value instanceof EncryptedJson) return value - return EncryptedJson.from(value) - }, - } - -function coerceToEnvelope( - columnCodecId: string, - value: unknown, -): EncryptedEnvelopeBase { - if (!isCipherstashCodecId(columnCodecId)) { - throw new Error( - `cipherstash operator: column codec id "${columnCodecId}" is not a cipherstash codec; ` + - 'this operator should not be reachable on a non-cipherstash column. ' + - 'If you see this error, the operator-registry trait dispatch is wired against a ' + - 'codec that should not advertise the cipherstash trait. File a bug against the package.', - ) - } - return ENVELOPE_COERCERS[columnCodecId](value) -} - -function envelopeTypeError( - envelopeType: string, - expected: string, - value: unknown, -): TypeError { - const got = - value === null ? 'null' : value instanceof Date ? 'Date' : typeof value - return new TypeError( - `cipherstash operator: expected a ${expected} plaintext or an ${envelopeType} envelope, ` + - `got ${got}. ` + - `Use \`${envelopeType}.from(plaintext)\` to construct an envelope explicitly, or ` + - 'pass the plaintext directly and let the operator wrap it.', - ) -} - -/** - * Find the column reference inside a `self` expression so the operator - * can stamp its `(table, column)` onto the encrypted-param envelope. - * - * Most calls flow through the ORM model-accessor, where `self` is a - * column-field accessor whose `buildAst()` returns a `ColumnRef` - * directly. For more complex `self` expressions (e.g. wrapped in a - * function call) we fall back to the `baseColumnRef()` inherited from - * `Expression` — every standard AST node walks down to the underlying - * column. If no column is reachable (e.g. a literal `self`), routing - * stamping is skipped; the envelope will surface the - * "envelope reached the bulk-encrypt phase without a (table, column) - * routing context" diagnostic from `collectTargets` at execute time. - */ -function extractColumnRef(selfAst: AnyExpression): ColumnRef | undefined { - if (selfAst.kind === 'column-ref') { - return selfAst - } - try { - return selfAst.baseColumnRef() - } catch { - return undefined - } -} - -/** - * Build a single-codec cipherstash operator descriptor — the - * original shape used by `cipherstashEq` / `cipherstashIlike`, - * pinned to `cipherstash/string@1`. Multi-codec operators use - * {@link envelopeOperator} with trait-based dispatch instead. - * - * @param publicMethod - The user-facing method name on the column - * accessor (e.g. `cipherstashEq`). Must not collide with any - * framework- or adapter-shipped method name. - * @param eqlFunction - The EQL function to lower to (`eq`, `ilike`). - * Embedded into the SQL lowering template as `eql_v2.(...)`. - */ -function eqlOperator( - publicMethod: string, - eqlFunction: 'eq' | 'ilike', -): SqlOperationDescriptor { - return { - self: { codecId: CIPHERSTASH_STRING_CODEC_ID }, - impl: ( - self: Expression, - value: unknown, - ): Expression => { - const selfCodec = requireSelfCodec(self, publicMethod) - const selfAst = toExpr(self, selfCodec) - return buildOperation({ - method: publicMethod, - args: [selfAst, asEncryptedParam(selfAst, selfCodec, value)], - returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, - lowering: { - targetFamily: 'sql', - strategy: 'function', - template: `eql_v2.${eqlFunction}({{self}}, {{arg0}})`, - }, - }) - }, - } -} - -/** - * Build a cipherstash predicate operator dispatched via a - * cipherstash-namespaced trait — the multi-codec shape used for the - * trait-namespaced predicate surface (see ADR 214). The operator - * attaches to every codec descriptor whose `traits` list contains - * {@link trait}; the model-accessor's trait dispatch - * (`packages/3-extensions/sql-orm-client/src/model-accessor.ts`) - * handles the per-codec attachment. - * - * Each user-supplied argument is wrapped in the envelope subclass - * that matches the column's codec id at impl time. The lowering - * template uses the standard `{{self}}` and `{{argN}}` placeholders - * that the postgres adapter's `sql-renderer` substitutes per call. - * - * @param publicMethod - User-facing method name on the column - * accessor (e.g. `cipherstashGt`). Must not collide with any - * framework- or adapter-shipped method name. - * @param trait - Cipherstash-namespaced trait that gates the codec - * set the operator attaches to (see `extension-metadata/constants.ts`). - * @param arity - Fixed user-arg count (1 for `gt`/`gte`/`lt`/`lte`/ - * `eq`/`ne`/`ilike`/`notIlike`, 2 for `between`/`notBetween`). - * Excludes the `self` (column-bound) argument. - * @param template - Lowering template, e.g. `eql_v2.gt({{self}}, {{arg0}})` - * or `NOT eql_v2.eq({{self}}, {{arg0}})`. Stored verbatim on the - * `OperationExpr` AST node and substituted by the postgres - * adapter at lower time. - */ -function envelopeOperator( - publicMethod: string, - trait: string, - arity: number, - template: string, -): SqlOperationDescriptor { - return { - // Cipherstash trait identifiers (`cipherstash:equality`, ...) - // intentionally live outside the framework`s closed `CodecTrait` - // union; the runtime dispatcher widens to `readonly string[]` - // before matching, so the namespace round-trips unchanged. See - // `extension-metadata/constants.ts:CIPHERSTASH_CODEC_TRAITS` for - // the full rationale; AGENTS.md requires the rationale comment - // alongside any `as unknown as` cast. - self: { traits: [trait] as unknown as readonly CodecTrait[] }, - impl: ( - self: Expression, - ...userArgs: unknown[] - ): Expression => { - if (userArgs.length !== arity) { - throw new TypeError( - `cipherstash ${publicMethod}: expected ${arity} argument${arity === 1 ? '' : 's'}, got ${userArgs.length}.`, - ) - } - const selfCodec = requireSelfCodec(self, publicMethod) - const selfAst = toExpr(self, selfCodec) - const argRefs = userArgs.map((value) => - asEncryptedParam(selfAst, selfCodec, value), - ) - return buildOperation({ - method: publicMethod, - args: [selfAst, ...argRefs], - returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, - lowering: { - targetFamily: 'sql', - strategy: 'function', - template, - }, - }) - }, - } -} - -/** - * Build a cipherstash variable-arity predicate operator — the shape - * used for `cipherstashInArray` / `cipherstashNotInArray`. Each - * array element is wrapped in its own envelope sharing the - * column's `(table, column)` routing key, and the lowering template - * is built dynamically per call from {@link buildTemplate} based on - * the array length so the framework's `{{argN}}` placeholder - * substitution covers every element. - * - * Empty arrays are rejected with a descriptive error: an OR-of-zero - * fragments lowers to a SQL syntax error and a silent rewrite to - * `FALSE` (or `TRUE` for `notInArray`) would mask the user's likely - * intent. Callers who want "match nothing" should use - * `WHERE FALSE` directly; this operator is for non-empty arrays. - * - * @param publicMethod - User-facing method name (`cipherstashInArray`, - * `cipherstashNotInArray`). - * @param trait - Cipherstash-namespaced trait that gates codec - * visibility (`cipherstash:equality` for both in-array operators). - * @param buildTemplate - Pure function `(n) => template` that - * produces the lowering template for an `n`-element array. For - * `cipherstashInArray`: `(n) => "()"`. For - * `cipherstashNotInArray`: `(n) => "NOT ()"`. - */ -function variableArityEnvelopeOperator( - publicMethod: string, - trait: string, - buildTemplate: (arity: number) => string, -): SqlOperationDescriptor { - return { - // See `envelopeOperator` for the cast rationale. - self: { traits: [trait] as unknown as readonly CodecTrait[] }, - impl: ( - self: Expression, - values: unknown, - ): Expression => { - if (!Array.isArray(values)) { - throw new TypeError( - `cipherstash ${publicMethod}: expected an array argument, got ${ - values === null ? 'null' : typeof values - }.`, - ) - } - if (values.length === 0) { - throw new TypeError( - `cipherstash ${publicMethod}: empty array is not supported. ` + - 'An empty membership check has no well-defined SQL lowering — use ' + - '`WHERE FALSE` directly if you want to match no rows.', - ) - } - const selfCodec = requireSelfCodec(self, publicMethod) - const selfAst = toExpr(self, selfCodec) - const argRefs = values.map((value) => - asEncryptedParam(selfAst, selfCodec, value), - ) - return buildOperation({ - method: publicMethod, - args: [selfAst, ...argRefs], - returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, - lowering: { - targetFamily: 'sql', - strategy: 'function', - template: buildTemplate(values.length), - }, - }) - }, - } -} - -/** - * Build the OR-of-equalities lowering template for an `n`-element - * array: `(eql_v2.eq({{self}}, {{arg0}}) OR eql_v2.eq({{self}}, {{arg1}}) OR ...)`. - * The single-element form collapses to one `eql_v2.eq` call with - * outer parentheses retained for shape stability. - */ -function buildInArrayTemplate(n: number): string { - const terms: string[] = [] - for (let i = 0; i < n; i++) { - terms.push(`eql_v2.eq({{self}}, {{arg${i}}})`) - } - return `(${terms.join(' OR ')})` -} - -function buildNotInArrayTemplate(n: number): string { - return `NOT ${buildInArrayTemplate(n)}` -} - -/** - * Build the cipherstash JSONB-path-exists operator. Unlike the - * envelope-wrapping operators above, the path argument is a plain - * SQL text literal — the JSONpath expression is a user-authored - * static input, not an encrypted value — so this operator passes - * the path through `toExpr` directly without envelope wrapping. The - * column self IS encrypted; only the path argument is plain. - * - * Note: predicate filtering via this operator is gapped against the - * live EQL bundle pending STE-VEC selector hashing — see TML-2504. - * The framework binds the JSONpath as a plain `pg/text@1` `ParamRef` - * but EQL probes the per-column STE-VEC index for a hashed-selector - * key. The lowering template + AST construction below are correct; - * the bundle-side hashing is the missing piece. - */ -function jsonbPathExistsOperator(): SqlOperationDescriptor { - return { - // See `envelopeOperator` for the cast rationale. - self: { - traits: [ - CIPHERSTASH_TRAIT_SEARCHABLE_JSON, - ] as unknown as readonly CodecTrait[], - }, - impl: ( - self: Expression, - path: unknown, - ): Expression => { - if (typeof path !== 'string') { - throw new TypeError( - `cipherstash cipherstashJsonbPathExists: expected a string path argument, got ${ - path === null ? 'null' : typeof path - }.`, - ) - } - const selfAst = toExpr(self) - return buildOperation({ - method: 'cipherstashJsonbPathExists', - args: [selfAst, ParamRef.of(path, { codec: { codecId: 'pg/text@1' } })], - returns: { codecId: PG_BOOL_CODEC_ID, nullable: false }, - lowering: { - targetFamily: 'sql', - strategy: 'function', - template: 'eql_v2.jsonb_path_exists({{self}}, {{arg0}})', - }, - }) - }, - } -} - -/** - * Cipherstash`s query-operations contributions. Wired into the - * runtime descriptor by `createCipherstashRuntimeDescriptor` and read - * by the SQL runtime`s `extractCodecLookup` / `queryOperations` - * aggregation (`packages/2-sql/5-runtime/src/sql-context.ts`). - * - * Two registration shapes are in use: - * - * - **Single-codec** (`cipherstashEq`, `cipherstashIlike`) — - * `self: { codecId: 'cipherstash/string@1' }`. Predates the - * trait-namespaced surface; visibility is fixed to the string - * codec. - * - **Trait-namespaced** (everything else, see ADR 214) — - * `self: { traits: ['cipherstash:'] }`. Visible on every - * codec descriptor whose `traits` list contains the trait - * identifier. The `cipherstash:` prefix isolates these from - * the framework`s closed `CodecTrait` union (`'equality'`, - * `'order'`, ...) so adding them to a cipherstash codec - * descriptor cannot silently re-attach a framework built-in. - * - * Operator -> codec visibility: - * - * - `cipherstashEq` (string only — single-codec, legacy) - * - `cipherstashIlike` (string only — single-codec, legacy) - * - `cipherstashNe` / `cipherstashInArray` / - * `cipherstashNotInArray` (trait `cipherstash:equality` -> - * string, double, bigint, date, boolean) - * - `cipherstashNotIlike` (trait `cipherstash:free-text-search` - * -> string) - * - `cipherstashGt` / `cipherstashGte` / `cipherstashLt` / - * `cipherstashLte` / `cipherstashBetween` / - * `cipherstashNotBetween` (trait `cipherstash:order-and-range` - * -> string, double, bigint, date) - * - `cipherstashJsonbPathExists` (trait - * `cipherstash:searchable-json` -> json) - * - * The lowering templates mirror the canonical EQL function calls. - * The variable-arity `inArray` / `notInArray` - * lowerings build their template per call from the array length - * (see {@link variableArityEnvelopeOperator}). - */ -export function cipherstashQueryOperations(): SqlOperationDescriptors { - return { - cipherstashEq: eqlOperator('cipherstashEq', 'eq'), - cipherstashIlike: eqlOperator('cipherstashIlike', 'ilike'), - cipherstashNe: envelopeOperator( - 'cipherstashNe', - CIPHERSTASH_TRAIT_EQUALITY, - 1, - 'NOT eql_v2.eq({{self}}, {{arg0}})', - ), - cipherstashInArray: variableArityEnvelopeOperator( - 'cipherstashInArray', - CIPHERSTASH_TRAIT_EQUALITY, - buildInArrayTemplate, - ), - cipherstashNotInArray: variableArityEnvelopeOperator( - 'cipherstashNotInArray', - CIPHERSTASH_TRAIT_EQUALITY, - buildNotInArrayTemplate, - ), - cipherstashNotIlike: envelopeOperator( - 'cipherstashNotIlike', - CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH, - 1, - 'NOT eql_v2.ilike({{self}}, {{arg0}})', - ), - cipherstashGt: envelopeOperator( - 'cipherstashGt', - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - 1, - 'eql_v2.gt({{self}}, {{arg0}})', - ), - cipherstashGte: envelopeOperator( - 'cipherstashGte', - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - 1, - 'eql_v2.gte({{self}}, {{arg0}})', - ), - cipherstashLt: envelopeOperator( - 'cipherstashLt', - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - 1, - 'eql_v2.lt({{self}}, {{arg0}})', - ), - cipherstashLte: envelopeOperator( - 'cipherstashLte', - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - 1, - 'eql_v2.lte({{self}}, {{arg0}})', - ), - cipherstashBetween: envelopeOperator( - 'cipherstashBetween', - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - 2, - 'eql_v2.gte({{self}}, {{arg0}}) AND eql_v2.lte({{self}}, {{arg1}})', - ), - cipherstashNotBetween: envelopeOperator( - 'cipherstashNotBetween', - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - 2, - 'NOT (eql_v2.gte({{self}}, {{arg0}}) AND eql_v2.lte({{self}}, {{arg1}}))', - ), - cipherstashJsonbPathExists: jsonbPathExistsOperator(), - } -} diff --git a/packages/prisma-next/src/execution/parameterized.ts b/packages/prisma-next/src/execution/parameterized.ts deleted file mode 100644 index 9d6b6be25..000000000 --- a/packages/prisma-next/src/execution/parameterized.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * `RuntimeParameterizedCodecDescriptor`s for the cipherstash storage - * codecs — the post-#402 unified `CodecDescriptor

` shape consumed by - * the SQL runtime via `SqlStaticContributions.parameterizedCodecs()`. - * - * Mirrors pgvector's `vectorParamsSchema` + `vectorFactory` precedent - * (`packages/3-extensions/pgvector/src/exports/runtime.ts`). Cipherstash - * differs from pgvector in two respects: each codec depends on the - * SDK (read-side single-cell `decrypt`, the bulk-encrypt middleware), - * so each `createParameterizedCodecDescriptors(sdk)` call produces a - * fresh descriptor list closed over the SDK so multi-tenant - * deployments can compose multiple cipherstash extensions side-by-side - * without cross-talk; and the cipherstash family ships six codecs - * (one per encrypted column type) which all share the same - * `eql_v2_encrypted` Postgres native type. - * - * Per-codec params shape (every flag defaults to `true` because - * searchable encryption is the legitimate default for an extension - * whose entire reason for existing is to make encrypted columns - * queryable): - * - * | Codec | Params | - * |---------------------|-------------------------------------| - * | `cipherstash/string@1` | `{ equality, freeTextSearch, orderAndRange }` | - * | `cipherstash/double@1` | `{ equality, orderAndRange }` | - * | `cipherstash/bigint@1` | `{ equality, orderAndRange }` | - * | `cipherstash/date@1` | `{ equality, orderAndRange }` | - * | `cipherstash/boolean@1` | `{ equality }` | - * | `cipherstash/json@1` | `{ searchableJson }` | - * - * The codec runtimes are per-cell stateless across params on the write - * side (encode reads ciphertext from the handle, independent of the - * search-mode flags); read-side decode constructs the per-type - * envelope independent of params. The factory therefore returns the - * same shared codec for every params instance, mirroring pgvector's - * `vectorFactory`. - */ - -import type { CodecInstanceContext } from '@prisma-next/framework-components/codec' -import type { RuntimeParameterizedCodecDescriptor } from '@prisma-next/sql-runtime' -import { type as arktype } from 'arktype' -import { - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_CODEC_TRAITS, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, - CIPHERSTASH_STRING_CODEC_ID, -} from '../extension-metadata/constants' -import { - createCipherstashBigIntCodec, - createCipherstashBooleanCodec, - createCipherstashDateCodec, - createCipherstashDoubleCodec, - createCipherstashJsonCodec, - createCipherstashStringCodec, -} from './codec-runtime' -import type { CipherstashSdk } from './sdk' - -export interface CipherstashStringParams { - readonly equality: boolean - readonly freeTextSearch: boolean - readonly orderAndRange: boolean -} - -export interface CipherstashNumericParams { - readonly equality: boolean - readonly orderAndRange: boolean -} - -export interface CipherstashDateParams { - readonly equality: boolean - readonly orderAndRange: boolean -} - -export interface CipherstashBooleanParams { - readonly equality: boolean -} - -export interface CipherstashJsonParams { - readonly searchableJson: boolean -} - -export const encryptedStringParamsSchema = arktype({ - equality: 'boolean', - freeTextSearch: 'boolean', - orderAndRange: 'boolean', -}) - -export const encryptedDoubleParamsSchema = arktype({ - equality: 'boolean', - orderAndRange: 'boolean', -}) - -export const encryptedBigIntParamsSchema = arktype({ - equality: 'boolean', - orderAndRange: 'boolean', -}) - -export const encryptedDateParamsSchema = arktype({ - equality: 'boolean', - orderAndRange: 'boolean', -}) - -export const encryptedBooleanParamsSchema = arktype({ - equality: 'boolean', -}) - -export const encryptedJsonParamsSchema = arktype({ - searchableJson: 'boolean', -}) - -export function renderEncryptedStringOutputType( - _params: CipherstashStringParams, -): string { - return 'EncryptedString' -} - -export function renderEncryptedDoubleOutputType( - _params: CipherstashNumericParams, -): string { - return 'EncryptedDouble' -} - -export function renderEncryptedBigIntOutputType( - _params: CipherstashNumericParams, -): string { - return 'EncryptedBigInt' -} - -export function renderEncryptedDateOutputType( - _params: CipherstashDateParams, -): string { - return 'EncryptedDate' -} - -export function renderEncryptedBooleanOutputType( - _params: CipherstashBooleanParams, -): string { - return 'EncryptedBoolean' -} - -export function renderEncryptedJsonOutputType( - _params: CipherstashJsonParams, -): string { - return 'EncryptedJson' -} - -const ENCRYPTED_TARGET_TYPES = ['eql_v2_encrypted'] as const -const ENCRYPTED_META = { - db: { sql: { postgres: { nativeType: 'eql_v2_encrypted' } } }, -} as const -// Per-codec traits live in `CIPHERSTASH_CODEC_TRAITS` and use the -// `cipherstash:*` namespace so the cipherstash-namespaced operators -// (`cipherstashEq`, `cipherstashGt`, etc.) can register against -// multiple codec ids at once via trait-based dispatch. The traits -// are intentionally namespaced to avoid colliding with framework -// built-ins like `'equality'` — see `equality-trait-removal.test.ts` -// for the regression rationale. - -export type CipherstashAnyParams = - | CipherstashStringParams - | CipherstashNumericParams - | CipherstashDateParams - | CipherstashBooleanParams - | CipherstashJsonParams - -export function createParameterizedCodecDescriptors( - sdk: CipherstashSdk, -): ReadonlyArray> { - const stringCodec = createCipherstashStringCodec(sdk) - const doubleCodec = createCipherstashDoubleCodec(sdk) - const bigIntCodec = createCipherstashBigIntCodec(sdk) - const dateCodec = createCipherstashDateCodec(sdk) - const booleanCodec = createCipherstashBooleanCodec(sdk) - const jsonCodec = createCipherstashJsonCodec(sdk) - - const stringDescriptor: RuntimeParameterizedCodecDescriptor = - { - codecId: CIPHERSTASH_STRING_CODEC_ID, - traits: CIPHERSTASH_CODEC_TRAITS[CIPHERSTASH_STRING_CODEC_ID] ?? [], - targetTypes: ENCRYPTED_TARGET_TYPES, - meta: ENCRYPTED_META, - paramsSchema: encryptedStringParamsSchema, - isParameterized: true as const, - renderOutputType: renderEncryptedStringOutputType, - factory: - (_params: CipherstashStringParams) => (_ctx: CodecInstanceContext) => - stringCodec, - } - - const doubleDescriptor: RuntimeParameterizedCodecDescriptor = - { - codecId: CIPHERSTASH_DOUBLE_CODEC_ID, - traits: CIPHERSTASH_CODEC_TRAITS[CIPHERSTASH_DOUBLE_CODEC_ID] ?? [], - targetTypes: ENCRYPTED_TARGET_TYPES, - meta: ENCRYPTED_META, - paramsSchema: encryptedDoubleParamsSchema, - isParameterized: true as const, - renderOutputType: renderEncryptedDoubleOutputType, - factory: - (_params: CipherstashNumericParams) => (_ctx: CodecInstanceContext) => - doubleCodec, - } - - const bigIntDescriptor: RuntimeParameterizedCodecDescriptor = - { - codecId: CIPHERSTASH_BIGINT_CODEC_ID, - traits: CIPHERSTASH_CODEC_TRAITS[CIPHERSTASH_BIGINT_CODEC_ID] ?? [], - targetTypes: ENCRYPTED_TARGET_TYPES, - meta: ENCRYPTED_META, - paramsSchema: encryptedBigIntParamsSchema, - isParameterized: true as const, - renderOutputType: renderEncryptedBigIntOutputType, - factory: - (_params: CipherstashNumericParams) => (_ctx: CodecInstanceContext) => - bigIntCodec, - } - - const dateDescriptor: RuntimeParameterizedCodecDescriptor = - { - codecId: CIPHERSTASH_DATE_CODEC_ID, - traits: CIPHERSTASH_CODEC_TRAITS[CIPHERSTASH_DATE_CODEC_ID] ?? [], - targetTypes: ENCRYPTED_TARGET_TYPES, - meta: ENCRYPTED_META, - paramsSchema: encryptedDateParamsSchema, - isParameterized: true as const, - renderOutputType: renderEncryptedDateOutputType, - factory: - (_params: CipherstashDateParams) => (_ctx: CodecInstanceContext) => - dateCodec, - } - - const booleanDescriptor: RuntimeParameterizedCodecDescriptor = - { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - traits: CIPHERSTASH_CODEC_TRAITS[CIPHERSTASH_BOOLEAN_CODEC_ID] ?? [], - targetTypes: ENCRYPTED_TARGET_TYPES, - meta: ENCRYPTED_META, - paramsSchema: encryptedBooleanParamsSchema, - isParameterized: true as const, - renderOutputType: renderEncryptedBooleanOutputType, - factory: - (_params: CipherstashBooleanParams) => (_ctx: CodecInstanceContext) => - booleanCodec, - } - - const jsonDescriptor: RuntimeParameterizedCodecDescriptor = - { - codecId: CIPHERSTASH_JSON_CODEC_ID, - traits: CIPHERSTASH_CODEC_TRAITS[CIPHERSTASH_JSON_CODEC_ID] ?? [], - targetTypes: ENCRYPTED_TARGET_TYPES, - meta: ENCRYPTED_META, - paramsSchema: encryptedJsonParamsSchema, - isParameterized: true as const, - renderOutputType: renderEncryptedJsonOutputType, - factory: - (_params: CipherstashJsonParams) => (_ctx: CodecInstanceContext) => - jsonCodec, - } - - return [ - stringDescriptor, - doubleDescriptor, - bigIntDescriptor, - dateDescriptor, - booleanDescriptor, - jsonDescriptor, - ] as ReadonlyArray> -} diff --git a/packages/prisma-next/src/execution/routing.ts b/packages/prisma-next/src/execution/routing.ts index d3b50e999..a30c7b316 100644 --- a/packages/prisma-next/src/execution/routing.ts +++ b/packages/prisma-next/src/execution/routing.ts @@ -3,9 +3,10 @@ * * The routing key is derived from the envelope handle's * `(table, column)` — there is no per-column override surface. Every - * cipherstash envelope passing through `bulkEncryptMiddleware` (and - * `decryptAll`) carries `(table, column)` on its handle, populated by - * the middleware's AST walk before the bulk-encrypt phase begins. + * cipherstash envelope passing through the v3 bulk-encrypt middleware + * (and `decryptAll`) carries `(table, column)` on its handle, populated + * by the routing-key AST walk ({@link stampRoutingKeysFromAst}) before + * the bulk-encrypt phase begins. * * `groupByRoutingKey` produces one homogeneous group per * `(table, column)` pair so each `bulkEncrypt` call serves a single @@ -14,7 +15,14 @@ * batching is a future optimization. */ -import type { EncryptedEnvelopeBase } from './envelope-base' +import type { + AnyExpression, + AnyQueryAst, + InsertAst, + InsertValue, + UpdateAst, +} from '@prisma-next/sql-relational-core/ast' +import { EncryptedEnvelopeBase, setHandleRoutingKey } from './envelope-base' import type { CipherstashRoutingKey } from './sdk' /** @@ -101,3 +109,59 @@ export function groupByRoutingKey( } return groups } + +/** + * Walk a lowered `InsertAst` / `UpdateAst` and stamp `(table, column)` + * routing context onto every cipherstash envelope embedded in a + * `ParamRef`. Version-neutral: it touches only the shared envelope base + * and the AST — no wire/codec knowledge — so the v3 bulk-encrypt + * middleware (`../v3/bulk-encrypt-v3.ts`) reuses it rather than forking + * the walk. This is the single place the AST's structural column + * metadata gets attached to the envelopes the SDK will see. + */ +export function stampRoutingKeysFromAst(ast: AnyQueryAst | undefined): void { + if (!ast) return + switch (ast.kind) { + case 'insert': + stampInsert(ast) + return + case 'update': + stampUpdate(ast) + return + default: + return + } +} + +function stampInsert(ast: InsertAst): void { + const tableName = ast.table.name + for (const row of ast.rows) { + for (const [column, value] of Object.entries(row)) { + stampParamRefIfEnvelope(value, tableName, column) + } + } + if (ast.onConflict?.action.kind === 'do-update-set') { + for (const [column, value] of Object.entries(ast.onConflict.action.set)) { + stampParamRefIfEnvelope(value, tableName, column) + } + } +} + +function stampUpdate(ast: UpdateAst): void { + const tableName = ast.table.name + for (const [column, value] of Object.entries(ast.set)) { + stampParamRefIfEnvelope(value, tableName, column) + } +} + +function stampParamRefIfEnvelope( + value: AnyExpression | InsertValue, + table: string, + column: string, +): void { + if (value.kind !== 'param-ref') return + const inner = value.value + if (inner instanceof EncryptedEnvelopeBase) { + setHandleRoutingKey(inner, table, column) + } +} diff --git a/packages/prisma-next/src/exports/column-types.ts b/packages/prisma-next/src/exports/column-types.ts index 8968f943b..4e90dcf25 100644 --- a/packages/prisma-next/src/exports/column-types.ts +++ b/packages/prisma-next/src/exports/column-types.ts @@ -11,18 +11,6 @@ * counterpart lowers to, so PSL- and TS-authored contracts emit * byte-identical `contract.json`. There are NO options: the factory IS the * capability set (`textSearch` vs `text`, and so on). - * - * ## v2: legacy `*V2` aliases - * - * The six pre-rename factories (`encryptedStringV2`, `encryptedDoubleV2`, - * `encryptedBigIntV2`, `encryptedDateV2`, `encryptedBooleanV2`, - * `encryptedJsonV2`) keep their previous bodies verbatim: single optional - * options object, every search-mode flag defaulting to `true` — searchable - * encryption is the legitimate default for an extension whose entire reason - * for existing is to make encrypted columns queryable — and the - * `cipherstash/*@1` codec + `eql_v2_encrypted` native type outputs. They - * mirror the `*V2` PSL constructors' `true` defaults declared via - * `AuthoringArgRef.default`. */ // `stripDomainSchema` is adapter-seam surface (`adapter-kit`), same import @@ -31,15 +19,6 @@ import { stripDomainSchema } from '@cipherstash/stack/adapter-kit' import type { AnyEncryptedV3Column } from '@cipherstash/stack/eql/v3' import { types } from '@cipherstash/stack/eql/v3' -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 { toV3CodecId, type V3CastAs } from '../v3/catalog' // --------------------------------------------------------------------------- @@ -164,221 +143,3 @@ export const timestampOrd = v3Authored(types.TimestampOrd) export const boolean = v3Authored(types.Boolean) // json (encrypted JSONB, ste_vec containment) export const json = v3Authored(types.Json) - -// --------------------------------------------------------------------------- -// v2 legacy aliases (verbatim pre-rename bodies, now named *V2) -// --------------------------------------------------------------------------- - -/** - * Search-mode parameters for `encryptedStringV2({...})`. Every flag is - * optional and defaults to `true` when omitted — searchable - * encryption is the legitimate default. `orderAndRange` gives string - * columns the same sortable / range-queryable surface the numeric + - * date codecs already had. - */ -export interface EncryptedStringOptions { - readonly equality?: boolean - readonly freeTextSearch?: boolean - readonly orderAndRange?: boolean -} - -export interface EncryptedStringColumnDescriptor { - readonly codecId: typeof CIPHERSTASH_STRING_CODEC_ID - readonly nativeType: typeof EQL_V2_ENCRYPTED_TYPE - readonly typeParams: { - readonly equality: boolean - readonly freeTextSearch: boolean - readonly orderAndRange: boolean - } -} - -/** - * `encryptedStringV2({ equality?, freeTextSearch?, orderAndRange? })` — - * TS contract factory that lowers to a `ColumnTypeDescriptor` with - * the `cipherstash/string@1` codec and the `eql_v2_encrypted` - * Postgres native type. Each boolean flag becomes a `typeParams.*` - * slot; all default to `true`. - * - * The shape matches what the PSL constructor - * `cipherstash.EncryptedStringV2({...})` lowers to, byte-for-byte. - */ -export function encryptedStringV2( - options: EncryptedStringOptions = {}, -): EncryptedStringColumnDescriptor { - return { - codecId: CIPHERSTASH_STRING_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - equality: options.equality ?? true, - freeTextSearch: options.freeTextSearch ?? true, - orderAndRange: options.orderAndRange ?? true, - }, - } -} - -/** - * Search-mode parameters for `encryptedDoubleV2({...})` and - * `encryptedBigIntV2({...})`. Both flags are optional and default to - * `true` when omitted — searchable encryption is the legitimate - * default. - */ -export interface EncryptedNumericOptions { - readonly equality?: boolean - readonly orderAndRange?: boolean -} - -export interface EncryptedDoubleColumnDescriptor { - readonly codecId: typeof CIPHERSTASH_DOUBLE_CODEC_ID - readonly nativeType: typeof EQL_V2_ENCRYPTED_TYPE - readonly typeParams: { - readonly equality: boolean - readonly orderAndRange: boolean - } -} - -export interface EncryptedBigIntColumnDescriptor { - readonly codecId: typeof CIPHERSTASH_BIGINT_CODEC_ID - readonly nativeType: typeof EQL_V2_ENCRYPTED_TYPE - readonly typeParams: { - readonly equality: boolean - readonly orderAndRange: boolean - } -} - -/** - * `encryptedDoubleV2({ equality?, orderAndRange? })` — TS contract - * factory that lowers to a `ColumnTypeDescriptor` with the - * `cipherstash/double@1` codec and the `eql_v2_encrypted` Postgres - * native type. Mirrors what - * `cipherstash.EncryptedDoubleV2({...})` lowers to byte-for-byte. - */ -export function encryptedDoubleV2( - options: EncryptedNumericOptions = {}, -): EncryptedDoubleColumnDescriptor { - return { - codecId: CIPHERSTASH_DOUBLE_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - equality: options.equality ?? true, - orderAndRange: options.orderAndRange ?? true, - }, - } -} - -/** - * `encryptedBigIntV2({ equality?, orderAndRange? })` — TS contract - * factory matching `cipherstash.EncryptedBigIntV2({...})`. - */ -export function encryptedBigIntV2( - options: EncryptedNumericOptions = {}, -): EncryptedBigIntColumnDescriptor { - return { - codecId: CIPHERSTASH_BIGINT_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - equality: options.equality ?? true, - orderAndRange: options.orderAndRange ?? true, - }, - } -} - -/** - * Search-mode parameters for `encryptedDateV2({...})`. Both flags are - * optional and default to `true`. - */ -export interface EncryptedDateOptions { - readonly equality?: boolean - readonly orderAndRange?: boolean -} - -export interface EncryptedDateColumnDescriptor { - readonly codecId: typeof CIPHERSTASH_DATE_CODEC_ID - readonly nativeType: typeof EQL_V2_ENCRYPTED_TYPE - readonly typeParams: { - readonly equality: boolean - readonly orderAndRange: boolean - } -} - -/** - * `encryptedDateV2({ equality?, orderAndRange? })` — TS contract factory - * matching `cipherstash.EncryptedDateV2({...})`. - */ -export function encryptedDateV2( - options: EncryptedDateOptions = {}, -): EncryptedDateColumnDescriptor { - return { - codecId: CIPHERSTASH_DATE_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - equality: options.equality ?? true, - orderAndRange: options.orderAndRange ?? true, - }, - } -} - -/** - * Search-mode parameters for `encryptedBooleanV2({...})`. The flag is - * optional and defaults to `true`. Booleans only support equality - * search (no meaningful range predicate over a 2-value domain). - */ -export interface EncryptedBooleanOptions { - readonly equality?: boolean -} - -export interface EncryptedBooleanColumnDescriptor { - readonly codecId: typeof CIPHERSTASH_BOOLEAN_CODEC_ID - readonly nativeType: typeof EQL_V2_ENCRYPTED_TYPE - readonly typeParams: { - readonly equality: boolean - } -} - -/** - * `encryptedBooleanV2({ equality? })` — TS contract factory matching - * `cipherstash.EncryptedBooleanV2({...})`. - */ -export function encryptedBooleanV2( - options: EncryptedBooleanOptions = {}, -): EncryptedBooleanColumnDescriptor { - return { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - equality: options.equality ?? true, - }, - } -} - -/** - * Search-mode parameters for `encryptedJsonV2({...})`. Single flag — - * `searchableJson` gates the entire `ste_vec` index family (containment - * + path-extraction predicates). Defaults to `true`. - */ -export interface EncryptedJsonOptions { - readonly searchableJson?: boolean -} - -export interface EncryptedJsonColumnDescriptor { - readonly codecId: typeof CIPHERSTASH_JSON_CODEC_ID - readonly nativeType: typeof EQL_V2_ENCRYPTED_TYPE - readonly typeParams: { - readonly searchableJson: boolean - } -} - -/** - * `encryptedJsonV2({ searchableJson? })` — TS contract factory matching - * `cipherstash.EncryptedJsonV2({...})`. - */ -export function encryptedJsonV2( - options: EncryptedJsonOptions = {}, -): EncryptedJsonColumnDescriptor { - return { - codecId: CIPHERSTASH_JSON_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - typeParams: { - searchableJson: options.searchableJson ?? true, - }, - } -} diff --git a/packages/prisma-next/src/exports/control.ts b/packages/prisma-next/src/exports/control.ts index 3497022ea..7f8023c1b 100644 --- a/packages/prisma-next/src/exports/control.ts +++ b/packages/prisma-next/src/exports/control.ts @@ -17,12 +17,13 @@ * * - `contractSpace.{contractJson,migrations,headRef}` — sourced from * the on-disk artefacts emitted by `build:contract-space`. - * - `types.codecTypes.controlPlaneHooks[CIPHERSTASH_STRING_CODEC_ID]` - * — the lifecycle hook the SQL planner extracts via - * `extractCodecControlHooks` and inlines into the application's - * migration via `planFieldEventOperations`. Implements - * `add_search_config` / `remove_search_config` / rotate behaviour - * for `searchable: true` `Encrypted` columns. + * - `types.codecTypes.controlPlaneHooks` — the v3 codec-control hooks + * (`cipherstashV3CodecControlHooks`) the SQL planner extracts via + * `extractCodecControlHooks`. v3 registers the identity + * `expandNativeType` ONLY (the planner requires a hook to exist for + * `typeParams`-carrying columns) and NO `onFieldEvent`, so v3 + * columns emit no `add_search_config` / `remove_search_config` ops — + * the v3 domains carry their own index metadata. * * @see docs/architecture docs/adrs/ADR 212 - Contract spaces.md * (contract-space package layout convention). @@ -32,12 +33,6 @@ import type { Contract } from '@prisma-next/contract/types' import type { SqlControlExtensionDescriptor } from '@prisma-next/family-sql/control' import { contractSpaceFromJson } from '@prisma-next/migration-tools/spaces' import type { SqlStorage } from '@prisma-next/sql-contract/types' -import baselineMetadata from '../../migrations/20260601T0000_install_eql_bundle/migration.json' with { - type: 'json', -} -import baselineOps from '../../migrations/20260601T0000_install_eql_bundle/ops.json' with { - type: 'json', -} import v3BaselineMetadata from '../../migrations/20260601T0100_install_eql_v3_bundle/migration.json' with { type: 'json', } @@ -46,25 +41,8 @@ import v3BaselineOps from '../../migrations/20260601T0100_install_eql_v3_bundle/ } import headRef from '../../migrations/refs/head.json' with { type: 'json' } import contractJson from '../contract.json' with { type: 'json' } -import { - CIPHERSTASH_BASELINE_MIGRATION_NAME, - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, - CIPHERSTASH_STRING_CODEC_ID, -} from '../extension-metadata/constants' import { CIPHERSTASH_V3_BASELINE_MIGRATION_NAME } from '../extension-metadata/constants-v3' import { cipherstashPackMeta } from '../extension-metadata/descriptor-meta' -import { - cipherstashBigIntCodecHooks, - cipherstashBooleanCodecHooks, - cipherstashDateCodecHooks, - cipherstashDoubleCodecHooks, - cipherstashJsonCodecHooks, - cipherstashStringCodecHooks, -} from '../migration/cipherstash-codec' import { cipherstashV3CodecControlHooks } from '../migration/cipherstash-codec-v3' import { withRuntimeEqlSqlPackage } from '../migration/eql-bundle-v3' @@ -76,18 +54,14 @@ const v3BaselineRuntimePackage = withRuntimeEqlSqlPackage( const cipherstashContractSpace = contractSpaceFromJson>({ contractJson, migrations: [ - { - dirName: CIPHERSTASH_BASELINE_MIGRATION_NAME, - metadata: baselineMetadata, - ops: baselineOps, - }, - // The v3 bundle baseline — an invariant-only edge (`from === to`; - // the bundle creates `public.eql_v3_*` domains + `eql_v3.*` - // functions but no contract-space storage). The v3 codec ids ARE - // registered in `controlPlaneHooks` below, but with the identity - // `expandNativeType` ONLY (the planner requires the hook to exist - // for `typeParams`-carrying columns) — no `onFieldEvent`, which is - // what guarantees v3 columns emit no `add_search_config` / + // The v3 bundle baseline is the SOLE migration — the package is EQL + // v3 only. It is an invariant-only genesis edge (`from: null` → the + // empty-storage hash; the bundle creates `public.eql_v3_*` domains + + // `eql_v3.*` functions but no contract-space storage). The v3 codec + // ids ARE registered in `controlPlaneHooks` below, but with the + // identity `expandNativeType` ONLY (the planner requires the hook to + // exist for `typeParams`-carrying columns) — no `onFieldEvent`, + // which is what guarantees v3 columns emit no `add_search_config` / // `remove_search_config` ops. { dirName: CIPHERSTASH_V3_BASELINE_MIGRATION_NAME, @@ -126,14 +100,9 @@ const cipherstashExtensionDescriptor: SqlControlExtensionDescriptor<'postgres'> ...cipherstashPackMeta.types.codecTypes, controlPlaneHooks: { // v3: identity expandNativeType only, no onFieldEvent — see - // `../migration/cipherstash-codec-v3.ts`. + // `../migration/cipherstash-codec-v3.ts`. This is the whole + // control-plane hook set: the package is EQL v3 only. ...cipherstashV3CodecControlHooks, - [CIPHERSTASH_STRING_CODEC_ID]: cipherstashStringCodecHooks, - [CIPHERSTASH_DOUBLE_CODEC_ID]: cipherstashDoubleCodecHooks, - [CIPHERSTASH_BIGINT_CODEC_ID]: cipherstashBigIntCodecHooks, - [CIPHERSTASH_DATE_CODEC_ID]: cipherstashDateCodecHooks, - [CIPHERSTASH_BOOLEAN_CODEC_ID]: cipherstashBooleanCodecHooks, - [CIPHERSTASH_JSON_CODEC_ID]: cipherstashJsonCodecHooks, }, }, }, diff --git a/packages/prisma-next/src/exports/middleware.ts b/packages/prisma-next/src/exports/middleware.ts deleted file mode 100644 index 20bdfd854..000000000 --- a/packages/prisma-next/src/exports/middleware.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Public middleware surface for the cipherstash extension. - * - * Consumers register the bulk-encrypt middleware in their runtime so - * `EncryptedString` envelopes embedded in `INSERT` / `UPDATE` plans get - * 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 = new PostgresRuntimeImpl({ - * extensionPacks: [createCipherstashRuntimeDescriptor({ sdk })], - * middleware: [bulkEncryptMiddleware(sdk)], - * }); - * ``` - * - * `SqlRuntimeExtensionDescriptor` does not own a middleware slot, so - * the descriptor wrapper (`createCipherstashRuntimeDescriptor`) and - * the middleware are composed manually by callers — by convention, - * once per cipherstash SDK binding. - */ - -export { bulkEncryptMiddleware } from '../middleware/bulk-encrypt' diff --git a/packages/prisma-next/src/exports/migration.ts b/packages/prisma-next/src/exports/migration.ts deleted file mode 100644 index cad360528..000000000 --- a/packages/prisma-next/src/exports/migration.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Public migration-time entry point for the cipherstash extension. - * - * Re-exports the user-callable factory functions used in hand-written - * migrations (or auto-imported by the planner-generated `migration.ts`) - * to wire EQL search-config rows alongside structural DDL: - * - * ```ts - * 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 [ - * 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' }), - * ]; - * } - * } - * - * MigrationCLI.run(import.meta.url, M); - * ``` - * - * 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. - */ - -export type { - CipherstashSearchConfigArgs, - CipherstashSearchIndex, -} from '../migration/call-classes' -export { - cipherstashAddSearchConfig, - cipherstashRemoveSearchConfig, -} from '../migration/call-classes' diff --git a/packages/prisma-next/src/exports/runtime.ts b/packages/prisma-next/src/exports/runtime.ts index 32f11097e..540e5eadd 100644 --- a/packages/prisma-next/src/exports/runtime.ts +++ b/packages/prisma-next/src/exports/runtime.ts @@ -1,10 +1,11 @@ /** - * Runtime-plane entry point for the CipherStash extension. + * Runtime-plane entry point for the CipherStash extension (EQL v3). * - * Consumed at query time by application runtimes that need to encode / - * decode `cipherstash/string@1` columns (envelope class) and talk to the - * CipherStash SDK shape the codec runtime + bulk-encrypt middleware - * depend on. + * Consumed at query time by application runtimes: the value envelopes + * (`EncryptedString`, `EncryptedNumber`, `EncryptedBigInt`, …), + * `decryptAll`, the CipherStash SDK shape the v3 codec runtime + + * bulk-encrypt middleware depend on, and the v3 runtime descriptor / + * operators. * * The runtime entry point is deliberately separate from `./control` * (descriptor, codec lifecycle hook, contract-space artefacts) so apps @@ -13,38 +14,13 @@ * descriptor — the control plane and runtime plane are tree-shakable * along this seam. * - * `createCipherstashRuntimeDescriptor({ sdk })` is the recommended - * composition entry — it bundles the SDK-bound codec, the parameterized - * codec descriptor, and the runtime-plane `codecInstances` slot into a - * single `SqlRuntimeExtensionDescriptor<'postgres'>` mirroring - * 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 `new PostgresRuntimeImpl({ middleware: - * [bulkEncryptMiddleware(sdk)] })` (or their target facade's - * `middleware` option). + * `createCipherstashV3RuntimeDescriptor({ sdk })` is the recommended + * composition entry — it bundles the SDK-bound v3 codecs and the + * runtime-plane `codecDescriptors` slot into a single + * `SqlRuntimeExtensionDescriptor<'postgres'>`. The bulk-encrypt + * middleware ships as `bulkEncryptMiddlewareV3(sdk)`. */ -import type { SqlRuntimeExtensionDescriptor } from '@prisma-next/sql-runtime' -import { cipherstashQueryOperations } from '../execution/operators' -import { createParameterizedCodecDescriptors } from '../execution/parameterized' -import type { CipherstashSdk } from '../execution/sdk' -import { - CIPHERSTASH_EXTENSION_VERSION, - CIPHERSTASH_SPACE_ID, -} from '../extension-metadata/constants' - -export type { CipherstashStringCodec } from '../execution/codec-runtime' -export { - CIPHERSTASH_STRING_CODEC_ID, - CipherstashCellCodec, - createCipherstashBigIntCodec, - createCipherstashBooleanCodec, - createCipherstashDateCodec, - createCipherstashDoubleCodec, - createCipherstashJsonCodec, - createCipherstashStringCodec, -} from '../execution/codec-runtime' export type { DecryptAllOptions } from '../execution/decrypt-all' export { decryptAll } from '../execution/decrypt-all' export type { @@ -62,11 +38,6 @@ export type { EncryptedDateHandle, } from '../execution/envelope-date' export { EncryptedDate } from '../execution/envelope-date' -export type { - EncryptedDoubleFromInternalArgs, - EncryptedDoubleHandle, -} from '../execution/envelope-double' -export { EncryptedDouble } from '../execution/envelope-double' export type { EncryptedJsonFromInternalArgs, EncryptedJsonHandle, @@ -77,35 +48,6 @@ export type { EncryptedStringHandle, } from '../execution/envelope-string' export { EncryptedString } from '../execution/envelope-string' -export { - cipherstashAsc, - cipherstashDesc, - cipherstashJsonbGet, - cipherstashJsonbPathQueryFirst, -} from '../execution/helpers' -export type { - CipherstashAnyParams, - CipherstashBooleanParams, - CipherstashDateParams, - CipherstashJsonParams, - CipherstashNumericParams, - CipherstashStringParams, -} from '../execution/parameterized' -export { - createParameterizedCodecDescriptors, - encryptedBigIntParamsSchema, - encryptedBooleanParamsSchema, - encryptedDateParamsSchema, - encryptedDoubleParamsSchema, - encryptedJsonParamsSchema, - encryptedStringParamsSchema, - renderEncryptedBigIntOutputType, - renderEncryptedBooleanOutputType, - renderEncryptedDateOutputType, - renderEncryptedDoubleOutputType, - renderEncryptedJsonOutputType, - renderEncryptedStringOutputType, -} from '../execution/parameterized' export type { CipherstashBulkDecryptArgs, CipherstashBulkEncryptArgs, @@ -113,18 +55,8 @@ export type { CipherstashSdk, CipherstashSingleDecryptArgs, } from '../execution/sdk' -export { - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, -} from '../extension-metadata/constants' // --------------------------------------------------------------------------- -// EQL v3 runtime surface (decision 1b: a SEPARATE extension descriptor -// under v3's own id — never co-register it with the v2 descriptor -// above; the shared `cipherstash*` method names collide in the flat -// OperationRegistry). +// EQL v3 runtime surface — the package installs EQL v3 only. // --------------------------------------------------------------------------- export { CIPHERSTASH_V3_CODEC_IDS, @@ -161,53 +93,3 @@ export { createCipherstashV3RuntimeDescriptor, } from '../v3/runtime-v3' export { v3FromDriver, v3ToDriver } from '../v3/wire-v3' - -export { CIPHERSTASH_EXTENSION_VERSION } - -export interface CreateCipherstashRuntimeDescriptorOptions { - readonly sdk: CipherstashSdk -} - -/** - * Compose the SDK-bound codec runtime + parameterized codec descriptors - * + runtime-plane codec-instances metadata into a single - * `SqlRuntimeExtensionDescriptor<'postgres'>`. - * - * The descriptor is per-SDK: cipherstash's codec captures the SDK at - * `decode` time (read-side single-cell `decrypt`) and the bulk-encrypt - * middleware captures it at `beforeExecute` time (write-side bulk - * round-trip). Multi-tenant deployments construct one descriptor per - * tenant SDK so per-tenant key material never crosses runtimes. - * - * Mirrors `packages/3-extensions/pgvector/src/exports/runtime.ts` — - * pgvector's vectorRuntimeDescriptor is a static default-export because - * its codec is fully stateless; cipherstash needs the factory wrapper - * because the codec depends on `sdk`. - */ -export function createCipherstashRuntimeDescriptor( - opts: CreateCipherstashRuntimeDescriptorOptions, -): SqlRuntimeExtensionDescriptor<'postgres'> { - const { sdk } = opts - const parameterizedDescriptors = createParameterizedCodecDescriptors(sdk) - - return { - kind: 'extension' as const, - id: CIPHERSTASH_SPACE_ID, - version: CIPHERSTASH_EXTENSION_VERSION, - familyId: 'sql' as const, - targetId: 'postgres' as const, - types: { - codecTypes: { - codecDescriptors: parameterizedDescriptors, - }, - }, - codecs: () => parameterizedDescriptors, - queryOperations: () => cipherstashQueryOperations(), - create() { - return { - familyId: 'sql' as const, - targetId: 'postgres' as const, - } - }, - } -} diff --git a/packages/prisma-next/src/exports/stack.ts b/packages/prisma-next/src/exports/stack.ts index 8ed398f8c..d72ce3254 100644 --- a/packages/prisma-next/src/exports/stack.ts +++ b/packages/prisma-next/src/exports/stack.ts @@ -1,48 +1,26 @@ /** * `@cipherstash/prisma-next/stack` — one-call setup for the - * `@cipherstash/stack` SDK against a Prisma Next contract. + * `@cipherstash/stack` SDK against a Prisma Next contract (EQL v3). * - * The three exports here form a layered API. Most consumers want - * {@link cipherstashFromStackV2}; the two primitives are exposed for - * advanced users who need to interpose custom logic. - * - * - {@link deriveStackSchemas} — pure function, contract.json → - * `EncryptedTable[]`. Use to construct `Encryption({ schemas })` - * yourself while keeping schemas in lockstep with the contract. - * - * - {@link createCipherstashSdk} — wraps an initialised stack - * `EncryptionClient` in the framework-native `CipherstashSdk` - * shape. Use when you've constructed the client yourself (custom - * keyset, multi-tenant routing). - * - * - {@link cipherstashFromStackV2} — the all-in-one factory. - * Returns ready-to-spread arrays for `postgres({...})`. + * Most consumers want {@link cipherstashFromStack}: it derives the v3 + * encryption schemas from your contract, constructs the + * `@cipherstash/stack` `EncryptionV3` client from your `CS_*` env vars or + * local profile, builds the SDK adapter, and returns ready-to-spread + * `extensions` / `middleware` for `postgres({...})`. The + * remaining exports are the primitives it composes, for advanced users + * who need to interpose custom logic (custom keyset, multi-tenant + * routing). * * This subpath imports `@cipherstash/stack` directly. Consumers who * implement `CipherstashSdk` against a different SDK should use - * `./runtime` and `./middleware` instead and pay no - * `@cipherstash/stack` bundle cost. + * `./runtime` instead and pay no `@cipherstash/stack` bundle cost. */ -export type { ContractStorageView } from '../stack/derive-schemas' -export { deriveStackSchemas } from '../stack/derive-schemas' - -export type { - CipherstashFromStackOptions, - CipherstashFromStackResult, -} from '../stack/from-stack' -export { cipherstashFromStackV2 } from '../stack/from-stack' -// --------------------------------------------------------------------------- -// EQL v3 (decision 1b: a SEPARATE entry point — a client is v2 or v3, -// never both; `cipherstashFromStack` rejects contracts carrying v2 -// cipherstash codec ids). -// --------------------------------------------------------------------------- export type { CipherstashFromStackV3Options, CipherstashFromStackV3Result, } from '../stack/from-stack-v3' export { cipherstashFromStack } from '../stack/from-stack-v3' -export { createCipherstashSdk } from '../stack/sdk-adapter' export type { V3ContractColumnEntry, V3ContractShape, diff --git a/packages/prisma-next/src/extension-metadata/codec-metadata.ts b/packages/prisma-next/src/extension-metadata/codec-metadata.ts index 53ffe72db..31c764cf0 100644 --- a/packages/prisma-next/src/extension-metadata/codec-metadata.ts +++ b/packages/prisma-next/src/extension-metadata/codec-metadata.ts @@ -5,13 +5,13 @@ * emit time — they never call `encode`/`decode`. * * The SDK-bound runtime codec for actual `encode`/`decode` lives in - * `../execution/codec-runtime`; it is resolved through - * `RuntimeParameterizedCodecDescriptor.factory` at runtime instead of - * through pack-meta's `codecInstances`. + * `../v3/codec-runtime-v3`; it is resolved through the runtime + * descriptor's `codecDescriptors` at runtime instead of through + * pack-meta's `codecInstances`. * * Keeping the SDK-free metadata in its own module — and *not* importing - * the runtime `CipherstashStringCodec` class — preserves the control - * vs runtime split. Control-plane consumers (`exports/control.ts`, + * the runtime envelope classes — preserves the control vs runtime + * split. Control-plane consumers (`exports/control.ts`, * `exports/pack.ts`) pull this file but never touch the envelope, the * SDK interface, or the bulk-encrypt middleware. The bundling-isolation * test pins this property by snapshotting that the control entry's @@ -33,44 +33,8 @@ import { V3_DOMAIN_META_BY_CODEC_ID, type V3DomainMeta, } from '../v3/catalog' -import { - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_CODEC_TRAITS, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, - CIPHERSTASH_STRING_CODEC_ID, - EQL_V2_ENCRYPTED_TYPE, -} from './constants' import { v3TraitsForCapabilities } from './constants-v3' -function makeMetadataDescriptor( - codecId: string, - typeName: string, -): AnyCodecDescriptor { - return { - codecId, - traits: CIPHERSTASH_CODEC_TRAITS[codecId] ?? [], - targetTypes: [EQL_V2_ENCRYPTED_TYPE], - meta: { db: { sql: { postgres: { nativeType: EQL_V2_ENCRYPTED_TYPE } } } }, - paramsSchema: { - '~standard': { - version: 1, - vendor: 'cipherstash', - validate: (value: unknown) => ({ value }), - }, - }, - isParameterized: false, - renderOutputType: () => typeName, - factory: () => () => { - throw new Error( - 'cipherstash codec: metadata descriptor factory is not callable', - ) - }, - } -} - class CipherstashCodecMetadata extends CodecImpl< string, readonly [], @@ -87,14 +51,14 @@ class CipherstashCodecMetadata extends CodecImpl< async encode(): Promise { throw new Error( 'cipherstash codec: encode called on the pack-meta metadata codec. ' + - 'Construct a runtime descriptor via `createCipherstashRuntimeDescriptor({ sdk })` and use that instead.', + 'Construct a runtime descriptor via `createCipherstashV3RuntimeDescriptor({ sdk })` and use that instead.', ) } async decode(): Promise { throw new Error( 'cipherstash codec: decode called on the pack-meta metadata codec. ' + - 'Construct a runtime descriptor via `createCipherstashRuntimeDescriptor({ sdk })` and use that instead.', + 'Construct a runtime descriptor via `createCipherstashV3RuntimeDescriptor({ sdk })` and use that instead.', ) } @@ -110,36 +74,6 @@ class CipherstashCodecMetadata extends CodecImpl< } } -export const cipherstashStringCodecMetadata = new CipherstashCodecMetadata( - makeMetadataDescriptor(CIPHERSTASH_STRING_CODEC_ID, 'EncryptedString'), - 'EncryptedString', -) - -export const cipherstashDoubleCodecMetadata = new CipherstashCodecMetadata( - makeMetadataDescriptor(CIPHERSTASH_DOUBLE_CODEC_ID, 'EncryptedDouble'), - 'EncryptedDouble', -) - -export const cipherstashBigIntCodecMetadata = new CipherstashCodecMetadata( - makeMetadataDescriptor(CIPHERSTASH_BIGINT_CODEC_ID, 'EncryptedBigInt'), - 'EncryptedBigInt', -) - -export const cipherstashDateCodecMetadata = new CipherstashCodecMetadata( - makeMetadataDescriptor(CIPHERSTASH_DATE_CODEC_ID, 'EncryptedDate'), - 'EncryptedDate', -) - -export const cipherstashBooleanCodecMetadata = new CipherstashCodecMetadata( - makeMetadataDescriptor(CIPHERSTASH_BOOLEAN_CODEC_ID, 'EncryptedBoolean'), - 'EncryptedBoolean', -) - -export const cipherstashJsonCodecMetadata = new CipherstashCodecMetadata( - makeMetadataDescriptor(CIPHERSTASH_JSON_CODEC_ID, 'EncryptedJson'), - 'EncryptedJson', -) - // --------------------------------------------------------------------------- // EQL v3 — one metadata codec per catalog domain (all 40), DERIVED from the // catalog (never hand-listed) so pack-meta can never drift from what the diff --git a/packages/prisma-next/src/extension-metadata/constants.ts b/packages/prisma-next/src/extension-metadata/constants.ts index bacfc223e..6bf2bf788 100644 --- a/packages/prisma-next/src/extension-metadata/constants.ts +++ b/packages/prisma-next/src/extension-metadata/constants.ts @@ -1,16 +1,14 @@ /** * Static names and identifiers used across CipherStash's contract space. * - * Centralising the strings here so: - * - the contract IR (`./contract`), the migration ops (`./migrations`), - * the head ref (`./head-ref`), and the descriptor (`../exports/control`) - * all reference the same values without typos; - * - the `cipherstash:*` invariantId namespace is locked in one place - * (once published, an invariantId cannot be renamed). + * The package installs EQL **v3** only; the v3-specific ids live in + * `./constants-v3`. This module holds the identifiers shared by both the + * control plane (pack meta / descriptor) and the v3 surface: the space id, + * the extension version, and the `cipherstash:*` trait vocabulary. * - * The space identifier `'cipherstash'` is what the framework writes to - * the consuming app's `migrations/cipherstash/` directory and what the marker table's - * `space` column carries for CipherStash-owned rows. + * The space identifier `'cipherstash'` is what the framework writes to the + * consuming app's `migrations/cipherstash/` directory and what the marker + * table's `space` column carries for CipherStash-owned rows. */ export const CIPHERSTASH_SPACE_ID = 'cipherstash' @@ -26,130 +24,20 @@ export const CIPHERSTASH_SPACE_ID = 'cipherstash' export const CIPHERSTASH_EXTENSION_VERSION = '0.0.1' as const /** - * Codec id the application-side `Encrypted` lowering targets. - * Lives here so the codec lifecycle hook (which emits - * `add_search_config` / `remove_search_config` ops on field events) and - * the descriptor's `controlPlaneHooks` wiring share the same constant. - */ -export const CIPHERSTASH_STRING_CODEC_ID = 'cipherstash/string@1' - -/** - * Codec id for the `cipherstash/double@1` codec — IEEE-754 double - * plaintext (`number`) lowering to `eql_v2_encrypted` with EQL - * `cast_as = 'double'`. The id encodes plaintext shape (not storage - * type) so each cipherstash envelope class binds 1:1 with a codec - * id. - */ -export const CIPHERSTASH_DOUBLE_CODEC_ID = 'cipherstash/double@1' - -/** - * Codec id for the `cipherstash/bigint@1` codec — JS `bigint` - * plaintext lowering to `eql_v2_encrypted` with EQL - * `cast_as = 'big_int'`. - */ -export const CIPHERSTASH_BIGINT_CODEC_ID = 'cipherstash/bigint@1' - -/** - * Codec id for the `cipherstash/date@1` codec — `Date` plaintext - * (calendar date) lowering to `eql_v2_encrypted` with EQL - * `cast_as = 'date'`. - */ -export const CIPHERSTASH_DATE_CODEC_ID = 'cipherstash/date@1' - -/** - * Codec id for the `cipherstash/boolean@1` codec — `boolean` - * plaintext lowering to `eql_v2_encrypted` with EQL - * `cast_as = 'boolean'`. - */ -export const CIPHERSTASH_BOOLEAN_CODEC_ID = 'cipherstash/boolean@1' - -/** - * Codec id for the `cipherstash/json@1` codec — JSON-serialisable - * `unknown` plaintext lowering to `eql_v2_encrypted` with EQL - * `cast_as = 'jsonb'`. - */ -export const CIPHERSTASH_JSON_CODEC_ID = 'cipherstash/json@1' - -/** - * The closed set of every codec id this package owns. Single source of - * truth for the bulk-encrypt middleware filter and any other call site - * that needs "is this a cipherstash codec id?" — using a closed set - * (rather than a `cipherstash/` prefix match) means the middleware - * never accidentally claims jurisdiction over a future cipherstash - * codec that hasn't been wired through the rest of the package yet - * (envelope subclass, codec hook, runtime descriptor, etc.). When a - * new codec is introduced its id lands here in the same diff that - * wires the rest of its surface; out-of-package consumers (e.g. tests - * pinning the closed set) catch a missed wiring with one assertion. + * Cipherstash-namespaced codec traits. The v3 codec descriptors + * (`../v3/codec-runtime-v3.ts`, `./codec-metadata.ts`) derive their trait + * sets from these via `../extension-metadata/constants-v3.ts` + * (`v3TraitsForCapabilities`), which re-exports them; the `eql*` query + * operators register against the matching `cipherstash:v3-*` markers. * - * Order mirrors `createParameterizedCodecDescriptors`'s descriptor - * list so an iteration here matches the iteration there cell-for-cell. - */ -export const CIPHERSTASH_CODEC_IDS = [ - CIPHERSTASH_STRING_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, -] as const - -/** - * Set form of {@link CIPHERSTASH_CODEC_IDS} for `O(1)` membership - * tests (the bulk-encrypt middleware's hot per-`ParamRef` filter). - */ -export const CIPHERSTASH_CODEC_ID_SET: ReadonlySet = new Set( - CIPHERSTASH_CODEC_IDS, -) - -/** - * Closed union of every cipherstash codec id this package owns. - * Drives compile-time exhaustiveness for codec-id-keyed dispatch - * tables (e.g. `coerceToEnvelope` in `src/execution/operators.ts`) - * and for the free-standing helpers in `src/execution/helpers.ts` - * that validate a column's codec id against the cipherstash set. - */ -export type CipherstashCodecId = (typeof CIPHERSTASH_CODEC_IDS)[number] - -/** - * Type-guard form of {@link CIPHERSTASH_CODEC_ID_SET}. Narrows - * `string` to {@link CipherstashCodecId} for downstream - * cipherstash-only branches (e.g. helper-side codec validation). - */ -export function isCipherstashCodecId( - codecId: string, -): codecId is CipherstashCodecId { - return CIPHERSTASH_CODEC_ID_SET.has(codecId) -} - -/** - * Cipherstash-namespaced codec traits. Used as the dispatch key for - * the multi-codec predicate operators in `src/execution/operators.ts` - * — operators register with `self: { traits: ['cipherstash:'] }` - * and the model accessor (`packages/3-extensions/sql-orm-client/src/ - * model-accessor.ts`) attaches the operator to every codec descriptor - * whose `traits` list contains the same trait identifier. - * - * The `cipherstash:` prefix is load-bearing — it isolates these - * traits from the framework's built-in trait surface (`'equality'`, - * `'orderable'`, `'numeric'`, `'boolean'`, ...) so adding them to a - * cipherstash codec does not silently re-enable a built-in operator - * (e.g. `equality` would re-attach the framework's `eq` which lowers - * to standard SQL `=` — wrong for EQL ciphers, see - * `equality-trait-removal.test.ts`). The cipherstash extension owns - * its namespace; collisions with a future framework trait are not - * possible. - * - * Codec ↔ trait mapping (see ADR 214): - * - * - `cipherstash:equality` — string, double, bigint, date, boolean - * - `cipherstash:order-and-range` — string, double, bigint, date - * - `cipherstash:free-text-search` — string - * - `cipherstash:searchable-json` — json - * - * Each predicate operator registers under exactly one of these - * traits; the codec ↔ operator visibility surface follows from the - * trait set declared on each codec descriptor. + * The `cipherstash:` prefix is load-bearing — it isolates these traits from + * the framework's built-in trait surface (`'equality'`, `'orderable'`, + * `'numeric'`, `'boolean'`, …) so adding them to a cipherstash codec does + * not silently re-enable a built-in operator (e.g. `equality` would + * re-attach the framework's `eq` which lowers to standard SQL `=` — wrong + * for EQL ciphers, see `equality-trait-removal.test.ts`). The cipherstash + * extension owns its namespace; collisions with a future framework trait + * are not possible. */ export const CIPHERSTASH_TRAIT_EQUALITY = 'cipherstash:equality' as const export const CIPHERSTASH_TRAIT_ORDER_AND_RANGE = @@ -158,99 +46,3 @@ export const CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH = 'cipherstash:free-text-search' as const export const CIPHERSTASH_TRAIT_SEARCHABLE_JSON = 'cipherstash:searchable-json' as const - -/** - * Per-codec trait sets keyed by codec id. Each codec descriptor in - * `parameterized.ts` / `codec-runtime.ts` / `codec-metadata.ts` reads - * the traits for its codec id from this map; the - * `equality-trait-removal.test.ts` regression also reads from here so - * the three trait declarations (runtime / parameterized / pack-meta) - * stay agreement-by-construction. - */ -// Local re-alias of the framework's `CodecTrait` union, used solely as -// the cast target below. Type-only import — adds no runtime -// dependency. -type FrameworkCodecTrait = - import('@prisma-next/framework-components/codec').CodecTrait - -const CIPHERSTASH_CODEC_TRAITS_RAW: Readonly< - Record -> = { - [CIPHERSTASH_STRING_CODEC_ID]: [ - CIPHERSTASH_TRAIT_EQUALITY, - CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH, - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - ], - [CIPHERSTASH_DOUBLE_CODEC_ID]: [ - CIPHERSTASH_TRAIT_EQUALITY, - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - ], - [CIPHERSTASH_BIGINT_CODEC_ID]: [ - CIPHERSTASH_TRAIT_EQUALITY, - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - ], - [CIPHERSTASH_DATE_CODEC_ID]: [ - CIPHERSTASH_TRAIT_EQUALITY, - CIPHERSTASH_TRAIT_ORDER_AND_RANGE, - ], - [CIPHERSTASH_BOOLEAN_CODEC_ID]: [CIPHERSTASH_TRAIT_EQUALITY], - [CIPHERSTASH_JSON_CODEC_ID]: [CIPHERSTASH_TRAIT_SEARCHABLE_JSON], -} - -// `CodecDescriptor.traits` is typed `readonly CodecTrait[]` where -// `CodecTrait` is a closed union of framework built-ins -// (`'equality' | 'order' | 'boolean' | 'numeric' | 'textual'`). The -// cipherstash trait strings live in the extension-private -// `cipherstash:` namespace and are intentionally not part of that -// union — they sit in their own namespace so adding them here cannot -// silently re-attach a framework built-in (e.g. `'equality'` would -// re-attach the wrong-SQL `eq` footgun, see -// `equality-trait-removal.test.ts`). The model-accessor's trait -// dispatch widens `descriptor.traits` to `readonly string[]` before -// the membership check (`packages/3-extensions/sql-orm-client/src/ -// model-accessor.ts:74-80`), so the extension-namespaced strings -// round-trip through the registry unchanged at runtime; the cast -// here is purely a type-level adapter from an extension namespace -// into the framework union. AGENTS.md requires the rationale comment -// alongside any `as unknown as` cast. -export const CIPHERSTASH_CODEC_TRAITS = - CIPHERSTASH_CODEC_TRAITS_RAW as unknown as Readonly< - Record - > - -/** Schema CipherStash installs its functions/operators/casts/types into. */ -export const EQL_V2_SCHEMA = 'eql_v2' - -/** Configuration table used by EQL's per-column index configuration. */ -export const EQL_V2_CONFIGURATION_TABLE = 'eql_v2_configuration' - -/** Enum type backing the `state` column on `eql_v2_configuration`. */ -export const EQL_V2_CONFIGURATION_STATE_TYPE = 'eql_v2_configuration_state' - -/** JSONB-domain composite type user `Encrypted` columns reference. */ -export const EQL_V2_ENCRYPTED_TYPE = 'eql_v2_encrypted' - -/** - * Migration directory name for the baseline. - * - * Per the framework's per-space layout convention this name is - * preserved verbatim when the framework writes the package to - * `migrations/cipherstash//` in the user's repo. - */ -export const CIPHERSTASH_BASELINE_MIGRATION_NAME = - '20260601T0000_install_eql_bundle' - -/** - * `cipherstash:*` invariantIds emitted by the baseline migration. Each - * `cipherstash:*` id, once published, is immutable: downstream - * consumers (other extensions, the marker table) reference them by - * literal string match. - * - * Today the baseline emits a single op (`installBundle`); the bundle - * SQL is the source of truth for every typed object it creates inside - * the `eql_v2` schema. New bundle versions or additional structural - * ops will mint new `cipherstash:*` ids alongside this entry. - */ -export const CIPHERSTASH_INVARIANTS = { - installBundle: 'cipherstash:install-eql-bundle-v1', -} as const diff --git a/packages/prisma-next/src/extension-metadata/descriptor-meta.ts b/packages/prisma-next/src/extension-metadata/descriptor-meta.ts index 4b2f2bb8d..67195c52f 100644 --- a/packages/prisma-next/src/extension-metadata/descriptor-meta.ts +++ b/packages/prisma-next/src/extension-metadata/descriptor-meta.ts @@ -7,12 +7,11 @@ * * SDK-free: the runtime descriptor layers SDK-bound codec instances on * top at execution time. The `codecInstances` slot here uses the - * metadata-only - * codec from `./codec-metadata` because pack-meta consumers only read - * codec metadata (typeId, targetTypes, traits, renderOutputType); - * runtime encode/decode always go through the SDK-bound codec produced - * by `RuntimeParameterizedCodecDescriptor.factory` (see - * `./parameterized`). + * metadata-only codec from `./codec-metadata` because pack-meta + * consumers only read codec metadata (typeId, targetTypes, traits, + * renderOutputType); runtime encode/decode always go through the + * SDK-bound v3 codecs produced by `createV3CodecDescriptors` (see + * `../v3/codec-runtime-v3`). * * The control descriptor in `../exports/control.ts` spreads this pack * meta so the framework's contract emitter sees `authoring`, @@ -23,25 +22,12 @@ import { cipherstashAuthoringTypes } from '../contract-authoring' import { - cipherstashBigIntCodecMetadata, - cipherstashBooleanCodecMetadata, - cipherstashDateCodecMetadata, - cipherstashDoubleCodecMetadata, - cipherstashJsonCodecMetadata, - cipherstashStringCodecMetadata, cipherstashV3CodecMetadataInstances, cipherstashV3StorageRows, } from './codec-metadata' import { - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, CIPHERSTASH_EXTENSION_VERSION, - CIPHERSTASH_JSON_CODEC_ID, CIPHERSTASH_SPACE_ID, - CIPHERSTASH_STRING_CODEC_ID, - EQL_V2_ENCRYPTED_TYPE, } from './constants' export { CIPHERSTASH_EXTENSION_VERSION } @@ -58,12 +44,6 @@ export const cipherstashPackMeta = { types: { codecTypes: { codecInstances: [ - cipherstashStringCodecMetadata, - cipherstashDoubleCodecMetadata, - cipherstashBigIntCodecMetadata, - cipherstashDateCodecMetadata, - cipherstashBooleanCodecMetadata, - cipherstashJsonCodecMetadata, // EQL v3 — all 40 catalog domains, derived (see codec-metadata.ts). ...cipherstashV3CodecMetadataInstances, ], @@ -71,17 +51,17 @@ export const cipherstashPackMeta = { // `import type { CodecTypes as CipherstashTypes } from '@prisma-next/extension-cipherstash/codec-types'` // and to intersect `CipherstashTypes` into the generated // `CodecTypes` type alias. Without this slot the codec-id-keyed - // type lookups (`CodecTypes['cipherstash/string@1']['traits']`) + // type lookups (`CodecTypes['cipherstash/eql-v3/eql_v3_text@1']`) // collapse to `unknown` on the consumer side, and the - // trait-dispatched operators (`cipherstashGt`, …) never surface - // on real model accessors. Mirrors pgvector's `import:` slot. + // trait-dispatched operators (`eqlGt`, …) never surface on real + // model accessors. Mirrors pgvector's `import:` slot. import: { package: '@prisma-next/extension-cipherstash/codec-types', named: 'CodecTypes', alias: 'CipherstashTypes', }, // `renderOutputType` returns the bare envelope type name (e.g. - // `EncryptedString`, `EncryptedDouble`) for parameterized + // `EncryptedString`, `EncryptedNumber`) for parameterized // cipherstash columns; the contract emitter needs to import each // type alongside its occurrence so the generated `.d.ts` // typechecks cleanly. Mirrors pgvector's `Vector` typeImports @@ -92,11 +72,6 @@ export const cipherstashPackMeta = { named: 'EncryptedString', alias: 'EncryptedString', }, - { - package: '@prisma-next/extension-cipherstash/runtime', - named: 'EncryptedDouble', - alias: 'EncryptedDouble', - }, { package: '@prisma-next/extension-cipherstash/runtime', named: 'EncryptedBigInt', @@ -135,42 +110,6 @@ export const cipherstashPackMeta = { }, }, storage: [ - { - typeId: CIPHERSTASH_STRING_CODEC_ID, - familyId: 'sql', - targetId: 'postgres', - nativeType: EQL_V2_ENCRYPTED_TYPE, - }, - { - typeId: CIPHERSTASH_DOUBLE_CODEC_ID, - familyId: 'sql', - targetId: 'postgres', - nativeType: EQL_V2_ENCRYPTED_TYPE, - }, - { - typeId: CIPHERSTASH_BIGINT_CODEC_ID, - familyId: 'sql', - targetId: 'postgres', - nativeType: EQL_V2_ENCRYPTED_TYPE, - }, - { - typeId: CIPHERSTASH_DATE_CODEC_ID, - familyId: 'sql', - targetId: 'postgres', - nativeType: EQL_V2_ENCRYPTED_TYPE, - }, - { - typeId: CIPHERSTASH_BOOLEAN_CODEC_ID, - familyId: 'sql', - targetId: 'postgres', - nativeType: EQL_V2_ENCRYPTED_TYPE, - }, - { - typeId: CIPHERSTASH_JSON_CODEC_ID, - familyId: 'sql', - targetId: 'postgres', - nativeType: EQL_V2_ENCRYPTED_TYPE, - }, // EQL v3 — one row per catalog domain, each targeting its own // concrete `public.eql_v3_*` native type (see codec-metadata.ts). ...cipherstashV3StorageRows, diff --git a/packages/prisma-next/src/middleware/bulk-encrypt.ts b/packages/prisma-next/src/middleware/bulk-encrypt.ts deleted file mode 100644 index 992366690..000000000 --- a/packages/prisma-next/src/middleware/bulk-encrypt.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Bulk-encrypt middleware for cipherstash envelopes. - * - * The middleware sits in the SQL runtime's `beforeExecute` chain and: - * - * 1. Walks the lowered query AST (`InsertAst` / `UpdateAst`) and stamps - * `(table, column)` routing context onto every cipherstash envelope - * (any `EncryptedEnvelopeBase` subclass) embedded in a `ParamRef`. - * The handle's `(table, column)` slots are the canonical input to - * {@link groupByRoutingKey}; this walk is the single place the AST's - * structural column metadata gets attached to the envelopes the SDK - * will see. - * - * 2. Iterates `params.entries()` to collect every cipherstash-codec'd - * `ParamRef` (matched against the closed - * {@link CIPHERSTASH_CODEC_ID_SET} — see the rationale on the - * constant in `extension-metadata/constants.ts`), groups them by - * routing key, and issues exactly one `sdk.bulkEncrypt(...)` call - * per group. Routing-key derivation is `(table, column)`; per-group - * homogeneity-by-column means each batch is naturally typed (every - * cell in a `(table, column)` group has the same codec id, hence - * the same plaintext type), so the SDK's polymorphic `values: - * ReadonlyArray` surface does not need narrowing inside - * this middleware. - * - * 3. Stamps each returned ciphertext onto the envelope's handle via - * `setHandleCiphertext` AND writes the **encoded wire-format - * string** (the `eql_v2_encrypted` composite-text literal produced - * by {@link encodeEqlV2EncryptedWire}) into the param slot via - * `params.replaceValues`. The wire format is what the pg driver - * can serialise directly — passing the envelope object itself - * would fail at the driver boundary. The handle's `plaintext` and - * `ciphertext` slots are both **retained** post-replacement so - * `envelope.decrypt()` continues to return the plaintext - * synchronously without consulting the SDK, and any follow-on - * query that reuses the same envelope skips the re-encrypt - * round-trip. - * - * Lifecycle: the codec's `encode` runs first (in `lower`/ - * `encodeParams`) and returns the envelope as a sentinel when no - * ciphertext is set; this middleware then runs in `beforeExecute` - * and replaces the param slot with the wire-format string before - * the driver reads `currentParams()`. See the comment on - * `CipherstashCellCodec#encode` in `../execution/cell-codec-factory.ts` - * for the full two-pass design. - * - * Cancellation: `ctx.signal` is forwarded by identity to every - * `bulkEncrypt` call via `ifDefined`; the SDK is responsible for - * honoring it. The awaiting middleware also races the SDK promise - * against `ctx.signal` via `raceCipherstashAbort` so a caller-side - * abort surfaces a `RUNTIME.ABORTED { phase: 'bulk-encrypt' }` - * envelope promptly even when the SDK body itself ignores the signal. - * A pre-flight `checkCipherstashAborted` short-circuits before any - * SDK round-trip is scheduled when the signal is already aborted at - * entry. - */ - -import type { - AnyExpression, - AnyQueryAst, - InsertAst, - InsertValue, - UpdateAst, -} from '@prisma-next/sql-relational-core/ast' -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 { - checkCipherstashAborted, - raceCipherstashAbort, -} from '../execution/abort' -import { encodeEqlV2EncryptedWire } from '../execution/cell-codec-factory' -import { - EncryptedEnvelopeBase, - setHandleCiphertext, - setHandleRoutingKey, -} from '../execution/envelope-base' -import { markBulkEncryptMiddlewareRegistered } from '../execution/middleware-registry' -import { type BulkEncryptTarget, groupByRoutingKey } from '../execution/routing' -import type { CipherstashSdk } from '../execution/sdk' -import { CIPHERSTASH_CODEC_ID_SET } from '../extension-metadata/constants' - -/** - * Construct the bulk-encrypt middleware. The returned middleware is - * stateless aside from the captured `sdk` reference; one instance per - * runtime extension is the expected pattern. - */ -export function bulkEncryptMiddleware(sdk: CipherstashSdk): SqlMiddleware { - // Mark this sdk as wired up so the codec's `encode` can distinguish - // "happy path: middleware will run later and fill in the ciphertext" - // from "misconfig: this sdk has no middleware registered". See - // `../execution/middleware-registry.ts`. - markBulkEncryptMiddlewareRegistered(sdk) - return { - name: 'cipherstash.bulk-encrypt', - familyId: 'sql', - async beforeExecute(plan, ctx, params) { - if (!params) { - return - } - - stampRoutingKeysFromAst(plan.ast) - - const targets = collectTargets(params) - if (targets.length === 0) { - return - } - - const groups = groupByRoutingKey(targets) - for (const [groupKey, group] of groups) { - const first = group[0] - if (!first) continue - const routingKey = first.routingKey - - checkCipherstashAborted(ctx.signal, 'bulk-encrypt') - const ciphertexts = await raceCipherstashAbort( - sdk.bulkEncrypt({ - routingKey, - values: group.map((t) => t.plaintext), - ...ifDefined('signal', ctx.signal), - }), - ctx.signal, - 'bulk-encrypt', - ) - - if (ciphertexts.length !== group.length) { - throw new Error( - `cipherstash bulk-encrypt: SDK returned ${ciphertexts.length} ciphertexts ` + - `for routing key ${groupKey} but ${group.length} were requested.`, - ) - } - - // Replace each ParamRef's value with the **wire-format** - // string that the pg driver can serialise directly. Passing - // the envelope itself (an `EncryptedEnvelopeBase` instance) - // would fail at the pg layer with `could not serialize` - // because the driver only knows how to marshal primitives / - // arrays / Buffers. We also keep the envelope's ciphertext - // slot stamped via `setHandleCiphertext` so subsequent reads - // off the same envelope (e.g. immediately reusing it in a - // follow-on query) work without a re-encrypt round-trip. - params.replaceValues( - group.map((t, i) => { - const ciphertext = ciphertexts[i] - setHandleCiphertext(t.envelope, ciphertext) - return { - ref: t.ref, - newValue: encodeEqlV2EncryptedWire(ciphertext), - } - }), - ) - } - }, - } -} - -function collectTargets( - params: SqlParamRefMutator, -): BulkEncryptTarget>[] { - const targets: BulkEncryptTarget>[] = [] - for (const entry of params.entries()) { - if ( - entry.codecId === undefined || - !CIPHERSTASH_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 bulk-encrypt: encountered an envelope with no plaintext on the write path. ' + - 'Use the relevant `Encrypted*.from(plaintext)` factory to construct write-side envelopes.', - ) - } - if (handle.table === undefined || handle.column === undefined) { - throw new Error( - 'cipherstash bulk-encrypt: envelope reached the bulk-encrypt phase without a (table, column) ' + - "routing context. The middleware's AST walk only handles `InsertAst` and `UpdateAst`; " + - 'cipherstash envelopes embedded in other plan shapes (e.g. raw SQL) must stamp routing ' + - 'context explicitly via `setHandleRoutingKey` before execute.', - ) - } - targets.push({ - ref: entry.ref, - plaintext: handle.plaintext, - envelope: value, - routingKey: { table: handle.table, column: handle.column }, - }) - } - return targets -} - -/** - * Walk a lowered `InsertAst` / `UpdateAst` and stamp `(table, column)` - * routing context onto every cipherstash envelope embedded in a - * `ParamRef`. Version-neutral: it touches only the shared envelope base - * and the AST — no wire/codec knowledge — so the v3 middleware - * (`../v3/bulk-encrypt-v3.ts`) reuses it rather than forking the walk. - */ -export function stampRoutingKeysFromAst(ast: AnyQueryAst | undefined): void { - if (!ast) return - switch (ast.kind) { - case 'insert': - stampInsert(ast) - return - case 'update': - stampUpdate(ast) - return - default: - return - } -} - -function stampInsert(ast: InsertAst): void { - const tableName = ast.table.name - for (const row of ast.rows) { - for (const [column, value] of Object.entries(row)) { - stampParamRefIfEnvelope(value, tableName, column) - } - } - if (ast.onConflict?.action.kind === 'do-update-set') { - for (const [column, value] of Object.entries(ast.onConflict.action.set)) { - stampParamRefIfEnvelope(value, tableName, column) - } - } -} - -function stampUpdate(ast: UpdateAst): void { - const tableName = ast.table.name - for (const [column, value] of Object.entries(ast.set)) { - stampParamRefIfEnvelope(value, tableName, column) - } -} - -function stampParamRefIfEnvelope( - value: AnyExpression | InsertValue, - table: string, - column: string, -): void { - if (value.kind !== 'param-ref') return - const inner = value.value - if (inner instanceof EncryptedEnvelopeBase) { - setHandleRoutingKey(inner, table, column) - } -} diff --git a/packages/prisma-next/src/migration/call-classes.ts b/packages/prisma-next/src/migration/call-classes.ts deleted file mode 100644 index b821e4990..000000000 --- a/packages/prisma-next/src/migration/call-classes.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Cipherstash migration IR — renderable `*Call` classes for the codec - * lifecycle hook + the public `@prisma-next/extension-cipherstash/migration` - * factory functions. - * - * Each `*Call` implements the framework `OpFactoryCall` interface (ADR - * 195) directly, so cipherstash's contributions flow through the postgres - * planner as first-class IR nodes — no `RawSqlCall` wrap, no detour - * through the unstructured-op fallback. The codec hook - * (`./cipherstash-codec.ts`) returns Calls; the postgres planner adds - * them to its call list and renders them via `renderCallsToTypeScript`. - * - * Public factory functions (`cipherstashAddSearchConfig` / - * `cipherstashRemoveSearchConfig`) are re-exported from - * `@prisma-next/extension-cipherstash/migration`. Users authoring a - * hand-written migration can call them directly: - * - * ```ts - * import { cipherstashAddSearchConfig } from '@prisma-next/extension-cipherstash/migration'; - * - * createTable('public', 'user', [...]); - * cipherstashAddSearchConfig({ table: 'user', column: 'email', index: 'unique' }); - * ``` - * - * Round-trip invariant: `toOp()` produces the same op shape the codec - * hook would emit directly — `ops.json` stays byte-identical; - * `migration.ts` carries a factory call instead of an opaque - * `rawSql({...})` block. - */ - -import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control' -import type { - MigrationOperationClass, - OpFactoryCall, -} from '@prisma-next/framework-components/control' -import { - type ImportRequirement, - jsonToTsSource, - TsExpression, -} from '@prisma-next/ts-render' -import { ifDefined } from '@prisma-next/utils/defined' - -const CIPHERSTASH_MIGRATION_MODULE = - '@prisma-next/extension-cipherstash/migration' - -/** Mirrors `eql_v2.add_search_config(table, column, index_name, cast_as)`. */ -const DEFAULT_CAST_AS = 'text' - -/** - * EQL search-config indices the cipherstash codecs emit — one per - * enabled `typeParams` flag, across every cipherstash-encrypted column - * type: - * - * - `'unique'` — equality lookup (every codec). - * - `'match'` — free-text search (`Encrypted` only). - * - `'ore'` — order-and-range comparisons (`Encrypted` / - * `Encrypted` / `Encrypted` / - * `Encrypted`). - * - `'ste_vec'` — searchable JSON path/value queries - * (`Encrypted`). - * - * Mirrors the full EQL `add_search_config` index vocabulary; the - * `cipherstashAddSearchConfig` / `cipherstashRemoveSearchConfig` - * factories accept any of the four without further changes. - */ -export type CipherstashSearchIndex = 'unique' | 'match' | 'ore' | 'ste_vec' - -/** - * Args shape accepted by the public `cipherstashAddSearchConfig` / - * `cipherstashRemoveSearchConfig` factory functions. - * - * `castAs` defaults to `'text'` — matches the cipherstash codec hook's - * canonical output and the EQL bundle's expected cast for - * `eql_v2_encrypted` columns. Override only if you know the runtime - * cast for your column differs. - */ -export interface CipherstashSearchConfigArgs { - readonly table: string - readonly column: string - readonly index: CipherstashSearchIndex - readonly castAs?: string -} - -type CipherstashOp = SqlMigrationPlanOperation -type OpStep = CipherstashOp['execute'][number] - -/** - * Escape a string so it can be embedded inside a Postgres single-quoted - * literal. Identifiers in our IR are unlikely to contain apostrophes, - * but doubling them keeps the emitted SQL safe under any future - * relaxation. - */ -function sqlLiteral(value: string): string { - return `'${value.replace(/'/g, "''")}'` -} - -function invariantIdFor( - tableName: string, - fieldName: string, - action: 'add-search-config' | 'remove-search-config', - indexName: CipherstashSearchIndex, -): string { - return `cipherstash-codec:${tableName}.${fieldName}:${action}:${indexName}@v1` -} - -/** - * Base class for cipherstash migration IR nodes. - * - * Each instance is *both* an `OpFactoryCall` (renderable to TypeScript, - * lowerable to a runtime op via `toOp()`) and a structurally-valid - * {@link CipherstashOp} — `id`, `label`, `operationClass`, - * `invariantId`, `target`, `precheck`, `execute`, `postcheck` are - * stored as enumerable own properties, populated in the concrete - * subclass constructors. So when the planner-rendered `migration.ts` - * runs and the user's `operations` getter returns Call instances - * directly, both `MigrationOpSchema` validation (which checks `id` / - * `label` / `operationClass`) and `JSON.stringify` (which writes - * `ops.json`) see the runtime op shape unchanged. - * - * The cipherstash-specific identity fields (`factoryName`, `table`, - * `column`, `index`, `castAs`) live on the subclass prototype as - * accessor getters and on a per-instance backing record kept in a - * private slot (`#args`). Accessor properties on the class are - * non-enumerable, and the backing record is a private field, so - * `Object.keys(call)` and `canonicalizeJson(...)` see only the op - * fields — `ops.json` and `migrationHash` stay byte-stable. - */ -abstract class CipherstashOpFactoryCallNode - extends TsExpression - implements OpFactoryCall -{ - abstract get factoryName(): string - abstract readonly operationClass: MigrationOperationClass - abstract readonly label: string - abstract readonly id: string - abstract readonly invariantId: string - abstract readonly target: { readonly id: string } - abstract readonly precheck: readonly OpStep[] - abstract readonly execute: readonly OpStep[] - abstract readonly postcheck: readonly OpStep[] - - importRequirements(): readonly ImportRequirement[] { - return [ - { - moduleSpecifier: CIPHERSTASH_MIGRATION_MODULE, - symbol: this.factoryName, - }, - ] - } - - /** - * Re-expose the runtime op view for callers that prefer to lower - * Calls explicitly (notably {@link renderOps} on the postgres lane). - * The returned object is a plain copy of this Call's op-shaped - * fields. - */ - toOp(): CipherstashOp { - return { - id: this.id, - label: this.label, - operationClass: this.operationClass, - invariantId: this.invariantId, - target: this.target, - precheck: this.precheck, - execute: this.execute, - postcheck: this.postcheck, - } - } - - protected freeze(): void { - Object.freeze(this) - } -} - -/** - * `cipherstashAddSearchConfig` — register an EQL search-config row for - * the given column / index combination. Lowers to a `SELECT - * eql_v2.add_search_config('', '', '', - * '')` op, classified `'additive'`. - */ -interface AddArgs { - readonly table: string - readonly column: string - readonly index: CipherstashSearchIndex - readonly castAs: string -} - -export class CipherstashAddSearchConfigCall extends CipherstashOpFactoryCallNode { - readonly id: string - readonly label: string - readonly operationClass: 'additive' - readonly invariantId: string - readonly target: { readonly id: string } - readonly precheck: readonly OpStep[] - readonly execute: readonly OpStep[] - readonly postcheck: readonly OpStep[] - - // Private slot keeps the renderer-side args off the enumerable - // own-property surface; the public accessors below expose them - // read-only on the prototype, so neither `Object.keys` nor - // `canonicalizeJson` walks them. - readonly #args: AddArgs - - constructor( - table: string, - column: string, - index: CipherstashSearchIndex, - castAs: string = DEFAULT_CAST_AS, - ) { - super() - this.#args = { table, column, index, castAs } - // Property assignment order is fixed (id → label → operationClass - // → invariantId → target → precheck → execute → postcheck) so - // `JSON.stringify(call)` lays out keys in the byte order the - // baseline `ops.json` carries. - this.id = `cipherstash-codec.${table}.${column}.add-search-config.${index}` - this.label = `Enable cipherstash search on ${table}.${column}` - this.operationClass = 'additive' - this.invariantId = invariantIdFor(table, column, 'add-search-config', index) - this.target = { id: 'postgres' } - this.precheck = [] - this.execute = [ - { - description: `Register cipherstash ${index} search config for ${table}.${column}`, - sql: `SELECT eql_v2.add_search_config(${sqlLiteral(table)}, ${sqlLiteral(column)}, ${sqlLiteral(index)}, ${sqlLiteral(castAs)});`, - }, - ] - this.postcheck = [] - this.freeze() - } - - get factoryName(): 'cipherstashAddSearchConfig' { - return 'cipherstashAddSearchConfig' - } - - get table(): string { - return this.#args.table - } - - get column(): string { - return this.#args.column - } - - get index(): CipherstashSearchIndex { - return this.#args.index - } - - get castAs(): string { - return this.#args.castAs - } - - renderTypeScript(): string { - const args = { - table: this.#args.table, - column: this.#args.column, - index: this.#args.index, - ...ifDefined( - 'castAs', - this.#args.castAs !== DEFAULT_CAST_AS ? this.#args.castAs : undefined, - ), - } - return `cipherstashAddSearchConfig(${jsonToTsSource(args)})` - } -} - -/** - * `cipherstashRemoveSearchConfig` — invert - * {@link CipherstashAddSearchConfigCall} for the same (table, column, - * index) tuple. Lowers to `SELECT eql_v2.remove_search_config('
', - * '', '')`, classified `'destructive'`. - * - * No `castAs` argument — `eql_v2.remove_search_config` takes only the - * three identifying fields; the cast was applied at the index's add - * site. - */ -interface RemoveArgs { - readonly table: string - readonly column: string - readonly index: CipherstashSearchIndex -} - -export class CipherstashRemoveSearchConfigCall extends CipherstashOpFactoryCallNode { - readonly id: string - readonly label: string - readonly operationClass: 'destructive' - readonly invariantId: string - readonly target: { readonly id: string } - readonly precheck: readonly OpStep[] - readonly execute: readonly OpStep[] - readonly postcheck: readonly OpStep[] - - readonly #args: RemoveArgs - - constructor(table: string, column: string, index: CipherstashSearchIndex) { - super() - this.#args = { table, column, index } - this.id = `cipherstash-codec.${table}.${column}.remove-search-config.${index}` - this.label = `Disable cipherstash search on ${table}.${column}` - this.operationClass = 'destructive' - this.invariantId = invariantIdFor( - table, - column, - 'remove-search-config', - index, - ) - this.target = { id: 'postgres' } - this.precheck = [] - this.execute = [ - { - description: `Remove cipherstash ${index} search config for ${table}.${column}`, - sql: `SELECT eql_v2.remove_search_config(${sqlLiteral(table)}, ${sqlLiteral(column)}, ${sqlLiteral(index)});`, - }, - ] - this.postcheck = [] - this.freeze() - } - - get factoryName(): 'cipherstashRemoveSearchConfig' { - return 'cipherstashRemoveSearchConfig' - } - - get table(): string { - return this.#args.table - } - - get column(): string { - return this.#args.column - } - - get index(): CipherstashSearchIndex { - return this.#args.index - } - - renderTypeScript(): string { - return `cipherstashRemoveSearchConfig(${jsonToTsSource({ - table: this.#args.table, - column: this.#args.column, - index: this.#args.index, - })})` - } -} - -/** - * Public factory: register a cipherstash search-config row. - * - * Use from a hand-written migration when you need to wire EQL - * search-config alongside a `createTable` / `addColumn`. The - * `Encrypted` codec hook calls this factory automatically when - * planning a contract diff that adds a `searchable: true` column. - * - * Returns the {@link CipherstashAddSearchConfigCall} IR node, which - * implements `OpFactoryCall` and is itself a `SqlMigrationPlanOperation` - * (its readonly op-shaped fields are populated in the constructor) — so - * the same value flows through both the renderer (planner-time IR) and - * the runtime ops list (`Migration.operations`) without an extra - * lowering step at the call site. - */ -export function cipherstashAddSearchConfig( - args: CipherstashSearchConfigArgs, -): CipherstashAddSearchConfigCall { - return new CipherstashAddSearchConfigCall( - args.table, - args.column, - args.index, - args.castAs ?? DEFAULT_CAST_AS, - ) -} - -/** - * Public factory: invert {@link cipherstashAddSearchConfig} for the - * same (table, column, index) tuple. - * - * Returns the {@link CipherstashRemoveSearchConfigCall} IR node — see - * {@link cipherstashAddSearchConfig} for the rationale. - */ -export function cipherstashRemoveSearchConfig( - args: CipherstashSearchConfigArgs, -): CipherstashRemoveSearchConfigCall { - return new CipherstashRemoveSearchConfigCall( - args.table, - args.column, - args.index, - ) -} diff --git a/packages/prisma-next/src/migration/cipherstash-codec.ts b/packages/prisma-next/src/migration/cipherstash-codec.ts deleted file mode 100644 index 744ba2435..000000000 --- a/packages/prisma-next/src/migration/cipherstash-codec.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Control hooks for the `cipherstash:string@1` codec. - * - * Implements `CodecControlHooks.onFieldEvent` via the shared - * {@link makeCipherstashCodecHooks} factory (see - * `./codec-hooks-factory.ts` for the per-flag walk that's identical - * across every cipherstash codec). Reacts to per-field added / - * dropped / altered events as the *application* emitter diffs the - * prior contract against the new contract; the returned Calls flow - * through the SQL planner's IR alongside structural DDL and render as - * `cipherstashAddSearchConfig({...})` / - * `cipherstashRemoveSearchConfig({...})` calls in the user's - * `migration.ts` (ADR 195 two-renderer pattern). - * - * Trigger: a field uses the `cipherstash:string@1` codec. The planner - * already dispatches per `(table, field)` based on the field's - * `codecId` (new field for `'added'` / `'altered'`, prior field for - * `'dropped'`), so this hook only fires when a cipherstash field is - * involved. Per field the hook emits one - * `cipherstashAddSearchConfig` Call per enabled flag in `typeParams` - * (and one `cipherstashRemoveSearchConfig` Call per previously-enabled - * flag on drop / altered-off). - * - * Flag → EQL index mapping for the string codec: - * - * - `equality: true` → `'unique'` index - * - `freeTextSearch: true` → `'match'` index - * - * `cast_as` is `'text'` for every string-codec search-config row; the - * EQL bundle's expected cast for `eql_v2_encrypted` columns derived - * from a `text` plaintext. - */ - -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, -} from '../extension-metadata/constants' -import { makeCipherstashCodecHooks } from './codec-hooks-factory' - -export const cipherstashStringCodecHooks = makeCipherstashCodecHooks({ - flagToIndex: { - equality: 'unique', - freeTextSearch: 'match', - orderAndRange: 'ore', - }, - castAs: 'text', -}) - -/** - * Codec lifecycle hooks for `cipherstash/double@1`. The numeric codecs - * share the `{ equality, orderAndRange }` flag set and differ only in - * `cast_as` (`double` vs `big_int`). Codec ids name the underlying - * machine type (the EQL `cast_as` value) rather than the JS-language - * category; the user-facing constructor name follows the same - * naming. - */ -export const cipherstashDoubleCodecHooks = makeCipherstashCodecHooks({ - flagToIndex: { - equality: 'unique', - orderAndRange: 'ore', - }, - castAs: 'double', -}) - -/** Codec lifecycle hooks for `cipherstash/bigint@1`. */ -export const cipherstashBigIntCodecHooks = makeCipherstashCodecHooks({ - flagToIndex: { - equality: 'unique', - orderAndRange: 'ore', - }, - castAs: 'big_int', -}) - -/** - * Codec lifecycle hooks for `cipherstash/date@1`. Calendar-date plaintext - * (no time component) — flag set mirrors the numeric codecs because EQL - * supports both equality (unique-index) and order/range (ORE-index) - * predicates over dates. - */ -export const cipherstashDateCodecHooks = makeCipherstashCodecHooks({ - flagToIndex: { - equality: 'unique', - orderAndRange: 'ore', - }, - castAs: 'date', -}) - -/** - * Codec lifecycle hooks for `cipherstash/boolean@1`. Booleans only - * support equality search (a 2-value domain has no meaningful range - * predicate), so the flag set collapses to `{ equality }`. - */ -export const cipherstashBooleanCodecHooks = makeCipherstashCodecHooks({ - flagToIndex: { - equality: 'unique', - }, - castAs: 'boolean', -}) - -/** - * Codec lifecycle hooks for `cipherstash/json@1`. EQL exposes structured - * JSON predicates through the `ste_vec` (Structured Encryption Vector) - * index family — a single flag (`searchableJson`) gates the entire - * suite of containment / path-extraction operators. - */ -export const cipherstashJsonCodecHooks = makeCipherstashCodecHooks({ - flagToIndex: { - searchableJson: 'ste_vec', - }, - castAs: 'jsonb', -}) - -/** Re-export the codec ids alongside the hooks so wiring sites import them together. */ -export { - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, - CIPHERSTASH_STRING_CODEC_ID, -} diff --git a/packages/prisma-next/src/migration/codec-hooks-factory.ts b/packages/prisma-next/src/migration/codec-hooks-factory.ts deleted file mode 100644 index afe94da05..000000000 --- a/packages/prisma-next/src/migration/codec-hooks-factory.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Shared factory for every cipherstash codec's `CodecControlHooks`. - * - * Every cipherstash codec (`cipherstash/string@1`, `cipherstash/double@1`, - * `cipherstash/bigint@1`, `cipherstash/date@1`, `cipherstash/boolean@1`, - * `cipherstash/json@1`) exposes the same hook-shape: - * - * - one `cipherstashAddSearchConfig` Call per enabled flag in - * `typeParams` on `'added'` / `'altered'`-flipped-on; - * - one `cipherstashRemoveSearchConfig` Call per previously-enabled - * flag on `'dropped'` / `'altered'`-flipped-off; - * - identity `expandNativeType` (the cipherstash `nativeType` is - * always `eql_v2_encrypted`; per-flag wiring is delivered by the - * `add_search_config` rows, not by widening the column type). - * - * Each codec configures the factory with two values that vary per - * codec: - * - * - `flagToIndex` — the codec's `typeParams` flag names mapped to the - * EQL `add_search_config` index name they enable (e.g. - * `equality → 'unique'`, `freeTextSearch → 'match'`, - * `orderAndRange → 'ore'`, `searchableJson → 'ste_vec'`). - * - `castAs` — the EQL `cast_as` argument passed to - * `eql_v2.add_search_config(...)` for every flag this codec emits. - * Static per codec (e.g. string → `'text'`, double → `'double'`). - * - * The factory's `onFieldEvent` body is otherwise identical across - * codecs — collapsing the ~80-line per-flag walk into one place. The - * shared shape is the natural shape for any future cipherstash codec - * that has a per-flag → per-EQL-index mapping; the contributor-facing - * per-codec wiring template at `../../DEVELOPING.md` references this - * factory as one of the substrate calls a new codec invocation needs. - */ - -import type { - CodecControlHooks, - FieldEventContext, -} from '@prisma-next/family-sql/control' -import type { OpFactoryCall } from '@prisma-next/framework-components/control' -import { - type CipherstashSearchIndex, - cipherstashAddSearchConfig, - cipherstashRemoveSearchConfig, -} from './call-classes' - -export interface MakeCipherstashCodecHooksOptions { - /** - * `typeParams` flag names mapped to the EQL search-config index each - * enables. The factory walks every key in this record per - * `onFieldEvent` invocation; the order is irrelevant to ops.json - * because the planner re-canonicalises the call list, but stable - * key ordering keeps debug output predictable. - */ - readonly flagToIndex: Readonly> - /** - * EQL `cast_as` argument for every `add_search_config` call this - * codec emits. Static per codec (`'text'` for string, `'double'` for - * IEEE-754, `'big_int'`, `'date'`, `'boolean'`, `'jsonb'`). - */ - readonly castAs: string -} - -function isEnabled( - typeParams: Readonly> | undefined, - flag: string, -): boolean { - return typeParams !== undefined && typeParams[flag] === true -} - -/** - * Construct the `CodecControlHooks` for a cipherstash codec given its - * per-codec flag-to-index mapping and `cast_as`. - * - * Pure and synchronous — the returned hook replays deterministically - * when the application emitter re-diffs the contract. - */ -export function makeCipherstashCodecHooks( - options: MakeCipherstashCodecHooksOptions, -): CodecControlHooks { - const { flagToIndex, castAs } = options - const allFlags = Object.keys(flagToIndex) - - function onFieldEvent( - event: 'added' | 'dropped' | 'altered', - ctx: FieldEventContext, - ): readonly OpFactoryCall[] { - const { tableName, fieldName, priorField, newField } = ctx - - if (event === 'added') { - if (newField === undefined) return [] - const calls: OpFactoryCall[] = [] - for (const flag of allFlags) { - if (isEnabled(newField.typeParams, flag)) { - calls.push( - cipherstashAddSearchConfig({ - table: tableName, - column: fieldName, - index: flagToIndex[flag] as CipherstashSearchIndex, - castAs, - }), - ) - } - } - return calls - } - - if (event === 'dropped') { - if (priorField === undefined) return [] - const calls: OpFactoryCall[] = [] - for (const flag of allFlags) { - if (isEnabled(priorField.typeParams, flag)) { - calls.push( - cipherstashRemoveSearchConfig({ - table: tableName, - column: fieldName, - index: flagToIndex[flag] as CipherstashSearchIndex, - }), - ) - } - } - return calls - } - - if (priorField === undefined || newField === undefined) return [] - const calls: OpFactoryCall[] = [] - for (const flag of allFlags) { - const before = isEnabled(priorField.typeParams, flag) - const after = isEnabled(newField.typeParams, flag) - if (after && !before) { - calls.push( - cipherstashAddSearchConfig({ - table: tableName, - column: fieldName, - index: flagToIndex[flag] as CipherstashSearchIndex, - castAs, - }), - ) - } else if (before && !after) { - calls.push( - cipherstashRemoveSearchConfig({ - table: tableName, - column: fieldName, - index: flagToIndex[flag] as CipherstashSearchIndex, - }), - ) - } - } - return calls - } - - /** - * The DDL type for any cipherstash column is always - * `eql_v2_encrypted` regardless of `typeParams` flags: the - * search-config wiring is delivered by the codec hook's - * `cipherstashAddSearchConfig` Calls (separate rows in - * `eql_v2_configuration`), not by the column type itself. Returning - * `nativeType` unchanged tells the planner "no expansion required" — - * see `expandParameterizedTypeSql` in - * `packages/3-targets/3-targets/postgres/src/core/migrations/planner-ddl-builders.ts`, - * which only requires this hook to *exist* for any column carrying - * `typeParams`. - */ - const expandNativeType: NonNullable< - CodecControlHooks['expandNativeType'] - > = ({ nativeType }) => nativeType - - return { onFieldEvent, expandNativeType } -} diff --git a/packages/prisma-next/src/migration/eql-bundle.ts b/packages/prisma-next/src/migration/eql-bundle.ts deleted file mode 100644 index a297ccd92..000000000 --- a/packages/prisma-next/src/migration/eql-bundle.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Vendored CipherStash EQL bundle SQL. - * - * The CipherStash team ships the bundle as a single Postgres script - * (~7,650 lines, currently `eql-2.3.1`) that creates the `eql_v2` - * schema, the `eql_v2_*` composite types / domains, the - * `eql_v2_configuration` table, plus roughly 169 functions, 46 - * operators, 4 casts, and 9 operator classes / families. CipherStash - * treats the bundle as one indivisible artefact: its contents flow - * into the `cipherstash:install-eql-bundle-v1` migration op - * **byte-for-byte** with no fork or split. - * - * The bundle source lives in {@link ./eql-install.generated} — a - * single committed `.generated.ts` file produced by - * `scripts/vendor-eql-install.ts`. Bumping the bundle version - * regenerates that file and re-runs - * `pnpm --filter @prisma-next/extension-cipherstash test` to confirm - * descriptor self-consistency. - * - * Hash impact: the bundle string lives inside the `installEqlBundle` - * migration op's `execute[]`, **not** in `contract.json` — so swapping - * the bundle changes `migrationHash` (consumed by the runner at apply - * time, see `packages/1-framework/3-tooling/migration/src/hash.ts`) - * but leaves `headRef.hash` (which only digests the contract IR) - * untouched. The descriptor self-consistency test in - * `test/descriptor.test.ts` re-runs `assertDescriptorSelfConsistency` - * to confirm that invariant. - */ -export { - EQL_INSTALL_SQL as EQL_BUNDLE_SQL, - EQL_INSTALL_VERSION, -} from './eql-install.generated' diff --git a/packages/prisma-next/src/migration/eql-install.generated.ts b/packages/prisma-next/src/migration/eql-install.generated.ts deleted file mode 100644 index 8fd3e2f58..000000000 --- a/packages/prisma-next/src/migration/eql-install.generated.ts +++ /dev/null @@ -1,7655 +0,0 @@ -// @generated — DO NOT EDIT. -// Source: scripts/vendor-eql-install.ts -// Bundle pinned version: eql-2.3.1 -// -// This file is committed to source control so dev environments and -// offline builds work without network access. Regenerate with -// `pnpm vendor-eql-install` after bumping EQL_VERSION in the script. - -export const EQL_INSTALL_VERSION = 'eql-2.3.1' as const - -export const EQL_INSTALL_SQL: string = `--! @file schema.sql ---! @brief EQL v2 schema creation ---! ---! Creates the eql_v2 schema which contains all Encrypt Query Language ---! functions, types, and tables. Drops existing schema if present to ---! support clean reinstallation. ---! ---! @warning DROP SCHEMA CASCADE will remove all objects in the schema ---! @note All EQL objects (functions, types, tables) reside in eql_v2 schema - ---! @brief Drop existing EQL v2 schema ---! @warning CASCADE will drop all dependent objects -DROP SCHEMA IF EXISTS eql_v2 CASCADE; - ---! @brief Create EQL v2 schema ---! @note All EQL functions and types will be created in this schema -CREATE SCHEMA eql_v2; - ---! @brief Composite type for encrypted column data ---! ---! Core type used for all encrypted columns in EQL. Stores encrypted data as JSONB ---! with the following structure: ---! - \`c\`: ciphertext (base64-encoded encrypted value) ---! - \`i\`: index terms (searchable metadata for encrypted searches) ---! - \`k\`: key ID (identifier for encryption key) ---! - \`m\`: metadata (additional encryption metadata) ---! ---! Created in public schema to persist independently of eql_v2 schema lifecycle. ---! Customer data columns use this type, so it must not be dropped if data exists. ---! ---! @note DO NOT DROP this type unless absolutely certain no encrypted data uses it ---! @see eql_v2.ciphertext ---! @see eql_v2.meta_data ---! @see eql_v2.add_column -DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eql_v2_encrypted') THEN - CREATE TYPE public.eql_v2_encrypted AS ( - data jsonb - ); - END IF; - END -$$; - - - - - - - - - - ---! @brief Bloom filter index term type ---! ---! Domain type representing Bloom filter bit arrays stored as smallint arrays. ---! Used for pattern-match encrypted searches via the 'match' index type. ---! The filter is stored in the 'bf' field of encrypted data payloads. ---! ---! @see eql_v2.add_search_config ---! @see eql_v2."~~" ---! @note This is a transient type used only during query execution -CREATE DOMAIN eql_v2.bloom_filter AS smallint[]; - - - ---! @brief ORE block term type for Order-Revealing Encryption ---! ---! Composite type representing a single ORE (Order-Revealing Encryption) block term. ---! Stores encrypted data as bytea that enables range comparisons without decryption. ---! ---! @see eql_v2.ore_block_u64_8_256 ---! @see eql_v2.compare_ore_block_u64_8_256_term -CREATE TYPE eql_v2.ore_block_u64_8_256_term AS ( - bytes bytea -); - - ---! @brief ORE block index term type for range queries ---! ---! Composite type containing an array of ORE block terms. Used for encrypted ---! range queries via the 'ore' index type. The array is stored in the 'ob' field ---! of encrypted data payloads. ---! ---! @see eql_v2.add_search_config ---! @see eql_v2.compare_ore_block_u64_8_256_terms ---! @note This is a transient type used only during query execution -CREATE TYPE eql_v2.ore_block_u64_8_256 AS ( - terms eql_v2.ore_block_u64_8_256_term[] -); - ---! @brief HMAC-SHA256 index term type ---! ---! Domain type representing HMAC-SHA256 hash values. ---! Used for exact-match encrypted searches via the 'unique' index type. ---! The hash is stored in the 'hm' field of encrypted data payloads. ---! ---! @see eql_v2.add_search_config ---! @note This is a transient type used only during query execution -CREATE DOMAIN eql_v2.hmac_256 AS text; - ---! @file src/ste_vec/types.sql ---! @brief Domain type for individual STE-vec entries ---! ---! Defines \`eql_v2.ste_vec_entry\` as a DOMAIN over \`jsonb\` constrained to the ---! shape of a single element inside an \`sv\` array — a JSON object that ---! carries at minimum a selector field (\`s\`). This is the type returned by ---! the \`->\` operator on \`eql_v2_encrypted\` (a single sv element extracted by ---! selector) and the type accepted by sv-element extractors such as ---! \`eql_v2.ore_cllw(eql_v2.ste_vec_entry)\` and ---! \`eql_v2.hmac_256(eql_v2.ste_vec_entry)\`. ---! ---! Why a separate type. Before #219, the \`(eql_v2_encrypted)\` overloads of ---! sv-element extractors read fields like \`oc\` off the root \`data\` jsonb, ---! which is misleading: a root \`EncryptedPayload\` or \`SteVecPayload\` (the ---! shapes that an actual \`eql_v2_encrypted\` column value carries) never has ---! \`oc\` at the root. The previous pattern only worked because the \`->\` ---! operator merged ste-vec entry fields into a fake root-shaped payload ---! before the extractor ran. This domain type makes the distinction ---! explicit: \`eql_v2_encrypted\` is the root shape; \`eql_v2.ste_vec_entry\` ---! is the per-entry shape; extractors are typed accordingly. ---! ---! @note The CHECK constraint reflects the cipherstash-suite emission ---! contract: ---! - \`s\` (selector — column-name HMAC) and \`c\` (ciphertext) are ---! emitted on every sv element. ---! - Each sv element carries **exactly one** of \`hm\` (HMAC-256, for ---! hash-equality queries) or \`oc\` (CLLW ORE, for ordered queries) ---! — they are mutually exclusive. A given selector / field is ---! configured for one mode or the other; the crypto layer emits ---! the corresponding term and only that term. ---! Other fields (\`a\` for array marker, etc.) are allowed but not ---! required. ---! ---! @see src/operators/->.sql ---! @see src/ore_cllw/functions.sql ---! @see src/hmac_256/functions.sql -CREATE DOMAIN eql_v2.ste_vec_entry AS jsonb - CHECK ( - jsonb_typeof(VALUE) = 'object' - AND VALUE ? 's' - AND VALUE ? 'c' - AND (VALUE ? 'hm') <> (VALUE ? 'oc') - ); - - ---! @brief Domain type for an STE-vec containment needle ---! ---! \`eql_v2.stevec_query\` is a query-shaped sv payload: a top-level ---! \`{"sv": [...]}\` object whose elements carry selector + index ---! terms but **never** a ciphertext (\`c\`) field. Containment (\`@>\`) ---! against an \`eql_v2_encrypted\` column is structurally typed ---! through this domain so the call site reads as "match against an ---! sv query", not "compare two encrypted values". ---! ---! Compared to \`eql_v2.ste_vec_entry\` (single sv element with \`s\`, ---! \`c\`, and \`hm\` XOR \`oc\`), \`stevec_query\` is the wrapping ---! \`{"sv": [...]}\` payload: it forbids \`c\` on every element but ---! otherwise keeps the same per-element contract — each element must ---! carry a selector \`s\` and exactly one deterministic term (\`hm\` XOR ---! \`oc\`). This mirrors the \`SteVecQueryElement\` JSON schema and stops ---! selector-only needles (e.g. \`{"sv":[{"s":"x"}]}\`) from casting and ---! then matching every row through the bare \`jsonb @>\` implementation. ---! The implementation of \`ste_vec_contains\` ignores \`c\` either way, ---! but typing the needle as \`stevec_query\` documents the contract at ---! the API surface. ---! ---! @note Constructing a \`stevec_query\` literal from inline JSON works ---! via the standard DOMAIN cast: ---! \`'{"sv":[{"s":"","hm":""}]}'::eql_v2.stevec_query\` ---! Casting an \`eql_v2_encrypted\` value strips \`c\` fields from ---! each sv element — see \`eql_v2.to_stevec_query\`. ---! ---! @see eql_v2.to_stevec_query ---! @see src/operators/@>.sql -CREATE DOMAIN eql_v2.stevec_query AS jsonb - CHECK ( - jsonb_typeof(VALUE) = 'object' - AND VALUE ? 'sv' - AND jsonb_typeof(VALUE -> 'sv') = 'array' - -- No element may carry a ciphertext (\`c\`) — this is a query, not a value. - AND NOT jsonb_path_exists(VALUE, '$.sv[*] ? (exists(@.c))'::jsonpath) - -- Every element must carry a selector (\`s\`) ... - AND NOT jsonb_path_exists(VALUE, '$.sv[*] ? (!exists(@.s))'::jsonpath) - -- ... and exactly one deterministic term — \`hm\` XOR \`oc\` — matching - -- the \`ste_vec_entry\` emission contract and the \`SteVecQueryElement\` - -- JSON schema. Rejects selector-only needles that would otherwise - -- cast and then match every row via the bare \`jsonb @>\` body. - AND NOT jsonb_path_exists(VALUE, '$.sv[*] ? (exists(@.hm) && exists(@.oc))'::jsonpath) - AND NOT jsonb_path_exists(VALUE, '$.sv[*] ? (!exists(@.hm) && !exists(@.oc))'::jsonpath) - ); - - ---! @brief Convert an \`eql_v2_encrypted\` to a \`stevec_query\` needle ---! ---! Normalises each sv element down to the matching-relevant fields: ---! \`s\` (selector) plus exactly one of \`hm\` / \`oc\`. Other fields ---! (\`c\` ciphertext, \`a\` array marker, \`i\`/\`v\` envelope metadata, anything ---! else cipherstash-client might emit) are stripped. This is the ---! canonical needle shape for \`@>\` containment — matching the contract ---! that containment compares by selector + deterministic term and ---! ignores everything else. ---! ---! Designed for use as a functional GIN index expression: a single ---! \`GIN (eql_v2.to_stevec_query(col)::jsonb jsonb_path_ops)\` index ---! covers containment queries against any selector (both hm-bearing ---! and oc-bearing — XOR-aware), and the typed \`@>\` overloads inline ---! to a native \`jsonb @>\` on the same expression so the planner ---! engages Bitmap Index Scan structurally. ---! ---! @param e eql_v2_encrypted Source encrypted payload ---! @return eql_v2.stevec_query Query-shaped needle, sv elements ---! normalised to \`{s, hm}\` or \`{s, oc}\`. ---! ---! @example ---! -- Functional GIN index — canonical containment recipe ---! CREATE INDEX ON users USING gin ( ---! eql_v2.to_stevec_query(encrypted_doc)::jsonb jsonb_path_ops ---! ); ---! ---! -- Cross-row containment ---! SELECT a.* ---! FROM docs a, docs b ---! WHERE a.encrypted_doc @> b.encrypted_doc::eql_v2.stevec_query ---! AND b.id = 42; ---! ---! @see eql_v2.stevec_query ---! @see eql_v2."@>"(eql_v2_encrypted, eql_v2.stevec_query) -CREATE FUNCTION eql_v2.to_stevec_query(e eql_v2_encrypted) - RETURNS eql_v2.stevec_query - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT jsonb_build_object( - 'sv', - coalesce( - (SELECT jsonb_agg( - jsonb_strip_nulls( - jsonb_build_object( - 's', elem -> 's', - 'hm', elem -> 'hm', - 'oc', elem -> 'oc' - ) - ) - ) - FROM jsonb_array_elements((e).data -> 'sv') AS elem), - '[]'::jsonb - ) - )::eql_v2.stevec_query -$$; - -CREATE CAST (eql_v2_encrypted AS eql_v2.stevec_query) - WITH FUNCTION eql_v2.to_stevec_query - AS ASSIGNMENT; - ---! @file crypto.sql ---! @brief PostgreSQL pgcrypto extension enablement ---! ---! Enables the pgcrypto extension which provides cryptographic functions ---! used by EQL for hashing and other cryptographic operations. ---! ---! Installs pgcrypto into the \`extensions\` schema (Supabase convention) to ---! avoid the \`extension_in_public\` lint. Every EQL function that uses ---! pgcrypto has \`pg_catalog, extensions, public\` on its \`search_path\`, so a ---! pre-existing install in \`public\` keeps working — and a pre-existing ---! install anywhere else will be rejected at install time rather than ---! failing later inside an encrypted comparison. ---! ---! @note pgcrypto provides functions like digest(), hmac(), gen_random_bytes() ---! @note If pgcrypto is already installed in \`public\`, EQL works but emits ---! a NOTICE recommending \`ALTER EXTENSION pgcrypto SET SCHEMA extensions\`. ---! @note If pgcrypto is already installed in any other schema, install ---! fails. Relocate it first with \`ALTER EXTENSION pgcrypto SET SCHEMA ---! extensions\` (or move it into \`public\` if compatibility with other ---! consumers requires it). - ---! @brief Create extensions schema (Supabase convention) -CREATE SCHEMA IF NOT EXISTS extensions; - ---! @brief Enable pgcrypto extension and validate its schema -DO $$ -DECLARE - pgcrypto_schema name; -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN - CREATE EXTENSION pgcrypto WITH SCHEMA extensions; - END IF; - - SELECT n.nspname INTO pgcrypto_schema - FROM pg_extension e - JOIN pg_namespace n ON n.oid = e.extnamespace - WHERE e.extname = 'pgcrypto'; - - IF pgcrypto_schema = 'extensions' THEN - -- expected location, nothing to say - NULL; - ELSIF pgcrypto_schema = 'public' THEN - RAISE NOTICE - 'pgcrypto is installed in the \`public\` schema. EQL works against this layout, ' - 'but Supabase splinter will flag it as \`extension_in_public\`. Move it with: ' - 'ALTER EXTENSION pgcrypto SET SCHEMA extensions'; - ELSE - RAISE EXCEPTION - 'pgcrypto is installed in schema \`%\`, which is not on the EQL function search_path ' - '(pg_catalog, extensions, public). EQL cryptographic operations would fail at ' - 'runtime. Relocate the extension before installing EQL: ' - 'ALTER EXTENSION pgcrypto SET SCHEMA extensions', - pgcrypto_schema; - END IF; -END $$; - ---! @brief Extract ciphertext from encrypted JSONB value ---! ---! Extracts the ciphertext (c field) from a raw JSONB encrypted value. ---! The ciphertext is the base64-encoded encrypted data. ---! ---! @param jsonb containing encrypted EQL payload ---! @return Text Base64-encoded ciphertext string ---! @throws Exception if 'c' field is not present in JSONB ---! ---! @example ---! -- Extract ciphertext from JSONB literal ---! SELECT eql_v2.ciphertext('{"c":"AQIDBA==","i":{"unique":"..."}}'::jsonb); ---! ---! @see eql_v2.ciphertext(eql_v2_encrypted) ---! @see eql_v2.meta_data -CREATE FUNCTION eql_v2.ciphertext(val jsonb) - RETURNS text - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF val ? 'c' THEN - RETURN val->>'c'; - END IF; - RAISE 'Expected a ciphertext (c) value in json: %', val; - END; -$$ LANGUAGE plpgsql; - ---! @brief Extract ciphertext from encrypted column value ---! ---! Extracts the ciphertext from an encrypted column value. Convenience ---! overload that unwraps eql_v2_encrypted type and delegates to JSONB version. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return Text Base64-encoded ciphertext string ---! @throws Exception if encrypted value is malformed ---! ---! @example ---! -- Extract ciphertext from encrypted column ---! SELECT eql_v2.ciphertext(encrypted_email) FROM users; ---! ---! @see eql_v2.ciphertext(jsonb) ---! @see eql_v2.meta_data -CREATE FUNCTION eql_v2.ciphertext(val eql_v2_encrypted) - RETURNS text - IMMUTABLE STRICT PARALLEL SAFE - LANGUAGE SQL -AS $$ - SELECT eql_v2.ciphertext(val.data); -$$; - ---! @brief State transition function for grouped_value aggregate ---! @internal ---! ---! Returns the first non-null value encountered. Used as state function ---! for the grouped_value aggregate to select first value in each group. ---! ---! @param $1 JSONB Accumulated state (first non-null value found) ---! @param $2 JSONB New value from current row ---! @return JSONB First non-null value (state or new value) ---! ---! @see eql_v2.grouped_value -CREATE FUNCTION eql_v2._first_grouped_value(jsonb, jsonb) -RETURNS jsonb -AS $$ - SELECT COALESCE($1, $2); -$$ LANGUAGE sql IMMUTABLE; - ---! @brief Return first non-null encrypted value in a group ---! ---! Aggregate function that returns the first non-null encrypted value ---! encountered within a GROUP BY clause. Useful for deduplication or ---! selecting representative values from grouped encrypted data. ---! ---! @param input JSONB Encrypted values to aggregate ---! @return JSONB First non-null encrypted value in group ---! ---! @example ---! -- Get first email per user group ---! SELECT user_id, eql_v2.grouped_value(encrypted_email) ---! FROM user_emails ---! GROUP BY user_id; ---! ---! -- Deduplicate encrypted values ---! SELECT DISTINCT ON (user_id) ---! user_id, ---! eql_v2.grouped_value(encrypted_ssn) as primary_ssn ---! FROM user_records ---! GROUP BY user_id; ---! ---! @see eql_v2._first_grouped_value -CREATE AGGREGATE eql_v2.grouped_value(jsonb) ( - SFUNC = eql_v2._first_grouped_value, - STYPE = jsonb -); - ---! @brief Add validation constraint to encrypted column ---! ---! Adds a CHECK constraint to ensure column values conform to encrypted data ---! structure. Constraint uses eql_v2.check_encrypted to validate format. ---! Called automatically by eql_v2.add_column. ---! ---! @param table_name TEXT Name of table containing the column ---! @param column_name TEXT Name of column to constrain ---! @return Void ---! ---! @example ---! -- Manually add constraint (normally done by add_column) ---! SELECT eql_v2.add_encrypted_constraint('users', 'encrypted_email'); ---! ---! -- Resulting constraint: ---! -- ALTER TABLE users ADD CONSTRAINT eql_v2_encrypted_check_encrypted_email ---! -- CHECK (eql_v2.check_encrypted(encrypted_email)); ---! ---! @see eql_v2.add_column ---! @see eql_v2.remove_encrypted_constraint -CREATE FUNCTION eql_v2.add_encrypted_constraint(table_name TEXT, column_name TEXT) - RETURNS void - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - EXECUTE format('ALTER TABLE %I ADD CONSTRAINT eql_v2_encrypted_constraint_%I_%I CHECK (eql_v2.check_encrypted(%I))', table_name, table_name, column_name, column_name); - EXCEPTION - WHEN duplicate_table THEN - WHEN duplicate_object THEN - RAISE NOTICE 'Constraint \`eql_v2_encrypted_constraint_%_%\` already exists, skipping', table_name, column_name; - END; -$$ LANGUAGE plpgsql; - ---! @brief Remove validation constraint from encrypted column ---! ---! Removes the CHECK constraint that validates encrypted data structure. ---! Called automatically by eql_v2.remove_column. Uses IF EXISTS to avoid ---! errors if constraint doesn't exist. ---! ---! @param table_name TEXT Name of table containing the column ---! @param column_name TEXT Name of column to unconstrain ---! @return Void ---! ---! @example ---! -- Manually remove constraint (normally done by remove_column) ---! SELECT eql_v2.remove_encrypted_constraint('users', 'encrypted_email'); ---! ---! @see eql_v2.remove_column ---! @see eql_v2.add_encrypted_constraint -CREATE FUNCTION eql_v2.remove_encrypted_constraint(table_name TEXT, column_name TEXT) - RETURNS void - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - EXECUTE format('ALTER TABLE %I DROP CONSTRAINT IF EXISTS eql_v2_encrypted_constraint_%I_%I', table_name, table_name, column_name); - END; -$$ LANGUAGE plpgsql; - ---! @brief Extract metadata from encrypted JSONB value ---! ---! Extracts index terms (i) and version (v) from a raw JSONB encrypted value. ---! Returns metadata object containing searchable index terms without ciphertext. ---! ---! @param jsonb containing encrypted EQL payload ---! @return JSONB Metadata object with 'i' (index terms) and 'v' (version) fields ---! ---! @example ---! -- Extract metadata to inspect index terms ---! SELECT eql_v2.meta_data('{"c":"...","i":{"unique":"abc123"},"v":1}'::jsonb); ---! -- Returns: {"i":{"unique":"abc123"},"v":1} ---! ---! @see eql_v2.meta_data(eql_v2_encrypted) ---! @see eql_v2.ciphertext -CREATE FUNCTION eql_v2.meta_data(val jsonb) - RETURNS jsonb - IMMUTABLE STRICT PARALLEL SAFE - LANGUAGE SQL -AS $$ - SELECT jsonb_build_object('i', val->'i', 'v', val->'v'); -$$; - ---! @brief Extract metadata from encrypted column value ---! ---! Extracts index terms and version from an encrypted column value. ---! Convenience overload that unwraps eql_v2_encrypted type and ---! delegates to JSONB version. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return JSONB Metadata object with 'i' (index terms) and 'v' (version) fields ---! ---! @example ---! -- Inspect index terms for encrypted column ---! SELECT user_id, eql_v2.meta_data(encrypted_email) as email_metadata ---! FROM users; ---! ---! @see eql_v2.meta_data(jsonb) ---! @see eql_v2.ciphertext -CREATE FUNCTION eql_v2.meta_data(val eql_v2_encrypted) - RETURNS jsonb - IMMUTABLE STRICT PARALLEL SAFE - LANGUAGE SQL -AS $$ - SELECT eql_v2.meta_data(val.data); -$$; - --- AUTOMATICALLY GENERATED FILE - ---! @file common.sql ---! @brief Common utility functions ---! ---! Provides general-purpose utility functions used across EQL: ---! - Constant-time bytea comparison for security ---! - JSONB to bytea array conversion ---! - Logging helpers for debugging and testing - - ---! @brief Constant-time comparison of bytea values ---! @internal ---! ---! Compares two bytea values in constant time to prevent timing attacks. ---! Always checks all bytes even after finding differences, maintaining ---! consistent execution time regardless of where differences occur. ---! ---! @param a bytea First value to compare ---! @param b bytea Second value to compare ---! @return boolean True if values are equal ---! ---! @note Returns false immediately if lengths differ (length is not secret) ---! @note Used for secure comparison of cryptographic values -CREATE FUNCTION eql_v2.bytea_eq(a bytea, b bytea) RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - result boolean; - differing bytea; -BEGIN - - -- Check if the bytea values are the same length - IF LENGTH(a) != LENGTH(b) THEN - RETURN false; - END IF; - - -- Compare each byte in the bytea values - result := true; - FOR i IN 1..LENGTH(a) LOOP - IF SUBSTRING(a FROM i FOR 1) != SUBSTRING(b FROM i FOR 1) THEN - result := result AND false; - END IF; - END LOOP; - - RETURN result; -END; -$$ LANGUAGE plpgsql; - - ---! @brief Convert JSONB hex array to bytea array ---! @internal ---! ---! Converts a JSONB array of hex-encoded strings into a PostgreSQL bytea array. ---! Used for deserializing binary data (like ORE terms) from JSONB storage. ---! ---! @param jsonb JSONB array of hex-encoded strings ---! @return bytea[] Array of decoded binary values ---! ---! @note Returns NULL if input is JSON null ---! @note Each array element is hex-decoded to bytea -CREATE FUNCTION eql_v2.jsonb_array_to_bytea_array(val jsonb) -RETURNS bytea[] - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - terms_arr bytea[]; -BEGIN - IF jsonb_typeof(val) = 'null' THEN - RETURN NULL; - END IF; - - SELECT array_agg(decode(value::text, 'hex')::bytea) - INTO terms_arr - FROM jsonb_array_elements_text(val) AS value; - - RETURN terms_arr; -END; -$$ LANGUAGE plpgsql; - - ---! @brief Log message for debugging ---! ---! Convenience function to emit log messages during testing and debugging. ---! Uses RAISE NOTICE to output messages to PostgreSQL logs. ---! ---! @param text Message to log ---! ---! @note Primarily used in tests and development ---! @see eql_v2.log(text, text) for contextual logging -CREATE FUNCTION eql_v2.log(s text) - RETURNS void - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RAISE NOTICE '[LOG] %', s; -END; -$$ LANGUAGE plpgsql; - - ---! @brief Log message with context ---! ---! Overload of log function that includes context label for better ---! log organization during testing. ---! ---! @param ctx text Context label (e.g., test name, module name) ---! @param s text Message to log ---! ---! @note Format: "[LOG] {ctx} {message}" ---! @see eql_v2.log(text) -CREATE FUNCTION eql_v2.log(ctx text, s text) - RETURNS void - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RAISE NOTICE '[LOG] % %', ctx, s; -END; -$$ LANGUAGE plpgsql; - ---! @brief CLLW ORE index term type for STE-vec range queries ---! ---! Composite type for CLLW (Copyless Logarithmic Width) Order-Revealing ---! Encryption. The ciphertext is stored in the \`oc\` field of encrypted data ---! payloads (Standard-mode \`ste_vec\` elements). Used by \`eql_v2.compare\` and ---! the range operators (\`<\`, \`<=\`, \`>\`, \`>=\`) when the payload carries an ---! \`oc\` term. ---! ---! The wire-format \`oc\` value is a hex string with a leading domain-tag byte ---! (\`0x00\` numeric, \`0x01\` string) followed by the CLLW ciphertext. The ---! decoded \`bytes\` field on this composite carries the full byte string ---! including the tag — the comparator is variable-length capable, so numeric ---! and string values within the same column are ordered correctly: the ---! domain tag separates the two ranges (numeric < string) and the ---! within-domain comparison falls through to the CLLW per-byte protocol. ---! ---! @see eql_v2.add_search_config ---! @see eql_v2.compare_ore_cllw ---! @note This is a transient type used only during query execution -CREATE TYPE eql_v2.ore_cllw AS ( - bytes bytea -); - ---! @brief Extract HMAC-SHA256 index term from JSONB payload ---! ---! Extracts the HMAC-SHA256 hash value from the 'hm' field of an encrypted ---! data payload. Inlinable single-statement SQL — the planner can fold this ---! into the calling query so functional hash indexes built on ---! \`eql_v2.hmac_256(col)\` engage structurally. ---! ---! @param jsonb containing encrypted EQL payload ---! @return eql_v2.hmac_256 HMAC-SHA256 hash value, or NULL when \`hm\` is absent ---! ---! @note Returns NULL when the payload lacks \`hm\`. Callers that need to ---! surface misconfiguration loudly should use ---! \`eql_v2.hash_encrypted\` (\`GROUP BY\` / \`DISTINCT\` / hash joins) ---! which raises with a clear message when \`hm\` is missing. ---! ---! @see eql_v2.has_hmac_256 ---! @see eql_v2.compare_hmac_256 ---! @see eql_v2.hash_encrypted -CREATE FUNCTION eql_v2.hmac_256(val jsonb) - RETURNS eql_v2.hmac_256 - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT (val ->> 'hm')::eql_v2.hmac_256 -$$; - - ---! @brief Check if JSONB payload contains HMAC-SHA256 index term ---! ---! Tests whether the encrypted data payload includes an 'hm' field, ---! indicating an HMAC-SHA256 hash is available for exact-match queries. ---! ---! @param jsonb containing encrypted EQL payload ---! @return Boolean True if 'hm' field is present and non-null ---! ---! @see eql_v2.hmac_256 -CREATE FUNCTION eql_v2.has_hmac_256(val jsonb) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN val ->> 'hm' IS NOT NULL; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Check if encrypted column value contains HMAC-SHA256 index term ---! ---! Tests whether an encrypted column value includes an HMAC-SHA256 hash ---! by checking its underlying JSONB data field. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return Boolean True if HMAC-SHA256 hash is present ---! ---! @see eql_v2.has_hmac_256(jsonb) -CREATE FUNCTION eql_v2.has_hmac_256(val eql_v2_encrypted) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.has_hmac_256(val.data); - END; -$$ LANGUAGE plpgsql; - - - ---! @brief Extract HMAC-SHA256 index term from encrypted column value ---! ---! Extracts the HMAC-SHA256 hash from an encrypted column value. Inlinable ---! single-statement SQL — see the jsonb overload for the rationale. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return eql_v2.hmac_256 HMAC-SHA256 hash value, or NULL when \`hm\` is absent ---! ---! @see eql_v2.hmac_256(jsonb) -CREATE FUNCTION eql_v2.hmac_256(val eql_v2_encrypted) - RETURNS eql_v2.hmac_256 - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT ((val).data ->> 'hm')::eql_v2.hmac_256 -$$; - - ---! @brief Extract HMAC-SHA256 index term from a ste_vec entry ---! ---! Extracts the HMAC from the \`hm\` field of an \`sv\` element extracted via ---! the \`->\` operator. Inlinable. The recipe for field-level equality on ---! encrypted JSON is: ---! ---! @example ---! -- Functional hash index ---! CREATE INDEX ON users USING hash (eql_v2.hmac_256(data -> '')); ---! -- Bare-form predicate matches via the inlined \`=\` on ste_vec_entry ---! SELECT * FROM users WHERE data -> '' = $1::eql_v2.ste_vec_entry; ---! ---! @param entry eql_v2.ste_vec_entry STE-vec entry (extracted via \`->\`) ---! @return eql_v2.hmac_256 HMAC value, or NULL when \`hm\` is absent ---! ---! @see eql_v2.has_hmac_256 ---! @see src/operators/->.sql -CREATE FUNCTION eql_v2.hmac_256(entry eql_v2.ste_vec_entry) - RETURNS eql_v2.hmac_256 - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT (entry ->> 'hm')::eql_v2.hmac_256 -$$; - - ---! @brief Check if a ste_vec entry contains an HMAC-SHA256 index term ---! ---! @param entry eql_v2.ste_vec_entry STE-vec entry ---! @return Boolean True if \`hm\` field is present and non-null -CREATE FUNCTION eql_v2.has_hmac_256(entry eql_v2.ste_vec_entry) - RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT entry ->> 'hm' IS NOT NULL -$$; - - - - ---! @brief Convert JSONB array to ORE block composite type ---! @internal ---! ---! Converts a JSONB array of hex-encoded ORE terms from the CipherStash Proxy ---! payload into the PostgreSQL composite type used for ORE operations. ---! ---! @param val JSONB Array of hex-encoded ORE block terms ---! @return eql_v2.ore_block_u64_8_256 ORE block composite type, or NULL if input is null ---! ---! @see eql_v2.ore_block_u64_8_256(jsonb) -CREATE FUNCTION eql_v2.jsonb_array_to_ore_block_u64_8_256(val jsonb) -RETURNS eql_v2.ore_block_u64_8_256 - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - terms eql_v2.ore_block_u64_8_256_term[]; -BEGIN - IF jsonb_typeof(val) = 'null' THEN - RETURN NULL; - END IF; - - SELECT array_agg(ROW(b)::eql_v2.ore_block_u64_8_256_term) - INTO terms - FROM unnest(eql_v2.jsonb_array_to_bytea_array(val)) AS b; - - RETURN ROW(terms)::eql_v2.ore_block_u64_8_256; -END; -$$ LANGUAGE plpgsql; - - ---! @brief Extract ORE block index term from JSONB payload ---! ---! Extracts the ORE block array from the 'ob' field of an encrypted ---! data payload. Used internally for range query comparisons. ---! ---! @param jsonb containing encrypted EQL payload ---! @return eql_v2.ore_block_u64_8_256 ORE block index term ---! @throws Exception if 'ob' field is missing when ore index is expected ---! ---! @see eql_v2.has_ore_block_u64_8_256 ---! @see eql_v2.compare_ore_block_u64_8_256 -CREATE FUNCTION eql_v2.ore_block_u64_8_256(val jsonb) - RETURNS eql_v2.ore_block_u64_8_256 - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF val IS NULL THEN - RETURN NULL; - END IF; - - IF eql_v2.has_ore_block_u64_8_256(val) THEN - RETURN eql_v2.jsonb_array_to_ore_block_u64_8_256(val->'ob'); - END IF; - RAISE 'Expected an ore index (ob) value in json: %', val; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Extract ORE block index term from encrypted column value ---! ---! Extracts the ORE block from an encrypted column value by accessing ---! its underlying JSONB data field. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return eql_v2.ore_block_u64_8_256 ORE block index term ---! ---! @see eql_v2.ore_block_u64_8_256(jsonb) -CREATE FUNCTION eql_v2.ore_block_u64_8_256(val eql_v2_encrypted) - RETURNS eql_v2.ore_block_u64_8_256 - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.ore_block_u64_8_256(val.data); - END; -$$ LANGUAGE plpgsql; - - ---! @brief Check if JSONB payload contains ORE block index term ---! ---! Tests whether the encrypted data payload includes an 'ob' field, ---! indicating an ORE block is available for range queries. ---! ---! @param jsonb containing encrypted EQL payload ---! @return Boolean True if 'ob' field is present and non-null ---! ---! @see eql_v2.ore_block_u64_8_256 -CREATE FUNCTION eql_v2.has_ore_block_u64_8_256(val jsonb) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN val ->> 'ob' IS NOT NULL; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Check if encrypted column value contains ORE block index term ---! ---! Tests whether an encrypted column value includes an ORE block ---! by checking its underlying JSONB data field. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return Boolean True if ORE block is present ---! ---! @see eql_v2.has_ore_block_u64_8_256(jsonb) -CREATE FUNCTION eql_v2.has_ore_block_u64_8_256(val eql_v2_encrypted) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.has_ore_block_u64_8_256(val.data); - END; -$$ LANGUAGE plpgsql; - - - ---! @brief Compare two ORE block terms using cryptographic comparison ---! @internal ---! ---! Performs a three-way comparison (returns -1/0/1) of individual ORE block terms ---! using the ORE cryptographic protocol. Compares PRP and PRF blocks to determine ---! ordering without decryption. ---! ---! @param a eql_v2.ore_block_u64_8_256_term First ORE term to compare ---! @param b eql_v2.ore_block_u64_8_256_term Second ORE term to compare ---! @return Integer -1 if a < b, 0 if a = b, 1 if a > b ---! @throws Exception if ciphertexts are different lengths ---! ---! @note Uses AES-ECB encryption for bit comparisons per ORE protocol ---! @see eql_v2.compare_ore_block_u64_8_256_terms -CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_term(a eql_v2.ore_block_u64_8_256_term, b eql_v2.ore_block_u64_8_256_term) - RETURNS integer - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - eq boolean := true; - unequal_block smallint := 0; - hash_key bytea; - data_block bytea; - encrypt_block bytea; - target_block bytea; - - left_block_size CONSTANT smallint := 16; - right_block_size CONSTANT smallint := 32; - right_offset CONSTANT smallint := 136; -- 8 * 17 - - indicator smallint := 0; - BEGIN - IF a IS NULL AND b IS NULL THEN - RETURN 0; - END IF; - - IF a IS NULL THEN - RETURN -1; - END IF; - - IF b IS NULL THEN - RETURN 1; - END IF; - - IF bit_length(a.bytes) != bit_length(b.bytes) THEN - RAISE EXCEPTION 'Ciphertexts are different lengths'; - END IF; - - FOR block IN 0..7 LOOP - -- Compare each PRP (byte from the first 8 bytes) and PRF block (8 byte - -- chunks of the rest of the value). - -- NOTE: - -- * Substr is ordinally indexed (hence 1 and not 0, and 9 and not 8). - -- * We are not worrying about timing attacks here; don't fret about - -- the OR or !=. - IF - substr(a.bytes, 1 + block, 1) != substr(b.bytes, 1 + block, 1) - OR substr(a.bytes, 9 + left_block_size * block, left_block_size) != substr(b.bytes, 9 + left_block_size * BLOCK, left_block_size) - THEN - -- set the first unequal block we find - IF eq THEN - unequal_block := block; - END IF; - eq = false; - END IF; - END LOOP; - - IF eq THEN - RETURN 0::integer; - END IF; - - -- Hash key is the IV from the right CT of b - hash_key := substr(b.bytes, right_offset + 1, 16); - - -- first right block is at right offset + nonce_size (ordinally indexed) - target_block := substr(b.bytes, right_offset + 17 + (unequal_block * right_block_size), right_block_size); - - data_block := substr(a.bytes, 9 + (left_block_size * unequal_block), left_block_size); - - encrypt_block := encrypt(data_block::bytea, hash_key::bytea, 'aes-ecb'); - - indicator := ( - get_bit( - encrypt_block, - 0 - ) + get_bit(target_block, get_byte(a.bytes, unequal_block))) % 2; - - IF indicator = 1 THEN - RETURN 1::integer; - ELSE - RETURN -1::integer; - END IF; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Compare arrays of ORE block terms recursively ---! @internal ---! ---! Recursively compares arrays of ORE block terms element-by-element. ---! Empty arrays are considered less than non-empty arrays. If the first elements ---! are equal, recursively compares remaining elements. ---! ---! @param a eql_v2.ore_block_u64_8_256_term[] First array of ORE terms ---! @param b eql_v2.ore_block_u64_8_256_term[] Second array of ORE terms ---! @return Integer -1 if a < b, 0 if a = b, 1 if a > b, NULL if either array is NULL ---! ---! @note Empty arrays sort before non-empty arrays ---! @see eql_v2.compare_ore_block_u64_8_256_term -CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256_term[], b eql_v2.ore_block_u64_8_256_term[]) -RETURNS integer - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - cmp_result integer; - BEGIN - - -- NULLs are NULL - IF a IS NULL OR b IS NULL THEN - RETURN NULL; - END IF; - - -- empty a and b - IF cardinality(a) = 0 AND cardinality(b) = 0 THEN - RETURN 0; - END IF; - - -- empty a and some b - IF (cardinality(a) = 0) AND cardinality(b) > 0 THEN - RETURN -1; - END IF; - - -- some a and empty b - IF cardinality(a) > 0 AND (cardinality(b) = 0) THEN - RETURN 1; - END IF; - - cmp_result := eql_v2.compare_ore_block_u64_8_256_term(a[1], b[1]); - - IF cmp_result = 0 THEN - -- Removes the first element in the array, and calls this fn again to compare the next element/s in the array. - RETURN eql_v2.compare_ore_block_u64_8_256_terms(a[2:array_length(a,1)], b[2:array_length(b,1)]); - END IF; - - RETURN cmp_result; - END -$$ LANGUAGE plpgsql; - - ---! @brief Compare ORE block composite types ---! @internal ---! ---! Wrapper function that extracts term arrays from ORE block composite types ---! and delegates to the array comparison function. ---! ---! @param a eql_v2.ore_block_u64_8_256 First ORE block ---! @param b eql_v2.ore_block_u64_8_256 Second ORE block ---! @return Integer -1 if a < b, 0 if a = b, 1 if a > b ---! ---! @see eql_v2.compare_ore_block_u64_8_256_terms(eql_v2.ore_block_u64_8_256_term[], eql_v2.ore_block_u64_8_256_term[]) -CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256) -RETURNS integer - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.compare_ore_block_u64_8_256_terms(a.terms, b.terms); - END -$$ LANGUAGE plpgsql; - - ---! @brief Extract CLLW ORE index term from a ste_vec entry ---! ---! Returns the CLLW ORE ciphertext from the \`oc\` field of an \`sv\` element. ---! \`oc\` is **only ever present on a \`SteVecElement\`** in the v2.3 payload ---! shape — never at the root of an \`eql_v2_encrypted\` column value — so the ---! type signature accepts \`eql_v2.ste_vec_entry\` directly. Callers must ---! extract first: \`eql_v2.ore_cllw(col -> '')\`. ---! ---! Inlinable single-statement SQL — the planner folds the body into the ---! calling query so the extractor disappears at planning time. Functional ---! btree index match on this extractor requires the \`eql_v2.ore_cllw_ops\` ---! opclass (installed automatically by the main / protect variants; absent ---! in the supabase variant). ---! ---! **Missing-\`oc\` semantics**: when the \`oc\` field is absent, returns a ---! SQL-level NULL (not a composite with NULL bytes). Btree's standard ---! NULL handling then filters those rows from range queries: they don't ---! match \`WHERE ore_cllw(col) $1\`, they sort at the NULLS LAST end ---! of \`ORDER BY ore_cllw(col)\`, and they never reach the comparator. ---! This avoids the btree FUNCTION 1 contract violation that ---! \`(bytes => NULL)\` would otherwise cause (\`compare_ore_cllw_term\` ---! must return non-NULL int for non-NULL composite inputs). ---! ---! Callers needing a loud RAISE on missing \`oc\` should check ---! \`eql_v2.has_ore_cllw(entry)\` first. ---! ---! @param entry eql_v2.ste_vec_entry STE-vec entry (extracted via \`->\`) ---! @return eql_v2.ore_cllw Composite carrying the CLLW ciphertext, or ---! NULL when the \`oc\` field is absent. ---! ---! @see eql_v2.has_ore_cllw ---! @see eql_v2.compare_ore_cllw_term ---! @see src/operators/->.sql -CREATE FUNCTION eql_v2.ore_cllw(entry eql_v2.ste_vec_entry) - RETURNS eql_v2.ore_cllw - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT CASE WHEN entry ->> 'oc' IS NULL THEN NULL - ELSE ROW(decode(entry ->> 'oc', 'hex'))::eql_v2.ore_cllw - END -$$; - - ---! @brief Extract CLLW ORE index term from raw jsonb (RHS parameter helper) ---! ---! Companion overload for \`eql_v2.ore_cllw(eql_v2.ste_vec_entry)\` that ---! accepts a raw \`jsonb\` value. Intended for the right-hand side of ---! comparisons where the caller binds a literal/parameter jsonb representing ---! a single ste_vec entry: \`... < eql_v2.ore_cllw($1::jsonb)\`. The (jsonb) ---! form skips the domain CHECK constraint so it works for ad-hoc test inputs ---! and for the GenericComparison case in \`eql_v2.compare_ore_cllw_term\`. ---! ---! Returns SQL-level NULL when the input lacks \`oc\`, matching the ---! \`(ste_vec_entry)\` overload's missing-\`oc\` semantics so a \`WHERE ---! ore_cllw(col) < ore_cllw($1::jsonb)\` with a malformed query needle ---! evaluates to no rows rather than indexing a NULL-bytes composite. ---! ---! @param val jsonb An object carrying an \`oc\` field ---! @return eql_v2.ore_cllw Composite carrying the CLLW ciphertext, or ---! NULL when the \`oc\` field is absent. -CREATE FUNCTION eql_v2.ore_cllw(val jsonb) - RETURNS eql_v2.ore_cllw - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT CASE WHEN val ->> 'oc' IS NULL THEN NULL - ELSE ROW(decode(val ->> 'oc', 'hex'))::eql_v2.ore_cllw - END -$$; - - ---! @brief Check if a ste_vec entry contains a CLLW ORE index term ---! ---! Tests whether the entry includes an \`oc\` field. Inlinable. ---! ---! @param entry eql_v2.ste_vec_entry STE-vec entry ---! @return Boolean True if \`oc\` field is present and non-null ---! ---! @see eql_v2.ore_cllw -CREATE FUNCTION eql_v2.has_ore_cllw(entry eql_v2.ste_vec_entry) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT entry ->> 'oc' IS NOT NULL -$$; - - ---! @brief Check if a raw jsonb value contains a CLLW ORE index term ---! ---! Companion to \`eql_v2.has_ore_cllw(ste_vec_entry)\` for raw jsonb inputs. ---! ---! @param val jsonb An object that may carry an \`oc\` field ---! @return Boolean True if \`oc\` field is present and non-null -CREATE FUNCTION eql_v2.has_ore_cllw(val jsonb) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT val ->> 'oc' IS NOT NULL -$$; - - ---! @brief CLLW per-byte comparison helper ---! @internal ---! ---! Byte-by-byte comparison implementing the CLLW order-revealing protocol. ---! Used by \`eql_v2.compare_ore_cllw_term\` for the within-prefix step. The ---! protocol: identify the index of the first differing byte across both ---! inputs; if \`(y_byte + 1) == x_byte\` modulo 256 at that index, then x > y; ---! otherwise x < y. Equal inputs return 0. ---! ---! Inputs MUST be the same length. The caller (\`compare_ore_cllw_term\`) ---! guarantees this by passing equal-length prefixes. ---! ---! @par Soft constant-time intent ---! Plpgsql is not a constant-time environment — the interpreter, \`SUBSTRING\`, ---! \`get_byte\`, and the SQL bytea representation all leak timing in ways we ---! can't control from here. Still, the loop deliberately walks every byte ---! (no \`EXIT\` on first difference) and the rotation check uses a bitmask ---! (\`& 255\`) instead of \`% 256\` so that what little timing structure plpgsql ---! does expose is independent of the position and value of the differing ---! byte. This is hardening intent, not a guarantee. ---! ---! Stays \`LANGUAGE plpgsql\` — the per-byte loop can't be expressed as a ---! single inlinable SQL expression. This is the architectural reason ORE ---! CLLW needs a custom operator class for index match, where OPE does not. ---! ---! @param a Bytea First CLLW ciphertext slice ---! @param b Bytea Second CLLW ciphertext slice ---! @return Integer -1, 0, or 1 ---! @throws Exception if inputs are different lengths ---! ---! @see eql_v2.compare_ore_cllw_term -CREATE FUNCTION eql_v2.compare_ore_cllw_term_bytes(a bytea, b bytea) -RETURNS int - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - len_a INT; - len_b INT; - i INT; - first_diff INT := 0; -BEGIN - - len_a := LENGTH(a); - len_b := LENGTH(b); - - IF len_a != len_b THEN - RAISE EXCEPTION 'ore_cllw index terms are not the same length'; - END IF; - - -- Walk every byte, even after a difference is found. Record only the - -- index of the first difference (1-based; 0 means "no difference"). - -- Avoids an early \`EXIT\` whose presence is itself a timing signal. - FOR i IN 1..len_a LOOP - IF first_diff = 0 AND get_byte(a, i - 1) != get_byte(b, i - 1) THEN - first_diff := i; - END IF; - END LOOP; - - IF first_diff = 0 THEN - RETURN 0; - END IF; - - -- Bitmask instead of \`% 256\` — the modulo's operand is a power of two - -- so the two are arithmetically equivalent, but \`& 255\` is a single - -- machine instruction with no division-related timing variance. - IF ((get_byte(b, first_diff - 1) + 1) & 255) = get_byte(a, first_diff - 1) THEN - RETURN 1; - ELSE - RETURN -1; - END IF; -END; -$$ LANGUAGE plpgsql; - - ---! @brief Variable-length CLLW ORE term comparison ---! @internal ---! ---! Three-way comparison of two CLLW ORE ciphertext terms of potentially ---! different lengths. Compares the shared prefix via the CLLW per-byte ---! protocol; on equal prefixes, the shorter input sorts first. ---! ---! Handles both numeric (Standard-mode 65-byte CLLW outputs from the u64 ---! variant) and string (variable-length CLLW outputs) by virtue of the ---! domain-tag byte being the first byte of \`bytes\`. A numeric/string pair ---! differs at byte 0 (\`0x00\` vs \`0x01\`), which the CLLW rule resolves ---! correctly to numeric < string. ---! ---! Stays \`LANGUAGE plpgsql\` because it dispatches to ---! \`compare_ore_cllw_term_bytes\`, which can't be inlined. ---! ---! @par Null handling — btree FUNCTION 1 contract ---! PostgreSQL's btree filters NULL composites at the row level, so this ---! function should never be called with \`a IS NULL\` or \`b IS NULL\` under ---! normal operation. The leading IS-NULL guard returns NULL defensively ---! to cover edge cases (e.g., a non-index \`ORDER BY\` or \`WHERE\` path ---! that bypasses the opclass). ---! ---! A composite that is non-NULL but whose \`bytes\` field is NULL is a ---! contract violation: btree expects FUNCTION 1 to return a non-NULL ---! integer for non-NULL composite inputs. The extractor overloads of ---! \`eql_v2.ore_cllw\` are designed to return SQL NULL (not \`ROW(NULL)\`) ---! when the source payload lacks \`oc\`, so a NULL-bytes composite should ---! only arise from a hand-crafted literal or a future field addition to ---! the composite type. Raise loudly to surface the bug instead of ---! producing silent misordering downstream. ---! ---! @param a eql_v2.ore_cllw First term ---! @param b eql_v2.ore_cllw Second term ---! @return Integer -1, 0, or 1; NULL if either composite is NULL ---! @throws Exception if either composite has a NULL \`bytes\` field ---! ---! @see eql_v2.compare_ore_cllw_term_bytes ---! @see eql_v2.compare_ore_cllw -CREATE FUNCTION eql_v2.compare_ore_cllw_term(a eql_v2.ore_cllw, b eql_v2.ore_cllw) -RETURNS int - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - len_a INT; - len_b INT; - common_len INT; - cmp_result INT; -BEGIN - -- Composite-level NULL: btree's null-handling layer filters these at - -- the row level under normal operation. Returning NULL covers - -- non-index code paths that might still reach here. - IF a IS NULL OR b IS NULL THEN - RETURN NULL; - END IF; - - -- Non-NULL composite with NULL bytes is a contract violation: btree's - -- FUNCTION 1 must return non-NULL int for non-NULL composite inputs. - -- The extractors return SQL NULL (not ROW(NULL)) on missing \`oc\`, so - -- reaching here means a hand-crafted literal or a regression in the - -- extractor body. Raise loudly rather than silently misorder. - IF a.bytes IS NULL OR b.bytes IS NULL THEN - RAISE EXCEPTION 'eql_v2.compare_ore_cllw_term: composite has NULL bytes field — extractor invariant violated. Check that the index expression uses eql_v2.ore_cllw(...) and not a hand-crafted ROW(NULL).'; - END IF; - - len_a := LENGTH(a.bytes); - len_b := LENGTH(b.bytes); - - IF len_a = 0 AND len_b = 0 THEN - RETURN 0; - ELSIF len_a = 0 THEN - RETURN -1; - ELSIF len_b = 0 THEN - RETURN 1; - END IF; - - IF len_a < len_b THEN - common_len := len_a; - ELSE - common_len := len_b; - END IF; - - cmp_result := eql_v2.compare_ore_cllw_term_bytes( - SUBSTRING(a.bytes FROM 1 FOR common_len), - SUBSTRING(b.bytes FROM 1 FOR common_len) - ); - - IF cmp_result = -1 THEN - RETURN -1; - ELSIF cmp_result = 1 THEN - RETURN 1; - END IF; - - -- Equal prefixes: shorter sorts first - IF len_a < len_b THEN - RETURN -1; - ELSIF len_a > len_b THEN - RETURN 1; - ELSE - RETURN 0; - END IF; -END; -$$ LANGUAGE plpgsql; - - - ---! @brief Convert JSONB to encrypted type ---! ---! Wraps a JSONB encrypted payload into the eql_v2_encrypted composite type. ---! Used internally for type conversions and operator implementations. ---! ---! @param jsonb JSONB encrypted payload with structure: {"c": "...", "i": {...}, "k": "...", "v": "2"} ---! @return eql_v2_encrypted Encrypted value wrapped in composite type ---! ---! @note This is primarily used for implicit casts in operator expressions ---! @see eql_v2.to_jsonb -CREATE FUNCTION eql_v2.to_encrypted(data jsonb) - RETURNS public.eql_v2_encrypted - IMMUTABLE STRICT PARALLEL SAFE - LANGUAGE SQL -AS $$ - SELECT ROW(data)::public.eql_v2_encrypted; -$$; - - ---! @brief Implicit cast from JSONB to encrypted type ---! ---! Enables PostgreSQL to automatically convert JSONB values to eql_v2_encrypted ---! in assignment contexts and comparison operations. ---! ---! @see eql_v2.to_encrypted(jsonb) -CREATE CAST (jsonb AS public.eql_v2_encrypted) - WITH FUNCTION eql_v2.to_encrypted(jsonb) AS ASSIGNMENT; - - ---! @brief Convert text to encrypted type ---! ---! Parses a text representation of encrypted JSONB payload and wraps it ---! in the eql_v2_encrypted composite type. ---! ---! @param text Text representation of JSONB encrypted payload ---! @return eql_v2_encrypted Encrypted value wrapped in composite type ---! ---! @note Delegates to eql_v2.to_encrypted(jsonb) after parsing text as JSON ---! @see eql_v2.to_encrypted(jsonb) -CREATE FUNCTION eql_v2.to_encrypted(data text) - RETURNS public.eql_v2_encrypted - IMMUTABLE STRICT PARALLEL SAFE - LANGUAGE SQL -AS $$ - SELECT eql_v2.to_encrypted(data::jsonb); -$$; - - ---! @brief Implicit cast from text to encrypted type ---! ---! Enables PostgreSQL to automatically convert text JSON strings to eql_v2_encrypted ---! in assignment contexts. ---! ---! @see eql_v2.to_encrypted(text) -CREATE CAST (text AS public.eql_v2_encrypted) - WITH FUNCTION eql_v2.to_encrypted(text) AS ASSIGNMENT; - - - ---! @brief Convert encrypted type to JSONB ---! ---! Extracts the underlying JSONB payload from an eql_v2_encrypted composite type. ---! Useful for debugging or when raw encrypted payload access is needed. ---! ---! @param e eql_v2_encrypted Encrypted value to unwrap ---! @return jsonb Raw JSONB encrypted payload ---! ---! @note Returns the raw encrypted structure including ciphertext and index terms ---! @see eql_v2.to_encrypted(jsonb) -CREATE FUNCTION eql_v2.to_jsonb(e public.eql_v2_encrypted) - RETURNS jsonb - IMMUTABLE STRICT PARALLEL SAFE - LANGUAGE SQL -AS $$ - SELECT e.data; -$$; - ---! @brief Implicit cast from encrypted type to JSONB ---! ---! Enables PostgreSQL to automatically extract the JSONB payload from ---! eql_v2_encrypted values in assignment contexts. ---! ---! @see eql_v2.to_jsonb(eql_v2_encrypted) -CREATE CAST (public.eql_v2_encrypted AS jsonb) - WITH FUNCTION eql_v2.to_jsonb(public.eql_v2_encrypted) AS ASSIGNMENT; - - - - - ---! @brief Compare two encrypted values using HMAC-SHA256 index terms ---! ---! Performs a three-way comparison (returns -1/0/1) of encrypted values using ---! their HMAC-SHA256 hash index terms. Used internally by the equality operator (=) ---! for exact-match queries without decryption. ---! ---! @param a eql_v2_encrypted First encrypted value to compare ---! @param b eql_v2_encrypted Second encrypted value to compare ---! @return Integer -1 if a < b, 0 if a = b, 1 if a > b ---! ---! @note NULL values are sorted before non-NULL values ---! @note Comparison uses underlying text type ordering of HMAC-SHA256 hashes ---! ---! @see eql_v2.hmac_256 ---! @see eql_v2.has_hmac_256 ---! @see eql_v2."=" -CREATE FUNCTION eql_v2.compare_hmac_256(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS integer - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - a_term eql_v2.hmac_256; - b_term eql_v2.hmac_256; - BEGIN - - IF a IS NULL AND b IS NULL THEN - RETURN 0; - END IF; - - IF a IS NULL THEN - RETURN -1; - END IF; - - IF b IS NULL THEN - RETURN 1; - END IF; - - IF eql_v2.has_hmac_256(a) THEN - a_term = eql_v2.hmac_256(a); - END IF; - - IF eql_v2.has_hmac_256(b) THEN - b_term = eql_v2.hmac_256(b); - END IF; - - IF a_term IS NULL AND b_term IS NULL THEN - RETURN 0; - END IF; - - IF a_term IS NULL THEN - RETURN -1; - END IF; - - IF b_term IS NULL THEN - RETURN 1; - END IF; - - -- Using the underlying text type comparison - IF a_term = b_term THEN - RETURN 0; - END IF; - - IF a_term < b_term THEN - RETURN -1; - END IF; - - IF a_term > b_term THEN - RETURN 1; - END IF; - - END; -$$ LANGUAGE plpgsql; - - - ---! @file src/operators/compare.sql ---! @brief Three-way ordering on the root \`eql_v2_encrypted\` type ---! ---! Returns \`-1\` / \`0\` / \`1\` for two encrypted column values that carry ---! Block ORE (\`ob\`) terms at the root. Used by the btree operator class on ---! \`eql_v2_encrypted\` (FUNCTION 1), by the legacy \`eql_v2.lt\` / \`lte\` / ---! \`gt\` / \`gte\` helpers, and by \`sort_compare\`'s \`strategy = 'compare'\` ---! fallback path. ---! ---! **Strict Block-ORE-only contract.** Root-level \`eql_v2_encrypted\` values ---! only carry root-scope ORE terms (\`ob\`) per the v2.3 payload shape — the ---! \`oc\` field (CLLW ORE) is sv-element scope only and never appears on a ---! root payload. Equality on \`eql_v2_encrypted\` is hm-only and runs through ---! the inlined \`=\` / \`<>\` operators (post-#193) — it does *not* go through ---! this function. For sv-element ordering, use the typed ---! \`eql_v2.compare(eql_v2.ste_vec_entry, eql_v2.ste_vec_entry)\` overload ---! (or the \`<\` / \`<=\` / \`>\` / \`>=\` operators on the same pair). ---! ---! @param a eql_v2_encrypted First encrypted value (STRICT — NULL inputs short-circuit to NULL) ---! @param b eql_v2_encrypted Second encrypted value (STRICT — NULL inputs short-circuit to NULL) ---! @return integer -1, 0, or 1 ---! ---! @throws Exception when either value lacks an \`ob\` (Block ORE) term ---! ---! @see eql_v2.compare_ore_block_u64_8_256 ---! @see eql_v2.compare(eql_v2.ste_vec_entry, eql_v2.ste_vec_entry) ---! @see eql_v2."=" -- hm-only equality, post-#193 inlining -CREATE FUNCTION eql_v2.compare(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS integer - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF eql_v2.has_ore_block_u64_8_256(a) AND eql_v2.has_ore_block_u64_8_256(b) THEN - RETURN eql_v2.compare_ore_block_u64_8_256(a, b); - END IF; - - RAISE EXCEPTION - 'eql_v2.compare requires Block ORE (\`ob\`) on both root operands. For sv-element ordering, extract entries via \`col -> ''''\` and use eql_v2.compare on the resulting \`eql_v2.ste_vec_entry\` values (or their \`<\` / \`<=\` / \`>\` / \`>=\` operators). Equality is hmac-only via the \`=\` operator — this function is for ordering only.' - USING ERRCODE = 'feature_not_supported'; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Three-way ordering on \`eql_v2.ste_vec_entry\` ---! ---! CLLW ORE three-way comparator on ste-vec entries. Returns \`-1\` / \`0\` / ---! \`1\` by extracting the \`oc\` term from each entry and delegating to ---! \`eql_v2.compare_ore_cllw_term\`. Use this when you need an \`int\` ordering ---! out of two extracted ste-vec entries — for the boolean-form operators ---! (\`<\` / \`<=\` / \`>\` / \`>=\`) on the same pair, see ---! \`src/operators/ste_vec_entry.sql\`. ---! ---! Note: the caller is responsible for extracting an \`eql_v2.ste_vec_entry\` ---! first; the \`(eql_v2_encrypted, text)\` form would be a natural extension ---! but is deliberately *not* added here so that callers stay aware of the ---! two-step shape (extract via \`->\`, then compare). ---! ---! @param a eql_v2.ste_vec_entry First entry ---! @param b eql_v2.ste_vec_entry Second entry ---! @return integer -1, 0, or 1 ---! ---! @throws Exception when either entry lacks an \`oc\` term ---! ---! @see eql_v2.compare_ore_cllw_term ---! @see src/operators/ste_vec_entry.sql -CREATE FUNCTION eql_v2.compare(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry) - RETURNS integer - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF NOT (eql_v2.has_ore_cllw(a) AND eql_v2.has_ore_cllw(b)) THEN - RAISE EXCEPTION - 'eql_v2.compare(ste_vec_entry, ste_vec_entry) requires \`oc\` (CLLW ORE) on both entries.' - USING ERRCODE = 'feature_not_supported'; - END IF; - - RETURN eql_v2.compare_ore_cllw_term(eql_v2.ore_cllw(a), eql_v2.ore_cllw(b)); - END; -$$ LANGUAGE plpgsql; - ---! @brief Equality operator for ORE block types ---! @internal ---! ---! Implements the = operator for direct ORE block comparisons. ---! ---! @param a eql_v2.ore_block_u64_8_256 Left operand ---! @param b eql_v2.ore_block_u64_8_256 Right operand ---! @return Boolean True if ORE blocks are equal ---! ---! @see eql_v2.compare_ore_block_u64_8_256_terms -CREATE FUNCTION eql_v2.ore_block_u64_8_256_eq(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256) -RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = 0 -$$; - - - ---! @brief Not equal operator for ORE block types ---! @internal ---! ---! Implements the <> operator for direct ORE block comparisons. ---! ---! @param a eql_v2.ore_block_u64_8_256 Left operand ---! @param b eql_v2.ore_block_u64_8_256 Right operand ---! @return Boolean True if ORE blocks are not equal ---! ---! @see eql_v2.compare_ore_block_u64_8_256_terms -CREATE FUNCTION eql_v2.ore_block_u64_8_256_neq(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256) -RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) <> 0 -$$; - - - ---! @brief Less than operator for ORE block types ---! @internal ---! ---! Implements the < operator for direct ORE block comparisons. ---! ---! @param a eql_v2.ore_block_u64_8_256 Left operand ---! @param b eql_v2.ore_block_u64_8_256 Right operand ---! @return Boolean True if left operand is less than right operand ---! ---! @see eql_v2.compare_ore_block_u64_8_256_terms -CREATE FUNCTION eql_v2.ore_block_u64_8_256_lt(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256) -RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = -1 -$$; - - - ---! @brief Less than or equal operator for ORE block types ---! @internal ---! ---! Implements the <= operator for direct ORE block comparisons. ---! ---! @param a eql_v2.ore_block_u64_8_256 Left operand ---! @param b eql_v2.ore_block_u64_8_256 Right operand ---! @return Boolean True if left operand is less than or equal to right operand ---! ---! @see eql_v2.compare_ore_block_u64_8_256_terms -CREATE FUNCTION eql_v2.ore_block_u64_8_256_lte(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256) -RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) != 1 -$$; - - - ---! @brief Greater than operator for ORE block types ---! @internal ---! ---! Implements the > operator for direct ORE block comparisons. ---! ---! @param a eql_v2.ore_block_u64_8_256 Left operand ---! @param b eql_v2.ore_block_u64_8_256 Right operand ---! @return Boolean True if left operand is greater than right operand ---! ---! @see eql_v2.compare_ore_block_u64_8_256_terms -CREATE FUNCTION eql_v2.ore_block_u64_8_256_gt(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256) -RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) = 1 -$$; - - - ---! @brief Greater than or equal operator for ORE block types ---! @internal ---! ---! Implements the >= operator for direct ORE block comparisons. ---! ---! @param a eql_v2.ore_block_u64_8_256 Left operand ---! @param b eql_v2.ore_block_u64_8_256 Right operand ---! @return Boolean True if left operand is greater than or equal to right operand ---! ---! @see eql_v2.compare_ore_block_u64_8_256_terms -CREATE FUNCTION eql_v2.ore_block_u64_8_256_gte(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256) -RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_block_u64_8_256_terms(a, b) != -1 -$$; - - - ---! @brief = operator for ORE block types -CREATE OPERATOR = ( - FUNCTION=eql_v2.ore_block_u64_8_256_eq, - LEFTARG=eql_v2.ore_block_u64_8_256, - RIGHTARG=eql_v2.ore_block_u64_8_256, - NEGATOR = <>, - RESTRICT = eqsel, - JOIN = eqjoinsel, - HASHES, - MERGES -); - - - ---! @brief <> operator for ORE block types -CREATE OPERATOR <> ( - FUNCTION=eql_v2.ore_block_u64_8_256_neq, - LEFTARG=eql_v2.ore_block_u64_8_256, - RIGHTARG=eql_v2.ore_block_u64_8_256, - NEGATOR = =, - RESTRICT = eqsel, - JOIN = eqjoinsel, - HASHES, - MERGES -); - - ---! @brief > operator for ORE block types -CREATE OPERATOR > ( - FUNCTION=eql_v2.ore_block_u64_8_256_gt, - LEFTARG=eql_v2.ore_block_u64_8_256, - RIGHTARG=eql_v2.ore_block_u64_8_256, - COMMUTATOR = <, - NEGATOR = <=, - RESTRICT = scalargtsel, - JOIN = scalargtjoinsel -); - - - ---! @brief < operator for ORE block types -CREATE OPERATOR < ( - FUNCTION=eql_v2.ore_block_u64_8_256_lt, - LEFTARG=eql_v2.ore_block_u64_8_256, - RIGHTARG=eql_v2.ore_block_u64_8_256, - COMMUTATOR = >, - NEGATOR = >=, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel -); - - - ---! @brief <= operator for ORE block types -CREATE OPERATOR <= ( - FUNCTION=eql_v2.ore_block_u64_8_256_lte, - LEFTARG=eql_v2.ore_block_u64_8_256, - RIGHTARG=eql_v2.ore_block_u64_8_256, - COMMUTATOR = >=, - NEGATOR = >, - RESTRICT = scalarlesel, - JOIN = scalarlejoinsel -); - - - ---! @brief >= operator for ORE block types -CREATE OPERATOR >= ( - FUNCTION=eql_v2.ore_block_u64_8_256_gte, - LEFTARG=eql_v2.ore_block_u64_8_256, - RIGHTARG=eql_v2.ore_block_u64_8_256, - COMMUTATOR = <=, - NEGATOR = <, - RESTRICT = scalargesel, - JOIN = scalargejoinsel -); - - ---! @brief Extract STE vector index from JSONB payload ---! ---! Extracts the STE (Searchable Symmetric Encryption) vector from the 'sv' field ---! of an encrypted data payload. Returns an array of encrypted values used for ---! containment queries (@>, <@). If no 'sv' field exists, wraps the entire payload ---! as a single-element array. ---! ---! @param jsonb containing encrypted EQL payload ---! @return eql_v2_encrypted[] Array of encrypted STE vector elements ---! ---! @see eql_v2.ste_vec(eql_v2_encrypted) ---! @see eql_v2.ste_vec_contains -CREATE FUNCTION eql_v2.ste_vec(val jsonb) - RETURNS public.eql_v2_encrypted[] - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - sv jsonb; - ary public.eql_v2_encrypted[]; - BEGIN - - IF val ? 'sv' THEN - sv := val->'sv'; - ELSE - sv := jsonb_build_array(val); - END IF; - - SELECT array_agg(eql_v2.to_encrypted(elem)) - INTO ary - FROM jsonb_array_elements(sv) AS elem; - - RETURN ary; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Extract STE vector index from encrypted column value ---! ---! Extracts the STE vector from an encrypted column value by accessing its ---! underlying JSONB data field. Used for containment query operations. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return eql_v2_encrypted[] Array of encrypted STE vector elements ---! ---! @see eql_v2.ste_vec(jsonb) -CREATE FUNCTION eql_v2.ste_vec(val eql_v2_encrypted) - RETURNS public.eql_v2_encrypted[] - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN (SELECT eql_v2.ste_vec(val.data)); - END; -$$ LANGUAGE plpgsql; - ---! @brief Check if JSONB payload is a single-element STE vector ---! ---! Tests whether the encrypted data payload contains an 'sv' field with exactly ---! one element. Single-element STE vectors can be treated as regular encrypted values. ---! ---! @param jsonb containing encrypted EQL payload ---! @return Boolean True if 'sv' field exists with exactly one element ---! ---! @see eql_v2.to_ste_vec_value -CREATE FUNCTION eql_v2.is_ste_vec_value(val jsonb) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF val ? 'sv' THEN - RETURN jsonb_array_length(val->'sv') = 1; - END IF; - - RETURN false; - END; -$$ LANGUAGE plpgsql; - ---! @brief Check if encrypted column value is a single-element STE vector ---! ---! Tests whether an encrypted column value is a single-element STE vector ---! by checking its underlying JSONB data field. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return Boolean True if value is a single-element STE vector ---! ---! @see eql_v2.is_ste_vec_value(jsonb) -CREATE FUNCTION eql_v2.is_ste_vec_value(val eql_v2_encrypted) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.is_ste_vec_value(val.data); - END; -$$ LANGUAGE plpgsql; - ---! @brief Convert single-element STE vector to regular encrypted value ---! ---! Extracts the single element from a single-element STE vector and returns it ---! as a regular encrypted value, preserving metadata. If the input is not a ---! single-element STE vector, returns it unchanged. ---! ---! @param jsonb containing encrypted EQL payload ---! @return eql_v2_encrypted Regular encrypted value (unwrapped if single-element STE vector) ---! ---! @see eql_v2.is_ste_vec_value -CREATE FUNCTION eql_v2.to_ste_vec_value(val jsonb) - RETURNS eql_v2_encrypted - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - meta jsonb; - sv jsonb; - BEGIN - - IF val IS NULL THEN - RETURN NULL; - END IF; - - IF eql_v2.is_ste_vec_value(val) THEN - meta := eql_v2.meta_data(val); - sv := val->'sv'; - sv := sv[0]; - - RETURN eql_v2.to_encrypted(meta || sv); - END IF; - - RETURN eql_v2.to_encrypted(val); - END; -$$ LANGUAGE plpgsql; - ---! @brief Convert single-element STE vector to regular encrypted value (encrypted type) ---! ---! Converts an encrypted column value to a regular encrypted value by unwrapping ---! if it's a single-element STE vector. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return eql_v2_encrypted Regular encrypted value (unwrapped if single-element STE vector) ---! ---! @see eql_v2.to_ste_vec_value(jsonb) -CREATE FUNCTION eql_v2.to_ste_vec_value(val eql_v2_encrypted) - RETURNS eql_v2_encrypted - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.to_ste_vec_value(val.data); - END; -$$ LANGUAGE plpgsql; - ---! @brief Extract selector value from JSONB payload ---! ---! Extracts the selector ('s') field from an encrypted data payload. ---! Selectors are used to match STE vector elements during containment queries. ---! ---! @param jsonb containing encrypted EQL payload ---! @return Text The selector value ---! @throws Exception if 's' field is missing ---! ---! @see eql_v2.ste_vec_contains -CREATE FUNCTION eql_v2.selector(val jsonb) - RETURNS text - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF val IS NULL THEN - RETURN NULL; - END IF; - - IF val ? 's' THEN - RETURN val->>'s'; - END IF; - RAISE 'Expected a selector index (s) value in json: %', val; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Extract selector value from encrypted column value ---! @internal ---! ---! Internal convenience: unwraps the encrypted composite and delegates ---! to \`eql_v2.selector(jsonb)\`. Exists so the encrypted-selector ---! overloads of \`eql_v2."->"\` / \`eql_v2."->>"\` / \`eql_v2.jsonb_path_*\` ---! can dispatch without each having to spell out \`(val).data\` first. ---! Not part of the public API — callers should use ---! \`eql_v2.selector(jsonb)\` or \`eql_v2.selector(eql_v2.ste_vec_entry)\`. ---! ---! @param eql_v2_encrypted Encrypted column value (single-element form) ---! @return Text The selector value ---! ---! @see eql_v2.selector(jsonb) ---! @see eql_v2.selector(eql_v2.ste_vec_entry) -CREATE FUNCTION eql_v2._selector(val eql_v2_encrypted) - RETURNS text - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN (SELECT eql_v2.selector(val.data)); - END; -$$ LANGUAGE plpgsql; - - ---! @brief Extract selector value from a ste_vec entry ---! ---! Direct overload on the domain type. The DOMAIN's CHECK constraint ---! already guarantees \`s\` is present, so this is a simple field access. ---! ---! @param entry eql_v2.ste_vec_entry STE-vec entry ---! @return Text The selector value ---! ---! @see eql_v2.selector(jsonb) -CREATE FUNCTION eql_v2.selector(entry eql_v2.ste_vec_entry) - RETURNS text - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT entry ->> 's' -$$; - - - ---! @brief Check if JSONB payload is marked as an STE vector array ---! ---! Tests whether the encrypted data payload has the 'a' (array) flag set to true, ---! indicating it represents an array for STE vector operations. ---! ---! @param jsonb containing encrypted EQL payload ---! @return Boolean True if 'a' field is present and true ---! ---! @see eql_v2.ste_vec -CREATE FUNCTION eql_v2.is_ste_vec_array(val jsonb) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF val ? 'a' THEN - RETURN (val->>'a')::boolean; - END IF; - - RETURN false; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Check if encrypted column value is marked as an STE vector array ---! ---! Tests whether an encrypted column value has the array flag set by checking ---! its underlying JSONB data field. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return Boolean True if value is marked as an STE vector array ---! ---! @see eql_v2.is_ste_vec_array(jsonb) -CREATE FUNCTION eql_v2.is_ste_vec_array(val eql_v2_encrypted) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN (SELECT eql_v2.is_ste_vec_array(val.data)); - END; -$$ LANGUAGE plpgsql; - - - ---! @brief Extract full encrypted JSONB elements as array ---! ---! Extracts all JSONB elements from the STE vector including non-deterministic fields. ---! Use jsonb_array() instead for GIN indexing and containment queries. ---! ---! @param val jsonb containing encrypted EQL payload ---! @return jsonb[] Array of full JSONB elements ---! ---! @see eql_v2.jsonb_array -CREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val jsonb) -RETURNS jsonb[] -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT CASE - WHEN val ? 'sv' THEN - ARRAY(SELECT elem FROM jsonb_array_elements(val->'sv') AS elem) - ELSE - ARRAY[val] - END; -$$; - - ---! @brief Extract full encrypted JSONB elements as array from encrypted column ---! ---! @param val eql_v2_encrypted Encrypted column value ---! @return jsonb[] Array of full JSONB elements ---! ---! @see eql_v2.jsonb_array_from_array_elements(jsonb) -CREATE FUNCTION eql_v2.jsonb_array_from_array_elements(val eql_v2_encrypted) -RETURNS jsonb[] -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT eql_v2.jsonb_array_from_array_elements(val.data); -$$; - - ---! @brief Extract deterministic fields as array for GIN indexing ---! ---! Extracts only deterministic search term fields (\`s\`, \`hm\`, \`oc\`, \`op\`) ---! from each STE vector element. Excludes non-deterministic ciphertext for ---! correct containment comparison using PostgreSQL's native \`@>\` operator. ---! ---! Field set: selector (\`s\`), HMAC equality (\`hm\`), ORE CLLW (\`oc\`, ---! Standard-mode), OPE CLLW (\`op\`, Compat-mode). The pre-2.3 fields ---! (\`b3\` / \`ocf\` / \`ocv\` / \`opf\` / \`opv\`) are no longer emitted — see U-004 ---! and U-006 in \`docs/upgrading/v2.3.md\`. ---! ---! @param val jsonb containing encrypted EQL payload ---! @return jsonb[] Array of JSONB elements with only deterministic fields ---! ---! @note Use this for GIN indexes and containment queries ---! @see eql_v2.jsonb_contains -CREATE FUNCTION eql_v2.jsonb_array(val jsonb) -RETURNS jsonb[] -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT ARRAY( - SELECT jsonb_object_agg(kv.key, kv.value) - FROM jsonb_array_elements( - CASE WHEN val ? 'sv' THEN val->'sv' ELSE jsonb_build_array(val) END - ) AS elem, - LATERAL jsonb_each(elem) AS kv(key, value) - WHERE kv.key IN ('s', 'hm', 'oc', 'op') - GROUP BY elem - ); -$$; - - ---! @brief Extract deterministic fields as array from encrypted column ---! ---! @param val eql_v2_encrypted Encrypted column value ---! @return jsonb[] Array of JSONB elements with only deterministic fields ---! ---! @see eql_v2.jsonb_array(jsonb) -CREATE FUNCTION eql_v2.jsonb_array(val eql_v2_encrypted) -RETURNS jsonb[] -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT eql_v2.jsonb_array(val.data); -$$; - - ---! @brief GIN-indexable JSONB containment check ---! ---! Checks if encrypted value 'a' contains all JSONB elements from 'b'. ---! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. ---! ---! This function is designed for use with a GIN index on jsonb_array(column). ---! When combined with such an index, PostgreSQL can efficiently search large tables. ---! ---! @param a eql_v2_encrypted Container value (typically a table column) ---! @param b eql_v2_encrypted Value to search for ---! @return Boolean True if a contains all elements of b ---! ---! @example ---! -- Create GIN index for efficient containment queries ---! CREATE INDEX idx ON mytable USING GIN (eql_v2.jsonb_array(encrypted_col)); ---! ---! -- Query using the helper function ---! SELECT * FROM mytable WHERE eql_v2.jsonb_contains(encrypted_col, search_value); ---! ---! @see eql_v2.jsonb_array -CREATE FUNCTION eql_v2.jsonb_contains(a eql_v2_encrypted, b eql_v2_encrypted) -RETURNS boolean -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b); -$$; - - ---! @brief GIN-indexable JSONB containment check (encrypted, jsonb) ---! ---! Checks if encrypted value 'a' contains all JSONB elements from jsonb value 'b'. ---! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. ---! ---! @param a eql_v2_encrypted Container value (typically a table column) ---! @param b jsonb JSONB value to search for ---! @return Boolean True if a contains all elements of b ---! ---! @see eql_v2.jsonb_array ---! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2.jsonb_contains(a eql_v2_encrypted, b jsonb) -RETURNS boolean -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b); -$$; - - ---! @brief GIN-indexable JSONB containment check (jsonb, encrypted) ---! ---! Checks if jsonb value 'a' contains all JSONB elements from encrypted value 'b'. ---! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. ---! ---! @param a jsonb Container JSONB value ---! @param b eql_v2_encrypted Encrypted value to search for ---! @return Boolean True if a contains all elements of b ---! ---! @see eql_v2.jsonb_array ---! @see eql_v2.jsonb_contains(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2.jsonb_contains(a jsonb, b eql_v2_encrypted) -RETURNS boolean -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT eql_v2.jsonb_array(a) @> eql_v2.jsonb_array(b); -$$; - - ---! @brief GIN-indexable JSONB "is contained by" check ---! ---! Checks if all JSONB elements from 'a' are contained in 'b'. ---! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. ---! ---! @param a eql_v2_encrypted Value to check (typically a table column) ---! @param b eql_v2_encrypted Container value ---! @return Boolean True if all elements of a are contained in b ---! ---! @see eql_v2.jsonb_array ---! @see eql_v2.jsonb_contains -CREATE FUNCTION eql_v2.jsonb_contained_by(a eql_v2_encrypted, b eql_v2_encrypted) -RETURNS boolean -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b); -$$; - - ---! @brief GIN-indexable JSONB "is contained by" check (encrypted, jsonb) ---! ---! Checks if all JSONB elements from encrypted value 'a' are contained in jsonb value 'b'. ---! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. ---! ---! @param a eql_v2_encrypted Value to check (typically a table column) ---! @param b jsonb Container JSONB value ---! @return Boolean True if all elements of a are contained in b ---! ---! @see eql_v2.jsonb_array ---! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2.jsonb_contained_by(a eql_v2_encrypted, b jsonb) -RETURNS boolean -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b); -$$; - - ---! @brief GIN-indexable JSONB "is contained by" check (jsonb, encrypted) ---! ---! Checks if all JSONB elements from jsonb value 'a' are contained in encrypted value 'b'. ---! Uses jsonb[] arrays internally for native PostgreSQL GIN index support. ---! ---! @param a jsonb Value to check ---! @param b eql_v2_encrypted Container encrypted value ---! @return Boolean True if all elements of a are contained in b ---! ---! @see eql_v2.jsonb_array ---! @see eql_v2.jsonb_contained_by(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2.jsonb_contained_by(a jsonb, b eql_v2_encrypted) -RETURNS boolean -IMMUTABLE STRICT PARALLEL SAFE -LANGUAGE SQL -AS $$ - SELECT eql_v2.jsonb_array(a) <@ eql_v2.jsonb_array(b); -$$; - - ---! @brief Check if STE vector array contains a specific encrypted element ---! ---! Tests whether any element in the STE vector array 'a' contains the encrypted value 'b'. ---! Matching requires both the selector and encrypted value to be equal. ---! Used internally by ste_vec_contains(encrypted, encrypted) for array containment checks. ---! ---! @param eql_v2_encrypted[] STE vector array to search within ---! @param eql_v2_encrypted Encrypted element to search for ---! @return Boolean True if b is found in any element of a ---! ---! @note Compares both selector and encrypted value for match ---! ---! @see eql_v2.selector ---! @see eql_v2.ste_vec_contains(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2.ste_vec_contains(a public.eql_v2_encrypted[], b eql_v2_encrypted) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - result boolean; - _a public.eql_v2_encrypted; - BEGIN - - result := false; - - FOR idx IN 1..array_length(a, 1) LOOP - _a := a[idx]; - -- Element-level match for ste_vec entries. - -- - -- Per the v2.3 sv-element contract (encoded in - -- \`docs/reference/schema/eql-payload-v2.3.schema.json\` and the - -- \`eql_v2.ste_vec_entry\` DOMAIN), each entry carries **exactly - -- one** of: - -- - \`hm\` — HMAC-256 for boolean leaves and for the placeholder - -- entries that represent array / object roots. - -- - \`oc\` — CLLW ORE for string and number leaves. - -- Both terms are deterministic for the same plaintext at the same - -- selector under the same workspace, so either one serves as the - -- equality discriminator. A selector configures the leaf's role - -- (eq / ordered), and the role determines which term is emitted — - -- two sv entries with the same selector therefore always carry - -- the same term type. - -- - -- The selector check is a fast-path gate so we don't compare - -- terms across mismatched fields. Once selectors match, exactly - -- one of the two CASE branches fires (XOR contract above). - -- - -- The \`ELSE false\` arm covers the malformed case (entry carries - -- neither term, or only one side has the term for a given role). - -- That's a data error rather than a normal containment result, - -- but returning false is safer than raising mid-array-scan. - result := result OR ( - eql_v2._selector(_a) = eql_v2._selector(b) AND - CASE - WHEN eql_v2.has_hmac_256(_a) AND eql_v2.has_hmac_256(b) THEN - eql_v2.compare_hmac_256(_a, b) = 0 - WHEN eql_v2.has_ore_cllw((_a).data) AND eql_v2.has_ore_cllw((b).data) THEN - eql_v2.compare_ore_cllw_term( - eql_v2.ore_cllw((_a).data), - eql_v2.ore_cllw((b).data) - ) = 0 - ELSE false - END - ); - - -- Short-circuit once a match is found. Without this we still walk - -- the rest of the sv array, which on a 100-element document means - -- 99 wasted selector + extractor calls per row. - EXIT WHEN result; - END LOOP; - - RETURN result; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Check if encrypted value 'a' contains all elements of encrypted value 'b' ---! ---! Performs STE vector containment comparison between two encrypted values. ---! Returns true if all elements in b's STE vector are found in a's STE vector. ---! Used internally by the @> containment operator for searchable encryption. ---! ---! @param a eql_v2_encrypted First encrypted value (container) ---! @param b eql_v2_encrypted Second encrypted value (elements to find) ---! @return Boolean True if all elements of b are contained in a ---! ---! @note Empty b is always contained in any a ---! @note Each element of b must match both selector and value in a ---! ---! @see eql_v2.ste_vec ---! @see eql_v2.ste_vec_contains(eql_v2_encrypted[], eql_v2_encrypted) ---! @see eql_v2."@>" -CREATE FUNCTION eql_v2.ste_vec_contains(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - result boolean; - sv_a public.eql_v2_encrypted[]; - sv_b public.eql_v2_encrypted[]; - _b public.eql_v2_encrypted; - BEGIN - - -- jsonb arrays of ste_vec encrypted values - sv_a := eql_v2.ste_vec(a); - sv_b := eql_v2.ste_vec(b); - - -- an empty b is always contained in a - IF array_length(sv_b, 1) IS NULL THEN - RETURN true; - END IF; - - IF array_length(sv_a, 1) IS NULL THEN - RETURN false; - END IF; - - result := true; - - -- for each element of b check if it is in a - FOR idx IN 1..array_length(sv_b, 1) LOOP - _b := sv_b[idx]; - result := result AND eql_v2.ste_vec_contains(sv_a, _b); - END LOOP; - - RETURN result; - END; -$$ LANGUAGE plpgsql; ---! @file config/types.sql ---! @brief Configuration state type definition ---! ---! Defines the ENUM type for tracking encryption configuration lifecycle states. ---! The configuration table uses this type to manage transitions between states ---! during setup, activation, and encryption operations. ---! ---! @note CREATE TYPE does not support IF NOT EXISTS, so wrapped in DO block ---! @note Configuration data stored as JSONB directly, not as DOMAIN ---! @see config/tables.sql - - ---! @brief Configuration lifecycle state ---! ---! Defines valid states for encryption configurations in the eql_v2_configuration table. ---! Configurations transition through these states during setup and activation. ---! ---! @note Only one configuration can be in 'active', 'pending', or 'encrypting' state at once ---! @see config/indexes.sql for uniqueness enforcement ---! @see config/tables.sql for usage in eql_v2_configuration table -DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eql_v2_configuration_state') THEN - CREATE TYPE public.eql_v2_configuration_state AS ENUM ('active', 'inactive', 'encrypting', 'pending'); - END IF; - END -$$; - - ---! @file src/ore_cllw/operators.sql ---! @brief Comparison operators on the \`eql_v2.ore_cllw\` composite type ---! ---! Same-type comparison operators backing the btree operator class on the ---! composite \`eql_v2.ore_cllw\` type. Each operator reduces to a single SELECT ---! over \`eql_v2.compare_ore_cllw_term(a, b)\`, which is the canonical CLLW ---! per-byte comparator (\`y + 1 == x\` mod 256). The operator wrappers are ---! inlinable \`LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE\` so the planner can ---! fold them into the calling query — that's what lets a functional btree ---! index on \`eql_v2.ore_cllw(col)\` engage for both \`WHERE eql_v2.ore_cllw(col) ---! < eql_v2.ore_cllw($1)\` and \`ORDER BY eql_v2.ore_cllw(col)\` shapes. ---! ---! The inner \`eql_v2.compare_ore_cllw_term\` is \`LANGUAGE plpgsql\` (it has a ---! per-byte loop) and is NOT inlined. That's fine for index *match* (the ---! planner only needs the outer operator function call to fold so the ---! predicate's expression tree matches the index's expression tree); only the ---! per-comparison cost is the plpgsql call overhead. That's the cost the ---! functional index avoids by walking the btree in order rather than calling ---! compare on every row. ---! ---! @note Deliberately no \`HASHES\` / \`MERGES\` flags on the operator ---! declarations. HASHES requires a registered hash function on the type ---! (the CLLW protocol gives ordering, not a sensible hashing); MERGES ---! requires an equivalent merge-joinable operator class on both sides. ---! ---! @see src/ore_cllw/operator_class.sql ---! @see src/ore_cllw/functions.sql - ---! @brief Equality operator backing function for \`eql_v2.ore_cllw\` ---! @internal ---! ---! @param a eql_v2.ore_cllw Left operand ---! @param b eql_v2.ore_cllw Right operand ---! @return boolean True if the CLLW terms compare equal ---! ---! @see eql_v2.compare_ore_cllw_term -CREATE FUNCTION eql_v2.ore_cllw_eq(a eql_v2.ore_cllw, b eql_v2.ore_cllw) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_cllw_term(a, b) = 0 -$$; - ---! @brief Inequality operator backing function for \`eql_v2.ore_cllw\` ---! @internal ---! ---! @param a eql_v2.ore_cllw Left operand ---! @param b eql_v2.ore_cllw Right operand ---! @return boolean True if the CLLW terms compare unequal ---! ---! @see eql_v2.compare_ore_cllw_term -CREATE FUNCTION eql_v2.ore_cllw_neq(a eql_v2.ore_cllw, b eql_v2.ore_cllw) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_cllw_term(a, b) <> 0 -$$; - ---! @brief Less-than operator backing function for \`eql_v2.ore_cllw\` ---! @internal ---! ---! @param a eql_v2.ore_cllw Left operand ---! @param b eql_v2.ore_cllw Right operand ---! @return boolean True if \`a\` orders before \`b\` ---! ---! @see eql_v2.compare_ore_cllw_term -CREATE FUNCTION eql_v2.ore_cllw_lt(a eql_v2.ore_cllw, b eql_v2.ore_cllw) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_cllw_term(a, b) = -1 -$$; - ---! @brief Less-than-or-equal operator backing function for \`eql_v2.ore_cllw\` ---! @internal ---! ---! @param a eql_v2.ore_cllw Left operand ---! @param b eql_v2.ore_cllw Right operand ---! @return boolean True if \`a\` orders before or equal to \`b\` ---! ---! @see eql_v2.compare_ore_cllw_term -CREATE FUNCTION eql_v2.ore_cllw_lte(a eql_v2.ore_cllw, b eql_v2.ore_cllw) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_cllw_term(a, b) <> 1 -$$; - ---! @brief Greater-than operator backing function for \`eql_v2.ore_cllw\` ---! @internal ---! ---! @param a eql_v2.ore_cllw Left operand ---! @param b eql_v2.ore_cllw Right operand ---! @return boolean True if \`a\` orders after \`b\` ---! ---! @see eql_v2.compare_ore_cllw_term -CREATE FUNCTION eql_v2.ore_cllw_gt(a eql_v2.ore_cllw, b eql_v2.ore_cllw) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_cllw_term(a, b) = 1 -$$; - ---! @brief Greater-than-or-equal operator backing function for \`eql_v2.ore_cllw\` ---! @internal ---! ---! @param a eql_v2.ore_cllw Left operand ---! @param b eql_v2.ore_cllw Right operand ---! @return boolean True if \`a\` orders after or equal to \`b\` ---! ---! @see eql_v2.compare_ore_cllw_term -CREATE FUNCTION eql_v2.ore_cllw_gte(a eql_v2.ore_cllw, b eql_v2.ore_cllw) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.compare_ore_cllw_term(a, b) <> -1 -$$; - - -CREATE OPERATOR = ( - FUNCTION = eql_v2.ore_cllw_eq, - LEFTARG = eql_v2.ore_cllw, - RIGHTARG = eql_v2.ore_cllw, - COMMUTATOR = =, - NEGATOR = <>, - RESTRICT = eqsel, - JOIN = eqjoinsel -); - -CREATE OPERATOR <> ( - FUNCTION = eql_v2.ore_cllw_neq, - LEFTARG = eql_v2.ore_cllw, - RIGHTARG = eql_v2.ore_cllw, - COMMUTATOR = <>, - NEGATOR = =, - RESTRICT = neqsel, - JOIN = neqjoinsel -); - -CREATE OPERATOR < ( - FUNCTION = eql_v2.ore_cllw_lt, - LEFTARG = eql_v2.ore_cllw, - RIGHTARG = eql_v2.ore_cllw, - COMMUTATOR = >, - NEGATOR = >=, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel -); - -CREATE OPERATOR <= ( - FUNCTION = eql_v2.ore_cllw_lte, - LEFTARG = eql_v2.ore_cllw, - RIGHTARG = eql_v2.ore_cllw, - COMMUTATOR = >=, - NEGATOR = >, - RESTRICT = scalarlesel, - JOIN = scalarlejoinsel -); - -CREATE OPERATOR > ( - FUNCTION = eql_v2.ore_cllw_gt, - LEFTARG = eql_v2.ore_cllw, - RIGHTARG = eql_v2.ore_cllw, - COMMUTATOR = <, - NEGATOR = <=, - RESTRICT = scalargtsel, - JOIN = scalargtjoinsel -); - -CREATE OPERATOR >= ( - FUNCTION = eql_v2.ore_cllw_gte, - LEFTARG = eql_v2.ore_cllw, - RIGHTARG = eql_v2.ore_cllw, - COMMUTATOR = <=, - NEGATOR = <, - RESTRICT = scalargesel, - JOIN = scalargejoinsel -); - - ---! @brief Extract Bloom filter index term from JSONB payload ---! ---! Extracts the Bloom filter array from the 'bf' field of an encrypted ---! data payload. Used internally for pattern-match queries (LIKE operator). ---! ---! @param jsonb containing encrypted EQL payload ---! @return eql_v2.bloom_filter Bloom filter as smallint array ---! @throws Exception if 'bf' field is missing when bloom_filter index is expected ---! ---! @see eql_v2.has_bloom_filter ---! @see eql_v2."~~" -CREATE FUNCTION eql_v2.bloom_filter(val jsonb) - RETURNS eql_v2.bloom_filter - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF val IS NULL THEN - RETURN NULL; - END IF; - - IF eql_v2.has_bloom_filter(val) THEN - RETURN ARRAY(SELECT jsonb_array_elements(val->'bf'))::eql_v2.bloom_filter; - END IF; - - RAISE 'Expected a match index (bf) value in json: %', val; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Extract Bloom filter index term from encrypted column value ---! ---! Extracts the Bloom filter from an encrypted column value by accessing ---! its underlying JSONB data field. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return eql_v2.bloom_filter Bloom filter as smallint array ---! ---! @see eql_v2.bloom_filter(jsonb) -CREATE FUNCTION eql_v2.bloom_filter(val eql_v2_encrypted) - RETURNS eql_v2.bloom_filter - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN (SELECT eql_v2.bloom_filter(val.data)); - END; -$$ LANGUAGE plpgsql; - - ---! @brief Check if JSONB payload contains Bloom filter index term ---! ---! Tests whether the encrypted data payload includes a 'bf' field, ---! indicating a Bloom filter is available for pattern-match queries. ---! ---! @param jsonb containing encrypted EQL payload ---! @return Boolean True if 'bf' field is present and non-null ---! ---! @see eql_v2.bloom_filter -CREATE FUNCTION eql_v2.has_bloom_filter(val jsonb) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN val ->> 'bf' IS NOT NULL; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Check if encrypted column value contains Bloom filter index term ---! ---! Tests whether an encrypted column value includes a Bloom filter ---! by checking its underlying JSONB data field. ---! ---! @param eql_v2_encrypted Encrypted column value ---! @return Boolean True if Bloom filter is present ---! ---! @see eql_v2.has_bloom_filter(jsonb) -CREATE FUNCTION eql_v2.has_bloom_filter(val eql_v2_encrypted) - RETURNS boolean - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.has_bloom_filter(val.data); - END; -$$ LANGUAGE plpgsql; - ---! @file src/ste_vec/eq_term.sql ---! @brief XOR-aware equality term extractor for \`eql_v2.ste_vec_entry\` ---! ---! Returns the bytea representation of whichever deterministic term ---! the sv entry carries — \`hm\` (HMAC-256) for bool leaves / array ---! roots / object roots, or \`oc\` (CLLW ORE) for string / number ---! leaves. The two byte distributions are disjoint by construction ---! (different keys, different protocols), so byte equality on the ---! coalesce is unambiguous: equal terms imply equal plaintexts under ---! the same selector, and unequal terms imply different plaintexts ---! (or different protocols, which can't happen for a single ---! selector). ---! ---! This is the canonical equality extractor used by \`=\` and \`<>\` on ---! \`eql_v2.ste_vec_entry\` — see \`src/operators/ste_vec_entry.sql\`. ---! The recipe for field-level equality on encrypted JSON is: ---! ---! @example ---! -- Functional hash index covers both hm-bearing and oc-bearing selectors ---! CREATE INDEX ON users USING hash (eql_v2.eq_term(data -> '')); ---! -- Bare-form predicate matches via the inlined \`=\` on ste_vec_entry ---! SELECT * FROM users WHERE data -> '' = $1::eql_v2.ste_vec_entry; ---! ---! @param entry eql_v2.ste_vec_entry STE-vec entry (extracted via \`->\`) ---! @return bytea Decoded \`hm\` or \`oc\` bytes (NULL if entry is NULL). ---! ---! @note The XOR contract (each sv entry carries exactly one of \`hm\` ---! or \`oc\` — enforced by the \`ste_vec_entry\` DOMAIN CHECK) means ---! the coalesce always picks the one present term. ---! ---! @see eql_v2.hmac_256(eql_v2.ste_vec_entry) ---! @see eql_v2.ore_cllw(eql_v2.ste_vec_entry) ---! @see src/operators/ste_vec_entry.sql -CREATE FUNCTION eql_v2.eq_term(entry eql_v2.ste_vec_entry) - RETURNS bytea - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT decode(coalesce(entry ->> 'hm', entry ->> 'oc'), 'hex') -$$; - ---! @brief Extract ORE index term for ordering encrypted values ---! ---! Helper function that extracts the ore_block_u64_8_256 index term from an encrypted value ---! for use in ORDER BY clauses when comparison operators are not appropriate or available. ---! ---! @param eql_v2_encrypted Encrypted value to extract order term from ---! @return eql_v2.ore_block_u64_8_256 ORE index term for ordering ---! ---! @example ---! -- Order encrypted values without using comparison operators ---! SELECT * FROM users ORDER BY eql_v2.order_by(encrypted_age); ---! ---! @note Requires 'ore' index configuration on the column ---! @see eql_v2.ore_block_u64_8_256 ---! @see eql_v2.add_search_config -CREATE FUNCTION eql_v2.order_by(a eql_v2_encrypted) - RETURNS eql_v2.ore_block_u64_8_256 - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.ore_block_u64_8_256(a); - END; -$$ LANGUAGE plpgsql; - ---! @brief Fallback literal comparison for encrypted values ---! @internal ---! ---! Compares two encrypted values by their raw JSONB representation when no ---! suitable index terms are available. This ensures consistent ordering required ---! for btree correctness and prevents "lock BufferContent is not held" errors. ---! ---! Used as a last resort fallback in eql_v2.compare() when encrypted values ---! lack matching index terms (hmac_256, ore). ---! ---! @param a eql_v2_encrypted First encrypted value ---! @param b eql_v2_encrypted Second encrypted value ---! @return integer -1 if a < b, 0 if a = b, 1 if a > b ---! ---! @note This compares the encrypted payloads directly, not the plaintext values ---! @note Ordering is consistent but not meaningful for range queries ---! @see eql_v2.compare -CREATE FUNCTION eql_v2.compare_literal(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS integer - IMMUTABLE STRICT PARALLEL SAFE - LANGUAGE SQL -AS $$ - SELECT CASE - WHEN a.data < b.data THEN -1 - WHEN a.data > b.data THEN 1 - ELSE 0 - END; -$$; - - ---! @brief Compare two encrypted values using ORE block index terms ---! ---! Performs a three-way comparison (returns -1/0/1) of encrypted values using ---! their ORE block index terms. Used internally by range operators (<, <=, >, >=) ---! for order-revealing comparisons without decryption. ---! ---! @param a eql_v2_encrypted First encrypted value to compare ---! @param b eql_v2_encrypted Second encrypted value to compare ---! @return Integer -1 if a < b, 0 if a = b, 1 if a > b ---! ---! @note NULL values are sorted before non-NULL values ---! @note Uses ORE cryptographic protocol for secure comparisons ---! ---! @see eql_v2.ore_block_u64_8_256 ---! @see eql_v2.has_ore_block_u64_8_256 ---! @see eql_v2."<" ---! @see eql_v2.">" -CREATE FUNCTION eql_v2.compare_ore_block_u64_8_256(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS integer - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - a_term eql_v2.ore_block_u64_8_256; - b_term eql_v2.ore_block_u64_8_256; - BEGIN - - IF a IS NULL AND b IS NULL THEN - RETURN 0; - END IF; - - IF a IS NULL THEN - RETURN -1; - END IF; - - IF b IS NULL THEN - RETURN 1; - END IF; - - IF eql_v2.has_ore_block_u64_8_256(a) THEN - a_term := eql_v2.ore_block_u64_8_256(a); - END IF; - - IF eql_v2.has_ore_block_u64_8_256(a) THEN - b_term := eql_v2.ore_block_u64_8_256(b); - END IF; - - IF a_term IS NULL AND b_term IS NULL THEN - RETURN 0; - END IF; - - IF a_term IS NULL THEN - RETURN -1; - END IF; - - IF b_term IS NULL THEN - RETURN 1; - END IF; - - RETURN eql_v2.compare_ore_block_u64_8_256_terms(a_term.terms, b_term.terms); - END; -$$ LANGUAGE plpgsql; - - ---! @brief Less-than comparison helper for encrypted values ---! @internal ---! @deprecated Slated for removal in EQL 3.0. Use the \`<\` operator instead. ---! ---! Internal helper that delegates to \`eql_v2.compare\` for less-than ---! testing. The \`<\` operator wrappers no longer call this helper — they ---! inline a direct \`ore_block_u64_8_256\` comparison instead (see the ---! inlinable bodies below). ---! ---! @warning Behaviour now diverges from the \`<\` operator: this helper ---! still walks \`eql_v2.compare\`'s priority list (ore_block → ore_cllw ---! → hm), whereas \`<\` goes straight to \`ore_block_u64_8_256\` and raises ---! on missing \`ob\`. Callers relying on the dispatcher fallback should ---! migrate to the extractor form: \`eql_v2.ore_cllw(col) < ---! eql_v2.ore_cllw($1::jsonb)\`. See U-005. ---! ---! @param a eql_v2_encrypted First encrypted value ---! @param b eql_v2_encrypted Second encrypted value ---! @return Boolean True if a < b (compare result = -1) ---! ---! @see eql_v2.compare ---! @see eql_v2."<" -CREATE FUNCTION eql_v2.lt(a eql_v2_encrypted, b eql_v2_encrypted) -RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.compare(a, b) = -1; - END; -$$ LANGUAGE plpgsql; - ---! @brief Less-than operator for encrypted values ---! ---! Implements the < operator for comparing two encrypted values via their ---! \`ob\` (ore_block_u64_8_256) ORE term. Enables range queries and sorting ---! without decryption. Requires the column to carry an \`ob\` term (configured ---! via the \`ore\` index in the EQL schema). ---! ---! @param a eql_v2_encrypted Left operand ---! @param b eql_v2_encrypted Right operand ---! @return Boolean True if a is less than b ---! ---! @example ---! -- Range query on encrypted timestamps ---! SELECT * FROM events ---! WHERE encrypted_timestamp < '2024-01-01'::timestamp::text::eql_v2_encrypted; ---! ---! -- Compare encrypted numeric columns ---! SELECT * FROM products WHERE encrypted_price < encrypted_discount_price; ---! ---! @see eql_v2.ore_block_u64_8_256 ---! @see eql_v2.add_search_config --- Inlinable: \`LANGUAGE sql IMMUTABLE\` with a single SELECT body and no --- \`SET\` clause. The Postgres planner inlines the body into the calling --- query during planning, so \`WHERE col < val\` reduces to --- \`WHERE eql_v2.ore_block_u64_8_256(col) < eql_v2.ore_block_u64_8_256(val)\` --- and matches a functional btree index built on --- \`eql_v2.ore_block_u64_8_256(col)\` (using the DEFAULT --- \`eql_v2.ore_block_u64_8_256_operator_class\`). Bare range queries --- (\`WHERE col < $1\`) engage the functional ORE index on Supabase and any --- install that doesn't ship \`eql_v2.encrypted_operator_class\`. --- --- Behaviour change vs the previous dispatcher-based impl: the old --- \`eql_v2."<"\` walked \`eql_v2.compare\`, which dispatched through --- ore_block / ore_cllw_u64 / ore_cllw_var / ope. Now \`<\` requires the --- column to have \`ore_block_u64_8_256\` configured (i.e. carry an \`ob\` --- field). Calling \`<\` on a column with only \`ore_cllw_*\` or OPE terms --- now raises from the \`ore_block_u64_8_256(jsonb)\` extractor --- (\`Expected an ore index (ob) value in json: ...\`) where it --- previously returned a Boolean. Loud failure surfaces config errors --- rather than silently producing zero rows — see U-005. -CREATE FUNCTION eql_v2."<"(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) < eql_v2.ore_block_u64_8_256(b) -$$; - -CREATE OPERATOR <( - FUNCTION=eql_v2."<", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted, - COMMUTATOR = >, - NEGATOR = >=, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel -); - ---! @brief Less-than operator for encrypted value and JSONB ---! ---! Overload of < operator accepting JSONB on the right side. Reduces to a ---! direct comparison of the \`ob\` ORE term on both sides; the jsonb ---! extractor \`eql_v2.ore_block_u64_8_256(jsonb)\` reads \`b->'ob'\` directly. ---! ---! @param eql_v2_encrypted Left operand (encrypted value) ---! @param b JSONB Right operand ---! @return Boolean True if a < b ---! ---! @example ---! SELECT * FROM events WHERE encrypted_age < '{"ob":[...]}'::jsonb; ---! ---! @see eql_v2."<"(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."<"(a eql_v2_encrypted, b jsonb) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) < eql_v2.ore_block_u64_8_256(b) -$$; - -CREATE OPERATOR <( - FUNCTION=eql_v2."<", - LEFTARG=eql_v2_encrypted, - RIGHTARG=jsonb, - COMMUTATOR = >, - NEGATOR = >=, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel -); - ---! @brief Less-than operator for JSONB and encrypted value ---! ---! Overload of < operator accepting JSONB on the left side. Reduces to a ---! direct comparison of the \`ob\` ORE term on both sides. ---! ---! @param a JSONB Left operand ---! @param eql_v2_encrypted Right operand (encrypted value) ---! @return Boolean True if a < b ---! ---! @example ---! SELECT * FROM events WHERE '{"ob":[...]}'::jsonb < encrypted_date; ---! ---! @see eql_v2."<"(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."<"(a jsonb, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) < eql_v2.ore_block_u64_8_256(b) -$$; - - -CREATE OPERATOR <( - FUNCTION=eql_v2."<", - LEFTARG=jsonb, - RIGHTARG=eql_v2_encrypted, - COMMUTATOR = >, - NEGATOR = >=, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel -); - ---! @brief Less-than-or-equal comparison helper for encrypted values ---! @internal ---! @deprecated Slated for removal in EQL 3.0. Use the \`<=\` operator instead. ---! ---! Internal helper that delegates to \`eql_v2.compare\` for \`<=\` testing. ---! The \`<=\` operator wrappers no longer go through this helper — see the ---! inlinable bodies below. ---! ---! @warning Behaviour now diverges from the \`<=\` operator: this helper ---! still walks \`eql_v2.compare\`'s priority list, whereas \`<=\` goes ---! straight to \`ore_block_u64_8_256\` and raises on missing \`ob\`. See ---! the matching note on \`eql_v2.lt\` and U-005 for migration guidance. ---! ---! @param a eql_v2_encrypted First encrypted value ---! @param b eql_v2_encrypted Second encrypted value ---! @return Boolean True if a <= b (compare result <= 0) ---! ---! @see eql_v2.compare ---! @see eql_v2."<=" -CREATE FUNCTION eql_v2.lte(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.compare(a, b) <= 0; - END; -$$ LANGUAGE plpgsql; - ---! @brief Less-than-or-equal operator for encrypted values ---! ---! Implements the <= operator for comparing two encrypted values via their ---! \`ob\` (ore_block_u64_8_256) ORE term. Requires the column to carry an ---! \`ob\` term. ---! ---! @param a eql_v2_encrypted Left operand ---! @param b eql_v2_encrypted Right operand ---! @return Boolean True if a <= b ---! ---! @example ---! SELECT * FROM users WHERE encrypted_age <= '18'::int::text::eql_v2_encrypted; ---! ---! @see eql_v2.ore_block_u64_8_256 ---! @see eql_v2.add_search_config --- Inlinable: see \`src/operators/<.sql\` for the rationale. -CREATE FUNCTION eql_v2."<="(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) <= eql_v2.ore_block_u64_8_256(b) -$$; - -CREATE OPERATOR <=( - FUNCTION = eql_v2."<=", - LEFTARG = eql_v2_encrypted, - RIGHTARG = eql_v2_encrypted, - COMMUTATOR = >=, - NEGATOR = >, - RESTRICT = scalarlesel, - JOIN = scalarlejoinsel -); - ---! @brief <= operator for encrypted value and JSONB ---! @see eql_v2."<="(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."<="(a eql_v2_encrypted, b jsonb) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) <= eql_v2.ore_block_u64_8_256(b) -$$; - -CREATE OPERATOR <=( - FUNCTION = eql_v2."<=", - LEFTARG = eql_v2_encrypted, - RIGHTARG = jsonb, - COMMUTATOR = >=, - NEGATOR = >, - RESTRICT = scalarlesel, - JOIN = scalarlejoinsel -); - ---! @brief <= operator for JSONB and encrypted value ---! @see eql_v2."<="(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."<="(a jsonb, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) <= eql_v2.ore_block_u64_8_256(b) -$$; - - -CREATE OPERATOR <=( - FUNCTION = eql_v2."<=", - LEFTARG = jsonb, - RIGHTARG = eql_v2_encrypted, - COMMUTATOR = >=, - NEGATOR = >, - RESTRICT = scalarlesel, - JOIN = scalarlejoinsel -); - ---! @brief Equality helper for encrypted values ---! @internal ---! ---! Inlinable SQL helper mirroring the \`=\` operator's body: reduces to ---! \`hmac_256(a) = hmac_256(b)\`. Kept for callers that invoked the ---! pre-#193 form (\`eql_v2.eq\`); equivalent to using the \`=\` operator ---! directly. ---! ---! Equality on \`eql_v2_encrypted\` is strictly hmac-based (see U-002). ---! Returns NULL when either side lacks an \`hm\` term — matching the ---! \`=\` operator's behaviour. ---! ---! @param a eql_v2_encrypted First encrypted value ---! @param b eql_v2_encrypted Second encrypted value ---! @return Boolean True if hmac terms match ---! ---! @see eql_v2."=" ---! @see eql_v2.hmac_256 -CREATE FUNCTION eql_v2.eq(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.hmac_256(a) = eql_v2.hmac_256(b) -$$; - ---! @brief Equality operator for encrypted values ---! ---! Implements the = operator for comparing two encrypted values using their ---! encrypted index terms (hmac_256). Enables WHERE clause comparisons ---! without decryption. ---! ---! @param a eql_v2_encrypted Left operand ---! @param b eql_v2_encrypted Right operand ---! @return Boolean True if encrypted values are equal ---! ---! @example ---! -- Compare encrypted columns ---! SELECT * FROM users WHERE encrypted_email = other_encrypted_email; ---! ---! -- Search using encrypted literal ---! SELECT * FROM users ---! WHERE encrypted_email = '{"c":"...","i":{"unique":"..."}}'::eql_v2_encrypted; ---! ---! @see eql_v2.compare ---! @see eql_v2.add_search_config --- Inlinable: \`LANGUAGE sql IMMUTABLE\` with a single SELECT body and no --- \`SET\` clause. The Postgres planner inlines the body into the calling --- query during planning, so \`WHERE col = val\` reduces to --- \`WHERE eql_v2.hmac_256(col) = eql_v2.hmac_256(val)\` and matches a --- functional hash index built on \`eql_v2.hmac_256(col)\`. Bare equality --- queries (including those issued by PostgREST and ORMs that don't --- wrap columns themselves) become fast on Supabase and any --- --exclude-operator-family install. --- --- Behaviour change vs the previous dispatcher-based impl: the old --- \`eql_v2.eq\` walked \`eql_v2.compare\`, which fell back to ORE / Blake3 / --- literal comparison when HMAC wasn't present. Now \`=\` requires the --- column to have \`equality\` configured (i.e. carry an \`hm\` field). --- Calling \`=\` on an ORE-only column will return NULL where it --- previously returned a Boolean. This is intentional — it surfaces --- config errors loudly. See the predicate/extractor RFC for context. -CREATE FUNCTION eql_v2."="(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.hmac_256(a) = eql_v2.hmac_256(b) -$$; - -CREATE OPERATOR = ( - FUNCTION=eql_v2."=", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted, - NEGATOR = <>, - RESTRICT = eqsel, - JOIN = eqjoinsel, - HASHES, - MERGES -); - ---! @brief Equality operator for encrypted value and JSONB ---! ---! Overload of = operator accepting JSONB on the right side. Automatically ---! casts JSONB to eql_v2_encrypted for comparison. Useful for comparing ---! against JSONB literals or columns. ---! ---! @param eql_v2_encrypted Left operand (encrypted value) ---! @param b JSONB Right operand (will be cast to eql_v2_encrypted) ---! @return Boolean True if values are equal ---! ---! @example ---! -- Compare encrypted column to JSONB literal ---! SELECT * FROM users ---! WHERE encrypted_email = '{"c":"...","i":{"unique":"..."}}'::jsonb; ---! ---! @see eql_v2."="(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."="(a eql_v2_encrypted, b jsonb) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.hmac_256(a) = eql_v2.hmac_256(b::eql_v2_encrypted) -$$; - -CREATE OPERATOR = ( - FUNCTION=eql_v2."=", - LEFTARG=eql_v2_encrypted, - RIGHTARG=jsonb, - NEGATOR = <>, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - ---! @brief Equality operator for JSONB and encrypted value ---! ---! Overload of = operator accepting JSONB on the left side. Automatically ---! casts JSONB to eql_v2_encrypted for comparison. Enables commutative ---! equality comparisons. ---! ---! @param a JSONB Left operand (will be cast to eql_v2_encrypted) ---! @param eql_v2_encrypted Right operand (encrypted value) ---! @return Boolean True if values are equal ---! ---! @example ---! -- Compare JSONB literal to encrypted column ---! SELECT * FROM users ---! WHERE '{"c":"...","i":{"unique":"..."}}'::jsonb = encrypted_email; ---! ---! @see eql_v2."="(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."="(a jsonb, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.hmac_256(a::eql_v2_encrypted) = eql_v2.hmac_256(b) -$$; - -CREATE OPERATOR = ( - FUNCTION=eql_v2."=", - LEFTARG=jsonb, - RIGHTARG=eql_v2_encrypted, - NEGATOR = <>, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - - ---! @brief Greater-than-or-equal comparison helper for encrypted values ---! @internal ---! @deprecated Slated for removal in EQL 3.0. Use the \`>=\` operator instead. ---! ---! Internal helper that delegates to \`eql_v2.compare\` for \`>=\` testing. ---! The \`>=\` operator wrappers no longer go through this helper — see the ---! inlinable bodies below. ---! ---! @warning Behaviour now diverges from the \`>=\` operator: this helper ---! still walks \`eql_v2.compare\`'s priority list, whereas \`>=\` goes ---! straight to \`ore_block_u64_8_256\` and raises on missing \`ob\`. See ---! the matching note on \`eql_v2.lt\` and U-005 for migration guidance. ---! ---! @param a eql_v2_encrypted First encrypted value ---! @param b eql_v2_encrypted Second encrypted value ---! @return Boolean True if a >= b (compare result >= 0) ---! ---! @see eql_v2.compare ---! @see eql_v2.">=" -CREATE FUNCTION eql_v2.gte(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.compare(a, b) >= 0; - END; -$$ LANGUAGE plpgsql; - ---! @brief Greater-than-or-equal operator for encrypted values ---! ---! Implements the >= operator for comparing two encrypted values via their ---! \`ob\` (ore_block_u64_8_256) ORE term. Requires the column to carry an ---! \`ob\` term. ---! ---! @param a eql_v2_encrypted Left operand ---! @param b eql_v2_encrypted Right operand ---! @return Boolean True if a >= b ---! ---! @example ---! SELECT * FROM users WHERE encrypted_age >= '18'::int::text::eql_v2_encrypted; ---! ---! @see eql_v2.ore_block_u64_8_256 ---! @see eql_v2.add_search_config --- Inlinable: see \`src/operators/<.sql\` for the rationale. -CREATE FUNCTION eql_v2.">="(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) >= eql_v2.ore_block_u64_8_256(b) -$$; - - -CREATE OPERATOR >=( - FUNCTION = eql_v2.">=", - LEFTARG = eql_v2_encrypted, - RIGHTARG = eql_v2_encrypted, - COMMUTATOR = <=, - NEGATOR = <, - RESTRICT = scalargesel, - JOIN = scalargejoinsel -); - ---! @brief >= operator for encrypted value and JSONB ---! @param a eql_v2_encrypted Left operand (encrypted value) ---! @param b jsonb Right operand ---! @return Boolean True if a >= b ---! @see eql_v2.">="(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2.">="(a eql_v2_encrypted, b jsonb) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) >= eql_v2.ore_block_u64_8_256(b) -$$; - -CREATE OPERATOR >=( - FUNCTION = eql_v2.">=", - LEFTARG = eql_v2_encrypted, - RIGHTARG=jsonb, - COMMUTATOR = <=, - NEGATOR = <, - RESTRICT = scalargesel, - JOIN = scalargejoinsel -); - ---! @brief >= operator for JSONB and encrypted value ---! @param a jsonb Left operand ---! @param b eql_v2_encrypted Right operand (encrypted value) ---! @return Boolean True if a >= b ---! @see eql_v2.">="(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2.">="(a jsonb, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) >= eql_v2.ore_block_u64_8_256(b) -$$; - - -CREATE OPERATOR >=( - FUNCTION = eql_v2.">=", - LEFTARG = jsonb, - RIGHTARG =eql_v2_encrypted, - COMMUTATOR = <=, - NEGATOR = <, - RESTRICT = scalargesel, - JOIN = scalargejoinsel -); - ---! @brief Greater-than comparison helper for encrypted values ---! @internal ---! @deprecated Slated for removal in EQL 3.0. Use the \`>\` operator instead. ---! ---! Internal helper that delegates to \`eql_v2.compare\` for greater-than ---! testing. The \`>\` operator wrappers no longer go through this helper — ---! see the inlinable bodies below. ---! ---! @warning Behaviour now diverges from the \`>\` operator: this helper ---! still walks \`eql_v2.compare\`'s priority list, whereas \`>\` goes ---! straight to \`ore_block_u64_8_256\` and raises on missing \`ob\`. See ---! the matching note on \`eql_v2.lt\` and U-005 for migration guidance. ---! ---! @param a eql_v2_encrypted First encrypted value ---! @param b eql_v2_encrypted Second encrypted value ---! @return Boolean True if a > b (compare result = 1) ---! ---! @see eql_v2.compare ---! @see eql_v2.">" -CREATE FUNCTION eql_v2.gt(a eql_v2_encrypted, b eql_v2_encrypted) -RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2.compare(a, b) = 1; - END; -$$ LANGUAGE plpgsql; - ---! @brief Greater-than operator for encrypted values ---! ---! Implements the > operator for comparing two encrypted values via their ---! \`ob\` (ore_block_u64_8_256) ORE term. Enables range queries and sorting ---! without decryption. Requires the column to carry an \`ob\` term. ---! ---! @param a eql_v2_encrypted Left operand ---! @param b eql_v2_encrypted Right operand ---! @return Boolean True if a is greater than b ---! ---! @example ---! SELECT * FROM events ---! WHERE encrypted_value > '100'::int::text::eql_v2_encrypted; ---! ---! @see eql_v2.ore_block_u64_8_256 ---! @see eql_v2.add_search_config --- Inlinable: see \`src/operators/<.sql\` for the rationale. Predicate --- \`WHERE col > val\` reduces to --- \`WHERE eql_v2.ore_block_u64_8_256(col) > eql_v2.ore_block_u64_8_256(val)\` --- and matches a functional ORE index built on the same expression. --- Breaking impact: columns with only \`ore_cllw_*\` or OPE terms now --- raise from the \`ore_block_u64_8_256(jsonb)\` extractor --- (\`Expected an ore index (ob) value in json: ...\`) where they --- previously fell through \`eql_v2.compare\`. See U-005. -CREATE FUNCTION eql_v2.">"(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) > eql_v2.ore_block_u64_8_256(b) -$$; - -CREATE OPERATOR >( - FUNCTION=eql_v2.">", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted, - COMMUTATOR = <, - NEGATOR = <=, - RESTRICT = scalargtsel, - JOIN = scalargtjoinsel -); - ---! @brief > operator for encrypted value and JSONB ---! @param a eql_v2_encrypted Left operand (encrypted value) ---! @param b jsonb Right operand ---! @return Boolean True if a > b ---! @see eql_v2.">"(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2.">"(a eql_v2_encrypted, b jsonb) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) > eql_v2.ore_block_u64_8_256(b) -$$; - -CREATE OPERATOR >( - FUNCTION = eql_v2.">", - LEFTARG = eql_v2_encrypted, - RIGHTARG = jsonb, - COMMUTATOR = <, - NEGATOR = <=, - RESTRICT = scalargtsel, - JOIN = scalargtjoinsel -); - ---! @brief > operator for JSONB and encrypted value ---! @param a jsonb Left operand ---! @param b eql_v2_encrypted Right operand (encrypted value) ---! @return Boolean True if a > b ---! @see eql_v2.">"(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2.">"(a jsonb, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_block_u64_8_256(a) > eql_v2.ore_block_u64_8_256(b) -$$; - - -CREATE OPERATOR >( - FUNCTION = eql_v2.">", - LEFTARG = jsonb, - RIGHTARG = eql_v2_encrypted, - COMMUTATOR = <, - NEGATOR = <=, - RESTRICT = scalargtsel, - JOIN = scalargtjoinsel -); - ---! @brief Compute hash integer for encrypted value ---! ---! Produces a 32-bit integer hash suitable for PostgreSQL hash joins, GROUP BY, ---! DISTINCT, and hash aggregate operations. Used by the \`eql_v2_encrypted\` hash ---! operator class (\`FUNCTION 1\`). Inlinable single-statement SQL — the SQL ---! function machinery is much cheaper per row than plpgsql, which matters ---! because HashAggregate / hash-join call this once per input row. ---! ---! Returns \`hashtext\` of the root payload's \`hm\` term. This is the canonical ---! bucket for equality groups, since \`=\` on \`eql_v2_encrypted\` reduces to ---! \`hmac_256(a) = hmac_256(b)\` post-#193. ---! ---! @par Contract ---! Callers using \`GROUP BY\` / \`DISTINCT\` / hash joins on \`eql_v2_encrypted\` ---! MUST configure the column with a \`unique\` index so the crypto layer ---! emits \`hm\` — \`hm\` is assumed present. A missing \`hm\` is a misconfiguration ---! that surfaces upstream via [U-002](docs/upgrading/v2.3.md#u-002-equality-and-hashing-require-hmac). ---! ---! @param val eql_v2_encrypted Encrypted value to hash ---! @return integer 32-bit hash value derived from \`hm\` ---! ---! @note For grouping a value extracted from an encrypted JSON document, use ---! the field-level recipe directly: \`GROUP BY eql_v2.eq_term(col -> '')\` ---! (covers both hm-bearing and oc-bearing selectors via the XOR-aware ---! extractor — see \`src/ste_vec/eq_term.sql\`). That bypasses ---! \`hash_encrypted\` entirely. ---! ---! @see eql_v2.hmac_256 ---! @see eql_v2.has_hmac_256 ---! @see eql_v2.compare -CREATE FUNCTION eql_v2.hash_encrypted(val eql_v2_encrypted) - RETURNS integer - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT pg_catalog.hashtext(eql_v2.hmac_256(val)::text) -$$; - ---! @brief Contains operator for encrypted values (@>) ---! ---! Implements the @> (contains) operator for testing if left encrypted value ---! contains the right encrypted value. Uses ste_vec (secure tree encoding vector) ---! index terms for containment testing without decryption. ---! ---! Primarily used for encrypted array or set containment queries. ---! ---! @param a eql_v2_encrypted Left operand (container) ---! @param b eql_v2_encrypted Right operand (contained value) ---! @return Boolean True if a contains b ---! ---! @example ---! -- Check if encrypted array contains value ---! SELECT * FROM documents ---! WHERE encrypted_tags @> '["security"]'::jsonb::eql_v2_encrypted; ---! ---! @note Requires ste_vec index configuration ---! @see eql_v2.ste_vec_contains ---! @see eql_v2.add_search_config --- Marked IMMUTABLE STRICT PARALLEL SAFE so the planner inlines the body --- and a functional GIN index on \`eql_v2.ste_vec(col)\` can match --- \`WHERE col @> val\`. The previous default-VOLATILE declaration prevented --- inlining and forced seq scan even on Supabase installs that have the --- ste_vec functional index in place. -CREATE FUNCTION eql_v2."@>"(a eql_v2_encrypted, b eql_v2_encrypted) -RETURNS boolean -LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ste_vec_contains(a, b) -$$; - -CREATE OPERATOR @>( - FUNCTION=eql_v2."@>", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted -); - - ---! @brief Contains operator (@>) with an \`eql_v2.stevec_query\` needle ---! ---! Type-safe containment for the recommended recipe: the right-hand ---! side is an \`stevec_query\` (sv-shaped payload, no \`c\` fields). The ---! body inlines to a native \`jsonb @>\` over \`eql_v2.to_stevec_query(a)::jsonb\`, ---! so the planner can match a functional GIN index built on the same ---! expression — engaging Bitmap Index Scan for bare-form containment ---! across both \`hm\`-bearing and \`oc\`-bearing selectors with a single ---! index. ---! ---! @param a eql_v2_encrypted Left operand (container) ---! @param b eql_v2.stevec_query Right operand (query payload) ---! @return Boolean True if a contains b ---! ---! @example ---! -- Functional GIN index (covers all selectors, hm and oc): ---! CREATE INDEX ON users USING gin ( ---! eql_v2.to_stevec_query(encrypted_doc)::jsonb jsonb_path_ops ---! ); ---! -- Bare-form predicate engages the index: ---! SELECT * FROM users ---! WHERE encrypted_doc @> '{"sv":[{"s":"","hm":""}]}'::eql_v2.stevec_query; ---! ---! @see eql_v2.stevec_query ---! @see eql_v2.to_stevec_query -CREATE FUNCTION eql_v2."@>"(a eql_v2_encrypted, b eql_v2.stevec_query) -RETURNS boolean -LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE -AS $$ - -- Single-expression body so the planner can inline. The haystack - -- normalisation happens in \`to_stevec_query\`; the needle is trusted - -- to be clean (sv elements of shape \`{s, hm-or-oc}\` — the documented - -- stevec_query contract). For untrusted needles, callers should - -- normalise via the json-shape \`{"sv":[{"s":"","hm":""}]}\`. - SELECT eql_v2.to_stevec_query(a)::jsonb @> b::jsonb -$$; - -CREATE OPERATOR @>( - FUNCTION=eql_v2."@>", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2.stevec_query -); - - ---! @brief Contains operator (@>) with an \`eql_v2.ste_vec_entry\` needle ---! ---! Convenience overload for the common pattern "does this encrypted ---! payload include this specific sv entry?". Wraps the entry into a ---! single-element sv array (stripping \`c\`) and reduces to the same ---! \`to_stevec_query(a)::jsonb @> needle::jsonb\` form as the ---! \`stevec_query\` overload — so it engages the same functional GIN ---! index. Inlinable. ---! ---! @param a eql_v2_encrypted Left operand (container) ---! @param b eql_v2.ste_vec_entry Right operand (single entry) ---! @return Boolean True if a contains an sv entry matching \`b\` ---! ---! @example ---! -- Does this row's encrypted doc contain the same name as this other doc? ---! SELECT a.* FROM docs a, docs b ---! WHERE a.doc @> (b.doc -> ''); ---! ---! @see eql_v2.ste_vec_entry ---! @see eql_v2."@>"(eql_v2_encrypted, eql_v2.stevec_query) -CREATE FUNCTION eql_v2."@>"(a eql_v2_encrypted, b eql_v2.ste_vec_entry) -RETURNS boolean -LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.to_stevec_query(a)::jsonb - @> jsonb_build_object( - 'sv', - jsonb_build_array( - jsonb_strip_nulls( - jsonb_build_object( - 's', b -> 's', - 'hm', b -> 'hm', - 'oc', b -> 'oc' - ) - ) - ) - ) -$$; - -CREATE OPERATOR @>( - FUNCTION=eql_v2."@>", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2.ste_vec_entry -); - ---! @file config/tables.sql ---! @brief Encryption configuration storage table ---! ---! Defines the main table for storing EQL v2 encryption configurations. ---! Each row represents a configuration specifying which tables/columns to encrypt ---! and what index types to use. Configurations progress through lifecycle states. ---! ---! @see config/types.sql for state ENUM definition ---! @see config/indexes.sql for state uniqueness constraints ---! @see config/constraints.sql for data validation - - ---! @brief Encryption configuration table ---! ---! Stores encryption configurations with their state and metadata. ---! The 'data' JSONB column contains the full configuration structure including ---! table/column mappings, index types, and casting rules. ---! ---! @note Only one configuration can be 'active', 'pending', or 'encrypting' at once ---! @note 'id' is auto-generated identity column ---! @note 'state' defaults to 'pending' for new configurations ---! @note 'data' validated by CHECK constraint (see config/constraints.sql) -CREATE TABLE IF NOT EXISTS public.eql_v2_configuration -( - id bigint GENERATED ALWAYS AS IDENTITY, - state eql_v2_configuration_state NOT NULL DEFAULT 'pending', - data jsonb, - created_at timestamptz not null default current_timestamp, - PRIMARY KEY(id) -); - - ---! @brief Initialize default configuration structure ---! @internal ---! ---! Creates a default configuration object if input is NULL. Used internally ---! by public configuration functions to ensure consistent structure. ---! ---! @param config JSONB Existing configuration or NULL ---! @return JSONB Configuration with default structure (version 1, empty tables) -CREATE FUNCTION eql_v2.config_default(config jsonb) - RETURNS jsonb - IMMUTABLE PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF config IS NULL THEN - SELECT jsonb_build_object('v', 1, 'tables', jsonb_build_object()) INTO config; - END IF; - RETURN config; - END; -$$ LANGUAGE plpgsql; - ---! @brief Add table to configuration if not present ---! @internal ---! ---! Ensures the specified table exists in the configuration structure. ---! Creates empty table entry if needed. Idempotent operation. ---! ---! @param table_name Text Name of table to add ---! @param config JSONB Configuration object ---! @return JSONB Updated configuration with table entry -CREATE FUNCTION eql_v2.config_add_table(table_name text, config jsonb) - RETURNS jsonb - IMMUTABLE PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - tbl jsonb; - BEGIN - IF NOT config #> array['tables'] ? table_name THEN - SELECT jsonb_insert(config, array['tables', table_name], jsonb_build_object()) INTO config; - END IF; - RETURN config; - END; -$$ LANGUAGE plpgsql; - ---! @brief Add column to table configuration if not present ---! @internal ---! ---! Ensures the specified column exists in the table's configuration structure. ---! Creates empty column entry with indexes object if needed. Idempotent operation. ---! ---! @param table_name Text Name of parent table ---! @param column_name Text Name of column to add ---! @param config JSONB Configuration object ---! @return JSONB Updated configuration with column entry -CREATE FUNCTION eql_v2.config_add_column(table_name text, column_name text, config jsonb) - RETURNS jsonb - IMMUTABLE PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - col jsonb; - BEGIN - IF NOT config #> array['tables', table_name] ? column_name THEN - SELECT jsonb_build_object('indexes', jsonb_build_object()) into col; - SELECT jsonb_set(config, array['tables', table_name, column_name], col) INTO config; - END IF; - RETURN config; - END; -$$ LANGUAGE plpgsql; - ---! @brief Set cast type for column in configuration ---! @internal ---! ---! Updates the cast_as field for a column, specifying the PostgreSQL type ---! that decrypted values should be cast to. ---! ---! @param table_name Text Name of parent table ---! @param column_name Text Name of column ---! @param cast_as Text PostgreSQL type for casting (e.g., 'text', 'int', 'jsonb') ---! @param config JSONB Configuration object ---! @return JSONB Updated configuration with cast_as set -CREATE FUNCTION eql_v2.config_add_cast(table_name text, column_name text, cast_as text, config jsonb) - RETURNS jsonb - IMMUTABLE PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - SELECT jsonb_set(config, array['tables', table_name, column_name, 'cast_as'], to_jsonb(cast_as)) INTO config; - RETURN config; - END; -$$ LANGUAGE plpgsql; - ---! @brief Add search index to column configuration ---! @internal ---! ---! Inserts a search index entry (unique, match, ore, ste_vec) with its options ---! into the column's indexes object. ---! ---! @param table_name Text Name of parent table ---! @param column_name Text Name of column ---! @param index_name Text Type of index to add ---! @param opts JSONB Index-specific options ---! @param config JSONB Configuration object ---! @return JSONB Updated configuration with index added -CREATE FUNCTION eql_v2.config_add_index(table_name text, column_name text, index_name text, opts jsonb, config jsonb) - RETURNS jsonb - IMMUTABLE PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - SELECT jsonb_insert(config, array['tables', table_name, column_name, 'indexes', index_name], opts) INTO config; - RETURN config; - END; -$$ LANGUAGE plpgsql; - ---! @brief Generate default options for match index ---! @internal ---! ---! Returns default configuration for match (LIKE) indexes: k=6, bf=2048, ---! ngram tokenizer with token_length=3, downcase filter, include_original=true. ---! ---! @return JSONB Default match index options -CREATE FUNCTION eql_v2.config_match_default() - RETURNS jsonb -LANGUAGE sql STRICT PARALLEL SAFE -BEGIN ATOMIC - SELECT jsonb_build_object( - 'k', 6, - 'bf', 2048, - 'include_original', true, - 'tokenizer', json_build_object('kind', 'ngram', 'token_length', 3), - 'token_filters', json_build_array(json_build_object('kind', 'downcase'))); -END; --- AUTOMATICALLY GENERATED FILE --- Source is version-template.sql - -DROP FUNCTION IF EXISTS eql_v2.version(); - ---! @file version.sql ---! @brief EQL version reporting ---! ---! This file is auto-generated from version.template during build. ---! The version string placeholder is replaced with the actual release version. - ---! @brief Get EQL library version string ---! ---! Returns the version string for the installed EQL library. ---! This value is set at build time from the project version. ---! ---! @return text Version string (e.g., "2.1.0" or "DEV" for development builds) ---! ---! @note Auto-generated during build from version.template ---! ---! @example ---! -- Check installed EQL version ---! SELECT eql_v2.version(); ---! -- Returns: '2.1.0' -CREATE FUNCTION eql_v2.version() - RETURNS text - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT 'eql-2.3.1'; -$$ LANGUAGE SQL; - - ---! @file src/ore_cllw/operator_class.sql ---! @brief Btree operator class on the \`eql_v2.ore_cllw\` composite type ---! ---! Registers the CLLW per-byte comparison operators as a btree opclass for ---! the \`eql_v2.ore_cllw\` composite type. With \`DEFAULT FOR TYPE\`, a functional ---! btree index on \`eql_v2.ore_cllw(col)\` (or any expression returning the ---! composite) automatically picks up this opclass — no annotation needed at ---! index creation time. ---! ---! Why this matters. After the consolidation in #219, ordered comparison on ---! sv-element values (via \`eql_v2.ore_cllw(value -> ''::text)\`) ---! has correct semantics through the operator backing functions (each ---! reduces to \`compare_ore_cllw_term 0\`), but PostgreSQL won't engage ---! a functional index for \`ORDER BY ...\` or \`WHERE ... < $1\` unless the ---! type has a registered btree opclass that the planner can structurally ---! match. Without this opclass, \`field_order/*\` queries on sv-element CLLW ---! columns fall back to seq scan + Top-N sort (measured 20s+ on 1M rows). ---! With it, the same queries become Index Scan + LIMIT — milliseconds. ---! ---! FUNCTION 1 is the three-way comparator that btree's internal sort uses ---! (returns -1 / 0 / +1). We point it at \`compare_ore_cllw_term\` directly: ---! that's plpgsql by design (the per-byte CLLW protocol needs iteration), ---! and btree calls it once per index entry pair during build / search — ---! not per-row in the outer query. ---! ---! @note Deliberately no operator family registration beyond the opclass ---! itself: no cross-type operators on \`eql_v2.ore_cllw\` × \`jsonb\`, no ---! hash support — see operators.sql for the rationale. ---! @note Excluded from the Supabase build variant (the build glob ---! \`**/*operator_class.sql\` strips operator classes for Supabase ---! compatibility). ---! ---! @see src/ore_cllw/operators.sql ---! @see src/ore_cllw/functions.sql - -CREATE OPERATOR FAMILY eql_v2.ore_cllw_ops USING btree; - -CREATE OPERATOR CLASS eql_v2.ore_cllw_ops - DEFAULT FOR TYPE eql_v2.ore_cllw - USING btree FAMILY eql_v2.ore_cllw_ops AS - OPERATOR 1 < (eql_v2.ore_cllw, eql_v2.ore_cllw), - OPERATOR 2 <= (eql_v2.ore_cllw, eql_v2.ore_cllw), - OPERATOR 3 = (eql_v2.ore_cllw, eql_v2.ore_cllw), - OPERATOR 4 >= (eql_v2.ore_cllw, eql_v2.ore_cllw), - OPERATOR 5 > (eql_v2.ore_cllw, eql_v2.ore_cllw), - FUNCTION 1 eql_v2.compare_ore_cllw_term(eql_v2.ore_cllw, eql_v2.ore_cllw); - - ---! @brief B-tree operator family for ORE block types ---! ---! Defines the operator family for creating B-tree indexes on ORE block types. ---! ---! @see eql_v2.ore_block_u64_8_256_operator_class -CREATE OPERATOR FAMILY eql_v2.ore_block_u64_8_256_operator_family USING btree; - ---! @brief B-tree operator class for ORE block encrypted values ---! ---! Defines the operator class required for creating B-tree indexes on columns ---! using the ore_block_u64_8_256 type. Enables range queries and ORDER BY on ---! ORE-encrypted data without decryption. ---! ---! Supports operators: <, <=, =, >=, > ---! Uses comparison function: compare_ore_block_u64_8_256_terms ---! ---! ---! @example ---! -- Would be used like (if enabled): ---! CREATE INDEX ON events USING btree ( ---! (encrypted_timestamp::jsonb->'ob')::eql_v2.ore_block_u64_8_256 ---! ); ---! ---! @see CREATE OPERATOR CLASS in PostgreSQL documentation ---! @see eql_v2.compare_ore_block_u64_8_256_terms -CREATE OPERATOR CLASS eql_v2.ore_block_u64_8_256_operator_class DEFAULT FOR TYPE eql_v2.ore_block_u64_8_256 USING btree FAMILY eql_v2.ore_block_u64_8_256_operator_family AS - OPERATOR 1 <, - OPERATOR 2 <=, - OPERATOR 3 =, - OPERATOR 4 >=, - OPERATOR 5 >, - FUNCTION 1 eql_v2.compare_ore_block_u64_8_256_terms(a eql_v2.ore_block_u64_8_256, b eql_v2.ore_block_u64_8_256); - ---! @brief Cast text to ORE block term ---! @internal ---! ---! Converts text to bytea and wraps in ore_block_u64_8_256_term type. ---! Used internally for ORE block extraction and manipulation. ---! ---! @param t Text Text value to convert ---! @return eql_v2.ore_block_u64_8_256_term ORE term containing bytea representation ---! ---! @see eql_v2.ore_block_u64_8_256_term -CREATE FUNCTION eql_v2.text_to_ore_block_u64_8_256_term(t text) - RETURNS eql_v2.ore_block_u64_8_256_term - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -BEGIN ATOMIC - RETURN t::bytea; -END; - ---! @brief Implicit cast from text to ORE block term ---! ---! Defines an implicit cast allowing automatic conversion of text values ---! to ore_block_u64_8_256_term type for ORE operations. ---! ---! @see eql_v2.text_to_ore_block_u64_8_256_term -CREATE CAST (text AS eql_v2.ore_block_u64_8_256_term) - WITH FUNCTION eql_v2.text_to_ore_block_u64_8_256_term(text) AS IMPLICIT; - ---! @brief Pattern matching helper using bloom filters ---! @internal ---! ---! Internal helper for LIKE-style pattern matching on encrypted values. ---! Uses bloom filter index terms to test substring containment without decryption. ---! Requires 'match' index configuration on the column. ---! ---! Marked IMMUTABLE so the planner inlines the body and a functional index on ---! \`eql_v2.bloom_filter(col)\` can match \`WHERE eql_v2.like(col, val)\`. ---! ---! @param a eql_v2_encrypted Haystack (value to search in) ---! @param b eql_v2_encrypted Needle (pattern to search for) ---! @return Boolean True if bloom filter of a contains bloom filter of b ---! ---! @see eql_v2."~~" ---! @see eql_v2.bloom_filter ---! @see eql_v2.add_search_config -CREATE FUNCTION eql_v2.like(a eql_v2_encrypted, b eql_v2_encrypted) -RETURNS boolean -LANGUAGE SQL -IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b); -$$; - ---! @brief Case-insensitive pattern matching helper ---! @internal ---! ---! Internal helper for ILIKE-style case-insensitive pattern matching. ---! Case sensitivity is controlled by index configuration (token_filters with downcase). ---! This function has same implementation as like() - actual case handling is in index terms. ---! ---! @param a eql_v2_encrypted Haystack (value to search in) ---! @param b eql_v2_encrypted Needle (pattern to search for) ---! @return Boolean True if bloom filter of a contains bloom filter of b ---! ---! @note Case sensitivity depends on match index token_filters configuration ---! @see eql_v2."~~" ---! @see eql_v2.add_search_config -CREATE FUNCTION eql_v2.ilike(a eql_v2_encrypted, b eql_v2_encrypted) -RETURNS boolean -LANGUAGE SQL -IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b); -$$; - ---! @brief LIKE operator for encrypted values (pattern matching) ---! ---! Implements the ~~ (LIKE) operator for substring/pattern matching on encrypted ---! text using bloom filter index terms. Enables WHERE col LIKE '%pattern%' queries ---! without decryption. Requires 'match' index configuration on the column. ---! ---! Pattern matching uses n-gram tokenization configured in match index. Token length ---! and filters affect matching behavior. ---! ---! @param a eql_v2_encrypted Haystack (encrypted text to search in) ---! @param b eql_v2_encrypted Needle (encrypted pattern to search for) ---! @return Boolean True if a contains b as substring ---! ---! @example ---! -- Search for substring in encrypted email ---! SELECT * FROM users ---! WHERE encrypted_email ~~ '%@example.com%'::text::eql_v2_encrypted; ---! ---! -- Pattern matching on encrypted names ---! SELECT * FROM customers ---! WHERE encrypted_name ~~ 'John%'::text::eql_v2_encrypted; ---! ---! @brief SQL LIKE operator (~~ operator) for encrypted text pattern matching ---! ---! @param a eql_v2_encrypted Left operand (encrypted value) ---! @param b eql_v2_encrypted Right operand (encrypted pattern) ---! @return boolean True if pattern matches ---! ---! @note Requires match index: eql_v2.add_search_config(table, column, 'match') ---! @see eql_v2.like ---! @see eql_v2.add_search_config --- Inlinable: delegates to \`eql_v2.like\` which is itself an inlinable --- single-statement SQL function. Two levels of inlining produce --- \`eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b)\`, which matches a --- functional GIN index built on \`eql_v2.bloom_filter(col)\`. PostgREST --- and ORM \`~~\`/\`~~*\` queries engage the bloom-filter index without --- the caller wrapping the column themselves. -CREATE FUNCTION eql_v2."~~"(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.like(a, b) -$$; - -CREATE OPERATOR ~~( - FUNCTION=eql_v2."~~", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - ---! @brief Case-insensitive LIKE operator (~~*) ---! ---! Implements ~~* (ILIKE) operator for case-insensitive pattern matching. ---! Case handling depends on match index token_filters configuration (use downcase filter). ---! Same implementation as ~~, with case sensitivity controlled by index configuration. ---! ---! @param a eql_v2_encrypted Haystack ---! @param b eql_v2_encrypted Needle ---! @return Boolean True if a contains b (case-insensitive) ---! ---! @note Configure match index with downcase token filter for case-insensitivity ---! @see eql_v2."~~" -CREATE OPERATOR ~~*( - FUNCTION=eql_v2."~~", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - ---! @brief LIKE operator for encrypted value and JSONB ---! ---! Overload of ~~ operator accepting JSONB on the right side. Automatically ---! casts JSONB to eql_v2_encrypted for bloom filter pattern matching. ---! ---! @param eql_v2_encrypted Haystack (encrypted value) ---! @param b JSONB Needle (will be cast to eql_v2_encrypted) ---! @return Boolean True if a contains b as substring ---! ---! @example ---! SELECT * FROM users WHERE encrypted_email ~~ '%gmail%'::jsonb; ---! ---! @see eql_v2."~~"(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."~~"(a eql_v2_encrypted, b jsonb) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.like(a, b::eql_v2_encrypted) -$$; - - -CREATE OPERATOR ~~( - FUNCTION=eql_v2."~~", - LEFTARG=eql_v2_encrypted, - RIGHTARG=jsonb, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - -CREATE OPERATOR ~~*( - FUNCTION=eql_v2."~~", - LEFTARG=eql_v2_encrypted, - RIGHTARG=jsonb, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - ---! @brief LIKE operator for JSONB and encrypted value ---! ---! Overload of ~~ operator accepting JSONB on the left side. Automatically ---! casts JSONB to eql_v2_encrypted for bloom filter pattern matching. ---! ---! @param a JSONB Haystack (will be cast to eql_v2_encrypted) ---! @param eql_v2_encrypted Needle (encrypted pattern) ---! @return Boolean True if a contains b as substring ---! ---! @example ---! SELECT * FROM users WHERE 'test@example.com'::jsonb ~~ encrypted_pattern; ---! ---! @see eql_v2."~~"(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."~~"(a jsonb, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.like(a::eql_v2_encrypted, b) -$$; - - -CREATE OPERATOR ~~( - FUNCTION=eql_v2."~~", - LEFTARG=jsonb, - RIGHTARG=eql_v2_encrypted, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - -CREATE OPERATOR ~~*( - FUNCTION=eql_v2."~~", - LEFTARG=jsonb, - RIGHTARG=eql_v2_encrypted, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - - --- ----------------------------------------------------------------------------- - ---! @file src/operators/ste_vec_entry.sql ---! @brief Comparison operators on \`eql_v2.ste_vec_entry\` ---! ---! Equality (\`=\`, \`<>\`) reduces to \`eq_term(a) = eq_term(b)\` — a bytea ---! comparison of \`coalesce(hm, oc)\`. Ordering (\`<\`, \`<=\`, \`>\`, \`>=\`) ---! reduces to \`ore_cllw(a) ore_cllw(b)\`. Each backing function is ---! inlinable single-statement SQL, so the planner can fold the ---! operator body into the calling query — \`WHERE col -> 'sel' = $1\` ---! and \`WHERE col -> 'sel' < $1\` therefore match functional indexes ---! built on \`eql_v2.eq_term(col -> 'sel')\` / ---! \`eql_v2.ore_cllw(col -> 'sel')\` without per-query rewriting. ---! ---! XOR contract. Each sv entry carries exactly one of \`hm\` (bool ---! leaves, array / object roots) or \`oc\` (string / number leaves) — ---! enforced by the \`ste_vec_entry\` DOMAIN CHECK. Equality coalesces ---! across both protocols because both are deterministic and the byte ---! distributions are disjoint; ordering strictly uses \`ore_cllw\` ---! (range on hm-only entries is meaningless and produces silent NULL, ---! which the lint subsystem \`src/lint/lints.sql\` flags as a ---! configuration error). ---! ---! Same convention as the \`eql_v2_encrypted\` operators (#193 / #211): the ---! operator-class function-matching layer is what makes index match work ---! structurally, the backing functions just need to inline cleanly through ---! to the extractor calls. ---! ---! @see eql_v2.eq_term(eql_v2.ste_vec_entry) ---! @see eql_v2.ore_cllw(eql_v2.ste_vec_entry) ---! @see src/operators/=.sql ---! @see src/operators/<.sql - ---! @brief Equality backing function for \`eql_v2.ste_vec_entry\` ---! @internal ---! @param a eql_v2.ste_vec_entry Left operand ---! @param b eql_v2.ste_vec_entry Right operand ---! @return boolean True if both entries share the same deterministic ---! equality term (hm-or-oc, via \`eq_term\`). -CREATE FUNCTION eql_v2.eq(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.eq_term(a) = eql_v2.eq_term(b) -$$; - -CREATE OPERATOR = ( - FUNCTION = eql_v2.eq, - LEFTARG = eql_v2.ste_vec_entry, - RIGHTARG = eql_v2.ste_vec_entry, - COMMUTATOR = =, - NEGATOR = <>, - RESTRICT = eqsel, - JOIN = eqjoinsel, - HASHES, - MERGES -); - - ---! @brief Inequality backing function for \`eql_v2.ste_vec_entry\` ---! @internal ---! @param a eql_v2.ste_vec_entry Left operand ---! @param b eql_v2.ste_vec_entry Right operand ---! @return boolean True if the entries' equality terms (hm-or-oc, via ---! \`eq_term\`) differ. -CREATE FUNCTION eql_v2.neq(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.eq_term(a) <> eql_v2.eq_term(b) -$$; - -CREATE OPERATOR <> ( - FUNCTION = eql_v2.neq, - LEFTARG = eql_v2.ste_vec_entry, - RIGHTARG = eql_v2.ste_vec_entry, - COMMUTATOR = <>, - NEGATOR = =, - RESTRICT = neqsel, - JOIN = neqjoinsel -); - - ---! @brief Less-than backing function for \`eql_v2.ste_vec_entry\` ---! @internal ---! @param a eql_v2.ste_vec_entry Left operand ---! @param b eql_v2.ste_vec_entry Right operand ---! @return boolean True if \`a\`'s CLLW ORE term sorts before \`b\`'s -CREATE FUNCTION eql_v2.lt(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_cllw(a) < eql_v2.ore_cllw(b) -$$; - -CREATE OPERATOR < ( - FUNCTION = eql_v2.lt, - LEFTARG = eql_v2.ste_vec_entry, - RIGHTARG = eql_v2.ste_vec_entry, - COMMUTATOR = >, - NEGATOR = >=, - RESTRICT = scalarltsel, - JOIN = scalarltjoinsel -); - - ---! @brief Less-than-or-equal backing function for \`eql_v2.ste_vec_entry\` ---! @internal ---! @param a eql_v2.ste_vec_entry Left operand ---! @param b eql_v2.ste_vec_entry Right operand ---! @return boolean True if \`a\`'s CLLW ORE term sorts before or equal to \`b\`'s -CREATE FUNCTION eql_v2.lte(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_cllw(a) <= eql_v2.ore_cllw(b) -$$; - -CREATE OPERATOR <= ( - FUNCTION = eql_v2.lte, - LEFTARG = eql_v2.ste_vec_entry, - RIGHTARG = eql_v2.ste_vec_entry, - COMMUTATOR = >=, - NEGATOR = >, - RESTRICT = scalarlesel, - JOIN = scalarlejoinsel -); - - ---! @brief Greater-than backing function for \`eql_v2.ste_vec_entry\` ---! @internal ---! @param a eql_v2.ste_vec_entry Left operand ---! @param b eql_v2.ste_vec_entry Right operand ---! @return boolean True if \`a\`'s CLLW ORE term sorts after \`b\`'s -CREATE FUNCTION eql_v2.gt(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_cllw(a) > eql_v2.ore_cllw(b) -$$; - -CREATE OPERATOR > ( - FUNCTION = eql_v2.gt, - LEFTARG = eql_v2.ste_vec_entry, - RIGHTARG = eql_v2.ste_vec_entry, - COMMUTATOR = <, - NEGATOR = <=, - RESTRICT = scalargtsel, - JOIN = scalargtjoinsel -); - - ---! @brief Greater-than-or-equal backing function for \`eql_v2.ste_vec_entry\` ---! @internal ---! @param a eql_v2.ste_vec_entry Left operand ---! @param b eql_v2.ste_vec_entry Right operand ---! @return boolean True if \`a\`'s CLLW ORE term sorts after or equal to \`b\`'s -CREATE FUNCTION eql_v2.gte(a eql_v2.ste_vec_entry, b eql_v2.ste_vec_entry) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.ore_cllw(a) >= eql_v2.ore_cllw(b) -$$; - -CREATE OPERATOR >= ( - FUNCTION = eql_v2.gte, - LEFTARG = eql_v2.ste_vec_entry, - RIGHTARG = eql_v2.ste_vec_entry, - COMMUTATOR = <=, - NEGATOR = <, - RESTRICT = scalargesel, - JOIN = scalargejoinsel -); - ---! @file operators/sort.sql ---! @brief Comparison-based sorting functions for encrypted values without operator classes ---! ---! Provides O(n log n) quicksort-based sorting using eql_v2.compare() for environments ---! where btree operator classes are unavailable (e.g., Supabase). This is significantly ---! faster than the O(n^2) correlated subquery workaround. ---! ---! When all input rows share an ORE term (\`ob\`) the sort path pre-extracts the ---! ORE order key once per row and compares those keys directly. Rows lacking ---! an ORE term entirely fall back to \`eql_v2.compare()\` per pair. - - ---! @internal ---! @brief Compare pre-extracted ORE order keys with encrypted NULL semantics ---! ---! Mirrors eql_v2.compare() for NULL handling, then delegates to the ---! ore_block_u64_8_256 comparator when both keys are present. ---! ---! @param a eql_v2.ore_block_u64_8_256 First order key ---! @param b eql_v2.ore_block_u64_8_256 Second order key ---! @return integer -1 if a < b, 0 if a = b, 1 if a > b -CREATE FUNCTION eql_v2._compare_order_key( - a eql_v2.ore_block_u64_8_256, - b eql_v2.ore_block_u64_8_256 -) -RETURNS integer -IMMUTABLE PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ -BEGIN - IF a IS NULL AND b IS NULL THEN - RETURN 0; - END IF; - - IF a IS NULL THEN - RETURN -1; - END IF; - - IF b IS NULL THEN - RETURN 1; - END IF; - - RETURN eql_v2.compare_ore_block_u64_8_256_terms(a, b); -END; -$$ LANGUAGE plpgsql; - - ---! @internal ---! @brief Compare two elements from aligned arrays using the selected sort strategy ---! ---! @param vals eql_v2_encrypted[] Encrypted values (used when strategy = 'compare') ---! @param ore_keys eql_v2.ore_block_u64_8_256[] Pre-extracted ORE keys (strategy = 'ore') ---! @param left_idx integer Index of the left element ---! @param right_idx integer Index of the right element ---! @param strategy text One of 'ore' or 'compare' ---! @return integer -1 if left < right, 0 if equal, 1 if left > right -CREATE FUNCTION eql_v2._compare_sort_elements( - vals eql_v2_encrypted[], - ore_keys eql_v2.ore_block_u64_8_256[], - left_idx integer, - right_idx integer, - strategy text -) -RETURNS integer -IMMUTABLE PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ -BEGIN - IF strategy = 'ore' THEN - RETURN eql_v2._compare_order_key(ore_keys[left_idx], ore_keys[right_idx]); - END IF; - - RETURN eql_v2.compare(vals[left_idx], vals[right_idx]); -END; -$$ LANGUAGE plpgsql; - - ---! @internal ---! @brief Compare an array element against a captured pivot using the selected strategy ---! ---! @param vals eql_v2_encrypted[] Array of encrypted values ---! @param ore_keys eql_v2.ore_block_u64_8_256[] Array of pre-extracted ORE keys ---! @param idx integer Index of the element to compare ---! @param pivot_val eql_v2_encrypted Pivot encrypted value (strategy = 'compare') ---! @param pivot_ore_key eql_v2.ore_block_u64_8_256 Pivot ORE key (strategy = 'ore') ---! @param strategy text One of 'ore' or 'compare' ---! @return integer -1 if element < pivot, 0 if equal, 1 if element > pivot -CREATE FUNCTION eql_v2._compare_sort_pivot( - vals eql_v2_encrypted[], - ore_keys eql_v2.ore_block_u64_8_256[], - idx integer, - pivot_val eql_v2_encrypted, - pivot_ore_key eql_v2.ore_block_u64_8_256, - strategy text -) -RETURNS integer -IMMUTABLE PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ -BEGIN - IF strategy = 'ore' THEN - RETURN eql_v2._compare_order_key(ore_keys[idx], pivot_ore_key); - END IF; - - RETURN eql_v2.compare(vals[idx], pivot_val); -END; -$$ LANGUAGE plpgsql; - - ---! @internal ---! @brief In-place insertion sort on parallel id/value/key arrays ---! ---! @param ids bigint[] Array of row identifiers (reordered in place) ---! @param vals eql_v2_encrypted[] Array of encrypted values (reordered in place) ---! @param ore_keys eql_v2.ore_block_u64_8_256[] Array of pre-extracted ORE keys (reordered in place) ---! @param lo integer Lower bound index (1-based, inclusive) ---! @param hi integer Upper bound index (1-based, inclusive) ---! @param strategy text One of 'ore' or 'compare' ---! @return ids bigint[] Sorted array of row identifiers ---! @return vals eql_v2_encrypted[] Sorted array of encrypted values ---! @return ore_keys eql_v2.ore_block_u64_8_256[] Sorted array of pre-extracted ORE keys -CREATE FUNCTION eql_v2._insertion_sort( - INOUT ids bigint[], - INOUT vals eql_v2_encrypted[], - INOUT ore_keys eql_v2.ore_block_u64_8_256[], - lo integer, - hi integer, - strategy text -) - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - i integer; - j integer; - key_id bigint; - key_val eql_v2_encrypted; - sort_ore_key eql_v2.ore_block_u64_8_256; -BEGIN - IF lo >= hi THEN - RETURN; - END IF; - - FOR i IN lo + 1..hi LOOP - key_id := ids[i]; - key_val := vals[i]; - sort_ore_key := ore_keys[i]; - j := i - 1; - - WHILE j >= lo LOOP - EXIT WHEN strategy = 'compare' - AND eql_v2.compare(vals[j], key_val) <= 0; - EXIT WHEN strategy = 'ore' - AND eql_v2._compare_order_key(ore_keys[j], sort_ore_key) <= 0; - - ids[j + 1] := ids[j]; - vals[j + 1] := vals[j]; - ore_keys[j + 1] := ore_keys[j]; - j := j - 1; - END LOOP; - - ids[j + 1] := key_id; - vals[j + 1] := key_val; - ore_keys[j + 1] := sort_ore_key; - END LOOP; -END; -$$ LANGUAGE plpgsql; - - ---! @internal ---! @brief In-place quicksort on parallel id/value/key arrays ---! ---! Sorts aligned arrays simultaneously using Hoare partition with median-of-three pivot ---! selection. The median-of-three strategy avoids O(n^2) degradation on already-sorted ---! input, which is common with sequential test data. ---! ---! @param ids bigint[] Array of row identifiers (reordered in place) ---! @param vals eql_v2_encrypted[] Array of encrypted values to compare (reordered in place) ---! @param ore_keys eql_v2.ore_block_u64_8_256[] Pre-extracted ORE keys (reordered in place) ---! @param lo integer Lower bound index (1-based, inclusive) ---! @param hi integer Upper bound index (1-based, inclusive) ---! @param strategy text One of 'ore' or 'compare' ---! ---! @return ids bigint[] Sorted array of row identifiers ---! @return vals eql_v2_encrypted[] Sorted array of encrypted values ---! @return ore_keys eql_v2.ore_block_u64_8_256[] Sorted array of pre-extracted ORE keys -CREATE FUNCTION eql_v2._quicksort_sorter( - INOUT ids bigint[], - INOUT vals eql_v2_encrypted[], - INOUT ore_keys eql_v2.ore_block_u64_8_256[], - lo integer, - hi integer, - strategy text -) - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - insertion_threshold CONSTANT integer := 16; - pivot_val eql_v2_encrypted; - pivot_ore_key eql_v2.ore_block_u64_8_256; - mid integer; - i integer; - j integer; - left_hi integer; - right_lo integer; - tmp_id bigint; - tmp_val eql_v2_encrypted; - tmp_ore_key eql_v2.ore_block_u64_8_256; -BEGIN - WHILE lo < hi LOOP - IF hi - lo <= insertion_threshold THEN - SELECT q.ids, q.vals, q.ore_keys - INTO ids, vals, ore_keys - FROM eql_v2._insertion_sort(ids, vals, ore_keys, lo, hi, strategy) q; - RETURN; - END IF; - - -- Median-of-three pivot selection: sort lo, mid, hi then use mid as pivot - mid := lo + (hi - lo) / 2; - - IF eql_v2._compare_sort_elements(vals, ore_keys, lo, mid, strategy) > 0 THEN - tmp_id := ids[lo]; ids[lo] := ids[mid]; ids[mid] := tmp_id; - tmp_val := vals[lo]; vals[lo] := vals[mid]; vals[mid] := tmp_val; - tmp_ore_key := ore_keys[lo]; ore_keys[lo] := ore_keys[mid]; ore_keys[mid] := tmp_ore_key; - END IF; - IF eql_v2._compare_sort_elements(vals, ore_keys, lo, hi, strategy) > 0 THEN - tmp_id := ids[lo]; ids[lo] := ids[hi]; ids[hi] := tmp_id; - tmp_val := vals[lo]; vals[lo] := vals[hi]; vals[hi] := tmp_val; - tmp_ore_key := ore_keys[lo]; ore_keys[lo] := ore_keys[hi]; ore_keys[hi] := tmp_ore_key; - END IF; - IF eql_v2._compare_sort_elements(vals, ore_keys, mid, hi, strategy) > 0 THEN - tmp_id := ids[mid]; ids[mid] := ids[hi]; ids[hi] := tmp_id; - tmp_val := vals[mid]; vals[mid] := vals[hi]; vals[hi] := tmp_val; - tmp_ore_key := ore_keys[mid]; ore_keys[mid] := ore_keys[hi]; ore_keys[hi] := tmp_ore_key; - END IF; - - pivot_val := vals[mid]; - pivot_ore_key := ore_keys[mid]; - i := lo; - j := hi; - - LOOP - WHILE eql_v2._compare_sort_pivot( - vals, ore_keys, i, - pivot_val, pivot_ore_key, strategy - ) < 0 LOOP - i := i + 1; - END LOOP; - WHILE eql_v2._compare_sort_pivot( - vals, ore_keys, j, - pivot_val, pivot_ore_key, strategy - ) > 0 LOOP - j := j - 1; - END LOOP; - - EXIT WHEN i >= j; - - tmp_id := ids[i]; ids[i] := ids[j]; ids[j] := tmp_id; - tmp_val := vals[i]; vals[i] := vals[j]; vals[j] := tmp_val; - tmp_ore_key := ore_keys[i]; ore_keys[i] := ore_keys[j]; ore_keys[j] := tmp_ore_key; - - i := i + 1; - j := j - 1; - END LOOP; - - left_hi := j; - right_lo := j + 1; - - IF left_hi - lo < hi - right_lo THEN - IF lo < left_hi THEN - SELECT q.ids, q.vals, q.ore_keys - INTO ids, vals, ore_keys - FROM eql_v2._quicksort_sorter(ids, vals, ore_keys, lo, left_hi, strategy) q; - END IF; - lo := right_lo; - ELSE - IF right_lo < hi THEN - SELECT q.ids, q.vals, q.ore_keys - INTO ids, vals, ore_keys - FROM eql_v2._quicksort_sorter(ids, vals, ore_keys, right_lo, hi, strategy) q; - END IF; - hi := left_hi; - END IF; - END LOOP; -END; -$$ LANGUAGE plpgsql; - - ---! @internal ---! @brief Emit aligned arrays as rows in ASC or DESC order ---! ---! @param ids bigint[] Array of sorted row identifiers ---! @param vals eql_v2_encrypted[] Array of sorted encrypted values ---! @param direction text Sort direction: 'ASC' (default) or 'DESC' ---! @return TABLE(id bigint, val eql_v2_encrypted) Rows emitted in the requested order -CREATE FUNCTION eql_v2._emit_sorted_rows( - ids bigint[], - vals eql_v2_encrypted[], - direction text DEFAULT 'ASC' -) -RETURNS TABLE(id bigint, val eql_v2_encrypted) -IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - n integer; - i integer; -BEGIN - n := coalesce(array_length(ids, 1), 0); - - IF upper(direction) = 'DESC' THEN - FOR i IN REVERSE n..1 LOOP - id := ids[i]; - val := vals[i]; - RETURN NEXT; - END LOOP; - ELSE - FOR i IN 1..n LOOP - id := ids[i]; - val := vals[i]; - RETURN NEXT; - END LOOP; - END IF; -END; -$$ LANGUAGE plpgsql; - - ---! @internal ---! @brief Sort encrypted values using precomputed ORE keys when available ---! ---! Shared implementation for public sorting entrypoints. The \`strategy\` ---! parameter selects the comparison path: \`'ore'\` uses the aligned \`ore_keys\` ---! array; \`'compare'\` falls back to \`eql_v2.compare()\` on the encrypted values ---! directly. ---! ---! @param ids bigint[] Row identifiers aligned with \`vals\` ---! @param vals eql_v2_encrypted[] Encrypted values to sort ---! @param ore_keys eql_v2.ore_block_u64_8_256[] Pre-extracted ORE keys (used when strategy = 'ore') ---! @param direction text Sort direction: 'ASC' (default) or 'DESC' ---! @param strategy text One of 'ore' or 'compare' ---! @return TABLE(id bigint, val eql_v2_encrypted) Sorted rows -CREATE FUNCTION eql_v2._sort_compare_precomputed( - ids bigint[], - vals eql_v2_encrypted[], - ore_keys eql_v2.ore_block_u64_8_256[], - direction text DEFAULT 'ASC', - strategy text DEFAULT 'ore' -) -RETURNS TABLE(id bigint, val eql_v2_encrypted) -IMMUTABLE PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - n integer; - m integer; - k integer; - sorted_ids bigint[]; - sorted_vals eql_v2_encrypted[]; - sorted_ore_keys eql_v2.ore_block_u64_8_256[]; -BEGIN - n := coalesce(array_length(ids, 1), 0); - m := coalesce(array_length(vals, 1), 0); - - IF n <> m THEN - RAISE EXCEPTION 'ids and vals must have the same length'; - END IF; - - IF strategy = 'ore' THEN - k := coalesce(array_length(ore_keys, 1), 0); - IF n <> k THEN - RAISE EXCEPTION 'ids and ore_keys must have the same length when strategy = ''ore'''; - END IF; - END IF; - - IF n = 0 THEN - RETURN; - END IF; - - IF n = 1 THEN - id := ids[1]; - val := vals[1]; - RETURN NEXT; - RETURN; - END IF; - - SELECT q.ids, q.vals, q.ore_keys - INTO sorted_ids, sorted_vals, sorted_ore_keys - FROM eql_v2._quicksort_sorter(ids, vals, ore_keys, 1, n, strategy) q; - - RETURN QUERY - SELECT emitted.id, emitted.val - FROM eql_v2._emit_sorted_rows(sorted_ids, sorted_vals, direction) emitted; -END; -$$ LANGUAGE plpgsql; - - ---! @brief Sort encrypted values using comparison-based quicksort ---! ---! Sorts parallel arrays of identifiers and encrypted values using O(n log n) ---! quicksort with eql_v2.compare(). Returns sorted rows as a table, avoiding ---! the need for unnest() or other array manipulation by callers. ---! ---! When all input rows share an \`ore\` term the sort uses pre-extracted ORE ---! keys; otherwise it falls back to \`eql_v2.compare()\` per pair. ---! ---! This function is designed for environments without operator classes (e.g., Supabase) ---! where direct ORDER BY on encrypted columns is not available. ---! ---! @param ids bigint[] Array of row identifiers ---! @param vals eql_v2_encrypted[] Array of encrypted values (must be same length as ids) ---! @param direction text Sort direction: 'ASC' (default) or 'DESC' ---! @return TABLE(id bigint, val eql_v2_encrypted) Sorted rows ---! ---! @example ---! -- Sort all rows from an encrypted table ---! SELECT * FROM eql_v2.sort_compare( ---! (SELECT array_agg(id ORDER BY id) FROM ore), ---! (SELECT array_agg(e ORDER BY id) FROM ore), ---! 'ASC' ---! ); ---! ---! -- Sort with a filter ---! SELECT * FROM eql_v2.sort_compare( ---! (SELECT array_agg(id ORDER BY id) FROM ore WHERE id > 42), ---! (SELECT array_agg(e ORDER BY id) FROM ore WHERE id > 42), ---! 'DESC' ---! ); ---! ---! -- Compose with LIMIT ---! SELECT * FROM eql_v2.sort_compare( ---! (SELECT array_agg(id ORDER BY id) FROM ore), ---! (SELECT array_agg(e ORDER BY id) FROM ore) ---! ) LIMIT 5; ---! ---! @see eql_v2.compare ---! @see eql_v2.order_by_compare -CREATE FUNCTION eql_v2.sort_compare( - ids bigint[], - vals eql_v2_encrypted[], - direction text DEFAULT 'ASC' -) -RETURNS TABLE(id bigint, val eql_v2_encrypted) -IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - n integer; - sorted_ore_keys eql_v2.ore_block_u64_8_256[]; - i integer; - use_ore boolean := true; - strategy text; -BEGIN - n := coalesce(array_length(ids, 1), 0); - - -- Pre-extract sort keys. ORE wins if every non-NULL row carries \`ob\`, - -- otherwise fall back to eql_v2.compare() per pair. - FOR i IN 1..n LOOP - IF vals[i] IS NULL THEN - sorted_ore_keys[i] := NULL; - ELSE - IF use_ore THEN - IF eql_v2.has_ore_block_u64_8_256(vals[i]) THEN - sorted_ore_keys[i] := eql_v2.order_by(vals[i]); - ELSE - use_ore := false; - END IF; - END IF; - - EXIT WHEN NOT use_ore; - END IF; - END LOOP; - - IF use_ore THEN - strategy := 'ore'; - ELSE - strategy := 'compare'; - END IF; - - RETURN QUERY - SELECT sc.id, sc.val - FROM eql_v2._sort_compare_precomputed( - ids, vals, sorted_ore_keys, direction, strategy - ) sc; -END; -$$ LANGUAGE plpgsql; - - ---! @brief Sort encrypted values from a table using column and table references ---! ---! Convenience overload that accepts column names, a table name, and an optional ---! filter clause instead of pre-aggregated arrays. Internally constructs the ---! query and delegates to eql_v2.order_by_compare(). ---! ---! @param id_column text Name of the bigint identifier column ---! @param val_column text Name of the eql_v2_encrypted value column ---! @param tbl text Table name (may be schema-qualified) ---! @param direction text Sort direction: 'ASC' (default) or 'DESC' ---! @param filter text Optional WHERE clause (without the WHERE keyword) ---! @return TABLE(id bigint, val eql_v2_encrypted) Sorted rows ---! ---! @note The id column must be castable to bigint. Uses dynamic SQL internally. ---! @warning The filter parameter is executed as dynamic SQL. Use only with trusted input. ---! ---! @example ---! -- Sort all rows ascending (default) ---! SELECT * FROM eql_v2.sort_compare('id', 'e', 'ore'); ---! ---! -- Sort descending ---! SELECT * FROM eql_v2.sort_compare('id', 'e', 'ore', 'DESC'); ---! ---! -- Sort with a filter ---! SELECT * FROM eql_v2.sort_compare('id', 'e', 'ore', 'ASC', 'id > 42'); ---! ---! -- Compose with LIMIT ---! SELECT * FROM eql_v2.sort_compare('id', 'e', 'ore') LIMIT 10; ---! ---! @see eql_v2.sort_compare(bigint[], eql_v2_encrypted[], text) ---! @see eql_v2.order_by_compare -CREATE FUNCTION eql_v2.sort_compare( - id_column text, - val_column text, - tbl text, - direction text DEFAULT 'ASC', - filter text DEFAULT NULL -) -RETURNS TABLE(id bigint, val eql_v2_encrypted) - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - query text; - resolved_tbl regclass; -BEGIN - resolved_tbl := to_regclass(tbl); - - IF resolved_tbl IS NULL THEN - RAISE EXCEPTION 'table "%" does not exist', tbl; - END IF; - - query := format('SELECT %I, %I FROM %s', id_column, val_column, resolved_tbl); - - IF filter IS NOT NULL THEN - query := query || ' WHERE ' || filter; - END IF; - - RETURN QUERY - SELECT sc.id, sc.val - FROM eql_v2.order_by_compare(query, direction) sc; -END; -$$ LANGUAGE plpgsql; - - ---! @brief Sort encrypted values from a query using comparison-based quicksort ---! ---! Convenience wrapper that accepts a SQL query string, executes it, collects the ---! results, and returns them sorted. For ORE-backed values this pre-extracts the ---! order key once per row and sorts on that key; other inputs fall back to ---! eql_v2.compare(). The query must return exactly two columns: a bigint ---! identifier and an eql_v2_encrypted value. ---! ---! @param query text SQL query returning (bigint, eql_v2_encrypted) columns ---! @param direction text Sort direction: 'ASC' (default) or 'DESC' ---! @return TABLE(id bigint, val eql_v2_encrypted) Sorted rows ---! ---! @note Uses dynamic SQL (EXECUTE) so cannot be IMMUTABLE or PARALLEL SAFE ---! @warning The query parameter is executed as dynamic SQL. Use only with trusted input. ---! ---! @example ---! -- Sort all rows ---! SELECT * FROM eql_v2.order_by_compare('SELECT id, e FROM ore'); ---! ---! -- Sort with WHERE clause ---! SELECT * FROM eql_v2.order_by_compare( ---! 'SELECT id, e FROM ore WHERE id > 42', ---! 'DESC' ---! ); ---! ---! @see eql_v2.sort_compare ---! @see eql_v2.compare -CREATE FUNCTION eql_v2.order_by_compare( - query text, - direction text DEFAULT 'ASC' -) -RETURNS TABLE(id bigint, val eql_v2_encrypted) - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - all_ids bigint[]; - all_vals eql_v2_encrypted[]; - all_ore_keys eql_v2.ore_block_u64_8_256[]; - all_have_ore_keys boolean; - strategy text; -BEGIN - -- Pre-extract sort keys. ORE wins if every non-NULL row carries \`ob\`, - -- otherwise fall back to eql_v2.compare() per pair. - EXECUTE format( - 'WITH input_rows AS ( - SELECT row_number() OVER () AS ord, - sub.id, - sub.val, - CASE - WHEN sub.val IS NULL THEN NULL - WHEN eql_v2.has_ore_block_u64_8_256(sub.val) THEN eql_v2.order_by(sub.val) - ELSE NULL - END AS ore_key, - CASE - WHEN sub.val IS NULL THEN TRUE - ELSE eql_v2.has_ore_block_u64_8_256(sub.val) - END AS has_ore_key - FROM (%s) sub(id, val) - ) - SELECT array_agg(id ORDER BY ord), - array_agg(val ORDER BY ord), - array_agg(ore_key ORDER BY ord), - coalesce(bool_and(has_ore_key), TRUE) - FROM input_rows', - query - ) INTO all_ids, all_vals, all_ore_keys, all_have_ore_keys; - - IF all_ids IS NULL THEN - RETURN; - END IF; - - IF all_have_ore_keys THEN - strategy := 'ore'; - ELSE - strategy := 'compare'; - END IF; - - RETURN QUERY - SELECT sc.id, sc.val - FROM eql_v2._sort_compare_precomputed( - all_ids, - all_vals, - all_ore_keys, - direction, - strategy - ) sc; -END; -$$ LANGUAGE plpgsql; - ---! @file src/operators/operator_class.sql ---! @brief Btree operator class for the \`eql_v2_encrypted\` composite type ---! ---! \`eql_v2_encrypted\` is a composite type. PostgreSQL gives every composite ---! type an implicit row-wise btree comparison (\`record_ops\`) — but that ---! compares the raw ciphertext byte-for-byte, so two encryptions of the same ---! plaintext (same \`hm\`, different \`c\`) would sort and group as *distinct*. ---! \`eql_v2.encrypted_operator_class\` is registered \`DEFAULT ... USING btree\` ---! specifically to override \`record_ops\` with a comparison that is correct ---! for encrypted data: \`GROUP BY\`, \`DISTINCT\`, \`ORDER BY\`, sort-merge joins ---! and \`ANALYZE\` on a bare \`eql_v2_encrypted\` column all route through ---! FUNCTION 1 below. ---! ---! @note FUNCTION 1 is \`eql_v2.encrypted_btree_compare\`, NOT the strict ---! \`eql_v2.compare\`. A btree support function must be total and must ---! never raise — \`ANALYZE\` calls it to build column statistics on ---! every encrypted column. \`eql_v2.compare\` is deliberately strict ---! (it raises without a Block-ORE \`ob\` term — see U-005); it backs ---! the \`<\` / \`>\` range operators, not this opclass. ---! ---! @note Functional indexes are the canonical recipe for *building* indexes ---! on encrypted columns (see U-001 and docs/reference/database-indexes.md). ---! This opclass exists to keep the composite type's built-in ---! comparison correct — not as an index-building recommendation. ---! ---! @see eql_v2.encrypted_hash_operator_class (hash — GROUP BY / hash joins) ---! @see eql_v2.compare - --------------------- - ---! @brief Total, non-raising btree comparator for \`eql_v2_encrypted\` ---! ---! Three-way comparison (\`-1\` / \`0\` / \`1\`) used as FUNCTION 1 of ---! \`eql_v2.encrypted_operator_class\`. Unlike \`eql_v2.compare\`, it never ---! raises: a btree support function is invoked by \`ANALYZE\`, sort, and ---! \`GROUP BY\` on every value, so raising is not an option. ---! ---! Comparison priority: ---! 1. Both operands carry \`ob\` (Block ORE) — order-preserving comparison ---! via \`eql_v2.compare_ore_block_u64_8_256\`. ---! 2. Both operands carry \`hm\` (HMAC-256) — a total order on the hmac ---! bytes. Not order-preserving on plaintext (hmac is not), but ---! deterministic, total, and \`= 0\` exactly when the hmac terms match ---! — consistent with the \`=\` operator, so \`GROUP BY\` / \`DISTINCT\` ---! deduplicate correctly. ---! 3. Otherwise — a deterministic order on the raw payload. Reached only ---! for term-less / mixed payloads; present so the function stays total. ---! ---! @param a eql_v2_encrypted First value ---! @param b eql_v2_encrypted Second value ---! @return integer -1, 0, or 1 ---! ---! @internal ---! @see eql_v2.encrypted_operator_class ---! @see eql_v2.compare -CREATE FUNCTION eql_v2.encrypted_btree_compare(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS integer - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - hm_a text; - hm_b text; - BEGIN - -- Block ORE on both sides: order-preserving comparison. - IF eql_v2.has_ore_block_u64_8_256(a) AND eql_v2.has_ore_block_u64_8_256(b) THEN - RETURN eql_v2.compare_ore_block_u64_8_256(a, b); - END IF; - - -- HMAC on both sides: total order on the hmac bytes. \`= 0\` iff the hmac - -- terms match, consistent with the \`=\` operator and the hash opclass. - hm_a := eql_v2.hmac_256(a)::text; - hm_b := eql_v2.hmac_256(b)::text; - IF hm_a IS NOT NULL AND hm_b IS NOT NULL THEN - RETURN CASE - WHEN hm_a < hm_b THEN -1 - WHEN hm_a > hm_b THEN 1 - ELSE 0 - END; - END IF; - - -- Fallback for term-less / mixed payloads: a deterministic, non-raising - -- total order on the raw payload. Not a normal column shape — this - -- branch only keeps the btree FUNCTION 1 contract (total, never raises). - RETURN CASE - WHEN (a).data::text < (b).data::text THEN -1 - WHEN (a).data::text > (b).data::text THEN 1 - ELSE 0 - END; - END; -$$ LANGUAGE plpgsql; - --------------------- - -CREATE OPERATOR FAMILY eql_v2.encrypted_operator_family USING btree; - -CREATE OPERATOR CLASS eql_v2.encrypted_operator_class DEFAULT FOR TYPE eql_v2_encrypted USING btree FAMILY eql_v2.encrypted_operator_family AS - OPERATOR 1 <, - OPERATOR 2 <=, - OPERATOR 3 =, - OPERATOR 4 >=, - OPERATOR 5 >, - FUNCTION 1 eql_v2.encrypted_btree_compare(a eql_v2_encrypted, b eql_v2_encrypted); - ---! @brief PostgreSQL hash operator class for encrypted value hashing ---! ---! Defines the hash operator family and operator class required for hash-based ---! operations on encrypted values. This enables PostgreSQL to use hash strategies for: ---! - Hash joins (cross-row equality via hash) ---! - GROUP BY (hash aggregation) ---! - DISTINCT (hash-based deduplication) ---! - UNION (hash-based set operations) ---! ---! Only the same-type equality operator (eql_v2_encrypted = eql_v2_encrypted) is ---! registered. Cross-type operators (encrypted/jsonb) are excluded because hash ---! joins require independent hashing of each side before comparison. ---! ---! @note Requires hmac_256 index terms for correct hashing ---! @see eql_v2.hash_encrypted ---! @see eql_v2.encrypted_operator_class (btree) - -CREATE OPERATOR FAMILY eql_v2.encrypted_hash_operator_family USING hash; - -CREATE OPERATOR CLASS eql_v2.encrypted_hash_operator_class - DEFAULT FOR TYPE eql_v2_encrypted USING hash - FAMILY eql_v2.encrypted_hash_operator_family AS - OPERATOR 1 = (eql_v2_encrypted, eql_v2_encrypted), - FUNCTION 1 eql_v2.hash_encrypted(eql_v2_encrypted); - ---! @brief Contained-by operator for encrypted values (<@) ---! ---! Implements the <@ (contained-by) operator for testing if left encrypted value ---! is contained by the right encrypted value. Uses ste_vec (secure tree encoding vector) ---! index terms for containment testing without decryption. Reverse of @> operator. ---! ---! Primarily used for encrypted array or set containment queries. ---! ---! @param a eql_v2_encrypted Left operand (contained value) ---! @param b eql_v2_encrypted Right operand (container) ---! @return Boolean True if a is contained by b ---! ---! @example ---! -- Check if value is contained in encrypted array ---! SELECT * FROM documents ---! WHERE '["security"]'::jsonb::eql_v2_encrypted <@ encrypted_tags; ---! ---! @note Requires ste_vec index configuration ---! @see eql_v2.ste_vec_contains ---! @see eql_v2.\\"@>\\" ---! @see eql_v2.add_search_config - --- Marked IMMUTABLE STRICT PARALLEL SAFE — see operators/@>.sql for rationale. -CREATE FUNCTION eql_v2."<@"(a eql_v2_encrypted, b eql_v2_encrypted) -RETURNS boolean -LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE -AS $$ - -- Contains with reversed arguments - SELECT eql_v2.ste_vec_contains(b, a) -$$; - -CREATE OPERATOR <@( - FUNCTION=eql_v2."<@", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted -); - - ---! @brief Contained-by operator (<@) with an \`eql_v2.stevec_query\` LHS ---! ---! Reverse of \`@>(eql_v2_encrypted, eql_v2.stevec_query)\`. Mirrors the ---! typed needle convention: "is this query payload contained in that ---! encrypted document?". ---! ---! @param a eql_v2.stevec_query Left operand (query payload) ---! @param b eql_v2_encrypted Right operand (container) ---! @return Boolean True if \`b\` contains \`a\` ---! @see eql_v2."@>"(eql_v2_encrypted, eql_v2.stevec_query) -CREATE FUNCTION eql_v2."<@"(a eql_v2.stevec_query, b eql_v2_encrypted) -RETURNS boolean -LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2."@>"(b, a) -$$; - -CREATE OPERATOR <@( - FUNCTION=eql_v2."<@", - LEFTARG=eql_v2.stevec_query, - RIGHTARG=eql_v2_encrypted -); - - ---! @brief Contained-by operator (<@) with an \`eql_v2.ste_vec_entry\` LHS ---! ---! Reverse of \`@>(eql_v2_encrypted, eql_v2.ste_vec_entry)\`. Convenience ---! shape for "is this entry contained in that encrypted document?". ---! ---! @param a eql_v2.ste_vec_entry Left operand (single entry) ---! @param b eql_v2_encrypted Right operand (container) ---! @return Boolean True if \`b\` contains \`a\` ---! @see eql_v2."@>"(eql_v2_encrypted, eql_v2.ste_vec_entry) -CREATE FUNCTION eql_v2."<@"(a eql_v2.ste_vec_entry, b eql_v2_encrypted) -RETURNS boolean -LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2."@>"(b, a) -$$; - -CREATE OPERATOR <@( - FUNCTION=eql_v2."<@", - LEFTARG=eql_v2.ste_vec_entry, - RIGHTARG=eql_v2_encrypted -); - ---! @brief Inequality helper for encrypted values ---! @internal ---! ---! Inlinable SQL helper mirroring the \`<>\` operator's body: reduces to ---! \`hmac_256(a) <> hmac_256(b)\`. Kept for callers that invoked the ---! pre-#193 form (\`eql_v2.neq\`); equivalent to using the \`<>\` operator ---! directly. ---! ---! Inequality on \`eql_v2_encrypted\` is strictly hmac-based (see U-002). ---! Returns NULL when either side lacks an \`hm\` term — matching the ---! \`<>\` operator's behaviour. ---! ---! @param a eql_v2_encrypted First encrypted value ---! @param b eql_v2_encrypted Second encrypted value ---! @return Boolean True if hmac terms differ ---! ---! @see eql_v2."<>" ---! @see eql_v2.hmac_256 -CREATE FUNCTION eql_v2.neq(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.hmac_256(a) <> eql_v2.hmac_256(b) -$$; - ---! @brief Not-equal operator for encrypted values ---! ---! Implements the <> (not equal) operator for comparing encrypted values using their ---! encrypted index terms. Enables WHERE clause inequality comparisons without decryption. ---! ---! @param a eql_v2_encrypted Left operand ---! @param b eql_v2_encrypted Right operand ---! @return Boolean True if encrypted values are not equal ---! ---! @example ---! -- Find records with non-matching values ---! SELECT * FROM users ---! WHERE encrypted_email <> 'admin@example.com'::text::eql_v2_encrypted; ---! ---! @see eql_v2.compare ---! @see eql_v2."=" --- Inlinable; mirrors \`=\` (see operators/=.sql for rationale). --- Returns NULL on ORE-only encrypted columns (no \`hm\` field) instead --- of falling back to a slower comparison path; surface the config --- error rather than hide it. -CREATE FUNCTION eql_v2."<>"(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.hmac_256(a) <> eql_v2.hmac_256(b) -$$; - - -CREATE OPERATOR <> ( - FUNCTION=eql_v2."<>", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted, - NEGATOR = =, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - ---! @brief <> operator for encrypted value and JSONB ---! @see eql_v2."<>"(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."<>"(a eql_v2_encrypted, b jsonb) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.hmac_256(a) <> eql_v2.hmac_256(b::eql_v2_encrypted) -$$; - -CREATE OPERATOR <> ( - FUNCTION=eql_v2."<>", - LEFTARG=eql_v2_encrypted, - RIGHTARG=jsonb, - NEGATOR = =, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - ---! @brief <> operator for JSONB and encrypted value ---! ---! @param jsonb Plain JSONB value ---! @param eql_v2_encrypted Encrypted value ---! @return boolean True if values are not equal ---! ---! @see eql_v2."<>"(eql_v2_encrypted, eql_v2_encrypted) -CREATE FUNCTION eql_v2."<>"(a jsonb, b eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.hmac_256(a::eql_v2_encrypted) <> eql_v2.hmac_256(b) -$$; - -CREATE OPERATOR <> ( - FUNCTION=eql_v2."<>", - LEFTARG=jsonb, - RIGHTARG=eql_v2_encrypted, - NEGATOR = =, - RESTRICT = eqsel, - JOIN = eqjoinsel, - MERGES -); - - - - - ---! @brief JSONB field accessor operator alias (->>) ---! ---! Implements the ->> operator as an alias of -> for encrypted JSONB data. This mirrors ---! PostgreSQL semantics where ->> returns text via implicit casts. The underlying ---! implementation delegates to eql_v2."->" and allows PostgreSQL to coerce the result. ---! ---! Provides two overloads: ---! - (eql_v2_encrypted, text) - Field name selector ---! - (eql_v2_encrypted, eql_v2_encrypted) - Encrypted selector ---! ---! @see eql_v2."->" ---! @see eql_v2.selector - ---! @brief ->> operator with text selector ---! @param eql_v2_encrypted Encrypted JSONB data ---! @param text Field name to extract ---! @return text Encrypted value at selector, implicitly cast from eql_v2_encrypted ---! @example ---! SELECT encrypted_json ->> 'field_name' FROM table; -CREATE FUNCTION eql_v2."->>"(e eql_v2_encrypted, selector text) - RETURNS text -IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - found eql_v2_encrypted; - BEGIN - -- found = eql_v2."->"(e, selector); - -- RETURN eql_v2.ciphertext(found); - RETURN eql_v2."->"(e, selector); - END; -$$ LANGUAGE plpgsql; - - -CREATE OPERATOR ->> ( - FUNCTION=eql_v2."->>", - LEFTARG=eql_v2_encrypted, - RIGHTARG=text -); - - - ---------------------------------------------------- - ---! @brief ->> operator with encrypted selector ---! @param e eql_v2_encrypted Encrypted JSONB data ---! @param selector eql_v2_encrypted Encrypted field selector ---! @return text Encrypted value at selector, implicitly cast from eql_v2_encrypted ---! @see eql_v2."->>"(eql_v2_encrypted, text) -CREATE FUNCTION eql_v2."->>"(e eql_v2_encrypted, selector eql_v2_encrypted) - RETURNS text - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN eql_v2."->>"(e, eql_v2._selector(selector)); - END; -$$ LANGUAGE plpgsql; - - -CREATE OPERATOR ->> ( - FUNCTION=eql_v2."->>", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted -); - ---! @brief JSONB field accessor operator for encrypted values (->) ---! ---! Implements the -> operator to access fields/elements from encrypted JSONB data. ---! Returns the matching sv entry as \`eql_v2.ste_vec_entry\` (or NULL on miss). ---! ---! Encrypted JSON is represented as an array of sv elements in the ---! StEVec format. Each element has a selector, ciphertext, and index ---! terms: \`{"sv": [{"c": "...", "s": "...", "hm": "..."}, ...]}\`. ---! ---! Provides three overloads: ---! - (eql_v2_encrypted, text) - Field name selector ---! - (eql_v2_encrypted, eql_v2_encrypted) - Encrypted selector ---! - (eql_v2_encrypted, integer) - Array index selector (0-based) ---! ---! All three return \`eql_v2.ste_vec_entry\` and preserve the source ---! payload's root \`i\` / \`v\` envelope metadata in the returned entry ---! (the DOMAIN CHECK on \`ste_vec_entry\` doesn't forbid extra fields). ---! ---! @note Operator resolution: Assignment casts are considered (PostgreSQL standard behavior). ---! To use text selector, parameter may need explicit cast to text. ---! ---! @see eql_v2.ste_vec_entry ---! @see eql_v2.selector ---! @see eql_v2."->>" - ---! @brief -> operator with text selector ---! ---! Returns the sv entry whose \`s\` selector equals @p selector, with ---! the source payload's \`i\` / \`v\` metadata merged in. Selectors are ---! deterministic per (path, key) within a document, so at most one ---! entry matches; \`jsonb_path_query_first\` returns the first match ---! and stops scanning. ---! ---! Inlinable single-statement SQL: the planner folds this body into ---! the calling query, so \`WHERE col -> 'sel' = $1\` reduces structurally ---! to \`eql_v2.eq_term(col -> 'sel') = eql_v2.eq_term($1)\` and matches ---! a functional index built on \`eql_v2.eq_term(col -> 'sel')\`. ---! ---! @param e eql_v2_encrypted Encrypted JSONB payload (root) ---! @param selector text Selector hash (the \`s\` field value) ---! @return eql_v2.ste_vec_entry Matching entry merged with root meta, ---! NULL if no element matches. ---! ---! @note The returned entry carries \`i\` / \`v\` from the root in addition ---! to the sv-element fields. This is intentional: per-entry ---! extractors (\`eql_v2.eq_term\`, \`eql_v2.ore_cllw\`, ...) read ---! only their own fields and ignore \`i\` / \`v\`; callers that need ---! the root envelope (e.g. for decryption) still see it. ---! ---! @example ---! SELECT encrypted_json -> 'field_name' FROM table; -CREATE FUNCTION eql_v2."->"(e eql_v2_encrypted, selector text) - RETURNS eql_v2.ste_vec_entry - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT ( - eql_v2.meta_data(e) || - jsonb_path_query_first( - (e).data, - '$.sv[*] ? (@.s == $sel)'::jsonpath, - jsonb_build_object('sel', selector) - ) - )::eql_v2.ste_vec_entry -$$; - - -CREATE OPERATOR ->( - FUNCTION=eql_v2."->", - LEFTARG=eql_v2_encrypted, - RIGHTARG=text -); - ---------------------------------------------------- - ---! @brief -> operator with encrypted selector ---! ---! Convenience overload: extracts the selector text from an encrypted ---! selector payload and delegates to the (text) form. Inlinable. ---! ---! @param e eql_v2_encrypted Encrypted JSONB data ---! @param selector eql_v2_encrypted Encrypted selector payload ---! @return eql_v2.ste_vec_entry Matching entry, NULL on miss ---! @see eql_v2."->"(eql_v2_encrypted, text) -CREATE FUNCTION eql_v2."->"(e eql_v2_encrypted, selector eql_v2_encrypted) - RETURNS eql_v2.ste_vec_entry - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2."->"(e, eql_v2._selector(selector)) -$$; - - - -CREATE OPERATOR ->( - FUNCTION=eql_v2."->", - LEFTARG=eql_v2_encrypted, - RIGHTARG=eql_v2_encrypted -); - - ---------------------------------------------------- - ---! @brief -> operator with integer array index ---! ---! Returns the sv entry at the given (0-based, JSONB-style) array ---! index, merged with the root payload's \`i\` / \`v\` metadata. Returns ---! NULL when the underlying value isn't an sv-array payload or when ---! the index is out of bounds. ---! ---! @param e eql_v2_encrypted Encrypted sv-array payload ---! @param selector integer Array index (0-based, JSONB convention) ---! @return eql_v2.ste_vec_entry Matching entry, NULL on miss ---! @note Array index is 0-based (JSONB standard) despite PostgreSQL arrays being 1-based ---! @example ---! SELECT encrypted_array -> 0 FROM table; ---! @see eql_v2.is_ste_vec_array -CREATE FUNCTION eql_v2."->"(e eql_v2_encrypted, selector integer) - RETURNS eql_v2.ste_vec_entry - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT CASE - WHEN eql_v2.is_ste_vec_array(e) THEN - (eql_v2.meta_data(e) || ((e).data -> 'sv' -> selector))::eql_v2.ste_vec_entry - ELSE NULL - END -$$; - - - - - -CREATE OPERATOR ->( - FUNCTION=eql_v2."->", - LEFTARG=eql_v2_encrypted, - RIGHTARG=integer -); - - ---! @brief EQL lint: detect non-inlinable operator implementation functions ---! ---! Returns one row per violation found in the installed EQL surface. The ---! Postgres planner can only inline a function during index matching when: ---! ---! * \`LANGUAGE sql\` (plpgsql / C / etc. cannot be inlined) ---! * \`IMMUTABLE\` or \`STABLE\` volatility (VOLATILE cannot be inlined into ---! index expressions) ---! * No \`SET\` clauses (e.g. \`SET search_path = ...\`) ---! * Not \`SECURITY DEFINER\` ---! * Single-statement SELECT body ---! ---! @note The single-statement SELECT body condition is **not yet checked** by ---! this lint. A \`LANGUAGE sql\` function with a multi-statement body, a CTE, ---! or any pre-SELECT statement will pass all four implemented checks while ---! remaining non-inlinable. Implementing the check requires walking \`prosrc\` ---! (or \`pg_get_functiondef\`); tracked as a follow-up to #194. ---! ---! Operators on encrypted types (\`eql_v2_encrypted\`, \`eql_v2.bloom_filter\`, ---! \`eql_v2.ore_*\`, etc.) whose implementation functions fail any of these ---! rules silently fall back to seq scan when the documented functional ---! indexes (\`eql_v2.hmac_256(col)\`, \`eql_v2.bloom_filter(col)\`, ---! \`eql_v2.ste_vec(col)\`) are in place. This lint surfaces every such case. ---! ---! Severity: ---! \`error\` — fixable, blocks index matching, ship-blocking. ---! \`warning\` — likely-fixable, may not block matching but signals intent. ---! \`info\` — observational; useful for review, not a defect on its own. ---! ---! Categories: ---! \`inlinability_language\` — implementation function isn't \`LANGUAGE sql\`. ---! \`inlinability_volatility\` — implementation function is VOLATILE. ---! \`inlinability_set_clause\` — implementation function has a \`SET\` clause. ---! \`inlinability_secdef\` — implementation function is \`SECURITY DEFINER\`. ---! \`inlinability_transitive\` — implementation function is itself inlinable ---! but its body invokes a non-inlinable function ---! (depth 1; the planner can't peek through ---! that boundary). ---! ---! @example ---! \`\`\` ---! SELECT severity, category, object_name, message ---! FROM eql_v2.lints() ---! WHERE severity = 'error' ---! ORDER BY category, object_name; ---! \`\`\` ---! ---! @return SETOF record (severity text, category text, object_name text, message text) -CREATE OR REPLACE FUNCTION eql_v2.lints() -RETURNS TABLE ( - severity text, - category text, - object_name text, - message text -) -LANGUAGE sql STABLE -AS $$ - WITH - -- All operators where at least one operand involves an EQL type. Limits - -- the scope of the lint to the operator surface customers actually hit - -- via SQL (\`col = val\`, \`col LIKE '...'\`, \`col @> '...'\` and friends). - eql_operators AS ( - SELECT - op.oid AS oprid, - op.oprname AS opname, - op.oprcode AS implfunc, - op.oprleft::regtype AS lhs, - op.oprright::regtype AS rhs, - op.oprcode::regprocedure AS impl_signature - FROM pg_operator op - WHERE EXISTS ( - SELECT 1 FROM pg_type t - WHERE t.oid IN (op.oprleft, op.oprright) - AND (t.typname LIKE 'eql_v2%' - OR t.typnamespace = 'eql_v2'::regnamespace) - ) - ), - - -- Cross-join with each operator's implementation function metadata. - -- One row per operator; columns describe the inlinability of the impl. - op_impl AS ( - SELECT - eo.opname, - eo.lhs, - eo.rhs, - eo.impl_signature::text AS impl_signature, - lang_l.lanname AS lang, - p.provolatile AS volatility, - p.proconfig AS config, - p.prosecdef AS secdef, - p.prosrc AS body - FROM eql_operators eo - JOIN pg_proc p ON p.oid = eo.implfunc - JOIN pg_language lang_l ON lang_l.oid = p.prolang - ) - - -- ┌─────────────────────────────────────────────────────────────────┐ - -- │ Direct inlinability checks: each row examines one operator's │ - -- │ implementation function and emits a violation if any rule is │ - -- │ broken. Multiple violations on the same function become │ - -- │ multiple rows (developers see every reason it doesn't inline). │ - -- └─────────────────────────────────────────────────────────────────┘ - - SELECT - 'error' AS severity, - 'inlinability_language' AS category, - format('operator %s(%s, %s) -> %s', - opname, lhs, rhs, impl_signature) AS object_name, - format( - 'Operator implementation function is \`LANGUAGE %s\`; only \`LANGUAGE sql\` functions can be inlined by the planner. Bare \`col %s val\` queries fall back to seq scan even when a matching functional index exists.', - lang, opname) AS message - FROM op_impl - WHERE lang <> 'sql' - - UNION ALL - - SELECT - 'error', - 'inlinability_volatility', - format('operator %s(%s, %s) -> %s', opname, lhs, rhs, impl_signature), - format( - 'Operator implementation function is \`VOLATILE\`. The Postgres planner refuses to inline volatile functions into index expressions, so functional indexes never engage. Mark the function \`IMMUTABLE\` (or \`STABLE\` if it depends on session state).', - opname) - FROM op_impl - WHERE volatility = 'v' - - UNION ALL - - SELECT - 'error', - 'inlinability_set_clause', - format('operator %s(%s, %s) -> %s', opname, lhs, rhs, impl_signature), - format( - 'Operator implementation function has a \`SET\` clause (e.g. \`SET search_path = ...\`). Per Postgres function-inlining rules, any \`SET\` clause blocks inlining. Use schema-qualified identifiers in the body and remove the \`SET\` clause to allow the planner to inline.') - FROM op_impl - WHERE config IS NOT NULL - - UNION ALL - - SELECT - 'error', - 'inlinability_secdef', - format('operator %s(%s, %s) -> %s', opname, lhs, rhs, impl_signature), - 'Operator implementation function is \`SECURITY DEFINER\`. Such functions cannot be inlined; remove \`SECURITY DEFINER\` or use a non-inlinable wrapper layer.' - FROM op_impl - WHERE secdef - - -- ┌─────────────────────────────────────────────────────────────────┐ - -- │ Transitive inlinability: an operator implementation function │ - -- │ that's itself inlinable can still fail to inline if its body │ - -- │ calls a non-inlinable function. Walk one level via pg_depend. │ - -- │ │ - -- │ Postgres records function-to-function dependencies in │ - -- │ pg_depend with deptype 'n' (normal) when one function references│ - -- │ another in its body — but only at CREATE time and only for │ - -- │ direct calls. This is good enough for v1; deeper transitive │ - -- │ analysis is a follow-up. │ - -- └─────────────────────────────────────────────────────────────────┘ - - UNION ALL - - SELECT - 'error', - 'inlinability_transitive', - format('operator %s(%s, %s) -> %s', oi.opname, oi.lhs, oi.rhs, - oi.impl_signature), - format( - 'Operator implementation function is inlinable but invokes non-inlinable function \`%s\` (lang=%s, volatility=%s%s). The chain blocks at depth 1: the planner inlines the outer call but cannot reduce the inner call into an index expression.', - called.proname, - called_lang.lanname, - CASE called.provolatile - WHEN 'i' THEN 'IMMUTABLE' - WHEN 's' THEN 'STABLE' - WHEN 'v' THEN 'VOLATILE' - END, - CASE WHEN called.proconfig IS NOT NULL - THEN ', has SET clause' - ELSE '' END) - FROM op_impl oi - -- Only worth the transitive check if the outer function is otherwise - -- inlinable — otherwise the direct lints above already report it. - JOIN pg_proc outer_p ON outer_p.oid = oi.impl_signature::regprocedure - JOIN pg_depend d - ON d.classid = 'pg_proc'::regclass - AND d.objid = outer_p.oid - AND d.refclassid = 'pg_proc'::regclass - AND d.deptype = 'n' - JOIN pg_proc called ON called.oid = d.refobjid - JOIN pg_language called_lang ON called_lang.oid = called.prolang - WHERE oi.lang = 'sql' - AND oi.volatility IN ('i', 's') - AND oi.config IS NULL - AND NOT oi.secdef - AND called.oid <> outer_p.oid - AND ( - called_lang.lanname <> 'sql' - OR called.provolatile = 'v' - OR called.proconfig IS NOT NULL - OR called.prosecdef - ) - - ORDER BY 1, 2, 3; -$$; - -COMMENT ON FUNCTION eql_v2.lints() IS - 'EQL lint: returns one row per non-inlinable operator implementation. ' - 'Run \`SELECT * FROM eql_v2.lints() WHERE severity = ''error''\` for a ' - 'CI-gateable check that all operator implementations on EQL types are ' - 'eligible for planner inlining.'; - ---! @file jsonb/functions.sql ---! @brief JSONB path query and array manipulation functions for encrypted data ---! ---! These functions provide PostgreSQL-compatible operations on encrypted JSONB values ---! using Structured Transparent Encryption (STE). They support: ---! - Path-based queries to extract nested encrypted values ---! - Existence checks for encrypted fields ---! - Array operations (length, elements extraction) ---! - Field-level HMAC term extraction for equality / GROUP BY / DISTINCT ---! ---! @note STE stores encrypted JSONB as a vector of encrypted elements ('sv') with selectors ---! @note Functions suppress errors for missing fields, type mismatches (similar to PostgreSQL jsonpath) ---! @note \`selector\` parameters in this module are *encrypted-side* selector ---! hashes — the deterministic hash that the crypto layer (e.g. ---! \`@cipherstash/protect\`) emits in the \`s\` field of each \`sv\` element ---! (e.g. \`'a7cea93975ed8c01f861ccb6bd082784'\`). Plaintext JSONPaths ---! like \`'$.address.city'\` are never accepted at runtime; the proxy / ---! client rewrites them to selector hashes before the query reaches EQL. - - ---! @brief Query encrypted JSONB for elements matching selector ---! ---! Searches the Structured Transparent Encryption (STE) vector for elements matching ---! the given selector path. Returns all matching encrypted elements. If multiple ---! matches form an array, they are wrapped with array metadata. ---! ---! @param jsonb Encrypted JSONB payload containing STE vector ('sv') ---! @param text Path selector to match against encrypted elements ---! @return SETOF eql_v2_encrypted Matching encrypted elements (may return multiple rows) ---! ---! @note Returns empty set if selector is not found (does not throw exception) ---! @note Array elements use same selector; multiple matches wrapped with 'a' flag ---! @note Returns a set containing NULL if val is NULL; returns empty set if no matches found ---! @see eql_v2.jsonb_path_query_first ---! @see eql_v2.jsonb_path_exists -CREATE FUNCTION eql_v2.jsonb_path_query(val jsonb, selector text) - RETURNS SETOF eql_v2_encrypted - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT - CASE - WHEN bool_or(eql_v2.is_ste_vec_array(elem)) THEN - (eql_v2.meta_data(val) || jsonb_build_object('sv', jsonb_agg(elem), 'a', 1))::eql_v2_encrypted - ELSE - (eql_v2.meta_data(val) || (array_agg(elem))[1])::eql_v2_encrypted - END - FROM jsonb_array_elements(val -> 'sv') elem - WHERE elem ->> 's' = selector - HAVING count(*) > 0 -$$; - - ---! @brief Query encrypted JSONB with encrypted selector ---! ---! Overload that accepts encrypted selector and extracts its plaintext value ---! before delegating to main jsonb_path_query implementation. ---! ---! @param val eql_v2_encrypted Encrypted JSONB value to query ---! @param selector eql_v2_encrypted Encrypted selector to match against ---! @return SETOF eql_v2_encrypted Matching encrypted elements ---! ---! @see eql_v2.jsonb_path_query(jsonb, text) -CREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector eql_v2_encrypted) - RETURNS SETOF eql_v2_encrypted - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT * FROM eql_v2.jsonb_path_query((val).data, eql_v2._selector(selector)); -$$; - - ---! @brief Query encrypted JSONB with text selector ---! ---! Overload that accepts encrypted JSONB value and text selector, ---! extracting the JSONB payload before querying. ---! ---! @param eql_v2_encrypted Encrypted JSONB value to query ---! @param text Path selector to match against ---! @return SETOF eql_v2_encrypted Matching encrypted elements ---! ---! @example ---! -- Query encrypted JSONB for the sv element at a given selector hash ---! SELECT * FROM eql_v2.jsonb_path_query(encrypted_document, 'a7cea93975ed8c01f861ccb6bd082784'); ---! ---! @see eql_v2.jsonb_path_query(jsonb, text) -CREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector text) - RETURNS SETOF eql_v2_encrypted - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT * FROM eql_v2.jsonb_path_query((val).data, selector); -$$; - - ------------------------------------------------------------------------------------- - - ---! @brief Check if selector path exists in encrypted JSONB ---! ---! Tests whether any encrypted elements match the given selector path. ---! More efficient than jsonb_path_query when only existence check is needed. ---! ---! @param jsonb Encrypted JSONB payload to check ---! @param text Path selector to test ---! @return boolean True if matching element exists, false otherwise ---! ---! @see eql_v2.jsonb_path_query(jsonb, text) -CREATE FUNCTION eql_v2.jsonb_path_exists(val jsonb, selector text) - RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT EXISTS ( - SELECT 1 FROM jsonb_array_elements(val -> 'sv') elem - WHERE elem ->> 's' = selector - ); -$$; - - ---! @brief Check existence with encrypted selector ---! ---! Overload that accepts encrypted selector and extracts its value ---! before checking existence. ---! ---! @param val eql_v2_encrypted Encrypted JSONB value to check ---! @param selector eql_v2_encrypted Encrypted selector to test ---! @return boolean True if path exists ---! ---! @see eql_v2.jsonb_path_exists(jsonb, text) -CREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector eql_v2_encrypted) - RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.jsonb_path_exists((val).data, eql_v2._selector(selector)); -$$; - - ---! @brief Check existence with text selector ---! ---! Overload that accepts encrypted JSONB value and text selector. ---! ---! @param eql_v2_encrypted Encrypted JSONB value to check ---! @param text Path selector to test ---! @return boolean True if path exists ---! ---! @example ---! -- Check if the encrypted document has an sv element at a given selector hash ---! SELECT eql_v2.jsonb_path_exists(encrypted_document, 'a7cea93975ed8c01f861ccb6bd082784'); ---! ---! @see eql_v2.jsonb_path_exists(jsonb, text) -CREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector text) - RETURNS boolean - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.jsonb_path_exists((val).data, selector); -$$; - - ------------------------------------------------------------------------------------- - - ---! @brief Get first element matching selector ---! ---! Returns only the first encrypted element matching the selector path, ---! or NULL if no match found. More efficient than jsonb_path_query when ---! only one result is needed. ---! ---! @param jsonb Encrypted JSONB payload to query ---! @param text Path selector to match ---! @return eql_v2_encrypted First matching element or NULL ---! ---! @note Uses LIMIT 1 internally for efficiency ---! @see eql_v2.jsonb_path_query(jsonb, text) -CREATE FUNCTION eql_v2.jsonb_path_query_first(val jsonb, selector text) - RETURNS eql_v2_encrypted - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT (eql_v2.meta_data(val) || elem)::eql_v2_encrypted - FROM jsonb_array_elements(val -> 'sv') elem - WHERE elem ->> 's' = selector - LIMIT 1 -$$; - - ---! @brief Get first element with encrypted selector ---! ---! Overload that accepts encrypted selector and extracts its value ---! before querying for first match. ---! ---! @param val eql_v2_encrypted Encrypted JSONB value to query ---! @param selector eql_v2_encrypted Encrypted selector to match ---! @return eql_v2_encrypted First matching element or NULL ---! ---! @see eql_v2.jsonb_path_query_first(jsonb, text) -CREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector eql_v2_encrypted) - RETURNS eql_v2_encrypted - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.jsonb_path_query_first((val).data, eql_v2._selector(selector)); -$$; - - ---! @brief Get first element with text selector ---! ---! Overload that accepts encrypted JSONB value and text selector. ---! ---! @param eql_v2_encrypted Encrypted JSONB value to query ---! @param text Path selector to match ---! @return eql_v2_encrypted First matching element or NULL ---! ---! @example ---! -- Get the first matching sv element from an encrypted document ---! SELECT eql_v2.jsonb_path_query_first(encrypted_document, 'a7cea93975ed8c01f861ccb6bd082784'); ---! ---! @see eql_v2.jsonb_path_query_first(jsonb, text) -CREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector text) - RETURNS eql_v2_encrypted - LANGUAGE sql - IMMUTABLE STRICT PARALLEL SAFE -AS $$ - SELECT eql_v2.jsonb_path_query_first((val).data, selector); -$$; - - - ------------------------------------------------------------------------------------- - - ---! @brief Get length of encrypted JSONB array ---! ---! Returns the number of elements in an encrypted JSONB array by counting ---! elements in the STE vector ('sv'). The encrypted value must have the ---! array flag ('a') set to true. ---! ---! @param jsonb Encrypted JSONB payload representing an array ---! @return integer Number of elements in the array ---! @throws Exception 'cannot get array length of a non-array' if 'a' flag is missing or not true ---! ---! @note Array flag 'a' must be present and set to true value ---! @see eql_v2.jsonb_array_elements -CREATE FUNCTION eql_v2.jsonb_array_length(val jsonb) - RETURNS integer - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - sv eql_v2_encrypted[]; - found eql_v2_encrypted[]; - BEGIN - - IF val IS NULL THEN - RETURN NULL; - END IF; - - IF eql_v2.is_ste_vec_array(val) THEN - sv := eql_v2.ste_vec(val); - RETURN array_length(sv, 1); - END IF; - - RAISE 'cannot get array length of a non-array'; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Get array length from encrypted type ---! ---! Overload that accepts encrypted composite type and extracts the ---! JSONB payload before computing array length. ---! ---! @param eql_v2_encrypted Encrypted array value ---! @return integer Number of elements in the array ---! @throws Exception if value is not an array ---! ---! @example ---! -- Get length of encrypted array ---! SELECT eql_v2.jsonb_array_length(encrypted_tags); ---! ---! @see eql_v2.jsonb_array_length(jsonb) -CREATE FUNCTION eql_v2.jsonb_array_length(val eql_v2_encrypted) - RETURNS integer - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN ( - SELECT eql_v2.jsonb_array_length(val.data) - ); - END; -$$ LANGUAGE plpgsql; - - - - ---! @brief Extract elements from encrypted JSONB array ---! ---! Returns each element of an encrypted JSONB array as a separate row. ---! Each element is returned as an eql_v2_encrypted value with metadata ---! preserved from the parent array. ---! ---! @param jsonb Encrypted JSONB payload representing an array ---! @return SETOF eql_v2_encrypted One row per array element ---! @throws Exception if value is not an array (missing 'a' flag) ---! ---! @note Each element inherits metadata (version, ident) from parent ---! @see eql_v2.jsonb_array_length ---! @see eql_v2.jsonb_array_elements_text -CREATE FUNCTION eql_v2.jsonb_array_elements(val jsonb) - RETURNS SETOF eql_v2_encrypted - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - sv eql_v2_encrypted[]; - meta jsonb; - item jsonb; - BEGIN - - IF NOT eql_v2.is_ste_vec_array(val) THEN - RAISE 'cannot extract elements from non-array'; - END IF; - - -- Column identifier and version - meta := eql_v2.meta_data(val); - - sv := eql_v2.ste_vec(val); - - FOR idx IN 1..array_length(sv, 1) LOOP - item = sv[idx]; - RETURN NEXT (meta || item)::eql_v2_encrypted; - END LOOP; - - RETURN; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Extract elements from encrypted array type ---! ---! Overload that accepts encrypted composite type and extracts each ---! array element as a separate row. ---! ---! @param eql_v2_encrypted Encrypted array value ---! @return SETOF eql_v2_encrypted One row per array element ---! @throws Exception if value is not an array ---! ---! @example ---! -- Expand encrypted array into rows ---! SELECT * FROM eql_v2.jsonb_array_elements(encrypted_tags); ---! ---! @see eql_v2.jsonb_array_elements(jsonb) -CREATE FUNCTION eql_v2.jsonb_array_elements(val eql_v2_encrypted) - RETURNS SETOF eql_v2_encrypted - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN QUERY - SELECT * FROM eql_v2.jsonb_array_elements(val.data); - END; -$$ LANGUAGE plpgsql; - - - ---! @brief Extract encrypted array elements as ciphertext ---! ---! Returns each element of an encrypted JSONB array as its raw ciphertext ---! value (text representation). Unlike jsonb_array_elements, this returns ---! only the ciphertext 'c' field without metadata. ---! ---! @param jsonb Encrypted JSONB payload representing an array ---! @return SETOF text One ciphertext string per array element ---! @throws Exception if value is not an array (missing 'a' flag) ---! ---! @note Returns ciphertext only, not full encrypted structure ---! @see eql_v2.jsonb_array_elements -CREATE FUNCTION eql_v2.jsonb_array_elements_text(val jsonb) - RETURNS SETOF text - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - sv eql_v2_encrypted[]; - found eql_v2_encrypted[]; - BEGIN - IF NOT eql_v2.is_ste_vec_array(val) THEN - RAISE 'cannot extract elements from non-array'; - END IF; - - sv := eql_v2.ste_vec(val); - - FOR idx IN 1..array_length(sv, 1) LOOP - RETURN NEXT eql_v2.ciphertext(sv[idx]); - END LOOP; - - RETURN; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Extract array elements as ciphertext from encrypted type ---! ---! Overload that accepts encrypted composite type and extracts each ---! array element's ciphertext as text. ---! ---! @param eql_v2_encrypted Encrypted array value ---! @return SETOF text One ciphertext string per array element ---! @throws Exception if value is not an array ---! ---! @example ---! -- Get ciphertext of each array element ---! SELECT * FROM eql_v2.jsonb_array_elements_text(encrypted_tags); ---! ---! @see eql_v2.jsonb_array_elements_text(jsonb) -CREATE FUNCTION eql_v2.jsonb_array_elements_text(val eql_v2_encrypted) - RETURNS SETOF text - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN QUERY - SELECT * FROM eql_v2.jsonb_array_elements_text(val.data); - END; -$$ LANGUAGE plpgsql; - - ------------------------------------------------------------------------------------- - --- \`eql_v2.hmac_256_terms(eql_v2_encrypted)\` was added under #205 as a --- GIN-indexable {s, hm} aggregate. It's been removed: under the XOR --- contract each sv element carries exactly one of \`hm\` (bool leaves, --- array / object roots) or \`oc\` (string / number leaves), and --- \`hmac_256_terms\` filters out everything without \`hm\` — so containment --- queries via this index could never match on string / number selectors. --- The canonical XOR-aware replacement is the typed --- \`@>(eql_v2_encrypted, eql_v2.stevec_query)\` overload, which inlines --- to \`eql_v2.to_stevec_query(col)::jsonb @> needle::jsonb\` and engages --- a functional GIN on \`(eql_v2.to_stevec_query(col)::jsonb) jsonb_path_ops\`. --- See U-007 / U-008 in \`docs/upgrading/v2.3.md\`. ---! @file encryptindex/functions.sql ---! @brief Configuration lifecycle and column encryption management ---! ---! Provides functions for managing encryption configuration transitions: ---! - Comparing configurations to identify changes ---! - Identifying columns needing encryption ---! - Creating and renaming encrypted columns during initial setup ---! - Tracking encryption progress ---! ---! These functions support the workflow of activating a pending configuration ---! and performing the initial encryption of plaintext columns. - - ---! @brief Compare two configurations and find differences ---! @internal ---! ---! Returns table/column pairs where configuration differs between two configs. ---! Used to identify which columns need encryption when activating a pending config. ---! ---! @param a jsonb First configuration to compare ---! @param b jsonb Second configuration to compare ---! @return TABLE(table_name text, column_name text) Columns with differing configuration ---! ---! @note Compares configuration structure, not just presence/absence ---! @see eql_v2.select_pending_columns -CREATE FUNCTION eql_v2.diff_config(a JSONB, b JSONB) - RETURNS TABLE(table_name TEXT, column_name TEXT) -IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - RETURN QUERY - WITH table_keys AS ( - SELECT jsonb_object_keys(a->'tables') AS key - UNION - SELECT jsonb_object_keys(b->'tables') AS key - ), - column_keys AS ( - SELECT tk.key AS table_key, jsonb_object_keys(a->'tables'->tk.key) AS column_key - FROM table_keys tk - UNION - SELECT tk.key AS table_key, jsonb_object_keys(b->'tables'->tk.key) AS column_key - FROM table_keys tk - ) - SELECT - ck.table_key AS table_name, - ck.column_key AS column_name - FROM - column_keys ck - WHERE - (a->'tables'->ck.table_key->ck.column_key IS DISTINCT FROM b->'tables'->ck.table_key->ck.column_key); - END; -$$ LANGUAGE plpgsql; - - ---! @brief Get columns with pending configuration changes ---! ---! Compares 'pending' and 'active' configurations to identify columns that need ---! encryption or re-encryption. Returns columns where configuration differs. ---! ---! @return TABLE(table_name text, column_name text) Columns needing encryption ---! @throws Exception if no pending configuration exists ---! ---! @note Treats missing active config as empty config ---! @see eql_v2.diff_config ---! @see eql_v2.select_target_columns -CREATE FUNCTION eql_v2.select_pending_columns() - RETURNS TABLE(table_name TEXT, column_name TEXT) - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - active JSONB; - pending JSONB; - config_id BIGINT; - BEGIN - SELECT data INTO active FROM eql_v2_configuration WHERE state = 'active'; - - -- set default config - IF active IS NULL THEN - active := '{}'; - END IF; - - SELECT id, data INTO config_id, pending FROM eql_v2_configuration WHERE state = 'pending'; - - -- set default config - IF config_id IS NULL THEN - RAISE EXCEPTION 'No pending configuration exists to encrypt'; - END IF; - - RETURN QUERY - SELECT d.table_name, d.column_name FROM eql_v2.diff_config(active, pending) as d; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Map pending columns to their encrypted target columns ---! ---! For each column with pending configuration, identifies the corresponding ---! encrypted column. During initial encryption, target is '{column_name}_encrypted'. ---! Returns NULL for target_column if encrypted column doesn't exist yet. ---! ---! @return TABLE(table_name text, column_name text, target_column text) Column mappings ---! ---! @note Target column is NULL if no column exists matching either 'column_name' or 'column_name_encrypted' with type eql_v2_encrypted ---! @note The LEFT JOIN checks both original and '_encrypted' suffix variations with type verification ---! @see eql_v2.select_pending_columns ---! @see eql_v2.create_encrypted_columns -CREATE FUNCTION eql_v2.select_target_columns() - RETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT) - STABLE STRICT PARALLEL SAFE -AS $$ - SELECT - c.table_name, - c.column_name, - s.column_name as target_column - FROM - eql_v2.select_pending_columns() c - LEFT JOIN information_schema.columns s ON - s.table_name = c.table_name AND - (s.column_name = c.column_name OR s.column_name = c.column_name || '_encrypted') AND - s.udt_name = 'eql_v2_encrypted'; -$$ LANGUAGE sql; - - ---! @brief Check if database is ready for encryption ---! ---! Verifies that all columns with pending configuration have corresponding ---! encrypted target columns created. Returns true if encryption can proceed. ---! ---! @return boolean True if all pending columns have target encrypted columns ---! ---! @note Returns false if any pending column lacks encrypted column ---! @see eql_v2.select_target_columns ---! @see eql_v2.create_encrypted_columns -CREATE FUNCTION eql_v2.ready_for_encryption() - RETURNS BOOLEAN - STABLE STRICT PARALLEL SAFE -AS $$ - SELECT EXISTS ( - SELECT * - FROM eql_v2.select_target_columns() AS c - WHERE c.target_column IS NOT NULL); -$$ LANGUAGE sql; - - ---! @brief Create encrypted columns for initial encryption ---! ---! For each plaintext column with pending configuration that lacks an encrypted ---! target column, creates a new column '{column_name}_encrypted' of type ---! eql_v2_encrypted. This prepares the database schema for initial encryption. ---! ---! @return TABLE(table_name text, column_name text) Created encrypted columns ---! ---! @warning Executes dynamic DDL (ALTER TABLE ADD COLUMN) - modifies database schema ---! @note Only creates columns that don't already exist ---! @see eql_v2.select_target_columns ---! @see eql_v2.rename_encrypted_columns -CREATE FUNCTION eql_v2.create_encrypted_columns() - RETURNS TABLE(table_name TEXT, column_name TEXT) - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - FOR table_name, column_name IN - SELECT c.table_name, (c.column_name || '_encrypted') FROM eql_v2.select_target_columns() AS c WHERE c.target_column IS NULL - LOOP - EXECUTE format('ALTER TABLE %I ADD column %I eql_v2_encrypted;', table_name, column_name); - RETURN NEXT; - END LOOP; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Finalize initial encryption by renaming columns ---! ---! After initial encryption completes, renames columns to complete the transition: ---! - Plaintext column '{column_name}' → '{column_name}_plaintext' ---! - Encrypted column '{column_name}_encrypted' → '{column_name}' ---! ---! This makes the encrypted column the primary column with the original name. ---! ---! @return TABLE(table_name text, column_name text, target_column text) Renamed columns ---! ---! @warning Executes dynamic DDL (ALTER TABLE RENAME COLUMN) - modifies database schema ---! @note Only renames columns where target is '{column_name}_encrypted' ---! @see eql_v2.create_encrypted_columns -CREATE FUNCTION eql_v2.rename_encrypted_columns() - RETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT) - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - FOR table_name, column_name, target_column IN - SELECT * FROM eql_v2.select_target_columns() as c WHERE c.target_column = c.column_name || '_encrypted' - LOOP - EXECUTE format('ALTER TABLE %I RENAME %I TO %I;', table_name, column_name, column_name || '_plaintext'); - EXECUTE format('ALTER TABLE %I RENAME %I TO %I;', table_name, target_column, column_name); - RETURN NEXT; - END LOOP; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Count rows encrypted with active configuration ---! @internal ---! ---! Counts rows in a table where the encrypted column was encrypted using ---! the currently active configuration. Used to track encryption progress. ---! ---! @param table_name text Name of table to check ---! @param column_name text Name of encrypted column to check ---! @return bigint Count of rows encrypted with active configuration ---! ---! @note The 'v' field in encrypted payloads stores the payload version ("2"), not the configuration ID ---! @note Configuration tracking mechanism is implementation-specific -CREATE FUNCTION eql_v2.count_encrypted_with_active_config(table_name TEXT, column_name TEXT) - RETURNS BIGINT - SET search_path = pg_catalog, extensions, public -AS $$ -DECLARE - result BIGINT; -BEGIN - EXECUTE format( - 'SELECT COUNT(%I) FROM %s t WHERE %I->>%L = (SELECT id::TEXT FROM eql_v2_configuration WHERE state = %L)', - column_name, table_name, column_name, 'v', 'active' - ) - INTO result; - RETURN result; -END; -$$ LANGUAGE plpgsql; - - - ---! @brief Validate presence of ident field in encrypted payload ---! @internal ---! ---! Checks that the encrypted JSONB payload contains the required 'i' (ident) field. ---! The ident field tracks which table and column the encrypted value belongs to. ---! ---! @param jsonb Encrypted payload to validate ---! @return Boolean True if 'i' field is present ---! @throws Exception if 'i' field is missing ---! ---! @note Used in CHECK constraints to ensure payload structure ---! @see eql_v2.check_encrypted -CREATE FUNCTION eql_v2._encrypted_check_i(val jsonb) - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF val ? 'i' THEN - RETURN true; - END IF; - RAISE 'Encrypted column missing ident (i) field: %', val; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Validate table and column fields in ident ---! @internal ---! ---! Checks that the 'i' (ident) field contains both 't' (table) and 'c' (column) ---! subfields, which identify the origin of the encrypted value. ---! ---! @param jsonb Encrypted payload to validate ---! @return Boolean True if both 't' and 'c' subfields are present ---! @throws Exception if 't' or 'c' subfields are missing ---! ---! @note Used in CHECK constraints to ensure payload structure ---! @see eql_v2.check_encrypted -CREATE FUNCTION eql_v2._encrypted_check_i_ct(val jsonb) - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF (val->'i' ?& array['t', 'c']) THEN - RETURN true; - END IF; - RAISE 'Encrypted column ident (i) missing table (t) or column (c) fields: %', val; - END; -$$ LANGUAGE plpgsql; - ---! @brief Validate version field in encrypted payload ---! @internal ---! ---! Checks that the encrypted payload has version field 'v' set to '2', ---! the current EQL v2 payload version. ---! ---! @param jsonb Encrypted payload to validate ---! @return Boolean True if 'v' field is present and equals '2' ---! @throws Exception if 'v' field is missing or not '2' ---! ---! @note Used in CHECK constraints to ensure payload structure ---! @see eql_v2.check_encrypted -CREATE FUNCTION eql_v2._encrypted_check_v(val jsonb) - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF (val ? 'v') THEN - - IF val->>'v' <> '2' THEN - RAISE 'Expected encrypted column version (v) 2'; - RETURN false; - END IF; - - RETURN true; - END IF; - RAISE 'Encrypted column missing version (v) field: %', val; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Validate ciphertext field in encrypted payload ---! @internal ---! ---! Checks that the encrypted payload carries the required root-level ciphertext ---! envelope. The v2.3 payload schema admits two mutually exclusive top-level ---! shapes (\`docs/reference/schema/eql-payload-v2.3.schema.json\`): ---! ---! - \`EncryptedPayload\` (scalar) — carries \`c\` at the root. ---! - \`SteVecPayload\` (jsonb / structured) — carries \`sv\` at the root; the ---! root document ciphertext lives inside \`sv[0].c\`, so \`c\` is absent at ---! the root. ---! ---! Either shape satisfies this check. Per-element ciphertext validity on ---! \`sv\` entries is enforced separately by the \`eql_v2.ste_vec_entry\` DOMAIN. ---! ---! @param jsonb Encrypted payload to validate ---! @return Boolean True if either 'c' or 'sv' is present at the root ---! @throws Exception if neither 'c' nor 'sv' is present ---! ---! @note Used in CHECK constraints to ensure payload structure ---! @see eql_v2.check_encrypted -CREATE FUNCTION eql_v2._encrypted_check_c(val jsonb) - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF (val ? 'c') OR (val ? 'sv') THEN - RETURN true; - END IF; - RAISE 'Encrypted column missing ciphertext (c) or ste_vec (sv) field: %', val; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Validate complete encrypted payload structure ---! ---! Comprehensive validation function that checks all required fields in an ---! encrypted JSONB payload: version ('v'), ciphertext ('c'), ident ('i'), ---! and ident subfields ('t', 'c'). ---! ---! This function is used in CHECK constraints to ensure encrypted column ---! data integrity at the database level. ---! ---! @param jsonb Encrypted payload to validate ---! @return Boolean True if all structure checks pass ---! @throws Exception if any required field is missing or invalid ---! ---! @example ---! -- Add validation constraint to encrypted column ---! ALTER TABLE users ADD CONSTRAINT check_email_encrypted ---! CHECK (eql_v2.check_encrypted(encrypted_email::jsonb)); ---! ---! @see eql_v2._encrypted_check_v ---! @see eql_v2._encrypted_check_c ---! @see eql_v2._encrypted_check_i ---! @see eql_v2._encrypted_check_i_ct -CREATE FUNCTION eql_v2.check_encrypted(val jsonb) - RETURNS BOOLEAN -LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -BEGIN ATOMIC - RETURN ( - eql_v2._encrypted_check_v(val) AND - eql_v2._encrypted_check_c(val) AND - eql_v2._encrypted_check_i(val) AND - eql_v2._encrypted_check_i_ct(val) - ); -END; - - ---! @brief Validate encrypted composite type structure ---! ---! Validates an eql_v2_encrypted composite type by checking its underlying ---! JSONB payload. Delegates to eql_v2.check_encrypted(jsonb). ---! ---! @param eql_v2_encrypted Encrypted value to validate ---! @return Boolean True if structure is valid ---! @throws Exception if any required field is missing or invalid ---! ---! @see eql_v2.check_encrypted(jsonb) -CREATE FUNCTION eql_v2.check_encrypted(val eql_v2_encrypted) - RETURNS BOOLEAN -LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -BEGIN ATOMIC - RETURN eql_v2.check_encrypted(val.data); -END; - - --- Aggregate functions for ORE - ---! @brief State transition function for min aggregate ---! @internal ---! ---! Returns the smaller of two encrypted values for use in MIN aggregate. ---! Comparison uses ORE index terms without decryption. ---! ---! @param a eql_v2_encrypted First encrypted value ---! @param b eql_v2_encrypted Second encrypted value ---! @return eql_v2_encrypted The smaller of the two values ---! ---! @see eql_v2.min(eql_v2_encrypted) -CREATE FUNCTION eql_v2.min(a eql_v2_encrypted, b eql_v2_encrypted) - RETURNS eql_v2_encrypted -STRICT - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF a < b THEN - RETURN a; - ELSE - RETURN b; - END IF; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Find minimum encrypted value in a group ---! ---! Aggregate function that returns the minimum encrypted value in a group ---! using ORE index term comparisons without decryption. ---! ---! @param input eql_v2_encrypted Encrypted values to aggregate ---! @return eql_v2_encrypted Minimum value in the group ---! ---! @example ---! -- Find minimum age per department ---! SELECT department, eql_v2.min(encrypted_age) ---! FROM employees ---! GROUP BY department; ---! ---! @note Requires 'ore' index configuration on the column ---! @see eql_v2.min(eql_v2_encrypted, eql_v2_encrypted) -CREATE AGGREGATE eql_v2.min(eql_v2_encrypted) -( - sfunc = eql_v2.min, - stype = eql_v2_encrypted -); - - ---! @brief State transition function for max aggregate ---! @internal ---! ---! Returns the larger of two encrypted values for use in MAX aggregate. ---! Comparison uses ORE index terms without decryption. ---! ---! @param a eql_v2_encrypted First encrypted value ---! @param b eql_v2_encrypted Second encrypted value ---! @return eql_v2_encrypted The larger of the two values ---! ---! @see eql_v2.max(eql_v2_encrypted) -CREATE FUNCTION eql_v2.max(a eql_v2_encrypted, b eql_v2_encrypted) -RETURNS eql_v2_encrypted -STRICT - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF a > b THEN - RETURN a; - ELSE - RETURN b; - END IF; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Find maximum encrypted value in a group ---! ---! Aggregate function that returns the maximum encrypted value in a group ---! using ORE index term comparisons without decryption. ---! ---! @param input eql_v2_encrypted Encrypted values to aggregate ---! @return eql_v2_encrypted Maximum value in the group ---! ---! @example ---! -- Find maximum salary per department ---! SELECT department, eql_v2.max(encrypted_salary) ---! FROM employees ---! GROUP BY department; ---! ---! @note Requires 'ore' index configuration on the column ---! @see eql_v2.max(eql_v2_encrypted, eql_v2_encrypted) -CREATE AGGREGATE eql_v2.max(eql_v2_encrypted) -( - sfunc = eql_v2.max, - stype = eql_v2_encrypted -); - - ---! @file config/indexes.sql ---! @brief Configuration state uniqueness indexes ---! ---! Creates partial unique indexes to enforce that only one configuration ---! can be in 'active', 'pending', or 'encrypting' state at any time. ---! Multiple 'inactive' configurations are allowed. ---! ---! @note Uses partial indexes (WHERE clauses) for efficiency ---! @note Prevents conflicting configurations from being active simultaneously ---! @see config/types.sql for state definitions - - ---! @brief Unique active configuration constraint ---! @note Only one configuration can be 'active' at once -CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'active'; - ---! @brief Unique pending configuration constraint ---! @note Only one configuration can be 'pending' at once -CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'pending'; - ---! @brief Unique encrypting configuration constraint ---! @note Only one configuration can be 'encrypting' at once -CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'encrypting'; - - ---! @brief Add a search index configuration for an encrypted column ---! ---! Configures a searchable encryption index (unique, match, ore, ope, or ste_vec) ---! on an encrypted column. Creates or updates the pending configuration, then ---! migrates and activates it unless migrating flag is set. ---! ---! @param table_name Text Name of the table containing the column ---! @param column_name Text Name of the column to configure ---! @param index_name Text Type of index ('unique', 'match', 'ore', 'ope', 'ste_vec') ---! @param cast_as Text PostgreSQL type for decrypted values (default: 'text') ---! @param opts JSONB Index-specific options (default: '{}') ---! @param migrating Boolean Skip auto-migration if true (default: false) ---! @return JSONB Updated configuration object ---! @throws Exception if index already exists for this column ---! @throws Exception if cast_as is not a valid type ---! ---! @example ---! -- Add unique index for exact-match searches ---! SELECT eql_v2.add_search_config('users', 'email', 'unique'); ---! ---! -- Add match index for LIKE searches with custom token length ---! SELECT eql_v2.add_search_config('posts', 'content', 'match', 'text', ---! '{"token_filters": [{"kind": "downcase"}], "tokenizer": {"kind": "ngram", "token_length": 3}}' ---! ); ---! ---! @see eql_v2.add_column ---! @see eql_v2.remove_search_config -CREATE FUNCTION eql_v2.add_search_config(table_name text, column_name text, index_name text, cast_as text DEFAULT 'text', opts jsonb DEFAULT '{}', migrating boolean DEFAULT false) - RETURNS jsonb - - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - o jsonb; - _config jsonb; - BEGIN - - -- set the active config - SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC; - - -- if index exists - IF _config #> array['tables', table_name, column_name, 'indexes'] ? index_name THEN - RAISE EXCEPTION '% index exists for column: % %', index_name, table_name, column_name; - END IF; - - IF NOT cast_as = ANY('{text, int, small_int, big_int, real, double, boolean, date, jsonb, json, float, decimal, timestamp}') THEN - RAISE EXCEPTION '% is not a valid cast type', cast_as; - END IF; - - -- set default config - SELECT eql_v2.config_default(_config) INTO _config; - - SELECT eql_v2.config_add_table(table_name, _config) INTO _config; - - SELECT eql_v2.config_add_column(table_name, column_name, _config) INTO _config; - - SELECT eql_v2.config_add_cast(table_name, column_name, cast_as, _config) INTO _config; - - -- set default options for index if opts empty - IF index_name = 'match' AND opts = '{}' THEN - SELECT eql_v2.config_match_default() INTO opts; - END IF; - - SELECT eql_v2.config_add_index(table_name, column_name, index_name, opts, _config) INTO _config; - - -- create a new pending record if we don't have one - INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config) - ON CONFLICT (state) - WHERE state = 'pending' - DO UPDATE - SET data = _config; - - IF NOT migrating THEN - PERFORM eql_v2.migrate_config(); - PERFORM eql_v2.activate_config(); - END IF; - - PERFORM eql_v2.add_encrypted_constraint(table_name, column_name); - - -- exeunt - RETURN _config; - END; -$$ LANGUAGE plpgsql; - ---! @brief Remove a search index configuration from an encrypted column ---! ---! Removes a previously configured search index from an encrypted column. ---! Updates the pending configuration, then migrates and activates it ---! unless migrating flag is set. ---! ---! @param table_name Text Name of the table containing the column ---! @param column_name Text Name of the column ---! @param index_name Text Type of index to remove ---! @param migrating Boolean Skip auto-migration if true (default: false) ---! @return JSONB Updated configuration object ---! @throws Exception if no active or pending configuration exists ---! @throws Exception if table is not configured ---! @throws Exception if column is not configured ---! ---! @example ---! -- Remove match index from column ---! SELECT eql_v2.remove_search_config('posts', 'content', 'match'); ---! ---! @see eql_v2.add_search_config ---! @see eql_v2.modify_search_config -CREATE FUNCTION eql_v2.remove_search_config(table_name text, column_name text, index_name text, migrating boolean DEFAULT false) - RETURNS jsonb - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - _config jsonb; - BEGIN - - -- set the active config - SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC; - - -- if no config - IF _config IS NULL THEN - RAISE EXCEPTION 'No active or pending configuration exists'; - END IF; - - -- if the table doesn't exist - IF NOT _config #> array['tables'] ? table_name THEN - RAISE EXCEPTION 'No configuration exists for table: %', table_name; - END IF; - - -- if the index does not exist - -- IF NOT _config->key ? index_name THEN - IF NOT _config #> array['tables', table_name] ? column_name THEN - RAISE EXCEPTION 'No % index exists for column: % %', index_name, table_name, column_name; - END IF; - - -- create a new pending record if we don't have one - INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config) - ON CONFLICT (state) - WHERE state = 'pending' - DO NOTHING; - - -- remove the index - SELECT _config #- array['tables', table_name, column_name, 'indexes', index_name] INTO _config; - - -- update the config and migrate (even if empty) - UPDATE public.eql_v2_configuration SET data = _config WHERE state = 'pending'; - - IF NOT migrating THEN - PERFORM eql_v2.migrate_config(); - PERFORM eql_v2.activate_config(); - END IF; - - -- exeunt - RETURN _config; - END; -$$ LANGUAGE plpgsql; - ---! @brief Modify a search index configuration for an encrypted column ---! ---! Updates an existing search index configuration by removing and re-adding it ---! with new options. Convenience function that combines remove and add operations. ---! If index does not exist, it is added. ---! ---! @param table_name Text Name of the table containing the column ---! @param column_name Text Name of the column ---! @param index_name Text Type of index to modify ---! @param cast_as Text PostgreSQL type for decrypted values (default: 'text') ---! @param opts JSONB New index-specific options (default: '{}') ---! @param migrating Boolean Skip auto-migration if true (default: false) ---! @return JSONB Updated configuration object ---! ---! @example ---! -- Change match index tokenizer settings ---! SELECT eql_v2.modify_search_config('posts', 'content', 'match', 'text', ---! '{"tokenizer": {"kind": "ngram", "token_length": 4}}' ---! ); ---! ---! @see eql_v2.add_search_config ---! @see eql_v2.remove_search_config -CREATE FUNCTION eql_v2.modify_search_config(table_name text, column_name text, index_name text, cast_as text DEFAULT 'text', opts jsonb DEFAULT '{}', migrating boolean DEFAULT false) - RETURNS jsonb - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - PERFORM eql_v2.remove_search_config(table_name, column_name, index_name, migrating); - RETURN eql_v2.add_search_config(table_name, column_name, index_name, cast_as, opts, migrating); - END; -$$ LANGUAGE plpgsql; - ---! @brief Migrate pending configuration to encrypting state ---! ---! Transitions the pending configuration to encrypting state, validating that ---! all configured columns have encrypted target columns ready. This is part of ---! the configuration lifecycle: pending → encrypting → active. ---! ---! @return Boolean True if migration succeeds ---! @throws Exception if encryption already in progress ---! @throws Exception if no pending configuration exists ---! @throws Exception if configured columns lack encrypted targets ---! ---! @example ---! -- Manually migrate configuration (normally done automatically) ---! SELECT eql_v2.migrate_config(); ---! ---! @see eql_v2.activate_config ---! @see eql_v2.add_column -CREATE FUNCTION eql_v2.migrate_config() - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - - IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'encrypting') THEN - RAISE EXCEPTION 'An encryption is already in progress'; - END IF; - - IF NOT EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'pending') THEN - RAISE EXCEPTION 'No pending configuration exists to encrypt'; - END IF; - - IF NOT eql_v2.ready_for_encryption() THEN - RAISE EXCEPTION 'Some pending columns do not have an encrypted target'; - END IF; - - UPDATE public.eql_v2_configuration SET state = 'encrypting' WHERE state = 'pending'; - RETURN true; - END; -$$ LANGUAGE plpgsql; - ---! @brief Activate encrypting configuration ---! ---! Transitions the encrypting configuration to active state, making it the ---! current operational configuration. Marks previous active configuration as ---! inactive. Final step in configuration lifecycle: pending → encrypting → active. ---! ---! @return Boolean True if activation succeeds ---! @throws Exception if no encrypting configuration exists to activate ---! ---! @example ---! -- Manually activate configuration (normally done automatically) ---! SELECT eql_v2.activate_config(); ---! ---! @see eql_v2.migrate_config ---! @see eql_v2.add_column -CREATE FUNCTION eql_v2.activate_config() - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - - IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'encrypting') THEN - UPDATE public.eql_v2_configuration SET state = 'inactive' WHERE state = 'active'; - UPDATE public.eql_v2_configuration SET state = 'active' WHERE state = 'encrypting'; - RETURN true; - ELSE - RAISE EXCEPTION 'No encrypting configuration exists to activate'; - END IF; - END; -$$ LANGUAGE plpgsql; - ---! @brief Discard pending configuration ---! ---! Deletes the pending configuration without applying changes. Use this to ---! abandon configuration changes before they are migrated and activated. ---! ---! @return Boolean True if discard succeeds ---! @throws Exception if no pending configuration exists to discard ---! ---! @example ---! -- Discard uncommitted configuration changes ---! SELECT eql_v2.discard(); ---! ---! @see eql_v2.add_column ---! @see eql_v2.add_search_config -CREATE FUNCTION eql_v2.discard() - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF EXISTS (SELECT FROM public.eql_v2_configuration c WHERE c.state = 'pending') THEN - DELETE FROM public.eql_v2_configuration WHERE state = 'pending'; - RETURN true; - ELSE - RAISE EXCEPTION 'No pending configuration exists to discard'; - END IF; - END; -$$ LANGUAGE plpgsql; - ---! @brief Configure a column for encryption ---! ---! Adds a column to the encryption configuration, making it eligible for ---! encrypted storage and search indexes. Creates or updates pending configuration, ---! adds encrypted constraint, then migrates and activates unless migrating flag is set. ---! ---! @param table_name Text Name of the table containing the column ---! @param column_name Text Name of the column to encrypt ---! @param cast_as Text PostgreSQL type to cast decrypted values (default: 'text') ---! @param migrating Boolean Skip auto-migration if true (default: false) ---! @return JSONB Updated configuration object ---! @throws Exception if column already configured for encryption ---! ---! @example ---! -- Configure email column for encryption ---! SELECT eql_v2.add_column('users', 'email', 'text'); ---! ---! -- Configure age column with integer casting ---! SELECT eql_v2.add_column('users', 'age', 'int'); ---! ---! @see eql_v2.add_search_config ---! @see eql_v2.remove_column -CREATE FUNCTION eql_v2.add_column(table_name text, column_name text, cast_as text DEFAULT 'text', migrating boolean DEFAULT false) - RETURNS jsonb - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - key text; - _config jsonb; - BEGIN - -- set the active config - SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC; - - -- set default config - SELECT eql_v2.config_default(_config) INTO _config; - - -- if index exists - IF _config #> array['tables', table_name] ? column_name THEN - RAISE EXCEPTION 'Config exists for column: % %', table_name, column_name; - END IF; - - SELECT eql_v2.config_add_table(table_name, _config) INTO _config; - - SELECT eql_v2.config_add_column(table_name, column_name, _config) INTO _config; - - SELECT eql_v2.config_add_cast(table_name, column_name, cast_as, _config) INTO _config; - - -- create a new pending record if we don't have one - INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config) - ON CONFLICT (state) - WHERE state = 'pending' - DO UPDATE - SET data = _config; - - IF NOT migrating THEN - PERFORM eql_v2.migrate_config(); - PERFORM eql_v2.activate_config(); - END IF; - - PERFORM eql_v2.add_encrypted_constraint(table_name, column_name); - - -- exeunt - RETURN _config; - END; -$$ LANGUAGE plpgsql; - ---! @brief Remove a column from encryption configuration ---! ---! Removes a column from the encryption configuration, including all associated ---! search indexes. Removes encrypted constraint, updates pending configuration, ---! then migrates and activates unless migrating flag is set. ---! ---! @param table_name Text Name of the table containing the column ---! @param column_name Text Name of the column to remove ---! @param migrating Boolean Skip auto-migration if true (default: false) ---! @return JSONB Updated configuration object ---! @throws Exception if no active or pending configuration exists ---! @throws Exception if table is not configured ---! @throws Exception if column is not configured ---! ---! @example ---! -- Remove email column from encryption ---! SELECT eql_v2.remove_column('users', 'email'); ---! ---! @see eql_v2.add_column ---! @see eql_v2.remove_search_config -CREATE FUNCTION eql_v2.remove_column(table_name text, column_name text, migrating boolean DEFAULT false) - RETURNS jsonb - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - key text; - _config jsonb; - BEGIN - -- set the active config - SELECT data INTO _config FROM public.eql_v2_configuration WHERE state = 'active' OR state = 'pending' ORDER BY state DESC; - - -- if no config - IF _config IS NULL THEN - RAISE EXCEPTION 'No active or pending configuration exists'; - END IF; - - -- if the table doesn't exist - IF NOT _config #> array['tables'] ? table_name THEN - RAISE EXCEPTION 'No configuration exists for table: %', table_name; - END IF; - - -- if the column does not exist - IF NOT _config #> array['tables', table_name] ? column_name THEN - RAISE EXCEPTION 'No configuration exists for column: % %', table_name, column_name; - END IF; - - -- create a new pending record if we don't have one - INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', _config) - ON CONFLICT (state) - WHERE state = 'pending' - DO NOTHING; - - -- remove the column - SELECT _config #- array['tables', table_name, column_name] INTO _config; - - -- if table is now empty, remove the table - IF _config #> array['tables', table_name] = '{}' THEN - SELECT _config #- array['tables', table_name] INTO _config; - END IF; - - PERFORM eql_v2.remove_encrypted_constraint(table_name, column_name); - - -- update the config (even if empty) and activate - UPDATE public.eql_v2_configuration SET data = _config WHERE state = 'pending'; - - IF NOT migrating THEN - -- For empty configs, skip migration validation and directly activate - IF _config #> array['tables'] = '{}' THEN - UPDATE public.eql_v2_configuration SET state = 'inactive' WHERE state = 'active'; - UPDATE public.eql_v2_configuration SET state = 'active' WHERE state = 'pending'; - ELSE - PERFORM eql_v2.migrate_config(); - PERFORM eql_v2.activate_config(); - END IF; - END IF; - - -- exeunt - RETURN _config; - - END; -$$ LANGUAGE plpgsql; - ---! @brief Reload configuration from CipherStash Proxy ---! ---! Placeholder function for reloading configuration from the CipherStash Proxy. ---! Currently returns NULL without side effects. ---! ---! @return Void ---! ---! @note This function may be used for configuration synchronization in future versions -CREATE FUNCTION eql_v2.reload_config() - RETURNS void -LANGUAGE sql STRICT PARALLEL SAFE -BEGIN ATOMIC - RETURN NULL; -END; - ---! @brief Query encryption configuration in tabular format ---! ---! Returns the active encryption configuration as a table for easier querying ---! and filtering. Shows all configured tables, columns, cast types, and indexes. ---! ---! @return TABLE Contains configuration state, relation name, column name, cast type, and indexes ---! ---! @example ---! -- View all encrypted columns ---! SELECT * FROM eql_v2.config(); ---! ---! -- Find all columns with match indexes ---! SELECT relation, col_name FROM eql_v2.config() ---! WHERE indexes ? 'match'; ---! ---! @see eql_v2.add_column ---! @see eql_v2.add_search_config -CREATE FUNCTION eql_v2.config() RETURNS TABLE ( - state eql_v2_configuration_state, - relation text, - col_name text, - decrypts_as text, - indexes jsonb -) - SET search_path = pg_catalog, extensions, public -AS $$ -BEGIN - RETURN QUERY - WITH tables AS ( - SELECT cfg.state, tables.key AS table, tables.value AS tbl_config - FROM public.eql_v2_configuration cfg, jsonb_each(data->'tables') tables - WHERE cfg.data->>'v' = '1' - ) - SELECT - tables.state, - tables.table, - column_config.key, - COALESCE(column_config.value->>'plaintext_type', column_config.value->>'cast_as'), - column_config.value->'indexes' - FROM tables, jsonb_each(tables.tbl_config) column_config; -END; -$$ LANGUAGE plpgsql; - ---! @file config/constraints.sql ---! @brief Configuration validation functions and constraints ---! ---! Provides CHECK constraint functions to validate encryption configuration structure. ---! Ensures configurations have required fields (version, tables) and valid values ---! for index types and cast types before being stored. ---! ---! @see config/tables.sql where constraints are applied - - ---! @brief Extract index type names from configuration ---! @internal ---! ---! Helper function that extracts all index type names from the configuration's ---! 'indexes' sections across all tables and columns. ---! ---! @param jsonb Configuration data to extract from ---! @return SETOF text Index type names (e.g., 'match', 'ore', 'unique', 'ste_vec') ---! ---! @note Used by config_check_indexes for validation ---! @see eql_v2.config_check_indexes -CREATE FUNCTION eql_v2.config_get_indexes(val jsonb) - RETURNS SETOF text - LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE -BEGIN ATOMIC - SELECT jsonb_object_keys(jsonb_path_query(val,'$.tables.*.*.indexes')); -END; - - ---! @brief Validate index types in configuration ---! @internal ---! ---! Checks that all index types specified in the configuration are valid. ---! Valid index types are: match, ore, ope, unique, ste_vec. ---! ---! @param jsonb Configuration data to validate ---! @return boolean True if all index types are valid ---! @throws Exception if any invalid index type found ---! ---! @note Used in CHECK constraint on eql_v2_configuration table ---! @see eql_v2.config_get_indexes -CREATE FUNCTION eql_v2.config_check_indexes(val jsonb) - RETURNS BOOLEAN - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - - IF (SELECT EXISTS (SELECT eql_v2.config_get_indexes(val))) THEN - IF (SELECT bool_and(index = ANY('{match, ore, ope, unique, ste_vec}')) FROM eql_v2.config_get_indexes(val) AS index) THEN - RETURN true; - END IF; - RAISE 'Configuration has an invalid index (%). Index should be one of {match, ore, ope, unique, ste_vec}', val; - END IF; - RETURN true; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Validate cast types in configuration ---! @internal ---! ---! Checks that all 'cast_as' and 'plaintext_type' types specified in the configuration are valid. ---! Valid cast types are: text, int, small_int, big_int, real, double, boolean, date, jsonb, json, float, decimal, timestamp. ---! ---! @param jsonb Configuration data to validate ---! @return boolean True if all cast types are valid or no cast types specified ---! @throws Exception if any invalid cast type found ---! ---! @note Used in CHECK constraint on eql_v2_configuration table ---! @note Empty configurations (no cast_as/plaintext_type fields) are valid ---! @note Cast type names are EQL's internal representations, not PostgreSQL native types ---! @note 'plaintext_type' is accepted as a canonical alias for 'cast_as' -CREATE FUNCTION eql_v2.config_check_cast(val jsonb) - RETURNS BOOLEAN - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - _valid_types text[] := '{text, int, small_int, big_int, real, double, boolean, date, jsonb, json, float, decimal, timestamp}'; - BEGIN - -- Validate cast_as fields - IF EXISTS (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.cast_as'))) THEN - IF NOT (SELECT bool_and(cast_as = ANY(_valid_types)) - FROM (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.cast_as')) AS cast_as) casts) THEN - RAISE 'Configuration has an invalid cast_as (%). Cast should be one of %', val, _valid_types; - END IF; - END IF; - - -- Validate plaintext_type fields (canonical alias for cast_as) - IF EXISTS (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.plaintext_type'))) THEN - IF NOT (SELECT bool_and(pt = ANY(_valid_types)) - FROM (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.plaintext_type')) AS pt) types) THEN - RAISE 'Configuration has an invalid plaintext_type (%). Type should be one of %', val, _valid_types; - END IF; - END IF; - - RETURN true; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Validate tables field presence ---! @internal ---! ---! Ensures the configuration has a 'tables' field, which is required ---! to specify which database tables contain encrypted columns. ---! ---! @param jsonb Configuration data to validate ---! @return boolean True if 'tables' field exists ---! @throws Exception if 'tables' field is missing ---! ---! @note Used in CHECK constraint on eql_v2_configuration table -CREATE FUNCTION eql_v2.config_check_tables(val jsonb) - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF (val ? 'tables') THEN - RETURN true; - END IF; - RAISE 'Configuration missing tables (tables) field: %', val; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Validate version field presence ---! @internal ---! ---! Ensures the configuration has a 'v' (version) field, which tracks ---! the configuration format version. ---! ---! @param jsonb Configuration data to validate ---! @return boolean True if 'v' field exists ---! @throws Exception if 'v' field is missing ---! ---! @note Used in CHECK constraint on eql_v2_configuration table -CREATE FUNCTION eql_v2.config_check_version(val jsonb) - RETURNS boolean - SET search_path = pg_catalog, extensions, public -AS $$ - BEGIN - IF (val ? 'v') THEN - RETURN true; - END IF; - RAISE 'Configuration missing version (v) field: %', val; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Validate ste_vec index mode option ---! @internal ---! ---! Checks that the optional \`mode\` field on \`ste_vec\` index configurations is ---! one of the recognised values. Valid modes are: standard, compat. ---! Configurations without a \`mode\` field (the default) pass unconditionally. ---! ---! @param jsonb Configuration data to validate ---! @return boolean True if every ste_vec mode is valid, or none are set ---! @throws Exception if any ste_vec.mode value is not in the allowed set ---! ---! @note Used in CHECK constraint on eql_v2_configuration table ---! @note Mode is optional — only configurations that set it are validated -CREATE FUNCTION eql_v2.config_check_ste_vec_mode(val jsonb) - RETURNS BOOLEAN - IMMUTABLE STRICT PARALLEL SAFE - SET search_path = pg_catalog, extensions, public -AS $$ - DECLARE - _valid_modes text[] := '{standard, compat}'; - BEGIN - IF EXISTS (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.indexes.ste_vec.mode'))) THEN - IF NOT (SELECT bool_and(mode = ANY(_valid_modes)) - FROM (SELECT jsonb_array_elements_text(jsonb_path_query_array(val, '$.tables.*.*.indexes.ste_vec.mode')) AS mode) modes) THEN - RAISE 'Configuration has an invalid ste_vec mode (%). Mode should be one of %', val, _valid_modes; - END IF; - END IF; - RETURN true; - END; -$$ LANGUAGE plpgsql; - - ---! @brief Drop existing data validation constraint if present ---! @note Allows constraint to be recreated during upgrades -ALTER TABLE public.eql_v2_configuration DROP CONSTRAINT IF EXISTS eql_v2_configuration_data_check; - - ---! @brief Comprehensive configuration data validation ---! ---! CHECK constraint that validates all aspects of configuration data: ---! - Version field presence ---! - Tables field presence ---! - Valid cast_as types ---! - Valid index types ---! - Valid ste_vec mode (when set) ---! ---! @note Combines all config_check_* validation functions ---! @see eql_v2.config_check_version ---! @see eql_v2.config_check_tables ---! @see eql_v2.config_check_cast ---! @see eql_v2.config_check_indexes ---! @see eql_v2.config_check_ste_vec_mode -ALTER TABLE public.eql_v2_configuration - ADD CONSTRAINT eql_v2_configuration_data_check CHECK ( - eql_v2.config_check_version(data) AND - eql_v2.config_check_tables(data) AND - eql_v2.config_check_cast(data) AND - eql_v2.config_check_indexes(data) AND - eql_v2.config_check_ste_vec_mode(data) -); - - ---! @file pin_search_path.sql ---! @brief Post-install: pin search_path on every eql_v2.* function ---! ---! This file is appended verbatim by \`tasks/build.sh\` to the end of every ---! release variant (main, supabase, protect/stack), AFTER all \`src/**/*.sql\` ---! files have been concatenated. It lives outside \`src/\` so it stays out of ---! the dependency graph entirely — each variant has a different leaf set ---! (supabase excludes \`**/*operator_class.sql\`; protect excludes \`src/config/*\` ---! and \`src/encryptindex/*\`), and threading REQUIREs to be ordered last in ---! every variant simultaneously is fragile. ---! ---! Iterates over functions in the \`eql_v2\` schema and applies a fixed ---! \`search_path\` via \`ALTER FUNCTION ... SET search_path = ...\`. This is the ---! only way to satisfy Supabase splinter's \`function_search_path_mutable\` ---! lint, which checks \`pg_proc.proconfig\` directly. ---! ---! @note A SET clause disables PostgreSQL's SQL-function inlining (see ---! inline_function() in src/backend/optimizer/util/clauses.c). For most ---! eql_v2 helpers this is irrelevant. The exceptions are wrappers that ---! must inline to expose \`eql_v2.jsonb_array(col) @> ...\` to the planner ---! so the GIN index on \`jsonb_array(e)\` can be matched. Those are ---! deliberately skipped here and allowlisted in \`tasks/test/splinter.sh\`. ---! ---! @see tasks/test/splinter.sh ---! @see tasks/build.sh - -DO $$ -DECLARE - fn_oid oid; - inline_critical_oids oid[]; - enc_oid oid; - jsonb_oid oid; - text_oid oid; - entry_oid oid; -BEGIN - -- Resolve type oids without depending on caller search_path. The encrypted - -- composite type is created in \`public\`; jsonb / text are in \`pg_catalog\`; - -- the ste_vec_entry DOMAIN lives in \`eql_v2\`. - SELECT t.oid INTO enc_oid - FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname = 'public' AND t.typname = 'eql_v2_encrypted'; - - IF enc_oid IS NULL THEN - RAISE EXCEPTION 'pin_search_path: type public.eql_v2_encrypted not found — ' - 'this script must run after all EQL src/**/*.sql files have been loaded'; - END IF; - - SELECT t.oid INTO jsonb_oid - FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname = 'pg_catalog' AND t.typname = 'jsonb'; - - IF jsonb_oid IS NULL THEN - RAISE EXCEPTION 'pin_search_path: type pg_catalog.jsonb not found'; - END IF; - - SELECT t.oid INTO text_oid - FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname = 'pg_catalog' AND t.typname = 'text'; - - IF text_oid IS NULL THEN - RAISE EXCEPTION 'pin_search_path: type pg_catalog.text not found'; - END IF; - - SELECT t.oid INTO entry_oid - FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname = 'eql_v2' AND t.typname = 'ste_vec_entry'; - - IF entry_oid IS NULL THEN - RAISE EXCEPTION 'pin_search_path: type eql_v2.ste_vec_entry not found'; - END IF; - - -- Wrappers that must remain inlinable for functional-index matching. - -- Verified empirically: with SET, EXPLAIN drops to Seq Scan; without, - -- it uses Bitmap Index Scan / Index Scan. - -- - -- Phase 1 operator inlining (#193): \`=\`, \`<>\`, \`~~\`, \`~~*\`, \`@>\`, \`<@\` - -- on \`eql_v2_encrypted\` and the cross-type (encrypted, jsonb) / - -- (jsonb, encrypted) overloads emitted by ORMs that bind parameters - -- as jsonb (Drizzle, PostgREST, encryptedSupabase). The implementation - -- functions reduce to \`extractor(a) op extractor(b)\` and must inline - -- to match the documented functional indexes - -- (\`eql_v2.hmac_256(col)\`, \`eql_v2.bloom_filter(col)\`, - -- \`eql_v2.ste_vec(col)\`). - -- - -- For \`~~\` / \`~~*\` the planner must inline two layers — the operator - -- function \`eql_v2."~~"\` and the helper \`eql_v2.like\` / \`eql_v2.ilike\` - -- — to reach the canonical \`eql_v2.bloom_filter(a) @> eql_v2.bloom_filter(b)\` - -- form that the documented functional index matches. The helpers are - -- allowlisted alongside the operator wrappers below; pinning either - -- layer breaks the chain and reverts to Seq Scan. - -- - -- Note: pg_proc.proargtypes is an oidvector with 0-based bounds, so we - -- compare elements individually rather than using array equality (which - -- requires matching bounds, not just contents). - SELECT pg_catalog.array_agg(p.oid) INTO inline_critical_oids - FROM pg_catalog.pg_proc p - JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = 'eql_v2' - AND ( - -- Same-type (encrypted, encrypted) operators that must inline. - -- \`like\`/\`ilike\` are the SQL helpers that \`~~\`/\`~~*\` delegate to; - -- both layers must inline to reach \`bloom_filter(a) @> bloom_filter(b)\`. - -- \`<\`, \`<=\`, \`>\`, \`>=\` inline to \`ore_block_u64_8_256(a) op - -- ore_block_u64_8_256(b)\`; they must reach the functional ORE index - -- expression \`eql_v2.ore_block_u64_8_256(col)\` for bare range - -- queries to engage Index Scan. - (p.pronargs = 2 - AND p.proname IN ('=', '<>', '<', '<=', '>', '>=', - '~~', '~~*', '@>', '<@', - 'jsonb_contains', 'jsonb_contained_by', - 'like', 'ilike') - AND p.proargtypes[0] = enc_oid AND p.proargtypes[1] = enc_oid) - -- Cross-type (encrypted, jsonb). - OR (p.pronargs = 2 - AND p.proname IN ('=', '<>', '<', '<=', '>', '>=', - '~~', '~~*', - 'jsonb_contains', 'jsonb_contained_by') - AND p.proargtypes[0] = enc_oid AND p.proargtypes[1] = jsonb_oid) - -- Cross-type (jsonb, encrypted). - OR (p.pronargs = 2 - AND p.proname IN ('=', '<>', '<', '<=', '>', '>=', - '~~', '~~*', - 'jsonb_contains', 'jsonb_contained_by') - AND p.proargtypes[0] = jsonb_oid AND p.proargtypes[1] = enc_oid) - -- Root-level HMAC extractor (#205): all 1-arg overloads are now - -- inlinable SQL. Must stay unpinned so the planner can fold extractor - -- calls inside the inlined equality operator bodies into the calling - -- query, preserving the functional-index match. - OR (p.pronargs = 1 - AND p.proname = 'hmac_256' - AND (p.proargtypes[0] = enc_oid OR p.proargtypes[0] = jsonb_oid)) - -- Field-level JSONB extractors (#205): inlinable SQL replacements for - -- the previous plpgsql bodies. Inlining lets the planner fold the - -- \`jsonb_array_elements(...) WHERE elem->>'s' = selector\` body into - -- the calling query, eliminating per-row function call overhead on - -- large ste_vec scans. - OR (p.pronargs = 2 - AND p.proname IN ('jsonb_path_query', - 'jsonb_path_query_first', - 'jsonb_path_exists')) - -- Inner ORE-block comparison helpers backing the \`<\`, \`<=\`, \`>\`, \`>=\` - -- operators on \`eql_v2.ore_block_u64_8_256\`. The outer operators on - -- \`eql_v2_encrypted\` inline to \`ore_block(a) ore_block(b)\`, and - -- PG only carries the inlined form through to index matching if the - -- inner operator function is also inlinable (no SET, IMMUTABLE). - -- Pinning these would prevent the planner from structurally matching - -- predicates against a functional \`eql_v2.ore_block_u64_8_256(col)\` - -- index. The inner functions are deterministic comparisons of - -- composite type bytes, declared IMMUTABLE STRICT PARALLEL SAFE. - OR (p.pronargs = 2 - AND p.proname IN ('ore_block_u64_8_256_eq', 'ore_block_u64_8_256_neq', - 'ore_block_u64_8_256_lt', 'ore_block_u64_8_256_lte', - 'ore_block_u64_8_256_gt', 'ore_block_u64_8_256_gte')) - -- Hash operator class FUNCTION 1: called once per row by HashAggregate, - -- hash joins, DISTINCT. Inlinable SQL avoids the per-row plpgsql - -- interpreter overhead — without this, \`GROUP BY value\` on - -- \`eql_v2_encrypted\` at 1M rows degrades super-linearly because the - -- plpgsql cost compounds with HashAggregate work_mem spillage. - OR (p.pronargs = 1 - AND p.proname = 'hash_encrypted' - AND p.proargtypes[0] = enc_oid) - -- Consolidated ORE-CLLW extractor (U-006). Inlinable SQL — pinning - -- would silently undo it and prevent the planner from folding - -- \`eql_v2.ore_cllw(col)\` calls into the calling query. The - -- \`compare_ore_cllw_term\` comparator stays plpgsql by design (per-byte - -- protocol can't be expressed as a single inlinable SELECT), so it is - -- NOT on this list. The (jsonb) form is a RHS-parameter helper for - -- comparisons against literal jsonb; the (eql_v2.ste_vec_entry) form - -- is the typed extractor for the result of \`col -> ''\`. - OR (p.pronargs = 1 - AND p.proname IN ('ore_cllw', 'has_ore_cllw') - AND (p.proargtypes[0] = jsonb_oid OR p.proargtypes[0] = entry_oid)) - -- Typed HMAC extractor on a ste_vec entry (#219 strict separation). - -- Same rationale as \`ore_cllw(ste_vec_entry)\` — must inline so - -- \`eql_v2.hmac_256(col -> 'sel')\` folds into the calling query and - -- matches a functional hash index built on the same expression. - OR (p.pronargs = 1 - AND p.proname IN ('hmac_256', 'has_hmac_256', 'selector') - AND p.proargtypes[0] = entry_oid) - -- \`eql_v2.ste_vec_entry × eql_v2.ste_vec_entry\` operators (#219). - -- Inline to \`hmac_256(a) = hmac_256(b)\` (equality) or - -- \`ore_cllw(a) ore_cllw(b)\` (ordering); both chains must remain - -- unpinned for functional-index match through extractor form. - OR (p.pronargs = 2 - AND p.proname IN ('=', '<>', '<', '<=', '>', '>=', - 'eq', 'neq', 'lt', 'lte', 'gt', 'gte') - AND p.proargtypes[0] = entry_oid AND p.proargtypes[1] = entry_oid) - -- Inner ORE-CLLW comparison helpers backing the \`<\`, \`<=\`, \`=\`, - -- \`>=\`, \`>\`, \`<>\` operators on \`eql_v2.ore_cllw\` (the composite - -- type, registered via \`eql_v2.ore_cllw_ops\` opclass — #221). Same - -- precedent as the \`ore_block_u64_8_256_*\` helpers above: PG only - -- carries the inlined operator wrapper through to functional-index - -- match if the inner backing function is also inlinable. Pinning - -- these would break the index match for \`ORDER BY eql_v2.ore_cllw - -- (value -> ''::text)\` and the matching \`WHERE\` form. - OR (p.pronargs = 2 - AND p.proname IN ('ore_cllw_eq', 'ore_cllw_neq', - 'ore_cllw_lt', 'ore_cllw_lte', - 'ore_cllw_gt', 'ore_cllw_gte')) - -- \`->\` selector lookup: inlinable SQL post the type flip - -- (returns \`eql_v2.ste_vec_entry\`). Must stay unpinned so the - -- planner can fold \`col -> ''\` into the calling query - -- — without this, the chained recipe - -- \`WHERE col -> 'sel' = $1::ste_vec_entry\` would not match a - -- functional hash index on \`eql_v2.eq_term(col -> 'sel')\`. - OR (p.proname = '->' - AND p.pronargs = 2 - AND p.proargtypes[0] = enc_oid - AND (p.proargtypes[1] = text_oid - OR p.proargtypes[1] = enc_oid - OR p.proargtypes[1] = (SELECT t.oid FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname = 'pg_catalog' AND t.typname = 'int4'))) - -- XOR-aware equality term extractor on a ste_vec entry. Must - -- inline so \`eql_v2.eq_term(col -> 'sel')\` folds into the - -- calling query and matches a functional hash index built on - -- the same expression. - OR (p.pronargs = 1 - AND p.proname = 'eq_term' - AND p.proargtypes[0] = entry_oid) - -- Type-safe \`@>\` / \`<@\` overloads with typed needles - -- (\`stevec_query\`, \`ste_vec_entry\`). Inline to the existing - -- \`ste_vec_contains\` machinery — must stay unpinned to engage - -- the GIN index on \`eql_v2.ste_vec(col)\` structurally for - -- bare-form containment. - OR (p.pronargs = 2 - AND p.proname IN ('@>', '<@') - AND p.proargtypes[0] = enc_oid - AND (p.proargtypes[1] = entry_oid - OR p.proargtypes[1] = (SELECT t.oid FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname = 'eql_v2' AND t.typname = 'stevec_query'))) - OR (p.pronargs = 2 - AND p.proname IN ('@>', '<@') - AND p.proargtypes[1] = enc_oid - AND (p.proargtypes[0] = entry_oid - OR p.proargtypes[0] = (SELECT t.oid FROM pg_catalog.pg_type t - JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname = 'eql_v2' AND t.typname = 'stevec_query'))) - ); - - FOR fn_oid IN - SELECT p.oid - FROM pg_catalog.pg_proc p - JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = 'eql_v2' - -- Only normal functions ('f') and window functions ('w') accept - -- ALTER FUNCTION ... SET. Aggregates ('a') would be rejected by - -- ALTER ROUTINE/FUNCTION, and procedures ('p') would need ALTER - -- PROCEDURE. The 3 affected aggregates (min, max, grouped_value) - -- are allowlisted in splinter. - AND p.prokind IN ('f', 'w') - AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.unnest(coalesce(p.proconfig, '{}'::text[])) c - WHERE c LIKE 'search_path=%' - ) - AND NOT (p.oid = ANY (coalesce(inline_critical_oids, '{}'::oid[]))) - LOOP - -- oid::regprocedure renders as \`schema.name(argtype, argtype)\` and is a - -- valid target for ALTER FUNCTION regardless of caller search_path. - EXECUTE pg_catalog.format( - 'ALTER FUNCTION %s SET search_path = pg_catalog, extensions, public', - fn_oid::regprocedure - ); - END LOOP; -END $$; -` diff --git a/packages/prisma-next/src/stack/derive-schemas.ts b/packages/prisma-next/src/stack/derive-schemas.ts deleted file mode 100644 index 785a05c40..000000000 --- a/packages/prisma-next/src/stack/derive-schemas.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Derive `@cipherstash/stack` encryption schemas from a Prisma Next - * contract. - * - * `contract.json` already declares every encrypted column the - * 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 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 - * footgun where the SDK's index set silently disagrees with the EQL - * bundle's installed configuration. - */ - -import { - type EncryptedColumn, - type EncryptedTable, - type EncryptedTableColumn, - encryptedColumn, - encryptedTable, -} from '@cipherstash/stack/schema' - -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, - type CipherstashCodecId, - isCipherstashCodecId, -} from '../extension-metadata/constants' - -/** - * Structural shape of the subset of `contract.json` this derivation - * reads. Declared structurally (vs importing the framework type) so - * the derivation has no `@prisma-next/*` import-edge and is callable - * against a raw JSON-typed import. - */ -export interface ContractStorageView { - readonly storage?: { - readonly namespaces?: Readonly< - Record< - string, - { - readonly entries?: { - readonly table?: Readonly> - } - } - > - > - } -} - -interface StorageTableView { - readonly columns?: Readonly> -} - -interface StorageColumnView { - readonly codecId?: string | null - readonly typeParams?: Readonly> | null -} - -type DataType = 'string' | 'number' | 'bigint' | 'date' | 'boolean' | 'json' - -const CODEC_ID_TO_DATA_TYPE: Readonly> = { - [CIPHERSTASH_STRING_CODEC_ID]: 'string', - [CIPHERSTASH_DOUBLE_CODEC_ID]: 'number', - [CIPHERSTASH_BIGINT_CODEC_ID]: 'bigint', - [CIPHERSTASH_DATE_CODEC_ID]: 'date', - [CIPHERSTASH_BOOLEAN_CODEC_ID]: 'boolean', - [CIPHERSTASH_JSON_CODEC_ID]: 'json', -} - -// Single source of truth for the cipherstash typeParams flag set: the -// keys drive both the dispatch table in `applyTypeParams` and the -// "Known flags" line in its error message. -const FLAG_DISPATCH = { - equality: (b: EncryptedColumn) => b.equality(), - freeTextSearch: (b: EncryptedColumn) => b.freeTextSearch(), - orderAndRange: (b: EncryptedColumn) => b.orderAndRange(), - searchableJson: (b: EncryptedColumn) => b.searchableJson(), -} as const - -type CipherstashFlag = keyof typeof FLAG_DISPATCH - -/** - * Derive an array of `EncryptedTable` builders from a Prisma Next - * contract. Returns an empty array when no cipherstash columns are - * present; callers must still pass at least one `EncryptedTable` to - * `Encryption({ schemas })`, which requires a non-empty array. - */ -export function deriveStackSchemas( - contractJson: ContractStorageView, -): 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: EncryptedTable[] = [] - - for (const [tableName, table] of Object.entries(tables)) { - const columns = table.columns - if (!columns) continue - - const builders: Record = {} - for (const [columnName, column] of Object.entries(columns)) { - const codecId = column.codecId - if (codecId == null || !isCipherstashCodecId(codecId)) continue - - const dataType = CODEC_ID_TO_DATA_TYPE[codecId] - builders[columnName] = applyTypeParams( - encryptedColumn(columnName).dataType(dataType), - column.typeParams ?? {}, - tableName, - columnName, - ) - } - - if (Object.keys(builders).length === 0) continue - result.push(encryptedTable(tableName, builders)) - } - - return result -} - -function applyTypeParams( - builder: EncryptedColumn, - typeParams: Readonly>, - tableName: string, - columnName: string, -): EncryptedColumn { - let result = builder - for (const [flag, value] of Object.entries(typeParams)) { - if (value !== true) continue - if (!isCipherstashFlag(flag)) { - throw new Error( - `deriveStackSchemas: unrecognised cipherstash typeParams flag "${flag}" ` + - `on column "${tableName}"."${columnName}". ` + - `Known flags: ${Object.keys(FLAG_DISPATCH).join(', ')}.`, - ) - } - result = FLAG_DISPATCH[flag](result) - } - return result -} - -function isCipherstashFlag(value: string): value is CipherstashFlag { - return value in FLAG_DISPATCH -} diff --git a/packages/prisma-next/src/stack/from-stack-v3.ts b/packages/prisma-next/src/stack/from-stack-v3.ts index 56b128819..28ab70452 100644 --- a/packages/prisma-next/src/stack/from-stack-v3.ts +++ b/packages/prisma-next/src/stack/from-stack-v3.ts @@ -1,9 +1,7 @@ /** * One-call setup for `@cipherstash/prisma-next` against the - * `@cipherstash/stack` EQL v3 client — the v3-only sibling of - * `./from-stack.ts` (decision 1b: v2 and v3 are SEPARATE entry points - * that are never co-registered in one client; the v2 - * `cipherstashFromStackV2` is untouched by this module). + * `@cipherstash/stack` EQL v3 client. `@cipherstash/prisma-next` is EQL + * v3 only; this is the sole `cipherstashFromStack` entry point. * * const cipherstash = await cipherstashFromStack({ contractJson }) * @@ -80,8 +78,8 @@ export async function cipherstashFromStack( if (foreignIds.length > 0) { throw new Error( `cipherstashFromStack: contract.json contains non-v3 cipherstash codec ids [${foreignIds.join(', ')}]. ` + - 'A v3 client is v3-only; use cipherstashFromStackV2 for a v2 contract. ' + - 'Mixed v2+v3 cipherstash columns in one client are unsupported.', + '`@cipherstash/prisma-next` is EQL v3 only — author columns with the v3 ' + + '`cipherstash.*()` constructors and re-emit the contract (`prisma-next contract emit`).', ) } @@ -90,7 +88,7 @@ export async function cipherstashFromStack( throw new Error( 'cipherstashFromStack: no v3 cipherstash columns found in contract.json. ' + 'Declare at least one v3 `cipherstash.*()` column (e.g. `cipherstash.TextSearch()`) in prisma/schema.prisma ' + - 'and re-emit the contract (or use cipherstashFromStackV2 if this is a v2 contract).', + 'and re-emit the contract (`prisma-next contract emit`).', ) } diff --git a/packages/prisma-next/src/stack/from-stack.ts b/packages/prisma-next/src/stack/from-stack.ts deleted file mode 100644 index f7328fc2f..000000000 --- a/packages/prisma-next/src/stack/from-stack.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * One-call setup for `@cipherstash/prisma-next` against - * `@cipherstash/stack`. - * - * Replaces the manual three-step wiring (derive schemas → construct - * `Encryption({ schemas })` → build `CipherstashSdk` adapter → wrap - * with `createCipherstashRuntimeDescriptor` and `bulkEncryptMiddleware`) - * with a single async factory that returns ready-to-spread arrays for - * `postgres({...})`: - * - * const cipherstash = await cipherstashFromStackV2({ contractJson }) - * - * const db = postgres({ - * contractJson, - * extensions: cipherstash.extensions, - * middleware: cipherstash.middleware, - * }) - * - * Override semantics: a user-supplied `schemas` array is allowed to - * add tables the contract doesn't model. For tables the contract - * **does** declare, the override must agree on column names, - * `cast_as`, and the installed index set — divergence throws at - * setup so ZeroKMS can't end up with an index set that the EQL - * bundle's installed configuration disagrees with. - * - * This is the **EQL v2** entry point. For an EQL v3 contract - * (`cipherstash/eql-v3/*` codec ids) use `cipherstashFromStack` - * (`./from-stack-v3.ts`) — v2 and v3 are separate entry points that - * are never co-registered in one client (decision 1b). - */ - -import { Encryption } from '@cipherstash/stack' -import type { EncryptionClient } from '@cipherstash/stack/client' -import { - type CastAs, - type EncryptedTable, - type EncryptedTableColumn, - toEqlCastAs, -} from '@cipherstash/stack/schema' -import type { - SqlMiddleware, - SqlRuntimeExtensionDescriptor, -} from '@prisma-next/sql-runtime' - -import { createCipherstashRuntimeDescriptor } from '../exports/runtime' -import { bulkEncryptMiddleware } from '../middleware/bulk-encrypt' -import { type ContractStorageView, deriveStackSchemas } from './derive-schemas' -import { createCipherstashSdk } from './sdk-adapter' - -export interface CipherstashFromStackOptions { - /** The contract.json artefact emitted by `prisma-next contract emit`. */ - readonly contractJson: ContractStorageView - - /** - * Optional schema override. Use this to add tables the contract - * does not model. For tables the contract **does** declare, the - * override must match on column names, `cast_as`, and installed - * indices — divergence throws at setup. - */ - readonly schemas?: ReadonlyArray> - - /** Pass-through to `Encryption({ config })` (keyset overrides, logging, …). */ - readonly encryptionConfig?: Parameters[0]['config'] -} - -export interface CipherstashFromStackResult { - /** Ready to spread into `postgres({ extensions })`. */ - readonly extensions: ReadonlyArray> - /** Ready to spread into `postgres({ middleware })`. */ - readonly middleware: ReadonlyArray - /** The initialised `EncryptionClient` for direct SDK access outside the ORM path. */ - readonly encryptionClient: EncryptionClient -} - -export async function cipherstashFromStackV2( - opts: CipherstashFromStackOptions, -): Promise { - const derived = deriveStackSchemas(opts.contractJson) - const schemas = resolveSchemas(derived, opts.schemas) - const [first, ...rest] = schemas - if (first === undefined) { - throw new Error( - 'cipherstashFromStackV2: no cipherstash columns found in contract.json AND no override `schemas` supplied. ' + - "`@cipherstash/stack`'s `Encryption({ schemas })` requires at least one `EncryptedTable`. " + - 'Check that prisma/schema.prisma declares at least one `cipherstash.Encrypted*V2()` column and that ' + - '`pnpm emit` has been run since the last edit.', - ) - } - - const encryptionClient = await Encryption({ - schemas: [first, ...rest], - ...(opts.encryptionConfig !== undefined - ? { config: opts.encryptionConfig } - : {}), - }) - - const sdk = createCipherstashSdk(encryptionClient, schemas) - - return { - extensions: [createCipherstashRuntimeDescriptor({ sdk })], - middleware: [bulkEncryptMiddleware(sdk)], - encryptionClient, - } -} - -function resolveSchemas( - derived: ReadonlyArray>, - override: ReadonlyArray> | undefined, -): ReadonlyArray> { - if (override === undefined || override.length === 0) return derived - - const derivedByName = new Map(derived.map((t) => [t.tableName, t])) - const overrideByName = new Map(override.map((t) => [t.tableName, t])) - - for (const [tableName, derivedTable] of derivedByName) { - const overrideTable = overrideByName.get(tableName) - if (overrideTable === undefined) continue - assertSchemasAgree(derivedTable, overrideTable) - } - - return [ - ...derived, - ...override.filter((t) => !derivedByName.has(t.tableName)), - ] -} - -function assertSchemasAgree( - derived: EncryptedTable, - user: EncryptedTable, -): void { - const derivedDef = derived.build() - const userDef = user.build() - - const derivedCols = new Set(Object.keys(derivedDef.columns)) - const userCols = new Set(Object.keys(userDef.columns)) - - const missingInUser = [...derivedCols].filter((c) => !userCols.has(c)).sort() - const extraInUser = [...userCols].filter((c) => !derivedCols.has(c)).sort() - - if (missingInUser.length > 0 || extraInUser.length > 0) { - const parts: string[] = [] - if (missingInUser.length > 0) - parts.push(`missing in override: [${missingInUser.join(', ')}]`) - if (extraInUser.length > 0) - parts.push(`extra in override: [${extraInUser.join(', ')}]`) - divergence( - `table "${derived.tableName}"`, - `declares columns [${[...derivedCols].sort().join(', ')}]`, - `declares [${[...userCols].sort().join(', ')}] (${parts.join('; ')})`, - 'Override `schemas` must match the contract on every contract-declared table; use it only to add tables the contract does not model.', - ) - } - - for (const colName of derivedCols) { - const d = derivedDef.columns[colName]! - const u = userDef.columns[colName]! - - // Normalise through `toEqlCastAs` so SDK-facing aliases agree — - // `dataType('string')` and `dataType('text')` both lower to EQL `'text'`. - const dCast = toEqlCastAs(d.cast_as as CastAs) - const uCast = toEqlCastAs(u.cast_as as CastAs) - if (dCast !== uCast) { - divergence( - `column "${derived.tableName}"."${colName}"`, - `declares cast_as="${d.cast_as}"`, - `declares cast_as="${u.cast_as}" (EQL cast_as "${dCast}" vs "${uCast}")`, - 'Fix prisma/schema.prisma and re-emit the contract rather than overriding.', - ) - } - - const derivedIndexes = indexKeys(d.indexes) - const userIndexes = indexKeys(u.indexes) - if (!setsEqual(derivedIndexes, userIndexes)) { - divergence( - `column "${derived.tableName}"."${colName}"`, - `installs indexes [${[...derivedIndexes].sort().join(', ') || '(none)'}]`, - `installs [${[...userIndexes].sort().join(', ') || '(none)'}]`, - 'Fix prisma/schema.prisma and re-emit the contract rather than overriding.', - ) - } - } -} - -function divergence( - loc: string, - contractSide: string, - overrideSide: string, - hint: string, -): never { - throw new Error( - `cipherstashFromStackV2: schema divergence on ${loc}. Contract ${contractSide} but override ${overrideSide}. ${hint}`, - ) -} - -function indexKeys(indexes: Record): Set { - return new Set(Object.keys(indexes).filter((k) => indexes[k] !== undefined)) -} - -function setsEqual(a: Set, b: Set): boolean { - if (a.size !== b.size) return false - for (const v of a) if (!b.has(v)) return false - return true -} diff --git a/packages/prisma-next/src/stack/sdk-adapter.ts b/packages/prisma-next/src/stack/sdk-adapter.ts deleted file mode 100644 index 4cbb48575..000000000 --- a/packages/prisma-next/src/stack/sdk-adapter.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Adapt `@cipherstash/stack`'s `EncryptionClient` to the - * framework-native `CipherstashSdk` shape consumed by - * `createCipherstashRuntimeDescriptor({ sdk })` and - * `bulkEncryptMiddleware(sdk)`. - * - * The framework calls into the SDK with `(table, column)` routing-key - * strings — it doesn't know about stack's typed `EncryptedTable` / - * `EncryptedColumn` objects. The adapter resolves those strings back - * to the typed objects via a registry built from the supplied - * schemas. The registry is keyed on the **physical** column name - * (post-`@map`), matching how the framework's bulk-encrypt middleware - * derives routing keys from the lowered AST. - * - * Plaintext coercion at the boundary: - * - * - `bigint → number` (ZeroKMS's `big_int` cast accepts numeric - * plaintexts only; values must fit in the JS safe-integer range; - * overflow throws eagerly). - * - `Date → ISO 8601 string` (both ZeroKMS and the EQL bundle accept - * the textual form). - * - * Every other JS value type is passed through to the stack SDK as-is; - * the stack SDK then validates against its declared per-column - * `dataType()` on each `bulkEncrypt` call. - */ - -import { type Encrypted, isEncryptedPayload } from '@cipherstash/stack' -import type { EncryptionClient } from '@cipherstash/stack/client' -import { - EncryptedColumn, - EncryptedField, - type EncryptedTable, - type EncryptedTableColumn, -} from '@cipherstash/stack/schema' - -import type { CipherstashRoutingKey, CipherstashSdk } from '../execution/sdk' - -// `JsPlaintext` is the input type `@cipherstash/stack`'s `bulkEncrypt` -// accepts for non-bigint, non-Date values. Redeclared locally because -// `@cipherstash/stack` does not re-export it through its public surface. -type JsPlaintext = - | string - | number - | boolean - | { [key: string]: unknown } - | JsPlaintext[] - -type StackColumn = EncryptedColumn | EncryptedField - -interface RegistryEntry { - readonly table: EncryptedTable - readonly columns: Readonly> -} - -/** - * Build a `CipherstashSdk` from an initialised `@cipherstash/stack` - * `EncryptionClient` plus the schemas it was constructed with. - * - * `schemas` should be the exact `EncryptedTable[]` array passed to - * `Encryption({ schemas })` (or its return value from - * {@link deriveStackSchemas}). The adapter uses it to translate - * framework `(table, column)` routing-key strings back to the typed - * schema objects the stack SDK's `bulkEncrypt(...)` expects in - * `{ column, table }`. - */ -export function createCipherstashSdk( - encryptionClient: EncryptionClient, - schemas: ReadonlyArray>, -): CipherstashSdk { - const registry = buildRegistry(schemas) - - return { - async bulkEncrypt({ values, routingKey }) { - const { table, column } = lookup(registry, routingKey) - const result = await encryptionClient.bulkEncrypt( - values.map((plaintext) => ({ plaintext: toJsPlaintext(plaintext) })), - { column, table }, - ) - return unwrap(result, 'bulkEncrypt').map((entry) => entry.data) - }, - - async bulkDecrypt({ ciphertexts }) { - const payload = ciphertexts.map((data, index) => ({ - data: ensureEncryptedEnvelope(data, 'bulkDecrypt', index), - })) - const result = await encryptionClient.bulkDecrypt(payload) - return unwrap(result, 'bulkDecrypt').map(unwrapBulkDecryptEntry) - }, - - async decrypt({ ciphertext }) { - const result = await encryptionClient.decrypt( - ensureEncryptedEnvelope(ciphertext, 'decrypt'), - ) - return asSdkPlaintext(unwrap(result, 'decrypt')) - }, - } -} - -// Mirrors `@byteslice/result`'s discriminated `Result` shape -// (which `@cipherstash/stack` returns) without forcing the package to -// depend on `@byteslice/result` directly. -type StackResult = - | { readonly failure?: never; readonly data: T } - | { readonly failure: { readonly message: string } } - -function unwrap(result: StackResult, op: string): T { - if (result.failure) { - throw new Error(`cipherstash ${op} failed: ${result.failure.message}`) - } - return result.data -} - -function unwrapBulkDecryptEntry(entry: { - data?: unknown - error?: unknown -}): string { - if ('error' in entry && entry.error !== undefined) { - throw new Error( - `cipherstash bulkDecrypt entry failed: ${String(entry.error)}`, - ) - } - return asSdkPlaintext(entry.data) -} - -function buildRegistry( - schemas: ReadonlyArray>, -): Map { - const registry = new Map() - for (const table of schemas) { - const columns: Record = {} - // `encryptedTable(name, builders)` intersects each column builder - // onto the table instance under its own key, so iterating own - // enumerable properties recovers them. - for (const [key, value] of Object.entries(table)) { - if (value instanceof EncryptedColumn || value instanceof EncryptedField) { - columns[key] = value - } - } - registry.set(table.tableName, { table, columns }) - } - return registry -} - -function lookup( - registry: Map, - routingKey: CipherstashRoutingKey, -): { table: EncryptedTable; column: StackColumn } { - const entry = registry.get(routingKey.table) - if (entry === undefined) { - throw new Error( - `cipherstash SDK adapter: routing-key table "${routingKey.table}" is not in the stack schemas. ` + - 'If you derived your schemas with `deriveStackSchemas(contractJson)`, this means the contract has no ' + - 'cipherstash columns on that table — check `prisma/schema.prisma` and re-emit the contract.', - ) - } - const column = entry.columns[routingKey.column] - if (column === undefined) { - throw new Error( - `cipherstash SDK adapter: routing-key column "${routingKey.column}" is not on stack table "${routingKey.table}". ` + - 'Routing keys use physical column names (post-`@map`). Check `prisma/schema.prisma` and re-emit the contract.', - ) - } - return { table: entry.table, column } -} - -function ensureEncryptedEnvelope( - value: unknown, - kind: 'decrypt' | 'bulkDecrypt', - index?: number, -): Encrypted { - // `isEncryptedPayload` checks the v1/v2 envelope basics (object, - // numeric `v`, `i` object, `c` or `sv` present). We additionally - // require the EQL v2 `i: { t, c }` substructure because that's - // what the framework's bulk-decrypt path expects. - const valid = - isEncryptedPayload(value) && - typeof (value as { i: { t?: unknown; c?: unknown } }).i === 'object' && - 't' in (value as { i: object }).i && - 'c' in (value as { i: object }).i - if (!valid) { - const where = index === undefined ? '' : ` at index ${index}` - throw new Error( - `cipherstash ${kind}: ciphertext${where} is not a valid EQL v2 envelope ` + - '(expected an object with `i: { t, c }`, numeric `v`, and `c` or `sv`).', - ) - } - return value -} - -function toJsPlaintext(value: unknown): JsPlaintext { - if (typeof value === 'bigint') { - // ZeroKMS's `big_int` cast accepts only numeric plaintexts. Throw - // eagerly on overflow rather than truncating silently on the wire. - if ( - value > BigInt(Number.MAX_SAFE_INTEGER) || - value < BigInt(Number.MIN_SAFE_INTEGER) - ) { - throw new Error( - `cipherstash bigint plaintext ${value} exceeds Number.MAX_SAFE_INTEGER; ` + - 'ZeroKMS does not accept string plaintexts for the big_int cast type.', - ) - } - return Number(value) - } - if (value instanceof Date) return value.toISOString() - return value as JsPlaintext -} - -// The framework's `CipherstashSdk.decrypt` is typed `Promise` -// but every envelope's `parseDecryptedValue` hook narrows the raw value -// to its concrete plaintext type. Forwarding through this cast keeps the -// codec-side polymorphism intact across the SDK contract. -function asSdkPlaintext(value: unknown): string { - return value as string -} diff --git a/packages/prisma-next/src/types/codec-types.ts b/packages/prisma-next/src/types/codec-types.ts index d20437a4d..9f54847da 100644 --- a/packages/prisma-next/src/types/codec-types.ts +++ b/packages/prisma-next/src/types/codec-types.ts @@ -4,9 +4,9 @@ * Type-only definitions for codec input/output/traits — consumed by * the contract emitter when generating an application's * `contract.d.ts`. Importing this subpath registers every cipherstash - * codec id with its `cipherstash:*` traits, so trait-dispatched - * operators (`cipherstashGt`, `cipherstashBetween`, - * `cipherstashInArray`, …) surface on real model accessors. + * v3 codec id with its `cipherstash:v3-*` markers, so trait-dispatched + * operators (`eqlGt`, `eqlBetween`, `eqlIn`, …) surface on real model + * accessors. * * # Why this is hand-written, not derived via `ExtractCodecTypes` * @@ -39,8 +39,7 @@ * (and the ORM read path returns an envelope the user calls * `.decrypt()` on); `input` is the union of the envelope class and * the bare plaintext, mirroring the polymorphic argument shapes the - * predicate operators accept (`coerceToEnvelope` in - * `src/execution/operators.ts`). + * `eql*` predicate operators accept (see `src/v3/operators-v3.ts`). */ // Type-only imports — the codec-types subpath compiles to an empty @@ -50,7 +49,6 @@ import type { EncryptedBigInt } from '../execution/envelope-bigint' import type { EncryptedBoolean } from '../execution/envelope-boolean' import type { EncryptedDate } from '../execution/envelope-date' -import type { EncryptedDouble } from '../execution/envelope-double' import type { EncryptedJson } from '../execution/envelope-json' import type { EncryptedString } from '../execution/envelope-string' import type { EncryptedNumber } from '../v3/envelope-number' @@ -94,43 +92,6 @@ interface V3Codec { } export type CodecTypes = { - readonly 'cipherstash/string@1': { - readonly input: string | EncryptedString - readonly output: EncryptedString - readonly traits: - | 'cipherstash:equality' - | 'cipherstash:free-text-search' - | 'cipherstash:order-and-range' - } - readonly 'cipherstash/double@1': { - readonly input: number | EncryptedDouble - readonly output: EncryptedDouble - readonly traits: 'cipherstash:equality' | 'cipherstash:order-and-range' - } - readonly 'cipherstash/bigint@1': { - readonly input: bigint | EncryptedBigInt - readonly output: EncryptedBigInt - readonly traits: 'cipherstash:equality' | 'cipherstash:order-and-range' - } - readonly 'cipherstash/date@1': { - readonly input: Date | EncryptedDate - readonly output: EncryptedDate - readonly traits: 'cipherstash:equality' | 'cipherstash:order-and-range' - } - readonly 'cipherstash/boolean@1': { - readonly input: boolean | EncryptedBoolean - readonly output: EncryptedBoolean - readonly traits: 'cipherstash:equality' - } - readonly 'cipherstash/json@1': { - // `unknown` already subsumes `EncryptedJson`, but the alias is kept in - // scope (via the import above) so the codec entry still flags JSON as - // an envelope-bearing codec at the type-import layer. - readonly input: unknown - readonly output: EncryptedJson - readonly traits: 'cipherstash:searchable-json' - } - // ------------------------------------------------------------------------- // EQL v3 — one entry per catalog domain (all 40, including the // authoring-unexposed `*_ord_ore` variants the codec layer still @@ -334,8 +295,10 @@ export type CodecTypes = { EncryptedNumber, V3TraitsOrd > - // `unknown` subsumes `EncryptedJson` on the input side — same note - // as the v2 JSON entry above. + // `unknown` subsumes `EncryptedJson` on the input side, but the + // alias is kept in scope (via the import above) so the codec entry + // still flags JSON as an envelope-bearing codec at the type-import + // layer. readonly 'cipherstash/eql-v3/eql_v3_json@1': { readonly input: unknown readonly output: EncryptedJson diff --git a/packages/prisma-next/src/types/operation-types.ts b/packages/prisma-next/src/types/operation-types.ts index e8cc77ae6..5226e705d 100644 --- a/packages/prisma-next/src/types/operation-types.ts +++ b/packages/prisma-next/src/types/operation-types.ts @@ -1,96 +1,55 @@ /** - * Operation type definitions for the cipherstash extension. + * Operation type definitions for the cipherstash extension (EQL v3). * - * Mirrors `packages/3-extensions/pgvector/src/types/operation-types.ts` - * — the type-only counterpart to `cipherstashQueryOperations()` in - * `../execution/operators.ts`. Every entry's `self` dispatch shape - * mirrors the runtime registration 1:1: + * The type-only counterpart to `cipherstashV3QueryOperations()` in + * `../v3/operators-v3.ts`. Every entry's `self` dispatch shape mirrors + * the runtime registration 1:1: each `eql*` operator declares + * `self: { traits: ['cipherstash:v3-*'] }`, and the framework's + * `OpMatchesField` trait dispatch surfaces the method on every column + * whose codec id resolves to a `CodecTypes` entry whose `traits` set + * includes the same marker. The cipherstash-namespaced prefix isolates + * these from the framework's closed `CodecTrait` union so adding the + * trait to a cipherstash codec descriptor cannot silently re-attach a + * framework built-in. * - * - Single-codec entries (`cipherstashEq`, `cipherstashIlike`, - * `cipherstashNotIlike`, `cipherstashJsonbPathExists`) declare - * `self: { codecId: '' }` (v2 legacy registrations). The framework's `OpMatchesField` - * direct-codec-id branch surfaces the method on columns whose - * codec id is the literal — no consumer-side `CodecTypes` - * augmentation needed. - * - * - Multi-codec entries (the equality / order-and-range operators) - * declare `self: { traits: ['cipherstash:'] }`. Trait dispatch - * surfaces the method on every column whose codec id resolves to - * a `CodecTypes` entry whose `traits` set includes the same - * identifier. The cipherstash-namespaced `cipherstash:` prefix - * isolates these from the framework's closed `CodecTrait` union - * so adding the trait to a cipherstash codec descriptor cannot - * silently re-attach a framework built-in. - * - * Both surfaces (codec-keyed `OperationTypes` and flat - * `QueryOperationTypes`) get composed into the consuming - * application's generated `contract.d.ts` by the contract emitter, - * via the `types.queryOperationTypes` import declaration on + * The flat `QueryOperationTypes` surface gets composed into the + * consuming application's generated `contract.d.ts` by the contract + * emitter, via the `types.queryOperationTypes` import declaration on * `cipherstashPackMeta` (`../extension-metadata/descriptor-meta.ts`). * - * Return-codec id is `pg/bool@1` for every predicate operator — - * pinned to what the runtime impl builds (`../execution/operators.ts` - * `PG_BOOL_CODEC_ID`). The framework's predicate machinery looks at - * the return codec's `'boolean'` trait to decide a value is suitable - * for a WHERE clause. + * Return-codec id is `pg/bool@1` for every predicate operator — pinned + * to what the runtime impl builds. The framework's predicate machinery + * looks at the return codec's `'boolean'` trait to decide a value is + * suitable for a WHERE clause. */ -import type { - CodecExpression, - Expression, -} from '@prisma-next/sql-relational-core/expression' +import type { Expression } from '@prisma-next/sql-relational-core/expression' type CodecTypesBase = Record< string, { readonly input: unknown; readonly output: unknown } > -const CIPHERSTASH_STRING_CODEC = 'cipherstash/string@1' -type CipherstashStringCodec = typeof CIPHERSTASH_STRING_CODEC - type PgBoolReturn = Expression<{ codecId: 'pg/bool@1'; nullable: false }> /** - * Trait tuples used to gate multi-codec operators (see ADR 214). + * v3 TYPE-LEVEL marker traits (no runtime counterpart) — carried only + * by the v3 codec entries in `codec-types.ts` (see the vocabulary + * comment there). Every `eql*` operator dispatches on a v3 marker, so + * type-level visibility stays exactly aligned with what each column's + * runtime can actually execute. * - * Cipherstash uses extension-namespaced trait identifiers - * (`cipherstash:equality`, `cipherstash:order-and-range`) that - * intentionally live outside the framework's closed `CodecTrait` - * union. Preserving the literal trait strings at the type level is + * Preserving the literal trait strings at the type level is * load-bearing: the consuming `OpMatchesField` predicate (in * `packages/3-extensions/sql-orm-client/src/types.ts`) reads - * `Self.traits` and tests - * `[traits[number]] extends [CT[CodecId]['traits']]`, so widening to - * the framework's closed `CodecTrait` union (or to `never[]` via - * intersection) erases the extension's dispatch information and - * collapses every codec into a trait match. - * - * The framework's `QueryOperationSelfSpec` types `traits` as - * `readonly CodecTrait[]`; cipherstash's `QueryOperationTypes` - * therefore declares its entries directly (rather than via the - * `SqlQueryOperationTypes` wrapper that constrains - * `T extends Record`) so the - * literal trait strings flow through untouched. The consumer-side - * pipeline (`ExtractQueryOperationTypes` -> `OpMatchesField`) walks - * the entries structurally and accepts any `traits` shape - * extending `readonly string[]`. AGENTS.md requires the rationale - * comment alongside any non-standard surface; this is the type-only - * twin of `extension-metadata/constants.ts:CIPHERSTASH_CODEC_TRAITS`, - * which carries the runtime-side rationale for the same pattern. - */ -type EqualityTraits = readonly ['cipherstash:equality'] -type OrderAndRangeTraits = readonly ['cipherstash:order-and-range'] -type FreeTextSearchTraits = readonly ['cipherstash:free-text-search'] -type SearchableJsonTraits = readonly ['cipherstash:searchable-json'] - -/** - * v3 TYPE-LEVEL marker traits (no runtime counterpart) — carried only - * by the v3 codec entries in `codec-types.ts` (see the vocabulary - * comment there). The v2 and v3 surfaces have DISJOINT method names - * (`cipherstash*` vs the EQL-derived `eql*`), so every v3 operator - * dispatches on a v3 marker and every v2 operator on the shared v2 - * trait (or legacy codec-id pin) — type-level visibility stays exactly - * aligned with what each column's runtime can actually execute. + * `Self.traits` and tests `[traits[number]] extends [CT[CodecId]['traits']]`, + * so widening to the framework's closed `CodecTrait` union (or to + * `never[]` via intersection) erases the extension's dispatch + * information. cipherstash's `QueryOperationTypes` therefore declares + * its entries directly (rather than via the `SqlQueryOperationTypes` wrapper that constrains `T extends Record`) so the literal trait strings flow through + * untouched. */ type V3EqualityMarker = readonly ['cipherstash:v3-equality'] type V3OrderAndRangeMarker = readonly ['cipherstash:v3-order-and-range'] @@ -98,14 +57,13 @@ type V3FreeTextSearchMarker = readonly ['cipherstash:v3-free-text-search'] type V3SearchableJsonMarker = readonly ['cipherstash:v3-searchable-json'] /** - * Schematic constraint on `self` for a multi-codec cipherstash - * predicate. The runtime impl reads `self.returnType.codecId` and - * dispatches to the matching `Encrypted*` envelope — accepting any - * `Expression` here is correct because the surface is column-method - * autocomplete, not a free-standing helper. The framework's - * `OpMatchesField` is what restricts visibility to codecs declaring - * the gating trait; this `self` argument type is irrelevant to that - * dispatch. + * Schematic constraint on `self` for a cipherstash predicate. The + * runtime impl reads `self.returnType.codecId` and dispatches to the + * matching envelope — accepting any `Expression` here is correct + * because the surface is column-method autocomplete, not a + * free-standing helper. The framework's `OpMatchesField` is what + * restricts visibility to codecs declaring the gating marker; this + * `self` argument type is irrelevant to that dispatch. */ type AnyExpressionLike = Expression<{ readonly codecId: string @@ -114,135 +72,28 @@ type AnyExpressionLike = Expression<{ /** * Flat operation signatures consumed by the SQL query builder. Read - * via the `queryOperations` slot on the runtime context to project - * the cipherstash predicate methods onto cipherstash column accessors - * inside `model.where(...)` / `sql(t).where(...)` callbacks. - * - * Every operator's runtime impl (`../execution/operators.ts`) wraps - * the user-supplied argument(s) in the appropriate `Encrypted*` - * envelope at lowering time and stamps the column's `(table, column)` - * routing context, then lowers to the canonical EQL function call. + * via the `queryOperations` slot on the runtime context to project the + * cipherstash `eql*` predicate methods onto cipherstash column + * accessors inside `model.where(...)` / `sql(t).where(...)` callbacks. * - * The user-facing argument type is intentionally permissive - * (`unknown` for multi-codec ops, `pg/text@1` for the legacy - * single-codec ops). The cipherstash extension does not ship a - * `codec-types` augmentation declaring `input` / `output` shapes for - * the cipherstash codec ids, so the symmetric encrypted-codec-typed - * `other` shape pgvector uses for its `cosineDistance` arg would only - * accept full `Expression` values, not raw plaintext literals. The - * asymmetry mirrors the runtime: the column `self` is the encrypted - * column; the comparand is plaintext the operator encrypts on the - * user's behalf. + * Every operator's runtime impl (`../v3/operators-v3.ts`) encrypts the + * user-supplied argument(s) at lowering time and stamps the column's + * `(table, column)` routing context, then lowers to the canonical EQL + * v3 function call. The comparand argument type is intentionally + * permissive (`unknown`): the column `self` is the encrypted column; + * the comparand is plaintext the operator encrypts on the user's + * behalf. */ export type QueryOperationTypes = CT extends CodecTypesBase ? { - // ------------------------------------------------------------- - // v2 legacy surface (`cipherstash*`). `cipherstashEq` / - // `cipherstashIlike` are codec-id-pinned to the v2 string codec - // (legacy single-codec registrations); the rest dispatch on the - // shared v2 traits, which v3 codec entries no longer carry — - // so none of these surface on a v3 column. - // ------------------------------------------------------------- - readonly cipherstashEq: { - readonly self: { readonly codecId: CipherstashStringCodec } - readonly impl: ( - self: AnyExpressionLike, - other: unknown, - ) => PgBoolReturn - } - readonly cipherstashIlike: { - readonly self: { readonly codecId: CipherstashStringCodec } - readonly impl: ( - self: AnyExpressionLike, - pattern: CodecExpression<'pg/text@1', boolean, CT>, - ) => PgBoolReturn - } - readonly cipherstashNotIlike: { - readonly self: { readonly traits: FreeTextSearchTraits } - readonly impl: ( - self: AnyExpressionLike, - pattern: string, - ) => PgBoolReturn - } - readonly cipherstashNe: { - readonly self: { readonly traits: EqualityTraits } - readonly impl: ( - self: AnyExpressionLike, - other: unknown, - ) => PgBoolReturn - } - readonly cipherstashInArray: { - readonly self: { readonly traits: EqualityTraits } - readonly impl: ( - self: AnyExpressionLike, - values: readonly unknown[], - ) => PgBoolReturn - } - readonly cipherstashNotInArray: { - readonly self: { readonly traits: EqualityTraits } - readonly impl: ( - self: AnyExpressionLike, - values: readonly unknown[], - ) => PgBoolReturn - } - readonly cipherstashGt: { - readonly self: { readonly traits: OrderAndRangeTraits } - readonly impl: ( - self: AnyExpressionLike, - other: unknown, - ) => PgBoolReturn - } - readonly cipherstashGte: { - readonly self: { readonly traits: OrderAndRangeTraits } - readonly impl: ( - self: AnyExpressionLike, - other: unknown, - ) => PgBoolReturn - } - readonly cipherstashLt: { - readonly self: { readonly traits: OrderAndRangeTraits } - readonly impl: ( - self: AnyExpressionLike, - other: unknown, - ) => PgBoolReturn - } - readonly cipherstashLte: { - readonly self: { readonly traits: OrderAndRangeTraits } - readonly impl: ( - self: AnyExpressionLike, - other: unknown, - ) => PgBoolReturn - } - readonly cipherstashBetween: { - readonly self: { readonly traits: OrderAndRangeTraits } - readonly impl: ( - self: AnyExpressionLike, - low: unknown, - high: unknown, - ) => PgBoolReturn - } - readonly cipherstashNotBetween: { - readonly self: { readonly traits: OrderAndRangeTraits } - readonly impl: ( - self: AnyExpressionLike, - low: unknown, - high: unknown, - ) => PgBoolReturn - } - readonly cipherstashJsonbPathExists: { - readonly self: { readonly traits: SearchableJsonTraits } - readonly impl: (self: AnyExpressionLike, path: string) => PgBoolReturn - } // ------------------------------------------------------------- // v3 surface (`eql*`, EQL-derived vocabulary — PR #655 review). // Every entry dispatches on a `cipherstash:v3-*` marker, which - // only v3 codec entries carry, so nothing here surfaces on a - // v2 column (whose runtime has no `eql*` method to dispatch) — - // and, symmetrically, the v2 entries above never surface on v3 - // columns. The comparand is `unknown` because each operator - // spans every capable domain family (bigint, date, numeric, …) - // and the runtime coerces + encrypts per the column's castAs. + // only v3 codec entries carry. The comparand is `unknown` + // because each operator spans every capable domain family + // (bigint, date, numeric, …) and the runtime coerces + encrypts + // per the column's castAs. // ------------------------------------------------------------- readonly eqlEq: { readonly self: { readonly traits: V3EqualityMarker } diff --git a/packages/prisma-next/src/v3/bulk-encrypt-v3.ts b/packages/prisma-next/src/v3/bulk-encrypt-v3.ts index 79caba2ce..3c8ca3291 100644 --- a/packages/prisma-next/src/v3/bulk-encrypt-v3.ts +++ b/packages/prisma-next/src/v3/bulk-encrypt-v3.ts @@ -52,11 +52,14 @@ import { setHandleCiphertext, } from '../execution/envelope-base' import { markBulkEncryptMiddlewareRegistered } from '../execution/middleware-registry' -import { type BulkEncryptTarget, groupByRoutingKey } from '../execution/routing' +// Version-neutral AST routing-key stamping — reused, not the v2 encode path. +import { + type BulkEncryptTarget, + groupByRoutingKey, + stampRoutingKeysFromAst, +} from '../execution/routing' import type { CipherstashSdk } from '../execution/sdk' import { CIPHERSTASH_V3_CODEC_ID_SET } from '../extension-metadata/constants-v3' -// Version-neutral AST routing-key stamping — reused, not the v2 encode path. -import { stampRoutingKeysFromAst } from '../middleware/bulk-encrypt' import { v3QueryTermTypeOf } from './query-term' import { v3ToDriver } from './wire-v3' diff --git a/packages/prisma-next/src/v3/codec-runtime-v3.ts b/packages/prisma-next/src/v3/codec-runtime-v3.ts index fc1e82687..a34b0bd89 100644 --- a/packages/prisma-next/src/v3/codec-runtime-v3.ts +++ b/packages/prisma-next/src/v3/codec-runtime-v3.ts @@ -40,6 +40,7 @@ import { EncryptedBoolean } from '../execution/envelope-boolean' import { EncryptedDate } from '../execution/envelope-date' import { EncryptedJson } from '../execution/envelope-json' import { EncryptedString } from '../execution/envelope-string' +import { isBulkEncryptMiddlewareRegistered } from '../execution/middleware-registry' import type { CipherstashSdk } from '../execution/sdk' import { isCipherstashV3CodecId, @@ -121,6 +122,10 @@ export class CipherstashV3CellCodec< readonly #sdk: CipherstashSdk readonly #fromInternal: FromInternal readonly #typeName: string + // Memo: once this SDK is known-registered it can never become + // unregistered (the registry is add-only), so the WeakSet lookup is + // paid once per codec rather than once per encoded cell. + #middlewareCheckPassed = false constructor( descriptor: AnyCodecDescriptor, @@ -148,6 +153,36 @@ export class CipherstashV3CellCodec< } const handle = value.expose() if (handle.ciphertext === undefined) { + // Misconfig diagnostic (ported from the v2 codec's + // `../execution/cell-codec-factory.ts`, deleted with the rest of + // v2): an SDK-bound codec seeing a pre-encrypt envelope means no + // `bulkEncryptMiddlewareV3(sdk)` was constructed against this same + // SDK, so the two-pass write can never complete. Throw at the + // codec boundary with a copy-pasteable wiring snippet rather than + // letting the envelope reach the pg driver, where it surfaces as + // an opaque serialise error. + if (!this.#middlewareCheckPassed) { + if (!isBulkEncryptMiddlewareRegistered(this.#sdk)) { + throw runtimeError( + 'RUNTIME.ENCODE_FAILED', + `cipherstash ${this.descriptor.codecId}: encrypted column value has not been encrypted, ` + + 'and no `bulkEncryptMiddlewareV3(sdk)` has been registered with this SDK. ' + + 'Wire it up alongside the extension descriptor:\n\n' + + ' postgres({\n' + + ' contractJson,\n' + + ' extensions: [createCipherstashV3RuntimeDescriptor({ sdk })],\n' + + ' middleware: [bulkEncryptMiddlewareV3(sdk)],\n' + + ' });\n\n' + + 'Both must close over the SAME `sdk` reference. See the @cipherstash/prisma-next README for the full wiring example.', + { + codecId: this.descriptor.codecId, + reason: 'cipherstash-bulk-encrypt-middleware-not-registered', + envelopeRouting: { table: handle.table, column: handle.column }, + }, + ) + } + this.#middlewareCheckPassed = true + } return value } return v3ToDriver(handle.ciphertext) diff --git a/packages/prisma-next/src/v3/envelope-number.ts b/packages/prisma-next/src/v3/envelope-number.ts index 445cfec84..048bf38be 100644 --- a/packages/prisma-next/src/v3/envelope-number.ts +++ b/packages/prisma-next/src/v3/envelope-number.ts @@ -4,12 +4,12 @@ * `..._smallint*`, `..._numeric*`, `..._real*`, `..._double*`). Concrete * subclass of {@link EncryptedEnvelopeBase} parameterised on `number`. * - * A SIBLING of the v2 `EncryptedDouble` — NOT a subclass or alias. Its - * distinct `typeName` drives the `$encryptedNumber` placeholder marker; - * keeping the class separate prevents `value instanceof EncryptedNumber` - * from matching a v2 `EncryptedDouble` (a cross-version leak). Every v3 - * number-castAs domain (integer / smallint / numeric / real / double) - * renders as `EncryptedNumber`. + * A distinct SIBLING of the other envelope classes — NOT a subclass or + * alias. Its distinct `typeName` drives the `$encryptedNumber` + * placeholder marker; keeping the class separate prevents `value + * instanceof EncryptedNumber` from matching a different envelope class. + * Every v3 number-castAs domain (integer / smallint / numeric / real / + * double) renders as `EncryptedNumber`. * * No `parseDecryptedValue` override is needed: the SDK's polymorphic * `bulkDecrypt` / single-cell `decrypt` already returns numeric diff --git a/packages/prisma-next/src/v3/runtime-v3.ts b/packages/prisma-next/src/v3/runtime-v3.ts index ec16933e2..61855235e 100644 --- a/packages/prisma-next/src/v3/runtime-v3.ts +++ b/packages/prisma-next/src/v3/runtime-v3.ts @@ -1,7 +1,7 @@ /** * The v3 runtime extension descriptor — - * `createCipherstashV3RuntimeDescriptor({ sdk })`, the v3 twin of - * `../exports/runtime.ts`'s `createCipherstashRuntimeDescriptor`. + * `createCipherstashV3RuntimeDescriptor({ sdk })`, exported from + * `../exports/runtime.ts` as the package's only runtime descriptor. * * Composes the SDK-bound v3 codec descriptors (Task 4, one per catalog * domain) and the v3 query operations (Task 6) into a single @@ -12,22 +12,18 @@ * The runtime asserts every extension pack the contract declares has a * runtime descriptor with a MATCHING ID * (`assertExecutionStackContractRequirements` in sql-runtime's - * `sql-context.ts`). Both v2 and v3 contracts are emitted by the ONE - * cipherstash control descriptor, whose pack id is - * `CIPHERSTASH_SPACE_ID` (`'cipherstash'`) — so the v3 runtime - * descriptor must present that id too, or `postgres({...})` - * rejects a v3 contract at startup with - * `RUNTIME.MISSING_EXTENSION_PACK`. The descriptor's VERSION carries - * v3's own identity (`CIPHERSTASH_V3_EXTENSION_VERSION`), and decision - * 1b's separation holds regardless: the v3 method names (`eqlEq`, …) - * are disjoint from the v2 set, but the v2 and v3 descriptors must - * still NEVER be co-registered in one client — a client is v2 or v3, - * never both (pinned in `test/v3/operator-gating-v3.test.ts`). + * `sql-context.ts`). The contract is emitted by the cipherstash control + * descriptor, whose pack id is `CIPHERSTASH_SPACE_ID` + * (`'cipherstash'`) — so the v3 runtime descriptor must present that id + * too, or `postgres({...})` rejects the contract at startup + * with `RUNTIME.MISSING_EXTENSION_PACK`. The descriptor's VERSION + * carries v3's own identity (`CIPHERSTASH_V3_EXTENSION_VERSION`), and + * every registered method wears the `eql*` prefix (pinned in + * `test/v3/operator-gating-v3.test.ts`). * * The v3 bulk-encrypt middleware ships separately * (`bulkEncryptMiddlewareV3(sdk)`) because - * `SqlRuntimeExtensionDescriptor` does not own a middleware slot — - * same split as v2. + * `SqlRuntimeExtensionDescriptor` does not own a middleware slot. */ import type { SqlRuntimeExtensionDescriptor } from '@prisma-next/sql-runtime' diff --git a/packages/prisma-next/test/abort.test.ts b/packages/prisma-next/test/abort.test.ts index 2a40b0097..bc7ff3b86 100644 --- a/packages/prisma-next/test/abort.test.ts +++ b/packages/prisma-next/test/abort.test.ts @@ -5,10 +5,12 @@ * envelope wrapping at every async observation point the extension * exposes: * - * - `bulk-encrypt` — bulk-encrypt middleware`s `sdk.bulkEncrypt` call. * - `decrypt` — single-cell `EncryptedString#decrypt()` SDK call. * - `decrypt-all` — `decryptAll` walker`s `sdk.bulkDecrypt` calls. * + * (The bulk-encrypt phase — the v3 middleware`s `sdk.bulkEncrypt` call — + * is covered by `test/v3/bulk-encrypt-v3.test.ts`.) + * * The codec`s `encode` / `decode` paths are deliberately NOT wrapped * here; both are synchronous (encode reads `handle.ciphertext`; decode * constructs a fresh envelope from `wire` + `ctx.column` + `sdk`). The @@ -16,8 +18,8 @@ * framework`s `encodeParams` / `decodeRow` paths — already throws * `RUNTIME.ABORTED` with `phase: 'encode'` / `phase: 'decode'` per * ADR 207. The cipherstash phases below cover the async work the - * framework cannot see (bulk SDK calls in `beforeExecute` middleware - * and post-stream caller-driven `decrypt()` / `decryptAll()` sites). + * framework cannot see (post-stream caller-driven `decrypt()` / + * `decryptAll()` sites). * * Envelope shape contract: every cipherstash phase wrapping reuses * the framework`s `RUNTIME.ABORTED` envelope (`code === 'RUNTIME.ABORTED'`, @@ -27,30 +29,17 @@ * behind it) come from the framework. See ADR 207 / 027. */ -import type { Contract } from '@prisma-next/contract/types' import { isRuntimeError, RUNTIME_ABORTED, } from '@prisma-next/framework-components/runtime' -import type { SqlStorage } from '@prisma-next/sql-contract/types' -import { - InsertAst, - ParamRef, - TableSource, -} from '@prisma-next/sql-relational-core/ast' -import { createSqlParamRefMutator } from '@prisma-next/sql-relational-core/middleware' -import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan' -import type { SqlMiddlewareContext } from '@prisma-next/sql-runtime' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { decryptAll } from '../src/execution/decrypt-all' import { EncryptedString, type EncryptedStringFromInternalArgs, - setHandleRoutingKey, } from '../src/execution/envelope-string' import type { CipherstashSdk } from '../src/execution/sdk' -import { CIPHERSTASH_STRING_CODEC_ID } from '../src/extension-metadata/constants' -import { bulkEncryptMiddleware } from '../src/middleware/bulk-encrypt' interface CounterSdk extends CipherstashSdk { readonly bulkEncryptCalls: number @@ -117,41 +106,6 @@ function expectAbortedEnvelope(error: unknown, phase: string): void { expect(error.details).toEqual({ phase }) } -function makeMiddlewareCtx( - signal: AbortSignal | undefined, -): SqlMiddlewareContext { - return { - contract: {} as Contract, - mode: 'strict', - scope: 'runtime', - now: () => Date.now(), - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - contentHash: async () => 'mock-hash', - planExecutionId: 'test-plan-execution', - ...(signal === undefined ? {} : { signal }), - } -} - -function buildInsertPlan( - envelopes: ReadonlyArray, -): SqlExecutionPlan { - const params: unknown[] = [] - const astRows = envelopes.map((envelope) => { - const ref = ParamRef.of(envelope, { - codec: { codecId: CIPHERSTASH_STRING_CODEC_ID }, - }) - params.push(envelope) - return { email: ref } - }) - const ast = new InsertAst(TableSource.named('user'), astRows) - return { - sql: `INSERT INTO "user" (email) VALUES ...`, - params, - meta: { target: 'postgres', storageHash: 'sha256:test', lane: 'dsl' }, - ast, - } as SqlExecutionPlan -} - interface MakeReadEnvelopeArgs { readonly plaintext: string readonly table: string @@ -169,67 +123,6 @@ function makeReadEnvelope(args: MakeReadEnvelopeArgs): EncryptedString { return EncryptedString.fromInternal(fromInternalArgs) } -describe('bulk-encrypt middleware — RUNTIME.ABORTED { phase: "bulk-encrypt" }', () => { - it('pre-aborted ctx.signal short-circuits before sdk.bulkEncrypt is called', async () => { - const sdk = makeStuckSdk('stuck') - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - setHandleRoutingKey(envelope, 'user', 'email') - const plan = buildInsertPlan([envelope]) - const params = createSqlParamRefMutator(plan) - const controller = new AbortController() - controller.abort(new Error('client gone')) - - const pending = middleware.beforeExecute?.( - plan, - makeMiddlewareCtx(controller.signal), - params, - ) - if (!pending) throw new Error('beforeExecute is required for this test') - const error = await pending.then( - () => { - throw new Error('expected RUNTIME.ABORTED rejection') - }, - (err: unknown) => err, - ) - - expectAbortedEnvelope(error, 'bulk-encrypt') - // The SDK must not have been entered; the pre-check fires before - // the bulk-encrypt round-trip is scheduled. - expect(sdk.bulkEncryptCalls).toBe(0) - }) - - it('mid-flight abort surfaces RUNTIME.ABORTED { phase: "bulk-encrypt" } via the race', async () => { - const sdk = makeStuckSdk('stuck') - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - setHandleRoutingKey(envelope, 'user', 'email') - const plan = buildInsertPlan([envelope]) - const params = createSqlParamRefMutator(plan) - const controller = new AbortController() - - const pending = middleware.beforeExecute?.( - plan, - makeMiddlewareCtx(controller.signal), - params, - ) - queueMicrotask(() => controller.abort(new Error('client gone'))) - - const error = await pending?.then( - () => { - throw new Error('expected RUNTIME.ABORTED rejection') - }, - (err: unknown) => err, - ) - - expectAbortedEnvelope(error, 'bulk-encrypt') - // The SDK call was scheduled (counter increments before the - // underlying promise settles) but never resolved; the wrapping - // observed the abort and rejected the awaiter. - expect(sdk.bulkEncryptCalls).toBe(1) - }) -}) - describe('EncryptedString.decrypt — RUNTIME.ABORTED { phase: "decrypt" }', () => { it('pre-aborted signal short-circuits before sdk.decrypt is called', async () => { const sdk = makeStuckSdk('stuck') @@ -363,28 +256,6 @@ describe('cipherstash phase wrappings preserve cause and reuse the framework env const controller = new AbortController() controller.abort(reason) - // bulk-encrypt - { - const sdk = makeStuckSdk('stuck') - const envelope = EncryptedString.from('alice@example.com') - setHandleRoutingKey(envelope, 'user', 'email') - const plan = buildInsertPlan([envelope]) - const params = createSqlParamRefMutator(plan) - const pending = bulkEncryptMiddleware(sdk).beforeExecute?.( - plan, - makeMiddlewareCtx(controller.signal), - params, - ) - if (!pending) throw new Error('beforeExecute is required for this test') - const error = await pending.then( - () => { - throw new Error('expected RUNTIME.ABORTED rejection') - }, - (err: unknown) => err, - ) - expect((error as { cause?: unknown }).cause).toBe(reason) - } - // decrypt { const sdk = makeStuckSdk('stuck') diff --git a/packages/prisma-next/test/authoring.test.ts b/packages/prisma-next/test/authoring.test.ts index a6b437e51..38ef02f51 100644 --- a/packages/prisma-next/test/authoring.test.ts +++ b/packages/prisma-next/test/authoring.test.ts @@ -9,13 +9,9 @@ * domain's STATIC codec id, `public.eql_v3_*` native type, and a static * `{ castAs, capabilities }` typeParams block — no options, no * `AuthoringArgRef` nodes. - * - The six pre-existing v2 constructors survive verbatim under their - * `*V2` names (`EncryptedStringV2`, …): same optional-object argument - * shapes, same `cipherstash/*@1` codec ids, same `eql_v2_encrypted` - * native type, same `true`-defaulting `AuthoringArgRef` typeParams. - * - `EncryptedString` (unqualified) no longer exists — v3 text columns use - * the `Text*` family. `Json` (unqualified) is now the - * v3 `eql_v3_json` domain. + * - `EncryptedString` (unqualified) does not exist — v3 text columns use + * the `Text*` family. `Json` (unqualified) is the v3 `eql_v3_json` + * domain. * * Full PSL→ColumnTypeDescriptor lowering is exercised in * `test/psl-interpretation*.test.ts`. @@ -26,7 +22,6 @@ import { cipherstashAuthoringTypes, v3PascalName, } from '../src/contract-authoring' -import cipherstashPack from '../src/exports/pack' import { EXPOSED_DOMAIN_ENTRIES } from '../src/v3/catalog' type ConstructorView = @@ -126,269 +121,10 @@ describe('cipherstash v3 authoring (concrete per-domain, static descriptors)', ( expect(ns.EncryptedString).toBeUndefined() }) - it('the exposed constructor set is exactly the derived v3 names plus the six *V2 aliases', () => { + it('the exposed constructor set is exactly the derived v3 names', () => { const derived = EXPOSED_DOMAIN_ENTRIES.map(([, meta]) => v3PascalName(meta.bareDomain), ) - const v2Aliases = [ - 'EncryptedStringV2', - 'EncryptedDoubleV2', - 'EncryptedBigIntV2', - 'EncryptedDateV2', - 'EncryptedBooleanV2', - 'EncryptedJsonV2', - ] - expect(Object.keys(ns).sort()).toEqual([...derived, ...v2Aliases].sort()) - }) -}) - -describe('cipherstash pack authoring contributions (v2 legacy aliases)', () => { - it('exposes cipherstash.EncryptedStringV2 as a namespaced type constructor', () => { - expect(cipherstashPack.authoring?.type).toMatchObject({ - cipherstash: { - EncryptedStringV2: { - kind: 'typeConstructor', - }, - }, - }) - }) - - it('declares a single optional object argument with optional equality + freeTextSearch + orderAndRange boolean properties', () => { - expect(ns.EncryptedStringV2).toMatchObject({ - kind: 'typeConstructor', - args: [ - { - kind: 'object', - optional: true, - properties: { - equality: { kind: 'boolean', optional: true }, - freeTextSearch: { kind: 'boolean', optional: true }, - orderAndRange: { kind: 'boolean', optional: true }, - }, - }, - ], - }) - }) - - it('lowers to ColumnTypeDescriptor with codecId cipherstash/string@1 + nativeType eql_v2_encrypted, defaulting all flags to true', () => { - expect(ns.EncryptedStringV2?.output).toMatchObject({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: { kind: 'arg', index: 0, path: ['equality'], default: true }, - freeTextSearch: { - kind: 'arg', - index: 0, - path: ['freeTextSearch'], - default: true, - }, - orderAndRange: { - kind: 'arg', - index: 0, - path: ['orderAndRange'], - default: true, - }, - }, - }) - }) - - it('exposes the storage type registration via pack meta', () => { - expect(cipherstashPack.types?.storage).toContainEqual({ - typeId: 'cipherstash/string@1', - familyId: 'sql', - targetId: 'postgres', - nativeType: 'eql_v2_encrypted', - }) - }) - - describe('cipherstash.EncryptedDoubleV2', () => { - it('exposes EncryptedDoubleV2 as a namespaced type constructor', () => { - expect(cipherstashPack.authoring?.type).toMatchObject({ - cipherstash: { EncryptedDoubleV2: { kind: 'typeConstructor' } }, - }) - }) - - it('declares { equality, orderAndRange } booleans, defaulting both to true', () => { - expect(ns.EncryptedDoubleV2).toMatchObject({ - kind: 'typeConstructor', - args: [ - { - kind: 'object', - optional: true, - properties: { - equality: { kind: 'boolean', optional: true }, - orderAndRange: { kind: 'boolean', optional: true }, - }, - }, - ], - }) - expect(ns.EncryptedDoubleV2?.output).toMatchObject({ - codecId: 'cipherstash/double@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: { - kind: 'arg', - index: 0, - path: ['equality'], - default: true, - }, - orderAndRange: { - kind: 'arg', - index: 0, - path: ['orderAndRange'], - default: true, - }, - }, - }) - }) - - it('registers the cipherstash/double@1 storage type', () => { - expect(cipherstashPack.types?.storage).toContainEqual({ - typeId: 'cipherstash/double@1', - familyId: 'sql', - targetId: 'postgres', - nativeType: 'eql_v2_encrypted', - }) - }) - }) - - describe('cipherstash.EncryptedBigIntV2', () => { - it('exposes EncryptedBigIntV2 as a namespaced type constructor', () => { - expect(cipherstashPack.authoring?.type).toMatchObject({ - cipherstash: { EncryptedBigIntV2: { kind: 'typeConstructor' } }, - }) - }) - - it('lowers to ColumnTypeDescriptor with codecId cipherstash/bigint@1, defaulting both flags to true', () => { - expect(ns.EncryptedBigIntV2?.output).toMatchObject({ - codecId: 'cipherstash/bigint@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: { - kind: 'arg', - index: 0, - path: ['equality'], - default: true, - }, - orderAndRange: { - kind: 'arg', - index: 0, - path: ['orderAndRange'], - default: true, - }, - }, - }) - }) - - it('registers the cipherstash/bigint@1 storage type', () => { - expect(cipherstashPack.types?.storage).toContainEqual({ - typeId: 'cipherstash/bigint@1', - familyId: 'sql', - targetId: 'postgres', - nativeType: 'eql_v2_encrypted', - }) - }) - }) - - describe('cipherstash.EncryptedDateV2', () => { - it('exposes EncryptedDateV2 as a namespaced type constructor', () => { - expect(cipherstashPack.authoring?.type).toMatchObject({ - cipherstash: { EncryptedDateV2: { kind: 'typeConstructor' } }, - }) - }) - - it('lowers to ColumnTypeDescriptor with codecId cipherstash/date@1, defaulting both flags to true', () => { - expect(ns.EncryptedDateV2?.output).toMatchObject({ - codecId: 'cipherstash/date@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: { - kind: 'arg', - index: 0, - path: ['equality'], - default: true, - }, - orderAndRange: { - kind: 'arg', - index: 0, - path: ['orderAndRange'], - default: true, - }, - }, - }) - }) - - it('registers the cipherstash/date@1 storage type', () => { - expect(cipherstashPack.types?.storage).toContainEqual({ - typeId: 'cipherstash/date@1', - familyId: 'sql', - targetId: 'postgres', - nativeType: 'eql_v2_encrypted', - }) - }) - }) - - describe('cipherstash.EncryptedBooleanV2', () => { - it('exposes EncryptedBooleanV2 as a namespaced type constructor', () => { - expect(cipherstashPack.authoring?.type).toMatchObject({ - cipherstash: { EncryptedBooleanV2: { kind: 'typeConstructor' } }, - }) - }) - - it('lowers to ColumnTypeDescriptor with codecId cipherstash/boolean@1, defaulting equality to true', () => { - expect(ns.EncryptedBooleanV2?.output).toMatchObject({ - codecId: 'cipherstash/boolean@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: { - kind: 'arg', - index: 0, - path: ['equality'], - default: true, - }, - }, - }) - }) - - it('registers the cipherstash/boolean@1 storage type', () => { - expect(cipherstashPack.types?.storage).toContainEqual({ - typeId: 'cipherstash/boolean@1', - familyId: 'sql', - targetId: 'postgres', - nativeType: 'eql_v2_encrypted', - }) - }) - }) - - describe('cipherstash.EncryptedJsonV2', () => { - it('exposes EncryptedJsonV2 as a namespaced type constructor', () => { - expect(cipherstashPack.authoring?.type).toMatchObject({ - cipherstash: { EncryptedJsonV2: { kind: 'typeConstructor' } }, - }) - }) - - it('lowers to ColumnTypeDescriptor with codecId cipherstash/json@1, defaulting searchableJson to true', () => { - expect(ns.EncryptedJsonV2?.output).toMatchObject({ - codecId: 'cipherstash/json@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - searchableJson: { - kind: 'arg', - index: 0, - path: ['searchableJson'], - default: true, - }, - }, - }) - }) - - it('registers the cipherstash/json@1 storage type', () => { - expect(cipherstashPack.types?.storage).toContainEqual({ - typeId: 'cipherstash/json@1', - familyId: 'sql', - targetId: 'postgres', - nativeType: 'eql_v2_encrypted', - }) - }) + expect(Object.keys(ns).sort()).toEqual([...derived].sort()) }) }) diff --git a/packages/prisma-next/test/bulk-encrypt-middleware.helpers.ts b/packages/prisma-next/test/bulk-encrypt-middleware.helpers.ts index 2ac3911b9..8922c3bcf 100644 --- a/packages/prisma-next/test/bulk-encrypt-middleware.helpers.ts +++ b/packages/prisma-next/test/bulk-encrypt-middleware.helpers.ts @@ -1,10 +1,10 @@ /** - * Shared harness for the bulk-encrypt middleware suites (v2 - * `bulk-encrypt-middleware.test.ts` and v3 `v3/bulk-encrypt-v3.test.ts`). - * Version-neutral by construction: plan builders take the codec id to - * stamp on each `ParamRef` (defaulting to the v2 string codec id so - * existing v2 call sites read unchanged), and the mock SDK echoes - * whatever ciphertext shape the caller's `encryptImpl` produces. + * Shared harness for the bulk-encrypt middleware suite + * (`v3/bulk-encrypt-v3.test.ts`) and the live-PG harness. Plan builders + * take the codec id to stamp on each `ParamRef` (defaulting to a + * searchable v3 text domain; callers that need a specific domain pass + * their own), and the mock SDK echoes whatever ciphertext shape the + * caller's `encryptImpl` produces. */ import type { Contract, PlanMeta } from '@prisma-next/contract/types' @@ -25,7 +25,10 @@ import type { CipherstashSdk, CipherstashSingleDecryptArgs, } from '../src/execution/sdk' -import { CIPHERSTASH_STRING_CODEC_ID } from '../src/extension-metadata/constants' + +// Default codec id for the plan-builder helpers below (callers that need a +// specific domain pass their own). A searchable v3 text domain. +const DEFAULT_CODEC_ID = 'cipherstash/eql-v3/eql_v3_text_search@1' export { createSqlParamRefMutator } from '@prisma-next/sql-relational-core/middleware' @@ -97,7 +100,7 @@ export function makeCounterSdk(options?: { export function buildInsertPlan( table: string, rows: ReadonlyArray>, - codecId: string = CIPHERSTASH_STRING_CODEC_ID, + codecId: string = DEFAULT_CODEC_ID, ): SqlExecutionPlan { const params: unknown[] = [] const astRows = rows.map((row) => { @@ -123,7 +126,7 @@ export function buildInsertPlan( export function buildUpdatePlan( table: string, set: Record, - codecId: string = CIPHERSTASH_STRING_CODEC_ID, + codecId: string = DEFAULT_CODEC_ID, ): SqlExecutionPlan { const params: unknown[] = [] const astSet: Record = {} diff --git a/packages/prisma-next/test/bulk-encrypt-middleware.test.ts b/packages/prisma-next/test/bulk-encrypt-middleware.test.ts deleted file mode 100644 index f1d8a19a0..000000000 --- a/packages/prisma-next/test/bulk-encrypt-middleware.test.ts +++ /dev/null @@ -1,492 +0,0 @@ -/** - * Bulk-encrypt middleware behaviour. - * - * Drives `bulkEncryptMiddleware(sdk).beforeExecute(plan, ctx, params)` - * against an instrumented mock `CipherstashSdk` and asserts: - * - * - One `bulkEncrypt` call per `(table, column)` group; N envelopes - * in the same column collapse into a single SDK round-trip. - * - `(table, column)` is derived from the lowered `InsertAst` / - * `UpdateAst` via the middleware's AST walk and stamped onto each - * envelope handle before grouping. A pre-stamped routing context - * (write-once-wins) is preserved. - * - The SDK-returned ciphertext is stamped onto every envelope - * handle via `setHandleCiphertext`; codec.encode then reads it - * on the wire. - * - `ctx.signal` is forwarded by identity to the SDK so downstream - * cancellation observes the same `AbortSignal`. - * - The handle's `plaintext` slot is **retained** post-encrypt — - * `envelope.decrypt()` returns the cached plaintext synchronously - * without consulting the SDK. - * - * Plus the no-op shape (no cipherstash params → no SDK call) and the - * SDK-shape error path (wrong number of ciphertexts → diagnostic). - */ - -import { - InsertAst, - ParamRef, - TableSource, -} from '@prisma-next/sql-relational-core/ast' -import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan' -import { describe, expect, it } from 'vitest' -import { - EncryptedString, - setHandleRoutingKey, -} from '../src/execution/envelope-string' -import { CIPHERSTASH_STRING_CODEC_ID } from '../src/extension-metadata/constants' -import { bulkEncryptMiddleware } from '../src/middleware/bulk-encrypt' -import { - baseMeta, - buildInsertPlan, - buildUpdatePlan, - createCtx, - createSqlParamRefMutator, - makeCounterSdk, -} from './bulk-encrypt-middleware.helpers' - -describe('bulkEncryptMiddleware', () => { - describe('one bulkEncrypt call per (table, column) group', () => { - it('issues exactly one bulkEncrypt call when 10 rows insert into one column', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelopes = Array.from({ length: 10 }, (_, i) => - EncryptedString.from(`alice${i}@example.com`), - ) - const plan = buildInsertPlan( - 'user', - envelopes.map((e) => ({ email: e })), - ) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(sdk.bulkEncryptCalls).toHaveLength(1) - expect(sdk.bulkEncryptCalls[0]?.routingKey).toEqual({ - table: 'user', - column: 'email', - }) - expect(sdk.bulkEncryptCalls[0]?.values).toEqual( - envelopes.map((_, i) => `alice${i}@example.com`), - ) - }) - - it('partitions targets across (table, column) groups: one bulkEncrypt per group', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const e1 = EncryptedString.from('a@x.com') - const e2 = EncryptedString.from('b@x.com') - const e3 = EncryptedString.from('alice') - const plan = buildInsertPlan('user', [ - { email: e1, username: e3 }, - { email: e2, username: EncryptedString.from('bob') }, - ]) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(sdk.bulkEncryptCalls).toHaveLength(2) - const byColumn = new Map( - sdk.bulkEncryptCalls.map((c) => [c.routingKey.column, c]), - ) - expect(byColumn.get('email')?.values).toEqual(['a@x.com', 'b@x.com']) - expect(byColumn.get('username')?.values).toEqual(['alice', 'bob']) - }) - }) - - describe('ciphertext is stamped onto each envelope handle', () => { - it('populates handle.ciphertext with the SDK-returned wire value', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - const plan = buildInsertPlan('user', [{ email: envelope }]) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(envelope.expose().ciphertext).toBe( - 'cipher:user.email:alice@example.com', - ) - }) - }) - - describe('param slot carries the encoded wire-format string post-middleware', () => { - it('replaces the envelope with the eql_v2_encrypted composite-text literal', async () => { - // The pg driver only knows how to serialise primitives / - // arrays / Buffers — passing the `EncryptedEnvelopeBase` - // instance through would fail at the driver boundary with a - // confusing serialize error. The middleware therefore writes - // the wire-format string (the `("...")` composite-text - // literal) into the param slot via `params.replaceValues`, - // and the runtime's `currentParams()` view reflects that - // before the driver reads. - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - const plan = buildInsertPlan('user', [{ email: envelope }]) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - const finalParams = params.currentParams() - expect(finalParams.length).toBe(1) - const onlyParam = finalParams[0] - expect(typeof onlyParam).toBe('string') - // Composite-text literal: `("` + escaped JSON of the ciphertext - // + `")`. Double-quotes inside the JSON are doubled. - const expectedPayload = JSON.stringify( - 'cipher:user.email:alice@example.com', - ).replaceAll('"', '""') - expect(onlyParam).toBe(`("${expectedPayload}")`) - }) - }) - - describe('ctx.signal is forwarded by identity to the SDK', () => { - it('passes ctx.signal to bulkEncrypt by reference', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - const plan = buildInsertPlan('user', [{ email: envelope }]) - const params = createSqlParamRefMutator(plan) - const controller = new AbortController() - - await middleware.beforeExecute?.( - plan, - createCtx({ signal: controller.signal }), - params, - ) - - expect(sdk.bulkEncryptCalls).toHaveLength(1) - expect(sdk.bulkEncryptCalls[0]?.signal).toBe(controller.signal) - }) - - it('omits signal when ctx.signal is undefined', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - const plan = buildInsertPlan('user', [{ email: envelope }]) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(sdk.bulkEncryptCalls).toHaveLength(1) - expect(sdk.bulkEncryptCalls[0]?.signal).toBeUndefined() - }) - }) - - describe('plaintext slot is retained post-encrypt', () => { - it('decrypt() returns plaintext synchronously without consulting the SDK', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - const plan = buildInsertPlan('user', [{ email: envelope }]) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - const plaintext = await envelope.decrypt() - - expect(plaintext).toBe('alice@example.com') - expect(sdk.singleDecryptCalls).toEqual([]) - expect(sdk.bulkDecryptCalls).toEqual([]) - }) - - it('keeps handle.plaintext populated after middleware returns', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - const plan = buildInsertPlan('user', [{ email: envelope }]) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(envelope.expose().plaintext).toBe('alice@example.com') - }) - }) - - describe('routing key is derived from envelope handle (table, column)', () => { - it('stamps (table, column) from InsertAst before grouping', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - const plan = buildInsertPlan('user', [{ email: envelope }]) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(envelope.expose().table).toBe('user') - expect(envelope.expose().column).toBe('email') - }) - - it('stamps (table, column) from UpdateAst before grouping', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - const plan = buildUpdatePlan('admin', { email: envelope }) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(sdk.bulkEncryptCalls).toHaveLength(1) - expect(sdk.bulkEncryptCalls[0]?.routingKey).toEqual({ - table: 'admin', - column: 'email', - }) - }) - - it('rejects re-binding a pre-stamped envelope to a different routing target', async () => { - // Reusing an envelope already bound to one (table, column) routing - // target inside a bulk-encrypt plan that lowers to a different - // target is a programming error: `setHandleRoutingKey` throws on a - // conflicting reassignment so the envelope cannot silently retain - // a stale binding and route to the wrong bulk-encrypt batch. - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - setHandleRoutingKey(envelope, 'admin', 'email') - const plan = buildInsertPlan('user', [{ email: envelope }]) - const params = createSqlParamRefMutator(plan) - - await expect( - middleware.beforeExecute?.(plan, createCtx(), params), - ).rejects.toThrow(/routing-key table conflict/) - expect(sdk.bulkEncryptCalls).toHaveLength(0) - }) - - it('re-stamping with the same routing target is a no-op', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const envelope = EncryptedString.from('alice@example.com') - setHandleRoutingKey(envelope, 'user', 'email') - const plan = buildInsertPlan('user', [{ email: envelope }]) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(sdk.bulkEncryptCalls[0]?.routingKey).toEqual({ - table: 'user', - column: 'email', - }) - }) - }) - - describe('no-op cases', () => { - it('does not call bulkEncrypt when the plan has no cipherstash params', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const ast = new InsertAst(TableSource.named('user'), [ - { id: ParamRef.of(1) }, - ]) - const plan = { - sql: 'INSERT INTO "user" (id) VALUES ($1)', - params: [1], - meta: { ...baseMeta }, - ast, - } as SqlExecutionPlan - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(sdk.bulkEncryptCalls).toEqual([]) - }) - - it('skips when params is undefined', async () => { - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const plan = { - sql: 'SELECT 1', - params: [], - meta: { ...baseMeta }, - } as unknown as SqlExecutionPlan - - await middleware.beforeExecute?.(plan, createCtx()) - - expect(sdk.bulkEncryptCalls).toEqual([]) - }) - }) - - describe('matches every cipherstash codec id', () => { - // The middleware filters `params.entries()` against the closed set - // `CIPHERSTASH_CODEC_ID_SET` rather than the single string codec - // id; this exercises that every codec in the package's surface - // (string + double + bigint + date + boolean + json) routes - // through the bulk-encrypt path, and that every plaintext slot - // in a mixed-codec INSERT participates in exactly one - // `bulkEncrypt` call per `(table, column)` group. - - function buildHeterogeneousInsertPlan( - table: string, - columns: ReadonlyArray<{ - name: string - codecId: string - envelope: unknown - }>, - ): SqlExecutionPlan { - const params: unknown[] = [] - const row: Record = {} - for (const col of columns) { - const ref = ParamRef.of(col.envelope, { - codec: { codecId: col.codecId }, - }) - row[col.name] = ref - params.push(col.envelope) - } - const ast = new InsertAst(TableSource.named(table), [row]) - return { - sql: `INSERT INTO "${table}" (...) VALUES (...)`, - params, - meta: { ...baseMeta }, - ast, - } as SqlExecutionPlan - } - - it('routes envelopes for each of the six cipherstash codec ids through bulk-encrypt', async () => { - const { EncryptedDouble } = await import( - '../src/execution/envelope-double' - ) - const { EncryptedBigInt } = await import( - '../src/execution/envelope-bigint' - ) - const { EncryptedDate } = await import('../src/execution/envelope-date') - const { EncryptedBoolean } = await import( - '../src/execution/envelope-boolean' - ) - const { EncryptedJson } = await import('../src/execution/envelope-json') - const { - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, - } = await import('../src/extension-metadata/constants') - - const sdk = makeCounterSdk({ - encryptImpl: (args) => - args.values.map((_, i) => `ct:${args.routingKey.column}:${i}`), - }) - const middleware = bulkEncryptMiddleware(sdk) - - const stringEnv = EncryptedString.from('alice@example.com') - const doubleEnv = EncryptedDouble.from(3.14) - const bigIntEnv = EncryptedBigInt.from(42n) - const dateEnv = EncryptedDate.from(new Date('2024-01-01')) - const boolEnv = EncryptedBoolean.from(true) - const jsonEnv = EncryptedJson.from({ k: 'v' }) - - const plan = buildHeterogeneousInsertPlan('item', [ - { - name: 'email', - codecId: CIPHERSTASH_STRING_CODEC_ID, - envelope: stringEnv, - }, - { - name: 'score', - codecId: CIPHERSTASH_DOUBLE_CODEC_ID, - envelope: doubleEnv, - }, - { - name: 'amount', - codecId: CIPHERSTASH_BIGINT_CODEC_ID, - envelope: bigIntEnv, - }, - { - name: 'birthday', - codecId: CIPHERSTASH_DATE_CODEC_ID, - envelope: dateEnv, - }, - { - name: 'enabled', - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - envelope: boolEnv, - }, - { - name: 'payload', - codecId: CIPHERSTASH_JSON_CODEC_ID, - envelope: jsonEnv, - }, - ]) - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - // One bulkEncrypt per (table, column) — six columns, one envelope - // each, so six bulkEncrypt calls. Every envelope's ciphertext - // slot ends up populated. - expect(sdk.bulkEncryptCalls).toHaveLength(6) - const byColumn = new Map( - sdk.bulkEncryptCalls.map((c) => [c.routingKey.column, c]), - ) - expect(byColumn.has('email')).toBe(true) - expect(byColumn.has('score')).toBe(true) - expect(byColumn.has('amount')).toBe(true) - expect(byColumn.has('birthday')).toBe(true) - expect(byColumn.has('enabled')).toBe(true) - expect(byColumn.has('payload')).toBe(true) - - // Per-envelope plaintext is forwarded to the SDK as `unknown` - // — the SDK sees the original JS plaintext untouched. - expect(byColumn.get('score')?.values).toEqual([3.14]) - expect(byColumn.get('amount')?.values).toEqual([42n]) - expect(byColumn.get('enabled')?.values).toEqual([true]) - expect(byColumn.get('payload')?.values).toEqual([{ k: 'v' }]) - - // Routing context stamped, ciphertext written back. - for (const env of [ - stringEnv, - doubleEnv, - bigIntEnv, - dateEnv, - boolEnv, - jsonEnv, - ]) { - expect(env.expose().table).toBe('item') - expect(env.expose().ciphertext).toBeDefined() - } - }) - - it('does not route non-cipherstash codec ids through bulk-encrypt', async () => { - // A `ParamRef` carrying a non-cipherstash codec id must not be - // observed by the middleware. The closed-set filter is the - // single defensible boundary against future codec-id collisions. - const sdk = makeCounterSdk() - const middleware = bulkEncryptMiddleware(sdk) - const ast = new InsertAst(TableSource.named('user'), [ - { id: ParamRef.of(1, { codec: { codecId: 'pg/text@1' } }) }, - ]) - const plan = { - sql: 'INSERT INTO "user" (id) VALUES ($1)', - params: [1], - meta: { ...baseMeta }, - ast, - } as SqlExecutionPlan - const params = createSqlParamRefMutator(plan) - - await middleware.beforeExecute?.(plan, createCtx(), params) - - expect(sdk.bulkEncryptCalls).toEqual([]) - }) - }) - - describe('error paths', () => { - it('throws when the SDK returns the wrong number of ciphertexts', async () => { - const sdk = makeCounterSdk({ encryptImpl: () => ['only-one'] }) - const middleware = bulkEncryptMiddleware(sdk) - const plan = buildInsertPlan('user', [ - { email: EncryptedString.from('a@x') }, - { email: EncryptedString.from('b@y') }, - ]) - const params = createSqlParamRefMutator(plan) - - await expect( - middleware.beforeExecute?.(plan, createCtx(), params), - ).rejects.toThrow(/1 ciphertexts.*2 were requested/) - }) - }) -}) - -describe('bulkEncryptMiddleware — name + family identity', () => { - it('declares the SQL family + a stable middleware name', () => { - const middleware = bulkEncryptMiddleware(makeCounterSdk()) - expect(middleware.familyId).toBe('sql') - expect(middleware.name).toBe('cipherstash.bulk-encrypt') - }) -}) diff --git a/packages/prisma-next/test/bundling-isolation.test.ts b/packages/prisma-next/test/bundling-isolation.test.ts index 73c3a0050..fc7e35110 100644 --- a/packages/prisma-next/test/bundling-isolation.test.ts +++ b/packages/prisma-next/test/bundling-isolation.test.ts @@ -1,34 +1,27 @@ /** - * Control vs runtime/middleware byte-level subpath isolation. + * Control vs runtime byte-level subpath isolation (EQL v3). * - * The cipherstash extension publishes three runtime-relevant subpath - * entries: `./control` (contract-space authoring + the codec lifecycle - * hook), `./runtime` (envelope + SDK + codec runtime), and - * `./middleware` (bulk-encrypt middleware). Each entry must compose - * tree-shakably so a consumer pulling `./runtime` does not drag in the - * EQL bundle SQL, the cipherstash baseline migration, or the codec - * lifecycle hook (any of which would defeat the runtime-bundle size - * budget and leak control-plane behaviour into runtime call paths) and - * a consumer pulling `./control` does not drag in `EncryptedString`, - * the SDK interface, the codec runtime, or the bulk-encrypt middleware. + * The cipherstash extension publishes control- and runtime-plane subpath + * entries: `./control` (contract-space authoring + the v3 codec + * lifecycle hook), `./runtime` (envelopes + SDK + v3 codec runtime + + * bulk-encrypt middleware), and `./v3` (the aggregated v3 surface). Each + * entry must compose tree-shakably so a consumer pulling `./runtime` + * (or `./v3`) does not drag in the EQL install SQL, the cipherstash + * baseline migration, or the codec lifecycle hook (any of which would + * defeat the runtime-bundle size budget and leak control-plane behaviour + * into runtime call paths), and a consumer pulling `./control` does not + * drag in the envelope classes, the SDK interface, the codec runtime, or + * the bulk-encrypt middleware. * * This test is the canonical isolation guard. It asserts: * - * 1. **Entry-body forbidden-substring check** (per entry): the - * entry `.js` body — both the inline source and its `import` / - * `export` statements — does not contain forbidden symbol names. - * Mirrors the predecessor `wip/verify-cipherstash-isolation.js` - * shallow check, which catches both inlined runtime behavior in - * a control entry and cross-chunk leaks via named-import lines - * (`import { ForbiddenName } from "./.js"`). Forbidden - * identifiers occurring inside a chunk's JSDoc or as a PSL type - * identifier string literal are out of scope — they ship no - * executable behavior — and are caught structurally by the - * disjointness check below if the chunk crosses planes. + * 1. **Entry-body forbidden-substring check** (per entry): the entry + * `.js` body — both the inline source and its `import` / `export` + * statements — does not contain forbidden symbol names. * 2. **Chunk-graph disjointness**: control's transitively reachable - * chunk-file set and runtime's (resp. middleware's) chunk-file - * set are disjoint, modulo the shared `constants-*.js` chunk - * (pure literal constants — no SDK / codec / migration code). + * chunk-file set and runtime's set are disjoint, modulo shared + * `chunk-*.js` files that carry ONLY pure constants / catalog + * metadata (no control- or runtime-plane behaviour). * * The dist outputs are produced by `tsup` from `src/exports/*.ts`. * The package's `turbo.json` declares `test` depends on its own @@ -36,7 +29,7 @@ * the current source. */ -import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'pathe' import { describe, expect, it } from 'vitest' @@ -44,165 +37,68 @@ import { describe, expect, it } from 'vitest' const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))) const DIST = join(PACKAGE_ROOT, 'dist') -const ENTRY_FILES = [ - 'control.js', - 'runtime.js', - 'middleware.js', - 'v3.js', -] as const +const ENTRY_FILES = ['control.js', 'runtime.js', 'v3.js'] as const /** - * Forbidden in `control.js` and its transitive chunk graph. - * These are runtime-plane symbols (envelope / SDK interface / codec - * runtime / middleware factory) that must never reach a control-plane + * Forbidden in `control.js` and its transitive chunk graph. These are + * runtime-plane symbols (envelope classes / codec runtime / bulk-encrypt + * middleware / read-side walker) that must never reach a control-plane * consumer. */ const CONTROL_FORBIDDEN = [ 'EncryptedString', - 'EncryptedDouble', 'EncryptedBigInt', 'EncryptedDate', 'EncryptedBoolean', 'EncryptedJson', + 'EncryptedNumber', 'setHandleCiphertext', - 'CipherstashSdk', - 'bulkEncryptMiddleware', - 'createCipherstashStringCodec', - 'createCipherstashDoubleCodec', - 'createCipherstashBigIntCodec', - 'createCipherstashDateCodec', - 'createCipherstashBooleanCodec', - 'createCipherstashJsonCodec', - 'createCipherstashRuntimeDescriptor', - 'cipherstashAsc', - 'cipherstashDesc', - 'cipherstashJsonbGet', - 'cipherstashJsonbPathQueryFirst', + 'bulkEncryptMiddlewareV3', + 'createV3CodecDescriptors', + 'CipherstashV3CellCodec', + 'decryptAll', ] as const /** - * Forbidden in `runtime.js` / `middleware.js` and their transitive - * chunk graph. These are contract-space artefacts (EQL bundle SQL, - * cipherstash contract IR, baseline migration, head-ref, the - * codec-control lifecycle hook, EQL bundle migration-op terms) that - * must never reach a runtime consumer. + * Forbidden in `runtime.js` / `v3.js` and their transitive chunk graph. + * These are contract-space artefacts (the EQL v3 install SQL injector, + * the contract-space builder, the codec-control lifecycle hook, and the + * search-config migration-op terms) that must never reach a runtime + * consumer. */ const RUNTIME_FORBIDDEN = [ - 'EQL_BUNDLE_SQL', - 'cipherstashContract', - 'cipherstashBaselineMigration', - 'cipherstashHeadRef', - 'cipherstashStringCodecHooks', - 'cipherstashDoubleCodecHooks', - 'cipherstashBigIntCodecHooks', - 'cipherstashDateCodecHooks', - 'cipherstashBooleanCodecHooks', - 'cipherstashJsonCodecHooks', + 'contractSpaceFromJson', + 'withRuntimeEqlSqlPackage', + 'RUNTIME_EQL_SQL_SENTINEL', + 'cipherstashV3CodecControlHooks', 'add_search_config', 'remove_search_config', ] as const /** - * Chunks whose name matches this pattern are allowed to appear in - * both the control graph and the runtime graph. Our tsup build emits - * code-split chunks as `chunk-.js` (vs upstream's `tsdown`, - * which uses content-named `constants-.mjs`). The cross-plane - * shared chunk in our output carries pure literal constants (codec - * id, native types, invariant ids) — sharing them is safe and - * desirable. `ALLOWED_SHARED_CHUNK_MARKER_SETS` below guards that - * the matched chunk's body does not also smuggle runtime-plane logic - * across the boundary. + * A code-split chunk is emitted as `chunk-.js`. Such a chunk may + * legitimately be shared across the control and runtime graphs ONLY when + * it is one of the pure-metadata chunks below — codec ids / native types + * / invariant ids / trait literals, or the per-domain catalog data and + * the pure `envelopeTypeNameForCastAs` / `v3TraitsForCapabilities` + * mappers (which carry envelope type-NAME strings, never the envelope + * classes or any SDK / codec / migration behaviour). + * + * Each allowed shared chunk must export every marker of at least one set; + * a `chunk-*.js` shared between planes that matches no set is not a + * known-safe metadata chunk and the disjointness test rightly fails. */ const SHARED_CHUNK_PATTERN = /^chunk-[A-Za-z0-9_-]+\.js$/ -/** - * Identifiers that uniquely fingerprint an allowed shared chunk: every - * shared chunk we accept must export every marker of at least one set. - * If a `chunk-*.js` is shared between planes but does NOT match a set, - * it is not one of the known-safe metadata chunks and the test rightly - * fails. - * - * Three safe-to-share chunks exist today: - * - * - the v2 constants chunk (pure codec-id / native-type / invariant - * literals); - * - the v3 constants chunk (`src/extension-metadata/constants-v3.ts` - * — the pinned v3 codec-id tuple, invariant ids, baseline - * migration name, space id, and the pure `v3TraitsForCapabilities` - * mapper over trait literals). The control plane reaches it through - * the v3 baseline migration wiring in `control.ts`, the runtime - * plane through the v3 codec descriptors; - * - the v3 catalog chunk (`src/v3/catalog.ts` — per-domain metadata - * derived from the stack's `DOMAIN_REGISTRY`: codec ids, castAs, - * capabilities, index names). The control plane reaches it through - * the v3 authoring constructors, the runtime plane through the v3 - * codec descriptors / operators; it carries no SDK, codec, wire, or - * migration behaviour. - */ const ALLOWED_SHARED_CHUNK_MARKER_SETS: ReadonlyArray = [ - // v2 constants chunk - [ - 'CIPHERSTASH_STRING_CODEC_ID', - 'CIPHERSTASH_DOUBLE_CODEC_ID', - 'CIPHERSTASH_BIGINT_CODEC_ID', - 'CIPHERSTASH_DATE_CODEC_ID', - 'CIPHERSTASH_BOOLEAN_CODEC_ID', - 'CIPHERSTASH_JSON_CODEC_ID', - ], - // v3 constants chunk + // constants chunk (constants.ts + constants-v3.ts — pure literals) [ + 'CIPHERSTASH_SPACE_ID', 'CIPHERSTASH_V3_CODEC_IDS', - 'CIPHERSTASH_V3_BASELINE_MIGRATION_NAME', 'v3TraitsForCapabilities', ], - // v3 catalog chunk - ['V3_DOMAIN_META_BY_CODEC_ID', 'V3_FACTORY_BY_NATIVE_TYPE', 'toV3CodecId'], -] as const - -/** - * Fingerprints of the v2 WIRE plane — the composite-literal codec - * (`encodeEqlV2EncryptedWire` produces the `("...")` wire) and the v2 - * cell-codec factory built on it. A v3 consumer must never load either: - * the v3 wire is plain JSONB (`v3ToDriver`), and mixing the two wire - * planes in one module graph is exactly the confusion decision 1b - * (a client is v2 or v3, never both) exists to prevent. - * - * Marker choice note: `bulkEncryptMiddleware` is NOT usable as a v2 marker — - * it is a substring of its v3 sibling `bulkEncryptMiddlewareV3`, so a substring - * scan would false-positive on every v3 chunk. (The setup functions carry the - * reverse hazard post-rename: the v3 `cipherstashFromStack` is a substring of - * the v2 `cipherstashFromStackV2`, so the v3 name can't serve as a v3 marker — - * neither setup symbol is used as a marker here.) - */ -const V2_WIRE_MARKERS = [ - 'encodeEqlV2EncryptedWire', - 'makeCipherstashCellCodec', -] as const - -/** - * Fingerprints of the v3 WIRE plane — the plain-JSONB cell codec, the - * per-domain descriptor factory, and the v3 bulk-encrypt middleware. - * A v2-only consumer (the `./middleware` entry) must never load these. - */ -const V3_WIRE_MARKERS = [ - 'CipherstashV3CellCodec', - 'createV3CodecDescriptors', - 'bulkEncryptMiddlewareV3', -] as const - -/** - * The six v2 codec-RUNTIME factories (all built on the composite wire's - * `makeCipherstashCellCodec`). Distinct from {@link V2_WIRE_MARKERS} so - * the v3-graph scans can name exactly which factory leaked, and safe as - * substrings: no v3 identifier contains any of them. - */ -const V2_CODEC_FACTORY_MARKERS = [ - 'createCipherstashStringCodec', - 'createCipherstashDoubleCodec', - 'createCipherstashBigIntCodec', - 'createCipherstashDateCodec', - 'createCipherstashBooleanCodec', - 'createCipherstashJsonCodec', + // v3 catalog chunk (per-domain metadata + name/trait mappers) + ['V3_DOMAIN_META_BY_CODEC_ID', 'toV3CodecId', 'EXPOSED_DOMAIN_ENTRIES'], ] as const interface ChunkFile { @@ -221,9 +117,6 @@ function readChunk(file: string): ChunkFile { // `from "./x.js"` — `import ... from`, `export ... from` // `import "./x.js"` — side-effect imports // `import("./x.js")` — dynamic imports -// Without each of these the disjointness check can silently pass for a -// chunk graph that re-exports cross-plane state through side-effect -// imports or `export ... from` edges. const RELATIVE_IMPORT_RE = /(?:from|import)\s*\(?\s*["'](\.\/[^"']+\.js)["']/g function collectGraph(entry: string): Map { @@ -257,53 +150,39 @@ function findLeaksInEntry( return forbidden.filter((needle) => entry.body.includes(needle)) } +/** + * A shared `chunk-*.js` is allowed across planes iff it matches one of + * the known-safe pure-metadata marker sets AND smuggles no control- or + * runtime-plane BEHAVIOUR marker (the envelope base class, the codec + * runtime, the middleware, the contract-space builder). The first clause + * pins WHICH chunks may be shared; the second guards that a matched + * metadata chunk has not also fused in behaviour. + */ function isAllowedSharedChunk(chunk: string): boolean { if (!SHARED_CHUNK_PATTERN.test(chunk)) { return false } const body = readChunk(chunk).body - return ALLOWED_SHARED_CHUNK_MARKER_SETS.some((markers) => - markers.every((marker) => body.includes(marker)), + const isKnownMetadataChunk = ALLOWED_SHARED_CHUNK_MARKER_SETS.some( + (markers) => markers.every((marker) => body.includes(marker)), ) -} - -/** - * Scan an entry's COMPLETE import graph (entry body + every transitive - * chunk) for forbidden markers, returning `""` - * diagnostics so a failure names the offending chunk, not just the - * entry. `exempt` skips known-safe chunks (with the invariant that the - * exemption itself is pinned elsewhere — see the call sites). - */ -function findLeaksInGraph( - entry: string, - forbidden: readonly string[], - exempt: (chunk: ChunkFile) => boolean = () => false, -): string[] { - const leaks: string[] = [] - for (const [file, chunk] of collectGraph(entry)) { - if (exempt(chunk)) continue - for (const marker of forbidden) { - if (chunk.body.includes(marker)) { - leaks.push(`${file} → ${marker}`) - } - } + if (!isKnownMetadataChunk) { + return false } - return leaks -} - -/** - * The version-neutral execution chunk — envelope base/classes, routing, - * abort, `stampRoutingKeysFromAst` — is legitimately reachable from - * EVERY entry (the v3 middleware imports the shared routing walk), and - * it currently also DEFINES the v2 wire encoder. Identified by CONTENT - * (it is the one chunk that defines the envelope base class), never by - * tsup's hash-named file. It is exempted ONLY from the v2-wire-marker - * scan of the v3 graph; the per-chunk "no chunk mixes the two wires" - * test below still pins that this chunk never gains a v3 wire marker, - * so the exemption cannot hide a fused chunk. - */ -function definesVersionNeutralExecutionChunk(chunk: ChunkFile): boolean { - return /var EncryptedEnvelopeBase\s*=/.test(chunk.body) + // A metadata chunk must never define/import plane behaviour. Envelope + // type-NAME strings (e.g. "EncryptedString" from `envelopeTypeNameForCastAs`) + // are fine; the envelope CLASS is not, so we fingerprint the class / + // codec-runtime / middleware / contract-space builders directly. + const behaviourMarkers = [ + 'EncryptedEnvelopeBase', + 'setHandleCiphertext', + 'bulkEncryptMiddlewareV3', + 'createV3CodecDescriptors', + 'CipherstashV3CellCodec', + 'decryptAll', + ...RUNTIME_FORBIDDEN, + ] + return !behaviourMarkers.some((marker) => body.includes(marker)) } describe('bundling isolation', () => { @@ -327,13 +206,13 @@ describe('bundling isolation', () => { expect(leaks, `runtime entry leaks: ${leaks.join(', ')}`).toEqual([]) }) - it('middleware.js does not pull contract-space artefacts', () => { - const entry = readChunk('middleware.js') + it('v3.js does not pull contract-space artefacts', () => { + const entry = readChunk('v3.js') const leaks = findLeaksInEntry(entry, RUNTIME_FORBIDDEN) - expect(leaks, `middleware entry leaks: ${leaks.join(', ')}`).toEqual([]) + expect(leaks, `v3 entry leaks: ${leaks.join(', ')}`).toEqual([]) }) - it('control vs runtime chunk graphs are disjoint (modulo shared constants chunk)', () => { + it('control vs runtime chunk graphs are disjoint (modulo pure metadata chunks)', () => { const controlChunks = new Set(collectGraph('control.js').keys()) const runtimeChunks = new Set(collectGraph('runtime.js').keys()) controlChunks.delete('control.js') @@ -347,107 +226,4 @@ describe('bundling isolation', () => { `control & runtime share unexpected chunks: ${unexpectedShared.join(', ')}`, ).toEqual([]) }) - - it('v3.js does not pull contract-space artefacts', () => { - const entry = readChunk('v3.js') - const leaks = findLeaksInEntry(entry, RUNTIME_FORBIDDEN) - expect(leaks, `v3 entry leaks: ${leaks.join(', ')}`).toEqual([]) - }) - - it('no chunk in the v3 graph references the v2 composite-literal wire (version-neutral execution chunk exempted)', () => { - // COMPLETE-graph scan, not just the entry body: a cross-plane leak - // usually arrives through a transitive chunk import, which an - // entry-body substring check never sees. `v3.js` legitimately - // shares the version-neutral execution chunk (envelope - // base/classes, routing, abort, `stampRoutingKeysFromAst`) with the - // v2 entries — that chunk currently also DEFINES the v2 wire - // encoder, so it is exempted here by content and pinned against v3 - // contamination by the per-chunk disjointness test below. - const leaks = findLeaksInGraph( - 'v3.js', - V2_WIRE_MARKERS, - definesVersionNeutralExecutionChunk, - ) - expect(leaks, `v3 graph leaks v2 wire: ${leaks.join(', ')}`).toEqual([]) - }) - - it('no chunk in the v3 graph references any v2 codec-runtime factory', () => { - // All six `createCipherstash*Codec` factories (built on the - // composite wire), against every chunk in the v3 graph — including - // the version-neutral execution chunk, which must never grow one. - const leaks = findLeaksInGraph('v3.js', V2_CODEC_FACTORY_MARKERS) - expect( - leaks, - `v3 graph reaches v2 codec-runtime factories: ${leaks.join(', ')}`, - ).toEqual([]) - }) - - it('no chunk in the middleware.js graph (v2 bulk-encrypt entry) references the v3 wire plane', () => { - const leaks = findLeaksInGraph('middleware.js', V3_WIRE_MARKERS) - expect( - leaks, - `middleware graph leaks v3 wire: ${leaks.join(', ')}`, - ).toEqual([]) - }) - - it('no code-split chunk mixes the v2 composite wire with the v3 JSONB wire', () => { - // Chunk-level two-wires-never-co-located pin. A chunk "references a - // wire plane" when any of that plane's marker identifiers appears in - // its body — which covers both defining the symbol and importing it - // by name from another chunk (`import { encodeEqlV2EncryptedWire } - // from "./chunk-*.js"`). Entry files are exempt: `runtime.js` and - // `stack.js` deliberately aggregate the v2 AND v3 public API (each - // client picks one at construction time); the invariant is that no - // shared IMPLEMENTATION chunk fuses the two wire codecs, which is - // what would let one wire's encode path silently reach the other's - // consumers. - const chunkFiles = readdirSync(DIST).filter((f) => - SHARED_CHUNK_PATTERN.test(f), - ) - expect(chunkFiles.length).toBeGreaterThan(0) - const mixed = chunkFiles.filter((file) => { - const body = readChunk(file).body - const referencesV2Wire = V2_WIRE_MARKERS.some((m) => body.includes(m)) - const referencesV3Wire = V3_WIRE_MARKERS.some((m) => body.includes(m)) - return referencesV2Wire && referencesV3Wire - }) - expect( - mixed, - `chunk(s) mix v2 and v3 wire planes: ${mixed.join(', ')}`, - ).toEqual([]) - }) - - it('v3-wire chunks reachable from runtime.js never reference the v2 composite encoder', () => { - // The plan's original Task-10 assertion, made chunk-name-agnostic: - // identify v3 chunks by CONTENT (they carry a v3 wire marker), not - // by tsup's hash-named files. - const runtimeChunks = collectGraph('runtime.js') - for (const [file, chunk] of runtimeChunks) { - if (file === 'runtime.js') continue - const isV3WireChunk = V3_WIRE_MARKERS.some((m) => chunk.body.includes(m)) - if (!isV3WireChunk) continue - const leaks = V2_WIRE_MARKERS.filter((m) => chunk.body.includes(m)) - expect( - leaks, - `v3 chunk ${file} references v2 wire: ${leaks.join(', ')}`, - ).toEqual([]) - } - }) - - it('control vs middleware chunk graphs are disjoint (modulo shared constants chunk)', () => { - const controlChunks = new Set(collectGraph('control.js').keys()) - const middlewareChunks = new Set(collectGraph('middleware.js').keys()) - controlChunks.delete('control.js') - middlewareChunks.delete('middleware.js') - const intersection = [...controlChunks].filter((f) => - middlewareChunks.has(f), - ) - const unexpectedShared = intersection.filter( - (f) => !isAllowedSharedChunk(f), - ) - expect( - unexpectedShared, - `control & middleware share unexpected chunks: ${unexpectedShared.join(', ')}`, - ).toEqual([]) - }) }) diff --git a/packages/prisma-next/test/call-classes.test.ts b/packages/prisma-next/test/call-classes.test.ts deleted file mode 100644 index 75575d79f..000000000 --- a/packages/prisma-next/test/call-classes.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Cipherstash migration IR call classes. - * - * Each `*Call` is a renderable node implementing the framework - * `OpFactoryCall` interface. The class carries the literal arguments its - * backing factory would receive, computes a human-readable `label` in its - * constructor, and exposes: - * - * - `toOp()` — produces the runtime op shape that the codec hook used - * to build via `buildAddOp` / `buildRemoveOp`. - * - `renderTypeScript()` — emits a `cipherstashAddSearchConfig({...})` - * / `cipherstashRemoveSearchConfig({...})` factory call so the - * generated `migration.ts` reads as a normal authored migration. - * - `importRequirements()` — declares the factory symbol pulled from - * `@prisma-next/extension-cipherstash/migration`. - */ - -import { describe, expect, it } from 'vitest' -import { - CipherstashAddSearchConfigCall, - CipherstashRemoveSearchConfigCall, - type CipherstashSearchIndex, - cipherstashAddSearchConfig, - cipherstashRemoveSearchConfig, -} from '../src/migration/call-classes' - -const TABLE = 'user' -const FIELD = 'email' -const MIGRATION_MODULE = '@prisma-next/extension-cipherstash/migration' - -describe('CipherstashAddSearchConfigCall', () => { - it('exposes factoryName, operationClass and label per (table, field, index)', () => { - const call = new CipherstashAddSearchConfigCall(TABLE, FIELD, 'unique') - expect(call.factoryName).toBe('cipherstashAddSearchConfig') - expect(call.operationClass).toBe('additive') - expect(call.label).toBe(`Enable cipherstash search on ${TABLE}.${FIELD}`) - }) - - it('toOp() produces the canonical add_search_config@v1 op shape', () => { - const call = new CipherstashAddSearchConfigCall(TABLE, FIELD, 'unique') - expect(call.toOp()).toEqual({ - id: `cipherstash-codec.${TABLE}.${FIELD}.add-search-config.unique`, - label: `Enable cipherstash search on ${TABLE}.${FIELD}`, - operationClass: 'additive', - invariantId: `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:unique@v1`, - target: { id: 'postgres' }, - precheck: [], - execute: [ - { - description: `Register cipherstash unique search config for ${TABLE}.${FIELD}`, - sql: `SELECT eql_v2.add_search_config('${TABLE}', '${FIELD}', 'unique', 'text');`, - }, - ], - postcheck: [], - }) - }) - - it("toOp() embeds 'match' when the index is 'match'", () => { - const call = new CipherstashAddSearchConfigCall(TABLE, FIELD, 'match') - const op = call.toOp() - expect(op.id).toBe( - `cipherstash-codec.${TABLE}.${FIELD}.add-search-config.match`, - ) - expect(op.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:match@v1`, - ) - expect(op.execute[0]!.sql).toContain(`'match'`) - }) - - it("toOp() defaults the cast type to 'text'", () => { - const call = new CipherstashAddSearchConfigCall(TABLE, FIELD, 'unique') - expect(call.toOp().execute[0]!.sql).toContain(`, 'text')`) - }) - - it('toOp() honours an explicit castAs override', () => { - const call = new CipherstashAddSearchConfigCall( - TABLE, - FIELD, - 'unique', - 'jsonb', - ) - expect(call.toOp().execute[0]!.sql).toContain(`, 'jsonb')`) - }) - - it('toOp() escapes embedded single quotes in identifiers', () => { - const call = new CipherstashAddSearchConfigCall("us'er", "em'ail", 'unique') - expect(call.toOp().execute[0]!.sql).toContain("'us''er'") - expect(call.toOp().execute[0]!.sql).toContain("'em''ail'") - }) - - it("renderTypeScript() emits cipherstashAddSearchConfig({...}) without castAs when 'text'", () => { - const call = new CipherstashAddSearchConfigCall(TABLE, FIELD, 'unique') - expect(call.renderTypeScript()).toBe( - `cipherstashAddSearchConfig({ table: "${TABLE}", column: "${FIELD}", index: "unique" })`, - ) - }) - - it('renderTypeScript() emits castAs only when it differs from the default', () => { - const call = new CipherstashAddSearchConfigCall( - TABLE, - FIELD, - 'match', - 'jsonb', - ) - expect(call.renderTypeScript()).toBe( - `cipherstashAddSearchConfig({ table: "${TABLE}", column: "${FIELD}", index: "match", castAs: "jsonb" })`, - ) - }) - - it('importRequirements() pulls cipherstashAddSearchConfig from the /migration subpath', () => { - const call = new CipherstashAddSearchConfigCall(TABLE, FIELD, 'unique') - expect(call.importRequirements()).toEqual([ - { - moduleSpecifier: MIGRATION_MODULE, - symbol: 'cipherstashAddSearchConfig', - }, - ]) - }) - - it('is frozen at construction', () => { - const call = new CipherstashAddSearchConfigCall(TABLE, FIELD, 'unique') - expect(Object.isFrozen(call)).toBe(true) - }) -}) - -describe('CipherstashRemoveSearchConfigCall', () => { - it('exposes factoryName, operationClass and label per (table, field, index)', () => { - const call = new CipherstashRemoveSearchConfigCall(TABLE, FIELD, 'match') - expect(call.factoryName).toBe('cipherstashRemoveSearchConfig') - expect(call.operationClass).toBe('destructive') - expect(call.label).toBe(`Disable cipherstash search on ${TABLE}.${FIELD}`) - }) - - it('toOp() produces the canonical remove_search_config@v1 op shape', () => { - const call = new CipherstashRemoveSearchConfigCall(TABLE, FIELD, 'unique') - expect(call.toOp()).toEqual({ - id: `cipherstash-codec.${TABLE}.${FIELD}.remove-search-config.unique`, - label: `Disable cipherstash search on ${TABLE}.${FIELD}`, - operationClass: 'destructive', - invariantId: `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:unique@v1`, - target: { id: 'postgres' }, - precheck: [], - execute: [ - { - description: `Remove cipherstash unique search config for ${TABLE}.${FIELD}`, - sql: `SELECT eql_v2.remove_search_config('${TABLE}', '${FIELD}', 'unique');`, - }, - ], - postcheck: [], - }) - }) - - it('renderTypeScript() emits cipherstashRemoveSearchConfig({...}) (castAs is irrelevant)', () => { - const call = new CipherstashRemoveSearchConfigCall(TABLE, FIELD, 'match') - expect(call.renderTypeScript()).toBe( - `cipherstashRemoveSearchConfig({ table: "${TABLE}", column: "${FIELD}", index: "match" })`, - ) - }) - - it('importRequirements() pulls cipherstashRemoveSearchConfig from the /migration subpath', () => { - const call = new CipherstashRemoveSearchConfigCall(TABLE, FIELD, 'unique') - expect(call.importRequirements()).toEqual([ - { - moduleSpecifier: MIGRATION_MODULE, - symbol: 'cipherstashRemoveSearchConfig', - }, - ]) - }) - - it('is frozen at construction', () => { - const call = new CipherstashRemoveSearchConfigCall(TABLE, FIELD, 'unique') - expect(Object.isFrozen(call)).toBe(true) - }) -}) - -describe('cipherstashAddSearchConfig / cipherstashRemoveSearchConfig factories', () => { - it('cipherstashAddSearchConfig constructs an Add call with the given args', () => { - const call = cipherstashAddSearchConfig({ - table: TABLE, - column: FIELD, - index: 'unique', - }) - expect(call).toBeInstanceOf(CipherstashAddSearchConfigCall) - expect(call.toOp().invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:unique@v1`, - ) - }) - - it('cipherstashAddSearchConfig honours an explicit castAs override', () => { - const call = cipherstashAddSearchConfig({ - table: TABLE, - column: FIELD, - index: 'unique', - castAs: 'jsonb', - }) - expect(call.toOp().execute[0]!.sql).toContain(`, 'jsonb')`) - expect(call.renderTypeScript()).toContain('castAs: "jsonb"') - }) - - it('cipherstashRemoveSearchConfig constructs a Remove call with the given args', () => { - const call = cipherstashRemoveSearchConfig({ - table: TABLE, - column: FIELD, - index: 'match', - }) - expect(call).toBeInstanceOf(CipherstashRemoveSearchConfigCall) - expect(call.toOp().invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:match@v1`, - ) - }) - - it('CipherstashSearchIndex narrows to the two supported indices', () => { - const indices: readonly CipherstashSearchIndex[] = ['unique', 'match'] - expect(indices).toEqual(['unique', 'match']) - }) -}) diff --git a/packages/prisma-next/test/call-classes.types.test-d.ts b/packages/prisma-next/test/call-classes.types.test-d.ts deleted file mode 100644 index 4a775e825..000000000 --- a/packages/prisma-next/test/call-classes.types.test-d.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Type-shape tests pinning `CipherstashSearchIndex` to the full EQL - * `add_search_config` index vocabulary used across every cipherstash - * codec (string, double, bigint, date, boolean, json). - * - * Negative cases use `@ts-expect-error` per `AGENTS.md § Typesafety - * rules` — the documented carve-out for negative type tests. - */ - -import { - type CipherstashSearchIndex, - cipherstashAddSearchConfig, - cipherstashRemoveSearchConfig, -} from '../src/migration/call-classes' - -// --- Positive: every EQL index name is an inhabitant of the union. ----- - -const _unique: CipherstashSearchIndex = 'unique' -const _match: CipherstashSearchIndex = 'match' -const _ore: CipherstashSearchIndex = 'ore' -const _steVec: CipherstashSearchIndex = 'ste_vec' -void _unique -void _match -void _ore -void _steVec - -// The factory functions accept all four index names without per-codec -// changes — the widening is purely a type-union extension; the factory -// bodies already accept arbitrary `index: string` at runtime. -void cipherstashAddSearchConfig({ table: 't', column: 'c', index: 'unique' }) -void cipherstashAddSearchConfig({ table: 't', column: 'c', index: 'match' }) -void cipherstashAddSearchConfig({ table: 't', column: 'c', index: 'ore' }) -void cipherstashAddSearchConfig({ table: 't', column: 'c', index: 'ste_vec' }) - -void cipherstashRemoveSearchConfig({ table: 't', column: 'c', index: 'unique' }) -void cipherstashRemoveSearchConfig({ table: 't', column: 'c', index: 'match' }) -void cipherstashRemoveSearchConfig({ table: 't', column: 'c', index: 'ore' }) -void cipherstashRemoveSearchConfig({ - table: 't', - column: 'c', - index: 'ste_vec', -}) - -// --- Negative: an index name outside the EQL vocabulary is rejected. --- - -// @ts-expect-error — `'btree'` is not in the EQL search-config index -// vocabulary; the union exists precisely to catch typos at the -// authoring boundary. -const _bogus: CipherstashSearchIndex = 'btree' -void _bogus - -// @ts-expect-error — same negative case routed through the factory: -// no `index` value outside the union compiles. -void cipherstashAddSearchConfig({ table: 't', column: 'c', index: 'btree' }) diff --git a/packages/prisma-next/test/cipherstash-codec-numeric.test.ts b/packages/prisma-next/test/cipherstash-codec-numeric.test.ts deleted file mode 100644 index 195c9265f..000000000 --- a/packages/prisma-next/test/cipherstash-codec-numeric.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Codec lifecycle hook tests for the numeric cipherstash codecs - * (`cipherstash/double@1`, `cipherstash/bigint@1`). - * - * Numeric codecs share the `{ equality, orderAndRange }` flag set; the - * only delta between them is the `cast_as` argument - * (`'double'` vs `'big_int'`). - * - * `invariantId` template (shared with the string codec): - * `cipherstash-codec:
.::@v1` - */ - -import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control' -import type { StorageColumn } from '@prisma-next/sql-contract/types' -import { describe, expect, it } from 'vitest' -import { - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, -} from '../src/extension-metadata/constants' -import { - cipherstashBigIntCodecHooks, - cipherstashDoubleCodecHooks, -} from '../src/migration/cipherstash-codec' - -const TABLE = 'User' -const FIELD = 'email' - -describe('cipherstashDoubleCodecHooks — flag → index mapping', () => { - function ctxNumeric(args: { - prior?: Partial | undefined - next?: Partial | undefined - codecId: string - }): { - readonly namespaceId: string - readonly tableName: string - readonly fieldName: string - readonly priorField?: StorageColumn - readonly newField?: StorageColumn - } { - const baseCol: StorageColumn = { - codecId: args.codecId, - nativeType: 'eql_v2_encrypted', - nullable: false, - } - return { - namespaceId: '__unbound__', - tableName: TABLE, - fieldName: FIELD, - ...(args.prior !== undefined - ? { priorField: { ...baseCol, ...args.prior } } - : {}), - ...(args.next !== undefined - ? { newField: { ...baseCol, ...args.next } } - : {}), - } - } - - const onFieldEvent = ( - event: 'added' | 'dropped' | 'altered', - args: { prior?: Partial; next?: Partial }, - ): readonly SqlMigrationPlanOperation[] => - cipherstashDoubleCodecHooks.onFieldEvent!( - event, - ctxNumeric({ ...args, codecId: CIPHERSTASH_DOUBLE_CODEC_ID }), - ).map((c) => c.toOp() as SqlMigrationPlanOperation) - - it("emits add_search_config(unique) with cast_as='double' when equality flips on", () => { - const ops = onFieldEvent('added', { - next: { typeParams: { equality: true } }, - }) - expect(ops).toHaveLength(1) - expect(ops[0]!.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:unique@v1`, - ) - expect(ops[0]!.execute[0]!.sql).toContain(`'unique'`) - expect(ops[0]!.execute[0]!.sql).toContain(`'double'`) - }) - - it("emits add_search_config(ore) with cast_as='double' when orderAndRange flips on", () => { - const ops = onFieldEvent('added', { - next: { typeParams: { orderAndRange: true } }, - }) - expect(ops).toHaveLength(1) - expect(ops[0]!.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:ore@v1`, - ) - expect(ops[0]!.execute[0]!.sql).toContain(`'ore'`) - expect(ops[0]!.execute[0]!.sql).toContain(`'double'`) - }) - - it('emits one op per enabled flag when both are true', () => { - const ops = onFieldEvent('added', { - next: { typeParams: { equality: true, orderAndRange: true } }, - }) - expect(ops).toHaveLength(2) - const ids = ops.map((o) => o.invariantId).sort() - expect(ids).toEqual([ - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:ore@v1`, - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:unique@v1`, - ]) - }) - - it('emits remove ops on drop for previously-enabled flags', () => { - const ops = onFieldEvent('dropped', { - prior: { typeParams: { equality: true, orderAndRange: true } }, - }) - expect(ops).toHaveLength(2) - const ids = ops.map((o) => o.invariantId).sort() - expect(ids).toEqual([ - `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:ore@v1`, - `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:unique@v1`, - ]) - }) - - it('emits no ops when freeTextSearch is set (the string-only flag is silently ignored)', () => { - // Numeric codecs do not register `freeTextSearch` in their - // `flagToIndex`, so a stale `freeTextSearch: true` slot in - // `typeParams` produces no ops. Authoring-time PSL/TS rejection - // catches the mistake earlier — see psl-interpretation.test.ts. - expect( - onFieldEvent('added', { next: { typeParams: { freeTextSearch: true } } }), - ).toEqual([]) - }) -}) - -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: { - codecId: CIPHERSTASH_BIGINT_CODEC_ID, - nativeType: 'eql_v2_encrypted', - nullable: false, - typeParams: { equality: true }, - } as StorageColumn, - } - const ops = cipherstashBigIntCodecHooks.onFieldEvent!('added', ctxArg).map( - (c) => c.toOp() as SqlMigrationPlanOperation, - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.execute[0]!.sql).toContain(`'unique'`) - expect(ops[0]!.execute[0]!.sql).toContain(`'big_int'`) - }) -}) diff --git a/packages/prisma-next/test/cipherstash-codec-other-codecs.test.ts b/packages/prisma-next/test/cipherstash-codec-other-codecs.test.ts deleted file mode 100644 index 103ecc3e5..000000000 --- a/packages/prisma-next/test/cipherstash-codec-other-codecs.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Codec lifecycle hook tests for the date, boolean, and JSON - * cipherstash codecs. - * - * Each codec exposes a narrower flag set than the string codec: - * - * - `cipherstash/date@1` — `{ equality, orderAndRange }`, cast_as=date - * - `cipherstash/boolean@1` — `{ equality }` only, cast_as=boolean - * - `cipherstash/json@1` — `{ searchableJson }`, cast_as=jsonb - * - * `invariantId` template (shared with the string codec): - * `cipherstash-codec:
.::@v1` - */ - -import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control' -import type { StorageColumn } from '@prisma-next/sql-contract/types' -import { describe, expect, it } from 'vitest' -import { - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, -} from '../src/extension-metadata/constants' -import { - cipherstashBooleanCodecHooks, - cipherstashDateCodecHooks, - cipherstashJsonCodecHooks, -} from '../src/migration/cipherstash-codec' - -const TABLE = 'User' -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: { - codecId: CIPHERSTASH_DATE_CODEC_ID, - nativeType: 'eql_v2_encrypted', - nullable: false, - typeParams: { equality: true, orderAndRange: true }, - } as StorageColumn, - } - const ops = cipherstashDateCodecHooks.onFieldEvent!('added', ctxArg).map( - (c) => c.toOp() as SqlMigrationPlanOperation, - ) - expect(ops).toHaveLength(2) - const sqls = ops.map((o) => o.execute[0]!.sql) - expect(sqls.some((s) => s.includes(`'unique'`))).toBe(true) - expect(sqls.some((s) => s.includes(`'ore'`))).toBe(true) - for (const s of sqls) expect(s).toContain(`'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: { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - nativeType: 'eql_v2_encrypted', - nullable: false, - typeParams: { equality: true }, - } as StorageColumn, - } - const ops = cipherstashBooleanCodecHooks.onFieldEvent!('added', ctxArg).map( - (c) => c.toOp() as SqlMigrationPlanOperation, - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.execute[0]!.sql).toContain(`'unique'`) - expect(ops[0]!.execute[0]!.sql).toContain(`'boolean'`) - }) - - it('does not emit ore ops — booleans have no orderAndRange flag', () => { - const ctxArg = { - namespaceId: '__unbound__', - tableName: TABLE, - fieldName: FIELD, - newField: { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - nativeType: 'eql_v2_encrypted', - nullable: false, - typeParams: { equality: true, orderAndRange: true }, - } as StorageColumn, - } - const ops = cipherstashBooleanCodecHooks.onFieldEvent!('added', ctxArg).map( - (c) => c.toOp() as SqlMigrationPlanOperation, - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.execute[0]!.sql).not.toContain(`'ore'`) - }) -}) - -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: { - codecId: CIPHERSTASH_JSON_CODEC_ID, - nativeType: 'eql_v2_encrypted', - nullable: false, - typeParams: { searchableJson: true }, - } as StorageColumn, - } - const ops = cipherstashJsonCodecHooks.onFieldEvent!('added', ctxArg).map( - (c) => c.toOp() as SqlMigrationPlanOperation, - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.execute[0]!.sql).toContain(`'ste_vec'`) - expect(ops[0]!.execute[0]!.sql).toContain(`'jsonb'`) - }) - - it('emits remove_search_config(ste_vec) on drop when searchableJson was previously enabled', () => { - const ctxArg = { - namespaceId: '__unbound__', - tableName: TABLE, - fieldName: FIELD, - priorField: { - codecId: CIPHERSTASH_JSON_CODEC_ID, - nativeType: 'eql_v2_encrypted', - nullable: false, - typeParams: { searchableJson: true }, - } as StorageColumn, - } - const ops = cipherstashJsonCodecHooks.onFieldEvent!('dropped', ctxArg).map( - (c) => c.toOp() as SqlMigrationPlanOperation, - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.execute[0]!.sql).toContain('eql_v2.remove_search_config') - expect(ops[0]!.execute[0]!.sql).toContain(`'ste_vec'`) - }) -}) diff --git a/packages/prisma-next/test/cipherstash-codec-string.test.ts b/packages/prisma-next/test/cipherstash-codec-string.test.ts deleted file mode 100644 index 52cc22bab..000000000 --- a/packages/prisma-next/test/cipherstash-codec-string.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Codec lifecycle hook tests for `cipherstash:string@1`. - * - * Each enabled flag in the column's `typeParams` - * maps to its own EQL search-config index: - * - * - `equality: true` → `'unique'` index - * - `freeTextSearch: true` → `'match'` index - * - `orderAndRange: true` → `'ore'` index - * - * The codec hook emits **one `add_search_config@v1` op per enabled - * flag** — each op is independently invertible by - * a paired `remove_search_config@v1` op carrying the same index name, - * which keeps the op-graph simple and the diff per-flag granular. - * - * `'altered'` events decompose into per-flag adds and removes against - * the prior side: a flag flipped on emits an add op for that index, a - * flag flipped off emits a remove op. Flags whose enabled state did - * not change yield no op (the index already matches the desired - * configuration). - * - * `invariantId` template: - * `cipherstash-codec:
.::@v1` - * - * Stable across regenerations — every input is deterministic. - */ - -import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control' -import type { StorageColumn } from '@prisma-next/sql-contract/types' -import { describe, expect, it } from 'vitest' -import { CIPHERSTASH_STRING_CODEC_ID } from '../src/extension-metadata/constants' -import { cipherstashStringCodecHooks } from '../src/migration/cipherstash-codec' - -const TABLE = 'User' -const FIELD = 'email' - -function ctx(args: { - prior?: Partial | undefined - next?: Partial | undefined - tableName?: string - fieldName?: string -}): { - readonly namespaceId: string - readonly tableName: string - readonly fieldName: string - readonly priorField?: StorageColumn - readonly newField?: StorageColumn -} { - const baseCol: StorageColumn = { - codecId: CIPHERSTASH_STRING_CODEC_ID, - nativeType: 'eql_v2_encrypted', - nullable: false, - } - return { - namespaceId: '__unbound__', - tableName: args.tableName ?? TABLE, - fieldName: args.fieldName ?? FIELD, - ...(args.prior !== undefined - ? { priorField: { ...baseCol, ...args.prior } } - : {}), - ...(args.next !== undefined - ? { newField: { ...baseCol, ...args.next } } - : {}), - } -} - -describe('cipherstashStringCodecHooks.onFieldEvent — flag → index mapping', () => { - // The hook returns `OpFactoryCall` instances (ADR 195). These tests - // verify the runtime op shape, so we lower each Call to its op via - // `.toOp()` once at the test boundary and assert against the - // resulting array. Render-side / class-side coverage lives in - // migration-call-classes.test.ts. - const onFieldEventCalls = cipherstashStringCodecHooks.onFieldEvent! - const onFieldEvent: ( - ...args: Parameters - ) => readonly SqlMigrationPlanOperation[] = (...args) => - onFieldEventCalls(...args).map( - (c) => c.toOp() as SqlMigrationPlanOperation, - ) - - describe("event 'added' — one add op per enabled flag", () => { - it('emits add_search_config(unique) when typeParams.equality is true', () => { - const ops = onFieldEvent( - 'added', - ctx({ next: { typeParams: { equality: true } } }), - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:unique@v1`, - ) - expect(ops[0]!.execute[0]!.sql).toContain('eql_v2.add_search_config') - expect(ops[0]!.execute[0]!.sql).toContain(`'unique'`) - expect(ops[0]!.execute[0]!.sql).toContain(`'${TABLE}'`) - expect(ops[0]!.execute[0]!.sql).toContain(`'${FIELD}'`) - }) - - it('emits add_search_config(match) when typeParams.freeTextSearch is true', () => { - const ops = onFieldEvent( - 'added', - ctx({ next: { typeParams: { freeTextSearch: true } } }), - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:match@v1`, - ) - expect(ops[0]!.execute[0]!.sql).toContain(`'match'`) - }) - - it('emits add_search_config(ore) when typeParams.orderAndRange is true', () => { - const ops = onFieldEvent( - 'added', - ctx({ next: { typeParams: { orderAndRange: true } } }), - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:ore@v1`, - ) - expect(ops[0]!.execute[0]!.sql).toContain(`'ore'`) - }) - - it('emits one op per enabled flag when both flags are true', () => { - const ops = onFieldEvent( - 'added', - ctx({ next: { typeParams: { equality: true, freeTextSearch: true } } }), - ) - expect(ops).toHaveLength(2) - const invariantIds = ops.map((op) => op.invariantId).sort() - expect(invariantIds).toEqual([ - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:match@v1`, - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:unique@v1`, - ]) - }) - - it('emits nothing when no flag is enabled', () => { - expect(onFieldEvent('added', ctx({ next: {} }))).toEqual([]) - expect(onFieldEvent('added', ctx({ next: { typeParams: {} } }))).toEqual( - [], - ) - expect( - onFieldEvent( - 'added', - ctx({ - next: { typeParams: { equality: false, freeTextSearch: false } }, - }), - ), - ).toEqual([]) - }) - }) - - describe("event 'dropped' — one remove op per previously-enabled flag", () => { - it('emits remove_search_config(unique) when prior typeParams.equality was true', () => { - const ops = onFieldEvent( - 'dropped', - ctx({ prior: { typeParams: { equality: true } } }), - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:unique@v1`, - ) - expect(ops[0]!.execute[0]!.sql).toContain('eql_v2.remove_search_config') - expect(ops[0]!.execute[0]!.sql).toContain(`'unique'`) - }) - - it('emits remove_search_config(match) when prior typeParams.freeTextSearch was true', () => { - const ops = onFieldEvent( - 'dropped', - ctx({ prior: { typeParams: { freeTextSearch: true } } }), - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:match@v1`, - ) - expect(ops[0]!.execute[0]!.sql).toContain(`'match'`) - }) - - it('emits one remove op per previously-enabled flag when both flags were true', () => { - const ops = onFieldEvent( - 'dropped', - ctx({ - prior: { typeParams: { equality: true, freeTextSearch: true } }, - }), - ) - expect(ops).toHaveLength(2) - const invariantIds = ops.map((op) => op.invariantId).sort() - expect(invariantIds).toEqual([ - `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:match@v1`, - `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:unique@v1`, - ]) - }) - - it('emits nothing when prior column had no flags enabled', () => { - expect(onFieldEvent('dropped', ctx({ prior: {} }))).toEqual([]) - expect( - onFieldEvent( - 'dropped', - ctx({ prior: { typeParams: { equality: false } } }), - ), - ).toEqual([]) - }) - }) - - describe("event 'altered' — per-flag delta against the prior side", () => { - it('emits an add op only for flags newly enabled', () => { - const ops = onFieldEvent( - 'altered', - ctx({ - prior: { typeParams: { equality: false, freeTextSearch: false } }, - next: { typeParams: { equality: true, freeTextSearch: false } }, - }), - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:unique@v1`, - ) - }) - - it('emits a remove op only for flags newly disabled', () => { - const ops = onFieldEvent( - 'altered', - ctx({ - prior: { typeParams: { equality: true, freeTextSearch: false } }, - next: { typeParams: { equality: false, freeTextSearch: false } }, - }), - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.invariantId).toBe( - `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:unique@v1`, - ) - }) - - it('emits an add and a remove op when one flag flips on while another flips off', () => { - const ops = onFieldEvent( - 'altered', - ctx({ - prior: { typeParams: { equality: true, freeTextSearch: false } }, - next: { typeParams: { equality: false, freeTextSearch: true } }, - }), - ) - expect(ops).toHaveLength(2) - const invariantIds = ops.map((op) => op.invariantId).sort() - expect(invariantIds).toEqual([ - `cipherstash-codec:${TABLE}.${FIELD}:add-search-config:match@v1`, - `cipherstash-codec:${TABLE}.${FIELD}:remove-search-config:unique@v1`, - ]) - }) - - it('emits nothing when flags are unchanged', () => { - const same = { equality: true, freeTextSearch: true } - expect( - onFieldEvent( - 'altered', - ctx({ prior: { typeParams: same }, next: { typeParams: same } }), - ), - ).toEqual([]) - }) - - it('emits nothing when neither side has flags enabled', () => { - expect( - onFieldEvent( - 'altered', - ctx({ - prior: { typeParams: {} }, - next: { typeParams: { other: 1 } }, - }), - ), - ).toEqual([]) - }) - }) - - describe('operation labels (first-time-user-readable)', () => { - it('add op label is action-first / column-first and free of extension jargon', () => { - const [op] = onFieldEvent( - 'added', - ctx({ next: { typeParams: { equality: true } } }), - ) - expect(op!.label).toBe(`Enable cipherstash search on ${TABLE}.${FIELD}`) - // Legacy wording must not reappear (regression bar). - expect(op!.label).not.toContain('Register cipherstash search config') - }) - - it('remove op label is action-first / column-first', () => { - const [op] = onFieldEvent( - 'dropped', - ctx({ prior: { typeParams: { equality: true } } }), - ) - expect(op!.label).toBe(`Disable cipherstash search on ${TABLE}.${FIELD}`) - expect(op!.label).not.toContain('Remove cipherstash search config') - }) - - it('altered op labels stay action-first when adding an index alongside an existing one', () => { - // Codec emits per-flag deltas: flipping `freeTextSearch` on while - // `equality` stays on produces a single add op (the rotate UX is - // expressed as add+remove pairs across flag transitions). - const ops = onFieldEvent( - 'altered', - ctx({ - prior: { typeParams: { equality: true } }, - next: { typeParams: { equality: true, freeTextSearch: true } }, - }), - ) - expect(ops).toHaveLength(1) - expect(ops[0]!.label).toBe( - `Enable cipherstash search on ${TABLE}.${FIELD}`, - ) - expect(ops[0]!.label).not.toContain('Register cipherstash search config') - }) - }) - - describe('invariantId + SQL conventions', () => { - it('namespaces every emitted op under cipherstash-codec:*', () => { - const allOps = [ - ...onFieldEvent( - 'added', - ctx({ - next: { typeParams: { equality: true, freeTextSearch: true } }, - }), - ), - ...onFieldEvent( - 'dropped', - ctx({ - prior: { typeParams: { equality: true, freeTextSearch: true } }, - }), - ), - ...onFieldEvent( - 'altered', - ctx({ - prior: { typeParams: { equality: false, freeTextSearch: true } }, - next: { typeParams: { equality: true, freeTextSearch: false } }, - }), - ), - ] - expect(allOps.length).toBeGreaterThan(0) - for (const op of allOps) { - expect(op.invariantId).toMatch(/^cipherstash-codec:/) - } - }) - - it('escapes embedded apostrophes in table/field identifiers', () => { - const ops = onFieldEvent( - 'added', - ctx({ - tableName: "us'er", - fieldName: "em'ail", - next: { typeParams: { equality: true } }, - }), - ) - expect(ops[0]!.execute[0]!.sql).toContain("'us''er'") - expect(ops[0]!.execute[0]!.sql).toContain("'em''ail'") - }) - - it('classifies add ops as additive and remove ops as destructive', () => { - const adds = onFieldEvent( - 'added', - ctx({ next: { typeParams: { equality: true, freeTextSearch: true } } }), - ) - const removes = onFieldEvent( - 'dropped', - ctx({ - prior: { typeParams: { equality: true, freeTextSearch: true } }, - }), - ) - for (const op of adds) { - expect(op.operationClass).toBe('additive') - } - for (const op of removes) { - expect(op.operationClass).toBe('destructive') - } - }) - }) -}) diff --git a/packages/prisma-next/test/cipherstash-codec.test.ts b/packages/prisma-next/test/cipherstash-codec.test.ts deleted file mode 100644 index 3375bf4c6..000000000 --- a/packages/prisma-next/test/cipherstash-codec.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Wiring tests for the cipherstash extension's codec lifecycle hooks. - * - * Two layers are pinned here: - * - * 1. `cipherstash descriptor wiring` — every codec hook is reachable - * under `types.codecTypes.controlPlaneHooks` on the descriptor, - * and `extractCodecControlHooks` discovers all of them. - * 2. `planFieldEventOperations driving the cipherstash hook` — - * end-to-end through the planner: per-flag add/remove ops are - * inlined on contract diffs, and an unchanged contract yields no - * ops. - * - * Per-codec hook behaviour (flag → index mapping) lives in the - * sibling test files: - * - * - `cipherstash-codec-string.test.ts` - * - `cipherstash-codec-numeric.test.ts` - * - `cipherstash-codec-other-codecs.test.ts` - */ - -import type { Contract, StorageHashBase } from '@prisma-next/contract/types' -import { profileHash } from '@prisma-next/contract/types' -import { - extractCodecControlHooks, - planFieldEventOperations, -} from '@prisma-next/family-sql/control' -import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components' -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' -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, -} from '../src/extension-metadata/constants' -import { - cipherstashBigIntCodecHooks, - cipherstashBooleanCodecHooks, - cipherstashDateCodecHooks, - cipherstashDoubleCodecHooks, - cipherstashJsonCodecHooks, - cipherstashStringCodecHooks, -} from '../src/migration/cipherstash-codec' - -describe('cipherstash descriptor wiring', () => { - it('exposes every codec hook under types.codecTypes.controlPlaneHooks', () => { - const hooks = ( - cipherstashExtensionDescriptor as { - types?: { codecTypes?: { controlPlaneHooks?: Record } } - } - ).types?.codecTypes?.controlPlaneHooks - expect(hooks?.[CIPHERSTASH_STRING_CODEC_ID]).toBe( - cipherstashStringCodecHooks, - ) - expect(hooks?.[CIPHERSTASH_DOUBLE_CODEC_ID]).toBe( - cipherstashDoubleCodecHooks, - ) - expect(hooks?.[CIPHERSTASH_BIGINT_CODEC_ID]).toBe( - cipherstashBigIntCodecHooks, - ) - expect(hooks?.[CIPHERSTASH_DATE_CODEC_ID]).toBe(cipherstashDateCodecHooks) - expect(hooks?.[CIPHERSTASH_BOOLEAN_CODEC_ID]).toBe( - cipherstashBooleanCodecHooks, - ) - expect(hooks?.[CIPHERSTASH_JSON_CODEC_ID]).toBe(cipherstashJsonCodecHooks) - }) - - it('extractCodecControlHooks finds every cipherstash hook on the descriptor', () => { - const map = extractCodecControlHooks([ - cipherstashExtensionDescriptor as unknown as TargetBoundComponentDescriptor< - 'sql', - string - >, - ]) - expect(map.get(CIPHERSTASH_STRING_CODEC_ID)).toBe( - cipherstashStringCodecHooks, - ) - expect(map.get(CIPHERSTASH_DOUBLE_CODEC_ID)).toBe( - cipherstashDoubleCodecHooks, - ) - expect(map.get(CIPHERSTASH_BIGINT_CODEC_ID)).toBe( - cipherstashBigIntCodecHooks, - ) - expect(map.get(CIPHERSTASH_DATE_CODEC_ID)).toBe(cipherstashDateCodecHooks) - expect(map.get(CIPHERSTASH_BOOLEAN_CODEC_ID)).toBe( - cipherstashBooleanCodecHooks, - ) - expect(map.get(CIPHERSTASH_JSON_CODEC_ID)).toBe(cipherstashJsonCodecHooks) - }) -}) - -describe('planFieldEventOperations driving the cipherstash hook', () => { - function userTable(typeParams?: Record): StorageTable { - return { - columns: { - id: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, - email: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - nativeType: 'eql_v2_encrypted', - nullable: false, - ...ifDefined('typeParams', typeParams), - }, - }, - uniques: [], - indexes: [], - foreignKeys: [], - } - } - - function build(tables: Record): Contract { - return { - target: 'postgres', - targetFamily: 'sql', - profileHash: profileHash('sha256:test'), - storage: new SqlStorage({ - storageHash: 'sha256:test' as StorageHashBase, - namespaces: { - __unbound__: buildSqlNamespace({ - id: '__unbound__', - entries: { table: tables }, - }), - }, - }), - domain: { namespaces: { __unbound__: { models: {} } } }, - roots: {}, - capabilities: {}, - extensionPacks: {}, - meta: {}, - } - } - - const codecHooks = extractCodecControlHooks([ - cipherstashExtensionDescriptor as unknown as TargetBoundComponentDescriptor< - 'sql', - string - >, - ]) - - it('inlines per-flag add ops on first emit (priorContract null) when flags are enabled', async () => { - const ops = planFieldEventOperations({ - priorContract: null, - newContract: build({ - User: userTable({ equality: true, freeTextSearch: true }), - }), - codecHooks, - }) - expect(ops).toHaveLength(2) - 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', async () => { - const prior = build({ - User: userTable({ equality: true, freeTextSearch: true }), - }) - const newer = build({ - User: { ...userTable(), columns: { id: userTable().columns['id']! } }, - }) - const ops = planFieldEventOperations({ - priorContract: prior, - newContract: newer, - codecHooks, - }) - expect(ops).toHaveLength(2) - 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', - ]) - }) - - it('emits nothing when contract is unchanged', () => { - const c = build({ User: userTable({ equality: true }) }) - expect( - planFieldEventOperations({ - priorContract: c, - newContract: c, - codecHooks, - }), - ).toEqual([]) - }) -}) diff --git a/packages/prisma-next/test/codec-runtime.test.ts b/packages/prisma-next/test/codec-runtime.test.ts deleted file mode 100644 index fadb2784a..000000000 --- a/packages/prisma-next/test/codec-runtime.test.ts +++ /dev/null @@ -1,573 +0,0 @@ -/** - * Behavioural tests for the cipherstash storage codec runtime + the - * parameterized codec descriptor. - * - * The codec runtime is constructed via `codec({ ... })` from - * `@prisma-next/sql-relational-core/ast`. Author-side `encode`/`decode` - * are sync; the factory lifts them to Promise-returning at the boundary - * (same pattern pgvector follows). - */ - -import type { SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast' -import { describe, expect, it, vi } from 'vitest' -import { - CIPHERSTASH_STRING_CODEC_ID, - createCipherstashBigIntCodec, - createCipherstashBooleanCodec, - createCipherstashDateCodec, - createCipherstashDoubleCodec, - createCipherstashJsonCodec, - createCipherstashStringCodec, -} from '../src/execution/codec-runtime' -import { EncryptedBigInt } from '../src/execution/envelope-bigint' -import { EncryptedBoolean } from '../src/execution/envelope-boolean' -import { EncryptedDate } from '../src/execution/envelope-date' -import { EncryptedDouble } from '../src/execution/envelope-double' -import { EncryptedJson } from '../src/execution/envelope-json' -import { - EncryptedString, - setHandleCiphertext, -} from '../src/execution/envelope-string' -import { - createParameterizedCodecDescriptors, - encryptedBigIntParamsSchema, - encryptedBooleanParamsSchema, - encryptedDateParamsSchema, - encryptedDoubleParamsSchema, - encryptedJsonParamsSchema, - encryptedStringParamsSchema, -} from '../src/execution/parameterized' -import type { CipherstashSdk } from '../src/execution/sdk' -import { - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, -} from '../src/extension-metadata/constants' -import { bulkEncryptMiddleware } from '../src/middleware/bulk-encrypt' - -function emptySdk(): CipherstashSdk { - return { - decrypt: vi.fn(), - bulkEncrypt: vi.fn(), - bulkDecrypt: vi.fn(), - } -} - -function ctxWithColumn(table: string, name: string): SqlCodecCallContext { - return { column: { table, name } } -} - -const ctxWithoutColumn: SqlCodecCallContext = {} - -describe('createCipherstashStringCodec — registration shape', () => { - it('uses cipherstash/string@1 as the codec id', () => { - const codec = createCipherstashStringCodec(emptySdk()) - expect(codec.id).toBe(CIPHERSTASH_STRING_CODEC_ID) - expect(CIPHERSTASH_STRING_CODEC_ID).toBe('cipherstash/string@1') - }) - - it('targets the eql_v2_encrypted Postgres native type', () => { - const codec = createCipherstashStringCodec(emptySdk()) - expect(codec.descriptor.targetTypes).toEqual(['eql_v2_encrypted']) - const dbMeta = codec.descriptor.meta?.['db'] as - | { sql?: { postgres?: { nativeType?: string } } } - | undefined - expect(dbMeta?.sql?.postgres?.nativeType).toBe('eql_v2_encrypted') - }) - - it('declares cipherstash-namespaced traits but never the framework `equality` trait', () => { - // Regression test: cipherstash columns do NOT advertise the - // framework`s `equality` trait at the codec level — the - // framework`s built-in `eq` is gated on `equality` and lowers to - // standard SQL `=`, which is wrong for EQL ciphers (randomized - // nonces). Equality search on cipherstash columns is delivered - // exclusively via the cipherstash-namespaced `cipherstashEq` - // operator (see `src/execution/operators.ts`). - // - // The cipherstash-namespaced traits (`cipherstash:equality`, - // `cipherstash:order-and-range`, etc.) ARE expected — they are the - // dispatch keys for the cipherstash-namespaced operator surface - // (multi-codec dispatch via `self: { traits: [...] }` in the - // model accessor). They are isolated from framework built-ins by - // the `cipherstash:` prefix. - const codec = createCipherstashStringCodec(emptySdk()) - const traits: ReadonlyArray = codec.descriptor.traits ?? [] - expect(traits.includes('equality')).toBe(false) - expect(traits.includes('cipherstash:equality')).toBe(true) - expect(traits.includes('cipherstash:free-text-search')).toBe(true) - expect(traits.includes('cipherstash:order-and-range')).toBe(true) - }) -}) - -describe('codec.decode(wire, ctx)', () => { - it('constructs an envelope carrying the column identity from ctx.column', async () => { - const sdk = emptySdk() - const codec = createCipherstashStringCodec(sdk) - const wire = `("${JSON.stringify({ c: 'cipher' }).replaceAll('"', '""')}")` - const envelope = await codec.decode(wire, ctxWithColumn('user', 'email')) - expect(envelope).toBeInstanceOf(EncryptedString) - const handle = envelope.expose() - expect(handle.table).toBe('user') - expect(handle.column).toBe('email') - expect(handle.sdk).toBe(sdk) - }) - - it('throws a RUNTIME.DECODE_FAILED envelope when the column routing context is absent', async () => { - const codec = createCipherstashStringCodec(emptySdk()) - const wire = `("${JSON.stringify({}).replaceAll('"', '""')}")` - await expect(codec.decode(wire, ctxWithoutColumn)).rejects.toMatchObject({ - code: 'RUNTIME.DECODE_FAILED', - category: 'RUNTIME', - details: { - codecId: 'cipherstash/string@1', - reason: 'cipherstash-decode-column-context-missing', - }, - }) - }) -}) - -describe('codec.encode(envelope, ctx)', () => { - it('extracts the ciphertext from the envelope handle', async () => { - const codec = createCipherstashStringCodec(emptySdk()) - const envelope = EncryptedString.from('alice@example.com') - const ciphertextPayload = { c: 'cipher', i: { t: 'user', c: 'email' } } - setHandleCiphertext(envelope, ciphertextPayload) - const wire = await codec.encode(envelope, ctxWithoutColumn) - expect(typeof wire).toBe('string') - expect(wire).toBe( - `("${JSON.stringify(ciphertextPayload).replaceAll('"', '""')}")`, - ) - }) - - it('returns the envelope unchanged when ciphertext is missing AND the bulk-encrypt middleware is registered for the sdk', async () => { - // Happy-path encode lifecycle: in the SQL runtime, - // `lower`/`encodeParams` runs before the `beforeExecute` - // middleware chain. Throwing here would block the bulk-encrypt - // middleware from ever running. The codec's contract is therefore - // "return *something* (the envelope itself is sufficient — it - // will be replaced via `params.replaceValues(...)`) when no - // ciphertext is set yet, **provided** the middleware has been - // wired up against the same sdk." - const sdk = emptySdk() - bulkEncryptMiddleware(sdk) // marks `sdk` as registered - const codec = createCipherstashStringCodec(sdk) - const envelope = EncryptedString.from('alice@example.com') - const result = await codec.encode(envelope, ctxWithoutColumn) - expect(result).toBe(envelope) - }) - - it('throws a clear RUNTIME.ENCODE_FAILED envelope when the bulk-encrypt middleware was never constructed against the sdk', async () => { - // Misconfig diagnostic: when the user forgets to construct - // `bulkEncryptMiddleware(sdk)`, the two-pass write path can never - // complete — there's no middleware to fill in the ciphertext. The - // codec surfaces the error at the codec boundary with a copy- - // pasteable wiring snippet, rather than letting the un-encrypted - // envelope reach the pg driver and produce an opaque serialise - // error. - const sdk = emptySdk() // never passed to `bulkEncryptMiddleware` - const codec = createCipherstashStringCodec(sdk) - const envelope = EncryptedString.from('alice@example.com') - await expect(codec.encode(envelope, ctxWithoutColumn)).rejects.toThrow( - /bulkEncryptMiddleware\(sdk\)/, - ) - }) -}) - -describe('codec.descriptor.renderOutputType', () => { - it('returns "EncryptedString"', () => { - const codec = createCipherstashStringCodec(emptySdk()) - expect(codec.descriptor.renderOutputType?.({})).toBe('EncryptedString') - }) -}) - -describe('eql_v2_encrypted wire-format round-trip — wire-format fix', () => { - it('encode then decode preserves the ciphertext payload through composite text format', async () => { - const sdk = emptySdk() - const codec = createCipherstashStringCodec(sdk) - const payload = { - c: 'mBbLh1eMyM/Iq/M=', - i: { t: 'user', c: 'email' }, - v: 2, - } - const envelope = EncryptedString.from('alice@example.com') - setHandleCiphertext(envelope, payload) - - const wire = await codec.encode(envelope, ctxWithoutColumn) - expect(typeof wire).toBe('string') - const wireString = wire as string - expect(wireString.startsWith('("')).toBe(true) - expect(wireString.endsWith('")')).toBe(true) - - const decoded = await codec.decode( - wireString, - ctxWithColumn('user', 'email'), - ) - expect(decoded.expose().ciphertext).toEqual(payload) - }) - - it('decode accepts a pre-parsed { data: ... } row from the pg driver', async () => { - const sdk = emptySdk() - const codec = createCipherstashStringCodec(sdk) - const payload = { c: 'cipher', i: { t: 'user', c: 'email' } } - const decoded = await codec.decode( - { data: payload } as unknown as string, - ctxWithColumn('user', 'email'), - ) - expect(decoded.expose().ciphertext).toEqual(payload) - }) - - it('decode passes through null/undefined unchanged', async () => { - const codec = createCipherstashStringCodec(emptySdk()) - const decoded = await codec.decode( - null as unknown as string, - ctxWithColumn('user', 'email'), - ) - expect(decoded.expose().ciphertext).toBeNull() - }) - - it('encode then decode preserves embedded double quotes via the composite text-format escape', async () => { - const codec = createCipherstashStringCodec(emptySdk()) - const payload = { c: 'has "quotes" inside' } - const envelope = EncryptedString.from('plain') - setHandleCiphertext(envelope, payload) - const wire = await codec.encode(envelope, ctxWithoutColumn) - const wireString = wire as string - expect(wireString.includes('""')).toBe(true) - const decoded = await codec.decode( - wireString, - ctxWithColumn('user', 'email'), - ) - expect(decoded.expose().ciphertext).toEqual(payload) - }) -}) - -describe('createParameterizedCodecDescriptors', () => { - // Pins the full six-descriptor surface — string + double + - // bigint + date + boolean + json — in stable order. - it('exposes the cipherstash/{string,double,bigint,date,boolean,json}@1 descriptors in stable order', () => { - const descriptors = createParameterizedCodecDescriptors(emptySdk()) - expect(descriptors).toHaveLength(6) - expect(descriptors.map((d) => d.codecId)).toEqual([ - CIPHERSTASH_STRING_CODEC_ID, - CIPHERSTASH_DOUBLE_CODEC_ID, - CIPHERSTASH_BIGINT_CODEC_ID, - CIPHERSTASH_DATE_CODEC_ID, - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_JSON_CODEC_ID, - ]) - for (const descriptor of descriptors) { - expect(descriptor.targetTypes).toEqual(['eql_v2_encrypted']) - // Per-codec `cipherstash:*` traits drive the multi-codec - // operator dispatch (see `extension-metadata/constants.ts`); the - // framework `'equality'` trait is intentionally absent across - // every cipherstash codec to keep the wrong-SQL `eq` footgun - // closed (see `equality-trait-removal.test.ts`). - const traits: ReadonlyArray = descriptor.traits ?? [] - expect(traits.includes('equality')).toBe(false) - expect(traits.length).toBeGreaterThan(0) - for (const trait of traits) { - expect(trait.startsWith('cipherstash:')).toBe(true) - } - } - }) - - it('renderOutputType returns the per-codec envelope class name', () => { - const [ - stringDescriptor, - doubleDescriptor, - bigIntDescriptor, - dateDescriptor, - booleanDescriptor, - jsonDescriptor, - ] = createParameterizedCodecDescriptors(emptySdk()) - expect( - stringDescriptor?.renderOutputType?.({ - equality: true, - freeTextSearch: true, - orderAndRange: true, - }), - ).toBe('EncryptedString') - expect( - doubleDescriptor?.renderOutputType?.({ - equality: true, - orderAndRange: true, - }), - ).toBe('EncryptedDouble') - expect( - bigIntDescriptor?.renderOutputType?.({ - equality: true, - orderAndRange: true, - }), - ).toBe('EncryptedBigInt') - expect( - dateDescriptor?.renderOutputType?.({ - equality: true, - orderAndRange: true, - }), - ).toBe('EncryptedDate') - expect(booleanDescriptor?.renderOutputType?.({ equality: true })).toBe( - 'EncryptedBoolean', - ) - expect(jsonDescriptor?.renderOutputType?.({ searchableJson: true })).toBe( - 'EncryptedJson', - ) - }) - - it('paramsSchema accepts { equality, freeTextSearch, orderAndRange } booleans via Standard Schema', () => { - const result = encryptedStringParamsSchema['~standard'].validate({ - equality: true, - freeTextSearch: false, - orderAndRange: true, - }) - if (result instanceof Promise) - throw new Error('expected synchronous validation') - if (result.issues) - throw new Error( - `expected success, got issues: ${JSON.stringify(result.issues)}`, - ) - expect(result.value).toEqual({ - equality: true, - freeTextSearch: false, - orderAndRange: true, - }) - }) - - it('paramsSchema rejects non-boolean fields via Standard Schema', () => { - const result = encryptedStringParamsSchema['~standard'].validate({ - equality: 'yes', - freeTextSearch: false, - orderAndRange: true, - }) - if (result instanceof Promise) - throw new Error('expected synchronous validation') - expect(result.issues?.length).toBeGreaterThan(0) - }) - - it('factory(params)(ctx) yields the codec instance', () => { - const sdk = emptySdk() - const [descriptor] = createParameterizedCodecDescriptors(sdk) - const codecForInstance = descriptor!.factory({ - equality: true, - freeTextSearch: false, - orderAndRange: true, - })({ - name: 'User.email', - }) - expect(codecForInstance.id).toBe(CIPHERSTASH_STRING_CODEC_ID) - }) - - it('numeric paramsSchemas accept { equality, orderAndRange } booleans via Standard Schema', () => { - for (const schema of [ - encryptedDoubleParamsSchema, - encryptedBigIntParamsSchema, - ]) { - const ok = schema['~standard'].validate({ - equality: true, - orderAndRange: false, - }) - if (ok instanceof Promise) - throw new Error('expected synchronous validation') - if (ok.issues) - throw new Error( - `expected success, got issues: ${JSON.stringify(ok.issues)}`, - ) - expect(ok.value).toEqual({ equality: true, orderAndRange: false }) - - const bad = schema['~standard'].validate({ - equality: 'yes', - orderAndRange: true, - }) - if (bad instanceof Promise) - throw new Error('expected synchronous validation') - expect(bad.issues?.length).toBeGreaterThan(0) - } - }) -}) - -describe('createCipherstashDoubleCodec — registration shape', () => { - it('uses cipherstash/double@1 as the codec id and targets eql_v2_encrypted', () => { - const codec = createCipherstashDoubleCodec(emptySdk()) - expect(codec.id).toBe(CIPHERSTASH_DOUBLE_CODEC_ID) - expect(codec.descriptor.targetTypes).toEqual(['eql_v2_encrypted']) - expect(codec.descriptor.traits).toEqual([ - 'cipherstash:equality', - 'cipherstash:order-and-range', - ]) - expect(codec.descriptor.renderOutputType?.({})).toBe('EncryptedDouble') - }) - - it('encode → decode round-trip preserves the ciphertext through the composite text format', async () => { - const sdk = emptySdk() - const codec = createCipherstashDoubleCodec(sdk) - const payload = { - c: 'numeric-cipher', - i: { t: 'metric', c: 'value' }, - v: 2, - } - const envelope = EncryptedDouble.from(3.14) - // The base's `setHandleCiphertext` helper accepts any envelope - // subclass; we re-use the string export as it's the same generic - // helper. (envelope.ts re-exports it; the function itself lives - // in envelope-base.ts and is generic over `T`.) - setHandleCiphertext(envelope, payload) - - const wire = await codec.encode(envelope, ctxWithoutColumn) - const decoded = await codec.decode( - wire as string, - ctxWithColumn('metric', 'value'), - ) - expect(decoded).toBeInstanceOf(EncryptedDouble) - expect(decoded.expose().ciphertext).toEqual(payload) - }) -}) - -describe('createCipherstashBigIntCodec — registration shape', () => { - it('uses cipherstash/bigint@1 as the codec id and targets eql_v2_encrypted', () => { - const codec = createCipherstashBigIntCodec(emptySdk()) - expect(codec.id).toBe(CIPHERSTASH_BIGINT_CODEC_ID) - expect(codec.descriptor.targetTypes).toEqual(['eql_v2_encrypted']) - expect(codec.descriptor.traits).toEqual([ - 'cipherstash:equality', - 'cipherstash:order-and-range', - ]) - expect(codec.descriptor.renderOutputType?.({})).toBe('EncryptedBigInt') - }) - - it('encode → decode round-trip preserves the ciphertext', async () => { - const sdk = emptySdk() - const codec = createCipherstashBigIntCodec(sdk) - const payload = { c: 'bigint-cipher', i: { t: 'ledger', c: 'amount' } } - const envelope = EncryptedBigInt.from(42n) - setHandleCiphertext(envelope, payload) - const wire = await codec.encode(envelope, ctxWithoutColumn) - const decoded = await codec.decode( - wire as string, - ctxWithColumn('ledger', 'amount'), - ) - expect(decoded).toBeInstanceOf(EncryptedBigInt) - expect(decoded.expose().ciphertext).toEqual(payload) - }) -}) - -describe('createCipherstashDateCodec — registration shape + round-trip', () => { - it('uses cipherstash/date@1 as the codec id and targets eql_v2_encrypted', () => { - const codec = createCipherstashDateCodec(emptySdk()) - expect(codec.id).toBe(CIPHERSTASH_DATE_CODEC_ID) - expect(codec.descriptor.targetTypes).toEqual(['eql_v2_encrypted']) - expect(codec.descriptor.traits).toEqual([ - 'cipherstash:equality', - 'cipherstash:order-and-range', - ]) - expect(codec.descriptor.renderOutputType?.({})).toBe('EncryptedDate') - }) - - it('encode → decode round-trip preserves the ciphertext', async () => { - const sdk = emptySdk() - const codec = createCipherstashDateCodec(sdk) - const payload = { c: 'date-cipher', i: { t: 'event', c: 'occurred_on' } } - const envelope = EncryptedDate.from(new Date('2024-01-01')) - setHandleCiphertext(envelope, payload) - const wire = await codec.encode(envelope, ctxWithoutColumn) - const decoded = await codec.decode( - wire as string, - ctxWithColumn('event', 'occurred_on'), - ) - expect(decoded).toBeInstanceOf(EncryptedDate) - expect(decoded.expose().ciphertext).toEqual(payload) - }) -}) - -describe('createCipherstashBooleanCodec — registration shape + round-trip', () => { - it('uses cipherstash/boolean@1 as the codec id and targets eql_v2_encrypted', () => { - const codec = createCipherstashBooleanCodec(emptySdk()) - expect(codec.id).toBe(CIPHERSTASH_BOOLEAN_CODEC_ID) - expect(codec.descriptor.targetTypes).toEqual(['eql_v2_encrypted']) - expect(codec.descriptor.traits).toEqual(['cipherstash:equality']) - expect(codec.descriptor.renderOutputType?.({})).toBe('EncryptedBoolean') - }) - - it('encode → decode round-trip preserves the ciphertext', async () => { - const sdk = emptySdk() - const codec = createCipherstashBooleanCodec(sdk) - const payload = { c: 'bool-cipher', i: { t: 'feature', c: 'enabled' } } - const envelope = EncryptedBoolean.from(true) - setHandleCiphertext(envelope, payload) - const wire = await codec.encode(envelope, ctxWithoutColumn) - const decoded = await codec.decode( - wire as string, - ctxWithColumn('feature', 'enabled'), - ) - expect(decoded).toBeInstanceOf(EncryptedBoolean) - expect(decoded.expose().ciphertext).toEqual(payload) - }) -}) - -describe('createCipherstashJsonCodec — registration shape + round-trip', () => { - it('uses cipherstash/json@1 as the codec id and targets eql_v2_encrypted', () => { - const codec = createCipherstashJsonCodec(emptySdk()) - expect(codec.id).toBe(CIPHERSTASH_JSON_CODEC_ID) - expect(codec.descriptor.targetTypes).toEqual(['eql_v2_encrypted']) - expect(codec.descriptor.traits).toEqual(['cipherstash:searchable-json']) - expect(codec.descriptor.renderOutputType?.({})).toBe('EncryptedJson') - }) - - it('encode → decode round-trip preserves the ciphertext for arbitrary JSON', async () => { - const sdk = emptySdk() - const codec = createCipherstashJsonCodec(sdk) - const payload = { c: 'json-cipher', i: { t: 'audit', c: 'payload' } } - const envelope = EncryptedJson.from({ event: 'login', userId: 42 }) - setHandleCiphertext(envelope, payload) - const wire = await codec.encode(envelope, ctxWithoutColumn) - const decoded = await codec.decode( - wire as string, - ctxWithColumn('audit', 'payload'), - ) - expect(decoded).toBeInstanceOf(EncryptedJson) - expect(decoded.expose().ciphertext).toEqual(payload) - }) -}) - -describe('paramsSchemas for date / boolean / json', () => { - it('encryptedDateParamsSchema accepts { equality, orderAndRange } booleans', () => { - const ok = encryptedDateParamsSchema['~standard'].validate({ - equality: true, - orderAndRange: false, - }) - if (ok instanceof Promise) - throw new Error('expected synchronous validation') - if (ok.issues) - throw new Error(`expected success, got: ${JSON.stringify(ok.issues)}`) - expect(ok.value).toEqual({ equality: true, orderAndRange: false }) - }) - - it('encryptedBooleanParamsSchema accepts { equality } and rejects extras of wrong type', () => { - const ok = encryptedBooleanParamsSchema['~standard'].validate({ - equality: true, - }) - if (ok instanceof Promise) - throw new Error('expected synchronous validation') - if (ok.issues) - throw new Error(`expected success, got: ${JSON.stringify(ok.issues)}`) - expect(ok.value).toEqual({ equality: true }) - - const bad = encryptedBooleanParamsSchema['~standard'].validate({ - equality: 'yes', - }) - if (bad instanceof Promise) - throw new Error('expected synchronous validation') - expect(bad.issues?.length).toBeGreaterThan(0) - }) - - it('encryptedJsonParamsSchema accepts { searchableJson } booleans', () => { - const ok = encryptedJsonParamsSchema['~standard'].validate({ - searchableJson: false, - }) - if (ok instanceof Promise) - throw new Error('expected synchronous validation') - if (ok.issues) - throw new Error(`expected success, got: ${JSON.stringify(ok.issues)}`) - expect(ok.value).toEqual({ searchableJson: false }) - }) -}) diff --git a/packages/prisma-next/test/column-types.test.ts b/packages/prisma-next/test/column-types.test.ts index 2ffd5aad7..ec1f33896 100644 --- a/packages/prisma-next/test/column-types.test.ts +++ b/packages/prisma-next/test/column-types.test.ts @@ -7,23 +7,11 @@ * `{ castAs, capabilities }` typeParams block — byte-identical to the * lowering output of the matching PSL constructor in * `src/contract-authoring.ts`. - * - * v2: the six pre-rename factories survive verbatim under their `*V2` - * names (`encryptedStringV2`, …) with their original option arguments and - * `eql_v2_encrypted` outputs. */ import { describe, expect, it } from 'vitest' import { v3CamelName } from '../src/contract-authoring' import * as columnTypes from '../src/exports/column-types' -import { - encryptedBigIntV2, - encryptedBooleanV2, - encryptedDateV2, - encryptedDoubleV2, - encryptedJsonV2, - encryptedStringV2, -} from '../src/exports/column-types' import { EXPOSED_DOMAIN_ENTRIES } from '../src/v3/catalog' describe('v3 TS factories', () => { @@ -121,262 +109,3 @@ describe('v3 TS factories', () => { expect(exported['string']).toBeUndefined() }) }) - -describe('cipherstash column-types (v2 legacy aliases)', () => { - describe('encryptedStringV2({...}) factory', () => { - it('produces a ColumnTypeDescriptor with cipherstash/string@1 codec id', () => { - const descriptor = encryptedStringV2() - expect(descriptor).toMatchObject({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - }) - }) - - it('defaults all flags to true when called with no arguments', () => { - expect(encryptedStringV2()).toEqual({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }, - }) - }) - - it('defaults all flags to true for an empty options object', () => { - expect(encryptedStringV2({})).toEqual({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }, - }) - }) - - it('lets equality be explicitly disabled', () => { - expect(encryptedStringV2({ equality: false })).toMatchObject({ - typeParams: { - equality: false, - freeTextSearch: true, - orderAndRange: true, - }, - }) - }) - - it('lets freeTextSearch be explicitly disabled', () => { - expect(encryptedStringV2({ freeTextSearch: false })).toMatchObject({ - typeParams: { - equality: true, - freeTextSearch: false, - orderAndRange: true, - }, - }) - }) - - it('lets orderAndRange be explicitly disabled', () => { - expect(encryptedStringV2({ orderAndRange: false })).toMatchObject({ - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: false, - }, - }) - }) - - it('lets all flags be explicitly disabled (storage-only encryption)', () => { - expect( - encryptedStringV2({ - equality: false, - freeTextSearch: false, - orderAndRange: false, - }), - ).toMatchObject({ - typeParams: { - equality: false, - freeTextSearch: false, - orderAndRange: false, - }, - }) - }) - - it('preserves all flags when explicitly enabled', () => { - expect( - encryptedStringV2({ - equality: true, - freeTextSearch: true, - orderAndRange: true, - }), - ).toMatchObject({ - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }, - }) - }) - - it('returns a structurally equivalent descriptor to the PSL constructor lowering', () => { - expect( - encryptedStringV2({ - equality: true, - freeTextSearch: true, - orderAndRange: true, - }), - ).toEqual({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }, - }) - }) - }) - - describe('encryptedDoubleV2({...}) factory', () => { - it('produces a ColumnTypeDescriptor with cipherstash/double@1 codec id', () => { - expect(encryptedDoubleV2()).toMatchObject({ - codecId: 'cipherstash/double@1', - nativeType: 'eql_v2_encrypted', - }) - }) - - it('defaults both flags to true when called with no arguments', () => { - expect(encryptedDoubleV2()).toMatchObject({ - typeParams: { equality: true, orderAndRange: true }, - }) - }) - - it('defaults both flags to true for an empty options object', () => { - expect(encryptedDoubleV2({})).toMatchObject({ - typeParams: { equality: true, orderAndRange: true }, - }) - }) - - it('lets equality be explicitly disabled', () => { - expect(encryptedDoubleV2({ equality: false })).toMatchObject({ - typeParams: { equality: false, orderAndRange: true }, - }) - }) - - it('lets orderAndRange be explicitly disabled', () => { - expect(encryptedDoubleV2({ orderAndRange: false })).toMatchObject({ - typeParams: { equality: true, orderAndRange: false }, - }) - }) - - it('lets both flags be explicitly disabled (storage-only encryption)', () => { - expect( - encryptedDoubleV2({ equality: false, orderAndRange: false }), - ).toEqual({ - codecId: 'cipherstash/double@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: false, orderAndRange: false }, - }) - }) - }) - - describe('encryptedBigIntV2({...}) factory', () => { - it('produces a ColumnTypeDescriptor with cipherstash/bigint@1 codec id', () => { - expect(encryptedBigIntV2()).toMatchObject({ - codecId: 'cipherstash/bigint@1', - nativeType: 'eql_v2_encrypted', - }) - }) - - it('defaults both flags to true when called with no arguments', () => { - expect(encryptedBigIntV2()).toMatchObject({ - typeParams: { equality: true, orderAndRange: true }, - }) - }) - - it('lets both flags be explicitly disabled (storage-only encryption)', () => { - expect( - encryptedBigIntV2({ equality: false, orderAndRange: false }), - ).toEqual({ - codecId: 'cipherstash/bigint@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: false, orderAndRange: false }, - }) - }) - }) - - describe('encryptedDateV2({...}) factory', () => { - it('produces a ColumnTypeDescriptor with cipherstash/date@1 codec id', () => { - expect(encryptedDateV2()).toMatchObject({ - codecId: 'cipherstash/date@1', - nativeType: 'eql_v2_encrypted', - }) - }) - - it('defaults both flags to true when called with no arguments', () => { - expect(encryptedDateV2()).toMatchObject({ - typeParams: { equality: true, orderAndRange: true }, - }) - }) - - it('lets both flags be explicitly disabled', () => { - expect( - encryptedDateV2({ equality: false, orderAndRange: false }), - ).toEqual({ - codecId: 'cipherstash/date@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: false, orderAndRange: false }, - }) - }) - }) - - describe('encryptedBooleanV2({...}) factory', () => { - it('produces a ColumnTypeDescriptor with cipherstash/boolean@1 codec id', () => { - expect(encryptedBooleanV2()).toMatchObject({ - codecId: 'cipherstash/boolean@1', - nativeType: 'eql_v2_encrypted', - }) - }) - - it('defaults equality to true when called with no arguments', () => { - expect(encryptedBooleanV2()).toEqual({ - codecId: 'cipherstash/boolean@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: true }, - }) - }) - - it('lets equality be explicitly disabled', () => { - expect(encryptedBooleanV2({ equality: false })).toEqual({ - codecId: 'cipherstash/boolean@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: false }, - }) - }) - }) - - describe('encryptedJsonV2({...}) factory', () => { - it('produces a ColumnTypeDescriptor with cipherstash/json@1 codec id', () => { - expect(encryptedJsonV2()).toMatchObject({ - codecId: 'cipherstash/json@1', - nativeType: 'eql_v2_encrypted', - }) - }) - - it('defaults searchableJson to true when called with no arguments', () => { - expect(encryptedJsonV2()).toEqual({ - codecId: 'cipherstash/json@1', - nativeType: 'eql_v2_encrypted', - typeParams: { searchableJson: true }, - }) - }) - - it('lets searchableJson be explicitly disabled (storage-only encryption)', () => { - expect(encryptedJsonV2({ searchableJson: false })).toEqual({ - codecId: 'cipherstash/json@1', - nativeType: 'eql_v2_encrypted', - typeParams: { searchableJson: false }, - }) - }) - }) -}) diff --git a/packages/prisma-next/test/decrypt-all.test.ts b/packages/prisma-next/test/decrypt-all.test.ts index 3c6d5da38..18bc992f2 100644 --- a/packages/prisma-next/test/decrypt-all.test.ts +++ b/packages/prisma-next/test/decrypt-all.test.ts @@ -25,7 +25,6 @@ import { decryptAll } from '../src/execution/decrypt-all' import { EncryptedBigInt } from '../src/execution/envelope-bigint' import { EncryptedBoolean } from '../src/execution/envelope-boolean' import { EncryptedDate } from '../src/execution/envelope-date' -import { EncryptedDouble } from '../src/execution/envelope-double' import { EncryptedJson } from '../src/execution/envelope-json' import { EncryptedString, @@ -38,6 +37,7 @@ import type { CipherstashSdk, CipherstashSingleDecryptArgs, } from '../src/execution/sdk' +import { EncryptedNumber } from '../src/v3/envelope-number' interface CounterSdk extends CipherstashSdk { readonly bulkDecryptCalls: CipherstashBulkDecryptArgs[] @@ -495,7 +495,7 @@ describe('decryptAll — heterogeneous envelope subclasses', () => { column: 'email', sdk, }) - const doubleEnv = EncryptedDouble.fromInternal({ + const doubleEnv = EncryptedNumber.fromInternal({ ciphertext: { v: 3.14 }, table: 'User', column: 'score', @@ -567,7 +567,7 @@ describe('decryptAll — heterogeneous envelope subclasses', () => { // contract with two envelopes of the same type at the same // routing key + a third envelope at a sibling column to confirm // the per-(table,column) split is preserved. - const { EncryptedDouble } = await import('../src/execution/envelope-double') + const { EncryptedNumber } = await import('../src/v3/envelope-number') const sdk = makeMultiSdk() const a = EncryptedString.fromInternal({ ciphertext: { v: 'alice' }, @@ -581,7 +581,7 @@ describe('decryptAll — heterogeneous envelope subclasses', () => { column: 'email', sdk, }) - const score = EncryptedDouble.fromInternal({ + const score = EncryptedNumber.fromInternal({ ciphertext: { v: 9.5 }, table: 'User', column: 'score', diff --git a/packages/prisma-next/test/derive-schemas.test.ts b/packages/prisma-next/test/derive-schemas.test.ts deleted file mode 100644 index 4f3dd6409..000000000 --- a/packages/prisma-next/test/derive-schemas.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Pin the shape of {@link deriveStackSchemas} against the full set of - * cipherstash codecs and search-mode flags. Uses synthesised contract - * JSON fragments so the test is hermetic — no dependency on the - * example app's contract.json or on the framework's contract emitter. - */ - -import { describe, expect, it } from 'vitest' - -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, -} from '../src/extension-metadata/constants' -import { deriveStackSchemas } from '../src/stack/derive-schemas' - -function makeContract( - tables: Record< - string, - Record< - string, - { codecId: string; typeParams?: Record | null } - > - >, -) { - return { - storage: { - namespaces: { - __unbound__: { - entries: { - table: Object.fromEntries( - Object.entries(tables).map(([name, cols]) => [ - name, - { - columns: cols as Record< - string, - { - codecId: string - typeParams?: Record | null - } - >, - }, - ]), - ), - }, - }, - }, - }, - } -} - -describe('deriveStackSchemas', () => { - it('returns an empty array when contract has no storage tables', () => { - expect(deriveStackSchemas({})).toEqual([]) - expect(deriveStackSchemas({ storage: {} })).toEqual([]) - expect(deriveStackSchemas({ storage: { namespaces: {} } })).toEqual([]) - expect( - deriveStackSchemas({ - storage: { namespaces: { __unbound__: { entries: { table: {} } } } }, - }), - ).toEqual([]) - }) - - it('skips tables with no cipherstash columns', () => { - const contract = makeContract({ - users: { - id: { codecId: 'pg/text@1', typeParams: null }, - }, - }) - expect(deriveStackSchemas(contract)).toEqual([]) - }) - - it('derives one EncryptedTable per table that has cipherstash columns', () => { - const contract = makeContract({ - users: { - id: { codecId: 'pg/text@1', typeParams: null }, - email: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - typeParams: { equality: true }, - }, - }, - audit_log: { - message: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - typeParams: { equality: true }, - }, - }, - }) - const schemas = deriveStackSchemas(contract) - expect(schemas).toHaveLength(2) - expect(schemas.map((t) => t.tableName).sort()).toEqual([ - 'audit_log', - 'users', - ]) - }) - - it('maps each cipherstash codec id to the correct dataType', () => { - const contract = makeContract({ - t: { - s: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - typeParams: { equality: true }, - }, - d: { - codecId: CIPHERSTASH_DOUBLE_CODEC_ID, - typeParams: { equality: true }, - }, - b: { - codecId: CIPHERSTASH_BIGINT_CODEC_ID, - typeParams: { equality: true }, - }, - dt: { - codecId: CIPHERSTASH_DATE_CODEC_ID, - typeParams: { equality: true }, - }, - bo: { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - typeParams: { equality: true }, - }, - j: { - codecId: CIPHERSTASH_JSON_CODEC_ID, - typeParams: { searchableJson: true }, - }, - }, - }) - const [t] = deriveStackSchemas(contract) - const built = t!.build() - // `build().cast_as` returns SDK-facing aliases ('string', 'number', 'bigint', ...); - // the EQL `cast_as` lower-form ('text', 'double', 'big_int', ...) is derived - // internally by the stack client at encrypt time. - expect(built.columns.s?.cast_as).toBe('string') - expect(built.columns.d?.cast_as).toBe('number') - expect(built.columns.b?.cast_as).toBe('bigint') - expect(built.columns.dt?.cast_as).toBe('date') - expect(built.columns.bo?.cast_as).toBe('boolean') - expect(built.columns.j?.cast_as).toBe('json') - }) - - it('installs index methods for each true-valued search-mode flag', () => { - const contract = makeContract({ - users: { - email: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }, - }, - bio: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - typeParams: { - equality: false, - freeTextSearch: true, - orderAndRange: false, - }, - }, - preferences: { - codecId: CIPHERSTASH_JSON_CODEC_ID, - typeParams: { searchableJson: true }, - }, - }, - }) - const [users] = deriveStackSchemas(contract) - const built = users!.build() - - // email — all three indices - expect(Object.keys(built.columns.email?.indexes ?? {})).toEqual( - expect.arrayContaining(['unique', 'match', 'ore']), - ) - - // bio — only match (freeTextSearch); equality/orderAndRange false → no unique/ore - expect(built.columns.bio?.indexes.unique).toBeUndefined() - expect(built.columns.bio?.indexes.match).toBeDefined() - expect(built.columns.bio?.indexes.ore).toBeUndefined() - - // preferences — ste_vec only - expect(built.columns.preferences?.indexes.ste_vec).toBeDefined() - }) - - it('skips false-valued flags (treats absent and false as equivalent)', () => { - const contract = makeContract({ - t: { - c: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - // explicit false on every flag should produce a column with no indices - typeParams: { - equality: false, - freeTextSearch: false, - orderAndRange: false, - }, - }, - }, - }) - const [t] = deriveStackSchemas(contract) - const built = t!.build() - expect(built.columns.c?.indexes).toEqual({}) - }) - - it('throws on an unrecognised typeParams flag (catches framework-vs-SDK vocabulary drift)', () => { - const contract = makeContract({ - t: { - c: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - typeParams: { equality: true, futureFlag: true } as Record< - string, - boolean - >, - }, - }, - }) - expect(() => deriveStackSchemas(contract)).toThrow(/futureFlag/) - }) - - it('uses the physical column name (the storage IR key, post-@map)', () => { - // contract.json's `storage.tables.
.columns.` keys are - // already the physical post-@map names. The derivation must keep - // those names verbatim, not the PSL field names. - const contract = makeContract({ - users: { - emailverified: { - codecId: CIPHERSTASH_BOOLEAN_CODEC_ID, - typeParams: { equality: true }, - }, - }, - }) - const [users] = deriveStackSchemas(contract) - expect(users!.build().columns.emailverified).toBeDefined() - }) -}) diff --git a/packages/prisma-next/test/descriptor.test.ts b/packages/prisma-next/test/descriptor.test.ts index 8dd3b3462..4e92d46cd 100644 --- a/packages/prisma-next/test/descriptor.test.ts +++ b/packages/prisma-next/test/descriptor.test.ts @@ -10,9 +10,14 @@ * - `/refs/head.json` * * These assertions lock down the wiring: the descriptor exposes - * structurally correct values; the emitted bundle SQL flows through - * `ops.json` byte-for-byte; and the head ref tracks the latest - * migration's `to` hash. + * structurally correct values; the EQL v3 install SQL is injected at + * descriptor-build time from `@cipherstash/eql`; and the head ref tracks + * the sole migration's `to` hash. + * + * **EQL v3 only.** The package installs EQL v3 exclusively. The contract + * models no storage (the v3 bundle creates `public.eql_v3_*` domains + + * `eql_v3.*` functions but no contract-space table), and the sole + * migration is an invariant-only genesis edge (`from: null`). * * Hash-level values are sourced from the on-disk artefacts (via the * descriptor's contractSpace) rather than hand-pinned in the test, so @@ -22,21 +27,16 @@ * @see docs/architecture docs/adrs/ADR 212 - Contract spaces.md */ +import { readInstallSql } from '@cipherstash/eql/sql' 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 { - CIPHERSTASH_BASELINE_MIGRATION_NAME, - CIPHERSTASH_INVARIANTS, - CIPHERSTASH_SPACE_ID, - EQL_V2_CONFIGURATION_TABLE, -} from '../src/extension-metadata/constants' +import { CIPHERSTASH_SPACE_ID } from '../src/extension-metadata/constants' import { CIPHERSTASH_V3_BASELINE_MIGRATION_NAME, CIPHERSTASH_V3_INVARIANTS, } from '../src/extension-metadata/constants-v3' -import { EQL_BUNDLE_SQL } from '../src/migration/eql-bundle' describe('cipherstash extension descriptor (contract-space package layout)', () => { it('identifies as a SQL extension targeted at postgres', () => { @@ -48,13 +48,13 @@ describe('cipherstash extension descriptor (contract-space package layout)', () }) }) - it('exposes a contractSpace declaring the eql_v2_configuration table', () => { + it('exposes a contractSpace that models no storage (EQL v3 only)', () => { const space = cipherstashExtensionDescriptor.contractSpace expect(space).toBeDefined() // 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`). + // `storage.namespaces..entries.table` since 0.13). The v3 bundle + // creates `public.eql_v3_*` domains + `eql_v3.*` functions but no + // contract-space table, so the contract models zero tables. const namespaces = space!.contractJson.storage.namespaces as Record< string, { entries?: { table?: Record } } @@ -62,35 +62,24 @@ describe('cipherstash extension descriptor (contract-space package layout)', () const tables = Object.values(namespaces).flatMap((ns) => Object.keys(ns.entries?.table ?? {}), ) - expect(tables).toEqual([EQL_V2_CONFIGURATION_TABLE]) + expect(tables).toEqual([]) }) - it('publishes the v2 + v3 baseline migrations sourced from the on-disk emit pipeline', () => { + it('publishes the v3 baseline migration as the sole invariant-only genesis edge', () => { const space = cipherstashExtensionDescriptor.contractSpace! - expect(space.migrations).toHaveLength(2) - const baseline = space.migrations[0]! - expect(baseline.dirName).toBe(CIPHERSTASH_BASELINE_MIGRATION_NAME) - expect(baseline.metadata.from).toBeNull() - expect(baseline.metadata.to).toBe(space.contractJson.storage.storageHash) - // The v3 baseline is an invariant-only edge: the bundle creates - // `public.eql_v3_*` domains + `eql_v3.*` functions but no - // contract-space storage, so the hash does not move. - const v3Baseline = space.migrations[1]! + expect(space.migrations).toHaveLength(1) + const v3Baseline = space.migrations[0]! expect(v3Baseline.dirName).toBe(CIPHERSTASH_V3_BASELINE_MIGRATION_NAME) - expect(v3Baseline.metadata.from).toBe(baseline.metadata.to) + // Genesis edge (`from: null`): the bundle declares no contract-space + // storage, so the resulting storage hash is the empty-storage hash — + // which is exactly `contractJson.storage.storageHash` / the head ref. + expect(v3Baseline.metadata.from).toBeNull() expect(v3Baseline.metadata.to).toBe(space.contractJson.storage.storageHash) }) - it('baseline ops carry the installEqlBundle op + structural create-* ops', () => { - const space = cipherstashExtensionDescriptor.contractSpace! - const baseline = space.migrations[0]! - const opIds = baseline.ops.map((op) => op.invariantId).filter(Boolean) - expect(opIds).toEqual([CIPHERSTASH_INVARIANTS.installBundle]) - }) - it('v3 baseline ops carry the installEqlV3Bundle op only', () => { const space = cipherstashExtensionDescriptor.contractSpace! - const v3Baseline = space.migrations[1]! + const v3Baseline = space.migrations[0]! const opIds = v3Baseline.ops.map((op) => op.invariantId).filter(Boolean) expect(opIds).toEqual([CIPHERSTASH_V3_INVARIANTS.installBundle]) }) @@ -106,16 +95,18 @@ describe('cipherstash extension descriptor (contract-space package layout)', () } }) - it('inlines the EQL bundle SQL byte-for-byte through ops.json', () => { - const baseline = + it('injects the runtime EQL v3 install SQL into ops.json (not the sentinel placeholder)', () => { + const v3Baseline = cipherstashExtensionDescriptor.contractSpace!.migrations[0]! - const installOp = baseline.ops.find( - (op) => op.invariantId === CIPHERSTASH_INVARIANTS.installBundle, + const installOp = v3Baseline.ops.find( + (op) => op.invariantId === CIPHERSTASH_V3_INVARIANTS.installBundle, ) as | { readonly execute?: ReadonlyArray<{ readonly sql: string }> } | undefined expect(installOp).toBeDefined() - expect(installOp?.execute?.[0]?.sql).toBe(EQL_BUNDLE_SQL) + // The descriptor swaps the committed sentinel placeholder for the + // real install SQL from the installed `@cipherstash/eql`. + expect(installOp?.execute?.[0]?.sql).toBe(readInstallSql()) }) it("points the head ref at the latest migration's destination hash with every migration's invariants", () => { diff --git a/packages/prisma-next/test/envelope-double.test.ts b/packages/prisma-next/test/envelope-double.test.ts deleted file mode 100644 index 0747beb1c..000000000 --- a/packages/prisma-next/test/envelope-double.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Behavioural tests for the `EncryptedDouble` envelope. - * - * Pins the subclass surface (`from` + `fromInternal` + decrypt - * round-trip), the four non-`toJSON` redaction overrides (return - * `[REDACTED]`), and the `JSON.stringify(envelope)` placeholder - * shape `{ "$encryptedDouble": "" }`. - */ - -import { inspect } from 'node:util' -import { describe, expect, it, vi } from 'vitest' -import { EncryptedEnvelopeBase } from '../src/execution/envelope-base' -import { EncryptedDouble } from '../src/execution/envelope-double' -import type { CipherstashSdk } from '../src/execution/sdk' - -function emptySdk(): CipherstashSdk { - return { - decrypt: vi.fn(), - bulkEncrypt: vi.fn(), - bulkDecrypt: vi.fn(), - } -} - -describe('EncryptedDouble.from(plaintext)', () => { - it('returns an EncryptedDouble instance that extends EncryptedEnvelopeBase', () => { - const envelope = EncryptedDouble.from(3.14) - expect(envelope).toBeInstanceOf(EncryptedDouble) - expect(envelope).toBeInstanceOf(EncryptedEnvelopeBase) - }) - - it('decrypt() resolves to the original numeric plaintext on the write side', async () => { - const envelope = EncryptedDouble.from(2.5) - await expect(envelope.decrypt()).resolves.toBe(2.5) - }) - - it('preserves negative and zero values without coercion', async () => { - await expect(EncryptedDouble.from(-1.5).decrypt()).resolves.toBe(-1.5) - await expect(EncryptedDouble.from(0).decrypt()).resolves.toBe(0) - }) -}) - -describe('EncryptedDouble.fromInternal(...) — read-side round-trip', () => { - it('decrypt({signal}) calls the SDK single-cell decrypt and returns the numeric plaintext', async () => { - const ciphertext = { c: 'cipher', i: { t: 'metric', c: 'value' } } - const decryptMock = vi.fn().mockResolvedValue(42.5) - const sdk: CipherstashSdk = { - decrypt: decryptMock, - bulkEncrypt: vi.fn(), - bulkDecrypt: vi.fn(), - } - - const envelope = EncryptedDouble.fromInternal({ - ciphertext, - table: 'metric', - column: 'value', - sdk, - }) - - const ac = new AbortController() - const result = await envelope.decrypt({ signal: ac.signal }) - - expect(result).toBe(42.5) - expect(decryptMock).toHaveBeenCalledTimes(1) - expect(decryptMock.mock.calls[0]?.[0]).toMatchObject({ - ciphertext, - table: 'metric', - column: 'value', - signal: ac.signal, - }) - }) -}) - -describe('EncryptedDouble — accidental-exposure overrides', () => { - // The four non-`toJSON` coercion paths return `[REDACTED]`; - // `toJSON` returns the per-type placeholder object. - it('toString() returns [REDACTED] regardless of plaintext value', () => { - expect(EncryptedDouble.from(42).toString()).toBe('[REDACTED]') - }) - - it('valueOf() returns [REDACTED]', () => { - expect(EncryptedDouble.from(42).valueOf()).toBe('[REDACTED]') - }) - - it('Symbol.toPrimitive returns [REDACTED] for template-literal coercion', () => { - const envelope = EncryptedDouble.from(42) - expect(`v=${envelope}`).toBe('v=[REDACTED]') - }) - - it('util.inspect returns [REDACTED]', () => { - const envelope = EncryptedDouble.from(42) - const inspected = inspect(envelope, { - depth: Number.POSITIVE_INFINITY, - getters: true, - }) - expect(inspected).not.toContain('42') - expect(inspected).toContain('[REDACTED]') - }) - - it('JSON.stringify renders the per-type placeholder marker shape', () => { - const envelope = EncryptedDouble.from(42) - expect(JSON.parse(JSON.stringify(envelope))).toEqual({ - $encryptedDouble: '', - }) - }) - - it('JSON.stringify cannot leak plaintext', () => { - const envelope = EncryptedDouble.from(123.456789) - const json = JSON.stringify({ value: envelope }) - expect(json).not.toContain('123.456789') - }) -}) - -describe('EncryptedDouble — fromInternal preserves SDK references', () => { - it('exposes the (table, column) routing context + SDK on the handle', () => { - const sdk = emptySdk() - const envelope = EncryptedDouble.fromInternal({ - ciphertext: 'wire', - table: 'metric', - column: 'value', - sdk, - }) - const handle = envelope.expose() - expect(handle.table).toBe('metric') - expect(handle.column).toBe('value') - expect(handle.sdk).toBe(sdk) - expect(handle.plaintext).toBeUndefined() - }) -}) diff --git a/packages/prisma-next/test/equality-trait-removal.test.ts b/packages/prisma-next/test/equality-trait-removal.test.ts index d131fe01d..29eb0371f 100644 --- a/packages/prisma-next/test/equality-trait-removal.test.ts +++ b/packages/prisma-next/test/equality-trait-removal.test.ts @@ -1,41 +1,24 @@ /** - * Regression test. + * Regression test: cipherstash v3 codec descriptors must NOT advertise + * the framework's built-in `equality` trait. * - * The cipherstash storage codec must NOT advertise the framework's - * `equality` trait. Re-adding it without re-routing through the - * cipherstash-namespaced operator surface (`cipherstashEq` / - * `cipherstashIlike` in `src/execution/operators.ts`) silently re-introduces - * a wrong-SQL footgun on cipherstash columns: + * Re-adding a framework built-in trait to a cipherstash codec silently + * re-introduces a wrong-SQL footgun: `COMPARISON_METHODS_META.eq` (in + * `packages/3-extensions/sql-orm-client/src/types.ts`) gates the + * framework's built-in `eq` on the column codec's `equality` trait, and + * the built-in lowers to standard SQL `=`. EQL ciphertexts contain + * randomized nonces, so two encrypts of the same plaintext do not + * byte-equal under SQL `=` — a built-in `email.eq(...)` on an encrypted + * column would produce `"email" = $1` and silently return zero matches. + * The supported equality search is the cipherstash-namespaced `eqlEq`, + * routed through the `cipherstash:v3-*` operator surface. * - * - `COMPARISON_METHODS_META.eq` (in `packages/3-extensions/sql-orm-client/ - * src/types.ts`) gates the framework`s built-in `eq` on the column - * codec`s `equality` trait. The built-in lowers to standard SQL `=` - * via `BinaryExpr eq`. - * - EQL ciphertexts contain randomized nonces, so two encrypts of the - * same plaintext do not byte-equal under SQL `=`. A built-in - * `email.eq('alice@example.com')` on a cipherstash column would - * therefore produce `"email" = $1::eql_v2_encrypted` and silently - * return zero matches at runtime. - * - The supported equality-search call is `email.cipherstashEq(value)`, - * which lowers to `eql_v2.eq(...)` (snapshot-pinned in - * `operator-lowering.test.ts`). - * - * The user-facing `EncryptedString({ equality: true })` flag in PSL/TS - * authoring is a SEPARATE concept from this codec trait — that flag - * controls whether the codec lifecycle hook emits an `add_search_config` - * op for the column`s `unique` index at migration time. The two - * `equality` concepts share only their name. - * - * Recorded here so a future change that flips the trait declaration - * without re-routing the dispatch trips this test loudly rather than - * re-opening the footgun. + * Recorded here so a future change that flips a trait declaration + * trips this test loudly rather than re-opening the footgun. */ import { describe, expect, it, vi } from 'vitest' -import { createCipherstashStringCodec } from '../src/execution/codec-runtime' -import { createParameterizedCodecDescriptors } from '../src/execution/parameterized' import type { CipherstashSdk } from '../src/execution/sdk' -import { cipherstashStringCodecMetadata } from '../src/extension-metadata/codec-metadata' import { createV3CodecDescriptors } from '../src/v3/codec-runtime-v3' function emptySdk(): CipherstashSdk { @@ -46,59 +29,14 @@ function emptySdk(): CipherstashSdk { } } -describe('cipherstash codec: no `equality` trait', () => { - it('runtime codec never advertises the framework `equality` trait', () => { - const codec = createCipherstashStringCodec(emptySdk()) - const traits: ReadonlyArray = codec.descriptor.traits ?? [] - expect(traits).not.toContain('equality') - // Cipherstash-namespaced traits (load-bearing for the multi-codec - // operator dispatch) ARE expected — they're isolated from - // framework built-ins by the `cipherstash:` prefix. - expect(traits.some((t) => t.startsWith('cipherstash:'))).toBe(true) - }) - - it('parameterized codec descriptors (the ones the runtime consumes for dispatch) never advertise `equality`', () => { - const descriptors = createParameterizedCodecDescriptors(emptySdk()) - expect(descriptors.length).toBeGreaterThan(0) - for (const descriptor of descriptors) { - const traits: ReadonlyArray = descriptor.traits ?? [] - expect(traits).not.toContain('equality') - expect(traits.some((t) => t.startsWith('cipherstash:'))).toBe(true) - } - }) - - it('SDK-free pack-meta codec metadata never advertises `equality`', () => { - const traits: ReadonlyArray = - cipherstashStringCodecMetadata.descriptor.traits ?? [] - expect(traits).not.toContain('equality') - expect(traits.some((t) => t.startsWith('cipherstash:'))).toBe(true) - }) - - it('the three trait declarations agree (runtime / parameterized / pack-meta) for the string codec', () => { - // If these three diverge, contract emit (which reads pack-meta) and - // the runtime (which reads the parameterized descriptor) will - // disagree about which built-in operations are reachable on - // cipherstash columns. They must always be identical. - const runtime = - createCipherstashStringCodec(emptySdk()).descriptor.traits ?? [] - const parameterized = - createParameterizedCodecDescriptors(emptySdk()).find( - (d) => d.codecId === 'cipherstash/string@1', - )?.traits ?? [] - const packMeta = cipherstashStringCodecMetadata.descriptor.traits ?? [] - expect([...runtime].sort()).toEqual([...parameterized].sort()) - expect([...runtime].sort()).toEqual([...packMeta].sort()) - }) -}) - describe('cipherstash v3 codec descriptors: no framework built-in traits', () => { it('every v3 descriptor advertises only cipherstash:* traits — never the framework built-in `equality`', () => { - // Same footgun as v2, per-domain: a framework built-in trait on a - // v3 codec descriptor would re-attach the built-in `eq` (SQL `=`) - // to encrypted columns, which silently returns zero rows against - // nondeterministic ciphertexts. Storage-only domains legitimately - // carry an EMPTY trait list (they answer no operator at all), so - // the invariant is subset-of-`cipherstash:*`, not non-empty. + // A framework built-in trait on a v3 codec descriptor would + // re-attach the built-in `eq` (SQL `=`) to encrypted columns, which + // silently returns zero rows against nondeterministic ciphertexts. + // Storage-only domains legitimately carry an EMPTY trait list (they + // answer no operator at all), so the invariant is + // subset-of-`cipherstash:*`, not non-empty. const descriptors = createV3CodecDescriptors(emptySdk()) expect(descriptors.length).toBeGreaterThan(0) for (const descriptor of descriptors) { @@ -110,35 +48,3 @@ describe('cipherstash v3 codec descriptors: no framework built-in traits', () => } }) }) - -describe('cipherstash columns: framework built-in `eq` is not reachable', () => { - it('documents the gating contract — built-in `eq` requires `equality` in column traits', () => { - // This test pins the contract that `cipherstash/string@1` columns - // intentionally lack the `equality` trait, so the per-column - // accessor synthesis in `createScalarFieldAccessor` (sql-orm-client) - // skips `COMPARISON_METHODS_META.eq` (it`s gated on `equality`). - // The accessor surface for a cipherstash column therefore has no - // `eq` / `neq` / `in` / `notIn` / `like` / `ilike` keys and only - // exposes the cipherstash-namespaced operators - // (`cipherstashEq` / `cipherstashIlike`) plus the always-on null - // checks (`isNull` / `isNotNull`). - // - // The end-to-end behavior — `(model accessor for cipherstash column).eq` - // is `undefined` — is exercised at the `sql-orm-client` layer - // (`packages/3-extensions/sql-orm-client/test/model-accessor.test.ts` - // already pins gating behavior for non-textual codecs via the - // `does not expose ilike on non-textual fields` test). Cipherstash - // does not depend on `sql-orm-client`, so this test asserts the - // *cause* (empty trait list) rather than the *effect* (undefined - // accessor key); a sibling `does not expose eq on cipherstash - // columns` test belongs in `sql-orm-client/test/model-accessor.test.ts` - // when that fixture grows a cipherstash codec entry. - // Widen via `ReadonlyArray` so `includes('equality')` is - // well-typed even when TS narrows the codec`s `traits` to - // `readonly never[]` (which is itself a strong static signal that - // the trait can`t be present). - const traits: ReadonlyArray = - createCipherstashStringCodec(emptySdk()).descriptor.traits ?? [] - expect(traits.includes('equality')).toBe(false) - }) -}) diff --git a/packages/prisma-next/test/from-stack-divergence.test.ts b/packages/prisma-next/test/from-stack-divergence.test.ts deleted file mode 100644 index 565059d3f..000000000 --- a/packages/prisma-next/test/from-stack-divergence.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Pin the divergence-check semantics of `cipherstashFromStackV2`. - * - * The full `cipherstashFromStackV2` path is not exercisable in unit - * tests because it calls `Encryption({ schemas })` which talks to - * ZeroKMS at module-evaluation time. We instead pull out the - * divergence check by calling `cipherstashFromStackV2` with an - * intentionally-broken override; the assertion fires before any - * SDK round-trip is attempted, so the test stays hermetic. - * - * The happy end-to-end path is covered by the example app's live - * `pnpm start` flow. - */ - -import { encryptedColumn, encryptedTable } from '@cipherstash/stack/schema' -import { describe, expect, it } from 'vitest' - -import { - CIPHERSTASH_BOOLEAN_CODEC_ID, - CIPHERSTASH_STRING_CODEC_ID, -} from '../src/extension-metadata/constants' -import { cipherstashFromStackV2 } from '../src/stack/from-stack' - -function makeContract() { - return { - storage: { - 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 }, - }, - }, - }, - }, - }, - }, - }, - }, - } -} - -describe('cipherstashFromStackV2 — divergence check', () => { - it('throws when an override drops a column the contract declares', async () => { - const override = encryptedTable('users', { - email: encryptedColumn('email').equality().freeTextSearch(), - // `verified` dropped from override - }) - - await expect( - cipherstashFromStackV2({ - contractJson: makeContract(), - schemas: [override], - }), - ).rejects.toThrow(/schema divergence on table "users"/) - }) - - it('throws when an override adds a column the contract does not declare', async () => { - const override = encryptedTable('users', { - email: encryptedColumn('email').equality().freeTextSearch(), - verified: encryptedColumn('verified').dataType('boolean').equality(), - phantom: encryptedColumn('phantom').equality(), - }) - - await expect( - cipherstashFromStackV2({ - contractJson: makeContract(), - schemas: [override], - }), - ).rejects.toThrow(/schema divergence on table "users"/) - }) - - it("throws when an override changes a column's cast_as", async () => { - const override = encryptedTable('users', { - email: encryptedColumn('email').dataType('number').equality(), - verified: encryptedColumn('verified').dataType('boolean').equality(), - }) - - await expect( - cipherstashFromStackV2({ - contractJson: makeContract(), - schemas: [override], - }), - ).rejects.toThrow( - /schema divergence on column "users"\."email".*cast_as="string".*cast_as="number"/s, - ) - }) - - it("throws when an override changes a column's installed index set", async () => { - const override = encryptedTable('users', { - // dropped `.freeTextSearch()` — contract declared it - email: encryptedColumn('email').equality(), - verified: encryptedColumn('verified').dataType('boolean').equality(), - }) - - await expect( - cipherstashFromStackV2({ - contractJson: makeContract(), - schemas: [override], - }), - ).rejects.toThrow(/schema divergence on column "users"\."email".*indexes/s) - }) - - it('throws when the contract has no cipherstash columns and no override is supplied', async () => { - const emptyContract = { - storage: { - namespaces: { - __unbound__: { entries: { table: { users: { columns: {} } } } }, - }, - }, - } - await expect( - cipherstashFromStackV2({ 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 deleted file mode 100644 index 0ca5aa527..000000000 --- a/packages/prisma-next/test/helpers.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * Free-standing helper tests — sort + JSON SELECT-expression - * helpers. - * - * These are not registered operators; they're pure functions imported - * from the runtime entry. The tests here pin: - * - * - **AST shape** — `cipherstashAsc(col)` produces an - * `OrderByItem` with `dir: 'asc'` wrapping the column's AST; - * `cipherstashDesc` mirrors with `dir: 'desc'`. - * - **SQL snapshot** — the lowered SELECT shape with the helper - * in `ORDER BY` (sort) or in the projection list (JSON helpers) - * pins the user-visible SQL the live-Postgres e2e harness - * executes against the EQL bundle. - * - **Error path** — each helper rejects a non-cipherstash column - * (or, for the JSON helpers, a cipherstash-but-non-JSON column) - * with a `TypeError` naming the helper and the accepted codec - * ids. - * - * Type-level tests are inline in `helpers.types.test-d.ts`; the - * helpers are typed at their declaration site (no - * `QueryOperationTypes` entry). - */ - -import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime' -import type { PostgresContract } from '@prisma-next/adapter-postgres/types' -import type { - RuntimeExtensionDescriptor, - RuntimeTargetDescriptor, -} from '@prisma-next/framework-components/execution' -import { validateSqlContractFully } from '@prisma-next/sql-contract/validators' -import { - type AnyExpression, - ColumnRef, - OrderByItem, - ProjectionItem, - SelectAst, - TableSource, -} from '@prisma-next/sql-relational-core/ast' -import type { - Expression, - ScopeField, -} from '@prisma-next/sql-relational-core/expression' -import { describe, expect, it, vi } from 'vitest' -import { - cipherstashAsc, - cipherstashDesc, - cipherstashJsonbGet, - cipherstashJsonbPathQueryFirst, -} from '../src/execution/helpers' -import type { CipherstashSdk } from '../src/execution/sdk' -import { createCipherstashRuntimeDescriptor } from '../src/exports/runtime' -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 '../src/extension-metadata/constants' - -function emptySdk(): CipherstashSdk { - return { - decrypt: vi.fn(), - bulkEncrypt: vi.fn(), - bulkDecrypt: vi.fn(), - } -} - -const TABLE = 'user' - -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: [], - }, - }, - }, - }, - }, - }, - domain: { namespaces: { __unbound__: { models: {} } } }, -}) - -const stubRuntimeTarget: RuntimeTargetDescriptor<'sql', 'postgres'> = { - kind: 'target', - id: 'postgres', - version: '0.0.1', - familyId: 'sql', - targetId: 'postgres', - create() { - return { familyId: 'sql', targetId: 'postgres' } - }, -} - -function makeAdapter() { - const cipherstash: RuntimeExtensionDescriptor<'sql', 'postgres'> = - createCipherstashRuntimeDescriptor({ sdk: emptySdk() }) - return postgresRuntimeAdapter.create({ - target: stubRuntimeTarget, - adapter: postgresRuntimeAdapter, - driver: undefined, - extensionPacks: [cipherstash], - }) -} - -function columnAccessor( - table: string, - column: string, - codecId: string, -): Expression { - const ref = ColumnRef.of(table, column) - return { - returnType: { codecId, nullable: true }, - buildAst: () => ref, - } -} - -function selectWithOrderBy(items: readonly OrderByItem[]) { - return SelectAst.from(TableSource.named(TABLE)) - .withProjection([ProjectionItem.of('id', ColumnRef.of(TABLE, 'id'))]) - .withOrderBy(items) -} - -function selectWithProjection(name: string, expr: AnyExpression) { - return SelectAst.from(TableSource.named(TABLE)).withProjection([ - ProjectionItem.of(name, expr), - ]) -} - -describe('cipherstashAsc / cipherstashDesc — AST shape', () => { - it('cipherstashAsc returns an OrderByItem with dir asc wrapping the column buildAst', () => { - const col = columnAccessor(TABLE, 'email', CIPHERSTASH_STRING_CODEC_ID) - const item = cipherstashAsc(col) - expect(item).toBeInstanceOf(OrderByItem) - expect(item).toMatchObject({ dir: 'asc', expr: col.buildAst() }) - }) - - it('cipherstashDesc returns an OrderByItem with dir desc wrapping the column buildAst', () => { - const col = columnAccessor(TABLE, 'score', CIPHERSTASH_DOUBLE_CODEC_ID) - const item = cipherstashDesc(col) - expect(item).toBeInstanceOf(OrderByItem) - expect(item).toMatchObject({ dir: 'desc', expr: col.buildAst() }) - }) -}) - -describe('cipherstashAsc / cipherstashDesc — SQL snapshot', () => { - it('lowers ORDER BY cipherstashAsc(email) to a bare-column ASC clause', () => { - const col = columnAccessor(TABLE, 'email', CIPHERSTASH_STRING_CODEC_ID) - const ast = selectWithOrderBy([cipherstashAsc(col)]) - const lowered = makeAdapter().lower(ast, { contract }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" ORDER BY "user"."email" ASC"`, - ) - expect(lowered.params).toHaveLength(0) - }) - - it('lowers ORDER BY cipherstashDesc(birthday) to a bare-column DESC clause', () => { - const col = columnAccessor(TABLE, 'birthday', CIPHERSTASH_DATE_CODEC_ID) - const ast = selectWithOrderBy([cipherstashDesc(col)]) - const lowered = makeAdapter().lower(ast, { contract }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" ORDER BY "user"."birthday" DESC"`, - ) - }) - - it('lowers a multi-key ORDER BY with mixed directions', () => { - const score = columnAccessor(TABLE, 'score', CIPHERSTASH_DOUBLE_CODEC_ID) - const amount = columnAccessor(TABLE, 'amount', CIPHERSTASH_BIGINT_CODEC_ID) - const ast = selectWithOrderBy([ - cipherstashDesc(score), - cipherstashAsc(amount), - ]) - const lowered = makeAdapter().lower(ast, { contract }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" ORDER BY "user"."score" DESC, "user"."amount" ASC"`, - ) - }) -}) - -describe('cipherstashAsc / cipherstashDesc — error paths', () => { - it('cipherstashAsc rejects a non-cipherstash column', () => { - const col = columnAccessor(TABLE, 'plain', 'pg/text@1') - expect(() => cipherstashAsc(col)).toThrowError( - /cipherstashAsc.*pg\/text@1.*one of.*cipherstash\/string@1.*cipherstash\/double@1.*cipherstash\/bigint@1.*cipherstash\/date@1/s, - ) - }) - - it('cipherstashAsc rejects a cipherstash boolean column (not in order-and-range set)', () => { - const col = columnAccessor(TABLE, 'enabled', CIPHERSTASH_BOOLEAN_CODEC_ID) - expect(() => cipherstashAsc(col)).toThrowError( - /cipherstashAsc.*cipherstash\/boolean@1.*does not support order-and-range/, - ) - }) - - it('cipherstashAsc rejects a cipherstash json column (not in order-and-range set)', () => { - const col = columnAccessor(TABLE, 'payload', CIPHERSTASH_JSON_CODEC_ID) - expect(() => cipherstashAsc(col)).toThrowError( - /cipherstashAsc.*cipherstash\/json@1/, - ) - }) - - it('cipherstashDesc rejects a non-cipherstash column with the same diagnostic shape', () => { - const col = columnAccessor(TABLE, 'plain', 'pg/text@1') - expect(() => cipherstashDesc(col)).toThrowError( - /cipherstashDesc.*pg\/text@1/, - ) - }) -}) - -describe('cipherstashJsonbPathQueryFirst — AST shape and SQL snapshot', () => { - it('returns an Expression whose returnType is cipherstash/json@1', () => { - const col = columnAccessor(TABLE, 'payload', CIPHERSTASH_JSON_CODEC_ID) - const expr = cipherstashJsonbPathQueryFirst(col, '$.user.email') - expect(expr.returnType).toEqual({ - codecId: CIPHERSTASH_JSON_CODEC_ID, - nullable: false, - }) - const ast = expr.buildAst() - expect(ast.kind).toBe('operation') - }) - - it('lowers to eql_v2.jsonb_path_query_first("payload", $1) with the path bound as pg/text@1', () => { - const col = columnAccessor(TABLE, 'payload', CIPHERSTASH_JSON_CODEC_ID) - const expr = cipherstashJsonbPathQueryFirst(col, '$.user.email') - const ast = selectWithProjection('first_email', expr.buildAst()) - const lowered = makeAdapter().lower(ast, { contract }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT eql_v2.jsonb_path_query_first("user"."payload", $1) AS "first_email" FROM "user""`, - ) - expect(lowered.params).toEqual([{ kind: 'literal', value: '$.user.email' }]) - }) -}) - -describe('cipherstashJsonbGet — AST shape and SQL snapshot', () => { - it('returns an Expression whose returnType is cipherstash/json@1', () => { - const col = columnAccessor(TABLE, 'payload', CIPHERSTASH_JSON_CODEC_ID) - const expr = cipherstashJsonbGet(col, 'email') - expect(expr.returnType).toEqual({ - codecId: CIPHERSTASH_JSON_CODEC_ID, - nullable: false, - }) - }) - - it('lowers to eql_v2."->"("payload", $1) with the key bound as pg/text@1', () => { - const col = columnAccessor(TABLE, 'payload', CIPHERSTASH_JSON_CODEC_ID) - const expr = cipherstashJsonbGet(col, 'email') - const ast = selectWithProjection('email_field', expr.buildAst()) - const lowered = makeAdapter().lower(ast, { contract }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT eql_v2."->"("user"."payload", $1) AS "email_field" FROM "user""`, - ) - expect(lowered.params).toEqual([{ kind: 'literal', value: 'email' }]) - }) -}) - -describe('cipherstashJsonbPathQueryFirst / cipherstashJsonbGet — error paths', () => { - it('cipherstashJsonbPathQueryFirst rejects a non-cipherstash column', () => { - const col = columnAccessor(TABLE, 'plain', 'pg/text@1') - expect(() => cipherstashJsonbPathQueryFirst(col, '$.foo')).toThrowError( - /cipherstashJsonbPathQueryFirst.*pg\/text@1.*cipherstash\/json@1/, - ) - }) - - it('cipherstashJsonbPathQueryFirst rejects a cipherstash-but-non-json column', () => { - const col = columnAccessor(TABLE, 'email', CIPHERSTASH_STRING_CODEC_ID) - expect(() => cipherstashJsonbPathQueryFirst(col, '$.foo')).toThrowError( - /cipherstashJsonbPathQueryFirst.*cipherstash\/string@1.*cipherstash\/json@1/, - ) - }) - - it('cipherstashJsonbPathQueryFirst rejects a non-string path', () => { - const col = columnAccessor(TABLE, 'payload', CIPHERSTASH_JSON_CODEC_ID) - expect(() => - cipherstashJsonbPathQueryFirst(col, 42 as unknown as string), - ).toThrowError(/cipherstashJsonbPathQueryFirst.*string path.*number/) - }) - - it('cipherstashJsonbGet rejects a non-json cipherstash column with a json-specific diagnostic', () => { - const col = columnAccessor(TABLE, 'score', CIPHERSTASH_DOUBLE_CODEC_ID) - expect(() => cipherstashJsonbGet(col, 'foo')).toThrowError( - /cipherstashJsonbGet.*cipherstash\/double@1.*cipherstash\/json@1/, - ) - }) - - it('cipherstashJsonbGet rejects a non-string path', () => { - const col = columnAccessor(TABLE, 'payload', CIPHERSTASH_JSON_CODEC_ID) - expect(() => - cipherstashJsonbGet(col, null as unknown as string), - ).toThrowError(/cipherstashJsonbGet.*string path.*null/) - }) -}) diff --git a/packages/prisma-next/test/helpers.types.test-d.ts b/packages/prisma-next/test/helpers.types.test-d.ts deleted file mode 100644 index 4b34dfd84..000000000 --- a/packages/prisma-next/test/helpers.types.test-d.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Type-level tests for the free-standing helpers. - * - * The helpers are typed at their declaration site (no - * `QueryOperationTypes` entry). These assertions pin: - * - * - sort helpers return `OrderByItem` - * - JSON SELECT-expression helpers return - * `Expression<{ codecId: 'cipherstash/json@1'; nullable: false }>` - * - * Argument validation (cipherstash codec id required at runtime) is - * deliberately not type-enforced — the helpers accept - * `Expression` so the column-bound expression types from - * the model accessor flow through without round-tripping through the - * codec-types augmentation. Runtime guard tests live in - * `helpers.test.ts`. - */ - -import type { OrderByItem } from '@prisma-next/sql-relational-core/ast' -import type { - Expression, - ScopeField, -} from '@prisma-next/sql-relational-core/expression' -import { expectTypeOf } from 'vitest' -import { - cipherstashAsc, - cipherstashDesc, - cipherstashJsonbGet, - cipherstashJsonbPathQueryFirst, -} from '../src/execution/helpers' - -declare const anyCol: Expression - -expectTypeOf(cipherstashAsc(anyCol)).toEqualTypeOf() -expectTypeOf(cipherstashDesc(anyCol)).toEqualTypeOf() - -type JsonReturn = Expression<{ codecId: 'cipherstash/json@1'; nullable: false }> - -// Bidirectional assignability check. `JsonReturn` is the -// `Expression<{codecId: 'cipherstash/json@1', nullable: false}>` type -// the JSON helpers commit to producing; the helpers must produce -// something assignable to that slot, and a `JsonReturn` value must -// be assignable back to the helper-return type. Direct `toEqualTypeOf` fails -// strict equality because `Expression` is an intersection of -// `QueryOperationReturn` and the narrowed `{returnType: R}` shape; -// the intersection's `returnType` field carries both the broad -// `{codecId: string; nullable: boolean}` quotient and the narrow -// literal at once, which expectTypeOf's strict comparator does not -// collapse. Bidirectional assignability is the exact assertion that -// the helper output is interchangeable with the typed slot — the -// stronger `toEqualTypeOf` shape would not catch any additional -// drift in practice. -declare const expectedJson: JsonReturn -const pathQuery = cipherstashJsonbPathQueryFirst(anyCol, '$.foo') -const pathGet = cipherstashJsonbGet(anyCol, 'foo') -const _assignA: JsonReturn = pathQuery -const _assignB: JsonReturn = pathGet -const _assignC: typeof pathQuery = expectedJson -const _assignD: typeof pathGet = expectedJson -void _assignA -void _assignB -void _assignC -void _assignD - -// The path must be a string (compile-time error on number / null / -// undefined). `@ts-expect-error` directives keep the negative -// assertion structurally — if the helper signature accidentally -// widens its `path` parameter, the directive becomes a noop and the -// test fails. -// @ts-expect-error path is required to be a string -cipherstashJsonbPathQueryFirst(anyCol, 42) -// @ts-expect-error path is required to be a string -cipherstashJsonbGet(anyCol, null) diff --git a/packages/prisma-next/test/live/side-by-side-clients-live-pg.test.ts b/packages/prisma-next/test/live/side-by-side-clients-live-pg.test.ts deleted file mode 100644 index ec1f451cf..000000000 --- a/packages/prisma-next/test/live/side-by-side-clients-live-pg.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Live-PG v2/v3 coexistence: a v2 client (`cipherstashFromStackV2`, v2 - * contract, `public.eql_v2_encrypted` composite wire) and a v3 client - * (`cipherstashFromStack`, v3 contract, plain-JSONB domain wire) - * running in the SAME process against the SAME database — each with - * its own table, registry, descriptor, and middleware. Decision 1b: - * the two generations never share a client; this suite proves the two - * entry points coexist side by side without cross-talk (a mixed - * v2+v3-columns client is unsupported and pinned as a hard error in - * `from-stack-divergence-v3.test.ts`). - * - * The v2 half needs EQL v2 installed (the `local/` docker image ships - * it preinstalled); it skips itself when `public.eql_v2_encrypted` is - * absent so the suite stays runnable against a v3-only database. - */ - -import 'dotenv/config' -import type { PostgresContract } from '@prisma-next/adapter-postgres/types' -import { validateSqlContractFully } from '@prisma-next/sql-contract/validators' -import type postgres from 'postgres' -import { afterAll, beforeAll, expect, it } from 'vitest' -import { EncryptedString } from '../../src/execution/envelope-string' -import type { CipherstashSdk } from '../../src/execution/sdk' -import { - CIPHERSTASH_SPACE_ID, - CIPHERSTASH_STRING_CODEC_ID, -} from '../../src/extension-metadata/constants' -import { CIPHERSTASH_V3_EXTENSION_VERSION } from '../../src/extension-metadata/constants-v3' -import { deriveStackSchemas } from '../../src/stack/derive-schemas' -import { - type CipherstashFromStackResult, - cipherstashFromStackV2, -} from '../../src/stack/from-stack' -import { createCipherstashSdk } from '../../src/stack/sdk-adapter' -import { - callOperator, - columnAccessorV3, - getOperator, -} from '../v3/operator-lowering-v3.helpers' -import { installEqlV3IfNeeded } from './helpers/eql-v3' -import { - createLiveTable, - decryptCell, - insertEncryptedRows, - type LiveV3Client, - liveConnection, - liveV3Contract, - runLoweredSelect, - selectIdsWhere, - setupLiveV3, - TEXT_SEARCH, -} from './helpers/harness' -import { describeLivePg } from './helpers/live-gate' - -const V2_TABLE = 'pn_live_side_v2' -const V3_TABLE = 'pn_live_side_v3' -const EQL_V2_ENCRYPTED_TYPE = 'public.eql_v2_encrypted' - -const v2Contract = validateSqlContractFully({ - target: 'postgres', - targetFamily: 'sql', - profileHash: 'sha256:cipherstash-live-side-by-side-v2', - roots: {}, - capabilities: {}, - extensionPacks: {}, - meta: {}, - storage: { - storageHash: 'sha256:cipherstash-live-side-by-side-v2-storage', - namespaces: { - __unbound__: { - id: '__unbound__', - entries: { - table: { - [V2_TABLE]: { - columns: { - id: { - codecId: 'pg/text@1', - nativeType: 'text', - nullable: false, - }, - email: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - nativeType: EQL_V2_ENCRYPTED_TYPE, - nullable: true, - }, - }, - uniques: [], - indexes: [], - foreignKeys: [], - }, - }, - }, - }, - }, - }, - domain: { namespaces: { __unbound__: { models: {} } } }, -}) - -const v3Contract = liveV3Contract(V3_TABLE, { email: TEXT_SEARCH }) - -const V2_EMAIL = 'v2-resident@example.com' -const V3_EMAIL = 'v3-resident@example.com' - -describeLivePg('v2 and v3 clients side by side against live Postgres', () => { - let sql: postgres.Sql - let hasEqlV2 = false - let v2: CipherstashFromStackResult | undefined - let v2Sdk: CipherstashSdk | undefined - let v3: LiveV3Client - - beforeAll(async () => { - sql = liveConnection() - await installEqlV3IfNeeded(sql) - - // v3 half — always available once the bundle is installed. - await createLiveTable(sql, V3_TABLE, { email: TEXT_SEARCH }) - v3 = await setupLiveV3(v3Contract) - await insertEncryptedRows(sql, v3.middleware, V3_TABLE, [ - { - id: 'v3-row', - cells: { - email: { - codecId: TEXT_SEARCH, - value: EncryptedString.from(V3_EMAIL), - }, - }, - }, - ]) - - // v2 half — only when the database carries the EQL v2 composite type. - const [probe] = await sql<{ present: boolean }[]>` - SELECT to_regtype(${EQL_V2_ENCRYPTED_TYPE}) IS NOT NULL AS present - ` - hasEqlV2 = probe?.present === true - if (!hasEqlV2) return - - await sql.unsafe(`DROP TABLE IF EXISTS "${V2_TABLE}"`) - await sql.unsafe( - `CREATE TABLE "${V2_TABLE}" (id text PRIMARY KEY, email ${EQL_V2_ENCRYPTED_TYPE})`, - ) - v2 = await cipherstashFromStackV2({ contractJson: v2Contract }) - v2Sdk = createCipherstashSdk( - v2.encryptionClient, - deriveStackSchemas(v2Contract), - ) - const v2Middleware = v2.middleware[0] - if (!v2Middleware) - throw new Error('cipherstashFromStackV2 returned no middleware') - await insertEncryptedRows(sql, v2Middleware, V2_TABLE, [ - { - id: 'v2-row', - cells: { - email: { - codecId: CIPHERSTASH_STRING_CODEC_ID, - value: EncryptedString.from(V2_EMAIL), - }, - }, - }, - ]) - }, 240_000) - - afterAll(async () => { - if (sql) await sql.end() - }) - - it('the two runtime descriptors share the pack id but carry distinct versions', () => { - // Both descriptors present the PACK id ('cipherstash') — the - // runtime matches contract extensionPacks by descriptor id, and one - // control descriptor emits both generations' contracts. Generation - // identity lives in the version. - expect(v3.cs.extensions[0]?.id).toBe(CIPHERSTASH_SPACE_ID) - expect(v3.cs.extensions[0]?.version).toBe(CIPHERSTASH_V3_EXTENSION_VERSION) - if (v2) { - expect(v2.extensions[0]?.id).toBe(CIPHERSTASH_SPACE_ID) - expect(v2.extensions[0]?.version).not.toBe(v3.cs.extensions[0]?.version) - } - }) - - it('the v3 client round-trips and queries with the v2 client alive in-process', async () => { - const result = await runLoweredSelect( - sql, - v3.middleware, - v3Contract, - selectIdsWhere( - V3_TABLE, - callOperator( - getOperator('eqlEq'), - columnAccessorV3(V3_TABLE, 'email', TEXT_SEARCH), - V3_EMAIL, - ), - ), - ) - expect(result.ids).toEqual(['v3-row']) - - const rows = (await sql.unsafe( - `SELECT email FROM "${V3_TABLE}" WHERE id = 'v3-row'`, - )) as unknown as Array<{ email: unknown }> - const decrypted = await decryptCell(v3.sdk, EncryptedString, { - cell: rows[0]?.email, - table: V3_TABLE, - column: 'email', - }) - expect(decrypted).toBe(V3_EMAIL) - }, 60_000) - - it('the two wires are physically distinct: v2 composite literal vs v3 JSONB document', async (ctx) => { - if (!hasEqlV2) return ctx.skip() - const [v2Row] = (await sql.unsafe( - `SELECT email::text AS wire FROM "${V2_TABLE}" WHERE id = 'v2-row'`, - )) as unknown as Array<{ wire: string }> - expect(v2Row?.wire.startsWith('(')).toBe(true) - - const [v3Row] = (await sql.unsafe( - `SELECT email::text AS wire FROM "${V3_TABLE}" WHERE id = 'v3-row'`, - )) as unknown as Array<{ wire: string }> - expect(v3Row?.wire.trimStart().startsWith('{')).toBe(true) - }, 60_000) - - it('the v2 client round-trips through its own registry and wire', async (ctx) => { - if (!hasEqlV2 || !v2Sdk) return ctx.skip() - // `eql_v2_encrypted` is a composite `(data jsonb)`; project the - // jsonb field so the driver hands back the EQL payload document. - const [row] = (await sql.unsafe( - `SELECT (email).data AS payload FROM "${V2_TABLE}" WHERE id = 'v2-row'`, - )) as unknown as Array<{ payload: unknown }> - expect(row?.payload).toBeTypeOf('object') - - const decrypted = await EncryptedString.fromInternal({ - ciphertext: row?.payload, - table: V2_TABLE, - column: 'email', - sdk: v2Sdk, - }).decrypt() - expect(decrypted).toBe(V2_EMAIL) - }, 60_000) -}) 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 22fdc87d0..635f04217 100644 --- a/packages/prisma-next/test/operation-types.types.test-d.ts +++ b/packages/prisma-next/test/operation-types.types.test-d.ts @@ -1,35 +1,17 @@ /** * Type-level acceptance tests for `QueryOperationTypes` in - * `src/types/operation-types.ts`. + * `src/types/operation-types.ts` (EQL v3). * * Mirrors the framework's `OpMatchesField` predicate (defined in * `packages/3-extensions/sql-orm-client/src/types.ts`) inline so the - * matching behaviour can be exercised against a synthetic - * `CodecTypes` table without pulling in the full ORM model accessor. + * matching behaviour can be exercised against the real `CodecTypes` + * table without pulling in the full ORM model accessor. * - * The tests pin three surface contracts: - * - * 1. **Codec-id dispatch (positive/negative)** for the legacy - * single-codec entries (`cipherstashEq`, `cipherstashIlike`): - * the operator must surface on its target codec id and on no - * other. - * - * 2. **Trait dispatch (positive/negative)** for the multi-codec - * entries (`cipherstashNe`, `cipherstashInArray`, - * `cipherstashNotInArray`, `cipherstashNotIlike`, - * `cipherstashGt`, `cipherstashGte`, `cipherstashLt`, - * `cipherstashLte`, `cipherstashBetween`, `cipherstashNotBetween`, - * `cipherstashJsonbPathExists`): the operator must surface on - * every cipherstash codec whose trait set carries the gating trait - * and on no codec without it (notably `pg/text@1`, which is the - * regression-pinning negative case for the wrong-SQL `eq` - * footgun). - * - * 3. **Generation split** for the v3 `eql*` entries (against the - * REAL `CodecTypes` table): every `eql*` operator dispatches on a - * `cipherstash:v3-*` marker only v3 entries carry, and no v2 - * `cipherstash*` operator surfaces on a v3 column — both - * directions of the disjoint-name-set correspondence. + * Pins the v3 dispatch contract: every `eql*` operator surfaces exactly + * on the v3 columns whose `cipherstash:v3-*` marker tier carries its + * capability, and on no column without it; storage-only v3 domains + * (boolean, bare `eql_v3_text`) surface NO operators; and the retired + * names (`cipherstashJsonContains`, a negated match) are absent. * * AGENTS.md permits `@ts-expect-error` exclusively in negative * type-test files; this is one of them. @@ -38,70 +20,6 @@ import type { CodecTypes as RealCodecTypes } from '../src/types/codec-types' import type { QueryOperationTypes } from '../src/types/operation-types' -// -- Synthetic CodecTypes table ---------------------------------------------- -// -// Declares each cipherstash codec id under test with the traits its -// runtime descriptor advertises (per -// `extension-metadata/constants.ts:CIPHERSTASH_CODEC_TRAITS`). The -// trait identifiers use the `cipherstash:` namespace literally, -// matching the runtime widening of `descriptor.traits` to -// `readonly string[]` performed by the ORM model accessor. -// -// `pg/text@1` is included as the regression-pinning negative codec — -// it carries the framework's built-in `textual` / `equality` traits -// but none of the cipherstash-namespaced traits, so trait-dispatched -// cipherstash operators must NOT surface on it. - -type CSEq = 'cipherstash:equality' -type CSOR = 'cipherstash:order-and-range' -type CSFTS = 'cipherstash:free-text-search' -type CSSJ = 'cipherstash:searchable-json' - -type CT = { - readonly 'cipherstash/string@1': { - readonly input: string - readonly output: string - readonly traits: CSEq | CSOR | CSFTS - } - readonly 'cipherstash/double@1': { - readonly input: number - readonly output: number - readonly traits: CSEq | CSOR - } - readonly 'cipherstash/bigint@1': { - readonly input: bigint - readonly output: bigint - readonly traits: CSEq | CSOR - } - readonly 'cipherstash/date@1': { - readonly input: Date - readonly output: Date - readonly traits: CSEq | CSOR - } - readonly 'cipherstash/boolean@1': { - readonly input: boolean - readonly output: boolean - readonly traits: CSEq - } - readonly 'cipherstash/json@1': { - readonly input: unknown - readonly output: unknown - readonly traits: CSSJ - } - readonly 'pg/text@1': { - readonly input: string - readonly output: string - readonly traits: 'textual' | 'equality' - } - readonly 'pg/bool@1': { - readonly input: boolean - readonly output: boolean - readonly traits: 'boolean' - } -} - -type Ops = QueryOperationTypes - // -- Inline `OpMatchesField` (mirrors the framework definition) -------------- type OpMatchesField< @@ -125,132 +43,17 @@ type OpMatchesField< : false type Expect = T -type M = OpMatchesField - -// -- cipherstashEq (string only) -------------------------------------------- - -type _eq_string_pos = Expect> -// @ts-expect-error cipherstashEq must not surface on cipherstash/double@1. -type _eq_double_neg = Expect> -// @ts-expect-error cipherstashEq must not surface on pg/text@1. -type _eq_text_neg = Expect> - -// -- cipherstashIlike (string only) ----------------------------------------- - -type _ilike_string_pos = Expect> -// @ts-expect-error cipherstashIlike must not surface on cipherstash/double@1. -type _ilike_double_neg = Expect> - -// -- cipherstashNotIlike (free-text-search trait dispatch) -------------------- - -type _notilike_string_pos = Expect< - M<'cipherstashNotIlike', 'cipherstash/string@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. -type _notilike_text_neg = Expect> - -// -- cipherstashNe (equality trait — string/double/bigint/date/boolean) ------ - -type _ne_string_pos = Expect> -type _ne_double_pos = Expect> -type _ne_bigint_pos = Expect> -type _ne_date_pos = Expect> -type _ne_boolean_pos = Expect> -// @ts-expect-error cipherstashNe must not surface on cipherstash/json@1 (no equality trait). -type _ne_json_neg = Expect> -// @ts-expect-error regression: framework `equality` trait must not re-attach cipherstash ops on pg/text@1. -type _ne_text_neg = Expect> - -// -- cipherstashInArray (equality trait) ------------------------------------ - -type _ina_string_pos = Expect> -type _ina_boolean_pos = Expect> -// @ts-expect-error cipherstashInArray must not surface on cipherstash/json@1. -type _ina_json_neg = Expect> -// @ts-expect-error cipherstashInArray must not surface on pg/text@1. -type _ina_text_neg = Expect> - -// -- cipherstashNotInArray (equality trait) --------------------------------- - -type _nina_double_pos = Expect< - M<'cipherstashNotInArray', 'cipherstash/double@1'> -> -// @ts-expect-error cipherstashNotInArray must not surface on cipherstash/json@1. -type _nina_json_neg = Expect> - -// -- cipherstashGt (order-and-range trait — string/double/bigint/date) ------- - -type _gt_string_pos = Expect> -type _gt_double_pos = Expect> -type _gt_bigint_pos = Expect> -type _gt_date_pos = Expect> -// @ts-expect-error cipherstashGt must not surface on cipherstash/boolean@1 (no order-and-range trait). -type _gt_boolean_neg = Expect> -// @ts-expect-error cipherstashGt must not surface on cipherstash/json@1. -type _gt_json_neg = Expect> -// @ts-expect-error cipherstashGt must not surface on pg/text@1. -type _gt_text_neg = Expect> - -// -- cipherstashGte / cipherstashLt / cipherstashLte (same trait set) ------- - -type _gte_double_pos = Expect> -type _lt_bigint_pos = Expect> -type _lte_date_pos = Expect> -// @ts-expect-error cipherstashGte must not surface on cipherstash/boolean@1. -type _gte_boolean_neg = Expect> -// @ts-expect-error cipherstashLt must not surface on cipherstash/json@1. -type _lt_json_neg = Expect> - -// -- cipherstashBetween / cipherstashNotBetween (order-and-range) ----------- - -type _between_string_pos = Expect< - M<'cipherstashBetween', 'cipherstash/string@1'> -> -type _between_double_pos = Expect< - M<'cipherstashBetween', 'cipherstash/double@1'> -> -type _notbetween_date_pos = Expect< - M<'cipherstashNotBetween', 'cipherstash/date@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. -type _notbetween_text_neg = Expect> - -// -- cipherstashJsonbPathExists (searchable-json trait dispatch) -------------- - -type _jpe_json_pos = Expect< - M<'cipherstashJsonbPathExists', 'cipherstash/json@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. -type _jpe_text_neg = Expect> // -- EQL v3 dispatch (against the REAL CodecTypes table) --------------------- // // The v3 assertions run against the real `CodecTypes` export (which // carries the 40 v3 entries plus the `cipherstash:v3-*` type-level -// markers) rather than the synthetic table above, so drift in the real -// table breaks this file. Pins the generation-split contracts (the v2 -// `cipherstash*` and v3 `eql*` method-name sets are disjoint — PR #655 -// review): +// markers), so drift in the real table breaks this file: // // - every `eql*` operator surfaces exactly on the v3 columns whose -// marker tier carries its capability, and on NO v2 codec; -// - no v2 `cipherstash*` operator surfaces on any v3 column — v3 -// entries carry only `cipherstash:v3-*` markers, never the shared -// v2 trait strings or the legacy codec-id pin; -// - storage-only v3 domains (boolean, bare `eql_v3_text`) surface -// NO operators at all; +// marker tier carries its capability; +// - storage-only v3 domains (boolean, bare `eql_v3_text`) surface NO +// operators at all; // - the retired names (`cipherstashJsonContains`, and any negated // match) are gone from the operation table entirely. @@ -319,39 +122,6 @@ type _v3_in_boolean_neg = Expect< M3<'eqlIn', 'cipherstash/eql-v3/eql_v3_boolean@1'> > -// Generation split, v3→v2 direction: `eql*` never surfaces on a v2 codec. -type _v3_eq_v2string_neg = Expect< - // @ts-expect-error eqlEq must not surface on the v2 string codec. - M3<'eqlEq', 'cipherstash/string@1'> -> -type _v3_match_v2string_neg = Expect< - // @ts-expect-error eqlMatch must not surface on the v2 string codec. - M3<'eqlMatch', 'cipherstash/string@1'> -> -type _v3_jsoncontains_v2json_neg = Expect< - // @ts-expect-error eqlJsonContains must not surface on the v2 json codec. - M3<'eqlJsonContains', 'cipherstash/json@1'> -> - -// Generation split, v2→v3 direction: `cipherstash*` never surfaces on a -// v3 column (v3 entries carry no shared v2 trait and no legacy codec id). -type _v2_eq_v3search_neg = Expect< - // @ts-expect-error v2 cipherstashEq must not surface on eql_v3_text_search. - M3<'cipherstashEq', 'cipherstash/eql-v3/eql_v3_text_search@1'> -> -type _v2_ilike_v3search_neg = Expect< - // @ts-expect-error v2 cipherstashIlike must not surface on eql_v3_text_search. - M3<'cipherstashIlike', 'cipherstash/eql-v3/eql_v3_text_search@1'> -> -type _v2_gt_v3ord_neg = Expect< - // @ts-expect-error v2 cipherstashGt must not surface on eql_v3_double_ord. - M3<'cipherstashGt', 'cipherstash/eql-v3/eql_v3_double_ord@1'> -> -type _v2_jpe_v3json_neg = Expect< - // @ts-expect-error v2-only cipherstashJsonbPathExists must not surface on eql_v3_json. - M3<'cipherstashJsonbPathExists', 'cipherstash/eql-v3/eql_v3_json@1'> -> - // -- Anchor unused type aliases so noUnusedLocals stays happy --------------- export type _Anchors = [ @@ -367,52 +137,4 @@ export type _Anchors = [ _v3_match_texteq_neg, _v3_gt_eqonly_neg, _v3_in_boolean_neg, - _v3_eq_v2string_neg, - _v3_match_v2string_neg, - _v3_jsoncontains_v2json_neg, - _v2_eq_v3search_neg, - _v2_ilike_v3search_neg, - _v2_gt_v3ord_neg, - _v2_jpe_v3json_neg, - _eq_string_pos, - _eq_double_neg, - _eq_text_neg, - _ilike_string_pos, - _ilike_double_neg, - _notilike_string_pos, - _notilike_double_neg, - _notilike_text_neg, - _ne_string_pos, - _ne_double_pos, - _ne_bigint_pos, - _ne_date_pos, - _ne_boolean_pos, - _ne_json_neg, - _ne_text_neg, - _ina_string_pos, - _ina_boolean_pos, - _ina_json_neg, - _ina_text_neg, - _nina_double_pos, - _nina_json_neg, - _gt_string_pos, - _gt_double_pos, - _gt_bigint_pos, - _gt_date_pos, - _gt_boolean_neg, - _gt_json_neg, - _gt_text_neg, - _gte_double_pos, - _lt_bigint_pos, - _lte_date_pos, - _gte_boolean_neg, - _lt_json_neg, - _between_string_pos, - _between_double_pos, - _notbetween_date_pos, - _between_boolean_neg, - _notbetween_text_neg, - _jpe_json_pos, - _jpe_string_neg, - _jpe_text_neg, ] diff --git a/packages/prisma-next/test/operator-lowering-equality.test.ts b/packages/prisma-next/test/operator-lowering-equality.test.ts deleted file mode 100644 index 694d0759b..000000000 --- a/packages/prisma-next/test/operator-lowering-equality.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Operator lowering — equality-family operators on - * `cipherstash/string@1` columns: - * - * - `cipherstashEq` (single-codec registration on the string codec) - * - `cipherstashNe` / `cipherstashInArray` / `cipherstashNotInArray` - * (trait-dispatched via `cipherstash:equality`) - * - * The lowered SQL pins the `eql_v2.eq(...)` shape (positive form) and - * the `NOT eql_v2.eq(...)` / OR-of-equalities (variable-arity forms). - * Each bound param is an `EncryptedString` envelope tagged with the - * `(table, column)` routing key — the cipherstash bulk-encrypt - * middleware identifies envelopes via `instanceof` and groups them by - * routing key at the encode-params boundary. - * - * Shared adapter / contract / operator-invocation scaffolding lives in - * `operator-lowering.helpers.ts`. - */ - -import { describe, expect, it } from 'vitest' -import { EncryptedString } from '../src/execution/envelope-string' -import { - COLUMN, - callOperator, - columnAccessor, - contract, - getOperator, - literalParamValue, - makeAdapter, - selectWithWhere, - TABLE, -} from './operator-lowering.helpers' - -describe('cipherstash operator lowering — cipherstashEq', () => { - it('lowers email.cipherstashEq(plaintext) to eql_v2.eq("email", $1::eql_v2_encrypted)', () => { - const op = getOperator('cipherstashEq') - const predicate = callOperator( - op, - columnAccessor(TABLE, COLUMN), - 'alice@example.com', - ) - const ast = selectWithWhere(predicate) - - const lowered = makeAdapter().lower(ast, { contract }) - - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE eql_v2.eq("user"."email", $1::eql_v2_encrypted)"`, - ) - }) - - it('binds the plaintext as an EncryptedString envelope tagged with the cipherstash routing key', () => { - const op = getOperator('cipherstashEq') - const predicate = callOperator( - op, - columnAccessor(TABLE, COLUMN), - 'alice@example.com', - ) - const ast = selectWithWhere(predicate) - - const lowered = makeAdapter().lower(ast, { contract }) - - // Single bound param; it is the `EncryptedString` envelope (NOT the - // raw plaintext string) so the bulk-encrypt middleware can identify - // it via `value instanceof EncryptedString` and group it by routing - // key. Stamping `(table, column)` on the envelope at lowering time - // is the mechanism that lets the SELECT-side (which - // `bulk-encrypt.ts:stampRoutingKeysFromAst` does not walk — only - // insert/update) still participate in the routing-key grouping. - expect(lowered.params).toHaveLength(1) - const envelope = literalParamValue(lowered.params[0]) - expect(envelope).toBeInstanceOf(EncryptedString) - const handle = (envelope as EncryptedString).expose() - expect(handle.plaintext).toBe('alice@example.com') - expect(handle.table).toBe(TABLE) - expect(handle.column).toBe(COLUMN) - }) - - it('passes a pre-built EncryptedString envelope through unchanged (advanced caller path)', () => { - const op = getOperator('cipherstashEq') - const userEnvelope = EncryptedString.from('alice@example.com') - const predicate = callOperator( - op, - columnAccessor(TABLE, COLUMN), - userEnvelope, - ) - const ast = selectWithWhere(predicate) - - const lowered = makeAdapter().lower(ast, { contract }) - - // The same envelope object flows through; the operator only - // augments it with the routing key (write-once-wins semantics — - // see `setHandleRoutingKey`). - expect(literalParamValue(lowered.params[0])).toBe(userEnvelope) - const handle = userEnvelope.expose() - expect(handle.table).toBe(TABLE) - expect(handle.column).toBe(COLUMN) - }) -}) - -describe('cipherstash operator lowering — equality extensions', () => { - // `cipherstashNe`, `cipherstashInArray`, `cipherstashNotInArray` - // dispatch via the `cipherstash:equality` trait — visible on - // string, double, bigint, date, boolean codecs. - - it('lowers email.cipherstashNe(plaintext) to NOT eql_v2.eq(...)', () => { - const op = getOperator('cipherstashNe') - const predicate = callOperator( - op, - columnAccessor(TABLE, COLUMN), - 'alice@example.com', - ) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE NOT eql_v2.eq("user"."email", $1::eql_v2_encrypted)"`, - ) - expect(lowered.params).toHaveLength(1) - expect(literalParamValue(lowered.params[0])).toBeInstanceOf(EncryptedString) - }) - - it('lowers cipherstashInArray with a single element to a one-term OR', () => { - const op = getOperator('cipherstashInArray') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), [ - 'alice@example.com', - ]) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE (eql_v2.eq("user"."email", $1::eql_v2_encrypted))"`, - ) - expect(lowered.params).toHaveLength(1) - }) - - it('lowers cipherstashInArray with two elements to a two-term OR', () => { - const op = getOperator('cipherstashInArray') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), [ - 'a@x.com', - 'b@x.com', - ]) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE (eql_v2.eq("user"."email", $1::eql_v2_encrypted) OR eql_v2.eq("user"."email", $2::eql_v2_encrypted))"`, - ) - expect(lowered.params).toHaveLength(2) - }) - - it('lowers cipherstashInArray with three elements to a three-term OR', () => { - const op = getOperator('cipherstashInArray') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), [ - 'a@x.com', - 'b@x.com', - 'c@x.com', - ]) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE (eql_v2.eq("user"."email", $1::eql_v2_encrypted) OR eql_v2.eq("user"."email", $2::eql_v2_encrypted) OR eql_v2.eq("user"."email", $3::eql_v2_encrypted))"`, - ) - expect(lowered.params).toHaveLength(3) - // Every envelope shares the same `(table, column)` routing key — - // the bulk-encrypt grouping invariant for variable-arity ops. - for (const param of lowered.params) { - const envelope = literalParamValue(param) - expect(envelope).toBeInstanceOf(EncryptedString) - const handle = (envelope as EncryptedString).expose() - expect(handle.table).toBe(TABLE) - expect(handle.column).toBe(COLUMN) - } - }) - - it('lowers cipherstashNotInArray to NOT-prefixed OR-of-equalities', () => { - const op = getOperator('cipherstashNotInArray') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), [ - 'a@x.com', - 'b@x.com', - ]) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE NOT (eql_v2.eq("user"."email", $1::eql_v2_encrypted) OR eql_v2.eq("user"."email", $2::eql_v2_encrypted))"`, - ) - }) - - it('cipherstashInArray rejects empty arrays with a descriptive error', () => { - const op = getOperator('cipherstashInArray') - expect(() => callOperator(op, columnAccessor(TABLE, COLUMN), [])).toThrow( - /empty array/, - ) - }) - - it('cipherstashInArray rejects non-array arguments with a descriptive error', () => { - const op = getOperator('cipherstashInArray') - expect(() => - callOperator(op, columnAccessor(TABLE, COLUMN), 'not-an-array'), - ).toThrow(/expected an array/) - }) -}) diff --git a/packages/prisma-next/test/operator-lowering-order-range.test.ts b/packages/prisma-next/test/operator-lowering-order-range.test.ts deleted file mode 100644 index 6d74b0a64..000000000 --- a/packages/prisma-next/test/operator-lowering-order-range.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Operator lowering — order-and-range operators trait-dispatched via - * `cipherstash:order-and-range`: - * - * - `cipherstashGt` / `cipherstashGte` / `cipherstashLt` / - * `cipherstashLte` - * - `cipherstashBetween` / `cipherstashNotBetween` - * - * The trait is visible on the string, double, bigint, and date - * codecs; this file exercises the lowered SQL shape against the - * string column. Per-codec envelope wrapping (the dispatch table - * picks `EncryptedDouble` / `EncryptedBigInt` / `EncryptedDate` - * subclasses for the matching columns) lives in the keep file - * `operator-lowering.test.ts`. - * - * Shared adapter / contract / operator-invocation scaffolding lives in - * `operator-lowering.helpers.ts`. - */ - -import { describe, expect, it } from 'vitest' -import { - COLUMN, - callOperator, - columnAccessor, - contract, - getOperator, - makeAdapter, - selectWithWhere, - TABLE, -} from './operator-lowering.helpers' - -describe('cipherstash operator lowering — order-and-range extensions', () => { - // `cipherstashGt/Gte/Lt/Lte/Between/NotBetween` dispatch via the - // `cipherstash:order-and-range` trait — visible on string, - // double, bigint, date codecs. - - it('lowers cipherstashGt(plaintext) to eql_v2.gt(...)', () => { - const op = getOperator('cipherstashGt') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), 'm') - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE eql_v2.gt("user"."email", $1::eql_v2_encrypted)"`, - ) - }) - - it('lowers cipherstashGte(plaintext) to eql_v2.gte(...)', () => { - const op = getOperator('cipherstashGte') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), 'm') - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE eql_v2.gte("user"."email", $1::eql_v2_encrypted)"`, - ) - }) - - it('lowers cipherstashLt(plaintext) to eql_v2.lt(...)', () => { - const op = getOperator('cipherstashLt') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), 'm') - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE eql_v2.lt("user"."email", $1::eql_v2_encrypted)"`, - ) - }) - - it('lowers cipherstashLte(plaintext) to eql_v2.lte(...)', () => { - const op = getOperator('cipherstashLte') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), 'm') - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE eql_v2.lte("user"."email", $1::eql_v2_encrypted)"`, - ) - }) - - it('lowers cipherstashBetween(lo, hi) to gte AND lte', () => { - const op = getOperator('cipherstashBetween') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), 'a', 'm') - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE eql_v2.gte("user"."email", $1::eql_v2_encrypted) AND eql_v2.lte("user"."email", $2::eql_v2_encrypted)"`, - ) - expect(lowered.params).toHaveLength(2) - }) - - it('lowers cipherstashNotBetween(lo, hi) to NOT (gte AND lte)', () => { - const op = getOperator('cipherstashNotBetween') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), 'a', 'm') - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE NOT (eql_v2.gte("user"."email", $1::eql_v2_encrypted) AND eql_v2.lte("user"."email", $2::eql_v2_encrypted))"`, - ) - }) - - it('cipherstashBetween rejects wrong arity with a descriptive error', () => { - const op = getOperator('cipherstashBetween') - expect(() => callOperator(op, columnAccessor(TABLE, COLUMN), 'a')).toThrow( - /expected 2/, - ) - }) -}) diff --git a/packages/prisma-next/test/operator-lowering-text-search.test.ts b/packages/prisma-next/test/operator-lowering-text-search.test.ts deleted file mode 100644 index 2e0d842db..000000000 --- a/packages/prisma-next/test/operator-lowering-text-search.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Operator lowering — free-text-search operators: - * - * - `cipherstashIlike` (single-codec registration on the string codec) - * - `cipherstashNotIlike` (trait-dispatched via - * `cipherstash:free-text-search`) - * - * EQL's `ilike` function takes an encrypted match-term (the pattern is - * encrypted just like an `eq` value); the bound param is an - * `EncryptedString` envelope tagged with the `(table, column)` routing - * key. - * - * Shared adapter / contract / operator-invocation scaffolding lives in - * `operator-lowering.helpers.ts`. - */ - -import { describe, expect, it } from 'vitest' -import { EncryptedString } from '../src/execution/envelope-string' -import { - COLUMN, - callOperator, - columnAccessor, - contract, - getOperator, - literalParamValue, - makeAdapter, - selectWithWhere, - TABLE, -} from './operator-lowering.helpers' - -describe('cipherstash operator lowering — cipherstashIlike', () => { - it('lowers email.cipherstashIlike(pattern) to eql_v2.ilike("email", $1::eql_v2_encrypted)', () => { - const op = getOperator('cipherstashIlike') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), '%alice%') - const ast = selectWithWhere(predicate) - - const lowered = makeAdapter().lower(ast, { contract }) - - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE eql_v2.ilike("user"."email", $1::eql_v2_encrypted)"`, - ) - }) - - it('binds the pattern as an EncryptedString envelope tagged with the cipherstash routing key', () => { - const op = getOperator('cipherstashIlike') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), '%alice%') - const ast = selectWithWhere(predicate) - - const lowered = makeAdapter().lower(ast, { contract }) - - expect(lowered.params).toHaveLength(1) - const envelope = literalParamValue(lowered.params[0]) - expect(envelope).toBeInstanceOf(EncryptedString) - const handle = (envelope as EncryptedString).expose() - expect(handle.plaintext).toBe('%alice%') - expect(handle.table).toBe(TABLE) - expect(handle.column).toBe(COLUMN) - }) -}) - -describe('cipherstash operator lowering — free-text-search extensions', () => { - it('lowers cipherstashNotIlike(pattern) to NOT eql_v2.ilike(...)', () => { - const op = getOperator('cipherstashNotIlike') - const predicate = callOperator(op, columnAccessor(TABLE, COLUMN), '%alice%') - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE NOT eql_v2.ilike("user"."email", $1::eql_v2_encrypted)"`, - ) - }) -}) diff --git a/packages/prisma-next/test/operator-lowering.helpers.ts b/packages/prisma-next/test/operator-lowering.helpers.ts deleted file mode 100644 index faa68f762..000000000 --- a/packages/prisma-next/test/operator-lowering.helpers.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Shared scaffolding for the `operator-lowering*.test.ts` files. - * - * The cipherstash operator-lowering tests all use the same: - * - Postgres runtime adapter composed with the cipherstash runtime - * descriptor (so `cipherstash/*@1` codecs are resolvable at - * lower-time and `renderTypedParam` can emit - * `$N::eql_v2_encrypted`). - * - Contract scaffold with one row of per-codec columns on a `user` - * table so trait-dispatched operators can be exercised against - * each codec. - * - Operator-invocation glue (`getOperator`, `callOperator`, - * `columnAccessor`, `selectWithWhere`). - * - * The lowering shape is verified against the stack-composed Postgres - * runtime adapter (the helper at `packages/3-targets/6-adapters/ - * postgres/test/helpers/composed-adapter.ts` reproduced inline so - * cipherstash does not pick up a postgres-package test export - * dependency) loaded with the cipherstash runtime descriptor. The - * adapter's `lower` is what the runtime's encode pipeline calls before - * driver execution; pinning its output is the strongest unit-level - * assurance available without standing up a real Postgres + EQL - * bundle. - */ - -import postgresRuntimeAdapter from '@prisma-next/adapter-postgres/runtime' -import type { PostgresContract } from '@prisma-next/adapter-postgres/types' -import type { - RuntimeExtensionDescriptor, - RuntimeTargetDescriptor, -} from '@prisma-next/framework-components/execution' -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, -} from '@prisma-next/sql-relational-core/ast' -import { vi } from 'vitest' -import { cipherstashQueryOperations } from '../src/execution/operators' -import type { CipherstashSdk } from '../src/execution/sdk' -import { createCipherstashRuntimeDescriptor } from '../src/exports/runtime' -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 '../src/extension-metadata/constants' - -// Minimal SDK stub. Operator lowering doesn't talk to the SDK — the codec -// captures it lazily for the read-side decrypt path — but -// `createCipherstashRuntimeDescriptor({ sdk })` requires one. -export function emptySdk(): CipherstashSdk { - return { - decrypt: vi.fn(), - bulkEncrypt: vi.fn(), - bulkDecrypt: vi.fn(), - } -} - -export const TABLE = 'user' -export const COLUMN = 'email' - -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: [], - }, - }, - }, - }, - }, - }, - domain: { namespaces: { __unbound__: { models: {} } } }, -}) - -// Stub runtime target — the Postgres adapter only consults `familyId` / -// `targetId` on the target during `create`. Replicates the helper at -// `packages/3-targets/6-adapters/postgres/test/helpers/composed-adapter.ts` -// inline so cipherstash does not depend on a postgres-package test export. -const stubRuntimeTarget: RuntimeTargetDescriptor<'sql', 'postgres'> = { - kind: 'target', - id: 'postgres', - version: '0.0.1', - familyId: 'sql', - targetId: 'postgres', - create() { - return { familyId: 'sql', targetId: 'postgres' } - }, -} - -export function makeAdapter() { - // Compose the Postgres runtime adapter with the cipherstash runtime - // descriptor so the `cipherstash/string@1` codec is resolvable at - // lower-time. `renderTypedParam` reads - // `meta.db.sql.postgres.nativeType` off the registered codec to emit - // `$N::eql_v2_encrypted`; without the cipherstash pack in the stack - // the codec lookup would throw with a "missing extension pack" hint. - const cipherstash: RuntimeExtensionDescriptor<'sql', 'postgres'> = - createCipherstashRuntimeDescriptor({ sdk: emptySdk() }) - return postgresRuntimeAdapter.create({ - target: stubRuntimeTarget, - adapter: postgresRuntimeAdapter, - driver: undefined, - extensionPacks: [cipherstash], - }) -} - -const cipherstashOperatorsByMethod = cipherstashQueryOperations() - -export function getOperator(method: string): SqlOperationDescriptor { - const op = cipherstashOperatorsByMethod[method] - if (!op) { - throw new Error( - `cipherstash operator descriptor for method "${method}" not found`, - ) - } - return op -} - -/** - * Invoke an operator's `impl` and return the produced AST node. The - * impl's declared return type is the framework's `QueryOperationReturn` - * (intentionally narrow — `sql-contract` does not depend on - * `relational-core`); the practical shape every `buildOperation`-built - * impl returns is `Expression<...>` whose `buildAst()` yields an - * `AnyExpression`. Mirrors the cast in - * `packages/3-extensions/sql-orm-client/src/model-accessor.ts:170`. - */ -export function callOperator( - op: SqlOperationDescriptor, - ...args: unknown[] -): AnyExpression { - // `op.impl` is typed `(...args: never[]) => QueryOperationReturn` to - // block accidental direct invocation; the practical shape is - // `(self, ...args) => Expression<...>`. Cast through `unknown` to - // bridge the framework's intentionally-narrow declared type. - const impl = op.impl as unknown as (...args: unknown[]) => { - buildAst(): AnyExpression - } - return impl(...args).buildAst() -} - -/** - * Build the same `Expression`-like shape the ORM model accessor - * synthesises for a column field (see - * `packages/3-extensions/sql-orm-client/src/model-accessor.ts:139-150`): - * an object with `buildAst()` returning the underlying `ColumnRef` plus - * the column's return type metadata. The operator impls call - * `toExpr(self)` which destructures `buildAst()` to get the AST node. - */ -export function columnAccessor( - table: string, - column: string, - codecId: string = CIPHERSTASH_STRING_CODEC_ID, -) { - const ref = ColumnRef.of(table, column) - return { - returnType: { codecId, nullable: true }, - buildAst: () => ref, - } -} - -export function selectWithWhere(whereExpr: AnyExpression) { - return SelectAst.from(TableSource.named(TABLE)) - .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 deleted file mode 100644 index 6d276bb76..000000000 --- a/packages/prisma-next/test/operator-lowering.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Operator lowering — cross-cutting cipherstash predicates that don't - * fit into a single operator-family file: - * - * - `null short-circuit` — `WHERE col IS [NOT] NULL` lowers to a - * plain Postgres null check (no EQL function call); the - * cipherstash extension must not intercept null checks. Null-check - * methods construct `NullCheckExpr` directly and never enter the - * operator-registry dispatch path, so cipherstash does not need to - * register an extension handler. The snapshot pins the absence of - * any EQL function call. - * - `per-codec envelope dispatch` — trait-dispatched operators - * (`cipherstashGt`, `cipherstashNe`, …) wrap the user-supplied - * value in the envelope subclass that matches the column's codec - * id at impl time. Each row pins the dispatch is correct for one - * codec (string / double / bigint / date / boolean). - * - `cipherstashJsonbPathExists` — lowers to - * `eql_v2.jsonb_path_exists(col, $1)`. The path is a plain text - * bind, not an envelope. - * - `createCipherstashRuntimeDescriptor — queryOperations - * registration` — exposes the full cipherstash operator surface - * via the runtime descriptor. Names are cipherstash-prefixed so - * they coexist with the framework's built-in `eq` / `ilike` - * registrations rather than overriding them. Two registration - * shapes coexist (see ADR 214): single-codec (`cipherstashEq` / - * `cipherstashIlike` target the string codec by id) and - * trait-namespaced (every other operator targets a `cipherstash:*` - * trait, attached to every codec descriptor whose `traits` list - * contains that identifier). - * - * Single-codec operator families have their own files: - * - `operator-lowering-equality.test.ts` - * - `operator-lowering-text-search.test.ts` - * - `operator-lowering-order-range.test.ts` - * - * The shared adapter / contract / operator-invocation scaffolding - * lives in `operator-lowering.helpers.ts` and is reused across all - * four operator-lowering test files. - * - * Why we do not exercise the bulk-encrypt middleware here. The - * middleware reads `params.entries()` and stamps ciphertexts via - * `replaceValues` — a concern of the runtime's `beforeExecute` chain, - * not of the AST → SQL lowering. The middleware's contract is covered - * exhaustively by `bulk-encrypt-middleware.test.ts` and the SDK-call- - * counter assertion of `storage-roundtrip.e2e.integration.test.ts`. - * These snapshot tests assert only that the SQL shape produced by - * lowering would be a valid input to that middleware (a `ParamRef` - * carrying an `EncryptedString` envelope tagged with the cipherstash - * codec id). - */ - -import { ColumnRef, NullCheckExpr } from '@prisma-next/sql-relational-core/ast' -import { describe, expect, it } from 'vitest' -import { EncryptedBigInt } from '../src/execution/envelope-bigint' -import { EncryptedBoolean } from '../src/execution/envelope-boolean' -import { EncryptedDate } from '../src/execution/envelope-date' -import { EncryptedDouble } from '../src/execution/envelope-double' -import { createCipherstashRuntimeDescriptor } from '../src/exports/runtime' -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, -} from '../src/extension-metadata/constants' -import { - COLUMN, - callOperator, - columnAccessor, - contract, - emptySdk, - getOperator, - literalParamValue, - makeAdapter, - selectWithWhere, - TABLE, -} from './operator-lowering.helpers' - -describe('cipherstash operator lowering — null short-circuit', () => { - // The `isNull` / `isNotNull` ORM column methods construct - // `NullCheckExpr` directly (see - // `packages/3-extensions/sql-orm-client/src/types.ts:374-381`); they - // never enter the operator-registry dispatch path, so cipherstash - // does not need to register an extension handler. The snapshot pins - // the absence of any EQL function call — the lowering is the same - // shape Postgres uses for any other column type. - - it('lowers email IS NULL to "user"."email" IS NULL — no EQL function call', () => { - const ast = selectWithWhere( - NullCheckExpr.isNull(ColumnRef.of(TABLE, COLUMN)), - ) - - const lowered = makeAdapter().lower(ast, { contract }) - - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE "user"."email" IS NULL"`, - ) - expect(lowered.sql).not.toContain('eql_v2.') - expect(lowered.params).toHaveLength(0) - }) - - it('lowers email IS NOT NULL to "user"."email" IS NOT NULL — no EQL function call', () => { - const ast = selectWithWhere( - NullCheckExpr.isNotNull(ColumnRef.of(TABLE, COLUMN)), - ) - - const lowered = makeAdapter().lower(ast, { contract }) - - expect(lowered.sql).toMatchInlineSnapshot( - `"SELECT "user"."id" AS "id" FROM "user" WHERE "user"."email" IS NOT NULL"`, - ) - expect(lowered.sql).not.toContain('eql_v2.') - expect(lowered.params).toHaveLength(0) - }) -}) - -describe('cipherstash operator lowering — per-codec envelope dispatch', () => { - // Trait-dispatched operators wrap the user-supplied value in the - // envelope subclass that matches the column's codec id at impl - // time. Each row here pins the dispatch is correct for one codec. - - it('cipherstashGt on a double column wraps the value in EncryptedDouble', () => { - const op = getOperator('cipherstashGt') - const predicate = callOperator( - op, - columnAccessor(TABLE, 'score', CIPHERSTASH_DOUBLE_CODEC_ID), - 3.14, - ) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.params).toHaveLength(1) - const envelope = literalParamValue(lowered.params[0]) - expect(envelope).toBeInstanceOf(EncryptedDouble) - }) - - it('cipherstashGt on a bigint column wraps the value in EncryptedBigInt', () => { - const op = getOperator('cipherstashGt') - const predicate = callOperator( - op, - columnAccessor(TABLE, 'amount', CIPHERSTASH_BIGINT_CODEC_ID), - 42n, - ) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(literalParamValue(lowered.params[0])).toBeInstanceOf(EncryptedBigInt) - }) - - it('cipherstashGt on a date column wraps the value in EncryptedDate', () => { - const op = getOperator('cipherstashGt') - const predicate = callOperator( - op, - columnAccessor(TABLE, 'birthday', CIPHERSTASH_DATE_CODEC_ID), - new Date('2024-01-01'), - ) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(literalParamValue(lowered.params[0])).toBeInstanceOf(EncryptedDate) - }) - - it('cipherstashNe on a boolean column wraps the value in EncryptedBoolean', () => { - const op = getOperator('cipherstashNe') - const predicate = callOperator( - op, - columnAccessor(TABLE, 'enabled', CIPHERSTASH_BOOLEAN_CODEC_ID), - true, - ) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(literalParamValue(lowered.params[0])).toBeInstanceOf( - EncryptedBoolean, - ) - }) - - it('cipherstashGt rejects a non-matching plaintext type for the column codec', () => { - const op = getOperator('cipherstashGt') - // Passing a string to a double column triggers the per-codec - // envelope coercion's diagnostic. - expect(() => - callOperator( - op, - columnAccessor(TABLE, 'score', CIPHERSTASH_DOUBLE_CODEC_ID), - 'not-a-number', - ), - ).toThrow(/EncryptedDouble/) - }) -}) - -describe('cipherstash operator lowering — JSON path predicate', () => { - it('lowers cipherstashJsonbPathExists(path) to eql_v2.jsonb_path_exists(...)', () => { - const op = getOperator('cipherstashJsonbPathExists') - const predicate = callOperator( - op, - columnAccessor(TABLE, 'payload', CIPHERSTASH_JSON_CODEC_ID), - '$.k', - ) - const lowered = makeAdapter().lower(selectWithWhere(predicate), { - contract, - }) - expect(lowered.sql).toMatchInlineSnapshot( - `"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.map(literalParamValue)).toEqual(['$.k']) - }) - - it('cipherstashJsonbPathExists rejects non-string path arguments', () => { - const op = getOperator('cipherstashJsonbPathExists') - expect(() => - callOperator( - op, - columnAccessor(TABLE, 'payload', CIPHERSTASH_JSON_CODEC_ID), - 42, - ), - ).toThrow(/string path/) - }) -}) - -describe('createCipherstashRuntimeDescriptor — queryOperations registration', () => { - it('exposes the full cipherstash operator surface via the runtime descriptor', () => { - // Names are cipherstash-prefixed so they coexist with the - // framework`s built-in `eq` / `ilike` registrations rather than - // overriding them. The trade-off is documented in - // `src/execution/operators.ts`'s top-level docblock. - // - // Two registration shapes coexist (see ADR 214): - // - Single-codec: `cipherstashEq` / `cipherstashIlike` (the - // original predicate pair) target the string codec by id. - // - Trait-namespaced: every other operator targets a - // `cipherstash:*` trait. The model accessor attaches the - // operator to every codec descriptor whose `traits` list - // contains that identifier. - const descriptor = createCipherstashRuntimeDescriptor({ sdk: emptySdk() }) - const ops = descriptor.queryOperations?.() ?? {} - const methods = Object.keys(ops).sort() - expect(methods).toEqual([ - 'cipherstashBetween', - 'cipherstashEq', - 'cipherstashGt', - 'cipherstashGte', - 'cipherstashIlike', - 'cipherstashInArray', - 'cipherstashJsonbPathExists', - 'cipherstashLt', - 'cipherstashLte', - 'cipherstashNe', - 'cipherstashNotBetween', - 'cipherstashNotIlike', - 'cipherstashNotInArray', - ]) - for (const method of ['cipherstashEq', 'cipherstashIlike']) { - expect(ops[method]?.self).toEqual({ - codecId: CIPHERSTASH_STRING_CODEC_ID, - }) - } - for (const method of [ - 'cipherstashNe', - 'cipherstashInArray', - 'cipherstashNotInArray', - ]) { - expect(ops[method]?.self).toEqual({ traits: ['cipherstash:equality'] }) - } - expect(ops['cipherstashNotIlike']?.self).toEqual({ - traits: ['cipherstash:free-text-search'], - }) - for (const method of [ - 'cipherstashGt', - 'cipherstashGte', - 'cipherstashLt', - 'cipherstashLte', - 'cipherstashBetween', - 'cipherstashNotBetween', - ]) { - expect(ops[method]?.self).toEqual({ - traits: ['cipherstash:order-and-range'], - }) - } - expect(ops['cipherstashJsonbPathExists']?.self).toEqual({ - traits: ['cipherstash:searchable-json'], - }) - }) -}) diff --git a/packages/prisma-next/test/psl-interpretation-numeric.test.ts b/packages/prisma-next/test/psl-interpretation-numeric.test.ts deleted file mode 100644 index 9751b2981..000000000 --- a/packages/prisma-next/test/psl-interpretation-numeric.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * PSL→ColumnTypeDescriptor lowering for the numeric cipherstash - * constructors: `cipherstash.EncryptedDoubleV2` / `cipherstash.EncryptedBigIntV2`. - * - * Pinned behaviour for numeric codecs (shared by both): - * - Full args lower to `typeParams { equality, orderAndRange }`. - * - Empty `{}` (and the no-args form) defaults both flags to `true`. - * - `freeTextSearch` is rejected with `PSL_INVALID_ATTRIBUTE_ARGUMENT` - * — numeric codecs do not expose the string-only flag. - * - The inline-form lowered descriptor mirrors the TS factory output - * 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' -import cipherstashControl from '../src/exports/control' -import cipherstashPack from '../src/exports/pack' - -const postgresTarget = { - kind: 'target' as const, - familyId: 'sql' as const, - targetId: 'postgres' as const, - defaultNamespaceId: 'public', - id: 'postgres', - version: '0.0.1', - capabilities: {}, -} - -const postgresScalarTypeDescriptors = new Map([ - ['String', { codecId: 'pg/text@1', nativeType: 'text' }], - ['Boolean', { codecId: 'pg/bool@1', nativeType: 'bool' }], - ['Int', { codecId: 'pg/int4@1', nativeType: 'int4' }], -]) - -function interpret(schema: string) { - return interpretPslDocumentToSqlContract({ - document: parsePslDocument({ schema, sourceId: 'schema.prisma' }), - 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: {} }, - }) -} - -// The interpreter returns `Result` and -// `Contract.storage` is the opaque `StorageBase`. Tests treat it as -// the structural shape it actually is (tables / types) — same pattern used -// by `packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts`. -type StorageView = { - readonly tables: Record< - string, - { - readonly columns: Record> - } - > - readonly types?: Record> -} -// 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.EncryptedDoubleV2 constructor', () => { - it('lowers full args to a column with cipherstash/double@1 codec, eql_v2_encrypted nativeType', () => { - const result = interpret(`model Metric { - id Int @id - value cipherstash.EncryptedDoubleV2({ equality: true, orderAndRange: true }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['metric']?.columns['value'], - ).toMatchObject({ - codecId: 'cipherstash/double@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: true, orderAndRange: true }, - nullable: false, - }) - }) - - it('defaults both flags to true for an empty options literal', () => { - const result = interpret(`model Metric { - id Int @id - value cipherstash.EncryptedDoubleV2({}) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['metric']?.columns['value'], - ).toMatchObject({ - codecId: 'cipherstash/double@1', - typeParams: { equality: true, orderAndRange: true }, - }) - }) - - it('rejects unknown argument names with PSL_INVALID_ATTRIBUTE_ARGUMENT', () => { - const result = interpret(`model Metric { - id Int @id - value cipherstash.EncryptedDoubleV2({ freeTextSearch: true }) -} -`) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining('freeTextSearch'), - }), - ]), - ) - }) - - it('produces an inline-form descriptor structurally identical to the TS factory output', () => { - const result = interpret(`model Metric { - id Int @id - value cipherstash.EncryptedDoubleV2({ equality: true, orderAndRange: false }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - const col = asStorage(result.value.storage).tables['metric']?.columns[ - 'value' - ] - // Stripping `nullable` (PSL-specific) the column descriptor mirrors - // the TS factory's lowered shape byte-for-byte (PSL/TS parity). - expect(col).toMatchObject({ - codecId: 'cipherstash/double@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: true, orderAndRange: false }, - }) - }) -}) - -describe('PSL interpretation: cipherstash.EncryptedBigIntV2 constructor', () => { - it('lowers full args to a column with cipherstash/bigint@1 codec, eql_v2_encrypted nativeType', () => { - const result = interpret(`model Ledger { - id Int @id - amount cipherstash.EncryptedBigIntV2({ equality: true, orderAndRange: true }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['ledger']?.columns['amount'], - ).toMatchObject({ - codecId: 'cipherstash/bigint@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: true, orderAndRange: true }, - }) - }) - - it('defaults both flags to true with no arguments', () => { - const result = interpret(`model Ledger { - id Int @id - amount cipherstash.EncryptedBigIntV2() -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['ledger']?.columns['amount'], - ).toMatchObject({ - codecId: 'cipherstash/bigint@1', - typeParams: { equality: true, orderAndRange: true }, - }) - }) - - it('rejects unknown argument names with PSL_INVALID_ATTRIBUTE_ARGUMENT', () => { - const result = interpret(`model Ledger { - id Int @id - amount cipherstash.EncryptedBigIntV2({ freeTextSearch: true }) -} -`) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining('freeTextSearch'), - }), - ]), - ) - }) -}) diff --git a/packages/prisma-next/test/psl-interpretation-other-types.test.ts b/packages/prisma-next/test/psl-interpretation-other-types.test.ts deleted file mode 100644 index 8bd952e4f..000000000 --- a/packages/prisma-next/test/psl-interpretation-other-types.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * PSL→ColumnTypeDescriptor lowering for the date, boolean, and JSON - * cipherstash constructors: - * - * - `cipherstash.EncryptedDateV2` — `{ equality, orderAndRange }` - * - `cipherstash.EncryptedBooleanV2` — `{ equality }` only; - * `orderAndRange` is rejected with `PSL_INVALID_ATTRIBUTE_ARGUMENT`. - * - `cipherstash.EncryptedJsonV2` — `{ searchableJson }`; - * `equality` is rejected with `PSL_INVALID_ATTRIBUTE_ARGUMENT`. - * - * Empty `{}` (and the no-args form) defaults the codec's flag(s) to - * `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' -import cipherstashControl from '../src/exports/control' -import cipherstashPack from '../src/exports/pack' - -const postgresTarget = { - kind: 'target' as const, - familyId: 'sql' as const, - targetId: 'postgres' as const, - defaultNamespaceId: 'public', - id: 'postgres', - version: '0.0.1', - capabilities: {}, -} - -const postgresScalarTypeDescriptors = new Map([ - ['String', { codecId: 'pg/text@1', nativeType: 'text' }], - ['Boolean', { codecId: 'pg/bool@1', nativeType: 'bool' }], - ['Int', { codecId: 'pg/int4@1', nativeType: 'int4' }], -]) - -function interpret(schema: string) { - return interpretPslDocumentToSqlContract({ - document: parsePslDocument({ schema, sourceId: 'schema.prisma' }), - 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: {} }, - }) -} - -// The interpreter returns `Result` and -// `Contract.storage` is the opaque `StorageBase`. Tests treat it as -// the structural shape it actually is (tables / types) — same pattern used -// by `packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts`. -type StorageView = { - readonly tables: Record< - string, - { - readonly columns: Record> - } - > - readonly types?: Record> -} -// 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.EncryptedDateV2 constructor', () => { - it('lowers full args to a column with cipherstash/date@1 codec, eql_v2_encrypted nativeType', () => { - const result = interpret(`model Event { - id Int @id - occurredOn cipherstash.EncryptedDateV2({ equality: true, orderAndRange: true }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['event']?.columns['occurredOn'], - ).toMatchObject({ - codecId: 'cipherstash/date@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: true, orderAndRange: true }, - }) - }) - - it('defaults both flags to true with no arguments', () => { - const result = interpret(`model Event { - id Int @id - occurredOn cipherstash.EncryptedDateV2() -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['event']?.columns['occurredOn'], - ).toMatchObject({ - codecId: 'cipherstash/date@1', - typeParams: { equality: true, orderAndRange: true }, - }) - }) -}) - -describe('PSL interpretation: cipherstash.EncryptedBooleanV2 constructor', () => { - it('lowers full args to a column with cipherstash/boolean@1 codec, equality typeParam', () => { - const result = interpret(`model Feature { - id Int @id - enabled cipherstash.EncryptedBooleanV2({ equality: true }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['feature']?.columns['enabled'], - ).toMatchObject({ - codecId: 'cipherstash/boolean@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: true }, - }) - }) - - it('defaults equality to true with no arguments', () => { - const result = interpret(`model Feature { - id Int @id - enabled cipherstash.EncryptedBooleanV2() -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['feature']?.columns['enabled'], - ).toMatchObject({ - codecId: 'cipherstash/boolean@1', - typeParams: { equality: true }, - }) - }) - - it('rejects orderAndRange (not a boolean codec flag)', () => { - const result = interpret(`model Feature { - id Int @id - enabled cipherstash.EncryptedBooleanV2({ orderAndRange: true }) -} -`) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining('orderAndRange'), - }), - ]), - ) - }) -}) - -describe('PSL interpretation: cipherstash.EncryptedJsonV2 constructor', () => { - it('lowers full args to a column with cipherstash/json@1 codec, searchableJson typeParam', () => { - const result = interpret(`model Audit { - id Int @id - payload cipherstash.EncryptedJsonV2({ searchableJson: true }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['audit']?.columns['payload'], - ).toMatchObject({ - codecId: 'cipherstash/json@1', - nativeType: 'eql_v2_encrypted', - typeParams: { searchableJson: true }, - }) - }) - - it('defaults searchableJson to true with no arguments', () => { - const result = interpret(`model Audit { - id Int @id - payload cipherstash.EncryptedJsonV2() -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['audit']?.columns['payload'], - ).toMatchObject({ - codecId: 'cipherstash/json@1', - typeParams: { searchableJson: true }, - }) - }) - - it('rejects equality (not a json codec flag)', () => { - const result = interpret(`model Audit { - id Int @id - payload cipherstash.EncryptedJsonV2({ equality: true }) -} -`) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining('equality'), - }), - ]), - ) - }) -}) diff --git a/packages/prisma-next/test/psl-interpretation.test.ts b/packages/prisma-next/test/psl-interpretation.test.ts index 64eb746ec..ae35e33b9 100644 --- a/packages/prisma-next/test/psl-interpretation.test.ts +++ b/packages/prisma-next/test/psl-interpretation.test.ts @@ -1,35 +1,16 @@ /** - * Full PSL→ColumnTypeDescriptor lowering for the - * `cipherstash.EncryptedStringV2({...})` constructor. + * Full PSL→ColumnTypeDescriptor lowering for the argument-less v3 + * `cipherstash.()` constructors. * * Exercises the interpreter end-to-end (parser → authoring contributions * → SQL contract IR) so the assertions are about *what users observe* * in the emitted contract, not about the descriptor template metadata. * - * Pinned behaviour: - * - Full args lower to `typeParams { equality, freeTextSearch, orderAndRange }`. - * - Empty `{}` (and the no-args form) defaults all three flags to `true` — - * searchable encryption is the legitimate default; users opt out - * explicitly with `equality: false` / `freeTextSearch: false` / - * `orderAndRange: false`. - * - `?` produces `nullable: true` on the column descriptor. - * - Unknown property name → `PSL_INVALID_ATTRIBUTE_ARGUMENT`. - * - Wrong type → `PSL_INVALID_ATTRIBUTE_ARGUMENT` mentioning - * "boolean"; diagnostic span points at the offending value. - * - `types { ... }` alias resolves and is reachable from a model - * field via `typeRef`; the alias's named-type descriptor matches - * the inline-form column's codec/nativeType/typeParams - * byte-for-byte. - * - * Sister files cover the other v2 cipherstash constructors: - * - `psl-interpretation-numeric.test.ts` - * (`EncryptedDoubleV2`, `EncryptedBigIntV2`) - * - `psl-interpretation-other-types.test.ts` - * (`EncryptedDateV2`, `EncryptedBooleanV2`, `EncryptedJsonV2`) - * - * The final describe block pins the v3 lowering path: argument-less - * constructors with STATIC `{ castAs, capabilities }` typeParams (no - * `AuthoringArgRef` resolution involved), and rejection of options. + * Pinned behaviour: argument-less v3 constructors lower to their + * STATIC `{ castAs, capabilities }` typeParams block (no `AuthoringArgRef` + * resolution involved), carrying the domain's concrete + * `cipherstash/eql-v3/*@1` codec id and `public.eql_v3_*` native type; + * `?` produces `nullable: true`; and passing options is rejected. */ import type { Contract } from '@prisma-next/contract/types' @@ -105,276 +86,6 @@ const asStorage = (storage: unknown): StorageView => { } } -describe('PSL interpretation: cipherstash.EncryptedStringV2 constructor', () => { - it('lowers full args to a column with codecId, nativeType, typeParams', () => { - const result = interpret(`model User { - id Int @id - email cipherstash.EncryptedStringV2({ equality: true, freeTextSearch: true, orderAndRange: true }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['user']?.columns['email'], - ).toEqual( - expect.objectContaining({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }, - nullable: false, - }), - ) - }) - - it('defaults all flags to true for an empty options literal', () => { - const result = interpret(`model User { - id Int @id - notes cipherstash.EncryptedStringV2({}) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['user']?.columns['notes'], - ).toEqual( - expect.objectContaining({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }, - nullable: false, - }), - ) - }) - - it('defaults all flags to true when called with no arguments', () => { - const result = interpret(`model User { - id Int @id - notes cipherstash.EncryptedStringV2() -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['user']?.columns['notes'], - ).toEqual( - expect.objectContaining({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }, - nullable: false, - }), - ) - }) - - it('lets orderAndRange be explicitly disabled', () => { - const result = interpret(`model User { - id Int @id - notes cipherstash.EncryptedStringV2({ orderAndRange: false }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['user']?.columns['notes'], - ).toEqual( - expect.objectContaining({ - codecId: 'cipherstash/string@1', - typeParams: { - equality: true, - freeTextSearch: true, - orderAndRange: false, - }, - }), - ) - }) - - it('lets equality be explicitly disabled', () => { - const result = interpret(`model User { - id Int @id - notes cipherstash.EncryptedStringV2({ equality: false }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['user']?.columns['notes'], - ).toMatchObject({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: false, freeTextSearch: true }, - nullable: false, - }) - }) - - it('lets both flags be explicitly disabled (storage-only encryption)', () => { - const result = interpret(`model User { - id Int @id - notes cipherstash.EncryptedStringV2({ equality: false, freeTextSearch: false }) -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['user']?.columns['notes'], - ).toMatchObject({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: false, freeTextSearch: false }, - nullable: false, - }) - }) - - it('marks nullable columns as nullable', () => { - const result = interpret(`model User { - id Int @id - username cipherstash.EncryptedStringV2({ freeTextSearch: false })? -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - expect( - asStorage(result.value.storage).tables['user']?.columns['username'], - ).toMatchObject({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: true, freeTextSearch: false }, - nullable: true, - }) - }) - - it('rejects unknown argument names with PSL_INVALID_ATTRIBUTE_ARGUMENT', () => { - const result = interpret(`model User { - id Int @id - email cipherstash.EncryptedStringV2({ unknownFlag: true }) -} -`) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining('unknownFlag'), - }), - ]), - ) - }) - - it('rejects wrong-typed argument values with PSL_INVALID_ATTRIBUTE_ARGUMENT', () => { - const result = interpret(`model User { - id Int @id - email cipherstash.EncryptedStringV2({ equality: "yes" }) -} -`) - expect(result.ok).toBe(false) - if (result.ok) return - expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining('boolean'), - }), - ]), - ) - }) - - it('resolves a named-type alias under types {} and uses it on a model field', () => { - const result = interpret(`types { - SearchableEmail = cipherstash.EncryptedStringV2({ freeTextSearch: false }) -} - -model User { - id Int @id - email SearchableEmail -} -`) - expect(result.ok).toBe(true) - if (!result.ok) return - const storage = asStorage(result.value.storage) - expect(storage.types?.['SearchableEmail']).toMatchObject({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - typeParams: { equality: true, freeTextSearch: false }, - }) - expect(storage.tables['user']?.columns['email']).toMatchObject({ - codecId: 'cipherstash/string@1', - nativeType: 'eql_v2_encrypted', - nullable: false, - typeRef: 'SearchableEmail', - }) - }) - - it('produces an alias whose typeParams match the inline-constructor form for the same args', () => { - const aliasResult = interpret(`types { - SearchableEmail = cipherstash.EncryptedStringV2({ equality: true, freeTextSearch: true }) -} - -model User { - id Int @id - email SearchableEmail -} -`) - const inlineResult = interpret(`model User { - id Int @id - email cipherstash.EncryptedStringV2({ equality: true, freeTextSearch: true }) -} -`) - expect(aliasResult.ok).toBe(true) - expect(inlineResult.ok).toBe(true) - if (!aliasResult.ok || !inlineResult.ok) return - - const aliasNamedType = asStorage(aliasResult.value.storage).types?.[ - 'SearchableEmail' - ] - const inlineCol = asStorage(inlineResult.value.storage).tables['user'] - ?.columns['email'] - expect(inlineCol).toBeDefined() - if (!inlineCol) return - - // 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, 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'], - }) - }) - - it('reports a span at the offending argument value', () => { - const result = interpret(`model User { - id Int @id - email cipherstash.EncryptedStringV2({ equality: 42 }) -} -`) - expect(result.ok).toBe(false) - if (result.ok) return - const diag = result.failure.diagnostics.find( - (d) => d.code === 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - ) - expect(diag?.span).toMatchObject({ - start: { line: expect.any(Number), column: expect.any(Number) }, - end: { line: expect.any(Number), column: expect.any(Number) }, - }) - }) -}) - describe('PSL interpretation: v3 argument-less constructors (static typeParams)', () => { it('lowers cipherstash.TextSearch() to the static v3 descriptor', () => { const result = interpret(`model User { diff --git a/packages/prisma-next/test/runtime-descriptor.test.ts b/packages/prisma-next/test/runtime-descriptor.test.ts deleted file mode 100644 index 6acfdd05c..000000000 --- a/packages/prisma-next/test/runtime-descriptor.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * `createCipherstashRuntimeDescriptor({ sdk })` — the consumer-facing - * wrapper that composes the SDK-bound parameterized codec descriptor - * into a single `SqlRuntimeExtensionDescriptor<'postgres'>`. - * - * The wrapper exposes the parameterized descriptor on - * `types.codecTypes.codecDescriptors` and through `codecs()`. The - * runtime extracts the descriptor at dispatch time and resolves a - * per-instance codec via `descriptor.factory(params)(ctx)`. The - * bulk-encrypt middleware ships separately under `./middleware`. - * - * Mirrors the pgvector wrapper at - * `packages/3-extensions/pgvector/src/exports/runtime.ts:62-88`. - */ - -import { describe, expect, it, vi } from 'vitest' -import type { CipherstashSdk } from '../src/execution/sdk' -import { - CIPHERSTASH_EXTENSION_VERSION, - createCipherstashRuntimeDescriptor, -} from '../src/exports/runtime' -import { - CIPHERSTASH_SPACE_ID, - CIPHERSTASH_STRING_CODEC_ID, -} from '../src/extension-metadata/constants' - -function emptySdk(): CipherstashSdk { - return { - decrypt: vi.fn(), - bulkEncrypt: vi.fn(), - bulkDecrypt: vi.fn(), - } -} - -describe('createCipherstashRuntimeDescriptor — descriptor shape', () => { - it('declares kind=extension with the cipherstash id, version, family, target', () => { - const descriptor = createCipherstashRuntimeDescriptor({ sdk: emptySdk() }) - expect(descriptor.kind).toBe('extension') - expect(descriptor.id).toBe(CIPHERSTASH_SPACE_ID) - expect(descriptor.version).toBe(CIPHERSTASH_EXTENSION_VERSION) - expect(descriptor.familyId).toBe('sql') - expect(descriptor.targetId).toBe('postgres') - }) - - it('exposes the cipherstash codec descriptors under types.codecTypes.codecDescriptors', () => { - // The descriptor wires the full six-codec surface (string + - // double + bigint + date + boolean + json). The current count + - // ordering is pinned here so a missed wiring surfaces in unit - // tests instead of leaking through e2e. - const descriptor = createCipherstashRuntimeDescriptor({ sdk: emptySdk() }) - const codecDescriptors = - descriptor.types?.codecTypes?.codecDescriptors ?? [] - expect(codecDescriptors).toHaveLength(6) - expect(codecDescriptors[0]?.codecId).toBe(CIPHERSTASH_STRING_CODEC_ID) - expect(codecDescriptors[1]?.codecId).toBe('cipherstash/double@1') - expect(codecDescriptors[2]?.codecId).toBe('cipherstash/bigint@1') - expect(codecDescriptors[3]?.codecId).toBe('cipherstash/date@1') - expect(codecDescriptors[4]?.codecId).toBe('cipherstash/boolean@1') - expect(codecDescriptors[5]?.codecId).toBe('cipherstash/json@1') - }) -}) - -describe('createCipherstashRuntimeDescriptor — codecs()', () => { - it('returns the parameterized codec descriptors in stable order', () => { - const descriptor = createCipherstashRuntimeDescriptor({ sdk: emptySdk() }) - const codecs = descriptor.codecs?.() ?? [] - expect(codecs).toHaveLength(6) - expect(codecs.map((c) => c.codecId)).toEqual([ - CIPHERSTASH_STRING_CODEC_ID, - 'cipherstash/double@1', - 'cipherstash/bigint@1', - 'cipherstash/date@1', - 'cipherstash/boolean@1', - 'cipherstash/json@1', - ]) - for (const c of codecs) { - expect(c.targetTypes).toEqual(['eql_v2_encrypted']) - // Per-codec `cipherstash:*` namespaced traits drive the - // multi-codec operator dispatch (see - // `extension-metadata/constants.ts` → - // `CIPHERSTASH_CODEC_TRAITS`); the framework `'equality'` trait - // is intentionally absent across every cipherstash codec so the - // built-in `eq` does not silently re-attach (see - // `equality-trait-removal.test.ts`). - const traits: ReadonlyArray = c.traits ?? [] - expect(traits.includes('equality')).toBe(false) - expect(traits.length).toBeGreaterThan(0) - for (const trait of traits) { - expect(trait.startsWith('cipherstash:')).toBe(true) - } - } - }) -}) - -describe('createCipherstashRuntimeDescriptor — create() returns a target-bound instance', () => { - it('returns a SqlRuntimeExtensionInstance carrying the SQL family and Postgres target', () => { - const descriptor = createCipherstashRuntimeDescriptor({ sdk: emptySdk() }) - const instance = descriptor.create() - expect(instance.familyId).toBe('sql') - expect(instance.targetId).toBe('postgres') - }) -}) - -describe('createCipherstashRuntimeDescriptor — SDK isolation per descriptor', () => { - it('produces a different codec instance per invocation so per-tenant SDKs do not cross-talk', () => { - const a = createCipherstashRuntimeDescriptor({ sdk: emptySdk() }) - const b = createCipherstashRuntimeDescriptor({ sdk: emptySdk() }) - const codecA = a.codecs?.()[0]?.factory({ - equality: false, - freeTextSearch: false, - orderAndRange: false, - })({ - name: 'x.y', - }) - const codecB = b.codecs?.()[0]?.factory({ - equality: false, - freeTextSearch: false, - orderAndRange: false, - })({ - name: 'x.y', - }) - expect(codecA).not.toBe(codecB) - }) -}) diff --git a/packages/prisma-next/test/sdk-adapter.test.ts b/packages/prisma-next/test/sdk-adapter.test.ts deleted file mode 100644 index afe728c51..000000000 --- a/packages/prisma-next/test/sdk-adapter.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Behaviour pins for `createCipherstashSdk`. - * - * Uses a hand-built fake `EncryptionClient` (no live ZeroKMS) — every - * call returns a deterministic, inspectable result so the adapter's - * routing, coercion, and error-mapping logic can be observed at the - * boundary. - */ - -import type { EncryptionClient } from '@cipherstash/stack/client' -import { encryptedColumn, encryptedTable } from '@cipherstash/stack/schema' -import { describe, expect, it, vi } from 'vitest' - -import { createCipherstashSdk } from '../src/stack/sdk-adapter' - -interface FakeBulkEncryptCall { - readonly plaintexts: ReadonlyArray - readonly column: unknown - readonly table: unknown -} - -interface FakeClientHandle { - readonly client: EncryptionClient - readonly bulkEncryptCalls: FakeBulkEncryptCall[] - readonly bulkDecryptCalls: ReadonlyArray[] - readonly decryptCalls: unknown[] -} - -function makeFakeClient(): FakeClientHandle { - const bulkEncryptCalls: FakeBulkEncryptCall[] = [] - const bulkDecryptCalls: ReadonlyArray[] = [] - const decryptCalls: unknown[] = [] - - const client = { - bulkEncrypt: vi.fn( - async ( - plaintexts: ReadonlyArray<{ plaintext: unknown }>, - opts: { column: unknown; table: unknown }, - ) => { - bulkEncryptCalls.push({ - plaintexts: plaintexts.map((p) => p.plaintext), - column: opts.column, - table: opts.table, - }) - return { - failure: null, - data: plaintexts.map((_, i) => ({ data: `ct-${i}` as unknown })), - } as { failure: null; data: ReadonlyArray<{ data: unknown }> } - }, - ), - bulkDecrypt: vi.fn(async (payload: ReadonlyArray<{ data: unknown }>) => { - bulkDecryptCalls.push(payload.map((p) => p.data)) - return { - failure: null, - data: payload.map((p, i) => ({ id: i, data: `pt-${i}` as unknown })), - } as { - failure: null - data: ReadonlyArray<{ id?: number; data?: unknown; error?: unknown }> - } - }), - decrypt: vi.fn(async (ciphertext: unknown) => { - decryptCalls.push(ciphertext) - return { failure: null, data: 'pt-single' as unknown } - }), - } - - return { - client: client as unknown as EncryptionClient, - bulkEncryptCalls, - bulkDecryptCalls, - decryptCalls, - } -} - -const validEnvelope = { - v: 2, - i: { t: 'users', c: 'email' }, - c: 'ct-blob', -} - -describe('createCipherstashSdk — routing-key lookup', () => { - it('resolves a (table, column) routing key to the typed schema objects', async () => { - const users = encryptedTable('users', { - email: encryptedColumn('email').equality(), - }) - const fake = makeFakeClient() - const sdk = createCipherstashSdk(fake.client, [users]) - - await sdk.bulkEncrypt({ - routingKey: { table: 'users', column: 'email' }, - values: ['alice'], - }) - - expect(fake.bulkEncryptCalls).toHaveLength(1) - expect(fake.bulkEncryptCalls[0]?.table).toBe(users) - expect(fake.bulkEncryptCalls[0]?.column).toBe(users.email) - expect(fake.bulkEncryptCalls[0]?.plaintexts).toEqual(['alice']) - }) - - it('throws a clear error when the routing-key table is unknown', async () => { - const users = encryptedTable('users', { - email: encryptedColumn('email').equality(), - }) - const fake = makeFakeClient() - const sdk = createCipherstashSdk(fake.client, [users]) - - await expect( - sdk.bulkEncrypt({ - routingKey: { table: 'audit_log', column: 'message' }, - values: ['x'], - }), - ).rejects.toThrow(/routing-key table "audit_log"/) - }) - - it('throws a clear error when the routing-key column is unknown on a known table', async () => { - const users = encryptedTable('users', { - email: encryptedColumn('email').equality(), - }) - const fake = makeFakeClient() - const sdk = createCipherstashSdk(fake.client, [users]) - - await expect( - sdk.bulkEncrypt({ - routingKey: { table: 'users', column: 'phone' }, - values: ['x'], - }), - ).rejects.toThrow(/column "phone" is not on stack table "users"/) - }) -}) - -describe('createCipherstashSdk — plaintext coercion at the boundary', () => { - it('coerces bigint to Number when in the safe-integer range', async () => { - const accounts = encryptedTable('accounts', { - id: encryptedColumn('id').dataType('bigint').equality(), - }) - const fake = makeFakeClient() - const sdk = createCipherstashSdk(fake.client, [accounts]) - - await sdk.bulkEncrypt({ - routingKey: { table: 'accounts', column: 'id' }, - values: [123_456n, 0n, -9_007_199_254_740_991n], - }) - - expect(fake.bulkEncryptCalls[0]?.plaintexts).toEqual([ - 123_456, 0, -9_007_199_254_740_991, - ]) - }) - - it('throws on bigint overflow rather than truncating silently', async () => { - const accounts = encryptedTable('accounts', { - id: encryptedColumn('id').dataType('bigint').equality(), - }) - const fake = makeFakeClient() - const sdk = createCipherstashSdk(fake.client, [accounts]) - - await expect( - sdk.bulkEncrypt({ - routingKey: { table: 'accounts', column: 'id' }, - // 2^54 — one past Number.MAX_SAFE_INTEGER - values: [BigInt(2) ** BigInt(54)], - }), - ).rejects.toThrow(/exceeds Number\.MAX_SAFE_INTEGER/) - }) - - it('coerces Date to an ISO 8601 string', async () => { - const events = encryptedTable('events', { - at: encryptedColumn('at').dataType('date').equality(), - }) - const fake = makeFakeClient() - const sdk = createCipherstashSdk(fake.client, [events]) - - await sdk.bulkEncrypt({ - routingKey: { table: 'events', column: 'at' }, - values: [new Date('2026-05-13T08:00:00.000Z')], - }) - - expect(fake.bulkEncryptCalls[0]?.plaintexts).toEqual([ - '2026-05-13T08:00:00.000Z', - ]) - }) - - it('passes string / number / boolean / object plaintexts through unchanged', async () => { - const t = encryptedTable('t', { - c: encryptedColumn('c').equality(), - }) - const fake = makeFakeClient() - const sdk = createCipherstashSdk(fake.client, [t]) - - await sdk.bulkEncrypt({ - routingKey: { table: 't', column: 'c' }, - values: ['s', 1, true, { k: 'v' }], - }) - - expect(fake.bulkEncryptCalls[0]?.plaintexts).toEqual([ - 's', - 1, - true, - { k: 'v' }, - ]) - }) -}) - -describe('createCipherstashSdk — bulkDecrypt envelope validation', () => { - it('rejects ciphertext values that are not EQL v2 envelopes with a clear error', async () => { - const t = encryptedTable('t', { c: encryptedColumn('c').equality() }) - const fake = makeFakeClient() - const sdk = createCipherstashSdk(fake.client, [t]) - - await expect( - sdk.bulkDecrypt({ - routingKey: { table: 't', column: 'c' }, - ciphertexts: [validEnvelope, { not: 'an envelope' }], - }), - ).rejects.toThrow(/at index 1.*not a valid EQL v2 envelope/) - }) - - it('forwards valid envelopes to the underlying client.bulkDecrypt', async () => { - const t = encryptedTable('t', { c: encryptedColumn('c').equality() }) - const fake = makeFakeClient() - const sdk = createCipherstashSdk(fake.client, [t]) - - const result = await sdk.bulkDecrypt({ - routingKey: { table: 't', column: 'c' }, - ciphertexts: [validEnvelope, validEnvelope], - }) - - expect(fake.bulkDecryptCalls).toHaveLength(1) - expect(fake.bulkDecryptCalls[0]).toEqual([validEnvelope, validEnvelope]) - expect(result).toEqual(['pt-0', 'pt-1']) - }) -}) - -describe('createCipherstashSdk — error mapping', () => { - it('propagates underlying client failures as Error with the failure message', async () => { - const t = encryptedTable('t', { c: encryptedColumn('c').equality() }) - const failingClient = { - bulkEncrypt: async () => ({ - failure: { message: 'workspace credentials missing' }, - }), - bulkDecrypt: async () => ({ failure: null, data: [] }), - decrypt: async () => ({ failure: null, data: '' }), - } as unknown as EncryptionClient - const sdk = createCipherstashSdk(failingClient, [t]) - - await expect( - sdk.bulkEncrypt({ - routingKey: { table: 't', column: 'c' }, - values: ['x'], - }), - ).rejects.toThrow(/workspace credentials missing/) - }) -}) diff --git a/packages/prisma-next/test/v3/bulk-encrypt-v3.test.ts b/packages/prisma-next/test/v3/bulk-encrypt-v3.test.ts index 11a73320e..4e0d6aaa0 100644 --- a/packages/prisma-next/test/v3/bulk-encrypt-v3.test.ts +++ b/packages/prisma-next/test/v3/bulk-encrypt-v3.test.ts @@ -26,7 +26,12 @@ import { describe, expect, it } from 'vitest' import { setHandleRoutingKey } from '../../src/execution/envelope-base' import { EncryptedJson } from '../../src/execution/envelope-json' import { EncryptedString } from '../../src/execution/envelope-string' -import { CIPHERSTASH_STRING_CODEC_ID } from '../../src/extension-metadata/constants' + +// A non-v3 codec id — used to prove the guard and the v3 middleware reject +// anything outside the pinned v3 set. (The legacy EQL v2 codec ids were +// removed; this literal stands in for "not a v3 codec id".) +const NON_V3_CODEC_ID = 'cipherstash/string@1' + import { isCipherstashV3CodecId } from '../../src/extension-metadata/constants-v3' import { bulkEncryptMiddlewareV3 } from '../../src/v3/bulk-encrypt-v3' import { EncryptedNumber } from '../../src/v3/envelope-number' @@ -58,7 +63,7 @@ const V3_JSON = 'cipherstash/eql-v3/eql_v3_json@1' it('test fixture codec ids are members of the pinned v3 set', () => { expect(isCipherstashV3CodecId(V3_TEXT_SEARCH)).toBe(true) expect(isCipherstashV3CodecId(V3_JSON)).toBe(true) - expect(isCipherstashV3CodecId(CIPHERSTASH_STRING_CODEC_ID)).toBe(false) + expect(isCipherstashV3CodecId(NON_V3_CODEC_ID)).toBe(false) }) /** SDK whose ciphertexts are object EQL payloads, the v3 SDK shape. */ @@ -231,13 +236,13 @@ describe('bulkEncryptMiddlewareV3', () => { }) describe('jurisdiction is the v3 codec-id set only', () => { - it('ignores v2-codec params', async () => { + it('ignores non-v3-codec params', async () => { const sdk = makeV3Sdk() const middleware = bulkEncryptMiddlewareV3(sdk) const plan = buildInsertPlan( 'user', [{ email: EncryptedString.from('x') }], - CIPHERSTASH_STRING_CODEC_ID, + NON_V3_CODEC_ID, ) const params = createSqlParamRefMutator(plan) diff --git a/packages/prisma-next/test/v3/codec-runtime-v3.test.ts b/packages/prisma-next/test/v3/codec-runtime-v3.test.ts index d6affd61f..5ad6fe30b 100644 --- a/packages/prisma-next/test/v3/codec-runtime-v3.test.ts +++ b/packages/prisma-next/test/v3/codec-runtime-v3.test.ts @@ -23,6 +23,7 @@ import { EncryptedBigInt } from '../../src/execution/envelope-bigint' import { EncryptedJson } from '../../src/execution/envelope-json' import { EncryptedString } from '../../src/execution/envelope-string' import type { CipherstashSdk } from '../../src/execution/sdk' +import { bulkEncryptMiddlewareV3 } from '../../src/v3/bulk-encrypt-v3' import { V3_CODEC_IDS } from '../../src/v3/catalog' import { createV3CodecDescriptors } from '../../src/v3/codec-runtime-v3' import { EncryptedNumber } from '../../src/v3/envelope-number' @@ -168,13 +169,49 @@ describe('CipherstashV3CellCodec — encode (plain JSONB)', () => { }) it('returns the envelope unchanged when it has no ciphertext yet (pre-encrypt sentinel)', async () => { + const sdk = emptySdk() + // The sentinel path is only legitimate once the middleware is wired + // against this SAME sdk — that is what promises a second pass will + // fill in the ciphertext. Register it the way production does. + bulkEncryptMiddlewareV3(sdk) const codec = codecFor( - createV3CodecDescriptors(emptySdk()), + createV3CodecDescriptors(sdk), 'cipherstash/eql-v3/eql_v3_text_eq@1', ) const preEncrypt = EncryptedString.from('plaintext') expect(await codec.encode(preEncrypt, callCtx)).toBe(preEncrypt) }) + + it('throws a wiring diagnostic when the sdk has no bulk-encrypt middleware registered', async () => { + // No `bulkEncryptMiddlewareV3(sdk)` for this sdk: the two-pass write + // can never complete, so fail at the codec boundary rather than + // letting the envelope reach the driver as an opaque serialise error. + const codec = codecFor( + createV3CodecDescriptors(emptySdk()), + 'cipherstash/eql-v3/eql_v3_text_eq@1', + ) + await expect( + codec.encode(EncryptedString.from('plaintext'), callCtx), + ).rejects.toThrow(/bulkEncryptMiddlewareV3\(sdk\)/) + }) + + it('does not fire the wiring diagnostic for an already-encrypted envelope', async () => { + // An envelope carrying ciphertext needs no second pass, so an + // unregistered sdk is not a misconfig on this path. + const codec = codecFor( + createV3CodecDescriptors(emptySdk()), + 'cipherstash/eql-v3/eql_v3_text_eq@1', + ) + const encrypted = EncryptedString.fromInternal({ + ciphertext: { c: 'abc' }, + table: 'users', + column: 'email', + sdk: emptySdk(), + }) + expect(await codec.encode(encrypted, callCtx)).toBe( + JSON.stringify({ c: 'abc' }), + ) + }) }) describe('CipherstashV3CellCodec — decode', () => { diff --git a/packages/prisma-next/test/v3/constants-v3.test.ts b/packages/prisma-next/test/v3/constants-v3.test.ts index 5de401445..fd64c0094 100644 --- a/packages/prisma-next/test/v3/constants-v3.test.ts +++ b/packages/prisma-next/test/v3/constants-v3.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { - CIPHERSTASH_CODEC_IDS, CIPHERSTASH_EXTENSION_VERSION, CIPHERSTASH_SPACE_ID, } from '../../src/extension-metadata/constants' @@ -28,11 +27,6 @@ describe('constants-v3', () => { ) }) - 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/eql_v3_text_eq@1')).toBe( true, diff --git a/packages/prisma-next/test/v3/envelope-number.test.ts b/packages/prisma-next/test/v3/envelope-number.test.ts index 950e88e2f..8cb4c9e6e 100644 --- a/packages/prisma-next/test/v3/envelope-number.test.ts +++ b/packages/prisma-next/test/v3/envelope-number.test.ts @@ -1,17 +1,16 @@ /** * Behavioural tests for the v3 `EncryptedNumber` envelope. * - * Mirrors `test/envelope-double.test.ts` (subclass surface, decrypt - * round-trip, the four non-`toJSON` redaction overrides, the - * `JSON.stringify` placeholder shape) and additionally pins the - * sibling relationship with the v2 `EncryptedDouble`: the two classes - * must not satisfy each other's `instanceof` (a cross-version leak). + * Covers the subclass surface, decrypt round-trip, the four + * non-`toJSON` redaction overrides, and the `JSON.stringify` + * placeholder shape, and pins that it does not satisfy a sibling + * envelope's `instanceof` (no cross-class leak). */ import { inspect } from 'node:util' import { describe, expect, it, vi } from 'vitest' import { EncryptedEnvelopeBase } from '../../src/execution/envelope-base' -import { EncryptedDouble } from '../../src/execution/envelope-double' +import { EncryptedString } from '../../src/execution/envelope-string' import type { CipherstashSdk } from '../../src/execution/sdk' import { EncryptedNumber } from '../../src/v3/envelope-number' @@ -23,12 +22,12 @@ function emptySdk(): CipherstashSdk { } } -describe('EncryptedNumber — sibling of EncryptedDouble', () => { - it('is a distinct sibling of EncryptedDouble (no cross-instanceof leak)', () => { +describe('EncryptedNumber — distinct envelope subclass', () => { + it('is a distinct sibling of the other envelopes (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) + expect(n instanceof EncryptedString).toBe(false) + expect(EncryptedString.from('42') instanceof EncryptedNumber).toBe(false) }) it('renders the $encryptedNumber placeholder marker (distinct typeName)', () => { diff --git a/packages/prisma-next/test/v3/from-stack-v3.test.ts b/packages/prisma-next/test/v3/from-stack-v3.test.ts index 759906d31..f172afe22 100644 --- a/packages/prisma-next/test/v3/from-stack-v3.test.ts +++ b/packages/prisma-next/test/v3/from-stack-v3.test.ts @@ -6,8 +6,8 @@ * * Decision 1b pins: a v3 client is v3-only — a contract carrying a v2 * cipherstash codec id is a hard error, never a silently-ignored - * column; and the v2 `cipherstashFromStackV2` is a separate, untouched - * entry point. + * column. (The v2 entry point has since been removed outright; these + * cases now guard against a stale v2 contract reaching a v3 client.) */ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' diff --git a/packages/prisma-next/test/v3/migration-v3.test.ts b/packages/prisma-next/test/v3/migration-v3.test.ts index 5691cc9d3..8a7bed350 100644 --- a/packages/prisma-next/test/v3/migration-v3.test.ts +++ b/packages/prisma-next/test/v3/migration-v3.test.ts @@ -4,9 +4,10 @@ * * The install SQL is NOT baked into `ops.json`: the committed op carries a * placeholder, and the descriptor (`control.ts`) injects `readInstallSql()` - * from the installed `@cipherstash/eql` at build time. The edge is - * invariant-only: the v3 bundle creates `public.eql_v3_*` domains + `eql_v3.*` - * functions but no contract-space storage, so `from === to === `. + * from the installed `@cipherstash/eql` at build time. The package installs EQL + * v3 only, so this is the sole migration: an invariant-only genesis edge + * (`from: null`) — the v3 bundle creates `public.eql_v3_*` domains + `eql_v3.*` + * functions but no contract-space storage, so `to` is the empty-storage hash. */ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -17,9 +18,6 @@ import { readMigrationPackage, } from '@prisma-next/migration-tools/io' import { describe, expect, it } from 'vitest' -import v2Metadata from '../../migrations/20260601T0000_install_eql_bundle/migration.json' with { - type: 'json', -} import v3Metadata from '../../migrations/20260601T0100_install_eql_v3_bundle/migration.json' with { type: 'json', } @@ -28,7 +26,6 @@ import v3Ops from '../../migrations/20260601T0100_install_eql_v3_bundle/ops.json } import headRef from '../../migrations/refs/head.json' with { type: 'json' } import cipherstashDescriptor from '../../src/exports/control' -import { CIPHERSTASH_INVARIANTS } from '../../src/extension-metadata/constants' import { CIPHERSTASH_V3_BASELINE_MIGRATION_NAME, CIPHERSTASH_V3_INVARIANTS, @@ -55,9 +52,10 @@ describe('v3 baseline migration (20260601T0100_install_eql_v3_bundle)', () => { expect(op.id).toBe('cipherstash.install-eql-v3-bundle') expect(op.invariantId).toBe(CIPHERSTASH_V3_INVARIANTS.installBundle) expect(op.invariantId).toBe('cipherstash:install-eql-v3-bundle-v1') - // `data`, not `additive`: the edge is a self-edge (from === to), - // and the aggregate integrity checker rejects self-edges without a - // data-class op — see the rationale comment in the migration file. + // `data`, not `additive`: this genesis edge moves no contract + // storage, and the aggregate integrity checker rejects a + // no-storage-movement edge without a data-class op — see the + // rationale comment in the migration file. expect(op.operationClass).toBe('data') }) @@ -143,27 +141,20 @@ describe('v3 baseline migration (20260601T0100_install_eql_v3_bundle)', () => { expect(json).not.toContain('remove_search_config') }) - it('sorts after the v2 baseline', () => { - expect( - CIPHERSTASH_V3_BASELINE_MIGRATION_NAME > - '20260601T0000_install_eql_bundle', - ).toBe(true) - }) - - it('is an invariant-only edge chained from the v2 baseline head', () => { - // The v3 bundle adds no contract-space storage, so the storage hash - // does not move: from === to === the v2 baseline's `to`. - expect(v3Metadata.from).toBe(v2Metadata.to) - expect(v3Metadata.to).toBe(v2Metadata.to) + it('is an invariant-only genesis edge (from: null → the empty-storage hash)', () => { + // The package is EQL v3 only, so this is the sole migration and its + // root: `from: null`. The v3 bundle adds no contract-space storage, + // so `to` is the empty-storage hash (the contract models no tables). + expect(v3Metadata.from).toBeNull() + expect(v3Metadata.to).toBe(headRef.hash) expect(v3Metadata.providedInvariants).toEqual([ CIPHERSTASH_V3_INVARIANTS.installBundle, ]) }) - it('pins the head ref at the (unchanged) hash with BOTH invariants', () => { + it('pins the head ref at the genesis hash with the v3 invariant only', () => { expect(headRef.hash).toBe(v3Metadata.to) expect(headRef.invariants).toEqual([ - CIPHERSTASH_INVARIANTS.installBundle, CIPHERSTASH_V3_INVARIANTS.installBundle, ]) }) diff --git a/packages/prisma-next/test/v3/operator-gating-v3.test.ts b/packages/prisma-next/test/v3/operator-gating-v3.test.ts index 89b6637b2..ff0584e9e 100644 --- a/packages/prisma-next/test/v3/operator-gating-v3.test.ts +++ b/packages/prisma-next/test/v3/operator-gating-v3.test.ts @@ -10,16 +10,13 @@ * cannot answer the operator. * * Also pins decision 1b's registration posture: the v3 descriptor - * stands alone (a v3-only adapter builds cleanly), and the v2 - * `cipherstash*` and v3 `eql*` method-name sets are DISJOINT — the two - * surfaces share no registry key, so a client is v2 or v3 purely by - * which descriptor it was constructed with, never by registry - * collision. v2 and v3 remain separate entry points, never composed - * into one client. + * stands alone (a v3-only adapter builds cleanly) and registers its + * full method set without collision, every method wearing the `eql*` + * prefix. A contract carrying a v2 cipherstash codec id is rejected + * outright — v2 is no longer a surface this package serves. */ import { describe, expect, it } from 'vitest' -import { cipherstashQueryOperations } from '../../src/execution/operators' import { cipherstashV3QueryOperations, EncryptionOperatorError, @@ -175,25 +172,14 @@ describe('v3 descriptor registration (decision 1b)', () => { ) }) - it('the v2 and v3 operator method-name sets are disjoint', () => { - // The v3 registry speaks the EQL-derived `eql*` vocabulary, v2 - // keeps its historical `cipherstash*` names (PR #655 review). No - // shared key means the flat, method-keyed OperationRegistry can - // never confuse the two surfaces — generation identity is fixed at - // client construction (decision 1b), not resolved per method name. - const v2Methods = Object.keys(cipherstashQueryOperations()) + it('every v3 operator method wears the `eql` prefix', () => { + // The v3 registry speaks the EQL-derived `eql*` vocabulary + // (PR #655 review): a fixed, self-consistent naming surface on the + // flat method-keyed OperationRegistry. Generation identity is fixed + // at client construction (decision 1b), not resolved per method + // name. const v3Methods = Object.keys(cipherstashV3QueryOperations()) - expect(v2Methods.length).toBeGreaterThan(0) expect(v3Methods.length).toBeGreaterThan(0) - const v2Set = new Set(v2Methods) - expect(v3Methods.filter((method) => v2Set.has(method))).toEqual([]) - const v3Set = new Set(v3Methods) - expect(v2Methods.filter((method) => v3Set.has(method))).toEqual([]) - // The naming split is total, not incidental: every v3 method wears - // the `eql` prefix, no v2 method does. expect(v3Methods.every((method) => method.startsWith('eql'))).toBe(true) - expect(v2Methods.every((method) => method.startsWith('cipherstash'))).toBe( - true, - ) }) }) diff --git a/packages/prisma-next/test/v3/properties.test.ts b/packages/prisma-next/test/v3/properties.test.ts index 6fedea85f..175d93f8b 100644 --- a/packages/prisma-next/test/v3/properties.test.ts +++ b/packages/prisma-next/test/v3/properties.test.ts @@ -207,15 +207,6 @@ describe('property: constructor ↔ domain totality (authoring namespace)', () = } } > - const V2_ALIASES = [ - 'EncryptedStringV2', - 'EncryptedDoubleV2', - 'EncryptedBigIntV2', - 'EncryptedDateV2', - 'EncryptedBooleanV2', - 'EncryptedJsonV2', - ] - it('every exposed domain has exactly one constructor whose descriptor equals the catalog values', () => { fc.assert( fc.property( @@ -237,24 +228,16 @@ describe('property: constructor ↔ domain totality (authoring namespace)', () = ) }) - it('no constructor maps off-catalog (v2 aliases excepted) and the counts agree', () => { + it('no constructor maps off-catalog and the counts agree', () => { 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 off-catalog`).toBe( true, ) } - expect( - Object.keys(ns) - .filter((n) => n.endsWith('V2')) - .sort(), - ).toEqual([...V2_ALIASES].sort()) - expect(Object.keys(ns)).toHaveLength( - EXPOSED_DOMAIN_ENTRIES.length + V2_ALIASES.length, - ) + expect(Object.keys(ns)).toHaveLength(EXPOSED_DOMAIN_ENTRIES.length) }) it('name transforms are injective and pascal/camel agree modulo first-letter case', () => { diff --git a/packages/prisma-next/test/v3/runtime-v3.test.ts b/packages/prisma-next/test/v3/runtime-v3.test.ts index 059286852..69ddafdca 100644 --- a/packages/prisma-next/test/v3/runtime-v3.test.ts +++ b/packages/prisma-next/test/v3/runtime-v3.test.ts @@ -12,10 +12,12 @@ import { describe, expect, it, vi } from 'vitest' import type { CipherstashSdk } from '../../src/execution/sdk' -import { - CIPHERSTASH_SPACE_ID, - CIPHERSTASH_STRING_CODEC_ID, -} from '../../src/extension-metadata/constants' +import { CIPHERSTASH_SPACE_ID } from '../../src/extension-metadata/constants' + +// A legacy (non-v3) codec id — the v3 runtime descriptor must never emit one. +// (The EQL v2 codec ids were removed; this literal stands in for "a v2 id".) +const LEGACY_CODEC_ID = 'cipherstash/string@1' + import { CIPHERSTASH_V3_CODEC_IDS, CIPHERSTASH_V3_EXTENSION_VERSION, @@ -59,10 +61,8 @@ describe('createCipherstashV3RuntimeDescriptor — descriptor shape', () => { expect(viaCodecs.map((c) => c.codecId).sort()).toEqual( [...CIPHERSTASH_V3_CODEC_IDS].sort(), ) - // Never a v2 codec id. - expect(viaCodecs.map((c) => c.codecId)).not.toContain( - CIPHERSTASH_STRING_CODEC_ID, - ) + // Never a legacy (v2) codec id. + expect(viaCodecs.map((c) => c.codecId)).not.toContain(LEGACY_CODEC_ID) }) it('every queryable codec carries only cipherstash:* namespaced traits', () => { diff --git a/packages/prisma-next/tsup.config.ts b/packages/prisma-next/tsup.config.ts index 75c98f666..9f209495f 100644 --- a/packages/prisma-next/tsup.config.ts +++ b/packages/prisma-next/tsup.config.ts @@ -5,8 +5,6 @@ export default defineConfig({ 'src/exports/codec-types.ts', 'src/exports/column-types.ts', 'src/exports/control.ts', - 'src/exports/middleware.ts', - 'src/exports/migration.ts', 'src/exports/operation-types.ts', 'src/exports/pack.ts', 'src/exports/runtime.ts', diff --git a/skills/stash-prisma-next/SKILL.md b/skills/stash-prisma-next/SKILL.md index 7030d65d5..55c6181d8 100644 --- a/skills/stash-prisma-next/SKILL.md +++ b/skills/stash-prisma-next/SKILL.md @@ -11,10 +11,8 @@ encrypted columns directly in `schema.prisma`; Prisma Next's migration system installs the EQL bundle in the same sweep that creates your tables — there is no separate `stash eql install` step. -> This is the **EQL v3** surface (the documented one). A legacy EQL v2 surface -> exists for existing deployments (`cipherstashFromStackV2` from -> `@cipherstash/prisma-next/stack`, `cipherstash*` operators); everything below -> is v3. New projects use v3. +> `@cipherstash/prisma-next` is **EQL v3 only** — there is no EQL v2 surface. +> Everything below is v3. In EQL v3 every encrypted column is a **concrete Postgres domain** (`public.eql_v3_text_search`, `public.eql_v3_double_ord`, …) whose query @@ -109,9 +107,7 @@ export const db = postgres({ the contract (one `public.eql_v3_*` domain per column), constructs the `@cipherstash/stack` `EncryptionV3` client from your `CS_*` env vars or local profile, builds the SDK adapter, and returns ready-to-spread `extensions` and -`middleware`. A v3 client is v3-only — a contract carrying v2 codec ids is -rejected at setup (use `cipherstashFromStackV2` from -`@cipherstash/prisma-next/stack` for a v2 contract). +`middleware`. ## Install the EQL bundle (part of your migration, not a separate step) @@ -170,7 +166,7 @@ column type is `DoubleOrd`. Envelope ↔ column pairing: `EncryptedString` Operators live on the encrypted column inside `.where((u) => …)` and encrypt the search term for you — Prisma Next never sees plaintext in a query. EQL v3 uses the -EQL-derived `eql*` vocabulary (the legacy v2 surface keeps `cipherstash*` names): +EQL-derived `eql*` vocabulary: | Operator | Meaning | Requires | |---|---|---| @@ -227,13 +223,13 @@ use `@cipherstash/stack/wasm-inline`. | `@cipherstash/prisma-next/v3` | The v3 surface: `cipherstashFromStack`, the SDK adapter, envelopes/middleware | | `@cipherstash/prisma-next/control` | The extension pack for `extensionPacks: [...]` | | `@cipherstash/prisma-next/runtime` | Envelope classes, `decryptAll`, `eql*` operators, `EncryptedString.from()`… | -| `@cipherstash/prisma-next/stack` | Legacy EQL v2 one-call setup (`cipherstashFromStackV2`) | +| `@cipherstash/prisma-next/stack` | One-call setup against `@cipherstash/stack`: `cipherstashFromStack` | ## Gotchas - **EQL installs via `prisma-next migration apply`, never `stash eql install`.** - **Column type (schema, domain-named) ≠ runtime envelope (value, primitive-named).** `DoubleOrd` column ↔ `EncryptedNumber.from(...)` value. -- **A v3 client rejects a v2 contract** at `cipherstashFromStack`. Regenerate the - contract (`prisma-next contract emit`) after switching a column to a v3 type. +- **Regenerate the contract** (`prisma-next contract emit`) after changing a + column's encrypted type, so `cipherstashFromStack` and the migrations agree. - **Never log or read `~/.cipherstash`** or `.env*` credential files (see `stash-cli`).