@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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/.pdr-tmp-20277-1783706384842.ts +59 -0
- package/src/.roundtrip-tmp-20275-1783706385459.ts +121 -0
- package/src/cli/authz-compile.ts +5 -2
- package/src/cli/aws.ts +12 -3
- package/src/cli/backfill.ts +1 -1
- package/src/cli/commands/db-authz.ts +3 -17
- package/src/cli/commands/db-branch.ts +28 -4
- package/src/cli/commands/db-check.ts +84 -33
- package/src/cli/commands/db-fingerprint.ts +7 -2
- package/src/cli/commands/db-generate.ts +102 -34
- package/src/cli/commands/db-pull.ts +82 -6
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/commands/db.ts +7 -6
- package/src/cli/commands/update.ts +2 -1
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- package/src/cli/declared-derived.ts +173 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +377 -0
- package/src/cli/derived-grants.ts +96 -0
- package/src/cli/derived-introspect.ts +258 -21
- package/src/cli/derived-lint.ts +261 -0
- package/src/cli/derived-plan.ts +176 -10
- package/src/cli/derived-render.ts +366 -0
- package/src/cli/derived-source.ts +53 -125
- package/src/cli/edge-plan.ts +4 -1
- package/src/cli/git-descent.ts +15 -2
- package/src/cli/index.ts +6 -6
- package/src/cli/migration-compile.ts +38 -7
- package/src/cli/migration-generate.ts +43 -4
- package/src/cli/model-render.ts +125 -16
- package/src/cli/models-path.ts +1 -1
- package/src/cli/ops-advice.ts +66 -0
- package/src/cli/pipeline-path.ts +1 -1
- package/src/cli/schema-compile.ts +41 -10
- package/src/cli/schema-diff.ts +123 -17
- package/src/cli/schema-fingerprint.ts +28 -18
- package/src/cli/schema-introspect.ts +175 -8
- package/src/cli/schema-source.ts +75 -6
- 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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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.)
|
package/src/cli/edge-plan.ts
CHANGED
|
@@ -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(
|
package/src/cli/git-descent.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|