|
| 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 | +} |
0 commit comments