@hanna84/mcp-writing 3.18.1 → 3.19.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.
package/CHANGELOG.md CHANGED
@@ -4,9 +4,16 @@ 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.19.0](https://github.com/hannasdev/mcp-writing/compare/v3.18.1...v3.19.0)
8
+
9
+ - feat: add project database backup foundation [`#219`](https://github.com/hannasdev/mcp-writing/pull/219)
10
+
7
11
  #### [v3.18.1](https://github.com/hannasdev/mcp-writing/compare/v3.18.0...v3.18.1)
8
12
 
13
+ > 23 May 2026
14
+
9
15
  - docs(product): activate database backup initiative [`#218`](https://github.com/hannasdev/mcp-writing/pull/218)
16
+ - Release 3.18.1 [`1a04eb9`](https://github.com/hannasdev/mcp-writing/commit/1a04eb905f20c54e5712300aa15bd1e837bc9ba4)
10
17
 
11
18
  #### [v3.18.0](https://github.com/hannasdev/mcp-writing/compare/v3.17.2...v3.18.0)
12
19
 
package/README.md CHANGED
@@ -30,7 +30,7 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
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
31
  - **Recently completed:** Docker, CI, and Deployment Workflow made Docker a supported way to build, run, smoke-test, and deploy Writing MCP.
32
32
  - **Previous milestone:** Filesystem Boundary Hardening centralized local file mutation through application-aware helpers and lint guardrails.
33
- - **Active development:** Database Backup and Recovery is defining deterministic Git-reviewable backup artifacts for SQLite-canonical project state.
33
+ - **Active development:** Database Backup and Recovery now has project backup export, freshness diagnostics, advisory operation history, and automatic backup refresh after sanctioned project-scoped canonical mutations; the next slice focuses on dry-run restore planning.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.18.1",
3
+ "version": "3.19.0",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -454,6 +454,18 @@ export function writeGeneratedOutputFile(filePath, data, {
454
454
  }
455
455
  }
456
456
 
457
+ export function appendGeneratedOutputFile(filePath, data, {
458
+ encoding,
459
+ errorCode = "INVALID_OUTPUT_PATH",
460
+ } = {}) {
461
+ assertRegularFileWriteTarget(filePath, { errorCode });
462
+ if (encoding) {
463
+ fs.appendFileSync(filePath, data, encoding);
464
+ } else {
465
+ fs.appendFileSync(filePath, data);
466
+ }
467
+ }
468
+
457
469
  export function copyFileInsideBoundary(sourcePath, targetPath, {
458
470
  sourceBoundaryRoot,
459
471
  sourceBoundaryRootReal = sourceBoundaryRoot,
package/src/index.js CHANGED
@@ -469,6 +469,7 @@ function createMcpServer() {
469
469
  SYNC_DIR_ABS,
470
470
  SYNC_DIR_REAL,
471
471
  SYNC_DIR_WRITABLE,
472
+ MCP_SERVER_VERSION,
472
473
  GIT_ENABLED,
473
474
  asyncJobs,
474
475
  errorResponse,
@@ -0,0 +1,346 @@
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
+
10
+ const MANIFEST_FILE = "manifest.json";
11
+ const SNAPSHOT_FILE = "canonical.snapshot.json";
12
+
13
+ function countBy(items, key) {
14
+ const result = {};
15
+ for (const item of items) {
16
+ const value = item[key] ?? "unknown";
17
+ result[value] = (result[value] ?? 0) + 1;
18
+ }
19
+ return result;
20
+ }
21
+
22
+ function normalizeRelativePath(syncDir, filePath) {
23
+ return path.relative(syncDir, filePath).split(path.sep).filter(Boolean).join("/");
24
+ }
25
+
26
+ function createDiagnostic(type, message, details = {}, {
27
+ severity = "warning",
28
+ nextStep = null,
29
+ } = {}) {
30
+ return {
31
+ type,
32
+ severity,
33
+ message,
34
+ details,
35
+ ...(nextStep ? { next_step: nextStep } : {}),
36
+ };
37
+ }
38
+
39
+ function addDiagnostic(diagnostics, type, message, details = {}, options = {}) {
40
+ diagnostics.push(createDiagnostic(type, message, details, options));
41
+ }
42
+
43
+ function fileState(filePath) {
44
+ if (!fs.existsSync(filePath)) {
45
+ return { exists: false, readable: false, regular: false, symlink: false };
46
+ }
47
+ let stat;
48
+ try {
49
+ stat = fs.lstatSync(filePath);
50
+ } catch (error) {
51
+ return {
52
+ exists: true,
53
+ readable: false,
54
+ regular: false,
55
+ symlink: false,
56
+ error: "lstat_failed",
57
+ message: error instanceof Error ? error.message : String(error),
58
+ };
59
+ }
60
+ return {
61
+ exists: true,
62
+ readable: true,
63
+ regular: stat.isFile(),
64
+ symlink: stat.isSymbolicLink(),
65
+ };
66
+ }
67
+
68
+ function readJsonFile(filePath) {
69
+ const state = fileState(filePath);
70
+ if (!state.exists) {
71
+ return { ok: false, state, error: "missing" };
72
+ }
73
+ if (state.symlink || !state.regular) {
74
+ return { ok: false, state, error: state.error ?? (state.symlink ? "symlink" : "not_regular"), message: state.message };
75
+ }
76
+ try {
77
+ return {
78
+ ok: true,
79
+ state,
80
+ value: JSON.parse(fs.readFileSync(filePath, "utf8")),
81
+ };
82
+ } catch (error) {
83
+ return {
84
+ ok: false,
85
+ state,
86
+ error: "unreadable_json",
87
+ message: error instanceof Error ? error.message : String(error),
88
+ };
89
+ }
90
+ }
91
+
92
+ function statusFromDiagnostics(diagnostics) {
93
+ if (diagnostics.some(diagnostic => diagnostic.type === "project_backup_stale")) return "stale";
94
+ if (diagnostics.length) return "untrusted";
95
+ return "current";
96
+ }
97
+
98
+ export function runProjectBackupDiagnostics(db, {
99
+ syncDir,
100
+ backupDir = null,
101
+ projectId,
102
+ applicationVersion = "0.0.0",
103
+ } = {}) {
104
+ const diagnostics = [];
105
+ const resolvedBackupDir = path.resolve(backupDir ?? path.join(syncDir, "project-backups", projectId));
106
+ const relativeBackupDir = normalizeRelativePath(syncDir, resolvedBackupDir);
107
+ const backupLocation = relativeBackupDir ? `${relativeBackupDir}/` : "./";
108
+ const manifestPath = path.join(resolvedBackupDir, MANIFEST_FILE);
109
+ const snapshotPath = path.join(resolvedBackupDir, SNAPSHOT_FILE);
110
+ const manifestRead = readJsonFile(manifestPath);
111
+ const snapshotRead = readJsonFile(snapshotPath);
112
+
113
+ if (!manifestRead.state.exists && !snapshotRead.state.exists) {
114
+ addDiagnostic(
115
+ diagnostics,
116
+ "project_backup_missing",
117
+ `Project backup for "${projectId}" is missing.`,
118
+ {
119
+ project_id: projectId,
120
+ backup_dir: resolvedBackupDir,
121
+ relative_backup_dir: relativeBackupDir,
122
+ },
123
+ { nextStep: "Run export_project_backup for this project, then review or commit the generated backup bundle." }
124
+ );
125
+ } else if (!manifestRead.state.exists || !snapshotRead.state.exists) {
126
+ addDiagnostic(
127
+ diagnostics,
128
+ "project_backup_partial",
129
+ `Project backup for "${projectId}" is incomplete.`,
130
+ {
131
+ project_id: projectId,
132
+ backup_dir: resolvedBackupDir,
133
+ manifest_exists: manifestRead.state.exists,
134
+ canonical_snapshot_exists: snapshotRead.state.exists,
135
+ },
136
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
137
+ );
138
+ }
139
+
140
+ for (const [label, readResult, filePath] of [
141
+ ["manifest", manifestRead, manifestPath],
142
+ ["canonical_snapshot", snapshotRead, snapshotPath],
143
+ ]) {
144
+ if (!readResult.state.exists) continue;
145
+ if (!readResult.ok) {
146
+ addDiagnostic(
147
+ diagnostics,
148
+ "project_backup_unreadable",
149
+ `Project backup ${label} is not readable as trusted JSON.`,
150
+ {
151
+ project_id: projectId,
152
+ backup_dir: resolvedBackupDir,
153
+ file: filePath,
154
+ reason: readResult.error,
155
+ message: readResult.message ?? null,
156
+ },
157
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
158
+ );
159
+ }
160
+ }
161
+
162
+ let built;
163
+ try {
164
+ built = buildProjectBackup(db, {
165
+ projectId,
166
+ syncDir,
167
+ applicationVersion,
168
+ backupLocation,
169
+ });
170
+ } catch (error) {
171
+ built = {
172
+ ok: false,
173
+ error: {
174
+ message: error instanceof Error ? error.message : String(error),
175
+ details: {
176
+ project_id: projectId,
177
+ backup_dir: resolvedBackupDir,
178
+ phase: "current_snapshot",
179
+ },
180
+ },
181
+ };
182
+ }
183
+ if (!built.ok) {
184
+ addDiagnostic(
185
+ diagnostics,
186
+ "project_backup_current_snapshot_failed",
187
+ built.error.message,
188
+ built.error.details,
189
+ { severity: "error", nextStep: "Confirm the project_id exists before diagnosing its backup bundle." }
190
+ );
191
+ }
192
+
193
+ const manifest = manifestRead.ok ? manifestRead.value : null;
194
+ const snapshot = snapshotRead.ok ? snapshotRead.value : null;
195
+
196
+ if (manifest) {
197
+ if (manifest.artifact_kind !== "project_backup") {
198
+ addDiagnostic(
199
+ diagnostics,
200
+ "project_backup_wrong_artifact",
201
+ `Backup manifest for "${projectId}" is not a project backup artifact.`,
202
+ {
203
+ project_id: projectId,
204
+ backup_dir: resolvedBackupDir,
205
+ artifact_kind: manifest.artifact_kind ?? null,
206
+ },
207
+ { nextStep: "Regenerate the backup with export_project_backup." }
208
+ );
209
+ }
210
+ if (manifest.project_id !== projectId) {
211
+ addDiagnostic(
212
+ diagnostics,
213
+ "project_backup_wrong_project",
214
+ `Backup manifest belongs to project "${manifest.project_id ?? "unknown"}", not "${projectId}".`,
215
+ {
216
+ project_id: projectId,
217
+ backup_dir: resolvedBackupDir,
218
+ manifest_project_id: manifest.project_id ?? null,
219
+ },
220
+ { nextStep: "Choose the backup directory for the requested project or regenerate the backup." }
221
+ );
222
+ }
223
+ if (manifest.schema_version !== PROJECT_BACKUP_SCHEMA_VERSION) {
224
+ addDiagnostic(
225
+ diagnostics,
226
+ "project_backup_incompatible_schema",
227
+ `Backup manifest schema version "${manifest.schema_version ?? "unknown"}" is not compatible with this server.`,
228
+ {
229
+ project_id: projectId,
230
+ backup_dir: resolvedBackupDir,
231
+ backup_schema_version: manifest.schema_version ?? null,
232
+ expected_schema_version: PROJECT_BACKUP_SCHEMA_VERSION,
233
+ },
234
+ { nextStep: "Regenerate the backup with a compatible server version before using it for recovery." }
235
+ );
236
+ }
237
+ }
238
+
239
+ if (snapshot && snapshot.project?.project_id !== projectId) {
240
+ addDiagnostic(
241
+ diagnostics,
242
+ "project_backup_wrong_project",
243
+ `Backup snapshot belongs to project "${snapshot.project?.project_id ?? "unknown"}", not "${projectId}".`,
244
+ {
245
+ project_id: projectId,
246
+ backup_dir: resolvedBackupDir,
247
+ snapshot_project_id: snapshot.project?.project_id ?? null,
248
+ },
249
+ { nextStep: "Choose the backup directory for the requested project or regenerate the backup." }
250
+ );
251
+ }
252
+
253
+ if (manifest && snapshot) {
254
+ const exportedSnapshotChecksum = manifest.checksums?.canonical_snapshot_sha256 ?? null;
255
+ const computedSnapshotChecksum = computeProjectBackupSnapshotChecksum(snapshot);
256
+ if (!exportedSnapshotChecksum || exportedSnapshotChecksum !== computedSnapshotChecksum) {
257
+ addDiagnostic(
258
+ diagnostics,
259
+ "project_backup_checksum_mismatch",
260
+ `Project backup snapshot checksum does not match manifest for "${projectId}".`,
261
+ {
262
+ project_id: projectId,
263
+ backup_dir: resolvedBackupDir,
264
+ exported_checksum: exportedSnapshotChecksum,
265
+ computed_checksum: computedSnapshotChecksum,
266
+ },
267
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
268
+ );
269
+ }
270
+
271
+ const exportedBundleChecksum = manifest.checksums?.bundle_sha256 ?? null;
272
+ const computedBundleChecksum = computeProjectBackupBundleChecksum({ manifest, snapshot });
273
+ if (!exportedBundleChecksum || exportedBundleChecksum !== computedBundleChecksum) {
274
+ addDiagnostic(
275
+ diagnostics,
276
+ "project_backup_bundle_checksum_mismatch",
277
+ `Project backup bundle checksum does not match manifest for "${projectId}".`,
278
+ {
279
+ project_id: projectId,
280
+ backup_dir: resolvedBackupDir,
281
+ exported_checksum: exportedBundleChecksum,
282
+ computed_checksum: computedBundleChecksum,
283
+ },
284
+ { nextStep: "Regenerate the backup with export_project_backup before using it for recovery." }
285
+ );
286
+ }
287
+
288
+ const canCheckFreshness = diagnostics.length === 0;
289
+ if (canCheckFreshness && built.ok && exportedSnapshotChecksum === computedSnapshotChecksum) {
290
+ const currentChecksum = built.manifest.checksums.canonical_snapshot_sha256;
291
+ if (exportedSnapshotChecksum !== currentChecksum) {
292
+ addDiagnostic(
293
+ diagnostics,
294
+ "project_backup_stale",
295
+ `Project backup for "${projectId}" is stale relative to current SQLite canonical state.`,
296
+ {
297
+ project_id: projectId,
298
+ backup_dir: resolvedBackupDir,
299
+ exported_checksum: exportedSnapshotChecksum,
300
+ current_checksum: currentChecksum,
301
+ },
302
+ { nextStep: "Regenerate the backup with export_project_backup, then review the Git diff." }
303
+ );
304
+ }
305
+ }
306
+ }
307
+
308
+ diagnostics.sort((a, b) => {
309
+ const typeCompare = a.type.localeCompare(b.type);
310
+ if (typeCompare) return typeCompare;
311
+ return a.message.localeCompare(b.message);
312
+ });
313
+
314
+ const status = statusFromDiagnostics(diagnostics);
315
+ return {
316
+ ok: diagnostics.length === 0,
317
+ checked: {
318
+ project_id: projectId,
319
+ backup_dir: resolvedBackupDir,
320
+ relative_backup_dir: relativeBackupDir,
321
+ files: {
322
+ manifest: manifestPath,
323
+ canonical_snapshot: snapshotPath,
324
+ },
325
+ manifest_exists: manifestRead.state.exists,
326
+ canonical_snapshot_exists: snapshotRead.state.exists,
327
+ },
328
+ trust: {
329
+ trusted: diagnostics.length === 0,
330
+ status,
331
+ freshness: status === "current" ? "current" : status === "stale" ? "stale" : "unknown",
332
+ schema_version: manifest?.schema_version ?? null,
333
+ backup_location: manifest?.backup_location ?? null,
334
+ checksums: manifest?.checksums ?? null,
335
+ },
336
+ summary: {
337
+ total: diagnostics.length,
338
+ by_type: countBy(diagnostics, "type"),
339
+ by_severity: countBy(diagnostics, "severity"),
340
+ },
341
+ diagnostics,
342
+ next_steps: diagnostics.length
343
+ ? ["Follow diagnostic next_step guidance before treating this backup as recovery input."]
344
+ : ["Project backup is trusted and current relative to SQLite canonical state."],
345
+ };
346
+ }
@@ -0,0 +1,116 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ appendGeneratedOutputFile,
5
+ assertRegularFileWriteTarget,
6
+ ensureDirectoryInsideBoundary,
7
+ resolveGeneratedOutputPath,
8
+ writeGeneratedOutputFile,
9
+ } from "../core/filesystem-boundary.js";
10
+ import { CURRENT_SCHEMA_VERSION } from "../core/db.js";
11
+
12
+ export const PROJECT_BACKUP_OPERATION_SCHEMA_VERSION = 1;
13
+ export const PROJECT_BACKUP_OPERATION_LOG_FILE = "operations.jsonl";
14
+ const PROJECT_BACKUP_SCHEMA_VERSION = 1;
15
+
16
+ function stableStringify(value, indent = 0) {
17
+ const seen = new WeakSet();
18
+ function normalize(input) {
19
+ if (input === null || typeof input !== "object") return input;
20
+ if (seen.has(input)) {
21
+ throw new TypeError("Cannot stable-stringify circular structure.");
22
+ }
23
+ seen.add(input);
24
+ if (Array.isArray(input)) {
25
+ const array = input.map(normalize);
26
+ seen.delete(input);
27
+ return array;
28
+ }
29
+ const object = {};
30
+ for (const key of Object.keys(input).sort()) {
31
+ object[key] = normalize(input[key]);
32
+ }
33
+ seen.delete(input);
34
+ return object;
35
+ }
36
+
37
+ return JSON.stringify(normalize(value), null, indent);
38
+ }
39
+
40
+ export function buildProjectBackupOperationRecord({
41
+ operation,
42
+ projectId,
43
+ affected = {},
44
+ timestamp = new Date().toISOString(),
45
+ actor = null,
46
+ before = null,
47
+ after = null,
48
+ summary = null,
49
+ applicationVersion = "0.0.0",
50
+ metadata = {},
51
+ } = {}) {
52
+ if (!operation || typeof operation !== "string") {
53
+ throw new TypeError("operation is required.");
54
+ }
55
+ if (!projectId || typeof projectId !== "string") {
56
+ throw new TypeError("projectId is required.");
57
+ }
58
+
59
+ return {
60
+ artifact_kind: "project_backup_operation",
61
+ schema_version: PROJECT_BACKUP_OPERATION_SCHEMA_VERSION,
62
+ backup_schema_version: PROJECT_BACKUP_SCHEMA_VERSION,
63
+ project_id: projectId,
64
+ operation,
65
+ timestamp,
66
+ actor,
67
+ affected,
68
+ summary,
69
+ before,
70
+ after,
71
+ metadata,
72
+ advisory: true,
73
+ restore_authority: false,
74
+ compatibility: {
75
+ application_version: applicationVersion,
76
+ current_sqlite_schema_version: CURRENT_SCHEMA_VERSION,
77
+ },
78
+ };
79
+ }
80
+
81
+ export function renderProjectBackupOperationRecord(record) {
82
+ return `${stableStringify(record, 0)}\n`;
83
+ }
84
+
85
+ export function ensureProjectBackupOperationLog({ outputDir }) {
86
+ const normalizedOutputDir = path.resolve(outputDir);
87
+ ensureDirectoryInsideBoundary(normalizedOutputDir, { label: "backup output_dir" });
88
+ const operationLogPath = resolveGeneratedOutputPath(normalizedOutputDir, PROJECT_BACKUP_OPERATION_LOG_FILE);
89
+ assertRegularFileWriteTarget(operationLogPath);
90
+
91
+ if (fs.existsSync(operationLogPath)) {
92
+ return {
93
+ operationLogPath,
94
+ written: false,
95
+ };
96
+ }
97
+
98
+ writeGeneratedOutputFile(operationLogPath, "", { encoding: "utf8" });
99
+ return {
100
+ operationLogPath,
101
+ written: true,
102
+ };
103
+ }
104
+
105
+ export function appendProjectBackupOperationRecord(record, { outputDir }) {
106
+ const { operationLogPath } = ensureProjectBackupOperationLog({ outputDir });
107
+ appendGeneratedOutputFile(
108
+ operationLogPath,
109
+ renderProjectBackupOperationRecord(record),
110
+ { encoding: "utf8" }
111
+ );
112
+ return {
113
+ operationLogPath,
114
+ appended: true,
115
+ };
116
+ }
@@ -0,0 +1,160 @@
1
+ import path from "node:path";
2
+ import { validateProjectId } from "../sync/importer.js";
3
+ import { buildProjectBackup, writeProjectBackupFiles } from "./project-backup.js";
4
+ import {
5
+ appendProjectBackupOperationRecord,
6
+ buildProjectBackupOperationRecord,
7
+ } from "./project-backup-operations.js";
8
+
9
+ function relativePath(syncDirAbs, filePath) {
10
+ return path.relative(syncDirAbs, filePath).split(path.sep).filter(Boolean).join("/");
11
+ }
12
+
13
+ function createBackupWarning(code, message, details = {}) {
14
+ return {
15
+ code,
16
+ severity: "warning",
17
+ message,
18
+ details,
19
+ };
20
+ }
21
+
22
+ export function createToolActor(id) {
23
+ return {
24
+ type: "tool",
25
+ id,
26
+ };
27
+ }
28
+
29
+ export function buildPostMutationBackupWarning({
30
+ projectId,
31
+ operation,
32
+ phase,
33
+ error,
34
+ details = {},
35
+ }) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ return createBackupWarning(
38
+ phase === "operation_history"
39
+ ? "OPERATION_LOG_APPEND_FAILED"
40
+ : "PROJECT_BACKUP_REFRESH_FAILED",
41
+ phase === "operation_history"
42
+ ? `Canonical mutation '${operation}' succeeded, but the advisory backup operation log could not be updated: ${message}`
43
+ : `Canonical mutation '${operation}' succeeded, but generated project backup artifacts could not be refreshed: ${message}`,
44
+ {
45
+ project_id: projectId,
46
+ operation,
47
+ phase,
48
+ ...details,
49
+ }
50
+ );
51
+ }
52
+
53
+ export function refreshProjectBackupAfterMutation(db, {
54
+ syncDir,
55
+ projectId,
56
+ applicationVersion = "0.0.0",
57
+ operation,
58
+ actor = null,
59
+ affected = {},
60
+ before = null,
61
+ after = null,
62
+ summary = null,
63
+ metadata = {},
64
+ timestamp,
65
+ buildBackup = buildProjectBackup,
66
+ writeBackup = writeProjectBackupFiles,
67
+ appendOperation = appendProjectBackupOperationRecord,
68
+ } = {}) {
69
+ if (!syncDir) throw new TypeError("syncDir is required.");
70
+ if (!projectId) throw new TypeError("projectId is required.");
71
+ if (!operation) throw new TypeError("operation is required.");
72
+ const projectIdCheck = validateProjectId(projectId);
73
+ if (!projectIdCheck.ok) {
74
+ throw new TypeError(projectIdCheck.reason);
75
+ }
76
+
77
+ const syncDirAbs = path.resolve(syncDir);
78
+ const outputDir = path.join(syncDirAbs, "project-backups", projectId);
79
+ const relativeOutputDir = relativePath(syncDirAbs, outputDir);
80
+ const backupLocation = relativeOutputDir ? `${relativeOutputDir}/` : "./";
81
+ const warnings = [];
82
+
83
+ let operationHistory = null;
84
+ const operationRecord = buildProjectBackupOperationRecord({
85
+ operation,
86
+ projectId,
87
+ timestamp,
88
+ actor,
89
+ affected,
90
+ before,
91
+ after,
92
+ summary,
93
+ applicationVersion,
94
+ metadata,
95
+ });
96
+
97
+ try {
98
+ const appended = appendOperation(operationRecord, { outputDir });
99
+ operationHistory = {
100
+ appended: appended.appended,
101
+ path: appended.operationLogPath,
102
+ relative_path: relativePath(syncDirAbs, appended.operationLogPath),
103
+ advisory: true,
104
+ restore_authority: false,
105
+ };
106
+ } catch (error) {
107
+ warnings.push(buildPostMutationBackupWarning({
108
+ projectId,
109
+ operation,
110
+ phase: "operation_history",
111
+ error,
112
+ }));
113
+ }
114
+
115
+ let backupRefresh = null;
116
+ try {
117
+ const built = buildBackup(db, {
118
+ projectId,
119
+ syncDir: syncDirAbs,
120
+ applicationVersion,
121
+ backupLocation,
122
+ });
123
+ if (!built.ok) {
124
+ throw new Error(built.error?.message ?? "Project backup could not be built.");
125
+ }
126
+ const written = writeBackup(built, { outputDir });
127
+ backupRefresh = {
128
+ ok: true,
129
+ output_dir: outputDir,
130
+ relative_output_dir: relativeOutputDir,
131
+ files: {
132
+ manifest: written.manifestPath,
133
+ canonical_snapshot: written.snapshotPath,
134
+ operations: written.operationLogPath,
135
+ },
136
+ relative_files: {
137
+ manifest: relativePath(syncDirAbs, written.manifestPath),
138
+ canonical_snapshot: relativePath(syncDirAbs, written.snapshotPath),
139
+ operations: relativePath(syncDirAbs, written.operationLogPath),
140
+ },
141
+ written: written.written,
142
+ generated_transparency: true,
143
+ restore_authority: "manifest_and_canonical_snapshot",
144
+ git_commit_created: false,
145
+ };
146
+ } catch (error) {
147
+ warnings.push(buildPostMutationBackupWarning({
148
+ projectId,
149
+ operation,
150
+ phase: "backup_refresh",
151
+ error,
152
+ }));
153
+ }
154
+
155
+ return {
156
+ operation_history: operationHistory,
157
+ backup_refresh: backupRefresh,
158
+ backup_warnings: warnings,
159
+ };
160
+ }