Skip to content

Add EQL v3 Drizzle support#565

Merged
tobyhede merged 8 commits into
feat/eql-v3-text-search-schemafrom
eql-v3-drizzle-concrete-types
Jul 9, 2026
Merged

Add EQL v3 Drizzle support#565
tobyhede merged 8 commits into
feat/eql-v3-text-search-schemafrom
eql-v3-drizzle-concrete-types

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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.

  • A Drizzle-native types namespace whose columns are the semantic eql_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-argument eql_v3 term-function SQL. The concrete column type drives which operators are legal (e.g. contains needs a match index; asc/between need order).
  • extractEncryptionSchemaV3 rebuilds the encrypt schema for EncryptionV3.
  • Live test coverage — a type-driven 35-domain matrix drives live encrypt/decrypt + SQL query proofs. Recent additions strengthen assertion quality: range exclusion (not just inclusion), type-boundary values through real domain columns, NULL persistence across capability tiers, and lock-context querying through the operator path — plus a CI guard that fails loudly if the live suites silently skip.

The existing v2 @cipherstash/stack/drizzle integration is unchanged. v3 free-text search is token containment, so contains replaces SQL like/ilike (deliberately not exposed).

Usage

import { drizzle } from "drizzle-orm/postgres-js"
import { integer, pgTable } from "drizzle-orm/pg-core"
import postgres from "postgres"
import { EncryptionV3 } from "@cipherstash/stack/v3"
import {
  createEncryptionOperatorsV3,
  extractEncryptionSchemaV3,
  types,
} from "@cipherstash/stack/eql/v3/drizzle"

const users = pgTable("users", {
  id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
  email: types.TextSearch("email"),   // free-text search
  nickname: types.TextEq("nickname"), // equality
  age: types.IntegerOrd("age"),       // order + range
})

const client = await EncryptionV3({ schemas: [extractEncryptionSchemaV3(users)] })
const e = createEncryptionOperatorsV3(client)
const db = drizzle({ client: postgres(process.env.DATABASE_URL!) })

// Filter operators are async (they encrypt the query term); asc/desc are sync.
const rows = await db
  .select()
  .from(users)
  .where(await e.contains(users.email, "example.com"))
  .orderBy(e.asc(users.age))

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 build
  • Live suites (Postgres + eql_v3) run when DATABASE_URL + CS_* are set and self-install the eql_v3 extension at beforeAll. The Drizzle lock-context suite additionally needs USER_JWT and soft-skips without it.

Notes

  • Live tests are credential-gated; a new REQUIRE_LIVE / REQUIRE_LIVE_PG guard turns a silent skip into a CI failure so the live matrix can't quietly go green.
  • The two-argument eql_v3.* function forms are interim (tracked by CIP-3402 / CIP-3423).

Summary by CodeRabbit

  • New Features

    • Added support for a new encrypted database integration with richer query capabilities, including equality, ranges, pattern matching, sorting, and membership checks.
    • Introduced a new set of column types for working with encrypted data while keeping query behavior aligned with the underlying field type.
    • Existing encrypted database integration remains available unchanged.
  • Bug Fixes

    • Improved handling of null and undefined values when reading and writing encrypted JSON-backed fields.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad483a96-f30b-48d3-a14d-3ee3abe1f70f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a new EQL v3 Drizzle integration under @cipherstash/stack/eql/v3/drizzle, including a JSONB codec, a column adapter with domain discovery/stashing, a PascalCase types namespace, an eql_v3 SQL dialect, capability-checked encryption operators, and schema extraction, plus package export wiring, build config updates, design docs, and comprehensive tests.

Changes

EQL v3 Drizzle Integration

Layer / File(s) Summary
Design docs and changeset
docs/superpowers/specs/2026-07-06-eql-v3-drizzle-concrete-types-design.md, .changeset/eql-v3-drizzle.md
Documents the concrete-type v3 architecture, module layout, operator/error semantics, and testing plan; declares a minor release.
JSONB codec
packages/stack/src/eql/v3/drizzle/codec.ts, packages/stack/__tests__/drizzle-v3/codec.test.ts
Adds v3ToDriver/v3FromDriver for JSONB serialization with null/undefined handling.
Column adapter
packages/stack/src/eql/v3/drizzle/column.ts, packages/stack/__tests__/drizzle-v3/column.test.ts
Adds EQL_V3_DOMAINS, makeEqlV3Column, getEqlV3Column, isEqlV3Column for stashing/recovering v3 builders on Drizzle columns.
Types namespace
packages/stack/src/eql/v3/drizzle/types.ts, packages/stack/__tests__/drizzle-v3/types.test.ts, types.test-d.ts
Exposes a types object mirroring @/eql/v3 types, wrapping factories with makeEqlV3Column.
SQL dialect
packages/stack/src/eql/v3/drizzle/sql-dialect.ts, packages/stack/__tests__/drizzle-v3/sql-dialect.test.ts
Adds v3Dialect generating eql_v3 term-function SQL for equality, comparison, range, match, orderBy.
Schema extraction
packages/stack/src/eql/v3/drizzle/schema-extraction.ts, packages/stack/__tests__/drizzle-v3/schema-extraction.test.ts
Adds extractEncryptionSchemaV3 to rebuild EncryptionV3 schemas from Drizzle tables.
Encryption operators
packages/stack/src/eql/v3/drizzle/operators.ts, packages/stack/__tests__/drizzle-v3/operators.test.ts, operators-live-pg.test.ts
Adds createEncryptionOperatorsV3 and EncryptionOperatorError with capability-checked eq/ne/comparison/range/match/inArray/order/boolean operators.
Export wiring and build
packages/stack/src/eql/v3/drizzle/index.ts, packages/stack/package.json, packages/stack/tsup.config.ts, packages/stack/__tests__/drizzle-v3/exports.test.ts
Adds the barrel export and wires new ./eql/v3/drizzle package export/build entry.

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
Loading

Possibly related issues

Suggested reviewers: coderdan

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding EQL v3 Drizzle support.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch eql-v3-drizzle-concrete-types

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.

@changeset-bot

changeset-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9c82f50

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

This PR includes changesets to release 6 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e 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

@tobyhede tobyhede changed the base branch from main to feat/eql-v3-text-search-schema July 6, 2026 06:56
@tobyhede tobyhede marked this pull request as ready for review July 6, 2026 06:57
@tobyhede tobyhede requested a review from a team as a code owner July 6, 2026 06:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
packages/stack/__tests__/drizzle-v3/column.test.ts (1)

11-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test 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 internal customTypeParams/config shape called out as fragile in column.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 win

Unbounded concurrent encryption calls for large inArray/notInArray inputs.

Each element triggers its own equality()encryptTerm() round trip, and all are fired concurrently via Promise.all with no batching or concurrency cap. A large values array could spike load on the encryption/KMS backend.

Consider chunking or capping concurrency (e.g. via p-limit or 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 value

Consider Drizzle's is() guard instead of blind any casts for column internals.

drizzleTableOf/resolveContext reach into (column as any).table / .name. Drizzle-orm exposes is(value, Column) specifically to narrow such values safely without any, which would also guard against non-Column SQLWrapper inputs 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

📥 Commits

Reviewing files that changed from the base of the PR and between 63fe076 and 863e521.

📒 Files selected for processing (20)
  • .changeset/eql-v3-drizzle.md
  • docs/superpowers/specs/2026-07-06-eql-v3-drizzle-concrete-types-design.md
  • packages/stack/__tests__/drizzle-v3/codec.test.ts
  • packages/stack/__tests__/drizzle-v3/column.test.ts
  • packages/stack/__tests__/drizzle-v3/exports.test.ts
  • packages/stack/__tests__/drizzle-v3/operators-live-pg.test.ts
  • packages/stack/__tests__/drizzle-v3/operators.test.ts
  • packages/stack/__tests__/drizzle-v3/schema-extraction.test.ts
  • packages/stack/__tests__/drizzle-v3/sql-dialect.test.ts
  • packages/stack/__tests__/drizzle-v3/types.test-d.ts
  • packages/stack/__tests__/drizzle-v3/types.test.ts
  • packages/stack/package.json
  • packages/stack/src/eql/v3/drizzle/codec.ts
  • packages/stack/src/eql/v3/drizzle/column.ts
  • packages/stack/src/eql/v3/drizzle/index.ts
  • packages/stack/src/eql/v3/drizzle/operators.ts
  • packages/stack/src/eql/v3/drizzle/schema-extraction.ts
  • packages/stack/src/eql/v3/drizzle/sql-dialect.ts
  • packages/stack/src/eql/v3/drizzle/types.ts
  • packages/stack/tsup.config.ts

Comment thread packages/stack/src/eql/v3/drizzle/operators.ts Outdated
Comment thread packages/stack/src/eql/v3/drizzle/schema-extraction.ts
freshtonic added a commit that referenced this pull request Jul 6, 2026
#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.
@tobyhede tobyhede force-pushed the eql-v3-drizzle-concrete-types branch 4 times, most recently from c43777e to dbe1ec0 Compare July 7, 2026 04:28
@tobyhede tobyhede changed the base branch from feat/eql-v3-text-search-schema to james/cip-3291-bigint-stack July 7, 2026 04:29
@tobyhede tobyhede requested a review from freshtonic July 7, 2026 04:33
Comment thread packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts Outdated
Comment thread packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts
Comment on lines +15 to +20
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')
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 coderdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) calls JSON.parse on 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 in codec.test.ts — worth a one-line test if you want the throw pinned.

No overflow beyond the cap.

Comment thread packages/stack/src/encryption/v3.ts
Comment thread packages/stack/src/eql/v3/drizzle/schema-extraction.ts
Comment thread packages/stack/src/eql/v3/drizzle/column.ts
Comment thread packages/stack/src/eql/v3/drizzle/operators.ts Outdated
Comment thread packages/stack/src/eql/v3/drizzle/operators.ts
Comment on lines +142 to +148
const skipUnlessJwt = (): boolean => {
if (!userJwt) {
console.log('Skipping lock-context operator test - no USER_JWT provided')
return true
}
return false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We'd probably miss this is no JWT set. In fact, I'm dubious that there is one set now.

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

@coderdan

coderdan commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Code review — EQL v3 Drizzle integration + bigint domains

Reviewed at extra-high effort (multi-angle finder pass → verify → sweep) against dbe1ec0. The change is well-tested (deterministic + live-pg matrix), which refuted several plausible bugs (see bottom). 12 findings survived, correctness first.

Correctness / robustness

1. bigint domains are type-blessed but have no runtime guard → opaque low-level errorpackages/stack/src/eql/v3/columns.ts:166 (also types.ts:128, Plaintext in types.ts:96)
types.Bigint/BigintEq/… are exported publicly and PlaintextForColumn resolves them to bigint, but the only value validator (assertValidNumericValue, helpers/validation.ts:43) checks typeof === 'number', so a bigint passes every guard to protect-ffi 0.27, which cannot marshal a JS BigInt across the Neon boundary.
Failure: client.encrypt(123n, { table, column }) on a types.BigintEq column type-checks and passes unit CI (FFI mocked), then fails at runtime with a cryptic Neon/serialization error instead of a clear "bigint not yet supported" SDK error. On the drizzle operator path the throw isn't wrapped in EncryptionOperatorError. The gate is prose comments only — an explicit runtime guard on cast_as === 'bigint' (or not exporting the factories until the pin bumps) would keep the type/runtime contract from diverging silently.

2. decryptModel/bulkDecryptModels key the reconstructor by table object identity → a valid, correctly-typed call can return a failurepackages/stack/src/encryption/v3.ts:217 (Map built at :190)
EncryptedTable is structurally typed (tableName: string, brand is only the phantom _columnType), so two separately-constructed but identical tables are the same type yet different objects. The old code built rowReconstructor(table) from whatever table was passed; the new code does reconstructors.get(table) and returns unknownTableFailure on a miss.
Failure: const c = await EncryptionV3({schemas:[usersA]}) then c.decryptModel(row, usersB) where usersB = encryptedTable('users', {…same…}) (a re-import, HMR reload, or table built in another module). Compiles (structural match), but at runtime returns DecryptionError: "…a table this client was not initialized with…" instead of decrypting — a regression vs. prior behavior. Keying by tableName would match the semantic identity the FFI/build() already use.

3. Drizzle customType declares plaintext data types but toDriver only JSON-serializes (no encryption)packages/stack/src/eql/v3/drizzle/codec.ts:10 (type declared in column.ts:88)
The column is typed data: PlaintextForColumn<C> (Date/bigint/number/string) with toDriver(value) = JSON.stringify(value). The intended flow inserts pre-encrypted envelopes (live tests cast through as never), but nothing in the types enforces that.
Failure: For types.Bigint, the type-valid db.insert(t).values({ n: 1n }) calls JSON.stringify(1n) → throws TypeError: Do not know how to serialize a BigInt synchronously. For date/string, the same type-valid plaintext insert bypasses encryption and JSON-stringifies plaintext into the eql_v3 column (a plaintext-exposure footgun — some domain CHECK constraints may reject the non-envelope shape, but the type actively invites the write). Consider typing data as the encrypted envelope, or documenting/guarding the insert path.

4. Drizzle types mirror can't reach EncryptedTextSearchColumn.freeTextSearch(opts) → tokenization silently locked to defaultspackages/stack/src/eql/v3/drizzle/types.ts:11
Each factory is (name) => makeEqlV3Column(factory(name)), returning a Drizzle column — there's no way to call the chainable .freeTextSearch(opts) tuner. The canonical flow (extractEncryptionSchemaV3(table)EncryptionV3({schemas}), per the live tests) bakes the recovered builder's build().indexes.match into the real encrypt config.
Failure: A drizzle user who needs custom tokenizer/k/m (short-substring or CJK free-text) has no drizzle-path way to set it; every text_search column gets defaultMatchOpts() ngram settings, and contains() behaves against the default tokenization with no error or warning. The JSDoc claims to "mirror" the v3 types namespace but omits this capability.

5. bigint decrypt is unsound once the encrypt gate liftspackages/stack/src/eql/v3/columns.ts:681 (reconstructor at encryption/v3.ts:147)
PlaintextFromKind types bigint columns as bigint, and rowReconstructor intentionally passes them through (only DATE_LIKE_CASTS are rebuilt). The FFI cannot return a JS bigint, so a decrypted bigint column would hold a number/string while typed bigint. Currently masked because encrypt throws first (finding 1).
Failure: After the FFI pin bumps, decryptModel returns the FFI's serialized form for a bigint column but the type asserts bigint; downstream value + 1n throws Cannot mix BigInt and other types. Worth a reconstruction step (or a test) landing alongside the pin bump.

Efficiency

6. inArray/notInArray re-resolve the column context and call build() once per array elementpackages/stack/src/eql/v3/drizzle/operators.ts:269
inArrayOp maps every value through equality(...), and each equality runs resolveContextgetEqlV3Column + builder.build().indexes (:132). The context is identical for all N values.
Failure: inArray(col, [...1000 ids]) triggers 1000 getEqlV3Column calls and 1000 build() invocations (for a text_search column each build() deep-clones the match block). Resolve context once, then reuse it per operand (keep the concurrency-4 encryption). A WeakMap<builder, indexes> memo on build() would also fix per-operator rebuilds.

7. between/notBetween encrypt min then max sequentiallypackages/stack/src/eql/v3/drizzle/operators.ts:237
const encMin = await encryptOperand(...) then const encMax = await encryptOperand(...) — the two operands are independent.
Failure: Every range predicate pays 2× encrypt latency (round-trips to the crypto backend). const [encMin, encMax] = await Promise.all([...]) halves it.

Cleanup / conventions

8. EncryptionOperatorError is redeclared, forking a class that already exists and is exported by v2packages/stack/src/eql/v3/drizzle/operators.ts:29
v2 (src/drizzle/operators.ts:82, re-exported from src/drizzle/index.ts) defines an identical-shaped EncryptionOperatorError. Two distinct runtime class identities: a consumer's catch (e) { if (e instanceof EncryptionOperatorError) } against one import silently fails to match the other, and the shared context shape must be edited in two places. Lift to one shared module.

9. A Linear issue ID appears in a source comment (and in the changeset)packages/stack/src/eql/v3/columns.ts:165 (also .changeset/eql-v3-bigint-domains.md lines 6 and 10)
Internal issue trackers shouldn't leak into this public repo's source or its published CHANGELOG. Drop the IDs or reword. (IDs omitted here for the same reason.)

10. Drizzle table-name extraction via Symbol.for('drizzle:Name') is hand-inlined across ~5 sitespackages/stack/src/eql/v3/drizzle/schema-extraction.ts:11 (again at operators.ts:115; v2 has getDrizzleTableName)
The Drizzle-internal drizzle:Name contract is hard-coded in multiple places with no single source of truth; a Drizzle upgrade that changes it breaks all of them independently. Share one getDrizzleTableName(table) helper.

11. EQL_V3_DOMAINS and buildersByDomain run the same probe iteration twice and can driftpackages/stack/src/eql/v3/drizzle/column.ts:10
Both do Object.values(v3Types).map(f => f('__probe__').getEqlType()), constructing throwaway probe columns per domain. EQL_V3_DOMAINS is exactly buildersByDomain's key set — derive it: new Set(buildersByDomain.keys()).

12. Dead EQL_V3_COLUMN_LEGACY_PARAM (_eqlv3Column) redundancypackages/stack/src/eql/v3/drizzle/column.ts:30
_eqlv3Column is never read outside this file; writeBuilder always sets it together with the Symbol, and readBuilder returns the Symbol first via ??, so the legacy branch is unreachable (the Symbol carrier is confirmed to survive pgTable via config.customTypeParams). The const, type member, extra write, and ?? branch can be deleted; "legacy" also implies a nonexistent external contract.


Cleared during verification (not bugs): eql_v3.eq/neq on ORE-only *_ord columns (live-pg matrix proves exact/complement selection); applyOperationOptions discarding withLock.audit()'s return (audit() mutates this and returns this); resolveMatchOpts vs. the old inline merge (behavior-identical, now safer — it clones for v2 too); EncryptedTextSearchColumn.build() super.build()+spread (byte-identical index block); V3DecryptedModel = V3ModelInput alias (textually identical to the prior mapped type); DATE_LIKE_CASTS filter (same set as === 'date' || === 'timestamp'); new Date(value) reconstruction (correct instant for the UTC samples; unchanged by this PR); mapWithConcurrency (no race, no unhandled-rejection leak); stash recovery before/after pgTable; and the batch-encrypt-query.ts change (type-only, runtime unchanged).

🤖 Generated with Claude Code

@tobyhede tobyhede force-pushed the eql-v3-drizzle-concrete-types branch 2 times, most recently from 17857ee to 2f89186 Compare July 9, 2026 02:26
@tobyhede tobyhede changed the base branch from james/cip-3291-bigint-stack to feat/eql-v3-text-search-schema July 9, 2026 04:23
@tobyhede tobyhede force-pushed the eql-v3-drizzle-concrete-types branch from 2706592 to db0c8ed Compare July 9, 2026 04:48
Comment on lines +54 to +56
expect(accounts.balance.getSQLType()).toBe('public.bigint_ord')
expect(accounts.ledgerId.getSQLType()).toBe('public.bigint_eq')
expect(accounts.archived.getSQLType()).toBe('public.bigint')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Type names will need to be updated when @freshtonic's stuff lands.

tobyhede added 3 commits July 9, 2026 14:50
…ains)

