@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.14
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 +116 -0
- package/COMPATIBILITY.md +6 -0
- package/README.md +5 -3
- package/docs/commands.md +15 -11
- package/docs/configuration.md +40 -5
- package/docs/getting-started.md +32 -3
- package/docs/production-source.md +175 -17
- package/docs/releasing.md +5 -3
- package/docs/roadmap.md +3 -2
- package/docs/sanitization.md +479 -12
- package/docs/security-model.md +8 -0
- package/docs/standalone-workflow.md +34 -13
- package/docs/troubleshooting.md +17 -6
- package/docs/tutorial.md +7 -1
- package/package.json +2 -2
- package/scripts/install/create_root_config.mjs +5 -22
- package/src/baseline/artifact.mjs +27 -4
- package/src/baseline/privacy_engine.mjs +865 -207
- package/src/baseline/privacy_structured_json.mjs +554 -0
- package/src/cli/arguments.mjs +3 -0
- package/src/cli/guided.mjs +8 -6
- package/src/cli/rehearsal.mjs +23 -3
- package/src/cli/renderers.mjs +29 -4
- package/src/cli/runtime_commands.mjs +92 -9
- package/src/cli/source_commands.mjs +37 -8
- package/src/identity/claim.mjs +23 -21
- package/src/project/install_scaffold.mjs +35 -1
- package/src/project/setup.mjs +14 -24
- package/src/runtime/plan.mjs +7 -3
- package/src/runtime/restore.mjs +13 -5
- package/src/shared/diagnostics.mjs +7 -0
- package/src/shared/node_runtime.mjs +31 -0
- package/src/source/access.mjs +271 -51
- package/src/source/asset_transfer.mjs +156 -74
- package/src/source/baseline.mjs +42 -6
- package/src/source/external_reader.mjs +368 -0
- package/src/source/postgresql_access.mjs +213 -55
- package/src/source/privacy_paths.mjs +38 -0
package/src/cli/renderers.mjs
CHANGED
|
@@ -270,6 +270,18 @@ export const renderSetup = (result) => {
|
|
|
270
270
|
return [
|
|
271
271
|
`REHEARSAL SETUP — ${result.mode.toUpperCase()}`,
|
|
272
272
|
`Project: ${result.project}`,
|
|
273
|
+
`Configuration: ${result.configurationPath} (${
|
|
274
|
+
result.configurationAction === "create"
|
|
275
|
+
? result.mode === "written"
|
|
276
|
+
? "created"
|
|
277
|
+
: "will create"
|
|
278
|
+
: "existing"
|
|
279
|
+
})`,
|
|
280
|
+
...(result.rootConfigGenerationSkipped
|
|
281
|
+
? [
|
|
282
|
+
"Root config: not created because the existing supported configuration remains active.",
|
|
283
|
+
]
|
|
284
|
+
: []),
|
|
273
285
|
`Database: ${result.target === "postgresql" ? "PostgreSQL" : "Supabase"}`,
|
|
274
286
|
`Runtime ID: ${result.projectId}`,
|
|
275
287
|
`Application: ${result.applicationUrl}`,
|
|
@@ -507,7 +519,10 @@ export const renderSourceAccessPlan = (plan) =>
|
|
|
507
519
|
"SOURCE ACCESS — PREVIEW",
|
|
508
520
|
"",
|
|
509
521
|
`Target fingerprint: ${plan.review.targetFingerprint}`,
|
|
510
|
-
|
|
522
|
+
plan.review.reader.mode === "managed"
|
|
523
|
+
? `Managed temporary reader: ${plan.review.reader.role} (${plan.review.reader.validForMinutes} minutes)`
|
|
524
|
+
: `Externally managed reader: ${plan.review.reader.role} (maximum ${plan.review.reader.maximumValidForMinutes} minutes remaining)`,
|
|
525
|
+
`Source changes by Rehearsal: ${plan.review.reader.sourceChanges ? "export views and temporary roles" : "none"}`,
|
|
511
526
|
`Export schema: ${plan.review.exportSchema}`,
|
|
512
527
|
...plan.review.relations.map(
|
|
513
528
|
(relation) =>
|
|
@@ -516,7 +531,10 @@ export const renderSourceAccessPlan = (plan) =>
|
|
|
516
531
|
...(plan.review.assets.length
|
|
517
532
|
? plan.review.assets.map(
|
|
518
533
|
(asset) =>
|
|
519
|
-
` → Storage ${asset.bucket}/${
|
|
534
|
+
` → Storage ${asset.bucket}/${
|
|
535
|
+
asset.prefix ??
|
|
536
|
+
`[private prefix from ${asset.prefixEnvironmentVariable}]`
|
|
537
|
+
} (${asset.rights})`,
|
|
520
538
|
)
|
|
521
539
|
: [" ○ No source assets declared"]),
|
|
522
540
|
"",
|
|
@@ -574,7 +592,12 @@ export const renderSourceRetirement = ({ mode, plan }) =>
|
|
|
574
592
|
`Target fingerprint: ${plan.review.targetFingerprint}`,
|
|
575
593
|
`Reader: ${plan.review.readerRole}`,
|
|
576
594
|
`Export schema: ${plan.review.exportSchema}`,
|
|
577
|
-
...plan.review.
|
|
595
|
+
...(plan.review.accessMode === "managed"
|
|
596
|
+
? plan.review.views.map((view) => ` → remove view ${view}`)
|
|
597
|
+
: [
|
|
598
|
+
" → remove only Rehearsal's local credential and receipt",
|
|
599
|
+
" ✓ preserve the provider-owned reader and export views",
|
|
600
|
+
]),
|
|
578
601
|
"",
|
|
579
602
|
...(mode === "preview"
|
|
580
603
|
? [
|
|
@@ -582,7 +605,9 @@ export const renderSourceRetirement = ({ mode, plan }) =>
|
|
|
582
605
|
`Exact confirmation: --confirm-source-retirement=${plan.digest}`,
|
|
583
606
|
]
|
|
584
607
|
: [
|
|
585
|
-
|
|
608
|
+
plan.review.accessMode === "managed"
|
|
609
|
+
? "The exact reader, export views, and local credential files were retired."
|
|
610
|
+
: "Rehearsal's local credential and receipt were removed. Provider-owned database resources were preserved.",
|
|
586
611
|
]),
|
|
587
612
|
].join("\n");
|
|
588
613
|
|
|
@@ -211,6 +211,82 @@ const projectCommandEnvironment = (additional = {}) => ({
|
|
|
211
211
|
...additional,
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
+
const RUNTIME_STARTUP_FAILURE_PATTERN =
|
|
215
|
+
/container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu;
|
|
216
|
+
|
|
217
|
+
export const classifyManagerFailure = ({
|
|
218
|
+
action,
|
|
219
|
+
output,
|
|
220
|
+
inferredCategory,
|
|
221
|
+
}) => {
|
|
222
|
+
if (RUNTIME_STARTUP_FAILURE_PATTERN.test(output)) {
|
|
223
|
+
return "runtime_dependency_failure";
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
action === "migrate" ||
|
|
227
|
+
(action === "run" &&
|
|
228
|
+
/migrateRuntime|candidate migration|migration up/iu.test(output))
|
|
229
|
+
) {
|
|
230
|
+
return "migration_candidate_failure";
|
|
231
|
+
}
|
|
232
|
+
return inferredCategory;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const parseDockerCapacity = (output) => {
|
|
236
|
+
const [cpuText, memoryText, runningText, totalText] = output
|
|
237
|
+
.trim()
|
|
238
|
+
.split(/\s+/u);
|
|
239
|
+
const values = [cpuText, memoryText, runningText, totalText].map(Number);
|
|
240
|
+
if (!values.every(Number.isFinite)) return null;
|
|
241
|
+
const [cpus, memoryBytes, runningContainers, containers] = values;
|
|
242
|
+
return { cpus, memoryBytes, runningContainers, containers };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const parseColimaMemory = (output) => {
|
|
246
|
+
const line = output
|
|
247
|
+
.split("\n")
|
|
248
|
+
.map((entry) => entry.trim())
|
|
249
|
+
.find((entry) => entry.startsWith("Mem:"));
|
|
250
|
+
if (!line) return null;
|
|
251
|
+
const columns = line.split(/\s+/u);
|
|
252
|
+
const totalBytes = Number(columns[1]);
|
|
253
|
+
const availableBytes = Number(columns[6]);
|
|
254
|
+
if (![totalBytes, availableBytes].every(Number.isFinite)) return null;
|
|
255
|
+
return { totalBytes, availableBytes };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const diagnosticCommand = (command, args) =>
|
|
259
|
+
spawnSync(command, args, {
|
|
260
|
+
encoding: "utf8",
|
|
261
|
+
env: projectCommandEnvironment(),
|
|
262
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
263
|
+
timeout: 5_000,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const inspectContainerCapacity = () => {
|
|
267
|
+
const docker = diagnosticCommand("docker", [
|
|
268
|
+
"info",
|
|
269
|
+
"--format",
|
|
270
|
+
"{{.NCPU}} {{.MemTotal}} {{.ContainersRunning}} {{.Containers}}",
|
|
271
|
+
]);
|
|
272
|
+
const capacity =
|
|
273
|
+
docker.status === 0 ? parseDockerCapacity(docker.stdout) : null;
|
|
274
|
+
if (!capacity) return undefined;
|
|
275
|
+
|
|
276
|
+
const context = diagnosticCommand("docker", ["context", "show"]);
|
|
277
|
+
if (context.status !== 0 || context.stdout.trim() !== "colima") {
|
|
278
|
+
return { engine: "docker-compatible", ...capacity };
|
|
279
|
+
}
|
|
280
|
+
const memory = diagnosticCommand("colima", ["ssh", "--", "free", "-b"]);
|
|
281
|
+
return {
|
|
282
|
+
engine: "colima",
|
|
283
|
+
...capacity,
|
|
284
|
+
...(memory.status === 0
|
|
285
|
+
? { runtimeMemory: parseColimaMemory(memory.stdout) ?? undefined }
|
|
286
|
+
: {}),
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
214
290
|
export const topologyCommandEnvironment = (topology) =>
|
|
215
291
|
Object.fromEntries(
|
|
216
292
|
topology.targets.map((target) => [
|
|
@@ -344,14 +420,15 @@ export const createRuntimeCommands = ({
|
|
|
344
420
|
const inferred = normalizeRehearsalError(
|
|
345
421
|
new Error(completeOutput || `Rehearsal ${action} failed.`),
|
|
346
422
|
);
|
|
347
|
-
const category =
|
|
348
|
-
action
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
423
|
+
const category = classifyManagerFailure({
|
|
424
|
+
action,
|
|
425
|
+
output: completeOutput,
|
|
426
|
+
inferredCategory: inferred.category,
|
|
427
|
+
});
|
|
428
|
+
const startupFailure = category === "runtime_dependency_failure";
|
|
429
|
+
const engineCapacity = startupFailure
|
|
430
|
+
? inspectContainerCapacity()
|
|
431
|
+
: undefined;
|
|
355
432
|
const conciseOutput = completeOutput
|
|
356
433
|
.replaceAll(String.fromCodePoint(27), "")
|
|
357
434
|
.split("\n")
|
|
@@ -366,10 +443,16 @@ export const createRuntimeCommands = ({
|
|
|
366
443
|
message: `The local Rehearsal ${action} operation did not complete.`,
|
|
367
444
|
expected: "the isolated runtime operation to finish and verify",
|
|
368
445
|
actual: conciseOutput || `exit ${result.status}`,
|
|
369
|
-
context: { action },
|
|
446
|
+
context: { action, ...(engineCapacity ? { engineCapacity } : {}) },
|
|
370
447
|
refused: "The runtime was not reported as trusted.",
|
|
371
448
|
suggestions: [
|
|
372
449
|
"Review the concise failure above, then rerun rehearsal doctor before retrying.",
|
|
450
|
+
...(startupFailure
|
|
451
|
+
? [
|
|
452
|
+
"Stop unrelated local stacks or increase Docker/Colima capacity, then retry. Rehearsal will not change engine settings or bypass service health checks.",
|
|
453
|
+
"Run docker stats --no-stream to identify active containers using the shared engine.",
|
|
454
|
+
]
|
|
455
|
+
: []),
|
|
373
456
|
"Use --debug only when the safe diagnostic detail is needed.",
|
|
374
457
|
],
|
|
375
458
|
cause: new Error(completeOutput || `exit ${result.status}`),
|
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
pruneBaselineGenerations,
|
|
14
14
|
verifyActiveBaseline,
|
|
15
15
|
} from "../baseline/artifact.mjs";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createPrivacyEngine,
|
|
18
|
+
readPrivacyKey,
|
|
19
|
+
} from "../baseline/privacy_engine.mjs";
|
|
17
20
|
import { loadRehearsalConfig } from "../project/configuration.mjs";
|
|
18
21
|
import {
|
|
19
22
|
createSourceAccessPlan,
|
|
@@ -21,9 +24,11 @@ import {
|
|
|
21
24
|
} from "../source/access.mjs";
|
|
22
25
|
import { streamApprovedSupabaseAssets } from "../source/asset_transfer.mjs";
|
|
23
26
|
import { refreshPostgresqlBaseline } from "../source/baseline.mjs";
|
|
27
|
+
import { resolvePrivacyMappedAssets } from "../source/privacy_paths.mjs";
|
|
24
28
|
import {
|
|
25
29
|
applyPostgresqlSourceAccess,
|
|
26
30
|
planPostgresqlSourceAccessRetirement,
|
|
31
|
+
resolvePostgresqlSourceStatePaths,
|
|
27
32
|
retirePostgresqlSourceAccess,
|
|
28
33
|
} from "../source/postgresql_access.mjs";
|
|
29
34
|
import { loadRuntimeTopology } from "../runtime/topology.mjs";
|
|
@@ -50,6 +55,7 @@ export const runSourceAccess = async ({ flags, planOptions }) => {
|
|
|
50
55
|
plan,
|
|
51
56
|
confirmation: flags.sourceAccessConfirmation,
|
|
52
57
|
projectRoot: loaded.projectRoot,
|
|
58
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
53
59
|
});
|
|
54
60
|
return {
|
|
55
61
|
mode: "written",
|
|
@@ -63,6 +69,8 @@ export const runSourceRetirement = async ({ flags, planOptions }) => {
|
|
|
63
69
|
const { loaded, sourcePolicy } = await loadPreparationContext(planOptions);
|
|
64
70
|
const plan = await planPostgresqlSourceAccessRetirement({
|
|
65
71
|
projectRoot: loaded.projectRoot,
|
|
72
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
73
|
+
credentialFile: sourcePolicy.reader.credentialFile,
|
|
66
74
|
targetFingerprint: sourcePolicy.targetFingerprint,
|
|
67
75
|
});
|
|
68
76
|
if (!flags.sourceRetirementConfirmation) return { mode: "preview", plan };
|
|
@@ -72,6 +80,7 @@ export const runSourceRetirement = async ({ flags, planOptions }) => {
|
|
|
72
80
|
administratorEnvironmentVariable:
|
|
73
81
|
sourcePolicy.administratorEnvironmentVariable,
|
|
74
82
|
projectRoot: loaded.projectRoot,
|
|
83
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
75
84
|
credentialFile: sourcePolicy.reader.credentialFile,
|
|
76
85
|
});
|
|
77
86
|
return { mode: "written", plan };
|
|
@@ -98,6 +107,11 @@ const readSourceReaderCredential = async (path) => {
|
|
|
98
107
|
|
|
99
108
|
export const runSourceBaselineRefresh = async ({ planOptions }) => {
|
|
100
109
|
const { loaded, sourcePolicy } = await loadPreparationContext(planOptions);
|
|
110
|
+
const { credentialPath } = resolvePostgresqlSourceStatePaths({
|
|
111
|
+
projectRoot: loaded.projectRoot,
|
|
112
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
113
|
+
credentialFile: sourcePolicy.reader.credentialFile,
|
|
114
|
+
});
|
|
101
115
|
const [
|
|
102
116
|
sourceAccessReceipt,
|
|
103
117
|
privacyPolicyBytes,
|
|
@@ -110,10 +124,17 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
|
|
|
110
124
|
).then(JSON.parse),
|
|
111
125
|
readFile(loaded.paths.sanitizationPolicy),
|
|
112
126
|
readPrivacyKey(loaded.paths.privacyKey),
|
|
113
|
-
readSourceReaderCredential(
|
|
114
|
-
join(loaded.projectRoot, sourcePolicy.reader.credentialFile),
|
|
115
|
-
),
|
|
127
|
+
readSourceReaderCredential(credentialPath),
|
|
116
128
|
]);
|
|
129
|
+
const privacyPolicy = JSON.parse(privacyPolicyBytes.toString("utf8"));
|
|
130
|
+
const assetPrivacyEngine = createPrivacyEngine({
|
|
131
|
+
policy: privacyPolicy,
|
|
132
|
+
key: privacyKey,
|
|
133
|
+
});
|
|
134
|
+
const assetDeclarations = resolvePrivacyMappedAssets({
|
|
135
|
+
sourcePolicy,
|
|
136
|
+
privacyPolicy,
|
|
137
|
+
});
|
|
117
138
|
return refreshPostgresqlBaseline({
|
|
118
139
|
connectionString,
|
|
119
140
|
sourcePolicy,
|
|
@@ -133,10 +154,12 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
|
|
|
133
154
|
baseUrl:
|
|
134
155
|
process.env[sourcePolicy.assetReader.baseUrlEnvironmentVariable],
|
|
135
156
|
token: process.env[sourcePolicy.assetReader.tokenEnvironmentVariable],
|
|
136
|
-
declarations:
|
|
157
|
+
declarations: assetDeclarations,
|
|
137
158
|
maximumObjects: sourcePolicy.assetReader.maximumObjects,
|
|
138
159
|
maximumObjectBytes: sourcePolicy.assetReader.maximumObjectBytes,
|
|
139
160
|
maximumTotalBytes: sourcePolicy.assetReader.maximumTotalBytes,
|
|
161
|
+
pathMapper: ({ mapping, value }) =>
|
|
162
|
+
assetPrivacyEngine.remapPath({ mapping, value }),
|
|
140
163
|
})
|
|
141
164
|
: [],
|
|
142
165
|
});
|
|
@@ -244,9 +267,15 @@ export const buildRefreshWorkflowPlan = async (planOptions) => {
|
|
|
244
267
|
};
|
|
245
268
|
|
|
246
269
|
export const createRefreshWorkflow =
|
|
247
|
-
({
|
|
270
|
+
({
|
|
271
|
+
runRuntimeStack,
|
|
272
|
+
prepareDependentTargets,
|
|
273
|
+
topologyCommandEnvironment,
|
|
274
|
+
buildPlan = buildRefreshWorkflowPlan,
|
|
275
|
+
refreshBaseline = runSourceBaselineRefresh,
|
|
276
|
+
}) =>
|
|
248
277
|
async ({ flags, planOptions }) => {
|
|
249
|
-
const context = await
|
|
278
|
+
const context = await buildPlan(planOptions);
|
|
250
279
|
if (!flags.refreshConfirmation) {
|
|
251
280
|
return { mode: "preview", plan: context.plan };
|
|
252
281
|
}
|
|
@@ -259,7 +288,7 @@ export const createRefreshWorkflow =
|
|
|
259
288
|
let runtime;
|
|
260
289
|
let preparations = [];
|
|
261
290
|
try {
|
|
262
|
-
refreshed = await
|
|
291
|
+
refreshed = await refreshBaseline({ planOptions });
|
|
263
292
|
const stack = await runRuntimeStack({
|
|
264
293
|
command: "reset",
|
|
265
294
|
flags,
|
package/src/identity/claim.mjs
CHANGED
|
@@ -740,18 +740,18 @@ export const applyIdentityClaim = async ({
|
|
|
740
740
|
"select 1 from auth.users where id = $1::uuid limit 1",
|
|
741
741
|
[plan.identity.placeholderUserId],
|
|
742
742
|
);
|
|
743
|
-
const placeholderReferenceCounts =
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
743
|
+
const placeholderReferenceCounts = [];
|
|
744
|
+
if (localUserId !== plan.identity.placeholderUserId) {
|
|
745
|
+
for (const reference of plan.identity.references) {
|
|
746
|
+
placeholderReferenceCounts.push(
|
|
747
|
+
await countIdentityRows({
|
|
748
|
+
client,
|
|
749
|
+
reference,
|
|
750
|
+
userId: plan.identity.placeholderUserId,
|
|
751
|
+
}),
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
755
|
const transferReferences = plan.identity.references.filter(
|
|
756
756
|
(reference) => reference.strategy === "transfer",
|
|
757
757
|
);
|
|
@@ -762,15 +762,17 @@ export const applyIdentityClaim = async ({
|
|
|
762
762
|
const index = plan.identity.references.indexOf(reference);
|
|
763
763
|
return placeholderReferenceCounts[index] ?? 0;
|
|
764
764
|
});
|
|
765
|
-
const transferLocalCounts =
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
765
|
+
const transferLocalCounts = [];
|
|
766
|
+
if (
|
|
767
|
+
localUserId !== plan.identity.placeholderUserId &&
|
|
768
|
+
preservedReferences.length > 0
|
|
769
|
+
) {
|
|
770
|
+
for (const reference of transferReferences) {
|
|
771
|
+
transferLocalCounts.push(
|
|
772
|
+
await countIdentityRows({ client, reference, userId: localUserId }),
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
774
776
|
const alreadyClaimed =
|
|
775
777
|
localUserId !== plan.identity.placeholderUserId &&
|
|
776
778
|
transferPlaceholderCounts.every((count) => count === 0) &&
|
|
@@ -138,6 +138,31 @@ const uniqueProjects = (projects) => [
|
|
|
138
138
|
|
|
139
139
|
const skip = (reason) => ({ status: "skipped", reason });
|
|
140
140
|
|
|
141
|
+
export const renderInstalledRehearsalScaffold = (result) => {
|
|
142
|
+
if (result.status !== "ready") {
|
|
143
|
+
return `Rehearsal did not create project files: ${result.reason}.\nNext: run npx rehearsal from the application directory.`;
|
|
144
|
+
}
|
|
145
|
+
const changed = result.files.filter((file) => file.action !== "unchanged");
|
|
146
|
+
return [
|
|
147
|
+
"Rehearsal is ready in this project.",
|
|
148
|
+
`Configuration: ${result.configurationPath} (${result.configurationAction === "create" ? "created" : "existing"})`,
|
|
149
|
+
...(result.rootConfigGenerationSkipped
|
|
150
|
+
? [
|
|
151
|
+
` Existing supported configuration detected at ${result.configurationPath}.`,
|
|
152
|
+
" rehearsal.config.mjs was not created because Rehearsal preserves one active configuration and never creates a competing file.",
|
|
153
|
+
]
|
|
154
|
+
: []),
|
|
155
|
+
...(changed.length
|
|
156
|
+
? [
|
|
157
|
+
...changed.map(
|
|
158
|
+
(file) => ` ${file.action === "create" ? "+" : "~"} ${file.path}`,
|
|
159
|
+
),
|
|
160
|
+
]
|
|
161
|
+
: [" Existing Rehearsal files preserved."]),
|
|
162
|
+
`Next: review ${result.configurationPath}, then run npx rehearsal.`,
|
|
163
|
+
].join("\n");
|
|
164
|
+
};
|
|
165
|
+
|
|
141
166
|
export const resolveRehearsalConsumerRoot = async ({
|
|
142
167
|
packageRoot,
|
|
143
168
|
initCwd,
|
|
@@ -281,11 +306,20 @@ export const createInstalledRehearsalScaffold = async ({
|
|
|
281
306
|
isPortAvailable,
|
|
282
307
|
});
|
|
283
308
|
const result = await applyRehearsalSetup(plan);
|
|
284
|
-
await loadRehearsalConfig({
|
|
309
|
+
const loaded = await loadRehearsalConfig({
|
|
310
|
+
projectRoot: resolution.projectRoot,
|
|
311
|
+
});
|
|
312
|
+
const configurationPath = relative(resolution.projectRoot, loaded.configPath);
|
|
313
|
+
const configurationAction =
|
|
314
|
+
result.files.find(({ path }) => path === configurationPath)?.action ??
|
|
315
|
+
"unchanged";
|
|
285
316
|
return {
|
|
286
317
|
status: "ready",
|
|
287
318
|
projectRoot: resolution.projectRoot,
|
|
288
319
|
target: plan.target,
|
|
289
320
|
files: result.files,
|
|
321
|
+
configurationPath,
|
|
322
|
+
configurationAction,
|
|
323
|
+
rootConfigGenerationSkipped: configurationPath !== "rehearsal.config.mjs",
|
|
290
324
|
};
|
|
291
325
|
};
|
package/src/project/setup.mjs
CHANGED
|
@@ -23,6 +23,11 @@ import {
|
|
|
23
23
|
renderDetectedPostgresqlConfig,
|
|
24
24
|
} from "./configuration.mjs";
|
|
25
25
|
import { resolveRuntimeTarget } from "../targets/target.mjs";
|
|
26
|
+
export {
|
|
27
|
+
assertSupportedRehearsalNodeRuntime,
|
|
28
|
+
inspectRehearsalNodeRuntime,
|
|
29
|
+
} from "../shared/node_runtime.mjs";
|
|
30
|
+
import { assertSupportedRehearsalNodeRuntime } from "../shared/node_runtime.mjs";
|
|
26
31
|
|
|
27
32
|
const CONFIG_PATH = "rehearsal.config.mjs";
|
|
28
33
|
const LOCAL_SUPABASE_CONFIG_PATH =
|
|
@@ -82,29 +87,6 @@ export const isRehearsalPortAvailable = async (port) => {
|
|
|
82
87
|
return canBindToPort(port);
|
|
83
88
|
};
|
|
84
89
|
|
|
85
|
-
export const inspectRehearsalNodeRuntime = (
|
|
86
|
-
version = process.versions.node,
|
|
87
|
-
) => {
|
|
88
|
-
const major = Number.parseInt(String(version).split(".")[0], 10);
|
|
89
|
-
return {
|
|
90
|
-
version: String(version),
|
|
91
|
-
major,
|
|
92
|
-
supported: major === 24,
|
|
93
|
-
};
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
export const assertSupportedRehearsalNodeRuntime = (
|
|
97
|
-
version = process.versions.node,
|
|
98
|
-
) => {
|
|
99
|
-
const runtime = inspectRehearsalNodeRuntime(version);
|
|
100
|
-
if (!runtime.supported) {
|
|
101
|
-
throw new Error(
|
|
102
|
-
`Rehearsal setup requires Node.js 24; this shell is using Node.js ${runtime.version}. Switch to Node.js 24, then rerun setup. With nvm: nvm install 24 && nvm use 24.`,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
return runtime;
|
|
106
|
-
};
|
|
107
|
-
|
|
108
90
|
export const findAvailableRehearsalPorts = async ({
|
|
109
91
|
isAvailable = isRehearsalPortAvailable,
|
|
110
92
|
} = {}) => {
|
|
@@ -392,6 +374,9 @@ export const planRehearsalSetup = async ({
|
|
|
392
374
|
];
|
|
393
375
|
return {
|
|
394
376
|
projectRoot: root,
|
|
377
|
+
configurationPath: existingConfigPath
|
|
378
|
+
? relative(root, existingConfigPath)
|
|
379
|
+
: CONFIG_PATH,
|
|
395
380
|
project: detected.projectName,
|
|
396
381
|
projectId,
|
|
397
382
|
target: runtimeTarget,
|
|
@@ -462,6 +447,11 @@ export const applyRehearsalSetup = async (plan) => {
|
|
|
462
447
|
|
|
463
448
|
export const summarizeRehearsalSetup = (plan, { mode }) => ({
|
|
464
449
|
mode,
|
|
450
|
+
configurationPath: plan.configurationPath,
|
|
451
|
+
configurationAction:
|
|
452
|
+
plan.files.find((file) => file.path === plan.configurationPath)?.action ??
|
|
453
|
+
"unchanged",
|
|
454
|
+
rootConfigGenerationSkipped: plan.configurationPath !== CONFIG_PATH,
|
|
465
455
|
project: plan.project,
|
|
466
456
|
projectId: plan.projectId,
|
|
467
457
|
target: plan.target,
|
|
@@ -479,6 +469,6 @@ export const summarizeRehearsalSetup = (plan, { mode }) => ({
|
|
|
479
469
|
safety: plan.safety,
|
|
480
470
|
nextAction:
|
|
481
471
|
mode === "written"
|
|
482
|
-
?
|
|
472
|
+
? `Review ${plan.configurationPath}, including its commands and sanitization policy path, then run rehearsal doctor.`
|
|
483
473
|
: "Review this plan, then rerun rehearsal setup --write to create it.",
|
|
484
474
|
});
|
package/src/runtime/plan.mjs
CHANGED
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
import { createCandidateMigrationReceipt } from "./restore.mjs";
|
|
23
23
|
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
24
24
|
import { REHEARSAL_VERSION } from "../shared/diagnostics.mjs";
|
|
25
|
+
import {
|
|
26
|
+
inspectRehearsalNodeRuntime,
|
|
27
|
+
REHEARSAL_NODE_LABEL,
|
|
28
|
+
} from "../shared/node_runtime.mjs";
|
|
25
29
|
import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
|
|
26
30
|
import {
|
|
27
31
|
readBoundRuntimeSanitizationPolicy,
|
|
@@ -357,17 +361,17 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
357
361
|
};
|
|
358
362
|
}
|
|
359
363
|
const { config, paths, projectRoot } = loaded;
|
|
360
|
-
const
|
|
364
|
+
const nodeRuntime = inspectRehearsalNodeRuntime();
|
|
361
365
|
const checkDefinitions = [
|
|
362
366
|
{
|
|
363
367
|
id: "node",
|
|
364
368
|
label: "Node.js compatibility",
|
|
365
369
|
run: async () => {
|
|
366
|
-
if (
|
|
370
|
+
if (!nodeRuntime.supported)
|
|
367
371
|
throw new Error(`Node ${process.versions.node} is unsupported.`);
|
|
368
372
|
return `Node ${process.versions.node}`;
|
|
369
373
|
},
|
|
370
|
-
remediation:
|
|
374
|
+
remediation: `Use ${REHEARSAL_NODE_LABEL}. Node.js 24 LTS is recommended.`,
|
|
371
375
|
},
|
|
372
376
|
{
|
|
373
377
|
id: "operating-system",
|
package/src/runtime/restore.mjs
CHANGED
|
@@ -194,11 +194,19 @@ const buildIdentitySequenceSql = (table) =>
|
|
|
194
194
|
const schema = tableSchema(table);
|
|
195
195
|
const relation = qualifiedRelation(table);
|
|
196
196
|
const columnName = quoteIdentifier(column.name);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
const sequenceValueQuery = `select max(${columnName})::bigint, exists(select 1 from ${relation}) from ${relation}`;
|
|
198
|
+
return `do $rehearsal_sequence$
|
|
199
|
+
declare
|
|
200
|
+
sequence_name text := pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)});
|
|
201
|
+
maximum_value bigint;
|
|
202
|
+
has_rows boolean;
|
|
203
|
+
begin
|
|
204
|
+
if sequence_name is not null then
|
|
205
|
+
execute ${quoteLiteral(sequenceValueQuery)} into maximum_value, has_rows;
|
|
206
|
+
perform setval(sequence_name, coalesce(maximum_value, 1), has_rows);
|
|
207
|
+
end if;
|
|
208
|
+
end
|
|
209
|
+
$rehearsal_sequence$;`;
|
|
202
210
|
})
|
|
203
211
|
.join("\n");
|
|
204
212
|
|
|
@@ -94,6 +94,13 @@ export class RehearsalError extends Error {
|
|
|
94
94
|
const inferCategory = (error) => {
|
|
95
95
|
const message = String(error?.message ?? error);
|
|
96
96
|
if (/checksum/iu.test(message)) return "baseline_checksum_mismatch";
|
|
97
|
+
if (
|
|
98
|
+
/container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu.test(
|
|
99
|
+
message,
|
|
100
|
+
)
|
|
101
|
+
) {
|
|
102
|
+
return "runtime_dependency_failure";
|
|
103
|
+
}
|
|
97
104
|
if (/candidate|migration history diverges/iu.test(message)) {
|
|
98
105
|
return "migration_candidate_failure";
|
|
99
106
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported Node.js release lines. Keep this aligned with package.json and the
|
|
3
|
+
* verification matrix.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const REHEARSAL_NODE_MAJORS = Object.freeze([22, 24, 26]);
|
|
7
|
+
export const REHEARSAL_NODE_LABEL = "Node.js 22, 24, or 26";
|
|
8
|
+
export const REHEARSAL_RECOMMENDED_NODE_MAJOR = 24;
|
|
9
|
+
|
|
10
|
+
export const inspectRehearsalNodeRuntime = (
|
|
11
|
+
version = process.versions.node,
|
|
12
|
+
) => {
|
|
13
|
+
const major = Number.parseInt(String(version).split(".")[0], 10);
|
|
14
|
+
return {
|
|
15
|
+
version: String(version),
|
|
16
|
+
major,
|
|
17
|
+
supported: REHEARSAL_NODE_MAJORS.includes(major),
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const assertSupportedRehearsalNodeRuntime = (
|
|
22
|
+
version = process.versions.node,
|
|
23
|
+
) => {
|
|
24
|
+
const runtime = inspectRehearsalNodeRuntime(version);
|
|
25
|
+
if (!runtime.supported) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Rehearsal requires ${REHEARSAL_NODE_LABEL}; this shell is using Node.js ${runtime.version}. Switch to a supported release, then rerun setup. With nvm: nvm install --lts && nvm use --lts.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return runtime;
|
|
31
|
+
};
|