dsh-lite-memory 0.3.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -17
- package/TRIGGERS.md +18 -14
- package/anchor.js +518 -0
- package/bench/bench.mjs +145 -0
- package/bench/evolution.mjs +123 -0
- package/client.js +1519 -33
- package/docs/BENCH.md +74 -0
- package/docs/RELEASE-0.11.0.md +27 -0
- package/docs/RELEASE-0.11.1.md +11 -0
- package/docs/RELEASE-0.12.0.md +15 -0
- package/docs/RELEASE-0.12.1.md +17 -0
- package/docs/RELEASE-0.12.2.md +17 -0
- package/docs/RELEASE-0.13.0.md +7 -0
- package/docs/RELEASE-0.14.0.md +7 -0
- package/docs/RELEASE-0.15.0.md +8 -0
- package/index.js +2080 -76
- package/learning.js +585 -0
- package/package.json +10 -2
- package/state.js +1476 -0
- package/store.js +2967 -244
- package/test/anchor.test.mjs +436 -0
- package/test/client.test.mjs +114 -0
- package/test/contract.test.mjs +880 -0
- package/test/control.test.mjs +278 -0
- package/test/crossing.test.mjs +372 -0
- package/test/evolution-read.test.mjs +448 -0
- package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
- package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
- package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
- package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
- package/test/fixtures/legacy-v1/index.json +63 -0
- package/test/learning.test.mjs +772 -0
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +449 -0
- package/test/scenario.test.mjs +184 -0
- package/test/state.test.mjs +485 -0
- package/test/store.test.mjs +816 -115
- package/test/view.test.mjs +257 -0
- package/view.js +616 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-state-'));
|
|
9
|
+
const state = await import('../state.js');
|
|
10
|
+
|
|
11
|
+
const { LIMITS, StateError, scopeOf, globalScope, memoryRoot, resolveRoot, load, transact,
|
|
12
|
+
readEntryBody, migrateLegacy, migrationReport, withScopeLock, validateState, emptyState, evolutionOf } = state;
|
|
13
|
+
|
|
14
|
+
let passed = 0;
|
|
15
|
+
const test = (name, fn) => {
|
|
16
|
+
try {
|
|
17
|
+
fn();
|
|
18
|
+
passed += 1;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error(`FAIL ${name}`);
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const dirs = [];
|
|
26
|
+
const fresh = (label) => {
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `lm-state-${label}-`));
|
|
28
|
+
dirs.push(dir);
|
|
29
|
+
return scopeOf(dir);
|
|
30
|
+
};
|
|
31
|
+
const stateFile = (scope) => path.join(scope.dir, 'state.json');
|
|
32
|
+
const raw = (scope) => fs.readFileSync(stateFile(scope), 'utf8');
|
|
33
|
+
const bodyOf = (scope, id) => {
|
|
34
|
+
const entry = load(scope).state.entries.find((row) => row.id === id);
|
|
35
|
+
return entry ? entry.current.body : undefined;
|
|
36
|
+
};
|
|
37
|
+
const addNote = (title, body) => (draft) => {
|
|
38
|
+
const at = new Date().toISOString();
|
|
39
|
+
draft.entries = [...draft.entries, {
|
|
40
|
+
id: `mem_${String(draft.entries.length + 1).padStart(12, '0')}`,
|
|
41
|
+
revision: 1,
|
|
42
|
+
status: 'active',
|
|
43
|
+
origin: 'user_explicit',
|
|
44
|
+
createdAt: at,
|
|
45
|
+
updatedAt: at,
|
|
46
|
+
current: { title, summary: title, body, kind: 'note', when: '', keywords: [], topicKey: '', triggers: [], applicability: 'all_in_scope', conditions: [], selection: null, links: [], aliases: [] },
|
|
47
|
+
maintenance: { reviewState: 'active', sourceRefs: [], retiredNames: [], versions: [], checks: [], hits: 0, merges: 0, lastUsed: at },
|
|
48
|
+
}];
|
|
49
|
+
return { changed: true };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
test('瘦包一封就是一份带校验和的状态', () => {
|
|
53
|
+
const scope = fresh('envelope');
|
|
54
|
+
const created = transact(scope, { expectCreate: true }, addNote('第一条', '正文'));
|
|
55
|
+
assert.equal(created.changed, true);
|
|
56
|
+
assert.equal(created.state.generation, 1);
|
|
57
|
+
const parsed = JSON.parse(raw(scope));
|
|
58
|
+
assert.equal(parsed.format, 'dsh-lite-memory/state@4');
|
|
59
|
+
assert.match(parsed.checksum, /^sha256:[a-f0-9]{64}$/);
|
|
60
|
+
assert.equal(parsed.payload.generation, 1);
|
|
61
|
+
assert.ok(fs.existsSync(path.join(scope.dir, 'state.prev.json')) === false);
|
|
62
|
+
assert.equal(fs.existsSync(path.join(scope.dir, '.lock')), false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('2 版状态可读,下一次写入升级为 4 版并保留记录', () => {
|
|
66
|
+
const scope = fresh('migrate-v2');
|
|
67
|
+
const payload = emptyState(scope);
|
|
68
|
+
addNote('旧库里的规则', '原文')(payload);
|
|
69
|
+
payload.schemaVersion = 2;
|
|
70
|
+
delete payload.experimental;
|
|
71
|
+
const checksum = `sha256:${createHash('sha256').update(JSON.stringify(payload)).digest('hex')}`;
|
|
72
|
+
fs.mkdirSync(scope.dir, { recursive: true });
|
|
73
|
+
fs.writeFileSync(stateFile(scope), `${JSON.stringify({ format: 'dsh-lite-memory/state@2', checksum, payload })}\n`);
|
|
74
|
+
const loaded = load(scope);
|
|
75
|
+
assert.equal(loaded.state.schemaVersion, 4);
|
|
76
|
+
assert.equal(loaded.state.entries[0].current.title, '旧库里的规则');
|
|
77
|
+
assert.equal(evolutionOf(loaded.state).enabled, false);
|
|
78
|
+
transact(scope, { expected: { generation: loaded.state.generation } }, (draft) => {
|
|
79
|
+
draft.entries[0].current.body = '升级后的正文';
|
|
80
|
+
return { changed: true };
|
|
81
|
+
});
|
|
82
|
+
const parsed = JSON.parse(raw(scope));
|
|
83
|
+
assert.equal(parsed.format, 'dsh-lite-memory/state@4');
|
|
84
|
+
assert.equal(parsed.payload.entries[0].current.body, '升级后的正文');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('D04 旧程序拒绝写新格式,不抹掉实验标记', () => {
|
|
88
|
+
const scope = fresh('d04-format');
|
|
89
|
+
transact(scope, { expectCreate: true }, (draft) => {
|
|
90
|
+
state.applyEvolution(draft, true);
|
|
91
|
+
return { changed: true };
|
|
92
|
+
});
|
|
93
|
+
const before = raw(scope);
|
|
94
|
+
assert.equal(JSON.parse(before).format, 'dsh-lite-memory/state@4');
|
|
95
|
+
// 旧版只认 state@2,读到新格式会拒绝而不是当空库覆盖;实验标记因此留在盘上。
|
|
96
|
+
const oldReader = (text) => {
|
|
97
|
+
const data = JSON.parse(text);
|
|
98
|
+
if (data.format !== 'dsh-lite-memory/state@2') throw new StateError('invalid', '旧版不认这个格式');
|
|
99
|
+
return data.payload;
|
|
100
|
+
};
|
|
101
|
+
assert.throws(() => oldReader(before), (error) => error instanceof StateError && error.code === 'invalid');
|
|
102
|
+
assert.equal(raw(scope), before);
|
|
103
|
+
assert.equal(load(scope).state.experimental.projectEvolution.enabled, true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('D01 发布点失败:状态一个字节没动,也没有留下锁', () => {
|
|
107
|
+
const scope = fresh('d01');
|
|
108
|
+
transact(scope, { expectCreate: true }, addNote('第一条', '正文'));
|
|
109
|
+
const before = raw(scope);
|
|
110
|
+
assert.throws(() => transact(scope, { expected: { generation: 1 } }, () => {
|
|
111
|
+
throw new Error('写盘前崩了');
|
|
112
|
+
}), /写盘前崩了/);
|
|
113
|
+
assert.equal(raw(scope), before);
|
|
114
|
+
assert.equal(fs.existsSync(path.join(scope.dir, '.lock')), false);
|
|
115
|
+
assert.equal(load(scope).state.generation, 1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('D01 current 丢了 prev 还在:读得到,下一次写入重新发布 current', () => {
|
|
119
|
+
const scope = fresh('d01-prev');
|
|
120
|
+
transact(scope, { expectCreate: true }, addNote('第一条', '正文'));
|
|
121
|
+
transact(scope, { expected: { generation: 1 } }, addNote('第二条', '正文'));
|
|
122
|
+
fs.copyFileSync(stateFile(scope), path.join(scope.dir, 'state.prev.json'));
|
|
123
|
+
fs.rmSync(stateFile(scope));
|
|
124
|
+
const recovered = load(scope);
|
|
125
|
+
assert.equal(recovered.recovered, true);
|
|
126
|
+
assert.equal(recovered.state.generation, 2);
|
|
127
|
+
const written = transact(scope, {}, addNote('第三条', '正文'));
|
|
128
|
+
assert.equal(written.changed, true);
|
|
129
|
+
assert.equal(load(scope).exists, true);
|
|
130
|
+
assert.equal(load(scope).state.generation, 3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('D02 响应丢了:同一 operationId 重放回执,不再写一遍', () => {
|
|
134
|
+
const scope = fresh('d02');
|
|
135
|
+
const note = addNote('一条', '正文');
|
|
136
|
+
const first = transact(scope, { operationId: 'op-1', payloadDigest: 'digest-a' }, note);
|
|
137
|
+
assert.equal(first.changed, true);
|
|
138
|
+
assert.equal(first.state.generation, 1);
|
|
139
|
+
const after = raw(scope);
|
|
140
|
+
const replay = transact(scope, { operationId: 'op-1', payloadDigest: 'digest-a' }, note);
|
|
141
|
+
assert.equal(replay.replayed, true);
|
|
142
|
+
assert.equal(replay.changed, true);
|
|
143
|
+
assert.equal(raw(scope), after);
|
|
144
|
+
assert.equal(load(scope).state.generation, 1);
|
|
145
|
+
assert.throws(() => transact(scope, { operationId: 'op-1', payloadDigest: 'digest-b' }, note), /不同内容/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('D08 别家进程占着锁:报忙,不猜也不抢', () => {
|
|
149
|
+
const scope = fresh('d08-lock');
|
|
150
|
+
fs.mkdirSync(scope.dir, { recursive: true });
|
|
151
|
+
fs.writeFileSync(path.join(scope.dir, '.lock'), '999999:deadbeef');
|
|
152
|
+
assert.throws(() => transact(scope, {}, addNote('一条', '正文')), (error) => error instanceof StateError && error.code === 'busy');
|
|
153
|
+
assert.equal(fs.existsSync(stateFile(scope)), false);
|
|
154
|
+
fs.rmSync(path.join(scope.dir, '.lock'));
|
|
155
|
+
assert.equal(transact(scope, {}, addNote('一条', '正文')).changed, true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('D08 坏 JSON 与不认识的版本都不当空库覆盖', () => {
|
|
159
|
+
const broken = fresh('d08-json');
|
|
160
|
+
fs.mkdirSync(broken.dir, { recursive: true });
|
|
161
|
+
fs.writeFileSync(stateFile(broken), '{"format":');
|
|
162
|
+
assert.throws(() => load(broken), (error) => error instanceof StateError && error.code === 'corrupt');
|
|
163
|
+
assert.equal(fs.readFileSync(stateFile(broken), 'utf8'), '{"format":');
|
|
164
|
+
const unknown = fresh('d08-version');
|
|
165
|
+
fs.mkdirSync(unknown.dir, { recursive: true });
|
|
166
|
+
const payload = emptyState(unknown);
|
|
167
|
+
payload.schemaVersion = 99;
|
|
168
|
+
const checksum = `sha256:${createHash('sha256').update(JSON.stringify(payload)).digest('hex')}`;
|
|
169
|
+
fs.writeFileSync(stateFile(unknown), `${JSON.stringify({ format: 'dsh-lite-memory/state@2', checksum, payload })}\n`);
|
|
170
|
+
assert.throws(() => load(unknown), (error) => error instanceof StateError && error.code === 'corrupt');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('D08 重入锁:读—改—写同一个窗口里连续调用不死锁', () => {
|
|
174
|
+
const scope = fresh('d08-reentrant');
|
|
175
|
+
const result = withScopeLock(scope, () => {
|
|
176
|
+
transact(scope, { expectCreate: true }, addNote('第一条', '正文'));
|
|
177
|
+
return transact(scope, { expected: { generation: 1 } }, addNote('第二条', '正文'));
|
|
178
|
+
});
|
|
179
|
+
assert.equal(result.changed, true);
|
|
180
|
+
assert.equal(load(scope).state.entries.length, 2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('S01 仓库根与子目录共用一份记忆', () => {
|
|
184
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-repo-'));
|
|
185
|
+
dirs.push(repo);
|
|
186
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
187
|
+
const sub = path.join(repo, 'packages', 'app');
|
|
188
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
189
|
+
const atRoot = scopeOf(repo);
|
|
190
|
+
const atSub = scopeOf(sub);
|
|
191
|
+
assert.equal(atSub.key, atRoot.key);
|
|
192
|
+
assert.equal(atSub.dir, atRoot.dir);
|
|
193
|
+
assert.equal(resolveRoot(sub).root, fs.realpathSync(repo));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('S02 符号链接、非 Git 目录、嵌套仓库各归各的', () => {
|
|
197
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-link-'));
|
|
198
|
+
dirs.push(repo);
|
|
199
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
200
|
+
const link = path.join(os.tmpdir(), `lm-state-alias-${Math.random().toString(16).slice(2, 8)}`);
|
|
201
|
+
dirs.push(link);
|
|
202
|
+
fs.symlinkSync(repo, link);
|
|
203
|
+
assert.equal(scopeOf(link).key, scopeOf(repo).key);
|
|
204
|
+
const plain = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-plain-'));
|
|
205
|
+
dirs.push(plain);
|
|
206
|
+
assert.equal(resolveRoot(plain).source, 'cwd');
|
|
207
|
+
const nested = path.join(repo, 'vendor', 'inner');
|
|
208
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
209
|
+
execFileSync('git', ['init', '-q'], { cwd: nested });
|
|
210
|
+
assert.notEqual(scopeOf(nested).key, scopeOf(repo).key);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('S03 配置的项目根只覆盖自己的子树,不吞别的目录', () => {
|
|
214
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-config-'));
|
|
215
|
+
dirs.push(root);
|
|
216
|
+
const sub = path.join(root, 'packages', 'app');
|
|
217
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
218
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-outside-'));
|
|
219
|
+
dirs.push(outside);
|
|
220
|
+
const config = path.join(memoryRoot(), 'config.json');
|
|
221
|
+
fs.mkdirSync(path.dirname(config), { recursive: true });
|
|
222
|
+
fs.writeFileSync(config, JSON.stringify({ projectRoot: root }));
|
|
223
|
+
try {
|
|
224
|
+
assert.equal(resolveRoot(sub).root, fs.realpathSync(root));
|
|
225
|
+
assert.equal(resolveRoot(sub).source, 'configured');
|
|
226
|
+
assert.equal(scopeOf(sub).key, scopeOf(root).key);
|
|
227
|
+
assert.notEqual(resolveRoot(outside).source, 'configured');
|
|
228
|
+
assert.notEqual(scopeOf(outside).key, scopeOf(root).key);
|
|
229
|
+
} finally {
|
|
230
|
+
fs.rmSync(config, { force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('P04 Git 查询失败不冒充已确认工程,不用 cwd 兜底学习', () => {
|
|
235
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-gitfail-'));
|
|
236
|
+
dirs.push(dir);
|
|
237
|
+
const previous = process.env.PATH;
|
|
238
|
+
process.env.PATH = '';
|
|
239
|
+
try {
|
|
240
|
+
const resolved = resolveRoot(dir);
|
|
241
|
+
assert.equal(resolved.source, 'cwd');
|
|
242
|
+
assert.equal(resolved.confirmed, false);
|
|
243
|
+
assert.equal(scopeOf(dir).confirmed, false);
|
|
244
|
+
} finally {
|
|
245
|
+
process.env.PATH = previous;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('S04 全局层从 41 条写到 200 条,一条不少也不拒绝', () => {
|
|
250
|
+
const global = globalScope();
|
|
251
|
+
transact(global, { expectCreate: true }, (draft) => {
|
|
252
|
+
for (let i = 0; i < 41; i += 1) addNote(`旧全局 ${i}`, `正文 ${i}`)(draft);
|
|
253
|
+
return { changed: true };
|
|
254
|
+
});
|
|
255
|
+
for (let i = 41; i < 200; i += 1) {
|
|
256
|
+
transact(global, {}, addNote(`全局 ${i}`, `正文 ${i}`));
|
|
257
|
+
}
|
|
258
|
+
assert.equal(load(global).state.entries.length, 200);
|
|
259
|
+
assert.equal(LIMITS.globalEntries, 200);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('S05 第 201 条与字节压力:先归档最久没读的,不拒绝', () => {
|
|
263
|
+
const scope = fresh('s05');
|
|
264
|
+
transact(scope, { expectCreate: true }, (draft) => {
|
|
265
|
+
for (let i = 0; i <= 200; i += 1) addNote(`条目 ${i}`, `正文 ${i}`)(draft);
|
|
266
|
+
return { changed: true };
|
|
267
|
+
});
|
|
268
|
+
assert.equal(load(scope).state.entries.length, 201);
|
|
269
|
+
const big = 'x'.repeat(Math.ceil(LIMITS.totalBytes / 4));
|
|
270
|
+
for (let i = 0; i < 4; i += 1) {
|
|
271
|
+
transact(scope, {}, addNote(`大条目 ${i}`, big));
|
|
272
|
+
}
|
|
273
|
+
const loaded = load(scope).state;
|
|
274
|
+
const active = loaded.entries.filter((entry) => entry.status === 'active');
|
|
275
|
+
const archived = loaded.entries.filter((entry) => entry.status === 'archived');
|
|
276
|
+
assert.ok(archived.length >= 1, '腾了位置');
|
|
277
|
+
assert.ok(active.reduce((sum, entry) => sum + state.entryBytesOf(entry), 0) <= LIMITS.totalBytes);
|
|
278
|
+
for (const entry of archived) assert.equal(entry.current.body.length > 0, true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('S10 旧库迁移:正文、归档、旧标题一起搬进状态', () => {
|
|
282
|
+
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-legacy-'));
|
|
283
|
+
dirs.push(project);
|
|
284
|
+
const scope = scopeOf(project);
|
|
285
|
+
fs.mkdirSync(scope.dir, { recursive: true });
|
|
286
|
+
fs.cpSync(new URL('./fixtures/legacy-v1', import.meta.url), scope.dir, { recursive: true });
|
|
287
|
+
const indexFile = path.join(scope.dir, 'index.json');
|
|
288
|
+
const legacy = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
289
|
+
legacy.cwd = scope.cwd;
|
|
290
|
+
fs.writeFileSync(indexFile, JSON.stringify(legacy));
|
|
291
|
+
const report = migrationReport(scope);
|
|
292
|
+
assert.equal(report.required, true);
|
|
293
|
+
assert.equal(report.entries, 3);
|
|
294
|
+
assert.deepEqual(report.missingBodies, []);
|
|
295
|
+
const migrated = migrateLegacy(scope);
|
|
296
|
+
assert.equal(migrated.migrated, true);
|
|
297
|
+
const loaded = load(scope).state;
|
|
298
|
+
assert.equal(loaded.entries.length, 4);
|
|
299
|
+
assert.equal(loaded.migration.fromVersion, 1);
|
|
300
|
+
assert.equal(bodyOf(scope, 'mem_111111111111'), '第一步跑测试\n第二步改版本号');
|
|
301
|
+
const archived = loaded.entries.find((entry) => entry.id === 'mem_444444444444');
|
|
302
|
+
assert.equal(archived.status, 'archived');
|
|
303
|
+
assert.equal(archived.current.body, '归档正文');
|
|
304
|
+
assert.equal(migrateLegacy(scope).migrated, false);
|
|
305
|
+
assert.equal(fs.existsSync(path.join(scope.dir, 'legacy', 'v1', 'index.json')), true);
|
|
306
|
+
assert.equal(fs.existsSync(indexFile), false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('S10 缺正文与同 id 冲突:先给报告,不动盘', () => {
|
|
310
|
+
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-legacy-bad-'));
|
|
311
|
+
dirs.push(project);
|
|
312
|
+
const scope = scopeOf(project);
|
|
313
|
+
fs.mkdirSync(scope.dir, { recursive: true });
|
|
314
|
+
fs.cpSync(new URL('./fixtures/legacy-v1', import.meta.url), scope.dir, { recursive: true });
|
|
315
|
+
const indexFile = path.join(scope.dir, 'index.json');
|
|
316
|
+
const legacy = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
|
317
|
+
legacy.cwd = scope.cwd;
|
|
318
|
+
fs.writeFileSync(indexFile, JSON.stringify(legacy));
|
|
319
|
+
fs.rmSync(path.join(scope.dir, 'entries', 'mem_222222222222.md'));
|
|
320
|
+
const before = fs.readFileSync(indexFile, 'utf8');
|
|
321
|
+
assert.throws(() => migrateLegacy(scope), (error) => error instanceof StateError && error.code === 'legacy_missing_body' && error.report.missingBodies.includes('mem_222222222222'));
|
|
322
|
+
assert.equal(fs.readFileSync(indexFile, 'utf8'), before);
|
|
323
|
+
assert.equal(fs.existsSync(stateFile(scope)), false);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('S10 旧的子目录库并进仓库根,同 id 只留一条', () => {
|
|
327
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-adopt-'));
|
|
328
|
+
dirs.push(repo);
|
|
329
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
330
|
+
const sub = path.join(repo, 'packages', 'app');
|
|
331
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
332
|
+
const scope = scopeOf(repo);
|
|
333
|
+
const childKey = `legacy-${Math.random().toString(16).slice(2, 8)}`;
|
|
334
|
+
const childDir = path.join(memoryRoot(), childKey);
|
|
335
|
+
fs.mkdirSync(path.join(childDir, 'entries'), { recursive: true });
|
|
336
|
+
fs.writeFileSync(path.join(childDir, 'index.json'), JSON.stringify({
|
|
337
|
+
version: 1, scope: childKey, cwd: sub,
|
|
338
|
+
entries: [{ id: 'mem_555555555555', title: '子目录条目', summary: '子目录存的', when: '', kind: 'note', tags: [], created: '2026-01-01T00:00:00.000Z', updated: '2026-01-01T00:00:00.000Z', revision: 1, status: 'active', source: 'user_explicit', hits: 0, merges: 0, versions: [], bytes: 10 }],
|
|
339
|
+
suggestions: [], receipts: [],
|
|
340
|
+
}));
|
|
341
|
+
fs.writeFileSync(path.join(childDir, 'entries', 'mem_555555555555.md'), '子目录正文');
|
|
342
|
+
const report = migrationReport(scope);
|
|
343
|
+
assert.equal(report.adoptable.length, 1);
|
|
344
|
+
const migrated = migrateLegacy(scope);
|
|
345
|
+
assert.equal(migrated.migrated, true);
|
|
346
|
+
assert.equal(bodyOf(scope, 'mem_555555555555'), '子目录正文');
|
|
347
|
+
assert.equal(fs.existsSync(childDir), false);
|
|
348
|
+
assert.equal(fs.existsSync(path.join(scope.dir, 'legacy', `scope-${childKey}`)), true);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('S10 迁移预检扫到所有被吸收的旧库:子目录缺正文/同 id 不同正文都不发布', () => {
|
|
352
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-adopt-bad-'));
|
|
353
|
+
dirs.push(repo);
|
|
354
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
355
|
+
const sub = path.join(repo, 'packages', 'bad');
|
|
356
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
357
|
+
const scope = scopeOf(repo);
|
|
358
|
+
const childKey = `legacy-bad-${Math.random().toString(16).slice(2, 8)}`;
|
|
359
|
+
const childDir = path.join(memoryRoot(), childKey);
|
|
360
|
+
fs.mkdirSync(path.join(childDir, 'entries'), { recursive: true });
|
|
361
|
+
fs.writeFileSync(path.join(childDir, 'index.json'), JSON.stringify({
|
|
362
|
+
version: 1, scope: childKey, cwd: sub,
|
|
363
|
+
entries: [
|
|
364
|
+
{ id: 'mem_666666666666', title: '子目录缺正文', summary: '正文文件被删了', when: '', kind: 'note', tags: [], revision: 1, status: 'active', source: 'user_explicit', created: '2026-01-01T00:00:00.000Z', updated: '2026-01-01T00:00:00.000Z' },
|
|
365
|
+
{ id: 'mem_777777777777', title: '同 id 不同正文', summary: '和主库撞 id', when: '', kind: 'note', tags: [], revision: 1, status: 'active', source: 'user_explicit', created: '2026-01-01T00:00:00.000Z', updated: '2026-01-01T00:00:00.000Z' },
|
|
366
|
+
],
|
|
367
|
+
suggestions: [], receipts: [],
|
|
368
|
+
}));
|
|
369
|
+
fs.writeFileSync(path.join(childDir, 'entries', 'mem_777777777777.md'), '子目录版本');
|
|
370
|
+
/** 主库有同 id 但正文不同:合并会静默丢数据,必须在预检就拦下。 */
|
|
371
|
+
fs.mkdirSync(path.join(scope.dir, 'entries'), { recursive: true });
|
|
372
|
+
fs.writeFileSync(path.join(scope.dir, 'index.json'), JSON.stringify({
|
|
373
|
+
version: 1, scope: scope.key, cwd: repo,
|
|
374
|
+
entries: [{ id: 'mem_777777777777', title: '同 id 不同正文', summary: '主库版本', when: '', kind: 'note', tags: [], revision: 1, status: 'active', source: 'user_explicit', created: '2026-01-01T00:00:00.000Z', updated: '2026-01-01T00:00:00.000Z' }],
|
|
375
|
+
suggestions: [], receipts: [],
|
|
376
|
+
}));
|
|
377
|
+
fs.writeFileSync(path.join(scope.dir, 'entries', 'mem_777777777777.md'), '主库版本');
|
|
378
|
+
const report = migrationReport(scope);
|
|
379
|
+
assert.equal(report.sources, 2);
|
|
380
|
+
assert.ok(report.missingBodies.includes('mem_666666666666'));
|
|
381
|
+
assert.ok(report.conflicts.some((row) => row.id === 'mem_777777777777' && /不同正文/.test(row.reason)));
|
|
382
|
+
assert.throws(() => migrateLegacy(scope), (error) => error instanceof StateError && error.code === 'legacy_conflict');
|
|
383
|
+
assert.equal(fs.existsSync(stateFile(scope)), false);
|
|
384
|
+
assert.equal(fs.existsSync(path.join(childDir, 'index.json')), true);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('exact noop 不涨 generation,也不改 updatedAt', () => {
|
|
388
|
+
const scope = fresh('noop');
|
|
389
|
+
transact(scope, { expectCreate: true }, addNote('一条', '正文'));
|
|
390
|
+
const before = load(scope).state;
|
|
391
|
+
const noop = transact(scope, { expected: { generation: 1 } }, () => ({ changed: false }));
|
|
392
|
+
assert.equal(noop.changed, false);
|
|
393
|
+
assert.equal(load(scope).state.generation, before.generation);
|
|
394
|
+
assert.equal(load(scope).state.updatedAt, before.updatedAt);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('validateState 拦坏结构,不拦合法的一条', () => {
|
|
398
|
+
const scope = fresh('validate');
|
|
399
|
+
const good = emptyState(scope);
|
|
400
|
+
addNote('一条', '正文')(good);
|
|
401
|
+
assert.equal(validateState(good).schemaVersion, 4);
|
|
402
|
+
const bad = JSON.parse(JSON.stringify(good));
|
|
403
|
+
bad.entries[0].id = 'not-an-id';
|
|
404
|
+
assert.throws(() => validateState(bad), (error) => error instanceof StateError);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
// ── 彻底忘掉:屏障、断点、旧副本 ─────────────────────────────────────────────
|
|
409
|
+
test('purge 删条目不回吐标题,轮换 epoch,prev 也是清过的', () => {
|
|
410
|
+
const scope = fresh('purge');
|
|
411
|
+
transact(scope, { expectCreate: true }, addNote('要忘掉的事', '旧正文-哨兵'));
|
|
412
|
+
transact(scope, { expected: { generation: 1 } }, addNote('要留下的事', '留下的正文'));
|
|
413
|
+
const before = load(scope).state;
|
|
414
|
+
const result = state.purge(scope, { ids: ['mem_000000000001'], operationId: 'op_purge_test' });
|
|
415
|
+
assert.equal(result.ok, true);
|
|
416
|
+
assert.equal(result.operationId, 'op_purge_test');
|
|
417
|
+
assert.equal(JSON.stringify(result).includes('要忘掉的事'), false);
|
|
418
|
+
const after = load(scope).state;
|
|
419
|
+
assert.equal(after.entries.some((entry) => entry.id === 'mem_000000000001'), false);
|
|
420
|
+
assert.equal(after.entries.some((entry) => entry.id === 'mem_000000000002'), true);
|
|
421
|
+
assert.notEqual(after.epoch, before.epoch);
|
|
422
|
+
assert.equal(after.receipts.length, 1);
|
|
423
|
+
assert.equal(after.receipts[0].kind, 'purge');
|
|
424
|
+
assert.equal(JSON.stringify(after.receipts).includes('要忘掉的事'), false);
|
|
425
|
+
const row = after.suppressions.find((item) => item.reason === 'forgotten_source');
|
|
426
|
+
assert.ok(row);
|
|
427
|
+
assert.equal(row.expiresAt, null);
|
|
428
|
+
assert.equal(fs.existsSync(path.join(scope.dir, 'bodies', 'mem_000000000001.md')), false);
|
|
429
|
+
const prev = JSON.parse(fs.readFileSync(path.join(scope.dir, 'state.prev.json'), 'utf8')).payload;
|
|
430
|
+
assert.equal(prev.entries.some((entry) => entry.id === 'mem_000000000001'), false);
|
|
431
|
+
assert.equal(fs.existsSync(path.join(scope.dir, 'purge.intent.json')), false);
|
|
432
|
+
// 同一个 id 不会再被当成活条目
|
|
433
|
+
assert.throws(() => transact(scope, {}, addNote('要忘掉的事', '冒充')), (error) => error instanceof StateError);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('purge 断点:有 intent 时普通读取被拦住,重启后收尾', () => {
|
|
437
|
+
const scope = fresh('purge-resume');
|
|
438
|
+
transact(scope, { expectCreate: true }, addNote('断点里的事', '断点正文-哨兵'));
|
|
439
|
+
const loaded = load(scope);
|
|
440
|
+
const intent = {
|
|
441
|
+
operationId: 'op_resume_test',
|
|
442
|
+
scopeKey: scope.key,
|
|
443
|
+
storeId: loaded.state.storeId,
|
|
444
|
+
fromEpoch: loaded.state.epoch,
|
|
445
|
+
toEpoch: '00000000-0000-4000-8000-000000000001',
|
|
446
|
+
ids: ['mem_000000000001'],
|
|
447
|
+
phase: 'planned',
|
|
448
|
+
at: new Date().toISOString(),
|
|
449
|
+
};
|
|
450
|
+
fs.writeFileSync(path.join(scope.dir, 'purge.intent.json'), `${JSON.stringify(intent, null, 2)}\n`);
|
|
451
|
+
assert.throws(() => load(scope), (error) => error instanceof StateError && error.code === 'purge_pending');
|
|
452
|
+
assert.throws(() => state.purge(scope, { ids: ['mem_000000000001'] }), (error) => error.code === 'purge_pending');
|
|
453
|
+
const resumed = state.resumePurge(scope);
|
|
454
|
+
assert.equal(resumed.epoch, intent.toEpoch);
|
|
455
|
+
assert.equal(fs.existsSync(path.join(scope.dir, 'purge.intent.json')), false);
|
|
456
|
+
assert.equal(load(scope).state.entries.length, 0);
|
|
457
|
+
assert.equal(JSON.stringify(resumed).includes('断点里的事'), false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('purge 之后再碰同一个 id 返回 missing,不复活', () => {
|
|
461
|
+
const scope = fresh('purge-missing');
|
|
462
|
+
transact(scope, { expectCreate: true }, addNote('删一次', '正文'));
|
|
463
|
+
state.purge(scope, { ids: ['mem_000000000001'] });
|
|
464
|
+
const again = state.purge(scope, { ids: ['mem_000000000001'] });
|
|
465
|
+
assert.equal(again.ok, true);
|
|
466
|
+
assert.equal(again.missing, true);
|
|
467
|
+
assert.equal(load(scope).state.entries.length, 0);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('写入重试不堆临时副本:固定临时名写前清理,成功后只留状态文件', () => {
|
|
471
|
+
const scope = fresh('retry-temp');
|
|
472
|
+
transact(scope, { expectCreate: true }, addNote('第一次', 'x'));
|
|
473
|
+
const before = load(scope);
|
|
474
|
+
/** 模拟上次写到一半:固定名和旧版随机名的残留都应在下一次写入前清掉。 */
|
|
475
|
+
fs.writeFileSync(path.join(scope.dir, '.state.json.tmp'), '{"half":true}');
|
|
476
|
+
fs.writeFileSync(path.join(scope.dir, '.state.prev.json.tmp'), '{"half":true}');
|
|
477
|
+
fs.writeFileSync(path.join(scope.dir, '.state.abandoned.tmp'), '{"half":true}');
|
|
478
|
+
const next = transact(scope, { expected: { generation: before.state.generation, epoch: before.state.epoch } }, addNote('第二次', 'y'));
|
|
479
|
+
assert.equal(next.state.generation, before.state.generation + 1);
|
|
480
|
+
assert.deepEqual(fs.readdirSync(scope.dir).filter((name) => name.endsWith('.tmp')), []);
|
|
481
|
+
assert.equal(JSON.parse(raw(scope)).payload.generation, next.state.generation);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
485
|
+
console.log(`state.test.mjs: ${passed} passed`);
|