@everystack/cli 0.4.40 → 0.4.43

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.
@@ -29,21 +29,32 @@ import type { ModelDescriptor } from '@everystack/model';
29
29
  import type { SchemaSnapshot } from './schema-introspect.js';
30
30
  import type { AuthzContract } from './authz-contract.js';
31
31
  import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
32
- import { classifyGeneratedStatements, classifyDestructive, renderStatementHistogram } from './state-apply.js';
32
+ import { compileTableContract } from './authz-compile.js';
33
+ import { classifyGeneratedStatements, classifyDestructive, partitionStatements, renderStatementHistogram } from './state-apply.js';
33
34
  import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
34
35
  import { compileDeclaredState } from './declared-diff.js';
35
36
  import { compileTableRenames, compileTableMoves } from './schema-compile.js';
36
37
 
37
38
  export const PLAN_VERSION = 2;
38
39
 
39
- /** Classification counts (brick 9, decision 11): destructive = drops + narrowings. */
40
+ /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
40
41
  export interface PlanClassification {
41
- /** Executable statements that lose no data. */
42
+ /** Statements that positively ADD. Counted from a matcher, never `total - destructive` —
43
+ * computing it by subtraction made "safe" the default for anything unrecognized. */
42
44
  additive: number;
43
45
  /** `DROP TABLE/COLUMN/TYPE` — the data is gone. */
44
46
  drops: number;
45
47
  /** Lossy/risky `ALTER COLUMN … TYPE` — data loss wearing an ALTER. */
46
48
  narrowings: number;
49
+ /** REVOKEs against a grantee no model declares — nothing re-derives that access.
50
+ * Optional: a plan minted before this classification existed has no field for it. */
51
+ strips?: number;
52
+ /** `DROP POLICY` / `REVOKE` / `DISABLE RLS` — authorization removed, no row lost. Not
53
+ * destructive (it re-declares from the models) and emphatically not additive. */
54
+ authzRemovals?: number;
55
+ /** Statements the classifier does not recognize. Never additive — a plan carrying these
56
+ * has not been fully described and a human should read them before it is applied. */
57
+ unclassified?: number;
47
58
  }
48
59
 
