@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,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Write, verify, and atomically activate immutable sanitized Rehearsal
|
|
3
|
+
* baseline generations. Do not run directly; this module is reusable script
|
|
4
|
+
* infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, readlink, rename, rm, symlink, } from "node:fs/promises";
|
|
8
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { createMigrationReplayReceipt, } from "../runtime/migration_history.mjs";
|
|
10
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
const hasErrorCode = (error, code) => isObject(error) && error.code === code;
|
|
12
|
+
const isBaselineRecord = (value) => isObject(value) &&
|
|
13
|
+
(value.schema === undefined ||
|
|
14
|
+
(typeof value.schema === "string" &&
|
|
15
|
+
/^[a-z][a-z0-9_]{0,62}$/u.test(value.schema))) &&
|
|
16
|
+
typeof value.table === "string" &&
|
|
17
|
+
/^[a-z][a-z0-9_]{0,62}$/u.test(value.table) &&
|
|
18
|
+
isObject(value.row);
|
|
19
|
+
const isByteStream = (value) => value !== null &&
|
|
20
|
+
value !== undefined &&
|
|
21
|
+
(Symbol.iterator in Object(value) || Symbol.asyncIterator in Object(value));
|
|
22
|
+
const parseManifest = (source) => {
|
|
23
|
+
const parsed = JSON.parse(source);
|
|
24
|
+
if (!isObject(parsed)) {
|
|
25
|
+
throw new Error("A Rehearsal baseline manifest must be an object.");
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
};
|
|
29
|
+
const generationPattern = /^\d{8}T\d{6}Z-[a-f0-9]{12}$/u;
|
|
30
|
+
const buildPrefix = ".building-";
|
|
31
|
+
const generationsDirectoryName = "generations";
|
|
32
|
+
const currentLinkName = "current";
|
|
33
|
+
const migrationNamePattern = /^\d{14}_[a-z0-9_]+\.sql$/u;
|
|
34
|
+
const storageBucketPattern = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
|
|
35
|
+
const maximumAssetBytes = 50 * 1024 * 1024;
|
|
36
|
+
const maximumTotalAssetBytes = 2 * 1024 * 1024 * 1024;
|
|
37
|
+
const relationPattern = /^(?:[a-z][a-z0-9_]{0,62}\.)?[a-z][a-z0-9_]{0,62}$/u;
|
|
38
|
+
const relationKey = (schema, table) => schema === "public" ? table : `${schema}.${table}`;
|
|
39
|
+
const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
|
|
40
|
+
const assertGenerationId = (value) => {
|
|
41
|
+
if (typeof value !== "string") {
|
|
42
|
+
throw new Error("Invalid Rehearsal baseline generation identifier.");
|
|
43
|
+
}
|
|
44
|
+
if (!generationPattern.test(value)) {
|
|
45
|
+
throw new Error(`Invalid Rehearsal baseline generation identifier: ${value}`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
};
|
|
49
|
+
const assertArtifactRoot = (artifactRoot) => {
|
|
50
|
+
const resolved = resolve(artifactRoot);
|
|
51
|
+
if (basename(resolved) !== ".rehearsal" || resolved === sep) {
|
|
52
|
+
throw new Error(`Refusing an unsafe Rehearsal artifact root: ${artifactRoot}`);
|
|
53
|
+
}
|
|
54
|
+
return resolved;
|
|
55
|
+
};
|
|
56
|
+
const pathInside = (parent, child) => {
|
|
57
|
+
const childRelative = relative(parent, child);
|
|
58
|
+
return (childRelative !== "" &&
|
|
59
|
+
!childRelative.startsWith(`..${sep}`) &&
|
|
60
|
+
childRelative !== ".." &&
|
|
61
|
+
!childRelative.startsWith(sep));
|
|
62
|
+
};
|
|
63
|
+
const assertInsideRoot = (root, target) => {
|
|
64
|
+
if (!pathInside(root, target)) {
|
|
65
|
+
throw new Error(`Rehearsal artifact path escaped its root: ${target}`);
|
|
66
|
+
}
|
|
67
|
+
return target;
|
|
68
|
+
};
|
|
69
|
+
const writeImmutableFile = async (path, content) => {
|
|
70
|
+
const handle = await open(path, "wx", 0o600);
|
|
71
|
+
try {
|
|
72
|
+
await handle.writeFile(content);
|
|
73
|
+
await handle.sync();
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await handle.close();
|
|
77
|
+
}
|
|
78
|
+
await chmod(path, 0o400);
|
|
79
|
+
};
|
|
80
|
+
const assetRelativePath = (bucket, objectPath) => {
|
|
81
|
+
if (!storageBucketPattern.test(bucket)) {
|
|
82
|
+
throw new Error(`Invalid Rehearsal Storage bucket: ${bucket}.`);
|
|
83
|
+
}
|
|
84
|
+
const segments = objectPath.split("/");
|
|
85
|
+
if (!objectPath ||
|
|
86
|
+
segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
|
87
|
+
throw new Error(`Invalid Rehearsal Storage object path: ${objectPath}.`);
|
|
88
|
+
}
|
|
89
|
+
return join("storage", encodeURIComponent(bucket), ...segments.map(encodeURIComponent));
|
|
90
|
+
};
|
|
91
|
+
const verifyNoSymlink = async (path, label) => {
|
|
92
|
+
try {
|
|
93
|
+
const stats = await lstat(path);
|
|
94
|
+
if (stats.isSymbolicLink()) {
|
|
95
|
+
throw new Error(`${label} may not be a symbolic link.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (!hasErrorCode(error, "ENOENT"))
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const makeArtifactTreeWritable = async (path) => {
|
|
104
|
+
await chmod(path, 0o700).catch(() => undefined);
|
|
105
|
+
for (const entry of await readdir(path, { withFileTypes: true }).catch(() => [])) {
|
|
106
|
+
const child = join(path, entry.name);
|
|
107
|
+
if (entry.isDirectory())
|
|
108
|
+
await makeArtifactTreeWritable(child);
|
|
109
|
+
else if (!entry.isSymbolicLink())
|
|
110
|
+
await chmod(child, 0o600);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
export const createBaselineGenerationId = ({ now = new Date(), } = {}) => {
|
|
114
|
+
const timestamp = now
|
|
115
|
+
.toISOString()
|
|
116
|
+
.replace(/\.\d{3}Z$/u, "Z")
|
|
117
|
+
.replaceAll("-", "")
|
|
118
|
+
.replaceAll(":", "");
|
|
119
|
+
return `${timestamp}-${randomBytes(6).toString("hex")}`;
|
|
120
|
+
};
|
|
121
|
+
export const createAndActivateBaseline = async ({ artifactRoot, generationId = createBaselineGenerationId(), records, metadata, expectedTables = [], migrationFiles = [], assets = [], schemaSql = null, activate = true, }) => {
|
|
122
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
123
|
+
assertGenerationId(generationId);
|
|
124
|
+
if (!Array.isArray(expectedTables) ||
|
|
125
|
+
expectedTables.some((name) => !relationPattern.test(name)) ||
|
|
126
|
+
new Set(expectedTables).size !== expectedTables.length) {
|
|
127
|
+
throw new Error("Rehearsal expected table names are invalid or duplicated.");
|
|
128
|
+
}
|
|
129
|
+
const expectedTableSet = new Set(expectedTables);
|
|
130
|
+
await verifyNoSymlink(root, "The Rehearsal artifact root");
|
|
131
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
132
|
+
const generationsRoot = assertInsideRoot(root, join(root, generationsDirectoryName));
|
|
133
|
+
await verifyNoSymlink(generationsRoot, "The Rehearsal generations directory");
|
|
134
|
+
await mkdir(generationsRoot, { recursive: true, mode: 0o700 });
|
|
135
|
+
const finalDirectory = assertInsideRoot(root, join(generationsRoot, generationId));
|
|
136
|
+
const buildDirectory = assertInsideRoot(root, join(root, `${buildPrefix}${generationId}-${randomBytes(4).toString("hex")}`));
|
|
137
|
+
const dataPath = join(buildDirectory, "sanitized-data.ndjson");
|
|
138
|
+
let dataHandle = null;
|
|
139
|
+
let finalCreated = false;
|
|
140
|
+
let rowCount = 0;
|
|
141
|
+
const tableCounts = new Map(expectedTables.map((name) => [name, 0]));
|
|
142
|
+
const dataHash = createHash("sha256");
|
|
143
|
+
try {
|
|
144
|
+
await mkdir(buildDirectory, { mode: 0o700 });
|
|
145
|
+
dataHandle = await open(dataPath, "wx", 0o600);
|
|
146
|
+
for await (const record of records) {
|
|
147
|
+
if (!isBaselineRecord(record)) {
|
|
148
|
+
throw new Error("A sanitized baseline record has an invalid shape.");
|
|
149
|
+
}
|
|
150
|
+
const key = relationKey(record.schema ?? "public", record.table);
|
|
151
|
+
if (expectedTableSet.size && !expectedTableSet.has(key)) {
|
|
152
|
+
throw new Error(`The baseline stream exposed unexpected table ${key}.`);
|
|
153
|
+
}
|
|
154
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
155
|
+
dataHash.update(line);
|
|
156
|
+
await dataHandle.write(line, null, "utf8");
|
|
157
|
+
rowCount += 1;
|
|
158
|
+
tableCounts.set(key, (tableCounts.get(key) ?? 0) + 1);
|
|
159
|
+
}
|
|
160
|
+
await dataHandle.sync();
|
|
161
|
+
await dataHandle.close();
|
|
162
|
+
dataHandle = null;
|
|
163
|
+
await chmod(dataPath, 0o400);
|
|
164
|
+
let schemaReceipt;
|
|
165
|
+
if (schemaSql !== null) {
|
|
166
|
+
if (typeof schemaSql !== "string" || schemaSql.length === 0) {
|
|
167
|
+
throw new Error("A Rehearsal production schema snapshot is invalid.");
|
|
168
|
+
}
|
|
169
|
+
const schemaPath = join(buildDirectory, "production-schema.sql");
|
|
170
|
+
await writeImmutableFile(schemaPath, schemaSql);
|
|
171
|
+
schemaReceipt = {
|
|
172
|
+
sha256: createHash("sha256").update(schemaSql).digest("hex"),
|
|
173
|
+
bytes: Buffer.byteLength(schemaSql),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const resolvedMigrationFiles = typeof migrationFiles === "function"
|
|
177
|
+
? await migrationFiles()
|
|
178
|
+
: await migrationFiles;
|
|
179
|
+
if (!Array.isArray(resolvedMigrationFiles)) {
|
|
180
|
+
throw new Error("Rehearsal migration source must be an array.");
|
|
181
|
+
}
|
|
182
|
+
if (!/^[a-f0-9]{64}$/u.test(metadata.migrationHistorySha256 ?? "")) {
|
|
183
|
+
throw new Error("A Rehearsal baseline requires a migration-history SHA-256.");
|
|
184
|
+
}
|
|
185
|
+
if (metadata.sourceMigrationHistory) {
|
|
186
|
+
const migrationReceipt = createMigrationReplayReceipt({
|
|
187
|
+
files: resolvedMigrationFiles,
|
|
188
|
+
ledger: metadata.sourceMigrationHistory,
|
|
189
|
+
});
|
|
190
|
+
if (migrationReceipt.historySha256 !== metadata.migrationHistorySha256) {
|
|
191
|
+
throw new Error("The Rehearsal baseline migration-history SHA-256 is invalid.");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const migrationDirectory = join(buildDirectory, "migrations");
|
|
195
|
+
await mkdir(migrationDirectory, { mode: 0o700 });
|
|
196
|
+
const migrationManifest = {};
|
|
197
|
+
for (const migration of resolvedMigrationFiles) {
|
|
198
|
+
if (!migration ||
|
|
199
|
+
!migrationNamePattern.test(migration.filename) ||
|
|
200
|
+
typeof migration.content !== "string" ||
|
|
201
|
+
Object.hasOwn(migrationManifest, migration.filename)) {
|
|
202
|
+
throw new Error("A Rehearsal migration bundle entry is invalid.");
|
|
203
|
+
}
|
|
204
|
+
const contentHash = createHash("sha256")
|
|
205
|
+
.update(migration.content)
|
|
206
|
+
.digest("hex");
|
|
207
|
+
if (migration.fileSha256 && migration.fileSha256 !== contentHash) {
|
|
208
|
+
throw new Error(`Rehearsal migration source checksum mismatch for ${migration.filename}.`);
|
|
209
|
+
}
|
|
210
|
+
await writeImmutableFile(join(migrationDirectory, migration.filename), migration.content);
|
|
211
|
+
migrationManifest[migration.filename] = { sha256: contentHash };
|
|
212
|
+
}
|
|
213
|
+
await chmod(migrationDirectory, 0o500);
|
|
214
|
+
const assetManifest = {};
|
|
215
|
+
let totalAssetBytes = 0;
|
|
216
|
+
for await (const asset of assets) {
|
|
217
|
+
if (!asset ||
|
|
218
|
+
typeof asset.bucket !== "string" ||
|
|
219
|
+
typeof asset.objectPath !== "string" ||
|
|
220
|
+
(!Buffer.isBuffer(asset.content) && !isByteStream(asset.content))) {
|
|
221
|
+
throw new Error("A Rehearsal Storage asset has an invalid shape.");
|
|
222
|
+
}
|
|
223
|
+
const relativePath = assetRelativePath(asset.bucket, asset.objectPath);
|
|
224
|
+
if (Object.hasOwn(assetManifest, relativePath)) {
|
|
225
|
+
throw new Error(`Duplicate Rehearsal Storage asset: ${relativePath}.`);
|
|
226
|
+
}
|
|
227
|
+
const target = assertInsideRoot(buildDirectory, join(buildDirectory, relativePath));
|
|
228
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
229
|
+
const handle = await open(target, "wx", 0o600);
|
|
230
|
+
const hash = createHash("sha256");
|
|
231
|
+
let bytes = 0;
|
|
232
|
+
try {
|
|
233
|
+
const chunks = Buffer.isBuffer(asset.content)
|
|
234
|
+
? [asset.content]
|
|
235
|
+
: asset.content;
|
|
236
|
+
for await (const chunk of chunks) {
|
|
237
|
+
const buffer = Buffer.from(chunk);
|
|
238
|
+
bytes += buffer.length;
|
|
239
|
+
totalAssetBytes += buffer.length;
|
|
240
|
+
if (bytes > maximumAssetBytes ||
|
|
241
|
+
totalAssetBytes > maximumTotalAssetBytes) {
|
|
242
|
+
throw new Error("The Rehearsal Storage asset boundary was exceeded.");
|
|
243
|
+
}
|
|
244
|
+
hash.update(buffer);
|
|
245
|
+
await handle.write(buffer);
|
|
246
|
+
}
|
|
247
|
+
await handle.sync();
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await handle.close();
|
|
251
|
+
}
|
|
252
|
+
await chmod(target, 0o400);
|
|
253
|
+
assetManifest[relativePath] = {
|
|
254
|
+
sha256: hash.digest("hex"),
|
|
255
|
+
bytes,
|
|
256
|
+
bucket: asset.bucket,
|
|
257
|
+
objectPath: asset.objectPath,
|
|
258
|
+
contentType: asset.contentType ?? "application/octet-stream",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const manifest = {
|
|
262
|
+
...metadata,
|
|
263
|
+
formatVersion: schemaReceipt ? 2 : 1,
|
|
264
|
+
generationId,
|
|
265
|
+
rowCount,
|
|
266
|
+
tableCounts: Object.fromEntries([...tableCounts].sort()),
|
|
267
|
+
files: {
|
|
268
|
+
"sanitized-data.ndjson": {
|
|
269
|
+
sha256: dataHash.digest("hex"),
|
|
270
|
+
rows: rowCount,
|
|
271
|
+
},
|
|
272
|
+
...(schemaReceipt ? { "production-schema.sql": schemaReceipt } : {}),
|
|
273
|
+
},
|
|
274
|
+
storageAssets: assetManifest,
|
|
275
|
+
migrations: migrationManifest,
|
|
276
|
+
};
|
|
277
|
+
await writeImmutableFile(join(buildDirectory, "baseline-manifest.json"), canonicalJson(manifest));
|
|
278
|
+
await rename(buildDirectory, finalDirectory);
|
|
279
|
+
finalCreated = true;
|
|
280
|
+
await chmod(finalDirectory, 0o500);
|
|
281
|
+
if (activate) {
|
|
282
|
+
await activateBaselineGeneration({
|
|
283
|
+
artifactRoot: root,
|
|
284
|
+
generationId,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return manifest;
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
let cleanupFailure;
|
|
291
|
+
if (dataHandle)
|
|
292
|
+
await dataHandle.close().catch(() => undefined);
|
|
293
|
+
await makeArtifactTreeWritable(buildDirectory).catch(() => undefined);
|
|
294
|
+
await rm(buildDirectory, { recursive: true, force: true }).catch((cleanupError) => {
|
|
295
|
+
cleanupFailure = cleanupError;
|
|
296
|
+
});
|
|
297
|
+
if (finalCreated) {
|
|
298
|
+
await makeArtifactTreeWritable(finalDirectory).catch(() => undefined);
|
|
299
|
+
await rm(finalDirectory, { recursive: true, force: true }).catch((cleanupError) => {
|
|
300
|
+
cleanupFailure ??= cleanupError;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (cleanupFailure &&
|
|
304
|
+
error &&
|
|
305
|
+
(typeof error === "object" || typeof error === "function")) {
|
|
306
|
+
try {
|
|
307
|
+
Object.defineProperty(error, "cleanupError", {
|
|
308
|
+
value: cleanupFailure,
|
|
309
|
+
enumerable: false,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Preserve the preparation failure even when its Error is immutable.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
export const activateBaselineGeneration = async ({ artifactRoot, generationId, }) => {
|
|
320
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
321
|
+
assertGenerationId(generationId);
|
|
322
|
+
const generationDirectory = assertInsideRoot(root, join(root, generationsDirectoryName, generationId));
|
|
323
|
+
const manifest = parseManifest(await readFile(join(generationDirectory, "baseline-manifest.json"), "utf8"));
|
|
324
|
+
if (manifest.generationId !== generationId) {
|
|
325
|
+
throw new Error("The staged baseline identifier does not match its manifest.");
|
|
326
|
+
}
|
|
327
|
+
const temporaryLink = assertInsideRoot(root, join(root, `.current-${randomBytes(4).toString("hex")}`));
|
|
328
|
+
await symlink(join(generationsDirectoryName, generationId), temporaryLink);
|
|
329
|
+
await rename(temporaryLink, join(root, currentLinkName));
|
|
330
|
+
return manifest;
|
|
331
|
+
};
|
|
332
|
+
export const removeStagedBaselineGeneration = async ({ artifactRoot, generationId, }) => {
|
|
333
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
334
|
+
assertGenerationId(generationId);
|
|
335
|
+
try {
|
|
336
|
+
const active = await resolveActiveBaselinePaths({ artifactRoot: root });
|
|
337
|
+
if (basename(active.generationDirectory) === generationId) {
|
|
338
|
+
throw new Error("Refusing to remove the active Rehearsal baseline generation.");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (!hasErrorCode(error, "ENOENT"))
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
const target = assertInsideRoot(root, join(root, generationsDirectoryName, generationId));
|
|
346
|
+
await verifyNoSymlink(target, "A staged Rehearsal baseline generation");
|
|
347
|
+
await makeArtifactTreeWritable(target);
|
|
348
|
+
await rm(target, { recursive: true, force: true });
|
|
349
|
+
};
|
|
350
|
+
export const resolveActiveBaselinePaths = async ({ artifactRoot, }) => {
|
|
351
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
352
|
+
const currentTarget = await readlink(join(root, currentLinkName));
|
|
353
|
+
const generationDirectory = resolve(root, currentTarget);
|
|
354
|
+
assertInsideRoot(root, generationDirectory);
|
|
355
|
+
if (dirname(generationDirectory) !== join(root, generationsDirectoryName)) {
|
|
356
|
+
throw new Error("The active Rehearsal baseline points outside generations.");
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
artifactRoot: root,
|
|
360
|
+
generationDirectory,
|
|
361
|
+
dataPath: join(generationDirectory, "sanitized-data.ndjson"),
|
|
362
|
+
manifestPath: join(generationDirectory, "baseline-manifest.json"),
|
|
363
|
+
migrationsDirectory: join(generationDirectory, "migrations"),
|
|
364
|
+
schemaPath: join(generationDirectory, "production-schema.sql"),
|
|
365
|
+
};
|
|
366
|
+
};
|
|
367
|
+
export const verifyActiveBaseline = async ({ artifactRoot, }) => {
|
|
368
|
+
const paths = await resolveActiveBaselinePaths({ artifactRoot });
|
|
369
|
+
const { generationDirectory } = paths;
|
|
370
|
+
const manifest = parseManifest(await readFile(paths.manifestPath, "utf8"));
|
|
371
|
+
assertGenerationId(manifest.generationId);
|
|
372
|
+
if (basename(generationDirectory) !== manifest.generationId) {
|
|
373
|
+
throw new Error("The active Rehearsal baseline identifier does not match its path.");
|
|
374
|
+
}
|
|
375
|
+
const data = await readFile(paths.dataPath);
|
|
376
|
+
const actualHash = createHash("sha256").update(data).digest("hex");
|
|
377
|
+
if (actualHash !== manifest.files?.["sanitized-data.ndjson"]?.sha256) {
|
|
378
|
+
throw new Error("The active Rehearsal baseline data checksum is invalid.");
|
|
379
|
+
}
|
|
380
|
+
if (manifest.files?.["production-schema.sql"]) {
|
|
381
|
+
const schema = await readFile(paths.schemaPath);
|
|
382
|
+
const receipt = manifest.files["production-schema.sql"];
|
|
383
|
+
if (receipt === undefined || receipt.bytes === undefined) {
|
|
384
|
+
throw new Error("The active Rehearsal production schema receipt is invalid.");
|
|
385
|
+
}
|
|
386
|
+
if (schema.length !== receipt.bytes ||
|
|
387
|
+
createHash("sha256").update(schema).digest("hex") !== receipt.sha256) {
|
|
388
|
+
throw new Error("The active Rehearsal production schema checksum is invalid.");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const migrationDirectory = paths.migrationsDirectory;
|
|
392
|
+
const expectedMigrationNames = Object.keys(manifest.migrations ?? {}).sort();
|
|
393
|
+
const actualMigrationNames = (await readdir(migrationDirectory)).sort();
|
|
394
|
+
if (expectedMigrationNames.join("\0") !== actualMigrationNames.join("\0")) {
|
|
395
|
+
throw new Error("The active Rehearsal migration bundle is not exact.");
|
|
396
|
+
}
|
|
397
|
+
for (const filename of expectedMigrationNames) {
|
|
398
|
+
if (!migrationNamePattern.test(filename)) {
|
|
399
|
+
throw new Error("The active Rehearsal migration manifest is invalid.");
|
|
400
|
+
}
|
|
401
|
+
const migrationHash = createHash("sha256")
|
|
402
|
+
.update(await readFile(join(migrationDirectory, filename)))
|
|
403
|
+
.digest("hex");
|
|
404
|
+
if (migrationHash !== manifest.migrations[filename]?.sha256) {
|
|
405
|
+
throw new Error(`The active Rehearsal migration checksum is invalid for ${filename}.`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (const [relativePath, receipt] of Object.entries(manifest.storageAssets ?? {})) {
|
|
409
|
+
if (relativePath !== assetRelativePath(receipt.bucket, receipt.objectPath)) {
|
|
410
|
+
throw new Error("The active Rehearsal Storage manifest path is invalid.");
|
|
411
|
+
}
|
|
412
|
+
const assetPath = assertInsideRoot(generationDirectory, join(generationDirectory, relativePath));
|
|
413
|
+
const asset = await readFile(assetPath);
|
|
414
|
+
if (asset.length !== receipt.bytes ||
|
|
415
|
+
createHash("sha256").update(asset).digest("hex") !== receipt.sha256) {
|
|
416
|
+
throw new Error(`The active Rehearsal Storage checksum is invalid for ${relativePath}.`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return manifest;
|
|
420
|
+
};
|
|
421
|
+
export const listIncompleteBaselineBuilds = async ({ artifactRoot, }) => {
|
|
422
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
423
|
+
try {
|
|
424
|
+
return (await readdir(root)).filter((name) => name.startsWith(buildPrefix));
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (hasErrorCode(error, "ENOENT"))
|
|
428
|
+
return [];
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
export const planBaselineGenerationPrune = async ({ artifactRoot, retain = 2, }) => {
|
|
433
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
434
|
+
if (!Number.isSafeInteger(retain) || retain < 1) {
|
|
435
|
+
throw new Error("Rehearsal baseline retention must be a positive integer.");
|
|
436
|
+
}
|
|
437
|
+
const active = await resolveActiveBaselinePaths({ artifactRoot: root });
|
|
438
|
+
const generationsRoot = join(root, generationsDirectoryName);
|
|
439
|
+
await verifyNoSymlink(generationsRoot, "The Rehearsal generations directory");
|
|
440
|
+
const entries = await readdir(generationsRoot, { withFileTypes: true });
|
|
441
|
+
for (const entry of entries) {
|
|
442
|
+
if (!entry.isDirectory() ||
|
|
443
|
+
entry.isSymbolicLink() ||
|
|
444
|
+
!generationPattern.test(entry.name)) {
|
|
445
|
+
throw new Error(`Refusing to prune an unexpected Rehearsal generation entry: ${entry.name}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const activeName = basename(active.generationDirectory);
|
|
449
|
+
const retained = new Set([
|
|
450
|
+
activeName,
|
|
451
|
+
...entries
|
|
452
|
+
.map((entry) => entry.name)
|
|
453
|
+
.sort()
|
|
454
|
+
.reverse(),
|
|
455
|
+
]
|
|
456
|
+
.filter((name, index, values) => values.indexOf(name) === index)
|
|
457
|
+
.slice(0, retain));
|
|
458
|
+
const removed = entries
|
|
459
|
+
.map((entry) => entry.name)
|
|
460
|
+
.filter((name) => !retained.has(name))
|
|
461
|
+
.sort();
|
|
462
|
+
return { retained: [...retained], removed };
|
|
463
|
+
};
|
|
464
|
+
export const planBaselineReplacementPrune = async ({ artifactRoot, retain = 2, }) => {
|
|
465
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
466
|
+
if (!Number.isSafeInteger(retain) || retain < 1) {
|
|
467
|
+
throw new Error("Rehearsal baseline retention must be a positive integer.");
|
|
468
|
+
}
|
|
469
|
+
await resolveActiveBaselinePaths({ artifactRoot: root });
|
|
470
|
+
const generationsRoot = join(root, generationsDirectoryName);
|
|
471
|
+
await verifyNoSymlink(generationsRoot, "The Rehearsal generations directory");
|
|
472
|
+
const entries = await readdir(generationsRoot, { withFileTypes: true });
|
|
473
|
+
for (const entry of entries) {
|
|
474
|
+
if (!entry.isDirectory() ||
|
|
475
|
+
entry.isSymbolicLink() ||
|
|
476
|
+
!generationPattern.test(entry.name)) {
|
|
477
|
+
throw new Error(`Refusing to plan replacement around an unexpected Rehearsal generation entry: ${entry.name}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const current = entries
|
|
481
|
+
.map((entry) => entry.name)
|
|
482
|
+
.sort()
|
|
483
|
+
.reverse();
|
|
484
|
+
const retained = current.slice(0, Math.max(0, retain - 1));
|
|
485
|
+
const retainedSet = new Set(retained);
|
|
486
|
+
return {
|
|
487
|
+
retainedAfterReplacement: retained,
|
|
488
|
+
removedAfterReplacement: current
|
|
489
|
+
.filter((name) => !retainedSet.has(name))
|
|
490
|
+
.sort(),
|
|
491
|
+
};
|
|
492
|
+
};
|
|
493
|
+
export const pruneBaselineGenerations = async ({ artifactRoot, retain = 2, expectedRemoved, }) => {
|
|
494
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
495
|
+
const plan = await planBaselineGenerationPrune({
|
|
496
|
+
artifactRoot: root,
|
|
497
|
+
retain,
|
|
498
|
+
});
|
|
499
|
+
const generationsRoot = join(root, generationsDirectoryName);
|
|
500
|
+
if (expectedRemoved &&
|
|
501
|
+
(expectedRemoved.length !== plan.removed.length ||
|
|
502
|
+
expectedRemoved.some((name, index) => name !== plan.removed[index]))) {
|
|
503
|
+
throw new Error("Rehearsal baseline generations changed after the cleanup preview.");
|
|
504
|
+
}
|
|
505
|
+
for (const name of plan.removed) {
|
|
506
|
+
const active = await resolveActiveBaselinePaths({ artifactRoot: root });
|
|
507
|
+
if (basename(active.generationDirectory) === name) {
|
|
508
|
+
throw new Error("Refusing to prune the active Rehearsal baseline generation.");
|
|
509
|
+
}
|
|
510
|
+
const target = assertInsideRoot(generationsRoot, join(generationsRoot, name));
|
|
511
|
+
await verifyNoSymlink(target, "A Rehearsal baseline generation");
|
|
512
|
+
await makeArtifactTreeWritable(target);
|
|
513
|
+
await rm(target, { recursive: true, force: true });
|
|
514
|
+
}
|
|
515
|
+
return plan;
|
|
516
|
+
};
|
|
517
|
+
export const removeBaselineArtifactRoot = async ({ artifactRoot, }) => {
|
|
518
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
519
|
+
await verifyNoSymlink(root, "The Rehearsal artifact root");
|
|
520
|
+
await makeArtifactTreeWritable(root);
|
|
521
|
+
await rm(root, { recursive: true, force: true });
|
|
522
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a synthetic baseline from explicit project-owned local files. This module does
|
|
3
|
+
* not connect to a hosted source or decide which values are safe.
|
|
4
|
+
*/
|
|
5
|
+
interface SyntheticBaselineOptions {
|
|
6
|
+
readonly projectRoot?: string;
|
|
7
|
+
readonly configPath?: string;
|
|
8
|
+
readonly recordsPath: string;
|
|
9
|
+
readonly ledgerPath: string;
|
|
10
|
+
readonly assetsPath?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const createSyntheticBaselineFromFiles: ({ projectRoot, configPath, recordsPath, ledgerPath, assetsPath, }: SyntheticBaselineOptions) => Promise<{
|
|
13
|
+
generationId: string;
|
|
14
|
+
migrationCutoff: string;
|
|
15
|
+
migrationCount: number;
|
|
16
|
+
rowCount: number;
|
|
17
|
+
tableCount: number;
|
|
18
|
+
}>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a synthetic baseline from explicit project-owned local files. This module does
|
|
3
|
+
* not connect to a hosted source or decide which values are safe.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { createReadStream } from "node:fs";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
import { createAndActivateBaseline, createBaselineGenerationId, } from "./artifact.mjs";
|
|
12
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
13
|
+
import { buildMigrationLedgerInventory, createMigrationReplayReceipt, readMigrationSourceBundle, } from "../runtime/migration_history.mjs";
|
|
14
|
+
import { validateRuntimeSanitizationPolicy } from "./sanitization_policy.mjs";
|
|
15
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
const parseAssets = (value) => {
|
|
17
|
+
if (!Array.isArray(value)) {
|
|
18
|
+
throw new Error("Synthetic assets manifest must be an array.");
|
|
19
|
+
}
|
|
20
|
+
return value.map((entry, index) => {
|
|
21
|
+
if (!isObject(entry) ||
|
|
22
|
+
typeof entry.bucket !== "string" ||
|
|
23
|
+
typeof entry.objectPath !== "string" ||
|
|
24
|
+
typeof entry.file !== "string" ||
|
|
25
|
+
(entry.contentType !== undefined && typeof entry.contentType !== "string")) {
|
|
26
|
+
throw new Error(`Synthetic asset ${index} has an invalid shape.`);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
bucket: entry.bucket,
|
|
30
|
+
objectPath: entry.objectPath,
|
|
31
|
+
file: entry.file,
|
|
32
|
+
...(entry.contentType === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { contentType: entry.contentType }),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const resolveProjectInput = (projectRoot, value, label) => {
|
|
39
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
40
|
+
throw new Error(`${label} is required.`);
|
|
41
|
+
}
|
|
42
|
+
const path = resolve(projectRoot, value);
|
|
43
|
+
const owned = relative(projectRoot, path);
|
|
44
|
+
if (owned === "" ||
|
|
45
|
+
owned === ".." ||
|
|
46
|
+
owned.startsWith(`..${sep}`) ||
|
|
47
|
+
isAbsolute(owned)) {
|
|
48
|
+
throw new Error(`${label} must be a file inside the project root.`);
|
|
49
|
+
}
|
|
50
|
+
return path;
|
|
51
|
+
};
|
|
52
|
+
const readRecords = async function* (path) {
|
|
53
|
+
const lines = createInterface({
|
|
54
|
+
input: createReadStream(path, { encoding: "utf8" }),
|
|
55
|
+
crlfDelay: Infinity,
|
|
56
|
+
});
|
|
57
|
+
for await (const line of lines) {
|
|
58
|
+
if (line.trim() === "")
|
|
59
|
+
continue;
|
|
60
|
+
let record;
|
|
61
|
+
try {
|
|
62
|
+
record = JSON.parse(line);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error("Synthetic baseline input contains invalid NDJSON.", {
|
|
66
|
+
cause: error,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
yield record;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
export const createSyntheticBaselineFromFiles = async ({ projectRoot = process.cwd(), configPath, recordsPath, ledgerPath, assetsPath, }) => {
|
|
73
|
+
const loaded = await loadRehearsalConfig({
|
|
74
|
+
projectRoot,
|
|
75
|
+
...(configPath === undefined ? {} : { configPath }),
|
|
76
|
+
});
|
|
77
|
+
const records = resolveProjectInput(loaded.projectRoot, recordsPath, "Synthetic records path");
|
|
78
|
+
const ledger = resolveProjectInput(loaded.projectRoot, ledgerPath, "Migration ledger path");
|
|
79
|
+
const [policyBytes, ledgerBytes] = await Promise.all([
|
|
80
|
+
readFile(loaded.paths.sanitizationPolicy),
|
|
81
|
+
readFile(ledger),
|
|
82
|
+
]);
|
|
83
|
+
const assets = assetsPath
|
|
84
|
+
? await Promise.all(parseAssets(JSON.parse(await readFile(resolveProjectInput(loaded.projectRoot, assetsPath, "Synthetic assets manifest path"), "utf8"))).map(async (asset) => ({
|
|
85
|
+
bucket: asset.bucket,
|
|
86
|
+
objectPath: asset.objectPath,
|
|
87
|
+
...(asset.contentType === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { contentType: asset.contentType }),
|
|
90
|
+
content: await readFile(resolveProjectInput(loaded.projectRoot, asset.file, "Synthetic asset file path")),
|
|
91
|
+
})))
|
|
92
|
+
: [];
|
|
93
|
+
const policy = validateRuntimeSanitizationPolicy(JSON.parse(policyBytes.toString("utf8")));
|
|
94
|
+
const expectedTables = policy.tables
|
|
95
|
+
.filter((table) => table.sourceRows !== "EXCLUDE")
|
|
96
|
+
.map((table) => (table.schema ?? "public") === "public"
|
|
97
|
+
? table.name
|
|
98
|
+
: `${table.schema}.${table.name}`);
|
|
99
|
+
if (expectedTables.length === 0) {
|
|
100
|
+
throw new Error("The sanitization policy includes no restorable tables.");
|
|
101
|
+
}
|
|
102
|
+
const sourceMigrationHistory = buildMigrationLedgerInventory(JSON.parse(ledgerBytes.toString("utf8")), "Synthetic migration ledger");
|
|
103
|
+
const migrationCutoff = sourceMigrationHistory.at(-1);
|
|
104
|
+
if (migrationCutoff === undefined) {
|
|
105
|
+
throw new Error("The synthetic migration ledger is empty.");
|
|
106
|
+
}
|
|
107
|
+
if (policy.migrationCutoff !== migrationCutoff.version) {
|
|
108
|
+
throw new Error(`The sanitization policy cutoff ${policy.migrationCutoff} does not match migration ledger cutoff ${migrationCutoff.version}.`);
|
|
109
|
+
}
|
|
110
|
+
const migrationFiles = await readMigrationSourceBundle({
|
|
111
|
+
directory: new URL("./", pathToFileURL(`${loaded.paths.migrationDirectory}${sep}`)),
|
|
112
|
+
sourceLedger: sourceMigrationHistory,
|
|
113
|
+
});
|
|
114
|
+
const migrationReceipt = createMigrationReplayReceipt({
|
|
115
|
+
files: migrationFiles,
|
|
116
|
+
ledger: sourceMigrationHistory,
|
|
117
|
+
});
|
|
118
|
+
const generationId = createBaselineGenerationId();
|
|
119
|
+
const baseline = await createAndActivateBaseline({
|
|
120
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
121
|
+
generationId,
|
|
122
|
+
records: readRecords(records),
|
|
123
|
+
metadata: {
|
|
124
|
+
migrationCutoff: migrationCutoff.version,
|
|
125
|
+
migrationHistorySha256: migrationReceipt.historySha256,
|
|
126
|
+
sanitizationPolicySha256: createHash("sha256")
|
|
127
|
+
.update(policyBytes)
|
|
128
|
+
.digest("hex"),
|
|
129
|
+
sourceMigrationHistory,
|
|
130
|
+
},
|
|
131
|
+
expectedTables,
|
|
132
|
+
migrationFiles,
|
|
133
|
+
assets,
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
generationId,
|
|
137
|
+
migrationCutoff: baseline.migrationCutoff,
|
|
138
|
+
migrationCount: Object.keys(baseline.migrations).length,
|
|
139
|
+
rowCount: baseline.rowCount,
|
|
140
|
+
tableCount: Object.keys(baseline.tableCounts).length,
|
|
141
|
+
};
|
|
142
|
+
};
|