@summerscar/dsh-mnemon-sync 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,86 +1,43 @@
1
1
  # dsh-mnemon-sync
2
2
 
3
- 为 [dsh-mnemon](https://github.com/omdsh-dev/dsh-mnemon) 提供备份与恢复:把 Mnemon 的记忆组件打包上传到 S3 兼容存储或 WebDAV,需要时再挑一个包恢复回来。
3
+ 为 [dsh-mnemon](https://github.com/omdsh-dev/dsh-mnemon) 提供备份与恢复:把记忆组件打包上传到 S3 兼容存储或 WebDAV,需要时挑一个包恢复回来。安装后在记忆系统里多一个「同步备份」标签页。
4
4
 
5
- 在记忆系统里以「同步备份」标签页出现,和「概览」「检索」「内容」并排。
6
-
7
- ## 能备份什么
8
-
9
- 按 dsh-mnemon 自己的组件划分,任意组合,多选:
10
-
11
- | 组件 | 内容 | 说明 |
12
- |---|---|---|
13
- | 记忆空间 | `<MNEMON_DATA_DIR>/data/` | 每个空间的 `mnemon.db` 与空间清单,数据库经 SQLite 在线备份 |
14
- | 项目档案 | `<MNEMON_DATA_DIR>/documents/` | `index.json` 与全部 Markdown 文档 |
15
- | 运行时记忆 | `<MNEMON_DATA_DIR>/runtime/` | `memories.json` 与两份投影文件 |
16
-
17
- `MNEMON_DATA_DIR` 未设置时取 `~/.mnemon`。
18
-
19
- ## 为什么不用 litestream
20
-
21
- litestream 是很好的工具,但和「目录级、多空间、可配置目标」这个需求是正交的:
5
+ ## 安装
22
6
 
23
- - 一个 litestream 实例只盯一个数据库文件,v0.5 起每个数据库只允许一个副本。要备份整个 data 目录就得为每个记忆空间养一个常驻进程,空间增删还得动态改配置。
24
- - 它接管 WAL checkpoint 并长期持有读锁。实测会让 `mnemon.db-wal` 停在非零字节,而 dsh-mnemon 自己的备份导出有这条检查:
7
+ ```sh
8
+ dsh plugin --profile web add @summerscar/dsh-mnemon-sync
9
+ ```
25
10
 
26
- ```js
27
- if (existsSync(wal) && statSync(wal).size > 0)
28
- throw new Error(`Memory Space ${id} is busy (mnemon.db-wal is not checkpointed)`)
29
- ```
11
+ 装完重启 DSH 生效。没有 `dsh-mnemon` 时插件仍可运行,只是不显示标签页。
30
12
 
31
- 也就是说上了 litestream,dsh-mnemon 自带的备份会直接报错。
13
+ ## 能备份什么
32
14
 
33
- 这里改用 `node:sqlite` 的 `backup()`:它走 SQLite 自己的在线备份 API,写入方持有 WAL 也不会把副本撕开,并发写只会让备份重跑当前批次,不产生常驻进程。
15
+ 三个组件任意组合,默认全选:
34
16
 
35
- ## 一致性
17
+ | 组件 | 内容 |
18
+ |---|---|
19
+ | 记忆空间 | `<MNEMON_DATA_DIR>/data/`,每个空间的 `mnemon.db` 与空间清单 |
20
+ | 项目档案 | `<MNEMON_DATA_DIR>/documents/`,`index.json` 与全部 Markdown |
21
+ | 运行时记忆 | `<MNEMON_DATA_DIR>/runtime/`,`memories.json` 与两份投影 |
36
22
 
37
- - **数据库**:`backup()` 生成事务一致的副本,产物是已 checkpoint 的单文件,不含 `-wal` / `-shm`。
38
- - **非数据库文件**:JSON 与 Markdown 由存储层原子写入,逐字节复制。
39
- - **校验**:每个文件在打包前记录 SHA-256,manifest 与内容一起入包;上传前先自校验,恢复前再校验一次。
40
- - **恢复顺序**:先下载 → 校验全部校验和 → 报告缺失组件 → 只写回确认的组件。不带 `apply` 时是纯预览,不碰磁盘。
41
- - **覆盖数据库前**会先删除同名的 `-wal` / `-shm`:残留的 WAL 会让 SQLite 重放恢复文件里并不存在的事务。
23
+ `MNEMON_DATA_DIR` 未设置时取 `~/.mnemon`。
42
24
 
43
- ## 目标
25
+ ## 备份目标
44
26
 
45
- - **S3 兼容**:AWS Signature V4 手写实现,已用 AWS 官方文档的测试向量逐字节校验。适用于 Cloudflare R2、MinIO、Backblaze B2、阿里云 OSS 等。
46
- - **WebDAV**:`PUT` 上传、`PROPFIND depth:1` 列目录、`DELETE` 清理,Basic 认证。
27
+ - **S3 兼容** — 手写 AWS Signature V4,适用于 Cloudflare R2、MinIO、Backblaze B2 等
28
+ - **WebDAV** — Basic 认证,自动创建远端目录
47
29
 
48
- 凭据保存在 `<MNEMON_DATA_DIR>/state/mnemon-sync.json`,权限 `0600`,不进 DSH 配置,也不会被备份包带走(`state/` 不属于任何备份组件)。页面回显密钥时只给掩码,回传掩码表示"不修改"。
30
+ 凭据存在 `<MNEMON_DATA_DIR>/state/mnemon-sync.json`,权限 `0600`,不写入 DSH 配置,也不进备份包。页面只回显掩码,回传掩码表示不修改。
49
31
 
50
32
  ## 自动备份
51
33
 
52
- 间隔与 dsh-mnemon 自己的 Git 备份保持一致,两项功能读起来一样:
53
-
54
34
  关闭 · 每小时 · 每 3 / 6 / 12 小时 · 每天 · 每 3 天 · 每 7 天
55
35
 
56
- 调度是一个 Host 内唯一的 `setTimeout` 自循环,从 `finally` 里重新排期。每次触发前重新读配置,改间隔不用重启;运行中再次触发会被丢弃而不是排队;失败只记录不抛出。
36
+ ## 恢复
57
37
 
58
- ## 安装
38
+ 从备份列表查看明细,选择要恢复的组件后执行;缺少的组件会明确提示。恢复默认只做预览,确认后才写盘。
59
39
 
60
- ```sh
61
- git clone https://github.com/summerscar/dsh-mnemon-sync.git
62
- ```
63
-
64
- 在 DSH 里用插件管理器安装这个目录,或直接加入 profile 的依赖:
65
-
66
- ```sh
67
- dsh plugin --profile web add /path/to/dsh-mnemon-sync
68
- ```
69
-
70
- 需要在 profile 里启用,并确保 `mnemon.sync` 这一层出现在 `memoryTopology.layers` 中。插件会注册一个最小 Source 来承载自己的页面 —— dsh-mnemon 只会为真实存在的 Source 渲染标签页,且标签高亮按 `sourceTypeId` 分组,借用别人的类型会导致两个标签同时点亮。
71
-
72
- ## 目录结构
73
-
74
- ```
75
- index.js Host 入口:HTTP 接口、调度装配、Source 注册
76
- source.js 承载页面身份的最小 Source(不存记忆)
77
- snapshot.js 组件识别、数据库在线备份、manifest 与校验
78
- scheduler.js 定时器与运行历史
79
- store.js 设置存取(0600)与规范化
80
- targets.js S3 SigV4 / WebDAV 的上传、下载、列目录、删除
81
- zip.js 零依赖 ZIP 读写(node:zlib)
82
- client.js 浏览器端页面与标签注册
83
- ```
40
+ 数据库用 `node:sqlite` 的 `backup()` 在线备份,副本一致;写回前会删掉同名 `-wal` / `-shm`,避免 SQLite 重放不存在的事务。每个文件都有 SHA-256 校验。
84
41
 
85
42
  ## 许可
86
43
 
package/client.js CHANGED
@@ -40,8 +40,8 @@ window.__ModuleLoader__.load({
40
40
 
41
41
  const zh = {
42
42
  tabLabel: '同步备份',
43
- title: '记忆空间同步备份',
44
- subtitle: '把 Mnemon 的记忆组件打包上传到 S3 或 WebDAV',
43
+ title: '同步备份',
44
+ subtitle: '把选定的记忆组件打包备份到 S3 兼容存储或 WebDAV,并可随时查看备份列表与恢复。',
45
45
  config: '备份配置',
46
46
  configNote: '以下三项一起保存。',
47
47
  scope: '备份范围',
@@ -56,6 +56,7 @@ window.__ModuleLoader__.load({
56
56
  componentSpaces: '{count} 个空间',
57
57
  backups: '备份列表',
58
58
  listBackups: '从目标列出备份',
59
+ refresh: '刷新',
59
60
  backupsHint: '列出远端已存在的备份包,可查看明细并恢复。',
60
61
  noBackups: '目标上还没有备份包',
61
62
  backupName: '文件名',
@@ -118,7 +119,6 @@ window.__ModuleLoader__.load({
118
119
  backingUp: '备份中…',
119
120
  backupDone: '备份完成',
120
121
  backupFailed: '备份失败',
121
- never: '从未',
122
122
  history: '运行记录',
123
123
  noHistory: '还没有运行记录',
124
124
  ok: '成功',
@@ -129,6 +129,9 @@ window.__ModuleLoader__.load({
129
129
  duration: '耗时',
130
130
  size: '体积',
131
131
  componentsCount: '组件',
132
+ clearHistory: '清空记录',
133
+ historyCleared: '运行记录已清空',
134
+ clearFailed: '清空失败',
132
135
  error: '错误',
133
136
  loading: '读取中…',
134
137
  loadFailed: '读取失败',
@@ -141,8 +144,8 @@ window.__ModuleLoader__.load({
141
144
 
142
145
  const en = {
143
146
  tabLabel: 'Sync backup',
144
- title: 'Memory space sync backup',
145
- subtitle: 'Pack Mnemon\u2019s memory components to S3 or WebDAV',
147
+ title: 'Sync backup',
148
+ subtitle: 'Back up selected memory components to S3-compatible storage or WebDAV, with an archive list and restore.',
146
149
  config: 'Backup settings',
147
150
  configNote: 'All three sections below save together.',
148
151
  scope: 'Backup scope',
@@ -157,6 +160,7 @@ window.__ModuleLoader__.load({
157
160
  componentSpaces: '{count} spaces',
158
161
  backups: 'Backups',
159
162
  listBackups: 'List from target',
163
+ refresh: 'Refresh',
160
164
  backupsHint: 'List the archives already on the target, inspect one, and restore it.',
161
165
  noBackups: 'No archive on the target yet',
162
166
  backupName: 'File',
@@ -229,6 +233,9 @@ window.__ModuleLoader__.load({
229
233
  duration: 'Duration',
230
234
  size: 'Size',
231
235
  componentsCount: 'Components',
236
+ clearHistory: 'Clear log',
237
+ historyCleared: 'Run history cleared',
238
+ clearFailed: 'Could not clear',
232
239
  error: 'Error',
233
240
  loading: 'Loading\u2026',
234
241
  loadFailed: 'Could not load',
@@ -657,6 +664,24 @@ window.__ModuleLoader__.load({
657
664
  }
658
665
  }
659
666
 
667
+ /**
668
+ * Drop the run log. Archives on the target are not touched — this is a
669
+ * display reset, and the page says so before the button is pressed.
670
+ */
671
+ const clearHistory = async () => {
672
+ setBusy(true)
673
+ setNotice(null)
674
+ try {
675
+ await api('/clear-history', { method: 'POST', body: '{}' })
676
+ await load()
677
+ setNotice({ ok: true, text: t('historyCleared') })
678
+ } catch (cause) {
679
+ setNotice({ ok: false, text: `${t('clearFailed')}: ${cause.message}` })
680
+ } finally {
681
+ setBusy(false)
682
+ }
683
+ }
684
+
660
685
  const loadArchives = async () => {
661
686
  setArchivesBusy(true)
662
687
  setArchivesError(null)
@@ -1082,7 +1107,15 @@ window.__ModuleLoader__.load({
1082
1107
 
1083
1108
  // history
1084
1109
  h('section', { style: styles.card },
1085
- h('h3', { style: styles.cardTitle }, t('history')),
1110
+ h('div', { style: styles.row },
1111
+ h('h3', { style: { ...styles.cardTitle, marginRight: 'auto' } }, t('history')),
1112
+ // The list keeps the newest 20 on its own; this clears what is left.
1113
+ history.length === 0 ? null : h('button', {
1114
+ style: styles.button,
1115
+ disabled: busy,
1116
+ onClick: () => void clearHistory(),
1117
+ }, t('clearHistory')),
1118
+ ),
1086
1119
  history.length === 0
1087
1120
  ? h('p', { style: styles.note }, t('noHistory'))
1088
1121
  : h('table', { style: styles.table },
package/index.js CHANGED
@@ -199,19 +199,8 @@ export function apply(ctx, config = {}) {
199
199
 
200
200
  if (req.method === 'PUT' && route === '/settings') {
201
201
  const body = await readJsonBody(req)
202
- const previous = settings()
203
202
  const incoming = body?.settings ?? body
204
- // A masked secret coming back from the UI means "unchanged".
205
- if (incoming !== null && typeof incoming === 'object' && incoming.target !== null && typeof incoming.target === 'object') {
206
- const target = incoming.target
207
- if (target.kind === 'webdav' && isMasked(target.password)) {
208
- target.password = previous.target.kind === 'webdav' ? previous.target.password : ''
209
- }
210
- if (target.kind === 's3' && isMasked(target.secretAccessKey)) {
211
- target.secretAccessKey = previous.target.kind === 's3' ? previous.target.secretAccessKey : ''
212
- }
213
- }
214
- const saved = writeSettings(home, normalizeSettings(incoming))
203
+ const saved = writeSettings(home, normalizeSettings(restoreMaskedSecrets(incoming, settings())))
215
204
  scheduler.refresh()
216
205
  sendJson(res, 200, { settings: maskSettings(saved) })
217
206
  return
@@ -225,7 +214,10 @@ export function apply(ctx, config = {}) {
225
214
  const body = await readJsonBody(req)
226
215
  try {
227
216
  if (body?.settings !== undefined) {
228
- const saved = writeSettings(home, normalizeSettings(body.settings))
217
+ // The same masked-secret rule as saving settings: the page always
218
+ // sends the mask it was shown, and writing that mask as the real
219
+ // password would silently break the next upload with a 401.
220
+ const saved = writeSettings(home, normalizeSettings(restoreMaskedSecrets(body.settings, settings())))
229
221
  scheduler.refresh()
230
222
  const result = await scheduler.runNow('manual')
231
223
  sendJson(res, 200, { result, settings: maskSettings(saved) })
@@ -239,6 +231,12 @@ export function apply(ctx, config = {}) {
239
231
  return
240
232
  }
241
233
 
234
+ if (req.method === 'POST' && route === '/clear-history') {
235
+ // Only the local run log is cleared; archives on the target are untouched.
236
+ sendJson(res, 200, { history: history.clear() })
237
+ return
238
+ }
239
+
242
240
  if (req.method === 'POST' && route === '/refresh-schedule') {
243
241
  scheduler.refresh()
244
242
  sendJson(res, 200, { scheduler: scheduler.snapshot(settings()) })
@@ -557,6 +555,33 @@ function isMasked(value) {
557
555
  return typeof value === 'string' && value !== '' && /^•+$/u.test(value)
558
556
  }
559
557
 
558
+ /**
559
+ * Put the real secrets back into a settings payload that arrived masked.
560
+ *
561
+ * The page never receives a secret — `maskSettings` replaces it with bullets —
562
+ * and echoes those bullets back on every save. Treating them as a value would
563
+ * overwrite the real credential with eight bullet characters, which is exactly
564
+ * what made a correctly configured WebDAV target answer 401: the stored
565
+ * password really was `••••••••`.
566
+ *
567
+ * Any write path that accepts settings from the browser must run this first.
568
+ */
569
+ function restoreMaskedSecrets(incoming, previous) {
570
+ if (incoming === null || typeof incoming !== 'object') return incoming
571
+ const target = incoming.target
572
+ if (target === null || typeof target !== 'object') return incoming
573
+ if (target.kind === 'webdav' && isMasked(target.password)) {
574
+ target.password = previous.target.kind === 'webdav' ? previous.target.password : ''
575
+ }
576
+ if (target.kind === 's3' && isMasked(target.secretAccessKey)) {
577
+ target.secretAccessKey = previous.target.kind === 's3' ? previous.target.secretAccessKey : ''
578
+ }
579
+ if (target.kind === 's3' && isMasked(target.sessionToken)) {
580
+ target.sessionToken = previous.target.kind === 's3' ? previous.target.sessionToken : ''
581
+ }
582
+ return incoming
583
+ }
584
+
560
585
  /**
561
586
  * Resolve the dsh-mnemon extension SDK.
562
587
  *
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "Memory space sync backup",
4
- "description": "Archive the whole Mnemon data directory — every memory space database — to S3-compatible storage or WebDAV, with minute/hour/day/clock scheduling and retention."
3
+ "title": "Sync backup",
4
+ "description": "Back up and restore dsh-mnemon memory components (memory spaces, project documents, runtime memory — pick any subset) to S3-compatible storage or WebDAV, with scheduled backups, an archive list and selective restore."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "记忆空间同步备份",
4
- "description": "把整个 Mnemon data 目录(含全部记忆空间数据库)打包备份到 S3 兼容存储或 WebDAV,支持按分钟/小时/天/固定时刻调度与保留份数。"
3
+ "title": "同步备份",
4
+ "description": "备份与恢复 dsh-mnemon 的记忆组件(记忆空间、项目档案、运行时记忆,可任选)到 S3 兼容存储或 WebDAV,支持定时备份、备份列表与选择性恢复。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@summerscar/dsh-mnemon-sync",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Back up and restore dsh-mnemon memory components (memory spaces, project documents, runtime memory) to S3-compatible storage or WebDAV.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/scheduler.js CHANGED
@@ -72,6 +72,22 @@ export class SyncHistory {
72
72
  latest() {
73
73
  return this.read()[0]
74
74
  }
75
+
76
+ /**
77
+ * Drop the whole run history.
78
+ *
79
+ * History is a display convenience, not a record of truth: the archives
80
+ * themselves live on the target, and retention keys off the same list, so
81
+ * clearing it simply stops the page from showing past runs. Retention then
82
+ * has nothing to delete, which is the honest outcome — after clearing, this
83
+ * plugin no longer knows which keys it uploaded.
84
+ */
85
+ clear() {
86
+ const path = this.path()
87
+ mkdirSync(join(this.root, STATE_DIRECTORY), { recursive: true, mode: 0o700 })
88
+ writeFileSync(path, `${JSON.stringify({ version: 1, runs: [] }, null, 2)}\n`, { mode: 0o600 })
89
+ return []
90
+ }
75
91
  }
76
92
 
77
93
  /**