@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.
@@ -0,0 +1,443 @@
1
+ /**
2
+ * swap-pair — the paired-schema swap: build the derived layer NEXT TO the incoming base
3
+ * schema, then rename every pair in one transaction.
4
+ *
5
+ * ## Why the obvious fix is wrong
6
+ *
7
+ * `db:swap` renames `<schema>` out and `<schema>_incoming` in. A matview over that schema binds
8
+ * its source by OID, so the rename WELDS it to `<schema>_retiring` — it now reads the retiring
9
+ * table. The tempting repair is to refresh it afterwards. Verified on PostgreSQL 16, that is
10
+ * worse than it looks:
11
+ *
12
+ * REFRESH MATERIALIZED VIEW CONCURRENTLY → OLD data, and NO error.
13
+ *
14
+ * The stored query still resolves to the retiring table, so a CONCURRENTLY refresh cheerfully
15
+ * repopulates the matview from stale rows and reports success. Silent staleness beats loud
16
+ * downtime in exactly zero situations. The only correct repair is RECREATION, and the only
17
+ * recreation with no serving gap is a parallel build swapped in atomically — this module.
18
+ *
19
+ * ## The shape
20
+ *
21
+ * 1. The artifact restores into `<schema>_incoming` (already the case)
22
+ * 2. The declared derived layer is BUILT into `<derived>_incoming`, over `<schema>_incoming`
23
+ * 3. Every pair renames in ONE transaction
24
+ * 4. Both retiring schemas drop after verify
25
+ *
26
+ * Because the incoming objects are created against the incoming base tables, their OID bindings
27
+ * are already correct before the rename — and a schema rename does not disturb them. There is no
28
+ * instant at which a reader sees a half-swapped layer, and no refresh runs at all.
29
+ *
30
+ * ## Granularity: whole-schema
31
+ *
32
+ * A derived schema is paired WHOLESALE the moment anything in it depends on the swapped schema.
33
+ * Objects in that schema which do NOT depend on the swap get rebuilt too. That is deliberate:
34
+ * the alternative (rebuild the dependents, `ALTER … SET SCHEMA` the rest across) has to partition
35
+ * the dependency graph exactly right, and an object left on the wrong side binds to a retiring
36
+ * schema — the precise failure this module exists to prevent. Rebuilding a few objects that did
37
+ * not strictly need it is the cheaper mistake. Object-level pruning is a later optimization that
38
+ * does not change this contract.
39
+ *
40
+ * ## Two wrinkles that are easy to miss
41
+ *
42
+ * - **Derived objects live INSIDE the base schema too.** A function in `analytics` returning
43
+ * SETOF a matview in `analytics_view` is reconcile-managed but resident in the schema being
44
+ * swapped, and the artifact's TOC filter strips it. It must be rebuilt into
45
+ * `<schema>_incoming` alongside the derived schemas, or the swap lands a base schema missing
46
+ * its own functions.
47
+ * - **The dependency runs BOTH ways.** That same function makes the base schema depend on the
48
+ * derived one (pg_proc → pg_type). Build order is the compiled topological order, which
49
+ * already crosses schemas correctly; nothing here may re-sort it per schema.
50
+ */
51
+
52
+ import type { ModelDescriptor, DerivedDescriptor } from '@everystack/model';
53
+ import { parseQualified } from './derived-source.js';
54
+ import type { SourceObject } from './derived-source.js';
55
+ import { rewriteStatementLineMulti, splitCodeSpans } from './schema-rewrite.js';
56
+ import { parseGrantAttachments } from './derived-grants.js';
57
+ import { derivedSearchPath, renderSetSearchPath, renderProvenanceUpsert, ENSURE_RECONCILER_SQL } from './derived-apply.js';
58
+ import type { LiveObject } from './derived-introspect.js';
59
+ import { compileTableContract } from './authz-compile.js';
60
+
61
+ const SAFE_SCHEMA = /^[a-z_][a-z0-9_$]*$/;
62
+
63
+ /** The identity (`schema.name`) a descriptor is known by. */
64
+ function identityOf(d: DerivedDescriptor): string {
65
+ const { schema, name } = parseQualified(d.name);
66
+ return `${schema}.${name}`;
67
+ }
68
+
69
+ /** The schema a descriptor lives in. */
70
+ export function schemaOfDescriptor(d: DerivedDescriptor): string {
71
+ return parseQualified(d.name).schema;
72
+ }
73
+
74
+ /**
75
+ * Every identity a descriptor declares a dependency on — `dependsOn` for all kinds, PLUS the
76
+ * `returns: setof(...)` target for functions and the owning table for triggers.
77
+ *
78
+ * The setof target matters more than it looks: it is the edge that makes a base-schema function
79
+ * depend on a derived-schema type, and it is recorded by PostgreSQL against the composite TYPE,
80
+ * so a dependsOn-only walk misses it entirely.
81
+ */
82
+ export function declaredDepIdentities(d: DerivedDescriptor): string[] {
83
+ const out = new Set<string>();
84
+ const add = (ref: unknown): void => {
85
+ if (!ref || typeof ref !== 'object') return;
86
+ const r = ref as { table?: string; schema?: string; name?: string };
87
+ if (typeof r.table === 'string') out.add(`${r.schema ?? 'public'}.${r.table}`);
88
+ else if (typeof r.name === 'string') out.add(identityOf(ref as DerivedDescriptor));
89
+ };
90
+ for (const ref of (d as { dependsOn?: readonly unknown[] }).dependsOn ?? []) add(ref);
91
+ const returns = (d as { returns?: unknown }).returns;
92
+ if (returns && typeof returns === 'object' && 'setof' in (returns as object)) {
93
+ add((returns as { setof: unknown }).setof);
94
+ }
95
+ const table = (d as { table?: unknown }).table;
96
+ if (typeof table === 'string') {
97
+ const { schema, name } = parseQualified(table);
98
+ out.add(`${schema}.${name}`);
99
+ }
100
+ return [...out];
101
+ }
102
+
103
+ /**
104
+ * The derived schemas that must swap ALONGSIDE `schema` — every schema holding a declared
105
+ * derived object that transitively depends on anything in `schema`.
106
+ *
107
+ * `schema` itself is never in the result: its own derived residents are rebuilt into
108
+ * `<schema>_incoming`, which is a different code path (the artifact already supplies the tables).
109
+ * The walk is transitive because a view over a matview over the swapped schema is just as welded
110
+ * to it as the matview is — the direct-only walk was a real bug once.
111
+ */
112
+ export function pairedDerivedSchemas(
113
+ models: readonly ModelDescriptor[],
114
+ derived: readonly DerivedDescriptor[],
115
+ schema: string,
116
+ ): string[] {
117
+ // Seed: everything the swapped schema physically contains — its models, and any derived
118
+ // object declared to live in it.
119
+ const reached = new Set<string>();
120
+ for (const m of models) {
121
+ if ((m.schema || 'public') === schema) reached.add(`${schema}.${m.table}`);
122
+ }
123
+ for (const d of derived) {
124
+ if (schemaOfDescriptor(d) === schema) reached.add(identityOf(d));
125
+ }
126
+
127
+ // Fixed point: a descriptor joins the closure once any of its declared deps is in it. Repeat
128
+ // until nothing new arrives — declaration order is not dependency order.
129
+ let grew = true;
130
+ while (grew) {
131
+ grew = false;
132
+ for (const d of derived) {
133
+ const id = identityOf(d);
134
+ if (reached.has(id)) continue;
135
+ if (declaredDepIdentities(d).some((dep) => reached.has(dep))) {
136
+ reached.add(id);
137
+ grew = true;
138
+ }
139
+ }
140
+ }
141
+
142
+ const schemas = new Set<string>();
143
+ for (const d of derived) {
144
+ if (!reached.has(identityOf(d))) continue;
145
+ const s = schemaOfDescriptor(d);
146
+ if (s !== schema) schemas.add(s);
147
+ }
148
+ return [...schemas].sort();
149
+ }
150
+
151
+ /**
152
+ * The schema rename map for a paired swap: every schema in the swap set to its `_incoming` twin.
153
+ * The base schema is always present; the paired derived schemas follow.
154
+ */
155
+ export function incomingSchemaMap(schema: string, paired: readonly string[], suffix = '_incoming'): Record<string, string> {
156
+ const map: Record<string, string> = { [schema]: `${schema}${suffix}` };
157
+ for (const p of paired) map[p] = `${p}${suffix}`;
158
+ return map;
159
+ }
160
+
161
+ /**
162
+ * The roles that hold ANY declared grant in each schema of the swap set — models' table grants
163
+ * plus the derived objects' own grant attachments.
164
+ *
165
+ * This is what schema-level USAGE has to be re-granted to. A role with SELECT on a table it cannot
166
+ * reach is not a degraded state, it is an invisible one: PostgreSQL reports a missing schema USAGE
167
+ * as ABSENCE, not denial —
168
+ *
169
+ * relation "player_gridiron_index" does not exist
170
+ *
171
+ * — so the app 500s and the error points at the wrong thing entirely.
172
+ */
173
+ export function swapSchemaRoles(
174
+ models: readonly ModelDescriptor[],
175
+ objects: readonly SourceObject[],
176
+ schemas: readonly string[],
177
+ ): Map<string, string[]> {
178
+ const byShema = new Map<string, Set<string>>();
179
+ for (const s of schemas) byShema.set(s, new Set());
180
+ for (const m of models) {
181
+ const set = byShema.get(m.schema || 'public');
182
+ if (!set) continue;
183
+ const c = compileTableContract(m);
184
+ for (const r of Object.keys(c.grants)) set.add(r);
185
+ for (const r of Object.keys(c.columnGrants ?? {})) set.add(r);
186
+ }
187
+ for (const o of objects) {
188
+ const set = byShema.get(o.schema);
189
+ if (!set) continue;
190
+ const { grants } = parseGrantAttachments(o.attachments);
191
+ for (const [role, privileges] of Object.entries(grants)) {
192
+ if (privileges.length > 0) set.add(role);
193
+ }
194
+ }
195
+ const out = new Map<string, string[]>();
196
+ for (const [s, roles] of byShema) if (roles.size) out.set(s, [...roles].sort());
197
+ return out;
198
+ }
199
+
200
+ /**
201
+ * `GRANT USAGE ON SCHEMA` for every schema in the swap set, naming the FINAL schemas.
202
+ *
203
+ * These run INSIDE the swap transaction, after the renames — the incoming schemas arrive with no
204
+ * schema-level ACL at all (the base one is restored `--no-privileges`; the derived twins are
205
+ * freshly created), so without this the swap commits a set of schemas no application role can
206
+ * enter. Table-level authz was always re-applied; the schema level was simply never considered.
207
+ */
208
+ export function renderSwapSchemaUsage(
209
+ models: readonly ModelDescriptor[],
210
+ objects: readonly SourceObject[],
211
+ schema: string,
212
+ paired: readonly string[],
213
+ ): string[] {
214
+ const roles = swapSchemaRoles(models, objects, [schema, ...paired]);
215
+ const out: string[] = [];
216
+ for (const s of [schema, ...paired]) {
217
+ const rs = roles.get(s);
218
+ if (!rs?.length) continue;
219
+ const targets = rs.map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : `"${r}"`)).join(', ');
220
+ out.push(`GRANT USAGE ON SCHEMA "${s}" TO ${targets};`);
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * The identities the incoming build is expected to produce, qualified onto the `_incoming`
227
+ * schemas — what the completeness assertion compares the live catalog against.
228
+ *
229
+ * Only CATALOG-VISIBLE kinds count. Triggers carry a compound `schema.table.name` identity and
230
+ * live in pg_trigger; `sql`-kind escape-hatch objects are invisible to the catalog by definition.
231
+ * Including either would report a permanent phantom shortfall.
232
+ */
233
+ export function expectedIncomingObjects(
234
+ objects: readonly SourceObject[],
235
+ schema: string,
236
+ paired: readonly string[],
237
+ opts: { suffix?: string } = {},
238
+ ): string[] {
239
+ const suffix = opts.suffix ?? '_incoming';
240
+ const inSet = new Set([schema, ...paired]);
241
+ const visible = new Set(['view', 'materialized view', 'function']);
242
+ return objects
243
+ .filter((o) => inSet.has(o.schema) && visible.has(o.kind))
244
+ .map((o) => `${o.schema}${suffix}.${o.name}`)
245
+ .sort();
246
+ }
247
+
248
+ /**
249
+ * The `search_path` the incoming build must run under.
250
+ *
251
+ * The schema rewrite is TEXTUAL and keys on a schema prefix, so it moves `stats.foo` to
252
+ * `stats_incoming.foo` and cannot touch a BARE `FROM foo` — there is no prefix to rewrite. A bare
253
+ * ref resolves against the search_path AT CREATE TIME, so without this the build looks for the
254
+ * table in the live schemas and fails with `relation "foo" does not exist` on a table the restore
255
+ * just landed thousands of rows into.
256
+ *
257
+ * Bare refs are not an author's mistake to be linted away: `db:pull` generates descriptors from
258
+ * `pg_get_viewdef`, which renders a reference bare whenever its schema is already on the path. Our
259
+ * own generator emits them, so the build has to honour them. The reconciler settled this long ago
260
+ * (derivedSearchPath) and the paired build simply never asked.
261
+ *
262
+ * Same function as the reconciler's, with each SWAPPED schema replaced by its `_incoming` twin in
263
+ * place — so a bare ref resolves to the incoming table exactly as it resolves to the live one
264
+ * under db:reconcile, and a layer that builds there builds here. Schemas OUTSIDE the swap set keep
265
+ * their live names: swapping `stats` must leave a bare ref to `curated` pointing at live `curated`.
266
+ */
267
+ export function incomingSearchPath(
268
+ objects: readonly SourceObject[],
269
+ schema: string,
270
+ paired: readonly string[],
271
+ opts: { suffix?: string } = {},
272
+ ): string[] {
273
+ const suffix = opts.suffix ?? '_incoming';
274
+ const map = incomingSchemaMap(schema, paired, suffix);
275
+ const path = derivedSearchPath([...objects]).map((s) => map[s] ?? s);
276
+ // A swapped schema the reconciler's path never included (nothing declared a dependency on it)
277
+ // still holds the artifact's tables. Front of the path, so the twin always wins.
278
+ for (const s of [schema, ...paired]) {
279
+ if (!path.includes(map[s])) path.unshift(map[s]);
280
+ }
281
+ return path;
282
+ }
283
+
284
+ /**
285
+ * Provenance for the objects the paired build created — the bookkeeping that makes the NEXT
286
+ * db:reconcile a no-op.
287
+ *
288
+ * The build creates the derived layer with raw DDL and never told the reconciler. So a swap that
289
+ * produced a byte-correct layer left every object looking UNRECORDED, and the next reconcile —
290
+ * run days later, for an unrelated one-line edit — saw the whole layer as drift and rebuilt it,
291
+ * holding ACCESS EXCLUSIVE the entire time. A consumer measured 331 MB across 55 matviews, and the
292
+ * rebuild was pure waste: the completeness assertion had already proven the objects correct. They
293
+ * were rebuilt because the bookkeeping said UNKNOWN, not because anything was wrong.
294
+ *
295
+ * The invariant this restores is the one worth stating plainly: a paired swap leaves the database
296
+ * in the state a reconcile would have left it in, bookkeeping included.
297
+ *
298
+ * Recorded at FINAL identities, after the rename — provenance is keyed on where an object lives,
299
+ * and during the build it lived in `_incoming`. `defHash` must come from the LIVE catalog for the
300
+ * same reason the reconciler reads it there: it is a hash of PostgreSQL's own deparse, which never
301
+ * matches the authored text.
302
+ */
303
+ export function renderPairedProvenance(
304
+ objects: readonly SourceObject[],
305
+ live: readonly LiveObject[],
306
+ schema: string,
307
+ paired: readonly string[],
308
+ ): { statements: string[]; recorded: string[]; unmatched: string[] } {
309
+ const inSet = new Set([schema, ...paired]);
310
+ const liveById = new Map(live.map((o) => [o.identity, o]));
311
+ // Catalog-invisible kinds have no live row by definition and are not a problem — they keep the
312
+ // reconciler's own record path. Anything else missing IS a problem worth naming.
313
+ const visible = new Set(['view', 'materialized view', 'function']);
314
+ const statements: string[] = [...ENSURE_RECONCILER_SQL];
315
+ const recorded: string[] = [];
316
+ const unmatched: string[] = [];
317
+ for (const o of objects) {
318
+ if (!inSet.has(o.schema)) continue;
319
+ const l = liveById.get(o.identity);
320
+ if (!l) {
321
+ if (visible.has(o.kind)) unmatched.push(o.identity);
322
+ continue;
323
+ }
324
+ statements.push(renderProvenanceUpsert(o.identity, o.hash, l.defHash, o.kind, o.drop, o.bodyHash));
325
+ recorded.push(o.identity);
326
+ }
327
+ // Returning the COUNTS, not just the SQL, so the caller can say what it actually did. The first
328
+ // version returned a bare string[] and an empty one on zero matches — so a swap that recorded
329
+ // NOTHING still printed "provenance recorded". Exactly the silent-success shape this whole
330
+ // feature keeps having to be defended against, reintroduced in the defence itself.
331
+ return { statements: recorded.length ? statements : [], recorded, unmatched };
332
+ }
333
+
334
+ export interface PairedBuild {
335
+ /** Ordered SQL creating the incoming derived layer. Run BEFORE the swap transaction. */
336
+ statements: string[];
337
+ /** The derived schemas swapping alongside the base schema. */
338
+ paired: string[];
339
+ /** Declared schema → its `_incoming` twin, for every schema in the swap set. */
340
+ schemaMap: Record<string, string>;
341
+ }
342
+
343
+ /**
344
+ * Render the build of the incoming derived layer.
345
+ *
346
+ * Takes the ALREADY-COMPILED derived objects (topological order preserved — see the wrinkle
347
+ * about cross-schema build order) and rewrites every reference in the swap set onto its
348
+ * `_incoming` twin. An object outside the swap set is skipped: it keeps serving throughout and
349
+ * must not be duplicated.
350
+ *
351
+ * Pure. The caller runs `statements` against the database while the live schemas keep serving.
352
+ */
353
+ export function renderPairedDerivedBuild(
354
+ objects: readonly SourceObject[],
355
+ schema: string,
356
+ paired: readonly string[],
357
+ opts: { suffix?: string } = {},
358
+ ): PairedBuild {
359
+ const suffix = opts.suffix ?? '_incoming';
360
+ for (const s of [schema, ...paired]) {
361
+ if (!SAFE_SCHEMA.test(s)) {
362
+ throw new Error(`swap schema ${JSON.stringify(s)} is not a plain lowercase identifier — everystack swaps schemas it can name without quoting games.`);
363
+ }
364
+ }
365
+ const schemaMap = incomingSchemaMap(schema, paired, suffix);
366
+ const inSet = new Set([schema, ...paired]);
367
+ // Dollar-quote state carries across lines — a multi-line body must stay literal throughout.
368
+ const rewrite = (sql: string): string => {
369
+ let inDollar: string | undefined;
370
+ return sql.split('\n').map((line) => {
371
+ const next = splitCodeSpans(line, inDollar).inDollar;
372
+ const out = rewriteStatementLineMulti(line, schemaMap, inDollar);
373
+ inDollar = next;
374
+ return out;
375
+ }).join('\n');
376
+ };
377
+
378
+ /**
379
+ * A FUNCTION is not a view, and the difference decides this whole module.
380
+ *
381
+ * A view's query is parsed at CREATE time and stored as a rewrite rule holding OIDs, so it must
382
+ * be built against the incoming tables — rewriting its body is exactly right. A function's body
383
+ * is stored as TEXT (`prosrc`) and re-resolved BY NAME on every call. Rewrite it and the
384
+ * function points at `<schema>_incoming` — a schema that stops existing the moment the swap
385
+ * renames it — and the first call after a successful swap fails with `relation … does not
386
+ * exist`.
387
+ *
388
+ * So a function is split: the header is rewritten (its own name, so it lands in the incoming
389
+ * schema; its argument and RETURNS types, which ARE OID-bound and must point at the incoming
390
+ * twins to survive the rename), and the body is left verbatim to resolve against the FINAL
391
+ * names after the swap. A `SECURITY DEFINER … SET search_path` clause is left alone for the
392
+ * same reason as the body: it is resolved at call time, not at create time.
393
+ *
394
+ * One consequence worth stating: the body is validated at CREATE time against the objects that
395
+ * are live right now — the OLD ones. A body that only compiles against the new artifact's shape
396
+ * fails here, before anything is renamed. That is the right place to fail.
397
+ */
398
+ const rewriteFunction = (sql: string): string => {
399
+ const lines = sql.split('\n');
400
+ const bodyStart = lines.findIndex((l) => /^AS\s+\$[A-Za-z0-9_]*\$\s*$/.test(l.trim()));
401
+ if (bodyStart === -1) return rewrite(sql); // not the shape we render; rewrite whole, as before
402
+ const header = lines.slice(0, bodyStart + 1).map((line) =>
403
+ /\bSET\s+search_path\b/i.test(line) ? line : rewriteStatementLineMulti(line, schemaMap),
404
+ );
405
+ return [...header, ...lines.slice(bodyStart + 1)].join('\n');
406
+ };
407
+
408
+ const statements: string[] = [];
409
+ // The path FIRST — every CREATE below resolves its bare refs against it, and a bare ref is the
410
+ // majority form in a db:pull-generated descriptor file.
411
+ const setPath = renderSetSearchPath(incomingSearchPath(objects, schema, paired, { suffix }), false);
412
+ if (setPath) statements.push(`${setPath};`);
413
+ // The derived schemas' twins must exist before anything lands in them. The BASE twin is not
414
+ // created here — the artifact restore already made it, and creating it would mask a restore
415
+ // that never ran.
416
+ //
417
+ // USAGE rides along per schema, exactly as the reconciler's renderEnsureObjectSchemas does. The
418
+ // first version of this hand-rolled a bare CREATE SCHEMA and skipped the grant, which is how a
419
+ // successful swap could dark-site an app: the objects were there, correct, and unreachable.
420
+ const usageByIncoming = swapSchemaRoles([], objects, paired);
421
+ for (const p of paired) {
422
+ statements.push(`CREATE SCHEMA IF NOT EXISTS "${p}${suffix}";`);
423
+ const roles = usageByIncoming.get(p);
424
+ if (roles?.length) {
425
+ const targets = roles.map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : `"${r}"`)).join(', ');
426
+ statements.push(`GRANT USAGE ON SCHEMA "${p}${suffix}" TO ${targets};`);
427
+ }
428
+ }
429
+
430
+ for (const obj of objects) {
431
+ if (!inSet.has(obj.schema)) continue;
432
+ statements.push(`${obj.kind === 'function' ? rewriteFunction(obj.sql) : rewrite(obj.sql)};`);
433
+ // Attachments (GRANT/COMMENT/CREATE INDEX) name their target, which HAS moved to the incoming
434
+ // schema — those are rewritten for every kind, functions included.
435
+ for (const a of obj.attachments) statements.push(`${rewrite(a.sql)};`);
436
+ }
437
+
438
+ // Leave the session as we found it — the swap transaction that follows is fully qualified, but
439
+ // a lingering path is a trap for anything else sharing this connection.
440
+ if (setPath) statements.push('RESET search_path;');
441
+
442
+ return { statements, paired: [...paired], schemaMap };
443
+ }