Skip to content

Remove EQL v2 repo-wide (umbrella) #707

Description

@tobyhede

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

  • drizzle-kit v3: ALTER COLUMN to an eql_v3 domain emits invalid DDL (no v3 rewriter) #693 — v3 has no ALTER COLUMN path. rewriteEncryptedAlterColumns is hardcoded to eql_v2_encrypted. Most contained of the three: generalise the regex to the eql_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.
  • DynamoDB: support EQL v3 schemas in encryptedDynamoDB #657 — DynamoDB is v2-only. The larger of the two. Re-scope it first: the issue cites packages/protect-dynamodb/ but describes encryptedDynamoDB, which lives in packages/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

The one deployed-state item

  • prisma-next contract chain — the v3 migration's from is the v2 baseline's to, and src/contract.json declares eql_v2_configuration as 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 contractSpaceFromJson accept a contract with zero declared roots? Yes — resolved. contractSpaceFromJson is a pure pass-through cast with no validation (its doc defers to validateContract). validate-domain.ts:44,160 iterates Object.entries(contract.roots), which is a no-op on {}, and no length or emptiness assertion exists anywhere. Decisively: canonicalization.ts:66 lists roots among the keys preserved when empty rather than stripped as a default, specifically so the emitted contract.json stays 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:571 reachable from v3? No — resolved. The v3 override of encryptCollectedTerms (query-builder-v3.ts:549) reads only term.value and term.column; it never inspects returnType. It groups by column, calls bulkEncrypt, and returns JSON.stringify(envelope). Its own comment at :567-570: "PostgREST cannot cast a filter value to the eql_v3.query_<name> twins, so v3 sends full envelopes where v2 sends encryptQuery composite literals." So returnType: '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 *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 packagespackages/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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions