From 5af4d0eaf8fde36cac95b736dc565255d3b1e8af Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 5 May 2026 23:52:11 +1000 Subject: [PATCH 1/4] perf(stack/drizzle): wrap eq/ne/inArray/notInArray in eql_v2.hmac_256(...) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare `col = value` (and `<>`, `IN`, `NOT IN`) only engages the `eql_v2.encrypted_operator_class` btree index, which doesn't exist on Supabase or any --exclude-operator-family install. Customers there silently seq-scan every encrypted equality lookup. Switch the emitted SQL to wrap both sides in `eql_v2.hmac_256(...)` so the canonical hmac_256 functional hash index engages. Verified via the bench introduced in the parent commit: Before fix: After fix: eq Seq Scan eq Index Scan on bench_text_hmac_idx inArray Seq Scan inArray Bitmap Heap Scan ne Seq Scan (low-selectivity) ne Seq Scan (low-selectivity, expected) notInArray Seq Scan (" ") notInArray Seq Scan (" ", expected) `ne` and `notInArray` continue to seq-scan post-fix, but for the right reason: `<>` against a single value matches ~all rows, so the planner correctly avoids the index. The SQL form is now correct and the planner gets to make the right decision — same code path is correct on Supabase. --- packages/stack/src/drizzle/operators.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/stack/src/drizzle/operators.ts b/packages/stack/src/drizzle/operators.ts index 25111d56..46d73879 100644 --- a/packages/stack/src/drizzle/operators.ts +++ b/packages/stack/src/drizzle/operators.ts @@ -725,7 +725,11 @@ function createComparisonOperator( }, ) } - return operator === 'eq' ? eq(left, encrypted) : ne(left, encrypted) + // Wrap both sides in eql_v2.hmac_256(...) so the hmac_256 functional + // hash index engages. Bare `col = value` falls back to a seq scan on + // any install without `eql_v2.encrypted_operator_class` (i.e. Supabase). + const op = sql.raw(operator === 'eq' ? '=' : '<>') + return sql`eql_v2.hmac_256(${left}) ${op} eql_v2.hmac_256(${bindIfParam(encrypted, left)})` } return createLazyOperator( @@ -1480,10 +1484,15 @@ export function createEncryptionOperators(encryptionClient: EncryptionClient): { tableCache, ) - // Use regular eq for each encrypted value - PostgreSQL operators handle it + // Wrap each comparison in eql_v2.hmac_256(...) so the hmac_256 functional + // hash index engages. Postgres can BitmapOr several hash-index scans, so + // OR-of-eq stays as fast as a single equality lookup per value. const conditions = encryptedValues .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => eq(left, encrypted)) + .map( + (encrypted) => + sql`eql_v2.hmac_256(${left}) = eql_v2.hmac_256(${bindIfParam(encrypted, left)})`, + ) if (conditions.length === 0) { return sql`false` @@ -1526,10 +1535,16 @@ export function createEncryptionOperators(encryptionClient: EncryptionClient): { tableCache, ) - // Use regular ne for each encrypted value - PostgreSQL operators handle it + // Wrap each comparison in eql_v2.hmac_256(...) for index engagement (see + // encryptedInArray above for rationale). NOT IN is naturally low- + // selectivity, so the planner may still pick a seq scan — the wrap keeps + // it correct on Supabase and lets the planner decide. const conditions = encryptedValues .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => ne(left, encrypted)) + .map( + (encrypted) => + sql`eql_v2.hmac_256(${left}) <> eql_v2.hmac_256(${bindIfParam(encrypted, left)})`, + ) if (conditions.length === 0) { return sql`true` From bbe843138db5355e04bf20348465ccf1c5c3a01c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 6 May 2026 13:15:40 +1000 Subject: [PATCH 2/4] chore: changeset for drizzle hmac_256 wrap fix --- .changeset/drizzle-hmac-256-equality.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/drizzle-hmac-256-equality.md diff --git a/.changeset/drizzle-hmac-256-equality.md b/.changeset/drizzle-hmac-256-equality.md new file mode 100644 index 00000000..657e702f --- /dev/null +++ b/.changeset/drizzle-hmac-256-equality.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/stack": patch +--- + +perf(drizzle): wrap `eq` / `ne` / `inArray` / `notInArray` in `eql_v2.hmac_256(...)` so encrypted equality lookups engage the hmac_256 functional hash index on Supabase and any `--exclude-operator-family` install. Previously the operators emitted bare `col = value` SQL that only matched the `eql_v2.encrypted_operator_class` btree index, which doesn't exist on those deployments — so every encrypted equality lookup silently fell back to a sequential scan. From a34f49ec10cf2766a9f629646e2df19666f9cedf Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 6 May 2026 15:43:32 +1000 Subject: [PATCH 3/4] docs(bench): correct login command (`stash login` -> `npx stash auth login`) Per Lindsay's review: the documented login flow is now `npx stash auth login`, not the legacy `stash login`. --- packages/bench/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bench/README.md b/packages/bench/README.md index af5c3e38..bc82d56f 100644 --- a/packages/bench/README.md +++ b/packages/bench/README.md @@ -17,8 +17,8 @@ on a Supabase-shaped install (no operator classes). It runs in two layers: ```bash cd ../../local && docker compose up -d ``` -- A CipherStash profile signed in (`stash login`). Auth is read from the - CipherStash profile; no environment variables required. +- A CipherStash profile signed in (`npx stash auth login`). Auth is read from + the CipherStash profile; no environment variables required. - `DATABASE_URL` only needs to be set if you want to override the default (`postgres://cipherstash:password@localhost:5432/cipherstash`). @@ -32,7 +32,7 @@ the repo's CI `test` step (the scripts are deliberately named `test:local` / # Credential-free smoke (verifies schema + EXPLAIN harness): pnpm test:local -- db-only -# Full suite (requires CipherStash auth via `stash login`, seeds 10k rows on first run): +# Full suite (requires CipherStash auth via `npx stash auth login`, seeds 10k rows on first run): pnpm db:setup # apply schema + seed BENCH_ROWS rows (default 10k) pnpm test:local # EXPLAIN-shape assertions for #421 / #422 pnpm bench:local # timing benches (slow) From 2d21c393538fd6eff52cd1f32ba036a321827e29 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 6 May 2026 15:50:40 +1000 Subject: [PATCH 4/4] fix(stack/drizzle): fail fast in inArray/notInArray when encryption fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the operators silently filtered out array values whose encryption returned `undefined` — but that changes query semantics: - `inArray([a, b, c])` with `b` failing to encrypt would compile to `inArray([a, c])` and miss real matches. - `notInArray([a, b, c])` with `b` failing would compile to `notInArray([a, c])` and admit rows that should be excluded. Throw a `EncryptionOperatorError` instead so the failure surfaces at the boundary instead of producing a quietly wrong predicate. Spotted by CodeRabbit on #425. --- packages/stack/src/drizzle/operators.ts | 46 ++++++++++++++++++------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/stack/src/drizzle/operators.ts b/packages/stack/src/drizzle/operators.ts index 46d73879..319c0505 100644 --- a/packages/stack/src/drizzle/operators.ts +++ b/packages/stack/src/drizzle/operators.ts @@ -1484,15 +1484,26 @@ export function createEncryptionOperators(encryptionClient: EncryptionClient): { tableCache, ) + // Fail fast if any value failed to encrypt — silently dropping a value + // would change query semantics (matches that should be found get missed). + if (encryptedValues.some((encrypted) => encrypted === undefined)) { + throw new EncryptionOperatorError( + 'Encryption failed for one or more inArray values', + { + columnName: columnInfo.columnName, + tableName: columnInfo.tableName, + operator: 'inArray', + }, + ) + } + // Wrap each comparison in eql_v2.hmac_256(...) so the hmac_256 functional // hash index engages. Postgres can BitmapOr several hash-index scans, so // OR-of-eq stays as fast as a single equality lookup per value. - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map( - (encrypted) => - sql`eql_v2.hmac_256(${left}) = eql_v2.hmac_256(${bindIfParam(encrypted, left)})`, - ) + const conditions = encryptedValues.map( + (encrypted) => + sql`eql_v2.hmac_256(${left}) = eql_v2.hmac_256(${bindIfParam(encrypted, left)})`, + ) if (conditions.length === 0) { return sql`false` @@ -1535,16 +1546,27 @@ export function createEncryptionOperators(encryptionClient: EncryptionClient): { tableCache, ) + // Fail fast if any value failed to encrypt — silently dropping a value + // from a NOT IN list would admit rows that should be excluded. + if (encryptedValues.some((encrypted) => encrypted === undefined)) { + throw new EncryptionOperatorError( + 'Encryption failed for one or more notInArray values', + { + columnName: columnInfo.columnName, + tableName: columnInfo.tableName, + operator: 'notInArray', + }, + ) + } + // Wrap each comparison in eql_v2.hmac_256(...) for index engagement (see // encryptedInArray above for rationale). NOT IN is naturally low- // selectivity, so the planner may still pick a seq scan — the wrap keeps // it correct on Supabase and lets the planner decide. - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map( - (encrypted) => - sql`eql_v2.hmac_256(${left}) <> eql_v2.hmac_256(${bindIfParam(encrypted, left)})`, - ) + const conditions = encryptedValues.map( + (encrypted) => + sql`eql_v2.hmac_256(${left}) <> eql_v2.hmac_256(${bindIfParam(encrypted, left)})`, + ) if (conditions.length === 0) { return sql`true`