@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,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* derived-grants — the grant half of B4's live drift
|
|
3
|
+
* (docs/plans/read-model-everywhere.md, "B4 — live drift + doctor").
|
|
4
|
+
*
|
|
5
|
+
* Content hashes see SOURCE changes; a hand-run GRANT/REVOKE on the live database touches
|
|
6
|
+
* no hash and used to be invisible. This module closes it with two pure pieces:
|
|
7
|
+
*
|
|
8
|
+
* - `parseGrantAttachments`: the declared grants, read back out of the object's own
|
|
9
|
+
* grant attachments. The grammar is OURS — the descriptor compiler emits
|
|
10
|
+
* every one of these statements — so the parse is narrow and total, never a general
|
|
11
|
+
* SQL parser. A `REVOKE ALL … FROM PUBLIC` declares PUBLIC as explicitly EMPTY, so a
|
|
12
|
+
* live default ACL (PostgreSQL grants functions EXECUTE to PUBLIC out of the box)
|
|
13
|
+
* diffs to the revoke.
|
|
14
|
+
* - `diffObjectGrants`: the reconcileGrants pattern from the table path — per grantee,
|
|
15
|
+
* revoke what live has that the source doesn't declare, grant what the source
|
|
16
|
+
* declares that live lacks. The declared source is authoritative for authz; grants
|
|
17
|
+
* converge without drift ceremony (rebuilds lose hand-edits, REVOKE/GRANT loses
|
|
18
|
+
* nothing but the drift itself).
|
|
19
|
+
*
|
|
20
|
+
* Column-scoped grants (`GRANT SELECT ("a", "b") …`) live in `pg_attribute.attacl`, which
|
|
21
|
+
* this drift pass does not introspect yet — an object carrying one is marked
|
|
22
|
+
* `hasColumnGrants` and its grant drift is skipped LOUDLY (the plan warns), never
|
|
23
|
+
* silently half-diffed.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Attachment } from './derived-source.js';
|
|
27
|
+
|
|
28
|
+
export interface ParsedGrants {
|
|
29
|
+
/** grantee → sorted privileges. PUBLIC present-with-[] means explicitly revoked. */
|
|
30
|
+
grants: Record<string, string[]>;
|
|
31
|
+
/** The `ON …` target as our statements spell it (`leaderboard`, `FUNCTION f(text)`). */
|
|
32
|
+
target: string | null;
|
|
33
|
+
/** A column-scoped grant is present — attacl is not introspected; skip drift, loudly. */
|
|
34
|
+
hasColumnGrants: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Our two emitters' shapes, nothing more:
|
|
38
|
+
// GRANT SELECT[ ("c1", "c2")] ON <target> TO r1[, r2…]
|
|
39
|
+
// GRANT EXECUTE ON FUNCTION <sig> TO r
|
|
40
|
+
// REVOKE ALL ON FUNCTION <sig> FROM PUBLIC
|
|
41
|
+
const GRANT_RE = /^GRANT\s+([A-Z]+)\s*(\([^)]*\))?\s+ON\s+(.+?)\s+TO\s+(.+)$/i;
|
|
42
|
+
const REVOKE_PUBLIC_RE = /^REVOKE\s+ALL\s+ON\s+(.+?)\s+FROM\s+PUBLIC$/i;
|
|
43
|
+
|
|
44
|
+
/** The declared grant contract, read back from an object's own grant attachments. */
|
|
45
|
+
export function parseGrantAttachments(attachments: readonly Attachment[]): ParsedGrants {
|
|
46
|
+
const grants: Record<string, Set<string>> = {};
|
|
47
|
+
let target: string | null = null;
|
|
48
|
+
let hasColumnGrants = false;
|
|
49
|
+
|
|
50
|
+
for (const a of attachments) {
|
|
51
|
+
if (a.kind !== 'grant') continue;
|
|
52
|
+
const revoke = a.sql.match(REVOKE_PUBLIC_RE);
|
|
53
|
+
if (revoke) {
|
|
54
|
+
target ??= revoke[1].trim();
|
|
55
|
+
grants.PUBLIC ??= new Set();
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const grant = a.sql.match(GRANT_RE);
|
|
59
|
+
if (!grant) continue;
|
|
60
|
+
const [, privilege, columns, onTarget, roleList] = grant;
|
|
61
|
+
if (columns) {
|
|
62
|
+
hasColumnGrants = true;
|
|
63
|
+
continue; // attacl territory — declared, applied at create, not drift-checked yet
|
|
64
|
+
}
|
|
65
|
+
target ??= onTarget.trim();
|
|
66
|
+
for (const role of roleList.split(',').map((r) => r.trim()).filter(Boolean)) {
|
|
67
|
+
(grants[role] ??= new Set()).add(privilege.toUpperCase());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const out: Record<string, string[]> = {};
|
|
72
|
+
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
73
|
+
return { grants: out, target, hasColumnGrants };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The idempotent REVOKE/GRANT delta between the declared contract and the live ACLs —
|
|
78
|
+
* the table path's reconcileGrants, per derived object. Empty when they match.
|
|
79
|
+
*/
|
|
80
|
+
export function diffObjectGrants(
|
|
81
|
+
target: string,
|
|
82
|
+
declared: Record<string, string[]>,
|
|
83
|
+
live: Record<string, string[]>,
|
|
84
|
+
): string[] {
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
const grantees = new Set([...Object.keys(declared), ...Object.keys(live)]);
|
|
87
|
+
for (const grantee of [...grantees].sort()) {
|
|
88
|
+
const want = new Set(declared[grantee] ?? []);
|
|
89
|
+
const have = new Set(live[grantee] ?? []);
|
|
90
|
+
const toRevoke = [...have].filter((p) => !want.has(p)).sort();
|
|
91
|
+
const toGrant = [...want].filter((p) => !have.has(p)).sort();
|
|
92
|
+
if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON ${target} FROM ${grantee}`);
|
|
93
|
+
if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${target} TO ${grantee}`);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
@@ -41,6 +41,28 @@ export interface LiveObject {
|
|
|
41
41
|
bytes?: number;
|
|
42
42
|
/** Matview only: reltuples estimate. */
|
|
43
43
|
rows?: number;
|
|
44
|
+
/** Trigger only: the (bare or schema-qualified) relation it rides — `DROP TRIGGER … ON` needs it. */
|
|
45
|
+
table?: string;
|
|
46
|
+
/** Live ACLs (grantee → sorted privileges), owner excluded, function defaults expanded.
|
|
47
|
+
* Absent on triggers (they take no grants) and when the introspection predates B4. */
|
|
48
|
+
grants?: Record<string, string[]>;
|
|
49
|
+
/** View only: the security_invoker reloption — db:pull renders the required property. */
|
|
50
|
+
securityInvoker?: boolean;
|
|
51
|
+
/** Function only (B5 pull rendering): the structured signature and body. */
|
|
52
|
+
fn?: {
|
|
53
|
+
/** pg_get_function_arguments — `'query text, max integer DEFAULT 20'`. */
|
|
54
|
+
args: string;
|
|
55
|
+
/** pg_get_function_result — `'integer'`, `'SETOF posts'`, `'trigger'`. */
|
|
56
|
+
returns: string;
|
|
57
|
+
language: string;
|
|
58
|
+
secdef: boolean;
|
|
59
|
+
/** provolatile: 'i' | 's' | 'v'. */
|
|
60
|
+
volatility: string;
|
|
61
|
+
/** The pinned search_path value, when set ('pg_catalog, public'). */
|
|
62
|
+
searchPath?: string;
|
|
63
|
+
/** prosrc — the raw body. */
|
|
64
|
+
src: string;
|
|
65
|
+
};
|
|
44
66
|
}
|
|
45
67
|
|
|
46
68
|
/** dependent identity → referenced identity (both derived objects). */
|
|
@@ -54,6 +76,10 @@ export interface ProvenanceRow {
|
|
|
54
76
|
identity: string;
|
|
55
77
|
srcHash: string;
|
|
56
78
|
defHash: string;
|
|
79
|
+
/** The object kind at record time — how a `'sql'` object (invisible to the catalog) is known. */
|
|
80
|
+
kind?: string;
|
|
81
|
+
/** How to remove what was recorded — the C1 closure: removal never orphans. */
|
|
82
|
+
dropSql?: string;
|
|
57
83
|
}
|
|
58
84
|
|
|
59
85
|
export interface DerivedCatalog {
|
|
@@ -66,7 +92,8 @@ export interface DerivedCatalog {
|
|
|
66
92
|
// Introspection SQL.
|
|
67
93
|
// ---------------------------------------------------------------------------
|
|
68
94
|
|
|
69
|
-
/** Views and matviews with canonical definitions, size, and
|
|
95
|
+
/** Views and matviews with canonical definitions, size, comment, and (views) the
|
|
96
|
+
* security_invoker reloption — db:pull renders it as the REQUIRED securityInvoker. */
|
|
70
97
|
export const DERIVED_RELATIONS_SQL = `
|
|
71
98
|
SELECT
|
|
72
99
|
n.nspname AS schema,
|
|
@@ -76,7 +103,11 @@ SELECT
|
|
|
76
103
|
obj_description(c.oid, 'pg_class') AS comment,
|
|
77
104
|
c.relispopulated AS populated,
|
|
78
105
|
pg_total_relation_size(c.oid) AS bytes,
|
|
79
|
-
c.reltuples::bigint AS rows
|
|
106
|
+
c.reltuples::bigint AS rows,
|
|
107
|
+
EXISTS (
|
|
108
|
+
SELECT 1 FROM unnest(COALESCE(c.reloptions, '{}'::text[])) o
|
|
109
|
+
WHERE o IN ('security_invoker=true', 'security_invoker=on')
|
|
110
|
+
) AS security_invoker
|
|
80
111
|
FROM pg_class c
|
|
81
112
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
82
113
|
WHERE c.relkind IN ('v', 'm')
|
|
@@ -88,15 +119,28 @@ WHERE c.relkind IN ('v', 'm')
|
|
|
88
119
|
ORDER BY n.nspname, c.relname;
|
|
89
120
|
`.trim();
|
|
90
121
|
|
|
91
|
-
/** Plain functions and procedures
|
|
122
|
+
/** Plain functions and procedures: the canonical definition (reconcile's def hash) plus
|
|
123
|
+
* the STRUCTURED fields db:pull renders into defineFunction — args/returns from the
|
|
124
|
+
* deparse helpers, language, SECDEF, volatility, the pinned search_path, and the raw
|
|
125
|
+
* body (prosrc). Anything the v1 signature vocabulary can't say falls back to defineSql
|
|
126
|
+
* with the whole pg_get_functiondef. */
|
|
92
127
|
export const DERIVED_FUNCTIONS_SQL = `
|
|
93
128
|
SELECT
|
|
94
129
|
n.nspname AS schema,
|
|
95
130
|
p.proname AS name,
|
|
96
131
|
pg_get_functiondef(p.oid) AS definition,
|
|
97
|
-
obj_description(p.oid, 'pg_proc') AS comment
|
|
132
|
+
obj_description(p.oid, 'pg_proc') AS comment,
|
|
133
|
+
pg_get_function_arguments(p.oid) AS args,
|
|
134
|
+
pg_get_function_result(p.oid) AS returns,
|
|
135
|
+
l.lanname AS language,
|
|
136
|
+
p.prosecdef AS secdef,
|
|
137
|
+
p.provolatile AS volatility,
|
|
138
|
+
(SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
|
|
139
|
+
WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path,
|
|
140
|
+
p.prosrc AS src
|
|
98
141
|
FROM pg_proc p
|
|
99
142
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
143
|
+
JOIN pg_language l ON l.oid = p.prolang
|
|
100
144
|
WHERE p.prokind IN ('f', 'p')
|
|
101
145
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
102
146
|
AND n.nspname NOT LIKE 'pg_%'
|
|
@@ -122,10 +166,15 @@ ORDER BY n.nspname, t.relname, idx.indexrelid;
|
|
|
122
166
|
`.trim();
|
|
123
167
|
|
|
124
168
|
/**
|
|
125
|
-
* Dependency edges
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
169
|
+
* Dependency edges from each view/matview's rewrite rule: view → view/matview/TABLE it
|
|
170
|
+
* selects from, and view → function it calls. (Function → function edges do not exist in
|
|
171
|
+
* pg_depend — function bodies are opaque to it; source order is the fallback there.)
|
|
172
|
+
*
|
|
173
|
+
* Table edges (relkind 'r') feed B4's declared-vs-actual dependency drift — the
|
|
174
|
+
* verification half of "dependsOn: declared, then verified". The cascade logic keys its
|
|
175
|
+
* lookups by MANAGED derived identities, so table-referenced edges never join it.
|
|
176
|
+
* System-schema referenced sides are excluded on both branches: a view reading
|
|
177
|
+
* pg_catalog is not an edge anyone declares.
|
|
129
178
|
*/
|
|
130
179
|
export const DERIVED_DEPENDS_SQL = `
|
|
131
180
|
SELECT DISTINCT
|
|
@@ -143,7 +192,12 @@ WHERE d.classid = 'pg_rewrite'::regclass
|
|
|
143
192
|
AND d.refclassid = 'pg_class'::regclass
|
|
144
193
|
AND dc.oid <> rc.oid
|
|
145
194
|
AND dc.relkind IN ('v', 'm')
|
|
146
|
-
AND rc.relkind IN ('v', 'm')
|
|
195
|
+
AND rc.relkind IN ('v', 'm', 'r')
|
|
196
|
+
AND rn.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
197
|
+
AND rn.nspname NOT LIKE 'pg_%'
|
|
198
|
+
AND NOT EXISTS (
|
|
199
|
+
SELECT 1 FROM pg_depend dep WHERE dep.objid = rc.oid AND dep.deptype = 'e'
|
|
200
|
+
)
|
|
147
201
|
UNION
|
|
148
202
|
SELECT DISTINCT
|
|
149
203
|
dn.nspname, dc.relname, rn.nspname, rp.proname
|
|
@@ -155,12 +209,90 @@ JOIN pg_proc rp ON rp.oid = d.refobjid
|
|
|
155
209
|
JOIN pg_namespace rn ON rn.oid = rp.pronamespace
|
|
156
210
|
WHERE d.classid = 'pg_rewrite'::regclass
|
|
157
211
|
AND d.refclassid = 'pg_proc'::regclass
|
|
158
|
-
AND dc.relkind IN ('v', 'm')
|
|
212
|
+
AND dc.relkind IN ('v', 'm')
|
|
213
|
+
AND rn.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
214
|
+
AND rn.nspname NOT LIKE 'pg_%'
|
|
215
|
+
AND NOT EXISTS (
|
|
216
|
+
SELECT 1 FROM pg_depend dep WHERE dep.objid = rp.oid AND dep.deptype = 'e'
|
|
217
|
+
);
|
|
218
|
+
`.trim();
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Live ACLs for the derived kinds — the grant half of B4's drift (a hand-run
|
|
222
|
+
* GRANT/REVOKE touches no content hash and was invisible without this read).
|
|
223
|
+
*
|
|
224
|
+
* Relations (`relacl`): a NULL acl means owner-only — aclexplode(NULL) yields nothing,
|
|
225
|
+
* which reads correctly as "no grants". Functions (`proacl`) are the opposite trap:
|
|
226
|
+
* NULL means the BUILT-IN DEFAULT, which includes EXECUTE to PUBLIC — so the function
|
|
227
|
+
* branch expands `acldefault('f', proowner)` and a never-revoked function honestly
|
|
228
|
+
* shows its PUBLIC EXECUTE. Owner rows are excluded (owner privileges are not grants);
|
|
229
|
+
* `LEFT JOIN pg_roles` + COALESCE('PUBLIC') keeps a dropped-role ACL from failing the
|
|
230
|
+
* read (the table path's offline-safety). `kind` disambiguates a relation and a
|
|
231
|
+
* function sharing a name.
|
|
232
|
+
*/
|
|
233
|
+
export const DERIVED_GRANTS_SQL = `
|
|
234
|
+
SELECT
|
|
235
|
+
n.nspname AS schema,
|
|
236
|
+
c.relname AS name,
|
|
237
|
+
'r' AS kind,
|
|
238
|
+
COALESCE(r.rolname, 'PUBLIC') AS grantee,
|
|
239
|
+
array_agg(DISTINCT a.privilege_type ORDER BY a.privilege_type) AS privileges
|
|
240
|
+
FROM pg_class c
|
|
241
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
242
|
+
CROSS JOIN LATERAL aclexplode(c.relacl) a
|
|
243
|
+
LEFT JOIN pg_roles r ON r.oid = a.grantee
|
|
244
|
+
WHERE c.relkind IN ('v', 'm')
|
|
245
|
+
AND a.grantee <> c.relowner
|
|
246
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
247
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
248
|
+
AND NOT EXISTS (
|
|
249
|
+
SELECT 1 FROM pg_depend dep WHERE dep.objid = c.oid AND dep.deptype = 'e'
|
|
250
|
+
)
|
|
251
|
+
GROUP BY n.nspname, c.relname, r.rolname
|
|
252
|
+
UNION ALL
|
|
253
|
+
SELECT
|
|
254
|
+
n.nspname,
|
|
255
|
+
p.proname,
|
|
256
|
+
'f',
|
|
257
|
+
COALESCE(r.rolname, 'PUBLIC'),
|
|
258
|
+
array_agg(DISTINCT a.privilege_type ORDER BY a.privilege_type)
|
|
259
|
+
FROM pg_proc p
|
|
260
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
261
|
+
CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a
|
|
262
|
+
LEFT JOIN pg_roles r ON r.oid = a.grantee
|
|
263
|
+
WHERE p.prokind IN ('f', 'p')
|
|
264
|
+
AND a.grantee <> p.proowner
|
|
265
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
266
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
267
|
+
AND NOT EXISTS (
|
|
268
|
+
SELECT 1 FROM pg_depend dep WHERE dep.objid = p.oid AND dep.deptype = 'e'
|
|
269
|
+
)
|
|
270
|
+
GROUP BY n.nspname, p.proname, r.rolname
|
|
271
|
+
ORDER BY 1, 2, 4;
|
|
159
272
|
`.trim();
|
|
160
273
|
|
|
161
|
-
/** The reconciler's provenance claims. The table may not exist yet — callers tolerate
|
|
274
|
+
/** The reconciler's provenance claims. The table may not exist yet — callers tolerate
|
|
275
|
+
* that. `to_jsonb(p)` carries the newer columns (kind, drop_sql) version-tolerantly:
|
|
276
|
+
* a legacy database without them still SELECTs clean, the keys are simply absent. */
|
|
162
277
|
export const PROVENANCE_SQL = `
|
|
163
|
-
SELECT identity, src_hash, def_hash FROM everystack.derived_provenance;
|
|
278
|
+
SELECT identity, src_hash, def_hash, to_jsonb(p) AS extra FROM everystack.derived_provenance p;
|
|
279
|
+
`.trim();
|
|
280
|
+
|
|
281
|
+
/** User triggers (never internal/constraint machinery) with their canonical definition —
|
|
282
|
+
* the live side of the trigger reconcile. Compound identity: schema.table.name. */
|
|
283
|
+
export const DERIVED_TRIGGERS_SQL = `
|
|
284
|
+
SELECT
|
|
285
|
+
n.nspname AS schema,
|
|
286
|
+
c.relname AS "table",
|
|
287
|
+
t.tgname AS name,
|
|
288
|
+
pg_get_triggerdef(t.oid) AS definition
|
|
289
|
+
FROM pg_trigger t
|
|
290
|
+
JOIN pg_class c ON c.oid = t.tgrelid
|
|
291
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
292
|
+
WHERE NOT t.tgisinternal
|
|
293
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
294
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
295
|
+
ORDER BY n.nspname, c.relname, t.tgname;
|
|
164
296
|
`.trim();
|
|
165
297
|
|
|
166
298
|
// ---------------------------------------------------------------------------
|
|
@@ -176,6 +308,8 @@ export interface RelationRow {
|
|
|
176
308
|
populated: unknown;
|
|
177
309
|
bytes: unknown;
|
|
178
310
|
rows: unknown;
|
|
311
|
+
/** security_invoker reloption (views). Optional — pre-B5 fixtures omit it. */
|
|
312
|
+
security_invoker?: unknown;
|
|
179
313
|
}
|
|
180
314
|
|
|
181
315
|
export interface FunctionRow {
|
|
@@ -183,6 +317,14 @@ export interface FunctionRow {
|
|
|
183
317
|
name: string;
|
|
184
318
|
definition: unknown;
|
|
185
319
|
comment: unknown;
|
|
320
|
+
/** Structured signature fields (B5 pull rendering). Optional — pre-B5 fixtures omit them. */
|
|
321
|
+
args?: unknown;
|
|
322
|
+
returns?: unknown;
|
|
323
|
+
language?: unknown;
|
|
324
|
+
secdef?: unknown;
|
|
325
|
+
volatility?: unknown;
|
|
326
|
+
search_path?: unknown;
|
|
327
|
+
src?: unknown;
|
|
186
328
|
}
|
|
187
329
|
|
|
188
330
|
export interface IndexDefRow {
|
|
@@ -202,6 +344,24 @@ export interface ProvenanceRawRow {
|
|
|
202
344
|
identity: string;
|
|
203
345
|
src_hash: unknown;
|
|
204
346
|
def_hash: unknown;
|
|
347
|
+
/** `to_jsonb(p)` — the version-tolerant payload; carries kind/drop_sql when the columns exist. */
|
|
348
|
+
extra?: Record<string, unknown> | null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface TriggerRow {
|
|
352
|
+
schema: string;
|
|
353
|
+
table: string;
|
|
354
|
+
name: string;
|
|
355
|
+
definition: unknown;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface GrantAclRow {
|
|
359
|
+
schema: string;
|
|
360
|
+
name: string;
|
|
361
|
+
/** 'r' = relation (view/matview), 'f' = function — disambiguates shared names. */
|
|
362
|
+
kind: unknown;
|
|
363
|
+
grantee: string;
|
|
364
|
+
privileges: unknown;
|
|
205
365
|
}
|
|
206
366
|
|
|
207
367
|
/** sha256 over an object's normalized definition + sorted indexes + comment. */
|
|
@@ -217,6 +377,19 @@ export interface DerivedRows {
|
|
|
217
377
|
indexes: IndexDefRow[];
|
|
218
378
|
depends: DependsRow[];
|
|
219
379
|
provenance: ProvenanceRawRow[];
|
|
380
|
+
/** pg_trigger rows (user triggers only). Optional — older callers predate triggers. */
|
|
381
|
+
triggers?: TriggerRow[];
|
|
382
|
+
/** Live ACL rows (DERIVED_GRANTS_SQL). Optional — older callers predate B4's drift. */
|
|
383
|
+
grants?: GrantAclRow[];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Coerce a Postgres text[] (a JS array, or the `{a,b}` text form some drivers return) to string[]. */
|
|
387
|
+
function pgTextArray(v: unknown): string[] {
|
|
388
|
+
if (Array.isArray(v)) return v.map(String);
|
|
389
|
+
if (typeof v === 'string') {
|
|
390
|
+
return v.replace(/^\{|\}$/g, '').split(',').map((s) => s.replace(/^"|"$/g, '').trim()).filter(Boolean);
|
|
391
|
+
}
|
|
392
|
+
return [];
|
|
220
393
|
}
|
|
221
394
|
|
|
222
395
|
/**
|
|
@@ -225,6 +398,21 @@ export interface DerivedRows {
|
|
|
225
398
|
* database is byte-stable.
|
|
226
399
|
*/
|
|
227
400
|
export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
401
|
+
// Live ACLs keyed by kind-class + identity ('r' relations, 'f' functions) so a view and
|
|
402
|
+
// a function sharing a name never swap grants.
|
|
403
|
+
const grantsByKey = new Map<string, Record<string, string[]>>();
|
|
404
|
+
for (const row of rows.grants ?? []) {
|
|
405
|
+
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
406
|
+
const key = `${String(row.kind)}:${row.schema}.${row.name}`;
|
|
407
|
+
const acc = grantsByKey.get(key) ?? {};
|
|
408
|
+
acc[row.grantee] = [...new Set([...(acc[row.grantee] ?? []), ...pgTextArray(row.privileges)])].sort();
|
|
409
|
+
grantsByKey.set(key, acc);
|
|
410
|
+
}
|
|
411
|
+
const grantsFor = (kindClass: 'r' | 'f', identity: string): { grants?: Record<string, string[]> } => {
|
|
412
|
+
if (rows.grants === undefined) return {}; // pre-B4 caller — absent, not empty
|
|
413
|
+
return { grants: grantsByKey.get(`${kindClass}:${identity}`) ?? {} };
|
|
414
|
+
};
|
|
415
|
+
|
|
228
416
|
const indexesByIdentity = new Map<string, string[]>();
|
|
229
417
|
for (const row of rows.indexes) {
|
|
230
418
|
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
@@ -248,12 +436,16 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
248
436
|
definition, indexes,
|
|
249
437
|
...(comment !== undefined ? { comment } : {}),
|
|
250
438
|
defHash: computeDefHash(definition, indexes, comment),
|
|
439
|
+
...grantsFor('r', identity),
|
|
251
440
|
};
|
|
252
441
|
if (kind === 'materialized view') {
|
|
253
442
|
obj.populated = row.populated == null ? undefined : String(row.populated) !== 'false';
|
|
254
443
|
obj.bytes = row.bytes == null ? undefined : Number(row.bytes);
|
|
255
444
|
obj.rows = row.rows == null ? undefined : Math.max(0, Number(row.rows));
|
|
256
445
|
}
|
|
446
|
+
if (kind === 'view' && row.security_invoker !== undefined) {
|
|
447
|
+
obj.securityInvoker = String(row.security_invoker) === 'true' || row.security_invoker === true || row.security_invoker === 't';
|
|
448
|
+
}
|
|
257
449
|
objects.push(obj);
|
|
258
450
|
}
|
|
259
451
|
|
|
@@ -267,19 +459,61 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
267
459
|
definition, indexes: [],
|
|
268
460
|
...(comment !== undefined ? { comment } : {}),
|
|
269
461
|
defHash: computeDefHash(definition, [], comment),
|
|
462
|
+
...grantsFor('f', identity),
|
|
463
|
+
...(row.src !== undefined ? {
|
|
464
|
+
fn: {
|
|
465
|
+
args: String(row.args ?? ''),
|
|
466
|
+
returns: String(row.returns ?? ''),
|
|
467
|
+
language: String(row.language ?? 'sql'),
|
|
468
|
+
secdef: row.secdef === true || row.secdef === 't' || row.secdef === 'true',
|
|
469
|
+
volatility: String(row.volatility ?? 'v'),
|
|
470
|
+
...(row.search_path != null && String(row.search_path) !== '' ? { searchPath: String(row.search_path) } : {}),
|
|
471
|
+
src: String(row.src),
|
|
472
|
+
},
|
|
473
|
+
} : {}),
|
|
270
474
|
});
|
|
271
475
|
}
|
|
272
476
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
477
|
+
// Triggers: compound identity (schema.table.name); the table rides along for the
|
|
478
|
+
// structural drop. An edge to the OWNING relation is synthesized when that relation
|
|
479
|
+
// is itself derived (a view/matview) — so a view rebuild cascades its INSTEAD OF
|
|
480
|
+
// triggers instead of CASCADE silently eating them. Tables are state — no edge.
|
|
481
|
+
const relationIdentities = new Set(objects.map((o) => o.identity));
|
|
482
|
+
const triggerEdges: DependencyEdge[] = [];
|
|
483
|
+
for (const row of rows.triggers ?? []) {
|
|
484
|
+
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
485
|
+
const ownerIdentity = `${row.schema}.${row.table}`;
|
|
486
|
+
const identity = `${ownerIdentity}.${row.name}`;
|
|
487
|
+
const definition = String(row.definition ?? '');
|
|
488
|
+
objects.push({
|
|
489
|
+
kind: 'trigger', schema: row.schema, name: row.name, identity,
|
|
490
|
+
definition, indexes: [],
|
|
491
|
+
defHash: computeDefHash(definition, []),
|
|
492
|
+
table: row.schema === 'public' ? row.table : ownerIdentity,
|
|
493
|
+
});
|
|
494
|
+
if (relationIdentities.has(ownerIdentity)) {
|
|
495
|
+
triggerEdges.push({ dependent: identity, referenced: ownerIdentity });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const edges: DependencyEdge[] = [
|
|
500
|
+
...rows.depends
|
|
501
|
+
.filter((r) => !IGNORED_SCHEMAS.has(r.dependent_schema) && !IGNORED_SCHEMAS.has(r.referenced_schema))
|
|
502
|
+
.map((r) => ({
|
|
503
|
+
dependent: `${r.dependent_schema}.${r.dependent_name}`,
|
|
504
|
+
referenced: `${r.referenced_schema}.${r.referenced_name}`,
|
|
505
|
+
})),
|
|
506
|
+
...triggerEdges,
|
|
507
|
+
].sort((a, b) => (a.dependent + a.referenced).localeCompare(b.dependent + b.referenced));
|
|
280
508
|
|
|
281
509
|
const provenance: ProvenanceRow[] = rows.provenance
|
|
282
|
-
.map((r) => ({
|
|
510
|
+
.map((r) => ({
|
|
511
|
+
identity: r.identity,
|
|
512
|
+
srcHash: String(r.src_hash ?? ''),
|
|
513
|
+
defHash: String(r.def_hash ?? ''),
|
|
514
|
+
...(r.extra?.kind != null ? { kind: String(r.extra.kind) } : {}),
|
|
515
|
+
...(r.extra?.drop_sql != null ? { dropSql: String(r.extra.drop_sql) } : {}),
|
|
516
|
+
}))
|
|
283
517
|
.sort((a, b) => a.identity.localeCompare(b.identity));
|
|
284
518
|
|
|
285
519
|
return {
|
|
@@ -295,8 +529,9 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
|
|
|
295
529
|
* the reconciler has never touched) folds to an empty claims list.
|
|
296
530
|
*/
|
|
297
531
|
export async function introspectDerived(run: QueryRunner): Promise<DerivedCatalog> {
|
|
298
|
-
const [relations, functions, indexes, depends] = await Promise.all([
|
|
532
|
+
const [relations, functions, indexes, depends, triggers, grants] = await Promise.all([
|
|
299
533
|
run(DERIVED_RELATIONS_SQL), run(DERIVED_FUNCTIONS_SQL), run(MATVIEW_INDEXES_SQL), run(DERIVED_DEPENDS_SQL),
|
|
534
|
+
run(DERIVED_TRIGGERS_SQL), run(DERIVED_GRANTS_SQL),
|
|
300
535
|
]);
|
|
301
536
|
let provenance: ProvenanceRawRow[] = [];
|
|
302
537
|
try {
|
|
@@ -309,6 +544,8 @@ export async function introspectDerived(run: QueryRunner): Promise<DerivedCatalo
|
|
|
309
544
|
functions: functions as FunctionRow[],
|
|
310
545
|
indexes: indexes as IndexDefRow[],
|
|
311
546
|
depends: depends as DependsRow[],
|
|
547
|
+
triggers: triggers as TriggerRow[],
|
|
548
|
+
grants: grants as GrantAclRow[],
|
|
312
549
|
provenance,
|
|
313
550
|
});
|
|
314
551
|
}
|