@rehearsal-db/core 1.0.1 → 1.0.2

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 (34) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/README.md +1 -1
  3. package/dist/package.json +1 -1
  4. package/dist/src/cli/failure_output.d.mts +3 -0
  5. package/dist/src/cli/failure_output.mjs +48 -0
  6. package/dist/src/cli/rehearsal.mjs +3 -2
  7. package/dist/src/cli/runtime_action_handlers.mjs +11 -4
  8. package/dist/src/cli/runtime_commands.mjs +21 -7
  9. package/dist/src/cli/runtime_failure_summary.d.mts +1 -0
  10. package/dist/src/cli/runtime_failure_summary.mjs +30 -4
  11. package/dist/src/runtime/migration_failure.d.mts +28 -0
  12. package/dist/src/runtime/migration_failure.mjs +197 -0
  13. package/dist/src/runtime/migration_failure_source.d.mts +16 -0
  14. package/dist/src/runtime/migration_failure_source.mjs +68 -0
  15. package/dist/src/shared/diagnostics.mjs +5 -0
  16. package/dist/src/shared/operation_guard.d.mts +4 -1
  17. package/dist/src/shared/operation_guard.mjs +18 -13
  18. package/dist/src/shared/operation_progress.mjs +35 -1
  19. package/dist/src/shared/operation_progress_storage.d.mts +27 -6
  20. package/dist/src/shared/operation_progress_storage.mjs +65 -9
  21. package/dist/src/targets/port_start_failure.d.mts +15 -0
  22. package/dist/src/targets/port_start_failure.mjs +46 -0
  23. package/dist/src/targets/postgresql.mjs +8 -7
  24. package/dist/src/targets/postgresql_process.d.mts +8 -1
  25. package/dist/src/targets/postgresql_process.mjs +46 -2
  26. package/dist/src/targets/supabase_process.mjs +2 -1
  27. package/dist/src/targets/supabase_start_failure.mjs +5 -1
  28. package/docs/architecture.md +32 -2
  29. package/docs/commands.md +35 -0
  30. package/docs/getting-started.md +1 -1
  31. package/docs/troubleshooting.md +54 -2
  32. package/docs/tutorial.md +93 -8
  33. package/docs/vscode.md +41 -16
  34. package/package.json +1 -1
@@ -1,5 +1,7 @@
1
1
  /** Project-wide mutation lock and installed-package integrity guard. */
2
+ import type { MigrationFailureDiagnostic } from "../runtime/migration_failure.mjs";
2
3
  import { type OperationProgressSnapshot, type RecentOperationProgress } from "./operation_progress_contract.mjs";
4
+ import { type RecentOperationFailure } from "./operation_progress_storage.mjs";
3
5
  export interface InstalledPackageFingerprint {
4
6
  readonly files: number;
5
7
  readonly sha256: string;
@@ -8,7 +10,7 @@ export interface ProjectOperation {
8
10
  readonly command: string;
9
11
  readonly id: string;
10
12
  readonly startedAt: string;
11
- writeProgress(progress: OperationProgressSnapshot): Promise<void>;
13
+ writeProgress(progress: OperationProgressSnapshot, migrationFailure?: MigrationFailureDiagnostic): Promise<void>;
12
14
  release(): Promise<void>;
13
15
  releaseSync(): void;
14
16
  }
@@ -22,6 +24,7 @@ export type ProjectOperationState = Readonly<{
22
24
  progress: OperationProgressSnapshot | null;
23
25
  }> | null;
24
26
  recent: readonly RecentOperationProgress[] | null;
27
+ recentFailures?: readonly RecentOperationFailure[] | null;
25
28
  }>;
