@rehearsal-db/core 0.1.0-beta.13 → 0.1.0-beta.15
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 +93 -0
- package/README.md +4 -1
- package/dist/package.json +100 -0
- package/dist/scripts/install/create_root_config.d.mts +3 -0
- package/dist/scripts/install/create_root_config.mjs +13 -0
- package/dist/scripts/runtime/manage_database.d.mts +6 -0
- package/{scripts → dist/scripts}/runtime/manage_database.mjs +6 -8
- package/dist/src/application/session.d.mts +96 -0
- package/dist/src/application/session.mjs +380 -0
- package/dist/src/baseline/artifact.d.mts +119 -0
- package/dist/src/baseline/artifact.mjs +522 -0
- package/dist/src/baseline/builder.d.mts +19 -0
- package/dist/src/baseline/builder.mjs +142 -0
- package/dist/src/baseline/input_discovery.d.mts +13 -0
- package/dist/src/baseline/input_discovery.mjs +149 -0
- package/dist/src/baseline/policy_review.d.mts +56 -0
- package/dist/src/baseline/policy_review.mjs +73 -0
- package/dist/src/baseline/preparation.d.mts +63 -0
- package/dist/src/baseline/preparation.mjs +295 -0
- package/dist/src/baseline/privacy_engine.d.mts +162 -0
- package/dist/src/baseline/privacy_engine.mjs +1162 -0
- package/dist/src/baseline/privacy_structured_json.d.mts +66 -0
- package/dist/src/baseline/privacy_structured_json.mjs +464 -0
- package/dist/src/baseline/sanitization_policy.d.mts +69 -0
- package/dist/src/baseline/sanitization_policy.mjs +227 -0
- package/dist/src/baseline/schema_snapshot.d.mts +19 -0
- package/dist/src/baseline/schema_snapshot.mjs +79 -0
- package/dist/src/cli/arguments.d.mts +43 -0
- package/dist/src/cli/arguments.mjs +135 -0
- package/dist/src/cli/guided.d.mts +51 -0
- package/dist/src/cli/guided.mjs +713 -0
- package/dist/src/cli/init_command.d.mts +21 -0
- package/dist/src/cli/init_command.mjs +90 -0
- package/dist/src/cli/project_commands.d.mts +157 -0
- package/dist/src/cli/project_commands.mjs +183 -0
- package/dist/src/cli/rehearsal.d.mts +7 -0
- package/dist/src/cli/rehearsal.mjs +706 -0
- package/dist/src/cli/renderers.d.mts +299 -0
- package/dist/src/cli/renderers.mjs +554 -0
- package/dist/src/cli/runtime_commands.d.mts +265 -0
- package/dist/src/cli/runtime_commands.mjs +599 -0
- package/dist/src/cli/source_commands.d.mts +432 -0
- package/dist/src/cli/source_commands.mjs +299 -0
- package/dist/src/cli/terminal.d.mts +59 -0
- package/dist/src/cli/terminal.mjs +230 -0
- package/dist/src/identity/claim.d.mts +144 -0
- package/dist/src/identity/claim.mjs +947 -0
- package/dist/src/identity/provider_configuration.d.mts +37 -0
- package/dist/src/identity/provider_configuration.mjs +132 -0
- package/dist/src/identity/storage.d.mts +21 -0
- package/dist/src/identity/storage.mjs +122 -0
- package/dist/src/index.d.mts +4 -0
- package/dist/src/index.mjs +3 -0
- package/dist/src/project/configuration.d.mts +181 -0
- package/dist/src/project/configuration.mjs +958 -0
- package/dist/src/project/install_scaffold.d.mts +42 -0
- package/dist/src/project/install_scaffold.mjs +263 -0
- package/dist/src/project/setup.d.mts +87 -0
- package/dist/src/project/setup.mjs +414 -0
- package/dist/src/project/support_report.d.mts +88 -0
- package/dist/src/project/support_report.mjs +83 -0
- package/dist/src/runtime/cleanup.d.mts +85 -0
- package/dist/src/runtime/cleanup.mjs +311 -0
- package/dist/src/runtime/migration_history.d.mts +51 -0
- package/dist/src/runtime/migration_history.mjs +170 -0
- package/dist/src/runtime/plan.d.mts +134 -0
- package/dist/src/runtime/plan.mjs +636 -0
- package/dist/src/runtime/policy.d.mts +66 -0
- package/dist/src/runtime/policy.mjs +369 -0
- package/dist/src/runtime/restore.d.mts +22 -0
- package/dist/src/runtime/restore.mjs +276 -0
- package/dist/src/runtime/service_environment.d.mts +11 -0
- package/dist/src/runtime/service_environment.mjs +58 -0
- package/dist/src/runtime/topology.d.mts +51 -0
- package/dist/src/runtime/topology.mjs +128 -0
- package/dist/src/shared/diagnostics.d.mts +97 -0
- package/dist/src/shared/diagnostics.mjs +170 -0
- package/dist/src/shared/human_output.d.mts +2 -0
- package/{src → dist/src}/shared/human_output.mjs +1 -3
- package/dist/src/shared/node_runtime.d.mts +14 -0
- package/dist/src/shared/node_runtime.mjs +25 -0
- package/dist/src/shared/operation_guard.d.mts +22 -0
- package/dist/src/shared/operation_guard.mjs +150 -0
- package/dist/src/shared/process_environment.d.mts +15 -0
- package/dist/src/shared/process_environment.mjs +50 -0
- package/dist/src/source/access.d.mts +165 -0
- package/dist/src/source/access.mjs +602 -0
- package/dist/src/source/asset_transfer.d.mts +27 -0
- package/dist/src/source/asset_transfer.mjs +202 -0
- package/dist/src/source/baseline.d.mts +66 -0
- package/dist/src/source/baseline.mjs +372 -0
- package/dist/src/source/external_reader.d.mts +42 -0
- package/dist/src/source/external_reader.mjs +278 -0
- package/dist/src/source/postgresql_access.d.mts +86 -0
- package/dist/src/source/postgresql_access.mjs +454 -0
- package/dist/src/source/privacy_paths.d.mts +10 -0
- package/dist/src/source/privacy_paths.mjs +27 -0
- package/dist/src/targets/driver.d.mts +2 -0
- package/dist/src/targets/driver.mjs +13 -0
- package/dist/src/targets/postgresql.d.mts +5 -0
- package/dist/src/targets/postgresql.mjs +641 -0
- package/dist/src/targets/supabase.d.mts +8 -0
- package/dist/src/targets/supabase.mjs +659 -0
- package/dist/src/targets/supabase_environment.d.mts +49 -0
- package/dist/src/targets/supabase_environment.mjs +162 -0
- package/dist/src/targets/target.d.mts +33 -0
- package/dist/src/targets/target.mjs +39 -0
- package/docs/README.md +1 -0
- package/docs/architecture.md +17 -9
- package/docs/configuration.md +49 -8
- package/docs/getting-started.md +28 -0
- package/docs/index.md +1 -0
- package/docs/production-source.md +43 -7
- package/docs/releasing.md +5 -3
- package/docs/roadmap.md +3 -2
- package/docs/sanitization.md +415 -14
- package/docs/standalone-workflow.md +12 -6
- package/docs/troubleshooting.md +5 -1
- package/docs/tutorial.md +5 -0
- package/docs/typescript.md +87 -0
- package/package.json +35 -29
- package/scripts/install/create_root_config.mjs +0 -36
- package/src/README.md +0 -17
- package/src/application/session.mjs +0 -435
- package/src/baseline/artifact.mjs +0 -684
- package/src/baseline/builder.mjs +0 -164
- package/src/baseline/input_discovery.mjs +0 -155
- package/src/baseline/policy_review.mjs +0 -92
- package/src/baseline/preparation.mjs +0 -323
- package/src/baseline/privacy_engine.mjs +0 -952
- package/src/baseline/sanitization_policy.mjs +0 -299
- package/src/baseline/schema_snapshot.mjs +0 -113
- package/src/cli/arguments.mjs +0 -126
- package/src/cli/guided.mjs +0 -814
- package/src/cli/init_command.mjs +0 -94
- package/src/cli/rehearsal.mjs +0 -970
- package/src/cli/renderers.mjs +0 -626
- package/src/cli/runtime_commands.mjs +0 -707
- package/src/cli/source_commands.mjs +0 -347
- package/src/cli/terminal.mjs +0 -275
- package/src/identity/claim.mjs +0 -1163
- package/src/identity/provider_configuration.mjs +0 -177
- package/src/identity/storage.mjs +0 -165
- package/src/project/configuration.mjs +0 -1337
- package/src/project/install_scaffold.mjs +0 -291
- package/src/project/setup.mjs +0 -466
- package/src/project/support_report.mjs +0 -105
- package/src/runtime/cleanup.mjs +0 -381
- package/src/runtime/migration_history.mjs +0 -220
- package/src/runtime/plan.mjs +0 -722
- package/src/runtime/policy.mjs +0 -438
- package/src/runtime/restore.mjs +0 -337
- package/src/runtime/service_environment.mjs +0 -82
- package/src/runtime/topology.mjs +0 -177
- package/src/shared/diagnostics.mjs +0 -207
- package/src/shared/node_runtime.mjs +0 -31
- package/src/shared/operation_guard.mjs +0 -162
- package/src/shared/process_environment.mjs +0 -68
- package/src/source/access.mjs +0 -753
- package/src/source/asset_transfer.mjs +0 -207
- package/src/source/baseline.mjs +0 -482
- package/src/source/external_reader.mjs +0 -255
- package/src/source/postgresql_access.mjs +0 -581
- package/src/source/privacy_paths.mjs +0 -38
- package/src/targets/postgresql.mjs +0 -808
- package/src/targets/supabase.mjs +0 -840
- package/src/targets/supabase_environment.mjs +0 -229
- package/src/targets/target.mjs +0 -66
- /package/{src/project → types}/configuration.d.mts +0 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Preview and apply narrowly scoped Rehearsal cleanup without global
|
|
3
|
+
* Docker pruning or deletion of unowned database resources.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { stat } from "node:fs/promises";
|
|
8
|
+
import { planBaselineGenerationPrune, pruneBaselineGenerations, } from "../baseline/artifact.mjs";
|
|
9
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
10
|
+
import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
|
|
11
|
+
const hasErrorCode = (error, code) => typeof error === "object" &&
|
|
12
|
+
error !== null &&
|
|
13
|
+
"code" in error &&
|
|
14
|
+
error.code === code;
|
|
15
|
+
const SUPABASE_IMAGE_PREFIX = "public.ecr.aws/supabase/";
|
|
16
|
+
const commandResult = (command, args, { projectRoot }) => spawnSync(command, args, {
|
|
17
|
+
cwd: projectRoot,
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
env: createCleanProcessEnvironment(),
|
|
20
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
});
|
|
23
|
+
const runDocker = (args, { projectRoot, allowFailure = false, }) => {
|
|
24
|
+
const result = commandResult("docker", args, { projectRoot });
|
|
25
|
+
if (!allowFailure && (result.error || result.status !== 0)) {
|
|
26
|
+
const detail = [result.stdout, result.stderr]
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join("\n")
|
|
29
|
+
.trim();
|
|
30
|
+
throw new Error(`docker ${args.join(" ")} failed${detail ? `:\n${detail}` : "."}`);
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
const nonEmptyLines = (value) => String(value ?? "")
|
|
35
|
+
.split("\n")
|
|
36
|
+
.map((line) => line.trim())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
const dockerAvailable = (projectRoot) => runDocker(["info"], { projectRoot, allowFailure: true }).status === 0;
|
|
39
|
+
export const parsePosixDiskUsage = (output) => {
|
|
40
|
+
const columns = nonEmptyLines(output).at(-1)?.split(/\s+/u) ?? [];
|
|
41
|
+
if (columns.length < 6)
|
|
42
|
+
return null;
|
|
43
|
+
const totalKiB = Number(columns[1]);
|
|
44
|
+
const usedKiB = Number(columns[2]);
|
|
45
|
+
const availableKiB = Number(columns[3]);
|
|
46
|
+
const usedPercent = Number.parseInt(columns[4], 10);
|
|
47
|
+
if (![totalKiB, usedKiB, availableKiB, usedPercent].every(Number.isFinite)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
totalBytes: totalKiB * 1024,
|
|
52
|
+
usedBytes: usedKiB * 1024,
|
|
53
|
+
availableBytes: availableKiB * 1024,
|
|
54
|
+
usedPercent,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const inspectContainerDisk = (projectRoot) => {
|
|
58
|
+
const context = commandResult("docker", ["context", "show"], {
|
|
59
|
+
projectRoot,
|
|
60
|
+
});
|
|
61
|
+
if (context.status !== 0 || context.stdout.trim() !== "colima")
|
|
62
|
+
return null;
|
|
63
|
+
const result = commandResult("colima", ["ssh", "--", "df", "-Pk", "/var/lib/docker"], { projectRoot });
|
|
64
|
+
const usage = result.status === 0 ? parsePosixDiskUsage(result.stdout) : null;
|
|
65
|
+
return usage ? { backend: "colima", ...usage } : null;
|
|
66
|
+
};
|
|
67
|
+
const inspectDockerUsage = (projectRoot) => {
|
|
68
|
+
if (!dockerAvailable(projectRoot)) {
|
|
69
|
+
return { available: false, resources: [], disk: null };
|
|
70
|
+
}
|
|
71
|
+
const result = runDocker(["system", "df", "--format", "{{json .}}"], {
|
|
72
|
+
projectRoot,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
available: true,
|
|
76
|
+
resources: nonEmptyLines(result.stdout).map((line) => JSON.parse(line)),
|
|
77
|
+
disk: inspectContainerDisk(projectRoot),
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
const inspectUsedImageIds = (projectRoot) => {
|
|
81
|
+
const containers = nonEmptyLines(runDocker(["container", "ls", "--all", "--quiet"], { projectRoot }).stdout);
|
|
82
|
+
if (containers.length === 0)
|
|
83
|
+
return new Set();
|
|
84
|
+
return new Set(nonEmptyLines(runDocker(["container", "inspect", "--format", "{{.Image}}", ...containers], { projectRoot }).stdout));
|
|
85
|
+
};
|
|
86
|
+
const splitImageTag = (value) => {
|
|
87
|
+
const separator = value.lastIndexOf(":");
|
|
88
|
+
if (separator <= value.lastIndexOf("/"))
|
|
89
|
+
return null;
|
|
90
|
+
return {
|
|
91
|
+
repository: value.slice(0, separator),
|
|
92
|
+
tag: value.slice(separator + 1),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
export const selectOlderUnusedSupabaseImages = ({ images, usedImageIds, }) => {
|
|
96
|
+
const byRepository = new Map();
|
|
97
|
+
for (const image of images) {
|
|
98
|
+
for (const fullTag of image.RepoTags ?? []) {
|
|
99
|
+
const parsed = splitImageTag(fullTag);
|
|
100
|
+
if (!parsed?.repository.startsWith(SUPABASE_IMAGE_PREFIX))
|
|
101
|
+
continue;
|
|
102
|
+
const entries = byRepository.get(parsed.repository) ?? [];
|
|
103
|
+
entries.push({
|
|
104
|
+
id: image.Id,
|
|
105
|
+
createdAt: image.Created,
|
|
106
|
+
bytes: image.Size,
|
|
107
|
+
fullTag,
|
|
108
|
+
});
|
|
109
|
+
byRepository.set(parsed.repository, entries);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const candidatesById = new Map();
|
|
113
|
+
for (const entries of byRepository.values()) {
|
|
114
|
+
const newestId = [...entries].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0]?.id;
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (entry.id === newestId || usedImageIds.has(entry.id))
|
|
117
|
+
continue;
|
|
118
|
+
const existing = candidatesById.get(entry.id) ?? {
|
|
119
|
+
id: entry.id,
|
|
120
|
+
createdAt: entry.createdAt,
|
|
121
|
+
bytes: entry.bytes,
|
|
122
|
+
tags: [],
|
|
123
|
+
};
|
|
124
|
+
existing.tags.push(entry.fullTag);
|
|
125
|
+
candidatesById.set(entry.id, existing);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...candidatesById.values()]
|
|
129
|
+
.map((entry) => ({ ...entry, tags: [...new Set(entry.tags)].sort() }))
|
|
130
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
131
|
+
};
|
|
132
|
+
const inspectUnusedImages = (projectRoot) => {
|
|
133
|
+
const imageIds = [
|
|
134
|
+
...new Set(nonEmptyLines(runDocker(["image", "ls", "--quiet", "--no-trunc"], {
|
|
135
|
+
projectRoot,
|
|
136
|
+
}).stdout)),
|
|
137
|
+
];
|
|
138
|
+
if (imageIds.length === 0)
|
|
139
|
+
return [];
|
|
140
|
+
const images = nonEmptyLines(runDocker(["image", "inspect", "--format", "{{json .}}", ...imageIds], {
|
|
141
|
+
projectRoot,
|
|
142
|
+
}).stdout).map((line) => JSON.parse(line));
|
|
143
|
+
return selectOlderUnusedSupabaseImages({
|
|
144
|
+
images,
|
|
145
|
+
usedImageIds: inspectUsedImageIds(projectRoot),
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
const runtimeDetected = async ({ config, paths, projectRoot, }) => {
|
|
149
|
+
try {
|
|
150
|
+
await stat(paths.runtimeWorkdir);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (!hasErrorCode(error, "ENOENT"))
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
if (!dockerAvailable(projectRoot))
|
|
158
|
+
return false;
|
|
159
|
+
if (config.runtime.target === "postgresql") {
|
|
160
|
+
const containers = runDocker([
|
|
161
|
+
"container",
|
|
162
|
+
"ls",
|
|
163
|
+
"--all",
|
|
164
|
+
"--filter",
|
|
165
|
+
`label=com.rehearsal-db.project=${config.runtime.projectId}`,
|
|
166
|
+
"--quiet",
|
|
167
|
+
], { projectRoot });
|
|
168
|
+
const volumes = runDocker([
|
|
169
|
+
"volume",
|
|
170
|
+
"ls",
|
|
171
|
+
"--filter",
|
|
172
|
+
`label=com.rehearsal-db.project=${config.runtime.projectId}`,
|
|
173
|
+
"--quiet",
|
|
174
|
+
], { projectRoot });
|
|
175
|
+
return (nonEmptyLines(containers.stdout).length > 0 ||
|
|
176
|
+
nonEmptyLines(volumes.stdout).length > 0);
|
|
177
|
+
}
|
|
178
|
+
const containers = runDocker([
|
|
179
|
+
"container",
|
|
180
|
+
"ls",
|
|
181
|
+
"--all",
|
|
182
|
+
"--filter",
|
|
183
|
+
`label=com.supabase.cli.project=${config.runtime.projectId}`,
|
|
184
|
+
"--quiet",
|
|
185
|
+
], { projectRoot });
|
|
186
|
+
const volumes = nonEmptyLines(runDocker(["volume", "ls", "--format", "{{.Name}}"], { projectRoot })
|
|
187
|
+
.stdout);
|
|
188
|
+
return (nonEmptyLines(containers.stdout).length > 0 ||
|
|
189
|
+
volumes.some((name) => name.endsWith(`_${config.runtime.projectId}`)));
|
|
190
|
+
};
|
|
191
|
+
const inspectBaselineCleanup = async ({ artifactRoot, retain, }) => {
|
|
192
|
+
try {
|
|
193
|
+
return {
|
|
194
|
+
available: true,
|
|
195
|
+
...(await planBaselineGenerationPrune({ artifactRoot, retain })),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
200
|
+
return { available: false, retained: [], removed: [] };
|
|
201
|
+
}
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const cleanupIdentity = (plan) => ({
|
|
206
|
+
projectId: plan.projectId,
|
|
207
|
+
retainBaselineGenerations: plan.retainBaselineGenerations,
|
|
208
|
+
baselineGenerations: plan.baselines.removed,
|
|
209
|
+
runtime: plan.runtime.included && plan.runtime.detected,
|
|
210
|
+
images: plan.images.candidates.map(({ id, tags }) => ({ id, tags })),
|
|
211
|
+
});
|
|
212
|
+
const cleanupDigest = (plan) => createHash("sha256")
|
|
213
|
+
.update(JSON.stringify(cleanupIdentity(plan)))
|
|
214
|
+
.digest("hex");
|
|
215
|
+
export const planRehearsalCleanup = async ({ projectRoot = process.cwd(), configPath, includeRuntime = false, includeImages = false, inspection = { inspectDockerUsage, runtimeDetected }, } = {}) => {
|
|
216
|
+
const loaded = await loadRehearsalConfig({
|
|
217
|
+
projectRoot,
|
|
218
|
+
...(configPath === undefined ? {} : { configPath }),
|
|
219
|
+
});
|
|
220
|
+
const docker = inspection.inspectDockerUsage(loaded.projectRoot);
|
|
221
|
+
const baselines = await inspectBaselineCleanup({
|
|
222
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
223
|
+
retain: loaded.config.cleanup.retainBaselineGenerations,
|
|
224
|
+
});
|
|
225
|
+
const images = {
|
|
226
|
+
requested: includeImages,
|
|
227
|
+
inspected: includeImages && docker.available,
|
|
228
|
+
candidates: includeImages && docker.available
|
|
229
|
+
? inspectUnusedImages(loaded.projectRoot)
|
|
230
|
+
: [],
|
|
231
|
+
};
|
|
232
|
+
const plan = {
|
|
233
|
+
project: loaded.config.project.name,
|
|
234
|
+
projectId: loaded.config.runtime.projectId,
|
|
235
|
+
target: loaded.config.runtime.target,
|
|
236
|
+
retainBaselineGenerations: loaded.config.cleanup.retainBaselineGenerations,
|
|
237
|
+
baselines,
|
|
238
|
+
runtime: {
|
|
239
|
+
included: includeRuntime,
|
|
240
|
+
detected: await inspection.runtimeDetected({ ...loaded }),
|
|
241
|
+
},
|
|
242
|
+
images,
|
|
243
|
+
docker,
|
|
244
|
+
};
|
|
245
|
+
return Object.freeze({ ...plan, digest: cleanupDigest(plan) });
|
|
246
|
+
};
|
|
247
|
+
const removePlannedImages = ({ candidates, projectRoot, }) => {
|
|
248
|
+
if (candidates.length === 0)
|
|
249
|
+
return [];
|
|
250
|
+
const usedImageIds = inspectUsedImageIds(projectRoot);
|
|
251
|
+
const removed = [];
|
|
252
|
+
for (const candidate of candidates) {
|
|
253
|
+
if (usedImageIds.has(candidate.id)) {
|
|
254
|
+
throw new Error(`Cleanup stopped because a container began using ${candidate.id}. Preview the cleanup again.`);
|
|
255
|
+
}
|
|
256
|
+
for (const tag of candidate.tags) {
|
|
257
|
+
const inspection = runDocker(["image", "inspect", "--format", "{{.Id}}", tag], { projectRoot, allowFailure: true });
|
|
258
|
+
if (inspection.status !== 0)
|
|
259
|
+
continue;
|
|
260
|
+
if (inspection.stdout.trim() !== candidate.id) {
|
|
261
|
+
throw new Error(`Cleanup stopped because ${tag} changed after the preview. Preview the cleanup again.`);
|
|
262
|
+
}
|
|
263
|
+
runDocker(["image", "rm", tag], { projectRoot });
|
|
264
|
+
removed.push(tag);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return removed;
|
|
268
|
+
};
|
|
269
|
+
export const applyRehearsalCleanup = async ({ plan, confirmation, projectRoot = process.cwd(), configPath, removeRuntime = async () => undefined, inspection, } = {}) => {
|
|
270
|
+
if (!plan || confirmation !== plan.digest) {
|
|
271
|
+
throw new Error("Cleanup requires the exact --confirm-cleanup digest from a fresh preview. Nothing was removed.");
|
|
272
|
+
}
|
|
273
|
+
const refreshed = await planRehearsalCleanup({
|
|
274
|
+
projectRoot,
|
|
275
|
+
...(configPath === undefined ? {} : { configPath }),
|
|
276
|
+
includeRuntime: plan.runtime.included,
|
|
277
|
+
includeImages: plan.images.inspected,
|
|
278
|
+
...(inspection ? { inspection } : {}),
|
|
279
|
+
});
|
|
280
|
+
if (refreshed.digest !== plan.digest) {
|
|
281
|
+
throw new Error("Cleanup targets changed after the preview. Nothing was removed; preview the cleanup again.");
|
|
282
|
+
}
|
|
283
|
+
if (plan.images.requested && !refreshed.images.inspected) {
|
|
284
|
+
throw new Error("Docker is unavailable, so Rehearsal cannot verify or remove unused images.");
|
|
285
|
+
}
|
|
286
|
+
if (refreshed.runtime.included && refreshed.runtime.detected) {
|
|
287
|
+
await removeRuntime();
|
|
288
|
+
}
|
|
289
|
+
const loaded = await loadRehearsalConfig({
|
|
290
|
+
projectRoot,
|
|
291
|
+
...(configPath === undefined ? {} : { configPath }),
|
|
292
|
+
});
|
|
293
|
+
const baselines = refreshed.baselines.available
|
|
294
|
+
? await pruneBaselineGenerations({
|
|
295
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
296
|
+
retain: refreshed.retainBaselineGenerations,
|
|
297
|
+
expectedRemoved: refreshed.baselines.removed,
|
|
298
|
+
})
|
|
299
|
+
: { retained: [], removed: [] };
|
|
300
|
+
const images = removePlannedImages({
|
|
301
|
+
candidates: refreshed.images.candidates,
|
|
302
|
+
projectRoot: loaded.projectRoot,
|
|
303
|
+
});
|
|
304
|
+
return {
|
|
305
|
+
project: refreshed.project,
|
|
306
|
+
projectId: refreshed.projectId,
|
|
307
|
+
baselines,
|
|
308
|
+
runtimeRemoved: refreshed.runtime.included && refreshed.runtime.detected,
|
|
309
|
+
imagesRemoved: images,
|
|
310
|
+
};
|
|
311
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Build and compare content-addressed application migration histories for
|
|
3
|
+
* Rehearsal without trusting timestamp-only migration alignment. Do not run directly;
|
|
4
|
+
* this module is reusable script infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
export interface MigrationHistoryEntry {
|
|
7
|
+
version: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
export interface MigrationFileEntry extends MigrationHistoryEntry {
|
|
11
|
+
filename: string;
|
|
12
|
+
fileSha256: string;
|
|
13
|
+
}
|
|
14
|
+
export interface MigrationSourceEntry extends MigrationFileEntry {
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
export interface MigrationLedgerEntry extends MigrationHistoryEntry {
|
|
18
|
+
statementCount: number;
|
|
19
|
+
statementSha256: string;
|
|
20
|
+
}
|
|
21
|
+
export interface MigrationReplayEntry extends MigrationFileEntry, MigrationLedgerEntry {
|
|
22
|
+
}
|
|
23
|
+
export interface MigrationReplayReceipt {
|
|
24
|
+
entries: MigrationReplayEntry[];
|
|
25
|
+
historySha256: string;
|
|
26
|
+
}
|
|
27
|
+
export declare const readMigrationFileInventory: (directory: URL) => Promise<MigrationFileEntry[]>;
|
|
28
|
+
export declare const readMigrationSourceBundle: ({ directory, sourceLedger, }: {
|
|
29
|
+
directory: URL;
|
|
30
|
+
sourceLedger: readonly MigrationLedgerEntry[];
|
|
31
|
+
}) => Promise<MigrationSourceEntry[]>;
|
|
32
|
+
export declare const buildMigrationLedgerInventory: (rows: unknown, label?: string) => MigrationLedgerEntry[];
|
|
33
|
+
export declare const createMigrationReplayReceipt: ({ files, ledger, }: {
|
|
34
|
+
files: readonly MigrationFileEntry[];
|
|
35
|
+
ledger: readonly MigrationLedgerEntry[];
|
|
36
|
+
}) => MigrationReplayReceipt;
|
|
37
|
+
export declare const compareSourceToReplay: ({ sourceLedger, replayReceipt, snapshotSchemaSha256, }: {
|
|
38
|
+
sourceLedger: readonly MigrationLedgerEntry[];
|
|
39
|
+
replayReceipt: MigrationReplayReceipt;
|
|
40
|
+
snapshotSchemaSha256?: string;
|
|
41
|
+
}) => {
|
|
42
|
+
cutoff: string;
|
|
43
|
+
candidates: MigrationReplayEntry[];
|
|
44
|
+
candidateSha256: string;
|
|
45
|
+
historicalStatementDifferences: number;
|
|
46
|
+
snapshotSchemaSha256: string | null;
|
|
47
|
+
};
|
|
48
|
+
export declare const assertNoCrossDatabaseMigrationVersions: ({ applicationFiles, apiFiles, }: {
|
|
49
|
+
applicationFiles: readonly MigrationHistoryEntry[];
|
|
50
|
+
apiFiles: readonly MigrationHistoryEntry[];
|
|
51
|
+
}) => void;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Build and compare content-addressed application migration histories for
|
|
3
|
+
* Rehearsal without trusting timestamp-only migration alignment. Do not run directly;
|
|
4
|
+
* this module is reusable script infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
8
|
+
const migrationNamePattern = /^(\d{14})_(.+)\.sql$/u;
|
|
9
|
+
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
10
|
+
const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
|
|
11
|
+
const normalizeStatement = (statement) => {
|
|
12
|
+
if (typeof statement !== "string") {
|
|
13
|
+
throw new Error("A Rehearsal migration ledger statement is not text.");
|
|
14
|
+
}
|
|
15
|
+
return statement.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
16
|
+
};
|
|
17
|
+
const assertOrderedUniqueHistory = (entries, label) => {
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
let previous = null;
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (!/^\d{14}$/u.test(entry.version)) {
|
|
22
|
+
throw new Error(`${label} has an invalid migration version: ${entry.version}`);
|
|
23
|
+
}
|
|
24
|
+
if (seen.has(entry.version)) {
|
|
25
|
+
throw new Error(`${label} contains duplicate migration ${entry.version}.`);
|
|
26
|
+
}
|
|
27
|
+
if (previous !== null && entry.version <= previous) {
|
|
28
|
+
throw new Error(`${label} is not in strictly increasing migration order at ${entry.version}.`);
|
|
29
|
+
}
|
|
30
|
+
seen.add(entry.version);
|
|
31
|
+
previous = entry.version;
|
|
32
|
+
}
|
|
33
|
+
return entries;
|
|
34
|
+
};
|
|
35
|
+
export const readMigrationFileInventory = async (directory) => {
|
|
36
|
+
const names = (await readdir(directory))
|
|
37
|
+
.filter((name) => name.endsWith(".sql"))
|
|
38
|
+
.sort();
|
|
39
|
+
const entries = [];
|
|
40
|
+
for (const filename of names) {
|
|
41
|
+
const match = migrationNamePattern.exec(filename);
|
|
42
|
+
if (!match) {
|
|
43
|
+
throw new Error(`Invalid migration filename in ${directory}: ${filename}`);
|
|
44
|
+
}
|
|
45
|
+
const bytes = await readFile(new URL(filename, directory));
|
|
46
|
+
entries.push({
|
|
47
|
+
version: match[1],
|
|
48
|
+
name: match[2],
|
|
49
|
+
filename,
|
|
50
|
+
fileSha256: sha256(bytes),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return [...assertOrderedUniqueHistory(entries, "Migration files")];
|
|
54
|
+
};
|
|
55
|
+
export const readMigrationSourceBundle = async ({ directory, sourceLedger, }) => {
|
|
56
|
+
assertOrderedUniqueHistory(sourceLedger, "Source migration ledger");
|
|
57
|
+
const inventory = await readMigrationFileInventory(directory);
|
|
58
|
+
if (sourceLedger.length > inventory.length) {
|
|
59
|
+
throw new Error("The source migration history contains a version absent from local migration source.");
|
|
60
|
+
}
|
|
61
|
+
const bundle = [];
|
|
62
|
+
for (let index = 0; index < sourceLedger.length; index += 1) {
|
|
63
|
+
const source = sourceLedger[index];
|
|
64
|
+
const file = inventory[index];
|
|
65
|
+
if (source.version !== file.version || source.name !== file.name) {
|
|
66
|
+
throw new Error(`Source migration ${source.version}_${source.name} does not match local file ${file.filename}.`);
|
|
67
|
+
}
|
|
68
|
+
bundle.push({
|
|
69
|
+
...file,
|
|
70
|
+
content: await readFile(new URL(file.filename, directory), "utf8"),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return bundle;
|
|
74
|
+
};
|
|
75
|
+
export const buildMigrationLedgerInventory = (rows, label = "Migration ledger") => {
|
|
76
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
77
|
+
throw new Error(`${label} is empty or unavailable.`);
|
|
78
|
+
}
|
|
79
|
+
const entries = rows.map((row) => {
|
|
80
|
+
if (!row ||
|
|
81
|
+
typeof row !== "object" ||
|
|
82
|
+
!("name" in row) ||
|
|
83
|
+
typeof row.name !== "string") {
|
|
84
|
+
throw new Error(`${label} contains an invalid row.`);
|
|
85
|
+
}
|
|
86
|
+
if (!("statements" in row) ||
|
|
87
|
+
!Array.isArray(row.statements) ||
|
|
88
|
+
row.statements.length === 0) {
|
|
89
|
+
throw new Error(`${label} migration ${"version" in row ? String(row.version) : "unknown"} has no applied statements.`);
|
|
90
|
+
}
|
|
91
|
+
const statements = row.statements.map(normalizeStatement);
|
|
92
|
+
return {
|
|
93
|
+
version: String("version" in row ? row.version : ""),
|
|
94
|
+
name: row.name,
|
|
95
|
+
statementCount: statements.length,
|
|
96
|
+
statementSha256: sha256(JSON.stringify(statements)),
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
return [...assertOrderedUniqueHistory(entries, label)];
|
|
100
|
+
};
|
|
101
|
+
export const createMigrationReplayReceipt = ({ files, ledger, }) => {
|
|
102
|
+
assertOrderedUniqueHistory(files, "Migration files");
|
|
103
|
+
assertOrderedUniqueHistory(ledger, "Replayed migration ledger");
|
|
104
|
+
if (files.length !== ledger.length) {
|
|
105
|
+
throw new Error(`Migration replay length mismatch: ${files.length} files but ${ledger.length} applied ledger rows.`);
|
|
106
|
+
}
|
|
107
|
+
const entries = files.map((file, index) => {
|
|
108
|
+
const applied = ledger[index];
|
|
109
|
+
if (file.version !== applied.version || file.name !== applied.name) {
|
|
110
|
+
throw new Error(`Migration replay mismatch at ${file.filename}: ledger contains ${applied.version}_${applied.name}.`);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
version: file.version,
|
|
114
|
+
name: file.name,
|
|
115
|
+
filename: file.filename,
|
|
116
|
+
fileSha256: file.fileSha256,
|
|
117
|
+
statementCount: applied.statementCount,
|
|
118
|
+
statementSha256: applied.statementSha256,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
entries,
|
|
123
|
+
historySha256: sha256(canonicalJson(entries)),
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
export const compareSourceToReplay = ({ sourceLedger, replayReceipt, snapshotSchemaSha256, }) => {
|
|
127
|
+
assertOrderedUniqueHistory(sourceLedger, "Source migration ledger");
|
|
128
|
+
if (!replayReceipt?.entries?.length || !replayReceipt.historySha256) {
|
|
129
|
+
throw new Error("The migration replay receipt is missing or invalid.");
|
|
130
|
+
}
|
|
131
|
+
if (sourceLedger.length > replayReceipt.entries.length) {
|
|
132
|
+
throw new Error("The source migration history contains a version absent from the reviewed replay.");
|
|
133
|
+
}
|
|
134
|
+
const snapshotBacked = snapshotSchemaSha256 !== undefined;
|
|
135
|
+
if (snapshotBacked && !/^[a-f0-9]{64}$/u.test(snapshotSchemaSha256)) {
|
|
136
|
+
throw new Error("The production schema snapshot receipt is invalid.");
|
|
137
|
+
}
|
|
138
|
+
let historicalStatementDifferences = 0;
|
|
139
|
+
for (let index = 0; index < sourceLedger.length; index += 1) {
|
|
140
|
+
const source = sourceLedger[index];
|
|
141
|
+
const replayed = replayReceipt.entries[index];
|
|
142
|
+
if (source.version !== replayed.version || source.name !== replayed.name) {
|
|
143
|
+
throw new Error(`Source migration history is not an exact replay prefix at ${source.version}_${source.name}.`);
|
|
144
|
+
}
|
|
145
|
+
if (source.statementCount !== replayed.statementCount ||
|
|
146
|
+
source.statementSha256 !== replayed.statementSha256) {
|
|
147
|
+
if (!snapshotBacked) {
|
|
148
|
+
throw new Error(`Applied statement content differs for migration ${source.version}_${source.name}.`);
|
|
149
|
+
}
|
|
150
|
+
historicalStatementDifferences += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const candidates = replayReceipt.entries.slice(sourceLedger.length);
|
|
154
|
+
return {
|
|
155
|
+
cutoff: sourceLedger.at(-1).version,
|
|
156
|
+
candidates,
|
|
157
|
+
candidateSha256: sha256(canonicalJson(candidates)),
|
|
158
|
+
historicalStatementDifferences,
|
|
159
|
+
snapshotSchemaSha256: snapshotBacked ? snapshotSchemaSha256 : null,
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
export const assertNoCrossDatabaseMigrationVersions = ({ applicationFiles, apiFiles, }) => {
|
|
163
|
+
const applicationVersions = new Set(applicationFiles.map((entry) => entry.version));
|
|
164
|
+
const collisions = apiFiles
|
|
165
|
+
.map((entry) => entry.version)
|
|
166
|
+
.filter((version) => applicationVersions.has(version));
|
|
167
|
+
if (collisions.length) {
|
|
168
|
+
throw new Error(`Primary and secondary database migration versions collide: ${collisions.join(", ")}.`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Build the immutable, audit-only Rehearsal execution plan and readiness
|
|
3
|
+
* report from project configuration and verified artifacts. Do not run directly;
|
|
4
|
+
* this module is reusable script infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
import type { RehearsalConfigPathOptions } from "../project/configuration.mjs";
|
|
7
|
+
export declare const buildRehearsalPlan: (options?: RehearsalConfigPathOptions) => Promise<{
|
|
8
|
+
config: {
|
|
9
|
+
schemaVersion: 1;
|
|
10
|
+
path: string;
|
|
11
|
+
project: string;
|
|
12
|
+
};
|
|
13
|
+
environment: {
|
|
14
|
+
target: "postgresql" | "supabase";
|
|
15
|
+
kind: string;
|
|
16
|
+
applicationUrl: string;
|
|
17
|
+
projectId: string;
|
|
18
|
+
ports: Readonly<{
|
|
19
|
+
api?: number;
|
|
20
|
+
database: number;
|
|
21
|
+
studio?: number;
|
|
22
|
+
}>;
|
|
23
|
+
hostedAccess: "disabled";
|
|
24
|
+
outboundNetwork: "deny";
|
|
25
|
+
authenticationProviders: readonly string[];
|
|
26
|
+
authenticationCallback: string | null;
|
|
27
|
+
barriers: string[];
|
|
28
|
+
};
|
|
29
|
+
baseline: {
|
|
30
|
+
rehearsalVersion: string;
|
|
31
|
+
formatVersion: 1 | 2;
|
|
32
|
+
generationId: string;
|
|
33
|
+
createdAt: string | null;
|
|
34
|
+
migrationCutoff: string;
|
|
35
|
+
migrationHistorySha256: string;
|
|
36
|
+
sanitizationPolicySha256: string;
|
|
37
|
+
dataSha256: string;
|
|
38
|
+
tableCount: number;
|
|
39
|
+
rowCount: number;
|
|
40
|
+
migrationCount: number;
|
|
41
|
+
verification: string;
|
|
42
|
+
};
|
|
43
|
+
migrations: {
|
|
44
|
+
representedCount: number;
|
|
45
|
+
candidateCount: number;
|
|
46
|
+
candidateSha256: string;
|
|
47
|
+
candidates: {
|
|
48
|
+
filename: string;
|
|
49
|
+
sha256: string;
|
|
50
|
+
}[];
|
|
51
|
+
};
|
|
52
|
+
execution: string[];
|
|
53
|
+
guarantee: string;
|
|
54
|
+
paths: {
|
|
55
|
+
artifactDirectory: string;
|
|
56
|
+
migrationDirectory: string;
|
|
57
|
+
sanitizationPolicy: string;
|
|
58
|
+
};
|
|
59
|
+
}>;
|
|
60
|
+
export declare const inspectRehearsalBaseline: (options?: RehearsalConfigPathOptions) => Promise<{
|
|
61
|
+
rehearsalVersion: string;
|
|
62
|
+
formatVersion: 1 | 2;
|
|
63
|
+
generationId: string;
|
|
64
|
+
createdAt: string | null;
|
|
65
|
+
migrationCutoff: string;
|
|
66
|
+
migrationHistorySha256: string;
|
|
67
|
+
sanitizationPolicySha256: string;
|
|
68
|
+
dataSha256: string;
|
|
69
|
+
tableCount: number;
|
|
70
|
+
rowCount: number;
|
|
71
|
+
migrationCount: number;
|
|
72
|
+
verification: string;
|
|
73
|
+
project: string;
|
|
74
|
+
provenance: {
|
|
75
|
+
configVersion: 1;
|
|
76
|
+
migrationCutoff: string;
|
|
77
|
+
sanitizationPolicySha256: string;
|
|
78
|
+
};
|
|
79
|
+
privacy: {
|
|
80
|
+
sourceRowsExposed: boolean;
|
|
81
|
+
sensitiveValuesIncludedInReport: boolean;
|
|
82
|
+
};
|
|
83
|
+
}>;
|
|
84
|
+
export declare const inspectRehearsalMigrations: (options?: RehearsalConfigPathOptions) => Promise<{
|
|
85
|
+
baselineGenerationId: string;
|
|
86
|
+
baselineCutoff: string;
|
|
87
|
+
candidateSha256: string;
|
|
88
|
+
migrations: {
|
|
89
|
+
version: string;
|
|
90
|
+
name: string;
|
|
91
|
+
filename: string;
|
|
92
|
+
sha256: string;
|
|
93
|
+
status: string;
|
|
94
|
+
}[];
|
|
95
|
+
definitions: {
|
|
96
|
+
represented_by_baseline: string;
|
|
97
|
+
applied_to_current_runtime: string;
|
|
98
|
+
candidate: string;
|
|
99
|
+
modified: string;
|
|
100
|
+
invalid: string;
|
|
101
|
+
};
|
|
102
|
+
}>;
|
|
103
|
+
export declare const runRehearsalDoctor: (options?: RehearsalConfigPathOptions) => Promise<{
|
|
104
|
+
state: string;
|
|
105
|
+
checks: {
|
|
106
|
+
id: string;
|
|
107
|
+
label: string;
|
|
108
|
+
status: string;
|
|
109
|
+
detail: string;
|
|
110
|
+
remediation: string;
|
|
111
|
+
}[];
|
|
112
|
+
ambientHostedVariables?: never;
|
|
113
|
+
} | {
|
|
114
|
+
state: string;
|
|
115
|
+
checks: ({
|
|
116
|
+
id: string;
|
|
117
|
+
label: string;
|
|
118
|
+
status: string;
|
|
119
|
+
detail: unknown;
|
|
120
|
+
durationMs: number;
|
|
121
|
+
remediation?: never;
|
|
122
|
+
} | {
|
|
123
|
+
id: string;
|
|
124
|
+
label: string;
|
|
125
|
+
status: string;
|
|
126
|
+
detail: string;
|
|
127
|
+
remediation: string;
|
|
128
|
+
durationMs: number;
|
|
129
|
+
})[];
|
|
130
|
+
ambientHostedVariables: {
|
|
131
|
+
presentButQuarantined: string[];
|
|
132
|
+
note: string;
|
|
133
|
+
};
|
|
134
|
+
}>;
|