@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.
- 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-reconcile.ts +8 -5
- package/src/cli/commands/db-swap.ts +285 -31
- package/src/cli/commands/db.ts +55 -8
- package/src/cli/derived-apply.ts +14 -4
- package/src/cli/derived-compile.ts +13 -2
- package/src/cli/derived-grants.ts +15 -0
- package/src/cli/derived-introspect.ts +4 -0
- package/src/cli/derived-plan.ts +27 -3
- package/src/cli/derived-source.ts +8 -0
- package/src/cli/swap-execute.ts +263 -2
- package/src/cli/swap-heartbeat.ts +407 -0
|
@@ -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
|
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -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. */
|
package/src/cli/swap-execute.ts
CHANGED
|
@@ -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
|
-
//
|
|
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();
|