Skip to content

feat(stack): encryptedSupabaseV3 — EQL v3 dialect of the Supabase adapter#588

Open
tobyhede wants to merge 27 commits into
feat/eql-v3-text-search-schemafrom
feat/eql-v3-supabase-adapter
Open

feat(stack): encryptedSupabaseV3 — EQL v3 dialect of the Supabase adapter#588
tobyhede wants to merge 27 commits into
feat/eql-v3-text-search-schemafrom
feat/eql-v3-supabase-adapter

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

encryptedSupabaseV3 — EQL v3 dialect of the Supabase adapter (@cipherstash/stack/supabase)

The EQL v3 Supabase adapter. encryptedSupabaseV3 introspects the database at connect time, detects EQL v3 columns by their Postgres domain, and derives each column's encryption config from it — so from() takes only a table name. Declaring schemas is optional, and buys compile-time types plus startup verification. v2 (encryptedSupabase) is unchanged; pick the dialect by function.

Stacked on feat/eql-v3-text-search-schema (#535) — set that as the merge base, not main. (#583 has since merged into #535.)

Usage

Zero config. No schema, no encryptedTable — the database already knows which columns are encrypted.

import { encryptedSupabaseV3 } from "@cipherstash/stack/supabase";

// Introspects over DATABASE_URL, then builds the encryption client for you.
const supabase = await encryptedSupabaseV3(supabaseUrl, supabaseKey);

// INSERT — auto-encrypts every eql_v3 column
await supabase.from("users").insert({ email: "alice@example.com", age: 30 });

// SELECT — auto-casts ::jsonb, auto-encrypts the filter term, auto-decrypts rows
const { data } = await supabase
  .from("users")
  .select("id, email")
  .eq("email", "alice@example.com");
// data => [{ id: 1, email: "alice@example.com" }]

A column is an EQL v3 column when its type is one of the public domains the EQL v3 bundle installs:

CREATE TABLE users (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email public.text_search,  -- equality, ordering, free-text match
  age   public.integer_ord,  -- equality, ordering
  name  text                 -- plaintext passthrough, untouched
);

Pass an existing Supabase client instead of a URL/key pair, and databaseUrl explicitly instead of DATABASE_URL:

const supabase = await encryptedSupabaseV3(supabaseClient, {
  databaseUrl: process.env.PG_URL,
});

The full supabase-js query surface works: eq/neq/gt/gte/lt/lte/in/is, like/ilike, or, not, match, order, limit, range, single, maybeSingle, plus insert/update/upsert/delete. select('*') and no-arg select() are expanded from the introspected column list.

// Substring match on a text_search column → bloom-filter containment (`cs`)
await supabase.from("users").select("*").like("email", "%@example.com");

Optional schemas: compile-time types + startup verification

Declare a v3 table and from() returns a typed builder for it — row types, filter-value types, and query-capability enforcement, all derived from the declared columns.

import { encryptedTable, types } from "@cipherstash/stack/eql/v3";
import { encryptedSupabaseV3 } from "@cipherstash/stack/supabase";

const users = encryptedTable("users", {
  email: types.TextSearch("email"),
  amount: types.IntegerOrd("amount"),
  createdAt: types.TimestampOrd("created_at"), // property ≠ DB name
  active: types.Boolean("active"),             // storage only
});

// Keyed by table name; verified against the live database at construction.
const supabase = await encryptedSupabaseV3(supabaseClient, { schemas: { users } });

const { data } = await supabase.from("users").select("*");
// data[0].email     => string
// data[0].amount    => number
// data[0].createdAt => Date      ← reconstructed from the encrypt-config cast_as
// data[0].active    => boolean

Filters and order() are pinned to each column's plaintext type and capabilities:

const q = supabase.from("users");
q.gte("createdAt", new Date());  // ok — Date column
q.eq("email", 42);               // ✗ type error: email is a string column
q.eq("active", true);            // ✗ type error: public.boolean is storage only
q.order("active");               // ✗ type error: no ORE index to order by

Declaring one table doesn't hide the others — undeclared tables fall through to the untyped surface, exactly as with no schemas at all:

const orders = supabase.from("orders");                 // introspected, not declared → untyped
const typed  = supabase.from<{ id: number }>("orders"); // or bring your own row type

Two behaviours require a declared column:

  • Column renames. createdAt: types.TimestampOrd("created_at") makes select('*') emit createdAt:created_at::jsonb, so the row comes back under the property name.
  • include_original: false on text_search. A substring like against an undeclared text_search column will not match: the synthesized default include_original: true puts the whole pattern into the bloom filter as an extra token.

Fails at construction, not on the first query

// Declared domain disagrees with the database
// → [supabase v3]: column "users.email" has domain "text_eq" but the schema declares "text_search"

// Column uses an EQL v3 domain this SDK version does not model (e.g. public.json, *_ord_ope)
// → [supabase v3]: column "users.payload" uses EQL v3 domain "public.json", which this
//   @cipherstash/stack version does not model. Upgrade the package or drop the column —
//   it cannot be used as a plaintext passthrough (reads would return ciphertext undecrypted).

// `pg` is an optional peer and is not installed
// → [supabase v3]: encryptedSupabaseV3 introspects the database over a direct Postgres
//   connection, but the optional peer dependency 'pg' is not installed. Install it …

The unmodelled-domain guard is the load-bearing one: without it such a column silently becomes a plaintext passthrough and reads return undecrypted ciphertext.

Implementation

EncryptedQueryBuilderImpl gains protected seams with v2-preserving defaults; EncryptedQueryBuilderV3Impl overrides them for raw jsonb mutations (v3 domains are plain jsonb, not v2's PG composite), full-envelope filter operands, like/ilikecs, Date reconstruction, and capability validation. Introspection reads information_schema.columns.domain_name, restricted to public domains carrying the EQL COMMENT ON DOMAIN marker.

Requires a Postgres connection for introspection (pg is a new optional peer dependency), so encryptedSupabaseV3 cannot run in a Worker or the browser. v2 has no such constraint.

Column names are translated once, at a phase boundary

A v3 table may rename a column (createdAt: types.TimestampOrd('created_at')). Translation used to be applied at each call site, and order() and the onConflict option never got it — .order('createdAt') sent a name PostgREST does not have. order() also accepted storage-only columns and sorted them by raw ciphertext, silently returning the wrong row order.

Rather than patch the two call sites, this branch removes the reason they were wrong:

  • DbName/DbSelect/DbFilterString/DbConflictList brand the SupabaseQueryBuilder seam. filterColumnName() is the only place a DbName is minted, so forgetting to translate is now a compile error, not a wrong query. The brands are erased at runtime, and a real SupabaseClient remains assignable to SupabaseClientLike (method-parameter bivariance), so this is not an API break.
  • toDbSpace() maps the whole recorded query into a branded DB-space IR in one pass. applyFilters/buildAndExecuteQuery do no translation at all. Adding a column-carrying method now means extending one switch the compiler forces you to complete.

This also removed a live fragility: the or() string used to be parsed twice — once to collect terms, once to apply them — and the two parses had to agree on condition indices for encrypted values to land on the right condition. It is now parsed once.

is and null operands are never encrypted (also fixes v2)

is is a SQL predicate — PostgREST accepts only null/true/false after it — and a null operand is SQL NULL, never a value to search for. Only the direct .is() filter skipped encryption. not(), or(), match(), raw filter(), and the in() element list all encrypted whatever they were handed:

not('age','is',null)     → age.not.is.("null")
or('age.is.null')        → age.is."("null")"
eq('email', null)        → email=("null")

Every one of those is an operand PostgREST rejects, so nothing can depend on the current output. This is wrong in v2 today, not only v3; it surfaced when a v2 regression test written for the toDbSpace refactor failed against unmodified code. A single isEncryptableTerm(operator, value) predicate now guards all six term collectors. On v3 it additionally removes a spurious does not support equality queries error, raised because is maps to the equality query type and so hit the column-capability guard.

Shipped as a separate patch changeset, since it fixes released v2 behaviour.

Resolves the auto-config review question from #583

@coderdan asked for the Rails-style shape from the launch blog post:

This interface differs to what is currently on the launch blog post. I think a Rails-style auto-config loading from the schema is the money shot of V3.

That is what shipped here — es.from('users'), no table argument. The original analysis assumed "loading from the schema" meant the stack schema (the encryptedTable(...) objects in TS), which would have needed a Drizzle-style type-level name→type registry to keep per-table filter-key safety. Introspecting the database schema turned out to be the better read: it removes the schema declaration entirely rather than merely relocating it, and schemas recovers the compile-time types for anyone who wants them. The blog post's interface needs no revision.

Open: Supabase grants are probably incomplete

supabasePermissionsSql (packages/cli/src/installer/index.ts) grants USAGE/SELECT/EXECUTE on exactly one schema — eql_v3. But the v3 bundle also creates eql_v3_internal, and public objects reach into it: eql_v3_internal.jsonb_blocked_exists backs a jsonb operator, and the MIN/MAX aggregates use sfunc = eql_v3_internal.min_sfunc. Postgres checks EXECUTE on an operator's backing function at query time, so anon/authenticated may hit permission errors on encrypted-column queries. The bundle header anticipates exactly this ("where a caller reaches an internal object indirectly… grant it deliberately").

Still unverified. It needs a live run against a Supabase role, and no such test exists (see below). Flagging because Supabase v3 is a headline feature.

Open: no Supabase v3 code has executed against a database

Worth stating plainly, because the test count does not make it obvious. Every Supabase v3 test drives a mock client that records {method, args}. supabase-v3-introspect-pg.test.ts is the only live file and it introspects only — no encrypt, insert, select, or decrypt.

The adapter's central design decision is that every filter operand is the full storage envelope from encrypt(), not a narrowed encryptQuery term, because each public.* domain CHECK requires the storage keys v/i/c — a narrowed term fails the CHECK with 23514 on every domain. Drizzle's live suite cannot vouch for this: src/drizzle/operators.ts uses encryptQuery, the exact encoding this adapter rejects. So the operand encoding that would break every domain if wrong is currently proven only by a mock that records strings.

Two follow-ups are planned: a 39-domain wire sweep reusing the compile-enforced V3_MATRIX catalog (only 5 of 39 domains reach the adapter today), and the first live PostgREST suite — run as anon, so it answers the grants question above.

Verification

Unit and type tests cover the synthesized/declared/merged schema paths, the select('*') aliasing round-trip, the unmodelled-domain partition, the branded seam, and the typed surface (supabase-v3.test-d.ts). Introspection is additionally exercised against live Postgres (supabase-v3-introspect-pg.test.ts), gated on DATABASE_URL.

At the branch tip: 94 tests across the six Supabase suites, 79 type tests, no tsc errors under src/. The order() regression is guarded by the compiler rather than a test — reverting the one-line fix yields TS2345: Argument of type 'string' is not assignable to parameter of type 'DbName'.

@tobyhede tobyhede requested a review from a team as a code owner July 9, 2026 01:23
@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 60fd960

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

This PR includes changesets to release 6 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e 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 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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

Run ID: 98f820ce-b632-4a78-9a24-bec3c6a61551

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 feat/eql-v3-supabase-adapter

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.

Base automatically changed from feat/eql-v3-bigint-restack to feat/eql-v3-text-search-schema July 9, 2026 03:17
@tobyhede tobyhede force-pushed the feat/eql-v3-supabase-adapter branch 2 times, most recently from 844e5c3 to aadfbbd Compare July 9, 2026 05:13
tobyhede added 10 commits July 9, 2026 16:27
…pter

The v2 query mechanism (direct EQL operators over PostgREST) is unchanged:
EncryptedQueryBuilderImpl gains narrow protected seams whose defaults preserve
the v2 behaviour byte-for-byte, and a v3 subclass (query-builder-v3.ts)
overrides them for native public.* concrete-domain columns:

- column recognition + property<->DB name resolution via buildColumnKeyMap
  (filters, mutations, aliased select casts prop:db::jsonb)
- raw jsonb mutation payloads (no eql_v2 composite wrap)
- full-envelope filter operands: every public.* domain CHECK requires the
  storage keys (v/i/c + index terms) and the SQL operators coerce their jsonb
  operand into the domain, so a narrowed encryptQuery term fails 23514 on EVERY
  domain — all operands go through encrypt() instead
- like/ilike on encrypted columns -> PostgREST cs (bloom @>)
- Date reconstruction from cast_as (date/timestamp) on decrypted rows
- capability validation: filters on storage-only columns or unsupported query
  types throw typed + runtime errors; filter keys are type-narrowed

Wire-encoding unit tests (mock encryption + supabase clients) cover both
dialects, including v2 regression pins for the seams.

Re-expressed from james/cip-3291-bigint-stack ecd3f38 against the base's
query-builder and the public.* domain surface (protect-ffi 0.28).
Adds describeLivePgOnly (DATABASE_URL only — introspection reads the schema,
it does not encrypt) and asserts its LIVE_PG_ENABLED flag in the ungated
live-coverage guard, so a missing DATABASE_URL fails CI loudly instead of
silently skipping the suite on a green job.
…roperty

`select('*')` expanded the introspected column list, which holds DB column
names. `addJsonbCastsV3` only emits the `prop:db_name::jsonb` PostgREST alias
when it sees a *property* name; a raw DB name took the unaliased
`dbNames.has(...)` branch. So on a declared table with a property→DB rename,
rows came back keyed `created_at` while the declared row type promises
`createdAt` — `row.createdAt` was silently `undefined` for a field TypeScript
guaranteed as a Date. Synthesized columns were unaffected (property == DB name),
which is why no existing test caught it: the select-star suite used only
matching names, and the rename suite used only explicit selects.

Adds an `expandAllColumns` hook on the base builder (identity for v2, which
never supplies a column list) that the v3 dialect overrides to map DB names
back through the inverse of `buildColumnKeyMap()`.

Also fixes a pre-existing prototype-inheritance hazard surfaced by the new
tests: `propToDb` was a plain object indexed by column names that originate in
the database, so a plaintext column named `constructor` resolved to
`Object.prototype.constructor` — truthy — and interpolated
`function Object() { [native code] }` into the emitted select string. It was
reachable from explicit selects too (`select('constructor')`), and equally from
`filterColumnName` and the mutation-payload transform. `buildColumnKeyMap()` now
returns a null-prototype map, and every read site is `Object.hasOwn`-guarded —
the same belt-and-braces the DOMAIN_REGISTRY already uses.

Regression tests assert the resulting ROW KEY, not just the select string: the
Supabase double now simulates PostgREST's alias projection, since the alias is
what performs the rename server-side. All five new assertions fail against the
prior implementation.
…h tuner

Rebasing onto feat/eql-v3-text-search-schema picks up 8123839, which removed
the vestigial chainable .freeTextSearch(opts) tuner from EncryptedTextSearchColumn
— in v3 the concrete type defines capabilities, so match is always emitted with
the default config.

The mergeDeclaredTables test used tuned match options as the observable proof
that a declared column wins over its synthesized twin. That observable is gone:
declared and synthesized TextSearch columns now build byte-identically. Assert
builder instance identity instead, which proves the same behaviour and is the
only remaining signal. Drop the now-stale "tuner options" clause from the
mergeDeclaredTables doc comment.
@tobyhede tobyhede force-pushed the feat/eql-v3-supabase-adapter branch from adc3db8 to 98f657a Compare July 9, 2026 06:34
@coderdan coderdan requested a review from Copilot July 9, 2026 06:40
@coderdan coderdan self-requested a review July 9, 2026 06:41
Comment on lines +76 to +92
/**
* Create an encrypted Supabase wrapper for **EQL v3** schemas by introspecting
* the database at connect time. Detects EQL v3 columns by their Postgres domain
* and derives each column's encryption config from it — callers no longer pass a
* schema to `from()`. Supplying `schemas` is optional: it adds compile-time
* types and verifies the declared tables against the database at construction.
*
* Requires a Postgres connection (`options.databaseUrl` or `DATABASE_URL`) for
* introspection, so it cannot run in a Worker or the browser.
*
* @example
* ```typescript
* const supabase = await encryptedSupabaseV3(supabaseUrl, supabaseKey)
* await supabase.from('users').insert({ email: 'alice@example.com' })
* const { data } = await supabase.from('users').select().eq('email', 'alice@example.com')
* ```
*/

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.

Might be worth including the DDL for an example table (including an EQL V3) column so its clear what's happening.

...but this is AWESOME!

Copilot AI 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.

Pull request overview

Adds encryptedSupabaseV3, an EQL v3 dialect of the Supabase adapter in @cipherstash/stack/supabase that introspects a live Postgres database at construction time, synthesizes v3 table schemas from EQL domain columns, and provides a query builder that uses v3’s native jsonb domains and operators while keeping v2 behavior intact.

Changes:

  • Introduce encryptedSupabaseV3 async factory that introspects information_schema (via optional pg) and constructs an Encryption client + v3 query builder.
  • Add v3-specific query-builder seams (select * expansion, property↔DB column mapping, full-envelope filter operands, like/ilikecs, Date reconstruction).
  • Add unit/type/live-PG tests and supporting utilities (domain registry, schema synthesis/merge/verification, new live gate).

Reviewed changes

Copilot reviewed 24 out of 25 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
pnpm-lock.yaml Adds lockfile entries for pg, @types/pg, and fast-check.
packages/stack/tsup.config.ts Externalizes pg from stack bundles.
packages/stack/src/supabase/verify.ts Verifies declared v3 schemas against introspected domains at construction time.
packages/stack/src/supabase/types.ts Adds v3 Supabase types/options and typed from() overloads with filter-key narrowing.
packages/stack/src/supabase/schema-builder.ts Synthesizes v3 tables from introspection, merges declared schemas, and guards unmodelled domains.
packages/stack/src/supabase/query-builder.ts Refactors v2 builder with protected seams to support a v3 dialect and select('*') when columns are known.
packages/stack/src/supabase/query-builder-v3.ts Implements the v3 dialect: property↔DB mapping, native jsonb mutations, full-envelope terms, cs pattern filters, Date reconstruction.
packages/stack/src/supabase/introspect.ts Adds Postgres introspection (columns + EQL-domain discovery via domain comments) using dynamic pg import.
packages/stack/src/supabase/index.ts Exposes encryptedSupabaseV3 factory and wires introspection → schema synthesis → client creation → v3 builder.
packages/stack/src/supabase/helpers.ts Adds addJsonbCastsV3 to cast/alias v3 columns for PostgREST selects.
packages/stack/src/eql/v3/table.ts Makes buildColumnKeyMap() return a null-prototype map to safely handle DB-provided names.
packages/stack/src/eql/v3/domain-registry.ts Introduces a null-prototype domain→factory registry for synthesizing v3 columns from domains.
packages/stack/package.json Adds pg optional peer dependency and dev deps for pg, @types/pg, and fast-check.
packages/stack/tests/supabase-verify.test.ts Tests declared-schema verification against introspection.
packages/stack/tests/supabase-v3.test-d.ts Adds type-level tests for the v3 typed/untyped surfaces and filter key/value constraints.
packages/stack/tests/supabase-v3-select-star.test.ts Tests select('*') expansion and aliasing behavior for renamed columns.
packages/stack/tests/supabase-v3-introspect-pg.test.ts Live Postgres tests for domain detection, synthesis mapping, and unmodelled-domain guard.
packages/stack/tests/supabase-v3-factory.test.ts Tests the v3 factory overloads, env fallback, verification ordering, and guard behavior via mocks.
packages/stack/tests/supabase-v3-builder.test.ts Tests v3 wire encoding and v2 regression invariants using mocks.
packages/stack/tests/supabase-schema-builder.test.ts Tests schema synthesis/merge behavior and unmodelled-domain guarding.
packages/stack/tests/supabase-introspect.test.ts Adds property-based tests for grouping logic and a connect-error handling test for introspection.
packages/stack/tests/live-coverage-guard.test.ts Ensures CI doesn’t silently skip pg-only suites by requiring DATABASE_URL.
packages/stack/tests/helpers/live-gate.ts Adds LIVE_PG_ENABLED and describeLivePgOnly gating for pg-only suites.
packages/stack/tests/eql-v3-domain-registry.test.ts Tests registry completeness, round-tripping, and prototype-key safety.
.changeset/eql-v3-supabase-adapter.md Changeset documenting the new encryptedSupabaseV3 feature and constraints.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +35 to +41
const builders: Record<string, AnyEncryptedV3Column> = {}
for (const col of table.columns) {
if (col.domainName === null) continue
const factory = factoryForDomain(col.domainName)
if (!factory) continue // unknown / unmodelled → guarded elsewhere
builders[col.columnName] = factory(col.columnName)
}
const tableName = declared.tableName
const synthesized = tables.get(tableName)

const merged: Record<string, AnyEncryptedV3Column> = {}
this.dbToProp[dbName] = property
}

this.v3Columns = {}
protected override transformEncryptedMutationModel(
model: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {}
protected override postprocessDecryptedRow(
row: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...row }
Comment on lines +100 to +109
export async function introspect(
databaseUrl: string,
): Promise<IntrospectionData> {
const { default: pg } = await import('pg')
// Mirror the CLI introspector's bounded connect so an unreachable DB fails
// fast rather than hanging construction.
const client = new pg.Client({
connectionString: databaseUrl,
connectionTimeoutMillis: 10_000,
})

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

Test-coverage review — encryptedSupabaseV3

This is an unusually well-tested PR. domain-registry, schema-builder, verify, groupIntrospectionRows, the factory wiring, select('*') expansion, and the v2 wire-encoding regression suite all have direct, well-targeted tests — including prototype-pollution negatives, fast-check properties, and a live-pg introspection suite backed by a CI guard that turns a silent whole-matrix skip into a loud failure. The gaps below are the remainder, not a general complaint.

The recurring shape of what's missing is lopsided negative/branch coverage inside the new v3 dialect overrides: not(like) → not(cs) is pinned, but the sibling or(like) → or(cs) axis is not; the array decrypt path is pinned, but the single() path is not; the property-keyed postprocessDecryptedRow branch is pinned, but the raw-DB-name branch it explicitly documents is not. addJsonbCastsV3 — a new exported function with six branches — has no direct unit test at all; it is only observed through two select string assertions that happen to exercise two of them.

Out-of-scope note (not a coverage gap)

The raw .filter() path in encryptFilterValues hardcodes queryType: 'equality' (query-builder.ts:633). On the v3 dialect that now flows into the new capability guard in encryptCollectedTerms, so .filter('email', 'cs', v) on a public.text_search column (equality: false) will throw does not support equality rather than doing what the caller asked. Worth a look independently of testing.

Additional coverage gaps not posted inline

  • query-builder.ts:102select('*') guards on allColumns === null || allColumns.length === 0, but only the null arm is tested (supabase-v3-select-star.test.ts:110). Pass [] to builderFor and assert the same throw, so a future ?? [] in index.ts can't turn the guard into an empty-select.
  • helpers.ts:113-116addJsonbCastsV3 preserves per-token leading whitespace (col.match(/^(\s*)/)) and returns col verbatim for an empty token. Neither is asserted; a multi-line / trailing-comma select string would regress silently.
  • introspect.ts:110 — only the connect-failure path is mocked (supabase-introspect.test.ts:78). The success path — both queries issued, eqlDomains built from domain_name rows, and client.end() actually called on the happy path — is exercised only by the live-pg suite. A mocked pg success test asserting end() was called would catch a leaked connection without needing DATABASE_URL.
  • schema-builder.ts:47mergeDeclaredTables has an untested if (synthesized) false arm (declared table absent from introspection). It's unreachable through the factory because verifyDeclaredSchemas throws first, but the function is exported and tested directly, so the arm is reachable by any future caller.
  • query-builder-v3.ts:225 — the queryType !== 'equality' | 'orderAndRange' | 'freeTextSearch' throw arm has no test. Low value: no current call site can produce another value.

const aliasMatch = trimmed.match(
/^([A-Za-z_][A-Za-z0-9_]*):([A-Za-z_][A-Za-z0-9_]*)$/,
)
if (aliasMatch) {

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.

Gap: addJsonbCastsV3 is a new exported function with six branches, but has no direct unit test — it is only observed indirectly via two select string assertions, which exercise the bare-property and rename branches. The already-aliased branch (this line), the raw-DB-name branch (line 142), and the three passthrough guards (::, (, .) are entirely unexercised.

There is no supabase-helpers test file yet; __tests__/helpers.test.ts covers encryption/helpers, so this wants a sibling.

// packages/stack/__tests__/supabase-helpers.test.ts
import { describe, expect, it } from 'vitest'
import { addJsonbCastsV3 } from '@/supabase/helpers'

const propToDb = { email: 'email', createdAt: 'created_at' }

describe('addJsonbCastsV3', () => {
  it('resolves an already-aliased token to the DB name', () => {
    expect(addJsonbCastsV3('e:createdAt', propToDb)).toBe('e:created_at::jsonb')
    expect(addJsonbCastsV3('e:created_at', propToDb)).toBe('e:created_at::jsonb')
    // alias onto an unknown column: untouched
    expect(addJsonbCastsV3('e:other', propToDb)).toBe('e:other')
  })

  it('casts a raw DB name in place, without aliasing', () => {
    expect(addJsonbCastsV3('created_at', propToDb)).toBe('created_at::jsonb')
  })

  it('leaves already-cast, function and dotted tokens untouched', () => {
    expect(addJsonbCastsV3('email::text', propToDb)).toBe('email::text')
    expect(addJsonbCastsV3('count(email)', propToDb)).toBe('count(email)')
    expect(addJsonbCastsV3('t.email', propToDb)).toBe('t.email')
  })

  it('does not resolve Object.prototype keys as properties', () => {
    expect(addJsonbCastsV3('constructor', propToDb)).toBe('constructor')
    expect(addJsonbCastsV3('a:toString', propToDb)).toBe('a:toString')
  })
})

Expected: all pass on the current implementation. The prototype cases fail (emitting function Object() {…}::jsonb) if the Object.hasOwn guard in lookupDbName is ever dropped — which is exactly the regression the comment above it warns about but nothing currently catches.

return op
}

protected override transformOrConditions(

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.

Gap: transformOrConditions — the whole .or() axis — is untested for v3, in both the string and structured forms. This is the classic lopsided-negative pattern: not('email','like',…) → not(…, 'cs') is pinned (supabase-v3-builder.test.ts:351), but the sibling or(… .like. …) → or(… .cs. …) transform, and the property→DB name mapping inside or, are not. The or-string is rebuilt by string concatenation (rebuildOrString), so a wrong column name or operator here reaches PostgREST as a silently-mismatched filter rather than an error.

Note the encrypted operand is JSON containing commas, so assert with toContain/toMatch, not by splitting on ,.

it('maps property→DB names and encrypted like→cs inside or() (string form)', async () => {
  const { es, supabase } = v3Instance()

  await es
    .from('users', users)
    .select('id, email')
    .or('createdAt.gte.2026-01-01,email.like.a@b')

  const [or] = supabase.callsFor('or')
  const s = or.args[0] as string
  expect(s).toMatch(/^created_at\.gte\./)
  expect(s).toContain('email.cs.')
  expect(s).not.toContain('createdAt.')
  expect(s).not.toContain('email.like.')
})

it('applies the same transform to the structured or() form', async () => {
  const { es, supabase } = v3Instance()

  await es
    .from('users', users)
    .select('id, email')
    .or([
      { column: 'createdAt', op: 'gte', value: new Date('2026-01-01T00:00:00.000Z') },
      { column: 'email', op: 'like', value: 'a@b' },
    ])

  const s = supabase.callsFor('or')[0].args[0] as string
  expect(s).toMatch(/^created_at\.gte\./)
  expect(s).toContain('email.cs.')
})

Expected: both pass. Delete the transformOrConditions override and the first fails on created_at. / email.cs., proving the assertions bite.

if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue
// Rows are keyed by property name when selected via the aliasing cast
// helper, but a caller selecting by raw DB name gets DB-name keys.
for (const key of property === dbName ? [property] : [property, dbName]) {

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.

Gap: the property === dbName ? [property] : [property, dbName] fork exists precisely to handle a caller who selected by raw DB name — and that arm has no test. supabase-v3-builder.test.ts:421 only covers the property-keyed row. The other three boundary inputs the loop body guards (null, an already-Date value, a numeric epoch) are also unexercised.

it('reconstructs Date when the row is keyed by the raw DB column name', async () => {
  const rows = [
    { id: 1, created_at: fakeEnvelope(new Date('2026-01-02T03:04:05.000Z'), 'created_at') },
  ]
  const { es } = v3Instance(rows)

  const { data } = await es.from('users', users).select('id, created_at')

  expect(data![0].created_at).toBeInstanceOf(Date)
  expect((data![0].created_at as Date).toISOString()).toBe('2026-01-02T03:04:05.000Z')
})

it('passes through null and an existing Date, and coerces an epoch number', async () => {
  const d = new Date('2026-01-02T03:04:05.000Z')
  const { es } = v3Instance([
    { createdAt: d },
    { createdAt: null },
    { createdAt: d.getTime() },
  ])

  const { data } = await es.from('users', users).select('createdAt')

  expect(data![0].createdAt).toBe(d) // identity: not re-wrapped
  expect(data![1].createdAt).toBeNull()
  expect(data![2].createdAt).toBeInstanceOf(Date)
})

Expected: both pass. Drop the [property, dbName] arm and the first fails with created_at still a string.

if (this.auditConfig) op.audit(this.auditConfig)

const result = await op
if (result.failure) {

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.

Gap: encryptCollectedTerms reimplements the failure / lock-context / audit threading rather than inheriting v2's, and neither the result.failure → EncryptionFailedError arm (this line) nor the withLockContext / audit threading (lines 228-232) is tested. The existing 500-status tests (supabase-v3-builder.test.ts:394, :407) all reach status: 500 via the capability guard throw, so they pass even if this whole block is deleted.

it('surfaces a filter-term encryption failure as a 500 response', async () => {
  const supabase = createMockSupabase()
  const failingOp = {
    withLockContext: () => failingOp,
    audit: () => failingOp,
    then: (f?: ((v: unknown) => unknown) | null) =>
      Promise.resolve({ failure: { message: 'boom' } }).then(f),
  }
  const failing = { encrypt: () => failingOp } as unknown as EncryptionClient

  const b = new EncryptedQueryBuilderV3Impl(
    'users', users, failing, supabase.client, USERS_ALL_COLUMNS,
  )
  const { error, status } = await b.select('id, email').eq('email', 'a@b.com')

  expect(status).toBe(500)
  expect(error?.message).toContain('Failed to encrypt query terms')
  expect(error?.message).toContain('boom')
})

it('threads lockContext and audit into filter-term encryption', async () => {
  const supabase = createMockSupabase()
  const audit = vi.fn()
  const withLockContext = vi.fn()
  const op: Record<string, unknown> = {
    withLockContext: (...a: unknown[]) => { withLockContext(...a); return op },
    audit: (...a: unknown[]) => { audit(...a); return op },
    then: (f?: ((v: unknown) => unknown) | null) =>
      Promise.resolve({ data: fakeEnvelope('a@b.com', 'email') }).then(f),
  }
  const client = { encrypt: () => op } as unknown as EncryptionClient
  const lc = {} as never

  await new EncryptedQueryBuilderV3Impl('users', users, client, supabase.client, USERS_ALL_COLUMNS)
    .withLockContext(lc)
    .audit({ metadata: { a: 1 } })
    .select('id, email')
    .eq('email', 'a@b.com')

  expect(withLockContext).toHaveBeenCalledWith(lc)
  expect(audit).toHaveBeenCalledWith({ metadata: { a: 1 } })
})

Expected: both pass. Today, silently dropping .withLockContext() on the v3 filter path — which would encrypt query terms under the wrong key — is caught by no test.

schemas: encryptionSchemas as unknown as Parameters<
typeof Encryption
>[0]['schemas'],
config: { ...options.config, eqlVersion: 3 },

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.

Gap: eqlVersion is forced to 3 here regardless of what the caller puts in options.config, and nothing asserts it. supabase-v3-factory.test.ts:117 inspects encryptionMock.mock.calls[0][0] only for schemas.length. A caller passing config: { eqlVersion: 2 } silently getting a v2 encryption client against v3 domains would fail at runtime with a 23514 CHECK violation — far from the cause.

it('forces eqlVersion 3 and passes the rest of config through', async () => {
  await encryptedSupabaseV3(fakeClient, {
    databaseUrl: 'postgres://x',
    config: { eqlVersion: 2, workspaceCrn: 'crn:test' } as never,
  })

  const arg = encryptionMock.mock.calls[0][0] as {
    config: Record<string, unknown>
  }
  expect(arg.config.eqlVersion).toBe(3)
  expect(arg.config.workspaceCrn).toBe('crn:test')
})

it('defaults config to { eqlVersion: 3 } when none is supplied', async () => {
  await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' })

  const arg = encryptionMock.mock.calls[0][0] as {
    config: Record<string, unknown>
  }
  expect(arg.config).toEqual({ eqlVersion: 3 })
})

Expected: both pass. Reorder the spread to { eqlVersion: 3, ...options.config } and the first fails — which is the whole point of the current ordering.

const actual = cols.get(dbName)
if (actual !== expected) {
throw new Error(
`[supabase v3]: column "${tableName}.${dbName}" has domain "${actual ?? '(none)'}" but the schema declares "${expected}"`,

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.

Gap: the actual ?? '(none)' fallback covers the most likely user error — declaring an encrypted column over a column that is plaintext in the DB (domainName: null) — and it is the one verifyDeclaredSchemas branch with no test. supabase-verify.test.ts:47 covers domain-A-vs-domain-B, and :40 covers the column-missing case, but never the null-domain arm.

Drop-in for supabase-verify.test.ts (the fixture already has id with domainName: null):

it('throws "(none)" when a declared column is plaintext in the database', () => {
  // `id` exists but has no domain — declaring it encrypted must fail at startup,
  // not as a 23514 CHECK violation on the first insert.
  const users = encryptedTable('users', { id: types.IntegerEq('id') })
  expect(() => verifyDeclaredSchemas({ users }, introspection)).toThrow(
    /users\.id.*\(none\).*integer_eq/,
  )
})

Expected: passes. Without it, an actual ?? '' refactor would produce has domain "", and no test would notice.


return {
data: decrypted.data as unknown as T[],
data: this.postprocessDecryptedRow(

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.

Gap: postprocessDecryptedRow is now called from two places — the single-row path (this line) and the array path (line 1137) — and only the array path is tested (supabase-v3-builder.test.ts:421). single() / maybeSingle() are exactly where a caller is most likely to read row.createdAt directly, so a missed Date reconstruction there surfaces as a string masquerading as a Date the row type guarantees.

The mock supabase's then returns resultData verbatim, so pass an object rather than an array:

it('reconstructs Date on the single() path', async () => {
  const { es } = v3Instance({
    id: 1,
    createdAt: fakeEnvelope(new Date('2026-01-02T03:04:05.000Z'), 'created_at'),
  })

  const { data, error } = await es
    .from('users', users)
    .select('id, createdAt')
    .single()

  expect(error).toBeNull()
  const row = data as unknown as { createdAt: Date }
  expect(row.createdAt).toBeInstanceOf(Date)
  expect(row.createdAt.toISOString()).toBe('2026-01-02T03:04:05.000Z')
})

Expected: passes. Revert this line to the bare decrypted.data as unknown as T[] and it fails, while every existing test still goes green.

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

Human (me) and agent reviews complete. Some suggested test gaps and general feedback to be addressed.

Very excited about this one!

@coderdan

coderdan commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Code review — high effort

Reviewed against the stated base (feat/eql-v3-text-search-schema), not main. Ten findings; all confirmed except #9, which is plausible.


1. Encrypted like/ilike never matches a substring — and the documented fix doesn't exist

packages/stack/src/supabase/query-builder-v3.ts:251

The README example in this PR's description returns [] for every row, always:

await supabase.from("users").select("*").like("email", "%@example.com");

Two compounding causes:

  • % is never stripped. like() pushes the raw pattern (query-builder.ts:210) and encryptCollectedTerms encrypts it verbatim (query-builder-v3.ts:224). The ngram tokenizer (token_length: 3, downcase — match-defaults.ts:35) yields tokens like '%@e' that exist in no stored bloom.
  • include_original: true is hardcoded (match-defaults.ts:39), so the query bloom carries the whole pattern as an extra token, which is present in a stored bloom only when the pattern equals the stored plaintext exactly. @> containment requires query-bloom ⊆ stored-bloom, so it fails.

The load-bearing part: the class doc at query-builder-v3.ts:68-71 and the PR description both say the remediation is a declared column with include_original: false.

include_original: false on text_search. A substring like against an undeclared text_search column will not match…

That remediation is unreachable from code. types.TextSearch: (name: string) => new EncryptedTextSearchColumn(name) (eql/v3/types.ts:168) takes no options; EncryptedTextSearchColumn (columns.ts:443) takes only a column name. Commit 8123839c ("remove obsolete v3 freeTextSearch tuner") deleted the only API that could set include_original, and the follow-up 98f657a0 states that declared and synthesized TextSearch columns now build byte-identically. Declaring the column changes nothing.

Why no test caught it: the mock test (supabase-v3-builder.test.ts:324) asserts only that the emitted operator is cs and that the operand JSON has a c key — it uses 'a@b' and never checks real matching. The live-PG matrix test (matrix-live-pg.test.ts:417-429) bypasses the Supabase .like() path entirely (it hand-encrypts via client.encrypt) and uses the substring 'ada' — exactly token_length = 3 chars, so its include_original token degenerately collides with its single ngram, masking the defect.

Suggest: drop the include_original bullet from the description, and either restore the tuner or make v3 like/ilike fail loudly rather than silently return nothing.


2. .order() was missed by the property→DB rename refactor

packages/stack/src/supabase/query-builder.ts:726

Every filter path in this diff was routed through filterColumnNameeq/neq/gt/gte/lt/lte/in/is/not/match/raw/or. The transforms loop still passes the raw name:

case 'order':
  query = query.order(t.column, t.options)   // ← not this.filterColumnName(t.column)

With createdAt: types.TimestampOrd('created_at'), .order('createdAt') emits order=createdAt → PostgREST 400. Synthesized tables mask this (property == DB name), so it fires only for the declared-rename feature this PR introduces.


3. .order() on an encrypted ord column sorts by ciphertext

packages/stack/src/supabase/query-builder.ts:726

Independent of the rename bug, and silent. The *_ord domains are plain jsonb (cipherstash-encrypt-v3-supabase.sql:3593), and the v3 bundle exposes ordering only through eql_v3.ord_term(col). There is no CREATE OPERATOR CLASS … USING btree anywhere in either v3 bundle — the bundle actively raises domain_opclass when one is added:

Opclasses on domains bypass operator resolution… Use a functional index on the extractor (e.g. ord_term(col)) instead.

So ORDER BY salary falls back to jsonb's default jsonb_cmp and sorts by envelope byte order. Correct ordering needs ORDER BY eql_v3.ord_term(col), which PostgREST cannot express and which no Proxy rewrites in the direct-Supabase path. No test orders an encrypted v3 column.


4. .or() operands are quoted but not escaped

packages/stack/src/supabase/helpers.ts:301

formatOrValue wraps a value in double quotes when it contains POSTGREST_RESERVED = /[,().]/, but never escapes the " already inside it. The v3 operand is JSON.stringify(envelope) (query-builder-v3.ts:245), which is dense with quotes:

email.eq."{"v":1,"i":{…},"c":"…"}"
        ↑ PostgREST terminates the value here

Pre-existing in shared code (v2's composite literal also carries quotes), but v3 adds a second, quote-dense consumer. No test covers .or() on an encrypted column in either dialect. Fix: escape backslashes, then double quotes, before wrapping.


5. An in list inside .or() is encrypted as a single ciphertext

packages/stack/src/supabase/query-builder.ts:578 (and the structured branch at :598)

parseOrValue turns (ada,grace) into the array ['ada','grace'] (helpers.ts:265). The or-path pushes one ScalarQueryTerm whose value is that array — it has no cond.op === 'in' special case, unlike the regular filter path at query-builder.ts:516 which encrypts each element separately. JsPlaintext admits arrays, so nothing rejects it. After substitution the value is a single string, so the (a,b) list form is lost:

nickname.in."<one-ciphertext-of-the-whole-array>"

Never matches either value. Silent. Pre-existing in v2, but this diff reworks the block (hasEncryptedencryptedIndexes, transformOrConditions).


6. select('*') collapses two columns when a property name collides with another column's DB name

packages/stack/src/supabase/helpers.ts:135 (root cause at query-builder-v3.ts:171)

Given an encrypted created_at declared as property createdAt, plus a distinct plaintext DB column literally named createdAt: expandAllColumns maps created_at → createdAt via dbToProp and passes the plaintext createdAt through unchanged, so both physical columns collapse to one token. addJsonbCastsV3 then emits createdAt:created_at::jsonb twice. PostgREST returns the encrypted column under key createdAt; the real plaintext column is never selected.

No guard catches it — EncryptedTable.build()'s duplicate check only fires when two builders share a getName(), and mergeDeclaredTables dedups only by DB name. Reordering the lookup in addJsonbCastsV3 would not help; the ambiguity is created in expandAllColumns.


7. A __proto__ column silently bypasses encryption

packages/stack/src/supabase/schema-builder.ts:35 (and :72)

const builders: Record<string, AnyEncryptedV3Column> = {}   // plain {}

builders[col.columnName] = factory(col.columnName)           // col.columnName is DB-controlled

For a column named __proto__ (legal via quoted identifier), the assignment invokes the inherited Object.prototype.__proto__ setter and re-parents builders instead of creating an own key — confirmed under node: own key false, Object.entries empty. EncryptedTable.build() and buildColumnKeyMap() both iterate own enumerable keys (table.ts:29,72), so the column never enters the encrypt config.

assertModelledDomains does not catch it first: it keys on the domain name, and factoryForDomain('text_search') is truthy → continue. Meanwhile allColumns still contains __proto__, so select('*') selects it. On write the value bypasses encryption → CHECK violation 23514; on read decryptModel skips it → raw ciphertext returned to the caller. That is exactly the silent-leak class assertModelledDomains exists to prevent.

This is the one place the design forgets Object.create(null)DOMAIN_REGISTRY (domain-registry.ts:22), buildColumnKeyMap (table.ts), and dbToProp (query-builder-v3.ts:114) each have it, each with a comment calling it load-bearing. Both synthesizeTables and mergeDeclaredTables use the raw new EncryptedTable(...) constructor specifically to bypass the reserved-key guard. Exotic trigger, two-line fix.


8. order is the only typed-builder method not capability-narrowed

packages/stack/src/supabase/types.ts:413

order<K extends StringKeyOf<T>> accepts any row key, while eq<K extends FK> (:381) and the rest narrow via V3FilterableKeys. So .eq('active', true) on a public.boolean storage-only column is a compile error, but .order('active') compiles and runs — order only pushes a transform, never a term, so encryptCollectedTerms's capability check never runs. supabase-v3.test-d.ts asserts nothing about order.

Note the fully correct guard narrows order to orderAndRange-capable columns, not merely to FK — an equality-only text_eq column is in FK yet still cannot be meaningfully ordered. And even then, finding #3 remains.


9. Duplicate declared rename throws from the wrong layer (plausible)

packages/stack/src/supabase/schema-builder.ts:80

schemas: { users: encryptedTable('users', { a: types.TextEq('col'), b: types.TextEq('col') }) }

verifyDeclaredSchemas passes — both columns verify against the real DB column col. In mergeDeclaredTables, prop a deletes the synthesized col and sets merged.a; prop b then finds col is no longer an own key, so no delete fires, leaving merged = {a: col, b: col}. That reaches Encryption()buildEncryptConfigEncryptedTable.build(), which throws duplicate column name "col" from the eql/v3 layer, naming neither the schemas entry nor the two colliding properties — instead of failing alongside the other declaration checks in encryptedSupabaseV3.


10. DOMAIN_REGISTRY is derivable from types

packages/stack/src/eql/v3/domain-registry.ts:22

All 40 entries are hand-copied from the types namespace, keyed by a value each factory already carries: stripDomainSchema(factory('x').getEqlType()). The new test in eql-v3-domain-registry.test.ts all but proves the map equals Object.values(types) re-keyed. When a new EQL domain lands in types but not here, factoryForDomain returns undefined and assertModelledDomains throws at construction for every consumer whose database has that column — including consumers that never touch the table. Deriving the registry makes the two impossible to diverge.


Scope notes

  • Findings 4 and 5 are pre-existing in v2, not introduced here — but the diff reworks both blocks, and v3 makes feat(ci): release jseql v2 #4 substantially easier to hit.
  • I did not investigate the eql_v3_internal grants question raised in the description. That still needs the live-PG run against a Supabase role, and it is independent of everything above.

🤖 Generated with Claude Code

`filterColumnName()` maps a v3 column's JS property name to its DB column
name. Filters, `match`, `not`, raw `filter`, and `select` route through it;
`order()` and the `onConflict` mutation option did not, and sent the property
name to PostgREST. `.order('createdAt')` on a table declaring
`createdAt: types.TimestampOrd('created_at')` therefore failed.

`order()` predates v3 and was correct until renames existed, so nothing
flagged it. Both now route through the seam, which is the identity in v2.

Separately, `order()`'s column was typed `StringKeyOf<T>` while every filter
method narrows to `FK`, so `.order('active')` on a storage-only
`types.Boolean` column compiled and sorted by raw ciphertext — wrong row
order, no error. Narrowed to `FK` (a no-op for v2, where `FK` defaults to
`StringKeyOf<T>`) and backed by a new `validateTransforms()` seam, which the
v3 dialect overrides to reject a column with no `orderAndRange` capability.
It is called inside `execute()`'s try, so it surfaces as a `status: 500`
result like the existing filter-path capability guard — and it is the only
protection the untyped (no-`schemas`) surface has.

`v3Columns` becomes null-prototype: `validateTransforms` indexes it without an
own-key guard, so an inherited `constructor`/`toString` would otherwise
resolve truthy for a plaintext column of that name.
tobyhede added 2 commits July 9, 2026 17:38
…ndary

The `order()`/`onConflict` bugs fixed in the previous commit were one design
property expressed twice: property->DB translation was applied opportunistically
at each call site, and `filterColumnName()` was a convention an author had to
remember. A property name and a DB name are both `string`, so the compiler was
indifferent to which reached PostgREST, and any new column-carrying method
started out broken by default.

Two changes make the bug class unrepresentable.

Brand the seam. `DbName`, `DbSelect`, `DbFilterString`, and `DbConflictList`
now type `SupabaseQueryBuilder`, and `filterColumnName()` is the only place a
`DbName` is minted. Reverting the one-line `order()` fix now yields a compile
error rather than a wrong query. The brands are erased at runtime and internal
(never re-exported); a real `SupabaseClient` stays assignable to
`SupabaseClientLike` via method-parameter bivariance, so this is not an API
break.

Translate once. `toDbSpace()` maps the whole recorded query into a branded
DB-space IR in a single total pass, between `buildSelectString()` and
`encryptFilterValues()`. `applyFilters` and `buildAndExecuteQuery` now consume
only that IR and perform no translation; adding a column-carrying method means
extending one switch the compiler forces you to complete. This is safe before
encryption because `getColumnMap()`/`encryptedColumnNames` are keyed by both
property and DB name in v3 (and property == DB name in v2), so column lookup
resolves identically either side of the translation.

It also removes the one live fragility here: `parseOrString` was called twice
on the same string — once to collect terms, once to apply them — and the two
parses had to agree on condition indices for encrypted values to land on the
right condition. It is now parsed once. The v3 `transformOrConditions` override
sheds its name-mapping half and keeps only the like/ilike -> cs rewrite, which
genuinely belongs at apply time because it depends on `wasEncrypted`.

`validateTransforms()` deliberately stays in `buildAndExecuteQuery` rather than
folding into `toDbSpace`: it runs after `encryptFilterValues`, so a filter
capability error wins over an order capability error, and moving it earlier
would silently flip that precedence. A test pins the ordering.

Nine characterization tests for `match`, `not`, `in`, `is`, raw `filter`, a
mixed encrypted/plaintext `or()`, and a combined query touching all six filter
arrays were written and landed green against the pre-refactor code; this commit
leaves them unedited. `match` is the one seam a brand cannot guard, since it
cannot ride on an object key -- hence its test.
`encryptedSupabaseV3` introspects over a direct Postgres connection, so a
project without the optional `pg` peer installed hit a bare module-resolution
error at construction. `loadPg` remaps it to a message naming the install and
the Worker/browser constraint, preserving the original as `cause`.

Guards on `err.code` rather than message text: CJS throws `MODULE_NOT_FOUND`,
ESM throws `ERR_MODULE_NOT_FOUND`. Any other failure propagates untouched, so
a genuine `pg` load error is not swallowed.

`importPg` is injectable because `vi.mock` cannot reproduce a module that
fails to resolve — it replaces the factory rejection with its own error,
discarding the `code` the branch depends on.
tobyhede added 14 commits July 9, 2026 17:44
`synthesizeTables` and `mergeDeclaredTables` key their column-builder records
by DB column name, which is attacker- or migration-controlled. On a normal
object literal a column literally named `__proto__` reparents the object
instead of landing as an own key, silently dropping the column — it would
then be treated as a plaintext passthrough and its ciphertext returned
undecrypted.
…tion design spec

The `encryptedSupabaseV3` jsdoc said columns are detected "by their Postgres
domain" without showing one. Add the CREATE TABLE it corresponds to, including
a plaintext column, so the passthrough behaviour is visible alongside the
encrypted ones.
…o__ column

Adds the test the previous commit's hardening was missing — and it caught that
the hardening was incomplete.

`synthesizeTables`/`mergeDeclaredTables` now keep a DB column literally named
`__proto__` as an own key, but `EncryptedTable.build()` re-keys the columns by
DB name into a plain object literal, so `builtColumns['__proto__'] = schema`
reparented the object instead of adding an own key. The column never reached
the encrypt config, `decryptModel` skips columns absent from the config, and
its ciphertext would be returned undecrypted — exactly the leak the hardening
was meant to prevent, one layer further down. `build()` now uses a null
prototype, matching `buildColumnKeyMap()` directly above it.

Two tests: one pins the builder-key hardening (passed before this change), one
pins the encrypt-config registration (failed before it).

`schema-v3.test.ts`'s `build()` assertion spreads `columns` to a plain object
before `toStrictEqual`, which compares prototypes. The contract under test is
the column configs; the prototype is now pinned directly by the new tests.
…e adapter

`is` is a SQL predicate — PostgREST accepts only null/true/false after it — and
a null operand is SQL NULL, not a value to search for. A null plaintext is
stored as a NULL column rather than ciphertext, so it is found with an
unencrypted `IS NULL`; encrypting the operand could never match.

Only the direct `.is()` filter skipped encryption, via an empty branch in the
regular-filter collector. Every other collector encrypted whatever it was
handed:

  not('age','is',null)      -> age.not.is.("null")
  filter('age','is',null)   -> age.is.("null")
  or('age.is.null')         -> age.is."("null")"
  match({email: null})      -> email=("null")
  eq('email', null)         -> email=("null")
  in('email',['a',null])    -> email=in.(("a"),("null"))

All of these are operands PostgREST rejects, so nothing can depend on the
current output. This is wrong in v2 today, not only v3; it surfaced when a v2
regression test written for the toDbSpace refactor failed against unmodified
code.

A single `isEncryptableTerm(operator, value)` predicate now guards all six
collectors. The operator must be checked independently of the value, because
`is(col, false)` carries a non-null operand. The apply side needs no change: it
already falls back to the original value when the term map lacks an index, and
`wasEncrypted` is correctly false for a skipped term, so `notFilterOperator`
leaves `is` alone.

On v3 this also removes a spurious capability error: `is` maps to the `equality`
query type, so an `is` term reached the column-capability guard and
`or('active.is.null')` on a storage-only `public.boolean` threw "does not
support equality queries" instead of querying.

One consequence needed fixing with it. The `or()` raw-string applier chose
rebuild-vs-verbatim on "was any value encrypted". An `is` on an encrypted column
now encrypts nothing, which would send it down the verbatim path and forward the
caller's JS property name to a database that only knows the DB name. It now
rebuilds whenever a condition REFERENCES an encrypted column. The comment
asserting the old invariant, added by the toDbSpace refactor, is corrected.
…, encrypt failure/threading

Characterization suite for the v3 supabase adapter's untested seams, from
review feedback on #588. Each assertion was verified to fail under mutation of
the code it covers:

- addJsonbCastsV3 had no direct test; its six branches and the Object.hasOwn
  guard in lookupDbName were only observed indirectly via two select strings.
- postprocessDecryptedRow is reached from the array AND single-row paths; only
  the array path was exercised.
- encryptCollectedTerms reimplements the failure / lockContext / audit
  threading rather than inheriting v2's. The existing 500-status tests all
  reach 500 via the capability guard, so they pass even if that block is
  deleted wholesale.
- eqlVersion is forced to 3 over options.config; nothing asserted it.
- verify.ts's 'actual ?? (none)' arm covers declaring an encrypted column over
  a plaintext DB column, and had no test.
…r queries

assertModelledDomains scanned every base table in `public` at construction, so
a single EQL v3 column this SDK cannot model — e.g. `audit_log.payload
public.json`, a shipped domain with no types factory — made
`encryptedSupabaseV3()` throw for a caller that only ever touches `users`. The
remedy it printed was unavailable: no package version models public.json, and
the table may belong to another team.

The guard is necessary: an unmodelled column never enters the encrypt config
but stays in `allColumns`, so `select('*')` selects it and `decryptModel` skips
it, returning raw ciphertext typed as data. Only its scope was wrong.

- Invert the catalog query. UNMODELLED_COLUMNS_QUERY takes the registry as $1
  and returns only offending columns, deleting EQL_DOMAINS_QUERY, the
  `eqlDomains` plumbing and assertModelledDomains. The registry IS the query
  parameter, so the two cannot drift.
- Index offenders by table; assert at from(), where the caller names one.
  Declared `schemas` are still validated eagerly, preserving 'fails at
  construction' where the caller asked for compile-time types.

The from() guard is unconditional: synthesizeTables silently drops unmodelled
columns, so it is the only thing between a caller and the leak. A live-PG test
pins that precondition, and both SQL predicates were mutation-verified against
a real catalog.
…ands

formatOrValue wrapped a value in double quotes when it contained one of
`,().` but never escaped the quotes already inside it. Every v3 encrypted
operand is JSON.stringify(envelope) — dense with quotes — so PostgREST
terminated the value at the first inner quote and the filter silently matched
nothing. Pre-existing in v2, whose composite literal also carries quotes; v3
makes it fire on every encrypted .or().

The three functions are only correct as a set:
- formatOrValue escapes \ then " before wrapping, and now quotes a value
  containing " or \ even without a comma.
- parseOrValue unescapes symmetrically; without this a parse -> rebuild
  round-trip doubles every backslash.
- splitOrString no longer toggles inQuotes on an escaped ", which would
  otherwise end the token and split the operand at its next comma.

formatOrValue also now formats array elements recursively, so a quoted element
inside an in-list is escaped rather than concatenated raw.
The regular filter path splits an `in` array and encrypts each element
(query-builder.ts:533). The .or() path had no such case: it pushed ONE term
whose value was the whole array. `JsPlaintext` admits arrays, so nothing
rejected it; after substitution the value was a single string, the `(a,b)`
list form was lost, and the filter could never match. Silent, fails closed,
pre-existing in v2.

- Collapse the string/structured or-term loops, which differed only in their
  `source` tag, so the in-split lands once for both.
- Widen the or TermMapping key with an optional `inIndex`, so a per-element
  value never collides with a whole-condition value.
- `substituteOrValue` rebuilds the array element-by-element, letting
  `formatOrValue` re-emit the `(a,b)` list form.
The hand-written supabase v3 wire tests exercised only 5 of the catalog's
39 domains; the whole numeric family (smallint/numeric/real/double/date/
bigint) never reached `EncryptedQueryBuilderV3Impl` at all, and nothing
guarded that number.

`supabase-v3-matrix.test.ts` reuses the compile-enforced `V3_MATRIX`, so
adding a domain to the SDK yields a Supabase wire assertion for free — or
fails to compile until it has a catalog row. Tiers are derived from
`indexes`, exactly as `drizzle-v3/operators-live-pg.test.ts` derives them,
rather than from `capabilities` (which is what the adapter's guard reads):
deriving from the other field turns a future capability/index divergence
into a visible tier mismatch instead of a test that agrees with itself.
The tier counts are asserted, so a domain silently dropping out of a tier
turns a test red rather than quietly shrinking an `it.each`.

The storage-only tier asserts the capability-guard MESSAGE, not merely the
500 status, and takes `samples[0]` explicitly: after fd33aad a null
operand skips encryption entirely and never reaches the guard, so a
status-only assertion on a future null sample would pass for the wrong
reason.

Also extracts the mock encryption/supabase clients into
`__tests__/helpers/supabase-mock.ts` so both suites drive the adapter
through the same doubles. 24784e2 committed a `supabase-v3-builder.test.ts`
that already imports this module without adding it; HEAD does not currently
resolve that import.

`fakeEnvelope` now normalizes `bigint` the way it already normalized `Date`:
the v3 filter path carries `pt` through `JSON.stringify`, which throws on a
BigInt. A real envelope only ever holds strings, so this is a mock artifact,
not a product bug — but the four `public.bigint*` domains' samples hit it.

Finally, the "35 domains" comments in the v3 matrix live suites were stale;
the four bigint domains brought the catalog to 39.
Under the shipped grants, EVERY encrypted filter the supabase v3 adapter
emits fails for `anon` and `authenticated` with

    permission denied for schema eql_v3_internal

`supabasePermissionsSql` granted exactly one schema, `eql_v3`. But the
public entry points the query path calls — `eql_v3.eq_term` (26 of 27
overloads), `ord_term` (38/38) and `match_term` (4/4) — are SECURITY
INVOKER and qualify `eql_v3_internal.*` by name in their bodies. Postgres
resolves those names with the CALLER's privileges and checks schema USAGE
at name resolution, so the call never reaches the function. `=` routes
through eq_term, `>=` through ord_term, `@>`/`cs` through match_term: there
is no encrypted operation that survives. The default PUBLIC EXECUTE on
functions means USAGE is the only real barrier; EXECUTE is granted anyway so
an install into a database that revoked it from PUBLIC still works.

`eql_v2` has no internal schema and is unaffected.

Fixes a second defect found on the way: `grantSupabasePermissions` rebuilt
the block from `supabasePermissionsSql(schemaNameFor(eqlVersion))` rather
than using the exported constant, so this fix would have reached the
generated Supabase migration and NOT the database `stash db install` writes
to. Both now go through `supabaseGrantsFor(eqlVersion)`, and the installer
test pins the executed string to the exported constant.

The grants SQL moves to an import-free `installer/grants.ts` so the live
proof in @cipherstash/stack can assert against the EXACT shipped strings;
`stash` already depends on @cipherstash/stack, so a dependency the other way
would be a cycle, and a local copy would drift.

`supabase-v3-grants-pg.test.ts` runs on the existing DATABASE_URL-only gate.
It needs no CipherStash credentials: `eql_v3_internal.ore_block_256('{}')`
fails on PERMISSION for a caller that cannot resolve the schema and on DATA
for one that can, and that distinction is the whole test. It revokes before
applying, because GRANTs persist and a reused database would otherwise let
the suite pass on leftover state without the SQL under test granting
anything — and it asserts the grants are load-bearing by stripping them and
watching anon break.
…ct the query-term doc

Two construction-time guards, and one factually wrong comment.

select('*') collision: a column renamed `created_at -> createdAt` alongside a
distinct plaintext column literally named `createdAt` makes expandAllColumns
emit the token `createdAt` twice, so addJsonbCastsV3 aliases both to
`createdAt:created_at::jsonb`. PostgREST returns the encrypted column under
that key and the plaintext one is never selected. EncryptedTable.build()'s
duplicate check only fires when two BUILDERS share a getName(), so nothing
caught it. Refuse to construct the builder.

Duplicate declared rename: two properties on the same DB column each verify
fine, then collide in mergeDeclaredTables and throw from EncryptedTable.build()
naming neither the properties nor the schemas entry. Detect it in verify.ts,
where both are known.

The class doc claimed a narrowed encryptQuery term 'fails the CHECK with 23514
for EVERY domain'. The opposite is true: the bundle defines 39 public.*_query
domains whose CHECK requires NOT (VALUE ? 'c'). The real reason full envelopes
are used is that PostgREST cannot cast a filter value, so those overloads are
unreachable and an uncast literal is ambiguous (42725) — and protect-ffi 0.28
cannot produce a v3 scalar query term at all. Also records that encrypted
like/ilike is known-broken for real substrings, with the same include_original
cause as v3 Drizzle's contains.
…anon

No supabase v3 code had ever reached a server. Every suite drove
`createMockSupabase`, an argument recorder with no SQL behind it: it can
prove the adapter EMITS `email.cs."{…}"`, never that PostgREST parses it,
that `cs` maps to `@>` on a domain column, that a full storage envelope
clears the `public.*` domain CHECK as a filter operand, or that `anon` has
the grants those operators need.

`supabase-v3-pgrest-live.test.ts` puts the real adapter, real
`@supabase/postgrest-js`, real PostgREST, real domains and the real
`eql_v3` operators in the path. Only ZeroKMS is stubbed: the domain CHECKs
are STRUCTURAL (`v`/`i`/`c` plus the domain's index terms, `v = '3'`), so
`helpers/v3-envelope.ts` builds valid envelopes directly and the suite runs
on the DATABASE_URL gate with no credentials. Cryptographic round-tripping
and ORE ordering stay `drizzle-v3/operators-live-pg`'s job — synthetic ORE
terms compare equal to themselves and carry no order semantics, so this
suite must never assert ordering.

It runs as `anon`, not as the owner: PostgREST connects as `authenticator`,
a non-superuser member of `anon`, so the Supabase grants are actually
exercised rather than bypassed by ambient superuser rights. Verified to have
teeth — emitting narrowed `encryptQuery` operands turns 7 tests red,
dropping the `like`→`cs` rewrite turns 3 red, and reverting the
`eql_v3_internal` grants turns 7 red with `permission denied for schema
eql_v3_internal` at the HTTP layer (401).

Two things this found that no unit test could:

`reloadSchemaCache` must poll the ROOT OpenAPI, not a GET against the new
table. PostgREST 12 self-heals a cache miss by scheduling a reload and
retrying that one request, so a probe GET starts succeeding while the reload
is still in flight — cold, the probe returned 200, the very next INSERT
returned 404, and a later GET returned 200 again. Root output is rendered
from the loaded cache, so a table appearing there means every verb sees it.

A pattern of exactly 3 characters is a degenerate `include_original` case:
its whole-value token IS one of the stored value's trigrams, so
`like('email','ada')` DOES match. The class doc's "a substring pattern only
matches when it equals the stored value" holds only for patterns longer than
the tokenizer window. Both behaviours are now pinned.

PostgREST starts as a CI STEP rather than a `services:` entry: service
containers start before checkout, and `authenticator` does not exist in the
postgres image until `local/postgrest-roles.sql` has run. `PGRST_URL` joins
the live-coverage guard so a missing service cannot silently skip the suite
while CI stays green.
Three branches the PR review flagged as untested, each verified to kill its
mutant before landing:

- `select('*')` guards on `allColumns === null || .length === 0`, but only the
  null arm had a caller. Pins the empty-array arm so a future `?? []` cannot
  turn an unusable `*` into a silent zero-column select.
- `mergeDeclaredTables`' `if (synthesized)` false arm. Unreachable through the
  factory (verifyDeclaredSchemas throws first) but the function is exported.
- `introspect()`'s happy path: both queries issued, the registry pushed as the
  query parameter, and `client.end()` called — on the success path and when a
  query throws. A leaked connection is invisible to every other assertion.

Two further proposed tests were dropped rather than landed: the
`addJsonbCastsV3` empty-token guard is a fast-path that falls through to the
same `return col`, so no test can kill it, and its leading-whitespace capture is
already pinned by an existing test.
…face

EQL v3 free-text search is token containment over a bloom filter, not SQL
wildcard matching: `%` is tokenized like any other character, so a `like`
pattern is a category error. The v3 Drizzle integration already omits
`like`/`ilike` in favour of `contains`; the supabase adapter now matches.

This is a surface rename, not a re-encoding. The bundle declares

    CREATE OPERATOR @> (FUNCTION = eql_v3.contains,
                        LEFTARG = public.text_search, RIGHTARG = jsonb)

so PostgREST's `cs` already resolved through `@>` into `eql_v3.contains` →
`match_term(a) @> match_term(b)` → smallint[] bloom containment. It was never
the built-in `jsonb @> jsonb`. The emitted operator and the full-envelope
operand are unchanged; no ciphertext moves. The live PostgREST suite proves the
resolution: a 3-character needle matches while a 7-character one does not, and
neither shares the stored `c`.

`contains` is narrowed at compile time to freeTextSearch-capable domains
(text_match, text_search). The shared builder interface is now parameterised by
its own return type, so `like` is absent at every chain depth rather than only
on the first call; the runtime still throws for untyped JS callers, and passes
`like` through on a genuine plaintext column.

Three silent-failure bugs fixed alongside.

order() on ANY encrypted v3 column now throws, including the ORE-capable ones —
the non-obvious half. The `*_ord` domains are `DOMAIN … AS jsonb` and the bundle
declares no btree operator class on any domain (it lints against one), so
`ORDER BY col` resolved through jsonb's default `jsonb_cmp` and sorted by the
envelope's byte structure, first key `bf`. Stable, plausible-looking, and
meaningless, with no error. Confirmed against the live database: integer_ord has
zero operator classes and a jsonb base type. Correct ordering is
`ORDER BY eql_v3.ord_term(col)`, which PostgREST's `order=` cannot express. The
old guard rejected exactly the columns where the wrongness was obvious and
admitted the ones where it was silent. gte/lte remain correct: the comparison
operators ARE declared on the ord domains; only sorting resolves through the
missing operator class.

or() now understands PostgREST's `column.not.<op>.<value>` negation. The parser
split on the first two dots, read `not` as the operator, and so
`or('nickname.not.in.(ada,grace)')` on an encrypted column encrypted the literal
string `in.(ada,grace)` as one plaintext — a filter that silently matched
nothing. A malformed `col.not.<value>` is passed through for PostgREST to
reject rather than dropped, which would quietly widen the result set.

Raw filter() derives its query type from the operator on v3 instead of always
encrypting an equality term, so `.filter('bio','cs',…)` on a text_match column
(equality: false, freeTextSearch: true) answers the query it was given rather
than being rejected. Unknown operators throw. The v2 base still returns
'equality' verbatim: there `queryType` selects the encryptQuery narrowing, so
correcting it moves v2 ciphertext. Tracked separately.

Every new test was mutation-checked against the implementation it protects.

The include_original substring defect is untouched and still pinned by a test
asserting the broken behaviour: the operand is a storage envelope whose bloom
carries the whole needle as an extra token, so a substring only matches when it
equals the stored value or is exactly the tokenizer's 3-character window. Shared
with v3 Drizzle's contains and tracked upstream in EQL.

BREAKING CHANGE: `like`/`ilike` are removed from the EQL v3 supabase builder —
use `contains`. `order()` on an encrypted v3 column now throws; order by a
plaintext column, expose `eql_v3.ord_term()` as a generated column or view, or
use the EQL v3 Drizzle integration. v2 (`encryptedSupabase`) is unchanged.
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.

3 participants