@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.
@@ -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: models.map((m) => compileTableContract(m, { schema })), functions: [] }, liveAuthzRenamed)
212
+ ? emitReconcileSql({ tables: desiredContracts, functions: [] }, liveAuthzRenamed)
212
213
  : [];
213
214
 
214
- return [...schemaPhase, ...dataPhase, ...authzPhase];
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
- // 1. Schema-qualified refs: `from.` or `"from".` `to.` (normalize to bare; the token is safe).
42
- let out = line.replace(new RegExp(`(^|[^A-Za-z0-9_$."])(?:${f}|"${f}")\\.`, 'g'), `$1${to}.`);
43
- // 2. Standalone schema in CREATE/ALTER/DROP SCHEMA and search_path — the token as a whole word,
44
- // bare or quoted, not followed by a dot (those were handled above).
45
- out = out.replace(new RegExp(`(^|[^A-Za-z0-9_$.])(?:${f}|"${f}")(?![A-Za-z0-9_$."])`, 'g'), `$1${to}`);
46
- return out;
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
- lines[i] = rewriteStatementLine(lines[i], from, to);
64
- if (opensCopyData(lines[i])) inCopy = true;
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
  }
@@ -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: toCamelCase(model.table) });
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
- pgCoreBuilders.add('pgTable');
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 = toCamelCase(model.table);
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} = pgTable(${strLiteral(model.table)}, ${cols}, (t) => [\n${extras.join('\n')}\n]);`;
389
+ block = `export const ${camel} = ${target}, ${cols}, (t) => [\n${extras.join('\n')}\n]);`;
367
390
  } else {
368
- block = `export const ${camel} = pgTable(${strLiteral(model.table)}, ${cols});`;
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 = toCamelCase(model.table);
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 = toCamelCase(target.table);
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(...)
@@ -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. */