@hanna84/mcp-writing 3.18.1 → 3.20.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,854 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ buildProjectBackup,
5
+ computeProjectBackupBundleChecksum,
6
+ computeProjectBackupSnapshotChecksum,
7
+ PROJECT_BACKUP_SCHEMA_VERSION,
8
+ } from "./project-backup.js";
9
+ import {
10
+ resolveBoundaryRootReal,
11
+ resolveCandidateInsideBoundary,
12
+ } from "../core/filesystem-boundary.js";
13
+
14
+ const MANIFEST_FILE = "manifest.json";
15
+ const SNAPSHOT_FILE = "canonical.snapshot.json";
16
+
17
+ const SNAPSHOT_ARRAY_DOMAINS = [
18
+ ["chapters", ["project_id", "chapter_id"]],
19
+ ["scenes", ["project_id", "scene_id"]],
20
+ ["epigraphs", ["project_id", "epigraph_id"]],
21
+ ["epigraph_characters", ["project_id", "epigraph_id", "character_id"]],
22
+ ["epigraph_tags", ["project_id", "epigraph_id", "tag"]],
23
+ ["scene_characters", ["project_id", "scene_id", "character_id"]],
24
+ ["scene_places", ["project_id", "scene_id", "place_id"]],
25
+ ["scene_tags", ["project_id", "scene_id", "tag"]],
26
+ ["scene_threads", ["project_id", "scene_id", "thread_id"]],
27
+ ["characters", ["character_id"]],
28
+ ["character_traits", ["character_id", "trait"]],
29
+ ["character_relationships", ["from_character", "to_character", "relationship_type", "scene_id", "note"]],
30
+ ["places", ["place_id"]],
31
+ ["threads", ["thread_id"]],
32
+ ["reference_docs", ["doc_id"]],
33
+ ["reference_doc_tags", ["doc_id", "tag"]],
34
+ ["reference_links", ["source_kind", "source_project_id", "source_id", "target_doc_id", "relation"]],
35
+ ];
36
+
37
+ const FILE_REFERENCE_FIELDS = [
38
+ ["chapters", "source_path", "optional", "directory"],
39
+ ["scenes", "file_path", "required", "file"],
40
+ ["epigraphs", "file_path", "required", "file"],
41
+ ["characters", "file_path", "optional", "file"],
42
+ ["places", "file_path", "optional", "file"],
43
+ ["reference_docs", "file_path", "required", "file"],
44
+ ];
45
+
46
+ const SNAPSHOT_SINGLETON_DOMAINS = [
47
+ ["project", "object"],
48
+ ["universe", "nullable_object"],
49
+ ["external_references", "object"],
50
+ ["operation_history", "object"],
51
+ ];
52
+
53
+ const NULLABLE_IDENTITY_FIELDS = new Map([
54
+ ["character_relationships", new Set(["scene_id", "note"])],
55
+ ]);
56
+
57
+ const EMPTY_STRING_IDENTITY_FIELDS = new Map([
58
+ ["reference_links", new Set(["source_project_id"])],
59
+ ]);
60
+
61
+ const PROJECT_SCOPE_FIELDS = new Map([
62
+ ["characters", ["project_id"]],
63
+ ["places", ["project_id"]],
64
+ ["reference_docs", ["project_id"]],
65
+ ["reference_links", ["source_project_id"]],
66
+ ]);
67
+
68
+ function stableStringify(value) {
69
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
70
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
71
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
72
+ }
73
+
74
+ function isRecord(value) {
75
+ return value !== null && typeof value === "object" && !Array.isArray(value);
76
+ }
77
+
78
+ function jsonType(value) {
79
+ if (value === null) return "null";
80
+ if (Array.isArray(value)) return "array";
81
+ return typeof value;
82
+ }
83
+
84
+ function fileReferenceBoundaryFailure(error, fallbackResolvedPath) {
85
+ const errorDetails = isRecord(error?.details) ? error.details : {};
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ const resolvedPath = typeof errorDetails.path === "string" ? errorDetails.path : fallbackResolvedPath;
88
+ const ancestorResolutionFailed =
89
+ Object.hasOwn(errorDetails, "existing_ancestor") ||
90
+ Object.hasOwn(errorDetails, "cause") ||
91
+ message === "Path ancestor could not be resolved: path may be inaccessible.";
92
+
93
+ if (ancestorResolutionFailed) {
94
+ return {
95
+ message: "file reference could not be resolved inside WRITING_SYNC_DIR.",
96
+ reason: "ancestor_resolution_failed",
97
+ resolvedPath,
98
+ nextStep: "Ensure the referenced path is accessible, then retry the dry run.",
99
+ details: {
100
+ existing_ancestor: errorDetails.existing_ancestor,
101
+ cause: errorDetails.cause,
102
+ },
103
+ errorMessage: message,
104
+ };
105
+ }
106
+
107
+ return {
108
+ message: "file reference points outside WRITING_SYNC_DIR.",
109
+ reason: "outside_sync_root",
110
+ resolvedPath,
111
+ nextStep: "Use only trusted backups generated for this sync root.",
112
+ details: {},
113
+ errorMessage: message,
114
+ };
115
+ }
116
+
117
+ function identityFieldError(row, field, nullableFields, emptyStringFields) {
118
+ const hasField = Object.hasOwn(row, field);
119
+ const value = row[field];
120
+ if (!hasField || value === undefined) return { reason: "missing_identity" };
121
+ if (value === null) {
122
+ return nullableFields.has(field) ? null : { reason: "missing_identity" };
123
+ }
124
+ if (typeof value !== "string") {
125
+ return {
126
+ reason: "non_string_identity",
127
+ actual_type: jsonType(value),
128
+ };
129
+ }
130
+ if (value === "" && !nullableFields.has(field) && !emptyStringFields.has(field)) {
131
+ return { reason: "empty_identity" };
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function createDiagnostic(type, message, details = {}, {
137
+ severity = "warning",
138
+ nextStep = null,
139
+ } = {}) {
140
+ return {
141
+ type,
142
+ severity,
143
+ message,
144
+ details,
145
+ ...(nextStep ? { next_step: nextStep } : {}),
146
+ };
147
+ }
148
+
149
+ function fileState(filePath) {
150
+ if (!fs.existsSync(filePath)) {
151
+ return { exists: false, readable: false, regular: false, symlink: false };
152
+ }
153
+ let stat;
154
+ try {
155
+ stat = fs.lstatSync(filePath);
156
+ } catch (error) {
157
+ return {
158
+ exists: true,
159
+ readable: false,
160
+ regular: false,
161
+ symlink: false,
162
+ error: "lstat_failed",
163
+ message: error instanceof Error ? error.message : String(error),
164
+ };
165
+ }
166
+ return {
167
+ exists: true,
168
+ readable: true,
169
+ regular: stat.isFile(),
170
+ directory: stat.isDirectory(),
171
+ symlink: stat.isSymbolicLink(),
172
+ };
173
+ }
174
+
175
+ function readJsonFile(filePath, label) {
176
+ const state = fileState(filePath);
177
+ if (!state.exists) {
178
+ return { ok: false, state, diagnostic: createDiagnostic(
179
+ "project_restore_backup_partial",
180
+ `Project backup ${label} is missing.`,
181
+ { file: filePath, reason: "missing" },
182
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
183
+ ) };
184
+ }
185
+ if (state.symlink || !state.regular) {
186
+ return { ok: false, state, diagnostic: createDiagnostic(
187
+ "project_restore_backup_unreadable",
188
+ `Project backup ${label} is not readable as trusted JSON.`,
189
+ { file: filePath, reason: state.error ?? (state.symlink ? "symlink" : "not_regular"), message: state.message ?? null },
190
+ { nextStep: "Use a regular generated backup file from export_project_backup." }
191
+ ) };
192
+ }
193
+ try {
194
+ return {
195
+ ok: true,
196
+ state,
197
+ value: JSON.parse(fs.readFileSync(filePath, "utf8")),
198
+ };
199
+ } catch (error) {
200
+ return { ok: false, state, diagnostic: createDiagnostic(
201
+ "project_restore_backup_unreadable",
202
+ `Project backup ${label} is not readable as trusted JSON.`,
203
+ { file: filePath, reason: "unreadable_json", message: error instanceof Error ? error.message : String(error) },
204
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
205
+ ) };
206
+ }
207
+ }
208
+
209
+ function resolveBackupDir(backupPath) {
210
+ const resolved = path.resolve(backupPath);
211
+ const base = path.basename(resolved);
212
+ if (base === MANIFEST_FILE || base === SNAPSHOT_FILE) return path.dirname(resolved);
213
+ return resolved;
214
+ }
215
+
216
+ function backupLocation(syncDir, backupDir) {
217
+ const relative = path.relative(syncDir, backupDir).split(path.sep).filter(Boolean).join("/");
218
+ return relative ? `${relative}/` : "./";
219
+ }
220
+
221
+ function encodeIdentityValue(value) {
222
+ if (value === null) return "null:";
223
+ if (value === undefined) return "undefined:";
224
+ return `${typeof value}:${String(value)}`;
225
+ }
226
+
227
+ function rowKey(row, keyFields) {
228
+ return keyFields.map(field => encodeIdentityValue(row?.[field])).join("\u0000");
229
+ }
230
+
231
+ function rowIdentity(row, keyFields) {
232
+ return Object.fromEntries(keyFields.map(field => [field, row?.[field] ?? null]));
233
+ }
234
+
235
+ function compareRows(currentRows = [], backupRows = [], keyFields) {
236
+ const currentByKey = new Map(currentRows.map(row => [rowKey(row, keyFields), row]));
237
+ const backupByKey = new Map(backupRows.map(row => [rowKey(row, keyFields), row]));
238
+ const keys = [...new Set([...currentByKey.keys(), ...backupByKey.keys()])].sort();
239
+ const changes = [];
240
+
241
+ for (const key of keys) {
242
+ const current = currentByKey.get(key) ?? null;
243
+ const backup = backupByKey.get(key) ?? null;
244
+ const identity = rowIdentity(backup ?? current, keyFields);
245
+ if (!current) {
246
+ changes.push({ action: "create", identity, backup });
247
+ } else if (!backup) {
248
+ changes.push({ action: "delete", identity, current, destructive: true });
249
+ } else if (stableStringify(current) === stableStringify(backup)) {
250
+ changes.push({ action: "unchanged", identity });
251
+ } else {
252
+ changes.push({ action: "update", identity, current, backup });
253
+ }
254
+ }
255
+
256
+ return changes;
257
+ }
258
+
259
+ function compareSingleton(domain, current, backup, keyFields) {
260
+ return compareRows(
261
+ current ? [current] : [],
262
+ backup ? [backup] : [],
263
+ keyFields
264
+ ).map(change => ({ domain, ...change }));
265
+ }
266
+
267
+ function countActions(changes) {
268
+ const counts = { create: 0, update: 0, delete: 0, unchanged: 0, refused: 0, conflict: 0 };
269
+ for (const change of changes) {
270
+ counts[change.action] = (counts[change.action] ?? 0) + 1;
271
+ }
272
+ return counts;
273
+ }
274
+
275
+ function buildEmptyCurrentSnapshot(db, backupSnapshot) {
276
+ const universeId = backupSnapshot.universe?.universe_id ?? null;
277
+ const universe = universeId
278
+ ? db.prepare(`
279
+ SELECT universe_id, name
280
+ FROM universes
281
+ WHERE universe_id = ?
282
+ `).get(universeId) ?? null
283
+ : null;
284
+
285
+ return {
286
+ project: null,
287
+ universe,
288
+ chapters: [],
289
+ scenes: [],
290
+ epigraphs: [],
291
+ epigraph_characters: [],
292
+ epigraph_tags: [],
293
+ scene_characters: [],
294
+ scene_places: [],
295
+ scene_tags: [],
296
+ scene_threads: [],
297
+ characters: [],
298
+ character_traits: [],
299
+ character_relationships: [],
300
+ places: [],
301
+ threads: [],
302
+ reference_docs: [],
303
+ reference_doc_tags: [],
304
+ reference_links: [],
305
+ external_references: { character_ids: [], place_ids: [], reference_doc_ids: [] },
306
+ operation_history: backupSnapshot.operation_history ?? null,
307
+ };
308
+ }
309
+
310
+ function collectCurrentSnapshot(db, {
311
+ projectId,
312
+ syncDir,
313
+ applicationVersion,
314
+ backupLocationValue,
315
+ backupSnapshot,
316
+ }) {
317
+ const built = buildProjectBackup(db, {
318
+ projectId,
319
+ syncDir,
320
+ applicationVersion,
321
+ backupLocation: backupLocationValue,
322
+ });
323
+ if (built.ok) return { ok: true, snapshot: built.snapshot, checksum: built.manifest.checksums.canonical_snapshot_sha256 };
324
+ if (built.error?.code === "NOT_FOUND") {
325
+ const snapshot = buildEmptyCurrentSnapshot(db, backupSnapshot);
326
+ return { ok: true, snapshot, checksum: null };
327
+ }
328
+ return built;
329
+ }
330
+
331
+ function validateFileReferences(snapshot, { syncDir }) {
332
+ const diagnostics = [];
333
+ const syncRoot = path.resolve(syncDir);
334
+ const syncRootReal = resolveBoundaryRootReal(syncRoot);
335
+ for (const [domain, field, requirement, expectedKind] of FILE_REFERENCE_FIELDS) {
336
+ for (const row of snapshot[domain] ?? []) {
337
+ const value = row[field];
338
+ const hasValue = value !== null && value !== undefined && value !== "";
339
+ if (!hasValue) {
340
+ if (requirement === "required") {
341
+ diagnostics.push(createDiagnostic(
342
+ "project_restore_file_reference_invalid",
343
+ `Backup ${domain} record is missing required ${field}.`,
344
+ { domain, field, identity: row },
345
+ { nextStep: "Regenerate the backup before using it for recovery." }
346
+ ));
347
+ }
348
+ continue;
349
+ }
350
+
351
+ if (typeof value !== "string") {
352
+ diagnostics.push(createDiagnostic(
353
+ "project_restore_file_reference_invalid",
354
+ `Backup ${domain} record has non-string ${field}.`,
355
+ {
356
+ domain,
357
+ field,
358
+ actual_type: jsonType(value),
359
+ reason: "non_string_file_reference",
360
+ },
361
+ { nextStep: "Regenerate the backup before using it for recovery." }
362
+ ));
363
+ continue;
364
+ }
365
+
366
+ const resolved = path.isAbsolute(value) ? path.resolve(value) : path.resolve(syncRoot, value);
367
+ let boundaryResolvedPath;
368
+ try {
369
+ boundaryResolvedPath = resolveCandidateInsideBoundary(resolved, {
370
+ boundaryRoot: syncRoot,
371
+ boundaryRootReal: syncRootReal,
372
+ errorCode: "project_restore_file_reference_invalid",
373
+ errorMessage: "Backup file reference must stay inside WRITING_SYNC_DIR.",
374
+ details: { domain, field, path: value },
375
+ }).resolvedPath;
376
+ } catch (error) {
377
+ const boundaryFailure = fileReferenceBoundaryFailure(error, resolved);
378
+ diagnostics.push(createDiagnostic(
379
+ "project_restore_file_reference_invalid",
380
+ `Backup ${domain} record ${boundaryFailure.message}`,
381
+ {
382
+ domain,
383
+ field,
384
+ path: value,
385
+ resolved_path: boundaryFailure.resolvedPath,
386
+ reason: boundaryFailure.reason,
387
+ message: boundaryFailure.errorMessage,
388
+ ...boundaryFailure.details,
389
+ },
390
+ { nextStep: boundaryFailure.nextStep }
391
+ ));
392
+ continue;
393
+ }
394
+
395
+ const state = fileState(resolved);
396
+ if (state.symlink) {
397
+ diagnostics.push(createDiagnostic(
398
+ "project_restore_file_reference_invalid",
399
+ `Backup ${domain} record points to a symlink, which is not trusted restore input.`,
400
+ { domain, field, path: value, resolved_path: resolved, reason: "symlink" },
401
+ { nextStep: "Restore the referenced prose file as a regular file, then retry the dry run." }
402
+ ));
403
+ } else if (!state.exists) {
404
+ diagnostics.push(createDiagnostic(
405
+ "project_restore_file_reference_missing",
406
+ `Backup ${domain} record points to a missing ${expectedKind}.`,
407
+ { domain, field, path: value, resolved_path: boundaryResolvedPath },
408
+ { nextStep: "Restore the referenced path, then retry the dry run." }
409
+ ));
410
+ } else if (
411
+ (expectedKind === "file" && !state.regular) ||
412
+ (expectedKind === "directory" && !state.directory)
413
+ ) {
414
+ diagnostics.push(createDiagnostic(
415
+ "project_restore_file_reference_invalid",
416
+ `Backup ${domain} record does not point to a ${expectedKind}.`,
417
+ { domain, field, path: value, resolved_path: boundaryResolvedPath, expected_kind: expectedKind, reason: state.error ?? `not_${expectedKind}` },
418
+ { nextStep: "Restore the referenced path with the expected kind, then retry the dry run." }
419
+ ));
420
+ }
421
+ }
422
+ }
423
+ return diagnostics;
424
+ }
425
+
426
+ function validateBundle({ manifest, snapshot, projectId, backupDir }) {
427
+ const diagnostics = [];
428
+ if (manifest.artifact_kind !== "project_backup") {
429
+ diagnostics.push(createDiagnostic(
430
+ "project_restore_wrong_artifact",
431
+ "Backup manifest is not a project backup artifact.",
432
+ { backup_dir: backupDir, artifact_kind: manifest.artifact_kind ?? null },
433
+ { nextStep: "Choose a generated project backup bundle." }
434
+ ));
435
+ }
436
+ if (manifest.project_id !== projectId || snapshot.project?.project_id !== projectId) {
437
+ diagnostics.push(createDiagnostic(
438
+ "project_restore_wrong_project",
439
+ `Backup bundle does not belong to project "${projectId}".`,
440
+ {
441
+ backup_dir: backupDir,
442
+ manifest_project_id: manifest.project_id ?? null,
443
+ snapshot_project_id: snapshot.project?.project_id ?? null,
444
+ },
445
+ { nextStep: "Choose the backup directory for the requested project." }
446
+ ));
447
+ }
448
+ if (manifest.schema_version !== PROJECT_BACKUP_SCHEMA_VERSION) {
449
+ diagnostics.push(createDiagnostic(
450
+ "project_restore_incompatible_schema",
451
+ `Backup schema version "${manifest.schema_version ?? "unknown"}" is not compatible with this server.`,
452
+ {
453
+ backup_dir: backupDir,
454
+ backup_schema_version: manifest.schema_version ?? null,
455
+ expected_schema_version: PROJECT_BACKUP_SCHEMA_VERSION,
456
+ },
457
+ { nextStep: "Regenerate the backup with a compatible server version before restoring." }
458
+ ));
459
+ }
460
+
461
+ const exportedSnapshotChecksum = manifest.checksums?.canonical_snapshot_sha256 ?? null;
462
+ const computedSnapshotChecksum = computeProjectBackupSnapshotChecksum(snapshot);
463
+ if (!exportedSnapshotChecksum || exportedSnapshotChecksum !== computedSnapshotChecksum) {
464
+ diagnostics.push(createDiagnostic(
465
+ "project_restore_checksum_mismatch",
466
+ "Backup snapshot checksum does not match manifest.",
467
+ { backup_dir: backupDir, exported_checksum: exportedSnapshotChecksum, computed_checksum: computedSnapshotChecksum },
468
+ { nextStep: "Regenerate the backup before using it for recovery." }
469
+ ));
470
+ }
471
+
472
+ const exportedBundleChecksum = manifest.checksums?.bundle_sha256 ?? null;
473
+ const computedBundleChecksum = computeProjectBackupBundleChecksum({ manifest, snapshot });
474
+ if (!exportedBundleChecksum || exportedBundleChecksum !== computedBundleChecksum) {
475
+ diagnostics.push(createDiagnostic(
476
+ "project_restore_bundle_checksum_mismatch",
477
+ "Backup bundle checksum does not match manifest.",
478
+ { backup_dir: backupDir, exported_checksum: exportedBundleChecksum, computed_checksum: computedBundleChecksum },
479
+ { nextStep: "Regenerate the backup before using it for recovery." }
480
+ ));
481
+ }
482
+ return diagnostics;
483
+ }
484
+
485
+ function validateBundleShape({ manifest, snapshot, backupDir, projectId }) {
486
+ const diagnostics = [];
487
+ if (!isRecord(manifest)) {
488
+ diagnostics.push(createDiagnostic(
489
+ "project_restore_invalid_manifest",
490
+ "Backup manifest must be a JSON object.",
491
+ { backup_dir: backupDir, actual_type: jsonType(manifest) },
492
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
493
+ ));
494
+ } else if (!isRecord(manifest.checksums)) {
495
+ diagnostics.push(createDiagnostic(
496
+ "project_restore_invalid_manifest",
497
+ "Backup manifest is missing its checksum object.",
498
+ { backup_dir: backupDir, field: "checksums" },
499
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
500
+ ));
501
+ }
502
+
503
+ if (!isRecord(snapshot)) {
504
+ diagnostics.push(createDiagnostic(
505
+ "project_restore_invalid_snapshot",
506
+ "Backup canonical snapshot must be a JSON object.",
507
+ { backup_dir: backupDir, actual_type: jsonType(snapshot) },
508
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
509
+ ));
510
+ return diagnostics;
511
+ }
512
+
513
+ for (const [domain, expected] of SNAPSHOT_SINGLETON_DOMAINS) {
514
+ const value = snapshot[domain];
515
+ const valid = expected === "nullable_object"
516
+ ? value === null || isRecord(value)
517
+ : isRecord(value);
518
+ if (!valid) {
519
+ diagnostics.push(createDiagnostic(
520
+ "project_restore_invalid_snapshot",
521
+ `Backup canonical snapshot field "${domain}" has an invalid shape.`,
522
+ {
523
+ backup_dir: backupDir,
524
+ domain,
525
+ expected,
526
+ actual_type: jsonType(value),
527
+ },
528
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
529
+ ));
530
+ }
531
+ }
532
+
533
+ for (const [domain, keyFields] of SNAPSHOT_ARRAY_DOMAINS) {
534
+ const nullableFields = NULLABLE_IDENTITY_FIELDS.get(domain) ?? new Set();
535
+ const emptyStringFields = EMPTY_STRING_IDENTITY_FIELDS.get(domain) ?? new Set();
536
+ const projectScopeFields = PROJECT_SCOPE_FIELDS.get(domain) ?? [];
537
+ if (!(domain in snapshot)) {
538
+ diagnostics.push(createDiagnostic(
539
+ "project_restore_incomplete_snapshot",
540
+ `Backup canonical snapshot is missing required domain "${domain}".`,
541
+ { backup_dir: backupDir, domain },
542
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
543
+ ));
544
+ } else if (!Array.isArray(snapshot[domain])) {
545
+ diagnostics.push(createDiagnostic(
546
+ "project_restore_invalid_snapshot",
547
+ `Backup canonical snapshot domain "${domain}" must be an array.`,
548
+ {
549
+ backup_dir: backupDir,
550
+ domain,
551
+ actual_type: jsonType(snapshot[domain]),
552
+ },
553
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
554
+ ));
555
+ } else {
556
+ const seenKeys = new Set();
557
+ snapshot[domain].forEach((row, index) => {
558
+ if (!isRecord(row)) {
559
+ diagnostics.push(createDiagnostic(
560
+ "project_restore_invalid_snapshot",
561
+ `Backup canonical snapshot row ${index} in domain "${domain}" must be an object.`,
562
+ {
563
+ backup_dir: backupDir,
564
+ domain,
565
+ index,
566
+ actual_type: jsonType(row),
567
+ },
568
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
569
+ ));
570
+ return;
571
+ }
572
+
573
+ let hasValidIdentity = true;
574
+ for (const field of keyFields) {
575
+ const identityError = identityFieldError(row, field, nullableFields, emptyStringFields);
576
+ if (identityError) {
577
+ hasValidIdentity = false;
578
+ diagnostics.push(createDiagnostic(
579
+ "project_restore_invalid_snapshot",
580
+ `Backup canonical snapshot row ${index} in domain "${domain}" has invalid identity field "${field}".`,
581
+ {
582
+ backup_dir: backupDir,
583
+ domain,
584
+ index,
585
+ field,
586
+ ...identityError,
587
+ },
588
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
589
+ ));
590
+ }
591
+ }
592
+
593
+ if (hasValidIdentity) {
594
+ const key = rowKey(row, keyFields);
595
+ if (seenKeys.has(key)) {
596
+ diagnostics.push(createDiagnostic(
597
+ "project_restore_duplicate_identity",
598
+ `Backup canonical snapshot domain "${domain}" contains duplicate identity values.`,
599
+ {
600
+ backup_dir: backupDir,
601
+ domain,
602
+ index,
603
+ identity: Object.fromEntries(keyFields.map(field => [field, row[field] ?? null])),
604
+ },
605
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
606
+ ));
607
+ } else {
608
+ seenKeys.add(key);
609
+ }
610
+ }
611
+
612
+ if (
613
+ keyFields.includes("project_id") &&
614
+ Object.hasOwn(row, "project_id") &&
615
+ row.project_id !== null &&
616
+ row.project_id !== undefined &&
617
+ row.project_id !== "" &&
618
+ row.project_id !== projectId
619
+ ) {
620
+ diagnostics.push(createDiagnostic(
621
+ "project_restore_wrong_project",
622
+ `Backup canonical snapshot row ${index} in domain "${domain}" belongs to project "${row.project_id}", not "${projectId}".`,
623
+ {
624
+ backup_dir: backupDir,
625
+ domain,
626
+ index,
627
+ row_project_id: row.project_id,
628
+ expected_project_id: projectId,
629
+ },
630
+ { nextStep: "Choose the backup directory for the requested project or regenerate the backup." }
631
+ ));
632
+ }
633
+
634
+ for (const field of projectScopeFields) {
635
+ if (!Object.hasOwn(row, field)) {
636
+ diagnostics.push(createDiagnostic(
637
+ "project_restore_invalid_snapshot",
638
+ `Backup canonical snapshot row ${index} in domain "${domain}" is missing required project scope field "${field}".`,
639
+ {
640
+ backup_dir: backupDir,
641
+ domain,
642
+ index,
643
+ field,
644
+ reason: "missing_project_scope",
645
+ },
646
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
647
+ ));
648
+ continue;
649
+ }
650
+ const value = row[field];
651
+ if (value !== null && value !== undefined && value !== "" && value !== projectId) {
652
+ diagnostics.push(createDiagnostic(
653
+ "project_restore_wrong_project",
654
+ `Backup canonical snapshot row ${index} in domain "${domain}" has ${field} "${value}", not "${projectId}".`,
655
+ {
656
+ backup_dir: backupDir,
657
+ domain,
658
+ index,
659
+ field,
660
+ row_project_id: value,
661
+ expected_project_id: projectId,
662
+ },
663
+ { nextStep: "Choose the backup directory for the requested project or regenerate the backup." }
664
+ ));
665
+ }
666
+ }
667
+ });
668
+ }
669
+ }
670
+
671
+ return diagnostics;
672
+ }
673
+
674
+ function validateCurrentSnapshotForPlanning(snapshot, { backupDir }) {
675
+ const diagnostics = [];
676
+ for (const [domain, keyFields] of SNAPSHOT_ARRAY_DOMAINS) {
677
+ const seenKeys = new Set();
678
+ for (const [index, row] of (snapshot[domain] ?? []).entries()) {
679
+ const key = rowKey(row, keyFields);
680
+ if (seenKeys.has(key)) {
681
+ diagnostics.push(createDiagnostic(
682
+ "project_restore_current_duplicate_identity",
683
+ `Current SQLite canonical snapshot domain "${domain}" contains duplicate identity values.`,
684
+ {
685
+ backup_dir: backupDir,
686
+ domain,
687
+ index,
688
+ identity: rowIdentity(row, keyFields),
689
+ },
690
+ {
691
+ severity: "error",
692
+ nextStep: "Fix duplicate current SQLite identity rows before retrying restore planning.",
693
+ }
694
+ ));
695
+ } else {
696
+ seenKeys.add(key);
697
+ }
698
+ }
699
+ }
700
+ return diagnostics;
701
+ }
702
+
703
+ function buildRestorePlan(currentSnapshot, backupSnapshot) {
704
+ const changes = [
705
+ ...compareSingleton("projects", currentSnapshot.project, backupSnapshot.project, ["project_id"]),
706
+ ...compareSingleton("universes", currentSnapshot.universe, backupSnapshot.universe, ["universe_id"]),
707
+ ];
708
+
709
+ for (const [domain, keyFields] of SNAPSHOT_ARRAY_DOMAINS) {
710
+ for (const change of compareRows(currentSnapshot[domain] ?? [], backupSnapshot[domain] ?? [], keyFields)) {
711
+ changes.push({ domain, ...change });
712
+ }
713
+ }
714
+
715
+ const byDomain = {};
716
+ for (const change of changes) {
717
+ byDomain[change.domain] ??= { create: 0, update: 0, delete: 0, unchanged: 0, refused: 0, conflict: 0 };
718
+ byDomain[change.domain][change.action] = (byDomain[change.domain][change.action] ?? 0) + 1;
719
+ }
720
+
721
+ return {
722
+ totals: countActions(changes),
723
+ by_domain: byDomain,
724
+ destructive_change_count: changes.filter(change => change.action === "delete").length,
725
+ changes,
726
+ };
727
+ }
728
+
729
+ export function restoreProjectFromBackup(db, {
730
+ syncDir,
731
+ projectId,
732
+ backupPath = null,
733
+ dryRun = true,
734
+ applicationVersion = "0.0.0",
735
+ } = {}) {
736
+ const resolvedBackupDir = resolveBackupDir(backupPath ?? path.join(syncDir, "project-backups", projectId));
737
+ const manifestPath = path.join(resolvedBackupDir, MANIFEST_FILE);
738
+ const snapshotPath = path.join(resolvedBackupDir, SNAPSHOT_FILE);
739
+ const manifestRead = readJsonFile(manifestPath, "manifest");
740
+ const snapshotRead = readJsonFile(snapshotPath, "canonical snapshot");
741
+ const diagnostics = [manifestRead.diagnostic, snapshotRead.diagnostic].filter(Boolean);
742
+
743
+ if (dryRun === false) {
744
+ diagnostics.push(createDiagnostic(
745
+ "project_restore_apply_not_implemented",
746
+ "Project backup restore apply is not implemented in this milestone.",
747
+ { project_id: projectId },
748
+ { severity: "error", nextStep: "Run with dry_run=true to inspect the restore plan. Apply support is planned for M7." }
749
+ ));
750
+ }
751
+
752
+ const manifest = manifestRead.ok ? manifestRead.value : null;
753
+ const snapshot = snapshotRead.ok ? snapshotRead.value : null;
754
+ if (manifestRead.ok && snapshotRead.ok) {
755
+ const shapeDiagnostics = validateBundleShape({
756
+ manifest,
757
+ snapshot,
758
+ backupDir: resolvedBackupDir,
759
+ projectId,
760
+ });
761
+ diagnostics.push(...shapeDiagnostics);
762
+ if (shapeDiagnostics.length === 0) {
763
+ diagnostics.push(...validateBundle({ manifest, snapshot, projectId, backupDir: resolvedBackupDir }));
764
+ diagnostics.push(...validateFileReferences(snapshot, { syncDir }));
765
+ }
766
+ }
767
+
768
+ diagnostics.sort((a, b) => {
769
+ const typeCompare = a.type.localeCompare(b.type);
770
+ if (typeCompare) return typeCompare;
771
+ return a.message.localeCompare(b.message);
772
+ });
773
+
774
+ if (diagnostics.length || !manifest || !snapshot) {
775
+ return {
776
+ ok: false,
777
+ action: "restore_refused",
778
+ dry_run: Boolean(dryRun),
779
+ project_id: projectId,
780
+ backup_dir: resolvedBackupDir,
781
+ diagnostics,
782
+ plan: null,
783
+ next_step: "Resolve restore diagnostics before using this backup as recovery input.",
784
+ };
785
+ }
786
+
787
+ const current = collectCurrentSnapshot(db, {
788
+ projectId,
789
+ syncDir,
790
+ applicationVersion,
791
+ backupLocationValue: backupLocation(syncDir, resolvedBackupDir),
792
+ backupSnapshot: snapshot,
793
+ });
794
+ if (!current.ok) {
795
+ return {
796
+ ok: false,
797
+ action: "restore_refused",
798
+ dry_run: Boolean(dryRun),
799
+ project_id: projectId,
800
+ backup_dir: resolvedBackupDir,
801
+ diagnostics: [createDiagnostic(
802
+ "project_restore_current_snapshot_failed",
803
+ current.error?.message ?? "Current SQLite canonical state could not be inspected.",
804
+ current.error?.details ?? { project_id: projectId },
805
+ { severity: "error", nextStep: "Fix the current project database state before retrying restore planning." }
806
+ )],
807
+ plan: null,
808
+ next_step: "Resolve restore diagnostics before using this backup as recovery input.",
809
+ };
810
+ }
811
+
812
+ const currentShapeDiagnostics = validateCurrentSnapshotForPlanning(current.snapshot, {
813
+ backupDir: resolvedBackupDir,
814
+ });
815
+ if (currentShapeDiagnostics.length) {
816
+ currentShapeDiagnostics.sort((a, b) => {
817
+ const typeCompare = a.type.localeCompare(b.type);
818
+ if (typeCompare) return typeCompare;
819
+ return a.message.localeCompare(b.message);
820
+ });
821
+ return {
822
+ ok: false,
823
+ action: "restore_refused",
824
+ dry_run: Boolean(dryRun),
825
+ project_id: projectId,
826
+ backup_dir: resolvedBackupDir,
827
+ diagnostics: currentShapeDiagnostics,
828
+ plan: null,
829
+ next_step: "Fix duplicate current SQLite identity rows before retrying restore planning.",
830
+ };
831
+ }
832
+
833
+ const plan = buildRestorePlan(current.snapshot, snapshot);
834
+ return {
835
+ ok: true,
836
+ action: "planned",
837
+ dry_run: Boolean(dryRun),
838
+ project_id: projectId,
839
+ backup_dir: resolvedBackupDir,
840
+ backup: {
841
+ manifest: manifestPath,
842
+ canonical_snapshot: snapshotPath,
843
+ schema_version: manifest.schema_version,
844
+ checksums: manifest.checksums,
845
+ },
846
+ current_snapshot_checksum: current.checksum,
847
+ backup_snapshot_checksum: manifest.checksums.canonical_snapshot_sha256,
848
+ plan,
849
+ diagnostics: [],
850
+ next_step: plan.destructive_change_count > 0
851
+ ? "Review destructive delete candidates carefully. Apply support will require explicit confirmation in a later milestone."
852
+ : "Review the dry-run plan. Apply support is intentionally not available until the transactional restore milestone.",
853
+ };
854
+ }