Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/drizzle-hmac-256-equality.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions packages/bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand All @@ -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)
Expand Down
55 changes: 46 additions & 9 deletions packages/stack/src/drizzle/operators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down