@rehearsal-db/core 0.1.0-beta.2 → 0.1.0-beta.3
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 +36 -1
- package/README.md +25 -2
- package/docs/commands.md +25 -2
- package/docs/getting-started.md +35 -7
- package/docs/sanitization.md +10 -0
- package/docs/troubleshooting.md +18 -2
- package/package.json +1 -1
- package/scripts/lib/rehearsal/baseline_builder.mjs +9 -4
- package/scripts/lib/rehearsal/baseline_preparation.mjs +201 -0
- package/scripts/lib/rehearsal/configuration.d.mts +14 -1
- package/scripts/lib/rehearsal/configuration.mjs +21 -10
- package/scripts/lib/rehearsal/diagnostics.mjs +7 -3
- package/scripts/lib/rehearsal/human_output.mjs +4 -0
- package/scripts/lib/rehearsal/plan.mjs +25 -2
- package/scripts/lib/rehearsal/sanitization_policy.mjs +110 -0
- package/scripts/lib/rehearsal/setup.mjs +347 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +12 -6
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +691 -15
|
@@ -19,6 +19,10 @@ import { createCandidateMigrationReceipt } from "./runtime_restore.mjs";
|
|
|
19
19
|
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
20
20
|
import { REHEARSAL_VERSION } from "./diagnostics.mjs";
|
|
21
21
|
import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
|
|
22
|
+
import {
|
|
23
|
+
readBoundRuntimeSanitizationPolicy,
|
|
24
|
+
validateRuntimeSanitizationPolicy,
|
|
25
|
+
} from "./sanitization_policy.mjs";
|
|
22
26
|
|
|
23
27
|
const commandAvailable = (command, args = ["--version"]) => {
|
|
24
28
|
const result = spawnSync(command, args, {
|
|
@@ -155,8 +159,15 @@ const assertConfiguredCommand = async ({ command, projectRoot }) => {
|
|
|
155
159
|
|
|
156
160
|
const loadPlanInputs = async (options = {}) => {
|
|
157
161
|
const loaded = await loadRehearsalConfig(options);
|
|
158
|
-
const baseline = await
|
|
159
|
-
|
|
162
|
+
const [baseline, policyBytes] = await Promise.all([
|
|
163
|
+
verifyActiveBaseline({
|
|
164
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
165
|
+
}),
|
|
166
|
+
readFile(loaded.paths.sanitizationPolicy),
|
|
167
|
+
]);
|
|
168
|
+
readBoundRuntimeSanitizationPolicy({
|
|
169
|
+
bytes: policyBytes,
|
|
170
|
+
expectedSha256: baseline.sanitizationPolicySha256,
|
|
160
171
|
});
|
|
161
172
|
const currentFiles = await readMigrationFileInventory(
|
|
162
173
|
new URL("./", pathToFileURL(`${loaded.paths.migrationDirectory}/`)),
|
|
@@ -412,6 +423,18 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
412
423
|
remediation:
|
|
413
424
|
"Correct the missing project path in the Rehearsal configuration.",
|
|
414
425
|
},
|
|
426
|
+
{
|
|
427
|
+
id: "sanitization-policy",
|
|
428
|
+
label: "Reviewed sanitization policy",
|
|
429
|
+
run: async () => {
|
|
430
|
+
const policy = validateRuntimeSanitizationPolicy(
|
|
431
|
+
JSON.parse(await readFile(paths.sanitizationPolicy, "utf8")),
|
|
432
|
+
);
|
|
433
|
+
return `${policy.tables.length} table classifications reviewed through ${policy.migrationCutoff}`;
|
|
434
|
+
},
|
|
435
|
+
remediation:
|
|
436
|
+
"Complete every REVIEW REQUIRED decision, remove draft only after review, and retry.",
|
|
437
|
+
},
|
|
415
438
|
{
|
|
416
439
|
id: "runtime-isolation",
|
|
417
440
|
label: "Runtime target isolation",
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Projects own the policy, schema inventory, pseudonym keys, and derivation logic.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
6
8
|
export const SANITIZATION_ACTIONS = Object.freeze({
|
|
7
9
|
KEEP: "KEEP",
|
|
8
10
|
PSEUDONYMIZE: "PSEUDONYMIZE",
|
|
@@ -44,6 +46,114 @@ export const normalizeSanitizationAction = (action) => {
|
|
|
44
46
|
return normalized;
|
|
45
47
|
};
|
|
46
48
|
|
|
49
|
+
export const validateRuntimeSanitizationPolicy = (policy) => {
|
|
50
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
|
|
51
|
+
throw new Error("A Rehearsal sanitization policy object is required.");
|
|
52
|
+
}
|
|
53
|
+
if (policy.draft === true) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"The sanitization policy is still a REVIEW REQUIRED draft.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (policy.policyVersion !== 1) {
|
|
59
|
+
throw new Error("The sanitization policy must use policyVersion 1.");
|
|
60
|
+
}
|
|
61
|
+
if (!/^\d{14}$/u.test(policy.migrationCutoff ?? "")) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"The sanitization policy must declare a timestamp migrationCutoff.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(policy.tables) || policy.tables.length === 0) {
|
|
67
|
+
throw new Error("The sanitization policy does not list tables.");
|
|
68
|
+
}
|
|
69
|
+
const tables = new Set();
|
|
70
|
+
for (const table of policy.tables) {
|
|
71
|
+
const tableName = assertIdentifier(
|
|
72
|
+
table?.name,
|
|
73
|
+
"Sanitization policy table name",
|
|
74
|
+
);
|
|
75
|
+
if (tables.has(tableName)) {
|
|
76
|
+
throw new Error(`Sanitization policy duplicates table ${tableName}.`);
|
|
77
|
+
}
|
|
78
|
+
tables.add(tableName);
|
|
79
|
+
if (!["STREAM AND SANITIZE", "EXCLUDE"].includes(table.sourceRows)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Sanitization policy table ${tableName} has an invalid sourceRows decision.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(table.columns) || table.columns.length === 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Sanitization policy table ${tableName} must list its columns.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const columns = new Set();
|
|
90
|
+
for (const column of table.columns) {
|
|
91
|
+
const columnName = assertIdentifier(
|
|
92
|
+
column?.name,
|
|
93
|
+
`Sanitization policy column in ${tableName}`,
|
|
94
|
+
);
|
|
95
|
+
if (columns.has(columnName)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Sanitization policy duplicates column ${tableName}.${columnName}.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
columns.add(columnName);
|
|
101
|
+
normalizeSanitizationAction(column.action);
|
|
102
|
+
if (!["ALWAYS", "NEVER"].includes(column.generated)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Sanitization policy column ${tableName}.${columnName} must classify generated as ALWAYS or NEVER.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (!["YES", "NO"].includes(column.identity)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Sanitization policy column ${tableName}.${columnName} must classify identity as YES or NO.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (column.foreignKey !== null) {
|
|
113
|
+
if (
|
|
114
|
+
!column.foreignKey ||
|
|
115
|
+
typeof column.foreignKey !== "object" ||
|
|
116
|
+
Array.isArray(column.foreignKey)
|
|
117
|
+
) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Sanitization policy column ${tableName}.${columnName} has an invalid foreignKey decision.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
assertIdentifier(
|
|
123
|
+
column.foreignKey.schema,
|
|
124
|
+
`Foreign-key schema for ${tableName}.${columnName}`,
|
|
125
|
+
);
|
|
126
|
+
assertIdentifier(
|
|
127
|
+
column.foreignKey.table,
|
|
128
|
+
`Foreign-key table for ${tableName}.${columnName}`,
|
|
129
|
+
);
|
|
130
|
+
assertIdentifier(
|
|
131
|
+
column.foreignKey.column,
|
|
132
|
+
`Foreign-key column for ${tableName}.${columnName}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return policy;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const readBoundRuntimeSanitizationPolicy = ({
|
|
141
|
+
bytes,
|
|
142
|
+
expectedSha256,
|
|
143
|
+
}) => {
|
|
144
|
+
const source = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
145
|
+
const policy = validateRuntimeSanitizationPolicy(
|
|
146
|
+
JSON.parse(source.toString("utf8")),
|
|
147
|
+
);
|
|
148
|
+
const actualSha256 = createHash("sha256").update(source).digest("hex");
|
|
149
|
+
if (actualSha256 !== expectedSha256) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
"The active sanitization policy does not match the reviewed baseline policy checksum.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return policy;
|
|
155
|
+
};
|
|
156
|
+
|
|
47
157
|
const indexTables = (tables, label) => {
|
|
48
158
|
if (!Array.isArray(tables)) throw new Error(`${label} must be an array.`);
|
|
49
159
|
const index = new Map();
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Plan and apply conservative first-run Rehearsal scaffolding without
|
|
3
|
+
* contacting hosted services or overwriting project-owned files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createConnection, createServer } from "node:net";
|
|
7
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
inspectDetectedProject,
|
|
11
|
+
renderDetectedConfig,
|
|
12
|
+
} from "./configuration.mjs";
|
|
13
|
+
|
|
14
|
+
const CONFIG_PATH = "rehearsal.config.mjs";
|
|
15
|
+
const LOCAL_SUPABASE_CONFIG_PATH =
|
|
16
|
+
"infrastructure/rehearsal/supabase/config.toml";
|
|
17
|
+
const GITIGNORE_PATH = ".gitignore";
|
|
18
|
+
const PORT_OFFSETS = Object.freeze({
|
|
19
|
+
shadow: 0,
|
|
20
|
+
api: 1,
|
|
21
|
+
database: 2,
|
|
22
|
+
studio: 3,
|
|
23
|
+
smtp: 4,
|
|
24
|
+
pooler: 9,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const pathExists = (path) =>
|
|
28
|
+
access(path)
|
|
29
|
+
.then(() => true)
|
|
30
|
+
.catch((error) => {
|
|
31
|
+
if (error?.code === "ENOENT") return false;
|
|
32
|
+
throw error;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const readOptionalFile = async (path) => {
|
|
36
|
+
try {
|
|
37
|
+
return await readFile(path, "utf8");
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code === "ENOENT") return null;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const canConnectToPort = (port) =>
|
|
45
|
+
new Promise((resolveAvailability) => {
|
|
46
|
+
const socket = createConnection({ host: "127.0.0.1", port });
|
|
47
|
+
const finish = (result) => {
|
|
48
|
+
socket.destroy();
|
|
49
|
+
resolveAvailability(result);
|
|
50
|
+
};
|
|
51
|
+
socket.setTimeout(500);
|
|
52
|
+
socket.once("connect", () => finish(true));
|
|
53
|
+
socket.once("error", () => finish(false));
|
|
54
|
+
socket.once("timeout", () => finish(false));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const canBindToPort = (port) =>
|
|
58
|
+
new Promise((resolveAvailability) => {
|
|
59
|
+
const server = createServer();
|
|
60
|
+
server.unref();
|
|
61
|
+
server.once("error", () => resolveAvailability(false));
|
|
62
|
+
server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
|
|
63
|
+
server.close(() => resolveAvailability(true));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const isRehearsalPortAvailable = async (port) => {
|
|
68
|
+
if (await canConnectToPort(port)) return false;
|
|
69
|
+
return canBindToPort(port);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const inspectRehearsalNodeRuntime = (
|
|
73
|
+
version = process.versions.node,
|
|
74
|
+
) => {
|
|
75
|
+
const major = Number.parseInt(String(version).split(".")[0], 10);
|
|
76
|
+
return {
|
|
77
|
+
version: String(version),
|
|
78
|
+
major,
|
|
79
|
+
supported: major === 24,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const assertSupportedRehearsalNodeRuntime = (
|
|
84
|
+
version = process.versions.node,
|
|
85
|
+
) => {
|
|
86
|
+
const runtime = inspectRehearsalNodeRuntime(version);
|
|
87
|
+
if (!runtime.supported) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Rehearsal setup requires Node.js 24; this shell is using Node.js ${runtime.version}. Switch to Node.js 24, then rerun setup. With nvm: nvm install 24 && nvm use 24.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return runtime;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const findAvailableRehearsalPorts = async ({
|
|
96
|
+
isAvailable = isRehearsalPortAvailable,
|
|
97
|
+
} = {}) => {
|
|
98
|
+
for (let base = 58_320; base <= 65_520; base += 20) {
|
|
99
|
+
const entries = Object.entries(PORT_OFFSETS).map(([name, offset]) => [
|
|
100
|
+
name,
|
|
101
|
+
base + offset,
|
|
102
|
+
]);
|
|
103
|
+
const availability = await Promise.all(
|
|
104
|
+
entries.map(([, port]) => isAvailable(port)),
|
|
105
|
+
);
|
|
106
|
+
if (availability.every(Boolean)) return Object.fromEntries(entries);
|
|
107
|
+
}
|
|
108
|
+
throw new Error(
|
|
109
|
+
"Rehearsal could not find an available local port block between 58320 and 65529.",
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const databaseMajorVersionFrom = (source) => {
|
|
114
|
+
const match = /^\s*major_version\s*=\s*(\d+)\s*(?:#.*)?$/imu.exec(
|
|
115
|
+
source ?? "",
|
|
116
|
+
);
|
|
117
|
+
return match ? Number(match[1]) : 17;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const renderSafeLocalSupabaseConfig = ({
|
|
121
|
+
projectId,
|
|
122
|
+
applicationUrl,
|
|
123
|
+
ports,
|
|
124
|
+
databaseMajorVersion = 17,
|
|
125
|
+
}) => `# Generated by Rehearsal for a disposable, local-only runtime.
|
|
126
|
+
# Review project-specific Auth or Storage additions before enabling them here.
|
|
127
|
+
project_id = ${JSON.stringify(projectId)}
|
|
128
|
+
|
|
129
|
+
[api]
|
|
130
|
+
enabled = true
|
|
131
|
+
port = ${ports.api}
|
|
132
|
+
schemas = ["public"]
|
|
133
|
+
extra_search_path = ["public", "extensions"]
|
|
134
|
+
|
|
135
|
+
[db]
|
|
136
|
+
port = ${ports.database}
|
|
137
|
+
shadow_port = ${ports.shadow}
|
|
138
|
+
major_version = ${databaseMajorVersion}
|
|
139
|
+
|
|
140
|
+
[db.pooler]
|
|
141
|
+
enabled = false
|
|
142
|
+
port = ${ports.pooler}
|
|
143
|
+
|
|
144
|
+
[db.migrations]
|
|
145
|
+
enabled = true
|
|
146
|
+
schema_paths = []
|
|
147
|
+
|
|
148
|
+
[db.seed]
|
|
149
|
+
enabled = false
|
|
150
|
+
sql_paths = []
|
|
151
|
+
|
|
152
|
+
[studio]
|
|
153
|
+
enabled = true
|
|
154
|
+
port = ${ports.studio}
|
|
155
|
+
api_url = "http://127.0.0.1"
|
|
156
|
+
|
|
157
|
+
[local_smtp]
|
|
158
|
+
enabled = false
|
|
159
|
+
port = ${ports.smtp}
|
|
160
|
+
|
|
161
|
+
[auth]
|
|
162
|
+
enabled = true
|
|
163
|
+
site_url = ${JSON.stringify(applicationUrl)}
|
|
164
|
+
enable_signup = false
|
|
165
|
+
|
|
166
|
+
[storage]
|
|
167
|
+
enabled = true
|
|
168
|
+
|
|
169
|
+
[realtime]
|
|
170
|
+
enabled = false
|
|
171
|
+
|
|
172
|
+
[edge_runtime]
|
|
173
|
+
enabled = false
|
|
174
|
+
|
|
175
|
+
[analytics]
|
|
176
|
+
enabled = false
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
const appendGitignoreEntries = (source) => {
|
|
180
|
+
const original = source ?? "";
|
|
181
|
+
const lines = new Set(
|
|
182
|
+
original
|
|
183
|
+
.split(/\r?\n/u)
|
|
184
|
+
.map((line) => line.trim())
|
|
185
|
+
.filter(Boolean),
|
|
186
|
+
);
|
|
187
|
+
const entries = [".rehearsal/", ".env.rehearsal-service.local"];
|
|
188
|
+
const missing = entries.filter(
|
|
189
|
+
(entry) =>
|
|
190
|
+
!lines.has(entry) &&
|
|
191
|
+
!lines.has(`/${entry}`) &&
|
|
192
|
+
!lines.has(entry.replace(/\/$/u, "")),
|
|
193
|
+
);
|
|
194
|
+
if (missing.length === 0) return original;
|
|
195
|
+
const prefix =
|
|
196
|
+
original && !original.endsWith("\n") ? `${original}\n` : original;
|
|
197
|
+
const separator = prefix && !prefix.endsWith("\n\n") ? "\n" : "";
|
|
198
|
+
return `${prefix}${separator}# Rehearsal local artifacts\n${missing.join("\n")}\n`;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const assertCreateTargetAvailable = async (file) => {
|
|
202
|
+
if (file.action === "create" && (await pathExists(file.absolutePath))) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Rehearsal setup will not overwrite the existing file at ${file.path}.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const planRehearsalSetup = async ({
|
|
210
|
+
projectRoot = process.cwd(),
|
|
211
|
+
isPortAvailable,
|
|
212
|
+
} = {}) => {
|
|
213
|
+
const root = resolve(projectRoot);
|
|
214
|
+
const detected = await inspectDetectedProject({ projectRoot: root });
|
|
215
|
+
const ports = await findAvailableRehearsalPorts({
|
|
216
|
+
...(isPortAvailable ? { isAvailable: isPortAvailable } : {}),
|
|
217
|
+
});
|
|
218
|
+
const applicationUrl = "http://localhost:5175";
|
|
219
|
+
const projectId = `${detected.projectName}-rehearsal`;
|
|
220
|
+
const sourceSupabaseConfig = await readOptionalFile(
|
|
221
|
+
join(root, "supabase/config.toml"),
|
|
222
|
+
);
|
|
223
|
+
const originalGitignore = await readOptionalFile(join(root, GITIGNORE_PATH));
|
|
224
|
+
const nextGitignore = appendGitignoreEntries(originalGitignore);
|
|
225
|
+
for (const path of [CONFIG_PATH, LOCAL_SUPABASE_CONFIG_PATH]) {
|
|
226
|
+
if (await pathExists(join(root, path))) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Rehearsal setup will not overwrite the existing file at ${path}.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const files = [
|
|
233
|
+
{
|
|
234
|
+
path: CONFIG_PATH,
|
|
235
|
+
absolutePath: join(root, CONFIG_PATH),
|
|
236
|
+
action: "create",
|
|
237
|
+
mode: 0o600,
|
|
238
|
+
content: renderDetectedConfig(detected, {
|
|
239
|
+
applicationUrl,
|
|
240
|
+
ports,
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
path: LOCAL_SUPABASE_CONFIG_PATH,
|
|
245
|
+
absolutePath: join(root, LOCAL_SUPABASE_CONFIG_PATH),
|
|
246
|
+
action: "create",
|
|
247
|
+
mode: 0o600,
|
|
248
|
+
content: renderSafeLocalSupabaseConfig({
|
|
249
|
+
projectId,
|
|
250
|
+
applicationUrl,
|
|
251
|
+
ports,
|
|
252
|
+
databaseMajorVersion: databaseMajorVersionFrom(sourceSupabaseConfig),
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
path: GITIGNORE_PATH,
|
|
257
|
+
absolutePath: join(root, GITIGNORE_PATH),
|
|
258
|
+
action:
|
|
259
|
+
nextGitignore === (originalGitignore ?? "")
|
|
260
|
+
? "unchanged"
|
|
261
|
+
: originalGitignore === null
|
|
262
|
+
? "create"
|
|
263
|
+
: "update",
|
|
264
|
+
mode: 0o644,
|
|
265
|
+
originalContent: originalGitignore,
|
|
266
|
+
content: nextGitignore,
|
|
267
|
+
},
|
|
268
|
+
];
|
|
269
|
+
return {
|
|
270
|
+
projectRoot: root,
|
|
271
|
+
project: detected.projectName,
|
|
272
|
+
projectId,
|
|
273
|
+
applicationUrl,
|
|
274
|
+
detected,
|
|
275
|
+
ports,
|
|
276
|
+
isPortAvailable: isPortAvailable ?? isRehearsalPortAvailable,
|
|
277
|
+
files,
|
|
278
|
+
safety: [
|
|
279
|
+
"hosted access remains disabled",
|
|
280
|
+
"the Supabase runtime binds to dedicated local ports",
|
|
281
|
+
"external identity providers, realtime, edge functions, and analytics start disabled",
|
|
282
|
+
"configuration files are never overwritten and .gitignore updates are concurrency-checked",
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export const applyRehearsalSetup = async (plan) => {
|
|
288
|
+
assertSupportedRehearsalNodeRuntime();
|
|
289
|
+
const unavailablePorts = (
|
|
290
|
+
await Promise.all(
|
|
291
|
+
Object.values(plan.ports).map(async (port) => ({
|
|
292
|
+
port,
|
|
293
|
+
available: await plan.isPortAvailable(port),
|
|
294
|
+
})),
|
|
295
|
+
)
|
|
296
|
+
).filter(({ available }) => !available);
|
|
297
|
+
if (unavailablePorts.length) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`Rehearsal setup ports became occupied after the preview (${unavailablePorts.map(({ port }) => port).join(", ")}); rerun setup to choose another local port block.`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
for (const file of plan.files) await assertCreateTargetAvailable(file);
|
|
303
|
+
const gitignore = plan.files.find((file) => file.path === GITIGNORE_PATH);
|
|
304
|
+
if (gitignore?.action === "update") {
|
|
305
|
+
const current = await readOptionalFile(gitignore.absolutePath);
|
|
306
|
+
if (current !== gitignore.originalContent) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
".gitignore changed after the setup preview; rerun rehearsal setup before writing.",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const file of plan.files.filter(
|
|
313
|
+
(entry) => entry.action !== "unchanged",
|
|
314
|
+
)) {
|
|
315
|
+
await mkdir(dirname(file.absolutePath), { recursive: true, mode: 0o700 });
|
|
316
|
+
await writeFile(file.absolutePath, file.content, {
|
|
317
|
+
flag: file.action === "create" ? "wx" : "w",
|
|
318
|
+
mode: file.mode,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
projectRoot: plan.projectRoot,
|
|
323
|
+
files: plan.files.map((file) => ({
|
|
324
|
+
path: relative(plan.projectRoot, file.absolutePath),
|
|
325
|
+
action: file.action,
|
|
326
|
+
})),
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
export const summarizeRehearsalSetup = (plan, { mode }) => ({
|
|
331
|
+
mode,
|
|
332
|
+
project: plan.project,
|
|
333
|
+
projectId: plan.projectId,
|
|
334
|
+
applicationUrl: plan.applicationUrl,
|
|
335
|
+
detected: plan.detected,
|
|
336
|
+
ports: {
|
|
337
|
+
api: plan.ports.api,
|
|
338
|
+
database: plan.ports.database,
|
|
339
|
+
studio: plan.ports.studio,
|
|
340
|
+
},
|
|
341
|
+
files: plan.files.map((file) => ({ path: file.path, action: file.action })),
|
|
342
|
+
safety: plan.safety,
|
|
343
|
+
nextAction:
|
|
344
|
+
mode === "written"
|
|
345
|
+
? "Review the generated commands and sanitization policy path, then run rehearsal doctor."
|
|
346
|
+
: "Review this plan, then rerun rehearsal setup --write to create it.",
|
|
347
|
+
});
|
|
@@ -44,10 +44,12 @@ import {
|
|
|
44
44
|
} from "../../lib/rehearsal/migration_history.mjs";
|
|
45
45
|
import { loadRehearsalConfig } from "../../lib/rehearsal/configuration.mjs";
|
|
46
46
|
import { readRehearsalServiceEnvironment } from "../../lib/rehearsal/service_environment.mjs";
|
|
47
|
+
import { readBoundRuntimeSanitizationPolicy } from "../../lib/rehearsal/sanitization_policy.mjs";
|
|
47
48
|
import {
|
|
48
49
|
captureLocalPublicSchema,
|
|
49
50
|
compareProductionSchemaDumps,
|
|
50
51
|
} from "../../lib/rehearsal/schema_snapshot.mjs";
|
|
52
|
+
import { formatCount } from "../../lib/rehearsal/human_output.mjs";
|
|
51
53
|
|
|
52
54
|
const repositoryRoot = process.cwd();
|
|
53
55
|
const loadedConfiguration = await loadRehearsalConfig({
|
|
@@ -443,11 +445,15 @@ const configureProjectRuntime = ({ environment, baseline }) => {
|
|
|
443
445
|
};
|
|
444
446
|
|
|
445
447
|
const readActive = async () => {
|
|
446
|
-
const [baseline, paths,
|
|
448
|
+
const [baseline, paths, policyBytes] = await Promise.all([
|
|
447
449
|
verifyActiveBaseline({ artifactRoot }),
|
|
448
450
|
resolveActiveBaselinePaths({ artifactRoot }),
|
|
449
|
-
readFile(manifestPath
|
|
451
|
+
readFile(manifestPath),
|
|
450
452
|
]);
|
|
453
|
+
const manifest = readBoundRuntimeSanitizationPolicy({
|
|
454
|
+
bytes: policyBytes,
|
|
455
|
+
expectedSha256: baseline.sanitizationPolicySha256,
|
|
456
|
+
});
|
|
451
457
|
return { baseline, paths, manifest };
|
|
452
458
|
};
|
|
453
459
|
|
|
@@ -496,7 +502,7 @@ const resetRuntime = async () => {
|
|
|
496
502
|
{ mode: 0o600 },
|
|
497
503
|
);
|
|
498
504
|
console.log(
|
|
499
|
-
`Restored Rehearsal baseline ${active.baseline.generationId}: ${active.baseline.rowCount}
|
|
505
|
+
`Restored Rehearsal baseline ${active.baseline.generationId}: ${formatCount(active.baseline.rowCount, "row")} across ${formatCount(Object.keys(active.baseline.tableCounts).length, "table")}.`,
|
|
500
506
|
);
|
|
501
507
|
console.log(projectRuntime.message);
|
|
502
508
|
return environment;
|
|
@@ -621,7 +627,7 @@ const verifyRuntime = async () => {
|
|
|
621
627
|
}
|
|
622
628
|
}
|
|
623
629
|
console.log(
|
|
624
|
-
`Verified Rehearsal runtime ${baseline.generationId}: immutable baseline and migration history are valid with ${migrationComparison.candidates.length
|
|
630
|
+
`Verified Rehearsal runtime ${baseline.generationId}: immutable baseline and migration history are valid with ${formatCount(migrationComparison.candidates.length, "applied candidate migration")}. Runtime rows may differ from the baseline until the next reset.`,
|
|
625
631
|
);
|
|
626
632
|
if (projectVerification) console.log(projectVerification.message);
|
|
627
633
|
return { baseline, candidateReceipt, migrationComparison };
|
|
@@ -652,7 +658,7 @@ const migrateRuntime = async () => {
|
|
|
652
658
|
return receipt;
|
|
653
659
|
}
|
|
654
660
|
console.log(
|
|
655
|
-
|
|
661
|
+
`${receipt.candidates.length === 1 ? "Candidate migration" : "Candidate migrations"} (${receipt.candidateSha256}): ${receipt.candidates.map((candidate) => candidate.filename).join(", ")}`,
|
|
656
662
|
);
|
|
657
663
|
const confirmation = process.argv
|
|
658
664
|
.find((argument) => argument.startsWith("--confirm-candidates="))
|
|
@@ -707,7 +713,7 @@ const migrateRuntime = async () => {
|
|
|
707
713
|
{ mode: 0o600 },
|
|
708
714
|
);
|
|
709
715
|
console.log(
|
|
710
|
-
`Applied ${receipt.candidates.length
|
|
716
|
+
`Applied ${formatCount(receipt.candidates.length, "confirmed candidate migration")} to the disposable Rehearsal runtime.`,
|
|
711
717
|
);
|
|
712
718
|
return receipt;
|
|
713
719
|
} catch (error) {
|