@everystack/cli 0.4.82 → 0.4.83

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.
@@ -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
- } else if (derived) refs.push(derived);
463
- else fixmes.push(`// FIXME: dependsOn ${e.referenced} — not in the pulled set; declare it by hand.`);
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
- if (o.kind === 'view' || o.kind === 'materialized view') {
473
- // The live ACL splits in two: the part `can('read')` can SAY (plain SELECT to named
474
- // app roles) becomes abilities, and everything else is RECORDED as `privileges`.
475
- //
476
- // This used to skip the object outright — definition and all — whenever any grant
477
- // was inexpressible, which on a real brownfield schema is every relation it has:
478
- // three roles hold the blanket DELETE/INSERT/REFERENCES/SELECT/TRIGGER/TRUNCATE/
479
- // UPDATE that `GRANT ALL ON ALL TABLES IN SCHEMA` leaves behind, and PUBLIC holds
480
- // SELECT on a few. The adopter got a FIXME instead of their view.
481
- //
482
- // Recording is also what makes rendering SAFE, and it is why the silent-REVOKE
483
- // question that blocked this no longer needs an answer: emitting the view with only
484
- // the abilities we can express would leave the write grants undeclared, so the next
485
- // generate would plan a REVOKE for each — a silent skip traded for a silent revoke.
486
- // Declared == live means nothing is planned at all.
487
- const { abilities, privileges, foreign } = splitRelationGrants(o.grants ?? {});
488
- if (foreign.length) {
489
- warnings.push(
490
- `${o.identity}: grants exist for ${foreign.join(', ')} — recorded as \`privileges\`, not declared as ` +
491
- 'abilities. Abilities name the roles the model governs (anon/authenticated/admin); a grantee outside ' +
492
- 'that vocabulary is fact, not an intended audience. Recorded rather than dropped because a derived ' +
493
- 'object REVOKES any live grantee the declaration omits.',
494
- )
495
- }
496
- const writeGrants = Object.values(privileges).some((ps) =>
497
- ps.some((p) => p === 'INSERT' || p === 'UPDATE' || p === 'DELETE'),
498
- );
499
- if (writeGrants) {
500
- // A view carrying INSERT/UPDATE/DELETE almost never means someone intended a
501
- // writable view — it usually traces to a blanket `GRANT ALL ON ALL TABLES IN
502
- // SCHEMA public` in an old migration, which sweeps views up with the tables.
503
- // Naming that here saves the diagnosis; an adopter paid for it once already.
504
- const grantText = Object.entries(privileges).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
505
- warnings.push(
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
- // --matviews-as-tables (the gap-B consumer flip): the matview renders as a
512
- // defineMaterializedTable MODEL — introspected fields, index constraints on field
513
- // keys, the defining query as metadata. Same topo slot (its dependents still
514
- // reference it by variable); the name lands in materializedTableNames, not names.
515
- if (o.kind === 'materialized view' && opts.matviewsAsTables) {
516
- const cols = opts.matviewsAsTables.columns.get(o.identity);
517
- if (!cols || cols.length === 0) {
518
- warnings.push(`${o.identity}: no live columns found for the table render — rendered as defineMaterializedView; re-pull or flip it by hand.`);
519
- } else {
520
- if (o.populated === false) {
521
- warnings.push(`${o.identity}: the live matview is UNPOPULATED — rendered anyway (a materialized table's contents are pipeline-owned).`);
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
- const shape: TableSchema = {
524
- table: o.identity, columns: cols,
525
- primaryKey: [], uniques: [], checks: [], foreignKeys: [], indexes: [],
526
- };
527
- // Field lines align with `cols` by index — append the --suggest-keys comment in place.
528
- const suggested = new Set(opts.matviewsAsTables.keyCandidates?.get(o.identity) ?? []);
529
- const fieldLines = renderFieldLines(shape, new Set(), opts.matviewsAsTables.enums ?? new Map())
530
- .map((line, i) => suggested.has(cols[i].name)
531
- ? `${line} // PK candidate: non-null + all-distinct across live rows — add .primaryKey() if this is the natural key`
532
- : line);
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 = renderTableIndexBuilder(def);
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) need('index');
539
- if (abilities.length) need('can');
540
- need('defineMaterializedTable');
541
- need('field');
542
- need('sql');
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
- const hasPrivileges = Object.keys(privileges).length > 0;
575
- if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
576
- // A15 — the owner, through the SAME discriminator functions use. Elevated exactly when
577
- // the relation runs as its owner: a view that is not securityInvoker, and a matview
578
- // always (its rows are computed at REFRESH time with the owner's rights, no exceptions).
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
- if (hasPrivileges) {
603
- const entries = Object.keys(privileges).sort()
604
- .map((g) => `${tsKey(g)}: [${privileges[g].map(tsString).join(', ')}]`);
605
- props.push(`privileges: { ${entries.join(', ')} },`);
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
- if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
608
- if (o.kind === 'materialized view') {
609
- const builders = o.indexes.map((def) => {
610
- const b = renderIndexBuilder(def);
611
- if (b === null) warnings.push(`${o.identity}: index not renderable: ${def}`);
612
- return b;
613
- }).filter((b): b is string => b !== null);
614
- if (builders.length) {
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(`as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`);
622
- need(o.kind === 'view' ? 'defineView' : 'defineMaterializedView');
776
+ props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
777
+ need('defineFunction');
623
778
  need('sql');
624
779
  lines.push(
625
780
  ...fixmes,
626
- `export const ${varName} = ${o.kind === 'view' ? 'defineView' : 'defineMaterializedView'}(${tsString(declaredName(o))}, {`,
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
- continue;
633
- }
634
-
635
- // Functions.
636
- const f = o.fn;
637
- const grants = o.grants ?? {};
638
- const roleGrants = Object.keys(grants).filter((r) => r.toUpperCase() !== 'PUBLIC').sort();
639
- const publicExec = Object.keys(grants).some((r) => r.toUpperCase() === 'PUBLIC');
640
- const args = f && (f.language === 'sql' || f.language === 'plpgsql') ? parseArgs(f.args) : null;
641
- const ownership = renderedFunctionOwner(o, f?.secdef === true);
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
+ }