Skip to content

feat(stack-supabase)!: remove EQL v2 authoring surface, de-suffix v3 API#769

Merged
tobyhede merged 7 commits into
remove-v2from
feat/remove-eql-v2-pr4-supabase
Jul 23, 2026
Merged

feat(stack-supabase)!: remove EQL v2 authoring surface, de-suffix v3 API#769
tobyhede merged 7 commits into
remove-v2from
feat/remove-eql-v2-pr4-supabase

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Part of the EQL v2 removal effort (#707). PR 4 of the linear stack — stacked on #768 (PR 3, stack core).

⚠️ Draft: stacked on #768. Review/merge PR 3 first. Diff shows only stack-supabase changes on top of PR 3.

What this does

Makes @cipherstash/stack-supabase EQL v3 only for authoring, per the design (docs/plans/2026-07-22-eql-v2-final-removal-design.md §"PR 4").

  • Refactor: deleted src/query-builder-v3.ts (989 lines); re-parameterized EncryptedQueryBuilderImpl over AnyV3Table with the v3 dialect overrides inlined and the generation-agnostic machinery preserved verbatim. No wire-encoding or Result-shape change.
  • De-suffix: encryptedSupabaseV3encryptedSupabase (+ @deprecated alias); ~13 *V3 public types de-suffixed, each keeping a @deprecated alias (Decision 5). Removed the v2 encryptedSupabase(...).from(table, schema) wrapper and v2 config/instance types.
  • Skill (stash-supabase), README, changesets (stack-supabase: major, stash: patch) updated.

Green gate

build ✓ · test:types 35 pass · test 466 pass (0 skipped) · code:check 0 errors.

Decision 6 (v2 decrypt) — RESOLVED

This adapter is now EQL v3 only for both read and write: it will not auto-read an eql_v2_encrypted column. That is intentional, not a gap:

  • No v2 ciphertext is stranded — EQL v2 payloads still decrypt through the core @cipherstash/stack client (decrypt / decryptModel), which is generation-agnostic.
  • Mixed-generation handling is a customer-side concern (install both, handle explicitly), matching the design's "Explicitly out of scope" stance ("install both, write a script" — not shipped auto-detection tooling). Baking dual-domain read into the adapter is exactly the auto-detection the project decided not to build; keeping the adapter single-generation is clearer and lower-complexity.
  • To read legacy v2 data during migration: decrypt fetched rows via @cipherstash/stack directly, or create an eql_v3_* twin and run the v3 rollout (see the stash-supabase skill), or pin the last release that shipped the v2 wrapper.

(The wizard PR #771 keeps both-domain awareness, but that is a detection clobber-guard, not a decrypt path — a different operation, not a conflicting choice.)

Notes

  • AGENTS.md Repository Layout / SECURITY.md deliberately not touched — meta-honesty sweep is PR 7.
  • Integration tests updated for the rename but not run (need live creds + PostgREST).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • encryptedSupabase now provides an asynchronous, connect-time introspecting EQL v3 integration.
    • Added improved encrypted filtering, JSON selectors, mutations, decryption, and column mapping.
    • Canonical unsuffixed TypeScript exports are now available.
  • Breaking Changes

    • Removed the legacy EQL v2 authoring wrapper and two-argument from() schema form.
    • EQL v2 columns are no longer automatically read by this adapter.
  • Bug Fixes

    • Corrected .single() and .maybeSingle() result types to represent a single row.
  • Documentation

    • Updated migration guidance and examples; *V3 names remain deprecated compatibility aliases.

tobyhede added 4 commits July 23, 2026 11:15
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.
@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3d69a59

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

This PR includes changesets to release 11 packages
Name Type
@cipherstash/stack-supabase Major
stash Major
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Major
@cipherstash/stack-drizzle Major
@cipherstash/prisma-next Major
@cipherstash/wizard Major
@cipherstash/bench Patch
@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
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 90.32% which is sufficient. The required threshold is 80.00%.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: removing the v2 authoring surface and de-suffixing the v3 API.
✨ 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 feat/remove-eql-v2-pr4-supabase

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 2 commits July 23, 2026 11:26
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.
@tobyhede
tobyhede marked this pull request as ready for review July 23, 2026 02:03
@tobyhede
tobyhede requested a review from a team as a code owner July 23, 2026 02:03
…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.

@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.

🧹 Nitpick comments (2)
packages/stack-supabase/__tests__/supabase-v3-builder.test.ts (1)

5-5: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Retain coverage through the builder execution path.

This replacement calls the internal assertTermQueryable directly with a forged term, bypassing EncryptedQueryBuilderImpl execution and its error propagation. It can pass even if the builder stops invoking the guard or mishandles the resulting error. Keep this focused unit assertion if useful, but retain/add a regression that exercises the builder path.

Also applies to: 1479-1505

🤖 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-supabase/__tests__/supabase-v3-builder.test.ts` at line 5,
Retain the direct assertTermQueryable assertion if useful, but add a regression
test that invokes the forged term through EncryptedQueryBuilderImpl’s normal
execution path and verifies the guard error is propagated. Ensure the test would
fail if the builder stops calling the guard or mishandles its error.

Source: Coding guidelines

packages/stack-supabase/src/query-encrypt.ts (1)

598-605: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace type-erasing assertions with narrowed types or a reasoned Biome ignore. These casts erase all type information at the FFI/column boundary; the guideline requires narrowing or a specific assertion, suppressing deliberate cases with a reasoned Biome ignore. Neither site has one.

  • packages/stack-supabase/src/query-encrypt.ts#L598-L605: as never on both bulkEncrypt(...) arguments fully erases the operand/context types — introduce a precise parameter type for bulkEncrypt (or a single, documented biome-ignore with rationale) instead.
  • packages/stack-supabase/src/query-encrypt.ts#L512-L512: term.column as unknown as V3ColumnLike — narrow ScalarQueryTerm['column'] to V3ColumnLike at the source, or add a reasoned biome-ignore.

As per coding guidelines: "Avoid type-erasing assertions such as as any, as never, and as unknown in source; narrow types or use a specific assertion, suppressing deliberate cases with a reasoned Biome ignore."

🤖 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-supabase/src/query-encrypt.ts` around lines 598 - 605, Replace
the type-erasing assertions in
packages/stack-supabase/src/query-encrypt.ts#L598-L605 by giving bulkEncrypt
precise parameter types and passing narrowed values, or add one documented Biome
ignore with a specific rationale; also narrow ScalarQueryTerm['column'] before
the term.column use at packages/stack-supabase/src/query-encrypt.ts#L512 or
apply a reasoned Biome ignore. Preserve the existing encryption behavior and
avoid as never, as unknown, or equivalent erasing casts.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@packages/stack-supabase/__tests__/supabase-v3-builder.test.ts`:
- Line 5: Retain the direct assertTermQueryable assertion if useful, but add a
regression test that invokes the forged term through EncryptedQueryBuilderImpl’s
normal execution path and verifies the guard error is propagated. Ensure the
test would fail if the builder stops calling the guard or mishandles its error.

In `@packages/stack-supabase/src/query-encrypt.ts`:
- Around line 598-605: Replace the type-erasing assertions in
packages/stack-supabase/src/query-encrypt.ts#L598-L605 by giving bulkEncrypt
precise parameter types and passing narrowed values, or add one documented Biome
ignore with a specific rationale; also narrow ScalarQueryTerm['column'] before
the term.column use at packages/stack-supabase/src/query-encrypt.ts#L512 or
apply a reasoned Biome ignore. Preserve the existing encryption behavior and
avoid as never, as unknown, or equivalent erasing casts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a8ea556-a9a6-4e90-a0ad-96b54fc85245

📥 Commits

Reviewing files that changed from the base of the PR and between 04f1942 and 3d69a59.

📒 Files selected for processing (29)
  • .changeset/remove-eql-v2-supabase-authoring.md
  • .changeset/remove-eql-v2-supabase-skill.md
  • .changeset/supabase-single-row-typing.md
  • .github/workflows/fta-v3.yml
  • packages/stack-supabase/README.md
  • packages/stack-supabase/__tests__/supabase-encryption-error.test.ts
  • packages/stack-supabase/__tests__/supabase-v3-builder.test.ts
  • packages/stack-supabase/__tests__/supabase-v3-factory.test.ts
  • packages/stack-supabase/__tests__/supabase-v3-json.test.ts
  • packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts
  • packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts
  • packages/stack-supabase/__tests__/supabase-v3-wire.test.ts
  • packages/stack-supabase/__tests__/supabase-v3.test-d.ts
  • packages/stack-supabase/__tests__/supabase.test.ts
  • packages/stack-supabase/integration/wire.integration.test.ts
  • packages/stack-supabase/package.json
  • packages/stack-supabase/src/column-map.ts
  • packages/stack-supabase/src/helpers.ts
  • packages/stack-supabase/src/index.ts
  • packages/stack-supabase/src/introspect.ts
  • packages/stack-supabase/src/query-builder-v3.ts
  • packages/stack-supabase/src/query-builder.ts
  • packages/stack-supabase/src/query-dbspace.ts
  • packages/stack-supabase/src/query-encrypt.ts
  • packages/stack-supabase/src/query-filters.ts
  • packages/stack-supabase/src/query-mutation.ts
  • packages/stack-supabase/src/query-results.ts
  • packages/stack-supabase/src/types.ts
  • skills/stash-supabase/SKILL.md
💤 Files with no reviewable changes (2)
  • packages/stack-supabase/tests/supabase.test.ts
  • packages/stack-supabase/src/query-builder-v3.ts

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed by Claude Code on behalf of James Sadler.

Verdict: Approve (non-blocking suggestions below).

This is a large but disciplined breaking-change refactor. I read the full diff and cross-checked the PR-head tree (the single() typing change, the query-builder fold, the de-suffixing, and the module split). No blocking correctness/security/API defects.

What I verified

De-suffixing is complete and non-breaking.

  • encryptedSupabaseV3encryptedSupabase with a runtime @deprecated alias (export const encryptedSupabaseV3 = encryptedSupabase).
  • Every renamed type (EncryptedSupabaseV3Options, *V3Instance, EncryptedQueryBuilderV3[/Untyped], V3FilterableKeys, V3OrderableKeys, V3PlaintextKeys, NonQueryableV3Keys, NonOrderableV3Keys, V3SearchableJsonKeys, V3EncryptedFreeTextKeys, V3FreeTextSearchableKeys) keeps a type-identical @deprecated alias, and both the canonical and alias names are re-exported from index.ts. EncryptedSupabaseConfig (v2-only) is fully removed on the branch — confirmed no residual references in src/.
  • No src file on the branch imports the deleted query-builder-v3.ts; all test/integration imports were repointed to query-builder.

No security regression in the v3 fold. The 989-line EncryptedQueryBuilderV3Impl was folded into EncryptedQueryBuilderImpl (now natively v3) and split across column-map/query-encrypt/query-dbspace/query-filters/query-mutation/query-results. The fail-safe guards all survive 1:1:

  • validateTransforms() is still invoked (buildAndExecuteQuery, line 665) — the only protection against silently sorting a ciphertext envelope on the untyped surface.
  • The free-text needle floor (matchNeedleError), the null/length-mismatch envelope contract checks in bulkEncryptGroup, the empty-needle JSON containment rejection, and the EQL 3.0.2 assertPostgrestCanQueryEncryptedOperator fail-before-encrypt gate are all preserved.
  • withOpContext centralizes lock-context/audit application, so no crossing can silently drop the lock context (which would encrypt under the wrong key).

v2 removal is intentional and correctly scoped. Only the v2 authoring/emission surface is removed; no ciphertext is stranded because core @cipherstash/stack decrypt is generation-agnostic. Changesets (stack-supabase: major x2, stash: patch), README, and the stash-supabase skill are all updated consistently.

Non-blocking suggestions

  1. Changeset overclaims .single().returns<U>(). .changeset/supabase-single-row-typing.md says returns<U>() preserves the awaited shape "so .single().returns<U>() still awaits one row." But the public return type EncryptedSingleQueryBuilder<T> (types.ts) exposes only abortSignal / throwOnError — no returns. Through the public typed surface .single().returns<U>() is a compile error (only the impl class, which no consumer sees, carries the TData-preserving returns). Either add returns to EncryptedSingleQueryBuilder, or drop that sentence from the changeset.

  2. EncryptedSingleQueryBuilder is not re-exported from index.ts. It's the return type of the public single()/maybeSingle() (via EncryptedQueryBuilderCore), so consumers can use it structurally but cannot name it. Minor DX gap — worth adding to the export type { ... } block for parity with the other public surface types.

  3. fta-v3.yml supabase cap = 71 with ~0.9 margin. The PR's own comment flags this as fragile (top three files cluster at 70.12/70.05/69.13). Acceptable as a ratchet; just noting the next reflow may trip it — the comment already tells the next person to re-measure before assuming a real regression.

Nice work on the guard-preservation discipline and the exhaustive @deprecated alias coverage.

Base automatically changed from feat/remove-eql-v2-pr3-stack-core to remove-v2 July 23, 2026 05:16
@tobyhede
tobyhede merged commit fecb883 into remove-v2 Jul 23, 2026
9 checks passed
@tobyhede
tobyhede deleted the feat/remove-eql-v2-pr4-supabase branch July 23, 2026 05:17
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.

2 participants