Adds the EQL v3 Drizzle integration at @cipherstash/stack/eql/v3/drizzle:
drizzle-native column types, capability-checked operators emitting the
two-argument eql_v3 SQL functions, and schema extraction for EncryptionV3.

Replayed onto feat/eql-v3-bigint-restack, which already provides the FFI
0.28 concrete types, public.* domains, the bigint family, and the
eqlVersion:3 fix. The adapter is namespace-agnostic (domains derived via
getEqlType); tests updated to the base's public.<domain> naming while
operator functions remain eql_v3.*.

Superseded local work (concrete-types/bigint/FFI-0.28 re-vendor, redundant
live guards, parallel matrix overhaul) dropped in favour of the base's
versions. Original granular history retained in
backup/eql-v3-drizzle-concrete-types-pre-ffi028-public-rebase.
typedClient() cached one row-reconstructor per schema table, keyed by the
table OBJECT. AnyV3Table is structurally typed, so a table re-imported across
modules, rebuilt after HMR, or re-derived via extractEncryptionSchemaV3 (the
Drizzle flow) is a distinct object with the same name — object-identity keying
returned a spurious 'unknown table' DecryptionError for that valid call. Key by
table.tableName (the identity the FFI encrypt config and build() already use).
Backward-compatible; adds tests for structural-match success and unknown-table
failure.

