@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 +1 -1
- package/src/cli/commands/db-swap.ts +114 -5
- 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 +268 -10
- package/src/cli/swap-pair.ts +443 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
|
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(...)
|