diff --git a/.changeset/eql-v3-json-skills.md b/.changeset/eql-v3-json-skills.md new file mode 100644 index 000000000..979c4e672 --- /dev/null +++ b/.changeset/eql-v3-json-skills.md @@ -0,0 +1,8 @@ +--- +'stash': patch +--- + +Document EQL v3 JSON columns in the bundled skills: `types.Json` in the +`stash-encryption` typed-schema catalog (capability suffix, family, and an +encrypted-JSONB query section), and `contains(col, subObject)` JSON containment +on the v3 Drizzle operators in `stash-drizzle`. diff --git a/.changeset/eql-v3-json.md b/.changeset/eql-v3-json.md new file mode 100644 index 000000000..6544cc04c --- /dev/null +++ b/.changeset/eql-v3-json.md @@ -0,0 +1,18 @@ +--- +'@cipherstash/stack': minor +--- + +Add EQL v3 JSON columns. `types.Json('col')` declares a `public.eql_v3_json` +column that encrypts a JSON document to an ste_vec `SteVecDocument` and +round-trips it losslessly through `encrypt`/`decrypt` and the model path. A new +`searchableJson` query capability emits the ste_vec index; the index uses +`mode: 'compat'`, which eql-3.0.0's `eql_v3_json` requires (it orders ste_vec +entries by the CLLW-OPE `op` term, so v2's `'standard'`/CLLW-`oc` terms are +rejected). + +The Drizzle integration's `contains(col, subObject)` now answers encrypted-JSONB +containment on a `types.Json` column, emitting the `@>` operator with a +`query_jsonb` needle (from `encryptQuery`). The ste_vec index indexes array +elements by identity but not position, so containment is a true subset test +(`{ roles: ['x'] }` matches any document whose `roles` array contains `x`, +regardless of index). diff --git a/packages/stack/__tests__/drizzle-v3/operators.test.ts b/packages/stack/__tests__/drizzle-v3/operators.test.ts index 0fe344fa1..d96b7e801 100644 --- a/packages/stack/__tests__/drizzle-v3/operators.test.ts +++ b/packages/stack/__tests__/drizzle-v3/operators.test.ts @@ -50,13 +50,21 @@ function setup( }), ) { 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 } + 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, render } + return { ops, encrypt, encryptQuery, render } } type BulkPayload = Array<{ id?: string; plaintext: unknown }> @@ -107,13 +115,23 @@ const orderDomains = matrixEntries.filter( ([, spec]) => spec.indexes.ore || spec.indexes.ope, ) const matchDomains = matrixEntries.filter(([, spec]) => spec.indexes.match) -const storageDomains = matrixEntries.filter( +// Domains with NO scalar query index (unique/ore/ope/match), so `eq`/`ne`/ +// ordering all throw. This is the truly storage-only scalar domains PLUS +// `eql_v3_json` — json is a QUERYABLE domain (containment), it just answers no +// scalar op, so it lands here for the eq/order rejection checks. +const nonScalarQueryDomains = matrixEntries.filter( ([, spec]) => !spec.indexes.unique && !spec.indexes.ore && !spec.indexes.ope && !spec.indexes.match, ) +// Of those, the ones that also reject `contains` — i.e. everything except json. +// json answers `contains` via `@>`, so it is excluded here and gets its own +// positive assertion in the JSON containment describe above. +const noContainmentDomains = nonScalarQueryDomains.filter( + ([, spec]) => !spec.indexes.ste_vec, +) const users = pgTable('users', { id: integer().primaryKey(), @@ -393,22 +411,91 @@ describe('createEncryptionOperatorsV3 - free-text match', () => { }) }) -describe('createEncryptionOperatorsV3 - storage-only domains', () => { - it.each(storageDomains)('%s eq throws', async (eqlType, spec) => { +describe('createEncryptionOperatorsV3 - JSON containment', () => { + const JSON_TYPE = 'public.eql_v3_json' + + // json has no `eql_v3.contains` overload: containment is the `@>` operator, + // and the needle is a narrowed `query_jsonb` term from `encryptQuery` (no + // ciphertext), cast to `eql_v3.query_jsonb`. + it('contains emits the @> operator with a query_jsonb needle', async () => { + const { ops, encryptQuery, render } = setup() + const q = render( + await ops.contains(matrixColumn(JSON_TYPE), { roles: ['eng'] }), + ) + + expect(q.sql.toLowerCase()).toContain( + `"matrix_users"."${slug(JSON_TYPE)}" operator(public.@>) $1::eql_v3.query_jsonb`, + ) + 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(encryptQuery).toHaveBeenCalledTimes(1) + expect(encryptQuery.mock.calls[0]?.[1]).toMatchObject({ + queryType: 'searchableJson', + }) + }) + + 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, + ) + await expect( + ops.contains(matrixColumn(JSON_TYPE), { roles: ['eng'] }), + ).rejects.toBeInstanceOf(EncryptionOperatorError) + }) + + it('contains rejects a null operand before calling encryptQuery', async () => { + const { ops, encryptQuery } = setup() + await expect( + ops.contains(matrixColumn(JSON_TYPE), null), + ).rejects.toBeInstanceOf(EncryptionOperatorError) + expect(encryptQuery).not.toHaveBeenCalled() + }) + + // `doc @> '{}'` holds for every row (jsonb `{} ⊆ anything`); an empty-object + // needle would silently return the whole table, so it is refused before + // encrypting — the same whole-table guard the bloom path applies. + it('contains rejects an empty-object needle before calling encryptQuery', async () => { + const { ops, encryptQuery } = setup() + await expect(ops.contains(matrixColumn(JSON_TYPE), {})).rejects.toThrow( + /matches every row/, + ) + 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', () => { + it.each(nonScalarQueryDomains)('%s eq throws', async (eqlType, spec) => { const { ops } = setup() await expect( ops.eq(matrixColumn(eqlType), sampleFor(spec)), ).rejects.toBeInstanceOf(EncryptionOperatorError) }) - it.each(storageDomains)('%s contains throws', async (eqlType, spec) => { + it.each(noContainmentDomains)('%s contains throws', async (eqlType, spec) => { const { ops } = setup() await expect( ops.contains(matrixColumn(eqlType), sampleFor(spec)), ).rejects.toBeInstanceOf(EncryptionOperatorError) }) - it.each(storageDomains)('%s asc throws synchronously', (eqlType) => { + it.each(nonScalarQueryDomains)('%s asc throws synchronously', (eqlType) => { const { ops } = setup() expect(() => ops.asc(matrixColumn(eqlType))).toThrow( EncryptionOperatorError, diff --git a/packages/stack/__tests__/eql-v3-domain-registry.test.ts b/packages/stack/__tests__/eql-v3-domain-registry.test.ts index 192b034e8..22665d21c 100644 --- a/packages/stack/__tests__/eql-v3-domain-registry.test.ts +++ b/packages/stack/__tests__/eql-v3-domain-registry.test.ts @@ -61,6 +61,7 @@ const EXPECTED_DOMAIN_KEYS = [ 'eql_v3_double_eq', 'eql_v3_double_ord_ore', 'eql_v3_double_ord', + 'eql_v3_json', ] as const describe('DOMAIN_REGISTRY', () => { diff --git a/packages/stack/__tests__/supabase-v3-matrix.test.ts b/packages/stack/__tests__/supabase-v3-matrix.test.ts index 876fe4ee3..3ecdb3d99 100644 --- a/packages/stack/__tests__/supabase-v3-matrix.test.ts +++ b/packages/stack/__tests__/supabase-v3-matrix.test.ts @@ -101,12 +101,15 @@ function firstSample(spec: DomainSpec): unknown { describe('supabase v3 wire encoding, every domain', () => { // Guards the tier arithmetic itself. A domain silently dropping out of a // tier would otherwise just shrink an `it.each` with no test turning red. - it('tiers all 39 domains', () => { - expect(matrixEntries).toHaveLength(39) + it('tiers all 40 domains', () => { + expect(matrixEntries).toHaveLength(40) expect(equalityDomains).toHaveLength(28) expect(orderDomains).toHaveLength(19) expect(matchDomains).toHaveLength(2) - expect(storageOnlyDomains).toHaveLength(10) + // +1: `eql_v3_json` carries only an `ste_vec` index, so from this scalar + // tiering it reads as storage-only — the Supabase adapter has no JSON + // containment path (PostgREST can't cast/call), so it rejects scalar ops. + expect(storageOnlyDomains).toHaveLength(11) }) describe.each(matrixEntries)('%s', (eqlType, spec) => { diff --git a/packages/stack/__tests__/test-kit-families.test.ts b/packages/stack/__tests__/test-kit-families.test.ts index 6a9d951c1..d94a3c2d3 100644 --- a/packages/stack/__tests__/test-kit-families.test.ts +++ b/packages/stack/__tests__/test-kit-families.test.ts @@ -55,7 +55,7 @@ describe('test-kit families partition the v3 catalog', () => { expect([...covered, ...deferred].sort()).toEqual([...allBare].sort()) }) - it('defers exactly the block-ORE domains, each with a reason', () => { + it('defers the block-ORE domains and json, each with a reason', () => { const deferred = FAMILY_NAMES.flatMap((f) => deferredForFamily(f)) expect(deferred.map((d) => d.bare).sort()).toEqual([ @@ -63,13 +63,19 @@ describe('test-kit families partition the v3 catalog', () => { 'date_ord_ore', 'double_ord_ore', 'integer_ord_ore', + 'json', 'numeric_ord_ore', 'real_ord_ore', 'smallint_ord_ore', 'text_ord_ore', 'timestamp_ord_ore', ]) - for (const { reason } of deferred) expect(reason).toMatch(/superuser-only/) + // ORE domains defer for the superuser-only opclass; json defers because it + // is queried by containment, not the scalar op-matrix (covered by dedicated + // suites — see catalog reason). + for (const { bare, reason } of deferred) { + expect(reason).toMatch(bare === 'json' ? /containment/ : /superuser-only/) + } }) it('never hands a family a domain from a neighbouring prefix', () => { diff --git a/packages/stack/integration/drizzle-v3/json-contains.integration.test.ts b/packages/stack/integration/drizzle-v3/json-contains.integration.test.ts new file mode 100644 index 000000000..ce409bace --- /dev/null +++ b/packages/stack/integration/drizzle-v3/json-contains.integration.test.ts @@ -0,0 +1,124 @@ +/** + * Live JSON containment for the v3 `types.Json()` column through the Drizzle + * operators. Seeds encrypted JSONB documents and queries them with + * `ops.contains(col, subObject)` — the same operator text search uses, but on a + * `json` column it emits the `@>` operator over the encrypted JSONB document + * (json has no `eql_v3.contains` overload) with a `query_jsonb` needle from + * `encryptQuery` — asserting it returns exactly the rows whose document contains + * the sub-object (jsonb `@>` semantics), and excludes the rest. + */ +import { databaseUrl, unwrapResult } from '@cipherstash/test-kit' +import { and, asc, eq, type SQL } from 'drizzle-orm' +import { integer, pgTable, text } from 'drizzle-orm/pg-core' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { EncryptionV3 } from '@/encryption/v3' +import type { JsonDocument } from '@/eql/v3' +import { + createEncryptionOperatorsV3, + extractEncryptionSchemaV3, + types, +} from '@/eql/v3/drizzle' + +const sqlClient = postgres(databaseUrl(), { prepare: false }) + +const TABLE_NAME = 'protect_ci_v3_json_contains' +const RUN = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + +const docTable = pgTable(TABLE_NAME, { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + rowKey: text('row_key').notNull(), + testRunId: text('test_run_id').notNull(), + doc: types.Json('doc'), +}) + +const schema = extractEncryptionSchemaV3(docTable) + +// Distinct documents so each containment query has a definite expected set. +const DOCS: Record = { + ada: { user: 'ada@example.com', roles: ['admin', 'eng'], active: true }, + grace: { user: 'grace@example.com', roles: ['eng'] }, + zoe: { user: 'zoe@example.com', roles: ['ops'], active: false }, +} + +type SelectRow = { rowKey: string } +let client: Awaited> +let ops: ReturnType +let db: ReturnType + +async function matching(condition: SQL): Promise { + const rows = (await db + .select({ rowKey: docTable.rowKey }) + .from(docTable) + .where(and(eq(docTable.testRunId, RUN), condition)) + .orderBy(asc(docTable.rowKey))) as SelectRow[] + return rows.map((row) => row.rowKey) +} + +beforeAll(async () => { + // EQL v3 is installed once per run by `global-setup.ts`. + client = await EncryptionV3({ schemas: [schema] }) + ops = createEncryptionOperatorsV3(client) + db = drizzle({ client: sqlClient }) + + await sqlClient.unsafe(`DROP TABLE IF EXISTS ${TABLE_NAME}`) + await sqlClient.unsafe(` + CREATE TABLE ${TABLE_NAME} ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + row_key TEXT NOT NULL, + test_run_id TEXT NOT NULL, + doc public.eql_v3_json NOT NULL + ) + `) + + const rows = Object.entries(DOCS).map(([rowKey, doc]) => ({ + rowKey, + testRunId: RUN, + doc, + })) + const encrypted = unwrapResult( + await client.bulkEncryptModels(rows, schema), + ) as Array> + await db.insert(docTable).values(encrypted as never) +}, 120000) + +afterAll(async () => { + await sqlClient`DELETE FROM ${sqlClient(TABLE_NAME)} WHERE test_run_id = ${RUN}` + await sqlClient.end() +}, 30000) + +describe('v3 drizzle JSON containment (live pg)', () => { + it('matches every document containing a sub-object (array element containment)', async () => { + // `{ roles: ['eng'] }` ⊆ ada (admin,eng) and grace (eng), not zoe (ops). + const condition = await ops.contains(docTable.doc, { roles: ['eng'] }) + expect(await matching(condition)).toEqual(['ada', 'grace']) + }, 30000) + + it('matches on a scalar field', async () => { + const condition = await ops.contains(docTable.doc, { + user: 'zoe@example.com', + }) + expect(await matching(condition)).toEqual(['zoe']) + }, 30000) + + it('matches on a nested boolean', async () => { + const condition = await ops.contains(docTable.doc, { active: true }) + expect(await matching(condition)).toEqual(['ada']) + }, 30000) + + it('returns nothing for a sub-object no document contains', async () => { + const condition = await ops.contains(docTable.doc, { roles: ['nope'] }) + expect(await matching(condition)).toEqual([]) + }, 30000) + + // `doc @> '{}'` holds for every row (jsonb `{} ⊆ anything`), so an empty-object + // needle would silently return the whole table — the same whole-table footgun + // the bloom path rejects. The operator must refuse it before querying, rather + // than leak every row. + it('rejects an empty-object needle before querying', async () => { + await expect(ops.contains(docTable.doc, {})).rejects.toThrow( + /matches every row/, + ) + }, 30000) +}) diff --git a/packages/stack/integration/shared/json-crypto.integration.test.ts b/packages/stack/integration/shared/json-crypto.integration.test.ts new file mode 100644 index 000000000..77382a72a --- /dev/null +++ b/packages/stack/integration/shared/json-crypto.integration.test.ts @@ -0,0 +1,87 @@ +/** + * Live round-trip for the v3 `types.Json()` column — an encrypted JSONB document + * (`public.eql_v3_json`). Proves the new json column model encrypts and decrypts + * a real document through protect-ffi (no DB query here; containment is exercised + * by the Drizzle json suite). The stored payload is protect-ffi's + * `SteVecDocument`, carrying an `sv` array rather than a scalar `c` ciphertext. + */ +import { unwrapResult } from '@cipherstash/test-kit' +import { beforeAll, describe, expect, it } from 'vitest' +import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' + +const docs = encryptedTable('v3_json_docs', { + profile: types.Json('profile'), +}) + +describe('v3 typed client — encrypted JSONB round-trip', () => { + let client: Awaited>> + + beforeAll(async () => { + client = await EncryptionV3({ schemas: [docs] }) + }, 30000) + + it('round-trips a JSON document through encrypt/decrypt', async () => { + const value = { + user: 'ada@example.com', + roles: ['admin', 'eng'], + active: true, + meta: { since: 2020 }, + } + + const encrypted = unwrapResult( + await client.encrypt(value, { table: docs, column: docs.profile }), + ) + // An encrypted JSONB document carries an `sv` array, not a scalar `c` + // ciphertext (the stored shape is protect-ffi's `SteVecDocument`). + expect(Array.isArray((encrypted as { sv?: unknown }).sv)).toBe(true) + + const decrypted = unwrapResult(await client.decrypt(encrypted)) + expect(decrypted).toEqual(value) + }, 30000) + + it('round-trips a JSON document through the model path', async () => { + const model = { profile: { user: 'grace@example.com', roles: ['eng'] } } + const encrypted = unwrapResult(await client.encryptModel(model, docs)) + const decrypted = unwrapResult(await client.decryptModel(encrypted, docs)) + expect(decrypted).toEqual(model) + }, 30000) + + // A JSON document is not only an object: `types.Json` (and the `JsonDocument` + // plaintext type) also admit a top-level array and `null`. Pin both so a + // regression in encrypted-JSONB encoding of non-object roots turns a test red. + it.each([ + ['array root', [1, 'two', { three: 3 }]], + ['null root', null], + ] as const)( + 'round-trips a %s', + async (_label, value) => { + const encrypted = unwrapResult( + await client.encrypt(value, { table: docs, column: docs.profile }), + ) + const decrypted = unwrapResult(await client.decrypt(encrypted)) + expect(decrypted).toEqual(value) + }, + 30000, + ) + + // The boundary the `JsonDocument` type encodes: a top-level SCALAR is not a + // JSON document. protect-ffi rejects it ("Cannot convert … to Json") — a bare + // scalar belongs in a scalar domain (`types.TextEq`, `types.IntegerEq`, …). + // `as never` bypasses the compile-time block to prove the runtime enforces it. + it.each([ + ['string', 'hello'], + ['number', 42], + ['boolean', true], + ] as const)( + 'rejects a top-level %s scalar at encrypt', + async (_label, scalar) => { + const result = await client.encrypt(scalar as never, { + table: docs, + column: docs.profile, + }) + expect(result.failure).toBeDefined() + expect(result.failure?.message).toMatch(/convert .* to Json/i) + }, + 30000, + ) +}) diff --git a/packages/stack/integration/shared/matrix-crypto.integration.test.ts b/packages/stack/integration/shared/matrix-crypto.integration.test.ts index c457b82e7..11dcd4432 100644 --- a/packages/stack/integration/shared/matrix-crypto.integration.test.ts +++ b/packages/stack/integration/shared/matrix-crypto.integration.test.ts @@ -87,9 +87,17 @@ describe('v3 matrix live round-trip (all domains × samples)', () => { it.each( roundTripCases, )('%s round-trips through the model path', (_label, col, sample, i) => { - // Guard against a false pass: the field must be a real ciphertext (`c`), - // not a plaintext value that slipped through un-encrypted. - expect(encrypted[i][col]).toHaveProperty('c') + // Guard against a false pass: the field must be a real ciphertext, not a + // plaintext value that slipped through un-encrypted. Scalar domains carry it + // at a top-level `c`; a JSON (`eql_v3_json`) document is an ste_vec payload + // (`{ k: 'sv', sv: [...] }`) with NO top-level `c`, so it is guarded by the + // presence of its `sv` array instead. + const payload = encrypted[i][col] as { k?: unknown; sv?: unknown } + if (payload?.k === 'sv') { + expect(Array.isArray(payload.sv)).toBe(true) + } else { + expect(payload).toHaveProperty('c') + } const actual = decrypted[i][col] if (sample instanceof Date) { diff --git a/packages/stack/integration/supabase/introspect.integration.test.ts b/packages/stack/integration/supabase/introspect.integration.test.ts index 69d394d60..11e70d16c 100644 --- a/packages/stack/integration/supabase/introspect.integration.test.ts +++ b/packages/stack/integration/supabase/introspect.integration.test.ts @@ -28,7 +28,13 @@ beforeAll(async () => { // as unmodelled — it is an ordinary plaintext passthrough. await sql.unsafe(`DROP DOMAIN IF EXISTS public.${USER_DOMAIN}`) await sql.unsafe(`CREATE DOMAIN public.${USER_DOMAIN} AS jsonb`) - // Columns typed with EQL domains that have NO types factory. + // This table exercises all three cases the introspector must tell apart: + // score → EQL domain (eql_v3_integer_ord_ope) the SDK has no factory for + // → "unmodelled", MUST be reported so the caller knows it can't query it + // doc → EQL domain (eql_v3_json) the SDK models via types.Json + // → modelled, MUST be synthesized and NOT reported + // own → a user's own plain jsonb domain with no EQL marker + // → plaintext passthrough, MUST never be reported await sql.unsafe(` CREATE TABLE ${UNMODELLED} ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, @@ -92,13 +98,16 @@ describe('eql_v3 supabase introspection', () => { it('reports unmodelled EQL columns, keyed by table', async () => { const { unmodelled } = await introspect(databaseUrl()) - // Sanity: these really are EQL domains with no types factory. - expect(factoryForDomain('integer_ord_ope')).toBeUndefined() - expect(factoryForDomain('json')).toBeUndefined() + // Sanity, keyed exactly as the registry keys and the introspection query do + // (the full `eql_v3_*` domain name): `eql_v3_integer_ord_ope` has no types + // factory (unmodelled → reported), whereas `eql_v3_json` now HAS one + // (`types.Json` → modelled → NOT reported). + expect(factoryForDomain('eql_v3_integer_ord_ope')).toBeUndefined() + expect(factoryForDomain('eql_v3_json')).toBeDefined() const offenders = unmodelled.get(UNMODELLED) expect(offenders).toBeDefined() - expect(offenders?.map((c) => c.columnName).sort()).toEqual(['doc', 'score']) + expect(offenders?.map((c) => c.columnName).sort()).toEqual(['score']) expect(offenders?.find((c) => c.columnName === 'score')?.domainName).toBe( 'eql_v3_integer_ord_ope', ) @@ -119,11 +128,13 @@ describe('eql_v3 supabase introspection', () => { // The precondition that makes the `from()` guard load-bearing: an unmodelled // column is silently dropped from the encrypt config, yet stays in // `allColumns` — so `select('*')` would select it and return raw ciphertext. + // `doc` (modelled json) gets a builder; `score` (unmodelled) is dropped from + // the config but retained in `allColumns`. it('synthesizeTables drops an unmodelled column but allColumns keeps it', async () => { const { tables } = await introspect(databaseUrl()) const { tables: synth, allColumns } = synthesizeTables(tables) - expect(Object.keys(synth.get(UNMODELLED)!.columnBuilders)).toEqual([]) + expect(Object.keys(synth.get(UNMODELLED)!.columnBuilders)).toEqual(['doc']) expect(allColumns.get(UNMODELLED)).toContain('score') expect(allColumns.get(UNMODELLED)).toContain('doc') }, 30000) diff --git a/packages/stack/src/eql/v3/columns.ts b/packages/stack/src/eql/v3/columns.ts index 823bc5d2e..fdcf56538 100644 --- a/packages/stack/src/eql/v3/columns.ts +++ b/packages/stack/src/eql/v3/columns.ts @@ -10,11 +10,15 @@ import { defaultMatchOpts } from '@/schema/match-defaults' * - `equality`: exact-match lookups (EQL `hm`, or comparison via `ob`). * - `orderAndRange`: comparison / range lookups (EQL `op`, or `ob` on `_ord_ore` domains). * - `freeTextSearch`: tokenised substring match (EQL `bf`). + * - `searchableJson`: encrypted-JSONB containment / selector lookups (EQL + * `ste_vec`). Optional and mutually exclusive with the scalar flags — a + * `public.eql_v3_json` document is queried by structure, not by scalar term. */ export type QueryCapabilities = Readonly<{ equality: boolean orderAndRange: boolean freeTextSearch: boolean + searchableJson?: boolean }> /** @@ -35,7 +39,13 @@ export type DateLikeCast = (typeof DATE_LIKE_CASTS)[number] /** The plaintext (TypeScript) kind a v3 domain decrypts to. A subset of the * SDK `CastAs` enum, restricted to the scalar kinds v3 domains actually use. */ -type PlaintextKind = 'string' | 'number' | 'bigint' | 'boolean' | DateLikeCast +type PlaintextKind = + | 'string' + | 'number' + | 'bigint' + | 'boolean' + | 'json' + | DateLikeCast /** * The full, literal definition of a v3 domain. This is the LOAD-BEARING type: @@ -57,6 +67,7 @@ type QueryableFlag = D['capabilities'] extends { equality: false orderAndRange: false freeTextSearch: false + searchableJson?: false | undefined } ? false : true @@ -357,6 +368,30 @@ function indexesForCapabilities( indexes.match = defaultMatchOpts() } + if (capabilities.searchableJson) { + // Encrypted-JSONB index (the protect-ffi config kind is named `ste_vec`). + // `prefix: 'enabled'` is a sentinel the table's `build()` rewrites to + // `${tableName}/${columnName}` — the same scheme v2 `searchableJson` uses — + // so each document's selectors are scoped to their column. + // + // `array_index_mode` indexes array elements by identity (`item`) and enables + // `$[*]` wildcard selectors, but deliberately drops `position`: `@>` + // containment must be a subset test (jsonb `@>` semantics), so `{roles:['x']}` + // matches any document whose `roles` array contains `x` REGARDLESS of index. + // With positional terms emitted (v2 `searchableJson`'s `'all'`), the needle + // for `x`-at-index-0 would miss a document holding `x` at index 1. + // + // `mode: 'compat'` is REQUIRED for eql-3.0.0: `public.eql_v3_json` orders the + // document's entries by the CLLW-OPE `op` term, so the index must emit `op` + // (compat) terms. `'standard'` emits v2's CLLW-ORE `oc` terms, which the v3 + // domain rejects at encrypt time. + indexes.ste_vec = { + prefix: 'enabled', + array_index_mode: { item: true, wildcard: true, position: false }, + mode: 'compat', + } + } + return indexes } @@ -374,7 +409,8 @@ function isQueryableCapabilities(capabilities: QueryCapabilities): boolean { return ( capabilities.equality || capabilities.orderAndRange || - capabilities.freeTextSearch + capabilities.freeTextSearch || + (capabilities.searchableJson ?? false) ) } @@ -584,6 +620,31 @@ export class EncryptedDoubleOrdColumn extends EncryptedV3Column< typeof DOUBLE_ORD > {} +// json +const JSON_DOMAIN = { + eqlType: 'public.eql_v3_json', + castAs: 'json', + capabilities: { + equality: false, + orderAndRange: false, + freeTextSearch: false, + searchableJson: true, + }, +} as const + +/** + * Builder for a `public.eql_v3_json` column — an encrypted JSONB document + * (a {@link JsonDocument}: object, array, or null). It round-trips through + * `encrypt`/`decrypt`, and containment queries use the encrypted-JSONB index + * emitted by `build()`. (The stored payload is protect-ffi's `SteVecDocument`, + * and the config index kind is `ste_vec` — both protect-ffi names.) + */ +export class EncryptedJsonColumn extends EncryptedV3Column { + constructor(columnName: string) { + super(columnName, JSON_DOMAIN) + } +} + /** * Union of every v3 concrete column type. Used as the value type for v3 table * columns so a table may mix any generated domains. @@ -628,6 +689,7 @@ export type AnyEncryptedV3Column = | EncryptedDoubleEqColumn | EncryptedDoubleOrdOreColumn | EncryptedDoubleOrdColumn + | EncryptedJsonColumn /** * A factory that builds a concrete v3 column for a given DB column name. @@ -646,6 +708,25 @@ export type EncryptedV3TableColumn = { [key: string]: AnyEncryptedV3Column } +/** A JSON value at any depth. Used for the values NESTED inside a document. */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue } + +/** + * The plaintext a `public.eql_v3_json` column encrypts and reconstructs: a JSON + * DOCUMENT — an object, an array, or `null`. NOT a bare top-level scalar: + * protect-ffi's `json` cast rejects a top-level string/number/boolean + * ("Cannot convert … to Json"), because a scalar belongs in a scalar domain + * (`types.TextEq`, `types.IntegerEq`, …). Nested scalars are ordinary + * {@link JsonValue}s and are fully supported. + */ +export type JsonDocument = null | JsonValue[] | { [key: string]: JsonValue } + /** Map a domain's {@link PlaintextKind} to its TypeScript plaintext type. */ type PlaintextFromKind = K extends 'string' ? string @@ -657,7 +738,9 @@ type PlaintextFromKind = K extends 'string' ? boolean : K extends DateLikeCast ? Date - : never + : K extends 'json' + ? JsonDocument + : never /** * The plaintext type for a single v3 column, read from the literal domain diff --git a/packages/stack/src/eql/v3/drizzle/operators.ts b/packages/stack/src/eql/v3/drizzle/operators.ts index bfdba90a3..dc85db026 100644 --- a/packages/stack/src/eql/v3/drizzle/operators.ts +++ b/packages/stack/src/eql/v3/drizzle/operators.ts @@ -46,6 +46,11 @@ const MAX_IN_ARRAY_CONCURRENCY = 4 * * `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. */ type OperandEncryptionClient = { encrypt( @@ -56,6 +61,12 @@ type OperandEncryptionClient = { 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 } /** @@ -228,7 +239,7 @@ export function createEncryptionOperatorsV3( */ function requireIndex( ctx: ColumnContext, - indexes: readonly ('unique' | 'ore' | 'ope' | 'match')[], + indexes: readonly ('unique' | 'ore' | 'ope' | 'match' | 'ste_vec')[], operator: string, capability: string, ): void { @@ -246,7 +257,13 @@ export function createEncryptionOperatorsV3( // order-capable column answers equality via its ordering term too. const EQUALITY_INDEXES = ['unique', 'ore', 'ope'] as const const ORDERING_INDEXES = ['ore', 'ope'] as const - const MATCH_INDEXES = ['match'] as const + // `contains` answers two shapes: bloom free-text (`match`, a `text_search`/ + // `text_match` column), which emits `eql_v3.contains(col, operand)`, and + // encrypted-JSONB containment (an `eql_v3_json` column, whose config carries + // the `ste_vec` index kind), which emits the `@>` operator instead — json has + // no `eql_v3.contains` overload. The branch in `contains()` picks the right SQL + // by index kind. + const CONTAINMENT_INDEXES = ['match', 'ste_vec'] as const function applyOperationOptions( op: ChainableOperation, @@ -436,12 +453,79 @@ export function createEncryptionOperatorsV3( opts?: EncryptionOperatorCallOpts, ): Promise { const ctx = resolveContext(left, operator) - requireIndex(ctx, MATCH_INDEXES, operator, 'free-text search') + requireIndex( + ctx, + CONTAINMENT_INDEXES, + operator, + 'free-text search or JSON containment', + ) + + // 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 `@>`. + if (ctx.indexes.ste_vec) { + const needle = await encryptJsonContainmentTerm(ctx, right, operator) + 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. requireAnswerableNeedle(ctx, right, operator) const enc = await encryptOperand(ctx, right, operator, 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. + */ + async function encryptJsonContainmentTerm( + ctx: ColumnContext, + value: unknown, + operator: string, + ): Promise { + requireNonNullOperand(ctx, value, operator) + // Reject the empty-object needle. `doc @> '{}'` holds for EVERY document + // (jsonb `{} ⊆ anything`), so `contains(col, {})` would silently return the + // whole table — the same whole-table footgun the bloom path guards against + // with `requireAnswerableNeedle`. An accidental empty filter is a bug, not a + // match-all request; callers wanting every row should omit the predicate. + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 0 + ) { + throw new EncryptionOperatorError( + `Operator "${operator}" cannot take an empty object needle on column "${ctx.columnName}": it matches every row. Pass a non-empty sub-object, or omit the predicate to select all rows.`, + { 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 } + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } + return sql`${JSON.stringify(result.data)}` + } + async function inArrayOp( left: SQLWrapper, values: unknown[], diff --git a/packages/stack/src/eql/v3/drizzle/sql-dialect.ts b/packages/stack/src/eql/v3/drizzle/sql-dialect.ts index 50e2a4b05..b88550d66 100644 --- a/packages/stack/src/eql/v3/drizzle/sql-dialect.ts +++ b/packages/stack/src/eql/v3/drizzle/sql-dialect.ts @@ -41,6 +41,19 @@ export const v3Dialect = { return sql`${fn('contains')}(${left}, ${enc}::jsonb)` }, + /** + * 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). + */ + containsJson(left: SQL, enc: SQL): SQL { + return sql`${left} OPERATOR(public.@>) ${enc}::eql_v3.query_jsonb` + }, + orderBy(left: SQL, flavour: 'ope' | 'ore'): SQL { // eql-3.0.0 splits the ordering extractor by term flavour: `ord_term` // takes the OPE-backed `_ord` domains (returns eql_v3_internal.ope_cllw), diff --git a/packages/stack/src/eql/v3/index.ts b/packages/stack/src/eql/v3/index.ts index 069dc7ad4..620132926 100644 --- a/packages/stack/src/eql/v3/index.ts +++ b/packages/stack/src/eql/v3/index.ts @@ -12,6 +12,8 @@ export type { AnyEncryptedV3Column, EncryptedV3TableColumn, EqlTypeForColumn, + JsonDocument, + JsonValue, PlaintextForColumn, QueryCapabilities, QueryTypesForColumn, @@ -35,6 +37,7 @@ export { EncryptedIntegerEqColumn, EncryptedIntegerOrdColumn, EncryptedIntegerOrdOreColumn, + EncryptedJsonColumn, EncryptedNumericColumn, EncryptedNumericEqColumn, EncryptedNumericOrdColumn, diff --git a/packages/stack/src/eql/v3/types.ts b/packages/stack/src/eql/v3/types.ts index 43a774c38..f14def386 100644 --- a/packages/stack/src/eql/v3/types.ts +++ b/packages/stack/src/eql/v3/types.ts @@ -29,6 +29,7 @@ import { EncryptedIntegerEqColumn, EncryptedIntegerOrdColumn, EncryptedIntegerOrdOreColumn, + EncryptedJsonColumn, EncryptedNumericColumn, EncryptedNumericEqColumn, EncryptedNumericOrdColumn, @@ -184,6 +185,9 @@ export const types = { DoubleOrdOre: (name: string) => new EncryptedDoubleOrdOreColumn(name, DOUBLE_ORD_ORE), DoubleOrd: (name: string) => new EncryptedDoubleOrdColumn(name, DOUBLE_ORD), + + // json (encrypted JSONB document, ste_vec containment) + Json: (name: string) => new EncryptedJsonColumn(name), // `satisfies` is load-bearing, not decoration: `DOMAIN_REGISTRY` derives itself // by calling every value here at module load. A non-factory export would throw // during module evaluation and take the supabase introspect/schema-build/verify diff --git a/packages/test-kit/src/catalog.ts b/packages/test-kit/src/catalog.ts index 93f085fd0..6ebe4287f 100644 --- a/packages/test-kit/src/catalog.ts +++ b/packages/test-kit/src/catalog.ts @@ -20,6 +20,7 @@ import type { AnyEncryptedV3Column, EqlTypeForColumn, + JsonValue, QueryCapabilities, } from '@cipherstash/stack/eql/v3' import { @@ -40,6 +41,7 @@ import { EncryptedIntegerEqColumn, EncryptedIntegerOrdColumn, EncryptedIntegerOrdOreColumn, + EncryptedJsonColumn, EncryptedNumericColumn, EncryptedNumericEqColumn, EncryptedNumericOrdColumn, @@ -103,7 +105,7 @@ export type DomainSpec = Readonly<{ * tell `integer` from `double`, and a fractional value on an int-named domain is * untested territory (it would truncate against a real narrow PG column). */ - samples: ReadonlyArray + samples: ReadonlyArray /** * Values that MUST fail encryption. Number domains reject `NaN`/`±Infinity` * and `bigint` domains reject values outside the signed 64-bit (`int8`) range @@ -665,4 +667,37 @@ export const V3_MATRIX = { samples: DOUBLE_S, errorSamples: NUM_ERR, }, + + // Encrypted JSONB document. Excluded from the SCALAR family driver (that is + // what `deferred` marks here) — NOT unimplemented: JSON is fully covered by + // its own live suites (`json-crypto`, `json-contains`). It is queried by + // containment (the `@>` operator), not the eq/ord/match ops the scalar oracle + // models, so `runFamilySuite` cannot exercise it. The row still pins the built + // shape (`cast_as: 'json'` + the encrypted-JSONB index) and carries + // representative document samples. + 'public.eql_v3_json': { + builder: types.Json, + ColumnClass: EncryptedJsonColumn, + castAs: 'json', + capabilities: { + equality: false, + orderAndRange: false, + freeTextSearch: false, + searchableJson: true, + }, + indexes: { + ste_vec: { + prefix: 'enabled', + array_index_mode: { item: true, wildcard: true, position: false }, + mode: 'compat', + }, + }, + samples: [ + { user: 'ada@example.com', roles: ['admin', 'eng'], active: true }, + { user: 'grace@example.com', roles: ['eng'] }, + ], + deferred: + 'json is covered by dedicated live suites (json-crypto, json-contains), ' + + 'not the scalar op-matrix: it is queried by containment, not eq/ord/match', + }, } as const satisfies Record diff --git a/packages/test-kit/src/families.ts b/packages/test-kit/src/families.ts index e895c9521..4ba86969a 100644 --- a/packages/test-kit/src/families.ts +++ b/packages/test-kit/src/families.ts @@ -26,6 +26,7 @@ export type FamilyName = | 'timestamp' | 'text' | 'boolean' + | 'json' /** * The bare-domain prefixes each family owns. `real-double` pairs two prefixes @@ -46,6 +47,13 @@ const FAMILY_PREFIXES: Readonly> = { timestamp: ['timestamp'], text: ['text'], boolean: ['boolean'], + // `json` is here only for coverage accounting — its one domain (`eql_v3_json`) + // is marked `deferred`, meaning "not run by the scalar op-matrix", because JSON + // is queried by containment (`@>`), not the eq/ord/match ops the oracle models. + // It is NOT unimplemented: dedicated live suites (`json-crypto`, `json-contains`) + // cover it. (Contrast the ORE domains, also `deferred` but because their opclass + // is superuser-only and cannot run on managed Postgres.) + json: ['json'], } export const FAMILY_NAMES = Object.keys(FAMILY_PREFIXES) as FamilyName[] diff --git a/packages/test-kit/src/ops.ts b/packages/test-kit/src/ops.ts index 9bf6724d1..a6d86fb85 100644 --- a/packages/test-kit/src/ops.ts +++ b/packages/test-kit/src/ops.ts @@ -57,6 +57,11 @@ const OPS_BY_CAPABILITY = { equality: ['eq', 'ne', 'in', 'notIn'], orderAndRange: ['gt', 'gte', 'lt', 'lte', 'between', 'notBetween', 'order'], freeTextSearch: ['contains'], + // Encrypted-JSONB containment surfaces as `contains` too (doc @> subset), but + // json domains are `deferred` from this driver (ste_vec doesn't fit the scalar + // oracle) and covered by a dedicated suite, so this entry exists to satisfy + // the capability-keyed type rather than to gate a run. + searchableJson: ['contains'], } as const satisfies Record /** diff --git a/packages/test-kit/src/oracle.ts b/packages/test-kit/src/oracle.ts index 26fefaf66..3006c702f 100644 --- a/packages/test-kit/src/oracle.ts +++ b/packages/test-kit/src/oracle.ts @@ -25,7 +25,10 @@ export function plainValue( if (sample === undefined) { throw new Error(`Domain has no samples for row ${rowKey}`) } - return sample + // Samples widened to include `JsonValue` for the (deferred) json domain; the + // scalar oracle is only ever called on covered scalar domains, so this narrows + // safely. + return sample as Plain } export function comparePlain(left: Plain, right: Plain): number { diff --git a/packages/test-kit/src/run-family-suite.ts b/packages/test-kit/src/run-family-suite.ts index f220e584a..524df7ecf 100644 --- a/packages/test-kit/src/run-family-suite.ts +++ b/packages/test-kit/src/run-family-suite.ts @@ -1,7 +1,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { IntegrationAdapter } from './adapter.ts' import type { DomainSpec } from './catalog.ts' -import { domainsForFamily, type FamilyName } from './families.ts' +import { + deferredForFamily, + domainsForFamily, + type FamilyName, +} from './families.ts' import { negativeOps, type Plain, positiveOps, type QueryOp } from './ops.ts' import { comparePlain, @@ -147,6 +151,25 @@ export function runFamilySuite( const adapter = makeAdapter() const domains = domainsForFamily(family) + // A family whose every domain is deferred (e.g. `json`: containment queries + // don't fit the scalar oracle this driver runs). It carries no op-matrix + // coverage here — its real coverage lives in dedicated suites (`json-crypto`, + // `json-contains`). Emit one assertion documenting the deferral so the family + // stays visible and is not a silent skip, rather than letting `planTable` throw + // on an empty column set. A family with NO domains at all (covered or deferred) + // is a wiring bug and still falls through to that throw. + if (domains.length === 0) { + const deferred = deferredForFamily(family) + if (deferred.length > 0) { + describe(`v3 ${adapter.name} — ${family}`, () => { + it('is fully deferred from the op-matrix; covered by dedicated suites', () => { + for (const { reason } of deferred) expect(reason).not.toBe('') + }) + }) + return + } + } + // Unique per run so a crashed run never leaves a table that shadows the next. const runId = Math.random().toString(36).slice(2, 8) const table = planTable(family, domains, runId) diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 206bf8bef..856e06894 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -774,13 +774,14 @@ const dec = await client.bulkDecryptModels(rows, usersSchema) |---|---| | `eq`, `ne`, `inArray`, `notInArray` | equality (`Eq`, `Ord`, `OrdOre`, `TextSearch`) | | `gt`, `gte`, `lt`, `lte`, `between`, `notBetween`, `asc`, `desc` | order/range (`Ord`, `OrdOre`, `TextSearch`) | -| `contains` | free-text (`TextMatch`, `TextSearch`) | +| `contains` | free-text (`TextMatch`, `TextSearch`) **or** encrypted-JSONB containment (`Json`) | | `and`, `or` | combinators — accept lazy (un-awaited) operators and `undefined`, resolve concurrently | | `isNull`, `isNotNull`, `not`, `exists`, `notExists` | Drizzle passthroughs, no encryption | Differences from the v2 operators to know about: - **`like` / `ilike` do not exist — by design.** v3 free-text search is tokenised containment, not SQL pattern matching; `contains(col, needle)` is the free-text operator. Don't pass `%` wildcards. +- **`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. diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index a2aee1353..794e2116b 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -632,6 +632,7 @@ Each factory in `types` maps 1:1 to a Postgres domain named `public.eql_v3_` semantics). Array containment is a subset test +regardless of element position — `{ roles: ['admin'] }` matches any document +whose `roles` array includes `admin`. + +```typescript +const events = encryptedTable("events", { metadata: types.Json("metadata") }) + +// containment: object needle +await client.encryptQuery({ roles: ["admin"] }, { column: events.metadata, table: events }) +``` + +Through the Drizzle v3 integration this is `ops.contains(col, subObject)` — see +the `stash-drizzle` skill. `types.Json` carries no equality or ordering, so +`eq` / `gt` / `asc` on it throw. + +> **Not yet implemented:** JSONPath selector-with-constraint queries +> (`metadata->'plan' = $1`, `metadata->'age' > $1`) — a distinct third pattern +> the `eql_v3_json` domain supports at the SQL level (`->` / `->>`). Neither the +> query operator nor the selector-string needle typing is wired up yet; tracked +> in [#623](https://github.com/cipherstash/stack/issues/623). + ### Strongly-Typed Client: `EncryptionV3` `EncryptionV3` from `@cipherstash/stack/v3` returns a `TypedEncryptionClient` whose method signatures are derived from your schemas — wrong-typed plaintext is rejected at compile time, and query methods only accept queryable columns with `queryType` constrained to the column's capabilities: