Add EQL v3 Drizzle support#565
Conversation
Column builders are copied onto the EncryptedTable instance for accessor access (users.email). A column named build/tableName/columnBuilders/ _columnType would silently overwrite that member — worst case a 'build' column breaks buildEncryptConfig's tb.build() call at runtime. Throw a clear error at table-definition time instead. Scoped to v3; v2 retains its existing behavior. Found by CodeRabbit review.
…ntry The wasm-inline encrypt entry typed opts.column as the widened structural BuildableColumn, but getColumnName still gated on instanceof EncryptedColumn || EncryptedField and threw for a v3 EncryptedTextSearchColumn — a runtime break the type promise hid. Resolve the name structurally (typeof getName) so v3 columns round-trip through WasmEncryptionClient.encrypt(); still throws for non-builder JS input. getColumnName is the only instanceof gate on this path; the rest reads table.tableName structurally. Adds wasm-inline-column-name.test.ts exercising the seam (v2 column/field + v3 column + non-builder). Like its sibling wasm-inline-normalize.test.ts the suite cannot load in environments missing the @cipherstash/protect-ffi /wasm-inline dep subpath.
Config tables are keyed by name, so two tables with the same tableName silently dropped the earlier one. Add a v3-only additive guard that throws on a duplicate (Object.hasOwn). v2's buildEncryptConfig keeps its existing silent-overwrite behavior (no-v2-change constraint).
The RESERVED_TABLE_KEYS guard only covered own members (build, tableName, columnBuilders, _columnType), so a column named constructor/toString/valueOf/ hasOwnProperty was assigned as an own property, shadowing the Object.prototype member. Add an `in` check (isReservedTableKey) so any prototype-chain member is also rejected, keeping the table object well-behaved for reflection.
…freeTextSearch' for match A v3 text_search column emits unique+ore+match, and shared index inference picks by priority unique > match > ore. So encryptQuery without an explicit queryType builds an EQUALITY term (via unique) — a substring matches nothing. Document on EncryptedTextSearchColumn + encryptedTextSearchColumn that callers must pass queryType:'freeTextSearch' (FFI 'match') for free-text queries. Addresses review finding #2 (naming footgun; doc-only, no runtime change).
Add table-driven runtime tests for all 40 EQL v3 domain builders (name, eqlType, capabilities, config, queryability) plus type-level tests for nominal domain distinctness, InferPlaintext mapping, queryability of BuildableQueryColumn, and v3/v2 model inference.
Add a generic EncryptedV3Column base parameterised by a literal domain definition (eqlType, castAs, capabilities), plus concrete builders for every EQL v3 domain (int2/4/8, float4/8, numeric, date, timestamptz, text*, bool). Each carries explicit getQueryCapabilities()/isQueryable() metadata, emits capability-derived indexes, and drives precise InferPlaintext. Refactors EncryptedTextSearchColumn onto the shared base while preserving its byte-identical config and match-tuning override.
Tighten BuildableQueryColumn so only capability-bearing, queryable columns are accepted by encryptQuery. Widen encryptModel/bulkEncryptModels to any BuildableTable via EncryptedFromBuildableTable (keyed off the _columnType brand), covering both v2 and v3 tables. Introduce a Plaintext type (JsPlaintext | Date | bigint) for the single-value encrypt/encryptQuery entry points so v3 date/timestamptz/int8 domains accept their natural JS values; cast to JsPlaintext only at the FFI boundary (the FFI declares these as CastAs targets but omits them from its JsPlaintext input union).
Expand env-gated client and Postgres suites with representative v3 domains
(storage-only, equality, order, match, bigint, date) and a typed-domain PG
table. Add a v3 CJS export assertion. Fix wasm-inline test resolution by
aliasing the unresolvable @cipherstash/{protect-ffi,auth}/wasm-inline
specifiers to local stubs in vitest.config.ts. Includes the v3 install
script, EQL v3 SQL fixture, and eql-v3 test helper.
- types: BuildableTableColumns fallback never -> Record<never, never> so a structurally BuildableTable-typed value degrades to the model unchanged instead of over-encrypting every field (keyof never is string|number|symbol) - schema/v3: clone-on-write in EncryptedTextSearchColumn.freeTextSearch so caller opts mutated before build() cannot leak into emitted config - test helper: run EQL v3 advisory lock/check/install/unlock on one reserved connection (sql.reserve) instead of across pooled backends - pg test: clean up protect_ci_v3_typed_domains rows in beforeEach/afterAll - install script: quiet dotenv config (drop bare dotenv/config banner) - tests: graceful-degradation, aliased-column, and freeTextSearch clone-on-write regression coverage - docs: replace developer-specific absolute paths with placeholders; wording
…/v3) Add EncryptionV3 / typedClient returning a TypedEncryptionClient<S> that derives types from the concrete table/column builder arguments: - encrypt/encryptQuery pin plaintext to the column domain; encryptQuery constrains queryType to the column capabilities and rejects storage-only columns at compile time - encryptModel/bulkEncryptModels validate schema fields against inferred plaintext (passthrough fields untouched), precise encrypted output - decryptModel/bulkDecryptModels return precise plaintext, reconstructing Date/bigint from the encrypt-config cast_as Binding to the concrete branded v3 classes closes the soundness gap where a non-branded structural table could encrypt at runtime while typed as plaintext. Runtime is unchanged except a per-column Date/bigint reconstruction on model decrypt. v2 client surface is untouched. New @cipherstash/stack/v3 subpath re-exports the v3 builders for a single import.
The native protect-ffi marshals plaintext through JSON serialization, which throws "Do not know how to serialize a BigInt" for JS bigint values. int8/bigint v3 domains now accept/return lossless string plaintext instead; cast_as stays big_int so server-side casting is unchanged. Fixes the failing schema-v3-client live integration test. Also hardens adjacent paths surfaced in review: - encrypt: reject NaN / Infinity number plaintexts - wasm-inline resolveStrategy: guard missing workspaceCrn/accessKey at runtime for JS callers that bypass the compile-time union - docs: correct query API walkthrough lock-context description
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 |
- Bump stack dependency and the e2e Deno wasm-inline pin to 0.28.0 - Regenerate lockfile - 0.28 widens JsPlaintext to include bigint (native i64 round-tripping); update the v3 type test to assert bigint plaintext is now accepted - Rename/refresh the FFI changeset for 0.28
…ngeset) The prior commit f84e4c1 captured only the changeset rename; the actual content edits were left unstaged. This commit carries them: - packages/stack/package.json: protect-ffi 0.27.0 → 0.28.0 - e2e/wasm/deno.json: wasm-inline pin → 0.28.0 - pnpm-lock.yaml: regenerated for 0.28.0 - schema-v3.test-d.ts: assert bigint plaintext now accepted (JsPlaintext widened) - changeset body: 0.27.0 → 0.28.0
…/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.
Native bigint round-tripping landed in protect-ffi 0.28, so restore the bigint domains that skip-v3-bigint deferred: - columns.ts: BIGINT/BIGINT_EQ/BIGINT_ORD/BIGINT_ORD_ORE definitions (concrete domain eql_v3.int8* per the SQL fixture, castAs 'bigint'), EncryptedBigint*Column classes, AnyEncryptedV3Column union, and PlaintextKind/PlaintextFromKind mapping 'bigint' -> JS bigint. - types.ts / index.ts: types.Bigint* factories and barrel exports. - v3-matrix/catalog.ts: four int8 rows with i64-bound samples (max/min/0 and a value past Number.MAX_SAFE_INTEGER); samples widened to bigint. Drizzle operator coverage is catalog-driven, so it picks these up too. - schema-v3.test-d: flip the "bigint not public" negative test to assert the factories now exist. - encryption/v3.ts: bigint needs no decrypt reconstruction (FFI returns a real JS bigint on 0.28). - drop .changeset/skip-v3-bigint.md; add re-enable changeset. i64 bounds are enforced at the protect-ffi boundary. *_ord_ope is out of scope (CIP-3403).
0fa0793 to
17857ee
Compare
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