From 7eba32d9f42427a09036c6d433fe4150e950db4b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 13 Jul 2026 16:42:37 +1000 Subject: [PATCH 1/3] feat(stack-drizzle): encrypt v3 query operands with encryptQuery, not encrypt (#622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every v3 Drizzle operator (eq/ne/gt/gte/lt/lte/between/notBetween/inArray/ notInArray/contains) previously encrypted its operand with client.encrypt — a full storage envelope (with ciphertext `c`) cast to `::jsonb`. A WHERE operand should be a query term, not a value to store. - All operators now use client.encryptQuery, producing a ciphertext-free term cast to the column's `eql_v3.query_` type, reaching the bundle's `(domain, query_)` overloads. The dialect no longer adds `::jsonb`; the operator layer owns the query-domain cast (`queryCastForDomain`). - encryptQuery is now a REQUIRED capability of the operand client (the optional guard and its only-JSON-uses-it branch are gone); scalar and JSON paths are unified. - inArray/notInArray encrypt the whole list in one batch encryptQuery crossing (position-stable, wrong-length rejected), replacing the bulkEncrypt/ bounded-concurrency path. - stack: add a batch `encryptQuery(terms)` overload to TypedEncryptionClient (mirrors the nominal client) so the documented `createEncryptionOperatorsV3(await EncryptionV3(...))` usage type-checks with the batch path. - Verified the query-domain mapping and the composite (hm/op/bf) query-term shape against the eql-3.0.0 bundle and a live encryptQuery probe; unit tests updated to assert query-term operands and `::eql_v3.query_` casts. The end-to-end operator behaviour is covered by the Drizzle integration matrix. Closes #622 Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/eql-v3-drizzle-encrypt-query.md | 22 ++ .../stack-drizzle/__tests__/v3/bigint.test.ts | 32 +- .../__tests__/v3/operators.test-d.ts | 56 ++-- .../__tests__/v3/operators.test.ts | 296 +++++++++--------- .../__tests__/v3/sql-dialect.test.ts | 18 +- packages/stack-drizzle/src/v3/operators.ts | 289 +++++++++-------- packages/stack-drizzle/src/v3/sql-dialect.ts | 30 +- packages/stack/src/encryption/v3.ts | 23 +- 8 files changed, 432 insertions(+), 334 deletions(-) create mode 100644 .changeset/eql-v3-drizzle-encrypt-query.md diff --git a/.changeset/eql-v3-drizzle-encrypt-query.md b/.changeset/eql-v3-drizzle-encrypt-query.md new file mode 100644 index 000000000..a0874de90 --- /dev/null +++ b/.changeset/eql-v3-drizzle-encrypt-query.md @@ -0,0 +1,22 @@ +--- +'@cipherstash/stack-drizzle': minor +'@cipherstash/stack': minor +--- + +EQL v3 Drizzle: encrypt every query operand with `encryptQuery`, not `encrypt` (#622). + +The v3 Drizzle operators (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`/`between`/`notBetween`/ +`inArray`/`notInArray`/`contains`) previously encrypted their operands with +`client.encrypt`, producing a full storage envelope (including the ciphertext `c`) +cast to `::jsonb`. A WHERE-clause operand should be a query *term*, not a value to +store. Every operator now uses `client.encryptQuery`, which yields a +ciphertext-free query term cast to the column's `eql_v3.query_` type — so +predicates carry no ciphertext and reach the bundle's `(domain, query_)` +operator overloads. This unifies the scalar/text operators with the JSON +containment path (already on `encryptQuery`) and removes the previously-optional +`encryptQuery` guard: it is now a required capability of the operand client. + +`@cipherstash/stack` gains a batch `encryptQuery(terms)` overload on +`TypedEncryptionClient` (the type `EncryptionV3` returns), mirroring the nominal +`EncryptionClient`. This is additive — it lets `inArray`/`notInArray` encrypt a +whole list of query terms in one crossing. diff --git a/packages/stack-drizzle/__tests__/v3/bigint.test.ts b/packages/stack-drizzle/__tests__/v3/bigint.test.ts index 8db51f376..6a7298170 100644 --- a/packages/stack-drizzle/__tests__/v3/bigint.test.ts +++ b/packages/stack-drizzle/__tests__/v3/bigint.test.ts @@ -8,9 +8,9 @@ import { } from '../../src/v3/operators' import { types } from '../../src/v3/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 } +// A representative query TERM — what `client.encryptQuery` returns for a bigint +// operand: a ciphertext-free term, deliberately NOT a plaintext bigint. +const TERM = { hm: 'h', v: 3 } const TERM_JSON = JSON.stringify(TERM) function chainable(result: unknown) { @@ -24,11 +24,11 @@ function chainable(result: unknown) { } function setup() { - const encrypt = vi.fn(() => chainable(Promise.resolve({ data: TERM }))) - const ops = createEncryptionOperatorsV3({ encrypt }) + const encryptQuery = vi.fn(() => chainable(Promise.resolve({ data: TERM }))) + const ops = createEncryptionOperatorsV3({ encryptQuery }) const dialect = new PgDialect() const render = (s: SQL) => dialect.sqlToQuery(s) - return { ops, encrypt, render } + return { ops, encryptQuery, render } } // A statically-typed table via the drizzle `types` namespace (no dynamic @@ -61,26 +61,34 @@ describe('v3 drizzle bigint columns', () => { }) it('encrypts a native bigint operand for eq without JSON-stringifying it', async () => { - const { ops, encrypt, render } = setup() + const { ops, encryptQuery, 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.sql).toContain( + 'eql_v3.eq("accounts"."ledger_id", $1::eql_v3.query_bigint_eq)', + ) expect(q.params).toEqual([TERM_JSON]) - expect(encrypt.mock.calls[0]?.[0]).toBe(value) + expect(encryptQuery.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)') + expect(gt.sql).toContain( + 'eql_v3.gt("accounts"."balance", $1::eql_v3.query_bigint_ord)', + ) 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)') + expect(between.sql).toContain( + 'eql_v3.gte("accounts"."balance", $1::eql_v3.query_bigint_ord)', + ) + expect(between.sql).toContain( + 'eql_v3.lte("accounts"."balance", $2::eql_v3.query_bigint_ord)', + ) const asc = render(ops.asc(accounts.balance)) expect(asc.sql).toContain('eql_v3.ord_term("accounts"."balance")') diff --git a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts b/packages/stack-drizzle/__tests__/v3/operators.test-d.ts index 3d3de6589..c2c09c1d6 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/v3/operators.test-d.ts @@ -3,7 +3,7 @@ import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { EncryptionError } from '@cipherstash/stack/errors' import type { LockContext } from '@cipherstash/stack/identity' -import type { Encrypted } from '@cipherstash/stack/types' +import type { EncryptedQueryResult } from '@cipherstash/stack/types' import type { EncryptionV3 } from '@cipherstash/stack/v3' import { describe, expectTypeOf, it } from 'vitest' import { createEncryptionOperatorsV3 } from '../../src/v3/index.js' @@ -12,15 +12,24 @@ import { createEncryptionOperatorsV3 } from '../../src/v3/index.js' * Static regression guard for M1: `createEncryptionOperatorsV3` must accept the * `TypedEncryptionClient` that `EncryptionV3` resolves to — the documented * `createEncryptionOperatorsV3(await EncryptionV3({ schemas }))` usage — as well - * as the nominal `EncryptionClient` and a hand-rolled `{ encrypt }` double, + * as the nominal `EncryptionClient` and a hand-rolled `{ encryptQuery }` double, * none requiring a cast. Typing the parameter to `EncryptionClient` (the * original bug) makes the first call below a compile error, which this suite - * would then catch. Lives in a `*.test-d.ts` so it is inside the existing - * typecheck scope without dragging the loose-typed runtime suites in. + * would then catch. Every operand is now encrypted with `encryptQuery` (#622), + * so the operand client contract is `encryptQuery` in both its single and batch + * forms; the doubles model that. Lives in a `*.test-d.ts` so it is inside the + * existing typecheck scope without dragging the loose-typed runtime suites in. */ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { type V3Client = Awaited> + // A query operation resolving `Result` — the surface the factory drives. + type QueryOp = { + withLockContext(lc: LockContext): QueryOp + audit(cfg: AuditConfig): QueryOp + then: PromiseLike>['then'] + } + it('accepts the client EncryptionV3 returns with no cast', () => { expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith({} as V3Client) }) @@ -31,35 +40,36 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { ) }) - it('accepts a minimal structural { encrypt } double', () => { - // The double now models what the real client returns: an operation whose - // `then` resolves a `Result`, not `unknown`. That is the point - // of the un-erasure — a `{ encrypt }` returning `unknown` no longer - // typechecks, because the factory reads `result.data` as an `Encrypted` - // envelope rather than casting it. - type Op = { - withLockContext(lc: LockContext): Op - audit(cfg: AuditConfig): Op - then: PromiseLike>['then'] - } + it('accepts a minimal structural { encryptQuery } double', () => { + // The double models what the real client returns for both encryptQuery forms: + // an operation whose `then` resolves `Result` (single) + // or `Result` (batch), not `unknown`. That is the + // point of the un-erasure — the factory reads `result.data` as a query term + // (single) or an array of them (batch) rather than casting it. The single + // implementation returns the intersection, so it satisfies both overloads. const double = { - encrypt: (_plaintext: never, _opts: never) => ({}) as Op, + encryptQuery: ( + _valueOrTerms: never, + _opts?: never, + ): QueryOp & QueryOp => + ({}) as never, } expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith(double) }) - it('rejects a { encrypt } double that returns `unknown` (the erasure regression)', () => { + it('rejects an { encryptQuery } double that resolves `unknown` (the erasure regression)', () => { // The complement of the test above, and the one with teeth: `toBeCallableWith` // a correctly-typed double keeps passing even if the client contract // regressed to `unknown` (a correct value is assignable to `unknown`), so it - // cannot catch re-erasure. This can. `encrypt` returning `unknown` must NOT - // satisfy the factory, whose contract is `ChainableOperation`; if - // the erasure ever comes back, the `@ts-expect-error` goes unused and fails. + // cannot catch re-erasure. This can. `encryptQuery` resolving `unknown` must + // NOT satisfy the factory, whose contract resolves an `EncryptedQueryResult`; + // if the erasure ever comes back, the `@ts-expect-error` goes unused and fails. const erased = { - encrypt: (_plaintext: never, _opts: never): unknown => ({}), + encryptQuery: (_valueOrTerms: never, _opts?: never): QueryOp => + ({}) as never, } - // @ts-expect-error — `encrypt` returning `unknown` does not satisfy the - // factory's `ChainableOperation` client contract. + // @ts-expect-error — `encryptQuery` resolving `unknown` does not satisfy the + // factory's `ChainableOperation` client contract. createEncryptionOperatorsV3(erased) }) }) diff --git a/packages/stack-drizzle/__tests__/v3/operators.test.ts b/packages/stack-drizzle/__tests__/v3/operators.test.ts index 1cd9ede4c..198b47d48 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test.ts +++ b/packages/stack-drizzle/__tests__/v3/operators.test.ts @@ -25,17 +25,25 @@ import { import { extractEncryptionSchemaV3 } from '../../src/v3/schema-extraction' import { types } from '../../src/v3/types' -const TERM = { c: 'ct', v: 1 } +// A query TERM (from `encryptQuery`), not a storage envelope: it carries index +// terms but NO ciphertext `c` — that is the whole point of the encrypt → +// encryptQuery move. The operator layer casts it to the column's query domain. +const TERM = { hm: 'h', v: 3 } const TERM_JSON = JSON.stringify(TERM) const lockContext = { identityClaim: 'user-123' } const audit = { metadata: { actor: 'test' } } -type EncryptResult = Promise< - { data: typeof TERM } | { failure: { message: string } } -> +// The `eql_v3.query_` type an operand for a matrix column casts to. +// `slug('public.eql_v3_text_eq')` → `eql_v3_text_eq`; stripping the `eql_v3_` +// prefix yields the domain suffix the query type is named for (`text_eq` → +// `eql_v3.query_text_eq`). +const qcast = (eqlType: string): string => + `eql_v3.query_${slug(eqlType).replace(/^eql_v3_/, '')}` -function chainable(result: EncryptResult) { - const op = result as EncryptResult & { +type TermResult = { data: unknown } | { failure: { message: string } } + +function chainable(result: Promise) { + const op = result as Promise & { withLockContext: ReturnType audit: ReturnType } @@ -44,53 +52,28 @@ function chainable(result: EncryptResult) { return op } -function setup( - encryptImpl: (...args: unknown[]) => EncryptResult = async () => ({ - data: TERM, - }), -) { - const encrypt = vi.fn((...args: unknown[]) => chainable(encryptImpl(...args))) - // `encryptQuery` backs JSON containment: it returns a narrowed `query_jsonb` - // term (no ciphertext). The double returns a recognisable ste_vec-shaped term - // so a rendered `@>` query can be asserted. - const encryptQuery = vi.fn(() => - chainable( - Promise.resolve({ data: { sv: [{ s: 'sel', hm: 'h' }] } }) as never, - ), - ) - // The factory's `client` parameter is the structural `{ encrypt }` surface, - // so this hand-rolled double satisfies it with no cast (M1). - const client = { encrypt, encryptQuery } - const ops = createEncryptionOperatorsV3(client, { lockContext, audit }) - const dialect = new PgDialect() - const render = (s: unknown) => dialect.sqlToQuery(s as SQL) - return { ops, encrypt, encryptQuery, render } -} - -type BulkPayload = Array<{ id?: string; plaintext: unknown }> -type BulkResult = Promise< - { data: Array<{ data: typeof TERM }> } | { failure: { message: string } } -> - -/** - * A double for the fuller client surface — one that also exposes `bulkEncrypt`, - * as the real `TypedEncryptionClient` does. `inArray`/`notInArray` should - * prefer it over N single `encrypt` crossings. - */ -function setupBulk( - bulkImpl: (payloads: BulkPayload) => BulkResult = async (payloads) => ({ - data: payloads.map(() => ({ data: TERM })), - }), -) { - const encrypt = vi.fn(() => chainable(Promise.resolve({ data: TERM }))) - const bulkEncrypt = vi.fn((payloads: BulkPayload, ..._rest: unknown[]) => - chainable(bulkImpl(payloads) as never), - ) - const client = { encrypt, bulkEncrypt } +// The double now models `encryptQuery` in BOTH its forms: the single +// `(value, opts)` form used by scalar/JSON operands, and the batch `(terms[])` +// form used by inArray/notInArray. `termImpl` maps a plaintext value to the +// query term the crossing would return (defaulting to a constant `TERM`); the +// batch form applies it position-for-position so callers can pin ordering. +function setup(termImpl: (value?: unknown) => unknown = () => TERM) { + const encryptQuery = vi.fn((valueOrTerms: unknown, _opts?: unknown) => { + if (Array.isArray(valueOrTerms)) { + const terms = valueOrTerms as Array<{ value: unknown }> + return chainable( + Promise.resolve({ data: terms.map((t) => termImpl(t.value)) }), + ) + } + return chainable(Promise.resolve({ data: termImpl(valueOrTerms) })) + }) + // The factory's `client` parameter is the structural `{ encryptQuery }` + // surface, so this hand-rolled double satisfies it with no cast. + const client = { encryptQuery } const ops = createEncryptionOperatorsV3(client, { lockContext, audit }) const dialect = new PgDialect() const render = (s: unknown) => dialect.sqlToQuery(s as SQL) - return { ops, encrypt, bulkEncrypt, render } + return { ops, encryptQuery, render } } const matrixEntries = typedEntries(V3_MATRIX) @@ -153,26 +136,32 @@ const users = pgTable('users', { describe('createEncryptionOperatorsV3 - equality', () => { it.each( equalityDomains, - )('%s eq emits the latest two-arg eql_v3.eq with a full-envelope operand', async (eqlType, spec) => { - const { ops, encrypt, render } = setup() + )('%s eq emits the latest two-arg eql_v3.eq with a query-term operand', async (eqlType, spec) => { + const { ops, encryptQuery, render } = setup() const q = render(await ops.eq(matrixColumn(eqlType), sampleFor(spec))) expect(q.sql).toContain( - `eql_v3.eq("matrix_users"."${slug(eqlType)}", $1::jsonb)`, + `eql_v3.eq("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, ) expect(q.params).toEqual([TERM_JSON]) - expect(encrypt.mock.calls[0]?.[1]?.column.getName()).toBe(slug(eqlType)) + expect(encryptQuery.mock.calls[0]?.[1]?.column.getName()).toBe( + slug(eqlType), + ) + expect(encryptQuery.mock.calls[0]?.[1]?.queryType).toBe('equality') }) it.each(equalityDomains)('%s ne emits eql_v3.neq', async (eqlType, spec) => { - const { ops, encrypt, render } = setup() + const { ops, encryptQuery, render } = setup() const q = render(await ops.ne(matrixColumn(eqlType), sampleFor(spec))) expect(q.sql).toContain( - `eql_v3.neq("matrix_users"."${slug(eqlType)}", $1::jsonb)`, + `eql_v3.neq("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, ) expect(q.params).toEqual([TERM_JSON]) - expect(encrypt.mock.calls[0]?.[1]?.column.getName()).toBe(slug(eqlType)) + expect(encryptQuery.mock.calls[0]?.[1]?.column.getName()).toBe( + slug(eqlType), + ) + expect(encryptQuery.mock.calls[0]?.[1]?.queryType).toBe('equality') }) it('same-named columns on different tables use their own equality capability', async () => { @@ -186,7 +175,9 @@ describe('createEncryptionOperatorsV3 - equality', () => { const q = render(await ops.eq(accounts.email, 'ada@example.com')) - expect(q.sql).toContain('eql_v3.eq("accounts"."email", $1::jsonb)') + expect(q.sql).toContain( + 'eql_v3.eq("accounts"."email", $1::eql_v3.query_text_eq)', + ) }) it('does not reuse a cached extracted schema across distinct pgTable objects with the same SQL name', async () => { @@ -196,26 +187,26 @@ describe('createEncryptionOperatorsV3 - equality', () => { const second = pgTable('shared', { age: types.IntegerOrd('age'), }) - const { ops, encrypt } = setup() + const { ops, encryptQuery } = setup() await ops.eq(first.email, 'ada@example.com') await ops.eq(second.age, 37) - expect(encrypt.mock.calls[1]?.[1]?.table.build()).toEqual( + expect(encryptQuery.mock.calls[1]?.[1]?.table.build()).toEqual( extractEncryptionSchemaV3(second).build(), ) }) it('passes default lock context and audit to operand encryption', async () => { - const { ops, encrypt } = setup() + const { ops, encryptQuery } = setup() await ops.eq(users.nickname, 'ada') - const op = encrypt.mock.results[0]?.value + const op = encryptQuery.mock.results[0]?.value expect(op.withLockContext).toHaveBeenCalledWith(lockContext) expect(op.audit).toHaveBeenCalledWith(audit) }) it('per-call lock context and audit override constructor defaults', async () => { - const { ops, encrypt } = setup() + const { ops, encryptQuery } = setup() const callLockContext = { identityClaim: 'user-456' } const callAudit = { metadata: { actor: 'override' } } @@ -224,7 +215,7 @@ describe('createEncryptionOperatorsV3 - equality', () => { audit: callAudit, }) - const op = encrypt.mock.results[0]?.value + const op = encryptQuery.mock.results[0]?.value expect(op.withLockContext).toHaveBeenCalledWith(callLockContext) expect(op.withLockContext).not.toHaveBeenCalledWith(lockContext) expect(op.audit).toHaveBeenCalledWith(callAudit) @@ -240,29 +231,32 @@ describe('createEncryptionOperatorsV3 - comparison & range', () => { ['lte', 'eql_v3.lte'], ] as const)('%s emits %s for every ORE domain', async (op, fn) => { for (const [eqlType, spec] of orderDomains) { - const { ops, encrypt, render } = setup() + const { ops, encryptQuery, render } = setup() const q = render(await ops[op](matrixColumn(eqlType), sampleFor(spec))) expect(q.sql).toContain( - `${fn}("matrix_users"."${slug(eqlType)}", $1::jsonb)`, + `${fn}("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, ) expect(q.params).toEqual([TERM_JSON]) - expect(encrypt.mock.calls[0]?.[1]?.column.getName()).toBe(slug(eqlType)) + expect(encryptQuery.mock.calls[0]?.[1]?.column.getName()).toBe( + slug(eqlType), + ) + expect(encryptQuery.mock.calls[0]?.[1]?.queryType).toBe('orderAndRange') } }) it.each( orderDomains, - )('%s between emits a bounded range with two full-envelope operands', async (eqlType, spec) => { + )('%s between emits a bounded range with two query-term operands', async (eqlType, spec) => { const { ops, render } = setup() const value = sampleFor(spec) const q = render(await ops.between(matrixColumn(eqlType), value, value)) expect(q.sql).toContain( - `eql_v3.gte("matrix_users"."${slug(eqlType)}", $1::jsonb)`, + `eql_v3.gte("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, ) expect(q.sql).toContain( - `eql_v3.lte("matrix_users"."${slug(eqlType)}", $2::jsonb)`, + `eql_v3.lte("matrix_users"."${slug(eqlType)}", $2::${qcast(eqlType)})`, ) expect(q.params).toEqual([TERM_JSON, TERM_JSON]) }) @@ -276,10 +270,10 @@ describe('createEncryptionOperatorsV3 - comparison & range', () => { expect(q.sql).toMatch(/^not \(/i) expect(q.sql).toContain( - `eql_v3.gte("matrix_users"."${slug(eqlType)}", $1::jsonb)`, + `eql_v3.gte("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, ) expect(q.sql).toContain( - `eql_v3.lte("matrix_users"."${slug(eqlType)}", $2::jsonb)`, + `eql_v3.lte("matrix_users"."${slug(eqlType)}", $2::${qcast(eqlType)})`, ) expect(q.params).toEqual([TERM_JSON, TERM_JSON]) }) @@ -289,22 +283,18 @@ describe('createEncryptionOperatorsV3 - comparison & range', () => { // transposition inside `range` is invisible. Echo the plaintext through the // stub instead, and pin that `gte` binds `min` and `lte` binds `max`. it('between binds min to gte and max to lte, in that order', async () => { - const { ops, render } = setup(async (value) => ({ - data: { p: value } as never, - })) + const { ops, render } = setup((value) => ({ p: value })) const q = render(await ops.between(users.age, -128, 127)) expect(q.sql).toBe( - '(eql_v3.gte("users"."age", $1::jsonb) AND eql_v3.lte("users"."age", $2::jsonb))', + '(eql_v3.gte("users"."age", $1::eql_v3.query_integer_ord) AND eql_v3.lte("users"."age", $2::eql_v3.query_integer_ord))', ) expect(q.params).toEqual(['{"p":-128}', '{"p":127}']) }) it('notBetween binds min to gte and max to lte, in that order', async () => { - const { ops, render } = setup(async (value) => ({ - data: { p: value } as never, - })) + const { ops, render } = setup((value) => ({ p: value })) const q = render(await ops.notBetween(users.age, -128, 127)) @@ -320,7 +310,7 @@ describe('createEncryptionOperatorsV3 - comparison & range', () => { // lte` in Postgres — every row under the lower bound, none of the intended // complement. The range must arrive pre-parenthesised. expect(q.sql).toBe( - 'not (eql_v3.gte("users"."age", $1::jsonb) AND eql_v3.lte("users"."age", $2::jsonb))', + 'not (eql_v3.gte("users"."age", $1::eql_v3.query_integer_ord) AND eql_v3.lte("users"."age", $2::eql_v3.query_integer_ord))', ) }) @@ -335,7 +325,7 @@ describe('createEncryptionOperatorsV3 - comparison & range', () => { ) expect(q.sql).toContain( - '(eql_v3.gte("users"."age", $1::jsonb) AND eql_v3.lte("users"."age", $2::jsonb))', + '(eql_v3.gte("users"."age", $1::eql_v3.query_integer_ord) AND eql_v3.lte("users"."age", $2::eql_v3.query_integer_ord))', ) }) @@ -363,15 +353,18 @@ describe('createEncryptionOperatorsV3 - comparison & range', () => { describe('createEncryptionOperatorsV3 - free-text match', () => { it.each( matchDomains, - )('%s contains emits latest eql_v3.contains with a full-envelope operand', async (eqlType, spec) => { - const { ops, encrypt, render } = setup() + )('%s contains emits latest eql_v3.contains with a query-term operand', async (eqlType, spec) => { + const { ops, encryptQuery, render } = setup() const q = render(await ops.contains(matrixColumn(eqlType), needleFor(spec))) expect(q.sql).toContain( - `eql_v3.contains("matrix_users"."${slug(eqlType)}", $1::jsonb)`, + `eql_v3.contains("matrix_users"."${slug(eqlType)}", $1::${qcast(eqlType)})`, ) expect(q.params).toEqual([TERM_JSON]) - expect(encrypt.mock.calls[0]?.[1]?.column.getName()).toBe(slug(eqlType)) + expect(encryptQuery.mock.calls[0]?.[1]?.column.getName()).toBe( + slug(eqlType), + ) + expect(encryptQuery.mock.calls[0]?.[1]?.queryType).toBe('freeTextSearch') }) // A needle shorter than the tokenizer's `token_length` produces an empty @@ -380,27 +373,31 @@ describe('createEncryptionOperatorsV3 - free-text match', () => { it.each( matchDomains, )('%s contains rejects a needle shorter than token_length before encrypting', async (eqlType) => { - const { ops, encrypt } = setup() + const { ops, encryptQuery } = setup() await expect(ops.contains(matrixColumn(eqlType), 'ad')).rejects.toThrow( /at least 3 characters/, ) await expect(ops.contains(matrixColumn(eqlType), '')).rejects.toThrow( EncryptionOperatorError, ) - expect(encrypt).not.toHaveBeenCalled() + expect(encryptQuery).not.toHaveBeenCalled() }) it('contains accepts a needle exactly at token_length', async () => { const { ops, render } = setup() const q = render(await ops.contains(users.email, 'ada')) - expect(q.sql).toContain('eql_v3.contains("users"."email", $1::jsonb)') + expect(q.sql).toContain( + 'eql_v3.contains("users"."email", $1::eql_v3.query_text_search)', + ) }) it('negation is expressed through the passthrough Drizzle not operator', async () => { const { ops, render } = setup() const q = render(ops.not(await ops.contains(users.email, 'example.com'))) expect(q.sql).toMatch(/^not /i) - expect(q.sql).toContain('eql_v3.contains("users"."email", $1::jsonb)') + expect(q.sql).toContain( + 'eql_v3.contains("users"."email", $1::eql_v3.query_text_search)', + ) }) it('does not expose obsolete like/ilike helpers', () => { @@ -428,19 +425,26 @@ describe('createEncryptionOperatorsV3 - JSON containment', () => { ) expect(q.sql).not.toContain('eql_v3.contains') // The needle is the encryptQuery result, not a full storage envelope. - expect(q.params).toEqual([JSON.stringify({ sv: [{ s: 'sel', hm: 'h' }] })]) + expect(q.params).toEqual([TERM_JSON]) expect(encryptQuery).toHaveBeenCalledTimes(1) + expect(encryptQuery.mock.calls[0]?.[0]).toEqual({ roles: ['eng'] }) expect(encryptQuery.mock.calls[0]?.[1]).toMatchObject({ queryType: 'searchableJson', }) }) + it('JSON containment carries the default lock context and audit config', async () => { + const { ops, encryptQuery } = setup() + await ops.contains(matrixColumn(JSON_TYPE), { roles: ['eng'] }) + const op = encryptQuery.mock.results[0]?.value + expect(op.withLockContext).toHaveBeenCalledWith(lockContext) + expect(op.audit).toHaveBeenCalledWith(audit) + }) + it('contains surfaces an encryptQuery failure as an EncryptionOperatorError', async () => { const { ops, encryptQuery } = setup() encryptQuery.mockReturnValueOnce( - chainable( - Promise.resolve({ failure: { message: 'boom' } }) as never, - ) as never, + chainable(Promise.resolve({ failure: { message: 'boom' } })), ) await expect( ops.contains(matrixColumn(JSON_TYPE), { roles: ['eng'] }), @@ -465,19 +469,6 @@ describe('createEncryptionOperatorsV3 - JSON containment', () => { ) expect(encryptQuery).not.toHaveBeenCalled() }) - - // `encryptQuery` is OPTIONAL on the operand client (see OperandEncryptionClient): - // a `{ encrypt }`-only client is structurally valid but cannot build the - // `query_jsonb` needle JSON containment needs. Without this guard the call would - // be a raw `TypeError: encryptQuery is not a function`; the guard turns it into a - // typed EncryptionOperatorError. Exercises the branch the real double can't. - it('contains throws a typed error when the client lacks encryptQuery', async () => { - const encrypt = vi.fn(() => chainable(Promise.resolve({ data: TERM }))) - const ops = createEncryptionOperatorsV3({ encrypt }) - await expect( - ops.contains(matrixColumn(JSON_TYPE), { roles: ['eng'] }), - ).rejects.toBeInstanceOf(EncryptionOperatorError) - }) }) describe('createEncryptionOperatorsV3 - domains with no scalar query', () => { @@ -512,8 +503,8 @@ describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { await expect(ops.inArray(users.nickname, [])).rejects.toThrow(/non-empty/) }) - it('inArray fans out more than MAX_IN_ARRAY_CONCURRENCY values exactly once', async () => { - const { ops, encrypt, render } = setup() + it('inArray encrypts every value in one batch crossing, ORing one eq term each', async () => { + const { ops, encryptQuery, render } = setup() const values = [ 'ada', 'grace', @@ -528,10 +519,14 @@ describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { expect((q.sql.match(/eql_v3\.eq/g) ?? []).length).toBe(values.length) expect(q.params).toEqual(values.map(() => TERM_JSON)) - expect(encrypt).toHaveBeenCalledTimes(values.length) - expect(encrypt.mock.calls.map(([value]) => value).sort()).toEqual( - [...values].sort(), - ) + // No fan-out anymore: the whole list crosses in a single batch call. + expect(encryptQuery).toHaveBeenCalledTimes(1) + const terms = encryptQuery.mock.calls[0]?.[0] as Array<{ + value: unknown + column: { getName(): string } + }> + expect(terms.map((c) => c.value)).toEqual(values) + expect(terms.every((c) => c.column.getName() === 'nickname')).toBe(true) }) it('inArray on an ORE column uses ORE equality for each term', async () => { @@ -557,65 +552,61 @@ describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { expect((q.sql.match(/eql_v3\.neq/g) ?? []).length).toBe(2) }) - it('inArray encrypts the whole list in a single bulkEncrypt crossing', async () => { - const { ops, encrypt, bulkEncrypt, render } = setupBulk() + it('inArray encrypts the whole list in a single encryptQuery batch crossing', async () => { + const { ops, encryptQuery, render } = setup() const values = ['ada', 'grace', 'alan', 'katherine', 'dorothy'] const q = render(await ops.inArray(users.nickname, values)) - expect(bulkEncrypt).toHaveBeenCalledTimes(1) - expect(encrypt).not.toHaveBeenCalled() - expect(bulkEncrypt.mock.calls[0]?.[0]).toEqual( - values.map((plaintext) => ({ plaintext })), - ) - const opts = bulkEncrypt.mock.calls[0]?.[1] as { + expect(encryptQuery).toHaveBeenCalledTimes(1) + const terms = encryptQuery.mock.calls[0]?.[0] as Array<{ + value: unknown column: { getName(): string } - } - expect(opts.column.getName()).toBe('nickname') + }> + expect(terms.map((c) => c.value)).toEqual(values) + expect(terms.every((c) => c.column.getName() === 'nickname')).toBe(true) expect((q.sql.match(/eql_v3\.eq/g) ?? []).length).toBe(values.length) expect(q.params).toEqual(values.map(() => TERM_JSON)) }) - it('notInArray bulk-encrypts once and ANDs one ne term per value', async () => { - const { ops, bulkEncrypt, render } = setupBulk() + it('notInArray batch-encrypts once and ANDs one ne term per value', async () => { + const { ops, encryptQuery, render } = setup() const q = render(await ops.notInArray(users.nickname, ['ada', 'grace'])) - expect(bulkEncrypt).toHaveBeenCalledTimes(1) + expect(encryptQuery).toHaveBeenCalledTimes(1) expect((q.sql.match(/eql_v3\.neq/g) ?? []).length).toBe(2) expect(q.sql).toContain(' and ') }) - it('bulk operand encryption carries the lock context and audit config', async () => { - const { ops, bulkEncrypt } = setupBulk() + it('batch operand encryption carries the lock context and audit config', async () => { + const { ops, encryptQuery } = setup() await ops.inArray(users.nickname, ['ada', 'grace']) - const op = bulkEncrypt.mock.results[0]?.value + const op = encryptQuery.mock.results[0]?.value expect(op.withLockContext).toHaveBeenCalledWith(lockContext) expect(op.audit).toHaveBeenCalledWith(audit) }) - it('bulk terms keep their positions so each eq term matches its value', async () => { - const terms = [{ c: 'ada' }, { c: 'grace' }] as unknown as Array<{ - data: typeof TERM - }> - const { ops, render } = setupBulk(async () => ({ - data: [{ data: terms[0] as never }, { data: terms[1] as never }], - })) + it('batch terms keep their positions so each eq term matches its value', async () => { + // Echo each value through the batch so a re-ordering inside `encryptOperands` + // would surface as a mismatched param, not be masked by a constant term. + const { ops, render } = setup((value) => ({ c: value })) const q = render(await ops.inArray(users.nickname, ['ada', 'grace'])) expect(q.params).toEqual([ - JSON.stringify(terms[0]), - JSON.stringify(terms[1]), + JSON.stringify({ c: 'ada' }), + JSON.stringify({ c: 'grace' }), ]) }) - it('a bulk encryption failure is wrapped with operator context', async () => { - const { ops } = setupBulk(async () => ({ - failure: { message: 'bad query term' }, - })) + it('a batch encryption failure is wrapped with operator context', async () => { + const { ops, encryptQuery } = setup() + encryptQuery.mockReturnValueOnce( + chainable(Promise.resolve({ failure: { message: 'bad query term' } })), + ) await expect( ops.inArray(users.nickname, ['ada', 'grace']), @@ -626,21 +617,23 @@ describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { }) it('a null value in the list throws before any encryption crossing', async () => { - const { ops, bulkEncrypt } = setupBulk() + const { ops, encryptQuery } = setup() await expect(ops.inArray(users.nickname, ['ada', null])).rejects.toThrow( /isNull/, ) - expect(bulkEncrypt).not.toHaveBeenCalled() + expect(encryptQuery).not.toHaveBeenCalled() }) - it('a bulk response of the wrong length is rejected rather than silently truncated', async () => { - const { ops } = setupBulk(async () => ({ data: [{ data: TERM }] })) + it('a batch response of the wrong length is rejected rather than silently truncated', async () => { + const { ops, encryptQuery } = setup() + // One term for two values — the batch contract is violated. + encryptQuery.mockReturnValue(chainable(Promise.resolve({ data: [TERM] }))) // Pin the counts: an off-by-one guard, or a rejection thrown for some // unrelated reason, must not pass as "handled". await expect(ops.inArray(users.nickname, ['ada', 'grace'])).rejects.toThrow( - /bulk encryption returned 1 terms for 2 values/, + /batch query encryption returned 1 terms for 2 values/, ) await expect( ops.inArray(users.nickname, ['ada', 'grace']), @@ -648,12 +641,12 @@ describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { }) it('inArray gates on the column capability before encrypting anything', async () => { - const { ops, bulkEncrypt } = setupBulk() + const { ops, encryptQuery } = setup() await expect(ops.inArray(users.flag, [true])).rejects.toBeInstanceOf( EncryptionOperatorError, ) - expect(bulkEncrypt).not.toHaveBeenCalled() + expect(encryptQuery).not.toHaveBeenCalled() }) it('and ignores undefined conditions and keeps the encrypted predicates', async () => { @@ -699,9 +692,10 @@ describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { describe('createEncryptionOperatorsV3 - gating errors', () => { it('wraps encryption failures with operator context', async () => { - const { ops } = setup(async () => ({ - failure: { message: 'bad query term' }, - })) + const { ops, encryptQuery } = setup() + encryptQuery.mockReturnValueOnce( + chainable(Promise.resolve({ failure: { message: 'bad query term' } })), + ) await expect(ops.eq(users.nickname, 'ada')).rejects.toMatchObject({ name: 'EncryptionOperatorError', diff --git a/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts b/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts index 3120bcd17..7520a672f 100644 --- a/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts +++ b/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts @@ -12,19 +12,19 @@ const enc = sql`${'{"v":"t"}'}` describe('v3Dialect', () => { it('equality via HMAC', () => { expect(render(v3Dialect.equality('eq', col, enc))).toBe( - 'eql_v3.eq("users"."x", $1::jsonb)', + 'eql_v3.eq("users"."x", $1)', ) }) it('inequality via HMAC', () => { expect(render(v3Dialect.equality('ne', col, enc))).toBe( - 'eql_v3.neq("users"."x", $1::jsonb)', + 'eql_v3.neq("users"."x", $1)', ) }) it('comparison via ORE', () => { expect(render(v3Dialect.comparison('gte', col, enc))).toBe( - 'eql_v3.gte("users"."x", $1::jsonb)', + 'eql_v3.gte("users"."x", $1)', ) }) @@ -32,7 +32,7 @@ describe('v3Dialect', () => { const lo = sql`${'{"v":"lo"}'}` const hi = sql`${'{"v":"hi"}'}` expect(render(v3Dialect.range(col, lo, hi))).toBe( - '(eql_v3.gte("users"."x", $1::jsonb) AND eql_v3.lte("users"."x", $2::jsonb))', + '(eql_v3.gte("users"."x", $1) AND eql_v3.lte("users"."x", $2))', ) }) @@ -44,13 +44,13 @@ describe('v3Dialect', () => { // own. Postgres binds NOT tighter than AND, so an unparenthesised range // would silently become `(NOT gte) AND lte`. expect(render(not(v3Dialect.range(col, lo, hi)))).toBe( - 'not (eql_v3.gte("users"."x", $1::jsonb) AND eql_v3.lte("users"."x", $2::jsonb))', + 'not (eql_v3.gte("users"."x", $1) AND eql_v3.lte("users"."x", $2))', ) }) it('contains via two-arg function', () => { expect(render(v3Dialect.contains(col, enc))).toBe( - 'eql_v3.contains("users"."x", $1::jsonb)', + 'eql_v3.contains("users"."x", $1)', ) }) @@ -75,7 +75,7 @@ describe('v3Dialect', () => { const query = renderFull(build()) expect(query.params).toEqual([hostile]) - expect(query.sql).toContain('$1::jsonb') + expect(query.sql).toContain('$1') // The raw value must appear nowhere in the SQL text. expect(query.sql).not.toContain('OR 1=1') expect(query.sql).not.toContain('back\\slash') @@ -88,7 +88,7 @@ describe('v3Dialect', () => { expect(query.params).toEqual(['{"b":"min"}', '{"b":"max"}']) expect(query.sql).toBe( - '(eql_v3.gte("users"."x", $1::jsonb) AND eql_v3.lte("users"."x", $2::jsonb))', + '(eql_v3.gte("users"."x", $1) AND eql_v3.lte("users"."x", $2))', ) }) @@ -97,7 +97,7 @@ describe('v3Dialect', () => { const query = renderFull(v3Dialect.equality('eq', col, sql`${big}`)) expect(query.params).toEqual([big]) - expect(query.sql).toBe('eql_v3.eq("users"."x", $1::jsonb)') + expect(query.sql).toBe('eql_v3.eq("users"."x", $1)') }) }) diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts index ca9437ecd..75acc4426 100644 --- a/packages/stack-drizzle/src/v3/operators.ts +++ b/packages/stack-drizzle/src/v3/operators.ts @@ -1,6 +1,9 @@ import type { Result } from '@byteslice/result' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' -import { matchNeedleError } from '@cipherstash/stack/adapter-kit' +import { + matchNeedleError, + stripDomainSchema, +} from '@cipherstash/stack/adapter-kit' import type { AnyEncryptedV3Column, AnyV3Table, @@ -8,7 +11,10 @@ import type { import type { EncryptionError } from '@cipherstash/stack/errors' import type { LockContext } from '@cipherstash/stack/identity' import type { ColumnSchema } from '@cipherstash/stack/schema' -import type { BulkEncryptedData, Encrypted } from '@cipherstash/stack/types' +import type { + EncryptedQueryResult, + QueryTypeName, +} from '@cipherstash/stack/types' import { and, asc, @@ -33,43 +39,33 @@ import { } from './schema-extraction.js' import { type ComparisonOp, type EqualityOp, v3Dialect } from './sql-dialect.js' -const MAX_IN_ARRAY_CONCURRENCY = 4 - /** - * The client capabilities this factory consumes: `encrypt`, and `bulkEncrypt` - * when the client offers it. Declared structurally — with maximally-permissive - * operands — so it is satisfied by the nominal `EncryptionClient`, by the - * `TypedEncryptionClient` that `EncryptionV3` returns (whatever its schema - * tuple), AND by a hand-rolled test double, none needing a cast. Typing the - * parameter to the nominal `TypedEncryptionClient` would reject a client - * built for a narrower schema tuple (it accepts fewer tables than - * `readonly AnyV3Table[]`); the structural surface sidesteps that variance. The - * factory resolves the column/table at runtime and encrypts through its own - * casts, so it relies on none of the client's per-column `encrypt` overloads. + * The client capability this factory consumes: `encryptQuery`, in both its + * single (`value, opts`) and batch (`terms[]`) forms. Declared structurally — + * with maximally-permissive operands — so it is satisfied by the nominal + * `EncryptionClient`, by the `TypedEncryptionClient` that `EncryptionV3` returns + * (whatever its schema tuple), AND by a hand-rolled test double, none needing a + * cast. Typing the parameter to the nominal `TypedEncryptionClient` would + * reject a client built for a narrower schema tuple (it accepts fewer tables than + * `readonly AnyV3Table[]`); the structural surface sidesteps that variance. + * + * Every operand is a QUERY TERM, not a storage envelope: `encryptQuery` mints a + * ciphertext-free term (no `c`) carrying all of the column's configured index + * terms, which the operator layer casts to the column's `eql_v3.query_` + * type. This reaches the bundle's `(domain, query_)` overloads and keeps + * WHERE-clause payloads free of the ciphertext a query never needs (#622). * - * `bulkEncrypt` is optional so a `{ encrypt }`-only client stays valid; the - * list operators fall back to bounded-concurrency single encryption without it. - * `encryptQuery` is optional only because today it is used by a single operator - * — JSON containment (`@>`), which needs a ciphertext-free `query_jsonb` needle; - * the `contains()` branch guards its absence. Every OTHER operator still encrypts - * its operand with `encrypt` (a full storage envelope). Unifying all operands - * onto `encryptQuery` — after which it stops being optional — is tracked in #622. + * `never` operands: the real client's `encryptQuery` is generic (`queryType` is + * constrained to the column's own query types), which a concrete signature here + * cannot match. `never` params keep the structural surface satisfiable by that + * generic method AND by a test double; the call sites cast their real operands. */ type OperandEncryptionClient = { - encrypt( - plaintext: never, - opts: { table: AnyV3Table; column: AnyEncryptedV3Column }, - ): ChainableOperation - bulkEncrypt?( - plaintexts: never, - opts: { table: AnyV3Table; column: AnyEncryptedV3Column }, - ): ChainableOperation - // `never` operands, like `encrypt` above: the real client's `encryptQuery` is - // generic (`queryType` is constrained to the column's own query types), which a - // concrete signature here cannot match. `never` params keep the structural - // surface satisfiable by that generic method AND by a test double. The call - // site casts its real operands. - encryptQuery?(value: never, opts: never): unknown + encryptQuery( + value: never, + opts: never, + ): ChainableOperation + encryptQuery(terms: never): ChainableOperation } /** @@ -101,6 +97,33 @@ interface ColumnContext { indexes: ColumnSchema['indexes'] columnName: string tableName: string + /** The `eql_v3.query_` type an operand for this column casts to, so + * `encryptQuery`'s ciphertext-free term reaches the narrowed-query overloads. + * `null` for storage-only columns (no query domain); those never encrypt an + * operand — every operator gates on a query capability first. JSON columns + * override this at the call site (`query_jsonb`, an irregular name). */ + queryCast: string | null +} + +/** + * The `eql_v3.query_` cast for a column's storage domain — e.g. + * `public.eql_v3_text_search` → `eql_v3.query_text_search`. Uniform across the + * queryable domains (`_eq`, `_ord*`, `_match`, `_search*`); the two irregular + * cases are handled elsewhere: storage-only domains (`eql_v3_boolean`, the bare + * base types) have no query domain and return `null` (they are never queried), + * and `eql_v3_json` maps to `query_jsonb`, cast explicitly on the JSON path. + */ +function queryCastForDomain(eqlType: string): string | null { + const bare = stripDomainSchema(eqlType) // public.eql_v3_text_search → eql_v3_text_search + const prefix = 'eql_v3_' + if (!bare.startsWith(prefix)) return null + const suffix = bare.slice(prefix.length) + // No index suffix (bare storage-only domain like `boolean`, `text`) → no query + // domain exists. These are gated out before any operand is encrypted. + if (!/_(eq|ord|ord_ope|ord_ore|match|search|search_ore)$/.test(suffix)) { + return null + } + return `eql_v3.query_${suffix}` } export type EncryptionOperatorCallOpts = { @@ -124,12 +147,12 @@ type AuditableOperation = { /** * The subset of an SDK encryption operation this factory drives: the fluent * `withLockContext`/`audit` chain, and a `then` that resolves the operation's - * `Result`. Generic over the resolved payload `T` so `encrypt` carries an - * `Encrypted` envelope and `bulkEncrypt` a `BulkEncryptedData`, rather than the - * `unknown` this erased to before. + * `Result`. Generic over the resolved payload `T` so the single `encryptQuery` + * carries an `EncryptedQueryResult` term and the batch form an + * `EncryptedQueryResult[]`, rather than the `unknown` this erased to before. * - * Structural, not the concrete `EncryptOperation` class, because the client is - * passed in and the factory must accept any implementation with this surface. + * Structural, not the concrete `EncryptQueryOperation` class, because the client + * is passed in and the factory must accept any implementation with this surface. */ type ChainableOperation = { withLockContext(lockContext: LockContext): AuditableOperation @@ -137,29 +160,6 @@ type ChainableOperation = { then: PromiseLike>['then'] } -async function mapWithConcurrency( - values: T[], - limit: number, - mapper: (value: T) => Promise, -): Promise { - const results = new Array(values.length) - let next = 0 - - async function worker(): Promise { - while (true) { - const index = next - next += 1 - if (index >= values.length) return - results[index] = await mapper(values[index]) - } - } - - await Promise.all( - Array.from({ length: Math.min(limit, values.length) }, () => worker()), - ) - return results -} - /** * Build v3-aware query operators (`eq`, `gte`, `contains`, `asc`, …) bound to an * encryption `client`. Each comparison/containment operator AUTO-ENCRYPTS its @@ -169,8 +169,9 @@ async function mapWithConcurrency( * {@link EncryptionOperatorError} when the column can't answer the operator * (e.g. ordering a non-`ore` column). * - * @param client - anything that can `encrypt` — the nominal `EncryptionClient` - * or the `TypedEncryptionClient` from `EncryptionV3` (no cast needed). + * @param client - anything that can `encryptQuery` — the nominal + * `EncryptionClient` or the `TypedEncryptionClient` from `EncryptionV3` (no + * cast needed). * @param defaults - lock context / audit applied to every operand encryption * unless a per-call override is supplied. * @@ -230,6 +231,7 @@ export function createEncryptionOperatorsV3( indexes: builder.build().indexes, columnName, tableName, + queryCast: queryCastForDomain(builder.getEqlType()), } contextCache.set(column, context) return context @@ -329,76 +331,94 @@ export function createEncryptionOperatorsV3( ) } + /** + * Render a query term as a cast operand: `''::eql_v3.query_`. + * The cast is what reaches the bundle's `(domain, query_)` overloads — + * a bare `::jsonb` would hit the storage-domain overload, whose CHECK demands + * the ciphertext `c` a query term deliberately omits. `queryCast` is derived + * from the column's own domain (see `queryCastForDomain`), so `sql.raw` is safe. + */ + function castOperand( + ctx: ColumnContext, + operator: string, + term: EncryptedQueryResult, + ): SQL { + if (ctx.queryCast === null) { + throw operandFailure( + ctx, + operator, + `column domain "${ctx.builder.getEqlType()}" has no query operand type.`, + ) + } + return sql`${JSON.stringify(term)}::${sql.raw(ctx.queryCast)}` + } + async function encryptOperand( ctx: ColumnContext, value: unknown, operator: string, + queryType: QueryTypeName, opts?: EncryptionOperatorCallOpts, ): Promise { requireNonNullOperand(ctx, value, operator) const result = await applyOperationOptions( - client.encrypt(value as never, { - table: ctx.table, - column: ctx.builder, - }), + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType, + } as never, + ), opts, ) if (result.failure) { throw operandFailure(ctx, operator, result.failure.message) } - // `result.data` is now `Encrypted` — the storage envelope — not `unknown`. - return sql`${JSON.stringify(result.data)}` + return castOperand(ctx, operator, result.data) } /** - * Encrypt a whole operand list. Prefers the client's `bulkEncrypt` — one FFI - * crossing for the entire list, rather than one per value — and falls back to - * bounded-concurrency single encryption for clients that don't expose it. - * - * `bulkEncrypt` is position-stable, so the returned terms align index-for- - * index with `values`; a response of a different length means the contract - * was violated and is rejected rather than silently truncating the predicate - * (which would widen an `inArray` or narrow a `notInArray`). + * Encrypt a whole operand list in ONE `encryptQuery` batch crossing (rather + * than one per value). The batch is position-stable, so the returned terms + * align index-for-index with `values`; a response of a different length means + * the contract was violated and is rejected rather than silently truncating + * the predicate (which would widen an `inArray` or narrow a `notInArray`). + * Null operands are rejected up front, so no term is filtered out of the batch. */ async function encryptOperands( ctx: ColumnContext, values: unknown[], operator: string, + queryType: QueryTypeName, opts?: EncryptionOperatorCallOpts, ): Promise { for (const value of values) requireNonNullOperand(ctx, value, operator) - const bulkEncrypt = client.bulkEncrypt?.bind(client) - if (!bulkEncrypt) { - return mapWithConcurrency(values, MAX_IN_ARRAY_CONCURRENCY, (value) => - encryptOperand(ctx, value, operator, opts), - ) - } - + const terms = values.map((value) => ({ + value, + column: ctx.builder, + table: ctx.table, + queryType, + })) const result = await applyOperationOptions( - bulkEncrypt(values.map((plaintext) => ({ plaintext })) as never, { - table: ctx.table, - column: ctx.builder, - }), + client.encryptQuery(terms as never), opts, ) if (result.failure) { throw operandFailure(ctx, operator, result.failure.message) } - // `result.data` is `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` - // — not `unknown`. The length check stays: a position-stable contract - // violation must not silently truncate the predicate. - const encrypted: BulkEncryptedData = result.data + const encrypted = result.data as EncryptedQueryResult[] if (encrypted.length !== values.length) { throw operandFailure( ctx, operator, - `bulk encryption returned ${encrypted.length} terms for ${values.length} values.`, + `batch query encryption returned ${encrypted.length} terms for ${values.length} values.`, ) } - return encrypted.map((term) => sql`${JSON.stringify(term.data)}`) + return encrypted.map((term) => castOperand(ctx, operator, term)) } const colSql = (column: SQLWrapper): SQL => sql`${column}` @@ -411,7 +431,7 @@ export function createEncryptionOperatorsV3( ): Promise { const ctx = resolveContext(left, op) requireIndex(ctx, EQUALITY_INDEXES, op, 'equality') - const enc = await encryptOperand(ctx, right, op, opts) + const enc = await encryptOperand(ctx, right, op, 'equality', opts) return v3Dialect.equality(op, colSql(left), enc) } @@ -423,7 +443,7 @@ export function createEncryptionOperatorsV3( ): Promise { const ctx = resolveContext(left, op) requireIndex(ctx, ORDERING_INDEXES, op, 'order/range') - const enc = await encryptOperand(ctx, right, op, opts) + const enc = await encryptOperand(ctx, right, op, 'orderAndRange', opts) return v3Dialect.comparison(op, colSql(left), enc) } @@ -440,8 +460,8 @@ export function createEncryptionOperatorsV3( // Independent operands — encrypt concurrently rather than paying two // sequential round-trips to the crypto backend. const [encMin, encMax] = await Promise.all([ - encryptOperand(ctx, min, operator, opts), - encryptOperand(ctx, max, operator, opts), + encryptOperand(ctx, min, operator, 'orderAndRange', opts), + encryptOperand(ctx, max, operator, 'orderAndRange', opts), ]) // `v3Dialect.range` is already parenthesised, so `NOT` binds to the whole // conjunction without a wrapper here. @@ -465,30 +485,44 @@ export function createEncryptionOperatorsV3( // JSON containment. `eql_v3_json` has no `eql_v3.contains` overload — its // containment is the `@>` operator, whose `(eql_v3_json, eql_v3.query_jsonb)` - // form takes a NARROWED query term from `encryptQuery` (searchableJson → no - // ciphertext), not the full storage envelope. The dialect casts it to - // `eql_v3.query_jsonb` and emits `@>`. + // form takes a NARROWED query term (searchableJson → no ciphertext). The + // needle is cast to `eql_v3.query_jsonb` (an irregular name, so it is handled + // here rather than by `queryCastForDomain`) and emitted with `@>`. if (ctx.indexes.ste_vec) { - const needle = await encryptJsonContainmentTerm(ctx, right, operator) + const needle = await encryptJsonContainmentTerm( + ctx, + right, + operator, + opts, + ) return v3Dialect.containsJson(colSql(left), needle) } // Bloom free-text (text_search / text_match): the answerable-needle rule - // applies, and the full-envelope operand disambiguates its own overload. + // applies, and the `query_` cast reaches the match overload. requireAnswerableNeedle(ctx, right, operator) - const enc = await encryptOperand(ctx, right, operator, opts) + const enc = await encryptOperand( + ctx, + right, + operator, + 'freeTextSearch', + opts, + ) return v3Dialect.contains(colSql(left), enc) } /** - * Build a `query_jsonb` containment needle for a `json` column. Uses - * `encryptQuery` (not `encrypt`): the JSON query term carries no ciphertext - * and satisfies the `eql_v3.query_jsonb` CHECK the `@>` overload needs. + * Build a `query_jsonb` containment needle for a `json` column — the JSON query + * term carries no ciphertext and satisfies the `eql_v3.query_jsonb` CHECK the + * `@>` overload needs. Cast here (not by `queryCastForDomain`): a json column's + * domain is `eql_v3_json` but its query operand type is the irregular + * `eql_v3.query_jsonb`. */ async function encryptJsonContainmentTerm( ctx: ColumnContext, value: unknown, operator: string, + opts?: EncryptionOperatorCallOpts, ): Promise { requireNonNullOperand(ctx, value, operator) // Reject the empty-object needle. `doc @> '{}'` holds for EVERY document @@ -507,26 +541,21 @@ export function createEncryptionOperatorsV3( { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } - const encryptQuery = client.encryptQuery?.bind(client) - if (!encryptQuery) { - throw operandFailure( - ctx, - operator, - 'this client does not support encryptQuery, which JSON containment requires', - ) - } - const result = (await encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'searchableJson', - } as never, - )) as { failure?: { message: string }; data?: unknown } + const result = await applyOperationOptions( + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'searchableJson', + } as never, + ), + opts, + ) if (result.failure) { throw operandFailure(ctx, operator, result.failure.message) } - return sql`${JSON.stringify(result.data)}` + return sql`${JSON.stringify(result.data)}::eql_v3.query_jsonb` } async function inArrayOp( @@ -547,7 +576,13 @@ export function createEncryptionOperatorsV3( // a single crossing where the client supports it. requireIndex(ctx, EQUALITY_INDEXES, operator, 'equality') const op: EqualityOp = negate ? 'ne' : 'eq' - const encrypted = await encryptOperands(ctx, values, operator, opts) + const encrypted = await encryptOperands( + ctx, + values, + operator, + 'equality', + opts, + ) const conditions = encrypted.map((enc) => v3Dialect.equality(op, colSql(left), enc), ) diff --git a/packages/stack-drizzle/src/v3/sql-dialect.ts b/packages/stack-drizzle/src/v3/sql-dialect.ts index b88550d66..38a3a5f1a 100644 --- a/packages/stack-drizzle/src/v3/sql-dialect.ts +++ b/packages/stack-drizzle/src/v3/sql-dialect.ts @@ -13,13 +13,23 @@ export const EQL_V3_FN_SCHEMA = 'eql_v3' const fn = (name: string): SQL => sql.raw(`${EQL_V3_FN_SCHEMA}.${name}`) +/** + * Emit the EQL v3 comparison/containment SQL. Every operand `enc` arrives ALREADY + * cast to its `eql_v3.query_` type by the operator layer (see + * `operators.ts` — the term comes from `encryptQuery`, so it is ciphertext-free + * and matches the `query_` CHECK). The dialect therefore adds no cast of + * its own; it only places the pre-cast operand into the right function/operator. + * This reaches the `(domain, query_)` overloads the bundle defines for + * narrowed query terms, rather than the `(domain, jsonb)` overload that coerces + * to the storage domain and demands the ciphertext `c`. + */ export const v3Dialect = { equality(op: EqualityOp, left: SQL, enc: SQL): SQL { - return sql`${fn(op === 'eq' ? 'eq' : 'neq')}(${left}, ${enc}::jsonb)` + return sql`${fn(op === 'eq' ? 'eq' : 'neq')}(${left}, ${enc})` }, comparison(op: ComparisonOp, left: SQL, enc: SQL): SQL { - return sql`${fn(op)}(${left}, ${enc}::jsonb)` + return sql`${fn(op)}(${left}, ${enc})` }, /** @@ -34,24 +44,24 @@ export const v3Dialect = { * composition safe instead of asking each caller to remember. */ range(left: SQL, min: SQL, max: SQL): SQL { - return sql`(${fn('gte')}(${left}, ${min}::jsonb) AND ${fn('lte')}(${left}, ${max}::jsonb))` + return sql`(${fn('gte')}(${left}, ${min}) AND ${fn('lte')}(${left}, ${max}))` }, contains(left: SQL, enc: SQL): SQL { - return sql`${fn('contains')}(${left}, ${enc}::jsonb)` + return sql`${fn('contains')}(${left}, ${enc})` }, /** * Encrypted-JSONB containment (`eql_v3_json @> sub-document`). Unlike text, * json has NO `eql_v3.contains` overload — containment is the `@>` operator, - * whose `(eql_v3_json, eql_v3.query_jsonb)` form takes a `query_jsonb` needle - * (from `encryptQuery`, no ciphertext). The needle is cast to - * `eql_v3.query_jsonb` EXPLICITLY: `eql_v3_json @> ?` has four RHS overloads - * (`eql_v3_json`, `eql_v3_jsonb_entry`, `jsonb`, `query_jsonb`), so a bare - * `::jsonb` is ambiguous ("operator is not unique", 42725). + * whose `(eql_v3_json, eql_v3.query_jsonb)` form takes the `query_jsonb` needle + * the operator layer produced. The four `eql_v3_json @> ?` RHS overloads mean a + * bare operand would be ambiguous ("operator is not unique", 42725), so the + * needle is pre-cast to `eql_v3.query_jsonb` upstream (see + * `encryptJsonContainmentTerm`). */ containsJson(left: SQL, enc: SQL): SQL { - return sql`${left} OPERATOR(public.@>) ${enc}::eql_v3.query_jsonb` + return sql`${left} OPERATOR(public.@>) ${enc}` }, orderBy(left: SQL, flavour: 'ope' | 'ore'): SQL { diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index acf4b1b82..7370c5151 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -19,8 +19,10 @@ import type { Encrypted, EncryptedReturnType, EncryptOptions, + ScalarQueryTerm, } from '@/types' import { + type BatchEncryptQueryOperation, type BulkDecryptOperation, type BulkEncryptModelsOperation, type BulkEncryptOperation, @@ -70,6 +72,14 @@ export interface TypedEncryptionClient { }, ): EncryptQueryOperation + /** + * Batch form: encrypt many query terms in one crossing. Mirrors the nominal + * {@link EncryptionClient} overload — the per-term columns are heterogeneous, + * so the terms are the base {@link ScalarQueryTerm} rather than a per-column + * narrowed type. Consumed by the Drizzle `inArray`/`notInArray` operators. + */ + encryptQuery(terms: readonly ScalarQueryTerm[]): BatchEncryptQueryOperation + encryptModel>( input: V3ModelInput, table: Table, @@ -207,8 +217,17 @@ export function typedClient( return { encrypt: (plaintext, opts) => client.encrypt(plaintext as never, opts as never), - encryptQuery: (plaintext, opts) => - client.encryptQuery(plaintext as never, opts as never), + // Forwards both forms to the nominal client, which routes to the batch + // operation when no `opts` are supplied. A single implementation signature + // cannot satisfy the two-overload interface member (their return types + // differ: single → EncryptQueryOperation, batch → BatchEncryptQueryOperation) + // and TS will not structurally reconcile them for an object-literal method, + // so bridge with a cast; the runtime forward is uniform. + encryptQuery: ((plaintextOrTerms: never, opts?: never) => + client.encryptQuery( + plaintextOrTerms, + opts as never, + )) as unknown as TypedEncryptionClient['encryptQuery'], encryptModel: (input, table) => client.encryptModel(input as never, table as never) as never, bulkEncryptModels: (input, table) => From f1a5bb9a59370d508f66842d38b8a29030d130be Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 13 Jul 2026 18:08:26 +1000 Subject: [PATCH 2/3] refactor(stack): implement TypedEncryptionClient.encryptQuery as a named overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the object-literal arrow + `as unknown as` bridge with a named function carrying the two real overload signatures (single per-column form + batch `terms[]` form). The compiler now checks the implementation against both signatures directly, so the whole-value cast is gone; only the forwarded args keep `as never`, consistent with the sibling encrypt/encryptModel wrappers. Pure type-level restructuring — the runtime forward to the nominal client is unchanged. Verified: stack + stack-drizzle build and test:types (incl. the M1 guard) pass, code:check clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- packages/stack/src/encryption/v3.ts | 42 +++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index 7370c5151..14d934556 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -214,20 +214,40 @@ export function typedClient( }, } + // Overloaded so the implementation is checked against BOTH forms directly — + // no whole-value cast. The two public signatures mirror the interface member; + // the hidden implementation signature is broad and forwards to the nominal + // client (which routes to the batch operation when no `opts` are supplied). + // Only the forwarded args are `as never`, exactly as the sibling wrappers + // below: one forwarding body cannot re-derive the nominal client's per-column + // signatures. + function encryptQuery< + Table extends S[number], + Col extends QueryableColumnsOf
, + QT extends QueryTypesForColumn= QueryTypesForColumn, + >( + plaintext: PlaintextForColumn, + opts: { + table: Table + column: Col + queryType?: QT + returnType?: EncryptedReturnType + }, + ): EncryptQueryOperation + function encryptQuery( + terms: readonly ScalarQueryTerm[], + ): BatchEncryptQueryOperation + function encryptQuery( + plaintextOrTerms: unknown, + opts?: unknown, + ): EncryptQueryOperation | BatchEncryptQueryOperation { + return client.encryptQuery(plaintextOrTerms as never, opts as never) + } + return { encrypt: (plaintext, opts) => client.encrypt(plaintext as never, opts as never), - // Forwards both forms to the nominal client, which routes to the batch - // operation when no `opts` are supplied. A single implementation signature - // cannot satisfy the two-overload interface member (their return types - // differ: single → EncryptQueryOperation, batch → BatchEncryptQueryOperation) - // and TS will not structurally reconcile them for an object-literal method, - // so bridge with a cast; the runtime forward is uniform. - encryptQuery: ((plaintextOrTerms: never, opts?: never) => - client.encryptQuery( - plaintextOrTerms, - opts as never, - )) as unknown as TypedEncryptionClient['encryptQuery'], + encryptQuery, encryptModel: (input, table) => client.encryptModel(input as never, table as never) as never, bulkEncryptModels: (input, table) => From 161f17b6dea643b409797a010561603464d6f748 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 13 Jul 2026 18:40:56 +1000 Subject: [PATCH 3/3] fix(627): address PR review on the encryptQuery migration (#636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - operators.ts: drop the two dead regex branches in queryCastForDomain (`ord_ope`, `search_ore` are not real column-domain suffixes — ope ordering is `_ord`, text search is `_search`); comment now ties the suffixes to the column factories. - operators.ts: fix stale docs left by the bulkEncrypt→encryptQuery move — the inArray/notInArray JSDoc and the inArrayOp comment no longer describe the removed bulkEncrypt path / deleted concurrency fallback. - operators.test.ts: the encryptQuery double now routes to batch only when `opts === undefined && Array.isArray(...)`, matching the real client — an array-valued single query WITH opts (e.g. a searchableJson array needle) no longer diverges to the batch path in the double. - stash-drizzle skill: correct the inArray/notInArray line (single encryptQuery batch crossing, not bulkEncrypt); + stash patch changeset (skills ship in the tarball). Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../stash-drizzle-skill-encrypt-query.md | 9 ++++++ .../__tests__/v3/operators.test.ts | 9 ++++-- packages/stack-drizzle/src/v3/operators.ts | 28 ++++++++++--------- skills/stash-drizzle/SKILL.md | 2 +- 4 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 .changeset/stash-drizzle-skill-encrypt-query.md diff --git a/.changeset/stash-drizzle-skill-encrypt-query.md b/.changeset/stash-drizzle-skill-encrypt-query.md new file mode 100644 index 000000000..9e5ea14c7 --- /dev/null +++ b/.changeset/stash-drizzle-skill-encrypt-query.md @@ -0,0 +1,9 @@ +--- +'stash': patch +--- + +Correct the `stash-drizzle` skill: `inArray` / `notInArray` now encrypt the whole +list in a single `encryptQuery` batch crossing (the `bulkEncrypt`/concurrency +fallback was removed when v3 query operands moved to `encryptQuery` — #622). The +skill ships inside the `stash` tarball, so this keeps the bundled guidance in step +with the adapter's behaviour. diff --git a/packages/stack-drizzle/__tests__/v3/operators.test.ts b/packages/stack-drizzle/__tests__/v3/operators.test.ts index 198b47d48..95ef00515 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test.ts +++ b/packages/stack-drizzle/__tests__/v3/operators.test.ts @@ -58,8 +58,13 @@ function chainable(result: Promise) { // query term the crossing would return (defaulting to a constant `TERM`); the // batch form applies it position-for-position so callers can pin ordering. function setup(termImpl: (value?: unknown) => unknown = () => TERM) { - const encryptQuery = vi.fn((valueOrTerms: unknown, _opts?: unknown) => { - if (Array.isArray(valueOrTerms)) { + const encryptQuery = vi.fn((valueOrTerms: unknown, opts?: unknown) => { + // Route exactly as the real client does (packages/stack/src/encryption/ + // index.ts): batch ONLY when no opts are supplied AND the arg is a term + // array. An array-valued single query WITH opts (e.g. a searchableJson + // array needle) must take the single path here too, or the double would + // diverge from production for that shape. + if (opts === undefined && Array.isArray(valueOrTerms)) { const terms = valueOrTerms as Array<{ value: unknown }> return chainable( Promise.resolve({ data: terms.map((t) => termImpl(t.value)) }), diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts index 75acc4426..dbf11dab1 100644 --- a/packages/stack-drizzle/src/v3/operators.ts +++ b/packages/stack-drizzle/src/v3/operators.ts @@ -108,10 +108,11 @@ interface ColumnContext { /** * The `eql_v3.query_` cast for a column's storage domain — e.g. * `public.eql_v3_text_search` → `eql_v3.query_text_search`. Uniform across the - * queryable domains (`_eq`, `_ord*`, `_match`, `_search*`); the two irregular - * cases are handled elsewhere: storage-only domains (`eql_v3_boolean`, the bare - * base types) have no query domain and return `null` (they are never queried), - * and `eql_v3_json` maps to `query_jsonb`, cast explicitly on the JSON path. + * queryable column domains (`_eq`, `_ord`, `_ord_ore`, `_match`, `_search`); the + * two irregular cases are handled elsewhere: storage-only domains + * (`eql_v3_boolean`, the bare base types) have no query domain and return `null` + * (they are never queried), and `eql_v3_json` maps to `query_jsonb`, cast + * explicitly on the JSON path. */ function queryCastForDomain(eqlType: string): string | null { const bare = stripDomainSchema(eqlType) // public.eql_v3_text_search → eql_v3_text_search @@ -119,8 +120,11 @@ function queryCastForDomain(eqlType: string): string | null { if (!bare.startsWith(prefix)) return null const suffix = bare.slice(prefix.length) // No index suffix (bare storage-only domain like `boolean`, `text`) → no query - // domain exists. These are gated out before any operand is encrypted. - if (!/_(eq|ord|ord_ope|ord_ore|match|search|search_ore)$/.test(suffix)) { + // domain exists. These are gated out before any operand is encrypted. The + // suffixes match the column factories in `@/eql/v3/columns` exactly: ope + // ordering is the `_ord` domain (not `_ord_ope`) and text search is `_search` + // (there is no `_search_ore` column), so those two never occur here. + if (!/_(eq|ord|ord_ore|match|search)$/.test(suffix)) { return null } return `eql_v3.query_${suffix}` @@ -573,7 +577,7 @@ export function createEncryptionOperatorsV3( ) } // Gate and resolve the context once for the whole list, then encrypt it in - // a single crossing where the client supports it. + // a single `encryptQuery` batch crossing. requireIndex(ctx, EQUALITY_INDEXES, operator, 'equality') const op: EqualityOp = negate ? 'ne' : 'eq' const encrypted = await encryptOperands( @@ -655,18 +659,16 @@ export function createEncryptionOperatorsV3( contains: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => contains(l, r, 'contains', opts), /** Membership: ORs one encrypted `eq` term per value. The whole list is - * encrypted in one `bulkEncrypt` crossing where the client supports it, - * otherwise concurrency-bounded. Rejects an empty list; requires a - * `unique` or `ore` index. */ + * encrypted in one `encryptQuery` batch crossing. Rejects an empty list; + * requires a `unique` or `ore` index. */ inArray: ( l: SQLWrapper, values: unknown[], opts?: EncryptionOperatorCallOpts, ) => inArrayOp(l, values, false, 'inArray', opts), /** Non-membership: ANDs one encrypted `ne` term per value. The whole list - * is encrypted in one `bulkEncrypt` crossing where the client supports it, - * otherwise concurrency-bounded. Rejects an empty list; requires a - * `unique` or `ore` index. */ + * is encrypted in one `encryptQuery` batch crossing. Rejects an empty list; + * requires a `unique` or `ore` index. */ notInArray: ( l: SQLWrapper, values: unknown[], diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index f5cecde05..80ea215fb 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -787,7 +787,7 @@ Differences from the v2 operators to know about: - **`contains` on a `types.Json` column** answers encrypted-JSONB containment instead of free-text: `contains(col, { roles: ['admin'] })` matches every row whose document contains that sub-object (jsonb `@>` semantics; array containment is position-independent). It emits the `@>` operator with a `query_jsonb` needle — a `Json` column carries no `eq` / ordering, so those operators throw on it. - **No plaintext-column fallback.** Every v3 operator requires an encrypted v3 column and throws `EncryptionOperatorError` otherwise. Use regular Drizzle operators for non-encrypted columns. - A `null` operand throws — use `isNull()` / `isNotNull()` for NULL checks. -- `inArray` / `notInArray` reject an empty list, and encrypt the whole list in a single `bulkEncrypt` crossing when the client exposes one. +- `inArray` / `notInArray` reject an empty list, and encrypt the whole list in a single `encryptQuery` batch crossing. - `contains` rejects a needle shorter than the match tokenizer's token length (it would otherwise silently match every row). - Operators gate on the column's capabilities and throw `EncryptionOperatorError` (with `context.columnName` / `tableName` / `operator`) when the domain can't answer the operator. This `EncryptionOperatorError` is exported from `@cipherstash/stack-drizzle/v3` and is deliberately separate from the v2 class of the same name; there is no `EncryptionConfigError` on the v3 path. - Every operator takes an optional trailing `{ lockContext, audit }` argument; `createEncryptionOperatorsV3(client, { lockContext, audit })` sets defaults applied to every operand encryption.