@rehearsal-db/core 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +56 -4
  2. package/COMPATIBILITY.md +41 -4
  3. package/README.md +8 -4
  4. package/dist/package.json +3 -1
  5. package/dist/src/cli/arguments.mjs +1 -0
  6. package/dist/src/cli/basic_command_handlers.mjs +4 -0
  7. package/dist/src/cli/editor_command_handlers.mjs +24 -0
  8. package/dist/src/project/configuration_contract.d.mts +1 -0
  9. package/dist/src/project/configuration_normalization.mjs +2 -1
  10. package/dist/src/project/configuration_rendering.mjs +3 -2
  11. package/dist/src/project/configuration_resolution.mjs +0 -2
  12. package/dist/src/project/project_detection.d.mts +2 -0
  13. package/dist/src/project/project_detection.mjs +1 -0
  14. package/dist/src/project/runtime_project_identity.d.mts +5 -0
  15. package/dist/src/project/runtime_project_identity.mjs +41 -0
  16. package/dist/src/project/setup.mjs +3 -1
  17. package/dist/src/runtime/migration_history.d.mts +5 -1
  18. package/dist/src/runtime/migration_history.mjs +24 -6
  19. package/dist/src/runtime/migration_impact.d.mts +125 -0
  20. package/dist/src/runtime/migration_impact.mjs +278 -0
  21. package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
  22. package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
  23. package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
  24. package/dist/src/runtime/migration_impact_plan.mjs +297 -0
  25. package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
  26. package/dist/src/runtime/migration_impact_queries.mjs +270 -0
  27. package/dist/src/runtime/migration_insights.d.mts +137 -0
  28. package/dist/src/runtime/migration_insights.mjs +143 -0
  29. package/dist/src/runtime/migration_insights_input.d.mts +13 -0
  30. package/dist/src/runtime/migration_insights_input.mjs +153 -0
  31. package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
  32. package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
  33. package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
  34. package/dist/src/runtime/migration_sql_scan.mjs +369 -0
  35. package/dist/src/runtime/plan.d.mts +2 -1
  36. package/dist/src/runtime/plan.mjs +4 -4
  37. package/dist/src/runtime/policy.d.mts +2 -0
  38. package/dist/src/runtime/policy.mjs +13 -1
  39. package/dist/src/runtime/privileges.d.mts +25 -0
  40. package/dist/src/runtime/privileges.mjs +198 -0
  41. package/dist/src/runtime/topology.d.mts +1 -1
  42. package/dist/src/runtime/topology.mjs +2 -2
  43. package/dist/src/shared/cancellation.mjs +1 -0
  44. package/dist/src/shared/held_path_ancestry.d.mts +7 -0
  45. package/dist/src/shared/held_path_ancestry.mjs +110 -0
  46. package/dist/src/shared/operation_guard.mjs +1 -0
  47. package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
  48. package/dist/src/targets/migration_impact_runtime.mjs +367 -0
  49. package/dist/src/targets/supabase.mjs +1 -0
  50. package/docs/README.md +20 -7
  51. package/docs/adapters.md +10 -0
  52. package/docs/architecture.md +75 -1
  53. package/docs/commands.md +169 -34
  54. package/docs/configuration.md +42 -9
  55. package/docs/getting-started.md +4 -4
  56. package/docs/index.md +1 -0
  57. package/docs/releasing.md +15 -11
  58. package/docs/roadmap.md +49 -9
  59. package/docs/runtime-lifecycle.md +15 -4
  60. package/docs/runtime-policies.md +145 -0
  61. package/docs/stability.md +8 -4
  62. package/docs/updating.md +73 -0
  63. package/docs/vscode.md +30 -19
  64. package/package.json +3 -1
@@ -0,0 +1,367 @@
1
+ /** Private non-starting transport for one explicitly selected owned local runtime. */
2
+ import { constants } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { lstat, open, realpath } from "node:fs/promises";
5
+ import { isAbsolute, join, relative, resolve } from "node:path";
6
+ import { isLocalDockerEndpoint } from "../runtime/local_docker_context.mjs";
7
+ import { diagnoseMigrationFailure } from "../runtime/migration_failure.mjs";
8
+ import { holdPathAncestry } from "../shared/held_path_ancestry.mjs";
9
+ import { runOwnedProcess } from "../shared/owned_process.mjs";
10
+ import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
11
+ import { assertDockerResourceLabels } from "./docker_resource_ownership.mjs";
12
+ export class MigrationImpactRuntimeError extends Error {
13
+ reason;
14
+ constructor(reason) {
15
+ super("The selected local runtime could not complete bounded read-only inspection; private output withheld.");
16
+ this.reason = reason;
17
+ this.name = "MigrationImpactRuntimeError";
18
+ }
19
+ }
20
+ const INSPECTION_BYTES = 65536;
21
+ const SQL_INPUT_BYTES = 262144;
22
+ const SQL_OUTPUT_BYTES = 131072;
23
+ function fail(reason) {
24
+ throw new MigrationImpactRuntimeError(reason);
25
+ }
26
+ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
27
+ const object = (value) => {
28
+ if (!value || typeof value !== "object" || Array.isArray(value))
29
+ fail("runtime_unavailable");
30
+ return value;
31
+ };
32
+ const inside = (root, path) => {
33
+ const child = relative(root, path);
34
+ return (child !== "" &&
35
+ child !== ".." &&
36
+ !child.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) &&
37
+ !isAbsolute(child));
38
+ };
39
+ const CONTAINER_FORMAT = '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"labels":{{json .Config.Labels}},"running":{{json .State.Running}},"ports":{{json .NetworkSettings.Ports}}}';
40
+ /** No lifecycle, adapter, source-reader, URL or credential-file access is permitted here. */
41
+ export async function openMigrationImpactRuntime(options) {
42
+ const { target, baseline } = options;
43
+ const signal = options.signal ?? AbortSignal.timeout(30000);
44
+ const run = options.runProcessImplementation ?? runOwnedProcess;
45
+ const cancelled = () => {
46
+ if (signal.aborted)
47
+ fail("cancelled");
48
+ };
49
+ cancelled();
50
+ const provider = target.config.runtime.target;
51
+ const projectId = target.config.runtime.projectId;
52
+ const postgres = provider === "postgresql" ? target.config.postgresql : null;
53
+ const databasePort = target.config.runtime.ports.database;
54
+ if (!["postgresql", "supabase"].includes(provider) ||
55
+ (provider === "postgresql" && !postgres))
56
+ fail("ownership_mismatch");
57
+ const dataSha256 = baseline.files["sanitized-data.ndjson"]?.sha256;
58
+ if (!/^[a-f0-9]{64}$/u.test(dataSha256 ?? "") ||
59
+ !/^\d{8}T\d{6}Z-[a-f0-9]{12}$/u.test(baseline.generationId))
60
+ fail("stale_runtime");
61
+ let root, receiptDirectory;
62
+ try {
63
+ root = await realpath(resolve(options.projectRoot));
64
+ const lexicalRoot = resolve(options.projectRoot), configured = resolve(target.paths.runtimeWorkdir);
65
+ receiptDirectory = inside(lexicalRoot, configured)
66
+ ? resolve(root, relative(lexicalRoot, configured))
67
+ : configured;
68
+ if (!inside(root, receiptDirectory) ||
69
+ (await realpath(receiptDirectory)) !== receiptDirectory)
70
+ fail("ownership_mismatch");
71
+ }
72
+ catch {
73
+ fail("ownership_mismatch");
74
+ }
75
+ const readReceipt = async () => {
76
+ cancelled();
77
+ let ancestry;
78
+ try {
79
+ ancestry = await holdPathAncestry(receiptDirectory);
80
+ await ancestry.assertUnchanged();
81
+ const path = join(ancestry.path, "baseline.json"), leaf = await lstat(path, { bigint: true });
82
+ if (!leaf.isFile() ||
83
+ leaf.isSymbolicLink() ||
84
+ leaf.size > BigInt(INSPECTION_BYTES) ||
85
+ (leaf.mode & 63n) !== 0n ||
86
+ (typeof process.getuid === "function" &&
87
+ leaf.uid !== BigInt(process.getuid())))
88
+ fail("stale_runtime");
89
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
90
+ try {
91
+ const before = await handle.stat({ bigint: true });
92
+ if (!before.isFile() ||
93
+ before.dev !== leaf.dev ||
94
+ before.ino !== leaf.ino ||
95
+ before.size !== leaf.size ||
96
+ before.mtimeNs !== leaf.mtimeNs ||
97
+ before.ctimeNs !== leaf.ctimeNs)
98
+ fail("stale_runtime");
99
+ const bytes = Buffer.alloc(Number(before.size) + 1);
100
+ let length = 0;
101
+ while (length < bytes.length) {
102
+ const next = await handle.read(bytes, length, bytes.length - length, length);
103
+ if (!next.bytesRead)
104
+ break;
105
+ length += next.bytesRead;
106
+ }
107
+ const after = await handle.stat({ bigint: true }), current = await lstat(path, { bigint: true });
108
+ if (BigInt(length) !== before.size ||
109
+ !current.isFile() ||
110
+ current.isSymbolicLink() ||
111
+ [after, current].some((stat) => stat.dev !== before.dev ||
112
+ stat.ino !== before.ino ||
113
+ stat.size !== before.size ||
114
+ stat.mtimeNs !== before.mtimeNs ||
115
+ stat.ctimeNs !== before.ctimeNs))
116
+ fail("stale_runtime");
117
+ await ancestry.assertUnchanged();
118
+ const contents = bytes.subarray(0, length);
119
+ const marker = object(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(contents)));
120
+ if (marker.target !== provider ||
121
+ marker.generationId !== baseline.generationId ||
122
+ marker.dataSha256 !== dataSha256 ||
123
+ (postgres
124
+ ? marker.formatVersion !== 1 ||
125
+ marker.image !== postgres.image ||
126
+ typeof marker.databasePassword !== "string" ||
127
+ marker.databasePassword.length < 32
128
+ : marker.formatVersion !== baseline.formatVersion ||
129
+ marker.schemaSha256 !==
130
+ (baseline.files["production-schema.sql"]?.sha256 ?? null)))
131
+ fail("stale_runtime");
132
+ return {
133
+ sha256: digest(contents),
134
+ inode: `${before.dev}:${before.ino}:${before.ctimeNs}:${before.mtimeNs}`,
135
+ };
136
+ }
137
+ finally {
138
+ await handle.close();
139
+ }
140
+ }
141
+ catch (error) {
142
+ if (error instanceof MigrationImpactRuntimeError)
143
+ throw error;
144
+ fail("stale_runtime");
145
+ }
146
+ finally {
147
+ try {
148
+ await ancestry?.close();
149
+ }
150
+ catch {
151
+ cancelled();
152
+ fail("stale_runtime");
153
+ }
154
+ }
155
+ };
156
+ const initialReceipt = await readReceipt();
157
+ let endpoint;
158
+ const invoke = async (args, input) => {
159
+ cancelled();
160
+ const maximum = input === undefined ? INSPECTION_BYTES : SQL_OUTPUT_BYTES;
161
+ try {
162
+ const result = await run({
163
+ command: "docker",
164
+ args,
165
+ cwd: root,
166
+ env: createCleanProcessEnvironment({
167
+ overrides: { DOCKER_HOST: endpoint },
168
+ }),
169
+ signal,
170
+ stdin: "ignore",
171
+ ...(input === undefined ? {} : { input }),
172
+ maxOutputBytes: maximum,
173
+ cancellationGraceMs: 1000,
174
+ });
175
+ cancelled();
176
+ if (typeof result.stdout !== "string" ||
177
+ typeof result.stderr !== "string" ||
178
+ !Number.isSafeInteger(result.stdoutByteCount) ||
179
+ !Number.isSafeInteger(result.stderrByteCount) ||
180
+ result.stdoutByteCount < Buffer.byteLength(result.stdout) ||
181
+ result.stderrByteCount < Buffer.byteLength(result.stderr) ||
182
+ result.stdoutByteCount > maximum ||
183
+ result.stderrByteCount > INSPECTION_BYTES ||
184
+ (input === undefined &&
185
+ result.stdoutByteCount + result.stderrByteCount > INSPECTION_BYTES))
186
+ fail(input === undefined ? "runtime_unavailable" : "query_failed");
187
+ if (result.error || result.signal || result.status !== 0) {
188
+ if (input !== undefined) {
189
+ const code = diagnoseMigrationFailure({
190
+ provider: "postgresql",
191
+ stderr: result.stderr,
192
+ }).sqlState;
193
+ if (code === "55P03")
194
+ fail("lock_timeout");
195
+ if (code === "57014")
196
+ fail("statement_timeout");
197
+ if (code === "25P04")
198
+ fail("transaction_timeout");
199
+ }
200
+ fail(input === undefined ? "runtime_unavailable" : "query_failed");
201
+ }
202
+ return result.stdout;
203
+ }
204
+ catch (error) {
205
+ cancelled();
206
+ if (error instanceof MigrationImpactRuntimeError)
207
+ throw error;
208
+ fail(input === undefined ? "runtime_unavailable" : "query_failed");
209
+ }
210
+ };
211
+ const parse = (text) => {
212
+ try {
213
+ return JSON.parse(text);
214
+ }
215
+ catch {
216
+ fail("runtime_unavailable");
217
+ }
218
+ };
219
+ const context = (await invoke(["context", "show"])).trim();
220
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/u.test(context))
221
+ fail("ownership_mismatch");
222
+ const selected = parse(await invoke([
223
+ "context",
224
+ "inspect",
225
+ "--format",
226
+ "{{json .Endpoints.docker.Host}}",
227
+ context,
228
+ ]));
229
+ if (!isLocalDockerEndpoint(selected))
230
+ fail("ownership_mismatch");
231
+ endpoint = selected;
232
+ const engineId = async () => {
233
+ const value = parse(await invoke(["info", "--format", "{{json .ID}}"]));
234
+ if (typeof value !== "string" || !/^[a-zA-Z0-9:-]{1,128}$/u.test(value))
235
+ fail("runtime_unavailable");
236
+ return value;
237
+ };
238
+ const engine = await engineId();
239
+ const expectedName = postgres
240
+ ? `${projectId}-postgresql`
241
+ : `supabase_db_${projectId}`;
242
+ const inspect = async (id) => {
243
+ const value = object(parse(await invoke([
244
+ "container",
245
+ "inspect",
246
+ "--format",
247
+ CONTAINER_FORMAT,
248
+ id,
249
+ ])));
250
+ if (typeof value.id !== "string" ||
251
+ !/^[a-f0-9]{64}$/u.test(value.id) ||
252
+ value.name !== `/${expectedName}` ||
253
+ value.running !== true ||
254
+ typeof value.imageId !== "string" ||
255
+ !/^sha256:[a-f0-9]{64}$/u.test(value.imageId))
256
+ fail("runtime_unavailable");
257
+ try {
258
+ assertDockerResourceLabels({
259
+ labels: value.labels,
260
+ expectedLabel: postgres
261
+ ? "com.rehearsal-db.project"
262
+ : "com.supabase.cli.project",
263
+ projectId,
264
+ });
265
+ }
266
+ catch {
267
+ fail("ownership_mismatch");
268
+ }
269
+ if (postgres) {
270
+ const ports = object(value.ports), mappings = ports["5432/tcp"];
271
+ if (value.image !== postgres.image ||
272
+ !Array.isArray(mappings) ||
273
+ mappings.length !== 1)
274
+ fail("ownership_mismatch");
275
+ const mapping = object(mappings[0]);
276
+ if (mapping.HostIp !== "127.0.0.1" ||
277
+ mapping.HostPort !== String(databasePort))
278
+ fail("ownership_mismatch");
279
+ }
280
+ return { id: value.id, imageId: value.imageId };
281
+ };
282
+ const container = await inspect(expectedName);
283
+ const assertCurrent = async () => {
284
+ cancelled();
285
+ const currentReceipt = await readReceipt();
286
+ if (currentReceipt.sha256 !== initialReceipt.sha256 ||
287
+ currentReceipt.inode !== initialReceipt.inode)
288
+ fail("stale_runtime");
289
+ if ((await engineId()) !== engine)
290
+ fail("stale_runtime");
291
+ const current = await inspect(container.id);
292
+ if (current.id !== container.id || current.imageId !== container.imageId)
293
+ fail("stale_runtime");
294
+ const finalReceipt = await readReceipt();
295
+ if (finalReceipt.sha256 !== initialReceipt.sha256 ||
296
+ finalReceipt.inode !== initialReceipt.inode)
297
+ fail("stale_runtime");
298
+ };
299
+ await assertCurrent();
300
+ return Object.freeze({
301
+ identitySha256: digest(JSON.stringify({
302
+ endpoint,
303
+ engine,
304
+ container,
305
+ receipt: initialReceipt.sha256,
306
+ provider,
307
+ projectId,
308
+ generation: baseline.generationId,
309
+ })),
310
+ assertCurrent,
311
+ runSql: async (input) => {
312
+ // Compile the remaining server deadline only after the final potentially
313
+ // slow Docker/receipt preflight. Never rewrite a caller's SQL text.
314
+ if (typeof input === "function")
315
+ await assertCurrent();
316
+ const sql = typeof input === "function" ? input() : input;
317
+ if (typeof sql !== "string" ||
318
+ Buffer.byteLength(sql) > SQL_INPUT_BYTES ||
319
+ sql.length === 0 ||
320
+ sql.includes("\0"))
321
+ fail("query_failed");
322
+ if (typeof input !== "function")
323
+ await assertCurrent();
324
+ let result = "", failure;
325
+ try {
326
+ // Docker exec inherits container variables unless env -i clears them;
327
+ // force a local socket so PGHOSTADDR/service/options cannot redirect it.
328
+ result = await invoke([
329
+ "exec",
330
+ "--interactive",
331
+ container.id,
332
+ "/usr/bin/env",
333
+ "-i",
334
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
335
+ "PGSSLMODE=disable",
336
+ "PGPASSFILE=/dev/null",
337
+ "psql",
338
+ "--no-psqlrc",
339
+ "--no-password",
340
+ "--host=/var/run/postgresql",
341
+ "--port=5432",
342
+ "--quiet",
343
+ "--no-align",
344
+ "--tuples-only",
345
+ "--set",
346
+ "ON_ERROR_STOP=1",
347
+ "--set",
348
+ "VERBOSITY=sqlstate",
349
+ "--username",
350
+ // Supabase's application-facing postgres role is NOSUPERUSER.
351
+ // The fixed disk bound and namespace lock require its built-in
352
+ // local administrator; no role changes or credentials are needed.
353
+ postgres?.user ?? "supabase_admin",
354
+ "--dbname",
355
+ postgres?.database ?? "postgres",
356
+ ], sql);
357
+ }
358
+ catch (error) {
359
+ failure = error;
360
+ }
361
+ await assertCurrent();
362
+ if (failure)
363
+ throw failure;
364
+ return result;
365
+ },
366
+ });
367
+ }
@@ -426,6 +426,7 @@ const resetRuntime = async () => {
426
426
  path: runtimeMarkerPath,
427
427
  marker: {
428
428
  formatVersion: active.baseline.formatVersion,
429
+ target: "supabase",
429
430
  generationId: active.baseline.generationId,
430
431
  dataSha256: readBaselineDataSha256(active.baseline),
431
432
  schemaSha256: active.baseline.files?.["production-schema.sql"]?.sha256 ?? null,
package/docs/README.md CHANGED
@@ -10,17 +10,26 @@ applies only the exact migrations you approve, and runs your application's own p
10
10
  You do not need to read this documentation from top to bottom. Choose what you want to
11
11
  accomplish and follow that path.
12
12
 
13
- ## Start here
13
+ ## Getting started
14
+
15
+ Install Rehearsal, rehearse a migration, understand the result and fix a failure.
14
16
 
15
17
  | Your goal | Best next page |
16
18
  | ----------------------------------- | ------------------------------------- |
17
19
  | Add Rehearsal to a project | [Getting started](getting-started.md) |
18
20
  | Use the visual database workbench | [VS Code extension](vscode.md) |
19
21
  | Try it in a disposable project | [Hands-on tutorial](tutorial.md) |
22
+ | Update an existing installation | [Releases and updates](updating.md) |
20
23
  | Fix a setup or runtime problem | [Troubleshooting](troubleshooting.md) |
21
24
  | Browse the friendlier web-docs home | [Documentation home](index.md) |
22
25
 
23
- ## Work safely
26
+ ## User reference
27
+
28
+ Look up only what you need for your next decision. Start with
29
+ [supported environments](https://github.com/Ddupasquier/rehearsal-db/blob/main/COMPATIBILITY.md),
30
+ [configuration](configuration.md) or [troubleshooting](troubleshooting.md).
31
+
32
+ ### Data safety
24
33
 
25
34
  - [Baselines](baselines.md) — the verified starting copy
26
35
  - [Sanitization](sanitization.md) — what may enter that copy
@@ -28,7 +37,7 @@ accomplish and follow that path.
28
37
  - [Security model](security-model.md) — the independent safety barriers
29
38
  - [Standalone workflow](standalone-workflow.md) — the complete declarative contract
30
39
 
31
- ## Look something up
40
+ ### Commands and settings
32
41
 
33
42
  - [CLI commands](commands.md)
34
43
  - [Configuration](configuration.md)
@@ -37,13 +46,17 @@ accomplish and follow that path.
37
46
  - [Runtime and identity policies](runtime-policies.md)
38
47
  - [Database adapters](adapters.md)
39
48
  - [Glossary](glossary.md)
49
+ - [Roadmap](roadmap.md)
40
50
 
41
- ## Follow the project
51
+ ## Maintainers
52
+
53
+ These pages explain developing and publishing Rehearsal itself, not using it in
54
+ your application. Installing or updating does not require the publisher checklist.
42
55
 
43
- - [Roadmap](roadmap.md)
44
- - [Stability and the first stable release](stability.md)
45
56
  - [Repository architecture](architecture.md)
46
- - [Release process](releasing.md)
57
+ - [Building and testing](https://github.com/Ddupasquier/rehearsal-db/blob/main/CONTRIBUTING.md)
58
+ - [Release verification](stability.md)
59
+ - [Maintainers: publishing Rehearsal](releasing.md)
47
60
 
48
61
  To preview the styled documentation locally, run `npm run docs:preview`, then open the
49
62
  address it prints.
package/docs/adapters.md CHANGED
@@ -35,3 +35,13 @@ adapter responsibility.
35
35
  Keep adapters small, deterministic, tested, and visibly project-owned. If a behavior is
36
36
  generic across unrelated projects, propose it for the engine instead of copying it into
37
37
  every adapter.
38
+
39
+ ## Migration-tool histories are a separate contract
40
+
41
+ Project runtime hooks do not add Prisma, Drizzle, Knex, Django or Alembic support.
42
+ The current timestamped-SQL inventory and Rehearsal/Supabase migration ledger must
43
+ not be presented as native compatibility with those tools. Do not flatten native
44
+ migration directories, rewrite an applied ledger or use a schema-push shortcut to
45
+ fit this adapter API. Versioned native history, exact review and owned-target
46
+ execution belong to the separate core integration tracked by
47
+ [issue #174](https://github.com/Ddupasquier/rehearsal-db/issues/174).
@@ -39,6 +39,23 @@ rehearsal-db/
39
39
  - Public consumers use only the paths declared in `package.json#exports`.
40
40
  - Unit tests mirror their source domain. Cross-domain workflows belong in
41
41
  `tests/integration/`; durable public promises belong in `tests/contracts/`.
42
+ - `tests/helpers/public_cli.mjs` bounds setup-test child output and projects only
43
+ fixed failure categories, exit/signal metadata and recognized setup refusal kinds.
44
+ It never retains raw child errors, argv, provider text or output in failure reports.
45
+ An unrecognized failure stays unclassified; a later green test does not establish
46
+ the cause of a historical failure. This test helper is not shipped product code.
47
+
48
+ ## Documentation audiences
49
+
50
+ Site navigation separates Getting started, User reference and Maintainers. Installation,
51
+ rehearsal, results and failure recovery belong in the first journey; configuration,
52
+ supported environments, troubleshooting and data safety belong in reference. Keep
53
+ deletion, production-access and unsupported-behavior warnings visible to users.
54
+
55
+ `docs/updating.md` answers release changes, compatibility, updating, breaking changes
56
+ and origin verification with links to deeper owners. Build, test and publishing details
57
+ belong in maintainer guidance, not installation prerequisites. `docs/releasing.md` owns
58
+ current publishing controls; historical incidents stay in release and issue history.
42
59
 
43
60
  ## CLI modules
44
61
 
@@ -62,7 +79,8 @@ typed registry and produce the same result contract:
62
79
  - `runtime_commands.mts` coordinates local runtimes and project proofs.
63
80
  - `runtime_planning.mts` owns read-only stack plans and exact candidate summaries.
64
81
  - `editor_command_handlers.mts` exposes bounded migration and configuration review
65
- contracts plus digest-bound, non-destructive stale-environment stop review.
82
+ contracts plus digest-bound, non-destructive stale-environment stop review. It also
83
+ keeps impact preview/measurement dispatch thin; aggregate policy stays in runtime.
66
84
  - `source_commands.mts` coordinates approved source access and refresh.
67
85
  - `renderers.mts` formats human-readable results without changing state.
68
86
  - `terminal.mts` owns prompts, colors, and terminal exit behavior.
@@ -98,6 +116,62 @@ handler-family module at 350 lines or fewer. A new command belongs in the narrow
98
116
  existing family, or in a new focused family when it represents a genuinely separate
99
117
  responsibility.
100
118
 
119
+ Static Migration Insights has a separate additive contract from migration-plan v1 and
120
+ post-failure SQLSTATE diagnosis. `runtime/migration_insights_input.mts` owns bounded
121
+ regular-file reads and private exact-input text; the existing inventory, baseline-prefix
122
+ and topology owners still calculate the candidate digest. Repeated shared-directory
123
+ inputs must keep the same raw hash; findings retrieve text only by its expected hash.
124
+ `shared/held_path_ancestry.mts` retains canonical directory identities and timestamps
125
+ to refuse detected concurrent ancestry changes. This is not atomic `openat` resolution
126
+ or a filesystem sandbox; leaf checks and existing workspace trust remain necessary.
127
+ `migration_sql_lexer.mts` and
128
+ `migration_sql_scan.mts` own bounded lexical positions and fixed review hints, not SQL
129
+ validation. `migration_insights.mts` alone projects redacted public findings and explicit
130
+ coverage/unknown states. The editor consumes that contract; it must not reimplement SQL
131
+ analysis or infer runtime measurements from static warnings.
132
+
133
+ Migration impact is a separate optional contract, not a new rehearsal gate.
134
+ `runtime/migration_impact_plan.mts` recognizes only bounded simple SQL forms and retains
135
+ private relation/column specifications; it does not execute migrations or claim SQL
136
+ validity. `migration_impact_queries.mts` validates those specifications and generates
137
+ fixed, identifier-quoted aggregate queries. `migration_impact_catalog.mts` owns the
138
+ closed native index, statistics and operator-family predicates. Metadata checks refuse unsupported
139
+ table, type and operator semantics; public output contains count strings or explicit
140
+ unknowns, never those private specifications, SQL, object names or row contents.
141
+ `migration_impact.mts` binds preview and measurement reports to the whole candidate
142
+ digest, selected target, immutable baseline and exact input bytes. It rechecks the
143
+ binding after measurement and never interprets current counts as sequential migration
144
+ simulation or a production prediction.
145
+
146
+ `targets/migration_impact_runtime.mts` owns access to an already-running, verified,
147
+ ownership-proven local sandbox and its unchanged identity. It does not start/repair
148
+ an engine, reset data or apply migrations. Measurement uses one read-only repeatable
149
+ read count transaction with bounded locks, statements, output and total duration.
150
+ The environment-scrubbed client connects only through the proven owned container's
151
+ fixed local socket. PostgreSQL uses its configured local owner; Supabase uses its
152
+ built-in `supabase_admin`, not the restricted application-facing `postgres` role.
153
+ The fixed temporary-file bound and namespace-catalog lock require that local
154
+ administrator. No grants, role promotion, password access, network connection or
155
+ caller-selected SQL is added; the same read-only compiler and metadata refusals apply.
156
+ A separate read-only catalog preflight retains relation OIDs; globally ordered
157
+ `SHARE ROW EXCLUSIVE` locks precede the first repeatable-read snapshot, preventing
158
+ index/statistics DDL from outrunning its metadata checks. Counts recheck the retained
159
+ OIDs and supported semantics after locking. A concurrent name-to-view swap can
160
+ transiently lock view dependencies within the selected sandbox, but cannot make the
161
+ compiler execute that view query or return its counts. This is not atomic name/OID
162
+ resolution or a sandbox for a compromised database server. The private transport
163
+ also takes a bounded database-local namespace-catalog `SHARE` lock before relation
164
+ name resolution, preventing schema rename/swap during checks and aggregates.
165
+ This can temporarily block schema-catalog changes anywhere in that selected
166
+ disposable database; it does not change another database or acquire a cluster-wide lock.
167
+ The transport invokes the script compiler after final identity preflight so a slow check cannot
168
+ leave an earlier, larger server deadline embedded in the submitted script. The CLI
169
+ classifies `measure impact` for the existing project-operation lock and cancellation
170
+ owner despite the SQL being read-only; operation metadata/history may be written.
171
+ `inspect impact` is an offline preview. Both require one explicit configured target;
172
+ measurement additionally requires the exact whole-plan confirmation. Neither command
173
+ is a prerequisite for ordinary `run` or `migrate`, and both remain free CLI features.
174
+
101
175
  ## Configuration pipeline
102
176
 
103
177
  The stable `project/configuration.mts` facade keeps existing imports compatible while