@everystack/cli 0.4.53 → 0.4.56

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.
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { IGNORED_SCHEMAS, coerceBool, type QueryRunner } from './authz-contract.js';
17
17
  import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
18
+ import { normalizeDeparsedExpr } from './deparse-normal.js';
18
19
 
19
20
  // ---------------------------------------------------------------------------
20
21
  // The data-layer snapshot — the structured shape both producers target.
@@ -28,10 +29,23 @@ export interface ColumnSchema {
28
29
  notNull: boolean;
29
30
  /** The DEFAULT expression (deparsed), or null. e.g. `now()`, `gen_random_uuid()`, `'draft'::text`. */
30
31
  default: string | null;
32
+ /**
33
+ * `GENERATED ALWAYS AS IDENTITY` / `GENERATED BY DEFAULT AS IDENTITY`, absent on a plain column.
34
+ *
35
+ * An identity column's auto-value lives in `pg_attribute.attidentity`, NOT in a default — so a
36
+ * read that only collects `pg_attrdef` sees a bare `bigint` and a rebuild produces a column with
37
+ * no auto-value at all. Every INSERT that omits the column then fails on NOT NULL. Measured on a
38
+ * consumer's `refresh_tokens.id`: token issuance died on a database built from the models.
39
+ *
40
+ * Identity implies NOT NULL in Postgres, so `notNull` is always true alongside it.
41
+ */
42
+ identity?: 'always' | 'byDefault';
31
43
  }
32
44
 
