@everystack/cli 0.4.100 → 0.4.103
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/CHANGELOG.md +112 -0
- package/package.json +3 -3
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +91 -14
- package/src/cli/authz-contract.ts +29 -14
- package/src/cli/authz-derive.ts +284 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +32 -13
- package/src/cli/commands/db-pull.ts +155 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +5 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +92 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index-lock.ts +65 -0
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +787 -0
- package/src/cli/lifecycle-pull.ts +252 -0
- package/src/cli/lifecycle-verify.ts +238 -0
- package/src/cli/migration-compile.ts +11 -5
- package/src/cli/migration-generate.ts +24 -3
- package/src/cli/model-api.ts +9 -0
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +232 -76
- package/src/cli/plain-output.ts +211 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +64 -18
- package/src/cli/schema-diff.ts +166 -31
- package/src/cli/schema-fingerprint.ts +40 -6
- package/src/cli/schema-introspect.ts +97 -4
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
package/src/cli/model-render.ts
CHANGED
|
@@ -9,22 +9,25 @@
|
|
|
9
9
|
* It renders what the field builder can express today (type, primary key, not-null,
|
|
10
10
|
* single-column unique, single-column references, the common defaults) and SURFACES what it
|
|
11
11
|
* can't as inline comments rather than faking it: an unmapped column type, a composite
|
|
12
|
-
* unique, a CHECK that needs a manual `.validate()
|
|
13
|
-
* pulled set
|
|
12
|
+
* unique, a CHECK that needs a manual `.validate()`. A foreign key to a table outside the
|
|
13
|
+
* pulled set is declared in the external `{ schema, table, column }` form.
|
|
14
14
|
*
|
|
15
15
|
* Pure and deterministic — the command shell (commands/db-pull.ts) does the IO.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { SchemaSnapshot, TableSchema, ColumnSchema, CheckConstraint } from './schema-introspect';
|
|
19
|
+
import { foreignKeyTargetKey } from './schema-introspect';
|
|
19
20
|
import type { DerivedRenderResult } from './derived-render';
|
|
20
21
|
import type { TableContract } from './authz-contract';
|
|
21
22
|
import { deriveAbilities, renderDerivedAbilities } from './authz-derive';
|
|
22
23
|
import { normalizeDefault, normalizeCheck } from './schema-diff';
|
|
23
24
|
import { generatedIndexName, generatedForeignKeyName } from './schema-compile';
|
|
25
|
+
import { templateBody, tsString as tsLiteral, commentText } from './ts-template';
|
|
26
|
+
import { safeBinding, safeKey, safeFileStem, liveObject } from './generated-names';
|
|
24
27
|
// The soft-delete RULE itself — one boolean combination, shared with `defineModel`'s refusal.
|
|
25
28
|
// Only the FACT EXTRACTION is duplicated here (this renderer holds text, not descriptors);
|
|
26
29
|
// the rule is not, so the two surfaces cannot drift on when a declaration is required.
|
|
27
|
-
import { needsSoftDeleteDeclaration, privilegesGrantDelete, positionalCheckName } from '@everystack/model';
|
|
30
|
+
import { needsSoftDeleteDeclaration, privilegesGrantDelete, positionalCheckName, externalIdentifierRefusal } from '@everystack/model';
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
|
|
@@ -85,13 +88,21 @@ export interface RenderOptions {
|
|
|
85
88
|
*/
|
|
86
89
|
liveAuthz?: Map<string, TableContract>;
|
|
87
90
|
/**
|
|
88
|
-
* `--govern-roles` — the operator's explicit decision to
|
|
89
|
-
* grants
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
* `--govern-roles` — the operator's explicit decision to GOVERN these foreign roles: their
|
|
92
|
+
* grants and policies render as role-scoped `can()` where they pair exactly, `privileges`
|
|
93
|
+
* otherwise, and the module footer declares them in `governedRoles`. Required for migration
|
|
94
|
+
* deletion when the migrations created grants to roles outside the vocabulary (an
|
|
95
|
+
* ops/connection role): a fresh build from models must recreate them or the deletion loses
|
|
96
|
+
* the ops lane its access. Never inferred — see deriveAbilities. Complete-or-refuse per role.
|
|
93
97
|
*/
|
|
94
98
|
governRoles?: string[];
|
|
99
|
+
/**
|
|
100
|
+
* Each FK's target as the CATALOG names it (FK_TARGETS_SQL → foreignKeyTargets). db:pull always
|
|
101
|
+
* passes it: an external target is then declared from these exact names, and a key it does not
|
|
102
|
+
* hold renders as a comment. Absent (a pure caller), the deparsed `refTable` is split instead,
|
|
103
|
+
* which is ambiguous for a quoted name holding a dot.
|
|
104
|
+
*/
|
|
105
|
+
fkTargets?: FkTargets;
|
|
95
106
|
/** The rendered derived layer (B5) — rides the barrel: block after the models,
|
|
96
107
|
* sequences/derived arrays on the module wrapper, symbols on the import header. */
|
|
97
108
|
derived?: DerivedRenderResult;
|
|
@@ -99,6 +110,10 @@ export interface RenderOptions {
|
|
|
99
110
|
* wires it instead of embedding it. Mutually exclusive with `derived` in practice —
|
|
100
111
|
* passing neither is what silently produced `defineModule({ models })`. */
|
|
101
112
|
externalDerived?: ExternalDerived;
|
|
113
|
+
/** Tables db:pull folded to `lifecycle: true` (lifecycle-pull.ts): rendered with that key, no softDelete line. */
|
|
114
|
+
lifecycleTables?: ReadonlySet<string>;
|
|
115
|
+
/** lifecycle: { deleteWhen }'s predicate per folded table (lifecycle-pull.ts), rendered verbatim. */
|
|
116
|
+
lifecycleDeleteWhen?: ReadonlyMap<string, string>;
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
/**
|
|
@@ -363,6 +378,21 @@ function schemaOf(table: string): string {
|
|
|
363
378
|
return dot === -1 ? 'public' : table.slice(0, dot);
|
|
364
379
|
}
|
|
365
380
|
|
|
381
|
+
/** `users` / `public.users` → `public.users`; `a6.users` stays. pg_get_constraintdef leaves a public ref bare. */
|
|
382
|
+
function qualifiedName(table: string): string {
|
|
383
|
+
return `${schemaOf(table)}.${bareName(table)}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The pulled set, keyed on QUALIFIED names: an FK renders as a model reference exactly when its
|
|
388
|
+
* qualified target is pulled. Keyed on bare names, a pull of `a6` alone (holding `a6.users`)
|
|
389
|
+
* rendered an FK into `public.users` as `() => User` and imported it from a file the pull never
|
|
390
|
+
* wrote. A caller's bare entry still reads as public.
|
|
391
|
+
*/
|
|
392
|
+
function knownSet(tables: Iterable<string>): Set<string> {
|
|
393
|
+
return new Set([...tables].map(qualifiedName));
|
|
394
|
+
}
|
|
395
|
+
|
|
366
396
|
/**
|
|
367
397
|
* A table name → its model variable: PascalCase, naively singularized (one trailing
|
|
368
398
|
* `s` strips, whatever the word). `image_variants` → `ImageVariant`.
|
|
@@ -383,7 +413,7 @@ export function modelVarName(table: string): string {
|
|
|
383
413
|
// nothing renames when a second schema arrives. Same rule the derived renderer already
|
|
384
414
|
// uses for its own symbols.
|
|
385
415
|
const owner = schemaOf(table);
|
|
386
|
-
return owner === 'public' ? pascal(singular) : pascal(owner) + pascal(singular);
|
|
416
|
+
return safeBinding(owner === 'public' ? pascal(singular) : pascal(owner) + pascal(singular), liveObject('table', table));
|
|
387
417
|
}
|
|
388
418
|
|
|
389
419
|
/**
|
|
@@ -489,11 +519,6 @@ function parsePgArrayElements(literal: string): Array<{ raw: string; quoted: boo
|
|
|
489
519
|
return out;
|
|
490
520
|
}
|
|
491
521
|
|
|
492
|
-
/** A single-quoted TS string literal (the model idiom), escaping embedded quotes. */
|
|
493
|
-
function tsLiteral(value: string): string {
|
|
494
|
-
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
522
|
/**
|
|
498
523
|
* The `field.serial()`/`field.bigserial()` factory for an integer/bigint column whose
|
|
499
524
|
* default is its own conventional sequence (`<table>_<column>_seq`, optionally
|
|
@@ -509,7 +534,7 @@ function serialFactoryCall(table: TableSchema, col: ColumnSchema): string | null
|
|
|
509
534
|
}
|
|
510
535
|
|
|
511
536
|
/** One field line: ` authorId: field.uuid().notNull().references(() => Profile),` (+ any surfacing comment). */
|
|
512
|
-
function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>, enums: Map<string, string[]>, validates: Map<string, string
|
|
537
|
+
function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>, enums: Map<string, string[]>, validates: Map<string, string>, fkTargets?: FkTargets): string {
|
|
513
538
|
// An enum column's type is the enum's name; render the explicit-name field.enum with its
|
|
514
539
|
// values (the always-explicit form round-trips the name and the value order).
|
|
515
540
|
const enumValues = enums.get(col.type);
|
|
@@ -522,7 +547,7 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
522
547
|
// type is the identity, so the round trip is clean: no coercion, no FIXME, no
|
|
523
548
|
// plan statement. The note stays — a verbatim type is a fact worth seeing.
|
|
524
549
|
let expr = call ?? verbatimFieldCall(col.type);
|
|
525
|
-
let comment = call ? '' : ` // verbatim: no first-class field for '${col.type}' — carried as-is`;
|
|
550
|
+
let comment = call ? '' : ` // verbatim: no first-class field for '${commentText(col.type)}' — carried as-is`;
|
|
526
551
|
|
|
527
552
|
// The auto-value the database actually holds. Rendered BEFORE .primaryKey()/.notNull() so a
|
|
528
553
|
// pulled surrogate key reads the way one is authored, and rendered at all because without it
|
|
@@ -540,26 +565,20 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
540
565
|
|
|
541
566
|
const fk = table.foreignKeys.find((f) => f.columns.length === 1 && f.columns[0] === col.name);
|
|
542
567
|
if (fk) {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
|
|
546
|
-
fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
|
|
547
|
-
].filter(Boolean);
|
|
548
|
-
// A8 — the FK's own name, said only when it is not the one the compiler would generate.
|
|
549
|
-
if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
|
|
550
|
-
actions.push(`name: ${tsLiteral(fk.name)}`);
|
|
551
|
-
}
|
|
552
|
-
// The key is live and has NEVER been checked against the rows already in the table. Rendering
|
|
553
|
-
// it as an ordinary reference would declare a state this database is not in — and the FIRST
|
|
554
|
-
// plan after the pull would then emit a `VALIDATE` the legacy rows cannot pass. On the schema
|
|
555
|
-
// this was measured against that was 12 of 23 keys, every one of them deliberately NOT VALID
|
|
556
|
-
// in the app's own migration ("enforce forward, grandfather legacy"). So the pull SAYS what it
|
|
557
|
-
// found, the adoption plan comes out empty, and removing the flag later is how an operator
|
|
558
|
-
// opts into the scan. Same rule as the index and constraint names one line up: be faithful.
|
|
559
|
-
if (fk.notValid) actions.push('validate: false');
|
|
560
|
-
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
568
|
+
const opts = foreignKeyOptions(table, fk);
|
|
569
|
+
if (known.has(qualifiedName(fk.refTable))) {
|
|
561
570
|
expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
|
|
562
|
-
} else
|
|
571
|
+
} else {
|
|
572
|
+
// F3e — the target is outside the pulled set, so it is declared as what it is: a table no
|
|
573
|
+
// model declares. The FK stays in the declared state, so pull then plan is empty; as a
|
|
574
|
+
// comment it was undeclared, and the first plan proposed to drop it.
|
|
575
|
+
const target = externalTarget(table, fk, fkTargets);
|
|
576
|
+
if (target.ok) {
|
|
577
|
+
expr += `.references({ schema: ${tsLiteral(target.schema)}, table: ${tsLiteral(target.table)}, `
|
|
578
|
+
+ `column: ${tsLiteral(fk.refColumns[0])} }${opts})`;
|
|
579
|
+
comment += externalCascadeComment(table, fk);
|
|
580
|
+
} else comment += ` // FK → ${commentText(target.shown)} (not in the pulled set)`;
|
|
581
|
+
}
|
|
563
582
|
}
|
|
564
583
|
|
|
565
584
|
if (col.default != null && !serial) {
|
|
@@ -568,13 +587,125 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
568
587
|
// normalized form — stripping a trailing cast here could change semantics
|
|
569
588
|
// (`'7 days'::interval`); the diff normalizes both sides for comparison instead.
|
|
570
589
|
// Drift closed by construction: pull output converges instead of shedding a TODO.
|
|
571
|
-
expr += d ?? `.defaultSql(sql\`${col.default}\`)`;
|
|
590
|
+
expr += d ?? `.defaultSql(sql\`${templateBody(col.default)}\`)`;
|
|
572
591
|
}
|
|
573
592
|
|
|
574
593
|
const validate = validates.get(col.name);
|
|
575
594
|
if (validate) expr += `.validate(${validate})`;
|
|
576
595
|
|
|
577
|
-
|
|
596
|
+
const key = safeKey(toCamelCase(col.name), `${liveObject('column', col.name)} of ${liveObject('table', table.table)}`);
|
|
597
|
+
return ` ${key}: ${expr},${comment}`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
type LiveForeignKey = TableSchema['foreignKeys'][number];
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The `, { … }` options a pulled FK renders with, '' when it needs none. Shared by the model
|
|
604
|
+
* thunk, the external form and the composite form, so all three say the same things.
|
|
605
|
+
*/
|
|
606
|
+
function foreignKeyOptions(table: TableSchema, fk: LiveForeignKey): string {
|
|
607
|
+
const actions = [
|
|
608
|
+
fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
|
|
609
|
+
fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
|
|
610
|
+
].filter(Boolean);
|
|
611
|
+
// A8 — the FK's own name, said only when it is not the one the compiler would generate.
|
|
612
|
+
if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
|
|
613
|
+
actions.push(`name: ${tsLiteral(fk.name)}`);
|
|
614
|
+
}
|
|
615
|
+
// The key is live and has NEVER been checked against the rows already in the table. Rendering
|
|
616
|
+
// it as an ordinary reference would declare a state this database is not in — and the FIRST
|
|
617
|
+
// plan after the pull would then emit a `VALIDATE` the legacy rows cannot pass. On the schema
|
|
618
|
+
// this was measured against that was 12 of 23 keys, every one of them deliberately NOT VALID
|
|
619
|
+
// in the app's own migration ("enforce forward, grandfather legacy"). So the pull SAYS what it
|
|
620
|
+
// found, the adoption plan comes out empty, and removing the flag later is how an operator
|
|
621
|
+
// opts into the scan. Same rule as the index and constraint names: be faithful.
|
|
622
|
+
if (fk.notValid) actions.push('validate: false');
|
|
623
|
+
return actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** (owning table, FK name) → the target's exact schema and table, from the catalog. */
|
|
627
|
+
export type FkTargets = ReadonlyMap<string, { schema: string; table: string }>;
|
|
628
|
+
|
|
629
|
+
/** `schema.table`, with a part double-quoted when it holds a dot or a quote: public."a.b", never public.a.b. */
|
|
630
|
+
function unambiguousName(...parts: string[]): string {
|
|
631
|
+
return parts.map((p) => (/[."]/.test(p) ? `"${p.replace(/"/g, '""')}"` : p)).join('.');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
type ExternalTarget =
|
|
635
|
+
| { ok: true; schema: string; table: string }
|
|
636
|
+
| { ok: false; shown: string; reason: string };
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The exact target of an out-of-set FK, and whether the external form can declare it.
|
|
640
|
+
*
|
|
641
|
+
* The schema and table come from the CATALOG (`fkTargets`, by confrelid), never from splitting
|
|
642
|
+
* the deparsed `refTable`: quotes are stripped there, so public."a.b" reads as `a.b`, and a split
|
|
643
|
+
* declared it as { schema: 'a', table: 'b' }, another table, while plan and fingerprint agreed
|
|
644
|
+
* (both sides compile to `a.b`). A key the catalog map does not hold fails closed. Without a map
|
|
645
|
+
* (a pure caller), the split is the only answer there is.
|
|
646
|
+
*
|
|
647
|
+
* Each name must be one `@everystack/model` accepts (no quote, dot or control character; at most
|
|
648
|
+
* 63 bytes). One that is not keeps the comment it had before F3e, and db:pull warns.
|
|
649
|
+
*/
|
|
650
|
+
function externalTarget(table: TableSchema, fk: LiveForeignKey, fkTargets?: FkTargets): ExternalTarget {
|
|
651
|
+
const found = fkTargets
|
|
652
|
+
? fkTargets.get(foreignKeyTargetKey(table.table, fk.name))
|
|
653
|
+
: { schema: schemaOf(fk.refTable), table: bareName(fk.refTable) };
|
|
654
|
+
if (!found) return { ok: false, shown: fk.refTable, reason: 'the catalog read names no target for it' };
|
|
655
|
+
const shown = unambiguousName(found.schema, found.table);
|
|
656
|
+
// A CHANGED target fails closed like a missing one: on --stage the catalog read and the
|
|
657
|
+
// snapshot are two reads at two moments. Compared in the deparse form (public bare, quotes
|
|
658
|
+
// stripped), which is exactly what the snapshot's refTable holds.
|
|
659
|
+
const deparsed = found.schema === 'public' ? found.table : `${found.schema}.${found.table}`;
|
|
660
|
+
if (fkTargets && deparsed !== fk.refTable) {
|
|
661
|
+
return {
|
|
662
|
+
ok: false, shown: fk.refTable,
|
|
663
|
+
reason: `the catalog read names ${shown}, a different target than the snapshot: the two reads disagree; pull again`,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const parts: Array<[string, string]> = [['schema', found.schema], ['table', found.table], ...fk.refColumns.map((c): [string, string] => ['column', c])];
|
|
667
|
+
for (const [what, name] of parts) {
|
|
668
|
+
const refusal = externalIdentifierRefusal(name);
|
|
669
|
+
if (refusal) return { ok: false, shown, reason: `${what} ${JSON.stringify(name)} ${refusal}` };
|
|
670
|
+
}
|
|
671
|
+
if (fk.refColumns.length !== fk.columns.length) return { ok: false, shown, reason: 'its column lists differ in length' };
|
|
672
|
+
return { ok: true, schema: found.schema, table: found.table };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** The model comment for an external ON DELETE CASCADE ('' otherwise): a delete elsewhere reaches here. */
|
|
676
|
+
function externalCascadeComment(table: TableSchema, fk: LiveForeignKey): string {
|
|
677
|
+
if (fk.onDelete !== 'cascade') return '';
|
|
678
|
+
return ` // external FK ON DELETE CASCADE: a delete in ${commentText(qualifiedName(fk.refTable))} cascades into ${commentText(qualifiedName(table.table))}`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The pull's warnings about FKs that point OUTSIDE the pulled set: one per key the external form
|
|
683
|
+
* cannot declare (it stays a comment, so the next plan proposes to drop it), and one per key with
|
|
684
|
+
* ON DELETE CASCADE (deleting a row in another system's table deletes rows in this one; the model
|
|
685
|
+
* carries that fact as a comment too).
|
|
686
|
+
*/
|
|
687
|
+
export function externalForeignKeyWarnings(snapshot: SchemaSnapshot, schema: string | string[], fkTargets?: FkTargets): string[] {
|
|
688
|
+
const tables = pullableTables(snapshot, schema);
|
|
689
|
+
const known = knownSet(tables.map((t) => t.table));
|
|
690
|
+
const out: string[] = [];
|
|
691
|
+
for (const t of tables) {
|
|
692
|
+
for (const fk of t.foreignKeys) {
|
|
693
|
+
if (known.has(qualifiedName(fk.refTable))) continue;
|
|
694
|
+
const target = externalTarget(t, fk, fkTargets);
|
|
695
|
+
if (!target.ok) {
|
|
696
|
+
out.push(
|
|
697
|
+
`${qualifiedName(t.table)}: foreign key ${fk.name} references ${target.shown}, which the external form cannot declare (${target.reason}): `
|
|
698
|
+
+ 'it is left as a comment, and the next plan proposes to drop it.',
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
if (fk.onDelete !== 'cascade') continue;
|
|
702
|
+
out.push(
|
|
703
|
+
`${qualifiedName(t.table)}: foreign key ${fk.name} references ${qualifiedName(fk.refTable)} (not pulled) with ON DELETE CASCADE: `
|
|
704
|
+
+ `a delete in another system's table cascades into this one.`,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return out;
|
|
578
709
|
}
|
|
579
710
|
|
|
580
711
|
/**
|
|
@@ -584,7 +715,8 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
|
|
|
584
715
|
* renders its introspected columns with the SAME field vocabulary a pulled table gets.
|
|
585
716
|
*/
|
|
586
717
|
export function renderFieldLines(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map()): string[] {
|
|
587
|
-
|
|
718
|
+
const pulled = knownSet(known);
|
|
719
|
+
return table.columns.map((c) => renderField(table, c, pulled, enums, new Map()));
|
|
588
720
|
}
|
|
589
721
|
|
|
590
722
|
/**
|
|
@@ -592,9 +724,10 @@ export function renderFieldLines(table: TableSchema, known: Set<string>, enums:
|
|
|
592
724
|
* multi-column foreign keys — the table-level surface a column-scoped `field()` can't carry.
|
|
593
725
|
* Single-column uniques and FKs are rendered on the field (`.unique()` / `.references()`), so
|
|
594
726
|
* only composite ones land here. A composite FK whose target isn't in the pulled set is
|
|
595
|
-
*
|
|
727
|
+
* declared in the external `{ schema, table }` form (a comment only when its names cannot be).
|
|
728
|
+
* Returns '' when there are none.
|
|
596
729
|
*/
|
|
597
|
-
function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], known: Set<string
|
|
730
|
+
function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], known: Set<string>, fkTargets?: FkTargets): string {
|
|
598
731
|
const items: string[] = [];
|
|
599
732
|
for (const u of table.uniques) {
|
|
600
733
|
if (u.columns.length > 1) items.push(`unique(${u.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`);
|
|
@@ -604,7 +737,7 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
604
737
|
// compiler counts only unnamed ones, so every pulled check compiles back to its live name.
|
|
605
738
|
let position = 0;
|
|
606
739
|
for (const ck of checks) {
|
|
607
|
-
const predicate = `sql\`${normalizeCheck(ck.expr)}\``;
|
|
740
|
+
const predicate = `sql\`${templateBody(normalizeCheck(ck.expr))}\``;
|
|
608
741
|
if (ck.name === positionalCheckName(bareName(table.table), position)) {
|
|
609
742
|
items.push(`check(${predicate})`);
|
|
610
743
|
position++;
|
|
@@ -616,12 +749,12 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
616
749
|
// Plain entries render as field keys; canonical-text entries (expressions, DESC,
|
|
617
750
|
// opclass) render as raw sql`` args — the same form the builder declares (Brick E).
|
|
618
751
|
const entry = (c: string): string =>
|
|
619
|
-
/^[a-z_][a-z0-9_$]*$/i.test(c) ? tsLiteral(toCamelCase(c)) : `sql\`${c}\``;
|
|
752
|
+
/^[a-z_][a-z0-9_$]*$/i.test(c) ? tsLiteral(toCamelCase(c)) : `sql\`${templateBody(c)}\``;
|
|
620
753
|
let s = `index(${ix.columns.map(entry).join(', ')})`;
|
|
621
754
|
if (ix.using) s += `.using(${tsLiteral(ix.using)})`;
|
|
622
755
|
if (ix.unique) s += '.unique()';
|
|
623
756
|
if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
|
|
624
|
-
if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
|
|
757
|
+
if (ix.where) s += `.where(sql\`${templateBody(normalizeCheck(ix.where))}\`)`;
|
|
625
758
|
// A7 — keep the name the database actually has, but only say it when it is not the one the
|
|
626
759
|
// compiler would generate anyway. A brownfield schema arrives with its previous tooling's
|
|
627
760
|
// names (`index_contents_on_destination_id`), and rebuilding under ours renamed ~200 of one
|
|
@@ -633,33 +766,35 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
|
|
|
633
766
|
}
|
|
634
767
|
items.push(s);
|
|
635
768
|
}
|
|
769
|
+
let target: ExternalTarget;
|
|
636
770
|
for (const fk of table.foreignKeys) {
|
|
637
771
|
if (fk.columns.length <= 1) continue; // single-column FKs are rendered on the field
|
|
638
772
|
const cols = `[${fk.columns.map((c) => tsLiteral(toCamelCase(c))).join(', ')}]`;
|
|
639
773
|
const refCols = `[${fk.refColumns.map((c) => tsLiteral(toCamelCase(c))).join(', ')}]`;
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
774
|
+
const opts = foreignKeyOptions(table, fk);
|
|
775
|
+
if (known.has(qualifiedName(fk.refTable))) {
|
|
776
|
+
items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
|
|
777
|
+
} else if ((target = externalTarget(table, fk, fkTargets)).ok) {
|
|
778
|
+
// F3e — the composite twin of the external `.references()`: the reference columns are the
|
|
779
|
+
// live PostgreSQL names, verbatim (there is no model whose field keys they could be).
|
|
780
|
+
const cascade = externalCascadeComment(table, fk);
|
|
781
|
+
if (cascade) items.push(cascade.trim());
|
|
782
|
+
items.push(
|
|
783
|
+
`foreignKey(${cols}, { schema: ${tsLiteral(target.schema)}, table: ${tsLiteral(target.table)} }, `
|
|
784
|
+
+ `[${fk.refColumns.map(tsLiteral).join(', ')}]${opts})`,
|
|
785
|
+
);
|
|
786
|
+
} else {
|
|
787
|
+
const shown = target.ok ? fk.refTable : target.shown;
|
|
788
|
+
items.push(`// FIXME: composite FK ${cols} → ${commentText(shown)} ${refCols} (target not in the pulled set)`);
|
|
651
789
|
}
|
|
652
|
-
// Faithful about validity, exactly as the single-column case above is — see there for why.
|
|
653
|
-
if (fk.notValid) actions.push('validate: false');
|
|
654
|
-
const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
|
|
655
|
-
items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
|
|
656
790
|
}
|
|
657
791
|
if (!items.length) return '';
|
|
658
|
-
|
|
792
|
+
// A comment line takes no trailing comma.
|
|
793
|
+
return `\n constraints: [\n${items.map((i) => (i.startsWith('//') ? ` ${i}` : ` ${i},`)).join('\n')}\n ],`;
|
|
659
794
|
}
|
|
660
795
|
|
|
661
796
|
/** One `export const X = defineModel(...)` block for a table. */
|
|
662
|
-
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string
|
|
797
|
+
export function renderModelBlock(table: TableSchema, known: Set<string>, enums: Map<string, string[]> = new Map(), abilities = 'commented', liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>, fkTargets?: FkTargets, lifecycle: boolean | string = false): string {
|
|
663
798
|
// A CHECK that reverses to a single field's .validate() is rendered on the field (ergonomic);
|
|
664
799
|
// the rest stay table-level check(). Both round-trip — this only chooses the nicer form.
|
|
665
800
|
const validates = new Map<string, string>();
|
|
@@ -677,14 +812,19 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
677
812
|
else tableChecks.push(ck);
|
|
678
813
|
}
|
|
679
814
|
|
|
680
|
-
const
|
|
681
|
-
const
|
|
815
|
+
const pulled = knownSet(known);
|
|
816
|
+
const fields = table.columns.map((c) => renderField(table, c, pulled, enums, validates, fkTargets)).join('\n');
|
|
817
|
+
const constraints = renderConstraintsBlock(table, tableChecks, pulled, fkTargets);
|
|
682
818
|
// The authz decision renders FIRST — before fields — because it is the first thing a
|
|
683
819
|
// reviewer must resolve about a model (and where the field-report consumer's codemod
|
|
684
820
|
// put it, proving the position is mechanical-edit-friendly).
|
|
685
821
|
const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
|
|
686
822
|
const writtenBy = writtenByStanza(table, liveAuthz);
|
|
687
|
-
|
|
823
|
+
// A guarded table renders its predicate inside sql`…`, escaped so the template's cooked string
|
|
824
|
+
// is the predicate text exactly (the model stores it verbatim; the generated guard hashes it).
|
|
825
|
+
const softDelete = typeof lifecycle === 'string'
|
|
826
|
+
? ` lifecycle: { deleteWhen: sql\`${lifecycle.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\` },\n`
|
|
827
|
+
: lifecycle ? ' lifecycle: true,\n' : softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
|
|
688
828
|
|
|
689
829
|
// A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
|
|
690
830
|
// qualification cannot ride in the first argument (that would make the table literally
|
|
@@ -693,8 +833,8 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
|
|
|
693
833
|
// selects FROM metrics.impressions then failed to build. Same idiom the derived renderer
|
|
694
834
|
// already uses for defineMaterializedTable.
|
|
695
835
|
const owner = schemaOf(table.table);
|
|
696
|
-
const schemaProp = owner === 'public' ? '' : ` schema:
|
|
697
|
-
return `export const ${modelVarName(table.table)} = defineModel(
|
|
836
|
+
const schemaProp = owner === 'public' ? '' : ` schema: ${tsLiteral(owner)},\n`;
|
|
837
|
+
return `export const ${modelVarName(table.table)} = defineModel(${tsLiteral(bareName(table.table))}, {\n${stanza.text}\n${schemaProp}${writtenBy}${softDelete} fields: {\n${fields}\n },${constraints}\n});`;
|
|
698
838
|
}
|
|
699
839
|
|
|
700
840
|
/** The `import` lines a rendered body needs — only what it actually uses, so the file reads clean. */
|
|
@@ -732,6 +872,7 @@ function moduleFooter(
|
|
|
732
872
|
multiline = false,
|
|
733
873
|
external?: ExternalDerived,
|
|
734
874
|
extensions?: string[],
|
|
875
|
+
governedRoles?: readonly string[],
|
|
735
876
|
): string {
|
|
736
877
|
// A materialized table (--matviews-as-tables) is a MODEL — its block rides the derived
|
|
737
878
|
// render (topo-ordered with the objects around it) but its name belongs in `models`.
|
|
@@ -767,9 +908,15 @@ function moduleFooter(
|
|
|
767
908
|
if (extensions?.length) {
|
|
768
909
|
// Sorted HERE, not just at the introspection: the renderer owns byte-stability, and a
|
|
769
910
|
// caller passing an unsorted list must not produce a diff against an identical database.
|
|
770
|
-
parts.push(`export const extensions = [${[...extensions].sort().map(
|
|
911
|
+
parts.push(`export const extensions = [${[...extensions].sort().map(tsLiteral).join(', ')}];`);
|
|
771
912
|
keys.push('extensions');
|
|
772
913
|
}
|
|
914
|
+
// --govern-roles is an access decision, so it is DECLARED where db:plan reads it: the module.
|
|
915
|
+
// Without it a later re-pull, or a table where the role holds nothing yet, would leave the
|
|
916
|
+
// role ungoverned again. Sorted for byte-stability; absent (byte-identical) without the flag.
|
|
917
|
+
if (governedRoles?.length) {
|
|
918
|
+
keys.push(`governedRoles: [${[...new Set(governedRoles)].sort().map(tsLiteral).join(', ')}]`);
|
|
919
|
+
}
|
|
773
920
|
parts.push(`export const appModule = defineModule({ ${keys.join(', ')} });`);
|
|
774
921
|
parts.push(`export const modules = [appModule];`);
|
|
775
922
|
return parts.join('\n\n');
|
|
@@ -801,17 +948,17 @@ function externalDerivedNames(e: ExternalDerived): string[] {
|
|
|
801
948
|
export function renderModelSource(snapshot: SchemaSnapshot, opts: RenderOptions = {}): string {
|
|
802
949
|
const schema = opts.schema ?? 'public';
|
|
803
950
|
const tables = pullableTables(snapshot, schema);
|
|
804
|
-
const known =
|
|
951
|
+
const known = knownSet(tables.map((t) => t.table));
|
|
805
952
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
806
953
|
|
|
807
|
-
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles)));
|
|
954
|
+
const blocks = tables.map((t) => renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table)));
|
|
808
955
|
// The derived layer (B5): sequences + views/matviews/functions, after the models they
|
|
809
956
|
// reference, before the module wrapper that composes all three.
|
|
810
957
|
if (opts.derived?.block) blocks.push(opts.derived.block);
|
|
811
958
|
|
|
812
959
|
// Module composition is the norm: the pulled barrel exports `modules` alongside `models`,
|
|
813
960
|
// so a fresh brownfield project lands on the same shape the framework composes.
|
|
814
|
-
const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived, false, undefined, snapshot.extensions);
|
|
961
|
+
const footer = moduleFooter(tables.map((t) => modelVarName(t.table)), opts.derived, false, undefined, snapshot.extensions, opts.governRoles);
|
|
815
962
|
|
|
816
963
|
const header = importHeader([...blocks, footer].join('\n\n'), opts.derived?.imports ?? []);
|
|
817
964
|
|
|
@@ -837,15 +984,24 @@ export function modelImportPath(table: string): string {
|
|
|
837
984
|
export function modelFileName(table: string): string {
|
|
838
985
|
const owner = schemaOf(table);
|
|
839
986
|
const bare = bareName(table).replace(/_/g, '-');
|
|
840
|
-
|
|
987
|
+
const stem = owner === 'public' ? bare : `${owner.replace(/_/g, '-')}-${bare}`;
|
|
988
|
+
// The stem is a path written under --out and an import specifier: a live name holding a
|
|
989
|
+
// separator or a dot would write, or import, outside the directory.
|
|
990
|
+
return `${safeFileStem(stem, liveObject('table', table))}.ts`;
|
|
841
991
|
}
|
|
842
992
|
|
|
843
|
-
/**
|
|
993
|
+
/**
|
|
994
|
+
* The tables (other than itself) a table's rendered FKs reference within the pulled set,
|
|
995
|
+
* QUALIFIED — the `.references(() => …)` thunk names `modelVarName(fk.refTable)`, so the
|
|
996
|
+
* import must too. Keeping only the bare name imported `Group` from './groups' for a thunk
|
|
997
|
+
* naming `A6Group`, and a pulled non-public schema could not plan. pg_get_constraintdef
|
|
998
|
+
* leaves a public ref bare, so a bare ref is read as public.
|
|
999
|
+
*/
|
|
844
1000
|
function referencedTables(table: TableSchema, known: Set<string>): string[] {
|
|
845
|
-
const self =
|
|
1001
|
+
const self = qualifiedName(table.table);
|
|
846
1002
|
const refs = new Set<string>();
|
|
847
1003
|
for (const fk of table.foreignKeys) {
|
|
848
|
-
const target =
|
|
1004
|
+
const target = qualifiedName(fk.refTable);
|
|
849
1005
|
if (target !== self && known.has(target)) refs.add(target);
|
|
850
1006
|
}
|
|
851
1007
|
return [...refs].sort();
|
|
@@ -868,11 +1024,11 @@ export interface RenderedModelFile {
|
|
|
868
1024
|
export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions = {}): RenderedModelFile[] {
|
|
869
1025
|
const schema = opts.schema ?? 'public';
|
|
870
1026
|
const tables = pullableTables(snapshot, schema);
|
|
871
|
-
const known =
|
|
1027
|
+
const known = knownSet(tables.map((t) => t.table));
|
|
872
1028
|
const enums = new Map((snapshot.enums ?? []).map((e) => [e.name, e.values]));
|
|
873
1029
|
|
|
874
1030
|
const files: RenderedModelFile[] = tables.map((t) => {
|
|
875
|
-
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles));
|
|
1031
|
+
const block = renderModelBlock(t, known, enums, opts.abilities ?? 'commented', opts.liveAuthz, opts.governRoles && new Set(opts.governRoles), opts.fkTargets, opts.lifecycleDeleteWhen?.get(t.table) ?? opts.lifecycleTables?.has(t.table));
|
|
876
1032
|
const crossImports = referencedTables(t, known).map(
|
|
877
1033
|
(target) => `import { ${modelVarName(target)} } from '${modelImportPath(target)}';`,
|
|
878
1034
|
);
|
|
@@ -899,7 +1055,7 @@ export function renderModelFiles(snapshot: SchemaSnapshot, opts: RenderOptions =
|
|
|
899
1055
|
// Re-exported so the barrel remains the one place that describes the database.
|
|
900
1056
|
...(extNames.length ? [`export { ${extNames.join(', ')} };`] : []),
|
|
901
1057
|
...(opts.derived?.block ? [opts.derived.block] : []),
|
|
902
|
-
moduleFooter(names, opts.derived, true, ext, snapshot.extensions),
|
|
1058
|
+
moduleFooter(names, opts.derived, true, ext, snapshot.extensions, opts.governRoles),
|
|
903
1059
|
].join('\n\n');
|
|
904
1060
|
files.push({ file: 'index.ts', source: index + '\n' });
|
|
905
1061
|
|