@everystack/cli 0.4.34 → 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-backup.ts +4 -2
- package/src/cli/commands/db-export.ts +3 -2
- package/src/cli/commands/db-swap.ts +397 -34
- package/src/cli/commands/db.ts +55 -8
- 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 +524 -5
- package/src/cli/swap-heartbeat.ts +407 -0
- package/src/cli/swap-pair.ts +443 -0
package/src/cli/swap-execute.ts
CHANGED
|
@@ -54,11 +54,260 @@ 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
|
+
* 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>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One table's landed-vs-live count, the intrinsic post-swap assertion's unit. */
|
|
112
|
+
export interface RowCount {
|
|
113
|
+
table: string;
|
|
114
|
+
incoming: number;
|
|
115
|
+
live: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The intrinsic count assertion: after the rename, the LIVE schema must hold exactly what the
|
|
120
|
+
* artifact landed in the incoming schema. A no-op swap (the rename silently not taking) leaves the
|
|
121
|
+
* OLD table under `<schema>.<table>` — its count differs from what was restored, and this catches it.
|
|
122
|
+
* A table present in incoming but absent live (a partial rename) is a mismatch too. Pure — the impure
|
|
123
|
+
* path reads the two count maps and hands them here, so the verdict wording is unit-tested.
|
|
124
|
+
*/
|
|
125
|
+
export function diffRowCounts(counts: RowCount[]): SwapCheck[] {
|
|
126
|
+
const bad: SwapCheck[] = [];
|
|
127
|
+
for (const c of counts) {
|
|
128
|
+
if (c.incoming !== c.live) {
|
|
129
|
+
bad.push({
|
|
130
|
+
name: `rowcount:${c.table}`,
|
|
131
|
+
ok: false,
|
|
132
|
+
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).`,
|
|
133
|
+
severity: 'fatal',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return bad;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One object OUTSIDE the swapped schema that depends on something inside it. */
|
|
141
|
+
export interface CrossSchemaDependent {
|
|
142
|
+
schema: string;
|
|
143
|
+
name: string;
|
|
144
|
+
/** `view`, `materialized view`, `function`, `procedure`. */
|
|
145
|
+
kind: string;
|
|
146
|
+
/** Identity arguments for a function/procedure — DROP FUNCTION needs them when overloaded. */
|
|
147
|
+
args?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `schema.name`, the join key against the declared derived layer's identities. */
|
|
151
|
+
export function dependentIdentity(d: CrossSchemaDependent): string {
|
|
152
|
+
return `${d.schema}.${d.name}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The DROP for one dependent. CASCADE is safe here and ordering does not matter: the dependent set
|
|
157
|
+
* is a transitive CLOSURE, so anything CASCADE could reach is already in the set being dropped.
|
|
158
|
+
*/
|
|
159
|
+
export function dropDependentSql(d: CrossSchemaDependent): string {
|
|
160
|
+
const ref = `"${d.schema.replace(/"/g, '""')}"."${d.name.replace(/"/g, '""')}"`;
|
|
161
|
+
if (d.kind === 'materialized view') return `DROP MATERIALIZED VIEW IF EXISTS ${ref} CASCADE;`;
|
|
162
|
+
if (d.kind === 'view') return `DROP VIEW IF EXISTS ${ref} CASCADE;`;
|
|
163
|
+
// Functions can be overloaded, so the identity arguments are load-bearing.
|
|
164
|
+
const kw = d.kind === 'procedure' ? 'PROCEDURE' : 'FUNCTION';
|
|
165
|
+
return `DROP ${kw} IF EXISTS ${ref}(${d.args ?? ''}) CASCADE;`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Objects in OTHER schemas that depend on the swapped schema — the CASCADE blast radius.
|
|
170
|
+
*
|
|
171
|
+
* Why this gate exists. The swap ends with `DROP SCHEMA <schema>_retiring CASCADE`. PostgreSQL
|
|
172
|
+
* binds a view, matview or function to its dependencies by OID, so the rename carries every such
|
|
173
|
+
* binding onto the RETIRING schema — and CASCADE then drops them. Silently. The swap already
|
|
174
|
+
* understands this for foreign keys (it drops and recreates them around the rename, see
|
|
175
|
+
* crossSchemaForeignKeys) and was blind to everything else.
|
|
176
|
+
*
|
|
177
|
+
* The walk must be TRANSITIVE, and the first version was not. Matching only DIRECT dependents,
|
|
178
|
+
* given `stats.t` ← `other.v1` ← `other.v2`, it found v1 and missed v2 entirely. v2 binds to v1 by
|
|
179
|
+
* OID, so when v1 goes so does v2. Worse: when the only direct dependent lives INSIDE the swapped
|
|
180
|
+
* schema, a direct-only query reports NOTHING while CASCADE still destroys everything downstream.
|
|
181
|
+
*
|
|
182
|
+
* So: seed the closure with every relation, type and function in the schema, then walk pg_depend
|
|
183
|
+
* forward to fixpoint. Views and matviews reach their dependencies through pg_rewrite, so an edge
|
|
184
|
+
* is normalized to the OWNING relation (`rw.ev_class`), not the rewrite rule. Types are seeded
|
|
185
|
+
* because `RETURNS SETOF <schema>.<table>` records against the table's composite TYPE rather than
|
|
186
|
+
* the table — a pg_class-only walk misses every such function.
|
|
187
|
+
*
|
|
188
|
+
* Objects INSIDE the schema are excluded (they ride along with the swap), as are the incoming and
|
|
189
|
+
* retiring schemas (transient, and a leftover `<schema>_incoming` from a failed run must not make
|
|
190
|
+
* the gate refuse forever). Foreign keys never surface here: they are pg_constraint, and the swap
|
|
191
|
+
* drops and recreates them itself.
|
|
192
|
+
*
|
|
193
|
+
* Verified against a live PostgreSQL 16 fixture: a three-deep view chain, a function returning
|
|
194
|
+
* SETOF a table, a view inside the schema, and an unrelated view in another schema.
|
|
195
|
+
*/
|
|
196
|
+
export function crossSchemaDependentsQuery(
|
|
197
|
+
schema: string,
|
|
198
|
+
incoming: string,
|
|
199
|
+
retiring: string,
|
|
200
|
+
opts: { paired?: string[]; incomingSuffix?: string; retiringSuffix?: string } = {},
|
|
201
|
+
): string {
|
|
202
|
+
const q = (s: string) => s.replace(/'/g, "''");
|
|
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)})`;
|
|
217
|
+
return `WITH RECURSIVE seed AS (
|
|
218
|
+
SELECT c.oid AS oid, 'pg_class'::regclass AS cls
|
|
219
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname ${seedIn}
|
|
220
|
+
UNION
|
|
221
|
+
SELECT t.oid, 'pg_type'::regclass
|
|
222
|
+
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname ${seedIn}
|
|
223
|
+
UNION
|
|
224
|
+
SELECT p.oid, 'pg_proc'::regclass
|
|
225
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname ${seedIn}
|
|
226
|
+
), closure AS (
|
|
227
|
+
SELECT oid, cls FROM seed
|
|
228
|
+
UNION
|
|
229
|
+
SELECT CASE WHEN d.classid = 'pg_rewrite'::regclass THEN rw.ev_class ELSE d.objid END,
|
|
230
|
+
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'::regclass ELSE d.classid END
|
|
231
|
+
FROM closure cl
|
|
232
|
+
JOIN pg_depend d ON d.refclassid = cl.cls AND d.refobjid = cl.oid
|
|
233
|
+
LEFT JOIN pg_rewrite rw ON rw.oid = d.objid AND d.classid = 'pg_rewrite'::regclass
|
|
234
|
+
WHERE d.classid IN ('pg_rewrite'::regclass, 'pg_class'::regclass, 'pg_proc'::regclass, 'pg_type'::regclass)
|
|
235
|
+
)
|
|
236
|
+
SELECT DISTINCT n.nspname AS dep_schema, c.relname AS dep_name,
|
|
237
|
+
CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE c.relkind::text END AS dep_kind,
|
|
238
|
+
''::text AS dep_args
|
|
239
|
+
FROM closure cl
|
|
240
|
+
JOIN pg_class c ON c.oid = cl.oid AND cl.cls = 'pg_class'::regclass
|
|
241
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
242
|
+
WHERE c.relkind IN ('v', 'm') AND ${notSelf}
|
|
243
|
+
UNION
|
|
244
|
+
SELECT DISTINCT n.nspname, p.proname,
|
|
245
|
+
CASE p.prokind WHEN 'p' THEN 'procedure' ELSE 'function' END,
|
|
246
|
+
pg_get_function_identity_arguments(p.oid)
|
|
247
|
+
FROM closure cl
|
|
248
|
+
JOIN pg_proc p ON p.oid = cl.oid AND cl.cls = 'pg_proc'::regclass
|
|
249
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
250
|
+
WHERE ${notSelf}
|
|
251
|
+
ORDER BY 1, 2`;
|
|
252
|
+
}
|
|
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
|
+
|
|
281
|
+
/** The refusal message — names what is at risk, and which of the two refusals this is. */
|
|
282
|
+
export function crossSchemaDependentsRefusal(
|
|
283
|
+
schema: string,
|
|
284
|
+
deps: CrossSchemaDependent[],
|
|
285
|
+
opts: { rebuildRequested?: boolean; undeclared?: CrossSchemaDependent[] } = {},
|
|
286
|
+
): string {
|
|
287
|
+
const name = (d: CrossSchemaDependent) => `${d.schema}.${d.name} (${d.kind})`;
|
|
288
|
+
const undeclared = opts.undeclared ?? [];
|
|
289
|
+
|
|
290
|
+
// Refusal 1: consent was given, but something in the set cannot be regenerated.
|
|
291
|
+
if (opts.rebuildRequested && undeclared.length > 0) {
|
|
292
|
+
return `${undeclared.length} of the ${deps.length} object(s) depending on ${schema} are NOT declared, so nothing can regenerate them: `
|
|
293
|
+
+ `${undeclared.slice(0, 20).map(name).join(', ')}${undeclared.length > 20 ? `, and ${undeclared.length - 20} more` : ''}. `
|
|
294
|
+
+ `--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. `
|
|
295
|
+
+ `Nothing was changed. Declare them in db/models (then db:reconcile --apply), or drop them yourself if they are genuinely disposable.`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Refusal 2: no consent given. Explain the danger and the flag.
|
|
299
|
+
const listed = deps.slice(0, 20).map(name).join(', ');
|
|
300
|
+
const more = deps.length > 20 ? `, and ${deps.length - 20} more` : '';
|
|
301
|
+
return `${deps.length} object(s) outside ${schema} depend on it, and the swap would DESTROY them: ${listed}${more}. `
|
|
302
|
+
+ `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. `
|
|
303
|
+
+ `Nothing was changed — this refusal happens before the snapshot. `
|
|
304
|
+
+ `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
305
|
}
|
|
58
306
|
|
|
59
307
|
export type SwapStatus =
|
|
60
308
|
| 'swapped'
|
|
61
309
|
| 'refused-fingerprint'
|
|
310
|
+
| 'refused-dependents'
|
|
62
311
|
| 'refused-integrity'
|
|
63
312
|
| 'rolled-back-verify';
|
|
64
313
|
|
|
@@ -69,6 +318,10 @@ export interface SwapResult {
|
|
|
69
318
|
verdict?: SwapVerdict;
|
|
70
319
|
/** Warn-severity checks that did NOT trigger rollback (surfaced). */
|
|
71
320
|
warnings?: SwapCheck[];
|
|
321
|
+
/** On `refused-dependents`: exactly what CASCADE would have destroyed. */
|
|
322
|
+
dependents?: CrossSchemaDependent[];
|
|
323
|
+
/** The subset nothing can regenerate — the reason --rebuild-derived was not enough. */
|
|
324
|
+
undeclaredDependents?: CrossSchemaDependent[];
|
|
72
325
|
}
|
|
73
326
|
|
|
74
327
|
/** Does the verdict force a rollback? Any failing check whose severity is fatal (the default). */
|
|
@@ -79,7 +332,93 @@ export function isFatalVerdict(verdict: SwapVerdict): boolean {
|
|
|
79
332
|
return checks.some((c) => !c.ok && (c.severity ?? 'fatal') === 'fatal');
|
|
80
333
|
}
|
|
81
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
|
+
|
|
401
|
+
/** Double-quote a Postgres identifier read from the catalog (embedded quotes doubled). */
|
|
402
|
+
function quoteIdent(name: string): string {
|
|
403
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The base tables physically present in `schema` (what a restore actually landed / what live serves). */
|
|
407
|
+
async function baseTablesIn(runner: QueryRunner, schema: string): Promise<string[]> {
|
|
408
|
+
const rows = await runner(
|
|
409
|
+
`SELECT table_name FROM information_schema.tables WHERE table_schema = '${schema.replace(/'/g, "''")}' AND table_type = 'BASE TABLE' ORDER BY table_name`,
|
|
410
|
+
);
|
|
411
|
+
return rows.map((r: any) => r.table_name as string);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** `count(*)` for one table, as a number. */
|
|
415
|
+
async function countRows(runner: QueryRunner, schema: string, table: string): Promise<number> {
|
|
416
|
+
const rows = await runner(`SELECT count(*)::bigint AS n FROM ${quoteIdent(schema)}.${quoteIdent(table)}`);
|
|
417
|
+
return Number(rows[0]?.n ?? 0);
|
|
418
|
+
}
|
|
419
|
+
|
|
82
420
|
export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions): Promise<SwapResult> {
|
|
421
|
+
const log = opts.log ?? (() => {});
|
|
83
422
|
// 1. Fingerprint gate — declared-vs-declared: does the artifact and the target agree on the shape.
|
|
84
423
|
if (opts.artifactFingerprint !== opts.declaredFingerprint) {
|
|
85
424
|
return {
|
|
@@ -88,14 +427,141 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
|
|
|
88
427
|
};
|
|
89
428
|
}
|
|
90
429
|
|
|
91
|
-
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
|
+
});
|
|
438
|
+
|
|
439
|
+
// 1b. THE CASCADE GATE. Refuse before the snapshot — before anything moves at all — if any object
|
|
440
|
+
// outside this schema depends on it. The swap's final DROP SCHEMA ... CASCADE would destroy
|
|
441
|
+
// them silently, and a silent destroyer is the worst thing this command could be.
|
|
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 }));
|
|
467
|
+
const dependents: CrossSchemaDependent[] = depRows.map((r: any) => ({
|
|
468
|
+
schema: r.dep_schema, name: r.dep_name, kind: r.dep_kind, args: r.dep_args || undefined,
|
|
469
|
+
}));
|
|
470
|
+
if (dependents.length > 0) {
|
|
471
|
+
const declared = new Set(opts.declaredIdentities ?? []);
|
|
472
|
+
const undeclared = dependents.filter((d) => !declared.has(dependentIdentity(d)));
|
|
473
|
+
|
|
474
|
+
// Consent covers only what can be rebuilt. An undeclared dependent is refused even WITH
|
|
475
|
+
// --rebuild-derived: dropping it destroys it, because nothing knows how to recreate it.
|
|
476
|
+
if (!opts.rebuildDerived || undeclared.length > 0) {
|
|
477
|
+
return {
|
|
478
|
+
status: 'refused-dependents',
|
|
479
|
+
reason: crossSchemaDependentsRefusal(opts.schema, dependents, {
|
|
480
|
+
rebuildRequested: !!opts.rebuildDerived,
|
|
481
|
+
undeclared,
|
|
482
|
+
}),
|
|
483
|
+
dependents,
|
|
484
|
+
undeclaredDependents: undeclared.length ? undeclared : undefined,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Consented, and every one is declared: drop them inside the swap transaction, before the
|
|
489
|
+
// rename. db:reconcile --apply regenerates them from the descriptors afterwards.
|
|
490
|
+
// Future tense on purpose: these DROPs ride the swap transaction, which is several steps away
|
|
491
|
+
// and may never run. Announcing them as done was misleading on a restore that failed first.
|
|
492
|
+
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.`);
|
|
493
|
+
plan.statements.unshift(...dependents.map(dropDependentSql));
|
|
494
|
+
}
|
|
92
495
|
|
|
93
496
|
// 2. Snapshot (the rollback point) before anything destructive.
|
|
94
497
|
if (opts.snapshot) await opts.snapshot();
|
|
95
498
|
|
|
96
|
-
// 3. Land the incoming schema while live keeps serving.
|
|
499
|
+
// 3. Land the incoming schema while live keeps serving. Drop a stale incoming FIRST so a retry
|
|
500
|
+
// after a failed restore is idempotent (a half-landed <schema>_incoming from a prior crash
|
|
501
|
+
// would otherwise collide on the artifact's CREATE SCHEMA and fail every retry).
|
|
502
|
+
await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`);
|
|
97
503
|
await opts.applyIncoming(runner);
|
|
98
504
|
|
|
505
|
+
// Capture what the artifact actually landed, per table — the count the live schema must match
|
|
506
|
+
// after the swap. Empty here means the restore created the schema but no tables: a silent
|
|
507
|
+
// partial that must fail, not pass.
|
|
508
|
+
const incomingTables = await baseTablesIn(runner, plan.incoming);
|
|
509
|
+
const expected = new Map<string, number>();
|
|
510
|
+
for (const t of incomingTables) expected.set(t, await countRows(runner, plan.incoming, t));
|
|
511
|
+
log(`landed ${incomingTables.length} table(s) into ${plan.incoming}: ${incomingTables.map((t) => `${t}=${expected.get(t)}`).join(', ') || '(none)'}`);
|
|
512
|
+
if (incomingTables.length === 0) {
|
|
513
|
+
try { await runner(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.incoming)} CASCADE`); } catch { /* best effort */ }
|
|
514
|
+
return {
|
|
515
|
+
status: 'refused-integrity',
|
|
516
|
+
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.`,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
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
|
+
|
|
99
565
|
// 4. The atomic swap. A FK re-validation failure (a bad artifact) rolls the whole thing back.
|
|
100
566
|
await runner('BEGIN');
|
|
101
567
|
try {
|
|
@@ -111,7 +577,43 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
|
|
|
111
577
|
};
|
|
112
578
|
}
|
|
113
579
|
|
|
114
|
-
//
|
|
580
|
+
// 5a. The intrinsic count assertion (ALWAYS runs — this is the safety net a silent no-op needs).
|
|
581
|
+
// After the rename, live must hold exactly what landed. A mismatch means the swap did not
|
|
582
|
+
// take effect; roll back to the snapshot and refuse LOUD.
|
|
583
|
+
const counts: RowCount[] = [];
|
|
584
|
+
for (const t of incomingTables) counts.push({ table: t, incoming: expected.get(t)!, live: await countRows(runner, opts.schema, t) });
|
|
585
|
+
log(`post-swap live counts: ${counts.map((c) => `${c.table}=${c.live}`).join(', ')}`);
|
|
586
|
+
const countChecks = diffRowCounts(counts);
|
|
587
|
+
if (countChecks.length > 0) {
|
|
588
|
+
if (opts.rollbackToSnapshot) await opts.rollbackToSnapshot();
|
|
589
|
+
return {
|
|
590
|
+
status: 'rolled-back-verify',
|
|
591
|
+
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.'}`,
|
|
592
|
+
verdict: { ok: false, checks: countChecks },
|
|
593
|
+
};
|
|
594
|
+
}
|
|
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
|
+
|
|
616
|
+
// 5b. Verify (post-commit). Fatal → roll back to the snapshot; warn → surface only.
|
|
115
617
|
let verdict: SwapVerdict | undefined;
|
|
116
618
|
if (opts.verify) {
|
|
117
619
|
verdict = await opts.verify();
|
|
@@ -125,8 +627,25 @@ export async function executeSwap(runner: QueryRunner, opts: ExecuteSwapOptions)
|
|
|
125
627
|
}
|
|
126
628
|
}
|
|
127
629
|
|
|
128
|
-
// 6. Drop
|
|
129
|
-
|
|
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
|
+
}
|
|
130
649
|
|
|
131
650
|
const warnings = (verdict?.checks ?? []).filter((c) => !c.ok && c.severity === 'warn');
|
|
132
651
|
return { status: 'swapped', verdict, ...(warnings.length ? { warnings } : {}) };
|