Make EQL v3 the sole generation across the workspace.
Full scope assessment: docs/plans/2026-07-20-eql-v2-removal-scope.md (six parallel source investigations, 2026-07-20). This issue is the tracking summary — the doc has the file:line evidence.
The framing that matters
Removal is not the work. Finishing v3 is.
Both prerequisites below are unfinished v3 features, not a consequence of deleting v2. Each needs building regardless of whether v2 ever goes — v2's presence currently disguises them as "use v2 there instead." Close both and v2 has no remaining use case, at which point the deletion is mechanical.
Prerequisites
Reassessed — not a prerequisite
The one deployed-state item
Open questions — resolve before these become load-bearing
Naming — decided
v3 takes the unsuffixed name. The *V3 name stays as a @deprecated alias.
Follows the precedent already shipped in 4923c0a, where prisma-next's v3 factory took cipherstashFromStack and v2 became cipherstashFromStackV2. Applying the same pattern everywhere leaves the workspace with one convention instead of two.
The aliases are not for v2 users — they are gone at that version. They exist so people who already migrated to v3 do not edit every import a second time for zero functional gain. One line each plus @deprecated.
Supabase — safe, do it
The two signatures are incompatible in every dimension, so a stale v2 call site fails loudly rather than silently rebinding:
v2 export function encryptedSupabase(config): EncryptedSupabaseInstance
sync · 1 arg · { encryptionClient, supabaseClient }
v3 export async function encryptedSupabaseV3(url, key, options?)
encryptedSupabaseV3(client, options?)
async · 2-3 args · string | SupabaseClientLike first
- TypeScript:
{ encryptionClient, supabaseClient } is not assignable to SupabaseClientLike | string — hard error.
- Plain JS:
typeof clientOrUrl === 'string' is false, so the config object is taken as the client and construction dies on the missing databaseUrl.
from() also diverges — v2 takes (tableName, schema), v3 takes (tableName).
Same treatment for the ~14 *V3 type exports in types.ts (EncryptedSupabaseV3Options, V3FilterableKeys, TypedEncryptedSupabaseV3Instance, …).
Drizzle — the exception, needs a hard break
The ./v3 subpath collapse cannot take an alias. Post-collapse the root names (createEncryptionOperators, extractEncryptionSchema) are the same names the v2 API uses today, and the two forms are similar enough that a stale import would plausibly keep type-checking while binding to v3 semantics. Remove the old path outright so the failure is loud.
prisma-next — already done
4923c0a already gave v3 the unsuffixed names. No work.
cli init scaffold — extra consumer to flip
#734 ported the stash init client scaffold to v3, so packages/cli/src/commands/init/utils.ts now emits EncryptionV3 and extractEncryptionSchemaV3 as generated strings. Update the scaffold output when the de-suffixing lands (it's generated text, not an import, so a grep for call sites won't obviously flag it).
Explicitly NOT blockers
Recorded because each was believed to be one during the investigation. Please don't re-derive these.
Customer data migration. Customers can install both majors: test/live/side-by-side-clients-live-pg.test.ts proves a v2 and a v3 client run in one process against one database. Decision 1b scopes to a client, not a process. Two protect-ffi versions already coexist in this repo's lockfile (0.23.0 and 0.29.0). And one protect-ffi handles both generations — eqlVersion is a runtime parameter to newClient, not a package split. The migration population is small; the story is "install both, write a script," not shipped tooling.
Lock contexts. Re-encryption needs each end user's live OIDC JWT, so a batch tool we ship cannot migrate lock-context data. A customer migrating in their own application has user sessions — lazy re-encryption on next login is routine. Infeasible for us, fine for them. (Worth knowing regardless: no discriminator marks a stored payload as lock-context-encrypted, so that population can't be scoped in advance by any tool.)
from_v2. encrypt-query-language/crates/eql-bindings/src/from_v2/ already implements v2.3→v3 as a pure wire rewrite with no crypto — copies c verbatim, so keys are untouched and no plaintext transits. Not exposed through protect-ffi. Given the small migration population this is a nice-to-have, not a dependency.
The init column picker. Reported as "the structurally hard piece" of the CLI work. It was already removed — SearchOp/ColumnDef are vestigial and init always sets schemas: [].
Scope summary
| Package |
Disposition |
prisma-next |
17 files delete; contract re-baseline; ~11k src + ~5.7k test lines |
cli |
22,512 lines of vendored v2 SQL (v3 sources from @cipherstash/eql at runtime); cutover.ts deletes entirely |
stack-supabase |
Refactor, not delete — the v3 builder extends the v2 base via a type-erasing cast; 1633-line class to re-parameterize |
stack |
./schema shrinks to types+zod, doesn't empty; Encryption is generation-neutral and survives |
protect, schema, protect-dynamodb |
Deleted packages — packages/stack is a fork of protect, not a consumer |
stack-drizzle |
Clean fork, v2 and v3 share nothing; 2,309 lines delete |
migrate |
Only eql.ts (166 lines) is v2 code |
nextjs |
Zero — exclude from scope |
⚠️ Do not scope this by grepping for v2. v2 is the unsuffixed generation, so such a grep finds v3 files that mention v2 and misses v2 files entirely. packages/stack/src/dynamodb/helpers.ts:183 is const v = 2 and matches nothing.
Closes with this work
#409, #599, #423, #426, #421, #422, #198 — all v2-only. Worth labelling now so nobody picks one up.
Related
#690 (migration-first v3 install), #613 (v3 Drizzle migration install — need met by eql migration --drizzle), #534, #637, #695.
🤖 Generated with Claude Code
Make EQL v3 the sole generation across the workspace.
Full scope assessment:
docs/plans/2026-07-20-eql-v2-removal-scope.md(six parallel source investigations, 2026-07-20). This issue is the tracking summary — the doc has the file:line evidence.The framing that matters
Removal is not the work. Finishing v3 is.
Both prerequisites below are unfinished v3 features, not a consequence of deleting v2. Each needs building regardless of whether v2 ever goes — v2's presence currently disguises them as "use v2 there instead." Close both and v2 has no remaining use case, at which point the deletion is mechanical.
Prerequisites
rewriteEncryptedAlterColumnsis hardcoded toeql_v2_encrypted. Most contained of the three: generalise the regex to theeql_v3_*family and reuse the existing ADD/DROP/RENAME rewrite. Note v3 users never had this path — deleting v2 removes the last working case, not a v3 capability.packages/protect-dynamodb/but describesencryptedDynamoDB, which lives inpackages/stack/src/dynamodb/. Those are two different forked implementations. The shipping one has zero test coverage — a larger risk than the port itself.Reassessed — not a prerequisite
encryptedSupabaseV3needs a Postgres connection because it detects encrypted columns from their domain types, where v2 took a declared schema. That limitation exists today and is unaffected by removing v2 — deleting v2 only removes the fallback advice in the error message. Not a blocker.The one deployed-state item
prisma-next contract chain — the v3 migration's
fromis the v2 baseline'sto, andsrc/contract.jsondeclareseql_v2_configurationas its only root. Deleting the v2 migration re-bases the chain and changes every downstream hash.Materially simplified because prisma-next v3 was never released — no deployed database holds those hashes. The fix is a re-emit (
pnpm exec tsx migrations/20260601T0100_install_eql_v3_bundle/migration.ts), not a hand-edit. Nine steps in §5 of the scope doc.Open questions — resolve before these become load-bearing
Can
contractSpaceFromJsonaccept a contract with zero declared roots? Yes — resolved.contractSpaceFromJsonis a pure pass-through cast with no validation (its doc defers tovalidateContract).validate-domain.ts:44,160iteratesObject.entries(contract.roots), which is a no-op on{}, and no length or emptiness assertion exists anywhere. Decisively:canonicalization.ts:66listsrootsamong the keys preserved when empty rather than stripped as a default, specifically so the emittedcontract.jsonstays structurally valid on round-trip. Empty roots is supported by design, not by accident. The prisma-next work is the re-emit described above.Is
query-builder.ts:571reachable from v3? No — resolved. The v3 override ofencryptCollectedTerms(query-builder-v3.ts:549) reads onlyterm.valueandterm.column; it never inspectsreturnType. It groups by column, callsbulkEncrypt, and returnsJSON.stringify(envelope). Its own comment at:567-570: "PostgREST cannot cast a filter value to theeql_v3.query_<name>twins, so v3 sends full envelopes where v2 sendsencryptQuerycomposite literals." SoreturnType: 'composite-literal'is dead data on the v3 path. No runtime break for v3 users — the semver surface stays at the 3–4 exported type names.Side finding: v3 Supabase sends the full storage ciphertext as a scalar filter operand. EQL v3 Supabase: JSON containment/selector operands carry full storage ciphertext in GET query strings #654 covers this for JSON containment/selector operands; this is the same shape on the scalar path. May already be in scope there.
De-suffixing Decided: v3 takes the unsuffixed names, with deprecated aliases for the suffixed ones. See the "Naming" section below.
Naming — decided
v3 takes the unsuffixed name. The
*V3name stays as a@deprecatedalias.Follows the precedent already shipped in 4923c0a, where prisma-next's v3 factory took
cipherstashFromStackand v2 becamecipherstashFromStackV2. Applying the same pattern everywhere leaves the workspace with one convention instead of two.The aliases are not for v2 users — they are gone at that version. They exist so people who already migrated to v3 do not edit every import a second time for zero functional gain. One line each plus
@deprecated.Supabase — safe, do it
The two signatures are incompatible in every dimension, so a stale v2 call site fails loudly rather than silently rebinding:
{ encryptionClient, supabaseClient }is not assignable toSupabaseClientLike | string— hard error.typeof clientOrUrl === 'string'is false, so the config object is taken as the client and construction dies on the missingdatabaseUrl.from()also diverges — v2 takes(tableName, schema), v3 takes(tableName).Same treatment for the ~14
*V3type exports intypes.ts(EncryptedSupabaseV3Options,V3FilterableKeys,TypedEncryptedSupabaseV3Instance, …).Drizzle — the exception, needs a hard break
The
./v3subpath collapse cannot take an alias. Post-collapse the root names (createEncryptionOperators,extractEncryptionSchema) are the same names the v2 API uses today, and the two forms are similar enough that a stale import would plausibly keep type-checking while binding to v3 semantics. Remove the old path outright so the failure is loud.prisma-next — already done
4923c0a already gave v3 the unsuffixed names. No work.
cli init scaffold — extra consumer to flip
#734 ported the
stash initclient scaffold to v3, sopackages/cli/src/commands/init/utils.tsnow emitsEncryptionV3andextractEncryptionSchemaV3as generated strings. Update the scaffold output when the de-suffixing lands (it's generated text, not an import, so a grep for call sites won't obviously flag it).Explicitly NOT blockers
Recorded because each was believed to be one during the investigation. Please don't re-derive these.
Customer data migration. Customers can install both majors:
test/live/side-by-side-clients-live-pg.test.tsproves a v2 and a v3 client run in one process against one database. Decision 1b scopes to a client, not a process. Two protect-ffi versions already coexist in this repo's lockfile (0.23.0 and 0.29.0). And one protect-ffi handles both generations —eqlVersionis a runtime parameter tonewClient, not a package split. The migration population is small; the story is "install both, write a script," not shipped tooling.Lock contexts. Re-encryption needs each end user's live OIDC JWT, so a batch tool we ship cannot migrate lock-context data. A customer migrating in their own application has user sessions — lazy re-encryption on next login is routine. Infeasible for us, fine for them. (Worth knowing regardless: no discriminator marks a stored payload as lock-context-encrypted, so that population can't be scoped in advance by any tool.)
from_v2.encrypt-query-language/crates/eql-bindings/src/from_v2/already implements v2.3→v3 as a pure wire rewrite with no crypto — copiescverbatim, so keys are untouched and no plaintext transits. Not exposed through protect-ffi. Given the small migration population this is a nice-to-have, not a dependency.The init column picker. Reported as "the structurally hard piece" of the CLI work. It was already removed —
SearchOp/ColumnDefare vestigial and init always setsschemas: [].Scope summary
prisma-nextcli@cipherstash/eqlat runtime);cutover.tsdeletes entirelystack-supabasestack./schemashrinks to types+zod, doesn't empty;Encryptionis generation-neutral and survivesprotect,schema,protect-dynamodbpackages/stackis a fork ofprotect, not a consumerstack-drizzlemigrateeql.ts(166 lines) is v2 codenextjsv2. v2 is the unsuffixed generation, so such a grep finds v3 files that mention v2 and misses v2 files entirely.packages/stack/src/dynamodb/helpers.ts:183isconst v = 2and matches nothing.Closes with this work
#409, #599, #423, #426, #421, #422, #198 — all v2-only. Worth labelling now so nobody picks one up.
Related
#690 (migration-first v3 install), #613 (v3 Drizzle migration install — need met by
eql migration --drizzle), #534, #637, #695.🤖 Generated with Claude Code