@everystack/cli 0.4.35 → 0.4.36

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.
@@ -99,6 +99,14 @@ export interface SwapPlan {
99
99
  /** The schema the live data is renamed to before the retiring drop (outside the txn). */
100
100
  retiring: string;
101
101
  incoming: string;
102
+ /**
103
+ * EVERY retiring schema the swap produced — the base schema first, then each paired derived
104
+ * schema. The caller drops all of them after verify; dropping only `retiring` would strand the
105
+ * derived twins as permanent clutter that the next swap then collides with.
106
+ */
107
+ retiringSchemas: string[];
108
+ /** The paired derived schemas, in the order they were renamed. Empty for an unpaired swap. */
109
+ paired: string[];
102
110
  }
103
111
 
104
112
  export interface SwapOptions {
@@ -108,6 +116,27 @@ export interface SwapOptions {
108
116
  incoming?: string;
109
117
  /** Where the live schema is renamed. Default `<schema>_retiring`; pass a stamped name for uniqueness. */
110
118
  retiring?: string;
119
+ /**
120
+ * Derived schemas swapping ALONGSIDE the base schema (the paired swap). Each is renamed in the
121
+ * SAME transaction as the base, so a reader never sees a base schema paired with a derived layer
122
+ * built over the retiring one. Their incoming twins must already be built — see swap-pair.
123
+ */
124
+ paired?: string[];
125
+ /** Suffix for the paired schemas' incoming twins. Must match the build. Default `_incoming`. */
126
+ incomingSuffix?: string;
127
+ /** Suffix for the paired schemas' retiring names. Default `_retiring`. */
128
+ retiringSuffix?: string;
129
+ /**
130
+ * `GRANT USAGE ON SCHEMA` for every schema in the swap set (see renderSwapSchemaUsage), applied
131
+ * INSIDE the swap transaction after the renames.
132
+ *
133
+ * The incoming schemas carry no schema-level ACL — the base one is restored `--no-privileges`,
134
+ * the derived twins are freshly created — so a swap that re-applies only table authz commits a
135
+ * set of schemas no application role can enter. Found on real infrastructure: every API endpoint
136
+ * 500'd, and because PostgreSQL reports missing USAGE as ABSENCE the error read
137
+ * `relation "..." does not exist`, which points nowhere near the cause.
138
+ */
139
+ schemaUsage?: string[];
111
140
  }
112
141
 
113
142
  /**
@@ -135,15 +164,40 @@ export function renderSchemaSwap(models: ModelDescriptor[], opts: SwapOptions):
135
164
  .map((m) => compileTableContract(m));
136
165
  const authz = emitSwapAuthzSql({ tables: statsContracts, functions: [] });
137
166
 
167
+ // The paired derived schemas rename in the SAME transaction as the base. Order between pairs
168
+ // does not matter — nothing is resolved by name inside the transaction; the objects already
169
+ // bind their sources by OID, and a rename does not disturb an OID. What matters is that all of
170
+ // them commit together, so there is no instant where the new base serves under a derived layer
171
+ // still welded to the retiring one.
172
+ const paired = opts.paired ?? [];
173
+ const incomingSuffix = opts.incomingSuffix ?? '_incoming';
174
+ const retiringSuffix = opts.retiringSuffix ?? '_retiring';
175
+ const pairRenames: string[] = [];
176
+ const retiringSchemas = [retiring];
177
+ for (const p of paired) {
178
+ const pRetiring = `${p}${retiringSuffix}`;
179
+ const pIncoming = `${p}${incomingSuffix}`;
180
+ assertSafeSchema(p);
181
+ assertSafeSchema(pRetiring);
182
+ assertSafeSchema(pIncoming);
183
+ pairRenames.push(`ALTER SCHEMA "${p}" RENAME TO "${pRetiring}";`);
184
+ pairRenames.push(`ALTER SCHEMA "${pIncoming}" RENAME TO "${p}";`);
185
+ retiringSchemas.push(pRetiring);
186
+ }
187
+
138
188
  const statements = [
139
189
  ...fks.map((f) => f.dropSql),
140
190
  `ALTER SCHEMA "${schema}" RENAME TO "${retiring}";`,
141
191
  `ALTER SCHEMA "${incoming}" RENAME TO "${schema}";`,
192
+ ...pairRenames,
142
193
  ...fks.map((f) => f.addSql),
143
194
  ...authz,
195
+ // Schema-level USAGE last: the table grants above are dead without it, and it must ride the
196
+ // same transaction so there is no committed instant where the new schemas serve unreachable.
197
+ ...(opts.schemaUsage ?? []),
144
198
  ];
145
199
 
146
- return { statements, crossSchemaFks: fks, retiring, incoming };
200
+ return { statements, crossSchemaFks: fks, retiring, incoming, retiringSchemas, paired: [...paired] };
147
201
  }
148
202
 
149
203
  /** The drop of the retiring schema, run AFTER the swap transaction commits and verify passes. */
@@ -71,6 +71,41 @@ export interface ExecuteSwapOptions {
71
71
  * are still refused: consent cannot cover an object nothing knows how to rebuild.
72
72
  */
73
73
  rebuildDerived?: boolean;
74
+ /**
75
+ * The PAIRED swap: derived schemas that swap alongside the base schema, with their incoming
76
+ * twins already built over `<schema>_incoming` (see swap-pair). This is the zero-downtime path
77
+ * — the derived layer is recreated in parallel and renamed in the same transaction, so it is
78
+ * never absent and never welded to the retiring schema.
79
+ *
80
+ * Supplying this makes the CASCADE gate treat the whole set as one unit: an object depending on
81
+ * a paired schema is INSIDE the swap and rides along, exactly as an object inside the base
82
+ * schema always has.
83
+ */
84
+ paired?: string[];
85
+ /**
86
+ * Build the incoming derived layer. Runs after the artifact lands in `<schema>_incoming` and
87
+ * before the swap transaction, while every live schema keeps serving. Paired swaps only.
88
+ */
89
+ buildPairedDerived?: (runner: QueryRunner) => Promise<void>;
90
+ /**
91
+ * Schema-level USAGE applied inside the swap transaction (renderSwapSchemaUsage), and the
92
+ * (schema, role) pairs asserted after it commits. Without these a swap can land correct data
93
+ * behind schemas no application role can enter — see diffSchemaUsage.
94
+ */
95
+ schemaUsage?: string[];
96
+ schemaUsageRoles?: Array<{ schema: string; role: string }>;
97
+ /**
98
+ * The `_incoming`-qualified identities the paired build must produce (expectedIncomingObjects).
99
+ * Asserted before the rename: a partial derived layer refuses while live is untouched, instead
100
+ * of committing a swap that reports success with objects missing.
101
+ */
102
+ expectedDerived?: string[];
103
+ /**
104
+ * Record provenance for the objects the paired build created, once they are live at their final
105
+ * identities. Without it the next db:reconcile sees the whole layer as drift and rebuilds it —
106
+ * an expensive, ACCESS EXCLUSIVE no-op that surfaces days later on an unrelated run.
107
+ */
108
+ recordProvenance?: (runner: QueryRunner) => Promise<void>;
74
109
  }
75
110
 
76
111
  /** One table's landed-vs-live count, the intrinsic post-swap assertion's unit. */
@@ -158,19 +193,36 @@ export function dropDependentSql(d: CrossSchemaDependent): string {
158
193
  * Verified against a live PostgreSQL 16 fixture: a three-deep view chain, a function returning
159
194
  * SETOF a table, a view inside the schema, and an unrelated view in another schema.
160
195
  */
161
- export function crossSchemaDependentsQuery(schema: string, incoming: string, retiring: string): string {
196
+ export function crossSchemaDependentsQuery(
197
+ schema: string,
198
+ incoming: string,
199
+ retiring: string,
200
+ opts: { paired?: string[]; incomingSuffix?: string; retiringSuffix?: string } = {},
201
+ ): string {
162
202
  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})`;
203
+ const arr = (xs: string[]) => `ARRAY[${xs.map((s) => `'${q(s)}'`).join(',')}]`;
204
+ const paired = opts.paired ?? [];
205
+ const inSuffix = opts.incomingSuffix ?? '_incoming';
206
+ const reSuffix = opts.retiringSuffix ?? '_retiring';
207
+
208
+ // A PAIRED derived schema is INSIDE the swap: its objects are rebuilt into the incoming twin
209
+ // and renamed in the same transaction, so they are not at risk and must not trigger a refusal.
210
+ // They also seed the walk — something outside the set depending on a PAIRED schema is destroyed
211
+ // by that schema's rename + drop just as surely as a dependent of the base schema is.
212
+ const swapped = [schema, ...paired];
213
+ const twins = paired.flatMap((p) => [`${p}${inSuffix}`, `${p}${reSuffix}`]);
214
+ const skip = arr([incoming, retiring, ...twins]);
215
+ const notSelf = `n.nspname <> ALL (${arr(swapped)}) AND n.nspname <> ALL (${skip})`;
216
+ const seedIn = `= ANY (${arr(swapped)})`;
165
217
  return `WITH RECURSIVE seed AS (
166
218
  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)}'
219
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname ${seedIn}
168
220
  UNION
169
221
  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)}'
