@rehearsal-db/core 1.0.0 → 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.
- package/CHANGELOG.md +46 -1
- package/COMPATIBILITY.md +43 -0
- package/README.md +31 -12
- package/dist/package.json +2 -2
- package/dist/scripts/install/create_root_config.mjs +9 -2
- package/dist/src/cli/failure_output.d.mts +3 -0
- package/dist/src/cli/failure_output.mjs +48 -0
- package/dist/src/cli/rehearsal.mjs +15 -4
- package/dist/src/cli/runtime_action_handlers.mjs +11 -4
- package/dist/src/cli/runtime_commands.mjs +23 -24
- package/dist/src/cli/runtime_failure_summary.d.mts +1 -0
- package/dist/src/cli/runtime_failure_summary.mjs +30 -4
- package/dist/src/cli/runtime_planning.mjs +6 -0
- package/dist/src/project/install_scaffold.mjs +7 -0
- package/dist/src/project/project_discovery_metadata.mjs +9 -11
- package/dist/src/runtime/migration_failure.d.mts +28 -0
- package/dist/src/runtime/migration_failure.mjs +197 -0
- package/dist/src/runtime/migration_failure_source.d.mts +16 -0
- package/dist/src/runtime/migration_failure_source.mjs +68 -0
- package/dist/src/runtime/plan.mjs +26 -15
- package/dist/src/shared/diagnostics.mjs +5 -0
- package/dist/src/shared/host_platform.d.mts +16 -0
- package/dist/src/shared/host_platform.mjs +109 -0
- package/dist/src/shared/operation_guard.d.mts +4 -1
- package/dist/src/shared/operation_guard.mjs +18 -13
- package/dist/src/shared/operation_progress.mjs +35 -1
- package/dist/src/shared/operation_progress_storage.d.mts +27 -6
- package/dist/src/shared/operation_progress_storage.mjs +65 -9
- package/dist/src/shared/process_environment.d.mts +2 -1
- package/dist/src/shared/process_environment.mjs +47 -5
- package/dist/src/targets/port_start_failure.d.mts +15 -0
- package/dist/src/targets/port_start_failure.mjs +46 -0
- package/dist/src/targets/postgresql.mjs +8 -7
- package/dist/src/targets/postgresql_process.d.mts +8 -1
- package/dist/src/targets/postgresql_process.mjs +46 -2
- package/dist/src/targets/supabase_process.mjs +2 -1
- package/dist/src/targets/supabase_start_failure.mjs +5 -1
- package/docs/architecture.md +32 -2
- package/docs/commands.md +35 -0
- package/docs/getting-started.md +48 -0
- package/docs/index.md +15 -9
- package/docs/releasing.md +28 -24
- package/docs/roadmap.md +8 -3
- package/docs/stability.md +8 -4
- package/docs/troubleshooting.md +54 -2
- package/docs/tutorial.md +93 -8
- package/docs/vscode.md +220 -37
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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<
|
|
14
|
-
export declare const
|
|
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
|
|
18
|
-
export declare const
|
|
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
|
-
|
|
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
|
|
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) => ![
|
|
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
|
-
|
|
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) => ![
|
|
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
|
|
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
|
|
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,
|
|
@@ -8,8 +8,9 @@ export interface CleanProcessEnvironmentOptions {
|
|
|
8
8
|
inheritedEnvironment?: ProcessEnvironment;
|
|
9
9
|
overrides?: ProcessEnvironment;
|
|
10
10
|
passthroughKeys?: readonly string[];
|
|
11
|
+
platform?: NodeJS.Platform;
|
|
11
12
|
}
|
|
12
13
|
export declare const isLoopbackUrl: (value: string) => boolean;
|
|
13
14
|
export declare const assertLoopbackUrl: (label: string, value: string) => void;
|
|
14
15
|
export declare const pickEnvironmentVariables: (source: ProcessEnvironment, keys: readonly string[]) => Record<string, string>;
|
|
15
|
-
export declare const createCleanProcessEnvironment: ({ inheritedEnvironment, overrides, passthroughKeys, }?: CleanProcessEnvironmentOptions) => Record<string, string>;
|
|
16
|
+
export declare const createCleanProcessEnvironment: ({ inheritedEnvironment, overrides, passthroughKeys, platform, }?: CleanProcessEnvironmentOptions) => Record<string, string>;
|
|
@@ -21,6 +21,17 @@ const SAFE_SYSTEM_ENVIRONMENT_KEYS = Object.freeze([
|
|
|
21
21
|
"TEMP",
|
|
22
22
|
"USER",
|
|
23
23
|
]);
|
|
24
|
+
// Windows resolves environment names case-insensitively. Keep only infrastructure
|
|
25
|
+
// needed to locate executables and user configuration, never ambient credentials.
|
|
26
|
+
const SAFE_WINDOWS_ENVIRONMENT_KEYS = Object.freeze([
|
|
27
|
+
"SystemRoot",
|
|
28
|
+
"WINDIR",
|
|
29
|
+
"ComSpec",
|
|
30
|
+
"PATHEXT",
|
|
31
|
+
"USERPROFILE",
|
|
32
|
+
"APPDATA",
|
|
33
|
+
"LOCALAPPDATA",
|
|
34
|
+
]);
|
|
24
35
|
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
|
|
25
36
|
export const isLoopbackUrl = (value) => {
|
|
26
37
|
try {
|
|
@@ -41,10 +52,41 @@ export const pickEnvironmentVariables = (source, keys) => Object.fromEntries(key
|
|
|
41
52
|
const value = source[key];
|
|
42
53
|
return value === undefined ? [] : [[key, value]];
|
|
43
54
|
}));
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
const pickWindowsEnvironmentVariables = (source, keys) => {
|
|
56
|
+
const selected = {};
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const key of keys) {
|
|
59
|
+
const normalized = key.toUpperCase();
|
|
60
|
+
if (seen.has(normalized))
|
|
61
|
+
continue;
|
|
62
|
+
seen.add(normalized);
|
|
63
|
+
const sourceKey = source[key] !== undefined
|
|
64
|
+
? key
|
|
65
|
+
: Object.keys(source).find((candidate) => candidate.toUpperCase() === normalized &&
|
|
66
|
+
source[candidate] !== undefined);
|
|
67
|
+
if (sourceKey !== undefined)
|
|
68
|
+
selected[key] = source[sourceKey];
|
|
69
|
+
}
|
|
70
|
+
return selected;
|
|
71
|
+
};
|
|
72
|
+
export const createCleanProcessEnvironment = ({ inheritedEnvironment = process.env, overrides = {}, passthroughKeys = [], platform = process.platform, } = {}) => {
|
|
73
|
+
const keys = [
|
|
46
74
|
...SAFE_SYSTEM_ENVIRONMENT_KEYS,
|
|
75
|
+
...(platform === "win32" ? SAFE_WINDOWS_ENVIRONMENT_KEYS : []),
|
|
47
76
|
...passthroughKeys,
|
|
48
|
-
]
|
|
49
|
-
|
|
50
|
-
|
|
77
|
+
];
|
|
78
|
+
if (platform !== "win32") {
|
|
79
|
+
return {
|
|
80
|
+
...pickEnvironmentVariables(inheritedEnvironment, keys),
|
|
81
|
+
...pickEnvironmentVariables(overrides, Object.keys(overrides)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const environment = pickWindowsEnvironmentVariables(inheritedEnvironment, keys);
|
|
85
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
86
|
+
if (value === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const existing = Object.keys(environment).find((candidate) => candidate.toUpperCase() === key.toUpperCase());
|
|
89
|
+
environment[existing ?? key] = value;
|
|
90
|
+
}
|
|
91
|
+
return environment;
|
|
92
|
+
};
|
|
@@ -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 {
|
|
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
|
-
|
|
197
|
-
await runOwnedPostgresqlSql({
|
|
197
|
+
await runOwnedPostgresqlMigration({
|
|
198
198
|
containerName,
|
|
199
199
|
database,
|
|
200
200
|
databaseUser,
|
|
201
201
|
repositoryRoot,
|
|
202
|
-
|
|
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
|
|
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
|
-
]
|
|
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
|
|
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
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Summarize provider startup evidence without retaining container logs or secrets. */
|
|
2
|
+
import { classifyPortStartupFailure, portStartupGuidance, } from "./port_start_failure.mjs";
|
|
2
3
|
const SERVICE_NAMES = Object.freeze({
|
|
3
4
|
db: "database",
|
|
4
5
|
auth: "auth",
|
|
@@ -18,7 +19,6 @@ const SERVICE_NAMES = Object.freeze({
|
|
|
18
19
|
const REASONS = Object.freeze([
|
|
19
20
|
["out_of_memory", /out of memory|oom[- ]killed|memory allocation failed/iu],
|
|
20
21
|
["disk_full", /no space left on device|disk quota exceeded/iu],
|
|
21
|
-
["port_in_use", /address already in use|port is already allocated/iu],
|
|
22
22
|
["image_architecture", /exec format error/iu],
|
|
23
23
|
["image_pull", /failed to pull|pull access denied|manifest unknown/iu],
|
|
24
24
|
[
|
|
@@ -40,6 +40,9 @@ export const summarizeSupabaseStartupFailure = ({ output, status, processErrorCo
|
|
|
40
40
|
reasons.add("process_timeout");
|
|
41
41
|
if (processErrorCode === "ENOBUFS")
|
|
42
42
|
reasons.add("provider_output_limit");
|
|
43
|
+
const portFailure = classifyPortStartupFailure(bounded);
|
|
44
|
+
if (portFailure)
|
|
45
|
+
reasons.add(portFailure);
|
|
43
46
|
for (const [code, pattern] of REASONS)
|
|
44
47
|
if (pattern.test(bounded))
|
|
45
48
|
reasons.add(code);
|
|
@@ -60,6 +63,7 @@ export const summarizeSupabaseStartupFailure = ({ output, status, processErrorCo
|
|
|
60
63
|
...(services.size
|
|
61
64
|
? [`Reported service states: ${[...services].slice(0, 16).join(", ")}.`]
|
|
62
65
|
: []),
|
|
66
|
+
...(portFailure ? [portStartupGuidance(portFailure, "supabase")] : []),
|
|
63
67
|
"Provider logs, paths, environment values and connection URLs are omitted. This summary does not establish the underlying cause.",
|
|
64
68
|
].join("\n");
|
|
65
69
|
};
|