Note: affects only the v3 typedClient wrapper (in-repo, only the Drizzle
adapter routes through it); isolated so it can be split to the base branch.
In v3 the concrete type defines capabilities, so the vestigial v2-style
chainable .freeTextSearch(opts) tuner on EncryptedTextSearchColumn is obsolete.
Remove the method, its matchOpts field, and the build() override that only
cloned tuner opts; default match building is unchanged (the base build() already
emits the default match config for the TextSearch type). The 'freeTextSearch'
query-type/capability is untouched. Drops the now-obsolete tuner tests.
tobyhede added 2 commits July 9, 2026 14:50
…, perf)

- A3: type the Drizzle customType around the stored Encrypted envelope, not
  plaintext — inserts take pre-encrypted rows, select returns envelopes; the
  codec is bigint-safe. Removes the as-never/Record casts on the insert path.
- M1: type createEncryptionOperatorsV3 structurally ({ encrypt }) so the
  EncryptionV3() return value is accepted without a cast; add operators.test-d
  guarding it (the typecheck surface that catches the regression).
- Add bigint drizzle coverage (unit + live round-trip).
- B6: memoize per-column context so inArray builds once, not per element.
- C10: extract a shared drizzle:Name helper. C8: document the intentional
  v2/v3 EncryptionOperatorError fork. m3/m4: correct stale public.* comment and
  error text. TSDoc for the factory and ~20 operators.
