Skip to content

refactor(stack): EQL v3 types namespace on @cipherstash/stack/eql/v3#541

Merged
tobyhede merged 12 commits into
feat/eql-v3-text-search-schemafrom
feat/eql-v3-types-module
Jul 6, 2026
Merged

refactor(stack): EQL v3 types namespace on @cipherstash/stack/eql/v3#541
tobyhede merged 12 commits into
feat/eql-v3-text-search-schemafrom
feat/eql-v3-types-module

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Refactors the EQL v3 authoring surface: the per-domain encrypted<Domain>Column factories become a single types namespace whose members mirror the underlying eql_v3.<name> domains, on the renamed @cipherstash/stack/eql/v3 subpath.

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
});

Before → after — the name maps 1:1 to the EQL v3 domain:

// before                                          // after
encryptedTextEqColumn("actor")                     types.TextEq("actor")          // eql_v3.text_eq
encryptedInt4OrdColumn("weight")                    types.Int4Ord("weight")        // eql_v3.int4_ord
encryptedTimestamptzColumn("created_at")            types.Timestamptz("created_at")// eql_v3.timestamptz
encryptedTextSearchColumn("email").freeTextSearch() types.TextSearch("email").freeTextSearch()

Per-domain plaintext inference and compile-time queryability are unchanged:

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

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

const client = await Encryption({ schemas: [events] });

await client.encryptQuery(30, { table: events, column: events.weight, queryType: "orderAndRange" });
await client.encryptQuery(new Date(), { table: events, column: events.createdAt });
//                                                            ^ type error: storage-only, not queryable

The @cipherstash/stack/v3 typed client re-exports types (in place of the standalone builders):

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

types members

One member per generated EQL v3 domain (PascalCase of the eql_v3.<name>):

Int4 Int4Eq Int4OrdOre Int4Ord · Int2* · Date* · Timestamptz* · Numeric* · Text TextEq TextMatch TextOrdOre TextOrd TextSearch · Bool · Float4* Float8* (int8/bigint still omitted pending lossless FFI I/O). Each returns its concrete branded class, so per-column inference stays precise.

Scope

  • Splits the 992-line schema/v3/index.ts into src/eql/v3/{columns,types,table,index}.ts.
  • Renames the subpath schema/v3eql/v3 (exports, tsup entry, FTA gate, ./v3 re-export); the old subpath and the standalone factories are removed.
  • Behaviour preserved: same classes, nominal typing, and build() output (text_search stays byte-identical).

Verified: schema/v3 no longer resolves (ERR_PACKAGE_PATH_NOT_EXPORTED) and factories are undefined on both subpaths; 101 v3 runtime + 50 type tests pass; FTA scores all 4 files (max 68.68 < 72). Stacked on #535.

@tobyhede tobyhede requested a review from a team as a code owner July 3, 2026 01:13
@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: dcd0fe7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

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

Click here to learn what changesets are, and how to add one.

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

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6c441b96-e254-4709-b648-7777a38ac6e4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/eql-v3-types-module

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.

@tobyhede tobyhede force-pushed the feat/eql-v3-types-module branch from b98ad89 to 2a078b8 Compare July 3, 2026 02:02
@tobyhede tobyhede requested review from coderdan and freshtonic July 3, 2026 04:07

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I approve but I notice that timestamptz is back - I thought we finally reached a conclusion (that Claude's advice was incorrect)?

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tres excellente!

Comment thread packages/stack/src/eql/v3/types.ts
Comment thread packages/stack/__tests__/encrypt-lock-context-guards.test.ts
Comment thread packages/stack/__tests__/encrypt-lock-context-guards.test.ts
Comment thread packages/stack/__tests__/encrypt-lock-context-guards.test.ts
Comment thread packages/stack/__tests__/schema-v3.test.ts

coderdan commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

From test-gap-review:

Test-coverage review — eql/v3 authoring DSL

Verdict: coverage is strong; one small lopsided gap worth closing.

The bulk of this PR is a rename/refactor (schema/v3eql/v3, the encrypted*Column factories → the types.* namespace) whose behavior is pinned by the exhaustive type-driven V3_MATRIX: matrix.test.ts asserts builder/getName/getEqlType/getQueryCapabilities/isQueryable/build() for all 35 domains, matrix-live*.test.ts roundtrips every domain through live FFI + Postgres, and the genuinely-new logic (text-order domains carrying the hm/unique index) already has dedicated regression tests. New public Buildable* type exports are guarded by types-public-surface.test-d.ts. The table guards (duplicate DB-column name, duplicate table name, reserved-key collision incl. prototype members) are all tested.

Inline gap (1)

packages/stack/src/eql/v3/columns.ts:355 — base-class match-index clone in indexesForCapabilities() (used by eql_v3.text_match) has no cross-column mutable-state test, while the parallel EncryptedTextSearchColumn.build() override does. Classic lopsided-clone-path anti-pattern: a regression to a shared const default would be caught for text_search but slip through for text_match. Sketch mirrors the existing "built columns share no mutable state" test using types.TextMatch(...). Passes today; fails if the base path stops cloning.

Additional gaps (body only)

  • freeTextSearch() called with no argument (opts === undefined) — untested branch; every test passes an explicit opts object.
  • buildEncryptConfig() with zero tables — untested empty-input boundary. Low value.

No security issues noticed incidentally.

tobyhede and others added 9 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 tobyhede force-pushed the feat/eql-v3-types-module branch from 9895e46 to 0eebebb Compare July 5, 2026 22:25
@tobyhede tobyhede merged commit 348e4fa into feat/eql-v3-text-search-schema Jul 6, 2026
7 checks passed
@tobyhede tobyhede deleted the feat/eql-v3-types-module branch July 6, 2026 00:40
@freshtonic

freshtonic commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Multi-angle review of this PR (8 finder passes + adversarial verification of each candidate, checked against current main since the PR is already merged). The refactor itself is clean — every export of the old schema/v3 barrel survives the split except the intentionally-removed factories, no stale schema/v3 references remain in exports/tsup/CI, the subpath was never published so the rename breaks no consumer, and the types.* → class → domain-const mappings are all consistent. Findings below are ranked most-severe first; all are follow-up material rather than blockers.

Verified findings

1. __tests__/v3-matrix/matrix-live-pg.test.ts (proofKindFor) — text order domains silently lost their live ORE proof.
Switching the catalog to TEXT_ORD_IDX = { unique: …, ore: {} } reclassifies eql_v3.text_ord / text_ord_ore under proofKindFor's unique-first priority: both now run only the eq_term/hmac_256 proof and never the ord_term/ore_block_256 proof. A wrong-valued ob term for text order domains would pass the whole live matrix green (a missing ob still fails the domain CHECK on insert, but wrong values don't). The doc comment ("every *_ord/*_ord_ore domain (including the text ones) gets the equality-via-ORE proof") and the catalog.ts note about the ord proof selecting row A are now both false. Fix: teach the classifier a third eq+ord kind for text order domains (they carry both indexes) so they run both proofs.

