@everystack/cli 0.4.93 → 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 +25 -0
- package/package.json +1 -1
- 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-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/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
|
|