33
45
  export interface ForeignKey {
34
46
  name: string;
47
+ /** The name was DECLARED, not generated — see {@link IndexSchema.nameDeclared}, same rule. */
48
+ nameDeclared?: boolean;
35
49
  columns: string[];
36
50
  refTable: string;
37
51
  refColumns: string[];
@@ -54,6 +68,16 @@ export interface CheckConstraint {
54
68
  /** A standalone index (NOT one backing a PK or UNIQUE constraint — those are excluded). */
55
69
  export interface IndexSchema {
56
70
  name: string;
71
+ /**
72
+ * The name was DECLARED by the model, not generated from the columns.
73
+ *
74
+ * Declared-side only: introspection never sets it (the database has a name but no opinion
75
+ * about whether anyone chose it), and it is absent from `indexKey`, so content matching and
76
+ * the fingerprint are untouched. The diff reads it to decide whether a name difference is an
77
+ * intended `ALTER INDEX … RENAME TO` or just the generated fallback, which must never rename
78
+ * an adopted database to a name nobody chose.
79
+ */
80
+ nameDeclared?: boolean;
57
81
  /** Canonical key text per position: a plain column name, or anything richer verbatim —
58
82
  * `lower(email)`, `created_at DESC`, `title text_pattern_ops` (Brick E: expression
59
83
  * indexes, direction, and opclass are IDENTITY, not noise). Default ASC is stripped. */
@@ -126,6 +150,9 @@ SELECT
126
150
  format_type(a.atttypid, a.atttypmod) AS type,
127
151
  a.attnotnull AS not_null,
128
152
  pg_get_expr(d.adbin, d.adrelid) AS "default",
153
+ -- 'a' = GENERATED ALWAYS AS IDENTITY, 'd' = GENERATED BY DEFAULT, '' = a plain column.
154
+ -- An identity column carries NO pg_attrdef row, so without this the auto-value is invisible.
155
+ a.attidentity AS identity,
129
156
  a.attnum AS position
130
157
  FROM pg_attribute a
131
158
  JOIN pg_class c ON c.oid = a.attrelid
@@ -301,9 +328,19 @@ export interface ColumnRow {
301
328
  type: unknown;
302
329
  not_null: unknown;
303
330
  default: unknown;
331
+ /** `pg_attribute.attidentity` — 'a', 'd', or '' / absent on a read that predates the column. */
332
+ identity?: unknown;
304
333
  position: unknown;
305
334
  }
306
335
 
336
+ /** `attidentity` → the IR's spelling. Anything else (including '') is a plain column. */
337
+ export function identityKind(attidentity: unknown): 'always' | 'byDefault' | undefined {
338
+ const c = attidentity == null ? '' : String(attidentity);
339
+ if (c === 'a') return 'always';
340
+ if (c === 'd') return 'byDefault';
341
+ return undefined;
342
+ }
343
+
307
344
  /**
308
345
  * Drop a precision qualifier that only restates the default.
309
346
  *
@@ -333,6 +370,7 @@ export { canonicalColumnType };
333
370
 
334
371
  export function columnRowToDescriptor(row: ColumnRow): { table: string; column: ColumnSchema; position: number } {
335
372
  const def = row.default == null ? null : String(row.default);
373
+ const identity = identityKind(row.identity);
336
374
  return {
337
375
  table: `${row.schema}.${row.table}`,
338
376
  position: Number(row.position),
@@ -341,6 +379,9 @@ export function columnRowToDescriptor(row: ColumnRow): { table: string; column:
341
379
  type: canonicalColumnType(String(row.type)),
342
380
  notNull: coerceBool(row.not_null),
343
381
  default: def && def.length > 0 ? def : null,
382
+ // Omitted, never `undefined`-valued: the key is absent on a plain column so every
383
+ // structural comparison of a pre-identity snapshot against a fresh one stays equal.
384
+ ...(identity ? { identity } : {}),
344
385
  },
345
386
  };
346
387
  }
@@ -409,9 +450,12 @@ export function constraintRowToDescriptor(row: ConstraintRow): ConstraintDescrip
409
450
  onUpdate: action('UPDATE'),
410
451
  };
411
452
  }
412
- // check — keep the predicate text inside the outer CHECK ( ... )
453
+ // check — keep the predicate text inside the outer CHECK ( ... ), normalized.
454
+ // `pg_get_expr` output is NOT a fixed point under re-parse: dump a CHECK and restore it
455
+ // and the same server deparses it differently (measured 2026-08-10, PG13-18 alike). A
456
+ // restored clone would then fingerprint differently from the database it was cloned from.
413
457
  const m = def.match(/^CHECK\s*\((.*)\)$/s);
414
- return { table, name: row.name, kind: 'check', expr: m ? m[1].trim() : def };
458
+ return { table, name: row.name, kind: 'check', expr: normalizeDeparsedExpr(m ? m[1].trim() : def) };
415
459
  }
416
460
 
417
461
  // ---------------------------------------------------------------------------
@@ -491,6 +535,9 @@ export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'nam
491
535
  let where = whereMatch ? whereMatch[1].trim() : '';
492
536
  // pg_get_indexdef wraps the predicate in one pair of parens — strip it.
493
537
  if (where.startsWith('(') && parenGroup(where, 0) === where.slice(1, -1)) where = where.slice(1, -1).trim();
538
+ // Same fixed-point failure as CHECK predicates: a partial index's WHERE survives a
539
+ // dump/restore as different text for the same predicate. Normalize at the producer.
540
+ where = normalizeDeparsedExpr(where);
494
541
  return {
495
542
  columns,
496
543
  unique: /^CREATE\s+UNIQUE\s+INDEX/i.test(definition),
@@ -504,7 +551,10 @@ export function parseIndexDefinition(definition: string): Omit<IndexSchema, 'nam
504
551
  * a legacy row without one keeps the old plain-columns path, so fixtures stay valid.
505
552
  * `is_unique`/`predicate`/`method` row fields take precedence over the parsed text. */
506
553
  export function indexRowToDescriptor(row: IndexRow): { table: string; index: IndexSchema } {
507
- const pred = row.predicate == null ? '' : String(row.predicate);
554
+ // The row's own `predicate` column takes precedence over the parsed definition text, so
555
+ // it is the producer that must normalize — normalizing only the parse path left the
556
+ // predicate unstable across a dump/restore and the fingerprint with it.
557
+ const pred = row.predicate == null ? '' : normalizeDeparsedExpr(String(row.predicate));
508
558
  const table = `${row.schema}.${row.table}`;
509
559
  const base = { name: row.name, unique: coerceBool(row.is_unique), ...(pred ? { where: pred } : {}) };
510
560
 
@@ -55,12 +55,23 @@ function strLiteral(value: string): string {
55
55
  return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
56
56
  }
57
57
 
58
- /** A `.default(<v>)` argument literal for a value default (the JSON forms drizzle accepts). */
59
- function defaultValueLiteral(value: unknown): string {
58
+ /**
59
+ * A `.default(<v>)` argument literal for a value default (the JSON forms drizzle accepts).
60
+ *
61
+ * `builder` is the drizzle column builder the value is a default FOR, because the accepted
62
+ * TYPE of the literal depends on it: drizzle's `numeric`/`decimal` take a STRING, since an
63
+ * arbitrary-precision default routed through a JS number is lossy by construction. A pulled
64
+ * model renders `field.numeric(4, 2).default(0.15)` — the model vocabulary accepting a number
65
+ * there is fine — and passing that straight through produced `.default(0.15)`, which fails
66
+ * `tsc --strict` with TS2345 and blocked a consumer from adopting the generated artifact.
67
+ */
68
+ function defaultValueLiteral(value: unknown, builder?: string): string {
60
69
  if (typeof value === 'string') return strLiteral(value);
61
70
  if (typeof value === 'boolean') return value ? 'true' : 'false';
62
71
  if (value === null) return 'null';
63
- if (typeof value === 'number') return String(value);
72
+ if (typeof value === 'number') {
73
+ return builder === 'numeric' || builder === 'decimal' ? strLiteral(String(value)) : String(value);
74
+ }
64
75
  // Arrays / objects: a JSON literal is valid TS for jsonb defaults.
65
76
  return JSON.stringify(value);
66
77
  }
@@ -130,6 +141,18 @@ function baseColumnSource(columnName: string, spec: FieldSpec): { call: string;
130
141
  }
131
142
  }
132
143
 
144
+ /**
145
+ * Whether the drizzle builder for this column carries `generatedAlwaysAsIdentity`.
146
+ *
147
+ * Only the integer family does (`PgIntColumnBaseBuilder`). A `field.pgType('smallint')`
148
+ * identity column compiles to `customType`, which has no identity builder — the DDL still
149
+ * carries the clause (that comes from the compiler, not from here), so the DATABASE is right
150
+ * and only the generated TS type is imprecise: it will ask for a value the database supplies.
151
+ */
152
+ function identitySupported(builder?: string): boolean {
153
+ return builder === 'integer' || builder === 'bigint' || builder === 'smallint';
154
+ }
155
+
133
156
  /** The hoisted const name for an enum type. `status` → `statusEnum`. */
134
157
  function enumConstName(enumName: string): string {
135
158
  return `${toCamelCase(enumName)}Enum`;
@@ -159,15 +182,22 @@ function columnModifiers(
159
182
  spec: FieldSpec,
160
183
  isComposite: boolean,
161
184
  modelsByDescriptor: Map<ModelDescriptor, { camel: string }>,
185
+ ctx: { builder?: string; self?: ModelDescriptor; usedTypes?: Set<string> } = {},
162
186
  ): string {
163
187
  let s = '';
164
188
  if (spec.isArray) s += '.array()';
165
- if (spec.isNotNull) s += '.notNull()';
189
+ // Identity comes FIRST and takes `.notNull()` with it: drizzle's builder sets notNull and
190
+ // hasDefault itself, and `hasDefault` is what makes the column OPTIONAL on insert. Without
191
+ // it the generated type demands an `id` the database supplies — which is how a correct
192
+ // database still fails to compile at the call site.
193
+ const identity = identitySupported(ctx.builder) ? spec.identity : undefined;
194
+ if (identity) s += identity === 'always' ? '.generatedAlwaysAsIdentity()' : '.generatedByDefaultAsIdentity()';
195
+ else if (spec.isNotNull) s += '.notNull()';
166
196
  if (spec.isUnique) s += '.unique()';
167
197
 
168
198
  if (spec.defaultKind === 'now') s += '.defaultNow()';
169
199
  else if (spec.defaultKind === 'random') s += '.defaultRandom()';
170
- else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default)})`;
200
+ else if (spec.defaultKind === 'value') s += `.default(${defaultValueLiteral(spec.default, ctx.builder)})`;
171
201
  else if (spec.defaultKind === 'sql') s += `.default(sql.raw(${strLiteral(String(spec.default))}))`;
172
202
 
173
203
  if (spec.isPrimaryKey && !isComposite) s += '.primaryKey()';
@@ -181,7 +211,17 @@ function columnModifiers(
181
211
  if (spec.onDelete) opts.push(`onDelete: ${strLiteral(spec.onDelete)}`);
182
212
  if (spec.onUpdate) opts.push(`onUpdate: ${strLiteral(spec.onUpdate)}`);
183
213
  const optsText = opts.length ? `, { ${opts.join(', ')} }` : '';
184
- s += `.references(() => ${entry.camel}.${parentPk}${optsText})`;
214
+ // A SELF-referential FK needs drizzle's documented return-type annotation. Without it
215
+ // the table's own initializer references itself, TypeScript cannot infer it, and under
216
+ // `strict` it is TS7022 + TS7024 — which does not merely warn: the whole table
217
+ // degrades to `any`, so every query against it silently loses type safety in a project
218
+ // that otherwise builds. A reference to ANOTHER table infers fine and is left alone.
219
+ if (ctx.self && target === ctx.self) {
220
+ ctx.usedTypes?.add('AnyPgColumn');
221
+ s += `.references((): AnyPgColumn => ${entry.camel}.${parentPk}${optsText})`;
222
+ } else {
223
+ s += `.references(() => ${entry.camel}.${parentPk}${optsText})`;
224
+ }
185
225
  }
186
226
  // A reference to a model OUTSIDE the emitted set (a cross-schema FK) is omitted —
187
227
  // the runtime + relations never needed it; the DB constraint stays unmanaged.
@@ -333,6 +373,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
333
373
 
334
374
  // --- Track which pg-core builders + drizzle-orm symbols are used ----------
335
375
  const pgCoreBuilders = new Set<string>();
376
+ // TYPE-only imports from pg-core (today: AnyPgColumn, for self-referential FKs). Kept apart
377
+ // from the value imports so the emitted `import type` line carries no runtime cost.
378
+ const pgCoreTypes = new Set<string>();
336
379
  // Only when something actually lands in `public` — an all-non-public model set would
337
380
  // otherwise import a builder it never calls.
338
381
  if (models.some((m) => m.schema === 'public')) pgCoreBuilders.add('pgTable');
@@ -353,7 +396,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
353
396
  const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
354
397
  pgCoreBuilders.add(pgBuilder);
355
398
  if (spec.defaultKind === 'sql') pgCoreBuilders.add('sql'); // imported from 'drizzle-orm', handled below
356
- const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
399
+ const mods = columnModifiers(spec, isComposite, modelsByDescriptor, { builder: pgBuilder, self: model, usedTypes: pgCoreTypes });
357
400
  if (spec.isDeprecated) {
358
401
  // The contract phase (decision 13): the column stays in the database and
359
402
  // stays readable; the strikethrough warns new code away at author time.
@@ -457,7 +500,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
457
500
  pgCoreBuilders.add(pgBuilder);
458
501
  // Only .array()/.notNull() can appear — defineView/defineMaterializedView
459
502
  // reject every table-structural modifier at define time.
460
- const mods = columnModifiers(spec, true, modelsByDescriptor);
503
+ const mods = columnModifiers(spec, true, modelsByDescriptor, { builder: pgBuilder });
461
504
  colLines.push(` ${key}: ${call}${mods},`);
462
505
  }
463
506
  const cols = `{\n${colLines.join('\n')}\n}`;
@@ -501,6 +544,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
501
544
 
502
545
  const importLines: string[] = [];
503
546
  importLines.push(`import { ${pgCoreNames.join(', ')} } from 'drizzle-orm/pg-core';`);
547
+ if (pgCoreTypes.size) {
548
+ importLines.push(`import type { ${[...pgCoreTypes].sort().join(', ')} } from 'drizzle-orm/pg-core';`);
549
+ }
504
550
 
505
551
  const drizzleOrmNames: string[] = [];
506
552
  if (anyRelations) drizzleOrmNames.push('relations');
@@ -43,6 +43,12 @@ export interface FunctionDescriptor {
43
43
  * A SECURITY DEFINER function owned by such a role is the maximum blast radius.
44
44
  */
45
45
  ownerBypassesRls?: boolean;
46
+ /**
47
+ * SUPERUSER (or rds_superuser member) specifically — the portable half of ownership.
48
+ * A scoped operator is often BYPASSRLS by design, so `ownerBypassesRls` alone cannot tell
49
+ * a correct design from a function left owned by the build identity. This can.
50
+ */
51
+ ownerIsSuperuser?: boolean;
46
52
  }
47
53
 
48
54
  export interface FunctionFinding {
@@ -86,12 +92,40 @@ export interface Waivers {
86
92
  secdef?: Record<string, string>;
87
93
  /** `schema.view` -> declared public column allowlist. */
88
94
  publicColumns?: Record<string, string[]>;
95
+ /** schema name -> justification for leaving it broadly writable. Same rule: no bare opt-out. */
96
+ schema?: Record<string, string>;
97
+ }
98
+
99
+ /**
100
+ * A schema and the two facts that decide whether it is a shadowing surface: who can CREATE in
101
+ * it, and what privileged code resolves through it.
102
+ */
103
+ export interface SchemaDescriptor {
104
+ schema: string;
105
+ /** Grantees holding CREATE on the schema. `PUBLIC` is the pseudo-role entry. */
106
+ createGrantees: string[];
107
+ /** SECURITY DEFINER functions living in it. */
108
+ definerFunctions: number;
109
+ /** …of which carry no `SET search_path`. */
110
+ unpinnedDefinerFunctions: number;
111
+ }
112
+
113
+ export interface SchemaFinding {
114
+ /** The schema name — the waiver-match key. */
115
+ key: string;
116
+ schema: string;
117
+ severity: Severity;
118
+ reasons: string[];
119
+ waived: boolean;
120
+ waiverReason?: string;
89
121
  }
90
122
 
91
123
  export interface AuditReport {
92
124
  functions: FunctionFinding[];
93
125
  views: ViewFinding[];
94
- /** Counts across both invariants, excluding waived. */
126
+ /** Writable-schema findings absent when no schema ACLs were supplied (static path). */
127
+ schemas: SchemaFinding[];
128
+ /** Counts across every invariant, excluding waived. */
95
129
  red: number;
96
130
  warn: number;
97
131
  }
@@ -181,6 +215,68 @@ export function classifyFunction(
181
215
  return { key, schema: fn.schema, name: fn.name, returnType: fn.returnType, returnClass, severity, reasons, waived: false };
182
216
  }
183
217
 
218
+ /**
219
+ * A10(ii) — the PRECONDITION leg: can an untrusted role CREATE in a schema that privileged code
220
+ * resolves through?
221
+ *
222
+ * The audit already flags an unpinned SECURITY DEFINER function and an owner that bypasses RLS.
223
+ * Both are about the privileged code. Neither says whether an attacker can actually plant
224
+ * anything for it to resolve to, and that is what separates latent debt from a live chain. A
225
+ * consumer asked for exactly this after finding `PUBLIC` holding CREATE on `public` across every
226
+ * dump-adopted database (the pre-PG15 default; PG15 removed it, so a database everystack BUILDS
227
+ * is hardened and one it ADOPTS is not).
228
+ *
229
+ * Tiering, and the reason it is not simply "red when SECDEF is present":
230
+ *
231
+ * - writable by a broad role, with an UNPINNED definer function in the same schema → RED.
232
+ * That is the whole chain: plant a shadow object, the unpinned function resolves through it
233
+ * and runs it with the owner's rights.
234
+ * - writable by a broad role, everything else → WARN. Pinning the definer functions defends
235
+ * that specific chain and does NOT make the schema safe: a PUBLIC-writable schema on a
236
+ * search path threatens EVERY privileged session resolving through it, including
237
+ * everystack's own build/reconcile credential executing DDL with `public` on its path. A
238
+ * shadow object planted there hijacks the builder, which outranks any SECDEF owner. This is
239
+ * why the finding does not require a definer function to fire.
240
+ *
241
+ * Only BROAD grantees count. A named operator role holding CREATE is how schemas get built.
242
+ */
243
+ export function classifySchema(schema: SchemaDescriptor, waivers: Waivers = {}): SchemaFinding {
244
+ const key = schema.schema;
245
+ const reasons: string[] = [];
246
+ let severity: Severity = 'ok';
247
+ const broad = schema.createGrantees.filter((g) => BROAD_ROLES.has(g.toLowerCase())).sort();
248
+
249
+ if (broad.length > 0) {
250
+ const who = broad.join(', ');
251
+ if (schema.unpinnedDefinerFunctions > 0) {
252
+ reasons.push(
253
+ `${who} can CREATE in schema "${key}", which holds ${schema.unpinnedDefinerFunctions} unpinned SECURITY DEFINER function(s) — `
254
+ + `that is a complete search_path hijack chain: plant a shadow object, the unpinned function resolves through it and runs it with the owner's rights. `
255
+ + `REVOKE CREATE ON SCHEMA ${key} FROM ${who}, and pin those functions.`,
256
+ );
257
+ severity = 'red';
258
+ } else {
259
+ reasons.push(
260
+ `${who} can CREATE in schema "${key}" — the pre-PG15 default, which PG15 removed. No unpinned SECURITY DEFINER function is here, `
261
+ + `but a writable schema on a search path threatens every privileged session that resolves through it, including the build credential `
262
+ + `running DDL. REVOKE CREATE ON SCHEMA ${key} FROM ${who}.`,
263
+ );
264
+ severity = raise(severity, 'warn');
265
+ }
266
+ }
267
+
268
+ const waiverReason = waivers.schema?.[key];
269
+ if (waiverReason !== undefined) {
270
+ if (typeof waiverReason === 'string' && waiverReason.trim().length > 0) {
271
+ return { key, schema: key, severity: 'ok', reasons, waived: true, waiverReason };
272
+ }
273
+ reasons.push('waiver present but missing a justification string — a waiver must explain why the schema is safe');
274
+ severity = raise(severity, 'red');
275
+ }
276
+
277
+ return { key, schema: key, severity, reasons, waived: false };
278
+ }
279
+
184
280
  /** Apply Invariant B to a single view/projection. */
