Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5d23e80
feat(stack): encryptedSupabaseV3 — EQL v3 dialect of the Supabase ada…
tobyhede Jul 8, 2026
2079d74
feat(stack): add v3 DOMAIN_REGISTRY keyed by unqualified domain name
tobyhede Jul 9, 2026
cb93504
feat(stack): expand select('*') from an introspected column list
tobyhede Jul 9, 2026
482bdde
feat(stack): introspect public columns + EQL v3 domains (by comment) …
tobyhede Jul 9, 2026
bbe0674
feat(stack): synthesize/merge v3 tables + guard unmodelled EQL domains
tobyhede Jul 9, 2026
722ca60
feat(stack): verify declared v3 schemas against introspected domains
tobyhede Jul 9, 2026
6769ca2
feat(stack): introspecting encryptedSupabaseV3 factory + optional sch…
tobyhede Jul 9, 2026
2354af4
test(stack): live Postgres introspection + unmodelled-domain partition
tobyhede Jul 9, 2026
dcb3745
fix(stack): select('*') must alias renamed v3 columns back to their p…
tobyhede Jul 9, 2026
98f657a
fix(stack): adapt supabase merge test to the removed v3 freeTextSearc…
tobyhede Jul 9, 2026
f785339
fix(stack): resolve v3 column renames in order() and onConflict
tobyhede Jul 9, 2026
3048d88
refactor(stack): translate supabase column names once, at a phase bou…
tobyhede Jul 9, 2026
34622f1
fix(stack): actionable error when the optional `pg` peer is missing
tobyhede Jul 9, 2026
3fe3c0c
fix(stack): null-prototype the synthesized/merged v3 column maps
tobyhede Jul 9, 2026
5fdfd87
docs(stack): show the SQL a v3 column is detected from; add introspec…
tobyhede Jul 9, 2026
3ecece0
fix(stack): null-prototype the built encrypt config; cover the __prot…
tobyhede Jul 9, 2026
fd33aad
fix(stack): never encrypt `is` or null filter operands in the supabas…
tobyhede Jul 9, 2026
24784e2
test(stack): cover addJsonbCastsV3, single() dates, forced eqlVersion…
tobyhede Jul 9, 2026
d02717c
fix(stack): scope the unmodelled-domain guard to the tables the calle…
tobyhede Jul 9, 2026
50494d0
fix(stack): escape double quotes and backslashes in .or() filter oper…
tobyhede Jul 9, 2026
81d2eb2
fix(stack): encrypt each element of an in() list inside .or()
tobyhede Jul 9, 2026
9c20935
test(stack): drive all 39 v3 domains through the supabase adapter
tobyhede Jul 9, 2026
a54bd5b
fix(cli): grant eql_v3_internal to the supabase roles
tobyhede Jul 9, 2026
3d9f3d5
fix(stack): reject property/DB name collisions at construction; corre…
tobyhede Jul 9, 2026
1666655
test(stack): run the supabase v3 adapter against a real PostgREST as …
tobyhede Jul 9, 2026
cbf2da4
test(stack): close the audited v3 coverage gaps
tobyhede Jul 9, 2026
60fd960
feat(stack)!: replace like/ilike with contains on the v3 supabase sur…
tobyhede Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/eql-v3-supabase-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@cipherstash/stack': minor
---

Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter. It is
now a connect-time-async factory: `await encryptedSupabaseV3(url, key)` (or
`(client)`) introspects the database over `DATABASE_URL`, detects EQL v3 columns
by their Postgres domain (`information_schema.columns.domain_name`), and derives
each column's encryption config from its domain — callers no longer pass a
schema to `from()`. `select('*')` is supported (expanded from the introspected
column list, and aliased back to each declared column's JS property name so a
property→DB rename round-trips). A column using an EQL v3 domain this SDK version does not model
(e.g. `public.json`, `*_ord_ope`) throws at construction rather than silently
passing through. Supplying `schemas` remains optional and adds compile-time
types plus startup verification of the declared tables against the database.
Requires a Postgres connection for introspection (`pg` is a new optional peer),
so it cannot run in a Worker or the browser. v2 (`encryptedSupabase`) is
unchanged.
82 changes: 82 additions & 0 deletions packages/stack/__tests__/eql-v3-domain-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import fc from 'fast-check'
import { describe, expect, it } from 'vitest'
import { types } from '@/eql/v3'
import {
DOMAIN_REGISTRY,
factoryForDomain,
stripDomainSchema,
type V3ColumnFactory,
} from '@/eql/v3/domain-registry'

