@everystack/cli 0.4.33 → 0.4.35

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.
@@ -41,6 +41,21 @@ export interface ParsedGrants {
41
41
  const GRANT_RE = /^GRANT\s+([A-Z]+)\s*(\([^)]*\))?\s+ON\s+(.+?)\s+TO\s+(.+)$/i;
42
42
  const REVOKE_PUBLIC_RE = /^REVOKE\s+ALL\s+ON\s+(.+?)\s+FROM\s+PUBLIC$/i;
43
43
 
44
+ /**
45
+ * A PLAIN (non-column-scoped) grant/revoke attachment — a table/function-level `GRANT … TO …` with
46
+ * no column list, or a `REVOKE ALL … FROM PUBLIC`. These are the grants `diffObjectGrants` can fully
47
+ * express, so `bodyHash` excludes them: an authz-only change to them applies as a bare regrant, never
48
+ * a rebuild. Column-scoped grants (`GRANT SELECT ("a","b") …`) are NOT plain — attacl is not
49
+ * introspected, so a change to them must still rebuild; they stay in `bodyHash`. Non-grant
50
+ * attachments (index/comment) are structural and always return false.
51
+ */
52
+ export function isPlainGrantAttachment(a: Attachment): boolean {
53
+ if (a.kind !== 'grant') return false;
54
+ if (REVOKE_PUBLIC_RE.test(a.sql)) return true;
55
+ const g = a.sql.match(GRANT_RE);
56
+ return g !== null && !g[2]; // g[2] is the (columns) group — present => column-scoped => not plain
57
+ }
58
+
44
59
  /** The declared grant contract, read back from an object's own grant attachments. */
45
60
  export function parseGrantAttachments(attachments: readonly Attachment[]): ParsedGrants {
46
61
  const grants: Record<string, Set<string>> = {};
@@ -81,6 +81,9 @@ export interface ProvenanceRow {
81
81
  kind?: string;
82
82
  /** How to remove what was recorded — the C1 closure: removal never orphans. */
83
83
  dropSql?: string;
84
+ /** The source hash MINUS plain grants at record time. Present only once the reconciler has
85
+ * re-recorded the row post-fix; absent on legacy rows → the planner falls back to rebuild. */
86
+ bodyHash?: string;
84
87
  }
85
88
 
86
89
  export interface DerivedCatalog {
@@ -514,6 +517,7 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
514
517
  defHash: String(r.def_hash ?? ''),
515
518
  ...(r.extra?.kind != null ? { kind: String(r.extra.kind) } : {}),
516
519
  ...(r.extra?.drop_sql != null ? { dropSql: String(r.extra.drop_sql) } : {}),
520
+ ...(r.extra?.body_hash != null ? { bodyHash: String(r.extra.body_hash) } : {}),
517
521
  }))
518
522
  .sort((a, b) => a.identity.localeCompare(b.identity));
519
523
 
@@ -29,7 +29,7 @@ import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
29
29
  import { triggerDropSql } from './derived-apply.js';
30
30
  import { parseGrantAttachments, diffObjectGrants } from './derived-grants.js';
31
31
 
32
- export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune';
32
+ export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune' | 'backfill';
33
33
 