49
60
  export interface EdgePlan {
@@ -72,6 +83,12 @@ export interface MintOptions {
72
83
  allowDrops?: boolean;
73
84
  gitRef?: string | null;
74
85
  actor?: string | null;
86
+ /**
87
+ * Roles the modules declare as governed beyond the ones the models name. A live grantee
88
+ * outside the governed set is left untouched rather than revoked — without this the
89
+ * MINTED PLAN carries those revokes, which is where they would actually be applied.
90
+ */
91
+ governedRoles?: string[];
75
92
  }
76
93
 
77
94
  /**
@@ -145,6 +162,7 @@ export function mintEdgePlan(
145
162
  schema: opts.schema,
146
163
  allowDrops: opts.allowDrops,
147
164
  liveAuthz: contract,
165
+ governedRoles: opts.governedRoles,
148
166
  });
149
167
  const classified = classifyGeneratedStatements(statements);
150
168
  if (classified.heldDrops.length > 0) {
@@ -158,8 +176,16 @@ export function mintEdgePlan(
158
176
  // (the authz phase is data-safe by construction); dropping a table,
159
177
  // column, or type is not — and neither is a narrowing type change, which
160
178
  // is data loss wearing an ALTER.
161
- const breakdown = classifyDestructive(classified.executable);
162
- const destructive = breakdown.drops.length + breakdown.narrowings.length;
179
+ // The grantees the models actually declare — the discriminator for a STRIP (a revoke
180
+ // against a role no model grants to has nothing to restore it from, so it carries the
181
+ // same ceremony as a dropped column).
182
+ const declaredGrantees = new Set<string>();
183
+ for (const t of models.map((m) => compileTableContract(m, { schema: opts.schema }))) {
184
+ for (const g of Object.keys(t.grants)) declaredGrantees.add(g);
185
+ for (const g of Object.keys(t.columnGrants ?? {})) declaredGrantees.add(g);
186
+ }
187
+ const breakdown = partitionStatements(classified.executable, { declaredGrantees });
188
+ const destructive = breakdown.drops.length + breakdown.narrowings.length + breakdown.strips.length;
163
189
 
164
190
  return {
165
191
  v: PLAN_VERSION,
@@ -170,9 +196,14 @@ export function mintEdgePlan(
170
196
  executable: classified.executable.length,
171
197
  destructive,
172
198
  classification: {
173
- additive: classified.executable.length - destructive,
199
+ // Counted, never inferred by subtraction — see partitionStatements. A statement nobody
200
+ // recognized is `unclassified`, and it must never be able to present as additive.
201
+ additive: breakdown.additive.length,
174
202
  drops: breakdown.drops.length,
175
203
  narrowings: breakdown.narrowings.length,
204
+ ...(breakdown.strips.length > 0 ? { strips: breakdown.strips.length } : {}),
205
+ ...(breakdown.authzRemovals.length > 0 ? { authzRemovals: breakdown.authzRemovals.length } : {}),
206
+ ...(breakdown.unclassified.length > 0 ? { unclassified: breakdown.unclassified.length } : {}),
176
207
  },
177
208
  notices: classified.notices.length,
178
209
  unmodeled: unmodeledTables(models, snapshot),
@@ -181,6 +212,35 @@ export function mintEdgePlan(
181
212
  };
182
213
  }
183
214
 
215
+ /**
216
+ * Tables this plan leaves with no SELECT-admitting policy — the "goes dark" set.
217
+ *
218
+ * A `DROP POLICY` is cheap to reverse and loses no row, which is why it is not gated. But when
219
+ * a plan drops every policy that admitted a read and creates none in its place, RLS is still
220
+ * enabled and the grant is still there, so the table returns ZERO rows to that role. On a real
221
+ * adoption plan that was 11 tables, including the users table, and it printed no notice at all.
222
+ *
223
+ * Deliberately conservative: it only names a table when the plan drops a read-admitting policy
224
+ * and adds none back for that table. It cannot know what other policies exist live, so it
225
+ * under-reports rather than crying wolf.
226
+ */
227
+ export function tablesLeftWithoutARead(statements: readonly string[]): string[] {
228
+ const dropped = new Map<string, number>();
229
+ const created = new Set<string>();
230
+ for (const statement of statements) {
231
+ const head = (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '').trim();
232
+ let m = /^DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?\S+\s+ON\s+(\S+?);?$/i.exec(head);
233
+ if (m) {
234
+ dropped.set(m[1], (dropped.get(m[1]) ?? 0) + 1);
235
+ continue;
236
+ }
237
+ m = /^CREATE\s+POLICY\s+\S+\s+ON\s+(\S+)/i.exec(head);
238
+ // Only a SELECT-admitting policy restores a read; an INSERT-only policy does not.
239
+ if (m && /\bFOR\s+(SELECT|ALL)\b/i.test(head)) created.add(m[1]);
240
+ }
241
+ return [...dropped.keys()].filter((t) => !created.has(t)).sort();
242
+ }
243
+
184
244
  /** The plan's content address — recorded as `plan_ref` on the schema_log row. */
185
245
  export function planHash(plan: EdgePlan): string {
186
246
  return createHash('sha256').update(stableStringify(plan)).digest('hex');
@@ -223,6 +283,24 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
223
283
  lines.push(`! ${ddl.trim()}`);
224
284
  }
225
285
  }
286
+ // Authorization removed loses no row, so it is not gated — but it decides who can SEE the
287
+ // rows, and a plan that drops a table's only read policy leaves that table returning nothing.
288
+ // 11 tables went dark in a real adoption plan that printed "0 notice(s)".
289
+ const { authzRemovals, unclassified } = partitionStatements(classifyGeneratedStatements(plan.statements).executable);
290
+ const dark = tablesLeftWithoutARead(plan.statements);
291
+ if (authzRemovals.length > 0) {
292
+ lines.push(`! ${authzRemovals.length} statement(s) REMOVE authorization (policies, grants, RLS). No data is lost; who can read it changes.`);
293
+ if (dark.length > 0) {
294
+ lines.push(`! ${dark.length} table(s) end this plan with NO read policy and RLS still on — they will return zero rows: ${dark.join(', ')}`);
295
+ }
296
+ }
297
+ if (unclassified.length > 0) {
298
+ lines.push(`! ${unclassified.length} statement(s) could NOT be classified — read them before applying. They are not counted as additive:`);
299
+ for (const statement of unclassified) {
300
+ const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
301
+ lines.push(`! ${ddl.trim()}`);
302
+ }
303
+ }
226
304
  if (plan.unmodeled.length > 0) {
227
305
  lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
228
306
  }
