Skip to content

feat!: remove EQL v2 repo-wide (umbrella, #707)#772

Draft
tobyhede wants to merge 26 commits into
mainfrom
remove-v2
Draft

feat!: remove EQL v2 repo-wide (umbrella, #707)#772
tobyhede wants to merge 26 commits into
mainfrom
remove-v2

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Umbrella PR for #707 — the integration branch collecting the EQL v2 removal stack. Still a draft: six of the planned PRs have landed, three pieces of removal remain, and the branch does not yet merge cleanly with main.

Design: docs/plans/2026-07-22-eql-v2-final-removal-design.md (rev 3, approved 2026-07-22).

Goal

Make EQL v3 the sole generation the workspace authors and emits, while preserving the ability to decrypt existing v2 payloads. "Remove v2" means remove the v2 emission and authoring surface, not the read path — every PR touching an encrypt/decrypt or classification path carries a v2-payload round-trip test (design Decision 6).

End state: no eql_v2_* emission or authoring path, one naming convention (v3 takes the unsuffixed name), green suite, v2 ciphertext still readable.

Landed

PRs 1–6 merged into remove-v2 on 2026-07-23. All 17 checks green on 38c0101.

# Scope PR
1 Delete the v2-only published packages — protect, schema, protect-dynamodb (~19k lines) #760
2 migrate — drop EQL v2 from the domain-type classifier #761
3 stack core — v3 audit-on-decrypt, collapse EncryptionV3 into Encryption #768
4 stack-supabase — remove v2 authoring surface, de-suffix v3, delete query-builder-v3.ts #769
5 stack-drizzle — remove v2 root, collapse ./v3 to root (hard break, no alias) #770
6 wizard — port the migration rewriter to the v3 domain family #771

Verified holding across the stack: the v2 decrypt path survives on the core client and through the DynamoDB adapter; the deprecated aliases exist on stack and stack-supabase and correctly do not on stack-drizzle; every exports entry keeps both import and require; EncryptionErrorTypes and the EQL payload keys are untouched; type-erasing casts went down in every package.

Remaining

Four pieces, none opened. The first was previously listed as "blocked on #751" — #751 closed 2026-07-23, so the leaf is unblocked (zero SearchOp on the branch; commands/schema and init/lib/introspect.ts are byte-identical to main).

A. Merge main into remove-v2 — 4 commits behind, mergeable: CONFLICTING. Six conflicts, all in stack-drizzle, all from PR 5's ./v3→root collapse meeting #763's new src/v3/indexes.ts. Resolving toward remove-v2 would silently drop the new encryptedIndexes export from a package already taking a major — move it to src/indexes.ts instead. Two follow-ups the merge itself won't catch: .changeset/drizzle-encrypted-indexes.md advertises the deleted /v3 entry, and skills/stash-indexing/SKILL.md:257 (new in #773) imports from @cipherstash/stack-drizzle/v3.

B. packages/cli — the v2 SQL leaf. Wider than the 22,512 vendored SQL lines and encrypt/cutover.ts (306): eql install --eql-version 2 and eql upgrade (cli/registry.ts:354-364,427-437), the routing in db/install.ts:259-264,708-715, and db push / db activate / eql status / installer/ — all still read and write eql_v2_configuration. packages/cli/README.md and skills/stash-cli move with it.

C. packages/migrate/src/eql.ts — 166 lines, not deleted. Design PR 2 called for its removal; #761 landed only the classifier half. Pure eql_v2.* mutation wrappers (activate_config, migrate_config, rename_encrypted_columns), still in the public API at src/index.ts:45-53. Sole consumer is cutover.ts, so it lands with B.

D. packages/stack — the v2 authoring surface and emission path are still live. Not previously tracked as outstanding. src/schema/index.ts is still 754 lines — design PR 3 said this subpath would "shrink to types+zod"; it only gained @deprecated JSDoc. The chainable builders are exported from the package root (src/index.ts:9), and config.eqlVersion: 2 (src/types.ts:203) leaves protect-ffi on its v2 default, so Encryption({ schemas: [v2Table] }) still writes v2 payloads. Deferred deliberately by .changeset/stack-audit-on-decrypt.md, but #707's end state is not reachable without it.

Ordering constraint: integration/shared/v2-decrypt-compat.integration.test.ts:40-46 mints its v2 fixture using the emission path being removed. Replace it with a static v2 ciphertext fixture first, or Decision 6's acceptance test dies with the code it guards. That test also only round-trips v2→v2; nothing yet asserts a v3-configured client decrypts v2 ciphertext, which is the actual invariant.

E. PR 7 — scaffold, example, meta sweep.

  • init/utils.ts:289,314,432,486 still emits EncryptionV3 from @cipherstash/stack/v3; __tests__/utils-codegen.test.ts:22,56 currently pins that form. (Commit 7b783eef fixed the drizzle half.)
  • examples/basic does not compile on this branchsrc/encryption/index.ts:2-5 imports encryptedType, deleted in PR 5; src/lib/supabase/encrypted.ts:6 calls the v2 encryptedSupabase({ encryptionClient, supabaseClient }), deleted in PR 4. CI cannot see this: turbo filters to ./packages/*.
  • Meta and published docs still invert the post-feat(stack-supabase)!: remove EQL v2 authoring surface, de-suffix v3 API #769 naming: AGENTS.md:81,157, SECURITY.md:17-18, packages/stack/README.md:766,783 and :788-790 ("DynamoDB still requires v2" — false after PR 3), docs/reference/supabase-sdk.md, packages/migrate/README.md, root README.md (quickstart is still the v2 builder API).
  • Skills ship to customersskills/stash-encryption/SKILL.md:1045 and skills/stash-cli/SKILL.md:511 both still describe encryptedSupabase as the legacy v2 surface. It is the v3 factory.

Release mechanics

Mid-RC with a fixed version group (stash, stack, stack-drizzle, stack-supabase, prisma-next, wizard). Four major changesets land inside the group, so the whole group majors — budget for that in the RC line. stack-dynamodb-v2-write-removal.md correctly carries its own major line, separate from the Encryption collapse.

Two deviations to accept or fix: remove-eql-v2-packages.md is a patch, where the design's deletion-notice rule calls for a major on a surviving consumer-facing package (net effect nil inside the group, but @cipherstash/nextjs is outside it). And @cipherstash/migrate is outside the group taking a patch for a real behaviour change — listEncryptedColumns now omits eql_v2_encrypted columns under an unchanged signature.

Out of scope

#708 (Supabase v3 in Workers/browser), #654 (v3 Supabase full-ciphertext scalar operand), #637, and customer data-migration tooling.

Closes

Closes #707
Closes #752
Closes #198

#752 was untracked until now — PRs 3/4/5 already delivered most of it; the stash init scaffold literals in E are its last item.

Already closed as NOT_PLANNED on 2026-07-21, carried by this work: #409, #599, #423, #426, #421, #422.

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6981a31

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

This PR includes changesets to release 13 packages
Name Type
stash Major
@cipherstash/stack-drizzle Major
@cipherstash/stack Major
@cipherstash/migrate Patch
@cipherstash/nextjs Patch
@cipherstash/stack-supabase Major
@cipherstash/wizard Major
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/bench Patch
@cipherstash/prisma-next Major
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example 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 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

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

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 34d67915-30ad-4eae-bdf3-ee328b0e2ca2

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch remove-v2

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 added 26 commits July 24, 2026 10:50
…t-dynamodb)

PR 1 of the EQL v2 final removal (#707).

Delete the closed v2-only dependency chain — @cipherstash/protect-dynamodb →
@cipherstash/protect → @cipherstash/schema — and every reference to it. Nothing
outside the three imported them (@cipherstash/stack depends only on the separate
@cipherstash/protect-ffi). They are superseded by @cipherstash/stack:

- @cipherstash/protect        -> @cipherstash/stack
- @cipherstash/schema         -> @cipherstash/stack/schema
- @cipherstash/protect-dynamodb -> @cipherstash/stack/dynamodb (encryptedDynamoDB)

Existing EQL v2 ciphertext stays decryptable through @cipherstash/stack; this
removes the v2 authoring/emission surface, not the read path.

Reference cleanup (dangling refs that would break build/CI):
- e2e/package.json @cipherstash/protect dep edge
- root package.json build:js turbo filter
- tests.yml protect/protect-dynamodb .env steps (would fail `touch` on gone dirs)
  and the bun-job test loop
- rebuild-docs.yml trigger tag (@cipherstash/protect@* -> @cipherstash/stack@*)
- integration-{drizzle,prisma-next,supabase}.yml packages/schema/** path filters
- lint-no-hardcoded-runners allowlist entry
- e2e package-managers BIN fixture (dead) + two stale source comments

Changeset / RC housekeeping:
- delete schema-stevec-standard-pin.md (only target was the deleted schema)
- prune the three from pre.json initialVersions
- add deletion-notice changeset on @cipherstash/stack + @cipherstash/nextjs

Meta honesty: SECURITY.md package list, AGENTS.md Repository Layout, nextjs
package description.
- Note the intentional @cipherstash/nextjs patch (package.json description edit)
- Use PCRE negative lookahead so the stale-reference check excludes protect-ffi
Remove the `eql_v2_encrypted → 2` branch from `classifyEqlDomain`, so the
migrate domain-type resolution (`detectColumnEqlVersion`,
`listEncryptedColumns`, `resolveEncryptedColumn`) recognises only the
self-describing `eql_v3_*` domains. v3 is the sole generation this workspace
authors and backfills; a legacy v2 column's version is carried by the
manifest's recorded `eqlVersion` (the CLI status renderers already fall back
to it), so v2 status output is unchanged.

This drops v2 *classification*, not the v2 read path — existing v2 ciphertext
stays decryptable via `@cipherstash/stack`. `EqlVersion` keeps its `2` member
for manifest-sourced legacy values and the exported function signatures are
unchanged. Tests in `version.test.ts` are updated to assert v2 domains are no
longer classified (excluded from `listEncryptedColumns`, `null` from the
detectors).

Decision 6 guard: `classifyEqlDomain` is a source-column classifier for
backfill planning and read-only CLI status display — no decrypt/round-trip
consumes its `2` result — so dropping v2 here is safe.

NOTE: `packages/migrate/src/eql.ts` (the `eql_v2.*` config-lifecycle wrappers)
is NOT deleted in this PR. Although it carries no decrypt path, it is hard-
imported by the CLI's v2 cut-over/config commands (`encrypt cutover`,
`db activate`, `db push`); deleting it would break the CLI build, which is
out of this migrate-scoped PR. Its removal must be sequenced with the CLI
v2-command removal.

PR 2 of the EQL v2 final removal (#707).
…cryption

PR 3 of the EQL v2 removal (#707). Makes EQL v3 the sole generation the client authors and writes, while preserving the minimal v2 READ path on the core client and through the DynamoDB adapter.

- Add MappedDecryptOperation: the typed client's decryptModel/bulkDecryptModels are now audit-chainable (.audit()/.withLockContext()) instead of a bare Promise, restoring audited decrypt including through encryptedDynamoDB.

- Collapse EncryptionV3 into an overloaded Encryption; EncryptionV3 is now a deprecated, type-identical alias. An explicit config.eqlVersion is honored (the migration escape hatch is retained).

- BREAKING: remove the DynamoDB v2 WRITE overloads (encryptModel/bulkEncryptModels); decrypt still reads existing v2 items.

- Deprecate (retain) ClientConfig.eqlVersion and the ./schema v2 builders for legacy v2 read/migrate; siblings still consume them, so full removal is deferred to a later PR.

- Tests: flip the audit-surface type assertions, add runtime audit-forwarding tests (both chaining orders), and v2-read acceptance integration tests (core + DynamoDB).

- Update stash-dynamodb and stash-encryption skills and AGENTS.md.

Changesets: @cipherstash/stack minor (audit-on-decrypt), @cipherstash/stack major (DynamoDB v2-write removal), stash patch (skills).
Verification of the EncryptionV3->Encryption collapse surfaced a regression: because Encryption now returns the TYPED client for a v3 schema set, stack-supabase's encryptedSupabaseV3 (which casts to the nominal overload and calls decryptModel(row)/bulkDecryptModels(rows) one-arg) hit the typed methods with no table argument and threw on undefined.tableName. The sibling build passed because it is a type/runtime mismatch with no v3 runtime coverage.

Fix: the typed client's decryptModel/bulkDecryptModels now tolerate a missing table, degrading to nominal behaviour (decrypt without date reconstruction) instead of dereferencing undefined — restoring exactly what stack-supabase received before the collapse. table stays required for genuinely-typed callers.

- Add regression tests: typed client one-arg decryptModel/bulkDecryptModels decrypt without throwing (no reconstruction).

- Correct now-stale customer-visible source docs flagged in review: the resolveDecryptResult docblock, the encryptedDynamoDB version-mismatch error string and @example, and the EncryptedDynamoDBConfig JSDoc (all pointed at EncryptionV3 / needless eqlVersion:3). Update construct-guard.test.ts to the new error wording.

Follows 686004f.
Re-parameterize EncryptedQueryBuilderImpl over AnyV3Table and inline every
EncryptedQueryBuilderV3Impl dialect override, then delete query-builder-v3.ts.
Pure refactor: no runtime behaviour or wire encoding change. The decrypt path
stays generation-agnostic (decryptModel/bulkDecryptModels), so stored EQL v2
payloads still decrypt (Decision 6).
encryptedSupabase is now the introspecting EQL v3 factory (was encryptedSupabaseV3);
encryptedSupabaseV3 kept as a @deprecated type-identical alias. The legacy v2
encryptedSupabase({ encryptionClient, supabaseClient }).from(table, schema) wrapper
and EncryptedSupabaseConfig are removed. All *V3 type exports de-suffixed to
canonical names with @deprecated *V3 aliases retained. v2 decrypt is unaffected.
Point tests at the folded EncryptedQueryBuilderImpl, drop the removed v2
authoring tests (v2 live suite, v2 wire-encoding block, v2 builder type test),
convert the shared execute() error-threading tests to a v3 table, and add
canonical-name type assertions.
v2 read via this adapter is intentionally removed; v2 ciphertext still
decrypts through the core @cipherstash/stack client. Mixed-generation
handling is customer-side (install both), per #707 out-of-scope stance.
…ap 91 -> 71

The EQL v2 removal folded `query-builder-v3.ts` into `query-builder.ts`
(2b4e2e9). FTA penalises size superlinearly, so two files that each passed
the complexity gate became one that did not: 90.88 + 73.95 -> 105.77, against
a cap of 91. That failed the "Analyze v3 complexity" check on #769.

`fta-v3.yml` is explicit that the cap is "a ratchet, not an aspiration", so
decompose rather than raise it. The 2331-line class becomes a pipeline:

  column-map.ts     ColumnMap - name + capability resolution, the concern
                    every stage needed (was six correlated fields)
  query-encrypt.ts  filter-operand terms: collect, validate, batch-encrypt
  query-mutation.ts row-data encryption for insert/update/upsert
  query-dbspace.ts  property-space -> DB-space, once
  query-filters.ts  operand substitution onto the PostgREST query
  query-results.ts  decryption + Date reconstruction
  query-builder.ts  the class: recorded state, fluent surface, execute()

Worst file 105.77 -> 70.12 (query-encrypt.ts); cap lowered 91 -> 71.

Also removed, all no-ops from the pre-fold v2/v3 inheritance:

- `notFilterOperator` - an identity function with an unused parameter
- `applyPatternFilter`'s dead `_wasEncrypted` parameter
- `protected` throughout, now `private`; nothing extends the class

and collapsed the six-times-repeated withLockContext/audit/await dance into
one `withOpContext` helper - a skipped lock context would encrypt under the
wrong data key, so the three steps must stay identical.

`EncryptedQueryBuilderImpl` keeps its name, export site and constructor;
nothing here is part of the package's public surface (index.ts does not
re-export it). 466 tests pass unchanged. One test reached the unsupported-
queryType backstop by subclassing to override `queryTypeForRawOp`, which is
now a module function; `assertTermQueryable` is exported instead and the test
calls it directly, dropping a subclass its own comment called "breaking the
internal contract".

Docs: correct comments asserting adapter-side EQL v2 reads. The adapter is v3
only and does not auto-read `eql_v2_encrypted` columns - introspection matches
`public.eql_v3_*` domains exclusively, so such a column never enters the
encrypt config. No ciphertext is stranded: core decryption stays
generation-agnostic. Also retires the two-dialect scaffolding in types.ts,
whose header claimed v3 does free-text via `contains` - contradicting the
typed surface forty lines above it and re-introducing the exact confusion
#617 removed. `EncryptedQueryBuilderCore`'s OK/BK defaults are kept: they are
live for the untyped surface, only their v2-era rationale was wrong.
Delete the EQL v2 authoring surface (`src/index.ts` `encryptedType` +
config extraction, `src/operators.ts`, `src/schema-extraction.ts`) and
promote the EQL v3 implementation from `./v3` to the package root as a
hard break with no alias.

- Drop the `./v3` subpath from the `exports` map and remove `typesVersions`;
  the v3 impl now lives at `src/*` and is the sole `.` export.
- De-suffix the public API: `createEncryptionOperatorsV3` ->
  `createEncryptionOperators`, `extractEncryptionSchemaV3` ->
  `extractEncryptionSchema`. No `*V3` aliases (they would type-check v2 call
  sites against v3 semantics).
- Delete the two v2 operator test files; move the v3 test suite to
  `__tests__/*` and repoint imports. ESM+CJS both preserved.
- Fix the `@cipherstash/bench` importer: rewrite the Drizzle table to v3
  `types.*` domains + `EncryptionV3`, switch operator usages off the removed
  v2-only ops (`like`/`ilike`, jsonb-path) to `matches`/`contains`/`selector`,
  and update the bench fixture schema.sql to the eql_v3 domains/index terms.
- Update the README and the bundled `stash-drizzle` skill to the collapsed
  root imports; remove v2 authoring guidance.

Existing v2 ciphertext still decrypts via `@cipherstash/stack`; only the
Drizzle-side v2 authoring/query-building is removed (Decision 6).

BREAKING CHANGE: `@cipherstash/stack-drizzle` no longer exports an EQL v2
surface and the `./v3` subpath is removed. Import the v3 API from the package
root with the de-suffixed names.
…ollapse

PR 3 made EncryptionV3 an overloaded alias; ReturnType<typeof EncryptionV3>
now resolves to the nominal overload (EncryptionClient) not the typed client.
Infer through a single-signature helper so the bench handle keeps the typed
client type.
…the image

The bench fixture schema moved to concrete EQL v3 domains
(`public.eql_v3_text_search`, `eql_v3.eq_term(...)`), but tests-bench.yml
started `postgres-eql:17-2.3.1` — which ships EQL v2 — and ran the suite
immediately. `applySchema` failed with `type "public.eql_v3_text_search" does
not exist`. Nothing tied the image tag to the EQL version the fixtures need,
so the coupling broke silently.

Install v3 the way every integration suite already does: a vitest
`globalSetup` calling test-kit's `installEqlV3`, which shells out to the real
`stash eql install --eql-version 3`. That needs only a database URL — no
CipherStash credentials — so `db-only.test.ts` stays the credential-free smoke
test it is meant to be, and CI and `pnpm test:local` become the same path
rather than CI depending on a step the local flow never ran.

The globalSetup imports `@cipherstash/test-kit/install` (a new narrow subpath
export) rather than the barrel: the barrel reaches `needle-for.ts`, which
consumes stack source through the `@/` alias, and bench has no
`stackSourceAlias` in its vitest config. `install.ts` depends on node builtins
only.

Workflow changes:
- Use the `integration-setup` composite, which already builds the `stash` CLI
  the install needs (pinned to this job's existing Node 22).
- Start only the `postgres` service; docker-compose.yml also defines a
  PostgREST that bench never talks to.
- Widen the trigger paths to follow the package graph. bench depends on
  `@cipherstash/stack` and `@cipherstash/stack-drizzle`, and now on the CLI's
  EQL installer, but the filter covered only `packages/bench/**` and
  `local/**` — a break upstream could never trigger this job.

No changeset: `@cipherstash/bench` and `@cipherstash/test-kit` are both
private, and workflow files are repo tooling.
… scaffold, stale shipped docs

Six review findings, each with the regression test that would have caught it.

- `packages/bench/sql/schema.sql` indexed `eql_v3.ste_vec(enc_jsonb)`, a
  mechanical rename of the v2 expression. It builds, and `db-only.test.ts`'s
  `pg_indexes` check passes, but nothing the adapter emits ever mentions it:
  `@>` on `eql_v3_json_search` inlines to `eql_v3.to_ste_vec_query(a)::jsonb`
  (the bundle's own comment says so). A permanently-dead index in the one
  fixture whose job is to prove index engagement. Now indexed on the inlined
  expression, with `scripts/__tests__/bench-index-expressions.test.mjs` pinning
  each bench index against the operator body in the vendored EQL bundle — no
  database, no credentials, which matters because the bench's own EXPLAIN
  assertions need credentials and never run in CI.

- `stash init --drizzle` scaffolded `extractEncryptionSchemaV3` from
  `@cipherstash/stack-drizzle/v3` into the user's repo — both removed here, so
  a freshly-initialised project would not resolve. Flipped the drizzle half of
  the generated strings (the `@cipherstash/stack/v3` half stays for PR 7) and
  pinned it in `utils-codegen-drizzle.test.ts`.

- `skills/stash-encryption`, `packages/stack/README.md` and `AGENTS.md` still
  documented the `./v3` subpath and the `*V3` names. Both skills and the stack
  README are shipped artifacts. `no-removed-drizzle-surface.test.mjs` scans the
  shipped file set (deliberately not CHANGELOGs or specs, which should still
  name the old surface).

- `vitest.shared.ts` aliased `@cipherstash/stack-drizzle/v3` to the deleted
  `src/v3/index.ts`; `vitest-shared-alias.test.mjs` asserts every alias target
  exists on disk.

- The bench `matches` operand was the shared `value` prefix, which every seeded
  row contains — the bloom index had nothing to narrow, so the number measured
  a full scan. Uses a full seeded value now.

- The bench importer's only CI gate was the Bun job's `turbo build`, which
  swallows its own test failures. Added an explicit typecheck step to the main
  test job.

The eq/matches index assertions are sound and now say why: the operator
wrappers are `LANGUAGE sql IMMUTABLE STRICT` single-SELECT bodies, so the
planner inlines them and applies the same inlining to the stored index
expression. The old comment claimed the adapter emitted
`eql_v3.eq_term(col) = eql_v3.hmac_256(value)` — it emits `eql_v3.eq(col,
term)`, and `eql_v3.hmac_256` does not exist in the bundle at all.
Addresses PR #760 review feedback.

- `docs/query-api-walkthrough.md` pointed at `packages/protect/src/ffi/*`,
  deleted by this PR. Replaced with a note rooting the doc's relative paths,
  and corrected the two other stale facts in the same block: the protect-ffi
  pin (0.24.0/0.23.0 → the actual 0.30.0) and row 1a's query-builder paths,
  which moved to stack-drizzle/stack-supabase in the #627 split.
- `.github/dependabot.yml`'s ignore-rule comment narrated the #673 incident
  in the present tense against a package that no longer exists. Kept the
  lesson, marked the package as removed, and stated why the rule still holds
  for the surviving consumers.
- `.github/workflows/tests.yml` looped over `packages/stack-forge`, which has
  never existed. The `[ -f ]` guard made it a no-op; dropped it.

`scripts/lint-no-dead-package-paths.mjs` fails CI on any `packages/<name>`
reference that doesn't resolve to a directory, across docs, .github, skills and
the root meta files. Design archives (docs/plans, docs/superpowers) and
CHANGELOGs are exempt — they record history, not the current tree. It catches
all three of the above; self-tests follow the existing scripts/__tests__
pattern.
…change

`classifyEqlDomain` now returns `3 | null`, so `listEncryptedColumns` can
never emit `version: 2` and the `c.version === 2` exemption in
`explainUnresolved` is dead. Removing it is provably behaviour-preserving:
a post-cutover v2 table (`<col>` carrying the v2 domain) now reaches
`explainUnresolved` with an EMPTY candidate list, which the
`candidates.length === 0` guard above already falls through on.

- Remove the branch; rewrite the doc comment and the stale v2
  parentheticals in `drop.ts` / `cutover.ts` to say why the post-cutover
  state now arrives as "no EQL columns".
- Rewrite the drop test that hand-built a `version: 2` candidate — a state
  resolution can no longer produce, so it only exercised the dead branch —
  to the state that actually occurs.
- Add unit tests pinning `explainUnresolved`'s contract, including that a
  candidate sharing the plaintext column's name still fails closed (the
  removed branch's only behaviour, and wrong at v3, which has no cut-over
  rename).
- Correct the backfill manifest comments: `null` now also means a legacy
  `eql_v2_encrypted` domain, so a v2 column backfilled from here on records
  no `eqlVersion` and reports no version in `encrypt status`. The live-domain
  fallback yields null for that case too. Existing manifests are unaffected.
  Noted in the changeset.
- Drop the PR-1 plan doc that landed in this PR's diff.
- MappedDecryptOperation.execute returns a fresh Result on the unknown-table path, so ops can't alias/mutate a shared failure object.

- Document the AnyV3Table cast in Encryption with a biome-ignore explaining the runtime isV3Only guard the compiler can't see.

- Restore CS_WORKSPACE_CRN in decrypt-audit-forwarding.test.ts afterEach so it doesn't leak env state across suites.

Verified the v2-read acceptance tests (integration/shared/v2-decrypt-compat) match the CI CS_IT_SUITE glob, so #1a/#1b run under test:integration.
…ent lock-context re-bind

- resolveDecryptResult: the inner comment and `logger.debug` message still
  said the typed client has no decrypt audit surface and told the reader to
  use `Encryption({ config: { eqlVersion: 3 } })`. Both shipped clients now
  carry `.audit()` on decrypt, so the branch only fires for a non-conforming
  custom client. Message rewritten to describe that, with a regression test
  asserting it names neither `eqlVersion` nor `EncryptionV3`. The same false
  claim had propagated to the resolve-decrypt test header, `dynamodb/types.ts`,
  a test literally named "though decrypt cannot carry it", and a comment in
  `stack-supabase` — all corrected.

- MappedDecryptOperation.withLockContext: chaining a second lock context onto
  an already-bound op silently dropped it. The wrapper always exposes the
  method, unlike the nominal path where it is absent after binding, so the
  re-bind type-checks. It now throws. Verified no internal or sibling call
  site chains after a positional bind (stack-supabase calls `decryptModel(row)`
  one-arg then chains, so its underlying op is unbound). Tests cover both
  `decryptModel` and `bulkDecryptModels`.

- Changeset bumped minor -> major. Making `Encryption({ schemas: [<v3>] })`
  return the typed client changes its return type AND adds `Date`
  reconstruction on the two-arg `decryptModel` for existing plain-`Encryption`
  v3 callers. The package already carried a major, so the released bump is
  unchanged — the changelog is just accurate now.

- skills/stash-encryption documented `decryptModel`/`bulkDecryptModels` as
  returning `Promise<Result<…>>` and said only encrypt-side ops are chainable.
  It ships in the `stash` tarball, so that was wrong guidance in customer
  repos. Updated, including the one-or-the-other lock-context rule.
…array

CodeRabbit review of the query-builder split. Six findings, all pre-dating
the refactor; these are the two worth acting on.

`single()`/`maybeSingle()` have always returned ONE object at runtime, but
returned `Self`, so the builder kept advertising the array shape it was created
with — `data` was typed `T[] | null` while holding a single row. Callers had to
launder it, and the test suite documented the lie in a comment while casting
through `unknown` to reach the row's fields.

Both now return `EncryptedSingleQueryBuilder<T>`, awaiting
`EncryptedSupabaseResponse<T>` (`data: T | null`) — which already covers the
zero-row case for `maybeSingle()` and the error case for both, so no separate
null modelling was needed. The impl class carries the awaited shape as a
`TData` parameter so the promise cannot keep advertising `T[]` after the runtime
has been switched to single-row mode; `returns<U>()` preserves that shape.

Filters and transforms are deliberately absent from the single-row builder,
matching supabase-js: applying one after `single()` would change the query the
single-row promise was made about.

Also drops two unnecessary `as unknown as T[]` bridges in query-results.ts
(`[] as T[]` and the bulk-decrypt map both compile directly).

Not acted on, with reasons:

- The missing `assertPostgrestCanQueryEncryptedOperator` in the not-filter
  branch is a false positive. The guard fires upstream in `assertTermQueryable`
  (`contains`/`matches` map to `freeTextSearch`), before encryption, and
  `supabase-v3-json.test.ts` already covers `.not(col,'contains')` and
  `.not(col,'matches')` under EQL 3.0.2. The suggested guard keys on
  `wasEncrypted`, which that path never reaches.
- Routing plaintext `in` arrays through `formatInListOperand` would change
  behaviour: `.filter()` is the raw escape hatch and forwards verbatim, as
  supabase-js does. The encrypted path only intervenes because it must encrypt
  element-wise.
- The `as never` on `bulkEncrypt` args and the `term.column` double assertion
  need `ScalarQueryTerm['column']` widened in `@cipherstash/stack` — a different
  package's public type.
CI runs against the PR MERGE commit, so it saw a file this branch never
had: `remove-v2` gained `utils-codegen.test.ts` (from the v3 domain-picker
work) after this branch was cut, and it pins the generated `stash init`
client to `extractEncryptionSchemaV3` / `@cipherstash/stack-drizzle/v3` —
exactly the two names this PR removes. Textually the merge is clean; the
conflict is semantic, which is why it only surfaced in CI.

Points those assertions at the collapsed root and adds the matching
negatives, so a regression to the `./v3` specifier fails here rather than in
a scaffolded project. Narrowed `utils-codegen-drizzle.test.ts` to what the
merged-in suite does not cover — `generatePlaceholderClient`, still
untested — and updated it to the new `ColumnDef` shape (`domain`, not
`dataType`/`searchOps`).
The wizard now scaffolds EQL v3 columns, so `drizzle-kit generate` emits
`ALTER COLUMN ... SET DATA TYPE eql_v3_<name>` — which Postgres rejects (no
cast from text/numeric to an EQL domain). The post-agent rewriter matched only
the single `eql_v2_encrypted` type, so those v3 statements slipped through
unrepaired and failed at migrate time.

Port the rewriter to the whole `eql_v3_*` concrete-domain family alongside
legacy `eql_v2_encrypted`, mirroring the sibling CLI fix (#693): every mangled
form drizzle-kit emits (incl. the 0.31.0+ `"undefined".` prefix and
schema-qualified pgSchema tables), near-miss flagging for `SET DATA TYPE ...
USING ...` it cannot safely repair, statement-breakpoints, and a clearer
data-destroying / empty-table-only warning that points populated tables at the
staged `stash encrypt` flow.

Database introspection (`isEqlEncrypted`) now recognises BOTH `eql_v2_encrypted`
and the `eql_v3_*` family as already-encrypted, matching migrate's
`classifyEqlDomain` v3 convention — so the agent won't scaffold over existing
encrypted data of either generation (v2 ciphertext stays valid and detected).

Add a rewrite-migrations test suite (adapted from the CLI's).
Address code-review feedback on the v3 migration-rewriter port.

**Sweep every candidate migration directory.** `rewriteEncryptedMigrations`
returned after the FIRST candidate that merely existed, even when that
directory contained zero matches — so an empty or already-rewritten
`drizzle/` sitting next to a project's real `migrations/` left those
migrations unrepaired, and they then failed at migrate time with the very
`cannot cast type ...` error the rewriter exists to prevent.

The comment justifying the early return ("running again on a different
candidate would double-transform already-rewritten SQL") was wrong on both
counts: distinct directories hold distinct files, and the rewrite is
idempotent anyway — a rewritten statement no longer contains `SET DATA
TYPE`, so neither the strict matcher nor the near-miss scan can match it a
second time.

Extract the sweep into an exported, log-free `sweepMigrationDirs(cwd, dirs)`
so it is directly testable without mocking @clack/prompts. It sweeps every
existing candidate and reports a directory that throws via `error` rather
than stranding the ones after it; post-agent keeps the reporting.

**Trim the near-miss statement preamble.** `NEAR_MISS_RE` opens with a lazy
`[^;]*?` whose only left boundary is the previous `;` — or the start of file
when there is none. The reported statement therefore dragged in every
comment and blank line since then, so a near-miss in a file opening with a
comment block was quoted back to the user with the whole header glued to its
front. Strip leading blank/comment lines (incl. `--> statement-breakpoint`)
so the statement reads as the offending statement alone. Detection is
unchanged; only the text shown to the user differs.

Applied to the `stash` CLI sibling too — it carried the identical defect and
its header mandates keeping the two in sync. The directory sweep does not
apply there: the CLI takes a single explicit `outDir`.

**Document the hardcoded `"public"."<domain>"`.** Behaviour unchanged and
not a regression, but worth stating: the domain qualifier is an assumption
(EQL installs into `public`), not something read back from the matched SQL —
the `schema` capture is the TABLE's schema and says nothing about where the
domain lives. Non-public domain installs would need it threaded in here and
in the CLI sibling. Already pinned by the existing pgSchema() test.

9 new tests across the two packages, each watched failing first.
…ypecheck

Closes the last open item from the #771 review — the 3 pre-existing
`AutoStrategy` tsc errors flagged as "would bite a future strict type gate".

**Root cause.** `@cipherstash/auth` uses conditional exports: `node` resolves
to `index.d.ts` (the full surface, including `AutoStrategy`), `default`
resolves to `wasm-types.d.ts` (which has no `AutoStrategy`). The wizard's
tsconfig sets `moduleResolution: "bundler"`, which does NOT include the `node`
condition, so tsc took the `default` branch and reported `AutoStrategy` as
missing on all three call sites — `agent/fetch-prompt.ts`, `agent/interface.ts`
and `lib/prerequisites.ts`.

Nothing was actually broken at runtime: the wizard is a Node CLI and always
loads the `node` branch. The types were simply resolved against the wrong
entry point.

**Fix.** Add `"customConditions": ["node"]`, matching what `packages/stack`,
`packages/prisma-next`, `packages/test-kit`, `packages/stack-drizzle` and
`packages/stack-supabase` already carry for the identical `protect-ffi`
conditional-export problem. `tsc --noEmit` now exits 0.

**Regression guard.** A type-level defect needs a type-level gate, or it comes
back silently — the wizard is built by tsup with `dts: false`, so the build
transpiles without ever typechecking, which is exactly why these three errors
sat in `main` unnoticed. Add a `typecheck` script and wire it into `tests.yml`
alongside the prisma-next gate (#684). Verified the gate fires: with
`customConditions` removed the step exits 2 on all three errors; restored, it
exits 0.

No changeset — `dts: false` means the published tarball is byte-identical.
Tooling only, no observable behaviour change.

Green: wizard 265 pass / 5 env-skipped, typecheck 0 errors; stash 770 pass /
53 files; `code:check` 0 errors.
Rebase fallout: main's `stash-indexing` skill (#773) and the indexing
section it added to `stash-drizzle` both import `encryptedIndexes` from
`@cipherstash/stack-drizzle/v3`, a subpath this branch removes when it
collapses `./v3` into the package root. Both files ship to customer repos,
so the stale specifier would not resolve in a freshly-initialised project.

Caught by `scripts/__tests__/no-removed-drizzle-surface.test.mjs`, the
guard this branch added for exactly this drift.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant