@substrat-run/cli 0.44.0 → 0.45.0

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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The data-loss check a push makes before upload (#2321): the vertical's authored migrations,
3
+ * replayed against an in-memory scope that holds data, through the data-loss fence (#115).
4
+ *
5
+ * The fence refuses, at apply, a migration that would lose data a scope holds and does not
6
+ * declare it — which closes every populated scope until a fixed version ships. In this repository
7
+ * `lint:migration-fence` asks the question in the pull request; a vertical built anywhere else
8
+ * has only this, the push, as the earlier and cheaper place to hear no.
9
+ *
10
+ * The replay is the VERTICAL's own kernel's (`replayMigrations`), reached at run time the way
11
+ * `moduleMigrations` is (#1677): it is the copy of the fence the vertical's scopes run at apply,
12
+ * so it is the one whose answer matters, and this CLI stays Apache-2.0 with no kernel dependency.
13
+ * What this module supplies is only what the kernel does not import: an empty in-memory SQLite
14
+ * (`node:sqlite`), and the platform's spine tables from the vertical's own
15
+ * `@substrat-run/adapter-sqlite`, when it has one, for an authored migration that reads them.
16
+ *
17
+ * It runs in a process of its own (`data-loss-replay-child.ts`), with an empty environment and a
18
+ * time limit per migration, because a runaway statement — a recursive CTE with no end — runs in
19
+ * native code nothing in this process could interrupt. The fence refuses ATTACH, so the replay
20
+ * never reaches a file; it opens no network connection and reads none of the vertical's data.
21
+ *
22
+ * Three outcomes, never confused:
23
+ * - REFUSED — the fence would refuse a migration. The push stops, with the fence's own message
24
+ * naming the declaration (`moves` / `discards`) to add. There is no flag past it: a `discards`
25
+ * entry with its reason is the reviewed way to lose data on purpose.
26
+ * - INCOMPLETE — a migration could not be run on synthetic rows at all (its SQL needs real input,
27
+ * reads a spine table this CLI could not build, or did not finish in time). The replay stops
28
+ * there, so the migrations after it go unchecked, and the warning says how many.
29
+ * - SKIPPED — nothing could be replayed: no `node:sqlite` (Node before 22.13), or a kernel with
30
+ * no replay this CLI knows how to call.
31
+ * The last two do not refuse by default — promote is the hard gate, and the fence still refuses
32
+ * there — unless the caller asks for the guarantee (`--require-data-loss-check`).
33
+ */
34
+ import { spawn } from 'node:child_process';
35
+ import { tmpdir } from 'node:os';
36
+ import { fileURLToPath } from 'node:url';
37
+ /** The replay API this CLI calls — `MIGRATION_REPLAY_API` in `@substrat-run/kernel`. */
38
+ const REPLAY_API = 1;
39
+ /**
40
+ * How long one migration may run. A Durable Object would stop a migration that ran this long
41
+ * on its own CPU limit, so a replay that has not finished by then is not one worth waiting for.
42
+ */
43
+ export const MIGRATION_TIMEOUT_MS = 30_000;
44
+ const isOutcome = (o) => {
45
+ const r = o;
46
+ const stop = (s) => s === undefined || (typeof s.key === 'string' && typeof s.message === 'string');
47
+ return (typeof r === 'object' && r !== null &&
48
+ typeof r.authored === 'number' && typeof r.passed === 'number' &&
49
+ Array.isArray(r.declared) && r.declared.every((d) => typeof d === 'string') &&
50
+ stop(r.refused) && stop(r.failed));
51
+ };
52
+ /** The built child, beside this module in `dist`. */
53
+ const CHILD = fileURLToPath(new URL('./data-loss-replay-child.js', import.meta.url));
54
+ /**
55
+ * Run the replay in its own process, killing it when one migration runs past `timeoutMs`. Each
56
+ * `started` line restarts the clock, so the limit is per migration, and says which one was
57
+ * running when it fired and how many authored migrations had passed before it.
58
+ */
59
+ export function replayInChild(request, opts = {}) {
60
+ const timeoutMs = opts.timeoutMs ?? MIGRATION_TIMEOUT_MS;
61
+ // The child's stderr is kept, never forwarded: node's notice that `node:sqlite` is experimental
62
+ // stays out of the push log, and only a process that dies without answering is shown it.
63
+ const child = spawn(process.execPath, [opts.childPath ?? CHILD], {
64
+ cwd: tmpdir(),
65
+ // Nothing of the pusher's environment: the replay needs none of it.
66
+ env: process.platform === 'win32' && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {},
67
+ stdio: ['pipe', 'pipe', 'pipe'],
68
+ });
69
+ return new Promise((resolveResult) => {
70
+ let settled = false;
71
+ let key;
72
+ let authoredStarted = 0;
73
+ let runningAuthored = false;
74
+ let stdout = '';
75
+ let stderr = '';
76
+ let timer;
77
+ // A push that ends any other way — it exits, throws, or is killed by a signal, SIGKILL
78
+ // included — takes the replay with it through the child's own watchdog
79
+ // (`data-loss-replay-child.ts`), which no handler here could do for SIGKILL. On POSIX; on
80
+ // Windows, where an orphan is not re-parented, only the child's hard deadline stops it.
81
+ const settle = (result) => {
82
+ if (settled)
83
+ return;
84
+ settled = true;
85
+ clearTimeout(timer);
86
+ if (child.exitCode !== null || child.signalCode !== null) {
87
+ resolveResult(result);
88
+ return;
89
+ }
90
+ // Answered only once the process is gone, so nothing the replay started outlives it.
91
+ child.once('exit', () => resolveResult(result));
92
+ child.kill('SIGKILL');
93
+ };
94
+ const arm = () => {
95
+ clearTimeout(timer);
96
+ timer = setTimeout(() => settle({ kind: 'timeout', key, passed: authoredStarted - (runningAuthored ? 1 : 0) }), timeoutMs);
97
+ };
98
+ child.stdout.setEncoding('utf8');
99
+ child.stdout.on('data', (chunk) => {
100
+ stdout += chunk;
101
+ let nl;
102
+ while ((nl = stdout.indexOf('\n')) >= 0) {
103
+ const line = stdout.slice(0, nl);
104
+ stdout = stdout.slice(nl + 1);
105
+ let message;
106
+ try {
107
+ message = JSON.parse(line);
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ if (message.type === 'started') {
113
+ key = message.key;
114
+ runningAuthored = message.authored;
115
+ if (message.authored)
116
+ authoredStarted += 1;
117
+ arm();
118
+ }
119
+ else if (message.type === 'done') {
120
+ settle(message.result);
121
+ }
122
+ }
123
+ });
124
+ child.stderr.setEncoding('utf8');
125
+ child.stderr.on('data', (chunk) => {
126
+ stderr = (stderr + chunk).slice(-2000);
127
+ });
128
+ child.on('error', (e) => settle({ kind: 'error', message: e.message }));
129
+ // `close`, not `exit`: it fires once stdout has drained, so a last `done` line is never lost.
130
+ child.on('close', (code, signal) => settle({ kind: 'error', message: `the replay process ended (${signal ?? `exit ${code}`}) without an answer${stderr ? `: ${stderr.trim()}` : ''}` }));
131
+ arm();
132
+ child.stdin.on('error', () => { });
133
+ child.stdin.end(JSON.stringify(request));
134
+ });
135
+ }
136
+ const DOCS = 'see https://substrat.net/concepts/deploying ("A migration cannot quietly lose data")';
137
+ const TRUTHY = /^(1|true|yes)$/i;
138
+ const FALSY = /^(0|false|no)?$/i;
139
+ /**
140
+ * Is the check required to have run: the flag, or `SUBSTRAT_REQUIRE_DATA_LOSS_CHECK` set to `1`,
141
+ * `true` or `yes` (any case). Any other non-empty value is warned about rather than read as no,
142
+ * because a CI job that meant yes would otherwise get the permissive default without a word.
143
+ */
144
+ export function dataLossCheckRequired(argv, env = process.env, warn = console.error) {
145
+ if (argv.includes('--require-data-loss-check'))
146
+ return true;
147
+ const value = (env.SUBSTRAT_REQUIRE_DATA_LOSS_CHECK ?? '').trim();
148
+ if (TRUTHY.test(value))
149
+ return true;
150
+ if (!FALSY.test(value)) {
151
+ warn(`⚠ SUBSTRAT_REQUIRE_DATA_LOSS_CHECK=${value} is not one of 1/true/yes or 0/false/no — read as no, so the data-loss check stays optional`);
152
+ }
153
+ return false;
154
+ }
155
+ /** The one summary line, the same in the check's own output and the push's last line. */
156
+ export function formatDataLossCheck(check) {
157
+ switch (check.status) {
158
+ case 'passed':
159
+ return `data-loss check: PASSED (${check.authored} authored migration(s) replayed against a populated scope)`;
160
+ case 'skipped':
161
+ return `data-loss check: SKIPPED (${check.why}) — the fence still refuses at promote`;
162
+ case 'incomplete':
163
+ return (`data-loss check: INCOMPLETE (${check.notReplayed} of ${check.authored} authored migration(s) were not replayed ` +
164
+ `after ${check.key} failed) — the fence still refuses at promote`);
165
+ }
166
+ }
167
+ /**
168
+ * Replay the vertical's migrations through its own kernel's fence. Throws when the fence would
169
+ * refuse one, and — with `require` — when the check was skipped or incomplete. Otherwise returns
170
+ * the outcome, having printed each declared move and discard with its reason (`log`) and the
171
+ * summary (`log` when passed, `warn` — stderr — when it is not).
172
+ */
173
+ export async function checkMigrationDataLoss(inputs, opts = {}) {
174
+ const log = opts.log ?? console.log;
175
+ const warn = opts.warn ?? console.error;
176
+ const check = await replay(inputs, opts, log);
177
+ const line = formatDataLossCheck(check);
178
+ if (check.status === 'passed') {
179
+ log(line);
180
+ return check;
181
+ }
182
+ if (check.status === 'incomplete') {
183
+ warn(`⚠ ${check.key} could not be replayed on synthetic data: ${check.message}`);
184
+ if (/no such table: _substrat_/i.test(check.message)) {
185
+ warn(' it reads a platform table this CLI could not build: add @substrat-run/adapter-sqlite as a devDependency ' +
186
+ 'so the replay can create them.');
187
+ }
188
+ }
189
+ if (opts.require) {
190
+ throw new Error(`${line}.\n--require-data-loss-check (or SUBSTRAT_REQUIRE_DATA_LOSS_CHECK=1) refuses a push it could not fully check.`);
191
+ }
192
+ warn(`⚠ ${line}`);
193
+ return check;
194
+ }
195
+ async function replay(inputs, opts, log) {
196
+ const kernel = inputs.kernel;
197
+ const authoredCount = inputs.modules.reduce((n, m) => {
198
+ const migrations = m.migrations;
199
+ return n + (Array.isArray(migrations) ? migrations.length : 0);
200
+ }, 0);
201
+ // Nothing authored, nothing the fence judges — and no reason to need SQLite.
202
+ if (authoredCount === 0)
203
+ return { status: 'passed', authored: 0 };
204
+ if (!kernel || !inputs.kernelUrl)
205
+ return { status: 'skipped', why: 'no @substrat-run/kernel resolves from the vertical' };
206
+ if (typeof kernel.replayMigrations !== 'function') {
207
+ return { status: 'skipped', why: "the vertical's @substrat-run/kernel has no migration replay (upgrade it)" };
208
+ }
209
+ if (kernel.MIGRATION_REPLAY_API !== REPLAY_API) {
210
+ return {
211
+ status: 'skipped',
212
+ why: `the vertical's @substrat-run/kernel has migration replay API ${String(kernel.MIGRATION_REPLAY_API)}, this CLI calls ${REPLAY_API} (upgrade the CLI)`,
213
+ };
214
+ }
215
+ // The registrations as data — what the replay reads of them, without the handlers no
216
+ // process boundary can carry.
217
+ const modules = JSON.parse(JSON.stringify(inputs.modules.map((m) => ({ manifest: m.manifest, migrations: m.migrations }))));
218
+ const timeoutMs = opts.timeoutMs ?? MIGRATION_TIMEOUT_MS;
219
+ // The child's own backstop, far past anything the per-migration limit allows: every authored
220
+ // migration and up to three derived ones per module (search, entity state, list), each at the
221
+ // limit, plus one for the start.
222
+ const deadlineMs = timeoutMs * (authoredCount + 3 * modules.length + 1);
223
+ const result = await replayInChild({ kernelUrl: inputs.kernelUrl, modules, spineDdl: await inputs.spineDdl(), deadlineMs }, { ...opts, timeoutMs });
224
+ if (result.kind === 'no-sqlite') {
225
+ return { status: 'skipped', why: `node:sqlite unavailable on Node ${process.versions.node}; it needs 22.13 or later` };
226
+ }
227
+ if (result.kind === 'error')
228
+ return { status: 'skipped', why: `the replay itself failed: ${result.message}` };
229
+ if (result.kind === 'timeout') {
230
+ return {
231
+ status: 'incomplete',
232
+ authored: authoredCount,
233
+ notReplayed: authoredCount - result.passed,
234
+ key: result.key ?? 'the replay',
235
+ message: `it did not finish within ${timeoutMs / 1000} s and was stopped — a statement with no end, such as a recursive CTE ` +
236
+ "without a limit? A Durable Object's own CPU limit would stop it at promote too",
237
+ };
238
+ }
239
+ const outcome = result.outcome;
240
+ if (!isOutcome(outcome))
241
+ return { status: 'skipped', why: "the vertical's kernel returned a replay outcome this CLI does not recognise" };
242
+ for (const line of outcome.declared)
243
+ log(`declared ${line}`);
244
+ if (outcome.refused) {
245
+ throw new Error(`data-loss check: REFUSED — ${outcome.refused.message}\n` +
246
+ 'A scope holding that data would refuse this migration at promote and fail closed. Fix the migration, or ' +
247
+ `declare the loss on it (\`moves\` / \`discards\`, with its reason) for review — ${DOCS}.`);
248
+ }
249
+ if (outcome.failed) {
250
+ return {
251
+ status: 'incomplete',
252
+ authored: outcome.authored,
253
+ notReplayed: outcome.authored - outcome.passed,
254
+ key: outcome.failed.key,
255
+ message: outcome.failed.message,
256
+ };
257
+ }
258
+ return { status: 'passed', authored: outcome.authored };
259
+ }
260
+ //# sourceMappingURL=data-loss-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-loss-check.js","sourceRoot":"","sources":["../src/data-loss-check.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ export interface ReplayRequest {
2
+ readonly kernelUrl: string;
3
+ readonly modules: readonly unknown[];
4
+ readonly spineDdl?: string;
5
+ /** Wall-clock limit for the whole replay, after which the child kills itself — a backstop. */
6
+ readonly deadlineMs: number;
7
+ }
8
+ /** How often the watchdog looks at its parent. */
9
+ export declare const WATCHDOG_POLL_MS = 200;
10
+ /** Each stdout line, in order: zero or more `started`, then one `done`. */
11
+ export type ChildMessage = {
12
+ readonly type: 'started';
13
+ readonly key: string;
14
+ readonly authored: boolean;
15
+ } | {
16
+ readonly type: 'done';
17
+ readonly result: {
18
+ readonly kind: 'outcome';
19
+ readonly outcome: unknown;
20
+ } | {
21
+ readonly kind: 'no-sqlite';
22
+ } | {
23
+ readonly kind: 'error';
24
+ readonly message: string;
25
+ };
26
+ };
27
+ //# sourceMappingURL=data-loss-replay-child.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-loss-replay-child.d.ts","sourceRoot":"","sources":["../src/data-loss-replay-child.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,OAAO,EAAE,CAAC;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,8FAA8F;IAC9F,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,kDAAkD;AAClD,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAepC,2EAA2E;AAC3E,MAAM,MAAM,YAAY,GACpB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,GAC9E;IACE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EACX;QAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;KAAE,GACvD;QAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;KAAE,GAC9B;QAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1D,CAAC"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The data-loss replay's own process (#2321): what `checkMigrationDataLoss` runs the vertical's
3
+ * migrations in, so a runaway one can be stopped. A SQLite statement runs in native code that no
4
+ * JavaScript timer and no `worker.terminate()` can interrupt, and this Node's `node:sqlite` has no
5
+ * progress handler; a process can always be killed. See `replayInChild` for the parent's half.
6
+ *
7
+ * Reads one request as JSON on stdin — the vertical's kernel URL, its modules as data, the spine
8
+ * DDL — and writes one JSON line per message to stdout, synchronously, so the line naming the
9
+ * migration about to run is out before that migration can block the process. Started with an empty
10
+ * environment, in the temp directory, against an in-memory database.
11
+ *
12
+ * It must not outlive the push that started it, however that push ends. The parent kills it on a
13
+ * timeout, but a parent killed by a signal runs no handler at all, and an orphaned replay blocked
14
+ * in a SQLite step would spin forever. So the child carries a watchdog of
15
+ * its own: a worker thread — which a blocked main thread cannot starve — that kills this process
16
+ * when its parent is gone (the parent pid changes as the OS re-parents it) or the request's hard
17
+ * deadline passes. Covered: the parent exiting, crashing, or being killed by any signal, SIGKILL
18
+ * included, within the watchdog's poll interval. Not covered: a parent that is alive but stopped
19
+ * (SIGSTOP) — the replay then runs until the deadline — and, because the parent check relies on
20
+ * POSIX re-parenting an orphan, Windows, where only the deadline stops an orphaned replay.
21
+ */
22
+ import { writeSync } from 'node:fs';
23
+ import { Worker } from 'node:worker_threads';
24
+ /** How often the watchdog looks at its parent. */
25
+ export const WATCHDOG_POLL_MS = 200;
26
+ /** Kill this process when the parent that started it is gone, or `deadlineMs` has passed. */
27
+ function watchdog(deadlineMs) {
28
+ const worker = new Worker(`const { workerData: { parent, until, every } } = require('node:worker_threads');
29
+ setInterval(() => {
30
+ if (process.ppid !== parent || Date.now() > until) process.kill(process.pid, 'SIGKILL');
31
+ }, every);`, { eval: true, workerData: { parent: process.ppid, until: Date.now() + deadlineMs, every: WATCHDOG_POLL_MS } });
32
+ // The watchdog never keeps a finished replay alive.
33
+ worker.unref();
34
+ }
35
+ const send = (message) => {
36
+ writeSync(1, `${JSON.stringify(message)}\n`);
37
+ };
38
+ async function main() {
39
+ const chunks = [];
40
+ for await (const chunk of process.stdin)
41
+ chunks.push(chunk);
42
+ const request = JSON.parse(Buffer.concat(chunks).toString('utf8'));
43
+ watchdog(request.deadlineMs);
44
+ let open;
45
+ try {
46
+ const { DatabaseSync } = (await import('node:sqlite'));
47
+ open = () => new DatabaseSync(':memory:');
48
+ }
49
+ catch {
50
+ send({ type: 'done', result: { kind: 'no-sqlite' } });
51
+ return;
52
+ }
53
+ try {
54
+ const kernel = (await import(request.kernelUrl));
55
+ const db = open();
56
+ const outcome = kernel.replayMigrations({
57
+ exec: (q) => db.exec(q),
58
+ query: (q, p = []) => db.prepare(q).all(...p),
59
+ run: (q, p = []) => ({ changes: Number(db.prepare(q).run(...p).changes) }),
60
+ }, request.modules, {
61
+ spineDdl: request.spineDdl,
62
+ onMigration: (key, authored) => send({ type: 'started', key, authored }),
63
+ });
64
+ send({ type: 'done', result: { kind: 'outcome', outcome } });
65
+ }
66
+ catch (e) {
67
+ send({ type: 'done', result: { kind: 'error', message: e instanceof Error ? e.message : String(e) } });
68
+ }
69
+ }
70
+ await main();
71
+ //# sourceMappingURL=data-loss-replay-child.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-loss-replay-child.js","sourceRoot":"","sources":["../src/data-loss-replay-child.ts"],"names":[],"mappings":"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"}
package/dist/promote.d.ts CHANGED
@@ -12,9 +12,14 @@
12
12
  * #1705 PR 3: a promote that drops or re-versions an exported event an installed app imports
13
13
  * is refused too (409), and the refusal lists the apps it would break: the caller's own tenant's
14
14
  * by name, any other tenant only as a count. `--ack-export-break` passes it once that is read.
15
+ *
16
+ * #2333: an acknowledgement answers ONE review — the diffs printed against the version prod served
17
+ * when they were read. Every refusal that prints them also prints the `--review` token they were
18
+ * read with, and the control plane refuses a token whose base or stored rows have moved since
19
+ * (and a `--ack-migrations` sent with none), so a person never acknowledges a diff they were not shown.
15
20
  */
16
21
  import { type MigrationDiff, type PermissionRegistry } from '@substrat-run/contracts';
17
- import { type ExportBreaks, type MintedStore, type PromoteResult } from '@substrat-run/control-plane-client';
22
+ import { type ExportBreaks, type MintedStore, type PromoteResult, type PromoteReviewBinding } from '@substrat-run/control-plane-client';
18
23
  export interface PromoteOptions {
19
24
  controlPlaneUrl: string;
20
25
  header: Record<string, string>;
@@ -25,8 +30,13 @@ export interface PromoteOptions {
25
30
  permissionChange?: boolean;
26
31
  migrationChange?: boolean;
27
32
  exportBreak?: boolean;
33
+ review?: PromoteReviewBinding;
28
34
  };
29
35
  }
36
+ /** How a review binding is written on the command line: `<base>:<digest>`, `-` for no base. */
37
+ export declare function reviewToken(review: PromoteReviewBinding): string;
38
+ /** `reviewToken` read back, or null when it is not one. The digest is hex, so the last `:` splits it. */
39
+ export declare function parseReviewToken(token: string): PromoteReviewBinding | null;
30
40
  /** The listing a refused (or acknowledged) export break carries, one line per affected app. */
31
41
  export declare function exportBreakLines(b: ExportBreaks): string[];
32
42
  /** The listing a refusal prints, and how to answer it once it has been read. */
@@ -53,6 +63,11 @@ export declare function explainRefusal(opts: PromoteOptions, refused: {
53
63
  * "none" is never the answer, because the gate has just said there is a change.
54
64
  */
55
65
  export declare function formatRegistryDiff(from: PermissionRegistry | null, to: PermissionRegistry | null, digestMoved: boolean): string[];
66
+ /**
67
+ * A module id or migration version on the listing's header line: bare when it is a plain id, as
68
+ * every id the push boundary admits is, and `quoted` otherwise, so nothing in it can break the line.
69
+ */
70
+ export declare const idOf: (text: string) => string;
56
71
  /**
57
72
  * The migrations a promote adds, each with its SQL. `null` is a version whose manifest carries
58
73
  * none. `digestMoved` is whether the gate refused on the migration digest.
@@ -1 +1 @@
1
- {"version":3,"file":"promote.d.ts","sourceRoot":"","sources":["../src/promote.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAKL,KAAK,aAAa,EAElB,KAAK,kBAAkB,EACxB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,aAAa,EACnB,MAAM,oCAAoC,CAAC;AAI5C,MAAM,WAAW,cAAc;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,OAAO,CAAC;QAAC,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,WAAW,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CAChG;AAED,+FAA+F;AAC/F,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,EAAE,CAQ1D;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,CAE1D;AAED,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,CAAC;AAEzD,wBAAsB,OAAO,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAkC1E;AAOD;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE;IAAE,UAAU,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GACnD,OAAO,CAAC,MAAM,EAAE,CAAC,CA8BnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI,EAAE,EAAE,EAAE,kBAAkB,GAAG,IAAI,EAAE,WAAW,EAAE,OAAO,GAAG,MAAM,EAAE,CA0BjI;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,EAAE,WAAW,EAAE,OAAO,GAAG,MAAM,EAAE,CAyB9F"}
1
+ {"version":3,"file":"promote.d.ts","sourceRoot":"","sources":["../src/promote.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAUL,KAAK,aAAa,EAElB,KAAK,kBAAkB,EACxB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EAC1B,MAAM,oCAAoC,CAAC;AAI5C,MAAM,WAAW,cAAc;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,OAAO,CAAC;QAAC,eAAe,CAAC,EAAE,OAAO,CAAC;QAAC,WAAW,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,oBAAoB,CAAA;KAAE,CAAC;CAC/H;AAED,+FAA+F;AAC/F,wBAAgB,WAAW,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CAEhE;AAED,yGAAyG;AACzG,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,oBAAoB,GAAG,IAAI,CAM3E;AAED,+FAA+F;AAC/F,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,EAAE,CAQ1D;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,YAAY,GAAG,MAAM,CAE1D;AAED,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,CAAC;AAEzD,wBAAsB,OAAO,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAqC1E;AAOD;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE;IAAE,UAAU,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GACnD,OAAO,CAAC,MAAM,EAAE,CAAC,CAmCnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI,EAAE,EAAE,EAAE,kBAAkB,GAAG,IAAI,EAAE,WAAW,EAAE,OAAO,GAAG,MAAM,EAAE,CA0BjI;AAYD;;;GAGG;AACH,eAAO,MAAM,IAAI,SAAU,MAAM,KAAG,MAAqD,CAAC;AAwB1F;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,EAAE,WAAW,EAAE,OAAO,GAAG,MAAM,EAAE,CA8C9F"}
package/dist/promote.js CHANGED
@@ -12,11 +12,29 @@
12
12
  * #1705 PR 3: a promote that drops or re-versions an exported event an installed app imports
13
13
  * is refused too (409), and the refusal lists the apps it would break: the caller's own tenant's
14
14
  * by name, any other tenant only as a count. `--ack-export-break` passes it once that is read.
15
+ *
16
+ * #2333: an acknowledgement answers ONE review — the diffs printed against the version prod served
17
+ * when they were read. Every refusal that prints them also prints the `--review` token they were
18
+ * read with, and the control plane refuses a token whose base or stored rows have moved since
19
+ * (and a `--ack-migrations` sent with none), so a person never acknowledges a diff they were not shown.
15
20
  */
16
- import { diffRegistries, grantShapeHolderNotes, unitemisedRegistryChanges, } from '@substrat-run/contracts';
21
+ import { diffRegistries, grantShapeHolderNotes, promoteReviewReasonIn, clipReviewText, discardTally, reviewSql, reviewText, unitemisedRegistryChanges, } from '@substrat-run/contracts';
17
22
  import { ControlPlaneError, } from '@substrat-run/control-plane-client';
18
23
  import { planeFor, viaPlane, walkAll } from './plane.js';
19
24
  import { failureMessage, readRefused } from './problem.js';
25
+ /** How a review binding is written on the command line: `<base>:<digest>`, `-` for no base. */
26
+ export function reviewToken(review) {
27
+ return `${review.base ?? '-'}:${review.digest}`;
28
+ }
29
+ /** `reviewToken` read back, or null when it is not one. The digest is hex, so the last `:` splits it. */
30
+ export function parseReviewToken(token) {
31
+ const at = token.lastIndexOf(':');
32
+ const base = token.slice(0, at);
33
+ const digest = token.slice(at + 1);
34
+ if (at <= 0 || !/^[0-9a-f]{64}$/.test(digest))
35
+ return null;
36
+ return { base: base === '-' ? null : base, digest };
37
+ }
20
38
  /** The listing a refused (or acknowledged) export break carries, one line per affected app. */
21
39
  export function exportBreakLines(b) {
22
40
  const lines = b.affected.map((r) => ` ${r.vertical} (scope ${r.scopeId}) imports ${r.type} v${r.schemaVersion} — ` +
@@ -59,11 +77,14 @@ export async function promote(opts) {
59
77
  ? `\n (${parsed.exportBreaksUnavailable}; re-run with --ack-export-break to promote anyway)`
60
78
  : '';
61
79
  const message = failureMessage('promote failed', e.status, body);
62
- if (!message.includes(NEEDS_ACK))
80
+ // A review that moved under the acknowledgement, or a migration acknowledgement sent with none
81
+ // (#2333), is answered like a digest refusal: by the diffs as they stand now, and their token.
82
+ const review = promoteReviewReasonIn(parsed);
83
+ if (!message.includes(NEEDS_ACK) && review === undefined)
63
84
  throw new Error(message + listing);
64
85
  const lines = await explainRefusal(opts, {
65
86
  permission: message.includes('changes the permission surface'),
66
- migration: message.includes('changes migrations'),
87
+ migration: message.includes('changes migrations') || review === 'promote_review_unbound',
67
88
  });
68
89
  throw new Error(`${message}${listing}\n\n${lines.join('\n')}`);
69
90
  }
@@ -89,21 +110,26 @@ export async function explainRefusal(opts, refused) {
89
110
  if (!serving)
90
111
  return ['(nothing serves this channel yet, so there is nothing to diff against)'];
91
112
  const registry = (id) => get(() => client.getVersionRegistry(opts.slug, id)).then((r) => r.registry);
92
- const [from, to, migrations] = await Promise.all([
113
+ const [from, to, read] = await Promise.all([
93
114
  registry(serving),
94
115
  registry(opts.versionId),
95
- get(() => client.getVersionMigrations(opts.slug, opts.versionId, serving)).then((r) => r.migrations),
116
+ get(() => client.getVersionMigrations(opts.slug, opts.versionId, serving)),
96
117
  ]);
118
+ // The token is the one the migrations read answered beside the diff printed here, never one
119
+ // put together from separate reads: the two must describe the same review.
120
+ const token = read.review ? reviewToken(read.review) : null;
97
121
  return [
98
- `${opts.channel} serves ${serving}; promoting ${opts.versionId}.`,
122
+ `${opts.channel} serves ${idOf(serving)}; promoting ${idOf(opts.versionId)}.`,
99
123
  '',
100
124
  'permission changes:',
101
125
  ...formatRegistryDiff(from, to, refused.permission).map((l) => ` ${l}`),
102
126
  '',
103
127
  'migration changes:',
104
- ...formatMigrationDiff(migrations, refused.migration).map((l) => ` ${l}`),
128
+ ...formatMigrationDiff(read.migrations, refused.migration).map((l) => ` ${l}`),
105
129
  '',
106
- 'note: the registry does not yet require --ack-migrations for a change to SQL migrations alone (#1754).',
130
+ token === null
131
+ ? '(this control plane hands out no review token, so --review cannot be given)'
132
+ : `to acknowledge exactly what is shown above, re-run with the --ack-* flags it needs and --review ${idOf(token)}`,
107
133
  ];
108
134
  }
109
135
  catch (e) {
@@ -146,6 +172,40 @@ export function formatRegistryDiff(from, to, digestMoved) {
146
172
  ? ["the permission digest changed, but the two stored registries read the same here — compare them in the repository"]
147
173
  : ['none'];
148
174
  }
175
+ /**
176
+ * Pusher-supplied text on one terminal line (#2322): every control, format and bidi character
177
+ * made visible (`reviewText`), then quoted, which escapes the newlines and tabs it keeps. So no
178
+ * reason, table name or id prints as a terminal command, a reordered word or a forged line.
179
+ */
180
+ const quoted = (text) => JSON.stringify(reviewText(text));
181
+ /** What an id may hold to print bare in the listing's one-line header. */
182
+ const PLAIN_ID = /^[A-Za-z0-9@/._~:+-]+$/;
183
+ /**
184
+ * A module id or migration version on the listing's header line: bare when it is a plain id, as
185
+ * every id the push boundary admits is, and `quoted` otherwise, so nothing in it can break the line.
186
+ */
187
+ export const idOf = (text) => (PLAIN_ID.test(text) ? text : quoted(text));
188
+ /**
189
+ * A discard's reason as the listing shows it: quoted, and cut where the review cuts it
190
+ * (`clipReviewText`: 280 characters or 6 lines), saying where the rest is. A terminal has no
191
+ * expander, and the listing must stay readable down to the acknowledgement.
192
+ */
193
+ function reasonOf(why) {
194
+ const { shown, clipped } = clipReviewText(why);
195
+ return clipped ? `${JSON.stringify(shown)}… (cut; the whole reason is in the dashboard's promote dialog and the migration's source)` : quoted(why);
196
+ }
197
+ /**
198
+ * What one migration declares it moves and discards (#2322), one line each under its id.
199
+ * Every target and reason is the pusher's text, so each goes through `quoted`.
200
+ */
201
+ function declarationLines(m) {
202
+ if (m.declarationsLeftOut)
203
+ return [' (declares moves or discards, left out: past the size bound of this answer; read them in the repository)'];
204
+ return [
205
+ ...Object.entries(m.discards ?? {}).map(([target, why]) => ` discards ${quoted(target)}: ${reasonOf(why)}`),
206
+ ...Object.entries(m.moves ?? {}).map(([from, to]) => ` moves ${quoted(from)} → ${quoted(to)} (verified at apply)`),
207
+ ];
208
+ }
149
209
  /**
150
210
  * The migrations a promote adds, each with its SQL. `null` is a version whose manifest carries
151
211
  * none. `digestMoved` is whether the gate refused on the migration digest.
@@ -157,15 +217,31 @@ export function formatMigrationDiff(diff, digestMoved) {
157
217
  'older than migrations in the manifest, or its migrations were over the size a manifest carries. Read them in the repository.',
158
218
  ];
159
219
  }
220
+ // The id and the SQL are the pusher's text too. The header is one line, so the module and the
221
+ // version print bare only when they are plain ids and are quoted otherwise (`idOf`): a newline
222
+ // in either would forge another entry above the real one. The SQL is split into its lines after
223
+ // `reviewSql`, so a control or bidi character in it prints as `<U+XXXX>`, not as itself.
160
224
  const entry = (mark, m) => [
161
- `${mark} ${m.moduleId} ${m.version}`,
162
- ...(m.sql === null ? [' (SQL left out: past the size bound of this answer)'] : m.sql.split('\n').map((l) => ` ${l}`)),
225
+ `${mark} ${idOf(m.moduleId)} ${idOf(m.version)}`,
226
+ ...declarationLines(m),
227
+ ...(m.sql === null ? [' (SQL left out: past the size bound of this answer)'] : reviewSql(m.sql).split('\n').map((l) => ` ${l}`)),
163
228
  ];
229
+ const discards = discardTally(diff);
164
230
  const lines = [
231
+ ...(discards.count > 0
232
+ ? [
233
+ `! this promote deliberately discards data in ${discards.atLeast ? 'at least ' : ''}${discards.count} place${discards.count === 1 ? '' : 's'}; ` +
234
+ "each reason is the migration author's own",
235
+ ]
236
+ : discards.atLeast
237
+ ? ["! this list is cut, so it cannot show whether a migration left out of it discards data; read the rest in the repository"]
238
+ : []),
165
239
  ...(diff.baseline === 'unavailable'
166
240
  ? ['(the serving version predates carried migrations, so every migration this version ships is listed)']
167
241
  : []),
168
- ...diff.changed.flatMap((m) => entry('~ (edited after shipping; a scope that already ran it will not run it again)', m)),
242
+ ...diff.changed.flatMap((m) => entry(m.edited === 'declarations'
243
+ ? '~ (declarations edited after shipping; a scope that already ran it is unaffected, one that has not is held to these)'
244
+ : '~ (edited after shipping; a scope that already ran it will not run it again)', m)),
169
245
  ...diff.added.flatMap((m) => entry('+', m)),
170
246
  ];
171
247
  if (diff.total === 0) {