@@ -20,7 +20,7 @@ import type { SchemaSnapshot } from './schema-introspect.js';
20
20
  import type { AuthzContract } from './authz-contract.js';
21
21
  import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile.js';
22
22
  import { compileTableContract } from './authz-compile.js';
23
- import { emitReconcileSql } from './authz-reconcile.js';
23
+ import { emitReconcileSql, governedRoleSet } from './authz-reconcile.js';
24
24
  import { diffSchema, emitSchemaSql, type SchemaChange } from './schema-diff.js';
25
25
 
26
26
  export interface GenerateOptions {
@@ -44,6 +44,13 @@ export interface GenerateOptions {
44
44
  * removed table/enum becomes a drop (still held by the `allowDrops` gate).
45
45
  */
46
46
  scope?: 'declared' | 'full';
47
+ /**
48
+ * Roles the modules declare as governed BEYOND the ones the models name
49
+ * (`moduleGovernedRoles`). A live grantee outside the governed set is left untouched
50
+ * rather than revoked — see authz-reconcile's `governedRoleSet`. Widen-only: this can
51
+ * never stop a model-named role being governed.
52
+ */
53
+ governedRoles?: string[];
47
54
  /**
48
55
  * The live authorization contract (`introspectContract`). When provided, the migration
49
56
  * carries the authz layer too — the Models' `abilities` compiled to RLS/policies/grants,
@@ -208,8 +215,11 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
208
215
  }
209
216
  : opts.liveAuthz;
210
217
  const desiredContracts = models.map((m) => compileTableContract(m, { schema }));
218
+ const desiredAuthz: AuthzContract = { tables: desiredContracts, functions: [] };
211
219
  const authzPhase = liveAuthzRenamed
212
- ? emitReconcileSql({ tables: desiredContracts, functions: [] }, liveAuthzRenamed)
220
+ ? emitReconcileSql(desiredAuthz, liveAuthzRenamed, {
221
+ governedRoles: governedRoleSet(desiredAuthz, opts.governedRoles ?? []),
222
+ })
213
223
  : [];
214
224
 
215
225
  // 0a-bis (the diff analog of compileMigration's). A role cannot reach a table in a
@@ -88,40 +88,74 @@ export interface RenderOptions {
88
88
  * dominant brownfield shape (public reference data, admin-managed). Anything else is a
89
89
  * per-model edit — the decision belongs in the file, not in a flag grammar.
90
90
  */
91
- export const ABILITY_PRESETS: Record<string, string> = {
92
- 'public-read': `abilities: [can('read'), can('manage', { role: 'admin' })],`,
91
+ export const ABILITY_PRESETS: Record<string, string[]> = {
92
+ 'public-read': [`can('read')`, `can('manage', { role: 'admin' })`],
93
93
  };
94
94
 
95
95
  /**
96
- * The scaffold stanza for one model: the commented decision surface (guide text says the
97
- * same thing the db:check gate failure says one voice, two doorways), or a preset
98
- * stamped uncommented. An unknown preset throws grants are authored, never guessed.
96
+ * A rendered ability that is a PUBLIC read anon-visible, the only shape the soft-delete
97
+ * guard ever applied to. Matches `defineModel`'s own rule: action `read`, with no `role`,
98
+ * `owner`, or `via` narrowing it. Tested per-ability (never against the whole joined stanza)
99
+ * so one ability's `role:` can never mask another's public read.
99
100
  */
100
- function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): string {
101
+ function isPublicReadAbility(expr: string): boolean {
102
+ return /^can\('read'/.test(expr.trim()) && !/\b(role|owner|via)\s*:/.test(expr);
103
+ }
104
+
105
+ /**
106
+ * The scaffold stanza for one model, plus whether it declares a public read — the renderer
107
+ * needs the second fact to decide the `softDelete` line, and it must come from the STRUCTURED
108
+ * abilities, not a regex over the joined text (a live predicate can span lines and carry its
109
+ * own braces). An unknown preset throws — grants are authored, never guessed.
110
+ */
111
+ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>): { text: string; publicRead: boolean } {
101
112
  if (mode === 'live') {
102
113
  const contract = table && liveAuthz?.get(table.table);
103
114
  if (!contract) {
104
- return [
105
- ' // no live authorization found for this table — nothing was granted, so nothing is',
106
- ' // rendered. Author the read model deliberately, or leave it internal:',
107
- ' // private: true,',
108
- ].join('\n');
115
+ return {
116
+ text: [
117
+ ' // no live authorization found for this table — nothing was granted, so nothing is',
118
+ ' // rendered. Author the read model deliberately, or leave it internal:',
119
+ ' // private: true,',
120
+ ].join('\n'),
121
+ publicRead: false,
122
+ };
109
123
  }
110
- return renderDerivedAbilities(deriveAbilities(contract));
124
+ const derived = deriveAbilities(contract);
125
+ return { text: renderDerivedAbilities(derived), publicRead: derived.abilities.some(isPublicReadAbility) };
111
126
  }
112
127
  if (mode === 'commented') {
113
- return [
114
- ' // Declare the read model — db:check fails this model until its authz is authored:',
115
- ` // abilities: [can('read')], // public data`,
116
- ` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
117
- ' // private: true, // not part of the data API',
118
- ].join('\n');
128
+ return {
129
+ text: [
130
+ ' // Declare the read model — db:check fails this model until its authz is authored:',
131
+ ` // abilities: [can('read')], // public data`,
132
+ ` // abilities: [can('read', { owner: '<column>' })], // rows owned by a user`,
133
+ ' // private: true, // not part of the data API',
134
+ ].join('\n'),
135
+ // Nothing is stamped uncommented, so the model declares no read at all yet.
136
+ publicRead: false,
137
+ };
119
138
  }
120
139
  const preset = ABILITY_PRESETS[mode];
121
140
  if (!preset) {
122
141
  throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
123
142
  }
124
- return ` ${preset}`;
143
+ return { text: ` abilities: [${preset.join(', ')}],`, publicRead: preset.some(isPublicReadAbility) };
144
+ }
145
+
146
+ /**
147
+ * The `softDelete` line, when the model would otherwise fail to define.
148
+ *
149
+ * `defineModel` refuses to guess for a table that has `deleted_at` AND a public read: the
150
+ * guard decides what anonymous users can see, and neither default is safe. A pull renders
151
+ * `false` — LIVE reality is the truth being transcribed, and no live policy carries a guard
152
+ * nobody wrote. The comment says how to get the other one, so the decision is visible in the
153
+ * file rather than buried in a compiler convention.
154
+ */
155
+ function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
156
+ const hasColumn = table.columns.some((c) => c.name === 'deleted_at');
157
+ if (!hasColumn || !publicRead) return '';
158
+ return ` softDelete: false, // live reality: no policy filters deleted_at. true excludes soft-deleted rows from public reads.\n`;
125
159
  }
126
160
 
127
161
  /** `format_type` → the `field.*()` factory that produces it (the non-parameterized types). */
@@ -448,8 +482,9 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
448
482
  // reviewer must resolve about a model (and where the field-report consumer's codemod
449
483
  // put it, proving the position is mechanical-edit-friendly).
450
484
  const stanza = abilitiesStanza(abilities, table, liveAuthz);
485
+ const softDelete = softDeleteStanza(table, stanza.publicRead);
451
486
 
452
- return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza}\n fields: {\n${fields}\n },${constraints}\n});`;
487
+ return `export const ${modelVarName(table.table)} = defineModel('${bareName(table.table)}', {\n${stanza.text}\n${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
453
488
  }
454
489
 
455
490
  /** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
@@ -223,12 +223,35 @@ function defaultExpr(spec: FieldSpec): string | null {
223
223
  }
224
224
 
225
225
  /**
226
- * A Postgres array literal for a default `[]` `'{}'`, `['a','b']` `'{"a","b"}'`.
226
+ * True when PostgreSQL's own `array_out` would quote this element: it is empty, it is the
227
+ * literal `NULL` (which unquoted means the SQL null), or it contains a delimiter, a brace, a
228
+ * quote, a backslash, or whitespace.
229
+ */
230
+ function needsArrayQuote(v: string): boolean {
231
+ return v === '' || /^NULL$/i.test(v) || /[{},"\\\s]/.test(v);
232
+ }
233
+
234
+ /**
235
+ * A Postgres array literal for a default — `[]` → `'{}'`, `['User']` → `'{User}'`,
236
+ * `['a b']` → `'{"a b"}'`.
237
+ *
238
+ * QUOTING MATTERS: this must match `array_out` exactly, because the value round-trips
239
+ * through the live catalog. Quoting every element unconditionally produced `'{"User"}'`
240
+ * where PostgreSQL deparses `'{User}'`, so the declared and live defaults never compared
241
+ * equal and `db:generate` re-emitted the same `SET DEFAULT` on every run, forever.
242
+ *
243
+ * Backslashes escape before quotes, or `\` would become `\\"` — the escaping bug the
244
+ * previous version also carried (it escaped `"` and left `\` alone).
245
+ *
227
246
  * The introspected form carries a `::type[]` cast (`'{}'::text[]`) that `normalizeDefault`
228
247
  * strips, so the bare literal round-trips.
229
248
  */
230
249
  function arrayLiteral(arr: unknown[]): string {
231
- const elems = arr.map((v) => (typeof v === 'string' ? `"${v.replace(/"/g, '\\"')}"` : String(v)));
250
+ const elems = arr.map((v) => {
251
+ if (typeof v !== 'string') return String(v);
252
+ if (!needsArrayQuote(v)) return v;
253
+ return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
254
+ });
232
255
  return `'{${elems.join(',')}}'`;
233
256
  }
234
257
 
@@ -220,6 +220,20 @@ function argsWiden(from: number[], to: number[]): boolean {
220
220
  return to.every((v, i) => from[i] == null || v >= from[i]);
221
221
  }
222
222
 
223
+ /**
224
+ * Types whose entire meaning survives a trip through `text` — scalars and strings whose
225
+ * printed form IS their value. Everything else (postgis geometry, tsvector, hstore, ltree,
226
+ * arrays, composites, ranges, and any extension type) carries structure the cast discards,
227
+ * so it is deliberately NOT listed: the default for an unknown base is "not free".
228
+ */
229
+ const TEXT_ROUND_TRIPS = new Set([
230
+ 'text', 'character varying', 'character', 'citext', 'name', 'uuid', 'boolean',
231
+ 'smallint', 'integer', 'bigint', 'numeric', 'decimal', 'real', 'double precision',
232
+ 'date', 'time', 'time without time zone', 'time with time zone',
233
+ 'timestamp', 'timestamp without time zone', 'timestamp with time zone',
234
+ 'json', 'jsonb', 'inet', 'cidr', 'macaddr', 'bytea', 'interval',
235
+ ]);
236
+
223
237
  /**
224
238
  * Classify a column type change. Conservative by construction — anything not provably safe
225
239
  * or merely lossy falls through to `risky`, so a dangerous conversion is never mistaken for
@@ -230,7 +244,18 @@ export function classifyTypeChange(from: string, to: string): TypeChangeRisk {
230
244
  const f = parseType(from);
231
245
  const t = parseType(to);
232
246
 
233
- if (t.base === 'text') return 'safe'; // text is the universal sink: every type assignment-casts to it losslessly
247
+ // `→ text` is only free from a type text can round-trip. It IS the universal sink for the
248
+ // printable VALUE — but a structured type loses its meaning, not its characters: postgis
249
+ // `geometry` → text takes every spatial index and `ST_*` call with it, `tsvector` → text
250
+ // kills the full-text index, `hstore` → text destroys the key/value structure. None of that
251
+ // is recoverable by casting back.
252
+ //
253
+ // This mattered far past taxonomy. A `safe` verdict emits a BARE `SET DATA TYPE` with no
254
+ // WARNING, and the plan classifier only counts a narrowing when it sees the `USING` cast a
255
+ // non-safe verdict adds — so 48 postgis/tsvector/hstore/`timestamp(6)` rewrites in a real
256
+ // adoption plan were reported as additive with `destructive: 0`, which is also the number
257
+ // that gates `--confirm` + snapshot. Calling these lossy is what arms the gate.
258
+ if (t.base === 'text') return TEXT_ROUND_TRIPS.has(f.base) ? 'safe' : 'lossy';
234
259
 
235
260
  const fr = NUMERIC_RANK[f.base];
236
261
  const tr = NUMERIC_RANK[t.base];
@@ -71,22 +71,147 @@ export function classifyGeneratedStatements(statements: string[]): ClassifiedSta
71
71
  *
72
72
  * Recoverable metadata is never destructive: policies, grants, constraints,
73
73
  * defaults, nullability all re-declare from the models without touching a row.
74
+ *
75
+ * strips — the ONE exception to that rule. A REVOKE against a grantee NO model
76
+ * declares is not recoverable from the models: there is no declaration to
77
+ * re-derive it from, and the role simply loses its access. This is the
78
+ * deliberate case left over once ungoverned grantees stopped being revoked
79
+ * by accident (authz-reconcile's governedRoleSet) — someone put a role in
80
+ * `governedRoles` and then granted it nothing, which reads as "strip it".
81
+ * Deliberate removal deserves the same ceremony as a dropped column.
82
+ * Framework roles (anon/authenticated/admin/PUBLIC) are excluded: revoking
83
+ * from those is ordinary authz churn and re-declaring an ability restores it.
74
84
  */
75
85
  export interface DestructiveBreakdown {
76
86
  drops: string[];
77
87
  narrowings: string[];
88
+ /** REVOKEs that leave an undeclared grantee with nothing to restore it from. */
89
+ strips: string[];
78
90
  }
79
91
 
80
- export function classifyDestructive(executable: string[]): DestructiveBreakdown {
92
+ /**
93
+ * Statements that positively ADD — the only ones a plan may call additive.
94
+ *
95
+ * `additive` used to be computed as `executable.length - destructive`, which made it the
96
+ * DEFAULT rather than a finding: any statement the classifier did not recognize was reported
97
+ * as safe. A real adoption plan carrying 38 DROP POLICY, 43 REVOKE and 48 column rewrites
98
+ * described itself as `additive: 158, destructive: 0`, and since `destructive > 0` is what
99
+ * gates `--confirm` + snapshot + the approver set, the false zero did not merely mislabel the
100
+ * plan — it disarmed the gate.
101
+ *
102
+ * So the default is inverted. A statement is additive only when it matches one of these; a
103
+ * statement nobody recognized is `unclassified`, and `unclassified` is never additive. This is
104
+ * the same rule the reconciler already applies to grants: absence of knowledge means LEAVE IT
105
+ * ALONE and say so, never "assume it is fine".
106
+ */
107
+ const ADDITIVE_MATCHERS: RegExp[] = [
108
+ /^CREATE\s+(TABLE|POLICY|INDEX|UNIQUE\s+INDEX|SEQUENCE|EXTENSION|TYPE|SCHEMA)\b/,
109
+ /^GRANT\b/,
110
+ /^COMMENT\s+ON\b/,
111
+ /^ALTER\s+TABLE\s+[\s\S]+?\sADD\s+(COLUMN|CONSTRAINT|PRIMARY\s+KEY)\b/,
112
+ // Enabling/forcing RLS only ever RESTRICTS; it cannot widen access or lose a row.
113
+ /^ALTER\s+TABLE\s+[\s\S]+?\s(ENABLE|FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
114
+ // Relaxing nullability and setting a default add capability; they remove nothing.
115
+ /^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sDROP\s+NOT\s+NULL\b/,
116
+ /^ALTER\s+TABLE\s+[\s\S]+?\sALTER\s+COLUMN\s+[\s\S]+?\sSET\s+DEFAULT\b/,
117
+ ];
118
+
119
+ /**
120
+ * Statements that REMOVE an authorization without losing a row.
121
+ *
122
+ * Deliberately NOT folded into `destructive`, which means data loss and whose taxonomy is
123
+ * defended above — a dropped policy or grant re-declares from the models without touching a
124
+ * row. But it is emphatically not ADDITIVE either, and that was the lie: a `DROP POLICY` that
125
+ * removes a table's only read policy leaves the table returning zero rows, which is the single
126
+ * highest-consequence thing an adoption plan can carry and it was reported as an addition.
127
+ */
128
+ const AUTHZ_REMOVAL_MATCHERS: RegExp[] = [
129
+ /^DROP\s+POLICY\b/,
130
+ /^REVOKE\b/,
131
+ /^ALTER\s+TABLE\s+[\s\S]+?\s(DISABLE|NO\s+FORCE)\s+ROW\s+LEVEL\s+SECURITY\b/,
132
+ ];
133
+
134
+ /** The full partition of an executable stream. Every statement lands in exactly one bucket. */
135
+ export interface StatementPartition {
136
+ additive: string[];
137
+ drops: string[];
138
+ narrowings: string[];
139
+ strips: string[];
140
+ /** Authorization removed, no data lost — visible, never additive. */
141
+ authzRemovals: string[];
142
+ /** Not positively recognized. NEVER additive; needs a human before this plan is applied. */
143
+ unclassified: string[];
144
+ }
145
+
146
+ /** The first non-comment line, upper-cased — WARNING prologues must not hide the verb. */
147
+ function statementHead(statement: string): string {
148
+ return (statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? '')
149
+ .trim()
150
+ .toUpperCase();
151
+ }
152
+
153
+ /**
154
+ * Partition an executable stream into exactly one bucket per statement.
155
+ *
156
+ * Order matters: the data-loss buckets are consulted FIRST so a statement that both drops and
157
+ * revokes is counted at its most severe reading, and `unclassified` is the fallthrough rather
158
+ * than `additive`.
159
+ */
160
+ export function partitionStatements(
161
+ executable: string[],
162
+ opts: { declaredGrantees?: ReadonlySet<string> } = {},
163
+ ): StatementPartition {
164
+ const { drops, narrowings, strips } = classifyDestructive(executable, opts);
165
+ const destructive = new Set([...drops, ...narrowings, ...strips]);
166
+ const additive: string[] = [];
167
+ const authzRemovals: string[] = [];
168
+ const unclassified: string[] = [];
169
+
170
+ for (const statement of executable) {
171
+ if (destructive.has(statement)) continue;
172
+ const head = statementHead(statement);
173
+ if (AUTHZ_REMOVAL_MATCHERS.some((re) => re.test(head))) authzRemovals.push(statement);
174
+ else if (ADDITIVE_MATCHERS.some((re) => re.test(head))) additive.push(statement);
175
+ else unclassified.push(statement);
176
+ }
177
+ return { additive, drops, narrowings, strips, authzRemovals, unclassified };
178
+ }
179
+
180
+ /** `REVOKE … ON <table> FROM <grantee>;` → the grantee, or null when it is not a revoke. */
181
+ export function revokeTarget(statement: string): string | null {
182
+ // A quoted identifier can hold anything (`"Odd-Role"`, a reserved word, mixed case), and
183
+ // reading one as null would silently UNDER-classify a strip — the failure direction that
184
+ // matters, since it skips the ceremony rather than adding one.
185
+ const m = /^REVOKE\s+[\s\S]+?\sFROM\s+("[^"]*"|[A-Za-z_][A-Za-z0-9_$]*)\s*;?\s*$/i.exec(statement.trim());
186
+ return m ? m[1].replace(/^"|"$/g, '') : null;
187
+ }
188
+
189
+ export function classifyDestructive(
190
+ executable: string[],
191
+ opts: { declaredGrantees?: ReadonlySet<string> } = {},
192
+ ): DestructiveBreakdown {
81
193
  const drops: string[] = [];
82
194
  const narrowings: string[] = [];
195
+ const strips: string[] = [];
83
196
  for (const statement of executable) {
84
197
  if (/\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(statement)) drops.push(statement);
85
198
  else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
199
+ else if (opts.declaredGrantees) {
200
+ // Only classified when the caller supplied the declared side — without it there is
201
+ // no way to tell a strip from ordinary churn, and guessing would newly force
202
+ // --confirm on plans that never needed it.
203
+ const grantee = revokeTarget(statement);
204
+ if (grantee && !opts.declaredGrantees.has(grantee) && !FRAMEWORK_ROLES.has(grantee.toUpperCase())) {
205
+ strips.push(statement);
206
+ }
207
+ }
86
208
  }
87
- return { drops, narrowings };
209
+ return { drops, narrowings, strips };
88
210
  }
89
211
 
212
+ /** Roles the framework itself owns — revoking from these re-declares from an ability. */
213
+ const FRAMEWORK_ROLES = new Set(['PUBLIC', 'ANON', 'AUTHENTICATED', 'ADMIN']);
214
+
90
215
  /**
91
216
  * The kind histogram — what a plan IS, before anyone reads its SQL. A real edge can
92
217
  * run to four digits of statements (a consumer's adoption edge was 1,671), and the