ds4-context-core 0.3.0-alpha.5 → 0.3.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +4 -1
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +4 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/manifest/context-manifest-storage.d.ts +63 -0
  7. package/dist/manifest/context-manifest-storage.d.ts.map +1 -0
  8. package/dist/manifest/context-manifest-storage.js +137 -0
  9. package/dist/manifest/context-manifest-storage.js.map +1 -0
  10. package/dist/manifest/context-manifest.d.ts +3 -0
  11. package/dist/manifest/context-manifest.d.ts.map +1 -1
  12. package/dist/persistence/database-client-lease.d.ts +46 -0
  13. package/dist/persistence/database-client-lease.d.ts.map +1 -0
  14. package/dist/persistence/database-client-lease.js +274 -0
  15. package/dist/persistence/database-client-lease.js.map +1 -0
  16. package/dist/persistence/repositories/context-manifest-repository.d.ts +24 -4
  17. package/dist/persistence/repositories/context-manifest-repository.d.ts.map +1 -1
  18. package/dist/persistence/repositories/context-manifest-repository.js +243 -44
  19. package/dist/persistence/repositories/context-manifest-repository.js.map +1 -1
  20. package/dist/persistence/sqlite.d.ts +14 -0
  21. package/dist/persistence/sqlite.d.ts.map +1 -1
  22. package/dist/persistence/sqlite.js +91 -22
  23. package/dist/persistence/sqlite.js.map +1 -1
  24. package/dist/persistence/storage-diagnostics.d.ts +51 -0
  25. package/dist/persistence/storage-diagnostics.d.ts.map +1 -0
  26. package/dist/persistence/storage-diagnostics.js +172 -0
  27. package/dist/persistence/storage-diagnostics.js.map +1 -0
  28. package/dist/persistence/storage-maintenance.d.ts +68 -0
  29. package/dist/persistence/storage-maintenance.d.ts.map +1 -0
  30. package/dist/persistence/storage-maintenance.js +676 -0
  31. package/dist/persistence/storage-maintenance.js.map +1 -0
  32. package/dist/persistence/write-coordinator.d.ts +1 -0
  33. package/dist/persistence/write-coordinator.d.ts.map +1 -1
  34. package/dist/persistence/write-coordinator.js +35 -11
  35. package/dist/persistence/write-coordinator.js.map +1 -1
  36. package/package.json +1 -1
