Skip to content

Commit 2decfb0

Browse files
authored
Merge pull request #565 from cipherstash/eql-v3-drizzle-concrete-types
Add EQL v3 Drizzle support
2 parents 419cc68 + 9c82f50 commit 2decfb0

37 files changed

Lines changed: 3434 additions & 157 deletions

.changeset/eql-v3-drizzle.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@cipherstash/stack": minor
3+
---
4+
5+
Add EQL v3 Drizzle support at `@cipherstash/stack/eql/v3/drizzle`. A Drizzle-native
6+
`types` namespace (same PascalCase names as `@cipherstash/stack/eql/v3`) declares
7+
encrypted columns whose Postgres type is the semantic `public.<domain>`; the concrete
8+
type drives the legal query operators. `createEncryptionOperatorsV3` provides
9+
capability-checked `eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`contains`/`inArray`/
10+
`asc`/`desc`/`and`/`or` that emit the latest two-argument `eql_v3` SQL functions with
11+
full-envelope operands, and
12+
`extractEncryptionSchemaV3` rebuilds the schema for `EncryptionV3`. The existing v2
13+
`@cipherstash/stack/drizzle` integration is unchanged.
14+
15+
The v3 text-search helper is `contains`; obsolete `like`/`ilike` helpers are not
16+
exposed because v3 free-text search is token containment rather than SQL wildcard
17+
matching.

packages/stack/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,87 @@ For columns with `searchableJson: true`, three JSONB operators are available:
366366

367367
These operators encrypt the JSON path selector using the `steVecSelector` query type and cast it to `eql_v2_encrypted` for use with the EQL PostgreSQL functions.
368368

369+
### EQL v3 Concrete-Type Columns (Drizzle)
370+
371+
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.
372+
373+
Declare a Drizzle table using the `types` factories. The suffix encodes the capability: `*Eq` (equality), `*Ord` (order + range, which also covers equality), `*Match` / `TextSearch` (free-text search), and the bare name (e.g. `Text`, `Bigint`) is storage-only.
374+
375+
```ts
376+
import { pgTable, integer } from "drizzle-orm/pg-core"
377+
import { drizzle } from "drizzle-orm/postgres-js"
378+
import {
379+
types,
380+
createEncryptionOperatorsV3,
381+
extractEncryptionSchemaV3,
382+
} from "@cipherstash/stack/eql/v3/drizzle"
383+
import { EncryptionV3 } from "@cipherstash/stack/v3"
384+
385+
// Capabilities come from the concrete type — no flags to configure.
386+
const users = pgTable("users", {
387+
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
388+
email: types.TextEq("email"), // equality: eq / ne / inArray
389+
age: types.IntegerOrd("age"), // order + range: gt/gte/lt/lte, between, asc/desc
390+
bio: types.TextMatch("bio"), // free-text search: contains
391+
balance: types.Bigint("balance"), // storage only (no query capability)
392+
})
393+
```
394+
395+
Derive the v3 schema from the table, build the typed client, and create the capability-checked operators:
396+
397+
```ts
398+
const usersSchema = extractEncryptionSchemaV3(users)
399+
const client = await EncryptionV3({ schemas: [usersSchema] })
400+
const ops = createEncryptionOperatorsV3(client)
401+
402+
const db = drizzle({ client: sqlClient })
403+
```
404+
405+
The operators auto-encrypt their operands and validate them against the column's concrete type. Applying an operator the type doesn't support throws `EncryptionOperatorError`:
406+
407+
```ts
408+
// Equality — email is TextEq
409+
const exact = await db.select().from(users)
410+
.where(await ops.eq(users.email, "alice@example.com"))
411+
412+
// Range + ordering — age is IntegerOrd
413+
const adults = await db.select().from(users)
414+
.where(await ops.gte(users.age, 18))
415+
.orderBy(ops.asc(users.age))
416+
417+
const midBand = await db.select().from(users)
418+
.where(await ops.between(users.age, 25, 40))
419+
420+
// Set membership — built on equality
421+
const listed = await db.select().from(users)
422+
.where(await ops.inArray(users.email, ["alice@example.com", "bob@example.com"]))
423+
424+
// Free-text token containment — bio is TextMatch
425+
const coffee = await db.select().from(users)
426+
.where(await ops.contains(users.bio, "coffee"))
427+
```
428+
429+
Rows are **pre-encrypted** with `client.bulkEncryptModels(...)` before they reach `db.insert(...).values(...)` — Drizzle never sees plaintext. `Bigint` columns take a native JS `bigint`:
430+
431+
```ts
432+
const rows = await client.bulkEncryptModels(
433+
[
434+
{ email: "alice@example.com", age: 30, bio: "climbing and coffee", balance: 100_000n },
435+
{ email: "bob@example.com", age: 41, bio: "cycling and coffee", balance: 250_000n },
436+
],
437+
usersSchema,
438+
)
439+
if (rows.failure) throw new Error(rows.failure.message)
440+
441+
await db.insert(users).values(rows.data)
442+
```
443+
444+
Notes:
445+
446+
- **Free-text search uses `ops.contains`** (token containment on a `match` index), not SQL `like` / `ilike`. It matches whole indexed tokens, so `contains(users.bio, "coffee")` finds rows whose `bio` contains the token `coffee`.
447+
- **The concrete type defines the legal operators.** `TextEq` supports `eq` / `ne` / `inArray` / `notInArray`; `*Ord` types add `gt` / `gte` / `lt` / `lte` / `between` / `notBetween` and `asc` / `desc`; `*Match` and `TextSearch` add `contains`; a bare `Text` / `Integer` / `Bigint` column is storage-only. Using an unsupported operator throws `EncryptionOperatorError`.
448+
- Combine conditions with `ops.and` / `ops.or`, and do NULL checks with `ops.isNull` / `ops.isNotNull` (the where-clause operators are `async` and must be `await`ed; `ops.asc` / `ops.desc` are synchronous).
449+
369450
## Identity-Aware Encryption
370451

371452
Lock encryption to a specific user by requiring a valid JWT for decryption.
@@ -664,6 +745,8 @@ csValue(valueName) // returns ProtectValue (for nested values)
664745
| `@cipherstash/stack/secrets` | `Secrets` class and secrets types |
665746
| `@cipherstash/stack/client` | Client-safe exports (schema builders and types only - no native FFI) |
666747
| `@cipherstash/stack/types` | All TypeScript types (`Encrypted`, `Decrypted`, `ClientConfig`, `EncryptionClientConfig`, query types, etc.) |
748+
| `@cipherstash/stack/v3` | `EncryptionV3` typed client plus the EQL v3 authoring DSL (`encryptedTable`, `types`, v3 type helpers) |
749+
| `@cipherstash/stack/eql/v3/drizzle` | EQL v3 Drizzle integration: `types` column factories, `createEncryptionOperatorsV3`, `extractEncryptionSchemaV3`, `makeEqlV3Column`, `EncryptionOperatorError` |
667750

668751
## Migration from @cipherstash/protect
669752

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
BulkEncryptOperation,
4+
BulkEncryptOperationWithLockContext,
5+
} from '@/encryption/operations/bulk-encrypt'
6+
import type {
7+
BuildableColumn,
8+
BuildableTable,
9+
BulkEncryptPayload,
10+
Client,
11+
} from '@/types'
12+
13+
/**
14+
* `EncryptOperation` rejects NaN / ±Infinity / out-of-int64 `bigint` values
15+
* client-side, before they reach protect-ffi (whose behaviour on such a value
16+
* is unobservable — see `helpers/validation.ts`). `BulkEncryptOperation` must
17+
* enforce the same contract: any caller that batches instead of looping — the
18+
* v3 Drizzle `inArray`, for one — otherwise silently loses the guard.
19+
*
20+
* The stub client is never reached: validation must throw first. If a case
21+
* here ever calls into protect-ffi, the fake client surfaces it as a different
22+
* error than the one asserted.
23+
*/
24+
const client = {} as Client
25+
26+
const column: BuildableColumn = {
27+
getName: () => 'age',
28+
build: () => ({}) as never,
29+
}
30+
31+
const table: BuildableTable = {
32+
tableName: 'users',
33+
build: () => ({ tableName: 'users', columns: {} }),
34+
}
35+
36+
const payload = (...values: unknown[]): BulkEncryptPayload =>
37+
values.map((plaintext) => ({ plaintext })) as BulkEncryptPayload
38+
39+
const lockContext = {
40+
ctsToken: { accessToken: 'token' },
41+
} as never
42+
43+
describe('BulkEncryptOperation numeric validation', () => {
44+
it.each([
45+
[Number.NaN, 'Cannot encrypt NaN value'],
46+
[Number.POSITIVE_INFINITY, 'Cannot encrypt Infinity value'],
47+
[Number.NEGATIVE_INFINITY, 'Cannot encrypt Infinity value'],
48+
[2n ** 70n, 'Cannot encrypt bigint value out of int64 range'],
49+
[-(2n ** 70n), 'Cannot encrypt bigint value out of int64 range'],
50+
])('rejects %s before reaching the FFI', async (value, message) => {
51+
const op = new BulkEncryptOperation(client, payload(value), {
52+
column,
53+
table,
54+
})
55+
56+
const result = await op.execute()
57+
58+
expect(result.failure?.message).toContain(message)
59+
})
60+
61+
it('rejects an invalid value anywhere in the list, not just the first', async () => {
62+
const op = new BulkEncryptOperation(client, payload(30, 42, Number.NaN), {
63+
column,
64+
table,
65+
})
66+
67+
const result = await op.execute()
68+
69+
expect(result.failure?.message).toContain('Cannot encrypt NaN value')
70+
})
71+
72+
it('still passes null entries through without tripping validation', async () => {
73+
const op = new BulkEncryptOperation(client, [{ plaintext: null }], {
74+
column,
75+
table,
76+
})
77+
78+
const result = await op.execute()
79+
80+
expect(result.failure).toBeUndefined()
81+
expect(result.data).toEqual([{ id: undefined, data: null }])
82+
})
83+
84+
it('accepts in-range bigints at the int64 boundary', async () => {
85+
const op = new BulkEncryptOperation(client, payload(9223372036854775807n), {
86+
column,
87+
table,
88+
})
89+
90+
const result = await op.execute()
91+
92+
// Validation passes, so the stub client is reached — proving the boundary
93+
// value was NOT rejected by the numeric guard.
94+
expect(result.failure?.message).not.toContain('out of int64 range')
95+
})
96+
97+
it('enforces the same guard on the lock-context variant', async () => {
98+
const op = new BulkEncryptOperationWithLockContext(
99+
new BulkEncryptOperation(client, payload(Number.NaN), { column, table }),
100+
lockContext,
101+
)
102+
103+
const result = await op.execute()
104+
105+
expect(result.failure?.message).toContain('Cannot encrypt NaN value')
106+
})
107+
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { type SQL, sql } from 'drizzle-orm'
2+
import { integer, PgDialect, pgTable } from 'drizzle-orm/pg-core'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import { getEqlV3Column, isEqlV3Column } from '@/eql/v3/drizzle/column'
5+
import {
6+
createEncryptionOperatorsV3,
7+
EncryptionOperatorError,
8+
} from '@/eql/v3/drizzle/operators'
9+
import { types } from '@/eql/v3/drizzle/types'
10+
11+
// A representative encrypted envelope — what `client.encrypt` actually returns
12+
// and what a bigint column stores. Deliberately NOT a plaintext bigint.
13+
const TERM = { c: 'ct', v: 1 }
14+
const TERM_JSON = JSON.stringify(TERM)
15+
16+
function chainable(result: unknown) {
17+
const op = result as {
18+
withLockContext: ReturnType<typeof vi.fn>
19+
audit: ReturnType<typeof vi.fn>
20+
}
21+
op.withLockContext = vi.fn(() => op)
22+
op.audit = vi.fn(() => op)
23+
return op
24+
}
25+
26+
function setup() {
27+
const encrypt = vi.fn(() => chainable(Promise.resolve({ data: TERM })))
28+
const ops = createEncryptionOperatorsV3({ encrypt })
29+
const dialect = new PgDialect()
30+
const render = (s: SQL) => dialect.sqlToQuery(s)
31+
return { ops, encrypt, render }
32+
}
33+
34+
// A statically-typed table via the drizzle `types` namespace (no dynamic
35+
// matrix, no casts) — the column set is known at compile time.
36+
const accounts = pgTable('accounts', {
37+
id: integer().primaryKey(),
38+
balance: types.BigintOrd('balance'),
39+
ledgerId: types.BigintEq('ledger_id'),
40+
archived: types.Bigint('archived'),
41+
})
42+
43+
describe('v3 drizzle bigint columns', () => {
44+
it('detects a bigint column and reports its concrete public.bigint domain', () => {
45+
const storage = types.Bigint('n')
46+
const ord = types.BigintOrd('n_ord')
47+
48+
expect(isEqlV3Column(storage)).toBe(true)
49+
expect(getEqlV3Column('n', storage)?.getEqlType()).toBe('public.bigint')
50+
expect(getEqlV3Column('n_ord', ord)?.getEqlType()).toBe('public.bigint_ord')
51+
})
52+
53+
it('emits the concrete public.bigint* SQL type through pgTable', () => {
54+
expect(accounts.balance.getSQLType()).toBe('public.bigint_ord')
55+
expect(accounts.ledgerId.getSQLType()).toBe('public.bigint_eq')
56+
expect(accounts.archived.getSQLType()).toBe('public.bigint')
57+
})
58+
59+
it('encrypts a native bigint operand for eq without JSON-stringifying it', async () => {
60+
const { ops, encrypt, render } = setup()
61+
// A value beyond Number.MAX_SAFE_INTEGER to prove the bigint is passed
62+
// through untouched (a JSON.stringify of a bigint would have thrown).
63+
const value = 9223372036854775807n
64+
const q = render(await ops.eq(accounts.ledgerId, value))
65+
66+
expect(q.sql).toContain('eql_v3.eq("accounts"."ledger_id", $1::jsonb)')
67+
expect(q.params).toEqual([TERM_JSON])
68+
expect(encrypt.mock.calls[0]?.[0]).toBe(value)
69+
})
70+
71+
it('emits ordering and range operators for a bigint_ord column', async () => {
72+
const { ops, render } = setup()
73+
74+
const gt = render(await ops.gt(accounts.balance, 10n))
75+
expect(gt.sql).toContain('eql_v3.gt("accounts"."balance", $1::jsonb)')
76+
77+
const between = render(await ops.between(accounts.balance, -5n, 5n))
78+
expect(between.sql).toContain('eql_v3.gte("accounts"."balance", $1::jsonb)')
79+
expect(between.sql).toContain('eql_v3.lte("accounts"."balance", $2::jsonb)')
80+
81+
const asc = render(ops.asc(accounts.balance))
82+
expect(asc.sql).toContain('eql_v3.ord_term("accounts"."balance")')
83+
})
84+
85+
it('rejects equality/ordering on a storage-only bigint column', async () => {
86+
const { ops } = setup()
87+
await expect(ops.eq(accounts.archived, 1n)).rejects.toBeInstanceOf(
88+
EncryptionOperatorError,
89+
)
90+
expect(() => ops.asc(accounts.archived)).toThrow(EncryptionOperatorError)
91+
})
92+
93+
it('rejects a bare SQL expression that is not an encrypted column', async () => {
94+
const { ops } = setup()
95+
await expect(ops.eq(sql`1`, 1n)).rejects.toBeInstanceOf(
96+
EncryptionOperatorError,
97+
)
98+
})
99+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { v3FromDriver, v3ToDriver } from '@/eql/v3/drizzle/codec'
3+
4+
describe('v3 codec', () => {
5+
it('serialises an object to a jsonb string', () => {
6+
expect(v3ToDriver({ v: 1, c: 'ct' })).toBe('{"v":1,"c":"ct"}')
7+
})
8+
9+
it('maps null/undefined to SQL NULL (JS null), never the JSON null literal', () => {
10+
expect(v3ToDriver(null)).toBeNull()
11+
expect(v3ToDriver(undefined)).toBeNull()
12+
})
13+
14+
it('parses a jsonb string back to an object', () => {
15+
expect(v3FromDriver('{"v":1,"c":"ct"}')).toEqual({ v: 1, c: 'ct' })
16+
})
17+
18+
it('passes an already-parsed object through unchanged', () => {
19+
const obj = { v: 1 }
20+
expect(v3FromDriver(obj)).toBe(obj)
21+
})
22+
23+
it('normalises null/undefined to SQL NULL (JS null) on read', () => {
24+
expect(v3FromDriver(null)).toBeNull()
25+
expect(v3FromDriver(undefined)).toBeNull()
26+
})
27+
28+
it('does not throw on a stray bigint in the envelope (defensive)', () => {
29+
expect(v3ToDriver({ v: 1n } as never)).toBe('{"v":"1"}')
30+
})
31+
})

0 commit comments

Comments
 (0)