Skip to content

feat(stack,cli): EQL v3 Supabase adapter (encryptedSupabaseV3) + v3 install path#547

Open
freshtonic wants to merge 61 commits into
mainfrom
james/cip-3300-spike-integrate-eql-v3-into-supabase-orm
Open

feat(stack,cli): EQL v3 Supabase adapter (encryptedSupabaseV3) + v3 install path#547
freshtonic wants to merge 61 commits into
mainfrom
james/cip-3300-spike-integrate-eql-v3-into-supabase-orm

Conversation

@freshtonic

@freshtonic freshtonic commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements EQL v3 on Supabase per the design spec in #546 (CIP-3300): encryptedSupabaseV3 at parity with the v2 encryptedSupabase adapter, using native eql_v3.* domain columns. Stacked on feat/eql-v3-types-module (#541), targeting that branch.

Core principle preserved from the spec: v3 mirrors v2 exactly — same query mechanism (direct EQL operators over PostgREST), same operator-visibility caveat + Exposed-schemas requirement, same install/permissions story. Only the column types and the wire encoding differ.

What's here

SDK adapter (@cipherstash/stack/supabase)

  • encryptedSupabaseV3 + EncryptedQueryBuilderV3Impl, a narrow subclass of EncryptedQueryBuilderImpl. The base class gains protected dialect seams whose defaults preserve v2 byte-for-byte (pinned by v2 regression tests); the v3 subclass overrides: column recognition, property↔DB name resolution (buildColumnKeyMap, aliased prop:db::jsonb select casts), raw-jsonb mutation payloads (no composite wrap), full-envelope filter operands, like/ilike → PostgREST cs, and Date reconstruction from cast_as.
  • Typing: rows default to InferPlaintext<Table> & Record<string, unknown>; with an explicit row type, storage-only columns (e.g. types.Bool) are excluded from filter methods at the type level (runtime guard always applies). The shared EncryptedQueryBuilder gains an optional filterable-keys generic (defaulted — v2 signatures unchanged).

