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