222
+ FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname ${seedIn}
171
223
  UNION
172
224
  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)}'
225
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname ${seedIn}
174
226
  ), closure AS (
175
227
  SELECT oid, cls FROM seed
176
228
  UNION
@@ -199,6 +251,33 @@ SELECT DISTINCT n.nspname, p.proname,
199
251
  ORDER BY 1, 2`;
200
252
  }
201
253
 
254
+ /**
255
+ * Every view / matview / function physically living in `schemas` — what a paired swap is about to
256
+ * replace wholesale.
257
+ *
258
+ * The paired swap renames a derived schema out and its rebuilt twin in, then drops the retiring
259
+ * one. The twin is built from the DECLARED descriptors, so anything live-but-undeclared in that
260
+ * schema has no counterpart in the twin and the drop destroys it. Pairing widens the set of
261
+ * schemas the gate considers "inside" the swap; this is what keeps that from becoming a licence to
262
+ * delete. Same reasoning as the undeclared-dependent refusal, applied one schema over.
263
+ */
264
+ export function schemaObjectsQuery(schemas: string[]): string {
265
+ const q = (s: string) => s.replace(/'/g, "''");
266
+ const list = `ARRAY[${schemas.map((s) => `'${q(s)}'`).join(',')}]`;
267
+ return `SELECT n.nspname AS dep_schema, c.relname AS dep_name,
268
+ CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE c.relkind::text END AS dep_kind,
269
+ ''::text AS dep_args
270
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
271
+ WHERE n.nspname = ANY (${list}) AND c.relkind IN ('v','m')
272
+ UNION
273
+ SELECT n.nspname, p.proname,
274
+ CASE p.prokind WHEN 'p' THEN 'procedure' ELSE 'function' END,
275
+ pg_get_function_identity_arguments(p.oid)
276
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
277
+ WHERE n.nspname = ANY (${list})
278
+ ORDER BY 1, 2`;
279
+ }
280
+
202
281
  /** The refusal message — names what is at risk, and which of the two refusals this is. */
203
282
  export function crossSchemaDependentsRefusal(
204
283
  schema: string,
@@ -253,6 +332,72 @@ export function isFatalVerdict(verdict: SwapVerdict): boolean {
253
332
  return checks.some((c) => !c.ok && (c.severity ?? 'fatal') === 'fatal');
254
333
  }
255
334
 
335
+ /**
336
+ * The reachability assertion: every role that holds a declared grant in a swapped schema must
337
+ * still have USAGE on it after the swap.
338
+ *
339
+ * The row-count assertion proves the DATA landed. This proves the data can be REACHED, which is a
340
+ * different failure and a quieter one: a swap that lands perfect data behind a schema no
341
+ * application role can enter reports success while every endpoint 500s. PostgreSQL surfaces a
342
+ * missing schema USAGE as ABSENCE rather than denial, so the error the operator sees is
343
+ * `relation "..." does not exist` — pointing at the table, not the grant.
344
+ *
345
+ * Pure: the impure half reads has_schema_privilege and hands the rows here.
346
+ */
347
+ export function diffSchemaUsage(rows: Array<{ schema: string; role: string; ok: boolean }>): SwapCheck[] {
348
+ return rows
349
+ .filter((r) => !r.ok)
350
+ .map((r) => ({
351
+ name: `usage:${r.schema}:${r.role}`,
352
+ ok: false,
353
+ severity: 'fatal' as const,
354
+ detail: `role ${r.role} has no USAGE on schema ${r.schema} after the swap — it holds declared grants there, so every one of them is unreachable (PostgreSQL will report the tables as "does not exist").`,
355
+ }));
356
+ }
357
+
358
+ /** `has_schema_privilege` for each (role, schema) pair — catalog-only, locks nothing. */
359
+ export function schemaUsageQuery(pairs: Array<{ schema: string; role: string }>): string {
360
+ const q = (s: string) => s.replace(/'/g, "''");
361
+ const values = pairs.map((p) => `('${q(p.schema)}','${q(p.role)}')`).join(',');
362
+ return `SELECT s AS schema, r AS role,
363
+ (EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r)
364
+ AND has_schema_privilege(r, s, 'USAGE')) AS ok
365
+ FROM (VALUES ${values}) AS t(s, r)`;
366
+ }
367
+
368
+ /**
369
+ * The derived-layer completeness assertion: every declared object in the swap set must actually
370
+ * EXIST in the incoming schemas after the build.
371
+ *
372
+ * The row-count assertion proves the base tables landed. This proves the derived layer did. They
373
+ * are different failures, and this one was silent: a build that throws is caught, but a build that
374
+ * quietly produces 38 of 79 objects sailed straight through to a successful swap. Seen in the
375
+ * field — a consumer's run printed the success line with half the layer missing.
376
+ *
377
+ * The same silent-success class as the original `--direct` swap reporting success without landing
378
+ * anything. That got a per-table count; the derived layer never got the equivalent until now.
379
+ *
380
+ * Pure: the caller reads the incoming catalog and hands both sides here.
381
+ */
382
+ export function diffDerivedObjects(expected: string[], present: string[]): SwapCheck[] {
383
+ const have = new Set(present);
384
+ const missing = expected.filter((id) => !have.has(id)).sort();
385
+ if (missing.length === 0) return [];
386
+ const shown = missing.slice(0, 20).join(', ');
387
+ return [{
388
+ name: 'derived:incomplete',
389
+ ok: false,
390
+ severity: 'fatal',
391
+ detail: `the incoming derived layer is INCOMPLETE — ${missing.length} of ${expected.length} declared object(s) were not built: `
392
+ + `${shown}${missing.length > 20 ? `, and ${missing.length - 20} more` : ''}.`,
393
+ }];
394
+ }
395
+
396
+ /** Every view / matview / function physically present in the given schemas, as `schema.name`. */
397
+ export function objectsPresentQuery(schemas: string[]): string {
398
+ return schemaObjectsQuery(schemas);
399
+ }
400
+
256
401
  /** Double-quote a Postgres identifier read from the catalog (embedded quotes doubled). */
257
402
  function quoteIdent(name: string): string {
258
403
  return `"${name.replace(/"/g, '""')}"`;
