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