@everystack/cli 0.4.35 → 0.4.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.35",
3
+ "version": "0.4.36",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -26,6 +26,9 @@ import { fingerprintModels } from '../schema-fingerprint.js';
26
26
  import { resolveModelsPath } from '../models-path.js';
27
27
  import { loadModels } from './db-generate.js';
28
28
  import { loadDeclaredDerived } from '../declared-derived.js';
29
+ import type { SourceObject } from '../derived-source.js';
30
+ import { pairedDerivedSchemas, renderPairedDerivedBuild, renderSwapSchemaUsage, swapSchemaRoles, expectedIncomingObjects, renderPairedProvenance } from '../swap-pair.js';
31
+ import { introspectDerived } from '../derived-introspect.js';
29
32
  import { createUrlRunner } from '../db-source.js';
30
33
  import type { QueryRunner } from '../authz-contract.js';
31
34
  import { executeSwap, type SwapVerdict } from '../swap-execute.js';
@@ -413,17 +416,40 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
413
416
  if (!from) { fail('db:swap needs --from <artifact.dump | artifact-id> (the schema-scoped -Fc archive to land).'); process.exit(1); }
414
417
 
415
418
  // Resolve the venue.
416
- // - --database-url (or DATABASE_URL): a local/direct operator connection.
419
+ // - --database-url: a local/direct operator connection. EXPLICIT FLAG ONLY.
417
420
  // - --stage --direct: resolve the stage's OPERATOR connection from its ops Lambda and execute
418
421
  // CLI-side with an unbounded clock (a multi-GB restore blows the 900s Lambda ceiling). The
419
422
  // operator never holds a URL; the swap snapshots the stage via db:backup before it lands.
420
423
  // - --stage alone: refuse, naming --direct — the ops-Lambda venue can't hold the restore clock.
421
- let url = flags['database-url'] || process.env.DATABASE_URL;
424
+ //
425
+ // `process.env.DATABASE_URL` is NOT a venue here, and used to be.
426
+ //
427
+ // It was read first, and the stage branch was guarded by `if (!url && stage)` — so an exported
428
+ // DATABASE_URL SILENTLY OVERRODE `--stage`. An operator asking for dev got whatever the
429
+ // environment named, the stage's snapshot was skipped (the branch that sets snapshotViaStage
430
+ // never ran, hence the "direct v1" warning), and the swap reported success against a database
431
+ // nobody had asked for. A consumer hit exactly this: two runs differing only by an unrelated
432
+ // diagnostic flag went to different databases, because one shell had the variable exported and
433
+ // the other did not. Their dev derived layer was untouched because dev was never the target.
434
+ //
435
+ // On a DESTRUCTIVE verb an ambient variable must never choose the target, and db:export already
436
+ // states the rule: --database-url is explicit-flag-only, never the env — the venue must be
437
+ // deliberate. This is that rule, applied where it mattered most and was missing.
438
+ const urlFlag = flags['database-url'];
439
+ if (urlFlag && stage) {
440
+ fail(`db:swap got BOTH --database-url and --stage ${stage} — that is two different targets and the wrong one is destructive. Pass exactly one.`);
441
+ process.exit(1);
442
+ }
443
+ if (!urlFlag && !stage && process.env.DATABASE_URL) {
444
+ fail('db:swap will not take its target from the DATABASE_URL environment variable — a destructive swap must name its target explicitly. Pass --database-url <url> (local/direct) or --stage <name> --direct.');
445
+ process.exit(1);
446
+ }
447
+ let url = urlFlag;
422
448
  let snapshotViaStage = false;
423
449
  let region: string | undefined;
424
450
  let opsFn: string | undefined;
425
451
 
426
- if (!url && stage) {
452
+ if (stage) {
427
453
  if (!direct) {
428
454
  fail('db:swap --stage needs --direct: a schema restore can exceed the ops-Lambda 900-second clock, so the swap runs CLI-side with an unbounded clock (credential-free — the operator never holds a URL). Re-run with --stage ' + stage + ' --direct.');
429
455
  process.exit(1);
@@ -451,15 +477,18 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
451
477
 
452
478
  // --rebuild-derived carries a real outage window: the derived layer does not exist between the
453
479
  // swap committing and db:reconcile --apply finishing. State it BEFORE the work starts — saying it
454
- // only afterward tells the operator about an outage they are already in.
480
+ // only afterward tells the operator about an outage they are already in. It is now the OPT-OUT:
481
+ // the paired swap below is the default and has no window at all.
455
482
  if (flags['rebuild-derived'] === 'true') {
456
483
  warn('--rebuild-derived drops the dependent derived objects as part of the swap. They do NOT exist until db:reconcile --apply finishes — an outage window proportional to the size of the derived layer.');
484
+ warn(' the paired swap (the default, without this flag) rebuilds the layer over the incoming data and renames it in the same transaction — no window. Drop the flag unless you specifically want the old behaviour.');
457
485
  }
458
486
 
459
487
  const modelsPath = resolveModelsPath(flags.models);
460
488
  let models: ModelDescriptor[];
461
489
  let declaredFingerprint: string;
462
- let declaredDerivedObjects: Array<{ identity: string }> = [];
490
+ let declaredDerivedObjects: SourceObject[] = [];
491
+ let paired: string[] = [];
463
492
  try {
464
493
  step(`Loading models from ${modelsPath}...`);
465
494
  models = await loadModels(modelsPath);
@@ -467,8 +496,19 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
467
496
  declaredFingerprint = fingerprintModels(models, { schemas: [schema], sequences: declaredDb?.sequences }).hash;
468
497
  // The identities db:reconcile can regenerate — what makes a dependent safe to drop.
469
498
  declaredDerivedObjects = declaredDb?.objects ?? [];
499
+ // The PAIRED swap is the default whenever a declared derived schema hangs off this one: the
500
+ // layer is rebuilt over the incoming tables and renamed in the same transaction, so it is
501
+ // never absent. --rebuild-derived is the explicit opt-out (drop, swap, reconcile after), kept
502
+ // for the case where rebuilding twice is not worth the zero-downtime guarantee.
503
+ if (flags['rebuild-derived'] !== 'true' && declaredDb) {
504
+ paired = pairedDerivedSchemas(models, declaredDb.derived, schema);
505
+ }
470
506
  } catch (err: any) { fail(err.message); process.exit(1); }
471
507
 
508
+ if (paired.length > 0) {
509
+ info(`paired swap: ${paired.join(', ')} will be rebuilt over the incoming data and renamed in the SAME transaction — the derived layer is never absent.`);
510
+ }
511
+
472
512
  // Resolve --from to a local plain -Fc dump (a local file, or an S3 export id fetched down).
473
513
  let artifact: ResolvedArtifact;
474
514
  try {
@@ -496,6 +536,68 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
496
536
  // What db:reconcile can regenerate — the set a dependent must be in to be safe to drop.
497
537
  declaredIdentities: declaredDerivedObjects.map((o) => o.identity),
498
538
  rebuildDerived: flags['rebuild-derived'] === 'true',
539
+ paired,
540
+ // Schema-level USAGE, re-applied in the swap transaction and asserted after it commits.
541
+ // The incoming schemas arrive with no schema ACL, so without this the swap lands correct
542
+ // data behind schemas the app cannot enter — every endpoint 500s with "does not exist".
543
+ schemaUsage: renderSwapSchemaUsage(models, declaredDerivedObjects, schema, paired),
544
+ schemaUsageRoles: [...swapSchemaRoles(models, declaredDerivedObjects, [schema, ...paired])]
545
+ .flatMap(([s, roles]) => roles.map((role) => ({ schema: s, role }))),
546
+ // Every declared object the paired build must produce. Checked before the rename, so a
547
+ // partial layer refuses with live untouched rather than committing a silent shortfall.
548
+ expectedDerived: paired.length
549
+ ? expectedIncomingObjects(declaredDerivedObjects, schema, paired)
550
+ : undefined,
551
+ // Tell the reconciler what the build made. Without this the next db:reconcile — days
552
+ // later, for an unrelated edit — sees the whole layer as drift and rebuilds it under
553
+ // ACCESS EXCLUSIVE. The objects are correct; only the bookkeeping was missing.
554
+ recordProvenance: paired.length
555
+ ? async (r) => {
556
+ const live = await introspectDerived(r);
557
+ const prov = renderPairedProvenance(declaredDerivedObjects, live.objects, schema, paired);
558
+ for (const st of prov.statements) await r(st);
559
+ if (prov.recorded.length === 0) {
560
+ warn(`provenance recorded NOTHING — no declared object in ${[schema, ...paired].join(', ')} matched a live catalog entry. `
561
+ + `The swap itself succeeded, but the next db:reconcile will treat this layer as drift. Run db:reconcile --check to see what it thinks.`);
562
+ } else {
563
+ info(`provenance recorded for ${prov.recorded.length} object(s) — a post-swap db:reconcile is NOT required.`);
564
+ if (prov.unmatched.length) {
565
+ warn(` ${prov.unmatched.length} declared object(s) had no live catalog entry and were NOT recorded: ${prov.unmatched.slice(0, 10).join(', ')}${prov.unmatched.length > 10 ? ', …' : ''}`);
566
+ }
567
+ }
568
+ }
569
+ : undefined,
570
+ // Build the incoming derived layer over <schema>_incoming, before the rename. Skipped
571
+ // entirely when nothing pairs — an all-public app's swap is byte-identical to before.
572
+ buildPairedDerived: paired.length
573
+ ? async (r) => {
574
+ step(`Building the incoming derived layer (${paired.join(', ')})...`);
575
+ const built = renderPairedDerivedBuild(declaredDerivedObjects, schema, paired);
576
+ for (const s of built.statements) await r(s);
577
+ // --dump-build: diagnostic only, no behaviour change. Writes what the build EMITTED
578
+ // and what the catalog HOLDS immediately afterwards, before the rename. Those two
579
+ // together separate "the build rendered the wrong SQL" from "the build was fine and
580
+ // the rename lost it" — a distinction that is otherwise only reachable by racing a
581
+ // second connection against the build window.
582
+ if (flags['dump-build']) {
583
+ const twins = paired.map((p) => `${p}_incoming`);
584
+ const rows = await r(
585
+ `SELECT n.nspname || '.' || c.relname AS identity, pg_get_viewdef(c.oid) AS definition
586
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
587
+ WHERE n.nspname = ANY (ARRAY[${twins.map((t) => `'${t.replace(/'/g, "''")}'`).join(',')}])
588
+ AND c.relkind IN ('v','m') ORDER BY 1`,
589
+ );
590
+ fs.writeFileSync(flags['dump-build'], JSON.stringify({
591
+ schema, paired,
592
+ searchPath: built.statements[0],
593
+ statementCount: built.statements.length,
594
+ statements: built.statements,
595
+ liveInIncomingAfterBuild: rows,
596
+ }, null, 2));
597
+ info(`--dump-build: wrote ${built.statements.length} rendered statement(s) and ${rows.length} live definition(s) to ${flags['dump-build']}`);
598
+ }
599
+ }
600
+ : undefined,
499
601
  log: (m) => info(m),
500
602
  // The runner is handed in and USED: it is idle for the whole restore, so the Phase B
501
603
  // heartbeat reads the loading backend's state over it (swap-heartbeat.ts).
@@ -526,6 +628,13 @@ export async function dbSwapCommand(flags: Record<string, string>): Promise<void
526
628
  if (flags['rebuild-derived'] === 'true') {
527
629
  warn(`the derived objects depending on ${schema} were dropped — they do NOT exist until you regenerate them.`);
528
630
  warn(` run now: everystack db:reconcile --apply --stage ${stage ?? '<stage>'} --direct`);
631
+ } else if (paired.length > 0) {
632
+ // Say it in the OUTPUT, not just the docs. The two-step "swap then reconcile" was correct
633
+ // advice for the unpaired swap and is baked into deploy scripts written against it. Run
634
+ // now, that reconcile drops and recreates the layer the swap just built correctly, holding
635
+ // ACCESS EXCLUSIVE — turning a zero-downtime swap into an outage. Seen in the field.
636
+ info(`the derived layer (${paired.join(', ')}) is live and complete — a post-swap db:reconcile is NOT required.`);
637
+ info(` if your deploy script still runs one, remove it: it would drop and rebuild these objects under ACCESS EXCLUSIVE and cause the outage this swap exists to avoid.`);
529
638
  }
530
639
  } else {
531
640
  fail(`db:swap ${res.status}: ${res.reason}`);
@@ -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
 
@@ -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.get(id) && isRelation(liveById.get(id)!.kind));
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
- for (const obj of compileDerived(models, modules.flatMap((m) => m.derived))) {
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: 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(...)