describe('DOMAIN_REGISTRY', () => {
it('strips the public. schema prefix', () => {
expect(stripDomainSchema('public.text_search')).toBe('text_search')
expect(stripDomainSchema('public.integer_ord')).toBe('integer_ord')
// idempotent for an already-unqualified name
expect(stripDomainSchema('boolean')).toBe('boolean')
})

it('has an entry for every types factory, keyed by the unqualified domain', () => {
const factories = Object.values(types) as V3ColumnFactory[]
for (const factory of factories) {
const eqlType = factory('probe').getEqlType()
const key = stripDomainSchema(eqlType)
expect(
DOMAIN_REGISTRY[key],
`missing registry entry for ${key}`,
).toBeDefined()
expect(DOMAIN_REGISTRY[key]('c').getEqlType()).toBe(eqlType)
}
})

it('has no registry entry that does not round-trip to its own key', () => {
for (const [key, factory] of Object.entries(DOMAIN_REGISTRY)) {
expect(stripDomainSchema(factory('c').getEqlType())).toBe(key)
}
})

it('has exactly as many entries as there are types factories', () => {
expect(Object.keys(DOMAIN_REGISTRY)).toHaveLength(Object.keys(types).length)
})

it('returns undefined for an unknown domain', () => {
expect(factoryForDomain('not_a_domain')).toBeUndefined()
expect(factoryForDomain('text_search')).toBe(DOMAIN_REGISTRY.text_search)
})

it('PROPERTY: round-trips for any registry key and rejects any non-key', () => {
const keys = Object.keys(DOMAIN_REGISTRY)
// Any known key builds a column whose stripped eqlType is that key.
fc.assert(
fc.property(fc.constantFrom(...keys), (key) => {
expect(stripDomainSchema(DOMAIN_REGISTRY[key]('c').getEqlType())).toBe(
key,
)
}),
)
// Any arbitrary string that is not a registry key resolves to undefined.
const keySet = new Set(keys)
fc.assert(
fc.property(fc.string(), (s) => {
fc.pre(!keySet.has(s))
expect(factoryForDomain(s)).toBeUndefined()
}),
)
})
})

describe('prototype keys are not domains', () => {
it.each([
'constructor',
'toString',
'valueOf',
'hasOwnProperty',
'__proto__',
])('factoryForDomain(%s) is undefined', (key) => {
expect(factoryForDomain(key)).toBeUndefined()
})

it('the registry has a null prototype', () => {
expect(Object.getPrototypeOf(DOMAIN_REGISTRY)).toBeNull()
})
})
13 changes: 13 additions & 0 deletions packages/stack/__tests__/helpers/live-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,16 @@ export const LIVE_LOCK_CONTEXT_ENABLED = Boolean(
export const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip

export const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip

/**
* True when only a Postgres `DATABASE_URL` is configured (no CipherStash creds
* needed — introspection reads the schema, it does not encrypt).
*
* Like the flags above, a false value turns its suites into `describe.skip`,
* which in CI would be a silent whole-suite skip on a green job. That hole is
* closed by `../live-coverage-guard.test.ts`, which asserts THIS flag in CI.
* Any new gate flag added here must be asserted there too.
*/
export const LIVE_PG_ENABLED = Boolean(process.env.DATABASE_URL)

export const describeLivePgOnly = LIVE_PG_ENABLED ? describe : describe.skip
17 changes: 17 additions & 0 deletions packages/stack/__tests__/live-coverage-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
LIVE_CIPHERSTASH_ENABLED,
LIVE_EQL_V3_PG_ENABLED,
LIVE_LOCK_CONTEXT_ENABLED,
LIVE_PG_ENABLED,
} from './helpers/live-gate'

