Skip to content

feat(stack): EQL v3 typed schema + strongly-typed client (@cipherstash/stack/schema/v3, /v3)#535

Open
tobyhede wants to merge 59 commits into
mainfrom
feat/eql-v3-text-search-schema
Open

feat(stack): EQL v3 typed schema + strongly-typed client (@cipherstash/stack/schema/v3, /v3)#535
tobyhede wants to merge 59 commits into
mainfrom
feat/eql-v3-text-search-schema

Conversation

@tobyhede

@tobyhede tobyhede commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

EQL v3 typed schema + strongly-typed client (@cipherstash/stack/eql/v3, @cipherstash/stack/v3)

Expands the EQL v3 authoring surface from the single text_search builder to every generated EQL v3 SQL domain — exposed through a single types namespace whose members mirror the underlying eql_v3.<name> domains — and adds a strongly-typed v3 client (@cipherstash/stack/v3) that derives input/output types from the schema and rejects misuse at compile time. v2 is unchanged; pick the model by import path.

Usage

import { Encryption } from "@cipherstash/stack";
import { encryptedTable, types } from "@cipherstash/stack/eql/v3";

// 1. Define your schema
const users = encryptedTable("users", {
  email: types.TextSearch("email"),
});

// 2. Initialize the client
const client = await Encryption({ schemas: [users] });

// 3. Encrypt
const encryptResult = await client.encrypt("secret@example.com", {
  column: users.email,
  table: users,
});
if (encryptResult.failure) {
  // Handle errors your way
}

// 4. Decrypt
const decryptResult = await client.decrypt(encryptResult.data);
// decryptResult.data => "secret@example.com"

Mix any v3 domains in one table — each column declares its own type and query capabilities. The types.* member name maps 1:1 to the eql_v3.<name> domain (strip eql_v3., PascalCase each segment):

import { encryptedTable, types } from "@cipherstash/stack/eql/v3";

const events = encryptedTable("events", {
  actor:     types.TextEq("actor"),           // equality      → eql_v3.text_eq
  weight:    types.Int4Ord("weight"),         // order + range → eql_v3.int4_ord
  createdAt: types.Timestamptz("created_at"), // storage only  → eql_v3.timestamptz
});

Plaintext types are inferred per domain, and Date values work directly:

import type { InferPlaintext } from "@cipherstash/stack/eql/v3";

type Events = InferPlaintext<typeof events>;
// { actor: string; weight: number; createdAt: Date }

await client.encrypt(new Date(), { table: events, column: events.createdAt });

Queryability is enforced at compile time — storage-only columns can't be queried:

await client.encryptQuery(30, {
  table: events,
  column: events.weight,
  queryType: "orderAndRange",
});

await client.encryptQuery(new Date(), { table: events, column: events.createdAt });
//                                                            ^ type error: not queryable

types members & capabilities

One member per generated EQL v3 domain (PascalCase of the eql_v3.<name>). Each maps to an EQL v3 SQL type and exposes getQueryCapabilities() / isQueryable().

Suffix Example member EQL v3 domain Capabilities
(none) types.Int4 int4 storage only
Eq types.TextEq text_eq equality
Ord, OrdOre types.Int4Ord int4_ord equality + order/range
TextMatch types.TextMatch text_match free-text
TextSearch types.TextSearch text_search equality + order/range + free-text

Covered domains: int2/int4, float4/float8, numeric, date, timestamptz, text*, bool → inferred as number / Date / string / boolean. int8/bigint is intentionally omitted pending lossless FFI round-tripping (a JS bigint cannot be marshalled by the native @cipherstash/protect-ffi build, and number loses precision above 2^53).

types.TextSearch keeps the chainable .freeTextSearch(opts) tuner — the only capability-bearing chain; every other domain is fully described by its type. Each factory returns the CONCRETE branded column class (never the widened AnyEncryptedV3Column), so per-column plaintext / query-capability inference stays precise.

Strongly-typed client (@cipherstash/stack/v3)

A dedicated, definitively-typed client surface for v3 schemas. EncryptionV3 mirrors Encryption; typedClient retypes an existing client. Both re-export the v3 types namespace and table API, so a single import provides everything needed to author and use a schema.

import { EncryptionV3, encryptedTable, types } from "@cipherstash/stack/v3";

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 }); // ok
await client.encrypt(123,       { table: users, column: users.email }); // ✗ number ≠ string

Every method derives its types from the concrete table / column arguments:

  • encrypt / encryptQuery pin the plaintext to the column's domain type (text → string, timestamptz → Date, …). encryptQuery additionally constrains queryType to the column's capabilities and rejects storage-only columns at compile time.
  • encryptModel / bulkEncryptModels validate schema-column fields against their inferred plaintext type (passthrough fields like id are untouched) and return a precise encrypted model.
  • decryptModel / bulkDecryptModels return the precise plaintext model, reconstructing Date values from the encrypt-config cast_as.

Because the typed methods bind to the concrete branded v3 classes, a hand-rolled structural table/column — or a column borrowed from a different table's domain — is rejected. This closes the soundness gap where a non-branded structural table could be encrypted at runtime while typed as plaintext.

Also

  • encryptModel / bulkEncryptModels accept any v3 table (structural BuildableTable); v2 unchanged.
  • encrypt / encryptQuery value type widened with Date (new Plaintext type) — the FFI declares Date as a cast target but omits it from its JsPlaintext input union. bigint is intentionally excluded until the FFI supports lossless bigint I/O.
  • Runtime payloads and emitted config are unchanged; text_search stays byte-identical to a v2 equality().orderAndRange().freeTextSearch() column. The only runtime addition is the per-column Date reconstruction on the typed client's model-decrypt paths.

Structure

  • The v3 authoring DSL lives under src/eql/v3/{columns,types,table,index}.ts, exported on the @cipherstash/stack/eql/v3 subpath. types.* is the single authoring API — there are no standalone encrypted<Domain>Column factories.
  • The strongly-typed client lives in src/encryption/v3.ts, exported on @cipherstash/stack/v3, and re-exports the full eql/v3 surface (types, table API, inference aliases).

Notes

Pre-existing on this branch, not introduced here: pnpm build's dts step may fail on src/wasm-inline.ts (missing @cipherstash/auth dep) in some environments, and live client/pg tests require CipherStash credentials. v3 unit + type tests are green, and v3 dist artifacts build (dist/encryption/v3.*, dist/eql/v3/*).

The typed client's decrypt reconstruction is gated on a live round-trip check (needs credentials): if the FFI already returns Date, the reconstruction can be dropped as a pure-type optimization — it is idempotent and safe either way.

@tobyhede tobyhede requested a review from a team as a code owner June 30, 2026 22:27
@changeset-bot

changeset-bot Bot commented Jun 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 63fe076

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a v3 text_search schema builder API, widens encryption/query types to accept structural builders, wires export and typecheck support, and updates CLI dotenv loading plus E2E test execution.

