diff --git a/.changeset/eql-v3-text-search.md b/.changeset/eql-v3-text-search.md index 41cff5d3..1abbb191 100644 --- a/.changeset/eql-v3-text-search.md +++ b/.changeset/eql-v3-text-search.md @@ -2,13 +2,17 @@ "@cipherstash/stack": minor --- -Add the EQL v3 `text_search` authoring DSL on a new `@cipherstash/stack/schema/v3` -subpath (`encryptedTextSearchColumn`, v3 `encryptedTable` / `buildEncryptConfig`). -The v3 builders emit the existing `EncryptConfig` shape, so encryption, payloads, -and query paths are unchanged at runtime. +Add the EQL v3 `text_search` authoring DSL on a new `@cipherstash/stack/eql/v3` +subpath (`types.TextSearch`, v3 `encryptedTable` / `buildEncryptConfig`). The v3 +builders emit the existing `EncryptConfig` shape, so encryption, payloads, and +query paths are unchanged at runtime. Also widens the public client types (`EncryptionClientConfig.schemas`, `EncryptOptions`, `SearchTerm`/`EncryptQueryOptions`) to a structural contract so both v2 and v3 builders are accepted by `Encryption` / `encrypt` / `decrypt` / `encryptQuery`. This is a backward-compatible widening — existing v2 usage is -unaffected. +unaffected. The structural contracts themselves (`BuildableColumn`, +`BuildableQueryColumn`, `BuildableV3QueryableColumn`, `BuildableTable`, +`BuildableTableColumns`) and the `encryptModel` return-type mapper +(`EncryptedFromBuildableTable`) are exported from `@cipherstash/stack/types` so +consumers can name them. diff --git a/.changeset/eql-v3-typed-client.md b/.changeset/eql-v3-typed-client.md index 39cd0e38..b8821cf6 100644 --- a/.changeset/eql-v3-typed-client.md +++ b/.changeset/eql-v3-typed-client.md @@ -4,8 +4,8 @@ Add a strongly-typed EQL v3 client surface on a new `@cipherstash/stack/v3` subpath (`EncryptionV3`, `typedClient`, `TypedEncryptionClient`). It re-exports -the v3 schema builders, so a single import provides everything needed to author -and use a v3 schema. +the v3 `types` namespace and table API (from `@cipherstash/stack/eql/v3`), so a +single import provides everything needed to author and use a v3 schema. Every method derives its types from the concrete `table` / `column` builder arguments: diff --git a/.changeset/eql-v3-typed-schema.md b/.changeset/eql-v3-typed-schema.md index 464054fb..a805d285 100644 --- a/.changeset/eql-v3-typed-schema.md +++ b/.changeset/eql-v3-typed-schema.md @@ -2,6 +2,6 @@ '@cipherstash/stack': minor --- -Add EQL v3 schema builders for all generated SQL domains under `@cipherstash/stack/schema/v3`, including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`). +Add EQL v3 schema builders for all generated SQL domains under `@cipherstash/stack/eql/v3`, exposed as the `types` namespace (one member per EQL v3 domain, e.g. `types.TextEq` / `types.Int4Ord` / `types.Timestamptz`), including explicit query capability metadata (`getQueryCapabilities()` / `isQueryable()`) and v3 table support in model encryption helpers (`encryptModel` / `bulkEncryptModels`). Also widen the accepted plaintext input type for `encrypt` / `encryptQuery` to include `Date` and `bigint` (via the new `Plaintext` type), so v3 `date` / `timestamptz` / `int8` domains can be encrypted and queried with their natural JavaScript values. diff --git a/.github/workflows/fta-v3.yml b/.github/workflows/fta-v3.yml index 3ee52b01..541ffc33 100644 --- a/.github/workflows/fta-v3.yml +++ b/.github/workflows/fta-v3.yml @@ -11,14 +11,14 @@ on: branches: - 'main' paths: - - 'packages/stack/src/schema/v3/**' + - 'packages/stack/src/eql/v3/**' - 'packages/stack/package.json' - '.github/workflows/fta-v3.yml' pull_request: branches: - "**" paths: - - 'packages/stack/src/schema/v3/**' + - 'packages/stack/src/eql/v3/**' - 'packages/stack/package.json' - '.github/workflows/fta-v3.yml' diff --git a/docs/superpowers/plans/2026-06-30-eql-v3-text-search-schema-plan.md b/docs/superpowers/plans/2026-06-30-eql-v3-text-search-schema-plan.md index 6df325f4..dfe6a84f 100644 --- a/docs/superpowers/plans/2026-06-30-eql-v3-text-search-schema-plan.md +++ b/docs/superpowers/plans/2026-06-30-eql-v3-text-search-schema-plan.md @@ -1,5 +1,11 @@ # EQL v3 `text_search` Schema DSL Implementation Plan +> **Superseded (2026-07-03):** this plan built the DSL on `@cipherstash/stack/schema/v3` +> with `encryptedTextSearchColumn(...)`. That surface has since been renamed to +> `@cipherstash/stack/eql/v3` and the factories replaced by the `types` namespace +> (`types.TextSearch(...)`). Retained as a historical execution record — do not +> re-run against the current tree. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an EQL v3 authoring DSL (`encryptedTextSearchColumn`, plus v3 `encryptedTable` / `buildEncryptConfig`) on a new `@cipherstash/stack/schema/v3` subpath that emits the existing `EncryptConfig` shape with zero native-client changes. diff --git a/docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md b/docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md index 8f5e10b6..98229d34 100644 --- a/docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md +++ b/docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md @@ -1,5 +1,10 @@ # EQL v3 Typed Schema Implementation Plan +> **Superseded (2026-07-03):** this plan added per-domain `encryptedColumn` +> builders on `@cipherstash/stack/schema/v3`. Those builders are now the `types` +> namespace (`types.TextEq` / `types.Int4Ord` / …) on `@cipherstash/stack/eql/v3`. +> Retained as a historical execution record — do not re-run against the current tree. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Expand `@cipherstash/stack/schema/v3` from the current `text_search` slice to all generated EQL v3 SQL domains with domain-precise builders, explicit query capability metadata, and structurally widened client/model support while preserving v2 behavior. diff --git a/docs/superpowers/specs/2026-06-30-eql-v3-text-search-schema-design.md b/docs/superpowers/specs/2026-06-30-eql-v3-text-search-schema-design.md index da27a884..2e452f04 100644 --- a/docs/superpowers/specs/2026-06-30-eql-v3-text-search-schema-design.md +++ b/docs/superpowers/specs/2026-06-30-eql-v3-text-search-schema-design.md @@ -1,5 +1,11 @@ # EQL v3 Schema DSL — `text_search` (Increment 1) +> **Superseded (2026-07-03):** the authoring surface described below has moved. +> The subpath is now `@cipherstash/stack/eql/v3` (not `schema/v3`) and columns +> are authored via the `types` namespace — `types.TextSearch('email')` replaces +> `encryptedTextSearchColumn('email')`. This document is retained as the original +> design record for the increment; the code examples show the historical API. + **Date:** 2026-06-30 **Status:** Approved (design) **Package:** `@cipherstash/stack` diff --git a/docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md b/docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md new file mode 100644 index 00000000..a149cdad --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md @@ -0,0 +1,196 @@ +# Stryker mutation testing as a blocking CI gate for EQL v3 + +Date: 2026-07-02 +Status: Proposed (awaiting review) +Branch: feat/eql-v3-text-search-schema + +## Goal + +Add StrykerJS mutation testing scoped to **EQL v3 only** +(`packages/stack/src/eql/v3/`) and wire it into CI as a **blocking** check. +The gate mirrors the existing `fta-v3.yml` complexity gate: paths-filtered to v3, +directory-scoped, and blocking (no `continue-on-error`). + +Mutation testing verifies that the v3 test suite actually *detects* changes in +behaviour — it mutates the source and fails if the tests still pass ("surviving +mutants"). This complements the FTA complexity gate (static) with a +test-effectiveness gate (dynamic). + +## Context / prior art + +- **rundown** (`~/psrc/rundown`) runs Stryker 9.6.1 across a pnpm monorepo with a + **Jest** runner, per-package configs, incremental runs backed by the public + Stryker Dashboard, a native `break: 70` aggregate threshold, and a custom + `assert-mutation-score.mjs` per-file gate. Its PR run is advisory; only the + push-to-`main`/weekly "producer" run is blocking. +- **stack** differs in ways that simplify the design considerably (below). + +## Key facts driving the design + +1. **stack uses Vitest 3.2.4, not Jest.** rundown's Jest-runner config is not + reusable. We use `@stryker-mutator/vitest-runner`. +2. **v3 scope is a small directory:** `packages/stack/src/eql/v3/` — four + cohesive files (`columns.ts`, `types.ts`, `table.ts`, `index.ts`), split out + of the former single `schema/v3/index.ts`. NOTE: an earlier draft of this + spec assumed a *single file*, from which it concluded the project-wide + aggregate score *is* that file's score and no per-file gate script was needed. + That premise no longer holds — the mutate scope is now four files. With only + four small files the aggregate is still a faithful signal, so start with an + aggregate `break` threshold only (no `assert-mutation-score.mjs`), but add + per-file gating if any one file's score later diverges from the aggregate. +3. **Live/DB tests self-skip without env.** `schema-v3-pg.test.ts` (guarded by + `DATABASE_URL` + `CS_*`) and `schema-v3-client.test.ts` (guarded by `CS_*`) + skip their `describe` blocks when the env vars are absent. So a CI run with no + Postgres service and no `.env` still runs cleanly — the live blocks skip; the + pure `schema-v3.test.ts` (~21 KB) and `typed-client-v3.test.ts` provide the + coverage. The gate stays as lean as the FTA job (no DB, no credentials). +4. **No build step needed.** Vitest transpiles TS on the fly and the tests import + from `@/eql/v3` (source, via the `@/` alias), so Stryker instruments source + directly — no `pnpm build` required. +5. **Supply-chain rule.** Tooling must be a pinned devDependency installed via + `--frozen-lockfile` (no `pnpm dlx` / `npx`), matching how `fta-cli@3.0.0` is + handled. + +## Scope decisions + +- **Mutate scope: `eql/v3` only** (`src/eql/v3/**/*.ts`). Matches the FTA + gate exactly. `src/encryption/v3.ts` is also v3 but is outside the current FTA + scope and is **excluded here** to keep the gate consistent and lean. +- **Test execution: lean, no DB.** Stryker runs only the v3 runtime test files; + the live pg/client blocks self-skip. No Postgres service is provisioned. + +## Components + +### 1. Dependencies + +Add to `packages/stack/package.json` devDependencies (pinned, matching versions, +9.x line): + +- `@stryker-mutator/core` +- `@stryker-mutator/vitest-runner` + +Update `pnpm-lock.yaml` accordingly (installed via `--frozen-lockfile` in CI). + +### 2. `packages/stack/vitest.stryker.config.ts` + +A dedicated Vitest config for mutation runs that: + +- imports/extends the base `vitest.config.ts` (keeps the `@/` alias and the + `wasm-inline` stub aliases — required for the pure tests to load), +- sets `test.include` to only the v3 runtime tests + (`__tests__/*v3*.test.ts`) so Stryker does not run the whole repo suite, +- disables the `typecheck` block (mutation testing exercises runtime behaviour, + not type tests; the `*.test-d.ts` files are excluded). + +Rationale: without scoping, Stryker's initial dry run would execute every test in +the package, wasting time and coupling the v3 gate to unrelated tests. + +### 3. `packages/stack/stryker.config.mjs` + +```js +export default { + packageManager: 'pnpm', + testRunner: 'vitest', + plugins: ['@stryker-mutator/vitest-runner'], + vitest: { configFile: 'vitest.stryker.config.ts' }, + mutate: ['src/eql/v3/**/*.ts'], + coverageAnalysis: 'perTest', + reporters: ['clear-text', 'progress', 'html', 'json'], + htmlReporter: { fileName: 'reports/mutation/index.html' }, + jsonReporter: { fileName: 'reports/mutation/mutation-report.json' }, + thresholds: { high: , low: , break: }, + concurrency: /* env-tunable, default 2 */, + timeoutMS: /* generous default, e.g. 60000 */, +} +``` + +- `break` is the **blocking mechanism**: Stryker exits non-zero when the score + drops below it. +- **No dashboard reporter and no incremental mode** initially (YAGNI for a single + file; avoids Stryker Dashboard API-key/project setup). Reporters kept local + (html + json for artifacts/debugging, clear-text + progress for logs). +- The explicit `plugins` list is required under pnpm's isolated `node_modules` + layout — Stryker's default `@stryker-mutator/*` auto-discovery fails there + (learned from rundown). + +### 4. Script + +Add to `packages/stack/package.json` scripts: + +```json +"test:mutation": "stryker run" +``` + +### 5. `.github/workflows/stryker-v3.yml` + +A near-clone of `fta-v3.yml`: + +- `on: push (main) / pull_request (**)` with `paths:`: + - `packages/stack/src/eql/v3/**` + - `packages/stack/package.json` + - `packages/stack/stryker.config.mjs` + - `packages/stack/vitest.stryker.config.ts` + - `.github/workflows/stryker-v3.yml` +- `permissions: contents: read` +- one job on `blacksmith-4vcpu-ubuntu-2404`, `timeout-minutes: 30` +- steps: checkout → `pnpm/action-setup@v6.0.8` → `setup-node@v6` (node 22, + `cache: pnpm`) → install `node-gyp` → `pnpm install --frozen-lockfile` → + `pnpm --filter @cipherstash/stack run test:mutation` +- **No `continue-on-error`** on the Stryker step → the check is blocking. + +## Threshold calibration (how "blocking" is made safe) + +We cannot know the current v3 mutation score until Stryker runs once. +Implementation therefore includes a **baseline step**: + +1. Install deps and run `pnpm --filter @cipherstash/stack run test:mutation` + locally. +2. Record the reported mutation score for `eql/v3`. +3. Set `thresholds.break` just **below** the measured score (a small buffer, the + way FTA sets `--score-cap 72` against a current 71.08). This ensures the + current state passes while any regression that lowers the score fails the gate. +4. Set `high`/`low` to reasonable display bands (do not affect pass/fail). + +If the measured baseline is very low (tests are weak), surface that to the user +before committing a `break` value — a near-zero gate provides little protection +and we may want to improve v3 tests first. This is a decision point during +implementation, not a silent choice. + +## Testing / verification + +- Run the Stryker gate locally and confirm it exits 0 at the chosen `break`. +- Confirm the run needs **no** Postgres/credentials (live blocks skip). +- Sanity-check the gate blocks: temporarily lower `break` above the score (or + delete an assertion) and confirm a non-zero exit. +- Confirm the workflow triggers only on v3-relevant paths. + +## Out of scope (YAGNI) + +- Stryker Dashboard reporter and incremental baseline. +- Per-file gate script (`assert-mutation-score.mjs`) — deferred: start with an + aggregate `break` across the four `src/eql/v3/**` files; add per-file gating + only if one file's score later diverges from the aggregate (see fact #2). +- Advisory PR comment job. +- Postgres-backed mutation runs / mutating `src/encryption/v3.ts`. + +These can be added later if the aggregate gate proves insufficient. + +## Decisions confirmed + +- **Test execution model: lean, no DB — confirmed by the user ("Start lean").** + Stryker runs only the pure v3 tests; the live pg/client blocks self-skip. A + full DB-backed run can be added later as a separate workflow if the lean + baseline proves too weak. +- **Single workflow, no split — confirmed.** rundown's two-workflow split + (blocking producer + advisory PR) is driven by its Stryker Dashboard + incremental baseline and its advisory-PR choice, neither of which applies here. + One `stryker-v3.yml` runs on both `push: main` and `pull_request`, blocking, in + the shape of `fta-v3.yml`. A second `stryker-v3-full.yml` (DB-backed, on + main/nightly) is only introduced if/when full accuracy is wanted. + +## Deferred to implementation + +- **Exact `break` value** — deferred to the baseline measurement step (run + Stryker once, set `break` just below the measured score). If the baseline is + very low, surface it before wiring the gate. +- **Stryker `9.x` exact patch version** — pinned at implementation time. diff --git a/packages/stack/__tests__/cjs-require.test.ts b/packages/stack/__tests__/cjs-require.test.ts index c2acda27..460e3b9f 100644 --- a/packages/stack/__tests__/cjs-require.test.ts +++ b/packages/stack/__tests__/cjs-require.test.ts @@ -83,16 +83,19 @@ describe('CJS consumers can require the built bundles', () => { it('discovers at least the public entry points', () => { expect(cjsEntries).toContain('dist/index.cjs') expect(cjsEntries).toContain('dist/encryption/index.cjs') - expect(cjsEntries).toContain('dist/schema/v3/index.cjs') + expect(cjsEntries).toContain('dist/eql/v3/index.cjs') }) - it('exposes v3 schema builders from the CJS bundle', () => { - const v3Bundle = path.join(distDir, 'schema', 'v3', 'index.cjs') + it('exposes the v3 `types` namespace + table API from the CJS bundle', () => { + const v3Bundle = path.join(distDir, 'eql', 'v3', 'index.cjs') const script = [ `const v3 = require(${JSON.stringify(v3Bundle)})`, - `const required = ['encryptedTextSearchColumn', 'encryptedInt4Column', 'encryptedBoolColumn', 'encryptedTimestamptzColumn', 'encryptedTable', 'buildEncryptConfig']`, - `const missing = required.filter((k) => typeof v3[k] !== 'function')`, - `if (missing.length > 0) { throw new Error('missing v3 CJS exports: ' + missing.join(', ')) }`, + `if (typeof v3.encryptedTable !== 'function') { throw new Error('missing v3 CJS export: encryptedTable') }`, + `if (typeof v3.buildEncryptConfig !== 'function') { throw new Error('missing v3 CJS export: buildEncryptConfig') }`, + `if (typeof v3.types !== 'object' || v3.types === null) { throw new Error('missing v3 CJS export: types namespace') }`, + `const requiredTypes = ['TextSearch', 'TextEq', 'Int4Ord', 'Bool', 'Timestamptz']`, + `const missing = requiredTypes.filter((k) => typeof v3.types[k] !== 'function')`, + `if (missing.length > 0) { throw new Error('missing v3 types.* CJS members: ' + missing.join(', ')) }`, ].join('\n') expect(() => diff --git a/packages/stack/__tests__/encrypt-lock-context-guards.test.ts b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts new file mode 100644 index 00000000..68fa4e65 --- /dev/null +++ b/packages/stack/__tests__/encrypt-lock-context-guards.test.ts @@ -0,0 +1,103 @@ +/** + * Offline guard tests for the lock-context encrypt path. + * + * `EncryptOperationWithLockContext.execute()` re-applies the NaN / Infinity + * runtime guards that the non-lock `EncryptOperation.execute()` has. The + * non-lock guards are exercised by the live `number-protect.test.ts` (its + * `beforeAll` builds a real client), but the lock-context arm — reached via + * `encrypt(value).withLockContext(...)` — had no coverage in any suite. These + * tests mock `@cipherstash/protect-ffi` so they run in CI without credentials + * and assert that: + * 1. NaN / +Infinity / -Infinity are rejected as failures with the same + * messages as the non-lock path, and + * 2. the guard short-circuits *before* the FFI encrypt call (a leaked NaN + * must never reach the ciphertext boundary). + * + * Every case runs against both a v2 fluent-builder column and a v3 domain + * column: the guards live on the shared `EncryptOperationWithLockContext`, so + * both schema styles must take the identical short-circuit path. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { encryptedTable as encryptedTableV3, types } from '@/eql/v3' +import { LockContext } from '@/identity' +import { Encryption } from '@/index' +import { encryptedColumn, encryptedTable } from '@/schema' + +vi.mock('@cipherstash/protect-ffi', () => ({ + // `getErrorCode` does `error instanceof ProtectError` on the failure path, + // so the mock must export the class even though the guards throw plain Errors. + ProtectError: class ProtectError extends Error {}, + newClient: vi.fn(async () => ({ __mock: 'client' })), + encrypt: vi.fn(async () => ({ v: 2, c: 'ciphertext' })), + decrypt: vi.fn(async () => 'decrypted'), +})) + +import * as ffi from '@cipherstash/protect-ffi' + +const users = encryptedTable('users', { + score: encryptedColumn('score').dataType('number').equality().orderAndRange(), +}) + +const usersV3 = encryptedTableV3('users_v3', { + score: types.Int4Ord('score'), +}) + +// biome-ignore lint/suspicious/noExplicitAny: test helper reads the Result union +const failure = (result: any) => result.failure + +let client: Awaited> + +beforeEach(async () => { + vi.clearAllMocks() + process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' + client = await Encryption({ schemas: [users, usersV3] }) +}) + +describe.each([ + ['v2 fluent builder', { column: users.score, table: users }], + ['v3 domain type', { column: usersV3.score, table: usersV3 }], +] as const)('encrypt with lock context rejects non-finite numbers (%s)', (_variant, target) => { + it('rejects NaN and never reaches the FFI', async () => { + const result = await client + .encrypt(Number.NaN, target) + .withLockContext(new LockContext()) + + expect(failure(result)).toBeDefined() + expect(failure(result)?.message).toContain('Cannot encrypt NaN value') + expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() + }) + + it('rejects +Infinity and never reaches the FFI', async () => { + const result = await client + .encrypt(Number.POSITIVE_INFINITY, target) + .withLockContext(new LockContext()) + + expect(failure(result)).toBeDefined() + expect(failure(result)?.message).toContain('Cannot encrypt Infinity value') + expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() + }) + + it('rejects -Infinity and never reaches the FFI', async () => { + const result = await client + .encrypt(Number.NEGATIVE_INFINITY, target) + .withLockContext(new LockContext()) + + expect(failure(result)).toBeDefined() + expect(failure(result)?.message).toContain('Cannot encrypt Infinity value') + expect(vi.mocked(ffi.encrypt)).not.toHaveBeenCalled() + }) + + it('accepts a finite number and forwards it to the FFI', async () => { + // Positive control: proves the guards above reject *because* of the value, + // not because the lock-context path is broken for all numbers. + const result = await client + .encrypt(42, target) + .withLockContext(new LockContext()) + + expect(failure(result)).toBeUndefined() + expect(vi.mocked(ffi.encrypt)).toHaveBeenCalledTimes(1) + const opts = vi.mocked(ffi.encrypt).mock.calls[0][1] + expect(opts.plaintext).toBe(42) + }) +}) diff --git a/packages/stack/__tests__/model-column-mapping.test.ts b/packages/stack/__tests__/model-column-mapping.test.ts index 785a47cd..55c19002 100644 --- a/packages/stack/__tests__/model-column-mapping.test.ts +++ b/packages/stack/__tests__/model-column-mapping.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest' import { resolveEncryptColumnMap } from '@/encryption/helpers/model-helpers' +import { encryptedTable, types } from '@/eql/v3' import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema' -import { - encryptedDateColumn, - encryptedTable, - encryptedTextColumn, -} from '@/schema/v3' // `resolveEncryptColumnMap` is how the model path reconciles the two keyings a // table can use: models are matched by JS property name, but the FFI / encrypt @@ -14,8 +10,8 @@ import { describe('resolveEncryptColumnMap', () => { it('v3: matches by JS property, addresses the FFI by DB name', () => { const users = encryptedTable('users', { - createdOn: encryptedDateColumn('created_on'), - notes: encryptedTextColumn('notes'), // property == name + createdOn: types.Date('created_on'), + notes: types.Text('notes'), // property == name }) const { columnPaths, toColumnName } = resolveEncryptColumnMap(users) diff --git a/packages/stack/__tests__/schema-v3-client.test.ts b/packages/stack/__tests__/schema-v3-client.test.ts index 660e77b2..247f9d98 100644 --- a/packages/stack/__tests__/schema-v3-client.test.ts +++ b/packages/stack/__tests__/schema-v3-client.test.ts @@ -2,32 +2,22 @@ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' import { typedClient } from '@/encryption/v3' +import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -import { - encryptedBoolColumn, - encryptedDateColumn, - encryptedInt4OrdColumn, - encryptedTable, - encryptedTextColumn, - encryptedTextEqColumn, - encryptedTextMatchColumn, - encryptedTextSearchColumn, - encryptedTimestamptzColumn, -} from '@/schema/v3' import { unwrapResult } from './fixtures' const users = encryptedTable('schema_v3_client_users', { - email: encryptedTextSearchColumn('email'), - age: encryptedInt4OrdColumn('age'), - nickname: encryptedTextEqColumn('nickname'), - body: encryptedTextMatchColumn('body'), - notes: encryptedTextColumn('notes'), - active: encryptedBoolColumn('active'), + email: types.TextSearch('email'), + age: types.Int4Ord('age'), + nickname: types.TextEq('nickname'), + body: types.TextMatch('body'), + notes: types.Text('notes'), + active: types.Bool('active'), // camelCase JS property → snake_case DB name on purpose: the model path must // match models by JS property (`createdOn`) yet address the FFI/config by DB // name (`created_on`). The round-trip tests below exercise that mapping. - createdOn: encryptedDateColumn('created_on'), - occurredAt: encryptedTimestamptzColumn('occurred_at'), + createdOn: types.Date('created_on'), + occurredAt: types.Timestamptz('occurred_at'), }) const LIVE_CIPHERSTASH_ENABLED = Boolean( diff --git a/packages/stack/__tests__/schema-v3-pg.test.ts b/packages/stack/__tests__/schema-v3-pg.test.ts index 8c5783d9..ae4682f4 100644 --- a/packages/stack/__tests__/schema-v3-pg.test.ts +++ b/packages/stack/__tests__/schema-v3-pg.test.ts @@ -2,14 +2,8 @@ import 'dotenv/config' import postgres from 'postgres' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -import { - encryptedBoolColumn, - encryptedInt4OrdColumn, - encryptedTable, - encryptedTextEqColumn, - encryptedTextSearchColumn, -} from '@/schema/v3' import type { Encrypted } from '@/types' import { unwrapResult } from './fixtures' import { installEqlV3IfNeeded } from './helpers/eql-v3' @@ -30,13 +24,13 @@ const sql = LIVE_EQL_V3_PG_ENABLED : (undefined as unknown as postgres.Sql) const table = encryptedTable('protect_ci_v3_text_search', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) const typedTable = encryptedTable('protect_ci_v3_typed_domains', { - age: encryptedInt4OrdColumn('age'), - nickname: encryptedTextEqColumn('nickname'), - active: encryptedBoolColumn('active'), + age: types.Int4Ord('age'), + nickname: types.TextEq('nickname'), + active: types.Bool('active'), }) const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` diff --git a/packages/stack/__tests__/schema-v3.test-d.ts b/packages/stack/__tests__/schema-v3.test-d.ts index b99fdbe3..4ddeadee 100644 --- a/packages/stack/__tests__/schema-v3.test-d.ts +++ b/packages/stack/__tests__/schema-v3.test-d.ts @@ -1,5 +1,11 @@ import { describe, expectTypeOf, it } from 'vitest' import { Encryption, type EncryptionClient } from '@/encryption' +import type { + EncryptedTextSearchColumn, + InferEncrypted, + InferPlaintext, +} from '@/eql/v3' +import { encryptedTable, types } from '@/eql/v3' // v2 column builders — used to prove the v3 table type rejects a v2 column and // to assert v2 backward-compat against the widened client types. import { @@ -7,35 +13,17 @@ import { encryptedField, encryptedTable as v2EncryptedTable, } from '@/schema' -import type { - EncryptedTextSearchColumn, - InferEncrypted, - InferPlaintext, -} from '@/schema/v3' -import { - encryptedBoolColumn, - encryptedDateColumn, - encryptedFloat8Column, - encryptedInt4Column, - encryptedTable, - encryptedTextColumn, - encryptedTextEqColumn, - encryptedTextMatchColumn, - encryptedTextSearchColumn, - encryptedTimestamptzColumn, - encryptedTimestamptzOrdColumn, -} from '@/schema/v3' import type { Encrypted } from '@/types' describe('eql_v3 schema type inference', () => { - it('encryptedTextSearchColumn returns an EncryptedTextSearchColumn', () => { - const col = encryptedTextSearchColumn('email') + it('types.TextSearch returns an EncryptedTextSearchColumn', () => { + const col = types.TextSearch('email') expectTypeOf(col).toEqualTypeOf() }) it('encryptedTable exposes column builders as typed properties', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) expectTypeOf(users.email).toEqualTypeOf() expectTypeOf(users.tableName).toBeString() @@ -50,8 +38,8 @@ describe('eql_v3 schema type inference', () => { it('InferPlaintext maps each column to string', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), - name: encryptedTextSearchColumn('name'), + email: types.TextSearch('email'), + name: types.TextSearch('name'), }) type Plaintext = InferPlaintext expectTypeOf().toEqualTypeOf<{ email: string; name: string }>() @@ -59,7 +47,7 @@ describe('eql_v3 schema type inference', () => { it('InferEncrypted maps each column to Encrypted', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) type Enc = InferEncrypted<typeof users> expectTypeOf<Enc>().toEqualTypeOf<{ email: Encrypted }>() @@ -67,11 +55,11 @@ describe('eql_v3 schema type inference', () => { it('InferPlaintext maps v3 concrete domains to plaintext TypeScript types', () => { const metrics = encryptedTable('metrics', { - name: encryptedTextColumn('name'), - age: encryptedInt4Column('age'), - active: encryptedBoolColumn('active'), - createdAt: encryptedTimestamptzColumn('created_at'), - score: encryptedFloat8Column('score'), + name: types.Text('name'), + age: types.Int4('age'), + active: types.Bool('active'), + createdAt: types.Timestamptz('created_at'), + score: types.Float8('score'), }) type Plaintext = InferPlaintext<typeof metrics> @@ -86,8 +74,8 @@ describe('eql_v3 schema type inference', () => { }) it('v3 domain classes remain nominal by literal domain definition', () => { - const date = encryptedDateColumn('created_on') - const bool = encryptedBoolColumn('active') + const date = types.Date('created_on') + const bool = types.Bool('active') expectTypeOf(date).not.toEqualTypeOf<typeof bool>() @@ -99,7 +87,7 @@ describe('eql_v3 schema type inference', () => { describe('eql_v3 client integration (type-level acceptance)', () => { const v3users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) it('Encryption accepts a v3 schema', () => { @@ -175,10 +163,10 @@ describe('eql_v3 client integration (type-level acceptance)', () => { it('encryptQuery accepts queryable v3 columns with explicit capability metadata', () => { const users = encryptedTable('users', { - emailEq: encryptedTextEqColumn('email_eq'), - emailMatch: encryptedTextMatchColumn('email_match'), - emailSearch: encryptedTextSearchColumn('email_search'), - createdAt: encryptedTimestamptzOrdColumn('created_at'), + emailEq: types.TextEq('email_eq'), + emailMatch: types.TextMatch('email_match'), + emailSearch: types.TextSearch('email_search'), + createdAt: types.TimestamptzOrd('created_at'), }) const client = {} as EncryptionClient @@ -205,8 +193,8 @@ describe('eql_v3 client integration (type-level acceptance)', () => { it('encryptQuery rejects storage-only v3 columns at compile time', () => { const users = encryptedTable('users', { - email: encryptedTextColumn('email'), - active: encryptedBoolColumn('active'), + email: types.Text('email'), + active: types.Bool('active'), }) const client = {} as EncryptionClient @@ -227,8 +215,8 @@ describe('eql_v3 client integration (type-level acceptance)', () => { describe('eql_v3 model encryption inference', () => { it('encryptModel and bulkEncryptModels infer encrypted fields from v3 tables', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), - active: encryptedBoolColumn('active'), + email: types.TextSearch('email'), + active: types.Bool('active'), }) const client = {} as EncryptionClient @@ -260,7 +248,7 @@ describe('eql_v3 model encryption inference', () => { it('v3 encryptModel preserves unrelated and nullable fields', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) const client = {} as EncryptionClient @@ -286,7 +274,7 @@ describe('eql_v3 model encryption inference', () => { // must resolve to `never` here, not `keyof never` (= string|number|symbol), // which would wrongly encrypt all fields including `id` and `untouched`. const usersConcrete = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) const table: import('@/types').BuildableTable = usersConcrete const client = {} as EncryptionClient @@ -309,7 +297,7 @@ describe('eql_v3 model encryption inference', () => { // ('occurredAt'). Model inference keys off the PROPERTY name, so `occurredAt` // must become `Encrypted` while unrelated fields are preserved verbatim. const events = encryptedTable('events', { - occurredAt: encryptedTimestamptzColumn('created_at'), + occurredAt: types.Timestamptz('created_at'), }) const client = {} as EncryptionClient diff --git a/packages/stack/__tests__/schema-v3.test.ts b/packages/stack/__tests__/schema-v3.test.ts index 2a4b1c82..d44b5f09 100644 --- a/packages/stack/__tests__/schema-v3.test.ts +++ b/packages/stack/__tests__/schema-v3.test.ts @@ -1,24 +1,18 @@ import { describe, expect, it } from 'vitest' import { resolveIndexType } from '@/encryption/helpers/infer-index-type' -import { encryptConfigSchema, encryptedColumn } from '@/schema' import { buildEncryptConfig, EncryptedTable, EncryptedTextSearchColumn, - encryptedDateColumn, - encryptedDateOrdColumn, - encryptedInt4OrdColumn, encryptedTable, - encryptedTextMatchColumn, - encryptedTextOrdColumn, - encryptedTextSearchColumn, - encryptedTimestamptzColumn, -} from '@/schema/v3' + types, +} from '@/eql/v3' +import { encryptConfigSchema, encryptedColumn } from '@/schema' import { type DomainSpec, typedEntries, V3_MATRIX } from './v3-matrix/catalog' describe('eql_v3 text_search column', () => { it('LOAD-BEARING: default build() deep-equals the v2 equality+order+match column', () => { - const v3 = encryptedTextSearchColumn('email').build() + const v3 = types.TextSearch('email').build() const v2 = encryptedColumn('email') .equality() .orderAndRange() @@ -29,7 +23,8 @@ describe('eql_v3 text_search column', () => { }) it('.freeTextSearch(opts) overrides each provided key and keeps the rest as defaults', () => { - const built = encryptedTextSearchColumn('email') + const built = types + .TextSearch('email') .freeTextSearch({ tokenizer: { kind: 'ngram', token_length: 4 }, k: 8, @@ -51,7 +46,8 @@ describe('eql_v3 text_search column', () => { // LOAD-BEARING: `[] ?? default` evaluates to `[]` (an empty array is not // nullish), so an explicit empty array must OVERRIDE the downcase default, // not fall back to it. Mirrors v2 (schema-builders.test.ts). - const built = encryptedTextSearchColumn('email') + const built = types + .TextSearch('email') .freeTextSearch({ token_filters: [] }) .build() expect(built.indexes.match.token_filters).toEqual([]) @@ -62,7 +58,8 @@ describe('eql_v3 text_search column', () => { // accumulated matchOpts — so the second call resets k back to its default // of 6. This is intentional: it mirrors v2 exactly. Pinned here so a future // "merge against current state" change can't silently slip in. - const built = encryptedTextSearchColumn('email') + const built = types + .TextSearch('email') .freeTextSearch({ k: 8 }) .freeTextSearch({ m: 4096 }) .build() @@ -70,10 +67,16 @@ describe('eql_v3 text_search column', () => { expect(built.indexes.match.m).toBe(4096) }) + it('.freeTextSearch() with no argument is a no-op: build() equals the default build()', () => { + // Pins the opts === undefined branch: every `opts?.x ?? default` falls + // through, so a bare call must emit exactly the default match block. + expect(types.TextSearch('email').freeTextSearch().build()).toStrictEqual( + types.TextSearch('email').build(), + ) + }) + it('.freeTextSearch() is tuning-only: unique and ore indexes stay present', () => { - const built = encryptedTextSearchColumn('email') - .freeTextSearch({ k: 8 }) - .build() + const built = types.TextSearch('email').freeTextSearch({ k: 8 }).build() expect(built.indexes.unique).toEqual({ token_filters: [] }) expect(built.indexes.ore).toEqual({}) }) @@ -81,8 +84,8 @@ describe('eql_v3 text_search column', () => { it('built columns share no mutable state: mutating one build() output does not affect another', () => { // Guards against the shared-defaults aliasing bug: defaults come from a // per-instance factory and build() deep-clones the match block. - const a = encryptedTextSearchColumn('a').build() - const b = encryptedTextSearchColumn('b').build() + const a = types.TextSearch('a').build() + const b = types.TextSearch('b').build() // Mutate every nested level of a's match block. a.indexes.match.k = 999 @@ -97,7 +100,7 @@ describe('eql_v3 text_search column', () => { }) // A second build() of an independent column is also pristine. - const c = encryptedTextSearchColumn('c').build() + const c = types.TextSearch('c').build() expect(c.indexes.match.k).toBe(6) expect(c.indexes.match.token_filters).toEqual([{ kind: 'downcase' }]) }) @@ -111,7 +114,7 @@ describe('eql_v3 text_search column', () => { tokenizer: { kind: 'ngram' as const, token_length: 3 }, token_filters: [{ kind: 'downcase' as const }], } - const col = encryptedTextSearchColumn('email').freeTextSearch(opts) + const col = types.TextSearch('email').freeTextSearch(opts) // Mutate the caller's own opts AFTER freeTextSearch but BEFORE build(). opts.tokenizer.token_length = 999 @@ -126,10 +129,37 @@ describe('eql_v3 text_search column', () => { }) }) +describe('eql_v3 text_match column', () => { + it('built columns share no mutable state: mutating one build() output does not affect another', () => { + // Same aliasing guard as the text_search test above, but through the base + // class indexesForCapabilities() match clone — text_match has no build() + // override, so a regression there (e.g. sharing a defaultMatchOpts() result + // across builds) would slip past the text_search-only test. + const a = types.TextMatch('a').build() + const b = types.TextMatch('b').build() + + a.indexes.match.k = 999 + a.indexes.match.token_filters.push({ kind: 'downcase' }) + a.indexes.match.tokenizer = { kind: 'standard' } + + expect(b.indexes.match.k).toBe(6) + expect(b.indexes.match.token_filters).toEqual([{ kind: 'downcase' }]) + expect(b.indexes.match.tokenizer).toEqual({ + kind: 'ngram', + token_length: 3, + }) + + // A fresh build() of an independent column is also pristine. + const c = types.TextMatch('c').build() + expect(c.indexes.match.k).toBe(6) + expect(c.indexes.match.token_filters).toEqual([{ kind: 'downcase' }]) + }) +}) + describe('eql_v3 encryptedTable', () => { it('creates a table exposing column builders as properties', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) expect(users).toBeInstanceOf(EncryptedTable) expect(users.tableName).toBe('users') @@ -137,7 +167,7 @@ describe('eql_v3 encryptedTable', () => { }) it('table.email returns the same builder instance passed in', () => { - const emailCol = encryptedTextSearchColumn('email') + const emailCol = types.TextSearch('email') const users = encryptedTable('users', { email: emailCol }) expect(users.email).toBe(emailCol) }) @@ -157,14 +187,14 @@ describe('eql_v3 encryptedTable', () => { ])('throws when a column name (%s) collides with a reserved property', (reserved) => { expect(() => encryptedTable('users', { - [reserved]: encryptedTextSearchColumn(reserved), + [reserved]: types.TextSearch(reserved), }), ).toThrow(/reserved EncryptedTable property/) }) it('build() assembles { tableName, columns } with built column configs', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) const built = users.build() expect(built.tableName).toBe('users') @@ -185,12 +215,38 @@ describe('eql_v3 encryptedTable', () => { }, }) }) + + it('build() throws when two columns resolve to the same DB name (no silent overwrite)', () => { + // Columns are keyed in the built config by DB name (`getName()`), so two JS + // properties whose builders resolve to the same name would silently + // overwrite — the later one wins and the first column's config is lost. + // Fail loudly, matching the reserved-key and duplicate-tableName guards. + const users = encryptedTable('users', { + email: types.TextEq('contact'), + contactEmail: types.TextMatch('contact'), + }) + expect(() => users.build()).toThrow(/duplicate column name "contact"/) + }) + + it('build() surfaces the duplicate DB name through buildEncryptConfig', () => { + const users = encryptedTable('users', { + email: types.TextEq('contact'), + contactEmail: types.TextMatch('contact'), + }) + expect(() => buildEncryptConfig(users)).toThrow( + /duplicate column name "contact"/, + ) + }) }) describe('eql_v3 buildEncryptConfig', () => { + it('zero tables yields an empty config (client-boundary Encryption() still rejects it)', () => { + expect(buildEncryptConfig()).toStrictEqual({ v: 1, tables: {} }) + }) + it('produces a { v: 1, tables } config', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) const config = buildEncryptConfig(users) expect(config.v).toBe(1) @@ -200,7 +256,7 @@ describe('eql_v3 buildEncryptConfig', () => { it('emits a config that passes encryptConfigSchema.parse()', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) const config = buildEncryptConfig(users) expect(() => encryptConfigSchema.parse(config)).not.toThrow() @@ -208,10 +264,10 @@ describe('eql_v3 buildEncryptConfig', () => { it('supports multiple tables', () => { const users = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) const posts = encryptedTable('posts', { - body: encryptedTextSearchColumn('body'), + body: types.TextSearch('body'), }) const config = buildEncryptConfig(users, posts) expect(Object.keys(config.tables).sort()).toEqual(['posts', 'users']) @@ -223,8 +279,8 @@ describe('eql_v3 buildEncryptConfig', () => { // `column.getName()`, so keying by the JS property name makes the FFI // report "column not found in Encrypt config" at encrypt time. const users = encryptedTable('accounts', { - createdOn: encryptedDateColumn('created_on'), - lastSeen: encryptedTimestamptzColumn('last_seen'), + createdOn: types.Date('created_on'), + lastSeen: types.Timestamptz('last_seen'), }) const config = buildEncryptConfig(users) expect(Object.keys(config.tables.accounts).sort()).toEqual([ @@ -240,9 +296,9 @@ describe('eql_v3 buildEncryptConfig', () => { // FFI/config by DB name. `build()` discards the property→name relationship // (it keys by DB name); `buildColumnKeyMap()` recovers it. const users = encryptedTable('accounts', { - createdOn: encryptedDateColumn('created_on'), - lastSeen: encryptedTimestamptzColumn('last_seen'), - email: encryptedTextSearchColumn('email'), + createdOn: types.Date('created_on'), + lastSeen: types.Timestamptz('last_seen'), + email: types.TextSearch('email'), }) expect(users.buildColumnKeyMap()).toEqual({ createdOn: 'created_on', @@ -257,10 +313,10 @@ describe('eql_v3 buildEncryptConfig', () => { // footgun surfaces at build time. (v2 keeps its silent-overwrite behavior // unchanged — the no-v2-change constraint.) const a = encryptedTable('users', { - email: encryptedTextSearchColumn('email'), + email: types.TextSearch('email'), }) const b = encryptedTable('users', { - name: encryptedTextSearchColumn('name'), + name: types.TextSearch('name'), }) expect(() => buildEncryptConfig(a, b)).toThrow( /duplicate table name "users"/, @@ -328,7 +384,7 @@ describe('eql_v3 catalog-driven query capability sweep', () => { // Spot-check the exact messages for a queryable-but-misused column, so the // broad regex above doesn't let a message regression slip through. it('reports the specific missing index for a match-only column', () => { - const matchOnly = encryptedTextMatchColumn('body') + const matchOnly = types.TextMatch('body') expect(() => resolveIndexType(matchOnly, 'equality')).toThrow( /Index type "unique" is not configured/, ) @@ -339,14 +395,16 @@ describe('eql_v3 catalog-driven query capability sweep', () => { }) describe('eql_v3 equality via ORE on order-capable columns (regression)', () => { - // The capability contract documents equality as answerable "via `ob`", so an - // order-capable column resolves equality to its `ore` index (same term as - // orderAndRange, distinguished by the SQL `=` operator) instead of throwing on - // the absent `unique` index. One domain per plaintext axis. + // The capability contract documents equality as answerable "via `ob`", so a + // numeric/date order-capable column (which has NO `hm`) resolves equality to + // its `ore` index (same term as orderAndRange, distinguished by the SQL `=` + // operator) instead of throwing on the absent `unique` index. (Text order + // domains DO carry `hm` and resolve equality to `unique` instead — see the + // text-order regression below.) it.each([ - ['int4_ord', encryptedInt4OrdColumn], - ['date_ord', encryptedDateOrdColumn], - ['text_ord', encryptedTextOrdColumn], + ['int4_ord', types.Int4Ord], + ['date_ord', types.DateOrd], + ['numeric_ord', types.NumericOrd], ] as const)('%s resolves equality to the ore index', (_name, builder) => { expect(resolveIndexType(builder('value'), 'equality')).toEqual({ indexType: 'ore', @@ -362,3 +420,41 @@ describe('eql_v3 equality via ORE on order-capable columns (regression)', () => ) }) }) + +describe('eql_v3 text order domains carry the hm (unique) index (regression)', () => { + // The `eql_v3.text_ord` and `eql_v3.text_ord_ore` SQL domains require BOTH + // `hm` (HMAC) and `ob` (ORE) in the stored ciphertext: text equality is + // HMAC-based (their `eql_v3.eq_term` extracts `hm`), unlike numeric/date order + // domains which answer equality via `ob` and need only ORE. So text order + // columns must emit `unique` (hm) IN ADDITION to `ore` (ob), or a real INSERT + // fails with `value for domain eql_v3.text_ord_ore violates check constraint`. + it.each([ + ['text_ord_ore', types.TextOrdOre], + ['text_ord', types.TextOrd], + ] as const)('%s emits both unique (hm) and ore (ob)', (_name, builder) => { + expect(builder('c').build().indexes).toStrictEqual({ + unique: { token_filters: [] }, + ore: {}, + }) + }) + + it.each([ + ['int4_ord_ore', types.Int4OrdOre], + ['int4_ord', types.Int4Ord], + ['date_ord_ore', types.DateOrdOre], + ['numeric_ord', types.NumericOrd], + ] as const)('%s (numeric/date order) emits ore only — no unique', (_name, builder) => { + expect(builder('c').build().indexes).toStrictEqual({ ore: {} }) + }) + + // With `unique` present, text order equality resolves to the hm index (not + // ORE): `resolvesEqualityViaOre` only fires when `unique` is ABSENT. + it.each([ + ['text_ord_ore', types.TextOrdOre], + ['text_ord', types.TextOrd], + ] as const)('%s resolves equality to the unique (hm) index', (_name, builder) => { + expect(resolveIndexType(builder('value'), 'equality')).toEqual({ + indexType: 'unique', + }) + }) +}) diff --git a/packages/stack/__tests__/typed-client-v3.test-d.ts b/packages/stack/__tests__/typed-client-v3.test-d.ts index 54bfca0c..2af6af37 100644 --- a/packages/stack/__tests__/typed-client-v3.test-d.ts +++ b/packages/stack/__tests__/typed-client-v3.test-d.ts @@ -3,13 +3,9 @@ import type { EncryptionClient } from '@/encryption' // Everything comes from the single `@cipherstash/stack/v3` surface (re-exported // from src/encryption/v3.ts), exercising the re-export at the same time. import { - encryptedInt4OrdColumn, encryptedTable, - encryptedTextColumn, - encryptedTextEqColumn, - encryptedTextSearchColumn, - encryptedTimestamptzOrdColumn, typedClient, + types, type V3DecryptedModel, type V3EncryptedModel, } from '@/encryption/v3' @@ -17,16 +13,16 @@ import type { Encrypted } from '@/types' // A v3 table mixing every relevant capability tier: const users = encryptedTable('users', { - email: encryptedTextEqColumn('email'), // equality only - bio: encryptedTextSearchColumn('bio'), // equality + order + free-text - note: encryptedTextColumn('note'), // storage only (not queryable) - createdAt: encryptedTimestamptzOrdColumn('created_at'), // equality + order + email: types.TextEq('email'), // equality only + bio: types.TextSearch('bio'), // equality + order + free-text + note: types.Text('note'), // storage only (not queryable) + createdAt: types.TimestamptzOrd('created_at'), // equality + order }) // A second registered table whose `weight` domain (int4_ord) is NOT present in // `users`, so borrowing it is a genuine cross-table type error. const other = encryptedTable('other', { - weight: encryptedInt4OrdColumn('weight'), + weight: types.Int4Ord('weight'), }) const client = typedClient({} as EncryptionClient, users, other) diff --git a/packages/stack/__tests__/typed-client-v3.test.ts b/packages/stack/__tests__/typed-client-v3.test.ts index 96754cdc..a3e60526 100644 --- a/packages/stack/__tests__/typed-client-v3.test.ts +++ b/packages/stack/__tests__/typed-client-v3.test.ts @@ -1,19 +1,13 @@ import { describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' -import { - encryptedDateColumn, - encryptedTable, - encryptedTextColumn, - encryptedTimestamptzColumn, - typedClient, -} from '@/encryption/v3' +import { encryptedTable, typedClient, types } from '@/encryption/v3' const table = encryptedTable('t', { - when: encryptedTimestamptzColumn('when'), - note: encryptedTextColumn('note'), + when: types.Timestamptz('when'), + note: types.Text('note'), // camelCase JS property → snake_case DB name: reconstruction must key by the // JS property (how the decrypted row is keyed), not the DB column name. - createdOn: encryptedDateColumn('created_on'), + createdOn: types.Date('created_on'), }) /** diff --git a/packages/stack/__tests__/types-public-surface.test-d.ts b/packages/stack/__tests__/types-public-surface.test-d.ts new file mode 100644 index 00000000..7bd45890 --- /dev/null +++ b/packages/stack/__tests__/types-public-surface.test-d.ts @@ -0,0 +1,42 @@ +import { describe, expectTypeOf, it } from 'vitest' +// Regression guard for the public `@cipherstash/stack/types` entrypoint +// (src/types-public.ts). The structural builder contracts and the +// `encryptModel` / `bulkEncryptModels` return-type mapper appear in PUBLIC +// return positions (encryption/index.ts), so consumers must be able to NAME +// them from the public path. Importing a member that is not re-exported fails +// typecheck — so this file compiling green proves the surface is complete. +import type { + BuildableColumn, + BuildableQueryColumn, + BuildableTable, + BuildableTableColumns, + BuildableV3QueryableColumn, + Encrypted, + EncryptedFromBuildableTable, +} from '@/types-public' + +describe('public @cipherstash/stack/types surface', () => { + it('exposes the structural builder contracts', () => { + // A v3 queryable column IS a BuildableColumn (interface extension). + expectTypeOf<BuildableV3QueryableColumn>().toMatchTypeOf<BuildableColumn>() + // The query-column union is nameable and non-trivial. + expectTypeOf<BuildableQueryColumn>().not.toBeNever() + // The client table contract is nameable. + expectTypeOf<BuildableTable['tableName']>().toBeString() + }) + + it('exposes EncryptedFromBuildableTable (the encryptModel return mapper)', () => { + interface Users extends BuildableTable { + readonly _columnType: { email: unknown } + } + type Row = { id: number; email: string } + type Enc = EncryptedFromBuildableTable<Row, Users> + + // Schema-column fields become Encrypted; passthrough fields keep their type. + expectTypeOf<Enc['email']>().toEqualTypeOf<Encrypted>() + expectTypeOf<Enc['id']>().toEqualTypeOf<number>() + + // The column-map helper is nameable too. + expectTypeOf<keyof BuildableTableColumns<Users>>().toEqualTypeOf<'email'>() + }) +}) diff --git a/packages/stack/__tests__/v3-matrix/catalog.ts b/packages/stack/__tests__/v3-matrix/catalog.ts index 888569fc..ce5b97f2 100644 --- a/packages/stack/__tests__/v3-matrix/catalog.ts +++ b/packages/stack/__tests__/v3-matrix/catalog.ts @@ -16,12 +16,12 @@ * instanceof check, `castAs` + `indexes` by the `build()` `toStrictEqual`, and * `capabilities` by `getQueryCapabilities()`/`isQueryable()`. */ -import type { ColumnSchema } from '@/schema' + import type { AnyEncryptedV3Column, EqlTypeForColumn, QueryCapabilities, -} from '@/schema/v3' +} from '@/eql/v3' import { EncryptedBoolColumn, EncryptedDateColumn, @@ -58,42 +58,9 @@ import { EncryptedTimestamptzEqColumn, EncryptedTimestamptzOrdColumn, EncryptedTimestamptzOrdOreColumn, - encryptedBoolColumn, - encryptedDateColumn, - encryptedDateEqColumn, - encryptedDateOrdColumn, - encryptedDateOrdOreColumn, - encryptedFloat4Column, - encryptedFloat4EqColumn, - encryptedFloat4OrdColumn, - encryptedFloat4OrdOreColumn, - encryptedFloat8Column, - encryptedFloat8EqColumn, - encryptedFloat8OrdColumn, - encryptedFloat8OrdOreColumn, - encryptedInt2Column, - encryptedInt2EqColumn, - encryptedInt2OrdColumn, - encryptedInt2OrdOreColumn, - encryptedInt4Column, - encryptedInt4EqColumn, - encryptedInt4OrdColumn, - encryptedInt4OrdOreColumn, - encryptedNumericColumn, - encryptedNumericEqColumn, - encryptedNumericOrdColumn, - encryptedNumericOrdOreColumn, - encryptedTextColumn, - encryptedTextEqColumn, - encryptedTextMatchColumn, - encryptedTextOrdColumn, - encryptedTextOrdOreColumn, - encryptedTextSearchColumn, - encryptedTimestamptzColumn, - encryptedTimestamptzEqColumn, - encryptedTimestamptzOrdColumn, - encryptedTimestamptzOrdOreColumn, -} from '@/schema/v3' + types, +} from '@/eql/v3' +import type { ColumnSchema } from '@/schema' /** * The canonical union of every v3 domain name — derived STRAIGHT from the real @@ -185,6 +152,10 @@ type Indexes = ColumnSchema['indexes'] const NONE: Indexes = {} const UNIQUE_IDX: Indexes = { unique: { token_filters: [] } } const ORE_IDX: Indexes = { ore: {} } +// Text order domains (`text_ord`, `text_ord_ore`) carry BOTH `hm` (unique) and +// `ob` (ore): their eql_v3 SQL domains require `hm` because text equality is +// HMAC-based, unlike numeric/date order domains which answer equality via `ob`. +const TEXT_ORD_IDX: Indexes = { unique: { token_filters: [] }, ore: {} } const MATCH_BLOCK: NonNullable<Indexes>['match'] = { tokenizer: { kind: 'ngram', token_length: 3 }, token_filters: [{ kind: 'downcase' }], @@ -223,47 +194,47 @@ const NUM_ERR = [ // biome-ignore format: one row per domain reads as a table; keep it dense. export const V3_MATRIX = { // int4 - 'eql_v3.int4': { builder: encryptedInt4Column, ColumnClass: EncryptedInt4Column, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: INT4_S, errorSamples: NUM_ERR }, - 'eql_v3.int4_eq': { builder: encryptedInt4EqColumn, ColumnClass: EncryptedInt4EqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: INT4_S, errorSamples: NUM_ERR }, - 'eql_v3.int4_ord_ore': { builder: encryptedInt4OrdOreColumn, ColumnClass: EncryptedInt4OrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT4_S, errorSamples: NUM_ERR }, - 'eql_v3.int4_ord': { builder: encryptedInt4OrdColumn, ColumnClass: EncryptedInt4OrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT4_S, errorSamples: NUM_ERR }, + 'eql_v3.int4': { builder: types.Int4, ColumnClass: EncryptedInt4Column, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: INT4_S, errorSamples: NUM_ERR }, + 'eql_v3.int4_eq': { builder: types.Int4Eq, ColumnClass: EncryptedInt4EqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: INT4_S, errorSamples: NUM_ERR }, + 'eql_v3.int4_ord_ore': { builder: types.Int4OrdOre, ColumnClass: EncryptedInt4OrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT4_S, errorSamples: NUM_ERR }, + 'eql_v3.int4_ord': { builder: types.Int4Ord, ColumnClass: EncryptedInt4OrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT4_S, errorSamples: NUM_ERR }, // int2 - 'eql_v3.int2': { builder: encryptedInt2Column, ColumnClass: EncryptedInt2Column, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: INT2_S, errorSamples: NUM_ERR }, - 'eql_v3.int2_eq': { builder: encryptedInt2EqColumn, ColumnClass: EncryptedInt2EqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: INT2_S, errorSamples: NUM_ERR }, - 'eql_v3.int2_ord_ore': { builder: encryptedInt2OrdOreColumn, ColumnClass: EncryptedInt2OrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT2_S, errorSamples: NUM_ERR }, - 'eql_v3.int2_ord': { builder: encryptedInt2OrdColumn, ColumnClass: EncryptedInt2OrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT2_S, errorSamples: NUM_ERR }, + 'eql_v3.int2': { builder: types.Int2, ColumnClass: EncryptedInt2Column, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: INT2_S, errorSamples: NUM_ERR }, + 'eql_v3.int2_eq': { builder: types.Int2Eq, ColumnClass: EncryptedInt2EqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: INT2_S, errorSamples: NUM_ERR }, + 'eql_v3.int2_ord_ore': { builder: types.Int2OrdOre, ColumnClass: EncryptedInt2OrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT2_S, errorSamples: NUM_ERR }, + 'eql_v3.int2_ord': { builder: types.Int2Ord, ColumnClass: EncryptedInt2OrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: INT2_S, errorSamples: NUM_ERR }, // date - 'eql_v3.date': { builder: encryptedDateColumn, ColumnClass: EncryptedDateColumn, castAs: 'date', capabilities: STORAGE, indexes: NONE, samples: DATE_S }, - 'eql_v3.date_eq': { builder: encryptedDateEqColumn, ColumnClass: EncryptedDateEqColumn, castAs: 'date', capabilities: EQ, indexes: UNIQUE_IDX, samples: DATE_S }, - 'eql_v3.date_ord_ore': { builder: encryptedDateOrdOreColumn, ColumnClass: EncryptedDateOrdOreColumn, castAs: 'date', capabilities: ORD, indexes: ORE_IDX, samples: DATE_S }, - 'eql_v3.date_ord': { builder: encryptedDateOrdColumn, ColumnClass: EncryptedDateOrdColumn, castAs: 'date', capabilities: ORD, indexes: ORE_IDX, samples: DATE_S }, + 'eql_v3.date': { builder: types.Date, ColumnClass: EncryptedDateColumn, castAs: 'date', capabilities: STORAGE, indexes: NONE, samples: DATE_S }, + 'eql_v3.date_eq': { builder: types.DateEq, ColumnClass: EncryptedDateEqColumn, castAs: 'date', capabilities: EQ, indexes: UNIQUE_IDX, samples: DATE_S }, + 'eql_v3.date_ord_ore': { builder: types.DateOrdOre, ColumnClass: EncryptedDateOrdOreColumn, castAs: 'date', capabilities: ORD, indexes: ORE_IDX, samples: DATE_S }, + 'eql_v3.date_ord': { builder: types.DateOrd, ColumnClass: EncryptedDateOrdColumn, castAs: 'date', capabilities: ORD, indexes: ORE_IDX, samples: DATE_S }, // timestamptz - 'eql_v3.timestamptz': { builder: encryptedTimestamptzColumn, ColumnClass: EncryptedTimestamptzColumn, castAs: 'date', capabilities: STORAGE, indexes: NONE, samples: DATE_S }, - 'eql_v3.timestamptz_eq': { builder: encryptedTimestamptzEqColumn, ColumnClass: EncryptedTimestamptzEqColumn, castAs: 'date', capabilities: EQ, indexes: UNIQUE_IDX, samples: DATE_S }, - 'eql_v3.timestamptz_ord_ore': { builder: encryptedTimestamptzOrdOreColumn, ColumnClass: EncryptedTimestamptzOrdOreColumn, castAs: 'date', capabilities: ORD, indexes: ORE_IDX, samples: DATE_S }, - 'eql_v3.timestamptz_ord': { builder: encryptedTimestamptzOrdColumn, ColumnClass: EncryptedTimestamptzOrdColumn, castAs: 'date', capabilities: ORD, indexes: ORE_IDX, samples: DATE_S }, + 'eql_v3.timestamptz': { builder: types.Timestamptz, ColumnClass: EncryptedTimestamptzColumn, castAs: 'date', capabilities: STORAGE, indexes: NONE, samples: DATE_S }, + 'eql_v3.timestamptz_eq': { builder: types.TimestamptzEq, ColumnClass: EncryptedTimestamptzEqColumn, castAs: 'date', capabilities: EQ, indexes: UNIQUE_IDX, samples: DATE_S }, + 'eql_v3.timestamptz_ord_ore': { builder: types.TimestamptzOrdOre, ColumnClass: EncryptedTimestamptzOrdOreColumn, castAs: 'date', capabilities: ORD, indexes: ORE_IDX, samples: DATE_S }, + 'eql_v3.timestamptz_ord': { builder: types.TimestamptzOrd, ColumnClass: EncryptedTimestamptzOrdColumn, castAs: 'date', capabilities: ORD, indexes: ORE_IDX, samples: DATE_S }, // numeric - 'eql_v3.numeric': { builder: encryptedNumericColumn, ColumnClass: EncryptedNumericColumn, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: NUMERIC_S, errorSamples: NUM_ERR }, - 'eql_v3.numeric_eq': { builder: encryptedNumericEqColumn, ColumnClass: EncryptedNumericEqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: NUMERIC_S, errorSamples: NUM_ERR }, - 'eql_v3.numeric_ord_ore': { builder: encryptedNumericOrdOreColumn, ColumnClass: EncryptedNumericOrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: NUMERIC_S, errorSamples: NUM_ERR }, - 'eql_v3.numeric_ord': { builder: encryptedNumericOrdColumn, ColumnClass: EncryptedNumericOrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: NUMERIC_S, errorSamples: NUM_ERR }, + 'eql_v3.numeric': { builder: types.Numeric, ColumnClass: EncryptedNumericColumn, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: NUMERIC_S, errorSamples: NUM_ERR }, + 'eql_v3.numeric_eq': { builder: types.NumericEq, ColumnClass: EncryptedNumericEqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: NUMERIC_S, errorSamples: NUM_ERR }, + 'eql_v3.numeric_ord_ore': { builder: types.NumericOrdOre, ColumnClass: EncryptedNumericOrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: NUMERIC_S, errorSamples: NUM_ERR }, + 'eql_v3.numeric_ord': { builder: types.NumericOrd, ColumnClass: EncryptedNumericOrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: NUMERIC_S, errorSamples: NUM_ERR }, // text - 'eql_v3.text': { builder: encryptedTextColumn, ColumnClass: EncryptedTextColumn, castAs: 'string', capabilities: STORAGE, indexes: NONE, samples: TEXT_S }, - 'eql_v3.text_eq': { builder: encryptedTextEqColumn, ColumnClass: EncryptedTextEqColumn, castAs: 'string', capabilities: EQ, indexes: UNIQUE_IDX, samples: TEXT_S }, - 'eql_v3.text_match': { builder: encryptedTextMatchColumn, ColumnClass: EncryptedTextMatchColumn, castAs: 'string', capabilities: MATCH_ONLY, indexes: MATCH_IDX, samples: TEXT_S }, - 'eql_v3.text_ord_ore': { builder: encryptedTextOrdOreColumn, ColumnClass: EncryptedTextOrdOreColumn, castAs: 'string', capabilities: ORD, indexes: ORE_IDX, samples: TEXT_S }, - 'eql_v3.text_ord': { builder: encryptedTextOrdColumn, ColumnClass: EncryptedTextOrdColumn, castAs: 'string', capabilities: ORD, indexes: ORE_IDX, samples: TEXT_S }, - 'eql_v3.text_search': { builder: encryptedTextSearchColumn, ColumnClass: EncryptedTextSearchColumn, castAs: 'string', capabilities: FULL, indexes: TEXT_SEARCH_IDX, samples: TEXT_S }, + 'eql_v3.text': { builder: types.Text, ColumnClass: EncryptedTextColumn, castAs: 'string', capabilities: STORAGE, indexes: NONE, samples: TEXT_S }, + 'eql_v3.text_eq': { builder: types.TextEq, ColumnClass: EncryptedTextEqColumn, castAs: 'string', capabilities: EQ, indexes: UNIQUE_IDX, samples: TEXT_S }, + 'eql_v3.text_match': { builder: types.TextMatch, ColumnClass: EncryptedTextMatchColumn, castAs: 'string', capabilities: MATCH_ONLY, indexes: MATCH_IDX, samples: TEXT_S }, + 'eql_v3.text_ord_ore': { builder: types.TextOrdOre, ColumnClass: EncryptedTextOrdOreColumn, castAs: 'string', capabilities: ORD, indexes: TEXT_ORD_IDX, samples: TEXT_S }, + 'eql_v3.text_ord': { builder: types.TextOrd, ColumnClass: EncryptedTextOrdColumn, castAs: 'string', capabilities: ORD, indexes: TEXT_ORD_IDX, samples: TEXT_S }, + 'eql_v3.text_search': { builder: types.TextSearch, ColumnClass: EncryptedTextSearchColumn, castAs: 'string', capabilities: FULL, indexes: TEXT_SEARCH_IDX, samples: TEXT_S }, // bool - 'eql_v3.bool': { builder: encryptedBoolColumn, ColumnClass: EncryptedBoolColumn, castAs: 'boolean', capabilities: STORAGE, indexes: NONE, samples: BOOL_S }, + 'eql_v3.bool': { builder: types.Bool, ColumnClass: EncryptedBoolColumn, castAs: 'boolean', capabilities: STORAGE, indexes: NONE, samples: BOOL_S }, // float4 - 'eql_v3.float4': { builder: encryptedFloat4Column, ColumnClass: EncryptedFloat4Column, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: FLOAT4_S, errorSamples: NUM_ERR }, - 'eql_v3.float4_eq': { builder: encryptedFloat4EqColumn, ColumnClass: EncryptedFloat4EqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: FLOAT4_S, errorSamples: NUM_ERR }, - 'eql_v3.float4_ord_ore': { builder: encryptedFloat4OrdOreColumn, ColumnClass: EncryptedFloat4OrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: FLOAT4_S, errorSamples: NUM_ERR }, - 'eql_v3.float4_ord': { builder: encryptedFloat4OrdColumn, ColumnClass: EncryptedFloat4OrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: FLOAT4_S, errorSamples: NUM_ERR }, + 'eql_v3.float4': { builder: types.Float4, ColumnClass: EncryptedFloat4Column, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: FLOAT4_S, errorSamples: NUM_ERR }, + 'eql_v3.float4_eq': { builder: types.Float4Eq, ColumnClass: EncryptedFloat4EqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: FLOAT4_S, errorSamples: NUM_ERR }, + 'eql_v3.float4_ord_ore': { builder: types.Float4OrdOre, ColumnClass: EncryptedFloat4OrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: FLOAT4_S, errorSamples: NUM_ERR }, + 'eql_v3.float4_ord': { builder: types.Float4Ord, ColumnClass: EncryptedFloat4OrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: FLOAT4_S, errorSamples: NUM_ERR }, // float8 - 'eql_v3.float8': { builder: encryptedFloat8Column, ColumnClass: EncryptedFloat8Column, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: FLOAT8_S, errorSamples: NUM_ERR }, - 'eql_v3.float8_eq': { builder: encryptedFloat8EqColumn, ColumnClass: EncryptedFloat8EqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: FLOAT8_S, errorSamples: NUM_ERR }, - 'eql_v3.float8_ord_ore': { builder: encryptedFloat8OrdOreColumn, ColumnClass: EncryptedFloat8OrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: FLOAT8_S, errorSamples: NUM_ERR }, - 'eql_v3.float8_ord': { builder: encryptedFloat8OrdColumn, ColumnClass: EncryptedFloat8OrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: FLOAT8_S, errorSamples: NUM_ERR }, + 'eql_v3.float8': { builder: types.Float8, ColumnClass: EncryptedFloat8Column, castAs: 'number', capabilities: STORAGE, indexes: NONE, samples: FLOAT8_S, errorSamples: NUM_ERR }, + 'eql_v3.float8_eq': { builder: types.Float8Eq, ColumnClass: EncryptedFloat8EqColumn, castAs: 'number', capabilities: EQ, indexes: UNIQUE_IDX, samples: FLOAT8_S, errorSamples: NUM_ERR }, + 'eql_v3.float8_ord_ore': { builder: types.Float8OrdOre, ColumnClass: EncryptedFloat8OrdOreColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: FLOAT8_S, errorSamples: NUM_ERR }, + 'eql_v3.float8_ord': { builder: types.Float8Ord, ColumnClass: EncryptedFloat8OrdColumn, castAs: 'number', capabilities: ORD, indexes: ORE_IDX, samples: FLOAT8_S, errorSamples: NUM_ERR }, } as const satisfies Record<EqlV3TypeName, DomainSpec> diff --git a/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts b/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts index 9c6e64bf..cdaa95db 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts @@ -9,13 +9,9 @@ */ import { describe, expectTypeOf, it } from 'vitest' import type { EncryptionClient } from '@/encryption' -import { - encryptedTable, - encryptedTextEqColumn, - typedClient, -} from '@/encryption/v3' +import { encryptedTable, typedClient, types } from '@/encryption/v3' -const users = encryptedTable('u', { email: encryptedTextEqColumn('email') }) +const users = encryptedTable('u', { email: types.TextEq('email') }) declare const client: EncryptionClient const typed = typedClient(client, users) diff --git a/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts b/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts index 9921c208..8bba5162 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts @@ -7,12 +7,7 @@ */ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' -import { - EncryptionV3, - encryptedInt4OrdColumn, - encryptedTable, - encryptedTextEqColumn, -} from '@/encryption/v3' +import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' import { unwrapResult } from '../fixtures' const LIVE_CIPHERSTASH_ENABLED = Boolean( @@ -24,8 +19,8 @@ const LIVE_CIPHERSTASH_ENABLED = Boolean( const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip const people = encryptedTable('v3_bulk_people', { - nickname: encryptedTextEqColumn('nickname'), - age: encryptedInt4OrdColumn('age'), + nickname: types.TextEq('nickname'), + age: types.Int4Ord('age'), }) describeLive('v3 typed client bulk-at-scale (live)', () => { diff --git a/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts b/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts index c1384d2e..2dcef6c4 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts @@ -8,11 +8,7 @@ */ import 'dotenv/config' import { beforeAll, describe, expect, it } from 'vitest' -import { - EncryptionV3, - encryptedTable, - encryptedTextEqColumn, -} from '@/encryption/v3' +import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' import { LockContext } from '@/identity' import { unwrapResult } from '../fixtures' @@ -25,7 +21,7 @@ const LIVE_CIPHERSTASH_ENABLED = Boolean( const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip const users = encryptedTable('v3_identity_live_users', { - email: encryptedTextEqColumn('email'), + email: types.TextEq('email'), }) describeLive('v3 typed client identity-aware operations (live)', () => { diff --git a/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts b/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts index c92ec487..b78b7e5d 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts @@ -6,15 +6,11 @@ import 'dotenv/config' import { ensureKeyset } from '@cipherstash/protect-ffi' import { beforeAll, describe, expect, it } from 'vitest' -import { - EncryptionV3, - encryptedTable, - encryptedTextEqColumn, -} from '@/encryption/v3' +import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' import { unwrapResult } from '../fixtures' const users = encryptedTable('v3_keyset_users', { - email: encryptedTextEqColumn('email'), + email: types.TextEq('email'), }) const LIVE_CIPHERSTASH_ENABLED = Boolean( diff --git a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts b/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts index 21570347..2a9aae11 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts @@ -50,19 +50,15 @@ const LIVE_EQL_V3_PG_ENABLED = Boolean( process.env.CS_CLIENT_KEY && process.env.CS_CLIENT_ACCESS_KEY, ) -// SKIPPED (CI run 28569708268, PR #540): `beforeAll` crashes with -// `PostgresError: invalid input syntax for type json` on the dynamic 35-column -// INSERT, before any of the 35 per-domain cases run. Root cause not yet -// pinned — the CI log's stack trace bottoms out inside postgres.js's -// connection handler with no frame back to this file or the offending -// parameter/domain, and the identical ciphertext values round-trip fine via -// FFI-only in the sibling `matrix-live.test.ts`, so the break is specific to -// how this file hands them to Postgres. Needs live query/parameter logging or -// a local repro against a real `eql_v3` install to isolate before fixing. -// Force-skipped (not just gated on credentials) until then — swap back to -// `LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip` once fixed. -void LIVE_EQL_V3_PG_ENABLED -const describeLivePg = describe.skip +// Previously force-skipped (CI run 28569708268, PR #540): `beforeAll` crashed +// with `PostgresError: invalid input syntax for type json` on the dynamic +// 35-column INSERT. Root cause was a postgres.js serialization gap — a bare +// ciphertext object stringified to `"[object Object]"` — now fixed by wrapping +// every INSERT param in `sql.json(...)` (see `beforeAll`; the fix landed right +// after the skip and the skip was simply left stale). Re-enabled here as an +// ordinary credential-gated suite: it runs in CI (which supplies DATABASE_URL + +// CS_* creds) and self-skips locally when they are absent. +const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip const databaseUrl = process.env.DATABASE_URL const sql = LIVE_EQL_V3_PG_ENABLED diff --git a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts index f1a697ab..d8041e33 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts +++ b/packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts @@ -37,14 +37,10 @@ vi.mock('@cipherstash/protect-ffi', () => ({ })) import * as ffi from '@cipherstash/protect-ffi' -import { - encryptedTable, - encryptedTextEqColumn, - typedClient, -} from '@/encryption/v3' +import { encryptedTable, typedClient, types } from '@/encryption/v3' const users = encryptedTable('users', { - email: encryptedTextEqColumn('email'), + email: types.TextEq('email'), }) const IDENTITY_CLAIM = { identityClaim: ['sub'] } diff --git a/packages/stack/__tests__/v3-matrix/matrix.test-d.ts b/packages/stack/__tests__/v3-matrix/matrix.test-d.ts index 60e54271..5a6e7c02 100644 --- a/packages/stack/__tests__/v3-matrix/matrix.test-d.ts +++ b/packages/stack/__tests__/v3-matrix/matrix.test-d.ts @@ -16,7 +16,7 @@ import { type InferPlaintext, type QueryableColumnsOf, type QueryTypesForColumn, -} from '@/schema/v3' +} from '@/eql/v3' import { type EqlV3TypeName, V3_MATRIX } from './catalog' // One mixed-tier table spanning every capability tier + plaintext axis, built diff --git a/packages/stack/__tests__/wasm-inline-column-name.test.ts b/packages/stack/__tests__/wasm-inline-column-name.test.ts index de96afd0..ee03ecf1 100644 --- a/packages/stack/__tests__/wasm-inline-column-name.test.ts +++ b/packages/stack/__tests__/wasm-inline-column-name.test.ts @@ -17,8 +17,8 @@ vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ newClient: vi.fn(), })) +import { types } from '../src/eql/v3' import { encryptedColumn, encryptedField } from '../src/schema' -import { encryptedTextSearchColumn } from '../src/schema/v3' import { getColumnName } from '../src/wasm-inline' describe('wasm-inline getColumnName', () => { @@ -36,7 +36,7 @@ describe('wasm-inline getColumnName', () => { // entry, but the old `instanceof EncryptedColumn || EncryptedField` gate // threw at runtime. The entry now resolves the name structurally so a v3 // column genuinely round-trips through WasmEncryptionClient.encrypt(). - expect(getColumnName(encryptedTextSearchColumn('email'))).toBe('email') + expect(getColumnName(types.TextSearch('email'))).toBe('email') }) it('throws when given a value that does not expose getName()', () => { diff --git a/packages/stack/__tests__/wasm-inline-new-client.test.ts b/packages/stack/__tests__/wasm-inline-new-client.test.ts new file mode 100644 index 00000000..b33c4d72 --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-new-client.test.ts @@ -0,0 +1,115 @@ +/** + * Offline coverage for the WASM `Encryption` factory's `newClient` call shape. + * + * protect-ffi 0.25 changed `newClient` from a two-argument form + * (`newClient(strategy, options)`) to a single options object with the + * strategy nested under `strategy`: + * `newClient({ strategy, encryptConfig, clientId, clientKey })`. + * + * `wasm-inline.ts` performs that migration, but the only end-to-end exercise + * of the factory is the Deno e2e (`e2e/wasm/roundtrip.test.ts`), which skips + * without real `CS_*` secrets — so a regression in the call shape (e.g. + * reverting to the two-arg form, dropping `clientId`/`clientKey`, or failing to + * normalise `cast_as`) would pass the normal suite. These tests mock the WASM + * bindings and assert the exact argument object handed to `wasmNewClient`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@cipherstash/auth/wasm-inline', () => ({ + AccessKeyStrategy: { + create: vi.fn(() => ({ __mock: 'access-key-strategy' })), + }, + OidcFederationStrategy: class {}, +})) + +vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ + newClient: vi.fn(async () => ({ __mock: 'wasm-client' })), + encrypt: vi.fn(), + decrypt: vi.fn(), + isEncrypted: vi.fn(), +})) + +import { newClient as wasmNewClient } from '@cipherstash/protect-ffi/wasm-inline' +import { Encryption, encryptedColumn, encryptedTable } from '../src/wasm-inline' + +const CRN = 'crn:ap-southeast-2.aws:test-workspace' + +const users = encryptedTable('users', { + email: encryptedColumn('email'), +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('wasm-inline Encryption → newClient (protect-ffi 0.25 single-object form)', () => { + it('calls newClient with a single options object, not the 0.24 two-arg form', async () => { + await Encryption({ + schemas: [users], + config: { + workspaceCrn: CRN, + accessKey: 'CSAK.test', + clientId: 'cid', + clientKey: 'ckey', + }, + }) + + expect(vi.mocked(wasmNewClient)).toHaveBeenCalledTimes(1) + // The 0.24 form passed the strategy as a separate first positional arg. + // The 0.25 form is a single object — guard against regressing to two args. + const call = vi.mocked(wasmNewClient).mock.calls[0] + expect(call).toHaveLength(1) + }) + + it('nests the resolved strategy and forwards clientId / clientKey', async () => { + await Encryption({ + schemas: [users], + config: { + workspaceCrn: CRN, + accessKey: 'CSAK.test', + clientId: 'cid', + clientKey: 'ckey', + }, + }) + + // biome-ignore lint/suspicious/noExplicitAny: reading the recorded single options object + const arg = vi.mocked(wasmNewClient).mock.calls[0][0] as any + expect(arg.strategy).toEqual({ __mock: 'access-key-strategy' }) + expect(arg.clientId).toBe('cid') + expect(arg.clientKey).toBe('ckey') + }) + + it('passes a cast_as-normalised encryptConfig (SDK "string" → EQL "text")', async () => { + // `encryptedColumn('email')` defaults to `cast_as: 'string'`; the WASM + // client only accepts EQL-native variants, so the factory must run the + // config through `normalizeCastAs` before handing it to `newClient`. + await Encryption({ + schemas: [users], + config: { + workspaceCrn: CRN, + accessKey: 'CSAK.test', + clientId: 'cid', + clientKey: 'ckey', + }, + }) + + // biome-ignore lint/suspicious/noExplicitAny: navigating the recorded encryptConfig + const arg = vi.mocked(wasmNewClient).mock.calls[0][0] as any + expect(arg.encryptConfig).toBeDefined() + expect(arg.encryptConfig.tables.users.email.cast_as).toBe('text') + }) + + it('uses an explicit config.strategy verbatim on the strategy path', async () => { + const explicit = { getToken: vi.fn() } + await Encryption({ + schemas: [users], + // biome-ignore lint/suspicious/noExplicitAny: exercise the strategy arm of the config union + config: { strategy: explicit, clientId: 'cid', clientKey: 'ckey' } as any, + }) + + // biome-ignore lint/suspicious/noExplicitAny: reading the recorded single options object + const arg = vi.mocked(wasmNewClient).mock.calls[0][0] as any + expect(arg.strategy).toBe(explicit) + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-strategy.test.ts b/packages/stack/__tests__/wasm-inline-strategy.test.ts index 84c2f1c2..4880bc43 100644 --- a/packages/stack/__tests__/wasm-inline-strategy.test.ts +++ b/packages/stack/__tests__/wasm-inline-strategy.test.ts @@ -65,11 +65,15 @@ describe('wasm-inline resolveStrategy', () => { expect(() => // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — no strategy, no accessKey resolveStrategy({ workspaceCrn: CRN } as any), - ).toThrowError(/`config\.workspaceCrn` and `config\.accessKey` are required/) + ).toThrowError( + /`config\.workspaceCrn` and `config\.accessKey` are required/, + ) expect(() => // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — no strategy, no workspaceCrn resolveStrategy({ accessKey: 'CSAK.test' } as any), - ).toThrowError(/`config\.workspaceCrn` and `config\.accessKey` are required/) + ).toThrowError( + /`config\.workspaceCrn` and `config\.accessKey` are required/, + ) // The guard must short-circuit *before* building a strategy. expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled() }) diff --git a/packages/stack/package.json b/packages/stack/package.json index 9ce6281d..9163f6da 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -51,8 +51,8 @@ "schema": [ "./dist/schema/index.d.ts" ], - "schema/v3": [ - "./dist/schema/v3/index.d.ts" + "eql/v3": [ + "./dist/eql/v3/index.d.ts" ], "v3": [ "./dist/encryption/v3.d.ts" @@ -125,14 +125,14 @@ "default": "./dist/schema/index.cjs" } }, - "./schema/v3": { + "./eql/v3": { "import": { - "types": "./dist/schema/v3/index.d.ts", - "default": "./dist/schema/v3/index.js" + "types": "./dist/eql/v3/index.d.ts", + "default": "./dist/eql/v3/index.js" }, "require": { - "types": "./dist/schema/v3/index.d.cts", - "default": "./dist/schema/v3/index.cjs" + "types": "./dist/eql/v3/index.d.cts", + "default": "./dist/eql/v3/index.cjs" } }, "./v3": { @@ -217,7 +217,7 @@ "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "tsup", "dev": "tsup --watch", - "analyze:complexity": "fta src/schema/v3 --score-cap 72", + "analyze:complexity": "fta src/eql/v3 --score-cap 72", "db:eql-v3:install": "tsx scripts/install-eql-v3.ts", "test": "vitest run", "test:types": "vitest --run --typecheck.only", diff --git a/packages/stack/src/encryption/v3.ts b/packages/stack/src/encryption/v3.ts index bff929f7..ae8e667c 100644 --- a/packages/stack/src/encryption/v3.ts +++ b/packages/stack/src/encryption/v3.ts @@ -1,6 +1,4 @@ import type { Result } from '@byteslice/result' -import type { EncryptionError } from '@/errors' -import type { LockContextInput } from '@/identity' import type { AnyV3Table, ColumnsOf, @@ -10,7 +8,9 @@ import type { V3DecryptedModel, V3EncryptedModel, V3ModelInput, -} from '@/schema/v3' +} from '@/eql/v3' +import type { EncryptionError } from '@/errors' +import type { LockContextInput } from '@/identity' import type { BulkDecryptPayload, BulkEncryptPayload, @@ -197,9 +197,9 @@ export function typedClient<const S extends readonly AnyV3Table[]>( * * @example * ```typescript - * import { EncryptionV3, encryptedTable, encryptedTextSearchColumn } from "@cipherstash/stack/v3" + * import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3" * - * const users = encryptedTable("users", { email: encryptedTextSearchColumn("email") }) + * const users = encryptedTable("users", { email: types.TextSearch("email") }) * const client = await EncryptionV3({ schemas: [users] }) * * await client.encrypt("a@b.com", { table: users, column: users.email }) @@ -220,6 +220,7 @@ export async function EncryptionV3< return typedClient(client, ...config.schemas) } -// Single import surface: re-export the v3 builders + type helpers so -// `@cipherstash/stack/v3` provides everything needed to author and use a schema. -export * from '@/schema/v3' +// Single import surface: re-export the v3 `types` namespace + table API + type +// helpers so `@cipherstash/stack/v3` provides everything needed to author and +// use a schema. +export * from '@/eql/v3' diff --git a/packages/stack/src/schema/v3/index.ts b/packages/stack/src/eql/v3/columns.ts similarity index 60% rename from packages/stack/src/schema/v3/index.ts rename to packages/stack/src/eql/v3/columns.ts index 77ab62b8..1172a555 100644 --- a/packages/stack/src/schema/v3/index.ts +++ b/packages/stack/src/eql/v3/columns.ts @@ -1,5 +1,4 @@ -import type { ColumnSchema, EncryptConfig, MatchIndexOpts } from '@/schema' -import type { Encrypted } from '@/types' +import type { ColumnSchema, MatchIndexOpts } from '@/schema' /** * The query capabilities a v3 concrete domain exposes. These are SDK-facing @@ -86,43 +85,46 @@ export const TEXT_SEARCH_EQL_TYPE = 'eql_v3.text_search' // by `typeof <CONST>`; the literal `eqlType`/`castAs`/`capabilities` on each is // what makes the otherwise-empty subclasses nominally distinct (see // V3DomainDefinition). Order mirrors eql-bindings `CATALOG` order. -const INT4 = { +// +// Exported for the `types` namespace factory (see ./types); they are internal +// building blocks and are intentionally NOT re-exported from the public barrel. +export const INT4 = { eqlType: 'eql_v3.int4', castAs: 'number', capabilities: STORAGE_ONLY, } as const -const INT4_EQ = { +export const INT4_EQ = { eqlType: 'eql_v3.int4_eq', castAs: 'number', capabilities: EQUALITY_ONLY, } as const -const INT4_ORD_ORE = { +export const INT4_ORD_ORE = { eqlType: 'eql_v3.int4_ord_ore', castAs: 'number', capabilities: ORDER_AND_RANGE, } as const -const INT4_ORD = { +export const INT4_ORD = { eqlType: 'eql_v3.int4_ord', castAs: 'number', capabilities: ORDER_AND_RANGE, } as const -const INT2 = { +export const INT2 = { eqlType: 'eql_v3.int2', castAs: 'number', capabilities: STORAGE_ONLY, } as const -const INT2_EQ = { +export const INT2_EQ = { eqlType: 'eql_v3.int2_eq', castAs: 'number', capabilities: EQUALITY_ONLY, } as const -const INT2_ORD_ORE = { +export const INT2_ORD_ORE = { eqlType: 'eql_v3.int2_ord_ore', castAs: 'number', capabilities: ORDER_AND_RANGE, } as const -const INT2_ORD = { +export const INT2_ORD = { eqlType: 'eql_v3.int2_ord', castAs: 'number', capabilities: ORDER_AND_RANGE, @@ -135,138 +137,138 @@ const INT2_ORD = { // 2^53. Re-add INT8/INT8_EQ/INT8_ORD_ORE/INT8_ORD and their builders once the // FFI accepts a lossless bigint on input and returns it on decrypt. -const DATE = { +export const DATE = { eqlType: 'eql_v3.date', castAs: 'date', capabilities: STORAGE_ONLY, } as const -const DATE_EQ = { +export const DATE_EQ = { eqlType: 'eql_v3.date_eq', castAs: 'date', capabilities: EQUALITY_ONLY, } as const -const DATE_ORD_ORE = { +export const DATE_ORD_ORE = { eqlType: 'eql_v3.date_ord_ore', castAs: 'date', capabilities: ORDER_AND_RANGE, } as const -const DATE_ORD = { +export const DATE_ORD = { eqlType: 'eql_v3.date_ord', castAs: 'date', capabilities: ORDER_AND_RANGE, } as const -const TIMESTAMPTZ = { +export const TIMESTAMPTZ = { eqlType: 'eql_v3.timestamptz', castAs: 'date', capabilities: STORAGE_ONLY, } as const -const TIMESTAMPTZ_EQ = { +export const TIMESTAMPTZ_EQ = { eqlType: 'eql_v3.timestamptz_eq', castAs: 'date', capabilities: EQUALITY_ONLY, } as const -const TIMESTAMPTZ_ORD_ORE = { +export const TIMESTAMPTZ_ORD_ORE = { eqlType: 'eql_v3.timestamptz_ord_ore', castAs: 'date', capabilities: ORDER_AND_RANGE, } as const -const TIMESTAMPTZ_ORD = { +export const TIMESTAMPTZ_ORD = { eqlType: 'eql_v3.timestamptz_ord', castAs: 'date', capabilities: ORDER_AND_RANGE, } as const -const NUMERIC = { +export const NUMERIC = { eqlType: 'eql_v3.numeric', castAs: 'number', capabilities: STORAGE_ONLY, } as const -const NUMERIC_EQ = { +export const NUMERIC_EQ = { eqlType: 'eql_v3.numeric_eq', castAs: 'number', capabilities: EQUALITY_ONLY, } as const -const NUMERIC_ORD_ORE = { +export const NUMERIC_ORD_ORE = { eqlType: 'eql_v3.numeric_ord_ore', castAs: 'number', capabilities: ORDER_AND_RANGE, } as const -const NUMERIC_ORD = { +export const NUMERIC_ORD = { eqlType: 'eql_v3.numeric_ord', castAs: 'number', capabilities: ORDER_AND_RANGE, } as const -const TEXT = { +export const TEXT = { eqlType: 'eql_v3.text', castAs: 'string', capabilities: STORAGE_ONLY, } as const -const TEXT_EQ = { +export const TEXT_EQ = { eqlType: 'eql_v3.text_eq', castAs: 'string', capabilities: EQUALITY_ONLY, } as const -const TEXT_MATCH = { +export const TEXT_MATCH = { eqlType: 'eql_v3.text_match', castAs: 'string', capabilities: MATCH_ONLY, } as const -const TEXT_ORD_ORE = { +export const TEXT_ORD_ORE = { eqlType: 'eql_v3.text_ord_ore', castAs: 'string', capabilities: ORDER_AND_RANGE, } as const -const TEXT_ORD = { +export const TEXT_ORD = { eqlType: 'eql_v3.text_ord', castAs: 'string', capabilities: ORDER_AND_RANGE, } as const -const BOOL = { +export const BOOL = { eqlType: 'eql_v3.bool', castAs: 'boolean', capabilities: STORAGE_ONLY, } as const -const FLOAT4 = { +export const FLOAT4 = { eqlType: 'eql_v3.float4', castAs: 'number', capabilities: STORAGE_ONLY, } as const -const FLOAT4_EQ = { +export const FLOAT4_EQ = { eqlType: 'eql_v3.float4_eq', castAs: 'number', capabilities: EQUALITY_ONLY, } as const -const FLOAT4_ORD_ORE = { +export const FLOAT4_ORD_ORE = { eqlType: 'eql_v3.float4_ord_ore', castAs: 'number', capabilities: ORDER_AND_RANGE, } as const -const FLOAT4_ORD = { +export const FLOAT4_ORD = { eqlType: 'eql_v3.float4_ord', castAs: 'number', capabilities: ORDER_AND_RANGE, } as const -const FLOAT8 = { +export const FLOAT8 = { eqlType: 'eql_v3.float8', castAs: 'number', capabilities: STORAGE_ONLY, } as const -const FLOAT8_EQ = { +export const FLOAT8_EQ = { eqlType: 'eql_v3.float8_eq', castAs: 'number', capabilities: EQUALITY_ONLY, } as const -const FLOAT8_ORD_ORE = { +export const FLOAT8_ORD_ORE = { eqlType: 'eql_v3.float8_ord_ore', castAs: 'number', capabilities: ORDER_AND_RANGE, } as const -const FLOAT8_ORD = { +export const FLOAT8_ORD = { eqlType: 'eql_v3.float8_ord', castAs: 'number', capabilities: ORDER_AND_RANGE, @@ -314,21 +316,33 @@ function defaultMatchOpts(): BuiltMatchIndexOpts { } /** - * Translate a domain's semantic {@link QueryCapabilities} into the concrete EQL - * index block emitted by `build()`. + * Translate a domain's semantic {@link QueryCapabilities} (plus its plaintext + * `castAs`, which decides how equality is answered) into the concrete EQL index + * block emitted by `build()`. * - * - equality WITHOUT order/range → `unique` (the `hm` HMAC index). - * - order/range → `ore` ONLY. The EQL `ob` key supports both equality and - * range, so an order-capable column does NOT also emit `unique`. - * - free-text search → `match` (the `bf` bloom-filter index), deep-cloned from + * - `unique` (the `hm` HMAC index) whenever equality is answered via HMAC: + * equality-only domains of ANY type, AND text order domains. Text equality is + * HMAC-based — the `eql_v3.text_ord` / `eql_v3.text_ord_ore` SQL domains + * REQUIRE `hm` in the stored ciphertext (their `eql_v3.eq_term` extracts it). + * - `ore` for any order/range domain (the `ob` term). For numeric/date order + * domains `ob` also answers equality (via the SQL `=` operator), so those emit + * `ore` ONLY — no `hm`. Text order domains emit BOTH `unique` and `ore`. + * - `match` (the `bf` bloom-filter index) for free-text search, deep-cloned from * the per-call defaults so no nested object is ever shared across columns. */ function indexesForCapabilities( capabilities: QueryCapabilities, + castAs: PlaintextKind, ): ColumnSchema['indexes'] { const indexes: ColumnSchema['indexes'] = {} - if (capabilities.equality && !capabilities.orderAndRange) { + // Text equality is always HMAC-based, so a text order domain (`string` + + // order/range) still needs `unique`; numeric/date order domains answer + // equality via `ob` and must NOT emit `unique`. + if ( + capabilities.equality && + (!capabilities.orderAndRange || castAs === 'string') + ) { indexes.unique = { token_filters: [] } } @@ -365,7 +379,7 @@ function isQueryableCapabilities(capabilities: QueryCapabilities): boolean { * `EncryptedDateColumn`, both storage-only) are NOT mutually assignable. This * nominality is what keeps plaintext inference precise. */ -class EncryptedV3Column<D extends V3DomainDefinition> { +export class EncryptedV3Column<D extends V3DomainDefinition> { constructor( private readonly columnName: string, private readonly definition: D, @@ -396,7 +410,10 @@ class EncryptedV3Column<D extends V3DomainDefinition> { build(): ColumnSchema { return { cast_as: this.definition.castAs, - indexes: indexesForCapabilities(this.definition.capabilities), + indexes: indexesForCapabilities( + this.definition.capabilities, + this.definition.castAs, + ), } } } @@ -483,70 +500,35 @@ export class EncryptedTextSearchColumn extends EncryptedV3Column< } } -/** - * Define an `eql_v3.text_search` column. The concrete type carries all three - * capabilities (equality + order/range + free-text match). Chain - * `.freeTextSearch(opts)` to tune the match index. - * - * Querying defaults to EQUALITY — pass `queryType: 'freeTextSearch'` to - * `encryptQuery` for free-text match. See {@link EncryptedTextSearchColumn}. - */ -export function encryptedTextSearchColumn( - columnName: string, -): EncryptedTextSearchColumn { - return new EncryptedTextSearchColumn(columnName) -} - // --------------------------------------------------------------------------- -// Concrete domain columns and builders +// Concrete domain columns // // Every concrete class is an empty subclass parameterised by its literal domain -// definition (see EncryptedV3Column). The paired builder passes the SAME literal -// constant so the instance's private `definition` field carries full literal -// type data — that is what keeps distinct domains nominally incompatible. +// definition (see EncryptedV3Column). The `types` namespace (see ./types) +// constructs these with the SAME literal constant so the instance's private +// `definition` field carries full literal type data — that is what keeps +// distinct domains nominally incompatible. // --------------------------------------------------------------------------- // int4 export class EncryptedInt4Column extends EncryptedV3Column<typeof INT4> {} -export const encryptedInt4Column = (columnName: string) => - new EncryptedInt4Column(columnName, INT4) - export class EncryptedInt4EqColumn extends EncryptedV3Column<typeof INT4_EQ> {} -export const encryptedInt4EqColumn = (columnName: string) => - new EncryptedInt4EqColumn(columnName, INT4_EQ) - export class EncryptedInt4OrdOreColumn extends EncryptedV3Column< typeof INT4_ORD_ORE > {} -export const encryptedInt4OrdOreColumn = (columnName: string) => - new EncryptedInt4OrdOreColumn(columnName, INT4_ORD_ORE) - export class EncryptedInt4OrdColumn extends EncryptedV3Column< typeof INT4_ORD > {} -export const encryptedInt4OrdColumn = (columnName: string) => - new EncryptedInt4OrdColumn(columnName, INT4_ORD) // int2 export class EncryptedInt2Column extends EncryptedV3Column<typeof INT2> {} -export const encryptedInt2Column = (columnName: string) => - new EncryptedInt2Column(columnName, INT2) - export class EncryptedInt2EqColumn extends EncryptedV3Column<typeof INT2_EQ> {} -export const encryptedInt2EqColumn = (columnName: string) => - new EncryptedInt2EqColumn(columnName, INT2_EQ) - export class EncryptedInt2OrdOreColumn extends EncryptedV3Column< typeof INT2_ORD_ORE > {} -export const encryptedInt2OrdOreColumn = (columnName: string) => - new EncryptedInt2OrdOreColumn(columnName, INT2_ORD_ORE) - export class EncryptedInt2OrdColumn extends EncryptedV3Column< typeof INT2_ORD > {} -export const encryptedInt2OrdColumn = (columnName: string) => - new EncryptedInt2OrdColumn(columnName, INT2_ORD) // int8 (bigint) domain builders are intentionally omitted pending FFI support // for lossless bigint round-tripping — see the note by the INT4/DATE domain @@ -554,150 +536,79 @@ export const encryptedInt2OrdColumn = (columnName: string) => // date export class EncryptedDateColumn extends EncryptedV3Column<typeof DATE> {} -export const encryptedDateColumn = (columnName: string) => - new EncryptedDateColumn(columnName, DATE) - export class EncryptedDateEqColumn extends EncryptedV3Column<typeof DATE_EQ> {} -export const encryptedDateEqColumn = (columnName: string) => - new EncryptedDateEqColumn(columnName, DATE_EQ) - export class EncryptedDateOrdOreColumn extends EncryptedV3Column< typeof DATE_ORD_ORE > {} -export const encryptedDateOrdOreColumn = (columnName: string) => - new EncryptedDateOrdOreColumn(columnName, DATE_ORD_ORE) - export class EncryptedDateOrdColumn extends EncryptedV3Column< typeof DATE_ORD > {} -export const encryptedDateOrdColumn = (columnName: string) => - new EncryptedDateOrdColumn(columnName, DATE_ORD) // timestamptz export class EncryptedTimestamptzColumn extends EncryptedV3Column< typeof TIMESTAMPTZ > {} -export const encryptedTimestamptzColumn = (columnName: string) => - new EncryptedTimestamptzColumn(columnName, TIMESTAMPTZ) - export class EncryptedTimestamptzEqColumn extends EncryptedV3Column< typeof TIMESTAMPTZ_EQ > {} -export const encryptedTimestamptzEqColumn = (columnName: string) => - new EncryptedTimestamptzEqColumn(columnName, TIMESTAMPTZ_EQ) - export class EncryptedTimestamptzOrdOreColumn extends EncryptedV3Column< typeof TIMESTAMPTZ_ORD_ORE > {} -export const encryptedTimestamptzOrdOreColumn = (columnName: string) => - new EncryptedTimestamptzOrdOreColumn(columnName, TIMESTAMPTZ_ORD_ORE) - export class EncryptedTimestamptzOrdColumn extends EncryptedV3Column< typeof TIMESTAMPTZ_ORD > {} -export const encryptedTimestamptzOrdColumn = (columnName: string) => - new EncryptedTimestamptzOrdColumn(columnName, TIMESTAMPTZ_ORD) // numeric export class EncryptedNumericColumn extends EncryptedV3Column<typeof NUMERIC> {} -export const encryptedNumericColumn = (columnName: string) => - new EncryptedNumericColumn(columnName, NUMERIC) - export class EncryptedNumericEqColumn extends EncryptedV3Column< typeof NUMERIC_EQ > {} -export const encryptedNumericEqColumn = (columnName: string) => - new EncryptedNumericEqColumn(columnName, NUMERIC_EQ) - export class EncryptedNumericOrdOreColumn extends EncryptedV3Column< typeof NUMERIC_ORD_ORE > {} -export const encryptedNumericOrdOreColumn = (columnName: string) => - new EncryptedNumericOrdOreColumn(columnName, NUMERIC_ORD_ORE) - export class EncryptedNumericOrdColumn extends EncryptedV3Column< typeof NUMERIC_ORD > {} -export const encryptedNumericOrdColumn = (columnName: string) => - new EncryptedNumericOrdColumn(columnName, NUMERIC_ORD) -// text (text_search stays defined above with its match-tuning override) +// text (text_search is defined above with its match-tuning override) export class EncryptedTextColumn extends EncryptedV3Column<typeof TEXT> {} -export const encryptedTextColumn = (columnName: string) => - new EncryptedTextColumn(columnName, TEXT) - export class EncryptedTextEqColumn extends EncryptedV3Column<typeof TEXT_EQ> {} -export const encryptedTextEqColumn = (columnName: string) => - new EncryptedTextEqColumn(columnName, TEXT_EQ) - export class EncryptedTextMatchColumn extends EncryptedV3Column< typeof TEXT_MATCH > {} -export const encryptedTextMatchColumn = (columnName: string) => - new EncryptedTextMatchColumn(columnName, TEXT_MATCH) - export class EncryptedTextOrdOreColumn extends EncryptedV3Column< typeof TEXT_ORD_ORE > {} -export const encryptedTextOrdOreColumn = (columnName: string) => - new EncryptedTextOrdOreColumn(columnName, TEXT_ORD_ORE) - export class EncryptedTextOrdColumn extends EncryptedV3Column< typeof TEXT_ORD > {} -export const encryptedTextOrdColumn = (columnName: string) => - new EncryptedTextOrdColumn(columnName, TEXT_ORD) // bool export class EncryptedBoolColumn extends EncryptedV3Column<typeof BOOL> {} -export const encryptedBoolColumn = (columnName: string) => - new EncryptedBoolColumn(columnName, BOOL) // float4 export class EncryptedFloat4Column extends EncryptedV3Column<typeof FLOAT4> {} -export const encryptedFloat4Column = (columnName: string) => - new EncryptedFloat4Column(columnName, FLOAT4) - export class EncryptedFloat4EqColumn extends EncryptedV3Column< typeof FLOAT4_EQ > {} -export const encryptedFloat4EqColumn = (columnName: string) => - new EncryptedFloat4EqColumn(columnName, FLOAT4_EQ) - export class EncryptedFloat4OrdOreColumn extends EncryptedV3Column< typeof FLOAT4_ORD_ORE > {} -export const encryptedFloat4OrdOreColumn = (columnName: string) => - new EncryptedFloat4OrdOreColumn(columnName, FLOAT4_ORD_ORE) - export class EncryptedFloat4OrdColumn extends EncryptedV3Column< typeof FLOAT4_ORD > {} -export const encryptedFloat4OrdColumn = (columnName: string) => - new EncryptedFloat4OrdColumn(columnName, FLOAT4_ORD) // float8 export class EncryptedFloat8Column extends EncryptedV3Column<typeof FLOAT8> {} -export const encryptedFloat8Column = (columnName: string) => - new EncryptedFloat8Column(columnName, FLOAT8) - export class EncryptedFloat8EqColumn extends EncryptedV3Column< typeof FLOAT8_EQ > {} -export const encryptedFloat8EqColumn = (columnName: string) => - new EncryptedFloat8EqColumn(columnName, FLOAT8_EQ) - export class EncryptedFloat8OrdOreColumn extends EncryptedV3Column< typeof FLOAT8_ORD_ORE > {} -export const encryptedFloat8OrdOreColumn = (columnName: string) => - new EncryptedFloat8OrdOreColumn(columnName, FLOAT8_ORD_ORE) - export class EncryptedFloat8OrdColumn extends EncryptedV3Column< typeof FLOAT8_ORD > {} -export const encryptedFloat8OrdColumn = (columnName: string) => - new EncryptedFloat8OrdColumn(columnName, FLOAT8_ORD) /** * Union of every v3 concrete column type. Used as the value type for v3 table @@ -748,137 +659,6 @@ export type EncryptedV3TableColumn = { [key: string]: AnyEncryptedV3Column } -interface TableDefinition { - tableName: string - columns: Record<string, ColumnSchema> -} - -/** - * A v3 encrypted table. Mirrors the v2 `EncryptedTable` but only accepts v3 - * column builders. Emits the same `{ tableName, columns }` definition shape. - */ -export class EncryptedTable<T extends EncryptedV3TableColumn> { - /** @internal Type-level brand so TypeScript can infer `T` from `EncryptedTable<T>`. */ - declare readonly _columnType: T - - constructor( - public readonly tableName: string, - public readonly columnBuilders: T, - ) {} - - build(): TableDefinition { - const builtColumns: Record<string, ColumnSchema> = {} - for (const builder of Object.values(this.columnBuilders)) { - // Key by the column's DB name (`getName()`), NOT the JS property name. - // `encrypt`/`decrypt` look columns up in the config by `column.getName()`, - // so a camelCase JS key mapping to a snake_case DB column (e.g. - // `createdOn: encryptedDateColumn('created_on')`) must register under - // `created_on` or the FFI reports "column not found in Encrypt config". - builtColumns[builder.getName()] = builder.build() - } - return { - tableName: this.tableName, - columns: builtColumns, - } - } - - /** - * Map each column's JS property name to its DB column name (`getName()`). - * The model path matches user models by property name but must address the - * encrypt config and FFI by DB name — `build()` keys columns by DB name, so - * the two only agree when property == name. This recovers the mapping that - * `build()` discards. - */ - buildColumnKeyMap(): Record<string, string> { - const map: Record<string, string> = {} - for (const [property, builder] of Object.entries(this.columnBuilders)) { - map[property] = builder.getName() - } - return map - } -} - -/** - * Own instance members of {@link EncryptedTable} that a column name must not - * shadow. Because {@link encryptedTable} copies column builders onto the - * instance for accessor access (`users.email`), a column named e.g. `build` - * would otherwise overwrite the method that {@link buildEncryptConfig} relies - * on. `_columnType` is a type-only `declare` (no runtime property) so it is - * listed explicitly here rather than caught by the `in` check below. - */ -const RESERVED_TABLE_KEYS = new Set([ - 'tableName', - 'columnBuilders', - '_columnType', - 'build', - 'buildColumnKeyMap', -]) - -/** - * Whether a column name would collide with a reserved property on the table - * object — either an own member ({@link RESERVED_TABLE_KEYS}) or any inherited - * `Object.prototype` member (`constructor`, `toString`, `valueOf`, - * `hasOwnProperty`, …). The `in` check covers the prototype chain so assigning - * the column as an own property can never shadow a prototype method/accessor. - */ -function isReservedTableKey(tableBuilder: object, colName: string): boolean { - return RESERVED_TABLE_KEYS.has(colName) || colName in tableBuilder -} - -/** - * Define a v3 encrypted table. Intentionally shadows the v2 `encryptedTable` - * name but lives on the `/v3` subpath — the importer picks the model by import - * path. The returned object is also a column accessor (`users.email`). - */ -export function encryptedTable<T extends EncryptedV3TableColumn>( - tableName: string, - columns: T, -): EncryptedTable<T> & T { - const tableBuilder = new EncryptedTable( - tableName, - columns, - ) as EncryptedTable<T> & T - - for (const [colName, colBuilder] of Object.entries(columns)) { - if (isReservedTableKey(tableBuilder, colName)) { - throw new Error( - `Column name "${colName}" collides with a reserved EncryptedTable property`, - ) - } - ;(tableBuilder as EncryptedV3TableColumn)[colName] = colBuilder - } - - return tableBuilder -} - -/** - * Build an `EncryptConfig` (`v: 1`) from one or more v3 tables. Emits the same - * shape as v2's `buildEncryptConfig`. - */ -export function buildEncryptConfig( - ...tables: Array<EncryptedTable<EncryptedV3TableColumn>> -): EncryptConfig { - const config: EncryptConfig = { - v: 1, - tables: {}, - } - - for (const tb of tables) { - const tableDef = tb.build() - // Config tables are keyed by name, so a duplicate would silently overwrite - // the earlier table. Fail loudly instead. (v3-only additive guard; v2's - // buildEncryptConfig keeps its existing silent-overwrite behavior.) - if (Object.hasOwn(config.tables, tableDef.tableName)) { - throw new Error( - `[schema/v3]: duplicate table name "${tableDef.tableName}" passed to buildEncryptConfig — each table must have a unique name`, - ) - } - config.tables[tableDef.tableName] = tableDef.columns - } - - return config -} - /** Map a domain's {@link PlaintextKind} to its TypeScript plaintext type. */ type PlaintextFromKind<K extends PlaintextKind> = K extends 'string' ? string @@ -900,37 +680,6 @@ type PlaintextFromKind<K extends PlaintextKind> = K extends 'string' export type PlaintextForColumn<C> = C extends EncryptedV3Column<infer D> ? PlaintextFromKind<D['castAs']> : never -/** - * The concrete EQL v3 type string for a single column, read from the literal - * domain definition carried on the base class's private field (mirrors - * {@link PlaintextForColumn}). Distributes over a union of columns, so - * `EqlTypeForColumn<AnyEncryptedV3Column>` yields the union of every domain's - * `eqlType` — the canonical, source-of-truth key set for a type-driven test - * matrix keyed by domain. - */ -export type EqlTypeForColumn<C> = - C extends EncryptedV3Column<infer D> ? D['eqlType'] : never - -/** - * Infer the plaintext (decrypted) shape from a v3 table schema. Each column maps - * to the TypeScript type of its domain's `castAs` kind. - */ -export type InferPlaintext<T extends EncryptedTable<EncryptedV3TableColumn>> = - T extends EncryptedTable<infer C> - ? { [K in keyof C]: PlaintextForColumn<C[K]> } - : never - -/** - * Infer the encrypted shape from a v3 table schema. See {@link InferPlaintext} - * for why no key-remap filter is needed in the flat single-type model. - */ -export type InferEncrypted<T extends EncryptedTable<EncryptedV3TableColumn>> = - T extends EncryptedTable<infer C> ? { [K in keyof C]: Encrypted } : never - -// --------------------------------------------------------------------------- -// Typed-client surface helpers (@cipherstash/stack/v3) -// --------------------------------------------------------------------------- - /** * The user-facing `queryType` names a v3 column supports, derived 1:1 from its * capability flags. Resolves to `never` for a storage-only column (all flags @@ -949,55 +698,13 @@ export type QueryTypesForColumn<C> = : never) : never -/** Any v3 table, regardless of its column map. */ -export type AnyV3Table = EncryptedTable<EncryptedV3TableColumn> - -/** Union of the concrete column builders declared on a v3 table. */ -export type ColumnsOf<T extends AnyV3Table> = - T extends EncryptedTable<infer C> ? C[keyof C] : never - -/** - * Union of the *queryable* column builders on a v3 table. Storage-only columns - * (whose {@link QueryTypesForColumn} is `never`) are filtered out, so they can't - * be passed to a query method. - */ -export type QueryableColumnsOf<T extends AnyV3Table> = - T extends EncryptedTable<infer C> - ? { - [K in keyof C]: [QueryTypesForColumn<C[K]>] extends [never] - ? never - : C[K] - }[keyof C] - : never - /** - * The accepted input model for {@link import('@/encryption/v3').TypedEncryptionClient.encryptModel}. - * `T` is inferred from the argument: keys that name a schema column are pinned to - * the column's plaintext type (nullable if the field is nullable), so a wrong-typed - * field fails assignability; all other keys pass through unchanged. + * The concrete EQL v3 type string for a single column, read from the literal + * domain definition carried on the base class's private field (mirrors + * {@link PlaintextForColumn}). Distributes over a union of columns, so + * `EqlTypeForColumn<AnyEncryptedV3Column>` yields the union of every domain's + * `eqlType` — the canonical, source-of-truth key set for a type-driven test + * matrix keyed by domain. */ -export type V3ModelInput<Table extends AnyV3Table, T> = { - [K in keyof T]: K extends keyof InferPlaintext<Table> - ? null extends T[K] - ? InferPlaintext<Table>[K] | null - : InferPlaintext<Table>[K] - : T[K] -} - -/** The encrypted result model: schema columns become `Encrypted`, others pass through. */ -export type V3EncryptedModel<Table extends AnyV3Table, T> = { - [K in keyof T]: K extends keyof InferPlaintext<Table> - ? null extends T[K] - ? Encrypted | null - : Encrypted - : T[K] -} - -/** The decrypted result model: schema columns become their plaintext type, others pass through. */ -export type V3DecryptedModel<Table extends AnyV3Table, T> = { - [K in keyof T]: K extends keyof InferPlaintext<Table> - ? null extends T[K] - ? InferPlaintext<Table>[K] | null - : InferPlaintext<Table>[K] - : T[K] -} +export type EqlTypeForColumn<C> = + C extends EncryptedV3Column<infer D> ? D['eqlType'] : never diff --git a/packages/stack/src/eql/v3/index.ts b/packages/stack/src/eql/v3/index.ts new file mode 100644 index 00000000..629a2d2c --- /dev/null +++ b/packages/stack/src/eql/v3/index.ts @@ -0,0 +1,70 @@ +// Public barrel for the EQL v3 authoring DSL (`@cipherstash/stack/eql/v3`). +// +// Curated on purpose: it re-exports the `types` namespace, the concrete column +// classes (load-bearing for the `AnyEncryptedV3Column` union and nominal +// typing), the table API, and the inference type aliases. It deliberately does +// NOT re-export the per-domain literal consts (`INT4`, `TEXT_EQ`, …) — those are +// internal building blocks for `types` — and there are no standalone +// `encrypted<Domain>Column` factories any more: `types.*` is the single +// authoring API. + +export type { + AnyEncryptedV3Column, + EncryptedV3TableColumn, + EqlTypeForColumn, + PlaintextForColumn, + QueryCapabilities, + QueryTypesForColumn, +} from './columns' + +export { + EncryptedBoolColumn, + EncryptedDateColumn, + EncryptedDateEqColumn, + EncryptedDateOrdColumn, + EncryptedDateOrdOreColumn, + EncryptedFloat4Column, + EncryptedFloat4EqColumn, + EncryptedFloat4OrdColumn, + EncryptedFloat4OrdOreColumn, + EncryptedFloat8Column, + EncryptedFloat8EqColumn, + EncryptedFloat8OrdColumn, + EncryptedFloat8OrdOreColumn, + EncryptedInt2Column, + EncryptedInt2EqColumn, + EncryptedInt2OrdColumn, + EncryptedInt2OrdOreColumn, + EncryptedInt4Column, + EncryptedInt4EqColumn, + EncryptedInt4OrdColumn, + EncryptedInt4OrdOreColumn, + EncryptedNumericColumn, + EncryptedNumericEqColumn, + EncryptedNumericOrdColumn, + EncryptedNumericOrdOreColumn, + EncryptedTextColumn, + EncryptedTextEqColumn, + EncryptedTextMatchColumn, + EncryptedTextOrdColumn, + EncryptedTextOrdOreColumn, + EncryptedTextSearchColumn, + EncryptedTimestamptzColumn, + EncryptedTimestamptzEqColumn, + EncryptedTimestamptzOrdColumn, + EncryptedTimestamptzOrdOreColumn, + TEXT_SEARCH_EQL_TYPE, +} from './columns' +export type { + AnyV3Table, + ColumnsOf, + InferEncrypted, + InferPlaintext, + QueryableColumnsOf, + V3DecryptedModel, + V3EncryptedModel, + V3ModelInput, +} from './table' + +export { buildEncryptConfig, EncryptedTable, encryptedTable } from './table' +export { types } from './types' diff --git a/packages/stack/src/eql/v3/table.ts b/packages/stack/src/eql/v3/table.ts new file mode 100644 index 00000000..c5b3f069 --- /dev/null +++ b/packages/stack/src/eql/v3/table.ts @@ -0,0 +1,221 @@ +import type { ColumnSchema, EncryptConfig } from '@/schema' +import type { Encrypted } from '@/types' +import type { + EncryptedV3TableColumn, + PlaintextForColumn, + QueryTypesForColumn, +} from './columns' + +interface TableDefinition { + tableName: string + columns: Record<string, ColumnSchema> +} + +/** + * A v3 encrypted table. Mirrors the v2 `EncryptedTable` but only accepts v3 + * column builders. Emits the same `{ tableName, columns }` definition shape. + */ +export class EncryptedTable<T extends EncryptedV3TableColumn> { + /** @internal Type-level brand so TypeScript can infer `T` from `EncryptedTable<T>`. */ + declare readonly _columnType: T + + constructor( + public readonly tableName: string, + public readonly columnBuilders: T, + ) {} + + build(): TableDefinition { + const builtColumns: Record<string, ColumnSchema> = {} + for (const builder of Object.values(this.columnBuilders)) { + // Key by the column's DB name (`getName()`), NOT the JS property name. + // `encrypt`/`decrypt` look columns up in the config by `column.getName()`, + // so a camelCase JS key mapping to a snake_case DB column (e.g. + // `createdOn: types.Date('created_on')`) must register under + // `created_on` or the FFI reports "column not found in Encrypt config". + const name = builder.getName() + // Two JS properties resolving to the same DB name would silently overwrite + // here (later wins), dropping the first column's config. Fail loudly — + // matching the duplicate-tableName guard in buildEncryptConfig and the + // reserved-key guard in encryptedTable. + if (Object.hasOwn(builtColumns, name)) { + throw new Error( + `[eql/v3]: duplicate column name "${name}" in table "${this.tableName}" — two columns resolve to the same DB name`, + ) + } + builtColumns[name] = builder.build() + } + return { + tableName: this.tableName, + columns: builtColumns, + } + } + + /** + * Map each column's JS property name to its DB column name (`getName()`). + * The model path matches user models by property name but must address the + * encrypt config and FFI by DB name — `build()` keys columns by DB name, so + * the two only agree when property == name. This recovers the mapping that + * `build()` discards. + */ + buildColumnKeyMap(): Record<string, string> { + const map: Record<string, string> = {} + for (const [property, builder] of Object.entries(this.columnBuilders)) { + map[property] = builder.getName() + } + return map + } +} + +/** + * Own instance members of {@link EncryptedTable} that a column name must not + * shadow. Because {@link encryptedTable} copies column builders onto the + * instance for accessor access (`users.email`), a column named e.g. `build` + * would otherwise overwrite the method that {@link buildEncryptConfig} relies + * on. `_columnType` is a type-only `declare` (no runtime property) so it is + * listed explicitly here rather than caught by the `in` check below. + */ +const RESERVED_TABLE_KEYS = new Set([ + 'tableName', + 'columnBuilders', + '_columnType', + 'build', + 'buildColumnKeyMap', +]) + +/** + * Whether a column name would collide with a reserved property on the table + * object — either an own member ({@link RESERVED_TABLE_KEYS}) or any inherited + * `Object.prototype` member (`constructor`, `toString`, `valueOf`, + * `hasOwnProperty`, …). The `in` check covers the prototype chain so assigning + * the column as an own property can never shadow a prototype method/accessor. + */ +function isReservedTableKey(tableBuilder: object, colName: string): boolean { + return RESERVED_TABLE_KEYS.has(colName) || colName in tableBuilder +} + +/** + * Define a v3 encrypted table. Intentionally shadows the v2 `encryptedTable` + * name but lives on the `/eql/v3` subpath — the importer picks the model by + * import path. The returned object is also a column accessor (`users.email`). + */ +export function encryptedTable<T extends EncryptedV3TableColumn>( + tableName: string, + columns: T, +): EncryptedTable<T> & T { + const tableBuilder = new EncryptedTable( + tableName, + columns, + ) as EncryptedTable<T> & T + + for (const [colName, colBuilder] of Object.entries(columns)) { + if (isReservedTableKey(tableBuilder, colName)) { + throw new Error( + `Column name "${colName}" collides with a reserved EncryptedTable property`, + ) + } + ;(tableBuilder as EncryptedV3TableColumn)[colName] = colBuilder + } + + return tableBuilder +} + +/** + * Build an `EncryptConfig` (`v: 1`) from one or more v3 tables. Emits the same + * shape as v2's `buildEncryptConfig`. + */ +export function buildEncryptConfig( + ...tables: Array<EncryptedTable<EncryptedV3TableColumn>> +): EncryptConfig { + const config: EncryptConfig = { + v: 1, + tables: {}, + } + + for (const tb of tables) { + const tableDef = tb.build() + // Config tables are keyed by name, so a duplicate would silently overwrite + // the earlier table. Fail loudly instead. (v3-only additive guard; v2's + // buildEncryptConfig keeps its existing silent-overwrite behavior.) + if (Object.hasOwn(config.tables, tableDef.tableName)) { + throw new Error( + `[eql/v3]: duplicate table name "${tableDef.tableName}" passed to buildEncryptConfig — each table must have a unique name`, + ) + } + config.tables[tableDef.tableName] = tableDef.columns + } + + return config +} + +/** + * Infer the plaintext (decrypted) shape from a v3 table schema. Each column maps + * to the TypeScript type of its domain's `castAs` kind. + */ +export type InferPlaintext<T extends EncryptedTable<EncryptedV3TableColumn>> = + T extends EncryptedTable<infer C> + ? { [K in keyof C]: PlaintextForColumn<C[K]> } + : never + +/** + * Infer the encrypted shape from a v3 table schema. See {@link InferPlaintext} + * for why no key-remap filter is needed in the flat single-type model. + */ +export type InferEncrypted<T extends EncryptedTable<EncryptedV3TableColumn>> = + T extends EncryptedTable<infer C> ? { [K in keyof C]: Encrypted } : never + +// --------------------------------------------------------------------------- +// Typed-client surface helpers (@cipherstash/stack/v3) +// --------------------------------------------------------------------------- + +/** Any v3 table, regardless of its column map. */ +export type AnyV3Table = EncryptedTable<EncryptedV3TableColumn> + +/** Union of the concrete column builders declared on a v3 table. */ +export type ColumnsOf<T extends AnyV3Table> = + T extends EncryptedTable<infer C> ? C[keyof C] : never + +/** + * Union of the *queryable* column builders on a v3 table. Storage-only columns + * (whose {@link QueryTypesForColumn} is `never`) are filtered out, so they can't + * be passed to a query method. + */ +export type QueryableColumnsOf<T extends AnyV3Table> = + T extends EncryptedTable<infer C> + ? { + [K in keyof C]: [QueryTypesForColumn<C[K]>] extends [never] + ? never + : C[K] + }[keyof C] + : never + +/** + * The accepted input model for {@link import('@/encryption/v3').TypedEncryptionClient.encryptModel}. + * `T` is inferred from the argument: keys that name a schema column are pinned to + * the column's plaintext type (nullable if the field is nullable), so a wrong-typed + * field fails assignability; all other keys pass through unchanged. + */ +export type V3ModelInput<Table extends AnyV3Table, T> = { + [K in keyof T]: K extends keyof InferPlaintext<Table> + ? null extends T[K] + ? InferPlaintext<Table>[K] | null + : InferPlaintext<Table>[K] + : T[K] +} + +/** The encrypted result model: schema columns become `Encrypted`, others pass through. */ +export type V3EncryptedModel<Table extends AnyV3Table, T> = { + [K in keyof T]: K extends keyof InferPlaintext<Table> + ? null extends T[K] + ? Encrypted | null + : Encrypted + : T[K] +} + +/** The decrypted result model: schema columns become their plaintext type, others pass through. */ +export type V3DecryptedModel<Table extends AnyV3Table, T> = { + [K in keyof T]: K extends keyof InferPlaintext<Table> + ? null extends T[K] + ? InferPlaintext<Table>[K] | null + : InferPlaintext<Table>[K] + : T[K] +} diff --git a/packages/stack/src/eql/v3/types.ts b/packages/stack/src/eql/v3/types.ts new file mode 100644 index 00000000..067e0c6f --- /dev/null +++ b/packages/stack/src/eql/v3/types.ts @@ -0,0 +1,164 @@ +import { + BOOL, + DATE, + DATE_EQ, + DATE_ORD, + DATE_ORD_ORE, + EncryptedBoolColumn, + EncryptedDateColumn, + EncryptedDateEqColumn, + EncryptedDateOrdColumn, + EncryptedDateOrdOreColumn, + EncryptedFloat4Column, + EncryptedFloat4EqColumn, + EncryptedFloat4OrdColumn, + EncryptedFloat4OrdOreColumn, + EncryptedFloat8Column, + EncryptedFloat8EqColumn, + EncryptedFloat8OrdColumn, + EncryptedFloat8OrdOreColumn, + EncryptedInt2Column, + EncryptedInt2EqColumn, + EncryptedInt2OrdColumn, + EncryptedInt2OrdOreColumn, + EncryptedInt4Column, + EncryptedInt4EqColumn, + EncryptedInt4OrdColumn, + EncryptedInt4OrdOreColumn, + EncryptedNumericColumn, + EncryptedNumericEqColumn, + EncryptedNumericOrdColumn, + EncryptedNumericOrdOreColumn, + EncryptedTextColumn, + EncryptedTextEqColumn, + EncryptedTextMatchColumn, + EncryptedTextOrdColumn, + EncryptedTextOrdOreColumn, + EncryptedTextSearchColumn, + EncryptedTimestamptzColumn, + EncryptedTimestamptzEqColumn, + EncryptedTimestamptzOrdColumn, + EncryptedTimestamptzOrdOreColumn, + FLOAT4, + FLOAT4_EQ, + FLOAT4_ORD, + FLOAT4_ORD_ORE, + FLOAT8, + FLOAT8_EQ, + FLOAT8_ORD, + FLOAT8_ORD_ORE, + INT2, + INT2_EQ, + INT2_ORD, + INT2_ORD_ORE, + INT4, + INT4_EQ, + INT4_ORD, + INT4_ORD_ORE, + NUMERIC, + NUMERIC_EQ, + NUMERIC_ORD, + NUMERIC_ORD_ORE, + TEXT, + TEXT_EQ, + TEXT_MATCH, + TEXT_ORD, + TEXT_ORD_ORE, + TIMESTAMPTZ, + TIMESTAMPTZ_EQ, + TIMESTAMPTZ_ORD, + TIMESTAMPTZ_ORD_ORE, +} from './columns' + +/** + * The v3 column-type namespace. Each member is a factory that builds a concrete + * EQL v3 column; the member name mirrors the underlying `eql_v3.<name>` domain + * (strip the `eql_v3.` prefix, PascalCase each `_`-separated segment). So + * `types.TextEq('actor')` builds an `eql_v3.text_eq` column, `types.Int4Ord` + * an `eql_v3.int4_ord`, `types.Timestamptz` an `eql_v3.timestamptz`, and so on. + * + * Each factory returns the CONCRETE column class instance (never the widened + * `AnyEncryptedV3Column`) so per-column plaintext / query-capability inference + * stays precise. + * + * ```ts + * import { encryptedTable, types } from '@cipherstash/stack/eql/v3' + * + * const events = encryptedTable('events', { + * actor: types.TextEq('actor'), // equality + * weight: types.Int4Ord('weight'), // order + range + * createdAt: types.Timestamptz('created_at'), // storage only + * }) + * ``` + * + * `types.TextSearch` keeps the chainable `.freeTextSearch(opts)` tuner (the + * only capability-bearing chain — every other domain is fully described by its + * type). int8/bigint domains are intentionally absent pending lossless FFI + * round-tripping (see ./columns). + */ +export const types = { + // int4 + Int4: (name: string) => new EncryptedInt4Column(name, INT4), + Int4Eq: (name: string) => new EncryptedInt4EqColumn(name, INT4_EQ), + Int4OrdOre: (name: string) => + new EncryptedInt4OrdOreColumn(name, INT4_ORD_ORE), + Int4Ord: (name: string) => new EncryptedInt4OrdColumn(name, INT4_ORD), + + // int2 + Int2: (name: string) => new EncryptedInt2Column(name, INT2), + Int2Eq: (name: string) => new EncryptedInt2EqColumn(name, INT2_EQ), + Int2OrdOre: (name: string) => + new EncryptedInt2OrdOreColumn(name, INT2_ORD_ORE), + Int2Ord: (name: string) => new EncryptedInt2OrdColumn(name, INT2_ORD), + + // date + Date: (name: string) => new EncryptedDateColumn(name, DATE), + DateEq: (name: string) => new EncryptedDateEqColumn(name, DATE_EQ), + DateOrdOre: (name: string) => + new EncryptedDateOrdOreColumn(name, DATE_ORD_ORE), + DateOrd: (name: string) => new EncryptedDateOrdColumn(name, DATE_ORD), + + // timestamptz + Timestamptz: (name: string) => + new EncryptedTimestamptzColumn(name, TIMESTAMPTZ), + TimestamptzEq: (name: string) => + new EncryptedTimestamptzEqColumn(name, TIMESTAMPTZ_EQ), + TimestamptzOrdOre: (name: string) => + new EncryptedTimestamptzOrdOreColumn(name, TIMESTAMPTZ_ORD_ORE), + TimestamptzOrd: (name: string) => + new EncryptedTimestamptzOrdColumn(name, TIMESTAMPTZ_ORD), + + // numeric + Numeric: (name: string) => new EncryptedNumericColumn(name, NUMERIC), + NumericEq: (name: string) => new EncryptedNumericEqColumn(name, NUMERIC_EQ), + NumericOrdOre: (name: string) => + new EncryptedNumericOrdOreColumn(name, NUMERIC_ORD_ORE), + NumericOrd: (name: string) => + new EncryptedNumericOrdColumn(name, NUMERIC_ORD), + + // text + Text: (name: string) => new EncryptedTextColumn(name, TEXT), + TextEq: (name: string) => new EncryptedTextEqColumn(name, TEXT_EQ), + TextMatch: (name: string) => new EncryptedTextMatchColumn(name, TEXT_MATCH), + TextOrdOre: (name: string) => + new EncryptedTextOrdOreColumn(name, TEXT_ORD_ORE), + TextOrd: (name: string) => new EncryptedTextOrdColumn(name, TEXT_ORD), + TextSearch: (name: string) => new EncryptedTextSearchColumn(name), + + // bool + Bool: (name: string) => new EncryptedBoolColumn(name, BOOL), + + // float4 + Float4: (name: string) => new EncryptedFloat4Column(name, FLOAT4), + Float4Eq: (name: string) => new EncryptedFloat4EqColumn(name, FLOAT4_EQ), + Float4OrdOre: (name: string) => + new EncryptedFloat4OrdOreColumn(name, FLOAT4_ORD_ORE), + Float4Ord: (name: string) => new EncryptedFloat4OrdColumn(name, FLOAT4_ORD), + + // float8 + Float8: (name: string) => new EncryptedFloat8Column(name, FLOAT8), + Float8Eq: (name: string) => new EncryptedFloat8EqColumn(name, FLOAT8_EQ), + Float8OrdOre: (name: string) => + new EncryptedFloat8OrdOreColumn(name, FLOAT8_ORD_ORE), + Float8Ord: (name: string) => new EncryptedFloat8OrdColumn(name, FLOAT8_ORD), +} as const diff --git a/packages/stack/src/types-public.ts b/packages/stack/src/types-public.ts index 929f0433..2553d3d3 100644 --- a/packages/stack/src/types-public.ts +++ b/packages/stack/src/types-public.ts @@ -14,6 +14,11 @@ // Query types (public only) export type { AuthStrategy, + BuildableColumn, + BuildableQueryColumn, + BuildableTable, + BuildableTableColumns, + BuildableV3QueryableColumn, BulkDecryptedData, BulkDecryptPayload, BulkEncryptedData, @@ -25,6 +30,7 @@ export type { DecryptionResult, Encrypted, EncryptedFields, + EncryptedFromBuildableTable, EncryptedFromSchema, EncryptedQuery, EncryptedQueryResult, diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 1a1dad06..f530b59d 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -74,7 +74,7 @@ export type EncryptedQuery = CipherStashEncryptedQuery * `bigint` is intentionally NOT included: the native `@cipherstash/protect-ffi` * build cannot marshal a JS `bigint` (V8 throws "Do not know how to serialize a * BigInt") and rejects a `string` for a `big_int` column. The v3 int8 domains - * are therefore omitted from the SDK entirely (see `schema/v3`) until the FFI + * are therefore omitted from the SDK entirely (see `eql/v3`) until the FFI * supports lossless bigint I/O; `bigint` returns here alongside them. * * When the upstream FFI `JsPlaintext` is corrected to include `Date`, the `Date` diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts index 85c88212..2380c8c4 100644 --- a/packages/stack/tsup.config.ts +++ b/packages/stack/tsup.config.ts @@ -15,7 +15,7 @@ export default defineConfig([ 'src/identity/index.ts', 'src/secrets/index.ts', 'src/schema/index.ts', - 'src/schema/v3/index.ts', + 'src/eql/v3/index.ts', 'src/drizzle/index.ts', 'src/dynamodb/index.ts', 'src/supabase/index.ts',