@@ -282,12 +427,43 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
282
427
  };
283
428
  }
284
429
 
285
- const plan = renderSchemaSwap(opts.models, { schema: opts.schema, incoming: opts.incoming, retiring: opts.retiring });
430
+ const paired = opts.paired ?? [];
431
+ const plan = renderSchemaSwap(opts.models, {
432
+ schema: opts.schema,
433
+ incoming: opts.incoming,
434
+ retiring: opts.retiring,
435
+ paired,
436
+ schemaUsage: opts.schemaUsage,
437
+ });
286
438
 
287
439
  // 1b. THE CASCADE GATE. Refuse before the snapshot — before anything moves at all — if any object
288
440
  // outside this schema depends on it. The swap's final DROP SCHEMA ... CASCADE would destroy
289
441
  // 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));
442
+ // 1a. PAIRED PRE-FLIGHT. A paired schema is replaced wholesale by a twin built from the declared
443
+ // descriptors, so anything live in it that is NOT declared has no counterpart in the twin
444
+ // and the retiring drop would destroy it. Pairing must never become a quieter way to lose an
445
+ // object than the unpaired gate already refuses to be.
446
+ if (paired.length > 0) {
447
+ const declared = new Set(opts.declaredIdentities ?? []);
448
+ const liveRows = await runner(schemaObjectsQuery(paired));
449
+ const orphans: CrossSchemaDependent[] = liveRows
450
+ .map((r: any) => ({ schema: r.dep_schema, name: r.dep_name, kind: r.dep_kind, args: r.dep_args || undefined }))
451
+ .filter((d: CrossSchemaDependent) => !declared.has(dependentIdentity(d)));
452
+ if (orphans.length > 0) {
453
+ const name = (d: CrossSchemaDependent) => `${d.schema}.${d.name} (${d.kind})`;
454
+ return {
455
+ status: 'refused-dependents',
456
+ reason: `${orphans.length} object(s) live in the paired schema(s) ${paired.join(', ')} but are NOT declared, so the rebuilt schema would not contain them and the swap would DESTROY them: `
457
+ + `${orphans.slice(0, 20).map(name).join(', ')}${orphans.length > 20 ? `, and ${orphans.length - 20} more` : ''}. `
458
+ + `A paired swap replaces the whole derived schema with one built from db/models — anything not declared there has nothing to rebuild it. `
459
+ + `Nothing was changed. Declare them in db/models, or drop them yourself if they are genuinely disposable.`,
460
+ dependents: orphans,
461
+ undeclaredDependents: orphans,
462
+ };
463
+ }
464
+ }
465
+
466
+ const depRows = await runner(crossSchemaDependentsQuery(opts.schema, plan.incoming, plan.retiring, { paired }));
291
467
  const dependents: CrossSchemaDependent[] = depRows.map((r: any) => ({
292
468
  schema: r.dep_schema, name: r.dep_name, kind: r.dep_kind, args: r.dep_args || undefined,
293
469
  }));
