@everystack/cli 0.2.39 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Whole-DB introspection — the data layer (the counterpart to authz-contract.ts).
3
+ *
4
+ * authz-contract reads RLS, policies, grants, and functions. This reads the rest
5
+ * of the database state drizzle-kit's pull would — columns and their pg-precise
6
+ * types, primary keys, uniques, checks, and foreign keys — into a structured
7
+ * SchemaSnapshot. That snapshot is what `db:generate` diffs the compiled Model
8
+ * against, and what `db:pull` reverses into `field()` declarations.
9
+ *
10
+ * Same contract as the authz introspection: the SQL runs through the ops Lambda
11
+ * (`db:query`); the mappers here are pure and unit-tested without a database.
12
+ * Types come from `format_type` (the canonical 'timestamp with time zone',
13
+ * 'numeric(12,2)', 'uuid' the Model compiler also emits), so the two sides diff.
14
+ */
15
+
16
+ import { IGNORED_SCHEMAS, coerceBool, type QueryRunner } from './authz-contract.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // The data-layer snapshot — the structured shape both producers target.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface ColumnSchema {
23
+ /** SQL column name (snake_case). */
24
+ name: string;
25
+ /** `format_type` output — the pg-precise type, e.g. `timestamp with time zone`, `numeric(12,2)`. */
26
+ type: string;
27
+ notNull: boolean;
28
+ /** The DEFAULT expression (deparsed), or null. e.g. `now()`, `gen_random_uuid()`, `'draft'::text`. */
29
+ default: string | null;
30
+ }
31
+
32
+ export interface ForeignKey {
33
+ name: string;
34
+ columns: string[];
35
+ refTable: string;
36
+ refColumns: string[];
37
+ /** Lowercased referential actions, omitted when the Postgres default (`no action`). */
38
+ onDelete?: string;
39
+ onUpdate?: string;
40
+ }
41
+
42
+ export interface UniqueConstraint {
43
+ name: string;
44
+ columns: string[];
45
+ }
46
+
47
+ export interface CheckConstraint {
48
+ name: string;
49
+ /** The predicate text, as `pg_get_constraintdef` deparses it. */
50
+ expr: string;
51
+ }
52
+
53
+ /** A standalone index (NOT one backing a PK or UNIQUE constraint — those are excluded). */
54
+ export interface IndexSchema {
55
+ name: string;
56
+ columns: string[];
57
+ unique: boolean;
58
+ /** Partial-index predicate (`WHERE …`), omitted when the index is total. */
59
+ where?: string;
60
+ }
61
+
62
+ export interface TableSchema {
63
+ /** Schema-qualified: `public.posts`. */
64
+ table: string;
65
+ /** Columns in ordinal position. */
66
+ columns: ColumnSchema[];
67
+ /** Primary-key column names (empty when the table has no PK). */
68
+ primaryKey: string[];
69
+ uniques: UniqueConstraint[];
70
+ checks: CheckConstraint[];
71
+ foreignKeys: ForeignKey[];
72
+ indexes: IndexSchema[];
73
+ }
74
+
75
+ /** A Postgres enum type — its name and its values in `enumsortorder` (the declared order). */
76
+ export interface EnumType {
77
+ name: string;
78
+ values: string[];
79
+ }
80
+
81
+ export interface SchemaSnapshot {
82
+ tables: TableSchema[];
83
+ /** Enum types in the schema (`CREATE TYPE … AS ENUM`). Absent/empty when none. */
84
+ enums?: EnumType[];
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Introspection SQL.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Every column of every base table, with its precise type (`format_type`),
93
+ * nullability, and default. Extension-owned tables are excluded via pg_depend,
94
+ * matching the authz queries.
95
+ */
96
+ export const COLUMNS_SQL = `
97
+ SELECT
98
+ n.nspname AS schema,
99
+ c.relname AS "table",
100
+ a.attname AS "column",
101
+ format_type(a.atttypid, a.atttypmod) AS type,
102
+ a.attnotnull AS not_null,
103
+ pg_get_expr(d.adbin, d.adrelid) AS "default",
104
+ a.attnum AS position
105
+ FROM pg_attribute a
106
+ JOIN pg_class c ON c.oid = a.attrelid
107
+ JOIN pg_namespace n ON n.oid = c.relnamespace
108
+ LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
109
+ WHERE c.relkind = 'r'
110
+ AND a.attnum > 0 AND NOT a.attisdropped
111
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
112
+ AND n.nspname NOT LIKE 'pg_%'
113
+ AND NOT EXISTS (
114
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = c.oid AND dep.deptype = 'e'
115
+ )
116
+ ORDER BY n.nspname, c.relname, a.attnum;
117
+ `.trim();
118
+
119
+ /**
120
+ * Every table constraint — primary key, unique, check, foreign key — as its
121
+ * canonical `pg_get_constraintdef` text. The mapper parses that text (stable
122
+ * deparser output) into structured columns/refs, the same way pg_dump-class tools
123
+ * read it.
124
+ */
125
+ export const CONSTRAINTS_SQL = `
126
+ SELECT
127
+ n.nspname AS schema,
128
+ c.relname AS "table",
129
+ con.conname AS name,
130
+ con.contype AS type,
131
+ pg_get_constraintdef(con.oid) AS definition
132
+ FROM pg_constraint con
133
+ JOIN pg_class c ON c.oid = con.conrelid
134
+ JOIN pg_namespace n ON n.oid = c.relnamespace
135
+ WHERE con.contype IN ('p', 'u', 'c', 'f')
136
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
137
+ AND n.nspname NOT LIKE 'pg_%'
138
+ AND NOT EXISTS (
139
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = c.oid AND dep.deptype = 'e'
140
+ )
141
+ ORDER BY n.nspname, c.relname, con.conname;
142
+ `.trim();
143
+
144
+ /**
145
+ * Every enum type and its values, in `enumsortorder` (the order the values were declared,
146
+ * which `CREATE TYPE` must reproduce). Extension-owned types are excluded via pg_depend,
147
+ * matching the column/constraint queries.
148
+ */
149
+ export const ENUMS_SQL = `
150
+ SELECT
151
+ n.nspname AS schema,
152
+ t.typname AS name,
153
+ array_agg(e.enumlabel ORDER BY e.enumsortorder) AS values
154
+ FROM pg_type t
155
+ JOIN pg_enum e ON e.enumtypid = t.oid
156
+ JOIN pg_namespace n ON n.oid = t.typnamespace
157
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
158
+ AND n.nspname NOT LIKE 'pg_%'
159
+ AND NOT EXISTS (
160
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = t.oid AND dep.deptype = 'e'
161
+ )
162
+ GROUP BY n.nspname, t.typname
163
+ ORDER BY n.nspname, t.typname;
164
+ `.trim();
165
+
166
+ /**
167
+ * Standalone indexes — `CREATE INDEX` the app declares, NOT the implicit indexes that back a
168
+ * primary key or unique constraint (those are excluded by the `conindid` check, since they're
169
+ * already represented as PK/unique). Columns come ordered by index position; a partial index
170
+ * carries its `WHERE` predicate. Expression indexes (no plain column list) fold to no columns
171
+ * and are skipped by the assembler.
172
+ */
173
+ export const INDEXES_SQL = `
174
+ SELECT
175
+ n.nspname AS schema,
176
+ t.relname AS "table",
177
+ i.relname AS name,
178
+ idx.indisunique AS is_unique,
179
+ (SELECT array_agg(a.attname ORDER BY k.ord)
180
+ FROM unnest(idx.indkey) WITH ORDINALITY k(attnum, ord)
181
+ JOIN pg_attribute a ON a.attrelid = idx.indrelid AND a.attnum = k.attnum) AS columns,
182
+ pg_get_expr(idx.indpred, idx.indrelid) AS predicate
183
+ FROM pg_index idx
184
+ JOIN pg_class i ON i.oid = idx.indexrelid
185
+ JOIN pg_class t ON t.oid = idx.indrelid
186
+ JOIN pg_namespace n ON n.oid = t.relnamespace
187
+ WHERE t.relkind = 'r'
188
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
189
+ AND n.nspname NOT LIKE 'pg_%'
190
+ AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = idx.indexrelid)
191
+ AND NOT EXISTS (SELECT 1 FROM pg_depend dep WHERE dep.objid = t.oid AND dep.deptype = 'e')
192
+ ORDER BY n.nspname, t.relname, i.relname;
193
+ `.trim();
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Pure mappers.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export interface ColumnRow {
200
+ schema: string;
201
+ table: string;
202
+ column: string;
203
+ type: unknown;
204
+ not_null: unknown;
205
+ default: unknown;
206
+ position: unknown;
207
+ }
208
+
209
+ export function columnRowToDescriptor(row: ColumnRow): { table: string; column: ColumnSchema; position: number } {
210
+ const def = row.default == null ? null : String(row.default);
211
+ return {
212
+ table: `${row.schema}.${row.table}`,
213
+ position: Number(row.position),
214
+ column: {
215
+ name: row.column,
216
+ type: String(row.type),
217
+ notNull: coerceBool(row.not_null),
218
+ default: def && def.length > 0 ? def : null,
219
+ },
220
+ };
221
+ }
222
+
223
+ /** Column list inside `(...)`, unquoted and trimmed: `(a, "b")` -> `['a', 'b']`. */
224
+ function parseColumnList(parenGroup: string): string[] {
225
+ const inner = parenGroup.replace(/^\(/, '').replace(/\)$/, '');
226
+ return inner.split(',').map((c) => c.trim().replace(/^"|"$/g, '')).filter(Boolean);
227
+ }
228
+
229
+ export type ConstraintKind = 'primaryKey' | 'unique' | 'check' | 'foreignKey';
230
+
231
+ export interface ConstraintRow {
232
+ schema: string;
233
+ table: string;
234
+ name: string;
235
+ type: unknown;
236
+ definition: unknown;
237
+ }
238
+
239
+ export interface ConstraintDescriptor {
240
+ table: string;
241
+ name: string;
242
+ kind: ConstraintKind;
243
+ /** Local columns (PK / unique / FK). */
244
+ columns?: string[];
245
+ /** CHECK predicate text. */
246
+ expr?: string;
247
+ /** FK target. */
248
+ refTable?: string;
249
+ refColumns?: string[];
250
+ /** FK referential actions, lowercased; omitted when the Postgres default (`no action`). */
251
+ onDelete?: string;
252
+ onUpdate?: string;
253
+ }
254
+
255
+ /** Parse one `pg_get_constraintdef` row into a structured descriptor. */
256
+ export function constraintRowToDescriptor(row: ConstraintRow): ConstraintDescriptor {
257
+ const table = `${row.schema}.${row.table}`;
258
+ const def = String(row.definition);
259
+ const type = String(row.type);
260
+
261
+ if (type === 'p' || type === 'u') {
262
+ const cols = def.match(/\(([^)]*)\)/);
263
+ return {
264
+ table, name: row.name,
265
+ kind: type === 'p' ? 'primaryKey' : 'unique',
266
+ columns: cols ? parseColumnList(cols[0]) : [],
267
+ };
268
+ }
269
+ if (type === 'f') {
270
+ // The ref table may be schema-qualified and/or double-quoted with embedded spaces
271
+ // ("My Schema"."Weird Table"), so match a quoted-or-bare token, not "up to whitespace".
272
+ const m = def.match(/FOREIGN KEY\s*(\([^)]*\))\s*REFERENCES\s+((?:"[^"]*"|[^\s("]+)(?:\.(?:"[^"]*"|[^\s("]+))?)\s*(\([^)]*\))/i);
273
+ const action = (clause: 'DELETE' | 'UPDATE'): string | undefined => {
274
+ const a = def.match(new RegExp(`ON ${clause}\\s+(CASCADE|RESTRICT|NO ACTION|SET NULL|SET DEFAULT)`, 'i'));
275
+ // `NO ACTION` is the default — Postgres omits it from constraintdef, so we omit it too.
276
+ return a && a[1].toUpperCase() !== 'NO ACTION' ? a[1].toLowerCase() : undefined;
277
+ };
278
+ return {
279
+ table, name: row.name, kind: 'foreignKey',
280
+ columns: m ? parseColumnList(m[1]) : [],
281
+ refTable: m ? m[2].replace(/"/g, '') : '',
282
+ refColumns: m ? parseColumnList(m[3]) : [],
283
+ onDelete: action('DELETE'),
284
+ onUpdate: action('UPDATE'),
285
+ };
286
+ }
287
+ // check — keep the predicate text inside the outer CHECK ( ... )
288
+ const m = def.match(/^CHECK\s*\((.*)\)$/s);
289
+ return { table, name: row.name, kind: 'check', expr: m ? m[1].trim() : def };
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Assembly.
294
+ // ---------------------------------------------------------------------------
295
+
296
+ export interface IndexRow {
297
+ schema: string;
298
+ table: string;
299
+ name: string;
300
+ is_unique: unknown;
301
+ columns: unknown;
302
+ predicate: unknown;
303
+ }
304
+
305
+ export function indexRowToDescriptor(row: IndexRow): { table: string; index: IndexSchema } {
306
+ const columns = Array.isArray(row.columns) ? row.columns.map(String) : [];
307
+ const pred = row.predicate == null ? '' : String(row.predicate);
308
+ return {
309
+ table: `${row.schema}.${row.table}`,
310
+ index: { name: row.name, columns, unique: coerceBool(row.is_unique), ...(pred ? { where: pred } : {}) },
311
+ };
312
+ }
313
+
314
+ export interface EnumRow {
315
+ schema: string;
316
+ name: string;
317
+ values: unknown;
318
+ }
319
+
320
+ export function enumRowToDescriptor(row: EnumRow): EnumType {
321
+ const values = Array.isArray(row.values) ? row.values.map(String) : [];
322
+ return { name: row.name, values };
323
+ }
324
+
325
+ export interface SchemaRows {
326
+ columns: ColumnRow[];
327
+ constraints: ConstraintRow[];
328
+ enums?: EnumRow[];
329
+ indexes?: IndexRow[];
330
+ }
331
+
332
+ /**
333
+ * Fold introspected rows into the snapshot. Deterministic: tables sorted by name,
334
+ * columns by ordinal position, and each constraint list sorted by name — so a
335
+ * re-pull of an unchanged database is byte-stable.
336
+ */
337
+ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
338
+ const tables = new Map<string, TableSchema>();
339
+ const positions = new Map<string, Map<string, number>>();
340
+
341
+ const ensure = (table: string): TableSchema => {
342
+ let t = tables.get(table);
343
+ if (!t) {
344
+ t = { table, columns: [], primaryKey: [], uniques: [], checks: [], foreignKeys: [], indexes: [] };
345
+ tables.set(table, t);
346
+ positions.set(table, new Map());
347
+ }
348
+ return t;
349
+ };
350
+
351
+ for (const row of rows.columns) {
352
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
353
+ const { table, column, position } = columnRowToDescriptor(row);
354
+ const t = ensure(table);
355
+ t.columns.push(column);
356
+ positions.get(table)!.set(column.name, position);
357
+ }
358
+
359
+ for (const t of tables.values()) {
360
+ const pos = positions.get(t.table)!;
361
+ t.columns.sort((a, b) => (pos.get(a.name) ?? 0) - (pos.get(b.name) ?? 0));
362
+ }
363
+
364
+ for (const row of rows.constraints) {
365
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
366
+ const d = constraintRowToDescriptor(row);
367
+ const t = tables.get(d.table);
368
+ if (!t) continue; // a constraint on a table we didn't enumerate (e.g. a view)
369
+ switch (d.kind) {
370
+ case 'primaryKey': t.primaryKey = d.columns ?? []; break;
371
+ case 'unique': t.uniques.push({ name: d.name, columns: d.columns ?? [] }); break;
372
+ case 'check': t.checks.push({ name: d.name, expr: d.expr ?? '' }); break;
373
+ case 'foreignKey':
374
+ t.foreignKeys.push({
375
+ name: d.name, columns: d.columns ?? [], refTable: d.refTable ?? '', refColumns: d.refColumns ?? [],
376
+ ...(d.onDelete ? { onDelete: d.onDelete } : {}),
377
+ ...(d.onUpdate ? { onUpdate: d.onUpdate } : {}),
378
+ });
379
+ break;
380
+ }
381
+ }
382
+
383
+ for (const row of rows.indexes ?? []) {
384
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
385
+ const { table, index } = indexRowToDescriptor(row);
386
+ const t = tables.get(table);
387
+ if (!t || index.columns.length === 0) continue; // unknown table, or an expression index (no plain columns)
388
+ t.indexes.push(index);
389
+ }
390
+
391
+ for (const t of tables.values()) {
392
+ t.uniques.sort((a, b) => a.name.localeCompare(b.name));
393
+ t.checks.sort((a, b) => a.name.localeCompare(b.name));
394
+ t.foreignKeys.sort((a, b) => a.name.localeCompare(b.name));
395
+ t.indexes.sort((a, b) => a.name.localeCompare(b.name));
396
+ }
397
+
398
+ const enums = (rows.enums ?? [])
399
+ .filter((r) => !IGNORED_SCHEMAS.has(r.schema))
400
+ .map(enumRowToDescriptor)
401
+ .sort((a, b) => a.name.localeCompare(b.name));
402
+
403
+ return {
404
+ tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
405
+ enums,
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Introspect the live database into a SchemaSnapshot — the data-layer analog of
411
+ * `introspectContract`. Runs the two read-only SQL constants through the injected runner
412
+ * (the ops Lambda `db:query` in production, a fake in tests) and folds the rows with
413
+ * `assembleSchema`. This is the `current` side `db:generate` diffs the compiled Models against.
414
+ */
415
+ export async function introspectSchema(run: QueryRunner): Promise<SchemaSnapshot> {
416
+ const [columns, constraints, enums, indexes] = await Promise.all([
417
+ run(COLUMNS_SQL), run(CONSTRAINTS_SQL), run(ENUMS_SQL), run(INDEXES_SQL),
418
+ ]);
419
+ return assembleSchema({
420
+ columns: columns as ColumnRow[],
421
+ constraints: constraints as ConstraintRow[],
422
+ enums: enums as EnumRow[],
423
+ indexes: indexes as IndexRow[],
424
+ });
425
+ }
@@ -36,6 +36,13 @@ export interface FunctionDescriptor {
36
36
  securityDefiner: boolean;
37
37
  /** True when the function pins `SET search_path`. */
38
38
  hasSearchPath: boolean;
39
+ /** Owning role (catalog path only — static SQL parsing can't know it). */
40
+ owner?: string;
41
+ /**
42
+ * True when the owner runs above RLS (superuser, BYPASSRLS, or rds_superuser member).
43
+ * A SECURITY DEFINER function owned by such a role is the maximum blast radius.
44
+ */
45
+ ownerBypassesRls?: boolean;
39
46
  }
40
47
 
41
48
  export interface FunctionFinding {
@@ -32,7 +32,18 @@ SELECT
32
32
  EXISTS (
33
33
  SELECT 1 FROM unnest(coalesce(p.proconfig, '{}'::text[])) cfg
34
34
  WHERE lower(cfg) LIKE 'search_path=%'
35
- ) AS has_search_path
35
+ ) AS has_search_path,
36
+ pg_get_userbyid(p.proowner) AS owner,
37
+ -- Does the owner run above RLS? A SECURITY DEFINER function owned by such a role is
38
+ -- the maximum blast radius — search_path pinning is necessary but not sufficient.
39
+ -- RDS has no true superuser (rolsuper=false), so also check rds_superuser membership.
40
+ (
41
+ SELECT r.rolsuper OR r.rolbypassrls OR EXISTS (
42
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
43
+ WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
44
+ )
45
+ FROM pg_roles r WHERE r.oid = p.proowner
46
+ ) AS owner_bypasses_rls
36
47
  FROM pg_proc p
37
48
  JOIN pg_namespace n ON n.oid = p.pronamespace
38
49
  JOIN pg_type t ON t.oid = p.prorettype
@@ -111,6 +122,8 @@ interface FunctionRow {
111
122
  returns_set: unknown;
112
123
  return_typtype: unknown;
113
124
  has_search_path: unknown;
125
+ owner?: unknown;
126
+ owner_bypasses_rls?: unknown;
114
127
  }
115
128
 
116
129
  /** Map a pg_proc row to a descriptor, computing a precise return class. */
@@ -132,6 +145,8 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
132
145
  returnClassHint,
133
146
  securityDefiner: truthy(row.security_definer),
134
147
  hasSearchPath: truthy(row.has_search_path),
148
+ owner: row.owner != null ? String(row.owner) : undefined,
149
+ ownerBypassesRls: row.owner_bypasses_rls != null ? truthy(row.owner_bypasses_rls) : undefined,
135
150
  };
136
151
  }
137
152