diff --git a/.changeset/adapter-release-readiness.md b/.changeset/adapter-release-readiness.md
new file mode 100644
index 000000000..f633bd8df
--- /dev/null
+++ b/.changeset/adapter-release-readiness.md
@@ -0,0 +1,50 @@
+---
+'@cipherstash/stack': minor
+'@cipherstash/stack-supabase': minor
+'stash': patch
+'@cipherstash/wizard': patch
+---
+
+Finish the EQL v2-removal release gates and adapter correctness pass.
+
+- **Supabase encrypts leaves nested inside a PostgREST boolean group.** This
+ is a disclosure fix, not a formatting one. The `.or()` string parser had
+ no group recursion, so `.or('and(createdAt.gte.2026-01-01,note.eq.x)')`
+ came back from the top-level split as one part and the leaf parser cut it
+ at the first dot into the pseudo-column `and(createdAt`. That name matched
+ no encrypted column, so the whole expression took the verbatim branch: the
+ operand `2026-01-01` reached PostgREST **as plaintext, against an
+ encrypted column**, under the JS property name `createdAt` rather than the
+ DB column name `created_at`. Every encrypted leaf nested inside `and(...)`
+ / `or(...)` / `not.and(...)` leaked its operand to the database and
+ returned wrong results. Nested groups and `referencedTable` are now
+ preserved while each encrypted leaf is substituted in place.
+- Supabase never sends nullish encrypted search operands as plaintext, honours
+ escaped LIKE metacharacters, rejects CSV result mode before decryption, and
+ diagnoses the removed object-form factory call. The bundled `stash-supabase`
+ skill no longer lists `csv()` among the transforms passed through to
+ Supabase — it throws, and the skill now says so and shows serializing the
+ decrypted rows instead.
+- Native, WASM, and Supabase model decryption reconstruct valid date and
+ timestamp values consistently, including nested paths, aliases, and bulk
+ results, while leaving invalid values unchanged. That last clause is a
+ behavioural change on the native typed client and the Supabase adapter,
+ which previously pushed every date-like column through `new Date(...)`
+ unconditionally: a stored value that does not parse used to come back as an
+ Invalid `Date` and now comes back as the raw string, matching what the WASM
+ entry already did. The declared column type is still `Date`, so code that
+ assumed `instanceof Date` held for every date column — or called a `Date`
+ method on it unguarded, so that `.getTime()` used to yield `NaN` and now
+ throws a `TypeError` — has to handle the raw value.
+- `stash init` names the concrete `public.eql_v3_*` domain family and gives
+ `public.eql_v3_text_search` as a valid Supabase example.
+- CLI and wizard skill selection stay in parity for every integration,
+ including the Prisma Next skill, and verify that each selected skill has a
+ `SKILL.md`.
+
+The final 1.0 integration surface is `Encryption` from
+`@cipherstash/stack/v3`, the `@cipherstash/stack-drizzle` package root, and
+`encryptedSupabase` from `@cipherstash/stack-supabase`. DynamoDB decrypt
+operations retain `.audit()` on the typed `Encryption` client. Existing EQL v2
+ciphertext remains readable through the core client; authoring and adapter
+writes use EQL v3.
diff --git a/.changeset/backfill-missing-column-message.md b/.changeset/backfill-missing-column-message.md
new file mode 100644
index 000000000..4586f422e
--- /dev/null
+++ b/.changeset/backfill-missing-column-message.md
@@ -0,0 +1,12 @@
+---
+'stash': patch
+---
+
+`stash encrypt backfill` now distinguishes a missing encrypted column from a
+legacy EQL v2 one. The domain probe returns the same "not v3" answer for both,
+so a user who had simply not added the `
_encrypted` column yet was told
+they were on a legacy EQL v2 column and advised to migrate a domain that did not
+exist. The command now reports that the column is absent, points at adding an
+`eql_v3_*`-domain column and applying the migration, and mentions
+`--encrypted-column` for non-standard names. The EQL v2 message is unchanged for
+columns that really are present.
diff --git a/.changeset/cli-near-miss-statement-trim.md b/.changeset/cli-near-miss-statement-trim.md
new file mode 100644
index 000000000..6cd7180aa
--- /dev/null
+++ b/.changeset/cli-near-miss-statement-trim.md
@@ -0,0 +1,15 @@
+---
+'stash': patch
+---
+
+Trim the leading comment block from near-miss statements reported by the Drizzle
+migration rewriter (`stash eql migration --drizzle`, `stash eql install`).
+
+The broad near-miss scan is anchored on the previous `;`, so a
+`SET DATA TYPE … USING …` it could not safely repair was quoted back to the user
+with every preceding comment and blank line glued to its front — in a file
+opening with a comment block, that meant the whole header. The reported
+statement is now the offending statement alone. Detection is unchanged; only the
+text shown to the user is affected.
+
+Keeps this rewriter in sync with its sibling in `@cipherstash/wizard`.
diff --git a/.changeset/cli-v2-cutover-prompt-correction.md b/.changeset/cli-v2-cutover-prompt-correction.md
new file mode 100644
index 000000000..4a90e2f6b
--- /dev/null
+++ b/.changeset/cli-v2-cutover-prompt-correction.md
@@ -0,0 +1,15 @@
+---
+'stash': patch
+---
+
+`stash init`'s setup prompt no longer tells agents to declare an EQL v2 cutover
+column with a `types.*` domain.
+
+The `types.*` factories are EQL v3 only — a v2 column is an `eql_v2_encrypted`
+composite — so an agent following the old step-4 guidance would author a schema
+that cannot describe the column it just cut over to. The prompt now says
+explicitly not to use `types.*` for a v2 column, and points at the deprecated
+`@cipherstash/stack/schema` builders with decryption through
+`@cipherstash/stack`, which is the actual read path for legacy v2 rows.
+
+Superseded later in this release: `stash encrypt cutover` and all v2 mutation guidance are removed; legacy v2 remains read-only.
diff --git a/.changeset/decrypt-chaining-docs.md b/.changeset/decrypt-chaining-docs.md
new file mode 100644
index 000000000..36afc506b
--- /dev/null
+++ b/.changeset/decrypt-chaining-docs.md
@@ -0,0 +1,73 @@
+---
+'stash': patch
+'@cipherstash/stack': patch
+---
+
+Correct the shipped documentation for `decryptModel` / `bulkDecryptModels`.
+
+Three places in `skills/stash-encryption` and four in `packages/stack/README.md`
+said these return "a plain `Promise>` (not a chainable operation)"
+and that there is therefore "no `.withLockContext()` to chain". They return an
+`AuditableDecryptModelOperation`, which is thenable and carries both
+`.withLockContext()` and `.audit()` — the same `.audit()` chain the
+audit-on-decrypt work advertises. The skill contradicted itself: its own
+reference table already listed the correct return type.
+
+The skill ships inside the `stash` tarball and `installSkills()` copies it into
+customer repos, so this was steering agents away from an API that exists. The
+README ships in the `@cipherstash/stack` tarball.
+
+The equivalent statement about the **WASM entry** is correct and unchanged —
+`@cipherstash/stack/wasm-inline` really does return a plain promise from decrypt,
+with no lock-context argument.
+
+Also fixes the setup prompt `stash init` writes for coding agents, which
+referenced `protectOps.eq` — an API that does not exist anywhere in the repo.
+Every step naming an integration-specific API now branches on the project's
+actual integration, instead of naming Drizzle's and Supabase's and leaving the
+other two to guess:
+
+- **Query paths.** `createEncryptionOperators(client)` (conventionally `ops`)
+ for Drizzle, the `encryptedSupabase` wrapper's own filters for Supabase, the
+ `eql*` column operators for Prisma Next, and `client.encryptQuery(...)` for a
+ plain Postgres project.
+- **Schema authoring.** The `types.*` column factories for Drizzle, a concrete
+ `public.eql_v3_*` domain such as `public.eql_v3_text_search` in migration SQL
+ for Supabase, the `cipherstash.*`
+ field constructors in `schema.prisma` for Prisma Next, and `encryptedTable`
+ for plain Postgres. Prisma Next was previously sent at `types.*` /
+ `encryptedTable` — the client `stash schema build` explicitly refuses to
+ scaffold for that integration.
+- **Read paths.** `decryptModel(row, usersSchema)` where that applies, and the
+ wrapper's transparent decryption where it does not.
+- **Skill pointers.** A plain Postgres project installs no integration-specific
+ skill, so each "see the integration skill" was a pointer at a file that was
+ never written. Those now point at `stash-encryption`, which it does get.
+
+`client.encryptQuery` is also shown taking the schema objects themselves
+(`{ table: usersSchema, column: usersSchema.email }`) rather than an
+object-shorthand that read as three required strings — `queryType` is inferred
+from the column's configured indexes.
+
+The cutover and complete-rollout **plan templates** now split EQL v3 from v2.
+Both described the v2 rename swap (`` → `_plaintext`, twin → ``)
+as the only cutover path, so on the default v3 install `stash plan` drafted a
+plan built around `stash encrypt cutover` — a command that refuses v3 columns
+outright ("there is no rename step") and refuses entirely on a v3-only
+database, where `eql_v2_configuration` does not exist. The implement prompt
+already carried this split; the plan templates did not. The version is
+per-column, so the templates tell the agent to establish it per column rather
+than deciding once for the whole plan.
+
+The "already encrypted" stop-and-ask now recognises `eql_v3_*` domains
+alongside the legacy `eql_v2_encrypted` udt, so it can fire on the default
+path at all.
+
+**`stash init` now detects already-encrypted columns on EQL v3.** Database
+introspection marked a column as CipherStash-managed only when its udt was
+exactly `eql_v2_encrypted`. v3 columns carry per-domain types
+(`eql_v3_text_search`, `eql_v3_integer_ord`, …), so on the default path every
+encrypted column was reported as plaintext — shown with its `dataType` and left
+unticked in the column picker, inviting a re-run to encrypt it a second time.
+The picker also labelled any encrypted column with the literal string
+`eql_v2_encrypted`; it now shows the column's real domain.
diff --git a/.changeset/domain-carrier-not-public.md b/.changeset/domain-carrier-not-public.md
new file mode 100644
index 000000000..202284dbb
--- /dev/null
+++ b/.changeset/domain-carrier-not-public.md
@@ -0,0 +1,18 @@
+---
+'@cipherstash/stack': patch
+---
+
+The phantom domain carrier on encrypted v3 columns is no longer part of the
+public surface.
+
+Recovering a column's domain after declaration emit requires the type parameter
+to appear bare somewhere in the instance type, which is what makes typed
+`encryptQuery` callable against the published package. That carrier was a
+plainly named `__domain` property, and this build has no `stripInternal`, so
+`@internal` was documentation rather than enforcement: it appeared in
+autocomplete on every column and could be read from consumer code, typed
+`D | undefined` while being `undefined` at runtime in every case.
+
+It is now keyed by a `unique symbol` that is not exported, so it is unnameable
+outside the package while remaining exactly as inferrable. Nothing is emitted at
+runtime and no call site changes.
diff --git a/.changeset/drizzle-encrypted-indexes.md b/.changeset/drizzle-encrypted-indexes.md
index d9f390253..9af771af2 100644
--- a/.changeset/drizzle-encrypted-indexes.md
+++ b/.changeset/drizzle-encrypted-indexes.md
@@ -2,7 +2,7 @@
'@cipherstash/stack-drizzle': minor
---
-New `encryptedIndexes` helper on the `/v3` entry: spread
+New `encryptedIndexes` helper on the package root: spread
`...encryptedIndexes(t)` in `pgTable`'s third-argument callback and it derives
the recommended functional indexes for every encrypted column in the table —
named `__`, tracked by `drizzle-kit generate` like
diff --git a/.changeset/dynamodb-eql-v3.md b/.changeset/dynamodb-eql-v3.md
index c6303c997..8ba065f9d 100644
--- a/.changeset/dynamodb-eql-v3.md
+++ b/.changeset/dynamodb-eql-v3.md
@@ -6,15 +6,17 @@
`encryptedDynamoDB` now accepts EQL v3 tables.
Pass a table built with `encryptedTable` + the `types.*` domains from
-`@cipherstash/stack/v3` (or `@cipherstash/stack/eql/v3`) to any of
-`encryptModel`, `bulkEncryptModels`, `decryptModel`, `bulkDecryptModels`. Both
-the typed client from `EncryptionV3` and the nominal client from
-`Encryption({ config: { eqlVersion: 3 } })` are accepted.
+`@cipherstash/stack/v3` to any of `encryptModel`, `bulkEncryptModels`,
+`decryptModel`, or `bulkDecryptModels`. Build the typed client with
+`Encryption({ schemas: [table] })`.
-EQL v2 tables continue to work unchanged — this is additive, and no existing
-caller needs to change. The table decides which wire format is used, so a
-DynamoDB table populated under one version must keep being read with that
-version.
+EQL v2 tables continue to be **readable** — `decryptModel` /
+`bulkDecryptModels` still accept one, so existing items stay accessible. Writing
+through a v2 table is a separate matter: `encryptModel` / `bulkEncryptModels`
+narrowed to EQL v3 tables in this same release, so a caller that still encrypts
+through a v2 table does need to change. The table decides which wire format is
+used, so a DynamoDB table populated under one version must keep being read with
+that version.
This fixes a latent bug that made v3 unusable: the write path detected an
encrypted value by its `k: 'ct'` tag, but EQL v3 scalars carry no `k`
@@ -33,8 +35,8 @@ Notes on capability:
path — `{ 'profile.ssn': types.TextEq('profile.ssn') }`. The model is
matched by dotted path, so `{ profile: { ssn } }` resolves, and the nested
attribute keeps its `__hmac` for key conditions.
-- Audit metadata on `decryptModel` / `bulkDecryptModels` requires the nominal
- client; the `EncryptionV3` client has no audit surface on decrypt.
+- The typed `Encryption` client supports `.audit()` on `decryptModel` and
+ `bulkDecryptModels`, including when used through the DynamoDB adapter.
The DynamoDB adapter also gains its first test coverage — across the v2 and v3
paths, where it previously had none.
@@ -71,4 +73,4 @@ type, where a declared column `email` becomes `email__source` (plus
`email`. `decryptModel` / `bulkDecryptModels` invert it via `DecryptedAttributes`.
`AnyEncryptedTable`, `DynamoDBEncryptionClient` and `AuditConfig` are now
exported from `@cipherstash/stack/dynamodb` so these signatures can be named.
-The EQL v2 overloads are unchanged.
+The EQL v2 **decrypt** overloads are unchanged; the v2 encrypt overloads are removed in this release.
diff --git a/.changeset/dynamodb-skill-wasm-caveat.md b/.changeset/dynamodb-skill-wasm-caveat.md
new file mode 100644
index 000000000..21d3f80ba
--- /dev/null
+++ b/.changeset/dynamodb-skill-wasm-caveat.md
@@ -0,0 +1,17 @@
+---
+'stash': patch
+---
+
+The `stash-dynamodb` and `stash-encryption` skills documented EQL v2 decrypt as
+unconditionally supported, without noting that `@cipherstash/stack/wasm-inline`
+is EQL v3 only. Since that is the documented entry for Deno, Bun, Cloudflare
+Workers and Supabase Edge Functions — runtimes commonly paired with DynamoDB — a
+reader following the skill hit a runtime refusal with no forewarning. Both skills
+now state that legacy v2 items are readable on the native `@cipherstash/stack`
+entry only.
+
+The `stash-dynamodb` API reference also claimed audit metadata forwards to
+ZeroKMS "regardless of client shape". It does not: the wasm-inline client's
+operations return a plain promise with no `.audit()`, so its audit metadata is
+dropped (logged at debug). The reference now says so, and says the operation
+still succeeds.
diff --git a/.changeset/dynamodb-v2-read-table-forwarding.md b/.changeset/dynamodb-v2-read-table-forwarding.md
new file mode 100644
index 000000000..82918224a
--- /dev/null
+++ b/.changeset/dynamodb-v2-read-table-forwarding.md
@@ -0,0 +1,27 @@
+---
+'@cipherstash/stack': patch
+---
+
+Fix `encryptedDynamoDB`'s EQL v2 read path, which failed against a v3-configured
+client — the case the v2 read support exists for.
+
+`decryptModel` and `bulkDecryptModels` forwarded the table to the encryption
+client unconditionally. The second argument is only meaningful to the typed EQL
+v3 client, which resolves it against its own reconstructor map; a legacy v2
+table is not in that map, so the call failed with:
+
+```
+[eql/v3]: decryptModel received a table this client was not initialized with
+```
+
+That contradicted the adapter's documented contract — writes are EQL v3 only,
+but decrypt keeps accepting a v2 table so previously stored items stay readable.
+In practice the failure hit exactly the customers the compatibility promise was
+written for: upgraded to a v3 schema, with v2 items still in the table.
+
+The table is now forwarded only when it is an EQL v3 table. A v2 table takes the
+table-less form, and the client derives the table from the payloads themselves.
+
+Both paths are covered by a credential-free test asserting what the adapter
+forwards, so a regression no longer depends on a live-credential integration run
+to surface.
diff --git a/.changeset/dynamodb-wasm-v2-read.md b/.changeset/dynamodb-wasm-v2-read.md
new file mode 100644
index 000000000..b75673051
--- /dev/null
+++ b/.changeset/dynamodb-wasm-v2-read.md
@@ -0,0 +1,39 @@
+---
+'@cipherstash/stack': patch
+---
+
+`encryptedDynamoDB` now refuses a `@cipherstash/stack/wasm-inline` client paired
+with a legacy EQL v2 table, instead of failing at the first read with a
+misleading error.
+
+The adapter's v2 read path deliberately calls `decryptModel(item)` with **no**
+table — a v2 table means nothing to a v3 client's reconstructor map, and the
+native clients derive the table from the payloads anyway. `WasmEncryptionClient`
+cannot do that: its decrypt requires the table and resolves date fields from a
+per-table map, so the omitted argument surfaced as
+`TypeError: Cannot read properties of undefined (reading 'tableName')` thrown
+from deep inside the client — on the documented entry for Deno, Cloudflare
+Workers and Supabase Edge Functions, which satisfies the adapter's client type
+structurally and so was accepted with no cast.
+
+The pairing is now rejected at the call site, with a message naming both the
+combination and the fix. The message is operation-neutral: the guard runs on all
+four operations, so a plain-JS caller reaching the write path with a v2 table
+gets a message that does not claim a read it never attempted.
+
+`encryptModel` / `bulkEncryptModels` now also tolerate a client whose encrypt
+returns a plain promise. They chained `.audit()` onto the result
+unconditionally, which the wasm-inline client does not carry — so **every EQL v3
+write through this adapter on that entry** failed with
+`client.encryptModel(...).audit is not a function`, surfaced as a
+`DYNAMODB_ENCRYPTION_ERROR` and so indistinguishable from a real encryption
+fault. The read path already handled this; the write path now matches, via the
+same fail-closed check that rejects a malformed result rather than passing an
+unencrypted item through as a success. Audit metadata still has nowhere to go on
+that client, so it is dropped and logged at debug — the write itself succeeds.
+
+Three comments in this package claimed audit metadata was forwarded "regardless
+of client shape" and that "every client this package ships carries `.audit()` on
+decrypt". Neither was true of the wasm-inline client, whose decrypt returns a
+plain promise — the metadata is dropped, observably (it is logged at debug), and
+the comments and the debug message now say so.
diff --git a/.changeset/encrypt-client-guard-parity.md b/.changeset/encrypt-client-guard-parity.md
new file mode 100644
index 000000000..63c3a2664
--- /dev/null
+++ b/.changeset/encrypt-client-guard-parity.md
@@ -0,0 +1,20 @@
+---
+'stash': patch
+---
+
+`stash encrypt backfill` now names the cause when the encryption client has no
+initialized encrypt config, instead of reporting a missing table.
+
+The guard that refuses an unusable client file existed twice — once in
+`loadEncryptConfig` (`stash db push` / `db validate`) and once, hand-copied, in
+`loadEncryptionContext` (`stash encrypt backfill`). The copies had already
+drifted: for a client whose `getEncryptConfig()` returns nothing, `db push`
+exited 1 with `Encryption client in has no initialized encrypt config`,
+while `encrypt backfill` fell through to `Table "users" was not found in the
+encryption client exports. Available: (none)` — naming the symptom rather than
+the cause, which is precisely the failure the guard was added to eliminate.
+
+Both loaders now call one shared guard, so a single file cannot produce two
+different diagnoses, and the refusals are pinned at both public entry points.
+The un-replaced `stash init` scaffold is unaffected — it was already refused by
+both, with the same message.
diff --git a/.changeset/encrypt-lifecycle-mixed-table.md b/.changeset/encrypt-lifecycle-mixed-table.md
new file mode 100644
index 000000000..f6b6273bd
--- /dev/null
+++ b/.changeset/encrypt-lifecycle-mixed-table.md
@@ -0,0 +1,61 @@
+---
+'stash': patch
+---
+
+`stash encrypt cutover` and `stash encrypt drop` no longer act on — or report
+success for — an encrypted column they only guessed at.
+
+On a table holding both a legacy EQL v2 pair (`ssn` / `ssn_encrypted`) and one
+unrelated EQL v3 column, the v2 ciphertext column is not classified as an EQL
+column at all, so column resolution fell through to the "this is the table's
+only EQL column" rule and claimed the unrelated v3 column. Three consequences,
+all now fixed:
+
+- **`cutover` reported success for work it never did.** Its EQL v3 branch had no
+ guard on how the column was resolved, and returned without setting an exit
+ code — so it printed "point your application at `email_enc`" and exited 0
+ while the v2 rename never ran. A scripted rollout read that as complete. It
+ now refuses, and exits 1, exactly as `drop` already did.
+
+- **The recorded pairing was discarded.** `encrypt backfill` writes the true
+ `encryptedColumn` to `.cipherstash/migrations.json`, so the answer was already
+ on disk — but a hint that failed to resolve was dropped entirely and the
+ re-resolution reached the guess. A hint naming a column that still exists but
+ is not an EQL v3 column is now reported as what it is (most often a legacy
+ `eql_v2_encrypted` counterpart) instead of being replaced by a guess. A
+ genuinely stale hint — one naming a column that is gone — still falls back to
+ the naming convention as before.
+
+- **`drop`'s refusal message prescribed the guess.** It told the user to re-run
+ `backfill --encrypted-column `. Following it recorded the
+ guess as fact, so the next run resolved "by hint", walked past the refusal,
+ and passed the coverage check vacuously — an unrelated but legitimately
+ backfilled column is non-NULL on every row — then generated a live
+ `DROP COLUMN` on the plaintext and exited 0. The message now asks for the
+ column that actually encrypts the named one, and says explicitly not to record
+ the guess.
+
+The `unresolvedHint` refusal is scoped to tables that actually hold EQL v3
+columns a guess could wrongly claim. A **pure-v2** table has none, so it still
+falls through to the EQL v2 lifecycle exactly as before — including when
+`encrypt backfill` recorded an `encryptedColumn` for it, which it does for v2
+columns too.
+
+Two cases DO newly exit 1, both deliberately:
+
+- Any table with at least one EQL v3 column where the manifest records an
+ `encryptedColumn` that exists but is not one of them — not only the
+ v2-pair-plus-one-v3 shape. The recorded pairing is authoritative and
+ disagrees with every candidate, so guessing past it is the bug.
+
+- A column whose encrypted counterpart could only be identified **by
+ elimination** — no recorded `encryptedColumn`, no `_encrypted` name
+ match, one EQL column left once the plaintext column itself is excluded.
+ `cutover` now refuses this as `drop` already did. Note `.cipherstash/` is
+ gitignored, so `migrations.json` is machine-local: a fresh clone or CI runner
+ can hit this on a **pure-v3** table whose encrypted column is named
+ unconventionally, where `cutover` previously exited 0 with "not applicable".
+ Re-run `stash encrypt backfill --table T --column C --encrypted-column `
+ to record the pairing.
+
+Superseded later in this release: no v2 lifecycle can be driven by `stash encrypt`; mixed and pure-v2 state now fail with migration/recovery guidance.
diff --git a/.changeset/encryption-schema-arrays.md b/.changeset/encryption-schema-arrays.md
new file mode 100644
index 000000000..f5a125303
--- /dev/null
+++ b/.changeset/encryption-schema-arrays.md
@@ -0,0 +1,52 @@
+---
+'@cipherstash/stack': minor
+'@cipherstash/prisma-next': patch
+---
+
+`Encryption({ schemas })` now accepts any non-empty array of EQL v3 tables, not
+only an array literal.
+
+Previously the v3 signature required a non-empty *tuple*, so every indirect form
+was rejected at compile time even though it worked at runtime:
+
+```ts
+// all of these used to fail with TS2769
+export const schemas: AnyV3Table[] = [users, orders]
+await Encryption({ schemas })
+
+const readonlySchemas: ReadonlyArray = [users, orders]
+await Encryption({ schemas: readonlySchemas })
+
+const built: AnyV3Table[] = []
+built.push(users)
+await Encryption({ schemas: built })
+```
+
+They all compile now. `Encryption({ schemas: [] })` is still a compile error, and
+an array literal still gets full per-column typing — passing the wrong plaintext
+for a column's domain, or a table the client was not built with, is still
+rejected.
+
+`EncryptionClientFor` is widened to match, so it names the typed client for a
+loose `readonly AnyV3Table[]` as well as for a tuple. Code that is generic over
+its schemas — an adapter that builds a table per request, say — can now write
+`EncryptionClientFor` and keep the typed surface.
+
+If you narrowed a schema array to `readonly [AnyV3Table, ...AnyV3Table[]]` to
+satisfy the old signature, that narrowing is no longer needed.
+
+A wrapper that is itself generic over its schemas keeps working too:
+
+```ts
+async function makeClient(
+ schemas: S,
+) {
+ return await Encryption({ schemas })
+}
+```
+
+Note the non-empty tuple constraint. A wrapper generic over a loose
+`readonly AnyV3Table[]` is still rejected — that type admits `readonly []`, so
+the wrapper cannot promise what `Encryption` requires. Constrain it as above, or
+take `EncryptionClientFor` as a parameter instead of
+building the client inside the generic function.
diff --git a/.changeset/eql-v3-cli-install.md b/.changeset/eql-v3-cli-install.md
index 68f88e443..264f449d9 100644
--- a/.changeset/eql-v3-cli-install.md
+++ b/.changeset/eql-v3-cli-install.md
@@ -11,3 +11,5 @@ stripped). v3 currently supports the direct install path only —
`--drizzle`/`--migration`/`--migrations-dir`/`--latest` are rejected — and the
installer keys `isInstalled`/version checks and Supabase grants to the `eql_v3`
schema.
+
+Superseded later in this release: `--eql-version` and the v2 installer are removed; installs and upgrades are v3-only.
diff --git a/.changeset/eql-v3-sole-docs.md b/.changeset/eql-v3-sole-docs.md
index 1e1a72020..cb21d891e 100644
--- a/.changeset/eql-v3-sole-docs.md
+++ b/.changeset/eql-v3-sole-docs.md
@@ -5,12 +5,24 @@
Docs: EQL v3 is now the sole documented approach. The `stash-encryption`,
`stash-drizzle`, and `stash-supabase` skills and the `@cipherstash/stack`
-README teach only the v3 typed surface (`EncryptionV3`, `types.*` concrete
-domains, `@cipherstash/stack-drizzle/v3`, `encryptedSupabaseV3`); EQL v2
-shrinks to one short Legacy section per document. Two explicit exceptions are
-called out: DynamoDB still requires the v2 schema surface (#657), and the
-encrypt rollout tooling (`stash encrypt backfill`/`cutover`,
-`@cipherstash/migrate`) currently targets v2 columns (#648) — its guidance is
-kept under a version callout. Also corrects the legacy `@cipherstash/drizzle`
-README's pointer to the removed `@cipherstash/stack/drizzle` subpath (now the
-separate `@cipherstash/stack-drizzle` package).
+README teach only the v3 typed surface (`Encryption`, the `types.*` concrete
+domains, the `@cipherstash/stack-drizzle` package root, `encryptedSupabase`);
+EQL v2 shrinks to one short Legacy section per document. Two places keep more
+than a Legacy section, because EQL v2 is still reachable there:
+
+- **DynamoDB reads.** `encryptedDynamoDB` writes EQL v3 only, but `decryptModel`
+ / `bulkDecryptModels` still accept an EQL v2 table so previously stored v2
+ items stay readable, so the `stash-dynamodb` skill keeps the v2 schema shape
+ documented for that read path (#657).
+- **The encrypt rollout lifecycle.** `stash encrypt *` and `@cipherstash/migrate`
+ detect a column's generation from its Postgres domain type, with EQL v3 as the
+ recognised and default generation; a legacy `eql_v2_encrypted` column does not
+ classify and falls through to the v2 lifecycle, which ends in
+ `stash encrypt cutover` rather than the v3 `stash encrypt drop`. That
+ difference is kept under a version callout (#648).
+
+Also corrects the legacy `@cipherstash/drizzle` README's pointer to the removed
+`@cipherstash/stack/drizzle` subpath (now the separate `@cipherstash/stack-drizzle`
+package).
+
+Superseded later in this release: CLI/migrate v2 mutation guidance is removed; only legacy ciphertext reads and status diagnostics remain.
diff --git a/.changeset/init-drizzle-eql-v3.md b/.changeset/init-drizzle-eql-v3.md
index fac150347..4ae941236 100644
--- a/.changeset/init-drizzle-eql-v3.md
+++ b/.changeset/init-drizzle-eql-v3.md
@@ -21,6 +21,7 @@ The generated migration also carries the `cs_migrations` tracking schema, so one
isn't installed or configured, init now reports EQL as not installed and points
at `stash eql migration --drizzle` rather than aborting the run.
-The v2 Drizzle path remains available for existing deployments via an explicit
-`stash eql install --drizzle --eql-version 2`; that command's error message now
-points at the v3 alternative instead of only suggesting `--eql-version 2`.
+The final CLI installation and mutation surface is v3-only: the explicit v2
+Drizzle install path is removed. Legacy v2 remains readable and visible in
+diagnostics. Generate a checked-in install migration with `stash eql migration
+--drizzle`.
diff --git a/.changeset/init-placeholder-eql-v3.md b/.changeset/init-placeholder-eql-v3.md
index b82cf31cc..6be0fe42e 100644
--- a/.changeset/init-placeholder-eql-v3.md
+++ b/.changeset/init-placeholder-eql-v3.md
@@ -17,3 +17,7 @@ the concrete-domain `types.*` factories (`types.TextSearch`, `types.IntegerOrd`,
`types.Text`, `types.Json`, …), and the `@cipherstash/stack-drizzle/v3` entry
(`extractEncryptionSchemaV3`) for Drizzle. The `encryptionClient` export shape
and the empty-schema "no schemas yet" error path are unchanged.
+
+**Superseded later in this release** by the `@cipherstash/stack-drizzle` EQL v2
+removal: the scaffolds now emit `Encryption` from `@cipherstash/stack/v3` and
+`extractEncryptionSchema` from the collapsed `@cipherstash/stack-drizzle` root.
diff --git a/.changeset/init-scaffold-compiles.md b/.changeset/init-scaffold-compiles.md
new file mode 100644
index 000000000..acb2a14ff
--- /dev/null
+++ b/.changeset/init-scaffold-compiles.md
@@ -0,0 +1,29 @@
+---
+'stash': patch
+---
+
+The client file `stash init` writes now compiles.
+
+Both placeholder templates emitted `await Encryption({ schemas: [] })`, and
+`Encryption` requires at least one table — an empty schema set is a deliberate
+compile error, so it cannot be relaxed. Every `stash init` therefore left a
+project whose first `tsc` or `next build` failed, in a file the CLI had just
+told the user not to hand-edit. (The previous scaffold called `EncryptionV3`,
+whose looser bound accepted `[]`; collapsing that into an alias of `Encryption`
+tightened it.)
+
+The scaffold now declares a single sentinel table, `__stash_placeholder__`, so
+the file typechecks as written. Every command that reads the encryption client
+— `stash db push`, `stash db validate`, and `stash encrypt backfill` — refuses
+to run while that table is still the only one declared, and names it, rather
+than failing later with a confusing "table not found". (`stash encrypt cutover`
+and `stash encrypt drop` do not read the client file at all; they resolve
+against the database.)
+
+Nothing in the repo compiled this output before: `packages/cli` has no
+typecheck step, the codegen tests only string-match fragments of the template,
+and the step test stubs the generator out entirely. Both templates are now
+committed as fixtures that CI typechecks, pinned byte-for-byte to the generator
+so they cannot drift.
+
+Superseded later in this release: the generated guidance no longer references removed `db push` or `encrypt cutover` commands.
diff --git a/.changeset/migrate-column-exists.md b/.changeset/migrate-column-exists.md
new file mode 100644
index 000000000..d88867430
--- /dev/null
+++ b/.changeset/migrate-column-exists.md
@@ -0,0 +1,21 @@
+---
+'@cipherstash/migrate': minor
+'stash': patch
+---
+
+Add `columnExists(client, tableName, columnName)` — a case-exact "does this
+column exist at all?" catalog probe, distinct from `detectColumnEqlVersion`'s
+"and is it an EQL column?".
+
+Callers need that difference to tell a STALE column reference (it is gone) from
+a live one the domain classifier simply does not recognise — most often a legacy
+`eql_v2_encrypted` counterpart.
+
+`stash encrypt cutover` / `drop` had a private copy of this probe built on a bare
+`to_regclass($1)`. That form *parses* its argument and case-folds unquoted
+identifiers, so on a Prisma-style `"User"` table it resolved `user`, reported the
+column missing, and treated a valid recorded pairing as stale — silently skipping
+the fail-closed that stops those commands acting on a guessed encrypted column.
+The shared implementation quotes with `format('%I')` first, like every other
+catalog probe in this package, so the lookup is case-exact while still honouring
+`search_path` for unqualified names.
diff --git a/.changeset/migrate-eql-v3.md b/.changeset/migrate-eql-v3.md
index d1e055117..6060e6c9b 100644
--- a/.changeset/migrate-eql-v3.md
+++ b/.changeset/migrate-eql-v3.md
@@ -58,3 +58,5 @@ right lifecycle, no new flags:
The `stash-cli` and `stash-encryption` skills and the `@cipherstash/migrate`
README document the two lifecycles (v2: backfill → cutover → drop;
v3: backfill → switch-by-name → drop).
+
+Superseded later in this release: `@cipherstash/migrate` and the CLI now author and mutate v3 only; legacy v2 manifest fields remain readable.
diff --git a/.changeset/nextjs-stack-metadata.md b/.changeset/nextjs-stack-metadata.md
new file mode 100644
index 000000000..2df589862
--- /dev/null
+++ b/.changeset/nextjs-stack-metadata.md
@@ -0,0 +1,8 @@
+---
+'@cipherstash/nextjs': patch
+---
+
+Correct the published package metadata to reference `@cipherstash/stack`
+instead of the removed `@cipherstash/protect` package. The package now also
+ships with its own source typecheck command and keeps its Vitest mock typing
+compatible with the repository-pinned test runner.
diff --git a/.changeset/one-sided-eql-detection-docs.md b/.changeset/one-sided-eql-detection-docs.md
new file mode 100644
index 000000000..0da0531a7
--- /dev/null
+++ b/.changeset/one-sided-eql-detection-docs.md
@@ -0,0 +1,31 @@
+---
+'stash': patch
+'@cipherstash/migrate': patch
+'@cipherstash/stack': patch
+---
+
+Correct shipped documentation that claimed the tooling detects a column's EQL
+**v2** generation. It does not, and has not since `classifyEqlDomain` dropped v2:
+detection is one-sided — a `public.eql_v3_*` Postgres domain classifies as **v3**,
+and anything else (a plaintext column, or a legacy `eql_v2_encrypted` one)
+classifies as *unknown* and falls through to the **v2** lifecycle. The v2 path is
+reached by fallback, not by detection, and a v2 column records no `eqlVersion` in
+`.cipherstash/migrations.json`, so `stash encrypt status` reports no version for
+it.
+
+- `skills/stash-supabase/SKILL.md` said the CLI "still auto-detects a v2 column"
+ (twice, once inside the "Stay on v2 for now" bullet — exactly the case it got
+ wrong) and that `stash encrypt drop` picks its target from a version the CLI
+ "auto-detects". All three now describe the one-sided rule, matching the correct
+ wording already in the same file's EQL version note. This skill is copied into
+ customer repos by `stash init`, so the wrong version of it was being installed
+ as guidance.
+- `packages/migrate/README.md` documented `detectColumnEqlVersion(client, table,
+ column)` as returning `2`, `3`, or `null`. It cannot return `2` — the return
+ type is now stated as `3` or `null`, with what a `null` means for the caller.
+ The lifecycle intro no longer presents the v2 ladder as a detection result.
+- `packages/stack/README.md`'s Supabase example imported and called
+ `encryptedSupabaseV3`, the `@deprecated` alias, contradicting the same file's
+ package table and v3-only note. It now uses `encryptedSupabase`.
+
+Documentation only — no behaviour change.
diff --git a/.changeset/pre.json b/.changeset/pre.json
index cdaa6fd7c..b874a8b82 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -11,9 +11,6 @@
"@cipherstash/migrate": "0.2.0",
"@cipherstash/nextjs": "4.1.1",
"@cipherstash/prisma-next": "0.3.2",
- "@cipherstash/protect": "12.0.1",
- "@cipherstash/protect-dynamodb": "12.0.1",
- "@cipherstash/schema": "3.0.1",
"@cipherstash/stack": "0.19.0",
"@cipherstash/stack-drizzle": "0.0.0",
"@cipherstash/stack-supabase": "0.0.0",
@@ -70,7 +67,6 @@
"remove-legacy-drizzle-package",
"remove-secrets-leftovers",
"rename-db-install-to-eql-install",
- "schema-stevec-standard-pin",
"skills-eql-v3-accuracy",
"skills-identity-docs-refresh",
"stack-1-0-0-rc",
diff --git a/.changeset/prisma-next-v3-client-config.md b/.changeset/prisma-next-v3-client-config.md
new file mode 100644
index 000000000..000bb7d41
--- /dev/null
+++ b/.changeset/prisma-next-v3-client-config.md
@@ -0,0 +1,32 @@
+---
+'@cipherstash/prisma-next': major
+---
+
+**Breaking:** `CipherstashFromStackV3Options.encryptionConfig` — the config
+passed through to the encryption client by `cipherstashFromStack` — is narrowed
+from `ClientConfig` to `V3ClientConfig` (`ClientConfig` without the legacy
+`eqlVersion: 2` escape hatch). Forcing EQL v2 no longer type-checks:
+
+```ts
+const cipherstash = await cipherstashFromStack({
+ contractJson,
+ encryptionConfig: { eqlVersion: 2 },
+ // ^^^^^^^^^^ error TS2322: Type '2' is not assignable to type '3'.
+})
+```
+
+The option never did what it looked like it did. This package is EQL v3 only,
+and `cipherstashFromStack` always builds from an all-v3 schema set, over which
+`eqlVersion: 2` is refused at setup — v2 payloads cannot satisfy the columns'
+`eql_v3_*` domains. The field promised a client it could never return.
+
+**Migration:** drop the `eqlVersion` field. Every other `ClientConfig` option
+(`workspaceCrn`, `clientId`, `clientKey`, `accessKey`, `authStrategy`, logging,
+…) is unchanged and still accepted.
+
+To read legacy EQL v2 rows, decrypt through `@cipherstash/stack` rather than
+asking this adapter for a v2 client: the decrypt path is generation-agnostic and
+reads both v2 and v3 payloads. Use the returned `encryptionClient` — `decrypt(…)`
+for a single value, or the no-table `decryptModel(row)` / `bulkDecryptModels(rows)`
+form for whole models, which is the supported path for models written before the
+v3 upgrade.
diff --git a/.changeset/reject-v2-wire-over-v3-schemas.md b/.changeset/reject-v2-wire-over-v3-schemas.md
new file mode 100644
index 000000000..a4b90ccfe
--- /dev/null
+++ b/.changeset/reject-v2-wire-over-v3-schemas.md
@@ -0,0 +1,30 @@
+---
+'@cipherstash/stack': minor
+---
+
+`Encryption({ schemas, config: { eqlVersion: 2 } })` now throws when every table
+in `schemas` is an EQL v3 table, instead of building a client that writes EQL v2
+payloads into `eql_v3_*` columns.
+
+`EncryptionV3` used to prevent this by forcing `eqlVersion: 3` over whatever the
+caller passed — the override's comment said that was why it existed. Collapsing
+`EncryptionV3` into a deprecated alias of `Encryption` removed the override, and
+nothing else rejected the combination: `resolveEqlVersion` already threw for a
+mixed v2/v3 schema set and for legacy v2 `searchableJson()`, but returned an
+explicit version unchanged. So a caller upgrading from
+`EncryptionV3({ schemas: [v3Table], config: { eqlVersion: 2 } })` — previously
+auto-corrected, and working — silently got a v2-wire client instead, with no
+diagnostic at any layer. The type surface agreed with the runtime (both say
+"nominal client"), so nothing disagreed loudly enough to notice, and the failure
+surfaced later as an `eql_v3_*` domain CHECK rejecting the write, or as v2 wire
+landing in a v3 column wherever the check is looser.
+
+The escape hatch itself is unchanged where it is actually used: an explicit
+`eqlVersion: 2` over an **EQL v2** schema set still emits v2 wire, which is how
+v2 payloads are minted for the read-compatibility suite. Mixed sets still throw
+the existing mixing error. Reading v2 payloads is unaffected — `decrypt` and
+`decryptModel` continue to read both generations regardless of the client's wire
+version.
+
+If you hit the new error, drop `config.eqlVersion` to emit v3, or build the
+client from the EQL v2 schema you actually intend to write.
diff --git a/.changeset/remove-cli-migrate-v2-leaf.md b/.changeset/remove-cli-migrate-v2-leaf.md
new file mode 100644
index 000000000..b88011fa3
--- /dev/null
+++ b/.changeset/remove-cli-migrate-v2-leaf.md
@@ -0,0 +1,8 @@
+---
+'stash': major
+'@cipherstash/migrate': major
+---
+
+Remove the remaining EQL v2 installation and rollout surface. CLI installs,
+upgrades, backfills, and drops now mutate EQL v3 state only, while legacy v2
+status diagnostics and migration-manifest compatibility remain read-only.
diff --git a/.changeset/remove-eql-v2-drizzle-root.md b/.changeset/remove-eql-v2-drizzle-root.md
new file mode 100644
index 000000000..eb06bd693
--- /dev/null
+++ b/.changeset/remove-eql-v2-drizzle-root.md
@@ -0,0 +1,26 @@
+---
+'@cipherstash/stack-drizzle': major
+'@cipherstash/stack': patch
+'stash': patch
+---
+
+Remove the EQL v2 authoring surface from `@cipherstash/stack-drizzle` and collapse the EQL v3 `./v3` subpath into the package root.
+
+**Breaking (`@cipherstash/stack-drizzle`):**
+
+- The EQL v2 root exports are gone: `encryptedType`, the v2 `extractEncryptionSchema`, the v2 `createEncryptionOperators` (including the `like` / `ilike` operators), and `EncryptionConfigError`. Authoring or querying `eql_v2_encrypted` columns through Drizzle is no longer supported.
+- The `./v3` subpath is **removed** from the package `exports` map and `typesVersions`. The EQL v3 implementation is now the package root, and the `*V3` names are de-suffixed (`createEncryptionOperatorsV3` → `createEncryptionOperators`, `extractEncryptionSchemaV3` → `extractEncryptionSchema`). This is a **hard break with no alias**: post-collapse the root names would collide with the removed v2 names, and keeping an alias would silently type-check v2 call sites against v3 semantics.
+
+**Migration** — import `types`, `extractEncryptionSchema`, and
+`createEncryptionOperators` from the `@cipherstash/stack-drizzle` package root.
+
+The `types.*` column factories, `makeEqlV3Column` / `getEqlV3Column` / `isEqlV3Column`, the codec helpers (`v3ToDriver` / `v3FromDriver` / `EqlV3CodecError`), and `EncryptionOperatorError` are unchanged apart from moving to the root.
+
+Existing EQL v2 ciphertext remains decryptable via `@cipherstash/stack` — only the Drizzle-side v2 authoring and query-building is removed.
+
+**`stash` (patch):** `stash init --drizzle` now emits the package-root
+`extractEncryptionSchema` import. The bundled `stash-drizzle` and
+`stash-encryption` skills are updated to match.
+
+**`@cipherstash/stack` (patch):** README only — its Drizzle section now
+documents the final package-root exports.
diff --git a/.changeset/remove-eql-v2-migrate-classifier.md b/.changeset/remove-eql-v2-migrate-classifier.md
new file mode 100644
index 000000000..04d74dda1
--- /dev/null
+++ b/.changeset/remove-eql-v2-migrate-classifier.md
@@ -0,0 +1,20 @@
+---
+'@cipherstash/migrate': minor
+---
+
+Drop EQL v2 from the domain-type classifier. `classifyEqlDomain` (and the
+`detectColumnEqlVersion` / `listEncryptedColumns` / `resolveEncryptedColumn`
+resolution built on it) no longer recognise the legacy `eql_v2_encrypted`
+domain — v3 is the sole generation this workspace authors and backfills, so a
+column's version is now determined solely from its self-describing `eql_v3_*`
+domain type. A legacy v2 column's version is carried by the manifest's recorded
+`eqlVersion` instead (the CLI's `encrypt status` / `status` renderers already
+fall back to it), so status output is unchanged for v2 columns already recorded
+in `.cipherstash/migrations.json`. A v2 column backfilled from here on records
+no `eqlVersion` and so reports no version in `stash encrypt status` — the v2
+lifecycle itself (cut-over, then dropping `_plaintext`) is unaffected.
+
+This removes v2 *classification*, not the v2 read path: existing v2 ciphertext
+remains decryptable through `@cipherstash/stack`. `EqlVersion` keeps its `2`
+member for manifest-sourced legacy values; the exported function signatures are
+unchanged.
diff --git a/.changeset/remove-eql-v2-packages.md b/.changeset/remove-eql-v2-packages.md
new file mode 100644
index 000000000..772a72e62
--- /dev/null
+++ b/.changeset/remove-eql-v2-packages.md
@@ -0,0 +1,21 @@
+---
+'@cipherstash/stack': patch
+---
+
+Remove the EQL v2-only published packages `@cipherstash/protect`,
+`@cipherstash/schema`, and `@cipherstash/protect-dynamodb` from the repository
+and the release train. They formed a closed dependency chain
+(`@cipherstash/protect-dynamodb` → `@cipherstash/protect` → `@cipherstash/schema`)
+and are superseded by `@cipherstash/stack`:
+
+- `@cipherstash/protect` (core encryption) → `@cipherstash/stack`, which now
+ carries the encryption client directly.
+- `@cipherstash/schema` (schema builders) → `@cipherstash/stack/schema`.
+- `@cipherstash/protect-dynamodb` (standalone DynamoDB adapter) →
+ `@cipherstash/stack/dynamodb` (`encryptedDynamoDB`), the maintained
+ implementation.
+
+Already-published versions remain installable from npm; the git history
+preserves the source for any emergency maintenance. Existing EQL v2 ciphertext
+stays decryptable through `@cipherstash/stack` — this removes the v2 authoring
+and emission surface, not the read path.
diff --git a/.changeset/remove-eql-v2-scaffold-examples-meta.md b/.changeset/remove-eql-v2-scaffold-examples-meta.md
new file mode 100644
index 000000000..38714d679
--- /dev/null
+++ b/.changeset/remove-eql-v2-scaffold-examples-meta.md
@@ -0,0 +1,22 @@
+---
+'stash': patch
+'@cipherstash/stack': patch
+---
+
+De-suffix the v3 client name in generated code and shipped guidance.
+
+`stash init` scaffolded `import { EncryptionV3 } from '@cipherstash/stack/v3'`
+into the client file it writes. `EncryptionV3` is a deprecated alias of
+`Encryption`, so new projects were started on the deprecated name. The
+scaffold now emits `Encryption`.
+
+`@cipherstash/stack/v3` now re-exports `Encryption` alongside the deprecated
+`EncryptionV3` alias, so a v3 schema and its client come from one import
+specifier — the deprecation notice already documented this import, but it did
+not resolve.
+
+Corrects the bundled agent skills and package docs, which described
+`encryptedSupabase` as the legacy EQL v2 wrapper. It is the EQL v3 factory;
+the v2 wrapper was removed. Also drops the stale "DynamoDB still requires v2"
+note from the `@cipherstash/stack` README — DynamoDB writes EQL v3 and reads
+existing v2 items.
diff --git a/.changeset/remove-eql-v2-supabase-authoring.md b/.changeset/remove-eql-v2-supabase-authoring.md
new file mode 100644
index 000000000..0bf7fb5f5
--- /dev/null
+++ b/.changeset/remove-eql-v2-supabase-authoring.md
@@ -0,0 +1,38 @@
+---
+'@cipherstash/stack-supabase': major
+---
+
+Remove the EQL v2 authoring surface and de-suffix the v3 API to the canonical
+unsuffixed names (part of the EQL v2 removal, #707).
+
+- **`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory**
+ (formerly `encryptedSupabaseV3`). `encryptedSupabaseV3` remains a
+ type-identical `@deprecated` alias, so existing imports keep working.
+- **The legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })`
+ wrapper is removed** — with it the two-argument `from(tableName, schema)` form
+ and the hand-written client-side v2 schema. Its `EncryptedSupabaseConfig` and
+ the v2 `EncryptedSupabaseInstance`/`EncryptedQueryBuilder` type shapes are gone;
+ the unsuffixed type names now denote the v3 surface.
+- **The public types use canonical unsuffixed names:**
+ `EncryptedSupabaseOptions`, `EncryptedSupabaseInstance`,
+ `TypedEncryptedSupabaseInstance`, `EncryptedQueryBuilder`,
+ `EncryptedQueryBuilderUntyped`, `FilterableKeys`, and `OrderableKeys`. Each
+ keeps a type-identical `@deprecated` `*V3` alias.
+
+**Reading existing v2 data.** Only the v2 *authoring/emission* surface is removed
+— no v2 ciphertext is stranded. Decryption in `@cipherstash/stack` is
+generation-agnostic, so EQL v2 payloads still decrypt through the core client
+(`decrypt` / `decryptModel`). This adapter, however, is now EQL v3 only and will
+not auto-read an `eql_v2_encrypted` column: to read legacy v2 data during
+migration, decrypt fetched rows with `@cipherstash/stack` directly, or run a
+v2-configured setup alongside the v3 one and route per column. Mixed-generation
+handling is a customer-side concern (install both and handle it explicitly), not
+adapter auto-detection.
+
+Internally the v3 query builder (`query-builder-v3.ts`) was folded into the base
+`EncryptedQueryBuilderImpl`, which is now natively EQL v3; no runtime behaviour or
+wire encoding changed.
+
+**Migration:** use `await encryptedSupabase(supabaseUrl, supabaseKey)` with
+`eql_v3_*` column domains. See the `stash-supabase` skill and
+https://cipherstash.com/docs.
diff --git a/.changeset/remove-eql-v2-supabase-skill.md b/.changeset/remove-eql-v2-supabase-skill.md
new file mode 100644
index 000000000..c632acc7f
--- /dev/null
+++ b/.changeset/remove-eql-v2-supabase-skill.md
@@ -0,0 +1,11 @@
+---
+'stash': patch
+---
+
+Update the bundled `stash-supabase` agent skill for the EQL v2 removal (#707):
+`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory (with
+`encryptedSupabaseV3` kept as a type-identical `@deprecated` alias), and the
+legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })` authoring
+wrapper has been removed. The skill's examples, exported-type list, and migration/cutover
+guidance are corrected accordingly. Skills ship inside the `stash` tarball, so
+the stale v2 guidance would otherwise land in a user's project.
diff --git a/.changeset/rewriter-never-drops-ciphertext.md b/.changeset/rewriter-never-drops-ciphertext.md
new file mode 100644
index 000000000..e32890e4c
--- /dev/null
+++ b/.changeset/rewriter-never-drops-ciphertext.md
@@ -0,0 +1,96 @@
+---
+'@cipherstash/wizard': patch
+'stash': patch
+---
+
+Fix a data-loss bug in the Drizzle migration rewriter: a **commented-out**
+`ALTER … SET DATA TYPE` was rewritten into executable SQL. The matcher was
+comment-blind and the replacement is multi-line, so the author's `-- ` survived
+on the first line only — the `DROP COLUMN` on the next line emitted live and
+dropped a populated column.
+
+A statement is now left exactly as written whenever it is inert — inside a `--`
+line comment, inside a `/* … */` block, or inside a single-quoted string
+literal, where an `ALTER` is data rather than SQL. (Rewriting one splices
+`--> statement-breakpoint` markers *inside* the literal, so splitting the file
+the way drizzle's migrator does yields a bare, live `DROP COLUMN` as a chunk of
+its own.) Quoting is tokenised properly in the process: a `--` inside a string
+no longer opens a comment, an apostrophe inside a quoted identifier such as
+`"o'brien_data"` no longer opens a phantom string literal, a doubled `''` or
+`""` reads as an escape rather than a delimiter, and an unterminated quote of
+either kind makes the rest of the file inert rather than live.
+
+The sweep also refuses to rewrite a column the migration corpus already gives an
+encrypted type, so changing a column's encrypted domain no longer drops a column
+full of ciphertext. Skipped statements report why they were left alone. This
+recognises the encrypted forms drizzle-kit emits, a domain installed into a
+non-`public` schema, and an array of a domain — so a corpus that shows a column
+as encrypted in any of those shapes is flagged, not rewritten.
+
+The sweep is now fail-closed about the columns it does not recognise at all.
+Previously a column missing from the corpus index was assumed to be plaintext
+and rewritten; absence is not evidence, and the declaration can simply live in a
+migration directory the sweep never reads — the wizard ships scanning three of
+them and indexes each separately. Such a statement is now reported for review
+rather than rewritten, so the ADD+DROP+RENAME no longer drops a column that the
+migration corpus itself shows already holds ciphertext. That is a guarantee
+about what the corpus says, not about the database: the sweep reasons entirely
+from migration files, and a database that has drifted from its migration
+history is outside what it can see. `stash encrypt cutover` is the sharpest
+example — it renames columns directly in the database and never writes drizzle
+SQL, so the corpus can still describe a column as plaintext after cutover has
+made it ciphertext; the same is true of any change made by hand via psql or the
+Supabase dashboard. If your migration history is squashed, the column's
+`CREATE TABLE` lives outside the directory being swept, or the database has
+simply drifted from what the migrations describe, you will see the statement
+flagged instead of repaired: check the column's current type in the database
+and either apply the rewrite by hand on an empty table, or use the staged
+`stash encrypt` lifecycle.
+
+An unreadable migration directory (`EACCES`) is reported rather than silently
+treated as empty, and the wizard's `Run the migration now?` prompt defaults to No
+whenever the sweep rewrote anything, flagged anything, or could not check a
+directory at all — naming the directories that went unchecked, and making no
+claim about data destruction for a directory nothing is known about.
+
+Four further ways the sweep could still reach a ciphertext column are closed:
+
+- **Chained conversions.** The corpus index read `CREATE TABLE`, `ADD COLUMN`
+ and `RENAME`, but never the sweep's own target. A directory where an earlier
+ migration already ran `SET DATA TYPE eql_v2_encrypted` — generated by a stack
+ version predating this sweep — left the column looking plaintext, so a later
+ domain change dropped its ciphertext. Conversions are now recorded as the
+ sweep walks the corpus in order, so the first conversion still applies and
+ only the ones after it are flagged.
+- **Schema qualification.** `"users"` and `"public"."users"` are the same table
+ — Postgres resolves the unqualified name through `search_path` — but they were
+ indexed under different keys. drizzle-kit emits unqualified while hand-written
+ SQL and this sweep's own output are qualified, so a corpus mixing the two hid
+ an encrypted column from the guard. A non-`public` schema stays distinct.
+- **Quote parity.** A `$$ … $$` body containing an odd number of apostrophes, or
+ an `E'a\'b'` literal, ended a string literal earlier than Postgres does. Every
+ token after it was then misread — including a commented-out `ALTER`, which
+ read as live and was rewritten into a live `DROP COLUMN`. Dollar-quoted bodies
+ are skipped whole and `E''` backslash escapes are honoured.
+- **Truncated `CREATE TABLE` bodies.** The body was matched up to the first
+ `);`, which can sit inside a `--` comment or a string `DEFAULT`. Columns
+ declared after that point vanished from the index entirely. The closing paren
+ is now located by skipping candidates that are inside a comment or literal.
+
+The two copies of this rewriter — one in `stash`, one in `@cipherstash/wizard` —
+are now compared by a repo test, so a fix can no longer land in one and silently
+miss the other.
+
+**The wizard now sweeps only drizzle-kit output directories.** It cannot
+discover a project's configured `out`, so it tries `drizzle/`, `migrations/` and
+`src/db/migrations/` — but the last two are generic names that Knex,
+node-pg-migrate, Flyway and hand-rolled psql also use. A project whose drizzle
+`out` is `drizzle/` and which also keeps a hand-maintained `migrations/` had
+that second directory rewritten into ADD+DROP+RENAME, in a directory the wizard
+was never pointed at. The fail-closed rule is no defence there: a real migration
+history declares its own columns, so the rewrite proceeds. A candidate is now
+swept only if it carries the `meta/_journal.json` drizzle-kit maintains. A
+directory that holds `.sql` files but no journal is reported rather than passed
+over in silence, so a genuine drizzle output whose `meta/` went missing is
+visible instead of looking clean. `stash eql migration` and `stash eql install`
+are unaffected — both already take a single explicit `--out`.
diff --git a/.changeset/schema-stevec-standard-pin.md b/.changeset/schema-stevec-standard-pin.md
deleted file mode 100644
index d96bf3ca6..000000000
--- a/.changeset/schema-stevec-standard-pin.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
-'@cipherstash/schema': patch
----
-
-`searchableJson()` now pins the SteVec encoding mode to `standard` explicitly.
-protect-ffi 0.29 flipped the library default to `compat` (the EQL v3
-encoding); pinning keeps the v2 wire format byte-stable so existing encrypted
-JSON columns stay queryable and comparable.
diff --git a/.changeset/skills-encryption-client-naming.md b/.changeset/skills-encryption-client-naming.md
new file mode 100644
index 000000000..4960a2212
--- /dev/null
+++ b/.changeset/skills-encryption-client-naming.md
@@ -0,0 +1,8 @@
+---
+'stash': patch
+---
+
+`skills/stash-encryption` now documents how to name the client's type
+(`EncryptionClientFor`, not `Awaited>`, which
+always resolves to the untyped nominal client) and states that `schemas` accepts
+any non-empty array of v3 tables rather than only an array literal.
diff --git a/.changeset/skills-ship-current-copy.md b/.changeset/skills-ship-current-copy.md
new file mode 100644
index 000000000..3aa96a880
--- /dev/null
+++ b/.changeset/skills-ship-current-copy.md
@@ -0,0 +1,18 @@
+---
+'stash': patch
+'@cipherstash/wizard': patch
+---
+
+Fixed: a change to `skills/` could ship a stale copy of that skill.
+
+Both CLIs copy the repo-root `skills/` into their bundle at build time
+(`dist/skills`), which `stash init` then installs into a customer's
+`.claude/skills/` or `.codex/skills/`. That directory sits outside the package,
+so it was not part of the build's declared inputs — a skills-only edit did not
+invalidate the cached build. Once the build began declaring its output
+directory, a cache hit stopped being merely stale and started actively restoring
+the previous `dist/skills` over the tree, so an edited skill could be published
+with the pre-edit text while the source file on disk was correct and CI green.
+
+The two builds now declare the skills directory as an input, and a test pins
+that coupling so it cannot come undone.
diff --git a/.changeset/skills-v3-lifecycle-honesty.md b/.changeset/skills-v3-lifecycle-honesty.md
new file mode 100644
index 000000000..2c29c1d0e
--- /dev/null
+++ b/.changeset/skills-v3-lifecycle-honesty.md
@@ -0,0 +1,29 @@
+---
+'stash': patch
+---
+
+Correct the EQL v2/v3 rollout lifecycle in the bundled `stash-encryption`,
+`stash-supabase` and `stash-drizzle` agent skills. Each described the **v2**
+lifecycle as the unqualified default even though v3 is the default generation,
+so an agent following the prose would run steps that do not apply — and, in one
+case, expect the wrong column to be dropped.
+
+- `stash encrypt drop` was documented as removing `_plaintext`. That is the
+ **v2** target. On a v3 column there is no `_plaintext`: the command drops
+ the **original ``**, guarded by a `DO` block that takes `ACCESS EXCLUSIVE`
+ and re-counts unencrypted rows at apply time, raising instead of dropping if
+ any remain. Each step in the cutover table is now marked v2-only or v3, and the
+ drop preconditions (`cut-over` for v2, `backfilled` for v3) are stated.
+- "The pending row will be promoted to active by `stash encrypt cutover`" was
+ false for v3, where cutover short-circuits before touching any configuration.
+ `stash db activate` is the only promotion path there.
+- The CipherStash Proxy call-outs told every reader to run `stash db push`.
+ `db push`/`db activate` manage `eql_v2_configuration`, which EQL v3 does not
+ ship — on a v3-only database `db push` reports "Nothing to do." and exits 0,
+ and `db activate` errors. The call-outs are now scoped to the EQL v2 + Proxy
+ path.
+
+Skills ship inside the `stash` tarball and are copied into user projects at
+`stash init`, so this guidance was being installed into customer repos.
+
+Superseded later in this release: the bundled skills no longer document v2/Proxy mutation commands because those CLI paths are removed.
diff --git a/.changeset/stack-audit-on-decrypt.md b/.changeset/stack-audit-on-decrypt.md
new file mode 100644
index 000000000..48b32a60e
--- /dev/null
+++ b/.changeset/stack-audit-on-decrypt.md
@@ -0,0 +1,54 @@
+---
+'@cipherstash/stack': major
+---
+
+The typed EQL v3 client's `decryptModel` / `bulkDecryptModels` are now
+audit-chainable. They return a chainable operation (a `MappedDecryptOperation`)
+instead of a bare `Promise>`, so you can attach audit metadata and a
+lock context before awaiting:
+
+```typescript
+await client
+ .decryptModel(item, table)
+ .audit({ metadata: { requestId } })
+ .withLockContext({ identityClaim: ["sub"] })
+```
+
+Both chaining orders forward the metadata to ZeroKMS, and the `Date`
+reconstruction still applies to the successful result. Await-only call sites are
+unchanged — the operation is still thenable to the same `Result`. This restores
+audited decrypt through the DynamoDB adapter (`encryptedDynamoDB(...).decryptModel`)
+for a v3 client, which previously had nowhere to carry the metadata.
+
+Chaining `.withLockContext()` onto a decrypt operation that already took a lock
+context positionally (`decryptModel(item, table, lc).withLockContext(other)`) now
+throws instead of silently keeping the first. Pass the lock context one way or
+the other, not both.
+
+**Breaking:** `EncryptionV3` is now a deprecated, type-identical alias of
+`Encryption`: `Encryption` is overloaded so an array of concrete EQL v3 tables
+yields the same strongly-typed client. Use `Encryption` for new code. If you were
+already passing EQL v3 tables to plain `Encryption`, you now receive the typed
+client rather than the nominal one — its `decryptModel` / `bulkDecryptModels`
+return type changes, and the two-argument form reconstructs `Date` columns from
+`cast_as` instead of leaving them as ISO strings. Code that read those columns as
+strings needs updating.
+
+The v3 overload takes a `V3ClientConfig` — `ClientConfig` without the deprecated
+`eqlVersion` escape hatch. So `Encryption({ schemas: [] })` no longer type-checks
+(it used to compile and then throw), and `config: { eqlVersion: 2 }` selects the
+nominal overload — though over an all-v3 schema set that call now throws at setup.
+`Awaited>` names the nominal client whatever you
+pass, because `ReturnType` reads the last overload; use the exported
+`EncryptionClientFor` to name the client for a schema tuple.
+
+`decryptModel` / `bulkDecryptModels` on the typed client also accept a call with
+no table, matching the runtime, which has always allowed it — that is the path
+for reading models written before the upgrade, above all legacy EQL v2 ones,
+whose table cannot be a member of a v3 schema tuple. Prefer the two-argument
+form whenever the table is registered.
+
+The `eqlVersion` config field and the `@cipherstash/stack/schema` EQL v2
+builders remain available (now marked `@deprecated`) for reading and migrating
+legacy v2 data; the client authors EQL v3 only. Their full removal is deferred to
+a later PR.
diff --git a/.changeset/stack-dynamodb-v2-write-removal.md b/.changeset/stack-dynamodb-v2-write-removal.md
new file mode 100644
index 000000000..445bd3729
--- /dev/null
+++ b/.changeset/stack-dynamodb-v2-write-removal.md
@@ -0,0 +1,18 @@
+---
+'@cipherstash/stack': major
+---
+
+**Breaking (DynamoDB adapter):** `encryptedDynamoDB(...).encryptModel` and
+`bulkEncryptModels` no longer accept an EQL v2 table. The v2 write type overloads
+have been removed, narrowing encrypt to `AnyV3Table`. The narrowing is
+type-level — treat the type as the contract, not a runtime guard.
+
+**Decrypt still reads existing v2 items.** `decryptModel` / `bulkDecryptModels`
+continue to accept an EQL v2 table (`encryptedColumn` / `encryptedField` from
+`@cipherstash/stack/schema`), so previously stored v2 DynamoDB items remain
+readable — the adapter keeps its v2 envelope reconstruction. Only the v2 write
+surface is gone.
+
+Migrate v2 write call sites to an EQL v3 table (`encryptedTable` + `types.*` from
+`@cipherstash/stack/eql/v3`). To keep reading old data, pass the v2 table to the
+decrypt methods.
diff --git a/.changeset/stack-logger-edge-safe.md b/.changeset/stack-logger-edge-safe.md
new file mode 100644
index 000000000..c84c0d97e
--- /dev/null
+++ b/.changeset/stack-logger-edge-safe.md
@@ -0,0 +1,11 @@
+---
+'@cipherstash/stack': patch
+---
+
+`@cipherstash/stack/adapter-kit` is now importable in a runtime with no `process` global.
+
+The shared logger read `process.env.STASH_STACK_LOG` unguarded while initialising at module scope, so importing adapter-kit — which re-exports that logger — threw `ReferenceError: process is not defined` before any user code ran. The environment read is now guarded.
+
+This is not a single-adapter fix. `@cipherstash/stack-supabase`, `@cipherstash/stack-drizzle` and `@cipherstash/prisma-next` all value-import `@cipherstash/stack/adapter-kit`, so edge users of all three hit the same import-time throw, and all three are fixed by this release.
+
+**No behaviour change on Node.** `STASH_STACK_LOG`, its accepted values (`debug` / `info` / `error`), its `error` default, and the point at which the logger is configured are all unchanged.
diff --git a/.changeset/stack-skills-eql-v3-audit.md b/.changeset/stack-skills-eql-v3-audit.md
new file mode 100644
index 000000000..f63f9b085
--- /dev/null
+++ b/.changeset/stack-skills-eql-v3-audit.md
@@ -0,0 +1,13 @@
+---
+'stash': patch
+---
+
+Skills refresh for the EQL v3 collapse (ships in the `stash` tarball):
+
+- `stash-dynamodb`: audited decrypt now works on the typed client —
+ `client.decryptModel(item, table).audit({ … })` — so the old "use
+ `Encryption({ config: { eqlVersion: 3 } })` for audited decrypts" caveat is
+ removed. Encrypt/write is EQL v3 only; decrypt still reads existing v2 items.
+- `stash-encryption`: canonical examples use `Encryption` (with `EncryptionV3`
+ noted as a deprecated alias); the DynamoDB notes state encrypt is v3-only while
+ decrypt still reads v2.
diff --git a/.changeset/stash-cli-eql-v3-default.md b/.changeset/stash-cli-eql-v3-default.md
index b19eecc57..b6eb6da19 100644
--- a/.changeset/stash-cli-eql-v3-default.md
+++ b/.changeset/stash-cli-eql-v3-default.md
@@ -8,3 +8,7 @@ Default EQL to v3 and stop the CLI recommending `stash db push` (#585).
- **`stash db push` is no longer recommended in CLI output.** `db push` writes the `public.eql_v2_configuration` table, which is a v2 + CipherStash Proxy artifact — EQL v3 has no configuration table (config lives in each column's `eql_v3.*` type) and nothing in the v3 stack reads it. The push recommendations are removed from `eql status`, the help banner, and the init/plan/cutover guidance. `db push` (and `db activate`) remain available for EQL v2 + Proxy users; they're now labelled as such.
- **`eql status` is v3-aware.** On a v3-only database it reports that encrypt config lives in the column types instead of hitting a "table not found" dead-end that told users to run `db push` (which neither creates that table nor applies to v3).
- **`stash db push` guards a v3-only database** with a clear "not needed under EQL v3" message instead of a raw `relation "public.eql_v2_configuration" does not exist` error.
+
+**Superseded later in this release:** the CLI no longer installs or mutates EQL
+v2 state; `db push`, `db activate`, the Proxy choice, and the v2 install flags
+described above are removed. Legacy state remains visible through status only.
diff --git a/.changeset/stash-cli-skill-refresh.md b/.changeset/stash-cli-skill-refresh.md
index 7a65d45c8..a2e62330b 100644
--- a/.changeset/stash-cli-skill-refresh.md
+++ b/.changeset/stash-cli-skill-refresh.md
@@ -29,3 +29,5 @@ handoff time, so a stale skill becomes stale guidance in the user's project.
and `--region` flags; corrects six programmatic API signatures; fixes the README's claim
that `stash init` ends in an agent-handoff menu (that belongs to `stash plan` / `stash impl`);
and marks `stash env` as the non-functional stub it currently is.
+
+Superseded later in this release: the v2/Proxy commands and flags listed above are removed from both the CLI and bundled skill.
diff --git a/.changeset/stash-postgres-edge-skills.md b/.changeset/stash-postgres-edge-skills.md
new file mode 100644
index 000000000..424c2774b
--- /dev/null
+++ b/.changeset/stash-postgres-edge-skills.md
@@ -0,0 +1,75 @@
+---
+'stash': minor
+'@cipherstash/wizard': minor
+'@cipherstash/stack': patch
+---
+
+Two new bundled agent skills for the integrations that don't use an ORM —
+`stash-postgres` and `stash-edge` (#754).
+
+Everything a raw-SQL or edge integration needed was reachable only from
+`dist/*.d.ts` JSDoc, the Postgres catalog, or experiment: grepping the skills
+`stash init` installs for `postgres-js|::jsonb::eql|sql.json|query_text_search`
+returned a single hit, in an unrelated code comment.
+
+**`stash-postgres`** — hand-written SQL over `pg` / `postgres-js`, no ORM. The
+column-domain-to-query-domain operator matrix (which of `=`, `<>`, `<`, `>=`,
+`@@`, `@>` each encrypted domain accepts, and against which `eql_v3.query_*`
+operand), the storage-vs-query payload distinction, per-driver parameter
+binding, recipes for equality / free-text / range / `ORDER BY` / JSON
+containment / JSON field selectors, and the `information_schema` drift check.
+Two failure modes get their mechanism spelled out: pre-stringifying a payload
+on postgres-js double-encodes it into a jsonb *string* scalar, tripping the
+domain CHECK with a message naming neither JSON nor encoding; and leaving an
+operand as bare `jsonb` silently selects a different operator overload — one
+that coerces to the *storage* domain and so rejects the ciphertext-free query
+term. It also scopes itself against the two things a hand-written-SQL reader
+is otherwise left to infer: **CipherStash Proxy** (where you write plaintext
+SQL and none of the skill applies — the `usesProxy` fork `stash init` already
+asked about), and the provenance of the operator surface itself (the EQL
+bundle from `cipherstash/encrypt-query-language`, version-checkable with
+`SELECT eql_v3.version()`, and where operator gaps should be filed). Its
+domain and operator tables are explicitly marked as a snapshot of a versioned
+surface, with a ranked list of authorities to confirm current types against —
+the EQL skill first, then the generated `@cipherstash/eql` types and install
+SQL, both of which need only `node_modules` and no database.
+
+**`stash-edge`** — the `@cipherstash/stack/wasm-inline` entry for Deno,
+Supabase Edge Functions, Cloudflare Workers, and Bun. Import specifier per
+runtime, the four mandatory `CS_*` variables and minting them with
+`stash env`, how the WASM client surface differs from the native typed client
+(no `.audit()`, no `.withLockContext()`, per-item bulk shape, a required
+`table` argument on `decryptModel` / `bulkDecryptModels`, ESM-only), and the
+auth-strategy `Result` that must be unwrapped before it reaches
+`config.authStrategy`.
+
+It also separates the two mechanisms behind identity-bound encryption, which
+are routinely conflated — and which the source comment on the entry itself got
+wrong. An auth strategy decides *who the client is*; a lock context decides
+*which key the value is encrypted under*. Only the first exists on this entry,
+so an `authStrategy` alone still writes values encrypted under the workspace
+key, and the entry cannot read what the native one wrote under a lock context.
+That is a silent read split between the two entries, and the skill says so
+rather than leaving it to be discovered as a failed decrypt.
+
+Both carry **the credential-identity rule**, a silent data-loss footgun now
+also stated in `stash-cli` (under `env` and `encrypt backfill`) and
+`stash-supabase`: EQL index terms derive from the ZeroKMS client key, so rows
+written under one credential and queried under another decrypt correctly and
+never match a query, with no error.
+
+`stash-encryption` now states that the two entries' schema types **do not
+interchange** — their column classes carry private fields, so TypeScript
+compares them nominally and rejects a shared schema module in both directions.
+It works at runtime, which makes a type assertion the tempting fix; the
+guidance is to author the schema against exactly one entry instead.
+
+`stash init` / `stash impl` handoffs and the `@cipherstash/wizard` skills
+prompt install both skills for the `postgresql` and `supabase` integrations.
+Drizzle and Prisma Next get cross-links from their own skills instead, since
+those integrations emit correctly-typed operands themselves.
+
+Also fixes the `@cipherstash/stack/wasm-inline` module JSDoc, which showed
+`OidcFederationStrategy.create(...)`'s `Result` being passed straight to
+`config.authStrategy` without unwrapping — the same JSDoc the raw-SQL surface
+was being reverse-engineered from.
diff --git a/.changeset/supabase-interface-row-types.md b/.changeset/supabase-interface-row-types.md
new file mode 100644
index 000000000..688927d2c
--- /dev/null
+++ b/.changeset/supabase-interface-row-types.md
@@ -0,0 +1,30 @@
+---
+'@cipherstash/stack-supabase': minor
+---
+
+Row-type generics now accept an `interface`, not just a `type` alias.
+
+`from()`, `returns()` and `single().returns()` constrained their row
+parameter to `Record`. An `interface` has no implicit index
+signature, so the most ordinary way to declare a row type failed to compile:
+
+```typescript
+interface User { id: string; email: string }
+
+// before: TS2344 — Index signature for type 'string' is missing in type 'User'
+// after: fine
+const { data } = await supabase.from('users').select('id, email')
+```
+
+A `type User = { … }` alias worked, which is why the existing type tests never
+caught it. The constraint is now `object`, which still rejects `string`/`number`
+row types. upstream `postgrest-js` constrains `returns` to nothing at all, so
+this brings the adapter in line with the API it mirrors rather than being
+stricter than it.
+
+Also corrects the `EncryptedSingleQueryBuilder` documentation, which claimed
+that "everything that only re-types or re-configures the pending request is
+carried over" after `single()`/`maybeSingle()`. `overrideTypes` and `setHeader`
+are not carried over — they have no adapter equivalent, and since
+`single()`/`maybeSingle()` return the same builder instance rather than a
+passthrough, calling them would fail at runtime, not just fail to typecheck.
diff --git a/.changeset/supabase-single-row-typing.md b/.changeset/supabase-single-row-typing.md
new file mode 100644
index 000000000..7df5932fe
--- /dev/null
+++ b/.changeset/supabase-single-row-typing.md
@@ -0,0 +1,38 @@
+---
+'@cipherstash/stack-supabase': major
+---
+
+`single()` and `maybeSingle()` now type `data` as the ROW, not an array.
+
+Both have always returned one object at runtime, but the builder kept
+advertising the array shape it was created with, so `data` was typed `T[] | null`
+while holding a single row. Every caller had to launder it:
+
+```typescript
+const { data } = await supabase.from('users').select('id, email').single()
+// before: data is `User[] | null` — wrong; a cast was the only way through
+const user = data as unknown as User
+// after: data is `User | null`
+data?.email
+```
+
+`single()`/`maybeSingle()` now return `EncryptedSingleQueryBuilder`, which
+awaits to `EncryptedSupabaseResponse` (`data: T | null`). That covers the
+zero-row case for `maybeSingle()` and the error case for both, so no separate
+null modelling was needed.
+
+Filters and transforms are no longer chainable after `single()`/`maybeSingle()`,
+matching supabase-js — applying one afterwards would change the query the
+single-row promise was made about. `.single().eq(...)`, `.single().limit(...)`
+and friends were previously accepted and are now compile errors. What only
+re-types or re-configures the pending request is carried over: `returns()`
+(preserving the awaited shape, so `.single().returns()` awaits one row),
+`abortSignal()`, `throwOnError()`, `withLockContext()` and `audit()`.
+`EncryptedSingleQueryBuilder` is exported so a stored builder can be
+annotated.
+
+**Migration:** delete the cast. Code that worked around the old typing with
+`data as unknown as Row` (or read `data![0]`) should now use `data` directly;
+the cast still compiles but is no longer needed, and `data![0]` becomes a type
+error. Move any filter or transform chained after `single()`/`maybeSingle()` to
+before it.
diff --git a/.changeset/supabase-structural-v3-columns.md b/.changeset/supabase-structural-v3-columns.md
new file mode 100644
index 000000000..27d017118
--- /dev/null
+++ b/.changeset/supabase-structural-v3-columns.md
@@ -0,0 +1,11 @@
+---
+'@cipherstash/stack-supabase': patch
+---
+
+Fix: a table authored with `encryptedTable`/`types` imported from `@cipherstash/stack/wasm-inline` was treated as having **no encrypted columns**, so filter operands were sent to PostgREST as plaintext.
+
+`ColumnMap` gated on `builder instanceof EncryptedV3Column`, and the published bundles contain two separately-emitted copies of that class (`dist/adapter-kit.js` and `dist/wasm-inline.js` are separate esbuild runs). The check is now structural, so both copies are recognised. Tables authored from `@cipherstash/stack/eql/v3` were never affected — they resolve to the same copy the adapter imports.
+
+The failure was silent: `::jsonb` casts and result decryption go through a different path and kept working.
+
+The recognition now also fails closed: a column builder that does not present the v3 surface makes `encryptedSupabase` throw at construction rather than silently omitting the column — an omitted column would send its filter operands to PostgREST as plaintext.
diff --git a/.changeset/supabase-v2-table-diagnosis.md b/.changeset/supabase-v2-table-diagnosis.md
new file mode 100644
index 000000000..1c6cfb160
--- /dev/null
+++ b/.changeset/supabase-v2-table-diagnosis.md
@@ -0,0 +1,25 @@
+---
+'@cipherstash/stack-supabase': patch
+'@cipherstash/stack': minor
+'stash': patch
+---
+
+Diagnose an EQL v2 table by name instead of crashing with a raw `TypeError`.
+
+A v2 `EncryptedTable` is structurally identical to a v3 one — same `tableName`,
+same `columnBuilders` — and only `buildColumnKeyMap()` tells them apart. Passing
+one to `encryptedSupabase({ schemas })` therefore sailed past every check that
+looked at shape and died deep inside verification as `builder.getEqlType is not
+a function`, naming an internal method rather than the version mismatch that
+caused it. Constructing the query builder directly failed the same way one layer
+down, as `table.buildColumnKeyMap is not a function`.
+
+Both paths now fail closed with the table named and the fix stated. The check
+routes through `hasBuildColumnKeyMap`, the canonical v2/v3 discriminator, rather
+than a second hand-written spelling of it.
+
+`@cipherstash/stack` re-exports `hasBuildColumnKeyMap` from
+`@cipherstash/stack/adapter-kit` so first-party adapters can make that routing
+decision without reaching into internals. It is deliberately not on `./types`:
+deciding which wire version a table targets is adapter plumbing, not end-user
+API.
diff --git a/.changeset/typecheck-gates-scope-source.md b/.changeset/typecheck-gates-scope-source.md
new file mode 100644
index 000000000..33879a9b5
--- /dev/null
+++ b/.changeset/typecheck-gates-scope-source.md
@@ -0,0 +1,20 @@
+---
+'@cipherstash/stack': patch
+---
+
+The declaration gate now covers all three emitted type artifacts, not just one.
+
+`packages/stack/dist-types` typechecks what `tsc` emits rather than what the
+source says — the gap that let typed `encryptQuery` ship uncallable for every
+column through the whole rc series. It resolved `../dist/*.js` by relative path
+under bundler resolution, which never consults the `exports` map, so it only ever
+exercised the ESM `.d.ts` set. `tsup` emits the CJS `.d.cts` set in a separate
+pass and `wasm-inline.d.ts` in a third, each with its own inlined copy of the
+column class whose domain parameter the bug was about.
+
+A second suite now resolves `@cipherstash/stack/*` **by package name** under
+`moduleResolution: node16`, with the file extension selecting the condition
+(`.cts` → `require` → `.d.cts`, `.mts` → `import` → `.d.ts`), plus a probe for
+the `wasm-inline` entry — the documented target for Workers, Deno, Bun and
+Supabase Edge, and previously the one artifact nothing typechecked. Removing the
+phantom domain carrier makes all three fail, so none of them passes vacuously.
diff --git a/.changeset/typed-client-init-parity.md b/.changeset/typed-client-init-parity.md
new file mode 100644
index 000000000..ecc41b3b5
--- /dev/null
+++ b/.changeset/typed-client-init-parity.md
@@ -0,0 +1,37 @@
+---
+'@cipherstash/stack': patch
+---
+
+The typed EQL v3 client no longer throws `TypeError: init is not a function`.
+
+`Encryption` selects its overload from the `config` argument but selects the
+client it actually returns by inspecting the `schemas`, so the two can disagree.
+Hoisting a config into a `ClientConfig`-typed variable is enough to split them —
+`ClientConfig.eqlVersion` is `2 | 3`, which the v3 overload's `eqlVersion?: 3`
+rejects — so the call types as the nominal `EncryptionClient` while the runtime
+still returns the typed client:
+
+```ts
+const config: ClientConfig = { keyset }
+const client = await Encryption({ schemas: [users], config })
+// type: EncryptionClient · runtime: TypedEncryptionClient
+```
+
+`init` was the only member of `EncryptionClient` missing from the typed client,
+so any call through the declared type crashed. It is now present — and pins the
+wire format rather than delegating verbatim.
+
+That distinction matters. `EncryptionClient.init` takes its own `eqlVersion` and
+forwards `undefined` to the FFI, whose default is EQL **v2**, and it never
+consults the check `Encryption` runs at construction. A bare passthrough would
+therefore have let a typed v3 client be re-initialised into v2 wire while keeping
+its v3 surface — writing `eql_v2_encrypted` payloads into `eql_v3_*` columns, the
+same contradiction refused at construction, one method call later. The typed
+`init` pins `eqlVersion: 3`, refuses an explicit `2` with a failure `Result`, and
+resolves to the **typed** client, so the common
+`client = (await client.init(cfg)).data` does not silently swap the typed surface
+for the nominal one.
+
+The type still under-reports in this case: you lose the typed surface with no
+diagnostic. Pass the config inline, or type the variable as `V3ClientConfig`, to
+keep it.
diff --git a/.changeset/typed-encrypt-query-dist.md b/.changeset/typed-encrypt-query-dist.md
new file mode 100644
index 000000000..1fce605e7
--- /dev/null
+++ b/.changeset/typed-encrypt-query-dist.md
@@ -0,0 +1,34 @@
+---
+'@cipherstash/stack': patch
+---
+
+Fixed: `encryptQuery` on the typed EQL v3 client did not typecheck against the
+published package — for any column.
+
+```ts
+const users = encryptedTable('users', { email: types.TextSearch('email') })
+const client = await Encryption({ schemas: [users] })
+
+client.encrypt('a@b.com', { table: users, column: users.email }) // fine
+client.encryptQuery('a@b.com', { table: users, column: users.email })
+// TS2345: Argument of type '"a@b.com"' is not assignable to parameter of type 'never'
+```
+
+`PlaintextForColumn` and `QueryTypesForColumn` recover a column's domain with
+`C extends EncryptedV3Column`, which needs `D` to appear bare somewhere
+in the instance type. Its only bare occurrence was a **private** field, and
+`tsc` strips the types of private members on declaration emit — so in the
+shipped `.d.ts` the inference fell back to the `V3DomainDefinition` constraint.
+`QueryTypesForColumn` collapsed to `never`, which made `QueryableColumnsOf`
+`never`, which typed every query plaintext `never`. `encrypt` was unaffected
+because it resolves through `ColumnsOf`.
+
+`EncryptedV3Column` now carries a type-only `declare readonly __domain?: D`.
+Nothing is emitted at runtime and no call site changes; the declaration survives
+emit and restores the inference site.
+
+This affected every published release with the v3 typed client, including
+`1.0.0-rc.4` — the searchable-query recipes in the `stash-encryption` skill did
+not compile in a customer's repo. It was invisible in CI because the type tests
+import source rather than `dist/`; a new `test:types:dist` suite now typechecks
+the emitted declarations and is wired into CI.
diff --git a/.changeset/wizard-eql-v3-migration-rewrite.md b/.changeset/wizard-eql-v3-migration-rewrite.md
new file mode 100644
index 000000000..a627e5eb9
--- /dev/null
+++ b/.changeset/wizard-eql-v3-migration-rewrite.md
@@ -0,0 +1,36 @@
+---
+'@cipherstash/wizard': minor
+---
+
+Teach the wizard's post-agent Drizzle step to repair EQL **v3** migrations, not
+just legacy EQL v2.
+
+The wizard now scaffolds EQL v3 columns, so `drizzle-kit generate` emits
+`ALTER TABLE … ALTER COLUMN … SET DATA TYPE eql_v3_` — which Postgres
+rejects (there is no cast from `text`/`numeric` to an EQL domain). The migration
+rewriter previously matched only the single `eql_v2_encrypted` type, so those v3
+statements slipped through unrepaired and failed at migrate time.
+
+The rewriter is ported to match the whole EQL v3 concrete-domain family
+(`eql_v3_text_search`, `eql_v3_integer_ord`, …) alongside legacy
+`eql_v2_encrypted`, across every mangled form drizzle-kit emits (including the
+`"undefined".` prefix from 0.31.0+ and schema-qualified `pgSchema()` tables). It
+now also flags near-miss `SET DATA TYPE … USING …` statements it cannot safely
+repair instead of leaving broken SQL, and each rewritten file carries a clearer
+warning that the ADD+DROP+RENAME is data-destroying and safe only on an empty
+table — a populated table must use the staged `stash encrypt` flow. This
+re-converges the rewriter with the sibling copy in the `stash` CLI.
+
+The post-agent step now sweeps **every** candidate migration directory
+(`drizzle/`, `migrations/`, `src/db/migrations/`) rather than stopping at the
+first one that exists. Previously an empty or already-rewritten `drizzle/`
+sitting next to a project's real `migrations/` caused those migrations to be
+skipped entirely, so they still failed at migrate time. A directory that can't
+be read is reported and the remaining candidates are still swept. Reported
+near-miss statements are also trimmed of any preceding comment block, so the
+statement quoted back to the user is the offending statement alone.
+
+Database introspection also recognises v3 encrypted columns: `isEqlEncrypted`
+now reports both `eql_v2_encrypted` and the `eql_v3_*` family as already
+encrypted, so the agent won't scaffold over existing encrypted data of either
+generation.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 5e475458e..3bcb587ca 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -48,9 +48,11 @@ updates:
- dependency-name: "@cipherstash/auth-*"
# Release-managed manually alongside stack releases. Grouped bumps are
# actively harmful here: Dependabot's "group consistency" once upgraded
- # the sunsetting packages/protect off its 0.23.0 pin (0.24+ renames the
- # exports it imports) while DOWNGRADING the stack packages from 0.29.0
- # (see #673).
+ # the (since-removed) `protect` package off its 0.23.0 pin — 0.24+ renamed
+ # the exports it imported — while DOWNGRADING the stack packages from
+ # 0.29.0 (see #673). The surviving consumers (stack, stack-drizzle,
+ # stack-supabase) pin one version between them and must stay in lockstep,
+ # so the rule stands.
- dependency-name: "@cipherstash/protect-ffi"
# 0.x bumps ship breaking type changes (e.g. 0.2 → 0.3 tightened the
# FailureOption constraint). Review and apply manually.
diff --git a/.github/workflows/fta-v3.yml b/.github/workflows/fta-v3.yml
index 80e494fec..dd3108b43 100644
--- a/.github/workflows/fta-v3.yml
+++ b/.github/workflows/fta-v3.yml
@@ -68,10 +68,19 @@ jobs:
# blocking gate. No `continue-on-error`.
# One step per package so a failure names the offending package. Each caps
# at its current worst file (a ratchet, not an aspiration): stack src/eql/v3
- # at 69, and the split adapter packages at their monolith maxima — drizzle
- # 89 (operators.ts), supabase 91 (query-builder.ts). Lower a cap whenever a
- # file is refactored below the next threshold; the v2 query-builder/operators
- # monoliths are the debt to whittle down toward stack's 69.
+ # at 69, drizzle 89 (operators.ts, still a monolith), and supabase 71
+ # (query-encrypt.ts at 70.12, after the query-builder monolith was split
+ # across column-map / query-encrypt / query-mutation / query-dbspace /
+ # query-filters / query-results). Lower a cap whenever a file is refactored
+ # below the next threshold; drizzle's operators.ts is the remaining debt to
+ # whittle down toward stack's 69.
+ #
+ # NB supabase's top three now cluster tightly (query-encrypt 70.12,
+ # query-builder 70.05, helpers 69.13), so 71 is a ~0.9 margin. A cap set
+ # flush against its max is fragile — the 91 this replaced was 0.12 above
+ # its max and a single refactor blew straight through it. If a formatting
+ # reflow trips this step without a real complexity increase, re-measure
+ # (`npx fta src --format table`) before assuming the code got worse.
- name: Analyze stack (eql/v3) complexity
run: pnpm --filter @cipherstash/stack run analyze:complexity
diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml
index ef8a42adc..c4b3e7669 100644
--- a/.github/workflows/integration-drizzle.yml
+++ b/.github/workflows/integration-drizzle.yml
@@ -23,7 +23,6 @@ on:
# suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- - 'packages/schema/**'
- 'packages/stack/integration/**'
# The WASM family suite (integration/wasm/**) exercises this entry:
- 'packages/stack/src/wasm-inline.ts'
@@ -46,7 +45,6 @@ on:
# suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- - 'packages/schema/**'
- 'packages/stack/integration/**'
# The WASM family suite (integration/wasm/**) exercises this entry:
- 'packages/stack/src/wasm-inline.ts'
@@ -155,8 +153,14 @@ jobs:
#
# Step env, not a `.env` file: `dotenv/config` does not override an
# already-set `process.env`, so these win and no secret is written to disk.
+ # Through turbo, not a bare `pnpm --filter`: `test:integration` declares
+ # `dependsOn: ["^build", "build"]`, and the only build in this job is the
+ # setup action's `--filter stash`, which reaches these packages purely by
+ # coincidence of stash's own dependency graph. `--env-mode=loose` because
+ # turbo defaults to strict and would otherwise withhold the step env below
+ # from the test process (#787 review follow-up).
- name: Drizzle v3 integration suites
- run: pnpm --filter @cipherstash/stack-drizzle run test:integration
+ run: pnpm exec turbo run test:integration --filter @cipherstash/stack-drizzle --env-mode=loose
env:
DATABASE_URL: ${{ steps.db.outputs.database-url }}
PGRST_URL: ${{ steps.db.outputs.pgrest-url }}
@@ -166,7 +170,7 @@ jobs:
# `isInstalled` short-circuits against the DB the first invocation already
# provisioned — a fast no-op check, not a second schema apply.
- name: Shared core + identity + wasm integration suites
- run: pnpm --filter @cipherstash/stack run test:integration
+ run: pnpm exec turbo run test:integration --filter @cipherstash/stack --env-mode=loose
env:
DATABASE_URL: ${{ steps.db.outputs.database-url }}
PGRST_URL: ${{ steps.db.outputs.pgrest-url }}
diff --git a/.github/workflows/integration-prisma-next.yml b/.github/workflows/integration-prisma-next.yml
index f3b128e01..6b7e19134 100644
--- a/.github/workflows/integration-prisma-next.yml
+++ b/.github/workflows/integration-prisma-next.yml
@@ -25,7 +25,6 @@ on:
# suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- - 'packages/schema/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.postgres.yml'
@@ -45,7 +44,6 @@ on:
# suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- - 'packages/schema/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.postgres.yml'
@@ -116,7 +114,7 @@ jobs:
# `stash eql install --eql-version 3`, so an installer regression fails
# here rather than hiding behind a test-only SQL apply.
- name: prisma-next v3 family suites
- run: pnpm --filter @cipherstash/prisma-next run test:integration
+ run: pnpm exec turbo run test:integration --filter @cipherstash/prisma-next --env-mode=loose
env:
# Step env, not a `.env` file: `dotenv/config` does not override an
# already-set `process.env`, so these win and no secret hits disk.
diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml
index 7ab83ca46..f5eff3093 100644
--- a/.github/workflows/integration-supabase.yml
+++ b/.github/workflows/integration-supabase.yml
@@ -19,7 +19,6 @@ on:
# trigger the live suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- - 'packages/schema/**'
- 'packages/stack/integration/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
@@ -38,7 +37,6 @@ on:
# trigger the live suite that would catch it.
- 'packages/stack/src/encryption/**'
- 'packages/stack/src/schema/**'
- - 'packages/schema/**'
- 'packages/stack/integration/**'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
@@ -131,7 +129,7 @@ jobs:
# Step env, not a `.env` file: `dotenv/config` does not override an
# already-set `process.env`, so these win and no secret is written to disk.
- name: Supabase v3 integration suites
- run: pnpm --filter @cipherstash/stack-supabase run test:integration
+ run: pnpm exec turbo run test:integration --filter @cipherstash/stack-supabase --env-mode=loose
env:
DATABASE_URL: ${{ steps.db.outputs.database-url }}
PGRST_URL: ${{ steps.db.outputs.pgrest-url }}
@@ -141,7 +139,7 @@ jobs:
# `isInstalled` short-circuits against the DB the first invocation already
# provisioned — so this is a fast no-op check, not a second schema apply.
- name: Shared core integration suites (against Supabase Postgres)
- run: pnpm --filter @cipherstash/stack run test:integration
+ run: pnpm exec turbo run test:integration --filter @cipherstash/stack --env-mode=loose
env:
DATABASE_URL: ${{ steps.db.outputs.database-url }}
PGRST_URL: ${{ steps.db.outputs.pgrest-url }}
diff --git a/.github/workflows/prisma-next-e2e.yml b/.github/workflows/prisma-next-e2e.yml
index 39be3949a..4bccc8dd4 100644
--- a/.github/workflows/prisma-next-e2e.yml
+++ b/.github/workflows/prisma-next-e2e.yml
@@ -121,7 +121,7 @@ jobs:
done
- name: Run E2E suite
- run: pnpm --filter @cipherstash/prisma-next-example test:e2e
+ run: pnpm exec turbo run test:e2e --filter @cipherstash/prisma-next-example --env-mode=loose
- name: Stop E2E Postgres container
if: always()
diff --git a/.github/workflows/rebuild-docs.yml b/.github/workflows/rebuild-docs.yml
index 80b320950..858732977 100644
--- a/.github/workflows/rebuild-docs.yml
+++ b/.github/workflows/rebuild-docs.yml
@@ -3,7 +3,7 @@ name: Rebuild Docs
on:
push:
tags:
- - '@cipherstash/protect@*'
+ - '@cipherstash/stack@*'
jobs:
trigger-docs-rebuild:
diff --git a/.github/workflows/tests-bench.yml b/.github/workflows/tests-bench.yml
index d36eee339..488b03ba4 100644
--- a/.github/workflows/tests-bench.yml
+++ b/.github/workflows/tests-bench.yml
@@ -1,20 +1,45 @@
name: "Test Benchmark JS"
+# Trigger paths follow the package GRAPH, not the bench directory. bench depends
+# on `@cipherstash/stack` and `@cipherstash/stack-drizzle`, and (since the fixture
+# schema moved to EQL v3 domains) on the CLI's EQL installer — a break in any of
+# them can red this job, so an edit to any of them has to be able to trigger it.
+# Scoped the same way the `integration-*` workflows scope theirs rather than
+# taking whole packages, to keep the trigger surface honest.
on:
push:
branches:
- 'main'
paths:
- 'packages/bench/**'
+ - 'packages/stack/src/eql/v3/**'
+ # The bench drives the typed v3 CLIENT, which lives in `encryption/`, not
+ # `eql/v3/` (that is the authoring DSL). Without this the job stayed green
+ # by never running for the changes most likely to break it.
+ - 'packages/stack/src/encryption/**'
+ - 'packages/stack-drizzle/**'
+ - 'packages/test-kit/**'
+ - 'packages/cli/src/installer/**'
- 'local/**'
- '.github/workflows/tests-bench.yml'
+ - '.github/actions/integration-setup/**'
pull_request:
branches:
- "**"
+ # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases.
paths:
- 'packages/bench/**'
+ - 'packages/stack/src/eql/v3/**'
+ # The bench drives the typed v3 CLIENT, which lives in `encryption/`, not
+ # `eql/v3/` (that is the authoring DSL). Without this the job stayed green
+ # by never running for the changes most likely to break it.
+ - 'packages/stack/src/encryption/**'
+ - 'packages/stack-drizzle/**'
+ - 'packages/test-kit/**'
+ - 'packages/cli/src/installer/**'
- 'local/**'
- '.github/workflows/tests-bench.yml'
+ - '.github/actions/integration-setup/**'
jobs:
tests-bench:
@@ -25,25 +50,14 @@ jobs:
- name: Checkout Repo
uses: actions/checkout@v6
- - uses: pnpm/action-setup@v6.0.9
- name: Install pnpm
- with:
- run_install: false
-
- - name: Install Node.js
- uses: actions/setup-node@v6.5.0
+ # The shared integration preamble, which also builds the `stash` CLI — not
+ # incidental here: the bench `globalSetup` installs EQL v3 by shelling out
+ # to the real `stash eql install --eql-version 3`.
+ - uses: ./.github/actions/integration-setup
with:
+ # This job's existing Node; the composite defaults to 24 for the
+ # integration suites.
node-version: 22
- cache: 'pnpm'
-
- # node-pty's install hook falls back to `node-gyp rebuild` when no
- # linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships
- # node-gyp on PATH, so install it explicitly.
- - name: Install node-gyp
- run: npm install -g node-gyp
-
- - name: Install dependencies
- run: pnpm recursive install --frozen-lockfile
# `@cipherstash/stack` ships dist/-based `exports`; bench imports
# from `@cipherstash/stack` and `@cipherstash/stack-drizzle` (the Drizzle
@@ -54,11 +68,28 @@ jobs:
- name: Build stack + adapter packages
run: pnpm exec turbo run build --filter @cipherstash/stack --filter @cipherstash/stack-drizzle
- # Starts the pinned postgres-eql container (PostgreSQL 17 + EQL
- # pre-installed) via local/docker-compose.yml; waits for healthcheck.
- - name: Start local Postgres (EQL)
+ # Deliberately BEFORE Postgres. Separate config, no globalSetup: these
+ # need neither a database nor credentials, so they must not be reachable
+ # only after the steps that do — a docker failure below would otherwise
+ # skip the one check whose whole rationale is that it cannot be skipped.
+ # The seed keying they check was wrong for the entire v2 -> v3 port
+ # precisely because every suite that would have caught it needs
+ # credentials, and `tsc --noEmit` passes on a hand-written row type that
+ # agrees with itself (#772 review, finding 12).
+ - name: Run bench unit checks (no database, no credentials)
+ working-directory: packages/bench
+ run: pnpm test:unit
+
+ # Service-scoped `postgres`: local/docker-compose.yml also defines a
+ # PostgREST that bench never talks to.
+ #
+ # This container has EQL v2 baked in and NOTHING to do with the v3 domains
+ # the fixture schema needs — those come from the `globalSetup` install
+ # below. Do not add an EQL step here; keeping the install inside vitest is
+ # what makes `pnpm test:local` and CI the same path.
+ - name: Start local Postgres
working-directory: local
- run: docker compose up --wait --wait-timeout 60
+ run: docker compose up --wait --wait-timeout 60 postgres
# `pnpm test:local` resolves to `vitest run`; the trailing `db-only`
# is a vitest path filter that narrows to __tests__/db-only.test.ts
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index f13e60086..4a656554d 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -136,21 +136,98 @@ jobs:
- name: Typecheck (prisma-next — enforces v3 operator-capability gating)
run: pnpm --filter @cipherstash/prisma-next run typecheck
+ # `packages/bench` is a live importer of `@cipherstash/stack` and
+ # `@cipherstash/stack-drizzle`, but it has no `test` script (its suites
+ # need a database), so `pnpm run test` never reaches it and an adapter
+ # rename could break it unnoticed. Its `build` is `tsc --noEmit`, and
+ # turbo's `^build` builds the two adapters first. This also runs in the
+ # Bun job's full `turbo build`, but that job swallows its own test
+ # failures — the importer guard should not depend on it.
+ - name: Typecheck (bench — guards the stack/stack-drizzle importers)
+ run: pnpm exec turbo run build --filter @cipherstash/bench
+
+ # The wizard is built by tsup, which transpiles without typechecking, so
+ # nothing here caught the three `auth.AutoStrategy` resolution errors that
+ # sat in `main` until #771. Gate it so they cannot come back silently.
+ - name: Typecheck (wizard)
+ run: pnpm --filter @cipherstash/wizard run typecheck
+
+ # `examples/*` are standalone apps outside the `./packages/*` filter that
+ # root `build`/`test` use, so nothing in CI compiled them. `examples/basic`
+ # had been broken since the v2 removal deleted `encryptedType` and the v2
+ # `encryptedSupabase` — on a fully green board. Gate it through turbo so
+ # `^build` builds stack/stack-drizzle/stash first.
+ - name: Typecheck (examples/basic — guards the v3 stack/stack-drizzle importers)
+ run: pnpm exec turbo run typecheck --filter @cipherstash/basic-example
+
+ # The rest of the surfaces that were compiling nowhere. Each of these has
+ # a `tsconfig.json` that covers its `src` and tests, and each was already
+ # clean — so they are gated now, before they drift. They resolve their
+ # workspace dependencies through `dist/*.d.ts`, hence the turbo filters
+ # (`^build` builds the dependencies first).
+ #
+ # NOT fully gated yet, and deliberately: `@cipherstash/stack` (147 errors
+ # under its own tsconfig), `stash` (21) and `@cipherstash/stack-supabase`
+ # (11). Their `test:types` scripts only cover `__tests__/**/*.test-d.ts`,
+ # so `src` and the runtime test suites compile nowhere.
+ # `@cipherstash/stack-drizzle` now type-tests every `integration/**`
+ # source as well; its remaining `src` / runtime-test errors share one
+ # root cause — `V3_MATRIX`'s `indexes` union in `@cipherstash/test-kit`
+ # — see #778.
+ - name: Typecheck (migrate)
+ run: pnpm exec turbo run typecheck --filter @cipherstash/migrate
+
+ - name: Typecheck (nextjs)
+ run: pnpm exec turbo run typecheck --filter @cipherstash/nextjs
+
+ - name: Typecheck (examples/prisma — guards the prisma-next importers)
+ run: pnpm exec turbo run typecheck --filter @cipherstash/prisma-next-example
+
+ - name: Typecheck (e2e)
+ run: pnpm exec turbo run typecheck --filter @cipherstash/e2e
+
+ # Everything else typechecks against SOURCE. This one reads the emitted
+ # `.d.ts`, which is what customers consume — and where typed `encryptQuery`
+ # sat broken for every column through the whole rc series, because `tsc`
+ # strips private member types and that was the only place the column's
+ # domain parameter appeared bare.
+ - name: Typecheck (stack — emitted declarations, not source)
+ run: pnpm exec turbo run test:types:dist --filter @cipherstash/stack
+
+ # `stash init` writes a client file into the user's project and tells them
+ # not to hand-edit it. Nothing compiled that file, so tightening
+ # `Encryption` to require a non-empty schema set left every `stash init`
+ # emitting a project that fails its first `tsc` — with CI green (#772
+ # review). The fixtures are pinned byte-for-byte to the generator by
+ # `placeholder-client-fixture.test.ts`.
+ # Through turbo, not `pnpm run` — the fixtures import `@cipherstash/stack/v3`,
+ # so this needs that package BUILT. Invoked directly it passed only because
+ # earlier steps in this job happen to build it via their own `^build`; drop
+ # or reorder those (they read as guards for other packages, so they look
+ # independently removable) and this fails `TS2307`, which reads as "the
+ # scaffold is broken" rather than "you forgot to build" (#787 review).
+ - name: Typecheck (stash init's scaffolded client)
+ run: pnpm exec turbo run typecheck:scaffold --filter stash
+
- name: Lint — no hardcoded package-manager runners
run: pnpm run lint:runners
+ # The gates above only mean something if they compile source. A tsconfig
+ # with no `include` globs `**/*`, which sweeps the package's own `dist/`
+ # into the program — and whether `dist/` exists during a gate depends on
+ # what an earlier step built transitively, so the gate compiles a
+ # different program in CI than it does locally.
+ - name: Lint — typecheck gates compile source, not build output
+ run: pnpm run lint:typecheck-scope
+
+ # Deleting or renaming a package leaves `packages/` references
+ # dangling in docs and CI config. Nothing else catches it (#760 review).
+ - name: Lint — no references to deleted package directories
+ run: pnpm run lint:package-paths
+
- name: Test — lint script self-tests
run: pnpm run test:scripts
- - name: Create .env file in ./packages/protect/
- run: |
- touch ./packages/protect/.env
- echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env
- echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect/.env
- echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env
- echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env
- echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env
-
- name: Create .env file in ./packages/stack/
run: |
touch ./packages/stack/.env
@@ -160,14 +237,6 @@ jobs:
echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env
echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env
- - name: Create .env file in ./packages/protect-dynamodb/
- run: |
- touch ./packages/protect-dynamodb/.env
- echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env
- echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env
- echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env
- echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env
-
# Run TurboRepo tests
- name: Run tests
run: pnpm run test
@@ -353,15 +422,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- - name: Create .env file in ./packages/protect/
- run: |
- touch ./packages/protect/.env
- echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env
- echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect/.env
- echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env
- echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env
- echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env
-
- name: Create .env file in ./packages/stack/
run: |
touch ./packages/stack/.env
@@ -371,21 +431,13 @@ jobs:
echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env
echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env
- - name: Create .env file in ./packages/protect-dynamodb/
- run: |
- touch ./packages/protect-dynamodb/.env
- echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env
- echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env
- echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env
- echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env
-
# Build with Node (turbo/tsup need Node), then run tests with Bun
- name: Build packages
run: pnpm turbo build --filter './packages/*'
- name: Run tests with Bun
run: |
- for dir in packages/schema packages/protect packages/stack packages/protect-dynamodb packages/stack-forge; do
+ for dir in packages/stack; do
if [ -f "$dir/vitest.config.ts" ] || [ -f "$dir/package.json" ]; then
echo "--- Testing $dir ---"
(cd "$dir" && bunx --bun vitest run) || true
diff --git a/AGENTS.md b/AGENTS.md
index 8c0566c11..2ce2f337b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,26 +73,19 @@ If these variables are missing, tests that require live encryption will fail or
- `packages/stack`: Main package (`@cipherstash/stack`) containing the encryption client and all integrations
- Subpath exports: `@cipherstash/stack`, `@cipherstash/stack/client`, `@cipherstash/stack/identity`, `@cipherstash/stack/schema`, `@cipherstash/stack/eql/v3`, `@cipherstash/stack/v3`, `@cipherstash/stack/types`, `@cipherstash/stack/dynamodb`, `@cipherstash/stack/encryption`, `@cipherstash/stack/errors`, `@cipherstash/stack/adapter-kit`, `@cipherstash/stack/wasm-inline` (the Drizzle and Supabase integrations moved to their own packages — see below)
-- `packages/protect`: Core encryption library (internal, re-exported via `@cipherstash/stack`)
- - `src/index.ts`: Public API (`Encryption`, exports)
- - `src/ffi/index.ts`: `EncryptionClient` implementation, bridges to `@cipherstash/protect-ffi`
- - `src/ffi/operations/*`: Encrypt/decrypt/model/bulk/query operations (thenable pattern with optional `.withLockContext()`)
- - `__tests__/*`: End-to-end and API contract tests (Vitest)
- `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 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`)
+- `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — **EQL v3 only**, on the package root (the v2 surface was removed and the old `./v3` subpath collapsed into `.`). Split out of `@cipherstash/stack`.
+- `packages/stack-supabase`: Supabase integration (`@cipherstash/stack-supabase`), depends on `@cipherstash/stack` — **EQL v3 only**: `encryptedSupabase` is the v3 factory (`encryptedSupabaseV3` remains as a `@deprecated` alias). Split out of `@cipherstash/stack`.
- `packages/nextjs`: Next.js helpers and Clerk integration (`./clerk` export)
-- `packages/protect-dynamodb`: DynamoDB helpers (`protectDynamoDB`), built on `@cipherstash/protect`. A fork of the shipping adapter, kept for existing consumers of the standalone package; EQL v2 only. The maintained implementation is `packages/stack/src/dynamodb` (`encryptedDynamoDB`)
- `packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`)
- `packages/bench`: Performance / index-engagement benchmarks (private, not published)
- `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README)
- `examples/*`: Working apps (basic, prisma, supabase-worker)
- `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo).
-- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`)
+- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-postgres`, `stash-edge`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`)
## Agent Skills — these ship to customers
@@ -120,6 +113,8 @@ nothing type-checks them, and the damage lands in a customer's repo, not ours.
| Drizzle / Supabase / Prisma Next / DynamoDB integrations | `skills/stash-drizzle`, `skills/stash-supabase`, `skills/stash-prisma-next`, `skills/stash-dynamodb` |
| The rollout/cutover lifecycle (`packages/migrate`, `stash encrypt *`) | `skills/stash-encryption` and `skills/stash-cli` |
| The `@cipherstash/eql` pin, `eql install`/`eql migration` behaviour, or index-related SQL guidance | `skills/stash-indexing` |
+| The EQL operator/domain surface (`eql_v3.query_*` casts, predicate forms) | `skills/stash-postgres` |
+| `packages/stack/src/wasm-inline.ts`, the WASM entry's exports, or `stash env` | `skills/stash-edge` |
| pnpm config, CI workflows, dependency policy | `skills/stash-supply-chain-security` |
| The durable agent rules themselves | `packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md` |
@@ -149,8 +144,8 @@ Three rules to remember when editing CI or pnpm config:
## Key Concepts and APIs
-- **Initialization**: `EncryptionV3({ schemas })` returns the typed EQL v3 client. (`Encryption({ schemas })` is the legacy v2 client.) Provide at least one `encryptedTable`.
-- **Schema (EQL v3, the documented approach)**: Define tables/columns with `encryptedTable` and the `types.*` concrete-domain factories from `@cipherstash/stack/eql/v3` (`types.TextSearch`, `types.IntegerOrd`, `types.Json`, …) — each domain's query capabilities are fixed by its type; there are no chainable capability tuners. Build the client with `EncryptionV3` from `@cipherstash/stack/v3`. (Legacy EQL v2 — `Encryption` + `encryptedColumn(...).equality().freeTextSearch().orderAndRange().searchableJson()` from `@cipherstash/stack/schema` — remains for existing deployments. DynamoDB accepts both v3 and v2 tables; nested fields work in both — v2 via `encryptedField` groups, v3 via a flat dotted column path (`'profile.ssn': types.TextEq(...)`), since `EncryptedV3TableColumn` admits no nested groups.)
+- **Initialization**: `Encryption({ schemas })` is the single client factory. It is overloaded: an array of concrete EQL v3 tables yields the strongly-typed EQL v3 client; a v2/loose schema set yields the nominal client. `EncryptionV3` (from `@cipherstash/stack/v3`) is a deprecated, type-identical alias of `Encryption`. Provide at least one `encryptedTable`.
+- **Schema (EQL v3, the documented approach)**: Define tables/columns with `encryptedTable` and the `types.*` concrete-domain factories from `@cipherstash/stack/eql/v3` (`types.TextSearch`, `types.IntegerOrd`, `types.Json`, …) — each domain's query capabilities are fixed by its type; there are no chainable capability tuners. Build the client with `Encryption` (import `encryptedTable`/`types` from `@cipherstash/stack/v3`). The client authors EQL v3 only; the `config.eqlVersion` field and the `@cipherstash/stack/schema` EQL v2 builders (`encryptedColumn(...).equality().freeTextSearch().orderAndRange().searchableJson()`) remain (now `@deprecated`) for reading/migrating legacy v2 data. `decrypt`/`decryptModel` read both v2 and v3 payloads. DynamoDB **writes EQL v3 only; decrypt still reads existing v2 items**; nested fields work in both — v2 via `encryptedField` groups (read only), v3 via a flat dotted column path (`'profile.ssn': types.TextEq(...)`), since `EncryptedV3TableColumn` admits no nested groups.
- **Operations** (all return Result-like objects and support chaining `.withLockContext(lockContext)` and `.audit()` when applicable):
- `encrypt(plaintext, { table, column })`
- `decrypt(encryptedPayload)`
@@ -161,8 +156,8 @@ Three rules to remember when editing CI or pnpm config:
- `encryptQuery(terms[])` for batch query encryption
- **Identity-aware encryption**: Authenticate the client as the end user with `OidcFederationStrategy` (`config.authStrategy`, re-exported from `@cipherstash/stack`), then chain `.withLockContext({ identityClaim })` on operations to bind the data key to a claim. The same claim must be used for encrypt and decrypt. (`LockContext.identify()` from `@cipherstash/stack/identity` is deprecated — the strategy now handles token acquisition; `.withLockContext()` also accepts a `LockContext`.)
- **Integrations**:
- - **Drizzle ORM**: `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack-drizzle` (EQL v3 factories from `@cipherstash/stack-drizzle/v3`)
- - **Supabase**: `encryptedSupabase` (v2) / `encryptedSupabaseV3` (v3) from `@cipherstash/stack-supabase`
+ - **Drizzle ORM**: `types.*` column factories, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack-drizzle`
+ - **Supabase**: `encryptedSupabase` from `@cipherstash/stack-supabase` (EQL v3; `encryptedSupabaseV3` is a `@deprecated` alias)
- **DynamoDB**: `encryptedDynamoDB` from `@cipherstash/stack/dynamodb`
## Critical Gotchas (read before coding)
@@ -225,11 +220,11 @@ pnpm changeset:publish
## Adding Features Safely (LLM checklist)
1. Identify the target package(s) in `packages/*` and confirm whether changes affect public APIs or payload shapes.
-2. If modifying `packages/protect` operations or `EncryptionClient`, ensure:
+2. If modifying `packages/stack` encryption operations or `EncryptionClient`, ensure:
- The Result contract and error type strings remain stable.
- `.withLockContext()` remains available for affected operations.
- ESM/CJS exports continue to work (don't break `require`).
-3. If changing schema behavior (`packages/schema`), update type definitions and ensure validation still works in `EncryptionClient.init`.
+3. If changing schema behavior (`packages/stack` schema builders, `@cipherstash/stack/schema`), update type definitions and ensure validation still works in `EncryptionClient.init`.
4. Add/extend tests in the same package. For features that require live credentials, guard with env checks or provide mock-friendly paths.
5. Run:
- `pnpm run code:fix`
diff --git a/README.md b/README.md
index 0753d24e1..489c7f83a 100644
--- a/README.md
+++ b/README.md
@@ -20,11 +20,11 @@
**Encryption**
```typescript
-import { Encryption, encryptedTable, encryptedColumn } from "@cipherstash/stack";
+import { Encryption, encryptedTable, types } from "@cipherstash/stack/v3";
-// 1. Define your schema
+// 1. Define your schema — the column type fixes its query capabilities
const users = encryptedTable("users", {
- email: encryptedColumn("email").equality().freeTextSearch(),
+ email: types.TextSearch("email"), // equality + order/range + free-text search
});
// 2. Initialize the client
@@ -67,7 +67,7 @@ bun add @cipherstash/stack
## Features
- **[Searchable encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption)**: query encrypted data with equality, free text search, range, and [JSONB queries](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption#jsonb-queries-with-searchablejson).
-- **[Type-safe schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema)**: define encrypted tables and columns with `encryptedTable` / `encryptedColumn`
+- **[Type-safe schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema)**: define encrypted tables and columns with `encryptedTable` and the `types.*` concrete-domain factories
- **[Model & bulk operations](https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt#model-operations)**: encrypt and decrypt entire objects or batches with `encryptModel` / `bulkEncryptModels`.
- **[Identity-aware encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/identity)**: authenticate as the end user with `OidcFederationStrategy` and bind the data key to their identity with `.withLockContext({ identityClaim })` for policy-based access control.
diff --git a/SECURITY.md b/SECURITY.md
index 85fd4e22b..ba3996d97 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -11,14 +11,11 @@ This repository is the CipherStash Stack monorepo for JavaScript/TypeScript. It
| ------- | ----------- |
| `@cipherstash/stack` | Main package: encryption client, schema, EQL v3 typed client |
| `stash` | CipherStash CLI |
-| `@cipherstash/protect` | Core encryption library (re-exported via `@cipherstash/stack`) |
-| `@cipherstash/schema` | Schema builder utilities |
| `@cipherstash/nextjs` | Next.js helpers |
-| `@cipherstash/protect-dynamodb` | DynamoDB helpers |
| `@cipherstash/migrate` | Plaintext-to-encrypted column migration tooling |
| `@cipherstash/prisma-next` | Prisma Next integration (searchable field-level encryption for Postgres) |
-| `@cipherstash/stack-drizzle` | Drizzle ORM integration for `@cipherstash/stack` (EQL v2 + v3) |
-| `@cipherstash/stack-supabase` | Supabase integration for `@cipherstash/stack` (EQL v2 + v3) |
+| `@cipherstash/stack-drizzle` | Drizzle ORM integration for `@cipherstash/stack` (EQL v3) |
+| `@cipherstash/stack-supabase` | Supabase integration for `@cipherstash/stack` (EQL v3) |
| `@cipherstash/wizard` | AI-powered encryption setup |
**Security fixes are released for the latest release line of each package.** Security reports are welcome for any version, but fixes land in the latest release — if you are running an older major version, plan to upgrade to receive them.
diff --git a/docs/plans/cli-help-and-manifest.md b/docs/plans/cli-help-and-manifest.md
index f79cd992b..60e620595 100644
--- a/docs/plans/cli-help-and-manifest.md
+++ b/docs/plans/cli-help-and-manifest.md
@@ -1,6 +1,11 @@
# Proposal: a command-descriptor registry for `stash` help + a `manifest --json`
-**Status:** proposal / for discussion
+**Status:** implemented — the registry and `stash manifest --json` shipped.
+Kept as the design record. **Note (2026-07-29):** the command names used as
+examples below are a snapshot of the surface at proposal time. `db push`, `db
+install`, `db upgrade`, and `db status` no longer exist (the installer is `stash
+eql install`), and `stash encrypt cutover` was removed with EQL v2. Run `stash
+manifest --json` for the current surface — that is the point of the registry.
**Area:** `packages/cli`
**Motivated by:** the docs V2 CLI reference, which is generated from the CLI
(cipherstash/docs#45). Today it parses `stash --help`; this proposal gives it —
@@ -20,7 +25,9 @@ and agents — a real structured source, and makes per-command help consistent.
2. **Per-command help is inconsistent.** `auth` implements its own `--help`
([`commands/auth/index.ts`](../../packages/cli/src/commands/auth/index.ts)),
but `stash eql install --help`, `stash db push --help`, etc. fall through to
- the top-level help. There is no per-command help for most commands.
+ the top-level help. There is no per-command help for most commands. (`db
+ push` has since been removed — the drift it illustrates was real, and is
+ what the registry now prevents.)
3. **No machine-readable output.** Docs have to scrape `--help`, and agents
running `npx stash` have no authoritative, versioned command surface to read.
diff --git a/docs/plans/encryption-migrations.md b/docs/plans/encryption-migrations.md
index b672867c9..eb47ee008 100644
--- a/docs/plans/encryption-migrations.md
+++ b/docs/plans/encryption-migrations.md
@@ -1,5 +1,27 @@
# Encryption Migrations — Implementation Plan
+> **Status (2026-07-29): historical. Parts of the surface below no longer exist.**
+>
+> This document is kept as the design record for `cs_migrations`, the manifest,
+> and the backfill engine — all of which shipped and are current. What was
+> retired with the EQL v2 removal:
+>
+> - **`stash encrypt cutover` and the whole `cut-over` phase.** EQL v3 has no
+> rename swap. The lifecycle is `schema-added → dual-writing → backfilling →
+> backfilled → dropped`: backfill, switch the application to the encrypted
+> column *by name*, then drop the plaintext column (which is the original
+> ``, not a `_plaintext` left behind by a rename).
+> - **`stash db push`, `db install`, `db upgrade`, `db status`.** `db push` and
+> its `eql_v2_configuration` DAO are gone entirely; the installer is now
+> `stash eql install`.
+> - **Every `eql_v2.*` function call.** `stash` no longer installs or drives EQL
+> v2. Existing v2 ciphertext stays readable; it is not an authoring or rollout
+> target.
+>
+> Read the sections below as "what we planned in Phase 1", not as a description
+> of the current CLI. `skills/stash-cli/SKILL.md` and
+> `skills/stash-encryption/SKILL.md` are the current lifecycle reference.
+
## Context
CipherStash today can encrypt a column at rest via EQL + either Stack/Protect.js (client-side) or the CipherStash Proxy (transparent). What it *doesn't* have is a first-class way to migrate an **existing plaintext column** into an encrypted one safely in production. EQL ships the schema/config primitives (`add_column`, `migrate_config`, `rename_encrypted_columns`) but no backfill orchestrator, no per-column phase tracking, and no resumable data mover. Today users have to wire this up themselves, which is both the biggest onboarding friction and the biggest correctness risk (partial backfills, reads on the wrong column, silent plaintext leaks).
@@ -10,6 +32,10 @@ This plan adds a shared migration substrate — CLI + library — that walks eac
schema-added → dual-writing → backfilling → backfilled → cut-over → dropped
```
+*The shipped lifecycle drops `cut-over`: `schema-added → dual-writing →
+backfilling → backfilled → dropped`. Status readers still display legacy
+`cut_over` rows so old history remains printable.*
+
The same mechanism serves Stack and Proxy users. Phase 1 ships the status inspector and the backfill engine (the two pieces with no good existing workaround). The other phases get lightweight commands that mostly orchestrate existing EQL functions and delegate the code changes (e.g. wiring dual-writes into the persistence layer) to the agent handoff that `stash init` set up — Claude / Codex / AGENTS.md, with the relevant skills already installed.
## Scope (Phase 1)
@@ -19,8 +45,8 @@ The same mechanism serves Stack and Proxy users. Phase 1 ships the status inspec
3. A new `cs_migrations` table + small library (`@cipherstash/migrate` or co-located in `@cipherstash/stack`) that the CLI commands drive. Library is exported so users can embed backfill in their own workers/cron later without new infra.
4. `.cipherstash/migrations.json` repo manifest = intent (desired columns + index set + target phase). `stash encrypt plan` diffs intent vs. observed state.
5. Thin wrappers for the post-backfill phases so users can drive end-to-end from the CLI today, even if those phases are mostly pass-throughs:
- - `stash encrypt cutover` — wraps `eql_v2.rename_encrypted_columns()` + `eql_v2.reload_config()` (via Proxy if present).
- - `stash encrypt drop` — emits a migration file that drops `_plaintext`.
+ - `stash encrypt cutover` — wraps `eql_v2.rename_encrypted_columns()` + `eql_v2.reload_config()` (via Proxy if present). *(Removed — see Status. There is no cut-over rename.)*
+ - `stash encrypt drop` — emits a migration file that drops `_plaintext`. *(Shipped, but it drops the original ``: with no rename, there is no `_plaintext`.)*
**Out of Phase 1:** Proxy-mode backfill (Phase 2), CS-hosted backfill runner (Phase 3), upstreaming `cs_migrations` into EQL as `eql_v2_migrations` (Phase 3), `stash encrypt update` for re-encrypting an already-cut-over column with new EQL config (next change).
@@ -131,7 +157,12 @@ Backfill now owns the bookmark. The first time backfill runs against a column, i
If the user lies (says yes but dual-writes aren't actually live), rows inserted during the backfill land in plaintext only and the recovery is `stash encrypt backfill --force`, which drops the `_encrypted IS NULL` guard and re-encrypts every row regardless of current state. Audit trail: the `force` run is recorded with `details.force = true` in `cs_migrations` so it shows up in `encrypt status` history as a distinct event.
-### 5. `stash encrypt cutover`
+### 5. `stash encrypt cutover` *(never shipped in this form; removed)*
+
+> Nothing in this section exists. The command, the `cut-over` phase transition,
+> and every `eql_v2.*` call below were removed with EQL v2. In EQL v3 the
+> application moves to the encrypted column by name after backfill — no rename,
+> no config promotion, no Proxy reload.
For each column in `backfilled` phase, in a single transaction:
@@ -155,6 +186,10 @@ Record `cut_over` event. App's existing `SELECT email FROM users` returns the en
### 6. `stash encrypt drop`
+> Shipped, but gated on `backfilled` (not `cut_over`, which no longer exists)
+> and it drops the original ``. It also re-verifies coverage under an
+> `ACCESS EXCLUSIVE` lock inside the generated migration.
+
For columns in `cut_over` phase:
1. Read Drizzle / Prisma / other migration tooling from repo (we already detect this in init).
@@ -168,17 +203,17 @@ For columns in `cut_over` phase:
- `status.ts` — new
- `plan.ts` — new (diffs intent vs. observed)
- `backfill.ts` — new (also handles the dual-write confirmation + `--force` recovery path)
- - `cutover.ts` — new
+ - `cutover.ts` — new *(never shipped / removed)*
- `drop.ts` — new
- `stack/packages/cli/src/bin/stash.ts` — register `encrypt` subcommand (analogous to existing `db` registration at ~line 237)
- `stack/packages/migrate/` — **new package** (library the CLI drives)
- `src/state.ts` — `cs_migrations` DAO (append event, get latest, get progress)
- `src/backfill.ts` — the chunked loop, exported as `runBackfill({ table, column, client, db, chunkSize, signal })`
- `src/cursor.ts` — keyset pagination primitive
- - `src/eql.ts` — thin wrappers over `eql_v2.*` functions (rename, reload, config read)
+ - `src/eql.ts` — thin wrappers over `eql_v2.*` functions (rename, reload, config read) *(never shipped; `@cipherstash/migrate` has no `eql_v2` wrappers — see `src/version.ts` for the v3 domain classifier that replaced this idea)*
- `src/manifest.ts` — read/write `.cipherstash/migrations.json`
- - `src/schema.sql` — `cs_migrations` DDL, installed by `db install` or an explicit `encrypt install` step
-- `stack/packages/cli/src/commands/db/install.ts` — extend to install `cs_migrations` schema alongside EQL
+ - `src/schema.sql` — `cs_migrations` DDL, installed by `db install` or an explicit `encrypt install` step *(`db install` is now `stash eql install`)*
+- `stack/packages/cli/src/commands/db/install.ts` — extend to install `cs_migrations` schema alongside EQL *(moved: the installer is `commands/eql/install.ts`)*
- `stack/packages/cli/src/commands/init/lib/introspect.ts` — `introspectDatabase` lives here post-#395; `status.ts` reuses it via direct import
- `stack/packages/cli/src/config/` — extend `stash.config.ts` loader so backfill subprocess can dynamically import user's encryption client
- `stack/packages/cli/package.json` — add `@cipherstash/migrate` dep
@@ -190,8 +225,8 @@ For columns in `cut_over` phase:
- `introspectDatabase` in `packages/cli/src/commands/init/lib/introspect.ts` (moved here from the old wizard package as part of the #395 init handoff work).
- `loadStashConfig` + dynamic encryption-client import lives at `packages/cli/src/config/`. Re-export from `@cipherstash/migrate` so library consumers don't need a hidden cross-package import.
- `rewriteEncryptedAlterColumns` in `packages/cli/src/commands/db/rewrite-migrations.ts` — the phase-1 schema-add is already solved by drizzle-kit + this rewriter. The new commands **will not** re-solve it.
-- EQL functions (Postgres): `eql_v2.add_column`, `eql_v2.add_search_config`, `eql_v2.migrate_config`, `eql_v2.activate_config`, `eql_v2.rename_encrypted_columns`, `eql_v2.reload_config`, `eql_v2.count_encrypted_with_active_config`, `eql_v2.select_pending_columns`, `eql_v2.ready_for_encryption`.
-- `db push` in `packages/cli/src/commands/db/push.ts` — already handles writing to `eql_v2_configuration`; reuse the DAO.
+- EQL functions (Postgres): `eql_v2.add_column`, `eql_v2.add_search_config`, `eql_v2.migrate_config`, `eql_v2.activate_config`, `eql_v2.rename_encrypted_columns`, `eql_v2.reload_config`, `eql_v2.count_encrypted_with_active_config`, `eql_v2.select_pending_columns`, `eql_v2.ready_for_encryption`. *(None of these are called any more — `stash` installs and drives EQL v3 only. EQL v3 needs no configuration table: the domain types carry the config.)*
+- `db push` in `packages/cli/src/commands/db/push.ts` — already handles writing to `eql_v2_configuration`; reuse the DAO. *(Both the command and that file were deleted; there is no `packages/cli/src/commands/db/push.ts` to read.)*
## Verification
@@ -201,15 +236,15 @@ For columns in `cut_over` phase:
- Manifest reader: schema validation, drift detection.
2. **Integration (Drizzle, local Postgres)**
- Seed 100k-row `users` table with plaintext `email`.
- - `stash db install` → EQL + `cs_migrations` installed.
+ - `stash db install` → EQL + `cs_migrations` installed. *(now `stash eql install`)*
- Manually wire dual-write in the test app's insert code (simulates user + agent handoff).
- `stash encrypt backfill --table users --column email` → interactive prompt confirms dual-writes are deployed, appends `dual_writing` event, runs to completion; progress output sane; `COUNT(*) WHERE email_encrypted IS NULL` = 0.
- Same flow non-interactively: `--confirm-dual-writes-deployed` accepted, loud warning printed.
- Kill mid-backfill (SIGINT) → re-run with `--resume` → completes without duplicate encryption; `cs_migrations` shows continuous cursor progression; no second `dual_writing` event.
- `stash encrypt backfill --force` after manually corrupting an encrypted row → re-encrypts every row; `details.force = true` recorded in `cs_migrations`.
- `stash encrypt status` → shows `backfilled`.
- - `stash encrypt cutover` → rename executes; app (still running, reads `email`) now gets decrypted ciphertext transparently.
- - `stash encrypt drop` → migration file emitted; apply; `email_plaintext` gone.
+ - `stash encrypt cutover` → rename executes; app (still running, reads `email`) now gets decrypted ciphertext transparently. *(Removed. The v3 equivalent is a deploy: point the app at `email_encrypted` and decrypt through the encryption client.)*
+ - `stash encrypt drop` → migration file emitted; apply; `email_plaintext` gone. *(Drops `email`, the original plaintext column.)*
3. **Idempotency**
- Run `backfill` twice with no kill — second run does 0 writes.
- Concurrent runners on two shells — both converge, no duplicate writes, no missed rows.
@@ -237,8 +272,10 @@ For columns in `cut_over` phase:
## Open items flagged (decisions already made)
+> Two of these were later reversed by the move to EQL v3 — marked inline.
+
- Phase 1 runtime mode = Protect/Stack client-side only.
-- Phase 4 default cutover mechanism = `eql_v2.rename_encrypted_columns()` (transparent to app code).
-- State store = repo manifest + `eql_v2_configuration` (EQL intent) + new `cs_migrations` table (runtime state).
+- Phase 4 default cutover mechanism = `eql_v2.rename_encrypted_columns()` (transparent to app code). *(Reversed: no cut-over mechanism at all.)*
+- State store = repo manifest + `eql_v2_configuration` (EQL intent) + new `cs_migrations` table (runtime state). *(Reversed: EQL v3 carries intent in the column's domain type, so there is no configuration table.)*
- Phase 1 shipping scope = status + backfill first-class; other phases as thin wrappers.
- `cs_migrations` is CLI-owned for now, explicitly designed to be upstreamed into EQL as `eql_v2_migrations` in a later release so both Stack and Proxy own it jointly.
diff --git a/docs/query-api-walkthrough.md b/docs/query-api-walkthrough.md
index 8559a2ed7..ac44297f9 100644
--- a/docs/query-api-walkthrough.md
+++ b/docs/query-api-walkthrough.md
@@ -37,7 +37,7 @@ flowchart TD
| # | Layer | Entry point | Role |
|---|-------|-------------|------|
| 1 | Public API | `encryption/index.ts:259/270` `encryptQuery()` | Overloaded: single value → `EncryptQueryOperation`; `ScalarQueryTerm[]` → `BatchEncryptQueryOperation`. |
-| 1a | Query builders | `drizzle/operators.ts:976`, `supabase/query-builder.ts:44` | `eq/gt/...` operators & deferred builders that batch-encrypt RHS values, then emit a WHERE clause. |
+| 1a | Query builders | `stack-drizzle/src/operators.ts`, `stack-supabase/src/query-builder.ts` | `eq/gt/...` operators & deferred builders that batch-encrypt RHS values, then emit a WHERE clause. |
| 2 | Operations | `operations/encrypt-query.ts:41`, `operations/batch-encrypt-query.ts:115` | `execute()`: validate → resolve index → call FFI. `*WithLockContext` resolves `LockContextInput` via `resolveLockContext` before the FFI call. |
| 3 | EQL resolution | `helpers/infer-index-type.ts:89`, `types.ts:292` | `resolveIndexType` + `queryTypeToFfi`/`queryTypeToQueryOp` map public `QueryTypeName` → FFI `indexType`/`queryOp`. |
| 4 | FFI JS wrapper | `protect-ffi/lib/index.cjs:155` | `encryptQuery`/`encryptQueryBulk` → `wrapAsync(native.*)`. |
@@ -73,5 +73,5 @@ flowchart LR
- **Client init:** `EncryptionClient.init()` (`encryption/index.ts:81`) calls FFI `newClient()` once; the returned `Client` handle is passed into every `encryptQuery` call.
- **`cipherstashclient`** = the CipherStash Client **Rust SDK**, compiled via Neon into the platform `.node` binary inside `@cipherstash/protect-ffi`. It performs the actual crypto and talks to ZeroKMS.
- **Result shape:** `EncryptedQueryResult` (`types.ts:175`); shaped by `formatEncryptedResult(..., returnType)` (`eql` vs raw).
-- **Version:** `package.json` pins `@cipherstash/protect-ffi@0.24.0` (installed tree observed at `0.23.0` — confirm before relying on it).
-- `packages/protect/src/ffi/*` mirrors this flow under the older `protect` package name.
+- **Version:** `packages/stack/package.json` pins `@cipherstash/protect-ffi@0.30.0` (`stack-drizzle` and `stack-supabase` pin the same version — they must move in lockstep).
+- **Paths:** rows 1–3 and the notes above are relative to `packages/stack/src/`; row 1a is rooted at `packages/`; rows 4–5 are inside the installed `@cipherstash/protect-ffi`. Line numbers drift — search the symbol, don't trust the offset.
diff --git a/docs/reference/supabase-sdk.md b/docs/reference/supabase-sdk.md
index 6561f255e..abbfa66d4 100644
--- a/docs/reference/supabase-sdk.md
+++ b/docs/reference/supabase-sdk.md
@@ -4,14 +4,21 @@
are transparently encrypted on mutations, `::jsonb`-cast on selects, encrypted
in filter terms, and decrypted in results.
-Two entry points, one query mechanism:
+One entry point, EQL v3 only:
| Entry point | Schema DSL | Column storage |
|---|---|---|
-| `encryptedSupabase` | `@cipherstash/stack/schema` (EQL v2) | `eql_v2_encrypted` composite |
-| `encryptedSupabaseV3` | `@cipherstash/stack/eql/v3` (EQL v3) | native `public.eql_v3_*` domains |
+| `encryptedSupabase` | `@cipherstash/stack/eql/v3` (EQL v3) | native `public.eql_v3_*` domains |
-Both filter via **direct EQL operators over PostgREST**: the wrapper encrypts
+Rows already written as EQL v2 still decrypt through `@cipherstash/stack`; what
+is gone is the ability to author new v2 columns here.
+
+`encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias. The old
+EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient,
+supabaseClient })` — has been removed; the name now binds to the v3 factory
+below.
+
+It filters via **direct EQL operators over PostgREST**: the wrapper encrypts
the filter term and emits an ordinary `col term` filter, which resolves
to the custom operator defined on the encrypted type (equality by HMAC, range
by the ordering term — CLLW-OPE on `_ord` domains, block-ORE on `_ord_ore` —
@@ -19,7 +26,7 @@ free-text by bloom-filter containment).
## Quick start (EQL v3)
-`encryptedSupabaseV3` is an async factory that **introspects the database at
+`encryptedSupabase` is an async factory that **introspects the database at
connect time**: it detects EQL v3 columns by their Postgres domain, derives
each column's encryption config from the domain, and builds the encryption
client internally. Introspection needs a direct Postgres connection
@@ -27,14 +34,14 @@ client internally. Introspection needs a direct Postgres connection
run in a Worker or the browser.
```typescript
-import { encryptedSupabaseV3 } from '@cipherstash/stack-supabase'
+import { encryptedSupabase } from '@cipherstash/stack-supabase'
// Introspects the database via options.databaseUrl or DATABASE_URL
-const es = await encryptedSupabaseV3(
+const es = await encryptedSupabase(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!,
)
-// or wrap an existing client: await encryptedSupabaseV3(supabaseClient, options)
+// or wrap an existing client: await encryptedSupabase(supabaseClient, options)
await es.from('users').insert({ email: 'a@b.com', amount: 30 })
@@ -49,16 +56,16 @@ await es.from('users').select('id, amount').gte('amount', 10).lte('amount', 100)
`from(tableName)` takes only the table name — no schema argument; column
capabilities come from the introspected domains.
-The builder surface is shared across v2 and v3:
-`.select/.insert/.update/.upsert/.delete`,
+The builder surface is `.select/.insert/.update/.upsert/.delete`,
`.eq/.neq/.in/.is/.gt/.gte/.lt/.lte/.match/.or/.not/.filter`,
-transforms (`.order/.limit/.range/.single/.maybeSingle/.csv/.abortSignal/.throwOnError`),
-plus `.withLockContext(lockContext)` and `.audit(config)` — with one fork:
-free-text search. v2 exposes `.like/.ilike` (SQL wildcard matching); v3
-exposes `.matches()` (fuzzy bloom token search) on encrypted columns, keeps
-`.contains()` for native (exact) containment on plaintext columns, and treats
-`like`/`ilike` on an encrypted column as an approximate shim that delegates to
-`.matches()` (see "v3 encoding details" below).
+transforms (`.order/.limit/.range/.single/.maybeSingle/.abortSignal/.throwOnError`
+— `.csv()` is declared but always throws, since PostgREST serializes rows
+before the wrapper can decrypt them),
+plus `.withLockContext(lockContext)` and `.audit(config)`. For free-text
+search it exposes `.matches()` (fuzzy bloom token search) on encrypted
+columns, keeps `.contains()` for native (exact) containment on plaintext
+columns, and treats `like`/`ilike` on an encrypted column as an approximate
+shim that delegates to `.matches()` (see "v3 encoding details" below).
### Typing (v3)
@@ -68,14 +75,14 @@ tables against the database at construction:
```typescript
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
-import { encryptedSupabaseV3 } from '@cipherstash/stack-supabase'
+import { encryptedSupabase } from '@cipherstash/stack-supabase'
const users = encryptedTable('users', {
email: types.TextSearch('email'), // public.eql_v3_text_search
amount: types.IntegerOrd('amount'), // public.eql_v3_integer_ord
})
-const es = await encryptedSupabaseV3(
+const es = await encryptedSupabase(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!,
{ schemas: { users } },
@@ -132,17 +139,14 @@ The domains use SQL-standard type names (`integer`, `smallint`, `real`,
### Install EQL
```bash
-# v2 (default)
stash eql install --supabase
-
-# v3
-stash eql install --eql-version 3 --supabase
```
-For **v2**, `--supabase` selects the opclass-stripped bundle (operator
-classes / families require superuser, which Supabase does not grant) and
-applies the schema grants for `anon`, `authenticated`, and `service_role`.
-Without the grants, encrypted queries fail with `42501`.
+`stash` no longer installs EQL v2 or mutates its Proxy configuration. To
+recover or restore an existing v2 database dump, use the upstream
+[EQL 2.3.1 release SQL](https://github.com/cipherstash/encrypt-query-language/releases/tag/eql-2.3.1),
+then migrate maintained deployments to v3 domains. Do not use v2 for new
+authoring.
For **v3**, since eql-3.0.0 there is **one** SQL artifact for every target —
no separate Supabase variant. The bundle's only superuser-requiring
@@ -163,9 +167,9 @@ denied for schema eql_v3_internal`).
### Exposed schemas
-**v2 (manual, required):** for a bare `col term` filter to reach the
-custom operator, `eql_v2` must be on PostgREST's request-time search_path —
-add it to **Dashboard → Settings → API → Exposed schemas**
+**Legacy v2 recovery deployments only:** for a bare `col term` filter to
+reach the custom operator, `eql_v2` must be on PostgREST's request-time
+search_path — add it to **Dashboard → Settings → API → Exposed schemas**
([Supabase custom-schemas guide](https://supabase.com/docs/guides/api/using-custom-schemas)).
> **Warning — silent fallback (v2).** If the schema is not exposed, the
diff --git a/docs/superpowers/specs/2026-07-24-a2-fail-closed-rewrite-design.md b/docs/superpowers/specs/2026-07-24-a2-fail-closed-rewrite-design.md
new file mode 100644
index 000000000..f44fa80f4
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-24-a2-fail-closed-rewrite-design.md
@@ -0,0 +1,203 @@
+# A-2 — fail-closed inversion for the encrypted ALTER COLUMN rewrite
+
+**Date:** 2026-07-24
+**Addresses:** A-2 in `.work/2026-07-24-eql-v2-removal-verification.md`
+**Base:** `05b7bf07` (A-1/A-3 tokenizer fix)
+**Affects:** `packages/cli/src/commands/db/rewrite-migrations.ts`, `packages/wizard/src/lib/rewrite-migrations.ts` (the same file twice)
+
+## Problem
+
+`rewriteEncryptedAlterColumns` rewrites an in-place `ALTER COLUMN … SET DATA TYPE
+` into an ADD + DROP + RENAME sequence. That sequence is
+equivalent to DROP + ADD: it fixes the column's type but destroys its contents.
+
+The sweep decides whether to rewrite by consulting a corpus-wide index of columns
+the migrations give an *encrypted* type (`indexEncryptedColumns`). A column found
+there is skipped as `already-encrypted`, because dropping it would destroy
+ciphertext with no plaintext left to backfill from. **Everything else is
+rewritten** — including a column the corpus never mentions at all.
+
+That default is fail-open. A column absent from the index is not known to be
+plaintext; it is simply unknown. Two reproduced triggers, both from the
+verification doc:
+
+- **(a) Lone ALTER, no source declaration.** The corpus contains the ALTER and
+ nothing that declares the column. Rewritten today: 1 live `DROP COLUMN`.
+- **(b) Cross-directory split.** The declaration lives in `drizzle/` and the
+ ALTER in `migrations/`. The index is built per directory, so the sweep of
+ `migrations/` never sees the declaration. Rewritten today: 1 live `DROP
+ COLUMN` — and the dropped column is already typed `eql_v3_text_eq`, i.e.
+ ciphertext.
+
+Trigger (b) is not contrived. `post-agent.ts:163` ships with
+`DRIZZLE_OUT_DIRS = ['drizzle', 'migrations', 'src/db/migrations']`, so scanning
+multiple directories with a per-directory index *is* the default configuration.
+
+## Decision
+
+Invert the default. Rewrite only a column the corpus positively declares and does
+not give an encrypted type. Anything unknown is skipped and reported.
+
+Two alternatives were considered and rejected:
+
+- **Widen the index across directories** (build one corpus index in
+ `sweepMigrationDirs` and pass it into each per-directory rewrite). Fixes (b)
+ with a sharper `already-encrypted` message, but unioning unrelated migration
+ histories can over-detect *plaintext*, which is itself fail-open. Rejected:
+ fail-closed already makes (b) safe, and this trades a better message for a new
+ fail-open surface.
+- **Require the declaration in the same file.** A column created in
+ `0001_init.sql` and altered in `0007_encrypt.sql` is the normal case, so this
+ would stop rewriting anything.
+
+## Mechanism
+
+The encrypted side is already precise: it matches against the known domain list
+in `ENCRYPTED_DOMAIN`. So "plaintext" needs no type classification — it is the
+residue. A column the corpus **declares** but that is not in the encrypted set is
+plaintext.
+
+That turns "what type is this column?" (a SQL parse) into "does a declaration for
+this column exist?" (a name match in a position the code already isolates). No
+comma splitting, no paren-depth tracking, no quote state machine.
+
+`indexEncryptedColumns` becomes `indexColumnDeclarations`, one traversal
+returning `{ encrypted, declared }`. `encrypted` is populated exactly as today —
+the existing broad scan is deliberately over-detecting and must not regress.
+`declared` is populated from two positions:
+
+1. **Inside a `CREATE TABLE` body**, already captured as group 3 of
+ `CREATE_TABLE_RE`: scan `"([^"]+)"\s+["a-z]` for declared names.
+2. **`ALTER TABLE … ADD COLUMN "col" `**: generalise
+ `ADD_ENCRYPTED_COLUMN_RE` to accept any type with the same tail.
+
+`RENAME COLUMN "a" TO "b"` propagates `declared` the way it already propagates
+`encrypted`.
+
+The `\s+["a-z]` tail is what makes the name match a declaration rather than a
+mention. A type token always begins with a letter or a double quote, so
+`"email" text` and `"email" "public"."eql_v3_text_search"` match, while these do
+not — the name is followed by `)`, `,` or an operator:
+
+```sql
+PRIMARY KEY ("id", "name")
+FOREIGN KEY ("org_id") REFERENCES "orgs"("id")
+CHECK ("age" > 0)
+UNIQUE("email")
+```
+
+**The scan must stay anchored to those two positions.** A whole-file scan would
+let `ALTER COLUMN "email" SET DATA TYPE …` match its own `"email" SET` and
+declare the very column it is asking about — a self-fulfilling fail-open.
+
+**Known false positive, accepted.** In
+`CONSTRAINT "users_email_unique" UNIQUE("email")` the constraint *name* is
+followed by whitespace and a letter, so it registers as a declared column. It is
+inert unless a constraint name exactly equals the name of a column that is also
+encrypted and undeclared; drizzle's `__unique` convention makes that
+contrived. Documented in the docstring rather than engineered around.
+
+**Corpus-wide, not migration-ordered.** As with the existing encrypted index, a
+declaration in a *later* migration than the ALTER still counts. This is a
+pre-existing property, not introduced here.
+
+### Decision order in the rewrite callback
+
+Unchanged first step, two steps after it:
+
+1. `isInsideCommentOrString(original, offset)` → return the match untouched.
+2. In `encrypted` → `skip(…, 'already-encrypted')`.
+3. Not in `declared` → `skip(…, 'source-unknown')`.
+4. Otherwise rewrite.
+
+Step 2 must precede step 3 so a column that is both declared and encrypted — the
+output of a previous sweep, which emits `ADD COLUMN ` plus
+`RENAME TO ` — still reports `already-encrypted`, the more specific
+and more actionable reason.
+
+`isInsideCommentOrString` is not touched: it is the subject of A-1/A-3, already
+landed in the base commit.
+
+## Skip reason
+
+`SkipReason` gains `'source-unknown'`. `describeSkipReason` has no `default` arm,
+so the compiler forces the new arm to be written. All three consumers
+(`install.ts:660`, `eql/migration.ts:248`, `post-agent.ts:192`) render
+`describeSkipReason(reason)` verbatim and need no edit.
+
+The text names both causes, because the user's remedy differs:
+
+> the sweep could not find where this column was declared in this migration
+> directory, so it cannot tell a plaintext column (safe to rewrite) from one that
+> already holds ciphertext (where the rewrite would DROP it). Usually the
+> column's `CREATE TABLE` / `ADD COLUMN` lives in a different directory, or the
+> migration history was squashed. Check the column's current type in the
+> database: if it is plaintext and the table is empty, apply the
+> ADD/DROP/RENAME by hand; if it already holds ciphertext, use the staged
+> `stash encrypt` lifecycle instead
+
+## Blast radius
+
+This is a behaviour change for correct corpora, not only for the bug. Any project
+whose swept directory does not contain the column's declaration — squashed
+migrations, a baseline pulled with `drizzle-kit pull`, a schema bootstrapped from
+a hand-written SQL file — stops being rewritten and starts being flagged for
+review. That is the trade being bought, and it is the correct direction: the cost
+of a false skip is a statement the user fixes by hand, and the cost of a false
+rewrite is irrecoverable data.
+
+## Testing
+
+Most existing tests in both files use a bare ALTER with no `CREATE` anywhere and
+assert a rewrite; under the inversion they would all become `source-unknown`.
+Rather than rewrite each fixture, add a helper that writes a `0000_init.sql`
+declaring the plaintext source column. It sorts first and is never rewritten, so
+`rewritten`/`skipped` assertions stay as they are and the diff is one line per
+test.
+
+New cases, in **both** test files:
+
+- Lone ALTER, no declaration anywhere → `skipped` with `source-unknown`, file
+ unchanged on disk, zero `DROP COLUMN`.
+- Declaration in a sibling file in the same directory → rewritten (the
+ non-regression case).
+- Declaration living in the `options.skip` file → still counts as declared.
+- `RENAME COLUMN` propagates declared-ness from the original name.
+- A column named only inside `PRIMARY KEY (…)` / `REFERENCES "t"("col")` →
+ **not** declared, so `source-unknown`.
+- A column that is both declared and encrypted → still `already-encrypted`, not
+ `source-unknown` (pins the ordering of steps 2 and 3).
+
+Wizard file only:
+
+- The cross-directory split from the verification doc, driven through
+ `sweepMigrationDirs`: encrypted declaration in `drizzle/`, ALTER in
+ `migrations/` → `source-unknown`, zero `DROP COLUMN` in the output.
+
+## Dual-copy sync
+
+The two files are the same file twice, differing only in the wizard's
+`sweepMigrationDirs` / `DirRewriteResult`, its attribution string
+(`@cipherstash/wizard` vs `stash`), and a few comment blocks. Apply one fix
+twice, then diff the two whitespace-normalised files to confirm nothing new
+diverged.
+
+## Docs and changeset
+
+- `skills/stash-cli/SKILL.md:393` and `skills/stash-drizzle/SKILL.md:64` both
+ state that each such statement is rewritten. Both need the qualifier that the
+ rewrite now requires the column's declaration to be present in the swept
+ directory, and otherwise flags the statement for review.
+- Extend `.changeset/rewriter-never-drops-ciphertext.md` rather than adding a
+ second changeset: it already ships the sibling `already-encrypted` behaviour
+ for the same component in the same release, and two changesets would describe
+ overlapping behaviour. Both `stash` and `@cipherstash/wizard` are already
+ listed.
+
+## Out of scope
+
+- Any change to `isInsideCommentOrString` (A-1/A-3, landed in the base commit).
+- Sharing a corpus index across directories in `sweepMigrationDirs` (rejected
+ above).
+- The near-miss scan (`NEAR_MISS_RE`) and its `unrecognised-form` reason, which
+ are unaffected.
diff --git a/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md
new file mode 100644
index 000000000..c8c1cdb93
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-24-encryption-signature-and-typecheck-gates-design.md
@@ -0,0 +1,275 @@
+# `Encryption` signature fixes and typecheck gates
+
+**Date:** 2026-07-24
+**Branch:** `fix/encryption-signature-and-typecheck-gates` (off `8b3f0b15`)
+**Addresses:** A-4, A-6, A-7, C-1 from `.work/2026-07-24-eql-v2-removal-verification.md`
+**Issue:** #778 (review remediation for #772)
+
+## Why these four, and why not more
+
+A-6 and A-7 exist because `Encryption` is overloaded, and it is overloaded because there
+are two kinds of client: the typed EQL v3 one and the nominal one that EQL v2 and loose
+introspection-derived schemas need. Two overloads means two discriminators that can
+disagree — overload resolution reads the `config` argument, the runtime reads the
+`schemas` argument (`encryption/index.ts:947`). Remove EQL v2 and there is one client,
+one signature, and both defects delete themselves.
+
+So this change deliberately does **not** attempt the type-level repair the verification
+doc explores (a single generic signature returning `ClientFor`, a discriminated-union
+`WireConfig`, `ConfigFor`). That machinery exists only to make two clients coexist
+safely, and it would be deleted by the v2 removal that motivates it. It is issue #637 and
+it belongs with PRs 9–12.
+
+What is left is the subset that is either independent of the v2 removal (A-4, C-1) or
+cheap and non-throwaway (A-6, A-7).
+
+## A-4 — non-tuple schema arrays are rejected
+
+### Problem
+
+`Encryption({ schemas })` accepts only an array *literal*. Every indirect form fails with
+`TS2769`:
+
+```ts
+export const all: AnyV3Table[] = [users, orders] // shared module
+await Encryption({ schemas: all }) // TS2769
+```
+
+Also broken: `ReadonlyArray` (the type `prisma-next` exposes publicly),
+push-built arrays, spreads, and unannotated `const all = [...]`.
+
+The constraint at `encryption/index.ts:872-877` is a non-empty *tuple*:
+
+```ts
+export function Encryption<
+ const S extends readonly [AnyV3Table, ...AnyV3Table[]],
+>(config: { schemas: S; config?: V3ClientConfig }): Promise>
+```
+
+It was narrowed to a tuple by `7e0092f3` for one reason: `readonly AnyV3Table[]` admits
+`readonly []`, so `Encryption({ schemas: [] })` type-checked and then threw at runtime.
+
+This is a live break on a released surface. `prisma-next` already had to work around it
+with a destructure-and-respread through three coupled edits in `from-stack-v3.ts`; any
+customer with schema-building indirection hits the same wall.
+
+### Design
+
+Widen the type parameter to the array and move the non-emptiness check to the property, so
+`[]` is rejected without the tuple constraining every other form:
+
+```ts
+type NonEmptyV3 = S['length'] extends 0 ? never : S
+
+export function Encryption(config: {
+ schemas: NonEmptyV3
+ config?: V3ClientConfig
+}): Promise>
+```
+
+`schemas` must resolve through `NonEmptyV3` rather than being wrapped in a conditional
+alias applied to the whole config — wrapping defeats `const` inference and degrades the
+tuple to an array, losing per-column typing on the literal path.
+
+The runtime throw at `encryption/index.ts:891` stays as the backstop for JavaScript callers.
+
+### Acceptance
+
+A `.test-d.ts` probe pinning both directions:
+
+- compile: inline literal, shared `AnyV3Table[]`, `ReadonlyArray`, push-built,
+ spread, unannotated `const`;
+- still error: `{ schemas: [] }`, wrong plaintext type for a column's domain, a table that
+ is not a member of the registered tuple.
+
+## A-6 — `ReturnType` resolves to the nominal client
+
+### Problem
+
+TypeScript's `ReturnType` reads the *last* overload, which is the nominal one. So
+`Awaited>` yields `EncryptionClient` even for an all-v3
+schema set, and assigning the real client to it fails:
+
+```text
+Type 'TypedEncryptionClient<…>' is missing the following properties
+from type 'EncryptionClient': client, encryptConfig, init
+```
+
+Overload order cannot fix this — whichever signature is last wins, so one of the two forms
+is always mis-resolved. Reordering additionally destroys the typed client, because a v3
+table structurally satisfies `BuildableTable` and so matches the nominal overload.
+
+This is a regression from the released surface, not a pre-existing wart: all 15 instances
+were introduced by `d7ff8471`, which turned a single-signature `EncryptionV3` into an alias
+of an overloaded `Encryption`. `packages/bench/src/drizzle/setup.ts:38-45` already carries a
+hand-rolled workaround.
+
+15 sites, none visible to CI:
+
+| Package | Sites |
+|---|---|
+| `packages/stack` | `__tests__/dynamodb/encrypted-dynamodb-v3.test.ts` (×3), `__tests__/encrypt-lock-context-guards.test.ts`, `__tests__/encrypt-query-searchable-json.test.ts`, `__tests__/encrypt-query-stevec.test.ts`, `integration/shared/{matrix-crypto,matrix-sql,schema-pg,schema-v3-client}.integration.test.ts` |
+| `packages/stack-drizzle` | `integration/{adapter,json-adapter}.ts`, `integration/{lock-context,null-persistence,relational}.integration.test.ts` |
+
+### Design
+
+No signature change. `EncryptionClientFor` (`encryption/v3.ts:399-402`) already exists and
+is the correct idiom — the prior-art survey in the verification doc found that no library
+dispatches a schema-dependent result type through `ReturnType`; they all expose a named
+extraction type (`z.infer`, `typeof x.infer`, hono's `Client`). Convert the call sites to
+it and document it as *the* way to name the client.
+
+**`EncryptionClientFor` must be widened in step with A-4.** It carries the same narrow tuple
+guard:
+
+```ts
+S extends readonly [AnyV3Table, ...AnyV3Table[]] ? TypedEncryptionClient : EncryptionClient
+```
+
+Left alone, `EncryptionClientFor` falls through to `EncryptionClient` —
+so the type A-6 tells callers to use would silently hand back the nominal client for exactly
+the non-tuple schemas A-4 just enabled. It becomes:
+
+```ts
+export type EncryptionClientFor =
+ S extends readonly AnyV3Table[]
+ ? S['length'] extends 0
+ ? EncryptionClient
+ : TypedEncryptionClient
+ : EncryptionClient
+```
+
+The `readonly []` arm must be checked *inside* the v3 branch and before the tuple is used:
+`never extends X` is true, so an empty tuple otherwise satisfies "all elements are v3".
+
+Erased sites that pass `[schema as never]` (the generic `stack-drizzle` integration adapters)
+are declared `EncryptionClientFor`, which resolves to
+`TypedEncryptionClient` and accepts `TypedEncryptionClient`
+by method bivariance.
+
+`encryption-overloads.test-d.ts:84-88` currently asserts the defect as expected behaviour and
+is green in CI. It is rewritten to assert `EncryptionClientFor` resolves correctly instead.
+
+## A-7 — the type says nominal, the runtime hands back typed
+
+### Problem
+
+Overload resolution matches on the `config` argument; the runtime picks its client by
+inspecting the `schemas` (`encryption/index.ts:947`, `isV3Only && eqlVersion === 3`). They
+disagree whenever a config is hoisted into a `ClientConfig`-typed variable:
+
+```ts
+const cfg: ClientConfig = {} // not assignable to V3ClientConfig
+const c = await Encryption({ schemas: [users], config: cfg })
+// type: EncryptionClient runtime: TypedEncryptionClient
+c.init(...) // TypeError: init is not a function
+```
+
+`ClientConfig.eqlVersion` is `2 | 3`; the v3 overload requires `eqlVersion?: 3`. So the
+variable form selects the nominal overload while the runtime still returns the typed client.
+This bites whenever the variable's runtime `eqlVersion` is anything but `2` — `{}`,
+`{ keyset }`, `{ authStrategy }`: the common case.
+
+Measured blast radius: `init` is the **only** member on `EncryptionClient.prototype` absent
+from the typed client at runtime. The other ten are all present.
+
+### Design
+
+Add an `init` passthrough to the object `typedClient()` returns, declared `@internal` on
+`TypedEncryptionClient` so `satisfies` still checks the shape:
+
+```ts
+init: (config) => client.init(config),
+```
+
+This reduces the runtime gap to zero. What remains is a silent capability downgrade — the
+type says nominal, so the caller loses the typed surface with no diagnostic. No type-level
+design closes that: the runtime inspects values while the type inspects an erasable static
+type. It ends when v2 removal collapses the two clients into one.
+
+### Acceptance
+
+A unit test reproducing the exact shape (config declared `ClientConfig`, v3 schemas,
+`EncryptionClient.prototype.init` stubbed so no credentials are needed) that fails with
+`TypeError: client.init is not a function` before the change and passes after.
+
+## C-1 — typecheck gates
+
+### Problem
+
+The root `typecheck` gate for `examples/*` already exists (`.github/workflows/tests.yml:155-161`,
+added by `5fab1cf6`) — the verification doc refutes the original framing. The remaining gap is
+the surfaces nothing typechecks at all.
+
+Measured after a full `pnpm run build` (most apparent failures were unbuilt workspace deps
+resolving to `dist/*.d.ts`, not real errors):
+
+| Surface | Errors | Has script |
+|---|---|---|
+| `packages/bench` | 0 | no |
+| `packages/migrate` | 0 | no |
+| `packages/prisma-next` | 0 | `typecheck` |
+| `packages/test-kit` | 0 | `test:types` |
+| `packages/wizard` | 0 | `typecheck` |
+| `examples/basic` | 0 | `typecheck` (gated) |
+| `examples/prisma` | 0 | `typecheck` (never invoked) |
+| `e2e` | 0 | no |
+| `packages/nextjs` | 2 | no |
+| `packages/stack-supabase` | 11 | `test:types` (narrower config) |
+| `packages/cli` | 21 | no |
+| `packages/stack-drizzle` | 69 | `test:types` (narrower config) |
+| `packages/stack` | 168 | `test:types` (narrower config) |
+
+The three `test:types` scripts run `vitest --typecheck.only` against a `tsconfig.typecheck.json`
+whose `include` is `__tests__/**/*.test-d.ts` — so the package's real `tsconfig.json`, which
+covers `src`, `__tests__/**/*.test.ts` and `integration/**`, is never checked.
+
+### Design
+
+Gate what is green, and record what is not with its count rather than silently skipping it.
+
+1. Add `typecheck` scripts (`tsc --noEmit -p tsconfig.json`) to `bench`, `migrate`, `nextjs`,
+ `e2e`; keep the existing ones on `prisma-next`, `wizard`, `examples/*`.
+2. One CI job running the eight green surfaces plus `nextjs`, after `pnpm run build` (they
+ resolve workspace deps through `dist/*.d.ts`).
+3. Fix `packages/nextjs`'s 2 errors: `vi.Mock` is used as a *type* in
+ `__tests__/nextjs.test.ts:68,81`; it needs `import type { Mock } from 'vitest'`.
+4. Add `"outputs": ["dist/**"]` to `turbo.json`'s `build` task. It currently declares none,
+ so a cached build restores nothing and the `examples/basic` gate — which typechecks
+ against `packages/stack/dist/*.d.ts` — holds today only because fresh CI runners have no
+ cache.
+5. Record `stack-supabase` (11), `cli` (21), `stack-drizzle` (69), `stack` (168) as
+ documented follow-ups.
+
+### Deliberately out of scope
+
+Making `stack`, `stack-drizzle`, `stack-supabase` and `cli` green. 51 of `stack-drizzle`'s 69
+and all 11 of `stack-supabase`'s are one root cause — `spec.indexes.unique` / `.ore` / `.ope`
+against `V3_MATRIX` in `packages/test-kit`'s catalog, where `typedEntries` collapses the spec
+to a union whose members do not all carry those keys. A single fix in `test-kit` likely clears
+~62 of the ~80 errors across those two packages. That is the highest-leverage next step and it
+is its own change.
+
+## Interactions and ordering
+
+A-4 and A-6 must land together: widening `Encryption` without widening `EncryptionClientFor`
+leaves the documented idiom resolving to the wrong client.
+
+A-7 is independent but shares the same files, and it removes `init` from A-6's `TS2739`
+message (leaving only the private `client` / `encryptConfig` fields), so it lands first to
+keep the A-6 diffs legible.
+
+C-1 is independent of all three. It is sequenced last because the A-6 conversions remove 15
+of the errors in the two packages it reports counts for.
+
+## Other deliverables
+
+- Changeset: `@cipherstash/stack` **minor** — A-4 widens a released signature and A-7 adds a
+ member to `TypedEncryptionClient`. `@cipherstash/stack-drizzle` patch for the integration
+ adapter conversions.
+- Delete the migration paragraph in `.changeset/stack-audit-on-decrypt.md` telling callers to
+ narrow `AnyV3Table[]` to `readonly [AnyV3Table, ...AnyV3Table[]]` — A-4 makes that advice
+ obsolete, and it was never correct for `ReadonlyArray` anyway.
+- Skills sweep: `skills/stash-encryption/SKILL.md` for any `ReturnType`
+ guidance and for schema-array examples that the widening now permits.
+- `packages/stack/README.md` and `packages/prisma-next` docs for the same.
diff --git a/e2e/README.md b/e2e/README.md
index 3e2e625ac..c3ee77197 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -25,6 +25,7 @@ pnpm --filter @cipherstash/e2e exec vitest run tests/package-managers.e2e.test.t
| `tests/package-managers.e2e.test.ts` | The `init` providers and the wizard binary render `bunx`/`pnpm dlx`/`yarn dlx`/`npx` based on detected package manager. |
| `tests/supply-chain.e2e.test.ts` | Lockfile/registry/CODEOWNERS controls from `skills/stash-supply-chain-security` are still enforced. |
| `tests/prisma-example-readme.e2e.test.ts` | Parses `examples/prisma/README.md`'s "Run it" section and asserts every command (skipping `stash auth login`) exits 0. |
+| `tests/skill-map-parity.e2e.test.ts` | The CLI's and the wizard's `SKILL_MAP` install the same skills for each equivalent integration. Lives here because this is the only workspace declaring both packages. |
## Auth-dependent suites
diff --git a/e2e/package.json b/e2e/package.json
index 7fd7205eb..0d328cec2 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -5,17 +5,18 @@
"description": "End-to-end tests that exercise built CipherStash binaries and cross-package behaviour.",
"type": "module",
"scripts": {
- "test:e2e": "vitest run"
+ "test:e2e": "vitest run",
+ "typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"stash": "workspace:*",
- "@cipherstash/protect": "workspace:*",
"@cipherstash/stack": "workspace:*",
"@cipherstash/wizard": "workspace:*"
},
"devDependencies": {
"@types/semver": "7.7.1",
"semver": "^7.8.0",
+ "typescript": "catalog:repo",
"vitest": "catalog:repo",
"yaml": "^2.9.0"
}
diff --git a/e2e/tests/package-managers.e2e.test.ts b/e2e/tests/package-managers.e2e.test.ts
index cf5e0951f..498f929cf 100644
--- a/e2e/tests/package-managers.e2e.test.ts
+++ b/e2e/tests/package-managers.e2e.test.ts
@@ -25,10 +25,9 @@ const RUNNER: Record = {
const BIN = {
cli: resolve(REPO_ROOT, 'packages/cli/dist/bin/stash.js'),
wizard: resolve(REPO_ROOT, 'packages/wizard/dist/bin/wizard.js'),
- protect: resolve(REPO_ROOT, 'packages/protect/dist/bin/stash.js'),
- // The legacy @cipherstash/drizzle `generate-eql-migration` bin is gone with
- // the package (protect sunsets at 1.0; @cipherstash/stack-drizzle is the
- // successor).
+ // The legacy @cipherstash/protect `stash` bin and the @cipherstash/drizzle
+ // `generate-eql-migration` bin are both gone with their packages
+ // (@cipherstash/stack and @cipherstash/stack-drizzle are the successors).
} as const
const UA: Record = {
@@ -56,7 +55,7 @@ describe('CLI init providers — package-manager-aware Next Steps', () => {
label: 'drizzle',
create: createDrizzleProvider,
firstStep: (r) =>
- `Set up your database: ${r} stash eql install --drizzle`,
+ `Set up your database: ${r} stash eql migration --drizzle`,
},
{
label: 'supabase',
diff --git a/e2e/tests/skill-map-parity.e2e.test.ts b/e2e/tests/skill-map-parity.e2e.test.ts
new file mode 100644
index 000000000..3766a48d1
--- /dev/null
+++ b/e2e/tests/skill-map-parity.e2e.test.ts
@@ -0,0 +1,60 @@
+/**
+ * The CLI and the wizard each own a `SKILL_MAP`, and the two must agree: both
+ * install skills into the same `.claude/skills` / `.codex/skills` directory of
+ * the same user project, so a user who ran `npx stash init` and a user who ran
+ * the wizard would otherwise end up with different guidance for the same
+ * integration. The maps drift silently — nothing imports one from the other.
+ *
+ * This assertion lives HERE, not in either package's own suite, because it is
+ * the only workspace that declares both `stash` and `@cipherstash/wizard` as
+ * dependencies. It reads source rather than a built binary (the same idiom as
+ * `package-managers.e2e.test.ts` Suite A) so it needs no build step, and
+ * `pnpm --filter @cipherstash/e2e run typecheck` compiles `tests/**` — which
+ * makes a relocated module a compile error in CI rather than a module-not-found
+ * inside an unrelated package's unit run.
+ *
+ * The integration names differ by design: the CLI names the packages
+ * (`prisma-next`, `postgresql`), the wizard names the user's situation
+ * (`prisma`, `generic`). The mapping below is the whole contract.
+ */
+
+import { describe, expect, it } from 'vitest'
+
+import { SKILL_MAP as CLI_SKILL_MAP } from '../../packages/cli/src/commands/init/lib/install-skills.js'
+import type { Integration as CliIntegration } from '../../packages/cli/src/commands/init/types.js'
+import { SKILL_MAP as WIZARD_SKILL_MAP } from '../../packages/wizard/src/lib/install-skills.js'
+import type { Integration as WizardIntegration } from '../../packages/wizard/src/lib/types.js'
+
+// Exhaustive over the wizard union: a new wizard integration added without a
+// CLI counterpart fails to compile here, rather than shipping a divergent set.
+const EQUIVALENT: Record = {
+ drizzle: 'drizzle',
+ supabase: 'supabase',
+ prisma: 'prisma-next',
+ generic: 'postgresql',
+}
+
+describe('CLI and wizard SKILL_MAP parity', () => {
+ it('installs the same skills for every equivalent integration', () => {
+ for (const [wizardName, cliName] of Object.entries(EQUIVALENT) as Array<
+ [WizardIntegration, CliIntegration]
+ >) {
+ expect(
+ WIZARD_SKILL_MAP[wizardName],
+ `${wizardName} (wizard) vs ${cliName} (cli)`,
+ ).toEqual(CLI_SKILL_MAP[cliName])
+ }
+ })
+
+ // Both unions are closed, so parity over `EQUIVALENT` is only complete while
+ // it covers every CLI integration too — a CLI-only integration would slip
+ // through the loop above unnoticed.
+ it('covers every integration on both sides', () => {
+ expect(Object.keys(WIZARD_SKILL_MAP).sort()).toEqual(
+ Object.keys(EQUIVALENT).sort(),
+ )
+ expect(Object.keys(CLI_SKILL_MAP).sort()).toEqual(
+ Object.values(EQUIVALENT).sort(),
+ )
+ })
+})
diff --git a/examples/basic/encrypt.ts b/examples/basic/encrypt.ts
index 140e22605..17f869fd5 100644
--- a/examples/basic/encrypt.ts
+++ b/examples/basic/encrypt.ts
@@ -1,9 +1,13 @@
import 'dotenv/config'
-import { Encryption, encryptedColumn, encryptedTable } from '@cipherstash/stack'
+import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
+// EQL v3: a column's query capabilities are fixed by the domain you pick —
+// there are no chainable capability tuners. `types.Text` is storage-only
+// (encrypt/decrypt, no queries), which is all this demo needs. Reach for
+// `types.TextEq` / `types.TextSearch` when you need to query the column.
export const users = encryptedTable('users', {
- name: encryptedColumn('name'),
+ name: types.Text('name'),
})
export const client = await Encryption({
diff --git a/examples/basic/index.ts b/examples/basic/index.ts
index 63bc282a6..b00ad13fa 100644
--- a/examples/basic/index.ts
+++ b/examples/basic/index.ts
@@ -1,7 +1,6 @@
import 'dotenv/config'
import readline from 'node:readline'
import { client, users } from './encrypt'
-import { createContact, getAllContacts } from './src/queries/contacts'
const rl = readline.createInterface({
input: process.stdin,
@@ -69,32 +68,6 @@ async function main() {
console.log('Bulk encrypted data:', bulkEncryptResult.data)
- // Demonstrate Supabase integration with CipherStash encryption
- console.log('\n--- Supabase Integration Demo ---')
-
- try {
- // Example: Create a new contact (would insert into encrypted Supabase table)
- console.log('Creating encrypted contact...')
- const newContact = {
- name: 'John Doe',
- email: 'john@example.com',
- role: 'Developer', // This field will be encrypted using CipherStash
- }
-
- // Note: This would fail in this basic example since we don't have actual Supabase setup
- // but shows the pattern for encrypted Supabase usage
- console.log('Contact data to encrypt:', newContact)
-
- // Example: Fetch contacts (would decrypt results from Supabase)
- console.log('Fetching encrypted contacts...')
- // const contacts = await getAllContacts()
- // console.log('Decrypted contacts:', contacts.data)
- } catch (error) {
- console.log(
- 'Supabase demo skipped (no actual Supabase connection in this basic example)',
- )
- }
-
rl.close()
}
diff --git a/examples/basic/package.json b/examples/basic/package.json
index 1c1de60b3..4a62550ad 100644
--- a/examples/basic/package.json
+++ b/examples/basic/package.json
@@ -4,7 +4,8 @@
"version": "1.2.14-rc.4",
"type": "module",
"scripts": {
- "start": "tsx index.ts"
+ "start": "tsx index.ts",
+ "typecheck": "tsc --project tsconfig.json --noEmit"
},
"keywords": [],
"author": "",
@@ -13,7 +14,7 @@
"dependencies": {
"@cipherstash/stack": "workspace:*",
"@cipherstash/stack-drizzle": "workspace:*",
- "@cipherstash/stack-supabase": "workspace:*",
+ "drizzle-orm": "^0.45.2",
"dotenv": "^17.4.2",
"pg": "8.22.0"
},
diff --git a/examples/basic/src/encryption/index.ts b/examples/basic/src/encryption/index.ts
index 4f78be08e..fca33a2bf 100644
--- a/examples/basic/src/encryption/index.ts
+++ b/examples/basic/src/encryption/index.ts
@@ -1,20 +1,15 @@
-import { Encryption } from '@cipherstash/stack'
-import {
- encryptedType,
- extractEncryptionSchema,
-} from '@cipherstash/stack-drizzle'
+import { Encryption } from '@cipherstash/stack/v3'
+import { extractEncryptionSchema, types } from '@cipherstash/stack-drizzle'
import { integer, pgTable, timestamp } from 'drizzle-orm/pg-core'
+// EQL v3 encrypted columns are concrete Postgres domains built with the
+// `types.*` factories. The domain fixes the query capabilities: `TextSearch`
+// is equality + order/range + free-text, the v3 equivalent of what the old v2
+// builder spelled `.equality().freeTextSearch()`.
export const usersTable = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
- email: encryptedType('email', {
- equality: true,
- freeTextSearch: true,
- }),
- name: encryptedType('name', {
- equality: true,
- freeTextSearch: true,
- }),
+ email: types.TextSearch('email'),
+ name: types.TextSearch('name'),
createdAt: timestamp('created_at').defaultNow(),
})
diff --git a/examples/basic/src/lib/supabase/encrypted.ts b/examples/basic/src/lib/supabase/encrypted.ts
deleted file mode 100644
index 147930987..000000000
--- a/examples/basic/src/lib/supabase/encrypted.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { encryptedSupabase } from '@cipherstash/stack-supabase'
-import { contactsTable, encryptionClient } from '../../encryption/index'
-import { createServerClient } from './server'
-
-const supabase = await createServerClient()
-export const eSupabase = encryptedSupabase({
- encryptionClient,
- supabaseClient: supabase,
-})
diff --git a/examples/basic/src/lib/supabase/server.ts b/examples/basic/src/lib/supabase/server.ts
deleted file mode 100644
index 967949176..000000000
--- a/examples/basic/src/lib/supabase/server.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { createClient } from '@supabase/supabase-js'
-
-export async function createServerClient() {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
- const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
-
- return createClient(supabaseUrl, supabaseKey)
-}
diff --git a/examples/basic/src/queries/contacts.ts b/examples/basic/src/queries/contacts.ts
deleted file mode 100644
index ad8979b39..000000000
--- a/examples/basic/src/queries/contacts.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { contactsTable } from '../encryption/index'
-import { eSupabase } from '../lib/supabase/encrypted'
-
-// Example queries using encrypted Supabase wrapper
-
-export async function getAllContacts() {
- const { data, error } = await eSupabase
- .from('contacts', contactsTable)
- .select('id, name, email, role') // explicit columns, no *
- .order('created_at', { ascending: false })
-
- return { data, error }
-}
-
-export async function getContactsByRole(role: string) {
- const { data, error } = await eSupabase
- .from('contacts', contactsTable)
- .select('id, name, email, role')
- .eq('role', role) // auto-encrypted
-
- return { data, error }
-}
-
-export async function searchContactsByName(searchTerm: string) {
- const { data, error } = await eSupabase
- .from('contacts', contactsTable)
- .select('id, name, email, role')
- .ilike('name', `%${searchTerm}%`) // auto-encrypted
-
- return { data, error }
-}
-
-export async function createContact(contact: {
- name: string
- email: string
- role: string
-}) {
- const { data, error } = await eSupabase
- .from('contacts', contactsTable)
- .insert(contact) // auto-encrypted
- .select('id, name, email, role')
- .single()
-
- return { data, error }
-}
-
-export async function updateContact(
- id: string,
- updates: Partial<{ name: string; email: string; role: string }>,
-) {
- const { data, error } = await eSupabase
- .from('contacts', contactsTable)
- .update(updates) // auto-encrypted
- .eq('id', id)
- .select('id, name, email, role')
- .single()
-
- return { data, error }
-}
-
-export async function deleteContact(id: string) {
- const { error } = await eSupabase
- .from('contacts', contactsTable)
- .delete()
- .eq('id', id)
-
- return { error }
-}
diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json
index 238655f2c..28110041f 100644
--- a/examples/basic/tsconfig.json
+++ b/examples/basic/tsconfig.json
@@ -23,5 +23,10 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
- }
+ },
+ // The `typecheck` gate must compile source, not build output. TypeScript's
+ // default `exclude` covers `node_modules` but NOT `dist`, and specifying
+ // `exclude` replaces that default — hence both. Enforced by
+ // `pnpm run lint:typecheck-scope`.
+ "exclude": ["dist", "node_modules"]
}
diff --git a/package.json b/package.json
index 75cbff39a..f25fe0c29 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
"license": "MIT",
"scripts": {
"build": "turbo build --filter './packages/*'",
- "build:js": "turbo build --filter './packages/protect' --filter './packages/nextjs'",
+ "build:js": "turbo build --filter './packages/nextjs'",
"changeset": "changeset",
"changeset:version": "changeset version",
"changeset:publish": "changeset publish",
@@ -29,7 +29,9 @@
"clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules",
"code:fix": "biome check --write",
"code:check": "biome check",
+ "lint:package-paths": "node scripts/lint-no-dead-package-paths.mjs",
"lint:runners": "node scripts/lint-no-hardcoded-runners.mjs",
+ "lint:typecheck-scope": "node scripts/lint-typecheck-scope.mjs",
"lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs",
"release": "pnpm run build && changeset publish",
"test": "turbo test --filter './packages/*'",
diff --git a/packages/bench/README.md b/packages/bench/README.md
index af5c3e38b..cd5409815 100644
--- a/packages/bench/README.md
+++ b/packages/bench/README.md
@@ -3,8 +3,9 @@
Performance / index-engagement benchmarks for stack integrations.
This package validates that each integration emits SQL that engages the canonical
-EQL functional indexes (`eql_v2.hmac_256`, `eql_v2.bloom_filter`, `eql_v2.ste_vec`)
-on a Supabase-shaped install (no operator classes). It runs in two layers:
+EQL functional indexes (`eql_v3.eq_term`, `eql_v3.match_term`,
+`eql_v3.to_ste_vec_query`) on a Supabase-shaped install (no operator classes).
+It runs in two layers:
1. **EXPLAIN-shape tests** (`__tests__/`) — vitest tests that assert on
`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output. Pass/fail. Cheap.
diff --git a/packages/bench/__benches__/drizzle/operators.bench.ts b/packages/bench/__benches__/drizzle/operators.bench.ts
index 4dd7c99af..c7079a5a9 100644
--- a/packages/bench/__benches__/drizzle/operators.bench.ts
+++ b/packages/bench/__benches__/drizzle/operators.bench.ts
@@ -44,8 +44,15 @@ describe('drizzle', () => {
await handle.db.select().from(benchTable).where(where)
})
- bench('like (prefix)', async () => {
- const where = (await ops.like(benchTable.encText, '%value-00000%')) as SQL
+ bench('matches (free-text)', async () => {
+ // A FULL seeded value, not the shared `value` prefix: every row is
+ // `value-<7 digits>`, so a bare `value` needle matches all 10k rows and the
+ // bloom index has nothing to narrow — the number would measure a full scan
+ // rather than the index path this bench exists to measure.
+ const where = (await ops.matches(
+ benchTable.encText,
+ 'value-0000042',
+ )) as SQL
await handle.db.select().from(benchTable).where(where)
})
diff --git a/packages/bench/__tests__/drizzle/operators.explain.test.ts b/packages/bench/__tests__/drizzle/operators.explain.test.ts
index 9aec76d4f..546f4fb2c 100644
--- a/packages/bench/__tests__/drizzle/operators.explain.test.ts
+++ b/packages/bench/__tests__/drizzle/operators.explain.test.ts
@@ -104,13 +104,19 @@ async function tryExplainWhere(name: string, where: SQL): Promise {
// --- #421: equality + array operators -------------------------------------
//
-// `bench_text_hmac_idx` (functional hash on eql_v2.hmac_256) is the expected
-// fast path. Pre-fix Drizzle emits bare `=` / `<>` / `IN (...)` which falls
-// back to seq scan. Post-fix it emits `eql_v2.hmac_256(col) =
-// eql_v2.hmac_256(value)` and the index scan kicks in.
+// `bench_text_hmac_idx` (functional hash on eql_v3.eq_term) is the expected
+// fast path. The v3 operators do NOT emit that expression directly — they emit
+// the wrapper `eql_v3.eq(col, $1::eql_v3.query_text_search)`. It engages the
+// index because the wrapper is `LANGUAGE sql IMMUTABLE STRICT` over a single
+// SELECT, so the planner inlines it to `eql_v3.eq_term(col) =
+// eql_v3.eq_term($1)` — and applies the same inlining to the stored index
+// expression, which is how the two meet. Break the inlinability (plpgsql body,
+// VOLATILE) and every assertion below silently degrades to a seq scan.
+// `scripts/__tests__/bench-index-expressions.test.mjs` pins that contract
+// against the shipped bundle without needing a database.
//
// `eq` and `inArray` are naturally high-selectivity (only a few rows match),
-// so the planner should pick the hmac index — assertion enforces it.
+// so the planner should pick the eq_term index — assertion enforces it.
//
// `ne` and `notInArray` are naturally low-selectivity (almost all rows match);
// even with the hmac index available the planner correctly chooses a seq
@@ -161,14 +167,12 @@ describe('#421: equality and array operators', () => {
// We don't yet know which call-shaped forms the planner inlines. Record plan
// shape; assertions land in a follow-up once #422 closes.
describe('#422: call-shaped operators (recorded, not asserted)', () => {
- it('records like / ilike plan shapes', async () => {
+ it('records matches plan shape', async () => {
await tryExplainWhere(
- 'like',
- (await ops.like(benchTable.encText, '%value-00000%')) as SQL,
- )
- await tryExplainWhere(
- 'ilike',
- (await ops.ilike(benchTable.encText, '%VALUE-00000%')) as SQL,
+ 'matches',
+ // A full seeded value — `value` alone is in every row, so the recorded
+ // plan would say nothing about the bloom index. See operators.bench.ts.
+ (await ops.matches(benchTable.encText, 'value-0000042')) as SQL,
)
})
@@ -190,20 +194,15 @@ describe('#422: call-shaped operators (recorded, not asserted)', () => {
)
})
- it('records jsonb operator plan shapes', async () => {
- for (const [name, build] of [
- [
- 'jsonbPathQueryFirst',
- () => ops.jsonbPathQueryFirst(benchTable.encJsonb, '$.idx'),
- ],
- ['jsonbGet', () => ops.jsonbGet(benchTable.encJsonb, '$.idx')],
- [
- 'jsonbPathExists',
- () => ops.jsonbPathExists(benchTable.encJsonb, '$.idx'),
- ],
- ] as const) {
- await tryExplainWhere(name, await build())
- }
+ it('records encrypted-JSONB plan shapes (contains / selector)', async () => {
+ await tryExplainWhere(
+ 'contains',
+ (await ops.contains(benchTable.encJsonb, { idx: 42 })) as SQL,
+ )
+ await tryExplainWhere(
+ 'selector.gt',
+ (await ops.selector(benchTable.encJsonb, '$.idx').gt(5000)) as SQL,
+ )
})
it('records ORDER BY plan shape (asc / desc)', async () => {
diff --git a/packages/bench/__unit__/seed-keys.test.ts b/packages/bench/__unit__/seed-keys.test.ts
new file mode 100644
index 000000000..82a356e11
--- /dev/null
+++ b/packages/bench/__unit__/seed-keys.test.ts
@@ -0,0 +1,50 @@
+/**
+ * The seed's row keys must be the keys model encryption MATCHES on.
+ *
+ * `extractEncryptionSchema` keys the encrypted-table column map by the Drizzle
+ * table's **JS property** (`encText`), while the column's DB name (`enc_text`)
+ * is what the builder carries. So `resolveEncryptColumnMap().columnPaths` — the
+ * list every model operation matches a row's fields against — is the property
+ * names. A row keyed by DB name matches nothing: `bulkEncryptModels` returns it
+ * untouched, with no failure, and the insert then puts PLAINTEXT into columns
+ * typed `eql_v3_*`.
+ *
+ * That is exactly what the v2 -> v3 port left behind, and nothing caught it:
+ * `tsc --noEmit` passes because `BenchPlaintextRow` was hand-written and agreed
+ * with itself, and CI only runs the `db-only` filter, which never seeds
+ * (#772 review, finding 12).
+ *
+ * Credential-free by construction — this compares two key sets and never
+ * reaches ZeroKMS.
+ */
+import { describe, expect, it } from 'vitest'
+import { encryptionBenchTable } from '../src/drizzle/setup.js'
+import { makePlaintextRow } from '../src/harness/seed.js'
+
+describe('bench seed rows are keyed for model encryption', () => {
+ // `resolveEncryptColumnMap` is internal, but it derives `columnPaths` as
+ // exactly `Object.keys(table.buildColumnKeyMap())` — read the same source.
+ const columnPaths = Object.keys(encryptionBenchTable.buildColumnKeyMap())
+
+ it('finds the encrypted columns (guards against an empty comparison)', () => {
+ expect(columnPaths.length).toBeGreaterThan(0)
+ })
+
+ it('emits a field for every encrypted column, under the matched key', () => {
+ const rowKeys = Object.keys(makePlaintextRow(0))
+
+ // Every encrypted column must be present in the row, or that column is
+ // silently never encrypted.
+ expect(rowKeys).toEqual(expect.arrayContaining([...columnPaths]))
+ })
+
+ it('emits no field the matcher would pass through as plaintext', () => {
+ const rowKeys = Object.keys(makePlaintextRow(0))
+ const unmatched = rowKeys.filter((key) => !columnPaths.includes(key))
+
+ expect(
+ unmatched,
+ `these seed fields match no encrypted column, so they would be inserted as plaintext into an eql_v3_* column: ${unmatched.join(', ')}`,
+ ).toEqual([])
+ })
+})
diff --git a/packages/bench/package.json b/packages/bench/package.json
index 1aa5b847a..46ef39c61 100644
--- a/packages/bench/package.json
+++ b/packages/bench/package.json
@@ -5,6 +5,8 @@
"description": "Performance / index-engagement benchmarks for stack integrations (Drizzle, encryptedSupabase, Prisma).",
"type": "module",
"scripts": {
+ "build": "tsc --noEmit",
+ "test:unit": "vitest run --config vitest.unit.config.ts",
"db:setup": "tsx src/cli/setup.ts",
"db:reset": "tsx src/cli/reset.ts",
"test:local": "vitest run",
@@ -17,6 +19,7 @@
"pg": "^8.22.0"
},
"devDependencies": {
+ "@cipherstash/test-kit": "workspace:*",
"@types/node": "^22.20.1",
"@types/pg": "^8.20.0",
"tsx": "catalog:repo",
diff --git a/packages/bench/sql/schema.sql b/packages/bench/sql/schema.sql
index cf861d2be..92e11c4e9 100644
--- a/packages/bench/sql/schema.sql
+++ b/packages/bench/sql/schema.sql
@@ -1,30 +1,51 @@
-- Bench fixture schema.
-- Single bench table covering text / int / jsonb encrypted columns plus the
--- three canonical EQL functional indexes: hmac_256 (hash), bloom_filter (GIN),
--- ste_vec (GIN).
+-- three canonical EQL v3 functional indexes.
--
--- We deliberately do NOT create the `eql_v2.encrypted_operator_class` btree
--- indexes that ore-benches uses. Encrypted composites for full-feature columns
--- (equality + match + ORE) blow past the 2704-byte btree page-size limit, and
--- those indexes don't exist on Supabase anyway — the bench's whole job is to
--- validate that the functional-index path works.
+-- Mirrors `src/drizzle/setup.ts`: the columns are concrete `eql_v3_*` Postgres
+-- domains (see `types.TextSearch` / `types.IntegerOrd` / `types.Json`). Install
+-- the domains and index-term functions first with `stash eql install --eql-version 3`.
+--
+-- Each index expression must be the expression the matching EQL v3 operator
+-- INLINES TO, not merely a term extractor for the same column. The adapter
+-- emits a function call (`eql_v3.eq(col, term)`, `eql_v3.matches(...)`,
+-- `col @> needle`); every one of those is `LANGUAGE sql IMMUTABLE STRICT`
+-- with a single-SELECT body, so the planner inlines it, and Postgres applies
+-- the same inlining to the stored index expression. The two only meet if the
+-- index is built on the inlined form:
+--
+-- eql_v3.eq(a, b) -> eql_v3.eq_term(a) = eql_v3.eq_term(b)
+-- eql_v3.matches(a, b) -> eql_v3.match_term(a) @> eql_v3.match_term(b)
+-- a @> b (json) -> eql_v3.to_ste_vec_query(a)::jsonb
+-- @> eql_v3.to_ste_vec_query(b)::jsonb
+--
+-- That last one is why the JSON index is NOT on `eql_v3.ste_vec(...)`: that
+-- function exists and the index builds happily, but nothing the adapter emits
+-- ever mentions it, so the index is dead weight. The bundle says so itself, on
+-- `eql_v3."@>"(eql_v3_json_search, eql_v3.query_json)`: "Inlines to native
+-- `jsonb @>` over `eql_v3.to_ste_vec_query(a)::jsonb`, so a functional GIN
+-- index on the same expression engages."
+--
+-- We deliberately do NOT create btree operator-class indexes: encrypted terms for
+-- full-feature columns blow past the 2704-byte btree page-size limit, and the
+-- bench's whole job is to validate that the functional-index path works.
DROP TABLE IF EXISTS bench;
CREATE TABLE bench (
id SERIAL PRIMARY KEY,
- enc_text eql_v2_encrypted NOT NULL,
- enc_int eql_v2_encrypted NOT NULL,
- enc_jsonb eql_v2_encrypted NOT NULL
+ enc_text public.eql_v3_text_search NOT NULL,
+ enc_int public.eql_v3_integer_ord NOT NULL,
+ enc_jsonb public.eql_v3_json_search NOT NULL
);
CREATE INDEX bench_text_hmac_idx
- ON bench USING hash (eql_v2.hmac_256(enc_text));
+ ON bench USING hash (eql_v3.eq_term(enc_text));
CREATE INDEX bench_text_bloom_idx
- ON bench USING gin (eql_v2.bloom_filter(enc_text));
+ ON bench USING gin (eql_v3.match_term(enc_text));
CREATE INDEX bench_jsonb_stevec_idx
- ON bench USING gin (eql_v2.ste_vec(enc_jsonb));
+ ON bench USING gin ((eql_v3.to_ste_vec_query(enc_jsonb)::jsonb));
ANALYZE bench;
diff --git a/packages/bench/src/drizzle/setup.ts b/packages/bench/src/drizzle/setup.ts
index 944ece227..9aa08bae5 100644
--- a/packages/bench/src/drizzle/setup.ts
+++ b/packages/bench/src/drizzle/setup.ts
@@ -1,9 +1,5 @@
-import { Encryption } from '@cipherstash/stack'
-import type { EncryptionClient } from '@cipherstash/stack/encryption'
-import {
- encryptedType,
- extractEncryptionSchema,
-} from '@cipherstash/stack-drizzle'
+import { type EncryptionClientFor, EncryptionV3 } from '@cipherstash/stack/v3'
+import { extractEncryptionSchema, types } from '@cipherstash/stack-drizzle'
import { drizzle } from 'drizzle-orm/node-postgres'
import { pgTable, serial } from 'drizzle-orm/pg-core'
import pg from 'pg'
@@ -12,48 +8,62 @@ import { getDatabaseUrl } from '../harness/db.js'
/**
* Drizzle schema for the bench table. Mirrors `sql/schema.sql`.
*
- * `id` is `serial`; the encrypted columns are `eql_v2_encrypted` composites
- * driven by `@cipherstash/stack-drizzle`'s `encryptedType`.
+ * `id` is `serial`; the encrypted columns are concrete `eql_v3_*` Postgres
+ * domains emitted by `@cipherstash/stack-drizzle`'s `types.*` factories.
*
- * Index config flags (`equality`, `freeTextSearch`, `orderAndRange`,
- * `searchableJson`) are deliberately all on — the bench needs to exercise
- * every query family that lands on the table.
+ * The domains are chosen to exercise every query family the bench lands on the
+ * table: `TextSearch` (equality + free-text + order/range), `IntegerOrd`
+ * (equality + order/range), and `Json` (encrypted-JSONB containment + selector).
*/
export const benchTable = pgTable('bench', {
id: serial('id').primaryKey(),
- encText: encryptedType('enc_text', {
- equality: true,
- freeTextSearch: true,
- orderAndRange: true,
- }),
- encInt: encryptedType('enc_int', {
- dataType: 'number',
- equality: true,
- orderAndRange: true,
- }),
- encJsonb: encryptedType<{ idx: number; group: number }>('enc_jsonb', {
- dataType: 'json',
- searchableJson: true,
- }),
+ encText: types.TextSearch('enc_text'),
+ encInt: types.IntegerOrd('enc_int'),
+ encJsonb: types.Json('enc_jsonb'),
})
/**
- * Encryption schema for the stack `Encryption()` client. Derived from the
- * Drizzle table above so the two can't drift apart.
+ * Encryption schema for the `EncryptionV3()` client. Derived from the Drizzle
+ * table above so the two can't drift apart.
*/
export const encryptionBenchTable = extractEncryptionSchema(benchTable)
+/**
+ * A seed row, keyed by the Drizzle table's **JS property** names.
+ *
+ * That is what model encryption matches on: `extractEncryptionSchema` keys the
+ * encrypted-table column map by property (`encText`), not by DB column name
+ * (`enc_text`). A row keyed by DB name matches nothing — `bulkEncryptModels`
+ * returns it untouched, with no failure, and the plaintext then goes into an
+ * `eql_v3_*` column (#772 review, finding 12).
+ *
+ * HAND-WRITTEN — it cannot be derived from `benchTable` without getting weaker,
+ * so `__unit__/seed-keys.test.ts` is the real drift guard, not the type.
+ * Drizzle's `InferInsertModel` describes the ENCRYPTED column (the custom type's
+ * `data` is the EQL envelope, not the plaintext) and degrades these three to
+ * optional `any`. Deriving from `encryptionBenchTable` is no better:
+ * `extractEncryptionSchema` returns the widened `AnyV3Table`, whose column map
+ * is an index signature — a type that admits `encTxt: 'x'` and `encText: 12345`
+ * alike, both of which the literal below rejects.
+ *
+ * Update it by hand when `benchTable` changes; the unit test will tell you.
+ */
export type BenchPlaintextRow = {
- enc_text: string
- enc_int: number
- enc_jsonb: { idx: number; group: number }
+ encText: string
+ encInt: number
+ encJsonb: { idx: number; group: number }
}
+/** The typed EQL v3 client this bench drives. */
+export type BenchEncryptionClient = EncryptionClientFor<
+ readonly [typeof encryptionBenchTable]
+>
+
export type BenchHandle = {
pgClient: pg.Client
pool: pg.Pool
db: ReturnType
- encryptionClient: EncryptionClient
+ encryptionClient: BenchEncryptionClient
}
/**
@@ -69,7 +79,9 @@ export async function buildBench(): Promise {
const db = drizzle(pool)
- const encryptionClient = await Encryption({ schemas: [encryptionBenchTable] })
+ const encryptionClient = await EncryptionV3({
+ schemas: [encryptionBenchTable],
+ })
return { pgClient, pool, db, encryptionClient }
}
diff --git a/packages/bench/src/harness/global-setup.ts b/packages/bench/src/harness/global-setup.ts
new file mode 100644
index 000000000..ffb318a6e
--- /dev/null
+++ b/packages/bench/src/harness/global-setup.ts
@@ -0,0 +1,31 @@
+// The `/install` subpath, NOT the barrel: the barrel reaches `needle-for.ts`,
+// which imports stack source through the `@/` alias, and bench has no
+// `stackSourceAlias` in its vitest config (it consumes stack through its
+// published dist/ exports). `install.ts` depends on node builtins only.
+import { installEqlV3 } from '@cipherstash/test-kit/install'
+import { getDatabaseUrl } from './db.js'
+
+/**
+ * Install EQL v3 once per run, before any suite applies `sql/schema.sql`.
+ *
+ * The fixture schema declares its columns as the concrete `eql_v3_*` domains, so
+ * the bundle has to be in the database before the first `CREATE TABLE`. Doing it
+ * here rather than in a CI-only step means the local `pnpm test:local` path and
+ * the CI path are the same path — the previous arrangement had the workflow rely
+ * on an EQL-v2-preinstalled image while the schema had already moved to v3, and
+ * nothing connected the two.
+ *
+ * Runs through the real `stash eql install`, matching the integration suites'
+ * harness (`@cipherstash/test-kit`'s `integration/global-setup.ts`): an installer
+ * regression fails here instead of hiding behind a test-only SQL apply.
+ *
+ * Unlike those suites this needs NO CipherStash credentials — `installEqlV3`
+ * wants only a database URL — which keeps `db-only.test.ts` the credential-free
+ * smoke test it is meant to be.
+ */
+export async function setup(): Promise {
+ // EXPLICIT, never inferred from the environment. `dbVariant()` would guess
+ // from `PGRST_URL`, and bench's compose stack happens to define a PostgREST
+ // it never talks to — a guess here would silently apply the Supabase grants.
+ await installEqlV3(getDatabaseUrl(), 'postgres')
+}
diff --git a/packages/bench/src/harness/seed.ts b/packages/bench/src/harness/seed.ts
index c25d21c7a..a145c6f6e 100644
--- a/packages/bench/src/harness/seed.ts
+++ b/packages/bench/src/harness/seed.ts
@@ -24,11 +24,12 @@ export function getTargetRows(): number {
return n
}
-function makePlaintextRow(idx: number): BenchPlaintextRow {
+/** Exported so `__unit__/seed-keys.test.ts` can check the row keys. */
+export function makePlaintextRow(idx: number): BenchPlaintextRow {
return {
- enc_text: `value-${String(idx).padStart(7, '0')}`,
- enc_int: idx,
- enc_jsonb: { idx, group: idx % 100 },
+ encText: `value-${String(idx).padStart(7, '0')}`,
+ encInt: idx,
+ encJsonb: { idx, group: idx % 100 },
}
}
@@ -53,25 +54,22 @@ export async function seed(
plaintexts.push(makePlaintextRow(rowsBefore + i))
}
- const encResult =
- await h.encryptionClient.bulkEncryptModels(
- plaintexts,
- encryptionBenchTable,
- )
+ const encResult = await h.encryptionClient.bulkEncryptModels(
+ plaintexts,
+ encryptionBenchTable,
+ )
if (encResult.failure) {
throw new Error(
`[bench:seed] bulkEncryptModels failed: ${encResult.failure.message}`,
)
}
- // bulkEncryptModels returns rows keyed by the encryptedTable column names
- // (snake_case here). Drizzle's `benchTable` uses camelCase TS field names —
- // remap before insert.
- const encRows = encResult.data.map((r) => ({
- encText: r.enc_text as unknown as string,
- encInt: r.enc_int as unknown as number,
- encJsonb: r.enc_jsonb as unknown as { idx: number; group: number },
- }))
+ // bulkEncryptModels returns rows under the SAME keys it matched on — the
+ // Drizzle table's JS property names — with EQL v3 envelopes as values. Those
+ // are the keys `db.insert()` wants, so there is nothing to remap. (The
+ // remap that used to sit here rewrote enc_text -> encText, which only
+ // appeared to work: nothing was ever encrypted, so it was moving plaintext.)
+ const encRows = encResult.data
for (let i = 0; i < encRows.length; i += INSERT_BATCH) {
const batch = encRows.slice(i, i + INSERT_BATCH)
diff --git a/packages/bench/vitest.config.ts b/packages/bench/vitest.config.ts
index c0f631c04..ac80b923e 100644
--- a/packages/bench/vitest.config.ts
+++ b/packages/bench/vitest.config.ts
@@ -3,6 +3,14 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['__tests__/**/*.test.ts'],
+ // Installs EQL v3 before any suite applies `sql/schema.sql`, which declares
+ // its columns as `eql_v3_*` domains. See the file for why this is not a
+ // CI-only step.
+ globalSetup: ['./src/harness/global-setup.ts'],
+ // `@cipherstash/test-kit` is consumed as unbuilt TypeScript source, so it
+ // must not be externalized — same reason the integration suites carry this
+ // (`packages/test-kit/src/integration/config.ts`).
+ server: { deps: { inline: [/packages\/test-kit/] } },
testTimeout: 300_000,
hookTimeout: 300_000,
pool: 'forks',
diff --git a/packages/bench/vitest.unit.config.ts b/packages/bench/vitest.unit.config.ts
new file mode 100644
index 000000000..6e8695ad8
--- /dev/null
+++ b/packages/bench/vitest.unit.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vitest/config'
+
+/**
+ * Bench checks that need neither a database nor credentials.
+ *
+ * The main config's `globalSetup` installs EQL v3 through the built CLI, so
+ * every suite under it requires `turbo run build --filter stash` and a live
+ * Postgres. That is right for the benchmarks, and wrong for a check that only
+ * compares two key sets — and "it was too expensive to run in CI" is exactly
+ * how the seed came to insert plaintext into `eql_v3_*` columns unnoticed
+ * (#772 review, finding 12).
+ */
+export default defineConfig({
+ test: {
+ include: ['__unit__/**/*.test.ts'],
+ },
+})
diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md
index 73795c3bc..aa65e4d00 100644
--- a/packages/cli/AGENTS.md
+++ b/packages/cli/AGENTS.md
@@ -6,11 +6,24 @@ This package has **two** Vitest configs. Run the right one for the change.
| Command | Config | Scope | Needs build? |
| --- | --- | --- | --- |
-| `pnpm --filter stash test` | `vitest.config.ts` | Unit tests under `src/__tests__/**` and `src/**/__tests__/**` | No |
+| `pnpm --filter stash test` | `vitest.config.ts` | Unit tests under `src/__tests__/**` and `src/**/__tests__/**` | **Partly** — needs `@cipherstash/stack` built (see below). Turbo's `^build` supplies it in CI. |
| `pnpm --filter stash test:e2e` | `vitest.integration.config.ts` | E2E tests under `tests/e2e/**.e2e.test.ts` driving the built `dist/bin/stash.js` through a real pty (`node-pty`) | **Yes** — run `pnpm --filter stash build` first, or use the turbo `test:e2e` task which depends on `build`. |
The unit config explicitly excludes `tests/e2e/**` so the default `pnpm test`
-stays fast and self-contained.
+stays fast.
+
+It is **not** fully self-contained, despite running standalone in CI. Some `src`
+modules import workspace packages that publish `./dist` only, so an unbuilt
+workspace fails at collection with `Failed to resolve entry for package …`
+rather than at an assertion. `vitest.config.ts` aliases `@cipherstash/migrate`
+to its source to remove one such coupling; `@cipherstash/stack` remains, reached
+via `packages/migrate/src/backfill.ts` and a direct import in
+`init/lib/__tests__/introspect.test.ts`. Deleting `packages/stack/dist` fails 10
+files. Closing it needs `vitest.shared.ts`'s `stackSourceAlias`, which cannot be
+spread into this config: its `'@/'` points at `packages/stack/src` while this
+package's points at `packages/cli/src`, and a flat alias map admits only one —
+spread it after and stack's entry clobbers the CLI's, spread it before and the
+CLI's breaks stack's own source imports (#787 review).
## When to add or update an E2E test
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 853e1b21b..7bb470e2b 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -3,7 +3,7 @@
[](https://www.npmjs.com/package/stash)
[](https://github.com/cipherstash/stack/blob/main/LICENSE.md)
-The single CLI for CipherStash. It handles authentication, project initialization, EQL database lifecycle (install, upgrade, validate, push, migrate), and schema building. Install it as a devDependency alongside the runtime SDK `@cipherstash/stack`.
+The single CLI for CipherStash. It handles authentication, project initialization, EQL v3 installation/upgrades, validation, migration rollout, and schema building.
---
@@ -15,7 +15,7 @@ npx stash auth login # authenticate with CipherStash
npx stash init # scaffold, introspect, install EQL
```
-`stash init` does the scaffold-once work as one flow: authenticate, resolve `DATABASE_URL`, choose Proxy or direct SDK access, introspect your database and scaffold an encryption client, install dependencies, install the EQL extension, and write `.cipherstash/context.json`. It stops there, at a clean save-point, and offers to continue into `stash plan`.
+`stash init` authenticates, resolves `DATABASE_URL`, introspects the database, scaffolds an encryption client, installs dependencies and EQL v3, and writes `.cipherstash/context.json`.
The agent handoff belongs to the next two commands — `stash plan` drafts a reviewable `.cipherstash/plan.md`, and `stash impl` executes it. Both present the same four targets:
@@ -42,9 +42,7 @@ npx stash auth login
└── npx stash status ← where am I?
```
-`stash` covers authentication, initialization, EQL install/upgrade/status, schema introspection, the encryption rollout and cutover commands, and a `stash wizard` subcommand that thin-wraps [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard). The wizard package itself is a separate npm install — kept out of the `stash` bundle so the agent SDK doesn't bloat the CLI.
-
-> `stash db push` is **not** part of the default flow. It registers the encryption config in `public.eql_v2_configuration`, which only [CipherStash Proxy](https://github.com/cipherstash/proxy) reads. SDK users (Drizzle, Supabase, plain PostgreSQL) keep that config in application code and can skip it.
+`stash` covers authentication, initialization, EQL install/upgrade/status, schema introspection, and the staged EQL v3 encryption rollout.
---
@@ -68,7 +66,7 @@ export default defineConfig({
The CLI loads `.env` files automatically before reading the config, so `process.env` references work without extra setup. The config file is resolved by walking up from the current working directory.
-Commands that consume `stash.config.ts`: `eql install`, `eql upgrade`, `db push`, `db validate`, `eql status`, `db test-connection`, `schema build`. `eql install` will scaffold `stash.config.ts` for you if it's missing.
+Commands that consume `stash.config.ts`: `eql install`, `eql upgrade`, `db validate`, `eql status`, `db test-connection`, `schema build`, and `encrypt *`.
---
@@ -92,19 +90,19 @@ What `init` does, in order:
1. **Authenticate** — re-uses an existing token if found, otherwise opens the browser device-code flow.
2. **Resolve `DATABASE_URL`** — flag → env → `supabase status` → interactive prompt → hard-fail. The same resolver `eql install` uses.
-3. **Generate the encryption client** — connects to your database, lists tables, and prompts you to multi-select which columns to encrypt. Writes `./src/encryption/index.ts` with the right shape for the detected ORM (Drizzle / Supabase / plain Postgres). Falls back to a placeholder if the database has no tables yet.
+3. **Generate the encryption client placeholder** — detects the integration and writes `./src/encryption/index.ts` without selecting columns or replacing the project's authoritative schema files. The subsequent `stash plan` / `stash impl` workflow edits the real schema.
4. **Install dependencies** — `@cipherstash/stack` (runtime) and `stash` (dev), with a confirmation prompt.
5. **Install EQL** — runs `stash eql install` against the resolved URL after a y/N confirm.
-6. **Hand off** — four-option menu (Claude Code / Codex / CipherStash Agent / write `AGENTS.md`). See the Quickstart section above for what each option writes and spawns.
+6. **Checkpoint** — writes `.cipherstash/context.json` and exits. Continue with `stash plan`, then `stash impl`, as described in Quickstart.
-The full pipeline state — integration, columns, env-key names, paths, versions — is captured in `.cipherstash/context.json`. The action plan at `.cipherstash/setup-prompt.md` tells whichever agent picks up next what's already done and what's left.
+The full pipeline state — integration, columns, env-key names, paths, versions — is captured in `.cipherstash/context.json`. The action plan at `.cipherstash/setup-prompt.md` records what's already done and what `stash plan` / `stash impl` should do next.
`CIPHERSTASH_WIZARD_URL` overrides the gateway endpoint for the rulebook fetch. Useful for local-dev against a wizard gateway running on `localhost`.
-**Running `init` non-interactively** (CI, agents, pipes): every prompt has an escape hatch, so `init` never blocks waiting on a TTY. Provide the region up front (`--region` / `STASH_REGION`) if you aren't already logged in, the database URL (`--database-url` / `DATABASE_URL`), the proxy choice (`--proxy` / `--no-proxy`), and — for the closing agent handoff — nothing is required (init exits at a clean checkpoint and points you at `stash plan --target …`). When a required value is missing in a non-TTY context the command exits non-zero with an actionable message rather than hanging.
+**Running `init` non-interactively** (CI, agents, pipes): every prompt has an escape hatch, so `init` never blocks waiting on a TTY. Provide the region up front (`--region` / `STASH_REGION`) if you aren't already logged in and set `DATABASE_URL`. Init exits at a clean checkpoint and points you at `stash plan --target …`; run `stash impl` after planning. When a required value is missing in a non-TTY context the command exits non-zero with an actionable message rather than hanging.
```bash
-STASH_REGION=us-east-1 DATABASE_URL=postgres://… npx stash init --no-proxy
+STASH_REGION=us-east-1 DATABASE_URL=postgres://… npx stash init
```
---
@@ -180,7 +178,7 @@ Any flags after `wizard` are forwarded verbatim to the wizard package. On the fi
Configure your database and install CipherStash EQL extensions in a single command. Run this after `npx stash init`. (`npx stash db install` is a deprecated alias — it still works but prints a warning.)
-When `stash.config.ts` is missing, the command auto-detects your encryption client file (or asks for the path) and writes the config before installing. Supabase and Drizzle are detected from your `DATABASE_URL` and project files, so the matching flags default on. Install uses bundled SQL for offline, deterministic runs.
+When `stash.config.ts` is missing, the command offers to scaffold it. Installation is direct and EQL v3 only, using the bundle pinned by `@cipherstash/eql`.
```bash
npx stash eql install [options]
@@ -190,22 +188,18 @@ npx stash eql install [options]
|------|-------------|
| `--force` | Reinstall even if EQL is already installed |
| `--dry-run` | Show what would happen without making changes |
-| `--supabase` | Supabase-compatible install (no operator families + grants Supabase roles) |
-| `--exclude-operator-family` | Skip operator family creation |
-| `--drizzle` | Generate a Drizzle migration instead of direct install |
-| `--latest` | Fetch the latest EQL from GitHub |
-| `--name ` | Migration name (Drizzle mode, default: `install-eql`) |
-| `--out ` | Drizzle output directory (default: `drizzle`) |
+| `--supabase` | Supabase-compatible install with grants for built-in roles |
+| `--database-url ` | One-shot target; leaves project files untouched |
-The `--supabase` flag uses a Supabase-specific SQL variant and grants `USAGE`, table, routine, and sequence permissions on the `eql_v2` schema to the `anon`, `authenticated`, and `service_role` roles.
+`--supabase` grants the built-in roles access to both `eql_v3` and `eql_v3_internal`. Removed v2 options fail explicitly; `--eql-version 2` points dump-recovery users to the upstream EQL 2.3.1 SQL release.
-> **Good to know:** Without operator families, `ORDER BY` on encrypted columns is not supported. Sort application-side after decrypting results as a workaround. This applies to both `--supabase` and `--exclude-operator-family` installs.
+> **Good to know:** The pinned EQL v3 bundle self-adapts when the install role cannot create its optional ORE operator family. In that case it disables the `*OrdOre` domains; use the ordinary `*Ord` domains for ordering.
---
### `npx stash eql upgrade`
-Upgrade an existing EQL installation to the version bundled with the package (or the latest from GitHub).
+Upgrade an existing EQL v3 installation to the package-pinned version.
```bash
npx stash eql upgrade [options]
@@ -215,40 +209,11 @@ npx stash eql upgrade [options]
|------|-------------|
| `--dry-run` | Show what would happen without making changes |
| `--supabase` | Use Supabase-compatible upgrade |
-| `--exclude-operator-family` | Skip operator family creation |
-| `--latest` | Fetch the latest EQL from GitHub |
The install SQL is idempotent and safe to re-run. If EQL is not installed, the command suggests running `npx stash eql install` instead.
---
-### `npx stash db push`
-
-Push your encryption schema to the database. **Only required when using CipherStash Proxy.** If you use the SDK directly with Drizzle, Supabase, or plain PostgreSQL, skip this step.
-
-```bash
-npx stash db push [--dry-run]
-```
-
-| Flag | Description |
-|------|-------------|
-| `--dry-run` | Load and validate the schema, print as JSON. No database changes. |
-
-When pushing, the CLI loads the encryption client from `stash.config.ts`, runs schema validation (warns but does not block), maps SDK types to EQL types, and upserts the config row in `eql_v2_configuration`.
-
-**SDK to EQL type mapping:**
-
-| SDK `dataType()` | EQL `cast_as` |
-|------------------|---------------|
-| `string` / `text` | `text` |
-| `number` | `double` |
-| `bigint` | `big_int` |
-| `boolean` | `boolean` |
-| `date` | `date` |
-| `json` | `jsonb` |
-
----
-
### `npx stash db validate`
Validate your encryption schema for common misconfigurations.
@@ -264,7 +229,7 @@ npx stash db validate [--supabase] [--exclude-operator-family]
| No indexes on an encrypted column | Info |
| `searchableJson` without `dataType("json")` | Error |
-The command exits with code 1 on errors (not on warnings or info). Validation also runs automatically before `db push`.
+The command exits with code 1 on errors (not on warnings or info).
---
@@ -288,7 +253,7 @@ Show the current state of EQL in your database.
npx stash eql status
```
-Reports EQL installation status and version, database permission status, and whether an active encrypt config exists in `eql_v2_configuration` (relevant only for CipherStash Proxy).
+Reports EQL installation status and version, database permission status, and read-only diagnostics for legacy EQL v2/Proxy configuration state.
---
@@ -320,22 +285,22 @@ Reads `databaseUrl` from `stash.config.ts`.
## Drizzle migration mode
-Use `--drizzle` with `npx stash eql install` to add EQL installation to your Drizzle migration history instead of applying it directly. `--drizzle` is auto-detected when your project has `drizzle-orm`, `drizzle-kit`, or a `drizzle.config.*` file, so you usually don't need to pass it explicitly.
+Use `eql migration --drizzle` to add EQL v3 installation to Drizzle migration history instead of applying it directly.
```bash
-npx stash eql install --drizzle
+npx stash eql migration --drizzle
npx drizzle-kit migrate
```
How it works:
1. Runs `npx drizzle-kit generate --custom --name=` to create an empty migration.
-2. Loads the bundled EQL SQL (or fetches from GitHub with `--latest`).
+2. Loads the pinned EQL v3 SQL.
3. Writes the EQL SQL into the generated migration file.
With a custom name or output directory:
```bash
-npx stash eql install --drizzle --name setup-eql --out ./migrations
+npx stash eql migration --drizzle --name setup-eql --out ./migrations
npx drizzle-kit migrate
```
@@ -348,7 +313,7 @@ npx drizzle-kit migrate
Before installing EQL, the CLI verifies that the connected role has:
- `CREATE` on the database (for `CREATE SCHEMA` and `CREATE EXTENSION`).
-- `CREATE` on the `public` schema (for `CREATE TYPE public.eql_v2_encrypted`).
+- `CREATE` on the `public` schema (for the `public.eql_v3_*` domains).
- `SUPERUSER` or extension owner privileges (for `CREATE EXTENSION pgcrypto`, if not already installed).
If permissions are insufficient, the CLI exits with a message listing what is missing.
@@ -363,7 +328,6 @@ import {
loadStashConfig,
EQLInstaller,
loadBundledEqlSql,
- downloadEqlSql,
} from 'stash'
```
@@ -415,11 +379,11 @@ if (!(await installer.isInstalled())) {
| Method | Returns | Description |
|--------|---------|-------------|
| `checkPermissions()` | `Promise` | Check required database permissions |
-| `isInstalled()` | `Promise` | Check if the `eql_v2` schema exists |
+| `isInstalled()` | `Promise` | Check if the EQL v3 schemas exist |
| `getInstalledVersion()` | `Promise` | Get the installed EQL version |
| `install(options?)` | `Promise` | Execute the EQL install SQL in a transaction |
-Install options: `excludeOperatorFamily`, `supabase`, `latest` (all boolean).
+Install options: `supabase`.
### `loadBundledEqlSql`
@@ -429,19 +393,6 @@ Load the bundled EQL install SQL as a string:
import { loadBundledEqlSql } from 'stash'
const sql = loadBundledEqlSql()
-const sql = loadBundledEqlSql({ supabase: true })
-const sql = loadBundledEqlSql({ excludeOperatorFamily: true })
-```
-
-### `downloadEqlSql`
-
-Download the latest EQL install SQL from GitHub:
-
-```typescript
-import { downloadEqlSql } from 'stash'
-
-const sql = await downloadEqlSql() // standard
-const sql = await downloadEqlSql(true) // no operator family variant
```
---
diff --git a/packages/cli/__fixtures__/scaffold/drizzle.generated.ts b/packages/cli/__fixtures__/scaffold/drizzle.generated.ts
new file mode 100644
index 000000000..51571571b
--- /dev/null
+++ b/packages/cli/__fixtures__/scaffold/drizzle.generated.ts
@@ -0,0 +1,66 @@
+/**
+ * CipherStash encryption client — placeholder.
+ *
+ * `stash init` wrote this file. It is intentionally NOT a real Drizzle
+ * schema. Your existing schema files (typically under `src/db/`) remain
+ * authoritative — your agent will edit those directly when you encrypt a
+ * column, then update the `Encryption({ schemas: [...] })` call below
+ * to reference the encrypted tables you declared there.
+ *
+ * Until that happens, the encryption client is initialised with a single
+ * placeholder table so that this file compiles, and `stash db validate` and
+ * `stash encrypt backfill` refuse to run and point back here. (`stash
+ * encrypt drop` resolves against the database and never reads this file.)
+ *
+ * This project uses EQL v3. Encrypted columns are concrete Postgres domains
+ * built with the `types.*` factories from `@cipherstash/stack-drizzle`.
+ * Each domain's query capabilities are FIXED by the type you pick — there is
+ * no capability config object. Choose the factory whose capabilities you need:
+ * types.Text / types.Integer / … storage only (encrypt/decrypt, no queries)
+ * types.TextEq / types.IntegerEq equality (eq, inArray)
+ * types.IntegerOrd / types.DateOrd equality + order/range (gt/lt/between/sort)
+ * types.TextMatch free-text match only
+ * types.TextSearch equality + order/range + free-text
+ * types.Json encrypted-JSONB containment + selectors
+ *
+ * --- Pattern reference (copy into your real schema, do NOT use as-is) ---
+ *
+ * Encrypted twin column for an existing populated column (path 3 — lifecycle):
+ *
+ * import { pgTable, integer, text } from 'drizzle-orm/pg-core'
+ * import { types } from '@cipherstash/stack-drizzle'
+ *
+ * export const users = pgTable('users', {
+ * id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
+ * email: text('email').notNull(), // existing plaintext, unchanged for now
+ * email_encrypted: types.TextSearch('email_encrypted'), // encrypted twin, NULLABLE — never .notNull()
+ * })
+ *
+ * Net-new encrypted column (path 1 — declare encrypted from the start):
+ *
+ * export const orders = pgTable('orders', {
+ * id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
+ * billing_address: types.TextEq('billing_address'),
+ * })
+ *
+ * Once you have encrypted tables declared, harvest them and pass to Encryption():
+ *
+ * import { extractEncryptionSchema } from '@cipherstash/stack-drizzle'
+ * import { Encryption } from '@cipherstash/stack/v3'
+ * import { users, orders } from './db/schema'
+ *
+ * export const encryptionClient = await Encryption({
+ * schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)],
+ * })
+ */
+import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
+
+// REPLACE THIS. It exists only so this file compiles before you have declared
+// any encrypted tables — `Encryption` requires at least one. Swap it for your
+// real tables (see the patterns above); `stash db validate` and `stash
+// encrypt backfill` refuse to run while the placeholder is still here.
+export const placeholderTable = encryptedTable('__stash_placeholder__', {
+ replace_me: types.Text('replace_me'),
+})
+
+export const encryptionClient = await Encryption({ schemas: [placeholderTable] })
diff --git a/packages/cli/__fixtures__/scaffold/generic.generated.ts b/packages/cli/__fixtures__/scaffold/generic.generated.ts
new file mode 100644
index 000000000..97b7c4c22
--- /dev/null
+++ b/packages/cli/__fixtures__/scaffold/generic.generated.ts
@@ -0,0 +1,61 @@
+/**
+ * CipherStash encryption client — placeholder.
+ *
+ * `stash init` wrote this file. It is intentionally NOT a real schema
+ * definition. Your existing schema files remain authoritative — your
+ * agent will declare encrypted columns there and update the
+ * `Encryption({ schemas: [...] })` call below to reference them.
+ *
+ * Until that happens, the encryption client is initialised with a single
+ * placeholder table so that this file compiles, and `stash db validate` and
+ * `stash encrypt backfill` refuse to run and point back here. (`stash
+ * encrypt drop` resolves against the database and never reads this file.)
+ *
+ * This project uses EQL v3. Encrypted columns are concrete Postgres domains
+ * built with the `types.*` factories from `@cipherstash/stack/eql/v3`
+ * (also re-exported from `@cipherstash/stack/v3`). Each domain's query
+ * capabilities are FIXED by the type you pick — there is no chainable
+ * capability tuner. Choose the factory whose capabilities you need:
+ * types.Text / types.Integer / … storage only (encrypt/decrypt, no queries)
+ * types.TextEq / types.IntegerEq equality
+ * types.IntegerOrd / types.DateOrd equality + order/range
+ * types.TextMatch free-text match only
+ * types.TextSearch equality + order/range + free-text
+ * types.Json encrypted-JSONB containment + selectors
+ *
+ * --- Pattern reference (copy into your real schema, do NOT use as-is) ---
+ *
+ * Encrypted twin column for an existing populated column (path 3 — lifecycle):
+ *
+ * import { encryptedTable, types } from '@cipherstash/stack/v3'
+ *
+ * export const users = encryptedTable('users', {
+ * email_encrypted: types.TextSearch('email_encrypted'),
+ * })
+ *
+ * Net-new encrypted column (path 1 — declare encrypted from the start):
+ *
+ * export const orders = encryptedTable('orders', {
+ * billing_address: types.TextEq('billing_address'),
+ * })
+ *
+ * Once you have encrypted tables declared, pass them to Encryption():
+ *
+ * import { Encryption } from '@cipherstash/stack/v3'
+ * import { users, orders } from './db/schema'
+ *
+ * export const encryptionClient = await Encryption({
+ * schemas: [users, orders],
+ * })
+ */
+import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
+
+// REPLACE THIS. It exists only so this file compiles before you have declared
+// any encrypted tables — `Encryption` requires at least one. Swap it for your
+// real tables (see the patterns above); `stash db validate` and `stash
+// encrypt backfill` refuse to run while the placeholder is still here.
+export const placeholderTable = encryptedTable('__stash_placeholder__', {
+ replace_me: types.Text('replace_me'),
+})
+
+export const encryptionClient = await Encryption({ schemas: [placeholderTable] })
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 8f1c36b90..5cafc5d7f 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -41,6 +41,7 @@
"postbuild": "chmod +x ./dist/bin/stash.js",
"dev": "tsup --watch",
"test": "vitest run",
+ "typecheck:scaffold": "tsc -p tsconfig.scaffold.json",
"test:e2e": "vitest run --config vitest.integration.config.ts",
"lint": "biome check ."
},
diff --git a/packages/cli/scripts/e2e-encrypt.sh b/packages/cli/scripts/e2e-encrypt.sh
index 6e310f505..0e027a80b 100755
--- a/packages/cli/scripts/e2e-encrypt.sh
+++ b/packages/cli/scripts/e2e-encrypt.sh
@@ -26,11 +26,11 @@ psql -h "$HOST" -d postgres -c "CREATE DATABASE ${DB}" >/dev/null
export DATABASE_URL
echo "==> 1. Install EQL + cs_migrations"
-"$STASH" db install --force
+"$STASH" eql install --force
echo "==> 2. Seed 5000 plaintext users"
psql "$DATABASE_URL" -f "$FIXTURES/seed-users.sql" >/dev/null
-psql "$DATABASE_URL" -c "ALTER TABLE users ADD COLUMN email_encrypted eql_v2_encrypted" >/dev/null
+psql "$DATABASE_URL" -c "ALTER TABLE users ADD COLUMN email_encrypted public.eql_v3_text_search" >/dev/null
echo "==> 3. Backfill with interrupt/resume (dual-writes confirmed via flag for non-interactive run)"
"$STASH" encrypt backfill --table users --column email --chunk-size 500 --confirm-dual-writes-deployed &
@@ -50,10 +50,9 @@ echo "OK: all 5000 rows encrypted"
echo "==> 4. Status"
"$STASH" encrypt status
-echo "==> 5. Cutover"
-"$STASH" encrypt cutover --table users --column email
+echo "==> 5. Switch application reads to email_encrypted (manual deploy step)"
-echo "==> 6. Drop"
+echo "==> 6. Generate plaintext drop migration"
"$STASH" encrypt drop --table users --column email --migrations-dir "$(pwd)/drizzle"
echo "==> Done."
diff --git a/packages/cli/scripts/fixtures/seed-users.sql b/packages/cli/scripts/fixtures/seed-users.sql
index 1b114e038..90352b934 100644
--- a/packages/cli/scripts/fixtures/seed-users.sql
+++ b/packages/cli/scripts/fixtures/seed-users.sql
@@ -1,7 +1,7 @@
-- Seed a users table with plaintext emails for e2e backfill testing.
--
--- The encrypted target column must be created separately (drizzle-kit /
--- stash db push route), after which the backfill encrypts `email` → `email_encrypted`.
+-- The EQL v3 encrypted target column must be created separately (for example,
+-- by Drizzle Kit), after which backfill encrypts `email` → `email_encrypted`.
DROP TABLE IF EXISTS users CASCADE;
diff --git a/packages/cli/src/__tests__/installer.test.ts b/packages/cli/src/__tests__/installer.test.ts
index 5ee0bd32e..52c23852b 100644
--- a/packages/cli/src/__tests__/installer.test.ts
+++ b/packages/cli/src/__tests__/installer.test.ts
@@ -4,381 +4,133 @@ const mockConnect = vi.fn()
const mockQuery = vi.fn()
const mockEnd = vi.fn()
-vi.mock('pg', () => {
- const Client = vi.fn(() => ({
- connect: mockConnect,
- query: mockQuery,
- end: mockEnd,
- }))
- return { default: { Client } }
-})
+vi.mock('pg', () => ({
+ default: {
+ Client: vi.fn(() => ({
+ connect: mockConnect,
+ query: mockQuery,
+ end: mockEnd,
+ })),
+ },
+}))
describe('EQLInstaller', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- afterEach(() => {
- vi.restoreAllMocks()
- })
-
- describe('checkPermissions', () => {
- it('returns ok when role is superuser', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockQuery.mockResolvedValue({
- rows: [{ rolsuper: true, rolcreatedb: true }],
- rowCount: 1,
- })
- mockEnd.mockResolvedValue(undefined)
-
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- const result = await installer.checkPermissions()
- expect(result.ok).toBe(true)
- expect(result.missing).toEqual([])
+ beforeEach(() => vi.clearAllMocks())
+ afterEach(() => vi.restoreAllMocks())
+
+ it('reports sufficient permissions for a superuser', async () => {
+ mockConnect.mockResolvedValue(undefined)
+ mockQuery.mockResolvedValue({
+ rows: [{ rolsuper: true, rolcreatedb: true }],
+ rowCount: 1,
})
-
- it('returns missing permissions when role lacks privileges', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockEnd.mockResolvedValue(undefined)
-
- let queryCall = 0
- mockQuery.mockImplementation(() => {
- queryCall++
- switch (queryCall) {
- // pg_roles query — not superuser
- case 1:
- return {
- rows: [{ rolsuper: false, rolcreatedb: false }],
- rowCount: 1,
- }
- // has_database_privilege — no CREATE
- case 2:
- return { rows: [{ has_create: false }], rowCount: 1 }
- // has_schema_privilege — no CREATE on public
- case 3:
- return { rows: [{ has_create: false }], rowCount: 1 }
- // pgcrypto check — not installed
- case 4:
- return { rows: [], rowCount: 0 }
- default:
- return { rows: [], rowCount: 0 }
- }
- })
-
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- const result = await installer.checkPermissions()
- expect(result.ok).toBe(false)
- expect(result.missing).toHaveLength(3)
+ mockEnd.mockResolvedValue(undefined)
+ const { EQLInstaller } = await import('@/installer/index.ts')
+ const installer = new EQLInstaller({ databaseUrl: 'postgres://test' })
+
+ await expect(installer.checkPermissions()).resolves.toEqual({
+ ok: true,
+ missing: [],
+ isSuperuser: true,
})
})
- describe('isInstalled', () => {
- it('returns false when schema does not exist', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
- mockEnd.mockResolvedValue(undefined)
-
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- const result = await installer.isInstalled()
- expect(result).toBe(false)
- })
-
- it('returns true when schema exists', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockQuery.mockResolvedValue({
- rows: [{ found: 1 }],
+ it('accepts database-local CREATE privilege for installing pgcrypto', async () => {
+ mockConnect.mockResolvedValue(undefined)
+ mockQuery
+ .mockResolvedValueOnce({
+ rows: [{ rolsuper: false, rolcreatedb: false }],
rowCount: 1,
})
- mockEnd.mockResolvedValue(undefined)
-
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- const result = await installer.isInstalled({ eqlVersion: 2 })
- expect(result).toBe(true)
- expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [['eql_v2']])
+ .mockResolvedValueOnce({ rows: [{ has_create: true }], rowCount: 1 })
+ .mockResolvedValueOnce({ rows: [{ has_create: true }], rowCount: 1 })
+ .mockResolvedValueOnce({ rows: [], rowCount: 0 })
+ mockEnd.mockResolvedValue(undefined)
+ const { EQLInstaller } = await import('@/installer/index.ts')
+ const installer = new EQLInstaller({ databaseUrl: 'postgres://test' })
+
+ await expect(installer.checkPermissions()).resolves.toEqual({
+ ok: true,
+ missing: [],
+ isSuperuser: false,
})
})
- describe('install', () => {
- it('uses bundled SQL and executes in a transaction', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
- mockEnd.mockResolvedValue(undefined)
-
- const fetchSpy = vi.spyOn(globalThis, 'fetch')
-
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- await installer.install({ eqlVersion: 2 })
-
- // Should NOT call fetch — uses bundled SQL
- expect(fetchSpy).not.toHaveBeenCalled()
- expect(mockQuery).toHaveBeenCalledWith('BEGIN')
- // The second query should be the bundled SQL (a large string)
- const sqlCall = mockQuery.mock.calls.find(
- (call: string[]) =>
- typeof call[0] === 'string' &&
- call[0] !== 'BEGIN' &&
- call[0] !== 'COMMIT',
- )
- expect(sqlCall).toBeDefined()
- expect(sqlCall[0]).toContain('eql_v2')
- expect(mockQuery).toHaveBeenCalledWith('COMMIT')
- })
-
- it('fetches from GitHub when latest: true', async () => {
- const installSql = 'CREATE SCHEMA eql_v2;'
-
- mockConnect.mockResolvedValue(undefined)
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
- mockEnd.mockResolvedValue(undefined)
-
- const fetchSpy = vi
- .spyOn(globalThis, 'fetch')
- .mockResolvedValue(new Response(installSql, { status: 200 }))
-
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- await installer.install({ eqlVersion: 2, latest: true })
-
- expect(fetchSpy).toHaveBeenCalledWith(
- expect.stringContaining('cipherstash-encrypt.sql'),
- )
- expect(mockQuery).toHaveBeenCalledWith('BEGIN')
- expect(mockQuery).toHaveBeenCalledWith(installSql)
- expect(mockQuery).toHaveBeenCalledWith('COMMIT')
- })
-
- it('grants Supabase permissions as a single SUPABASE_PERMISSIONS_SQL query', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
- mockEnd.mockResolvedValue(undefined)
+ it('requires both EQL v3 schemas for the current install', async () => {
+ mockConnect.mockResolvedValue(undefined)
+ mockEnd.mockResolvedValue(undefined)
+ const { EQLInstaller } = await import('@/installer/index.ts')
+ const installer = new EQLInstaller({ databaseUrl: 'postgres://test' })
- const { EQLInstaller, SUPABASE_PERMISSIONS_SQL } = await import(
- '@/installer/index.ts'
- )
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- await installer.install({ eqlVersion: 2, supabase: true })
-
- // Capture every query string that isn't a transaction control verb.
- const otherCalls = mockQuery.mock.calls
- .map((call: unknown[]) => call[0])
- .filter(
- (sql: unknown): sql is string =>
- typeof sql === 'string' &&
- sql !== 'BEGIN' &&
- sql !== 'COMMIT' &&
- sql !== 'ROLLBACK',
- )
-
- // Two non-transaction queries: bundled EQL SQL, then permissions SQL.
- expect(otherCalls).toHaveLength(2)
- expect(otherCalls[1]).toBe(SUPABASE_PERMISSIONS_SQL)
- // Permissions SQL must mention each role + the eql_v2 schema.
- expect(SUPABASE_PERMISSIONS_SQL).toContain('eql_v2')
- expect(SUPABASE_PERMISSIONS_SQL).toContain('anon')
- expect(SUPABASE_PERMISSIONS_SQL).toContain('authenticated')
- expect(SUPABASE_PERMISSIONS_SQL).toContain('service_role')
- })
-
- it('installs the v3 bundle and grants eql_v3 permissions with eqlVersion: 3 + supabase', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
- mockEnd.mockResolvedValue(undefined)
-
- const { EQLInstaller, SUPABASE_PERMISSIONS_SQL_V3 } = await import(
- '@/installer/index.ts'
- )
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- await installer.install({ eqlVersion: 3, supabase: true })
-
- const otherCalls = mockQuery.mock.calls
- .map((call: unknown[]) => call[0])
- .filter(
- (sql: unknown): sql is string =>
- typeof sql === 'string' &&
- sql !== 'BEGIN' &&
- sql !== 'COMMIT' &&
- sql !== 'ROLLBACK',
- )
-
- expect(otherCalls).toHaveLength(2)
- // Since eql-3.0.0 there is ONE v3 bundle for every target: the
- // operator-class statements run inside a DO block that self-skips on
- // insufficient_privilege, and the bundle disables the ORE-backed
- // domains when the opclass is absent (CIP-3468). The supabase install
- // therefore executes the SAME artifact as the direct install.
- expect(otherCalls[0]).toContain('eql_v3')
- expect(otherCalls[0]).toContain('CREATE OPERATOR CLASS')
- expect(otherCalls[0]).toContain('insufficient_privilege')
- // The grants are keyed to eql_v3, not eql_v2. The installed block must be
- // the SAME string the Supabase migration file embeds — the installer used
- // to rebuild it from the schema name alone, letting the two drift.
- expect(otherCalls[1]).toBe(SUPABASE_PERMISSIONS_SQL_V3)
- expect(SUPABASE_PERMISSIONS_SQL_V3).toContain('eql_v3')
- expect(SUPABASE_PERMISSIONS_SQL_V3).not.toContain('eql_v2')
-
- // `eql_v3.eq_term`/`ord_term`/`match_term` are SECURITY INVOKER and
- // qualify `eql_v3_internal.*` in their bodies, so without USAGE on that
- // schema every encrypted filter fails for anon/authenticated with
- // "permission denied for schema eql_v3_internal". See
- // `supabaseInternalPermissionsSql`, and the live proof in
- // packages/stack/__tests__/supabase-v3-grants-pg.test.ts.
- expect(SUPABASE_PERMISSIONS_SQL_V3).toContain(
- 'GRANT USAGE ON SCHEMA eql_v3_internal TO anon, authenticated, service_role;',
- )
- expect(SUPABASE_PERMISSIONS_SQL_V3).toContain(
- 'GRANT EXECUTE ON ALL ROUTINES IN SCHEMA eql_v3_internal TO anon, authenticated, service_role;',
- )
- })
-
- // `eql_v2` has no internal schema; the v3-only addition must not leak into
- // the v2 block, where it would fail with "schema does not exist".
- it('does not grant an internal schema in the v2 permissions block', async () => {
- const { SUPABASE_PERMISSIONS_SQL } = await import('@/installer/index.ts')
-
- expect(SUPABASE_PERMISSIONS_SQL).not.toContain('_internal')
- })
-
- it('installs the full v3 bundle (with operator classes) without supabase', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
- mockEnd.mockResolvedValue(undefined)
-
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- await installer.install({ eqlVersion: 3 })
+ mockQuery.mockResolvedValue({ rows: [{ found: 2 }], rowCount: 1 })
+ await expect(installer.isInstalled()).resolves.toBe(true)
+ expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [
+ ['eql_v3', 'eql_v3_internal'],
+ ])
- const sqlCall = mockQuery.mock.calls.find(
- (call: string[]) =>
- typeof call[0] === 'string' &&
- call[0] !== 'BEGIN' &&
- call[0] !== 'COMMIT',
- )
- expect(sqlCall).toBeDefined()
- expect(sqlCall?.[0]).toContain('eql_v3')
- expect(sqlCall?.[0]).toContain('CREATE OPERATOR CLASS')
- })
-
- it('rejects latest: true for eqlVersion: 3', async () => {
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- await expect(
- installer.install({ eqlVersion: 3, latest: true }),
- ).rejects.toThrow('not supported for EQL v3')
- })
-
- it('requires BOTH eql_v3 and eql_v3_internal for isInstalled({ eqlVersion: 3 })', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockEnd.mockResolvedValue(undefined)
+ mockQuery.mockResolvedValue({ rows: [{ found: 1 }], rowCount: 1 })
+ await expect(installer.isInstalled()).resolves.toBe(false)
+ })
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
+ it('retains read-only EQL v2 installation detection for status', async () => {
+ mockConnect.mockResolvedValue(undefined)
+ mockEnd.mockResolvedValue(undefined)
+ mockQuery.mockResolvedValue({ rows: [{ found: 1 }], rowCount: 1 })
+ const { EQLInstaller } = await import('@/installer/index.ts')
+ const installer = new EQLInstaller({ databaseUrl: 'postgres://test' })
- // Current-generation install: both schemas present
- mockQuery.mockResolvedValue({ rows: [{ found: 2 }], rowCount: 1 })
- await expect(installer.isInstalled({ eqlVersion: 3 })).resolves.toBe(true)
- expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [
- ['eql_v3', 'eql_v3_internal'],
- ])
+ await expect(installer.isInstalled({ eqlVersion: 2 })).resolves.toBe(true)
+ expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [['eql_v2']])
+ })
- // STALE pre-alpha.2 install: eql_v3 exists but eql_v3_internal does not
- // — must report NOT installed so an install run replaces it instead of
- // a stale surface silently accepting wrong-generation wire data.
- mockQuery.mockResolvedValue({ rows: [{ found: 1 }], rowCount: 1 })
- await expect(installer.isInstalled({ eqlVersion: 3 })).resolves.toBe(
- false,
- )
- })
+ it('installs only the pinned EQL v3 bundle', async () => {
+ mockConnect.mockResolvedValue(undefined)
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
+ mockEnd.mockResolvedValue(undefined)
+ const { EQLInstaller } = await import('@/installer/index.ts')
+ const installer = new EQLInstaller({ databaseUrl: 'postgres://test' })
+
+ await installer.install()
+
+ const sqlCall = mockQuery.mock.calls.find(
+ ([sql]) =>
+ typeof sql === 'string' &&
+ !['BEGIN', 'COMMIT', 'ROLLBACK'].includes(sql),
+ )
+ expect(sqlCall?.[0]).toContain('eql_v3')
+ expect(sqlCall?.[0]).not.toContain('CREATE SCHEMA eql_v2')
+ expect(mockQuery).toHaveBeenCalledWith('COMMIT')
+ })
- it('grants eql_v3 AND eql_v3_internal for the v3 supabase install', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
- mockEnd.mockResolvedValue(undefined)
+ it('grants both EQL v3 schemas to Supabase roles', async () => {
+ mockConnect.mockResolvedValue(undefined)
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
+ mockEnd.mockResolvedValue(undefined)
+ const { EQLInstaller, SUPABASE_PERMISSIONS_SQL_V3 } = await import(
+ '@/installer/index.ts'
+ )
+ const installer = new EQLInstaller({ databaseUrl: 'postgres://test' })
- const { EQLInstaller, SUPABASE_PERMISSIONS_SQL_V3 } = await import(
- '@/installer/index.ts'
- )
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
+ await installer.install({ supabase: true })
- await installer.install({ eqlVersion: 3, supabase: true })
+ expect(mockQuery).toHaveBeenCalledWith(SUPABASE_PERMISSIONS_SQL_V3)
+ expect(SUPABASE_PERMISSIONS_SQL_V3).toContain('eql_v3_internal')
+ expect(SUPABASE_PERMISSIONS_SQL_V3).not.toContain('eql_v2')
+ })
- const otherCalls = mockQuery.mock.calls
- .map((call: unknown[]) => call[0])
- .filter(
- (sql: unknown): sql is string =>
- typeof sql === 'string' &&
- sql !== 'BEGIN' &&
- sql !== 'COMMIT' &&
- sql !== 'ROLLBACK',
- )
- expect(otherCalls[1]).toBe(SUPABASE_PERMISSIONS_SQL_V3)
- // The eql_v3 operators call SECURITY INVOKER functions that live in
- // eql_v3_internal — the roles need grants on BOTH schemas.
- expect(SUPABASE_PERMISSIONS_SQL_V3).toContain(
- 'GRANT USAGE ON SCHEMA eql_v3 ',
- )
- expect(SUPABASE_PERMISSIONS_SQL_V3).toContain(
- 'GRANT USAGE ON SCHEMA eql_v3_internal ',
- )
+ it('rolls back when the install SQL fails', async () => {
+ mockConnect.mockResolvedValue(undefined)
+ mockEnd.mockResolvedValue(undefined)
+ mockQuery.mockImplementation((sql: string) => {
+ if (!['BEGIN', 'COMMIT', 'ROLLBACK'].includes(sql)) {
+ return Promise.reject(new Error('permission denied'))
+ }
+ return Promise.resolve({ rows: [], rowCount: 0 })
})
+ const { EQLInstaller } = await import('@/installer/index.ts')
+ const installer = new EQLInstaller({ databaseUrl: 'postgres://test' })
- it('rolls back on SQL execution failure', async () => {
- mockConnect.mockResolvedValue(undefined)
- mockEnd.mockResolvedValue(undefined)
-
- mockQuery.mockImplementation((sql: string) => {
- // BEGIN succeeds, any SQL containing eql_v2 (the bundled install) fails
- if (sql !== 'BEGIN' && sql !== 'COMMIT' && sql !== 'ROLLBACK') {
- return Promise.reject(new Error('permission denied'))
- }
- return Promise.resolve({ rows: [], rowCount: 0 })
- })
-
- const { EQLInstaller } = await import('@/installer/index.ts')
- const installer = new EQLInstaller({
- databaseUrl: 'postgresql://localhost:5432/test',
- })
-
- await expect(installer.install()).rejects.toThrow('Failed to install EQL')
- expect(mockQuery).toHaveBeenCalledWith('ROLLBACK')
- })
+ await expect(installer.install()).rejects.toThrow('Failed to install EQL')
+ expect(mockQuery).toHaveBeenCalledWith('ROLLBACK')
})
})
diff --git a/packages/cli/src/__tests__/placeholder-guard-parity.test.ts b/packages/cli/src/__tests__/placeholder-guard-parity.test.ts
new file mode 100644
index 000000000..6b57d14d0
--- /dev/null
+++ b/packages/cli/src/__tests__/placeholder-guard-parity.test.ts
@@ -0,0 +1,125 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { PLACEHOLDER_TABLE_NAME } from '@/config/index.js'
+
+/**
+ * `stash db validate` reaches the user's encryption client through
+ * `loadEncryptConfig`; `stash encrypt backfill` reaches the same file through
+ * `loadEncryptionContext`. Both must refuse the un-replaced `stash init`
+ * scaffold, and — because it is one file and one mistake — both must say the
+ * same thing about it.
+ *
+ * The two guards were hand-copied rather than shared, so nothing held them
+ * together: the message text could drift on one side, and the nullish-config
+ * case HAD already drifted (#787 review follow-up). This pins the agreement at
+ * both public seams rather than testing the shared helper directly, which
+ * would prove only that a function calls itself.
+ *
+ * Real jiti, real temp project, real filesystem — the only doubles are
+ * `process.exit` and `console.error`.
+ */
+describe('the un-replaced init scaffold, refused identically by both loaders', () => {
+ let tmpDir: string
+ let originalCwd: () => string
+
+ const writeProject = (clientBody: string) => {
+ fs.writeFileSync(
+ path.join(tmpDir, 'stash.config.ts'),
+ `export default {
+ databaseUrl: 'postgresql://u:p@127.0.0.1:5432/db',
+ client: './client.ts',
+ }`,
+ )
+ fs.writeFileSync(path.join(tmpDir, 'client.ts'), clientBody)
+ process.cwd = () => tmpDir
+ }
+
+ /** The duck-typed table shape `loadEncryptionContext` harvests. */
+ const table = (name: string) =>
+ `{ tableName: '${name}', build: () => ({ tableName: '${name}', columns: {} }) }`
+
+ /** Run one loader, capturing whatever it wrote to stderr before exiting. */
+ const captureRefusal = async (load: () => Promise) => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
+ throw new Error('process.exit')
+ })
+ let exited = false
+ try {
+ await load()
+ } catch (err) {
+ exited = (err as Error).message === 'process.exit'
+ if (!exited) throw err
+ }
+ const message = error.mock.calls.flat().join('\n')
+ error.mockRestore()
+ exit.mockRestore()
+ return { exited, message }
+ }
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-guard-parity-'))
+ originalCwd = process.cwd
+ })
+
+ afterEach(() => {
+ process.cwd = originalCwd
+ vi.restoreAllMocks()
+ if (tmpDir && fs.existsSync(tmpDir)) {
+ fs.rmSync(tmpDir, { recursive: true, force: true })
+ }
+ })
+
+ it('gives db validate and encrypt backfill the same refusal for the same file', async () => {
+ writeProject(
+ `export const encryptionClient = {
+ getEncryptConfig: () => ({ tables: { '${PLACEHOLDER_TABLE_NAME}': {} } }),
+ }
+ export const placeholderTable = ${table(PLACEHOLDER_TABLE_NAME)}`,
+ )
+
+ const { loadEncryptConfig } = await import('@/config/index.js')
+ const dbValidate = await captureRefusal(() =>
+ loadEncryptConfig('./client.ts'),
+ )
+
+ const { loadEncryptionContext } = await import(
+ '../commands/encrypt/context.js'
+ )
+ const backfill = await captureRefusal(() => loadEncryptionContext())
+
+ expect(dbValidate.exited).toBe(true)
+ expect(backfill.exited).toBe(true)
+ expect(dbValidate.message).toContain('still contains the placeholder table')
+ expect(backfill.message).toEqual(dbValidate.message)
+ })
+
+ it('names the cause, not the symptom, when the client has no encrypt config', async () => {
+ // A client whose `getEncryptConfig()` returns nothing is the same class of
+ // unfinished setup. `db validate` already named it; backfill used to
+ // fall through to `requireTable`'s `Table "users" was not found …
+ // Available: (none)` — the symptom-not-cause message this guard exists to
+ // replace (#787 review follow-up).
+ writeProject(
+ `export const encryptionClient = { getEncryptConfig: () => undefined }
+ export const users = ${table('users')}`,
+ )
+
+ const { loadEncryptConfig } = await import('@/config/index.js')
+ const dbValidate = await captureRefusal(() =>
+ loadEncryptConfig('./client.ts'),
+ )
+
+ const { loadEncryptionContext } = await import(
+ '../commands/encrypt/context.js'
+ )
+ const backfill = await captureRefusal(() => loadEncryptionContext())
+
+ expect(dbValidate.exited).toBe(true)
+ expect(backfill.exited).toBe(true)
+ expect(backfill.message).toContain('no initialized encrypt config')
+ expect(backfill.message).not.toContain('was not found')
+ })
+})
diff --git a/packages/cli/src/__tests__/release-train.test.ts b/packages/cli/src/__tests__/release-train.test.ts
index 7776d1e41..64dc1da48 100644
--- a/packages/cli/src/__tests__/release-train.test.ts
+++ b/packages/cli/src/__tests__/release-train.test.ts
@@ -33,6 +33,20 @@ describe('release train coverage', () => {
}
})
+ it('pins the bare-project stash invocation in the published CLI skill', () => {
+ const cliManifest = JSON.parse(
+ readFileSync(resolve(CLI_ROOT, 'package.json'), 'utf8'),
+ ) as { version: string }
+ const skill = readFileSync(
+ resolve(REPO_ROOT, 'skills/stash-cli/SKILL.md'),
+ 'utf8',
+ )
+
+ expect(skill).toContain(
+ `npx --package=stash@${cliManifest.version} stash eql install --database-url 'postgres://...'`,
+ )
+ })
+
it('every train manifest exists and carries a version (what tsup will embed)', () => {
// Exercises the exact inputs tsup.config.ts reads at build time, so a
// renamed/moved workspace package fails HERE in source-mode tests, not
diff --git a/packages/cli/src/__tests__/rewrite-migrations.test.ts b/packages/cli/src/__tests__/rewrite-migrations.test.ts
index e213cc886..4a0d231bd 100644
--- a/packages/cli/src/__tests__/rewrite-migrations.test.ts
+++ b/packages/cli/src/__tests__/rewrite-migrations.test.ts
@@ -2,7 +2,10 @@ import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { rewriteEncryptedAlterColumns } from '../commands/db/rewrite-migrations.js'
+import {
+ describeSkipReason,
+ rewriteEncryptedAlterColumns,
+} from '../commands/db/rewrite-migrations.js'
describe('rewriteEncryptedAlterColumns', () => {
let tmpDir: string
@@ -15,7 +18,28 @@ describe('rewriteEncryptedAlterColumns', () => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})
+ /**
+ * Declare `columns` on `tableRef` as PLAINTEXT, in a migration that sorts
+ * before every fixture below.
+ *
+ * The sweep is fail-closed: it rewrites a column only when the corpus shows
+ * the column exists and is not already encrypted. A fixture that is just an
+ * ALTER declares nothing, so it is `source-unknown` by design — a test that
+ * exercises the REWRITE has to supply the `CREATE TABLE` a real drizzle
+ * corpus would carry.
+ *
+ * `tableRef` is written exactly as it appears in the ALTER, so a pgSchema()
+ * table passes `'"app"."users"'` and the declaration lands on the same key.
+ */
+ const declarePlaintext = (tableRef: string, ...columns: string[]): void => {
+ const file = path.join(tmpDir, '0000_declare.sql')
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''
+ const defs = columns.map((column) => `"${column}" text`).join(', ')
+ fs.writeFileSync(file, `${existing}CREATE TABLE ${tableRef} (${defs});\n`)
+ }
+
it('rewrites an in-place ALTER COLUMN with the bare type name', async () => {
+ declarePlaintext('"transactions"', 'amount')
const original = `ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE eql_v2_encrypted;\n`
const filePath = path.join(tmpDir, '0002_alter.sql')
fs.writeFileSync(filePath, original)
@@ -37,6 +61,7 @@ describe('rewriteEncryptedAlterColumns', () => {
})
it('rewrites the schema-qualified form produced by drizzle-kit', async () => {
+ declarePlaintext('"users"', 'email')
const original =
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "public"."eql_v2_encrypted";\n'
const filePath = path.join(tmpDir, '0003_alter.sql')
@@ -54,6 +79,7 @@ describe('rewriteEncryptedAlterColumns', () => {
it('rewrites a schema-qualified table produced by pgSchema()', async () => {
// drizzle-kit emits `"app"."users"` for a table declared in a pgSchema();
// the old `\s+` between the table and ALTER COLUMN could never cross the `.`.
+ declarePlaintext('"app"."users"', 'email')
const original =
'ALTER TABLE "app"."users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_text_search";\n'
const filePath = path.join(tmpDir, '0014_qualified.sql')
@@ -75,6 +101,7 @@ describe('rewriteEncryptedAlterColumns', () => {
})
it('rewrites the "undefined" schema form drizzle-kit emits for bare custom types', async () => {
+ declarePlaintext('"transactions"', 'amount')
const original =
'ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE "undefined"."eql_v2_encrypted";\n'
const filePath = path.join(tmpDir, '0005_undef.sql')
@@ -90,6 +117,7 @@ describe('rewriteEncryptedAlterColumns', () => {
})
it('rewrites the double-quoted form produced by stack 0.15.0', async () => {
+ declarePlaintext('"transactions"', 'description')
const original =
'ALTER TABLE "transactions" ALTER COLUMN "description" SET DATA TYPE "undefined".""public"."eql_v2_encrypted"";\n'
const filePath = path.join(tmpDir, '0006_double.sql')
@@ -117,6 +145,7 @@ describe('rewriteEncryptedAlterColumns', () => {
})
it('skips the file passed in options.skip', async () => {
+ declarePlaintext('"t"', 'c')
const install = path.join(tmpDir, '0000_install-eql.sql')
const alter = path.join(tmpDir, '0002_alter.sql')
fs.writeFileSync(install, 'CREATE SCHEMA eql_v2;\n')
@@ -169,6 +198,7 @@ describe('rewriteEncryptedAlterColumns', () => {
]
it.each(V3_DOMAINS)('rewrites an ALTER COLUMN to %s', async (domain) => {
+ declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0007_v3.sql')
fs.writeFileSync(
filePath,
@@ -196,6 +226,7 @@ describe('rewriteEncryptedAlterColumns', () => {
...V3_DOMAINS,
'eql_v2_encrypted',
])('extracts the bare domain %s from a mangled ALTER', async (domain) => {
+ declarePlaintext('"t"', 'c')
const filePath = path.join(tmpDir, '0015_drift.sql')
fs.writeFileSync(
filePath,
@@ -243,6 +274,7 @@ describe('rewriteEncryptedAlterColumns', () => {
]
it.each(MANGLED_FORMS)('rewrites the v3 %s form', async (_label, emitted) => {
+ declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0008_form.sql')
fs.writeFileSync(
filePath,
@@ -265,6 +297,7 @@ describe('rewriteEncryptedAlterColumns', () => {
'"undefined"."public.eql_v2_encrypted"',
],
])('rewrites the previously unmatched v2 %s form', async (_label, emitted) => {
+ declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0009_v2form.sql')
fs.writeFileSync(
filePath,
@@ -281,6 +314,7 @@ describe('rewriteEncryptedAlterColumns', () => {
})
it('names the target domain in the guidance comment', async () => {
+ declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0010_comment.sql')
fs.writeFileSync(
filePath,
@@ -295,6 +329,7 @@ describe('rewriteEncryptedAlterColumns', () => {
})
it('notes that constraints/defaults/indexes are not carried over', async () => {
+ declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0016_constraints.sql')
fs.writeFileSync(
filePath,
@@ -309,6 +344,7 @@ describe('rewriteEncryptedAlterColumns', () => {
it('does not terminate the commented UPDATE placeholder with a semicolon', async () => {
// A runner that naively splits on `;` must not cut mid-comment.
+ declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0017_semicolon.sql')
fs.writeFileSync(
filePath,
@@ -328,6 +364,7 @@ describe('rewriteEncryptedAlterColumns', () => {
})
it('separates ADD/DROP/RENAME with --> statement-breakpoint, one exec stmt per chunk', async () => {
+ declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0018_breakpoint.sql')
fs.writeFileSync(
filePath,
@@ -353,6 +390,7 @@ describe('rewriteEncryptedAlterColumns', () => {
})
it('rewrites each statement to its own domain when v2 and v3 are mixed', async () => {
+ declarePlaintext('"a"', 'x', 'y')
const filePath = path.join(tmpDir, '0011_mixed.sql')
fs.writeFileSync(
filePath,
@@ -424,6 +462,152 @@ describe('rewriteEncryptedAlterColumns', () => {
expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
})
+ // A near-miss is quoted back to the user verbatim, so it must read as the
+ // offending statement alone. NEAR_MISS_RE opens with a lazy `[^;]*?`, which
+ // can only be bounded by the previous `;` — so without an explicit trim the
+ // reported "statement" drags in every comment and blank line since then.
+ it('reports a near-miss without the file-leading comment block', async () => {
+ const statement =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;'
+ const filePath = path.join(tmpDir, '0022_preamble.sql')
+ fs.writeFileSync(
+ filePath,
+ [
+ '-- Custom SQL migration file, put your code below! --',
+ '-- Hand-converts the email column in place.',
+ '',
+ statement,
+ '',
+ ].join('\n'),
+ )
+
+ const { skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].statement).toBe(statement)
+ })
+
+ // A statement the STRICT matcher already matched but skipped (here:
+ // source-unknown) is left on disk unchanged, so it still contains
+ // `SET DATA TYPE` and the broad near-miss scan finds it again. Before the
+ // preamble regex stripped a leading block comment, that second pass reported
+ // a DIFFERENT statement string (comment glued to the front) than the strict
+ // pass's `match.trim()`, so the dedup key never matched and the same
+ // statement came back twice: once correctly as `source-unknown`, once as
+ // `unrecognised-form` — contradictory advice (look for a hand-authored
+ // `USING` clause) for a statement that has none.
+ //
+ // The block comment must NOT sit at the very start of the file — that is
+ // the one shape a previous, narrower version of the preamble regex happened
+ // to handle. A realistic migration file has a preceding statement, so
+ // NEAR_MISS_RE's match starts at THAT statement's `;`, dragging the newline
+ // before the comment in too; a regex that only strips a comment anchored to
+ // the very start of the match fails here.
+ it('reports a block-comment-prefixed statement once, not twice', async () => {
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const filePath = path.join(tmpDir, '0040_block-preamble.sql')
+ fs.writeFileSync(
+ filePath,
+ [
+ 'CREATE TABLE "users" ("id" integer PRIMARY KEY);',
+ '/* note */',
+ alterSql,
+ '',
+ ].join('\n'),
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([
+ { file: filePath, statement: alterSql, reason: 'source-unknown' },
+ ])
+ })
+
+ // Same bug, but the comment sits on the ALTER's own line rather than its
+ // own — the preceding statement's `;` still starts the near-miss match
+ // before the (indented) comment, not at it.
+ it('reports an indented block-comment-prefixed statement once, not twice', async () => {
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const filePath = path.join(tmpDir, '0041_block-preamble-indented.sql')
+ fs.writeFileSync(
+ filePath,
+ [
+ 'CREATE TABLE "users" ("id" integer PRIMARY KEY);',
+ ` /* note */ ${alterSql}`,
+ '',
+ ].join('\n'),
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([
+ { file: filePath, statement: alterSql, reason: 'source-unknown' },
+ ])
+ })
+
+ // Same bug again, this time on the OTHER correct reason a near-miss can
+ // carry: the column is already encrypted, not merely undeclared.
+ it('reports a block-comment-prefixed statement once, not twice, for an already-encrypted column', async () => {
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const filePath = path.join(tmpDir, '0042_block-preamble-encrypted.sql')
+ fs.writeFileSync(
+ filePath,
+ [
+ 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" "public"."eql_v3_text_eq");',
+ '/* note */',
+ alterSql,
+ '',
+ ].join('\n'),
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([
+ { file: filePath, statement: alterSql, reason: 'already-encrypted' },
+ ])
+ })
+
+ it('reports a near-miss without a preceding statement-breakpoint marker', async () => {
+ const statement =
+ 'ALTER TABLE "users" ALTER COLUMN "meta" SET DATA TYPE eql_v3_json USING (meta)::jsonb;'
+ const filePath = path.join(tmpDir, '0023_breakpoint-preamble.sql')
+ fs.writeFileSync(
+ filePath,
+ [
+ 'CREATE TABLE "users" ("id" integer PRIMARY KEY);',
+ '--> statement-breakpoint',
+ statement,
+ '',
+ ].join('\n'),
+ )
+
+ const { skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].statement).toBe(statement)
+ })
+
+ it('keeps a multi-line near-miss statement intact after the preamble trim', async () => {
+ const statement = [
+ 'ALTER TABLE "users"',
+ ' ALTER COLUMN "email"',
+ ' SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;',
+ ].join('\n')
+ const filePath = path.join(tmpDir, '0024_multiline.sql')
+ fs.writeFileSync(filePath, `-- leading note\n\n${statement}\n`)
+
+ const { skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].statement).toBe(statement)
+ })
+
it('reports no skipped statements for a clean file', async () => {
const filePath = path.join(tmpDir, '0020_clean.sql')
fs.writeFileSync(filePath, 'CREATE TABLE "t" ("id" integer);\n')
@@ -435,6 +619,7 @@ describe('rewriteEncryptedAlterColumns', () => {
})
it('reports no skipped statements when the strict rewrite fully handled the file', async () => {
+ declarePlaintext('"users"', 'email')
const filePath = path.join(tmpDir, '0021_handled.sql')
fs.writeFileSync(
filePath,
@@ -447,7 +632,847 @@ describe('rewriteEncryptedAlterColumns', () => {
expect(skipped).toEqual([])
})
+ // A multi-line replacement inherits the author's `-- ` on line 1 ONLY, so
+ // rewriting a commented-out ALTER turns lines 2+ — including DROP COLUMN —
+ // into live SQL. Commented SQL never runs; leave it exactly as written.
+ describe('commented-out statements', () => {
+ it.each([
+ ['a line comment', '-- '],
+ ['an indented line comment', ' -- '],
+ ['a drizzle statement-breakpoint style prefix', '--> '],
+ ])('leaves an ALTER behind %s untouched', async (_label, prefix) => {
+ const original = `${prefix}ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n`
+ const filePath = path.join(tmpDir, '0030_commented.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ const updated = fs.readFileSync(filePath, 'utf-8')
+ expect(updated).toBe(original)
+ expect(updated).not.toContain('DROP COLUMN')
+ })
+
+ it('leaves an ALTER inside a block comment untouched', async () => {
+ const original = [
+ '/* superseded by 0031',
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '*/',
+ '',
+ ].join('\n')
+ const filePath = path.join(tmpDir, '0030_block.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
+ })
+
+ it('leaves an ALTER inside a NESTED block comment untouched', async () => {
+ const original = [
+ '/* outer /* inner */',
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '*/',
+ '',
+ ].join('\n')
+ const filePath = path.join(tmpDir, '0030_nested.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
+ })
+
+ it('does not report a commented-out near-miss', async () => {
+ const filePath = path.join(tmpDir, '0030_commented-using.sql')
+ fs.writeFileSync(
+ filePath,
+ '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;\n',
+ )
+
+ const { skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(skipped).toEqual([])
+ })
+
+ // The comment scan must not be fooled by `--` inside a string literal, or
+ // it would skip a live statement and leave broken SQL to fail at migrate.
+ it('still rewrites an ALTER that follows a "--" inside a string literal', async () => {
+ declarePlaintext('"users"', 'email')
+ const filePath = path.join(tmpDir, '0030_literal.sql')
+ fs.writeFileSync(
+ filePath,
+ [
+ `INSERT INTO "notes" ("body") VALUES ('a -- b');`,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\n'),
+ )
+
+ const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([filePath])
+ expect(fs.readFileSync(filePath, 'utf-8')).toContain(
+ 'ALTER TABLE "users" DROP COLUMN "email";',
+ )
+ })
+
+ // An apostrophe inside a DOUBLE-QUOTED identifier is not a string
+ // delimiter. Reading it as one opens a phantom literal whose "closing"
+ // quote is the apostrophe in the SAME identifier further down the file —
+ // PAST the commented-out ALTER — so the scan concludes the ALTER is live
+ // and rewrites it into a real DROP COLUMN. The CREATE that declared the
+ // column always sits above the ALTER, so a real corpus produces exactly
+ // this shape.
+ it('leaves a commented-out ALTER untouched when an earlier identifier holds an apostrophe', async () => {
+ const original = [
+ 'CREATE TABLE "users" (',
+ '\t"id" serial PRIMARY KEY NOT NULL,',
+ '\t"o\'brien_data" text',
+ ');',
+ '--> statement-breakpoint',
+ '-- ALTER TABLE "users" ALTER COLUMN "o\'brien_data" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\n')
+ const filePath = path.join(tmpDir, '0031_apostrophe.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ const updated = fs.readFileSync(filePath, 'utf-8')
+ expect(updated).toBe(original)
+ expect(updated).not.toContain('DROP COLUMN')
+ })
+
+ // A statement inside a single-quoted literal is DATA, not SQL. Rewriting it
+ // splices `--> statement-breakpoint` markers INSIDE the literal, so
+ // splitting the file the way drizzle's migrator does yields a bare, live
+ // `ALTER TABLE ... DROP COLUMN ...;` as a chunk of its own.
+ it('leaves an ALTER inside a string literal untouched', async () => {
+ const original = [
+ `INSERT INTO "audit_log" ("note") VALUES ('the reverted migration read:`,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ `(do not run it again)');`,
+ '',
+ ].join('\n')
+ const filePath = path.join(tmpDir, '0032_string-literal.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ const updated = fs.readFileSync(filePath, 'utf-8')
+ expect(updated).toBe(original)
+ expect(updated).not.toContain('DROP COLUMN')
+ expect(updated).not.toContain('--> statement-breakpoint')
+ })
+
+ // An UNTERMINATED quoted identifier must fail the same way an unterminated
+ // string literal does — by swallowing the rest of the file as inert. If it
+ // instead runs the scan cursor to the end, the loop exits and every
+ // commented-out ALTER below it is reported live and rewritten: the same
+ // destructive outcome as the apostrophe case above, one branch over.
+ it('leaves a commented-out ALTER untouched after an unterminated quoted identifier', async () => {
+ const original = [
+ 'CREATE TABLE "users" ("id" serial PRIMARY KEY NOT NULL, "email" text);',
+ '--> statement-breakpoint',
+ 'SELECT "unclosed FROM users;',
+ '--> statement-breakpoint',
+ '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\n')
+ const filePath = path.join(tmpDir, '0033_unterminated-identifier.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ const updated = fs.readFileSync(filePath, 'utf-8')
+ expect(updated).toBe(original)
+ expect(updated).not.toContain('DROP COLUMN')
+ })
+
+ // Regression pin, not a bug fix — this already behaves. A commented-out
+ // ALTER in a CRLF file must come back byte-identical.
+ it('leaves a commented-out ALTER with CRLF line endings byte-identical', async () => {
+ const original = [
+ 'CREATE TABLE "users" ("id" integer PRIMARY KEY);',
+ '--> statement-breakpoint',
+ '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\r\n')
+ const filePath = path.join(tmpDir, '0033_crlf.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
+ })
+
+ // #772 review, finding 1. Quote parity is a whole-file property: anything
+ // that makes the scanner disagree with Postgres about where a literal ENDS
+ // shifts every following token, so a commented-out ALTER downstream reads
+ // as live and is rewritten into a live DROP COLUMN.
+ //
+ // The two shapes below both do that. A `$$ … $$` body was documented as
+ // safe on the grounds that mis-reading one can only make us SKIP — that
+ // holds for an unterminated literal, but an odd apostrophe count inside the
+ // body ends a literal EARLY, which is the opposite direction.
+ it('leaves a commented-out ALTER alone after a dollar-quoted body with an odd apostrophe count', async () => {
+ declarePlaintext('"users"', 'email')
+ const original = [
+ "CREATE FUNCTION note() RETURNS text AS $$ don't $$ LANGUAGE sql;",
+ '--> statement-breakpoint',
+ '-- it\'s ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\n')
+ const filePath = path.join(tmpDir, '0033_dollar-quoted.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ const updated = fs.readFileSync(filePath, 'utf-8')
+ expect(updated).toBe(original)
+ expect(updated).not.toContain('DROP COLUMN')
+ })
+
+ it('leaves a commented-out ALTER alone after a tagged dollar-quoted body', async () => {
+ declarePlaintext('"users"', 'email')
+ const original = [
+ "CREATE FUNCTION note() RETURNS text AS $fn$ don't $fn$ LANGUAGE sql;",
+ '--> statement-breakpoint',
+ '-- it\'s ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\n')
+ const filePath = path.join(tmpDir, '0033_tagged-dollar.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
+ })
+
+ // A backslash-escaped quote inside an E'' string does not close it. Reading
+ // it as a close shifts parity for the rest of the file the same way.
+ it('leaves a commented-out ALTER alone after an E-string with a backslash-escaped quote', async () => {
+ declarePlaintext('"users"', 'email')
+ const original = [
+ 'CREATE TABLE "notes" ("body" text);',
+ '--> statement-breakpoint',
+ "INSERT INTO \"notes\" VALUES (E'a\\'b');",
+ '--> statement-breakpoint',
+ '-- it\'s ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\n')
+ const filePath = path.join(tmpDir, '0033_estring.sql')
+ fs.writeFileSync(filePath, original)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([])
+ const updated = fs.readFileSync(filePath, 'utf-8')
+ expect(updated).toBe(original)
+ expect(updated).not.toContain('DROP COLUMN')
+ })
+
+ // The dollar-quote skip must not swallow live SQL: a `$$` body sitting
+ // BEFORE a genuine plaintext ALTER still leaves that ALTER rewritable.
+ it('still rewrites a live ALTER that follows a dollar-quoted body', async () => {
+ declarePlaintext('"users"', 'email')
+ const filePath = path.join(tmpDir, '0033_dollar-then-live.sql')
+ fs.writeFileSync(
+ filePath,
+ [
+ "CREATE FUNCTION note() RETURNS text AS $$ don't $$ LANGUAGE sql;",
+ '--> statement-breakpoint',
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\n'),
+ )
+
+ const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([filePath])
+ expect(fs.readFileSync(filePath, 'utf-8')).toContain('DROP COLUMN')
+ })
+ })
+
+ // ADD+DROP+RENAME on a column that is ALREADY encrypted drops CIPHERTEXT, and
+ // unlike the plaintext case there is nothing left anywhere to backfill from.
+ describe('columns that are already encrypted', () => {
+ it('refuses to rewrite a domain change on a column created encrypted', async () => {
+ const create = path.join(tmpDir, '0000_create.sql')
+ fs.writeFileSync(
+ create,
+ [
+ 'CREATE TABLE "users" (',
+ '\t"id" integer PRIMARY KEY,',
+ '\t"email" "public"."eql_v3_text_eq"',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0001_domain-change.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(fs.readFileSync(alter, 'utf-8')).toBe(`${alterSql}\n`)
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'already-encrypted' },
+ ])
+ })
+
+ it('refuses to rewrite a domain change on a column ADDed encrypted', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_add.sql'),
+ 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n',
+ )
+ const alter = path.join(tmpDir, '0001_domain-change.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].reason).toBe('already-encrypted')
+ })
+
+ // An EQL domain installed into a NON-`public` schema is still ciphertext.
+ // The mangled forms special-case the literal `public`, so without the
+ // extra alternative this column falls to the plaintext residue and the
+ // ALTER drops a column full of ciphertext.
+ it('refuses to rewrite a domain change on a column encrypted in a non-public schema', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ [
+ 'CREATE TABLE "users" (',
+ '\t"id" integer PRIMARY KEY,',
+ '\t"email" "app"."eql_v3_text_eq"',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alter = path.join(tmpDir, '0001_domain-change.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].reason).toBe('already-encrypted')
+ })
+
+ // An ARRAY of an EQL domain is still ciphertext. The trailing delimiter
+ // lookahead must admit `[` or the column falls to the plaintext residue.
+ it('refuses to rewrite a domain change on a column encrypted as a domain array', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_add.sql'),
+ 'ALTER TABLE "users" ADD COLUMN "email" public.eql_v3_text_eq[];\n',
+ )
+ const alter = path.join(tmpDir, '0001_domain-change.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].reason).toBe('already-encrypted')
+ })
+
+ // A previous sweep of this directory leaves ADD tmp + RENAME behind. The
+ // column it renamed onto is encrypted, so a later domain change on it is
+ // just as destructive.
+ it('follows a RENAME from a previous sweep', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_swept.sql'),
+ [
+ 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_eq";',
+ '--> statement-breakpoint',
+ 'ALTER TABLE "users" DROP COLUMN "email";',
+ '--> statement-breakpoint',
+ 'ALTER TABLE "users" RENAME COLUMN "email__cipherstash_tmp" TO "email";',
+ '',
+ ].join('\n'),
+ )
+ const alter = path.join(tmpDir, '0001_domain-change.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].reason).toBe('already-encrypted')
+ })
+
+ it('reports the destructive statement once, not twice', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_add.sql'),
+ 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n',
+ )
+ fs.writeFileSync(
+ path.join(tmpDir, '0001_domain-change.sql'),
+ [
+ '-- Custom SQL migration file, put your code below! --',
+ '',
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\n'),
+ )
+
+ const { skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].reason).toBe('already-encrypted')
+ })
+
+ // The scoping matters: encrypting `contacts.email` must not be blocked by
+ // an unrelated `users.email` that happens to share a column name.
+ it('scopes the check to the table', async () => {
+ declarePlaintext('"contacts"', 'email')
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_add.sql'),
+ 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n',
+ )
+ const alter = path.join(tmpDir, '0001_contacts.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "contacts" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([alter])
+ expect(skipped).toEqual([])
+ })
+
+ // The ordinary case this rewrite exists for: plaintext today, encrypted
+ // after the ALTER. Nothing to preserve, so rewrite it.
+ it('still rewrites a plaintext column created in an earlier migration', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ 'CREATE TABLE "users" (\n\t"id" integer PRIMARY KEY,\n\t"email" text\n);\n',
+ )
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([alter])
+ expect(skipped).toEqual([])
+ })
+
+ // A commented-out ADD never ran, so it says nothing about the live schema.
+ it('ignores an encrypted ADD COLUMN that is commented out', async () => {
+ declarePlaintext('"users"', 'email')
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_add.sql'),
+ '-- ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n',
+ )
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([alter])
+ })
+
+ // `options.skip` excludes a file from being EDITED, not from describing the
+ // schema the other files are altering.
+ it('honours an encrypted column defined in the skipped file', async () => {
+ const skipPath = path.join(tmpDir, '0000_install.sql')
+ fs.writeFileSync(
+ skipPath,
+ 'ALTER TABLE "users" ADD COLUMN "email" eql_v3_text_eq;\n',
+ )
+ fs.writeFileSync(
+ path.join(tmpDir, '0001_domain-change.sql'),
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(
+ tmpDir,
+ {
+ skip: skipPath,
+ },
+ )
+
+ expect(rewritten).toEqual([])
+ expect(skipped[0]?.reason).toBe('already-encrypted')
+ })
+
+ // Ordering pin: this column is BOTH declared plaintext (0000) and made
+ // encrypted by a previous sweep (0001). `already-encrypted` is the more
+ // specific reason and must win over `source-unknown`.
+ it('reports already-encrypted even when the column was also declared plaintext', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" text);\n',
+ )
+ fs.writeFileSync(
+ path.join(tmpDir, '0001_swept.sql'),
+ [
+ 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_eq";',
+ '--> statement-breakpoint',
+ 'ALTER TABLE "users" DROP COLUMN "email";',
+ '--> statement-breakpoint',
+ 'ALTER TABLE "users" RENAME COLUMN "email__cipherstash_tmp" TO "email";',
+ '',
+ ].join('\n'),
+ )
+ const alter = path.join(tmpDir, '0002_domain-change.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].reason).toBe('already-encrypted')
+ })
+
+ // A commented-out encrypted column line inside an otherwise LIVE
+ // CREATE TABLE must still count as encrypted. The comment check applies
+ // only to the DECLARED scan (over-detecting plaintext costs data); the
+ // ENCRYPTED scan never re-checks comments inside a live CREATE TABLE, so
+ // this stays over-detecting — the safe direction — exactly as it was
+ // before the corpus learned to track declarations at all.
+ it('still counts a commented-out encrypted column inside a live CREATE TABLE as encrypted', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ [
+ 'CREATE TABLE "users" (',
+ '\t"id" integer,',
+ '\t-- "email" "public"."eql_v3_text_eq",',
+ '\t"email" text',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].reason).toBe('already-encrypted')
+ })
+
+ // #772 review, finding 3. The index reads CREATE TABLE, ADD COLUMN and
+ // RENAME, but never the strict matcher's OWN target. So a corpus where an
+ // earlier migration already converted the column — realistic wherever a
+ // previous stack version's ALTER ran before this sweep existed — leaves
+ // that column looking plaintext, and the SECOND conversion rewrites a
+ // column that now holds ciphertext. `declared` cannot catch it: the column
+ // IS declared, as plaintext, by the original CREATE TABLE.
+ it('rewrites only the first conversion when two ALTERs chain on one column', async () => {
+ declarePlaintext('"users"', 'email')
+ const first = path.join(tmpDir, '0002_v2.sql')
+ fs.writeFileSync(
+ first,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;\n',
+ )
+ const secondSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const second = path.join(tmpDir, '0003_v3.sql')
+ fs.writeFileSync(second, `${secondSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ // The first conversion is the legitimate plaintext -> encrypted one and
+ // must still happen — flagging it too would be the naive fix.
+ expect(rewritten).toEqual([first])
+ expect(fs.readFileSync(first, 'utf-8')).toContain('DROP COLUMN')
+ // The second targets ciphertext. Left byte-identical on disk.
+ expect(fs.readFileSync(second, 'utf-8')).toBe(`${secondSql}\n`)
+ expect(skipped).toEqual([
+ { file: second, statement: secondSql, reason: 'already-encrypted' },
+ ])
+ })
+
+ it('flags the second of two chained ALTERs inside a single file', async () => {
+ declarePlaintext('"users"', 'email')
+ const secondSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const filePath = path.join(tmpDir, '0002_chain.sql')
+ fs.writeFileSync(
+ filePath,
+ [
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;',
+ secondSql,
+ '',
+ ].join('\n'),
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([filePath])
+ const updated = fs.readFileSync(filePath, 'utf-8')
+ // Exactly one conversion was applied, and the v3 statement survives verbatim.
+ expect(updated.match(/DROP COLUMN/g)?.length).toBe(1)
+ expect(updated).toContain(secondSql)
+ expect(skipped).toEqual([
+ { file: filePath, statement: secondSql, reason: 'already-encrypted' },
+ ])
+ })
+
+ // A chain on DIFFERENT columns is not a chain — each is its own first
+ // conversion and both must be rewritten.
+ it('rewrites both when chained ALTERs target different columns', async () => {
+ declarePlaintext('"users"', 'email', 'name')
+ const first = path.join(tmpDir, '0002_email.sql')
+ fs.writeFileSync(
+ first,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+ const second = path.join(tmpDir, '0003_name.sql')
+ fs.writeFileSync(
+ second,
+ 'ALTER TABLE "users" ALTER COLUMN "name" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([first, second])
+ expect(skipped).toEqual([])
+ })
+
+ // A commented-out first conversion never ran, so the column is still
+ // plaintext and the live second conversion is the legitimate one.
+ it('does not treat a commented-out earlier ALTER as having encrypted the column', async () => {
+ declarePlaintext('"users"', 'email')
+ fs.writeFileSync(
+ path.join(tmpDir, '0002_v2.sql'),
+ '-- ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v2_encrypted;\n',
+ )
+ const live = path.join(tmpDir, '0003_v3.sql')
+ fs.writeFileSync(
+ live,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([live])
+ expect(fs.readFileSync(live, 'utf-8')).toContain('DROP COLUMN')
+ })
+
+ // #772 review, finding 4. `columnKey` keys on the schema exactly as written,
+ // but Postgres resolves an unqualified name through `search_path` — so
+ // `"users"` and `"public"."users"` are the SAME table. drizzle-kit emits
+ // unqualified; hand-written SQL and this sweep's own output are qualified.
+ // A corpus mixing the two split the index across two keys.
+ it('treats "public"."users" and "users" as the same table when checking encryption', async () => {
+ // 0000 declares the column unqualified — drizzle-kit's default output.
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_declare.sql'),
+ 'CREATE TABLE "users" ("email" text);\n',
+ )
+ // 0001 is a previous sweep's output, schema-qualified. The RENAME leaves
+ // `email` holding ciphertext, recorded under the QUALIFIED key.
+ fs.writeFileSync(
+ path.join(tmpDir, '0001_encrypt.sql'),
+ [
+ 'ALTER TABLE "public"."users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_eq";',
+ 'ALTER TABLE "public"."users" DROP COLUMN "email";',
+ 'ALTER TABLE "public"."users" RENAME COLUMN "email__cipherstash_tmp" TO "email";',
+ '',
+ ].join('\n'),
+ )
+ // 0002 changes the domain, written unqualified.
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0002_domain-change.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(fs.readFileSync(alter, 'utf-8')).toBe(`${alterSql}\n`)
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'already-encrypted' },
+ ])
+ })
+
+ // The mirror direction: declared/encrypted unqualified, altered qualified.
+ // Before the fix this reported `source-unknown` — a WRONG reason, telling
+ // the user the corpus never declares a column it declares two files up.
+ it('resolves an unqualified declaration against a schema-qualified ALTER', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ 'CREATE TABLE "users" ("email" "public"."eql_v3_text_eq");\n',
+ )
+ const alterSql =
+ 'ALTER TABLE "public"."users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0001_domain-change.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'already-encrypted' },
+ ])
+ })
+
+ // #772 review, finding 2. CREATE_TABLE_RE's body is lazy up to the first
+ // `)\s*;`, which can sit inside a `--` comment or a string DEFAULT. The
+ // body is then truncated and every column declared after that point is
+ // lost from BOTH indexes — so an encrypted column reads as undeclared.
+ it('indexes columns declared after a ");" inside a comment in the CREATE TABLE body', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ [
+ 'CREATE TABLE "users" (',
+ '\t"id" text, -- pk (uuid);',
+ '\t"email" "public"."eql_v3_text_eq"',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0001_domain-change.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'already-encrypted' },
+ ])
+ })
+
+ it('indexes columns declared after a ");" inside a string DEFAULT', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ [
+ 'CREATE TABLE "users" (',
+ '\t"note" text DEFAULT \'see (ticket);\',',
+ '\t"email" "public"."eql_v3_text_eq"',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0001_domain-change.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'already-encrypted' },
+ ])
+ })
+
+ // The destructive composition: the truncated body loses the encrypted
+ // column, but a LATER migration re-declares the table so `declared` is
+ // satisfied from elsewhere — the fail-closed rule passes and the ALTER
+ // drops a column full of ciphertext.
+ it('does not drop ciphertext when a truncated CREATE TABLE body hides the encrypted column', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_declare.sql'),
+ 'CREATE TABLE "users" ("email" text);\n',
+ )
+ fs.writeFileSync(
+ path.join(tmpDir, '0001_recreate.sql'),
+ [
+ 'DROP TABLE "users";',
+ '--> statement-breakpoint',
+ 'CREATE TABLE "users" (',
+ '\t"id" text, -- pk (uuid);',
+ '\t"email" "public"."eql_v3_text_eq"',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0002_domain-change.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(fs.readFileSync(alter, 'utf-8')).toBe(`${alterSql}\n`)
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'already-encrypted' },
+ ])
+ })
+
+ // Only the IMPLICIT schema collapses. A real non-public schema is a
+ // genuinely different table and must not be conflated with the bare name.
+ it('does not conflate a non-public schema with the unqualified table', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_app.sql'),
+ 'CREATE TABLE "app"."users" ("email" "public"."eql_v3_text_eq");\n',
+ )
+ fs.writeFileSync(
+ path.join(tmpDir, '0001_public.sql'),
+ 'CREATE TABLE "users" ("email" text);\n',
+ )
+ const alter = path.join(tmpDir, '0002_encrypt.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ // public.users.email is plaintext — app.users.email being encrypted is
+ // about a different table entirely.
+ expect(rewritten).toEqual([alter])
+ expect(skipped).toEqual([])
+ })
+ })
+
it('handles multiple ALTER statements in one file', async () => {
+ declarePlaintext('"a"', 'x', 'y')
const original = [
'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v2_encrypted;',
'ALTER TABLE "a" ALTER COLUMN "y" SET DATA TYPE eql_v2_encrypted;',
@@ -464,4 +1489,294 @@ describe('rewriteEncryptedAlterColumns', () => {
// Non-matching statement preserved
expect(updated).toContain('CREATE INDEX "a_z" ON "a" ("z");')
})
+
+ // Regression pin, not a bug fix — the matchers carry `/gi`, so a
+ // hand-lowercased migration is rewritten just like drizzle-kit's output.
+ it('rewrites a lowercase alter table ... set data type', async () => {
+ declarePlaintext('"users"', 'email')
+ const filePath = path.join(tmpDir, '0034_lowercase.sql')
+ fs.writeFileSync(
+ filePath,
+ 'alter table "users" alter column "email" set data type eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([filePath])
+ expect(skipped).toEqual([])
+ const updated = fs.readFileSync(filePath, 'utf-8')
+ expect(updated).toContain(
+ 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";',
+ )
+ expect(updated).toContain('ALTER TABLE "users" DROP COLUMN "email";')
+ expect(updated).not.toMatch(/set data type/i)
+ })
+
+ // A-2: the sweep is FAIL-CLOSED. A column the corpus never declares is
+ // UNKNOWN, not plaintext — it may already hold ciphertext, with its
+ // declaration sitting in a migration directory this sweep never sees.
+ describe('columns the corpus does not declare', () => {
+ it('refuses to rewrite a column the corpus never declares', async () => {
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ const updated = fs.readFileSync(alter, 'utf-8')
+ expect(updated).toBe(`${alterSql}\n`)
+ expect(updated).not.toContain('DROP COLUMN')
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'source-unknown' },
+ ])
+ })
+
+ it('rewrites a column declared plaintext by an ADD COLUMN', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_add.sql'),
+ 'ALTER TABLE "users" ADD COLUMN "email" text;\n',
+ )
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([alter])
+ expect(skipped).toEqual([])
+ })
+
+ // The skipped file is still part of the corpus: a column's current type
+ // comes from the migrations that ran before this one, edit-eligible or not.
+ it('counts a declaration living in the file passed to options.skip', async () => {
+ const install = path.join(tmpDir, '0000_install-eql.sql')
+ fs.writeFileSync(
+ install,
+ 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email" text);\n',
+ )
+ const alter = path.join(tmpDir, '0002_encrypt.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(
+ tmpDir,
+ {
+ skip: install,
+ },
+ )
+
+ expect(rewritten).toEqual([alter])
+ expect(skipped).toEqual([])
+ })
+
+ it('follows a RENAME when deciding a column is declared', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ [
+ 'CREATE TABLE "users" ("id" integer PRIMARY KEY, "email_address" text);',
+ '--> statement-breakpoint',
+ 'ALTER TABLE "users" RENAME COLUMN "email_address" TO "email";',
+ '',
+ ].join('\n'),
+ )
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([alter])
+ expect(skipped).toEqual([])
+ })
+
+ // A name inside a table/key constraint is a MENTION, not a declaration.
+ // Here every mention is followed by `)` or `,`, which the declaration
+ // regex's tail alone already rejects. A mention followed by a SQL keyword
+ // instead (e.g. `CHECK ("email" IS NOT NULL)`) is a separate case, closed
+ // by the regex's keyword lookahead and pinned by its own tests below.
+ // Counting either would put the rewrite back on the fail-open path.
+ it('does not treat a name inside PRIMARY KEY / REFERENCES as a declaration', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ [
+ 'CREATE TABLE "sessions" (',
+ '\t"id" integer,',
+ '\t"user_id" integer,',
+ '\tPRIMARY KEY ("id", "email"),',
+ '\tFOREIGN KEY ("user_id") REFERENCES "users"("email")',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alterSql =
+ 'ALTER TABLE "sessions" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'source-unknown' },
+ ])
+ })
+
+ // A column line commented out INSIDE a live CREATE TABLE never ran, so it
+ // declares nothing.
+ it('does not count a column commented out inside a live CREATE TABLE', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ [
+ 'CREATE TABLE "users" (',
+ '\t"id" integer PRIMARY KEY,',
+ '\t-- "email" text,',
+ '\t"name" text',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(
+ alter,
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n',
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toHaveLength(1)
+ expect(skipped[0].reason).toBe('source-unknown')
+ })
+
+ // A CHECK predicate mentioning a column has the same `"name" `
+ // shape as a declaration — `"email" IS` — but IS is a predicate keyword,
+ // not a type token. Without the keyword lookahead this would read as a
+ // declaration and rewrite the column, dropping any ciphertext it already
+ // holds via a declaration this sweep never sees.
+ it('does not treat a CHECK predicate mention as a declaration', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ [
+ 'CREATE TABLE "users" (',
+ '\t"id" integer PRIMARY KEY,',
+ '\tCONSTRAINT "c1" CHECK ("email" IS NOT NULL)',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ const updated = fs.readFileSync(alter, 'utf-8')
+ expect(updated).toBe(`${alterSql}\n`)
+ expect(updated).not.toContain('DROP COLUMN')
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'source-unknown' },
+ ])
+ })
+
+ // A constraint's NAME can coincide with a column's name — drizzle's
+ // `__unique` convention makes this contrived, but the keyword
+ // lookahead closes it regardless: a constraint name is always followed
+ // immediately by its constraint-type keyword (UNIQUE here), which the
+ // lookahead excludes.
+ it('does not let a same-named constraint declare the column', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ [
+ 'CREATE TABLE "users" (',
+ '\t"id" integer PRIMARY KEY,',
+ '\tCONSTRAINT "email" UNIQUE("id")',
+ ');',
+ '',
+ ].join('\n'),
+ )
+ const alterSql =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;'
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(alter, `${alterSql}\n`)
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([])
+ expect(skipped).toEqual([
+ { file: alter, statement: alterSql, reason: 'source-unknown' },
+ ])
+ })
+
+ // The keyword lookahead is pinned to a word boundary specifically so it
+ // does not eat real type names that merely START WITH a blocked
+ // keyword's letters: `interval`/`inet` both start "in" (colliding with
+ // IN) but must still count as genuine declarations.
+ it('still declares a column whose type name starts with a blocked keyword', async () => {
+ fs.writeFileSync(
+ path.join(tmpDir, '0000_create.sql'),
+ 'CREATE TABLE "events" ("email" interval, "note" inet);\n',
+ )
+ const alter = path.join(tmpDir, '0001_encrypt.sql')
+ fs.writeFileSync(
+ alter,
+ [
+ 'ALTER TABLE "events" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;',
+ 'ALTER TABLE "events" ALTER COLUMN "note" SET DATA TYPE eql_v3_text_search;',
+ '',
+ ].join('\n'),
+ )
+
+ const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir)
+
+ expect(rewritten).toEqual([alter])
+ expect(skipped).toEqual([])
+ })
+ })
+})
+
+// The three reasons drive very different user action — re-encrypt through the
+// staged lifecycle, fix a hand-authored cast, or go check the database. A
+// switch with no `default` arm means a missing case fails the build, but a
+// mis-MAPPED case (wiring `source-unknown` to the `already-encrypted` string)
+// compiles fine and ships wrong remediation into a data-loss decision. Pin the
+// mapping so that swap is caught.
+describe('describeSkipReason', () => {
+ it('describes already-encrypted as a re-encrypt-through-the-lifecycle action', () => {
+ const text = describeSkipReason('already-encrypted')
+ expect(text).toContain('ALREADY encrypted')
+ expect(text).toContain('DROP the ciphertext')
+ expect(text).toContain('`stash encrypt` lifecycle')
+ })
+
+ it('describes unrecognised-form as a hand-authored / unknown cast', () => {
+ const text = describeSkipReason('unrecognised-form')
+ expect(text).toContain('SET DATA TYPE ... USING')
+ expect(text).toContain('fails at migrate time')
+ })
+
+ it('describes source-unknown as a go-check-the-database action', () => {
+ const text = describeSkipReason('source-unknown')
+ expect(text).toContain('could not find where this column was declared')
+ expect(text).toContain("Check the column's current type in the database")
+ })
+
+ it('gives each reason a distinct description', () => {
+ const reasons = [
+ 'already-encrypted',
+ 'unrecognised-form',
+ 'source-unknown',
+ ] as const
+ const described = reasons.map(describeSkipReason)
+ expect(new Set(described).size).toBe(reasons.length)
+ })
})
diff --git a/packages/cli/src/__tests__/supabase-migration.test.ts b/packages/cli/src/__tests__/supabase-migration.test.ts
deleted file mode 100644
index bf2a57f6c..000000000
--- a/packages/cli/src/__tests__/supabase-migration.test.ts
+++ /dev/null
@@ -1,479 +0,0 @@
-import fs from 'node:fs'
-import os from 'node:os'
-import path from 'node:path'
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { detectSupabaseProject } from '../commands/db/detect.js'
-import {
- chooseSupabaseInstallMode,
- routeInstallPathForEqlVersion,
- validateInstallFlags,
-} from '../commands/db/install.js'
-import {
- SUPABASE_EQL_MIGRATION_FILENAME,
- writeSupabaseEqlMigration,
-} from '../commands/db/supabase-migration.js'
-import {
- DEFAULT_EQL_VERSION,
- resolveEqlVersion,
- SUPABASE_PERMISSIONS_SQL,
-} from '../installer/index.js'
-
-/**
- * Generate the migration header for testing purposes.
- * Mirrors the production function but imported for testing.
- */
-function migrationHeader(runner: string): string {
- return `-- CipherStash EQL — installed by \`${runner} stash eql install --supabase --migration\`.
---
--- This migration installs the CipherStash Encrypt Query Language (EQL) types,
--- functions, and operators into the \`eql_v2\` schema, then grants Supabase's
--- \`anon\`, \`authenticated\`, and \`service_role\` roles the access they need.
---
--- The all-zero \`YYYYMMDDHHMMSS\` prefix is intentional: Supabase orders
--- migrations lexically, so this file runs before any user migration that
--- references the \`eql_v2_encrypted\` type. Do not rename it.
---
--- To upgrade EQL, re-run the install command — it will refuse to overwrite
--- this file unless you pass --force.
---
--- Docs: https://cipherstash.com/docs/stack/cipherstash/supabase
-`
-}
-
-describe('detectSupabaseProject', () => {
- let tmpDir: string
-
- beforeEach(() => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-supa-detect-'))
- })
-
- afterEach(() => {
- fs.rmSync(tmpDir, { recursive: true, force: true })
- })
-
- it('detects only config.toml', () => {
- fs.mkdirSync(path.join(tmpDir, 'supabase'), { recursive: true })
- fs.writeFileSync(path.join(tmpDir, 'supabase', 'config.toml'), '')
-
- const info = detectSupabaseProject(tmpDir)
- expect(info.hasConfigToml).toBe(true)
- expect(info.hasMigrationsDir).toBe(false)
- expect(info.migrationsDir).toBe(
- path.resolve(tmpDir, 'supabase', 'migrations'),
- )
- })
-
- it('detects only the migrations directory', () => {
- fs.mkdirSync(path.join(tmpDir, 'supabase', 'migrations'), {
- recursive: true,
- })
-
- const info = detectSupabaseProject(tmpDir)
- expect(info.hasConfigToml).toBe(false)
- expect(info.hasMigrationsDir).toBe(true)
- })
-
- it('detects both config.toml and the migrations directory', () => {
- fs.mkdirSync(path.join(tmpDir, 'supabase', 'migrations'), {
- recursive: true,
- })
- fs.writeFileSync(path.join(tmpDir, 'supabase', 'config.toml'), '')
-
- const info = detectSupabaseProject(tmpDir)
- expect(info.hasConfigToml).toBe(true)
- expect(info.hasMigrationsDir).toBe(true)
- })
-
- it('returns false flags when neither marker is present', () => {
- const info = detectSupabaseProject(tmpDir)
- expect(info.hasConfigToml).toBe(false)
- expect(info.hasMigrationsDir).toBe(false)
- })
-
- it('honors a custom override path (relative + absolute)', () => {
- const customRel = 'db/migrations'
- fs.mkdirSync(path.join(tmpDir, customRel), { recursive: true })
-
- const relInfo = detectSupabaseProject(tmpDir, customRel)
- expect(relInfo.migrationsDir).toBe(path.resolve(tmpDir, customRel))
- expect(relInfo.hasMigrationsDir).toBe(true)
-
- const absPath = path.resolve(tmpDir, customRel)
- const absInfo = detectSupabaseProject(tmpDir, absPath)
- expect(absInfo.migrationsDir).toBe(absPath)
- expect(absInfo.hasMigrationsDir).toBe(true)
- })
-
- it('treats a file at the migrations path as a missing directory', () => {
- fs.mkdirSync(path.join(tmpDir, 'supabase'), { recursive: true })
- fs.writeFileSync(path.join(tmpDir, 'supabase', 'migrations'), 'not a dir')
-
- const info = detectSupabaseProject(tmpDir)
- expect(info.hasMigrationsDir).toBe(false)
- })
-})
-
-describe('writeSupabaseEqlMigration', () => {
- let tmpDir: string
-
- beforeEach(() => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-supa-write-'))
- })
-
- afterEach(() => {
- fs.rmSync(tmpDir, { recursive: true, force: true })
- })
-
- it('writes the file at the well-known filename', async () => {
- const migrationsDir = path.join(tmpDir, 'supabase', 'migrations')
-
- const result = await writeSupabaseEqlMigration({ migrationsDir })
-
- expect(path.basename(result.path)).toBe(SUPABASE_EQL_MIGRATION_FILENAME)
- expect(result.overwritten).toBe(false)
- expect(fs.existsSync(result.path)).toBe(true)
- })
-
- it('writes EQL SQL plus the SUPABASE_PERMISSIONS_SQL block', async () => {
- const migrationsDir = path.join(tmpDir, 'supabase', 'migrations')
- const result = await writeSupabaseEqlMigration({ migrationsDir })
-
- const contents = fs.readFileSync(result.path, 'utf-8')
- // Header comment block includes the detected runner instruction
- expect(contents).toMatch(
- /-- CipherStash EQL — installed by `(npx|bunx|pnpm dlx|yarn dlx) stash eql install --supabase --migration`/,
- )
- expect(contents).toContain('CipherStash')
- // EQL SQL body — the bundled supabase variant defines eql_v2. The
- // migration-file flow is v2-only, so the body must be the v2 generation:
- // assert the v2 composite type is present and no v3 schema leaks in. (A
- // bare `toContain('eql_v2')` isn't enough — the header + permissions
- // mention eql_v2 even if the body were v3.)
- expect(contents).toContain('eql_v2_encrypted')
- expect(contents).not.toContain('eql_v3')
- // Permissions block (single source of truth).
- expect(contents).toContain(SUPABASE_PERMISSIONS_SQL.trim())
- })
-
- it('creates the migrations directory if missing', async () => {
- const migrationsDir = path.join(tmpDir, 'supabase', 'migrations')
- expect(fs.existsSync(migrationsDir)).toBe(false)
-
- const result = await writeSupabaseEqlMigration({ migrationsDir })
-
- expect(fs.statSync(migrationsDir).isDirectory()).toBe(true)
- expect(fs.existsSync(result.path)).toBe(true)
- })
-
- it('throws when the file already exists and force is false', async () => {
- const migrationsDir = path.join(tmpDir, 'supabase', 'migrations')
- fs.mkdirSync(migrationsDir, { recursive: true })
- const existingPath = path.join(
- migrationsDir,
- SUPABASE_EQL_MIGRATION_FILENAME,
- )
- fs.writeFileSync(existingPath, '-- existing')
-
- await expect(writeSupabaseEqlMigration({ migrationsDir })).rejects.toThrow(
- /already exists/,
- )
-
- // Existing content untouched
- expect(fs.readFileSync(existingPath, 'utf-8')).toBe('-- existing')
- })
-
- it('overwrites when force is true', async () => {
- const migrationsDir = path.join(tmpDir, 'supabase', 'migrations')
- fs.mkdirSync(migrationsDir, { recursive: true })
- const existingPath = path.join(
- migrationsDir,
- SUPABASE_EQL_MIGRATION_FILENAME,
- )
- fs.writeFileSync(existingPath, '-- existing')
-
- const result = await writeSupabaseEqlMigration({
- migrationsDir,
- force: true,
- })
-
- expect(result.overwritten).toBe(true)
- expect(fs.readFileSync(result.path, 'utf-8')).not.toBe('-- existing')
- expect(fs.readFileSync(result.path, 'utf-8')).toContain('eql_v2')
- })
-
- it('sorts before realistic Supabase-style migration filenames', () => {
- const filenames = [
- SUPABASE_EQL_MIGRATION_FILENAME,
- '20251015120000_users.sql',
- '99999999999999_other.sql',
- ]
- const sorted = [...filenames].sort()
- expect(sorted[0]).toBe(SUPABASE_EQL_MIGRATION_FILENAME)
- })
-})
-
-describe('resolveEqlVersion', () => {
- it('defaults a missing version to DEFAULT_EQL_VERSION (v3)', () => {
- expect(DEFAULT_EQL_VERSION).toBe(3)
- expect(resolveEqlVersion(undefined)).toBe(3)
- })
-
- it('resolves explicit strings to their generation', () => {
- expect(resolveEqlVersion('2')).toBe(2)
- expect(resolveEqlVersion('3')).toBe(3)
- })
-})
-
-describe('validateInstallFlags', () => {
- it('returns null for an empty options object', () => {
- expect(validateInstallFlags({})).toBeNull()
- })
-
- it('returns null when --supabase + --migration is pinned to --eql-version 2', () => {
- // --migration is a v2-only path, so under the v3 default it needs an
- // explicit `--eql-version 2` alongside --supabase.
- expect(
- validateInstallFlags({
- supabase: true,
- migration: true,
- eqlVersion: '2',
- }),
- ).toBeNull()
- })
-
- it('returns null when --supabase is paired with --direct', () => {
- expect(validateInstallFlags({ supabase: true, direct: true })).toBeNull()
- })
-
- it('rejects --migration without --supabase', () => {
- const err = validateInstallFlags({ migration: true })
- expect(err).toMatch(/--migration/)
- expect(err).toMatch(/--supabase/)
- })
-
- it('rejects --direct without --supabase', () => {
- const err = validateInstallFlags({ direct: true })
- expect(err).toMatch(/--direct/)
- expect(err).toMatch(/--supabase/)
- })
-
- it('rejects --migrations-dir without --supabase', () => {
- const err = validateInstallFlags({ migrationsDir: 'db/migrations' })
- expect(err).toMatch(/--migrations-dir/)
- expect(err).toMatch(/--supabase/)
- })
-
- it('rejects --migration AND --direct together', () => {
- const err = validateInstallFlags({
- supabase: true,
- migration: true,
- direct: true,
- })
- expect(err).toMatch(/mutually exclusive/i)
- })
-
- it('accepts --eql-version 2 and 3', () => {
- expect(validateInstallFlags({ eqlVersion: '2' })).toBeNull()
- expect(validateInstallFlags({ eqlVersion: '3' })).toBeNull()
- expect(
- validateInstallFlags({ eqlVersion: '3', supabase: true, direct: true }),
- ).toBeNull()
- })
-
- it('rejects an unknown --eql-version value', () => {
- expect(validateInstallFlags({ eqlVersion: '4' })).toMatch(/--eql-version/)
- })
-
- it('rejects --eql-version 3 with --drizzle, --migration, --latest, or --migrations-dir', () => {
- expect(validateInstallFlags({ eqlVersion: '3', drizzle: true })).toMatch(
- /--drizzle/,
- )
- expect(
- validateInstallFlags({
- eqlVersion: '3',
- supabase: true,
- migration: true,
- }),
- ).toMatch(/--migration/)
- expect(validateInstallFlags({ eqlVersion: '3', latest: true })).toMatch(
- /--latest/,
- )
- // --migrations-dir only feeds the v2 Supabase migration-file path; the v3
- // direct install would silently ignore it (flagged in review of #547).
- expect(
- validateInstallFlags({
- eqlVersion: '3',
- supabase: true,
- migrationsDir: 'db/migrations',
- }),
- ).toMatch(/--migrations-dir/)
- })
-
- it('defaults to v3: a plain install with no version is valid', () => {
- expect(validateInstallFlags({})).toBeNull()
- expect(validateInstallFlags({ supabase: true })).toBeNull()
- })
-
- it('v2-only flags without an explicit --eql-version 2 are rejected under the v3 default', () => {
- // No eqlVersion passed → resolves to the v3 default, which can't do these.
- for (const opts of [
- { drizzle: true },
- { latest: true },
- { supabase: true, migration: true },
- { supabase: true, migrationsDir: 'db/migrations' },
- ]) {
- const err = validateInstallFlags(opts)
- expect(err).toMatch(/--eql-version 2/)
- }
- })
-
- it('accepts the v2-only paths when --eql-version 2 is explicit', () => {
- expect(validateInstallFlags({ eqlVersion: '2', drizzle: true })).toBeNull()
- expect(validateInstallFlags({ eqlVersion: '2', latest: true })).toBeNull()
- expect(
- validateInstallFlags({
- eqlVersion: '2',
- supabase: true,
- migration: true,
- }),
- ).toBeNull()
- })
-
- it('points --drizzle at the v3 migration command instead of only at --eql-version 2', () => {
- // Steering users to `--eql-version 2` is how new projects ended up on the
- // legacy generation (#691). Both the default-branch wording and the explicit
- // `--eql-version 3` wording must carry the pointer. Assert the distinctive
- // substring, not a bare `/--drizzle/` — that already appears in the base
- // message body and would pass even if the pointer were dropped.
- expect(validateInstallFlags({ drizzle: true })).toMatch(
- /stash eql migration --drizzle/,
- )
- expect(validateInstallFlags({ eqlVersion: '3', drizzle: true })).toMatch(
- /stash eql migration --drizzle/,
- )
- })
-
- it('does NOT offer the Drizzle alternative for the other v2-only flags', () => {
- // There is no `stash eql migration` route for these — suggesting one would
- // send the user to a command that cannot help them. Guards the pointer being
- // gated on `v2OnlyFlag === '--drizzle'`.
- for (const opts of [
- { latest: true },
- { supabase: true, migration: true },
- { supabase: true, migrationsDir: 'db/migrations' },
- ]) {
- expect(validateInstallFlags(opts)).not.toMatch(/stash eql migration/)
- expect(validateInstallFlags({ ...opts, eqlVersion: '3' })).not.toMatch(
- /stash eql migration/,
- )
- }
- })
-})
-
-describe('routeInstallPathForEqlVersion', () => {
- it('passes v2 routing through untouched', () => {
- expect(
- routeInstallPathForEqlVersion(2, { supabase: false, drizzle: true }),
- ).toEqual({ drizzle: true, useSupabaseInstallModeSelection: false })
- expect(
- routeInstallPathForEqlVersion(2, { supabase: true, drizzle: false }),
- ).toEqual({ drizzle: false, useSupabaseInstallModeSelection: true })
- })
-
- it('falls back from auto-detected drizzle to direct for v3, with a notice', () => {
- const routing = routeInstallPathForEqlVersion(3, {
- supabase: false,
- drizzle: true,
- })
- expect(routing.drizzle).toBe(false)
- expect(routing.useSupabaseInstallModeSelection).toBe(false)
- expect(routing.notice).toMatch(/Drizzle/)
- })
-
- it('skips the supabase migration-vs-direct mode selection for v3', () => {
- const routing = routeInstallPathForEqlVersion(3, {
- supabase: true,
- drizzle: false,
- })
- expect(routing.drizzle).toBe(false)
- expect(routing.useSupabaseInstallModeSelection).toBe(false)
- expect(routing.notice).toBeUndefined()
- })
-
- it('does NOT auto-imply --supabase from --migration', () => {
- // Even with --supabase: false explicitly, --migration must error.
- const err = validateInstallFlags({ supabase: false, migration: true })
- expect(err).not.toBeNull()
- })
-})
-
-describe('migrationHeader', () => {
- it('renders the header with the provided runner for npx', () => {
- const header = migrationHeader('npx')
- expect(header).toContain(
- '-- CipherStash EQL — installed by `npx stash eql install --supabase --migration`.',
- )
- })
-
- it('renders the header with the provided runner for bunx', () => {
- const header = migrationHeader('bunx')
- expect(header).toContain('bunx stash eql install')
- })
-
- it('renders the header with the provided runner for pnpm dlx', () => {
- const header = migrationHeader('pnpm dlx')
- expect(header).toContain('pnpm dlx stash eql install')
- })
-
- it('includes all expected documentation lines', () => {
- const header = migrationHeader('npx')
- expect(header).toContain('eql_v2_encrypted')
- expect(header).toContain(
- 'https://cipherstash.com/docs/stack/cipherstash/supabase',
- )
- })
-})
-
-describe('chooseSupabaseInstallMode', () => {
- const projectWith = {
- hasMigrationsDir: true,
- hasConfigToml: true,
- migrationsDir: '/tmp/x',
- }
- const projectWithout = {
- hasMigrationsDir: false,
- hasConfigToml: false,
- migrationsDir: '/tmp/x',
- }
-
- it('honors explicit --migration regardless of TTY or detection', () => {
- expect(
- chooseSupabaseInstallMode({ migration: true }, projectWithout, true),
- ).toBe('migration')
- expect(
- chooseSupabaseInstallMode({ migration: true }, projectWithout, false),
- ).toBe('migration')
- })
-
- it('honors explicit --direct regardless of TTY or detection', () => {
- expect(chooseSupabaseInstallMode({ direct: true }, projectWith, true)).toBe(
- 'direct',
- )
- expect(
- chooseSupabaseInstallMode({ direct: true }, projectWith, false),
- ).toBe('direct')
- })
-
- it('returns null in TTY mode when neither sub-flag is set (caller should prompt)', () => {
- expect(chooseSupabaseInstallMode({}, projectWith, true)).toBeNull()
- expect(chooseSupabaseInstallMode({}, projectWithout, true)).toBeNull()
- })
-
- it('non-interactive: defaults to migration when supabase/migrations exists', () => {
- expect(chooseSupabaseInstallMode({}, projectWith, false)).toBe('migration')
- })
-
- it('non-interactive: defaults to direct when supabase/migrations is missing', () => {
- expect(chooseSupabaseInstallMode({}, projectWithout, false)).toBe('direct')
- })
-})
diff --git a/packages/cli/src/__tests__/v2-retirement.test.ts b/packages/cli/src/__tests__/v2-retirement.test.ts
new file mode 100644
index 000000000..32140668b
--- /dev/null
+++ b/packages/cli/src/__tests__/v2-retirement.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { registry } from '../cli/registry.js'
+import { validateInstallFlags } from '../commands/db/install.js'
+
+const commands = registry.flatMap((group) => group.commands)
+
+describe('EQL v2 CLI retirement', () => {
+ it('removes Proxy configuration and v2 cutover commands from the manifest', () => {
+ const names = commands.map((command) => command.name)
+
+ expect(names).not.toContain('db push')
+ expect(names).not.toContain('db activate')
+ expect(names).not.toContain('encrypt cutover')
+ })
+
+ it('removes generation selection and release downloads from install and upgrade', () => {
+ for (const name of ['eql install', 'eql upgrade']) {
+ const command = commands.find((candidate) => candidate.name === name)
+ const flags = command?.flags?.map((flag) => flag.name) ?? []
+
+ expect(flags).not.toContain('--eql-version')
+ expect(flags).not.toContain('--latest')
+ expect(flags).not.toContain('--exclude-operator-family')
+ }
+ })
+
+ it('removes the Proxy choice from init', () => {
+ const init = commands.find((command) => command.name === 'init')
+ const flags = init?.flags?.map((flag) => flag.name) ?? []
+
+ expect(flags).not.toContain('--proxy')
+ expect(flags).not.toContain('--no-proxy')
+ })
+
+ it('points legacy install requests at the upstream EQL 2.3.1 release', () => {
+ const releaseUrl =
+ 'https://github.com/cipherstash/encrypt-query-language/releases/tag/eql-2.3.1'
+
+ expect(validateInstallFlags({ eqlVersion: '2' })).toContain(releaseUrl)
+ expect(validateInstallFlags({ latest: true })).toContain(releaseUrl)
+ })
+
+ it('rejects the obsolete operator-family flag because v3 self-adapts', () => {
+ expect(validateInstallFlags({ excludeOperatorFamily: true })).toMatch(
+ /self-adapts/,
+ )
+ })
+})
diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts
index 1b164fa5b..773e41c3a 100644
--- a/packages/cli/src/bin/main.ts
+++ b/packages/cli/src/bin/main.ts
@@ -21,6 +21,7 @@ import { fileURLToPath } from 'node:url'
import * as p from '@clack/prompts'
import { CliExit } from '../cli/exit.js'
import { renderCommandHelp } from '../cli/help.js'
+import { validateInstallFlags } from '../commands/db/install.js'
// Commands that depend on @cipherstash/stack are lazy-loaded in the switch below.
import {
authCommand,
@@ -112,8 +113,6 @@ Commands:
eql upgrade Upgrade EQL extensions to the latest version
eql status Show EQL installation status
- db push (EQL v2 + Proxy) Push encryption schema to eql_v2_configuration
- db activate (EQL v2 + Proxy) Promote pending → active without renames
db validate Validate encryption schema
db migrate Run pending encrypt config migrations
db test-connection Test database connectivity
@@ -123,7 +122,6 @@ Commands:
encrypt status Show per-column migration status (phase, progress, drift)
encrypt plan Diff intent (.cipherstash/migrations.json) vs observed state
encrypt backfill Resumably encrypt plaintext into the encrypted column
- encrypt cutover Rename swap encrypted → primary column (EQL v2 only)
encrypt drop Generate a migration to drop the plaintext column
env Mint deployment credentials and print them as env vars
@@ -163,7 +161,13 @@ function parseArgs(argv: string[]): ParsedArgs {
for (let i = 0; i < rest.length; i++) {
const arg = rest[i]
if (arg.startsWith('--')) {
- const key = arg.slice(2)
+ const raw = arg.slice(2)
+ const equals = raw.indexOf('=')
+ if (equals >= 0) {
+ values[raw.slice(0, equals)] = raw.slice(equals + 1)
+ continue
+ }
+ const key = raw
const nextArg = rest[i + 1]
if (nextArg !== undefined && !nextArg.startsWith('-')) {
values[key] = nextArg
@@ -190,19 +194,11 @@ async function runInstall(
flags: Record,
values: Record,
) {
+ rejectRetiredEqlFlags(flags, values)
await installCommand({
force: flags.force,
dryRun: flags['dry-run'],
supabase: flags.supabase,
- excludeOperatorFamily: flags['exclude-operator-family'],
- drizzle: flags.drizzle,
- latest: flags.latest,
- name: values.name,
- out: values.out,
- migration: flags.migration,
- direct: flags.direct,
- migrationsDir: values['migrations-dir'],
- eqlVersion: values['eql-version'],
databaseUrl: values['database-url'],
// An explicit `--database-url` is a one-shot install against that DB — leave
// the project untouched. Otherwise offer to scaffold a config for later.
@@ -214,16 +210,37 @@ async function runUpgrade(
flags: Record,
values: Record,
) {
+ rejectRetiredEqlFlags(flags, values)
await upgradeCommand({
dryRun: flags['dry-run'],
supabase: flags.supabase,
- excludeOperatorFamily: flags['exclude-operator-family'],
- latest: flags.latest,
- eqlVersion: values['eql-version'],
databaseUrl: values['database-url'],
})
}
+function rejectRetiredEqlFlags(
+ flags: Record,
+ values: Record,
+): void {
+ const present = (key: string) =>
+ flags[key] === true || Object.hasOwn(values, key)
+ const error = validateInstallFlags({
+ eqlVersion: values['eql-version'],
+ latest: present('latest'),
+ drizzle: present('drizzle'),
+ name: values.name,
+ out: values.out,
+ migration: present('migration'),
+ direct: present('direct'),
+ migrationsDir: values['migrations-dir'],
+ excludeOperatorFamily: present('exclude-operator-family'),
+ })
+ if (error) {
+ p.log.error(error)
+ throw new CliExit(1)
+ }
+}
+
async function runEqlCommand(
sub: string | undefined,
flags: Record,
@@ -282,19 +299,12 @@ async function runDbCommand(
p.log.warn(messages.db.aliasDeprecated(STASH, 'upgrade'))
await runUpgrade(flags, values)
break
- case 'push': {
- const { pushCommand } = await requireStack(
- () => import('../commands/db/push.js'),
- )
- await pushCommand({ dryRun: flags['dry-run'], databaseUrl })
- break
- }
+ case 'push':
case 'activate': {
- const { activateCommand } = await requireStack(
- () => import('../commands/db/activate.js'),
+ p.log.error(
+ `stash db ${sub} was removed with the EQL v2 CipherStash Proxy configuration lifecycle. EQL v3 stores query configuration in its column domains and has nothing to push or activate.`,
)
- await activateCommand({ databaseUrl })
- break
+ throw new CliExit(1)
}
case 'validate': {
const { validateCommand } = await requireStack(
@@ -366,18 +376,10 @@ async function runEncryptCommand(
break
}
case 'cutover': {
- const table = requireValue(values, 'table')
- const column = requireValue(values, 'column')
- const { cutoverCommand } = await requireStack(
- () => import('../commands/encrypt/cutover.js'),
+ p.log.error(
+ '`stash encrypt cutover` was the EQL v2 rename/config-promotion command and has been removed. For EQL v3, finish the backfill, switch the application to the encrypted column by name, then run `stash encrypt drop` for the plaintext column.',
)
- await cutoverCommand({
- table,
- column,
- proxyUrl: values['proxy-url'],
- migrationsDir: values['migrations-dir'],
- })
- break
+ throw new CliExit(1)
}
case 'drop': {
const table = requireValue(values, 'table')
diff --git a/packages/cli/src/cli/__tests__/help.test.ts b/packages/cli/src/cli/__tests__/help.test.ts
index 5e737bca5..de0c48906 100644
--- a/packages/cli/src/cli/__tests__/help.test.ts
+++ b/packages/cli/src/cli/__tests__/help.test.ts
@@ -15,9 +15,8 @@ describe('renderCommandHelp', () => {
)
expect(out).toContain('Options:')
expect(out).toContain('--force')
- // Value-taking flag renders its placeholder + default annotation.
- expect(out).toContain('--eql-version <2|3>')
- expect(out).toContain('(default: 3)')
+ // Value-taking flags render their placeholder.
+ expect(out).toContain('--database-url ')
})
it('surfaces a flag env var alongside its description', () => {
diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts
index d287dffde..dcbe319c0 100644
--- a/packages/cli/src/cli/registry.ts
+++ b/packages/cli/src/cli/registry.ts
@@ -130,15 +130,6 @@ export const registry: CommandGroup[] = [
description:
'Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migrate).',
},
- {
- name: '--proxy',
- description: 'Query encrypted data via CipherStash Proxy.',
- },
- {
- name: '--no-proxy',
- description: 'Query encrypted data directly via the SDK.',
- default: 'true',
- },
{
...REGION_FLAG,
description:
@@ -327,53 +318,6 @@ export const registry: CommandGroup[] = [
description:
'Use Supabase-compatible mode (auto-detected from DATABASE_URL).',
},
- {
- name: '--drizzle',
- description:
- 'Generate a Drizzle migration instead of direct install (auto-detected from project).',
- },
- {
- name: '--migration',
- description:
- 'Write a Supabase migration file instead of running SQL directly (requires --supabase).',
- },
- {
- name: '--direct',
- description:
- 'Run the SQL directly against the database (requires --supabase; mutually exclusive with --migration).',
- },
- {
- name: '--migrations-dir',
- value: '',
- description:
- 'Override the Supabase migrations directory (requires --supabase).',
- default: 'supabase/migrations',
- },
- EXCLUDE_OPERATOR_FAMILY_FLAG,
- {
- name: '--eql-version',
- value: '<2|3>',
- description:
- 'EQL generation to target. v3 (native eql_v3.* domain schema) is the default; pass `2` for the legacy composite type. --drizzle, --migration, --migrations-dir, and --latest are v2-only and require `--eql-version 2`.',
- default: '3',
- },
- {
- name: '--latest',
- description:
- 'Fetch the latest EQL from GitHub (v2 only — requires --eql-version 2).',
- },
- {
- name: '--name',
- value: '',
- description:
- 'With --drizzle: name for the generated migration (defaults to a scaffold name).',
- },
- {
- name: '--out',
- value: '',
- description:
- 'With --drizzle: directory to write the generated migration into.',
- },
DATABASE_URL_FLAG,
],
},
@@ -419,24 +363,7 @@ export const registry: CommandGroup[] = [
{
name: 'eql upgrade',
summary: 'Upgrade EQL extensions to the latest version',
- flags: [
- DRY_RUN_FLAG,
- SUPABASE_COMPAT_FLAG,
- EXCLUDE_OPERATOR_FAMILY_FLAG,
- {
- name: '--eql-version',
- value: '<2|3>',
- description:
- 'EQL generation to target. v3 is the default; pass `2` for the legacy composite type. --latest is v2-only and requires `--eql-version 2`.',
- default: '3',
- },
- {
- name: '--latest',
- description:
- 'Fetch the latest EQL from GitHub (v2 only — requires --eql-version 2).',
- },
- DATABASE_URL_FLAG,
- ],
+ flags: [DRY_RUN_FLAG, SUPABASE_COMPAT_FLAG, DATABASE_URL_FLAG],
},
{
name: 'eql status',
@@ -448,18 +375,6 @@ export const registry: CommandGroup[] = [
{
title: 'Database',
commands: [
- {
- name: 'db push',
- summary:
- 'Push encryption schema (writes pending if active config already exists)',
- flags: [DRY_RUN_FLAG, DATABASE_URL_FLAG],
- },
- {
- name: 'db activate',
- summary:
- 'Promote pending → active without renames (use after additive db push)',
- flags: [DATABASE_URL_FLAG],
- },
{
name: 'db validate',
summary: 'Validate encryption schema',
@@ -544,24 +459,6 @@ export const registry: CommandGroup[] = [
},
],
},
- {
- name: 'encrypt cutover',
- summary: 'Rename swap encrypted → primary column (EQL v2 only)',
- flags: [
- TABLE_FLAG,
- COLUMN_FLAG,
- {
- name: '--proxy-url',
- value: '',
- description: 'Proxy URL to verify against.',
- },
- {
- name: '--migrations-dir',
- value: '',
- description: 'Directory to write the rename migration into.',
- },
- ],
- },
{
name: 'encrypt drop',
summary: 'Generate a migration to drop the plaintext column',
diff --git a/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts b/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts
index 8674bdf9f..f75dfc948 100644
--- a/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts
+++ b/packages/cli/src/commands/db/__tests__/find-generated-migration.test.ts
@@ -2,13 +2,11 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { findGeneratedMigration } from '../install.js'
+import { findGeneratedMigration } from '../../eql/migration.js'
/**
- * `findGeneratedMigration` was promoted to public API by the `eql migration`
- * command and now has two consumers (`db install --drizzle` and
- * `eql migration --drizzle`), so its branches are pinned directly — a change for
- * one consumer must not silently break the other.
+ * `findGeneratedMigration` locates the custom migration scaffolded by
+ * `eql migration --drizzle`; pin its failure and ordering branches directly.
*/
describe('findGeneratedMigration', () => {
let dir: string
@@ -38,6 +36,7 @@ describe('findGeneratedMigration', () => {
'0010_install-eql.sql',
'0011_install-eql.txt', // not .sql
'0001_users.sql', // doesn't match the name
+ '9999_install-eql-backup.sql', // contains the name, but is not an exact match
]) {
writeFileSync(join(dir, f), '')
}
diff --git a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts b/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts
deleted file mode 100644
index 3bfc03804..000000000
--- a/packages/cli/src/commands/db/__tests__/generate-drizzle-migration.test.ts
+++ /dev/null
@@ -1,297 +0,0 @@
-import {
- existsSync,
- mkdirSync,
- mkdtempSync,
- readFileSync,
- rmSync,
- writeFileSync,
-} from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join, resolve } from 'node:path'
-import * as p from '@clack/prompts'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { CliExit } from '../../../cli/exit.js'
-import { messages } from '../../../messages.js'
-
-// clack is chrome — silence it and spy on the channels the generator reports
-// through. The spinner instance doubles as the `s` argument.
-const clack = vi.hoisted(() => ({
- spinnerInstance: { start: vi.fn(), stop: vi.fn() },
- log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() },
- intro: vi.fn(),
- note: vi.fn(),
- outro: vi.fn(),
-}))
-vi.mock('@clack/prompts', () => ({
- spinner: vi.fn(() => clack.spinnerInstance),
- log: clack.log,
- intro: clack.intro,
- note: clack.note,
- outro: clack.outro,
-}))
-
-// Only the child process is faked — everything else (fs, bundled SQL) is real.
-const spawnMock = vi.hoisted(() => vi.fn())
-vi.mock('node:child_process', () => ({ spawnSync: spawnMock }))
-
-// node:fs stays REAL by default — the generator's own writes, the bundled-SQL
-// reads, and cleanup all need it. We only need a seam on `writeFileSync` to
-// simulate the SQL-bundle write failing, so route it through a spy that
-// delegates to the real implementation (reset in beforeEach) and is overridden
-// only in the write-failure test.
-const fsWrite = vi.hoisted(() => ({
- real: (() => {
- throw new Error('fsWrite.real not initialised')
- }) as typeof import('node:fs').writeFileSync,
- spy: vi.fn(),
-}))
-vi.mock('node:fs', async (importOriginal) => {
- const actual = await importOriginal()
- fsWrite.real = actual.writeFileSync
- return { ...actual, default: actual, writeFileSync: fsWrite.spy }
-})
-
-// The `--latest` path calls `downloadEqlSql`; stub just that export so we can
-// make the network fetch reject. Everything else in the installer module
-// (including the real `loadBundledEqlSql` the other tests rely on) stays real.
-const downloadMock = vi.hoisted(() => vi.fn())
-vi.mock('@/installer/index.js', async (importOriginal) => ({
- ...(await importOriginal()),
- downloadEqlSql: downloadMock,
-}))
-
-// Pin the package manager so the argv assertion below is exact. Detection reads
-// the lockfile in cwd and npm_config_user_agent, both of which vary by how the
-// suite was launched. The runner MAPPING stays real — `pnpm` + `['exec', …]` is
-// part of what's being asserted, so mocking it would defeat the test.
-vi.mock('@/commands/init/utils.js', async (importOriginal) => ({
- ...(await importOriginal()),
- detectPackageManager: () => 'pnpm',
-}))
-
-const { generateDrizzleMigration } = await import('../install.js')
-
-const spinner = p.spinner()
-
-beforeEach(() => {
- // Default: `writeFileSync` behaves exactly like the real thing. Tests that
- // need it to fail override the implementation for their own scope.
- fsWrite.spy.mockImplementation(fsWrite.real)
-})
-
-afterEach(() => {
- vi.clearAllMocks()
-})
-
-/**
- * The v2 (`eql install --drizzle`) generator. Both regressions pinned here are
- * invocation-level: an unvalidated `--name` reaching a shell string, and
- * `--out` being computed for the search but never handed to drizzle-kit.
- */
-describe('generateDrizzleMigration', () => {
- let tmp: string
- beforeEach(() => {
- tmp = mkdtempSync(join(tmpdir(), 'stash-v2-drizzle-migration-'))
- })
- afterEach(() => {
- rmSync(tmp, { recursive: true, force: true })
- })
-
- it('rejects a migration name with unsafe characters before spawning', async () => {
- await expect(
- generateDrizzleMigration(spinner, {
- name: 'x; rm -rf ~',
- out: join(tmp, 'drizzle'),
- }),
- ).rejects.toBeInstanceOf(CliExit)
- expect(clack.log.error).toHaveBeenCalledWith(messages.eql.migrationBadName)
- expect(spawnMock).not.toHaveBeenCalled()
- })
-
- it.each([
- ['command substitution', 'a$(whoami)'],
- ['backticks', 'a`id`'],
- ['a space', 'add eql'],
- ['a path separator', '../escape'],
- // `''` is not nullish, so it slips past `options.name ?? DEFAULT` and hits
- // the regex, where `+` rejects it — `--name ''` aborts, it does NOT fall
- // back to `install-eql`. The one input where "empty" and "absent" diverge.
- ['an empty string', ''],
- ])('rejects %s in --name', async (_label, name) => {
- await expect(
- generateDrizzleMigration(spinner, { name, out: join(tmp, 'drizzle') }),
- ).rejects.toBeInstanceOf(CliExit)
- expect(spawnMock).not.toHaveBeenCalled()
- })
-
- it('rejects an unsafe name in a dry run too (validation precedes the preview)', async () => {
- await expect(
- generateDrizzleMigration(spinner, { name: 'x; ls', dryRun: true }),
- ).rejects.toBeInstanceOf(CliExit)
- expect(clack.note).not.toHaveBeenCalled()
- })
-
- it('passes --name and --out to drizzle-kit as argv (no shell) and writes the SQL', async () => {
- const out = join(tmp, 'db', 'migrations')
- mkdirSync(out, { recursive: true })
- // Stand in for drizzle-kit scaffolding an empty custom migration.
- spawnMock.mockImplementation(() => {
- writeFileSync(join(out, '0000_add-eql.sql'), '')
- return { status: 0, stdout: '', stderr: '' }
- })
-
- await generateDrizzleMigration(spinner, { name: 'add-eql', out })
-
- expect(spawnMock).toHaveBeenCalledTimes(1)
- const [command, argv] = spawnMock.mock.calls[0]
- // The whole argv, exactly — not `toContain` checks, which would still pass
- // if the runner prefix (`exec`) were dropped and drizzle-kit ran under the
- // wrong resolver. DEFECT 1: name and out are discrete inert tokens in an
- // array, never interpolated into a shell string. DEFECT 2: `--out` is
- // actually passed, so drizzle-kit writes where step 2 then looks. The
- // project-local `exec` form (not `dlx`) is asserted so a regression back to
- // download-and-run — which resolves a different drizzle.config.ts — fails.
- expect(command).toBe('pnpm')
- expect(argv).toEqual([
- 'exec',
- 'drizzle-kit',
- 'generate',
- '--custom',
- '--name=add-eql',
- `--out=${out}`,
- ])
-
- const written = readFileSync(join(out, '0000_add-eql.sql'), 'utf-8')
- expect(written).toContain('cs_migrations')
- })
-
- it('includes --out in the dry-run preview', async () => {
- const out = join(tmp, 'custom-out')
- await generateDrizzleMigration(spinner, { dryRun: true, out })
- expect(spawnMock).not.toHaveBeenCalled()
- expect(clack.note).toHaveBeenCalledWith(
- expect.stringContaining(`--out=${out}`),
- 'Dry Run',
- )
- })
-
- it('aborts with CliExit when drizzle-kit exits non-zero', async () => {
- spawnMock.mockReturnValue({ status: 1, stdout: '', stderr: 'boom' })
- await expect(
- generateDrizzleMigration(spinner, { out: join(tmp, 'drizzle') }),
- ).rejects.toBeInstanceOf(CliExit)
- expect(clack.log.error).toHaveBeenCalledWith('boom')
- })
-
- it('reports the spawn error when drizzle-kit cannot be launched', async () => {
- // spawnSync's ENOENT shape: null status, no captured stderr, `error` set.
- // `result.stderr?.trim()` is undefined, so the message falls through to the
- // second arm (`result.error?.message`) — the realistic "drizzle-kit isn't
- // installed" case. If the `?.` on stderr were dropped this shape would
- // throw a TypeError instead of reporting.
- spawnMock.mockReturnValue({
- status: null,
- stdout: null,
- stderr: null,
- error: Object.assign(new Error('spawnSync pnpm ENOENT'), {
- code: 'ENOENT',
- }),
- })
-
- await expect(
- generateDrizzleMigration(spinner, { out: join(tmp, 'drizzle') }),
- ).rejects.toBeInstanceOf(CliExit)
- expect(clack.log.error).toHaveBeenCalledWith('spawnSync pnpm ENOENT')
- expect(clack.log.info).toHaveBeenCalledWith(
- 'Make sure drizzle-kit is installed: npm install -D drizzle-kit',
- )
- })
-
- it('falls back to the exit status when there is no stderr or spawn error', async () => {
- // Third arm of the fallback chain: non-zero status, empty stderr, no
- // `error`. The user still gets a message instead of a blank error line.
- spawnMock.mockReturnValue({ status: 2, stdout: '', stderr: '' })
-
- await expect(
- generateDrizzleMigration(spinner, { out: join(tmp, 'drizzle') }),
- ).rejects.toBeInstanceOf(CliExit)
- expect(clack.log.error).toHaveBeenCalledWith(
- 'drizzle-kit exited with status 2.',
- )
- })
-
- it('defaults --out to an absolute drizzle/ when the flag is omitted', async () => {
- // Widest blast radius in the diff: because `--out` is always appended, a
- // flag-less invocation has its drizzle.config.ts `out` overridden by
- // `/drizzle`. The dry-run preview reaches that arm without touching
- // the filesystem or spawning.
- await generateDrizzleMigration(spinner, { dryRun: true })
- expect(clack.note).toHaveBeenCalledWith(
- expect.stringContaining(`--out=${resolve('drizzle')}`),
- 'Dry Run',
- )
- })
-
- it('points at --out when the generated migration is not where we looked', async () => {
- const out = join(tmp, 'drizzle')
- mkdirSync(out, { recursive: true })
- // drizzle-kit "succeeds" but wrote to its drizzle.config.ts `out`, not ours,
- // so step 2 finds nothing and aborts with the remediation hint (defect 2).
- spawnMock.mockReturnValue({ status: 0, stdout: '', stderr: '' })
-
- await expect(
- generateDrizzleMigration(spinner, { out }),
- ).rejects.toBeInstanceOf(CliExit)
- expect(clack.log.info).toHaveBeenCalledWith(
- 'If your drizzle.config.ts writes elsewhere, pass --out so it matches.',
- )
- })
-
- it('removes the scaffolded migration when writing the SQL fails', async () => {
- const out = join(tmp, 'drizzle')
- mkdirSync(out, { recursive: true })
- const scaffolded = join(out, '0000_install-eql.sql')
- // drizzle-kit scaffolds an empty custom migration where we look.
- spawnMock.mockImplementation(() => {
- fsWrite.real(scaffolded, '')
- return { status: 0, stdout: '', stderr: '' }
- })
- // Throw only on the big bundle write, letting the empty scaffold through.
- fsWrite.spy.mockImplementation(((path, data, ...rest) => {
- if (typeof data === 'string' && data.includes('cs_migrations')) {
- throw new Error('EACCES: permission denied')
- }
- return fsWrite.real(
- path as string,
- data as string,
- ...(rest as unknown[]),
- )
- }) as typeof import('node:fs').writeFileSync)
-
- await expect(
- generateDrizzleMigration(spinner, { out }),
- ).rejects.toBeInstanceOf(CliExit)
- // Without cleanup, an empty scaffolded migration survives and
- // `drizzle-kit migrate` applies and records it — a silent no-op migration.
- expect(existsSync(scaffolded)).toBe(false)
- expect(clack.log.error).toHaveBeenCalledWith('EACCES: permission denied')
- })
-
- it('cleans up the scaffold when --latest cannot fetch the SQL', async () => {
- const out = join(tmp, 'drizzle')
- mkdirSync(out, { recursive: true })
- const scaffolded = join(out, '0000_install-eql.sql')
- spawnMock.mockImplementation(() => {
- fsWrite.real(scaffolded, '')
- return { status: 0, stdout: '', stderr: '' }
- })
- // A network failure (rate limit / offline) is the likeliest real trip here.
- downloadMock.mockRejectedValueOnce(new Error('403 rate limited'))
-
- await expect(
- generateDrizzleMigration(spinner, { out, latest: true }),
- ).rejects.toBeInstanceOf(CliExit)
- expect(existsSync(scaffolded)).toBe(false)
- expect(clack.log.error).toHaveBeenCalledWith('403 rate limited')
- })
-})
diff --git a/packages/cli/src/commands/db/activate.ts b/packages/cli/src/commands/db/activate.ts
deleted file mode 100644
index 86e19af72..000000000
--- a/packages/cli/src/commands/db/activate.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { activateConfig, migrateConfig } from '@cipherstash/migrate'
-import * as p from '@clack/prompts'
-import pg from 'pg'
-import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
-import { loadStashConfig } from '@/config/index.js'
-
-/**
- * `stash db activate` — promote the pending EQL configuration to active
- * **without** renaming any columns.
- *
- * Used after `stash db push` when the new config is purely additive
- * (e.g. registering a brand-new encrypted column on a project that
- * already has an active config) — there are no `_encrypted` twins
- * to rename, so the cut-over rename step is unnecessary.
- *
- * For path 3 (existing populated column → encrypted via lifecycle), use
- * `stash encrypt cutover` instead. Cutover does the same activation but
- * also runs the physical rename.
- *
- * Mechanics: chains `eql_v2.migrate_config()` (pending → encrypting) and
- * `eql_v2.activate_config()` (encrypting → active) inside a single
- * transaction. Errors out clearly when there is no pending config to
- * activate.
- */
-export interface ActivateCommandOptions {
- databaseUrl?: string
-}
-
-export async function activateCommand(
- options: ActivateCommandOptions,
-): Promise {
- p.intro(runnerCommand(detectPackageManager(), 'stash db activate'))
-
- const stashConfig = await loadStashConfig({
- databaseUrlFlag: options.databaseUrl,
- })
- const client = new pg.Client({ connectionString: stashConfig.databaseUrl })
- let exitCode = 0
-
- try {
- await client.connect()
-
- const pending = await client.query<{ exists: boolean }>(
- "SELECT EXISTS(SELECT 1 FROM public.eql_v2_configuration WHERE state = 'pending') AS exists",
- )
- if (pending.rows[0]?.exists !== true) {
- p.log.error(
- 'No pending EQL configuration to activate. This applies to the EQL v2 + CipherStash Proxy config lifecycle — register a pending change before activating.',
- )
- exitCode = 1
- return
- }
-
- await client.query('BEGIN')
- try {
- await migrateConfig(client)
- await activateConfig(client)
- await client.query('COMMIT')
- } catch (err) {
- await client.query('ROLLBACK').catch(() => {})
- throw err
- }
-
- p.log.success('Pending configuration promoted to active.')
- p.outro('Done.')
- } catch (error) {
- p.log.error(error instanceof Error ? error.message : 'Activation failed.')
- exitCode = 1
- } finally {
- await client.end()
- }
- if (exitCode) process.exit(exitCode)
-}
diff --git a/packages/cli/src/commands/db/config-scaffold.ts b/packages/cli/src/commands/db/config-scaffold.ts
index 8bec16da2..b8b09f6d1 100644
--- a/packages/cli/src/commands/db/config-scaffold.ts
+++ b/packages/cli/src/commands/db/config-scaffold.ts
@@ -96,8 +96,8 @@ function writeStashConfig(configPath: string, clientPath: string): string {
}
/**
- * Create a `stash.config.ts` for the rest of the workflow (`db push` /
- * `schema build` / `encrypt *` load the encryption client through it).
+ * Create a `stash.config.ts` for the rest of the workflow (`db validate` and
+ * `encrypt *` load the encryption client through it).
* `eql install` itself doesn't need one — it resolves the database URL
* directly — so this is a setup convenience, never a blocker.
*
@@ -136,7 +136,7 @@ export async function offerStashConfig(
if (!isInteractive()) return null
const create = await p.confirm({
- message: `Create a ${CONFIG_FILENAME}? (needed later for db push / schema build / encrypt)`,
+ message: `Create a ${CONFIG_FILENAME}? (needed later for db validate / encrypt)`,
initialValue: true,
})
if (p.isCancel(create) || !create) {
diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts
index 8b230552f..b17c9061a 100644
--- a/packages/cli/src/commands/db/install.ts
+++ b/packages/cli/src/commands/db/install.ts
@@ -1,151 +1,65 @@
-import { spawnSync } from 'node:child_process'
-import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
-import { readdir } from 'node:fs/promises'
-import { join, resolve } from 'node:path'
-import {
- installMigrationsSchema,
- MIGRATIONS_SCHEMA_SQL,
-} from '@cipherstash/migrate'
+import { installMigrationsSchema } from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import pg from 'pg'
-import { CliExit } from '@/cli/exit.js'
-import {
- detectPackageManager,
- execArgv,
- execCommand,
- runnerCommand,
-} from '@/commands/init/utils.js'
import { resolveDatabaseUrl } from '@/config/database-url.js'
import { findConfigFile, loadStashConfig } from '@/config/index.js'
-import { isInteractive } from '@/config/tty.js'
-import {
- downloadEqlSql,
- EQLInstaller,
- loadBundledEqlSql,
- resolveEqlVersion,
-} from '@/installer/index.js'
+import { EQLInstaller } from '@/installer/index.js'
import { messages } from '@/messages.js'
+import { detectPackageManager, runnerCommand } from '../init/utils.js'
import { ensureEncryptionClient } from './client-scaffold.js'
import { offerStashConfig } from './config-scaffold.js'
-import {
- detectDrizzle,
- detectPrismaNext,
- detectSupabase,
- detectSupabaseProject,
- type SupabaseProjectInfo,
-} from './detect.js'
-import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js'
-import {
- SUPABASE_EQL_MIGRATION_FILENAME,
- writeSupabaseEqlMigration,
-} from './supabase-migration.js'
+import { detectPrismaNext, detectSupabase } from './detect.js'
-const DEFAULT_MIGRATION_NAME = 'install-eql'
-const DEFAULT_DRIZZLE_OUT = 'drizzle'
-
-/**
- * File-system-safe migration name: what drizzle-kit accepts, and shell-inert.
- *
- * Lives here rather than in `commands/eql/migration.ts` (the v3 generator) only
- * because that module already imports from this one — keeping the constant on
- * this side of the existing edge avoids an import cycle. Both generators share
- * it; there must not be a second copy.
- */
export const SAFE_MIGRATION_NAME = /^[\w-]+$/
+export const EQL_V2_RELEASE_URL =
+ 'https://github.com/cipherstash/encrypt-query-language/releases/tag/eql-2.3.1'
+
export interface InstallOptions {
force?: boolean
dryRun?: boolean
- /**
- * `undefined` means "auto-detect" (via {@link detectSupabase}). An explicit
- * `true`/`false` from the user is preserved and skips detection.
- */
- excludeOperatorFamily?: boolean
supabase?: boolean
- drizzle?: boolean
- latest?: boolean
- name?: string
- out?: string
- /**
- * Write the EQL install SQL into a Supabase migration file instead of
- * running it directly against the database. Requires `--supabase`.
- */
- migration?: boolean
- /**
- * Run the EQL install SQL directly against the database (current behavior).
- * Requires `--supabase`. Mutually exclusive with `--migration`.
- */
- direct?: boolean
- /**
- * Override the directory the Supabase migration file is written into.
- * Defaults to `/supabase/migrations`.
- */
- migrationsDir?: string
- /**
- * Connection string passed via `--database-url`. Used for this run only —
- * never persisted. See `src/config/database-url.ts`.
- */
databaseUrl?: string
- /**
- * How to handle a missing `stash.config.ts` — the caller owns this intent
- * rather than it being inferred from whether a URL was supplied:
- * - `'ensure'` — create it without asking (`stash init`, where the user has
- * already committed to setting the project up).
- * - `'offer'` — offer to create it (plain `stash eql install`). Default.
- * - `'skip'` — never scaffold (a one-shot `eql install --database-url ...`).
- */
scaffoldConfig?: 'ensure' | 'offer' | 'skip'
- /**
- * EQL generation to install: `'3'` (default, native `eql_v3.*` domain
- * schema) or `'2'` (composite `eql_v2_encrypted`). v3 currently supports the
- * direct install path only — `--drizzle`, `--migration`, `--migrations-dir`,
- * and `--latest` require an explicit `'2'`.
- */
- eqlVersion?: string
}
-/** Resolved install mode for the Supabase non-Drizzle branch. */
-export type SupabaseInstallMode = 'migration' | 'direct'
+/** Recognise removed argv before any I/O instead of silently ignoring it. */
+export function validateInstallFlags(
+ options: Record,
+): string | null {
+ if (options.eqlVersion === '2' || options.latest === true) {
+ return `EQL v2 installation has been removed from stash. To recover or restore an existing EQL v2 database dump, download the EQL 2.3.1 SQL from ${EQL_V2_RELEASE_URL}. New installs use EQL v3.`
+ }
+ if (options.eqlVersion !== undefined) {
+ return '`--eql-version` has been removed. EQL v3 is now the only installable generation; remove the flag.'
+ }
+ if (
+ options.drizzle === true ||
+ options.name !== undefined ||
+ options.out !== undefined
+ ) {
+ return '`eql install --drizzle` has been removed. Generate an EQL v3 Drizzle migration with `stash eql migration --drizzle` (and pass --name/--out there).'
+ }
+ if (options.migration === true || options.migrationsDir !== undefined) {
+ return '`eql install --migration` has been removed. Use `stash eql migration --drizzle` to keep the EQL v3 install in migration history, adding `--supabase` when needed.'
+ }
+ if (options.direct === true) {
+ return '`--direct` has been removed because `stash eql install` is now always a direct EQL v3 install.'
+ }
+ if (options.excludeOperatorFamily === true) {
+ return '`--exclude-operator-family` has been removed. The pinned EQL v3 bundle self-adapts when the database role cannot create its optional operator family.'
+ }
+ return null
+}
-/**
- * Resolve the database URL + encryption-client path for the install.
- *
- * A pre-existing `stash.config.ts` is authoritative — later workflow commands
- * (db push / schema build / encrypt) load the client through it — so we load
- * it. Without one, `eql install` doesn't need a config: resolve the URL
- * directly (flag → env → supabase → prompt). Decoupling install from the config
- * is what lets `npx stash eql install --database-url ...` run in a bare project
- * without the `stash` / `@cipherstash/stack` dependencies the config would
- * otherwise import (#579).
- *
- * Whether a missing config gets scaffolded is the caller's explicit intent
- * ({@link InstallOptions.scaffoldConfig}), not inferred from whether a URL was
- * supplied — `stash init` passes a resolved URL but still wants a config, while
- * a one-shot `--database-url` run wants the project left untouched. When no
- * config is created, `clientPath` comes back `null` so the caller skips the
- * client scaffold too.
- *
- * A one-shot run (`mode === 'skip'`, set when `--database-url` is passed alone)
- * bypasses config loading entirely: it must leave the project untouched, so it
- * neither honours nor scaffolds a config or client. This also means the flag
- * always wins — loading a config here could pick up a parent-directory config
- * with a hand-edited literal `databaseUrl` that silently overrides the flag and
- * installs EQL against the wrong database.
- */
async function resolveInstallContext(
options: InstallOptions,
s: ReturnType,
): Promise<{ databaseUrl: string; clientPath: string | null }> {
const mode = options.scaffoldConfig ?? 'offer'
-
- // A one-shot `--database-url` install leaves the project untouched: don't load
- // an existing config (see doc comment re: parent-dir literal override) and
- // don't scaffold. Resolve the URL directly and return a null clientPath.
const configPath = mode === 'skip' ? null : findConfigFile(process.cwd())
if (configPath) {
s.start('Loading stash.config.ts...')
- // Pass the path we already located so loadStashConfig doesn't re-walk the
- // filesystem to find it.
const config = await loadStashConfig(
{
databaseUrlFlag: options.databaseUrl,
@@ -154,11 +68,6 @@ async function resolveInstallContext(
configPath,
)
s.stop('Configuration loaded.')
-
- // A config with a hand-edited literal `databaseUrl` bypasses the resolver,
- // so a `--database-url` flag would be silently ignored. Surface it rather
- // than installing against a different database than the user asked for —
- // especially since `findConfigFile` walks up into parent directories.
if (
options.databaseUrl !== undefined &&
config.databaseUrl !== options.databaseUrl.trim()
@@ -174,45 +83,19 @@ async function resolveInstallContext(
databaseUrlFlag: options.databaseUrl,
supabase: options.supabase,
})
-
- // A dry run must not write scaffold files, so it never enters the scaffold
- // path (nor does an explicit `skip`).
if (mode === 'skip' || options.dryRun) {
return { databaseUrl, clientPath: null }
}
-
const clientPath = await offerStashConfig({ ensure: mode === 'ensure' })
return { databaseUrl, clientPath }
}
-/**
- * What `installCommand` actually did — so callers (notably `stash init`'s
- * completion gate) can tell "EQL is now in the database" from "a migration
- * file was written but nothing was applied yet".
- *
- * - `installed` / `already-installed`: EQL is present in the target database.
- * - `migration-generated`: a migration file was written (Drizzle, or the
- * Supabase `--migration` mode); the user still has to APPLY it
- * (`drizzle-kit migrate` / `supabase db push`) before EQL exists in the DB.
- * - `dry-run`: nothing was changed.
- *
- * Terminal error paths call `process.exit(1)` and never return an outcome.
- */
-export type InstallOutcome =
- | 'installed'
- | 'already-installed'
- | 'migration-generated'
- | 'dry-run'
+export type InstallOutcome = 'installed' | 'already-installed' | 'dry-run'
export async function installCommand(
options: InstallOptions,
): Promise {
p.intro(runnerCommand(detectPackageManager(), 'stash eql install'))
-
- // Validate mutually-exclusive / supabase-required flags BEFORE doing any
- // I/O. `--migration` and `--direct` only make sense in the Supabase flow;
- // they must NOT implicitly enable `--supabase`. (Strong product preference
- // — auto-enabling here has bitten users before.)
const flagError = validateInstallFlags(options)
if (flagError) {
p.log.error(flagError)
@@ -220,10 +103,6 @@ export async function installCommand(
process.exit(1)
}
- // Prisma Next owns EQL installation via its own migration system, so the
- // standalone installer is the wrong tool here. Refuse (before any DB I/O)
- // unless --force. Fires fast so a user who typed the wrong command gets a
- // pointer, not a half-applied install.
const prismaNextBlock = prismaNextInstallGuard(process.cwd(), options)
if (prismaNextBlock) {
p.log.error(prismaNextBlock)
@@ -232,143 +111,52 @@ export async function installCommand(
}
const s = p.spinner()
-
- // `eql install` only needs a database URL to install EQL. It does NOT require
- // a stash.config.ts: an existing config is authoritative (later workflow
- // commands rely on it), but without one we resolve the URL directly — so a
- // standalone `npx stash eql install --database-url ...` works with zero
- // project dependencies. A one-shot `--database-url` run leaves the project
- // untouched; otherwise we offer to scaffold a config for the rest of the
- // workflow (CIP-2986 / #579).
const { databaseUrl, clientPath } = await resolveInstallContext(options, s)
-
- // Safety net: if the user ran `eql install` without first running `init`,
- // scaffold the encryption client file so clientPath points somewhere real.
- // No-op when the file already exists. Skipped for a one-shot `--database-url`
- // install (clientPath === null) or a dry run, which leave the project
- // untouched — an existing config still yields a clientPath, so guard on dryRun
- // too.
if (clientPath && !options.dryRun) {
ensureEncryptionClient(clientPath, process.cwd(), databaseUrl)
}
- // Auto-detect provider hints when the user didn't explicitly pass flags.
- // CIP-2985.
- const resolved = resolveProviderOptions(options, databaseUrl)
-
- const eqlVersion: 2 | 3 = resolveEqlVersion(options.eqlVersion)
-
- // v3 supports the direct install path only. Explicit --drizzle/--migration
- // are rejected up-front by validateInstallFlags; auto-DETECTED drizzle or
- // migration modes fall back to direct here rather than erroring.
- const routing = routeInstallPathForEqlVersion(eqlVersion, resolved)
- if (routing.notice) {
- p.log.info(routing.notice)
- }
-
- if (routing.drizzle) {
- await generateDrizzleMigration(s, {
- name: options.name,
- out: options.out,
- dryRun: options.dryRun,
- latest: options.latest,
- supabase: resolved.supabase,
- excludeOperatorFamily: resolved.excludeOperatorFamily,
- })
- // A Drizzle migration file was written — NOT applied. EQL only lands in
- // the database once the user runs `drizzle-kit migrate`.
- return 'migration-generated'
- }
-
- // Supabase non-Drizzle path: pick between writing a migration file and
- // running SQL directly. Detection of `supabase/migrations/` only seeds the
- // prompt default — it never enables `--supabase`. Direct install is the
- // historical default and remains the fallback when nothing else applies.
- // v3 skips the mode selection entirely: direct install only for now.
- if (routing.useSupabaseInstallModeSelection) {
- const projectInfo = detectSupabaseProject(
- process.cwd(),
- options.migrationsDir,
+ const supabase =
+ options.supabase === undefined
+ ? detectSupabase(databaseUrl)
+ : options.supabase
+ if (options.supabase === undefined && supabase) {
+ p.log.info(
+ 'Detected Supabase database from DATABASE_URL — enabling --supabase.',
)
- const mode = await resolveSupabaseInstallMode(options, projectInfo)
-
- if (mode === 'migration') {
- // CIP: --latest in the migration path is not yet implemented. Loading
- // the bundled SQL works today; downloading from GitHub adds an extra
- // moving part we'd rather defer until someone needs it.
- if (options.latest) {
- p.log.error(
- '`eql install --supabase --migration --latest` is not yet supported. Please open an issue at https://github.com/cipherstash/stack/issues if you need this.',
- )
- p.outro('Installation aborted.')
- process.exit(1)
- }
-
- await writeSupabaseMigrationFile(s, {
- projectInfo,
- force: options.force,
- dryRun: options.dryRun,
- })
- // Migration file written, not applied — the user runs it via their
- // Supabase migration workflow (`supabase db push`).
- return 'migration-generated'
- }
- // mode === 'direct' — fall through to existing direct-install behavior.
}
if (options.dryRun) {
p.log.info('Dry run — no changes will be made.')
- const source = options.latest
- ? 'Would download EQL install script from GitHub'
- : `Would use bundled EQL${eqlVersion === 3 ? ' v3' : ''} install script`
- p.note(`${source}\nWould execute the SQL against the database`, 'Dry Run')
+ p.note(
+ 'Would use the pinned EQL v3 install SQL\nWould execute the SQL against the database',
+ 'Dry Run',
+ )
p.outro('Dry run complete.')
return 'dry-run'
}
- const installer = new EQLInstaller({
- databaseUrl,
- })
-
+ const installer = new EQLInstaller({ databaseUrl })
s.start('Checking database permissions...')
const permissions = await installer.checkPermissions()
-
- // CIP-2989: if the role is not a superuser and neither --supabase nor
- // --exclude-operator-family was passed, auto-fall back to the
- // no-operator-family (OPE) install variant. This is the same thing an
- // experienced user would do manually; doing it automatically avoids the
- // "what flag do I need?" failure mode on Supabase/Neon/RDS.
- let excludeOperatorFamily = resolved.excludeOperatorFamily
- if (
- !permissions.isSuperuser &&
- !resolved.supabase &&
- options.excludeOperatorFamily === undefined
- ) {
- excludeOperatorFamily = true
- s.stop(
- 'Role lacks superuser — falling back to the no-operator-family (OPE) install.',
- )
- } else if (!permissions.ok) {
+ if (!permissions.ok) {
s.stop('Insufficient database permissions.')
p.log.error('The connected database role is missing required permissions:')
- for (const missing of permissions.missing) {
- p.log.warn(` - ${missing}`)
- }
- p.note(
- 'EQL installation requires a role with CREATE SCHEMA,\nCREATE TYPE, and CREATE EXTENSION privileges.\n\nConnect with a superuser or admin role, or ask your\ndatabase administrator to grant the required permissions.',
- 'Required Permissions',
- )
+ for (const missing of permissions.missing) p.log.warn(` - ${missing}`)
p.outro('Installation aborted.')
process.exit(1)
+ } else if (!permissions.isSuperuser && !supabase) {
+ s.stop(
+ 'Database permissions verified. The EQL v3 bundle will self-skip optional operator classes this role cannot create.',
+ )
} else {
s.stop('Database permissions verified.')
}
if (!options.force) {
s.start('Checking if EQL is already installed...')
- const installed = await installer.isInstalled({ eqlVersion })
+ const installed = await installer.isInstalled()
s.stop(installed ? 'EQL is already installed.' : 'EQL is not installed.')
-
if (installed) {
p.log.info('Use --force to re-run the install script.')
p.outro('Nothing to do.')
@@ -376,21 +164,10 @@ export async function installCommand(
}
}
- const source = options.latest ? 'from GitHub (latest)' : 'bundled'
- s.start(
- `Installing EQL ${eqlVersion === 3 ? 'v3 ' : ''}extensions (${source})...`,
- )
- await installer.install({
- excludeOperatorFamily,
- supabase: resolved.supabase,
- latest: options.latest,
- eqlVersion,
- })
+ s.start('Installing EQL v3 extensions (pinned bundle)...')
+ await installer.install({ supabase })
s.stop('EQL extensions installed.')
-
- if (resolved.supabase) {
- p.log.success('Supabase role permissions granted.')
- }
+ if (supabase) p.log.success('Supabase role permissions granted.')
s.start('Installing cs_migrations tracking schema...')
const migrationsDb = new pg.Client({ connectionString: databaseUrl })
@@ -414,45 +191,17 @@ export async function installCommand(
return 'installed'
}
-/**
- * Merge explicit CLI flags with auto-detected hints.
- *
- * Rules:
- * - `--supabase` explicitly passed wins.
- * - `--supabase` not passed → if the database URL looks like Supabase, enable it.
- * - `--drizzle` explicitly passed wins.
- * - `--drizzle` not passed → if drizzle-orm/drizzle-kit/drizzle.config.* exists, enable it.
- * - `--exclude-operator-family` explicitly passed wins.
- */
-function resolveProviderOptions(
- options: InstallOptions,
- databaseUrl: string,
-): {
- supabase: boolean
- drizzle: boolean
- excludeOperatorFamily: boolean
-} {
- const supabase =
- options.supabase === undefined
- ? detectSupabase(databaseUrl)
- : options.supabase
- if (options.supabase === undefined && supabase) {
- p.log.info(
- 'Detected Supabase database from DATABASE_URL — enabling --supabase.',
- )
- }
-
- const drizzle =
- options.drizzle === undefined
- ? detectDrizzle(process.cwd())
- : options.drizzle
- if (options.drizzle === undefined && drizzle) {
- p.log.info('Detected Drizzle in this project — enabling --drizzle.')
- }
-
- const excludeOperatorFamily = options.excludeOperatorFamily ?? false
-
- return { supabase, drizzle, excludeOperatorFamily }
+export function prismaNextInstallGuard(
+ cwd: string,
+ options: Pick,
+): string | null {
+ if (options.force || !detectPrismaNext(cwd)) return null
+ return (
+ `${messages.eql.prismaNextDetected} (found prisma-next.config.* or @cipherstash/prisma-next). ` +
+ 'Prisma Next installs the EQL bundle through its own migration system — run ' +
+ '`prisma-next migrate` instead of `stash eql install`. ' +
+ 'Pass --force to run the standalone installer against this database anyway.'
+ )
}
export function printNextSteps(): void {
@@ -476,546 +225,3 @@ export function printNextSteps(): void {
'What next',
)
}
-
-/**
- * Generate a Drizzle migration that installs CipherStash EQL.
- *
- * Uses `drizzle-kit generate --custom` to scaffold an empty migration,
- * then loads the EQL install SQL (bundled by default, or from GitHub with
- * `--latest`) and writes it into the file.
- *
- * Exported for unit tests; not part of the CLI's public surface. Failures
- * throw {@link CliExit} rather than calling `process.exit` so the telemetry
- * `finally` in `main.ts` still runs (and the branches stay testable).
- */
-export async function generateDrizzleMigration(
- s: ReturnType,
- options: {
- name?: string
- out?: string
- dryRun?: boolean
- latest?: boolean
- supabase?: boolean
- excludeOperatorFamily?: boolean
- },
-) {
- const migrationName = options.name ?? DEFAULT_MIGRATION_NAME
- if (!SAFE_MIGRATION_NAME.test(migrationName)) {
- p.log.error(messages.eql.migrationBadName)
- p.outro('Migration aborted.')
- throw new CliExit(1)
- }
- const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT)
-
- // Run the PROJECT-LOCAL drizzle-kit (`pnpm exec` / `npx --no-install`), not
- // the download-and-run form (`pnpm dlx`) — it must resolve THIS project's
- // drizzle.config.ts and schema, and `--no-install` fails loudly instead of
- // surprise-downloading a possibly-different drizzle-kit major. Matches the v3
- // generator (`commands/eql/migration.ts`). Invoke via spawnSync with an argv
- // array (no shell), so a `--name` carrying spaces or shell metacharacters is
- // one inert token, never word-split or executed. `--out` is always passed so
- // drizzle-kit WRITES where we then LOOK — otherwise a project whose
- // drizzle.config.ts points elsewhere would have drizzle-kit write there while
- // we search `drizzle/` and fail in step 2.
- const pm = detectPackageManager()
- const { command, prefixArgs } = execArgv(pm)
- const drizzleArgs = [
- ...prefixArgs,
- 'drizzle-kit',
- 'generate',
- '--custom',
- `--name=${migrationName}`,
- `--out=${outDir}`,
- ]
- const drizzleCmd = `${execCommand(pm)} ${drizzleArgs.slice(prefixArgs.length).join(' ')}`
-
- if (options.dryRun) {
- p.log.info('Dry run — no changes will be made.')
- const source = options.latest
- ? 'Would download EQL install SQL from GitHub'
- : 'Would use bundled EQL install SQL'
- p.note(
- `Would run: ${drizzleCmd}\n${source}\nWould write SQL to migration file in ${outDir}`,
- 'Dry Run',
- )
- p.outro('Dry run complete.')
- return
- }
-
- let generatedMigrationPath: string | undefined
-
- // Step 1: Generate a custom Drizzle migration
- s.start('Generating custom Drizzle migration...')
-
- const result = spawnSync(command, drizzleArgs, {
- stdio: 'pipe',
- encoding: 'utf-8',
- })
- if (result.status !== 0) {
- s.stop('Failed to generate migration.')
- const stderr = result.stderr?.trim()
- p.log.error(
- stderr ||
- result.error?.message ||
- `drizzle-kit exited with status ${result.status ?? 'unknown'}.`,
- )
- p.log.info('Make sure drizzle-kit is installed: npm install -D drizzle-kit')
- p.outro('Migration aborted.')
- throw new CliExit(1)
- }
- s.stop('Custom Drizzle migration generated.')
-
- // Step 2: Find the generated migration file
- s.start('Locating generated migration file...')
-
- try {
- generatedMigrationPath = await findGeneratedMigration(outDir, migrationName)
- s.stop(`Found migration: ${generatedMigrationPath}`)
- } catch (error) {
- s.stop('Failed to locate migration file.')
- p.log.error(error instanceof Error ? error.message : String(error))
- p.log.info(
- 'If your drizzle.config.ts writes elsewhere, pass --out so it matches.',
- )
- p.outro('Migration aborted.')
- throw new CliExit(1)
- }
-
- // Step 3: Load the EQL SQL (bundled or from GitHub). Thread supabase /
- // excludeOperatorFamily through so the user's flag reaches the SQL
- // selection — previously this path ignored both (CIP-2988).
- let eqlSql: string
- const sqlOptions = {
- supabase: options.supabase ?? false,
- excludeOperatorFamily: options.excludeOperatorFamily ?? false,
- }
-
- if (options.latest) {
- s.start('Downloading EQL install script from GitHub (latest)...')
- try {
- eqlSql = await downloadEqlSql(sqlOptions)
- s.stop('EQL install script downloaded.')
- } catch (error) {
- s.stop('Failed to download EQL install script.')
- p.log.error(error instanceof Error ? error.message : String(error))
- cleanupMigrationFile(generatedMigrationPath)
- p.outro('Migration aborted.')
- throw new CliExit(1)
- }
- } else {
- s.start('Loading bundled EQL install script...')
- try {
- eqlSql = loadBundledEqlSql(sqlOptions)
- s.stop('Bundled EQL install script loaded.')
- } catch (error) {
- s.stop('Failed to load bundled EQL install script.')
- p.log.error(error instanceof Error ? error.message : String(error))
- cleanupMigrationFile(generatedMigrationPath)
- p.outro('Migration aborted.')
- throw new CliExit(1)
- }
- }
-
- // Step 4: Write the EQL SQL (and cs_migrations tracking schema) into
- // the migration file. Bundling both means `drizzle-kit migrate` rolls
- // everything needed for `stash encrypt ...` out to each environment
- // in one go, rather than requiring an out-of-band `stash eql install`.
- s.start('Writing EQL SQL into migration file...')
-
- const migrationContents = `${eqlSql}\n\n-- CipherStash encryption-migration tracking schema.\n-- Tracks per-column phase + backfill progress for \`stash encrypt\`.\n${MIGRATIONS_SCHEMA_SQL.trim()}\n`
-
- try {
- writeFileSync(generatedMigrationPath, migrationContents, 'utf-8')
- s.stop('EQL SQL written to migration file.')
- } catch (error) {
- s.stop('Failed to write migration file.')
- p.log.error(error instanceof Error ? error.message : String(error))
- cleanupMigrationFile(generatedMigrationPath)
- p.outro('Migration aborted.')
- throw new CliExit(1)
- }
-
- // Step 5: Sweep for sibling migrations that drizzle-kit may have emitted
- // with `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted`. These fail in
- // Postgres because there's no implicit cast from text/numeric to the
- // encrypted type. Rewrite them into a runnable ADD+DROP+RENAME sequence.
- // That sequence is equivalent to DROP+ADD — safe on an EMPTY table, but
- // data-destroying on a populated one — so the rewritten file carries a
- // comment steering populated tables to the staged `stash encrypt` path.
- // CIP-2991 + CIP-2994.
- let sweepIncomplete = false
- try {
- const { rewritten, skipped } = await rewriteEncryptedAlterColumns(outDir, {
- skip: generatedMigrationPath,
- })
- if (rewritten.length > 0) {
- p.log.info(
- `Rewrote ${rewritten.length} migration file(s) into a runnable ADD+DROP+RENAME for encrypted columns (safe on empty tables; see each file's header before running against populated data):`,
- )
- for (const file of rewritten) p.log.step(` - ${file}`)
- }
- if (skipped.length > 0) {
- sweepIncomplete = true
- p.log.warn(
- `Found ${skipped.length} ALTER-to-encrypted statement(s) the sweep could not rewrite automatically. Review and fix them before running your migrations:`,
- )
- for (const { file, statement } of skipped) {
- p.log.step(` - ${file}: ${statement}`)
- }
- }
- } catch (error) {
- sweepIncomplete = true
- p.log.warn(
- `Could not rewrite ALTER COLUMN migrations: ${error instanceof Error ? error.message : String(error)}`,
- )
- }
-
- p.log.success(`Migration created: ${generatedMigrationPath}`)
- if (sweepIncomplete) {
- p.log.warn(
- `The ALTER COLUMN sweep did not fully complete — review the sibling migrations in ${outDir} before running drizzle-kit migrate, or you may apply broken/unsafe SQL.`,
- )
- }
- p.note(
- `Run your Drizzle migrations to install EQL:\n\n ${execCommand(detectPackageManager())} drizzle-kit migrate`,
- 'Next Steps',
- )
- printNextSteps()
- p.outro('Done!')
-}
-
-/**
- * Validate flag combinations that we can detect without doing any I/O.
- *
- * Rules:
- * - `--migration` and `--direct` are mutually exclusive.
- * - `--migration`, `--direct`, and `--migrations-dir` each REQUIRE
- * `--supabase`. They do NOT auto-imply it.
- *
- * Returns a user-facing error message, or `null` when the flags are valid.
- */
-/**
- * Route the install between the drizzle / supabase-migration / direct paths
- * for the requested EQL generation. Pure — no I/O, no prompts — so the v3
- * fallback behaviour is unit-testable.
- *
- * v3 supports the direct path only: auto-detected drizzle falls back to
- * direct with a user-facing notice (explicit `--drizzle`/`--migration` are
- * already rejected by {@link validateInstallFlags}), and the Supabase
- * migration-vs-direct mode selection is skipped entirely.
- */
-export function routeInstallPathForEqlVersion(
- eqlVersion: 2 | 3,
- resolved: { supabase: boolean; drizzle: boolean },
-): {
- drizzle: boolean
- useSupabaseInstallModeSelection: boolean
- notice?: string
-} {
- if (eqlVersion === 3) {
- return {
- drizzle: false,
- useSupabaseInstallModeSelection: false,
- notice: resolved.drizzle
- ? 'EQL v3 does not support the Drizzle migration path yet — installing directly.'
- : undefined,
- }
- }
- return {
- drizzle: resolved.drizzle,
- useSupabaseInstallModeSelection: resolved.supabase,
- }
-}
-
-/**
- * `stash eql install` is the wrong tool in a Prisma Next project: Prisma Next
- * contributes a `migrations/cipherstash/` control space that installs the EQL
- * bundle as part of `prisma-next migrate`, in the same ledger as the
- * app schema. Running the standalone installer applies EQL out-of-band from
- * that ledger. `stash init --prisma-next` already skips the installer; this
- * guards the manual-invocation path too.
- *
- * Returns the guidance string when the install should be blocked, else null.
- * `--force` overrides (an escape hatch for a deliberate standalone install).
- * Pure + cwd-injected so it unit-tests without a real project.
- */
-export function prismaNextInstallGuard(
- cwd: string,
- options: Pick,
-): string | null {
- if (options.force) return null
- if (!detectPrismaNext(cwd)) return null
- return (
- `${messages.eql.prismaNextDetected} (found prisma-next.config.* or @cipherstash/prisma-next). ` +
- 'Prisma Next installs the EQL bundle through its own migration system — run ' +
- '`prisma-next migrate` instead of `stash eql install`. ' +
- 'Pass --force to run the standalone installer against this database anyway.'
- )
-}
-
-export function validateInstallFlags(options: InstallOptions): string | null {
- if (options.migration && options.direct) {
- return '`--migration` and `--direct` are mutually exclusive. Pick one.'
- }
-
- if (
- options.eqlVersion !== undefined &&
- options.eqlVersion !== '2' &&
- options.eqlVersion !== '3'
- ) {
- return `Unknown \`--eql-version ${options.eqlVersion}\`. Supported values: 2, 3.`
- }
-
- // `--migration` / `--direct` / `--migrations-dir` require `--supabase`. Check
- // this before the version gate below so a bare `--migration` still points at
- // the missing `--supabase` (its more fundamental prerequisite) rather than
- // the version.
- const subFlag =
- options.migration === true
- ? '--migration'
- : options.direct === true
- ? '--direct'
- : options.migrationsDir !== undefined
- ? '--migrations-dir'
- : null
-
- if (subFlag !== null && options.supabase !== true) {
- return `\`${subFlag}\` requires \`--supabase\`. Re-run with \`eql install --supabase ${subFlag}\`.`
- }
-
- // v3 is the default and installs via the direct path only. The Drizzle /
- // Supabase-migration / `--latest` paths are v2-only, so they require an
- // explicit `--eql-version 2` — otherwise they'd silently resolve to a v3
- // direct install that ignores what the user asked for. `--migrations-dir`
- // only feeds the Supabase v2 migration-file path, so it's in the same bucket.
- const resolvedVersion = resolveEqlVersion(options.eqlVersion)
- if (resolvedVersion === 3) {
- const v2OnlyFlag = options.drizzle
- ? '--drizzle'
- : options.migration
- ? '--migration'
- : options.latest
- ? '--latest'
- : options.migrationsDir !== undefined
- ? '--migrations-dir'
- : null
- if (v2OnlyFlag) {
- // `--drizzle` has a v3 answer now (`stash eql migration --drizzle`, #691),
- // so point there rather than at `--eql-version 2` — steering users to v2
- // is how new projects ended up on the legacy generation.
- const v3Alternative =
- v2OnlyFlag === '--drizzle'
- ? ' For a v3 install as a Drizzle migration, use `stash eql migration --drizzle`.'
- : ''
- return options.eqlVersion === '3'
- ? `\`--eql-version 3\` does not support \`${v2OnlyFlag}\` yet — v3 currently installs via the direct path only.${v3Alternative}`
- : `\`${v2OnlyFlag}\` requires EQL v2. Re-run with \`--eql-version 2 ${v2OnlyFlag}\` (v3 is the default and installs via the direct path only).${v3Alternative}`
- }
- }
-
- return null
-}
-
-/**
- * Pick the Supabase install mode purely from inputs. No I/O, no prompts —
- * easy to unit-test and to reason about.
- *
- * - Explicit `--migration` or `--direct` always wins.
- * - Otherwise, when stdin isn't a TTY, default to `migration` if the
- * `supabase/migrations/` directory exists and `direct` otherwise. This is
- * the same heuristic the prompt uses for its default — keeps interactive
- * and non-interactive runs aligned.
- * - When stdin IS a TTY and neither flag is set, returns `null` to signal
- * that the caller should prompt.
- */
-export function chooseSupabaseInstallMode(
- options: Pick,
- projectInfo: SupabaseProjectInfo,
- isTTY: boolean,
-): SupabaseInstallMode | null {
- if (options.migration) return 'migration'
- if (options.direct) return 'direct'
- if (!isTTY) return projectInfo.hasMigrationsDir ? 'migration' : 'direct'
- return null
-}
-
-/**
- * Resolve the install mode, prompting the user when stdin is a TTY and
- * neither sub-flag was passed. Pure logic lives in
- * {@link chooseSupabaseInstallMode}; this is the I/O wrapper.
- */
-async function resolveSupabaseInstallMode(
- options: InstallOptions,
- projectInfo: SupabaseProjectInfo,
-): Promise {
- const interactive = isInteractive()
- const decided = chooseSupabaseInstallMode(options, projectInfo, interactive)
-
- if (decided !== null) {
- if (
- !interactive &&
- options.migration === undefined &&
- options.direct === undefined
- ) {
- // Make non-interactive choices visible — surprise auto-decisions are a
- // common debugging headache.
- p.log.info(
- projectInfo.hasMigrationsDir
- ? `Detected ${projectInfo.migrationsDir} — defaulting to --migration in non-interactive mode.`
- : 'No supabase/migrations directory found — defaulting to --direct in non-interactive mode.',
- )
- }
- return decided
- }
-
- const defaultMode: SupabaseInstallMode = projectInfo.hasMigrationsDir
- ? 'migration'
- : 'direct'
-
- const choice = await p.select({
- message: 'How should EQL be installed?',
- initialValue: defaultMode,
- options: [
- {
- value: 'migration',
- label: 'Write a Supabase migration file',
- hint: projectInfo.hasMigrationsDir
- ? 'recommended — works with `supabase db reset`'
- : 'creates supabase/migrations/ if missing',
- },
- {
- value: 'direct',
- label: 'Run the SQL directly against the database',
- hint: 'fastest, but `supabase db reset` will not re-install EQL',
- },
- ],
- })
-
- if (p.isCancel(choice)) {
- p.cancel('Installation cancelled.')
- process.exit(0)
- }
-
- return choice
-}
-
-/**
- * Write the `00000000000000_cipherstash_eql.sql` migration to the project's
- * Supabase migrations directory. Mirrors the structure of the Drizzle
- * migration helper for parity in the user-facing flow.
- */
-async function writeSupabaseMigrationFile(
- s: ReturnType,
- opts: {
- projectInfo: SupabaseProjectInfo
- force?: boolean
- dryRun?: boolean
- },
-): Promise {
- const { projectInfo, force, dryRun } = opts
- const targetPath = join(
- projectInfo.migrationsDir,
- SUPABASE_EQL_MIGRATION_FILENAME,
- )
-
- if (dryRun) {
- p.log.info('Dry run — no changes will be made.')
- p.note(
- [
- `Would write Supabase migration to:\n ${targetPath}`,
- '',
- 'Apply with one of:',
- ' supabase db reset # local',
- ' supabase migration up # remote (or push)',
- ].join('\n'),
- 'Dry Run',
- )
- p.outro('Dry run complete.')
- return
- }
-
- s.start('Writing CipherStash EQL migration...')
- let result: { path: string; overwritten: boolean }
- try {
- result = await writeSupabaseEqlMigration({
- migrationsDir: projectInfo.migrationsDir,
- force,
- })
- } catch (error) {
- s.stop('Failed to write Supabase migration.')
- const message = error instanceof Error ? error.message : String(error)
- p.log.error(message)
- if (!force && message.includes('already exists')) {
- p.log.info(
- 'Re-run with --force to overwrite the existing migration file.',
- )
- }
- p.outro('Installation aborted.')
- process.exit(1)
- }
-
- s.stop(
- result.overwritten
- ? `Overwrote ${result.path}`
- : `Migration created: ${result.path}`,
- )
-
- p.note(
- [
- 'Apply the migration to install EQL:',
- '',
- ' supabase db reset # local — re-runs all migrations',
- ' supabase migration up # remote — applies pending migrations',
- '',
- 'EQL is NOT installed yet. The SQL only runs when Supabase applies the migration.',
- ].join('\n'),
- 'Next Steps',
- )
- printNextSteps()
- p.outro('Done!')
-}
-
-/**
- * Find the most recently generated migration file matching the given name.
- * Drizzle-kit generates flat SQL files like `0000_install-eql.sql`.
- */
-export async function findGeneratedMigration(
- outDir: string,
- migrationName: string,
-): Promise {
- if (!existsSync(outDir)) {
- throw new Error(
- `Drizzle output directory not found: ${outDir}\nMake sure drizzle-kit is configured correctly.`,
- )
- }
-
- const entries = await readdir(outDir)
-
- const matchingFiles = entries
- .filter((entry) => entry.endsWith('.sql') && entry.includes(migrationName))
- .sort()
-
- if (matchingFiles.length === 0) {
- throw new Error(
- `Could not find a migration matching "${migrationName}" in ${outDir}`,
- )
- }
-
- return join(outDir, matchingFiles[matchingFiles.length - 1])
-}
-
-/**
- * Attempt to clean up a generated migration file on failure.
- */
-export function cleanupMigrationFile(filePath: string | undefined): void {
- if (!filePath) return
-
- try {
- if (existsSync(filePath)) {
- unlinkSync(filePath)
- p.log.info(`Cleaned up migration file: ${filePath}`)
- }
- } catch {
- p.log.warn(`Could not clean up migration file: ${filePath}`)
- }
-}
diff --git a/packages/cli/src/commands/db/push.ts b/packages/cli/src/commands/db/push.ts
deleted file mode 100644
index 21602be01..000000000
--- a/packages/cli/src/commands/db/push.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { discardPendingConfig } from '@cipherstash/migrate'
-import type { CastAs, EncryptConfig } from '@cipherstash/stack/schema'
-import { toEqlCastAs } from '@cipherstash/stack/schema'
-import * as p from '@clack/prompts'
-import pg from 'pg'
-import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
-import { loadEncryptConfig, loadStashConfig } from '@/config/index.js'
-import { validateEncryptConfig } from './validate.js'
-
-/**
- * Transform an EncryptConfig so that all `cast_as` values use EQL-compatible
- * types (e.g. `'number'` → `'double'`, `'string'` → `'text'`, `'json'` → `'jsonb'`).
- */
-function toEqlConfig(config: EncryptConfig): Record {
- const tables: Record> = {}
-
- for (const [tableName, columns] of Object.entries(config.tables)) {
- const eqlColumns: Record = {}
- for (const [columnName, column] of Object.entries(columns)) {
- eqlColumns[columnName] = {
- ...column,
- cast_as: toEqlCastAs(column.cast_as as CastAs),
- }
- }
- tables[tableName] = eqlColumns
- }
-
- return { v: config.v, tables }
-}
-
-export async function pushCommand(options: {
- dryRun?: boolean
- databaseUrl?: string
-}) {
- p.intro(runnerCommand(detectPackageManager(), 'stash db push'))
- p.log.info(
- 'This command pushes the encryption schema to the database for use with CipherStash Proxy.\nIf you are using the SDK directly (Drizzle, Supabase, or plain PostgreSQL), this step is not required.',
- )
-
- const s = p.spinner()
-
- s.start('Loading stash.config.ts...')
- const config = await loadStashConfig({ databaseUrlFlag: options.databaseUrl })
- s.stop('Configuration loaded.')
-
- s.start(`Loading encrypt client from ${config.client}...`)
- const encryptConfig = await loadEncryptConfig(config.client)
- s.stop('Encrypt client loaded and validated.')
-
- // Run validation as a pre-push check (warn but don't block)
- if (encryptConfig) {
- const issues = validateEncryptConfig(encryptConfig, {})
- if (issues.length > 0) {
- p.log.warn('Schema validation found issues:')
- for (const issue of issues) {
- const logFn =
- issue.severity === 'error'
- ? p.log.error
- : issue.severity === 'warning'
- ? p.log.warn
- : p.log.info
- logFn(`${issue.table}.${issue.column}: ${issue.message}`)
- }
- console.log()
- }
- }
-
- // Transform SDK types to EQL types for the database
- const eqlConfig = toEqlConfig(encryptConfig)
-
- if (options.dryRun) {
- p.log.info('Dry run — no changes will be pushed.')
- p.note(JSON.stringify(eqlConfig, null, 2), 'Encryption Schema')
- p.outro('Dry run complete.')
- return
- }
-
- const client = new pg.Client({ connectionString: config.databaseUrl })
- let exitCode = 0
-
- try {
- s.start('Connecting to Postgres...')
- await client.connect()
- s.stop('Connected to Postgres.')
-
- // `db push` writes `public.eql_v2_configuration` — a v2 + CipherStash Proxy
- // artifact. EQL v3 has no configuration table (config lives in each
- // column's `eql_v3.*` domain type) and nothing reads it, so if the table is
- // absent — a v3-only database, or a database with no EQL installed at all —
- // there's nothing to push. Check the table directly (one query on the
- // connection we already hold) and exit cleanly instead of failing later
- // with a raw `relation "public.eql_v2_configuration" does not exist`.
- const tableResult = await client.query<{ reg: string | null }>(
- "SELECT to_regclass('public.eql_v2_configuration') AS reg",
- )
- if (tableResult.rows[0]?.reg == null) {
- p.log.info(
- "`stash db push` writes the EQL v2 configuration table, which this database doesn't have. It only applies to EQL v2 with CipherStash Proxy — under EQL v3 (the default) encryption config lives in each column's `eql_v3.*` type, so there's nothing to push. For a v2 + Proxy setup, run `stash eql install --eql-version 2` first.",
- )
- p.outro('Nothing to do.')
- return
- }
-
- s.start('Checking public.eql_v2_configuration state...')
- const activeResult = await client.query<{ exists: boolean }>(
- "SELECT EXISTS(SELECT 1 FROM public.eql_v2_configuration WHERE state = 'active') AS exists",
- )
- const hasActive = activeResult.rows[0]?.exists === true
- s.stop(
- hasActive
- ? 'Active configuration found.'
- : 'No active configuration yet (first push).',
- )
-
- if (!hasActive) {
- // First push: nothing to rename, no risk of contention with a live
- // Proxy reading the active config. Insert directly as `active`.
- s.start('Writing initial active configuration...')
- await client.query(
- "INSERT INTO public.eql_v2_configuration (state, data) VALUES ('active', $1)",
- [eqlConfig],
- )
- s.stop('Active configuration written.')
- p.outro('Push complete. Encryption is live.')
- return
- }
-
- // Active config already exists. Write the new config as `pending` so
- // the EQL state machine (pending → encrypting → active) can mediate
- // the change — the same flow Proxy uses for hot-reloads. The user
- // promotes pending → active by running either `stash encrypt cutover`
- // (when columns need renaming, e.g. `_encrypted` → ``) or
- // `stash db activate` (when the change is purely additive).
- s.start('Replacing pending configuration...')
- await client.query('BEGIN')
- try {
- await discardPendingConfig(client)
- await client.query(
- "INSERT INTO public.eql_v2_configuration (state, data) VALUES ('pending', $1)",
- [eqlConfig],
- )
- await client.query('COMMIT')
- } catch (err) {
- await client.query('ROLLBACK').catch(() => {})
- throw err
- }
- s.stop('Pending configuration written.')
-
- p.note(
- [
- 'A pending configuration is registered but not yet active. The current',
- 'active configuration continues to serve reads until you finalise it:',
- '',
- ' stash encrypt cutover --table T --column C',
- ' Renames `_encrypted` → `` (and `` → `_plaintext`),',
- ' then promotes pending → active. Use this when the new config replaces',
- ' a column you migrated via `stash encrypt backfill`.',
- '',
- ' stash db activate',
- ' Promotes pending → active without renaming. Use this when the new',
- ' config purely adds columns or changes index ops on already-active',
- ' columns (no `_encrypted` twin to swap in).',
- ].join('\n'),
- 'Next step',
- )
- p.outro('Push complete (pending).')
- } catch (error) {
- s.stop('Failed.')
- p.log.error(
- error instanceof Error ? error.message : 'Failed to push configuration.',
- )
- exitCode = 1
- } finally {
- await client.end()
- }
- if (exitCode) process.exit(exitCode)
-}
diff --git a/packages/cli/src/commands/db/rewrite-migrations.ts b/packages/cli/src/commands/db/rewrite-migrations.ts
index a96dc5229..2e0c33c8b 100644
--- a/packages/cli/src/commands/db/rewrite-migrations.ts
+++ b/packages/cli/src/commands/db/rewrite-migrations.ts
@@ -95,12 +95,559 @@ const ALTER_COLUMN_TO_ENCRYPTED_RE = new RegExp(
const NEAR_MISS_RE =
/[^;]*?\bSET\s+DATA\s+TYPE\b[^;]*?\beql_v[23][a-z0-9_]*[^;]*?;/gi
+/**
+ * Strips any run of blank lines, `--` line comments (including drizzle-kit's
+ * `--> statement-breakpoint`), and `/* … *\/` block comments — in any order,
+ * repeated as many times as they occur — from the head of a
+ * {@link NEAR_MISS_RE} match.
+ *
+ * That regex opens with a lazy `[^;]*?`, whose only left boundary is the
+ * previous `;` — or the start of the file when there is no preceding statement.
+ * So the raw match drags in every comment and blank line since then, and a
+ * near-miss preceded by a comment block gets reported to the user with that
+ * whole block glued to its front. Strip the preamble so the statement we quote
+ * back reads as the offending statement alone.
+ *
+ * The block comment is matched as its OWN loop alternative rather than a
+ * single group anchored ahead of the line-comment loop, because the latter
+ * only works when the block comment sits at the very start of the file: in
+ * the far more common case — a preceding statement earlier in the same file —
+ * the match starts at THAT statement's `;`, so it opens with the newline
+ * after it, not with the comment. An anchored `^(?:/\*…\*\/\s*)?` can't match
+ * past that newline to reach the comment, so it silently matches nothing and
+ * leaves the comment attached to the reported statement. Folding the block
+ * comment into the repeating loop lets it match after any number of leading
+ * newlines/line-comments, in any interleaving.
+ *
+ * The block comment strip is NOT cosmetic: a statement the strict
+ * {@link ALTER_COLUMN_TO_ENCRYPTED_RE} matched but skipped (`already-encrypted`
+ * or `source-unknown`) is left on disk unchanged, so it still contains
+ * `SET DATA TYPE` and the broad scan below finds it again. Without stripping
+ * the block comment here, that second pass reports a DIFFERENT statement string
+ * (comment glued to the front) than the strict pass's `match.trim()`, so
+ * {@link rewriteEncryptedAlterColumns}'s dedup key never matches and the same
+ * statement is reported twice — once with the correct reason, once as
+ * `unrecognised-form`, whose guidance (look for a hand-authored `USING`) is
+ * wrong for a statement the strict matcher already matched. Stripping the
+ * comment here makes both passes agree on the statement text so the second
+ * report collapses into the first.
+ *
+ * Known residue, accepted rather than fixed: a NESTED closed block comment
+ * ahead of a live ALTER (`/* outer /* inner *\/ still *\/`) still
+ * double-reports. The block-comment alternative's `*?` is lazy, so it stops
+ * at the FIRST `*\/` — consuming only `/* outer /* inner *\/` — and leaves
+ * `` still *\/`` glued to the front of the next iteration, which the
+ * line-comment alternative doesn't recognise either. That residual text rides
+ * along into the `unrecognised-form` report.
+ */
+const STATEMENT_PREAMBLE_RE =
+ /^(?:\s*\/\*[\s\S]*?\*\/|[^\S\n]*(?:--[^\n]*)?\n)*/
+
+/** Drop the leading blank/comment lines a `[^;]*?`-anchored match dragged in. */
+function trimStatementPreamble(statement: string): string {
+ return statement.replace(STATEMENT_PREAMBLE_RE, '').trim()
+}
+
+/**
+ * True when the character at `index` is INERT — it sits inside a SQL comment (a
+ * `--` line comment, or a nestable block comment) or inside a single-quoted
+ * string literal. Either way it is not a statement the database will execute,
+ * so the rewrite must leave it exactly as written.
+ *
+ * **Why the rewrite needs this:** {@link ALTER_COLUMN_TO_ENCRYPTED_RE} is
+ * comment-blind and {@link renderSafeAlter} returns MULTIPLE lines. Rewriting a
+ * commented-out `-- ALTER TABLE … SET DATA TYPE …;` therefore leaves the
+ * author's `-- ` prefix on line 1 only — lines 2+, including `DROP COLUMN`,
+ * become live executable SQL and destroy the column. Commented SQL is inert by
+ * definition, so the only correct move is to leave it exactly as written.
+ *
+ * **Why string literals count as inert too:** an ALTER quoted inside an
+ * `INSERT … VALUES ('…')` is DATA. Rewriting it splices `--> statement-breakpoint`
+ * markers INSIDE the literal, so splitting the file the way drizzle's migrator
+ * does yields a bare, live `ALTER TABLE … DROP COLUMN …;` as a chunk of its own.
+ * Escaping the injected text's own apostrophe (`@cipherstash/stack's`) would fix
+ * only the syntax error, not that live DROP COLUMN — so the statement is left
+ * as written, exactly like a commented one.
+ *
+ * Double-quoted identifiers are tokenised BEFORE `'` is considered: an
+ * apostrophe inside `"o'brien_data"` must not open a phantom literal, or the
+ * scan runs to the NEXT apostrophe — typically the same identifier in a
+ * commented-out ALTER further down — decides that ALTER is live, and rewrites
+ * it into a real `DROP COLUMN`. A doubled delimiter (`''` in a literal, `""` in
+ * an identifier) is an escape and does not close the token.
+ *
+ * Dollar-quoted bodies (`$$ … $$`, `$fn$ … $fn$`) are skipped whole, and an
+ * `E''` literal gives backslash its escape meaning. Both are quote-PARITY
+ * concerns, not merely "we might skip a rewrite": an apostrophe inside an
+ * untracked `$$` body, or a `\'` read as a close, ends a literal EARLY, and
+ * every token after it is then misread — including a commented-out ALTER, which
+ * reads as live and gets rewritten into a live DROP COLUMN. That is the same
+ * failure mode as the apostrophe-in-identifier case below, and it is why the
+ * earlier reasoning here ("can only make us skip") was wrong: it holds for an
+ * UNDER-terminated literal, not an over-terminated one (#772 review, finding 1).
+ */
+function isInsideCommentOrString(sql: string, index: number): boolean {
+ let i = 0
+ while (i < index) {
+ if (sql.startsWith('--', i)) {
+ const eol = sql.indexOf('\n', i)
+ if (eol === -1 || eol >= index) return true
+ i = eol + 1
+ } else if (sql.startsWith('/*', i)) {
+ // Postgres block comments nest, so track depth rather than stopping at
+ // the first `*/` — a nested close would otherwise end the comment early
+ // and let the text after it read as live SQL.
+ let depth = 1
+ let j = i + 2
+ while (j < sql.length && depth > 0) {
+ if (sql.startsWith('/*', j)) {
+ depth += 1
+ j += 2
+ } else if (sql.startsWith('*/', j)) {
+ depth -= 1
+ j += 2
+ } else {
+ j += 1
+ }
+ }
+ if (j > index) return true
+ i = j
+ } else if (sql[i] === '$') {
+ // A `$tag$ … $tag$` body is opaque: its content is not SQL, so a `'` or
+ // `--` inside it means nothing. Skipping the whole body is what keeps the
+ // rest of the file's quote parity aligned with Postgres.
+ const delimiter = dollarQuoteDelimiter(sql, i)
+ if (delimiter === undefined) {
+ i += 1
+ } else {
+ const close = sql.indexOf(delimiter, i + delimiter.length)
+ // Unterminated: inert to the end of the file, like the cases below.
+ const end = close === -1 ? sql.length : close + delimiter.length
+ if (end > index) return true
+ i = end
+ }
+ } else if (sql[i] === '"') {
+ // A quoted identifier is live SQL, but its body is not: consuming it here
+ // — before the `'` branch below — is what stops an apostrophe inside one
+ // from opening a string literal that never really existed.
+ const end = endOfQuoted(sql, i, '"')
+ // An unterminated identifier swallows the rest of the file, so treat it
+ // as inert exactly like the unterminated literal below. Running the
+ // cursor to the end instead would exit the loop and report `false` —
+ // "live" — which is how the apostrophe bug destroyed a column in the
+ // first place, one branch over.
+ if (end > index) return true
+ i = end
+ } else if (sql[i] === "'") {
+ const end = endOfQuoted(sql, i, "'", isEscapeStringOpener(sql, i))
+ // Unterminated, or the literal runs past `index`: `index` is inside a
+ // string literal, which is every bit as inert as a comment.
+ if (end > index) return true
+ i = end
+ } else {
+ i += 1
+ }
+ }
+ return false
+}
+
+/**
+ * The `$tag$` delimiter opening at `open`, or `undefined` when what sits there
+ * is just a `$`. The tag is empty (`$$`) or an SQL identifier (`$fn$`); a digit
+ * cannot start one, so a positional parameter (`$1`) is never mistaken for a
+ * dollar-quote opener.
+ */
+function dollarQuoteDelimiter(sql: string, open: number): string | undefined {
+ DOLLAR_QUOTE_OPEN_RE.lastIndex = open
+ return DOLLAR_QUOTE_OPEN_RE.exec(sql)?.[0]
+}
+
+/** Sticky, so the scan never has to slice the file to test one position. */
+const DOLLAR_QUOTE_OPEN_RE = /\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/y
+
+/**
+ * Whether the `'` at `open` starts an `E''` escape-string, in which a backslash
+ * escapes the following character. Plain literals give backslash no special
+ * meaning under the default `standard_conforming_strings = on`.
+ *
+ * The character before the `E` must not be part of an identifier, so a column
+ * or type name that merely ends in `e` does not turn the literal after it into
+ * an escape-string.
+ */
+function isEscapeStringOpener(sql: string, open: number): boolean {
+ const prefix = sql[open - 1]
+ if (prefix !== 'E' && prefix !== 'e') return false
+ return !/[A-Za-z0-9_]/.test(sql[open - 2] ?? '')
+}
+
+/**
+ * The index just past the `quote`-delimited token that opens at `open`, or
+ * `sql.length` when it is never closed. A doubled delimiter inside the token is
+ * an escaped one (`''`, `""`) and does not end it.
+ *
+ * With `backslashEscapes`, a `\` also escapes the next character — the `E''`
+ * form. Getting this wrong ends the literal EARLY, which shifts quote parity
+ * for the whole rest of the file and can make a commented-out ALTER downstream
+ * read as live (#772 review, finding 1).
+ */
+function endOfQuoted(
+ sql: string,
+ open: number,
+ quote: "'" | '"',
+ backslashEscapes = false,
+): number {
+ let i = open + 1
+ while (i < sql.length) {
+ if (backslashEscapes && sql[i] === '\\') {
+ i += 2
+ } else if (sql[i] !== quote) {
+ i += 1
+ } else if (sql[i + 1] === quote) {
+ i += 2
+ } else {
+ return i + 1
+ }
+ }
+ return sql.length
+}
+
+/** A table reference, bare (`"users"`) or schema-qualified (`"app"."users"`). */
+const TABLE_REF = String.raw`"([^"]+)"(?:\."([^"]+)")?`
+
+/**
+ * An encrypted type in any of the {@link MANGLED_TYPE_FORMS}, pinned to end at a
+ * delimiter so a bare domain cannot match a prefix of a longer identifier.
+ *
+ * Two shapes {@link MANGLED_TYPE_FORMS} does not enumerate are folded in here so
+ * the encrypted index recognises them and the ADD+DROP+RENAME cannot drop their
+ * ciphertext: a domain in ANY quoted schema (`"app"."eql_v3_text_search"`, not
+ * just the literal `public` the mangled forms special-case), and a `[` in the
+ * trailing lookahead so an ARRAY of the domain (`public.eql_v3_text_search[]`)
+ * ends at a delimiter too. Both feed only the encrypted index — never the
+ * rewrite matcher — so the worst case of admitting one is a flagged statement,
+ * the same safe asymmetry the fail-closed rule already relies on.
+ */
+const ENCRYPTED_TYPE_REF = String.raw`(?:${MANGLED_TYPE_FORMS}|"[^"]+"\."(?:${ENCRYPTED_DOMAIN})")(?=[\s,;)[]|$)`
+
+/** `ALTER TABLE … ADD COLUMN "col" ` — $1/$2 table, $3 column. */
+const ADD_ENCRYPTED_COLUMN_RE = new RegExp(
+ String.raw`ALTER TABLE\s+${TABLE_REF}\s+ADD COLUMN\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s+${ENCRYPTED_TYPE_REF}`,
+ 'gi',
+)
+
+/** `ALTER TABLE … RENAME COLUMN "a" TO "b"` — $1/$2 table, $3 from, $4 to. */
+const RENAME_COLUMN_RE = new RegExp(
+ String.raw`ALTER TABLE\s+${TABLE_REF}\s+RENAME COLUMN\s+"([^"]+)"\s+TO\s+"([^"]+)"`,
+ 'gi',
+)
+
+/**
+ * `CREATE TABLE … (` — $1/$2 table. The body's END is found by
+ * {@link endOfCreateTableBody} rather than by the matcher.
+ *
+ * This used to carry a lazy `([\s\S]*?)\)\s*;` body, which stopped at the FIRST
+ * `);` in the file — and that can sit inside a `--` comment or a string DEFAULT
+ * (`"id" text, -- pk (uuid);`). The body was then truncated and every column
+ * declared after that point vanished from both indexes, including an ENCRYPTED
+ * one — which is how a later domain change on a ciphertext column got past the
+ * already-encrypted guard (#772 review, finding 2).
+ */
+const CREATE_TABLE_HEAD_RE = new RegExp(
+ String.raw`CREATE TABLE\s+(?:IF NOT EXISTS\s+)?${TABLE_REF}\s*\(`,
+ 'gi',
+)
+
+/** Candidate body terminators, filtered by {@link isInsideCommentOrString}. */
+const CREATE_TABLE_BODY_END_RE = /\)\s*;/g
+
+/**
+ * The offset of the `)` closing the CREATE TABLE body that opens at
+ * `bodyStart`, or `undefined` when the statement is never closed.
+ *
+ * Parens nested inside the body (`numeric(10,2)`, `CHECK (x > 0)`) are not
+ * followed by `;`, so requiring the terminator keeps them from matching.
+ */
+function endOfCreateTableBody(
+ sql: string,
+ bodyStart: number,
+): number | undefined {
+ CREATE_TABLE_BODY_END_RE.lastIndex = bodyStart
+ for (
+ let end = CREATE_TABLE_BODY_END_RE.exec(sql);
+ end !== null;
+ end = CREATE_TABLE_BODY_END_RE.exec(sql)
+ ) {
+ if (!isInsideCommentOrString(sql, end.index)) return end.index
+ }
+ return undefined
+}
+
+/** `"col" ` inside a CREATE TABLE body — $1 column. */
+const CREATE_TABLE_ENCRYPTED_COLUMN_RE = new RegExp(
+ String.raw`"([^"]+)"\s+${ENCRYPTED_TYPE_REF}`,
+ 'gi',
+)
+
+/**
+ * A column name in a DECLARATION position — `"email" text`,
+ * `"email" "public"."eql_v3_text_search"`, `"amount" numeric(10, 2)`.
+ *
+ * The `\s+["a-z]` tail is what separates a declaration from a MENTION. Most
+ * mentions are followed by `)`, `,`, or an operator, which the tail alone
+ * already rejects:
+ *
+ * ```sql
+ * PRIMARY KEY ("id", "name")
+ * FOREIGN KEY ("org_id") REFERENCES "orgs"("id")
+ * ```
+ *
+ * But a mention inside a predicate or a table-level constraint is often
+ * followed by whitespace and a SQL KEYWORD — which has exactly the same
+ * `\s+[a-z]` shape as a real declaration, and drizzle-kit emits both forms
+ * inside a `CREATE TABLE` (a `CONSTRAINT … CHECK ()` body is
+ * user-authored, so the predicate form is reachable too):
+ *
+ * ```sql
+ * CHECK ("email" IS NOT NULL)
+ * CONSTRAINT "users_email_unique" UNIQUE ("email")
+ * ```
+ *
+ * The negative lookahead rejects the keywords reachable this way: predicate
+ * keywords (`IS`, `IN`, `NOT`, `LIKE`, `ILIKE`, `BETWEEN`, `AND`, `OR`),
+ * ordering/collation (`COLLATE`, `ASC`, `DESC`, `NULLS`), and every
+ * table/column-constraint keyword (`UNIQUE`, `PRIMARY`, `FOREIGN`, `CHECK`,
+ * `EXCLUDE`, `REFERENCES`, `CONSTRAINT`, `USING`, `WITH`) — the last six also
+ * close the constraint-NAME case above, since Postgres always puts one of
+ * them immediately after a constraint's name.
+ *
+ * A real type token still matches because the lookahead is pinned to a word
+ * boundary (`\b`) right after each keyword, so it only rejects a keyword when
+ * that keyword is the WHOLE next word: `interval` and `inet` both start with
+ * `IN`'s letters, but their third character keeps them inside one word, so
+ * `\bIN\b` never matches there; the same reasoning clears `int`,
+ * `char`/`citext` against `CHECK`, and `eql_v2_encrypted`/`eql_v3_*` against
+ * `EXCLUDE`.
+ *
+ * Deliberately NOT pinned to the encrypted domains. A column the corpus
+ * declares but does not give an encrypted type is, by residue, plaintext — so
+ * the fail-closed rule needs no type classification and no SQL parsing, only
+ * the known encrypted list that {@link ENCRYPTED_TYPE_REF} already provides.
+ *
+ * **The residue claim depends on {@link ENCRYPTED_TYPE_REF} recognising every
+ * encrypted shape.** "By residue, plaintext" is only as good as the encrypted
+ * side's coverage: a declaration {@link ENCRYPTED_TYPE_REF} fails to recognise
+ * falls to the plaintext residue and gets rewritten. Two such shapes are now
+ * covered explicitly by {@link ENCRYPTED_TYPE_REF} — a domain in a non-`public`
+ * schema (`"email" "app"."eql_v3_text_search"`) and an array of the domain
+ * (`"email" public.eql_v3_text_search[]`) — because both name ciphertext the
+ * corpus can see, so rewriting them is the exact drop this rule exists to
+ * prevent. The dependency itself remains: any encrypted shape a future EQL
+ * install introduces must be added to {@link ENCRYPTED_TYPE_REF} or it too will
+ * fall to the plaintext residue.
+ *
+ * **Residue, accepted.** The lookahead is a fixed keyword list, not a parser:
+ * a predicate keyword it does not enumerate (`SIMILAR`, `ISNULL`, `NOTNULL`,
+ * `OVERLAPS`, `ON`, …) still lets a bare mention through, e.g.
+ * `CHECK ("email" SIMILAR TO '...')`, or a `REFERENCES "email" ON DELETE
+ * CASCADE` where `"email"` names the referenced TABLE rather than the column
+ * being declared — `"id" integer REFERENCES "email" ON DELETE CASCADE` inside
+ * a `CREATE TABLE` body still registers `email` as declared. That one only
+ * bites when a column shares a referenced table's name, so like the rest of
+ * this residue it is inert unless that mention's name exactly matches a
+ * DIFFERENT column that is both encrypted and genuinely undeclared anywhere
+ * else in the corpus — contrived, and strictly narrower than the gap this
+ * replaces.
+ */
+const DECLARED_COLUMN_RE =
+ /"([^"]+)"\s+(?!(?:IS|IN|NOT|LIKE|ILIKE|BETWEEN|AND|OR|COLLATE|ASC|DESC|NULLS|UNIQUE|PRIMARY|FOREIGN|CHECK|EXCLUDE|REFERENCES|CONSTRAINT|USING|WITH)\b)["a-z]/gi
+
+/**
+ * `ALTER TABLE … ADD COLUMN "col" ` — $1/$2 table, $3 column.
+ *
+ * Needs no {@link DECLARED_COLUMN_RE}-style keyword lookahead: the token
+ * right after an ADD COLUMN's column name is always its type, so no SQL
+ * keyword can occupy that position.
+ */
+const ADD_COLUMN_RE = new RegExp(
+ String.raw`ALTER TABLE\s+${TABLE_REF}\s+ADD COLUMN\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s+["a-z]`,
+ 'gi',
+)
+
+/** Splits a `TABLE_REF` capture pair into its schema and table halves. */
+function tableOf(
+ first: string,
+ second: string | undefined,
+): { schema?: string; table: string } {
+ // When schema-qualified (`"app"."users"`) the first capture is the schema and
+ // the second is the table; otherwise the first is the table.
+ return second === undefined
+ ? { table: first }
+ : { schema: first, table: second }
+}
+
+/**
+ * Identity of a column across the corpus, for {@link indexColumnDeclarations}.
+ *
+ * `public` and no schema at all are the SAME table: Postgres resolves an
+ * unqualified name through `search_path`, which starts at `public`. The two
+ * spellings coexist in one corpus routinely — drizzle-kit emits unqualified,
+ * while hand-written SQL and this sweep's own {@link renderSafeAlter} output
+ * are qualified. Keying on the literal text split one column across two keys,
+ * so a column already encrypted under one spelling looked plaintext when
+ * altered under the other and the ADD+DROP+RENAME dropped its ciphertext
+ * (#772 review, finding 4).
+ *
+ * Only the IMPLICIT schema collapses. `"app"."users"` is a genuinely different
+ * table from `"users"` and keeps its own key. No case folding either:
+ * `TABLE_REF` matches quoted identifiers only, and those are case-sensitive in
+ * Postgres — `"PUBLIC"` really is a different schema from `"public"`.
+ */
+function columnKey(table: string, column: string, schema?: string): string {
+ return JSON.stringify([
+ schema === 'public' ? '' : (schema ?? ''),
+ table,
+ column,
+ ])
+}
+
+/** What the migration corpus says about the columns it mentions. */
+interface ColumnIndex {
+ /** Columns the corpus gives an ENCRYPTED type. Rewriting one drops ciphertext. */
+ encrypted: Set
+ /** Columns the corpus DECLARES at all, whatever type it gives them. */
+ declared: Set
+}
+
+/**
+ * Index what the migration corpus knows about each column, so the rewrite can
+ * tell the change it exists for (plaintext → encrypted) from the two it must
+ * never make: encrypted → encrypted, and a change to a column it knows nothing
+ * about.
+ *
+ * **Why `encrypted` (#772 review, W-3):** the strict matcher captures only the
+ * TARGET type. A column whose encrypted domain merely changes (`types.TextEq` →
+ * `types.TextSearch`) matches just as well as a plaintext column, and the
+ * ADD+DROP+RENAME then drops a column full of CIPHERTEXT — with no plaintext
+ * left anywhere to backfill from, so unlike the plaintext case the data is not
+ * recoverable from the application at all.
+ *
+ * **Why `declared` (A-2):** absence from `encrypted` is not evidence of
+ * plaintext. It is evidence of nothing. The sweep runs per directory, and the
+ * shipped wizard default scans three of them, so a column's `CREATE TABLE` can
+ * simply live somewhere this sweep never reads — the column is then rewritten
+ * on an assumption, and the ADD+DROP+RENAME drops live ciphertext. Requiring a
+ * POSITIVE declaration makes the unknown case a flagged statement instead.
+ *
+ * Because the encrypted side is matched against the known domain list, the
+ * plaintext side needs no type classification: a column the corpus declares but
+ * does not give an encrypted type is plaintext by residue.
+ *
+ * The index is corpus-wide rather than ordered by migration: over-detecting
+ * "encrypted" costs a flagged statement the user handles by hand, while
+ * under-detecting costs irrecoverable ciphertext. That asymmetry is why the
+ * two per-column scans inside a `CREATE TABLE` body are deliberately
+ * inconsistent about comments (see the comment at the loops themselves) —
+ * do not "fix" that inconsistency, it is the point.
+ */
+function indexColumnDeclarations(contents: readonly string[]): ColumnIndex {
+ const encrypted = new Set()
+ const declared = new Set()
+
+ for (const sql of contents) {
+ for (const created of sql.matchAll(CREATE_TABLE_HEAD_RE)) {
+ if (isInsideCommentOrString(sql, created.index)) continue
+ const { schema, table } = tableOf(created[1], created[2])
+ const bodyStart = created.index + created[0].length
+ const bodyEnd = endOfCreateTableBody(sql, bodyStart)
+ // Never closed — treat the statement as absent rather than index a body
+ // that runs to the end of the file.
+ if (bodyEnd === undefined) continue
+ const body = sql.slice(bodyStart, bodyEnd)
+
+ // The body is scanned as its own document: a `--` earlier in it comments
+ // out the rest of that line, and a CREATE inside a block comment or a
+ // string literal was already skipped above.
+ //
+ // Asymmetric on purpose. ENCRYPTED does NOT re-check comments here: a
+ // commented-out encrypted column inside an otherwise live CREATE TABLE
+ // still counts, exactly as it did before this sweep learned to declare
+ // anything — over-detecting "encrypted" only costs a flagged statement.
+ // DECLARED DOES re-check: a commented-out plaintext line never ran, so
+ // it must not count as a declaration — over-detecting "declared" is
+ // what would put a truly-undeclared, possibly already-encrypted column
+ // back on the fail-open path.
+ for (const column of body.matchAll(CREATE_TABLE_ENCRYPTED_COLUMN_RE)) {
+ encrypted.add(columnKey(table, column[1], schema))
+ }
+
+ for (const column of body.matchAll(DECLARED_COLUMN_RE)) {
+ if (isInsideCommentOrString(body, column.index)) continue
+ declared.add(columnKey(table, column[1], schema))
+ }
+ }
+
+ for (const added of sql.matchAll(ADD_ENCRYPTED_COLUMN_RE)) {
+ if (isInsideCommentOrString(sql, added.index)) continue
+ const { schema, table } = tableOf(added[1], added[2])
+ encrypted.add(columnKey(table, added[3], schema))
+ }
+
+ for (const added of sql.matchAll(ADD_COLUMN_RE)) {
+ if (isInsideCommentOrString(sql, added.index)) continue
+ const { schema, table } = tableOf(added[1], added[2])
+ declared.add(columnKey(table, added[3], schema))
+ }
+
+ // A rename carries the column's type with it — and `__cipherstash_tmp`
+ // renamed onto the real name is exactly what a previous sweep of this very
+ // directory emitted. Run after ADD so that tmp column is already indexed.
+ for (const renamed of sql.matchAll(RENAME_COLUMN_RE)) {
+ if (isInsideCommentOrString(sql, renamed.index)) continue
+ const { schema, table } = tableOf(renamed[1], renamed[2])
+ const from = columnKey(table, renamed[3], schema)
+ const to = columnKey(table, renamed[4], schema)
+ if (encrypted.has(from)) encrypted.add(to)
+ if (declared.has(from)) declared.add(to)
+ }
+ }
+
+ return { encrypted, declared }
+}
+
+/** Why a recognised ALTER-to-encrypted statement was left alone. */
+export type SkipReason =
+ /** Outside the strict matcher — hand-authored `USING`, or an unknown form. */
+ | 'unrecognised-form'
+ /** The column already holds an encrypted domain; rewriting drops ciphertext. */
+ | 'already-encrypted'
+ /** The corpus never declares the column, so its current type is unknown. */
+ | 'source-unknown'
+
/** A statement the sweep recognised as ALTER-to-encrypted but did NOT rewrite. */
export interface SkippedAlter {
/** Absolute path of the migration file the statement lives in. */
file: string
/** The offending statement, verbatim (trimmed), for the user to review. */
statement: string
+ /** Why it was left alone — the caller turns this into user-facing guidance. */
+ reason: SkipReason
+}
+
+/**
+ * One-line explanation of a {@link SkipReason}, for the CLI/wizard to print
+ * next to the statement. Lives here so every caller says the same thing — the
+ * three reasons need very different action from the user, and a single generic
+ * "could not rewrite automatically" hides that.
+ */
+export function describeSkipReason(reason: SkipReason): string {
+ switch (reason) {
+ case 'already-encrypted':
+ return "the column is ALREADY encrypted, so the ADD+DROP+RENAME rewrite would DROP the ciphertext with no plaintext left to backfill from. Changing an encrypted column's domain changes its index terms, so the data must be re-encrypted through the staged `stash encrypt` lifecycle"
+ case 'unrecognised-form':
+ return 'it falls outside the strict matcher (a hand-authored `SET DATA TYPE ... USING ...`, or a drizzle-kit form the sweep does not recognise) and an in-place cast to an encrypted domain fails at migrate time'
+ case 'source-unknown':
+ return "the sweep could not find where this column was declared in this migration directory, so it cannot tell a plaintext column (safe to rewrite) from one that already holds ciphertext (where the rewrite would DROP it). Usually the column's `CREATE TABLE` / `ADD COLUMN` lives in a different directory, or the migration history was squashed. Check the column's current type in the database: if it is plaintext and the table is empty, apply the ADD/DROP/RENAME by hand; if it already holds ciphertext, use the staged `stash encrypt` lifecycle instead"
+ }
}
/** Outcome of a sweep: the files rewritten, and near-misses left for review. */
@@ -128,32 +675,68 @@ export interface RewriteResult {
* plaintext is destroyed. The commented UPDATE is a placeholder that can never
* become real SQL (the encrypted value is the EQL envelope produced by ZeroKMS
* via the client — there is no expression Postgres can evaluate to fill it), so
- * a populated table must instead use the staged `stash encrypt` lifecycle
- * (add → backfill via `@cipherstash/stack`'s `encryptModel` → cutover → drop),
- * which keeps both columns alive across deploys. Each rewritten file carries a
- * header comment saying exactly this.
- *
- * Returns {@link RewriteResult}: the files rewritten, plus `skipped` near-misses
- * — statements that look like an ALTER-to-encrypted but fall outside the strict
- * matcher (a hand-authored `SET DATA TYPE … USING …;`, or a future drizzle-kit
- * form). Near-misses are left untouched on disk and surfaced non-fatally so the
- * caller can tell the user to review them, rather than silently shipping broken
- * SQL.
+ * a populated table must instead use the staged EQL v3 lifecycle (add an
+ * encrypted twin → dual-write → backfill via `@cipherstash/stack`'s
+ * `encryptModel` → switch the application to the encrypted column by name →
+ * drop plaintext), which keeps both columns alive across deploys. Each
+ * rewritten file carries a header comment saying exactly this.
+ *
+ * Returns {@link RewriteResult}: the files rewritten, plus `skipped` statements
+ * left for a human — ones outside the strict matcher (a hand-authored
+ * `SET DATA TYPE … USING …;`, or a future drizzle-kit form); ones targeting a
+ * column that is ALREADY encrypted, where the rewrite would drop ciphertext;
+ * and ones targeting a column the corpus never DECLARES anywhere, where the
+ * rewrite would be guessing at a type it cannot see (likely the most common
+ * skip on a real corpus, since the sweep only ever reads part of the
+ * migration history). All three are left untouched on disk and surfaced
+ * non-fatally so the caller can tell the user to review them, rather than
+ * silently shipping broken SQL or destroying data. Statements sitting inside a
+ * SQL comment — or inside a single-quoted string literal, where they are data
+ * rather than SQL — are inert and are neither rewritten nor reported.
*/
export async function rewriteEncryptedAlterColumns(
outDir: string,
options: { skip?: string } = {},
): Promise {
- const entries = await readdir(outDir).catch(() => [])
+ const entries = await readdir(outDir).catch(
+ (error: NodeJS.ErrnoException) => {
+ // A missing directory is simply nothing to sweep. Anything else — EACCES
+ // above all — is a sweep that did NOT happen, and the caller reports it
+ // rather than letting the user believe their migrations were checked.
+ if (error.code === 'ENOENT') return [] as string[]
+ throw error
+ },
+ )
const rewritten: string[] = []
const skipped: SkippedAlter[] = []
+ const seen = new Set()
+
+ /** Record a skip once — the strict pass and the broad scan can both find it. */
+ const skip = (file: string, statement: string, reason: SkipReason): void => {
+ // Keyed on collapsed whitespace: the two passes trim the same statement
+ // by slightly different rules, and the strict pass runs first so its
+ // more specific reason is the one kept.
+ const key = `${file} :: ${statement.replace(/\s+/g, ' ')}`
+ if (seen.has(key)) return
+ seen.add(key)
+ skipped.push({ file, statement, reason })
+ }
- for (const entry of entries) {
- if (!entry.endsWith('.sql')) continue
+ const sqlFiles = entries.filter((entry) => entry.endsWith('.sql')).sort()
+ const contents = new Map()
+ for (const entry of sqlFiles) {
const filePath = join(outDir, entry)
- if (options.skip && filePath === options.skip) continue
+ contents.set(filePath, await readFile(filePath, 'utf-8'))
+ }
+
+ // Built from the WHOLE corpus, including `options.skip`: a column's current
+ // type comes from the migrations that ran before this one, not just the files
+ // we are allowed to edit.
+ const { encrypted: encryptedColumns, declared: declaredColumns } =
+ indexColumnDeclarations([...contents.values()])
- const original = await readFile(filePath, 'utf-8')
+ for (const [filePath, original] of contents) {
+ if (options.skip && filePath === options.skip) continue
// Reset the regex's lastIndex — it's stateful on /g
ALTER_COLUMN_TO_ENCRYPTED_RE.lastIndex = 0
@@ -166,15 +749,54 @@ export async function rewriteEncryptedAlterColumns(
second: string | undefined,
column: string,
mangledType: string,
+ offset: number,
) => {
- // When schema-qualified (`"app"."users"`) the first capture is the
- // schema and the second is the table; otherwise the first is the table.
- const schema = second === undefined ? undefined : first
- const table = second === undefined ? first : second
+ // Commented-out SQL never runs, and a multi-line replacement would only
+ // inherit the `-- ` on its first line — leaving the rest live.
+ if (isInsideCommentOrString(original, offset)) return match
+
+ const { schema, table } = tableOf(first, second)
+
+ // Already encrypted: the ADD+DROP+RENAME would drop the ciphertext and
+ // there is no plaintext left to backfill from. Flag, never guess.
+ if (encryptedColumns.has(columnKey(table, column, schema))) {
+ skip(filePath, match.trim(), 'already-encrypted')
+ return match
+ }
+
+ // Fail closed. Absence from `declaredColumns` does not mean the column
+ // is plaintext — it means the corpus never said. The declaration can
+ // sit in a directory this sweep does not read (the wizard ships with
+ // three candidates and indexes each separately), so rewriting on the
+ // assumption is how a live `DROP COLUMN` reaches a populated — possibly
+ // already-encrypted — column. Flag it and let the user look.
+ if (!declaredColumns.has(columnKey(table, column, schema))) {
+ skip(filePath, match.trim(), 'source-unknown')
+ return match
+ }
+
const domain = DOMAIN_RE.exec(mangledType)?.[0]?.toLowerCase()
// Unreachable — the outer regex only matches when a domain is present —
// but leave the statement alone rather than emit a broken rewrite.
if (!domain) return match
+
+ // This statement converts the column, so from here on in the corpus it
+ // holds CIPHERTEXT. `indexColumnDeclarations` cannot know that: it
+ // reads CREATE TABLE, ADD COLUMN and RENAME, never the strict matcher's
+ // own target. Without this, a corpus carrying an earlier conversion
+ // (`... SET DATA TYPE eql_v2_encrypted` from a stack version that
+ // predates this sweep) leaves the column looking plaintext, and the
+ // NEXT domain change drops the ciphertext — `declared` cannot catch it,
+ // because the column really is declared, as plaintext, by the original
+ // CREATE TABLE.
+ //
+ // Recorded here rather than in the corpus-wide index on purpose: the
+ // index has no order, so it would flag THIS conversion — the legitimate
+ // plaintext -> encrypted one — as already-encrypted too. Files are
+ // walked in sorted order and matches within a file in source order, so
+ // "already converted" means "converted by a statement that runs before
+ // this one".
+ encryptedColumns.add(columnKey(table, column, schema))
return renderSafeAlter(table, column, domain, schema)
},
)
@@ -189,7 +811,16 @@ export async function rewriteEncryptedAlterColumns(
// matcher. Flag it — non-fatally — rather than leave the user shipping SQL
// that fails at migrate time.
for (const nearMiss of updated.matchAll(NEAR_MISS_RE)) {
- skipped.push({ file: filePath, statement: nearMiss[0].trim() })
+ const statement = trimStatementPreamble(nearMiss[0])
+ // Anchor the comment test on the `SET DATA TYPE` itself: the match starts
+ // at the previous `;`, so its own offset sits before any preamble.
+ const keyword = nearMiss[0].search(/\bSET\s+DATA\s+TYPE\b/i)
+ if (
+ isInsideCommentOrString(updated, nearMiss.index + Math.max(keyword, 0))
+ ) {
+ continue
+ }
+ skip(filePath, statement, 'unrecognised-form')
}
}
@@ -210,9 +841,10 @@ export async function rewriteEncryptedAlterColumns(
* composite, but this is equally true on both surfaces.)
*
* So the guidance does NOT tell the user to backfill and run this migration —
- * that would still lose data on cutover. It points a populated table at the
- * staged `stash encrypt` lifecycle (add → backfill → cutover → drop), which
- * keeps both columns alive across deploys.
+ * that would still lose data. It points a populated table at the staged EQL v3
+ * lifecycle (add an encrypted twin → dual-write → backfill → switch the
+ * application to the encrypted column by name → drop plaintext), which keeps
+ * both columns alive across deploys.
*/
function renderSafeAlter(
table: string,
@@ -229,8 +861,8 @@ function renderSafeAlter(
`-- ${domain}. This ADD+DROP+RENAME equals DROP+ADD and is safe ONLY if`,
`-- ${qualifiedTable} is empty. On a populated table it DESTROYS existing "${column}"`,
'-- data (the new column starts NULL) — do NOT run it there. Use the staged',
- "-- `stash encrypt` path instead: add -> backfill via @cipherstash/stack's",
- '-- encryptModel in application code -> cutover -> drop.',
+ '-- EQL v3 path instead: add an encrypted twin -> dual-write -> backfill via',
+ '-- encryptModel -> switch the app to the encrypted column -> drop plaintext.',
'-- NOTE: constraints, defaults, and indexes on the original column are NOT',
'-- carried over by this ADD/DROP/RENAME — re-add any NOT NULL, DEFAULT,',
'-- UNIQUE, or index definitions manually.',
diff --git a/packages/cli/src/commands/db/status.ts b/packages/cli/src/commands/db/status.ts
index 20fe19864..0b4291f0c 100644
--- a/packages/cli/src/commands/db/status.ts
+++ b/packages/cli/src/commands/db/status.ts
@@ -99,8 +99,7 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) {
// install creates it and Proxy reads it. EQL v3 has no configuration table —
// encryption config lives in each column's `eql_v3.*` domain type — so on a
// v3-only install there's nothing to probe. Gate the check on v2 being
- // installed; this also removes the old dead-end that told v3-only users to
- // run `db push` (which neither creates that table nor applies to v3).
+ // installed; this also avoids probing a table that does not apply to v3.
if (!installedV2) {
p.log.info(
"Encrypt config: carried in each column's `eql_v3.*` type (EQL v3 has no Proxy config table).",
diff --git a/packages/cli/src/commands/db/supabase-migration.ts b/packages/cli/src/commands/db/supabase-migration.ts
deleted file mode 100644
index c541a8a7e..000000000
--- a/packages/cli/src/commands/db/supabase-migration.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { existsSync } from 'node:fs'
-import { mkdir, writeFile } from 'node:fs/promises'
-import { join } from 'node:path'
-import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
-import {
- loadBundledEqlSql,
- SUPABASE_PERMISSIONS_SQL,
-} from '@/installer/index.js'
-
-/**
- * Filename of the Supabase migration that installs CipherStash EQL.
- *
- * Supabase orders migrations lexically by the `YYYYMMDDHHMMSS_` prefix; an
- * all-zero prefix is guaranteed to sort before any real timestamp, so this
- * file always runs first on `supabase db reset` and `supabase migration up`.
- * That ordering is the whole point of this code path — without it, user
- * migrations referencing `eql_v2_encrypted` blow up because the EQL types
- * aren't installed yet.
- */
-export const SUPABASE_EQL_MIGRATION_FILENAME =
- '00000000000000_cipherstash_eql.sql'
-
-/**
- * Header comment block prepended to the generated migration. Explains *why*
- * this file exists for future maintainers reading their own migrations
- * directory. The runner is resolved at call time based on the detected
- * package manager.
- */
-function migrationHeader(runner: string): string {
- return `-- CipherStash EQL — installed by \`${runner} stash eql install --supabase --migration\`.
---
--- This migration installs the CipherStash Encrypt Query Language (EQL) types,
--- functions, and operators into the \`eql_v2\` schema, then grants Supabase's
--- \`anon\`, \`authenticated\`, and \`service_role\` roles the access they need.
---
--- The all-zero \`YYYYMMDDHHMMSS\` prefix is intentional: Supabase orders
--- migrations lexically, so this file runs before any user migration that
--- references the \`eql_v2_encrypted\` type. Do not rename it.
---
--- To upgrade EQL, re-run the install command — it will refuse to overwrite
--- this file unless you pass --force.
---
--- Docs: https://cipherstash.com/docs/stack/cipherstash/supabase
-`
-}
-
-export interface WriteSupabaseEqlMigrationOptions {
- /**
- * Absolute path to the directory the migration file should be written into.
- * Created (recursively) if it doesn't already exist.
- */
- migrationsDir: string
- /**
- * Overwrite an existing migration file at this path. When `false` (default)
- * an existing file causes the function to throw.
- */
- force?: boolean
- /**
- * Whether to use the no-operator-family EQL bundle. Supabase always wants
- * this — we expose the flag for symmetry with the runtime install path and
- * to leave room for future provider variants.
- */
- excludeOperatorFamily?: boolean
-}
-
-export interface WriteSupabaseEqlMigrationResult {
- /** Absolute path to the migration file that was written. */
- path: string
- /** Whether an existing file at `path` was overwritten. */
- overwritten: boolean
-}
-
-/**
- * Generate the `/00000000000000_cipherstash_eql.sql` migration.
- *
- * The file body is, in order:
- * 1. Migration header (generated from {@link migrationHeader}) — explains why the file exists.
- * 2. The bundled `cipherstash-encrypt-supabase.sql` install script.
- * 3. {@link SUPABASE_PERMISSIONS_SQL} — the same grants the runtime install
- * path issues. One source of truth for both code paths.
- *
- * @throws if the target file already exists and `force` is `false`.
- */
-export async function writeSupabaseEqlMigration(
- options: WriteSupabaseEqlMigrationOptions,
-): Promise {
- const {
- migrationsDir,
- force = false,
- excludeOperatorFamily = false,
- } = options
-
- const targetPath = join(migrationsDir, SUPABASE_EQL_MIGRATION_FILENAME)
- const alreadyExists = existsSync(targetPath)
-
- if (alreadyExists && !force) {
- throw new Error(
- `Refusing to overwrite ${targetPath}: file already exists. Re-run with --force to overwrite.`,
- )
- }
-
- // The Supabase migration-file flow is v2-only (v3 has no migration-file
- // path), so pin `eqlVersion: 2` explicitly — otherwise it would follow the
- // v3 default and emit `cipherstash-encrypt-v3-supabase.sql` alongside the v2
- // `SUPABASE_PERMISSIONS_SQL` below, producing a mismatched migration. We also
- // pass both supabase/no-operator-family flags so intent is explicit and
- // `loadBundledEqlSql` resolves the supabase file even if selection rules ever
- // change.
- const eqlSql = loadBundledEqlSql({
- eqlVersion: 2,
- supabase: true,
- excludeOperatorFamily: excludeOperatorFamily || true,
- })
-
- const pm = detectPackageManager()
- const runner = runnerCommand(pm, '').trim()
- const header = migrationHeader(runner)
-
- const body = [
- header,
- '',
- eqlSql.trimEnd(),
- '',
- '-- Grant access to Supabase roles (anon, authenticated, service_role).',
- SUPABASE_PERMISSIONS_SQL.trimEnd(),
- '',
- ].join('\n')
-
- await mkdir(migrationsDir, { recursive: true })
- await writeFile(targetPath, body, 'utf-8')
-
- return { path: targetPath, overwritten: alreadyExists }
-}
diff --git a/packages/cli/src/commands/db/upgrade.ts b/packages/cli/src/commands/db/upgrade.ts
index b62926778..bd0514aa0 100644
--- a/packages/cli/src/commands/db/upgrade.ts
+++ b/packages/cli/src/commands/db/upgrade.ts
@@ -1,45 +1,15 @@
import * as p from '@clack/prompts'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
-import { EQLInstaller, resolveEqlVersion } from '@/installer/index.js'
+import { EQLInstaller } from '@/installer/index.js'
export async function upgradeCommand(options: {
dryRun?: boolean
supabase?: boolean
- excludeOperatorFamily?: boolean
- latest?: boolean
databaseUrl?: string
- /** EQL generation to upgrade: `'3'` (default) or `'2'`. */
- eqlVersion?: string
}) {
const pm = detectPackageManager()
p.intro(runnerCommand(pm, 'stash eql upgrade'))
-
- if (
- options.eqlVersion !== undefined &&
- options.eqlVersion !== '2' &&
- options.eqlVersion !== '3'
- ) {
- p.log.error(
- `Unknown \`--eql-version ${options.eqlVersion}\`. Supported values: 2, 3.`,
- )
- p.outro('Upgrade aborted.')
- process.exit(1)
- }
- const eqlVersion: 2 | 3 = resolveEqlVersion(options.eqlVersion)
-
- if (eqlVersion === 3 && options.latest) {
- // `--latest` is v2-only (no public v3 release artifacts exist yet). Since
- // v3 is the default, tell the user how to reach the v2 latest upgrade.
- p.log.error(
- options.eqlVersion === '3'
- ? '`--eql-version 3` does not support `--latest` — no public v3 release artifacts exist yet. Use the bundled upgrade.'
- : '`--latest` requires EQL v2 — no public v3 release artifacts exist yet. Re-run with `--eql-version 2 --latest`, or drop `--latest` for the bundled v3 upgrade.',
- )
- p.outro('Upgrade aborted.')
- process.exit(1)
- }
-
const s = p.spinner()
s.start('Loading stash.config.ts...')
@@ -49,73 +19,40 @@ export async function upgradeCommand(options: {
})
s.stop('Configuration loaded.')
- const installer = new EQLInstaller({
- databaseUrl: config.databaseUrl,
- })
-
- s.start('Checking current EQL installation...')
- const installed = await installer.isInstalled({ eqlVersion })
-
+ const installer = new EQLInstaller({ databaseUrl: config.databaseUrl })
+ s.start('Checking current EQL v3 installation...')
+ const installed = await installer.isInstalled()
if (!installed) {
- s.stop(`EQL v${eqlVersion} is not installed.`)
- // A version mismatch is the likely cause — point at the generation that
- // IS installed rather than a bare "run install".
- const otherVersion: 2 | 3 = eqlVersion === 3 ? 2 : 3
- const otherInstalled = await installer
- .isInstalled({ eqlVersion: otherVersion })
- .catch(() => false)
- if (otherInstalled) {
- p.log.warn(
- `EQL v${eqlVersion} is not installed, but EQL v${otherVersion} is. Re-run with \`--eql-version ${otherVersion}\`, or install v${eqlVersion} with "${runnerCommand(pm, `stash eql install --eql-version ${eqlVersion}`)}".`,
- )
- } else {
- p.log.warn(
- `EQL is not currently installed. Run "${runnerCommand(pm, 'stash eql install')}" first.`,
- )
- }
+ s.stop('EQL v3 is not installed.')
+ p.log.warn(
+ `EQL v3 is not currently installed. Run "${runnerCommand(pm, 'stash eql install')}" first.`,
+ )
p.outro('Upgrade aborted.')
process.exit(1)
}
- const previousVersion = await installer.getInstalledVersion({ eqlVersion })
+ const previousVersion = await installer.getInstalledVersion()
s.stop(`Current version: ${previousVersion ?? 'unknown'}`)
-
if (options.dryRun) {
p.log.info('Dry run — no changes will be made.')
- const source = options.latest
- ? 'Would download EQL install script from GitHub (latest)'
- : 'Would re-run bundled EQL install script'
p.note(
- `Current version: ${previousVersion ?? 'unknown'}\n${source}\nWould execute the SQL against the database`,
+ `Current version: ${previousVersion ?? 'unknown'}\nWould re-run the pinned EQL v3 install SQL against the database`,
'Dry Run',
)
p.outro('Dry run complete.')
return
}
- const source = options.latest ? 'from GitHub (latest)' : 'bundled'
- s.start(
- `Upgrading EQL ${eqlVersion === 3 ? 'v3 ' : ''}extensions (${source})...`,
- )
- await installer.install({
- excludeOperatorFamily: options.excludeOperatorFamily,
- supabase: options.supabase,
- latest: options.latest,
- eqlVersion,
- })
+ s.start('Upgrading EQL v3 extensions (pinned bundle)...')
+ await installer.install({ supabase: options.supabase })
s.stop('EQL extensions upgraded.')
-
- if (options.supabase) {
- p.log.success('Supabase role permissions granted.')
- }
+ if (options.supabase) p.log.success('Supabase role permissions granted.')
s.start('Verifying new version...')
- const newVersion = await installer.getInstalledVersion({ eqlVersion })
+ const newVersion = await installer.getInstalledVersion()
s.stop(`New version: ${newVersion ?? 'unknown'}`)
-
if (previousVersion && newVersion && previousVersion === newVersion) {
p.log.info('Version unchanged — EQL was already up to date.')
}
-
p.outro('Done!')
}
diff --git a/packages/cli/src/commands/encrypt/__tests__/backfill-target.test.ts b/packages/cli/src/commands/encrypt/__tests__/backfill-target.test.ts
new file mode 100644
index 000000000..04ae3adad
--- /dev/null
+++ b/packages/cli/src/commands/encrypt/__tests__/backfill-target.test.ts
@@ -0,0 +1,103 @@
+/**
+ * Pins `assertEqlV3Target`'s two distinct failure modes.
+ *
+ * `detectColumnEqlVersion` returns `null` for BOTH "the column exists but its
+ * type is not an `eql_v3_*` domain" AND "there is no such column". Collapsing
+ * them into one message told a user who simply had not added the encrypted
+ * column yet that they were on a legacy EQL v2 column — a diagnosis with no
+ * relation to their actual problem, and a remedy (migrate the domain) they
+ * cannot act on.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Only the two catalog probes are replaced; everything else in
+// `@cipherstash/migrate` stays real so a rename on either side fails loudly
+// rather than silently mocking a function that no longer exists.
+const detectColumnEqlVersion = vi.hoisted(() =>
+ vi.fn(async (): Promise<2 | 3 | null> => null),
+)
+const columnExists = vi.hoisted(() => vi.fn(async (): Promise => true))
+vi.mock('@cipherstash/migrate', async (importOriginal) => ({
+ ...(await importOriginal()),
+ detectColumnEqlVersion,
+ columnExists,
+}))
+
+// `backfill.ts` pulls in the encryption-client loader and clack at module
+// scope; neither is reachable from the guard, but both must import cleanly.
+vi.mock('@clack/prompts', () => ({
+ intro: vi.fn(),
+ outro: vi.fn(),
+ note: vi.fn(),
+ confirm: vi.fn(async () => true),
+ isCancel: vi.fn(() => false),
+ log: {
+ info: vi.fn(),
+ success: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ step: vi.fn(),
+ },
+}))
+vi.mock('@/config/index.js', () => ({
+ loadStashConfig: vi.fn(async () => ({ databaseUrl: 'postgres://test' })),
+}))
+vi.mock('../context.js', () => ({
+ loadEncryptionContext: vi.fn(async () => ({ client: {}, tables: new Map() })),
+ requireTable: vi.fn(),
+}))
+
+import type pg from 'pg'
+import { assertEqlV3Target, BackfillConfigError } from '../backfill.js'
+
+// The guard only ever hands this to the mocked probes.
+const db = {} as pg.ClientBase
+
+describe('assertEqlV3Target', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('accepts an EQL v3 domain without probing for existence', async () => {
+ detectColumnEqlVersion.mockResolvedValue(3)
+
+ await expect(
+ assertEqlV3Target(db, 'users', 'email_encrypted'),
+ ).resolves.toBe(3)
+ // The happy path must not pay for a second catalog round-trip.
+ expect(columnExists).not.toHaveBeenCalled()
+ })
+
+ it('reports a MISSING column as missing, not as legacy EQL v2', async () => {
+ detectColumnEqlVersion.mockResolvedValue(null)
+ columnExists.mockResolvedValue(false)
+
+ const error = await assertEqlV3Target(db, 'users', 'email_encrypted').catch(
+ (e: unknown) => e,
+ )
+
+ expect(error).toBeInstanceOf(BackfillConfigError)
+ const message = (error as Error).message
+ expect(message).toContain('does not exist on users')
+ expect(message).toContain('eql_v3_*')
+ // The remedy is to add the column, not to migrate a domain that isn't there.
+ expect(message).toContain('--encrypted-column')
+ expect(message).not.toContain('EQL v2')
+ })
+
+ it('keeps the legacy-v2 diagnosis for a column that DOES exist', async () => {
+ detectColumnEqlVersion.mockResolvedValue(null)
+ columnExists.mockResolvedValue(true)
+
+ const error = await assertEqlV3Target(db, 'users', 'email_encrypted').catch(
+ (e: unknown) => e,
+ )
+
+ expect(error).toBeInstanceOf(BackfillConfigError)
+ const message = (error as Error).message
+ expect(message).toContain('is not an EQL v3 domain')
+ expect(message).toContain('no longer backfills legacy EQL v2 columns')
+ expect(message).not.toContain('does not exist')
+ })
+})
diff --git a/packages/cli/src/commands/encrypt/__tests__/context-placeholder.test.ts b/packages/cli/src/commands/encrypt/__tests__/context-placeholder.test.ts
new file mode 100644
index 000000000..71d0d4af7
--- /dev/null
+++ b/packages/cli/src/commands/encrypt/__tests__/context-placeholder.test.ts
@@ -0,0 +1,170 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { PLACEHOLDER_TABLE_NAME } from '@/config/index.js'
+
+/**
+ * `stash encrypt` does not load its client through `loadEncryptConfig`, so the
+ * placeholder guard that command group needs lives in `loadEncryptionContext`.
+ * Both must refuse the un-replaced scaffold; otherwise `requireTable` reports
+ * `Table "users" was not found … Available: __stash_placeholder__`, which names
+ * the symptom instead of the cause (#787 review).
+ *
+ * Runs against the real jiti runtime — the guard reads the tables harvested
+ * from an actually-evaluated client module, which is the part worth pinning.
+ */
+describe('loadEncryptionContext — the un-replaced init scaffold', () => {
+ let tmpDir: string
+ let originalCwd: () => string
+
+ const writeProject = (clientBody: string) => {
+ fs.writeFileSync(
+ path.join(tmpDir, 'stash.config.ts'),
+ `export default {
+ databaseUrl: 'postgresql://u:p@127.0.0.1:5432/db',
+ client: './client.ts',
+ }`,
+ )
+ fs.writeFileSync(path.join(tmpDir, 'client.ts'), clientBody)
+ process.cwd = () => tmpDir
+ }
+
+ /**
+ * The duck-typed shapes `loadEncryptionContext` harvests: any export with a
+ * `getEncryptConfig()` method is the client, and any with `tableName` +
+ * `build()` is a table. Hand-rolled rather than imported from
+ * `@cipherstash/stack` so the test needs no native module.
+ */
+ const table = (name: string) =>
+ `{ tableName: '${name}', build: () => ({ tableName: '${name}', columns: {} }) }`
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-encrypt-ctx-'))
+ originalCwd = process.cwd
+ })
+
+ afterEach(() => {
+ process.cwd = originalCwd
+ vi.restoreAllMocks()
+ if (tmpDir && fs.existsSync(tmpDir)) {
+ fs.rmSync(tmpDir, { recursive: true, force: true })
+ }
+ })
+
+ it('exits 1 naming the sentinel when it is the only table declared', async () => {
+ // The scaffold's real shape: the sentinel is both exported AND the sole
+ // entry in the built encrypt config, because it was passed to `Encryption`.
+ writeProject(
+ `export const encryptionClient = {
+ getEncryptConfig: () => ({ tables: { '${PLACEHOLDER_TABLE_NAME}': {} } }),
+ }
+ export const placeholderTable = ${table(PLACEHOLDER_TABLE_NAME)}`,
+ )
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(process, 'exit').mockImplementation(() => {
+ throw new Error('process.exit')
+ })
+
+ const { loadEncryptionContext } = await import('../context.js')
+ await expect(loadEncryptionContext()).rejects.toThrow('process.exit')
+
+ const message = error.mock.calls.flat().join('\n')
+ expect(message).toContain(PLACEHOLDER_TABLE_NAME)
+ // Names the cause, not `requireTable`'s "table not found" symptom.
+ expect(message).toContain('still contains the placeholder table')
+ expect(message).not.toContain('was not found in the encryption client')
+ })
+
+ /**
+ * #787 review. The guard originally read the harvested EXPORT map, while the
+ * The `db validate` guard it mirrors reads `getEncryptConfig().tables`.
+ * Those disagree in both directions on the same client file, so the two
+ * commands gave different answers for identical input.
+ */
+ it('fires when the placeholder is passed to Encryption but never exported', async () => {
+ // The false NEGATIVE. `schemas: [placeholderTable]` with a bare `const` —
+ // the scaffold minus one `export` keyword. Reading exports, the guard saw
+ // no tables and fell through to `requireTable`'s "table not found …
+ // Available: (none)" — the very error this guard exists to replace.
+ writeProject(
+ `export const encryptionClient = {
+ getEncryptConfig: () => ({ tables: { '${PLACEHOLDER_TABLE_NAME}': {} } }),
+ }`,
+ )
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(process, 'exit').mockImplementation(() => {
+ throw new Error('process.exit')
+ })
+
+ const { loadEncryptionContext } = await import('../context.js')
+ await expect(loadEncryptionContext()).rejects.toThrow('process.exit')
+
+ const message = error.mock.calls.flat().join('\n')
+ expect(message).toContain('still contains the placeholder table')
+ expect(message).not.toContain('was not found in the encryption client')
+ })
+
+ it('stays silent when a stale placeholder export sits beside real configured tables', async () => {
+ // The false POSITIVE, and the mirror of the case above: the user replaced
+ // the schema set but left the sentinel `export` behind (or imports their
+ // real tables without re-exporting them). Reading exports, the guard fired
+ // and told them to declare columns they had already declared. `db validate`
+ // passes on this same file.
+ writeProject(
+ `export const encryptionClient = {
+ getEncryptConfig: () => ({ tables: { users: {} } }),
+ }
+ export const placeholderTable = ${table(PLACEHOLDER_TABLE_NAME)}`,
+ )
+
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
+ throw new Error('process.exit')
+ })
+
+ const { loadEncryptionContext } = await import('../context.js')
+ const ctx = await loadEncryptionContext()
+
+ // The silence this test is named for. Without the `exit` spy the guard
+ // firing here would kill the worker instead of failing an assertion.
+ expect(exit).not.toHaveBeenCalled()
+ expect(error.mock.calls.flat().join('\n')).not.toContain(
+ 'still contains the placeholder table',
+ )
+ expect(ctx.tables.has(PLACEHOLDER_TABLE_NAME)).toBe(true)
+ })
+
+ it('allows the sentinel through once a real table sits alongside it', async () => {
+ // Only the SOLE-placeholder case is the un-replaced scaffold. A user who
+ // has added real tables must not be blocked by a leftover sentinel.
+ //
+ // The sentinel is declared FIRST in the config deliberately: the guard
+ // reads `configuredTables[0]`, so a placeholder-last fixture passes even
+ // with the arity check deleted. Ordered this way, dropping
+ // `configuredTables.length === 1` makes this test fail — which is the only
+ // thing that pins that clause anywhere in the suite (#787 review follow-up).
+ writeProject(
+ `export const encryptionClient = {
+ getEncryptConfig: () => ({
+ tables: { '${PLACEHOLDER_TABLE_NAME}': {}, users: {} },
+ }),
+ }
+ export const placeholderTable = ${table(PLACEHOLDER_TABLE_NAME)}
+ export const users = ${table('users')}`,
+ )
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
+ throw new Error('process.exit')
+ })
+
+ const { loadEncryptionContext } = await import('../context.js')
+ const ctx = await loadEncryptionContext()
+
+ expect(exit).not.toHaveBeenCalled()
+ expect(error.mock.calls.flat().join('\n')).not.toContain(
+ 'still contains the placeholder table',
+ )
+ expect(ctx.tables.has('users')).toBe(true)
+ })
+})
diff --git a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
index da2389aff..2b447264c 100644
--- a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
+++ b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
@@ -1,9 +1,12 @@
+import type {
+ EncryptedColumnInfo,
+ ResolvedEncryptedColumn,
+} from '@cipherstash/migrate'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ResolvedLifecycle } from '../lib/resolve-eql.js'
-// The EQL-v3 lifecycle branches (#648/#649): v3 has no cut-over rename —
-// `encrypt cutover` must short-circuit with guidance instead of running the
-// v2 config machine, and `encrypt drop` must target the ORIGINAL plaintext
-// column (there is no `_plaintext`) gated on `backfilled` AND a live
+// The EQL-v3 drop lifecycle (#649): `encrypt drop` must target the ORIGINAL
+// plaintext column (there is no `_plaintext`) gated on `backfilled` AND a live
// coverage check. Version + encrypted-column NAME come from the domain types
// via `resolveColumnLifecycle` — the `_encrypted` naming is a
// convention, never relied upon — and both commands FAIL CLOSED when
@@ -23,9 +26,14 @@ vi.mock('pg', () => ({
},
}))
-type ColumnInfo = { column: string; domain: string; version: 2 | 3 }
-type ResolvedInfo = ColumnInfo & { via: 'hint' | 'convention' | 'sole' }
-type Lifecycle = { info: ResolvedInfo | null; candidates: ColumnInfo[] }
+// Imported, never re-declared. These stubs stand in for the real
+// `resolveColumnLifecycle`, so a structural copy would let a renamed or added
+// field compile on both sides while the commands read something the resolver no
+// longer returns — the exact seam `v2-lifecycle-composition.integration.test.ts`
+// exercises at runtime, pinned here at compile time (#787 review follow-up).
+type ColumnInfo = EncryptedColumnInfo
+type ResolvedInfo = ResolvedEncryptedColumn
+type Lifecycle = ResolvedLifecycle
const migrateMocks = vi.hoisted(() => ({
listEncryptedColumns: vi.fn(async (): Promise => []),
@@ -37,10 +45,12 @@ const migrateMocks = vi.hoisted(() => ({
),
appendEvent: vi.fn(async () => {}),
setManifestTargetPhase: vi.fn(async () => {}),
- renameEncryptedColumns: vi.fn(async () => {}),
- migrateConfig: vi.fn(async () => {}),
- activateConfig: vi.fn(async () => {}),
- reloadConfig: vi.fn(async () => {}),
+ quoteIdent: (identifier: string) => `"${identifier.replace(/"/g, '""')}"`,
+ qualifyTable: (table: string) =>
+ table
+ .split('.')
+ .map((part) => `"${part.replace(/"/g, '""')}"`)
+ .join('.'),
}))
vi.mock('@cipherstash/migrate', () => migrateMocks)
@@ -98,7 +108,6 @@ vi.mock('node:fs', () => ({
}))
import * as p from '@clack/prompts'
-import { cutoverCommand } from '../cutover.js'
import { dropCommand } from '../drop.js'
const V3_INFO: ColumnInfo = {
@@ -111,12 +120,6 @@ const V3_CUSTOM_INFO: ColumnInfo = {
domain: 'eql_v3_text_search',
version: 3,
}
-const V2_INFO: ColumnInfo = {
- column: 'email_encrypted',
- domain: 'eql_v2_encrypted',
- version: 2,
-}
-
function resolved(
info: ColumnInfo,
via: ResolvedInfo['via'] = 'convention',
@@ -124,152 +127,12 @@ function resolved(
return { info: { ...info, via }, candidates: [info] }
}
-/** v2 config machine present + a pending config row. */
-function mockV2ConfigQueries() {
- queryMock.mockImplementation(async (sql: string) => {
- if (typeof sql !== 'string') return { rows: [] }
- if (sql.includes('to_regclass')) {
- return { rows: [{ exists: 'eql_v2_configuration' }] }
- }
- if (sql.includes('eql_v2_configuration')) {
- return { rows: [{ exists: true }] }
- }
- return { rows: [] }
- })
-}
-
function spyExit() {
return vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
}
-describe('encrypt cutover — EQL version awareness', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- queryMock.mockResolvedValue({ rows: [] })
- migrateMocks.progress.mockResolvedValue({ phase: 'backfilled' })
- lifecycleMock.mockResolvedValue(resolved(V3_INFO))
- })
-
- it('short-circuits on a backfilled v3 column: guidance, no rename, no config machine, exit 0', async () => {
- const exitSpy = spyExit()
-
- await cutoverCommand({ table: 'users', column: 'email' })
-
- expect(p.log.info).toHaveBeenCalledWith(
- expect.stringContaining('not applicable to EQL v3'),
- )
- expect(p.log.info).toHaveBeenCalledWith(
- expect.stringContaining('stash encrypt drop'),
- )
- expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled()
- expect(migrateMocks.migrateConfig).not.toHaveBeenCalled()
- expect(migrateMocks.activateConfig).not.toHaveBeenCalled()
- expect(migrateMocks.appendEvent).not.toHaveBeenCalled()
- expect(exitSpy).not.toHaveBeenCalled()
- exitSpy.mockRestore()
- })
-
- it('v3 mid-backfill: does NOT tell the user to switch; exits 1', async () => {
- migrateMocks.progress.mockResolvedValue({ phase: 'dual-writing' })
- const exitSpy = spyExit()
-
- await cutoverCommand({ table: 'users', column: 'email' })
-
- expect(p.log.error).toHaveBeenCalledWith(
- expect.stringContaining("hasn't finished backfilling"),
- )
- // The switch-now guidance must not appear before backfill completes —
- // following it mid-backfill reads NULLs for unbackfilled rows.
- expect(p.log.info).not.toHaveBeenCalledWith(
- expect.stringContaining('point your application'),
- )
- expect(exitSpy).toHaveBeenCalledWith(1)
- exitSpy.mockRestore()
- })
-
- it('v3 already dropped: terminal phase is "nothing to do", not "finish the backfill"; exit 0', async () => {
- migrateMocks.progress.mockResolvedValue({ phase: 'dropped' })
- const exitSpy = spyExit()
-
- await cutoverCommand({ table: 'users', column: 'email' })
-
- expect(p.log.info).toHaveBeenCalledWith(
- expect.stringContaining('already completed'),
- )
- expect(p.log.error).not.toHaveBeenCalled()
- expect(exitSpy).not.toHaveBeenCalled()
- exitSpy.mockRestore()
- })
-
- it('uses the RESOLVED encrypted column name, not the naming convention', async () => {
- lifecycleMock.mockResolvedValue(resolved(V3_CUSTOM_INFO, 'hint'))
-
- await cutoverCommand({ table: 'users', column: 'email' })
-
- expect(p.log.info).toHaveBeenCalledWith(
- expect.stringContaining('email_enc'),
- )
- expect(p.log.info).not.toHaveBeenCalledWith(
- expect.stringContaining('email_encrypted'),
- )
- })
-
- it('fails closed when EQL columns exist but none is identifiable', async () => {
- lifecycleMock.mockResolvedValue({
- info: null,
- candidates: [
- { column: 'a_enc', domain: 'eql_v3_text_eq', version: 3 },
- { column: 'b_enc', domain: 'eql_v3_text_eq', version: 3 },
- ],
- })
- const exitSpy = spyExit()
-
- await cutoverCommand({ table: 'users', column: 'email' })
-
- expect(p.log.error).toHaveBeenCalledWith(
- expect.stringContaining('Cannot identify which encrypted column'),
- )
- expect(p.log.error).toHaveBeenCalledWith(expect.stringContaining('a_enc'))
- expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled()
- expect(exitSpy).toHaveBeenCalledWith(1)
- exitSpy.mockRestore()
- })
-
- it('still runs the v2 flow for a v2 column (regression pin)', async () => {
- lifecycleMock.mockResolvedValue(resolved(V2_INFO))
- mockV2ConfigQueries()
-
- await cutoverCommand({ table: 'users', column: 'email' })
-
- expect(migrateMocks.renameEncryptedColumns).toHaveBeenCalled()
- expect(migrateMocks.migrateConfig).toHaveBeenCalled()
- expect(migrateMocks.activateConfig).toHaveBeenCalled()
- })
-
- it('explains a v3-only database instead of a raw relation-does-not-exist error', async () => {
- // Detection missed (e.g. no EQL columns visible) → v2 path — but the
- // config table doesn't exist on this database.
- lifecycleMock.mockResolvedValue({ info: null, candidates: [] })
- queryMock.mockImplementation(async (sql: string) =>
- typeof sql === 'string' && sql.includes('to_regclass')
- ? { rows: [{ exists: null }] }
- : { rows: [] },
- )
- const exitSpy = spyExit()
-
- await cutoverCommand({ table: 'users', column: 'email' })
-
- expect(p.log.error).toHaveBeenCalledWith(
- expect.stringContaining('no EQL v2 configuration table'),
- )
- expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled()
- expect(exitSpy).toHaveBeenCalledWith(1)
- exitSpy.mockRestore()
- })
-})
-
describe('encrypt drop — EQL version awareness', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -318,6 +181,32 @@ describe('encrypt drop — EQL version awareness', () => {
expect(sql).toMatch(/EXECUTE 'ALTER TABLE "users" DROP COLUMN "email"'/)
})
+ it('quotes unusual identifiers and sanitizes the migration filename', async () => {
+ lifecycleMock.mockResolvedValue(
+ resolved(
+ {
+ column: 'email"encrypted',
+ domain: 'eql_v3_text_search',
+ version: 3,
+ },
+ 'hint',
+ ),
+ )
+ migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' })
+
+ await dropCommand({
+ table: 'tenant.weird"table',
+ column: '../email"',
+ })
+
+ const [filePath, sql] = writeFileMock.mock.calls[0] as [string, string]
+ expect(sql).toContain('LOCK TABLE "tenant"."weird""table"')
+ expect(sql).toContain('"../email""" IS NOT NULL')
+ expect(sql).toContain('"email""encrypted" IS NULL')
+ expect(filePath).toMatch(/_drop_tenant_weird_table_email\.sql$/)
+ expect(filePath).not.toContain('../email')
+ })
+
it('v3: refuses to generate when rows are still plaintext-only', async () => {
migrateMocks.progress.mockResolvedValueOnce({ phase: 'backfilled' })
migrateMocks.countUnencrypted.mockResolvedValueOnce(7)
@@ -372,9 +261,21 @@ describe('encrypt drop — EQL version awareness', () => {
expect(p.log.error).toHaveBeenCalledWith(
expect.stringContaining('nothing confirms it encrypts "email"'),
)
- expect(p.log.error).toHaveBeenCalledWith(
+ // The remedy must not prescribe the GUESS. Recording `secret_blob` makes
+ // the next resolution `via: 'hint'`, which walks past this very gate — and
+ // the coverage check then passes vacuously, because an unrelated but
+ // legitimately-backfilled column is non-NULL on every row. Following that
+ // advice generated a live DROP COLUMN on the plaintext at exit 0
+ // (#772 review, finding 7).
+ expect(p.log.error).not.toHaveBeenCalledWith(
expect.stringContaining('--encrypted-column secret_blob'),
)
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining('--encrypted-column '),
+ )
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining('do not record secret_blob'),
+ )
expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled()
expect(writeFileMock).not.toHaveBeenCalled()
expect(exitSpy).toHaveBeenCalledWith(1)
@@ -415,31 +316,19 @@ describe('encrypt drop — EQL version awareness', () => {
exitSpy.mockRestore()
})
- it('v2: unchanged — requires cut-over, no coverage gate, drops _plaintext (regression pin)', async () => {
- lifecycleMock.mockResolvedValue(resolved(V2_INFO))
- migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' })
-
- await dropCommand({ table: 'users', column: 'email' })
-
- const sql = writeFileMock.mock.calls[0]?.[1] as string
- expect(sql).toContain('DROP COLUMN "email_plaintext"')
- expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled()
- })
-
- it('v2 post-cutover: `email` itself carrying the v2 domain is NOT ambiguity — proceeds down the v2 path', async () => {
- // After cutover renamed the ciphertext onto `email`, no counterpart is
- // resolvable BY DESIGN. The fail-closed guard must recognize this state
- // rather than blocking the one drop the lifecycle actually wants.
- lifecycleMock.mockResolvedValue({
- info: null,
- candidates: [{ column: 'email', domain: 'eql_v2_encrypted', version: 2 }],
- })
- migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' })
+ it('rejects an unclassified legacy column instead of taking a v2 drop path', async () => {
+ lifecycleMock.mockResolvedValue({ info: null, candidates: [] })
+ const exitSpy = spyExit()
await dropCommand({ table: 'users', column: 'email' })
- const sql = writeFileMock.mock.calls[0]?.[1] as string
- expect(sql).toContain('DROP COLUMN "email_plaintext"')
- expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled()
+ expect(p.log.error).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Legacy EQL v2 drop/cut-over automation has been removed',
+ ),
+ )
+ expect(writeFileMock).not.toHaveBeenCalled()
+ expect(exitSpy).toHaveBeenCalledWith(1)
+ exitSpy.mockRestore()
})
})
diff --git a/packages/cli/src/commands/encrypt/backfill.ts b/packages/cli/src/commands/encrypt/backfill.ts
index 7f7edb006..b4e3fdcf4 100644
--- a/packages/cli/src/commands/encrypt/backfill.ts
+++ b/packages/cli/src/commands/encrypt/backfill.ts
@@ -1,7 +1,7 @@
import {
appendEvent,
+ columnExists,
detectColumnEqlVersion,
- type EqlVersion,
type ManifestColumn,
progress,
runBackfill,
@@ -138,21 +138,14 @@ export async function backfillCommand(options: BackfillCommandOptions) {
const encryptedColumn =
options.encryptedColumn ?? `${options.column}_encrypted`
- // v2 or v3 changes the rest of the LIFECYCLE (v3 has no cut-over — the
- // ladder is backfill → switch-by-name → drop), so detect it up front,
- // record it in the manifest, and tell the user which path they're on.
- // `null` means the target column doesn't exist or isn't an EQL domain —
- // let the existing checks below produce their specific errors.
- const eqlVersion = await detectColumnEqlVersion(
+ const eqlVersion = await assertEqlV3Target(
db,
options.table,
encryptedColumn,
)
- if (eqlVersion) {
- p.log.info(
- `${options.table}.${encryptedColumn} is EQL v${eqlVersion}${eqlVersion === 3 ? ' — lifecycle is backfill → switch the app to the encrypted column by name → drop (no cut-over rename).' : ''}`,
- )
- }
+ p.log.info(
+ `${options.table}.${encryptedColumn} is EQL v3 — lifecycle is backfill → switch the app to the encrypted column by name → drop (no cut-over rename).`,
+ )
// Phase guard: backfill requires the application to already be writing
// to both columns, otherwise rows inserted *during* the backfill land
@@ -274,12 +267,10 @@ export async function backfillCommand(options: BackfillCommandOptions) {
return
}
- if (eqlVersion === 3) {
- p.note(
- `EQL v3 has no cut-over. Next:\n 1. Point your application at ${encryptedColumn} (schema + queries), deploy, verify reads.\n 2. Generate the plaintext drop: stash encrypt drop --table ${options.table} --column ${plaintextColumn}`,
- 'Next steps (EQL v3)',
- )
- }
+ p.note(
+ `EQL v3 has no cut-over. Next:\n 1. Point your application at ${encryptedColumn} (schema + queries), deploy, verify reads.\n 2. Generate the plaintext drop: stash encrypt drop --table ${options.table} --column ${plaintextColumn}`,
+ 'Next steps (EQL v3)',
+ )
p.outro(
`Backfill complete. ${result.rowsProcessed.toLocaleString()} rows encrypted.`,
)
@@ -316,13 +307,43 @@ export async function backfillCommand(options: BackfillCommandOptions) {
* upstream encryption errors, which can embed plaintext samples and are
* suppressed by the catch block in {@link backfillCommand}.
*/
-class BackfillConfigError extends Error {
+export class BackfillConfigError extends Error {
constructor(message: string) {
super(message)
this.name = 'BackfillConfigError'
}
}
+/**
+ * Backfill authors ciphertext, so it accepts only the current EQL v3 domains.
+ * Legacy v2 remains visible to status/read diagnostics but is no longer a
+ * writable rollout target.
+ *
+ * `detectColumnEqlVersion` answers `null` for a non-v3 domain AND for a column
+ * that isn't there, so the failure path pays for one extra probe to tell them
+ * apart. Conflating them accused a user who had merely not added the encrypted
+ * twin yet of running legacy v2, and handed them a remedy — migrate the domain
+ * — for a column that does not exist. The probe stays off the happy path: a v3
+ * answer already proves existence.
+ */
+export async function assertEqlV3Target(
+ db: pg.ClientBase,
+ table: string,
+ encryptedColumn: string,
+): Promise<3> {
+ const eqlVersion = await detectColumnEqlVersion(db, table, encryptedColumn)
+ if (eqlVersion === 3) return 3
+
+ if (!(await columnExists(db, table, encryptedColumn))) {
+ throw new BackfillConfigError(
+ `Column ${encryptedColumn} does not exist on ${table}. Add the encrypted destination column with an eql_v3_* domain type to your schema and apply the migration before backfilling. If your schema names it something other than ${encryptedColumn}, pass --encrypted-column .`,
+ )
+ }
+ throw new BackfillConfigError(
+ `${table}.${encryptedColumn} is not an EQL v3 domain. stash no longer backfills legacy EQL v2 columns; migrate the schema to an eql_v3_* domain first.`,
+ )
+}
+
/**
* Pick the schema-column key for this physical column. The drizzle
* helper (`extractEncryptionSchema`) keys by the physical encrypted name;
@@ -526,11 +547,11 @@ function buildManifestEntry(
plaintextColumn: string,
encryptedColumn: string,
pkColumn: string | undefined,
- eqlVersion: EqlVersion | null,
+ eqlVersion: 3,
): ManifestColumn {
// SDK `cast_as` ('string', 'number', …) and EQL `castAs` ('text',
// 'double', …) are different vocabularies; translate via the same
- // helper `stash db push` uses so the two stay aligned.
+ // shared schema translation helper so SDK and EQL vocabularies stay aligned.
const castAs: ManifestColumn['castAs'] =
column?.cast_as !== undefined
? translateCastAs(
@@ -548,18 +569,14 @@ function buildManifestEntry(
column: plaintextColumn,
castAs,
indexes,
- // Recorded so later commands (cutover/drop/status) don't have to guess
+ // Recorded so later commands (drop/status) don't have to guess
// the name from the `_encrypted` convention — the name is a
// convention only, never relied upon.
encryptedColumn,
- // v2's ladder ends with the rename cut-over; v3 has none — its end
- // state is the plaintext column dropped.
- targetPhase: eqlVersion === 3 ? 'dropped' : 'cut-over',
+ targetPhase: 'dropped',
+ eqlVersion,
}
if (pkColumn) entry.pkColumn = pkColumn
- // Absent means UNKNOWN (detection couldn't see the column), not v2 —
- // readers fall back to the domain type in the database.
- if (eqlVersion) entry.eqlVersion = eqlVersion
return entry
}
diff --git a/packages/cli/src/commands/encrypt/context.ts b/packages/cli/src/commands/encrypt/context.ts
index 1975e2d96..264bc2099 100644
--- a/packages/cli/src/commands/encrypt/context.ts
+++ b/packages/cli/src/commands/encrypt/context.ts
@@ -1,7 +1,11 @@
import fs from 'node:fs'
import path from 'node:path'
import type { EncryptionClient } from '@cipherstash/stack/encryption'
-import { loadStashConfig, type ResolvedStashConfig } from '@/config/index.js'
+import {
+ loadStashConfig,
+ type ResolvedStashConfig,
+ requireUsableEncryptConfig,
+} from '@/config/index.js'
/**
* Structural shape of `@cipherstash/stack`'s `EncryptedTable` class.
@@ -138,6 +142,14 @@ export async function loadEncryptionContext(): Promise {
process.exit(1)
}
+ // The same refusal `stash db validate` gets from
+ // `loadEncryptConfig`, applied here because `stash encrypt` does not go
+ // through that loader. Called, not re-implemented: it guards one file, so the
+ // two commands must say one thing about it. Without this, `requireTable`
+ // reported `Table "users" was not found … Available: __stash_placeholder__`,
+ // naming the symptom and not the cause (#787 review).
+ requireUsableEncryptConfig(client.getEncryptConfig(), stashConfig.client)
+
return { stashConfig, client, tables }
}
diff --git a/packages/cli/src/commands/encrypt/cutover.ts b/packages/cli/src/commands/encrypt/cutover.ts
deleted file mode 100644
index 6584ef075..000000000
--- a/packages/cli/src/commands/encrypt/cutover.ts
+++ /dev/null
@@ -1,304 +0,0 @@
-import {
- activateConfig,
- appendEvent,
- migrateConfig,
- progress,
- reloadConfig,
- renameEncryptedColumns,
-} from '@cipherstash/migrate'
-import * as p from '@clack/prompts'
-import pg from 'pg'
-import { detectDrizzle } from '@/commands/db/detect.js'
-import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
-import { loadStashConfig } from '@/config/index.js'
-import { scaffoldDrizzleMigration } from './drizzle-helper.js'
-import { explainUnresolved, resolveColumnLifecycle } from './lib/resolve-eql.js'
-
-/**
- * Options accepted by `stash encrypt cutover`. Swaps the plaintext and
- * encrypted columns via `eql_v2.rename_encrypted_columns()` so that apps
- * reading `` transparently receive the encrypted column
- * (decrypted on read by Proxy or client-side by Stack).
- */
-export interface CutoverCommandOptions {
- /** Physical table name, e.g. `users`. Supports `schema.table`. */
- table: string
- /**
- * Physical plaintext column that is being cut over, e.g. `email`. Used
- * only for the state-transition check and event log; the actual rename
- * affects every column in the active EQL config in a single call.
- */
- column: string
- /**
- * Optional Postgres URL of a CipherStash Proxy. When set, the command
- * connects to the Proxy after the rename and runs `eql_v2.reload_config()`
- * so Proxy picks up the renamed columns immediately rather than waiting
- * for its 60-second refresh. When unset, prints a warning to that effect
- * and returns — the Proxy will refresh on its own.
- *
- * Also readable from `CIPHERSTASH_PROXY_URL` in the environment.
- */
- proxyUrl?: string
- /**
- * Drizzle migrations directory (passed to `drizzle-kit generate
- * --custom`). Defaults to `./drizzle`. Only used when the project is
- * Drizzle — non-Drizzle projects skip the snapshot-resync step.
- */
- migrationsDir?: string
-}
-
-/**
- * CLI handler for `stash encrypt cutover`. Verifies the target column is
- * in phase `backfilled`, runs `eql_v2.rename_encrypted_columns()` inside
- * a transaction, appends a `cut_over` event, and optionally triggers a
- * Proxy config reload. Exits with code `1` if preconditions are not met.
- */
-export async function cutoverCommand(options: CutoverCommandOptions) {
- p.intro(runnerCommand(detectPackageManager(), 'stash encrypt cutover'))
-
- const config = await loadStashConfig()
- const client = new pg.Client({ connectionString: config.databaseUrl })
- let exitCode = 0
-
- try {
- await client.connect()
-
- // Cut-over is an EQL v2 concept: v2 hides the swap behind
- // `eql_v2.rename_encrypted_columns()` + a Proxy config promotion. A v3
- // column has neither — the application switches to the encrypted column
- // BY NAME, and the plaintext column is dropped later. Resolve from the
- // DOMAIN TYPES (manifest name as a hint; the `_encrypted` naming is
- // a convention, never relied upon) before any phase/config checks so v3
- // users get the real answer, not a confusing precondition error.
- const { info, candidates } = await resolveColumnLifecycle(
- client,
- options.table,
- options.column,
- )
- // Fail closed on ambiguity: `info === null` with EQL columns present
- // means we can't tell WHICH lifecycle applies — running the v2 config
- // machine against (possibly) v3 columns would only produce a misleading
- // downstream error. (No EQL columns at all, or the post-cutover v2
- // same-name state, still falls through to the v2 preconditions below.)
- const unresolved = explainUnresolved(
- options.table,
- options.column,
- candidates,
- )
- if (!info && unresolved) {
- p.log.error(unresolved)
- exitCode = 1
- return
- }
- const state = await progress(client, options.table, options.column)
-
- if (info?.version === 3) {
- const encryptedColumn = info.column
- if (state?.phase === 'dropped') {
- // Terminal phase — the lifecycle already finished. Not an error and
- // not "finish the backfill": there is nothing left to backfill.
- p.log.info(
- `${options.table}.${options.column} has already completed the EQL v3 lifecycle (plaintext dropped). Nothing to cut over.`,
- )
- p.outro('Nothing to do for EQL v3.')
- return
- }
- if (state?.phase !== 'backfilled') {
- // Not a "nothing to do" — the user isn't ready for ANY next step
- // yet. Exit 1 so scripted pipelines gating on cutover don't read
- // an incomplete backfill as success.
- p.log.error(
- `Cut-over is not applicable to EQL v3 columns, and ${options.table}.${options.column} hasn't finished backfilling (phase '${state?.phase ?? '—'}'). Finish the backfill first:\n stash encrypt backfill --table ${options.table} --column ${options.column}`,
- )
- exitCode = 1
- return
- }
- p.log.info(
- `Cut-over is not applicable to EQL v3 columns. ${options.table}.${encryptedColumn} is EQL v3 (${info.domain}): there is no rename step — point your application at ${encryptedColumn} (update your schema/queries), verify reads, then generate the plaintext drop with:\n stash encrypt drop --table ${options.table} --column ${options.column}`,
- )
- p.outro('Nothing to do for EQL v3.')
- return
- }
-
- if (state?.phase !== 'backfilled') {
- p.log.error(
- `Cannot cut over: ${options.table}.${options.column} is in phase '${state?.phase ?? '—'}'. Must be 'backfilled'.`,
- )
- exitCode = 1
- return
- }
-
- // Guard the v2 config machinery's existence before querying it: on a
- // v3-only database (v3 is the default install) there is no
- // `eql_v2_configuration` relation, and an unguarded query surfaces as a
- // raw "relation does not exist" error instead of an explanation.
- const configTable = await client.query<{ exists: string | null }>(
- "SELECT to_regclass('public.eql_v2_configuration')::text AS exists",
- )
- if (configTable.rows[0]?.exists == null) {
- p.log.error(
- `This database has no EQL v2 configuration table — it looks like an EQL v3-only install. Cut-over only applies to EQL v2 columns; for v3, point your application at the encrypted column by name, then generate the plaintext drop with:\n stash encrypt drop --table ${options.table} --column ${options.column}`,
- )
- exitCode = 1
- return
- }
-
- // Verify a pending EQL config exists. cutover assumes the user has
- // already run `stash db push` against a schema that switches the
- // column from `_encrypted` (or whatever twin name) to `` —
- // db push writes that as pending, and cutover transitions
- // pending → encrypting → active alongside the physical rename.
- const pending = await client.query<{ exists: boolean }>(
- "SELECT EXISTS(SELECT 1 FROM public.eql_v2_configuration WHERE state = 'pending') AS exists",
- )
- if (pending.rows[0]?.exists !== true) {
- p.log.error(
- 'No pending EQL configuration to cut over. Cutover operates on the EQL v2 + CipherStash Proxy config lifecycle — update your schema to point at the encrypted column (drop the `_encrypted` suffix) and register the pending change before cutting over.',
- )
- exitCode = 1
- return
- }
-
- // Full lifecycle in one transaction:
- // 1. rename_encrypted_columns — physical column rename
- // 2. migrate_config — pending → encrypting
- // 3. activate_config — encrypting → active (and prior active → inactive)
- // Each step is a side-effect-free function from the user's POV
- // (everything happens inside the txn). Rollback on any error leaves
- // the system in its pre-cutover state.
- await client.query('BEGIN')
- try {
- await renameEncryptedColumns(client)
- await migrateConfig(client)
- await activateConfig(client)
- await appendEvent(client, {
- tableName: options.table,
- columnName: options.column,
- event: 'cut_over',
- phase: 'cut-over',
- details: { renamed: true },
- })
- await client.query('COMMIT')
- } catch (err) {
- await client.query('ROLLBACK').catch(() => {})
- throw err
- }
-
- p.log.success(
- `Renamed ${options.column} → ${options.column}_plaintext and ${options.column}_encrypted → ${options.column}; pending config promoted to active.`,
- )
-
- // Drizzle snapshot resync. The rename above ran outside drizzle-kit's
- // authority — the snapshot at `/meta/_snapshot.json` still
- // describes the pre-rename column shape. If we don't acknowledge the
- // change in Drizzle's metadata, the next `drizzle-kit generate` will
- // produce a confused diff trying to re-create the old layout.
- //
- // Scaffolding a custom migration with idempotent rename SQL solves
- // both problems: it adds the journal entry + snapshot diff that
- // Drizzle expects, and the SQL itself is a no-op on the source DB
- // (the pre-rename column doesn't exist any more) but applies
- // correctly when migrating a fresh database.
- if (detectDrizzle(process.cwd())) {
- try {
- const renameSql = buildRenameMigrationSql(options.table, options.column)
- const result = await scaffoldDrizzleMigration({
- name: `cutover_${options.table}_${options.column}`,
- outDir: options.migrationsDir ?? 'drizzle',
- sql: renameSql,
- })
- p.log.success(
- `Drizzle snapshot updated: ${result.path} (idempotent — no-op on this DB, applies on a fresh restore).`,
- )
- } catch (err) {
- const reason = err instanceof Error ? err.message : String(err)
- p.log.warn(
- `Could not scaffold the Drizzle rename migration: ${reason}\nDrizzle's snapshot may be out of sync with the live schema. Run \`drizzle-kit pull\` to resync, or scaffold the rename migration manually.`,
- )
- }
- }
-
- // Proxy reload runs *after* the cutover transaction has committed.
- // Any error from here on is post-commit cosmetic — the rename and
- // config promotion are durable. Catch the proxy reload separately so
- // a transient Proxy connectivity blip doesn't make the outer catch
- // exit(1), which would falsely tell automation the cutover failed
- // and encourage unsafe retries.
- const proxyUrl = options.proxyUrl ?? process.env.CIPHERSTASH_PROXY_URL
- if (proxyUrl) {
- const proxy = new pg.Client({ connectionString: proxyUrl })
- try {
- await proxy.connect()
- try {
- await reloadConfig(proxy)
- p.log.success('Proxy config reloaded.')
- } catch (err) {
- const reason = err instanceof Error ? err.message : String(err)
- p.log.warn(
- `Proxy config reload failed (${reason}). The cutover itself is committed and durable; Proxy will pick up the new config on its next 60s refresh.`,
- )
- }
- } finally {
- await proxy.end()
- }
- } else {
- p.log.warn(
- 'CIPHERSTASH_PROXY_URL not set; Proxy users must wait up to 60s for config refresh.',
- )
- }
-
- p.outro(
- 'Cut-over complete. Your app reads the encrypted column transparently.',
- )
- } catch (error) {
- p.log.error(error instanceof Error ? error.message : 'Cut-over failed.')
- exitCode = 1
- } finally {
- await client.end()
- // In `finally` (not after the try/catch) deliberately: the precondition
- // guards above `return` from inside `try`, which skips any code placed
- // after the block — so a trailing `if (exitCode) process.exit(...)`
- // was unreachable on exactly the failure paths it existed for, and
- // guard failures exited 0.
- if (exitCode) process.exit(exitCode)
- }
-}
-
-/**
- * Build the SQL body for the post-cutover Drizzle migration. Wrapped in a
- * `DO` block that checks whether `_encrypted` still exists — on the
- * source database the rename already ran (so the column is gone and the
- * block does nothing), but on a fresh restore the rename hasn't run yet
- * (so the block performs the swap). Same migration file, both behaviours,
- * idempotent.
- *
- * Splits `schema.table` into separate identifiers so the generated SQL is
- * correct regardless of whether the user passed `users` or `public.users`.
- */
-function buildRenameMigrationSql(table: string, column: string): string {
- const dot = table.indexOf('.')
- const [schema, tableName] =
- dot >= 0 ? [table.slice(0, dot), table.slice(dot + 1)] : [null, table]
-
- const qualified = schema ? `"${schema}"."${tableName}"` : `"${tableName}"`
- const schemaPredicate = schema
- ? `table_schema = '${schema.replace(/'/g, "''")}' AND `
- : ''
-
- return `-- Generated by stash encrypt cutover.
--- Records the rename that eql_v2.rename_encrypted_columns() performed
--- so Drizzle's snapshot stays in sync. Idempotent: a no-op on the DB
--- where cutover already ran; applies on a fresh restore.
-DO $$
-BEGIN
- IF EXISTS (
- SELECT 1 FROM information_schema.columns
- WHERE ${schemaPredicate}table_name = '${tableName.replace(/'/g, "''")}'
- AND column_name = '${column}_encrypted'
- ) THEN
- ALTER TABLE ${qualified} RENAME COLUMN "${column}" TO "${column}_plaintext";
- ALTER TABLE ${qualified} RENAME COLUMN "${column}_encrypted" TO "${column}";
- END IF;
-END $$;
-`
-}
diff --git a/packages/cli/src/commands/encrypt/drizzle-helper.ts b/packages/cli/src/commands/encrypt/drizzle-helper.ts
index fb775cafa..f820d6c46 100644
--- a/packages/cli/src/commands/encrypt/drizzle-helper.ts
+++ b/packages/cli/src/commands/encrypt/drizzle-helper.ts
@@ -10,10 +10,8 @@ import { detectPackageManager, runnerArgv } from '@/commands/init/utils.js'
* does — `drizzle-kit generate --custom` creates the file and records the
* journal entry / snapshot, then we overwrite the empty body with our SQL.
*
- * Used by `encrypt drop` (to ship the plaintext-column drop migration in a
- * shape `drizzle-kit migrate` actually picks up) and `encrypt cutover` (to
- * record the live rename so Drizzle's snapshot reflects post-cutover
- * reality).
+ * Used by `encrypt drop` to ship the plaintext-column drop migration in a
+ * shape `drizzle-kit migrate` actually picks up.
*
* Throws if `drizzle-kit` isn't on PATH or the generated file can't be
* located afterwards. Callers should fall back to the self-named-file
diff --git a/packages/cli/src/commands/encrypt/drop.ts b/packages/cli/src/commands/encrypt/drop.ts
index 2fe440bb0..ec7871a35 100644
--- a/packages/cli/src/commands/encrypt/drop.ts
+++ b/packages/cli/src/commands/encrypt/drop.ts
@@ -4,6 +4,8 @@ import {
appendEvent,
countUnencrypted,
progress,
+ qualifyTable,
+ quoteIdent,
setManifestTargetPhase,
} from '@cipherstash/migrate'
import * as p from '@clack/prompts'
@@ -16,9 +18,7 @@ import { explainUnresolved, resolveColumnLifecycle } from './lib/resolve-eql.js'
/**
* Options accepted by `stash encrypt drop`. Generates a migration file that
- * drops the now-unused plaintext column — for EQL v2 that is
- * `_plaintext` (the name cutover's rename left it with); for EQL v3
- * (no rename) it is the original `` itself. Does *not* apply the
+ * drops the now-unused original plaintext column for EQL v3. Does *not* apply the
* migration — the user runs their usual migration tool (drizzle-kit,
* prisma, psql) to actually execute it.
*/
@@ -27,9 +27,7 @@ export interface DropCommandOptions {
table: string
/**
* Physical column — the original plaintext name. What gets dropped
- * depends on the column's EQL version: v2 drops `_plaintext`
- * (post-cutover leftover); v3 drops `` itself, gated on a live
- * ciphertext-coverage check.
+ * is `` itself, gated on a live ciphertext-coverage check.
*/
column: string
/**
@@ -41,9 +39,8 @@ export interface DropCommandOptions {
}
/**
- * CLI handler for `stash encrypt drop`. Version-aware preconditions:
- * EQL v2 requires phase `cut-over`; EQL v3 (which has no cut-over) requires
- * `backfilled` plus a live coverage check — and the generated v3 migration
+ * CLI handler for `stash encrypt drop`. EQL v3 requires `backfilled` plus a
+ * live coverage check — and the generated migration
* re-verifies coverage at APPLY time, since rows can be written between
* generation and application.
*
@@ -67,14 +64,9 @@ export async function dropCommand(options: DropCommandOptions) {
try {
await client.connect()
- // The plaintext column's name depends on the EQL version's lifecycle:
- // v2's cut-over renames `` → `_plaintext` (so that's what we
- // drop, after `cut-over`); v3 has no rename — the app switched to the
- // encrypted column by name, so the original `` IS the plaintext
- // column, droppable straight after `backfilled`. The version and the
- // encrypted column's name are resolved from the DOMAIN TYPES (manifest
- // name as a hint) — the `_encrypted` naming is a convention only.
- const { info, candidates } = await resolveColumnLifecycle(
+ // EQL v3 has no rename: the app switches to the encrypted column by name,
+ // so the original `` remains the plaintext column to drop.
+ const { info, candidates, unresolvedHint } = await resolveColumnLifecycle(
client,
options.table,
options.column,
@@ -82,84 +74,89 @@ export async function dropCommand(options: DropCommandOptions) {
// Fail closed on ambiguity: with EQL columns present but no identifiable
// counterpart, guessing a lifecycle here could validate coverage against
// the wrong ciphertext and generate an irreversible drop of the wrong
- // data. (The post-cutover v2 state — `` itself carries the v2
- // domain, counterpart legitimately unresolvable — falls through to the
- // v2 path; live truth from the DB wins over the manifest's cached
- // version throughout.)
+ // data.
const unresolved = explainUnresolved(
options.table,
options.column,
candidates,
+ unresolvedHint,
)
if (!info && unresolved) {
p.log.error(unresolved)
exitCode = 1
return
}
+ if (!info) {
+ p.log.error(
+ `Cannot identify an EQL v3 encrypted column for ${options.table}.${options.column}. Legacy EQL v2 drop/cut-over automation has been removed; migrate the schema to EQL v3 before continuing.`,
+ )
+ exitCode = 1
+ return
+ }
// A `via: 'sole'` match only proves the column is the table's ONE EQL
// column — it may encrypt a DIFFERENT field, in which case the coverage
// gate below would count the wrong ciphertext and wave through a drop
// that destroys the only copy of this data. Dropping is the single
// irreversible step in the lifecycle, so it demands a positively
// asserted pairing (manifest hint or the naming convention).
+ //
+ // The remedy must NOT name `info.column`. That is the guess itself, and
+ // recording it turns the next resolution into `via: 'hint'`, which walks
+ // straight past this gate — while the coverage check below passes
+ // vacuously, because a legitimately-backfilled unrelated column is
+ // non-NULL on every row. Following the old message verbatim generated a
+ // live `DROP COLUMN` on the plaintext at exit 0 (#772 review, finding 7).
if (info?.via === 'sole') {
p.log.error(
- `${options.table}.${info.column} (${info.domain}) is the table's only encrypted column, but nothing confirms it encrypts "${options.column}" — refusing to generate an irreversible drop on that guess. Record the pairing and retry: re-run \`stash encrypt backfill --table ${options.table} --column ${options.column} --encrypted-column ${info.column}\` (which writes it to the manifest), or set "encryptedColumn": "${info.column}" for this column in .cipherstash/migrations.json.`,
+ `${options.table}.${info.column} (${info.domain}) is the only EQL column left on ${options.table} once "${options.column}" itself is excluded, but nothing confirms it encrypts "${options.column}" — refusing to generate an irreversible drop on that guess. Identify the EQL v3 column that actually encrypts "${options.column}" and record that pairing: re-run \`stash encrypt backfill --table ${options.table} --column ${options.column} --encrypted-column \` (which writes it to the manifest), or set "encryptedColumn" for this column in .cipherstash/migrations.json. Legacy EQL v2 drop/cut-over automation has been removed — do not record ${info.column} as a workaround.`,
)
exitCode = 1
return
}
- const isV3 = info?.version === 3
- const encryptedColumn = info?.column ?? `${options.column}_encrypted`
- const requiredPhase = isV3 ? 'backfilled' : 'cut-over'
- const plaintextToDrop = isV3
- ? options.column
- : `${options.column}_plaintext`
+ const encryptedColumn = info.column
+ const requiredPhase = 'backfilled'
+ const plaintextToDrop = options.column
const state = await progress(client, options.table, options.column)
if (state?.phase !== requiredPhase) {
p.log.error(
- `Cannot generate drop migration: ${options.table}.${options.column} is in phase '${state?.phase ?? '—'}'. Must be '${requiredPhase}'${isV3 ? ' (EQL v3 has no cut-over — backfill, switch the app to the encrypted column, then drop)' : ''}.`,
+ `Cannot generate drop migration: ${options.table}.${options.column} is in phase '${state?.phase ?? '—'}'. Must be '${requiredPhase}' (EQL v3 has no cut-over — backfill, switch the app to the encrypted column, then drop).`,
)
exitCode = 1
return
}
- if (isV3) {
- // The phase gate above proves a backfill FINISHED at some point; it
- // says nothing about rows written since (a bulk import or a service
- // that isn't dual-writing leaves plaintext-only rows). Dropping the
- // original column is the one irreversible step in the v3 ladder, so
- // verify live coverage before generating the migration.
- const unencrypted = await countUnencrypted(
- client,
- options.table,
- options.column,
- encryptedColumn,
- )
- if (unencrypted > 0) {
- p.log.error(
- `Refusing to generate the drop migration: ${unencrypted} row(s) in ${options.table} have "${options.column}" set but "${encryptedColumn}" NULL — dropping "${options.column}" would permanently destroy that data. Likely rows written without dual-writes since the backfill. Re-run:\n stash encrypt backfill --table ${options.table} --column ${options.column}\nthen generate the drop again.`,
- )
- exitCode = 1
- return
- }
- p.log.success(
- `Verified: no rows with "${options.column}" set and "${encryptedColumn}" NULL.`,
- )
- p.log.info(
- `${options.table}.${encryptedColumn} is EQL v3 (${info?.domain}) — the drop targets the original plaintext column "${options.column}" (v3 has no rename, so there is no "${options.column}_plaintext"). Make sure your application reads/writes ${encryptedColumn} before applying this migration.`,
+ // The phase gate above proves a backfill FINISHED at some point; it
+ // says nothing about rows written since (a bulk import or a service
+ // that isn't dual-writing leaves plaintext-only rows). Dropping the
+ // original column is the one irreversible step in the v3 ladder, so
+ // verify live coverage before generating the migration.
+ const unencrypted = await countUnencrypted(
+ client,
+ options.table,
+ options.column,
+ encryptedColumn,
+ )
+ if (unencrypted > 0) {
+ p.log.error(
+ `Refusing to generate the drop migration: ${unencrypted} row(s) in ${options.table} have "${options.column}" set but "${encryptedColumn}" NULL — dropping "${options.column}" would permanently destroy that data. Likely rows written without dual-writes since the backfill. Re-run:\n stash encrypt backfill --table ${options.table} --column ${options.column}\nthen generate the drop again.`,
)
+ exitCode = 1
+ return
}
+ p.log.success(
+ `Verified: no rows with "${options.column}" set and "${encryptedColumn}" NULL.`,
+ )
+ p.log.info(
+ `${options.table}.${encryptedColumn} is EQL v3 (${info.domain}) — the drop targets the original plaintext column "${options.column}" (v3 has no rename, so there is no "${options.column}_plaintext"). Make sure your application reads/writes ${encryptedColumn} before applying this migration.`,
+ )
- const dot = options.table.indexOf('.')
- const qualifiedTable =
- dot >= 0
- ? `"${options.table.slice(0, dot)}"."${options.table.slice(dot + 1)}"`
- : `"${options.table}"`
- const dropSql = isV3
- ? buildV3DropSql(qualifiedTable, options.column, encryptedColumn)
- : `-- Generated by stash encrypt drop\n-- Drops the plaintext column now that ${options.table}.${options.column} is encrypted.\n\nALTER TABLE ${qualifiedTable} DROP COLUMN "${plaintextToDrop}";\n`
+ const dropSql = buildV3DropSql(
+ options.table,
+ options.column,
+ encryptedColumn,
+ )
+ const migrationStem = buildMigrationStem(options.table, plaintextToDrop)
const cwd = process.cwd()
const migrationsDir = options.migrationsDir ?? 'drizzle'
@@ -172,7 +169,7 @@ export async function dropCommand(options: DropCommandOptions) {
// Without this, the file ships but `drizzle-kit migrate` never picks it
// up because the journal doesn't reference it.
const result = await scaffoldDrizzleMigration({
- name: `drop_${options.table}_${plaintextToDrop}`,
+ name: migrationStem,
outDir: migrationsDir,
sql: dropSql,
})
@@ -187,7 +184,7 @@ export async function dropCommand(options: DropCommandOptions) {
.toISOString()
.replace(/[-:.TZ]/g, '')
.slice(0, 14)
- const fileName = `${ts}_drop_${options.table}_${plaintextToDrop}.sql`
+ const fileName = `${ts}_${migrationStem}.sql`
filePath = path.join(dirAbs, fileName)
fs.writeFileSync(filePath, dropSql, 'utf-8')
nextStep = `Review the migration, then apply with your migration tool:\n - prisma migrate deploy\n - psql -f ${fileName}`
@@ -237,17 +234,20 @@ export async function dropCommand(options: DropCommandOptions) {
* DO block so check-and-drop stay atomic even under migration runners that
* don't wrap files in a transaction (plain `psql -f`).
*
- * Identifiers arrive pre-validated (they resolved against the live catalog
- * above); embedded string literals escape single quotes defensively.
+ * Identifiers resolved against the live catalog above, but still require SQL
+ * quoting because valid PostgreSQL identifiers may contain quotes or dots.
*/
function buildV3DropSql(
- qualifiedTable: string,
+ table: string,
plaintextColumn: string,
encryptedColumn: string,
): string {
const lit = (s: string) => s.replace(/'/g, "''")
+ const qualifiedTable = qualifyTable(table)
+ const quotedPlaintext = quoteIdent(plaintextColumn)
+ const quotedEncrypted = quoteIdent(encryptedColumn)
return `-- Generated by stash encrypt drop
--- Drops the plaintext column now that ${qualifiedTable}."${encryptedColumn}" is encrypted.
+-- Drops the plaintext column now that ${qualifiedTable}.${quotedEncrypted} is encrypted.
-- Coverage is re-verified here, at apply time: the check stash ran at
-- generation time cannot see rows written after it.
@@ -261,16 +261,27 @@ BEGIN
SELECT count(*) INTO unencrypted
FROM ${qualifiedTable}
- WHERE "${plaintextColumn}" IS NOT NULL
- AND "${encryptedColumn}" IS NULL;
+ WHERE ${quotedPlaintext} IS NOT NULL
+ AND ${quotedEncrypted} IS NULL;
IF unencrypted > 0 THEN
RAISE EXCEPTION 'stash encrypt drop: refusing to drop %.% — % row(s) have plaintext set but % NULL. Dropping now would permanently destroy that data. Re-run: stash encrypt backfill, then regenerate this migration.',
'${lit(qualifiedTable)}', '${lit(plaintextColumn)}', unencrypted, '${lit(encryptedColumn)}';
END IF;
- EXECUTE 'ALTER TABLE ${lit(qualifiedTable)} DROP COLUMN "${lit(plaintextColumn)}"';
+ EXECUTE 'ALTER TABLE ${lit(qualifiedTable)} DROP COLUMN ${lit(quotedPlaintext)}';
END
$stash_drop$;
`
}
+
+/** Filesystem- and drizzle-kit-safe name derived from untrusted identifiers. */
+function buildMigrationStem(...identifiers: string[]): string {
+ const sanitized = identifiers
+ .map((identifier) =>
+ identifier.replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, ''),
+ )
+ .filter(Boolean)
+ .join('_')
+ return `drop_${sanitized || 'column'}`
+}
diff --git a/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts b/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts
new file mode 100644
index 000000000..154c5e563
--- /dev/null
+++ b/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts
@@ -0,0 +1,276 @@
+/**
+ * Pins {@link explainUnresolved}'s fail-closed contract now that the domain
+ * classifier recognises `eql_v3_*` only.
+ *
+ * `listEncryptedColumns` can no longer emit `version: 2` — a legacy
+ * `eql_v2_encrypted` column is not classified as an EQL column at all, so it
+ * never reaches this function as a candidate. Every pure-v2 table therefore
+ * arrives here as an EMPTY candidate list, both pre-cutover (`` /
+ * `_encrypted`) and post-cutover (the ciphertext renamed onto the
+ * plaintext column's own name), and the first guard falls through on it.
+ *
+ * These tests exist so removing the now-unreachable `version === 2` branch is
+ * provably behaviour-preserving, and so a future v2 sweep cannot delete the
+ * empty-list guard the v2 lifecycle actually depends on. That guard has to hold
+ * even when the manifest recorded an `encryptedColumn` — `backfill` records one
+ * for v2 columns too — which is the `candidates.length > 0` gate on the
+ * fail-closed path (#787 review).
+ */
+
+import type { EncryptedColumnInfo } from '@cipherstash/migrate'
+import type pg from 'pg'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Only the two I/O boundaries are replaced. `pickEncryptedColumn` stays real —
+// it IS the resolution rule under test, and stubbing it (as `encrypt-v3.test.ts`
+// stubs `resolveColumnLifecycle`) is why the hint-discard below had no coverage.
+const readManifest = vi.hoisted(() =>
+ vi.fn(async () => null as { tables: Record } | null),
+)
+const listEncryptedColumns = vi.hoisted(() =>
+ vi.fn(async () => [] as EncryptedColumnInfo[]),
+)
+vi.mock('@cipherstash/migrate', async (importOriginal) => ({
+ ...(await importOriginal()),
+ readManifest,
+ listEncryptedColumns,
+}))
+
+const { explainUnresolved, resolveColumnLifecycle } = await import(
+ '../resolve-eql.js'
+)
+
+/**
+ * A `pg` double answering only the `pg_attribute` existence probe, from a set
+ * of column names the table is pretended to have. That probe is the whole
+ * point: it is what separates "the hint is stale" from "the hint names a real
+ * column that simply is not EQL v3".
+ */
+const clientWithColumns = (...columns: string[]): pg.ClientBase =>
+ ({
+ // `columnExists` binds [table, schema, column] — it splits schema-qualified
+ // names and quotes with `format('%I')` so the lookup is case-EXACT. This
+ // double matches case-exactly for the same reason: a case-insensitive
+ // fixture would keep passing if the probe regressed to a bare
+ // `to_regclass($1)`, which case-folds (#787 review).
+ query: async (_sql: string, params: unknown[]) => ({
+ rows: [{ exists: columns.includes(String(params[2])) }],
+ }),
+ }) as unknown as pg.ClientBase
+
+const v3 = (
+ column: string,
+ domain = 'eql_v3_text_eq',
+): EncryptedColumnInfo => ({
+ column,
+ domain,
+ version: 3,
+})
+
+describe('explainUnresolved', () => {
+ it('falls through (null) when the table has no EQL columns at all', () => {
+ // Both the not-yet-backfilled case and the post-cutover v2 same-name case
+ // land here: the caller's own preconditions produce the accurate error.
+ expect(explainUnresolved('users', 'email', [])).toBeNull()
+ })
+
+ it('still falls through when no EQL v3 columns exist BUT a hint was recorded', () => {
+ // The pure-v2 table. `encrypt backfill` records `encryptedColumn` for v2
+ // columns too, so a hint is present on every table backfilled with this
+ // release — it must not flip the empty-candidate fall-through into a
+ // refusal, because `cutover` / `drop` in this same build still implement
+ // the v2 ladder this falls through to (#787 review).
+ expect(explainUnresolved('users', 'ssn', [], 'ssn_encrypted')).toBeNull()
+ })
+
+ it('fails closed, naming every candidate, when none is identifiable', () => {
+ const message = explainUnresolved('users', 'email', [
+ v3('a_enc'),
+ v3('b_enc', 'eql_v3_text_search'),
+ ])
+
+ expect(message).toContain('Cannot identify which encrypted column')
+ expect(message).toContain('a_enc (eql_v3_text_eq)')
+ expect(message).toContain('b_enc (eql_v3_text_search)')
+ expect(message).toContain('--encrypted-column')
+ })
+
+ it('gives no free pass to a candidate sharing the plaintext column name', () => {
+ // The removed branch exempted a SAME-NAME candidate, but only at
+ // `version === 2`. A v3 domain on the plaintext column's own name is not
+ // the post-cutover state (v3 has no cut-over rename), so it must still
+ // fail closed rather than let a destructive command guess a lifecycle.
+ const message = explainUnresolved('users', 'email', [
+ v3('email'),
+ v3('email_enc'),
+ ])
+
+ expect(message).toContain('Cannot identify which encrypted column')
+ })
+})
+
+/**
+ * #772 review, finding 7 — the mixed-table dead end.
+ *
+ * `classifyEqlDomain` recognises `eql_v3_*` only, so on a table holding a v2
+ * pair (`ssn` / `ssn_encrypted`) plus one unrelated v3 column, the v2 ciphertext
+ * column is not a candidate at all. `pickEncryptedColumn`'s sole-EQL-column rule
+ * then claims the unrelated v3 column for `ssn`.
+ *
+ * `encrypt backfill` records the true pairing in the manifest, so the answer is
+ * on disk — but the hint was thrown away whenever it failed to resolve, and the
+ * re-pick without it reached the sole rule. Recording the pairing changed
+ * nothing, and `drop`'s remedy told the user to record the guess instead.
+ */
+describe('resolveColumnLifecycle — a recorded hint that is not a v3 candidate', () => {
+ const V3_OTHER: EncryptedColumnInfo = {
+ column: 'email_enc',
+ domain: 'eql_v3_text_search',
+ version: 3,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ readManifest.mockResolvedValue(null)
+ listEncryptedColumns.mockResolvedValue([])
+ })
+
+ it('does not fall back to the sole-column guess when the manifest names a counterpart', async () => {
+ // The v2 pair is invisible to the classifier; only email_enc is a candidate.
+ listEncryptedColumns.mockResolvedValue([V3_OTHER])
+ readManifest.mockResolvedValue({
+ tables: { users: [{ column: 'ssn', encryptedColumn: 'ssn_encrypted' }] },
+ })
+
+ const { info, unresolvedHint } = await resolveColumnLifecycle(
+ clientWithColumns('ssn', 'ssn_encrypted', 'email_enc'),
+ 'users',
+ 'ssn',
+ )
+
+ expect(info).toBeNull()
+ expect(unresolvedHint).toBe('ssn_encrypted')
+ })
+
+ // The pure-v2 shape, and the regression the `candidates.length > 0` gate
+ // exists to prevent (#787 review). `backfill` records `encryptedColumn`
+ // unconditionally — v2 included — so EVERY pure-v2 table backfilled with this
+ // release carries a hint naming a real, existing, non-v3 column. Without the
+ // gate, `columnExists` returned true, `unresolvedHint` was set, and
+ // `cutover` / `drop` refused a lifecycle this same build still performs.
+ it('does not fail closed on a pure-v2 table, where no v3 column can be mis-claimed', async () => {
+ // No v3 columns at all: just the `ssn` / `ssn_encrypted` v2 pair, which the
+ // classifier does not see.
+ listEncryptedColumns.mockResolvedValue([])
+ readManifest.mockResolvedValue({
+ tables: { users: [{ column: 'ssn', encryptedColumn: 'ssn_encrypted' }] },
+ })
+
+ const { info, candidates, unresolvedHint } = await resolveColumnLifecycle(
+ clientWithColumns('ssn', 'ssn_encrypted'),
+ 'users',
+ 'ssn',
+ )
+
+ expect(info).toBeNull()
+ expect(candidates).toEqual([])
+ // The fall-through signal: no hint reported, so `explainUnresolved` returns
+ // null and the caller reaches its own v2 preconditions.
+ expect(unresolvedHint).toBeUndefined()
+ expect(
+ explainUnresolved('users', 'ssn', candidates, unresolvedHint),
+ ).toBeNull()
+ })
+
+ // #787 review. `columnExists` used to be a local copy using a bare
+ // `to_regclass($1)`, which PARSES and case-folds its argument — so on a
+ // Prisma-style `"User"` table the probe reported "missing", the hint was
+ // treated as stale, and the fail-closed above silently did not fire. It fell
+ // through to the sole/convention rules and resolved a guess, which is exactly
+ // what #772 finding 7 exists to prevent. Now delegated to
+ // `@cipherstash/migrate`'s shared, case-exact probe.
+ it('fires the fail-closed on a mixed-case table name too', async () => {
+ listEncryptedColumns.mockResolvedValue([V3_OTHER])
+ readManifest.mockResolvedValue({
+ tables: { User: [{ column: 'ssn', encryptedColumn: 'ssn_encrypted' }] },
+ })
+
+ const { info, unresolvedHint } = await resolveColumnLifecycle(
+ clientWithColumns('ssn', 'ssn_encrypted', 'email_enc'),
+ 'User',
+ 'ssn',
+ )
+
+ expect(info).toBeNull()
+ expect(unresolvedHint).toBe('ssn_encrypted')
+ })
+
+ it('explains the recorded counterpart by name rather than listing candidates', async () => {
+ const message = explainUnresolved(
+ 'users',
+ 'ssn',
+ [V3_OTHER],
+ 'ssn_encrypted',
+ )
+
+ expect(message).toContain('ssn_encrypted')
+ expect(message).toContain('not an EQL v3 column')
+ // Naming the guess here is what sent users to `--encrypted-column email_enc`.
+ expect(message).not.toContain('--encrypted-column email_enc')
+ })
+
+ // The fallback the comment actually describes — a hint naming a column that
+ // is simply gone — must still resolve through convention.
+ it('still falls back to convention when the hint is genuinely stale', async () => {
+ listEncryptedColumns.mockResolvedValue([
+ { column: 'ssn_encrypted', domain: 'eql_v3_text_eq', version: 3 },
+ ])
+ readManifest.mockResolvedValue({
+ tables: { users: [{ column: 'ssn', encryptedColumn: 'ssn_old' }] },
+ })
+
+ // `ssn_old` is gone from the table entirely — the genuinely stale case.
+ const { info } = await resolveColumnLifecycle(
+ clientWithColumns('ssn', 'ssn_encrypted'),
+ 'users',
+ 'ssn',
+ )
+
+ expect(info?.column).toBe('ssn_encrypted')
+ expect(info?.via).toBe('convention')
+ })
+
+ it('resolves through the hint when it does name a candidate', async () => {
+ listEncryptedColumns.mockResolvedValue([
+ { column: 'ssn_enc', domain: 'eql_v3_text_eq', version: 3 },
+ ])
+ readManifest.mockResolvedValue({
+ tables: { users: [{ column: 'ssn', encryptedColumn: 'ssn_enc' }] },
+ })
+
+ const { info, unresolvedHint } = await resolveColumnLifecycle(
+ clientWithColumns('ssn', 'ssn_enc'),
+ 'users',
+ 'ssn',
+ )
+
+ expect(info?.column).toBe('ssn_enc')
+ expect(info?.via).toBe('hint')
+ expect(unresolvedHint).toBeUndefined()
+ })
+
+ // No manifest entry at all is the ordinary case; the sole rule still applies
+ // and `drop`'s own `via === 'sole'` gate is what refuses it.
+ it('leaves the sole-column rule alone when nothing was recorded', async () => {
+ listEncryptedColumns.mockResolvedValue([V3_OTHER])
+
+ const { info, unresolvedHint } = await resolveColumnLifecycle(
+ clientWithColumns('ssn', 'email_enc'),
+ 'users',
+ 'ssn',
+ )
+
+ expect(info?.via).toBe('sole')
+ expect(unresolvedHint).toBeUndefined()
+ })
+})
diff --git a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts
index 9ce9afb6a..eac1f21f1 100644
--- a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts
+++ b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts
@@ -1,4 +1,5 @@
import {
+ columnExists,
type EncryptedColumnInfo,
listEncryptedColumns,
pickEncryptedColumn,
@@ -23,6 +24,23 @@ export interface ResolvedLifecycle {
* identifiable — callers name them instead of erroring blind.
*/
candidates: EncryptedColumnInfo[]
+ /**
+ * The manifest's recorded `encryptedColumn` when it named a real column that
+ * is NOT among `candidates`, AND `candidates` is non-empty — i.e. the pairing
+ * is on record, the column it names is not an EQL v3 column (typically legacy
+ * `eql_v2_encrypted`, which `classifyEqlDomain` no longer recognises), and
+ * there are v3 columns here that a guess could wrongly claim.
+ *
+ * Distinct from a stale hint. It means the answer IS known and disagrees
+ * with anything resolution could otherwise guess, so callers must fail closed
+ * naming it rather than fall through to `via: 'sole'` (#772 review, finding 7).
+ *
+ * Deliberately NOT set when `candidates` is empty. That is the pure-v2 table
+ * — a `` / `_encrypted` pair and nothing else — where there is no
+ * v3 column to mis-claim. The caller then emits its explicit fail-closed
+ * legacy-v2 diagnostic; no v2 mutation lifecycle remains.
+ */
+ unresolvedHint?: string
}
/**
@@ -59,35 +77,74 @@ export async function resolveColumnLifecycle(
)?.encryptedColumn
const candidates = await listEncryptedColumns(client, table)
- let info = hint ? pickEncryptedColumn(candidates, column, hint) : null
- // A stale hint (column since renamed/retyped) must not mask a resolvable
- // counterpart — fall back to convention + sole-EQL-column resolution.
- if (!info) info = pickEncryptedColumn(candidates, column)
- return { info, candidates }
+ const hinted = hint ? pickEncryptedColumn(candidates, column, hint) : null
+ if (hinted) return { info: hinted, candidates }
+
+ // The hint named a column that is not a candidate. Three very different
+ // reasons, and they must not share an outcome:
+ //
+ // - the column no longer exists (renamed, dropped) — a genuinely STALE hint,
+ // which must not mask a resolvable counterpart, so fall through;
+ // - the column exists, is not an EQL v3 column, and the table HAS v3 columns
+ // — the mixed table. The usual shape is a legacy `eql_v2_encrypted`
+ // counterpart, which `classifyEqlDomain` stopped recognising. Here the
+ // pairing IS known and falling through would discard it in favour of a
+ // guess: the sole-EQL-column rule claims an unrelated v3 column, `cutover`
+ // reports success for a rename it never performed, and `drop`'s remedy
+ // tells the user to record the guess (#772 review, finding 7). Fail closed;
+ // - the column exists, is not an EQL v3 column, and there are NO v3 columns
+ // on the table — the pure-v2 table. Nothing here can be mis-claimed, so
+ // fall through and let the caller emit its explicit v2-retirement error.
+ if (
+ hint &&
+ candidates.length > 0 &&
+ (await columnExists(client, table, hint))
+ ) {
+ return { info: null, candidates, unresolvedHint: hint }
+ }
+
+ return { info: pickEncryptedColumn(candidates, column), candidates }
}
/**
* Explain a failed resolution (`info === null`) to the user, or return
- * `null` when the failure is fine to fall through to the v2 lifecycle:
+ * `null` when no v3 candidate exists and the caller should emit its explicit
+ * fail-closed legacy-v2 diagnostic.
*
- * - No EQL columns at all → the v2 phase/config preconditions produce the
- * accurate error ("not backfilled", "no pending config", …).
- * - The plaintext column ITSELF carries the v2 domain → the normal
- * post-cutover v2 state (`` was renamed onto the ciphertext), where
- * "no counterpart" is expected, not a problem.
+ * The one fall-through case is "no EQL v3 columns at all". Since
+ * `classifyEqlDomain` recognises `eql_v3_*` only, that covers pure-v2 tables.
+ * Neither v2 column is a candidate, so the caller reaches its v2-retirement
+ * error regardless of what the manifest recorded; no mutation is attempted.
*
- * Anything else means EQL columns exist but none is identifiable — the
- * caller must fail closed with this message rather than guess a lifecycle.
+ * A non-empty candidate list therefore means EQL v3 columns exist but none is
+ * identifiable — the caller must fail closed with this message rather than
+ * guess a lifecycle, including when one candidate happens to share the
+ * plaintext column's name (v3 has no cut-over rename, so that is not the
+ * post-cutover state).
*/
export function explainUnresolved(
table: string,
column: string,
candidates: readonly EncryptedColumnInfo[],
+ unresolvedHint?: string,
): string | null {
+ // "No EQL v3 columns at all" always falls through, even with a recorded hint.
+ // That is the pure-v2 table; the caller's own fail-closed retirement branch
+ // produces the actionable error. Ordered ahead of the hint branch
+ // deliberately: `resolveColumnLifecycle` already declines to set
+ // `unresolvedHint` on an empty candidate list, and this keeps the two agreeing
+ // for direct callers of this function (#787 review).
if (candidates.length === 0) return null
- if (candidates.some((c) => c.column === column && c.version === 2)) {
- return null
+
+ // The recorded pairing points at a real column that is not an EQL v3 column,
+ // on a table that does have v3 columns — almost always a legacy
+ // `eql_v2_encrypted` counterpart alongside an unrelated v3 one. Say exactly
+ // that: listing the v3 candidates here would invite the user to record one of
+ // them, which is how the guess used to get laundered into a `via: 'hint'` match.
+ if (unresolvedHint !== undefined) {
+ return `${table}.${column} is recorded as pairing with "${unresolvedHint}", but ${unresolvedHint} is not an EQL v3 column — it is most likely a legacy eql_v2_encrypted column. ${table} also holds EQL v3 columns, and none of them is a confirmed counterpart for ${column}, so this command cannot tell which lifecycle applies and will not guess.\n\nIf that pairing is wrong, correct or remove "encryptedColumn" for ${column} in .cipherstash/migrations.json and re-run. If it is right, v2 mutation automation has been removed. For dump recovery, use the upstream EQL 2.3.1 SQL from https://github.com/cipherstash/encrypt-query-language/releases/tag/eql-2.3.1.`
}
+
const listed = candidates
.map((c) => ` - ${c.column} (${c.domain})`)
.join('\n')
diff --git a/packages/cli/src/commands/eql/__tests__/migration.test.ts b/packages/cli/src/commands/eql/__tests__/migration.test.ts
index ba3f8d7d1..c0193b665 100644
--- a/packages/cli/src/commands/eql/__tests__/migration.test.ts
+++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts
@@ -18,7 +18,16 @@ import { buildEqlV3MigrationSql, eqlMigrationCommand } from '../migration.js'
// reports through.
const clack = vi.hoisted(() => ({
spinnerInstance: { start: vi.fn(), stop: vi.fn() },
- log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() },
+ // `step` is on the real clack `log`; omitting it made the sweep's
+ // per-statement report throw and land in the command's catch block, so no
+ // test ever saw the report. Keep it here.
+ log: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ success: vi.fn(),
+ step: vi.fn(),
+ },
intro: vi.fn(),
note: vi.fn(),
outro: vi.fn(),
@@ -217,6 +226,14 @@ describe('eqlMigrationCommand — Drizzle', () => {
it('rewrites a sibling migration with a broken v3 ALTER COLUMN', async () => {
const out = join(tmp, 'drizzle')
mkdirSync(out, { recursive: true })
+ // The sweep is fail-closed: it rewrites a column only when the corpus
+ // positively declares it (and it isn't already encrypted). A real drizzle
+ // corpus carries this declaration in an earlier migration — supply it so
+ // the fixture matches what the sweep actually requires.
+ writeFileSync(
+ join(out, '0000_declare.sql'),
+ 'CREATE TABLE "users" ("email" text);\n',
+ )
const sibling = join(out, '0001_encrypt-email.sql')
writeFileSync(
sibling,
@@ -239,10 +256,17 @@ describe('eqlMigrationCommand — Drizzle', () => {
it('does not rewrite the EQL install migration it just generated', async () => {
const out = join(tmp, 'drizzle')
mkdirSync(out, { recursive: true })
- const generated = join(out, '0000_install-eql.sql')
+ // Fail-closed requires the corpus to positively declare the column before
+ // the sweep will touch it — supply the declaration a real drizzle corpus
+ // would carry, same as the sibling-rewrite test above.
+ writeFileSync(
+ join(out, '0000_declare.sql'),
+ 'CREATE TABLE "users" ("email" text);\n',
+ )
+ const generated = join(out, '0001_install-eql.sql')
// A sibling carrying the SAME statement — the differential that proves the
// sweep ran at all, rather than no-opping over the whole directory.
- const sibling = join(out, '0001_encrypt-email.sql')
+ const sibling = join(out, '0002_encrypt-email.sql')
const unsafeAlter =
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_text_search";\n'
writeFileSync(sibling, unsafeAlter)
@@ -298,6 +322,44 @@ describe('eqlMigrationCommand — Drizzle', () => {
)
})
+ // A column the corpus never declares is fail-closed to `source-unknown`: left
+ // on disk, and reported so the user goes and checks its type. This is the most
+ // common skip on a real (e.g. squashed) corpus, and it renders through the
+ // `p.log.step` path that a missing mock method used to make throw — so assert
+ // the guidance text actually reaches the user, not just that a warning fired.
+ it('reports source-unknown guidance for an undeclared column and warns at the close', async () => {
+ const out = join(tmp, 'drizzle')
+ mkdirSync(out, { recursive: true })
+ // No CREATE TABLE / ADD COLUMN anywhere declares "users"."email".
+ const sibling = join(out, '0001_encrypt-email.sql')
+ const unsafeAlter =
+ 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n'
+ writeFileSync(sibling, unsafeAlter)
+ spawnMock.mockImplementation(() => {
+ writeFileSync(join(out, '0002_install-eql.sql'), '')
+ return { status: 0, stdout: '', stderr: '' }
+ })
+
+ await eqlMigrationCommand({ drizzle: true, out })
+
+ // Left exactly as written — a source-unknown statement is never rewritten.
+ expect(readFileSync(sibling, 'utf-8')).toBe(unsafeAlter)
+ // The per-statement report reached the user with the source-unknown
+ // remediation (the whole point of the reason), not a crash into the catch.
+ const stepped = clack.log.step.mock.calls.map((c) => String(c[0]))
+ expect(stepped.some((msg) => msg.includes(sibling))).toBe(true)
+ expect(
+ stepped.some((msg) =>
+ msg.includes("Check the column's current type in the database"),
+ ),
+ ).toBe(true)
+ // And the closing note warns the sweep did not fully complete.
+ const warned = clack.log.warn.mock.calls.map((c) => String(c[0]))
+ expect(warned.some((msg) => msg.includes('did not fully complete'))).toBe(
+ true,
+ )
+ })
+
it('aborts (exit 1) when drizzle-kit exits non-zero', async () => {
spawnMock.mockReturnValue({ status: 1, stdout: '', stderr: 'boom' })
await expect(
diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts
index 8923f7478..e68cb0b44 100644
--- a/packages/cli/src/commands/eql/migration.ts
+++ b/packages/cli/src/commands/eql/migration.ts
@@ -1,16 +1,15 @@
import { spawnSync } from 'node:child_process'
-import { writeFileSync } from 'node:fs'
-import { resolve } from 'node:path'
+import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
+import { readdir } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
import { MIGRATIONS_SCHEMA_SQL } from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import { CliExit } from '@/cli/exit.js'
+import { printNextSteps, SAFE_MIGRATION_NAME } from '@/commands/db/install.js'
import {
- cleanupMigrationFile,
- findGeneratedMigration,
- printNextSteps,
- SAFE_MIGRATION_NAME,
-} from '@/commands/db/install.js'
-import { rewriteEncryptedAlterColumns } from '@/commands/db/rewrite-migrations.js'
+ describeSkipReason,
+ rewriteEncryptedAlterColumns,
+} from '@/commands/db/rewrite-migrations.js'
import {
detectPackageManager,
execArgv,
@@ -22,6 +21,40 @@ import { messages } from '@/messages.js'
const DEFAULT_MIGRATION_NAME = 'install-eql'
const DEFAULT_DRIZZLE_OUT = 'drizzle'
+/** Find the most recently generated Drizzle migration matching the name. */
+export async function findGeneratedMigration(
+ outDir: string,
+ migrationName: string,
+): Promise {
+ if (!existsSync(outDir)) {
+ throw new Error(
+ `Drizzle output directory not found: ${outDir}\nMake sure drizzle-kit is configured correctly.`,
+ )
+ }
+ const migrationSuffix = `_${migrationName}.sql`
+ const matchingFiles = (await readdir(outDir))
+ .filter((entry) => entry.endsWith(migrationSuffix))
+ .sort()
+ if (matchingFiles.length === 0) {
+ throw new Error(
+ `Could not find a migration matching "${migrationName}" in ${outDir}`,
+ )
+ }
+ return join(outDir, matchingFiles[matchingFiles.length - 1])
+}
+
+function cleanupMigrationFile(filePath: string | undefined): void {
+ if (!filePath) return
+ try {
+ if (existsSync(filePath)) {
+ unlinkSync(filePath)
+ p.log.info(`Cleaned up migration file: ${filePath}`)
+ }
+ } catch {
+ p.log.warn(`Could not clean up migration file: ${filePath}`)
+ }
+}
+
export interface EqlMigrationOptions {
/** Emit a Drizzle custom migration. */
drizzle?: boolean
@@ -49,9 +82,9 @@ export interface EqlMigrationOptions {
* Assemble the EQL **v3** install SQL for a generated migration.
*
* One source of truth: the SQL is the CLI's bundled v3 install script
- * (`loadBundledEqlSql({ eqlVersion: 3 })`) — the same bundle `stash eql install`
+ * (`loadBundledEqlSql()`) — the same bundle `stash eql install`
* applies directly. On `--supabase` the v3 role grants are appended
- * (`supabaseGrantsFor(3)` → USAGE/EXECUTE on `eql_v3` + `eql_v3_internal` for
+ * (`supabaseGrantsFor()` → USAGE/EXECUTE on `eql_v3` + `eql_v3_internal` for
* `anon`/`authenticated`/`service_role`), matching `stash eql install --supabase`.
* Apps that connect directly as `postgres` don't need the grants, but they're
* idempotent and harmless, and required when the same tables are reached via
@@ -63,9 +96,9 @@ export interface EqlMigrationOptions {
* install`.
*/
export function buildEqlV3MigrationSql(opts: { supabase: boolean }): string {
- const eqlSql = loadBundledEqlSql({ eqlVersion: 3 })
+ const eqlSql = loadBundledEqlSql()
const grants = opts.supabase
- ? `\n\n-- Supabase role grants: let anon/authenticated/service_role use the\n-- eql_v3 + eql_v3_internal schemas (required when tables are reached via\n-- PostgREST/RLS; harmless otherwise).\n${supabaseGrantsFor(3).trim()}`
+ ? `\n\n-- Supabase role grants: let anon/authenticated/service_role use the\n-- eql_v3 + eql_v3_internal schemas (required when tables are reached via\n-- PostgREST/RLS; harmless otherwise).\n${supabaseGrantsFor().trim()}`
: ''
return `${eqlSql.trim()}${grants}\n\n-- CipherStash encryption-migration tracking schema.\n-- Tracks per-column phase + backfill progress for \`stash encrypt\`.\n${MIGRATIONS_SCHEMA_SQL.trim()}\n`
}
@@ -245,10 +278,11 @@ async function generateDrizzleEqlMigration(
if (skipped.length > 0) {
sweepIncomplete = true
p.log.warn(
- `Found ${skipped.length} ALTER-to-encrypted statement(s) the sweep could not rewrite automatically. Review and fix them before running your migrations:`,
+ `Found ${skipped.length} ALTER-to-encrypted statement(s) the sweep left alone. Review and fix them before running your migrations:`,
)
- for (const { file, statement } of skipped) {
+ for (const { file, statement, reason } of skipped) {
p.log.step(` - ${file}: ${statement}`)
+ p.log.step(` ${describeSkipReason(reason)}`)
}
}
} catch (error) {
diff --git a/packages/cli/src/commands/impl/index.ts b/packages/cli/src/commands/impl/index.ts
index 044d82e6f..44fa65f28 100644
--- a/packages/cli/src/commands/impl/index.ts
+++ b/packages/cli/src/commands/impl/index.ts
@@ -40,7 +40,6 @@ function buildStateFromContext(
eqlInstalled: true,
agents,
mode: 'implement',
- usesProxy: ctx.usesProxy ?? false,
}
}
diff --git a/packages/cli/src/commands/init/__tests__/init-command.test.ts b/packages/cli/src/commands/init/__tests__/init-command.test.ts
index 44c5b3a65..cf9344eee 100644
--- a/packages/cli/src/commands/init/__tests__/init-command.test.ts
+++ b/packages/cli/src/commands/init/__tests__/init-command.test.ts
@@ -23,9 +23,6 @@ vi.mock('../steps/authenticate.js', () => ({
vi.mock('../steps/resolve-database.js', () => ({
resolveDatabaseStep: { id: 'resolve-database', ...passthrough },
}))
-vi.mock('../steps/resolve-proxy-choice.js', () => ({
- resolveProxyChoiceStep: { id: 'resolve-proxy-choice', ...passthrough },
-}))
vi.mock('../steps/build-schema.js', () => ({
buildSchemaStep: { id: 'build-schema', ...passthrough },
}))
diff --git a/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts b/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts
new file mode 100644
index 000000000..c3ddbf548
--- /dev/null
+++ b/packages/cli/src/commands/init/__tests__/placeholder-client-fixture.test.ts
@@ -0,0 +1,74 @@
+/**
+ * Binds `__fixtures__/scaffold/*.ts` to what `generatePlaceholderClient`
+ * actually emits.
+ *
+ * The fixtures are the only thing in the repo that COMPILES the file
+ * `stash init` writes into a user's project — `tsconfig.scaffold.json` and the
+ * CI step that runs it exist for that. But a typechecked fixture is worthless
+ * if the generator can drift away from it, and the existing codegen tests only
+ * `toContain`-match fragments while `build-schema.test.ts` stubs the generator
+ * out entirely. That combination is how `Encryption({ schemas: [] })` shipped:
+ * every test was green and no compiler ever saw the output (#772 review,
+ * finding 6).
+ *
+ * So: byte-for-byte, both directions. Change a template, regenerate the
+ * fixture; the compiler then has an opinion about it.
+ */
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { PLACEHOLDER_TABLE_NAME } from '@/config/index.js'
+import { generatePlaceholderClient } from '../utils.js'
+
+const FIXTURE_DIR = path.resolve(
+ fileURLToPath(import.meta.url),
+ '../../../../../__fixtures__/scaffold',
+)
+
+const fixture = (name: string) =>
+ readFileSync(path.join(FIXTURE_DIR, name), 'utf-8')
+
+describe('the scaffolded client fixtures match the generator', () => {
+ // Named `.generated.ts` so biome.json's existing exclusion leaves them alone:
+ // they are template OUTPUT, and reformatting them would break the byte-for-byte
+ // comparison below (which is the only thing tying the compiler to the generator).
+ it.each([
+ ['generic.generated.ts', 'postgresql'],
+ ['drizzle.generated.ts', 'drizzle'],
+ ] as const)('%s', (file, integration) => {
+ expect(fixture(file)).toBe(generatePlaceholderClient(integration))
+ })
+
+ // `supabase` shares the generic template; pin that so a future split does not
+ // silently leave the supabase path ungated.
+ it('supabase reuses the generic template', () => {
+ expect(generatePlaceholderClient('supabase')).toBe(
+ fixture('generic.generated.ts'),
+ )
+ })
+})
+
+describe('the scaffold compiles because it declares a table', () => {
+ it.each([
+ 'generic.generated.ts',
+ 'drizzle.generated.ts',
+ ])('%s passes a non-empty schema set', (file) => {
+ const body = fixture(file)
+ // The empty form is a hard TS2769 against both overloads — `Encryption`
+ // requires at least one table by design (S-6), so the scaffold cannot go
+ // back to `schemas: []` without breaking every project it is written into.
+ expect(body).not.toContain('Encryption({ schemas: [] })')
+ expect(body).toContain('schemas: [placeholderTable]')
+ })
+
+ it.each([
+ 'generic.generated.ts',
+ 'drizzle.generated.ts',
+ ])('%s uses the sentinel name the config loader refuses', (file) => {
+ // `loadEncryptConfig` exits 1 when this is the only table left, so the
+ // two must agree — otherwise the user gets a confusing "table not found"
+ // from whichever command runs next instead of "you never replaced this".
+ expect(fixture(file)).toContain(`'${PLACEHOLDER_TABLE_NAME}'`)
+ })
+})
diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts
new file mode 100644
index 000000000..d30246f14
--- /dev/null
+++ b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest'
+import type { SchemaDef } from '../types.js'
+import {
+ generateClientFromSchemas,
+ generatePlaceholderClient,
+} from '../utils.js'
+
+// `stash init --drizzle` writes these strings into the USER's repo as real
+// source. Nothing type-checks a template literal, so a rename in
+// `@cipherstash/stack-drizzle` cannot break the build here — it breaks the
+// scaffolded project instead, in someone else's checkout.
+//
+// `utils-codegen.test.ts` covers the generated client's happy path. This file
+// covers the surface that had no test at all — `generatePlaceholderClient`,
+// whose doc block is a copy-paste reference the handoff agent works from — and
+// asserts, for BOTH strings, that nothing removed with EQL v2 survives:
+// - the `@cipherstash/stack-drizzle/v3` specifier (`./v3` collapsed into `.`)
+// - `extractEncryptionSchemaV3` / `createEncryptionOperatorsV3`
+// - `encryptedType`, the v2 config-flag column factory
+
+const SCHEMAS: SchemaDef[] = [
+ {
+ tableName: 'users',
+ columns: [
+ { name: 'email', domain: 'TextSearch' },
+ { name: 'age', domain: 'IntegerOrd' },
+ ],
+ },
+]
+
+/** Every drizzle-flavoured string `stash init` can write into a user's repo. */
+const GENERATED_SOURCES: Array<[string, string]> = [
+ ['generateClientFromSchemas', generateClientFromSchemas('drizzle', SCHEMAS)],
+ ['generatePlaceholderClient', generatePlaceholderClient('drizzle')],
+]
+
+describe.each(
+ GENERATED_SOURCES,
+)('drizzle scaffold — %s', (_name, generated) => {
+ it('names the drizzle package root, never the removed ./v3 subpath', () => {
+ expect(generated).toContain('@cipherstash/stack-drizzle')
+ expect(generated).not.toContain('@cipherstash/stack-drizzle/v3')
+ })
+
+ it('uses the de-suffixed export names, never the removed *V3 aliases', () => {
+ expect(generated).not.toContain('extractEncryptionSchemaV3')
+ expect(generated).not.toContain('createEncryptionOperatorsV3')
+ })
+
+ it('never scaffolds the removed EQL v2 authoring surface', () => {
+ expect(generated).not.toContain('encryptedType')
+ expect(generated).not.toContain('eql_v2_encrypted')
+ })
+})
+
+describe('generatePlaceholderClient (drizzle)', () => {
+ const placeholder = generatePlaceholderClient('drizzle')
+
+ it('shows the harvest pattern with the collapsed root import', () => {
+ expect(placeholder).toContain(
+ "import { extractEncryptionSchema } from '@cipherstash/stack-drizzle'",
+ )
+ expect(placeholder).toContain('extractEncryptionSchema(users)')
+ })
+})
diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts
index 1221065d5..59067ee14 100644
--- a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts
+++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts
@@ -20,15 +20,27 @@ describe('generateClientFromSchemas', () => {
expect(out).toContain("age: types.IntegerOrd('age'),")
expect(out).toContain("verified: types.Boolean('verified'),")
expect(out).toContain("from '@cipherstash/stack/v3'")
- expect(out).toContain('EncryptionV3(')
+ // `Encryption` is the current name; `EncryptionV3` is a deprecated alias.
+ // Same reasoning as the drizzle negatives below — this is a template
+ // literal written into the user's repo as real source, so nothing but an
+ // assertion here catches a scaffold that teaches the deprecated name.
+ expect(out).toContain('Encryption(')
+ expect(out).not.toContain('EncryptionV3')
})
it('emits the chosen v3 domain factory per column (drizzle)', () => {
const out = generateClientFromSchemas('drizzle', schemas)
expect(out).toContain("email: types.TextSearch('email'),")
expect(out).toContain("age: types.IntegerOrd('age'),")
- expect(out).toContain('extractEncryptionSchemaV3')
- expect(out).toContain("from '@cipherstash/stack-drizzle/v3'")
+ // The collapsed root: `@cipherstash/stack-drizzle` dropped its EQL v2
+ // surface and folded `./v3` into `.`, de-suffixing the exports. The
+ // negatives matter as much as the positives — this string is written into
+ // the user's repo as real source, and nothing type-checks a template
+ // literal, so the removed names can only be caught here.
+ expect(out).toContain('extractEncryptionSchema(')
+ expect(out).toContain("from '@cipherstash/stack-drizzle'")
+ expect(out).not.toContain('extractEncryptionSchemaV3')
+ expect(out).not.toContain('@cipherstash/stack-drizzle/v3')
})
it('carries no residual v2 capability vocabulary', () => {
@@ -47,6 +59,7 @@ describe('generateClientFromSchemas', () => {
const out = generateClientFromSchemas('supabase', schemas)
expect(out).toContain("email: types.TextSearch('email'),")
expect(out).toContain("from '@cipherstash/stack/v3'")
+ expect(out).not.toContain('EncryptionV3')
expect(out).not.toContain('@cipherstash/stack-drizzle')
})
diff --git a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md
index 411114a0d..8d271e939 100644
--- a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md
+++ b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md
@@ -7,7 +7,7 @@ This document is the **durable rule book** for any agent working on this codebas
## What you are working with
- **Encryption client** — a per-project module that defines which tables and columns are encrypted, what data type each column holds, and which search operations are enabled. The path is in `.cipherstash/context.json` under `encryptionClientPath`.
-- **EQL extension** — a Postgres extension installed via `stash eql install` that provides server-side functions for searchable encryption (`eql_v2.*`). Required before any migration that creates encrypted columns.
+- **EQL extension** — installed via `stash eql install`, providing the column domains and server-side functions for searchable encryption. `stash eql install` installs **EQL v3** by default: the `public.eql_v3_*` column domains and the `eql_v3.*` functions behind them. (A project set up before v3 may instead carry the legacy `eql_v2` schema and `eql_v2_encrypted` column type; both generations decrypt, but new columns are v3.) Required before any migration that creates encrypted columns.
- **Project context** — `.cipherstash/context.json` records what `stash init` discovered: integration, package manager, env key names (never values), schemas, install command, CLI version. Treat it as authoritative.
- **Action plan** — `.cipherstash/setup-prompt.md` is the project-specific to-do list for the current setup run. Read it first.
@@ -18,7 +18,7 @@ This document is the **durable rule book** for any agent working on this codebas
3. **Never read or echo secrets.** Env key *names* (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`, `DATABASE_URL`) are fine to reference in code and docs. Their *values* are not. New env keys go in `.env.example` with placeholders; instruct the user to add the real value locally. Never read, `cat`, `grep`, or echo `~/.cipherstash/secretkey.json` (the development key), `~/.cipherstash/auth.json` (OAuth token and JWTs), anything under `~/.cipherstash/workspaces/`, or a value-bearing env file (`.env`, `.env.local`, `.env.production`, …). `.env.example` is the exception — it holds placeholders, not values, and you are expected to edit it. The CLI reads the credentials itself; no command needs you to open them. If a command fails on authentication, re-run `stash auth login` rather than inspecting the profile.
4. **Never invent CipherStash APIs.** If you don't know how a function is called, read the relevant skill (see below) — don't guess. The TypeScript types in `@cipherstash/stack` are the source of truth for what's callable.
5. **Never run database introspection yourself.** Don't run `psql`, `\d`, `pg_dump`, `supabase db dump`, or `drizzle-kit introspect`. The CLI already did this; the result is in `context.json`. If you need fresh introspection, ask the user to re-run `stash init`.
-6. **Never modify these files.** `stash.config.ts` (generated by init — edits go in `.env`). `.cipherstash/` (CLI-owned). `~/.cipherstash/` (CLI-owned credentials — see invariant 3). The `eql_v2` schema and `eql_v2_*` functions (CLI-managed; missing function ⇒ `stash eql upgrade`, not a hand-edit).
+6. **Never modify these files.** `stash.config.ts` (generated by init — edits go in `.env`). `.cipherstash/` (CLI-owned). `~/.cipherstash/` (CLI-owned credentials — see invariant 3). The EQL schemas and functions installed by the CLI — `eql_v3`/`public.eql_v3_*` today, `eql_v2`/`eql_v2_*` on a legacy install. `stash eql upgrade` upgrades v3 only; legacy v2 state is diagnostic/read-only in current tooling.
7. **`@cipherstash/stack` must be excluded from any bundler.** The package wraps a native FFI module (`@cipherstash/protect-ffi`) that cannot be bundled. The moment you `npm install @cipherstash/stack` in a project with a bundler, configure the exclusion *before* writing any code that imports it. Concretely: Next.js needs `serverExternalPackages: ['@cipherstash/stack', '@cipherstash/protect-ffi']` in `next.config.{js,ts,mjs}`; webpack needs `externals` entries; esbuild needs `external`; Vite SSR needs `ssr.external`. Skipping this surfaces as `Cannot find module '@cipherstash/protect-ffi-*'` at runtime, often after the user has shipped to production. If you're declaring an encrypted column for the first time in a project, treat configuring this exclusion as part of the same change.
## Migrations — three phases, always reversible
diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts
index 4fec87bfc..7a8288458 100644
--- a/packages/cli/src/commands/init/index.ts
+++ b/packages/cli/src/commands/init/index.ts
@@ -14,7 +14,6 @@ import { gatherContextStep } from './steps/gather-context.js'
import { installDepsStep } from './steps/install-deps.js'
import { installEqlStep } from './steps/install-eql.js'
import { resolveDatabaseStep } from './steps/resolve-database.js'
-import { resolveProxyChoiceStep } from './steps/resolve-proxy-choice.js'
import type { InitProvider, InitState } from './types.js'
import { CancelledError } from './types.js'
import { detectPackageManager, runnerCommand } from './utils.js'
@@ -39,7 +38,6 @@ const PROVIDER_MAP: Record InitProvider> = {
const STEPS = [
authenticateStep,
resolveDatabaseStep,
- resolveProxyChoiceStep,
buildSchemaStep,
installDepsStep,
installEqlStep,
@@ -73,6 +71,16 @@ export async function initCommand(
flags: Record,
values: Record = {},
) {
+ const retiredProxyFlag = ['proxy', 'no-proxy'].find(
+ (name) => flags[name] === true || Object.hasOwn(values, name),
+ )
+ if (retiredProxyFlag) {
+ p.log.error(
+ `\`--${retiredProxyFlag}\` has been removed. EQL v3 stores query configuration in column domains and does not use CipherStash Proxy; remove this flag and select only the project integration (for example, \`--supabase\` or \`--drizzle\`).`,
+ )
+ throw new CliExit(1)
+ }
+
const provider = resolveProvider(flags)
p.intro('CipherStash Stack Setup')
@@ -87,13 +95,6 @@ export async function initCommand(
state.regionFlag = values.region
}
- // Parse --proxy and --no-proxy flags; --proxy wins if both are set
- if (flags.proxy) {
- state.usesProxy = true
- } else if (flags['no-proxy']) {
- state.usesProxy = false
- }
-
try {
for (const step of STEPS) {
state = await step.run(state, provider)
diff --git a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts
index e4a2e3aa1..5da026907 100644
--- a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts
+++ b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts
@@ -39,7 +39,23 @@ const ALL_INTEGRATIONS: Integration[] = [
'postgresql',
]
+// Parity with the wizard's own SKILL_MAP is asserted in
+// `e2e/tests/skill-map-parity.e2e.test.ts` — the only workspace that declares
+// both `stash` and `@cipherstash/wizard`, and whose `typecheck` script compiles
+// the cross-package import.
describe('SKILL_MAP', () => {
+ it('selects only skills that contain a bundled SKILL.md', () => {
+ for (const integration of ALL_INTEGRATIONS) {
+ const available = new Set(availableSkills(integration))
+ const skills = SKILL_MAP[integration]
+ for (const skill of skills) {
+ expect(available.has(skill), `${integration}: ${skill}/SKILL.md`).toBe(
+ true,
+ )
+ }
+ }
+ })
+
it('has a non-empty entry for every integration (no undefined → crash)', () => {
for (const integration of ALL_INTEGRATIONS) {
const skills = SKILL_MAP[integration]
@@ -82,6 +98,28 @@ describe('SKILL_MAP', () => {
expect(SKILL_MAP.postgresql).not.toContain('stash-supabase')
expect(SKILL_MAP.postgresql).not.toContain('stash-prisma-next')
})
+
+ // #754: the no-ORM path had no source for the raw-SQL binding surface or
+ // the WASM entry — an integration on it had to reverse-engineer both from
+ // `dist/*.d.ts` and the Postgres catalog. `postgresql` is that path;
+ // Supabase shares it (Edge Functions are the flagship WASM-entry use, and
+ // its migrations/RPC are hand-written SQL).
+ it.each([
+ 'postgresql',
+ 'supabase',
+ ] as const)('%s includes the raw-SQL and edge skills', (integration) => {
+ expect(SKILL_MAP[integration]).toContain('stash-postgres')
+ expect(SKILL_MAP[integration]).toContain('stash-edge')
+ })
+
+ // The ORM integrations emit correctly-typed operands themselves, so they
+ // get cross-links from their own skills rather than the full install.
+ it.each([
+ 'drizzle',
+ 'prisma-next',
+ ] as const)('%s does not install the raw-SQL skill', (integration) => {
+ expect(SKILL_MAP[integration]).not.toContain('stash-postgres')
+ })
})
describe('skillsFor', () => {
diff --git a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts
index 40a011e95..1efca50e3 100644
--- a/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts
+++ b/packages/cli/src/commands/init/lib/__tests__/introspect.test.ts
@@ -5,6 +5,7 @@ import type { DataType } from '../../types.js'
import {
candidateDomains,
defaultDomain,
+ isEqlEncryptedDomain,
pgTypeToDataType,
selectTableColumns,
} from '../introspect.js'
@@ -103,6 +104,44 @@ describe('defaultDomain', () => {
})
})
+// The marker used to be `udt_name === 'eql_v2_encrypted'` alone. v3 is the
+// default generation and its columns carry `eql_v3_*` domains, so on the
+// default path an already-encrypted column was reported as plaintext: shown
+// with its `dataType` hint and left unticked. `packages/wizard` already made
+// this call, and its comment names the consequence — misreporting encrypted
+// columns as plaintext lets an agent clobber real ciphertext.
+describe('isEqlEncryptedDomain', () => {
+ it('recognises every eql_v3_* domain', () => {
+ for (const udt of [
+ 'eql_v3_encrypted',
+ 'eql_v3_text_search',
+ 'eql_v3_integer_ord',
+ 'eql_v3_date_ord',
+ 'eql_v3_json',
+ ]) {
+ expect(isEqlEncryptedDomain(udt)).toBe(true)
+ }
+ })
+
+ it('still recognises the legacy v2 udt', () => {
+ expect(isEqlEncryptedDomain('eql_v2_encrypted')).toBe(true)
+ })
+
+ it('does not claim plaintext types', () => {
+ for (const udt of ['text', 'int4', 'jsonb', 'bool', 'timestamptz']) {
+ expect(isEqlEncryptedDomain(udt)).toBe(false)
+ }
+ })
+
+ // The trailing underscore is load-bearing: a bare `startsWith('eql_v3')`
+ // would also claim a hypothetical future `eql_v30_*` generation. Same
+ // reasoning as `classifyEqlDomain` in `@cipherstash/migrate`.
+ it('does not claim a future generation by prefix', () => {
+ expect(isEqlEncryptedDomain('eql_v30_text_search')).toBe(false)
+ expect(isEqlEncryptedDomain('eql_v3')).toBe(false)
+ })
+})
+
describe('selectTableColumns', () => {
beforeEach(() => {
selectMock.mockReset()
@@ -214,6 +253,38 @@ describe('selectTableColumns', () => {
expect(schema?.columns).toEqual([{ name: 'ssn', domain: 'TextSearch' }])
})
+ // The per-column hint was the literal string 'eql_v2_encrypted' for any
+ // encrypted column, so a v3 column was labelled with a domain it does not
+ // have. Report the column's actual udt.
+ it('hints an encrypted column with its real domain, not a hardcoded v2 udt', async () => {
+ const withV3 = [
+ {
+ tableName: 'accounts',
+ columns: [
+ {
+ columnName: 'ssn',
+ dataType: 'jsonb',
+ udtName: 'eql_v3_text_search',
+ isEqlEncrypted: true,
+ },
+ ],
+ },
+ ]
+ selectMock
+ .mockResolvedValueOnce('accounts')
+ .mockResolvedValueOnce('TextSearch')
+ multiselectMock.mockResolvedValueOnce(['ssn'])
+
+ await selectTableColumns(withV3)
+
+ const opts = multiselectMock.mock.calls[0][0].options
+ expect(opts).toEqual([
+ expect.objectContaining({ value: 'ssn', hint: 'eql_v3_text_search' }),
+ ])
+ // And it starts ticked, so a re-run does not offer to re-encrypt it.
+ expect(multiselectMock.mock.calls[0][0].initialValues).toEqual(['ssn'])
+ })
+
it('pre-selects the widest searchable domain as the per-column default', async () => {
// Asserts the ARGS to the domain prompt, not just its return: the picker
// must pass initialValue: defaultDomain(options) so the widest searchable
diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts
index b1b684910..bd9dbff9f 100644
--- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts
+++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts
@@ -24,6 +24,12 @@ const baseCtx: SetupPromptContext = {
},
}
+function hasCompletePlanSequence(prompt: string): boolean {
+ return /`pnpm dlx stash encrypt backfill`[\s\S]*application read switch.*encrypted column by name[\s\S]*`pnpm dlx stash encrypt drop`/i.test(
+ prompt,
+ )
+}
+
describe('renderSetupPrompt — orient + route (implement mode)', () => {
it('emits integration + package manager in the header', () => {
const out = renderSetupPrompt(baseCtx)
@@ -50,14 +56,13 @@ describe('renderSetupPrompt — orient + route (implement mode)', () => {
it('frames the migrate-existing flow as rollout + backfill-and-switch with a deploy gate', () => {
// The whole point of the rewrite. No "phase" jargon; explicit deploy
- // gate banner; named sections. The switch step is EQL-version-aware:
- // v3 (the default) has no rename — the app points at the encrypted
- // column by name; cutover is the v2 rename path.
+ // gate banner; named sections. The v3 switch has no rename — the app
+ // points at the encrypted column by name.
const out = renderSetupPrompt(baseCtx)
expect(out).toMatch(/encryption rollout/i)
expect(out).toMatch(/backfill and switch/i)
- expect(out).toMatch(/EQL v3 \(the default\)/)
- expect(out).toMatch(/encrypt cutover/)
+ expect(out).toMatch(/EQL v3/)
+ expect(out).not.toMatch(/encrypt cutover/)
expect(out).toMatch(/deploy gate/i)
expect(out).not.toMatch(/phase 1|phase 2|four-deploy/i)
})
@@ -93,7 +98,7 @@ describe('renderSetupPrompt — orient + route (implement mode)', () => {
it('names the lifecycle CLI commands inline in the migrate-existing flow', () => {
const out = renderSetupPrompt(baseCtx)
expect(out).toContain('pnpm dlx stash encrypt backfill')
- expect(out).toContain('pnpm dlx stash encrypt cutover')
+ expect(out).not.toContain('pnpm dlx stash encrypt cutover')
expect(out).toContain('pnpm dlx stash encrypt drop')
expect(out).toContain('--confirm-dual-writes-deployed')
expect(out).toContain('--force')
@@ -105,6 +110,118 @@ describe('renderSetupPrompt — orient + route (implement mode)', () => {
expect(out).toContain('pnpm exec drizzle-kit migrate')
})
+ // The "wire the column through" step names the query API by hand. It used to
+ // name `protectOps.eq`, which exists nowhere; naming `createEncryptionOperators`
+ // unconditionally is the same mistake one step smaller — that symbol is
+ // exported only by `@cipherstash/stack-drizzle`, which a Supabase or Prisma
+ // project does not even have in its dependency tree. Each integration gets
+ // the API it can actually import.
+ describe('query-operator guidance is per-integration', () => {
+ const render = (integration: SetupPromptContext['integration']) =>
+ renderSetupPrompt({ ...baseCtx, integration })
+
+ it('names the Drizzle operators for drizzle', () => {
+ const out = render('drizzle')
+ expect(out).toContain('createEncryptionOperators(client)')
+ expect(out).toContain('ops.eq')
+ })
+
+ it('names the wrapper builder for supabase, not the Drizzle operators', () => {
+ const out = render('supabase')
+ expect(out).not.toContain('createEncryptionOperators')
+ expect(out).toContain('encryptedSupabase')
+ })
+
+ it('names the eql* column operators for prisma-next', () => {
+ const out = render('prisma-next')
+ expect(out).not.toContain('createEncryptionOperators')
+ expect(out).toContain('eqlEq')
+ })
+
+ // `postgresql` is the default when nothing is detected, and it gets no
+ // integration skill at all (install-skills.ts) — so "see the integration
+ // skill" is a dangling pointer for it too.
+ it('names the core encryptQuery path for plain postgresql', () => {
+ const out = render('postgresql')
+ expect(out).not.toContain('createEncryptionOperators')
+ expect(out).toContain('encryptQuery')
+ expect(out).toContain('stash-encryption')
+ })
+
+ // `EncryptQueryOptions` takes the schema OBJECTS — `usersSchema` and
+ // `usersSchema.email` — not their names as strings, and `queryType` is
+ // inferred from the column's indexes when omitted. The object-shorthand
+ // `{ table, column, queryType }` reads as three required string fields:
+ // the same species of plausible-but-wrong API as the `protectOps.eq` this
+ // PR exists to have removed.
+ it('shows encryptQuery taking schema objects, not string names', () => {
+ const out = render('postgresql')
+ expect(out).toContain('column: usersSchema.email')
+ expect(out).not.toContain('{ table, column, queryType }')
+ })
+
+ // `packages/cli` builds with tsup, which transpiles without type-checking,
+ // and has no `typecheck` script — so `Record` buys nothing
+ // at build time here. An if-chain ending in a bare Drizzle `return` hands a
+ // future fifth integration the exact string this helper exists to stop it
+ // getting. Degrade to neutral guidance, as `skillsFor()` degrades to
+ // BASE_SKILLS.
+ it('degrades to neutral guidance for an unrecognised integration', () => {
+ const out = renderSetupPrompt({
+ ...baseCtx,
+ integration: 'mystery-orm' as SetupPromptContext['integration'],
+ })
+ expect(out).not.toContain('createEncryptionOperators')
+ expect(out).not.toContain('encryptedSupabase')
+ expect(out).toContain('encryptQuery')
+ })
+ })
+
+ // Step 2 named the Drizzle and Supabase schema APIs unconditionally — the
+ // identical defect step 5 above was just fixed for. A prisma-next project was
+ // sent at `types.*` / `encryptedTable`, which `stash schema build` explicitly
+ // refuses to scaffold for that integration; a plain-Postgres project was
+ // given no named path at all.
+ describe('schema-authoring guidance is per-integration', () => {
+ const render = (integration: SetupPromptContext['integration']) =>
+ renderSetupPrompt({ ...baseCtx, integration })
+
+ it('names the Drizzle column factories for drizzle', () => {
+ const out = render('drizzle')
+ expect(out).toContain('`types.*` column factories')
+ expect(out).toContain('@cipherstash/stack-drizzle')
+ })
+
+ it('names the eql_v3 domain for supabase', () => {
+ expect(render('supabase')).toContain('email public.eql_v3_text_search')
+ expect(render('supabase')).not.toContain('eql_v3_encrypted')
+ })
+
+ it('names the cipherstash.* field constructors for prisma-next', () => {
+ const out = render('prisma-next')
+ expect(out).toContain('cipherstash.TextSearch()')
+ expect(out).toContain('prisma/schema.prisma')
+ // The `types.*` client is precisely what `stash schema build` refuses to
+ // emit for prisma-next.
+ expect(out).not.toContain('`types.*` column factories')
+ })
+
+ it('names encryptedTable for plain postgresql', () => {
+ const out = render('postgresql')
+ expect(out).toContain('encryptedTable')
+ expect(out).toContain('@cipherstash/stack/v3')
+ })
+ })
+
+ // `postgresql` installs no integration-specific skill (SKILL_MAP), so every
+ // unconditional "see the integration skill" is a pointer at a file that was
+ // never written. Step 5's was fixed; two more survived elsewhere.
+ it('never points plain postgresql at "the integration skill"', () => {
+ const out = renderSetupPrompt({ ...baseCtx, integration: 'postgresql' })
+ expect(out).not.toMatch(/the integration skill/i)
+ expect(out).toContain('stash-encryption')
+ })
+
it('emits supabase migration commands for supabase integration', () => {
const out = renderSetupPrompt({
...baseCtx,
@@ -247,7 +364,7 @@ describe('renderSetupPrompt — plan mode (rollout, default)', () => {
const out = renderSetupPrompt(planCtx)
expect(out).toContain('## What you must NOT do')
expect(out).toMatch(/encrypt backfill/)
- expect(out).toMatch(/encrypt cutover/)
+ expect(out).not.toMatch(/encrypt cutover/)
expect(out).toMatch(/encrypt drop/)
})
@@ -428,7 +545,7 @@ describe('renderSetupPrompt — no db push recommendations', () => {
// `db push` / `eql_v2_configuration` is a v2 + CipherStash Proxy artifact and
// is redundant under EQL v3 (the default). The setup prompt no longer steers
// the agent toward it in any mode.
- it('omits db push from the add-new-column and cutover flows (implement mode)', () => {
+ it('omits db push and the removed cutover command (implement mode)', () => {
const out = renderSetupPrompt(baseCtx)
expect(out).not.toMatch(/db push/)
expect(out).not.toMatch(/Register the encryption config/)
@@ -437,9 +554,10 @@ describe('renderSetupPrompt — no db push recommendations', () => {
// The rollout path is schema-add → dual-write, with no push step between.
expect(out).toMatch(/1\.\s*\*\*Schema-add/)
expect(out).toMatch(/2\.\s*\*\*Dual-write/)
- // Cutover is still covered, just without a db push workaround note.
- const cutoverSection = out.substring(out.indexOf('#### Encryption cutover'))
- expect(cutoverSection).toMatch(/encrypt cutover/)
+ const heading = '#### Backfill and switch'
+ expect(out).toContain(heading)
+ const cutoverSection = out.substring(out.indexOf(heading))
+ expect(cutoverSection).not.toMatch(/encrypt cutover/)
})
it('omits db push from every plan-mode template', () => {
@@ -450,6 +568,56 @@ describe('renderSetupPrompt — no db push recommendations', () => {
})
})
+describe('renderSetupPrompt — plan templates are EQL v3-only', () => {
+ const plan = (planStep: 'cutover' | 'complete') =>
+ renderSetupPrompt({ ...baseCtx, mode: 'plan', planStep })
+
+ for (const planStep of ['cutover', 'complete'] as const) {
+ describe(`${planStep} plan`, () => {
+ it('states v3 has no rename and never schedules v2 mutation', () => {
+ const out = plan(planStep)
+ expect(out).toContain('EQL v3')
+ expect(out).toMatch(/no rename/i)
+ expect(out).not.toMatch(/encrypt cutover/)
+ })
+ })
+ }
+
+ // The stop-and-ask trigger for "already encrypted" named the v2 UDT alone.
+ // v3 is the default and its columns carry `eql_v3_*` domains, so on the
+ // default path the check silently never fired. The agent reads the schema
+ // itself, so this half is fixable here; `introspect.ts`'s `isEqlEncrypted`
+ // has the same gap and is a separate behavioural change.
+ it('recognises v3 domains in the already-encrypted stop-and-ask', () => {
+ for (const mode of ['implement', 'plan'] as const) {
+ const out = renderSetupPrompt({ ...baseCtx, mode })
+ expect(out).toMatch(/eql_v3_/)
+ }
+ })
+
+ it('does not emit the removed cutover invocation', () => {
+ expect(plan('cutover')).not.toMatch(/encrypt cutover/)
+ })
+
+ it('does not list cutover inside a brace-form or comma-separated encrypt command index', () => {
+ const out = renderSetupPrompt(baseCtx)
+ expect(out).not.toMatch(/encrypt\s*\{[^}]*\bcutover\b[^}]*\}/i)
+ expect(out).not.toMatch(/encrypt[^\n]*,\s*cutover\b/i)
+ })
+
+ it('gives the complete plan the supported backfill, read-switch, and drop sequence', () => {
+ const out = plan('complete')
+ expect(hasCompletePlanSequence(out)).toBe(true)
+ expect(out).not.toContain('`cutover`')
+ })
+
+ it('does not require a deployment in the no-deployed-application flow', () => {
+ const out = plan('complete')
+
+ expect(out).not.toMatch(/encrypted column by name and deploy/i)
+ })
+})
+
describe('renderSetupPrompt — honours what the handoff actually wrote', () => {
for (const mode of ['implement', 'plan'] as const) {
it(`claude-code with no skills points at neither a skills dir nor AGENTS.md (${mode})`, () => {
diff --git a/packages/cli/src/commands/init/lib/install-skills.ts b/packages/cli/src/commands/init/lib/install-skills.ts
index acd158180..8353ce1b1 100644
--- a/packages/cli/src/commands/init/lib/install-skills.ts
+++ b/packages/cli/src/commands/init/lib/install-skills.ts
@@ -12,10 +12,16 @@ import { findBundledDir } from './bundled-paths.js'
*/
export const SKILL_MAP: Record = {
drizzle: ['stash-encryption', 'stash-drizzle', 'stash-indexing', 'stash-cli'],
+ // Supabase gets the raw-SQL and edge skills on top of its own: Edge
+ // Functions are the flagship use of the WASM entry, and Supabase projects
+ // write hand-written SQL in migrations and RPC even when the app itself
+ // goes through PostgREST (#754).
supabase: [
'stash-encryption',
'stash-supabase',
'stash-indexing',
+ 'stash-postgres',
+ 'stash-edge',
'stash-cli',
],
'prisma-next': [
@@ -24,7 +30,16 @@ export const SKILL_MAP: Record = {
'stash-indexing',
'stash-cli',
],
- postgresql: ['stash-encryption', 'stash-indexing', 'stash-cli'],
+ // The no-ORM path: `stash-postgres` (binding + predicate forms) and `stash-edge`
+ // (WASM entry, CS_* credentials) are the two skills this integration has no
+ // other source for — everything else assumes an ORM emits the operands.
+ postgresql: [
+ 'stash-encryption',
+ 'stash-indexing',
+ 'stash-postgres',
+ 'stash-edge',
+ 'stash-cli',
+ ],
}
/** The skills every integration gets — the safe fallback for an unmapped one. */
diff --git a/packages/cli/src/commands/init/lib/introspect.ts b/packages/cli/src/commands/init/lib/introspect.ts
index 2e4a4c732..fb2d6c3d1 100644
--- a/packages/cli/src/commands/init/lib/introspect.ts
+++ b/packages/cli/src/commands/init/lib/introspect.ts
@@ -42,12 +42,37 @@ export function pgTypeToDataType(udtName: string): DataType {
}
}
+/**
+ * Is this column already managed by CipherStash?
+ *
+ * Both generations count. v3 is the sole generation this workspace authors,
+ * and its columns carry per-domain types (`eql_v3_text_search`,
+ * `eql_v3_integer_ord`, …) rather than v2's single `eql_v2_encrypted` udt —
+ * so keying on the v2 name alone, as this did, reported every column on the
+ * default path as plaintext. That is the dangerous direction to be wrong in:
+ * an encrypted column shown as plaintext invites the caller to encrypt it a
+ * second time. `packages/wizard` already carries this predicate; the two
+ * should agree.
+ *
+ * Deliberately not `classifyEqlDomain` from `@cipherstash/migrate`, despite
+ * the dependency being present: that answers "which generation authors this",
+ * Legacy `eql_v2_encrypted` remains recognisable for read-only diagnostics even
+ * though v2 is no longer authorable.
+ * The question here is "is this encrypted at all", which v2 answers yes to.
+ *
+ * The trailing underscore matters — a bare `eql_v3` prefix would also claim a
+ * hypothetical future `eql_v30_*`.
+ */
+export function isEqlEncryptedDomain(udtName: string): boolean {
+ return udtName === 'eql_v2_encrypted' || udtName.startsWith('eql_v3_')
+}
+
/**
* Read every base table in the `public` schema along with its columns.
*
- * The `eql_v2_encrypted` UDT marker tells us a column is already managed by
- * CipherStash — useful for re-runs against a partially set up DB so we can
- * pre-select those columns rather than asking the user to reconfirm.
+ * The EQL domain markers tell us a column is already managed by CipherStash —
+ * useful for re-runs against a partially set up DB so we can pre-select those
+ * columns rather than asking the user to reconfirm.
*/
export async function introspectDatabase(
databaseUrl: string,
@@ -85,7 +110,7 @@ export async function introspectDatabase(
columnName: row.column_name,
dataType: row.data_type,
udtName: row.udt_name,
- isEqlEncrypted: row.udt_name === 'eql_v2_encrypted',
+ isEqlEncrypted: isEqlEncryptedDomain(row.udt_name),
})
tableMap.set(row.table_name, cols)
}
@@ -203,7 +228,7 @@ export function defaultDomain(
* Returns `undefined` if the user cancels at any prompt — callers should
* propagate the cancellation rather than treating it as "no columns selected".
*
- * Pre-selects columns that are already `eql_v2_encrypted` so re-running on a
+ * Pre-selects columns that already carry an EQL domain so re-running on a
* partially encrypted DB is a no-op by default.
*/
export async function selectTableColumns(
@@ -230,7 +255,7 @@ export async function selectTableColumns(
if (eqlColumns.length > 0) {
p.log.info(
- `Detected ${eqlColumns.length} column${eqlColumns.length !== 1 ? 's' : ''} with eql_v2_encrypted type — pre-selected for you.`,
+ `Detected ${eqlColumns.length} already-encrypted column${eqlColumns.length !== 1 ? 's' : ''} (${[...new Set(eqlColumns.map((c) => c.udtName))].join(', ')}) — pre-selected for you.`,
)
}
@@ -239,7 +264,7 @@ export async function selectTableColumns(
options: table.columns.map((col) => ({
value: col.columnName,
label: col.columnName,
- hint: col.isEqlEncrypted ? 'eql_v2_encrypted' : col.dataType,
+ hint: col.isEqlEncrypted ? col.udtName : col.dataType,
})),
required: true,
initialValues: eqlColumns.map((c) => c.columnName),
diff --git a/packages/cli/src/commands/init/lib/read-context.ts b/packages/cli/src/commands/init/lib/read-context.ts
index 9b7550917..4d6fa2efb 100644
--- a/packages/cli/src/commands/init/lib/read-context.ts
+++ b/packages/cli/src/commands/init/lib/read-context.ts
@@ -38,13 +38,7 @@ export function readContextFile(cwd: string): ContextFile | undefined {
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
if (!isContextFile(parsed)) return undefined
- // Normalize usesProxy to a strict boolean: older files lack it and a
- // hand-edited file could hold any JSON value, so coerce to `true` only
- // when it is exactly `true` and `false` otherwise.
- return {
- ...parsed,
- usesProxy: parsed.usesProxy === true,
- }
+ return parsed
} catch {
return undefined
}
diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts
index ae3fa5aa3..3a7d3abb3 100644
--- a/packages/cli/src/commands/init/lib/setup-prompt.ts
+++ b/packages/cli/src/commands/init/lib/setup-prompt.ts
@@ -68,6 +68,94 @@ function migrationCommands(
return undefined
}
+/**
+ * Where this integration's rules were actually written.
+ *
+ * `postgresql` is the fallback integration and installs no integration-specific
+ * skill (see `SKILL_MAP` in `install-skills.ts`), so "the integration skill" is
+ * a pointer at a file that was never created. Point it at `stash-encryption`,
+ * which it does get.
+ */
+function integrationSkillRef(integration: Integration): string {
+ switch (integration) {
+ case 'drizzle':
+ case 'supabase':
+ case 'prisma-next':
+ return 'the integration skill'
+ default:
+ return 'the `stash-encryption` skill'
+ }
+}
+
+/**
+ * How this integration declares an encrypted column in the schema.
+ *
+ * Per-integration for the same reason the query operators are: the APIs are not
+ * interchangeable, and `stash schema build` (`utils.ts`) refuses outright to
+ * scaffold a `types.*` client for prisma-next — that integration authors its
+ * columns with `cipherstash.*` constructors in `schema.prisma` instead.
+ */
+function schemaAuthoringGuidance(integration: Integration): string {
+ switch (integration) {
+ case 'drizzle':
+ return 'Declare it with the `types.*` column factories from `@cipherstash/stack-drizzle` on the existing `pgTable`'
+ case 'supabase':
+ return 'Declare the column with a concrete `public.eql_v3_*` domain in the migration SQL — for example, `email public.eql_v3_text_search` (`encryptedSupabase` derives its encryption config by introspecting those domains)'
+ case 'prisma-next':
+ return 'Declare the field with the `cipherstash.*` constructors in `prisma/schema.prisma` (`cipherstash.TextSearch()`, `cipherstash.DoubleOrd()`, …), with the `cipherstash` extension pack wired up per `@cipherstash/prisma-next/control`'
+ default:
+ return 'Declare the table with `encryptedTable` and the `types.*` domain factories from `@cipherstash/stack/v3`, then pass it to `Encryption({ schemas })`'
+ }
+}
+
+/**
+ * How this integration filters on an encrypted column. Named per-integration
+ * rather than generically because the APIs are not interchangeable and only one
+ * of them is importable from any given project: `createEncryptionOperators` is
+ * exported by `@cipherstash/stack-drizzle` alone, so naming it for a Supabase
+ * or Prisma project sends the agent after a package that is not installed.
+ *
+ * A `switch` with a neutral `default`, not an if-chain ending in the Drizzle
+ * string: `packages/cli` is built by tsup, which transpiles without
+ * type-checking, and the package has no `typecheck` script — so nothing would
+ * catch a fifth `Integration` variant silently inheriting Drizzle's answer.
+ * `skillsFor()` in `install-skills.ts` degrades the same way, for the same
+ * reason.
+ */
+function queryOperatorGuidance(integration: Integration): string {
+ switch (integration) {
+ case 'supabase':
+ return 'query paths filter through the `encryptedSupabase` wrapper (`es.from("users").select(...).eq("email", value)`) — it encrypts filter operands for encrypted columns automatically; see the integration skill'
+ case 'prisma-next':
+ return 'query paths use the `eql*` operators on the column inside `.where()` (`u.email.eqlEq(value)`, `eqlMatch`, `eqlGt`, …) — see the integration skill'
+ case 'drizzle':
+ return 'query paths use the right operator (`ops.eq`, from `createEncryptionOperators(client)`) — see the integration skill'
+ default:
+ // `table` and `column` are the schema OBJECTS, not their names as
+ // strings, and `queryType` is inferred from the column's configured
+ // indexes unless it is passed to override the inference.
+ return 'query paths encrypt the search term first — `client.encryptQuery(value, { table: usersSchema, column: usersSchema.email })`, passing the schema objects themselves rather than their names — and compare that against the encrypted column; see the `stash-encryption` skill (a plain Postgres project gets no integration-specific skill)'
+ }
+}
+
+/**
+ * How this integration turns ciphertext back into values on the read path.
+ *
+ * Named per-integration for the third time in this file, and for the third
+ * time because the answer is not portable: `decryptModel` is the typed
+ * client's, transparent decryption is the Supabase wrapper's.
+ */
+function readPathGuidance(integration: Integration): string {
+ switch (integration) {
+ case 'supabase':
+ return 'selects through the `encryptedSupabase` wrapper decrypt transparently'
+ case 'prisma-next':
+ return 'the encrypted fields decrypt through the Prisma Next client'
+ default:
+ return 'call `decryptModel(row, usersSchema)` — or `bulkDecryptModels` for a set — before returning the value to callers'
+ }
+}
+
function bullet(line: string): string {
return `- ${line}`
}
@@ -110,10 +198,14 @@ const SKILL_PURPOSES: Record = {
'Prisma Next-specific patterns: `cipherstash.*` field constructors, migration flow, encrypted query operators',
'stash-indexing':
'index recipes for encrypted columns — the `eql_v3` extractor functional indexes, Supabase/managed-Postgres constraints, EXPLAIN verification',
+ 'stash-postgres':
+ 'hand-written Postgres SQL over `pg` / `postgres-js`: the encrypted predicate matrix, `eql_v3.query_*` operand casts, per-driver parameter binding',
+ 'stash-edge':
+ 'the `@cipherstash/stack/wasm-inline` entry for Deno / Supabase Edge Functions / Workers: imports, `CS_*` credentials, the credential-identity rule',
'stash-dynamodb':
'DynamoDB encryption: per-item encrypt/decrypt, HMAC attribute keys, audit logging',
'stash-cli':
- '`stash` command reference — `status`, `plan`, `impl`, `eql install`, `encrypt {backfill,cutover,drop}`, etc.',
+ '`stash` command reference — `status`, `plan`, `impl`, `eql install`, the staged EQL v3 read-switch workflow, and `encrypt {backfill,drop}`.',
'stash-supply-chain-security':
'supply-chain controls (post-install policy, lockfile integrity, etc.)',
}
@@ -274,20 +366,20 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string {
'',
'### Add a new encrypted column',
'',
- 'Use when the column **does not yet exist** in the database (no plaintext predecessor to preserve). This is normal Drizzle / Supabase work plus the encryption client patterns from the integration skill.',
+ `Use when the column **does not yet exist** in the database (no plaintext predecessor to preserve). This is normal schema work in the project's own ORM or migration tooling, plus the encryption client patterns from ${integrationSkillRef(ctx.integration)}.`,
'',
"1. **If this is the first encrypted column in the project, configure the bundler exclusion first.** `@cipherstash/stack` cannot be bundled (it wraps a native FFI module). Next.js: add `serverExternalPackages: ['@cipherstash/stack', '@cipherstash/protect-ffi']` to `next.config.*`. Webpack: `externals`. esbuild: `external`. Vite SSR: `ssr.external`. Without this, the encryption client crashes at runtime with `Cannot find module '@cipherstash/protect-ffi-*'`. See the `stash-encryption` skill's Installation section for the full snippets.",
- "2. Edit the user's real schema file (`src/db/schema.ts` or wherever they keep it) to declare the new encrypted column. Use the patterns in the integration skill — `encryptedType` for Drizzle, `encryptedColumn` for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. Never `.notNull()`.",
+ `2. Edit the user's real schema file (\`src/db/schema.ts\` or wherever they keep it) to declare the new encrypted column. ${schemaAuthoringGuidance(ctx.integration)} — the patterns are in ${integrationSkillRef(ctx.integration)}. Encrypted columns must be **nullable \`jsonb\`** at creation time (the \`eql_v3_*\` domains are over \`jsonb\`). Never \`.notNull()\`.`,
`3. Generate the schema migration${migration ? ` — \`${migration.generate}\` (${migration.tool})` : " using the project's existing migration tooling"}.`,
`4. Show the user the generated SQL before applying${migration ? ` — \`${migration.apply}\`` : ''}.`,
- '5. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).',
+ `5. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, ${queryOperatorGuidance(ctx.integration)}.`,
'6. Verify with a round-trip: insert a record, select it back, confirm the value decrypts and the search ops work.',
'',
'### Migrate an existing column to encrypted',
'',
"Use when the column **already exists** in the user's database and contains live data that must be preserved.",
'',
- "Why it's staged: there is no atomic way to replace a populated column with an encrypted one without corrupting data. Instead, taking encryption to production happens in two passes around a deploy gate. The first pass — the **encryption rollout** — adds the encrypted twin column and the dual-write code; the user deploys that to production so every new write produces both plaintext and ciphertext. The second pass backfills historical rows and switches reads to the encrypted column: on EQL v3 (the default) the application points at the encrypted column **by name** and the old plaintext column is dropped — there is no rename step; on EQL v2 a **cutover** renames the encrypted twin into the original column name first.",
+ "Why it's staged: there is no atomic way to replace a populated column with an encrypted one without corrupting data. Instead, taking encryption to production happens in two passes around a deploy gate. The first pass — the **encryption rollout** — adds the encrypted twin column and the dual-write code; the user deploys that to production so every new write produces both plaintext and ciphertext. The second pass backfills historical rows, switches reads to the EQL v3 encrypted column by name, and drops the old plaintext column. There is no rename step.",
'',
'#### Encryption rollout — what lands before the deploy',
'',
@@ -298,14 +390,14 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string {
'',
` \`${cli} status\``,
'',
- `to confirm where they are, then \`${cli} plan\` to draft the cutover. Do not run \`${cli} encrypt backfill\`, \`${cli} encrypt cutover\`, or \`${cli} encrypt drop\` until that has happened — \`${cli} impl\` will refuse to run cutover-step plans without a recorded \`dual_writing\` event.`,
+ `to confirm where they are, then \`${cli} plan\` to draft the remaining rollout. Do not run \`${cli} encrypt backfill\` or \`${cli} encrypt drop\` until that has happened — \`${cli} impl\` will refuse to run cutover-step plans without a recorded \`dual_writing\` event.`,
'',
'#### Backfill and switch — after dual-writes are live',
'',
`3. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`,
- `4. **Switch reads to the encrypted column.** The step depends on the EQL version (\`${cli} encrypt backfill\` prints it; \`${cli} encrypt status\` shows it). **EQL v3 (the default):** there is no rename — update the schema and queries to read/write the encrypted column by its own name, and wire decryption through the encryption client. **EQL v2:** update the schema file to declare the encrypted column under its final name (drop the twin suffix, switch \`\` to \`encryptedType\`), then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, twin → \`\`).`,
- '5. **Wire the read path through the encryption client.** The read column now holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw encrypted payloads to end users. The integration skill has the exact API.',
- '6. **Remove the dual-write code.** The plaintext column (still `` on v3; renamed `_plaintext` on v2) is no longer authoritative. Delete the dual-write logic from the persistence layer.',
+ `4. **Switch reads to the encrypted column.** Update the schema and queries to read/write the EQL v3 encrypted column by its own name, and wire decryption through the encryption client. There is no rename step. \`${cli} encrypt backfill\` rejects legacy EQL v2 rollout state because v2 mutation automation has been removed.`,
+ `5. **Wire the read path through the encryption client.** The read column now holds ciphertext — ${readPathGuidance(ctx.integration)}. Without this step, your read paths return raw encrypted payloads to end users. See ${integrationSkillRef(ctx.integration)} for the exact API.`,
+ '6. **Remove the dual-write code.** The original plaintext column is no longer authoritative. Delete the dual-write logic from the persistence layer.',
`7. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused plaintext column (on v3 it first verifies no rows are still plaintext-only). Apply with the project's normal migration tooling.`,
'',
'Recovery: if the user reports that backfill ran *before* the dual-write code was actually live, drift is expected (rows written during the backfill window land in plaintext only). Re-run with `--force` to encrypt every plaintext row regardless of current state.',
@@ -326,7 +418,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string {
"The user asks to convert a populated column in place. Explain why it doesn't work and offer the migrate-existing-column flow instead.",
),
bullet(
- "A column the user names is already encrypted (`eql_v2_encrypted` udt) but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it instead of guessing.",
+ "A column the user names is already encrypted — an `eql_v3_*` domain (`eql_v3_text_search`, `eql_v3_integer_ord`, …) on the default path, or the legacy `eql_v2_encrypted` udt — but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it instead of guessing.",
),
bullet(
'The schema migration would change the data type of a column the user has already filled.',
@@ -406,7 +498,7 @@ function planSharedNotDoBlock(ctx: SetupPromptContext): string[] {
'Edit schema files, application code, or migration files. The plan describes future changes — it does not perform them.',
),
bullet(
- `Run \`${cli} encrypt backfill\`, \`${cli} encrypt cutover\`, \`${cli} encrypt drop\`, or any other state-mutating command.`,
+ `Run \`${cli} encrypt backfill\`, \`${cli} encrypt drop\`, or any other state-mutating command.`,
),
bullet(
'Run schema migrations (`drizzle-kit migrate`, `supabase migration up`, `prisma migrate`, etc.).',
@@ -428,7 +520,7 @@ function planSharedStopAndAsk(): string[] {
"The user asks to convert a populated column in place. Explain why it doesn't work and offer the migrate-existing-column flow instead.",
),
bullet(
- "A column the user names is already encrypted (`eql_v2_encrypted` udt) but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it in the plan as a flagged risk.",
+ "A column the user names is already encrypted — an `eql_v3_*` domain (`eql_v3_text_search`, `eql_v3_integer_ord`, …) on the default path, or the legacy `eql_v2_encrypted` udt — but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it in the plan as a flagged risk.",
),
bullet(
'You discover existing partial CipherStash setup that disagrees with what the user is describing — someone else may have run `stash init` earlier with different choices. Note this in the plan and ask the user to clarify before writing prescriptive steps.',
@@ -556,19 +648,16 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string {
'**Backfill.** Encrypt the historical rows that pre-date the rollout deploy. Resumable; chunked; SIGINT-safe.',
),
bullet(
- '**Schema rename.** Update the schema declaration so the original column points at the encrypted type.',
- ),
- bullet(
- '**Cutover.** A single transaction renames `` → `_plaintext` and `_encrypted` → ``.',
+ `**Switch reads to the encrypted column.** Update the schema declaration and queries to read and write the EQL v3 \`_encrypted\` column under its own name. There is no rename step; legacy EQL v2 rollout state must be migrated before mutation commands can continue.`,
),
bullet(
- '**Read path.** Application reads of `` now return ciphertext until the read path decrypts via the encryption client. The plan must specify what changes per read site.',
+ '**Read path.** Reads of the encrypted column return ciphertext until the read path decrypts via the encryption client. The plan must specify what changes per read site.',
),
bullet(
- '**Remove dual-writes.** The plaintext column is now `_plaintext` and is no longer authoritative. Delete the dual-write code paths.',
+ '**Remove dual-writes.** The original plaintext column is no longer authoritative. Delete the dual-write code paths.',
),
bullet(
- "**Drop plaintext.** `stash encrypt drop` emits a migration that removes `_plaintext`. Apply with the project's normal migration tooling.",
+ `**Drop plaintext.** \`${cli} encrypt drop\` emits a migration that removes the now-unused plaintext column; on v3 it first verifies no rows are still plaintext-only. Apply with the project's normal migration tooling.`,
),
'',
'## Your task: produce the cutover plan file',
@@ -591,12 +680,7 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string {
' encrypt backfill` invocation with concrete `--table` / `--column` values.',
),
bullet(
- 'The schema-edit step, with the exact rename pattern (drop `_encrypted` suffix on the encrypted column, switch the original column declaration off `text`/`varchar` and onto the encrypted type).',
- ),
- bullet(
- 'The cutover invocation per column: `' +
- cli +
- ' encrypt cutover --table --column `.',
+ 'The schema-edit step: point the declaration and queries at the EQL v3 `_encrypted` column under its own name. There is no rename and no `_encrypted` suffix to drop.',
),
bullet(
'Read-path code changes: every site that reads `` from this table must decrypt via the encryption client. Enumerate the sites you can find via grep so the user can verify nothing was missed.',
@@ -608,7 +692,7 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string {
' encrypt drop --table --column `, plus the schema-migration apply step that follows.',
),
bullet(
- `Risks specific to cutover: row-count for the backfill (use \`${cli} eql status\` to estimate if helpful), tables under heavy write load (cutover holds a brief lock on the rename), application code that constructs SQL by string (those reads won't transparently decrypt).`,
+ `Risks specific to cutover: row-count for the backfill (use \`${cli} eql status\` to estimate if helpful), tables under heavy write load, and application code that constructs SQL by string (those reads won't transparently decrypt).`,
),
bullet(
"Open questions for the user — anything you can't determine from the schema, context.json, or the skills.",
@@ -653,7 +737,7 @@ function renderCompletePlanPrompt(ctx: SetupPromptContext): string {
'**Add new encrypted columns** — declared encrypted from the start; single-deploy.',
),
bullet(
- '**Migrate existing columns** — schema-add → dual-write code → backfill → schema rename → cutover → read-path switch → remove dual-write code → drop plaintext. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.',
+ `**Migrate existing columns** — schema-add → dual-write code → backfill → switch reads to the EQL v3 encrypted column by name → remove dual-write code → drop plaintext. There is no rename step. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.`,
),
'',
'## Your task: produce the complete-rollout plan file',
@@ -676,7 +760,9 @@ function renderCompletePlanPrompt(ctx: SetupPromptContext): string {
bullet(
'For migrate columns: the full step list with the exact CLI invocations (`' +
cli +
- ' encrypt backfill`, `cutover`, `drop`) and concrete `--table` / `--column` values.',
+ ' encrypt backfill`, an application read switch to the EQL v3 encrypted column by name, `' +
+ cli +
+ ' encrypt drop`) and concrete `--table` / `--column` values.',
),
bullet('For new columns: the additive single-deploy walkthrough.'),
bullet(
diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts
index ba5bd3e95..25514ad2d 100644
--- a/packages/cli/src/commands/init/lib/write-context.ts
+++ b/packages/cli/src/commands/init/lib/write-context.ts
@@ -66,8 +66,6 @@ export interface ContextFile {
* handoffs use. Absent when the file was written by `stash init` or
* `stash impl` rather than `stash plan`. */
planStep?: PlanStep
- /** Whether the user queries encrypted data via CipherStash Proxy. Captured in stash init. SDK users default to false. */
- usesProxy?: boolean
generatedAt: string
}
@@ -134,7 +132,6 @@ export function buildContextFile(state: InitState): ContextFile {
installedSkills: [],
inlinedSkills: [],
planStep: state.planStep,
- usesProxy: state.usesProxy,
generatedAt: new Date().toISOString(),
}
}
diff --git a/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts b/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts
index ef6ccfe8a..0040deb71 100644
--- a/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts
+++ b/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts
@@ -7,14 +7,14 @@ describe('createDrizzleProvider getNextSteps', () => {
it('uses npx when package manager is npm', () => {
const steps = provider.getNextSteps({}, 'npm')
expect(steps[0]).toBe(
- 'Set up your database: npx stash eql install --drizzle',
+ 'Set up your database: npx stash eql migration --drizzle',
)
})
it('uses bunx when package manager is bun', () => {
const steps = provider.getNextSteps({}, 'bun')
expect(steps[0]).toBe(
- 'Set up your database: bunx stash eql install --drizzle',
+ 'Set up your database: bunx stash eql migration --drizzle',
)
expect(steps[1]).toContain('bunx stash wizard')
for (const s of steps) expect(s).not.toMatch(/\bnpx\b/)
@@ -23,14 +23,14 @@ describe('createDrizzleProvider getNextSteps', () => {
it('uses pnpm dlx when package manager is pnpm', () => {
const steps = provider.getNextSteps({}, 'pnpm')
expect(steps[0]).toBe(
- 'Set up your database: pnpm dlx stash eql install --drizzle',
+ 'Set up your database: pnpm dlx stash eql migration --drizzle',
)
})
it('uses yarn dlx when package manager is yarn', () => {
const steps = provider.getNextSteps({}, 'yarn')
expect(steps[0]).toBe(
- 'Set up your database: yarn dlx stash eql install --drizzle',
+ 'Set up your database: yarn dlx stash eql migration --drizzle',
)
})
})
diff --git a/packages/cli/src/commands/init/providers/drizzle.ts b/packages/cli/src/commands/init/providers/drizzle.ts
index ac79167f2..3516491cc 100644
--- a/packages/cli/src/commands/init/providers/drizzle.ts
+++ b/packages/cli/src/commands/init/providers/drizzle.ts
@@ -7,7 +7,7 @@ export function createDrizzleProvider(): InitProvider {
introMessage: 'Setting up CipherStash for your Drizzle project...',
getNextSteps(state: InitState, pm: PackageManager): string[] {
const cli = runnerCommand(pm, 'stash')
- const steps = [`Set up your database: ${cli} eql install --drizzle`]
+ const steps = [`Set up your database: ${cli} eql migration --drizzle`]
const manualEdit = state.clientFilePath
? `edit ${state.clientFilePath} directly`
diff --git a/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts b/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts
index 7a8f373e9..023e91e8f 100644
--- a/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts
+++ b/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts
@@ -106,10 +106,9 @@ describe('installEqlStep', () => {
it('never pins EQL v2 for the non-Drizzle paths', async () => {
await installEqlStep.run(baseState, provider)
- // `undefined` means "take the v3 default" in resolveEqlVersion.
- expect(
- vi.mocked(installCommand).mock.calls[0][0].eqlVersion,
- ).toBeUndefined()
+ expect(vi.mocked(installCommand).mock.calls[0][0]).not.toHaveProperty(
+ 'eqlVersion',
+ )
})
describe('Drizzle', () => {
diff --git a/packages/cli/src/commands/init/steps/__tests__/resolve-proxy-choice.test.ts b/packages/cli/src/commands/init/steps/__tests__/resolve-proxy-choice.test.ts
deleted file mode 100644
index 9dd72eb2c..000000000
--- a/packages/cli/src/commands/init/steps/__tests__/resolve-proxy-choice.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import * as p from '@clack/prompts'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { InitProvider, InitState } from '../../types.js'
-import { resolveProxyChoiceStep } from '../resolve-proxy-choice.js'
-
-vi.mock('@clack/prompts', () => ({
- select: vi.fn(),
- isCancel: vi.fn(() => false),
- log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() },
-}))
-
-const provider = {} as InitProvider
-const baseState = {} as InitState
-
-const originalStdinIsTTY = process.stdin.isTTY
-const originalStdoutIsTTY = process.stdout.isTTY
-
-/**
- * Force BOTH streams. A CI runner that allocates a TTY has stdin *and* stdout
- * as TTYs — that is the configuration this gate used to hang in, and setting
- * stdin alone would let the old `process.stdout.isTTY` gate pass these tests
- * for the wrong reason (vitest's own stdout is not a TTY).
- */
-function setTty(value: boolean) {
- Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true })
- Object.defineProperty(process.stdout, 'isTTY', { value, configurable: true })
-}
-
-function restoreTty() {
- Object.defineProperty(process.stdin, 'isTTY', {
- value: originalStdinIsTTY,
- configurable: true,
- })
- Object.defineProperty(process.stdout, 'isTTY', {
- value: originalStdoutIsTTY,
- configurable: true,
- })
-}
-
-beforeEach(() => {
- vi.clearAllMocks()
-})
-
-afterEach(() => {
- restoreTty()
- vi.unstubAllEnvs()
-})
-
-describe('resolveProxyChoiceStep — flag short-circuit', () => {
- it('returns state untouched when --proxy/--no-proxy already set it', async () => {
- setTty(true)
- vi.stubEnv('CI', '')
-
- const state = { ...baseState, usesProxy: true }
- await expect(resolveProxyChoiceStep.run(state, provider)).resolves.toEqual(
- state,
- )
- expect(vi.mocked(p.select)).not.toHaveBeenCalled()
- })
-
- it('returns state untouched when --no-proxy set usesProxy to false', async () => {
- // The guard is `state.usesProxy !== undefined`; `false` is the value that
- // distinguishes it from a truthiness check. Rewriting it as
- // `if (state.usesProxy)` would silently re-prompt for --no-proxy (and log
- // the misleading "No --proxy flag set" notice non-interactively).
- setTty(true)
- vi.stubEnv('CI', '')
-
- const state = { ...baseState, usesProxy: false }
- await expect(resolveProxyChoiceStep.run(state, provider)).resolves.toEqual(
- state,
- )
- expect(vi.mocked(p.select)).not.toHaveBeenCalled()
- // An explicit --no-proxy is not "no flag set" — don't log the default notice.
- expect(vi.mocked(p.log.info)).not.toHaveBeenCalled()
- })
-})
-
-describe('resolveProxyChoiceStep — CI detection with a TTY attached', () => {
- // Regression: this gate was `process.stdout.isTTY`, which consulted CI not
- // at all. On a CI runner with an allocated TTY the clack `select` rendered
- // and blocked on /dev/tty forever — a hang, not an error. `isInteractive()`
- // gates on stdin and treats CI=1/TRUE/true alike.
- beforeEach(() => {
- setTty(true)
- })
-
- for (const ciValue of ['1', 'TRUE', 'true']) {
- it(`defaults to SDK-only under CI=${ciValue} without prompting`, async () => {
- vi.stubEnv('CI', ciValue)
-
- const result = await resolveProxyChoiceStep.run(baseState, provider)
-
- // Non-interactive proceeds with the safe default rather than failing:
- // SDK-only is what the flagless interactive prompt defaults to anyway.
- expect(vi.mocked(p.select)).not.toHaveBeenCalled()
- expect(result.usesProxy).toBe(false)
- expect(vi.mocked(p.log.info)).toHaveBeenCalledWith(
- expect.stringContaining('defaulting to SDK-only mode'),
- )
- })
- }
-
- it('prompts when CI is unset and stdin is a TTY', async () => {
- vi.stubEnv('CI', '')
- vi.mocked(p.select).mockResolvedValueOnce(true)
-
- const result = await resolveProxyChoiceStep.run(baseState, provider)
-
- expect(vi.mocked(p.select)).toHaveBeenCalledTimes(1)
- expect(result.usesProxy).toBe(true)
- })
-
- it('falls back to SDK-only when the interactive prompt is cancelled', async () => {
- vi.stubEnv('CI', '')
- vi.mocked(p.select).mockResolvedValueOnce(Symbol('cancel'))
- vi.mocked(p.isCancel).mockReturnValueOnce(true)
-
- const result = await resolveProxyChoiceStep.run(baseState, provider)
-
- expect(result.usesProxy).toBe(false)
- })
-})
-
-describe('resolveProxyChoiceStep — no TTY', () => {
- it('defaults to SDK-only when stdin is not a TTY, CI unset', async () => {
- setTty(false)
- vi.stubEnv('CI', '')
-
- const result = await resolveProxyChoiceStep.run(baseState, provider)
-
- expect(vi.mocked(p.select)).not.toHaveBeenCalled()
- expect(result.usesProxy).toBe(false)
- })
-
- it('does not prompt when stdin is piped even though stdout is a TTY', async () => {
- // setTty() can't express this — it moves both streams together. The fix
- // gates on stdin, so a redirected stdin (`stash init < /dev/null` from a
- // terminal) must not reach the prompt even while stdout is still a TTY.
- // This is the case that verifies the changeset's "wrong stream" claim;
- // only the CI axis distinguishes old from new anywhere else.
- Object.defineProperty(process.stdin, 'isTTY', {
- value: false,
- configurable: true,
- })
- Object.defineProperty(process.stdout, 'isTTY', {
- value: true,
- configurable: true,
- })
- vi.stubEnv('CI', '')
-
- const result = await resolveProxyChoiceStep.run(baseState, provider)
-
- expect(vi.mocked(p.select)).not.toHaveBeenCalled()
- expect(result.usesProxy).toBe(false)
- })
-})
diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts
index 9ee6bd880..e8e5003a8 100644
--- a/packages/cli/src/commands/init/steps/install-eql.ts
+++ b/packages/cli/src/commands/init/steps/install-eql.ts
@@ -83,7 +83,7 @@ export const installEqlStep: InitStep = {
// installCommand scaffolds stash.config.ts (which `import`s from `stash`)
// for the rest of the workflow. `stash` must be installed or the config the
- // user relies on next (db push / schema build / encrypt) can't load. Detect
+ // user relies on next (db validate / encrypt) can't load. Detect
// the precondition and bail with a clear message instead. install-deps is
// what installs the package, so a "no" there leaves us here.
if (!isPackageInstalled('stash')) {
@@ -104,13 +104,12 @@ export const installEqlStep: InitStep = {
// --drizzle`) rather than routing through `eql install`.
//
// `eql install --drizzle` is v2-only — under the v3 default it rejects the
- // flag outright, so init used to pin `eqlVersion: '2'` to get a migration
- // at all. That pin made `stash init --drizzle` the one flow that provisions
- // a v2 database while every other integration (and a bare `stash eql
- // install`) gets v3, and it contradicted the stash-drizzle skill we install
- // into the very same project — that skill documents the `/v3` surface
- // (`types.*` domains, `EncryptionV3`) and would have the user's agent
- // author v3 code against a v2 database.
+ // flag outright, so routing Drizzle through it would provision a v2
+ // database while every other integration (and a bare `stash eql install`)
+ // gets v3. That also contradicts the stash-drizzle skill installed into the
+ // very same project, which documents the v3 surface (`types.*` domains,
+ // `Encryption`) and would have the user's agent author v3 code against a v2
+ // database.
//
// `stash eql migration --drizzle` (added in #691) closes that gap: v3 SQL,
// still migration-first, and it bundles the `cs_migrations` tracking schema
@@ -148,13 +147,10 @@ export const installEqlStep: InitStep = {
return { ...state, eqlInstalled: false, eqlMigrationPending: true }
}
- let outcome: Awaited>
try {
- outcome = await installCommand({
+ await installCommand({
supabase: supabase || undefined,
databaseUrl: state.databaseUrl,
- // No `eqlVersion` — take the v3 default. (The Drizzle branch above
- // returns before this point.)
// init passes a resolved URL to avoid re-prompting, but still wants a
// config scaffolded — this is NOT a one-shot `--database-url` run.
scaffoldConfig: 'ensure',
@@ -175,14 +171,6 @@ export const installEqlStep: InitStep = {
return { ...state, eqlInstalled: false }
}
- // Supabase `--migration` mode only WRITES a migration file — EQL isn't in
- // the database until the user applies it. (Drizzle is handled above.)
- // Report that honestly rather than claiming the extension is installed;
- // the init summary turns this into "migration generated, apply it".
- if (outcome === 'migration-generated') {
- return { ...state, eqlInstalled: false, eqlMigrationPending: true }
- }
-
// 'installed' | 'already-installed' — the extension is present in the DB.
// ('dry-run' never happens from init; it doesn't pass dryRun.)
return { ...state, eqlInstalled: true }
diff --git a/packages/cli/src/commands/init/steps/resolve-proxy-choice.ts b/packages/cli/src/commands/init/steps/resolve-proxy-choice.ts
deleted file mode 100644
index 1aaef5537..000000000
--- a/packages/cli/src/commands/init/steps/resolve-proxy-choice.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import * as p from '@clack/prompts'
-import { isInteractive } from '../../../config/tty.js'
-import type { InitProvider, InitState, InitStep } from '../types.js'
-
-/**
- * Resolve whether the user queries encrypted data via CipherStash Proxy or
- * directly via the SDK. Captured as a flag (--proxy / --no-proxy) or an
- * interactive prompt, and stored on state.usesProxy.
- *
- * The prompt is non-blocking: cancellation falls back to false (SDK-only,
- * the default). In non-interactive contexts without a flag, defaults to false
- * and logs an info message.
- */
-export const resolveProxyChoiceStep: InitStep = {
- id: 'resolve-proxy-choice',
- name: 'Resolve proxy choice',
- async run(state: InitState, _provider: InitProvider): Promise {
- // If the flag was already set by the user, use it
- if (state.usesProxy !== undefined) {
- return state
- }
-
- // Prompt only when it's safe to: stdin is a real TTY and we're not in CI.
- // Via the shared `isInteractive()` (config/tty.ts) so this gate matches
- // every other prompt gate. Keying off `process.stdout.isTTY` was wrong on
- // both counts — it ignored CI entirely (a runner with an allocated TTY
- // blocked here forever), and a redirected stdin still hangs clack `select`,
- // which reads from /dev/tty.
- if (isInteractive()) {
- const choice = await p.select({
- message:
- 'Are you planning to query encrypted data via CipherStash Proxy, or directly via the SDK?',
- options: [
- {
- value: false,
- label: 'Directly via the SDK (most common)',
- },
- {
- value: true,
- label: 'CipherStash Proxy',
- },
- ],
- initialValue: false,
- })
-
- // Non-blocking: if cancelled, default to false
- if (p.isCancel(choice)) {
- p.log.info(
- 'Cancelled proxy choice; defaulting to SDK-only mode (no `stash db push` in default flows).',
- )
- return { ...state, usesProxy: false }
- }
-
- return { ...state, usesProxy: choice }
- }
-
- // Non-interactive: default to false and log
- p.log.info(
- 'No --proxy flag set; defaulting to SDK-only mode (no `stash db push` in default flows).',
- )
- return { ...state, usesProxy: false }
- },
-}
diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts
index d1317ef0b..738955b26 100644
--- a/packages/cli/src/commands/init/types.ts
+++ b/packages/cli/src/commands/init/types.ts
@@ -108,8 +108,6 @@ export interface InitState {
* on-disk plan-summary block instead. Defaults to `'rollout'` when the
* CLI has nothing else to go on (fresh project, no DB connectivity). */
planStep?: PlanStep
- /** Whether the user queries encrypted data via CipherStash Proxy. Captured in stash init. SDK users default to false; setting true makes prompts/skills include `stash db push` steps. */
- usesProxy?: boolean
}
/**
diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts
index 3045433d7..a1b134fe2 100644
--- a/packages/cli/src/commands/init/utils.ts
+++ b/packages/cli/src/commands/init/utils.ts
@@ -279,18 +279,18 @@ ${columnDefs.join('\n')}
createdAt: timestamp('created_at').defaultNow(),
})
-const ${schemaVarName} = extractEncryptionSchemaV3(${varName})`
+const ${schemaVarName} = extractEncryptionSchema(${varName})`
})
const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`)
return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core'
-import { types, extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3'
-import { EncryptionV3 } from '@cipherstash/stack/v3'
+import { types, extractEncryptionSchema } from '@cipherstash/stack-drizzle'
+import { Encryption } from '@cipherstash/stack/v3'
${tableDefs.join('\n\n')}
-export const encryptionClient = await EncryptionV3({
+export const encryptionClient = await Encryption({
schemas: [${schemaVarNames.join(', ')}],
})
`
@@ -311,11 +311,11 @@ ${columnDefs.join('\n')}
const tableVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Table`)
- return `import { EncryptionV3, encryptedTable, types } from '@cipherstash/stack/v3'
+ return `import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
${tableDefs.join('\n\n')}
-export const encryptionClient = await EncryptionV3({
+export const encryptionClient = await Encryption({
schemas: [${tableVarNames.join(', ')}],
})
`
@@ -366,7 +366,7 @@ export function generateClientFromSchemas(
* schemas yet, and explicitly tells the agent that the user's existing
* schema files remain authoritative. The agent's job during the handoff
* is to declare encrypted columns directly in those files and update the
- * `EncryptionV3({ schemas: [...] })` call below to reference them.
+ * `Encryption({ schemas: [...] })` call below to reference them.
*/
export function generatePlaceholderClient(integration: Integration): string {
if (integration === 'drizzle') {
@@ -381,15 +381,16 @@ const DRIZZLE_PLACEHOLDER = `/**
* \`stash init\` wrote this file. It is intentionally NOT a real Drizzle
* schema. Your existing schema files (typically under \`src/db/\`) remain
* authoritative — your agent will edit those directly when you encrypt a
- * column, then update the \`EncryptionV3({ schemas: [...] })\` call below
+ * column, then update the \`Encryption({ schemas: [...] })\` call below
* to reference the encrypted tables you declared there.
*
- * Until that happens, the encryption client is initialised with no
- * schemas, and \`stash encrypt\` commands will surface a clear error
- * pointing at this file.
+ * Until that happens, the encryption client is initialised with a single
+ * placeholder table so that this file compiles, and \`stash db validate\` and
+ * \`stash encrypt backfill\` refuse to run and point back here. (\`stash
+ * encrypt drop\` resolves against the database and never reads this file.)
*
* This project uses EQL v3. Encrypted columns are concrete Postgres domains
- * built with the \`types.*\` factories from \`@cipherstash/stack-drizzle/v3\`.
+ * built with the \`types.*\` factories from \`@cipherstash/stack-drizzle\`.
* Each domain's query capabilities are FIXED by the type you pick — there is
* no capability config object. Choose the factory whose capabilities you need:
* types.Text / types.Integer / … storage only (encrypt/decrypt, no queries)
@@ -404,7 +405,7 @@ const DRIZZLE_PLACEHOLDER = `/**
* Encrypted twin column for an existing populated column (path 3 — lifecycle):
*
* import { pgTable, integer, text } from 'drizzle-orm/pg-core'
- * import { types } from '@cipherstash/stack-drizzle/v3'
+ * import { types } from '@cipherstash/stack-drizzle'
*
* export const users = pgTable('users', {
* id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
@@ -419,19 +420,27 @@ const DRIZZLE_PLACEHOLDER = `/**
* billing_address: types.TextEq('billing_address'),
* })
*
- * Once you have encrypted tables declared, harvest them and pass to EncryptionV3():
+ * Once you have encrypted tables declared, harvest them and pass to Encryption():
*
- * import { extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3'
- * import { EncryptionV3 } from '@cipherstash/stack/v3'
+ * import { extractEncryptionSchema } from '@cipherstash/stack-drizzle'
+ * import { Encryption } from '@cipherstash/stack/v3'
* import { users, orders } from './db/schema'
*
- * export const encryptionClient = await EncryptionV3({
- * schemas: [extractEncryptionSchemaV3(users), extractEncryptionSchemaV3(orders)],
+ * export const encryptionClient = await Encryption({
+ * schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)],
* })
*/
-import { EncryptionV3 } from '@cipherstash/stack/v3'
+import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
+
+// REPLACE THIS. It exists only so this file compiles before you have declared
+// any encrypted tables — \`Encryption\` requires at least one. Swap it for your
+// real tables (see the patterns above); \`stash db validate\` and \`stash
+// encrypt backfill\` refuse to run while the placeholder is still here.
+export const placeholderTable = encryptedTable('__stash_placeholder__', {
+ replace_me: types.Text('replace_me'),
+})
-export const encryptionClient = await EncryptionV3({ schemas: [] })
+export const encryptionClient = await Encryption({ schemas: [placeholderTable] })
`
const GENERIC_PLACEHOLDER = `/**
@@ -440,11 +449,12 @@ const GENERIC_PLACEHOLDER = `/**
* \`stash init\` wrote this file. It is intentionally NOT a real schema
* definition. Your existing schema files remain authoritative — your
* agent will declare encrypted columns there and update the
- * \`EncryptionV3({ schemas: [...] })\` call below to reference them.
+ * \`Encryption({ schemas: [...] })\` call below to reference them.
*
- * Until that happens, the encryption client is initialised with no
- * schemas, and \`stash encrypt\` commands will surface a clear error
- * pointing at this file.
+ * Until that happens, the encryption client is initialised with a single
+ * placeholder table so that this file compiles, and \`stash db validate\` and
+ * \`stash encrypt backfill\` refuse to run and point back here. (\`stash
+ * encrypt drop\` resolves against the database and never reads this file.)
*
* This project uses EQL v3. Encrypted columns are concrete Postgres domains
* built with the \`types.*\` factories from \`@cipherstash/stack/eql/v3\`
@@ -474,16 +484,24 @@ const GENERIC_PLACEHOLDER = `/**
* billing_address: types.TextEq('billing_address'),
* })
*
- * Once you have encrypted tables declared, pass them to EncryptionV3():
+ * Once you have encrypted tables declared, pass them to Encryption():
*
- * import { EncryptionV3 } from '@cipherstash/stack/v3'
+ * import { Encryption } from '@cipherstash/stack/v3'
* import { users, orders } from './db/schema'
*
- * export const encryptionClient = await EncryptionV3({
+ * export const encryptionClient = await Encryption({
* schemas: [users, orders],
* })
*/
-import { EncryptionV3 } from '@cipherstash/stack/v3'
+import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
+
+// REPLACE THIS. It exists only so this file compiles before you have declared
+// any encrypted tables — \`Encryption\` requires at least one. Swap it for your
+// real tables (see the patterns above); \`stash db validate\` and \`stash
+// encrypt backfill\` refuse to run while the placeholder is still here.
+export const placeholderTable = encryptedTable('__stash_placeholder__', {
+ replace_me: types.Text('replace_me'),
+})
-export const encryptionClient = await EncryptionV3({ schemas: [] })
+export const encryptionClient = await Encryption({ schemas: [placeholderTable] })
`
diff --git a/packages/cli/src/commands/plan/index.ts b/packages/cli/src/commands/plan/index.ts
index 094afd685..18182d9a4 100644
--- a/packages/cli/src/commands/plan/index.ts
+++ b/packages/cli/src/commands/plan/index.ts
@@ -68,7 +68,6 @@ function buildStateFromContext(
agents,
mode: 'plan',
planStep,
- usesProxy: ctx.usesProxy ?? false,
}
}
diff --git a/packages/cli/src/commands/status/__tests__/status.test.ts b/packages/cli/src/commands/status/__tests__/status.test.ts
index 343acc230..8546e9c72 100644
--- a/packages/cli/src/commands/status/__tests__/status.test.ts
+++ b/packages/cli/src/commands/status/__tests__/status.test.ts
@@ -193,17 +193,21 @@ describe('buildColumnQuest — migrate path', () => {
// Regression guard: the hint must be prefixed with whatever `cli` the
// caller passed in (e.g. `pnpm dlx stash`), never a hard-coded
// `npx stash` string.
- const backfill = buildColumnQuest(obs({ phase: 'dual-writing' }), CLI)
+ const backfill = buildColumnQuest(
+ obs({ phase: 'dual-writing', eqlVersion: 3 }),
+ CLI,
+ )
expect(backfill.nextMove).toContain(`${CLI} encrypt backfill`)
expect(backfill.nextMove).toContain('--table users')
expect(backfill.nextMove).toContain('--column email')
expect(backfill.nextMove).not.toContain('npx stash')
const cutover = buildColumnQuest(obs({ phase: 'backfilled' }), CLI)
- expect(cutover.nextMove).toContain(`${CLI} encrypt cutover`)
+ expect(cutover.nextMove).toMatch(/Legacy EQL v2 rollout state/)
+ expect(cutover.nextMove).not.toContain('encrypt cutover')
const drop = buildColumnQuest(obs({ phase: 'cut-over' }), CLI)
- expect(drop.nextMove).toContain(`${CLI} encrypt drop`)
+ expect(drop.nextMove).toMatch(/Mutation commands for v2 have been removed/)
})
it('falls back to physical-column existence as a schema-add signal', () => {
@@ -231,7 +235,7 @@ describe('buildColumnQuest — new path', () => {
expect(quest.objectives[0].status).toBe('active')
})
- it('EQL pending: 1/2, activate active, hint uses caller-supplied runner', () => {
+ it('EQL pending: 1/2, retains diagnostics without recommending activation', () => {
const quest = buildColumnQuest(
{
table: 'orders',
@@ -243,7 +247,8 @@ describe('buildColumnQuest — new path', () => {
)
expect(quest.progress).toEqual({ done: 1, total: 2 })
expect(quest.objectives[1].status).toBe('active')
- expect(quest.nextMove).toContain(`${CLI} db activate`)
+ expect(quest.nextMove).toMatch(/Automatic activation has been removed/)
+ expect(quest.nextMove).not.toContain('db activate')
})
it('EQL active: 2/2, complete', () => {
@@ -347,7 +352,12 @@ describe('renderQuestLogTTY', () => {
planExists: false,
observedFromDb: true,
observations: [
- { table: 'users', column: 'email', phase: 'dual-writing' },
+ {
+ table: 'users',
+ column: 'email',
+ phase: 'dual-writing',
+ eqlVersion: 3,
+ },
],
cli: CLI,
})
@@ -355,7 +365,7 @@ describe('renderQuestLogTTY', () => {
expect(out).toContain('CipherStash Quest Log')
expect(out).toContain('ACTIVE QUEST')
expect(out).toContain('Encrypt users.email')
- expect(out).toMatch(/2\/5 objectives/)
+ expect(out).toMatch(/2\/4 objectives/)
expect(out).toContain('▓')
expect(out).toContain('░')
expect(out).toMatch(/← you are here/)
@@ -419,14 +429,19 @@ describe('renderQuestLogPlain', () => {
planExists: false,
observedFromDb: true,
observations: [
- { table: 'users', column: 'email', phase: 'dual-writing' },
+ {
+ table: 'users',
+ column: 'email',
+ phase: 'dual-writing',
+ eqlVersion: 3,
+ },
],
cli: CLI,
})
const out = renderQuestLogPlain(log, CLI)
expect(out).not.toMatch(/⚔️|🏆|🔒|💡|▓|░/)
expect(out).toContain('Encrypt users.email')
- expect(out).toMatch(/Progress: 2\/5/)
+ expect(out).toMatch(/Progress: 2\/4/)
expect(out).toContain('Next move:')
expect(out).toContain(`${CLI} encrypt backfill`)
expect(out).not.toContain('npx stash')
@@ -487,8 +502,13 @@ describe('renderQuestLogJSON', () => {
planExists: false,
observedFromDb: true,
observations: [
- { table: 'users', column: 'email', phase: 'dual-writing' },
- { table: 'users', column: 'ssn', phase: 'dropped' },
+ {
+ table: 'users',
+ column: 'email',
+ phase: 'dual-writing',
+ eqlVersion: 3,
+ },
+ { table: 'users', column: 'ssn', phase: 'dropped', eqlVersion: 3 },
],
cli: CLI,
})
@@ -506,7 +526,7 @@ describe('renderQuestLogJSON', () => {
expect(active.table).toBe('users')
expect(active.column).toBe('email')
expect(active.path).toBe('migrate')
- expect(active.progress).toEqual({ done: 2, total: 5 })
+ expect(active.progress).toEqual({ done: 2, total: 4 })
expect(active.complete).toBe(false)
expect(active.nextMove).toContain(`${CLI} encrypt backfill`)
expect(Array.isArray(active.objectives)).toBe(true)
@@ -557,7 +577,12 @@ describe('nextMoveHint', () => {
planExists: false,
observedFromDb: true,
observations: [
- { table: 'users', column: 'email', phase: 'dual-writing' },
+ {
+ table: 'users',
+ column: 'email',
+ phase: 'dual-writing',
+ eqlVersion: 3,
+ },
],
cli: CLI,
})
diff --git a/packages/cli/src/commands/status/quest.ts b/packages/cli/src/commands/status/quest.ts
index 2c5ceca3d..c69d7a709 100644
--- a/packages/cli/src/commands/status/quest.ts
+++ b/packages/cli/src/commands/status/quest.ts
@@ -249,7 +249,7 @@ function nextMoveFor(
if (doneCount === 0) {
return 'Declare the encrypted column in your schema and run the migration.'
}
- return `Promote the pending EQL config — \`${cli} db activate\`.`
+ return 'Legacy EQL v2 pending configuration detected. Automatic activation has been removed; migrate the column definition to EQL v3 before continuing.'
}
// Migrate. The v3 ladder has no cut-over — after backfill the app
@@ -269,20 +269,9 @@ function nextMoveFor(
}
}
- switch (doneCount) {
- case 0:
- return 'Add the encrypted twin column (`_encrypted`) and run the migration.'
- case 1:
- return `Wire dual-write code on every persistence path, deploy to production, then run \`${cli} encrypt backfill\` (it confirms dual-writes and records the event).`
- case 2:
- return `Run \`${cli} encrypt backfill --table ${obs.table} --column ${obs.column}\` to encrypt historical rows.`
- case 3:
- return `Run \`${cli} encrypt cutover --table ${obs.table} --column ${obs.column}\` to rename the encrypted twin into place and switch reads.`
- case 4:
- return `Run \`${cli} encrypt drop --table ${obs.table} --column ${obs.column}\` to remove the plaintext column.`
- default:
- return ''
- }
+ return doneCount >= 5
+ ? ''
+ : 'Legacy EQL v2 rollout state detected. Mutation commands for v2 have been removed; migrate the deployment to EQL v3 before continuing.'
}
/**
diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts
index 7103d7baa..2932be644 100644
--- a/packages/cli/src/config/index.ts
+++ b/packages/cli/src/config/index.ts
@@ -231,12 +231,62 @@ export async function loadEncryptConfig(
process.exit(1)
}
- const config = encryptClient.getEncryptConfig()
+ return requireUsableEncryptConfig(
+ encryptClient.getEncryptConfig(),
+ encryptClientPath,
+ )
+}
+
+/**
+ * Refuse an encryption client that cannot drive any command yet, naming the
+ * cause.
+ *
+ * Shared rather than duplicated because it guards ONE file reached by two
+ * loaders — `loadEncryptConfig` for `stash db validate`, and
+ * `loadEncryptionContext` for `stash encrypt backfill`. When the copies were
+ * separate they had already drifted on the nullish-config case, so one command
+ * named the cause while the other fell through to `requireTable`'s `Table
+ * "users" was not found … Available: (none)` — the symptom-not-cause message
+ * this guard exists to replace (#787 review follow-up).
+ *
+ * Both refusals are hard exits: there is no partially-usable state here, and
+ * every caller would otherwise have to re-derive that.
+ */
+export function requireUsableEncryptConfig(
+ config: EncryptConfig | undefined,
+ encryptClientPath: string,
+): EncryptConfig {
if (!config) {
console.error(
`Error: Encryption client in ${encryptClientPath} has no initialized encrypt config.`,
)
process.exit(1)
}
+
+ // `stash init` scaffolds a client holding one placeholder table, because
+ // `Encryption` requires a non-empty schema set and the scaffold has no real
+ // tables to name yet. Reaching here with only that table means the user never
+ // replaced it.
+ //
+ // Read from the built encrypt config, never from the module's export map: a
+ // scaffold whose `export` keyword was dropped is still un-replaced, while a
+ // stale `export const placeholderTable` beside real tables that are imported
+ // rather than re-exported is not (#787 review).
+ const tables = Object.keys(config.tables ?? {})
+ if (tables.length === 1 && tables[0] === PLACEHOLDER_TABLE_NAME) {
+ console.error(
+ `Error: ${encryptClientPath} still contains the placeholder table \`${PLACEHOLDER_TABLE_NAME}\` that \`stash init\` wrote.\n\nDeclare your encrypted columns and pass those tables to Encryption({ schemas: [...] }) in that file, then re-run this command.`,
+ )
+ process.exit(1)
+ }
+
return config
}
+
+/**
+ * The table name `stash init`'s scaffold uses so the file it writes compiles.
+ *
+ * Kept in sync with the templates in `commands/init/utils.ts` by
+ * `__tests__/placeholder-client-fixture.test.ts`, which also typechecks them.
+ */
+export const PLACEHOLDER_TABLE_NAME = '__stash_placeholder__'
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index bcc9316cd..fafff0b57 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -6,8 +6,4 @@ export { resolveDatabaseUrl } from './config/database-url.ts'
export type { StashConfig } from './config/index.ts'
export { defineConfig, loadStashConfig } from './config/index.ts'
export type { PermissionCheckResult } from './installer/index.ts'
-export {
- downloadEqlSql,
- EQLInstaller,
- loadBundledEqlSql,
-} from './installer/index.ts'
+export { EQLInstaller, loadBundledEqlSql } from './installer/index.ts'
diff --git a/packages/cli/src/installer/grants.ts b/packages/cli/src/installer/grants.ts
index ee9ec00af..9a808a3c2 100644
--- a/packages/cli/src/installer/grants.ts
+++ b/packages/cli/src/installer/grants.ts
@@ -10,9 +10,6 @@
* the public entry point.
*/
-/** EQL v2's operator schema. It has no internal schema. */
-export const EQL_SCHEMA_NAME = 'eql_v2'
-
/**
* EQL v3 installs its operator functions into `eql_v3` (constructors live in
* `eql_v3_internal`; the scalar type domains live in `public`). The `eql_v3`
@@ -31,8 +28,7 @@ export const EQL_V3_INTERNAL_SCHEMA_NAME = 'eql_v3_internal'
* are required. Returned as a single multi-statement string so it can be
* executed in one `client.query()` (Postgres accepts multi-statement strings)
* AND embedded directly into a Supabase migration file. One source of truth
- * for both the runtime install path and the generated migration file, shared
- * by the v2 (`eql_v2`) and v3 (`eql_v3`) installs.
+ * for both the runtime install path and the generated migration file.
*/
export function supabasePermissionsSql(schemaName: string): string {
return `GRANT USAGE ON SCHEMA ${schemaName} TO anon, authenticated, service_role;
@@ -60,7 +56,6 @@ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT USAGE O
* only real barrier; EXECUTE is granted too so an install into a database that
* has revoked EXECUTE from PUBLIC still works.
*
- * `eql_v2` has no internal schema, so this applies to v3 only.
*/
export function supabaseInternalPermissionsSql(schemaName: string): string {
return `GRANT USAGE ON SCHEMA ${schemaName} TO anon, authenticated, service_role;
@@ -69,9 +64,6 @@ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT EXECUTE
`
}
-/** The v2 (`eql_v2`) Supabase grants block. See {@link supabasePermissionsSql}. */
-export const SUPABASE_PERMISSIONS_SQL = supabasePermissionsSql(EQL_SCHEMA_NAME)
-
/**
* The v3 Supabase grants block: `eql_v3` (the public surface) AND
* `eql_v3_internal` (which its function bodies reach into). See
diff --git a/packages/cli/src/installer/index.ts b/packages/cli/src/installer/index.ts
index 90d6ad7f7..6da7ea045 100644
--- a/packages/cli/src/installer/index.ts
+++ b/packages/cli/src/installer/index.ts
@@ -1,149 +1,44 @@
-import { existsSync, readFileSync } from 'node:fs'
-import { dirname, join, resolve } from 'node:path'
import { readInstallSql } from '@cipherstash/eql/sql'
import pg from 'pg'
import {
- EQL_SCHEMA_NAME,
EQL_V3_INTERNAL_SCHEMA_NAME,
EQL_V3_SCHEMA_NAME,
- SUPABASE_PERMISSIONS_SQL,
SUPABASE_PERMISSIONS_SQL_V3,
} from './grants.js'
-/**
- * The EQL **v3** install SQL, read from `@cipherstash/eql` at runtime — the
- * single source of truth (the same `readInstallSql()` the stack and prisma-next
- * consume). We deliberately do NOT vendor a copy into the repo:
- * - a ~44k-line plpgsql artifact in the tree made GitHub classify the whole
- * repo as plpgsql (CIP-3518);
- * - a vendored copy silently drifts from the pinned package on every bump.
- * Reading it here means a `@cipherstash/eql` version bump flows straight through.
- * There is no Supabase / no-operator-family variant for v3: the bundle installs
- * everywhere from one artifact (its superuser-only operator-class statements run
- * inside a `DO` block that catches `insufficient_privilege` and skips).
- */
-function readV3InstallSql(): string {
- try {
- return readInstallSql()
- } catch (error) {
- throw new Error(
- 'Failed to read the EQL v3 install SQL from `@cipherstash/eql`. Reinstall dependencies (the package ships the bundle in `dist/sql/`).',
- { cause: error },
- )
- }
-}
-
-// EQL release, pinned to match the EQL payload format this package emits.
-// Bump in lockstep with @cipherstash/protect-ffi.
-const EQL_VERSION = 'eql-2.3.1'
-const EQL_INSTALL_URL = `https://github.com/cipherstash/encrypt-query-language/releases/download/${EQL_VERSION}/cipherstash-encrypt.sql`
-const EQL_INSTALL_NO_OPERATOR_FAMILY_URL = `https://github.com/cipherstash/encrypt-query-language/releases/download/${EQL_VERSION}/cipherstash-encrypt-supabase.sql`
-
-// The grants SQL lives in `./grants.ts` (import-free) so the live proof in
-// `@cipherstash/stack` can assert against the exact shipped strings without a
-// package cycle. Re-exported here: this module stays the public entry point.
export {
- EQL_SCHEMA_NAME,
EQL_V3_INTERNAL_SCHEMA_NAME,
EQL_V3_SCHEMA_NAME,
- SUPABASE_PERMISSIONS_SQL,
SUPABASE_PERMISSIONS_SQL_V3,
supabaseInternalPermissionsSql,
supabasePermissionsSql,
} from './grants.js'
-/**
- * Which EQL generation to install / inspect. `2` is the composite
- * `eql_v2_encrypted` type; `3` is the native concrete-domain schema
- * (`public.*` domains, `eql_v3` operators).
- */
+/** EQL generations recognised by read-only installation diagnostics. */
export type EqlVersion = 2 | 3
-/**
- * The generation `stash eql install` / `eql upgrade` target when the user
- * doesn't pass `--eql-version`. v3 (native `eql_v3.*` domain types) is the
- * current default; v2 is opt-in via `--eql-version 2` and is required for the
- * Drizzle / Supabase-migration / `--latest` paths, which v3 doesn't support
- * yet.
- */
-export const DEFAULT_EQL_VERSION: EqlVersion = 3
-
-/**
- * Resolve the `--eql-version` CLI string (`'2'` / `'3'` / `undefined`) to a
- * concrete {@link EqlVersion}, applying {@link DEFAULT_EQL_VERSION} for anything
- * that isn't an explicit `'2'`. The single authority for "which generation does
- * a bare invocation target", shared by `eql install` / `eql upgrade` so the
- * default lives in one place rather than being re-inlined as `=== '2' ? 2 : 3`.
- * Assumes the value has already been validated (see `validateInstallFlags`).
- */
-export function resolveEqlVersion(eqlVersion?: string): EqlVersion {
- return eqlVersion === '2' ? 2 : DEFAULT_EQL_VERSION
-}
-
-function schemaNameFor(eqlVersion: EqlVersion): string {
- return eqlVersion === 3 ? EQL_V3_SCHEMA_NAME : EQL_SCHEMA_NAME
-}
+const EQL_V2_SCHEMA_NAME = 'eql_v2'
-/**
- * The grants block for an EQL generation — the ONE source of truth for both the
- * runtime install path ({@link EQLInstaller.grantSupabasePermissions}) and the
- * generated Supabase migration file. Previously the installer rebuilt the block
- * from `supabasePermissionsSql(schemaNameFor(...))`, so a v3-only addition (the
- * `eql_v3_internal` grants) reached the migration file and NOT the database the
- * CLI installs into.
- */
-export function supabaseGrantsFor(eqlVersion: EqlVersion): string {
- return eqlVersion === 3
- ? SUPABASE_PERMISSIONS_SQL_V3
- : SUPABASE_PERMISSIONS_SQL
-}
-
-/**
- * Get the directory of the current file, supporting both ESM and CJS.
- */
-function getCurrentDir(): string {
- // ESM: import.meta.url is available
- if (typeof import.meta?.url === 'string' && import.meta.url) {
- return dirname(new URL(import.meta.url).pathname)
+/** The pinned EQL v3 install SQL. */
+export function loadBundledEqlSql(): string {
+ try {
+ return readInstallSql()
+ } catch (error) {
+ throw new Error(
+ 'Failed to read the EQL v3 install SQL from `@cipherstash/eql`. Reinstall dependencies (the package ships the bundle in `dist/sql/`).',
+ { cause: error },
+ )
}
- // CJS: __dirname is available
- return __dirname
}
-/**
- * Resolve the path to a bundled SQL file shipped with the package.
- *
- * tsup bundles everything flat:
- * - Library: dist/index.js → SQL at dist/sql/
- * - CLI: dist/bin/stash.js → SQL at dist/sql/
- *
- * We walk up from the current file until we find the sql/ directory.
- */
-function bundledSqlPath(filename: string): string {
- const thisDir = getCurrentDir()
-
- // Try sql/ as a sibling first (library path: dist/ -> dist/sql/)
- const sibling = join(thisDir, 'sql', filename)
- if (existsSync(sibling)) return sibling
-
- // Try one level up (CLI path: dist/bin/ -> dist/sql/)
- const parent = join(thisDir, '..', 'sql', filename)
- if (existsSync(parent)) return resolve(parent)
-
- // Fallback: return the sibling path and let the caller handle the error
- return sibling
+/** Supabase grants for the sole installable generation, EQL v3. */
+export function supabaseGrantsFor(): string {
+ return SUPABASE_PERMISSIONS_SQL_V3
}
export interface PermissionCheckResult {
ok: boolean
missing: string[]
- /**
- * Whether the connected role is a Postgres superuser. Managed Postgres
- * providers (Supabase, Neon, RDS, etc.) do not grant superuser, which means
- * `CREATE OPERATOR FAMILY` / `CREATE OPERATOR CLASS` in the EQL install
- * script will fail. Callers use this to auto-fall back to the
- * no-operator-family install variant (OPE index only) instead of aborting.
- */
isSuperuser: boolean
}
@@ -154,42 +49,20 @@ export class EQLInstaller {
this.databaseUrl = options.databaseUrl
}
- /**
- * Check whether the connected database role has the permissions required
- * to install EQL.
- *
- * EQL installation requires:
- * - SUPERUSER or CREATEDB — for `CREATE EXTENSION IF NOT EXISTS pgcrypto`
- * - CREATE on the current database — for `CREATE SCHEMA eql_v2`
- * - CREATE on the public schema — for `CREATE TYPE public.eql_v2_encrypted`
- */
async checkPermissions(): Promise {
const client = new pg.Client({ connectionString: this.databaseUrl })
-
try {
await client.connect()
-
const missing: string[] = []
-
- // Check if the role is a superuser (can do everything)
const roleResult = await client.query(`
- SELECT
- rolsuper,
- rolcreatedb
+ SELECT rolsuper, rolcreatedb
FROM pg_roles
WHERE rolname = current_user
`)
-
const role = roleResult.rows[0]
const isSuperuser = role?.rolsuper === true
+ if (isSuperuser) return { ok: true, missing: [], isSuperuser: true }
- if (isSuperuser) {
- return { ok: true, missing: [], isSuperuser: true }
- }
-
- // Not a superuser — check individual permissions
-
- // CREATE on the current database (needed for CREATE SCHEMA, CREATE EXTENSION)
const dbCreateResult = await client.query(`
SELECT has_database_privilege(current_user, current_database(), 'CREATE') AS has_create
`)
@@ -199,28 +72,25 @@ export class EQLInstaller {
)
}
- // CREATE on the public schema (needed for CREATE TYPE public.eql_v2_encrypted)
const schemaCreateResult = await client.query(`
SELECT has_schema_privilege(current_user, 'public', 'CREATE') AS has_create
`)
if (!schemaCreateResult.rows[0]?.has_create) {
missing.push(
- 'CREATE on public schema (required for CREATE TYPE public.eql_v2_encrypted)',
+ 'CREATE on public schema (required for CREATE DOMAIN public.eql_v3_*)',
)
}
- // Check if pgcrypto is already installed — if not, we need CREATE EXTENSION privilege
const pgcryptoResult = await client.query(`
SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto'
`)
- if (pgcryptoResult.rowCount === 0 || pgcryptoResult.rowCount === null) {
- // pgcrypto not installed — need to be able to create extensions
- // This typically requires superuser or the role must be the extension owner
- if (!role?.rolcreatedb) {
- missing.push(
- 'SUPERUSER or extension owner (required for CREATE EXTENSION pgcrypto)',
- )
- }
+ if (
+ (pgcryptoResult.rowCount === 0 || pgcryptoResult.rowCount === null) &&
+ !dbCreateResult.rows[0]?.has_create
+ ) {
+ missing.push(
+ 'SUPERUSER or extension owner (required for CREATE EXTENSION pgcrypto)',
+ )
}
return { ok: missing.length === 0, missing, isSuperuser: false }
@@ -234,32 +104,19 @@ export class EQLInstaller {
}
}
- /**
- * Check whether the EQL extension is installed by looking for its schema
- * (`eql_v3` by default, `eql_v2` when `eqlVersion: 2`).
- */
+ /** Generation-aware read-only detection retained for legacy diagnostics. */
async isInstalled(options?: { eqlVersion?: EqlVersion }): Promise {
const client = new pg.Client({ connectionString: this.databaseUrl })
- const eqlVersion = options?.eqlVersion ?? DEFAULT_EQL_VERSION
-
+ const requiredSchemas =
+ (options?.eqlVersion ?? 3) === 3
+ ? [EQL_V3_SCHEMA_NAME, EQL_V3_INTERNAL_SCHEMA_NAME]
+ : [EQL_V2_SCHEMA_NAME]
try {
await client.connect()
-
- // v3 is generation-aware: a pre-alpha.2 install has eql_v3 but no
- // eql_v3_internal (and pins v:2 envelopes in its domain CHECKs) — treat
- // it as NOT installed so an install run replaces it (the bundle opens by
- // dropping both schemas) instead of a stale surface silently accepting
- // wrong-generation wire data.
- const requiredSchemas =
- eqlVersion === 3
- ? [EQL_V3_SCHEMA_NAME, EQL_V3_INTERNAL_SCHEMA_NAME]
- : [EQL_SCHEMA_NAME]
-
const result = await client.query(
'SELECT count(*)::int AS found FROM information_schema.schemata WHERE schema_name = ANY($1)',
[requiredSchemas],
)
-
return result.rows[0]?.found === requiredSchemas.length
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
@@ -271,45 +128,32 @@ export class EQLInstaller {
}
}
- /**
- * Return the installed EQL version, or `null` if EQL is not installed.
- *
- * This is best-effort: if the schema exists but no version metadata is
- * available, `'unknown'` is returned.
- */
+ /** Read-only version diagnostics for current and legacy installs. */
async getInstalledVersion(options?: {
eqlVersion?: EqlVersion
}): Promise {
- const schemaName = schemaNameFor(options?.eqlVersion ?? DEFAULT_EQL_VERSION)
+ const schemaName =
+ (options?.eqlVersion ?? 3) === 3 ? EQL_V3_SCHEMA_NAME : EQL_V2_SCHEMA_NAME
const client = new pg.Client({ connectionString: this.databaseUrl })
-
try {
await client.connect()
-
const schemaResult = await client.query(
'SELECT schema_name FROM information_schema.schemata WHERE schema_name = $1',
[schemaName],
)
-
if (schemaResult.rowCount === null || schemaResult.rowCount === 0) {
return null
}
-
- // Attempt to read a version from the schema — the EQL extension may
- // expose a `version()` function or a `version` table. If neither exists
- // we fall back to 'unknown'.
try {
const versionResult = await client.query(
`SELECT ${schemaName}.version() AS version`,
)
-
- if (versionResult.rows.length > 0 && versionResult.rows[0].version) {
+ if (versionResult.rows[0]?.version) {
return String(versionResult.rows[0].version)
}
} catch {
- // version() function does not exist — that's fine
+ // Older installs may not expose version().
}
-
return 'unknown'
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
@@ -321,48 +165,9 @@ export class EQLInstaller {
}
}
- /**
- * Install the CipherStash EQL PostgreSQL extension.
- *
- * By default, uses the SQL bundled with this package. Pass `latest: true`
- * to fetch the latest version from GitHub instead.
- *
- * This method is intentionally "silent" — it does not produce any console
- * output. The calling CLI command is responsible for all user-facing output.
- */
- async install(options?: {
- excludeOperatorFamily?: boolean
- supabase?: boolean
- latest?: boolean
- eqlVersion?: EqlVersion
- }): Promise {
- const {
- supabase = false,
- latest = false,
- eqlVersion = DEFAULT_EQL_VERSION,
- } = options ?? {}
- const excludeOperatorFamily = options?.excludeOperatorFamily || supabase
-
- if (latest && eqlVersion === 3) {
- // The v3 bundle is read from the pinned `@cipherstash/eql` package (see
- // `readV3InstallSql`), not fetched from a GitHub release, so `--latest`
- // (which downloads a release asset) has no v3 target.
- // Gating --latest behind --eql-version 2 is tracked in #585.
- throw new Error(
- '`--latest` is not supported for EQL v3 yet: no public v3 release artifacts exist. Use the bundled install.',
- )
- }
-
- const sql = latest
- ? await this.downloadInstallScript(excludeOperatorFamily)
- : this.loadBundledInstallScript({
- excludeOperatorFamily,
- supabase,
- eqlVersion,
- })
-
+ /** Install the pinned EQL v3 bundle. */
+ async install(options?: { supabase?: boolean }): Promise {
const client = new pg.Client({ connectionString: this.databaseUrl })
-
try {
await client.connect()
} catch (error) {
@@ -374,200 +179,17 @@ export class EQLInstaller {
try {
await client.query('BEGIN')
- await client.query(sql)
-
- if (supabase) {
- await this.grantSupabasePermissions(client, eqlVersion)
+ await client.query(loadBundledEqlSql())
+ if (options?.supabase) {
+ await client.query(SUPABASE_PERMISSIONS_SQL_V3)
}
-
await client.query('COMMIT')
} catch (error) {
- await client.query('ROLLBACK').catch(() => {
- // Swallow rollback errors — the original error is more important.
- })
-
+ await client.query('ROLLBACK').catch(() => {})
const detail = error instanceof Error ? error.message : String(error)
- throw new Error(`Failed to install EQL: ${detail}`, {
- cause: error,
- })
+ throw new Error(`Failed to install EQL: ${detail}`, { cause: error })
} finally {
await client.end()
}
}
-
- /**
- * Grant Supabase roles access to the installed EQL schema.
- *
- * Supabase uses dedicated roles (anon, authenticated, service_role) that
- * don't own the schema, so explicit grants are required. Issues the
- * {@link supabaseGrantsFor} block as a single multi-statement query —
- * Postgres accepts that and it keeps the SQL identical to what we'd write
- * into a Supabase migration file.
- */
- private async grantSupabasePermissions(
- client: pg.Client,
- eqlVersion: EqlVersion,
- ): Promise {
- await client.query(supabaseGrantsFor(eqlVersion))
- }
-
- /**
- * Load the EQL SQL install script bundled with this package.
- */
- private loadBundledInstallScript(options: {
- excludeOperatorFamily: boolean
- supabase: boolean
- eqlVersion: EqlVersion
- }): string {
- // v3 is read from `@cipherstash/eql` at runtime; only v2 is vendored.
- if (options.eqlVersion === 3) return readV3InstallSql()
-
- const filename = resolveBundledFilename(options)
-
- try {
- return readFileSync(bundledSqlPath(filename), 'utf-8')
- } catch (error) {
- throw new Error(
- `Failed to load bundled EQL install script (${filename}). The package may be corrupted — try reinstalling stash.`,
- { cause: error },
- )
- }
- }
-
- /**
- * Download the EQL SQL install script from GitHub.
- */
- private async downloadInstallScript(
- excludeOperatorFamily: boolean,
- ): Promise {
- const url = excludeOperatorFamily
- ? EQL_INSTALL_NO_OPERATOR_FAMILY_URL
- : EQL_INSTALL_URL
-
- let response: Response
-
- try {
- response = await fetch(url)
- } catch (error) {
- throw new Error('Failed to download EQL install script from GitHub.', {
- cause: error,
- })
- }
-
- if (!response.ok) {
- throw new Error(
- `Failed to download EQL install script from GitHub. HTTP ${response.status}: ${response.statusText}`,
- )
- }
-
- return response.text()
- }
-}
-
-/**
- * Determine which bundled SQL file to use based on install options.
- *
- * - `supabase: true` → Supabase-specific variant
- * - `excludeOperatorFamily: true` → no operator family variant
- * - default → standard install
- *
- * EQL v3 (eql-3.0.0+) ships ONE artifact for every target: its only
- * superuser-requiring statements (the ore_block_256 operator class/family)
- * self-skip on `insufficient_privilege`, and the bundle then disables the
- * ORE-backed encrypted domains it cannot support (CIP-3468). So `supabase`
- * and `excludeOperatorFamily` change nothing for v3 — the bundle adapts at
- * install time instead of at file-selection time.
- */
-function resolveBundledFilename(options: {
- excludeOperatorFamily: boolean
- supabase: boolean
- eqlVersion?: EqlVersion
-}): string {
- // v3 is not vendored — it's read from `@cipherstash/eql` at runtime (see
- // `readV3InstallSql`), so callers route it away before reaching here.
- if ((options.eqlVersion ?? DEFAULT_EQL_VERSION) === 3) {
- throw new Error(
- 'resolveBundledFilename is v2-only; v3 SQL comes from `@cipherstash/eql`.',
- )
- }
- if (options.supabase) return 'cipherstash-encrypt-supabase.sql'
- if (options.excludeOperatorFamily)
- return 'cipherstash-encrypt-no-operator-family.sql'
- return 'cipherstash-encrypt.sql'
-}
-
-/**
- * Load the bundled EQL install SQL. Used by the Drizzle migration path.
- */
-export function loadBundledEqlSql(
- options: {
- excludeOperatorFamily?: boolean
- supabase?: boolean
- eqlVersion?: EqlVersion
- } = {},
-): string {
- const eqlVersion = options.eqlVersion ?? DEFAULT_EQL_VERSION
- // v3 is read from `@cipherstash/eql` at runtime; only v2 is vendored.
- if (eqlVersion === 3) return readV3InstallSql()
-
- const filename = resolveBundledFilename({
- excludeOperatorFamily: options.excludeOperatorFamily ?? false,
- supabase: options.supabase ?? false,
- eqlVersion,
- })
-
- try {
- return readFileSync(bundledSqlPath(filename), 'utf-8')
- } catch (error) {
- throw new Error(
- `Failed to load bundled EQL install script (${filename}). The package may be corrupted — try reinstalling stash.`,
- { cause: error },
- )
- }
-}
-
-/**
- * Download the latest EQL install SQL from GitHub. Used by the Drizzle migration path
- * when `--latest` is passed.
- *
- * Supabase uses the same GitHub asset as the no-operator-family variant —
- * treating either flag as "no operator families" keeps the intent explicit
- * even though the underlying URL is the same.
- */
-export async function downloadEqlSql(
- options:
- | { excludeOperatorFamily?: boolean; supabase?: boolean }
- | boolean = false,
-): Promise {
- const normalized =
- typeof options === 'boolean'
- ? { excludeOperatorFamily: options, supabase: false }
- : {
- excludeOperatorFamily: options.excludeOperatorFamily ?? false,
- supabase: options.supabase ?? false,
- }
-
- const useNoOperatorFamilyUrl =
- normalized.excludeOperatorFamily || normalized.supabase
- const url = useNoOperatorFamilyUrl
- ? EQL_INSTALL_NO_OPERATOR_FAMILY_URL
- : EQL_INSTALL_URL
-
- let response: Response
-
- try {
- response = await fetch(url)
- } catch (error) {
- throw new Error('Failed to download EQL install script from GitHub.', {
- cause: error,
- })
- }
-
- if (!response.ok) {
- throw new Error(
- `Failed to download EQL install script. HTTP ${response.status}: ${response.statusText}`,
- )
- }
-
- return response.text()
}
diff --git a/packages/cli/src/sql/cipherstash-encrypt-no-operator-family.sql b/packages/cli/src/sql/cipherstash-encrypt-no-operator-family.sql
deleted file mode 100644
index e3a0e4a94..000000000
--- a/packages/cli/src/sql/cipherstash-encrypt-no-operator-family.sql
+++ /dev/null
@@ -1,7434 +0,0 @@
---! @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 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;
-
---! @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 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
-$$;
-
-
--- 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
-);
-
---! @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);
-$$;
-
-
-
-
---! @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;
-
-
---! @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 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 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
-$$;
-
-
-
---! @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')
-$$;
-
-
-
---! @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 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 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 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/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 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 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;
-
---! @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 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 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 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 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 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 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 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;
-
-
---! @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;
-$$;
-
--- 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/cli/src/sql/cipherstash-encrypt-supabase.sql b/packages/cli/src/sql/cipherstash-encrypt-supabase.sql
deleted file mode 100644
index e3a0e4a94..000000000
--- a/packages/cli/src/sql/cipherstash-encrypt-supabase.sql
+++ /dev/null
@@ -1,7434 +0,0 @@
---! @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 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;
-
---! @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 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
-$$;
-
-
--- 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
-);
-
---! @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);
-$$;
-
-
-
-
---! @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;
-
-
---! @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 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 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
-$$;
-
-
-
---! @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')
-$$;
-
-
-
---! @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 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 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 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/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 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 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;
-
---! @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 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 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 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 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 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 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 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;
-
-
---! @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;
-$$;
-
--- 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/cli/src/sql/cipherstash-encrypt.sql b/packages/cli/src/sql/cipherstash-encrypt.sql
deleted file mode 100644
index 3cd4f3984..000000000
--- a/packages/cli/src/sql/cipherstash-encrypt.sql
+++ /dev/null
@@ -1,7644 +0,0 @@
---! @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/cli/tests/e2e/command-help.e2e.test.ts b/packages/cli/tests/e2e/command-help.e2e.test.ts
index 0134ebec3..411dcf3e9 100644
--- a/packages/cli/tests/e2e/command-help.e2e.test.ts
+++ b/packages/cli/tests/e2e/command-help.e2e.test.ts
@@ -40,7 +40,8 @@ describe('per-command --help', () => {
})
expect(r.exitCode).toBe(0)
expect(r.output).toContain('Usage: npx stash eql install [options]')
- expect(r.output).toContain('--eql-version <2|3>')
+ expect(r.output).not.toContain('--eql-version')
+ expect(r.output).not.toContain('--latest')
expect(r.output).toContain('Also settable via DATABASE_URL.')
})
diff --git a/packages/cli/tests/e2e/smoke.e2e.test.ts b/packages/cli/tests/e2e/smoke.e2e.test.ts
index d2eecbcc1..febcf3b56 100644
--- a/packages/cli/tests/e2e/smoke.e2e.test.ts
+++ b/packages/cli/tests/e2e/smoke.e2e.test.ts
@@ -117,9 +117,8 @@ describe('stash CLI — non-interactive smoke', () => {
expect(r.output).toContain('bogus-sub')
})
- // `--migration` without `--supabase` fails flag validation before any I/O
- // or prompt, so these two cases can observe the install entry path
- // deterministically without a database.
+ // The retired `--migration` flag fails before any I/O or prompt, so these
+ // cases can observe the install entry path deterministically without a DB.
it('db install still works as a deprecated alias and warns', async () => {
const r = render(['db', 'install', '--migration'])
const { exitCode } = await r.exit
@@ -128,7 +127,8 @@ describe('stash CLI — non-interactive smoke', () => {
expect(r.output).toContain('stash db install" is deprecated')
expect(r.output).toContain('eql install" instead')
// The alias reaches the real install command (its flag validation ran).
- expect(r.output).toContain('requires `--supabase`')
+ expect(r.output).toContain('eql install --migration` has been removed')
+ expect(r.output).toContain('eql migration --drizzle')
})
it('eql install routes to the install command without a deprecation warning', async () => {
@@ -136,7 +136,8 @@ describe('stash CLI — non-interactive smoke', () => {
const { exitCode } = await r.exit
expect(exitCode).toBe(1)
expect(r.output).not.toContain('is deprecated')
- expect(r.output).toContain('requires `--supabase`')
+ expect(r.output).toContain('eql install --migration` has been removed')
+ expect(r.output).toContain('eql migration --drizzle')
})
it('db migrate is a stub that exits 0 with a "not yet implemented" warning', async () => {
diff --git a/packages/cli/tests/e2e/v2-retirement.e2e.test.ts b/packages/cli/tests/e2e/v2-retirement.e2e.test.ts
new file mode 100644
index 000000000..eeaa73dca
--- /dev/null
+++ b/packages/cli/tests/e2e/v2-retirement.e2e.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+import { runPiped } from '../helpers/spawn-piped.js'
+
+const output = (result: { stdout: string; stderr: string }) =>
+ `${result.stdout}\n${result.stderr}`
+
+describe('retired EQL v2 CLI surface', () => {
+ it('rejects v2 installation before database access and links recovery SQL', async () => {
+ const result = await runPiped(['eql', 'install', '--eql-version', '2'])
+
+ expect(result.exitCode).toBe(1)
+ expect(output(result)).toContain(
+ 'https://github.com/cipherstash/encrypt-query-language/releases/tag/eql-2.3.1',
+ )
+ })
+
+ it('rejects the obsolete operator-family install flag before database access', async () => {
+ const result = await runPiped([
+ 'eql',
+ 'install',
+ '--exclude-operator-family',
+ ])
+
+ expect(result.exitCode).toBe(1)
+ expect(output(result)).toMatch(/self-adapts/i)
+ })
+
+ it.each([
+ ['--proxy', '--proxy'],
+ ['--no-proxy', '--no-proxy'],
+ ['--proxy=true', '--proxy'],
+ ['--no-proxy=true', '--no-proxy'],
+ ] as const)('rejects retired init flag `%s` before init work', async (arg, flag) => {
+ const result = await runPiped(['init', arg], { timeoutMs: 2_000 })
+
+ expect(result.timedOut).toBe(false)
+ expect(result.exitCode).toBe(1)
+ expect(output(result)).toContain(flag)
+ expect(output(result)).toMatch(/removed|retired/i)
+ expect(output(result)).toMatch(/EQL v3/i)
+ })
+
+ it.each([
+ '--eql-version=2',
+ '--latest=true',
+ '--drizzle=true',
+ '--name=install-eql',
+ '--out=drizzle',
+ '--migration=true',
+ '--migrations-dir=drizzle',
+ '--direct=true',
+ '--exclude-operator-family=true',
+ ])('rejects retired eql install equals form `%s`', async (arg) => {
+ const result = await runPiped(['eql', 'install', arg])
+
+ expect(result.exitCode).toBe(1)
+ expect(output(result)).toMatch(/removed/i)
+ })
+
+ it.each([
+ [['db', 'push'], 'db push'],
+ [['db', 'activate'], 'db activate'],
+ [['encrypt', 'cutover'], 'encrypt cutover'],
+ ] as const)('rejects removed `%s` routing', async (args, command) => {
+ const result = await runPiped([...args])
+
+ expect(result.exitCode).toBe(1)
+ expect(output(result)).toContain(command)
+ expect(output(result)).toMatch(/removed/i)
+ })
+})
diff --git a/packages/cli/tsconfig.scaffold.json b/packages/cli/tsconfig.scaffold.json
new file mode 100644
index 000000000..1a11f6286
--- /dev/null
+++ b/packages/cli/tsconfig.scaffold.json
@@ -0,0 +1,25 @@
+{
+ // Compiles the exact files `stash init` writes into a user's project.
+ //
+ // Nothing else in the repo did. `packages/cli` has no typecheck script (21
+ // pre-existing errors), the codegen tests only string-match the templates,
+ // and `build-schema.test.ts` stubs the generator out entirely — so when
+ // `Encryption` was tightened to require a non-empty schema set, both
+ // placeholders started emitting a file that fails `tsc` on first build, in a
+ // file the CLI had just told the user not to hand-edit, and CI stayed green
+ // (#772 review, finding 6).
+ //
+ // Deliberately scoped to the fixtures so it gates the scaffold without
+ // waiting on the rest of the package to compile.
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "target": "ESNext",
+ "lib": ["ESNext"],
+ "strict": true,
+ "skipLibCheck": true
+ },
+ "include": ["__fixtures__/scaffold/*.ts"]
+}
diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts
index 12aa690b8..feb085885 100644
--- a/packages/cli/tsup.config.ts
+++ b/packages/cli/tsup.config.ts
@@ -79,8 +79,6 @@ export default defineConfig([
options.define = { ...options.define, ...buildDefines }
},
onSuccess: async () => {
- // Copy bundled SQL files into dist so they ship with the package
- cpSync('src/sql', 'dist/sql', { recursive: true })
// Skills live at the monorepo root and ship inside the CLI tarball so
// `stash init` can copy them into the user's `.claude/skills/` or
// `.codex/skills/` directory at handoff time. Mirror of
diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts
index 53df05899..76b3903f4 100644
--- a/packages/cli/vitest.config.ts
+++ b/packages/cli/vitest.config.ts
@@ -9,6 +9,30 @@ export default defineConfig({
resolve: {
alias: {
'@/': `${resolve(__dirname, './src')}/`,
+ // `@cipherstash/migrate` publishes `./dist` only, so importing it — or
+ // spreading `importOriginal()` inside a partial `vi.mock` — makes the
+ // UNIT suite require a prior workspace build. CI only hides that because
+ // turbo runs `^build` first; without this alias and without a build, 10
+ // files / 177 tests fail to collect with `Failed to resolve entry for
+ // package "@cipherstash/migrate"` — the transitive `src` importers
+ // (`status/index.ts`, `db/install.ts`, `encrypt/lib/db-readers.ts`), not
+ // just the one mocked test. A full-factory `vi.mock` does not help:
+ // Vite's import analysis fails on the source module's import statement
+ // before mocking runs.
+ //
+ // This removes the `@cipherstash/migrate` build coupling ONLY. The suite
+ // is still not self-contained: removing `packages/stack/dist` still
+ // fails 10 files, via TWO independent routes —
+ // 1. `packages/migrate/src/backfill.ts` imports `@cipherstash/stack`,
+ // so this alias reaches it transitively; and
+ // 2. `init/lib/__tests__/introspect.test.ts` imports
+ // `@cipherstash/stack/eql/v3` directly, never touching migrate.
+ // Route 2 means decoupling `backfill.ts` would NOT make the suite
+ // standalone. Closing both needs `stackSourceAlias`, which cannot be
+ // spread here — its `'@/'` entry (pointing at `packages/stack/src`)
+ // would clobber this package's own `'@/'`. That is why
+ // `vitest.shared.ts` is not imported by this config (#787 review).
+ '@cipherstash/migrate': resolve(__dirname, '../migrate/src/index.ts'),
},
},
})
diff --git a/packages/migrate/README.md b/packages/migrate/README.md
index ea1fb6549..310a8d150 100644
--- a/packages/migrate/README.md
+++ b/packages/migrate/README.md
@@ -1,35 +1,29 @@
# @cipherstash/migrate
-Primitives for migrating existing plaintext columns to CipherStash encrypted columns (EQL v2 `eql_v2_encrypted` or the concrete EQL v3 `eql_v3_*` domains) in production Postgres databases, safely and resumably.
+Primitives for safely and resumably migrating existing plaintext columns to concrete EQL v3 `eql_v3_*` domains in production PostgreSQL databases.
-Backs the `stash encrypt` CLI command group, but also exported for direct use — embed `runBackfill()` in your own worker or cron job when you'd rather not pipe gigabytes through a CLI process.
+The package backs `stash encrypt backfill` and `stash encrypt drop`. You can also embed `runBackfill()` in a worker or cron job when a large migration should not run inside a CLI process.
## Lifecycle
-Each column walks through these phases — the ladder depends on the column's EQL version (auto-detected from its Postgres domain type via `detectColumnEqlVersion`):
-
```text
-EQL v2: schema-added → dual-writing → backfilling → backfilled → cut-over → dropped
-EQL v3: schema-added → dual-writing → backfilling → backfilled ————————————→ dropped
+schema-added → dual-writing → backfilling → backfilled → dropped
```
-State is tracked in an append-only `cipherstash.cs_migrations` table installed by `stash eql install`.
+EQL v3 has no configuration table and no rename cut-over. The application switches to the encrypted column by name after backfill, then drops the original plaintext column after verifying coverage. State is tracked in the append-only `cipherstash.cs_migrations` table installed by `stash eql install`.
-- **EQL v2** additionally keeps its intent (indexes, cast_as) in `eql_v2_configuration` so CipherStash Proxy works against the same database, and finishes with a **cut-over**: `eql_v2.rename_encrypted_columns()` swaps `_encrypted` into place (`` becomes `_plaintext`) alongside a config promotion.
-- **EQL v3** has **no configuration table and no cut-over** — each column's domain type encodes its own configuration. The v3 types are *self-describing*, so tooling resolves encrypted columns from the domain types themselves; the `_encrypted` naming is a convention only, never enforced or relied upon (`resolveEncryptedColumn`). The application switches to the encrypted column *by name*, and the original plaintext `` is dropped once verified: `stash encrypt drop` refuses to generate the migration while any row still has the plaintext set and the encrypted column NULL (`countUnencrypted`); the concrete `eql_v3_*` domain's CHECK constraint guarantees every non-null value is a valid v3 envelope.
+State readers still accept the legacy `cut_over` event and its `cut-over` phase. Manifest readers retain the `cut-over` target phase and `eqlVersion: 2`. Those fields are kept only so status tools can display existing migration history; this package no longer exports the EQL v2 Proxy configuration or rename primitives.
## API
```ts
import {
- installMigrationsSchema,
appendEvent,
+ installMigrationsSchema,
latestByColumn,
progress,
- runBackfill,
- renameEncryptedColumns,
- reloadConfig,
readManifest,
+ runBackfill,
writeManifest,
} from '@cipherstash/migrate'
```
@@ -38,38 +32,23 @@ import {
Creates `cipherstash.cs_migrations` idempotently. Normally called by `stash eql install`.
-### `runBackfill({ db, encryptionClient, tableSchema, tableName, plaintextColumn, encryptedColumn, pkColumn, schemaColumnKey, chunkSize?, signal?, onProgress? })`
-
-Chunked, resumable, idempotent backfill of plaintext → encrypted. Per chunk, in a single transaction: select next page → encrypt via `client.bulkEncryptModels` → `UPDATE … FROM (VALUES …)` → `INSERT` a `backfill_checkpoint` event. Guards with `encrypted IS NULL` so re-runs never double-write.
-
-- `db`: a `pg.PoolClient` (the runner drives transactions on it).
-- `encryptionClient`: your initialised `@cipherstash/stack` client (or anything that exposes `bulkEncryptModels(models, table)` returning `{ data } | { failure }`). For an EQL v3 column pass an `EncryptionV3` client (from `@cipherstash/stack/v3`) — it pins the v3 wire format; the engine itself is version-agnostic and writes whatever envelope the client produces.
-- `tableSchema`: the `EncryptedTable` for the target table from your encryption client file.
-- `signal`: optional `AbortSignal`. If aborted between chunks, the backfill exits cleanly and leaves a resumable checkpoint.
-
-Returns `{ resumed, rowsProcessed, rowsTotal, completed }`.
-
-### `appendEvent(client, { tableName, columnName, event, phase, … })` / `progress(client, table, column)` / `latestByColumn(client)`
-
-Direct access to the `cs_migrations` event log. Use these if you're building your own migration UI or orchestration on top.
-
-### `renameEncryptedColumns(client)` / `reloadConfig(client)`
+### `runBackfill(options)`
-Thin wrappers around `eql_v2.rename_encrypted_columns()` (the **v2** cut-over primitive) and `eql_v2.reload_config()` (Proxy refresh hint — no-op when connected directly to Postgres). Not used in the v3 lifecycle — v3 has no rename step.
+Runs a chunked, resumable, idempotent plaintext-to-encrypted backfill. For each chunk, it selects the next keyset page and encrypts it through `bulkEncryptModels` before `BEGIN`. The database transaction commits only the encrypted-column writes and the corresponding `cs_migrations` checkpoint. The `encrypted IS NULL` guard makes retries converge.
-### `detectColumnEqlVersion` / `resolveEncryptedColumn` / `listEncryptedColumns` / `classifyEqlDomain`
+Pass an initialized EQL v3 `Encryption` client and an EQL v3 `encryptedTable`. The lower-level runner writes the envelope produced by the supplied client; the `stash encrypt backfill` command additionally verifies that the destination is an `eql_v3_*` domain before invoking it.
-The EQL types are self-describing, and these are the domain-type primitives everything version-specific above branches on. `detectColumnEqlVersion(client, table, column)` inspects one column's Postgres domain type and returns `2`, `3`, or `null` (not an EQL column); resolution is case-exact (quoted-identifier semantics, matching the rest of the pipeline) and honours `search_path`. `resolveEncryptedColumn(client, table, plaintextColumn, hint?)` finds a plaintext column's encrypted counterpart from the domain types — an explicit hint (e.g. the manifest's recorded `encryptedColumn`) wins, then the `_encrypted` convention, then the table's sole EQL column; the name is never assumed. `listEncryptedColumns` returns every EQL-domain column on a table, classified.
+### State and manifest helpers
-### `countEncrypted` / `countUnencrypted`
+`appendEvent`, `progress`, and `latestByColumn` access the migration event log. `readManifest` and `writeManifest` manage the Zod-validated `.cipherstash/migrations.json` intent file.
-Coverage counts over the live table. `countUnencrypted(client, table, plaintextColumn, encryptedColumn)` counts rows with plaintext set and ciphertext NULL — the check `stash encrypt drop` runs before generating the v3 plaintext-drop migration (a non-zero count means rows were written without dual-writes since the backfill). `countEncrypted` counts populated target-column rows (v2 verifies through `eql_v2.count_encrypted_with_active_config`, which needs the config table v3 doesn't have). Both are full-table scans — fine as one-shot verification, not per-row primitives.
+### Domain and coverage helpers
-### `readManifest(cwd)` / `writeManifest(manifest, cwd)`
+`detectColumnEqlVersion`, `classifyEqlDomain`, `listEncryptedColumns`, and `resolveEncryptedColumn` recognize concrete EQL v3 domains and resolve the encrypted counterpart without relying on a naming convention. A legacy `eql_v2_encrypted` column returns `null`; callers must not treat that as an authorable generation.
-Read/write `.cipherstash/migrations.json` — the repo-side intent declaration. Zod-validated. The manifest is optional; commands work without it but you lose the `plan` diff.
+`countUnencrypted(client, table, plaintextColumn, encryptedColumn)` counts rows with plaintext set and ciphertext NULL. `stash encrypt drop` uses it before generating the v3 plaintext-drop migration. `countEncrypted` counts populated target rows. Both are full-table scans intended for one-shot verification.
-## Drop-in usage in a BullMQ/Inngest worker
+## Worker example
```ts
import pg from 'pg'
@@ -86,13 +65,12 @@ export async function handler({ signal }: { signal: AbortSignal }) {
encryptionClient,
tableSchema: usersTable,
tableName: 'users',
- schemaColumnKey: 'email',
+ schemaColumnKey: 'email_encrypted',
plaintextColumn: 'email',
encryptedColumn: 'email_encrypted',
pkColumn: 'id',
chunkSize: 2000,
signal,
- onProgress: (p) => console.log(`${p.rowsProcessed}/${p.rowsTotal}`),
})
} finally {
db.release()
diff --git a/packages/migrate/package.json b/packages/migrate/package.json
index b14343caa..c96f2f9db 100644
--- a/packages/migrate/package.json
+++ b/packages/migrate/package.json
@@ -42,6 +42,7 @@
},
"scripts": {
"build": "tsup",
+ "typecheck": "tsc --noEmit -p tsconfig.json",
"dev": "tsup --watch",
"test": "vitest run",
"lint": "biome check ."
diff --git a/packages/migrate/src/__tests__/backfill-v3.integration.test.ts b/packages/migrate/src/__tests__/backfill-v3.integration.test.ts
index bbd73f31e..4c426f282 100644
--- a/packages/migrate/src/__tests__/backfill-v3.integration.test.ts
+++ b/packages/migrate/src/__tests__/backfill-v3.integration.test.ts
@@ -10,7 +10,7 @@
* (implicit jsonb→domain cast + CHECK enforcement, the same assignment
* path a real `eql_v3_*` column takes);
* - `countEncrypted` (the v3 verification primitive — v3 has no
- * `eql_v2.count_encrypted_with_active_config`) counts them.
+ * the encrypted-column coverage check counts them.
*
* Skipped unless `PG_TEST_URL` is set (same harness as the v2 file):
*
diff --git a/packages/migrate/src/__tests__/manifest.test.ts b/packages/migrate/src/__tests__/manifest.test.ts
index c98bc8cd8..41cbd1837 100644
--- a/packages/migrate/src/__tests__/manifest.test.ts
+++ b/packages/migrate/src/__tests__/manifest.test.ts
@@ -1,4 +1,4 @@
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -76,6 +76,44 @@ describe('manifest', () => {
}
})
+ it('retains legacy v2 status fields for existing migration history', async () => {
+ const tmp = mkdtempSync(join(tmpdir(), 'cs-manifest-'))
+ try {
+ const legacy = {
+ version: 1,
+ tables: {
+ users: [
+ {
+ column: 'email',
+ castAs: 'text',
+ indexes: ['unique'],
+ targetPhase: 'cut-over',
+ encryptedColumn: 'email_encrypted',
+ eqlVersion: 2,
+ },
+ ],
+ },
+ }
+ const file = manifestPath(tmp)
+ mkdirSync(join(tmp, '.cipherstash'), { recursive: true })
+ writeFileSync(file, JSON.stringify(legacy), 'utf-8')
+
+ expect(await readManifest(tmp)).toMatchObject({
+ tables: {
+ users: [
+ {
+ targetPhase: 'cut-over',
+ encryptedColumn: 'email_encrypted',
+ eqlVersion: 2,
+ },
+ ],
+ },
+ })
+ } finally {
+ rmSync(tmp, { recursive: true, force: true })
+ }
+ })
+
it('rejects invalid index kinds', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'cs-manifest-'))
try {
diff --git a/packages/migrate/src/__tests__/v2-retirement.test.ts b/packages/migrate/src/__tests__/v2-retirement.test.ts
new file mode 100644
index 000000000..8f5eb110a
--- /dev/null
+++ b/packages/migrate/src/__tests__/v2-retirement.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import * as migrate from '../index.js'
+
+describe('EQL v2 lifecycle retirement', () => {
+ it('does not export Proxy configuration or cutover primitives', () => {
+ for (const name of [
+ 'activateConfig',
+ 'countEncryptedWithActiveConfig',
+ 'discardPendingConfig',
+ 'migrateConfig',
+ 'readyForEncryption',
+ 'reloadConfig',
+ 'renameEncryptedColumns',
+ 'selectPendingColumns',
+ ]) {
+ expect(migrate).not.toHaveProperty(name)
+ }
+ })
+})
diff --git a/packages/migrate/src/__tests__/version.test.ts b/packages/migrate/src/__tests__/version.test.ts
index b6cfdb9d6..140b12738 100644
--- a/packages/migrate/src/__tests__/version.test.ts
+++ b/packages/migrate/src/__tests__/version.test.ts
@@ -2,6 +2,7 @@ import type { ClientBase } from 'pg'
import { describe, expect, it, vi } from 'vitest'
import {
classifyEqlDomain,
+ columnExists,
detectColumnEqlVersion,
type EncryptedColumnInfo,
listEncryptedColumns,
@@ -18,8 +19,13 @@ function mockClient(rows: Array>) {
}
describe('classifyEqlDomain', () => {
- it('maps eql_v2_encrypted to 2', () => {
- expect(classifyEqlDomain('eql_v2_encrypted')).toBe(2)
+ it('no longer classifies eql_v2_encrypted — v3 is the sole authored generation', () => {
+ // The v2 branch was removed: this workspace authors/backfills v3 only, so
+ // the domain classifier recognises `eql_v3_*` alone. A legacy v2 column's
+ // version now comes from the manifest's recorded `eqlVersion`, not here.
+ // (Existing v2 ciphertext stays decryptable — only classification of v2 as
+ // an authorable generation is dropped.)
+ expect(classifyEqlDomain('eql_v2_encrypted')).toBeNull()
})
it('maps any eql_v3_* domain to 3', () => {
@@ -46,10 +52,20 @@ describe('classifyEqlDomain', () => {
describe('detectColumnEqlVersion', () => {
it('classifies from the domain type', async () => {
- const { client } = mockClient([{ domain_name: 'eql_v2_encrypted' }])
+ const { client } = mockClient([{ domain_name: 'eql_v3_text_search' }])
expect(
await detectColumnEqlVersion(client, 'users', 'email_encrypted'),
- ).toBe(2)
+ ).toBe(3)
+ })
+
+ it('returns null for a legacy eql_v2_encrypted domain (v2 no longer classified)', async () => {
+ // A v2 column still exists physically, but the classifier no longer treats
+ // it as an authorable EQL generation — callers fall back to the manifest's
+ // recorded eqlVersion.
+ const { client } = mockClient([{ domain_name: 'eql_v2_encrypted' }])
+ expect(
+ await detectColumnEqlVersion(client, 'users', 'ssn_encrypted'),
+ ).toBeNull()
})
it('returns null for a plaintext column (base type, not a domain)', async () => {
@@ -88,16 +104,17 @@ describe('detectColumnEqlVersion', () => {
})
describe('listEncryptedColumns', () => {
- it('returns only EQL-domain columns, classified', async () => {
+ it('returns only EQL v3-domain columns, classified (legacy v2 domains excluded)', async () => {
const { client } = mockClient([
{ column: 'id', domain_name: 'int8' },
{ column: 'email', domain_name: 'text' },
{ column: 'email_enc', domain_name: 'eql_v3_text_search' },
+ // A legacy v2 column is no longer classified as EQL, so it drops out of
+ // the encrypted-column listing entirely.
{ column: 'ssn_encrypted', domain_name: 'eql_v2_encrypted' },
])
expect(await listEncryptedColumns(client, 'users')).toEqual([
{ column: 'email_enc', domain: 'eql_v3_text_search', version: 3 },
- { column: 'ssn_encrypted', domain: 'eql_v2_encrypted', version: 2 },
])
})
})
@@ -153,10 +170,10 @@ describe('pickEncryptedColumn', () => {
})
it('never resolves the plaintext column to itself', () => {
- // Post-cutover v2: `email` itself carries the v2 domain. It is the
- // ciphertext, not a counterpart of itself.
+ // A column that IS the plaintext argument cannot be its own encrypted
+ // counterpart, even when it's the table's sole EQL-domain column.
expect(
- pickEncryptedColumn([col('email', 'eql_v2_encrypted', 2)], 'email'),
+ pickEncryptedColumn([col('email', 'eql_v3_encrypted')], 'email'),
).toBeNull()
})
@@ -186,3 +203,36 @@ describe('resolveEncryptedColumn', () => {
})
})
})
+
+describe('columnExists', () => {
+ it('returns true when the catalog reports the column', async () => {
+ const { client } = mockClient([{ exists: true }])
+ expect(await columnExists(client, 'users', 'ssn_encrypted')).toBe(true)
+ })
+
+ it('returns false when it does not', async () => {
+ const { client } = mockClient([{ exists: false }])
+ expect(await columnExists(client, 'users', 'gone')).toBe(false)
+ })
+
+ it('preserves identifier case — no bare to_regclass($1)', async () => {
+ // Same guard as `detectColumnEqlVersion` above. A bare `to_regclass($1)`
+ // parses its argument and case-folds it, so a Prisma-style `"User"` table
+ // silently resolves to `user` and the probe reports "column missing" for a
+ // column that plainly exists. In `resolveColumnLifecycle` that turns the
+ // #772 fail-closed into a silent no-op (#787 review).
+ const { client, query } = mockClient([{ exists: true }])
+ await columnExists(client, 'User', 'ssn_encrypted')
+ const [sql, values] = query.mock.calls[0] as [string, unknown[]]
+ expect(sql).toContain("format('%I'")
+ expect(sql).not.toMatch(/to_regclass\(\$1\)/)
+ expect(values).toEqual(['User', null, 'ssn_encrypted'])
+ })
+
+ it('splits schema-qualified names on the first dot, like qualifyTable', async () => {
+ const { client, query } = mockClient([{ exists: true }])
+ await columnExists(client, 'app.users', 'ssn_encrypted')
+ const [, values] = query.mock.calls[0] as [string, unknown[]]
+ expect(values).toEqual(['users', 'app', 'ssn_encrypted'])
+ })
+})
diff --git a/packages/migrate/src/backfill.ts b/packages/migrate/src/backfill.ts
index 0b1ce5c5c..d20153b5a 100644
--- a/packages/migrate/src/backfill.ts
+++ b/packages/migrate/src/backfill.ts
@@ -158,8 +158,8 @@ export interface BackfillOptions {
*/
plaintextColumn: string
/**
- * Physical column that receives the `eql_v2_encrypted` ciphertext JSON,
- * e.g. `email_encrypted`. Must already exist (typically created by
+ * Physical EQL v3 domain column that receives ciphertext, e.g.
+ * `email_encrypted`. Must already exist (typically created by
* `drizzle-kit` / a prior migration) before backfill starts.
*/
encryptedColumn: string
diff --git a/packages/migrate/src/cursor.ts b/packages/migrate/src/cursor.ts
index 4546daf4f..15b2a7eb3 100644
--- a/packages/migrate/src/cursor.ts
+++ b/packages/migrate/src/cursor.ts
@@ -123,10 +123,7 @@ export async function countUnencrypted(
/**
* Count rows whose encrypted column is populated: `encrypted IS NOT NULL`.
*
- * The v3 backfill-verification primitive. EQL v2 verified via
- * `eql_v2.count_encrypted_with_active_config(...)`, which reads the
- * `eql_v2_configuration` table — v3 has no configuration table, so the
- * equivalent check is a plain count of the target column (the concrete
+ * Backfill verification for EQL v3 is a plain count of the target column (the concrete
* `eql_v3_*` domain's CHECK constraint already guarantees every non-null
* value is a valid v3 envelope).
*/
diff --git a/packages/migrate/src/eql.ts b/packages/migrate/src/eql.ts
deleted file mode 100644
index f169442a5..000000000
--- a/packages/migrate/src/eql.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import type { ClientBase } from 'pg'
-
-/**
- * Thin, typed wrappers around the EQL (Encrypt Query Language) functions
- * installed by `stash eql install`. These mirror the canonical SQL API that
- * CipherStash Proxy also drives, so every action we take here stays
- * visible to Proxy using the same column-level config.
- *
- * Defined by the EQL project at
- * https://github.com/cipherstash/encrypt-query-language — see
- * `src/config/functions.sql` and `src/encryptindex/functions.sql` for the
- * source of truth.
- */
-
-/**
- * A column that has been registered in the `pending` EQL configuration but
- * is not yet part of the `active` config. Returned by
- * {@link selectPendingColumns}.
- */
-export interface PendingColumn {
- tableName: string
- columnName: string
-}
-
-/**
- * Return columns present in the `pending` EQL config but absent (or
- * different) in the `active` one. Wraps `eql_v2.select_pending_columns()`.
- * Useful for showing "what's about to change" before calling
- * {@link readyForEncryption} + activating the pending config.
- */
-export async function selectPendingColumns(
- client: ClientBase,
-): Promise {
- const result = await client.query<{
- table_name: string
- column_name: string
- }>('SELECT table_name, column_name FROM eql_v2.select_pending_columns()')
- return result.rows.map((row) => ({
- tableName: row.table_name,
- columnName: row.column_name,
- }))
-}
-
-/**
- * Check EQL's precondition for activating a pending configuration: every
- * pending column must have a matching `eql_v2_encrypted`-typed target
- * column in the schema. Returns `true` if activation is safe.
- * Wraps `eql_v2.ready_for_encryption()`.
- */
-export async function readyForEncryption(client: ClientBase): Promise {
- const result = await client.query<{ ready: boolean }>(
- 'SELECT eql_v2.ready_for_encryption() AS ready',
- )
- return result.rows[0]?.ready === true
-}
-
-/**
- * Atomically rename every `` → `_plaintext` and
- * `_encrypted` → `` across tables in the **pending** EQL config.
- * Wraps `eql_v2.rename_encrypted_columns()`.
- *
- * This is the **cut-over primitive**: after this returns, any SQL that
- * reads `` transparently receives the encrypted column (decrypted on
- * read by Proxy or Protect). Call inside a transaction.
- *
- * **Requires a pending configuration.** The underlying EQL function calls
- * `select_pending_columns()` and raises `'No pending configuration exists
- * to encrypt'` if none is registered. Call `db push` to register a pending
- * config first; if no rename targets are present in the diff, the loop
- * inside `rename_encrypted_columns()` does nothing — but the function
- * still requires pending to exist.
- *
- * Renames physical columns only — does not advance the EQL state machine.
- * Pair with {@link migrateConfig} + {@link activateConfig} to finish the
- * pending → encrypting → active transition.
- */
-export async function renameEncryptedColumns(
- client: ClientBase,
-): Promise {
- await client.query('SELECT eql_v2.rename_encrypted_columns()')
-}
-
-/**
- * Advance the EQL state machine: `pending → encrypting`. Wraps
- * `eql_v2.migrate_config()`.
- *
- * Throws when:
- * - There is no pending configuration to migrate.
- * - There is already an encrypting configuration in flight.
- * - Some pending column lacks its encrypted target column (`_encrypted`
- * doesn't exist in the schema with `eql_v2_encrypted` UDT).
- */
-export async function migrateConfig(client: ClientBase): Promise {
- await client.query('SELECT eql_v2.migrate_config()')
-}
-
-/**
- * Advance the EQL state machine: `encrypting → active`. Wraps
- * `eql_v2.activate_config()`. Marks any prior `active` row as `inactive`
- * in the same call.
- *
- * Throws when there is no encrypting configuration to activate. Always
- * call after {@link migrateConfig} has flipped the pending row to
- * encrypting (typically inside the same transaction as
- * {@link renameEncryptedColumns} for cutover; or alone for non-rename
- * activations like adding a new column to an existing config).
- */
-export async function activateConfig(client: ClientBase): Promise {
- await client.query('SELECT eql_v2.activate_config()')
-}
-
-/**
- * Discard the pending configuration without applying it. Wraps
- * `eql_v2.discard()`. Used by `db push` to clean up any stale pending
- * before writing a new one — the state machine only allows one pending
- * row at a time, and a stale pending blocks new pushes.
- *
- * No-op when no pending exists.
- */
-export async function discardPendingConfig(client: ClientBase): Promise {
- // The EQL `discard()` function raises when there's no pending row —
- // we want a no-op in that case, so DELETE directly. Safer than
- // wrapping eql_v2.discard() in a try/catch that swallows shape errors.
- await client.query(
- "DELETE FROM public.eql_v2_configuration WHERE state = 'pending'",
- )
-}
-
-/**
- * Nudge Proxy to re-read its config immediately instead of waiting for its
- * next 60-second refresh tick. Wraps `eql_v2.reload_config()`.
- *
- * **Must be executed through a CipherStash Proxy connection** — when
- * connected directly to Postgres, `reload_config()` is a no-op (by design,
- * per the EQL documentation). The CLI's `cutover` command accepts a
- * `--proxy-url` flag and will connect to that separately to issue this.
- */
-export async function reloadConfig(client: ClientBase): Promise