Skip to content

Commit 014f285

Browse files
committed
feat(stack): introspect public columns + EQL v3 domains (by comment) over pg
1 parent 20d0c4f commit 014f285

5 files changed

Lines changed: 242 additions & 3 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import fc from 'fast-check'
2+
import { afterEach, describe, expect, it, vi } from 'vitest'
3+
import { groupIntrospectionRows } from '@/supabase/introspect'
4+
5+
describe('groupIntrospectionRows', () => {
6+
it('groups rows by table, preserving row order as column order', () => {
7+
const result = groupIntrospectionRows([
8+
{ table_name: 'users', column_name: 'id', domain_name: null },
9+
{ table_name: 'users', column_name: 'email', domain_name: 'text_search' },
10+
{ table_name: 'users', column_name: 'note', domain_name: null },
11+
{ table_name: 'orders', column_name: 'id', domain_name: null },
12+
{
13+
table_name: 'orders',
14+
column_name: 'total',
15+
domain_name: 'integer_ord',
16+
},
17+
])
18+
19+
expect(result).toEqual([
20+
{
21+
tableName: 'users',
22+
columns: [
23+
{ columnName: 'id', domainName: null },
24+
{ columnName: 'email', domainName: 'text_search' },
25+
{ columnName: 'note', domainName: null },
26+
],
27+
},
28+
{
29+
tableName: 'orders',
30+
columns: [
31+
{ columnName: 'id', domainName: null },
32+
{ columnName: 'total', domainName: 'integer_ord' },
33+
],
34+
},
35+
])
36+
})
37+
38+
it('returns an empty array for no rows', () => {
39+
expect(groupIntrospectionRows([])).toEqual([])
40+
})
41+
42+
it('PROPERTY: preserves total column count, first-seen table order, and domains', () => {
43+
const rowArb = fc.record({
44+
table_name: fc.constantFrom('a', 'b', 'c'),
45+
column_name: fc.string(),
46+
domain_name: fc.option(fc.string(), { nil: null }),
47+
})
48+
fc.assert(
49+
fc.property(fc.array(rowArb), (rows) => {
50+
const grouped = groupIntrospectionRows(rows)
51+
// (1) Column count is preserved.
52+
const total = grouped.reduce((n, t) => n + t.columns.length, 0)
53+
expect(total).toBe(rows.length)
54+
// (2) Table order is first-seen order of the input.
55+
const firstSeen: string[] = []
56+
for (const r of rows) {
57+
if (!firstSeen.includes(r.table_name)) firstSeen.push(r.table_name)
58+
}
59+
expect(grouped.map((t) => t.tableName)).toEqual(firstSeen)
60+
// (3) Per-table column names+domains match the input rows in order.
61+
for (const t of grouped) {
62+
const expected = rows
63+
.filter((r) => r.table_name === t.tableName)
64+
.map((r) => ({
65+
columnName: r.column_name,
66+
domainName: r.domain_name,
67+
}))
68+
expect(t.columns).toEqual(expected)
69+
}
70+
}),
71+
)
72+
})
73+
})
74+
75+
describe('introspect connection error handling', () => {
76+
afterEach(() => vi.resetModules())
77+
78+
it('surfaces the connect error, not a failing end()', async () => {
79+
vi.doMock('pg', () => {
80+
class Client {
81+
connect() {
82+
return Promise.reject(new Error('ECONNREFUSED'))
83+
}
84+
end() {
85+
// A throwing end() must NOT replace the connect error.
86+
return Promise.reject(new Error('end failed'))
87+
}
88+
query() {
89+
return Promise.resolve({ rows: [] })
90+
}
91+
}
92+
return { default: { Client } }
93+
})
94+
95+
const { introspect } = await import('@/supabase/introspect')
96+
await expect(introspect('postgres://unreachable')).rejects.toThrow(
97+
'ECONNREFUSED',
98+
)
99+
vi.doUnmock('pg')
100+
})
101+
})

