@everystack/cli 0.4.93 → 0.4.96

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.
@@ -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) for the given app
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();
@@ -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 = readDigest(await run(digestQuery));
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
- const after = readDigest(await run(digestQuery));
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