26
29
  export declare const createInstalledPackageFingerprint: ({ packageRoot, }: {
27
30
  packageRoot: string;
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
6
6
  import { join, relative } from "node:path";
7
7
  import { REHEARSAL_VERSION } from "./diagnostics.mjs";
8
8
  import { refreshLiveOperationProgress, } from "./operation_progress_contract.mjs";
9
- import { archiveOperationProgress, readOwnedOperationProgress, readRecentOperationProgress, writeOwnedOperationProgress, } from "./operation_progress_storage.mjs";
9
+ import { archiveOperationProgress, readOwnedOperationProgress, readOwnedOperationProgressRecord, readRecentOperationHistory, writeOwnedOperationProgress, } from "./operation_progress_storage.mjs";
10
10
  const LOCK_ROOT = join(tmpdir(), "rehearsal-db-operation-locks");
11
11
  const OPERATION_KINDS = new Set([
12
12
  "stop-stale",
@@ -164,7 +164,7 @@ export const acquireProjectOperation = async ({ projectRoot, command, }) => {
164
164
  command,
165
165
  id: digest(token),
166
166
  startedAt: owner.startedAt,
167
- async writeProgress(progress) {
167
+ async writeProgress(progress, migrationFailure) {
168
168
  const current = await readPrivateLockOwner(ownerPath);
169
169
  assertOwnership(current);
170
170
  if (progress.startedAt !== owner.startedAt)
@@ -174,25 +174,29 @@ export const acquireProjectOperation = async ({ projectRoot, command, }) => {
174
174
  token,
175
175
  operationId: digest(token),
176
176
  progress,
177
+ ...(migrationFailure === undefined ? {} : { migrationFailure }),
177
178
  });
178
179
  },
179
180
  async release() {
180
181
  const current = await readPrivateLockOwner(ownerPath);
181
182
  assertOwnership(current);
182
- const progress = await readOwnedOperationProgress({
183
+ const stored = await readOwnedOperationProgressRecord({
183
184
  lockDirectory,
184
185
  token,
185
186
  operationId: digest(token),
186
187
  startedAt: owner.startedAt,
187
188
  });
188
- if (progress) {
189
+ if (stored) {
189
190
  try {
190
191
  await archiveOperationProgress({
191
192
  projectHash,
192
193
  operationKinds: OPERATION_KINDS,
193
194
  kind: command,
194
195
  rehearsalVersion: REHEARSAL_VERSION,
195
- progress,
196
+ progress: stored.progress,
197
+ ...(stored.migrationFailure === undefined
198
+ ? {}
199
+ : { migrationFailure: stored.migrationFailure }),
196
200
  });
197
201
  }
198
202
  catch {
@@ -240,7 +244,7 @@ export const acquireProjectOperation = async ({ projectRoot, command, }) => {
240
244
  }
241
245
  throw new Error("Rehearsal could not acquire its project operation lock.");
242
246
  };
243
- const operationState = (state, owner = null, progress = null, recent = null) => Object.freeze({
247
+ const operationState = (state, owner = null, progress = null, recent = null, recentFailures = null) => Object.freeze({
244
248
  schemaVersion: 1,
245
249
  state,
246
250
  operation: owner
@@ -252,6 +256,7 @@ const operationState = (state, owner = null, progress = null, recent = null) =>
252
256
  })
253
257
  : null,
254
258
  recent,
259
+ recentFailures,
255
260
  });
256
261
  export const inspectProjectOperationState = async ({ projectRoot, }) => {
257
262
  await assertPrivateLockRoot();
@@ -259,7 +264,7 @@ export const inspectProjectOperationState = async ({ projectRoot, }) => {
259
264
  const projectHash = digest(canonicalRoot);
260
265
  const lockDirectory = join(LOCK_ROOT, projectHash);
261
266
  const ownerPath = join(lockDirectory, "owner.json");
262
- const recent = await readRecentOperationProgress({
267
+ const { recent, recentFailures } = await readRecentOperationHistory({
263
268
  projectHash,
264
269
  operationKinds: OPERATION_KINDS,
265
270
  });
@@ -269,14 +274,14 @@ export const inspectProjectOperationState = async ({ projectRoot, }) => {
269
274
  }
270
275
  catch (error) {
271
276
  if (hasErrorCode(error, "ENOENT"))
272
- return operationState("idle", null, null, recent);
277
+ return operationState("idle", null, null, recent, recentFailures);
273
278
  throw error;
274
279
  }
275
280
  if (!details.isDirectory() ||
276
281
  details.isSymbolicLink() ||
277
282
  (details.mode & 0o077) !== 0 ||
278
283
  (typeof process.getuid === "function" && details.uid !== process.getuid())) {
279
- return operationState("invalid", null, null, recent);
284
+ return operationState("invalid", null, null, recent, recentFailures);
280
285
  }
281
286
  let owner;
282
287
  try {
@@ -284,11 +289,11 @@ export const inspectProjectOperationState = async ({ projectRoot, }) => {
284
289
  }
285
290
  catch (error) {
286
291
  if (hasErrorCode(error, "ENOENT"))
287
- return operationState("idle", null, null, recent);
288
- return operationState("invalid", null, null, recent);
292
+ return operationState("idle", null, null, recent, recentFailures);
293
+ return operationState("invalid", null, null, recent, recentFailures);
289
294
  }
290
295
  if (owner.projectHash !== projectHash)
291
- return operationState("invalid", null, null, recent);
296
+ return operationState("invalid", null, null, recent, recentFailures);
292
297
  const live = processIsAlive(owner.pid);
293
298
  const progress = await readOwnedOperationProgress({
294
299
  lockDirectory,
@@ -296,7 +301,7 @@ export const inspectProjectOperationState = async ({ projectRoot, }) => {
296
301
  operationId: digest(owner.token),
297
302
  startedAt: owner.startedAt,
298
303
  });
299
- return operationState(live ? "busy" : "stale", owner, live && progress ? refreshLiveOperationProgress(progress) : progress, recent);
304
+ return operationState(live ? "busy" : "stale", owner, live && progress ? refreshLiveOperationProgress(progress) : progress, recent, recentFailures);
300
305
  };
301
306
  export const inspectProjectOperationLocks = async () => readdir(LOCK_ROOT).catch((error) => {
302
307
  if (hasErrorCode(error, "ENOENT"))
@@ -2,6 +2,8 @@
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { performance } from "node:perf_hooks";
4
4
  import { isOperationCancelled } from "./cancellation.mjs";
5
+ import { RehearsalError } from "./diagnostics.mjs";
6
+ import { parseMigrationFailureDiagnostic, } from "../runtime/migration_failure.mjs";
5
7
  import { MAX_OPERATION_PROGRESS_EVENTS, MAX_OPERATION_PROGRESS_STAGES, OPERATION_PROGRESS_PHASES, parseOperationProgressSnapshot, } from "./operation_progress_contract.mjs";
6
8
  const contexts = new AsyncLocalStorage();
7
9
  export const getOwnedOperationProgressId = () => contexts.getStore()?.operation.id ?? null;
@@ -47,9 +49,36 @@ const snapshot = (context) => {
47
49
  };
48
50
  const publish = async (context) => {
49
51
  const value = parseOperationProgressSnapshot(snapshot(context));
50
- context.writes = context.writes.then(() => context.operation.writeProgress(value));
52
+ const migrationFailure = context.migrationFailure;
53
+ context.writes = context.writes.then(() => context.operation.writeProgress(value, migrationFailure));
51
54
  await context.writes;
52
55
  };
56
+ /** Cleanup aggregates retain the original error first; never inspect sibling failures. */
57
+ const originalMigrationFailure = (error) => {
58
+ try {
59
+ let original = error;
60
+ for (let depth = 0; depth <= 4; depth += 1) {
61
+ if (original instanceof AggregateError) {
62
+ if (depth === 4 || !Array.isArray(original.errors))
63
+ return undefined;
64
+ original = original.errors[0];
65
+ continue;
66
+ }
67
+ if (original instanceof RehearsalError &&
68
+ !isOperationCancelled(original) &&
69
+ original.context !== null &&
70
+ typeof original.context === "object" &&
71
+ !Array.isArray(original.context) &&
72
+ "migrationFailure" in original.context)
73
+ return parseMigrationFailureDiagnostic(original.context.migrationFailure);
74
+ return undefined;
75
+ }
76
+ }
77
+ catch {
78
+ /* Invalid error metadata is not evidence and cannot replace the original failure. */
79
+ }
80
+ return undefined;
81
+ };
53
82
  export const withOperationProgress = async ({ operation, task, now = () => new Date(), }) => {
54
83
  if (operation === null)
55
84
  return task();
@@ -85,6 +114,11 @@ export const withOperationProgress = async ({ operation, task, now = () => new D
85
114
  }
86
115
  catch (error) {
87
116
  context.state = isOperationCancelled(error) ? "cancelled" : "failed";
117
+ if (context.state === "failed") {
118
+ const diagnostic = originalMigrationFailure(error);
119
+ if (diagnostic !== undefined)
120
+ context.migrationFailure = diagnostic;
121
+ }
88
122
  appendEvent(context, context.state === "cancelled"
89
123
  ? "operation.cancelled"
90
124
  : "operation.failed");
@@ -1,24 +1,45 @@
1
+ import { type MigrationFailureDiagnostic } from "../runtime/migration_failure.mjs";
1
2
  import { type OperationProgressSnapshot, type RecentOperationProgress } from "./operation_progress_contract.mjs";
2
- export declare const writeOwnedOperationProgress: ({ lockDirectory, token, operationId, progress, }: {
3
+ interface StoredOperationProgress extends RecentOperationProgress {
4
+ readonly migrationFailure?: MigrationFailureDiagnostic;
5
+ }
6
+ export interface RecentOperationFailure {
7
+ readonly operationId: string;
8
+ readonly diagnostic: MigrationFailureDiagnostic;
9
+ }
10
+ interface OwnedOperationProgress {
11
+ readonly progress: OperationProgressSnapshot;
12
+ readonly migrationFailure?: MigrationFailureDiagnostic;
13
+ }
14
+ export declare const writeOwnedOperationProgress: ({ lockDirectory, token, operationId, progress, migrationFailure, }: {
3
15
  readonly lockDirectory: string;
4
16
  readonly token: string;
5
17
  readonly operationId: string;
6
18
  readonly progress: OperationProgressSnapshot;
19
+ readonly migrationFailure?: MigrationFailureDiagnostic;
7
20
  }) => Promise<void>;
8
- export declare const readOwnedOperationProgress: ({ lockDirectory, token, operationId, startedAt, }: {
21
+ export declare const readOwnedOperationProgressRecord: ({ lockDirectory, token, operationId, startedAt, }: {
9
22
  readonly lockDirectory: string;
10
23
  readonly token: string;
11
24
  readonly operationId: string;
12
25
  readonly startedAt: string;
13
- }) => Promise<OperationProgressSnapshot | null>;
14
- export declare const readRecentOperationProgress: ({ projectHash, operationKinds, }: {
26
+ }) => Promise<OwnedOperationProgress | null>;
27
+ export declare const readOwnedOperationProgress: (input: Parameters<typeof readOwnedOperationProgressRecord>[0]) => Promise<OperationProgressSnapshot | null>;
28
+ declare const readStoredOperationProgress: ({ projectHash, operationKinds, }: {
15
29
  readonly projectHash: string;
16
30
  readonly operationKinds: ReadonlySet<string>;
17
- }) => Promise<readonly RecentOperationProgress[] | null>;
18
- export declare const archiveOperationProgress: ({ projectHash, operationKinds, kind, rehearsalVersion, progress, }: {
31
+ }) => Promise<readonly StoredOperationProgress[] | null>;
32
+ export declare const readRecentOperationHistory: (input: Parameters<typeof readStoredOperationProgress>[0]) => Promise<{
33
+ readonly recent: readonly RecentOperationProgress[] | null;
34
+ readonly recentFailures: readonly RecentOperationFailure[] | null;
35
+ }>;
36
+ export declare const readRecentOperationProgress: (input: Parameters<typeof readStoredOperationProgress>[0]) => Promise<readonly RecentOperationProgress[] | null>;
37
+ export declare const archiveOperationProgress: ({ projectHash, operationKinds, kind, rehearsalVersion, progress, migrationFailure, }: {
19
38
  readonly projectHash: string;
20
39
  readonly operationKinds: ReadonlySet<string>;
21
40
  readonly kind: string;
22
41
  readonly rehearsalVersion: string | null;
23
42
  readonly progress: OperationProgressSnapshot;
43
+ readonly migrationFailure?: MigrationFailureDiagnostic;
24
44
  }) => Promise<void>;
45
+ export {};
@@ -3,6 +3,7 @@ import { lstat, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
+ import { parseMigrationFailureDiagnostic, } from "../runtime/migration_failure.mjs";
6
7
  import { MAX_RECENT_OPERATION_PROGRESS, parseOperationProgressSnapshot, } from "./operation_progress_contract.mjs";
7
8
  const HISTORY_ROOT = join(tmpdir(), "rehearsal-db-operation-activity");
8
9
  const MAX_PROGRESS_BYTES = 32 * 1024;
@@ -47,29 +48,47 @@ const writePrivateJson = async (path, value, maximumBytes) => {
47
48
  await rm(temporary, { force: true });
48
49
  }
49
50
  };
50
- export const writeOwnedOperationProgress = async ({ lockDirectory, token, operationId, progress, }) => {
51
+ export const writeOwnedOperationProgress = async ({ lockDirectory, token, operationId, progress, migrationFailure, }) => {
51
52
  const validated = parseOperationProgressSnapshot(progress);
52
53
  if (validated.operationId !== operationId)
53
54
  throw new Error("Operation telemetry belongs to another owner.");
54
- await writePrivateJson(join(lockDirectory, "progress.json"), { formatVersion: 1, ownerToken: token, progress: validated }, MAX_PROGRESS_BYTES);
55
+ const failure = migrationFailure === undefined
56
+ ? {}
57
+ : { migrationFailure: parseMigrationFailureDiagnostic(migrationFailure) };
58
+ if (migrationFailure !== undefined && validated.state !== "failed")
59
+ throw new Error("Migration failure evidence requires a failed operation.");
60
+ await writePrivateJson(join(lockDirectory, "progress.json"), { formatVersion: 1, ownerToken: token, progress: validated, ...failure }, MAX_PROGRESS_BYTES);
55
61
  };
56
- export const readOwnedOperationProgress = async ({ lockDirectory, token, operationId, startedAt, }) => {
62
+ export const readOwnedOperationProgressRecord = async ({ lockDirectory, token, operationId, startedAt, }) => {
57
63
  try {
58
64
  const value = (await readPrivateJson(join(lockDirectory, "progress.json"), MAX_PROGRESS_BYTES));
59
65
  if (value.formatVersion !== 1 ||
60
66
  value.ownerToken !== token ||
61
- Object.keys(value).some((key) => !["formatVersion", "ownerToken", "progress"].includes(key)))
67
+ Object.keys(value).some((key) => ![
68
+ "formatVersion",
69
+ "ownerToken",
70
+ "progress",
71
+ "migrationFailure",
72
+ ].includes(key)))
62
73
  return null;
63
74
  const snapshot = parseOperationProgressSnapshot(value.progress);
75
+ const failure = value.migrationFailure === undefined
76
+ ? {}
77
+ : {
78
+ migrationFailure: parseMigrationFailureDiagnostic(value.migrationFailure),
79
+ };
80
+ if (value.migrationFailure !== undefined && snapshot.state !== "failed")
81
+ return null;
64
82
  return snapshot.operationId === operationId &&
65
83
  snapshot.startedAt === startedAt
66
- ? snapshot
84
+ ? { progress: snapshot, ...failure }
67
85
  : null;
68
86
  }
69
87
  catch {
70
88
  return null;
71
89
  }
72
90
  };
91
+ export const readOwnedOperationProgress = async (input) => (await readOwnedOperationProgressRecord(input))?.progress ?? null;
73
92
  const assertHistoryRoot = async () => {
74
93
  await mkdir(HISTORY_ROOT, { recursive: true, mode: 0o700 });
75
94
  const details = await lstat(HISTORY_ROOT);
@@ -79,7 +98,7 @@ const assertHistoryRoot = async () => {
79
98
  (typeof process.getuid === "function" && details.uid !== process.getuid()))
80
99
  throw new Error("Operation history directory is not private.");
81
100
  };
82
- export const readRecentOperationProgress = async ({ projectHash, operationKinds, }) => {
101
+ const readStoredOperationProgress = async ({ projectHash, operationKinds, }) => {
83
102
  requireProjectHash(projectHash);
84
103
  try {
85
104
  const root = await lstat(HISTORY_ROOT);
@@ -105,15 +124,28 @@ export const readRecentOperationProgress = async ({ projectHash, operationKinds,
105
124
  (typeof record.rehearsalVersion !== "string" ||
106
125
  record.rehearsalVersion.length > 64 ||
107
126
  !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/u.test(record.rehearsalVersion))) ||
108
- Object.keys(record).some((key) => !["kind", "rehearsalVersion", "progress"].includes(key)))
127
+ Object.keys(record).some((key) => ![
128
+ "kind",
129
+ "rehearsalVersion",
130
+ "progress",
131
+ "migrationFailure",
132
+ ].includes(key)))
109
133
  throw new Error("Invalid history metadata.");
110
134
  const progress = parseOperationProgressSnapshot(record.progress);
111
135
  if (progress.state === "running")
112
136
  throw new Error("An unfinished operation cannot be reported as completed history.");
137
+ const failure = record.migrationFailure === undefined
138
+ ? {}
139
+ : {
140
+ migrationFailure: parseMigrationFailureDiagnostic(record.migrationFailure),
141
+ };
142
+ if (record.migrationFailure !== undefined && progress.state !== "failed")
143
+ throw new Error("Migration failure history contradicts its outcome.");
113
144
  return Object.freeze({
114
145
  kind: record.kind,
115
146
  rehearsalVersion: record.rehearsalVersion,
116
147
  progress,
148
+ ...failure,
117
149
  });
118
150
  });
119
151
  if (new Set(recent.map((entry) => entry.progress.operationId)).size !==
@@ -125,12 +157,29 @@ export const readRecentOperationProgress = async ({ projectHash, operationKinds,
125
157
  return missing(error) ? Object.freeze([]) : null;
126
158
  }
127
159
  };
128
- export const archiveOperationProgress = async ({ projectHash, operationKinds, kind, rehearsalVersion, progress, }) => {
160
+ export const readRecentOperationHistory = async (input) => {
161
+ const stored = await readStoredOperationProgress(input);
162
+ if (stored === null)
163
+ return { recent: null, recentFailures: null };
164
+ return {
165
+ recent: Object.freeze(stored.map(({ kind, rehearsalVersion, progress }) => Object.freeze({ kind, rehearsalVersion, progress }))),
166
+ recentFailures: Object.freeze(stored.flatMap(({ progress, migrationFailure }) => migrationFailure === undefined
167
+ ? []
168
+ : [
169
+ Object.freeze({
170
+ operationId: progress.operationId,
171
+ diagnostic: migrationFailure,
172
+ }),
173
+ ])),
174
+ };
175
+ };
176
+ export const readRecentOperationProgress = async (input) => (await readRecentOperationHistory(input)).recent;
177
+ export const archiveOperationProgress = async ({ projectHash, operationKinds, kind, rehearsalVersion, progress, migrationFailure, }) => {
129
178
  requireProjectHash(projectHash);
130
179
  if (progress.state === "running")
131
180
  return;
132
181
  await assertHistoryRoot();
133
- const previous = await readRecentOperationProgress({
182
+ const previous = await readStoredOperationProgress({
134
183
  projectHash,
135
184
  operationKinds,
136
185
  });
@@ -140,7 +189,14 @@ export const archiveOperationProgress = async ({ projectHash, operationKinds, ki
140
189
  kind,
141
190
  rehearsalVersion,
142
191
  progress: parseOperationProgressSnapshot(progress),
192
+ ...(migrationFailure === undefined
193
+ ? {}
194
+ : {
195
+ migrationFailure: parseMigrationFailureDiagnostic(migrationFailure),
196
+ }),
143
197
  };
198
+ if (migrationFailure !== undefined && progress.state !== "failed")
199
+ throw new Error("Migration failure history requires a failed operation.");
144
200
  const recent = [
145
201
  ...previous.filter((item) => item.progress.operationId !== progress.operationId),
146
202
  entry,
@@ -0,0 +1,15 @@
1
+ import type { SpawnSyncReturns } from "node:child_process";
2
+ export type PortStartupFailure = "port_in_use" | "port_unavailable";
3
+ type StartupCommandResult = Pick<SpawnSyncReturns<string>, "status" | "stdout" | "stderr" | "error">;
4
+ /** Recognized bind evidence, never an assertion about the host's root cause. */
5
+ export declare const classifyPortStartupFailure: (output: string) => PortStartupFailure | null;
6
+ /** All advice is fixed text: provider output, port numbers and names stay private. */
7
+ export declare const portStartupGuidance: (evidence: PortStartupFailure, target: "postgresql" | "supabase") => string;
8
+ /** Preserve startup failure without echoing Docker arguments, credentials or logs. */
9
+ export declare const assertPostgresqlStartupResult: (result: StartupCommandResult) => void;
10
+ /** The injected runner retains cancellation and runtime ownership enforcement. */
11
+ export declare const runPostgresqlStartupCommand: (runCommand: (command: string, args: readonly string[], options: {
12
+ capture: boolean;
13
+ allowFailure: boolean;
14
+ }) => StartupCommandResult, args: readonly string[]) => void;
15
+ export {};
@@ -0,0 +1,46 @@
1
+ /** Recognized bind evidence, never an assertion about the host's root cause. */
2
+ export const classifyPortStartupFailure = (output) => {
3
+ const bounded = `${output.slice(0, 64 * 1024)}\n${output.slice(-64 * 1024)}`.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "");
4
+ if (/address already in use|port is already allocated|only one usage of each socket address/iu.test(bounded))
5
+ return "port_in_use";
6
+ if (/ports? (?:are|is) not available|\b(?:bind|listen)\b[^\n]{0,400}(?:forbidden by its access permissions|permission denied|access is denied|cannot assign requested address|WSAEACCES)|\bsocket\b[^\n]{0,400}forbidden by its access permissions/iu.test(bounded))
7
+ return "port_unavailable";
8
+ return null;
9
+ };
10
+ /** All advice is fixed text: provider output, port numbers and names stay private. */
11
+ export const portStartupGuidance = (evidence, target) => [
12
+ evidence === "port_in_use"
13
+ ? "Port guidance: the provider reported an allocated or occupied local port."
14
+ : "Port guidance: the provider could not bind a requested local port. This does not prove an existing listener or a Windows port reservation; an exclusion or host access policy is possible.",
15
+ target === "postgresql"
16
+ ? "Review runtime.databasePort in the active Rehearsal config and choose a different available local port before retrying."
17
+ : "Review runtime.apiPort, runtime.databasePort and runtime.studioPort in the active Rehearsal config and the matching ports in the dedicated Supabase config; choose an available local port block before retrying.",
18
+ ...(evidence === "port_unavailable"
19
+ ? [
20
+ "For a Windows/WSL Docker host, inspect exclusions in Windows PowerShell (read-only): netsh interface ipv4 show excludedportrange protocol=tcp",
21
+ ]
22
+ : []),
23
+ "Do not kill unknown listeners or change Windows excluded/reserved port ranges to make these ports fit.",
24
+ ].join("\n");
25
+ /** Preserve startup failure without echoing Docker arguments, credentials or logs. */
26
+ export const assertPostgresqlStartupResult = (result) => {
27
+ if (!result.error && result.status === 0)
28
+ return;
29
+ const portFailure = classifyPortStartupFailure(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
30
+ const processCode = result.error?.code;
31
+ const processEvidence = processCode === "ENOENT"
32
+ ? "docker_unavailable"
33
+ : processCode === "ETIMEDOUT"
34
+ ? "process_timeout"
35
+ : processCode === "ENOBUFS"
36
+ ? "provider_output_limit"
37
+ : null;
38
+ throw new Error([
39
+ `PostgreSQL startup failed (${Number.isInteger(result.status) ? `exit ${result.status}` : "process failure"}).`,
40
+ `Reported startup evidence: ${portFailure ?? processEvidence ?? "unknown; provider supplied no recognized safe failure detail"}.`,
41
+ ...(portFailure ? [portStartupGuidance(portFailure, "postgresql")] : []),
42
+ "Provider logs, command arguments, paths, environment values and connection URLs are omitted. This summary does not establish the underlying cause.",
43
+ ].join("\n"));
44
+ };
45
+ /** The injected runner retains cancellation and runtime ownership enforcement. */
46
+ export const runPostgresqlStartupCommand = (runCommand, args) => assertPostgresqlStartupResult(runCommand("docker", args, { capture: true, allowFailure: true }));
@@ -18,10 +18,11 @@ import { inspectPostgresqlRuntimeStatus } from "./postgresql_status.mjs";
18
18
  import { ensureConfiguredContainerRuntime } from "./supabase_environment.mjs";
19
19
  import { getOperationCancellationSignal, throwIfOperationCancelled, } from "../shared/cancellation.mjs";
20
20
  import { bindOwnedProcessCancellation, signalOwnedProcessGroup, } from "../shared/owned_process.mjs";
21
- import { runOwnedPostgresqlSql, runPostgresqlCommand, } from "./postgresql_process.mjs";
21
+ import { runOwnedPostgresqlMigration, runPostgresqlCommand, } from "./postgresql_process.mjs";
22
22
  import { parseRuntimeInvocation } from "./target.mjs";
23
23
  import { guardRuntimeManagerAction } from "../runtime/runtime_manager_guards.mjs";
24
24
  import { runtimeDockerEnvironment } from "../runtime/local_docker_context.mjs";
25
+ import { runPostgresqlStartupCommand } from "./port_start_failure.mjs";
25
26
  import { assertDockerResourceLabels } from "./docker_resource_ownership.mjs";
26
27
  import { buildRuntimeFinalizationSql, buildRuntimePostSchemaSql, buildRuntimePrerequisiteSql, buildRuntimeVerificationSql, validateRuntimePolicy, } from "../runtime/policy.mjs";
27
28
  const repositoryRoot = process.cwd();
@@ -193,13 +194,13 @@ create table if not exists rehearsal_internal.schema_migrations (
193
194
  applied_at timestamptz not null default now()
194
195
  );`);
195
196
  const applyMigration = async ({ migration, directory, kind, }) => {
196
- const source = await readFile(join(directory, migration.filename), "utf8");
197
- await runOwnedPostgresqlSql({
197
+ await runOwnedPostgresqlMigration({
198
198
  containerName,
199
199
  database,
200
200
  databaseUser,
201
201
  repositoryRoot,
202
- sql: source,
202
+ migration,
203
+ directory,
203
204
  migrationKind: kind,
204
205
  });
205
206
  runPsql(`insert into rehearsal_internal.schema_migrations
@@ -415,7 +416,7 @@ const createContainer = async (password) => {
415
416
  runCommand("docker", ["volume", "create", "--label", label, volumeName], {
416
417
  capture: true,
417
418
  });
418
- runCommand("docker", [
419
+ runPostgresqlStartupCommand(runCommand, [
419
420
  "run",
420
421
  "--detach",
421
422
  "--pull=never",
@@ -434,7 +435,7 @@ const createContainer = async (password) => {
434
435
  "--volume",
435
436
  `${volumeName}:/var/lib/postgresql/data`,
436
437
  image,
437
- ], { capture: true });
438
+ ]);
438
439
  await waitForDatabase();
439
440
  };
440
441
  const resetRuntime = async () => {
@@ -518,7 +519,7 @@ const startRuntime = async () => {
518
519
  }
519
520
  const running = runCommand("docker", ["inspect", "--format", "{{.State.Running}}", containerName], { capture: true }).stdout.trim();
520
521
  if (running !== "true") {
521
- runCommand("docker", ["start", containerName], { capture: true });
522
+ runPostgresqlStartupCommand(runCommand, ["start", containerName]);
522
523
  }
523
524
  await waitForDatabase();
524
525
  const environment = databaseEnvironment(marker.databasePassword);
@@ -1,4 +1,6 @@
1
1
  /** Run one PostgreSQL command in an interruptible process owned by Rehearsal. */
2
+ import type { MigrationFileEntry } from "../runtime/migration_history.mjs";
3
+ import { type MigrationFailureSource } from "../runtime/migration_failure_source.mjs";
2
4
  import { runOwnedProcess } from "../shared/owned_process.mjs";
3
5
  export declare const runPostgresqlCommand: (command: string, args: readonly string[], { cwd, capture, input, allowFailure, }: {
4
6
  readonly cwd: string;
@@ -6,12 +8,17 @@ export declare const runPostgresqlCommand: (command: string, args: readonly stri
6
8
  readonly input?: string;
7
9
  readonly allowFailure?: boolean;
8
10
  }) => import("child_process").SpawnSyncReturns<string>;
9
- export declare const runOwnedPostgresqlSql: ({ containerName, database, databaseUser, repositoryRoot, sql, migrationKind, runProcessImplementation, }: {
11
+ export declare const runOwnedPostgresqlSql: ({ containerName, database, databaseUser, repositoryRoot, sql, migrationKind, migrationSource, runProcessImplementation, }: {
10
12
  containerName: string;
11
13
  database: string;
12
14
  databaseUser: string;
13
15
  repositoryRoot: string;
14
16
  sql: string;
15
17
  migrationKind: "baseline" | "candidate";
18
+ migrationSource?: MigrationFailureSource;
16
19
  runProcessImplementation?: typeof runOwnedProcess;
17
20
  }) => Promise<void>;
21
+ export declare const runOwnedPostgresqlMigration: ({ migration, directory, migrationKind, ...options }: Omit<Parameters<typeof runOwnedPostgresqlSql>[0], "sql" | "migrationSource"> & {
22
+ migration: MigrationFileEntry;
23
+ directory: string;
24
+ }) => Promise<void>;
@@ -1,10 +1,15 @@
1
1
  /** Run one PostgreSQL command in an interruptible process owned by Rehearsal. */
2
2
  import { spawnSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { readFile, realpath } from "node:fs/promises";
5
+ import { join, relative, sep } from "node:path";
6
+ import { isMigrationWorkspacePath, parseMigrationFailureSource, } from "../runtime/migration_failure_source.mjs";
3
7
  import { getOperationCancellationSignal, throwIfOperationCancelled, } from "../shared/cancellation.mjs";
4
8
  import { runOwnedProcess } from "../shared/owned_process.mjs";
5
9
  import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
6
10
  import { runtimeDockerEnvironment } from "../runtime/local_docker_context.mjs";
7
11
  import { captureRuntimeStartupOwnership } from "../runtime/runtime_startup_ownership.mjs";
12
+ import { diagnoseMigrationFailure, renderMigrationFailureDiagnostic, } from "../runtime/migration_failure.mjs";
8
13
  export const runPostgresqlCommand = (command, args, { cwd, capture = false, input, allowFailure = false, }) => {
9
14
  if (command === "docker" &&
10
15
  (["run", "start"].includes(args[0] ?? "") ||
@@ -33,7 +38,7 @@ export const runPostgresqlCommand = (command, args, { cwd, capture = false, inpu
33
38
  }
34
39
  return result;
35
40
  };
36
- export const runOwnedPostgresqlSql = async ({ containerName, database, databaseUser, repositoryRoot, sql, migrationKind, runProcessImplementation = runOwnedProcess, }) => {
41
+ export const runOwnedPostgresqlSql = async ({ containerName, database, databaseUser, repositoryRoot, sql, migrationKind, migrationSource, runProcessImplementation = runOwnedProcess, }) => {
37
42
  const cancellationSignal = getOperationCancellationSignal();
38
43
  const result = await runProcessImplementation({
39
44
  command: "docker",
@@ -45,6 +50,8 @@ export const runOwnedPostgresqlSql = async ({ containerName, database, databaseU
45
50
  "--quiet",
46
51
  "--set",
47
52
  "ON_ERROR_STOP=1",
53
+ "--set",
54
+ "VERBOSITY=sqlstate",
48
55
  "--username",
49
56
  databaseUser,
50
57
  "--dbname",
@@ -62,6 +69,43 @@ export const runOwnedPostgresqlSql = async ({ containerName, database, databaseU
62
69
  if (result.error)
63
70
  throw result.error;
64
71
  if (result.status !== 0) {
65
- throw new Error(`The ${migrationKind === "candidate" ? "confirmed candidate" : "baseline"} PostgreSQL migration failed (exit ${Number.isInteger(result.status) ? result.status : "unknown"}; stderr ${result.stderrByteCount} bytes). SQL and provider output are withheld.`);
72
+ throw new Error(`The ${migrationKind === "candidate" ? "confirmed candidate" : "baseline"} PostgreSQL migration failed (exit ${Number.isInteger(result.status) ? result.status : "unknown"}; stderr ${result.stderrByteCount} bytes). SQL and provider output are withheld.\n${renderMigrationFailureDiagnostic({ ...diagnoseMigrationFailure({ provider: "postgresql", stderr: result.stderr }), ...(migrationSource === undefined ? {} : { source: parseMigrationFailureSource(migrationSource) }) })}`);
73
+ }
74
+ };
75
+ export const runOwnedPostgresqlMigration = async ({ migration, directory, migrationKind, ...options }) => {
76
+ const path = join(directory, migration.filename);
77
+ const canonicalPath = await realpath(path);
78
+ const bytes = await readFile(canonicalPath);
79
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
80
+ if (sha256 !== migration.fileSha256)
81
+ throw new Error("The reviewed migration file changed before execution. Review the new exact plan before retrying; no migration SQL was started.");
82
+ const sql = bytes.toString("utf8");
83
+ if (!Buffer.from(sql, "utf8").equals(bytes))
84
+ throw new Error("The reviewed migration file is not valid UTF-8. Save it as UTF-8 and review the new exact plan before retrying; no migration SQL was started.");
85
+ const canonicalRoot = await realpath(options.repositoryRoot);
86
+ const local = relative(canonicalRoot, canonicalPath).split(sep).join("/");
87
+ let migrationSource;
88
+ try {
89
+ migrationSource = parseMigrationFailureSource({
90
+ schemaVersion: 1,
91
+ kind: migrationKind,
92
+ filename: migration.filename,
93
+ sha256,
94
+ workspaceRelativePath: migrationKind === "candidate" && isMigrationWorkspacePath(local)
95
+ ? local
96
+ : null,
97
+ line: null,
98
+ column: null,
99
+ evidence: "owned_execution_input",
100
+ });
66
101
  }
102
+ catch {
103
+ /* Unsupported names omit telemetry, not previously permitted SQL execution. */
104
+ }
105
+ await runOwnedPostgresqlSql({
106
+ ...options,
107
+ migrationKind,
108
+ sql,
109
+ ...(migrationSource === undefined ? {} : { migrationSource }),
110
+ });
67
111
  };
@@ -3,6 +3,7 @@ import { getOperationCancellationSignal, throwIfOperationCancelled, } from "../s
3
3
  import { runOwnedProcess } from "../shared/owned_process.mjs";
4
4
  import { createCleanProcessEnvironment, } from "../shared/process_environment.mjs";
5
5
  import { runtimeDockerEnvironment } from "../runtime/local_docker_context.mjs";
6
+ import { diagnoseMigrationFailure, renderMigrationFailureDiagnostic, } from "../runtime/migration_failure.mjs";
6
7
  export const runOwnedSupabaseMigration = async ({ environment, repositoryRoot, runtimeWorkdir, runProcessImplementation = runOwnedProcess, }) => {
7
8
  const cancellationSignal = getOperationCancellationSignal();
8
9
  const result = await runProcessImplementation({
@@ -19,6 +20,6 @@ export const runOwnedSupabaseMigration = async ({ environment, repositoryRoot, r
19
20
  if (result.error)
20
21
  throw result.error;
21
22
  if (result.status !== 0) {
22
- throw new Error(`The confirmed candidate Supabase migration failed (exit ${Number.isInteger(result.status) ? result.status : "unknown"}; stderr ${result.stderrByteCount} bytes). SQL and provider output are withheld.`);
23
+ throw new Error(`The confirmed candidate Supabase migration failed (exit ${Number.isInteger(result.status) ? result.status : "unknown"}; stderr ${result.stderrByteCount} bytes). SQL and provider output are withheld.\n${renderMigrationFailureDiagnostic(diagnoseMigrationFailure({ provider: "supabase", stderr: result.stderr }))}`);
23
24
  }
24
25
  };