@rehearsal-db/core 0.1.0-beta.1 → 0.1.0-beta.10
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 +244 -1
- package/COMPATIBILITY.md +17 -0
- package/README.md +139 -346
- package/SECURITY.md +2 -1
- package/SUPPORT.md +5 -0
- package/docs/README.md +29 -0
- package/docs/adapters.md +20 -6
- package/docs/architecture.md +58 -0
- package/docs/baselines.md +102 -22
- package/docs/commands.md +196 -33
- package/docs/configuration.md +258 -13
- package/docs/getting-started.md +106 -182
- package/docs/glossary.md +11 -8
- package/docs/production-source.md +176 -33
- package/docs/releasing.md +28 -38
- package/docs/roadmap.md +41 -0
- package/docs/runtime-policies.md +193 -0
- package/docs/sanitization.md +43 -8
- package/docs/security-model.md +34 -9
- package/docs/standalone-workflow.md +107 -0
- package/docs/troubleshooting.md +102 -2
- package/docs/tutorial.md +70 -59
- package/package.json +30 -22
- package/scripts/runtime/manage_database.mjs +18 -0
- package/src/README.md +17 -0
- package/src/application/session.mjs +435 -0
- package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +142 -17
- package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +19 -10
- package/src/baseline/input_discovery.mjs +155 -0
- package/src/baseline/policy_review.mjs +92 -0
- package/src/baseline/preparation.mjs +323 -0
- package/src/baseline/privacy_engine.mjs +413 -0
- package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +133 -3
- package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
- package/src/cli/arguments.mjs +123 -0
- package/src/cli/guided.mjs +812 -0
- package/src/cli/rehearsal.mjs +997 -0
- package/src/cli/renderers.mjs +584 -0
- package/src/cli/runtime_commands.mjs +624 -0
- package/src/cli/source_commands.mjs +326 -0
- package/src/cli/terminal.mjs +275 -0
- package/src/identity/claim.mjs +975 -0
- package/src/identity/storage.mjs +165 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +72 -4
- package/src/project/configuration.mjs +1199 -0
- package/src/project/setup.mjs +379 -0
- package/src/project/support_report.mjs +105 -0
- package/src/runtime/cleanup.mjs +381 -0
- package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +118 -20
- package/src/runtime/policy.mjs +438 -0
- package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +46 -27
- package/src/runtime/topology.mjs +177 -0
- package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +10 -3
- package/src/shared/human_output.mjs +4 -0
- package/src/shared/operation_guard.mjs +162 -0
- package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
- package/src/source/access.mjs +578 -0
- package/src/source/asset_transfer.mjs +177 -0
- package/src/source/baseline.mjs +446 -0
- package/src/source/postgresql_access.mjs +480 -0
- package/src/targets/postgresql.mjs +808 -0
- package/{scripts/operations/database/manage_rehearsal_database.mjs → src/targets/supabase.mjs} +66 -20
- package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +60 -28
- package/src/targets/target.mjs +66 -0
- package/scripts/lib/rehearsal/configuration.mjs +0 -559
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -565
- /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/** Launch ordinary application commands against verified local runtime files. */
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { parseEnv } from "node:util";
|
|
6
|
+
import {
|
|
7
|
+
createCleanProcessEnvironment,
|
|
8
|
+
isLoopbackUrl,
|
|
9
|
+
} from "../shared/process_environment.mjs";
|
|
10
|
+
|
|
11
|
+
const ENV_KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
12
|
+
const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
|
|
13
|
+
const SESSION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGTSTP"];
|
|
14
|
+
|
|
15
|
+
const parseCommand = (source) => {
|
|
16
|
+
if (typeof source !== "string" || !source.trim())
|
|
17
|
+
throw new Error("Application command is empty.");
|
|
18
|
+
const values = [];
|
|
19
|
+
let value = "";
|
|
20
|
+
let quote = null;
|
|
21
|
+
for (const character of source) {
|
|
22
|
+
if (quote) {
|
|
23
|
+
if (character === quote) quote = null;
|
|
24
|
+
else value += character;
|
|
25
|
+
} else if (character === '"' || character === "'") quote = character;
|
|
26
|
+
else if (/\s/u.test(character)) {
|
|
27
|
+
if (value) values.push(value);
|
|
28
|
+
value = "";
|
|
29
|
+
} else value += character;
|
|
30
|
+
}
|
|
31
|
+
if (quote)
|
|
32
|
+
throw new Error("Application command contains an unmatched quote.");
|
|
33
|
+
if (value) values.push(value);
|
|
34
|
+
return values;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const readOwnedEnvironment = async (path) => {
|
|
38
|
+
const details = await stat(path);
|
|
39
|
+
if (!details.isFile() || (details.mode & 0o077) !== 0) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Runtime environment file must be an owner-only regular file.",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return parseEnv(await readFile(path, "utf8"));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const assertLocalValue = (key, value) => {
|
|
48
|
+
if (/(_URL|_URI|_ENDPOINT)$/u.test(key)) {
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = new URL(value);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error(`Mapped runtime variable ${key} is not a URL.`);
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
!isLoopbackUrl(value) &&
|
|
57
|
+
!["postgres:", "postgresql:"].includes(parsed.protocol)
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(`Mapped runtime variable ${key} is not local.`);
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
["postgres:", "postgresql:"].includes(parsed.protocol) &&
|
|
63
|
+
!["127.0.0.1", "::1", "localhost"].includes(parsed.hostname)
|
|
64
|
+
) {
|
|
65
|
+
throw new Error(`Mapped runtime variable ${key} is not local.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const buildApplicationEnvironment = async ({ files, mappings }) => {
|
|
71
|
+
if (!files?.primary)
|
|
72
|
+
throw new Error("Primary runtime environment file is required.");
|
|
73
|
+
const environments = {};
|
|
74
|
+
for (const [name, path] of Object.entries(files)) {
|
|
75
|
+
environments[name] = await readOwnedEnvironment(path);
|
|
76
|
+
}
|
|
77
|
+
if (!mappings || typeof mappings !== "object" || Array.isArray(mappings)) {
|
|
78
|
+
throw new Error("Application environment mappings must be an object.");
|
|
79
|
+
}
|
|
80
|
+
const result = {};
|
|
81
|
+
for (const [destination, reference] of Object.entries(mappings)) {
|
|
82
|
+
if (!ENV_KEY.test(destination) || typeof reference !== "string") {
|
|
83
|
+
throw new Error("Application environment mapping is invalid.");
|
|
84
|
+
}
|
|
85
|
+
const separator = reference.indexOf(":");
|
|
86
|
+
if (separator < 1)
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Application mapping ${destination} has an invalid reference.`,
|
|
89
|
+
);
|
|
90
|
+
const target = reference.slice(0, separator);
|
|
91
|
+
const source = reference.slice(separator + 1);
|
|
92
|
+
if (!ENV_KEY.test(source) || !environments[target]?.[source]) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Application mapping ${destination} references a missing runtime value.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
assertLocalValue(source, environments[target][source]);
|
|
98
|
+
result[destination] = environments[target][source];
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const boundedAppend = (current, chunk) =>
|
|
104
|
+
`${current}${chunk}`.slice(-MAX_DIAGNOSTIC_BYTES);
|
|
105
|
+
|
|
106
|
+
const childHasExited = (child) =>
|
|
107
|
+
child.exitCode !== null || child.signalCode !== null;
|
|
108
|
+
|
|
109
|
+
const waitForChildExit = (child) => {
|
|
110
|
+
if (childHasExited(child)) {
|
|
111
|
+
return Promise.resolve({
|
|
112
|
+
status: child.exitCode,
|
|
113
|
+
signal: child.signalCode,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
child.once("exit", (status, signal) => resolve({ status, signal }));
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const summarizeProjectCommandFailure = ({
|
|
122
|
+
error,
|
|
123
|
+
status,
|
|
124
|
+
signal,
|
|
125
|
+
stdout = "",
|
|
126
|
+
stderr = "",
|
|
127
|
+
stdoutByteCount,
|
|
128
|
+
stderrByteCount,
|
|
129
|
+
}) => {
|
|
130
|
+
const stdoutBytes = stdoutByteCount ?? Buffer.byteLength(String(stdout));
|
|
131
|
+
const stderrBytes = stderrByteCount ?? Buffer.byteLength(String(stderr));
|
|
132
|
+
if (error) {
|
|
133
|
+
const code = /^[A-Z0-9_]+$/u.test(error.code ?? "")
|
|
134
|
+
? ` (${error.code})`
|
|
135
|
+
: "";
|
|
136
|
+
return `command could not start${code}; output withheld`;
|
|
137
|
+
}
|
|
138
|
+
const outcome = signal
|
|
139
|
+
? `signal ${signal}`
|
|
140
|
+
: `exit ${Number.isInteger(status) ? status : "unknown"}`;
|
|
141
|
+
return `${outcome}; stdout ${stdoutBytes} bytes; stderr ${stderrBytes} bytes; output withheld`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const waitForReadiness = async ({ readiness, fetchImplementation, child }) => {
|
|
145
|
+
if (!readiness || !isLoopbackUrl(readiness.url)) {
|
|
146
|
+
throw new Error("Application readiness must use a loopback URL.");
|
|
147
|
+
}
|
|
148
|
+
const expected = readiness.expectedStatus ?? 200;
|
|
149
|
+
const deadline = Date.now() + (readiness.timeoutSeconds ?? 30) * 1_000;
|
|
150
|
+
let lastStatus = null;
|
|
151
|
+
while (Date.now() < deadline) {
|
|
152
|
+
if (childHasExited(child))
|
|
153
|
+
throw new Error("Application exited before becoming ready.");
|
|
154
|
+
try {
|
|
155
|
+
const response = await fetchImplementation(readiness.url, {
|
|
156
|
+
redirect: "manual",
|
|
157
|
+
signal: AbortSignal.timeout(2_000),
|
|
158
|
+
});
|
|
159
|
+
lastStatus = response.status;
|
|
160
|
+
if (response.status === expected)
|
|
161
|
+
return { url: readiness.url, status: response.status };
|
|
162
|
+
} catch {
|
|
163
|
+
// A bounded retry is expected while the local process starts.
|
|
164
|
+
}
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
166
|
+
}
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Application readiness timed out${lastStatus === null ? "" : ` after HTTP ${lastStatus}`}.`,
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const stopOwnedChild = async (child) => {
|
|
173
|
+
if (childHasExited(child)) return;
|
|
174
|
+
const exit = waitForChildExit(child);
|
|
175
|
+
const kill = (signal) => {
|
|
176
|
+
try {
|
|
177
|
+
if (process.platform === "win32") child.kill(signal);
|
|
178
|
+
else process.kill(-child.pid, signal);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error?.code !== "ESRCH") throw error;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
kill("SIGTERM");
|
|
184
|
+
await Promise.race([
|
|
185
|
+
exit,
|
|
186
|
+
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
187
|
+
]);
|
|
188
|
+
if (!childHasExited(child)) {
|
|
189
|
+
kill("SIGKILL");
|
|
190
|
+
await exit;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const startApplicationSession = async ({
|
|
195
|
+
command,
|
|
196
|
+
cwd,
|
|
197
|
+
files,
|
|
198
|
+
mappings,
|
|
199
|
+
readiness,
|
|
200
|
+
inheritedEnvironment = process.env,
|
|
201
|
+
spawnImplementation = spawn,
|
|
202
|
+
fetchImplementation = fetch,
|
|
203
|
+
signalTarget = process,
|
|
204
|
+
}) => {
|
|
205
|
+
const mapped = await buildApplicationEnvironment({ files, mappings });
|
|
206
|
+
const [program, ...args] = parseCommand(command);
|
|
207
|
+
const child = spawnImplementation(program, args, {
|
|
208
|
+
cwd,
|
|
209
|
+
env: createCleanProcessEnvironment({
|
|
210
|
+
inheritedEnvironment,
|
|
211
|
+
overrides: mapped,
|
|
212
|
+
}),
|
|
213
|
+
detached: process.platform !== "win32",
|
|
214
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
215
|
+
});
|
|
216
|
+
let stdout = "";
|
|
217
|
+
let stderr = "";
|
|
218
|
+
child.stdout?.on("data", (chunk) => {
|
|
219
|
+
stdout = boundedAppend(stdout, chunk.toString("utf8"));
|
|
220
|
+
});
|
|
221
|
+
child.stderr?.on("data", (chunk) => {
|
|
222
|
+
stderr = boundedAppend(stderr, chunk.toString("utf8"));
|
|
223
|
+
});
|
|
224
|
+
await new Promise((resolve, reject) => {
|
|
225
|
+
child.once("spawn", resolve);
|
|
226
|
+
child.once("error", reject);
|
|
227
|
+
});
|
|
228
|
+
let interruptStartup;
|
|
229
|
+
const interruption = new Promise((_, reject) => {
|
|
230
|
+
interruptStartup = (signal) =>
|
|
231
|
+
reject(new Error(`Application startup was interrupted by ${signal}.`));
|
|
232
|
+
});
|
|
233
|
+
const startupHandlers = new Map(
|
|
234
|
+
SESSION_SIGNALS.map((signal) => [signal, () => interruptStartup(signal)]),
|
|
235
|
+
);
|
|
236
|
+
for (const [signal, handler] of startupHandlers) {
|
|
237
|
+
signalTarget.prependListener(signal, handler);
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const ready = await Promise.race([
|
|
241
|
+
waitForReadiness({
|
|
242
|
+
readiness,
|
|
243
|
+
fetchImplementation,
|
|
244
|
+
child,
|
|
245
|
+
}),
|
|
246
|
+
interruption,
|
|
247
|
+
]);
|
|
248
|
+
return Object.freeze({
|
|
249
|
+
pid: child.pid,
|
|
250
|
+
ready,
|
|
251
|
+
stop: () => stopOwnedChild(child),
|
|
252
|
+
wait: () => waitForChildExit(child),
|
|
253
|
+
diagnostics: () => ({
|
|
254
|
+
stdoutBytes: Buffer.byteLength(stdout),
|
|
255
|
+
stderrBytes: Buffer.byteLength(stderr),
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
await stopOwnedChild(child);
|
|
260
|
+
throw error;
|
|
261
|
+
} finally {
|
|
262
|
+
for (const [signal, handler] of startupHandlers) {
|
|
263
|
+
signalTarget.removeListener(signal, handler);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Keep a ready application session attached until the user interrupts it.
|
|
270
|
+
* The child process group is always stopped before this function returns.
|
|
271
|
+
*/
|
|
272
|
+
export const holdApplicationSession = async ({
|
|
273
|
+
session,
|
|
274
|
+
signalTarget = process,
|
|
275
|
+
input = process.stdin,
|
|
276
|
+
onReady = () => undefined,
|
|
277
|
+
}) => {
|
|
278
|
+
if (
|
|
279
|
+
!session ||
|
|
280
|
+
typeof session.wait !== "function" ||
|
|
281
|
+
typeof session.stop !== "function"
|
|
282
|
+
) {
|
|
283
|
+
throw new Error("A started application session is required.");
|
|
284
|
+
}
|
|
285
|
+
let finishInterruption;
|
|
286
|
+
const interruption = new Promise((resolve) => {
|
|
287
|
+
finishInterruption = resolve;
|
|
288
|
+
});
|
|
289
|
+
const handlers = new Map(
|
|
290
|
+
SESSION_SIGNALS.map((signal) => [
|
|
291
|
+
signal,
|
|
292
|
+
() => finishInterruption({ kind: "interrupted", signal }),
|
|
293
|
+
]),
|
|
294
|
+
);
|
|
295
|
+
for (const [signal, handler] of handlers) {
|
|
296
|
+
signalTarget.prependListener(signal, handler);
|
|
297
|
+
}
|
|
298
|
+
const onInput = (chunk) => {
|
|
299
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
300
|
+
if (bytes.includes(0x03)) {
|
|
301
|
+
finishInterruption({ kind: "interrupted", signal: "SIGINT" });
|
|
302
|
+
} else if (bytes.includes(0x1a)) {
|
|
303
|
+
finishInterruption({ kind: "interrupted", signal: "SIGTSTP" });
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
if (input?.isTTY) input.prependListener("data", onInput);
|
|
307
|
+
try {
|
|
308
|
+
onReady(session.ready);
|
|
309
|
+
const outcome = await Promise.race([
|
|
310
|
+
interruption,
|
|
311
|
+
session.wait().then((exit) => ({ kind: "exited", ...exit })),
|
|
312
|
+
]);
|
|
313
|
+
if (outcome.kind === "exited") {
|
|
314
|
+
const diagnostics = session.diagnostics?.() ?? {};
|
|
315
|
+
throw new Error(
|
|
316
|
+
`Application exited before the sandbox was closed (${summarizeProjectCommandFailure(
|
|
317
|
+
{
|
|
318
|
+
status: outcome.status,
|
|
319
|
+
signal: outcome.signal,
|
|
320
|
+
stdoutByteCount: diagnostics.stdoutBytes ?? 0,
|
|
321
|
+
stderrByteCount: diagnostics.stderrBytes ?? 0,
|
|
322
|
+
},
|
|
323
|
+
)}).`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return Object.freeze({
|
|
327
|
+
signal: outcome.signal,
|
|
328
|
+
diagnostics: session.diagnostics?.() ?? {
|
|
329
|
+
stdoutBytes: 0,
|
|
330
|
+
stderrBytes: 0,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
} finally {
|
|
334
|
+
try {
|
|
335
|
+
// npm can forward the terminal's SIGINT after the CLI receives it.
|
|
336
|
+
// Keep our handlers installed until the owned process group is fully
|
|
337
|
+
// stopped so a duplicate signal cannot terminate Rehearsal mid-cleanup.
|
|
338
|
+
await session.stop();
|
|
339
|
+
} finally {
|
|
340
|
+
for (const [signal, handler] of handlers) {
|
|
341
|
+
signalTarget.removeListener(signal, handler);
|
|
342
|
+
}
|
|
343
|
+
if (input?.isTTY) input.removeListener("data", onInput);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const valueAtPath = (value, path) => {
|
|
349
|
+
let current = value;
|
|
350
|
+
for (const segment of path) {
|
|
351
|
+
if (
|
|
352
|
+
current === null ||
|
|
353
|
+
typeof current !== "object" ||
|
|
354
|
+
!Object.hasOwn(current, segment)
|
|
355
|
+
) {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
current = current[segment];
|
|
359
|
+
}
|
|
360
|
+
return current;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
export const runHttpProofs = async ({
|
|
364
|
+
checks,
|
|
365
|
+
fetchImplementation = fetch,
|
|
366
|
+
}) => {
|
|
367
|
+
if (!Array.isArray(checks) || checks.length < 2) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
"HTTP proof requires explicit positive and negative checks.",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (
|
|
373
|
+
!checks.some((check) => check.kind === "positive") ||
|
|
374
|
+
!checks.some((check) => check.kind === "negative")
|
|
375
|
+
) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
"HTTP proof requires at least one positive and one negative check.",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const results = [];
|
|
381
|
+
for (const check of checks) {
|
|
382
|
+
if (!isLoopbackUrl(check.url))
|
|
383
|
+
throw new Error(`HTTP proof ${check.name} is not local.`);
|
|
384
|
+
const response = await fetchImplementation(check.url, {
|
|
385
|
+
method: check.method ?? "GET",
|
|
386
|
+
redirect: "manual",
|
|
387
|
+
signal: AbortSignal.timeout(check.timeoutMs ?? 5_000),
|
|
388
|
+
});
|
|
389
|
+
if (response.status !== check.expectedStatus) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`HTTP proof ${check.name} expected status ${check.expectedStatus} but received ${response.status}.`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
395
|
+
if (bytes.length > 1024 * 1024)
|
|
396
|
+
throw new Error(`HTTP proof ${check.name} returned too much data.`);
|
|
397
|
+
if (check.json) {
|
|
398
|
+
let parsed;
|
|
399
|
+
try {
|
|
400
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
401
|
+
} catch {
|
|
402
|
+
throw new Error(`HTTP proof ${check.name} did not return JSON.`);
|
|
403
|
+
}
|
|
404
|
+
const actual = valueAtPath(parsed, check.json.path);
|
|
405
|
+
if (
|
|
406
|
+
Object.hasOwn(check.json, "equals") &&
|
|
407
|
+
!Object.is(actual, check.json.equals)
|
|
408
|
+
) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`HTTP proof ${check.name} did not return its required value.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
Object.hasOwn(check.json, "notEquals") &&
|
|
415
|
+
Object.is(actual, check.json.notEquals)
|
|
416
|
+
) {
|
|
417
|
+
throw new Error(`HTTP proof ${check.name} returned a forbidden value.`);
|
|
418
|
+
}
|
|
419
|
+
if (
|
|
420
|
+
check.json.minimumItems !== undefined &&
|
|
421
|
+
(!Array.isArray(actual) || actual.length < check.json.minimumItems)
|
|
422
|
+
) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`HTTP proof ${check.name} returned too few matching items.`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
results.push({
|
|
429
|
+
name: check.name,
|
|
430
|
+
kind: check.kind,
|
|
431
|
+
status: response.status,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return Object.freeze(results);
|
|
435
|
+
};
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
symlink,
|
|
19
19
|
} from "node:fs/promises";
|
|
20
20
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
21
|
-
import { createMigrationReplayReceipt } from "
|
|
21
|
+
import { createMigrationReplayReceipt } from "../runtime/migration_history.mjs";
|
|
22
22
|
|
|
23
23
|
const generationPattern = /^\d{8}T\d{6}Z-[a-f0-9]{12}$/u;
|
|
24
24
|
const buildPrefix = ".building-";
|
|
@@ -28,6 +28,9 @@ const migrationNamePattern = /^\d{14}_[a-z0-9_]+\.sql$/u;
|
|
|
28
28
|
const storageBucketPattern = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
|
|
29
29
|
const maximumAssetBytes = 50 * 1024 * 1024;
|
|
30
30
|
const maximumTotalAssetBytes = 2 * 1024 * 1024 * 1024;
|
|
31
|
+
const relationPattern = /^(?:[a-z][a-z0-9_]{0,62}\.)?[a-z][a-z0-9_]{0,62}$/u;
|
|
32
|
+
const relationKey = (schema, table) =>
|
|
33
|
+
schema === "public" ? table : `${schema}.${table}`;
|
|
31
34
|
|
|
32
35
|
const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
|
|
33
36
|
|
|
@@ -136,6 +139,7 @@ export const createAndActivateBaseline = async ({
|
|
|
136
139
|
migrationFiles = [],
|
|
137
140
|
assets = [],
|
|
138
141
|
schemaSql = null,
|
|
142
|
+
activate = true,
|
|
139
143
|
}) => {
|
|
140
144
|
const root = assertArtifactRoot(artifactRoot);
|
|
141
145
|
assertGenerationId(generationId);
|
|
@@ -149,7 +153,7 @@ export const createAndActivateBaseline = async ({
|
|
|
149
153
|
}
|
|
150
154
|
if (
|
|
151
155
|
!Array.isArray(expectedTables) ||
|
|
152
|
-
expectedTables.some((name) =>
|
|
156
|
+
expectedTables.some((name) => !relationPattern.test(name)) ||
|
|
153
157
|
new Set(expectedTables).size !== expectedTables.length
|
|
154
158
|
) {
|
|
155
159
|
throw new Error(
|
|
@@ -190,23 +194,25 @@ export const createAndActivateBaseline = async ({
|
|
|
190
194
|
for await (const record of records) {
|
|
191
195
|
if (
|
|
192
196
|
!record ||
|
|
197
|
+
(record.schema !== undefined &&
|
|
198
|
+
!/^[a-z][a-z0-9_]{0,62}$/u.test(record.schema)) ||
|
|
193
199
|
typeof record.table !== "string" ||
|
|
200
|
+
!/^[a-z][a-z0-9_]{0,62}$/u.test(record.table) ||
|
|
194
201
|
!record.row ||
|
|
195
202
|
typeof record.row !== "object" ||
|
|
196
203
|
Array.isArray(record.row)
|
|
197
204
|
) {
|
|
198
205
|
throw new Error("A sanitized baseline record has an invalid shape.");
|
|
199
206
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
);
|
|
207
|
+
const key = relationKey(record.schema ?? "public", record.table);
|
|
208
|
+
if (expectedTableSet.size && !expectedTableSet.has(key)) {
|
|
209
|
+
throw new Error(`The baseline stream exposed unexpected table ${key}.`);
|
|
204
210
|
}
|
|
205
211
|
const line = `${JSON.stringify(record)}\n`;
|
|
206
212
|
dataHash.update(line);
|
|
207
213
|
await dataHandle.write(line, null, "utf8");
|
|
208
214
|
rowCount += 1;
|
|
209
|
-
tableCounts.set(
|
|
215
|
+
tableCounts.set(key, (tableCounts.get(key) ?? 0) + 1);
|
|
210
216
|
}
|
|
211
217
|
await dataHandle.sync();
|
|
212
218
|
await dataHandle.close();
|
|
@@ -358,12 +364,12 @@ export const createAndActivateBaseline = async ({
|
|
|
358
364
|
finalCreated = true;
|
|
359
365
|
await chmod(finalDirectory, 0o500);
|
|
360
366
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
+
if (activate) {
|
|
368
|
+
await activateBaselineGeneration({
|
|
369
|
+
artifactRoot: root,
|
|
370
|
+
generationId,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
367
373
|
return manifest;
|
|
368
374
|
} catch (error) {
|
|
369
375
|
if (dataHandle) await dataHandle.close().catch(() => undefined);
|
|
@@ -377,6 +383,58 @@ export const createAndActivateBaseline = async ({
|
|
|
377
383
|
}
|
|
378
384
|
};
|
|
379
385
|
|
|
386
|
+
export const activateBaselineGeneration = async ({
|
|
387
|
+
artifactRoot,
|
|
388
|
+
generationId,
|
|
389
|
+
}) => {
|
|
390
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
391
|
+
assertGenerationId(generationId);
|
|
392
|
+
const generationDirectory = assertInsideRoot(
|
|
393
|
+
root,
|
|
394
|
+
join(root, generationsDirectoryName, generationId),
|
|
395
|
+
);
|
|
396
|
+
const manifest = JSON.parse(
|
|
397
|
+
await readFile(join(generationDirectory, "baseline-manifest.json"), "utf8"),
|
|
398
|
+
);
|
|
399
|
+
if (manifest.generationId !== generationId) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
"The staged baseline identifier does not match its manifest.",
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
const temporaryLink = assertInsideRoot(
|
|
405
|
+
root,
|
|
406
|
+
join(root, `.current-${randomBytes(4).toString("hex")}`),
|
|
407
|
+
);
|
|
408
|
+
await symlink(join(generationsDirectoryName, generationId), temporaryLink);
|
|
409
|
+
await rename(temporaryLink, join(root, currentLinkName));
|
|
410
|
+
return manifest;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export const removeStagedBaselineGeneration = async ({
|
|
414
|
+
artifactRoot,
|
|
415
|
+
generationId,
|
|
416
|
+
}) => {
|
|
417
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
418
|
+
assertGenerationId(generationId);
|
|
419
|
+
try {
|
|
420
|
+
const active = await resolveActiveBaselinePaths({ artifactRoot: root });
|
|
421
|
+
if (basename(active.generationDirectory) === generationId) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
"Refusing to remove the active Rehearsal baseline generation.",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error?.code !== "ENOENT") throw error;
|
|
428
|
+
}
|
|
429
|
+
const target = assertInsideRoot(
|
|
430
|
+
root,
|
|
431
|
+
join(root, generationsDirectoryName, generationId),
|
|
432
|
+
);
|
|
433
|
+
await verifyNoSymlink(target, "A staged Rehearsal baseline generation");
|
|
434
|
+
await makeArtifactTreeWritable(target);
|
|
435
|
+
await rm(target, { recursive: true, force: true });
|
|
436
|
+
};
|
|
437
|
+
|
|
380
438
|
export const resolveActiveBaselinePaths = async ({ artifactRoot }) => {
|
|
381
439
|
const root = assertArtifactRoot(artifactRoot);
|
|
382
440
|
const currentTarget = await readlink(join(root, currentLinkName));
|
|
@@ -478,7 +536,7 @@ export const listIncompleteBaselineBuilds = async ({ artifactRoot }) => {
|
|
|
478
536
|
}
|
|
479
537
|
};
|
|
480
538
|
|
|
481
|
-
export const
|
|
539
|
+
export const planBaselineGenerationPrune = async ({
|
|
482
540
|
artifactRoot,
|
|
483
541
|
retain = 2,
|
|
484
542
|
}) => {
|
|
@@ -515,8 +573,75 @@ export const pruneBaselineGenerations = async ({
|
|
|
515
573
|
);
|
|
516
574
|
const removed = entries
|
|
517
575
|
.map((entry) => entry.name)
|
|
518
|
-
.filter((name) => !retained.has(name))
|
|
519
|
-
|
|
576
|
+
.filter((name) => !retained.has(name))
|
|
577
|
+
.sort();
|
|
578
|
+
return { retained: [...retained], removed };
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
export const planBaselineReplacementPrune = async ({
|
|
582
|
+
artifactRoot,
|
|
583
|
+
retain = 2,
|
|
584
|
+
}) => {
|
|
585
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
586
|
+
if (!Number.isSafeInteger(retain) || retain < 1) {
|
|
587
|
+
throw new Error("Rehearsal baseline retention must be a positive integer.");
|
|
588
|
+
}
|
|
589
|
+
await resolveActiveBaselinePaths({ artifactRoot: root });
|
|
590
|
+
const generationsRoot = join(root, generationsDirectoryName);
|
|
591
|
+
await verifyNoSymlink(generationsRoot, "The Rehearsal generations directory");
|
|
592
|
+
const entries = await readdir(generationsRoot, { withFileTypes: true });
|
|
593
|
+
for (const entry of entries) {
|
|
594
|
+
if (
|
|
595
|
+
!entry.isDirectory() ||
|
|
596
|
+
entry.isSymbolicLink() ||
|
|
597
|
+
!generationPattern.test(entry.name)
|
|
598
|
+
) {
|
|
599
|
+
throw new Error(
|
|
600
|
+
`Refusing to plan replacement around an unexpected Rehearsal generation entry: ${entry.name}`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const current = entries
|
|
605
|
+
.map((entry) => entry.name)
|
|
606
|
+
.sort()
|
|
607
|
+
.reverse();
|
|
608
|
+
const retained = current.slice(0, Math.max(0, retain - 1));
|
|
609
|
+
const retainedSet = new Set(retained);
|
|
610
|
+
return {
|
|
611
|
+
retainedAfterReplacement: retained,
|
|
612
|
+
removedAfterReplacement: current
|
|
613
|
+
.filter((name) => !retainedSet.has(name))
|
|
614
|
+
.sort(),
|
|
615
|
+
};
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
export const pruneBaselineGenerations = async ({
|
|
619
|
+
artifactRoot,
|
|
620
|
+
retain = 2,
|
|
621
|
+
expectedRemoved,
|
|
622
|
+
}) => {
|
|
623
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
624
|
+
const plan = await planBaselineGenerationPrune({
|
|
625
|
+
artifactRoot: root,
|
|
626
|
+
retain,
|
|
627
|
+
});
|
|
628
|
+
const generationsRoot = join(root, generationsDirectoryName);
|
|
629
|
+
if (
|
|
630
|
+
expectedRemoved &&
|
|
631
|
+
(expectedRemoved.length !== plan.removed.length ||
|
|
632
|
+
expectedRemoved.some((name, index) => name !== plan.removed[index]))
|
|
633
|
+
) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
"Rehearsal baseline generations changed after the cleanup preview.",
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
for (const name of plan.removed) {
|
|
639
|
+
const active = await resolveActiveBaselinePaths({ artifactRoot: root });
|
|
640
|
+
if (basename(active.generationDirectory) === name) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
"Refusing to prune the active Rehearsal baseline generation.",
|
|
643
|
+
);
|
|
644
|
+
}
|
|
520
645
|
const target = assertInsideRoot(
|
|
521
646
|
generationsRoot,
|
|
522
647
|
join(generationsRoot, name),
|
|
@@ -525,7 +650,7 @@ export const pruneBaselineGenerations = async ({
|
|
|
525
650
|
await makeArtifactTreeWritable(target);
|
|
526
651
|
await rm(target, { recursive: true, force: true });
|
|
527
652
|
}
|
|
528
|
-
return
|
|
653
|
+
return plan;
|
|
529
654
|
};
|
|
530
655
|
|
|
531
656
|
export const removeBaselineArtifactRoot = async ({ artifactRoot }) => {
|