dsh-lite-memory 0.18.43 → 0.18.44

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.
@@ -1,663 +0,0 @@
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
- /** Windows 会把同一个临时目录报成 8.3 短名或长名;按目录的文件 ID 判断是不是同一处。 */
27
- const pathId = (target) => {
28
- const stat = fs.statSync(target, { bigint: true });
29
- return `${stat.dev}:${stat.ino}`;
30
- };
31
- const fresh = (label) => {
32
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), `lm-state-${label}-`));
33
- dirs.push(dir);
34
- return scopeOf(dir);
35
- };
36
- const stateFile = (scope) => path.join(scope.dir, 'state.json');
37
- const raw = (scope) => fs.readFileSync(stateFile(scope), 'utf8');
38
- const bodyOf = (scope, id) => {
39
- const entry = load(scope).state.entries.find((row) => row.id === id);
40
- return entry ? entry.current.body : undefined;
41
- };
42
- const addNote = (title, body) => (draft) => {
43
- const at = new Date().toISOString();
44
- draft.entries = [...draft.entries, {
45
- id: `mem_${String(draft.entries.length + 1).padStart(12, '0')}`,
46
- revision: 1,
47
- status: 'active',
48
- origin: 'user_explicit',
49
- createdAt: at,
50
- updatedAt: at,
51
- current: { title, summary: title, body, kind: 'note', when: '', keywords: [], topicKey: '', triggers: [], applicability: 'all_in_scope', conditions: [], selection: null, links: [], aliases: [] },
52
- maintenance: { reviewState: 'active', sourceRefs: [], retiredNames: [], versions: [], checks: [], hits: 0, merges: 0, lastUsed: at },
53
- }];
54
- return { changed: true };
55
- };
56
-
57
- test('瘦包一封就是一份带校验和的状态', () => {
58
- const scope = fresh('envelope');
59
- const created = transact(scope, { expectCreate: true }, addNote('第一条', '正文'));
60
- assert.equal(created.changed, true);
61
- assert.equal(created.state.generation, 1);
62
- const parsed = JSON.parse(raw(scope));
63
- assert.equal(parsed.format, 'dsh-lite-memory/state@4');
64
- assert.match(parsed.checksum, /^sha256:[a-f0-9]{64}$/);
65
- assert.equal(parsed.payload.generation, 1);
66
- assert.ok(fs.existsSync(path.join(scope.dir, 'state.prev.json')) === false);
67
- assert.equal(fs.existsSync(path.join(scope.dir, '.lock')), false);
68
- });
69
-
70
- test('2 版状态可读,下一次写入升级为 4 版并保留记录', () => {
71
- const scope = fresh('migrate-v2');
72
- const payload = emptyState(scope);
73
- addNote('旧库里的规则', '原文')(payload);
74
- payload.schemaVersion = 2;
75
- delete payload.experimental;
76
- const checksum = `sha256:${createHash('sha256').update(JSON.stringify(payload)).digest('hex')}`;
77
- fs.mkdirSync(scope.dir, { recursive: true });
78
- fs.writeFileSync(stateFile(scope), `${JSON.stringify({ format: 'dsh-lite-memory/state@2', checksum, payload })}\n`);
79
- const loaded = load(scope);
80
- assert.equal(loaded.state.schemaVersion, 4);
81
- assert.equal(loaded.state.entries[0].current.title, '旧库里的规则');
82
- assert.equal(evolutionOf(loaded.state).enabled, false);
83
- transact(scope, { expected: { generation: loaded.state.generation } }, (draft) => {
84
- draft.entries[0].current.body = '升级后的正文';
85
- return { changed: true };
86
- });
87
- const parsed = JSON.parse(raw(scope));
88
- assert.equal(parsed.format, 'dsh-lite-memory/state@4');
89
- assert.equal(parsed.payload.entries[0].current.body, '升级后的正文');
90
- });
91
-
92
- test('D04b 旧实验规则缺作用域指纹时转待复核,正文与来源保留', () => {
93
- const scope = fresh('legacy-rules');
94
- transact(scope, { expectCreate: true }, (draft) => {
95
- addNote('旧规则', '旧正文')(draft);
96
- const entry = draft.entries[0];
97
- entry.origin = 'project_evolution';
98
- entry.maintenance.sourceRefs = [{ sourceType: 'human_feedback', sourceExcerpt: '以后都这样' }];
99
- entry.maintenance.learning = { state: 'trial', trialUntil: '2026-01-01T00:00:00.000Z' };
100
- addNote('新规则', '新正文')(draft);
101
- const freshRule = draft.entries[1];
102
- freshRule.origin = 'project_evolution';
103
- freshRule.maintenance.learning = { state: 'active', specDigest: 'digest' };
104
- return { changed: true };
105
- });
106
- const first = transact(scope, {}, (draft) => state.applyEvolution(draft, true));
107
- assert.equal(first.changed, true);
108
- const entries = first.state.entries;
109
- assert.equal(entries[0].maintenance.reviewState, 'legacy_unreviewed');
110
- assert.equal(entries[0].maintenance.legacyReview.reason, 'scope_unverified');
111
- assert.equal(entries[0].current.body, '旧正文');
112
- assert.equal(entries[0].maintenance.sourceRefs[0].sourceExcerpt, '以后都这样');
113
- assert.equal(entries[0].maintenance.learning.state, 'paused');
114
- assert.equal(entries[0].maintenance.learning.blockedReason, 'needs_scope_review');
115
- assert.equal(entries[1].maintenance.reviewState, 'active');
116
- const second = transact(scope, {}, (draft) => state.applyEvolution(draft, true));
117
- assert.equal(second.changed, false, '已复核的不再重复改写');
118
- });
119
-
120
- test('D04 旧程序拒绝写新格式,不抹掉实验标记', () => {
121
- const scope = fresh('d04-format');
122
- transact(scope, { expectCreate: true }, (draft) => {
123
- state.applyEvolution(draft, true);
124
- return { changed: true };
125
- });
126
- const before = raw(scope);
127
- assert.equal(JSON.parse(before).format, 'dsh-lite-memory/state@4');
128
- // 旧版只认 state@2,读到新格式会拒绝而不是当空库覆盖;实验标记因此留在盘上。
129
- const oldReader = (text) => {
130
- const data = JSON.parse(text);
131
- if (data.format !== 'dsh-lite-memory/state@2') throw new StateError('invalid', '旧版不认这个格式');
132
- return data.payload;
133
- };
134
- assert.throws(() => oldReader(before), (error) => error instanceof StateError && error.code === 'invalid');
135
- assert.equal(raw(scope), before);
136
- assert.equal(load(scope).state.experimental.projectEvolution.enabled, true);
137
- });
138
-
139
- test('D01 发布点失败:状态一个字节没动,也没有留下锁', () => {
140
- const scope = fresh('d01');
141
- transact(scope, { expectCreate: true }, addNote('第一条', '正文'));
142
- const before = raw(scope);
143
- assert.throws(() => transact(scope, { expected: { generation: 1 } }, () => {
144
- throw new Error('写盘前崩了');
145
- }), /写盘前崩了/);
146
- assert.equal(raw(scope), before);
147
- assert.equal(fs.existsSync(path.join(scope.dir, '.lock')), false);
148
- assert.equal(load(scope).state.generation, 1);
149
- });
150
-
151
- test('D01 current 丢了 prev 还在:读得到,下一次写入重新发布 current', () => {
152
- const scope = fresh('d01-prev');
153
- transact(scope, { expectCreate: true }, addNote('第一条', '正文'));
154
- transact(scope, { expected: { generation: 1 } }, addNote('第二条', '正文'));
155
- fs.copyFileSync(stateFile(scope), path.join(scope.dir, 'state.prev.json'));
156
- fs.rmSync(stateFile(scope));
157
- const recovered = load(scope);
158
- assert.equal(recovered.recovered, true);
159
- assert.equal(recovered.state.generation, 2);
160
- const written = transact(scope, {}, addNote('第三条', '正文'));
161
- assert.equal(written.changed, true);
162
- assert.equal(load(scope).exists, true);
163
- assert.equal(load(scope).state.generation, 3);
164
- });
165
-
166
- test('D02 响应丢了:同一 operationId 重放回执,不再写一遍', () => {
167
- const scope = fresh('d02');
168
- const note = addNote('一条', '正文');
169
- const first = transact(scope, { operationId: 'op-1', payloadDigest: 'digest-a' }, note);
170
- assert.equal(first.changed, true);
171
- assert.equal(first.state.generation, 1);
172
- const after = raw(scope);
173
- const replay = transact(scope, { operationId: 'op-1', payloadDigest: 'digest-a' }, note);
174
- assert.equal(replay.replayed, true);
175
- assert.equal(replay.changed, true);
176
- assert.equal(raw(scope), after);
177
- assert.equal(load(scope).state.generation, 1);
178
- assert.throws(() => transact(scope, { operationId: 'op-1', payloadDigest: 'digest-b' }, note), /不同内容/);
179
- });
180
-
181
- test('D08 活进程占着锁:报忙,不猜也不抢(死 pid 的残留锁另行回收)', () => {
182
- const scope = fresh('d08-lock');
183
- fs.mkdirSync(scope.dir, { recursive: true });
184
- fs.writeFileSync(path.join(scope.dir, '.lock'), `${process.pid}:deadbeef`);
185
- assert.throws(() => transact(scope, {}, addNote('一条', '正文')), (error) => error instanceof StateError && error.code === 'busy');
186
- assert.equal(fs.existsSync(stateFile(scope)), false);
187
- fs.rmSync(path.join(scope.dir, '.lock'));
188
- assert.equal(transact(scope, {}, addNote('一条', '正文')).changed, true);
189
- });
190
-
191
- test('D08 坏 JSON 与不认识的版本都不当空库覆盖', () => {
192
- const broken = fresh('d08-json');
193
- fs.mkdirSync(broken.dir, { recursive: true });
194
- fs.writeFileSync(stateFile(broken), '{"format":');
195
- assert.throws(() => load(broken), (error) => error instanceof StateError && error.code === 'corrupt');
196
- assert.equal(fs.readFileSync(stateFile(broken), 'utf8'), '{"format":');
197
- const unknown = fresh('d08-version');
198
- fs.mkdirSync(unknown.dir, { recursive: true });
199
- const payload = emptyState(unknown);
200
- payload.schemaVersion = 99;
201
- const checksum = `sha256:${createHash('sha256').update(JSON.stringify(payload)).digest('hex')}`;
202
- fs.writeFileSync(stateFile(unknown), `${JSON.stringify({ format: 'dsh-lite-memory/state@2', checksum, payload })}\n`);
203
- assert.throws(() => load(unknown), (error) => error instanceof StateError && error.code === 'corrupt');
204
- });
205
-
206
- test('D08 重入锁:读—改—写同一个窗口里连续调用不死锁', () => {
207
- const scope = fresh('d08-reentrant');
208
- const result = withScopeLock(scope, () => {
209
- transact(scope, { expectCreate: true }, addNote('第一条', '正文'));
210
- return transact(scope, { expected: { generation: 1 } }, addNote('第二条', '正文'));
211
- });
212
- assert.equal(result.changed, true);
213
- assert.equal(load(scope).state.entries.length, 2);
214
- });
215
-
216
- test('S01 仓库根与子目录共用一份记忆', () => {
217
- const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-repo-'));
218
- dirs.push(repo);
219
- execFileSync('git', ['init', '-q'], { cwd: repo });
220
- const sub = path.join(repo, 'packages', 'app');
221
- fs.mkdirSync(sub, { recursive: true });
222
- const atRoot = scopeOf(repo);
223
- const atSub = scopeOf(sub);
224
- assert.equal(atSub.key, atRoot.key);
225
- assert.equal(atSub.dir, atRoot.dir);
226
- assert.equal(pathId(resolveRoot(sub).root), pathId(repo));
227
- });
228
-
229
- test('S02 符号链接、非 Git 目录、嵌套仓库各归各的', () => {
230
- const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-link-'));
231
- dirs.push(repo);
232
- execFileSync('git', ['init', '-q'], { cwd: repo });
233
- const link = path.join(os.tmpdir(), `lm-state-alias-${Math.random().toString(16).slice(2, 8)}`);
234
- dirs.push(link);
235
- fs.symlinkSync(repo, link);
236
- assert.equal(scopeOf(link).key, scopeOf(repo).key);
237
- const plain = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-plain-'));
238
- dirs.push(plain);
239
- assert.equal(resolveRoot(plain).source, 'cwd');
240
- const nested = path.join(repo, 'vendor', 'inner');
241
- fs.mkdirSync(nested, { recursive: true });
242
- execFileSync('git', ['init', '-q'], { cwd: nested });
243
- assert.notEqual(scopeOf(nested).key, scopeOf(repo).key);
244
- });
245
-
246
- test('S03 配置的项目根只覆盖自己的子树,不吞别的目录', () => {
247
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-config-'));
248
- dirs.push(root);
249
- const sub = path.join(root, 'packages', 'app');
250
- fs.mkdirSync(sub, { recursive: true });
251
- const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-outside-'));
252
- dirs.push(outside);
253
- const config = path.join(memoryRoot(), 'config.json');
254
- fs.mkdirSync(path.dirname(config), { recursive: true });
255
- fs.writeFileSync(config, JSON.stringify({ projectRoot: root }));
256
- try {
257
- assert.equal(pathId(resolveRoot(sub).root), pathId(root));
258
- assert.equal(resolveRoot(sub).source, 'configured');
259
- assert.equal(scopeOf(sub).key, scopeOf(root).key);
260
- assert.notEqual(resolveRoot(outside).source, 'configured');
261
- assert.notEqual(scopeOf(outside).key, scopeOf(root).key);
262
- } finally {
263
- fs.rmSync(config, { force: true });
264
- }
265
- });
266
-
267
- test('P04 Git 查询失败不冒充已确认工程,不用 cwd 兜底学习', () => {
268
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-gitfail-'));
269
- dirs.push(dir);
270
- const previous = process.env.PATH;
271
- process.env.PATH = '';
272
- try {
273
- const resolved = resolveRoot(dir);
274
- assert.equal(resolved.source, 'cwd');
275
- assert.equal(resolved.confirmed, false);
276
- assert.equal(scopeOf(dir).confirmed, false);
277
- } finally {
278
- process.env.PATH = previous;
279
- }
280
- });
281
-
282
- test('S04 全局层从 41 条写到 200 条,一条不少也不拒绝', () => {
283
- const global = globalScope();
284
- transact(global, { expectCreate: true }, (draft) => {
285
- for (let i = 0; i < 41; i += 1) addNote(`旧全局 ${i}`, `正文 ${i}`)(draft);
286
- return { changed: true };
287
- });
288
- for (let i = 41; i < 200; i += 1) {
289
- transact(global, {}, addNote(`全局 ${i}`, `正文 ${i}`));
290
- }
291
- assert.equal(load(global).state.entries.length, 200);
292
- assert.equal(LIMITS.globalEntries, 200);
293
- });
294
-
295
- test('S05 第 201 条与字节压力:先归档最久没读的,不拒绝', () => {
296
- const scope = fresh('s05');
297
- transact(scope, { expectCreate: true }, (draft) => {
298
- for (let i = 0; i <= 200; i += 1) addNote(`条目 ${i}`, `正文 ${i}`)(draft);
299
- return { changed: true };
300
- });
301
- assert.equal(load(scope).state.entries.length, 201);
302
- const big = 'x'.repeat(Math.ceil(LIMITS.totalBytes / 4));
303
- for (let i = 0; i < 4; i += 1) {
304
- transact(scope, {}, addNote(`大条目 ${i}`, big));
305
- }
306
- const loaded = load(scope).state;
307
- const active = loaded.entries.filter((entry) => entry.status === 'active');
308
- const archived = loaded.entries.filter((entry) => entry.status === 'archived');
309
- assert.ok(archived.length >= 1, '腾了位置');
310
- assert.ok(active.reduce((sum, entry) => sum + state.entryBytesOf(entry), 0) <= LIMITS.totalBytes);
311
- for (const entry of archived) assert.equal(entry.current.body.length > 0, true);
312
- });
313
-
314
- test('S10 旧库迁移:正文、归档、旧标题一起搬进状态', () => {
315
- const project = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-legacy-'));
316
- dirs.push(project);
317
- const scope = scopeOf(project);
318
- fs.mkdirSync(scope.dir, { recursive: true });
319
- fs.cpSync(new URL('./fixtures/legacy-v1', import.meta.url), scope.dir, { recursive: true });
320
- const indexFile = path.join(scope.dir, 'index.json');
321
- const legacy = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
322
- legacy.cwd = scope.cwd;
323
- fs.writeFileSync(indexFile, JSON.stringify(legacy));
324
- const report = migrationReport(scope);
325
- assert.equal(report.required, true);
326
- assert.equal(report.entries, 3);
327
- assert.deepEqual(report.missingBodies, []);
328
- const migrated = migrateLegacy(scope);
329
- assert.equal(migrated.migrated, true);
330
- const loaded = load(scope).state;
331
- assert.equal(loaded.entries.length, 4);
332
- assert.equal(loaded.migration.fromVersion, 1);
333
- assert.equal(bodyOf(scope, 'mem_111111111111').replace(/\r\n/g, '\n'), '第一步跑测试\n第二步改版本号');
334
- const archived = loaded.entries.find((entry) => entry.id === 'mem_444444444444');
335
- assert.equal(archived.status, 'archived');
336
- assert.equal(archived.current.body, '归档正文');
337
- assert.equal(migrateLegacy(scope).migrated, false);
338
- assert.equal(fs.existsSync(path.join(scope.dir, 'legacy', 'v1', 'index.json')), true);
339
- assert.equal(fs.existsSync(indexFile), false);
340
- });
341
-
342
- test('S10 缺正文与同 id 冲突:先给报告,不动盘', () => {
343
- const project = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-legacy-bad-'));
344
- dirs.push(project);
345
- const scope = scopeOf(project);
346
- fs.mkdirSync(scope.dir, { recursive: true });
347
- fs.cpSync(new URL('./fixtures/legacy-v1', import.meta.url), scope.dir, { recursive: true });
348
- const indexFile = path.join(scope.dir, 'index.json');
349
- const legacy = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
350
- legacy.cwd = scope.cwd;
351
- fs.writeFileSync(indexFile, JSON.stringify(legacy));
352
- fs.rmSync(path.join(scope.dir, 'entries', 'mem_222222222222.md'));
353
- const before = fs.readFileSync(indexFile, 'utf8');
354
- assert.throws(() => migrateLegacy(scope), (error) => error instanceof StateError && error.code === 'legacy_missing_body' && error.report.missingBodies.includes('mem_222222222222'));
355
- assert.equal(fs.readFileSync(indexFile, 'utf8'), before);
356
- assert.equal(fs.existsSync(stateFile(scope)), false);
357
- });
358
-
359
- test('S10 旧的子目录库并进仓库根,同 id 只留一条', () => {
360
- const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-adopt-'));
361
- dirs.push(repo);
362
- execFileSync('git', ['init', '-q'], { cwd: repo });
363
- const sub = path.join(repo, 'packages', 'app');
364
- fs.mkdirSync(sub, { recursive: true });
365
- const scope = scopeOf(repo);
366
- const childKey = `legacy-${Math.random().toString(16).slice(2, 8)}`;
367
- const childDir = path.join(memoryRoot(), childKey);
368
- fs.mkdirSync(path.join(childDir, 'entries'), { recursive: true });
369
- fs.writeFileSync(path.join(childDir, 'index.json'), JSON.stringify({
370
- version: 1, scope: childKey, cwd: sub,
371
- 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 }],
372
- suggestions: [], receipts: [],
373
- }));
374
- fs.writeFileSync(path.join(childDir, 'entries', 'mem_555555555555.md'), '子目录正文');
375
- const report = migrationReport(scope);
376
- assert.equal(report.adoptable.length, 1);
377
- const migrated = migrateLegacy(scope);
378
- assert.equal(migrated.migrated, true);
379
- assert.equal(bodyOf(scope, 'mem_555555555555'), '子目录正文');
380
- assert.equal(fs.existsSync(childDir), false);
381
- assert.equal(fs.existsSync(path.join(scope.dir, 'legacy', `scope-${childKey}`)), true);
382
- });
383
-
384
- test('S10 迁移预检扫到所有被吸收的旧库:子目录缺正文/同 id 不同正文都不发布', () => {
385
- const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-adopt-bad-'));
386
- dirs.push(repo);
387
- execFileSync('git', ['init', '-q'], { cwd: repo });
388
- const sub = path.join(repo, 'packages', 'bad');
389
- fs.mkdirSync(sub, { recursive: true });
390
- const scope = scopeOf(repo);
391
- const childKey = `legacy-bad-${Math.random().toString(16).slice(2, 8)}`;
392
- const childDir = path.join(memoryRoot(), childKey);
393
- fs.mkdirSync(path.join(childDir, 'entries'), { recursive: true });
394
- fs.writeFileSync(path.join(childDir, 'index.json'), JSON.stringify({
395
- version: 1, scope: childKey, cwd: sub,
396
- entries: [
397
- { 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' },
398
- { 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' },
399
- ],
400
- suggestions: [], receipts: [],
401
- }));
402
- fs.writeFileSync(path.join(childDir, 'entries', 'mem_777777777777.md'), '子目录版本');
403
- /** 主库有同 id 但正文不同:合并会静默丢数据,必须在预检就拦下。 */
404
- fs.mkdirSync(path.join(scope.dir, 'entries'), { recursive: true });
405
- fs.writeFileSync(path.join(scope.dir, 'index.json'), JSON.stringify({
406
- version: 1, scope: scope.key, cwd: repo,
407
- 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' }],
408
- suggestions: [], receipts: [],
409
- }));
410
- fs.writeFileSync(path.join(scope.dir, 'entries', 'mem_777777777777.md'), '主库版本');
411
- const report = migrationReport(scope);
412
- assert.equal(report.sources, 2);
413
- assert.ok(report.missingBodies.includes('mem_666666666666'));
414
- assert.ok(report.conflicts.some((row) => row.id === 'mem_777777777777' && /不同正文/.test(row.reason)));
415
- assert.throws(() => migrateLegacy(scope), (error) => error instanceof StateError && error.code === 'legacy_conflict');
416
- assert.equal(fs.existsSync(stateFile(scope)), false);
417
- assert.equal(fs.existsSync(path.join(childDir, 'index.json')), true);
418
- });
419
-
420
- test('exact noop 不涨 generation,也不改 updatedAt', () => {
421
- const scope = fresh('noop');
422
- transact(scope, { expectCreate: true }, addNote('一条', '正文'));
423
- const before = load(scope).state;
424
- const noop = transact(scope, { expected: { generation: 1 } }, () => ({ changed: false }));
425
- assert.equal(noop.changed, false);
426
- assert.equal(load(scope).state.generation, before.generation);
427
- assert.equal(load(scope).state.updatedAt, before.updatedAt);
428
- });
429
-
430
- test('validateState 拦坏结构,不拦合法的一条', () => {
431
- const scope = fresh('validate');
432
- const good = emptyState(scope);
433
- addNote('一条', '正文')(good);
434
- assert.equal(validateState(good).schemaVersion, 4);
435
- const bad = JSON.parse(JSON.stringify(good));
436
- bad.entries[0].id = 'not-an-id';
437
- assert.throws(() => validateState(bad), (error) => error instanceof StateError);
438
- });
439
-
440
-
441
- // ── 彻底忘掉:屏障、断点、旧副本 ─────────────────────────────────────────────
442
- test('purge 删条目不回吐标题,轮换 epoch,prev 也是清过的', () => {
443
- const scope = fresh('purge');
444
- transact(scope, { expectCreate: true }, addNote('要忘掉的事', '旧正文-哨兵'));
445
- transact(scope, { expected: { generation: 1 } }, addNote('要留下的事', '留下的正文'));
446
- const before = load(scope).state;
447
- const result = state.purge(scope, { ids: ['mem_000000000001'], operationId: 'op_purge_test' });
448
- assert.equal(result.ok, true);
449
- assert.equal(result.operationId, 'op_purge_test');
450
- assert.equal(JSON.stringify(result).includes('要忘掉的事'), false);
451
- const after = load(scope).state;
452
- assert.equal(after.entries.some((entry) => entry.id === 'mem_000000000001'), false);
453
- assert.equal(after.entries.some((entry) => entry.id === 'mem_000000000002'), true);
454
- assert.notEqual(after.epoch, before.epoch);
455
- assert.equal(after.receipts.length, 1);
456
- assert.equal(after.receipts[0].kind, 'purge');
457
- assert.equal(JSON.stringify(after.receipts).includes('要忘掉的事'), false);
458
- const row = after.suppressions.find((item) => item.reason === 'forgotten_source');
459
- assert.ok(row);
460
- assert.equal(row.expiresAt, null);
461
- assert.equal(fs.existsSync(path.join(scope.dir, 'bodies', 'mem_000000000001.md')), false);
462
- const prev = JSON.parse(fs.readFileSync(path.join(scope.dir, 'state.prev.json'), 'utf8')).payload;
463
- assert.equal(prev.entries.some((entry) => entry.id === 'mem_000000000001'), false);
464
- assert.equal(fs.existsSync(path.join(scope.dir, 'purge.intent.json')), false);
465
- // 同一个 id 不会再被当成活条目
466
- assert.throws(() => transact(scope, {}, addNote('要忘掉的事', '冒充')), (error) => error instanceof StateError);
467
- });
468
-
469
- test('purge 断点:有 intent 时普通读取被拦住,重启后收尾', () => {
470
- const scope = fresh('purge-resume');
471
- transact(scope, { expectCreate: true }, addNote('断点里的事', '断点正文-哨兵'));
472
- const loaded = load(scope);
473
- const intent = {
474
- operationId: 'op_resume_test',
475
- scopeKey: scope.key,
476
- storeId: loaded.state.storeId,
477
- fromEpoch: loaded.state.epoch,
478
- toEpoch: '00000000-0000-4000-8000-000000000001',
479
- ids: ['mem_000000000001'],
480
- phase: 'planned',
481
- at: new Date().toISOString(),
482
- };
483
- fs.writeFileSync(path.join(scope.dir, 'purge.intent.json'), `${JSON.stringify(intent, null, 2)}\n`);
484
- assert.throws(() => load(scope), (error) => error instanceof StateError && error.code === 'purge_pending');
485
- assert.throws(() => state.purge(scope, { ids: ['mem_000000000001'] }), (error) => error.code === 'purge_pending');
486
- const resumed = state.resumePurge(scope);
487
- assert.equal(resumed.epoch, intent.toEpoch);
488
- assert.equal(fs.existsSync(path.join(scope.dir, 'purge.intent.json')), false);
489
- assert.equal(load(scope).state.entries.length, 0);
490
- assert.equal(JSON.stringify(resumed).includes('断点里的事'), false);
491
- });
492
-
493
- test('purge 之后再碰同一个 id 返回 missing,不复活', () => {
494
- const scope = fresh('purge-missing');
495
- transact(scope, { expectCreate: true }, addNote('删一次', '正文'));
496
- state.purge(scope, { ids: ['mem_000000000001'] });
497
- const again = state.purge(scope, { ids: ['mem_000000000001'] });
498
- assert.equal(again.ok, true);
499
- assert.equal(again.missing, true);
500
- assert.equal(load(scope).state.entries.length, 0);
501
- });
502
-
503
- test('写入重试不堆临时副本:固定临时名写前清理,成功后只留状态文件', () => {
504
- const scope = fresh('retry-temp');
505
- transact(scope, { expectCreate: true }, addNote('第一次', 'x'));
506
- const before = load(scope);
507
- /** 模拟上次写到一半:固定名和旧版随机名的残留都应在下一次写入前清掉。 */
508
- fs.writeFileSync(path.join(scope.dir, '.state.json.tmp'), '{"half":true}');
509
- fs.writeFileSync(path.join(scope.dir, '.state.prev.json.tmp'), '{"half":true}');
510
- fs.writeFileSync(path.join(scope.dir, '.state.abandoned.tmp'), '{"half":true}');
511
- const next = transact(scope, { expected: { generation: before.state.generation, epoch: before.state.epoch } }, addNote('第二次', 'y'));
512
- assert.equal(next.state.generation, before.state.generation + 1);
513
- assert.deepEqual(fs.readdirSync(scope.dir).filter((name) => name.endsWith('.tmp')), []);
514
- assert.equal(JSON.parse(raw(scope)).payload.generation, next.state.generation);
515
- });
516
-
517
-
518
- /** 崩溃残留的 .lock:死 pid 能自动回收,活 pid 仍然 busy,迟到的新锁不能被误删。 */
519
- const lockPath = (scope) => path.join(scope.dir, '.lock');
520
- const writeLock = (scope, token) => {
521
- fs.mkdirSync(scope.dir, { recursive: true });
522
- fs.writeFileSync(lockPath(scope), token);
523
- };
524
- const deadPid = () => {
525
- for (const pid of [999999, 999998, 999997, 888888]) {
526
- try {
527
- process.kill(pid, 0);
528
- } catch (error) {
529
- if (error.code === 'ESRCH') return pid;
530
- }
531
- }
532
- return null;
533
- };
534
-
535
- test('崩溃残留的锁(死 pid)自动回收,不再卡 busy', () => {
536
- const pid = deadPid();
537
- if (pid === null) return;
538
- const scope = fresh('stale-lock-dead');
539
- writeLock(scope, `${pid}:deadbeef`);
540
- const started = Date.now();
541
- const result = withScopeLock(scope, () => 'locked');
542
- const elapsed = Date.now() - started;
543
- assert.equal(result, 'locked');
544
- assert.ok(elapsed < 300, `回收要立刻重试,实际等了 ${elapsed}ms`);
545
- assert.equal(fs.existsSync(lockPath(scope)), false, '退出时自己的锁要删掉');
546
- });
547
-
548
- test('活 pid 的锁不回收:仍然报 busy,也不删别人的锁', () => {
549
- const scope = fresh('stale-lock-live');
550
- const token = `${process.pid}:other`;
551
- writeLock(scope, token);
552
- fs.utimesSync(lockPath(scope), new Date(0), new Date(0));
553
- const started = Date.now();
554
- assert.throws(() => withScopeLock(scope, () => 'never'), (error) => error.code === 'busy');
555
- assert.ok(Date.now() - started >= LIMITS.lockWaitMs - 50, '活锁要等满等待窗口');
556
- assert.equal(fs.readFileSync(lockPath(scope), 'utf8'), token, '活进程的锁一个字节都不能动');
557
- });
558
-
559
- test('回收窗口里锁被换成活 token:不删别人的新锁,最终报 busy', () => {
560
- const pid = deadPid();
561
- if (pid === null) return;
562
- const scope = fresh('stale-lock-race');
563
- const stale = `${pid}:stale`;
564
- const live = `${process.pid}:live`;
565
- writeLock(scope, stale);
566
- const originalRead = fs.readFileSync;
567
- const originalWrite = fs.writeFileSync;
568
- let swapped = false;
569
- /**
570
- * 真正注入竞态:回收函数第一次读到的还是死 pid 的 token,就在它读完之后、
571
- * 二次校验之前,另一个进程把锁换成了活 token。回收必须放弃删除。
572
- */
573
- fs.readFileSync = function (target, ...rest) {
574
- const result = originalRead.call(fs, target, ...rest);
575
- if (!swapped && String(target) === lockPath(scope)) {
576
- swapped = true;
577
- originalWrite.call(fs, lockPath(scope), live);
578
- }
579
- return result;
580
- };
581
- try {
582
- assert.throws(() => withScopeLock(scope, () => 'never'), (error) => error.code === 'busy');
583
- assert.equal(swapped, true, '竞态注入没生效,用例等于没测');
584
- assert.equal(originalRead.call(fs, lockPath(scope), 'utf8'), live, '换过主人的锁不能被误删');
585
- } finally {
586
- fs.readFileSync = originalRead;
587
- fs.rmSync(lockPath(scope), { force: true });
588
- }
589
- });
590
-
591
- test('P0-B 坏 state + 好 prev:从上一版恢复,坏字节留成副本,写入继续', () => {
592
- const scope = fresh('p0b-recover');
593
- transact(scope, { expectCreate: true }, addNote('第一条', '正文一'));
594
- transact(scope, { expected: { generation: 1 } }, addNote('第二条', '正文二'));
595
- const broken = '{"format":';
596
- fs.writeFileSync(stateFile(scope), broken);
597
- const first = load(scope);
598
- assert.equal(first.recovered, true);
599
- assert.equal(first.state.generation, 1, '采用上一版,不能凭空造新代数');
600
- assert.equal(first.state.entries.length, 1);
601
- assert.match(first.recovery.message, /已从上一版恢复/);
602
- assert.equal(fs.readFileSync(first.recovery.corruptFile, 'utf8'), broken, '原始字节要原样保留');
603
- assert.equal(fs.readFileSync(stateFile(scope), 'utf8'), broken, '读路径不许静默覆盖坏文件');
604
- assert.equal(fs.readdirSync(scope.dir).filter((name) => name.startsWith('state.corrupt-')).length, 1);
605
- load(scope);
606
- assert.equal(fs.readdirSync(scope.dir).filter((name) => name.startsWith('state.corrupt-')).length, 1, '同一份坏字节不重复堆副本');
607
- const written = transact(scope, { expected: { generation: 1 } }, addNote('第三条', '正文三'));
608
- assert.equal(written.changed, true);
609
- const after = load(scope);
610
- assert.equal(after.recovered, false);
611
- assert.equal(after.state.generation, 2);
612
- assert.equal(after.state.entries.length, 2);
613
- assert.doesNotThrow(() => JSON.parse(fs.readFileSync(path.join(scope.dir, 'state.prev.json'), 'utf8')), 'prev 不能被坏字节污染');
614
- });
615
-
616
- test('P0-B 坏 state 且没有 prev:报错给出损坏副本与可执行的恢复路径', () => {
617
- const scope = fresh('p0b-noprev');
618
- fs.mkdirSync(scope.dir, { recursive: true });
619
- const broken = '{"format":';
620
- fs.writeFileSync(stateFile(scope), broken);
621
- let error = null;
622
- try {
623
- load(scope);
624
- } catch (caught) {
625
- error = caught;
626
- }
627
- assert.ok(error, '坏 state 不能当空库');
628
- assert.equal(error.code, 'corrupt');
629
- assert.match(error.message, /state\.prev\.json/, '要给出上一版路径');
630
- assert.match(error.message, /恢复步骤/);
631
- assert.ok(error.recovery && error.recovery.corruptFile, '要指出损坏副本');
632
- assert.equal(fs.readFileSync(error.recovery.corruptFile, 'utf8'), broken);
633
- assert.equal(fs.readFileSync(stateFile(scope), 'utf8'), broken);
634
- });
635
-
636
- test('D05 学习开关关掉再开,不重置已用次数、滚动额度与整理记录', () => {
637
- const scope = fresh('d05-learning-budget');
638
- const at = new Date().toISOString();
639
- transact(scope, {}, (draft) => {
640
- const evo = draft.experimental.projectEvolution;
641
- evo.learning = {
642
- attempts: { ep_seed: { count: 2, lastAt: at } },
643
- recent: [at],
644
- receipts: [{ operationId: 'op_seed', at, inputTokens: 10, outputTokens: 5 }],
645
- };
646
- return state.applyEvolution(draft, true);
647
- });
648
- const opened = load(scope).state.experimental.projectEvolution;
649
- assert.equal(opened.enabled, true);
650
- assert.deepEqual(opened.learning.recent, [at], '滚动额度时间戳要留着,关开不洗额度');
651
- assert.equal(opened.learning.receipts.length, 1, '打开不能清整理回执');
652
- assert.equal(opened.learning.attempts.ep_seed.count, 2, '打开不能清已用次数');
653
- transact(scope, {}, (draft) => state.applyEvolution(draft, false));
654
- transact(scope, {}, (draft) => state.applyEvolution(draft, true));
655
- const again = load(scope).state.experimental.projectEvolution;
656
- assert.equal(again.enabled, true);
657
- assert.equal(again.learning.receipts.length, 1, '关掉再开不重置整理回执');
658
- assert.equal(again.learning.attempts.ep_seed.count, 2, '关掉再开不重置已用次数');
659
- assert.deepEqual(again.learning.recent, [at], '关掉再开也不能洗掉滚动额度');
660
- });
661
-
662
- for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
663
- console.log(`state.test.mjs: ${passed} passed`);