- README: document @cipherstash/stack/eql/v3/drizzle.
The identity/lock-context live suites soft-skip on a missing USER_JWT, which the
CI coverage guard never asserted. Add a LIVE_LOCK_CONTEXT_ENABLED flag and a
guard assertion, kept as it.skip until the USER_JWT CI secret is provisioned
(flip to it.runIf(IN_CI) then) so CI does not fail on the not-yet-present secret.
@tobyhede tobyhede force-pushed the eql-v3-drizzle-concrete-types branch from db0c8ed to dcae4a2 Compare July 9, 2026 04:53
Comment thread packages/stack/README.md

### EQL v3 Concrete-Type Columns (Drizzle)

The `@cipherstash/stack/eql/v3/drizzle` module targets EQL v3, where each encrypted column is a **concrete Postgres domain type** (`eql_v3.text_eq`, `eql_v3.integer_ord`, …). Instead of toggling capability flags, you pick a concrete type from the `types` namespace and its query capabilities are fixed by that choice. The v2 `@cipherstash/stack/drizzle` module above is unchanged — this is an additive export.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Flagging types need update

Comment on lines +91 to +96
// DEFERRED (follow-up): the CI `USER_JWT` secret is not yet provisioned, so
// enforcing this now would fail CI. Skipped until the secret exists — flip
// back to `it.runIf(IN_CI)` at that point. It still documents the real gap:
// the identity / lock-context live suites soft-skip on a missing USER_JWT, so
// once the secret lands this guard turns a silent whole-suite skip into a
// loud failure (as the CS_*/DATABASE_URL guards already do).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should make sure there is an issue tracked

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A couple of test gaps flagged and we'll need to revise the public type names either here or in a follow-up. But this looks excellent!

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed with a multi-angle pass (line-by-line, removed-behavior, cross-file tracing, reuse/simplification/efficiency/altitude), each candidate independently verified against the head commit and the installed drizzle-orm 0.45.2 / vendored EQL v3 SQL. The integration is solid: production wiring (exports map ↔ tsup entry ↔ barrel), extractEncryptionSchemaV3EncryptionV3 consistency, and the public.* domain / eql_v3.* function split all check out, and several scary-sounding candidates were refuted along the way (details at the end). Findings, most severe first:

1. packages/stack/src/eql/v3/drizzle/sql-dialect.ts:17v3Dialect.range emits an unparenthesized gte(...) AND lte(...) fragment, so ops.not(await ops.between(...)) silently returns wrong rows (CONFIRMED)

