@summerscar/dsh-mnemon-sync 0.0.0-stage → 0.1.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/snapshot.js ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Mnemon Sync — snapshot building.
3
+ *
4
+ * A backup is built from up to three *components*, the same three dsh-mnemon
5
+ * itself groups its own Pack by:
6
+ *
7
+ * - `memory-spaces` — the `<MNEMON_DATA_DIR>/data` directory: one SQLite
8
+ * database per space (`<id>/mnemon.db`) plus the space registry.
9
+ * - `documents` — `<MNEMON_DATA_DIR>/documents`: `index.json` and the
10
+ * active / archived Markdown files it points at.
11
+ * - `runtime` — `<MNEMON_DATA_DIR>/runtime`: `memories.json` and the two
12
+ * projections (`USER.md`, `MEMORY.md`) generated from it.
13
+ *
14
+ * Any subset may be selected, which is why the snapshot records exactly which
15
+ * components it holds: a restore can then say "this archive has no documents"
16
+ * instead of silently restoring a subset and calling it complete.
17
+ *
18
+ * Copying `mnemon.db` with `cp` while the store is live produces torn pages:
19
+ * the database runs in WAL mode, so the newest committed rows live in
20
+ * `mnemon.db-wal` until a checkpoint folds them back. `node:sqlite`'s
21
+ * `backup()` reads through SQLite itself, which is the only copy that stays
22
+ * consistent under concurrent writes. Files that are not databases are copied
23
+ * verbatim: they are small JSON and Markdown documents written atomically by
24
+ * the store.
25
+ */
26
+
27
+ import { createHash } from 'node:crypto'
28
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
29
+ import { dirname, join } from 'node:path'
30
+
31
+ /** Memory space directories are validated with the store's own rule. */
32
+ export const MEMORY_SPACE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/
33
+ export const MEMORY_SPACES_DIRECTORY = 'data'
34
+ export const MEMORY_SPACE_DATABASE_FILE = 'mnemon.db'
35
+ export const MEMORY_SPACE_REGISTRY_FILE = '.dsh-memory-bodies.json'
36
+ export const DOCUMENTS_DIRECTORY = 'documents'
37
+ export const DOCUMENTS_INDEX_FILE = 'index.json'
38
+ export const RUNTIME_DIRECTORY = 'runtime'
39
+ export const RUNTIME_MEMORIES_FILE = 'memories.json'
40
+ export const RUNTIME_PROJECTION_FILES = { user: 'USER.md', memory: 'MEMORY.md' }
41
+
42
+ /** The components a backup may contain, in the order dsh-mnemon lists them. */
43
+ export const COMPONENTS = ['memory-spaces', 'documents', 'runtime']
44
+
45
+ /** Files inside a space directory that are regenerable and never worth carrying. */
46
+ const VOLATILE_SUFFIXES = ['-wal', '-shm', '-journal']
47
+
48
+ export function isComponent(value) {
49
+ return COMPONENTS.includes(value)
50
+ }
51
+
52
+ /** Normalise a caller's selection to a stable, ordered, de-duplicated list. */
53
+ export function normalizeSelection(selection) {
54
+ const requested = Array.isArray(selection) ? selection : COMPONENTS
55
+ const kept = requested.filter((value) => isComponent(value))
56
+ return COMPONENTS.filter((component) => kept.includes(component))
57
+ }
58
+
59
+ /**
60
+ * Report what a Mnemon home currently holds, per component, so the page can
61
+ * show each with its own count and size before a backup runs.
62
+ *
63
+ * @param {string} home absolute path of `<MNEMON_DATA_DIR>`
64
+ */
65
+ export function inspectHome(home) {
66
+ const dataRoot = join(home, MEMORY_SPACES_DIRECTORY)
67
+ const documentsRoot = join(home, DOCUMENTS_DIRECTORY)
68
+ const runtimeRoot = join(home, RUNTIME_DIRECTORY)
69
+
70
+ const spaces = []
71
+ const extras = []
72
+ if (existsSync(dataRoot)) {
73
+ for (const entry of readdirSync(dataRoot, { withFileTypes: true })) {
74
+ const full = join(dataRoot, entry.name)
75
+ if (entry.isFile()) {
76
+ extras.push({ name: entry.name, bytes: safeSize(full) })
77
+ continue
78
+ }
79
+ if (!entry.isDirectory()) continue
80
+ if (!MEMORY_SPACE_ID_PATTERN.test(entry.name)) continue
81
+ const database = join(full, MEMORY_SPACE_DATABASE_FILE)
82
+ if (!existsSync(database)) continue
83
+ spaces.push({
84
+ id: entry.name,
85
+ database,
86
+ bytes: safeSize(database),
87
+ walBytes: safeSize(`${database}-wal`),
88
+ files: listRegularFiles(full),
89
+ })
90
+ }
91
+ spaces.sort((left, right) => left.id.localeCompare(right.id))
92
+ }
93
+
94
+ const documents = existsSync(documentsRoot)
95
+ ? {
96
+ indexBytes: safeSize(join(documentsRoot, DOCUMENTS_INDEX_FILE)),
97
+ files: collectRelativeFiles(documentsRoot),
98
+ }
99
+ : { indexBytes: 0, files: [] }
100
+
101
+ const runtimeFiles = existsSync(runtimeRoot) ? collectRelativeFiles(runtimeRoot) : []
102
+ const runtime = {
103
+ memoriesBytes: safeSize(join(runtimeRoot, RUNTIME_MEMORIES_FILE)),
104
+ files: runtimeFiles,
105
+ }
106
+
107
+ return {
108
+ home,
109
+ components: {
110
+ 'memory-spaces': {
111
+ available: spaces.length > 0,
112
+ spaces,
113
+ extras,
114
+ totalBytes: spaces.reduce((total, space) => total + space.bytes, 0),
115
+ },
116
+ documents: {
117
+ available: documents.files.length > 0,
118
+ files: documents.files,
119
+ totalBytes: documents.files.reduce((total, file) => total + file.bytes, 0),
120
+ },
121
+ runtime: {
122
+ available: runtime.files.length > 0,
123
+ files: runtime.files,
124
+ totalBytes: runtime.files.reduce((total, file) => total + file.bytes, 0),
125
+ },
126
+ },
127
+ }
128
+ }
129
+
130
+ /** Backwards-compatible view of just the memory-space part. */
131
+ export function inspectDataDirectory(dataRoot) {
132
+ const home = dirname(dataRoot)
133
+ const inspected = inspectHome(home)
134
+ const spaces = inspected.components['memory-spaces'].spaces
135
+ return {
136
+ exists: spaces.length > 0,
137
+ root: dataRoot,
138
+ spaces,
139
+ extras: inspected.components['memory-spaces'].extras,
140
+ totalBytes: inspected.components['memory-spaces'].totalBytes,
141
+ }
142
+ }
143
+
144
+ function listRegularFiles(directory) {
145
+ const files = []
146
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
147
+ if (!entry.isFile()) continue
148
+ if (VOLATILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) continue
149
+ files.push(entry.name)
150
+ }
151
+ return files.sort()
152
+ }
153
+
154
+ /** Every regular file under `root`, as `{ relative, bytes }`, sorted. */
155
+ function collectRelativeFiles(root, prefix = '') {
156
+ const files = []
157
+ for (const entry of readdirSync(join(root, prefix), { withFileTypes: true })) {
158
+ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`
159
+ if (entry.isDirectory()) {
160
+ files.push(...collectRelativeFiles(root, relative))
161
+ continue
162
+ }
163
+ if (!entry.isFile()) continue
164
+ files.push({ relative, bytes: safeSize(join(root, relative)) })
165
+ }
166
+ return files.sort((left, right) => left.relative.localeCompare(right.relative))
167
+ }
168
+
169
+ function safeSize(path) {
170
+ try {
171
+ return statSync(path).size
172
+ } catch {
173
+ return 0
174
+ }
175
+ }
176
+
177
+ function sha256(bytes) {
178
+ return createHash('sha256').update(bytes).digest('hex')
179
+ }
180
+
181
+ /**
182
+ * Load `node:sqlite` lazily and tolerate builds without it.
183
+ *
184
+ * `backup()` is already promise-based in current Node (calling it with a
185
+ * callback throws ERR_INVALID_ARG_TYPE). It is returned as-is, and the result
186
+ * is awaited by the caller, so no promisification happens here.
187
+ */
188
+ async function loadSqlite() {
189
+ const module = await import('node:sqlite')
190
+ return {
191
+ DatabaseSync: module.DatabaseSync,
192
+ backup: typeof module.backup === 'function' ? module.backup : undefined,
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Copy one live database into `destination` consistently.
198
+ *
199
+ * Preferred: `backup()` — it drives SQLite's online backup API, so a writer
200
+ * holding the WAL open cannot tear the copy, and a concurrent write only makes
201
+ * the backup restart its current batch rather than fail.
202
+ *
203
+ * Fallback: open the source read-only and `VACUUM INTO`, which materialises a
204
+ * transaction-consistent copy when the backup API is unavailable.
205
+ */
206
+ export async function copyDatabase(source, destination) {
207
+ const { DatabaseSync, backup } = await loadSqlite()
208
+ if (backup !== undefined) {
209
+ const handle = new DatabaseSync(source, { readOnly: true })
210
+ try {
211
+ await backup(handle, destination)
212
+ } finally {
213
+ handle.close()
214
+ }
215
+ return 'backup-api'
216
+ }
217
+ const handle = new DatabaseSync(source, { readOnly: true })
218
+ try {
219
+ // VACUUM INTO refuses to overwrite, so the target must be absent.
220
+ handle.exec(`VACUUM INTO '${destination.replace(/'/g, "''")}'`)
221
+ } finally {
222
+ handle.close()
223
+ }
224
+ return 'vacuum-into'
225
+ }
226
+
227
+ /**
228
+ * Build a snapshot of the selected components under `stagingDirectory`.
229
+ *
230
+ * Layout produced:
231
+ * payload/data/<spaceId>/mnemon.db consistent copy of each space
232
+ * payload/data/<registry> space registry, verbatim
233
+ * payload/documents/** index plus every Markdown file
234
+ * payload/runtime/** memories.json plus both projections
235
+ * manifest.json components, files and checksums
236
+ *
237
+ * @returns {Promise<{directory: string, manifest: object, files: string[]}>}
238
+ */
239
+ export async function buildSnapshot(home, stagingDirectory, options = {}) {
240
+ const selection = normalizeSelection(options.components)
241
+ if (selection.length === 0) {
242
+ throw new Error('no backup component was selected')
243
+ }
244
+ const inspected = inspectHome(home)
245
+ const available = selection.filter((component) => inspected.components[component].available)
246
+ if (available.length === 0) {
247
+ throw new Error(`the selected components hold no data: ${selection.join(', ')}`)
248
+ }
249
+
250
+ const payloadRoot = join(stagingDirectory, 'payload')
251
+ mkdirSync(payloadRoot, { recursive: true })
252
+
253
+ const checksums = {}
254
+ const captured = []
255
+ const skipped = []
256
+ const onProgress = options.onProgress
257
+
258
+ const record = (relative, bytes) => {
259
+ checksums[relative] = sha256(bytes)
260
+ }
261
+
262
+ if (selection.includes('memory-spaces')) {
263
+ const part = inspected.components['memory-spaces']
264
+ if (!part.available) {
265
+ skipped.push({ component: 'memory-spaces', reason: 'no memory space database was found' })
266
+ } else {
267
+ const dataDirectory = join(payloadRoot, MEMORY_SPACES_DIRECTORY)
268
+ mkdirSync(dataDirectory, { recursive: true })
269
+ for (const space of part.spaces) {
270
+ const destinationDirectory = join(dataDirectory, space.id)
271
+ mkdirSync(destinationDirectory, { recursive: true })
272
+ const destination = join(destinationDirectory, MEMORY_SPACE_DATABASE_FILE)
273
+ onProgress?.(`backing up ${space.id}`)
274
+ const method = await copyDatabase(space.database, destination)
275
+ const bytes = readFileSync(destination)
276
+ record(`payload/${MEMORY_SPACES_DIRECTORY}/${space.id}/${MEMORY_SPACE_DATABASE_FILE}`, bytes)
277
+ captured.push({
278
+ component: 'memory-spaces',
279
+ id: space.id,
280
+ path: `payload/${MEMORY_SPACES_DIRECTORY}/${space.id}/${MEMORY_SPACE_DATABASE_FILE}`,
281
+ bytes: bytes.length,
282
+ sourceBytes: space.bytes,
283
+ method,
284
+ // Recorded so a restore can tell the operator the copy is not a byte
285
+ // image of the live file: it is a checkpointed, consistent version.
286
+ wireFormat: 'checkpointed',
287
+ })
288
+ // Small sidecars travel verbatim; WAL and SHM are deliberately excluded,
289
+ // see the module comment.
290
+ const spaceDirectory = dirname(space.database)
291
+ for (const name of space.files) {
292
+ if (name === MEMORY_SPACE_DATABASE_FILE) continue
293
+ const from = join(spaceDirectory, name)
294
+ if (!existsSync(from)) continue
295
+ const fileBytes = readFileSync(from)
296
+ writeFileSync(join(destinationDirectory, name), fileBytes)
297
+ record(`payload/${MEMORY_SPACES_DIRECTORY}/${space.id}/${name}`, fileBytes)
298
+ }
299
+ }
300
+ for (const extra of part.extras) {
301
+ const from = join(home, MEMORY_SPACES_DIRECTORY, extra.name)
302
+ if (!existsSync(from)) continue
303
+ const bytes = readFileSync(from)
304
+ writeFileSync(join(dataDirectory, extra.name), bytes)
305
+ record(`payload/${MEMORY_SPACES_DIRECTORY}/${extra.name}`, bytes)
306
+ }
307
+ // `entries` holds one summary record per captured item, so a summary for
308
+ // memory spaces carries the space ids it wrote — never a second record of
309
+ // the same component, which would read as an extra space in a count.
310
+ captured.push({
311
+ component: 'memory-spaces',
312
+ spaces: part.spaces.map((space) => space.id),
313
+ files: Object.keys(checksums).filter((relative) => relative.startsWith(`payload/${MEMORY_SPACES_DIRECTORY}/`)).length,
314
+ bytes: part.totalBytes,
315
+ })
316
+ }
317
+ }
318
+
319
+ for (const component of ['documents', 'runtime']) {
320
+ if (!selection.includes(component)) continue
321
+ const part = inspected.components[component]
322
+ if (!part.available) {
323
+ skipped.push({ component, reason: 'no files were found' })
324
+ continue
325
+ }
326
+ onProgress?.(`backing up ${component}`)
327
+ const targetRoot = join(payloadRoot, component === 'documents' ? DOCUMENTS_DIRECTORY : RUNTIME_DIRECTORY)
328
+ for (const file of part.files) {
329
+ const from = join(home, component === 'documents' ? DOCUMENTS_DIRECTORY : RUNTIME_DIRECTORY, file.relative)
330
+ const bytes = readFileSync(from)
331
+ const destination = join(targetRoot, file.relative)
332
+ mkdirSync(dirname(destination), { recursive: true })
333
+ writeFileSync(destination, bytes)
334
+ record(`payload/${component === 'documents' ? DOCUMENTS_DIRECTORY : RUNTIME_DIRECTORY}/${file.relative}`, bytes)
335
+ }
336
+ captured.push({
337
+ component,
338
+ files: part.files.length,
339
+ bytes: part.totalBytes,
340
+ })
341
+ }
342
+
343
+ const manifest = {
344
+ format: 'mnemon-sync-snapshot',
345
+ version: 2,
346
+ createdAt: new Date().toISOString(),
347
+ source: { home },
348
+ // What the operator asked for, what actually shipped, and what was empty —
349
+ // a restore reads this to explain a missing component instead of guessing.
350
+ requested: selection,
351
+ components: [...new Set(captured.map((entry) => entry.component))],
352
+ entries: captured,
353
+ skipped,
354
+ checksums: { algorithm: 'sha256', files: checksums },
355
+ }
356
+ writeFileSync(join(stagingDirectory, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
357
+
358
+ const files = [...Object.keys(checksums), 'manifest.json']
359
+ return { directory: stagingDirectory, manifest, files }
360
+ }
361
+
362
+ /**
363
+ * Verify a snapshot directory against its own manifest before it is uploaded.
364
+ * A backup that cannot be read back is worse than no backup.
365
+ */
366
+ export function verifySnapshot(stagingDirectory) {
367
+ const manifest = JSON.parse(readFileSync(join(stagingDirectory, 'manifest.json'), 'utf8'))
368
+ if (manifest.format !== 'mnemon-sync-snapshot') {
369
+ throw new Error(`unexpected manifest format: ${String(manifest.format)}`)
370
+ }
371
+ const expected = manifest.checksums?.files ?? {}
372
+ let verified = 0
373
+ for (const [relative, digest] of Object.entries(expected)) {
374
+ const actual = sha256(readFileSync(join(stagingDirectory, relative)))
375
+ if (actual !== digest) {
376
+ throw new Error(`snapshot checksum mismatch for ${relative}`)
377
+ }
378
+ verified += 1
379
+ }
380
+ return { manifest, files: verified }
381
+ }
382
+
383
+ /**
384
+ * Read the space registry so the UI can name spaces the way the store does.
385
+ * Degrades to directory names when the registry is absent or unreadable: a
386
+ * backup must not depend on metadata being well-formed.
387
+ */
388
+ export function readSpaceNames(home) {
389
+ const path = join(home, MEMORY_SPACES_DIRECTORY, MEMORY_SPACE_REGISTRY_FILE)
390
+ const names = new Map()
391
+ try {
392
+ const registry = JSON.parse(readFileSync(path, 'utf8'))
393
+ for (const body of registry?.bodies ?? []) {
394
+ if (typeof body?.id === 'string' && typeof body?.name === 'string') names.set(body.id, body.name)
395
+ }
396
+ } catch {
397
+ // ignored on purpose, see the doc comment
398
+ }
399
+ return names
400
+ }
package/source.js ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Mnemon Sync — the Source that owns this page.
3
+ *
4
+ * Why a Source is required at all: dsh-mnemon renders a page tab only when its
5
+ * `sourceTypeId` appears in the live source catalog, and the catalog lists
6
+ * real, running Source implementations. A UI-only contribution can borrow
7
+ * `memory-spaces` and be shown, but the tab bar highlights *every* tab sharing
8
+ * the selected page's type prefix, so borrowing means clicking one lights up
9
+ * the other. Declaring our own type is the only way to get an independent tab.
10
+ *
11
+ * This Source is deliberately honest about what it holds. It is not a memory
12
+ * backend: it stores no insights, so it contributes an empty projection and
13
+ * declares no read routes or write actions. Its role is to be the identity the
14
+ * sync page hangs from, which is why every capability is false and the facts
15
+ * report only what the backup configuration currently is.
16
+ *
17
+ * The alternative — faking routes that answer with nothing — would pollute the
18
+ * model's memory view with an empty source and could make a composition look
19
+ * incomplete. An empty, clearly-labelled source is the smaller lie.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto'
23
+ import { join } from 'node:path'
24
+
25
+ import { resolveDataRoot } from './index.js'
26
+ import { inspectHome } from './snapshot.js'
27
+ import { readSettings } from './store.js'
28
+
29
+ export const SYNC_SOURCE_TYPE_ID = 'mnemon-sync'
30
+ export const SYNC_PACKAGE_NAME = '@summerscar/dsh-mnemon-sync'
31
+
32
+ /** The manifest Core validates before this Source may take part in a View. */
33
+ export const syncSourceManifest = {
34
+ apiVersion: 'dsh-mnemon/v1',
35
+ kind: 'source',
36
+ typeId: SYNC_SOURCE_TYPE_ID,
37
+ packageName: SYNC_PACKAGE_NAME,
38
+ // A role of its own keeps it out of any Strategy's default source selection:
39
+ // no shipped Strategy asks for a "maintenance" role, so this Source never
40
+ // lands in a model prompt.
41
+ role: 'maintenance',
42
+ capabilities: [],
43
+ consistency: 'exact-snapshot',
44
+ routes: [],
45
+ actions: [],
46
+ // A management descriptor is not decoration. dsh-mnemon builds its Source
47
+ // catalog from installed Sources that declare one, and the workbench decides
48
+ // a layer is "running" by whether its type is in that catalog — so a Source
49
+ // without `management` is installed but reported as "on yet not running".
50
+ // Fields stay empty: this page does its own editing.
51
+ management: {
52
+ label: 'Sync backup',
53
+ description: 'Archive memory spaces to S3 or WebDAV.',
54
+ },
55
+ }
56
+
57
+ /**
58
+ * Build the Source definition.
59
+ *
60
+ * `facts()` must return the Host's *facts envelope* — `sourceInstanceKey`,
61
+ * `sourceTypeId`, `role`, `availability`, `revision`, `capabilities`,
62
+ * `routeIds`, `actionIds` (plus optional `hints`). dsh-mnemon rejects anything
63
+ * else: `normalizeFacts()` compares `facts.sourceInstanceKey` against the
64
+ * registry key and throws `memory Source facts instance mismatch` when it is
65
+ * missing, which is exactly what a `{ summary, details }` shape produced.
66
+ *
67
+ * `hints` is the only free-form channel that survives normalization, so the
68
+ * backup configuration a human may want to see travels there. `project()`
69
+ * returns `{ fragments: [] }`: there is no memory content here to contribute.
70
+ */
71
+ export function createSyncSource(manifest = syncSourceManifest) {
72
+ return {
73
+ manifest,
74
+ create(context) {
75
+ const configuredRoot = typeof context?.configuration?.dataRoot === 'string'
76
+ ? context.configuration.dataRoot
77
+ : undefined
78
+ const dataRoot = resolveDataRoot(configuredRoot === undefined ? {} : { dataRoot: configuredRoot })
79
+ const home = join(dataRoot, '..')
80
+
81
+ return {
82
+ facts() {
83
+ const settings = safeSettings(home)
84
+ const inspected = safeInspect(dataRoot)
85
+ return {
86
+ sourceInstanceKey: context.sourceInstanceKey,
87
+ sourceTypeId: manifest.typeId,
88
+ role: manifest.role,
89
+ // This Source is a maintenance identity, not a memory backend: it
90
+ // is always ready and holds nothing a View could select.
91
+ availability: 'ready',
92
+ revision: configurationRevision(settings, inspected),
93
+ capabilities: [],
94
+ routeIds: [],
95
+ actionIds: [],
96
+ // Facts are JSON only and must stay small: metadata the Host may
97
+ // surface, never memory content. Only `hints` survives
98
+ // normalization, so anything worth showing goes here.
99
+ hints: {
100
+ summary: settings.enabled
101
+ ? `Sync backup every ${String(settings.autoSyncMinutes)} min to ${describeTarget(settings.target)}`
102
+ : 'Sync backup is configured but disabled',
103
+ enabled: settings.enabled,
104
+ configured: isConfigured(settings),
105
+ autoSyncMinutes: settings.autoSyncMinutes,
106
+ components: settings.components,
107
+ targetKind: settings.target.kind,
108
+ remotePath: settings.target.remotePath,
109
+ keep: settings.retention.keep,
110
+ spaces: inspected.components['memory-spaces'].spaces.map((space) => space.id),
111
+ totalBytes: Object.values(inspected.components)
112
+ .reduce((total, part) => total + part.totalBytes, 0),
113
+ },
114
+ }
115
+ },
116
+ project() {
117
+ // Nothing to inject: this Source holds no memories to contribute.
118
+ return { fragments: [] }
119
+ },
120
+ dispose() {},
121
+ }
122
+ },
123
+ }
124
+ }
125
+
126
+ /**
127
+ * A stable revision for the current backup configuration.
128
+ *
129
+ * The Host requires a non-empty string and compares it across a turn, so this
130
+ * must change when the configuration changes and stay equal otherwise. It is
131
+ * derived from the facts themselves rather than a clock, which keeps
132
+ * composition deterministic.
133
+ */
134
+ function configurationRevision(settings, inspected) {
135
+ const material = JSON.stringify({
136
+ enabled: settings.enabled,
137
+ target: describeTarget(settings.target),
138
+ remotePath: settings.target?.remotePath ?? '',
139
+ autoSyncMinutes: settings.autoSyncMinutes ?? 0,
140
+ components: settings.components ?? [],
141
+ keep: settings.retention?.keep ?? 0,
142
+ spaces: inspected.components['memory-spaces'].spaces.map((space) => space.id).sort(),
143
+ totalBytes: Object.values(inspected.components)
144
+ .reduce((total, part) => total + part.totalBytes, 0),
145
+ })
146
+ return createHash('sha256').update(material).digest('hex').slice(0, 32)
147
+ }
148
+
149
+ const EMPTY_INSPECTION = {
150
+ components: {
151
+ 'memory-spaces': { available: false, spaces: [], extras: [], totalBytes: 0 },
152
+ documents: { available: false, files: [], totalBytes: 0 },
153
+ runtime: { available: false, files: [], totalBytes: 0 },
154
+ },
155
+ }
156
+
157
+ function safeSettings(home) {
158
+ try {
159
+ return readSettings(home)
160
+ } catch {
161
+ return {
162
+ enabled: false,
163
+ target: { kind: 'webdav', remotePath: '' },
164
+ components: [],
165
+ autoSyncMinutes: 0,
166
+ retention: { keep: 0 },
167
+ }
168
+ }
169
+ }
170
+
171
+ function safeInspect(home) {
172
+ try {
173
+ return inspectHome(home)
174
+ } catch {
175
+ return EMPTY_INSPECTION
176
+ }
177
+ }
178
+
179
+ /** Whether the target carries enough detail to actually upload somewhere. */
180
+ export function isConfigured(settings) {
181
+ if (settings?.target === undefined) return false
182
+ if (settings.target.kind === 's3') {
183
+ return nonEmpty(settings.target.endpoint)
184
+ && nonEmpty(settings.target.bucket)
185
+ && nonEmpty(settings.target.accessKeyId)
186
+ && nonEmpty(settings.target.secretAccessKey)
187
+ }
188
+ return nonEmpty(settings.target.url)
189
+ }
190
+
191
+ function nonEmpty(value) {
192
+ return typeof value === 'string' && value !== ''
193
+ }
194
+
195
+ function describeTarget(target) {
196
+ if (target?.kind === 's3') {
197
+ const bucket = String(target.bucket ?? '')
198
+ return bucket === '' ? 'S3' : `S3 ${bucket}`
199
+ }
200
+ return 'WebDAV'
201
+ }