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. 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) diff --git a/packages/stack/src/drizzle/operators.ts b/packages/stack/src/drizzle/operators.ts index 25111d56..319c0505 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,26 @@ export function createEncryptionOperators(encryptionClient: EncryptionClient): { tableCache, ) - // Use regular eq for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => eq(left, encrypted)) + // 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.map( + (encrypted) => + sql`eql_v2.hmac_256(${left}) = eql_v2.hmac_256(${bindIfParam(encrypted, left)})`, + ) if (conditions.length === 0) { return sql`false` @@ -1526,10 +1546,27 @@ export function createEncryptionOperators(encryptionClient: EncryptionClient): { tableCache, ) - // Use regular ne for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => ne(left, encrypted)) + // 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.map( + (encrypted) => + sql`eql_v2.hmac_256(${left}) <> eql_v2.hmac_256(${bindIfParam(encrypted, left)})`, + ) if (conditions.length === 0) { return sql`true`