Drizzle's not renders not ${condition} with no parens (drizzle-orm 0.45.2 conditions.js:58-60), and this module re-exports it unchanged. NOT eql_v3.gte(col, x) AND eql_v3.lte(col, y) parses as (NOT gte) AND lte — for between(age, 25, 40) that's age < 25, not the intended complement. Your own notBetween proves the fragment is unsafe bare: it wraps in sql\NOT (...)`atoperators.ts:299. Tests cover not(contains(...))(an atomic call, safe) but nevernot(between(...)). Fix at the dialect level — parenthesize inside range` itself — so every composition is safe, rather than relying on each caller to wrap.

2. packages/stack/src/eql/v3/drizzle/operators.ts:328inArray/notInArray pay N FFI round-trips where one bulk call works (CONFIRMED)

Each value goes through a separate client.encrypt() crossing, four at a time (MAX_IN_ARRAY_CONCURRENCY). The v2 adapter already batches its inArray in a single call (src/drizzle/operators.ts encryptedInArray), and the v3 TypedEncryptionClient exposes bulkEncrypt(plaintexts, opts) with exactly the shape needed (N plaintexts, one {table, column}). Widening OperandEncryptionClient to include bulkEncrypt collapses an N=100 inArray from ~25 waves of FFI crossings to one.

3. __tests__/drizzle-v3/column.test.ts:12, operators.test.ts:57, operators-live-pg.test.ts:41 — three slug helpers still strip the old eql_v3. prefix (CONFIRMED)

eqlType values are now public.*, so the replace is a no-op and the live suite emits column identifiers like "public.integer_ord". Self-consistent (everything routes through the same no-op) so nothing fails, which is exactly why it slipped through — but the doc comments claim a bare integer_ord and the generated DDL is misleading. Same bug family exists in the sibling v3-matrix live tests. Suggest one shared slug in a common test helper so the next prefix change is a single edit.

4. packages/stack/src/eql/v3/drizzle/column.ts:26-27 — the "legacy" string key _eqlv3Column is redundant alongside the Symbol key (CONFIRMED)

This is brand-new code with no prior on-disk format. In drizzle-orm 0.45.2 the builder→column path passes config by reference (custom.js:12-16, column.js:5) and customTypeParams is never cloned, so the Symbol.for key always survives; Symbol.for is registry-global so CJS/ESM duality can't break it either, and no test exercises the string fallback. Every read/write maintaining two keys that can never legitimately diverge is pure drift surface — a single Symbol key suffices.

5. packages/stack/src/eql/v3/drizzle/sql-dialect.ts:9-25 — the eql_v3. function-schema prefix is hardcoded in all five dialect helpers

Domains are already centralized (derived from columns.ts via getEqlType()), but the invoked functions repeat the literal eql_v3. prefix five times, plus in test assertions. Given the domain rename that just swept this branch (eql_v3.*public.*), a future function-schema move must hand-edit every site — and the unit tests assert the literal string while the live suite skips without credentials, so a half-migration ships green. One EQL_V3_FN prefix constant makes it a one-line change.

6. __tests__/live-coverage-guard.test.ts:97 — the USER_JWT guard is it.skip'd, so the new lock-context suite has zero effective CI coverage today

operators-lock-context-live-pg.test.ts soft-skips when USER_JWT is absent (the normal state until the secret is provisioned), and the guard designed to make that loud is disabled. Understood this is staged deliberately — flagging it so provisioning the secret and un-skipping doesn't slip; until then a lock-context regression ships undetected, which is the exact failure mode this guard file exists to prevent.

7. Changeset omission — the .freeTextSearch(opts) tuner removal isn't mentioned anywhere

The PR removes per-column match tuning from EncryptedTextSearchColumn (match index now always default config) along with the six tests covering its merge semantics. The code comments document the new behavior and there are no v3 callers, so the removal itself is fine — but neither eql-v3-drizzle.md nor the earlier text-search changeset records that tuning was dropped. Worth a line so the capability change is traceable.

8. packages/stack/src/eql/v3/drizzle/operators.ts:260 — the equality gate is inlined while every other operator uses requireIndex

