Skip to content

Commit 099d18e

Browse files
committed
feat(stack): introspecting encryptedSupabaseV3 factory + optional schemas
1 parent d595e64 commit 099d18e

6 files changed

Lines changed: 500 additions & 122 deletions

File tree

.changeset/eql-v3-supabase-adapter.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22
'@cipherstash/stack': minor
33
---
44

5-
Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter, for
6-
tables authored with `@cipherstash/stack/eql/v3` (native `public.*` concrete
7-
domains, `eql_v3` operators). The v2 query mechanism (direct EQL operators over
8-
PostgREST) is unchanged: `EncryptedQueryBuilderImpl` gains narrow protected
9-
seams whose defaults preserve v2 byte-for-byte, and a v3 subclass overrides them
10-
for property↔DB-name resolution (`buildColumnKeyMap`, aliased `prop:db::jsonb`
11-
select casts), raw jsonb mutation payloads (no `eql_v2` composite wrap),
12-
full-envelope filter operands (every `public.*` domain CHECK needs the storage
13-
keys, so narrowed query terms are not usable), `like`/`ilike` → PostgREST `cs`
14-
(bloom `@>`), `Date` reconstruction from `cast_as`, and capability validation
15-
(filtering a storage-only column or with an unsupported query type throws a
16-
typed + runtime error). Filter keys are type-narrowed to exclude storage-only
17-
columns.
5+
Add `encryptedSupabaseV3` — the EQL v3 dialect of the Supabase adapter. It is
6+
now a connect-time-async factory: `await encryptedSupabaseV3(url, key)` (or
7+
`(client)`) introspects the database over `DATABASE_URL`, detects EQL v3 columns
8+
by their Postgres domain (`information_schema.columns.domain_name`), and derives
9+
each column's encryption config from its domain — callers no longer pass a
10+
schema to `from()`. `select('*')` is supported (expanded from the introspected
11+
column list). A column using an EQL v3 domain this SDK version does not model
12+
(e.g. `public.json`, `*_ord_ope`) throws at construction rather than silently
13+
passing through. Supplying `schemas` remains optional and adds compile-time
14+
types plus startup verification of the declared tables against the database.
15+
Requires a Postgres connection for introspection (`pg` is a new optional peer),
16+
so it cannot run in a Worker or the browser. v2 (`encryptedSupabase`) is
17+
unchanged.

packages/stack/__tests__/supabase-v3-builder.test.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
22
import type { EncryptionClient } from '@/encryption'
33
import { encryptedTable, types } from '@/eql/v3'
44
import { encryptedColumn, encryptedTable as encryptedTableV2 } from '@/schema'
5-
import { encryptedSupabase, encryptedSupabaseV3 } from '@/supabase'
5+
import { encryptedSupabase } from '@/supabase'
6+
import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3'
67

78
// ---------------------------------------------------------------------------
89
// Mocks
@@ -200,12 +201,37 @@ const usersV2 = encryptedTableV2('users', {
200201
age: encryptedColumn('age').dataType('number').equality().orderAndRange(),
201202
})
202203

