@max-null/dsh-memory 0.21.0 → 0.22.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/README.md +52 -2
- package/dist/engine.d.ts +68 -0
- package/dist/engine.js +38 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +104 -4
- package/dist/self.d.ts +3 -0
- package/dist/self.js +30 -19
- package/dist/sync-export.d.ts +10 -5
- package/dist/sync-export.js +21 -12
- package/dist/sync-git.d.ts +57 -0
- package/dist/sync-git.js +88 -0
- package/dist/sync-import.d.ts +83 -0
- package/dist/sync-import.js +82 -0
- package/dist/sync-remote.d.ts +81 -0
- package/dist/sync-remote.js +157 -0
- package/dist/sync-service.d.ts +108 -0
- package/dist/sync-service.js +347 -0
- package/dist/sync-tombstone.d.ts +67 -0
- package/dist/sync-tombstone.js +83 -0
- package/dist/sync-writer.d.ts +46 -0
- package/dist/sync-writer.js +85 -0
- package/package.json +2 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步编排:把导出 / 写盘 / 墓碑 / git / 远端五层串成一次操作。
|
|
3
|
+
*
|
|
4
|
+
* 这一层没有自己的算法,只有**顺序**。顺序在这里是有意义的:
|
|
5
|
+
*
|
|
6
|
+
* push 时先写镜像与墓碑、再提交、最后推送——反过来的话,一次失败会留下
|
|
7
|
+
* 「远端已有、本地没有」的状态,下次 push 会把差异当成删除。
|
|
8
|
+
*
|
|
9
|
+
* pull 时先拉、再规划导入、最后逐条落地——导入是唯一会改本地记忆库的一步,
|
|
10
|
+
* 放在最后意味着前面任何一步失败都不会动数据。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-memory/sync-service
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { exportRecords } from "./sync-export.js";
|
|
17
|
+
import { commitMirror, readHistory } from "./sync-git.js";
|
|
18
|
+
import { planImport } from "./sync-import.js";
|
|
19
|
+
import { SYNC_STATE_FILE, TOMBSTONE_DIR, dropTombstoned, planTombstones } from "./sync-tombstone.js";
|
|
20
|
+
import { ensureMirrorRepo, ensureRemote, pullMirror, pushMirror } from "./sync-remote.js";
|
|
21
|
+
import { writeMirror } from "./sync-writer.js";
|
|
22
|
+
/** 作者信息——isomorphic-git 没有可读的全局配置,必须显式给。 */
|
|
23
|
+
const AUTHOR = { name: 'dsh-memory', email: 'dsh-memory@localhost' };
|
|
24
|
+
/** 把一份 unit 文档里的 block 取出来。 */
|
|
25
|
+
function blocksOfDocument(document) {
|
|
26
|
+
const doc = document;
|
|
27
|
+
return doc?.tables?.blocks ?? {};
|
|
28
|
+
}
|
|
29
|
+
/** 记录数组 → unit 文档形状(`exportRecords` 的入参)。 */
|
|
30
|
+
function recordsToDocument(records) {
|
|
31
|
+
const blocks = {};
|
|
32
|
+
for (const record of records) {
|
|
33
|
+
const { id, ...rest } = record;
|
|
34
|
+
blocks[id] = rest;
|
|
35
|
+
}
|
|
36
|
+
return { tables: { blocks } };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 收集当前记忆库应有的镜像文件。
|
|
40
|
+
*
|
|
41
|
+
* 空项目不产生目录——`records/project/<name>/` 里一个文件都没有时,那个目录本身
|
|
42
|
+
* 不该出现在仓库里(否则每加一个空项目就多一个空目录)。
|
|
43
|
+
*
|
|
44
|
+
* @param memory - 引擎实例。
|
|
45
|
+
* @returns 待写入的记录文件列表。
|
|
46
|
+
*/
|
|
47
|
+
export async function collectMirror(memory) {
|
|
48
|
+
const global = await memory.list({ namespace: 'global' });
|
|
49
|
+
const projectDocuments = [];
|
|
50
|
+
for (const cwd of memory.projectCwds()) {
|
|
51
|
+
const records = await memory.list({ namespace: 'project' }, cwd);
|
|
52
|
+
if (records.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
projectDocuments.push({ cwd, document: recordsToDocument(records) });
|
|
55
|
+
}
|
|
56
|
+
return exportRecords({ globalDocument: recordsToDocument(global), projectDocuments }).files;
|
|
57
|
+
}
|
|
58
|
+
/** 从镜像文件列表推出「当前有哪些 id」——墓碑比对的当前态。 */
|
|
59
|
+
function stateOfFiles(files) {
|
|
60
|
+
const global = [];
|
|
61
|
+
const project = {};
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const parts = file.path.replace(/\\/gu, '/').split('/');
|
|
64
|
+
// records/global/<id>.json 或 records/project/<name>/<id>.json
|
|
65
|
+
if (parts[0] !== 'records' || parts.length < 3)
|
|
66
|
+
continue;
|
|
67
|
+
const id = parts[parts.length - 1]?.replace(/\.json$/u, '') ?? '';
|
|
68
|
+
if (id === '')
|
|
69
|
+
continue;
|
|
70
|
+
if (parts[1] === 'global')
|
|
71
|
+
global.push(id);
|
|
72
|
+
else if (parts[1] === 'project' && parts.length >= 4) {
|
|
73
|
+
const name = parts[2] ?? '';
|
|
74
|
+
(project[name] ??= []).push(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { global, project };
|
|
78
|
+
}
|
|
79
|
+
/** 读上一次同步留下的状态;没有时返回 `undefined`(首次同步)。 */
|
|
80
|
+
function readState(dir) {
|
|
81
|
+
const file = join(dir, SYNC_STATE_FILE);
|
|
82
|
+
if (!existsSync(file))
|
|
83
|
+
return undefined;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
86
|
+
return {
|
|
87
|
+
global: Array.isArray(parsed.global) ? parsed.global : [],
|
|
88
|
+
project: typeof parsed.project === 'object' && parsed.project !== null ? parsed.project : {},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// 坏了就当没有:宁可这轮不产生墓碑,也不要基于坏数据删东西。
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** 写状态文件。 */
|
|
97
|
+
function writeState(dir, state) {
|
|
98
|
+
mkdirSync(dir, { recursive: true });
|
|
99
|
+
writeFileSync(join(dir, SYNC_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
100
|
+
}
|
|
101
|
+
/** 把墓碑落成文件(`tombstones/<id>.json`)。 */
|
|
102
|
+
function writeTombstones(dir, tombstones) {
|
|
103
|
+
if (tombstones.length === 0)
|
|
104
|
+
return;
|
|
105
|
+
const target = join(dir, TOMBSTONE_DIR);
|
|
106
|
+
mkdirSync(target, { recursive: true });
|
|
107
|
+
for (const tombstone of tombstones) {
|
|
108
|
+
writeFileSync(join(target, `${tombstone.id}.json`), `${JSON.stringify(tombstone, null, 2)}\n`, 'utf8');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 推一次:收集 → 写镜像 → 写墓碑 → 提交 → 推送。
|
|
113
|
+
*
|
|
114
|
+
* @param memory - 引擎实例。
|
|
115
|
+
* @param config - 同步配置。
|
|
116
|
+
* @param now - 时间注入点(测试用)。
|
|
117
|
+
* @returns 各阶段统计;远端失败时保留本地提交已完成的事实。
|
|
118
|
+
*/
|
|
119
|
+
export async function syncPush(memory, config, now = Date.now()) {
|
|
120
|
+
// 首次同步**必须先 clone**:本地 `git init` 出来的仓库与远端是两条无关的历史线,
|
|
121
|
+
// push 会被拒(`not a simple fast-forward`)。这一步得在写镜像之前,否则写好的内容
|
|
122
|
+
// 会被随后的 clone 覆盖掉。
|
|
123
|
+
if (config.remote !== undefined && config.remote !== '' && !existsSync(join(config.mirrorDir, '.git'))) {
|
|
124
|
+
const ready = await ensureMirrorRepo({
|
|
125
|
+
dir: config.mirrorDir,
|
|
126
|
+
url: config.remote,
|
|
127
|
+
...config.branch === undefined ? {} : { branch: config.branch },
|
|
128
|
+
...config.token === undefined ? {} : { token: config.token },
|
|
129
|
+
});
|
|
130
|
+
if (!ready.cloned) {
|
|
131
|
+
return {
|
|
132
|
+
files: 0,
|
|
133
|
+
written: 0,
|
|
134
|
+
unchanged: 0,
|
|
135
|
+
removed: 0,
|
|
136
|
+
tombstones: 0,
|
|
137
|
+
committed: false,
|
|
138
|
+
pushed: false,
|
|
139
|
+
error: `首次同步无法 clone 远端:${ready.error ?? '未知原因'}`,
|
|
140
|
+
...ready.kind === undefined ? {} : { errorKind: ready.kind },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const files = await collectMirror(memory);
|
|
145
|
+
const write = writeMirror({ targetDir: config.mirrorDir, files });
|
|
146
|
+
// 墓碑:用「上次状态」与「本次要写的 id」对比。首次同步没有上次状态 → 不产生墓碑。
|
|
147
|
+
const previous = readState(config.mirrorDir);
|
|
148
|
+
const current = stateOfFiles(files);
|
|
149
|
+
const tombstones = planTombstones({ previous, current, at: now });
|
|
150
|
+
writeTombstones(config.mirrorDir, tombstones);
|
|
151
|
+
writeState(config.mirrorDir, dropTombstoned(current, tombstones));
|
|
152
|
+
const commit = await commitMirror({
|
|
153
|
+
dir: config.mirrorDir,
|
|
154
|
+
message: `sync: ${files.length} 条记录${tombstones.length > 0 ? `,${tombstones.length} 条墓碑` : ''}`,
|
|
155
|
+
author: AUTHOR,
|
|
156
|
+
});
|
|
157
|
+
if (config.remote === undefined || config.remote === '') {
|
|
158
|
+
return {
|
|
159
|
+
files: files.length,
|
|
160
|
+
written: write.written,
|
|
161
|
+
unchanged: write.unchanged,
|
|
162
|
+
removed: write.removed,
|
|
163
|
+
tombstones: tombstones.length,
|
|
164
|
+
committed: commit.committed,
|
|
165
|
+
...commit.sha === undefined ? {} : { sha: commit.sha },
|
|
166
|
+
pushed: false,
|
|
167
|
+
error: '未配置远端地址(memory.sync.remote)—— 已完成本地提交',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
await ensureRemote(config.mirrorDir, config.remote);
|
|
171
|
+
const remote = await pushMirror({
|
|
172
|
+
dir: config.mirrorDir,
|
|
173
|
+
url: config.remote,
|
|
174
|
+
...config.branch === undefined ? {} : { branch: config.branch },
|
|
175
|
+
...config.token === undefined ? {} : { token: config.token },
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
files: files.length,
|
|
179
|
+
written: write.written,
|
|
180
|
+
unchanged: write.unchanged,
|
|
181
|
+
removed: write.removed,
|
|
182
|
+
tombstones: tombstones.length,
|
|
183
|
+
committed: commit.committed,
|
|
184
|
+
...commit.sha === undefined ? {} : { sha: commit.sha },
|
|
185
|
+
pushed: remote.ok,
|
|
186
|
+
...remote.error === undefined ? {} : { error: remote.error },
|
|
187
|
+
...remote.kind === undefined ? {} : { errorKind: remote.kind },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** 读镜像目录里的记录文件。 */
|
|
191
|
+
function readIncoming(mirrorDir) {
|
|
192
|
+
const root = join(mirrorDir, 'records');
|
|
193
|
+
if (!existsSync(root))
|
|
194
|
+
return [];
|
|
195
|
+
const incoming = [];
|
|
196
|
+
const walk = (dir, segments) => {
|
|
197
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
198
|
+
if (entry.isDirectory()) {
|
|
199
|
+
walk(join(dir, entry.name), [...segments, entry.name]);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!entry.name.endsWith('.json'))
|
|
203
|
+
continue;
|
|
204
|
+
const id = entry.name.replace(/\.json$/u, '');
|
|
205
|
+
try {
|
|
206
|
+
const block = JSON.parse(readFileSync(join(dir, entry.name), 'utf8'));
|
|
207
|
+
if (segments[0] === 'global')
|
|
208
|
+
incoming.push({ id, scope: 'global', block });
|
|
209
|
+
else if (segments[0] === 'project' && segments.length >= 2) {
|
|
210
|
+
incoming.push({ id, scope: 'project', projectName: segments[1] ?? '', block });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// 坏文件跳过:一条损坏不该挡住整次拉取。
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
walk(root, []);
|
|
219
|
+
return incoming;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* 拉一次:拉取 → 规划导入 → 逐条落地。
|
|
223
|
+
*
|
|
224
|
+
* 冲突不自动裁决:两边版本都写进 `conflicts/`,本地版本保留(§3.3)。
|
|
225
|
+
*
|
|
226
|
+
* @param memory - 引擎实例。
|
|
227
|
+
* @param config - 同步配置。
|
|
228
|
+
* @param now - 时间注入点。
|
|
229
|
+
* @returns 导入统计。
|
|
230
|
+
*/
|
|
231
|
+
export async function syncPull(memory, config, now = Date.now()) {
|
|
232
|
+
if (config.remote !== undefined && config.remote !== '') {
|
|
233
|
+
// 同 push:首次必须先 clone,否则本地 init 出来的仓库与远端没有共同祖先。
|
|
234
|
+
if (!existsSync(join(config.mirrorDir, '.git'))) {
|
|
235
|
+
const ready = await ensureMirrorRepo({
|
|
236
|
+
dir: config.mirrorDir,
|
|
237
|
+
url: config.remote,
|
|
238
|
+
...config.branch === undefined ? {} : { branch: config.branch },
|
|
239
|
+
...config.token === undefined ? {} : { token: config.token },
|
|
240
|
+
});
|
|
241
|
+
if (!ready.cloned) {
|
|
242
|
+
return {
|
|
243
|
+
pulled: false,
|
|
244
|
+
imported: 0,
|
|
245
|
+
conflicts: 0,
|
|
246
|
+
orphaned: 0,
|
|
247
|
+
error: `首次同步无法 clone 远端:${ready.error ?? '未知原因'}`,
|
|
248
|
+
...ready.kind === undefined ? {} : { errorKind: ready.kind },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
await ensureRemote(config.mirrorDir, config.remote);
|
|
253
|
+
const remote = await pullMirror({
|
|
254
|
+
dir: config.mirrorDir,
|
|
255
|
+
url: config.remote,
|
|
256
|
+
...config.branch === undefined ? {} : { branch: config.branch },
|
|
257
|
+
...config.token === undefined ? {} : { token: config.token },
|
|
258
|
+
});
|
|
259
|
+
if (!remote.ok) {
|
|
260
|
+
return {
|
|
261
|
+
pulled: false,
|
|
262
|
+
imported: 0,
|
|
263
|
+
conflicts: 0,
|
|
264
|
+
orphaned: 0,
|
|
265
|
+
...remote.error === undefined ? {} : { error: remote.error },
|
|
266
|
+
...remote.kind === undefined ? {} : { errorKind: remote.kind },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const incoming = readIncoming(config.mirrorDir);
|
|
271
|
+
// 本地各层现状
|
|
272
|
+
const globalRecords = await memory.list({ namespace: 'global' });
|
|
273
|
+
const localGlobal = { blocks: blocksOfDocument(recordsToDocument(globalRecords)) };
|
|
274
|
+
const localProjects = {};
|
|
275
|
+
for (const cwd of memory.projectCwds()) {
|
|
276
|
+
const name = (cwd.replace(/\\/gu, '/').split('/').pop() ?? '').toLowerCase();
|
|
277
|
+
if (name === '')
|
|
278
|
+
continue;
|
|
279
|
+
const records = await memory.list({ namespace: 'project' }, cwd);
|
|
280
|
+
localProjects[name] = { blocks: blocksOfDocument(recordsToDocument(records)) };
|
|
281
|
+
}
|
|
282
|
+
const plan = planImport({ incoming, localGlobal, localProjects });
|
|
283
|
+
// 冲突留证:两边都写进 conflicts/
|
|
284
|
+
if (plan.conflicts.length > 0) {
|
|
285
|
+
const dir = join(config.mirrorDir, 'conflicts');
|
|
286
|
+
mkdirSync(dir, { recursive: true });
|
|
287
|
+
for (const conflict of plan.conflicts) {
|
|
288
|
+
const env = config.envName ?? 'local';
|
|
289
|
+
writeFileSync(join(dir, `${conflict.id}.${env}.json`), `${JSON.stringify(conflict, null, 2)}\n`, 'utf8');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// 逐条落地(唯一会改本地记忆库的一步)
|
|
293
|
+
let imported = 0;
|
|
294
|
+
for (const upsert of plan.upserts) {
|
|
295
|
+
const cwd = upsert.scope === 'project' && upsert.projectName !== undefined
|
|
296
|
+
? memory.projectCwds().find(candidate => (candidate.replace(/\\/gu, '/').split('/').pop() ?? '').toLowerCase() === upsert.projectName?.toLowerCase())
|
|
297
|
+
: undefined;
|
|
298
|
+
if (upsert.scope === 'project' && cwd === undefined)
|
|
299
|
+
continue;
|
|
300
|
+
try {
|
|
301
|
+
await memory.adopt(upsert.id, upsert.block, cwd);
|
|
302
|
+
imported += 1;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// 单条失败跳过——一条不合规不该挡住整次拉取。
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
pulled: true,
|
|
310
|
+
imported,
|
|
311
|
+
conflicts: plan.conflicts.length,
|
|
312
|
+
orphaned: plan.orphaned.length,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* 查同步状态。
|
|
317
|
+
*
|
|
318
|
+
* @param config - 同步配置。
|
|
319
|
+
* @returns 镜像目录、最近提交、冲突与墓碑数量。
|
|
320
|
+
*/
|
|
321
|
+
export async function syncStatus(config) {
|
|
322
|
+
const initialized = existsSync(join(config.mirrorDir, '.git'));
|
|
323
|
+
const history = initialized ? await readHistory(config.mirrorDir) : [];
|
|
324
|
+
const conflictsDir = join(config.mirrorDir, 'conflicts');
|
|
325
|
+
const tombstonesDir = join(config.mirrorDir, TOMBSTONE_DIR);
|
|
326
|
+
const countFiles = (dir) => {
|
|
327
|
+
if (!existsSync(dir))
|
|
328
|
+
return 0;
|
|
329
|
+
return readdirSync(dir).filter(name => name.endsWith('.json')).length;
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
mirrorDir: config.mirrorDir,
|
|
333
|
+
initialized,
|
|
334
|
+
...config.remote === undefined ? {} : { remote: config.remote },
|
|
335
|
+
...history[0] === undefined ? {} : { lastCommit: history[0] },
|
|
336
|
+
commits: history.length,
|
|
337
|
+
conflicts: countFiles(conflictsDir),
|
|
338
|
+
tombstones: countFiles(tombstonesDir),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** 便于测试:导出一份当前冲突的快照。 */
|
|
342
|
+
export function conflictFiles(config) {
|
|
343
|
+
const dir = join(config.mirrorDir, 'conflicts');
|
|
344
|
+
if (!existsSync(dir))
|
|
345
|
+
return [];
|
|
346
|
+
return readdirSync(dir).filter(name => name.endsWith('.json'));
|
|
347
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步墓碑:把 `memory_forget` 的真删表达成「记录被修改」,而不是「文件消失」。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要**(设计 §3.1):多写者下,本地看着「git 里有、我这儿没有」有两种可能——
|
|
5
|
+
* 本机删了(该删远端),或另一端刚加、我还没拉到(该拉下来)。两种情况的正确动作**完全
|
|
6
|
+
* 相反**,靠猜一定出错。
|
|
7
|
+
*
|
|
8
|
+
* 区分它们的唯一依据是**上一次同步时本机有哪些 id**:曾同步过、现在没了 ⇒ 本机删的。
|
|
9
|
+
* 所以本模块配一个随仓库走的 `.sync-state.json`。
|
|
10
|
+
*
|
|
11
|
+
* **墓碑不删文件、只改文件**,还顺带避开 git 里最难处理的 modify/delete 冲突:删除与
|
|
12
|
+
* 「另一端改了同一条」于是变成同一类冲突。
|
|
13
|
+
*
|
|
14
|
+
* **不记删前内容**:内容在 git 历史里(文件被删除但仍在历史),墓碑只负责回答
|
|
15
|
+
* 「这条是被删的还是还没同步到」。省下的体积换的是快照体积可控。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-memory/sync-tombstone
|
|
18
|
+
*/
|
|
19
|
+
/** 随同步仓库走的本地状态。 */
|
|
20
|
+
export interface SyncState {
|
|
21
|
+
/** 上次同步时 global 层有哪些 id。 */
|
|
22
|
+
readonly global: readonly string[];
|
|
23
|
+
/** 上次同步时各项目有哪些 id(项目名已小写)。 */
|
|
24
|
+
readonly project: Readonly<Record<string, readonly string[]>>;
|
|
25
|
+
}
|
|
26
|
+
/** 一条墓碑。 */
|
|
27
|
+
export interface Tombstone {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly scope: 'global' | 'project';
|
|
30
|
+
/** `project` 作用域下的项目名。 */
|
|
31
|
+
readonly projectName?: string;
|
|
32
|
+
readonly deletedAt: number;
|
|
33
|
+
}
|
|
34
|
+
/** {@link planTombstones} 的入参。 */
|
|
35
|
+
export interface TombstonePlanOptions {
|
|
36
|
+
/** 上一次同步留下的状态;首次同步时为 `undefined`。 */
|
|
37
|
+
readonly previous: SyncState | undefined;
|
|
38
|
+
/** 当前本地各层实际有哪些 id。 */
|
|
39
|
+
readonly current: SyncState;
|
|
40
|
+
/** 删除时刻(epoch ms);测试可注入。 */
|
|
41
|
+
readonly at: number;
|
|
42
|
+
}
|
|
43
|
+
/** 状态文件在同步仓库里的位置。 */
|
|
44
|
+
export declare const SYNC_STATE_FILE = ".sync-state.json";
|
|
45
|
+
/** 状态文件的相对路径(含墓碑目录)。 */
|
|
46
|
+
export declare const TOMBSTONE_DIR = "tombstones";
|
|
47
|
+
/**
|
|
48
|
+
* 对比上一次与当前的 id 集合,算出本次该写哪些墓碑。
|
|
49
|
+
*
|
|
50
|
+
* **首次同步(`previous` 为空)永远不产生墓碑**:那时没有「曾经有过」这个信息,
|
|
51
|
+
* 把所有"远端有、本地没有"都判成删除会把另一端刚记的东西全删掉。这是整个模块最容易
|
|
52
|
+
* 写错的一处。
|
|
53
|
+
*
|
|
54
|
+
* @param options - 上次状态、当前状态与删除时刻。
|
|
55
|
+
* @returns 待写墓碑列表。
|
|
56
|
+
*/
|
|
57
|
+
export declare function planTombstones(options: TombstonePlanOptions): Tombstone[];
|
|
58
|
+
/**
|
|
59
|
+
* 把墓碑合并进上一次的状态:写过的墓碑不该在下一轮又被判成"删除"。
|
|
60
|
+
*
|
|
61
|
+
* 具体做法是把墓碑 id 从 `previous` 里剔掉——它们的删除已经被记录过了。
|
|
62
|
+
*
|
|
63
|
+
* @param state - 刚刚用于比对的那份状态。
|
|
64
|
+
* @param written - 本次写下的墓碑。
|
|
65
|
+
* @returns 更新后的状态。
|
|
66
|
+
*/
|
|
67
|
+
export declare function dropTombstoned(state: SyncState, written: readonly Tombstone[]): SyncState;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步墓碑:把 `memory_forget` 的真删表达成「记录被修改」,而不是「文件消失」。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要**(设计 §3.1):多写者下,本地看着「git 里有、我这儿没有」有两种可能——
|
|
5
|
+
* 本机删了(该删远端),或另一端刚加、我还没拉到(该拉下来)。两种情况的正确动作**完全
|
|
6
|
+
* 相反**,靠猜一定出错。
|
|
7
|
+
*
|
|
8
|
+
* 区分它们的唯一依据是**上一次同步时本机有哪些 id**:曾同步过、现在没了 ⇒ 本机删的。
|
|
9
|
+
* 所以本模块配一个随仓库走的 `.sync-state.json`。
|
|
10
|
+
*
|
|
11
|
+
* **墓碑不删文件、只改文件**,还顺带避开 git 里最难处理的 modify/delete 冲突:删除与
|
|
12
|
+
* 「另一端改了同一条」于是变成同一类冲突。
|
|
13
|
+
*
|
|
14
|
+
* **不记删前内容**:内容在 git 历史里(文件被删除但仍在历史),墓碑只负责回答
|
|
15
|
+
* 「这条是被删的还是还没同步到」。省下的体积换的是快照体积可控。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-memory/sync-tombstone
|
|
18
|
+
*/
|
|
19
|
+
/** 状态文件在同步仓库里的位置。 */
|
|
20
|
+
export const SYNC_STATE_FILE = '.sync-state.json';
|
|
21
|
+
/** 状态文件的相对路径(含墓碑目录)。 */
|
|
22
|
+
export const TOMBSTONE_DIR = 'tombstones';
|
|
23
|
+
/**
|
|
24
|
+
* 对比上一次与当前的 id 集合,算出本次该写哪些墓碑。
|
|
25
|
+
*
|
|
26
|
+
* **首次同步(`previous` 为空)永远不产生墓碑**:那时没有「曾经有过」这个信息,
|
|
27
|
+
* 把所有"远端有、本地没有"都判成删除会把另一端刚记的东西全删掉。这是整个模块最容易
|
|
28
|
+
* 写错的一处。
|
|
29
|
+
*
|
|
30
|
+
* @param options - 上次状态、当前状态与删除时刻。
|
|
31
|
+
* @returns 待写墓碑列表。
|
|
32
|
+
*/
|
|
33
|
+
export function planTombstones(options) {
|
|
34
|
+
const { previous, current, at } = options;
|
|
35
|
+
if (previous === undefined)
|
|
36
|
+
return [];
|
|
37
|
+
const tombstones = [];
|
|
38
|
+
const currentGlobal = new Set(current.global);
|
|
39
|
+
for (const id of previous.global) {
|
|
40
|
+
if (!currentGlobal.has(id))
|
|
41
|
+
tombstones.push({ id, scope: 'global', deletedAt: at });
|
|
42
|
+
}
|
|
43
|
+
for (const [projectName, ids] of Object.entries(previous.project)) {
|
|
44
|
+
const still = new Set(current.project[projectName] ?? []);
|
|
45
|
+
for (const id of ids) {
|
|
46
|
+
if (!still.has(id))
|
|
47
|
+
tombstones.push({ id, scope: 'project', projectName, deletedAt: at });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return tombstones;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 把墓碑合并进上一次的状态:写过的墓碑不该在下一轮又被判成"删除"。
|
|
54
|
+
*
|
|
55
|
+
* 具体做法是把墓碑 id 从 `previous` 里剔掉——它们的删除已经被记录过了。
|
|
56
|
+
*
|
|
57
|
+
* @param state - 刚刚用于比对的那份状态。
|
|
58
|
+
* @param written - 本次写下的墓碑。
|
|
59
|
+
* @returns 更新后的状态。
|
|
60
|
+
*/
|
|
61
|
+
export function dropTombstoned(state, written) {
|
|
62
|
+
if (written.length === 0)
|
|
63
|
+
return state;
|
|
64
|
+
const globalGone = new Set(written.filter(t => t.scope === 'global').map(t => t.id));
|
|
65
|
+
const projectGone = new Map();
|
|
66
|
+
for (const tombstone of written) {
|
|
67
|
+
if (tombstone.scope !== 'project' || tombstone.projectName === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
const key = tombstone.projectName;
|
|
70
|
+
const set = projectGone.get(key) ?? new Set();
|
|
71
|
+
set.add(tombstone.id);
|
|
72
|
+
projectGone.set(key, set);
|
|
73
|
+
}
|
|
74
|
+
const project = {};
|
|
75
|
+
for (const [name, ids] of Object.entries(state.project)) {
|
|
76
|
+
const gone = projectGone.get(name);
|
|
77
|
+
project[name] = gone === undefined ? ids : ids.filter(id => !gone.has(id));
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
global: state.global.filter(id => !globalGone.has(id)),
|
|
81
|
+
project,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步写盘层:把 {@link exportRecords} 的输出落到磁盘上的镜像目录。
|
|
3
|
+
*
|
|
4
|
+
* **两条硬要求**(见设计文档 §9.2 第 1 步):
|
|
5
|
+
*
|
|
6
|
+
* 1. **增量**:内容相同的文件不重写。不做这件事的后果不是"慢",是每次写盘都产生
|
|
7
|
+
* 一批 mtime 变动——git 看起来像全量改动,`git log` 与 `git diff` 随即失去意义。
|
|
8
|
+
* 2. **删除先算差集**:现有文件里不在待写列表的才算该删。不能边扫边删——边扫边删
|
|
9
|
+
* 在目录枚举顺序变化时会漏删或错删。
|
|
10
|
+
*
|
|
11
|
+
* **只管 `records/`**:`conflicts/`(§3.3 的留证)与 `index.json`(§3.2 的派生品)
|
|
12
|
+
* 都不归这里管。写盘层负责的是"把记忆库投影成一条一文件",不是"维护整个同步仓库"。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-memory/sync-writer
|
|
15
|
+
*/
|
|
16
|
+
import type { SyncRecordFile } from './sync-export.ts';
|
|
17
|
+
/** {@link writeMirror} 的入参。 */
|
|
18
|
+
export interface MirrorWriteOptions {
|
|
19
|
+
/** 镜像仓库的根(`records/` 就在它下面)。 */
|
|
20
|
+
readonly targetDir: string;
|
|
21
|
+
/** 待写入的记录文件;路径相对 `targetDir`。 */
|
|
22
|
+
readonly files: readonly SyncRecordFile[];
|
|
23
|
+
}
|
|
24
|
+
/** 一次写盘的结果。 */
|
|
25
|
+
export interface MirrorWriteRun {
|
|
26
|
+
/** 真正写了的文件数(内容有变化的)。 */
|
|
27
|
+
readonly written: number;
|
|
28
|
+
/** 内容与磁盘一致的、跳过的文件数。 */
|
|
29
|
+
readonly unchanged: number;
|
|
30
|
+
/** 被删掉的文件数。 */
|
|
31
|
+
readonly removed: number;
|
|
32
|
+
/** 被删掉的相对路径(便于日志与测试断言)。 */
|
|
33
|
+
readonly removedPaths: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 把记录文件镜像到磁盘。**纯 IO,不读写任何记忆状态**。
|
|
37
|
+
*
|
|
38
|
+
* 三个步骤按固定顺序:先算差集(该删哪些)、再写(内容有变的)、最后删。
|
|
39
|
+
* 顺序不能调换——先删会让"写失败 + 删成功"变成净损失。
|
|
40
|
+
*
|
|
41
|
+
* 单文件失败不会中断整批:记忆镜像少一份总比整次同步失败好,缺的那份下次写盘会补上。
|
|
42
|
+
*
|
|
43
|
+
* @param options - 目标目录与待写文件。
|
|
44
|
+
* @returns 写入 / 跳过 / 删除的统计。
|
|
45
|
+
*/
|
|
46
|
+
export declare function writeMirror(options: MirrorWriteOptions): MirrorWriteRun;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步写盘层:把 {@link exportRecords} 的输出落到磁盘上的镜像目录。
|
|
3
|
+
*
|
|
4
|
+
* **两条硬要求**(见设计文档 §9.2 第 1 步):
|
|
5
|
+
*
|
|
6
|
+
* 1. **增量**:内容相同的文件不重写。不做这件事的后果不是"慢",是每次写盘都产生
|
|
7
|
+
* 一批 mtime 变动——git 看起来像全量改动,`git log` 与 `git diff` 随即失去意义。
|
|
8
|
+
* 2. **删除先算差集**:现有文件里不在待写列表的才算该删。不能边扫边删——边扫边删
|
|
9
|
+
* 在目录枚举顺序变化时会漏删或错删。
|
|
10
|
+
*
|
|
11
|
+
* **只管 `records/`**:`conflicts/`(§3.3 的留证)与 `index.json`(§3.2 的派生品)
|
|
12
|
+
* 都不归这里管。写盘层负责的是"把记忆库投影成一条一文件",不是"维护整个同步仓库"。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-memory/sync-writer
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
/** 记录文件所在的顶层目录名。 */
|
|
19
|
+
const RECORDS_DIR = 'records';
|
|
20
|
+
/** 路径规范成 `/` 分隔——`exportRecords` 用 `join()` 产出,在 Windows 上是反斜杠。 */
|
|
21
|
+
function normalize(path) {
|
|
22
|
+
return path.replace(/\\/gu, '/');
|
|
23
|
+
}
|
|
24
|
+
/** 递归列出 `root` 下的文件,返回相对 `relBase` 的路径(`/` 分隔)。 */
|
|
25
|
+
function scan(root, relBase) {
|
|
26
|
+
if (!existsSync(root))
|
|
27
|
+
return [];
|
|
28
|
+
const found = [];
|
|
29
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
30
|
+
const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`;
|
|
31
|
+
if (entry.isDirectory())
|
|
32
|
+
found.push(...scan(join(root, entry.name), rel));
|
|
33
|
+
else
|
|
34
|
+
found.push(rel);
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 把记录文件镜像到磁盘。**纯 IO,不读写任何记忆状态**。
|
|
40
|
+
*
|
|
41
|
+
* 三个步骤按固定顺序:先算差集(该删哪些)、再写(内容有变的)、最后删。
|
|
42
|
+
* 顺序不能调换——先删会让"写失败 + 删成功"变成净损失。
|
|
43
|
+
*
|
|
44
|
+
* 单文件失败不会中断整批:记忆镜像少一份总比整次同步失败好,缺的那份下次写盘会补上。
|
|
45
|
+
*
|
|
46
|
+
* @param options - 目标目录与待写文件。
|
|
47
|
+
* @returns 写入 / 跳过 / 删除的统计。
|
|
48
|
+
*/
|
|
49
|
+
export function writeMirror(options) {
|
|
50
|
+
const recordsRoot = join(options.targetDir, RECORDS_DIR);
|
|
51
|
+
// 待写集合:normalize 之后的相对路径 → 内容
|
|
52
|
+
const wanted = new Map();
|
|
53
|
+
for (const file of options.files)
|
|
54
|
+
wanted.set(normalize(file.path), file.content);
|
|
55
|
+
// 差集先算:磁盘上有、但这次不该存在的,才是要删的
|
|
56
|
+
const stale = scan(recordsRoot, RECORDS_DIR).filter(path => !wanted.has(path));
|
|
57
|
+
let written = 0;
|
|
58
|
+
let unchanged = 0;
|
|
59
|
+
for (const [relativePath, content] of wanted) {
|
|
60
|
+
const absolute = join(options.targetDir, relativePath);
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(absolute) && readFileSync(absolute, 'utf8') === content) {
|
|
63
|
+
unchanged += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
67
|
+
writeFileSync(absolute, content, 'utf8');
|
|
68
|
+
written += 1;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// 单个文件写失败不中断整批;缺的那份下次写盘会补。
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const removedPaths = [];
|
|
75
|
+
for (const relativePath of stale) {
|
|
76
|
+
try {
|
|
77
|
+
rmSync(join(options.targetDir, relativePath), { force: true });
|
|
78
|
+
removedPaths.push(relativePath);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// 同上:删除失败留到下次。
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { written, unchanged, removed: removedPaths.length, removedPaths };
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, writes take effect immediately, signal-driven injection, no embeddings",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@deepseek-ai/schemastery": "^3.18.4",
|
|
69
|
+
"isomorphic-git": "^1.44.11",
|
|
69
70
|
"zod": "^4.4.3"
|
|
70
71
|
},
|
|
71
72
|
"peerDependencies": {
|