equality() hand-rolls if (!ctx.indexes.unique && !ctx.indexes.ore) with its own message format; comparison/range/contains/orderTerm all route through requireIndex. Extending the helper to accept an index set keeps the "what grants equality" rule in one place and the diagnostics consistent — today a future change to gating semantics has to be made twice.


Checked and refuted, for the record: ops.eq on an ore-only IntegerOrd column is correct — Postgres selects the domain-specific eql_v3.eq(a public.integer_ord, b jsonb) overload which compares ord_terms (no HMAC needed), and the live matrix includes integer_ord in its equality domains and asserts the row comes back. The reconstructors Map in encryption/v3.ts can't collide on tableName (duplicate names throw in buildEncryptConfig at init), and rejecting unregistered tables in decryptModel is intentional and directly tested. Single-element inArray under not() is safe (atomic function-call term). The EncryptionOperatorError fork is explicitly documented as intentional.

tobyhede added 3 commits July 9, 2026 15:14
The rebase moved the v3 catalog keys from eql_v3.* to public.*, but the
drizzle-v3 test slug helper still stripped the 'eql_v3.' prefix — a no-op
against public.* keys. It left every matrix column named 'public.<domain>'
(with a dot) instead of '<domain>'. The mocked unit suites passed (they assert
slug() against itself), but the LIVE operators-live-pg matrix failed at
bulkEncryptModels with 'Cannot convert undefined or null to object' because
protect-ffi cannot resolve a dotted column identifier. Strip '^public.' to
match the passing v3-matrix suites (matrix-live-pg) and yield clean column
names. Test-only; no source change.
…tion

v3Dialect.range emitted a bare `eql_v3.gte(..) AND eql_v3.lte(..)`. Drizzle's
`not` renders `not ${condition}` with no parentheses, and Postgres binds NOT
tighter than AND, so `ops.not(await ops.between(age, 25, 40))` ran as
`(NOT gte) AND lte` — i.e. `age < 25` — silently returning rows below the range
instead of its complement. `range` now emits a self-contained parenthesised
conjunction, so every composition is safe rather than each caller having to
wrap. `notBetween`'s manual `NOT (...)` wrapper becomes redundant.

inArray/notInArray encrypted one value per FFI crossing (4 at a time). They now
encrypt the whole list in a single `bulkEncrypt` call. `OperandEncryptionClient`
gains an optional `bulkEncrypt`, so `{ encrypt }`-only clients stay valid and
fall back to the existing concurrency pool. A bulk response whose length differs
from the input is rejected rather than truncated, which would otherwise widen an
inArray or narrow a notInArray.

That batching exposed a gap: EncryptOperation rejects NaN/±Infinity/out-of-int64
bigint client-side, BulkEncryptOperation did not — so routing operands through
the bulk path lost the guard for every bulkEncrypt caller. Validation now lives
in the shared `createEncryptPayloads`, restoring parity for both bulk classes.

Also: `requireIndex` takes a disjunction of index keys, so equality (unique OR
ore) routes through it like every other operator and gains the offending-domain
diagnostic; the redundant `_eqlv3Column` string key is dropped in favour of the
Symbol.for key that provably survives the builder→column path; the `eql_v3.`
function prefix is centralised behind EQL_V3_FN_SCHEMA; and the five copies of
the domain-slug helper collapse into one shared `eqlTypeSlug`.

Regression tests were written first and verified by mutation: reverting each fix
fails at least one test. The live `not(between)` proof anchors its bound at the
smaller seeded value — anchored at the larger one, the buggy `col < bound` and
the correct `col != bound` select the same row and the test is vacuous.

No changeset: the v3 Drizzle adapter is unreleased (CHANGELOG is at 0.18.0 with
no eql/v3), so these fixes fold into the pending eql-v3-drizzle changeset.
The bigint_ord/bigint_ord_ore domains are ORE-indexed, so their i64
bigint samples flow into comparePlain, which only handled Date/number/
string and threw "Unsupported ordered values" at runtime. Add a bigint
branch (mirroring matrix-live-pg's oracle) and widen PlainValue.
@tobyhede tobyhede merged commit 2decfb0 into feat/eql-v3-text-search-schema Jul 9, 2026
7 checks passed
@tobyhede tobyhede deleted the eql-v3-drizzle-concrete-types branch July 9, 2026 06:17
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.

3 participants