204+
// DB column names as introspection would report them (id/note are plaintext).
205+
const USERS_ALL_COLUMNS = [
206+
'id',
207+
'email',
208+
'nickname',
209+
'amount',
210+
'created_at',
211+
'active',
212+
'note',
213+
]
214+
215+
// `encryptedSupabaseV3` is now an async, DB-introspecting factory, so the wire
216+
// tests construct the v3 builder directly. The declared `users` table is kept
217+
// (not a synthesized one) because the `createdAt:created_at::jsonb` assertions
218+
// are inherently about a property→DB rename that a synthesized table — where
219+
// property == DB name — cannot express. `supabase-schema-builder.test.ts`
220+
// proves synthesized ≡ declared byte-for-byte.
203221
function v3Instance(resultData: unknown = []) {
204222
const supabase = createMockSupabase(resultData)
205-
const es = encryptedSupabaseV3({
206-
encryptionClient: createMockEncryptionClient(),
207-
supabaseClient: supabase.client,
208-
})
223+
const encryptionClient = createMockEncryptionClient()
224+
const es = {
225+
from(tableName: string, table: typeof users) {
226+
return new EncryptedQueryBuilderV3Impl(
227+
tableName,
228+
table,
229+
encryptionClient,
230+
supabase.client,
231+
USERS_ALL_COLUMNS,
232+
)
233+
},
234+
}
209235
return { es, supabase }
210236
}
211237

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { encryptedTable, types } from '@/eql/v3'
3+
import type { SupabaseClientLike } from '@/supabase'
4+
import { encryptedSupabaseV3 } from '@/supabase'
5+
import type { IntrospectionData } from '@/supabase/introspect'
6+
import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3'
7+
8+
// --- Mocks -----------------------------------------------------------------
9+
//
10+
// `vi.mock` factories are hoisted above every import, so they cannot close over
11+
// a plain top-level `const` (it would still be in its TDZ when the factory runs
12+
// on first import). `vi.hoisted` lifts the spies alongside them.
13+
14+
const { introspectMock, encryptionMock, createClientMock } = vi.hoisted(() => ({
15+
introspectMock: vi.fn<(url: string) => Promise<unknown>>(),
16+
encryptionMock: vi.fn<(cfg: unknown) => Promise<unknown>>(),
17+
createClientMock: vi.fn(() => ({ from: () => ({}) })),
18+
}))
19+
20+
vi.mock('@/supabase/introspect', async (importActual) => {
21+
const actual = await importActual<typeof import('@/supabase/introspect')>()
22+
return { ...actual, introspect: (url: string) => introspectMock(url) }
23+
})
24+
25+
vi.mock('@/encryption', async (importActual) => {
26+
const actual = await importActual<typeof import('@/encryption')>()
27+
return { ...actual, Encryption: (cfg: unknown) => encryptionMock(cfg) }
28+
})
29+
30+
vi.mock('@supabase/supabase-js', () => ({ createClient: createClientMock }))
31+
32+
const fakeClient = { from: () => ({}) } as unknown as SupabaseClientLike
33+
34+
function introspectionOf(data: Partial<IntrospectionData>): IntrospectionData {
35+
return { tables: data.tables ?? [], eqlDomains: data.eqlDomains ?? new Set() }
36+
}
37+
38+
const usersIntrospection = introspectionOf({
39+
tables: [
40+
{
41+
tableName: 'users',
42+
columns: [
43+
{ columnName: 'id', domainName: null },
44+
{ columnName: 'email', domainName: 'text_search' },
45+
],
46+
},
47+
],
48+
eqlDomains: new Set(['text_search']),
49+
})
50+
51+
beforeEach(() => {
52+
introspectMock.mockReset().mockResolvedValue(usersIntrospection)
53+
encryptionMock.mockReset().mockResolvedValue({})
54+
createClientMock.mockClear()
55+
delete process.env.DATABASE_URL
56+
})
57+
afterEach(() => vi.restoreAllMocks())
58+
59+
describe('encryptedSupabaseV3 factory', () => {
60+
it('url+key overload builds a client and introspects the given databaseUrl', async () => {
61+
await encryptedSupabaseV3('http://sb', 'anon-key', {
62+
databaseUrl: 'postgres://x',
63+
})
64+
expect(createClientMock).toHaveBeenCalledWith('http://sb', 'anon-key')
65+
expect(introspectMock).toHaveBeenCalledWith('postgres://x')
66+
expect(encryptionMock).toHaveBeenCalledTimes(1)
67+
})
68+
69+
it('client overload uses the supplied client (no createClient)', async () => {
70+
await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' })
71+
expect(createClientMock).not.toHaveBeenCalled()
72+
})
73+
74+
it('falls back to process.env.DATABASE_URL', async () => {
75+
process.env.DATABASE_URL = 'postgres://env'
76+
await encryptedSupabaseV3(fakeClient)
77+
expect(introspectMock).toHaveBeenCalledWith('postgres://env')
78+
})
79+
80+
it('throws naming DATABASE_URL when no URL is available', async () => {
81+
await expect(encryptedSupabaseV3(fakeClient)).rejects.toThrow(
82+
/DATABASE_URL/,
83+
)
84+
expect(introspectMock).not.toHaveBeenCalled()
85+
})
86+
87+
it('verifies BEFORE building the encryption client (wrong domain)', async () => {
88+
// email is text_search in the DB; declaring text_eq must fail...
89+
const users = encryptedTable('users', { email: types.TextEq('email') })
90+
await expect(
91+
encryptedSupabaseV3(fakeClient, {
92+
databaseUrl: 'postgres://x',
93+
schemas: { users },
94+
}),
95+
).rejects.toThrow(/text_eq|text_search/)
96+
// ...and Encryption must never be reached.
97+
expect(encryptionMock).not.toHaveBeenCalled()
98+
})
99+
100+
it('passes only non-empty tables to Encryption', async () => {
101+
introspectMock.mockResolvedValue(
102+
introspectionOf({
103+
tables: [
104+
{
105+
tableName: 'users',
106+
columns: [{ columnName: 'email', domainName: 'text_search' }],
107+
},
108+
{
109+
tableName: 'logs',
110+
columns: [{ columnName: 'line', domainName: null }],
111+
},
112+
],
113+
eqlDomains: new Set(['text_search']),
114+
}),
115+
)
116+
await encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' })
117+
const arg = encryptionMock.mock.calls[0][0] as { schemas: unknown[] }
118+
expect(arg.schemas).toHaveLength(1)
119+
})
120+
121+
it('throws a diagnosis when no modelled EQL v3 columns exist anywhere', async () => {
122+
introspectMock.mockResolvedValue(
123+
introspectionOf({
124+
tables: [
125+
{
126+
tableName: 'logs',
127+
columns: [{ columnName: 'line', domainName: null }],
128+
},
129+
],
130+
}),
131+
)
132+
await expect(
133+
encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }),
134+
).rejects.toThrow(/no EQL v3 encrypted columns found/)
135+
expect(encryptionMock).not.toHaveBeenCalled()
136+
})
137+
138+
it('throws from from() on an unknown table', async () => {
139+
const supabase = await encryptedSupabaseV3(fakeClient, {
140+
databaseUrl: 'postgres://x',
141+
})
142+
expect(() => supabase.from('nope')).toThrow(/unknown table/)
143+
})
144+
145+
it('returns a v3 builder from from() on a known table', async () => {
146+
const supabase = await encryptedSupabaseV3(fakeClient, {
147+
databaseUrl: 'postgres://x',
148+
})
149+
expect(supabase.from('users')).toBeInstanceOf(EncryptedQueryBuilderV3Impl)
150+
})
151+
152+
it('throws when a schemas record key ≠ its table name', async () => {
153+
const mislabelled = encryptedTable('users', {
154+
email: types.TextSearch('email'),
155+
})
156+
await expect(
157+
encryptedSupabaseV3(fakeClient, {
158+
databaseUrl: 'postgres://x',
159+
schemas: { orders: mislabelled },
160+
}),
161+
).rejects.toThrow(/orders.*users|record key/)
162+
})
163+
164+
it('throws on a recognized-but-unmodelled EQL domain', async () => {
165+
introspectMock.mockResolvedValue(
166+
introspectionOf({
167+
tables: [
168+
{
169+
tableName: 'metrics',
170+
columns: [{ columnName: 'score', domainName: 'integer_ord_ope' }],
171+
},
172+
],
173+
eqlDomains: new Set(['integer_ord_ope']),
174+
}),
175+
)
176+
await expect(
177+
encryptedSupabaseV3(fakeClient, { databaseUrl: 'postgres://x' }),
178+
).rejects.toThrow(/integer_ord_ope/)
179+
expect(encryptionMock).not.toHaveBeenCalled()
180+
})
181+
})

0 commit comments

Comments
 (0)