34
34
  export interface ReconcileAction {
35
35
  action: ReconcileVerb;
@@ -192,6 +192,9 @@ export function planReconcile(
192
192
  const baseline: string[] = [];
193
193
  const rebaseline: string[] = [];
194
194
  const prune: string[] = [];
195
+ /** Up-to-date objects (src+live unchanged) whose provenance predates body_hash — record it once
196
+ * so a FUTURE authz-only change is a regrant, not a rebuild. Record-only, no DDL; arms the fix. */
197
+ const backfill: string[] = [];
195
198
 
196
199
  // -------------------------------------------------------------------------
197
200
  // The decision table.
@@ -289,14 +292,34 @@ export function planReconcile(
289
292
  continue;
290
293
  }
291
294
  if (srcChanged) {
295
+ // Authz-only change: the BODY (and live) is untouched, only plain grants moved — bodyHash
296
+ // matches provenance. Route it like a rebaseline: no rebuild, re-record the (new) source +
297
+ // body hash, and the grant-drift pass below (which covers skipped ∪ rebaseline) applies the
298
+ // bare GRANT/REVOKE delta. Gate is strict — any leg missing falls through to today's rebuild:
299
+ // - bodyHash recorded AND equal → the change is grants-only, not body (armed row only)
300
+ // - live grants introspected (≠ undef) → diffObjectGrants can actually run (absent ≠ empty)
301
+ // - no column-scoped grants → attacl isn't drift-applied, so those MUST rebuild
302
+ const parsed = parseGrantAttachments(src.attachments);
303
+ const authzOnly =
304
+ prov.bodyHash != null &&
305
+ src.bodyHash === prov.bodyHash &&
306
+ liveObj.grants !== undefined &&
307
+ !parsed.hasColumnGrants;
292
308
  // The live object is verifiably untouched here (liveChanged was handled above), so
293
309
  // under rebaseline a source re-render is bookkeeping: re-record the source hash,
294
310
  // rebuild nothing, cascade nothing.
295
- if (options.rebaseline) rebaseline.push(src.identity);
311
+ if (options.rebaseline || authzOnly) rebaseline.push(src.identity);
296
312
  else if (isRelation(src.kind)) rebuild.set(src.identity, 'source changed');
297
313
  else fnReplace.set(src.identity, 'source changed');
298
314
  continue;
299
315
  }
316
+ // Up-to-date (src + live both match provenance). If provenance predates body_hash, record it
317
+ // once — a record-only backfill that ARMS the authz-only fast path for a future grant change.
318
+ // Converges: after this run the row has body_hash, so it skips cleanly next time.
319
+ if (prov.bodyHash == null) {
320
+ backfill.push(src.identity);
321
+ continue;
322
+ }
300
323
  skipped.push(src.identity);
301
324
  }
302
325
 
@@ -336,7 +359,7 @@ export function planReconcile(
336
359
  // Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
337
360
  // and running the checks NOW means a migration converges (grants ride the same apply)
338
361
  // instead of surfacing on the next plan.
339
- const lensSet = new Set([...skipped, ...rebaseline]);
362
+ const lensSet = new Set([...skipped, ...rebaseline, ...backfill]);
340
363
  for (const src of source.objects) {
341
364
  if (!lensSet.has(src.identity)) continue;
342
365
  if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
@@ -485,6 +508,7 @@ export function planReconcile(
485
508
  .map(([id, reason]) => actionFor(id, 'refresh', reason)),
486
509
  ...baseline.sort().map((id) => actionFor(id, 'baseline', 'recording provenance for an existing match (trusted once, explicitly)')),
487
510
  ...rebaseline.sort().map((id) => actionFor(id, 'rebaseline', 'source re-rendered — live verified untouched (defHash match); provenance re-recorded, no rebuild')),
511
+ ...backfill.sort().map((id) => actionFor(id, 'backfill', 'recording body_hash on an up-to-date object — arms the authz-only fast path, no rebuild')),
488
512
  ...prune.sort().map((id) => ({
489
513
  action: 'prune' as const, identity: id, kind: 'view' as DerivedKind,
490
514
  reason: 'stale provenance — object gone from both source and database',
@@ -48,6 +48,14 @@ export interface SourceObject {
48
48
  attachments: Attachment[];
49
49
  /** sha256 of the normalized CREATE + attachments — comment/whitespace edits do not change it. */
50
50
  hash: string;
51
+ /**
52
+ * sha256 of the normalized CREATE + attachments EXCEPT plain (non-column-scoped) grants — the
53
+ * "would a rebuild be required" hash. Equal across an authz-only change (plain grants moved, body
54
+ * unchanged), so the reconciler can route that to a bare GRANT delta instead of a matview rebuild.
55
+ * Column-scoped grants stay IN (attacl not introspected → they must rebuild). `hash` stays the
56
+ * full/stable formula for provenance compat; this is the additional discriminator.
57
+ */
58
+ bodyHash: string;
51
59
  /** Source file this object came from (descriptors: the declared-models marker). */
52
60
  file: string;
53
61
  /** Position in the concatenated source — a valid dependency order by convention. */
@@ -54,11 +54,181 @@ export interface ExecuteSwapOptions {
54
54
  verify?: () => Promise<SwapVerdict>;
55
55
  /** Restore the stage to the snapshot from step 2 when verify is fatal. */
56
56
  rollbackToSnapshot?: () => Promise<void>;
57
+ /** Progress/instrumentation sink (per-table landed rows, the count assertion). Defaults to a no-op. */
58
+ log?: (msg: string) => void;
59
+ /**
60
+ * `schema.name` of every object the DECLARED derived layer can regenerate (db:reconcile's source
61
+ * of truth). A dependent in this set is safe to drop: reconcile rebuilds it from the descriptor.
62
+ * A dependent NOT in it can never be rebuilt, so it is refused unconditionally.
63
+ */
64
+ declaredIdentities?: string[];
65
+ /**
66
+ * Informed consent to DROP the dependent derived objects as part of the swap (`--rebuild-derived`).
67
+ *
68
+ * This is the intended workflow — swap the schema, then regenerate the derived layer — not a
69
+ * workaround. The gate exists to stop SILENT destruction, so an operator who says "drop them, I
70
+ * will reconcile after" has supplied exactly the consent that was missing. Undeclared dependents
71
+ * are still refused: consent cannot cover an object nothing knows how to rebuild.
72
+ */
73
+ rebuildDerived?: boolean;
74
+ }
75
+
76
+ /** One table's landed-vs-live count, the intrinsic post-swap assertion's unit. */
77
+ export interface RowCount {
78
+ table: string;
79
+ incoming: number;
80
+ live: number;
81
+ }
82
+
83
+ /**
84
+ * The intrinsic count assertion: after the rename, the LIVE schema must hold exactly what the
85
+ * artifact landed in the incoming schema. A no-op swap (the rename silently not taking) leaves the
86
+ * OLD table under `<schema>.<table>` — its count differs from what was restored, and this catches it.
87
+ * A table present in incoming but absent live (a partial rename) is a mismatch too. Pure — the impure
88
+ * path reads the two count maps and hands them here, so the verdict wording is unit-tested.
89
+ */
90
+ export function diffRowCounts(counts: RowCount[]): SwapCheck[] {
91
+ const bad: SwapCheck[] = [];
92
+ for (const c of counts) {
93
+ if (c.incoming !== c.live) {
94
+ bad.push({
95
+ name: `rowcount:${c.table}`,
96
+ ok: false,
97
+ detail: `artifact landed ${c.incoming} rows but live ${c.table} has ${c.live} after swap — the swap did not take effect (or landed partial data).`,
98
+ severity: 'fatal',
99
+ });
100
+ }
101
+ }
102
+ return bad;
103
+ }
104
+
105
+ /** One object OUTSIDE the swapped schema that depends on something inside it. */
106
+ export interface CrossSchemaDependent {
107
+ schema: string;
108
+ name: string;
109
+ /** `view`, `materialized view`, `function`, `procedure`. */
110
+ kind: string;
111
+ /** Identity arguments for a function/procedure — DROP FUNCTION needs them when overloaded. */
112
+ args?: string;
113
+ }
114
+
115
+ /** `schema.name`, the join key against the declared derived layer's identities. */
116
+ export function dependentIdentity(d: CrossSchemaDependent): string {
117
+ return `${d.schema}.${d.name}`;
118
+ }
119
+
120
+ /**
121
+ * The DROP for one dependent. CASCADE is safe here and ordering does not matter: the dependent set
122
+ * is a transitive CLOSURE, so anything CASCADE could reach is already in the set being dropped.
123
+ */
124
+ export function dropDependentSql(d: CrossSchemaDependent): string {
125
+ const ref = `"${d.schema.replace(/"/g, '""')}"."${d.name.replace(/"/g, '""')}"`;
126
+ if (d.kind === 'materialized view') return `DROP MATERIALIZED VIEW IF EXISTS ${ref} CASCADE;`;
127
+ if (d.kind === 'view') return `DROP VIEW IF EXISTS ${ref} CASCADE;`;
128
+ // Functions can be overloaded, so the identity arguments are load-bearing.
129
+ const kw = d.kind === 'procedure' ? 'PROCEDURE' : 'FUNCTION';
130
+ return `DROP ${kw} IF EXISTS ${ref}(${d.args ?? ''}) CASCADE;`;
131
+ }
132
+
133
+ /**
134
+ * Objects in OTHER schemas that depend on the swapped schema — the CASCADE blast radius.
135
+ *
136
+ * Why this gate exists. The swap ends with `DROP SCHEMA <schema>_retiring CASCADE`. PostgreSQL
137
+ * binds a view, matview or function to its dependencies by OID, so the rename carries every such
138
+ * binding onto the RETIRING schema — and CASCADE then drops them. Silently. The swap already
139
+ * understands this for foreign keys (it drops and recreates them around the rename, see
140
+ * crossSchemaForeignKeys) and was blind to everything else.
141
+ *
142
+ * The walk must be TRANSITIVE, and the first version was not. Matching only DIRECT dependents,
143
+ * given `stats.t` ← `other.v1` ← `other.v2`, it found v1 and missed v2 entirely. v2 binds to v1 by
144
+ * OID, so when v1 goes so does v2. Worse: when the only direct dependent lives INSIDE the swapped
145
+ * schema, a direct-only query reports NOTHING while CASCADE still destroys everything downstream.
146
+ *
147
+ * So: seed the closure with every relation, type and function in the schema, then walk pg_depend
148
+ * forward to fixpoint. Views and matviews reach their dependencies through pg_rewrite, so an edge
149
+ * is normalized to the OWNING relation (`rw.ev_class`), not the rewrite rule. Types are seeded
150
+ * because `RETURNS SETOF <schema>.<table>` records against the table's composite TYPE rather than
151
+ * the table — a pg_class-only walk misses every such function.
152
+ *
153
+ * Objects INSIDE the schema are excluded (they ride along with the swap), as are the incoming and
154
+ * retiring schemas (transient, and a leftover `<schema>_incoming` from a failed run must not make
155
+ * the gate refuse forever). Foreign keys never surface here: they are pg_constraint, and the swap
156
+ * drops and recreates them itself.
157
+ *
158
+ * Verified against a live PostgreSQL 16 fixture: a three-deep view chain, a function returning
159
+ * SETOF a table, a view inside the schema, and an unrelated view in another schema.
160
+ */
161
+ export function crossSchemaDependentsQuery(schema: string, incoming: string, retiring: string): string {
162
+ const q = (s: string) => s.replace(/'/g, "''");
163
+ const skip = `ARRAY['${q(incoming)}','${q(retiring)}']`;
164
+ const notSelf = `n.nspname <> '${q(schema)}' AND n.nspname <> ALL (${skip})`;
165
+ return `WITH RECURSIVE seed AS (
166
+ SELECT c.oid AS oid, 'pg_class'::regclass AS cls
167
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '${q(schema)}'
168
+ UNION
169
+ SELECT t.oid, 'pg_type'::regclass
170
+ FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = '${q(schema)}'
171
+ UNION
172
+ SELECT p.oid, 'pg_proc'::regclass
173
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = '${q(schema)}'
174
+ ), closure AS (
175
+ SELECT oid, cls FROM seed
176
+ UNION
177
+ SELECT CASE WHEN d.classid = 'pg_rewrite'::regclass THEN rw.ev_class ELSE d.objid END,
178
+ CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'::regclass ELSE d.classid END
179
+ FROM closure cl
180
+ JOIN pg_depend d ON d.refclassid = cl.cls AND d.refobjid = cl.oid
181
+ LEFT JOIN pg_rewrite rw ON rw.oid = d.objid AND d.classid = 'pg_rewrite'::regclass
182
+ WHERE d.classid IN ('pg_rewrite'::regclass, 'pg_class'::regclass, 'pg_proc'::regclass, 'pg_type'::regclass)
183
+ )
184
+ SELECT DISTINCT n.nspname AS dep_schema, c.relname AS dep_name,
185
+ CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE c.relkind::text END AS dep_kind,
186
+ ''::text AS dep_args
187
+ FROM closure cl
188
+ JOIN pg_class c ON c.oid = cl.oid AND cl.cls = 'pg_class'::regclass
189
+ JOIN pg_namespace n ON n.oid = c.relnamespace
190
+ WHERE c.relkind IN ('v', 'm') AND ${notSelf}
191
+ UNION
192
+ SELECT DISTINCT n.nspname, p.proname,
193
+ CASE p.prokind WHEN 'p' THEN 'procedure' ELSE 'function' END,
194
+ pg_get_function_identity_arguments(p.oid)
195
+ FROM closure cl
196
+ JOIN pg_proc p ON p.oid = cl.oid AND cl.cls = 'pg_proc'::regclass
197
+ JOIN pg_namespace n ON n.oid = p.pronamespace
198
+ WHERE ${notSelf}
199
+ ORDER BY 1, 2`;
200
+ }
201
+
202
+ /** The refusal message — names what is at risk, and which of the two refusals this is. */
203
+ export function crossSchemaDependentsRefusal(
204
+ schema: string,
205
+ deps: CrossSchemaDependent[],
206
+ opts: { rebuildRequested?: boolean; undeclared?: CrossSchemaDependent[] } = {},
207
+ ): string {
208
+ const name = (d: CrossSchemaDependent) => `${d.schema}.${d.name} (${d.kind})`;
209
+ const undeclared = opts.undeclared ?? [];
210
+
211
+ // Refusal 1: consent was given, but something in the set cannot be regenerated.
212
+ if (opts.rebuildRequested && undeclared.length > 0) {
213
+ return `${undeclared.length} of the ${deps.length} object(s) depending on ${schema} are NOT declared, so nothing can regenerate them: `
214
+ + `${undeclared.slice(0, 20).map(name).join(', ')}${undeclared.length > 20 ? `, and ${undeclared.length - 20} more` : ''}. `
215
+ + `--rebuild-derived drops dependents on the promise that db:reconcile --apply rebuilds them, and that promise does not hold for an undeclared object — dropping it would destroy it. `
216
+ + `Nothing was changed. Declare them in db/models (then db:reconcile --apply), or drop them yourself if they are genuinely disposable.`;
217
+ }
218
+
219
+ // Refusal 2: no consent given. Explain the danger and the flag.
220
+ const listed = deps.slice(0, 20).map(name).join(', ');
221
+ const more = deps.length > 20 ? `, and ${deps.length - 20} more` : '';
222
+ return `${deps.length} object(s) outside ${schema} depend on it, and the swap would DESTROY them: ${listed}${more}. `
223
+ + `PostgreSQL binds views, matviews and functions to their dependencies by OID, so renaming ${schema} carries those bindings onto ${schema}_retiring, and the swap's final DROP SCHEMA ... CASCADE drops them silently. `
224
+ + `Nothing was changed — this refusal happens before the snapshot. `
225
+ + `If regenerating them is what you want, re-run with --rebuild-derived: the swap drops them in the same transaction and db:reconcile --apply rebuilds them from the declared descriptors.`;
57
226
  }
58
227
 
59
228
  export type SwapStatus =
60
229
  | 'swapped'
61
230
  | 'refused-fingerprint'
231
+ | 'refused-dependents'
62
232
  | 'refused-integrity'
63
233
  | 'rolled-back-verify';
64
234
 
@@ -69,6 +239,10 @@ export interface SwapResult {
69
239
  verdict?: SwapVerdict;
70
240
  /** Warn-severity checks that did NOT trigger rollback (surfaced). */
71
241
  warnings?: SwapCheck[];
242
+ /** On `refused-dependents`: exactly what CASCADE would have destroyed. */
243
+ dependents?: CrossSchemaDependent[];
244
+ /** The subset nothing can regenerate — the reason --rebuild-derived was not enough. */
245
+ undeclaredDependents?: CrossSchemaDependent[];
72
246
  }
73
247
 
74
248
  /** Does the verdict force a rollback? Any failing check whose severity is fatal (the default). */
@@ -79,7 +253,27 @@ export function isFatalVerdict(verdict: SwapVerdict): boolean {
79
253
  return checks.some((c) => !c.ok && (c.severity ?? 'fatal') === 'fatal');
80
254
  }
81
255
 
256
+ /** Double-quote a Postgres identifier read from the catalog (embedded quotes doubled). */
257
+ function quoteIdent(name: string): string {
258
+ return `"${name.replace(/"/g, '""')}"`;
259
+ }
260
+
261
+ /** The base tables physically present in `schema` (what a restore actually landed / what live serves). */
262
+ async function baseTablesIn(runner: QueryRunner, schema: string): Promise<string[]> {
263
+ const rows = await runner(
264
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = '${schema.replace(/'/g, "''")}' AND table_type = 'BASE TABLE' ORDER BY table_name`,
265
+ );
266
+ return rows.map((r: any) => r.table_name as string);
267
+ }
268
+
269
+ /** `count(*)` for one table, as a number. */
270
+ async function countRows(runner: QueryRunner, schema: string, table: string): Promise<number> {
271
+ const rows = await runner(`SELECT count(*)::bigint AS n FROM ${quoteIdent(schema)}.${quoteIdent(table)}`);
272
+ return Number(rows[0]?.n ?? 0);
273
+ }
274
+
82
275
  export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions): Promise<SwapResult> {
276
+ const log = opts.log ?? (() => {});
83
277
  // 1. Fingerprint gate — declared-vs-declared: does the artifact and the target agree on the shape.
84
278
  if (opts.artifactFingerprint !== opts.declaredFingerprint) {
85
279
  return {
@@ -90,12 +284,63 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
90
284
 
91
285
  const plan = renderSchemaSwap(opts.models, { schema: opts.schema, incoming: opts.incoming, retiring: opts.retiring });
92
286
 
287
+ // 1b. THE CASCADE GATE. Refuse before the snapshot — before anything moves at all — if any object
288
+ // outside this schema depends on it. The swap's final DROP SCHEMA ... CASCADE would destroy
289
+ // them silently, and a silent destroyer is the worst thing this command could be.
290
+ const depRows = await runner(crossSchemaDependentsQuery(opts.schema, plan.incoming, plan.retiring));
291
+ const dependents: CrossSchemaDependent[] = depRows.map((r: any) => ({
292
+ schema: r.dep_schema, name: r.dep_name, kind: r.dep_kind, args: r.dep_args || undefined,
293
+ }));
294
+ if (dependents.length > 0) {
295
+ const declared = new Set(opts.declaredIdentities ?? []);
296
+ const undeclared = dependents.filter((d) => !declared.has(dependentIdentity(d)));
297
+
298
+ // Consent covers only what can be rebuilt. An undeclared dependent is refused even WITH
299
+ // --rebuild-derived: dropping it destroys it, because nothing knows how to recreate it.
300
+ if (!opts.rebuildDerived || undeclared.length > 0) {
301
+ return {
302
+ status: 'refused-dependents',
303
+ reason: crossSchemaDependentsRefusal(opts.schema, dependents, {
304
+ rebuildRequested: !!opts.rebuildDerived,
305
+ undeclared,
306
+ }),
307
+ dependents,
308
+ undeclaredDependents: undeclared.length ? undeclared : undefined,
309
+ };
310
+ }
311
+
312
+ // Consented, and every one is declared: drop them inside the swap transaction, before the
313
+ // rename. db:reconcile --apply regenerates them from the descriptors afterwards.
314
+ // Future tense on purpose: these DROPs ride the swap transaction, which is several steps away
315
+ // and may never run. Announcing them as done was misleading on a restore that failed first.
316
+ log(`${dependents.length} declared derived object(s) depend on ${opts.schema} and WILL BE DROPPED by the swap transaction (regenerate with db:reconcile --apply). Nothing is dropped unless the swap itself commits.`);
317
+ plan.statements.unshift(...dependents.map(dropDependentSql));
318
+ }
319
+
93
320
  // 2. Snapshot (the rollback point) before anything destructive.
94
321
  if (opts.snapshot) await opts.snapshot();
95
322
 
96
- // 3. Land the incoming schema while live keeps serving.
323
+ // 3. Land the incoming schema while live keeps serving. Drop a stale incoming FIRST so a retry
324
+ // after a failed restore is idempotent (a half-landed <schema>_incoming from a prior crash
325
+ // would otherwise collide on the artifact's CREATE SCHEMA and fail every retry).
326
+ await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`);
97
327
  await opts.applyIncoming(runner);
98
328
 
329
+ // Capture what the artifact actually landed, per table — the count the live schema must match
330
+ // after the swap. Empty here means the restore created the schema but no tables: a silent
331
+ // partial that must fail, not pass.
332
+ const incomingTables = await baseTablesIn(runner, plan.incoming);
333
+ const expected = new Map<string, number>();
334
+ for (const t of incomingTables) expected.set(t, await countRows(runner, plan.incoming, t));
335
+ log(`landed ${incomingTables.length} table(s) into ${plan.incoming}: ${incomingTables.map((t) => `${t}=${expected.get(t)}`).join(', ') || '(none)'}`);
336
+ if (incomingTables.length === 0) {
337
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`); } catch { /* best effort */ }
338
+ return {
339
+ status: 'refused-integrity',
340
+ reason: `the restore landed NO base tables into ${plan.incoming} — the artifact did not apply (a restore that reported success but wrote nothing). The swap was NOT applied; live is untouched.`,
341
+ };
342
+ }
343
+
99
344
  // 4. The atomic swap. A FK re-validation failure (a bad artifact) rolls the whole thing back.
100
345
  await runner('BEGIN');
101
346
  try {
@@ -111,7 +356,23 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
111
356
  };
112
357
  }
113
358
 
114
- // 5. Verify (post-commit). Fatal roll back to the snapshot; warn surface only.
359
+ // 5a. The intrinsic count assertion (ALWAYS runs this is the safety net a silent no-op needs).
360
+ // After the rename, live must hold exactly what landed. A mismatch means the swap did not
361
+ // take effect; roll back to the snapshot and refuse LOUD.
362
+ const counts: RowCount[] = [];
363
+ for (const t of incomingTables) counts.push({ table: t, incoming: expected.get(t)!, live: await countRows(runner, opts.schema, t) });
364
+ log(`post-swap live counts: ${counts.map((c) => `${c.table}=${c.live}`).join(', ')}`);
365
+ const countChecks = diffRowCounts(counts);
366
+ if (countChecks.length > 0) {
367
+ if (opts.rollbackToSnapshot) await opts.rollbackToSnapshot();
368
+ return {
369
+ status: 'rolled-back-verify',
370
+ reason: `post-swap row counts do not match the artifact — the swap did not land: ${countChecks.map((c) => c.detail).join(' ')} ${opts.rollbackToSnapshot ? 'Rolled back to the pre-swap snapshot.' : 'NO snapshot was configured to roll back to.'}`,
371
+ verdict: { ok: false, checks: countChecks },
372
+ };
373
+ }
374
+
375
+ // 5b. Verify (post-commit). Fatal → roll back to the snapshot; warn → surface only.
115
376
  let verdict: SwapVerdict | undefined;
116
377
  if (opts.verify) {
117
378
  verdict = await opts.verify();