DB bundle + install

  • v3 bundles vendored into packages/cli/src/sql/ via a checked-in derivation script (fallback strategy from the spec; the Supabase variant strips the two opclass chunks at their --! @file markers, mirroring upstream's **/*operator_class.sql exclusion glob; sync risk documented in the script).
  • stash db install --eql-version 3 [--supabase] — direct path only for now (--drizzle/--migration/--latest rejected with clear errors). Grants generalized: supabasePermissionsSql(schemaName) shared by v2/v3 (SUPABASE_PERMISSIONS_SQL unchanged, SUPABASE_PERMISSIONS_SQL_V3 added).
  • installEqlV3IfNeeded (test helper) is Supabase-aware: { supabase: true } = stripped bundle + eql_v3 grants.

Tests

  • 18 wire-encoding unit tests (mock encryption + supabase clients, run in CI without live creds) covering both dialects incl. v2 regression pins for every seam.
  • 7 type-level tests (supabase-v3.test-d.ts).
  • Live suite supabase-v3.test.ts mirroring the v2 suite (same env gating): round-trips incl. a Timestamptz column proving Date reconstruction, bulk models, text_search equality, free-text likecs, int4_ord equality + range, timestamptz_ord range with Date values.
  • v2 range baseline added to supabase.test.ts (gte/lte on an orderAndRange number column) — the spec flagged that encrypted range on Supabase had no CI-covered v2 baseline.
  • CLI: +7 installer/flag-validation tests (44 total pass).

Docs

  • stash-supabase skill: new EQL v3 section (setup, DDL mapping, install, the Exposed-schemas silent-fallback warning, v3 behaviour, shared caveats).
  • Recreated docs/reference/supabase-sdk.md (deleted in def9f4bd; fixes the dangling AGENTS.md link).
  • Changeset: minor for @cipherstash/stack + stash.

Deviations from the spec (found in the bundle source, not guessed)

  1. Full-envelope operands are required for every domain, not just text_search equality. The spec assumed single-capability domains' terms satisfy their own CHECKs (e.g. "an int4_ord range term carries ob, which is exactly what int4_ord requires"). They don't: every eql_v3.* domain CHECK also requires v, i, and c (see e.g. the int4_ord / text_eq CHECKs in the bundle), and protect-ffi's EncryptedScalarQuery is typed c?: never — query terms can never pass any domain CHECK. The (domain, jsonb) operator functions also cast their operand into the domain (b::eql_v3.text_search), so there is no coercion-free path. The adapter therefore encrypts all filter operands with the storage path; the operators extract the term they need (eq_term/ord_term/match_term). This also resolves the spec's open question fix(ci): set up bun in github actions for release #2 — the full envelope isn't just the cleanest way, it's the only way.
  2. like/ilike cannot stay like on the wire. The v3 domains define =,<>,<,<=,>,>=,@>,<@ but no ~~. Encrypted pattern filters are emitted as PostgREST cs (@> on match_term). Match is tokenized + downcased so like/ilike are equivalent; % wildcards should not be used.
  3. Free-text search needs include_original: false on the column's match index: with the default true, the full-envelope operand's bloom carries the whole pattern as an extra token, so substring patterns only match when equal to the stored value. Documented in the skill/reference; the live test's schema sets it explicitly with a load-bearing comment.

Verification

  • packages/stack: 0 src/ type errors; 25 unit tests + 7 type tests pass; live suites parse and gate correctly (skipped without env). Pre-existing __tests__ type errors and Not authenticated live-test failures on this branch are unchanged (no local CS_*/Supabase creds — same count at baseline).
  • packages/cli: typecheck error count identical to baseline (all pre-existing, unbuilt workspace deps); 44 tests pass; both packages tsup build cleanly, v3 bundles ship in dist/sql/.
  • Not verified here: the live Supabase suites need a project with eql_v3 in Exposed schemas + CS_* creds. The full-envelope equality behaviour matches the spec's live spike; the range/free-text assertions should be confirmed on the first live run.

Out of scope (per spec)

Plaintext→encrypted migration lifecycle; encrypted ORDER BY (OPE terms are the forward path); v3 in the Drizzle/Supabase-migration-file install paths; the spec's proposed install-time exposed-schema probe (documented as a firm follow-up — worth its own issue since it retroactively protects v2 too).

Closes CIP-3300. Design: #546.

Summary by CodeRabbit

  • New Features
    • Added EQL v3 support across the CLI and SDK, including --eql-version 2|3, v3 install/upgrade/status, and Supabase encryptedSupabaseV3.
    • Introduced EQL v3 typed client and new eql/v3 authoring DSL with stronger TypeScript inference (including Date plaintext support) and new v3 domain builders.
    • Added v3 Supabase query builder support for encrypted filters and field name mapping.
  • Bug Fixes
    • Strengthened validation for invalid filter inputs (including null) and non-finite numeric values; improved v3 decrypted Date reconstruction.
  • Documentation
    • Added end-to-end query walkthrough and Supabase SDK reference for v2 vs v3 behavior.
  • Tests
    • Expanded unit/type/integration coverage and added CI gates for v3 SQL regeneration sync and complexity checks.

@freshtonic freshtonic requested a review from a team as a code owner July 3, 2026 14:16
@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 21f8f3e

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

This PR includes changesets to release 7 packages
Name Type
@cipherstash/stack Minor
stash 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 Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 09ccfead-f033-4ec9-86a2-21c66029c197

📥 Commits

Reviewing files that changed from the base of the PR and between 299b680 and 21f8f3e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (86)
  • .changeset/eql-v3-supabase.md
  • .changeset/eql-v3-text-search.md
  • .changeset/eql-v3-typed-client.md
  • .changeset/eql-v3-typed-schema.md
  • .github/workflows/fta-v3.yml
  • .github/workflows/tests.yml
  • docs/query-api-walkthrough.md
  • docs/reference/supabase-sdk.md
  • docs/superpowers/plans/2026-06-30-eql-v3-text-search-schema-plan.md
  • docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md
  • docs/superpowers/specs/2026-06-30-eql-v3-text-search-schema-design.md
  • docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md
  • packages/cli/package.json
  • packages/cli/scripts/build-eql-v3-sql.mjs
  • packages/cli/src/__tests__/installer.test.ts
  • packages/cli/src/__tests__/supabase-migration.test.ts
  • packages/cli/src/bin/main.ts
  • packages/cli/src/commands/db/install.ts
  • packages/cli/src/commands/db/status.ts
  • packages/cli/src/commands/db/upgrade.ts
  • packages/cli/src/installer/index.ts
  • packages/cli/src/sql/cipherstash-encrypt-v3-supabase.sql
  • packages/cli/src/sql/cipherstash-encrypt-v3.sql
  • packages/cli/tests/helpers/run.ts
  • packages/stack/__tests__/cjs-require.test.ts
  • packages/stack/__tests__/encrypt-lock-context-guards.test.ts
  • packages/stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql
  • packages/stack/__tests__/helpers/eql-v3.ts
  • packages/stack/__tests__/helpers/live-gate.ts
  • packages/stack/__tests__/helpers/stub-auth-wasm-inline.ts
  • packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts
  • packages/stack/__tests__/model-column-mapping.test.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__/supabase-v3-builder.test.ts
  • packages/stack/__tests__/supabase-v3.test-d.ts
  • packages/stack/__tests__/supabase-v3.test.ts
  • packages/stack/__tests__/supabase.test.ts
  • packages/stack/__tests__/typed-client-v3.test-d.ts
  • packages/stack/__tests__/typed-client-v3.test.ts
  • packages/stack/__tests__/types-public-surface.test-d.ts
  • packages/stack/__tests__/v3-matrix/catalog.ts
  • packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts
  • packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-live.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts
  • packages/stack/__tests__/v3-matrix/matrix.test-d.ts
  • packages/stack/__tests__/v3-matrix/matrix.test.ts
  • packages/stack/__tests__/wasm-inline-column-name.test.ts
  • packages/stack/__tests__/wasm-inline-new-client.test.ts
  • packages/stack/__tests__/wasm-inline-strategy.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/bulk-encrypt.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/encryption/v3.ts
  • packages/stack/src/eql/v3/columns.ts
  • packages/stack/src/eql/v3/index.ts
  • packages/stack/src/eql/v3/table.ts
  • packages/stack/src/eql/v3/types.ts
  • packages/stack/src/identity/index.ts
  • packages/stack/src/schema/index.ts
  • packages/stack/src/schema/match-defaults.ts
  • packages/stack/src/supabase/helpers.ts
  • packages/stack/src/supabase/index.ts
  • packages/stack/src/supabase/query-builder-v3.ts
  • packages/stack/src/supabase/query-builder.ts
  • packages/stack/src/supabase/types.ts
  • packages/stack/src/types-public.ts
  • packages/stack/src/types.ts
  • packages/stack/src/wasm-inline.ts
  • packages/stack/tsconfig.typecheck.json
  • packages/stack/tsup.config.ts
  • packages/stack/vitest.config.ts
  • skills/stash-supabase/SKILL.md
✅ Files skipped from review due to trivial changes (10)
  • packages/cli/package.json
  • packages/stack/tests/wasm-inline-new-client.test.ts
  • packages/stack/src/types-public.ts
  • packages/stack/tests/helpers/stub-protect-ffi-wasm-inline.ts
  • docs/query-api-walkthrough.md
  • .changeset/eql-v3-typed-schema.md
  • .changeset/eql-v3-typed-client.md
  • .changeset/eql-v3-text-search.md
  • docs/reference/supabase-sdk.md
  • docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md
🚧 Files skipped from review as they are similar to previous changes (63)
  • packages/stack/tests/helpers/stub-auth-wasm-inline.ts
  • packages/stack/tests/v3-matrix/matrix-audit.test-d.ts
  • packages/stack/tests/model-column-mapping.test.ts
  • packages/stack/tsup.config.ts
  • packages/stack/tests/wasm-inline-column-name.test.ts
  • packages/stack/tests/wasm-inline-strategy.test.ts
  • packages/stack/tests/types-public-surface.test-d.ts
  • packages/stack/tsconfig.typecheck.json
  • packages/stack/src/eql/v3/index.ts
  • packages/stack/src/schema/match-defaults.ts
  • packages/stack/tests/v3-matrix/matrix.test.ts
  • packages/stack/tests/supabase-v3.test-d.ts
  • packages/stack/tests/v3-matrix/matrix-live.test.ts
  • packages/stack/tests/encrypt-lock-context-guards.test.ts
  • packages/stack/src/schema/index.ts
  • packages/stack/src/identity/index.ts
  • packages/stack/src/eql/v3/types.ts
  • packages/stack/src/encryption/operations/encrypt-model.ts
  • packages/stack/tests/v3-matrix/matrix-bulk.test.ts
  • packages/cli/src/bin/main.ts
  • packages/cli/src/commands/db/status.ts
  • packages/stack/tests/typed-client-v3.test.ts
  • packages/stack/tests/v3-matrix/matrix-lock-context.test.ts
  • packages/stack/tests/helpers/eql-v3.ts
  • packages/stack/tests/v3-matrix/matrix-keyset.test.ts
  • packages/stack/tests/v3-matrix/catalog.ts
  • packages/stack/src/encryption/operations/bulk-encrypt-models.ts
  • packages/cli/src/tests/supabase-migration.test.ts
  • packages/stack/tests/schema-v3-client.test.ts
  • packages/stack/tests/v3-matrix/matrix.test-d.ts
  • packages/stack/src/encryption/operations/encrypt-query.ts
  • packages/stack/tests/schema-v3.test-d.ts
  • .github/workflows/tests.yml
  • packages/stack/src/encryption/operations/bulk-encrypt.ts
  • packages/stack/vitest.config.ts
  • packages/stack/scripts/install-eql-v3.ts
  • packages/stack/src/encryption/v3.ts
  • packages/stack/tests/v3-matrix/matrix-identity-live.test.ts
  • skills/stash-supabase/SKILL.md
  • packages/stack/tests/supabase.test.ts
  • packages/cli/src/commands/db/upgrade.ts
  • packages/stack/tests/schema-v3.test.ts
  • packages/stack/tests/v3-matrix/matrix-live-pg.test.ts
  • packages/stack/src/wasm-inline.ts
  • packages/stack/tests/helpers/live-gate.ts
  • packages/stack/src/supabase/index.ts
  • packages/stack/tests/typed-client-v3.test-d.ts
  • packages/stack/src/supabase/query-builder-v3.ts
  • packages/stack/src/supabase/helpers.ts
  • packages/stack/src/encryption/operations/encrypt.ts
  • packages/stack/package.json
  • packages/stack/tests/schema-v3-pg.test.ts
  • packages/stack/src/eql/v3/columns.ts
  • packages/stack/tests/supabase-v3-builder.test.ts
  • packages/stack/src/encryption/helpers/infer-index-type.ts
  • packages/cli/src/tests/installer.test.ts
  • packages/stack/src/encryption/helpers/model-helpers.ts
  • packages/stack/src/eql/v3/table.ts
  • packages/cli/scripts/build-eql-v3-sql.mjs
  • packages/stack/src/types.ts
  • packages/stack/tests/supabase-v3.test.ts
  • packages/stack/src/supabase/query-builder.ts
  • packages/stack/src/supabase/types.ts

📝 Walkthrough

Walkthrough

This PR adds EQL v3 support across the stack: new v3 schema/table DSLs and public types, a typed v3 encryption client, Supabase v3 query handling, CLI --eql-version 3 install/upgrade/status support, package/CI wiring, and expanded tests and documentation.

Changes

EQL v3 Core and Client

Layer / File(s) Summary
Core v3 schema and type contracts
packages/stack/src/eql/v3/*, packages/stack/src/types.ts, packages/stack/src/types-public.ts
Adds v3 domain builders, table/config inference, structural buildable contracts, Plaintext widening, and new public subpath exports.
Typed encryption helpers and model mapping
packages/stack/src/encryption/*, packages/stack/src/identity/index.ts, packages/stack/src/wasm-inline.ts
Switches encrypt/model helpers to buildable tables and Plaintext, adds typed v3 decrypt reconstruction, and updates lock-context and column-name resolution.
Supabase v3 query builder
packages/stack/src/supabase/*
Adds encryptedSupabaseV3 and EncryptedQueryBuilderV3Impl, with v3-specific filter encoding, select casting, and decrypt postprocessing.
CLI EQL version-aware install/upgrade/status
packages/cli/src/*, packages/cli/scripts/build-eql-v3-sql.mjs
Adds --eql-version handling, v3 bundle selection, Supabase permission generation, and generation-aware command behavior.
Build, package export, and CI configuration
packages/stack/package.json, tsup.config.ts, vitest.config.ts, .github/workflows/*
Adds v3 exports/entry points, typecheck scoping, wasm-inline stubs, and CI jobs for v3 checks.
Tests and supporting helpers
packages/stack/__tests__/*, packages/cli/src/__tests__/*
Adds runtime/type/live coverage and helper fixtures for the new v3 surface and behaviors.
Documentation, specs, and changesets
docs/*, skills/stash-supabase/SKILL.md, .changeset/*
Adds release notes, design/spec history, walkthrough docs, and Supabase guidance for the v3 feature set.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

  • cipherstash/stack#366: Both PRs modify the CLI installer Supabase permissions SQL path in packages/cli/src/installer/index.ts.
  • cipherstash/stack#540: Both PRs share the v3 resolveIndexType equality-via-ore behavior and related matrix coverage.
  • cipherstash/stack#317: Both PRs touch the Supabase wrapper’s encrypted filter/mutation wire encoding path.

Suggested reviewers: coderdan, auxesis

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: the EQL v3 Supabase adapter and the new v3 install path.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch james/cip-3300-spike-integrate-eql-v3-into-supabase-orm

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 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Consolidated & verified reviews from multiple sources

  • CodeRabbit
  • Claude code review
  • Test coverage

Every item below was re-verified against the branch by a dedicated agent. Verdicts: ✅ confirmed · ⚠️ partially-correct (claim adjusted) · ❌ false positive.


🔴 Runtime bugs (fix before merge)

  • db status and db upgrade are hardcoded to EQL v2 — both omit eqlVersion, so isInstalled/getInstalledVersion default to the eql_v2 schema (installer/index.ts:201,224). A v3-only DB reports "EQL is not installed" (status.ts:28,86) and upgrade aborts / queries eql_v2.version() (upgrade.ts:30,41,71; it has no --eql-version at all). User-visible false negative on the install path this PR ships. → thread eqlVersion through, or auto-detect the installed schema.
  • identify() is a silent auth-downgrade footgunidentity/index.ts:129-180 still fetches + stores a CTS token that is now discarded everywhere (auth moved to the client-level strategy). An upgraded caller who keeps lc.identify(jwt) but doesn't configure OidcFederationStrategy silently authenticates as the service identity — fail-open, no runtime signal (only @deprecated JSDoc). → emit a logger.warn on the discard; consider dropping the now-pointless network fetch.
  • null filter operand serializes to the literal string "null"encrypt(null) short-circuits to null (encrypt.ts:64), then JSON.stringify"null" reaches q.eq(col,'null') (query-builder-v3.ts:203). Reach is higher than flagged: operands are typed unknown, so .eq('email', null) / a null in .in([...]) compiles with no cast → silent wrong rows. → guard null before encrypt (throw, or route to .is(col,null)).
  • date column selected under a PostgREST alias loses Date reconstructionpostprocessDecryptedRow only inspects [property, dbName] (query-builder-v3.ts:248); select('ts:created_at') keys the row ts, so it stays an ISO string, silently. (Related, lower: selecting a property-mapped column by raw DB name leaves Row.<prop> undefined — helpers.ts:123.)
  • bulk-encrypt id ${idx}-${key} corrupts field names containing -key.split('-')[1] truncates at the first hyphen (model-helpers.ts:670,700-704), duplicated across all 4 multi-model fns. Pre-existing (commit f1248d5f, not this branch), low real-world reach (hyphenated JS props are rare; nested dotted paths widen it). → split on first - only, or use a tuple id.

🟠 Type-safety — the advertised v3 filter-key guard is currently a no-op

  • default Row widens keyof to stringRow = InferPlaintext<Table> & Record<string,unknown> (types.ts:79-80) collapses V3FilterableKeysFK = string, so every filter method accepts non-queryable columns. ⚠️ The fix (drop the intersection) is a behavior change: non-schema passthrough columns in the default-Row case become compile errors; passthrough callers must pass an explicit Row. Mirror site at index.ts:106.
  • match() uses Partial<T>, not FK-narrowed — lone exception among filter methods (types.ts:356); .match({storageOnlyCol}) type-checks then 500s at runtime. Fix Partial<Pick<T,FK>> — but inert until the Row fix above lands (Pick<T,string>=T). Ship the two together.

🟡 Test coverage

  • v3 lock-context live round-trip is a false-green — bare return (not it.skipIf) when USER_JWT is absent, and USER_JWT is set nowhere in CI → the test reports PASSED while running nothing (v3-matrix/matrix-identity-live.test.ts). Offline mocked wiring exists, so bounded. Same anti-pattern in the v2 suites it was copied from. → it.skipIf(!process.env.USER_JWT).
  • installCommand v3 orchestration untested — the drizzle→direct fallback + supabase/v2 migration-skip branches (install.ts:119-148) have zero coverage; only the pure validateInstallFlags helper is tested. → extract/export the branch logic and test it.
  • ⚠️ OidcFederationStrategy behavioral test — literal claim is false: init-strategy.test.ts does wire a strategy into config.strategy and asserts it reaches newClient. But the real OidcFederationStrategy.create/getToken is only exercised by the USER_JWT-gated dead test → not CI-covered. → add an offline smoke test on the real .create shape.
  • .or() / transformOrConditions untested — the most complex v3 builder fork (query-builder-v3.ts:232), no coverage at any level. → assert the produced or string incl. encrypted ilikecs remap + encryptedIndexes alignment.
  • ⚠️ matrix-live-pg oversells coverage — for ord domains it proves equality-via-ORE, not range/order (</>); per-test names are honest, only the top-line "query-correctness per domain" framing overstates. → add a real ordering assertion, or rename the framing.

🟢 CI / release process

  • vendored SQL can drift silentlybuild-eql-v3-sql.mjs (generates ~29k-line bundles) is wired into no package.json script or CI check; self-flagged "sync risk". → add a gen: script + a regenerate-and-git diff --exit-code CI gate.
  • ⚠️ region removed / workspaceCrn required (breaking) — real & confirmed (wasm-inline.ts), but is documented in .changeset/stack-protect-ffi-0-26-oidc-strategy.md, and "needs a major bump" is wrong for 0.18.0 (pre-1.0) — the minor changeset is semver-correct. → just add an explicit regionworkspaceCrn migration sentence.
  • server-side-only lock-context enforcement is now the whole model (client can no longer throw) — intended, but not spelled out. → add one line to the changeset.

⚪ Maintainability / perf (minor)

  • reconstructRow rebuilds table config per rowtable.build() + buildColumnKeyMap() are row-invariant but called per row (v3.ts:135,182); 10k rows = ~350k redundant ColumnSchema allocations. → hoist out of the map.
  • NaN/Infinity guard duplicated — a shared helper already exists (validation.ts) yet encrypt.ts:74-86 & :163-169 inline byte-identical copies. → call assertValidNumericValue.
  • like/ilike → cs rule re-derived in 3 seamsquery-builder-v3.ts:216,226,239; miss one and .like()/.not()/.or() disagree on the wire op. → one encryptedFilterOp(op, wasEncrypted).
  • stripOperatorClassChunks + grants duplicated with divergence — the test-helper copy (__tests__/helpers/eql-v3.ts:28) drops the removedChunks !== 2 assertion the CLI script relies on (build-eql-v3-sql.mjs:40) and re-inlines the grant block instead of importing SUPABASE_PERMISSIONS_SQL_V3. → share one module.
  • misc duplication — match-block deep-clone ×3 (columns.ts:356,473,494cloneMatchOpts); V3ColumnLike == BuildableV3QueryableColumn (query-builder-v3.ts:28 vs types.ts:168); defaultMatchOpts values dup v2; LIVE_CIPHERSTASH_ENABLED/describeLive gate copy-pasted in 5 test files (mega-table harness in only 2 — "~6" overstated).

Nits

  • resolveLockContext throws a raw TypeError on null (identity/index.ts:41-48) → domain error.
  • ✅ CRLF footgun in the @file regex (build-eql-v3-sql.mjs:37, dup eql-v3.ts:34) → split on /\r?\n/.
  • ✅ dry-run output doesn't indicate v3 (install.ts:177-184).
  • ✅ stale src/schema/v3 path comment (infer-index-type.ts:82) → src/eql/v3.
  • ⚠️ InstallOptions.eqlVersion: string'2'|'3' (install.ts:73) — defensible at the CLI boundary.
  • as never wall (v3.ts:162-215) — localized, guarded by satisfies; intended, no change.

❌ Not actionable

  • timestamptz range test "will fail on truncation" (supabase-v3.test.ts:317) — false positive. All values are midnight-UTC, so cast_as:'date' truncation is a no-op; range filter + .toISOString() both pass. Same technique as the un-skipped createdOn test.
  • keep occurredAt test skipped (schema-v3-client.test.ts:245) — already it.skip with an accurate rationale. No-op.

🤖 Generated with Claude Code

freshtonic added a commit that referenced this pull request Jul 4, 2026
… status/upgrade, drift gate

Runtime (stack adapter):
- reject null filter operands with a pointer to .is(col, null) —
  encrypt(null) short-circuited to null and JSON.stringify sent the
  literal string "null" as the operand
- Date reconstruction now covers user-chosen PostgREST aliases:
  addJsonbCastsV3 returns the result-key→DB-column map the select
  actually produces and postprocessDecryptedRow consumes it (static
  property/DB names remain the fallback for no-select paths)
- single source for the encrypted like/ilike→cs remap
  (encryptedFilterOp), consumed by applyPatternFilter,
  notFilterOperator, and transformOrConditions

Type safety (behavior change):
- the v3 builder's default Row is exactly InferPlaintext<Table> — the
  previous `& Record<string, unknown>` widening collapsed
  V3FilterableKeys to string, silently disabling the storage-only
  filter guard. Passthrough columns now need an explicit Row.
- match() is FK-narrowed (Partial<Pick<T, FK>>) like every other
  filter method

CLI:
- db status reports v2 and v3 installs independently (a v3-only DB no
  longer reads "EQL is not installed")
- db upgrade accepts --eql-version, rejects v3+--latest, and points at
  the other generation when the requested one isn't installed
- v3 path routing extracted to pure routeInstallPathForEqlVersion
  (drizzle fallback + migration-mode skip) with unit tests
- dry-run output names the v3 bundle; CRLF-safe @file regex in the
  bundle derivation script
- gen:eql-v3-sql script + CI regenerate-and-diff gate so the vendored
  bundles cannot drift from the fixture silently
- test helper reads the CLI-vendored Supabase bundle instead of
  duplicating the strip logic (live suite now installs exactly what
  `stash db install --eql-version 3 --supabase` installs)

Tests: +6 builder unit tests (or() structured/string/verbatim, null
guard, is-null passthrough, aliased-date), +2 type tests (default-Row
narrowing, match() FK), +3 CLI routing tests. Docs + changeset updated
for the Row semantics; stale src/schema/v3 path comment fixed.
@freshtonic

Copy link
Copy Markdown
Contributor Author

Thanks — addressed in 53d8421. Point-by-point below. Items whose flagged code predates this branch (main / the stacked base PRs) are dispositioned at the end rather than folded into this diff.

🔴 Runtime bugs

  • db status / db upgrade hardcoded to v2 — ✅ fixed. status now checks both schemas and reports v2 and v3 installs independently (a v3-only DB no longer reads "EQL is not installed"). upgrade accepts --eql-version, rejects v3+--latest, and when the requested generation isn't installed it points at the one that is (…but EQL v2 is. Re-run with --eql-version 2…).
  • null filter operand → literal "null" — ✅ fixed. encryptCollectedTerms rejects null operands with a pointer to .is(col, null); .is() null checks pass through untouched. Unit tests for both.
  • aliased date column loses Date reconstruction — ✅ fixed properly rather than patched: addJsonbCastsV3 now returns the result-key→DB-column map the select actually produces (including user aliases like ts:created_at), and postprocessDecryptedRow consumes it, with the static property/DB names as fallback for no-select paths. This also covers the "selected by raw DB name" sibling. Unit test: select('id, ts:createdAt')row.ts instanceof Date.
  • identify() silent auth-downgrade — agreed and real, but identity/index.ts is main-branch code untouched by this PR (it landed with the protect-ffi 0.26 strategy work, feat(stack): protect-ffi 0.26.0 + auth 0.39 OidcFederationStrategy (stacked on #496) #497). Filing separately rather than smuggling an identity-layer change into a Supabase adapter PR.
  • bulk-encrypt ${idx}-${key} split — pre-existing (f1248d5f, as you noted). Same disposition: separate fix.

🟠 Type safety

  • default Row widening → guard no-op — ✅ fixed, taking the behavior change you called: default Row is now exactly InferPlaintext<Table> (both EncryptedSupabaseV3Instance.from and the factory mirror). The storage-only guard is real in the default case; passthrough columns need an explicit Row. Doc comments on the type, the skill, the reference doc, and the changeset all state the trade-off. Type tests cover default-case narrowing (eq('active', …) rejected without any explicit Row) and the passthrough consequence (eq('id', …) needs one).
  • match() not FK-narrowed — ✅ fixed (Partial<Pick<T, FK>>), shipped together with the Row fix as you specified. Type test added.

🟡 Test coverage

  • .or() / transformOrConditions untested — ✅ three unit tests: structured or() (db-name mapping + ilikecs remap + encrypted operand quoting + plain-condition passthrough, exercising encryptedIndexes alignment), string-form or() with substitution, and the verbatim no-encrypted path.
  • installCommand v3 orchestration untested — ✅ the branch logic is extracted to pure routeInstallPathForEqlVersion (drizzle fallback + notice, migration-mode skip) with three unit tests; installCommand consumes it.
  • v3 lock-context false-green (matrix-identity-live), OidcFederationStrategy smoke test, matrix-live-pg framing — all in base-branch test files (refactor(stack): EQL v3 types namespace on @cipherstash/stack/eql/v3 #541 and below), not this diff. Flagging on the base PR / filing rather than editing them here.

🟢 CI / release

  • vendored SQL drift — ✅ gen:eql-v3-sql script in the cli package + a CI step in tests.yml that regenerates and git diff --exit-codes the two bundles.
  • regionworkspaceCrn migration sentence + lock-context enforcement line — those live in .changeset/stack-protect-ffi-0-26-oidc-strategy.md, which belongs to the main-branch protect-ffi work; editing it here would tangle the stack. Happy to PR that one-liner against main.

⚪ Maintainability

  • like/ilike → cs re-derived ×3 — ✅ consolidated into one encryptedFilterOp(op, wasEncrypted); all three seams derive from it.
  • strip/grants duplication in the test helper — ✅ better than shared-module: the helper now reads the CLI-vendored cipherstash-encrypt-v3-supabase.sql directly, so the strip logic exists once (in the generation script, with its chunk-count assertion) and the live suite installs exactly what stash db install --eql-version 3 --supabase installs. The grants block stays inlined (the stack test context can't resolve the cli package's pg dependency) with a comment pinning it to SUPABASE_PERMISSIONS_SQL_V3.
  • V3ColumnLike vs BuildableV3QueryableColumn — kept separate deliberately, now with a comment explaining why: BuildableV3QueryableColumn pins isQueryable(): true, and the dialect's column map intentionally retains storage-only columns (where that's false) so filters on them produce a precise error instead of passing through unencrypted.
  • reconstructRow per-row rebuild, NaN-guard dup, cloneMatchOpts, defaultMatchOpts dup, live-gate copy-paste — all in base-branch/main files (encryption/v3.ts, encrypt.ts, columns.ts, the pre-existing suites). Not folded in; noting for the base PRs. (This PR's own builder already hoists the table config at construction.)

Nits

  • ✅ CRLF-safe @file regex in the generation script (the helper-side duplicate is gone entirely).
  • ✅ dry-run output names the v3 bundle.
  • ✅ stale src/schema/v3 comment → src/eql/v3 (one-liner, took it even though it's a base-branch file).
  • InstallOptions.eqlVersion: string — keeping, per your "defensible at the CLI boundary".
  • resolveLockContext raw TypeError — main-branch identity code; same disposition as identify().

Verification: stack — 24 builder unit tests, 8 type tests, 0 src/ type errors; cli — all 333 tests pass (installer/flag/routing coverage included); live suites still parse and gate correctly.

@freshtonic

Copy link
Copy Markdown
Contributor Author

Follow-up: the three deferred groups from my reply above now have their fixes up —

@freshtonic freshtonic requested review from coderdan and tobyhede July 4, 2026 03:41
freshtonic added a commit that referenced this pull request Jul 4, 2026
chore(stack): EQL v3 maintainability follow-ups from the #547 review
@freshtonic freshtonic force-pushed the james/cip-3300-spike-integrate-eql-v3-into-supabase-orm branch from 53d8421 to 51c25dc Compare July 4, 2026 04:01
freshtonic added a commit that referenced this pull request Jul 4, 2026
- 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.
freshtonic added a commit that referenced this pull request Jul 4, 2026
… status/upgrade, drift gate

Runtime (stack adapter):
- reject null filter operands with a pointer to .is(col, null) —
  encrypt(null) short-circuited to null and JSON.stringify sent the
  literal string "null" as the operand
- Date reconstruction now covers user-chosen PostgREST aliases:
  addJsonbCastsV3 returns the result-key→DB-column map the select
  actually produces and postprocessDecryptedRow consumes it (static
  property/DB names remain the fallback for no-select paths)
- single source for the encrypted like/ilike→cs remap
  (encryptedFilterOp), consumed by applyPatternFilter,
  notFilterOperator, and transformOrConditions

Type safety (behavior change):
- the v3 builder's default Row is exactly InferPlaintext<Table> — the
  previous `& Record<string, unknown>` widening collapsed
  V3FilterableKeys to string, silently disabling the storage-only
  filter guard. Passthrough columns now need an explicit Row.
- match() is FK-narrowed (Partial<Pick<T, FK>>) like every other
  filter method

CLI:
- db status reports v2 and v3 installs independently (a v3-only DB no
  longer reads "EQL is not installed")
- db upgrade accepts --eql-version, rejects v3+--latest, and points at
  the other generation when the requested one isn't installed
- v3 path routing extracted to pure routeInstallPathForEqlVersion
  (drizzle fallback + migration-mode skip) with unit tests
- dry-run output names the v3 bundle; CRLF-safe @file regex in the
  bundle derivation script
- gen:eql-v3-sql script + CI regenerate-and-diff gate so the vendored
  bundles cannot drift from the fixture silently
- test helper reads the CLI-vendored Supabase bundle instead of
  duplicating the strip logic (live suite now installs exactly what
  `stash db install --eql-version 3 --supabase` installs)

Tests: +6 builder unit tests (or() structured/string/verbatim, null
guard, is-null passthrough, aliased-date), +2 type tests (default-Row
narrowing, match() FK), +3 CLI routing tests. Docs + changeset updated
for the Row semantics; stale src/schema/v3 path comment fixed.
@freshtonic freshtonic changed the base branch from feat/eql-v3-types-module to main July 4, 2026 04:01
@freshtonic

Copy link
Copy Markdown
Contributor Author

Rebased this branch onto origin/main and force-pushed (53d8421051c25dcb); base retargeted feat/eql-v3-types-modulemain to match. The PR is now self-contained: it carries the full EQL v3 stack (the #535 / #541 commits), the #550 maintainability follow-ups (merged into the old base and picked up during the rebase), and the Supabase adapter work — 57 commits total.

Rebase notes:

  • Zero conflicts across the replay. The two overlap files merged cleanly and were verified semantically: model-helpers.ts keeps fix(stack): bulk-model id split corrupts field names containing hyphens #548's fieldsForModelIndex fix at all four reconstruction sites (no naive split('-') reintroduced by the v3 commits), and pnpm-lock.yaml passes pnpm install --frozen-lockfile.
  • Post-rebase verification: 0 src/ type errors in packages/stack; supabase builder + bulk-hyphen + schema-v3 + schema-builders suites (218 tests) and all 333 cli tests pass.

@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: 6

Caution

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

⚠️ Outside diff range comments (2)
packages/stack/src/supabase/query-builder.ts (2)

608-615: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Route raw encrypted filters through query-type and operator seams.

For v3, .filter('email', 'like', value) is collected as an equality term and later sent as like, bypassing the v3 like/ilikecs remap. Add seams for raw-filter query type/operator so encrypted raw pattern filters use freeTextSearch and cs.

Also applies to: 918-922

🤖 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/supabase/query-builder.ts` around lines 608 - 615, Raw
encrypted filters are being recorded as equality terms in the query builder,
which bypasses the v3 query-type/operator remap for pattern matching. Update the
raw-filter handling in QueryBuilder so the path that pushes terms and the
corresponding raw term mapping carry the actual query type and operator seams
instead of hardcoding equality/composite-literal. Use the existing
QueryBuilder/raw-filter symbols and ensure `.filter(..., 'like'/'ilike', ...)`
routes through freeTextSearch and remaps to cs for encrypted raw filters.

704-706: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Map ordered columns through the dialect seam.

Filters/selects/mutations resolve v3 property names to DB names, but order('createdAt') still reaches PostgREST as createdAt instead of created_at.

Proposed fix
         case 'order':
-          query = query.order(t.column, t.options)
+          query = query.order(this.filterColumnName(t.column), t.options)
           break
🤖 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/supabase/query-builder.ts` around lines 704 - 706, The
order clause in the query builder is bypassing the dialect seam, so v3 property
names are not being mapped to database column names before reaching PostgREST.
Update the `query-builder.ts` handling for the `order` case in the
query-building flow to resolve `t.column` through the same dialect mapping used
by filters/selects/mutations, then pass the translated DB column into
`query.order(...)`. Make the change in the `order` branch of the query builder
logic so `order('createdAt')` is sent as the DB name rather than the property
name.
🧹 Nitpick comments (10)
packages/stack/src/types.ts (1)

253-255: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the public EncryptOptions.column doc to match the widened contract.

Line 253 still documents only EncryptedColumn / EncryptedField, but Line 254 now accepts any BuildableColumn, including v3 builders.

Proposed doc update
 export type EncryptOptions = {
-  /** The column or nested field to encrypt into. From {`@link` EncryptedColumn} or {`@link` EncryptedField}. */
+  /** The column or nested field to encrypt into. Accepts v2/v3 buildable storage columns. */
   column: BuildableColumn // storage: fields are encryptable, so stays wide
   table: BuildableTable
 }
