@everystack/cli 0.3.31 → 0.4.1

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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/.pdr-tmp-20277-1783706384842.ts +59 -0
  4. package/src/.roundtrip-tmp-20275-1783706385459.ts +121 -0
  5. package/src/cli/authz-compile.ts +5 -2
  6. package/src/cli/aws.ts +12 -3
  7. package/src/cli/backfill.ts +1 -1
  8. package/src/cli/commands/db-authz.ts +3 -17
  9. package/src/cli/commands/db-branch.ts +28 -4
  10. package/src/cli/commands/db-check.ts +84 -33
  11. package/src/cli/commands/db-fingerprint.ts +7 -2
  12. package/src/cli/commands/db-generate.ts +102 -34
  13. package/src/cli/commands/db-pull.ts +82 -6
  14. package/src/cli/commands/db-reconcile.ts +132 -41
  15. package/src/cli/commands/db-sync.ts +54 -19
  16. package/src/cli/commands/db.ts +7 -6
  17. package/src/cli/commands/update.ts +2 -1
  18. package/src/cli/db-build.ts +9 -3
  19. package/src/cli/db-source.ts +5 -1
  20. package/src/cli/declared-derived.ts +173 -0
  21. package/src/cli/derived-apply.ts +40 -6
  22. package/src/cli/derived-compile.ts +377 -0
  23. package/src/cli/derived-grants.ts +96 -0
  24. package/src/cli/derived-introspect.ts +258 -21
  25. package/src/cli/derived-lint.ts +261 -0
  26. package/src/cli/derived-plan.ts +176 -10
  27. package/src/cli/derived-render.ts +366 -0
  28. package/src/cli/derived-source.ts +53 -125
  29. package/src/cli/edge-plan.ts +4 -1
  30. package/src/cli/git-descent.ts +15 -2
  31. package/src/cli/index.ts +6 -6
  32. package/src/cli/migration-compile.ts +38 -7
  33. package/src/cli/migration-generate.ts +43 -4
  34. package/src/cli/model-render.ts +125 -16
  35. package/src/cli/models-path.ts +1 -1
  36. package/src/cli/ops-advice.ts +66 -0
  37. package/src/cli/pipeline-path.ts +1 -1
  38. package/src/cli/schema-compile.ts +41 -10
  39. package/src/cli/schema-diff.ts +123 -17
  40. package/src/cli/schema-fingerprint.ts +28 -18
  41. package/src/cli/schema-introspect.ts +175 -8
  42. package/src/cli/schema-source.ts +75 -6
  43. package/src/cli/state-apply.ts +52 -1
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The declared-DB loader — one call that turns the models barrel into everything the
3
+ * verbs consume beyond the models themselves: the descriptor-compiled derived layer
4
+ * (views/matviews/functions/triggers/escape-hatch objects, topologically ordered), the
5
+ * standalone sequences (state), and the pending table renames (so trigger provenance
6
+ * migrates instead of drop+create-ing). This is the B3 seam: the same loader feeds
7
+ * db:reconcile, db:sync, db:check, and db:generate — descriptors are live everywhere
8
+ * or nowhere.
9
+ */
10
+
11
+ import path from 'node:path';
12
+ import fs from 'node:fs/promises';
13
+ import { pathToFileURL } from 'node:url';
14
+ import type { Module, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
15
+ import { compileDerived } from './derived-compile.js';
16
+ import { compileTableRenames } from './schema-compile.js';
17
+ import type { SourceObject } from './derived-source.js';
18
+ import { resolveModelsPath } from './models-path.js';
19
+ import { asModelComposeError } from './ops-advice.js';
20
+
21
+ export interface DeclaredDerived {
22
+ /** Descriptor-compiled derived objects, in dependency order. */
23
+ objects: SourceObject[];
24
+ /** The raw descriptors — the B2 gates (db:check) read abilities, not compiled SQL. */
25
+ derived: DerivedDescriptor[];
26
+ /** Standalone sequences (state layer — created before tables, fingerprinted). */
27
+ sequences: SequenceDescriptor[];
28
+ /** Pending table renames (`new qualified` → `old qualified`) from the models' markers. */
29
+ renamedTables: Record<string, string>;
30
+ }
31
+
32
+ /**
33
+ * The loud undefined gate at the barrel boundary: a bare `export const models = [...]`
34
+ * never passes through defineModule, so a missing named import (undefined under CJS
35
+ * interop) survives to the loader. Name the file, the list, and the index here.
36
+ */
37
+ export function assertNoHoles(source: string, listName: string, list: readonly unknown[]): void {
38
+ list.forEach((entry, i) => {
39
+ if (entry == null) {
40
+ throw new Error(`${source}: ${listName}[${i}] is ${entry === undefined ? 'undefined' : 'null'} — renamed or missing import?`);
41
+ }
42
+ });
43
+ }
44
+
45
+ /** Barrel → normalized Module list (a bare `models` export wraps into one module).
46
+ * Every failure here is the operator's own barrel — thrown as ModelComposeError so
47
+ * the command shells never dress it as an IAM/Lambda problem. */
48
+ export async function loadModulesFrom(modelsPath: string): Promise<Module[]> {
49
+ try {
50
+ const abs = path.resolve(modelsPath);
51
+ let mod: any;
52
+ try {
53
+ mod = await import(pathToFileURL(abs).href);
54
+ } catch (err: any) {
55
+ throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
56
+ }
57
+ if (Array.isArray(mod.modules)) {
58
+ assertNoHoles(modelsPath, 'modules', mod.modules);
59
+ return mod.modules;
60
+ }
61
+ const models = mod.models ?? mod.default;
62
+ if (Array.isArray(models)) {
63
+ assertNoHoles(modelsPath, 'models', models);
64
+ return [{ models, extensions: [], sequences: [], derived: [], functions: [] }];
65
+ }
66
+ throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
67
+ } catch (err) {
68
+ throw asModelComposeError(modelsPath, err);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * B7 — db/sql is RETIRED; descriptors are the single home. A project still carrying
74
+ * `.sql` files in db/sql gets this message from every verb that used to read the
75
+ * directory: a hard failure carrying the migration path, never a silent skip (silence
76
+ * would leave those objects unmanaged and, later, honestly-dropped). An empty leftover
77
+ * directory or non-.sql stragglers are harmless — only actual `.sql` files trip it.
78
+ */
79
+ export async function retiredSqlDir(dir: string = 'db/sql'): Promise<string | null> {
80
+ let entries: string[];
81
+ try {
82
+ entries = await fs.readdir(path.resolve(dir));
83
+ } catch (err: any) {
84
+ // A missing directory has nothing to say. Anything else (EACCES, EIO) must NOT
85
+ // silently disarm the tombstone — an unreadable db/sql could still hold live sources.
86
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return null;
87
+ throw new Error(`could not inspect ${dir} for retired db/sql sources: ${err?.message ?? err}`);
88
+ }
89
+ const files = entries.filter((f) => f.endsWith('.sql')).sort();
90
+ if (files.length === 0) return null;
91
+ return (
92
+ `${dir} is retired — the derived layer is DECLARED as descriptors, and this directory is no longer read. ` +
93
+ `${files.length} file(s) still present: ${files.join(', ')}. ` +
94
+ `Migrate: declare each object with defineView / defineMaterializedView / defineFunction / defineSql on your module ` +
95
+ `(or run \`everystack db:pull\` against the live database to render them), delete this directory, commit, then run ` +
96
+ `\`db:reconcile --apply --rebaseline\` once — it verifies each live object is untouched since the last reconcile ` +
97
+ `and re-records the re-rendered source, no rebuild (objects the reconciler never touched want --baseline instead; ` +
98
+ `the flags compose). See docs/derived-objects.md.`
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The detector, anchored to BOTH homes a checkout can carry: the CWD's `db/sql` (the
104
+ * classic layout) and the resolved models home's sibling `sql/` directory (monorepos
105
+ * running verbs from the repo root with `--models apps/x/db/models/index.ts` — the
106
+ * red-team's CWD-anchoring finding). First message wins; probes are deduped.
107
+ */
108
+ export async function retiredSqlDirAnywhere(modelsFlag?: string): Promise<string | null> {
109
+ const probes = new Map<string, string>(); // resolved → display
110
+ const add = (dir: string): void => { probes.set(path.resolve(dir), dir); };
111
+ add('db/sql');
112
+ add(path.join(path.dirname(path.dirname(resolveModelsPath(modelsFlag))), 'sql'));
113
+ for (const dir of probes.values()) {
114
+ const message = await retiredSqlDir(dir);
115
+ if (message) return message;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * The `--sql-dir` refusal — never a dead end. The flag is retired, but the directory it
122
+ * points at may still hold live db/sql-era sources the default probes would never see;
123
+ * the refusal carries THAT directory's state and migration path, so dropping the flag
124
+ * doesn't strand the files (the red-team's dead-end-refusal finding).
125
+ */
126
+ export async function retiredSqlDirFlagRefusal(sqlDirFlag: string): Promise<string> {
127
+ const head =
128
+ '--sql-dir is retired — the derived layer is DECLARED as descriptors ' +
129
+ '(defineView/defineMaterializedView/defineFunction/defineSql on your module) and no raw-SQL directory is read. Remove the flag.';
130
+ let theirs: string | null;
131
+ try {
132
+ theirs = await retiredSqlDir(sqlDirFlag);
133
+ } catch (err: any) {
134
+ return `${head}\n${err?.message ?? err}`;
135
+ }
136
+ return theirs
137
+ ? `${head}\n${theirs}`
138
+ : `${head} (${sqlDirFlag} carries no .sql files — nothing left to migrate.)`;
139
+ }
140
+
141
+ /**
142
+ * Load the declared derived layer + sequences + renames from the models barrel.
143
+ * Returns null when no barrel exists (a models-less V1 project) — the verbs then
144
+ * run with no derived layer at all.
145
+ */
146
+ export async function loadDeclaredDerived(modelsFlag?: string): Promise<DeclaredDerived | null> {
147
+ const modelsPath = resolveModelsPath(modelsFlag);
148
+ try {
149
+ await fs.access(path.resolve(modelsPath));
150
+ } catch {
151
+ return null;
152
+ }
153
+ const modules = await loadModulesFrom(modelsPath);
154
+ return composeDeclaredDerived(modules, modelsPath);
155
+ }
156
+
157
+ /** The pure compose half of {@link loadDeclaredDerived} — every gate failure in the
158
+ * compile (holes, reachability, cycles, undeclared trigger functions) is the
159
+ * operator's own declaration, wrapped as ModelComposeError naming the barrel. */
160
+ export function composeDeclaredDerived(modules: Module[], modelsPath: string): DeclaredDerived {
161
+ try {
162
+ const models = modules.flatMap((m) => m.models);
163
+ const derived = modules.flatMap((m) => m.derived);
164
+ return {
165
+ objects: compileDerived(models, derived),
166
+ derived,
167
+ sequences: modules.flatMap((m) => m.sequences),
168
+ renamedTables: compileTableRenames(models, {}),
169
+ };
170
+ } catch (err) {
171
+ throw asModelComposeError(modelsPath, err);
172
+ }
173
+ }
@@ -34,6 +34,12 @@ export const ENSURE_RECONCILER_SQL: string[] = [
34
34
  def_hash text NOT NULL,
35
35
  applied_at timestamptz NOT NULL DEFAULT now()
36
36
  )`,
37
+ // Provenance records how to REMOVE what it recorded (the C1 closure): a 'sql'-kind
38
+ // object never joins the live catalog, so its recorded drop_sql is the only way a
39
+ // removal can drop it instead of silently orphaning it. Idempotent — legacy
40
+ // databases upgrade in place; their old rows (relations/functions) never need these.
41
+ `ALTER TABLE everystack.derived_provenance ADD COLUMN IF NOT EXISTS kind text`,
42
+ `ALTER TABLE everystack.derived_provenance ADD COLUMN IF NOT EXISTS drop_sql text`,
37
43
  `CREATE TABLE IF NOT EXISTS everystack.schema_log (
38
44
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
39
45
  applied_at timestamptz NOT NULL DEFAULT now(),
@@ -56,6 +62,15 @@ export interface RenderedReconcile {
56
62
  record: string[];
57
63
  /** Identities whose provenance rows must be deleted (drops + prunes). */
58
64
  remove: string[];
65
+ /** Provenance identity migrations (a table rename carried its triggers — same object, new identity). */
66
+ migrate: Array<{ from: string; to: string }>;
67
+ }
68
+
69
+ /** The structural trigger drop — `DROP TRIGGER … ON <table>`, never derivable from a
70
+ * plain identity (the red-team's C4). Shared by the plan (drop actions) and the
71
+ * command (recording drop_sql at provenance time). */
72
+ export function triggerDropSql(name: string, table: string): string {
73
+ return `DROP TRIGGER IF EXISTS "${name}" ON ${table}`;
59
74
  }
60
75
 
61
76
  const DROP_KEYWORD: Record<string, string> = {
@@ -96,7 +111,10 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
96
111
  for (const action of plan.actions) {
97
112
  switch (action.action) {
98
113
  case 'drop': {
99
- statements.push(`DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${quoteQualified(action.identity)}`);
114
+ // The new kinds carry their drop on the action (triggers: composed from structure;
115
+ // 'sql' objects: the recorded/declared drop_sql) — the legacy kinds keep the
116
+ // keyword rendering they always had.
117
+ statements.push(action.dropSql ?? `DROP ${DROP_KEYWORD[action.kind]} IF EXISTS ${quoteQualified(action.identity)}`);
100
118
  // A rebuild's drop is followed by its create; only a true removal loses provenance.
101
119
  if (!plan.actions.some((a) => a.action === 'create' && a.identity === action.identity)) {
102
120
  remove.push(action.identity);
@@ -125,6 +143,12 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
125
143
  record.push(action.identity);
126
144
  break;
127
145
  }
146
+ // A re-render of an untouched live object: no DDL — the upsert re-records the new
147
+ // source hash against the live def hash the post-batch introspection reads.
148
+ case 'rebaseline': {
149
+ record.push(action.identity);
150
+ break;
151
+ }
128
152
  case 'prune': {
129
153
  remove.push(action.identity);
130
154
  break;
@@ -132,13 +156,23 @@ export function renderReconcileSql(plan: ReconcilePlan, source: SourceObject[]):
132
156
  }
133
157
  }
134
158
 
135
- return { statements, record, remove };
159
+ // Grant convergence rides the same batch, after every object exists. Idempotent
160
+ // REVOKE/GRANT — no provenance to record (ACLs are not in any hash).
161
+ for (const r of plan.regrants) statements.push(...r.statements);
162
+
163
+ return { statements, record, remove, migrate: [...plan.migrations] };
164
+ }
165
+
166
+ export function renderProvenanceUpsert(identity: string, srcHash: string, defHash: string, kind?: string, dropSql?: string): string {
167
+ return `INSERT INTO everystack.derived_provenance (identity, src_hash, def_hash, kind, drop_sql, applied_at)
168
+ VALUES (${escapeLiteral(identity)}, ${escapeLiteral(srcHash)}, ${escapeLiteral(defHash)}, ${nullable(kind)}, ${nullable(dropSql)}, now())
169
+ ON CONFLICT (identity) DO UPDATE SET src_hash = EXCLUDED.src_hash, def_hash = EXCLUDED.def_hash, kind = EXCLUDED.kind, drop_sql = EXCLUDED.drop_sql, applied_at = now()`;
136
170
  }
137
171
 
138
- export function renderProvenanceUpsert(identity: string, srcHash: string, defHash: string): string {
139
- return `INSERT INTO everystack.derived_provenance (identity, src_hash, def_hash, applied_at)
140
- VALUES (${escapeLiteral(identity)}, ${escapeLiteral(srcHash)}, ${escapeLiteral(defHash)}, now())
141
- ON CONFLICT (identity) DO UPDATE SET src_hash = EXCLUDED.src_hash, def_hash = EXCLUDED.def_hash, applied_at = now()`;
172
+ /** A table rename carried its triggers along — same object, new identity; the provenance
173
+ * row migrates instead of the object drop+create-ing. */
174
+ export function renderProvenanceMigrate(from: string, to: string): string {
175
+ return `UPDATE everystack.derived_provenance SET identity = ${escapeLiteral(to)} WHERE identity = ${escapeLiteral(from)}`;
142
176
  }
143
177
 
144
178
  export function renderProvenanceDelete(identity: string): string {
@@ -0,0 +1,377 @@
1
+ /**
2
+ * The descriptor compiler — the declarative derived layer's front-end to the reconciler
3
+ * (Brick B1b of docs/plans/read-model-everywhere.md).
4
+ *
5
+ * defineView / defineMaterializedView / defineFunction / defineSql / trigger() render to
6
+ * the SAME `SourceObject` shape (and content hash) the retired db/sql parser produced
7
+ * (`hashSourceContent`) — so an object authored either way is ONE object, and the
8
+ * db/sql-era → descriptor migration reconciled as a no-op instead of a full rebuild. The
9
+ * reconciler core (content-hash identity, provenance, cascade, baseline) is reused
10
+ * untouched; the edges the NEW kinds need (trigger drops, explicit-drop objects) land
11
+ * with the plan/apply extensions (B1c).
12
+ *
13
+ * Ordering is a topological sort over DECLARED `dependsOn` refs (plus the deps that come
14
+ * free: a trigger's `execute:` function, its declaration-site relation; a function's
15
+ * `setof(ref)` return). Ties break by identity sort — never by declaration or import
16
+ * order, so the plan is stable under refactors. Model (table) refs are state-layer:
17
+ * always created before any compute, so they impose no edge here.
18
+ */
19
+
20
+ import type {
21
+ ModelDescriptor, DerivedDescriptor, ViewDescriptor, MaterializedViewDescriptor,
22
+ FunctionDescriptor, SqlDescriptor, TriggerSpec, Ability, SetofReturn, DependsOnRef,
23
+ } from '@everystack/model';
24
+ import { hashSourceContent, parseQualified, DECLARED_SOURCE_FILE, type Attachment, type SourceObject } from './derived-source.js';
25
+ import { findInvokerReachabilityGaps } from './derived-lint.js';
26
+
27
+ /** The provenance marker for descriptor-compiled objects (SourceObject.file). */
28
+ const DECLARED = DECLARED_SOURCE_FILE;
29
+
30
+ /** `public.x` renders bare (authored style — hash parity with hand-written sources); other schemas qualify. */
31
+ function renderName(schema: string, name: string): string {
32
+ return schema === 'public' ? name : `${schema}.${name}`;
33
+ }
34
+
35
+ /** The declared name of a dependable ref, for rendering (`SETOF posts`) and identity. */
36
+ function refName(ref: SetofReturn['setof']): string {
37
+ if ('table' in ref) return renderName(ref.schema, ref.table);
38
+ return ref.name;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Grants — the same audience semantics as tables
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Relation read grants: bare read → anon + authenticated (the table precedent);
46
+ * `{ role }` narrows; `{ columns }` scopes the SELECT to a column list. */
47
+ function relationGrants(target: string, abilities: readonly Ability[]): Attachment[] {
48
+ const out: Attachment[] = [];
49
+ for (const a of abilities) {
50
+ const roles = a.condition.role ? a.condition.role : 'anon, authenticated';
51
+ const cols = a.condition.columns?.length
52
+ ? ` (${a.condition.columns.map((c) => `"${c}"`).join(', ')})`
53
+ : '';
54
+ out.push({ kind: 'grant', sql: `GRANT SELECT${cols} ON ${target} TO ${roles}` });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** Function authz: `REVOKE ALL … FROM PUBLIC` UNCONDITIONALLY (PostgreSQL grants EXECUTE
60
+ * to PUBLIC by default — the silent-open inverse of the RLS trap), then the declared
61
+ * `can('execute', { role })` grants. */
62
+ function functionGrants(signature: string, abilities: readonly Ability[]): Attachment[] {
63
+ const out: Attachment[] = [{ kind: 'grant', sql: `REVOKE ALL ON FUNCTION ${signature} FROM PUBLIC` }];
64
+ for (const a of abilities) {
65
+ out.push({ kind: 'grant', sql: `GRANT EXECUTE ON FUNCTION ${signature} TO ${a.condition.role}` });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Renderers — one CREATE (no trailing semicolon) + attachments per descriptor
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function escapeLiteral(s: string): string {
75
+ return s.replace(/'/g, "''");
76
+ }
77
+
78
+ function commentAttachment(kindWord: string, target: string, comment: string | undefined): Attachment[] {
79
+ return comment ? [{ kind: 'comment', sql: `COMMENT ON ${kindWord} ${target} IS '${escapeLiteral(comment)}'` }] : [];
80
+ }
81
+
82
+ function renderView(v: ViewDescriptor): { sql: string; attachments: Attachment[] } {
83
+ const { schema, name } = parseQualified(v.name);
84
+ const target = renderName(schema, name);
85
+ // security_invoker only when true — false is PostgreSQL's default, and emitting the
86
+ // default would break hash parity with hand-authored sources.
87
+ const invoker = v.securityInvoker ? ' WITH (security_invoker = true)' : '';
88
+ return {
89
+ sql: `CREATE VIEW ${target}${invoker} AS\n${v.as}`,
90
+ attachments: [
91
+ ...commentAttachment('VIEW', target, v.comment),
92
+ ...relationGrants(target, v.abilities),
93
+ ],
94
+ };
95
+ }
96
+
97
+ function renderMaterializedView(mv: MaterializedViewDescriptor): { sql: string; attachments: Attachment[] } {
98
+ const { schema, name } = parseQualified(mv.name);
99
+ const target = renderName(schema, name);
100
+ // populate: 'deferred' → WITH NO DATA; the existing rebuild machinery (derived-apply's
101
+ // objectSql) appends the REFRESH after creation — outside the AS-SELECT populate lock.
102
+ const noData = mv.populate === 'deferred' ? '\nWITH NO DATA' : '';
103
+ const indexes: Attachment[] = mv.indexes.map((ix) => {
104
+ // Same vocabulary and rendering rules as table indexes (Brick E): plain entries
105
+ // quote, raw entries (expressions/DESC/opclass) pass verbatim, names sanitize.
106
+ const nameParts = ix.columns.map((c) => c.replace(/\W+/g, '_').replace(/^_+|_+$/g, ''));
107
+ const idxName = `${name}_${nameParts.join('_')}_index`;
108
+ const cols = ix.columns
109
+ .map((c, i) => (ix.rawPositions?.[i] ? c : `"${c}"`))
110
+ .join(', ');
111
+ const unique = ix.isUnique ? 'UNIQUE ' : '';
112
+ const using = ix.method ? ` USING ${ix.method}` : '';
113
+ const include = ix.includeColumns?.length ? ` INCLUDE (${ix.includeColumns.map((c) => `"${c}"`).join(', ')})` : '';
114
+ const where = ix.predicate ? ` WHERE ${ix.predicate}` : '';
115
+ return { kind: 'index', sql: `CREATE ${unique}INDEX ${idxName} ON ${target}${using} (${cols})${include}${where}` };
116
+ });
117
+ return {
118
+ sql: `CREATE MATERIALIZED VIEW ${target} AS\n${mv.as}${noData}`,
119
+ attachments: [
120
+ ...indexes,
121
+ ...commentAttachment('MATERIALIZED VIEW', target, mv.comment),
122
+ ...relationGrants(target, mv.abilities),
123
+ ],
124
+ };
125
+ }
126
+
127
+ /** A dollar-quote tag the body cannot collide with. */
128
+ function dollarTag(body: string): string {
129
+ let tag = '$fn$';
130
+ for (let n = 1; body.includes(tag); n++) tag = `$fn${n}$`;
131
+ return tag;
132
+ }
133
+
134
+ /** `name(text, integer)` — the signature GRANT/COMMENT statements reference. */
135
+ function functionSignature(target: string, fn: FunctionDescriptor): string {
136
+ return `${target}(${fn.args.map((a) => a.type).join(', ')})`;
137
+ }
138
+
139
+ function renderFunction(fn: FunctionDescriptor): { sql: string; attachments: Attachment[] } {
140
+ const { schema, name } = parseQualified(fn.name);
141
+ const target = renderName(schema, name);
142
+ const args = fn.args
143
+ .map((a) => `${a.name} ${a.type}${a.default != null ? ` DEFAULT ${a.default}` : ''}`)
144
+ .join(', ');
145
+ const returns = typeof fn.returns === 'string' ? fn.returns : `SETOF ${refName(fn.returns.setof)}`;
146
+ const volatility = fn.volatility === 'volatile' ? '' : ` ${fn.volatility.toUpperCase()}`;
147
+ const security = fn.security === 'definer'
148
+ ? ` SECURITY DEFINER SET search_path = ${(fn.searchPath ?? []).join(', ')}`
149
+ : '';
150
+ const tag = dollarTag(fn.body);
151
+ const signature = functionSignature(target, fn);
152
+ return {
153
+ sql:
154
+ `CREATE FUNCTION ${target}(${args})\n` +
155
+ `RETURNS ${returns}\n` +
156
+ `LANGUAGE ${fn.language}${volatility}${security}\n` +
157
+ `AS ${tag}\n${fn.body}\n${tag}`,
158
+ attachments: [
159
+ ...commentAttachment('FUNCTION', signature, fn.comment),
160
+ ...functionGrants(signature, fn.abilities),
161
+ ],
162
+ };
163
+ }
164
+
165
+ const TIMING_SQL = { before: 'BEFORE', after: 'AFTER', insteadOf: 'INSTEAD OF' } as const;
166
+
167
+ function renderTrigger(ownerSchema: string, ownerName: string, t: TriggerSpec): { sql: string; table: string } {
168
+ const table = renderName(ownerSchema, ownerName);
169
+ const events = t.events
170
+ .map((e) => (e === 'update' && t.of?.length ? `UPDATE OF ${t.of.map((c) => `"${c}"`).join(', ')}` : e.toUpperCase()))
171
+ .join(' OR ');
172
+ const when = t.condition ? ` WHEN (${t.condition})` : '';
173
+ const fnTarget = renderName(...(([q]) => [q.schema, q.name] as const)([parseQualified(t.execute.name)]));
174
+ return {
175
+ sql: `CREATE TRIGGER ${t.name} ${TIMING_SQL[t.timing]} ${events} ON ${table} FOR EACH ${t.forEach.toUpperCase()}${when} EXECUTE FUNCTION ${fnTarget}()`,
176
+ table,
177
+ };
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // The compiler — descriptors (+ model/view triggers) → topologically ordered SourceObjects
182
+ // ---------------------------------------------------------------------------
183
+
184
+ interface Node {
185
+ identity: string;
186
+ deps: string[];
187
+ build: (seq: number) => SourceObject;
188
+ }
189
+
190
+ function derivedIdentity(d: DerivedDescriptor): string {
191
+ const { schema, name } = parseQualified(d.name);
192
+ return `${schema}.${name}`;
193
+ }
194
+
195
+ /** What a bad ref IS, for the gate's message — null if the value is a usable object. */
196
+ function badRef(r: unknown): string | null {
197
+ if (r === undefined) return 'undefined';
198
+ if (r === null) return 'null';
199
+ if (typeof r !== 'object') return `not a descriptor (${typeof r})`;
200
+ return null;
201
+ }
202
+
203
+ /**
204
+ * The loud undefined gate — every list compileDerived walks, checked up front. The
205
+ * message shape mirrors the define-time gate in @everystack/model: owner, list,
206
+ * index, and the likely cause (a missing named import evaluates to undefined).
207
+ */
208
+ function rejectBadRefs(models: readonly ModelDescriptor[], derived: readonly DerivedDescriptor[]): void {
209
+ const hole = (context: string, listName: string, list: readonly unknown[], indexed = true): void => {
210
+ list.forEach((entry, i) => {
211
+ const bad = badRef(entry);
212
+ if (bad) throw new Error(`${context}: ${listName}${indexed ? `[${i}]` : ''} is ${bad} — renamed or missing import?`);
213
+ });
214
+ };
215
+ hole('compileDerived', 'models', models);
216
+ hole('compileDerived', 'derived', derived);
217
+ for (const d of derived) {
218
+ hole(`${d.kind} '${d.name}'`, 'dependsOn', d.dependsOn ?? []);
219
+ if (d.kind === 'function' && typeof d.returns !== 'string') {
220
+ hole(`function '${d.name}'`, 'returns.setof', [d.returns.setof], false);
221
+ }
222
+ }
223
+ const triggerOwners = [
224
+ ...models.map((m) => ({ owner: `${m.schema}.${m.table}`, triggers: m.triggers ?? [] })),
225
+ ...derived.filter((d): d is ViewDescriptor => d.kind === 'view').map((v) => {
226
+ const q = parseQualified(v.name);
227
+ return { owner: `${q.schema}.${q.name}`, triggers: v.triggers ?? [] };
228
+ }),
229
+ ];
230
+ for (const { owner, triggers } of triggerOwners) {
231
+ hole(owner, 'triggers', triggers);
232
+ for (const t of triggers) {
233
+ const bad = badRef(t.execute);
234
+ if (bad) throw new Error(`trigger '${t.name}' on ${owner}: execute is ${bad} — renamed or missing import?`);
235
+ }
236
+ }
237
+ }
238
+
239
+ function make(kind: SourceObject['kind'], rawName: string, sql: string, attachments: Attachment[], extra: Partial<SourceObject> = {}): (seq: number) => SourceObject {
240
+ const { schema, name } = parseQualified(rawName);
241
+ return (seq) => ({
242
+ kind, schema, name,
243
+ identity: extra.identity ?? `${schema}.${name}`,
244
+ sql, attachments,
245
+ hash: hashSourceContent(sql, attachments),
246
+ file: DECLARED, seq,
247
+ ...extra,
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Compile the declared derived layer: every descriptor plus every trigger declared on the
253
+ * models and views, in dependency order. A trigger's `execute:` function must itself be in
254
+ * the derived set — a trigger cannot draw on a function nothing declares.
255
+ */
256
+ export function compileDerived(models: readonly ModelDescriptor[], derived: readonly DerivedDescriptor[]): SourceObject[] {
257
+ // The loud undefined gate, compile-side. Define-time already rejects holes, but
258
+ // hand-rolled descriptors and a stale @everystack/model can carry them here — so a
259
+ // bad ref is a HARD error naming the descriptor and index, never a silent drop
260
+ // (the old non-object filter dropped the edge and let the lint crash on it later).
261
+ rejectBadRefs(models, derived);
262
+
263
+ // Invoker-view reachability is a COMPILE fail (B2): a granted-but-unreadable view is
264
+ // structurally broken — every verb that consumes descriptors refuses, not just db:check.
265
+ const unreachable = findInvokerReachabilityGaps(derived);
266
+ if (unreachable.length > 0) {
267
+ throw new Error(unreachable.map((g) => g.message).join('\n'));
268
+ }
269
+
270
+ const identities = new Set(derived.map(derivedIdentity));
271
+ const nodes: Node[] = [];
272
+
273
+ const depIdentities = (refs: readonly DependsOnRef[]): string[] =>
274
+ refs
275
+ .filter((r): r is DerivedDescriptor => !('table' in r))
276
+ .map((r) => derivedIdentity(r))
277
+ .filter((id) => identities.has(id));
278
+
279
+ // The DECLARED dependency set, verbatim: every ref (models included, in-set or not) as
280
+ // an identity — what B4's dependency drift verifies against the live catalog's edges.
281
+ // Distinct from the topo `deps` above, which keep only in-set derived identities.
282
+ const declaredIdentities = (refs: readonly DependsOnRef[]): string[] =>
283
+ [...new Set(refs.map((r) => ('table' in r ? `${r.schema}.${r.table}` : derivedIdentity(r))))].sort();
284
+
285
+ for (const d of derived) {
286
+ const identity = derivedIdentity(d);
287
+ switch (d.kind) {
288
+ case 'view': {
289
+ const { sql, attachments } = renderView(d);
290
+ nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn) }) });
291
+ break;
292
+ }
293
+ case 'materialized view': {
294
+ const { sql, attachments } = renderMaterializedView(d);
295
+ nodes.push({ identity, deps: depIdentities(d.dependsOn), build: make('materialized view', d.name, sql, attachments, { declaredDeps: declaredIdentities(d.dependsOn) }) });
296
+ break;
297
+ }
298
+ case 'function': {
299
+ const { sql, attachments } = renderFunction(d);
300
+ const setofDep = typeof d.returns === 'string' ? [] : depIdentities([d.returns.setof]);
301
+ nodes.push({ identity, deps: [...depIdentities(d.dependsOn), ...setofDep], build: make('function', d.name, sql, attachments) });
302
+ break;
303
+ }
304
+ case 'sql': {
305
+ // The drop is ALWAYS present on the compiled object — explicit from the descriptor,
306
+ // or derived for the simple (name-only DROP) kinds — so provenance always records
307
+ // how to remove it (removal never orphans; the C1 closure).
308
+ const q = parseQualified(d.name);
309
+ const drop = d.drop ?? `DROP ${d.objectKind.toUpperCase()} IF EXISTS ${renderName(q.schema, q.name)}`;
310
+ nodes.push({
311
+ identity,
312
+ deps: depIdentities(d.dependsOn),
313
+ build: make('sql', d.name, d.as, [], { objectKind: d.objectKind, drop }),
314
+ });
315
+ break;
316
+ }
317
+ }
318
+ }
319
+
320
+ // Triggers declared on models and on views — the deps come free (execute fn; the
321
+ // declaration-site view when it is itself derived). The function MUST be declared.
322
+ const triggerOwners: Array<{ schema: string; name: string; ownerIdentity: string | null; triggers: TriggerSpec[] }> = [
323
+ ...models.map((m) => ({ schema: m.schema, name: m.table, ownerIdentity: null, triggers: m.triggers })),
324
+ ...derived.filter((d): d is ViewDescriptor => d.kind === 'view').map((v) => {
325
+ const q = parseQualified(v.name);
326
+ return { schema: q.schema, name: q.name, ownerIdentity: `${q.schema}.${q.name}`, triggers: v.triggers };
327
+ }),
328
+ ];
329
+ for (const owner of triggerOwners) {
330
+ for (const t of owner.triggers) {
331
+ const fnIdentity = derivedIdentity(t.execute);
332
+ if (!identities.has(fnIdentity)) {
333
+ throw new Error(
334
+ `trigger '${t.name}' on ${owner.schema}.${owner.name}: its execute function '${t.execute.name}' is not in the derived set — declare it in defineModule({ derived: [...] }).`,
335
+ );
336
+ }
337
+ const { sql, table } = renderTrigger(owner.schema, owner.name, t);
338
+ const identity = `${owner.schema}.${owner.name}.${t.name}`;
339
+ nodes.push({
340
+ identity,
341
+ deps: [fnIdentity, ...(owner.ownerIdentity ? [owner.ownerIdentity] : [])],
342
+ build: make('trigger', `${owner.schema}.${t.name}`, sql, [], { identity, table }),
343
+ });
344
+ }
345
+ }
346
+
347
+ // Kahn's algorithm with a sorted ready-set: deterministic under any declaration order,
348
+ // ties broken by identity — never by import order. A cycle is a compile error.
349
+ const byIdentity = new Map(nodes.map((n) => [n.identity, n]));
350
+ const indegree = new Map(nodes.map((n) => [n.identity, 0]));
351
+ const dependents = new Map<string, string[]>();
352
+ for (const n of nodes) {
353
+ for (const dep of n.deps) {
354
+ indegree.set(n.identity, (indegree.get(n.identity) ?? 0) + 1);
355
+ dependents.set(dep, [...(dependents.get(dep) ?? []), n.identity]);
356
+ }
357
+ }
358
+ const ready = nodes.filter((n) => (indegree.get(n.identity) ?? 0) === 0).map((n) => n.identity).sort();
359
+ const ordered: SourceObject[] = [];
360
+ while (ready.length) {
361
+ const id = ready.shift()!;
362
+ ordered.push(byIdentity.get(id)!.build(ordered.length));
363
+ for (const dep of dependents.get(id) ?? []) {
364
+ const left = (indegree.get(dep) ?? 0) - 1;
365
+ indegree.set(dep, left);
366
+ if (left === 0) {
367
+ ready.push(dep);
368
+ ready.sort();
369
+ }
370
+ }
371
+ }
372
+ if (ordered.length !== nodes.length) {
373
+ const stuck = nodes.filter((n) => !ordered.some((o) => o.identity === n.identity)).map((n) => n.identity).sort();
374
+ throw new Error(`derived dependency cycle among: ${stuck.join(', ')} — a view cannot (transitively) depend on itself; PostgreSQL would refuse it too.`);
375
+ }
376
+ return ordered;
377
+ }