185
281
  export function classifyView(view: ViewDescriptor, waivers: Waivers = {}): ViewFinding {
186
282
  const key = `${view.schema}.${view.name}`;
@@ -225,18 +321,51 @@ export function audit(
225
321
  functions: FunctionDescriptor[],
226
322
  views: ViewDescriptor[],
227
323
  waivers: Waivers = {},
324
+ schemas: SchemaDescriptor[] = [],
228
325
  ): AuditReport {
229
326
  const fnFindings = functions.map((f) => classifyFunction(f, waivers));
230
327
  const viewFindings = views.map((v) => classifyView(v, waivers));
231
- const all: { severity: Severity }[] = [...fnFindings, ...viewFindings];
328
+ // Empty by default: the STATIC path parses migration SQL and cannot know a schema ACL, and a
329
+ // silent `ok` there would be a claim the parser is not entitled to make.
330
+ const schemaFindings = schemas.map((s) => classifySchema(s, waivers));
331
+ const all: { severity: Severity }[] = [...fnFindings, ...viewFindings, ...schemaFindings];
232
332
  return {
233
333
  functions: fnFindings,
234
334
  views: viewFindings,
335
+ schemas: schemaFindings,
235
336
  red: all.filter((f) => f.severity === 'red').length,
236
337
  warn: all.filter((f) => f.severity === 'warn').length,
237
338
  };
238
339
  }
239
340
 
341
+ /**
342
+ * Build the schema descriptors from a live authz contract — the catalog path's adapter.
343
+ *
344
+ * `schemaAcls` absent means the read never ran, and the audit must then say nothing about
345
+ * schemas rather than report every one of them as clean.
346
+ */
347
+ export function schemaDescriptorsFromContract(
348
+ schemaAcls: Record<string, Record<string, string[]>> | undefined,
349
+ functions: readonly { name: string; securityDefiner: boolean; hasSearchPath: boolean }[],
350
+ ): SchemaDescriptor[] {
351
+ if (!schemaAcls) return [];
352
+ const schemaOf = (qualified: string): string => qualified.split('.').slice(0, -1).join('.') || 'public';
353
+ return Object.entries(schemaAcls)
354
+ .map(([schema, grants]) => {
355
+ const here = functions.filter((f) => f.securityDefiner && schemaOf(f.name) === schema);
356
+ return {
357
+ schema,
358
+ createGrantees: Object.entries(grants)
359
+ .filter(([, privileges]) => privileges.includes('CREATE'))
360
+ .map(([grantee]) => grantee)
361
+ .sort(),
362
+ definerFunctions: here.length,
363
+ unpinnedDefinerFunctions: here.filter((f) => !f.hasSearchPath).length,
364
+ };
365
+ })
366
+ .sort((a, b) => a.schema.localeCompare(b.schema));
367
+ }
368
+
240
369
  function raise(current: Severity, next: Severity): Severity {
241
370
  const rank: Record<Severity, number> = { ok: 0, warn: 1, red: 2 };
242
371
  return rank[next] > rank[current] ? next : current;
@@ -43,7 +43,20 @@ SELECT
43
43
  WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
44
44
  )
45
45
  FROM pg_roles r WHERE r.oid = p.proowner
46
- ) AS owner_bypasses_rls
46
+ ) AS owner_bypasses_rls,
47
+ -- SUPERUSER specifically, split out from owner_bypasses_rls. A deliberately-scoped
48
+ -- operator role is commonly BYPASSRLS by design, so the combined flag reads true for
49
+ -- the correct design AND for a function accidentally left owned by the build identity --
50
+ -- identical value, wildly different blast radius. This is the portable half: it separates
51
+ -- a scoped operator from a superuser without comparing a role NAME, which legitimately
52
+ -- differs between a dev machine, a deployed master and a normalized operator.
53
+ (
54
+ SELECT r.rolsuper OR EXISTS (
55
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
56
+ WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
57
+ )
58
+ FROM pg_roles r WHERE r.oid = p.proowner
59
+ ) AS owner_is_superuser
47
60
  FROM pg_proc p
48
61
  JOIN pg_namespace n ON n.oid = p.pronamespace
49
62
  JOIN pg_type t ON t.oid = p.prorettype
@@ -124,6 +137,7 @@ interface FunctionRow {
124
137
  has_search_path: unknown;
125
138
  owner?: unknown;
126
139
  owner_bypasses_rls?: unknown;
140
+ owner_is_superuser?: unknown;
127
141
  }
128
142
 
129
143
  /** Map a pg_proc row to a descriptor, computing a precise return class. */
@@ -147,6 +161,7 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
147
161
  hasSearchPath: truthy(row.has_search_path),
148
162
  owner: row.owner != null ? String(row.owner) : undefined,
149
163
  ownerBypassesRls: row.owner_bypasses_rls != null ? truthy(row.owner_bypasses_rls) : undefined,
164
+ ownerIsSuperuser: row.owner_is_superuser != null ? truthy(row.owner_is_superuser) : undefined,
150
165
  };
151
166
  }
152
167
 
@@ -155,9 +170,9 @@ export function catalogFunctionToDescriptor(row: FunctionRow): FunctionDescripto
155
170
  * every `introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL)` call
156
171
  * site uses this instead of keeping its own copy.
157
172
  */
158
- export const contractFunctionRow = (row: any): { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean } => {
173
+ export const contractFunctionRow = (row: any): { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean; ownerIsSuperuser?: boolean } => {
159
174
  const d = catalogFunctionToDescriptor(row);
160
- return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls };
175
+ return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls, ownerIsSuperuser: d.ownerIsSuperuser };
161
176
  };
162
177
 
163
178
  interface RelationRow {
@@ -115,6 +115,10 @@ const ADDITIVE_MATCHERS: RegExp[] = [
115
115
  // Relaxing nullability and setting a default add capability; they remove nothing.
116
116
  /^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sDROP\s+NOT\s+NULL\b/,
117
117
  /^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sSET\s+DEFAULT\b/,
118
+ // Giving a column an auto-value adds capability and removes nothing. Its sibling
119
+ // `SET GENERATED ALWAYS` is deliberately NOT here — it starts REFUSING client-supplied
120
+ // values, so a human reads it as `unclassified` rather than being told it is safe.
121
+ /^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sADD\s+GENERATED\b/,
118
122
  ];
119
123
 
120
124
  /**
@@ -195,7 +199,9 @@ export function classifyDestructive(
195
199
  const narrowings: string[] = [];
196
200
  const strips: string[] = [];
197
201
  for (const statement of executable) {
198
- if (/\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(statement)) drops.push(statement);
202
+ // DROP IDENTITY is a drop: it takes the backing sequence and its counter with it, and a
203
+ // counter is state no re-run reconstructs. The column's rows survive; its auto-value does not.
204
+ if (/\bDROP\s+(TABLE|COLUMN|TYPE|IDENTITY)\b/.test(statement)) drops.push(statement);
199
205
  else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
200
206
  else if (opts.declaredGrantees) {
201
207
  // Only classified when the caller supplied the declared side — without it there is
@@ -347,6 +353,9 @@ export interface StateSyncOptions extends StateApplyOptions {
347
353
  allowDrops?: boolean;
348
354
  /** Standalone sequences — the verify re-diff must see the same declared state as the plan. */
349
355
  sequences?: SequenceDescriptor[];
356
+ /** Extensions the modules declare — the re-generated stream must carry them too, or a
357
+ * verify-after re-plan disagrees with the plan that was applied. */
358
+ extensions?: string[];
350
359
  }
351
360
 
352
361
  export interface StateSyncOutcome {
@@ -406,7 +415,7 @@ export async function applyStateAndVerify(
406
415
  }
407
416
 
408
417
  const remaining = classifyGeneratedStatements(
409
- generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract, sequences: options.sequences }),
418
+ generateMigrationSql(models, snapshot, { allowDrops: options.allowDrops, liveAuthz: contract, sequences: options.sequences, extensions: options.extensions }),
410
419
  ).executable;
411
420
 
412
421
  return {