diff --git a/.changeset/remove-eql-v2-drizzle-root.md b/.changeset/remove-eql-v2-drizzle-root.md new file mode 100644 index 000000000..64d918746 --- /dev/null +++ b/.changeset/remove-eql-v2-drizzle-root.md @@ -0,0 +1,27 @@ +--- +'@cipherstash/stack-drizzle': major +'@cipherstash/stack': patch +'stash': patch +--- + +Remove the EQL v2 authoring surface from `@cipherstash/stack-drizzle` and collapse the EQL v3 `./v3` subpath into the package root. + +**Breaking (`@cipherstash/stack-drizzle`):** + +- The EQL v2 root exports are gone: `encryptedType`, the v2 `extractEncryptionSchema`, the v2 `createEncryptionOperators` (including the `like` / `ilike` operators), and `EncryptionConfigError`. Authoring or querying `eql_v2_encrypted` columns through Drizzle is no longer supported. +- The `./v3` subpath is **removed** from the package `exports` map and `typesVersions`. The EQL v3 implementation is now the package root, and the `*V3` names are de-suffixed (`createEncryptionOperatorsV3` → `createEncryptionOperators`, `extractEncryptionSchemaV3` → `extractEncryptionSchema`). This is a **hard break with no alias**: post-collapse the root names would collide with the removed v2 names, and keeping an alias would silently type-check v2 call sites against v3 semantics. + +**Migration** — import the v3 surface from the package root instead of `./v3`, and drop the `V3` suffix: + +```diff +- import { types, extractEncryptionSchemaV3, createEncryptionOperatorsV3 } from '@cipherstash/stack-drizzle/v3' ++ import { types, extractEncryptionSchema, createEncryptionOperators } from '@cipherstash/stack-drizzle' +``` + +The `types.*` column factories, `makeEqlV3Column` / `getEqlV3Column` / `isEqlV3Column`, the codec helpers (`v3ToDriver` / `v3FromDriver` / `EqlV3CodecError`), and `EncryptionOperatorError` are unchanged apart from moving to the root. + +Existing EQL v2 ciphertext remains decryptable via `@cipherstash/stack` — only the Drizzle-side v2 authoring and query-building is removed. + +**`stash` (patch):** `stash init --drizzle` scaffolded the removed surface — the generated encryption-client file and the Drizzle placeholder both imported `extractEncryptionSchemaV3` from `@cipherstash/stack-drizzle/v3`, so a freshly-initialised project would not resolve against this release. Both now emit the collapsed root import. The bundled `stash-drizzle` and `stash-encryption` skills are updated to match. + +**`@cipherstash/stack` (patch):** README only — its Drizzle section documented the removed `./v3` subpath and the `*V3` export names. diff --git a/.github/workflows/tests-bench.yml b/.github/workflows/tests-bench.yml index eb3691d1d..5f1d8c5ad 100644 --- a/.github/workflows/tests-bench.yml +++ b/.github/workflows/tests-bench.yml @@ -1,20 +1,37 @@ name: "Test Benchmark JS" +# Trigger paths follow the package GRAPH, not the bench directory. bench depends +# on `@cipherstash/stack` and `@cipherstash/stack-drizzle`, and (since the fixture +# schema moved to EQL v3 domains) on the CLI's EQL installer — a break in any of +# them can red this job, so an edit to any of them has to be able to trigger it. +# Scoped the same way the `integration-*` workflows scope theirs rather than +# taking whole packages, to keep the trigger surface honest. on: push: branches: - 'main' paths: - 'packages/bench/**' + - 'packages/stack/src/eql/v3/**' + - 'packages/stack-drizzle/**' + - 'packages/test-kit/**' + - 'packages/cli/src/installer/**' - 'local/**' - '.github/workflows/tests-bench.yml' + - '.github/actions/integration-setup/**' pull_request: branches: - "**" + # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. paths: - 'packages/bench/**' + - 'packages/stack/src/eql/v3/**' + - 'packages/stack-drizzle/**' + - 'packages/test-kit/**' + - 'packages/cli/src/installer/**' - 'local/**' - '.github/workflows/tests-bench.yml' + - '.github/actions/integration-setup/**' jobs: tests-bench: @@ -25,25 +42,14 @@ jobs: - name: Checkout Repo uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6.0.9 - name: Install pnpm - with: - run_install: false - - - name: Install Node.js - uses: actions/setup-node@v6.4.0 + # The shared integration preamble, which also builds the `stash` CLI — not + # incidental here: the bench `globalSetup` installs EQL v3 by shelling out + # to the real `stash eql install --eql-version 3`. + - uses: ./.github/actions/integration-setup with: + # This job's existing Node; the composite defaults to 24 for the + # integration suites. node-version: 22 - cache: 'pnpm' - - # node-pty's install hook falls back to `node-gyp rebuild` when no - # linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships - # node-gyp on PATH, so install it explicitly. - - name: Install node-gyp - run: npm install -g node-gyp - - - name: Install dependencies - run: pnpm recursive install --frozen-lockfile # `@cipherstash/stack` ships dist/-based `exports`; bench imports # from `@cipherstash/stack` and `@cipherstash/stack-drizzle` (the Drizzle @@ -54,11 +60,16 @@ jobs: - name: Build stack + adapter packages run: pnpm exec turbo run build --filter @cipherstash/stack --filter @cipherstash/stack-drizzle - # Starts the pinned postgres-eql container (PostgreSQL 17 + EQL - # pre-installed) via local/docker-compose.yml; waits for healthcheck. - - name: Start local Postgres (EQL) + # Service-scoped `postgres`: local/docker-compose.yml also defines a + # PostgREST that bench never talks to. + # + # This container has EQL v2 baked in and NOTHING to do with the v3 domains + # the fixture schema needs — those come from the `globalSetup` install + # below. Do not add an EQL step here; keeping the install inside vitest is + # what makes `pnpm test:local` and CI the same path. + - name: Start local Postgres working-directory: local - run: docker compose up --wait --wait-timeout 60 + run: docker compose up --wait --wait-timeout 60 postgres # `pnpm test:local` resolves to `vitest run`; the trailing `db-only` # is a vitest path filter that narrows to __tests__/db-only.test.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aff5d1da9..d98cde74b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -136,6 +136,16 @@ jobs: - name: Typecheck (prisma-next — enforces v3 operator-capability gating) run: pnpm --filter @cipherstash/prisma-next run typecheck + # `packages/bench` is a live importer of `@cipherstash/stack` and + # `@cipherstash/stack-drizzle`, but it has no `test` script (its suites + # need a database), so `pnpm run test` never reaches it and an adapter + # rename could break it unnoticed. Its `build` is `tsc --noEmit`, and + # turbo's `^build` builds the two adapters first. This also runs in the + # Bun job's full `turbo build`, but that job swallows its own test + # failures — the importer guard should not depend on it. + - name: Typecheck (bench — guards the stack/stack-drizzle importers) + run: pnpm exec turbo run build --filter @cipherstash/bench + - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners diff --git a/AGENTS.md b/AGENTS.md index 4f29f4fe4..74587aff0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ If these variables are missing, tests that require live encryption will fail or - `packages/wizard`: AI-powered encryption setup (`@cipherstash/wizard`) - `packages/migrate`: Plaintext-to-encrypted column migration (`@cipherstash/migrate`) — resumable backfill, per-column state - `packages/prisma-next`: Prisma Next integration (`@cipherstash/prisma-next`) — searchable field-level encryption for Postgres. **EQL v3 only**: per-domain constructors (`cipherstash.TextSearch()` / `text()` / `bigIntOrd()` / …) and `cipherstashFromStack` (the `./v3` and `./stack` entries). The EQL v2 surface was removed — the adapter's baseline migration installs the EQL v3 bundle only (works on Supabase as a non-superuser) -- `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — EQL v2 (`.`) and EQL v3 (`./v3`). Split out of `@cipherstash/stack`. +- `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — **EQL v3 only**, on the package root (the v2 surface was removed and the old `./v3` subpath collapsed into `.`). Split out of `@cipherstash/stack`. - `packages/stack-supabase`: Supabase integration (`@cipherstash/stack-supabase`), depends on `@cipherstash/stack` — `encryptedSupabase` (v2) and `encryptedSupabaseV3` (v3). Split out of `@cipherstash/stack`. - `packages/nextjs`: Next.js helpers and Clerk integration (`./clerk` export) - `packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`) @@ -153,7 +153,7 @@ Three rules to remember when editing CI or pnpm config: - `encryptQuery(terms[])` for batch query encryption - **Identity-aware encryption**: Authenticate the client as the end user with `OidcFederationStrategy` (`config.authStrategy`, re-exported from `@cipherstash/stack`), then chain `.withLockContext({ identityClaim })` on operations to bind the data key to a claim. The same claim must be used for encrypt and decrypt. (`LockContext.identify()` from `@cipherstash/stack/identity` is deprecated — the strategy now handles token acquisition; `.withLockContext()` also accepts a `LockContext`.) - **Integrations**: - - **Drizzle ORM**: `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack-drizzle` (EQL v3 factories from `@cipherstash/stack-drizzle/v3`) + - **Drizzle ORM**: `types.*` column factories, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack-drizzle` - **Supabase**: `encryptedSupabase` (v2) / `encryptedSupabaseV3` (v3) from `@cipherstash/stack-supabase` - **DynamoDB**: `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` diff --git a/packages/bench/__benches__/drizzle/operators.bench.ts b/packages/bench/__benches__/drizzle/operators.bench.ts index 4dd7c99af..c7079a5a9 100644 --- a/packages/bench/__benches__/drizzle/operators.bench.ts +++ b/packages/bench/__benches__/drizzle/operators.bench.ts @@ -44,8 +44,15 @@ describe('drizzle', () => { await handle.db.select().from(benchTable).where(where) }) - bench('like (prefix)', async () => { - const where = (await ops.like(benchTable.encText, '%value-00000%')) as SQL + bench('matches (free-text)', async () => { + // A FULL seeded value, not the shared `value` prefix: every row is + // `value-<7 digits>`, so a bare `value` needle matches all 10k rows and the + // bloom index has nothing to narrow — the number would measure a full scan + // rather than the index path this bench exists to measure. + const where = (await ops.matches( + benchTable.encText, + 'value-0000042', + )) as SQL await handle.db.select().from(benchTable).where(where) }) diff --git a/packages/bench/__tests__/drizzle/operators.explain.test.ts b/packages/bench/__tests__/drizzle/operators.explain.test.ts index 9aec76d4f..546f4fb2c 100644 --- a/packages/bench/__tests__/drizzle/operators.explain.test.ts +++ b/packages/bench/__tests__/drizzle/operators.explain.test.ts @@ -104,13 +104,19 @@ async function tryExplainWhere(name: string, where: SQL): Promise { // --- #421: equality + array operators ------------------------------------- // -// `bench_text_hmac_idx` (functional hash on eql_v2.hmac_256) is the expected -// fast path. Pre-fix Drizzle emits bare `=` / `<>` / `IN (...)` which falls -// back to seq scan. Post-fix it emits `eql_v2.hmac_256(col) = -// eql_v2.hmac_256(value)` and the index scan kicks in. +// `bench_text_hmac_idx` (functional hash on eql_v3.eq_term) is the expected +// fast path. The v3 operators do NOT emit that expression directly — they emit +// the wrapper `eql_v3.eq(col, $1::eql_v3.query_text_search)`. It engages the +// index because the wrapper is `LANGUAGE sql IMMUTABLE STRICT` over a single +// SELECT, so the planner inlines it to `eql_v3.eq_term(col) = +// eql_v3.eq_term($1)` — and applies the same inlining to the stored index +// expression, which is how the two meet. Break the inlinability (plpgsql body, +// VOLATILE) and every assertion below silently degrades to a seq scan. +// `scripts/__tests__/bench-index-expressions.test.mjs` pins that contract +// against the shipped bundle without needing a database. // // `eq` and `inArray` are naturally high-selectivity (only a few rows match), -// so the planner should pick the hmac index — assertion enforces it. +// so the planner should pick the eq_term index — assertion enforces it. // // `ne` and `notInArray` are naturally low-selectivity (almost all rows match); // even with the hmac index available the planner correctly chooses a seq @@ -161,14 +167,12 @@ describe('#421: equality and array operators', () => { // We don't yet know which call-shaped forms the planner inlines. Record plan // shape; assertions land in a follow-up once #422 closes. describe('#422: call-shaped operators (recorded, not asserted)', () => { - it('records like / ilike plan shapes', async () => { + it('records matches plan shape', async () => { await tryExplainWhere( - 'like', - (await ops.like(benchTable.encText, '%value-00000%')) as SQL, - ) - await tryExplainWhere( - 'ilike', - (await ops.ilike(benchTable.encText, '%VALUE-00000%')) as SQL, + 'matches', + // A full seeded value — `value` alone is in every row, so the recorded + // plan would say nothing about the bloom index. See operators.bench.ts. + (await ops.matches(benchTable.encText, 'value-0000042')) as SQL, ) }) @@ -190,20 +194,15 @@ describe('#422: call-shaped operators (recorded, not asserted)', () => { ) }) - it('records jsonb operator plan shapes', async () => { - for (const [name, build] of [ - [ - 'jsonbPathQueryFirst', - () => ops.jsonbPathQueryFirst(benchTable.encJsonb, '$.idx'), - ], - ['jsonbGet', () => ops.jsonbGet(benchTable.encJsonb, '$.idx')], - [ - 'jsonbPathExists', - () => ops.jsonbPathExists(benchTable.encJsonb, '$.idx'), - ], - ] as const) { - await tryExplainWhere(name, await build()) - } + it('records encrypted-JSONB plan shapes (contains / selector)', async () => { + await tryExplainWhere( + 'contains', + (await ops.contains(benchTable.encJsonb, { idx: 42 })) as SQL, + ) + await tryExplainWhere( + 'selector.gt', + (await ops.selector(benchTable.encJsonb, '$.idx').gt(5000)) as SQL, + ) }) it('records ORDER BY plan shape (asc / desc)', async () => { diff --git a/packages/bench/package.json b/packages/bench/package.json index 1aa5b847a..4134c57ce 100644 --- a/packages/bench/package.json +++ b/packages/bench/package.json @@ -5,6 +5,7 @@ "description": "Performance / index-engagement benchmarks for stack integrations (Drizzle, encryptedSupabase, Prisma).", "type": "module", "scripts": { + "build": "tsc --noEmit", "db:setup": "tsx src/cli/setup.ts", "db:reset": "tsx src/cli/reset.ts", "test:local": "vitest run", @@ -17,6 +18,7 @@ "pg": "^8.22.0" }, "devDependencies": { + "@cipherstash/test-kit": "workspace:*", "@types/node": "^22.20.1", "@types/pg": "^8.20.0", "tsx": "catalog:repo", diff --git a/packages/bench/sql/schema.sql b/packages/bench/sql/schema.sql index cf861d2be..92e11c4e9 100644 --- a/packages/bench/sql/schema.sql +++ b/packages/bench/sql/schema.sql @@ -1,30 +1,51 @@ -- Bench fixture schema. -- Single bench table covering text / int / jsonb encrypted columns plus the --- three canonical EQL functional indexes: hmac_256 (hash), bloom_filter (GIN), --- ste_vec (GIN). +-- three canonical EQL v3 functional indexes. -- --- We deliberately do NOT create the `eql_v2.encrypted_operator_class` btree --- indexes that ore-benches uses. Encrypted composites for full-feature columns --- (equality + match + ORE) blow past the 2704-byte btree page-size limit, and --- those indexes don't exist on Supabase anyway — the bench's whole job is to --- validate that the functional-index path works. +-- Mirrors `src/drizzle/setup.ts`: the columns are concrete `eql_v3_*` Postgres +-- domains (see `types.TextSearch` / `types.IntegerOrd` / `types.Json`). Install +-- the domains and index-term functions first with `stash eql install --eql-version 3`. +-- +-- Each index expression must be the expression the matching EQL v3 operator +-- INLINES TO, not merely a term extractor for the same column. The adapter +-- emits a function call (`eql_v3.eq(col, term)`, `eql_v3.matches(...)`, +-- `col @> needle`); every one of those is `LANGUAGE sql IMMUTABLE STRICT` +-- with a single-SELECT body, so the planner inlines it, and Postgres applies +-- the same inlining to the stored index expression. The two only meet if the +-- index is built on the inlined form: +-- +-- eql_v3.eq(a, b) -> eql_v3.eq_term(a) = eql_v3.eq_term(b) +-- eql_v3.matches(a, b) -> eql_v3.match_term(a) @> eql_v3.match_term(b) +-- a @> b (json) -> eql_v3.to_ste_vec_query(a)::jsonb +-- @> eql_v3.to_ste_vec_query(b)::jsonb +-- +-- That last one is why the JSON index is NOT on `eql_v3.ste_vec(...)`: that +-- function exists and the index builds happily, but nothing the adapter emits +-- ever mentions it, so the index is dead weight. The bundle says so itself, on +-- `eql_v3."@>"(eql_v3_json_search, eql_v3.query_json)`: "Inlines to native +-- `jsonb @>` over `eql_v3.to_ste_vec_query(a)::jsonb`, so a functional GIN +-- index on the same expression engages." +-- +-- We deliberately do NOT create btree operator-class indexes: encrypted terms for +-- full-feature columns blow past the 2704-byte btree page-size limit, and the +-- bench's whole job is to validate that the functional-index path works. DROP TABLE IF EXISTS bench; CREATE TABLE bench ( id SERIAL PRIMARY KEY, - enc_text eql_v2_encrypted NOT NULL, - enc_int eql_v2_encrypted NOT NULL, - enc_jsonb eql_v2_encrypted NOT NULL + enc_text public.eql_v3_text_search NOT NULL, + enc_int public.eql_v3_integer_ord NOT NULL, + enc_jsonb public.eql_v3_json_search NOT NULL ); CREATE INDEX bench_text_hmac_idx - ON bench USING hash (eql_v2.hmac_256(enc_text)); + ON bench USING hash (eql_v3.eq_term(enc_text)); CREATE INDEX bench_text_bloom_idx - ON bench USING gin (eql_v2.bloom_filter(enc_text)); + ON bench USING gin (eql_v3.match_term(enc_text)); CREATE INDEX bench_jsonb_stevec_idx - ON bench USING gin (eql_v2.ste_vec(enc_jsonb)); + ON bench USING gin ((eql_v3.to_ste_vec_query(enc_jsonb)::jsonb)); ANALYZE bench; diff --git a/packages/bench/src/drizzle/setup.ts b/packages/bench/src/drizzle/setup.ts index 944ece227..16ba56d2e 100644 --- a/packages/bench/src/drizzle/setup.ts +++ b/packages/bench/src/drizzle/setup.ts @@ -1,9 +1,5 @@ -import { Encryption } from '@cipherstash/stack' -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { - encryptedType, - extractEncryptionSchema, -} from '@cipherstash/stack-drizzle' +import { EncryptionV3 } from '@cipherstash/stack/v3' +import { extractEncryptionSchema, types } from '@cipherstash/stack-drizzle' import { drizzle } from 'drizzle-orm/node-postgres' import { pgTable, serial } from 'drizzle-orm/pg-core' import pg from 'pg' @@ -12,34 +8,23 @@ import { getDatabaseUrl } from '../harness/db.js' /** * Drizzle schema for the bench table. Mirrors `sql/schema.sql`. * - * `id` is `serial`; the encrypted columns are `eql_v2_encrypted` composites - * driven by `@cipherstash/stack-drizzle`'s `encryptedType`. + * `id` is `serial`; the encrypted columns are concrete `eql_v3_*` Postgres + * domains emitted by `@cipherstash/stack-drizzle`'s `types.*` factories. * - * Index config flags (`equality`, `freeTextSearch`, `orderAndRange`, - * `searchableJson`) are deliberately all on — the bench needs to exercise - * every query family that lands on the table. + * The domains are chosen to exercise every query family the bench lands on the + * table: `TextSearch` (equality + free-text + order/range), `IntegerOrd` + * (equality + order/range), and `Json` (encrypted-JSONB containment + selector). */ export const benchTable = pgTable('bench', { id: serial('id').primaryKey(), - encText: encryptedType('enc_text', { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }), - encInt: encryptedType('enc_int', { - dataType: 'number', - equality: true, - orderAndRange: true, - }), - encJsonb: encryptedType<{ idx: number; group: number }>('enc_jsonb', { - dataType: 'json', - searchableJson: true, - }), + encText: types.TextSearch('enc_text'), + encInt: types.IntegerOrd('enc_int'), + encJsonb: types.Json('enc_jsonb'), }) /** - * Encryption schema for the stack `Encryption()` client. Derived from the - * Drizzle table above so the two can't drift apart. + * Encryption schema for the `EncryptionV3()` client. Derived from the Drizzle + * table above so the two can't drift apart. */ export const encryptionBenchTable = extractEncryptionSchema(benchTable) @@ -49,11 +34,26 @@ export type BenchPlaintextRow = { enc_jsonb: { idx: number; group: number } } +/** + * Build the typed EQL v3 client this bench drives. Wrapped in a + * single-signature helper because `EncryptionV3` is now overloaded (typed v3 + * vs. nominal) — `ReturnType` resolves to the *last* + * (nominal) overload, so we infer the return type through this helper instead. + */ +function makeEncryptionClient() { + return EncryptionV3({ schemas: [encryptionBenchTable] }) +} + +/** The typed EQL v3 client this bench drives. */ +export type BenchEncryptionClient = Awaited< + ReturnType +> + export type BenchHandle = { pgClient: pg.Client pool: pg.Pool db: ReturnType - encryptionClient: EncryptionClient + encryptionClient: BenchEncryptionClient } /** @@ -69,7 +69,7 @@ export async function buildBench(): Promise { const db = drizzle(pool) - const encryptionClient = await Encryption({ schemas: [encryptionBenchTable] }) + const encryptionClient = await makeEncryptionClient() return { pgClient, pool, db, encryptionClient } } diff --git a/packages/bench/src/harness/global-setup.ts b/packages/bench/src/harness/global-setup.ts new file mode 100644 index 000000000..ffb318a6e --- /dev/null +++ b/packages/bench/src/harness/global-setup.ts @@ -0,0 +1,31 @@ +// The `/install` subpath, NOT the barrel: the barrel reaches `needle-for.ts`, +// which imports stack source through the `@/` alias, and bench has no +// `stackSourceAlias` in its vitest config (it consumes stack through its +// published dist/ exports). `install.ts` depends on node builtins only. +import { installEqlV3 } from '@cipherstash/test-kit/install' +import { getDatabaseUrl } from './db.js' + +/** + * Install EQL v3 once per run, before any suite applies `sql/schema.sql`. + * + * The fixture schema declares its columns as the concrete `eql_v3_*` domains, so + * the bundle has to be in the database before the first `CREATE TABLE`. Doing it + * here rather than in a CI-only step means the local `pnpm test:local` path and + * the CI path are the same path — the previous arrangement had the workflow rely + * on an EQL-v2-preinstalled image while the schema had already moved to v3, and + * nothing connected the two. + * + * Runs through the real `stash eql install`, matching the integration suites' + * harness (`@cipherstash/test-kit`'s `integration/global-setup.ts`): an installer + * regression fails here instead of hiding behind a test-only SQL apply. + * + * Unlike those suites this needs NO CipherStash credentials — `installEqlV3` + * wants only a database URL — which keeps `db-only.test.ts` the credential-free + * smoke test it is meant to be. + */ +export async function setup(): Promise { + // EXPLICIT, never inferred from the environment. `dbVariant()` would guess + // from `PGRST_URL`, and bench's compose stack happens to define a PostgREST + // it never talks to — a guess here would silently apply the Supabase grants. + await installEqlV3(getDatabaseUrl(), 'postgres') +} diff --git a/packages/bench/src/harness/seed.ts b/packages/bench/src/harness/seed.ts index c25d21c7a..e9fc828b6 100644 --- a/packages/bench/src/harness/seed.ts +++ b/packages/bench/src/harness/seed.ts @@ -53,11 +53,10 @@ export async function seed( plaintexts.push(makePlaintextRow(rowsBefore + i)) } - const encResult = - await h.encryptionClient.bulkEncryptModels( - plaintexts, - encryptionBenchTable, - ) + const encResult = await h.encryptionClient.bulkEncryptModels( + plaintexts, + encryptionBenchTable, + ) if (encResult.failure) { throw new Error( `[bench:seed] bulkEncryptModels failed: ${encResult.failure.message}`, @@ -65,12 +64,12 @@ export async function seed( } // bulkEncryptModels returns rows keyed by the encryptedTable column names - // (snake_case here). Drizzle's `benchTable` uses camelCase TS field names — - // remap before insert. + // (snake_case here) with encrypted EQL v3 envelopes as values. Drizzle's + // `benchTable` uses camelCase TS field names — remap before insert. const encRows = encResult.data.map((r) => ({ - encText: r.enc_text as unknown as string, - encInt: r.enc_int as unknown as number, - encJsonb: r.enc_jsonb as unknown as { idx: number; group: number }, + encText: r.enc_text, + encInt: r.enc_int, + encJsonb: r.enc_jsonb, })) for (let i = 0; i < encRows.length; i += INSERT_BATCH) { diff --git a/packages/bench/vitest.config.ts b/packages/bench/vitest.config.ts index c0f631c04..ac80b923e 100644 --- a/packages/bench/vitest.config.ts +++ b/packages/bench/vitest.config.ts @@ -3,6 +3,14 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['__tests__/**/*.test.ts'], + // Installs EQL v3 before any suite applies `sql/schema.sql`, which declares + // its columns as `eql_v3_*` domains. See the file for why this is not a + // CI-only step. + globalSetup: ['./src/harness/global-setup.ts'], + // `@cipherstash/test-kit` is consumed as unbuilt TypeScript source, so it + // must not be externalized — same reason the integration suites carry this + // (`packages/test-kit/src/integration/config.ts`). + server: { deps: { inline: [/packages\/test-kit/] } }, testTimeout: 300_000, hookTimeout: 300_000, pool: 'forks', diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts new file mode 100644 index 000000000..d30246f14 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import type { SchemaDef } from '../types.js' +import { + generateClientFromSchemas, + generatePlaceholderClient, +} from '../utils.js' + +// `stash init --drizzle` writes these strings into the USER's repo as real +// source. Nothing type-checks a template literal, so a rename in +// `@cipherstash/stack-drizzle` cannot break the build here — it breaks the +// scaffolded project instead, in someone else's checkout. +// +// `utils-codegen.test.ts` covers the generated client's happy path. This file +// covers the surface that had no test at all — `generatePlaceholderClient`, +// whose doc block is a copy-paste reference the handoff agent works from — and +// asserts, for BOTH strings, that nothing removed with EQL v2 survives: +// - the `@cipherstash/stack-drizzle/v3` specifier (`./v3` collapsed into `.`) +// - `extractEncryptionSchemaV3` / `createEncryptionOperatorsV3` +// - `encryptedType`, the v2 config-flag column factory + +const SCHEMAS: SchemaDef[] = [ + { + tableName: 'users', + columns: [ + { name: 'email', domain: 'TextSearch' }, + { name: 'age', domain: 'IntegerOrd' }, + ], + }, +] + +/** Every drizzle-flavoured string `stash init` can write into a user's repo. */ +const GENERATED_SOURCES: Array<[string, string]> = [ + ['generateClientFromSchemas', generateClientFromSchemas('drizzle', SCHEMAS)], + ['generatePlaceholderClient', generatePlaceholderClient('drizzle')], +] + +describe.each( + GENERATED_SOURCES, +)('drizzle scaffold — %s', (_name, generated) => { + it('names the drizzle package root, never the removed ./v3 subpath', () => { + expect(generated).toContain('@cipherstash/stack-drizzle') + expect(generated).not.toContain('@cipherstash/stack-drizzle/v3') + }) + + it('uses the de-suffixed export names, never the removed *V3 aliases', () => { + expect(generated).not.toContain('extractEncryptionSchemaV3') + expect(generated).not.toContain('createEncryptionOperatorsV3') + }) + + it('never scaffolds the removed EQL v2 authoring surface', () => { + expect(generated).not.toContain('encryptedType') + expect(generated).not.toContain('eql_v2_encrypted') + }) +}) + +describe('generatePlaceholderClient (drizzle)', () => { + const placeholder = generatePlaceholderClient('drizzle') + + it('shows the harvest pattern with the collapsed root import', () => { + expect(placeholder).toContain( + "import { extractEncryptionSchema } from '@cipherstash/stack-drizzle'", + ) + expect(placeholder).toContain('extractEncryptionSchema(users)') + }) +}) diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts index 1221065d5..5aabc73d4 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts @@ -27,8 +27,15 @@ describe('generateClientFromSchemas', () => { const out = generateClientFromSchemas('drizzle', schemas) expect(out).toContain("email: types.TextSearch('email'),") expect(out).toContain("age: types.IntegerOrd('age'),") - expect(out).toContain('extractEncryptionSchemaV3') - expect(out).toContain("from '@cipherstash/stack-drizzle/v3'") + // The collapsed root: `@cipherstash/stack-drizzle` dropped its EQL v2 + // surface and folded `./v3` into `.`, de-suffixing the exports. The + // negatives matter as much as the positives — this string is written into + // the user's repo as real source, and nothing type-checks a template + // literal, so the removed names can only be caught here. + expect(out).toContain('extractEncryptionSchema(') + expect(out).toContain("from '@cipherstash/stack-drizzle'") + expect(out).not.toContain('extractEncryptionSchemaV3') + expect(out).not.toContain('@cipherstash/stack-drizzle/v3') }) it('carries no residual v2 capability vocabulary', () => { diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 364728eea..6ff780d41 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -273,7 +273,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { 'Use when the column **does not yet exist** in the database (no plaintext predecessor to preserve). This is normal Drizzle / Supabase work plus the encryption client patterns from the integration skill.', '', "1. **If this is the first encrypted column in the project, configure the bundler exclusion first.** `@cipherstash/stack` cannot be bundled (it wraps a native FFI module). Next.js: add `serverExternalPackages: ['@cipherstash/stack', '@cipherstash/protect-ffi']` to `next.config.*`. Webpack: `externals`. esbuild: `external`. Vite SSR: `ssr.external`. Without this, the encryption client crashes at runtime with `Cannot find module '@cipherstash/protect-ffi-*'`. See the `stash-encryption` skill's Installation section for the full snippets.", - "2. Edit the user's real schema file (`src/db/schema.ts` or wherever they keep it) to declare the new encrypted column. Use the patterns in the integration skill — `encryptedType` for Drizzle, `encryptedColumn` for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. Never `.notNull()`.", + "2. Edit the user's real schema file (`src/db/schema.ts` or wherever they keep it) to declare the new encrypted column. Use the patterns in the integration skill — the `types.*` domain factories for Drizzle, `encryptedColumn` for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. Never `.notNull()`.", `3. Generate the schema migration${migration ? ` — \`${migration.generate}\` (${migration.tool})` : " using the project's existing migration tooling"}.`, `4. Show the user the generated SQL before applying${migration ? ` — \`${migration.apply}\`` : ''}.`, '5. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).', diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 3045433d7..68526460e 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -279,13 +279,13 @@ ${columnDefs.join('\n')} createdAt: timestamp('created_at').defaultNow(), }) -const ${schemaVarName} = extractEncryptionSchemaV3(${varName})` +const ${schemaVarName} = extractEncryptionSchema(${varName})` }) const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`) return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' -import { types, extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' +import { types, extractEncryptionSchema } from '@cipherstash/stack-drizzle' import { EncryptionV3 } from '@cipherstash/stack/v3' ${tableDefs.join('\n\n')} @@ -389,7 +389,7 @@ const DRIZZLE_PLACEHOLDER = `/** * pointing at this file. * * This project uses EQL v3. Encrypted columns are concrete Postgres domains - * built with the \`types.*\` factories from \`@cipherstash/stack-drizzle/v3\`. + * built with the \`types.*\` factories from \`@cipherstash/stack-drizzle\`. * Each domain's query capabilities are FIXED by the type you pick — there is * no capability config object. Choose the factory whose capabilities you need: * types.Text / types.Integer / … storage only (encrypt/decrypt, no queries) @@ -404,7 +404,7 @@ const DRIZZLE_PLACEHOLDER = `/** * Encrypted twin column for an existing populated column (path 3 — lifecycle): * * import { pgTable, integer, text } from 'drizzle-orm/pg-core' - * import { types } from '@cipherstash/stack-drizzle/v3' + * import { types } from '@cipherstash/stack-drizzle' * * export const users = pgTable('users', { * id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -421,12 +421,12 @@ const DRIZZLE_PLACEHOLDER = `/** * * Once you have encrypted tables declared, harvest them and pass to EncryptionV3(): * - * import { extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' + * import { extractEncryptionSchema } from '@cipherstash/stack-drizzle' * import { EncryptionV3 } from '@cipherstash/stack/v3' * import { users, orders } from './db/schema' * * export const encryptionClient = await EncryptionV3({ - * schemas: [extractEncryptionSchemaV3(users), extractEncryptionSchemaV3(orders)], + * schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)], * }) */ import { EncryptionV3 } from '@cipherstash/stack/v3' diff --git a/packages/stack-drizzle/README.md b/packages/stack-drizzle/README.md index e78a27caf..0d5b0efb4 100644 --- a/packages/stack-drizzle/README.md +++ b/packages/stack-drizzle/README.md @@ -9,7 +9,7 @@ Depends on `@cipherstash/stack`; install both: npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm ``` -## EQL v3 (`/v3` subpath) +## Usage (EQL v3) Each encrypted column is a concrete `public.eql_v3_*` Postgres domain whose query capabilities are fixed by the `types.*` factory you choose — no per-column config @@ -20,9 +20,9 @@ import { pgTable, integer } from 'drizzle-orm/pg-core' import { EncryptionV3 } from '@cipherstash/stack/v3' import { types as encryptedTypes, - extractEncryptionSchemaV3, - createEncryptionOperatorsV3, -} from '@cipherstash/stack-drizzle/v3' + extractEncryptionSchema, + createEncryptionOperators, +} from '@cipherstash/stack-drizzle' const users = pgTable('users', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -30,9 +30,9 @@ const users = pgTable('users', { age: encryptedTypes.IntegerOrd('age'), // equality + order/range }) -const schema = extractEncryptionSchemaV3(users) +const schema = extractEncryptionSchema(users) const client = await EncryptionV3({ schemas: [schema] }) -const ops = createEncryptionOperatorsV3(client) +const ops = createEncryptionOperators(client) // Insert — encrypt models first const enc = await client.bulkEncryptModels( @@ -60,20 +60,6 @@ comparisons and ordering at a scalar JSONPath leaf. For example, `.orderBy(await ops.selector(users.profile, '$.age').asc())` lowers to `ORDER BY eql_v3.ord_term(...)` over the selected encrypted entry. -## EQL v2 (package root) — legacy - -The v2 integration predates the typed v3 domains and is kept for existing -projects. New projects should use v3 above. - -```ts -import { encryptedType, extractEncryptionSchema, createEncryptionOperators } from '@cipherstash/stack-drizzle' -import { Encryption } from '@cipherstash/stack' -``` - -`encryptedType` defines an `eql_v2_encrypted` column; `createEncryptionOperators` -returns query operators (`eq`, `like`, `gt`, `inArray`, …) that transparently -encrypt search values. - ## Docs Full guide: https://cipherstash.com/docs/integrations/drizzle — see also the diff --git a/packages/stack-drizzle/__tests__/v3/bigint.test.ts b/packages/stack-drizzle/__tests__/bigint.test.ts similarity index 94% rename from packages/stack-drizzle/__tests__/v3/bigint.test.ts rename to packages/stack-drizzle/__tests__/bigint.test.ts index e31a6a4e2..7cfbf9f3d 100644 --- a/packages/stack-drizzle/__tests__/v3/bigint.test.ts +++ b/packages/stack-drizzle/__tests__/bigint.test.ts @@ -1,12 +1,12 @@ import { type SQL, sql } from 'drizzle-orm' import { integer, PgDialect, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it, vi } from 'vitest' -import { getEqlV3Column, isEqlV3Column } from '../../src/v3/column' +import { getEqlV3Column, isEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, -} from '../../src/v3/operators' -import { types } from '../../src/v3/types' +} from '../src/operators' +import { types } from '../src/types' // A representative query TERM — what `client.encryptQuery` returns for a bigint // operand: a ciphertext-free term, deliberately NOT a plaintext bigint. @@ -25,7 +25,7 @@ function chainable(result: unknown) { function setup() { const encryptQuery = vi.fn(() => chainable(Promise.resolve({ data: TERM }))) - const ops = createEncryptionOperatorsV3({ encryptQuery }) + const ops = createEncryptionOperators({ encryptQuery }) const dialect = new PgDialect() const render = (s: SQL) => dialect.sqlToQuery(s) return { ops, encryptQuery, render } diff --git a/packages/stack-drizzle/__tests__/v3/codec.test.ts b/packages/stack-drizzle/__tests__/codec.test.ts similarity index 98% rename from packages/stack-drizzle/__tests__/v3/codec.test.ts rename to packages/stack-drizzle/__tests__/codec.test.ts index 3049c2c2a..6d44985a4 100644 --- a/packages/stack-drizzle/__tests__/v3/codec.test.ts +++ b/packages/stack-drizzle/__tests__/codec.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { EqlV3CodecError, v3FromDriver, v3ToDriver } from '../../src/v3/codec' +import { EqlV3CodecError, v3FromDriver, v3ToDriver } from '../src/codec' // A realistic `public.eql_v3_text_eq` envelope: schema version, table/column // identifier, mp_base85 ciphertext, HMAC term. The trivial `{v:1,c:'ct'}` diff --git a/packages/stack-drizzle/__tests__/v3/column.test.ts b/packages/stack-drizzle/__tests__/column.test.ts similarity index 99% rename from packages/stack-drizzle/__tests__/v3/column.test.ts rename to packages/stack-drizzle/__tests__/column.test.ts index 9ca47384d..dfca7f9b8 100644 --- a/packages/stack-drizzle/__tests__/v3/column.test.ts +++ b/packages/stack-drizzle/__tests__/column.test.ts @@ -12,7 +12,7 @@ import { getEqlV3Column, isEqlV3Column, makeEqlV3Column, -} from '../../src/v3/column' +} from '../src/column' describe('makeEqlV3Column', () => { it('sets dataType() to the BARE eql_v3 domain (no schema qualifier)', () => { diff --git a/packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts b/packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts deleted file mode 100644 index e71e47622..000000000 --- a/packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { PgDialect, pgTable } from 'drizzle-orm/pg-core' -import { describe, expect, it, vi } from 'vitest' -import { createEncryptionOperators, encryptedType } from '../src/index.js' - -// Regression coverage for the `bigint` (int8) plaintext path through the v3 -// Drizzle operators. Before the fix, `toPlaintext` fell through to -// `String(value)`, so `ops.eq(bigintCol, 1n)` was encrypted as the TEXT "1" and -// silently mismatched the column's bigint domain. The operators must forward a -// native `bigint` to `encryptQuery` (whose term type is `Plaintext`), so -// protect-ffi 0.28 encrypts it against the int8 domain and bounds-checks it. - -const ENCRYPTED_VALUE = '{"v":"encrypted-value"}' - -function setup() { - const encryptQuery = vi.fn(async (termsOrValue: unknown) => - Array.isArray(termsOrValue) - ? { data: termsOrValue.map(() => ENCRYPTED_VALUE) } - : { data: ENCRYPTED_VALUE }, - ) - const client = { encryptQuery } as unknown as EncryptionClient - const encryptionOps = createEncryptionOperators(client) - const dialect = new PgDialect() - return { encryptQuery, encryptionOps, dialect } -} - -const accounts = pgTable('accounts', { - balance: encryptedType('balance', { - dataType: 'bigint', - equality: true, - orderAndRange: true, - }), -}) - -describe('Drizzle v3 operators preserve bigint plaintext (int8 columns)', () => { - it('eq forwards a native bigint to encryptQuery, not a String()-coerced value', async () => { - const { encryptQuery, encryptionOps } = setup() - - await encryptionOps.eq(accounts.balance, 1n) - - expect(encryptQuery).toHaveBeenCalledTimes(1) - const terms = encryptQuery.mock.calls[0]?.[0] as Array<{ value: unknown }> - expect(terms).toHaveLength(1) - expect(typeof terms[0]?.value).toBe('bigint') - expect(terms[0]?.value).toBe(1n) - // The pre-fix bug: a stringified bigint. - expect(terms[0]?.value).not.toBe('1') - }) - - it('preserves i64 boundary magnitudes losslessly (beyond Number.MAX_SAFE_INTEGER)', async () => { - const { encryptQuery, encryptionOps } = setup() - const big = 9223372036854775807n // i64::MAX - - await encryptionOps.gt(accounts.balance, big) - - const terms = encryptQuery.mock.calls[0]?.[0] as Array<{ value: unknown }> - expect(terms[0]?.value).toBe(big) - }) -}) diff --git a/packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts b/packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts deleted file mode 100644 index 47ab6bce0..000000000 --- a/packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { PgDialect, pgTable } from 'drizzle-orm/pg-core' -import { describe, expect, it, vi } from 'vitest' -import { - createEncryptionOperators, - EncryptionOperatorError, - encryptedType, -} from '../src/index.js' - -const ENCRYPTED_VALUE = '{"v":"encrypted-value"}' - -function createMockEncryptionClient() { - const encryptQuery = vi.fn(async (termsOrValue: unknown) => { - if (Array.isArray(termsOrValue)) { - return { data: termsOrValue.map(() => ENCRYPTED_VALUE) } - } - return { data: ENCRYPTED_VALUE } - }) - - return { - client: { encryptQuery } as unknown as EncryptionClient, - encryptQuery, - } -} - -function setup() { - const { client, encryptQuery } = createMockEncryptionClient() - const encryptionOps = createEncryptionOperators(client) - const dialect = new PgDialect() - return { client, encryptQuery, encryptionOps, dialect } -} - -const docsTable = pgTable('json_docs', { - metadata: encryptedType>('metadata', { - dataType: 'json', - searchableJson: true, - }), - noJsonConfig: encryptedType('no_json_config', { - equality: true, - }), -}) - -describe('createEncryptionOperators JSONB selector typing', () => { - it('casts jsonbPathQueryFirst selector params to eql_v2_encrypted', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.jsonbPathQueryFirst( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch( - /eql_v2\.jsonb_path_query_first\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, - ) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) - - it('casts jsonbPathExists selector params to eql_v2_encrypted', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.jsonbPathExists( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch( - /eql_v2\.jsonb_path_exists\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, - ) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) - - it('casts jsonbGet selector params to eql_v2_encrypted', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.jsonbGet( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch(/->\s*\$\d+::eql_v2_encrypted/) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) -}) - -describe('JSONB operator error paths', () => { - it('throws EncryptionOperatorError when column lacks searchableJson config', () => { - const { encryptionOps } = setup() - - expect(() => - encryptionOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), - ).toThrow(EncryptionOperatorError) - - expect(() => - encryptionOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), - ).toThrow(/searchableJson/) - }) - - it('throws EncryptionOperatorError for jsonbPathExists without searchableJson', () => { - const { encryptionOps } = setup() - - expect(() => - encryptionOps.jsonbPathExists(docsTable.noJsonConfig, '$.path'), - ).toThrow(EncryptionOperatorError) - }) - - it('throws EncryptionOperatorError for jsonbGet without searchableJson', () => { - const { encryptionOps } = setup() - - expect(() => - encryptionOps.jsonbGet(docsTable.noJsonConfig, '$.path'), - ).toThrow(EncryptionOperatorError) - }) - - it('error includes column name and operator context', () => { - const { encryptionOps } = setup() - - try { - encryptionOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path') - expect.fail('Should have thrown') - } catch (error) { - expect(error).toBeInstanceOf(EncryptionOperatorError) - const opError = error as EncryptionOperatorError - expect(opError.context?.columnName).toBe('no_json_config') - expect(opError.context?.operator).toBe('jsonbPathQueryFirst') - } - }) -}) - -describe('JSONB batched operations', () => { - it('batches jsonbPathQueryFirst and jsonbGet in encryptionOps.and()', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.and( - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.email'), - encryptionOps.jsonbGet(docsTable.metadata, '$.profile.name'), - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.jsonb_path_query_first') - expect(query.sql).toContain('->') - // Verify batch encryption happened (at least one call with 2 terms) - expect( - encryptQuery.mock.calls.some( - (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, - ), - ).toBe(true) - }) - - it('batches jsonbPathExists and jsonbPathQueryFirst in encryptionOps.or()', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.or( - encryptionOps.jsonbPathExists(docsTable.metadata, '$.profile.email'), - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.name'), - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.jsonb_path_exists') - expect(query.sql).toContain('eql_v2.jsonb_path_query_first') - // Verify batch encryption happened (at least one call with 2 terms) - expect( - encryptQuery.mock.calls.some( - (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, - ), - ).toBe(true) - }) - - it('generates SQL combining conditions with AND', async () => { - const { encryptionOps, dialect } = setup() - - const condition = await encryptionOps.and( - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), - encryptionOps.jsonbPathExists(docsTable.metadata, '$.b'), - ) - const query = dialect.sqlToQuery(condition) - - // AND combines conditions - expect(query.sql).toContain(' and ') - }) - - it('generates SQL combining conditions with OR', async () => { - const { encryptionOps, dialect } = setup() - - const condition = await encryptionOps.or( - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), - encryptionOps.jsonbPathExists(docsTable.metadata, '$.b'), - ) - const query = dialect.sqlToQuery(condition) - - // OR combines conditions - expect(query.sql).toContain(' or ') - }) -}) diff --git a/packages/stack-drizzle/__tests__/v3/exports.test.ts b/packages/stack-drizzle/__tests__/exports.test.ts similarity index 91% rename from packages/stack-drizzle/__tests__/v3/exports.test.ts rename to packages/stack-drizzle/__tests__/exports.test.ts index 4ad6d98e3..299954fa0 100644 --- a/packages/stack-drizzle/__tests__/v3/exports.test.ts +++ b/packages/stack-drizzle/__tests__/exports.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import * as barrel from '../../src/v3/index.js' +import * as barrel from '../src/index.js' // Exhaustive, so ADDING an export fails here too. The previous version asserted // only four names, leaving the codec/column re-exports unpinned and letting a @@ -7,8 +7,8 @@ import * as barrel from '../../src/v3/index.js' const PUBLIC_SURFACE = [ 'EncryptionOperatorError', 'EqlV3CodecError', - 'createEncryptionOperatorsV3', - 'extractEncryptionSchemaV3', + 'createEncryptionOperators', + 'extractEncryptionSchema', 'getEqlV3Column', 'isEqlV3Column', 'makeEqlV3Column', diff --git a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts b/packages/stack-drizzle/__tests__/operators.test-d.ts similarity index 87% rename from packages/stack-drizzle/__tests__/v3/operators.test-d.ts rename to packages/stack-drizzle/__tests__/operators.test-d.ts index 7c04db648..b5e1be918 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/operators.test-d.ts @@ -6,12 +6,12 @@ import type { LockContext } from '@cipherstash/stack/identity' 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' +import { createEncryptionOperators } from '../src/index.js' /** - * Static regression guard for M1: `createEncryptionOperatorsV3` must accept the + * Static regression guard for M1: `createEncryptionOperators` must accept the * `TypedEncryptionClient` that `EncryptionV3` resolves to — the documented - * `createEncryptionOperatorsV3(await EncryptionV3({ schemas }))` usage — as well + * `createEncryptionOperators(await EncryptionV3({ schemas }))` usage — as well * 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 @@ -20,7 +20,7 @@ import { createEncryptionOperatorsV3 } from '../../src/v3/index.js' * 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)', () => { +describe('createEncryptionOperators - client parameter (M1)', () => { type V3Client = Awaited> // A query operation resolving `Result` — the surface the factory drives. @@ -31,11 +31,11 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { } it('accepts the client EncryptionV3 returns with no cast', () => { - expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith({} as V3Client) + expectTypeOf(createEncryptionOperators).toBeCallableWith({} as V3Client) }) it('still accepts the nominal EncryptionClient', () => { - expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith( + expectTypeOf(createEncryptionOperators).toBeCallableWith( {} as EncryptionClient, ) }) @@ -57,7 +57,7 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { // the operand-client contract, so a structural double must supply it too. encrypt: (_value: never, _opts: never): QueryOp => ({}) as never, } - expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith(double) + expectTypeOf(createEncryptionOperators).toBeCallableWith(double) }) it('rejects an { encryptQuery } double that resolves `unknown` (the erasure regression)', () => { @@ -76,6 +76,6 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { } // @ts-expect-error — `encryptQuery` resolving `unknown` does not satisfy the // factory's `ChainableOperation` client contract. - createEncryptionOperatorsV3(erased) + createEncryptionOperators(erased) }) }) diff --git a/packages/stack-drizzle/__tests__/v3/operators.test.ts b/packages/stack-drizzle/__tests__/operators.test.ts similarity index 97% rename from packages/stack-drizzle/__tests__/v3/operators.test.ts rename to packages/stack-drizzle/__tests__/operators.test.ts index 3a5ccc120..bc2456173 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test.ts +++ b/packages/stack-drizzle/__tests__/operators.test.ts @@ -17,13 +17,13 @@ import { } from 'drizzle-orm' import { integer, PgDialect, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it, vi } from 'vitest' -import { makeEqlV3Column } from '../../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, -} from '../../src/v3/operators' -import { extractEncryptionSchemaV3 } from '../../src/v3/schema-extraction' -import { types } from '../../src/v3/types' +} from '../src/operators' +import { extractEncryptionSchema } from '../src/schema-extraction' +import { types } from '../src/types' // 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 → @@ -75,7 +75,7 @@ function setup(termImpl: (value?: unknown) => unknown = () => TERM) { // 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 ops = createEncryptionOperators(client, { lockContext, audit }) const dialect = new PgDialect() const render = (s: unknown) => dialect.sqlToQuery(s as SQL) return { ops, encryptQuery, render } @@ -138,7 +138,7 @@ const users = pgTable('users', { flag: types.Boolean('flag'), }) -describe('createEncryptionOperatorsV3 - equality', () => { +describe('createEncryptionOperators - equality', () => { it.each( equalityDomains, )('%s eq emits the latest two-arg eql_v3.eq with a query-term operand', async (eqlType, spec) => { @@ -198,7 +198,7 @@ describe('createEncryptionOperatorsV3 - equality', () => { await ops.eq(second.age, 37) expect(encryptQuery.mock.calls[1]?.[1]?.table.build()).toEqual( - extractEncryptionSchemaV3(second).build(), + extractEncryptionSchema(second).build(), ) }) @@ -228,7 +228,7 @@ describe('createEncryptionOperatorsV3 - equality', () => { }) }) -describe('createEncryptionOperatorsV3 - comparison & range', () => { +describe('createEncryptionOperators - comparison & range', () => { it.each([ ['gt', 'eql_v3.gt'], ['gte', 'eql_v3.gte'], @@ -355,7 +355,7 @@ describe('createEncryptionOperatorsV3 - comparison & range', () => { }) }) -describe('createEncryptionOperatorsV3 - free-text match', () => { +describe('createEncryptionOperators - free-text match', () => { it.each( matchDomains, )('%s matches emits latest eql_v3.matches with a query-term operand', async (eqlType, spec) => { @@ -413,7 +413,7 @@ describe('createEncryptionOperatorsV3 - free-text match', () => { }) }) -describe('createEncryptionOperatorsV3 - JSON containment', () => { +describe('createEncryptionOperators - JSON containment', () => { const JSON_TYPE = 'public.eql_v3_json_search' // json has no `eql_v3.matches` overload: containment is the `@>` operator, @@ -476,7 +476,7 @@ describe('createEncryptionOperatorsV3 - JSON containment', () => { }) }) -describe('createEncryptionOperatorsV3 - JSON selectors', () => { +describe('createEncryptionOperators - JSON selectors', () => { const JSON_TYPE = 'public.eql_v3_json_search' const column = matrixColumn(JSON_TYPE) const VALUE_SELECTOR = { sv: [{ s: 'value-selector' }] } @@ -565,7 +565,7 @@ describe('createEncryptionOperatorsV3 - JSON selectors', () => { }) }) -describe('createEncryptionOperatorsV3 - domains with no scalar query', () => { +describe('createEncryptionOperators - domains with no scalar query', () => { it.each(nonScalarQueryDomains)('%s eq throws', async (eqlType, spec) => { const { ops } = setup() await expect( @@ -588,7 +588,7 @@ describe('createEncryptionOperatorsV3 - domains with no scalar query', () => { }) }) -describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { +describe('createEncryptionOperators - array, ordering, combinators', () => { it('inArray ORs one eq term per value; empty array throws', async () => { const { ops, render } = setup() const q = render(await ops.inArray(users.nickname, ['ada', 'grace'])) @@ -784,7 +784,7 @@ describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { }) }) -describe('createEncryptionOperatorsV3 - gating errors', () => { +describe('createEncryptionOperators - gating errors', () => { it('wraps encryption failures with operator context', async () => { const { ops, encryptQuery } = setup() encryptQuery.mockReturnValueOnce( diff --git a/packages/stack-drizzle/__tests__/v3/schema-extraction.test.ts b/packages/stack-drizzle/__tests__/schema-extraction.test.ts similarity index 82% rename from packages/stack-drizzle/__tests__/v3/schema-extraction.test.ts rename to packages/stack-drizzle/__tests__/schema-extraction.test.ts index e3767fcc0..4c9c32a6d 100644 --- a/packages/stack-drizzle/__tests__/v3/schema-extraction.test.ts +++ b/packages/stack-drizzle/__tests__/schema-extraction.test.ts @@ -1,10 +1,10 @@ import { encryptedTable, types as v3Types } from '@cipherstash/stack/eql/v3' import { integer, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' -import { extractEncryptionSchemaV3 } from '../../src/v3/schema-extraction' -import { types } from '../../src/v3/types' +import { extractEncryptionSchema } from '../src/schema-extraction' +import { types } from '../src/types' -describe('extractEncryptionSchemaV3', () => { +describe('extractEncryptionSchema', () => { it('rebuilds an equivalent eql/v3 encryptedTable from every drizzle v3 factory', () => { const drizzleColumns = Object.fromEntries( Object.entries(types).map(([factoryName, factory]) => [ @@ -19,7 +19,7 @@ describe('extractEncryptionSchemaV3', () => { ]), ) - const extracted = extractEncryptionSchemaV3( + const extracted = extractEncryptionSchema( pgTable('users', drizzleColumns as never), ) const authored = encryptedTable('users', authoredColumns as never) @@ -34,7 +34,7 @@ describe('extractEncryptionSchemaV3', () => { age: types.IntegerOrd('age'), }) - const extracted = extractEncryptionSchemaV3(users) + const extracted = extractEncryptionSchema(users) const authored = encryptedTable('users', { email: v3Types.TextSearch('email'), age: v3Types.IntegerOrd('age'), @@ -51,8 +51,8 @@ describe('extractEncryptionSchemaV3', () => { email: types.IntegerOrd('email'), }) - const accountsSchema = extractEncryptionSchemaV3(accounts) - const metricsSchema = extractEncryptionSchemaV3(metrics) + const accountsSchema = extractEncryptionSchema(accounts) + const metricsSchema = extractEncryptionSchema(metrics) expect(accountsSchema.build()).toStrictEqual( encryptedTable('accounts', { @@ -72,7 +72,7 @@ describe('extractEncryptionSchemaV3', () => { emailAddress: types.TextEq('email_address'), }) - expect(extractEncryptionSchemaV3(users).build()).toStrictEqual( + expect(extractEncryptionSchema(users).build()).toStrictEqual( encryptedTable('users', { createdOn: v3Types.Date('created_on'), emailAddress: v3Types.TextEq('email_address'), @@ -89,7 +89,7 @@ describe('extractEncryptionSchemaV3', () => { }, } - expect(extractEncryptionSchemaV3(table as never).build()).toStrictEqual( + expect(extractEncryptionSchema(table as never).build()).toStrictEqual( encryptedTable('users', { createdOn: v3Types.Date('created_on'), }).build(), @@ -98,12 +98,12 @@ describe('extractEncryptionSchemaV3', () => { it('throws when the table has no encrypted v3 columns', () => { const plain = pgTable('plain', { id: integer() }) - expect(() => extractEncryptionSchemaV3(plain)).toThrow(/no encrypted v3/i) + expect(() => extractEncryptionSchema(plain)).toThrow(/no encrypted v3/i) }) it('throws the table-name error before checking for encrypted columns', () => { expect(() => - extractEncryptionSchemaV3({ + extractEncryptionSchema({ secret: { getSQLType: () => 'public.eql_v3_text_eq', name: 'secret' }, } as never), ).toThrow('Unable to read table name from Drizzle table.') diff --git a/packages/stack-drizzle/__tests__/v3/selector.test.ts b/packages/stack-drizzle/__tests__/selector.test.ts similarity index 96% rename from packages/stack-drizzle/__tests__/v3/selector.test.ts rename to packages/stack-drizzle/__tests__/selector.test.ts index e3c2296cf..cf2fec265 100644 --- a/packages/stack-drizzle/__tests__/v3/selector.test.ts +++ b/packages/stack-drizzle/__tests__/selector.test.ts @@ -1,12 +1,12 @@ import { integer, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, parseSelectorSegments, reconstructSelectorDocument, -} from '../../src/v3/operators.js' -import { types } from '../../src/v3/types.js' +} from '../src/operators.js' +import { types } from '../src/types.js' describe('parseSelectorSegments', () => { it('parses $-rooted, bare, and whitespace-padded dot paths', () => { @@ -70,7 +70,7 @@ describe('ops.selector — up-front guards (no encryption reached)', () => { const failIfCalled = () => { throw new Error('guard should reject before encrypting') } - const ops = createEncryptionOperatorsV3({ + const ops = createEncryptionOperators({ encryptQuery: failIfCalled, encrypt: failIfCalled, } as never) diff --git a/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts b/packages/stack-drizzle/__tests__/sql-dialect.test.ts similarity index 98% rename from packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts rename to packages/stack-drizzle/__tests__/sql-dialect.test.ts index 42feef81a..bd67750b3 100644 --- a/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts +++ b/packages/stack-drizzle/__tests__/sql-dialect.test.ts @@ -1,7 +1,7 @@ import { not, sql } from 'drizzle-orm' import { PgDialect } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' -import { EQL_V3_FN_SCHEMA, v3Dialect } from '../../src/v3/sql-dialect' +import { EQL_V3_FN_SCHEMA, v3Dialect } from '../src/sql-dialect' const dialect = new PgDialect() const render = (s: ReturnType) => dialect.sqlToQuery(s).sql diff --git a/packages/stack-drizzle/__tests__/v3/types.test-d.ts b/packages/stack-drizzle/__tests__/types.test-d.ts similarity index 97% rename from packages/stack-drizzle/__tests__/v3/types.test-d.ts rename to packages/stack-drizzle/__tests__/types.test-d.ts index 55b8022c2..18f81ae88 100644 --- a/packages/stack-drizzle/__tests__/v3/types.test-d.ts +++ b/packages/stack-drizzle/__tests__/types.test-d.ts @@ -1,7 +1,7 @@ import type { types as v3Types } from '@cipherstash/stack/eql/v3' import type { Encrypted } from '@cipherstash/stack/types' import { describe, expectTypeOf, it } from 'vitest' -import { types } from '../../src/v3/types' +import { types } from '../src/types' describe('v3 drizzle types - type-level', () => { it('exposes exactly the same factory keys as @/eql/v3 types', () => { diff --git a/packages/stack-drizzle/__tests__/v3/types.test.ts b/packages/stack-drizzle/__tests__/types.test.ts similarity index 87% rename from packages/stack-drizzle/__tests__/v3/types.test.ts rename to packages/stack-drizzle/__tests__/types.test.ts index face747e1..b82251a9a 100644 --- a/packages/stack-drizzle/__tests__/v3/types.test.ts +++ b/packages/stack-drizzle/__tests__/types.test.ts @@ -1,7 +1,7 @@ import { types as v3Types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { getEqlV3Column } from '../../src/v3/column' -import { types } from '../../src/v3/types' +import { getEqlV3Column } from '../src/column' +import { types } from '../src/types' describe('v3 drizzle types namespace', () => { it('exposes the same factory names as @/eql/v3 types', () => { diff --git a/packages/stack-drizzle/integration/adapter.ts b/packages/stack-drizzle/integration/adapter.ts index 524ad33d1..a2e1ea848 100644 --- a/packages/stack-drizzle/integration/adapter.ts +++ b/packages/stack-drizzle/integration/adapter.ts @@ -11,11 +11,11 @@ import { asc, type SQL } from 'drizzle-orm' import { type PgTable, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from '../src/v3/index.js' + createEncryptionOperators, + extractEncryptionSchema, +} from '../src/index.js' /** * The Drizzle v3 adapter under real ZeroKMS ciphertext. @@ -58,9 +58,9 @@ export function makeDrizzleAdapter(): IntegrationAdapter { let sqlClient: postgres.Sql let db: ReturnType let client: Awaited> - let ops: ReturnType + let ops: ReturnType let table: AnyTable - let schema: ReturnType + let schema: ReturnType let tableName: string const column = (slug: string) => table[slug] @@ -140,7 +140,7 @@ export function makeDrizzleAdapter(): IntegrationAdapter { rowKey: text('row_key').primaryKey(), ...columns, }) - schema = extractEncryptionSchemaV3(table) + schema = extractEncryptionSchema(table) // The DDL comes from the column builders, not from a hand-written list, so // a domain rename cannot silently desync the table from the schema. @@ -158,7 +158,7 @@ export function makeDrizzleAdapter(): IntegrationAdapter { // The client must know this table's schema to encrypt for it. Rebuilt per // family, since each family has its own columns. client = await EncryptionV3({ schemas: [schema as never] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) }, async insertSingle(_spec: TableSpec, row: PlainRow) { diff --git a/packages/stack-drizzle/integration/json-adapter.ts b/packages/stack-drizzle/integration/json-adapter.ts index 181b98fb5..2ed37577b 100644 --- a/packages/stack-drizzle/integration/json-adapter.ts +++ b/packages/stack-drizzle/integration/json-adapter.ts @@ -11,10 +11,10 @@ import { type PgTable, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, + createEncryptionOperators, + extractEncryptionSchema, types, -} from '../src/v3/index.js' +} from '../src/index.js' // biome-ignore lint/suspicious/noExplicitAny: the table name is supplied by the shared suite at runtime type AnyTable = any @@ -26,7 +26,7 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { let tableName: string let table: AnyTable let client: Awaited> - let ops: ReturnType + let ops: ReturnType const rowsFor = async ( where: SQL | undefined, @@ -71,9 +71,9 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { rowKey: text('row_key').primaryKey(), document: types.Json('document'), }) - const schema = extractEncryptionSchemaV3(table) + const schema = extractEncryptionSchema(table) client = await EncryptionV3({ schemas: [schema] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) await sqlClient.unsafe(`DROP TABLE IF EXISTS ${tableName}`) await sqlClient.unsafe(` diff --git a/packages/stack-drizzle/integration/lock-context.integration.test.ts b/packages/stack-drizzle/integration/lock-context.integration.test.ts index 293c34853..8167c0edf 100644 --- a/packages/stack-drizzle/integration/lock-context.integration.test.ts +++ b/packages/stack-drizzle/integration/lock-context.integration.test.ts @@ -41,11 +41,11 @@ 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 { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from '../src/v3/index.js' + createEncryptionOperators, + extractEncryptionSchema, +} from '../src/index.js' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -67,12 +67,12 @@ const secretTable = pgTable(TABLE_NAME, { secret: makeEqlV3Column(V3_MATRIX['public.eql_v3_text_eq'].builder('secret')), } as never) -const schema = extractEncryptionSchemaV3(secretTable) +const schema = extractEncryptionSchema(secretTable) type SelectRow = { rowKey: string } let client: Awaited> -let ops: ReturnType +let ops: ReturnType let db: ReturnType /** @@ -159,7 +159,7 @@ beforeAll(async () => { schemas: [schema], config: { authStrategy: federation.data }, }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) await sqlClient.unsafe(` diff --git a/packages/stack-drizzle/integration/null-persistence.integration.test.ts b/packages/stack-drizzle/integration/null-persistence.integration.test.ts index 6534c60ac..f97c1d29f 100644 --- a/packages/stack-drizzle/integration/null-persistence.integration.test.ts +++ b/packages/stack-drizzle/integration/null-persistence.integration.test.ts @@ -20,11 +20,11 @@ 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 { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from '../src/v3/index.js' + createEncryptionOperators, + extractEncryptionSchema, +} from '../src/index.js' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -80,12 +80,12 @@ const TIERS = [ }, ] as const -const schema = extractEncryptionSchemaV3(nullableTable) +const schema = extractEncryptionSchema(nullableTable) type SelectRow = { rowKey: string } let client: Awaited> -let ops: ReturnType +let ops: ReturnType let db: ReturnType function unwrap(result: { data?: T; failure?: { message: string } }): T { @@ -108,7 +108,7 @@ async function selectRowKeys(condition: SQL): Promise { beforeAll(async () => { // EQL v3 is installed once per run by `global-setup.ts`. client = await EncryptionV3({ schemas: [schema] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) const columnDefs = TIERS.map((t) => `"${t.db}" ${t.domain}`).join(',\n ') diff --git a/packages/stack-drizzle/integration/relational.integration.test.ts b/packages/stack-drizzle/integration/relational.integration.test.ts index ee36fcf7f..8e6eecb38 100644 --- a/packages/stack-drizzle/integration/relational.integration.test.ts +++ b/packages/stack-drizzle/integration/relational.integration.test.ts @@ -43,13 +43,13 @@ 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 { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, - extractEncryptionSchemaV3, + extractEncryptionSchema, types as v3drizzle, -} from '../src/v3/index.js' +} from '../src/index.js' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -128,8 +128,8 @@ const BIGINT_LEDGER = -9223372036854775808n const BIGINT_B_BALANCE = -5n const BIGINT_B_LEDGER = 100n -const schema = extractEncryptionSchemaV3(matrixTable) -const bigintSchema = extractEncryptionSchemaV3(bigintTable) +const schema = extractEncryptionSchema(matrixTable) +const bigintSchema = extractEncryptionSchema(bigintTable) type PlainValue = string | number | bigint | boolean | Date type RowKey = (typeof ROWS)[number] @@ -137,7 +137,7 @@ type MatrixPlainRow = Record type SelectRow = { rowKey: string } type Db = ReturnType type Client = Awaited> -type Ops = ReturnType +type Ops = ReturnType type ComparisonOperator = 'gt' | 'gte' | 'lt' | 'lte' let client: Client @@ -213,7 +213,7 @@ function encryptedInsertRows(): MatrixPlainRow[] { beforeAll(async () => { client = await EncryptionV3({ schemas: [schema, bigintSchema] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) const columnDefs = matrixEntries diff --git a/packages/stack-drizzle/package.json b/packages/stack-drizzle/package.json index dd004ad62..48320122c 100644 --- a/packages/stack-drizzle/package.json +++ b/packages/stack-drizzle/package.json @@ -43,25 +43,8 @@ "default": "./dist/index.cjs" } }, - "./v3": { - "import": { - "types": "./dist/v3/index.d.ts", - "default": "./dist/v3/index.js" - }, - "require": { - "types": "./dist/v3/index.d.cts", - "default": "./dist/v3/index.cjs" - } - }, "./package.json": "./package.json" }, - "typesVersions": { - "*": { - "v3": [ - "./dist/v3/index.d.ts" - ] - } - }, "scripts": { "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "tsup", diff --git a/packages/stack-drizzle/src/v3/codec.ts b/packages/stack-drizzle/src/codec.ts similarity index 100% rename from packages/stack-drizzle/src/v3/codec.ts rename to packages/stack-drizzle/src/codec.ts diff --git a/packages/stack-drizzle/src/v3/column.ts b/packages/stack-drizzle/src/column.ts similarity index 100% rename from packages/stack-drizzle/src/v3/column.ts rename to packages/stack-drizzle/src/column.ts diff --git a/packages/stack-drizzle/src/index.ts b/packages/stack-drizzle/src/index.ts index ff3f12bb0..a2f106095 100644 --- a/packages/stack-drizzle/src/index.ts +++ b/packages/stack-drizzle/src/index.ts @@ -1,234 +1,8 @@ -import type { - CastAs, - MatchIndexOpts, - TokenFilter, -} from '@cipherstash/stack/schema' -import { customType } from 'drizzle-orm/pg-core' - -export type { CastAs, MatchIndexOpts, TokenFilter } - -// The encrypted column type is created by the EQL install script in the -// `public` schema (see packages/cli/src/installer/index.ts). We return the -// bare identifier here — drizzle-kit's CREATE TABLE path emits it correctly -// (it only prepends a schema when `typeSchema` is set, which is only true -// for pgEnum columns). The ALTER COLUMN path is a different story: it -// unconditionally wraps the dataType() return in double-quotes and prepends -// `"{typeSchema}".`, and since custom types have no typeSchema, the output -// becomes `"undefined"."eql_v2_encrypted"`. Returning a pre-quoted -// `"public"."eql_v2_encrypted"` here does NOT fix that — drizzle-kit just -// double-escapes the quotes, producing `"undefined".""public"."eql_v2_encrypted""`. -// Instead, the CLI's `rewriteEncryptedAlterColumns` rewrites every broken -// ALTER COLUMN form into an ADD + DROP + RENAME sequence that does work. -const EQL_ENCRYPTED_DATA_TYPE = 'eql_v2_encrypted' - -/** - * Configuration for encrypted column indexes and data types - */ -export type EncryptedColumnConfig = { - /** - * Data type for the column (default: 'string') - */ - dataType?: CastAs - /** - * Enable free text search. Can be a boolean for default options, or an object for custom configuration. - */ - freeTextSearch?: boolean | MatchIndexOpts - /** - * Enable equality index. Can be a boolean for default options, or an array of token filters. - */ - equality?: boolean | TokenFilter[] - /** - * Enable order and range index for sorting and range queries. - */ - orderAndRange?: boolean - /** - * Enable searchable JSON index for JSONB path queries. - * Requires dataType: 'json'. - */ - searchableJson?: boolean -} - -/** - * Map to store configuration for encrypted columns - * Keyed by column name (the name passed to encryptedType) - */ -const columnConfigMap = new Map< - string, - EncryptedColumnConfig & { name: string } ->() - -/** - * Creates an encrypted column type for Drizzle ORM with configurable searchable encryption options. - * - * When data is encrypted, the actual stored value is an [EQL v2](/docs/reference/eql) encrypted composite type which includes any searchable encryption indexes defined for the column. - * Importantly, the original data type is not known until it is decrypted. Therefore, this function allows specifying - * the original data type via the `dataType` option in the configuration. - * This ensures that when data is decrypted, it can be correctly interpreted as the intended TypeScript type. - * - * @typeParam TData - The TypeScript type of the data stored in the column - * @param name - The column name in the database - * @param config - Optional configuration for data type and searchable encryption indexes - * @returns A Drizzle column type that can be used in pgTable definitions - * - * ## Searchable Encryption Options - * - * - `dataType`: Specifies the original data type of the column (e.g., 'string', 'number', 'json'). Default is 'string'. - * - `freeTextSearch`: Enables free text search index. Can be a boolean for default options, or an object for custom configuration. - * - `equality`: Enables equality index. Can be a boolean for default options, or an array of token filters. - * - `orderAndRange`: Enables order and range index for sorting and range queries. - * - `searchableJson`: Enables searchable JSON index for JSONB path queries on encrypted JSON columns. - * - * See {@link EncryptedColumnConfig}. - * - * @example - * Defining a drizzle table schema for postgres table with encrypted columns. - * - * ```typescript - * import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' - * import { encryptedType } from '@cipherstash/stack-drizzle' - * - * const users = pgTable('users', { - * email: encryptedType('email', { - * freeTextSearch: true, - * equality: true, - * orderAndRange: true, - * }), - * age: encryptedType('age', { - * dataType: 'number', - * equality: true, - * orderAndRange: true, - * }), - * profile: encryptedType('profile', { - * dataType: 'json', - * }), - * }) - * ``` - */ -export const encryptedType = ( - name: string, - config?: EncryptedColumnConfig, -) => { - // Create the Drizzle custom type - const customColumnType = customType<{ data: TData; driverData: string }>({ - dataType() { - return EQL_ENCRYPTED_DATA_TYPE - }, - toDriver(value: TData): string { - const jsonStr = JSON.stringify(value) - const escaped = jsonStr.replace(/"/g, '""') - return `("${escaped}")` - }, - fromDriver(value: string): TData { - const parseComposite = (str: string) => { - if (!str || str === '') return null - - const trimmed = str.trim() - - if (trimmed.startsWith('(') && trimmed.endsWith(')')) { - let inner = trimmed.slice(1, -1) - inner = inner.replace(/""/g, '"') - - if (inner.startsWith('"') && inner.endsWith('"')) { - const stripped = inner.slice(1, -1) - return JSON.parse(stripped) - } - - if (inner.startsWith('{') || inner.startsWith('[')) { - return JSON.parse(inner) - } - - return inner - } - - return JSON.parse(str) - } - - return parseComposite(value) as TData - }, - }) - - // Create the column instance - const column = customColumnType(name) - - // Store configuration keyed by column name - // This allows us to look it up during schema extraction - const fullConfig: EncryptedColumnConfig & { name: string } = { - name, - ...config, - } - - // Store in Map keyed by column name (will be looked up during extraction) - columnConfigMap.set(name, fullConfig) - - // Also store on property for immediate access (before pgTable processes it) - // We need to use any here because Drizzle columns don't have a type for custom properties - // biome-ignore lint/suspicious/noExplicitAny: Drizzle columns don't expose custom property types - ;(column as any)._encryptionConfig = fullConfig - - return column -} - -/** - * Get configuration for an encrypted column by checking if it's an encrypted type - * and looking up the config by column name - * @internal - */ -export function getEncryptedColumnConfig( - columnName: string, - column: unknown, -): (EncryptedColumnConfig & { name: string }) | undefined { - // Check if this is an encrypted column - if (column && typeof column === 'object') { - // We need to use any here to access Drizzle column properties - // biome-ignore lint/suspicious/noExplicitAny: Drizzle column types don't expose all properties - const columnAny = column as any - - // Check if it's an encrypted column by checking sqlName or dataType. - // We accept both the bare `eql_v2_encrypted` form (current) and the - // fully-qualified `"public"."eql_v2_encrypted"` form that @cipherstash/stack - // 0.15.0 briefly emitted, for back-compat with tables built against that - // release. - const isEncryptedTypeString = (value: unknown): boolean => - value === EQL_ENCRYPTED_DATA_TYPE || - value === '"public"."eql_v2_encrypted"' - - const isEncrypted = - isEncryptedTypeString(columnAny.sqlName) || - isEncryptedTypeString(columnAny.dataType) || - (columnAny.dataType && - typeof columnAny.dataType === 'function' && - isEncryptedTypeString(columnAny.dataType())) - - if (isEncrypted) { - // Try to get config from property (if still there) - if (columnAny._encryptionConfig) { - return columnAny._encryptionConfig - } - - // Look up config by column name (the name passed to encryptedType) - // The column.name should match what was passed to encryptedType - const lookupName = columnAny.name || columnName - return columnConfigMap.get(lookupName) - } - } - return undefined -} - -/** - * Create Drizzle query operators (`eq`, `lt`, `gt`, etc.) that work with - * encrypted columns. The returned operators encrypt query values before - * passing them to Drizzle, enabling searchable encryption in standard - * Drizzle queries. - */ +export { EqlV3CodecError, v3FromDriver, v3ToDriver } from './codec.js' +export { getEqlV3Column, isEqlV3Column, makeEqlV3Column } from './column.js' export { createEncryptionOperators, - EncryptionConfigError, EncryptionOperatorError, } from './operators.js' -/** - * Extract a CipherStash encryption schema from a Drizzle table definition. - * - * Inspects columns created with {@link encryptedType} and builds the equivalent - * `encryptedTable` / `encryptedColumn` schema automatically. - */ export { extractEncryptionSchema } from './schema-extraction.js' +export { types } from './types.js' diff --git a/packages/stack-drizzle/src/operators.ts b/packages/stack-drizzle/src/operators.ts index 674bb3ba3..4f6df813d 100644 --- a/packages/stack-drizzle/src/operators.ts +++ b/packages/stack-drizzle/src/operators.ts @@ -1,90 +1,96 @@ -import type { EncryptionClient } from '@cipherstash/stack/encryption' +import type { Result } from '@byteslice/result' +import type { AuditConfig } from '@cipherstash/stack/adapter-kit' +import { + jsonPathOf, + matchNeedleError, + parseSelectorSegments, + reconstructSelectorDocument, + stripDomainSchema, + unsupportedLeafReason, +} from '@cipherstash/stack/adapter-kit' +import type { + AnyEncryptedV3Column, + AnyV3Table, +} from '@cipherstash/stack/eql/v3' +import type { EncryptionError } from '@cipherstash/stack/errors' +import type { LockContext } from '@cipherstash/stack/identity' +import type { ColumnSchema } from '@cipherstash/stack/schema' import type { - EncryptedColumn, - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' -import { type QueryTypeName, queryTypes } from '@cipherstash/stack/types' + EncryptedQueryResult, + QueryTypeName, +} from '@cipherstash/stack/types' import { and, - arrayContained, - arrayContains, - arrayOverlaps, asc, - between, - bindIfParam, + Column, desc, - eq, exists, - gt, - gte, - ilike, - inArray, + is, isNotNull, isNull, - like, - lt, - lte, - ne, not, - notBetween, notExists, - notIlike, - notInArray, or, type SQL, type SQLWrapper, sql, } from 'drizzle-orm' import type { PgTable } from 'drizzle-orm/pg-core' -import type { EncryptedColumnConfig } from './index.js' -import { getEncryptedColumnConfig } from './index.js' -import { extractEncryptionSchema } from './schema-extraction.js' - -// ============================================================================ -// Type Definitions and Type Guards -// ============================================================================ - -/** - * Branded type for Drizzle table with encrypted columns - */ -// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types don't expose Symbol properties -type EncryptedDrizzleTable = PgTable & { - readonly __isEncryptedTable?: true -} +import { getEqlV3Column } from './column.js' +import { + extractEncryptionSchema, + getDrizzleTableName, +} from './schema-extraction.js' +import { type ComparisonOp, type EqualityOp, v3Dialect } from './sql-dialect.js' /** - * Type guard to check if a value is a Drizzle SQLWrapper + * 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). + * + * `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. */ -function isSQLWrapper(value: unknown): value is SQLWrapper { - return ( - typeof value === 'object' && - value !== null && - 'sql' in value && - typeof (value as { sql: unknown }).sql !== 'undefined' - ) +type OperandEncryptionClient = { + encryptQuery( + value: never, + opts: never, + ): ChainableOperation + encryptQuery(terms: never): ChainableOperation } -/** - * Type guard to check if a value is a Drizzle table - */ -function isPgTable(value: unknown): value is EncryptedDrizzleTable { - return ( - typeof value === 'object' && - value !== null && - Symbol.for('drizzle:Name') in value - ) -} +// Path helpers now live in @cipherstash/stack/adapter-kit (shared with the +// Supabase adapter, #650); re-exported so existing imports keep working. +export { parseSelectorSegments, reconstructSelectorDocument } /** - * Custom error types for better debugging + * A dedicated error for v3 operator gating and operand-encryption failures, + * carrying the offending column/table/operator for diagnostics. + * + * INTENTIONAL FORK: this mirrors the v2 adapter's `EncryptionOperatorError` + * rather than sharing it. Unifying the two would couple `./drizzle` and + * `./eql/v3/drizzle` — two independently-versioned public entry points — so the + * duplication is deliberate, not an oversight. */ export class EncryptionOperatorError extends Error { constructor( message: string, public readonly context?: { - tableName?: string columnName?: string + tableName?: string operator?: string }, ) { @@ -93,1853 +99,861 @@ export class EncryptionOperatorError extends Error { } } -export class EncryptionConfigError extends EncryptionOperatorError { - constructor(message: string, context?: EncryptionOperatorError['context']) { - super(message, context) - this.name = 'EncryptionConfigError' - } -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Helper to extract table name from a Drizzle table - */ -function getDrizzleTableName(drizzleTable: unknown): string | undefined { - if (!isPgTable(drizzleTable)) { - return undefined - } - // Access Symbol property using Record type to avoid indexing errors - const tableWithSymbol = drizzleTable as unknown as Record< - symbol, - string | undefined - > - return tableWithSymbol[Symbol.for('drizzle:Name')] -} - -/** - * Helper to get the drizzle table from a drizzle column - */ -function getDrizzleTableFromColumn(drizzleColumn: SQLWrapper): unknown { - const column = drizzleColumn as unknown as Record - return column.table as unknown -} - -/** - * Helper to extract encrypted table from a drizzle column by deriving it from the column's parent table - */ -function getEncryptedTableFromColumn( - drizzleColumn: SQLWrapper, - tableCache: Map>, -): EncryptedTable | undefined { - const drizzleTable = getDrizzleTableFromColumn(drizzleColumn) - if (!drizzleTable) { - return undefined - } - - const tableName = getDrizzleTableName(drizzleTable) - if (!tableName) { - return undefined - } - - // Check cache first - let encryptedTable = tableCache.get(tableName) - if (encryptedTable) { - return encryptedTable - } - - // Extract encryption schema from drizzle table and cache it - try { - // biome-ignore lint/suspicious/noExplicitAny: PgTable type doesn't expose all needed properties - encryptedTable = extractEncryptionSchema(drizzleTable as PgTable) - tableCache.set(tableName, encryptedTable) - return encryptedTable - } catch { - // Table doesn't have encrypted columns or extraction failed - return undefined - } -} - -/** - * Helper to get the encrypted column definition for a Drizzle column from the encrypted table - */ -function getEncryptedColumn( - drizzleColumn: SQLWrapper, - encryptedTable: EncryptedTable, -): EncryptedColumn | undefined { - const column = drizzleColumn as unknown as Record - const columnName = column.name as string | undefined - if (!columnName) { - return undefined - } - - const tableRecord = encryptedTable as unknown as Record - return tableRecord[columnName] as EncryptedColumn | undefined +interface ColumnContext { + builder: AnyEncryptedV3Column + table: AnyV3Table + 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_json`, an irregular name). */ + queryCast: string | null } /** - * Column metadata extracted from a Drizzle column + * 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 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_search` maps to `query_json`, cast + * explicitly on the JSON path. */ -interface ColumnInfo { - readonly encryptedColumn: EncryptedColumn | undefined - readonly config: (EncryptedColumnConfig & { name: string }) | undefined - readonly encryptedTable: EncryptedTable | undefined - readonly columnName: string - readonly tableName: string | undefined +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. 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}` } -/** - * Helper to get the encrypted column and column config for a Drizzle column - * If encryptedTable is not provided, it will be derived from the column - */ -function getColumnInfo( - drizzleColumn: SQLWrapper, - encryptedTable: EncryptedTable | undefined, - tableCache: Map>, -): ColumnInfo { - const column = drizzleColumn as unknown as Record - const columnName = (column.name as string | undefined) || 'unknown' - - // If encryptedTable not provided, try to derive it from the column - let resolvedTable = encryptedTable - if (!resolvedTable) { - resolvedTable = getEncryptedTableFromColumn(drizzleColumn, tableCache) - } - - const drizzleTable = getDrizzleTableFromColumn(drizzleColumn) - const tableName = getDrizzleTableName(drizzleTable) - - if (!resolvedTable) { - // Column is not from an encrypted table - const config = getEncryptedColumnConfig(columnName, drizzleColumn) - return { - encryptedColumn: undefined, - config, - encryptedTable: undefined, - columnName, - tableName, - } - } - - const encryptedColumn = getEncryptedColumn(drizzleColumn, resolvedTable) - const config = getEncryptedColumnConfig(columnName, drizzleColumn) - - return { - encryptedColumn, - config, - encryptedTable: resolvedTable, - columnName, - tableName, - } +export type EncryptionOperatorCallOpts = { + lockContext?: LockContext + audit?: AuditConfig } /** - * Helper to convert a value to plaintext format + * An SDK encryption operation after its lock context has been applied: still + * auditable and awaitable, but not re-lockable. `withLockContext` returns this, + * not the full {@link ChainableOperation}, mirroring the real + * `EncryptOperationWithLockContext`, which drops `withLockContext` (you cannot + * lock-context twice). Modelling that is what lets the real client type satisfy + * the structural surface with no cast. */ -function toPlaintext(value: unknown): string | number | bigint { - if (typeof value === 'boolean') { - return value ? 1 : 0 - } - if ( - typeof value === 'string' || - typeof value === 'number' || - // `bigint` (int8 columns) must pass through as a native bigint, NOT via the - // `String(value)` fallthrough below — a stringified bigint would be - // encrypted as text and silently mismatch the column's bigint domain. The - // downstream `encryptQuery` term type is `Plaintext`, which carries bigint, - // and protect-ffi 0.28 i64-bounds-checks it at the boundary. - typeof value === 'bigint' - ) { - return value - } - if (value instanceof Date) { - return value.toISOString() - } - return String(value) +type AuditableOperation = { + audit(config: AuditConfig): AuditableOperation + then: PromiseLike>['then'] } /** - * Value to encrypt with its associated column + * 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 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 `EncryptQueryOperation` class, because the client + * is passed in and the factory must accept any implementation with this surface. */ -interface ValueToEncrypt { - readonly value: string | number | bigint - readonly column: SQLWrapper - readonly columnInfo: ColumnInfo - readonly queryType?: QueryTypeName - readonly originalIndex: number +type ChainableOperation = { + withLockContext(lockContext: LockContext): AuditableOperation + audit(config: AuditConfig): AuditableOperation + then: PromiseLike>['then'] } /** - * Helper to encrypt multiple values for use in a query - * Returns an array of encrypted search terms or original values if not encrypted + * Build v3-aware query operators (`eq`, `gte`, `matches`, `contains`, `asc`, …) bound to an + * encryption `client`. Each comparison/containment operator AUTO-ENCRYPTS its + * plaintext operand into an EQL v3 query term before handing it to Drizzle, so + * callers pass plaintext and the emitted SQL compares encrypted values. Every + * operator also gates on the target column's capabilities and throws + * {@link EncryptionOperatorError} when the column can't answer the operator + * (e.g. ordering a non-`ore` column). + * + * @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. + * + * @example + * ```typescript + * const ops = createEncryptionOperators(await EncryptionV3({ schemas: [users] })) + * await db.select().from(users).where(await ops.eq(users.email, 'a@b.com')) + * ``` */ -async function encryptValues( - encryptionClient: EncryptionClient, - values: Array<{ - value: unknown - column: SQLWrapper - queryType?: QueryTypeName - }>, - encryptedTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise { - if (values.length === 0) { - return [] - } - - // Single pass: collect values to encrypt with their metadata - const valuesToEncrypt: ValueToEncrypt[] = [] - const results: unknown[] = new Array(values.length) - - for (let i = 0; i < values.length; i++) { - const { value, column, queryType } = values[i] - const columnInfo = getColumnInfo(column, encryptedTable, tableCache) - - if ( - !columnInfo.encryptedColumn || - !columnInfo.config || - !columnInfo.encryptedTable - ) { - // Column is not encrypted, return value as-is - results[i] = value - continue - } - - const plaintextValue = toPlaintext(value) - valuesToEncrypt.push({ - value: plaintextValue, - column, - columnInfo, - queryType, - originalIndex: i, - }) - } - - if (valuesToEncrypt.length === 0) { - return results - } - - // Group values by column to batch encrypt with same column/table - const columnGroups = new Map< - string, - { - column: EncryptedColumn - table: EncryptedTable - columnName: string - values: Array<{ - value: string | number | bigint - index: number - queryType?: QueryTypeName - }> - resultIndices: number[] - } - >() - - let valueIndex = 0 - for (const { - value, - columnInfo, - queryType, - originalIndex, - } of valuesToEncrypt) { - // Safe access with validation - we know these exist from earlier checks - if ( - !columnInfo.config || - !columnInfo.encryptedColumn || - !columnInfo.encryptedTable - ) { - continue - } - - const columnName = columnInfo.config.name - const groupKey = `${columnInfo.tableName ?? 'unknown'}/${columnName}` - let group = columnGroups.get(groupKey) - if (!group) { - group = { - column: columnInfo.encryptedColumn, - table: columnInfo.encryptedTable, - columnName, - values: [], - resultIndices: [], - } - columnGroups.set(groupKey, group) - } - group.values.push({ value, index: valueIndex++, queryType }) - group.resultIndices.push(originalIndex) - } - - // Encrypt all values for each column in batches - for (const [, group] of columnGroups) { - const { columnName } = group - try { - const terms = group.values.map((v) => ({ - value: v.value, - column: group.column, - table: group.table, - queryType: v.queryType, - })) - - const encryptedTerms = await encryptionClient.encryptQuery(terms) - - if (encryptedTerms.failure) { - throw new EncryptionOperatorError( - `Failed to encrypt query terms for column "${columnName}": ${encryptedTerms.failure.message}`, - { columnName }, - ) - } - - // Map results back to original indices - for (let i = 0; i < group.values.length; i++) { - const resultIndex = group.resultIndices[i] ?? -1 - if (resultIndex >= 0 && resultIndex < results.length) { - results[resultIndex] = encryptedTerms.data[i] - } - } - } catch (error) { - if (error instanceof EncryptionOperatorError) { - throw error - } - const errorMessage = - error instanceof Error ? error.message : String(error) +export function createEncryptionOperators( + client: OperandEncryptionClient, + defaults: EncryptionOperatorCallOpts = {}, +) { + const tableCache = new WeakMap() + // Per-column context memo. `resolveContext` is value-independent, so caching + // by column identity makes `inArray`/`notInArray` build the context (and its + // deep-cloned match block) once for the whole list instead of once per value. + const contextCache = new WeakMap() + + function drizzleTableOf(column: SQLWrapper): PgTable | undefined { + return is(column, Column) + ? (column.table as PgTable | undefined) + : undefined + } + + function resolveContext(column: SQLWrapper, operator: string): ColumnContext { + const cached = contextCache.get(column) + if (cached) return cached + + const columnName = is(column, Column) ? column.name : 'unknown' + const builder = getEqlV3Column(columnName, column) + if (!builder) { throw new EncryptionOperatorError( - `Unexpected error encrypting values for column "${columnName}": ${errorMessage}`, - { columnName }, + `Operator "${operator}" requires an encrypted v3 column, but "${columnName}" is not one.`, + { columnName, operator }, ) } - } - - return results -} -/** - * Helper to encrypt a single value for use in a query - * Returns the encrypted search term or the original value if not encrypted - */ -async function encryptValue( - encryptionClient: EncryptionClient, - value: unknown, - drizzleColumn: SQLWrapper, - encryptedTable: EncryptedTable | undefined, - tableCache: Map>, - queryType?: QueryTypeName, -): Promise { - const results = await encryptValues( - encryptionClient, - [{ value, column: drizzleColumn, queryType }], - encryptedTable, - tableCache, - ) - return results[0] -} - -// ============================================================================ -// Lazy Operator Pattern -// ============================================================================ - -/** - * Simplified lazy operator that defers encryption until awaited or batched - */ -interface LazyOperator { - readonly __isLazyOperator: true - readonly operator: string - readonly queryType?: QueryTypeName - readonly left: SQLWrapper - readonly right: unknown - readonly min?: unknown - readonly max?: unknown - readonly needsEncryption: boolean - readonly columnInfo: ColumnInfo - execute( - encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ): SQL -} - -/** - * Type guard for lazy operators - */ -function isLazyOperator(value: unknown): value is LazyOperator { - return ( - typeof value === 'object' && - value !== null && - '__isLazyOperator' in value && - (value as LazyOperator).__isLazyOperator === true - ) -} - -/** - * Creates a lazy operator that defers execution - */ -function createLazyOperator( - operator: string, - left: SQLWrapper, - right: unknown, - execute: ( - encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ) => SQL, - needsEncryption: boolean, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, - min?: unknown, - max?: unknown, - queryType?: QueryTypeName, -): LazyOperator & Promise { - let resolvedSQL: SQL | undefined - let encryptionPromise: Promise | undefined - - const lazyOp: LazyOperator = { - __isLazyOperator: true, - operator, - queryType, - left, - right, - min, - max, - needsEncryption, - columnInfo, - execute, - } - - // Create a promise that will be resolved when encryption completes - const promise = new Promise((resolve, reject) => { - // Auto-execute when awaited directly - queueMicrotask(async () => { - if (resolvedSQL !== undefined) { - resolve(resolvedSQL) - return - } - - try { - if (!encryptionPromise) { - encryptionPromise = executeLazyOperatorDirect( - lazyOp, - encryptionClient, - defaultTable, - tableCache, - ) - } - const sql = await encryptionPromise - resolvedSQL = sql - resolve(sql) - } catch (error) { - reject(error) - } - }) - }) - - // Attach lazy operator properties to the promise - return Object.assign(promise, lazyOp) -} + const drizzleTable = drizzleTableOf(column) + const tableName = getDrizzleTableName(drizzleTable) ?? 'unknown' -/** - * Executes a lazy operator with pre-encrypted values (used in batched mode) - */ -async function executeLazyOperator( - lazyOp: LazyOperator, - encryptedValues?: { value: unknown; encrypted: unknown }[], -): Promise { - if (!lazyOp.needsEncryption) { - return lazyOp.execute(lazyOp.right) - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - // Between operator - use provided encrypted values - let encryptedMin: unknown - let encryptedMax: unknown - - if (encryptedValues && encryptedValues.length >= 2) { - encryptedMin = encryptedValues[0]?.encrypted - encryptedMax = encryptedValues[1]?.encrypted - } else { - throw new EncryptionOperatorError( - 'Between operator requires both min and max encrypted values', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) + let table = drizzleTable ? tableCache.get(drizzleTable) : undefined + if (!table && drizzleTable) { + table = extractEncryptionSchema(drizzleTable) + tableCache.set(drizzleTable, table) } - - if (encryptedMin === undefined || encryptedMax === undefined) { + if (!table) { throw new EncryptionOperatorError( - 'Between operator requires both min and max values to be encrypted', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, + `Unable to resolve the encrypted table for column "${columnName}".`, + { columnName, operator }, ) } - return lazyOp.execute(undefined, encryptedMin, encryptedMax) - } - - // Single value operator - let encrypted: unknown - - if (encryptedValues && encryptedValues.length > 0) { - encrypted = encryptedValues[0]?.encrypted - } else { - throw new EncryptionOperatorError( - 'Operator requires encrypted value but none provided', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - if (encrypted === undefined) { - throw new EncryptionOperatorError( - 'Encryption failed or value was not encrypted', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - return lazyOp.execute(encrypted) -} - -/** - * Executes a lazy operator directly by encrypting values on demand - * Used when operator is awaited directly (not batched) - */ -async function executeLazyOperatorDirect( - lazyOp: LazyOperator, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise { - if (!lazyOp.needsEncryption) { - return lazyOp.execute(lazyOp.right) - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - // Between operator - encrypt min and max - const [encryptedMin, encryptedMax] = await encryptValues( - encryptionClient, - [ - { value: lazyOp.min, column: lazyOp.left, queryType: lazyOp.queryType }, - { value: lazyOp.max, column: lazyOp.left, queryType: lazyOp.queryType }, - ], - defaultTable, - tableCache, - ) - return lazyOp.execute(undefined, encryptedMin, encryptedMax) - } - - // Single value operator - const encrypted = await encryptValue( - encryptionClient, - lazyOp.right, - lazyOp.left, - defaultTable, - tableCache, - lazyOp.queryType, - ) - - return lazyOp.execute(encrypted) -} - -// ============================================================================ -// Operator Factory Functions -// ============================================================================ - -/** - * Creates a comparison operator (eq, ne, gt, gte, lt, lte) - */ -function createComparisonOperator( - operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - // Operators requiring orderAndRange index - const requiresOrderAndRange = ['gt', 'gte', 'lt', 'lte'].includes(operator) - - if (requiresOrderAndRange) { - if (!config?.orderAndRange) { - // Return regular Drizzle operator for non-encrypted columns - switch (operator) { - case 'gt': - return gt(left, right) - case 'gte': - return gte(left, right) - case 'lt': - return lt(left, right) - case 'lte': - return lte(left, right) - } - } - - // This will be replaced with encrypted value in executeLazyOperator - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - return sql`eql_v2.${sql.raw(operator)}(${left}, ${bindIfParam(encrypted, left)})` - } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, // min - undefined, // max - queryTypes.orderAndRange, - ) as Promise - } - - // Equality operators (eq, ne) - const requiresEquality = ['eq', 'ne'].includes(operator) - - if (requiresEquality && config?.equality) { - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - return operator === 'eq' ? eq(left, encrypted) : ne(left, encrypted) + const context: ColumnContext = { + builder, + table, + indexes: builder.build().indexes, + columnName, + tableName, + queryCast: queryCastForDomain(builder.getEqlType()), } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, // min - undefined, // max - queryTypes.equality, - ) as Promise + contextCache.set(column, context) + return context } - // Fallback to regular Drizzle operators - return operator === 'eq' ? eq(left, right) : ne(left, right) -} - -/** - * Creates a range operator (between, notBetween) - */ -function createRangeOperator( - operator: 'between' | 'notBetween', - left: SQLWrapper, - min: unknown, - max: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - if (!config?.orderAndRange) { - return operator === 'between' - ? between(left, min, max) - : notBetween(left, min, max) - } - - const executeFn = ( - _encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ) => { - if (encryptedMin === undefined || encryptedMax === undefined) { + /** + * Gate an operator on the column's indexes. `indexes` is a disjunction — any + * one of them grants the capability — so equality (`unique` OR `ore`) and the + * single-index gates share one rule and one diagnostic shape. + */ + function requireIndex( + ctx: ColumnContext, + indexes: readonly ('unique' | 'ore' | 'ope' | 'match' | 'ste_vec')[], + operator: string, + capability: string, + ): void { + if (!indexes.some((index) => ctx.indexes[index])) { throw new EncryptionOperatorError( - `${operator} operator requires both min and max values`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, + `Operator "${operator}" requires ${capability} on column "${ctx.columnName}" (domain ${ctx.builder.getEqlType()} does not support it).`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } - - const rangeCondition = sql`eql_v2.gte(${left}, ${bindIfParam(encryptedMin, left)}) AND eql_v2.lte(${left}, ${bindIfParam(encryptedMax, left)})` - - return operator === 'between' - ? rangeCondition - : sql`NOT (${rangeCondition})` } - return createLazyOperator( - operator, - left, - undefined, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - min, - max, - queryTypes.orderAndRange, - ) as Promise -} - -/** - * Creates a text search operator (like, ilike, notIlike) - */ -function createTextSearchOperator( - operator: 'like' | 'ilike' | 'notIlike', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - if (!config?.freeTextSearch) { - // Cast to satisfy TypeScript - const rightValue = right as string | SQLWrapper - switch (operator) { - case 'like': - return like(left as Parameters[0], rightValue) - case 'ilike': - return ilike(left as Parameters[0], rightValue) - case 'notIlike': - return notIlike(left as Parameters[0], rightValue) - } - } - - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { + // Ordering flavour is pinned by the column's domain (eql-3.0.0): `_ord` + // domains carry `ope` (`op` CLLW-OPE term), `_ord_ore` domains carry `ore` + // (`ob` block-ORE term). Either satisfies the order/range operators, and an + // 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 + // Two DISTINCT operators, split by semantics (#617): + // - `matches` is bloom free-text (`match`, a `text_search`/`text_match` + // column): a one-sided, order- and multiplicity-insensitive token match that + // may false-positive. It emits `eql_v3.matches(col, operand)` (the SQL + // function keeps its bundle name) but is NOT containment. + // - `contains` is encrypted-JSONB containment (an `eql_v3_json_search` column, + // `ste_vec` index): exact jsonb `@>`, no false positives — genuine + // containment, so it keeps the `contains` name. + const MATCH_INDEXES = ['match'] as const + const JSON_CONTAINMENT_INDEXES = ['ste_vec'] as const + + function applyOperationOptions( + op: ChainableOperation, + opts?: EncryptionOperatorCallOpts, + ): AuditableOperation { + const lockContext = opts?.lockContext ?? defaults.lockContext + const audit = opts?.audit ?? defaults.audit + const withLock = lockContext ? op.withLockContext(lockContext) : op + if (audit) withLock.audit(audit) + return withLock + } + + function requireNonNullOperand( + ctx: ColumnContext, + value: unknown, + operator: string, + ): void { + if (value == null) { throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, + `Operator "${operator}" cannot encrypt a null operand for column "${ctx.columnName}". Use isNull() or isNotNull() for NULL checks.`, { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, + columnName: ctx.columnName, + tableName: ctx.tableName, operator, }, ) } - - const sqlFn = sql`eql_v2.${sql.raw(operator === 'notIlike' ? 'ilike' : operator)}(${left}, ${bindIfParam(encrypted, left)})` - return operator === 'notIlike' ? sql`NOT (${sqlFn})` : sqlFn } - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, // min - undefined, // max - queryTypes.freeTextSearch, - ) as Promise -} - -/** - * Creates a JSONB operator that encrypts a JSON path selector and wraps it - * in the appropriate `eql_v2` function call. - * - * Supports `jsonbPathQueryFirst`, `jsonbGet`, and `jsonbPathExists`. - * The column must have `searchableJson` enabled in its {@link EncryptedColumnConfig}. - */ -function createJsonbOperator( - operator: 'jsonbPathQueryFirst' | 'jsonbGet' | 'jsonbPathExists', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise { - const { config } = columnInfo - const encryptedSelector = (value: unknown) => - sql`${bindIfParam(value, left)}::eql_v2_encrypted` - - if (!config?.searchableJson) { - throw new EncryptionOperatorError( - `The ${operator} operator requires searchableJson to be enabled on the column configuration.`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { + /** + * Reject a free-text needle the column's match index cannot answer. A needle + * shorter than the tokenizer's `token_length` yields an empty bloom filter, + * and `stored_bf @> '{}'` holds for every row — so without this the query + * silently returns the whole table. + */ + function requireAnswerableNeedle( + ctx: ColumnContext, + value: unknown, + operator: string, + ): void { + const match = ctx.indexes.match + if (!match) return + const reason = matchNeedleError(value, match) + if (reason) { throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, + `Operator "${operator}" cannot search column "${ctx.columnName}": ${reason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } - switch (operator) { - case 'jsonbPathQueryFirst': - return sql`eql_v2.jsonb_path_query_first(${left}, ${encryptedSelector(encrypted)})` - case 'jsonbGet': - return sql`${left} -> ${encryptedSelector(encrypted)}` - case 'jsonbPathExists': - return sql`eql_v2.jsonb_path_exists(${left}, ${encryptedSelector(encrypted)})` - } } - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, - undefined, - queryTypes.steVecSelector, - ) as Promise -} - -// ============================================================================ -// Public API: createEncryptionOperators -// ============================================================================ - -/** - * Creates a set of encryption-aware operators that automatically encrypt values - * for encrypted columns before using them with Drizzle operators. - * - * For equality and text search operators (eq, ne, like, ilike, inArray, etc.): - * Values are encrypted and then passed to regular Drizzle operators, which use - * PostgreSQL's built-in operators for eql_v2_encrypted types. - * - * For order and range operators (gt, gte, lt, lte, between, notBetween): - * Values are encrypted and then use eql_v2.* functions (eql_v2.gt(), eql_v2.gte(), etc.) - * which are required for ORE (Order-Revealing Encryption) comparisons. - * - * @param encryptionClient - The EncryptionClient instance - * @returns An object with all Drizzle operators wrapped for encrypted columns - * - * @example - * ```ts - * // Initialize operators - * const ops = createEncryptionOperators(encryptionClient) - * - * // Equality search - automatically encrypts and uses PostgreSQL operators - * const results = await db - * .select() - * .from(usersTable) - * .where(await ops.eq(usersTable.email, 'user@example.com')) - * - * // Range query - automatically encrypts and uses eql_v2.gte() - * const olderUsers = await db - * .select() - * .from(usersTable) - * .where(await ops.gte(usersTable.age, 25)) - * ``` - */ -export function createEncryptionOperators(encryptionClient: EncryptionClient): { - // Comparison operators - /** - * Equality operator - encrypts value for encrypted columns. - * Requires either `equality` or `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users with a specific email address. - * ```ts - * const condition = await ops.eq(usersTable.email, 'user@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - eq: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Not equal operator - encrypts value for encrypted columns. - * Requires either `equality` or `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users whose email address is not a specific value. - * ```ts - * const condition = await ops.ne(usersTable.email, 'user@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - ne: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Greater than operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users older than a specific age. - * ```ts - * const condition = await ops.gt(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - gt: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Greater than or equal operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users older than or equal to a specific age. - * ```ts - * const condition = await ops.gte(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - gte: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Less than operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users younger than a specific age. - * ```ts - * const condition = await ops.lt(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - lt: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Less than or equal operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users younger than or equal to a specific age. - * ```ts - * const condition = await ops.lte(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - lte: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Between operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users within a specific age range. - * ```ts - * const condition = await ops.between(usersTable.age, 20, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - between: (left: SQLWrapper, min: unknown, max: unknown) => Promise | SQL - - /** - * Not between operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users outside a specific age range. - * ```ts - * const condition = await ops.notBetween(usersTable.age, 20, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - notBetween: ( - left: SQLWrapper, - min: unknown, - max: unknown, - ) => Promise | SQL - - /** - * Like operator for encrypted columns with free text search. - * Requires `freeTextSearch` to be set on {@link EncryptedColumnConfig}. - * - * > [!IMPORTANT] - * > Case sensitivity on encrypted columns depends on the {@link EncryptedColumnConfig}. - * > Ensure that the column is configured for case-insensitive search if needed. - * - * @example - * Select users with email addresses matching a pattern. - * ```ts - * const condition = await ops.like(usersTable.email, '%@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - like: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * ILike operator for encrypted columns with free text search. - * Requires `freeTextSearch` to be set on {@link EncryptedColumnConfig}. - * - * > [!IMPORTANT] - * > Case sensitivity on encrypted columns depends on the {@link EncryptedColumnConfig}. - * > Ensure that the column is configured for case-insensitive search if needed. - * - * @example - * Select users with email addresses matching a pattern (case-insensitive). - * ```ts - * const condition = await ops.ilike(usersTable.email, '%@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - ilike: (left: SQLWrapper, right: unknown) => Promise | SQL - notIlike: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * JSONB path query first operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_query_first()`, - * casting the parameter to `eql_v2_encrypted`. - * - * @throws {EncryptionOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbPathQueryFirst: (left: SQLWrapper, right: unknown) => Promise - - /** - * JSONB get operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and uses the `->` operator, - * casting the parameter to `eql_v2_encrypted`. - * - * @throws {EncryptionOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbGet: (left: SQLWrapper, right: unknown) => Promise - - /** - * JSONB path exists operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_exists()`, - * casting the parameter to `eql_v2_encrypted`. - * - * @throws {EncryptionOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbPathExists: (left: SQLWrapper, right: unknown) => Promise - // Array operators - inArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise - notInArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise - // Sorting operators - asc: (column: SQLWrapper) => SQL - desc: (column: SQLWrapper) => SQL - and: ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ) => Promise - or: ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ) => Promise - // Operators that don't need encryption (pass through to Drizzle) - exists: typeof exists - notExists: typeof notExists - isNull: typeof isNull - isNotNull: typeof isNotNull - not: typeof not - // Array operators that work with arrays directly (not encrypted values) - arrayContains: typeof arrayContains - arrayContained: typeof arrayContained - arrayOverlaps: typeof arrayOverlaps -} { - // Create a cache for encrypted tables keyed by table name - const tableCache = new Map>() - const defaultTable: EncryptedTable | undefined = - undefined - - /** - * Equality operator - encrypts value and uses regular Drizzle operator - */ - const encryptedEq = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'eq', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + function operandFailure( + ctx: ColumnContext, + operator: string, + reason: string, + ): EncryptionOperatorError { + return new EncryptionOperatorError( + `Failed to encrypt query operand for "${ctx.columnName}": ${reason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } /** - * Not equal operator - encrypts value and uses regular Drizzle operator + * 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. */ - const encryptedNe = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'ne', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + 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)}` } - /** - * Greater than operator - uses eql_v2.gt() for encrypted columns with ORE index - */ - const encryptedGt = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'gt', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } + async function encryptOperand( + ctx: ColumnContext, + value: unknown, + operator: string, + queryType: QueryTypeName, + opts?: EncryptionOperatorCallOpts, + ): Promise { + requireNonNullOperand(ctx, value, operator) - /** - * Greater than or equal operator - uses eql_v2.gte() for encrypted columns with ORE index - */ - const encryptedGte = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'gte', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + const result = await applyOperationOptions( + 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) + } + return castOperand(ctx, operator, result.data) } /** - * Less than operator - uses eql_v2.lt() for encrypted columns with ORE index + * 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. */ - const encryptedLt = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'lt', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } + async function encryptOperands( + ctx: ColumnContext, + values: unknown[], + operator: string, + queryType: QueryTypeName, + opts?: EncryptionOperatorCallOpts, + ): Promise { + for (const value of values) requireNonNullOperand(ctx, value, operator) - /** - * Less than or equal operator - uses eql_v2.lte() for encrypted columns with ORE index - */ - const encryptedLte = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'lte', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + const terms = values.map((value) => ({ + value, + column: ctx.builder, + table: ctx.table, + queryType, + })) + const result = await applyOperationOptions( + client.encryptQuery(terms as never), + opts, ) - } + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } - /** - * Between operator - uses eql_v2.gte() and eql_v2.lte() for encrypted columns with ORE index - */ - const encryptedBetween = ( - left: SQLWrapper, - min: unknown, - max: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createRangeOperator( - 'between', - left, - min, - max, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + const encrypted = result.data as EncryptedQueryResult[] + if (encrypted.length !== values.length) { + throw operandFailure( + ctx, + operator, + `batch query encryption returned ${encrypted.length} terms for ${values.length} values.`, + ) + } + return encrypted.map((term) => castOperand(ctx, operator, term)) } - /** - * Not between operator - uses eql_v2.gte() and eql_v2.lte() for encrypted columns with ORE index - */ - const encryptedNotBetween = ( - left: SQLWrapper, - min: unknown, - max: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createRangeOperator( - 'notBetween', - left, - min, - max, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } + const colSql = (column: SQLWrapper): SQL => sql`${column}` - /** - * Like operator - encrypts value and uses eql_v2.like() for encrypted columns with match index - */ - const encryptedLike = ( + async function equality( + op: EqualityOp, left: SQLWrapper, right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createTextSearchOperator( - 'like', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, op) + requireIndex(ctx, EQUALITY_INDEXES, op, 'equality') + const enc = await encryptOperand(ctx, right, op, 'equality', opts) + return v3Dialect.equality(op, colSql(left), enc) } - /** - * Case-insensitive like operator - encrypts value and uses eql_v2.ilike() for encrypted columns with match index - */ - const encryptedIlike = ( + async function comparison( + op: ComparisonOp, left: SQLWrapper, right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createTextSearchOperator( - 'ilike', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, op) + requireIndex(ctx, ORDERING_INDEXES, op, 'order/range') + const enc = await encryptOperand(ctx, right, op, 'orderAndRange', opts) + return v3Dialect.comparison(op, colSql(left), enc) } - /** - * Not like operator (case insensitive) - encrypts value and uses eql_v2.ilike() for encrypted columns with match index - */ - const encryptedNotIlike = ( + async function range( left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createTextSearchOperator( - 'notIlike', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } - - /** - * JSONB path query first operator - encrypts the selector and calls - * `eql_v2.jsonb_path_query_first()` for encrypted columns with searchable JSON. - */ - const encryptedJsonbPathQueryFirst = ( + min: unknown, + max: unknown, + negate: boolean, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') + // 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, 'orderAndRange', opts), + encryptOperand(ctx, max, operator, 'orderAndRange', opts), + ]) + // `v3Dialect.range` is already parenthesised, so `NOT` binds to the whole + // conjunction without a wrapper here. + const condition = v3Dialect.range(colSql(left), encMin, encMax) + return negate ? sql`NOT ${condition}` : condition + } + + /** + * Fuzzy free-text token match on a `text_search`/`text_match` column. NOT + * containment: it tests whether the needle's downcased 3-gram set is a subset + * of the haystack's, via a bloom filter — order- and multiplicity-insensitive + * and one-sided (a `true` may be a false positive, a `false` never is). Emits + * `eql_v3.matches(col, operand)` (the SQL function's bundle name). + */ + async function matches( left: SQLWrapper, right: unknown, - ): Promise => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createJsonbOperator( - 'jsonbPathQueryFirst', - left, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + requireIndex(ctx, MATCH_INDEXES, operator, 'free-text search') + // The answerable-needle rule applies (a sub-`token_length` needle blooms to + // nothing and would match every row); the `query_` cast reaches the + // match overload. + requireAnswerableNeedle(ctx, right, operator) + const enc = await encryptOperand( + ctx, right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + operator, + 'freeTextSearch', + opts, ) + return v3Dialect.contains(colSql(left), enc) } /** - * JSONB get operator - encrypts the selector and uses the `->` operator - * for encrypted columns with searchable JSON. + * Exact encrypted-JSONB containment on an `eql_v3_json_search` (`ste_vec`) column: + * genuine jsonb `@>`, no false positives — hence it keeps the `contains` name. + * `eql_v3_json_search` has no `eql_v3.matches` overload; containment is the `@>` + * operator, whose `(eql_v3_json_search, eql_v3.query_json)` form takes a NARROWED + * query term (searchableJson → no ciphertext), cast to `eql_v3.query_json`. */ - const encryptedJsonbGet = ( + async function containsJsonOp( left: SQLWrapper, right: unknown, - ): Promise => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createJsonbOperator( - 'jsonbGet', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + requireIndex(ctx, JSON_CONTAINMENT_INDEXES, operator, 'JSON containment') + const needle = await encryptJsonContainmentTerm(ctx, right, operator, opts) + return v3Dialect.containsJson(colSql(left), needle) + } + + /** + * Build a `query_json` containment needle for a `json` column — the JSON query + * term carries no ciphertext and satisfies the `eql_v3.query_json` CHECK the + * `@>` overload needs. Cast here (not by `queryCastForDomain`): a json column's + * domain is `eql_v3_json_search` but its query operand type is the irregular + * `eql_v3.query_json`. + */ + 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 + // (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 result = await applyOperationOptions( + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'searchableJson', + } as never, + ), + opts, ) - } - - /** - * JSONB path exists operator - encrypts the selector and calls - * `eql_v2.jsonb_path_exists()` for encrypted columns with searchable JSON. - */ - const encryptedJsonbPathExists = ( - left: SQLWrapper, - right: unknown, - ): Promise => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createJsonbOperator( - 'jsonbPathExists', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } + return sql`${JSON.stringify(result.data)}::eql_v3.query_json` + } + + /** + * JSONPath selector-with-constraint on an `eql_v3_json_search` (`ste_vec`) + * column. Equality uses a value-selector containment needle, allowing the + * functional GIN index to answer the query. Ordering extracts the path entry + * with a selector hash and compares it to a ciphertext-free scalar ordering + * term. No storage ciphertext is placed in the WHERE clause. + */ + async function selectorCompare( + col: SQLWrapper, + path: string, + op: EqualityOp | ComparisonOp, + value: unknown, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(col, operator) + requireIndex( + ctx, + JSON_CONTAINMENT_INDEXES, + operator, + 'JSON selector (searchableJson)', ) - } - - /** - * In array operator - encrypts all values in the array - */ - const encryptedInArray = async ( - left: SQLWrapper, - right: unknown[] | SQLWrapper, - ): Promise => { - // If right is a SQLWrapper (subquery), pass through to Drizzle - if (isSQLWrapper(right)) { - return inArray(left, right as unknown as Parameters[1]) - } + requireNonNullOperand(ctx, value, operator) - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - - if (!columnInfo.config?.equality || !Array.isArray(right)) { - return inArray(left, right as unknown[]) + // A selector compares a scalar leaf — reject non-scalars / non-orderable + // types up front with a clear error, not a deferred DB failure. + const ordering = op !== 'eq' && op !== 'ne' + const leafReason = unsupportedLeafReason(value, ordering) + if (leafReason) { + throw new EncryptionOperatorError( + `Operator "${operator}" cannot compare column "${ctx.columnName}": ${leafReason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - // Encrypt all values in the array in a single batch - const encryptedValues = await encryptValues( - encryptionClient, - right.map((value) => ({ - value, - column: left, - queryType: queryTypes.equality, - })), - defaultTable, - tableCache, - ) - - // Use regular eq for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => eq(left, encrypted)) - - if (conditions.length === 0) { - return sql`false` + // Surface path-validation failures as EncryptionOperatorError with context. + let segments: string[] + try { + segments = parseSelectorSegments(path) + } catch (err) { + throw new EncryptionOperatorError( + `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - const combined = or(...conditions) - return combined ?? sql`false` - } + const canonicalPath = jsonPathOf(segments) - /** - * Not in array operator - */ - const encryptedNotInArray = async ( - left: SQLWrapper, - right: unknown[] | SQLWrapper, - ): Promise => { - // If right is a SQLWrapper (subquery), pass through to Drizzle - if (isSQLWrapper(right)) { - return notInArray( - left, - right as unknown as Parameters[1], + if (op === 'eq' || op === 'ne') { + const result = await applyOperationOptions( + client.encryptQuery( + { path: canonicalPath, value } as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecValueSelector', + } as never, + ), + opts, + ) + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } + const contains = v3Dialect.containsJson( + colSql(col), + sql`${JSON.stringify(result.data)}::eql_v3.query_json`, + ) + return op === 'eq' + ? contains + : sql`(NOT ${contains} OR ${colSql(col)} IS NULL)` + } + + // Selector hashing and scalar-term encryption are independent, so order + // them concurrently. `steVecTerm` accepts only the JSON-orderable scalar + // families (string and number), enforced above. + const [selResult, termResult] = await Promise.all([ + applyOperationOptions( + client.encryptQuery( + canonicalPath as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecSelector', + } as never, + ), + opts, + ), + applyOperationOptions( + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecTerm', + } as never, + ), + opts, + ), + ]) + if (selResult.failure) { + throw operandFailure(ctx, operator, selResult.failure.message) + } + if (termResult.failure) { + throw operandFailure(ctx, operator, termResult.failure.message) + } + + // A v3 selector term is the bare HMAC hash string; guard the shape so a + // wrapped envelope can't silently bind as a JSON blob and match no rows. + const selValue = selResult.data + if (typeof selValue !== 'string') { + throw operandFailure( + ctx, + operator, + `expected a bare selector hash, got ${typeof selValue}.`, ) } - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - - if (!columnInfo.config?.equality || !Array.isArray(right)) { - return notInArray(left, right as unknown[]) - } + const selSql = sql`${selValue}::text` + const leftEntry = v3Dialect.selectorEntry(colSql(col), selSql) + const termCast = + typeof value === 'string' + ? 'eql_v3.query_text_ord' + : 'eql_v3.query_double_ord' + const rightTerm = sql`${JSON.stringify(termResult.data)}::${sql.raw(termCast)}` + return v3Dialect.comparison(op, leftEntry, rightTerm) + } - // Encrypt all values in the array in a single batch - const encryptedValues = await encryptValues( - encryptionClient, - right.map((value) => ({ - value, - column: left, - queryType: queryTypes.equality, - })), - defaultTable, - tableCache, + /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar + * operators. Async: each encrypts its operand. */ + function selectorOps(col: SQLWrapper, path: string) { + const at = + (op: EqualityOp | ComparisonOp) => + (value: unknown, opts?: EncryptionOperatorCallOpts) => + selectorCompare(col, path, op, value, `selector(${path}).${op}`, opts) + return { + /** `col->'path' = value` (encrypted equality at the selector). A row whose + * document lacks `path` is excluded (it is not equal to `value`). */ + eq: at('eq'), + /** `col->'path' <> value`, INCLUDING rows whose document lacks `path` + * ("not equal to value" covers "has no value"). */ + ne: at('ne'), + /** `col->'path' > value` (encrypted ordering at the selector). */ + gt: at('gt'), + /** `col->'path' >= value`. */ + gte: at('gte'), + /** `col->'path' < value`. */ + lt: at('lt'), + /** `col->'path' <= value`. */ + lte: at('lte'), + /** Order rows ascending by the encrypted scalar at `path`. Missing paths + * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ + asc: (opts?: EncryptionOperatorCallOpts) => + selectorOrder(col, path, 'asc', opts), + /** Order rows descending by the encrypted scalar at `path`. Missing paths + * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ + desc: (opts?: EncryptionOperatorCallOpts) => + selectorOrder(col, path, 'desc', opts), + } + } + + /** Build `ORDER BY eql_v3.ord_term(col -> selector::text)`. + * The selector is encrypted, but the extracted SteVec entry already carries + * its OPE ordering term, so no plaintext comparison operand is needed. */ + async function selectorOrder( + col: SQLWrapper, + path: string, + direction: 'asc' | 'desc', + opts?: EncryptionOperatorCallOpts, + ): Promise { + const operator = `selector(${path}).${direction}` + const ctx = resolveContext(col, operator) + requireIndex( + ctx, + JSON_CONTAINMENT_INDEXES, + operator, + 'JSON selector (searchableJson)', ) - // Use regular ne for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => ne(left, encrypted)) - - if (conditions.length === 0) { - return sql`true` + let canonicalPath: string + try { + canonicalPath = jsonPathOf(parseSelectorSegments(path)) + } catch (err) { + throw new EncryptionOperatorError( + `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - const combined = and(...conditions) - return combined ?? sql`true` - } - - /** - * Ascending order helper - uses eql_v2.order_by() for encrypted columns with ORE index - */ - const encryptedAsc = (column: SQLWrapper): SQL => { - const columnInfo = getColumnInfo(column, defaultTable, tableCache) - - if (columnInfo.config?.orderAndRange) { - return asc(sql`eql_v2.order_by(${column})`) + const result = await applyOperationOptions( + client.encryptQuery( + canonicalPath as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecSelector', + } as never, + ), + opts, + ) + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) } - - return asc(column) - } - - /** - * Descending order helper - uses eql_v2.order_by() for encrypted columns with ORE index - */ - const encryptedDesc = (column: SQLWrapper): SQL => { - const columnInfo = getColumnInfo(column, defaultTable, tableCache) - - if (columnInfo.config?.orderAndRange) { - return desc(sql`eql_v2.order_by(${column})`) + if (typeof result.data !== 'string') { + throw operandFailure( + ctx, + operator, + `expected a bare selector hash, got ${typeof result.data}.`, + ) } - return desc(column) + const entry = v3Dialect.selectorEntry( + colSql(col), + sql`${result.data}::text`, + ) + const term = v3Dialect.orderBy(entry, 'ope') + return direction === 'asc' ? asc(term) : desc(term) } - /** - * Batched AND operator - collects lazy operators, batches encryption, and combines conditions - */ - const encryptedAnd = async ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ): Promise => { - // Single pass: separate lazy operators from regular conditions - const lazyOperators: LazyOperator[] = [] - const regularConditions: (SQL | SQLWrapper | undefined)[] = [] - const regularPromises: Promise[] = [] - - for (const condition of conditions) { - if (condition === undefined) { - continue - } - - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else if (condition instanceof Promise) { - // Check if promise is also a lazy operator - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else { - regularPromises.push(condition) - } - } else { - regularConditions.push(condition) - } - } - - // If there are no lazy operators, just use Drizzle's and() - if (lazyOperators.length === 0) { - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...(await Promise.all(regularPromises)), - ] - return and(...allConditions) ?? sql`true` - } - - // Single pass: collect all values to encrypt with metadata - const valuesToEncrypt: Array<{ - value: unknown - column: SQLWrapper - columnInfo: ColumnInfo - queryType?: QueryTypeName - lazyOpIndex: number - isMin?: boolean - isMax?: boolean - }> = [] - - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - if (!lazyOp.needsEncryption) { - continue - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.min, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMin: true, - }) - valuesToEncrypt.push({ - value: lazyOp.max, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMax: true, - }) - } else if (lazyOp.right !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.right, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - }) - } + async function inArrayOp( + left: SQLWrapper, + values: unknown[], + negate: boolean, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + if (values.length === 0) { + throw new EncryptionOperatorError( + `Operator "${operator}" requires a non-empty list of values for column "${ctx.columnName}".`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - - // Batch encrypt all values - const encryptedResults = await encryptValues( - encryptionClient, - valuesToEncrypt.map((v) => ({ - value: v.value, - column: v.column, - queryType: v.queryType, - })), - defaultTable, - tableCache, + // Gate and resolve the context once for the whole list, then encrypt it in + // a single `encryptQuery` batch crossing. + requireIndex(ctx, EQUALITY_INDEXES, operator, 'equality') + const op: EqualityOp = negate ? 'ne' : 'eq' + const encrypted = await encryptOperands( + ctx, + values, + operator, + 'equality', + opts, ) - - // Group encrypted values by lazy operator index - const encryptedByLazyOp = new Map< - number, - { value?: unknown; min?: unknown; max?: unknown } - >() - - for (let i = 0; i < valuesToEncrypt.length; i++) { - const { lazyOpIndex, isMin, isMax } = valuesToEncrypt[i] - const encrypted = encryptedResults[i] - - let group = encryptedByLazyOp.get(lazyOpIndex) - if (!group) { - group = {} - encryptedByLazyOp.set(lazyOpIndex, group) - } - - if (isMin) { - group.min = encrypted - } else if (isMax) { - group.max = encrypted - } else { - group.value = encrypted - } - } - - // Execute all lazy operators with their encrypted values - const sqlConditions: SQL[] = [] - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - const encrypted = encryptedByLazyOp.get(i) - - let sqlCondition: SQL - if (lazyOp.needsEncryption && encrypted) { - const encryptedValues: Array<{ value: unknown; encrypted: unknown }> = - [] - if (encrypted.value !== undefined) { - encryptedValues.push({ - value: lazyOp.right, - encrypted: encrypted.value, - }) - } - if (encrypted.min !== undefined) { - encryptedValues.push({ value: lazyOp.min, encrypted: encrypted.min }) - } - if (encrypted.max !== undefined) { - encryptedValues.push({ value: lazyOp.max, encrypted: encrypted.max }) - } - sqlCondition = await executeLazyOperator(lazyOp, encryptedValues) - } else { - sqlCondition = lazyOp.execute(lazyOp.right) - } - - sqlConditions.push(sqlCondition) - } - - // Await any regular promises - const regularPromisesResults = await Promise.all(regularPromises) - - // Combine all conditions - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...sqlConditions, - ...regularPromisesResults, - ] - - return and(...allConditions) ?? sql`true` + const conditions = encrypted.map((enc) => + v3Dialect.equality(op, colSql(left), enc), + ) + // The empty-list guard above leaves `conditions` non-empty, so `and`/`or` + // never return undefined here. + return (negate ? and(...conditions) : or(...conditions)) as SQL } - /** - * Batched OR operator - collects lazy operators, batches encryption, and combines conditions - */ - const encryptedOr = async ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ): Promise => { - const lazyOperators: LazyOperator[] = [] - const regularConditions: (SQL | SQLWrapper | undefined)[] = [] - const regularPromises: Promise[] = [] - - for (const condition of conditions) { - if (condition === undefined) { - continue - } - - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else if (condition instanceof Promise) { - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else { - regularPromises.push(condition) - } - } else { - regularConditions.push(condition) - } - } - - if (lazyOperators.length === 0) { - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...(await Promise.all(regularPromises)), - ] - return or(...allConditions) ?? sql`false` - } - - const valuesToEncrypt: Array<{ - value: unknown - column: SQLWrapper - columnInfo: ColumnInfo - queryType?: QueryTypeName - lazyOpIndex: number - isMin?: boolean - isMax?: boolean - }> = [] - - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - if (!lazyOp.needsEncryption) { - continue - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.min, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMin: true, - }) - valuesToEncrypt.push({ - value: lazyOp.max, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMax: true, - }) - } else if (lazyOp.right !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.right, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - }) - } - } + function orderTerm(column: SQLWrapper, operator: string): SQL { + const ctx = resolveContext(column, operator) + requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') + return v3Dialect.orderBy(colSql(column), ctx.indexes.ore ? 'ore' : 'ope') + } - const encryptedResults = await encryptValues( - encryptionClient, - valuesToEncrypt.map((v) => ({ - value: v.value, - column: v.column, - queryType: v.queryType, - })), - defaultTable, - tableCache, + async function combine( + joiner: typeof and, + empty: SQL, + conditions: (SQL | SQLWrapper | Promise | undefined)[], + ): Promise { + const present = conditions.filter( + (c): c is SQL | SQLWrapper | Promise => c !== undefined, ) - - const encryptedByLazyOp = new Map< - number, - { value?: unknown; min?: unknown; max?: unknown } - >() - - for (let i = 0; i < valuesToEncrypt.length; i++) { - const { lazyOpIndex, isMin, isMax } = valuesToEncrypt[i] - const encrypted = encryptedResults[i] - - let group = encryptedByLazyOp.get(lazyOpIndex) - if (!group) { - group = {} - encryptedByLazyOp.set(lazyOpIndex, group) - } - - if (isMin) { - group.min = encrypted - } else if (isMax) { - group.max = encrypted - } else { - group.value = encrypted - } - } - - const sqlConditions: SQL[] = [] - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - const encrypted = encryptedByLazyOp.get(i) - - let sqlCondition: SQL - if (lazyOp.needsEncryption && encrypted) { - const encryptedValues: Array<{ value: unknown; encrypted: unknown }> = - [] - if (encrypted.value !== undefined) { - encryptedValues.push({ - value: lazyOp.right, - encrypted: encrypted.value, - }) - } - if (encrypted.min !== undefined) { - encryptedValues.push({ value: lazyOp.min, encrypted: encrypted.min }) - } - if (encrypted.max !== undefined) { - encryptedValues.push({ value: lazyOp.max, encrypted: encrypted.max }) - } - sqlCondition = await executeLazyOperator(lazyOp, encryptedValues) - } else { - sqlCondition = lazyOp.execute(lazyOp.right) - } - - sqlConditions.push(sqlCondition) - } - - const regularPromisesResults = await Promise.all(regularPromises) - - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...sqlConditions, - ...regularPromisesResults, - ] - - return or(...allConditions) ?? sql`false` + const resolved = await Promise.all(present) + return joiner(...resolved) ?? empty } return { - // Comparison operators - eq: encryptedEq, - ne: encryptedNe, - gt: encryptedGt, - gte: encryptedGte, - lt: encryptedLt, - lte: encryptedLte, - - // Range operators - between: encryptedBetween, - notBetween: encryptedNotBetween, - - // Text search operators - like: encryptedLike, - ilike: encryptedIlike, - notIlike: encryptedNotIlike, - - // Searchable JSON operators - jsonbPathQueryFirst: encryptedJsonbPathQueryFirst, - jsonbGet: encryptedJsonbGet, - jsonbPathExists: encryptedJsonbPathExists, - - // Array operators - inArray: encryptedInArray, - notInArray: encryptedNotInArray, - - // Sorting operators - asc: encryptedAsc, - desc: encryptedDesc, - - // AND operator - batches encryption operations - and: encryptedAnd, - - // OR operator - batches encryption operations - or: encryptedOr, - - // Operators that don't need encryption (pass through to Drizzle) - exists, - notExists, + /** Equality: `column = value`. Encrypts `r` and emits `eql_v3.eq`. + * Requires a `unique` or `ore` index on the column. */ + eq: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + equality('eq', l, r, opts), + /** Inequality: `column <> value`. Encrypts `r` and emits `eql_v3.neq`. + * Requires a `unique` or `ore` index on the column. */ + ne: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + equality('ne', l, r, opts), + /** Greater-than: `column > value`. Encrypts `r` and emits `eql_v3.gt`. + * Requires an `ore` (order/range) index. */ + gt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('gt', l, r, opts), + /** Greater-than-or-equal: `column >= value`. Encrypts `r` and emits + * `eql_v3.gte`. Requires an `ore` (order/range) index. */ + gte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('gte', l, r, opts), + /** Less-than: `column < value`. Encrypts `r` and emits `eql_v3.lt`. + * Requires an `ore` (order/range) index. */ + lt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('lt', l, r, opts), + /** Less-than-or-equal: `column <= value`. Encrypts `r` and emits + * `eql_v3.lte`. Requires an `ore` (order/range) index. */ + lte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('lte', l, r, opts), + /** Inclusive range `min <= column <= max`. Encrypts both bounds + * concurrently. Requires an `ore` (order/range) index. */ + between: ( + l: SQLWrapper, + min: unknown, + max: unknown, + opts?: EncryptionOperatorCallOpts, + ) => range(l, min, max, false, 'between', opts), + /** Negated inclusive range `NOT (min <= column <= max)`. Encrypts both + * bounds concurrently. Requires an `ore` (order/range) index. */ + notBetween: ( + l: SQLWrapper, + min: unknown, + max: unknown, + opts?: EncryptionOperatorCallOpts, + ) => range(l, min, max, true, 'notBetween', opts), + /** Fuzzy free-text token match — the needle's 3-gram set is (bloom-)tested + * as a subset of the column's. NOT containment: order/multiplicity- + * insensitive and one-sided (a `true` may be a false positive). Encrypts + * `r`. Requires a `match` (free-text search) index. */ + matches: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + matches(l, r, 'matches', opts), + /** Exact encrypted-JSONB containment (`@>`): matches rows whose document + * contains the given sub-object. No false positives. Encrypts `r`. Requires + * a `ste_vec` index (a `types.Json` column). */ + contains: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + containsJsonOp(l, r, 'contains', opts), + /** JSONPath selector-with-constraint on a `types.Json` (`ste_vec`) column. + * Returns comparison and ordering methods bound to `col->'path'` — e.g. + * `await ops.selector(users.doc, '$.age').gt(21)` emits + * `col->'' > `, while `.asc()` emits + * `ORDER BY eql_v3.ord_term(col->'')`. Its unique power over `contains` + * is ordering at a path (`gt`/`gte`/`lt`/`lte` and `asc`/`desc`); `eq`/`ne` + * are also provided. Dot-notation object paths only in v1. */ + selector: (l: SQLWrapper, path: string) => selectorOps(l, path), + /** Membership: ORs one encrypted `eq` term per value. The whole list is + * 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 `encryptQuery` batch crossing. Rejects an empty list; + * requires a `unique` or `ore` index. */ + notInArray: ( + l: SQLWrapper, + values: unknown[], + opts?: EncryptionOperatorCallOpts, + ) => inArrayOp(l, values, true, 'notInArray', opts), + /** Ascending order by the encrypted order term (`eql_v3.ord_term` / + * `eql_v3.ord_term_ore`, by the column's ordering flavour). + * Synchronous (no operand to encrypt). Requires an ordering index. */ + asc: (c: SQLWrapper) => asc(orderTerm(c, 'asc')), + /** Descending order by the encrypted order term (`eql_v3.ord_term` / + * `eql_v3.ord_term_ore`, by the column's ordering flavour). + * Synchronous (no operand to encrypt). Requires an ordering index. */ + desc: (c: SQLWrapper) => desc(orderTerm(c, 'desc')), + /** Conjunction of the given conditions, awaiting any async operands and + * dropping `undefined`. Empty input resolves to `true`. */ + and: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => + combine(and, sql`true`, conds), + /** Disjunction of the given conditions, awaiting any async operands and + * dropping `undefined`. Empty input resolves to `false`. */ + or: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => + combine(or, sql`false`, conds), + /** Drizzle's `isNull`, re-exported unchanged — `column IS NULL` needs no + * encryption and works on any (nullable) encrypted column. */ isNull, + /** Drizzle's `isNotNull`, re-exported unchanged — `column IS NOT NULL` + * needs no encryption. */ isNotNull, + /** Drizzle's `not`, re-exported unchanged — negates an already-built + * (encrypted) predicate. Safe over any operator here, including `between`, + * whose fragment is self-parenthesising. */ not, - // Array operators that work with arrays directly (not encrypted values) - arrayContains, - arrayContained, - arrayOverlaps, + /** Drizzle's `exists`, re-exported unchanged — for correlated subqueries. */ + exists, + /** Drizzle's `notExists`, re-exported unchanged — for correlated + * subqueries. */ + notExists, } } diff --git a/packages/stack-drizzle/src/schema-extraction.ts b/packages/stack-drizzle/src/schema-extraction.ts index 90bb3ec1e..7e0b4226f 100644 --- a/packages/stack-drizzle/src/schema-extraction.ts +++ b/packages/stack-drizzle/src/schema-extraction.ts @@ -1,130 +1,50 @@ import { - type EncryptedColumn, - type EncryptedTable, - encryptedColumn, + type AnyEncryptedV3Column, + type AnyV3Table, encryptedTable, -} from '@cipherstash/stack/schema' -import type { PgCustomColumn, PgTable } from 'drizzle-orm/pg-core' -import { getEncryptedColumnConfig } from './index.js' +} from '@cipherstash/stack/eql/v3' +import type { PgTable } from 'drizzle-orm/pg-core' +import { getEqlV3Column } from './column.js' + +/** Drizzle stashes the SQL table name on this well-known symbol key. */ +const DRIZZLE_NAME = Symbol.for('drizzle:Name') /** - * Extracts the encrypted column keys from a Drizzle table type. - * Columns created with `encryptedType` are `PgCustomColumn` instances; - * this picks only those keys and maps them to `EncryptedColumn`. + * Read the SQL table name Drizzle stashes on a `pgTable`. Returns `undefined` + * for a non-object or a table not built with `pgTable()`. Shared by + * {@link extractEncryptionSchema} and the operator factory so the + * symbol-key introspection lives in exactly one place. */ -// biome-ignore lint/suspicious/noExplicitAny: PgCustomColumn requires a wide generic -type DrizzleEncryptedSchema = { - [K in keyof T as T[K] extends PgCustomColumn - ? K - : never]: EncryptedColumn +export function getDrizzleTableName(table: unknown): string | undefined { + if (!table || typeof table !== 'object') return undefined + const name = (table as Record)[DRIZZLE_NAME] + return typeof name === 'string' ? name : undefined } -/** - * Extracts an encryption schema from a Drizzle table definition. - * This function identifies columns created with `encryptedType` and - * builds a corresponding `EncryptedTable` with `encryptedColumn` definitions. - * - * @param table - The Drizzle table definition - * @returns A EncryptedTable that can be used with encryption client initialization - * - * @example - * ```ts - * const drizzleUsersTable = pgTable('users', { - * email: encryptedType('email', { freeTextSearch: true, equality: true }), - * age: encryptedType('age', { dataType: 'number', orderAndRange: true }), - * }) - * - * const encryptionSchema = extractEncryptionSchema(drizzleUsersTable) - * const client = await createEncryptionClient({ schemas: [encryptionSchema.build()] }) - * ``` - */ -// We use any for the PgTable generic because we need to access Drizzle's internal properties -// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types don't expose Symbol properties -export function extractEncryptionSchema>( - table: T, -): EncryptedTable> & DrizzleEncryptedSchema { - // Drizzle tables store the name in a Symbol property - // biome-ignore lint/suspicious/noExplicitAny: Drizzle tables don't expose Symbol properties in types - const tableName = (table as any)[Symbol.for('drizzle:Name')] as - | string - | undefined +export function extractEncryptionSchema(table: PgTable): AnyV3Table { + const tableName = getDrizzleTableName(table) if (!tableName) { throw new Error( - 'Unable to extract table name from Drizzle table. Ensure you are using a table created with pgTable().', + 'Unable to read table name from Drizzle table. Use a table created with pgTable().', ) } - const columns: Record = {} - - // Iterate through table columns - for (const [columnName, column] of Object.entries(table)) { - // Skip if it's not a column (could be methods or other properties) - if (typeof column !== 'object' || column === null) { - continue - } - - // Check if this column has encrypted configuration - const config = getEncryptedColumnConfig(columnName, column) - - if (config) { - // Extract the actual column name from the column object (not the schema key) - // Drizzle columns have a 'name' property that contains the actual database column name - const actualColumnName = column.name || config.name - - // This is an encrypted column - build encryptedColumn using the actual column name - const csCol = encryptedColumn(actualColumnName) - - // Apply data type - if (config.dataType && config.dataType !== 'string') { - csCol.dataType(config.dataType) - } - - // Apply indexes based on configuration - if (config.orderAndRange) { - csCol.orderAndRange() - } - - if (config.equality) { - if (Array.isArray(config.equality)) { - // Custom token filters - csCol.equality(config.equality) - } else { - // Default equality (boolean true) - csCol.equality() - } - } - - if (config.freeTextSearch) { - if (typeof config.freeTextSearch === 'object') { - // Custom match options - csCol.freeTextSearch(config.freeTextSearch) - } else { - // Default freeTextSearch (boolean true) - csCol.freeTextSearch() - } - } - - if (config.searchableJson) { - if (config.dataType !== 'json') { - throw new Error( - `Column "${columnName}" has searchableJson enabled but dataType is "${config.dataType ?? 'string'}". searchableJson requires dataType: 'json'.`, - ) - } - csCol.searchableJson() - } - - columns[actualColumnName] = csCol - } + const columns: Record = {} + for (const [property, column] of Object.entries(table)) { + if (typeof column !== 'object' || column === null) continue + const columnName = + 'name' in column && typeof column.name === 'string' + ? column.name + : property + const builder = getEqlV3Column(columnName, column) + if (builder) columns[property] = builder } if (Object.keys(columns).length === 0) { throw new Error( - `No encrypted columns found in table "${tableName}". Use encryptedType() to define encrypted columns.`, + `No encrypted v3 columns found in table "${tableName}". Declare columns with the v3 drizzle \`types\` namespace.`, ) } - return encryptedTable(tableName, columns) as EncryptedTable< - DrizzleEncryptedSchema - > & - DrizzleEncryptedSchema + return encryptedTable(tableName, columns) } diff --git a/packages/stack-drizzle/src/v3/sql-dialect.ts b/packages/stack-drizzle/src/sql-dialect.ts similarity index 100% rename from packages/stack-drizzle/src/v3/sql-dialect.ts rename to packages/stack-drizzle/src/sql-dialect.ts diff --git a/packages/stack-drizzle/src/v3/types.ts b/packages/stack-drizzle/src/types.ts similarity index 100% rename from packages/stack-drizzle/src/v3/types.ts rename to packages/stack-drizzle/src/types.ts diff --git a/packages/stack-drizzle/src/v3/index.ts b/packages/stack-drizzle/src/v3/index.ts deleted file mode 100644 index 7c0426d4c..000000000 --- a/packages/stack-drizzle/src/v3/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { EqlV3CodecError, v3FromDriver, v3ToDriver } from './codec.js' -export { getEqlV3Column, isEqlV3Column, makeEqlV3Column } from './column.js' -export { - createEncryptionOperatorsV3, - EncryptionOperatorError, -} from './operators.js' -export { extractEncryptionSchemaV3 } from './schema-extraction.js' -export { types } from './types.js' diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts deleted file mode 100644 index f2e113855..000000000 --- a/packages/stack-drizzle/src/v3/operators.ts +++ /dev/null @@ -1,959 +0,0 @@ -import type { Result } from '@byteslice/result' -import type { AuditConfig } from '@cipherstash/stack/adapter-kit' -import { - jsonPathOf, - matchNeedleError, - parseSelectorSegments, - reconstructSelectorDocument, - stripDomainSchema, - unsupportedLeafReason, -} from '@cipherstash/stack/adapter-kit' -import type { - AnyEncryptedV3Column, - AnyV3Table, -} from '@cipherstash/stack/eql/v3' -import type { EncryptionError } from '@cipherstash/stack/errors' -import type { LockContext } from '@cipherstash/stack/identity' -import type { ColumnSchema } from '@cipherstash/stack/schema' -import type { - EncryptedQueryResult, - QueryTypeName, -} from '@cipherstash/stack/types' -import { - and, - asc, - Column, - desc, - exists, - is, - isNotNull, - isNull, - not, - notExists, - or, - type SQL, - type SQLWrapper, - sql, -} from 'drizzle-orm' -import type { PgTable } from 'drizzle-orm/pg-core' -import { getEqlV3Column } from './column.js' -import { - extractEncryptionSchemaV3, - getDrizzleTableName, -} from './schema-extraction.js' -import { type ComparisonOp, type EqualityOp, v3Dialect } from './sql-dialect.js' - -/** - * 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). - * - * `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 = { - encryptQuery( - value: never, - opts: never, - ): ChainableOperation - encryptQuery(terms: never): ChainableOperation -} - -// Path helpers now live in @cipherstash/stack/adapter-kit (shared with the -// Supabase adapter, #650); re-exported so existing imports keep working. -export { parseSelectorSegments, reconstructSelectorDocument } - -/** - * A dedicated error for v3 operator gating and operand-encryption failures, - * carrying the offending column/table/operator for diagnostics. - * - * INTENTIONAL FORK: this mirrors the v2 adapter's `EncryptionOperatorError` - * rather than sharing it. Unifying the two would couple `./drizzle` and - * `./eql/v3/drizzle` — two independently-versioned public entry points — so the - * duplication is deliberate, not an oversight. - */ -export class EncryptionOperatorError extends Error { - constructor( - message: string, - public readonly context?: { - columnName?: string - tableName?: string - operator?: string - }, - ) { - super(message) - this.name = 'EncryptionOperatorError' - } -} - -interface ColumnContext { - builder: AnyEncryptedV3Column - table: AnyV3Table - 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_json`, 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 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_search` maps to `query_json`, 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. 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}` -} - -export type EncryptionOperatorCallOpts = { - lockContext?: LockContext - audit?: AuditConfig -} - -/** - * An SDK encryption operation after its lock context has been applied: still - * auditable and awaitable, but not re-lockable. `withLockContext` returns this, - * not the full {@link ChainableOperation}, mirroring the real - * `EncryptOperationWithLockContext`, which drops `withLockContext` (you cannot - * lock-context twice). Modelling that is what lets the real client type satisfy - * the structural surface with no cast. - */ -type AuditableOperation = { - audit(config: AuditConfig): AuditableOperation - then: PromiseLike>['then'] -} - -/** - * 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 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 `EncryptQueryOperation` class, because the client - * is passed in and the factory must accept any implementation with this surface. - */ -type ChainableOperation = { - withLockContext(lockContext: LockContext): AuditableOperation - audit(config: AuditConfig): AuditableOperation - then: PromiseLike>['then'] -} - -/** - * Build v3-aware query operators (`eq`, `gte`, `matches`, `contains`, `asc`, …) bound to an - * encryption `client`. Each comparison/containment operator AUTO-ENCRYPTS its - * plaintext operand into an EQL v3 query term before handing it to Drizzle, so - * callers pass plaintext and the emitted SQL compares encrypted values. Every - * operator also gates on the target column's capabilities and throws - * {@link EncryptionOperatorError} when the column can't answer the operator - * (e.g. ordering a non-`ore` column). - * - * @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. - * - * @example - * ```typescript - * const ops = createEncryptionOperatorsV3(await EncryptionV3({ schemas: [users] })) - * await db.select().from(users).where(await ops.eq(users.email, 'a@b.com')) - * ``` - */ -export function createEncryptionOperatorsV3( - client: OperandEncryptionClient, - defaults: EncryptionOperatorCallOpts = {}, -) { - const tableCache = new WeakMap() - // Per-column context memo. `resolveContext` is value-independent, so caching - // by column identity makes `inArray`/`notInArray` build the context (and its - // deep-cloned match block) once for the whole list instead of once per value. - const contextCache = new WeakMap() - - function drizzleTableOf(column: SQLWrapper): PgTable | undefined { - return is(column, Column) - ? (column.table as PgTable | undefined) - : undefined - } - - function resolveContext(column: SQLWrapper, operator: string): ColumnContext { - const cached = contextCache.get(column) - if (cached) return cached - - const columnName = is(column, Column) ? column.name : 'unknown' - const builder = getEqlV3Column(columnName, column) - if (!builder) { - throw new EncryptionOperatorError( - `Operator "${operator}" requires an encrypted v3 column, but "${columnName}" is not one.`, - { columnName, operator }, - ) - } - - const drizzleTable = drizzleTableOf(column) - const tableName = getDrizzleTableName(drizzleTable) ?? 'unknown' - - let table = drizzleTable ? tableCache.get(drizzleTable) : undefined - if (!table && drizzleTable) { - table = extractEncryptionSchemaV3(drizzleTable) - tableCache.set(drizzleTable, table) - } - if (!table) { - throw new EncryptionOperatorError( - `Unable to resolve the encrypted table for column "${columnName}".`, - { columnName, operator }, - ) - } - - const context: ColumnContext = { - builder, - table, - indexes: builder.build().indexes, - columnName, - tableName, - queryCast: queryCastForDomain(builder.getEqlType()), - } - contextCache.set(column, context) - return context - } - - /** - * Gate an operator on the column's indexes. `indexes` is a disjunction — any - * one of them grants the capability — so equality (`unique` OR `ore`) and the - * single-index gates share one rule and one diagnostic shape. - */ - function requireIndex( - ctx: ColumnContext, - indexes: readonly ('unique' | 'ore' | 'ope' | 'match' | 'ste_vec')[], - operator: string, - capability: string, - ): void { - if (!indexes.some((index) => ctx.indexes[index])) { - throw new EncryptionOperatorError( - `Operator "${operator}" requires ${capability} on column "${ctx.columnName}" (domain ${ctx.builder.getEqlType()} does not support it).`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - } - - // Ordering flavour is pinned by the column's domain (eql-3.0.0): `_ord` - // domains carry `ope` (`op` CLLW-OPE term), `_ord_ore` domains carry `ore` - // (`ob` block-ORE term). Either satisfies the order/range operators, and an - // 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 - // Two DISTINCT operators, split by semantics (#617): - // - `matches` is bloom free-text (`match`, a `text_search`/`text_match` - // column): a one-sided, order- and multiplicity-insensitive token match that - // may false-positive. It emits `eql_v3.matches(col, operand)` (the SQL - // function keeps its bundle name) but is NOT containment. - // - `contains` is encrypted-JSONB containment (an `eql_v3_json_search` column, - // `ste_vec` index): exact jsonb `@>`, no false positives — genuine - // containment, so it keeps the `contains` name. - const MATCH_INDEXES = ['match'] as const - const JSON_CONTAINMENT_INDEXES = ['ste_vec'] as const - - function applyOperationOptions( - op: ChainableOperation, - opts?: EncryptionOperatorCallOpts, - ): AuditableOperation { - const lockContext = opts?.lockContext ?? defaults.lockContext - const audit = opts?.audit ?? defaults.audit - const withLock = lockContext ? op.withLockContext(lockContext) : op - if (audit) withLock.audit(audit) - return withLock - } - - function requireNonNullOperand( - ctx: ColumnContext, - value: unknown, - operator: string, - ): void { - if (value == null) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot encrypt a null operand for column "${ctx.columnName}". Use isNull() or isNotNull() for NULL checks.`, - { - columnName: ctx.columnName, - tableName: ctx.tableName, - operator, - }, - ) - } - } - - /** - * Reject a free-text needle the column's match index cannot answer. A needle - * shorter than the tokenizer's `token_length` yields an empty bloom filter, - * and `stored_bf @> '{}'` holds for every row — so without this the query - * silently returns the whole table. - */ - function requireAnswerableNeedle( - ctx: ColumnContext, - value: unknown, - operator: string, - ): void { - const match = ctx.indexes.match - if (!match) return - const reason = matchNeedleError(value, match) - if (reason) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot search column "${ctx.columnName}": ${reason}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - } - - function operandFailure( - ctx: ColumnContext, - operator: string, - reason: string, - ): EncryptionOperatorError { - return new EncryptionOperatorError( - `Failed to encrypt query operand for "${ctx.columnName}": ${reason}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - /** - * 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.encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType, - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - return castOperand(ctx, operator, result.data) - } - - /** - * 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 terms = values.map((value) => ({ - value, - column: ctx.builder, - table: ctx.table, - queryType, - })) - const result = await applyOperationOptions( - client.encryptQuery(terms as never), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - - const encrypted = result.data as EncryptedQueryResult[] - if (encrypted.length !== values.length) { - throw operandFailure( - ctx, - operator, - `batch query encryption returned ${encrypted.length} terms for ${values.length} values.`, - ) - } - return encrypted.map((term) => castOperand(ctx, operator, term)) - } - - const colSql = (column: SQLWrapper): SQL => sql`${column}` - - async function equality( - op: EqualityOp, - left: SQLWrapper, - right: unknown, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, op) - requireIndex(ctx, EQUALITY_INDEXES, op, 'equality') - const enc = await encryptOperand(ctx, right, op, 'equality', opts) - return v3Dialect.equality(op, colSql(left), enc) - } - - async function comparison( - op: ComparisonOp, - left: SQLWrapper, - right: unknown, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, op) - requireIndex(ctx, ORDERING_INDEXES, op, 'order/range') - const enc = await encryptOperand(ctx, right, op, 'orderAndRange', opts) - return v3Dialect.comparison(op, colSql(left), enc) - } - - async function range( - left: SQLWrapper, - min: unknown, - max: unknown, - negate: boolean, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') - // 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, 'orderAndRange', opts), - encryptOperand(ctx, max, operator, 'orderAndRange', opts), - ]) - // `v3Dialect.range` is already parenthesised, so `NOT` binds to the whole - // conjunction without a wrapper here. - const condition = v3Dialect.range(colSql(left), encMin, encMax) - return negate ? sql`NOT ${condition}` : condition - } - - /** - * Fuzzy free-text token match on a `text_search`/`text_match` column. NOT - * containment: it tests whether the needle's downcased 3-gram set is a subset - * of the haystack's, via a bloom filter — order- and multiplicity-insensitive - * and one-sided (a `true` may be a false positive, a `false` never is). Emits - * `eql_v3.matches(col, operand)` (the SQL function's bundle name). - */ - async function matches( - left: SQLWrapper, - right: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - requireIndex(ctx, MATCH_INDEXES, operator, 'free-text search') - // The answerable-needle rule applies (a sub-`token_length` needle blooms to - // nothing and would match every row); the `query_` cast reaches the - // match overload. - requireAnswerableNeedle(ctx, right, operator) - const enc = await encryptOperand( - ctx, - right, - operator, - 'freeTextSearch', - opts, - ) - return v3Dialect.contains(colSql(left), enc) - } - - /** - * Exact encrypted-JSONB containment on an `eql_v3_json_search` (`ste_vec`) column: - * genuine jsonb `@>`, no false positives — hence it keeps the `contains` name. - * `eql_v3_json_search` has no `eql_v3.matches` overload; containment is the `@>` - * operator, whose `(eql_v3_json_search, eql_v3.query_json)` form takes a NARROWED - * query term (searchableJson → no ciphertext), cast to `eql_v3.query_json`. - */ - async function containsJsonOp( - left: SQLWrapper, - right: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - requireIndex(ctx, JSON_CONTAINMENT_INDEXES, operator, 'JSON containment') - const needle = await encryptJsonContainmentTerm(ctx, right, operator, opts) - return v3Dialect.containsJson(colSql(left), needle) - } - - /** - * Build a `query_json` containment needle for a `json` column — the JSON query - * term carries no ciphertext and satisfies the `eql_v3.query_json` CHECK the - * `@>` overload needs. Cast here (not by `queryCastForDomain`): a json column's - * domain is `eql_v3_json_search` but its query operand type is the irregular - * `eql_v3.query_json`. - */ - 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 - // (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 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)}::eql_v3.query_json` - } - - /** - * JSONPath selector-with-constraint on an `eql_v3_json_search` (`ste_vec`) - * column. Equality uses a value-selector containment needle, allowing the - * functional GIN index to answer the query. Ordering extracts the path entry - * with a selector hash and compares it to a ciphertext-free scalar ordering - * term. No storage ciphertext is placed in the WHERE clause. - */ - async function selectorCompare( - col: SQLWrapper, - path: string, - op: EqualityOp | ComparisonOp, - value: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(col, operator) - requireIndex( - ctx, - JSON_CONTAINMENT_INDEXES, - operator, - 'JSON selector (searchableJson)', - ) - requireNonNullOperand(ctx, value, operator) - - // A selector compares a scalar leaf — reject non-scalars / non-orderable - // types up front with a clear error, not a deferred DB failure. - const ordering = op !== 'eq' && op !== 'ne' - const leafReason = unsupportedLeafReason(value, ordering) - if (leafReason) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot compare column "${ctx.columnName}": ${leafReason}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - // Surface path-validation failures as EncryptionOperatorError with context. - let segments: string[] - try { - segments = parseSelectorSegments(path) - } catch (err) { - throw new EncryptionOperatorError( - `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - const canonicalPath = jsonPathOf(segments) - - if (op === 'eq' || op === 'ne') { - const result = await applyOperationOptions( - client.encryptQuery( - { path: canonicalPath, value } as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecValueSelector', - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - const contains = v3Dialect.containsJson( - colSql(col), - sql`${JSON.stringify(result.data)}::eql_v3.query_json`, - ) - return op === 'eq' - ? contains - : sql`(NOT ${contains} OR ${colSql(col)} IS NULL)` - } - - // Selector hashing and scalar-term encryption are independent, so order - // them concurrently. `steVecTerm` accepts only the JSON-orderable scalar - // families (string and number), enforced above. - const [selResult, termResult] = await Promise.all([ - applyOperationOptions( - client.encryptQuery( - canonicalPath as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecSelector', - } as never, - ), - opts, - ), - applyOperationOptions( - client.encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecTerm', - } as never, - ), - opts, - ), - ]) - if (selResult.failure) { - throw operandFailure(ctx, operator, selResult.failure.message) - } - if (termResult.failure) { - throw operandFailure(ctx, operator, termResult.failure.message) - } - - // A v3 selector term is the bare HMAC hash string; guard the shape so a - // wrapped envelope can't silently bind as a JSON blob and match no rows. - const selValue = selResult.data - if (typeof selValue !== 'string') { - throw operandFailure( - ctx, - operator, - `expected a bare selector hash, got ${typeof selValue}.`, - ) - } - - const selSql = sql`${selValue}::text` - const leftEntry = v3Dialect.selectorEntry(colSql(col), selSql) - const termCast = - typeof value === 'string' - ? 'eql_v3.query_text_ord' - : 'eql_v3.query_double_ord' - const rightTerm = sql`${JSON.stringify(termResult.data)}::${sql.raw(termCast)}` - return v3Dialect.comparison(op, leftEntry, rightTerm) - } - - /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar - * operators. Async: each encrypts its operand. */ - function selectorOps(col: SQLWrapper, path: string) { - const at = - (op: EqualityOp | ComparisonOp) => - (value: unknown, opts?: EncryptionOperatorCallOpts) => - selectorCompare(col, path, op, value, `selector(${path}).${op}`, opts) - return { - /** `col->'path' = value` (encrypted equality at the selector). A row whose - * document lacks `path` is excluded (it is not equal to `value`). */ - eq: at('eq'), - /** `col->'path' <> value`, INCLUDING rows whose document lacks `path` - * ("not equal to value" covers "has no value"). */ - ne: at('ne'), - /** `col->'path' > value` (encrypted ordering at the selector). */ - gt: at('gt'), - /** `col->'path' >= value`. */ - gte: at('gte'), - /** `col->'path' < value`. */ - lt: at('lt'), - /** `col->'path' <= value`. */ - lte: at('lte'), - /** Order rows ascending by the encrypted scalar at `path`. Missing paths - * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ - asc: (opts?: EncryptionOperatorCallOpts) => - selectorOrder(col, path, 'asc', opts), - /** Order rows descending by the encrypted scalar at `path`. Missing paths - * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ - desc: (opts?: EncryptionOperatorCallOpts) => - selectorOrder(col, path, 'desc', opts), - } - } - - /** Build `ORDER BY eql_v3.ord_term(col -> selector::text)`. - * The selector is encrypted, but the extracted SteVec entry already carries - * its OPE ordering term, so no plaintext comparison operand is needed. */ - async function selectorOrder( - col: SQLWrapper, - path: string, - direction: 'asc' | 'desc', - opts?: EncryptionOperatorCallOpts, - ): Promise { - const operator = `selector(${path}).${direction}` - const ctx = resolveContext(col, operator) - requireIndex( - ctx, - JSON_CONTAINMENT_INDEXES, - operator, - 'JSON selector (searchableJson)', - ) - - let canonicalPath: string - try { - canonicalPath = jsonPathOf(parseSelectorSegments(path)) - } catch (err) { - throw new EncryptionOperatorError( - `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - const result = await applyOperationOptions( - client.encryptQuery( - canonicalPath as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecSelector', - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - if (typeof result.data !== 'string') { - throw operandFailure( - ctx, - operator, - `expected a bare selector hash, got ${typeof result.data}.`, - ) - } - - const entry = v3Dialect.selectorEntry( - colSql(col), - sql`${result.data}::text`, - ) - const term = v3Dialect.orderBy(entry, 'ope') - return direction === 'asc' ? asc(term) : desc(term) - } - - async function inArrayOp( - left: SQLWrapper, - values: unknown[], - negate: boolean, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - if (values.length === 0) { - throw new EncryptionOperatorError( - `Operator "${operator}" requires a non-empty list of values for column "${ctx.columnName}".`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - // Gate and resolve the context once for the whole list, then encrypt it in - // a single `encryptQuery` batch crossing. - requireIndex(ctx, EQUALITY_INDEXES, operator, 'equality') - const op: EqualityOp = negate ? 'ne' : 'eq' - const encrypted = await encryptOperands( - ctx, - values, - operator, - 'equality', - opts, - ) - const conditions = encrypted.map((enc) => - v3Dialect.equality(op, colSql(left), enc), - ) - // The empty-list guard above leaves `conditions` non-empty, so `and`/`or` - // never return undefined here. - return (negate ? and(...conditions) : or(...conditions)) as SQL - } - - function orderTerm(column: SQLWrapper, operator: string): SQL { - const ctx = resolveContext(column, operator) - requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') - return v3Dialect.orderBy(colSql(column), ctx.indexes.ore ? 'ore' : 'ope') - } - - async function combine( - joiner: typeof and, - empty: SQL, - conditions: (SQL | SQLWrapper | Promise | undefined)[], - ): Promise { - const present = conditions.filter( - (c): c is SQL | SQLWrapper | Promise => c !== undefined, - ) - const resolved = await Promise.all(present) - return joiner(...resolved) ?? empty - } - - return { - /** Equality: `column = value`. Encrypts `r` and emits `eql_v3.eq`. - * Requires a `unique` or `ore` index on the column. */ - eq: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - equality('eq', l, r, opts), - /** Inequality: `column <> value`. Encrypts `r` and emits `eql_v3.neq`. - * Requires a `unique` or `ore` index on the column. */ - ne: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - equality('ne', l, r, opts), - /** Greater-than: `column > value`. Encrypts `r` and emits `eql_v3.gt`. - * Requires an `ore` (order/range) index. */ - gt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('gt', l, r, opts), - /** Greater-than-or-equal: `column >= value`. Encrypts `r` and emits - * `eql_v3.gte`. Requires an `ore` (order/range) index. */ - gte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('gte', l, r, opts), - /** Less-than: `column < value`. Encrypts `r` and emits `eql_v3.lt`. - * Requires an `ore` (order/range) index. */ - lt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('lt', l, r, opts), - /** Less-than-or-equal: `column <= value`. Encrypts `r` and emits - * `eql_v3.lte`. Requires an `ore` (order/range) index. */ - lte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('lte', l, r, opts), - /** Inclusive range `min <= column <= max`. Encrypts both bounds - * concurrently. Requires an `ore` (order/range) index. */ - between: ( - l: SQLWrapper, - min: unknown, - max: unknown, - opts?: EncryptionOperatorCallOpts, - ) => range(l, min, max, false, 'between', opts), - /** Negated inclusive range `NOT (min <= column <= max)`. Encrypts both - * bounds concurrently. Requires an `ore` (order/range) index. */ - notBetween: ( - l: SQLWrapper, - min: unknown, - max: unknown, - opts?: EncryptionOperatorCallOpts, - ) => range(l, min, max, true, 'notBetween', opts), - /** Fuzzy free-text token match — the needle's 3-gram set is (bloom-)tested - * as a subset of the column's. NOT containment: order/multiplicity- - * insensitive and one-sided (a `true` may be a false positive). Encrypts - * `r`. Requires a `match` (free-text search) index. */ - matches: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - matches(l, r, 'matches', opts), - /** Exact encrypted-JSONB containment (`@>`): matches rows whose document - * contains the given sub-object. No false positives. Encrypts `r`. Requires - * a `ste_vec` index (a `types.Json` column). */ - contains: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - containsJsonOp(l, r, 'contains', opts), - /** JSONPath selector-with-constraint on a `types.Json` (`ste_vec`) column. - * Returns comparison and ordering methods bound to `col->'path'` — e.g. - * `await ops.selector(users.doc, '$.age').gt(21)` emits - * `col->'' > `, while `.asc()` emits - * `ORDER BY eql_v3.ord_term(col->'')`. Its unique power over `contains` - * is ordering at a path (`gt`/`gte`/`lt`/`lte` and `asc`/`desc`); `eq`/`ne` - * are also provided. Dot-notation object paths only in v1. */ - selector: (l: SQLWrapper, path: string) => selectorOps(l, path), - /** Membership: ORs one encrypted `eq` term per value. The whole list is - * 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 `encryptQuery` batch crossing. Rejects an empty list; - * requires a `unique` or `ore` index. */ - notInArray: ( - l: SQLWrapper, - values: unknown[], - opts?: EncryptionOperatorCallOpts, - ) => inArrayOp(l, values, true, 'notInArray', opts), - /** Ascending order by the encrypted order term (`eql_v3.ord_term` / - * `eql_v3.ord_term_ore`, by the column's ordering flavour). - * Synchronous (no operand to encrypt). Requires an ordering index. */ - asc: (c: SQLWrapper) => asc(orderTerm(c, 'asc')), - /** Descending order by the encrypted order term (`eql_v3.ord_term` / - * `eql_v3.ord_term_ore`, by the column's ordering flavour). - * Synchronous (no operand to encrypt). Requires an ordering index. */ - desc: (c: SQLWrapper) => desc(orderTerm(c, 'desc')), - /** Conjunction of the given conditions, awaiting any async operands and - * dropping `undefined`. Empty input resolves to `true`. */ - and: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => - combine(and, sql`true`, conds), - /** Disjunction of the given conditions, awaiting any async operands and - * dropping `undefined`. Empty input resolves to `false`. */ - or: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => - combine(or, sql`false`, conds), - /** Drizzle's `isNull`, re-exported unchanged — `column IS NULL` needs no - * encryption and works on any (nullable) encrypted column. */ - isNull, - /** Drizzle's `isNotNull`, re-exported unchanged — `column IS NOT NULL` - * needs no encryption. */ - isNotNull, - /** Drizzle's `not`, re-exported unchanged — negates an already-built - * (encrypted) predicate. Safe over any operator here, including `between`, - * whose fragment is self-parenthesising. */ - not, - /** Drizzle's `exists`, re-exported unchanged — for correlated subqueries. */ - exists, - /** Drizzle's `notExists`, re-exported unchanged — for correlated - * subqueries. */ - notExists, - } -} diff --git a/packages/stack-drizzle/src/v3/schema-extraction.ts b/packages/stack-drizzle/src/v3/schema-extraction.ts deleted file mode 100644 index 68f8796bb..000000000 --- a/packages/stack-drizzle/src/v3/schema-extraction.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - type AnyEncryptedV3Column, - type AnyV3Table, - encryptedTable, -} from '@cipherstash/stack/eql/v3' -import type { PgTable } from 'drizzle-orm/pg-core' -import { getEqlV3Column } from './column.js' - -/** Drizzle stashes the SQL table name on this well-known symbol key. */ -const DRIZZLE_NAME = Symbol.for('drizzle:Name') - -/** - * Read the SQL table name Drizzle stashes on a `pgTable`. Returns `undefined` - * for a non-object or a table not built with `pgTable()`. Shared by - * {@link extractEncryptionSchemaV3} and the operator factory so the - * symbol-key introspection lives in exactly one place. - */ -export function getDrizzleTableName(table: unknown): string | undefined { - if (!table || typeof table !== 'object') return undefined - const name = (table as Record)[DRIZZLE_NAME] - return typeof name === 'string' ? name : undefined -} - -export function extractEncryptionSchemaV3(table: PgTable): AnyV3Table { - const tableName = getDrizzleTableName(table) - if (!tableName) { - throw new Error( - 'Unable to read table name from Drizzle table. Use a table created with pgTable().', - ) - } - - const columns: Record = {} - for (const [property, column] of Object.entries(table)) { - if (typeof column !== 'object' || column === null) continue - const columnName = - 'name' in column && typeof column.name === 'string' - ? column.name - : property - const builder = getEqlV3Column(columnName, column) - if (builder) columns[property] = builder - } - - if (Object.keys(columns).length === 0) { - throw new Error( - `No encrypted v3 columns found in table "${tableName}". Declare columns with the v3 drizzle \`types\` namespace.`, - ) - } - - return encryptedTable(tableName, columns) -} diff --git a/packages/stack-drizzle/tsup.config.ts b/packages/stack-drizzle/tsup.config.ts index 19ce5967e..dcffd3486 100644 --- a/packages/stack-drizzle/tsup.config.ts +++ b/packages/stack-drizzle/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts', 'src/v3/index.ts'], + entry: ['src/index.ts'], outDir: 'dist', format: ['cjs', 'esm'], sourcemap: true, diff --git a/packages/stack/README.md b/packages/stack/README.md index f5450a725..8ca24e116 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -362,7 +362,7 @@ The one limitation is the ORE-backed `*OrdOre` domains: their ordering term need ### Drizzle Integration -The `@cipherstash/stack-drizzle/v3` subpath (of the separate `@cipherstash/stack-drizzle` package) provides Drizzle-native column factories, schema extraction, and auto-encrypting, capability-checked query operators. +The separate `@cipherstash/stack-drizzle` package provides Drizzle-native column factories, schema extraction, and auto-encrypting, capability-checked query operators. It is EQL v3 only, all on the package root — the EQL v2 surface was removed and the old `./v3` subpath collapsed into `.`. Declare a Drizzle table using the `types` factories — each factory emits its domain as the column's SQL type, so `drizzle-kit generate` produces `ADD COLUMN email public.eql_v3_text_search` etc.: @@ -371,9 +371,9 @@ import { pgTable, integer } from "drizzle-orm/pg-core" import { drizzle } from "drizzle-orm/postgres-js" import { types, - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from "@cipherstash/stack-drizzle/v3" + createEncryptionOperators, + extractEncryptionSchema, +} from "@cipherstash/stack-drizzle" import { EncryptionV3 } from "@cipherstash/stack/v3" // Capabilities come from the concrete type — no flags to configure. @@ -389,9 +389,9 @@ const users = pgTable("users", { Derive the v3 schema from the table, build the typed client, and create the operators: ```ts -const usersSchema = extractEncryptionSchemaV3(users) +const usersSchema = extractEncryptionSchema(users) const client = await EncryptionV3({ schemas: [usersSchema] }) -const ops = createEncryptionOperatorsV3(client) +const ops = createEncryptionOperators(client) const db = drizzle({ client: sqlClient }) ``` @@ -762,9 +762,8 @@ depend on `@cipherstash/stack` (they are no longer subpaths of it): | Package | Provides | |-------|-----| -| `@cipherstash/stack-drizzle/v3` | EQL v3 Drizzle integration: `types` column factories, `createEncryptionOperatorsV3`, `extractEncryptionSchemaV3`, `makeEqlV3Column`, `EncryptionOperatorError` | +| `@cipherstash/stack-drizzle` | EQL v3 Drizzle integration (package root, v3 only): `types` column factories, `createEncryptionOperators`, `extractEncryptionSchema`, `makeEqlV3Column`, `EncryptionOperatorError` | | `@cipherstash/stack-supabase` | Supabase integration: `encryptedSupabaseV3` (and the legacy v2 `encryptedSupabase`) | -| `@cipherstash/stack-drizzle` | Legacy EQL v2 Drizzle integration (root subpath): `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` | ## Legacy: EQL v2 @@ -781,9 +780,11 @@ emitted by protect-ffi 0.30 and must migrate to v3 `types.Json`: - **Query formatting**: v2 query terms can be rendered as strings with `returnType: 'composite-literal'` / `'escaped-composite-literal'` for string-based APIs. -- **Integrations**: the v2 Drizzle surface is the root of - `@cipherstash/stack-drizzle` (`encryptedType`, `extractEncryptionSchema`, - `createEncryptionOperators`); the v2 Supabase surface is `encryptedSupabase`. +- **Integrations**: the v2 Supabase surface is `encryptedSupabase`. There is no + v2 Drizzle surface any more — `@cipherstash/stack-drizzle` dropped + `encryptedType` and the v2 operators, so existing v2 Drizzle columns are + read-only: decrypt them through `@cipherstash/stack` and migrate to a v3 + domain. - **DynamoDB still requires v2**: `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` works with the v2 API only — v3 support is tracked in [#657](https://github.com/cipherstash/stack/issues/657). diff --git a/packages/test-kit/package.json b/packages/test-kit/package.json index 656ffc96c..8c4738389 100644 --- a/packages/test-kit/package.json +++ b/packages/test-kit/package.json @@ -8,6 +8,7 @@ "./integration-clerk": "./src/integration/clerk.ts", ".": "./src/index.ts", "./catalog": "./src/catalog.ts", + "./install": "./src/install.ts", "./json-suite": "./src/run-json-suite.ts", "./suite": "./src/run-family-suite.ts" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33cdbce42..31f1f10fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: specifier: ^8.22.0 version: 8.22.0 devDependencies: + '@cipherstash/test-kit': + specifier: workspace:* + version: link:../test-kit '@types/node': specifier: ^22.20.1 version: 22.20.1 diff --git a/scripts/__tests__/bench-index-expressions.test.mjs b/scripts/__tests__/bench-index-expressions.test.mjs new file mode 100644 index 000000000..0ea8c9d10 --- /dev/null +++ b/scripts/__tests__/bench-index-expressions.test.mjs @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +// The bench exists to prove the functional-index path engages. An index whose +// expression is merely "a term extractor for the right column" satisfies +// `CREATE INDEX` and satisfies the db-only smoke test's `pg_indexes` check, and +// is still never used — the planner only matches an index against the +// expression the operator INLINES TO. `eql_v3.ste_vec(enc_jsonb)` was exactly +// that: a valid, buildable, permanently-dead index, because `@>` on +// `eql_v3_json_search` inlines to `eql_v3.to_ste_vec_query(a)::jsonb`. +// +// Every EQL v3 operator wrapper is `LANGUAGE sql IMMUTABLE STRICT` with a +// single-SELECT body, which is what makes it inlinable — and Postgres runs the +// same simplification over stored index expressions, so the two meet only if +// the index names the function in the operator's BODY. +// +// This test reads the vendored EQL bundle (the same SQL `stash eql install` +// applies) and pins each bench index to the function its operator actually +// lowers to. It needs no database and no credentials, which is the point: the +// bench's own EXPLAIN assertions require CipherStash credentials and never run +// in CI. + +const require = createRequire(resolve(REPO_ROOT, 'packages/cli/package.json')) + +/** The EQL v3 bundle `stash eql install --eql-version 3` applies. */ +function bundleSql() { + const pkgJson = require.resolve('@cipherstash/eql/package.json') + return readFileSync( + resolve(dirname(pkgJson), 'dist/sql/cipherstash-encrypt.sql'), + 'utf8', + ) +} + +/** + * The body of one `CREATE FUNCTION` in the bundle, from its signature line to + * the `$$` that closes it. + */ +function functionBody(sql, signature) { + const start = sql.indexOf(signature) + expect(start, `bundle has no ${signature}`).toBeGreaterThan(-1) + const bodyStart = sql.indexOf('$$', start) + const bodyEnd = sql.indexOf('$$', bodyStart + 2) + return sql.slice(bodyStart + 2, bodyEnd) +} + +/** `eql_v3.(` calls appearing in a SQL fragment. */ +function eqlCalls(fragment) { + return [...fragment.matchAll(/eql_v3\.([a-z_0-9]+)\s*\(/g)].map((m) => m[1]) +} + +const SCHEMA_SQL = readFileSync( + resolve(REPO_ROOT, 'packages/bench/sql/schema.sql'), + 'utf8', +) + +/** `CREATE INDEX ... ()` pairs from the bench fixture. */ +function benchIndexes() { + const found = new Map() + for (const m of SCHEMA_SQL.matchAll( + /CREATE INDEX\s+(\w+)\s+ON bench USING \w+\s*\(([\s\S]*?)\);/g, + )) { + found.set(m[1], m[2].trim()) + } + return found +} + +/** + * Each bench index, and the operator wrapper whose inlined body it has to + * match. Signatures are the `query_` overloads — the narrowed query + * term is what the Drizzle adapter casts its operand to. + */ +const INDEX_CONTRACTS = [ + { + index: 'bench_text_hmac_idx', + operator: + 'CREATE FUNCTION eql_v3.eq(a public.eql_v3_text_search, b eql_v3.query_text_search)', + inlinesTo: 'eq_term', + }, + { + index: 'bench_text_bloom_idx', + operator: + 'CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_search, b eql_v3.query_text_search)', + inlinesTo: 'match_term', + }, + { + index: 'bench_jsonb_stevec_idx', + operator: + 'CREATE FUNCTION eql_v3."@>"(a public.eql_v3_json_search, b eql_v3.query_json)', + inlinesTo: 'to_ste_vec_query', + }, +] + +describe('bench index expressions match the EQL v3 operator lowering', () => { + const sql = bundleSql() + const indexes = benchIndexes() + + it('parses all three bench indexes (guards a silently-empty regex)', () => { + expect([...indexes.keys()].sort()).toEqual( + INDEX_CONTRACTS.map((c) => c.index).sort(), + ) + }) + + it.each( + INDEX_CONTRACTS, + )('$index is built on the function $operator inlines to', ({ + index, + operator, + inlinesTo, + }) => { + const body = functionBody(sql, operator) + + // The operator really does lower to this function — if the bundle + // changes shape, this fails here rather than silently passing the + // schema check below against a stale assumption. + expect( + eqlCalls(body), + `${operator} no longer calls eql_v3.${inlinesTo}`, + ).toContain(inlinesTo) + + expect( + eqlCalls(indexes.get(index)), + `${index} must index eql_v3.${inlinesTo}(...) — the expression the ` + + 'operator inlines to — or the planner can never match it', + ).toEqual([inlinesTo]) + }) + + it('the operator wrappers are inlinable (LANGUAGE sql IMMUTABLE STRICT)', () => { + for (const { operator } of INDEX_CONTRACTS) { + const start = sql.indexOf(operator) + const header = sql.slice(start, sql.indexOf('$$', start)) + // A plpgsql or VOLATILE wrapper would never be inlined, so no functional + // index on its body could ever engage. + expect(header.toLowerCase(), operator).toMatch(/language sql/) + expect(header.toLowerCase(), operator).toMatch(/immutable/) + } + }) +}) diff --git a/scripts/__tests__/no-removed-drizzle-surface.test.mjs b/scripts/__tests__/no-removed-drizzle-surface.test.mjs new file mode 100644 index 000000000..64334bce4 --- /dev/null +++ b/scripts/__tests__/no-removed-drizzle-surface.test.mjs @@ -0,0 +1,69 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +/** + * The `@cipherstash/stack-drizzle` names removed when EQL v2 went away and + * `./v3` collapsed into the package root. Nothing type-checks a README, a + * SKILL.md, or a template literal, so these three drifted silently before — + * and `skills/` ships inside the `stash` tarball, which means the drift lands + * in a customer's repo rather than in CI. + */ +const REMOVED = [ + '@cipherstash/stack-drizzle/v3', + 'extractEncryptionSchemaV3', + 'createEncryptionOperatorsV3', +] + +/** + * Files whose contents are SHIPPED — published to npm, copied into a user's + * repo, or written there by `stash init`. Deliberately not the whole tree: + * CHANGELOGs and `docs/superpowers/specs/**` are historical records that + * SHOULD still name the old surface, and rewriting history to appease a lint + * is worse than the drift it prevents. + */ +// `:(glob)` magic so `*` stops at a path separator — without it git's default +// wildmatch crosses `/` and `lib/*.ts` sweeps in `lib/__tests__/*.test.ts`. +const SHIPPED_GLOBS = [ + ':(glob)skills/*/SKILL.md', + ':(glob)packages/*/README.md', + 'README.md', + 'AGENTS.md', + // `stash init` writes these strings into the user's project as real source. + 'packages/cli/src/commands/init/utils.ts', + ':(glob)packages/cli/src/commands/init/lib/*.ts', + ':(glob)packages/cli/src/commands/init/doctrine/*.md', +] + +/** Tracked files matching the shipped globs, via git so it honours .gitignore. */ +function shippedFiles() { + const out = execFileSync('git', ['ls-files', '-z', ...SHIPPED_GLOBS], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + return out.split('\0').filter(Boolean) +} + +describe('removed stack-drizzle surface is absent from shipped files', () => { + const files = shippedFiles() + + it('finds the shipped file set (guards against a silently-empty glob)', () => { + expect(files.length).toBeGreaterThan(5) + expect(files).toContain('skills/stash-drizzle/SKILL.md') + expect(files).toContain('packages/stack/README.md') + }) + + it.each(files)('%s', (file) => { + const body = readFileSync(resolve(REPO_ROOT, file), 'utf8') + const found = REMOVED.filter((name) => body.includes(name)) + expect( + found, + `${file} names removed @cipherstash/stack-drizzle exports: ${found.join(', ')}. ` + + 'The `./v3` subpath collapsed into the package root and the *V3 suffixes were dropped.', + ).toEqual([]) + }) +}) diff --git a/scripts/__tests__/vitest-shared-alias.test.mjs b/scripts/__tests__/vitest-shared-alias.test.mjs new file mode 100644 index 000000000..771d68f8a --- /dev/null +++ b/scripts/__tests__/vitest-shared-alias.test.mjs @@ -0,0 +1,34 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { sharedAlias, stackSourceAlias } from '../../vitest.shared.ts' + +// A vite alias pointing at a file that no longer exists fails LATE and badly: +// nothing validates the map, so the entry survives a package refactor and the +// next test to use that specifier gets a "failed to resolve import" on a path +// the author never wrote, instead of the honest "subpath is not exported". +// `@cipherstash/stack-drizzle/v3` sat here for exactly that reason after the +// `./v3` collapse. + +/** Directory aliases are written with a trailing slash (`'@/'`). */ +const targets = (map) => + Object.entries(map).map(([specifier, target]) => [ + specifier, + target.endsWith('/') ? target.slice(0, -1) : target, + ]) + +describe('vitest.shared.ts alias targets exist on disk', () => { + it('exports both maps, non-empty (guards a silently-renamed export)', () => { + expect(Object.keys(sharedAlias).length).toBeGreaterThan(5) + expect(Object.keys(stackSourceAlias).length).toBeGreaterThan(0) + }) + + it.each(targets(sharedAlias))('sharedAlias %s', (_specifier, target) => { + expect(existsSync(target), `missing alias target: ${target}`).toBe(true) + }) + + it.each( + targets(stackSourceAlias), + )('stackSourceAlias %s', (_specifier, target) => { + expect(existsSync(target), `missing alias target: ${target}`).toBe(true) + }) +}) diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 565f5d6e9..19ae76087 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -1,11 +1,11 @@ --- name: stash-drizzle -description: Integrate CipherStash encryption with Drizzle ORM using @cipherstash/stack-drizzle/v3 (EQL v3). Covers the types.* encrypted column factories (concrete Postgres domains), auto-encrypting query operators (eq, ne, gt/gte/lt/lte, between, inArray, matches, contains, JSON selector, asc/desc), schema extraction, the EncryptionV3 typed client, database setup with stash eql install, and migrating existing plaintext columns to encrypted. Use when adding encryption to a Drizzle ORM project, defining encrypted Drizzle schemas, or querying encrypted columns with Drizzle. +description: Integrate CipherStash encryption with Drizzle ORM using @cipherstash/stack-drizzle (EQL v3). Covers the types.* encrypted column factories (concrete Postgres domains), auto-encrypting query operators (eq, ne, gt/gte/lt/lte, between, inArray, matches, contains, JSON selector, asc/desc), schema extraction, the EncryptionV3 typed client, database setup with stash eql install, and migrating existing plaintext columns to encrypted. Use when adding encryption to a Drizzle ORM project, defining encrypted Drizzle schemas, or querying encrypted columns with Drizzle. --- # CipherStash Stack - Drizzle ORM Integration -Guide for integrating CipherStash field-level encryption with Drizzle ORM using `@cipherstash/stack-drizzle/v3` (EQL v3). Provides Drizzle-native encrypted column factories and query operators that transparently encrypt search values — Drizzle never sees plaintext in a query. +Guide for integrating CipherStash field-level encryption with Drizzle ORM using `@cipherstash/stack-drizzle` (EQL v3). Provides Drizzle-native encrypted column factories and query operators that transparently encrypt search values — Drizzle never sees plaintext in a query. In EQL v3 every encrypted column is a **concrete Postgres domain** (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, ...) whose query capabilities are fixed by the type you pick — there is no capability config object. See the `stash-encryption` skill's "Schema Definition" section (the `types` catalog) for the full catalog and capability suffixes (`Eq`, `Ord`/`OrdOre`, `Match`, `Search`, `Json`). @@ -33,8 +33,8 @@ npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm The Drizzle integration ships as its own first-party package, `@cipherstash/stack-drizzle`, which depends on `@cipherstash/stack`. Install both. -The v3 surface documented here lives on the `@cipherstash/stack-drizzle/v3` subpath. -It is distinct from the older, separate `@cipherstash/drizzle` package (which is +The v3 surface documented here is exported from the `@cipherstash/stack-drizzle` +package root. It is distinct from the older, separate `@cipherstash/drizzle` package (which is `@cipherstash/protect`-based, with different symbol names) — that package is **deprecated and no longer published**; do not install it. This package replaces it. @@ -81,11 +81,11 @@ You don't usually hand-write this: the `types.*` factories below emit the domain ## Schema Definition -Use the `types` namespace from `@cipherstash/stack-drizzle/v3` to define encrypted columns. Each factory maps 1:1 to a Postgres domain, and the column's query capabilities are fixed by the type: +Use the `types` namespace from `@cipherstash/stack-drizzle` to define encrypted columns. Each factory maps 1:1 to a Postgres domain, and the column's query capabilities are fixed by the type: ```typescript import { pgTable, integer, timestamp, varchar } from "drizzle-orm/pg-core" -import { types } from "@cipherstash/stack-drizzle/v3" +import { types } from "@cipherstash/stack-drizzle" const usersTable = pgTable("users", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), @@ -115,18 +115,18 @@ Capability suffixes at a glance (full catalog: `stash-encryption` skill, "Schema Value families: `Integer`/`Smallint`/`Numeric`/`Real`/`Double` (`number`), `Bigint` (`bigint`), `Date`/`Timestamp` (`Date`), `Text` (`string`), `Boolean` (`boolean`, storage only), `Json` (a JSON document — object or array, not a top-level scalar). -`makeEqlV3Column(builder)` wraps a column builder from `@cipherstash/stack/eql/v3` (e.g. `makeEqlV3Column(v3types.TextEq("email"))`) — `types.TextEq("email")` from the Drizzle subpath is shorthand for the same thing. +`makeEqlV3Column(builder)` wraps a column builder from `@cipherstash/stack/eql/v3` (e.g. `makeEqlV3Column(v3types.TextEq("email"))`) — `types.TextEq("email")` from the Drizzle package is shorthand for the same thing. ## Initialization ### 1. Extract Schema from Drizzle Table ```typescript -import { extractEncryptionSchemaV3, createEncryptionOperatorsV3 } from "@cipherstash/stack-drizzle/v3" +import { extractEncryptionSchema, createEncryptionOperators } from "@cipherstash/stack-drizzle" import { EncryptionV3 } from "@cipherstash/stack/v3" // Convert the Drizzle table definition to a CipherStash v3 schema -const usersSchema = extractEncryptionSchemaV3(usersTable) +const usersSchema = extractEncryptionSchema(usersTable) ``` ### 2. Initialize the Encryption Client @@ -142,10 +142,10 @@ const encryptionClient = await EncryptionV3({ ### 3. Create Query Operators ```typescript -const ops = createEncryptionOperatorsV3(encryptionClient) +const ops = createEncryptionOperators(encryptionClient) ``` -`createEncryptionOperatorsV3(client, { lockContext, audit })` optionally sets defaults applied to every operand encryption; the async encrypting operators (`eq`, `ne`, `inArray`, `notInArray`, `gt`/`gte`/`lt`/`lte`, `between`/`notBetween`, `matches`, `contains`, and all methods returned by `selector(...)`) also take an optional trailing `{ lockContext, audit }` argument per call. Top-level `asc`/`desc` and the passthrough operators (`isNull`, `isNotNull`, `not`, `and`, `or`, `exists`, `notExists`) encrypt nothing and take no such argument. +`createEncryptionOperators(client, { lockContext, audit })` optionally sets defaults applied to every operand encryption; the async encrypting operators (`eq`, `ne`, `inArray`, `notInArray`, `gt`/`gte`/`lt`/`lte`, `between`/`notBetween`, `matches`, `contains`, and all methods returned by `selector(...)`) also take an optional trailing `{ lockContext, audit }` argument per call. Top-level `asc`/`desc` and the passthrough operators (`isNull`, `isNotNull`, `not`, `and`, `or`, `exists`, `notExists`) encrypt nothing and take no such argument. ### 4. Create Drizzle Instance @@ -404,7 +404,7 @@ Add an `email_encrypted` column **alongside** `email`. Crucially, the encrypted ```typescript // src/db/schema.ts -import { types } from '@cipherstash/stack-drizzle/v3' +import { types } from '@cipherstash/stack-drizzle' export const users = pgTable('users', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -418,10 +418,10 @@ Update the encryption client to harvest the encrypted columns from the table: ```typescript // src/encryption/index.ts import { EncryptionV3 } from '@cipherstash/stack/v3' -import { extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' +import { extractEncryptionSchema } from '@cipherstash/stack-drizzle' import { users } from '../db/schema' -const usersEncryptionSchema = extractEncryptionSchemaV3(users) +const usersEncryptionSchema = extractEncryptionSchema(users) export const encryptionClient = await EncryptionV3({ schemas: [usersEncryptionSchema] }) ``` @@ -497,49 +497,14 @@ If something goes wrong (e.g. you discover the dual-write code wasn't actually l #### Switch reads to the encrypted column -**EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps -its own name — you switch the application to it by name, verify reads, then drop -the plaintext column. Point your queries at `email_encrypted`, deploy, and -confirm reads decrypt correctly; then skip ahead to the drop step. Running -`stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). +**EQL v3: there is no cut-over.** The encrypted column keeps its own name — you +switch the application to it by name, verify reads, then drop the plaintext +column. Running `stash encrypt cutover` on a **backfilled** v3 column reports +"not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). -The rest of this subsection is the **EQL v2** path (a `eql_v2_encrypted` twin), -kept for existing v2 deployments. - -First, update the Drizzle schema to the post-cutover shape — switch `email` to the encrypted type and remove the `email_encrypted` column. - -> **Using CipherStash Proxy?** -> -> If using Proxy, re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): -> -> ```bash -> stash db push -> # → writes the new config as `pending`. Active config (still pointing at -> # `email_encrypted`) keeps serving while we complete the cutover. -> ``` - -Now run the cutover: - -```bash -stash encrypt cutover --table users --column email -``` - -Inside one transaction it: (1) renames `email` → `email_plaintext` and `email_encrypted` → `email`, (2) promotes the pending EQL config to `active` (and the prior active to `inactive`), (3) records a `cut_over` event in `cs_migrations`. - -The Drizzle schema you just edited now matches the physical DB shape — `email` is the encrypted column. Keep the temporary `email_plaintext: text('email_plaintext')` declaration in the schema file until the drop step: - -```typescript -// src/db/schema.ts (post-cutover) -export const users = pgTable('users', { - id: integer('id').primaryKey().generatedAlwaysAsIdentity(), - email: types.TextSearch('email'), - email_plaintext: text('email_plaintext'), // temporary; dropped next -}) -``` - -App code that does `SELECT email FROM users` now returns ciphertext that must be decrypted via the encryption client. **This is the moment that breaks read paths if they aren't decrypting.** - -Update read paths to decrypt: +Point your read paths at `email_encrypted` and decrypt the selected envelopes +with the encryption client. **This is the moment that breaks read paths if they +aren't decrypting.** ```typescript // Before @@ -550,10 +515,10 @@ const email = rows[0].email const rows = await db.select().from(users).where(eq(users.id, id)) const decrypted = await encryptionClient.decryptModel(rows[0], usersEncryptionSchema) if (decrypted.failure) throw new Error(decrypted.failure.message) -const email = decrypted.data.email +const email = decrypted.data.email_encrypted ``` -For queries that filter on `email`, switch to the encrypted operators from `createEncryptionOperatorsV3` — `eq`, `matches`, `gte`, etc. (See `## Query Encrypted Data` above.) +For queries that filter on the column, switch to the encrypted operators from `createEncryptionOperators` — `eq`, `matches`, `gte`, etc. (See `## Query Encrypted Data` above.) Deploy, confirm reads decrypt correctly, then drop the plaintext column. #### Drop: remove the plaintext column @@ -563,10 +528,10 @@ Once read paths are updated and you're confident reads are decrypting correctly, stash encrypt drop --table users --column email ``` -The CLI emits a Drizzle migration file with the drop. **Which column it drops depends on the EQL version**, which the CLI auto-detects: - -- **v3** — drops the original plaintext column, `ALTER TABLE users DROP COLUMN email;`. There was no rename, so no `email_plaintext` exists. Requires the column to be in the `backfilled` phase, plus a live coverage check. -- **v2** — drops the post-rename leftover, `ALTER TABLE users DROP COLUMN email_plaintext;`. Requires the `cut-over` phase. +The CLI emits a Drizzle migration file with the drop. For a v3 column it drops +the original plaintext column, `ALTER TABLE users DROP COLUMN email;` — there was +no rename, so no `email_plaintext` exists. Requires the column to be in the +`backfilled` phase, plus a live coverage check. Review and apply with `drizzle-kit migrate`, then update the schema to its final shape — the encrypted column is the only one left: @@ -631,11 +596,11 @@ All comparison/containment operators auto-encrypt their operands and are async; ### Other v3 Exports -`types`, `makeEqlV3Column`, `getEqlV3Column`, `isEqlV3Column`, `extractEncryptionSchemaV3`, `createEncryptionOperatorsV3`, `EncryptionOperatorError`, and the codec helpers `v3ToDriver` / `v3FromDriver` / `EqlV3CodecError` — all from `@cipherstash/stack-drizzle/v3`. +`types`, `makeEqlV3Column`, `getEqlV3Column`, `isEqlV3Column`, `extractEncryptionSchema`, `createEncryptionOperators`, `EncryptionOperatorError`, and the codec helpers `v3ToDriver` / `v3FromDriver` / `EqlV3CodecError` — all from `@cipherstash/stack-drizzle`. ## Error Handling -Operators throw `EncryptionOperatorError` (exported from `@cipherstash/stack-drizzle/v3`) whenever the query cannot be answered safely: +Operators throw `EncryptionOperatorError` (exported from `@cipherstash/stack-drizzle`) whenever the query cannot be answered safely: - the column is not an encrypted v3 column (there is no plaintext fallback); - the column's domain lacks the operator's capability (e.g. ordering a `TextEq` column, `eq` on a `Json` column); @@ -644,7 +609,7 @@ Operators throw `EncryptionOperatorError` (exported from `@cipherstash/stack-dri - operand encryption itself fails. ```typescript -import { EncryptionOperatorError } from "@cipherstash/stack-drizzle/v3" +import { EncryptionOperatorError } from "@cipherstash/stack-drizzle" class EncryptionOperatorError extends Error { context?: { @@ -659,6 +624,4 @@ There is no `EncryptionConfigError` on the v3 path — capability problems surfa Encryption client operations (`encryptModel`, `bulkDecryptModels`, ...) don't throw — they return `Result` objects with `data` or `failure`. Check `.failure` before using `.data`. -## Legacy: EQL v2 - -The original v2 integration — `encryptedType` config-flag columns, `extractEncryptionSchema`, and `createEncryptionOperators` (with `like`/`ilike`) from the `@cipherstash/stack-drizzle` package root, over the `eql_v2_encrypted` column type installed via `stash eql install --drizzle` (the deprecated standalone `@cipherstash/drizzle` package shipped its own `generate-eql-migration` bin for the same purpose) — still exists for existing deployments and is documented at https://cipherstash.com/docs. New projects must use the `/v3` surface documented above — `stash init --drizzle` generates an EQL **v3** migration via `stash eql migration --drizzle`. Reaching the v2 install path now requires opting in explicitly with `stash eql install --drizzle --eql-version 2`. +> **EQL v2 removed.** `@cipherstash/stack-drizzle` no longer ships an EQL v2 authoring surface: the pre-v3 `encryptedType` config-flag columns and the `like`/`ilike` operators are gone, and the `./v3` subpath has collapsed into the package root (`@cipherstash/stack-drizzle`). All exports documented here are EQL v3. Existing v2 ciphertext is still decryptable via `@cipherstash/stack`; only the Drizzle-side authoring/query-building of new v2 columns is removed. `stash init --drizzle` generates an EQL v3 migration via `stash eql migration --drizzle`. diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 39028c702..64cbff15d 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -188,7 +188,7 @@ The SDK never logs plaintext data. | `@cipherstash/stack/identity` | `LockContext` class and identity types | | `@cipherstash/stack/errors` | `EncryptionErrorTypes`, `StackError`, error subtypes, `getErrorMessage` | | `@cipherstash/stack/types` | All TypeScript types | -| `@cipherstash/stack-drizzle/v3` | Drizzle ORM integration for EQL v3 schemas (see the `stash-drizzle` skill) | +| `@cipherstash/stack-drizzle` | Drizzle ORM integration for EQL v3 schemas — the package root, EQL v3 only (see the `stash-drizzle` skill) | | `@cipherstash/stack-supabase` | `encryptedSupabaseV3` wrapper for Supabase (see the `stash-supabase` skill) | | `@cipherstash/stack/wasm-inline` | The **edge** entry — Deno, Bun, Cloudflare Workers, Supabase Edge Functions. Its own `Encryption` factory plus the v3 authoring surface, `EncryptionErrorTypes`, and the WASM build of protect-ffi inlined into the bundle. No native binding, so no bundler externalisation needed. | | `@cipherstash/stack/dynamodb` | `encryptedDynamoDB` — encrypt/write is **EQL v3 only** (`types.*`); decrypt still reads existing v2 items. See the `stash-dynamodb` skill | @@ -993,7 +993,7 @@ Useful when the backfill needs to run in a worker, on a schedule, or alongside a | Target | Package / entry point | Skill | |---|---|---| -| Drizzle ORM | `@cipherstash/stack-drizzle/v3` — v3 column factories (each `types.*` factory emits its domain as the column's SQL type for `drizzle-kit generate`), schema extraction, auto-encrypting operators (`ops.eq`, `ops.matches`, `ops.contains`, `ops.selector`, `ops.asc`, ...) | `stash-drizzle` | +| Drizzle ORM | `@cipherstash/stack-drizzle` — v3 column factories (each `types.*` factory emits its domain as the column's SQL type for `drizzle-kit generate`), schema extraction, auto-encrypting operators (`ops.eq`, `ops.matches`, `ops.contains`, `ops.selector`, `ops.asc`, ...) | `stash-drizzle` | | Supabase | `encryptedSupabaseV3` from `@cipherstash/stack-supabase` — schema-aware query builder (`eq`, `matches`, `contains`, `selectorEq`/`selectorNe`, ...) that works through PostgREST, including as `anon` | `stash-supabase` | | Prisma | `@cipherstash/prisma-next` — searchable field-level encryption for Postgres | — | | DynamoDB | `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` — encrypt is **EQL v3 only**; decrypt still reads existing v2 items | `stash-dynamodb` | @@ -1042,6 +1042,6 @@ const users = encryptedTable("users", { const client = await Encryption({ schemas: [users] }) ``` -The scalar v2 API — `Encryption` plus the `@cipherstash/stack/schema` builders, the v2 Drizzle/Supabase integrations (`@cipherstash/stack-drizzle`, `encryptedSupabase`), and `stash eql install --eql-version 2` — still exists for existing deployments. Legacy v2 `searchableJson()` is the exception: protect-ffi 0.30 cannot emit the removed selector envelope, so migrate those columns to v3 `types.Json`. Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs). Remember: v2 and v3 tables cannot be mixed in one client. (If you are migrating code from the old `@cipherstash/protect` package, its `protect`/`csTable`/`csColumn` names map onto this v2 surface.) +The scalar v2 API — `Encryption` plus the `@cipherstash/stack/schema` builders, the v2 Supabase integration (`encryptedSupabase`), and `stash eql install --eql-version 2` — still exists for existing deployments, with two carve-outs. **Drizzle has no v2 surface at all**: `@cipherstash/stack-drizzle` dropped `encryptedType` and the `like`/`ilike` operators, so a v2 Drizzle column is read-only — decrypt it through `@cipherstash/stack` rather than through the adapter. **Legacy v2 `searchableJson()`** cannot be emitted by protect-ffi 0.30 (the selector envelope was removed), so migrate those columns to v3 `types.Json`. Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs). Remember: v2 and v3 tables cannot be mixed in one client. (If you are migrating code from the old `@cipherstash/protect` package, its `protect`/`csTable`/`csColumn` names map onto this v2 surface.) > **DynamoDB.** The DynamoDB integration (`encryptedDynamoDB` from `@cipherstash/stack/dynamodb`) now **encrypts EQL v3 only** — author tables with `types.*` from `@cipherstash/stack/eql/v3`. Its decrypt methods still accept a v2 table so previously stored v2 items remain readable. See the `stash-dynamodb` skill. diff --git a/vitest.shared.ts b/vitest.shared.ts index cbf78fd5b..56fee9202 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -78,11 +78,8 @@ export const sharedAlias: Record = { 'packages/stack-supabase/src/index.ts', ), // The Drizzle adapter package (was `@cipherstash/stack/drizzle` + - // `@cipherstash/stack/eql/v3/drizzle`). `/v3` first — longest prefix wins. - '@cipherstash/stack-drizzle/v3': resolve( - repoRoot, - 'packages/stack-drizzle/src/v3/index.ts', - ), + // `@cipherstash/stack/eql/v3/drizzle`). Root only: the `./v3` subpath + // collapsed into it, so there is no longer a longer prefix to order first. '@cipherstash/stack-drizzle': resolve( repoRoot, 'packages/stack-drizzle/src/index.ts',