🤖 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 253 - 255, The public
EncryptOptions.column documentation still implies only
EncryptedColumn/EncryptedField, but the type now accepts any BuildableColumn.
Update the doc comment on EncryptOptions.column in types.ts to describe the
widened contract and mention that v3 builders are supported, keeping the wording
aligned with the actual BuildableColumn type and related EncryptOptions symbols.
packages/stack/__tests__/encrypt-lock-context-guards.test.ts (1)

51-55: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Env var set without cleanup across test runs.

process.env.CS_WORKSPACE_CRN is set in beforeEach but never restored/deleted in an afterEach. If other test files run in the same worker process, this can leak into unrelated suites and cause flaky/order-dependent behavior.

🧹 Suggested cleanup
+afterEach(() => {
+  delete process.env.CS_WORKSPACE_CRN
+})
+
 beforeEach(async () => {
   vi.clearAllMocks()
   process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace'
   client = await Encryption({ schemas: [users, usersV3] })
 })
🤖 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__/encrypt-lock-context-guards.test.ts` around lines 51
- 55, The test setup in beforeEach is mutating process.env.CS_WORKSPACE_CRN
without cleaning it up, which can leak state into other suites. Update the
encrypt-lock-context-guards.test.ts setup around beforeEach/afterEach to save
the previous value, restore or delete CS_WORKSPACE_CRN after each test, and keep
the environment isolated for the Encryption test cases. Use the existing
vi.clearAllMocks and client setup as the anchor points when adding the cleanup.
packages/stack/src/schema/index.ts (1)

354-366: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

User-supplied match opts aliased by reference, unlike the v3 counterpart.

defaults are fresh objects, but when a caller passes opts.tokenizer/opts.token_filters, they're stored by reference (not cloned). The v3 EncryptedTextSearchColumn.freeTextSearch (which shares defaultMatchOpts) explicitly wraps its merged result in cloneMatchOpts for exactly this reason — a caller mutating their own opts object after calling .freeTextSearch(opts) can corrupt the column's stored index config. Worth aligning v2 with the same defensive copy for consistency and correctness.

🔧 Suggested fix
   freeTextSearch(opts?: MatchIndexOpts) {
     // Shared defaults (schema/match-defaults) — one source of truth with the
     // EQL v3 domain builders. The factory returns fresh nested objects.
     const defaults = defaultMatchOpts()
-    this.indexesValue.match = {
-      tokenizer: opts?.tokenizer ?? defaults.tokenizer,
-      token_filters: opts?.token_filters ?? defaults.token_filters,
-      k: opts?.k ?? defaults.k,
-      m: opts?.m ?? defaults.m,
-      include_original: opts?.include_original ?? defaults.include_original,
-    }
+    this.indexesValue.match = cloneMatchOpts({
+      tokenizer: opts?.tokenizer ?? defaults.tokenizer,
+      token_filters: opts?.token_filters ?? defaults.token_filters,
+      k: opts?.k ?? defaults.k,
+      m: opts?.m ?? defaults.m,
+      include_original: opts?.include_original ?? defaults.include_original,
+    })
     return this
   }

(requires importing cloneMatchOpts alongside defaultMatchOpts from ./match-defaults)

🤖 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/index.ts` around lines 354 - 366, The
freeTextSearch method in Schema is storing caller-provided match options by
reference, so later mutations to opts.tokenizer or opts.token_filters can leak
into this.indexesValue.match. Update freeTextSearch to defensively clone the
merged match options, matching the EncryptedTextSearchColumn.freeTextSearch
behavior and using cloneMatchOpts with defaultMatchOpts. Also import
cloneMatchOpts alongside defaultMatchOpts so the stored index config is isolated
from external object mutation.
packages/stack/__tests__/model-column-mapping.test.ts (1)

1-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reaching into private helper for tests.

This test imports resolveEncryptColumnMap directly from @/encryption/helpers/model-helpers, which is not re-exported from the public eql/v3 barrel. As per path instructions for packages/**/__tests__/**/*.test.{ts,tsx}: "Prefer testing via public API; avoid reaching into private internals in tests."

🤖 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__/model-column-mapping.test.ts` around lines 1 - 39,
The test is importing a private helper directly instead of exercising the public
API, so update it to cover the same behavior through the exported surface from
encryptedTable/encryptedColumn and the eql/v3 barrel. Keep the assertions about
JS property matching and DB-name resolution, but move the setup to the public
model path so the test validates resolveEncryptColumnMap indirectly without
importing it from model-helpers.

Source: Path instructions

packages/stack/src/eql/v3/columns.ts (1)

297-324: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

EncryptedTextSearchColumn.build() duplicates indexesForCapabilities output instead of reusing it.

The override hand-rolls { unique: { token_filters: [] }, ore: {} }, which is exactly what indexesForCapabilities(this.getQueryCapabilities(), 'string') would already produce for TEXT_SEARCH capabilities (equality+orderAndRange with castAs: 'string'). If the shared unique/ore derivation rules ever change (e.g. a new castAs branch), this override won't pick up the change and could silently diverge from the rest of the domain matrix.

♻️ Proposed refactor to delegate to the shared helper
   override build(): ColumnSchema {
-    // Deep-clone the match block so the returned config NEVER aliases this
-    // builder's internal `matchOpts` (or any caller-supplied opts merged into
-    // it). A caller mutating the returned object cannot corrupt this builder's
-    // state or another column's defaults.
-    return {
-      cast_as: 'string',
-      indexes: {
-        unique: { token_filters: [] },
-        ore: {},
-        match: cloneMatchOpts(this.matchOpts),
-      },
-    }
+    // Deep-clone the match block so the returned config NEVER aliases this
+    // builder's internal `matchOpts` (or any caller-supplied opts merged into
+    // it). A caller mutating the returned object cannot corrupt this builder's
+    // state or another column's defaults.
+    return {
+      cast_as: 'string',
+      indexes: {
+        ...indexesForCapabilities(TEXT_SEARCH, 'string'),
+        match: cloneMatchOpts(this.matchOpts),
+      },
+    }
   }

Also applies to: 441-456

🤖 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/eql/v3/columns.ts` around lines 297 - 324,
`EncryptedTextSearchColumn.build()` is duplicating the shared index-derivation
logic instead of delegating to `indexesForCapabilities()`. Replace the
hand-built indexes object in `EncryptedTextSearchColumn.build()` with a call to
`indexesForCapabilities(this.getQueryCapabilities(), 'string')` so `TEXT_SEARCH`
keeps matching the centralized capability rules, and ensure any other build
paths that construct the same `{ unique, ore }` shape are updated to use the
helper as well.
packages/stack/__tests__/helpers/live-gate.ts (1)

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

