dsh-config-manager 0.1.72 → 0.1.73
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 +40 -4
- package/README.zh-CN.md +40 -4
- package/lib/adapters/index.d.ts +1 -0
- package/lib/adapters/index.js +5 -0
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-data.d.ts +114 -0
- package/lib/adapters/plugin-data.js +309 -0
- package/lib/adapters/self.d.ts +1 -1
- package/lib/adapters/self.js +2 -1
- package/lib/adapters/test-helpers.d.ts +9 -0
- package/lib/adapters/test-helpers.js +14 -0
- package/lib/client.d.ts +95 -6
- package/lib/client.js +10630 -10133
- package/lib/core/analyzer.d.ts +11 -2
- package/lib/core/analyzer.js +31 -8
- package/lib/core/backup-plan.d.ts +10 -0
- package/lib/core/backup-plan.js +21 -3
- package/lib/core/backup.d.ts +20 -1
- package/lib/core/backup.js +48 -3
- package/lib/core/command-rollback.d.ts +6 -2
- package/lib/core/command-rollback.js +6 -2
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +2 -1
- package/lib/core/messages.js +4 -2
- package/lib/core/plugin-data-registry.d.ts +124 -0
- package/lib/core/plugin-data-registry.js +198 -0
- package/lib/core/rollback-gate.d.ts +33 -0
- package/lib/core/rollback-gate.js +57 -0
- package/lib/core/rollback-ledger.d.ts +248 -0
- package/lib/core/rollback-ledger.js +1282 -0
- package/lib/core/rollback-point.d.ts +187 -4
- package/lib/core/rollback-point.js +689 -179
- package/lib/core/rollback.js +6 -4
- package/lib/core/types.d.ts +19 -0
- package/lib/foreign/registry.d.ts +1 -1
- package/lib/foreign/registry.js +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +83 -13
- package/lib/routes/messages.d.ts +1 -0
- package/lib/routes/messages.js +2 -0
- package/lib/routes/prefs.js +26 -3
- package/lib/routes/recovery.d.ts +21 -0
- package/lib/routes/recovery.js +127 -7
- package/lib/routes/sync.js +11 -1
- package/lib/schema/section-registry.d.ts +1 -1
- package/lib/schema/section-registry.js +32 -3
- package/lib/schema/types.d.ts +1 -1
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +52 -11
- package/lib/sync/ui-prefs.d.ts +22 -0
- package/lib/sync/ui-prefs.js +28 -0
- package/lib/ui/diff-view.d.ts +10 -0
- package/lib/ui/diff-view.js +17 -0
- package/lib/ui/export-flow.js +2 -0
- package/lib/ui/i18n.d.ts +4 -2
- package/lib/ui/i18n.js +8 -4
- package/lib/ui/rollback-view.d.ts +59 -0
- package/lib/ui/rollback-view.js +14 -0
- package/lib/ui/test-helpers.js +2 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +5 -0
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-data.test.ts +225 -0
- package/src/adapters/plugin-data.ts +351 -0
- package/src/adapters/self.ts +2 -1
- package/src/adapters/test-helpers.ts +15 -0
- package/src/client/chat/ChatEntryView.tsx +21 -1
- package/src/client/chat/RollbackDiffTab.tsx +49 -2
- package/src/client/chat/RollbackReviewDialog.tsx +20 -4
- package/src/client/chat/chat-point-window.ts +11 -1
- package/src/client/chat/rollback-diff-tab.ts +13 -3
- package/src/client/chat/rollback-entry-view.ts +5 -0
- package/src/client/chat/sidebar-slots.ts +24 -2
- package/src/client/common/DiffTable.tsx +44 -8
- package/src/client/common/client-route-parity.test.ts +6 -0
- package/src/client/common/routes.ts +10 -0
- package/src/client/common/section-labels.test.ts +1 -0
- package/src/client/common/section-labels.ts +2 -0
- package/src/client/config-manager.module.css +113 -10
- package/src/client/index.ts +47 -1
- package/src/client/locales.ts +8 -4
- package/src/client/recovery/RollbackCard.tsx +201 -7
- package/src/client/recovery/RollbackDiffPane.tsx +13 -2
- package/src/client/recovery/recovery-api.ts +28 -0
- package/src/client/recovery/recovery-locales.ts +34 -2
- package/src/client/recovery/rollback-enabled.ts +41 -0
- package/src/client/recovery/rollback-file-changes.test.ts +41 -0
- package/src/client/sync/sync-view.test.ts +1 -1
- package/src/core/analyzer.ts +31 -9
- package/src/core/backup-plan.test.ts +20 -5
- package/src/core/backup-plan.ts +22 -3
- package/src/core/backup.ts +50 -4
- package/src/core/command-rollback.ts +6 -2
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +4 -2
- package/src/core/plugin-data-registry.test.ts +113 -0
- package/src/core/plugin-data-registry.ts +261 -0
- package/src/core/rollback-gate.test.ts +66 -0
- package/src/core/rollback-gate.ts +61 -0
- package/src/core/rollback-ledger.test.ts +655 -0
- package/src/core/rollback-ledger.ts +1314 -0
- package/src/core/rollback-point.test.ts +274 -24
- package/src/core/rollback-point.ts +745 -160
- package/src/core/rollback.ts +5 -3
- package/src/core/types.ts +19 -0
- package/src/foreign/registry.test.ts +1 -1
- package/src/foreign/registry.ts +1 -1
- package/src/index.ts +73 -10
- package/src/routes/messages.ts +2 -0
- package/src/routes/prefs.ts +31 -2
- package/src/routes/recovery.ts +122 -7
- package/src/routes/sync.ts +15 -1
- package/src/schema/manifest-schema.test.ts +4 -4
- package/src/schema/registry.test.ts +9 -2
- package/src/schema/section-registry.ts +32 -3
- package/src/schema/types.ts +2 -1
- package/src/sync/sync-credentials.test.ts +119 -0
- package/src/sync/sync-engine.ts +51 -11
- package/src/sync/transport.test.ts +1 -0
- package/src/sync/ui-prefs.test.ts +44 -1
- package/src/sync/ui-prefs.ts +39 -0
- package/src/ui/diff-view.test.ts +9 -1
- package/src/ui/diff-view.ts +22 -0
- package/src/ui/export-flow.ts +2 -0
- package/src/ui/i18n.ts +8 -4
- package/src/ui/rollback-view.test.ts +17 -0
- package/src/ui/rollback-view.ts +66 -0
- package/src/ui/test-helpers.ts +2 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内置来源 —— 只放已取证的。
|
|
3
|
+
*
|
|
4
|
+
* 收录口径(2026-10-14 调研:按 GitHub star + npm 周下载量排序后逐一核对):
|
|
5
|
+
* - 有自有数据目录(不在 DSH 三个核心存储里,也不在 profile 树里);
|
|
6
|
+
* - 目录里不含凭据(~/.commandcode/auth.json、~/.dsh/agy-accounts 这类命中即整来源不收录 ——
|
|
7
|
+
* 文件类分区的内容不进 SecretScanner 的剥离路径,带出去就是明文);
|
|
8
|
+
* - 位置由插件自己的 README 明示,或本机实测。
|
|
9
|
+
*
|
|
10
|
+
* 未收录但确实有数据目录的插件(如实登记,避免「没列出来 = 不存在」的误读):
|
|
11
|
+
* - dsh-better-sidebar(4175 star):状态全在 DSH settings/storages,已被既有分区覆盖;
|
|
12
|
+
* - @mars-sea/dsh-commandcode-provider(389 star):~/.commandcode/ 含 auth.json(凭据);
|
|
13
|
+
* - dsh-agy-link(111 star):~/.dsh/agy-accounts 为账号池(凭据)。
|
|
14
|
+
*/
|
|
15
|
+
export const BUILTIN_PLUGIN_DATA_SOURCES = [
|
|
16
|
+
{
|
|
17
|
+
// README「配置与数据」表逐项明示:数据目录 + 缓存/上传/官方资源的覆盖变量
|
|
18
|
+
id: 'wallpaper-engine',
|
|
19
|
+
packages: ['dsh-plugin-wallpaper-engine'],
|
|
20
|
+
evidence: 'documented',
|
|
21
|
+
areas: [
|
|
22
|
+
{
|
|
23
|
+
relPath: '.dsh-wallpaper-engine',
|
|
24
|
+
role: 'config',
|
|
25
|
+
envOverrides: ['DSH_WE_DATA_DIR'],
|
|
26
|
+
// cache = 抽帧/转码/缩略图(可再生产物);uploads 进 assets;bin 由插件管理(按需下载的运行时)
|
|
27
|
+
excludeRel: ['cache', 'uploads', 'bin'],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
relPath: '.dsh-wallpaper-engine/uploads',
|
|
31
|
+
role: 'assets',
|
|
32
|
+
envOverrides: ['DSH_WE_DATA_DIR', 'DSH_WE_UPLOAD_DIR'],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
relPath: '.dsh-wallpaper-engine/cache',
|
|
36
|
+
role: 'cache',
|
|
37
|
+
envOverrides: ['DSH_WE_DATA_DIR', 'DSH_WE_CACHE_DIR'],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
// 本机实测(~/.mnemon 下 runtime / data / documents 三个子目录);MNEMON_DATA_DIR 覆盖整个根
|
|
43
|
+
id: 'mnemon',
|
|
44
|
+
packages: ['dsh-mnemon'],
|
|
45
|
+
evidence: 'measured',
|
|
46
|
+
areas: [
|
|
47
|
+
{ relPath: '.mnemon/runtime', role: 'config', envOverrides: ['MNEMON_DATA_DIR'] },
|
|
48
|
+
{ relPath: '.mnemon/data', role: 'assets', envOverrides: ['MNEMON_DATA_DIR'] },
|
|
49
|
+
{ relPath: '.mnemon/documents', role: 'assets', envOverrides: ['MNEMON_DATA_DIR'] },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
// README 明示 ~/.fastctx/ 为持久状态(只有 --purge-fastctx 才删)
|
|
54
|
+
id: 'dsh-ops',
|
|
55
|
+
packages: ['dsh-ops'],
|
|
56
|
+
evidence: 'documented',
|
|
57
|
+
areas: [{ relPath: '.fastctx', role: 'assets' }],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
// README 明示:所有设置落 ~/.dsh/.dsh-any-background-data/(不是 localStorage)
|
|
61
|
+
id: 'any-background',
|
|
62
|
+
packages: ['dsh-any-background'],
|
|
63
|
+
evidence: 'documented',
|
|
64
|
+
areas: [{ relPath: '.dsh/.dsh-any-background-data', role: 'config' }],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// README 明示 ~/.opencode2dsh/adapter-status.json
|
|
68
|
+
id: 'opencode2dsh',
|
|
69
|
+
packages: ['@opencode2dsh/dsh-plugin'],
|
|
70
|
+
evidence: 'documented',
|
|
71
|
+
areas: [{ relPath: '.opencode2dsh', role: 'config' }],
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
/** 角色 → 分区(cache 不属于任何分区) */
|
|
75
|
+
export function sectionOfRole(role) {
|
|
76
|
+
if (role === 'config')
|
|
77
|
+
return 'pluginData';
|
|
78
|
+
if (role === 'assets')
|
|
79
|
+
return 'pluginAssets';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/** 该分区要收集的区域清单(保序:来源顺序 → 区域内顺序) */
|
|
83
|
+
export function areasForSection(sources, section) {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const source of sources) {
|
|
86
|
+
for (const area of source.areas) {
|
|
87
|
+
if (sectionOfRole(area.role) === section)
|
|
88
|
+
out.push({ source, area });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* home 相对路径归一化 + 安全判定。
|
|
95
|
+
*
|
|
96
|
+
* 只接受 POSIX 相对路径:拒绝绝对路径(含 Windows 盘符写法 C:/x)、..、反斜杠、空段、
|
|
97
|
+
* 以 / 结尾、含 NUL 的值。
|
|
98
|
+
* 返回 null = 调用方必须报错/跳过,绝不「尽力而为」地拼一个路径出来。
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeHomeRel(value) {
|
|
101
|
+
if (typeof value !== 'string')
|
|
102
|
+
return null;
|
|
103
|
+
const v = value.trim().replace(/\\/g, '/');
|
|
104
|
+
if (v === '' || v.startsWith('/') || /^[A-Za-z]:/.test(v) || v.includes('\u0000'))
|
|
105
|
+
return null;
|
|
106
|
+
if (v.endsWith('/'))
|
|
107
|
+
return null;
|
|
108
|
+
for (const part of v.split('/')) {
|
|
109
|
+
if (part === '' || part === '.' || part === '..')
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return v;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 目录区域的排除判定(issue:**excludeRel 是区域相对路径,rel 是 home 相对路径**)。
|
|
116
|
+
*
|
|
117
|
+
* 必须先把 rel 折成区域相对再比:`excludeRel: ['uploads']` 配 `.dsh-wallpaper-engine` 区域时,
|
|
118
|
+
* 真正的判别对象是 `uploads/a.mp4`(区域相对),不是 `.dsh-wallpaper-engine/uploads/a.mp4`(home 相对)——
|
|
119
|
+
* 直接拿 home 相对路径去 startsWith,排除项永远不命中,缓存与大资产会悄悄跟着配置分区一起进包。
|
|
120
|
+
*/
|
|
121
|
+
export function isExcludedRel(area, rel) {
|
|
122
|
+
const inner = rel === area.relPath
|
|
123
|
+
? ''
|
|
124
|
+
: rel.startsWith(area.relPath + '/') ? rel.slice(area.relPath.length + 1) : null;
|
|
125
|
+
if (inner === null || inner === '')
|
|
126
|
+
return false;
|
|
127
|
+
for (const ex of area.excludeRel ?? []) {
|
|
128
|
+
if (inner === ex || inner.startsWith(ex + '/'))
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/** rel 是否属于该区域(文件区域 = 精确相等;目录区域 = 精确相等或前缀) */
|
|
134
|
+
export function relInArea(area, rel) {
|
|
135
|
+
if (rel === area.relPath)
|
|
136
|
+
return true;
|
|
137
|
+
return rel.startsWith(area.relPath + '/');
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 路径 → 区域(最长前缀优先)。
|
|
141
|
+
*
|
|
142
|
+
* 为什么必须最长优先:.dsh-wallpaper-engine 与 .dsh-wallpaper-engine/uploads 同时是区域,
|
|
143
|
+
* 短前缀先匹配会把上传的壁纸算进「配置」分区(用户取消勾选大资产也照样带走几百 MB)。
|
|
144
|
+
*/
|
|
145
|
+
export function areaOfPath(entries, rel) {
|
|
146
|
+
let best = null;
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (!relInArea(entry.area, rel))
|
|
149
|
+
continue;
|
|
150
|
+
if (isExcludedRel(entry.area, rel))
|
|
151
|
+
continue;
|
|
152
|
+
if (best === null || entry.area.relPath.length > best.area.relPath.length)
|
|
153
|
+
best = entry;
|
|
154
|
+
}
|
|
155
|
+
return best;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 解析一个已安装插件 package.json 里的自述声明(dsh.dataDirs)—— 「让别的插件自己适配」的钩子。
|
|
159
|
+
*
|
|
160
|
+
* 契约(对外文档见 docs/spec/plugin-data-declaration.md):
|
|
161
|
+
* { "dsh": { "dataDirs": [ { "path": ".my-plugin", "role": "config", "env": ["MY_PLUGIN_DIR"] } ] } }
|
|
162
|
+
*
|
|
163
|
+
* 形状不认识就整条丢弃(返回 null),绝不猜;path 非法(绝对路径 / .. / 空段)同样丢弃。
|
|
164
|
+
*/
|
|
165
|
+
export function parseDeclaredDataDirs(packageName, manifest) {
|
|
166
|
+
if (manifest === null || typeof manifest !== 'object')
|
|
167
|
+
return null;
|
|
168
|
+
const dsh = manifest.dsh;
|
|
169
|
+
if (dsh === null || typeof dsh !== 'object')
|
|
170
|
+
return null;
|
|
171
|
+
const raw = dsh.dataDirs;
|
|
172
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
173
|
+
return null;
|
|
174
|
+
const areas = [];
|
|
175
|
+
for (const item of raw) {
|
|
176
|
+
if (item === null || typeof item !== 'object')
|
|
177
|
+
return null;
|
|
178
|
+
const rec = item;
|
|
179
|
+
const relPath = normalizeHomeRel(rec.path);
|
|
180
|
+
if (relPath === null)
|
|
181
|
+
return null;
|
|
182
|
+
const role = rec.role === 'assets' ? 'assets' : rec.role === 'cache' ? 'cache' : 'config';
|
|
183
|
+
const envOverrides = Array.isArray(rec.env)
|
|
184
|
+
? rec.env.filter((e) => typeof e === 'string' && e !== '')
|
|
185
|
+
: [];
|
|
186
|
+
const excludes = Array.isArray(rec.exclude)
|
|
187
|
+
? rec.exclude.map((e) => normalizeHomeRel(e)).filter((e) => e !== null)
|
|
188
|
+
: [];
|
|
189
|
+
areas.push({
|
|
190
|
+
relPath,
|
|
191
|
+
role,
|
|
192
|
+
...(envOverrides.length > 0 ? { envOverrides } : {}),
|
|
193
|
+
...(excludes.length > 0 ? { excludeRel: excludes } : {}),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return { id: 'declared:' + packageName, packages: [packageName], evidence: 'documented', areas };
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=plugin-data-registry.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合自动留点(回滚点)的**进程内开关**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它(issue #88 / #89):
|
|
5
|
+
* · #88:回滚点与插件主旨(配置备份 / 同步)无关,却随 bundle 默认启用、没有任何关闭入口;
|
|
6
|
+
* · #89:默认启用时,每个 `turn/start` 都要留点,而台账写入是「整份读改写」——
|
|
7
|
+
* 真机 `index.json` 涨到 451 MiB 后宿主反复 V8 heap OOM(exit 134)。
|
|
8
|
+
*
|
|
9
|
+
* 设计(三条,别简化):
|
|
10
|
+
* 1. **缺省关闭**:`readRollbackEnabledSync()` 读不到 / 形状不符一律 false —— fail-closed,
|
|
11
|
+
* 绝不因为「文件没写成功」就把功能打开。
|
|
12
|
+
* 2. **不订阅 = 真的不订阅**:宿主订阅 `session/event` 的那条链由这里驱动 attach / detach。
|
|
13
|
+
* 关闭时**必须解绑**,而不是「订阅了但回调里 return」(后者仍然吃事件分发路径)。
|
|
14
|
+
* 3. **活切换**:开关可以在不重载插件的前提下改变 —— 写偏好的一方调 `setRollbackEnabled`,
|
|
15
|
+
* 订阅方通过 `onRollbackEnabledChange` 收到通知。事件回调**不读文件**(每回合读一次磁盘
|
|
16
|
+
* 正是本 issue 要消灭的开销形态)。
|
|
17
|
+
*/
|
|
18
|
+
type RollbackEnabledListener = (enabled: boolean) => void;
|
|
19
|
+
/** 当前是否启用(同步、零 I/O —— 事件回调用它做第一道门)。 */
|
|
20
|
+
export declare function isRollbackEnabled(): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* 设置开关并通知订阅方。值未变化时**不通知**(避免每次保存偏好都触发一次 attach/detach 抖动)。
|
|
23
|
+
* 监听器里的异常一律吞掉:一个监听器写坏了不能影响开关本身,更不能影响宿主启动。
|
|
24
|
+
*/
|
|
25
|
+
export declare function setRollbackEnabled(value: boolean): void;
|
|
26
|
+
/** 注册开关变化监听;返回解绑函数(解绑幂等)。 */
|
|
27
|
+
export declare function onRollbackEnabledChange(listener: RollbackEnabledListener): () => void;
|
|
28
|
+
/**
|
|
29
|
+
* 测试专用:把开关与监听器一并复位。
|
|
30
|
+
* 生产路径**绝不**调用它 —— 重置监听器会让宿主那条订阅链永远收不到后续切换。
|
|
31
|
+
*/
|
|
32
|
+
export declare function resetRollbackGateForTest(): void;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回合自动留点(回滚点)的**进程内开关**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它(issue #88 / #89):
|
|
5
|
+
* · #88:回滚点与插件主旨(配置备份 / 同步)无关,却随 bundle 默认启用、没有任何关闭入口;
|
|
6
|
+
* · #89:默认启用时,每个 `turn/start` 都要留点,而台账写入是「整份读改写」——
|
|
7
|
+
* 真机 `index.json` 涨到 451 MiB 后宿主反复 V8 heap OOM(exit 134)。
|
|
8
|
+
*
|
|
9
|
+
* 设计(三条,别简化):
|
|
10
|
+
* 1. **缺省关闭**:`readRollbackEnabledSync()` 读不到 / 形状不符一律 false —— fail-closed,
|
|
11
|
+
* 绝不因为「文件没写成功」就把功能打开。
|
|
12
|
+
* 2. **不订阅 = 真的不订阅**:宿主订阅 `session/event` 的那条链由这里驱动 attach / detach。
|
|
13
|
+
* 关闭时**必须解绑**,而不是「订阅了但回调里 return」(后者仍然吃事件分发路径)。
|
|
14
|
+
* 3. **活切换**:开关可以在不重载插件的前提下改变 —— 写偏好的一方调 `setRollbackEnabled`,
|
|
15
|
+
* 订阅方通过 `onRollbackEnabledChange` 收到通知。事件回调**不读文件**(每回合读一次磁盘
|
|
16
|
+
* 正是本 issue 要消灭的开销形态)。
|
|
17
|
+
*/
|
|
18
|
+
let enabled = false;
|
|
19
|
+
const listeners = new Set();
|
|
20
|
+
/** 当前是否启用(同步、零 I/O —— 事件回调用它做第一道门)。 */
|
|
21
|
+
export function isRollbackEnabled() {
|
|
22
|
+
return enabled;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 设置开关并通知订阅方。值未变化时**不通知**(避免每次保存偏好都触发一次 attach/detach 抖动)。
|
|
26
|
+
* 监听器里的异常一律吞掉:一个监听器写坏了不能影响开关本身,更不能影响宿主启动。
|
|
27
|
+
*/
|
|
28
|
+
export function setRollbackEnabled(value) {
|
|
29
|
+
const next = value === true;
|
|
30
|
+
if (next === enabled)
|
|
31
|
+
return;
|
|
32
|
+
enabled = next;
|
|
33
|
+
for (const listener of [...listeners]) {
|
|
34
|
+
try {
|
|
35
|
+
listener(next);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* 监听器自己的问题;开关状态已生效 */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** 注册开关变化监听;返回解绑函数(解绑幂等)。 */
|
|
43
|
+
export function onRollbackEnabledChange(listener) {
|
|
44
|
+
listeners.add(listener);
|
|
45
|
+
return () => {
|
|
46
|
+
listeners.delete(listener);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 测试专用:把开关与监听器一并复位。
|
|
51
|
+
* 生产路径**绝不**调用它 —— 重置监听器会让宿主那条订阅链永远收不到后续切换。
|
|
52
|
+
*/
|
|
53
|
+
export function resetRollbackGateForTest() {
|
|
54
|
+
enabled = false;
|
|
55
|
+
listeners.clear();
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=rollback-gate.js.map
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { RollbackPointKind, RollbackPointRecord, RollbackPointSummary, RollbackSegmentSummary, RollbackStateKind } from './rollback-point.ts';
|
|
2
|
+
/** 清单格式版本(与**记录**的 schemaVersion 无关:后者是记录内容的版本,恒为 2) */
|
|
3
|
+
export declare const ROLLBACK_LEDGER_SCHEMA_VERSION = 3;
|
|
4
|
+
/** 单条记录目录名 */
|
|
5
|
+
export declare const ROLLBACK_RECORDS_DIR_NAME = "records";
|
|
6
|
+
/** 内容寻址载荷目录名 */
|
|
7
|
+
export declare const ROLLBACK_PAYLOADS_DIR_NAME = "payloads";
|
|
8
|
+
/**
|
|
9
|
+
* 回退前像暂存目录名(`<root>/undo/<pid>-<ts36>-<8hex>/`)。
|
|
10
|
+
* 名字以 pid 开头是**清理判据的一部分**(见 cleanupOrphanTempFiles);改命名必须同步改 `UNDO_DIR_RE`。
|
|
11
|
+
*/
|
|
12
|
+
export declare const ROLLBACK_UNDO_DIR_NAME = "undo";
|
|
13
|
+
/** v2 单文件迁移成功后的保留名(改名,不是删除) */
|
|
14
|
+
export declare const ROLLBACK_LEDGER_BACKUP_NAME = "index.v2.backup.json";
|
|
15
|
+
/**
|
|
16
|
+
* 台账(records/ + payloads/)缺省字节预算。
|
|
17
|
+
*
|
|
18
|
+
* 为什么需要它:ROLLBACK_MAX_RECORDS(50)只限**条数**,一条记录的工作区载荷可以到几十 MB
|
|
19
|
+
* (本机实测 50 条 = 473 MB,直接把宿主撑爆)。字节预算与条数预算**互相独立**,两者都生效。
|
|
20
|
+
*/
|
|
21
|
+
export declare const ROLLBACK_DEFAULT_LEDGER_BYTES: number;
|
|
22
|
+
/** 记录文件名 = 记录 id;只允许可自证安全的字符(防篡改清单把路径引出去) */
|
|
23
|
+
export declare const LEDGER_RECORD_ID_RE: RegExp;
|
|
24
|
+
/** 台账里一条记录的**纯标量**头(与 RollbackTrack 的可序列化部分逐字对应) */
|
|
25
|
+
export interface RollbackLedgerTrackHeader {
|
|
26
|
+
kind: RollbackStateKind;
|
|
27
|
+
capturedAt: number;
|
|
28
|
+
ok: boolean;
|
|
29
|
+
degraded: boolean;
|
|
30
|
+
fingerprint: string | null;
|
|
31
|
+
reasonCode: string | null;
|
|
32
|
+
reasonDetail: string | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 清单条目:**绝不含大数组**。段摘要(segments)在写入时由注入的 summarize 算出,
|
|
36
|
+
* list() 直接回放 —— 口径只有一份,且清单大小与工作区文件数无关。
|
|
37
|
+
*/
|
|
38
|
+
export interface RollbackLedgerManifestEntry {
|
|
39
|
+
id: string;
|
|
40
|
+
kind: RollbackPointKind;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
spreadMs: number;
|
|
43
|
+
protected: boolean;
|
|
44
|
+
incomplete: boolean;
|
|
45
|
+
note: string | null;
|
|
46
|
+
sessionId: string | null;
|
|
47
|
+
unitId: string | null;
|
|
48
|
+
turnSeq: number | null;
|
|
49
|
+
boundarySeq: number | null;
|
|
50
|
+
/** 该条记录文件的字节数(预算淘汰时用于估算可释放空间) */
|
|
51
|
+
recordBytes: number;
|
|
52
|
+
workspace: RollbackLedgerTrackHeader & {
|
|
53
|
+
/** 内容寻址载荷哈希;null = 该态没有载荷(捕获失败) */
|
|
54
|
+
payload: string | null;
|
|
55
|
+
/** 载荷文件字节数(**同一哈希只算一次**,预算与统计用) */
|
|
56
|
+
payloadBytes: number;
|
|
57
|
+
};
|
|
58
|
+
session: RollbackLedgerTrackHeader & {
|
|
59
|
+
/** 会话字节副本的对象仓键;null = 没有副本(回收对象时用,不必读记录文件) */
|
|
60
|
+
object: string | null;
|
|
61
|
+
};
|
|
62
|
+
/** 冻结口径的段摘要(= summarizeRollbackPoint(record).segments) */
|
|
63
|
+
segments: RollbackSegmentSummary[];
|
|
64
|
+
}
|
|
65
|
+
/** 清单条目 -> 列表投影(逐字等于旧的 summarizeRollbackPoint(record),因为它是写入时算好的) */
|
|
66
|
+
export declare function entrySummaryOf(entry: RollbackLedgerManifestEntry): RollbackPointSummary;
|
|
67
|
+
export interface RollbackLedgerMigration {
|
|
68
|
+
/** 是否真的改了磁盘布局(v2 -> v3,或从 records/ 重建清单) */
|
|
69
|
+
migrated: boolean;
|
|
70
|
+
/** 成功落盘的记录条数 */
|
|
71
|
+
records: number;
|
|
72
|
+
/** 形状不合格被跳过的条数(如实计数,绝不静默) */
|
|
73
|
+
skipped: number;
|
|
74
|
+
/** 非空 = 迁移失败(原文件未动,下次启动重试) */
|
|
75
|
+
error?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface RollbackLedgerOptions {
|
|
78
|
+
/** 回滚点库根目录(= dataDir/rollback-points) */
|
|
79
|
+
root: string;
|
|
80
|
+
/** 冻结口径的摘要(rollback-point.ts 的 summarizeRollbackPoint) */
|
|
81
|
+
summarize: (record: RollbackPointRecord) => RollbackPointSummary;
|
|
82
|
+
/** 冻结口径的记录校验(rollback-point.ts 的 isRollbackPointRecord) */
|
|
83
|
+
isLedgerRecord: (value: unknown) => value is RollbackPointRecord;
|
|
84
|
+
/** 字节预算;缺省 ROLLBACK_DEFAULT_LEDGER_BYTES */
|
|
85
|
+
bytesBudget?: number;
|
|
86
|
+
now?: () => number;
|
|
87
|
+
/** best-effort 日志(绝不抛给调用方) */
|
|
88
|
+
log?: (line: string) => void;
|
|
89
|
+
/**
|
|
90
|
+
* 每次「读一条记录文件 / 一份载荷文件」前的观测钩子(生产不传)。
|
|
91
|
+
* 为什么要有它:内存护栏需要**确定性**证据 —— 「单次 append 绝不读别人的记录文件」
|
|
92
|
+
* 只能靠数读盘次数来钉,而不是靠不可复现的堆采样。
|
|
93
|
+
*/
|
|
94
|
+
onFileRead?: (file: string) => void;
|
|
95
|
+
/** 写 seam(缺省 = atomicWriteFile);测试用它注入「第 N 次写失败」以验证迁移中断可重试 */
|
|
96
|
+
writeFile?: (target: string, bytes: Uint8Array) => Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
/** 一次「准备写一条记录」的产物(先把清单条目算出来,预算淘汰才能在写之前决策) */
|
|
99
|
+
export interface RollbackLedgerPrepared {
|
|
100
|
+
entry: RollbackLedgerManifestEntry;
|
|
101
|
+
/** 记录文件内容(workspace.payload 已换成哈希引用) */
|
|
102
|
+
recordJson: string;
|
|
103
|
+
/** 载荷文件内容;null = 该态没有载荷 */
|
|
104
|
+
payloadJson: string | null;
|
|
105
|
+
payloadHash: string | null;
|
|
106
|
+
}
|
|
107
|
+
export interface RollbackLedgerStats {
|
|
108
|
+
/** records/ 下的文件数 */
|
|
109
|
+
records: number;
|
|
110
|
+
/** records/ + payloads/ 的总字节 */
|
|
111
|
+
bytes: number;
|
|
112
|
+
}
|
|
113
|
+
export interface RollbackTempCleanupResult {
|
|
114
|
+
removed: number;
|
|
115
|
+
bytes: number;
|
|
116
|
+
/** 形似临时文件但**判据不足**(判不准就不删) */
|
|
117
|
+
skipped: number;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 删除 rollback-points/ 下**形状可自证**的崩溃残留原子写临时文件。
|
|
121
|
+
*
|
|
122
|
+
* 判据(三条全中才删,判不准一律不删):
|
|
123
|
+
* 1. 名字逐字符合 atomic-write.ts 的 tmpPath() 形态:.dshcm.<base>.<pid>.<16hex>.tmp;
|
|
124
|
+
* 2. 该 pid **不是本进程**(本进程的 tmp 可能正在写);
|
|
125
|
+
* 3. 该 pid 进程已不存在(崩溃残留)—— 仍然活着的进程可能正在写,绝不抢。
|
|
126
|
+
* 只扫根目录与它的一级子目录(records/ payloads/ objects/),不递归。
|
|
127
|
+
*/
|
|
128
|
+
export declare function cleanupOrphanTempFiles(root: string): Promise<RollbackTempCleanupResult>;
|
|
129
|
+
/** 清单条目校验:形状不对就**丢掉并计数**(与 v2 的 records.filter(isRollbackPointRecord) 同一条路) */
|
|
130
|
+
export declare function normalizeManifestEntry(value: unknown): RollbackLedgerManifestEntry | null;
|
|
131
|
+
export declare class RollbackLedger {
|
|
132
|
+
readonly root: string;
|
|
133
|
+
readonly manifestPath: string;
|
|
134
|
+
readonly backupPath: string;
|
|
135
|
+
readonly recordsDir: string;
|
|
136
|
+
readonly payloadsDir: string;
|
|
137
|
+
readonly objectsDir: string;
|
|
138
|
+
readonly bytesBudget: number;
|
|
139
|
+
private readonly summarize;
|
|
140
|
+
private readonly isLedgerRecord;
|
|
141
|
+
private readonly nowFn;
|
|
142
|
+
private readonly log;
|
|
143
|
+
private readonly onFileRead;
|
|
144
|
+
private readonly writeSeam;
|
|
145
|
+
/**
|
|
146
|
+
* 被**字节预算**淘汰的自动点累计数(可观测;与「条数上限」淘汰分开计数)。
|
|
147
|
+
*
|
|
148
|
+
* 为什么必须**持久化**而不是只记在实例上:宿主每个请求都新建一个引擎(路由侧),
|
|
149
|
+
* 只记在实例上的话这个数字永远是 0 —— 界面就永远看不到「已经因为预算淘汰过 N 个」。
|
|
150
|
+
* 值随清单落盘(`evicted` 字段),读清单时取「磁盘值与内存值的大者」,
|
|
151
|
+
* 这样「先 markEvicted 再读清单」的时序也不会把刚加上的计数抹掉。
|
|
152
|
+
*/
|
|
153
|
+
private evicted;
|
|
154
|
+
constructor(options: RollbackLedgerOptions);
|
|
155
|
+
get evictedCount(): number;
|
|
156
|
+
markEvicted(count: number): void;
|
|
157
|
+
private warn;
|
|
158
|
+
private write;
|
|
159
|
+
private readText;
|
|
160
|
+
private exists;
|
|
161
|
+
private recordPath;
|
|
162
|
+
private payloadPath;
|
|
163
|
+
/**
|
|
164
|
+
* 清单文件的**代际戳**(mtimeMs + 字节数)。跨进程乐观并发用:留点侧在读清单前后各取一次,
|
|
165
|
+
* 不一致说明另一个实例写过 ⇒ 重读重试,绝不拿自己的快照覆盖别人的清单。
|
|
166
|
+
* 读不到返回 null = **不参与判定**(可选探针失败绝不阻塞留点)。
|
|
167
|
+
*/
|
|
168
|
+
manifestStamp(): Promise<string | null>;
|
|
169
|
+
/** 这份载荷是否已经落过盘(内容寻址 ⇒ 已存在就是同一份,可零写入;也是「本次新增字节」的判据) */
|
|
170
|
+
hasPayload(hash: string): Promise<boolean>;
|
|
171
|
+
readManifest(): Promise<{
|
|
172
|
+
entries: RollbackLedgerManifestEntry[];
|
|
173
|
+
error?: string;
|
|
174
|
+
}>;
|
|
175
|
+
private findMissingRecordFiles;
|
|
176
|
+
/** 原子写清单(小文件;条目由调用方决定) */
|
|
177
|
+
writeManifest(entries: readonly RollbackLedgerManifestEntry[]): Promise<void>;
|
|
178
|
+
/** 把完整记录拆成「清单条目 + 记录文件内容 + 载荷文件内容」(**不做 IO**,纯函数) */
|
|
179
|
+
prepareRecord(record: RollbackPointRecord): RollbackLedgerPrepared;
|
|
180
|
+
/**
|
|
181
|
+
* 落盘:载荷(内容寻址;同一哈希已存在则**零写入**)+ 记录文件。
|
|
182
|
+
* 先载荷后记录:中途失败只留一份没人引用的载荷(垃圾,不是坏数据),下次 prune 回收。
|
|
183
|
+
*/
|
|
184
|
+
writePrepared(prepared: RollbackLedgerPrepared): Promise<void>;
|
|
185
|
+
/** 读校验过的载荷(哈希不符 / 形状不符 -> error,绝不返回半个) */
|
|
186
|
+
private readVerifiedPayload;
|
|
187
|
+
/** 读一条**完整**记录(引用解析对调用方透明);任何一步不成立就如实失败 */
|
|
188
|
+
readRecord(id: string): Promise<{
|
|
189
|
+
record?: RollbackPointRecord;
|
|
190
|
+
error?: string;
|
|
191
|
+
}>;
|
|
192
|
+
/** 删记录文件(返回实际释放字节;ENOENT 不计),**不**动载荷与对象 */
|
|
193
|
+
deleteRecords(ids: readonly string[]): Promise<{
|
|
194
|
+
removed: number;
|
|
195
|
+
bytes: number;
|
|
196
|
+
}>;
|
|
197
|
+
/** 没人引用的载荷文件一律删(引用计数靠扫清单得到,**不读载荷内容**) */
|
|
198
|
+
prunePayloads(entries: readonly RollbackLedgerManifestEntry[]): Promise<{
|
|
199
|
+
removed: number;
|
|
200
|
+
bytes: number;
|
|
201
|
+
}>;
|
|
202
|
+
/**
|
|
203
|
+
* 内容寻址对象仓回收(**重写版,内存 ∝ 单份载荷 + 对象键集合**)。
|
|
204
|
+
*
|
|
205
|
+
* 旧实现要先在内存里拿到「所有记录的全部 files 数组」,于是回收一次就等于把整份台账读进内存
|
|
206
|
+
* (issue #89 的 OOM 现场之一)。现在:扫清单 -> **逐个读引用到的载荷文件(一次只驻留一份)**
|
|
207
|
+
* -> 累计 referenced 对象键 -> 扫 objects/ 删无引用项。
|
|
208
|
+
*
|
|
209
|
+
* 任何一份被引用的载荷读不出来(缺失 / 哈希不符 / 形状不符)⇒ **整体放弃回收**(aborted 非空):
|
|
210
|
+
* 读不到的载荷里有什么对象谁也不知道,此时删对象可能删掉活数据 —— fail-closed。
|
|
211
|
+
*/
|
|
212
|
+
collectObjects(entries: readonly RollbackLedgerManifestEntry[]): Promise<{
|
|
213
|
+
removed: number;
|
|
214
|
+
bytes: number;
|
|
215
|
+
referencedPaths: Set<string>;
|
|
216
|
+
aborted: string | null;
|
|
217
|
+
}>;
|
|
218
|
+
/** records/ + payloads/ 的**实际**磁盘占用与文件数(诚实:含尚未回收的孤儿文件) */
|
|
219
|
+
stats(): Promise<RollbackLedgerStats>;
|
|
220
|
+
private sniffSchemaVersion;
|
|
221
|
+
private acquireMigrationLock;
|
|
222
|
+
/**
|
|
223
|
+
* v2 单文件 -> v3:**流式**切记录、逐条落盘,最后才动原文件。
|
|
224
|
+
*
|
|
225
|
+
* 幂等与崩溃安全:
|
|
226
|
+
* · 步骤 1(流式落 records/ + payloads/)可重复执行 —— id 稳定,重复写是覆盖,不产生重复条目;
|
|
227
|
+
* · 步骤 2 才把 v2 文件改名为 index.v2.backup.json(**成功之前原文件一动不动**);
|
|
228
|
+
* · 若在步骤 2 与 3 之间崩溃(没有 index.json、有 index.v2.backup.json),下次启动走
|
|
229
|
+
* rebuildManifest() 从 records/ 重建清单 —— 清单自证:每条 id 必须在 records/ 找得到。
|
|
230
|
+
*/
|
|
231
|
+
/**
|
|
232
|
+
* 清单**读不出来**时从 `records/` 重建,并返回重建后重新读到的清单。
|
|
233
|
+
*
|
|
234
|
+
* 为什么公开:留点侧(`appendRecord`)在 `readManifest` 报错时**必须**走这里 —— 旧点的最终真相
|
|
235
|
+
* 在记录文件里,而拿一个空 entries 直接覆盖清单会把旧点整批抹掉、紧接着 `prunePayloads` 还会删掉
|
|
236
|
+
* 它们的载荷(永久不可恢复)。重建后仍读不出来才由调用方拒写(fail-closed)。
|
|
237
|
+
*/
|
|
238
|
+
recoverManifest(): Promise<{
|
|
239
|
+
entries: RollbackLedgerManifestEntry[];
|
|
240
|
+
error?: string;
|
|
241
|
+
}>;
|
|
242
|
+
migrateIfNeeded(): Promise<RollbackLedgerMigration>;
|
|
243
|
+
private migrateV2;
|
|
244
|
+
/** 从 records/ 重建清单(崩溃恢复;清单自证:找不到记录文件的 id 一律跳过并计数) */
|
|
245
|
+
private rebuildManifest;
|
|
246
|
+
private listRecordIds;
|
|
247
|
+
private readEntriesFromRecords;
|
|
248
|
+
}
|