Skip to content

Commit 1cbd957

Browse files
committed
feat(stack): synthesize/merge v3 tables + guard unmodelled EQL domains
1 parent 014f285 commit 1cbd957

2 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { encryptedTable, types } from '@/eql/v3'
3+
import type { IntrospectionResult } from '@/supabase/introspect'
4+
import {
5+
assertModelledDomains,
6+
mergeDeclaredTables,
7+
synthesizeTables,
8+
} from '@/supabase/schema-builder'
9+
10+
const introspection: IntrospectionResult = [
11+
{
12+
tableName: 'users',
13+
columns: [
14+
{ columnName: 'id', domainName: null },
15+
{ columnName: 'email', domainName: 'text_search' },
16+
{ columnName: 'amount', domainName: 'integer_ord' },
17+
{ columnName: 'note', domainName: null },
18+
{ columnName: 'weird', domainName: 'not_a_domain' }, // unknown → plaintext
19+
],
20+
},
21+
]
22+
23+
describe('synthesizeTables', () => {
24+
it('builds an EncryptedTable with only the recognised domain columns', () => {
25+
const { tables } = synthesizeTables(introspection)
26+
const users = tables.get('users')
27+
expect(users).toBeDefined()
28+
expect(Object.keys(users!.columnBuilders).sort()).toEqual([
29+
'amount',
30+
'email',
31+
])
32+
})
33+
34+
it('records the FULL column list (encrypted + plaintext) for select(*)', () => {
35+
const { allColumns } = synthesizeTables(introspection)
36+
expect(allColumns.get('users')).toEqual([
37+
'id',
38+
'email',
39+
'amount',
40+
'note',
41+
'weird',
42+
])
43+
})
44+
45+
it('synthesizes a domain column byte-identically to a declared column', () => {
46+
const { tables } = synthesizeTables(introspection)
47+
const synthesized = tables.get('users')!.build()
48+
const declared = encryptedTable('users', {
49+
email: types.TextSearch('email'),
50+
amount: types.IntegerOrd('amount'),
51+
}).build()
52+
expect(synthesized.columns.email).toEqual(declared.columns.email)
53+
expect(synthesized.columns.amount).toEqual(declared.columns.amount)
54+
})
55+
56+
it('treats an Object.prototype-named domain as plaintext, not a domain', () => {
57+
// `DOMAIN_REGISTRY['constructor']` would be truthy on a plain object; the
58+
// null prototype + Object.hasOwn guard keep this column plaintext.
59+
const prototypeNamed: IntrospectionResult = [
60+
{
61+
tableName: 'weird',
62+
columns: [
63+
{ columnName: 'a', domainName: 'constructor' },
64+
{ columnName: 'b', domainName: '__proto__' },
65+
{ columnName: 'c', domainName: 'toString' },
66+
],
67+
},
68+
]
69+
const { tables, allColumns } = synthesizeTables(prototypeNamed)
70+
expect(Object.keys(tables.get('weird')!.columnBuilders)).toEqual([])
71+
expect(allColumns.get('weird')).toEqual(['a', 'b', 'c'])
72+
})
73+
})
74+
75+
describe('mergeDeclaredTables', () => {
76+
it('lets a declared TextSearch column keep its tuned match options', () => {
77+
const synth = synthesizeTables(introspection)
78+
const declaredTable = encryptedTable('users', {
79+
email: types
80+
.TextSearch('email')
81+
.freeTextSearch({ include_original: false }),
82+
})
83+
const merged = mergeDeclaredTables(synth, { users: declaredTable })
84+
const built = merged.tables.get('users')!.build()
85+
expect(built.columns.email.indexes.match?.include_original).toBe(false)
86+
expect(built.columns.amount).toBeDefined()
87+
expect(merged.allColumns.get('users')).toEqual(
88+
synth.allColumns.get('users'),
89+
)
90+
})
91+
92+
it('preserves a declared property→DB-name rename', () => {
93+
const renamed: IntrospectionResult = [
94+
{
95+
tableName: 'events',
96+
columns: [
97+
{ columnName: 'id', domainName: null },
98+
{ columnName: 'created_on', domainName: 'timestamp_ord' },
99+
],
100+
},
101+
]
102+
const synth = synthesizeTables(renamed)
103+
const declaredTable = encryptedTable('events', {
104+
createdAt: types.TimestampOrd('created_on'),
105+
})
106+
const merged = mergeDeclaredTables(synth, { events: declaredTable })
107+
const table = merged.tables.get('events')!
108+
expect(table.buildColumnKeyMap()).toEqual({ createdAt: 'created_on' })
109+
expect(Object.keys(table.columnBuilders)).toEqual(['createdAt'])
110+
})
111+
})
112+
113+
describe('assertModelledDomains', () => {
114+
it('passes when every EQL domain in use is modelled', () => {
115+
const eqlDomains = new Set(['text_search', 'integer_ord'])
116+
expect(() => assertModelledDomains(introspection, eqlDomains)).not.toThrow()
117+
})
118+
119+
it('throws naming the column + domain for a recognised-but-unmodelled domain', () => {
120+
const withOpe: IntrospectionResult = [
121+
{
122+
tableName: 'metrics',
123+
columns: [
124+
{ columnName: 'id', domainName: null },
125+
{ columnName: 'score', domainName: 'integer_ord_ope' },
126+
],
127+
},
128+
]
129+
// integer_ord_ope IS an EQL v3 domain, but has no types factory.
130+
const eqlDomains = new Set(['integer_ord_ope'])
131+
expect(() => assertModelledDomains(withOpe, eqlDomains)).toThrow(
132+
/metrics\.score.*integer_ord_ope|integer_ord_ope.*metrics\.score/,
133+
)
134+
})
135+
136+
it('does NOT throw for a user jsonb domain that is not an EQL domain', () => {
137+
const withUserDomain: IntrospectionResult = [
138+
{
139+
tableName: 'docs',
140+
columns: [{ columnName: 'body', domainName: 'my_json' }],
141+
},
142+
]
143+
// my_json is NOT in eqlDomains → plaintext passthrough, no throw.
144+
expect(() => assertModelledDomains(withUserDomain, new Set())).not.toThrow()
145+
})
146+
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { type AnyV3Table, EncryptedTable } from '@/eql/v3'
2+
import type { AnyEncryptedV3Column } from '@/eql/v3/columns'
3+
import { factoryForDomain } from '@/eql/v3/domain-registry'
4+
import type { IntrospectionResult } from './introspect'
5+
6+
/** A record of declared v3 tables, keyed by table name. */
7+
export type V3Schemas = Record<string, AnyV3Table>
8+
9+
export interface SynthesizedSchema {
10+
/** Every introspected table (even zero-encrypted ones), keyed by table name.
11+
* Values hold ONLY the encrypted columns; plaintext columns live in
12+
* `allColumns`. */
13+
tables: Map<string, AnyV3Table>
14+
/** Full column list per table (encrypted + plaintext), for select('*'). */
15+
allColumns: Map<string, string[]>
16+
}
17+
18+
/**
19+
* Build one `EncryptedTable` per introspected table from its domain columns.
20+
* A column whose `domainName` is NULL or absent from the registry is treated as
21+
* plaintext — retained in `allColumns` but not added to the table. Synthesized
22+
* columns are keyed by DB name (property == DB name).
23+
*
24+
* NOTE: this does NOT reject recognized-but-unmodelled EQL domains — call
25+
* {@link assertModelledDomains} first; here such a column would silently become
26+
* a plaintext passthrough.
27+
*/
28+
export function synthesizeTables(
29+
introspection: IntrospectionResult,
30+
): SynthesizedSchema {
31+
const tables = new Map<string, AnyV3Table>()
32+
const allColumns = new Map<string, string[]>()
33+
34+
for (const table of introspection) {
35+
const builders: Record<string, AnyEncryptedV3Column> = {}
36+
for (const col of table.columns) {
37+
if (col.domainName === null) continue
38+
const factory = factoryForDomain(col.domainName)
39+
if (!factory) continue // unknown / unmodelled → guarded elsewhere
40+
builders[col.columnName] = factory(col.columnName)
41+
}
42+
// Raw constructor (not `encryptedTable`) — no accessor copy or reserved-key
43+
// guard is needed, and it avoids throwing on an arbitrary DB column name
44+
// that happens to collide with a reserved table member.
45+
tables.set(table.tableName, new EncryptedTable(table.tableName, builders))
46+
allColumns.set(
47+
table.tableName,
48+
table.columns.map((c) => c.columnName),
49+
)
50+
}
51+
52+
return { tables, allColumns }
53+
}
54+
55+
/**
56+
* Replace synthesized tables with a merge of declared-over-synthesized columns.
57+
* For each declared column, drop the synthesized entry that resolves to the
58+
* same DB name and add the declared builder under its JS property name (so a
59+
* property→DB rename and any tuner options survive). Undeclared columns stay
60+
* synthesized. `allColumns` is unchanged (DB-name based, from introspection).
61+
*/
62+
export function mergeDeclaredTables(
63+
synth: SynthesizedSchema,
64+
schemas: V3Schemas,
65+
): SynthesizedSchema {
66+
const tables = new Map(synth.tables)
67+
68+
for (const declared of Object.values(schemas)) {
69+
const tableName = declared.tableName
70+
const synthesized = tables.get(tableName)
71+
72+
const merged: Record<string, AnyEncryptedV3Column> = {}
73+
if (synthesized) {
74+
for (const [prop, builder] of Object.entries(
75+
synthesized.columnBuilders,
76+
)) {
77+
merged[prop] = builder as AnyEncryptedV3Column
78+
}
79+
}
80+
for (const [prop, builder] of Object.entries(declared.columnBuilders)) {
81+
const dbName = builder.getName()
82+
if (dbName !== prop && Object.hasOwn(merged, dbName)) {
83+
delete merged[dbName]
84+
}
85+
merged[prop] = builder as AnyEncryptedV3Column
86+
}
87+
tables.set(tableName, new EncryptedTable(tableName, merged))
88+
}
89+
90+
return { tables, allColumns: synth.allColumns }
91+
}
92+
93+
/**
94+
* Throw if any introspected column uses an EQL v3 domain this SDK version does
95+
* not model. Such a column would otherwise become a plaintext passthrough:
96+
* inserts fail on the domain CHECK, but reads return raw ciphertext undecrypted
97+
* (`decryptModel` skips columns absent from the config) — a silent data leak.
98+
* A domain not in `eqlDomains` (a user's own jsonb domain) is fine — plaintext.
99+
*/
100+
export function assertModelledDomains(
101+
introspection: IntrospectionResult,
102+
eqlDomains: Set<string>,
103+
): void {
104+
for (const table of introspection) {
105+
for (const col of table.columns) {
106+
const domain = col.domainName
107+
if (domain === null) continue
108+
if (!eqlDomains.has(domain)) continue // not an EQL domain → plaintext
109+
if (factoryForDomain(domain)) continue // modelled → ok
110+
throw new Error(
111+
`[supabase v3]: column "${table.tableName}.${col.columnName}" uses EQL v3 domain "public.${domain}", 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).`,
112+
)
113+
}
114+
}
115+
}

0 commit comments

Comments
 (0)