Module-load-time env gates are import-order fragile.

LIVE_CIPHERSTASH_ENABLED/LIVE_EQL_V3_PG_ENABLED are computed once at import time. The comment correctly documents that callers must import 'dotenv/config' first, but nothing enforces that ordering — a future test file that imports this helper before dotenv/config would silently get false gates instead of an error. Not urgent given the documented convention is followed everywhere today.

🤖 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__/helpers/live-gate.ts` around lines 14 - 29, The live
test gates in live-gate.ts are computed at module load time, so importing this
helper before dotenv/config can silently freeze them to false. Update
LIVE_CIPHERSTASH_ENABLED and LIVE_EQL_V3_PG_ENABLED to be evaluated after env
loading, or add an explicit runtime check/error in the live gating helpers
(describeLive, describeLivePg) so import order is no longer fragile.
packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts (1)

175-213: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Parallelize per-domain query-term encryption in beforeAll.

The eqDomains/ordDomains/matchDomains loops await client.encryptQuery(...) sequentially, one network round-trip per domain (up to ~35). Batching with Promise.all would cut setup latency significantly without changing behavior.

⚡ Proposed fix
-  for (const [t, spec] of eqDomains) {
-    eqTerms[slug(t)] = unwrapResult(
-      await client.encryptQuery(
-        spec.samples[0] as never,
-        {
-          table,
-          column: columnRef(t),
-          queryType: 'equality',
-        } as never,
-      ),
-    )
-  }
+  await Promise.all(
+    eqDomains.map(async ([t, spec]) => {
+      eqTerms[slug(t)] = unwrapResult(
+        await client.encryptQuery(
+          spec.samples[0] as never,
+          { table, column: columnRef(t), queryType: 'equality' } as never,
+        ),
+      )
+    }),
+  )

Apply the same pattern to the ordDomains and matchDomains loops.

🤖 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__/v3-matrix/matrix-live-pg.test.ts` around lines 175 -
213, The per-domain setup in `beforeAll` is doing `await
client.encryptQuery(...)` sequentially in the `eqDomains`, `ordDomains`, and
`matchDomains` loops, which adds unnecessary latency. Refactor the `eqTerms`,
`ordTerms`, and `matchTerms` population in `matrix-live-pg.test.ts` to build
promises per domain and resolve them with `Promise.all`, preserving the same
`queryType` and `columnRef(t)` behavior while parallelizing the network
round-trips.
packages/stack/scripts/install-eql-v3.ts (1)

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

Move the shared installer helper out of __tests__.

This script performs live installation work but imports from the test tree. If tests are excluded from a packaged or script-only context, install-eql-v3.ts stops resolving. Prefer a shared non-test helper and import it from both tests and this script.

🤖 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 14, The live installer
script currently depends on a helper inside the test tree, which can break when
tests are not packaged or available. Move installEqlV3IfNeeded out of the
__tests__ helpers into a shared non-test module, then update install-eql-v3.ts
and any test callers to import that shared helper instead so the script resolves
in script-only builds.
.github/workflows/fta-v3.yml (1)

34-35: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add persist-credentials: false to the read-only checkout.

This job only reads source for static analysis (permissions: contents: read, no push). The repo already applies persist-credentials: false on comparable read-only jobs (e.g. wasm-e2e-tests in tests.yml); this new workflow should follow the same pattern to avoid leaving a usable token in the workspace.

🔒 Proposed fix
       - name: Checkout Repo
         uses: actions/checkout@v6
+        with:
+          persist-credentials: false
🤖 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 @.github/workflows/fta-v3.yml around lines 34 - 35, The Checkout Repo step in
this read-only workflow should not leave credentials in the workspace, so update
the actions/checkout usage to disable persisted auth. Add persist-credentials
set to false on the checkout step in this workflow, matching the read-only
pattern already used elsewhere, and keep the change scoped to the checkout
action configuration.

Source: Linters/SAST tools

packages/cli/src/commands/db/upgrade.ts (1)

18-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared EQL version validation

upgrade.ts duplicates the same unknown --eql-version check and the v3/--latest incompatibility already centralized in install.ts. Pulling this into a shared helper would keep the two commands aligned and avoid drift.

