@rehearsal-db/core 0.1.0-beta.7 → 0.1.0-beta.9
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 +77 -1
- package/COMPATIBILITY.md +15 -3
- package/README.md +31 -13
- package/docs/README.md +29 -0
- package/docs/adapters.md +15 -6
- package/docs/architecture.md +58 -0
- package/docs/baselines.md +22 -1
- package/docs/commands.md +81 -14
- package/docs/configuration.md +144 -11
- package/docs/getting-started.md +12 -2
- package/docs/glossary.md +4 -0
- package/docs/production-source.md +176 -33
- package/docs/roadmap.md +30 -28
- package/docs/runtime-policies.md +191 -0
- package/docs/sanitization.md +24 -8
- package/docs/security-model.md +29 -8
- package/docs/standalone-workflow.md +107 -0
- package/docs/troubleshooting.md +8 -0
- package/package.json +25 -22
- package/scripts/runtime/manage_database.mjs +18 -0
- package/src/README.md +17 -0
- package/src/application/session.mjs +313 -0
- package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
- package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
- package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
- package/src/baseline/privacy_engine.mjs +413 -0
- package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
- package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
- package/src/cli/arguments.mjs +120 -0
- package/src/cli/guided.mjs +807 -0
- package/src/cli/rehearsal.mjs +933 -0
- package/src/cli/renderers.mjs +584 -0
- package/src/cli/runtime_commands.mjs +553 -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 +34 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
- package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
- package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
- package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
- package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
- package/src/runtime/policy.mjs +438 -0
- package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
- package/src/runtime/topology.mjs +177 -0
- package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
- 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/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
- package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
- package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
- package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
- package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
- /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/shared}/human_output.mjs +0 -0
|
@@ -0,0 +1,313 @@
|
|
|
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
|
+
|
|
14
|
+
const parseCommand = (source) => {
|
|
15
|
+
if (typeof source !== "string" || !source.trim())
|
|
16
|
+
throw new Error("Application command is empty.");
|
|
17
|
+
const values = [];
|
|
18
|
+
let value = "";
|
|
19
|
+
let quote = null;
|
|
20
|
+
for (const character of source) {
|
|
21
|
+
if (quote) {
|
|
22
|
+
if (character === quote) quote = null;
|
|
23
|
+
else value += character;
|
|
24
|
+
} else if (character === '"' || character === "'") quote = character;
|
|
25
|
+
else if (/\s/u.test(character)) {
|
|
26
|
+
if (value) values.push(value);
|
|
27
|
+
value = "";
|
|
28
|
+
} else value += character;
|
|
29
|
+
}
|
|
30
|
+
if (quote)
|
|
31
|
+
throw new Error("Application command contains an unmatched quote.");
|
|
32
|
+
if (value) values.push(value);
|
|
33
|
+
return values;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const readOwnedEnvironment = async (path) => {
|
|
37
|
+
const details = await stat(path);
|
|
38
|
+
if (!details.isFile() || (details.mode & 0o077) !== 0) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Runtime environment file must be an owner-only regular file.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return parseEnv(await readFile(path, "utf8"));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const assertLocalValue = (key, value) => {
|
|
47
|
+
if (/(_URL|_URI|_ENDPOINT)$/u.test(key)) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = new URL(value);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error(`Mapped runtime variable ${key} is not a URL.`);
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
!isLoopbackUrl(value) &&
|
|
56
|
+
!["postgres:", "postgresql:"].includes(parsed.protocol)
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(`Mapped runtime variable ${key} is not local.`);
|
|
59
|
+
}
|
|
60
|
+
if (
|
|
61
|
+
["postgres:", "postgresql:"].includes(parsed.protocol) &&
|
|
62
|
+
!["127.0.0.1", "::1", "localhost"].includes(parsed.hostname)
|
|
63
|
+
) {
|
|
64
|
+
throw new Error(`Mapped runtime variable ${key} is not local.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const buildApplicationEnvironment = async ({ files, mappings }) => {
|
|
70
|
+
if (!files?.primary)
|
|
71
|
+
throw new Error("Primary runtime environment file is required.");
|
|
72
|
+
const environments = {};
|
|
73
|
+
for (const [name, path] of Object.entries(files)) {
|
|
74
|
+
environments[name] = await readOwnedEnvironment(path);
|
|
75
|
+
}
|
|
76
|
+
if (!mappings || typeof mappings !== "object" || Array.isArray(mappings)) {
|
|
77
|
+
throw new Error("Application environment mappings must be an object.");
|
|
78
|
+
}
|
|
79
|
+
const result = {};
|
|
80
|
+
for (const [destination, reference] of Object.entries(mappings)) {
|
|
81
|
+
if (!ENV_KEY.test(destination) || typeof reference !== "string") {
|
|
82
|
+
throw new Error("Application environment mapping is invalid.");
|
|
83
|
+
}
|
|
84
|
+
const separator = reference.indexOf(":");
|
|
85
|
+
if (separator < 1)
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Application mapping ${destination} has an invalid reference.`,
|
|
88
|
+
);
|
|
89
|
+
const target = reference.slice(0, separator);
|
|
90
|
+
const source = reference.slice(separator + 1);
|
|
91
|
+
if (!ENV_KEY.test(source) || !environments[target]?.[source]) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Application mapping ${destination} references a missing runtime value.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
assertLocalValue(source, environments[target][source]);
|
|
97
|
+
result[destination] = environments[target][source];
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const boundedAppend = (current, chunk) =>
|
|
103
|
+
`${current}${chunk}`.slice(-MAX_DIAGNOSTIC_BYTES);
|
|
104
|
+
|
|
105
|
+
export const summarizeProjectCommandFailure = ({
|
|
106
|
+
error,
|
|
107
|
+
status,
|
|
108
|
+
stdout = "",
|
|
109
|
+
stderr = "",
|
|
110
|
+
}) => {
|
|
111
|
+
const stdoutBytes = Buffer.byteLength(String(stdout));
|
|
112
|
+
const stderrBytes = Buffer.byteLength(String(stderr));
|
|
113
|
+
if (error) {
|
|
114
|
+
const code = /^[A-Z0-9_]+$/u.test(error.code ?? "")
|
|
115
|
+
? ` (${error.code})`
|
|
116
|
+
: "";
|
|
117
|
+
return `command could not start${code}; output withheld`;
|
|
118
|
+
}
|
|
119
|
+
return `exit ${Number.isInteger(status) ? status : "unknown"}; stdout ${stdoutBytes} bytes; stderr ${stderrBytes} bytes; output withheld`;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const waitForReadiness = async ({ readiness, fetchImplementation, child }) => {
|
|
123
|
+
if (!readiness || !isLoopbackUrl(readiness.url)) {
|
|
124
|
+
throw new Error("Application readiness must use a loopback URL.");
|
|
125
|
+
}
|
|
126
|
+
const expected = readiness.expectedStatus ?? 200;
|
|
127
|
+
const deadline = Date.now() + (readiness.timeoutSeconds ?? 30) * 1_000;
|
|
128
|
+
let lastStatus = null;
|
|
129
|
+
while (Date.now() < deadline) {
|
|
130
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
131
|
+
throw new Error("Application exited before becoming ready.");
|
|
132
|
+
try {
|
|
133
|
+
const response = await fetchImplementation(readiness.url, {
|
|
134
|
+
redirect: "manual",
|
|
135
|
+
signal: AbortSignal.timeout(2_000),
|
|
136
|
+
});
|
|
137
|
+
lastStatus = response.status;
|
|
138
|
+
if (response.status === expected)
|
|
139
|
+
return { url: readiness.url, status: response.status };
|
|
140
|
+
} catch {
|
|
141
|
+
// A bounded retry is expected while the local process starts.
|
|
142
|
+
}
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
144
|
+
}
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Application readiness timed out${lastStatus === null ? "" : ` after HTTP ${lastStatus}`}.`,
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const stopOwnedChild = async (child) => {
|
|
151
|
+
const exited = () => child.exitCode !== null || child.signalCode !== null;
|
|
152
|
+
if (exited()) return;
|
|
153
|
+
const kill = (signal) => {
|
|
154
|
+
try {
|
|
155
|
+
if (process.platform === "win32") child.kill(signal);
|
|
156
|
+
else process.kill(-child.pid, signal);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error?.code !== "ESRCH") throw error;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
kill("SIGTERM");
|
|
162
|
+
await Promise.race([
|
|
163
|
+
new Promise((resolve) => child.once("exit", resolve)),
|
|
164
|
+
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
165
|
+
]);
|
|
166
|
+
if (!exited()) {
|
|
167
|
+
kill("SIGKILL");
|
|
168
|
+
await new Promise((resolve) => child.once("exit", resolve));
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const startApplicationSession = async ({
|
|
173
|
+
command,
|
|
174
|
+
cwd,
|
|
175
|
+
files,
|
|
176
|
+
mappings,
|
|
177
|
+
readiness,
|
|
178
|
+
inheritedEnvironment = process.env,
|
|
179
|
+
spawnImplementation = spawn,
|
|
180
|
+
fetchImplementation = fetch,
|
|
181
|
+
}) => {
|
|
182
|
+
const mapped = await buildApplicationEnvironment({ files, mappings });
|
|
183
|
+
const [program, ...args] = parseCommand(command);
|
|
184
|
+
const child = spawnImplementation(program, args, {
|
|
185
|
+
cwd,
|
|
186
|
+
env: createCleanProcessEnvironment({
|
|
187
|
+
inheritedEnvironment,
|
|
188
|
+
overrides: mapped,
|
|
189
|
+
}),
|
|
190
|
+
detached: process.platform !== "win32",
|
|
191
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
192
|
+
});
|
|
193
|
+
let stdout = "";
|
|
194
|
+
let stderr = "";
|
|
195
|
+
child.stdout?.on("data", (chunk) => {
|
|
196
|
+
stdout = boundedAppend(stdout, chunk.toString("utf8"));
|
|
197
|
+
});
|
|
198
|
+
child.stderr?.on("data", (chunk) => {
|
|
199
|
+
stderr = boundedAppend(stderr, chunk.toString("utf8"));
|
|
200
|
+
});
|
|
201
|
+
await new Promise((resolve, reject) => {
|
|
202
|
+
child.once("spawn", resolve);
|
|
203
|
+
child.once("error", reject);
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
const ready = await waitForReadiness({
|
|
207
|
+
readiness,
|
|
208
|
+
fetchImplementation,
|
|
209
|
+
child,
|
|
210
|
+
});
|
|
211
|
+
return Object.freeze({
|
|
212
|
+
pid: child.pid,
|
|
213
|
+
ready,
|
|
214
|
+
stop: () => stopOwnedChild(child),
|
|
215
|
+
diagnostics: () => ({
|
|
216
|
+
stdoutBytes: Buffer.byteLength(stdout),
|
|
217
|
+
stderrBytes: Buffer.byteLength(stderr),
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
await stopOwnedChild(child);
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const valueAtPath = (value, path) => {
|
|
227
|
+
let current = value;
|
|
228
|
+
for (const segment of path) {
|
|
229
|
+
if (
|
|
230
|
+
current === null ||
|
|
231
|
+
typeof current !== "object" ||
|
|
232
|
+
!Object.hasOwn(current, segment)
|
|
233
|
+
) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
current = current[segment];
|
|
237
|
+
}
|
|
238
|
+
return current;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export const runHttpProofs = async ({
|
|
242
|
+
checks,
|
|
243
|
+
fetchImplementation = fetch,
|
|
244
|
+
}) => {
|
|
245
|
+
if (!Array.isArray(checks) || checks.length < 2) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
"HTTP proof requires explicit positive and negative checks.",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
!checks.some((check) => check.kind === "positive") ||
|
|
252
|
+
!checks.some((check) => check.kind === "negative")
|
|
253
|
+
) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
"HTTP proof requires at least one positive and one negative check.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const results = [];
|
|
259
|
+
for (const check of checks) {
|
|
260
|
+
if (!isLoopbackUrl(check.url))
|
|
261
|
+
throw new Error(`HTTP proof ${check.name} is not local.`);
|
|
262
|
+
const response = await fetchImplementation(check.url, {
|
|
263
|
+
method: check.method ?? "GET",
|
|
264
|
+
redirect: "manual",
|
|
265
|
+
signal: AbortSignal.timeout(check.timeoutMs ?? 5_000),
|
|
266
|
+
});
|
|
267
|
+
if (response.status !== check.expectedStatus) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`HTTP proof ${check.name} expected status ${check.expectedStatus} but received ${response.status}.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
273
|
+
if (bytes.length > 1024 * 1024)
|
|
274
|
+
throw new Error(`HTTP proof ${check.name} returned too much data.`);
|
|
275
|
+
if (check.json) {
|
|
276
|
+
let parsed;
|
|
277
|
+
try {
|
|
278
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
279
|
+
} catch {
|
|
280
|
+
throw new Error(`HTTP proof ${check.name} did not return JSON.`);
|
|
281
|
+
}
|
|
282
|
+
const actual = valueAtPath(parsed, check.json.path);
|
|
283
|
+
if (
|
|
284
|
+
Object.hasOwn(check.json, "equals") &&
|
|
285
|
+
!Object.is(actual, check.json.equals)
|
|
286
|
+
) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`HTTP proof ${check.name} did not return its required value.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (
|
|
292
|
+
Object.hasOwn(check.json, "notEquals") &&
|
|
293
|
+
Object.is(actual, check.json.notEquals)
|
|
294
|
+
) {
|
|
295
|
+
throw new Error(`HTTP proof ${check.name} returned a forbidden value.`);
|
|
296
|
+
}
|
|
297
|
+
if (
|
|
298
|
+
check.json.minimumItems !== undefined &&
|
|
299
|
+
(!Array.isArray(actual) || actual.length < check.json.minimumItems)
|
|
300
|
+
) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`HTTP proof ${check.name} returned too few matching items.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
results.push({
|
|
307
|
+
name: check.name,
|
|
308
|
+
kind: check.kind,
|
|
309
|
+
status: response.status,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return Object.freeze(results);
|
|
313
|
+
};
|
|
@@ -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));
|
|
@@ -515,10 +573,48 @@ export const planBaselineGenerationPrune = async ({
|
|
|
515
573
|
);
|
|
516
574
|
const removed = entries
|
|
517
575
|
.map((entry) => entry.name)
|
|
518
|
-
.filter((name) => !retained.has(name))
|
|
576
|
+
.filter((name) => !retained.has(name))
|
|
577
|
+
.sort();
|
|
519
578
|
return { retained: [...retained], removed };
|
|
520
579
|
};
|
|
521
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
|
+
|
|
522
618
|
export const pruneBaselineGenerations = async ({
|
|
523
619
|
artifactRoot,
|
|
524
620
|
retain = 2,
|
|
@@ -12,13 +12,13 @@ import { pathToFileURL } from "node:url";
|
|
|
12
12
|
import {
|
|
13
13
|
createAndActivateBaseline,
|
|
14
14
|
createBaselineGenerationId,
|
|
15
|
-
} from "./
|
|
16
|
-
import { loadRehearsalConfig } from "
|
|
15
|
+
} from "./artifact.mjs";
|
|
16
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
17
17
|
import {
|
|
18
18
|
buildMigrationLedgerInventory,
|
|
19
19
|
createMigrationReplayReceipt,
|
|
20
20
|
readMigrationSourceBundle,
|
|
21
|
-
} from "
|
|
21
|
+
} from "../runtime/migration_history.mjs";
|
|
22
22
|
import { validateRuntimeSanitizationPolicy } from "./sanitization_policy.mjs";
|
|
23
23
|
|
|
24
24
|
const resolveProjectInput = (projectRoot, value, label) => {
|
|
@@ -109,7 +109,11 @@ export const createSyntheticBaselineFromFiles = async ({
|
|
|
109
109
|
);
|
|
110
110
|
const expectedTables = policy.tables
|
|
111
111
|
.filter((table) => table.sourceRows !== "EXCLUDE")
|
|
112
|
-
.map((table) =>
|
|
112
|
+
.map((table) =>
|
|
113
|
+
(table.schema ?? "public") === "public"
|
|
114
|
+
? table.name
|
|
115
|
+
: `${table.schema}.${table.name}`,
|
|
116
|
+
);
|
|
113
117
|
if (expectedTables.length === 0) {
|
|
114
118
|
throw new Error("The sanitization policy includes no restorable tables.");
|
|
115
119
|
}
|
|
@@ -7,8 +7,8 @@ import { createReadStream } from "node:fs";
|
|
|
7
7
|
import { access, lstat, mkdir, open, readFile } from "node:fs/promises";
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
10
|
-
import { loadRehearsalConfig } from "
|
|
11
|
-
import { buildMigrationLedgerInventory } from "
|
|
10
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
11
|
+
import { buildMigrationLedgerInventory } from "../runtime/migration_history.mjs";
|
|
12
12
|
|
|
13
13
|
const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
14
14
|
const storageBucketPattern = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
|
|
@@ -70,6 +70,7 @@ const inspectSyntheticRecords = async (path) => {
|
|
|
70
70
|
}
|
|
71
71
|
if (
|
|
72
72
|
!record ||
|
|
73
|
+
!identifierPattern.test(record.schema ?? "public") ||
|
|
73
74
|
!identifierPattern.test(record.table ?? "") ||
|
|
74
75
|
!record.row ||
|
|
75
76
|
typeof record.row !== "object" ||
|
|
@@ -86,14 +87,18 @@ const inspectSyntheticRecords = async (path) => {
|
|
|
86
87
|
`Synthetic baseline table ${record.table} has invalid or empty columns.`,
|
|
87
88
|
);
|
|
88
89
|
}
|
|
89
|
-
const
|
|
90
|
+
const schema = record.schema ?? "public";
|
|
91
|
+
const key =
|
|
92
|
+
schema === "public" ? record.table : `${schema}.${record.table}`;
|
|
93
|
+
const table = tables.get(key) ?? {
|
|
94
|
+
schema,
|
|
90
95
|
name: record.table,
|
|
91
96
|
rowCount: 0,
|
|
92
97
|
columns: new Set(),
|
|
93
98
|
};
|
|
94
99
|
table.rowCount += 1;
|
|
95
100
|
for (const column of columns) table.columns.add(column);
|
|
96
|
-
tables.set(
|
|
101
|
+
tables.set(key, table);
|
|
97
102
|
rowCount += 1;
|
|
98
103
|
}
|
|
99
104
|
if (rowCount === 0) {
|
|
@@ -104,6 +109,7 @@ const inspectSyntheticRecords = async (path) => {
|
|
|
104
109
|
tables: [...tables.values()]
|
|
105
110
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
106
111
|
.map((table) => ({
|
|
112
|
+
schema: table.schema,
|
|
107
113
|
name: table.name,
|
|
108
114
|
rowCount: table.rowCount,
|
|
109
115
|
columns: [...table.columns].sort(),
|
|
@@ -232,6 +238,7 @@ const renderPolicyDraft = ({ migrationCutoff, tables }) =>
|
|
|
232
238
|
draft: true,
|
|
233
239
|
migrationCutoff,
|
|
234
240
|
tables: tables.map((table) => ({
|
|
241
|
+
...(table.schema === "public" ? {} : { schema: table.schema }),
|
|
235
242
|
name: table.name,
|
|
236
243
|
group: "synthetic",
|
|
237
244
|
sourceRows: "STREAM AND SANITIZE",
|