@everystack/cli 0.4.34 → 0.4.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/commands/db-backup.ts +4 -2
- package/src/cli/commands/db-export.ts +3 -2
- package/src/cli/commands/db-swap.ts +397 -34
- package/src/cli/commands/db.ts +55 -8
- package/src/cli/derived-introspect.ts +30 -0
- package/src/cli/derived-plan.ts +16 -1
- package/src/cli/migration-compile.ts +12 -1
- package/src/cli/migration-generate.ts +31 -2
- package/src/cli/mutation-lease.ts +8 -0
- package/src/cli/schema-rewrite.ts +142 -9
- package/src/cli/schema-source.ts +30 -7
- package/src/cli/schema-swap.ts +55 -1
- package/src/cli/swap-execute.ts +524 -5
- package/src/cli/swap-heartbeat.ts +407 -0
- package/src/cli/swap-pair.ts +443 -0
|
@@ -218,6 +218,36 @@ WHERE d.classid = 'pg_rewrite'::regclass
|
|
|
218
218
|
AND rn.nspname NOT LIKE 'pg_%'
|
|
219
219
|
AND NOT EXISTS (
|
|
220
220
|
SELECT 1 FROM pg_depend dep WHERE dep.objid = rp.oid AND dep.deptype = 'e'
|
|
221
|
+
)
|
|
222
|
+
UNION
|
|
223
|
+
-- A FUNCTION depending on a view/matview's composite ROW TYPE — RETURNS SETOF <view>,
|
|
224
|
+
-- or a view rowtype as an argument.
|
|
225
|
+
--
|
|
226
|
+
-- Both branches above are rooted at pg_rewrite, so their dependent side is always a view or
|
|
227
|
+
-- matview; this edge has a function on the dependent side and is recorded against pg_type, not
|
|
228
|
+
-- pg_class. Nothing found it, so the cascade could not see that dropping the view required
|
|
229
|
+
-- dropping the function first — and PostgreSQL refuses the drop:
|
|
230
|
+
--
|
|
231
|
+
-- cannot drop materialized view post_engagement because other objects depend on it
|
|
232
|
+
-- DETAIL: function analytics.engagement_for_author depends on type analytics_view.post_engagement
|
|
233
|
+
--
|
|
234
|
+
-- Latent until something UPSTREAM of such a view actually changes, which is why an app can carry
|
|
235
|
+
-- this shape for a long time and only meet it the first time the view has to rebuild.
|
|
236
|
+
SELECT DISTINCT
|
|
237
|
+
dn.nspname, dp.proname, rn.nspname, rc.relname
|
|
238
|
+
FROM pg_depend d
|
|
239
|
+
JOIN pg_proc dp ON dp.oid = d.objid
|
|
240
|
+
JOIN pg_namespace dn ON dn.oid = dp.pronamespace
|
|
241
|
+
JOIN pg_type rt ON rt.oid = d.refobjid
|
|
242
|
+
JOIN pg_class rc ON rc.oid = rt.typrelid
|
|
243
|
+
JOIN pg_namespace rn ON rn.oid = rc.relnamespace
|
|
244
|
+
WHERE d.classid = 'pg_proc'::regclass
|
|
245
|
+
AND d.refclassid = 'pg_type'::regclass
|
|
246
|
+
AND rc.relkind IN ('v', 'm')
|
|
247
|
+
AND rn.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
248
|
+
AND rn.nspname NOT LIKE 'pg_%'
|
|
249
|
+
AND NOT EXISTS (
|
|
250
|
+
SELECT 1 FROM pg_depend dep WHERE dep.objid = dp.oid AND dep.deptype = 'e'
|
|
221
251
|
);
|
|
222
252
|
`.trim();
|
|
223
253
|
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -419,9 +419,19 @@ export function planReconcile(
|
|
|
419
419
|
};
|
|
420
420
|
|
|
421
421
|
// Relation roots whose live dependents must be handled: rebuilds and drops.
|
|
422
|
+
//
|
|
423
|
+
// FUNCTIONS ARE IN THE CLOSURE, not filtered out. A function whose return type is a view's
|
|
424
|
+
// composite rowtype (`RETURNS SETOF <view>`) makes PostgreSQL refuse to drop that view while
|
|
425
|
+
// the function exists. Excluding functions here left the cascade unable to express that, so a
|
|
426
|
+
// view backing a function's return type could never be rebuilt: the plan plotted the view's
|
|
427
|
+
// drop, counted the function as up to date, and the apply died on the drop.
|
|
428
|
+
//
|
|
429
|
+
// A function pulled in this way must be DROP + CREATE, never CREATE OR REPLACE. Replace does
|
|
430
|
+
// not drop, so the view's drop still runs with the function present and fails exactly as
|
|
431
|
+
// before — the `rebuild` map is what puts an identity in BOTH the drop set and the create set.
|
|
422
432
|
const relationRoots = [...rebuild.keys(), ...drop.keys()];
|
|
423
433
|
for (const root of relationRoots) {
|
|
424
|
-
const closure = closureOf(root).filter((id) => liveById.
|
|
434
|
+
const closure = closureOf(root).filter((id) => liveById.has(id));
|
|
425
435
|
const blockers = closure.filter((id) => !srcById.has(id) && !drop.has(id));
|
|
426
436
|
if (blockers.length > 0) {
|
|
427
437
|
blocked.push({
|
|
@@ -434,7 +444,12 @@ export function planReconcile(
|
|
|
434
444
|
}
|
|
435
445
|
for (const dep of closure) {
|
|
436
446
|
if (!rebuild.has(dep) && !drop.has(dep) && srcById.has(dep)) {
|
|
447
|
+
const kind = liveById.get(dep)!.kind;
|
|
437
448
|
rebuild.set(dep, `dependency rebuild (depends on ${root})`);
|
|
449
|
+
// Promoting a function out of the replace lane: a plain replace here would be a no-op
|
|
450
|
+
// against the problem, and leaving it in both lanes would emit a replace AND a
|
|
451
|
+
// drop+create for the same object.
|
|
452
|
+
if (!isRelation(kind)) fnReplace.delete(dep);
|
|
438
453
|
}
|
|
439
454
|
}
|
|
440
455
|
}
|
|
@@ -26,6 +26,7 @@ import { compileTableContract } from './authz-compile.js';
|
|
|
26
26
|
import { emitReconcileSql } from './authz-reconcile.js';
|
|
27
27
|
import { emitSchemaSql, nextvalSequence, type SchemaChange } from './schema-diff.js';
|
|
28
28
|
import { compileDerived } from './derived-compile.js';
|
|
29
|
+
import { renderEnsureObjectSchemas } from './derived-apply.js';
|
|
29
30
|
|
|
30
31
|
/** The empty (not-yet-created) authz state for a table — the greenfield baseline. */
|
|
31
32
|
function emptyTable(table: string): TableContract {
|
|
@@ -155,7 +156,17 @@ export function compileModuleMigration(modules: Module[], opts: CompileTableOpti
|
|
|
155
156
|
// objects — compiled in topological order, after every table. Greenfield = one
|
|
156
157
|
// complete script: state + compute; from then on the layer deploys via db:reconcile.
|
|
157
158
|
const models = modules.flatMap((m) => m.models);
|
|
158
|
-
|
|
159
|
+
const derivedObjects = compileDerived(models, modules.flatMap((m) => m.derived));
|
|
160
|
+
|
|
161
|
+
// 4a. A schema that ONLY derived objects live in has no model to create it, so phase 2
|
|
162
|
+
// never does — and the first CREATE VIEW in it fails with `schema … does not exist`.
|
|
163
|
+
// db:reconcile has always handled this; the greenfield module migration did not, so a
|
|
164
|
+
// derived-only schema was buildable by reconcile and not by a from-scratch deploy.
|
|
165
|
+
// Same renderer as the reconcile path, deliberately: one implementation, one behaviour.
|
|
166
|
+
// Idempotent — a schema phase 2 already created is a no-op here.
|
|
167
|
+
sql.push(...renderEnsureObjectSchemas(derivedObjects).map((s) => `${s};`));
|
|
168
|
+
|
|
169
|
+
for (const obj of derivedObjects) {
|
|
159
170
|
sql.push(`${obj.sql};`);
|
|
160
171
|
for (const a of obj.attachments) sql.push(`${a.sql};`);
|
|
161
172
|
}
|
|
@@ -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. */
|