🤖 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/src/commands/db/upgrade.ts` around lines 18 - 37, The EQL
version checks in upgrade flow are duplicated and should be centralized to match
the existing install command logic. Extract the shared validation from
upgrade.ts into the same helper used by install.ts, then have the upgrade
command call that helper instead of repeating the unknown `--eql-version` and
`--eql-version 3` with `--latest` checks. Keep the behavior and error messaging
consistent by reusing the same validation entry point referenced by the upgrade
command’s version parsing and the install command’s option validation.
🤖 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 @.changeset/eql-v3-typed-client.md:
- Around line 20-29: Update the v3 release note in the changeset to remove the
bigint/int8 claim and keep it limited to Date reconstruction only. Adjust the
wording around the typed decrypt paths so it matches the current behavior in v3
and the deferred bigint handling in the v3 encryption code, without mentioning
bigint support in the release note.

In @.changeset/eql-v3-typed-schema.md:
- Around line 5-7: Update the changeset text in the EQL v3 schema builders note
to remove any mention of bigint support and keep the release note focused on
Date-only plaintext support; the v3 int8/bigint domain should not be advertised
yet. Keep the rest of the summary about `@cipherstash/stack/eql/v3`, `types`,
`getQueryCapabilities()` / `isQueryable()`, and the model encryption helpers
unchanged.

In `@packages/cli/src/commands/db/install.ts`:
- Around line 119-124: The permission check in EQL install is still using
v2-only logic, which can wrongly require CREATE on public for v3 installs.
Thread eqlVersion through EQLInstaller.checkPermissions and the call site in
db/install.ts, and update checkPermissions so it skips the
public.eql_v2_encrypted validation when eqlVersion is 3 while preserving the
existing v2 behavior.
- Around line 555-566: The EQL v3 validation in the install command currently
rejects --drizzle, --migration, and --latest, but still allows --migrations-dir
to slip through because v3 uses the direct install path. Update the
compatibility check in the install flow (the options.eqlVersion === '3' block in
db/install.ts) to also treat --migrations-dir as incompatible, using the same
return-message pattern as the existing flags so it is rejected before any direct
DB modification occurs.

In `@packages/stack/src/identity/index.ts`:
- Around line 41-47: The structural check in resolveLockContext currently uses
the in operator on input before নিশ্চিতing it is an object, which can throw for
null, undefined, or primitives. Update resolveLockContext in the identity module
to add an object/type guard before checking 'identityContext' in input, while
keeping the existing instanceof LockContext path so valid LockContext instances
still resolve correctly and malformed inputs fall through consistently.

In `@packages/stack/src/supabase/types.ts`:
- Around line 360-364: The structured .or() overload on EncryptedQueryBuilder
still accepts PendingOrCondition[] with untyped string columns, so callers can
target non-FK encrypted fields. Make PendingOrCondition generic over the allowed
column keys and update the EncryptedQueryBuilder.or() signature to use the
FK-bound version, keeping the same options shape while ensuring structured
filters are restricted to FK columns only.

---

Outside diff comments:
In `@packages/stack/src/supabase/query-builder.ts`:
- Around line 608-615: Raw encrypted filters are being recorded as equality
terms in the query builder, which bypasses the v3 query-type/operator remap for
pattern matching. Update the raw-filter handling in QueryBuilder so the path
that pushes terms and the corresponding raw term mapping carry the actual query
type and operator seams instead of hardcoding equality/composite-literal. Use
the existing QueryBuilder/raw-filter symbols and ensure `.filter(...,
'like'/'ilike', ...)` routes through freeTextSearch and remaps to cs for
encrypted raw filters.
- Around line 704-706: The order clause in the query builder is bypassing the
dialect seam, so v3 property names are not being mapped to database column names
before reaching PostgREST. Update the `query-builder.ts` handling for the
`order` case in the query-building flow to resolve `t.column` through the same
dialect mapping used by filters/selects/mutations, then pass the translated DB
column into `query.order(...)`. Make the change in the `order` branch of the
query builder logic so `order('createdAt')` is sent as the DB name rather than
the property name.

---

Nitpick comments:
In @.github/workflows/fta-v3.yml:
- Around line 34-35: The Checkout Repo step in this read-only workflow should
not leave credentials in the workspace, so update the actions/checkout usage to
disable persisted auth. Add persist-credentials set to false on the checkout
step in this workflow, matching the read-only pattern already used elsewhere,
and keep the change scoped to the checkout action configuration.

In `@packages/cli/src/commands/db/upgrade.ts`:
- Around line 18-37: The EQL version checks in upgrade flow are duplicated and
should be centralized to match the existing install command logic. Extract the
shared validation from upgrade.ts into the same helper used by install.ts, then
have the upgrade command call that helper instead of repeating the unknown
`--eql-version` and `--eql-version 3` with `--latest` checks. Keep the behavior
and error messaging consistent by reusing the same validation entry point
referenced by the upgrade command’s version parsing and the install command’s
option validation.

In `@packages/stack/__tests__/encrypt-lock-context-guards.test.ts`:
- Around line 51-55: The test setup in beforeEach is mutating
process.env.CS_WORKSPACE_CRN without cleaning it up, which can leak state into
other suites. Update the encrypt-lock-context-guards.test.ts setup around
beforeEach/afterEach to save the previous value, restore or delete
CS_WORKSPACE_CRN after each test, and keep the environment isolated for the
Encryption test cases. Use the existing vi.clearAllMocks and client setup as the
anchor points when adding the cleanup.

In `@packages/stack/__tests__/helpers/live-gate.ts`:
- Around line 14-29: The live test gates in live-gate.ts are computed at module
load time, so importing this helper before dotenv/config can silently freeze
them to false. Update LIVE_CIPHERSTASH_ENABLED and LIVE_EQL_V3_PG_ENABLED to be
evaluated after env loading, or add an explicit runtime check/error in the live
gating helpers (describeLive, describeLivePg) so import order is no longer
fragile.

In `@packages/stack/__tests__/model-column-mapping.test.ts`:
- Around line 1-39: The test is importing a private helper directly instead of
exercising the public API, so update it to cover the same behavior through the
exported surface from encryptedTable/encryptedColumn and the eql/v3 barrel. Keep
the assertions about JS property matching and DB-name resolution, but move the
setup to the public model path so the test validates resolveEncryptColumnMap
indirectly without importing it from model-helpers.

In `@packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts`:
- Around line 175-213: The per-domain setup in `beforeAll` is doing `await
client.encryptQuery(...)` sequentially in the `eqDomains`, `ordDomains`, and
`matchDomains` loops, which adds unnecessary latency. Refactor the `eqTerms`,
`ordTerms`, and `matchTerms` population in `matrix-live-pg.test.ts` to build
promises per domain and resolve them with `Promise.all`, preserving the same
`queryType` and `columnRef(t)` behavior while parallelizing the network
round-trips.

In `@packages/stack/scripts/install-eql-v3.ts`:
- Line 14: The live installer script currently depends on a helper inside the
test tree, which can break when tests are not packaged or available. Move
installEqlV3IfNeeded out of the __tests__ helpers into a shared non-test module,
then update install-eql-v3.ts and any test callers to import that shared helper
instead so the script resolves in script-only builds.

In `@packages/stack/src/eql/v3/columns.ts`:
- Around line 297-324: `EncryptedTextSearchColumn.build()` is duplicating the
shared index-derivation logic instead of delegating to
`indexesForCapabilities()`. Replace the hand-built indexes object in
`EncryptedTextSearchColumn.build()` with a call to
`indexesForCapabilities(this.getQueryCapabilities(), 'string')` so `TEXT_SEARCH`
keeps matching the centralized capability rules, and ensure any other build
paths that construct the same `{ unique, ore }` shape are updated to use the
helper as well.

In `@packages/stack/src/schema/index.ts`:
- Around line 354-366: The freeTextSearch method in Schema is storing
caller-provided match options by reference, so later mutations to opts.tokenizer
or opts.token_filters can leak into this.indexesValue.match. Update
freeTextSearch to defensively clone the merged match options, matching the
EncryptedTextSearchColumn.freeTextSearch behavior and using cloneMatchOpts with
defaultMatchOpts. Also import cloneMatchOpts alongside defaultMatchOpts so the
stored index config is isolated from external object mutation.

In `@packages/stack/src/types.ts`:
- Around line 253-255: The public EncryptOptions.column documentation still
implies only EncryptedColumn/EncryptedField, but the type now accepts any
BuildableColumn. Update the doc comment on EncryptOptions.column in types.ts to
describe the widened contract and mention that v3 builders are supported,
keeping the wording aligned with the actual BuildableColumn type and related
EncryptOptions symbols.
🪄 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: b63665e9-f34b-4934-afbe-ba963ecdddce

📥 Commits

Reviewing files that changed from the base of the PR and between 195076c and 4133260.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (86)
  • .changeset/eql-v3-supabase.md
  • .changeset/eql-v3-text-search.md
  • .changeset/eql-v3-typed-client.md
  • .changeset/eql-v3-typed-schema.md
  • .github/workflows/fta-v3.yml
  • .github/workflows/tests.yml
  • docs/query-api-walkthrough.md
  • docs/reference/supabase-sdk.md
  • docs/superpowers/plans/2026-06-30-eql-v3-text-search-schema-plan.md
  • docs/superpowers/plans/2026-07-01-eql-v3-typed-schema.md
  • docs/superpowers/specs/2026-06-30-eql-v3-text-search-schema-design.md
  • docs/superpowers/specs/2026-07-02-stryker-v3-ci-gate-design.md
  • packages/cli/package.json
  • packages/cli/scripts/build-eql-v3-sql.mjs
  • packages/cli/src/__tests__/installer.test.ts
  • packages/cli/src/__tests__/supabase-migration.test.ts
  • packages/cli/src/bin/main.ts
  • packages/cli/src/commands/db/install.ts
  • packages/cli/src/commands/db/status.ts
  • packages/cli/src/commands/db/upgrade.ts
  • packages/cli/src/installer/index.ts
  • packages/cli/src/sql/cipherstash-encrypt-v3-supabase.sql
  • packages/cli/src/sql/cipherstash-encrypt-v3.sql
  • packages/cli/tests/helpers/run.ts
  • packages/stack/__tests__/cjs-require.test.ts
  • packages/stack/__tests__/encrypt-lock-context-guards.test.ts
  • packages/stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql
  • packages/stack/__tests__/helpers/eql-v3.ts
  • packages/stack/__tests__/helpers/live-gate.ts
  • packages/stack/__tests__/helpers/stub-auth-wasm-inline.ts
  • packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts
  • packages/stack/__tests__/model-column-mapping.test.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__/supabase-v3-builder.test.ts
  • packages/stack/__tests__/supabase-v3.test-d.ts
  • packages/stack/__tests__/supabase-v3.test.ts
  • packages/stack/__tests__/supabase.test.ts
  • packages/stack/__tests__/typed-client-v3.test-d.ts
  • packages/stack/__tests__/typed-client-v3.test.ts
  • packages/stack/__tests__/types-public-surface.test-d.ts
  • packages/stack/__tests__/v3-matrix/catalog.ts
  • packages/stack/__tests__/v3-matrix/matrix-audit.test-d.ts
  • packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-live.test.ts
  • packages/stack/__tests__/v3-matrix/matrix-lock-context.test.ts
  • packages/stack/__tests__/v3-matrix/matrix.test-d.ts
  • packages/stack/__tests__/v3-matrix/matrix.test.ts
  • packages/stack/__tests__/wasm-inline-column-name.test.ts
  • packages/stack/__tests__/wasm-inline-new-client.test.ts
  • packages/stack/__tests__/wasm-inline-strategy.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/bulk-encrypt.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/encryption/v3.ts
  • packages/stack/src/eql/v3/columns.ts
  • packages/stack/src/eql/v3/index.ts
  • packages/stack/src/eql/v3/table.ts
  • packages/stack/src/eql/v3/types.ts
  • packages/stack/src/identity/index.ts
  • packages/stack/src/schema/index.ts
  • packages/stack/src/schema/match-defaults.ts
  • packages/stack/src/supabase/helpers.ts
  • packages/stack/src/supabase/index.ts
  • packages/stack/src/supabase/query-builder-v3.ts
  • packages/stack/src/supabase/query-builder.ts
  • packages/stack/src/supabase/types.ts
  • packages/stack/src/types-public.ts
  • packages/stack/src/types.ts
  • packages/stack/src/wasm-inline.ts
  • packages/stack/tsconfig.typecheck.json
  • packages/stack/tsup.config.ts
  • packages/stack/vitest.config.ts
  • skills/stash-supabase/SKILL.md

Comment thread .changeset/eql-v3-typed-client.md
Comment thread .changeset/eql-v3-typed-schema.md
Comment on lines +119 to +124
const eqlVersion: 2 | 3 = options.eqlVersion === '3' ? 3 : 2

// v3 supports the direct install path only. Explicit --drizzle/--migration
// are rejected up-front by validateInstallFlags; auto-DETECTED drizzle or
// migration modes fall back to direct here rather than erroring.
const routing = routeInstallPathForEqlVersion(eqlVersion, resolved)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Thread eqlVersion into the permission check.

The v3 path still calls the v2-oriented checkPermissions(), which requires CREATE on public for public.eql_v2_encrypted. That can incorrectly block native eql_v3.* installs for roles that do not need public-schema type creation.

Suggested direction
-  const permissions = await installer.checkPermissions()
+  const permissions = await installer.checkPermissions({ eqlVersion })

Then make EQLInstaller.checkPermissions({ eqlVersion }) skip the public.eql_v2_encrypted check when eqlVersion === 3.

Also applies to: 189-190

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execSync } from 'node:child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 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/src/commands/db/install.ts` around lines 119 - 124, The
permission check in EQL install is still using v2-only logic, which can wrongly
require CREATE on public for v3 installs. Thread eqlVersion through
EQLInstaller.checkPermissions and the call site in db/install.ts, and update
checkPermissions so it skips the public.eql_v2_encrypted validation when
eqlVersion is 3 while preserving the existing v2 behavior.

