@summerscar/dsh-mnemon-sync 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +86 -2
- package/client.js +1194 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +15 -0
- package/index.js +731 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
- package/scheduler.js +241 -0
- package/snapshot.js +400 -0
- package/source.js +201 -0
- package/store.js +187 -0
- package/targets.js +533 -0
- package/zip.js +168 -0
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
|
+
}
|