@everystack/cli 0.4.82 → 0.4.84
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 +44 -0
- package/package.json +10 -2
- package/src/cli/authz-compile.ts +0 -0
- package/src/cli/authz-contract.ts +5 -3
- package/src/cli/authz-reconcile.ts +18 -15
- package/src/cli/aws.ts +17 -1
- package/src/cli/commands/db-apply.ts +445 -60
- package/src/cli/commands/db-check.ts +39 -20
- package/src/cli/commands/db-plan.ts +61 -27
- package/src/cli/commands/db-pull.ts +67 -11
- package/src/cli/commands/db-reconcile.ts +22 -1
- package/src/cli/commands/db.ts +137 -19
- package/src/cli/db-source.ts +8 -1
- package/src/cli/derived-catchup.ts +154 -0
- package/src/cli/derived-render.ts +531 -245
- package/src/cli/derived-source.ts +25 -90
- package/src/cli/edge-plan.ts +76 -14
- package/src/cli/index.ts +7 -7
- package/src/cli/model-wire-lane.ts +143 -0
- package/src/cli/output.ts +12 -0
- package/src/cli/plain-output.ts +521 -0
- package/src/cli/schema-diff.ts +6 -0
- package/src/cli/schema-fingerprint-v11.ts +234 -0
- package/src/cli/schema-fingerprint.ts +20 -6
- package/src/cli/sql-statement.ts +98 -0
- package/src/cli/state-apply.ts +46 -24
- package/src/wire.ts +30 -0
|
@@ -13,13 +13,35 @@
|
|
|
13
13
|
* checklist effect, on purpose — never a silent drop.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import path from 'node:path';
|
|
16
17
|
import type { DerivedCatalog, LiveObject } from './derived-introspect';
|
|
17
18
|
import type { ColumnSchema, SequenceSchema, TableSchema } from './schema-introspect';
|
|
18
19
|
import { parseIndexDefinition } from './schema-introspect';
|
|
19
|
-
import { modelImportPath, renderFieldLines } from './model-render';
|
|
20
|
+
import { modelImportPath, modelFileName, renderFieldLines } from './model-render';
|
|
20
21
|
import { splitFunctionIdentity } from './pg-argtypes';
|
|
21
22
|
import { GOVERNED_VOCABULARY, KNOWN_ROLES } from './authz-derive';
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* One rendered object's own lines, plus what it depends on — the unit `--derived-out <dir>`
|
|
26
|
+
* splits by. Purely additive alongside `block` (Step 1 of the directory split): `block` stays
|
|
27
|
+
* exactly `records.flatMap(r => r.lines).join('\n')` mixed with the pre-loop sequence spacer,
|
|
28
|
+
* so the single-file path is byte-identical to before this existed.
|
|
29
|
+
*/
|
|
30
|
+
export interface DerivedRecord {
|
|
31
|
+
/** Live catalog identity (`public.active_posts`, `auth.sign_jwt(text)`, …) — a synthetic
|
|
32
|
+
* `sequence:<name>` for a standalone sequence, which the catalog does not carry. */
|
|
33
|
+
identity: string;
|
|
34
|
+
varName: string;
|
|
35
|
+
kind: LiveObject['kind'] | 'sequence';
|
|
36
|
+
schema: string;
|
|
37
|
+
/** This object's own rendered lines, exactly as they appear in `block`. */
|
|
38
|
+
lines: string[];
|
|
39
|
+
/** Qualified tables (knownTables keys) this record's dependsOn/execute/on needs imported. */
|
|
40
|
+
modelRefs: string[];
|
|
41
|
+
/** Other records' identities this record's dependsOn/execute/on needs imported. */
|
|
42
|
+
derivedRefs: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
export interface DerivedRenderResult {
|
|
24
46
|
/** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
|
|
25
47
|
block: string;
|
|
@@ -37,6 +59,12 @@ export interface DerivedRenderResult {
|
|
|
37
59
|
modelRefs: string[];
|
|
38
60
|
/** Everything skipped or flagged — mirrored as FIXME comments in the block. */
|
|
39
61
|
warnings: string[];
|
|
62
|
+
/** Per-object records, emission order — what `--derived-out <dir>` partitions by kind
|
|
63
|
+
* and schema. `records.flatMap(r => r.lines)` reproduces `block`'s content exactly.
|
|
64
|
+
* Optional so a hand-built fixture typed as `DerivedRenderResult` elsewhere in the
|
|
65
|
+
* codebase (there is no reason to fake a directory split) does not need updating —
|
|
66
|
+
* `renderDerivedSource` always populates it; `renderDerivedDirectory` requires it. */
|
|
67
|
+
records?: DerivedRecord[];
|
|
40
68
|
}
|
|
41
69
|
|
|
42
70
|
export interface DerivedRenderOptions {
|
|
@@ -375,11 +403,15 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
375
403
|
const materializedTableNames: string[] = [];
|
|
376
404
|
const used = new Set<string>();
|
|
377
405
|
const need = (sym: string): void => { used.add(sym); };
|
|
406
|
+
// Step 1 of the directory split: accumulated ALONGSIDE `lines`, never instead of it — every
|
|
407
|
+
// push below still goes through `lines` first, so `block` is built exactly as it always was.
|
|
408
|
+
const records: DerivedRecord[] = [];
|
|
378
409
|
|
|
379
410
|
// -- sequences (state layer — they precede the compute in the module) -------
|
|
380
411
|
|
|
381
412
|
const sequenceNames: string[] = [];
|
|
382
413
|
for (const seq of opts.sequences ?? []) {
|
|
414
|
+
const linesStart = lines.length;
|
|
383
415
|
const varName = toCamelCase(seq.name.replace(/\./g, '_'));
|
|
384
416
|
const props: string[] = [];
|
|
385
417
|
if (seq.as !== 'bigint') props.push(`as: ${tsString(seq.as)}`);
|
|
@@ -388,6 +420,15 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
388
420
|
lines.push(`export const ${varName} = defineSequence(${tsString(seq.name)}${props.length ? `, { ${props.join(', ')} }` : ''});`);
|
|
389
421
|
need('defineSequence');
|
|
390
422
|
sequenceNames.push(varName);
|
|
423
|
+
records.push({
|
|
424
|
+
identity: `sequence:${seq.name}`,
|
|
425
|
+
varName,
|
|
426
|
+
kind: 'sequence',
|
|
427
|
+
schema: seq.name.includes('.') ? seq.name.split('.')[0] : 'public',
|
|
428
|
+
lines: lines.slice(linesStart),
|
|
429
|
+
modelRefs: [],
|
|
430
|
+
derivedRefs: [],
|
|
431
|
+
});
|
|
391
432
|
}
|
|
392
433
|
if (sequenceNames.length) lines.push('');
|
|
393
434
|
|
|
@@ -447,9 +488,14 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
447
488
|
// -- per-object rendering ----------------------------------------------------
|
|
448
489
|
|
|
449
490
|
const modelRefs = new Set<string>();
|
|
450
|
-
const depsFor = (identity: string): { refs: string[]; fixmes: string[] } => {
|
|
491
|
+
const depsFor = (identity: string): { refs: string[]; fixmes: string[]; identRefs: { model: string[]; derived: string[] } } => {
|
|
451
492
|
const refs: string[] = [];
|
|
452
493
|
const fixmes: string[] = [];
|
|
494
|
+
// Identity-level split, alongside the resolved-var-name `refs` above — what
|
|
495
|
+
// `--derived-out <dir>` needs to know which FILE a dependency lives in, since a var
|
|
496
|
+
// name alone doesn't say whether it came from a model or another derived object.
|
|
497
|
+
const modelIdents: string[] = [];
|
|
498
|
+
const derivedIdents: string[] = [];
|
|
453
499
|
const seen = new Set<string>();
|
|
454
500
|
for (const e of catalog.edges) {
|
|
455
501
|
if (e.dependent !== identity || seen.has(e.referenced)) continue;
|
|
@@ -459,278 +505,302 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
459
505
|
if (model) {
|
|
460
506
|
refs.push(model);
|
|
461
507
|
modelRefs.add(e.referenced);
|
|
462
|
-
|
|
463
|
-
else
|
|
508
|
+
modelIdents.push(e.referenced);
|
|
509
|
+
} else if (derived) {
|
|
510
|
+
refs.push(derived);
|
|
511
|
+
derivedIdents.push(e.referenced);
|
|
512
|
+
} else fixmes.push(`// FIXME: dependsOn ${e.referenced} — not in the pulled set; declare it by hand.`);
|
|
464
513
|
}
|
|
465
|
-
return { refs: refs.sort(), fixmes };
|
|
514
|
+
return { refs: refs.sort(), fixmes, identRefs: { model: modelIdents.sort(), derived: derivedIdents.sort() } };
|
|
466
515
|
};
|
|
467
516
|
|
|
468
517
|
for (const o of ordered) {
|
|
469
518
|
const varName = varOf.get(o.identity)!;
|
|
470
|
-
const { refs, fixmes } = depsFor(o.identity);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
`${o.identity}: write privileges (${grantText}) recorded as \`privileges\` — the object is declared and nothing will be revoked. ` +
|
|
507
|
-
'Write privileges on a view usually come from a blanket `GRANT ALL ON ALL TABLES IN SCHEMA …` rather than a deliberate writable view — check that first, then revoke and re-pull.',
|
|
519
|
+
const { refs, fixmes, identRefs } = depsFor(o.identity);
|
|
520
|
+
// What the RECORD claims this object references — normally exactly the edges `dependsOn`
|
|
521
|
+
// renders. The defineSql fallback is the exception and it has to be said here: that branch
|
|
522
|
+
// renders NO dependsOn at all, so a record still carrying the edges would make
|
|
523
|
+
// renderDerivedDirectory emit `import { X } from './views'` into a split file whose
|
|
524
|
+
// generated code never names X. An unused import in generated code is a hard compile
|
|
525
|
+
// error under a consumer's `noUnusedLocals`.
|
|
526
|
+
let recordRefs: { model: string[]; derived: string[] } = identRefs;
|
|
527
|
+
const linesStart = lines.length;
|
|
528
|
+
try {
|
|
529
|
+
if (o.kind === 'view' || o.kind === 'materialized view') {
|
|
530
|
+
// The live ACL splits in two: the part `can('read')` can SAY (plain SELECT to named
|
|
531
|
+
// app roles) becomes abilities, and everything else is RECORDED as `privileges`.
|
|
532
|
+
//
|
|
533
|
+
// This used to skip the object outright — definition and all — whenever any grant
|
|
534
|
+
// was inexpressible, which on a real brownfield schema is every relation it has:
|
|
535
|
+
// three roles hold the blanket DELETE/INSERT/REFERENCES/SELECT/TRIGGER/TRUNCATE/
|
|
536
|
+
// UPDATE that `GRANT ALL ON ALL TABLES IN SCHEMA` leaves behind, and PUBLIC holds
|
|
537
|
+
// SELECT on a few. The adopter got a FIXME instead of their view.
|
|
538
|
+
//
|
|
539
|
+
// Recording is also what makes rendering SAFE, and it is why the silent-REVOKE
|
|
540
|
+
// question that blocked this no longer needs an answer: emitting the view with only
|
|
541
|
+
// the abilities we can express would leave the write grants undeclared, so the next
|
|
542
|
+
// generate would plan a REVOKE for each — a silent skip traded for a silent revoke.
|
|
543
|
+
// Declared == live means nothing is planned at all.
|
|
544
|
+
const { abilities, privileges, foreign } = splitRelationGrants(o.grants ?? {});
|
|
545
|
+
if (foreign.length) {
|
|
546
|
+
warnings.push(
|
|
547
|
+
`${o.identity}: grants exist for ${foreign.join(', ')} — recorded as \`privileges\`, not declared as ` +
|
|
548
|
+
'abilities. Abilities name the roles the model governs (anon/authenticated/admin); a grantee outside ' +
|
|
549
|
+
'that vocabulary is fact, not an intended audience. Recorded rather than dropped because a derived ' +
|
|
550
|
+
'object REVOKES any live grantee the declaration omits.',
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
const writeGrants = Object.values(privileges).some((ps) =>
|
|
554
|
+
ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
|
|
508
555
|
);
|
|
509
|
-
|
|
556
|
+
if (writeGrants) {
|
|
557
|
+
// A view carrying INSERT/UPDATE/DELETE almost never means someone intended a
|
|
558
|
+
// writable view — it usually traces to a blanket `GRANT ALL ON ALL TABLES IN
|
|
559
|
+
// SCHEMA public` in an old migration, which sweeps views up with the tables.
|
|
560
|
+
// Naming that here saves the diagnosis; an adopter paid for it once already.
|
|
561
|
+
const grantText = Object.entries(privileges).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
|
|
562
|
+
warnings.push(
|
|
563
|
+
`${o.identity}: write privileges (${grantText}) recorded as \`privileges\` — the object is declared and nothing will be revoked. ` +
|
|
564
|
+
'Write privileges on a view usually come from a blanket `GRANT ALL ON ALL TABLES IN SCHEMA …` rather than a deliberate writable view — check that first, then revoke and re-pull.',
|
|
565
|
+
);
|
|
566
|
+
}
|
|
510
567
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
568
|
+
// --matviews-as-tables (the gap-B consumer flip): the matview renders as a
|
|
569
|
+
// defineMaterializedTable MODEL — introspected fields, index constraints on field
|
|
570
|
+
// keys, the defining query as metadata. Same topo slot (its dependents still
|
|
571
|
+
// reference it by variable); the name lands in materializedTableNames, not names.
|
|
572
|
+
if (o.kind === 'materialized view' && opts.matviewsAsTables) {
|
|
573
|
+
const cols = opts.matviewsAsTables.columns.get(o.identity);
|
|
574
|
+
if (!cols || cols.length === 0) {
|
|
575
|
+
warnings.push(`${o.identity}: no live columns found for the table render — rendered as defineMaterializedView; re-pull or flip it by hand.`);
|
|
576
|
+
} else {
|
|
577
|
+
if (o.populated === false) {
|
|
578
|
+
warnings.push(`${o.identity}: the live matview is UNPOPULATED — rendered anyway (a materialized table's contents are pipeline-owned).`);
|
|
579
|
+
}
|
|
580
|
+
const shape: TableSchema = {
|
|
581
|
+
table: o.identity, columns: cols,
|
|
582
|
+
primaryKey: [], uniques: [], checks: [], foreignKeys: [], indexes: [],
|
|
583
|
+
};
|
|
584
|
+
// Field lines align with `cols` by index — append the --suggest-keys comment in place.
|
|
585
|
+
const suggested = new Set(opts.matviewsAsTables.keyCandidates?.get(o.identity) ?? []);
|
|
586
|
+
const fieldLines = renderFieldLines(shape, new Set(), opts.matviewsAsTables.enums ?? new Map())
|
|
587
|
+
.map((line, i) => suggested.has(cols[i].name)
|
|
588
|
+
? `${line} // PK candidate: non-null + all-distinct across live rows — add .primaryKey() if this is the natural key`
|
|
589
|
+
: line);
|
|
590
|
+
const builders = o.indexes.map((def) => {
|
|
591
|
+
const b = renderTableIndexBuilder(def);
|
|
592
|
+
if (b === null) warnings.push(`${o.identity}: index not renderable: ${def}`);
|
|
593
|
+
return b;
|
|
594
|
+
}).filter((b): b is string => b !== null);
|
|
595
|
+
if (builders.length) need('index');
|
|
596
|
+
if (abilities.length) need('can');
|
|
597
|
+
need('defineMaterializedTable');
|
|
598
|
+
need('field');
|
|
599
|
+
need('sql');
|
|
600
|
+
// MODEL naming idiom: bare table + a schema prop. The derived layer's
|
|
601
|
+
// qualified-first-arg spelling must NOT leak here — defineModel stores the
|
|
602
|
+
// table name verbatim, so 'stats.x' would become a dot-containing identifier
|
|
603
|
+
// in public and 3-part-qualify (public.stats.x) downstream.
|
|
604
|
+
lines.push(
|
|
605
|
+
...fixmes,
|
|
606
|
+
`export const ${varName} = defineMaterializedTable(${tsString(o.name)}, {`,
|
|
607
|
+
// Same split as the relation path: a model carries `privileges` too, so the
|
|
608
|
+
// inexpressible grants are recorded rather than dropped on this branch either.
|
|
609
|
+
...(abilities.length ? [` abilities: [${abilities.join(', ')}],`]
|
|
610
|
+
: Object.keys(privileges).length ? [] : [' private: true,']),
|
|
611
|
+
...(Object.keys(privileges).length
|
|
612
|
+
? [` privileges: { ${Object.keys(privileges).sort().map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`).join(', ')} },`]
|
|
613
|
+
: []),
|
|
614
|
+
...(o.schema !== 'public' ? [` schema: ${tsString(o.schema)},`] : []),
|
|
615
|
+
' fields: {',
|
|
616
|
+
...fieldLines,
|
|
617
|
+
' },',
|
|
618
|
+
...(builders.length ? [` constraints: [${builders.join(', ')}],`] : []),
|
|
619
|
+
...(refs.length ? [` dependsOn: [${refs.join(', ')}],`] : []),
|
|
620
|
+
...(o.comment ? [` comment: ${tsString(o.comment)},`] : []),
|
|
621
|
+
` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
|
|
622
|
+
'});',
|
|
623
|
+
'',
|
|
624
|
+
);
|
|
625
|
+
materializedTableNames.push(varName);
|
|
626
|
+
continue;
|
|
522
627
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const props: string[] = [];
|
|
631
|
+
const hasPrivileges = Object.keys(privileges).length > 0;
|
|
632
|
+
if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
|
|
633
|
+
// A15 — the owner, through the SAME discriminator functions use. Elevated exactly when
|
|
634
|
+
// the relation runs as its owner: a view that is not securityInvoker, and a matview
|
|
635
|
+
// always (its rows are computed at REFRESH time with the owner's rights, no exceptions).
|
|
636
|
+
const relationOwnership = renderedOwner(
|
|
637
|
+
o,
|
|
638
|
+
o.kind === 'materialized view' ? MATVIEW_RIGHTS
|
|
639
|
+
: o.securityInvoker === false ? DEFINER_VIEW_RIGHTS
|
|
640
|
+
: null,
|
|
641
|
+
);
|
|
642
|
+
if (relationOwnership.prop) props.push(relationOwnership.prop);
|
|
643
|
+
if (relationOwnership.fixme) fixmes.push(relationOwnership.fixme);
|
|
644
|
+
// The FIXME lands in the file, at the declaration, which is where the operator acts on
|
|
645
|
+
// it. The stderr WARNING is deliberately NOT raised for relations: `SECURITY DEFINER` is
|
|
646
|
+
// opt-in and rare, so echoing those is signal, but `security_invoker = false` is
|
|
647
|
+
// PostgreSQL's DEFAULT for views — on any real database nearly every view and every
|
|
648
|
+
// matview qualifies, and promoting all of them would bury the function findings the
|
|
649
|
+
// channel exists for. Same judgement the function branch already makes for its plain
|
|
650
|
+
// case, applied where the base rate is inverted.
|
|
651
|
+
// `private: true` means "declared dark, no grants" — it contradicts a recorded
|
|
652
|
+
// grant, and the model refuses the pair. A relation whose only reach is recorded
|
|
653
|
+
// (PUBLIC SELECT, or a write grant) is not dark; it just has no CHOSEN audience.
|
|
654
|
+
if (abilities.length === 0 && !hasPrivileges) props.push('private: true,');
|
|
655
|
+
else if (abilities.length) {
|
|
656
|
+
props.push(`abilities: [${abilities.join(', ')}],`);
|
|
657
|
+
need('can');
|
|
658
|
+
}
|
|
659
|
+
if (hasPrivileges) {
|
|
660
|
+
const entries = Object.keys(privileges).sort()
|
|
661
|
+
.map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`);
|
|
662
|
+
props.push(`privileges: { ${entries.join(', ')} },`);
|
|
663
|
+
}
|
|
664
|
+
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
665
|
+
if (o.kind === 'materialized view') {
|
|
533
666
|
const builders = o.indexes.map((def) => {
|
|
534
|
-
const b =
|
|
667
|
+
const b = renderIndexBuilder(def);
|
|
535
668
|
if (b === null) warnings.push(`${o.identity}: index not renderable: ${def}`);
|
|
536
669
|
return b;
|
|
537
670
|
}).filter((b): b is string => b !== null);
|
|
538
|
-
if (builders.length)
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
// MODEL naming idiom: bare table + a schema prop. The derived layer's
|
|
544
|
-
// qualified-first-arg spelling must NOT leak here — defineModel stores the
|
|
545
|
-
// table name verbatim, so 'stats.x' would become a dot-containing identifier
|
|
546
|
-
// in public and 3-part-qualify (public.stats.x) downstream.
|
|
547
|
-
lines.push(
|
|
548
|
-
...fixmes,
|
|
549
|
-
`export const ${varName} = defineMaterializedTable(${tsString(o.name)}, {`,
|
|
550
|
-
// Same split as the relation path: a model carries `privileges` too, so the
|
|
551
|
-
// inexpressible grants are recorded rather than dropped on this branch either.
|
|
552
|
-
...(abilities.length ? [` abilities: [${abilities.join(', ')}],`]
|
|
553
|
-
: Object.keys(privileges).length ? [] : [' private: true,']),
|
|
554
|
-
...(Object.keys(privileges).length
|
|
555
|
-
? [` privileges: { ${Object.keys(privileges).sort().map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`).join(', ')} },`]
|
|
556
|
-
: []),
|
|
557
|
-
...(o.schema !== 'public' ? [` schema: ${tsString(o.schema)},`] : []),
|
|
558
|
-
' fields: {',
|
|
559
|
-
...fieldLines,
|
|
560
|
-
' },',
|
|
561
|
-
...(builders.length ? [` constraints: [${builders.join(', ')}],`] : []),
|
|
562
|
-
...(refs.length ? [` dependsOn: [${refs.join(', ')}],`] : []),
|
|
563
|
-
...(o.comment ? [` comment: ${tsString(o.comment)},`] : []),
|
|
564
|
-
` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
|
|
565
|
-
'});',
|
|
566
|
-
'',
|
|
567
|
-
);
|
|
568
|
-
materializedTableNames.push(varName);
|
|
569
|
-
continue;
|
|
671
|
+
if (builders.length) {
|
|
672
|
+
props.push(`indexes: [${builders.join(', ')}],`);
|
|
673
|
+
need('index');
|
|
674
|
+
}
|
|
675
|
+
if (o.populated === false) props.push(`populate: 'deferred',`);
|
|
570
676
|
}
|
|
677
|
+
if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
|
|
678
|
+
props.push(`as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`);
|
|
679
|
+
need(o.kind === 'view' ? 'defineView' : 'defineMaterializedView');
|
|
680
|
+
need('sql');
|
|
681
|
+
lines.push(
|
|
682
|
+
...fixmes,
|
|
683
|
+
`export const ${varName} = ${o.kind === 'view' ? 'defineView' : 'defineMaterializedView'}(${tsString(declaredName(o))}, {`,
|
|
684
|
+
...props.map((p) => ` ${p}`),
|
|
685
|
+
'});',
|
|
686
|
+
'',
|
|
687
|
+
);
|
|
688
|
+
names.push(varName);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Functions.
|
|
693
|
+
const f = o.fn;
|
|
694
|
+
const grants = o.grants ?? {};
|
|
695
|
+
const roleGrants = Object.keys(grants).filter((r) => r.toUpperCase() !== 'PUBLIC').sort();
|
|
696
|
+
const publicExec = Object.keys(grants).some((r) => r.toUpperCase() === 'PUBLIC');
|
|
697
|
+
const args = f && (f.language === 'sql' || f.language === 'plpgsql') ? parseArgs(f.args) : null;
|
|
698
|
+
const ownership = renderedFunctionOwner(o, f?.secdef === true);
|
|
699
|
+
|
|
700
|
+
if (!f || args === null || (f.language !== 'sql' && f.language !== 'plpgsql')) {
|
|
701
|
+
warnings.push(`${o.identity}: signature beyond the v1 vocabulary (${f ? `language ${f.language}, args '${f.args}'` : 'no structured fields'}) — rendered as defineSql.`);
|
|
702
|
+
// Nothing below renders `dependsOn`, so the record declares no refs — see recordRefs.
|
|
703
|
+
recordRefs = { model: [], derived: [] };
|
|
704
|
+
// A defineSql object is identified by its NAME alone, so an overload pair would
|
|
705
|
+
// collapse to one provenance row here too — the declared name carries the signature
|
|
706
|
+
// (the drop is explicit, so the name is a label, and a unique one is what it needs).
|
|
707
|
+
const sqlName = declaredName(o) + (f?.identityArgs !== undefined ? `(${f.identityArgs})` : '');
|
|
708
|
+
// A defineSql object cannot carry an owner. Where the pull observed a CHOSEN owner that
|
|
709
|
+
// is real fidelity loss, so it is said out loud on both surfaces — never dropped. An
|
|
710
|
+
// ambient owner needs no note here: there is nothing to declare and nothing was lost.
|
|
711
|
+
if (ownership.prop) {
|
|
712
|
+
const why = `${o.identity}: owned by '${o.owner}', but defineSql cannot declare an owner — the build will create it as the connecting role. Move it into defineFunction's vocabulary, or set the owner by hand after the build.`;
|
|
713
|
+
warnings.push(why);
|
|
714
|
+
lines.push(`// FIXME: ${why}`);
|
|
715
|
+
}
|
|
716
|
+
lines.push(
|
|
717
|
+
`// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
|
|
718
|
+
`export const ${varName} = defineSql(${tsString(sqlName)}, {`,
|
|
719
|
+
` kind: 'function',`,
|
|
720
|
+
` drop: sql\`DROP FUNCTION IF EXISTS ${o.schema === 'public' ? o.name : `${o.schema}.${o.name}`}${f?.identityArgs !== undefined ? `(${f.identityArgs})` : ''}\`,`,
|
|
721
|
+
` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
|
|
722
|
+
'});',
|
|
723
|
+
'',
|
|
724
|
+
);
|
|
725
|
+
need('defineSql');
|
|
726
|
+
need('sql');
|
|
727
|
+
names.push(varName);
|
|
728
|
+
continue;
|
|
571
729
|
}
|
|
572
730
|
|
|
573
731
|
const props: string[] = [];
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const relationOwnership = renderedOwner(
|
|
580
|
-
o,
|
|
581
|
-
o.kind === 'materialized view' ? MATVIEW_RIGHTS
|
|
582
|
-
: o.securityInvoker === false ? DEFINER_VIEW_RIGHTS
|
|
583
|
-
: null,
|
|
584
|
-
);
|
|
585
|
-
if (relationOwnership.prop) props.push(relationOwnership.prop);
|
|
586
|
-
if (relationOwnership.fixme) fixmes.push(relationOwnership.fixme);
|
|
587
|
-
// The FIXME lands in the file, at the declaration, which is where the operator acts on
|
|
588
|
-
// it. The stderr WARNING is deliberately NOT raised for relations: `SECURITY DEFINER` is
|
|
589
|
-
// opt-in and rare, so echoing those is signal, but `security_invoker = false` is
|
|
590
|
-
// PostgreSQL's DEFAULT for views — on any real database nearly every view and every
|
|
591
|
-
// matview qualifies, and promoting all of them would bury the function findings the
|
|
592
|
-
// channel exists for. Same judgement the function branch already makes for its plain
|
|
593
|
-
// case, applied where the base rate is inverted.
|
|
594
|
-
// `private: true` means "declared dark, no grants" — it contradicts a recorded
|
|
595
|
-
// grant, and the model refuses the pair. A relation whose only reach is recorded
|
|
596
|
-
// (PUBLIC SELECT, or a write grant) is not dark; it just has no CHOSEN audience.
|
|
597
|
-
if (abilities.length === 0 && !hasPrivileges) props.push('private: true,');
|
|
598
|
-
else if (abilities.length) {
|
|
599
|
-
props.push(`abilities: [${abilities.join(', ')}],`);
|
|
600
|
-
need('can');
|
|
732
|
+
if (args.length) {
|
|
733
|
+
const rendered = args.map((a) =>
|
|
734
|
+
`arg(${tsString(a.name)}, ${tsString(a.type)}${a.default ? `, { default: sql\`${tsTemplate(a.default)}\` }` : ''})`);
|
|
735
|
+
props.push(`args: [${rendered.join(', ')}],`);
|
|
736
|
+
need('arg');
|
|
601
737
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
738
|
+
props.push(`returns: ${tsString(f.returns)},`);
|
|
739
|
+
props.push(`language: ${tsString(f.language)},`);
|
|
740
|
+
if (VOLATILITY[f.volatility]) props.push(`volatility: ${tsString(VOLATILITY[f.volatility])},`);
|
|
741
|
+
if (f.secdef) props.push(`security: 'definer',`);
|
|
742
|
+
// THE PIN IS READ, NEVER INVENTED.
|
|
743
|
+
//
|
|
744
|
+
// This used to render `searchPath: ['pg_catalog']` for any definer function whose live
|
|
745
|
+
// proconfig was empty. A build from those models then applied the invented pin for real, so
|
|
746
|
+
// functions that had always resolved `public` stopped resolving it — sign-up and password
|
|
747
|
+
// change died on a database built from the consumer's own models, with every gate green.
|
|
748
|
+
// 34 of their 34 pinned-in-the-model functions were unpinned in the database.
|
|
749
|
+
//
|
|
750
|
+
// A live pin renders as the array. No live pin on a definer function renders the explicit
|
|
751
|
+
// `'unpinned'` sentinel — an ANSWER, not silence, so `defineFunction` still throws for the
|
|
752
|
+
// author who simply forgot. No live pin on an invoker function renders nothing, which
|
|
753
|
+
// already means exactly that.
|
|
754
|
+
if (f.searchPath) {
|
|
755
|
+
const searchPathParts = f.searchPath.split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
|
756
|
+
props.push(`searchPath: [${searchPathParts.map(tsString).join(', ')}],`);
|
|
757
|
+
} else if (f.secdef) {
|
|
758
|
+
props.push(`searchPath: 'unpinned',`);
|
|
606
759
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
props.push(`indexes: [${builders.join(', ')}],`);
|
|
616
|
-
need('index');
|
|
617
|
-
}
|
|
618
|
-
if (o.populated === false) props.push(`populate: 'deferred',`);
|
|
760
|
+
// Ownership sits beside security because it IS the other half of it: `security: 'definer'`
|
|
761
|
+
// says the function runs as its owner, and this says who that is.
|
|
762
|
+
if (ownership.prop) props.push(ownership.prop);
|
|
763
|
+
if (ownership.fixme) fixmes.push(ownership.fixme);
|
|
764
|
+
if (ownership.warning) warnings.push(ownership.warning);
|
|
765
|
+
if (roleGrants.length) {
|
|
766
|
+
props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
|
|
767
|
+
need('can');
|
|
619
768
|
}
|
|
769
|
+
// A live PUBLIC EXECUTE is RECORDED, not converted into an ability. Rendering it as
|
|
770
|
+
// can('execute', { role: … }) would invent an audience the database never named, and
|
|
771
|
+
// dropping it silently would make the descriptor plan a REVOKE on adoption. Both are
|
|
772
|
+
// the adopter's database being changed to suit our vocabulary.
|
|
773
|
+
if (publicExec) props.push(`privileges: { PUBLIC: ['EXECUTE'] },`);
|
|
774
|
+
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
620
775
|
if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
|
|
621
|
-
props.push(`
|
|
622
|
-
need(
|
|
776
|
+
props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
|
|
777
|
+
need('defineFunction');
|
|
623
778
|
need('sql');
|
|
624
779
|
lines.push(
|
|
625
780
|
...fixmes,
|
|
626
|
-
|
|
781
|
+
// Not a FIXME any more — `privileges: { PUBLIC: ['EXECUTE'] }` above says it, and
|
|
782
|
+
// adopting the descriptor no longer applies a revoke. It is still worth a look on a
|
|
783
|
+
// SECURITY DEFINER function, which is what db:check now WARNs about.
|
|
784
|
+
...(publicExec && f.secdef
|
|
785
|
+
? [`// NOTE: ${o.identity} is SECURITY DEFINER and live-granted EXECUTE to PUBLIC — recorded, not chosen. Narrow it to can('execute', { role }) once you know the intended callers.`]
|
|
786
|
+
: []),
|
|
787
|
+
`export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
|
|
627
788
|
...props.map((p) => ` ${p}`),
|
|
628
789
|
'});',
|
|
629
790
|
'',
|
|
630
791
|
);
|
|
631
792
|
names.push(varName);
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (!f || args === null || (f.language !== 'sql' && f.language !== 'plpgsql')) {
|
|
644
|
-
warnings.push(`${o.identity}: signature beyond the v1 vocabulary (${f ? `language ${f.language}, args '${f.args}'` : 'no structured fields'}) — rendered as defineSql.`);
|
|
645
|
-
// A defineSql object is identified by its NAME alone, so an overload pair would
|
|
646
|
-
// collapse to one provenance row here too — the declared name carries the signature
|
|
647
|
-
// (the drop is explicit, so the name is a label, and a unique one is what it needs).
|
|
648
|
-
const sqlName = declaredName(o) + (f?.identityArgs !== undefined ? `(${f.identityArgs})` : '');
|
|
649
|
-
// A defineSql object cannot carry an owner. Where the pull observed a CHOSEN owner that
|
|
650
|
-
// is real fidelity loss, so it is said out loud on both surfaces — never dropped. An
|
|
651
|
-
// ambient owner needs no note here: there is nothing to declare and nothing was lost.
|
|
652
|
-
if (ownership.prop) {
|
|
653
|
-
const why = `${o.identity}: owned by '${o.owner}', but defineSql cannot declare an owner — the build will create it as the connecting role. Move it into defineFunction's vocabulary, or set the owner by hand after the build.`;
|
|
654
|
-
warnings.push(why);
|
|
655
|
-
lines.push(`// FIXME: ${why}`);
|
|
656
|
-
}
|
|
657
|
-
lines.push(
|
|
658
|
-
`// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
|
|
659
|
-
`export const ${varName} = defineSql(${tsString(sqlName)}, {`,
|
|
660
|
-
` kind: 'function',`,
|
|
661
|
-
` drop: sql\`DROP FUNCTION IF EXISTS ${o.schema === 'public' ? o.name : `${o.schema}.${o.name}`}${f?.identityArgs !== undefined ? `(${f.identityArgs})` : ''}\`,`,
|
|
662
|
-
` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
|
|
663
|
-
'});',
|
|
664
|
-
'',
|
|
665
|
-
);
|
|
666
|
-
need('defineSql');
|
|
667
|
-
need('sql');
|
|
668
|
-
names.push(varName);
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const props: string[] = [];
|
|
673
|
-
if (args.length) {
|
|
674
|
-
const rendered = args.map((a) =>
|
|
675
|
-
`arg(${tsString(a.name)}, ${tsString(a.type)}${a.default ? `, { default: sql\`${tsTemplate(a.default)}\` }` : ''})`);
|
|
676
|
-
props.push(`args: [${rendered.join(', ')}],`);
|
|
677
|
-
need('arg');
|
|
678
|
-
}
|
|
679
|
-
props.push(`returns: ${tsString(f.returns)},`);
|
|
680
|
-
props.push(`language: ${tsString(f.language)},`);
|
|
681
|
-
if (VOLATILITY[f.volatility]) props.push(`volatility: ${tsString(VOLATILITY[f.volatility])},`);
|
|
682
|
-
if (f.secdef) props.push(`security: 'definer',`);
|
|
683
|
-
// THE PIN IS READ, NEVER INVENTED.
|
|
684
|
-
//
|
|
685
|
-
// This used to render `searchPath: ['pg_catalog']` for any definer function whose live
|
|
686
|
-
// proconfig was empty. A build from those models then applied the invented pin for real, so
|
|
687
|
-
// functions that had always resolved `public` stopped resolving it — sign-up and password
|
|
688
|
-
// change died on a database built from the consumer's own models, with every gate green.
|
|
689
|
-
// 34 of their 34 pinned-in-the-model functions were unpinned in the database.
|
|
690
|
-
//
|
|
691
|
-
// A live pin renders as the array. No live pin on a definer function renders the explicit
|
|
692
|
-
// `'unpinned'` sentinel — an ANSWER, not silence, so `defineFunction` still throws for the
|
|
693
|
-
// author who simply forgot. No live pin on an invoker function renders nothing, which
|
|
694
|
-
// already means exactly that.
|
|
695
|
-
if (f.searchPath) {
|
|
696
|
-
const path = f.searchPath.split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
|
697
|
-
props.push(`searchPath: [${path.map(tsString).join(', ')}],`);
|
|
698
|
-
} else if (f.secdef) {
|
|
699
|
-
props.push(`searchPath: 'unpinned',`);
|
|
700
|
-
}
|
|
701
|
-
// Ownership sits beside security because it IS the other half of it: `security: 'definer'`
|
|
702
|
-
// says the function runs as its owner, and this says who that is.
|
|
703
|
-
if (ownership.prop) props.push(ownership.prop);
|
|
704
|
-
if (ownership.fixme) fixmes.push(ownership.fixme);
|
|
705
|
-
if (ownership.warning) warnings.push(ownership.warning);
|
|
706
|
-
if (roleGrants.length) {
|
|
707
|
-
props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
|
|
708
|
-
need('can');
|
|
793
|
+
} finally {
|
|
794
|
+
records.push({
|
|
795
|
+
identity: o.identity,
|
|
796
|
+
varName,
|
|
797
|
+
kind: o.kind,
|
|
798
|
+
schema: o.schema,
|
|
799
|
+
lines: lines.slice(linesStart),
|
|
800
|
+
modelRefs: recordRefs.model,
|
|
801
|
+
derivedRefs: recordRefs.derived,
|
|
802
|
+
});
|
|
709
803
|
}
|
|
710
|
-
// A live PUBLIC EXECUTE is RECORDED, not converted into an ability. Rendering it as
|
|
711
|
-
// can('execute', { role: … }) would invent an audience the database never named, and
|
|
712
|
-
// dropping it silently would make the descriptor plan a REVOKE on adoption. Both are
|
|
713
|
-
// the adopter's database being changed to suit our vocabulary.
|
|
714
|
-
if (publicExec) props.push(`privileges: { PUBLIC: ['EXECUTE'] },`);
|
|
715
|
-
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
716
|
-
if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
|
|
717
|
-
props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
|
|
718
|
-
need('defineFunction');
|
|
719
|
-
need('sql');
|
|
720
|
-
lines.push(
|
|
721
|
-
...fixmes,
|
|
722
|
-
// Not a FIXME any more — `privileges: { PUBLIC: ['EXECUTE'] }` above says it, and
|
|
723
|
-
// adopting the descriptor no longer applies a revoke. It is still worth a look on a
|
|
724
|
-
// SECURITY DEFINER function, which is what db:check now WARNs about.
|
|
725
|
-
...(publicExec && f.secdef
|
|
726
|
-
? [`// NOTE: ${o.identity} is SECURITY DEFINER and live-granted EXECUTE to PUBLIC — recorded, not chosen. Narrow it to can('execute', { role }) once you know the intended callers.`]
|
|
727
|
-
: []),
|
|
728
|
-
`export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
|
|
729
|
-
...props.map((p) => ` ${p}`),
|
|
730
|
-
'});',
|
|
731
|
-
'',
|
|
732
|
-
);
|
|
733
|
-
names.push(varName);
|
|
734
804
|
}
|
|
735
805
|
|
|
736
806
|
// -- trigger bindings, last -------------------------------------------------
|
|
@@ -765,6 +835,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
765
835
|
// Registered only once the binding actually renders — an import emitted for a skipped
|
|
766
836
|
// trigger would be an unused name in generated code.
|
|
767
837
|
if (modelVar) modelRefs.add(owner);
|
|
838
|
+
const linesStart = lines.length;
|
|
768
839
|
// A trigger's name collides freely: with its OWN function (`update_users_tsvector` is
|
|
769
840
|
// both, on the reference schema) and with a same-named trigger on another table
|
|
770
841
|
// (trigger names are unique per table, not per schema). Either collision emits two
|
|
@@ -794,6 +865,15 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
794
865
|
'',
|
|
795
866
|
);
|
|
796
867
|
names.push(varName);
|
|
868
|
+
records.push({
|
|
869
|
+
identity: o.identity,
|
|
870
|
+
varName,
|
|
871
|
+
kind: 'trigger',
|
|
872
|
+
schema: o.schema,
|
|
873
|
+
lines: lines.slice(linesStart),
|
|
874
|
+
modelRefs: modelVar ? [owner] : [],
|
|
875
|
+
derivedRefs: [...(modelVar ? [] : [owner]), `${o.trg.functionIdentity}()`],
|
|
876
|
+
});
|
|
797
877
|
}
|
|
798
878
|
|
|
799
879
|
return {
|
|
@@ -804,6 +884,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
|
|
|
804
884
|
imports: [...used].sort(),
|
|
805
885
|
modelRefs: [...modelRefs].sort(),
|
|
806
886
|
warnings,
|
|
887
|
+
records,
|
|
807
888
|
};
|
|
808
889
|
}
|
|
809
890
|
|
|
@@ -845,3 +926,208 @@ export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<
|
|
|
845
926
|
}
|
|
846
927
|
return lines.join('\n') + '\n';
|
|
847
928
|
}
|
|
929
|
+
|
|
930
|
+
export interface RenderedDerivedFile {
|
|
931
|
+
/** File name relative to the derived directory (`views.ts`, `functions-auth.ts`,
|
|
932
|
+
* `triggers.ts`, `index.ts` last). */
|
|
933
|
+
file: string;
|
|
934
|
+
source: string;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** Which split file a record belongs to. Views and matviews (including a --matviews-as-tables
|
|
938
|
+
* flip, which is still rendered FROM a view/matview object) share one file — the plan's
|
|
939
|
+
* stated shape names only views/functions/triggers, so a matview is a kind of view here.
|
|
940
|
+
* Sequences have no file of their own: they carry no refs, so declaring them directly in the
|
|
941
|
+
* barrel needs no import wiring — the same reason a single-file pull keeps them inline. */
|
|
942
|
+
function derivedBucket(kind: DerivedRecord['kind'], schema: string): string {
|
|
943
|
+
if (kind === 'view' || kind === 'materialized view') return 'views';
|
|
944
|
+
if (kind === 'trigger') return 'triggers';
|
|
945
|
+
if (kind === 'sequence') return 'index';
|
|
946
|
+
return `functions-${schema}`;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function derivedBucketFile(bucket: string): string {
|
|
950
|
+
return `${bucket.replace(/_/g, '-')}.ts`;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** The `@everystack/model` symbols one split file's own text needs — the same regex-scan
|
|
954
|
+
* idiom `importHeader` (model-render.ts) uses per model file, so each split file imports
|
|
955
|
+
* only what it actually calls rather than the whole render's global symbol set. */
|
|
956
|
+
function derivedFileSymbols(body: string): string[] {
|
|
957
|
+
const symbols: string[] = [];
|
|
958
|
+
const add = (s: string): void => { if (!symbols.includes(s)) symbols.push(s); };
|
|
959
|
+
if (/\bdefineView\(/.test(body)) add('defineView');
|
|
960
|
+
if (/\bdefineMaterializedView\(/.test(body)) add('defineMaterializedView');
|
|
961
|
+
if (/\bdefineMaterializedTable\(/.test(body)) { add('defineMaterializedTable'); add('field'); }
|
|
962
|
+
if (/\bdefineFunction\(/.test(body)) add('defineFunction');
|
|
963
|
+
if (/\bdefineSql\(/.test(body)) add('defineSql');
|
|
964
|
+
if (/\bdefineTrigger\(/.test(body)) add('defineTrigger');
|
|
965
|
+
if (/\barg\(/.test(body)) add('arg');
|
|
966
|
+
if (/\bindex\(/.test(body)) add('index');
|
|
967
|
+
if (/\bcan\(/.test(body)) add('can');
|
|
968
|
+
if (/\bsql`/.test(body)) add('sql');
|
|
969
|
+
return symbols.sort();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* DFS cycle detection over the split-file dependency graph. Returns the cycle as a list of
|
|
974
|
+
* bucket names (`a -> b -> a`) or null. Small graph (views/triggers/one-per-schema functions),
|
|
975
|
+
* so a plain recursive DFS with a color map is plenty — no need for Tarjan here.
|
|
976
|
+
*/
|
|
977
|
+
function findFileCycle(graph: Map<string, Set<string>>): string[] | null {
|
|
978
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
979
|
+
const color = new Map<string, number>();
|
|
980
|
+
const nodes = new Set<string>([...graph.keys(), ...[...graph.values()].flatMap((s) => [...s])]);
|
|
981
|
+
for (const n of nodes) color.set(n, WHITE);
|
|
982
|
+
const stack: string[] = [];
|
|
983
|
+
const visit = (n: string): string[] | null => {
|
|
984
|
+
color.set(n, GRAY);
|
|
985
|
+
stack.push(n);
|
|
986
|
+
for (const next of graph.get(n) ?? []) {
|
|
987
|
+
if (color.get(next) === GRAY) return [...stack.slice(stack.indexOf(next)), next];
|
|
988
|
+
if (color.get(next) === WHITE) {
|
|
989
|
+
const found = visit(next);
|
|
990
|
+
if (found) return found;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
stack.pop();
|
|
994
|
+
color.set(n, BLACK);
|
|
995
|
+
return null;
|
|
996
|
+
};
|
|
997
|
+
for (const n of [...nodes].sort()) {
|
|
998
|
+
if (color.get(n) === WHITE) {
|
|
999
|
+
const found = visit(n);
|
|
1000
|
+
if (found) return found;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* The `--derived-out <dir>` case: split by kind and schema instead of one monolith — a full
|
|
1008
|
+
* pull otherwise renders a single multi-thousand-line file that killed a consumer's
|
|
1009
|
+
* hand-maintained `derived.ts` once already (docs/derived-objects.md).
|
|
1010
|
+
*
|
|
1011
|
+
* The import graph between split files is the RENDERED-ref graph (dependsOn/execute/on), not
|
|
1012
|
+
* the catalog graph: `pg_depend` cannot see inside a plpgsql body, so most function→function
|
|
1013
|
+
* calls never become an edge here in the first place — the risk this function guards against
|
|
1014
|
+
* is real but narrow (a view's `dependsOn` naming a function, or vice versa). A cycle between
|
|
1015
|
+
* split files is FATAL at import time (the same TDZ `ReferenceError` that kept trigger
|
|
1016
|
+
* bindings off models), so it is detected and refused here, before anything is written —
|
|
1017
|
+
* never shipped as a barrel nobody can import.
|
|
1018
|
+
*
|
|
1019
|
+
* Model imports are resolved against the PARENT of `derivedDir` — the same co-location
|
|
1020
|
+
* convention the single-file `--derived-out db/models/derived.ts` already relies on
|
|
1021
|
+
* (`renderDerivedFile`'s model imports assume the derived file sits beside the model files).
|
|
1022
|
+
* For the directory form that convention becomes: `db/models/derived/` sits inside
|
|
1023
|
+
* `db/models/`, so `path.dirname(derivedDir)` IS the models directory.
|
|
1024
|
+
*/
|
|
1025
|
+
export function renderDerivedDirectory(
|
|
1026
|
+
result: DerivedRenderResult,
|
|
1027
|
+
knownTables: Map<string, string>,
|
|
1028
|
+
derivedDir: string,
|
|
1029
|
+
): RenderedDerivedFile[] {
|
|
1030
|
+
const records = result.records ?? [];
|
|
1031
|
+
const byIdentity = new Map(records.map((r) => [r.identity, r]));
|
|
1032
|
+
const bucketOf = (r: DerivedRecord): string => derivedBucket(r.kind, r.schema);
|
|
1033
|
+
|
|
1034
|
+
// The file-level dependency graph — self-references (same bucket) don't count, and nothing
|
|
1035
|
+
// ever targets 'index' (sequences carry no refs, so nothing resolves TO one).
|
|
1036
|
+
const fileDeps = new Map<string, Set<string>>();
|
|
1037
|
+
for (const r of records) {
|
|
1038
|
+
const from = bucketOf(r);
|
|
1039
|
+
if (from === 'index') continue;
|
|
1040
|
+
for (const ref of r.derivedRefs) {
|
|
1041
|
+
const target = byIdentity.get(ref);
|
|
1042
|
+
if (!target) continue;
|
|
1043
|
+
const to = bucketOf(target);
|
|
1044
|
+
if (to === from) continue;
|
|
1045
|
+
if (!fileDeps.has(from)) fileDeps.set(from, new Set());
|
|
1046
|
+
fileDeps.get(from)!.add(to);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
const cycle = findFileCycle(fileDeps);
|
|
1050
|
+
if (cycle) {
|
|
1051
|
+
throw new Error(
|
|
1052
|
+
`--derived-out <dir>: ${cycle.map(derivedBucketFile).join(' -> ')} would import each other — a cycle ` +
|
|
1053
|
+
'between descriptor modules is fatal at import time (TDZ). Use --derived-out <file.ts> for this schema instead.',
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const buckets = new Map<string, DerivedRecord[]>();
|
|
1058
|
+
for (const r of records) {
|
|
1059
|
+
const b = bucketOf(r);
|
|
1060
|
+
if (!buckets.has(b)) buckets.set(b, []);
|
|
1061
|
+
buckets.get(b)!.push(r);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const modelsDir = path.dirname(derivedDir);
|
|
1065
|
+
const modelImportFor = (table: string): string => {
|
|
1066
|
+
const target = path.join(modelsDir, modelFileName(table)).replace(/\.ts$/, '');
|
|
1067
|
+
const rel = path.relative(derivedDir, target).split(path.sep).join('/');
|
|
1068
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
const files: RenderedDerivedFile[] = [];
|
|
1072
|
+
const contentBuckets = [...buckets.keys()].filter((b) => b !== 'index').sort();
|
|
1073
|
+
for (const bucket of contentBuckets) {
|
|
1074
|
+
const recs = buckets.get(bucket)!;
|
|
1075
|
+
const body = recs.map((r) => r.lines.join('\n')).join('\n');
|
|
1076
|
+
const modelTables = [...new Set(recs.flatMap((r) => r.modelRefs))].sort();
|
|
1077
|
+
const modelImports = modelTables.map((t) => `import { ${knownTables.get(t)!} } from '${modelImportFor(t)}';`);
|
|
1078
|
+
// Cross-file derived imports, grouped by the OTHER bucket they come from.
|
|
1079
|
+
const crossVarsByBucket = new Map<string, Set<string>>();
|
|
1080
|
+
for (const r of recs) {
|
|
1081
|
+
for (const ref of r.derivedRefs) {
|
|
1082
|
+
const target = byIdentity.get(ref);
|
|
1083
|
+
if (!target) continue;
|
|
1084
|
+
const tb = bucketOf(target);
|
|
1085
|
+
if (tb === bucket || tb === 'index') continue;
|
|
1086
|
+
if (!crossVarsByBucket.has(tb)) crossVarsByBucket.set(tb, new Set());
|
|
1087
|
+
crossVarsByBucket.get(tb)!.add(target.varName);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const crossImports = [...crossVarsByBucket.keys()].sort().map((tb) =>
|
|
1091
|
+
`import { ${[...crossVarsByBucket.get(tb)!].sort().join(', ')} } from './${derivedBucketFile(tb).replace(/\.ts$/, '')}';`);
|
|
1092
|
+
const symbols = derivedFileSymbols(body);
|
|
1093
|
+
const header = [
|
|
1094
|
+
...(symbols.length ? [`import { ${symbols.join(', ')} } from '@everystack/model';`] : []),
|
|
1095
|
+
...modelImports,
|
|
1096
|
+
...crossImports,
|
|
1097
|
+
].join('\n');
|
|
1098
|
+
files.push({ file: derivedBucketFile(bucket), source: `${header ? header + '\n\n' : ''}${body}\n` });
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// index.ts — the derived barrel. Sequences declare INLINE (they carry no refs, so nothing
|
|
1102
|
+
// to import for them); everything else is imported from its split file purely to re-export
|
|
1103
|
+
// through `sequences`/`materializedTables`/`derived`, exactly what renderDerivedFile exports
|
|
1104
|
+
// for the single-file case.
|
|
1105
|
+
const seqRecords = buckets.get('index') ?? [];
|
|
1106
|
+
const indexLines: string[] = [
|
|
1107
|
+
'// Derived layer barrel (views, functions, triggers, sequences) — rendered by',
|
|
1108
|
+
'// `everystack db:pull --derived-out <dir>`. Bodies are the catalog\'s canonical deparse (they',
|
|
1109
|
+
'// never match an authored spelling byte-for-byte); commit, wire this directory on your',
|
|
1110
|
+
'// module — defineModule({ models, sequences, derived }) — then adopt with',
|
|
1111
|
+
'// `db:reconcile --apply --baseline` (or --rebaseline over db/sql-era provenance).',
|
|
1112
|
+
'// docs/derived-objects.md.',
|
|
1113
|
+
];
|
|
1114
|
+
if (seqRecords.length) indexLines.push(`import { defineSequence } from '@everystack/model';`);
|
|
1115
|
+
for (const bucket of contentBuckets) {
|
|
1116
|
+
const vars = buckets.get(bucket)!.map((r) => r.varName);
|
|
1117
|
+
if (vars.length) indexLines.push(`import { ${vars.join(', ')} } from './${derivedBucketFile(bucket).replace(/\.ts$/, '')}';`);
|
|
1118
|
+
}
|
|
1119
|
+
indexLines.push('');
|
|
1120
|
+
for (const r of seqRecords) indexLines.push(...r.lines, '');
|
|
1121
|
+
if (result.sequenceNames.length) {
|
|
1122
|
+
indexLines.push(`export const sequences = [\n${result.sequenceNames.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
1123
|
+
}
|
|
1124
|
+
if (result.materializedTableNames.length) {
|
|
1125
|
+
indexLines.push(`export const materializedTables = [\n${result.materializedTableNames.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
1126
|
+
}
|
|
1127
|
+
if (result.names.length) {
|
|
1128
|
+
indexLines.push(`export const derived = [\n${result.names.map((n) => ` ${n},`).join('\n')}\n];`);
|
|
1129
|
+
}
|
|
1130
|
+
files.push({ file: 'index.ts', source: indexLines.join('\n') + '\n' });
|
|
1131
|
+
|
|
1132
|
+
return files;
|
|
1133
|
+
}
|