-
Notifications
You must be signed in to change notification settings - Fork 4
Add EQL v3 Drizzle support #565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
tobyhede
merged 8 commits into
feat/eql-v3-text-search-schema
from
eql-v3-drizzle-concrete-types
Jul 9, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
d73a03c
feat(stack): EQL v3 Drizzle adapter on protect-ffi 0.28 (public.* dom…
tobyhede ba99c79
fix(stack): resolve v3 typedClient decrypt reconstructors by tableName
tobyhede 8123839
refactor(stack): remove obsolete v3 freeTextSearch tuner
tobyhede f281167
feat(stack): strongly-typed v3 Drizzle adapter (envelope typing, docs…
tobyhede dcae4a2
test(stack): stage USER_JWT live-coverage guard (skipped pending secret)
tobyhede 5e9875e
fix(stack): correct stale slug in v3 drizzle tests (eql_v3. -> public.)
tobyhede 1be5b5e
fix(stack): parenthesise v3 range, batch inArray, restore bulk valida…
tobyhede 9c82f50
fix(stack): compare bigint order domains in v3 drizzle operators oracle
tobyhede File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| --- | ||
| "@cipherstash/stack": minor | ||
| --- | ||
|
|
||
| Add EQL v3 Drizzle support at `@cipherstash/stack/eql/v3/drizzle`. A Drizzle-native | ||
| `types` namespace (same PascalCase names as `@cipherstash/stack/eql/v3`) declares | ||
| encrypted columns whose Postgres type is the semantic `public.<domain>`; the concrete | ||
| type drives the legal query operators. `createEncryptionOperatorsV3` provides | ||
| capability-checked `eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`contains`/`inArray`/ | ||
| `asc`/`desc`/`and`/`or` that emit the latest two-argument `eql_v3` SQL functions with | ||
| full-envelope operands, and | ||
| `extractEncryptionSchemaV3` rebuilds the schema for `EncryptionV3`. The existing v2 | ||
| `@cipherstash/stack/drizzle` integration is unchanged. | ||
|
|
||
| The v3 text-search helper is `contains`; obsolete `like`/`ilike` helpers are not | ||
| exposed because v3 free-text search is token containment rather than SQL wildcard | ||
| matching. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
107 changes: 107 additions & 0 deletions
107
packages/stack/__tests__/bulk-encrypt-validation.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { | ||
| BulkEncryptOperation, | ||
| BulkEncryptOperationWithLockContext, | ||
| } from '@/encryption/operations/bulk-encrypt' | ||
| import type { | ||
| BuildableColumn, | ||
| BuildableTable, | ||
| BulkEncryptPayload, | ||
| Client, | ||
| } from '@/types' | ||
|
|
||
| /** | ||
| * `EncryptOperation` rejects NaN / ±Infinity / out-of-int64 `bigint` values | ||
| * client-side, before they reach protect-ffi (whose behaviour on such a value | ||
| * is unobservable — see `helpers/validation.ts`). `BulkEncryptOperation` must | ||
| * enforce the same contract: any caller that batches instead of looping — the | ||
| * v3 Drizzle `inArray`, for one — otherwise silently loses the guard. | ||
| * | ||
| * The stub client is never reached: validation must throw first. If a case | ||
| * here ever calls into protect-ffi, the fake client surfaces it as a different | ||
| * error than the one asserted. | ||
| */ | ||
| const client = {} as Client | ||
|
|
||
| const column: BuildableColumn = { | ||
| getName: () => 'age', | ||
| build: () => ({}) as never, | ||
| } | ||
|
|
||
| const table: BuildableTable = { | ||
| tableName: 'users', | ||
| build: () => ({ tableName: 'users', columns: {} }), | ||
| } | ||
|
|
||
| const payload = (...values: unknown[]): BulkEncryptPayload => | ||
| values.map((plaintext) => ({ plaintext })) as BulkEncryptPayload | ||
|
|
||
| const lockContext = { | ||
| ctsToken: { accessToken: 'token' }, | ||
| } as never | ||
|
|
||
| describe('BulkEncryptOperation numeric validation', () => { | ||
| it.each([ | ||
| [Number.NaN, 'Cannot encrypt NaN value'], | ||
| [Number.POSITIVE_INFINITY, 'Cannot encrypt Infinity value'], | ||
| [Number.NEGATIVE_INFINITY, 'Cannot encrypt Infinity value'], | ||
| [2n ** 70n, 'Cannot encrypt bigint value out of int64 range'], | ||
| [-(2n ** 70n), 'Cannot encrypt bigint value out of int64 range'], | ||
| ])('rejects %s before reaching the FFI', async (value, message) => { | ||
| const op = new BulkEncryptOperation(client, payload(value), { | ||
| column, | ||
| table, | ||
| }) | ||
|
|
||
| const result = await op.execute() | ||
|
|
||
| expect(result.failure?.message).toContain(message) | ||
| }) | ||
|
|
||
| it('rejects an invalid value anywhere in the list, not just the first', async () => { | ||
| const op = new BulkEncryptOperation(client, payload(30, 42, Number.NaN), { | ||
| column, | ||
| table, | ||
| }) | ||
|
|
||
| const result = await op.execute() | ||
|
|
||
| expect(result.failure?.message).toContain('Cannot encrypt NaN value') | ||
| }) | ||
|
|
||
| it('still passes null entries through without tripping validation', async () => { | ||
| const op = new BulkEncryptOperation(client, [{ plaintext: null }], { | ||
| column, | ||
| table, | ||
| }) | ||
|
|
||
| const result = await op.execute() | ||
|
|
||
| expect(result.failure).toBeUndefined() | ||
| expect(result.data).toEqual([{ id: undefined, data: null }]) | ||
| }) | ||
|
|
||
| it('accepts in-range bigints at the int64 boundary', async () => { | ||
| const op = new BulkEncryptOperation(client, payload(9223372036854775807n), { | ||
| column, | ||
| table, | ||
| }) | ||
|
|
||
| const result = await op.execute() | ||
|
|
||
| // Validation passes, so the stub client is reached — proving the boundary | ||
| // value was NOT rejected by the numeric guard. | ||
| expect(result.failure?.message).not.toContain('out of int64 range') | ||
| }) | ||
|
|
||
| it('enforces the same guard on the lock-context variant', async () => { | ||
| const op = new BulkEncryptOperationWithLockContext( | ||
| new BulkEncryptOperation(client, payload(Number.NaN), { column, table }), | ||
| lockContext, | ||
| ) | ||
|
|
||
| const result = await op.execute() | ||
|
|
||
| expect(result.failure?.message).toContain('Cannot encrypt NaN value') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { type SQL, sql } from 'drizzle-orm' | ||
| import { integer, PgDialect, pgTable } from 'drizzle-orm/pg-core' | ||
| import { describe, expect, it, vi } from 'vitest' | ||
| import { getEqlV3Column, isEqlV3Column } from '@/eql/v3/drizzle/column' | ||
| import { | ||
| createEncryptionOperatorsV3, | ||
| EncryptionOperatorError, | ||
| } from '@/eql/v3/drizzle/operators' | ||
| import { types } from '@/eql/v3/drizzle/types' | ||
|
|
||
| // A representative encrypted envelope — what `client.encrypt` actually returns | ||
| // and what a bigint column stores. Deliberately NOT a plaintext bigint. | ||
| const TERM = { c: 'ct', v: 1 } | ||
| const TERM_JSON = JSON.stringify(TERM) | ||
|
|
||
| function chainable(result: unknown) { | ||
| const op = result as { | ||
| withLockContext: ReturnType<typeof vi.fn> | ||
| audit: ReturnType<typeof vi.fn> | ||
| } | ||
| op.withLockContext = vi.fn(() => op) | ||
| op.audit = vi.fn(() => op) | ||
| return op | ||
| } | ||
|
|
||
| function setup() { | ||
| const encrypt = vi.fn(() => chainable(Promise.resolve({ data: TERM }))) | ||
| const ops = createEncryptionOperatorsV3({ encrypt }) | ||
| const dialect = new PgDialect() | ||
| const render = (s: SQL) => dialect.sqlToQuery(s) | ||
| return { ops, encrypt, render } | ||
| } | ||
|
|
||
| // A statically-typed table via the drizzle `types` namespace (no dynamic | ||
| // matrix, no casts) — the column set is known at compile time. | ||
| const accounts = pgTable('accounts', { | ||
| id: integer().primaryKey(), | ||
| balance: types.BigintOrd('balance'), | ||
| ledgerId: types.BigintEq('ledger_id'), | ||
| archived: types.Bigint('archived'), | ||
| }) | ||
|
|
||
| describe('v3 drizzle bigint columns', () => { | ||
| it('detects a bigint column and reports its concrete public.bigint domain', () => { | ||
| const storage = types.Bigint('n') | ||
| const ord = types.BigintOrd('n_ord') | ||
|
|
||
| expect(isEqlV3Column(storage)).toBe(true) | ||
| expect(getEqlV3Column('n', storage)?.getEqlType()).toBe('public.bigint') | ||
| expect(getEqlV3Column('n_ord', ord)?.getEqlType()).toBe('public.bigint_ord') | ||
| }) | ||
|
|
||
| it('emits the concrete public.bigint* SQL type through pgTable', () => { | ||
| expect(accounts.balance.getSQLType()).toBe('public.bigint_ord') | ||
| expect(accounts.ledgerId.getSQLType()).toBe('public.bigint_eq') | ||
| expect(accounts.archived.getSQLType()).toBe('public.bigint') | ||
|
Comment on lines
+54
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Type names will need to be updated when @freshtonic's stuff lands. |
||
| }) | ||
|
|
||
| it('encrypts a native bigint operand for eq without JSON-stringifying it', async () => { | ||
| const { ops, encrypt, render } = setup() | ||
| // A value beyond Number.MAX_SAFE_INTEGER to prove the bigint is passed | ||
| // through untouched (a JSON.stringify of a bigint would have thrown). | ||
| const value = 9223372036854775807n | ||
| const q = render(await ops.eq(accounts.ledgerId, value)) | ||
|
|
||
| expect(q.sql).toContain('eql_v3.eq("accounts"."ledger_id", $1::jsonb)') | ||
| expect(q.params).toEqual([TERM_JSON]) | ||
| expect(encrypt.mock.calls[0]?.[0]).toBe(value) | ||
| }) | ||
|
|
||
| it('emits ordering and range operators for a bigint_ord column', async () => { | ||
| const { ops, render } = setup() | ||
|
|
||
| const gt = render(await ops.gt(accounts.balance, 10n)) | ||
| expect(gt.sql).toContain('eql_v3.gt("accounts"."balance", $1::jsonb)') | ||
|
|
||
| const between = render(await ops.between(accounts.balance, -5n, 5n)) | ||
| expect(between.sql).toContain('eql_v3.gte("accounts"."balance", $1::jsonb)') | ||
| expect(between.sql).toContain('eql_v3.lte("accounts"."balance", $2::jsonb)') | ||
|
|
||
| const asc = render(ops.asc(accounts.balance)) | ||
| expect(asc.sql).toContain('eql_v3.ord_term("accounts"."balance")') | ||
| }) | ||
|
|
||
| it('rejects equality/ordering on a storage-only bigint column', async () => { | ||
| const { ops } = setup() | ||
| await expect(ops.eq(accounts.archived, 1n)).rejects.toBeInstanceOf( | ||
| EncryptionOperatorError, | ||
| ) | ||
| expect(() => ops.asc(accounts.archived)).toThrow(EncryptionOperatorError) | ||
| }) | ||
|
|
||
| it('rejects a bare SQL expression that is not an encrypted column', async () => { | ||
| const { ops } = setup() | ||
| await expect(ops.eq(sql`1`, 1n)).rejects.toBeInstanceOf( | ||
| EncryptionOperatorError, | ||
| ) | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { v3FromDriver, v3ToDriver } from '@/eql/v3/drizzle/codec' | ||
|
|
||
| describe('v3 codec', () => { | ||
| it('serialises an object to a jsonb string', () => { | ||
| expect(v3ToDriver({ v: 1, c: 'ct' })).toBe('{"v":1,"c":"ct"}') | ||
| }) | ||
|
|
||
| it('maps null/undefined to SQL NULL (JS null), never the JSON null literal', () => { | ||
| expect(v3ToDriver(null)).toBeNull() | ||
| expect(v3ToDriver(undefined)).toBeNull() | ||
| }) | ||
|
|
||
| it('parses a jsonb string back to an object', () => { | ||
| expect(v3FromDriver('{"v":1,"c":"ct"}')).toEqual({ v: 1, c: 'ct' }) | ||
| }) | ||
|
|
||
| it('passes an already-parsed object through unchanged', () => { | ||
| const obj = { v: 1 } | ||
| expect(v3FromDriver(obj)).toBe(obj) | ||
| }) | ||
|
|
||
| it('normalises null/undefined to SQL NULL (JS null) on read', () => { | ||
| expect(v3FromDriver(null)).toBeNull() | ||
| expect(v3FromDriver(undefined)).toBeNull() | ||
| }) | ||
|
|
||
| it('does not throw on a stray bigint in the envelope (defensive)', () => { | ||
| expect(v3ToDriver({ v: 1n } as never)).toBe('{"v":"1"}') | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Flagging types need update