Skip to content

Add EQL v3 Drizzle support#565

Open
tobyhede wants to merge 15 commits into
feat/eql-v3-text-search-schemafrom
eql-v3-drizzle-concrete-types
Open

Add EQL v3 Drizzle support#565
tobyhede wants to merge 15 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

  • Add @cipherstash/stack/eql/v3/drizzle with concrete v3 Drizzle column types, schema extraction, and capability-checked operators.
  • Emit v3 term-function SQL for equality, order/range, match, ordering, and async combinators.
  • Fix review feedback so same-named columns across tables keep their own v3 builder metadata, and make isEqlV3Column use Drizzle getSQLType() API.

Usage Example

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

const users = pgTable("users", {
  id: integer("id").primaryKey(),
  email: types.TextSearch("email"),
  nickname: types.TextEq("nickname"),
  age: types.Int4Ord("age"),
})

const schema = extractEncryptionSchemaV3(users)
const client = await EncryptionV3({ schemas: [schema] })
const e = createEncryptionOperatorsV3(client)
const db = drizzle(postgresClient)

const rows = await db
  .select()
  .from(users)
  .where(await e.ilike(users.email, "example.com"))
  .orderBy(e.asc(users.age))

Test Plan

  • cd packages/stack && pnpm biome check --write src/eql/v3/drizzle __tests__/drizzle-v3
  • cd packages/stack && pnpm vitest run __tests__/drizzle-v3
  • cd packages/stack && pnpm test:types
  • cd packages/stack && pnpm build

Notes

  • Live Postgres tests are gated and skipped unless DATABASE_URL and CipherStash CS_* credentials are present.

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: 3b7c86d2-b1b9-44c1-99e9-05f2dcc508db

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 clearly 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: 338552a

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


## 4. Architecture & location

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Label the fenced examples.

These bare fences trip MD040 in docs lint and are harder to render/read. Add a language tag such as text to both blocks.

♻️ Proposed fix
-```
+```text
-```
+```text

Also applies to: 227-227

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 113-113: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@docs/superpowers/specs/2026-07-06-eql-v3-drizzle-concrete-types-design.md` at
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.

Source: Linters/SAST tools

}

export function createEncryptionOperatorsV3(client: EncryptionClient) {
const tableCache = new Map<string, AnyV3Table>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does drizzle-orm allow two pgTable() definitions with the same table name in different pgSchema() namespaces?

💡 Result:

Yes, Drizzle ORM allows you to define tables with the same name in different PostgreSQL schemas [1][2][3]. While earlier versions had limitations regarding relational queries when tables shared the same name across different schemas [4], these issues were addressed in drizzle-orm@0.31.4 and subsequent releases [4][1]. To define tables with the same name in different schemas, you use the pgSchema() function to create a schema namespace, and then call the .table() method on that schema object [2][3]. Example usage: import { pgSchema, pgTable, serial, text } from 'drizzle-orm/pg-core'; // Table in the public schema export const users = pgTable('users', { id: serial('id').primaryKey, email: text('email').notNull, }); // Table with the same name in a custom 'auth' schema export const authSchema = pgSchema('auth'); export const authUsers = authSchema.table('users', { id: serial('id').primaryKey, email: text('email').notNull, }); In this example, Drizzle treats these as distinct entities: public.users and auth.users [2][3]. Note that you cannot use pgSchema('public') to define tables in the default schema; you must use the standard pgTable() function for that [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- file outline ---'
ast-grep outline packages/stack/src/eql/v3/drizzle/operators.ts --view expanded || true

echo '--- relevant lines ---'
sed -n '1,170p' packages/stack/src/eql/v3/drizzle/operators.ts | cat -n

echo '--- search for tableCache and resolveContext usage ---'
rg -n "tableCache|drizzleTableOf|resolveContext|extractEncryptionSchemaV3|getEqlV3Column" packages/stack/src/eql/v3/drizzle/operators.ts

Repository: cipherstash/stack

Length of output: 7718


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- relevant Drizzle schema identity docs / code references in repo ---'
rg -n "pgSchema\\(|schema\\)|getTableConfig|drizzle:Name|drizzle:Schema|AnyV3Table|extractEncryptionSchemaV3" packages -g '!**/node_modules/**' || true

Repository: cipherstash/stack

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- lines around cache and resolution ---'
sed -n '1,140p' packages/stack/src/eql/v3/drizzle/operators.ts | cat -n

echo '--- any other cache keyed by table name in this file ---'
rg -n "Map<string, AnyV3Table>|WeakMap|Symbol.for\\('drizzle:Name'\\)|extractEncryptionSchemaV3\\(" packages/stack/src/eql/v3/drizzle/operators.ts

Repository: cipherstash/stack

Length of output: 5613


Key the table cache by table object, not name. pgSchema() tables can share a name across schemas, and the 'unknown' fallback collapses unrelated tables into one slot. That can reuse the wrong AnyV3Table and apply encryption/index metadata to the wrong table; use WeakMap<PgTable, AnyV3Table> instead.

🤖 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` at 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.

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.
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.

1 participant