// GitHub Actions always sets CI=true; treat any truthy CI as "must run live".
Expand Down Expand Up @@ -110,6 +111,22 @@ describe('live-coverage guard', () => {
},
)

it.runIf(IN_CI)(
'CI must have DATABASE_URL so the pg-only suites do not silently skip',
() => {
expect(
LIVE_PG_ENABLED,
'CI must run the DB-only suites — `LIVE_PG_ENABLED` is false. This ' +
'needs only `DATABASE_URL` (no CS_* creds: introspection reads the ' +
'schema, it does not encrypt), and `.github/workflows/tests.yml` ' +
'writes it as a literal into packages/stack/.env alongside a Postgres ' +
'service — so a false value here is a broken workflow, never a valid ' +
'configuration. It makes every `describeLivePgOnly` suite (e.g. ' +
'supabase-v3-introspect-pg) silently skip while CI stays green.',
).toBe(true)
},
)

// Local dev with no creds: nothing to assert. Keep at least one always-run
// assertion so the file is never reported as fully empty/pending.
it('is always collected (guard file runs outside every live gate)', () => {
Expand Down
101 changes: 101 additions & 0 deletions packages/stack/__tests__/supabase-introspect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import fc from 'fast-check'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { groupIntrospectionRows } from '@/supabase/introspect'

describe('groupIntrospectionRows', () => {
it('groups rows by table, preserving row order as column order', () => {
const result = groupIntrospectionRows([
{ table_name: 'users', column_name: 'id', domain_name: null },
{ table_name: 'users', column_name: 'email', domain_name: 'text_search' },
{ table_name: 'users', column_name: 'note', domain_name: null },
{ table_name: 'orders', column_name: 'id', domain_name: null },
{
table_name: 'orders',
column_name: 'total',
domain_name: 'integer_ord',
},
])

expect(result).toEqual([
{
tableName: 'users',
columns: [
{ columnName: 'id', domainName: null },
{ columnName: 'email', domainName: 'text_search' },
{ columnName: 'note', domainName: null },
],
},
{
tableName: 'orders',
columns: [
{ columnName: 'id', domainName: null },
{ columnName: 'total', domainName: 'integer_ord' },
],
},
])
})

it('returns an empty array for no rows', () => {
expect(groupIntrospectionRows([])).toEqual([])
})

it('PROPERTY: preserves total column count, first-seen table order, and domains', () => {
const rowArb = fc.record({
table_name: fc.constantFrom('a', 'b', 'c'),
column_name: fc.string(),
domain_name: fc.option(fc.string(), { nil: null }),
})
fc.assert(
fc.property(fc.array(rowArb), (rows) => {
const grouped = groupIntrospectionRows(rows)
// (1) Column count is preserved.
const total = grouped.reduce((n, t) => n + t.columns.length, 0)
expect(total).toBe(rows.length)
// (2) Table order is first-seen order of the input.
const firstSeen: string[] = []
for (const r of rows) {
if (!firstSeen.includes(r.table_name)) firstSeen.push(r.table_name)
}
expect(grouped.map((t) => t.tableName)).toEqual(firstSeen)
// (3) Per-table column names+domains match the input rows in order.
for (const t of grouped) {
const expected = rows
.filter((r) => r.table_name === t.tableName)
.map((r) => ({
columnName: r.column_name,
domainName: r.domain_name,
}))
expect(t.columns).toEqual(expected)
}
}),
)
})
})

describe('introspect connection error handling', () => {
afterEach(() => vi.resetModules())

it('surfaces the connect error, not a failing end()', async () => {
vi.doMock('pg', () => {
class Client {
connect() {
return Promise.reject(new Error('ECONNREFUSED'))
}
end() {
// A throwing end() must NOT replace the connect error.
return Promise.reject(new Error('end failed'))
}
query() {
return Promise.resolve({ rows: [] })
}
}
return { default: { Client } }
})

const { introspect } = await import('@/supabase/introspect')
await expect(introspect('postgres://unreachable')).rejects.toThrow(
'ECONNREFUSED',
)
vi.doUnmock('pg')
})
})
Loading
Loading