Skip to content

Commit d595e64

Browse files
committed
feat(stack): verify declared v3 schemas against introspected domains
1 parent 1cbd957 commit d595e64

2 files changed

Lines changed: 102 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { encryptedTable, types } from '@/eql/v3'
3+
import type { IntrospectionResult } from '@/supabase/introspect'
4+
import { verifyDeclaredSchemas } from '@/supabase/verify'
5+
6+
const introspection: IntrospectionResult = [
7+
{
8+
tableName: 'users',
9+
columns: [
10+
{ columnName: 'id', domainName: null },
11+
{ columnName: 'email', domainName: 'text_search' },
12+
{ columnName: 'amount', domainName: 'integer_ord' },
13+
],
14+
},
15+
]
16+
17+
describe('verifyDeclaredSchemas', () => {
18+
it('passes when every declared column matches its introspected domain', () => {
19+
const users = encryptedTable('users', {
20+
email: types.TextSearch('email'),
21+
amount: types.IntegerOrd('amount'),
22+
})
23+
expect(() => verifyDeclaredSchemas({ users }, introspection)).not.toThrow()
24+
})
25+
26+
it('passes when only a subset of encrypted columns is declared', () => {
27+
const users = encryptedTable('users', { email: types.TextSearch('email') })
28+
expect(() => verifyDeclaredSchemas({ users }, introspection)).not.toThrow()
29+
})
30+
31+
it('throws naming the table when a declared table is absent', () => {
32+
const orders = encryptedTable('orders', {
33+
total: types.IntegerOrd('total'),
34+
})
35+
expect(() => verifyDeclaredSchemas({ orders }, introspection)).toThrow(
36+
/table "orders"/,
37+
)
38+
})
39+
40+
it('throws naming the column when a declared column is absent', () => {
41+
const users = encryptedTable('users', { missing: types.TextEq('missing') })
42+
expect(() => verifyDeclaredSchemas({ users }, introspection)).toThrow(
43+
/users\.missing/,
44+
)
45+
})
46+
47+
it('throws naming both domains when the domain differs', () => {
48+
// email is actually text_search; declaring text_eq must fail at startup.
49+
const users = encryptedTable('users', { email: types.TextEq('email') })
50+
expect(() => verifyDeclaredSchemas({ users }, introspection)).toThrow(
51+
/text_eq.*text_search|text_search.*text_eq/,
52+
)
53+
})
54+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { stripDomainSchema } from '@/eql/v3/domain-registry'
2+
import type { IntrospectionResult } from './introspect'
3+
import type { V3Schemas } from './schema-builder'
4+
5+
/**
6+
* Verify declared v3 tables against the introspected database. For every
7+
* declared column, assert the column exists and its introspected `domain_name`
8+
* matches the declared (unqualified) `eqlType`. Any mismatch throws at
9+
* construction — a wrong domain is caught here instead of as a 23514 CHECK
10+
* violation on the first query. Declaring a subset of a table's encrypted
11+
* columns is allowed; undeclared columns are synthesized from their domains.
12+
*/
13+
export function verifyDeclaredSchemas(
14+
schemas: V3Schemas,
15+
introspection: IntrospectionResult,
16+
): void {
17+
const index = new Map<string, Map<string, string | null>>()
18+
for (const table of introspection) {
19+
const cols = new Map<string, string | null>()
20+
for (const col of table.columns) cols.set(col.columnName, col.domainName)
21+
index.set(table.tableName, cols)
22+
}
23+
24+
for (const declared of Object.values(schemas)) {
25+
const tableName = declared.tableName
26+
const cols = index.get(tableName)
27+
if (!cols) {
28+
throw new Error(
29+
`[supabase v3]: declared table "${tableName}" was not found in the database`,
30+
)
31+
}
32+
for (const builder of Object.values(declared.columnBuilders)) {
33+
const dbName = builder.getName()
34+
if (!cols.has(dbName)) {
35+
throw new Error(
36+
`[supabase v3]: declared column "${tableName}.${dbName}" was not found in the database`,
37+
)
38+
}
39+
const expected = stripDomainSchema(builder.getEqlType())
40+
const actual = cols.get(dbName)
41+
if (actual !== expected) {
42+
throw new Error(
43+
`[supabase v3]: column "${tableName}.${dbName}" has domain "${actual ?? '(none)'}" but the schema declares "${expected}"`,
44+
)
45+
}
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)