@@ -341,6 +517,51 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
341
517
  };
342
518
  }
343
519
 
520
+ // 3b. PAIRED: build the derived layer over the incoming base tables, while every live schema
521
+ // keeps serving. This is the whole point — the layer exists in full before the rename, so
522
+ // there is no window in which it is absent and no refresh that could read stale rows.
523
+ // A failure here leaves live untouched: nothing has been renamed yet.
524
+ if (opts.buildPairedDerived) {
525
+ const twins = paired.map((p) => `${p}_incoming`);
526
+ for (const t of twins) await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(t)} CASCADE`);
527
+ try {
528
+ await opts.buildPairedDerived(runner);
529
+ } catch (err: any) {
530
+ for (const t of twins) {
531
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(t)} CASCADE`); } catch { /* best effort */ }
532
+ }
533
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`); } catch { /* best effort */ }
534
+ return {
535
+ status: 'refused-integrity',
536
+ reason: `building the incoming derived layer failed, so the swap was NOT applied and live is untouched: ${String(err?.message ?? err)}. `
537
+ + `The declared descriptors must compose against the INCOMING base schema — if the artifact's shape no longer matches what the derived layer selects, that mismatch surfaces here rather than after the rename.`,
538
+ };
539
+ }
540
+ // COMPLETENESS. A build that throws is caught above; a build that quietly produces only some
541
+ // of its objects is not, and used to reach a successful swap. Assert BEFORE the rename, while
542
+ // live is still untouched — a refusal here costs nothing, where the same finding after the
543
+ // rename would cost a snapshot restore.
544
+ if (opts.expectedDerived?.length) {
545
+ const rows = await runner(objectsPresentQuery(twins.length ? [plan.incoming, ...twins] : [plan.incoming]));
546
+ const present = rows.map((r: any) => `${r.dep_schema}.${r.dep_name}`);
547
+ const checks = diffDerivedObjects(opts.expectedDerived, present);
548
+ if (checks.length > 0) {
549
+ for (const t of twins) {
550
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(t)} CASCADE`); } catch { /* best effort */ }
551
+ }
552
+ try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`); } catch { /* best effort */ }
553
+ return {
554
+ status: 'refused-integrity',
555
+ reason: `${checks[0].detail} The swap was NOT applied and live is untouched. `
556
+ + `A partial derived layer is what a swap that reports success while half the layer is missing looks like from the inside — this refuses instead.`,
557
+ verdict: { ok: false, checks },
558
+ };
559
+ }
560
+ log(`derived layer complete: ${opts.expectedDerived.length} declared object(s) present in the incoming schemas`);
561
+ }
562
+ log(`built the incoming derived layer into ${twins.join(', ')} — live still serving the old one`);
563
+ }
564
+
344
565
  // 4. The atomic swap. A FK re-validation failure (a bad artifact) rolls the whole thing back.
345
566
  await runner('BEGIN');
346
567
  try {
@@ -372,6 +593,26 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
372
593
  };
373
594
  }
374
595
 
596
+ // 5a-bis. The REACHABILITY assertion. Counts prove the data landed; this proves an application
597
+ // role can still get to it. A swap that commits perfect data behind a schema nobody can
598
+ // enter looks like a success and reads, at the app, as every table having vanished.
599
+ if (opts.schemaUsageRoles?.length) {
600
+ const usageRows = await runner(schemaUsageQuery(opts.schemaUsageRoles));
601
+ const usageChecks = diffSchemaUsage(
602
+ usageRows.map((r: any) => ({ schema: r.schema, role: r.role, ok: r.ok === true })),
603
+ );
604
+ if (usageChecks.length > 0) {
605
+ if (opts.rollbackToSnapshot) await opts.rollbackToSnapshot();
606
+ return {
607
+ status: 'rolled-back-verify',
608
+ reason: `the swap landed but the schemas are unreachable: ${usageChecks.map((c) => c.detail).join(' ')} `
609
+ + `${opts.rollbackToSnapshot ? 'Rolled back to the pre-swap snapshot.' : 'NO snapshot was configured to roll back to.'}`,
610
+ verdict: { ok: false, checks: usageChecks },
611
+ };
612
+ }
613
+ log(`reachability: ${opts.schemaUsageRoles.length} (role, schema) pair(s) verified — the app can still read through the swapped schemas`);
614
+ }
615
+
375
616
  // 5b. Verify (post-commit). Fatal → roll back to the snapshot; warn → surface only.
