Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/eql-v3-drizzle-encrypt-query.md
Original file line number Diff line number Diff line change
@@ -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_<domain>` type — so
predicates carry no ciphertext and reach the bundle's `(domain, query_<domain>)`
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.
9 changes: 9 additions & 0 deletions .changeset/stash-drizzle-skill-encrypt-query.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 20 additions & 12 deletions packages/stack-drizzle/__tests__/v3/bigint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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")')
Expand Down
56 changes: 33 additions & 23 deletions packages/stack-drizzle/__tests__/v3/operators.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<ReturnType<typeof EncryptionV3>>

// A query operation resolving `Result<T, …>` — the surface the factory drives.
type QueryOp<T> = {
withLockContext(lc: LockContext): QueryOp<T>
audit(cfg: AuditConfig): QueryOp<T>
then: PromiseLike<Result<T, EncryptionError>>['then']
}

it('accepts the client EncryptionV3 returns with no cast', () => {
expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith({} as V3Client)
})
Expand All @@ -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<Encrypted, …>`, 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<Result<Encrypted, EncryptionError>>['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<EncryptedQueryResult, …>` (single)
// or `Result<EncryptedQueryResult[], …>` (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<EncryptedQueryResult> & QueryOp<EncryptedQueryResult[]> =>
({}) 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<Encrypted>`; 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<unknown> =>
({}) as never,
}
// @ts-expect-error — `encrypt` returning `unknown` does not satisfy the
// factory's `ChainableOperation<Encrypted>` client contract.
// @ts-expect-error — `encryptQuery` resolving `unknown` does not satisfy the
// factory's `ChainableOperation<EncryptedQueryResult>` client contract.
createEncryptionOperatorsV3(erased)
})
})
Loading
Loading