@everystack/cli 0.4.92 → 0.4.95
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/CHANGELOG.md +76 -0
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +100 -19
- package/src/cli/commands/db-apply.ts +19 -18
- package/src/cli/commands/db-plan.ts +12 -18
- package/src/cli/commands/db-pull.ts +2 -2
- package/src/cli/derived-apply.ts +56 -20
- package/src/cli/derived-plan.ts +7 -1
- package/src/cli/exec-digest.ts +61 -16
- package/src/cli/exec-run.ts +81 -2
- package/src/cli/git-descent.ts +204 -628
- package/src/cli/index.ts +1 -1
- package/src/cli/plain-output.ts +12 -6
- package/src/cli/state-apply.ts +2 -2
- package/src/exec.ts +1 -1
package/src/cli/derived-apply.ts
CHANGED
|
@@ -273,12 +273,13 @@ export function renderEnsureObjectSchemas(objects: SourceObject[]): string[] {
|
|
|
273
273
|
// Declared-owner preflight — the refusal that beats a raw Postgres error.
|
|
274
274
|
// ---------------------------------------------------------------------------
|
|
275
275
|
|
|
276
|
-
/** One declared owner
|
|
277
|
-
*
|
|
278
|
-
*
|
|
276
|
+
/** One declared owner, every schema the batch will create an object of theirs in (`schemas`: the
|
|
277
|
+
* owner needs CREATE and USAGE there), and every other schema it only GRANTs/REVOKEs in
|
|
278
|
+
* (`grantSchemas`: USAGE alone — a GRANT names `schema.object`, and that lookup needs USAGE). */
|
|
279
279
|
export interface OwnerRequirement {
|
|
280
280
|
owner: string;
|
|
281
281
|
schemas: string[];
|
|
282
|
+
grantSchemas?: string[];
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
/**
|
|
@@ -298,12 +299,18 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
298
299
|
if (!obj?.owner) continue;
|
|
299
300
|
(byOwner.get(obj.owner) ?? byOwner.set(obj.owner, new Set()).get(obj.owner)!).add(obj.schema);
|
|
300
301
|
}
|
|
302
|
+
const grantOnly = new Map<string, Set<string>>();
|
|
301
303
|
for (const r of plan.regrants) {
|
|
302
|
-
|
|
304
|
+
const schema = srcById.get(r.identity)?.schema;
|
|
305
|
+
if (!r.owner || !schema) continue;
|
|
306
|
+
(grantOnly.get(r.owner) ?? grantOnly.set(r.owner, new Set()).get(r.owner)!).add(schema);
|
|
303
307
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
.
|
|
308
|
+
const owners = new Set([...byOwner.keys(), ...grantOnly.keys()]);
|
|
309
|
+
return [...owners].sort().map((owner) => {
|
|
310
|
+
const schemas = [...(byOwner.get(owner) ?? [])].sort();
|
|
311
|
+
const grantSchemas = [...(grantOnly.get(owner) ?? [])].filter((s) => !schemas.includes(s)).sort();
|
|
312
|
+
return { owner, schemas, ...(grantSchemas.length ? { grantSchemas } : {}) };
|
|
313
|
+
});
|
|
307
314
|
}
|
|
308
315
|
|
|
309
316
|
/**
|
|
@@ -315,11 +322,13 @@ export function ownerRequirements(plan: ReconcilePlan, source: SourceObject[]):
|
|
|
315
322
|
* and the privilege only evaluated when it is really there.
|
|
316
323
|
*/
|
|
317
324
|
export function ownerPreflightSql(requirements: OwnerRequirement[]): string {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
.map((s) => `(${escapeLiteral(r.owner)}, ${
|
|
325
|
+
const rowsFor = (r: OwnerRequirement) => [
|
|
326
|
+
...r.schemas.map((s) => `(${escapeLiteral(r.owner)}, ${escapeLiteral(s)}, true)`),
|
|
327
|
+
...(r.grantSchemas ?? []).map((s) => `(${escapeLiteral(r.owner)}, ${escapeLiteral(s)}, false)`),
|
|
328
|
+
];
|
|
329
|
+
const triples = requirements.flatMap(rowsFor);
|
|
321
330
|
return `
|
|
322
|
-
WITH declared(owner_name, schema_name) AS (VALUES ${
|
|
331
|
+
WITH declared(owner_name, schema_name, needs_create) AS (VALUES ${triples.join(', ')})
|
|
323
332
|
SELECT
|
|
324
333
|
CURRENT_USER AS builder,
|
|
325
334
|
-- Which mechanism the applier may use. A SUPERUSER (RDS: an rds_superuser member) can hand an
|
|
@@ -335,12 +344,15 @@ SELECT
|
|
|
335
344
|
) AS builder_is_superuser,
|
|
336
345
|
d.owner_name,
|
|
337
346
|
d.schema_name,
|
|
347
|
+
d.needs_create,
|
|
338
348
|
(r.oid IS NOT NULL) AS role_exists,
|
|
339
349
|
(n.oid IS NOT NULL) AS schema_exists,
|
|
340
350
|
r.rolsuper AS owner_is_superuser,
|
|
341
351
|
CASE WHEN r.oid IS NULL THEN NULL ELSE pg_has_role(CURRENT_USER, r.oid, 'SET') END AS can_set_role,
|
|
342
352
|
CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN NULL
|
|
343
|
-
ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create
|
|
353
|
+
ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create,
|
|
354
|
+
CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN NULL
|
|
355
|
+
ELSE has_schema_privilege(r.oid, n.oid, 'USAGE') END AS owner_can_use
|
|
344
356
|
FROM declared d
|
|
345
357
|
LEFT JOIN pg_roles r ON r.rolname = d.owner_name
|
|
346
358
|
LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
|
|
@@ -352,13 +364,15 @@ export interface OwnerPreflightRow {
|
|
|
352
364
|
builder?: unknown;
|
|
353
365
|
builder_is_superuser?: unknown;
|
|
354
366
|
owner_name: string;
|
|
355
|
-
|
|
356
|
-
|
|
367
|
+
schema_name: string;
|
|
368
|
+
/** False for a schema the owner only GRANTs/REVOKEs in — USAGE is checked, CREATE is not. */
|
|
369
|
+
needs_create?: unknown;
|
|
357
370
|
role_exists?: unknown;
|
|
358
371
|
schema_exists?: unknown;
|
|
359
372
|
owner_is_superuser?: unknown;
|
|
360
373
|
can_set_role?: unknown;
|
|
361
374
|
owner_can_create?: unknown;
|
|
375
|
+
owner_can_use?: unknown;
|
|
362
376
|
}
|
|
363
377
|
|
|
364
378
|
const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
|
|
@@ -377,7 +391,7 @@ export function builderOwnerMode(rows: OwnerPreflightRow[]): OwnerApplyMode {
|
|
|
377
391
|
/**
|
|
378
392
|
* The refusal text, or null when every declared owner is usable.
|
|
379
393
|
*
|
|
380
|
-
*
|
|
394
|
+
* Four ways a declared owner fails, all named at once with the exact statement that fixes each:
|
|
381
395
|
*
|
|
382
396
|
* 1. THE ROLE DOES NOT EXIST. The build refuses and does NOT create it. A role is
|
|
383
397
|
* cluster-scoped and a security principal, and a principal invented by a tool cannot know
|
|
@@ -389,6 +403,9 @@ export function builderOwnerMode(rows: OwnerPreflightRow[]): OwnerApplyMode {
|
|
|
389
403
|
* 3. THE OWNER CANNOT CREATE IN THE SCHEMA. Same mechanism — the CREATE happens AS the owner,
|
|
390
404
|
* so the owner needs CREATE on the schema the object lives in. A schema that does not exist
|
|
391
405
|
* yet counts: this build creates it as the BUILDER, and the owner will not hold CREATE on it.
|
|
406
|
+
* 4. THE OWNER CANNOT USE THE SCHEMA. Every GRANT/REVOKE it runs on its objects (attachments,
|
|
407
|
+
* a replace's revokes, regrants, the settle pass) names `schema.object`, and resolving that
|
|
408
|
+
* needs USAGE — checked for schemas it creates in AND schemas it only grants in.
|
|
392
409
|
*/
|
|
393
410
|
export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null {
|
|
394
411
|
const builder = rows.find((r) => r.builder != null)?.builder;
|
|
@@ -396,6 +413,7 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
396
413
|
const missing = new Set<string>();
|
|
397
414
|
const unassumable = new Set<string>();
|
|
398
415
|
const noCreate: Array<{ owner: string; schema: string; exists: boolean }> = [];
|
|
416
|
+
const noUsage: Array<{ owner: string; schema: string }> = [];
|
|
399
417
|
|
|
400
418
|
// A superuser builder creates as itself and ALTERs the owner, so it needs neither membership in
|
|
401
419
|
// the owner nor CREATE on the owner's behalf. Refusing on either would be a false refusal that
|
|
@@ -411,16 +429,28 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
411
429
|
// A superuser owner holds CREATE everywhere, including in a schema this build is about to
|
|
412
430
|
// make — there is nothing to refuse.
|
|
413
431
|
if (truthy(row.owner_is_superuser)) continue;
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
432
|
+
const schema = String(row.schema_name);
|
|
433
|
+
const needsCreate = truthy(row.needs_create);
|
|
434
|
+
if (!truthy(row.schema_exists)) {
|
|
435
|
+
// Named whole, in one round: the owner will need both once the builder creates the schema.
|
|
436
|
+
if (needsCreate) {
|
|
437
|
+
noCreate.push({ owner, schema, exists: false });
|
|
438
|
+
noUsage.push({ owner, schema });
|
|
439
|
+
}
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (needsCreate && !truthy(row.owner_can_create)) noCreate.push({ owner, schema, exists: true });
|
|
443
|
+
// Every GRANT/REVOKE the owner runs names `schema.object`, and resolving it needs USAGE. An
|
|
444
|
+
// owned function always has one (its REVOKE ALL … FROM PUBLIC), so this was a mid-batch
|
|
445
|
+
// `permission denied for schema` before it was a refusal.
|
|
446
|
+
if (!truthy(row.owner_can_use)) noUsage.push({ owner, schema });
|
|
417
447
|
}
|
|
418
448
|
|
|
419
|
-
if (!missing.size && !unassumable.size && !noCreate.length) return null;
|
|
449
|
+
if (!missing.size && !unassumable.size && !noCreate.length && !noUsage.length) return null;
|
|
420
450
|
|
|
421
451
|
const lines: string[] = [
|
|
422
452
|
`declared owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied.`,
|
|
423
|
-
`The one-command fix is \`everystack db:provision
|
|
453
|
+
`The one-command fix is \`everystack db:provision\` (USAGE needs @everystack/server >= 0.4.33 on the stage): it ENACTS these grants (and ADOPTS any`,
|
|
424
454
|
`pre-existing objects owned by the master) from the declared model, so you never hand-write them.`,
|
|
425
455
|
`The exact statements, if you must run them by hand:`,
|
|
426
456
|
];
|
|
@@ -443,6 +473,12 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
|
|
|
443
473
|
` GRANT CREATE ON SCHEMA ${schema} TO ${owner};`,
|
|
444
474
|
);
|
|
445
475
|
}
|
|
476
|
+
for (const { owner, schema } of noUsage) {
|
|
477
|
+
lines.push(
|
|
478
|
+
` NO USAGE ON SCHEMA '${owner}' cannot use schema "${schema}" — its GRANT/REVOKE on the objects there name the schema:`,
|
|
479
|
+
` GRANT USAGE ON SCHEMA ${schema} TO ${owner};`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
446
482
|
return lines.join('\n');
|
|
447
483
|
}
|
|
448
484
|
|
package/src/cli/derived-plan.ts
CHANGED
|
@@ -509,7 +509,13 @@ export function planReconcile(
|
|
|
509
509
|
// Rebaselined objects join the lens: their live catalog rows are as real as a skip's,
|
|
510
510
|
// and running the checks NOW means a migration converges (grants ride the same apply)
|
|
511
511
|
// instead of surfacing on the next plan.
|
|
512
|
-
|
|
512
|
+
//
|
|
513
|
+
// Baselined objects join it too. A baseline trusts the STRUCTURE it cannot verify (source text
|
|
514
|
+
// and live deparse live in different hash spaces); the grants it CAN verify, so it never trusts
|
|
515
|
+
// them. Without this, an object restored with default privileges — every function EXECUTE to
|
|
516
|
+
// PUBLIC, SECURITY DEFINER ones included — was adopted with its open grants recorded as fine.
|
|
517
|
+
// (needsBaseline objects stay out: nothing is adopted, so nothing is touched.)
|
|
518
|
+
const lensSet = new Set([...skipped, ...rebaseline, ...backfill, ...baseline]);
|
|
513
519
|
for (const src of source.objects) {
|
|
514
520
|
if (!lensSet.has(src.identity)) continue;
|
|
515
521
|
if (src.kind === 'trigger' || src.kind === 'sql') continue; // no grants, no rewrite edges
|
package/src/cli/exec-digest.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* exec-digest — the catalog content-address that makes db:exec DML-only WITHOUT a role, a
|
|
3
3
|
* superuser, or a regex. A self-contained SQL query that md5s the schema-relevant catalog
|
|
4
|
-
* (pg_class / pg_attribute / pg_constraint / pg_index / pg_proc / pg_type
|
|
4
|
+
* (pg_class / pg_attribute / pg_constraint / pg_index / pg_proc / pg_type, plus the behaviour
|
|
5
|
+
* attached to relations — pg_rewrite / pg_trigger / pg_attrdef — and the database-wide
|
|
6
|
+
* pg_event_trigger / pg_extension) for the given app
|
|
5
7
|
* schemas. db:exec takes this digest before and after the file inside one transaction; any
|
|
6
8
|
* difference is a schema change — however it was caused (a CREATE/ALTER/DROP, a function, a DO
|
|
7
9
|
* block, dynamic SQL) — and rolls the whole transaction back.
|
|
@@ -66,7 +68,7 @@ FROM (
|
|
|
66
68
|
-- but CAN change who may read it is not a DML-only lane. None of the three move under DML.
|
|
67
69
|
SELECT format('rel:%s.%s:%s:%s:%s:%s:%s', n.nspname, c.relname, c.relkind, c.relpersistence,
|
|
68
70
|
coalesce(c.relacl::text, ''), c.relrowsecurity, c.relforcerowsecurity) AS line
|
|
69
|
-
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
71
|
+
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
70
72
|
WHERE ${NS} AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
|
|
71
73
|
UNION ALL
|
|
72
74
|
-- Row policies: a CREATE/ALTER/DROP POLICY rewrites who sees which rows and touches no other
|
|
@@ -74,32 +76,32 @@ FROM (
|
|
|
74
76
|
SELECT format('pol:%s.%s.%s:%s:%s:%s:%s:%s', n.nspname, c.relname, pol.polname, pol.polcmd,
|
|
75
77
|
pol.polpermissive,
|
|
76
78
|
coalesce((SELECT string_agg(r.rolname, ',' ORDER BY r.rolname)
|
|
77
|
-
FROM pg_roles r WHERE r.oid = ANY(pol.polroles)), 'PUBLIC'),
|
|
79
|
+
FROM pg_catalog.pg_roles r WHERE r.oid = ANY(pol.polroles)), 'PUBLIC'),
|
|
78
80
|
coalesce(pg_get_expr(pol.polqual, pol.polrelid), ''),
|
|
79
81
|
coalesce(pg_get_expr(pol.polwithcheck, pol.polrelid), ''))
|
|
80
|
-
FROM pg_policy pol
|
|
81
|
-
JOIN pg_class c ON c.oid = pol.polrelid
|
|
82
|
-
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
82
|
+
FROM pg_catalog.pg_policy pol
|
|
83
|
+
JOIN pg_catalog.pg_class c ON c.oid = pol.polrelid
|
|
84
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
83
85
|
WHERE ${NS}
|
|
84
86
|
UNION ALL
|
|
85
87
|
-- Columns: number, type, typmod, not-null — the shape ALTER TABLE moves, DML never.
|
|
86
88
|
SELECT format('att:%s.%s.%s:%s:%s:%s:%s',
|
|
87
89
|
n.nspname, c.relname, a.attname, a.attnum, a.atttypid, a.atttypmod, a.attnotnull)
|
|
88
|
-
FROM pg_attribute a
|
|
89
|
-
JOIN pg_class c ON c.oid = a.attrelid
|
|
90
|
-
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
90
|
+
FROM pg_catalog.pg_attribute a
|
|
91
|
+
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
92
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
91
93
|
WHERE ${NS} AND a.attnum > 0 AND NOT a.attisdropped
|
|
92
94
|
UNION ALL
|
|
93
95
|
-- Constraints: the full textual definition (PK/FK/unique/check predicate).
|
|
94
96
|
SELECT format('con:%s.%s:%s', n.nspname, con.conname, pg_get_constraintdef(con.oid))
|
|
95
|
-
FROM pg_constraint con JOIN pg_namespace n ON n.oid = con.connamespace
|
|
97
|
+
FROM pg_catalog.pg_constraint con JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
|
|
96
98
|
WHERE ${NS}
|
|
97
99
|
UNION ALL
|
|
98
100
|
-- Indexes: the full textual definition (columns, uniqueness, partial WHERE).
|
|
99
101
|
SELECT format('idx:%s.%s:%s', n.nspname, ic.relname, pg_get_indexdef(i.indexrelid))
|
|
100
|
-
FROM pg_index i
|
|
101
|
-
JOIN pg_class ic ON ic.oid = i.indexrelid
|
|
102
|
-
JOIN pg_namespace n ON n.oid = ic.relnamespace
|
|
102
|
+
FROM pg_catalog.pg_index i
|
|
103
|
+
JOIN pg_catalog.pg_class ic ON ic.oid = i.indexrelid
|
|
104
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = ic.relnamespace
|
|
103
105
|
WHERE ${NS}
|
|
104
106
|
UNION ALL
|
|
105
107
|
-- Routines: name + arg types + a hash of the body, PLUS proacl (the EXECUTE grants) and
|
|
@@ -107,14 +109,57 @@ FROM (
|
|
|
107
109
|
-- that opens one, is a privilege change and must trip the guard like any other.
|
|
108
110
|
SELECT format('proc:%s.%s:%s:%s:%s:%s', n.nspname, p.proname, p.proargtypes::text,
|
|
109
111
|
md5(coalesce(p.prosrc, '')), coalesce(p.proacl::text, ''), p.prosecdef)
|
|
110
|
-
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
112
|
+
FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
111
113
|
WHERE ${NS}
|
|
112
114
|
UNION ALL
|
|
115
|
+
-- Behaviour ATTACHED to a relation — no column, constraint or index moves, so each of these
|
|
116
|
+
-- passed the guard until the closing-gate review (2026-09-24) proved it: a RULE on the ledger
|
|
117
|
+
-- could retire every future restore witness at birth. Rules (view bodies included: a view
|
|
118
|
+
-- redefinition is DDL too), user triggers, and column defaults, by their full definitions.
|
|
119
|
+
SELECT format('rule:%s.%s.%s:%s', n.nspname, c.relname, r.rulename, md5(pg_get_ruledef(r.oid)))
|
|
120
|
+
FROM pg_catalog.pg_rewrite r
|
|
121
|
+
JOIN pg_catalog.pg_class c ON c.oid = r.ev_class
|
|
122
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
123
|
+
WHERE ${NS}
|
|
124
|
+
UNION ALL
|
|
125
|
+
SELECT format('trg:%s.%s.%s:%s:%s', n.nspname, c.relname, t.tgname, t.tgenabled, md5(pg_get_triggerdef(t.oid)))
|
|
126
|
+
FROM pg_catalog.pg_trigger t
|
|
127
|
+
JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
|
|
128
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
129
|
+
WHERE ${NS} AND NOT t.tgisinternal
|
|
130
|
+
UNION ALL
|
|
131
|
+
SELECT format('def:%s.%s.%s:%s', n.nspname, c.relname, a.attname, pg_get_expr(d.adbin, d.adrelid))
|
|
132
|
+
FROM pg_catalog.pg_attrdef d
|
|
133
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
|
134
|
+
JOIN pg_catalog.pg_class c ON c.oid = d.adrelid
|
|
135
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
136
|
+
WHERE ${NS}
|
|
137
|
+
UNION ALL
|
|
138
|
+
-- Database-wide behaviour: event triggers fire on every DDL, extensions install whole schemas.
|
|
139
|
+
SELECT format('evt:%s:%s:%s:%s', e.evtname, e.evtevent, e.evtenabled, e.evtfoid::regproc::text)
|
|
140
|
+
FROM pg_catalog.pg_event_trigger e
|
|
141
|
+
UNION ALL
|
|
142
|
+
SELECT format('ext:%s:%s', x.extname, x.extversion)
|
|
143
|
+
FROM pg_catalog.pg_extension x
|
|
144
|
+
UNION ALL
|
|
145
|
+
-- TEMP triggers and functions. Temp TABLES stay out (ordinary scripts stage data in them), but a
|
|
146
|
+
-- DML file never needs a trigger or a function of its own — and one on a temp relation is how a
|
|
147
|
+
-- file runs code at COMMIT, after the checks (closing-gate review round 3).
|
|
148
|
+
SELECT format('ttrg:%s.%s:%s', c.relname, t.tgname, md5(pg_get_triggerdef(t.oid)))
|
|
149
|
+
FROM pg_catalog.pg_trigger t
|
|
150
|
+
JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
|
|
151
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
152
|
+
WHERE n.nspname LIKE 'pg_temp%' AND NOT t.tgisinternal
|
|
153
|
+
UNION ALL
|
|
154
|
+
SELECT format('tproc:%s:%s', p.proname, md5(coalesce(p.prosrc, '')))
|
|
155
|
+
FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
156
|
+
WHERE n.nspname LIKE 'pg_temp%'
|
|
157
|
+
UNION ALL
|
|
113
158
|
-- Types: kind + ordered enum labels. CREATE TYPE / ALTER TYPE ADD VALUE move it.
|
|
114
159
|
SELECT format('type:%s.%s:%s:%s', n.nspname, t.typname, t.typtype,
|
|
115
160
|
coalesce((SELECT string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder)
|
|
116
|
-
FROM pg_enum e WHERE e.enumtypid = t.oid), ''))
|
|
117
|
-
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
161
|
+
FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid), ''))
|
|
162
|
+
FROM pg_catalog.pg_type t JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
118
163
|
WHERE ${NS}
|
|
119
164
|
) s
|
|
120
165
|
`.trim();
|
package/src/cli/exec-run.ts
CHANGED
|
@@ -46,6 +46,74 @@ export class SchemaChangedError extends Error {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The ledger violation: the file wrote to the `everystack` schema. Those tables are what every
|
|
51
|
+
* safety decision reads — schema_log is the descent anchor and a restore's witness, derived_provenance
|
|
52
|
+
* decides what the reconciler trusts, exec_log is this lane's own audit. A DML lane that can write
|
|
53
|
+
* them can forge any of it, so the whole transaction was rolled back.
|
|
54
|
+
*/
|
|
55
|
+
export class LedgerWriteError extends Error {
|
|
56
|
+
constructor(detail: string) {
|
|
57
|
+
super(
|
|
58
|
+
`db:exec cannot write the everystack schema (${detail}) — its tables are the ledgers every safety check reads, ` +
|
|
59
|
+
'and a write here could forge one. The whole transaction was rolled back; nothing was applied.',
|
|
60
|
+
);
|
|
61
|
+
this.name = 'LedgerWriteError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Rows written to everystack.* by THIS transaction (pg_stat_xact_* counts the open transaction), and
|
|
67
|
+
* each table's relfilenode — TRUNCATE touches no row counter but always gives the table a new file.
|
|
68
|
+
* Semantic, like the catalog digest: it reads what happened, not what the SQL text looks like.
|
|
69
|
+
*/
|
|
70
|
+
export const LEDGER_GUARD_SQL = `
|
|
71
|
+
SELECT coalesce(sum(s.n_tup_ins + s.n_tup_upd + s.n_tup_del), 0)::bigint AS writes,
|
|
72
|
+
coalesce(string_agg(c.relname || ':' || c.relfilenode::text, ',' ORDER BY c.relname), '') AS files,
|
|
73
|
+
-- A table that INHERITS from a ledger table (a temp one is enough) makes its rows read as
|
|
74
|
+
-- ledger rows, and its writes count against the child. Ledger tables never have children.
|
|
75
|
+
(SELECT count(*) FROM pg_catalog.pg_inherits i JOIN pg_catalog.pg_class p ON p.oid = i.inhparent
|
|
76
|
+
JOIN pg_catalog.pg_namespace pn ON pn.oid = p.relnamespace WHERE pn.nspname = 'everystack')::int AS children,
|
|
77
|
+
pg_catalog.current_setting('track_counts') AS track_counts
|
|
78
|
+
FROM pg_catalog.pg_class c
|
|
79
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
80
|
+
LEFT JOIN pg_catalog.pg_stat_xact_user_tables s ON s.relid = c.oid
|
|
81
|
+
WHERE n.nspname = 'everystack' AND c.relkind IN ('r', 'p')`.trim();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fire every DEFERRED constraint trigger NOW. A deferred trigger runs at COMMIT — after both
|
|
85
|
+
* checks — so a file could hide DDL or a ledger write inside one (closing-gate review round 2).
|
|
86
|
+
* Made immediate here, it fires inside the window the checks read.
|
|
87
|
+
*/
|
|
88
|
+
export const FIRE_DEFERRED_SQL = 'SET CONSTRAINTS ALL IMMEDIATE';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The digest reads `pg_get_*def` / `pg_get_expr` text, which qualifies names relative to the
|
|
92
|
+
* search_path: a file that sets its own path would move the digest with no schema change (a
|
|
93
|
+
* serial default prints `nextval('t_id_seq')` or `nextval('public.t_id_seq')`). Pin the path for
|
|
94
|
+
* the read, then give the file its own back.
|
|
95
|
+
*/
|
|
96
|
+
// pg_temp named LAST: unnamed, PostgreSQL searches it FIRST for relations, and a file's temp view
|
|
97
|
+
// called pg_class would answer the check (closing-gate review round 3). Every catalog the checks
|
|
98
|
+
// read is also written pg_catalog.x; this pin is the second lock.
|
|
99
|
+
const PATH_SAVE_SQL = `SELECT pg_catalog.current_setting('search_path') AS path`;
|
|
100
|
+
const PATH_PIN_SQL = `SELECT pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true)`;
|
|
101
|
+
const pathRestoreSql = (path: string): string => `SELECT pg_catalog.set_config('search_path', '${path.replace(/'/g, "''")}', true)`;
|
|
102
|
+
|
|
103
|
+
/** Run a check query under the pinned path, then give the file its own path back. */
|
|
104
|
+
async function pinned(run: TxRunner, query: string): Promise<ExecStmtResult> {
|
|
105
|
+
const saved = (await run(PATH_SAVE_SQL)).rows?.[0]?.path;
|
|
106
|
+
await run(PATH_PIN_SQL);
|
|
107
|
+
const res = await run(query); // a failure aborts the transaction: nothing to restore, keep its error
|
|
108
|
+
if (typeof saved === 'string') await run(pathRestoreSql(saved));
|
|
109
|
+
return res;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const pinnedDigest = async (run: TxRunner, digestQuery: string): Promise<string> => readDigest(await pinned(run, digestQuery));
|
|
113
|
+
|
|
114
|
+
/** A WITH HOLD cursor finishes its query at COMMIT — after the checks. Close them all first. */
|
|
115
|
+
export const CLOSE_CURSORS_SQL = 'CLOSE ALL';
|
|
116
|
+
|
|
49
117
|
/** Transaction rails — a file can't hang the plane or block behind a lock forever. */
|
|
50
118
|
export interface ExecRails {
|
|
51
119
|
statementTimeoutMs?: number;
|
|
@@ -87,7 +155,8 @@ export async function runExecInTx(
|
|
|
87
155
|
for (const rail of railStatements(opts.rails)) await run(rail);
|
|
88
156
|
|
|
89
157
|
const digestQuery = catalogDigestQuery(opts.schemas);
|
|
90
|
-
const before =
|
|
158
|
+
const before = await pinnedDigest(run, digestQuery);
|
|
159
|
+
const ledgerBefore = (await pinned(run, LEDGER_GUARD_SQL)).rows?.[0] ?? {};
|
|
91
160
|
|
|
92
161
|
const rowsAffected: number[] = [];
|
|
93
162
|
for (const stmt of splitSqlStatements(fileSql)) {
|
|
@@ -95,8 +164,18 @@ export async function runExecInTx(
|
|
|
95
164
|
rowsAffected.push(res.count);
|
|
96
165
|
}
|
|
97
166
|
|
|
98
|
-
|
|
167
|
+
await run(FIRE_DEFERRED_SQL);
|
|
168
|
+
await run(CLOSE_CURSORS_SQL);
|
|
169
|
+
const after = await pinnedDigest(run, digestQuery);
|
|
99
170
|
if (before !== after) throw new SchemaChangedError(before, after);
|
|
171
|
+
const ledgerAfter = (await pinned(run, LEDGER_GUARD_SQL)).rows?.[0] ?? {};
|
|
172
|
+
if (String(ledgerAfter.track_counts ?? 'on') === 'off') {
|
|
173
|
+
throw new LedgerWriteError('track_counts was turned off, so ledger writes could not be counted');
|
|
174
|
+
}
|
|
175
|
+
if (Number(ledgerAfter.children ?? 0) > 0) throw new LedgerWriteError('a table inherits from a ledger table');
|
|
176
|
+
const wrote = Number(ledgerAfter.writes ?? 0) - Number(ledgerBefore.writes ?? 0);
|
|
177
|
+
if (wrote > 0) throw new LedgerWriteError(`${wrote} row(s) written`);
|
|
178
|
+
if (String(ledgerAfter.files ?? '') !== String(ledgerBefore.files ?? '')) throw new LedgerWriteError('a table was truncated or rewritten');
|
|
100
179
|
return rowsAffected;
|
|
101
180
|
}
|
|
102
181
|
|