@@ -0,0 +1,676 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, statSync, statfsSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { backup, DatabaseSync } from "node:sqlite";
5
+ import { DatabaseProtocolError, acquireDatabaseMaintenanceLock, databasePathFingerprint, databaseProtocolPaths, } from "./database-client-lease.js";
6
+ import { CURRENT_SCHEMA_VERSION, MIGRATIONS, listAppliedMigrations } from "./migrations.js";
7
+ import { ContextDatabase } from "./sqlite.js";
8
+ import { collectStorageDiagnostics, } from "./storage-diagnostics.js";
9
+ import { SqliteWriteCoordinator } from "./write-coordinator.js";
10
+ import { HARD_PERSISTED_MANIFEST_BYTES } from "../manifest/context-manifest-storage.js";
11
+ import { sha256 } from "../shared/hash.js";
12
+ import { stableStringify } from "../shared/stable-json.js";
13
+ const STATE_PROTOCOL = "ds4-context-storage-maintenance-state-v1";
14
+ const SAFETY_MARGIN_BYTES = 256 * 1024 * 1024;
15
+ const PROTECTED_TABLES = [
16
+ "sessions",
17
+ "entries",
18
+ "entries_fts",
19
+ "summaries",
20
+ "summary_sources",
21
+ "summary_edges",
22
+ "memory_items",
23
+ "memory_sources",
24
+ "memory_fts",
25
+ "pins",
26
+ "memory_mutations",
27
+ "pin_mutations",
28
+ "artifacts",
29
+ "artifact_objects",
30
+ "project_states",
31
+ "project_files",
32
+ "project_snippets",
33
+ "project_snippets_fts",
34
+ "project_memory_sessions",
35
+ "project_memory_source_exclusions",
36
+ "derived_embeddings",
37
+ "context_quality_samples",
38
+ "resource_leases",
39
+ "session_index_state",
40
+ ];
41
+ export class StorageMaintenanceError extends Error {
42
+ category;
43
+ stage;
44
+ constructor(category, stage) {
45
+ super(`Storage maintenance failed (stage=${stage}; category=${category})`);
46
+ this.category = category;
47
+ this.stage = stage;
48
+ this.name = "StorageMaintenanceError";
49
+ }
50
+ }
51
+ export function storageMaintenancePaths(databasePath) {
52
+ const source = resolve(databasePath);
53
+ const protocol = databaseProtocolPaths(source);
54
+ return {
55
+ source,
56
+ backup: `${source}.precompact.bak`,
57
+ work: `${source}.maintenance-work`,
58
+ candidate: `${source}.compact-ready`,
59
+ retired: `${source}.swap-old`,
60
+ state: protocol.maintenanceState,
61
+ maintenanceLock: protocol.maintenanceLock,
62
+ clientsDirectory: protocol.clientsDirectory,
63
+ };
64
+ }
65
+ function maintenanceError(error, category, stage) {
66
+ return error instanceof StorageMaintenanceError
67
+ ? error
68
+ : new StorageMaintenanceError(category, stage);
69
+ }
70
+ function bestEffortChmod(path, mode) {
71
+ try {
72
+ chmodSync(path, mode);
73
+ }
74
+ catch (error) {
75
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
76
+ if (code !== "ENOSYS" && code !== "EPERM" && code !== "EINVAL")
77
+ throw error;
78
+ }
79
+ }
80
+ function fsyncPath(path) {
81
+ const descriptor = openSync(path, "r");
82
+ try {
83
+ fsyncSync(descriptor);
84
+ }
85
+ finally {
86
+ closeSync(descriptor);
87
+ }
88
+ }
89
+ function fsyncDirectory(path) {
90
+ let descriptor;
91
+ try {
92
+ descriptor = openSync(path, "r");
93
+ fsyncSync(descriptor);
94
+ }
95
+ catch (error) {
96
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
97
+ if (code !== "EINVAL" && code !== "ENOTSUP" && code !== "EBADF" && code !== "EPERM")
98
+ throw error;
99
+ }
100
+ finally {
101
+ if (descriptor !== undefined)
102
+ closeSync(descriptor);
103
+ }
104
+ }
105
+ function reservePrivateFile(path) {
106
+ const descriptor = openSync(path, "wx", 0o600);
107
+ closeSync(descriptor);
108
+ bestEffortChmod(path, 0o600);
109
+ fsyncDirectory(dirname(path));
110
+ }
111
+ function removeFile(path) {
112
+ try {
113
+ unlinkSync(path);
114
+ }
115
+ catch (error) {
116
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
117
+ if (code !== "ENOENT")
118
+ throw error;
119
+ }
120
+ }
121
+ function renameIfExists(source, target) {
122
+ if (existsSync(source))
123
+ renameSync(source, target);
124
+ }
125
+ function requireRegularDatabase(databasePath) {
126
+ let canonical;
127
+ try {
128
+ canonical = realpathSync(resolve(databasePath));
129
+ }
130
+ catch {
131
+ throw new StorageMaintenanceError("database-not-found", "path-preflight");
132
+ }
133
+ let stats;
134
+ try {
135
+ stats = lstatSync(canonical);
136
+ }
137
+ catch {
138
+ throw new StorageMaintenanceError("database-not-readable", "path-preflight");
139
+ }
140
+ if (!stats.isFile())
141
+ throw new StorageMaintenanceError("database-not-regular-file", "path-preflight");
142
+ return canonical;
143
+ }
144
+ function availableFilesystemBytes(path) {
145
+ const stats = statfsSync(dirname(path));
146
+ const value = Number(stats.bavail) * Number(stats.bsize);
147
+ return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER;
148
+ }
149
+ function requiredFilesystemBytes(path) {
150
+ const sourceBytes = statSync(path).size + (existsSync(`${path}-wal`) ? statSync(`${path}-wal`).size : 0);
151
+ const safety = Math.max(SAFETY_MARGIN_BYTES, Math.ceil(sourceBytes * 0.1));
152
+ const required = sourceBytes * 3 + safety;
153
+ return Number.isSafeInteger(required) ? required : Number.MAX_SAFE_INTEGER;
154
+ }
155
+ function migrationChecksum(version, name, sql) {
156
+ return sha256(`${version}\n${name}\n${sql}`);
157
+ }
158
+ function validateMigrations(database) {
159
+ const applied = listAppliedMigrations(database);
160
+ if (applied.length !== MIGRATIONS.length) {
161
+ throw new StorageMaintenanceError("migration-count-mismatch", "database-validation");
162
+ }
163
+ for (const migration of MIGRATIONS) {
164
+ const actual = applied.find((item) => item.version === migration.version);
165
+ if (!actual
166
+ || actual.name !== migration.name
167
+ || actual.checksum !== migrationChecksum(migration.version, migration.name, migration.sql)) {
168
+ throw new StorageMaintenanceError("migration-checksum-mismatch", "database-validation");
169
+ }
170
+ }
171
+ }
172
+ function scalarNumber(database, sql, ...parameters) {
173
+ const row = database.prepare(sql).get(...parameters);
174
+ if (!row || typeof row !== "object")
175
+ throw new StorageMaintenanceError("missing-scalar", "database-validation");
176
+ const value = Object.values(row)[0];
177
+ if (typeof value !== "number" || !Number.isFinite(value)) {
178
+ throw new StorageMaintenanceError("invalid-scalar", "database-validation");
179
+ }
180
+ return value;
181
+ }
182
+ function sqliteIdentifier(value) {
183
+ return `"${value.replaceAll('"', '""')}"`;
184
+ }
185
+ function updateLengthPrefixedHash(hash, tag, value) {
186
+ const length = typeof value === "string" ? Buffer.byteLength(value, "utf8") : value.byteLength;
187
+ hash.update(`${tag}${length}:`, "utf8");
188
+ hash.update(value);
189
+ hash.update(";", "utf8");
190
+ }
191
+ function updateSqliteValueHash(hash, value) {
192
+ if (value === null) {
193
+ hash.update("n;", "utf8");
194
+ }
195
+ else if (typeof value === "string") {
196
+ updateLengthPrefixedHash(hash, "s", value);
197
+ }
198
+ else if (typeof value === "number") {
199
+ if (!Number.isFinite(value)) {
200
+ throw new StorageMaintenanceError("invalid-protected-value", "database-validation");
201
+ }
202
+ updateLengthPrefixedHash(hash, "d", Object.is(value, -0) ? "-0" : String(value));
203
+ }
204
+ else if (typeof value === "bigint") {
205
+ updateLengthPrefixedHash(hash, "i", value.toString());
206
+ }
207
+ else if (value instanceof Uint8Array) {
208
+ updateLengthPrefixedHash(hash, "b", value);
209
+ }
210
+ else {
211
+ throw new StorageMaintenanceError("invalid-protected-value", "database-validation");
212
+ }
213
+ }
214
+ function protectedTableSnapshot(database, table) {
215
+ const columns = database.prepare("SELECT name, pk FROM pragma_table_info(?) ORDER BY cid").all(table);
216
+ if (columns.length === 0 || columns.some((column) => typeof column.name !== "string")) {
217
+ throw new StorageMaintenanceError("protected-table-schema-mismatch", "database-validation");
218
+ }
219
+ const primaryKey = columns
220
+ .filter((column) => column.pk > 0)
221
+ .sort((left, right) => left.pk - right.pk);
222
+ const order = primaryKey.length > 0 ? primaryKey : columns;
223
+ const selected = columns.map((column) => sqliteIdentifier(column.name)).join(", ");
224
+ const ordered = order.map((column) => sqliteIdentifier(column.name)).join(", ");
225
+ const statement = database.prepare(`SELECT ${selected} FROM ${sqliteIdentifier(table)} ORDER BY ${ordered}`);
226
+ const hash = createHash("sha256");
227
+ updateLengthPrefixedHash(hash, "t", table);
228
+ for (const column of columns)
229
+ updateLengthPrefixedHash(hash, "c", column.name);
230
+ let count = 0;
231
+ for (const row of statement.iterate()) {
232
+ hash.update("r;", "utf8");
233
+ for (const column of columns)
234
+ updateSqliteValueHash(hash, row[column.name]);
235
+ count++;
236
+ }
237
+ return { count, digest: hash.digest("hex") };
238
+ }
239
+ function protectedSnapshot(database) {
240
+ const protectedCounts = {};
241
+ const protectedDigests = {};
242
+ for (const table of PROTECTED_TABLES) {
243
+ const snapshot = protectedTableSnapshot(database, table);
244
+ protectedCounts[table] = snapshot.count;
245
+ protectedDigests[table] = snapshot.digest;
246
+ }
247
+ const exclusions = database.prepare(`
248
+ SELECT project_path, session_id
249
+ FROM project_memory_source_exclusions
250
+ ORDER BY project_path, session_id
251
+ `).all();
252
+ return {
253
+ protectedCounts,
254
+ protectedDigests,
255
+ sourceExclusions: exclusions.length,
256
+ sourceExclusionDigest: sha256(stableStringify(exclusions)),
257
+ };
258
+ }
259
+ function validateOpenDatabase(database, path) {
260
+ const quickRow = database.prepare("PRAGMA quick_check").get();
261
+ const quickCheck = quickRow && typeof quickRow === "object" ? String(Object.values(quickRow)[0]) : "";
262
+ if (quickCheck !== "ok")
263
+ throw new StorageMaintenanceError("quick-check-failed", "database-validation");
264
+ if (scalarNumber(database, "PRAGMA foreign_keys") !== 1) {
265
+ throw new StorageMaintenanceError("foreign-keys-disabled", "database-validation");
266
+ }
267
+ const foreignKeyViolations = database.prepare("PRAGMA foreign_key_check").all();
268
+ if (foreignKeyViolations.length > 0) {
269
+ throw new StorageMaintenanceError("foreign-key-check-failed", "database-validation");
270
+ }
271
+ const schemaVersion = scalarNumber(database, "PRAGMA user_version");
272
+ if (schemaVersion !== CURRENT_SCHEMA_VERSION) {
273
+ throw new StorageMaintenanceError("unsupported-schema", "database-validation");
274
+ }
275
+ validateMigrations(database);
276
+ return {
277
+ diagnostics: collectStorageDiagnostics(database, path),
278
+ ...protectedSnapshot(database),
279
+ };
280
+ }
281
+ function validateDatabase(path) {
282
+ let database;
283
+ try {
284
+ database = new DatabaseSync(path, {
285
+ readOnly: true,
286
+ enableForeignKeyConstraints: true,
287
+ enableDoubleQuotedStringLiterals: false,
288
+ allowExtension: false,
289
+ timeout: 5_000,
290
+ });
291
+ database.exec("PRAGMA trusted_schema = OFF");
292
+ return validateOpenDatabase(database, path);
293
+ }
294
+ catch (error) {
295
+ throw maintenanceError(error, "database-validation-failed", "database-validation");
296
+ }
297
+ finally {
298
+ database?.close();
299
+ }
300
+ }
301
+ function assertProtectedState(snapshot, sourceSnapshot, stage) {
302
+ for (const [table, count] of Object.entries(sourceSnapshot.protectedCounts)) {
303
+ if (snapshot.protectedCounts[table] !== count) {
304
+ throw new StorageMaintenanceError("protected-count-mismatch", stage);
305
+ }
306
+ if (snapshot.protectedDigests[table] !== sourceSnapshot.protectedDigests[table]) {
307
+ throw new StorageMaintenanceError("protected-digest-mismatch", stage);
308
+ }
309
+ }
310
+ if (snapshot.sourceExclusions !== sourceSnapshot.sourceExclusions
311
+ || snapshot.sourceExclusionDigest !== sourceSnapshot.sourceExclusionDigest) {
312
+ throw new StorageMaintenanceError("source-exclusion-mismatch", stage);
313
+ }
314
+ }
315
+ function assertCandidate(path, sourceSnapshot) {
316
+ const candidate = validateDatabase(path);
317
+ assertProtectedState(candidate, sourceSnapshot, "candidate-validation");
318
+ if (candidate.diagnostics.manifests.rows > candidate.diagnostics.manifests.retainedLimit) {
319
+ throw new StorageMaintenanceError("manifest-retention-not-converged", "candidate-validation");
320
+ }
321
+ if (candidate.diagnostics.retention.calibrationExcess > 0) {
322
+ throw new StorageMaintenanceError("calibration-retention-not-converged", "candidate-validation");
323
+ }
324
+ const database = new DatabaseSync(path, { readOnly: true, enableForeignKeyConstraints: true });
325
+ try {
326
+ const oversized = scalarNumber(database, "SELECT count(*) FROM context_manifests WHERE length(CAST(manifest_json AS BLOB)) > ?", HARD_PERSISTED_MANIFEST_BYTES);
327
+ if (oversized > 0)
328
+ throw new StorageMaintenanceError("oversized-manifest", "candidate-validation");
329
+ }
330
+ finally {
331
+ database.close();
332
+ }
333
+ return candidate;
334
+ }
335
+ function assertStagePathsAbsent(paths) {
336
+ for (const path of [paths.backup, paths.work, paths.candidate, paths.retired, paths.state]) {
337
+ if (existsSync(path))
338
+ throw new StorageMaintenanceError("stage-path-exists", "path-preflight");
339
+ }
340
+ }
341
+ function writeState(paths, phase) {
342
+ const state = {
343
+ protocol: STATE_PROTOCOL,
344
+ phase,
345
+ databaseFingerprint: databasePathFingerprint(paths.source),
346
+ source: paths.source,
347
+ candidate: paths.candidate,
348
+ backup: paths.backup,
349
+ work: paths.work,
350
+ retired: paths.retired,
351
+ };
352
+ writeFileSync(paths.state, `${JSON.stringify(state)}\n`, {
353
+ encoding: "utf8",
354
+ mode: 0o600,
355
+ flag: existsSync(paths.state) ? "w" : "wx",
356
+ });
357
+ bestEffortChmod(paths.state, 0o600);
358
+ fsyncPath(paths.state);
359
+ fsyncDirectory(dirname(paths.state));
360
+ }
361
+ function parseState(path, expectedSource) {
362
+ let value;
363
+ try {
364
+ value = JSON.parse(readFileSync(path, "utf8"));
365
+ }
366
+ catch {
367
+ throw new StorageMaintenanceError("maintenance-state-invalid", "recover");
368
+ }
369
+ if (!value || typeof value !== "object") {
370
+ throw new StorageMaintenanceError("maintenance-state-invalid", "recover");
371
+ }
372
+ const state = value;
373
+ const expectedPaths = storageMaintenancePaths(expectedSource);
374
+ const validPhase = state.phase === "candidate-validated"
375
+ || state.phase === "source-retired"
376
+ || state.phase === "candidate-installed"
377
+ || state.phase === "completed";
378
+ if (state.protocol !== STATE_PROTOCOL
379
+ || !validPhase
380
+ || state.source !== expectedSource
381
+ || state.databaseFingerprint !== databasePathFingerprint(expectedSource)
382
+ || state.candidate !== expectedPaths.candidate
383
+ || state.backup !== expectedPaths.backup
384
+ || state.work !== expectedPaths.work
385
+ || state.retired !== expectedPaths.retired) {
386
+ throw new StorageMaintenanceError("maintenance-state-invalid", "recover");
387
+ }
388
+ return state;
389
+ }
390
+ function cleanupWork(paths) {
391
+ for (const path of [
392
+ paths.work,
393
+ `${paths.work}-wal`,
394
+ `${paths.work}-shm`,
395
+ ])
396
+ removeFile(path);
397
+ }
398
+ function cleanupRetired(paths) {
399
+ for (const path of [
400
+ paths.retired,
401
+ `${paths.retired}-wal`,
402
+ `${paths.retired}-shm`,
403
+ ])
404
+ removeFile(path);
405
+ }
406
+ function restoreRetired(paths) {
407
+ if (existsSync(paths.source) || !existsSync(paths.retired)) {
408
+ throw new StorageMaintenanceError("rollback-state-ambiguous", "swap-rollback");
409
+ }
410
+ renameSync(paths.retired, paths.source);
411
+ renameIfExists(`${paths.retired}-wal`, `${paths.source}-wal`);
412
+ renameIfExists(`${paths.retired}-shm`, `${paths.source}-shm`);
413
+ fsyncDirectory(dirname(paths.source));
414
+ }
415
+ function restoreBackup(paths) {
416
+ if (existsSync(paths.source) || !existsSync(paths.backup)) {
417
+ throw new StorageMaintenanceError("backup-restore-state-ambiguous", "recover");
418
+ }
419
+ validateDatabase(paths.backup);
420
+ copyFileSync(paths.backup, paths.source, constants.COPYFILE_EXCL);
421
+ bestEffortChmod(paths.source, 0o600);
422
+ fsyncPath(paths.source);
423
+ fsyncDirectory(dirname(paths.source));
424
+ }
425
+ export function inspectStorage(databasePath) {
426
+ const source = requireRegularDatabase(databasePath);
427
+ const paths = storageMaintenancePaths(source);
428
+ const snapshot = validateDatabase(source);
429
+ return {
430
+ databasePath: source,
431
+ diagnostics: snapshot.diagnostics,
432
+ quickCheck: "ok",
433
+ foreignKeyViolations: 0,
434
+ schemaVersion: snapshot.diagnostics.schemaVersion ?? CURRENT_SCHEMA_VERSION,
435
+ manifestsToPrune: snapshot.diagnostics.retention.manifestExcess,
436
+ calibrationToPrune: snapshot.diagnostics.retention.calibrationExcess,
437
+ availableBytes: availableFilesystemBytes(source),
438
+ requiredBytes: requiredFilesystemBytes(source),
439
+ paths,
440
+ };
441
+ }
442
+ export async function compactStorage(databasePath, options = {}) {
443
+ const source = requireRegularDatabase(databasePath);
444
+ const paths = storageMaintenancePaths(source);
445
+ assertStagePathsAbsent(paths);
446
+ let lock;
447
+ let sourceRetired = false;
448
+ let candidateInstalled = false;
449
+ let backupVerified = false;
450
+ try {
451
+ lock = acquireDatabaseMaintenanceLock(source, {
452
+ ...(options.processProbe ? { processProbe: options.processProbe } : {}),
453
+ ...(options.now !== undefined ? { createdAt: options.now } : {}),
454
+ });
455
+ const sourceSnapshot = validateDatabase(source);
456
+ const requiredBytes = requiredFilesystemBytes(source);
457
+ const availableBytes = options.availableBytes ?? availableFilesystemBytes(source);
458
+ if (availableBytes < requiredBytes) {
459
+ throw new StorageMaintenanceError("insufficient-disk-space", "disk-preflight");
460
+ }
461
+ options.onPhase?.("before-backup");
462
+ reservePrivateFile(paths.backup);
463
+ const sourceDatabase = new DatabaseSync(source, {
464
+ readOnly: true,
465
+ enableForeignKeyConstraints: true,
466
+ enableDoubleQuotedStringLiterals: false,
467
+ allowExtension: false,
468
+ timeout: 5_000,
469
+ });
470
+ try {
471
+ await backup(sourceDatabase, paths.backup);
472
+ }
473
+ catch (error) {
474
+ throw maintenanceError(error, "backup-failed", "backup");
475
+ }
476
+ finally {
477
+ sourceDatabase.close();
478
+ }
479
+ bestEffortChmod(paths.backup, 0o600);
480
+ fsyncPath(paths.backup);
481
+ const backupSnapshot = validateDatabase(paths.backup);
482
+ assertProtectedState(backupSnapshot, sourceSnapshot, "backup-validation");
483
+ if (backupSnapshot.diagnostics.manifests.rows !== sourceSnapshot.diagnostics.manifests.rows
484
+ || backupSnapshot.diagnostics.calibration.rows !== sourceSnapshot.diagnostics.calibration.rows) {
485
+ throw new StorageMaintenanceError("backup-count-mismatch", "backup-validation");
486
+ }
487
+ backupVerified = true;
488
+ options.onPhase?.("backup-created");
489
+ options.onPhase?.("before-work-copy");
490
+ copyFileSync(paths.backup, paths.work, constants.COPYFILE_EXCL);
491
+ bestEffortChmod(paths.work, 0o600);
492
+ let workDatabase;
493
+ let maintenance;
494
+ options.onPhase?.("before-work-rewrite");
495
+ try {
496
+ workDatabase = ContextDatabase.open(paths.work, {
497
+ clientLease: false,
498
+ ...(options.now !== undefined ? { now: options.now } : {}),
499
+ });
500
+ maintenance = workDatabase.manifests.applyMaintenanceRetention();
501
+ workDatabase.optimize();
502
+ workDatabase.checkpoint("TRUNCATE");
503
+ }
504
+ catch (error) {
505
+ throw maintenanceError(error, "working-copy-rewrite-failed", "working-copy");
506
+ }
507
+ finally {
508
+ workDatabase?.close();
509
+ }
510
+ options.onPhase?.("work-rewritten");
511
+ options.onPhase?.("before-vacuum");
512
+ const vacuumDatabase = new DatabaseSync(paths.work, {
513
+ enableForeignKeyConstraints: true,
514
+ enableDoubleQuotedStringLiterals: false,
515
+ allowExtension: false,
516
+ timeout: 5_000,
517
+ });
518
+ try {
519
+ const writes = new SqliteWriteCoordinator(vacuumDatabase);
520
+ writes.execute("storage-vacuum-candidate", () => {
521
+ vacuumDatabase.prepare("VACUUM INTO ?").run(paths.candidate);
522
+ });
523
+ }
524
+ catch (error) {
525
+ throw maintenanceError(error, "vacuum-failed", "vacuum");
526
+ }
527
+ finally {
528
+ vacuumDatabase.close();
529
+ }
530
+ bestEffortChmod(paths.candidate, 0o600);
531
+ fsyncPath(paths.candidate);
532
+ options.onPhase?.("before-candidate-validation");
533
+ assertCandidate(paths.candidate, sourceSnapshot);
534
+ writeState(paths, "candidate-validated");
535
+ options.onPhase?.("candidate-validated");
536
+ options.onPhase?.("before-source-rename");
537
+ renameSync(paths.source, paths.retired);
538
+ renameIfExists(`${paths.source}-wal`, `${paths.retired}-wal`);
539
+ renameIfExists(`${paths.source}-shm`, `${paths.retired}-shm`);
540
+ sourceRetired = true;
541
+ writeState(paths, "source-retired");
542
+ options.onPhase?.("source-retired");
543
+ options.onPhase?.("before-candidate-rename");
544
+ renameSync(paths.candidate, paths.source);
545
+ candidateInstalled = true;
546
+ bestEffortChmod(paths.source, 0o600);
547
+ fsyncPath(paths.source);
548
+ fsyncDirectory(dirname(paths.source));
549
+ writeState(paths, "candidate-installed");
550
+ options.onPhase?.("candidate-installed");
551
+ options.onPhase?.("before-final-validation");
552
+ const installed = assertCandidate(paths.source, sourceSnapshot);
553
+ writeState(paths, "completed");
554
+ options.onPhase?.("completed");
555
+ options.onPhase?.("before-cleanup");
556
+ cleanupRetired(paths);
557
+ cleanupWork(paths);
558
+ removeFile(paths.state);
559
+ fsyncDirectory(dirname(paths.source));
560
+ return {
561
+ databasePath: paths.source,
562
+ backupPath: paths.backup,
563
+ beforeBytes: sourceSnapshot.diagnostics.databaseBytes ?? sourceSnapshot.diagnostics.allocatedBytes ?? 0,
564
+ afterBytes: statSync(paths.source).size,
565
+ maintenance,
566
+ diagnostics: installed.diagnostics,
567
+ };
568
+ }
569
+ catch (error) {
570
+ if (sourceRetired) {
571
+ try {
572
+ if (candidateInstalled && existsSync(paths.source) && !existsSync(paths.candidate)) {
573
+ renameSync(paths.source, paths.candidate);
574
+ }
575
+ restoreRetired(paths);
576
+ sourceRetired = false;
577
+ candidateInstalled = false;
578
+ removeFile(paths.candidate);
579
+ cleanupWork(paths);
580
+ removeFile(paths.state);
581
+ }
582
+ catch {
583
+ throw new StorageMaintenanceError("rollback-required", "swap-rollback");
584
+ }
585
+ }
586
+ else {
587
+ removeFile(paths.state);
588
+ removeFile(paths.candidate);
589
+ cleanupWork(paths);
590
+ if (!backupVerified)
591
+ removeFile(paths.backup);
592
+ }
593
+ if (error instanceof DatabaseProtocolError)
594
+ throw error;
595
+ throw maintenanceError(error, "maintenance-failed", "compact");
596
+ }
597
+ finally {
598
+ lock?.release();
599
+ }
600
+ }
601
+ export function recoverStorage(databasePath, options = {}) {
602
+ const source = resolve(databasePath);
603
+ const paths = storageMaintenancePaths(source);
604
+ if (!existsSync(paths.state))
605
+ throw new StorageMaintenanceError("maintenance-state-missing", "recover");
606
+ let lock;
607
+ try {
608
+ lock = acquireDatabaseMaintenanceLock(source, {
609
+ replaceStaleLock: true,
610
+ ...(options.processProbe ? { processProbe: options.processProbe } : {}),
611
+ ...(options.now !== undefined ? { createdAt: options.now } : {}),
612
+ });
613
+ const state = parseState(paths.state, source);
614
+ if (state.phase === "candidate-validated") {
615
+ if (!existsSync(paths.source) || existsSync(paths.retired)) {
616
+ throw new StorageMaintenanceError("recovery-state-ambiguous", "recover");
617
+ }
618
+ removeFile(paths.candidate);
619
+ cleanupWork(paths);
620
+ removeFile(paths.state);
621
+ return { databasePath: source, action: "staging-cleared" };
622
+ }
623
+ if (state.phase === "source-retired") {
624
+ if (existsSync(paths.retired))
625
+ restoreRetired(paths);
626
+ else
627
+ restoreBackup(paths);
628
+ validateDatabase(paths.source);
629
+ removeFile(paths.candidate);
630
+ cleanupWork(paths);
631
+ removeFile(paths.state);
632
+ return { databasePath: source, action: "source-restored" };
633
+ }
634
+ if (!existsSync(paths.source)) {
635
+ if (existsSync(paths.retired))
636
+ restoreRetired(paths);
637
+ else
638
+ restoreBackup(paths);
639
+ validateDatabase(paths.source);
640
+ cleanupWork(paths);
641
+ removeFile(paths.state);
642
+ return { databasePath: source, action: "source-restored" };
643
+ }
644
+ try {
645
+ validateDatabase(paths.source);
646
+ cleanupRetired(paths);
647
+ cleanupWork(paths);
648
+ removeFile(paths.candidate);
649
+ removeFile(paths.state);
650
+ return { databasePath: source, action: "candidate-kept" };
651
+ }
652
+ catch {
653
+ if (existsSync(paths.candidate)) {
654
+ throw new StorageMaintenanceError("recovery-state-ambiguous", "recover");
655
+ }
656
+ renameSync(paths.source, paths.candidate);
657
+ if (existsSync(paths.retired))
658
+ restoreRetired(paths);
659
+ else
660
+ restoreBackup(paths);
661
+ validateDatabase(paths.source);
662
+ cleanupWork(paths);
663
+ removeFile(paths.state);
664
+ return { databasePath: source, action: "source-restored" };
665
+ }
666
+ }
667
+ catch (error) {
668
+ if (error instanceof DatabaseProtocolError)
669
+ throw error;
670
+ throw maintenanceError(error, "recovery-failed", "recover");
671
+ }
672
+ finally {
673
+ lock?.release();
674
+ }
675
+ }
676
+ //# sourceMappingURL=storage-maintenance.js.map