Changes

EQL v3 text_search schema DSL and client type widening

Layer / File(s) Summary
Planning and design documentation
.changeset/eql-v3-text-search.md, .changeset/eql-v3-typed-schema.md, docs/superpowers/plans/..., docs/superpowers/specs/...
Adds a minor changeset note plus implementation plan and design spec documents for the v3 text_search DSL, compatibility constraints, and testing expectations.
v3 text_search schema builders
packages/stack/src/schema/v3/index.ts
Implements EncryptedTextSearchColumn, encryptedTextSearchColumn, v3 EncryptedTable/encryptedTable, buildEncryptConfig, and v3 inference helpers with deep-cloned build output and reserved-key collision checks.
Public client and operation type widening
packages/stack/src/types.ts, packages/stack/src/schema/index.ts, packages/stack/src/encryption/helpers/infer-index-type.ts, packages/stack/src/encryption/helpers/model-helpers.ts, packages/stack/src/encryption/index.ts, packages/stack/src/encryption/operations/*
Adds BuildableColumn, BuildableQueryColumn, and BuildableTable, updates client config and query/encrypt option types, and retypes the encrypt, bulk-encrypt, schema, and index-inference helpers to accept structural builders.
Structural column-name resolution
packages/stack/src/wasm-inline.ts, packages/stack/__tests__/wasm-inline-column-name.test.ts
Exports getColumnName and changes it to validate columns structurally through getName(), removing the previous instanceof-based checks for v2-only encrypted column classes.
Export wiring and typecheck pipeline
packages/stack/package.json, packages/stack/tsup.config.ts, packages/stack/tsconfig.typecheck.json, packages/stack/vitest.config.ts, .github/workflows/tests.yml, packages/stack/scripts/install-eql-v3.ts
Adds the ./schema/v3 package export and type mappings, includes the v3 entry in tsup, and wires scoped typecheck config, Vitest typecheck settings, a test:types script, and the CI step that runs it.
Runtime and type-level acceptance tests
packages/stack/__tests__/schema-v3.test.ts, packages/stack/__tests__/schema-v3.test-d.ts, packages/stack/__tests__/schema-v3-client.test.ts, packages/stack/__tests__/schema-v3-pg.test.ts, packages/stack/__tests__/cjs-require.test.ts, packages/stack/__tests__/helpers/*
Adds runtime tests for the v3 schema builders and type-level tests for v3 schema inference and client integration, plus live Postgres/CJS helper coverage and the negative queryability check.

CLI dotenv loading and non-PTY test helper

Layer / File(s) Summary
CLI dotenv loading and helper
packages/cli/src/bin/main.ts, packages/cli/tests/helpers/run.ts
Loads .env* files with quiet: true, adds the pipe-based run helper, and exports result types for CLI test execution.
CLI E2E updates
packages/cli/tests/e2e/runner-aware-help.e2e.test.ts, packages/cli/tests/e2e/smoke.e2e.test.ts
Switches runner-aware help tests to the new run helper and adds a smoke-test assertion that the dotenv banner is absent from help output.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • cipherstash/stack#493: Shares the same encryption query/bulk typing surface and the Plaintext widening work.
  • cipherstash/stack#496: Closely related to the structural getColumnName() change in packages/stack/src/wasm-inline.ts.
  • cipherstash/stack#497: Shares the same encryption operation code paths and lock-context typing changes.

Suggested reviewers: calvinbrewer

Poem

A bunny hopped through schemas bright,
With v3 text search taking flight 🐇
New builders bloom, the types align,
And CLI banners settle fine.
Thump! The tests now run with cheer,
Quiet envs and clean paths here.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: adding EQL v3 typed schema support and a strongly typed client surface.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/eql-v3-text-search-schema

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/stack/src/types.ts (1)

111-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Possible redundant union arm.

If EncryptedColumn already has a public getEqlType() method and no private/protected members forcing nominal typing, the explicit EncryptedColumn arm is structurally subsumed by BuildableColumn & { getEqlType(): string } and could be dropped for simplicity. Not a functional issue either way; only worth simplifying if confirmed redundant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/src/types.ts` around lines 111 - 113, The BuildableQueryColumn
union appears to have a redundant arm if EncryptedColumn already satisfies
BuildableColumn & { getEqlType(): string } structurally. Check the
EncryptedColumn type and, if it does not rely on nominal typing via
private/protected members, simplify the BuildableQueryColumn alias in types.ts
by removing the explicit EncryptedColumn union member and keeping only the
shared structural form.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/stack/src/types.ts`:
- Around line 111-113: The BuildableQueryColumn union appears to have a
redundant arm if EncryptedColumn already satisfies BuildableColumn & {
getEqlType(): string } structurally. Check the EncryptedColumn type and, if it
does not rely on nominal typing via private/protected members, simplify the
BuildableQueryColumn alias in types.ts by removing the explicit EncryptedColumn
union member and keeping only the shared structural form.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 01405e99-c5e9-4153-8af3-10759fd8ebbd

📥 Commits

Reviewing files that changed from the base of the PR and between 93fc5f9 and efe4cc0.

📒 Files selected for processing (16)
  • .changeset/eql-v3-text-search.md
  • .github/workflows/tests.yml
  • docs/superpowers/plans/2026-06-30-eql-v3-text-search-schema-plan.md
  • docs/superpowers/specs/2026-06-30-eql-v3-text-search-schema-design.md
  • packages/stack/__tests__/schema-v3.test-d.ts
  • packages/stack/__tests__/schema-v3.test.ts
  • packages/stack/package.json
  • packages/stack/src/encryption/helpers/infer-index-type.ts
  • packages/stack/src/encryption/operations/bulk-encrypt.ts
  • packages/stack/src/encryption/operations/encrypt.ts
  • packages/stack/src/schema/index.ts
  • packages/stack/src/schema/v3/index.ts
  • packages/stack/src/types.ts
  • packages/stack/tsconfig.typecheck.json
  • packages/stack/tsup.config.ts
  • packages/stack/vitest.config.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/cli/tests/helpers/run.ts (1)

44-87: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

No timeout/kill safeguard for a hung child.

If the spawned CLI process hangs (e.g., waiting on unexpected input despite stdio: ['ignore', ...]), nothing here kills it — the test will eventually time out via vitest, but the orphaned child process keeps running. Consider an optional timeout that calls child.kill() and rejects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/tests/helpers/run.ts` around lines 44 - 87, The run helper
currently waits indefinitely for the spawned CLI in run() and only resolves on
close, so a hung child can outlive the test. Update
packages/cli/tests/helpers/run.ts by adding an optional timeout to RunOptions
and wiring it in run() to call child.kill() and reject if the process does not
exit in time, while preserving the existing stdout/stderr capture and cleanup in
the child.on('close') path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/cli/tests/helpers/run.ts`:
- Around line 44-87: The run helper currently waits indefinitely for the spawned
CLI in run() and only resolves on close, so a hung child can outlive the test.
Update packages/cli/tests/helpers/run.ts by adding an optional timeout to
RunOptions and wiring it in run() to call child.kill() and reject if the process
does not exit in time, while preserving the existing stdout/stderr capture and
cleanup in the child.on('close') path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8faee085-09ef-4411-822e-50addd54c10c

📥 Commits

Reviewing files that changed from the base of the PR and between 32707e2 and 30cd5f4.

📒 Files selected for processing (5)
  • packages/cli/src/bin/main.ts
  • packages/cli/tests/e2e/runner-aware-help.e2e.test.ts
  • packages/cli/tests/e2e/smoke.e2e.test.ts
  • packages/cli/tests/helpers/run.ts
  • packages/stack/src/schema/v3/index.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/cli/tests/e2e/smoke.e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/stack/src/schema/v3/index.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/stack/src/schema/v3/index.ts (1)

460-470: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Snapshot nested freeTextSearch() options when you store them.

Lines 465-466 keep opts.tokenizer and opts.token_filters by reference, so mutating the caller’s options object after configuration silently changes this builder’s later build() output. The rest of this class is explicitly avoiding shared nested state, so this should clone on write too.

Suggested fix
   freeTextSearch(opts?: MatchIndexOpts): this {
     // A fresh defaults object per call supplies the `?? ` fallbacks, so no
     // nested default object is ever shared into `this.matchOpts` by reference.
     const defaults = defaultMatchOpts()
 
     this.matchOpts = {
-      tokenizer: opts?.tokenizer ?? defaults.tokenizer,
-      token_filters: opts?.token_filters ?? defaults.token_filters,
+      tokenizer: opts?.tokenizer
+        ? { ...opts.tokenizer }
+        : { ...defaults.tokenizer },
+      token_filters: opts?.token_filters
+        ? opts.token_filters.map((f) => ({ ...f }))
+        : defaults.token_filters.map((f) => ({ ...f })),
       k: opts?.k ?? defaults.k,
       m: opts?.m ?? defaults.m,
       include_original: opts?.include_original ?? defaults.include_original,
     }
     return this
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/src/schema/v3/index.ts` around lines 460 - 470, The
freeTextSearch method in the v3 schema builder is still storing nested options
by reference, so later mutations to the caller’s MatchIndexOpts can leak into
build() output. Update freeTextSearch in packages/stack/src/schema/v3/index.ts
to clone the tokenizer and token_filters values when assigning this.matchOpts,
matching the class’s existing clone-on-write approach and avoiding shared nested
state.
🧹 Nitpick comments (6)
docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md (3)

510-510: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix redundant phrasing.

"Repeat the same exact pattern" → "Repeat the same pattern" or "Repeat this exact pattern".

- Repeat the same exact pattern for:
+ Repeat the same pattern for:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md` at line 510, Update
the phrasing in the plans document to remove redundancy: change the “Repeat the
same exact pattern for:” text to either “Repeat the same pattern for:” or
“Repeat this exact pattern for:”. Locate the sentence in the document section
containing that exact phrase and keep the rest unchanged.

Source: Linters/SAST tools


117-117: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider rewording for readability.

Three successive list items begin with "Schemas with". While this is a list format where parallelism is expected, consider varying the structure if the static analysis tool flagged it as an issue.

- - Schemas with required `hm` support equality.
- - Schemas with required `ob` support order/range.
- - Schemas with required `bf` support free-text search.
+ - `hm` required → equality support.
+ - `ob` required → order/range support.
+ - `bf` required → free-text search support.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md` at line 117, Reword
the list item about storage-only schemas to avoid repeating the same “Schemas
with” sentence pattern as the surrounding bullets. Update the wording in the
schema documentation section so it still conveys that schemas containing only v,
i, and c are storage-only, but uses a different sentence structure for
readability and to satisfy the static analysis warning.

Source: Linters/SAST tools


27-27: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider rewording for readability.

Three successive sentences beginning with "In" - though this appears to be in the file structure list where parallelism is intentional. Given the context is a structured plan document, this is acceptable but could be tightened.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md` at line 27, The
plan document wording is a bit repetitive in the file-structure list, where
several consecutive bullets/sentences start with the same “In” pattern. Rephrase
the affected entries in the schema-plan section to improve readability while
preserving the parallel structure, keeping the “BuildableTable” bullet clear and
concise.

Source: Linters/SAST tools

packages/stack/scripts/install-eql-v3.ts (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Bare dotenv/config re-introduces the banner noise this cohort suppresses elsewhere.

The CLI entrypoint now loads env files with config({ path: '.env.local', quiet: true }) specifically to avoid dotenv v17's injected-env banner. This script imports dotenv/config directly with no options, so it will still print that banner whenever it runs (e.g. in CI logs).

♻️ Suggested fix
-import 'dotenv/config'
+import { config } from 'dotenv'
 import postgres from 'postgres'
 import { installEqlV3IfNeeded } from '../__tests__/helpers/eql-v3'
+
+config({ quiet: true })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/scripts/install-eql-v3.ts` at line 1, The install-eql-v3
script is importing dotenv in a way that re-enables the noisy injected-env
banner. Update the startup env loading in this script to use the same explicit
dotenv config pattern as the CLI entrypoint, with a fixed .env.local path and
quiet enabled, and remove the bare dotenv/config import so the script stays
silent in CI. Reference the script’s top-level env bootstrap in
install-eql-v3.ts.
packages/stack/__tests__/schema-v3.test.ts (1)

2-3: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise this failure through a public API instead of resolveIndexType.

This test now imports @/encryption/helpers/infer-index-type directly and asserts the helper’s internal error strings, which makes the suite brittle to refactors inside the implementation rather than the supported contract. Please move this misuse coverage to the public entry point that surfaces the same runtime failure. As per coding guidelines, "Prefer testing via public API; avoid reaching into private internals in tests".

Also applies to: 661-677

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/__tests__/schema-v3.test.ts` around lines 2 - 3, The test is
reaching into the private `resolveIndexType` helper and asserting its internal
error strings, which makes it brittle. Update
`packages/stack/__tests__/schema-v3.test.ts` to remove the direct
`@/encryption/helpers/infer-index-type` import and exercise the same failure
through the public `encryptConfigSchema`/`encryptedColumn` API instead. Keep the
coverage for the misuse case, but assert the runtime failure surfaced by the
supported schema entry point rather than helper internals.

Source: Coding guidelines

packages/stack/__tests__/schema-v3.test-d.ts (1)

229-301: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a model-inference case with aliased column names.

This file already proves v3 domain inference for aliased builders like createdAtcreated_at, but the encryptModel / bulkEncryptModels acceptance cases only cover same-name keys. One typed assertion for an aliased encryptedTimestamptzColumn('created_at') model field would protect the exact v3 field mapping this PR is widening.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/__tests__/schema-v3.test-d.ts` around lines 229 - 301, Add a
typed model-inference test for aliased v3 encrypted columns, since the current
`encryptModel` and `bulkEncryptModels` cases only cover same-name fields. Update
the `schema-v3.test-d.ts` assertions near the existing
`encryptModel`/`bulkEncryptModels` checks to include a model using
`encryptedTimestamptzColumn('created_at')` with an aliased property name. Verify
the inferred `EncryptionClient.encryptModel` and/or `bulkEncryptModels` result
type maps the aliased field correctly while still preserving unrelated fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md`:
- Around line 63-65: The task steps currently hardcode a developer-specific
absolute path in the read-only references, which will not work for other
environments. Update the instructions in the plan to use repository-relative
paths or an environment-agnostic placeholder, and if needed mention that the
base path must be configured by the user; keep the references to the
inventory.rs and schema/v3/*.json locations clear without embedding a local
machine path.

In `@packages/stack/__tests__/helpers/eql-v3.ts`:
- Around line 28-41: The advisory lock handling in installEqlV3IfNeeded is using
separate sql calls, so the lock and unlock may run on different pooled
connections. Update the function to run the entire check/install/unlock flow on
a reserved connection via sql.reserve(), or replace
pg_advisory_lock/pg_advisory_unlock with pg_advisory_xact_lock if the EQL v3
install path is transaction-safe, and keep the existing hasEqlV3TextSearch and
eqlV3Sql execution logic inside that reserved scope.

In `@packages/stack/__tests__/schema-v3-pg.test.ts`:
- Around line 133-149: The cleanup hooks in the schema-v3-pg test only remove
rows from protect_ci_v3_text_search, so the typed-domain fixture data in
protect_ci_v3_typed_domains is left behind. Update the existing beforeEach and
afterAll hooks in schema-v3-pg.test.ts to also delete rows for the typed-domain
table using the same TEST_RUN_ID guard, alongside the current cleanup logic. Use
the existing beforeEach, afterAll, and sql cleanup blocks as the place to add
the matching protect_ci_v3_typed_domains deletion.

In `@packages/stack/src/types.ts`:
- Around line 151-183: The public BuildableTable shape is too weak for the
encryption inference used by encryptModel() and bulkEncryptModels(), so
structurally accepted tables lose the literal column keys needed by
EncryptedFromBuildableTable. Fix this by either adding the column map
brand/_columnType to the BuildableTable contract and keeping
BuildableTableColumns aligned with it, or by narrowing the affected APIs/types
back to the branded table builder type so the return type reflects encrypted
fields correctly.

---

Outside diff comments:
In `@packages/stack/src/schema/v3/index.ts`:
- Around line 460-470: The freeTextSearch method in the v3 schema builder is
still storing nested options by reference, so later mutations to the caller’s
MatchIndexOpts can leak into build() output. Update freeTextSearch in
packages/stack/src/schema/v3/index.ts to clone the tokenizer and token_filters
values when assigning this.matchOpts, matching the class’s existing
clone-on-write approach and avoiding shared nested state.

---

Nitpick comments:
In `@docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md`:
- Line 510: Update the phrasing in the plans document to remove redundancy:
change the “Repeat the same exact pattern for:” text to either “Repeat the same
pattern for:” or “Repeat this exact pattern for:”. Locate the sentence in the
document section containing that exact phrase and keep the rest unchanged.
- Line 117: Reword the list item about storage-only schemas to avoid repeating
the same “Schemas with” sentence pattern as the surrounding bullets. Update the
wording in the schema documentation section so it still conveys that schemas
containing only v, i, and c are storage-only, but uses a different sentence
structure for readability and to satisfy the static analysis warning.
- Line 27: The plan document wording is a bit repetitive in the file-structure
list, where several consecutive bullets/sentences start with the same “In”
pattern. Rephrase the affected entries in the schema-plan section to improve
readability while preserving the parallel structure, keeping the
“BuildableTable” bullet clear and concise.

In `@packages/stack/__tests__/schema-v3.test-d.ts`:
- Around line 229-301: Add a typed model-inference test for aliased v3 encrypted
columns, since the current `encryptModel` and `bulkEncryptModels` cases only
cover same-name fields. Update the `schema-v3.test-d.ts` assertions near the
existing `encryptModel`/`bulkEncryptModels` checks to include a model using
`encryptedTimestamptzColumn('created_at')` with an aliased property name. Verify
the inferred `EncryptionClient.encryptModel` and/or `bulkEncryptModels` result
type maps the aliased field correctly while still preserving unrelated fields.

In `@packages/stack/__tests__/schema-v3.test.ts`:
- Around line 2-3: The test is reaching into the private `resolveIndexType`
helper and asserting its internal error strings, which makes it brittle. Update
`packages/stack/__tests__/schema-v3.test.ts` to remove the direct
`@/encryption/helpers/infer-index-type` import and exercise the same failure
through the public `encryptConfigSchema`/`encryptedColumn` API instead. Keep the
coverage for the misuse case, but assert the runtime failure surfaced by the
supported schema entry point rather than helper internals.

In `@packages/stack/scripts/install-eql-v3.ts`:
- Line 1: The install-eql-v3 script is importing dotenv in a way that re-enables
the noisy injected-env banner. Update the startup env loading in this script to
use the same explicit dotenv config pattern as the CLI entrypoint, with a fixed
.env.local path and quiet enabled, and remove the bare dotenv/config import so
the script stays silent in CI. Reference the script’s top-level env bootstrap in
install-eql-v3.ts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4aa02b84-db93-4a9b-aa84-d185e2c884d9

📥 Commits

Reviewing files that changed from the base of the PR and between 30cd5f4 and b54e6d4.

📒 Files selected for processing (25)
  • .changeset/eql-v3-typed-schema.md
  • docs/query-api-walkthrough.md
  • docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md
  • packages/stack/__tests__/cjs-require.test.ts
  • packages/stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql
  • packages/stack/__tests__/helpers/eql-v3.ts
  • packages/stack/__tests__/helpers/stub-auth-wasm-inline.ts
  • packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts
  • packages/stack/__tests__/schema-v3-client.test.ts
  • packages/stack/__tests__/schema-v3-pg.test.ts
  • packages/stack/__tests__/schema-v3.test-d.ts
  • packages/stack/__tests__/schema-v3.test.ts
  • packages/stack/__tests__/wasm-inline-column-name.test.ts
  • packages/stack/package.json
  • packages/stack/scripts/install-eql-v3.ts
  • packages/stack/src/encryption/helpers/infer-index-type.ts
  • packages/stack/src/encryption/helpers/model-helpers.ts
  • packages/stack/src/encryption/index.ts
  • packages/stack/src/encryption/operations/bulk-encrypt-models.ts
  • packages/stack/src/encryption/operations/encrypt-model.ts
  • packages/stack/src/encryption/operations/encrypt-query.ts
  • packages/stack/src/encryption/operations/encrypt.ts
  • packages/stack/src/schema/v3/index.ts
  • packages/stack/src/types.ts
  • packages/stack/vitest.config.ts
✅ Files skipped from review due to trivial changes (3)
  • docs/query-api-walkthrough.md
  • packages/stack/tests/helpers/stub-protect-ffi-wasm-inline.ts
  • .changeset/eql-v3-typed-schema.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/stack/tests/wasm-inline-column-name.test.ts
  • packages/stack/src/encryption/helpers/infer-index-type.ts
  • packages/stack/package.json
  • packages/stack/src/encryption/operations/encrypt.ts

Comment thread docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md Outdated
Comment thread packages/stack/__tests__/helpers/eql-v3.ts Outdated
Comment thread packages/stack/__tests__/schema-v3-pg.test.ts
Comment thread packages/stack/src/types.ts
@tobyhede tobyhede force-pushed the feat/eql-v3-text-search-schema branch from b54e6d4 to ed78233 Compare July 1, 2026 03:13
@tobyhede tobyhede changed the title feat(stack): EQL v3 text_search authoring DSL (@cipherstash/stack/schema/v3) feat(stack): EQL v3 typed schema + strongly-typed client (@cipherstash/stack/schema/v3, /v3) Jul 1, 2026
tobyhede added 22 commits July 2, 2026 13:10
Column builders are copied onto the EncryptedTable instance for accessor
access (users.email). A column named build/tableName/columnBuilders/
_columnType would silently overwrite that member — worst case a 'build'
column breaks buildEncryptConfig's tb.build() call at runtime.

Throw a clear error at table-definition time instead. Scoped to v3; v2
retains its existing behavior.

Found by CodeRabbit review.
…ntry

The wasm-inline encrypt entry typed opts.column as the widened structural
BuildableColumn, but getColumnName still gated on instanceof EncryptedColumn
|| EncryptedField and threw for a v3 EncryptedTextSearchColumn — a runtime
break the type promise hid. Resolve the name structurally (typeof getName)
so v3 columns round-trip through WasmEncryptionClient.encrypt(); still throws
for non-builder JS input. getColumnName is the only instanceof gate on this
path; the rest reads table.tableName structurally.

Adds wasm-inline-column-name.test.ts exercising the seam (v2 column/field +
v3 column + non-builder). Like its sibling wasm-inline-normalize.test.ts the
suite cannot load in environments missing the @cipherstash/protect-ffi
/wasm-inline dep subpath.
Config tables are keyed by name, so two tables with the same tableName
silently dropped the earlier one. Add a v3-only additive guard that throws
on a duplicate (Object.hasOwn). v2's buildEncryptConfig keeps its existing
silent-overwrite behavior (no-v2-change constraint).
The RESERVED_TABLE_KEYS guard only covered own members (build, tableName,
columnBuilders, _columnType), so a column named constructor/toString/valueOf/
hasOwnProperty was assigned as an own property, shadowing the Object.prototype
member. Add an `in` check (isReservedTableKey) so any prototype-chain member
is also rejected, keeping the table object well-behaved for reflection.
…freeTextSearch' for match

A v3 text_search column emits unique+ore+match, and shared index inference
picks by priority unique > match > ore. So encryptQuery without an explicit
queryType builds an EQUALITY term (via unique) — a substring matches nothing.
Document on EncryptedTextSearchColumn + encryptedTextSearchColumn that callers
must pass queryType:'freeTextSearch' (FFI 'match') for free-text queries.

Addresses review finding #2 (naming footgun; doc-only, no runtime change).
Add table-driven runtime tests for all 40 EQL v3 domain builders (name,
eqlType, capabilities, config, queryability) plus type-level tests for
nominal domain distinctness, InferPlaintext mapping, queryability of
BuildableQueryColumn, and v3/v2 model inference.
tobyhede added a commit that referenced this pull request Jul 3, 2026
The structural builder contracts (BuildableColumn, BuildableQueryColumn,
BuildableV3QueryableColumn, BuildableTable, BuildableTableColumns) and the
encryptModel/bulkEncryptModels return-type mapper (EncryptedFromBuildableTable)
appear in public return positions but were not re-exported from
`@cipherstash/stack/types`, so consumers could not name them — an inconsistency
with the already-exposed `EncryptedFromSchema`. No build breakage (the mapped
types were emitted inline); this closes the nameability gap.

Regression guard: types-public-surface.test-d.ts imports each contract from the
public `@/types-public` entrypoint (a missing re-export fails typecheck).

Note: these types are inherited from the base branch (feat/eql-v3-text-search-schema,
PR #535); the export is added here in response to review feedback on the stacked PR.
@coderdan coderdan self-requested a review July 3, 2026 10:43
@coderdan

coderdan commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Bug: text_ord / text_ord_ore columns produce payloads their own SQL domains reject

encryptedTextOrdColumn and encryptedTextOrdOreColumn map to ORDER_AND_RANGE, and indexesForCapabilities emits an ore-only index config for that capability shape (src/schema/v3/index.ts:331-337) — so the encrypted payload carries only the ob term. But the vendored EQL v3 SQL requires both hm and ob for the three text ord domains (cipherstash-encrypt-v3.sql:10823-10824, 10842-10843), unlike every non-text _ord domain which requires ob alone. Result: an SDK-encrypted value can never be inserted into its own eql_v3.text_ord / eql_v3.text_ord_ore column — the domain CHECK rejects every row.

text_search only escapes this because EncryptedTextSearchColumn.build() bypasses indexesForCapabilities and hardcodes unique + ore + match (index.ts:471-482) — i.e. the domain→index mapping is already encoded twice and has already drifted.

This is the same failure class I flagged on protectjs-ffi#104: hand-deriving the per-domain term sets erases the type information that makes these mismatches unrepresentable. See my comments there:

eql-bindings exists exactly for this: one canonical struct per eql_v3 SQL domain, with the required term keys as data. The domain definitions in schema/v3/index.ts (capabilities → index set → term keys) should be generated from or validated against those bindings rather than maintained by hand — that fixes text_ord here and prevents the next drift structurally, on both sides of the FFI.

Worth noting: the one suite that would have caught this (matrix-live-pg.test.ts) is force-describe.skip'd, which is what let it ship green — see the companion review comment.

@coderdan

coderdan commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review findings (10)

Multi-angle review of this branch vs main; every finding below was independently re-verified against the code (several with tsc/runtime repros). Ordered most-severe first. The text_ord domain-CHECK bug is in a separate comment above.

1. timestamptz columns silently truncate time-of-day — src/schema/v3/index.ts:159

All four timestamptz domain builders set castAs: 'date'. protect-ffi's native vocabulary has a distinct 'timestamp' cast (normalizeEncryptConfig.d.ts:9), but the SDK's castAsEnum/toEqlCastAs never expose it — so new Date('…T14:30:00Z') decrypts as midnight. The round-trip test that catches this is it.skip'd (schema-v3-client.test.ts:255), and dfb2890's commit message confirms the root cause was known when it was skipped. This is irreversible data loss in a public API; suggest either plumbing 'timestamp' through the cast layer or withholding the timestamptz builders (as was done for int8) until it works.

2. Lock-context silently drops identity binding — src/identity/index.ts:45

The new structural fallback 'identityContext' in input matches a present-but-undefined key. A spread-built { identityClaim: ['sub'], identityContext: undefined } (passes TS — excess-property checks only reject direct literals) now resolves to undefined, and ffiEncrypt's optional lockContext accepts it: encryption proceeds identity-unbound where it previously bound to the claims. Repro'd at runtime; same pattern in all 9 operation call sites. input.identityContext !== undefined keeps the cross-realm fix without the silent downgrade.

3. encryptModel/bulkEncryptModels break single-type-argument callers — src/encryption/index.ts:404

The defaulted generic (S extends EncryptedTableColumn = EncryptedTableColumn) became a required Table extends BuildableTable. client.bulkEncryptModels<BenchPlaintextRow>(rows, table) (live site: packages/bench/src/harness/seed.ts:57) and the documented encryptModel<User>(user, users) pattern now fail with TS2558 — tsc-verified. CI can't see it: tsconfig.typecheck.json covers only *.test-d.ts. Adding = BuildableTable restores compatibility.

4. EncryptedFromBuildableTable regresses v2 model types — src/types.ts:235

It drops EncryptedFromSchema's [S[K]] extends [EncryptedColumn | EncryptedField] value filter. Two tsc-verified consequences: (a) nested v2 schemas ({ example: { field: encryptedField(…) } }) now type data.example as Encrypted while the runtime value is a nested object — data.example.field (the exact access in __tests__/nested-models.test.ts:101) is TS2339; (b) a table typed via the old index-signature form types every key, including non-encrypted ones, as Encrypted. Notably nested-models.test.ts itself no longer compiles on this branch (also hit by finding 3) — invisible to CI for the same typecheck-scope reason.

5. Plaintext widened to Date across all v2 surfaces with blind casts — src/types.ts:83

Plaintext = JsPlaintext | Date now flows into encrypt/encryptQuery/bulk payloads for all columns, and the operations cast plaintext as JsPlaintext at the FFI boundary (operations/encrypt.ts:94/175, encrypt-query.ts:94/184, bulk-encrypt.ts:36, batch-encrypt-query.ts:65) with no instanceof Date guard. On main, client.encrypt(new Date(), { column: users.email, … }) on a string column was a compile error; now it typechecks and native marshaling decides the outcome. Per-column Date pinning exists only in the typed v3 client — consider a runtime guard (or per-column typing) on the base client too.

6. Duplicate DB column names silently overwrite in v3 tables — src/schema/v3/index.ts:777

EncryptedTable.build() keys by builder.getName() with no duplicate guard, so encryptedTable('t', { a: encryptedTextColumn('x'), b: encryptedInt4Column('x') }) builds a config where 'x' is int4-only and buildColumnKeyMap points both properties at it — encrypting a sends a string under an int4 cast (opaque FFI error), or silently wrong index terms for same-type duplicates. New v3 behavior (v2 keys by JS property name), and the PR's own duplicate-table guard (index.ts:871-875) shows the intent — a matching column-level guard belongs next to it.

7. EncryptionV3 bypasses the duplicate-table guard — src/encryption/v3.ts:214

The factory delegates schemas to the v2 Encryption() factory, whose buildEncryptConfig (src/schema/index.ts:688-691) silently last-wins-overwrites duplicate table names. The guard added in v3's buildEncryptConfig (schema/v3/index.ts:871-875) never runs on this path — duplicate tables initialize cleanly and the first table's columns vanish. Either route EncryptionV3 through the guarded builder or move the guard into the shared v2 one (which would also collapse the duplicated config-assembly loop).

8. Invalid Date passes all guards and hits the FFI as JSON nullsrc/encryption/operations/encrypt.ts:74

The new guards check only typeof === 'number' NaN/Infinity; no path checks Number.isNaN(date.getTime()). JSON.stringify turns an Invalid Date into null (toJSON guards with isFinite — it doesn't throw), which deserializes as JsonB(Value::Null) in protect-ffi and fails with the misleading TypeParseError: "Cannot convert Json to Date… Check your column's cast_as setting" — blaming the user's schema. On a cast_as: json column the null encrypts silently. A validity check next to the NaN/Infinity guards closes it.

9. matrix-live-pg is hard-skipped with a stale rationale — __tests__/v3-matrix/matrix-live-pg.test.ts:65

const describeLivePg = describe.skip unconditionally (line 64 even discards the credential gate with void LIVE_EQL_V3_PG_ENABLED), while sibling suites gate on credentials. The skip comment says "root cause not yet pinned", but 53cf854 pinned and fixed that exact cause (sql.json() for the postgres.js [object Object] coercion) — so the fix ships unverified, and this is the suite that would have caught the text_ord CHECK bug. Restore the credential gate; the suite skipping in CI-without-credentials is fine, being unrunnable-by-anyone is not.

10. Per-row/per-call schema rebuilds on the bulk hot paths — src/encryption/helpers/model-helpers.ts:591

resolveEncryptColumnMap(table) runs inside the per-model loop of prepareBulkModelsForOperation (loop-invariant), encryptModelFields recomputes the map its callee just built (365 after 293; same at 480/482, 665/667), and v3's reconstructRow re-runs table.build() + buildColumnKeyMap() for every row inside bulkDecryptModels' map (v3.ts:135-136, :182-184) — O(rows × columns) redundant construction, including text_search match-opts deep clones. Hoisting to per-call scope is behavior-preserving (synchronous loops, no intervening mutation). Note: class-level memoization on EncryptedTable would be unsafe — freeTextSearch() mutates builder state post-construction.


Verified-and-dropped (for transparency): the encryptQuery array+opts routing change at src/encryption/index.ts:286 is real but deliberate and documented in the new comment (it does change behavior for JS callers who passed both — worth a changeset note); a reserved-name collision (buildColumnKeyMap as a v2 column property now throws in model-helpers.ts:222) is confirmed but joins an existing class of unguarded v2 collisions (build, tableName) — a shared reserved-name guard would fix the class.

@coderdan

coderdan commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Follow-up on the text_ord finding, with the canonical source. The domain catalog on the eql_v3 branch (crates/eql-domains/src/lib.rs, TEXT_DOMAINS) defines:

Domain { name: "ord",     terms: &[Term::Hm, Term::Ore] },
Domain { name: "ord_ore", terms: &[Term::Hm, Term::Ore] },
Domain { name: "ord_ope", terms: &[Term::Hm, Term::Ope] },
Domain { name: "search",  terms: &[Term::Hm, Term::Ore, Term::Bloom] },

with this design rationale in the doc comment:

Equality always routes through hm. Every eq-capable text domain leads with Hm so =/<> resolve to eq_term/hm, never the ORE (ob) or OPE (op) ordering term — text ordering terms are not equality-lossless. […] Integer kinds keep [Ore]-only _ord and [Ope]-only _ord_ope domains — ordering-term equality is lossless for them.

Three consequences for this PR:

  1. The insert bug's correct fix is "emit hm too," not "drop the builders." text_ord/text_ord_ore are real domains; the SDK's indexesForCapabilities applies the integer rule (ore-only for order-capable columns) to text, where the catalog deliberately breaks that symmetry. The bindings schema (crates/eql-bindings/schema/v3/text_ord.json) requires hm + ob — as data.

  2. The query path has a second instance of the same bug. resolvesEqualityViaOre (src/encryption/helpers/infer-index-type.ts:94) routes equality through the ore term for order-capable columns. That's valid for integer families and wrong for text — per the catalog, text =/<> must resolve via hm, never ob. Even with the insert fixed, equality queries against text ord columns would target a term that never serves text equality.

  3. text_ord_ope ([Hm, Ope]) exists in the bindings but has no builder here. The catalog gates op emission on a future client release, so omitting it may well be deliberate — worth a comment in schema/v3/index.ts saying so, so it isn't read as an oversight.

All three are the same lesson: the capability→term mapping has a single Rust source of truth with generated JSON/TS bindings, and every hand-written re-derivation of it (here, and the one previously flagged on protectjs-ffi#104) has drifted in a way the bindings would have made unrepresentable. Until @cipherstash/eql is published, even vendoring the per-domain JSON schemas and adding a unit test that asserts schema/v3's index sets produce exactly the required term keys per domain would turn this whole class of drift into a CI failure instead of a silent data bug.

freshtonic pushed a commit that referenced this pull request Jul 4, 2026
The structural builder contracts (BuildableColumn, BuildableQueryColumn,
BuildableV3QueryableColumn, BuildableTable, BuildableTableColumns) and the
encryptModel/bulkEncryptModels return-type mapper (EncryptedFromBuildableTable)
appear in public return positions but were not re-exported from
`@cipherstash/stack/types`, so consumers could not name them — an inconsistency
with the already-exposed `EncryptedFromSchema`. No build breakage (the mapped
types were emitted inline); this closes the nameability gap.

Regression guard: types-public-surface.test-d.ts imports each contract from the
public `@/types-public` entrypoint (a missing re-export fails typecheck).

Note: these types are inherited from the base branch (feat/eql-v3-text-search-schema,
PR #535); the export is added here in response to review feedback on the stacked PR.
freshtonic pushed a commit that referenced this pull request Jul 4, 2026
The structural builder contracts (BuildableColumn, BuildableQueryColumn,
BuildableV3QueryableColumn, BuildableTable, BuildableTableColumns) and the
encryptModel/bulkEncryptModels return-type mapper (EncryptedFromBuildableTable)
appear in public return positions but were not re-exported from
`@cipherstash/stack/types`, so consumers could not name them — an inconsistency
with the already-exposed `EncryptedFromSchema`. No build breakage (the mapped
types were emitted inline); this closes the nameability gap.

Regression guard: types-public-surface.test-d.ts imports each contract from the
public `@/types-public` entrypoint (a missing re-export fails typecheck).

Note: these types are inherited from the base branch (feat/eql-v3-text-search-schema,
PR #535); the export is added here in response to review feedback on the stacked PR.
tobyhede and others added 10 commits July 6, 2026 08:24
Replace the 35 verbose `encrypted<Domain>Column` factories with a single
`types` namespace whose members mirror the underlying `eql_v3.<name>` domains
1:1 (`types.TextEq`, `types.Int4Ord`, `types.Timestamptz`, …), and split the
992-line `src/schema/v3/index.ts` into a cohesive module under `src/eql/v3/`
(`columns.ts`, `types.ts`, `table.ts`, curated `index.ts`).

The authoring subpath is renamed `@cipherstash/stack/schema/v3` ->
`@cipherstash/stack/eql/v3`; the `./v3` typed-client surface now re-exports the
`types` namespace instead of the standalone factories. Behaviour is preserved:
same classes, same nominal-typing mechanism, same `build()` output.

- Rewire tsup entry, package.json exports/typesVersions/analyze:complexity,
  the `@/eql/v3` re-export, `[eql/v3]` error prefix, and fta-v3.yml paths.
- Migrate all v3 tests + CJS smoke test to `types.*` and the new subpath.
- Reconcile the three unreleased changesets and refresh tracked v3 design docs
  (supersede banners on completed-work records; correct the not-yet-built
  Stryker gate spec's single-file premise for the 4-file split).

Verified: build emits dist/eql/v3; schema/v3 subpath and factories are gone
(ERR_PACKAGE_PATH_NOT_EXPORTED, undefined on both subpaths); 101 v3 runtime +
50 type tests pass; FTA scores all 4 files (max 68.68 < 72); e2e authoring
config byte-matches expected.
Two JS properties whose builders resolve to the same DB name (getName())
silently overwrote in the built config — the later column won and the first's
config was dropped. Throw instead, matching the existing duplicate-tableName
guard in buildEncryptConfig and the reserved-key guard in encryptedTable.

Regression tests: `EncryptedTable.build()` and `buildEncryptConfig` both throw
on a duplicate DB name (schema-v3.test.ts, eql_v3 encryptedTable block).
The structural builder contracts (BuildableColumn, BuildableQueryColumn,
BuildableV3QueryableColumn, BuildableTable, BuildableTableColumns) and the
encryptModel/bulkEncryptModels return-type mapper (EncryptedFromBuildableTable)
appear in public return positions but were not re-exported from
`@cipherstash/stack/types`, so consumers could not name them — an inconsistency
with the already-exposed `EncryptedFromSchema`. No build breakage (the mapped
types were emitted inline); this closes the nameability gap.

Regression guard: types-public-surface.test-d.ts imports each contract from the
public `@/types-public` entrypoint (a missing re-export fails typecheck).

Note: these types are inherited from the base branch (feat/eql-v3-text-search-schema,
PR #535); the export is added here in response to review feedback on the stacked PR.
The v3-matrix domain suite (catalog.ts + matrix tests) landed on the base
branch via PR #540 after this branch was cut, and used the pre-refactor
`@/schema/v3` path and `encrypted<Domain>Column` factories. Retarget it to
`@/eql/v3` and the `types.*` namespace so the base's matrix coverage keeps
working on top of the refactor. `EqlTypeForColumn` (which #540's catalog.ts
consumes) is preserved — ported into eql/v3/columns.ts and re-exported from the
barrel during the rebase.

Post-rebase reconciliation only; no behavior change.
Close two coverage gaps on the eql/v3 branch that only live/e2e tests
touched:

- encrypt-lock-context-guards: assert NaN/+Inf/-Inf are rejected on the
  `encrypt(...).withLockContext(...)` path and short-circuit before the
  FFI call. The non-lock guards run only under the live number-protect
  suite; the lock-context arm (encrypt.ts:163-168) had no coverage.
- wasm-inline-new-client: assert the protect-ffi 0.25 single-object
  `newClient({ strategy, encryptConfig, clientId, clientKey })` shape,
  incl. cast_as normalisation. Previously exercised only by the
  secret-gated Deno e2e, so a regression to the 0.24 two-arg form would
  pass normal CI.

Both run offline (mocked FFI).
The all-35-domain live Postgres suite was force-skipped (describe.skip, not
credential-gated) after `beforeAll`'s dynamic INSERT crashed with
`invalid input syntax for type json` (PR #540). That crash was a postgres.js
serialization gap — a bare ciphertext object stringified to "[object Object]" —
and was fixed 32 minutes later by wrapping every INSERT param in `sql.json(...)`
(commit 53cf854). The force-skip was simply left stale; it is not an FFI
limitation.

Restore the credential-gated form (`LIVE_EQL_V3_PG_ENABLED ? describe :
describe.skip`) as the file's own comment instructed, so the 35-domain SQL
round-trip runs in CI (which supplies DATABASE_URL + CS_* creds) and self-skips
locally. The genuine FFI-level skip — timestamptz `cast_as:'date'` time-of-day
truncation in schema-v3-client.test.ts — stays skipped (needs a native
'timestamp' cast_as variant). timestamptz matrix cases are unaffected (midnight
samples, no truncation).
…equirement)

The `eql_v3.text_ord` and `eql_v3.text_ord_ore` Postgres 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. The SDK's
`indexesForCapabilities` treated every order/range domain identically, emitting
`ore` only, so text-order ciphertexts lacked `hm` and a real INSERT failed with
`value for domain eql_v3.text_ord_ore violates check constraint`. (Surfaced by
re-enabling matrix-live-pg; masked before by the suite skip.)

Make index derivation castAs-aware: emit `unique` (hm) when equality is
answered via HMAC — equality-only domains of any type, AND text order domains
(`string` + order/range). Numeric/date order domains are unchanged (`ore` only).

Query path follows automatically: `resolvesEqualityViaOre` only fires when
`unique` is absent, so text-order equality now resolves to the `hm` index
(eq_term) while numeric/date order equality still resolves to `ore`.

TDD: text_ord/text_ord_ore build() now emits { unique, ore }; numeric order
stays { ore }; text-order equality resolves to unique. Catalog + matrix build()
assertions updated (TEXT_ORD_IDX). Verified against the eql_v3 domain checks in
the fixture; live SQL runs in CI.
… guards

Test-only additions (separated from the in-flight EQL v3 bundle upgrade so they
land on this branch, not the bundle branch):

- encrypt-lock-context-guards.test.ts: run every non-finite-number guard case
  against BOTH a v2 fluent-builder column and a v3 domain column, since the
  guard lives on the shared EncryptOperationWithLockContext.
- schema-v3.test.ts: `.freeTextSearch()` no-arg is a no-op (pins the
  opts===undefined branch); a text_match mutable-state aliasing guard (base-class
  match-clone path, which the text_search-only test can't cover); and
  buildEncryptConfig() with zero tables yields { v: 1, tables: {} }.
- wasm-inline-strategy.test.ts: Biome line-wrap formatting only.
- encryption/v3: reconstructRow → rowReconstructor factory — the table
  config (build() + buildColumnKeyMap()) is row-invariant but was
  rebuilt per row on the bulk decrypt path; it is now derived once per
  call site, with date columns resolved up front
- encrypt operations: replace the two inline NaN/Infinity guard copies
  with the existing assertValidNumericValue helper (validation.ts)
- schema/match-defaults: single source of truth for the default match
  index parameters (previously duplicated between the v2 freeTextSearch
  builder and the v3 domain builders) plus a shared cloneMatchOpts
  deep-clone used at all three v3 clone sites
- tests: one shared live-gate helper (LIVE_CIPHERSTASH_ENABLED /
  LIVE_EQL_V3_PG_ENABLED + describeLive/describeLivePg) replaces the
  gate blocks copy-pasted across seven live suites

No behavioral changes: emitted encrypt configs are byte-identical
(schema-v3 fixture tests unchanged), guard error messages unchanged,
gating semantics unchanged.
tobyhede added a commit that referenced this pull request Jul 5, 2026
The structural builder contracts (BuildableColumn, BuildableQueryColumn,
BuildableV3QueryableColumn, BuildableTable, BuildableTableColumns) and the
encryptModel/bulkEncryptModels return-type mapper (EncryptedFromBuildableTable)
appear in public return positions but were not re-exported from
`@cipherstash/stack/types`, so consumers could not name them — an inconsistency
with the already-exposed `EncryptedFromSchema`. No build breakage (the mapped
types were emitted inline); this closes the nameability gap.

Regression guard: types-public-surface.test-d.ts imports each contract from the
public `@/types-public` entrypoint (a missing re-export fails typecheck).

Note: these types are inherited from the base branch (feat/eql-v3-text-search-schema,
PR #535); the export is added here in response to review feedback on the stacked PR.
tobyhede added 4 commits July 6, 2026 09:19
refactor(stack): EQL v3 `types` namespace on @cipherstash/stack/eql/v3
Resolves pending review feedback from #541 (merged). Seven findings:

- test: restore the live ORE proof for text order domains. text_ord /
  text_ord_ore carry both hm (unique) and ob (ore), so the single-kind
  classifier ran only the eq_term/hmac_256 proof and silently skipped
  ord_term/ore_block_256 -- a wrong-valued ob would pass the matrix green.
  Run both proofs; build the ob term with queryType:'orderAndRange' since
  equality resolves to hm on these domains.
- schema: extract a shared resolveMatchOpts() merge+clone helper used by
  both the v2 and v3 freeTextSearch builders, giving v2 the clone-on-write
  protection it lacked (it stored caller opts by reference).
- eql/v3: derive EncryptedTextSearchColumn.build()'s unique/ore blocks via
  indexesForCapabilities (override only match) so the emitted index shape
  cannot drift from the shared capability mapping.
- encryption/v3: precompute row reconstructors per schema table at
  typedClient construction and return a DecryptionError failure for unknown
  tables, so table.build()'s duplicate-column throw can no longer escape the
  decrypt Result contract as a promise rejection.
- eql/v3: alias V3DecryptedModel to V3ModelInput (character-identical) to
  stop the input/output model shapes silently drifting.
- ci: add src/schema/match-defaults.ts to the fta-v3 path scope -- it shapes
  every emitted v3 match block but sat outside the gate.
- ci: re-baseline FTA --score-cap 72 -> 69 after the eql/v3 file split and
  refresh the spec doc's stale 71.08 baseline claim.
fix(stack): address EQL v3 types-module review follow-ups
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants