@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.
- package/package.json +1 -1
- package/src/cli/commands/db-swap.ts +114 -5
- package/src/cli/derived-introspect.ts +30 -0
- package/src/cli/derived-plan.ts +16 -1
- package/src/cli/migration-compile.ts +12 -1
- package/src/cli/migration-generate.ts +31 -2
- package/src/cli/mutation-lease.ts +8 -0
- package/src/cli/schema-rewrite.ts +142 -9
- package/src/cli/schema-source.ts +30 -7
- package/src/cli/schema-swap.ts +55 -1
- package/src/cli/swap-execute.ts +268 -10
- package/src/cli/swap-pair.ts +443 -0
package/src/cli/schema-swap.ts
CHANGED
|
@@ -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. */
|
package/src/cli/swap-execute.ts
CHANGED
|
@@ -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(
|
|
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
|
|
164
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
390
|
-
|
|
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 } : {}) };
|