376
617
  let verdict: SwapVerdict | undefined;
377
618
  if (opts.verify) {
@@ -386,8 +627,25 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
386
627
  }
387
628
  }
388
629
 
389
- // 6. Drop the retiring schema — the swap is committed and verified.
390
- await runner(dropRetiringSql(plan.retiring));
630
+ // 6. Drop EVERY retiring schema — the swap is committed and verified. On a paired swap that is
631
+ // the base plus each derived twin; dropping only the base would strand the old derived
632
+ // schemas, which the next swap then collides with on its own rename.
633
+ for (const r of plan.retiringSchemas) await runner(dropRetiringSql(r));
634
+
635
+ // 7. Bookkeeping. The build created the derived layer with raw DDL; the reconciler knows nothing
636
+ // about it until this runs. Recorded AFTER the retiring drop so the catalog read behind it
637
+ // sees only the live objects. A failure here does not undo a good swap — the data is correct
638
+ // and serving; the cost is a redundant rebuild on the next reconcile, which is what this
639
+ // prevents rather than something it can break.
640
+ if (opts.recordProvenance) {
641
+ try {
642
+ await opts.recordProvenance(runner);
643
+ } catch (err: any) {
644
+ log(`WARNING: the swap succeeded but recording provenance failed: ${String(err?.message ?? err)}. `
645
+ + `The derived layer is live and correct; the next db:reconcile will rebuild it needlessly. `
646
+ + `Run db:reconcile --rebaseline to record it without DDL.`);
647
+ }
648
+ }
391
649
 
392
650
  const warnings = (verdict?.checks ?? []).filter((c) => !c.ok && c.severity === 'warn');
393
651
  return { status: 'swapped', verdict, ...(warnings.length ? { warnings } : {}) };