packages/stack/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,15 @@
226226
"devDependencies": {
227227
"@clack/prompts": "^1.4.0",
228228
"@supabase/supabase-js": "^2.105.4",
229+
"@types/pg": "^8.20.0",
229230
"@types/uuid": "^11.0.0",
230231
"dotenv": "17.4.2",
231232
"drizzle-orm": "^0.45.2",
232233
"execa": "^9.5.2",
233234
"fast-check": "^4.8.0",
234235
"fta-cli": "3.0.0",
235236
"json-schema-to-typescript": "^15.0.2",
237+
"pg": "8.20.0",
236238
"postgres": "^3.4.8",
237239
"tsup": "catalog:repo",
238240
"tsx": "catalog:repo",
@@ -261,14 +263,18 @@
261263
},
262264
"peerDependencies": {
263265
"@supabase/supabase-js": ">=2",
264-
"drizzle-orm": ">=0.33"
266+
"drizzle-orm": ">=0.33",
267+
"pg": ">=8"
265268
},
266269
"peerDependenciesMeta": {
267270
"drizzle-orm": {
268271
"optional": true
269272
},
270273
"@supabase/supabase-js": {
271274
"optional": true
275+
},
276+
"pg": {
277+
"optional": true
272278
}
273279
},
274280
"engines": {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/** One introspected column: its DB name and its `public` EQL v3 domain (or `null`). */
2+
export interface IntrospectedColumn {
3+
columnName: string
4+
domainName: string | null
5+
}
6+
7+
/** One introspected base table with its columns in ordinal order. */
8+
export interface IntrospectedTable {
9+
tableName: string
10+
columns: IntrospectedColumn[]
11+
}
12+
13+
export type IntrospectionResult = IntrospectedTable[]
14+
15+
/**
16+
* Raw `information_schema` column row. `domain_name` is the column's domain when
17+
* that domain lives in `public`, else `NULL` (the query nulls out non-`public`
18+
* domains — see the CASE below), so a same-named domain in another schema is
19+
* never mistaken for an EQL v3 domain.
20+
*/
21+
export interface IntrospectionRow {
22+
table_name: string
23+
column_name: string
24+
domain_name: string | null
25+
}
26+
27+
/** Tables + the set of `public` domains recognised as EQL v3 (modelled or not). */
28+
export interface IntrospectionData {
29+
tables: IntrospectionResult
30+
eqlDomains: Set<string>
31+
}
32+
33+
/**
34+
* Group flat `information_schema` rows into tables. Row order is the query's
35+
* `ORDER BY table_name, ordinal_position`, so pushing in order preserves both
36+
* table grouping and per-table column order.
37+
*/
38+
export function groupIntrospectionRows(
39+
rows: IntrospectionRow[],
40+
): IntrospectionResult {
41+
const byTable = new Map<string, IntrospectedColumn[]>()
42+
const order: string[] = []
43+
for (const row of rows) {
44+
let cols = byTable.get(row.table_name)
45+
if (!cols) {
46+
cols = []
47+
byTable.set(row.table_name, cols)
48+
order.push(row.table_name)
49+
}
50+
cols.push({ columnName: row.column_name, domainName: row.domain_name })
51+
}
52+
return order.map((tableName) => ({
53+
tableName,
54+
columns: byTable.get(tableName) as IntrospectedColumn[],
55+
}))
56+
}
57+
58+
// DELIBERATE FORK of packages/cli/src/commands/init/lib/introspect.ts — keep the
59+
// two in sync. `stack` cannot depend on `cli`, and the projections differ: the
60+
// CLI detects v2 composites via `udt_name = 'eql_v2_encrypted'`; this reads v3
61+
// domains via `domain_name`. `udt_name` is `jsonb` for a v3 domain column, so it
62+
// cannot be reused here.
63+
const COLUMNS_QUERY = `
64+
SELECT
65+
c.table_name,
66+
c.column_name,
67+
CASE WHEN c.domain_schema = 'public' THEN c.domain_name ELSE NULL END
68+
AS domain_name
69+
FROM information_schema.columns c
70+
JOIN information_schema.tables t
71+
ON t.table_name = c.table_name AND t.table_schema = c.table_schema
72+
WHERE c.table_schema = 'public'
73+
AND t.table_type = 'BASE TABLE'
74+
ORDER BY c.table_name, c.ordinal_position
75+
`
76+
77+
// The authoritative EQL-domain signal is the domain's COMMENT: every EQL v3
78+
// domain in the bundle is `COMMENT ON DOMAIN public.<name> IS 'EQL…'` (89/89,
79+
// zero exceptions). The CHECK bodies are NOT usable — they are non-uniform
80+
// (`integer_ord` names no function; `json` calls a `public.eql_v3_*` function).
81+
// `obj_description(oid, 'pg_type')` reads that comment for a domain type.
82+
const EQL_DOMAINS_QUERY = `
83+
SELECT tp.typname AS domain_name
84+
FROM pg_type tp
85+
JOIN pg_namespace ns ON ns.oid = tp.typnamespace
86+
WHERE tp.typtype = 'd'
87+
AND ns.nspname = 'public'
88+
AND obj_description(tp.oid, 'pg_type') LIKE 'EQL%'
89+
`
90+
91+
/**
92+
* Connect over `databaseUrl`, read every base table in the `public` schema with
93+
* its EQL v3 domain (`domain_name`), and the set of `public` domains recognised
94+
* as EQL v3 (by their `COMMENT`). `pg` is loaded with a dynamic import so
95+
* bundlers do not pull it in unless introspection runs.
96+
*
97+
* `udt_name` is `jsonb` for a domain column, so ONLY `domain_name` distinguishes
98+
* an EQL v3 column from a plain `jsonb` column (whose `domain_name` is NULL).
99+
*/
100+
export async function introspect(
101+
databaseUrl: string,
102+
): Promise<IntrospectionData> {
103+
const { default: pg } = await import('pg')
104+
// Mirror the CLI introspector's bounded connect so an unreachable DB fails
105+
// fast rather than hanging construction.
106+
const client = new pg.Client({
107+
connectionString: databaseUrl,
108+
connectionTimeoutMillis: 10_000,
109+
})
110+
await client.connect()
111+
try {
112+
const [columns, domains] = await Promise.all([
113+
client.query<IntrospectionRow>(COLUMNS_QUERY),
114+
client.query<{ domain_name: string }>(EQL_DOMAINS_QUERY),
115+
])
116+
return {
117+
tables: groupIntrospectionRows(columns.rows),
118+
eqlDomains: new Set(domains.rows.map((r) => r.domain_name)),
119+
}
120+
} finally {
121+
// `end()` runs only after a successful connect; swallow its own failure so it
122+
// can never mask a query error (and, on the connect-failure path above,
123+
// `connect()` throws before this try/finally is entered at all).
124+
await client.end().catch(() => {})
125+
}
126+
}

packages/stack/tsup.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default defineConfig([
2929
clean: false,
3030
target: 'es2022',
3131
tsconfig: './tsconfig.json',
32-
external: ['drizzle-orm', '@supabase/supabase-js'],
32+
external: ['drizzle-orm', '@supabase/supabase-js', 'pg'],
3333
// zod + @byteslice/result are bundled so dist/wasm-inline.js carries
3434
// no bare-specifier transitive imports — important for Deno / Edge /
3535
// browser consumers whose runtime won't resolve npm names without an
@@ -51,7 +51,7 @@ export default defineConfig([
5151
clean: false,
5252
target: 'es2022',
5353
tsconfig: './tsconfig.json',
54-
external: ['drizzle-orm', '@supabase/supabase-js'],
54+
external: ['drizzle-orm', '@supabase/supabase-js', 'pg'],
5555
noExternal: ['evlog', 'uuid', 'zod', '@byteslice/result'],
5656
},
5757
])

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)