@everystack/cli 0.4.35 → 0.4.38
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/bin/everystack.mjs +52 -0
- package/package.json +4 -3
- package/src/cli/commands/db-authz.ts +94 -38
- package/src/cli/commands/db-swap.ts +229 -24
- package/src/cli/db-source.ts +67 -16
- package/src/cli/derived-introspect.ts +30 -0
- package/src/cli/derived-plan.ts +16 -1
- package/src/cli/index.ts +6 -5
- 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-heartbeat.ts +106 -17
- package/src/cli/swap-pair.ts +443 -0
- package/src/cli/swap-snapshot.ts +260 -0
|
@@ -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
|
+
}
|