@everystack/cli 0.3.31 → 0.4.0
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/cli/authz-compile.ts +5 -2
- package/src/cli/backfill.ts +1 -1
- 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 +71 -20
- package/src/cli/commands/db-pull.ts +37 -5
- package/src/cli/commands/db-reconcile.ts +132 -41
- package/src/cli/commands/db-sync.ts +54 -19
- package/src/cli/db-build.ts +9 -3
- package/src/cli/db-source.ts +5 -1
- package/src/cli/declared-derived.ts +136 -0
- package/src/cli/derived-apply.ts +40 -6
- package/src/cli/derived-compile.ts +327 -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 +320 -0
- package/src/cli/derived-source.ts +53 -125
- 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 +106 -13
- package/src/cli/models-path.ts +1 -1
- 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 +4 -1
package/src/cli/derived-plan.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* derived-plan — the reconciler's pure decision core.
|
|
3
3
|
*
|
|
4
|
-
* Joins the three inputs on identity — source objects (
|
|
4
|
+
* Joins the three inputs on identity — source objects (descriptor-compiled), the live
|
|
5
5
|
* catalog, and the provenance claims — and decides, per object: skip, create,
|
|
6
|
-
* replace, refresh, drop, baseline, or prune. Hand-edited live objects are
|
|
6
|
+
* replace, refresh, drop, baseline, rebaseline, or prune. Hand-edited live objects are
|
|
7
7
|
* DRIFT: reported, never silently overwritten (`overwriteDrift` makes the
|
|
8
8
|
* overwrite explicit, and the drift stays in the report so the operator sees
|
|
9
9
|
* what they overwrote). Never-reconciled matches are `needsBaseline`: the
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
* drift report, not a silent skip.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import type
|
|
27
|
+
import { type ParsedSources, type SourceObject, type DerivedKind } from './derived-source.js';
|
|
28
28
|
import type { DerivedCatalog, LiveObject } from './derived-introspect.js';
|
|
29
|
+
import { triggerDropSql } from './derived-apply.js';
|
|
30
|
+
import { parseGrantAttachments, diffObjectGrants } from './derived-grants.js';
|
|
29
31
|
|
|
30
|
-
export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'prune';
|
|
32
|
+
export type ReconcileVerb = 'create' | 'replace' | 'refresh' | 'drop' | 'baseline' | 'rebaseline' | 'prune';
|
|
31
33
|
|
|
32
34
|
export interface ReconcileAction {
|
|
33
35
|
action: ReconcileVerb;
|
|
@@ -37,6 +39,9 @@ export interface ReconcileAction {
|
|
|
37
39
|
/** Rebuild-cost signal from the live catalog (matviews). */
|
|
38
40
|
bytes?: number;
|
|
39
41
|
rows?: number;
|
|
42
|
+
/** Drops for the structural kinds — a trigger's `DROP TRIGGER … ON table`, a 'sql'
|
|
43
|
+
* object's recorded/declared drop. Legacy kinds keep the keyword rendering. */
|
|
44
|
+
dropSql?: string;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export interface DriftFinding {
|
|
@@ -44,6 +49,21 @@ export interface DriftFinding {
|
|
|
44
49
|
detail: string;
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
/** Grant drift on an up-to-date object — the idempotent REVOKE/GRANT delta that converges it. */
|
|
53
|
+
export interface RegrantFinding {
|
|
54
|
+
identity: string;
|
|
55
|
+
statements: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Declared `dependsOn` vs the live catalog's actual edges (B4: declared, then verified). */
|
|
59
|
+
export interface DependencyDriftFinding {
|
|
60
|
+
identity: string;
|
|
61
|
+
/** Live edges the source never declared — the reachability gate walked past these. */
|
|
62
|
+
missing: string[];
|
|
63
|
+
/** Declared refs with no live edge — stale declarations. */
|
|
64
|
+
stale: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
export interface BlockedFinding {
|
|
48
68
|
identity: string;
|
|
49
69
|
reason: string;
|
|
@@ -66,6 +86,15 @@ export interface ReconcilePlan {
|
|
|
66
86
|
warnings: string[];
|
|
67
87
|
/** Sum of live bytes over rebuilt + refreshed matviews — the headline cost estimate. */
|
|
68
88
|
totalRebuildBytes: number;
|
|
89
|
+
/** Provenance identity migrations — a table rename carried its triggers (same object,
|
|
90
|
+
* new identity); the bookkeeping follows instead of drop+create-ing a live trigger. */
|
|
91
|
+
migrations: Array<{ from: string; to: string }>;
|
|
92
|
+
/** Grant drift on up-to-date objects: a hand-run GRANT/REVOKE touches no content hash,
|
|
93
|
+
* so this is the only place it surfaces. Applied without ceremony — declared authz is
|
|
94
|
+
* authoritative, and REVOKE/GRANT loses nothing but the drift itself. */
|
|
95
|
+
regrants: RegrantFinding[];
|
|
96
|
+
/** Declared-vs-actual dependency drift — check-fails (fix the declaration), never blocks apply. */
|
|
97
|
+
dependencyDrift: DependencyDriftFinding[];
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
export interface ReconcileOptions {
|
|
@@ -75,9 +104,28 @@ export interface ReconcileOptions {
|
|
|
75
104
|
overwriteDrift?: boolean;
|
|
76
105
|
/**
|
|
77
106
|
* Rebuild never-reconciled source↔live matches from source (drop+create) instead of trusting
|
|
78
|
-
* them via baseline — the guarantee that live ==
|
|
107
|
+
* them via baseline — the guarantee that live == source when first contact can't verify it.
|
|
79
108
|
*/
|
|
80
109
|
rebuild?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Re-record provenance for objects whose SOURCE hash changed while the LIVE object is
|
|
112
|
+
* verifiably untouched (live defHash == provenance defHash) — bookkeeping instead of a
|
|
113
|
+
* rebuild. The migration verb for a source re-render that produces the same object:
|
|
114
|
+
* the db/sql-era → descriptor move re-renders every object in the compiler's dialect
|
|
115
|
+
* (grant attachments on functions, pinned dollar-tags, plain CREATE), so the source
|
|
116
|
+
* hash changes even when the SQL is semantically identical. The live side is VERIFIED
|
|
117
|
+
* (defHash proves the object is exactly what the reconciler last applied); the source
|
|
118
|
+
* side is TRUSTED (a descriptor that actually differs will never deploy — its change
|
|
119
|
+
* is recorded as applied). Catalog-invisible 'sql'-kind objects are excluded: with no
|
|
120
|
+
* defHash to verify, they keep their recorded-drop rebuild.
|
|
121
|
+
*/
|
|
122
|
+
rebaseline?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Pending table renames (new qualified table → old qualified table, from the models'
|
|
125
|
+
* `renamedFrom` markers). PostgreSQL carries triggers through ALTER TABLE RENAME, so
|
|
126
|
+
* their provenance identity migrates with them.
|
|
127
|
+
*/
|
|
128
|
+
renamedTables?: Record<string, string>;
|
|
81
129
|
}
|
|
82
130
|
|
|
83
131
|
const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
|
|
@@ -91,6 +139,23 @@ export function planReconcile(
|
|
|
91
139
|
const liveById = new Map(live.objects.map((o) => [o.identity, o]));
|
|
92
140
|
const provById = new Map(live.provenance.map((p) => [p.identity, p]));
|
|
93
141
|
|
|
142
|
+
// Rename pre-pass: a pending table rename (renamedFrom) carried its triggers along in
|
|
143
|
+
// PostgreSQL, so a provenance row recorded under the OLD table is the SAME object as
|
|
144
|
+
// the source trigger under the NEW one — the bookkeeping migrates; nothing drops.
|
|
145
|
+
const migrations: Array<{ from: string; to: string }> = [];
|
|
146
|
+
for (const [newTable, oldTable] of Object.entries(options.renamedTables ?? {})) {
|
|
147
|
+
for (const prov of [...provById.values()]) {
|
|
148
|
+
if (prov.kind !== 'trigger' || !prov.identity.startsWith(`${oldTable}.`)) continue;
|
|
149
|
+
const to = `${newTable}.${prov.identity.slice(oldTable.length + 1)}`;
|
|
150
|
+
if (!srcById.has(to) || provById.has(to)) continue;
|
|
151
|
+
provById.delete(prov.identity);
|
|
152
|
+
provById.set(to, { ...prov, identity: to });
|
|
153
|
+
migrations.push({ from: prov.identity, to });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Every provenance consumer below reads the POST-MIGRATION view.
|
|
157
|
+
const provRows = [...provById.values()];
|
|
158
|
+
|
|
94
159
|
const skipped: string[] = [];
|
|
95
160
|
const drift: DriftFinding[] = [];
|
|
96
161
|
const needsBaseline: string[] = [];
|
|
@@ -106,16 +171,37 @@ export function planReconcile(
|
|
|
106
171
|
/** Managed objects the source removed. */
|
|
107
172
|
const drop = new Map<string, string>();
|
|
108
173
|
const baseline: string[] = [];
|
|
174
|
+
const rebaseline: string[] = [];
|
|
109
175
|
const prune: string[] = [];
|
|
110
176
|
|
|
111
177
|
// -------------------------------------------------------------------------
|
|
112
178
|
// The decision table.
|
|
113
179
|
// -------------------------------------------------------------------------
|
|
114
180
|
|
|
181
|
+
/** identity → { statement, reason } for the provenance-tracked ('sql') kinds — these
|
|
182
|
+
* never pass the live-existence filter, so they assemble their own drop actions. */
|
|
183
|
+
const sqlDrops = new Map<string, { dropSql: string; reason: string }>();
|
|
184
|
+
const extraWarnings: string[] = [];
|
|
185
|
+
|
|
115
186
|
for (const src of source.objects) {
|
|
116
187
|
const liveObj = liveById.get(src.identity);
|
|
117
188
|
const prov = provById.get(src.identity);
|
|
118
189
|
|
|
190
|
+
// 'sql' objects (aggregates, domains, …) never join the live catalog — PROVENANCE is
|
|
191
|
+
// the live proxy. The documented contract of the escape hatch: provenance-tracked,
|
|
192
|
+
// no live drift detection; a change rebuilds through the recorded drop.
|
|
193
|
+
if (src.kind === 'sql') {
|
|
194
|
+
if (!prov) {
|
|
195
|
+
create.set(src.identity, 'new in source (provenance-tracked kind — invisible to the catalog)');
|
|
196
|
+
} else if (src.hash !== prov.srcHash) {
|
|
197
|
+
sqlDrops.set(src.identity, { dropSql: prov.dropSql ?? src.drop ?? '', reason: 'source changed' });
|
|
198
|
+
create.set(src.identity, 'source changed (rebuilt through the recorded drop)');
|
|
199
|
+
} else {
|
|
200
|
+
skipped.push(src.identity);
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
119
205
|
if (!liveObj) {
|
|
120
206
|
create.set(src.identity, prov
|
|
121
207
|
? 'missing from the database (recorded as applied — dropped by hand?)'
|
|
@@ -155,7 +241,11 @@ export function planReconcile(
|
|
|
155
241
|
continue;
|
|
156
242
|
}
|
|
157
243
|
if (srcChanged) {
|
|
158
|
-
|
|
244
|
+
// The live object is verifiably untouched here (liveChanged was handled above), so
|
|
245
|
+
// under rebaseline a source re-render is bookkeeping: re-record the source hash,
|
|
246
|
+
// rebuild nothing, cascade nothing.
|
|
247
|
+
if (options.rebaseline) rebaseline.push(src.identity);
|
|
248
|
+
else if (isRelation(src.kind)) rebuild.set(src.identity, 'source changed');
|
|
159
249
|
else fnReplace.set(src.identity, 'source changed');
|
|
160
250
|
continue;
|
|
161
251
|
}
|
|
@@ -168,8 +258,68 @@ export function planReconcile(
|
|
|
168
258
|
else unmanaged.push(liveObj.identity);
|
|
169
259
|
}
|
|
170
260
|
|
|
171
|
-
for (const prov of
|
|
172
|
-
if (
|
|
261
|
+
for (const prov of provRows) {
|
|
262
|
+
if (srcById.has(prov.identity) || liveById.has(prov.identity)) continue;
|
|
263
|
+
// A removed 'sql' object is invisible to the catalog — pruning would silently orphan
|
|
264
|
+
// it in the database. The recorded drop_sql removes it (the C1 closure); only a
|
|
265
|
+
// legacy row with no recorded drop falls back to prune, and says so.
|
|
266
|
+
if (prov.kind === 'sql') {
|
|
267
|
+
if (prov.dropSql) {
|
|
268
|
+
sqlDrops.set(prov.identity, { dropSql: prov.dropSql, reason: 'removed from source (dropped via the recorded drop_sql)' });
|
|
269
|
+
} else {
|
|
270
|
+
prune.push(prov.identity);
|
|
271
|
+
extraWarnings.push(`${prov.identity}: a 'sql'-kind object recorded without drop_sql (legacy row) — bookkeeping pruned; drop the object by hand if it still exists.`);
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
prune.push(prov.identity);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// -------------------------------------------------------------------------
|
|
279
|
+
// B4 — live drift the content hashes can't see, over the UP-TO-DATE objects
|
|
280
|
+
// only: created/replaced objects re-run their attachments, drifted objects
|
|
281
|
+
// are owned by the drift refusal.
|
|
282
|
+
// -------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
const regrants: RegrantFinding[] = [];
|
|
285
|
+
const dependencyDrift: DependencyDriftFinding[] = [];
|
|
286
|
+
// Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
|
|
287
|
+
// and running the checks NOW means a migration converges (grants ride the same apply)
|
|
288
|
+
// instead of surfacing on the next plan.
|
|
289
|
+
const lensSet = new Set([...skipped, ...rebaseline]);
|
|
290
|
+
for (const src of source.objects) {
|
|
291
|
+
if (!lensSet.has(src.identity)) continue;
|
|
292
|
+
if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
|
|
293
|
+
const liveObj = liveById.get(src.identity);
|
|
294
|
+
|
|
295
|
+
// Grant drift — only when the catalog actually reported ACLs (absent ≠ empty).
|
|
296
|
+
// Every object is descriptor-compiled (db/sql retired, B7), and the compiler
|
|
297
|
+
// ALWAYS declares authz — a private relation is declared-empty, not undeclared —
|
|
298
|
+
// so every up-to-date object is grant-checked.
|
|
299
|
+
if (liveObj?.grants !== undefined) {
|
|
300
|
+
const parsed = parseGrantAttachments(src.attachments);
|
|
301
|
+
if (parsed.hasColumnGrants) {
|
|
302
|
+
extraWarnings.push(
|
|
303
|
+
`${src.identity}: column-scoped grants are applied at create but not drift-checked (attacl introspection is future work) — hand edits to them are invisible here.`,
|
|
304
|
+
);
|
|
305
|
+
} else {
|
|
306
|
+
const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
|
|
307
|
+
const statements = diffObjectGrants(target, parsed.grants, liveObj.grants);
|
|
308
|
+
if (statements.length > 0) regrants.push({ identity: src.identity, statements });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Dependency drift — declared, then verified. Only descriptor-home relations carry a
|
|
313
|
+
// declaration, and only relations have rewrite-rule edges for the catalog to answer with.
|
|
314
|
+
if (src.declaredDeps !== undefined && (src.kind === 'view' || src.kind === 'materialized view')) {
|
|
315
|
+
const liveDeps = new Set(live.edges.filter((e) => e.dependent === src.identity).map((e) => e.referenced));
|
|
316
|
+
const declaredSet = new Set(src.declaredDeps);
|
|
317
|
+
const missing = [...liveDeps].filter((d) => !declaredSet.has(d)).sort();
|
|
318
|
+
const stale = [...declaredSet].filter((d) => !liveDeps.has(d)).sort();
|
|
319
|
+
if (missing.length > 0 || stale.length > 0) {
|
|
320
|
+
dependencyDrift.push({ identity: src.identity, missing, stale });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
173
323
|
}
|
|
174
324
|
|
|
175
325
|
// -------------------------------------------------------------------------
|
|
@@ -260,11 +410,23 @@ export function planReconcile(
|
|
|
260
410
|
const cost = liveObj?.kind === 'materialized view'
|
|
261
411
|
? { ...(liveObj.bytes !== undefined ? { bytes: liveObj.bytes } : {}), ...(liveObj.rows !== undefined ? { rows: liveObj.rows } : {}) }
|
|
262
412
|
: {};
|
|
263
|
-
|
|
413
|
+
// A trigger's drop needs its table — composed from structure (source or live), never
|
|
414
|
+
// from the identity string (the red-team's C4: quoting a compound identity mangles it).
|
|
415
|
+
const structural = action === 'drop' && kind === 'trigger'
|
|
416
|
+
? (() => {
|
|
417
|
+
const o = src ?? liveObj;
|
|
418
|
+
return o?.table ? { dropSql: triggerDropSql(o.name, o.table) } : {};
|
|
419
|
+
})()
|
|
420
|
+
: {};
|
|
421
|
+
return { action, identity: id, kind, reason, ...cost, ...structural };
|
|
264
422
|
};
|
|
265
423
|
|
|
266
424
|
const actions: ReconcileAction[] = [
|
|
267
425
|
...orderDrops(dropSet).map((id) => actionFor(id, 'drop', rebuild.get(id) ?? drop.get(id) ?? '')),
|
|
426
|
+
// 'sql'-kind drops (rebuilds + removals) — invisible to the live catalog, so they
|
|
427
|
+
// carry their own statement; ordered with the other drops, before every create.
|
|
428
|
+
...[...sqlDrops.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
429
|
+
.map(([id, d]) => ({ action: 'drop' as const, identity: id, kind: 'sql' as DerivedKind, reason: d.reason, dropSql: d.dropSql })),
|
|
268
430
|
...[...fnReplace.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
|
|
269
431
|
.map(([id, reason]) => actionFor(id, 'replace', reason)),
|
|
270
432
|
...createSet.sort((a, b) => seqOf(a) - seqOf(b))
|
|
@@ -272,6 +434,7 @@ export function planReconcile(
|
|
|
272
434
|
...[...refresh.entries()].sort((a, b) => seqOf(a[0]) - seqOf(b[0]))
|
|
273
435
|
.map(([id, reason]) => actionFor(id, 'refresh', reason)),
|
|
274
436
|
...baseline.sort().map((id) => actionFor(id, 'baseline', 'recording provenance for an existing match (trusted once, explicitly)')),
|
|
437
|
+
...rebaseline.sort().map((id) => actionFor(id, 'rebaseline', 'source re-rendered — live verified untouched (defHash match); provenance re-recorded, no rebuild')),
|
|
275
438
|
...prune.sort().map((id) => ({
|
|
276
439
|
action: 'prune' as const, identity: id, kind: 'view' as DerivedKind,
|
|
277
440
|
reason: 'stale provenance — object gone from both source and database',
|
|
@@ -289,7 +452,10 @@ export function planReconcile(
|
|
|
289
452
|
needsBaseline: needsBaseline.sort(),
|
|
290
453
|
unmanaged: unmanaged.sort(),
|
|
291
454
|
blocked,
|
|
292
|
-
warnings: [...source.warnings],
|
|
455
|
+
warnings: [...source.warnings, ...extraWarnings],
|
|
293
456
|
totalRebuildBytes,
|
|
457
|
+
migrations,
|
|
458
|
+
regrants: regrants.sort((a, b) => a.identity.localeCompare(b.identity)),
|
|
459
|
+
dependencyDrift: dependencyDrift.sort((a, b) => a.identity.localeCompare(b.identity)),
|
|
294
460
|
};
|
|
295
461
|
}
|
|
@@ -0,0 +1,320 @@
|
|
|
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
|
+
|
|
20
|
+
export interface DerivedRenderResult {
|
|
21
|
+
/** The source block: `export const … = defineView(…)` etc., dependency-ordered. */
|
|
22
|
+
block: string;
|
|
23
|
+
/** Rendered derived variable names, emission order — feed `defineModule({ derived })`. */
|
|
24
|
+
names: string[];
|
|
25
|
+
/** Rendered sequence variable names — feed `defineModule({ sequences })`. */
|
|
26
|
+
sequenceNames: string[];
|
|
27
|
+
/** Model-package symbols the block uses (defineView, can, sql, arg, index, …). */
|
|
28
|
+
imports: string[];
|
|
29
|
+
/** Everything skipped or flagged — mirrored as FIXME comments in the block. */
|
|
30
|
+
warnings: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DerivedRenderOptions {
|
|
34
|
+
/** Qualified table (`public.posts`) → the model variable name rendered in the barrel. */
|
|
35
|
+
knownTables: Map<string, string>;
|
|
36
|
+
/** Standalone sequences from the snapshot (serial-owned stay implicit). */
|
|
37
|
+
sequences?: SequenceSchema[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PLAIN_IDENT = /^[a-z_][a-z0-9_$]*$/;
|
|
41
|
+
|
|
42
|
+
function toCamelCase(name: string): string {
|
|
43
|
+
return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Escape a body for a template literal in generated source — it must always parse. */
|
|
47
|
+
function tsTemplate(body: string): string {
|
|
48
|
+
return body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tsString(s: string): string {
|
|
52
|
+
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The relation grant shapes `abilities` can say. Null = inexpressible (caller FIXMEs). */
|
|
56
|
+
function relationAbilities(grants: Record<string, string[]>): string[] | null {
|
|
57
|
+
const roles = Object.keys(grants);
|
|
58
|
+
for (const role of roles) {
|
|
59
|
+
if (role.toUpperCase() === 'PUBLIC') return null;
|
|
60
|
+
if (grants[role].some((p) => p !== 'SELECT')) return null;
|
|
61
|
+
}
|
|
62
|
+
const set = new Set(roles);
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
if (set.has('anon') && set.has('authenticated')) {
|
|
65
|
+
out.push(`can('read')`);
|
|
66
|
+
set.delete('anon');
|
|
67
|
+
set.delete('authenticated');
|
|
68
|
+
}
|
|
69
|
+
for (const role of [...set].sort()) out.push(`can('read', { role: ${tsString(role)} })`);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** VOLATILITY letters → the descriptor words ('v' is the default, never spelled). */
|
|
74
|
+
const VOLATILITY: Record<string, string> = { i: 'immutable', s: 'stable' };
|
|
75
|
+
|
|
76
|
+
interface ParsedArg {
|
|
77
|
+
name: string;
|
|
78
|
+
type: string;
|
|
79
|
+
default?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parse `pg_get_function_arguments` output. Null = beyond the v1 vocabulary. */
|
|
83
|
+
function parseArgs(args: string): ParsedArg[] | null {
|
|
84
|
+
const trimmed = args.trim();
|
|
85
|
+
if (!trimmed) return [];
|
|
86
|
+
const out: ParsedArg[] = [];
|
|
87
|
+
let depth = 0;
|
|
88
|
+
let start = 0;
|
|
89
|
+
const entries: string[] = [];
|
|
90
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
91
|
+
if (trimmed[i] === '(') depth++;
|
|
92
|
+
else if (trimmed[i] === ')') depth--;
|
|
93
|
+
else if (trimmed[i] === ',' && depth === 0) {
|
|
94
|
+
entries.push(trimmed.slice(start, i));
|
|
95
|
+
start = i + 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
entries.push(trimmed.slice(start));
|
|
99
|
+
for (const entry of entries.map((e) => e.trim())) {
|
|
100
|
+
if (/^(OUT|INOUT|VARIADIC)\b/i.test(entry)) return null;
|
|
101
|
+
const m = entry.match(/^([a-z_][a-z0-9_$]*)\s+(.+?)(?:\s+DEFAULT\s+(.+))?$/i);
|
|
102
|
+
if (!m || !m[2]) return null;
|
|
103
|
+
// A single token is an UNNAMED arg ('text') — the match would misread type as name.
|
|
104
|
+
if (!/\s/.test(entry.replace(/\s+DEFAULT\s+.+$/i, '')) ) return null;
|
|
105
|
+
out.push({ name: m[1], type: m[2], ...(m[3] ? { default: m[3] } : {}) });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One matview index attachment → the Brick E builder chain, or null (FIXME). */
|
|
111
|
+
function renderIndexBuilder(indexdef: string): string | null {
|
|
112
|
+
const parsed = parseIndexDefinition(indexdef);
|
|
113
|
+
if (!parsed) return null;
|
|
114
|
+
const entry = (c: string): string => (PLAIN_IDENT.test(c) ? tsString(c) : `sql\`${tsTemplate(c)}\``);
|
|
115
|
+
let s = `index(${parsed.columns.map(entry).join(', ')})`;
|
|
116
|
+
if (parsed.using) s += `.using(${tsString(parsed.using)})`;
|
|
117
|
+
if (parsed.unique) s += '.unique()';
|
|
118
|
+
if (parsed.include?.length) s += `.include(${parsed.include.map(tsString).join(', ')})`;
|
|
119
|
+
if (parsed.where) s += `.where(sql\`${tsTemplate(parsed.where)}\`)`;
|
|
120
|
+
return s;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRenderOptions): DerivedRenderResult {
|
|
124
|
+
const warnings: string[] = [];
|
|
125
|
+
const lines: string[] = [];
|
|
126
|
+
const names: string[] = [];
|
|
127
|
+
const used = new Set<string>();
|
|
128
|
+
const need = (sym: string): void => { used.add(sym); };
|
|
129
|
+
|
|
130
|
+
// -- sequences (state layer — they precede the compute in the module) -------
|
|
131
|
+
|
|
132
|
+
const sequenceNames: string[] = [];
|
|
133
|
+
for (const seq of opts.sequences ?? []) {
|
|
134
|
+
const varName = toCamelCase(seq.name.replace(/\./g, '_'));
|
|
135
|
+
const props: string[] = [];
|
|
136
|
+
if (seq.as !== 'bigint') props.push(`as: ${tsString(seq.as)}`);
|
|
137
|
+
if (seq.start != null) props.push(`start: ${seq.start}`);
|
|
138
|
+
if (seq.increment != null) props.push(`increment: ${seq.increment}`);
|
|
139
|
+
lines.push(`export const ${varName} = defineSequence(${tsString(seq.name)}${props.length ? `, { ${props.join(', ')} }` : ''});`);
|
|
140
|
+
need('defineSequence');
|
|
141
|
+
sequenceNames.push(varName);
|
|
142
|
+
}
|
|
143
|
+
if (sequenceNames.length) lines.push('');
|
|
144
|
+
|
|
145
|
+
// -- emission order: dependencies first (Kahn over derived→derived edges) ---
|
|
146
|
+
|
|
147
|
+
const renderable = catalog.objects.filter((o) => o.kind !== 'trigger');
|
|
148
|
+
for (const t of catalog.objects.filter((o) => o.kind === 'trigger')) {
|
|
149
|
+
warnings.push(`${t.identity}: live trigger not rendered — declare it on its model with trigger() (v1 renders relations, functions, and sequences).`);
|
|
150
|
+
}
|
|
151
|
+
const byIdentity = new Map(renderable.map((o) => [o.identity, o]));
|
|
152
|
+
// Var names carry the schema for non-public objects — two schemas can share a bare name.
|
|
153
|
+
const varOf = new Map(renderable.map((o) =>
|
|
154
|
+
[o.identity, toCamelCase(o.schema === 'public' ? o.name : `${o.schema}_${o.name}`)]));
|
|
155
|
+
// The name a descriptor DECLARES: bare for public, qualified otherwise. parseQualified
|
|
156
|
+
// round-trips it, so the compiled identity matches the live object it was pulled from —
|
|
157
|
+
// a bare name would compile to public.<name>, baseline would never join, and reconcile
|
|
158
|
+
// would plan duplicate creates in public (the red-team's pull-qualification finding).
|
|
159
|
+
const declaredName = (o: LiveObject): string => (o.schema === 'public' ? o.name : o.identity);
|
|
160
|
+
|
|
161
|
+
const indegree = new Map<string, number>();
|
|
162
|
+
const dependents = new Map<string, string[]>();
|
|
163
|
+
for (const o of renderable) indegree.set(o.identity, 0);
|
|
164
|
+
for (const e of catalog.edges) {
|
|
165
|
+
if (!byIdentity.has(e.dependent) || !byIdentity.has(e.referenced)) continue;
|
|
166
|
+
indegree.set(e.dependent, (indegree.get(e.dependent) ?? 0) + 1);
|
|
167
|
+
dependents.set(e.referenced, [...(dependents.get(e.referenced) ?? []), e.dependent]);
|
|
168
|
+
}
|
|
169
|
+
const ready = renderable.filter((o) => indegree.get(o.identity) === 0).map((o) => o.identity).sort();
|
|
170
|
+
const ordered: LiveObject[] = [];
|
|
171
|
+
while (ready.length) {
|
|
172
|
+
const id = ready.shift()!;
|
|
173
|
+
ordered.push(byIdentity.get(id)!);
|
|
174
|
+
for (const dep of dependents.get(id) ?? []) {
|
|
175
|
+
const left = (indegree.get(dep) ?? 0) - 1;
|
|
176
|
+
indegree.set(dep, left);
|
|
177
|
+
if (left === 0) {
|
|
178
|
+
ready.push(dep);
|
|
179
|
+
ready.sort();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// A cycle is impossible in a live catalog (PostgreSQL refuses it); anything left over
|
|
184
|
+
// would mean edge noise — emit it anyway, identity-sorted, rather than dropping it.
|
|
185
|
+
for (const o of renderable) if (!ordered.includes(o)) ordered.push(o);
|
|
186
|
+
|
|
187
|
+
// -- per-object rendering ----------------------------------------------------
|
|
188
|
+
|
|
189
|
+
const depsFor = (identity: string): { refs: string[]; fixmes: string[] } => {
|
|
190
|
+
const refs: string[] = [];
|
|
191
|
+
const fixmes: string[] = [];
|
|
192
|
+
const seen = new Set<string>();
|
|
193
|
+
for (const e of catalog.edges) {
|
|
194
|
+
if (e.dependent !== identity || seen.has(e.referenced)) continue;
|
|
195
|
+
seen.add(e.referenced);
|
|
196
|
+
const model = opts.knownTables.get(e.referenced);
|
|
197
|
+
const derived = varOf.get(e.referenced);
|
|
198
|
+
if (model) refs.push(model);
|
|
199
|
+
else if (derived) refs.push(derived);
|
|
200
|
+
else fixmes.push(`// FIXME: dependsOn ${e.referenced} — not in the pulled set; declare it by hand.`);
|
|
201
|
+
}
|
|
202
|
+
return { refs: refs.sort(), fixmes };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
for (const o of ordered) {
|
|
206
|
+
const varName = varOf.get(o.identity)!;
|
|
207
|
+
const { refs, fixmes } = depsFor(o.identity);
|
|
208
|
+
|
|
209
|
+
if (o.kind === 'view' || o.kind === 'materialized view') {
|
|
210
|
+
const abilities = relationAbilities(o.grants ?? {});
|
|
211
|
+
if (abilities === null) {
|
|
212
|
+
const grantText = Object.entries(o.grants ?? {}).map(([r, p]) => `${r}: ${p.join('/')}`).join(', ');
|
|
213
|
+
warnings.push(`${o.identity}: live grants (${grantText}) are not expressible as relation abilities (read-only, role-shaped) — object skipped; migrate it by hand.`);
|
|
214
|
+
lines.push(`// FIXME: ${o.identity} skipped — live grants (${grantText}) are not expressible as abilities (views are read surfaces).`, '');
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const props: string[] = [];
|
|
218
|
+
if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
|
|
219
|
+
if (abilities.length === 0) props.push('private: true,');
|
|
220
|
+
else {
|
|
221
|
+
props.push(`abilities: [${abilities.join(', ')}],`);
|
|
222
|
+
need('can');
|
|
223
|
+
}
|
|
224
|
+
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
225
|
+
if (o.kind === 'materialized view') {
|
|
226
|
+
const builders = o.indexes.map((def) => {
|
|
227
|
+
const b = renderIndexBuilder(def);
|
|
228
|
+
if (b === null) warnings.push(`${o.identity}: index not renderable: ${def}`);
|
|
229
|
+
return b;
|
|
230
|
+
}).filter((b): b is string => b !== null);
|
|
231
|
+
if (builders.length) {
|
|
232
|
+
props.push(`indexes: [${builders.join(', ')}],`);
|
|
233
|
+
need('index');
|
|
234
|
+
}
|
|
235
|
+
if (o.populated === false) props.push(`populate: 'deferred',`);
|
|
236
|
+
}
|
|
237
|
+
if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
|
|
238
|
+
props.push(`as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`);
|
|
239
|
+
need(o.kind === 'view' ? 'defineView' : 'defineMaterializedView');
|
|
240
|
+
need('sql');
|
|
241
|
+
lines.push(
|
|
242
|
+
...fixmes,
|
|
243
|
+
`export const ${varName} = ${o.kind === 'view' ? 'defineView' : 'defineMaterializedView'}(${tsString(declaredName(o))}, {`,
|
|
244
|
+
...props.map((p) => ` ${p}`),
|
|
245
|
+
'});',
|
|
246
|
+
'',
|
|
247
|
+
);
|
|
248
|
+
names.push(varName);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Functions.
|
|
253
|
+
const f = o.fn;
|
|
254
|
+
const grants = o.grants ?? {};
|
|
255
|
+
const roleGrants = Object.keys(grants).filter((r) => r.toUpperCase() !== 'PUBLIC').sort();
|
|
256
|
+
const publicExec = Object.keys(grants).some((r) => r.toUpperCase() === 'PUBLIC');
|
|
257
|
+
const args = f && (f.language === 'sql' || f.language === 'plpgsql') ? parseArgs(f.args) : null;
|
|
258
|
+
|
|
259
|
+
if (!f || args === null || (f.language !== 'sql' && f.language !== 'plpgsql')) {
|
|
260
|
+
warnings.push(`${o.identity}: signature beyond the v1 vocabulary (${f ? `language ${f.language}, args '${f.args}'` : 'no structured fields'}) — rendered as defineSql.`);
|
|
261
|
+
lines.push(
|
|
262
|
+
`// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
|
|
263
|
+
`export const ${varName} = defineSql(${tsString(declaredName(o))}, {`,
|
|
264
|
+
` kind: 'function',`,
|
|
265
|
+
` drop: sql\`DROP FUNCTION IF EXISTS ${o.identity.startsWith('public.') ? o.name : o.identity}\`,`,
|
|
266
|
+
` as: sql\`${tsTemplate(o.definition.trim().replace(/;$/, ''))}\`,`,
|
|
267
|
+
'});',
|
|
268
|
+
'',
|
|
269
|
+
);
|
|
270
|
+
need('defineSql');
|
|
271
|
+
need('sql');
|
|
272
|
+
names.push(varName);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const props: string[] = [];
|
|
277
|
+
if (args.length) {
|
|
278
|
+
const rendered = args.map((a) =>
|
|
279
|
+
`arg(${tsString(a.name)}, ${tsString(a.type)}${a.default ? `, { default: sql\`${tsTemplate(a.default)}\` }` : ''})`);
|
|
280
|
+
props.push(`args: [${rendered.join(', ')}],`);
|
|
281
|
+
need('arg');
|
|
282
|
+
}
|
|
283
|
+
props.push(`returns: ${tsString(f.returns)},`);
|
|
284
|
+
props.push(`language: ${tsString(f.language)},`);
|
|
285
|
+
if (VOLATILITY[f.volatility]) props.push(`volatility: ${tsString(VOLATILITY[f.volatility])},`);
|
|
286
|
+
if (f.secdef) {
|
|
287
|
+
props.push(`security: 'definer',`);
|
|
288
|
+
const path = (f.searchPath ?? 'pg_catalog').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
|
289
|
+
props.push(`searchPath: [${path.map(tsString).join(', ')}],`);
|
|
290
|
+
}
|
|
291
|
+
if (roleGrants.length) {
|
|
292
|
+
props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
|
|
293
|
+
need('can');
|
|
294
|
+
}
|
|
295
|
+
if (refs.length) props.push(`dependsOn: [${refs.join(', ')}],`);
|
|
296
|
+
if (o.comment) props.push(`comment: ${tsString(o.comment)},`);
|
|
297
|
+
props.push(`body: sql\`${tsTemplate(f.src.trim())}\`,`);
|
|
298
|
+
need('defineFunction');
|
|
299
|
+
need('sql');
|
|
300
|
+
lines.push(
|
|
301
|
+
...fixmes,
|
|
302
|
+
...(publicExec
|
|
303
|
+
? [`// FIXME: ${o.identity} — live grants EXECUTE to PUBLIC; descriptors ALWAYS revoke PUBLIC. Declare the intended callers (adopting this descriptor applies the revoke).`]
|
|
304
|
+
: []),
|
|
305
|
+
`export const ${varName} = defineFunction(${tsString(declaredName(o))}, {`,
|
|
306
|
+
...props.map((p) => ` ${p}`),
|
|
307
|
+
'});',
|
|
308
|
+
'',
|
|
309
|
+
);
|
|
310
|
+
names.push(varName);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
block: lines.join('\n').trimEnd(),
|
|
315
|
+
names,
|
|
316
|
+
sequenceNames,
|
|
317
|
+
imports: [...used].sort(),
|
|
318
|
+
warnings,
|
|
319
|
+
};
|
|
320
|
+
}
|