@rehearsal-db/core 0.1.0-beta.1 → 0.1.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +244 -1
- package/COMPATIBILITY.md +17 -0
- package/README.md +139 -346
- package/SECURITY.md +2 -1
- package/SUPPORT.md +5 -0
- package/docs/README.md +29 -0
- package/docs/adapters.md +20 -6
- package/docs/architecture.md +58 -0
- package/docs/baselines.md +102 -22
- package/docs/commands.md +196 -33
- package/docs/configuration.md +258 -13
- package/docs/getting-started.md +106 -182
- package/docs/glossary.md +11 -8
- package/docs/production-source.md +176 -33
- package/docs/releasing.md +28 -38
- package/docs/roadmap.md +41 -0
- package/docs/runtime-policies.md +193 -0
- package/docs/sanitization.md +43 -8
- package/docs/security-model.md +34 -9
- package/docs/standalone-workflow.md +107 -0
- package/docs/troubleshooting.md +102 -2
- package/docs/tutorial.md +70 -59
- package/package.json +30 -22
- package/scripts/runtime/manage_database.mjs +18 -0
- package/src/README.md +17 -0
- package/src/application/session.mjs +435 -0
- package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +142 -17
- package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +19 -10
- package/src/baseline/input_discovery.mjs +155 -0
- package/src/baseline/policy_review.mjs +92 -0
- package/src/baseline/preparation.mjs +323 -0
- package/src/baseline/privacy_engine.mjs +413 -0
- package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +133 -3
- package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
- package/src/cli/arguments.mjs +123 -0
- package/src/cli/guided.mjs +812 -0
- package/src/cli/rehearsal.mjs +997 -0
- package/src/cli/renderers.mjs +584 -0
- package/src/cli/runtime_commands.mjs +624 -0
- package/src/cli/source_commands.mjs +326 -0
- package/src/cli/terminal.mjs +275 -0
- package/src/identity/claim.mjs +975 -0
- package/src/identity/storage.mjs +165 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +72 -4
- package/src/project/configuration.mjs +1199 -0
- package/src/project/setup.mjs +379 -0
- package/src/project/support_report.mjs +105 -0
- package/src/runtime/cleanup.mjs +381 -0
- package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +118 -20
- package/src/runtime/policy.mjs +438 -0
- package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +46 -27
- package/src/runtime/topology.mjs +177 -0
- package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +10 -3
- package/src/shared/human_output.mjs +4 -0
- package/src/shared/operation_guard.mjs +162 -0
- package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
- package/src/source/access.mjs +578 -0
- package/src/source/asset_transfer.mjs +177 -0
- package/src/source/baseline.mjs +446 -0
- package/src/source/postgresql_access.mjs +480 -0
- package/src/targets/postgresql.mjs +808 -0
- package/{scripts/operations/database/manage_rehearsal_database.mjs → src/targets/supabase.mjs} +66 -20
- package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +60 -28
- package/src/targets/target.mjs +66 -0
- package/scripts/lib/rehearsal/configuration.mjs +0 -559
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -565
- /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
|
@@ -12,13 +12,14 @@ import { pathToFileURL } from "node:url";
|
|
|
12
12
|
import {
|
|
13
13
|
createAndActivateBaseline,
|
|
14
14
|
createBaselineGenerationId,
|
|
15
|
-
} from "./
|
|
16
|
-
import { loadRehearsalConfig } from "
|
|
15
|
+
} from "./artifact.mjs";
|
|
16
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
17
17
|
import {
|
|
18
18
|
buildMigrationLedgerInventory,
|
|
19
19
|
createMigrationReplayReceipt,
|
|
20
20
|
readMigrationSourceBundle,
|
|
21
|
-
} from "
|
|
21
|
+
} from "../runtime/migration_history.mjs";
|
|
22
|
+
import { validateRuntimeSanitizationPolicy } from "./sanitization_policy.mjs";
|
|
22
23
|
|
|
23
24
|
const resolveProjectInput = (projectRoot, value, label) => {
|
|
24
25
|
if (typeof value !== "string" || value.trim() === "") {
|
|
@@ -103,13 +104,16 @@ export const createSyntheticBaselineFromFiles = async ({
|
|
|
103
104
|
})),
|
|
104
105
|
)
|
|
105
106
|
: [];
|
|
106
|
-
const policy =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
107
|
+
const policy = validateRuntimeSanitizationPolicy(
|
|
108
|
+
JSON.parse(policyBytes.toString("utf8")),
|
|
109
|
+
);
|
|
110
110
|
const expectedTables = policy.tables
|
|
111
111
|
.filter((table) => table.sourceRows !== "EXCLUDE")
|
|
112
|
-
.map((table) =>
|
|
112
|
+
.map((table) =>
|
|
113
|
+
(table.schema ?? "public") === "public"
|
|
114
|
+
? table.name
|
|
115
|
+
: `${table.schema}.${table.name}`,
|
|
116
|
+
);
|
|
113
117
|
if (expectedTables.length === 0) {
|
|
114
118
|
throw new Error("The sanitization policy includes no restorable tables.");
|
|
115
119
|
}
|
|
@@ -117,6 +121,11 @@ export const createSyntheticBaselineFromFiles = async ({
|
|
|
117
121
|
JSON.parse(ledgerBytes.toString("utf8")),
|
|
118
122
|
"Synthetic migration ledger",
|
|
119
123
|
);
|
|
124
|
+
if (policy.migrationCutoff !== sourceMigrationHistory.at(-1).version) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`The sanitization policy cutoff ${policy.migrationCutoff} does not match migration ledger cutoff ${sourceMigrationHistory.at(-1).version}.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
120
129
|
const migrationFiles = await readMigrationSourceBundle({
|
|
121
130
|
directory: new URL(
|
|
122
131
|
"./",
|
|
@@ -148,8 +157,8 @@ export const createSyntheticBaselineFromFiles = async ({
|
|
|
148
157
|
return {
|
|
149
158
|
generationId,
|
|
150
159
|
migrationCutoff: baseline.migrationCutoff,
|
|
151
|
-
migrationCount: baseline.
|
|
160
|
+
migrationCount: Object.keys(baseline.migrations).length,
|
|
152
161
|
rowCount: baseline.rowCount,
|
|
153
|
-
tableCount: baseline.
|
|
162
|
+
tableCount: Object.keys(baseline.tableCounts).length,
|
|
154
163
|
};
|
|
155
164
|
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover likely project-owned baseline inputs without exposing their values.
|
|
3
|
+
* Discovery is bounded, ignores generated/private directories, and only sniffs
|
|
4
|
+
* small JSON or NDJSON files before returning project-relative paths.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { open, readFile, readdir, stat } from "node:fs/promises";
|
|
8
|
+
import { extname, join, relative, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
const ignoredDirectories = new Set([
|
|
11
|
+
".git",
|
|
12
|
+
".next",
|
|
13
|
+
".rehearsal",
|
|
14
|
+
".turbo",
|
|
15
|
+
"build",
|
|
16
|
+
"coverage",
|
|
17
|
+
"dist",
|
|
18
|
+
"node_modules",
|
|
19
|
+
]);
|
|
20
|
+
const maximumDepth = 4;
|
|
21
|
+
const maximumFiles = 2_000;
|
|
22
|
+
const maximumJsonBytes = 2 * 1024 * 1024;
|
|
23
|
+
const maximumNdjsonProbeBytes = 64 * 1024;
|
|
24
|
+
|
|
25
|
+
const projectRelativePath = (projectRoot, path) =>
|
|
26
|
+
relative(projectRoot, path).split(sep).join("/");
|
|
27
|
+
|
|
28
|
+
const preference = {
|
|
29
|
+
records: [
|
|
30
|
+
"rehearsal/sanitized-data.ndjson",
|
|
31
|
+
"rehearsal/synthetic-data.ndjson",
|
|
32
|
+
],
|
|
33
|
+
ledgers: ["rehearsal/migration-ledger.json"],
|
|
34
|
+
assetManifests: ["rehearsal/assets.json"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const sortCandidates = (kind, candidates) => {
|
|
38
|
+
const preferred = new Map(
|
|
39
|
+
preference[kind].map((path, index) => [path, index]),
|
|
40
|
+
);
|
|
41
|
+
return candidates.sort((left, right) => {
|
|
42
|
+
const leftRank = preferred.get(left) ?? Number.POSITIVE_INFINITY;
|
|
43
|
+
const rightRank = preferred.get(right) ?? Number.POSITIVE_INFINITY;
|
|
44
|
+
return leftRank - rightRank || left.localeCompare(right);
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const readFirstRecord = async (path) => {
|
|
49
|
+
const handle = await open(path, "r");
|
|
50
|
+
try {
|
|
51
|
+
const buffer = Buffer.alloc(maximumNdjsonProbeBytes);
|
|
52
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
53
|
+
const probe = buffer.subarray(0, bytesRead).toString("utf8");
|
|
54
|
+
const lines = probe.split(/\r?\n/u);
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
return JSON.parse(line);
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const isRecord = (value) =>
|
|
66
|
+
Boolean(
|
|
67
|
+
value &&
|
|
68
|
+
typeof value === "object" &&
|
|
69
|
+
!Array.isArray(value) &&
|
|
70
|
+
typeof value.table === "string" &&
|
|
71
|
+
value.row &&
|
|
72
|
+
typeof value.row === "object" &&
|
|
73
|
+
!Array.isArray(value.row),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const isMigrationLedger = (value) =>
|
|
77
|
+
Array.isArray(value) &&
|
|
78
|
+
value.length > 0 &&
|
|
79
|
+
value.every(
|
|
80
|
+
(entry) =>
|
|
81
|
+
entry &&
|
|
82
|
+
typeof entry === "object" &&
|
|
83
|
+
typeof entry.version === "string" &&
|
|
84
|
+
typeof entry.name === "string" &&
|
|
85
|
+
Array.isArray(entry.statements),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const isAssetManifest = (value) =>
|
|
89
|
+
Array.isArray(value) &&
|
|
90
|
+
value.length > 0 &&
|
|
91
|
+
value.every(
|
|
92
|
+
(entry) =>
|
|
93
|
+
entry &&
|
|
94
|
+
typeof entry === "object" &&
|
|
95
|
+
typeof entry.bucket === "string" &&
|
|
96
|
+
typeof entry.objectPath === "string" &&
|
|
97
|
+
typeof entry.file === "string",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const collectCandidateFiles = async (projectRoot) => {
|
|
101
|
+
const files = [];
|
|
102
|
+
const visit = async (directory, depth) => {
|
|
103
|
+
if (depth > maximumDepth || files.length >= maximumFiles) return;
|
|
104
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(
|
|
105
|
+
() => [],
|
|
106
|
+
);
|
|
107
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (files.length >= maximumFiles) return;
|
|
110
|
+
if (entry.isSymbolicLink()) continue;
|
|
111
|
+
const path = join(directory, entry.name);
|
|
112
|
+
if (entry.isDirectory()) {
|
|
113
|
+
if (!ignoredDirectories.has(entry.name)) await visit(path, depth + 1);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!entry.isFile() || ![".json", ".ndjson"].includes(extname(path))) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
files.push(path);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
await visit(projectRoot, 0);
|
|
123
|
+
return files;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const discoverBaselineInputFiles = async ({
|
|
127
|
+
projectRoot = process.cwd(),
|
|
128
|
+
} = {}) => {
|
|
129
|
+
const result = { records: [], ledgers: [], assetManifests: [] };
|
|
130
|
+
for (const path of await collectCandidateFiles(projectRoot)) {
|
|
131
|
+
const pathStats = await stat(path).catch(() => undefined);
|
|
132
|
+
if (!pathStats?.isFile()) continue;
|
|
133
|
+
const relativePath = projectRelativePath(projectRoot, path);
|
|
134
|
+
try {
|
|
135
|
+
if (extname(path) === ".ndjson") {
|
|
136
|
+
if (isRecord(await readFirstRecord(path))) {
|
|
137
|
+
result.records.push(relativePath);
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (pathStats.size > maximumJsonBytes) continue;
|
|
142
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
143
|
+
if (isMigrationLedger(value)) result.ledgers.push(relativePath);
|
|
144
|
+
else if (isAssetManifest(value)) result.assetManifests.push(relativePath);
|
|
145
|
+
} catch {
|
|
146
|
+
// Invalid or unreadable files are not candidates; explicit validation will
|
|
147
|
+
// provide actionable errors if the user enters one manually.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
records: sortCandidates("records", result.records),
|
|
152
|
+
ledgers: sortCandidates("ledgers", result.ledgers),
|
|
153
|
+
assetManifests: sortCandidates("assetManifests", result.assetManifests),
|
|
154
|
+
};
|
|
155
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Complete and safely apply a generated REVIEW REQUIRED policy draft.
|
|
3
|
+
* The caller owns the interactive presentation; this module owns validation and
|
|
4
|
+
* concurrent-change refusal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { validateRuntimeSanitizationPolicy } from "./sanitization_policy.mjs";
|
|
9
|
+
|
|
10
|
+
export const SAFE_COLUMN_PRESET = Object.freeze({
|
|
11
|
+
action: "REPLACE WITH SYNTHETIC",
|
|
12
|
+
generated: "NEVER",
|
|
13
|
+
identity: "NO",
|
|
14
|
+
foreignKey: null,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const createSafeTablePreset = (columns) =>
|
|
18
|
+
Object.fromEntries(
|
|
19
|
+
columns.map((column) => [
|
|
20
|
+
typeof column === "string" ? column : column.name,
|
|
21
|
+
{ ...SAFE_COLUMN_PRESET },
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export const suggestPolicyExceptionColumns = (columns) =>
|
|
26
|
+
columns
|
|
27
|
+
.map((column) => (typeof column === "string" ? column : column.name))
|
|
28
|
+
.filter(
|
|
29
|
+
(column) =>
|
|
30
|
+
column === "id" ||
|
|
31
|
+
column.endsWith("_id") ||
|
|
32
|
+
["created_at", "updated_at", "deleted_at"].includes(column),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const readReviewablePolicyDraft = async (path) => {
|
|
36
|
+
const source = await readFile(path, "utf8");
|
|
37
|
+
const draft = JSON.parse(source);
|
|
38
|
+
if (draft?.draft !== true || !Array.isArray(draft.tables)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Interactive review requires a generated sanitization policy with draft: true.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return { source, draft };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const completePolicyDraft = async ({
|
|
47
|
+
draft,
|
|
48
|
+
reviewColumn,
|
|
49
|
+
reviewTable,
|
|
50
|
+
}) => {
|
|
51
|
+
if (draft?.draft !== true || !Array.isArray(draft.tables)) {
|
|
52
|
+
throw new Error("A generated REVIEW REQUIRED policy draft is required.");
|
|
53
|
+
}
|
|
54
|
+
const tables = [];
|
|
55
|
+
for (const table of draft.tables) {
|
|
56
|
+
const columnNames = (table.columns ?? []).map((column) => column.name);
|
|
57
|
+
const tableDecisions = reviewTable
|
|
58
|
+
? await reviewTable({ table: table.name, columns: columnNames })
|
|
59
|
+
: {};
|
|
60
|
+
const columns = [];
|
|
61
|
+
for (const column of table.columns ?? []) {
|
|
62
|
+
const reviewed =
|
|
63
|
+
tableDecisions?.[column.name] ??
|
|
64
|
+
(await reviewColumn?.({
|
|
65
|
+
table: table.name,
|
|
66
|
+
column: column.name,
|
|
67
|
+
}));
|
|
68
|
+
if (!reviewed) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Policy review did not classify ${table.name}.${column.name}.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
columns.push({ name: column.name, ...reviewed });
|
|
74
|
+
}
|
|
75
|
+
tables.push({ ...table, columns });
|
|
76
|
+
}
|
|
77
|
+
const reviewed = { ...draft, tables };
|
|
78
|
+
delete reviewed.draft;
|
|
79
|
+
return validateRuntimeSanitizationPolicy(reviewed);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const applyReviewedPolicy = async ({ path, originalSource, policy }) => {
|
|
83
|
+
validateRuntimeSanitizationPolicy(policy);
|
|
84
|
+
if ((await readFile(path, "utf8")) !== originalSource) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"The sanitization policy changed during review; no reviewed policy was written.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
await writeFile(path, `${JSON.stringify(policy, null, 2)}\n`, {
|
|
90
|
+
mode: 0o600,
|
|
91
|
+
});
|
|
92
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Inspect explicit local synthetic records and migration evidence, then
|
|
3
|
+
* create a fail-closed sanitization-policy draft for human review.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createReadStream } from "node:fs";
|
|
7
|
+
import { access, lstat, mkdir, open, readFile } from "node:fs/promises";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
11
|
+
import { buildMigrationLedgerInventory } from "../runtime/migration_history.mjs";
|
|
12
|
+
|
|
13
|
+
const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
14
|
+
const storageBucketPattern = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
|
|
15
|
+
|
|
16
|
+
const resolveProjectInput = (projectRoot, value, label) => {
|
|
17
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
18
|
+
throw new Error(`${label} is required.`);
|
|
19
|
+
}
|
|
20
|
+
const path = resolve(projectRoot, value);
|
|
21
|
+
const owned = relative(projectRoot, path);
|
|
22
|
+
if (
|
|
23
|
+
owned === "" ||
|
|
24
|
+
owned === ".." ||
|
|
25
|
+
owned.startsWith(`..${sep}`) ||
|
|
26
|
+
isAbsolute(owned)
|
|
27
|
+
) {
|
|
28
|
+
throw new Error(`${label} must be a file inside the project root.`);
|
|
29
|
+
}
|
|
30
|
+
return path;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const pathExists = (path) =>
|
|
34
|
+
access(path)
|
|
35
|
+
.then(() => true)
|
|
36
|
+
.catch((error) => {
|
|
37
|
+
if (error?.code === "ENOENT") return false;
|
|
38
|
+
throw error;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const assertRegularInput = async (path, label) => {
|
|
42
|
+
let stats;
|
|
43
|
+
try {
|
|
44
|
+
stats = await lstat(path);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code === "ENOENT") throw new Error(`${label} does not exist.`);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
50
|
+
throw new Error(`${label} must be a regular project-local file.`);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const inspectSyntheticRecords = async (path) => {
|
|
55
|
+
const tables = new Map();
|
|
56
|
+
let rowCount = 0;
|
|
57
|
+
const lines = createInterface({
|
|
58
|
+
input: createReadStream(path, { encoding: "utf8" }),
|
|
59
|
+
crlfDelay: Infinity,
|
|
60
|
+
});
|
|
61
|
+
for await (const line of lines) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
let record;
|
|
64
|
+
try {
|
|
65
|
+
record = JSON.parse(line);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new Error("Synthetic baseline input contains invalid NDJSON.", {
|
|
68
|
+
cause: error,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
!record ||
|
|
73
|
+
!identifierPattern.test(record.schema ?? "public") ||
|
|
74
|
+
!identifierPattern.test(record.table ?? "") ||
|
|
75
|
+
!record.row ||
|
|
76
|
+
typeof record.row !== "object" ||
|
|
77
|
+
Array.isArray(record.row)
|
|
78
|
+
) {
|
|
79
|
+
throw new Error("A synthetic baseline record has an invalid shape.");
|
|
80
|
+
}
|
|
81
|
+
const columns = Object.keys(record.row);
|
|
82
|
+
if (
|
|
83
|
+
columns.length === 0 ||
|
|
84
|
+
columns.some((column) => !identifierPattern.test(column))
|
|
85
|
+
) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Synthetic baseline table ${record.table} has invalid or empty columns.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const schema = record.schema ?? "public";
|
|
91
|
+
const key =
|
|
92
|
+
schema === "public" ? record.table : `${schema}.${record.table}`;
|
|
93
|
+
const table = tables.get(key) ?? {
|
|
94
|
+
schema,
|
|
95
|
+
name: record.table,
|
|
96
|
+
rowCount: 0,
|
|
97
|
+
columns: new Set(),
|
|
98
|
+
};
|
|
99
|
+
table.rowCount += 1;
|
|
100
|
+
for (const column of columns) table.columns.add(column);
|
|
101
|
+
tables.set(key, table);
|
|
102
|
+
rowCount += 1;
|
|
103
|
+
}
|
|
104
|
+
if (rowCount === 0) {
|
|
105
|
+
throw new Error("Synthetic baseline input contains no records.");
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
rowCount,
|
|
109
|
+
tables: [...tables.values()]
|
|
110
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
111
|
+
.map((table) => ({
|
|
112
|
+
schema: table.schema,
|
|
113
|
+
name: table.name,
|
|
114
|
+
rowCount: table.rowCount,
|
|
115
|
+
columns: [...table.columns].sort(),
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const inspectAssetManifest = async (path, projectRoot) => {
|
|
121
|
+
let manifest;
|
|
122
|
+
try {
|
|
123
|
+
manifest = JSON.parse(await readFile(path, "utf8"));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new Error("Storage asset manifest contains invalid JSON.", {
|
|
126
|
+
cause: error,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(manifest)) {
|
|
130
|
+
throw new Error("Storage asset manifest must be a JSON array.");
|
|
131
|
+
}
|
|
132
|
+
const destinations = new Set();
|
|
133
|
+
for (const [index, asset] of manifest.entries()) {
|
|
134
|
+
if (
|
|
135
|
+
!asset ||
|
|
136
|
+
typeof asset !== "object" ||
|
|
137
|
+
Array.isArray(asset) ||
|
|
138
|
+
!storageBucketPattern.test(asset.bucket ?? "") ||
|
|
139
|
+
typeof asset.objectPath !== "string" ||
|
|
140
|
+
!asset.objectPath ||
|
|
141
|
+
asset.objectPath
|
|
142
|
+
.split("/")
|
|
143
|
+
.some((segment) => !segment || segment === "." || segment === "..") ||
|
|
144
|
+
typeof asset.file !== "string" ||
|
|
145
|
+
!asset.file.trim() ||
|
|
146
|
+
(asset.contentType !== undefined && typeof asset.contentType !== "string")
|
|
147
|
+
) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Storage asset manifest entry ${index + 1} has an invalid shape.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const destination = `${asset.bucket}/${asset.objectPath}`;
|
|
153
|
+
if (destinations.has(destination)) {
|
|
154
|
+
throw new Error(`Duplicate Storage asset destination: ${destination}.`);
|
|
155
|
+
}
|
|
156
|
+
destinations.add(destination);
|
|
157
|
+
const assetFile = resolveProjectInput(
|
|
158
|
+
projectRoot,
|
|
159
|
+
asset.file,
|
|
160
|
+
`Storage asset file in entry ${index + 1}`,
|
|
161
|
+
);
|
|
162
|
+
await assertRegularInput(
|
|
163
|
+
assetFile,
|
|
164
|
+
`Storage asset file in entry ${index + 1}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return { assetCount: manifest.length };
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const inspectBaselineInputFiles = async ({
|
|
171
|
+
projectRoot = process.cwd(),
|
|
172
|
+
configPath,
|
|
173
|
+
recordsPath,
|
|
174
|
+
ledgerPath,
|
|
175
|
+
assetsPath,
|
|
176
|
+
}) => {
|
|
177
|
+
const loaded = await loadRehearsalConfig({ projectRoot, configPath });
|
|
178
|
+
const records = resolveProjectInput(
|
|
179
|
+
loaded.projectRoot,
|
|
180
|
+
recordsPath,
|
|
181
|
+
"Synthetic records path",
|
|
182
|
+
);
|
|
183
|
+
const ledger = resolveProjectInput(
|
|
184
|
+
loaded.projectRoot,
|
|
185
|
+
ledgerPath,
|
|
186
|
+
"Migration ledger path",
|
|
187
|
+
);
|
|
188
|
+
const assets = assetsPath
|
|
189
|
+
? resolveProjectInput(
|
|
190
|
+
loaded.projectRoot,
|
|
191
|
+
assetsPath,
|
|
192
|
+
"Storage asset manifest path",
|
|
193
|
+
)
|
|
194
|
+
: undefined;
|
|
195
|
+
await Promise.all([
|
|
196
|
+
assertRegularInput(records, "Synthetic records path"),
|
|
197
|
+
assertRegularInput(ledger, "Migration ledger path"),
|
|
198
|
+
...(assets
|
|
199
|
+
? [assertRegularInput(assets, "Storage asset manifest path")]
|
|
200
|
+
: []),
|
|
201
|
+
]);
|
|
202
|
+
let ledgerRows;
|
|
203
|
+
try {
|
|
204
|
+
ledgerRows = JSON.parse(await readFile(ledger, "utf8"));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error("Migration ledger contains invalid JSON.", {
|
|
207
|
+
cause: error,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const [inspection, assetInspection] = await Promise.all([
|
|
211
|
+
inspectSyntheticRecords(records),
|
|
212
|
+
assets
|
|
213
|
+
? inspectAssetManifest(assets, loaded.projectRoot)
|
|
214
|
+
: Promise.resolve({ assetCount: 0 }),
|
|
215
|
+
]);
|
|
216
|
+
const migrationHistory = buildMigrationLedgerInventory(
|
|
217
|
+
ledgerRows,
|
|
218
|
+
"Synthetic migration ledger",
|
|
219
|
+
);
|
|
220
|
+
return {
|
|
221
|
+
projectRoot: loaded.projectRoot,
|
|
222
|
+
recordsPath: relative(loaded.projectRoot, records),
|
|
223
|
+
ledgerPath: relative(loaded.projectRoot, ledger),
|
|
224
|
+
assetsPath: assets ? relative(loaded.projectRoot, assets) : undefined,
|
|
225
|
+
migrationHistory,
|
|
226
|
+
migrationCutoff: migrationHistory.at(-1).version,
|
|
227
|
+
migrationCount: migrationHistory.length,
|
|
228
|
+
rowCount: inspection.rowCount,
|
|
229
|
+
tables: inspection.tables,
|
|
230
|
+
assetCount: assetInspection.assetCount,
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const renderPolicyDraft = ({ migrationCutoff, tables }) =>
|
|
235
|
+
`${JSON.stringify(
|
|
236
|
+
{
|
|
237
|
+
policyVersion: 1,
|
|
238
|
+
draft: true,
|
|
239
|
+
migrationCutoff,
|
|
240
|
+
tables: tables.map((table) => ({
|
|
241
|
+
...(table.schema === "public" ? {} : { schema: table.schema }),
|
|
242
|
+
name: table.name,
|
|
243
|
+
group: "synthetic",
|
|
244
|
+
sourceRows: "STREAM AND SANITIZE",
|
|
245
|
+
columns: table.columns.map((name) => ({
|
|
246
|
+
name,
|
|
247
|
+
action: "REVIEW REQUIRED",
|
|
248
|
+
generated: "REVIEW REQUIRED",
|
|
249
|
+
identity: "REVIEW REQUIRED",
|
|
250
|
+
foreignKey: "REVIEW REQUIRED",
|
|
251
|
+
})),
|
|
252
|
+
})),
|
|
253
|
+
},
|
|
254
|
+
null,
|
|
255
|
+
2,
|
|
256
|
+
)}\n`;
|
|
257
|
+
|
|
258
|
+
export const planBaselinePreparation = async ({
|
|
259
|
+
projectRoot = process.cwd(),
|
|
260
|
+
configPath,
|
|
261
|
+
recordsPath,
|
|
262
|
+
ledgerPath,
|
|
263
|
+
}) => {
|
|
264
|
+
const loaded = await loadRehearsalConfig({ projectRoot, configPath });
|
|
265
|
+
const inspection = await inspectBaselineInputFiles({
|
|
266
|
+
projectRoot: loaded.projectRoot,
|
|
267
|
+
configPath,
|
|
268
|
+
recordsPath,
|
|
269
|
+
ledgerPath,
|
|
270
|
+
});
|
|
271
|
+
const destination = loaded.paths.sanitizationPolicy;
|
|
272
|
+
if (await pathExists(destination)) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Rehearsal baseline preparation will not overwrite ${relative(loaded.projectRoot, destination)}.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
projectRoot: loaded.projectRoot,
|
|
279
|
+
destination,
|
|
280
|
+
destinationRelative: relative(loaded.projectRoot, destination),
|
|
281
|
+
recordsPath: inspection.recordsPath,
|
|
282
|
+
ledgerPath: inspection.ledgerPath,
|
|
283
|
+
migrationCutoff: inspection.migrationCutoff,
|
|
284
|
+
migrationCount: inspection.migrationCount,
|
|
285
|
+
rowCount: inspection.rowCount,
|
|
286
|
+
tables: inspection.tables,
|
|
287
|
+
content: renderPolicyDraft({
|
|
288
|
+
migrationCutoff: inspection.migrationCutoff,
|
|
289
|
+
tables: inspection.tables,
|
|
290
|
+
}),
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
export const applyBaselinePreparation = async (plan) => {
|
|
295
|
+
if (await pathExists(plan.destination)) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`Rehearsal baseline preparation will not overwrite ${plan.destinationRelative}.`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
await mkdir(dirname(plan.destination), { recursive: true, mode: 0o700 });
|
|
301
|
+
const handle = await open(plan.destination, "wx", 0o600);
|
|
302
|
+
try {
|
|
303
|
+
await handle.writeFile(plan.content);
|
|
304
|
+
await handle.sync();
|
|
305
|
+
} finally {
|
|
306
|
+
await handle.close();
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export const summarizeBaselinePreparation = (plan, { mode }) => ({
|
|
311
|
+
mode,
|
|
312
|
+
destination: plan.destinationRelative,
|
|
313
|
+
recordsPath: plan.recordsPath,
|
|
314
|
+
ledgerPath: plan.ledgerPath,
|
|
315
|
+
migrationCutoff: plan.migrationCutoff,
|
|
316
|
+
migrationCount: plan.migrationCount,
|
|
317
|
+
rowCount: plan.rowCount,
|
|
318
|
+
tables: plan.tables,
|
|
319
|
+
nextAction:
|
|
320
|
+
mode === "written"
|
|
321
|
+
? `Review every REVIEW REQUIRED decision in ${plan.destinationRelative}, remove draft only after review, then create the baseline.`
|
|
322
|
+
: "Review this schema-only summary, then rerun with --write to create the draft.",
|
|
323
|
+
});
|