Add EQL v3 Drizzle support#565
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR adds a new EQL v3 Drizzle integration under ChangesEQL v3 Drizzle Integration
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant App
participant createEncryptionOperatorsV3
participant EncryptionClient
participant v3Dialect
participant Postgres
App->>createEncryptionOperatorsV3: eq(column, value)
createEncryptionOperatorsV3->>createEncryptionOperatorsV3: resolve column context and requireIndex
createEncryptionOperatorsV3->>EncryptionClient: encryptQuery(value, queryType)
EncryptionClient-->>createEncryptionOperatorsV3: encrypted term
createEncryptionOperatorsV3->>v3Dialect: equality(term)
v3Dialect-->>createEncryptionOperatorsV3: SQL fragment
createEncryptionOperatorsV3-->>App: SQL
App->>Postgres: execute query with SQL
Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
🦋 Changeset detectedLatest commit: 0fa0793 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
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 |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
packages/stack/__tests__/drizzle-v3/column.test.ts (1)
11-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTest reaches into Drizzle's internal column config instead of the public API.
(col as any).config.customTypeParams.dataType()asserts on an internal Drizzle builder shape rather than exercising a public entry point (e.g.,col.getSQLType(), which is Drizzle's documented API for the emitted SQL domain type). This couples the test to internalcustomTypeParams/configshape called out as fragile incolumn.ts.
As per coding guidelines, "prefer testing through the public API rather than private internals."♻️ Proposed fix using public API
it('sets dataType() to the concrete eql_v3 domain', () => { const col = makeEqlV3Column(v3Types.Int4Ord('age')) - // biome-ignore lint/suspicious/noExplicitAny: reading drizzle internals in test - expect((col as any).config.customTypeParams.dataType()).toBe( - 'eql_v3.int4_ord', - ) + expect((col as unknown as { getSQLType(): string }).getSQLType()).toBe( + 'eql_v3.int4_ord', + ) })🤖 Prompt for 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. In `@packages/stack/__tests__/drizzle-v3/column.test.ts` around lines 11 - 18, The `makeEqlV3Column` test is asserting against Drizzle internals via `(col as any).config.customTypeParams.dataType()`, so update the test to verify the emitted SQL domain through the public `getSQLType()` API on the column returned by `makeEqlV3Column` instead. Keep the test focused on the same `makeEqlV3Column`/`v3Types.Int4Ord` path, but remove the `any` cast and internal `config` access so it only depends on documented behavior.Source: Coding guidelines
packages/stack/src/eql/v3/drizzle/operators.ts (2)
188-200: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winUnbounded concurrent encryption calls for large
inArray/notInArrayinputs.Each element triggers its own
equality()→encryptTerm()round trip, and all are fired concurrently viaPromise.allwith no batching or concurrency cap. A largevaluesarray could spike load on the encryption/KMS backend.Consider chunking or capping concurrency (e.g. via
p-limitor manual batching) for large arrays.🤖 Prompt for 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. In `@packages/stack/src/eql/v3/drizzle/operators.ts` around lines 188 - 200, The inArrayOp helper currently fans out one equality() call per value through Promise.all, which can overwhelm the encryption/KMS path for large arrays. Update inArrayOp to limit concurrency by batching or capping parallel equality() work (for example with a small worker pool or chunked awaits) while preserving the existing negate/or/and behavior and the empty-array true/false handling.
49-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider Drizzle's
is()guard instead of blindanycasts for column internals.
drizzleTableOf/resolveContextreach into(column as any).table/.name. Drizzle-orm exposesis(value, Column)specifically to narrow such values safely withoutany, which would also guard against non-ColumnSQLWrapperinputs being misread.🤖 Prompt for 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. In `@packages/stack/src/eql/v3/drizzle/operators.ts` around lines 49 - 56, The `drizzleTableOf` and `resolveContext` helpers are reading `.table` and `.name` via `any` casts on `SQLWrapper`, which should be replaced with Drizzle’s `is()` guard for `Column` instead. Update these helpers to first narrow the input with `is(value, Column)` and only then access column internals, falling back safely for non-Column wrappers. Keep the changes localized to the `drizzleTableOf` and `resolveContext` logic so the context resolution remains type-safe without relying on blind casts.
🤖 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 `@docs/superpowers/specs/2026-07-06-eql-v3-drizzle-concrete-types-design.md`:
- Line 113: The fenced example blocks in this spec are unlabeled, which triggers
docs lint and hurts readability. Update both fenced blocks in the affected
sections to include a language tag using the existing markdown fences in this
document, and keep the content otherwise unchanged. Use the surrounding
fenced-example markup in the spec to locate the two bare fences that need the
same labeling fix.
In `@packages/stack/src/eql/v3/drizzle/operators.ts`:
- Line 47: The table cache in operators.ts is keyed by table name, which can
collide for pgSchema() tables that share names across schemas and merge
unrelated entries via the 'unknown' fallback. Update the cache used by the v3
operator logic to key by the table object itself, replacing the current
Map<string, AnyV3Table> with a WeakMap<PgTable, AnyV3Table>, and adjust the
lookup/insert logic in the table caching path so the AnyV3Table metadata stays
attached to the correct table instance.
In `@packages/stack/src/eql/v3/drizzle/schema-extraction.ts`:
- Around line 20-25: In schema-extraction.ts, the column identifier passed into
getEqlV3Column() is using the JS property key instead of the Drizzle column
name, which can rebuild v3 columns under the wrong name in the fallback path.
Update the loop that populates columns to prefer column.name when available and
only fall back to property if no Drizzle name exists, keeping the rest of the
logic in getEqlV3Column() unchanged.
---
Nitpick comments:
In `@packages/stack/__tests__/drizzle-v3/column.test.ts`:
- Around line 11-18: The `makeEqlV3Column` test is asserting against Drizzle
internals via `(col as any).config.customTypeParams.dataType()`, so update the
test to verify the emitted SQL domain through the public `getSQLType()` API on
the column returned by `makeEqlV3Column` instead. Keep the test focused on the
same `makeEqlV3Column`/`v3Types.Int4Ord` path, but remove the `any` cast and
internal `config` access so it only depends on documented behavior.
In `@packages/stack/src/eql/v3/drizzle/operators.ts`:
- Around line 188-200: The inArrayOp helper currently fans out one equality()
call per value through Promise.all, which can overwhelm the encryption/KMS path
for large arrays. Update inArrayOp to limit concurrency by batching or capping
parallel equality() work (for example with a small worker pool or chunked
awaits) while preserving the existing negate/or/and behavior and the empty-array
true/false handling.
- Around line 49-56: The `drizzleTableOf` and `resolveContext` helpers are
reading `.table` and `.name` via `any` casts on `SQLWrapper`, which should be
replaced with Drizzle’s `is()` guard for `Column` instead. Update these helpers
to first narrow the input with `is(value, Column)` and only then access column
internals, falling back safely for non-Column wrappers. Keep the changes
localized to the `drizzleTableOf` and `resolveContext` logic so the context
resolution remains type-safe without relying on blind casts.
🪄 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: c223fed7-0a1c-4838-90db-88844e2a9f9f
📒 Files selected for processing (20)
.changeset/eql-v3-drizzle.mddocs/superpowers/specs/2026-07-06-eql-v3-drizzle-concrete-types-design.mdpackages/stack/__tests__/drizzle-v3/codec.test.tspackages/stack/__tests__/drizzle-v3/column.test.tspackages/stack/__tests__/drizzle-v3/exports.test.tspackages/stack/__tests__/drizzle-v3/operators-live-pg.test.tspackages/stack/__tests__/drizzle-v3/operators.test.tspackages/stack/__tests__/drizzle-v3/schema-extraction.test.tspackages/stack/__tests__/drizzle-v3/sql-dialect.test.tspackages/stack/__tests__/drizzle-v3/types.test-d.tspackages/stack/__tests__/drizzle-v3/types.test.tspackages/stack/package.jsonpackages/stack/src/eql/v3/drizzle/codec.tspackages/stack/src/eql/v3/drizzle/column.tspackages/stack/src/eql/v3/drizzle/index.tspackages/stack/src/eql/v3/drizzle/operators.tspackages/stack/src/eql/v3/drizzle/schema-extraction.tspackages/stack/src/eql/v3/drizzle/sql-dialect.tspackages/stack/src/eql/v3/drizzle/types.tspackages/stack/tsup.config.ts
#565 ships the sibling integration but on the protect-ffi 0.26 / pre-rename-domain bundle line, while main (this module's base) is 0.27 + SQL-standard names with term constructors in eql_v3_internal and scalar encryptQuery unsupported — so its exact SQL and term-operand path do not transfer. Adopt its dialect shape instead: gating on build().indexes, equality-via-ORE fallback, encrypted ORDER BY (now in scope as a fragment builder), whereIn with bounded operand-encryption concurrency, and the no-silent-fallback error convention. Free-text match pinned to the two-arg eql_v3.contains form; sequencing no longer blocks on #565.
0a99c6a to
c43777e
Compare
Resolves pending review feedback from #541 (merged). Seven findings: - test: restore the live ORE proof for text order domains. text_ord / text_ord_ore carry both hm (unique) and ob (ore), so the single-kind classifier ran only the eq_term/hmac_256 proof and silently skipped ord_term/ore_block_256 -- a wrong-valued ob would pass the matrix green. Run both proofs; build the ob term with queryType:'orderAndRange' since equality resolves to hm on these domains. - schema: extract a shared resolveMatchOpts() merge+clone helper used by both the v2 and v3 freeTextSearch builders, giving v2 the clone-on-write protection it lacked (it stored caller opts by reference). - eql/v3: derive EncryptedTextSearchColumn.build()'s unique/ore blocks via indexesForCapabilities (override only match) so the emitted index shape cannot drift from the shared capability mapping. - encryption/v3: precompute row reconstructors per schema table at typedClient construction and return a DecryptionError failure for unknown tables, so table.build()'s duplicate-column throw can no longer escape the decrypt Result contract as a promise rejection. - eql/v3: alias V3DecryptedModel to V3ModelInput (character-identical) to stop the input/output model shapes silently drifting. - ci: add src/schema/match-defaults.ts to the fta-v3 path scope -- it shapes every emitted v3 match block but sat outside the gate. - ci: re-baseline FTA --score-cap 72 -> 69 after the eql/v3 file split and refresh the spec doc's stale 71.08 baseline claim.
…e casts Follow-ups from PR review (non-blocking): - catalog: give v3 timestamp domains a non-midnight sample set (TS_S) so the live matrix actually detects a cast_as 'timestamp' -> 'date' truncation regression; midnight-only DATE_S survived truncation silently. Add a runnable matrix.test.ts guard so the detection power can't be reverted unnoticed. - schema: document the 'timestamp' cast_as on both dataType() JSDoc @param lists (and restore the dropped 'text' on EncryptedColumn), noting 'timestamp' preserves time-of-day where 'date' truncates. - columns/encryption: extract DATE_LIKE_CASTS as the single source of truth for the date-like set, shared by the type-level PlaintextFromKind and the runtime rowReconstructor (previously hand-synced in two places).
…/null) Close assertion-strength gaps in the EQL v3 live suites where a real regression could pass green: - between/notBetween: add narrow single-point range cases that prove EXCLUSION (the spanning cases only ever proved inclusion). - ordering oracle: compare text bytewise, not via localeCompare, to match ORE byte order. - inArray/notInArray: exercise the >4-value concurrency pool. - matrix-boundary-live-pg: drive catalog boundary samples (INT4/smallint bounds, 1e15) through real eql_v3.<type> columns, not just the FFI. - operators-lock-context-live-pg: query lock-context-bound rows through the Drizzle operator path (ops.eq + lockContext/audit); soft-skips on USER_JWT. - operators-null-live-pg: NULL persistence across storage/eq/ord/match tiers. - live-gate-required guard + CI: fail loudly (REQUIRE_LIVE/REQUIRE_LIVE_PG) instead of silently skipping the live matrices when creds are absent.
c43777e to
dbe1ec0
Compare
| // Pure-ORE domains (numeric/date) answer equality via ORE, so | ||
| // `queryType:'equality'` resolves to the `ob` term — the exact | ||
| // equality-via-ORE path Part A fixed. Text order domains ALSO carry `hm`, | ||
| // where `equality` resolves to HMAC by the shared `unique > … > ore` | ||
| // priority; force `orderAndRange` there so the term still carries the `ob` | ||
| // this proof extracts with `ore_block_256`. |
There was a problem hiding this comment.
Why does Drizzle care if its ORE or HMAC under the hood? I don't understand.
| * Which proofs a domain's configured indexes call for. Unlike a single-kind | ||
| * classifier these lists are NOT mutually exclusive — a domain runs EVERY proof | ||
| * its indexes support: | ||
| * | ||
| * - eq (`hm`): every domain carrying a `unique` index → `eq_term`/`hmac_256`. | ||
| * - ord (`ob`): every domain carrying an `ore` index → `ord_term`/`ore_block_256`. | ||
| * - match (`bf`): every domain carrying a `match` index → `match_term`/`bloom_filter`. | ||
| * - storage: a domain with NO index — only the ciphertext round-trip proof. | ||
| * | ||
| * Text order domains (`text_ord`/`text_ord_ore`) carry BOTH `unique` and `ore`, | ||
| * so they appear in `eqDomains` AND `ordDomains` and run both proofs — a | ||
| * wrong-valued `ob` would otherwise slip through an eq-only check (text equality | ||
| * is HMAC-based, so `queryType:'equality'` on them resolves to `hm`, never `ob`; | ||
| * their ord term is built with `queryType:'orderAndRange'` below). `text_search` | ||
| * also carries all three indexes but is deliberately exercised by the match | ||
| * proof ALONE — its distinguishing, richest capability and the one canonical | ||
| * example per tier — so it is excluded from the eq/ord lists via `!match`. |
There was a problem hiding this comment.
IMHO checking for the presence of specific SEM is brittle and breaks the abstraction. The tests should be based on properties - (e.g. do values equal each other or not)
| it('sets dataType() to the concrete eql_v3 domain', () => { | ||
| const col = makeEqlV3Column(v3Types.IntegerOrd('age')) | ||
| const table = pgTable('users', { age: col }) | ||
|
|
||
| expect(table.age.getSQLType()).toBe('eql_v3.integer_ord') | ||
| }) |
There was a problem hiding this comment.
Is there any way to set the SEM specifier? i.e. distinguish between ORE and OPE variants? Can be a follow-up if not.
coderdan
left a comment
There was a problem hiding this comment.
Test coverage review — PR #565 (EQL v3 Drizzle integration)
This PR lands a large, well-covered v3 Drizzle integration (codec, columns, operators, schema extraction, dialect) plus a typedClient reconstructor refactor. Happy paths and most gating errors are thoroughly tested via the V3_MATRIX-driven suites. The gaps below are all new negative / fallback branches introduced by this diff that no test currently exercises — none are blocking.
Verdict: COMMENT — 5 coverage gaps flagged inline.
Out of scope (noted, not posted inline)
v3FromDriver(src/eql/v3/drizzle/codec.ts) callsJSON.parseon the driver string with no guard, so a malformed/corrupt jsonb value throws rather than surfacing a typed error. It's a thin wrapper and low priority, but there is no malformed-input case incodec.test.ts— worth a one-line test if you want the throw pinned.
No overflow beyond the cap.
| decrypt: (encrypted) => client.decrypt(encrypted), | ||
| decryptModel: async (input, table, lockContext) => { | ||
| const reconstruct = reconstructors.get(table) | ||
| if (!reconstruct) return unknownTableFailure as never |
There was a problem hiding this comment.
Gap: The new reconstructors.get(table) miss branch — returning unknownTableFailure when decryptModel/bulkDecryptModels is handed a table the client was not initialized with — is untested. typed-client-v3.test.ts only ever passes the same table given to typedClient(...), so this negative arm (and its mirror at bulkDecryptModels, line 226) never runs. This is the classic missing-negative-case pattern: the happy reconstruct path is covered, the wrong-table path is not.
// __tests__/typed-client-v3.test.ts — mirrors the existing fakeClient pattern
it('fails with a DecryptionError when given a table it was not initialized with', async () => {
const other = encryptedTable('other', { x: types.Text('x') })
const client = typedClient(fakeClient({ when: null }), table) // NOT `other`
const single = await client.decryptModel({}, other as never)
expect(single.failure?.type).toBe('DecryptionError')
expect(single.failure?.message).toMatch(/not initialized with/i)
const bulk = await client.bulkDecryptModels([{}], other as never)
expect(bulk.failure?.type).toBe('DecryptionError')
})Expected: both calls resolve to a { failure } Result (never throw / reject), pinning the contract the comment above the branch describes.
| const tableName = (table as any)[Symbol.for('drizzle:Name')] as | ||
| | string | ||
| | undefined | ||
| if (!tableName) { |
There was a problem hiding this comment.
Gap: The missing-table-name throw is untested. schema-extraction.test.ts covers the sibling throw (/no encrypted v3/i) but not this one — a lopsided negative case (one throw arm tested, the other not). Every current test builds the table via pgTable(...), which always sets the drizzle:Name symbol, so the !tableName arm never runs.
// __tests__/drizzle-v3/schema-extraction.test.ts
it('throws when the drizzle table has no name symbol', () => {
const table = { secret: { getSQLType: () => 'eql_v3.text_eq', name: 'secret' } }
expect(() => extractEncryptionSchemaV3(table as never)).toThrow(
/read table name/i,
)
})Expected: throws "Unable to read table name from Drizzle table." (the check must fire before the no-columns check).
| : undefined | ||
| if (typeof sqlType === 'string') return sqlType | ||
| const dt = columnAny.dataType | ||
| const domain = typeof dt === 'function' ? dt() : (columnAny.sqlName ?? dt) |
There was a problem hiding this comment.
Gap: getSqlType's fallback branch — recovering the domain from dataType() or sqlName when getSQLType() is absent — is never exercised. column.test.ts and schema-extraction.test.ts only feed columns that expose getSQLType() (line 74 path), so the dataType/sqlName recovery at lines 78–79 is untested even though getEqlV3Column/isEqlV3Column are public API.
// __tests__/drizzle-v3/column.test.ts
it('recovers a v3 column exposing only dataType()/sqlName (no getSQLType)', () => {
expect(isEqlV3Column({ dataType: () => 'eql_v3.text_eq' })).toBe(true)
expect(isEqlV3Column({ sqlName: 'eql_v3.integer_ord' })).toBe(true)
expect(
getEqlV3Column('nickname', { dataType: () => 'eql_v3.text_eq' })?.getEqlType(),
).toBe('eql_v3.text_eq')
})Expected: both shapes are recognised and rebuilt to their concrete domain; a regression that drops the fallback would return false/undefined.
| } | ||
| const conditions = await mapWithConcurrency( | ||
| values, | ||
| MAX_IN_ARRAY_CONCURRENCY, |
There was a problem hiding this comment.
Gap: inArray/notInArray are only tested with 2 values, so the mapWithConcurrency worker-pool loop (lines 73–80) that engages when values.length > MAX_IN_ARRAY_CONCURRENCY (4) never runs — a single worker handling ≤2 items never iterates the while (true) queue. A boundary case (>4 values) is needed to prove every value produces exactly one term with none dropped or duplicated.
// __tests__/drizzle-v3/operators.test.ts
it('inArray fans out every value past the concurrency limit', async () => {
const { ops, encrypt, render } = setup()
const values = [1, 2, 3, 4, 5, 6, 7] // > MAX_IN_ARRAY_CONCURRENCY (4)
const q = render(await ops.inArray(users.age, values))
expect((q.sql.match(/eql_v3\.eq/g) ?? []).length).toBe(values.length)
expect(encrypt).toHaveBeenCalledTimes(values.length)
})Expected: 7 eql_v3.eq terms and 7 encrypt calls — an off-by-one in the worker's index handling would drop or repeat a term.
| } | ||
| } | ||
|
|
||
| function applyOperationOptions( |
There was a problem hiding this comment.
Gap: applyOperationOptions merges per-call opts over constructor defaults (opts?.lockContext ?? defaults.lockContext, same for audit), but only the default-forwarding arm is tested (ops.eq(users.nickname, 'ada') with no opts). The override arm — a per-call lockContext/audit winning over a constructor default — is unexercised.
// __tests__/drizzle-v3/operators.test.ts (setup() defaults: lockContext user-123, audit actor:test)
it('per-call lockContext / audit override the constructor defaults', async () => {
const { ops, encrypt } = setup()
const callLock = { identityClaim: 'call-override' }
const callAudit = { metadata: { actor: 'call' } }
await ops.eq(users.nickname, 'ada', { lockContext: callLock, audit: callAudit })
const op = encrypt.mock.results[0]?.value
expect(op.withLockContext).toHaveBeenCalledWith(callLock)
expect(op.audit).toHaveBeenCalledWith(callAudit)
})Expected: the call-site values are forwarded, not the constructor defaults; a swap of the ?? operands would fail this.
| function unwrap<T>(result: { data?: T; failure?: { message: string } }): T { | ||
| if (result.failure) throw new Error(result.failure.message) | ||
| return result.data as T | ||
| } |
There was a problem hiding this comment.
Side note: @byteslice/result 0.5.0 includes an unwrap function.
| const skipUnlessJwt = (): boolean => { | ||
| if (!userJwt) { | ||
| console.log('Skipping lock-context operator test - no USER_JWT provided') | ||
| return true | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
We'd probably miss this is no JWT set. In fact, I'm dubious that there is one set now.
coderdan
left a comment
There was a problem hiding this comment.
Looks great. A couple of items that should be addressed before merge. Also, the docs are almost non-existent. Should there be a section in the README and some typedoc?
Code review — EQL v3 Drizzle integration + bigint domainsReviewed at extra-high effort (multi-angle finder pass → verify → sweep) against Correctness / robustness1. 2. 3. Drizzle 4. Drizzle 5. Efficiency6. 7. Cleanup / conventions8. 9. A Linear issue ID appears in a source comment (and in the changeset) — 10. Drizzle table-name extraction via 11. 12. Dead Cleared during verification (not bugs): 🤖 Generated with Claude Code |
Summary
Adds the EQL v3 Drizzle integration (
@cipherstash/stack/eql/v3/drizzle) plus hardened live test coverage, on top of the v3 typed client / schema DSL.typesnamespace whose columns are the semanticeql_v3.<domain>Postgres types.createEncryptionOperatorsV3— capability-checked operators (eq/ne/gt/gte/lt/lte/between/notBetween/contains/inArray/notInArray/asc/desc/and/or/not/isNull/isNotNull/exists/notExists) that emit the two-argumenteql_v3term-function SQL. The concrete column type drives which operators are legal (e.g.containsneeds a match index;asc/betweenneed order).extractEncryptionSchemaV3rebuilds the encrypt schema forEncryptionV3.The existing v2
@cipherstash/stack/drizzleintegration is unchanged. v3 free-text search is token containment, socontainsreplaces SQLlike/ilike(deliberately not exposed).Usage
Test Plan
cd packages/stack && pnpm biome check src/eql/v3/drizzle __tests__/{drizzle-v3,v3-matrix}cd packages/stack && pnpm vitest run __tests__/{drizzle-v3,v3-matrix}cd packages/stack && pnpm test:types && pnpm buildeql_v3) run whenDATABASE_URL+CS_*are set and self-install theeql_v3extension atbeforeAll. The Drizzle lock-context suite additionally needsUSER_JWTand soft-skips without it.Notes
REQUIRE_LIVE/REQUIRE_LIVE_PGguard turns a silent skip into a CI failure so the live matrix can't quietly go green.eql_v3.*function forms are interim (tracked by CIP-3402 / CIP-3423).Summary by CodeRabbit
New Features
Bug Fixes