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

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. Done via feat(prisma-next)!: make the adapter EQL v3 only (fixes Supabase superuser block, #709) #712 (make the adapter EQL v3 only, merged to main 2026-07-21) — which delivered the whole prisma-next v2 removal (empty contract → roots: [] / storageHash efd408…, v3 genesis migration, all v2 files/exports/execution-layer deleted), not just the re-baseline. No standalone prisma-next removal PR is needed.

    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

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions