@zq-silk/yui 0.5.3 → 0.6.1
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/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/agentConfigurationPicker.js +1 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +35 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +610 -201
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +24 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +85 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +60 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +413 -61
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/ephemeralResourceReaper.js +2 -1
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/resourceInventoryRpc.js +85 -0
- package/dist/controller/resourceInventoryWorker.js +50 -0
- package/dist/controller/runtime.js +238 -22
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +549 -42
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/boundedRpc.js +475 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/setup/setupCommand.js +27 -4
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +769 -1
- package/dist/storage/persistenceWorker.js +194 -0
- package/dist/storage/sqliteSchema.js +705 -0
- package/dist/storage/sqliteStore.js +1695 -0
- package/dist/storage/storageVersions.js +9 -2
- package/dist/storage/storeRpc.js +298 -0
- package/dist/storage/taskStore.js +982 -21
- package/dist/storage/upgrade/homeClassification.js +157 -12
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +351 -0
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +713 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +510 -18
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +343 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +2 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQLite-backed {@link MigrationTarget} for the layout 6 -> 7 staged
|
|
3
|
+
* migration (task-21 §8, work-item-4).
|
|
4
|
+
*
|
|
5
|
+
* This target is the seam between the generic, domain-free migration engine
|
|
6
|
+
* and the SQLite writer. It stages the migrated state into a sidecar
|
|
7
|
+
* `yui.db.staged` INSIDE the Home (not a sibling copy), verifies the staged
|
|
8
|
+
* database against an independent re-read of `state.json`, and commits by
|
|
9
|
+
* swapping `yui.db.staged` -> `yui.db` and advancing `schema.json` to layout 7
|
|
10
|
+
* in the same coordination critical section.
|
|
11
|
+
*
|
|
12
|
+
* Staged orchestration (§8.2):
|
|
13
|
+
* - Snapshot: `readSource` reads `schema.json` + `state.json` read-only.
|
|
14
|
+
* - Stage: `writeFreshOutput` populates `yui.db.staged` from the (possibly
|
|
15
|
+
* transformed) snapshot; refuses to overwrite an existing stage.
|
|
16
|
+
* - Verify: `validateCurrentState` independently re-reads `state.json`,
|
|
17
|
+
* re-derives the expected state through the registered transforms,
|
|
18
|
+
* and compares per-family checksums against the staged database.
|
|
19
|
+
* - Commit: `atomicSwitchWithBackup` swaps the sidecar into `yui.db` (with
|
|
20
|
+
* a timestamped backup of any prior database) and advances
|
|
21
|
+
* `schema.json` to layout 7.
|
|
22
|
+
* - Rollback: `rollbackSqliteMigration` quarantines `yui.db` and flips
|
|
23
|
+
* `schema.json` back to layout 6. A layout-6→7 migration retains
|
|
24
|
+
* `state.json` in place (never touched); a pseudo-layout-7 repair
|
|
25
|
+
* archived it to `state.json.backup-*`, so rollback restores the
|
|
26
|
+
* newest backup when `state.json` is absent.
|
|
27
|
+
*
|
|
28
|
+
* The source `state.json` is retained read-only throughout: it is never
|
|
29
|
+
* overwritten, truncated, or deleted by the migration. This preserves the
|
|
30
|
+
* rollback path and the §8.4 invariants (no healthy Session reset, no evidence
|
|
31
|
+
* deleted).
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
import { writeTextFileAtomically } from "../durableFile.js";
|
|
37
|
+
import { STORAGE_SCHEMA_FILE } from "../storageSchema.js";
|
|
38
|
+
import { STORAGE_STATE_FILE } from "../taskStore.js";
|
|
39
|
+
import { planMigration } from "../migration/planner.js";
|
|
40
|
+
import { AmbiguousSwitchError } from "../migration/index.js";
|
|
41
|
+
import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
|
|
42
|
+
import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState } from "./sqliteStateMigration.js";
|
|
43
|
+
import { latestStateBackupPath } from "./pseudoLayoutRepair.js";
|
|
44
|
+
import { migrationReceiptPath, writeMigrationReceipt } from "./migrationReceipt.js";
|
|
45
|
+
/** Build the SQLite-backed migration target. */
|
|
46
|
+
export function createSqliteMigrationTarget(options) {
|
|
47
|
+
const home = options.home;
|
|
48
|
+
const latest = options.latest;
|
|
49
|
+
const registry = options.registry;
|
|
50
|
+
const now = options.now ?? (() => new Date());
|
|
51
|
+
const callerPid = options.callerPid ?? process.pid;
|
|
52
|
+
const stagedDbPath = join(home, STAGED_DATABASE_FILENAME);
|
|
53
|
+
const committedDbPath = join(home, COMMITTED_DATABASE_FILENAME);
|
|
54
|
+
// The transformed schema manifest is cached during writeFreshOutput so the
|
|
55
|
+
// switch can advance schema.json without re-reading or re-deriving it.
|
|
56
|
+
let stagedSchemaManifest = null;
|
|
57
|
+
// The source document's revision and sha256, cached during writeFreshOutput
|
|
58
|
+
// so the switch can certify the dual-copy state with a persistent receipt
|
|
59
|
+
// (Issue 01: a layout-7 Home that retains state.json must carry the receipt
|
|
60
|
+
// that proves yui.db was promoted from that exact document).
|
|
61
|
+
let stagedReceiptSource = null;
|
|
62
|
+
return {
|
|
63
|
+
stagedDbPath,
|
|
64
|
+
inspectVersions() {
|
|
65
|
+
const inspected = inspectSourceVersionState(home, latest);
|
|
66
|
+
if ("corruption" in inspected) {
|
|
67
|
+
throw new Error(inspected.corruption.detail);
|
|
68
|
+
}
|
|
69
|
+
return inspected.source;
|
|
70
|
+
},
|
|
71
|
+
detectLiveRuntime() {
|
|
72
|
+
const signals = inspectHomeRuntime(home, callerPid);
|
|
73
|
+
if (!homeRuntimeIsActive(signals))
|
|
74
|
+
return { active: false };
|
|
75
|
+
return { active: true, detail: describeActiveRuntime(signals) };
|
|
76
|
+
},
|
|
77
|
+
readSource() {
|
|
78
|
+
const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
|
|
79
|
+
const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
|
|
80
|
+
const statePath = join(home, STORAGE_STATE_FILE);
|
|
81
|
+
const state = existsSync(statePath)
|
|
82
|
+
? parseJsonObject(readFileSync(statePath, "utf8"), STORAGE_STATE_FILE)
|
|
83
|
+
: null;
|
|
84
|
+
return Object.freeze({ schemaManifest, state });
|
|
85
|
+
},
|
|
86
|
+
writeFreshOutput(snapshot) {
|
|
87
|
+
if (existsSync(stagedDbPath)) {
|
|
88
|
+
throw new Error(`Refusing to overwrite an existing staged SQLite database: ${stagedDbPath}. ` +
|
|
89
|
+
"Discard it and retry.");
|
|
90
|
+
}
|
|
91
|
+
// Cache the transformed manifest for the switch's schema.json advancement.
|
|
92
|
+
// Ensure the layout version is the latest: the orchestrator applies the
|
|
93
|
+
// 6->7 transform before calling us, but direct callers (tests, drills)
|
|
94
|
+
// may pass an untransformed snapshot. Setting it here is idempotent.
|
|
95
|
+
stagedSchemaManifest = {
|
|
96
|
+
...snapshot.schemaManifest,
|
|
97
|
+
storageVersion: latest.layout,
|
|
98
|
+
updatedAt: now().toISOString()
|
|
99
|
+
};
|
|
100
|
+
if (snapshot.state !== null) {
|
|
101
|
+
// Hash the actual on-disk bytes (the file is retained read-only
|
|
102
|
+
// throughout the migration), not a re-serialization of the parsed
|
|
103
|
+
// snapshot, so the receipt can be compared against the file itself.
|
|
104
|
+
const stateRaw = readFileSync(join(home, STORAGE_STATE_FILE), "utf8");
|
|
105
|
+
stagedReceiptSource = {
|
|
106
|
+
revision: typeof snapshot.state.revision === "number" ? snapshot.state.revision : 0,
|
|
107
|
+
sha256: createHash("sha256").update(stateRaw, "utf8").digest("hex")
|
|
108
|
+
};
|
|
109
|
+
populateSqliteFromState(home, snapshot.state, STAGED_DATABASE_FILENAME);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// An empty Home (no state.json) still gets a schema-ready database.
|
|
113
|
+
stagedReceiptSource = { revision: 0, sha256: "" };
|
|
114
|
+
populateSqliteFromState(home, {}, STAGED_DATABASE_FILENAME);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
rebuildDerivedState(effects) {
|
|
118
|
+
// The SQLite database is fully normalised by populateSqliteFromState;
|
|
119
|
+
// there is no separate derived index to rebuild. Echo the declared
|
|
120
|
+
// effects for the report, mirroring the file target.
|
|
121
|
+
return { rebuiltEffects: [...effects] };
|
|
122
|
+
},
|
|
123
|
+
validateCurrentState() {
|
|
124
|
+
// Independently re-read state.json from disk (not the in-memory snapshot
|
|
125
|
+
// used for staging) and re-derive the expected state by applying the
|
|
126
|
+
// registered transforms. This catches staging corruption, a torn read,
|
|
127
|
+
// or a concurrent writer that slipped past the quiesce gate.
|
|
128
|
+
const freshSnapshot = readSourceFresh();
|
|
129
|
+
const expectedState = deriveExpectedState(freshSnapshot);
|
|
130
|
+
if (expectedState !== null) {
|
|
131
|
+
verifyChecksums(expectedState);
|
|
132
|
+
}
|
|
133
|
+
const dbChecksums = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
|
|
134
|
+
const familyCount = Object.keys(dbChecksums).length;
|
|
135
|
+
return {
|
|
136
|
+
checks: [
|
|
137
|
+
{
|
|
138
|
+
name: "SQLite staged-database checksum verification",
|
|
139
|
+
outcome: "passed",
|
|
140
|
+
detail: `verified ${familyCount} record families against an independent state.json re-read`
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
atomicSwitchWithBackup() {
|
|
146
|
+
if (!existsSync(stagedDbPath)) {
|
|
147
|
+
throw new Error(`No staged SQLite database to promote: ${stagedDbPath}.`);
|
|
148
|
+
}
|
|
149
|
+
const stamp = now().toISOString().replace(/[:.]/g, "-");
|
|
150
|
+
let backupPath;
|
|
151
|
+
// Phase 1: back up any existing yui.db, then promote the sidecar.
|
|
152
|
+
try {
|
|
153
|
+
if (existsSync(committedDbPath)) {
|
|
154
|
+
backupPath = join(home, `${COMMITTED_DATABASE_FILENAME}.backup-${stamp}`);
|
|
155
|
+
if (existsSync(backupPath)) {
|
|
156
|
+
throw new Error(`Refusing to overwrite an existing database backup: ${backupPath}.`);
|
|
157
|
+
}
|
|
158
|
+
renameSync(committedDbPath, backupPath);
|
|
159
|
+
}
|
|
160
|
+
renameSync(stagedDbPath, committedDbPath);
|
|
161
|
+
// The staged connection may leave empty WAL/SHM sidecars behind
|
|
162
|
+
// even after a clean close; they are dead once promoted.
|
|
163
|
+
rmSync(`${stagedDbPath}-wal`, { force: true });
|
|
164
|
+
rmSync(`${stagedDbPath}-shm`, { force: true });
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
// Pre-promotion failure: restore the original database if we moved it.
|
|
168
|
+
if (backupPath !== undefined && existsSync(backupPath)) {
|
|
169
|
+
try {
|
|
170
|
+
renameSync(backupPath, committedDbPath);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new AmbiguousSwitchError({
|
|
174
|
+
homePath: home,
|
|
175
|
+
backupPath,
|
|
176
|
+
stagingPath: stagedDbPath,
|
|
177
|
+
detail: `SQLite switch failed (${messageOf(error)}) and the automatic rollback also failed. ` +
|
|
178
|
+
`The original database is at ${backupPath}; recover manually by renaming it to ${committedDbPath}.`
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
// Phase 2: advance schema.json to layout 7 in the same critical section.
|
|
185
|
+
try {
|
|
186
|
+
if (stagedSchemaManifest !== null) {
|
|
187
|
+
writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
|
|
188
|
+
}
|
|
189
|
+
// Certify the dual-copy state (Issue 01): the 6→7 migration retains
|
|
190
|
+
// state.json read-only, so the Home legitimately holds both copies.
|
|
191
|
+
// The persistent receipt is the evidence that lets the classifier and
|
|
192
|
+
// doctor distinguish this certified switch from a drifted conflict.
|
|
193
|
+
if (stagedReceiptSource !== null) {
|
|
194
|
+
const familyCount = Object.keys(computeDbFamilyChecksums(home, COMMITTED_DATABASE_FILENAME)).length;
|
|
195
|
+
writeMigrationReceipt(home, {
|
|
196
|
+
kind: "layout6-to-7",
|
|
197
|
+
completedAt: now().toISOString(),
|
|
198
|
+
sourceRevision: stagedReceiptSource.revision,
|
|
199
|
+
targetLayoutVersion: latest.layout,
|
|
200
|
+
sourceStateSha256: stagedReceiptSource.sha256,
|
|
201
|
+
verifiedFamilies: familyCount
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
// The database is promoted but schema.json could not be advanced.
|
|
207
|
+
// Attempt to restore the original database; if that fails, the Home
|
|
208
|
+
// is ambiguous and must be recovered manually.
|
|
209
|
+
try {
|
|
210
|
+
if (backupPath !== undefined && existsSync(backupPath)) {
|
|
211
|
+
renameSync(backupPath, committedDbPath);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
rmSync(committedDbPath, { force: true });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new AmbiguousSwitchError({
|
|
219
|
+
homePath: home,
|
|
220
|
+
backupPath: backupPath ?? committedDbPath,
|
|
221
|
+
stagingPath: stagedDbPath,
|
|
222
|
+
detail: `SQLite database was promoted but schema.json could not be advanced (${messageOf(error)}), ` +
|
|
223
|
+
`and the automatic rollback also failed. The database is at ${committedDbPath}; ` +
|
|
224
|
+
`recover by advancing schema.json storageVersion to ${latest.layout} or restoring the backup.`
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
status: "switched",
|
|
231
|
+
...(backupPath === undefined ? {} : { backupPath }),
|
|
232
|
+
detail: `SQLite database promoted to ${committedDbPath} and schema.json advanced to layout ${latest.layout}.`
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
discardFreshOutput() {
|
|
236
|
+
rmSync(stagedDbPath, { force: true });
|
|
237
|
+
// Clean up WAL/SHM sidecars if the connection left them.
|
|
238
|
+
rmSync(`${stagedDbPath}-wal`, { force: true });
|
|
239
|
+
rmSync(`${stagedDbPath}-shm`, { force: true });
|
|
240
|
+
stagedSchemaManifest = null;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
// -- helpers ---------------------------------------------------------------
|
|
244
|
+
function readSourceFresh() {
|
|
245
|
+
const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
|
|
246
|
+
const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
|
|
247
|
+
const statePath = join(home, STORAGE_STATE_FILE);
|
|
248
|
+
const state = existsSync(statePath)
|
|
249
|
+
? parseJsonObject(readFileSync(statePath, "utf8"), STORAGE_STATE_FILE)
|
|
250
|
+
: null;
|
|
251
|
+
return Object.freeze({ schemaManifest, state });
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Re-derive the expected post-migration state by reading the fresh snapshot,
|
|
255
|
+
* planning from its versions to `latest`, and applying every registered
|
|
256
|
+
* step transform. This mirrors the engine's apply phase but starts from an
|
|
257
|
+
* independent disk read.
|
|
258
|
+
*/
|
|
259
|
+
function deriveExpectedState(snapshot) {
|
|
260
|
+
const inspected = inspectSnapshotVersionState(snapshot, latest);
|
|
261
|
+
if ("corruption" in inspected) {
|
|
262
|
+
throw new Error(inspected.corruption.detail);
|
|
263
|
+
}
|
|
264
|
+
const plan = planMigration(registry, inspected.source, latest);
|
|
265
|
+
if (plan.kind === "blocked") {
|
|
266
|
+
throw new Error(`SQLite migration verification cannot derive expected state: ${plan.blocker.message}`);
|
|
267
|
+
}
|
|
268
|
+
if (plan.kind === "no-op")
|
|
269
|
+
return snapshot.state;
|
|
270
|
+
let current = snapshot;
|
|
271
|
+
for (const planned of plan.steps) {
|
|
272
|
+
planned.step.preconditions(current);
|
|
273
|
+
current = planned.step.transform(current);
|
|
274
|
+
}
|
|
275
|
+
return current.state;
|
|
276
|
+
}
|
|
277
|
+
function verifyChecksums(expectedState) {
|
|
278
|
+
const expected = computeStateFamilyChecksums(expectedState);
|
|
279
|
+
const actual = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
|
|
280
|
+
const families = new Set([...Object.keys(expected), ...Object.keys(actual)]);
|
|
281
|
+
const mismatches = [];
|
|
282
|
+
for (const family of families) {
|
|
283
|
+
const e = expected[family];
|
|
284
|
+
const a = actual[family];
|
|
285
|
+
if (e === undefined || a === undefined || e.count !== a.count || e.hash !== a.hash) {
|
|
286
|
+
mismatches.push(`${family} (expected ${e === undefined ? "absent" : `${e.count}/${e.hash.slice(0, 12)}`}, ` +
|
|
287
|
+
`found ${a === undefined ? "absent" : `${a.count}/${a.hash.slice(0, 12)}`})`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (mismatches.length > 0) {
|
|
291
|
+
throw new Error(`SQLite migration checksum mismatch for ${mismatches.length} family/families: ${mismatches.join("; ")}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Roll back a committed layout-7 SQLite migration: quarantine `yui.db` and
|
|
297
|
+
* flip `schema.json` back to layout 6. A layout-6→7 migration retained
|
|
298
|
+
* `state.json` read-only in place, so it is untouched there. A pseudo-layout-7
|
|
299
|
+
* repair archived it to `state.json.backup-*`; when `state.json` is absent the
|
|
300
|
+
* newest backup is restored so the layout-6 File store recovers every
|
|
301
|
+
* pre-switch committed revision. The persistent migration receipt is removed.
|
|
302
|
+
*
|
|
303
|
+
* Returns the quarantine path. Throws if the Home is not at layout 7, has no
|
|
304
|
+
* `yui.db` to quarantine, or has neither `state.json` nor a backup to restore.
|
|
305
|
+
*/
|
|
306
|
+
export function rollbackSqliteMigration(home, options = {}) {
|
|
307
|
+
const now = options.now ?? (() => new Date());
|
|
308
|
+
const schemaPath = join(home, STORAGE_SCHEMA_FILE);
|
|
309
|
+
const manifest = parseJsonObject(readFileSync(schemaPath, "utf8"), STORAGE_SCHEMA_FILE);
|
|
310
|
+
if (manifest.storageVersion !== 7) {
|
|
311
|
+
throw new Error(`Rollback requires a layout-7 Home; found layout ${manifest.storageVersion}.`);
|
|
312
|
+
}
|
|
313
|
+
const dbPath = join(home, COMMITTED_DATABASE_FILENAME);
|
|
314
|
+
if (!existsSync(dbPath)) {
|
|
315
|
+
throw new Error(`No SQLite database to quarantine: ${dbPath}.`);
|
|
316
|
+
}
|
|
317
|
+
const stamp = now().toISOString().replace(/[:.]/g, "-");
|
|
318
|
+
const quarantinePath = join(home, `${COMMITTED_DATABASE_FILENAME}.quarantine-${stamp}`);
|
|
319
|
+
if (existsSync(quarantinePath)) {
|
|
320
|
+
throw new Error(`Refusing to overwrite an existing quarantine: ${quarantinePath}.`);
|
|
321
|
+
}
|
|
322
|
+
renameSync(dbPath, quarantinePath);
|
|
323
|
+
// Clean up WAL/SHM sidecars.
|
|
324
|
+
rmSync(`${dbPath}-wal`, { force: true });
|
|
325
|
+
rmSync(`${dbPath}-shm`, { force: true });
|
|
326
|
+
// Restore the file-store authoritative document when the repair archived it.
|
|
327
|
+
const statePath = join(home, STORAGE_STATE_FILE);
|
|
328
|
+
if (!existsSync(statePath)) {
|
|
329
|
+
const backupPath = latestStateBackupPath(home);
|
|
330
|
+
if (backupPath === null) {
|
|
331
|
+
throw new Error(`Rollback requires ${STORAGE_STATE_FILE} or a state.json.backup-* archive; neither exists.`);
|
|
332
|
+
}
|
|
333
|
+
renameSync(backupPath, statePath);
|
|
334
|
+
}
|
|
335
|
+
// The persistent receipt certified the switch being rolled back.
|
|
336
|
+
rmSync(migrationReceiptPath(home), { force: true });
|
|
337
|
+
// Flip schema.json back to layout 6.
|
|
338
|
+
const rolledBack = { ...manifest, storageVersion: 6 };
|
|
339
|
+
writeTextFileAtomically(schemaPath, `${JSON.stringify(rolledBack, null, 2)}\n`);
|
|
340
|
+
return quarantinePath;
|
|
341
|
+
}
|
|
342
|
+
function parseJsonObject(raw, label) {
|
|
343
|
+
const value = JSON.parse(raw);
|
|
344
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
345
|
+
throw new Error(`${label} must be a JSON object.`);
|
|
346
|
+
}
|
|
347
|
+
return value;
|
|
348
|
+
}
|
|
349
|
+
function messageOf(error) {
|
|
350
|
+
return error instanceof Error ? error.message : String(error);
|
|
351
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQLite-backed {@link MigrationTarget} for record-family migrations on a
|
|
3
|
+
* layout-7 Home (Issue 01 Phase 2).
|
|
4
|
+
*
|
|
5
|
+
* The layout 6 -> 7 target ({@link createSqliteMigrationTarget}) stages a
|
|
6
|
+
* fresh database from `state.json`. Once a Home is at layout 7 its
|
|
7
|
+
* authoritative store is `yui.db`; `state.json` may have been archived by the
|
|
8
|
+
* pseudo-layout-7 repair, so a record-only migration on such a Home cannot
|
|
9
|
+
* read its source from the document. This target closes that gap:
|
|
10
|
+
*
|
|
11
|
+
* - Snapshot: `readSource` reads `schema.json` and reconstructs the
|
|
12
|
+
* state.json-shaped snapshot from `yui.db` via
|
|
13
|
+
* {@link readStateFromSqlite} (raw payloads, so older record
|
|
14
|
+
* versions survive with their original `schemaVersion`).
|
|
15
|
+
* - Stage: `writeFreshOutput` populates a sidecar `yui.db.staged` from
|
|
16
|
+
* the (possibly transformed) snapshot; refuses to overwrite an
|
|
17
|
+
* existing stage.
|
|
18
|
+
* - Verify: `validateCurrentState` independently re-reads `yui.db`,
|
|
19
|
+
* re-derives the expected state through the registered
|
|
20
|
+
* transforms, and compares per-family checksums against the
|
|
21
|
+
* staged database.
|
|
22
|
+
* - Commit: `atomicSwitchWithBackup` swaps the sidecar into `yui.db` (with
|
|
23
|
+
* a timestamped backup of the prior database) and advances
|
|
24
|
+
* `schema.json`'s record-family versions in the same
|
|
25
|
+
* coordination critical section. The layout version is
|
|
26
|
+
* unchanged (this target never crosses a layout boundary).
|
|
27
|
+
*
|
|
28
|
+
* The source `yui.db` is retained read-only throughout: it is never
|
|
29
|
+
* overwritten, truncated, or deleted by the migration. This preserves the
|
|
30
|
+
* rollback path (restore the timestamped backup).
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { writeTextFileAtomically } from "../durableFile.js";
|
|
35
|
+
import { STORAGE_SCHEMA_FILE } from "../storageSchema.js";
|
|
36
|
+
import { planMigration } from "../migration/planner.js";
|
|
37
|
+
import { AmbiguousSwitchError } from "../migration/index.js";
|
|
38
|
+
import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
|
|
39
|
+
import { writeSwitchProgress } from "./switchProgress.js";
|
|
40
|
+
import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState, readStateFromSqlite } from "./sqliteStateMigration.js";
|
|
41
|
+
/** Build the SQLite-backed record-migration target. */
|
|
42
|
+
export function createSqliteRecordMigrationTarget(options) {
|
|
43
|
+
const home = options.home;
|
|
44
|
+
const latest = options.latest;
|
|
45
|
+
const registry = options.registry;
|
|
46
|
+
const now = options.now ?? (() => new Date());
|
|
47
|
+
const callerPid = options.callerPid ?? process.pid;
|
|
48
|
+
const promoteRename = options.renameImpl ?? renameSync;
|
|
49
|
+
const stagedDbPath = join(home, STAGED_DATABASE_FILENAME);
|
|
50
|
+
const committedDbPath = join(home, COMMITTED_DATABASE_FILENAME);
|
|
51
|
+
// The transformed schema manifest is cached during writeFreshOutput so the
|
|
52
|
+
// switch can advance schema.json without re-reading or re-deriving it.
|
|
53
|
+
let stagedSchemaManifest = null;
|
|
54
|
+
return {
|
|
55
|
+
stagedDbPath,
|
|
56
|
+
inspectVersions() {
|
|
57
|
+
const inspected = inspectSourceVersionState(home, latest);
|
|
58
|
+
if ("corruption" in inspected) {
|
|
59
|
+
throw new Error(inspected.corruption.detail);
|
|
60
|
+
}
|
|
61
|
+
return inspected.source;
|
|
62
|
+
},
|
|
63
|
+
detectLiveRuntime() {
|
|
64
|
+
const signals = inspectHomeRuntime(home, callerPid);
|
|
65
|
+
if (!homeRuntimeIsActive(signals))
|
|
66
|
+
return { active: false };
|
|
67
|
+
return { active: true, detail: describeActiveRuntime(signals) };
|
|
68
|
+
},
|
|
69
|
+
readSource() {
|
|
70
|
+
const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
|
|
71
|
+
const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
|
|
72
|
+
const state = readStateFromSqlite(home);
|
|
73
|
+
return Object.freeze({ schemaManifest, state });
|
|
74
|
+
},
|
|
75
|
+
writeFreshOutput(snapshot) {
|
|
76
|
+
if (existsSync(stagedDbPath)) {
|
|
77
|
+
throw new Error(`Refusing to overwrite an existing staged SQLite database: ${stagedDbPath}. ` +
|
|
78
|
+
"Discard it and retry.");
|
|
79
|
+
}
|
|
80
|
+
// Cache the transformed manifest for the switch's schema.json advancement.
|
|
81
|
+
// The orchestrator applies the record transforms before calling us, but
|
|
82
|
+
// direct callers (tests, drills) may pass an untransformed snapshot. The
|
|
83
|
+
// record-family versions are re-derived from the latest map so the
|
|
84
|
+
// staged manifest always declares the post-migration versions.
|
|
85
|
+
const recordVersions = {};
|
|
86
|
+
for (const [kind, entry] of Object.entries(latest.record)) {
|
|
87
|
+
recordVersions[kind] = entry.version;
|
|
88
|
+
}
|
|
89
|
+
stagedSchemaManifest = {
|
|
90
|
+
...snapshot.schemaManifest,
|
|
91
|
+
recordVersions,
|
|
92
|
+
updatedAt: now().toISOString()
|
|
93
|
+
};
|
|
94
|
+
populateSqliteFromState(home, snapshot.state ?? {}, STAGED_DATABASE_FILENAME);
|
|
95
|
+
},
|
|
96
|
+
rebuildDerivedState(effects) {
|
|
97
|
+
// The SQLite database is fully normalised by populateSqliteFromState;
|
|
98
|
+
// there is no separate derived index to rebuild. Echo the declared
|
|
99
|
+
// effects for the report, mirroring the layout 6 -> 7 target.
|
|
100
|
+
return { rebuiltEffects: [...effects] };
|
|
101
|
+
},
|
|
102
|
+
validateCurrentState() {
|
|
103
|
+
// Independently re-read yui.db from disk (not the in-memory snapshot
|
|
104
|
+
// used for staging) and re-derive the expected state by applying the
|
|
105
|
+
// registered transforms. This catches staging corruption, a torn read,
|
|
106
|
+
// or a concurrent writer that slipped past the quiesce gate.
|
|
107
|
+
const freshSnapshot = readSourceFresh();
|
|
108
|
+
const expectedState = deriveExpectedState(freshSnapshot);
|
|
109
|
+
if (expectedState !== null) {
|
|
110
|
+
verifyChecksums(expectedState);
|
|
111
|
+
}
|
|
112
|
+
const dbChecksums = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
|
|
113
|
+
const familyCount = Object.keys(dbChecksums).length;
|
|
114
|
+
return {
|
|
115
|
+
checks: [
|
|
116
|
+
{
|
|
117
|
+
name: "SQLite staged-database checksum verification",
|
|
118
|
+
outcome: "passed",
|
|
119
|
+
detail: `verified ${familyCount} record families against an independent yui.db re-read`
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
atomicSwitchWithBackup() {
|
|
125
|
+
if (!existsSync(stagedDbPath)) {
|
|
126
|
+
throw new Error(`No staged SQLite database to promote: ${stagedDbPath}.`);
|
|
127
|
+
}
|
|
128
|
+
const stamp = now().toISOString().replace(/[:.]/g, "-");
|
|
129
|
+
let backupPath;
|
|
130
|
+
// Phase 1: back up the existing yui.db, then promote the sidecar.
|
|
131
|
+
try {
|
|
132
|
+
if (existsSync(committedDbPath)) {
|
|
133
|
+
backupPath = join(home, `${COMMITTED_DATABASE_FILENAME}.backup-${stamp}`);
|
|
134
|
+
if (existsSync(backupPath)) {
|
|
135
|
+
throw new Error(`Refusing to overwrite an existing database backup: ${backupPath}.`);
|
|
136
|
+
}
|
|
137
|
+
renameSync(committedDbPath, backupPath);
|
|
138
|
+
}
|
|
139
|
+
promoteRename(stagedDbPath, committedDbPath);
|
|
140
|
+
// The staged connection may leave empty WAL/SHM sidecars behind
|
|
141
|
+
// even after a clean close; they are dead once promoted.
|
|
142
|
+
rmSync(`${stagedDbPath}-wal`, { force: true });
|
|
143
|
+
rmSync(`${stagedDbPath}-shm`, { force: true });
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
// Pre-promotion failure: restore the original database if we moved it.
|
|
147
|
+
if (backupPath !== undefined && existsSync(backupPath)) {
|
|
148
|
+
try {
|
|
149
|
+
promoteRename(backupPath, committedDbPath);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
writeInterruptedMarker(home, backupPath, stagedDbPath, now);
|
|
153
|
+
throw new AmbiguousSwitchError({
|
|
154
|
+
homePath: home,
|
|
155
|
+
backupPath,
|
|
156
|
+
stagingPath: stagedDbPath,
|
|
157
|
+
detail: `SQLite switch failed (${messageOf(error)}) and the automatic rollback also failed. ` +
|
|
158
|
+
`The original database is at ${backupPath}; recover manually by renaming it to ${committedDbPath}.`
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
// Phase 2: advance schema.json record-family versions in the same
|
|
165
|
+
// critical section. The layout version is unchanged.
|
|
166
|
+
try {
|
|
167
|
+
if (stagedSchemaManifest !== null) {
|
|
168
|
+
writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// The database is promoted but schema.json could not be advanced.
|
|
173
|
+
// Attempt to restore the original database; if that fails, the Home
|
|
174
|
+
// is ambiguous and must be recovered manually.
|
|
175
|
+
try {
|
|
176
|
+
if (backupPath !== undefined && existsSync(backupPath)) {
|
|
177
|
+
promoteRename(backupPath, committedDbPath);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
rmSync(committedDbPath, { force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
writeInterruptedMarker(home, backupPath ?? committedDbPath, stagedDbPath, now);
|
|
185
|
+
throw new AmbiguousSwitchError({
|
|
186
|
+
homePath: home,
|
|
187
|
+
backupPath: backupPath ?? committedDbPath,
|
|
188
|
+
stagingPath: stagedDbPath,
|
|
189
|
+
detail: `SQLite database was promoted but schema.json could not be advanced (${messageOf(error)}), ` +
|
|
190
|
+
`and the automatic rollback also failed. The database is at ${committedDbPath}; ` +
|
|
191
|
+
`recover by advancing schema.json recordVersions or restoring the backup.`
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
status: "switched",
|
|
198
|
+
...(backupPath === undefined ? {} : { backupPath }),
|
|
199
|
+
detail: `SQLite database promoted to ${committedDbPath} and schema.json record versions advanced.`
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
discardFreshOutput() {
|
|
203
|
+
rmSync(stagedDbPath, { force: true });
|
|
204
|
+
// Clean up WAL/SHM sidecars if the connection left them.
|
|
205
|
+
rmSync(`${stagedDbPath}-wal`, { force: true });
|
|
206
|
+
rmSync(`${stagedDbPath}-shm`, { force: true });
|
|
207
|
+
stagedSchemaManifest = null;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
// -- helpers ---------------------------------------------------------------
|
|
211
|
+
function readSourceFresh() {
|
|
212
|
+
const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
|
|
213
|
+
const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
|
|
214
|
+
const state = readStateFromSqlite(home);
|
|
215
|
+
return Object.freeze({ schemaManifest, state });
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Re-derive the expected post-migration state by reading the fresh snapshot,
|
|
219
|
+
* planning from its versions to `latest`, and applying every registered
|
|
220
|
+
* step transform. This mirrors the engine's apply phase but starts from an
|
|
221
|
+
* independent disk read.
|
|
222
|
+
*/
|
|
223
|
+
function deriveExpectedState(snapshot) {
|
|
224
|
+
const inspected = inspectSnapshotVersionState(snapshot, latest);
|
|
225
|
+
if ("corruption" in inspected) {
|
|
226
|
+
throw new Error(inspected.corruption.detail);
|
|
227
|
+
}
|
|
228
|
+
const plan = planMigration(registry, inspected.source, latest);
|
|
229
|
+
if (plan.kind === "blocked") {
|
|
230
|
+
throw new Error(`SQLite record migration verification cannot derive expected state: ${plan.blocker.message}`);
|
|
231
|
+
}
|
|
232
|
+
if (plan.kind === "no-op")
|
|
233
|
+
return snapshot.state;
|
|
234
|
+
let current = snapshot;
|
|
235
|
+
for (const planned of plan.steps) {
|
|
236
|
+
planned.step.preconditions(current);
|
|
237
|
+
current = planned.step.transform(current);
|
|
238
|
+
}
|
|
239
|
+
return current.state;
|
|
240
|
+
}
|
|
241
|
+
function verifyChecksums(expectedState) {
|
|
242
|
+
const expected = computeStateFamilyChecksums(expectedState);
|
|
243
|
+
const actual = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
|
|
244
|
+
const families = new Set([...Object.keys(expected), ...Object.keys(actual)]);
|
|
245
|
+
const mismatches = [];
|
|
246
|
+
for (const family of families) {
|
|
247
|
+
const e = expected[family];
|
|
248
|
+
const a = actual[family];
|
|
249
|
+
if (e === undefined || a === undefined || e.count !== a.count || e.hash !== a.hash) {
|
|
250
|
+
mismatches.push(`${family} (expected ${e === undefined ? "absent" : `${e.count}/${e.hash.slice(0, 12)}`}, ` +
|
|
251
|
+
`found ${a === undefined ? "absent" : `${a.count}/${a.hash.slice(0, 12)}`})`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (mismatches.length > 0) {
|
|
255
|
+
throw new Error(`SQLite record migration checksum mismatch for ${mismatches.length} family/families: ${mismatches.join("; ")}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function parseJsonObject(raw, label) {
|
|
260
|
+
const value = JSON.parse(raw);
|
|
261
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
262
|
+
throw new Error(`${label} must be a JSON object.`);
|
|
263
|
+
}
|
|
264
|
+
return value;
|
|
265
|
+
}
|
|
266
|
+
function messageOf(error) {
|
|
267
|
+
return error instanceof Error ? error.message : String(error);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Persist the durable `interrupted` switch-progress marker for an ambiguous
|
|
271
|
+
* SQLite switch (P1-4): the original database was moved to its timestamped
|
|
272
|
+
* backup and neither the promotion nor its rollback completed. The marker is
|
|
273
|
+
* the honest durable signal — a completion receipt is never written for a
|
|
274
|
+
* switch that did not commit. Best-effort: the backup and on-disk state still
|
|
275
|
+
* recover the Home even if the marker write fails.
|
|
276
|
+
*/
|
|
277
|
+
function writeInterruptedMarker(home, backupPath, stagingPath, now) {
|
|
278
|
+
try {
|
|
279
|
+
writeSwitchProgress(home, {
|
|
280
|
+
phase: "interrupted",
|
|
281
|
+
homePath: home,
|
|
282
|
+
backupPath,
|
|
283
|
+
stagingPath,
|
|
284
|
+
updatedAt: now().toISOString()
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// Marker best-effort; the backup + on-disk state still recover the Home.
|
|
289
|
+
}
|
|
290
|
+
}
|