2. .changeset/eql-v3-typed-schema.md + eql-v3-typed-client.md — pending changesets still promise bigint / int8 support the code intentionally omits.

NOTE: James has an open PR for supporting bigint

The changesets say encrypt/encryptQuery were widened to include "Date and bigint … so v3 date/timestamptz/int8 domains can be encrypted" and "int8 → bigint", but Plaintext = JsPlaintext | Date (src/types.ts) explicitly excludes bigint (the FFI can't marshal it) and the int8 domain builders are deliberately omitted from columns.ts. If released as-is, the notes direct users at a runtime FFI serialization failure and nonexistent types.Int8* members. Worth fixing before these changesets ship.

3. packages/stack/package.json (analyze:complexity) — the FTA gate was silently de-calibrated by the file split.
The --score-cap 72 was set as a <1-point buffer over the old monolith (71.08 — the gate-design doc explicitly prescribes "a small buffer, the way FTA sets --score-cap 72 against a current 71.08"). After the split the worst file (columns.ts) scores 67.83, so the gate now has ~4 points of slack — the exact regression class it was built to block would pass CI. Re-baseline to ~68–69 (and refresh the stale "71.08" claim in the spec doc, which this PR edited around).

4. src/eql/v3/columns.ts (EncryptedTextSearchColumn.build()) — unique/ore emission rules now live in two places.
The override hand-writes { unique: { token_filters: [] }, ore: {}, match: … } while indexesForCapabilities — which this PR just taught the text/HMAC rule — would produce the identical unique/ore shape for text_search's capabilities. The text_ord hm-index bug this PR fixed is the template for how divergence bites. Deriving via indexesForCapabilities and overriding only match is safe: the byte-identical build() unit tests would catch any drift.

5. src/eql/v3/columns.ts (freeTextSearch) vs src/schema/index.ts — the five-field match-opts merge is duplicated, and only v3 clones.
match-defaults.ts unified the default values but both freeTextSearch implementations still hand-copy the identical opts?.x ?? defaults.x merge. The copies already differ in one real way: v3 wraps in cloneMatchOpts, v2 stores the caller's nested tokenizer/token_filters objects by reference — mutating a shared opts object after calling v2's freeTextSearch leaks into the built schema. A shared resolveMatchOpts(opts) in match-defaults.ts collapses both and gives v2 the clone-on-write protection for free.

6. src/encryption/v3.ts (decryptModel/bulkDecryptModels) — table.build()'s duplicate-column throw escapes the Result contract. (plausible, edge-case)
rowReconstructor(table) calls table.build(), which throws on duplicate DB column names — surfacing as a promise rejection on the otherwise Result-shaped decrypt path. Only reachable by passing typedClient a table that wasn't in the client's init schemas (init validation catches the supported path), and encryptModel never builds at operation time, so the asymmetry only bites decrypt. Either catch-and-return { failure } or validate tables in typedClient (the ..._schemas rest param is currently captured and unused).

7. .github/workflows/fta-v3.ymlsrc/schema/match-defaults.ts sits outside the v3 gate scope it now feeds.
The extracted defaults directly shape every emitted v3 (and v2) match index block, but the workflow's paths: filters and the planned Stryker mutate scope cover src/eql/v3/** only, so edits to the defaults never trigger the v3 gates. One-line path addition; mainly matters for the eventual mutation gate, since k: 6, m: 2048 are load-bearing ciphertext parameters.

8. src/eql/v3/table.tsV3DecryptedModel is character-for-character identical to V3ModelInput.
Both map columns to InferPlaintext<Table>[K] with identical nullability handling; V3EncryptedModel differs only in the mapped-to value. Keeping the semantic names is right, but export type V3DecryptedModel<…> = V3ModelInput<…> (or one shared mapped type parameterized on the value) prevents the input and output shapes from silently drifting when one copy is edited.

Noted but not counted

  • rowReconstructor still rebuilds the table config on every decryptModel/bulkDecryptModels call — but the PR strictly improved this (previously per-row on the bulk path), so it's a pre-existing pattern; a per-table precompute in typedClient remains an easy follow-up.
  • The implicit query-type flip for text_ord (unique now wins over ore in inferIndexType) was investigated and refuted as a regression: text_ord columns couldn't insert at all pre-PR, the behavior is test-asserted, and explicit queryType: 'orderAndRange' works — same documented escape hatch as text_search.

Automated multi-agent review (8 finder angles, per-candidate adversarial verification); findings verified against main post-merge.

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