@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,366 @@
1
+ /**
2
+ * derived-render — db:pull's derived-layer renderer (B5, read-model-everywhere).
3
+ *
4
+ * Live catalog → descriptor source. Bodies are the catalog's canonical deparse — that is
5
+ * EXPECTED (a pulled body never matches an authored spelling byte-for-byte); the
6
+ * adoption path is the existing `--baseline`: pull, commit, baseline once, and the next
7
+ * reconcile is a no-op. Grants render to `abilities` where the standard shapes match;
8
+ * `dependsOn` renders from the live edge graph, so pulled declarations are born accurate
9
+ * (B4's dependency drift and B2's reachability gate get real inputs from day one);
10
+ * matview indexes ride Brick E's parser; standalone sequences become `defineSequence`;
11
+ * a function the v1 signature vocabulary can't say falls back to `defineSql` carrying
12
+ * the whole `pg_get_functiondef`. Anything inexpressible is a LOUD FIXME — the adoption
13
+ * checklist effect, on purpose — never a silent drop.
14
+ */
15
+
16
+ import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
17
+ import type { SequenceSchema } from './schema-introspect.js';
18
+ import { parseIndexDefinition } from './schema-introspect.js';
19
+ import { modelFileName } from './model-render.js';
20
+
21
+ export interface DerivedRenderResult {
22
+ /** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
23
+ block: string;
24
+ /** Rendered derived variable names, emission order — feed `defineModule({ derived })`. */
25
+ names: string[];
26
+ /** Rendered sequence variable names — feed `defineModule({ sequences })`. */
27
+ sequenceNames: string[];
28
+ /** Model-package symbols the block uses (defineView, can, sql, arg, index, …). */
29
+ imports: string[];
30
+ /** Qualified tables the block references through knownTables — what a standalone
31
+ * derived file must import (sorted). */
32
+ modelRefs: string[];
33
+ /** Everything skipped or flagged — mirrored as FIXME comments in the block. */
34
+ warnings: string[];
35
+ }
36
+
37
+ export interface DerivedRenderOptions {
38
+ /** Qualified table (`public.posts`) → the model variable name rendered in the barrel. */
39
+ knownTables: Map<string, string>;
40
+ /** Standalone sequences from the snapshot (serial-owned stay implicit). */
41
+ sequences?: SequenceSchema[];
42
+ }
43
+
44
+ const PLAIN_IDENT = /^[a-z_][a-z0-9_$]*$/;
45
+
46
+ /**
47
+ * Derived symbols are camelCase VERBATIM — never singularized (deliberately distinct
48
+ * from modelVarName's rule; a matview named `nfl_player_honors` is `nflPlayerHonors`).
49
+ * NAMING CONTRACT — a rename here breaks consumer splices; pinned by
50
+ * naming-contract.test.ts.
51
+ */
52
+ function toCamelCase(name: string): string {
53
+ return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
54
+ }
55
+
56
+ /** Escape a body for a template literal in generated source — it must always parse. */
57
+ function tsTemplate(body: string): string {
58
+ return body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
59
+ }
60
+
61
+ function tsString(s: string): string {
62
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
63
+ }
64
+
65
+ /** The relation grant shapes `abilities` can say. Null = inexpressible (caller FIXMEs). */
66
+ function relationAbilities(grants: Record<string, string[]>): string[] | null {
67
+ const roles = Object.keys(grants);
68
+ for (const role of roles) {
69
+ if (role.toUpperCase() === 'PUBLIC') return null;
70
+ if (grants[role].some((p) => p !== 'SELECT')) return null;
71
+ }
72
+ const set = new Set(roles);
73
+ const out: string[] = [];
74
+ if (set.has('anon') && set.has('authenticated')) {
75
+ out.push(`can('read')`);
76
+ set.delete('anon');
77
+ set.delete('authenticated');
78
+ }
79
+ for (const role of [...set].sort()) out.push(`can('read', { role: ${tsString(role)} })`);
80
+ return out;
81
+ }
82
+
83
+ /** VOLATILITY letters → the descriptor words ('v' is the default, never spelled). */
84
+ const VOLATILITY: Record<string, string> = { i: 'immutable', s: 'stable' };
85
+
86
+ interface ParsedArg {
87
+ name: string;
88
+ type: string;
89
+ default?: string;
90
+ }
91
+
92
+ /** Parse `pg_get_function_arguments` output. Null = beyond the v1 vocabulary. */
93
+ function parseArgs(args: string): ParsedArg[] | null {
94
+ const trimmed = args.trim();
95
+ if (!trimmed) return [];
96
+ const out: ParsedArg[] = [];
97
+ let depth = 0;
98
+ let start = 0;
99
+ const entries: string[] = [];
100
+ for (let i = 0; i < trimmed.length; i++) {
101
+ if (trimmed[i] === '(') depth++;
102
+ else if (trimmed[i] === ')') depth--;
103
+ else if (trimmed[i] === ',' && depth === 0) {
104
+ entries.push(trimmed.slice(start, i));
105
+ start = i + 1;
106
+ }
107
+ }
108
+ entries.push(trimmed.slice(start));
109
+ for (const entry of entries.map((e) => e.trim())) {
110
+ if (/^(OUT|INOUT|VARIADIC)\b/i.test(entry)) return null;
111
+ const m = entry.match(/^([a-z_][a-z0-9_$]*)\s+(.+?)(?:\s+DEFAULT\s+(.+))?$/i);
112
+ if (!m || !m[2]) return null;
113
+ // A single token is an UNNAMED arg ('text') — the match would misread type as name.
114
+ if (!/\s/.test(entry.replace(/\s+DEFAULT\s+.+$/i, '')) ) return null;
115
+ out.push({ name: m[1], type: m[2], ...(m[3] ? { default: m[3] } : {}) });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** One matview index attachment → the Brick E builder chain, or null (FIXME). */
121
+ function renderIndexBuilder(indexdef: string): string | null {
122
+ const parsed = parseIndexDefinition(indexdef);
123
+ if (!parsed) return null;
124
+ const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(c) : `sql\`${tsTemplate(c)}\``);
125
+ let s = `index(${parsed.columns.map(entry).join(', ')})`;
126
+ if (parsed.using) s += `.using(${tsString(parsed.using)})`;
127
+ if (parsed.unique) s += '.unique()';
128
+ if (parsed.include?.length) s += `.include(${parsed.include.map(tsString).join(', ')})`;
129
+ if (parsed.where) s += `.where(sql\`${tsTemplate(parsed.where)}\`)`;
130
+ return s;
131
+ }
132
+
133
+ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRenderOptions): DerivedRenderResult {
134
+ const warnings: string[] = [];
135
+ const lines: string[] = [];
136
+ const names: string[] = [];
137
+ const used = new Set<string>();
138
+ const need = (sym: string): void => { used.add(sym); };
139
+
140
+ // -- sequences (state layer — they precede the compute in the module) -------
141
+
142
+ const sequenceNames: string[] = [];
143
+ for (const seq of opts.sequences ?? []) {
144
+ const varName = toCamelCase(seq.name.replace(/\./g, '_'));
145
+ const props: string[] = [];
146
+ if (seq.as !== 'bigint') props.push(`as: ${tsString(seq.as)}`);
147
+ if (seq.start != null) props.push(`start: ${seq.start}`);
148
+ if (seq.increment != null) props.push(`increment: ${seq.increment}`);
149
+ lines.push(`export const ${varName} = defineSequence(${tsString(seq.name)}${props.length ? `, { ${props.join(', ')} }` : ''});`);
150
+ need('defineSequence');
151
+ sequenceNames.push(varName);
152
+ }
153
+ if (sequenceNames.length) lines.push('');
154
+
155
+ // -- emission order: dependencies first (Kahn over derived→derived edges) ---
156
+
157
+ const renderable = catalog.objects.filter((o) => o.kind !== 'trigger');
158
+ for (const t of catalog.objects.filter((o) => o.kind === 'trigger')) {
159
+ warnings.push(`${t.identity}: live trigger not rendered — declare it on its model with trigger() (v1 renders relations, functions, and sequences).`);
160
+ }
161
+ const byIdentity = new Map(renderable.map((o) => [o.identity, o]));
162
+ // Var names carry the schema for non-public objects — two schemas can share a bare name.
163
+ const varOf = new Map(renderable.map((o) =>
164
+ [o.identity, toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`)]));
165
+ // The name a descriptor DECLARES: bare for public, qualified otherwise. parseQualified
166
+ // round-trips it, so the compiled identity matches the live object it was pulled from —
167
+ // a bare name would compile to public.<name>, baseline would never join, and reconcile
168
+ // would plan duplicate creates in public (the red-team's pull-qualification finding).
169
+ const declaredName = (o: LiveObject): string => (o.schema === 'public' ? o.name : o.identity);
170
+
171
+ const indegree = new Map<string, number>();
172
+ const dependents = new Map<string, string[]>();
173
+ for (const o of renderable) indegree.set(o.identity, 0);
174
+ for (const e of catalog.edges) {
175
+ if (!byIdentity.has(e.dependent) || !byIdentity.has(e.referenced)) continue;
176
+ indegree.set(e.dependent, (indegree.get(e.dependent) ?? 0) + 1);
177
+ dependents.set(e.referenced, [...(dependents.get(e.referenced) ?? []), e.dependent]);
178
+ }
179
+ const ready = renderable.filter((o) => indegree.get(o.identity) === 0).map((o) => o.identity).sort();
180
+ const ordered: LiveObject[] = [];
181
+ while (ready.length) {
182
+ const id = ready.shift()!;
183
+ ordered.push(byIdentity.get(id)!);
184
+ for (const dep of dependents.get(id) ?? []) {
185
+ const left = (indegree.get(dep) ?? 0) - 1;
186
+ indegree.set(dep, left);
187
+ if (left === 0) {
188
+ ready.push(dep);
189
+ ready.sort();
190
+ }
191
+ }
192
+ }
193
+ // A cycle is impossible in a live catalog (PostgreSQL refuses it); anything left over
194
+ // would mean edge noise — emit it anyway, identity-sorted, rather than dropping it.
195
+ for (const o of renderable) if (!ordered.includes(o)) ordered.push(o);
196
+
197
+ // -- per-object rendering ----------------------------------------------------
198
+
199
+ const modelRefs = new Set<string>();
200
+ const depsFor = (identity: string): { refs: string[]; fixmes: string[] } => {
201
+ const refs: string[] = [];
202
+ const fixmes: string[] = [];
203
+ const seen = new Set<string>();
204
+ for (const e of catalog.edges) {
205
+ if (e.dependent !== identity || seen.has(e.referenced)) continue;
206
+ seen.add(e.referenced);
207
+ const model = opts.knownTables.get(e.referenced);
208
+ const derived = varOf.get(e.referenced);
209
+ if (model) {
210
+ refs.push(model);
211
+ modelRefs.add(e.referenced);
212
+ } else if (derived) refs.push(derived);
213
+ else fixmes.push(`// FIXME: dependsOn ${e.referenced} — not in the pulled set; declare it by hand.`);
214
+ }
215
+ return { refs: refs.sort(), fixmes };
216
+ };
217
+
218
+ for (const o of ordered) {
219
+ const varName = varOf.get(o.identity)!;
220
+ const { refs, fixmes } = depsFor(o.identity);
221
+
222
+ if (o.kind === 'view' || o.kind === 'materialized view') {
223
+ const abilities = relationAbilities(o.grants ?? {});
224
+ if (abilities === null) {
225
+ const grantText = Object.entries(o.grants ?? {}).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
226
+ warnings.push(`${o.identity}: live grants (${grantText}) are not expressible as relation abilities (read-only, role-shaped) — object skipped; migrate it by hand.`);
227
+ lines.push(`// FIXME: ${o.identity} skipped — live grants (${grantText}) are not expressible as abilities (views are read surfaces).`, '');
228
+ continue;
229
+ }
230
+ const props: string[] = [];
231
+ if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
232
+ if (abilities.length === 0) props.push('private: true,');
233
+ else {
234
+ props.push(`abilities: [${abilities.join(', ')}],`);
235
+ need('can');
236
+ }
237
+ if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
238
+ if (o.kind === 'materialized view') {
239
+ const builders = o.indexes.map((def) => {
240
+ const b = renderIndexBuilder(def);
241
+ if (b === null) warnings.push(`${o.identity}: index not renderable: ${def}`);
242
+ return b;
243
+ }).filter((b): b is string => b !== null);
244
+ if (builders.length) {
245
+ props.push(`indexes: [${builders.join(', ')}],`);
246
+ need('index');
247
+ }
248
+ if (o.populated === false) props.push(`populate: 'deferred',`);
249
+ }
250
+ if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
251
+ props.push(`as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`);
252
+ need(o.kind === 'view' ? 'defineView' : 'defineMaterializedView');
253
+ need('sql');
254
+ lines.push(
255
+ ...fixmes,
256
+ `export const ${varName} = ${o.kind === 'view' ? 'defineView' : 'defineMaterializedView'}(${tsString(declaredName(o))}, {`,
257
+ ...props.map((p) => ` ${p}`),
258
+ '});',
259
+ '',
260
+ );
261
+ names.push(varName);
262
+ continue;
263
+ }
264
+
265
+ // Functions.
266
+ const f = o.fn;
267
+ const grants = o.grants ?? {};
268
+ const roleGrants = Object.keys(grants).filter((r) => r.toUpperCase() !== 'PUBLIC').sort();
269
+ const publicExec = Object.keys(grants).some((r) => r.toUpperCase() === 'PUBLIC');
270
+ const args = f && (f.language === 'sql' || f.language === 'plpgsql') ? parseArgs(f.args) : null;
271
+
272
+ if (!f || args === null || (f.language !== 'sql' && f.language !== 'plpgsql')) {
273
+ warnings.push(`${o.identity}: signature beyond the v1 vocabulary (${f ? `language ${f.language}, args '${f.args}'` : 'no structured fields'}) — rendered as defineSql.`);
274
+ lines.push(
275
+ `// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
276
+ `export const ${varName} = defineSql(${tsString(declaredName(o))}, {`,
277
+ ` kind: 'function',`,
278
+ ` drop: sql\`DROP FUNCTION IF EXISTS ${o.identity.startsWith('public.') ? o.name : o.identity}\`,`,
279
+ ` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
280
+ '});',
281
+ '',
282
+ );
283
+ need('defineSql');
284
+ need('sql');
285
+ names.push(varName);
286
+ continue;
287
+ }
288
+
289
+ const props: string[] = [];
290
+ if (args.length) {
291
+ const rendered = args.map((a) =>
292
+ `arg(${tsString(a.name)}, ${tsString(a.type)}${a.default ? `, { default: sql\`${tsTemplate(a.default)}\` }` : ''})`);
293
+ props.push(`args: [${rendered.join(', ')}],`);
294
+ need('arg');
295
+ }
296
+ props.push(`returns: ${tsString(f.returns)},`);
297
+ props.push(`language: ${tsString(f.language)},`);
298
+ if (VOLATILITY[f.volatility]) props.push(`volatility: ${tsString(VOLATILITY[f.volatility])},`);
299
+ if (f.secdef) {
300
+ props.push(`security: 'definer',`);
301
+ const path = (f.searchPath ?? 'pg_catalog').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
302
+ props.push(`searchPath: [${path.map(tsString).join(', ')}],`);
303
+ }
304
+ if (roleGrants.length) {
305
+ props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
306
+ need('can');
307
+ }
308
+ if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
309
+ if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
310
+ props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
311
+ need('defineFunction');
312
+ need('sql');
313
+ lines.push(
314
+ ...fixmes,
315
+ ...(publicExec
316
+ ? [`// FIXME: ${o.identity} — live grants EXECUTE to PUBLIC; descriptors ALWAYS revoke PUBLIC. Declare the intended callers (adopting this descriptor applies the revoke).`]
317
+ : []),
318
+ `export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
319
+ ...props.map((p) => ` ${p}`),
320
+ '});',
321
+ '',
322
+ );
323
+ names.push(varName);
324
+ }
325
+
326
+ return {
327
+ block: lines.join('\n').trimEnd(),
328
+ names,
329
+ sequenceNames,
330
+ imports: [...used].sort(),
331
+ modelRefs: [...modelRefs].sort(),
332
+ warnings,
333
+ };
334
+ }
335
+
336
+ /**
337
+ * The standalone derived module — `db:pull --derived-out <file>` (the brownfield
338
+ * splice, first-class). A consumer with an existing hand-maintained barrel wants the
339
+ * derived layer as its OWN file, not spliced out of a rendered monolith by hand: this
340
+ * renders exactly the shape their module wires — model-package imports, model-var
341
+ * imports from the contract kebab files (naming-contract.test.ts), the descriptors,
342
+ * and the `sequences`/`derived` arrays for `defineModule({ models, sequences, derived })`.
343
+ */
344
+ export function renderDerivedFile(result: DerivedRenderResult, knownTables: Map<string, string>): string {
345
+ const lines: string[] = [
346
+ '// Derived layer (views, materialized views, functions, sequences) — rendered by `everystack db:pull`.',
347
+ '// Bodies are the catalog\'s canonical deparse (they never match an authored spelling byte-for-byte);',
348
+ '// commit, wire it on your module — defineModule({ models, sequences, derived }) — then adopt with',
349
+ '// `db:reconcile --apply --baseline` (or --rebaseline over db/sql-era provenance). docs/derived-objects.md.',
350
+ ];
351
+ if (result.imports.length) {
352
+ lines.push(`import { ${result.imports.join(', ')} } from '@everystack/model';`);
353
+ }
354
+ const refs = result.modelRefs
355
+ .map((table) => ({ varName: knownTables.get(table)!, path: `./${modelFileName(table).replace(/\.ts$/, '')}` }))
356
+ .sort((a, b) => a.path.localeCompare(b.path));
357
+ for (const ref of refs) lines.push(`import { ${ref.varName} } from '${ref.path}';`);
358
+ lines.push('', result.block, '');
359
+ if (result.sequenceNames.length) {
360
+ lines.push(`export const sequences = [\n${result.sequenceNames.map((n) => ` ${n},`).join('\n')}\n];`);
361
+ }
362
+ if (result.names.length) {
363
+ lines.push(`export const derived = [\n${result.names.map((n) => ` ${n},`).join('\n')}\n];`);
364
+ }
365
+ return lines.join('\n') + '\n';
366
+ }
@@ -1,26 +1,32 @@
1
1
  /**
2
2
  * derived-source — the source side of the derived-object reconciler.
3
3
  *
4
- * Parses an app's ordered db/sql files into structured derived objects
5
- * (functions, views, materialized views), attaching the statements that ride
6
- * with an object (its indexes, comments, grants) and hashing each object's
7
- * normalized content. The reconciler diffs these hashes against the live
8
- * catalog's provenance to decide what to create, replace, or drop.
4
+ * The `SourceObject` shape every derived declaration compiles to (identity,
5
+ * content hash, attachments), plus the SQL infrastructure shared across the
6
+ * reconciler: the Postgres-aware lexer, statement splitting, hash
7
+ * normalization, and qualified-name parsing.
8
+ *
9
+ * History note: this file once parsed an app's raw `db/sql` files into these
10
+ * shapes. That parser is retired (B7, read-model-everywhere) — descriptors
11
+ * (defineView / defineMaterializedView / defineFunction / defineSql, compiled
12
+ * by derived-compile.ts) are the single home. The content-hash formula is
13
+ * unchanged, so provenance recorded from db/sql-era objects still matches the
14
+ * descriptor-compiled hash of the same SQL — the migration reconciles as a
15
+ * no-op, never a rebuild.
9
16
  *
10
17
  * Everything here is pure and database-free. The lexer respects Postgres
11
18
  * string syntax — single quotes with '' doubling, E-strings with backslash
12
19
  * escapes, double-quoted identifiers, tagged dollar quoting, and nested block
13
20
  * comments — because a matview body is exactly where naive splitting dies.
14
- *
15
- * Identity note: functions are identified by schema-qualified NAME, not full
16
- * signature. Overloads are rare in app SQL layers; when they appear we warn
17
- * rather than mis-model them (the honest limitation, revisit if a consumer
18
- * actually needs overloads).
19
21
  */
20
22
 
21
23
  import { createHash } from 'node:crypto';
22
24
 
23
- export type DerivedKind = 'function' | 'view' | 'materialized view';
25
+ /**
26
+ * `'trigger'` and `'sql'` carry the extra fields the reconciler edges need (a trigger's
27
+ * table for `DROP TRIGGER … ON`, a defineSql object's explicit drop).
28
+ */
29
+ export type DerivedKind = 'function' | 'view' | 'materialized view' | 'trigger' | 'sql';
24
30
 
25
31
  /** A statement that belongs to an object: its index, comment, or grant. */
26
32
  export interface Attachment {
@@ -33,7 +39,8 @@ export interface SourceObject {
33
39
  /** Schema name, defaulted to `public` when unqualified. */
34
40
  schema: string;
35
41
  name: string;
36
- /** `schema.name` — the reconciler's join key against the live catalog. */
42
+ /** `schema.name` — the reconciler's join key against the live catalog.
43
+ * (Triggers: `schema.table.name` — compound identity, joined against pg_trigger.) */
37
44
  identity: string;
38
45
  /** The CREATE statement as authored (no trailing semicolon). */
39
46
  sql: string;
@@ -41,17 +48,46 @@ export interface SourceObject {
41
48
  attachments: Attachment[];
42
49
  /** sha256 of the normalized CREATE + attachments — comment/whitespace edits do not change it. */
43
50
  hash: string;
44
- /** Source file this object came from. */
51
+ /** Source file this object came from (descriptors: the declared-models marker). */
45
52
  file: string;
46
53
  /** Position in the concatenated source — a valid dependency order by convention. */
47
54
  seq: number;
55
+ /** kind 'sql' only: the declared PostgreSQL object kind (`'aggregate'`, …). */
56
+ objectKind?: string;
57
+ /** kind 'sql' only: the explicit DROP for kinds whose drop isn't derivable from the name. */
58
+ drop?: string;
59
+ /** kind 'trigger' only: the (possibly schema-qualified) table/view the trigger rides. */
60
+ table?: string;
61
+ /**
62
+ * Views/matviews: every declared `dependsOn` ref as an identity (models AND derived),
63
+ * sorted. B4's dependency drift verifies it against the live catalog's actual edges —
64
+ * "declared, then verified". Absent on kinds whose live edges pg_depend can't see.
65
+ */
66
+ declaredDeps?: string[];
67
+ }
68
+
69
+ /** The provenance marker descriptor-compiled objects carry in `file` — the compiler
70
+ * ALWAYS declares authz (a private relation is declared-empty, not undeclared), so
71
+ * B4's grant drift checks every object. */
72
+ export const DECLARED_SOURCE_FILE = 'db/models (declared)';
73
+
74
+ /** The one content-hash formula. Unchanged since the db/sql era on purpose: provenance
75
+ * recorded from parser-era objects still matches the descriptor-compiled hash of the
76
+ * same SQL, so the db/sql → descriptor migration reconciles as a no-op. */
77
+ export function hashSourceContent(createSql: string, attachments: Attachment[]): string {
78
+ const content = [normalizeSql(createSql), ...attachments.map((a) => normalizeSql(a.sql))].join('\n');
79
+ return createHash('sha256').update(content).digest('hex');
48
80
  }
49
81
 
82
+ /** A raw file the generic dir readers return (db/backfills, seed lanes). */
50
83
  export interface SourceFile {
51
84
  file: string;
52
85
  sql: string;
53
86
  }
54
87
 
88
+ /** The reconciler's source-stream input: the declared objects plus any compile-time
89
+ * warnings to surface with the plan. (Named for its parser-era producer; the only
90
+ * producer now is the descriptor compiler.) */
55
91
  export interface ParsedSources {
56
92
  objects: SourceObject[];
57
93
  /** Statements the reconciler does not manage — surfaced, never silently dropped. */
@@ -242,7 +278,7 @@ export function normalizeSql(sql: string): string {
242
278
  // ---------------------------------------------------------------------------
243
279
 
244
280
  /** Strip quotes from an identifier and split an optionally schema-qualified name. */
245
- function parseQualified(raw: string): { schema: string; name: string } {
281
+ export function parseQualified(raw: string): { schema: string; name: string } {
246
282
  const parts: string[] = [];
247
283
  let current = '';
248
284
  let quoted = false;
@@ -261,114 +297,6 @@ function parseQualified(raw: string): { schema: string; name: string } {
261
297
  return { schema: 'public', name: parts[0] };
262
298
  }
263
299
 
264
- const FUNCTION_RE = /^CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+((?:"[^"]*"|[^\s(])+)\s*\(/i;
265
- const VIEW_RE = /^CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+((?:"[^"]*"|[^\s(])+)/i;
266
- const MATVIEW_RE = /^CREATE\s+MATERIALIZED\s+VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?((?:"[^"]*"|[^\s(])+)/i;
267
- const INDEX_RE = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:(?:"[^"]*"|[^\s(])+\s+)?ON\s+(?:ONLY\s+)?((?:"[^"]*"|[^\s(])+)/i;
268
- const COMMENT_RE = /^COMMENT\s+ON\s+(MATERIALIZED\s+VIEW|VIEW|FUNCTION|COLUMN)\s+((?:"[^"]*"|[^\s(,])+)/i;
269
- const GRANT_RE = /^GRANT\s+[\s\S]*?\bON\s+(?:TABLE\s+)?((?:"[^"]*"|[^\s(,])+)/i;
270
-
271
- /**
272
- * The first non-comment text of a statement, whitespace collapsed but token
273
- * boundaries preserved (unlike the aggressive hash form — `VIEW "MyView"` must
274
- * keep its space for the classification regexes).
275
- */
276
- function statementHead(statement: string): string {
277
- let out = '';
278
- lex(statement, (ch, state) => {
279
- if (state === 'line-comment' || state === 'block-comment') return;
280
- if (state === 'normal' && /\s/.test(ch)) {
281
- if (out.length > 0 && !out.endsWith(' ')) out += ' ';
282
- return;
283
- }
284
- out += ch;
285
- });
286
- return out.trim().slice(0, 400);
287
- }
288
-
289
- /**
290
- * Parse ordered source files into derived objects. Attachments (indexes,
291
- * comments, grants) join the object they target; statements that are neither a
292
- * derived object nor an attachment become warnings — the reconciler manages
293
- * the derived layer only, and it says so out loud.
294
- */
295
- export function parseSqlSources(files: SourceFile[]): ParsedSources {
296
- const objects: SourceObject[] = [];
297
- const warnings: string[] = [];
298
- const byIdentity = new Map<string, SourceObject>();
299
- let seq = 0;
300
-
301
- const identityOf = (raw: string): { schema: string; name: string; identity: string } => {
302
- const { schema, name } = parseQualified(raw);
303
- return { schema, name, identity: `${schema}.${name}` };
304
- };
305
-
306
- for (const { file, sql } of files) {
307
- for (const statement of splitSqlStatements(sql)) {
308
- const head = statementHead(statement);
309
-
310
- const asObject = (kind: DerivedKind, rawName: string): void => {
311
- const { schema, name, identity } = identityOf(rawName);
312
- if (byIdentity.has(identity)) {
313
- warnings.push(
314
- `${file}: duplicate definition for ${identity} — function overloads are not modeled; ` +
315
- `the reconciler tracks one definition per name (last one wins).`,
316
- );
317
- }
318
- const obj: SourceObject = {
319
- kind, schema, name, identity,
320
- sql: statement, attachments: [], hash: '', file, seq: seq++,
321
- };
322
- byIdentity.set(identity, obj);
323
- objects.push(obj);
324
- };
325
-
326
- const attach = (kind: Attachment['kind'], rawTarget: string, stripColumn = false): boolean => {
327
- let { identity } = identityOf(rawTarget);
328
- if (stripColumn) {
329
- // COMMENT ON COLUMN schema.obj.col — the owning object is one segment up.
330
- const parts = identity.split('.');
331
- identity = parts.length > 2 ? parts.slice(0, -1).join('.') : `public.${parts[0]}`;
332
- }
333
- const target = byIdentity.get(identity);
334
- if (!target) return false;
335
- target.attachments.push({ kind, sql: statement });
336
- return true;
337
- };
338
-
339
- let m: RegExpExecArray | null;
340
- if ((m = FUNCTION_RE.exec(head))) { asObject('function', m[1]); continue; }
341
- if ((m = MATVIEW_RE.exec(head))) { asObject('materialized view', m[1]); continue; }
342
- if ((m = VIEW_RE.exec(head))) { asObject('view', m[1]); continue; }
343
-
344
- if ((m = INDEX_RE.exec(head))) {
345
- if (!attach('index', m[1])) {
346
- warnings.push(`${file}: CREATE INDEX targets ${m[1]}, which is not a derived object in these sources — not managed here (base-table indexes belong to the model layer).`);
347
- }
348
- continue;
349
- }
350
- if ((m = COMMENT_RE.exec(head))) {
351
- const isColumn = /^COLUMN$/i.test(m[1].replace(/\s+/g, ' ').split(' ')[0]) || /^COLUMN/i.test(m[1]);
352
- if (!attach('comment', m[2], isColumn)) {
353
- warnings.push(`${file}: COMMENT ON targets ${m[2]}, which is not a derived object in these sources.`);
354
- }
355
- continue;
356
- }
357
- if ((m = GRANT_RE.exec(head))) {
358
- if (!attach('grant', m[1])) {
359
- warnings.push(`${file}: GRANT targets ${m[1]}, which is not a derived object in these sources.`);
360
- }
361
- continue;
362
- }
363
-
364
- warnings.push(`${file}: unmanaged statement (${head.slice(0, 60)}…) — the reconciler manages functions, views, and materialized views; move this to the appropriate layer.`);
365
- }
366
- }
367
-
368
- for (const obj of objects) {
369
- const content = [normalizeSql(obj.sql), ...obj.attachments.map((a) => normalizeSql(a.sql))].join('\n');
370
- obj.hash = createHash('sha256').update(content).digest('hex');
371
- }
372
-
373
- return { objects, warnings };
374
- }
300
+ // (The db/sql statement classifier and parseSqlSources lived here until B7 —
301
+ // descriptors are the single home; declared-derived.ts's retiredSqlDir() is the
302
+ // loud tombstone for checkouts still carrying the directory.)
@@ -29,7 +29,7 @@ import type { ModelDescriptor } from '@everystack/model';
29
29
  import type { SchemaSnapshot } from './schema-introspect.js';
30
30
  import type { AuthzContract } from './authz-contract.js';
31
31
  import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
32
- import { classifyGeneratedStatements, classifyDestructive } from './state-apply.js';
32
+ import { classifyGeneratedStatements, classifyDestructive, renderStatementHistogram } from './state-apply.js';
33
33
  import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
34
34
  import { compileDeclaredState } from './declared-diff.js';
35
35
  import { compileTableRenames } from './schema-compile.js';
@@ -202,6 +202,9 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
202
202
  return lines;
203
203
  }
204
204
  lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
205
+ // The shape first — a four-digit edge is reviewable as a histogram, not a wall.
206
+ const histogram = renderStatementHistogram(classifyGeneratedStatements(plan.statements).executable);
207
+ if (histogram) lines.push(`shape: ${histogram}`);
205
208
  if (plan.destructive > 0) {
206
209
  const { drops, narrowings } = classifyDestructive(classifyGeneratedStatements(plan.statements).executable);
207
210
  lines.push(
@@ -188,10 +188,23 @@ export async function verifyDescent(
188
188
  for (const candidate of candidates) {
189
189
  const dest = path.join(materializeRoot, candidate.tree);
190
190
  let predicted: string;
191
- try {
191
+ // A swallowed error here skips the candidate — and if it was the DECLARING tree, the
192
+ // verdict silently falls through to 'drift'. Real compile failures are deterministic;
193
+ // a transient materialize/import failure (fd pressure, module-loader contention under
194
+ // a parallel test run or a busy CI box) is not — so a failed candidate gets exactly
195
+ // one retry, from a clean materialization, before it is recorded as a compile failure.
196
+ const attempt = async (): Promise<string> => {
192
197
  if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
193
198
  const models = await loader(path.join(dest, barrel));
194
- predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema });
199
+ return predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema });
200
+ };
201
+ try {
202
+ try {
203
+ predicted = await attempt();
204
+ } catch {
205
+ fs.rmSync(dest, { recursive: true, force: true });
206
+ predicted = await attempt();
207
+ }
195
208
  } catch (err: any) {
196
209
  compileFailures.push(`${short(candidate.tree)} (at ${short(candidate.commits[0])}): ${err.message}`);
197
210
  continue;