@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
|
@@ -207,11 +207,40 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
|
|
|
207
207
|
),
|
|
208
208
|
}
|
|
209
209
|
: opts.liveAuthz;
|
|
210
|
+
const desiredContracts = models.map((m) => compileTableContract(m, { schema }));
|
|
210
211
|
const authzPhase = liveAuthzRenamed
|
|
211
|
-
? emitReconcileSql({ tables:
|
|
212
|
+
? emitReconcileSql({ tables: desiredContracts, functions: [] }, liveAuthzRenamed)
|
|
212
213
|
: [];
|
|
213
214
|
|
|
214
|
-
|
|
215
|
+
// 0a-bis (the diff analog of compileMigration's). A role cannot reach a table in a
|
|
216
|
+
// non-public schema without USAGE on that schema, so every table grant emitted below is
|
|
217
|
+
// DEAD without this — declared, and denied by the database. Nothing introspects schema
|
|
218
|
+
// ACLs, so the diff has no live side to compare against; it re-emits USAGE for every
|
|
219
|
+
// non-public declared schema exactly as the authz phase re-emits every table grant.
|
|
220
|
+
// GRANT is idempotent, so this stays correct both for a brand-new schema and for a role
|
|
221
|
+
// added to an existing one.
|
|
222
|
+
//
|
|
223
|
+
// The from-scratch path had this from the start; the diff path did not, so a non-public
|
|
224
|
+
// model reached through db:sync/db:generate was unreachable by every role that did not
|
|
225
|
+
// pick up USAGE some other way. The example app's analytics schema is what surfaced it.
|
|
226
|
+
const usagePhase = authzPhase.length
|
|
227
|
+
? [...new Set(desiredContracts.map((c) => c.table.split('.')[0]))]
|
|
228
|
+
.filter((s) => s !== 'public')
|
|
229
|
+
.sort()
|
|
230
|
+
.flatMap((s) => {
|
|
231
|
+
const roles = new Set<string>();
|
|
232
|
+
for (const c of desiredContracts) {
|
|
233
|
+
if (!c.table.startsWith(`${s}.`)) continue;
|
|
234
|
+
for (const r of Object.keys(c.grants)) roles.add(r);
|
|
235
|
+
for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
|
|
236
|
+
}
|
|
237
|
+
if (!roles.size) return [];
|
|
238
|
+
const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
|
|
239
|
+
return [`GRANT USAGE ON SCHEMA "${s}" TO ${targets}`];
|
|
240
|
+
})
|
|
241
|
+
: [];
|
|
242
|
+
|
|
243
|
+
return [...schemaPhase, ...usagePhase, ...dataPhase, ...authzPhase];
|
|
215
244
|
}
|
|
216
245
|
|
|
217
246
|
/** The marker drizzle migration files put between statements. */
|
|
@@ -97,6 +97,14 @@ WHERE l.locktype = 'advisory'
|
|
|
97
97
|
AND l.classid = ${MUTATION_LEASE_KEY.classid}
|
|
98
98
|
AND l.objid = ${MUTATION_LEASE_KEY.objid}
|
|
99
99
|
AND l.granted
|
|
100
|
+
-- Scoped to THIS database. An advisory lock's tag includes the database oid, so the lease is
|
|
101
|
+
-- already per-database and the acquire above contends correctly. pg_locks, though, is
|
|
102
|
+
-- cluster-wide: without this filter a LIMIT 1 could return a holder from a completely
|
|
103
|
+
-- different database, and the refusal would name an innocent backend — while telling the
|
|
104
|
+
-- operator to terminate it. Caught when a swap on a neighbouring database on the same
|
|
105
|
+
-- cluster was reported as the holder. db:branch mints many databases on one cluster, so
|
|
106
|
+
-- this is the normal case, not an exotic one.
|
|
107
|
+
AND l.database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
100
108
|
LIMIT 1;
|
|
101
109
|
`.trim();
|
|
102
110
|
|
|
@@ -19,6 +19,99 @@
|
|
|
19
19
|
|
|
20
20
|
const IDENT = '[A-Za-z_][A-Za-z0-9_$]*';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A line split into CODE spans (rewritable) and LITERAL spans (never touched): single-quoted
|
|
24
|
+
* strings, dollar-quoted strings, and `--` comments.
|
|
25
|
+
*
|
|
26
|
+
* This exists because the rewrite is a regex over identifiers and a schema name is also an
|
|
27
|
+
* ordinary word. `jsonb_build_object('stats', …)` has `stats` as a JSON KEY, and rewriting it
|
|
28
|
+
* silently renames a key in the output payload:
|
|
29
|
+
*
|
|
30
|
+
* jsonb_build_object('stats', …) → jsonb_build_object('stats_incoming', …)
|
|
31
|
+
*
|
|
32
|
+
* Found in the field, and it is the nastiest failure this module can produce: every object is
|
|
33
|
+
* present, non-empty and correctly wired, so the completeness and reachability assertions all
|
|
34
|
+
* pass while the DATA is wrong. A consumer found 20 renamed keys in one matview.
|
|
35
|
+
*
|
|
36
|
+
* Double-quoted spans are deliberately NOT literals — those are quoted IDENTIFIERS (`"stats".x`)
|
|
37
|
+
* and must still be rewritten.
|
|
38
|
+
*
|
|
39
|
+
* `inDollar` carries dollar-quote state across lines, because a dollar-quoted body spans them and
|
|
40
|
+
* the streaming rewriter is line-oriented.
|
|
41
|
+
*/
|
|
42
|
+
export function splitCodeSpans(
|
|
43
|
+
line: string,
|
|
44
|
+
inDollar?: string,
|
|
45
|
+
): { spans: Array<{ text: string; code: boolean }>; inDollar?: string } {
|
|
46
|
+
const spans: Array<{ text: string; code: boolean }> = [];
|
|
47
|
+
let i = 0;
|
|
48
|
+
let start = 0;
|
|
49
|
+
let dollar = inDollar;
|
|
50
|
+
|
|
51
|
+
// Mid-body of a dollar-quoted string that opened on an earlier line: consume to its close.
|
|
52
|
+
if (dollar) {
|
|
53
|
+
const end = line.indexOf(dollar);
|
|
54
|
+
if (end === -1) return { spans: [{ text: line, code: false }], inDollar: dollar };
|
|
55
|
+
const stop = end + dollar.length;
|
|
56
|
+
spans.push({ text: line.slice(0, stop), code: false });
|
|
57
|
+
i = start = stop;
|
|
58
|
+
dollar = undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const push = (to: number, code: boolean): void => {
|
|
62
|
+
if (to > start) spans.push({ text: line.slice(start, to), code });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
while (i < line.length) {
|
|
66
|
+
const ch = line[i];
|
|
67
|
+
if (ch === "'") {
|
|
68
|
+
push(i, true);
|
|
69
|
+
let j = i + 1;
|
|
70
|
+
while (j < line.length) {
|
|
71
|
+
if (line[j] === "'") {
|
|
72
|
+
if (line[j + 1] === "'") { j += 2; continue; } // '' is an escaped quote, not the end
|
|
73
|
+
j++;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
j++;
|
|
77
|
+
}
|
|
78
|
+
spans.push({ text: line.slice(i, j), code: false });
|
|
79
|
+
i = start = j;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === '$') {
|
|
83
|
+
const m = /^\$[A-Za-z0-9_]*\$/.exec(line.slice(i));
|
|
84
|
+
if (m) {
|
|
85
|
+
push(i, true);
|
|
86
|
+
const tag = m[0];
|
|
87
|
+
const end = line.indexOf(tag, i + tag.length);
|
|
88
|
+
if (end === -1) {
|
|
89
|
+
spans.push({ text: line.slice(i), code: false });
|
|
90
|
+
return { spans, inDollar: tag };
|
|
91
|
+
}
|
|
92
|
+
const stop = end + tag.length;
|
|
93
|
+
spans.push({ text: line.slice(i, stop), code: false });
|
|
94
|
+
i = start = stop;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (ch === '-' && line[i + 1] === '-') {
|
|
99
|
+
push(i, true);
|
|
100
|
+
spans.push({ text: line.slice(i), code: false });
|
|
101
|
+
return { spans, inDollar: undefined };
|
|
102
|
+
}
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
push(line.length, true);
|
|
106
|
+
return { spans, inDollar: dollar };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Apply `fn` to the CODE spans of a line only, leaving string/dollar/comment spans verbatim. */
|
|
110
|
+
function overCode(line: string, fn: (code: string) => string, inDollar?: string): { text: string; inDollar?: string } {
|
|
111
|
+
const { spans, inDollar: next } = splitCodeSpans(line, inDollar);
|
|
112
|
+
return { text: spans.map((s) => (s.code ? fn(s.text) : s.text)).join(''), inDollar: next };
|
|
113
|
+
}
|
|
114
|
+
|
|
22
115
|
/** True once this line OPENS a COPY data block (`COPY … FROM stdin;`) — data follows until `\.`. */
|
|
23
116
|
export function opensCopyData(line: string): boolean {
|
|
24
117
|
return /^\s*COPY\s+.*\sFROM\s+stdin;\s*$/i.test(line);
|
|
@@ -36,14 +129,49 @@ export function closesCopyData(line: string): boolean {
|
|
|
36
129
|
* `SET search_path` naming it. The schema token must be a plain identifier at both ends so a
|
|
37
130
|
* substring of another name (`from_archive`) is never touched.
|
|
38
131
|
*/
|
|
39
|
-
export function rewriteStatementLine(line: string, from: string, to: string): string {
|
|
132
|
+
export function rewriteStatementLine(line: string, from: string, to: string, inDollar?: string): string {
|
|
40
133
|
const f = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
134
|
+
// CODE spans only — a schema name inside a string literal is DATA (a JSON key, an enum value),
|
|
135
|
+
// and renaming it corrupts the output while every structural check still passes.
|
|
136
|
+
return overCode(line, (code) => {
|
|
137
|
+
// 1. Schema-qualified refs: `from.` or `"from".` → `to.` (normalize to bare; the token is safe).
|
|
138
|
+
let out = code.replace(new RegExp(`(^|[^A-Za-z0-9_$."])(?:${f}|"${f}")\\.`, 'g'), `$1${to}.`);
|
|
139
|
+
// 2. Standalone schema in CREATE/ALTER/DROP SCHEMA and search_path — the token as a whole word,
|
|
140
|
+
// bare or quoted, not followed by a dot (those were handled above).
|
|
141
|
+
out = out.replace(new RegExp(`(^|[^A-Za-z0-9_$.])(?:${f}|"${f}")(?![A-Za-z0-9_$."])`, 'g'), `$1${to}`);
|
|
142
|
+
return out;
|
|
143
|
+
}, inDollar).text;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The N-schema form of {@link rewriteStatementLine}, applied in ONE pass.
|
|
148
|
+
*
|
|
149
|
+
* The paired swap renames several schemas at once (`analytics` + `analytics_view`), and their
|
|
150
|
+
* names overlap by construction — a derived schema is conventionally the base name plus a
|
|
151
|
+
* suffix. Rewriting them one after another is not obviously safe to a reader even when it
|
|
152
|
+
* happens to be (the identifier-boundary rules make `analytics` miss `analytics_view`), and it
|
|
153
|
+
* gets less safe the moment someone picks different names. One pass with the longest name tried
|
|
154
|
+
* first removes the ordering question entirely.
|
|
155
|
+
*/
|
|
156
|
+
export function rewriteStatementLineMulti(line: string, map: Record<string, string>, inDollar?: string): string {
|
|
157
|
+
const froms = Object.keys(map);
|
|
158
|
+
if (froms.length === 0) return line;
|
|
159
|
+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
160
|
+
// Longest first: a schema whose name is a PREFIX of another must never win the alternation.
|
|
161
|
+
const alt = [...froms].sort((a, b) => b.length - a.length).map(esc).join('|');
|
|
162
|
+
const pick = (bare?: string, quoted?: string) => map[(bare ?? quoted)!];
|
|
163
|
+
// CODE spans only — see splitCodeSpans. A schema name inside quotes is DATA.
|
|
164
|
+
return overCode(line, (code) => {
|
|
165
|
+
let out = code.replace(
|
|
166
|
+
new RegExp(`(^|[^A-Za-z0-9_$."])(?:(${alt})|"(${alt})")\\.`, 'g'),
|
|
167
|
+
(_m, pre: string, bare: string, quoted: string) => `${pre}${pick(bare, quoted)}.`,
|
|
168
|
+
);
|
|
169
|
+
out = out.replace(
|
|
170
|
+
new RegExp(`(^|[^A-Za-z0-9_$.])(?:(${alt})|"(${alt})")(?![A-Za-z0-9_$."])`, 'g'),
|
|
171
|
+
(_m, pre: string, bare: string, quoted: string) => `${pre}${pick(bare, quoted)}`,
|
|
172
|
+
);
|
|
173
|
+
return out;
|
|
174
|
+
}, inDollar).text;
|
|
47
175
|
}
|
|
48
176
|
|
|
49
177
|
/**
|
|
@@ -55,13 +183,18 @@ export function rewriteStatementLine(line: string, from: string, to: string): st
|
|
|
55
183
|
export function rewriteSchemaDump(sql: string, from: string, to: string): string {
|
|
56
184
|
const lines = sql.split('\n');
|
|
57
185
|
let inCopy = false;
|
|
186
|
+
let inDollar: string | undefined;
|
|
58
187
|
for (let i = 0; i < lines.length; i++) {
|
|
59
188
|
if (inCopy) {
|
|
60
189
|
if (closesCopyData(lines[i])) inCopy = false;
|
|
61
190
|
continue; // data (or the terminator) — never rewritten
|
|
62
191
|
}
|
|
63
|
-
|
|
64
|
-
|
|
192
|
+
// Dollar-quote state carries ACROSS lines: a function body spanning them must stay literal
|
|
193
|
+
// for its whole length, not just the line that opened it.
|
|
194
|
+
const next = splitCodeSpans(lines[i], inDollar).inDollar;
|
|
195
|
+
lines[i] = rewriteStatementLine(lines[i], from, to, inDollar);
|
|
196
|
+
inDollar = next;
|
|
197
|
+
if (!inDollar && opensCopyData(lines[i])) inCopy = true;
|
|
65
198
|
}
|
|
66
199
|
return lines.join('\n');
|
|
67
200
|
}
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -38,6 +38,18 @@ function toCamelCase(name: string): string {
|
|
|
38
38
|
return name.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The export name for a model's drizzle binding — schema-qualified when the table
|
|
43
|
+
* does not live in `public`, so `analytics.post_metrics` and a hypothetical
|
|
44
|
+
* `public.post_metrics` cannot collide on one identifier.
|
|
45
|
+
*
|
|
46
|
+
* Same rule the derived relations already use, deliberately: the two halves of this
|
|
47
|
+
* file name things the same way.
|
|
48
|
+
*/
|
|
49
|
+
function modelCamel(model: { schema: string; table: string }): string {
|
|
50
|
+
return toCamelCase(model.schema === 'public' ? model.table : `${model.schema}_${model.table}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
/** A JS/TS string literal — single-quoted, the example's style. */
|
|
42
54
|
function strLiteral(value: string): string {
|
|
43
55
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
@@ -290,7 +302,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
290
302
|
const derivedRelations = typedRelations(_opts.derived ?? []);
|
|
291
303
|
|
|
292
304
|
const modelsByDescriptor = new Map<ModelDescriptor, { camel: string }>();
|
|
293
|
-
for (const model of models) modelsByDescriptor.set(model, { camel:
|
|
305
|
+
for (const model of models) modelsByDescriptor.set(model, { camel: modelCamel(model) });
|
|
294
306
|
|
|
295
307
|
// --- Collect enums (deduped by name, sorted) -----------------------------
|
|
296
308
|
const enumsByName = new Map<string, readonly string[]>();
|
|
@@ -312,7 +324,9 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
312
324
|
|
|
313
325
|
// --- Track which pg-core builders + drizzle-orm symbols are used ----------
|
|
314
326
|
const pgCoreBuilders = new Set<string>();
|
|
315
|
-
|
|
327
|
+
// Only when something actually lands in `public` — an all-non-public model set would
|
|
328
|
+
// otherwise import a builder it never calls.
|
|
329
|
+
if (models.some((m) => m.schema === 'public')) pgCoreBuilders.add('pgTable');
|
|
316
330
|
if (enumNames.length) pgCoreBuilders.add('pgEnum');
|
|
317
331
|
|
|
318
332
|
const relationNaming = planRelationNames(models);
|
|
@@ -321,7 +335,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
321
335
|
// --- Emit each table -------------------------------------------------------
|
|
322
336
|
const tableBlocks: string[] = [];
|
|
323
337
|
for (const model of models) {
|
|
324
|
-
const camel =
|
|
338
|
+
const camel = modelCamel(model);
|
|
325
339
|
const isComposite = model.primaryKey.length > 1;
|
|
326
340
|
|
|
327
341
|
const colLines: string[] = [];
|
|
@@ -361,11 +375,20 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
361
375
|
}
|
|
362
376
|
|
|
363
377
|
const cols = `{\n${colLines.join('\n')}\n}`;
|
|
378
|
+
// A non-public model must emit through `pgSchema(...)`, exactly as the runtime
|
|
379
|
+
// builder does (`toDrizzleTable`: pgSchema(model.schema).table(...)). A bare
|
|
380
|
+
// pgTable() resolves against the search_path at query time and silently reads
|
|
381
|
+
// `public.<table>` — a table that need not even exist. The app's models were all
|
|
382
|
+
// public until the analytics fixture, so this never surfaced.
|
|
383
|
+
const target = model.schema === 'public'
|
|
384
|
+
? `pgTable(${strLiteral(model.table)}`
|
|
385
|
+
: `pgSchema(${strLiteral(model.schema)}).table(${strLiteral(model.table)}`;
|
|
386
|
+
if (model.schema !== 'public') pgCoreBuilders.add('pgSchema');
|
|
364
387
|
let block: string;
|
|
365
388
|
if (extras.length) {
|
|
366
|
-
block = `export const ${camel} =
|
|
389
|
+
block = `export const ${camel} = ${target}, ${cols}, (t) => [\n${extras.join('\n')}\n]);`;
|
|
367
390
|
} else {
|
|
368
|
-
block = `export const ${camel} =
|
|
391
|
+
block = `export const ${camel} = ${target}, ${cols});`;
|
|
369
392
|
}
|
|
370
393
|
tableBlocks.push(block);
|
|
371
394
|
}
|
|
@@ -375,7 +398,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
375
398
|
for (const model of models) {
|
|
376
399
|
const relEntries = Object.entries(model.relations);
|
|
377
400
|
if (relEntries.length === 0) continue;
|
|
378
|
-
const camel =
|
|
401
|
+
const camel = modelCamel(model);
|
|
379
402
|
|
|
380
403
|
const usesOne = relEntries.some(([, r]) => r.kind === 'belongsTo');
|
|
381
404
|
const usesMany = relEntries.some(([, r]) => r.kind === 'hasMany');
|
|
@@ -384,7 +407,7 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
|
|
|
384
407
|
const lines: string[] = [];
|
|
385
408
|
for (const [key, rel] of relEntries) {
|
|
386
409
|
const target = rel.target();
|
|
387
|
-
const targetCamel =
|
|
410
|
+
const targetCamel = modelCamel(target);
|
|
388
411
|
const nameFrag = relationNameFragment(model, rel, relationNaming);
|
|
389
412
|
if (rel.kind === 'belongsTo') {
|
|
390
413
|
// belongsTo(() => Target, column, references?) -> one(...)
|
package/src/cli/schema-swap.ts
CHANGED
|
@@ -99,6 +99,14 @@ export interface SwapPlan {
|
|
|
99
99
|
/** The schema the live data is renamed to before the retiring drop (outside the txn). */
|
|
100
100
|
retiring: string;
|
|
101
101
|
incoming: string;
|
|
102
|
+
/**
|
|
103
|
+
* EVERY retiring schema the swap produced — the base schema first, then each paired derived
|
|
104
|
+
* schema. The caller drops all of them after verify; dropping only `retiring` would strand the
|
|
105
|
+
* derived twins as permanent clutter that the next swap then collides with.
|
|
106
|
+
*/
|
|
107
|
+
retiringSchemas: string[];
|
|
108
|
+
/** The paired derived schemas, in the order they were renamed. Empty for an unpaired swap. */
|
|
109
|
+
paired: string[];
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
export interface SwapOptions {
|
|
@@ -108,6 +116,27 @@ export interface SwapOptions {
|
|
|
108
116
|
incoming?: string;
|
|
109
117
|
/** Where the live schema is renamed. Default `<schema>_retiring`; pass a stamped name for uniqueness. */
|
|
110
118
|
retiring?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Derived schemas swapping ALONGSIDE the base schema (the paired swap). Each is renamed in the
|
|
121
|
+
* SAME transaction as the base, so a reader never sees a base schema paired with a derived layer
|
|
122
|
+
* built over the retiring one. Their incoming twins must already be built — see swap-pair.
|
|
123
|
+
*/
|
|
124
|
+
paired?: string[];
|
|
125
|
+
/** Suffix for the paired schemas' incoming twins. Must match the build. Default `_incoming`. */
|
|
126
|
+
incomingSuffix?: string;
|
|
127
|
+
/** Suffix for the paired schemas' retiring names. Default `_retiring`. */
|
|
128
|
+
retiringSuffix?: string;
|
|
129
|
+
/**
|
|
130
|
+
* `GRANT USAGE ON SCHEMA` for every schema in the swap set (see renderSwapSchemaUsage), applied
|
|
131
|
+
* INSIDE the swap transaction after the renames.
|
|
132
|
+
*
|
|
133
|
+
* The incoming schemas carry no schema-level ACL — the base one is restored `--no-privileges`,
|
|
134
|
+
* the derived twins are freshly created — so a swap that re-applies only table authz commits a
|
|
135
|
+
* set of schemas no application role can enter. Found on real infrastructure: every API endpoint
|
|
136
|
+
* 500'd, and because PostgreSQL reports missing USAGE as ABSENCE the error read
|
|
137
|
+
* `relation "..." does not exist`, which points nowhere near the cause.
|
|
138
|
+
*/
|
|
139
|
+
schemaUsage?: string[];
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
/**
|
|
@@ -135,15 +164,40 @@ export function renderSchemaSwap(models: ModelDescriptor[], opts: SwapOptions):
|
|
|
135
164
|
.map((m) => compileTableContract(m));
|
|
136
165
|
const authz = emitSwapAuthzSql({ tables: statsContracts, functions: [] });
|
|
137
166
|
|
|
167
|
+
// The paired derived schemas rename in the SAME transaction as the base. Order between pairs
|
|
168
|
+
// does not matter — nothing is resolved by name inside the transaction; the objects already
|
|
169
|
+
// bind their sources by OID, and a rename does not disturb an OID. What matters is that all of
|
|
170
|
+
// them commit together, so there is no instant where the new base serves under a derived layer
|
|
171
|
+
// still welded to the retiring one.
|
|
172
|
+
const paired = opts.paired ?? [];
|
|
173
|
+
const incomingSuffix = opts.incomingSuffix ?? '_incoming';
|
|
174
|
+
const retiringSuffix = opts.retiringSuffix ?? '_retiring';
|
|
175
|
+
const pairRenames: string[] = [];
|
|
176
|
+
const retiringSchemas = [retiring];
|
|
177
|
+
for (const p of paired) {
|
|
178
|
+
const pRetiring = `${p}${retiringSuffix}`;
|
|
179
|
+
const pIncoming = `${p}${incomingSuffix}`;
|
|
180
|
+
assertSafeSchema(p);
|
|
181
|
+
assertSafeSchema(pRetiring);
|
|
182
|
+
assertSafeSchema(pIncoming);
|
|
183
|
+
pairRenames.push(`ALTER SCHEMA "${p}" RENAME TO "${pRetiring}";`);
|
|
184
|
+
pairRenames.push(`ALTER SCHEMA "${pIncoming}" RENAME TO "${p}";`);
|
|
185
|
+
retiringSchemas.push(pRetiring);
|
|
186
|
+
}
|
|
187
|
+
|
|
138
188
|
const statements = [
|
|
139
189
|
...fks.map((f) => f.dropSql),
|
|
140
190
|
`ALTER SCHEMA "${schema}" RENAME TO "${retiring}";`,
|
|
141
191
|
`ALTER SCHEMA "${incoming}" RENAME TO "${schema}";`,
|
|
192
|
+
...pairRenames,
|
|
142
193
|
...fks.map((f) => f.addSql),
|
|
143
194
|
...authz,
|
|
195
|
+
// Schema-level USAGE last: the table grants above are dead without it, and it must ride the
|
|
196
|
+
// same transaction so there is no committed instant where the new schemas serve unreachable.
|
|
197
|
+
...(opts.schemaUsage ?? []),
|
|
144
198
|
];
|
|
145
199
|
|
|
146
|
-
return { statements, crossSchemaFks: fks, retiring, incoming };
|
|
200
|
+
return { statements, crossSchemaFks: fks, retiring, incoming, retiringSchemas, paired: [...paired] };
|
|
147
201
|
}
|
|
148
202
|
|
|
149
203
|
/** The drop of the retiring schema, run AFTER the swap transaction commits and verify passes. */
|