Comment on lines +555 to +566
if (options.eqlVersion === '3') {
const incompatible = options.drizzle
? '--drizzle'
: options.migration
? '--migration'
: options.latest
? '--latest'
: null
if (incompatible) {
return `\`--eql-version 3\` does not support \`${incompatible}\` yet — v3 currently installs via the direct path only.`
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject --migrations-dir for EQL v3 too.

--migrations-dir is migration-path-only, but v3 routing skips migration selection and proceeds with direct install. With --supabase --eql-version 3 --migrations-dir ..., the flag is silently ignored and the DB is modified directly.

Proposed fix
     const incompatible = options.drizzle
       ? '--drizzle'
       : options.migration
         ? '--migration'
-        : options.latest
-          ? '--latest'
-          : null
+        : options.migrationsDir !== undefined
+          ? '--migrations-dir'
+          : options.latest
+            ? '--latest'
+            : null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (options.eqlVersion === '3') {
const incompatible = options.drizzle
? '--drizzle'
: options.migration
? '--migration'
: options.latest
? '--latest'
: null
if (incompatible) {
return `\`--eql-version 3\` does not support \`${incompatible}\` yet — v3 currently installs via the direct path only.`
}
}
if (options.eqlVersion === '3') {
const incompatible = options.drizzle
? '--drizzle'
: options.migration
? '--migration'
: options.migrationsDir !== undefined
? '--migrations-dir'
: options.latest
? '--latest'
: null
if (incompatible) {
return `\`--eql-version 3\` does not support \`${incompatible}\` yet — v3 currently installs via the direct path only.`
}
}
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execSync } from 'node:child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 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/src/commands/db/install.ts` around lines 555 - 566, The EQL v3
validation in the install command currently rejects --drizzle, --migration, and
--latest, but still allows --migrations-dir to slip through because v3 uses the
direct install path. Update the compatibility check in the install flow (the
options.eqlVersion === '3' block in db/install.ts) to also treat
--migrations-dir as incompatible, using the same return-message pattern as the
existing flags so it is rejected before any direct DB modification occurs.

Comment on lines 41 to +47
export function resolveLockContext(input: LockContextInput): Context {
return input instanceof LockContext ? input.identityContext : input
// Use a structural check as well as `instanceof` so a `LockContext`
// constructed in another realm (or from a duplicate module instance) is still
// resolved rather than slipping through as a raw `Context`.
return input instanceof LockContext || 'identityContext' in input
? (input as LockContext).identityContext
: input

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the structural lock-context check before using in.

Line 45 can throw a TypeError for plain JS callers that pass null, undefined, or a primitive. Add an object guard so malformed inputs fail downstream consistently instead of crashing in the resolver.

Proposed guard
 export function resolveLockContext(input: LockContextInput): Context {
   // Use a structural check as well as `instanceof` so a `LockContext`
   // constructed in another realm (or from a duplicate module instance) is still
   // resolved rather than slipping through as a raw `Context`.
-  return input instanceof LockContext || 'identityContext' in input
+  return (
+    input instanceof LockContext ||
+    (typeof input === 'object' && input !== null && 'identityContext' in input)
+  )
     ? (input as LockContext).identityContext
     : input
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function resolveLockContext(input: LockContextInput): Context {
return input instanceof LockContext ? input.identityContext : input
// Use a structural check as well as `instanceof` so a `LockContext`
// constructed in another realm (or from a duplicate module instance) is still
// resolved rather than slipping through as a raw `Context`.
return input instanceof LockContext || 'identityContext' in input
? (input as LockContext).identityContext
: input
export function resolveLockContext(input: LockContextInput): Context {
// Use a structural check as well as `instanceof` so a `LockContext`
// constructed in another realm (or from a duplicate module instance) is still
// resolved rather than slipping through as a raw `Context`.
return (
input instanceof LockContext ||
(typeof input === 'object' && input !== null && 'identityContext' in input)
)
? (input as LockContext).identityContext
: input
}
🤖 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/identity/index.ts` around lines 41 - 47, The structural
check in resolveLockContext currently uses the in operator on input before
নিশ্চিতing it is an object, which can throw for null, undefined, or primitives.
Update resolveLockContext in the identity module to add an object/type guard
before checking 'identityContext' in input, while keeping the existing
instanceof LockContext path so valid LockContext instances still resolve
correctly and malformed inputs fall through consistently.

Comment on lines 360 to +364
or(
conditions: PendingOrCondition[],
options?: { referencedTable?: string; foreignTable?: string },
): EncryptedQueryBuilder<T>
match(query: Partial<T>): EncryptedQueryBuilder<T>
): EncryptedQueryBuilder<T, FK>
match(query: Partial<Pick<T, FK>>): EncryptedQueryBuilder<T, FK>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Narrow structured .or() conditions to FK.

PendingOrCondition[] keeps column: string, so v3 callers can still compile filters against storage-only encrypted columns via structured .or(...). Make PendingOrCondition generic, then bind this overload to FK.

Proposed type direction
-  or(
-    conditions: PendingOrCondition[],
+  or<K extends FK>(
+    conditions: PendingOrCondition<K>[],
     options?: { referencedTable?: string; foreignTable?: string },
   ): EncryptedQueryBuilder<T, FK>
🤖 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/supabase/types.ts` around lines 360 - 364, The structured
.or() overload on EncryptedQueryBuilder still accepts PendingOrCondition[] with
untyped string columns, so callers can target non-FK encrypted fields. Make
PendingOrCondition generic over the allowed column keys and update the
EncryptedQueryBuilder.or() signature to use the FK-bound version, keeping the
same options shape while ensuring structured filters are restricted to FK
columns only.

@freshtonic

Copy link
Copy Markdown
Contributor Author

CI is fully green ✅ — including the live matrix-live-pg suite (all 35 domains against real Postgres + ZeroKMS) for the first time on any branch in this stack.

Two fixes got it there, both real issues the previously-failing seed INSERT had masked:

  1. 3bd7ebb3 — the ob-carrying text domains (text_ord, text_ord_ore, text_search) cannot store the empty string: their domain CHECKs require jsonb_array_length(VALUE->'ob') > 0 and the ORE term of '' has zero blocks. The matrix catalog now seeds those three domains from a non-empty TEXT_ORD_S (row-targeting constraints for the ord/match proofs preserved); the '' edge stays covered on text/text_eq/text_match where it is storable. This also documents a real user-facing constraint of the ob-carrying text domains.
  2. 299b680f — the storage-tier date proofs (running in CI for the first time once the seed succeeded) compared lone-decrypt() output against a Date; a lone ciphertext has no column identity, so cast_as reconstruction (a decrypt-model feature) can't apply and the FFI returns the serialized instant. The test now parses before comparing.

Run: https://github.com/cipherstash/stack/actions/runs/28694787649 — Node 22/24, Bun, Deno WASM, E2E, CodeQL, OSV, FTA all pass.

tobyhede and others added 28 commits July 4, 2026 20:47
Add a per-package Fast TypeScript Analyzer (fta-cli) gate scoped to the
EQL v3 text-search schema source (packages/stack/src/schema/v3). The gate
fails CI when any v3 file exceeds the FTA score cap.

- pin fta-cli@3.0.0 as a stack devDependency (repo installs tooling via
  frozen-lockfile; no pnpm dlx/npx per supply-chain policy)
- add analyze:complexity script: fta src/schema/v3 --score-cap 72
  (current v3 score is 71.08, so the cap blocks regressions)
- add paths-filtered blocking workflow .github/workflows/fta-v3.yml;
  no build/DB/credentials needed (FTA is static source analysis)
Add a single declarative catalog that drives both a runtime `it.each`
matrix and type-level `expectTypeOf` assertions for every EQL v3 scalar
domain — the TypeScript analog of the Rust `eql_v3` `scalar_matrix!`
harness. Replaces the hand-rolled, per-domain test bodies with one
source of truth.

- add exported `EqlTypeForColumn<C>` helper beside `PlaintextForColumn`,
  so the catalog keys off `EqlTypeForColumn<AnyEncryptedV3Column>` (the
  full domain union) rather than a hand-copied list.
- __tests__/v3-matrix/catalog.ts: `V3_MATRIX` covering all 35 domains,
  `as const satisfies Record<EqlV3TypeName, DomainSpec>`. Coverage is
  MANDATORY — omitting a domain fails `tsc` and names the missing one,
  the compile-time analog of (and stronger than) the Rust
  `test:matrix:inventory` cross-check. Every field is consumed by a
  test. `typedEntries` keeps the matrix key as `EqlV3TypeName`.
- matrix.test.ts: runtime matrix asserting `build()` toStrictEqual
  `{ cast_as, indexes }` at full fidelity across all domains.
- matrix.test-d.ts: type-level matrix (plaintext axis, derived queryType
  union, storage-only exclusion, exhaustiveness anchor), with the table
  built from the catalog's own builders so one catalog drives both
  surfaces.
- schema-v3.test.ts: remove the superseded `domainCases` array + its
  it.each and the now-redundant basic text_search asserts; keep the
  text_search-specific behavior (v2 parity, freeTextSearch tuning,
  clone-on-write / no-alias). Prune now-unused imports.

`indexes` is stored per-row as data, not derived, because text_search
overrides build() to emit unique+ore+match.

Verified: test:types 54/54 (no type errors), runtime matrix 35/35,
schema-v3 26/26, tsup build + biome clean. No regressions — full-suite
failures are the 18 pre-existing FFI/env cases (identical with changes
stashed).
Fix an EQL v3 SDK bug and close the largest test-coverage gaps between v3
and v2, driven off the type-driven domain catalog.

SDK fix (Part A):
- resolveIndexType now resolves `equality` to the `ore` (`ob`) index on
  order-capable v3 columns instead of throwing on the absent `unique`
  index, matching the documented capability ("exact-match ... or
  comparison via `ob`") and the type surface. Gated on getQueryCapabilities
  (v3-only), so v2 columns keep their equality-without-unique throw
  unchanged (no-v2-change constraint). No build()/wire change.
- Deterministic regressions (ord+equality resolves to ore per plaintext
  axis; v2 order-only column still throws) plus a required live pg proof
  that `ord_term(x) = ore_block_256(term)` selects the exact row.

Test coverage (Part B):
- catalog: add samples/errorSamples per domain (numeric split
  integer-vs-fractional; NaN/±Infinity as error samples).
- matrix-live: live round-trip of all 35 domains x samples via batched
  bulkEncryptModels/bulkDecryptModels, plus NaN/Infinity rejection.
- schema-v3: catalog-driven blocker sweep over every (domain, queryType)
  pair, superseding the two hand-picked misuse cases.
- matrix-lock-context: offline wiring for the v3 typed client, incl. the
  positional decryptModel lockContext path; matrix-identity-live: live
  lock-context + audit round-trip; matrix-audit.test-d: pins that v3
  decryptModel has no .audit() hook.
- matrix-keyset: invalid-UUID (deterministic) + live ensureKeyset.
- matrix-bulk: 100-item live round-trip through the v3 typed client.
- wire the previously-dead occurredAt timestamptz column into a
  round-trip assertion.

190 deterministic tests pass, 56 type tests pass, tsc clean; live suites
soft-skip without credentials.
Defence in depth: the equality-via-ORE fix shows an SDK-side bug can hide
behind a clean FFI round-trip and only surface against real SQL, so every
domain gets a live query-correctness proof, not just the 4 already covered.

- matrix-live-pg.test.ts (new): one mega Postgres table across all 35
  domains, one proof per domain dispatched by capability tier (mirrors
  resolveIndexType's own priority — match > unique > ore > none):
  eq_term/hmac_256 for *_eq (8), ord_term/ore_block_256 equality-via-ORE for
  *_ord/*_ord_ore (16 — verified against the SQL fixture that non-text ord
  domains have no eq_term at all, so this is the only equality path that
  exists for them), match_term/bloom_filter for text_match/text_search (2),
  plain INSERT/SELECT round-trip for storage-only domains (9). Doubles as a
  canonical example per capability tier of how to query each v3 domain kind.
- matrix-live.test.ts: fix 2 latent type errors (spec.errorSamples didn't
  resolve because `as const satisfies Record<...>` gives rows that omit the
  optional field a type lacking the key, not `undefined`) by pinning
  typedEntries's type arguments explicitly. Caught by running real tsc
  against the file — vitest run only transpiles .test.ts files, it never
  type-checks them, so this had shipped unnoticed in the prior commit.

Both live suites soft-skip without credentials; verified via tsc, biome,
and vitest in a sandbox with no live DB — SQL correctness itself is
unverified beyond static checks against the real eql_v3 fixture.
Applies 4 of 6 findings from the CodeRabbit review of cfacc3b7
(equality-via-ORE fix + live v3 domain coverage). The other 2 findings are
plan/design-doc feedback, not source changes.

- matrix-lock-context.test.ts: restore CS_WORKSPACE_CRN after each test so
  it doesn't leak into other suites sharing the Vitest worker.
- stub-auth-wasm-inline.ts: add an OidcFederationStrategy stub alongside
  AccessKeyStrategy — src/wasm-inline.ts re-exports both, so importing it
  under the Vitest alias could fail with only one stubbed.
- identity/index.ts: omit ctsToken from getLockContext()'s return when
  unset, instead of returning it as an explicit `undefined`, so the shape
  matches the optional `ctsToken?` type callers check presence against.
- tests.yml: fix a stale version comment (protect-ffi 0.25+/auth 0.38+ ->
  0.26+/0.40+, matching the actual e2e/wasm deps).

Verified: schema-v3/v3-matrix/lock-context suites pass (212/212, rest
soft-skip without live creds), biome clean, build clean.
… order

Address CodeRabbit review findings:
- cjs-require: also assert encryptedTable and buildEncryptConfig are
  exported from the v3 CJS bundle so regressions in the primary
  /schema/v3 export surface are caught.
- cli run() helper: build raw from interleaved chunks instead of
  stdout + stderr so the combined transcript preserves real ordering.
CI run 28569708268 (PR #540, Node 22) surfaced two real, distinct bugs
against live credentials. Disabling both to unblock CI; root causes are
identified but not fixed here.

- schema-v3-client.test.ts: skip the occurredAt timestamptz round-trip test.
  Confirmed root cause: protect-ffi's native CastAs has a distinct
  'timestamp' variant (full date+time) separate from 'date' (calendar-date
  only), but this SDK's CastAs/PlaintextKind types never included
  'timestamp' - every timestamptz domain sets cast_as: 'date', identical to
  the plain date domain, so the native layer silently truncates time-of-day.
  Pre-existing SDK gap (predates this branch), not a test bug.
- matrix-live-pg.test.ts: force-skip the whole suite. beforeAll crashes with
  `PostgresError: invalid input syntax for type json` on the dynamic
  35-column INSERT, before any per-domain case runs. Root cause not yet
  pinned - CI's stack trace bottoms out in postgres.js's connection handler
  with no frame back to the offending parameter/domain, and the same
  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 to
  isolate.

Verified: 441 passing (18 pre-existing/unrelated failures, reproduced
identically without these changes), test:types 56/56, build clean.
Addresses code review of the disabled matrix-live-pg suite (one finding
confirmed invalid, one skipped as not worth the tradeoff, this one confirmed
and fixed - see prior turn for full verification detail).

Root cause of the original CI crash (PostgresError: invalid input syntax
for type json), traced into postgres.js's Bind() in connection.js: a bare
ciphertext object has no recognized wire type under inferType() (only
Parameter/Date/Uint8Array/boolean/bigint are special-cased), so it falls
back to `'' + x` - literal JS string coercion, producing "[object Object]"
on the wire. sql.json(value) avoids this by returning a Parameter with an
explicit type OID that has a registered serializer.

Fixed both insertRow's values and the eqTerms/ordTerms/matchTerms
references in the it.each blocks - all four pass raw ciphertext/query-term
objects through sql.unsafe() the same way, so all four had the identical
bug. schema-v3-pg.test.ts already uses this exact sql.json() pattern,
confirming it's correct.

Suite stays describe.skip'd - the underlying bug is fixed but unverified
against live credentials in this sandbox, so re-enabling is a separate call.

Verified: biome clean, tsc clean (no new errors), full suite unchanged at
441 passing / 18 pre-existing-unrelated failures, test:types 56/56, build
clean.
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.
…pter

The v2 query mechanism (direct EQL operators over PostgREST) unchanged;
EncryptedQueryBuilderImpl gains narrow protected seams whose defaults
preserve the v2 behaviour byte-for-byte, and a v3 subclass overrides them:

- column recognition + property↔DB name resolution via buildColumnKeyMap
  (filters, mutations, aliased select casts `prop:db::jsonb`)
- raw jsonb mutation payloads (no eql_v2 composite wrap)
- full-envelope filter operands: every eql_v3.* domain CHECK requires the
  storage keys (v/i/c + index terms) and the SQL operators coerce their
  jsonb operand into the domain, so a narrowed encryptQuery term (c?: never)
  fails 23514 on EVERY domain — not just text_search as the design spec
  assumed. All operands go through encrypt() instead.
- like/ilike on encrypted columns → PostgREST cs (bloom @>); the domains
  define no LIKE operator
- Date reconstruction from cast_as on decrypted rows
- capability validation: filters on storage-only columns or unsupported
  query types throw typed + runtime errors

Wire-encoding unit tests (mock encryption + supabase clients) cover both
dialects, including v2 regression pins for the seams.

Part of CIP-3300; design spec in PR #546.
- Vendor the v3 SQL bundles into packages/cli/src/sql via a checked-in
  derivation script (scripts/build-eql-v3-sql.mjs): the full bundle is a
  byte-identical copy of the stack fixture monolith; the Supabase variant
  strips the two CREATE OPERATOR CLASS/FAMILY chunks at their --! @file
  markers, mirroring upstream's **/*operator_class.sql exclusion glob.
  Temporary vendoring (sync risk documented) until upstream ships v3
  release artifacts.
- EQLInstaller: eqlVersion option on install/isInstalled/
  getInstalledVersion; v3 + --latest rejected (no public artifacts);
  grants keyed to the installed schema via the new
  supabasePermissionsSql(schemaName) helper (SUPABASE_PERMISSIONS_SQL
  unchanged, SUPABASE_PERMISSIONS_SQL_V3 added).
- stash db install --eql-version 3: direct install only for now —
  explicit --drizzle/--migration/--latest are rejected up-front,
  auto-detected drizzle falls back to direct with a notice.

Part of CIP-3300.
- supabase-v3.test.ts mirrors the v2 live suite over eql_v3 domains:
  round-trips (incl. a Timestamptz column proving Date reconstruction),
  bulk models, text_search equality (full-envelope operand), free-text
  like→cs (include_original: false — load-bearing, see the suite header),
  int4_ord equality + gte/lte range, timestamptz_ord range with Date
  values. Same env gating as the v2 suite; the eql_v3 Exposed-schemas
  dashboard step is documented as the manual prerequisite.
- supabase.test.ts gains the v2 encrypted-range test (gte/lte on an
  orderAndRange number column) — the 'range filtering works on Supabase'
  claim previously rested on a one-off live spike with no CI baseline.
- installEqlV3IfNeeded accepts { supabase: true }: opclass-stripped
  bundle + eql_v3 grants, matching the CLI's --eql-version 3 --supabase.

Part of CIP-3300.
- stash-supabase skill: new 'EQL v3 (native eql_v3.* domains)' section —
  setup, per-domain DDL, --eql-version 3 install, the Exposed-schemas
  silent-fallback warning, v3-specific behaviour (full-envelope operands,
  like→cs, include_original: false for substring match), shared caveats.
- Recreate docs/reference/supabase-sdk.md (deleted in def9f4b; AGENTS.md
  'Useful Links' had a dangling reference) covering both adapters, the
  install + Exposed-schemas story, and the v3 encoding details.
- Changeset: minor for @cipherstash/stack and stash.

Part of CIP-3300.
… status/upgrade, drift gate

Runtime (stack adapter):
- reject null filter operands with a pointer to .is(col, null) —
  encrypt(null) short-circuited to null and JSON.stringify sent the
  literal string "null" as the operand
- Date reconstruction now covers user-chosen PostgREST aliases:
  addJsonbCastsV3 returns the result-key→DB-column map the select
  actually produces and postprocessDecryptedRow consumes it (static
  property/DB names remain the fallback for no-select paths)
- single source for the encrypted like/ilike→cs remap
  (encryptedFilterOp), consumed by applyPatternFilter,
  notFilterOperator, and transformOrConditions

Type safety (behavior change):
- the v3 builder's default Row is exactly InferPlaintext<Table> — the
  previous `& Record<string, unknown>` widening collapsed
  V3FilterableKeys to string, silently disabling the storage-only
  filter guard. Passthrough columns now need an explicit Row.
- match() is FK-narrowed (Partial<Pick<T, FK>>) like every other
  filter method

CLI:
- db status reports v2 and v3 installs independently (a v3-only DB no
  longer reads "EQL is not installed")
- db upgrade accepts --eql-version, rejects v3+--latest, and points at
  the other generation when the requested one isn't installed
- v3 path routing extracted to pure routeInstallPathForEqlVersion
  (drizzle fallback + migration-mode skip) with unit tests
- dry-run output names the v3 bundle; CRLF-safe @file regex in the
  bundle derivation script
- gen:eql-v3-sql script + CI regenerate-and-diff gate so the vendored
  bundles cannot drift from the fixture silently
- test helper reads the CLI-vendored Supabase bundle instead of
  duplicating the strip logic (live suite now installs exactly what
  `stash db install --eql-version 3 --supabase` installs)

Tests: +6 builder unit tests (or() structured/string/verbatim, null
guard, is-null passthrough, aliased-date), +2 type tests (default-Row
narrowing, match() FK), +3 CLI routing tests. Docs + changeset updated
for the Row semantics; stale src/schema/v3 path comment fixed.
The mega-table seed INSERT failed CI with 'value for domain
eql_v3.text_ord_ore violates check constraint "text_ord_ore_check"':
row A seeds every text domain with TEXT_S[0] = '', and the text_ord /
text_ord_ore / text_search domain CHECKs demand a non-empty ore term
(jsonb_array_length(VALUE->'ob') > 0) — the ORE term of the empty
string has zero blocks, so the domain cast rejects the row before it
ever lands. (text_eq and text_match accept '' — hm/bf don't have the
non-empty requirement — which is why the insert died exactly at the
first ob-requiring text column, $25.)

Give the ob-carrying text domains their own TEXT_ORD_S sample set
(non-empty; ord-equality and match-proof row-targeting constraints
preserved), keeping the '' edge covered on text / text_eq / text_match
where it is actually storable.
…before Date comparison

Unblocked by the previous commit (the seed INSERT now succeeds, so the
storage-tier proofs run in CI for the first time): the date/timestamptz
cases compared client.decrypt() output against a Date, but lone-
ciphertext decrypt has no column identity — cast_as-driven Date
reconstruction is a decrypt-model feature — so the FFI returns the
serialized instant ('2026-07-01T00:00:00Z'). Parse before comparing.
main took #543 (db install/upgrade/status → the eql command group with
deprecated db aliases) under this branch; update the v3 docs, skill,
changeset, and test-helper comments to reference the new spellings
(stash eql install --eql-version 3 --supabase etc.).
@freshtonic freshtonic force-pushed the james/cip-3300-spike-integrate-eql-v3-into-supabase-orm branch from 299b680 to 21f8f3e Compare July 4, 2026 10:50
@coderdan

coderdan commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Great work on the seam architecture — the pinned-default dialect seams with v2 regression tests and the deviations write-up made a big PR easy to review. Before this merges I want to line up three structural concerns, because they compound with work that's in flight upstream.

1. The vendored bundle is a stale snapshot of the v3 surface

The fixture (and both derived CLI copies) was generated before several changes on the eql_v3 branch:

So a refresh is not a mechanical re-vendor: it renames every domain in columns.ts, changes the envelope version, and changes the equality-routing logic. My preference is to re-baseline on the current eql_v3 tip before merge rather than ship on the old snapshot — the gap is only getting wider.

2. Everything this writes is v2-wire data — migration debt from the first insert

eqlVersion never appears in packages/stack/src — it's only in the CLI installer. The SDK pins protect-ffi 0.26.0, which predates cipherstash/protectjs-ffi#104 entirely (no eqlVersion, no EncryptedPayload, no typed v3 output). So every payload this adapter writes into an eql_v3.* column is the v2 wire shape, accepted only because of the stale bundle's v = '2' pins. The moment the bundle is re-baselined, every stored row fails its domain CHECK on rewrite and needs re-encryption. If anyone adopts this before the re-baseline, we've manufactured a migration for them.

Related: the typed DomainPayload output that came out of the type-erasure thread on cipherstash/protectjs-ffi#104 doesn't reach this PR for the same reason — and at the TS layer every payload is typed as the v2 Encrypted regardless of domain. Once the FFI bump happens, let's wire the vendored per-domain types through so text_search and int4_ord payloads stop being indistinguishable to the compiler.

3. Full-envelope filter operands — correctly diagnosed, but let's not harden it in

Deviation 1's analysis is right (EncryptedScalarQuery is c?: never, every domain CHECK requires c, and the operator functions cast their operand into the domain — no coercion-free path). But I want us to treat this as an interim workaround rather than the design:

  • Every filter operand now carries a real, decryptable ciphertext c plus all the column's index terms, not just the one the operator extracts. PostgREST filters travel in GET query strings, so full envelopes land in URL logs, proxies, and Supabase request logs. Query terms are supposed to be index-terms-only by design.
  • The include_original: false requirement is a symptom of the same thing — the operand's bloom filter drags the whole pattern in as a token.
  • Upgrading protect-ffi doesn't fix it: under feat: dual-format EQL v2/v3 support (eqlVersion option) protectjs-ffi#104, scalar encryptQuery with v3 deliberately throws, pending a v3 scalar query wire shape.

The root fix is on the EQL side and it's mine to own: scalars need a query-shaped counterpart the way jsonb already has eql_v3.jsonb_query — a term-only envelope that passes the CHECKs, or operator forms that extract terms from a raw jsonb operand without the domain cast. I'll spec that. In the meantime can we (a) mark the full-envelope path as interim in the skill and supabase-sdk.md rather than documenting it as the way it works, and (b) keep encryptCollectedTerms isolated enough that swapping to real query terms later doesn't ripple through the seams?

None of this blocks the adapter architecture or the tests — it's sequencing. My preference: re-baseline bundle + FFI first and land this against the current surface. If you'd rather merge now, let's capture 1–3 as tracked follow-ups and gate any release on the re-baseline.

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