@hanna84/mcp-writing 3.20.0 → 3.21.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/CHANGELOG.md +14 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/structure/project-backup-restore.js +467 -22
- package/src/sync/sync.js +123 -8
- package/src/tools/sync.js +9 -3
- package/src/workflows/workflow-catalogue.js +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v3.21.1](https://github.com/hannasdev/mcp-writing/compare/v3.21.0...v3.21.1)
|
|
8
|
+
|
|
9
|
+
- fix(sync): preserve managed canonical structure [`#222`](https://github.com/hannasdev/mcp-writing/pull/222)
|
|
10
|
+
|
|
11
|
+
#### [v3.21.0](https://github.com/hannasdev/mcp-writing/compare/v3.20.0...v3.21.0)
|
|
12
|
+
|
|
13
|
+
> 26 May 2026
|
|
14
|
+
|
|
15
|
+
- feat(backup): apply project restores transactionally [`#221`](https://github.com/hannasdev/mcp-writing/pull/221)
|
|
16
|
+
- Release 3.21.0 [`4097165`](https://github.com/hannasdev/mcp-writing/commit/40971653b4332af83c15a9337fa1089b61aa0973)
|
|
17
|
+
|
|
7
18
|
#### [v3.20.0](https://github.com/hannasdev/mcp-writing/compare/v3.19.0...v3.20.0)
|
|
8
19
|
|
|
20
|
+
> 24 May 2026
|
|
21
|
+
|
|
9
22
|
- feat(backup): add project restore dry-run planning [`#220`](https://github.com/hannasdev/mcp-writing/pull/220)
|
|
23
|
+
- Release 3.20.0 [`0237cae`](https://github.com/hannasdev/mcp-writing/commit/0237cae3743e46cd19433e3e47cf5582d37dfe7c)
|
|
10
24
|
|
|
11
25
|
#### [v3.19.0](https://github.com/hannasdev/mcp-writing/compare/v3.18.1...v3.19.0)
|
|
12
26
|
|
package/README.md
CHANGED
|
@@ -28,9 +28,9 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
|
|
|
28
28
|
|
|
29
29
|
**Current status:**
|
|
30
30
|
- **Core platform complete:** Metadata-first analysis, sidecar-backed metadata maintenance, AI-assisted prose editing with confirmation + git history, review bundles, and Scrivener Direct extraction are all implemented.
|
|
31
|
-
- **Recently completed:**
|
|
32
|
-
- **Previous milestone:**
|
|
33
|
-
- **Active development:**
|
|
31
|
+
- **Recently completed:** Database Backup and Recovery added project backup export, freshness diagnostics, advisory operation history, automatic backup refresh after sanctioned project-scoped canonical mutations, dry-run restore planning, transactional restore application, and backup/restore operations guidance.
|
|
32
|
+
- **Previous milestone:** Docker, CI, and Deployment Workflow made Docker a supported way to build, run, smoke-test, and deploy Writing MCP.
|
|
33
|
+
- **Active development:** Post-initiative stabilization and backlog selection.
|
|
34
34
|
- **Deferred backlog:** OpenClaw integration, client-agnostic setup, divisions, and embeddings search.
|
|
35
35
|
- **Ideas and open questions:** tracked separately so future exploration does not distort the active roadmap.
|
|
36
36
|
|
|
@@ -47,6 +47,7 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
|
|
|
47
47
|
| [docs/guides/setup.md](docs/guides/setup.md) | Prerequisites, first-time setup, Scrivener import, native sync format |
|
|
48
48
|
| [mcp-writing-vscode](https://github.com/hannasdev/mcp-writing-vscode) | VS Code extension for client-native setup flows |
|
|
49
49
|
| [docs/guides/docker.md](docs/guides/docker.md) | Docker Compose, deployment operations, MCP gateway notes |
|
|
50
|
+
| [docs/guides/backup-recovery.md](docs/guides/backup-recovery.md) | Project backup artifacts, diagnostics, and explicit restore workflow |
|
|
50
51
|
| [docs/foundations/managed-structure-contract.md](docs/foundations/managed-structure-contract.md) | Design boundaries for structural mutation, generated views, import, and maintenance workflows |
|
|
51
52
|
| [docs/agents/tools.md](docs/agents/tools.md) | Full tool reference — auto-generated from source |
|
|
52
53
|
| [docs/agents/README.md](docs/agents/README.md) | Index of agent-focused guidance, examples, and boot files |
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
3
4
|
import {
|
|
4
5
|
buildProjectBackup,
|
|
5
6
|
computeProjectBackupBundleChecksum,
|
|
@@ -65,6 +66,30 @@ const PROJECT_SCOPE_FIELDS = new Map([
|
|
|
65
66
|
["reference_links", ["source_project_id"]],
|
|
66
67
|
]);
|
|
67
68
|
|
|
69
|
+
const SNAPSHOT_DOMAIN_COLUMNS = new Map([
|
|
70
|
+
["project", ["project_id", "universe_id", "name"]],
|
|
71
|
+
["universe", ["universe_id", "name"]],
|
|
72
|
+
["external_references", ["character_ids", "place_ids", "reference_doc_ids"]],
|
|
73
|
+
["operation_history", ["supported", "authority", "advisory", "artifact"]],
|
|
74
|
+
["chapters", ["chapter_id", "project_id", "title", "sort_index", "logline", "source_path", "source_checksum", "metadata_stale", "updated_at"]],
|
|
75
|
+
["scenes", ["scene_id", "project_id", "chapter_id", "scene_role", "title", "part", "chapter", "chapter_title", "pov", "logline", "scene_change", "causality", "stakes", "scene_functions", "save_the_cat_beat", "timeline_position", "story_time", "word_count", "file_path", "prose_checksum", "metadata_stale", "updated_at"]],
|
|
76
|
+
["epigraphs", ["epigraph_id", "project_id", "chapter_id", "file_path", "prose_checksum", "metadata_stale", "updated_at"]],
|
|
77
|
+
["epigraph_characters", ["epigraph_id", "project_id", "character_id"]],
|
|
78
|
+
["epigraph_tags", ["epigraph_id", "project_id", "tag"]],
|
|
79
|
+
["scene_characters", ["scene_id", "project_id", "character_id"]],
|
|
80
|
+
["scene_places", ["scene_id", "project_id", "place_id"]],
|
|
81
|
+
["scene_tags", ["scene_id", "project_id", "tag"]],
|
|
82
|
+
["scene_threads", ["scene_id", "project_id", "thread_id", "beat"]],
|
|
83
|
+
["characters", ["character_id", "project_id", "universe_id", "name", "role", "arc_summary", "first_appearance", "file_path"]],
|
|
84
|
+
["character_traits", ["character_id", "trait"]],
|
|
85
|
+
["character_relationships", ["from_character", "to_character", "relationship_type", "strength", "scene_id", "note"]],
|
|
86
|
+
["places", ["place_id", "project_id", "universe_id", "name", "file_path"]],
|
|
87
|
+
["threads", ["thread_id", "project_id", "name", "status"]],
|
|
88
|
+
["reference_docs", ["doc_id", "project_id", "universe_id", "type", "title", "summary", "file_path"]],
|
|
89
|
+
["reference_doc_tags", ["doc_id", "tag"]],
|
|
90
|
+
["reference_links", ["source_kind", "source_project_id", "source_id", "target_doc_id", "relation", "origin"]],
|
|
91
|
+
]);
|
|
92
|
+
|
|
68
93
|
function stableStringify(value) {
|
|
69
94
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
70
95
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
@@ -81,6 +106,10 @@ function jsonType(value) {
|
|
|
81
106
|
return typeof value;
|
|
82
107
|
}
|
|
83
108
|
|
|
109
|
+
function sortedUnique(values) {
|
|
110
|
+
return [...new Set(values.filter(value => value !== null && value !== undefined && value !== ""))].sort();
|
|
111
|
+
}
|
|
112
|
+
|
|
84
113
|
function fileReferenceBoundaryFailure(error, fallbackResolvedPath) {
|
|
85
114
|
const errorDetails = isRecord(error?.details) ? error.details : {};
|
|
86
115
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -127,7 +156,7 @@ function identityFieldError(row, field, nullableFields, emptyStringFields) {
|
|
|
127
156
|
actual_type: jsonType(value),
|
|
128
157
|
};
|
|
129
158
|
}
|
|
130
|
-
if (value === "" && !
|
|
159
|
+
if (value === "" && !emptyStringFields.has(field)) {
|
|
131
160
|
return { reason: "empty_identity" };
|
|
132
161
|
}
|
|
133
162
|
return null;
|
|
@@ -256,6 +285,75 @@ function compareRows(currentRows = [], backupRows = [], keyFields) {
|
|
|
256
285
|
return changes;
|
|
257
286
|
}
|
|
258
287
|
|
|
288
|
+
function rowIsCrossScope(domain, row, projectId) {
|
|
289
|
+
if (!row) return false;
|
|
290
|
+
if (domain === "universes") return true;
|
|
291
|
+
if (["characters", "places", "reference_docs"].includes(domain)) {
|
|
292
|
+
return row.project_id == null || row.project_id === "";
|
|
293
|
+
}
|
|
294
|
+
if (domain === "character_traits") {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
if (domain === "reference_links") {
|
|
298
|
+
return row.source_project_id == null || row.source_project_id === "";
|
|
299
|
+
}
|
|
300
|
+
if (domain === "reference_doc_tags") {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
if (domain === "character_relationships") {
|
|
304
|
+
return row.scene_id == null || row.scene_id === "";
|
|
305
|
+
}
|
|
306
|
+
return Object.hasOwn(row, "project_id") && row.project_id !== projectId;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function mergeScopedIds(map, rows, idField, projectId) {
|
|
310
|
+
for (const row of rows ?? []) {
|
|
311
|
+
const id = row?.[idField];
|
|
312
|
+
if (!id) continue;
|
|
313
|
+
const crossScope = row.project_id == null || row.project_id === "" || row.project_id !== projectId;
|
|
314
|
+
map.set(id, (map.get(id) ?? false) || crossScope);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function buildRestoreScopeContext(currentSnapshot, backupSnapshot, projectId) {
|
|
319
|
+
const projectSceneIds = new Set();
|
|
320
|
+
for (const snapshot of [currentSnapshot, backupSnapshot]) {
|
|
321
|
+
for (const row of snapshot.scenes ?? []) {
|
|
322
|
+
if (row.project_id === projectId) projectSceneIds.add(row.scene_id);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const characterIds = new Map();
|
|
327
|
+
mergeScopedIds(characterIds, currentSnapshot.characters, "character_id", projectId);
|
|
328
|
+
mergeScopedIds(characterIds, backupSnapshot.characters, "character_id", projectId);
|
|
329
|
+
|
|
330
|
+
const referenceDocIds = new Map();
|
|
331
|
+
mergeScopedIds(referenceDocIds, currentSnapshot.reference_docs, "doc_id", projectId);
|
|
332
|
+
mergeScopedIds(referenceDocIds, backupSnapshot.reference_docs, "doc_id", projectId);
|
|
333
|
+
|
|
334
|
+
return { characterIds, projectSceneIds, referenceDocIds };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function rowIsContextCrossScope(domain, row, projectId, scopeContext) {
|
|
338
|
+
if (domain === "character_traits") {
|
|
339
|
+
return scopeContext.characterIds.get(row?.character_id) === true;
|
|
340
|
+
}
|
|
341
|
+
if (domain === "reference_doc_tags") {
|
|
342
|
+
return scopeContext.referenceDocIds.get(row?.doc_id) === true;
|
|
343
|
+
}
|
|
344
|
+
if (domain === "character_relationships") {
|
|
345
|
+
if (!row || row.scene_id == null || row.scene_id === "") return true;
|
|
346
|
+
return !scopeContext.projectSceneIds.has(row.scene_id);
|
|
347
|
+
}
|
|
348
|
+
return rowIsCrossScope(domain, row, projectId);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function markChangeScope(change, projectId, scopeContext) {
|
|
352
|
+
const row = change.backup ?? change.current ?? null;
|
|
353
|
+
const crossScope = rowIsContextCrossScope(change.domain, row, projectId, scopeContext);
|
|
354
|
+
return crossScope ? { ...change, cross_scope: true } : change;
|
|
355
|
+
}
|
|
356
|
+
|
|
259
357
|
function compareSingleton(domain, current, backup, keyFields) {
|
|
260
358
|
return compareRows(
|
|
261
359
|
current ? [current] : [],
|
|
@@ -323,7 +421,7 @@ function collectCurrentSnapshot(db, {
|
|
|
323
421
|
if (built.ok) return { ok: true, snapshot: built.snapshot, checksum: built.manifest.checksums.canonical_snapshot_sha256 };
|
|
324
422
|
if (built.error?.code === "NOT_FOUND") {
|
|
325
423
|
const snapshot = buildEmptyCurrentSnapshot(db, backupSnapshot);
|
|
326
|
-
return { ok: true, snapshot, checksum:
|
|
424
|
+
return { ok: true, snapshot, checksum: computeProjectBackupSnapshotChecksum(snapshot) };
|
|
327
425
|
}
|
|
328
426
|
return built;
|
|
329
427
|
}
|
|
@@ -527,6 +625,21 @@ function validateBundleShape({ manifest, snapshot, backupDir, projectId }) {
|
|
|
527
625
|
},
|
|
528
626
|
{ nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
|
|
529
627
|
));
|
|
628
|
+
} else if (value !== null) {
|
|
629
|
+
const allowedColumns = SNAPSHOT_DOMAIN_COLUMNS.get(domain) ?? [];
|
|
630
|
+
const unexpectedColumns = Object.keys(value).filter(column => !allowedColumns.includes(column));
|
|
631
|
+
if (unexpectedColumns.length) {
|
|
632
|
+
diagnostics.push(createDiagnostic(
|
|
633
|
+
"project_restore_invalid_snapshot",
|
|
634
|
+
`Backup canonical snapshot field "${domain}" contains unsupported columns.`,
|
|
635
|
+
{
|
|
636
|
+
backup_dir: backupDir,
|
|
637
|
+
domain,
|
|
638
|
+
unsupported_columns: unexpectedColumns,
|
|
639
|
+
},
|
|
640
|
+
{ nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
|
|
641
|
+
));
|
|
642
|
+
}
|
|
530
643
|
}
|
|
531
644
|
}
|
|
532
645
|
|
|
@@ -570,6 +683,22 @@ function validateBundleShape({ manifest, snapshot, backupDir, projectId }) {
|
|
|
570
683
|
return;
|
|
571
684
|
}
|
|
572
685
|
|
|
686
|
+
const allowedColumns = SNAPSHOT_DOMAIN_COLUMNS.get(domain) ?? [];
|
|
687
|
+
const unexpectedColumns = Object.keys(row).filter(column => !allowedColumns.includes(column));
|
|
688
|
+
if (unexpectedColumns.length) {
|
|
689
|
+
diagnostics.push(createDiagnostic(
|
|
690
|
+
"project_restore_invalid_snapshot",
|
|
691
|
+
`Backup canonical snapshot row ${index} in domain "${domain}" contains unsupported columns.`,
|
|
692
|
+
{
|
|
693
|
+
backup_dir: backupDir,
|
|
694
|
+
domain,
|
|
695
|
+
index,
|
|
696
|
+
unsupported_columns: unexpectedColumns,
|
|
697
|
+
},
|
|
698
|
+
{ nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
|
|
699
|
+
));
|
|
700
|
+
}
|
|
701
|
+
|
|
573
702
|
let hasValidIdentity = true;
|
|
574
703
|
for (const field of keyFields) {
|
|
575
704
|
const identityError = identityFieldError(row, field, nullableFields, emptyStringFields);
|
|
@@ -648,7 +777,21 @@ function validateBundleShape({ manifest, snapshot, backupDir, projectId }) {
|
|
|
648
777
|
continue;
|
|
649
778
|
}
|
|
650
779
|
const value = row[field];
|
|
651
|
-
|
|
780
|
+
const emptyStringFields = EMPTY_STRING_IDENTITY_FIELDS.get(domain) ?? new Set();
|
|
781
|
+
if (value === "" && !emptyStringFields.has(field)) {
|
|
782
|
+
diagnostics.push(createDiagnostic(
|
|
783
|
+
"project_restore_invalid_snapshot",
|
|
784
|
+
`Backup canonical snapshot row ${index} in domain "${domain}" has empty project scope field "${field}".`,
|
|
785
|
+
{
|
|
786
|
+
backup_dir: backupDir,
|
|
787
|
+
domain,
|
|
788
|
+
index,
|
|
789
|
+
field,
|
|
790
|
+
reason: "empty_project_scope",
|
|
791
|
+
},
|
|
792
|
+
{ nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
|
|
793
|
+
));
|
|
794
|
+
} else if (value !== null && value !== undefined && value !== "" && value !== projectId) {
|
|
652
795
|
diagnostics.push(createDiagnostic(
|
|
653
796
|
"project_restore_wrong_project",
|
|
654
797
|
`Backup canonical snapshot row ${index} in domain "${domain}" has ${field} "${value}", not "${projectId}".`,
|
|
@@ -700,7 +843,7 @@ function validateCurrentSnapshotForPlanning(snapshot, { backupDir }) {
|
|
|
700
843
|
return diagnostics;
|
|
701
844
|
}
|
|
702
845
|
|
|
703
|
-
function buildRestorePlan(currentSnapshot, backupSnapshot) {
|
|
846
|
+
function buildRestorePlan(currentSnapshot, backupSnapshot, { projectId }) {
|
|
704
847
|
const changes = [
|
|
705
848
|
...compareSingleton("projects", currentSnapshot.project, backupSnapshot.project, ["project_id"]),
|
|
706
849
|
...compareSingleton("universes", currentSnapshot.universe, backupSnapshot.universe, ["universe_id"]),
|
|
@@ -712,25 +855,210 @@ function buildRestorePlan(currentSnapshot, backupSnapshot) {
|
|
|
712
855
|
}
|
|
713
856
|
}
|
|
714
857
|
|
|
858
|
+
const scopeContext = buildRestoreScopeContext(currentSnapshot, backupSnapshot, projectId);
|
|
859
|
+
const scopedChanges = changes.map(change => markChangeScope(change, projectId, scopeContext));
|
|
860
|
+
|
|
715
861
|
const byDomain = {};
|
|
716
|
-
for (const change of
|
|
862
|
+
for (const change of scopedChanges) {
|
|
717
863
|
byDomain[change.domain] ??= { create: 0, update: 0, delete: 0, unchanged: 0, refused: 0, conflict: 0 };
|
|
718
864
|
byDomain[change.domain][change.action] = (byDomain[change.domain][change.action] ?? 0) + 1;
|
|
719
865
|
}
|
|
720
866
|
|
|
721
867
|
return {
|
|
722
|
-
totals: countActions(
|
|
868
|
+
totals: countActions(scopedChanges),
|
|
723
869
|
by_domain: byDomain,
|
|
724
|
-
destructive_change_count:
|
|
725
|
-
|
|
870
|
+
destructive_change_count: scopedChanges.filter(change => change.action === "delete").length,
|
|
871
|
+
cross_scope_change_count: scopedChanges.filter(change => change.cross_scope && change.action !== "unchanged").length,
|
|
872
|
+
changes: scopedChanges,
|
|
726
873
|
};
|
|
727
874
|
}
|
|
728
875
|
|
|
876
|
+
function placeholders(values) {
|
|
877
|
+
return values.map(() => "?").join(",") || "NULL";
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function allSnapshotIds(snapshot, domain, field) {
|
|
881
|
+
return sortedUnique((snapshot[domain] ?? []).map(row => row[field]));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function deleteCharacterRelationship(db, row) {
|
|
885
|
+
db.prepare(`
|
|
886
|
+
DELETE FROM character_relationships
|
|
887
|
+
WHERE from_character = ?
|
|
888
|
+
AND to_character = ?
|
|
889
|
+
AND relationship_type = ?
|
|
890
|
+
AND (strength IS ? OR strength = ?)
|
|
891
|
+
AND (scene_id IS ? OR scene_id = ?)
|
|
892
|
+
AND (note IS ? OR note = ?)
|
|
893
|
+
`).run(
|
|
894
|
+
row.from_character,
|
|
895
|
+
row.to_character,
|
|
896
|
+
row.relationship_type,
|
|
897
|
+
row.strength ?? null,
|
|
898
|
+
row.strength ?? null,
|
|
899
|
+
row.scene_id ?? null,
|
|
900
|
+
row.scene_id ?? null,
|
|
901
|
+
row.note ?? null,
|
|
902
|
+
row.note ?? null
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function upsertRow(db, table, row, conflictColumns) {
|
|
907
|
+
const columns = Object.keys(row);
|
|
908
|
+
if (!conflictColumns.length) {
|
|
909
|
+
db.prepare(`
|
|
910
|
+
INSERT INTO ${table} (${columns.join(", ")})
|
|
911
|
+
VALUES (${columns.map(() => "?").join(", ")})
|
|
912
|
+
`).run(...columns.map(column => row[column]));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const updateColumns = columns.filter(column => !conflictColumns.includes(column));
|
|
916
|
+
const conflictSql = conflictColumns.join(", ");
|
|
917
|
+
const updateSql = updateColumns.length
|
|
918
|
+
? `DO UPDATE SET ${updateColumns.map(column => `${column} = excluded.${column}`).join(", ")}`
|
|
919
|
+
: "DO NOTHING";
|
|
920
|
+
db.prepare(`
|
|
921
|
+
INSERT INTO ${table} (${columns.join(", ")})
|
|
922
|
+
VALUES (${columns.map(() => "?").join(", ")})
|
|
923
|
+
ON CONFLICT (${conflictSql}) ${updateSql}
|
|
924
|
+
`).run(...columns.map(column => row[column]));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function insertRows(db, table, rows, conflictColumns) {
|
|
928
|
+
for (const row of rows) {
|
|
929
|
+
upsertRow(db, table, row, conflictColumns);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function restoreEpigraphRows(db, snapshot, { syncDir }) {
|
|
934
|
+
for (const row of snapshot.epigraphs) {
|
|
935
|
+
const filePath = path.isAbsolute(row.file_path)
|
|
936
|
+
? path.resolve(row.file_path)
|
|
937
|
+
: path.resolve(syncDir, row.file_path);
|
|
938
|
+
const body = matter(fs.readFileSync(filePath, "utf8")).content;
|
|
939
|
+
upsertRow(db, "epigraphs", {
|
|
940
|
+
...row,
|
|
941
|
+
body,
|
|
942
|
+
file_path: row.file_path,
|
|
943
|
+
}, ["epigraph_id", "project_id"]);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function clearDerivedRestoreRows(db, { projectId, currentSnapshot, backupSnapshot }) {
|
|
948
|
+
db.prepare(`DELETE FROM scenes_fts WHERE project_id = ?`).run(projectId);
|
|
949
|
+
|
|
950
|
+
const referenceDocIds = sortedUnique([
|
|
951
|
+
...allSnapshotIds(currentSnapshot, "reference_docs", "doc_id"),
|
|
952
|
+
...allSnapshotIds(backupSnapshot, "reference_docs", "doc_id"),
|
|
953
|
+
]);
|
|
954
|
+
if (referenceDocIds.length) {
|
|
955
|
+
db.prepare(`
|
|
956
|
+
DELETE FROM reference_docs_fts
|
|
957
|
+
WHERE doc_id IN (${placeholders(referenceDocIds)})
|
|
958
|
+
`).run(...referenceDocIds);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function applyProjectRestore(db, {
|
|
963
|
+
projectId,
|
|
964
|
+
syncDir,
|
|
965
|
+
currentSnapshot,
|
|
966
|
+
backupSnapshot,
|
|
967
|
+
}) {
|
|
968
|
+
const currentCharacterIds = allSnapshotIds(currentSnapshot, "characters", "character_id");
|
|
969
|
+
const currentPlaceIds = allSnapshotIds(currentSnapshot, "places", "place_id");
|
|
970
|
+
const currentReferenceDocIds = allSnapshotIds(currentSnapshot, "reference_docs", "doc_id");
|
|
971
|
+
|
|
972
|
+
clearDerivedRestoreRows(db, { projectId, currentSnapshot, backupSnapshot });
|
|
973
|
+
|
|
974
|
+
for (const domain of [
|
|
975
|
+
"epigraph_characters",
|
|
976
|
+
"epigraph_tags",
|
|
977
|
+
"scene_characters",
|
|
978
|
+
"scene_places",
|
|
979
|
+
"scene_tags",
|
|
980
|
+
"scene_threads",
|
|
981
|
+
]) {
|
|
982
|
+
db.prepare(`DELETE FROM ${domain} WHERE project_id = ?`).run(projectId);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
db.prepare(`DELETE FROM chapters WHERE project_id = ?`).run(projectId);
|
|
986
|
+
db.prepare(`DELETE FROM epigraphs WHERE project_id = ?`).run(projectId);
|
|
987
|
+
db.prepare(`DELETE FROM scenes WHERE project_id = ?`).run(projectId);
|
|
988
|
+
db.prepare(`DELETE FROM threads WHERE project_id = ?`).run(projectId);
|
|
989
|
+
|
|
990
|
+
if (currentCharacterIds.length) {
|
|
991
|
+
db.prepare(`DELETE FROM character_traits WHERE character_id IN (${placeholders(currentCharacterIds)})`).run(...currentCharacterIds);
|
|
992
|
+
}
|
|
993
|
+
for (const row of currentSnapshot.character_relationships ?? []) {
|
|
994
|
+
deleteCharacterRelationship(db, row);
|
|
995
|
+
}
|
|
996
|
+
if (currentCharacterIds.length) {
|
|
997
|
+
db.prepare(`DELETE FROM characters WHERE character_id IN (${placeholders(currentCharacterIds)})`).run(...currentCharacterIds);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (currentPlaceIds.length) {
|
|
1001
|
+
db.prepare(`DELETE FROM places WHERE place_id IN (${placeholders(currentPlaceIds)})`).run(...currentPlaceIds);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (currentReferenceDocIds.length) {
|
|
1005
|
+
db.prepare(`DELETE FROM reference_doc_tags WHERE doc_id IN (${placeholders(currentReferenceDocIds)})`).run(...currentReferenceDocIds);
|
|
1006
|
+
db.prepare(`DELETE FROM reference_docs WHERE doc_id IN (${placeholders(currentReferenceDocIds)})`).run(...currentReferenceDocIds);
|
|
1007
|
+
}
|
|
1008
|
+
for (const row of currentSnapshot.reference_links ?? []) {
|
|
1009
|
+
db.prepare(`
|
|
1010
|
+
DELETE FROM reference_links
|
|
1011
|
+
WHERE source_kind = ?
|
|
1012
|
+
AND source_project_id = ?
|
|
1013
|
+
AND source_id = ?
|
|
1014
|
+
AND target_doc_id = ?
|
|
1015
|
+
AND relation = ?
|
|
1016
|
+
`).run(row.source_kind, row.source_project_id, row.source_id, row.target_doc_id, row.relation);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (currentSnapshot.universe && !backupSnapshot.universe) {
|
|
1020
|
+
db.prepare(`DELETE FROM universes WHERE universe_id = ?`).run(currentSnapshot.universe.universe_id);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (backupSnapshot.universe) {
|
|
1024
|
+
upsertRow(db, "universes", backupSnapshot.universe, ["universe_id"]);
|
|
1025
|
+
}
|
|
1026
|
+
if (backupSnapshot.project) {
|
|
1027
|
+
upsertRow(db, "projects", backupSnapshot.project, ["project_id"]);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
insertRows(db, "chapters", backupSnapshot.chapters, ["chapter_id", "project_id"]);
|
|
1031
|
+
insertRows(db, "scenes", backupSnapshot.scenes, ["scene_id", "project_id"]);
|
|
1032
|
+
restoreEpigraphRows(db, backupSnapshot, { syncDir });
|
|
1033
|
+
insertRows(db, "characters", backupSnapshot.characters, ["character_id"]);
|
|
1034
|
+
insertRows(db, "character_traits", backupSnapshot.character_traits, ["character_id", "trait"]);
|
|
1035
|
+
insertRows(db, "character_relationships", backupSnapshot.character_relationships, []);
|
|
1036
|
+
insertRows(db, "places", backupSnapshot.places, ["place_id"]);
|
|
1037
|
+
insertRows(db, "threads", backupSnapshot.threads, ["thread_id"]);
|
|
1038
|
+
insertRows(db, "reference_docs", backupSnapshot.reference_docs, ["doc_id"]);
|
|
1039
|
+
insertRows(db, "reference_doc_tags", backupSnapshot.reference_doc_tags, ["doc_id", "tag"]);
|
|
1040
|
+
insertRows(db, "reference_links", backupSnapshot.reference_links, ["source_kind", "source_project_id", "source_id", "target_doc_id", "relation"]);
|
|
1041
|
+
|
|
1042
|
+
for (const domain of [
|
|
1043
|
+
["epigraph_characters", ["epigraph_id", "project_id", "character_id"]],
|
|
1044
|
+
["epigraph_tags", ["epigraph_id", "project_id", "tag"]],
|
|
1045
|
+
["scene_characters", ["scene_id", "project_id", "character_id"]],
|
|
1046
|
+
["scene_places", ["scene_id", "project_id", "place_id"]],
|
|
1047
|
+
["scene_tags", ["scene_id", "project_id", "tag"]],
|
|
1048
|
+
["scene_threads", ["scene_id", "project_id", "thread_id"]],
|
|
1049
|
+
]) {
|
|
1050
|
+
insertRows(db, domain[0], backupSnapshot[domain[0]], domain[1]);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
729
1054
|
export function restoreProjectFromBackup(db, {
|
|
730
1055
|
syncDir,
|
|
731
1056
|
projectId,
|
|
732
1057
|
backupPath = null,
|
|
733
1058
|
dryRun = true,
|
|
1059
|
+
confirmDestructive = false,
|
|
1060
|
+
confirmCrossScope = false,
|
|
1061
|
+
expectedCurrentSnapshotChecksum = null,
|
|
734
1062
|
applicationVersion = "0.0.0",
|
|
735
1063
|
} = {}) {
|
|
736
1064
|
const resolvedBackupDir = resolveBackupDir(backupPath ?? path.join(syncDir, "project-backups", projectId));
|
|
@@ -740,15 +1068,6 @@ export function restoreProjectFromBackup(db, {
|
|
|
740
1068
|
const snapshotRead = readJsonFile(snapshotPath, "canonical snapshot");
|
|
741
1069
|
const diagnostics = [manifestRead.diagnostic, snapshotRead.diagnostic].filter(Boolean);
|
|
742
1070
|
|
|
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
1071
|
const manifest = manifestRead.ok ? manifestRead.value : null;
|
|
753
1072
|
const snapshot = snapshotRead.ok ? snapshotRead.value : null;
|
|
754
1073
|
if (manifestRead.ok && snapshotRead.ok) {
|
|
@@ -830,10 +1149,125 @@ export function restoreProjectFromBackup(db, {
|
|
|
830
1149
|
};
|
|
831
1150
|
}
|
|
832
1151
|
|
|
833
|
-
const plan = buildRestorePlan(current.snapshot, snapshot);
|
|
1152
|
+
const plan = buildRestorePlan(current.snapshot, snapshot, { projectId });
|
|
1153
|
+
const applyDiagnostics = [];
|
|
1154
|
+
if (dryRun === false) {
|
|
1155
|
+
if (typeof expectedCurrentSnapshotChecksum !== "string" || expectedCurrentSnapshotChecksum === "") {
|
|
1156
|
+
applyDiagnostics.push(createDiagnostic(
|
|
1157
|
+
"project_restore_current_snapshot_confirmation_required",
|
|
1158
|
+
"Project backup restore requires the current_snapshot_checksum from the reviewed dry-run plan.",
|
|
1159
|
+
{
|
|
1160
|
+
project_id: projectId,
|
|
1161
|
+
current_snapshot_checksum: current.checksum,
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
severity: "error",
|
|
1165
|
+
nextStep: "Run a dry-run restore, review the plan, then retry with expected_current_snapshot_checksum set to that dry-run current_snapshot_checksum.",
|
|
1166
|
+
}
|
|
1167
|
+
));
|
|
1168
|
+
} else if (expectedCurrentSnapshotChecksum !== current.checksum) {
|
|
1169
|
+
applyDiagnostics.push(createDiagnostic(
|
|
1170
|
+
"project_restore_current_snapshot_changed",
|
|
1171
|
+
"Current SQLite canonical state changed after the reviewed dry-run plan.",
|
|
1172
|
+
{
|
|
1173
|
+
project_id: projectId,
|
|
1174
|
+
expected_current_snapshot_checksum: expectedCurrentSnapshotChecksum,
|
|
1175
|
+
current_snapshot_checksum: current.checksum,
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
severity: "error",
|
|
1179
|
+
nextStep: "Run a new dry-run restore, review the updated plan, then retry with the new current_snapshot_checksum.",
|
|
1180
|
+
}
|
|
1181
|
+
));
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (dryRun === false && plan.destructive_change_count > 0 && confirmDestructive !== true) {
|
|
1185
|
+
applyDiagnostics.push(createDiagnostic(
|
|
1186
|
+
"project_restore_destructive_confirmation_required",
|
|
1187
|
+
"Project backup restore would delete canonical SQLite records and requires explicit confirmation.",
|
|
1188
|
+
{
|
|
1189
|
+
project_id: projectId,
|
|
1190
|
+
destructive_change_count: plan.destructive_change_count,
|
|
1191
|
+
},
|
|
1192
|
+
{
|
|
1193
|
+
severity: "error",
|
|
1194
|
+
nextStep: "Review the dry-run delete candidates, then retry with confirm_destructive=true if those deletes are intended.",
|
|
1195
|
+
}
|
|
1196
|
+
));
|
|
1197
|
+
}
|
|
1198
|
+
if (dryRun === false && plan.cross_scope_change_count > 0 && confirmCrossScope !== true) {
|
|
1199
|
+
applyDiagnostics.push(createDiagnostic(
|
|
1200
|
+
"project_restore_cross_scope_confirmation_required",
|
|
1201
|
+
"Project backup restore would create, update, or delete universe-scoped records and requires explicit confirmation.",
|
|
1202
|
+
{
|
|
1203
|
+
project_id: projectId,
|
|
1204
|
+
cross_scope_change_count: plan.cross_scope_change_count,
|
|
1205
|
+
},
|
|
1206
|
+
{
|
|
1207
|
+
severity: "error",
|
|
1208
|
+
nextStep: "Review the dry-run cross_scope changes, then retry with confirm_cross_scope=true if those changes are intended.",
|
|
1209
|
+
}
|
|
1210
|
+
));
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (applyDiagnostics.length) {
|
|
1214
|
+
applyDiagnostics.sort((a, b) => {
|
|
1215
|
+
const typeCompare = a.type.localeCompare(b.type);
|
|
1216
|
+
if (typeCompare) return typeCompare;
|
|
1217
|
+
return a.message.localeCompare(b.message);
|
|
1218
|
+
});
|
|
1219
|
+
return {
|
|
1220
|
+
ok: false,
|
|
1221
|
+
action: "restore_refused",
|
|
1222
|
+
dry_run: Boolean(dryRun),
|
|
1223
|
+
project_id: projectId,
|
|
1224
|
+
backup_dir: resolvedBackupDir,
|
|
1225
|
+
diagnostics: applyDiagnostics,
|
|
1226
|
+
plan,
|
|
1227
|
+
next_step: "Resolve confirmation requirements before applying this trusted backup.",
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (dryRun === false) {
|
|
1232
|
+
try {
|
|
1233
|
+
db.exec("BEGIN");
|
|
1234
|
+
applyProjectRestore(db, {
|
|
1235
|
+
projectId,
|
|
1236
|
+
syncDir,
|
|
1237
|
+
currentSnapshot: current.snapshot,
|
|
1238
|
+
backupSnapshot: snapshot,
|
|
1239
|
+
});
|
|
1240
|
+
db.exec("COMMIT");
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
try {
|
|
1243
|
+
db.exec("ROLLBACK");
|
|
1244
|
+
} catch (rollbackError) {
|
|
1245
|
+
void rollbackError;
|
|
1246
|
+
}
|
|
1247
|
+
return {
|
|
1248
|
+
ok: false,
|
|
1249
|
+
action: "restore_refused",
|
|
1250
|
+
dry_run: false,
|
|
1251
|
+
project_id: projectId,
|
|
1252
|
+
backup_dir: resolvedBackupDir,
|
|
1253
|
+
diagnostics: [createDiagnostic(
|
|
1254
|
+
"project_restore_write_failed",
|
|
1255
|
+
"Failed to apply project backup restore transaction.",
|
|
1256
|
+
{
|
|
1257
|
+
project_id: projectId,
|
|
1258
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1259
|
+
},
|
|
1260
|
+
{ severity: "error", nextStep: "Review the database error and retry after resolving conflicts." }
|
|
1261
|
+
)],
|
|
1262
|
+
plan,
|
|
1263
|
+
next_step: "Resolve restore write diagnostics before retrying.",
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
834
1268
|
return {
|
|
835
1269
|
ok: true,
|
|
836
|
-
action: "planned",
|
|
1270
|
+
action: dryRun ? "planned" : "restored",
|
|
837
1271
|
dry_run: Boolean(dryRun),
|
|
838
1272
|
project_id: projectId,
|
|
839
1273
|
backup_dir: resolvedBackupDir,
|
|
@@ -846,9 +1280,20 @@ export function restoreProjectFromBackup(db, {
|
|
|
846
1280
|
current_snapshot_checksum: current.checksum,
|
|
847
1281
|
backup_snapshot_checksum: manifest.checksums.canonical_snapshot_sha256,
|
|
848
1282
|
plan,
|
|
1283
|
+
applied: dryRun ? null : {
|
|
1284
|
+
restored: true,
|
|
1285
|
+
destructive_confirmed: Boolean(confirmDestructive),
|
|
1286
|
+
cross_scope_confirmed: Boolean(confirmCrossScope),
|
|
1287
|
+
derived_indexes: {
|
|
1288
|
+
scenes_fts: "cleared_for_restored_project",
|
|
1289
|
+
reference_docs_fts: "cleared_for_restored_reference_docs",
|
|
1290
|
+
},
|
|
1291
|
+
},
|
|
849
1292
|
diagnostics: [],
|
|
850
|
-
next_step:
|
|
851
|
-
?
|
|
852
|
-
|
|
1293
|
+
next_step: dryRun
|
|
1294
|
+
? (plan.destructive_change_count > 0 || plan.cross_scope_change_count > 0
|
|
1295
|
+
? "Review destructive delete and cross_scope candidates carefully before applying with explicit confirmation."
|
|
1296
|
+
: "Review the dry-run plan, then rerun with dry_run=false to apply the restore transactionally.")
|
|
1297
|
+
: "Run sync, diagnose_project_backups, and export_project_backup to rebuild derived indexes and refresh generated backup transparency.",
|
|
853
1298
|
};
|
|
854
1299
|
}
|
package/src/sync/sync.js
CHANGED
|
@@ -957,15 +957,24 @@ function canPruneScenes(syncDir) {
|
|
|
957
957
|
return hasBroadRootChild;
|
|
958
958
|
}
|
|
959
959
|
|
|
960
|
-
function pruneMissingScenes(db, seenSceneKeys, syncDir) {
|
|
960
|
+
function pruneMissingScenes(db, seenSceneKeys, syncDir, { managedProjectIds = new Set() } = {}) {
|
|
961
961
|
const projectScope = inferSceneProjectScopeFromSyncDir(syncDir);
|
|
962
962
|
const rows = projectScope
|
|
963
|
-
? db.prepare(`SELECT scene_id, project_id FROM scenes WHERE project_id = ?`).all(projectScope)
|
|
964
|
-
: db.prepare(`SELECT scene_id, project_id FROM scenes`).all();
|
|
963
|
+
? db.prepare(`SELECT scene_id, project_id, file_path FROM scenes WHERE project_id = ?`).all(projectScope)
|
|
964
|
+
: db.prepare(`SELECT scene_id, project_id, file_path FROM scenes`).all();
|
|
965
965
|
|
|
966
|
+
const result = { deleted: 0, preservedManagedScenes: [] };
|
|
966
967
|
for (const row of rows) {
|
|
967
968
|
const key = `${row.scene_id}::${row.project_id}`;
|
|
968
969
|
if (seenSceneKeys.has(key)) continue;
|
|
970
|
+
if (managedProjectIds.has(row.project_id)) {
|
|
971
|
+
result.preservedManagedScenes.push({
|
|
972
|
+
scene_id: row.scene_id,
|
|
973
|
+
project_id: row.project_id,
|
|
974
|
+
file_path: row.file_path,
|
|
975
|
+
});
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
969
978
|
|
|
970
979
|
db.prepare(`DELETE FROM scenes_fts WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
|
|
971
980
|
db.prepare(`
|
|
@@ -977,7 +986,9 @@ function pruneMissingScenes(db, seenSceneKeys, syncDir) {
|
|
|
977
986
|
db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
|
|
978
987
|
db.prepare(`DELETE FROM scene_tags WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
|
|
979
988
|
db.prepare(`DELETE FROM scene_threads WHERE scene_id = ? AND project_id = ?`).run(row.scene_id, row.project_id);
|
|
989
|
+
result.deleted++;
|
|
980
990
|
}
|
|
991
|
+
return result;
|
|
981
992
|
}
|
|
982
993
|
|
|
983
994
|
function pruneMissingChapters(db, seenChapterKeys, syncDir) {
|
|
@@ -1013,19 +1024,99 @@ function pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir) {
|
|
|
1013
1024
|
}
|
|
1014
1025
|
}
|
|
1015
1026
|
|
|
1027
|
+
function formatSyncRelativePath(syncDir, filePath) {
|
|
1028
|
+
if (!filePath) return null;
|
|
1029
|
+
if (!path.isAbsolute(filePath)) return filePath.split(/[\\/]+/).join("/");
|
|
1030
|
+
const resolvedSyncDir = path.resolve(syncDir);
|
|
1031
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
1032
|
+
const relativePath = path.relative(resolvedSyncDir, resolvedFilePath);
|
|
1033
|
+
if (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
|
|
1034
|
+
return relativePath.split(path.sep).join("/");
|
|
1035
|
+
}
|
|
1036
|
+
return resolvedFilePath;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function storedSyncPathCandidates(syncDir, filePath) {
|
|
1040
|
+
if (!filePath) return [];
|
|
1041
|
+
if (path.isAbsolute(filePath)) return [path.resolve(filePath)];
|
|
1042
|
+
|
|
1043
|
+
const candidates = new Set([path.resolve(syncDir, filePath)]);
|
|
1044
|
+
const normalizedParts = filePath.split(/[\\/]+/).filter(Boolean);
|
|
1045
|
+
const anchor = normalizedParts[0];
|
|
1046
|
+
if (["projects", "universes", "scenes"].includes(anchor)) {
|
|
1047
|
+
const syncParts = path.resolve(syncDir).split(path.sep);
|
|
1048
|
+
for (let index = syncParts.length - 1; index >= 0; index--) {
|
|
1049
|
+
if (syncParts[index] !== anchor) continue;
|
|
1050
|
+
const rootParts = syncParts.slice(0, index);
|
|
1051
|
+
const root = rootParts.length ? rootParts.join(path.sep) : path.sep;
|
|
1052
|
+
candidates.add(path.resolve(root, filePath));
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return [...candidates];
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function collectMissingManagedRepresentationWarnings(db, syncDir, {
|
|
1060
|
+
observedChapterKeys,
|
|
1061
|
+
observedEpigraphKeys,
|
|
1062
|
+
}) {
|
|
1063
|
+
const projectScope = inferSceneProjectScopeFromSyncDir(syncDir);
|
|
1064
|
+
const chapterRows = projectScope
|
|
1065
|
+
? db.prepare(`SELECT chapter_id, project_id, source_path FROM chapters WHERE project_id = ?`).all(projectScope)
|
|
1066
|
+
: db.prepare(`SELECT chapter_id, project_id, source_path FROM chapters`).all();
|
|
1067
|
+
const epigraphRows = projectScope
|
|
1068
|
+
? db.prepare(`SELECT epigraph_id, project_id, file_path FROM epigraphs WHERE project_id = ?`).all(projectScope)
|
|
1069
|
+
: db.prepare(`SELECT epigraph_id, project_id, file_path FROM epigraphs`).all();
|
|
1070
|
+
const warnings = [];
|
|
1071
|
+
|
|
1072
|
+
for (const row of chapterRows) {
|
|
1073
|
+
const key = `${row.chapter_id}::${row.project_id}`;
|
|
1074
|
+
if (observedChapterKeys.has(key)) continue;
|
|
1075
|
+
const sourcePaths = storedSyncPathCandidates(syncDir, row.source_path);
|
|
1076
|
+
if (!sourcePaths.length || sourcePaths.some(candidate => fs.existsSync(candidate))) continue;
|
|
1077
|
+
warnings.push(`Managed sync preserved canonical chapter missing from filesystem: ${row.project_id}/${row.chapter_id} (${formatSyncRelativePath(syncDir, row.source_path)})`);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
for (const row of epigraphRows) {
|
|
1081
|
+
const key = `${row.epigraph_id}::${row.project_id}`;
|
|
1082
|
+
if (observedEpigraphKeys.has(key)) continue;
|
|
1083
|
+
const filePaths = storedSyncPathCandidates(syncDir, row.file_path);
|
|
1084
|
+
if (!filePaths.length || filePaths.some(candidate => fs.existsSync(candidate))) continue;
|
|
1085
|
+
warnings.push(`Managed sync preserved canonical epigraph missing from filesystem: ${row.project_id}/${row.epigraph_id} (${formatSyncRelativePath(syncDir, row.file_path)})`);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return warnings;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function formatStoredPathSuffix(syncDir, filePath) {
|
|
1092
|
+
const displayPath = formatSyncRelativePath(syncDir, filePath);
|
|
1093
|
+
return displayPath ? ` (${displayPath})` : "";
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function formatPreservedManagedSceneWarning(syncDir, scene) {
|
|
1097
|
+
const pathSuffix = formatStoredPathSuffix(syncDir, scene.file_path);
|
|
1098
|
+
const sceneLabel = `${scene.project_id}/${scene.scene_id}`;
|
|
1099
|
+
const filePaths = storedSyncPathCandidates(syncDir, scene.file_path);
|
|
1100
|
+
if (filePaths.some(candidate => fs.existsSync(candidate))) {
|
|
1101
|
+
return `Managed sync preserved canonical scene not observed during sync scan: ${sceneLabel}${pathSuffix}`;
|
|
1102
|
+
}
|
|
1103
|
+
return `Managed sync preserved canonical scene missing from filesystem: ${sceneLabel}${pathSuffix}`;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1016
1106
|
export function pruneSyncDerivedIndexes(db, syncDir, {
|
|
1017
1107
|
seenSceneKeys,
|
|
1018
1108
|
seenEpigraphKeys,
|
|
1019
1109
|
seenChapterKeys,
|
|
1020
1110
|
sceneIndexFailures,
|
|
1111
|
+
managedProjectIds = new Set(),
|
|
1021
1112
|
}) {
|
|
1022
1113
|
if (!canPruneScenes(syncDir)) return { pruned: false, reason: "scope_not_prunable" };
|
|
1023
1114
|
if (sceneIndexFailures !== 0) return { pruned: false, reason: "scene_index_failures" };
|
|
1024
1115
|
|
|
1025
|
-
pruneMissingScenes(db, seenSceneKeys, syncDir);
|
|
1116
|
+
const scenePrune = pruneMissingScenes(db, seenSceneKeys, syncDir, { managedProjectIds });
|
|
1026
1117
|
pruneMissingEpigraphs(db, seenEpigraphKeys, syncDir);
|
|
1027
1118
|
pruneMissingChapters(db, seenChapterKeys, syncDir);
|
|
1028
|
-
return { pruned: true, reason: null };
|
|
1119
|
+
return { pruned: true, reason: null, scenePrune };
|
|
1029
1120
|
}
|
|
1030
1121
|
|
|
1031
1122
|
export function regenerateReferenceAndWorldIndexes(db, syncDir, files, {
|
|
@@ -1449,6 +1540,10 @@ const WARNING_TYPE_LABELS = {
|
|
|
1449
1540
|
chapter_structure: "Chapter structure",
|
|
1450
1541
|
orphaned_sidecar: "Orphaned sidecar",
|
|
1451
1542
|
moved_scene: "Moved scene",
|
|
1543
|
+
missing_canonical_scene: "Missing canonical scene",
|
|
1544
|
+
unobserved_canonical_scene: "Unobserved canonical scene",
|
|
1545
|
+
missing_canonical_chapter: "Missing canonical chapter",
|
|
1546
|
+
missing_canonical_epigraph: "Missing canonical epigraph",
|
|
1452
1547
|
nested_mirror: "Ignored nested mirror path",
|
|
1453
1548
|
};
|
|
1454
1549
|
|
|
@@ -1459,6 +1554,10 @@ const WARNING_PATTERNS = [
|
|
|
1459
1554
|
{ type: "chapter_structure", re: /^(Chapter structure warning|Epigraph requires explicit chapter linkage|Epigraph references unknown chapter_id|Scene references unknown chapter_id|Ambiguous chapter linkage|Epigraph identity conflict|Managed structure sync ignored|Managed structure sync preserved canonical epigraph)/ },
|
|
1460
1555
|
{ type: "moved_scene", re: /^Moved scene detected:/ },
|
|
1461
1556
|
{ type: "orphaned_sidecar", re: /^Orphaned sidecar/ },
|
|
1557
|
+
{ type: "missing_canonical_scene", re: /^Managed sync preserved canonical scene missing from filesystem:/ },
|
|
1558
|
+
{ type: "unobserved_canonical_scene", re: /^Managed sync preserved canonical scene not observed during sync scan:/ },
|
|
1559
|
+
{ type: "missing_canonical_chapter", re: /^Managed sync preserved canonical chapter missing from filesystem:/ },
|
|
1560
|
+
{ type: "missing_canonical_epigraph", re: /^Managed sync preserved canonical epigraph missing from filesystem:/ },
|
|
1462
1561
|
{ type: "nested_mirror", re: /^Ignored nested mirror path:/ },
|
|
1463
1562
|
];
|
|
1464
1563
|
|
|
@@ -1511,6 +1610,8 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1511
1610
|
const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
|
|
1512
1611
|
const seenChapterKeys = new Set(managedChapterKeys);
|
|
1513
1612
|
const seenEpigraphKeys = new Set(managedEpigraphKeys);
|
|
1613
|
+
const observedChapterKeys = new Set();
|
|
1614
|
+
const observedEpigraphKeys = new Set();
|
|
1514
1615
|
let sceneIndexFailures = 0;
|
|
1515
1616
|
const warnings = [];
|
|
1516
1617
|
const chapterFoldersByProject = new Map();
|
|
@@ -1588,7 +1689,9 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1588
1689
|
warnings.push(result.warning);
|
|
1589
1690
|
}
|
|
1590
1691
|
if (result.chapterId) {
|
|
1591
|
-
|
|
1692
|
+
const chapterKey = `${result.chapterId}::${project_id}`;
|
|
1693
|
+
seenChapterKeys.add(chapterKey);
|
|
1694
|
+
observedChapterKeys.add(chapterKey);
|
|
1592
1695
|
}
|
|
1593
1696
|
if (chapterStructure.chapter && result.chapterId) {
|
|
1594
1697
|
const chapterMapKey = `${project_id}::${chapterStructure.chapter.sort_index}`;
|
|
@@ -1607,7 +1710,9 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1607
1710
|
if (result.skippedAsEpigraph) {
|
|
1608
1711
|
if (result.epigraphIndexed && result.epigraphId) {
|
|
1609
1712
|
const epigraphId = result.epigraphId;
|
|
1610
|
-
|
|
1713
|
+
const epigraphKey = `${epigraphId}::${project_id}`;
|
|
1714
|
+
seenEpigraphKeys.add(epigraphKey);
|
|
1715
|
+
observedEpigraphKeys.add(epigraphKey);
|
|
1611
1716
|
}
|
|
1612
1717
|
if (result.epigraphIndexed) {
|
|
1613
1718
|
epigraphsIndexed++;
|
|
@@ -1626,12 +1731,22 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
1626
1731
|
}
|
|
1627
1732
|
}
|
|
1628
1733
|
|
|
1629
|
-
pruneSyncDerivedIndexes(db, syncDir, {
|
|
1734
|
+
const pruneResult = pruneSyncDerivedIndexes(db, syncDir, {
|
|
1630
1735
|
seenSceneKeys,
|
|
1631
1736
|
seenEpigraphKeys,
|
|
1632
1737
|
seenChapterKeys,
|
|
1633
1738
|
sceneIndexFailures,
|
|
1739
|
+
managedProjectIds,
|
|
1634
1740
|
});
|
|
1741
|
+
for (const scene of pruneResult.scenePrune?.preservedManagedScenes ?? []) {
|
|
1742
|
+
warnings.push(formatPreservedManagedSceneWarning(syncDir, scene));
|
|
1743
|
+
}
|
|
1744
|
+
for (const warning of collectMissingManagedRepresentationWarnings(db, syncDir, {
|
|
1745
|
+
observedChapterKeys,
|
|
1746
|
+
observedEpigraphKeys,
|
|
1747
|
+
})) {
|
|
1748
|
+
warnings.push(warning);
|
|
1749
|
+
}
|
|
1635
1750
|
|
|
1636
1751
|
// --- Orphaned sidecar detection ---
|
|
1637
1752
|
for (const diagnostic of observeOrphanedSidecars(syncDir, { indexedSceneIds })) {
|
package/src/tools/sync.js
CHANGED
|
@@ -338,13 +338,16 @@ export function registerSyncTools(s, {
|
|
|
338
338
|
|
|
339
339
|
s.tool(
|
|
340
340
|
"restore_project_from_backup",
|
|
341
|
-
"
|
|
341
|
+
"Explicitly restore a project from a trusted generated project backup bundle. Defaults to dry-run planning; dry_run=false applies canonical SQLite changes transactionally after the reviewed current snapshot checksum and required destructive or cross-scope confirmations are provided.",
|
|
342
342
|
{
|
|
343
343
|
project_id: z.string().describe("Project ID to restore (e.g. 'test-novel' or 'universe-1/book-1-the-lamb')."),
|
|
344
344
|
backup_path: z.string().optional().describe("Path under WRITING_SYNC_DIR to a project backup directory, manifest.json, or canonical.snapshot.json. Defaults to project-backups/<project_id>."),
|
|
345
|
-
dry_run: z.boolean().optional().describe("If true (default), validate and summarize the restore plan without writing SQLite state.
|
|
345
|
+
dry_run: z.boolean().optional().describe("If true (default), validate and summarize the restore plan without writing SQLite state."),
|
|
346
|
+
confirm_destructive: z.boolean().optional().describe("Required with dry_run=false when the restore plan includes delete candidates."),
|
|
347
|
+
confirm_cross_scope: z.boolean().optional().describe("Required with dry_run=false when the restore plan changes universe-scoped records."),
|
|
348
|
+
expected_current_snapshot_checksum: z.string().optional().describe("Required with dry_run=false; pass the current_snapshot_checksum returned by the reviewed dry-run plan to guard against state changes before apply."),
|
|
346
349
|
},
|
|
347
|
-
async ({ project_id, backup_path, dry_run = true } = {}) => {
|
|
350
|
+
async ({ project_id, backup_path, dry_run = true, confirm_destructive = false, confirm_cross_scope = false, expected_current_snapshot_checksum = null } = {}) => {
|
|
348
351
|
if (!SYNC_DIR_WRITABLE && dry_run === false) {
|
|
349
352
|
return errorResponse("READ_ONLY", "Cannot restore project from backup: server is in read-only mode for canonical structure mutations.");
|
|
350
353
|
}
|
|
@@ -368,6 +371,9 @@ export function registerSyncTools(s, {
|
|
|
368
371
|
projectId: project_id,
|
|
369
372
|
backupPath: requestedBackupPath,
|
|
370
373
|
dryRun: dry_run,
|
|
374
|
+
confirmDestructive: confirm_destructive,
|
|
375
|
+
confirmCrossScope: confirm_cross_scope,
|
|
376
|
+
expectedCurrentSnapshotChecksum: expected_current_snapshot_checksum,
|
|
371
377
|
applicationVersion: MCP_SERVER_VERSION,
|
|
372
378
|
}));
|
|
373
379
|
} catch (error) {
|
|
@@ -95,6 +95,19 @@ export const WORKFLOW_CATALOGUE = [
|
|
|
95
95
|
{ tool: "export_project_backup", note: "Generate or repair the broader deterministic project backup bundle for Git review and future recovery input; editing it does not mutate canonical state." },
|
|
96
96
|
],
|
|
97
97
|
},
|
|
98
|
+
{
|
|
99
|
+
id: "backup_recovery",
|
|
100
|
+
label: "Verify or restore project backups",
|
|
101
|
+
use_when: "Use when the user asks about backup freshness, generated project-backups files, database loss, recovery readiness, or restoring SQLite-canonical project state.",
|
|
102
|
+
steps: [
|
|
103
|
+
{ tool: "diagnose_project_backups", note: "Start here to verify whether the bundle is missing, stale, tampered, incompatible, partial, or current. This never mutates SQLite or generated files." },
|
|
104
|
+
{ tool: "export_project_backup", note: "Generate or refresh the deterministic project backup bundle when diagnostics report missing or stale backup artifacts." },
|
|
105
|
+
{ tool: "restore_project_from_backup", note: "Use dry_run=true first to validate the trusted bundle and inspect create/update/delete/unchanged and cross_scope changes before applying anything." },
|
|
106
|
+
{ tool: "restore_project_from_backup", note: "Apply only after the dry-run plan is reviewed. Pass dry_run=false with expected_current_snapshot_checksum from that dry run, confirm_destructive=true for deletes, and confirm_cross_scope=true for universe-scoped changes." },
|
|
107
|
+
{ tool: "sync", note: "Run after a successful restore to regenerate derived indexes and confirm ordinary read workflows see the restored state." },
|
|
108
|
+
{ tool: "diagnose_project_backups", note: "Re-check backup health after restore, then run export_project_backup if generated transparency needs refreshing." },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
98
111
|
{
|
|
99
112
|
id: "review_preparation",
|
|
100
113
|
label: "Prepare material for human review",
|