@zq-silk/yui 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
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; `state.json` is never touched.
24
+ *
25
+ * The source `state.json` is retained read-only throughout: it is never
26
+ * overwritten, truncated, or deleted by the migration. This preserves the
27
+ * rollback path and the §8.4 invariants (no healthy Session reset, no evidence
28
+ * deleted).
29
+ */
30
+ import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { writeTextFileAtomically } from "../durableFile.js";
33
+ import { STORAGE_SCHEMA_FILE } from "../storageSchema.js";
34
+ import { STORAGE_STATE_FILE } from "../taskStore.js";
35
+ import { planMigration } from "../migration/planner.js";
36
+ import { AmbiguousSwitchError } from "../migration/index.js";
37
+ import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
38
+ import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState } from "./sqliteStateMigration.js";
39
+ /** Build the SQLite-backed migration target. */
40
+ export function createSqliteMigrationTarget(options) {
41
+ const home = options.home;
42
+ const latest = options.latest;
43
+ const registry = options.registry;
44
+ const now = options.now ?? (() => new Date());
45
+ const callerPid = options.callerPid ?? process.pid;
46
+ const stagedDbPath = join(home, STAGED_DATABASE_FILENAME);
47
+ const committedDbPath = join(home, COMMITTED_DATABASE_FILENAME);
48
+ // The transformed schema manifest is cached during writeFreshOutput so the
49
+ // switch can advance schema.json without re-reading or re-deriving it.
50
+ let stagedSchemaManifest = null;
51
+ return {
52
+ stagedDbPath,
53
+ inspectVersions() {
54
+ const inspected = inspectSourceVersionState(home, latest);
55
+ if ("corruption" in inspected) {
56
+ throw new Error(inspected.corruption.detail);
57
+ }
58
+ return inspected.source;
59
+ },
60
+ detectLiveRuntime() {
61
+ const signals = inspectHomeRuntime(home, callerPid);
62
+ if (!homeRuntimeIsActive(signals))
63
+ return { active: false };
64
+ return { active: true, detail: describeActiveRuntime(signals) };
65
+ },
66
+ readSource() {
67
+ const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
68
+ const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
69
+ const statePath = join(home, STORAGE_STATE_FILE);
70
+ const state = existsSync(statePath)
71
+ ? parseJsonObject(readFileSync(statePath, "utf8"), STORAGE_STATE_FILE)
72
+ : null;
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
+ // Ensure the layout version is the latest: the orchestrator applies the
82
+ // 6->7 transform before calling us, but direct callers (tests, drills)
83
+ // may pass an untransformed snapshot. Setting it here is idempotent.
84
+ stagedSchemaManifest = {
85
+ ...snapshot.schemaManifest,
86
+ storageVersion: latest.layout,
87
+ updatedAt: now().toISOString()
88
+ };
89
+ if (snapshot.state !== null) {
90
+ populateSqliteFromState(home, snapshot.state, STAGED_DATABASE_FILENAME);
91
+ }
92
+ else {
93
+ // An empty Home (no state.json) still gets a schema-ready database.
94
+ populateSqliteFromState(home, {}, STAGED_DATABASE_FILENAME);
95
+ }
96
+ },
97
+ rebuildDerivedState(effects) {
98
+ // The SQLite database is fully normalised by populateSqliteFromState;
99
+ // there is no separate derived index to rebuild. Echo the declared
100
+ // effects for the report, mirroring the file target.
101
+ return { rebuiltEffects: [...effects] };
102
+ },
103
+ validateCurrentState() {
104
+ // Independently re-read state.json from disk (not the in-memory snapshot
105
+ // used for staging) and re-derive the expected state by applying the
106
+ // registered transforms. This catches staging corruption, a torn read,
107
+ // or a concurrent writer that slipped past the quiesce gate.
108
+ const freshSnapshot = readSourceFresh();
109
+ const expectedState = deriveExpectedState(freshSnapshot);
110
+ if (expectedState !== null) {
111
+ verifyChecksums(expectedState);
112
+ }
113
+ const dbChecksums = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
114
+ const familyCount = Object.keys(dbChecksums).length;
115
+ return {
116
+ checks: [
117
+ {
118
+ name: "SQLite staged-database checksum verification",
119
+ outcome: "passed",
120
+ detail: `verified ${familyCount} record families against an independent state.json re-read`
121
+ }
122
+ ]
123
+ };
124
+ },
125
+ atomicSwitchWithBackup() {
126
+ if (!existsSync(stagedDbPath)) {
127
+ throw new Error(`No staged SQLite database to promote: ${stagedDbPath}.`);
128
+ }
129
+ const stamp = now().toISOString().replace(/[:.]/g, "-");
130
+ let backupPath;
131
+ // Phase 1: back up any existing yui.db, then promote the sidecar.
132
+ try {
133
+ if (existsSync(committedDbPath)) {
134
+ backupPath = join(home, `${COMMITTED_DATABASE_FILENAME}.backup-${stamp}`);
135
+ if (existsSync(backupPath)) {
136
+ throw new Error(`Refusing to overwrite an existing database backup: ${backupPath}.`);
137
+ }
138
+ renameSync(committedDbPath, backupPath);
139
+ }
140
+ renameSync(stagedDbPath, committedDbPath);
141
+ }
142
+ catch (error) {
143
+ // Pre-promotion failure: restore the original database if we moved it.
144
+ if (backupPath !== undefined && existsSync(backupPath)) {
145
+ try {
146
+ renameSync(backupPath, committedDbPath);
147
+ }
148
+ catch {
149
+ throw new AmbiguousSwitchError({
150
+ homePath: home,
151
+ backupPath,
152
+ stagingPath: stagedDbPath,
153
+ detail: `SQLite switch failed (${messageOf(error)}) and the automatic rollback also failed. ` +
154
+ `The original database is at ${backupPath}; recover manually by renaming it to ${committedDbPath}.`
155
+ });
156
+ }
157
+ }
158
+ throw error;
159
+ }
160
+ // Phase 2: advance schema.json to layout 7 in the same critical section.
161
+ try {
162
+ if (stagedSchemaManifest !== null) {
163
+ writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
164
+ }
165
+ }
166
+ catch (error) {
167
+ // The database is promoted but schema.json could not be advanced.
168
+ // Attempt to restore the original database; if that fails, the Home
169
+ // is ambiguous and must be recovered manually.
170
+ try {
171
+ if (backupPath !== undefined && existsSync(backupPath)) {
172
+ renameSync(backupPath, committedDbPath);
173
+ }
174
+ else {
175
+ rmSync(committedDbPath, { force: true });
176
+ }
177
+ }
178
+ catch {
179
+ throw new AmbiguousSwitchError({
180
+ homePath: home,
181
+ backupPath: backupPath ?? committedDbPath,
182
+ stagingPath: stagedDbPath,
183
+ detail: `SQLite database was promoted but schema.json could not be advanced (${messageOf(error)}), ` +
184
+ `and the automatic rollback also failed. The database is at ${committedDbPath}; ` +
185
+ `recover by advancing schema.json storageVersion to ${latest.layout} or restoring the backup.`
186
+ });
187
+ }
188
+ throw error;
189
+ }
190
+ return {
191
+ status: "switched",
192
+ ...(backupPath === undefined ? {} : { backupPath }),
193
+ detail: `SQLite database promoted to ${committedDbPath} and schema.json advanced to layout ${latest.layout}.`
194
+ };
195
+ },
196
+ discardFreshOutput() {
197
+ rmSync(stagedDbPath, { force: true });
198
+ // Clean up WAL/SHM sidecars if the connection left them.
199
+ rmSync(`${stagedDbPath}-wal`, { force: true });
200
+ rmSync(`${stagedDbPath}-shm`, { force: true });
201
+ stagedSchemaManifest = null;
202
+ }
203
+ };
204
+ // -- helpers ---------------------------------------------------------------
205
+ function readSourceFresh() {
206
+ const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
207
+ const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
208
+ const statePath = join(home, STORAGE_STATE_FILE);
209
+ const state = existsSync(statePath)
210
+ ? parseJsonObject(readFileSync(statePath, "utf8"), STORAGE_STATE_FILE)
211
+ : null;
212
+ return Object.freeze({ schemaManifest, state });
213
+ }
214
+ /**
215
+ * Re-derive the expected post-migration state by reading the fresh snapshot,
216
+ * planning from its versions to `latest`, and applying every registered
217
+ * step transform. This mirrors the engine's apply phase but starts from an
218
+ * independent disk read.
219
+ */
220
+ function deriveExpectedState(snapshot) {
221
+ const inspected = inspectSnapshotVersionState(snapshot, latest);
222
+ if ("corruption" in inspected) {
223
+ throw new Error(inspected.corruption.detail);
224
+ }
225
+ const plan = planMigration(registry, inspected.source, latest);
226
+ if (plan.kind === "blocked") {
227
+ throw new Error(`SQLite migration verification cannot derive expected state: ${plan.blocker.message}`);
228
+ }
229
+ if (plan.kind === "no-op")
230
+ return snapshot.state;
231
+ let current = snapshot;
232
+ for (const planned of plan.steps) {
233
+ planned.step.preconditions(current);
234
+ current = planned.step.transform(current);
235
+ }
236
+ return current.state;
237
+ }
238
+ function verifyChecksums(expectedState) {
239
+ const expected = computeStateFamilyChecksums(expectedState);
240
+ const actual = computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME);
241
+ const families = new Set([...Object.keys(expected), ...Object.keys(actual)]);
242
+ const mismatches = [];
243
+ for (const family of families) {
244
+ const e = expected[family];
245
+ const a = actual[family];
246
+ if (e === undefined || a === undefined || e.count !== a.count || e.hash !== a.hash) {
247
+ mismatches.push(`${family} (expected ${e === undefined ? "absent" : `${e.count}/${e.hash.slice(0, 12)}`}, ` +
248
+ `found ${a === undefined ? "absent" : `${a.count}/${a.hash.slice(0, 12)}`})`);
249
+ }
250
+ }
251
+ if (mismatches.length > 0) {
252
+ throw new Error(`SQLite migration checksum mismatch for ${mismatches.length} family/families: ${mismatches.join("; ")}`);
253
+ }
254
+ }
255
+ }
256
+ /**
257
+ * Roll back a committed layout-7 SQLite migration: quarantine `yui.db` and
258
+ * flip `schema.json` back to layout 6. The `state.json` document is never
259
+ * touched — it was retained read-only during the migration and remains the
260
+ * authoritative source after rollback.
261
+ *
262
+ * Returns the quarantine path. Throws if the Home is not at layout 7 or has
263
+ * no `yui.db` to quarantine.
264
+ */
265
+ export function rollbackSqliteMigration(home, options = {}) {
266
+ const now = options.now ?? (() => new Date());
267
+ const schemaPath = join(home, STORAGE_SCHEMA_FILE);
268
+ const manifest = parseJsonObject(readFileSync(schemaPath, "utf8"), STORAGE_SCHEMA_FILE);
269
+ if (manifest.storageVersion !== 7) {
270
+ throw new Error(`Rollback requires a layout-7 Home; found layout ${manifest.storageVersion}.`);
271
+ }
272
+ const dbPath = join(home, COMMITTED_DATABASE_FILENAME);
273
+ if (!existsSync(dbPath)) {
274
+ throw new Error(`No SQLite database to quarantine: ${dbPath}.`);
275
+ }
276
+ const stamp = now().toISOString().replace(/[:.]/g, "-");
277
+ const quarantinePath = join(home, `${COMMITTED_DATABASE_FILENAME}.quarantine-${stamp}`);
278
+ if (existsSync(quarantinePath)) {
279
+ throw new Error(`Refusing to overwrite an existing quarantine: ${quarantinePath}.`);
280
+ }
281
+ renameSync(dbPath, quarantinePath);
282
+ // Clean up WAL/SHM sidecars.
283
+ rmSync(`${dbPath}-wal`, { force: true });
284
+ rmSync(`${dbPath}-shm`, { force: true });
285
+ // Flip schema.json back to layout 6.
286
+ const rolledBack = { ...manifest, storageVersion: 6 };
287
+ writeTextFileAtomically(schemaPath, `${JSON.stringify(rolledBack, null, 2)}\n`);
288
+ return quarantinePath;
289
+ }
290
+ function parseJsonObject(raw, label) {
291
+ const value = JSON.parse(raw);
292
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
293
+ throw new Error(`${label} must be a JSON object.`);
294
+ }
295
+ return value;
296
+ }
297
+ function messageOf(error) {
298
+ return error instanceof Error ? error.message : String(error);
299
+ }