feat(stack): encryptedSupabaseV3 — EQL v3 dialect of the Supabase adapter#588
feat(stack): encryptedSupabaseV3 — EQL v3 dialect of the Supabase adapter#588tobyhede wants to merge 27 commits into
Conversation
🦋 Changeset detectedLatest commit: 60fd960 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
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 |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
844e5c3 to
aadfbbd
Compare
…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.
adc3db8 to
98f657a
Compare
| /** | ||
| * 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') | ||
| * ``` | ||
| */ |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
encryptedSupabaseV3async factory that introspectsinformation_schema(via optionalpg) and constructs anEncryptionclient + v3 query builder. - Add v3-specific query-builder seams (select
*expansion, property↔DB column mapping, full-envelope filter operands,like/ilike→cs, 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.
| 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 } |
| 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
left a comment
There was a problem hiding this comment.
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:102 —
select('*')guards onallColumns === null || allColumns.length === 0, but only thenullarm is tested (supabase-v3-select-star.test.ts:110). Pass[]tobuilderForand assert the same throw, so a future?? []inindex.tscan't turn the guard into an empty-select. - helpers.ts:113-116 —
addJsonbCastsV3preserves per-token leading whitespace (col.match(/^(\s*)/)) and returnscolverbatim 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,
eqlDomainsbuilt fromdomain_namerows, andclient.end()actually called on the happy path — is exercised only by the live-pg suite. A mockedpgsuccess test assertingend()was called would catch a leaked connection without needingDATABASE_URL. - schema-builder.ts:47 —
mergeDeclaredTableshas an untestedif (synthesized)false arm (declared table absent from introspection). It's unreachable through the factory becauseverifyDeclaredSchemasthrows 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) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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]) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 }, |
There was a problem hiding this comment.
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}"`, |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Human (me) and agent reviews complete. Some suggested test gaps and general feedback to be addressed.
Very excited about this one!
Code review — high effortReviewed against the stated base ( 1. Encrypted
|
`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.
…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.
`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.
encryptedSupabaseV3— EQL v3 dialect of the Supabase adapter (@cipherstash/stack/supabase)The EQL v3 Supabase adapter.
encryptedSupabaseV3introspects the database at connect time, detects EQL v3 columns by their Postgres domain, and derives each column's encryption config from it — sofrom()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, notmain. (#583 has since merged into #535.)Usage
Zero config. No schema, no
encryptedTable— the database already knows which columns are encrypted.A column is an EQL v3 column when its type is one of the
publicdomains the EQL v3 bundle installs:Pass an existing Supabase client instead of a URL/key pair, and
databaseUrlexplicitly instead ofDATABASE_URL:The full
supabase-jsquery surface works:eq/neq/gt/gte/lt/lte/in/is,like/ilike,or,not,match,order,limit,range,single,maybeSingle, plusinsert/update/upsert/delete.select('*')and no-argselect()are expanded from the introspected column list.Optional
schemas: compile-time types + startup verificationDeclare 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.Filters and
order()are pinned to each column's plaintext type and capabilities:Declaring one table doesn't hide the others — undeclared tables fall through to the untyped surface, exactly as with no
schemasat all:Two behaviours require a declared column:
createdAt: types.TimestampOrd("created_at")makesselect('*')emitcreatedAt:created_at::jsonb, so the row comes back under the property name.include_original: falseontext_search. A substringlikeagainst an undeclaredtext_searchcolumn will not match: the synthesized defaultinclude_original: trueputs the whole pattern into the bloom filter as an extra token.Fails at construction, not on the first query
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
EncryptedQueryBuilderImplgains protected seams with v2-preserving defaults;EncryptedQueryBuilderV3Imploverrides them for raw jsonb mutations (v3 domains are plainjsonb, not v2's PG composite), full-envelope filter operands,like/ilike→cs,Datereconstruction, and capability validation. Introspection readsinformation_schema.columns.domain_name, restricted topublicdomains carrying the EQLCOMMENT ON DOMAINmarker.Requires a Postgres connection for introspection (
pgis a new optional peer dependency), soencryptedSupabaseV3cannot 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, andorder()and theonConflictoption 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/DbConflictListbrand theSupabaseQueryBuilderseam.filterColumnName()is the only place aDbNameis minted, so forgetting to translate is now a compile error, not a wrong query. The brands are erased at runtime, and a realSupabaseClientremains assignable toSupabaseClientLike(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/buildAndExecuteQuerydo no translation at all. Adding a column-carrying method now means extending oneswitchthe 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.isandnulloperands are never encrypted (also fixes v2)isis a SQL predicate — PostgREST accepts onlynull/true/falseafter it — and anulloperand is SQL NULL, never a value to search for. Only the direct.is()filter skipped encryption.not(),or(),match(), rawfilter(), and thein()element list all encrypted whatever they were handed: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
toDbSpacerefactor failed against unmodified code. A singleisEncryptableTerm(operator, value)predicate now guards all six term collectors. On v3 it additionally removes a spuriousdoes not support equality querieserror, raised becauseismaps to theequalityquery type and so hit the column-capability guard.Shipped as a separate
patchchangeset, 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:
That is what shipped here —
es.from('users'), no table argument. The original analysis assumed "loading from the schema" meant the stack schema (theencryptedTable(...)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, andschemasrecovers 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) grantsUSAGE/SELECT/EXECUTEon exactly one schema —eql_v3. But the v3 bundle also createseql_v3_internal, and public objects reach into it:eql_v3_internal.jsonb_blocked_existsbacks a jsonb operator, and theMIN/MAXaggregates usesfunc = eql_v3_internal.min_sfunc. Postgres checksEXECUTEon an operator's backing function at query time, soanon/authenticatedmay 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.tsis 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 narrowedencryptQueryterm, because eachpublic.*domain CHECK requires the storage keysv/i/c— a narrowed term fails the CHECK with 23514 on every domain. Drizzle's live suite cannot vouch for this:src/drizzle/operators.tsusesencryptQuery, 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_MATRIXcatalog (only 5 of 39 domains reach the adapter today), and the first live PostgREST suite — run asanon, 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 onDATABASE_URL.At the branch tip: 94 tests across the six Supabase suites, 79 type tests, no
tscerrors undersrc/. Theorder()regression is guarded by the compiler rather than a test — reverting the one-line fix yieldsTS2345: Argument of type 'string' is not assignable to parameter of type 'DbName'.