Skip to content

perf(stack/drizzle): wrap eq/ne/inArray/notInArray in eql_v2.hmac_256(...)#425

Open
coderdan wants to merge 4 commits intomainfrom
dan/fix-bare-equality-supabase
Open

perf(stack/drizzle): wrap eq/ne/inArray/notInArray in eql_v2.hmac_256(...)#425
coderdan wants to merge 4 commits intomainfrom
dan/fix-bare-equality-supabase

Conversation

@coderdan
Copy link
Copy Markdown
Contributor

@coderdan coderdan commented May 5, 2026

Summary

Stacked on #424.

Fixes #421. 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 at any scale.

This change wraps both sides of the comparison in eql_v2.hmac_256(...) so the canonical hmac_256 functional hash index engages on every install.

What changed

packages/stack/src/drizzle/operators.ts:

  • eq / ne (around line 728): emitted SQL goes from col = $1 to eql_v2.hmac_256(col) = eql_v2.hmac_256($1) (and <> for ne).
  • inArray / notInArray (around lines 1486 / 1532): each comparison in the OR/AND chain wraps both sides in eql_v2.hmac_256(...). Postgres can BitmapOr several hash-index scans, so the OR-of-eq stays as fast as a single equality lookup per value.

Plan-shape verification

EXPLAIN on the same 10k-row fixture (from #424's bench), before vs after:

Operator Before fix After fix
eq Seq Scan Index Scan on bench_text_hmac_idx
inArray Seq Scan Bitmap Heap Scan
ne Seq Scan Seq Scan (low-selectivity, expected)
notInArray Seq Scan Seq Scan (low-selectivity, 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 on Supabase and the planner makes the right call regardless.

Wall-clock benchmark

pnpm -F @cipherstash/bench bench:local against the same 10k-row fixture, vitest --bench (tinybench under the hood). Higher hz is better; lower mean is better.

Operator Before (hz) After (hz) Before mean (ms) After mean (ms) Speedup
eq (string match) 5.53 1,910.73 180.7 0.52 ~345×
inArray (3 string matches) 1.46 1,696.64 686.4 0.59 ~1163×
like (prefix) 1.33 1.38 754.6 724.8 — (#422)
gt (int) 2.03 1.95 493.0 513.4 — (#422)
between (int) 1.25 1.27 798.1 787.0 — (#422)
orderBy desc + limit 10 2.99 3.09 334.8 323.2 — (#422)

Operators outside the scope of this fix (like, gt, between, orderBy) are unchanged within noise — those still seq-scan because their call-shaped forms aren't being inlined by the planner. Tracked separately in #422.

What's NOT in this PR

Test plan

  • On this branch, in packages/bench: pnpm db:setup && pnpm test:local. Expect 9/9 green on __tests__/drizzle/operators.explain.test.ts.
  • Inspect packages/bench/results/explain-shape.jsoneq should report Index Scan on bench_text_hmac_idx; inArray should report Bitmap Heap Scan.
  • Existing @cipherstash/stack unit tests still pass: pnpm -F @cipherstash/stack test.

Summary by CodeRabbit

  • Performance
    • Improved query performance for encrypted columns. Equality checks, inequality comparisons, and array filtering operations now execute significantly faster, reducing database query latency and improving overall application responsiveness. This enhancement delivers consistent performance benefits when filtering or searching encrypted data, resulting in a noticeably faster user experience.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Warning

Rate limit exceeded

@coderdan has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 21 minutes and 22 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b9cb0f7a-e487-4fc8-b1ae-2e9141401e05

📥 Commits

Reviewing files that changed from the base of the PR and between bbe8431 and 2d21c39.

📒 Files selected for processing (2)
  • packages/bench/README.md
  • packages/stack/src/drizzle/operators.ts
📝 Walkthrough

Walkthrough

This PR wraps Drizzle's equality operators (eq, ne, inArray, notInArray) with eql_v2.hmac_256(...) to enable functional hash index engagement on encrypted columns, preventing fallback to sequential scans. A changeset documents the performance improvement.

Changes

Equality Operators with Functional Hash Index

Layer / File(s) Summary
Operator Implementation
packages/stack/src/drizzle/operators.ts
Equality (eq/ne) operators wrap both operands with eql_v2.hmac_256(...) and use a hashed operator (= or <>). InArray and NotInArray build per-value conditions using eql_v2.hmac_256(${left}) = eql_v2.hmac_256(${value}) and eql_v2.hmac_256(${left}) <> eql_v2.hmac_256(${value}) respectively, replacing direct Drizzle operators.
Documentation
.changeset/drizzle-hmac-256-equality.md
Changeset entry documents the perf improvement: hmac_256 wrapping engages functional hash indexes on Supabase and --exclude-operator-family installs, avoiding sequential scans.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Suggested reviewers

  • auxesis

Poem

🐰 A whisper through the hash—
Where hmac_256 takes the clash,
No more sequential crawl,
Indexes answer the call! ✨
Performance leaps, equality's thrash.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: wrapping equality and membership operators in eql_v2.hmac_256() for performance improvement.
Linked Issues check ✅ Passed The PR successfully addresses #421 by wrapping eq/ne/inArray/notInArray operators in eql_v2.hmac_256() to engage functional hash indexes and avoid sequential scans.
Out of Scope Changes check ✅ Passed All changes are scoped to the stated objectives: only equality/membership operators are modified, with no unrelated alterations to other operator families or APIs.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dan/fix-bare-equality-supabase

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 5, 2026

🦋 Changeset detected

Latest commit: 2d21c39

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@cipherstash/stack Patch
@cipherstash/bench Patch
@cipherstash/basic-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

coderdan added 2 commits May 6, 2026 15:11
…(...)

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.
@coderdan coderdan force-pushed the dan/fix-bare-equality-supabase branch from 25e1b97 to bbe8431 Compare May 6, 2026 05:12
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/stack/src/drizzle/operators.ts`:
- Around line 1490-1495: The current code filters out undefined entries from
encryptedValues causing silent predicate changes; instead, check for missing
encryption results and throw an error (fail fast) when any encrypted value is
undefined so callers like inArray() and notInArray() don't get altered
predicates; update the logic around encryptedValues in operators.ts (the block
that builds conditions using eql_v2.hmac_256 and bindIfParam) to validate and
throw on undefined entries, and apply the same guard in encryptedNotInArray, or
alternatively enforce one-result-per-input inside encryptValues() so all callers
receive complete results.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db7911e8-b9de-4522-9ef2-b691c4f4654d

📥 Commits

Reviewing files that changed from the base of the PR and between e57b53f and bbe8431.

📒 Files selected for processing (2)
  • .changeset/drizzle-hmac-256-equality.md
  • packages/stack/src/drizzle/operators.ts

Comment thread packages/stack/src/drizzle/operators.ts Outdated
coderdan added 2 commits May 6, 2026 15:43
…login`)

Per Lindsay's review: the documented login flow is now
`npx stash auth login`, not the legacy `stash login`.
…ails

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: Drizzle eq/ne/inArray emit bare equality, bypassing hmac functional indexes

1 participant