@modusensus/dsh-mneme 0.3.7 → 0.3.9
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 +31 -3
- package/lib/api.js +9 -1
- package/lib/mirror.js +24 -12
- package/lib/service.js +105 -36
- package/lib/store.js +169 -50
- package/package.json +2 -2
- package/scripts/e2e-dsh.js +4 -2
- package/src/api.js +9 -1
- package/src/mirror.js +24 -12
- package/src/service.js +105 -36
- package/src/store.js +169 -50
- package/test/mirror-generation.test.js +58 -22
- package/test/peer-blockers.test.js +190 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { EventEmitter } from "node:events";
|
|
@@ -141,30 +141,33 @@ test("V0.3.6-C3: incrementGeneration 每次 +1,不动 dirty/applied", () => {
|
|
|
141
141
|
|
|
142
142
|
// ── D. 逐 type 部分成功(store 层 setTypeStatus)──────────────────────────
|
|
143
143
|
|
|
144
|
-
test("V0.3.6-D1: setTypeStatus
|
|
144
|
+
test("V0.3.6-D1: setTypeStatus 记录逐 type committed/failed/pending 回执(peer blocker 4)", () => {
|
|
145
145
|
const { dir, store } = setup();
|
|
146
146
|
try {
|
|
147
|
-
|
|
148
|
-
assert.
|
|
147
|
+
// 状态必填且必须合法
|
|
148
|
+
assert.throws(() => store.setTypeStatus("project", { applied_gen: 1 }), /status must be one of/, "缺 status 必须抛错");
|
|
149
|
+
assert.throws(() => store.setTypeStatus("project", { status: "bogus" }), /status must be one of/, "非法 status 必须抛错");
|
|
150
|
+
|
|
151
|
+
const s1 = store.setTypeStatus("project", { status: "failed", last_error: "e1" });
|
|
152
|
+
assert.equal(s1.type_status.project.status, "failed");
|
|
149
153
|
assert.equal(s1.type_status.project.last_error, "e1");
|
|
150
154
|
|
|
151
|
-
const s2 = store.setTypeStatus("project", { applied_gen: 7 });
|
|
152
|
-
assert.equal(s2.type_status.project.
|
|
153
|
-
assert.equal(s2.type_status.project.
|
|
154
|
-
assert.equal(s2.type_status.project.last_error, "e1", "未传字段保留");
|
|
155
|
+
const s2 = store.setTypeStatus("project", { status: "committed", applied_gen: 7, last_error: null });
|
|
156
|
+
assert.equal(s2.type_status.project.status, "committed", "同 type 状态更新");
|
|
157
|
+
assert.equal(s2.type_status.project.applied_gen, 7);
|
|
155
158
|
|
|
156
|
-
const s3 = store.setTypeStatus("decision", {
|
|
157
|
-
assert.equal(s3.type_status.decision.
|
|
158
|
-
assert.equal(s3.type_status.project.
|
|
159
|
+
const s3 = store.setTypeStatus("decision", { status: "pending" });
|
|
160
|
+
assert.equal(s3.type_status.decision.status, "pending", "另一 type 独立");
|
|
161
|
+
assert.equal(s3.type_status.project.status, "committed", "同 type 不受影响");
|
|
159
162
|
|
|
160
163
|
// getTypeStatus 返回同一解析后的 map
|
|
161
164
|
assert.deepEqual(store.getTypeStatus(), {
|
|
162
|
-
project: {
|
|
163
|
-
decision: {
|
|
165
|
+
project: { status: "committed", applied_gen: 7, last_error: null },
|
|
166
|
+
decision: { status: "pending" }
|
|
164
167
|
});
|
|
165
168
|
// type_status 持久化为 JSON 文本
|
|
166
169
|
const row = store.db.prepare("SELECT type_status FROM mirror_state WHERE id='main'").get();
|
|
167
|
-
assert.ok(JSON.parse(row.type_status).project.
|
|
170
|
+
assert.ok(JSON.parse(row.type_status).project.status === "committed", "type_status 必须以 JSON 落库");
|
|
168
171
|
} finally {
|
|
169
172
|
store.close();
|
|
170
173
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -253,7 +256,7 @@ test("V0.3.6-B1: markMirrorDirty 自身写失败——syncMirror 不抛、genera
|
|
|
253
256
|
|
|
254
257
|
// ── D. 逐 type 部分成功(service 层 type_status 生命周期)─────────────────
|
|
255
258
|
|
|
256
|
-
test("V0.3.6-D2: syncMirror 成功时逐 type 记录
|
|
259
|
+
test("V0.3.6-D2: syncMirror 成功时逐 type 记录 committed(applied_gen=gen)", () => {
|
|
257
260
|
const { dir, store, mirror, service } = setup();
|
|
258
261
|
try {
|
|
259
262
|
const mock = makeSyncMock();
|
|
@@ -261,7 +264,7 @@ test("V0.3.6-D2: syncMirror 成功时逐 type 记录 clean(applied_gen=gen)"
|
|
|
261
264
|
service.saveWithDedupe({ type: "project", title: "P", content: "x", importance: 3 });
|
|
262
265
|
const ts = store.getTypeStatus();
|
|
263
266
|
assert.ok(ts.project, "覆盖的 type 必须记录 type_status");
|
|
264
|
-
assert.equal(ts.project.
|
|
267
|
+
assert.equal(ts.project.status, "committed");
|
|
265
268
|
assert.equal(ts.project.last_error, null);
|
|
266
269
|
assert.equal(ts.project.applied_gen, store.getMirrorState().applied_generation,
|
|
267
270
|
"type_status.applied_gen 与全局 applied 一致");
|
|
@@ -271,15 +274,15 @@ test("V0.3.6-D2: syncMirror 成功时逐 type 记录 clean(applied_gen=gen)"
|
|
|
271
274
|
}
|
|
272
275
|
});
|
|
273
276
|
|
|
274
|
-
test("V0.3.6-D3: syncMirror 失败时逐 type 记录
|
|
277
|
+
test("V0.3.6-D3: syncMirror 失败时逐 type 记录 failed,recoverMirror 后追到最新 applied", () => {
|
|
275
278
|
const { dir, store, mirror, service } = setup();
|
|
276
279
|
try {
|
|
277
280
|
const mock = makeSyncMock();
|
|
278
281
|
mirror.sync = mock.sync;
|
|
279
|
-
// 第一次成功建立
|
|
282
|
+
// 第一次成功建立 committed type_status
|
|
280
283
|
mock.mode = "ok";
|
|
281
284
|
service.saveWithDedupe({ type: "project", title: "P", content: "x", importance: 3 });
|
|
282
|
-
assert.equal(store.getTypeStatus().project.
|
|
285
|
+
assert.equal(store.getTypeStatus().project.status, "committed");
|
|
283
286
|
|
|
284
287
|
// 模拟一次"债务轮次":incrementGeneration 但未成功 applied(崩溃在 type_status 层面)
|
|
285
288
|
store.incrementGeneration();
|
|
@@ -293,7 +296,7 @@ test("V0.3.6-D3: syncMirror 失败时逐 type 记录 dirty,recoverMirror 后
|
|
|
293
296
|
const ts = store.getTypeStatus();
|
|
294
297
|
assert.equal(ts.project.applied_gen, finalState.applied_generation,
|
|
295
298
|
"type_status 必须追到最新 applied(部分成功债务表达并收敛)");
|
|
296
|
-
assert.equal(ts.project.
|
|
299
|
+
assert.equal(ts.project.status, "committed");
|
|
297
300
|
} finally {
|
|
298
301
|
store.close();
|
|
299
302
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -454,10 +457,43 @@ test("V0.3.6-F1: 旧库(v0.3.5 5 列)打开自动 ALTER 加 3 列,不丢
|
|
|
454
457
|
// 新方法在迁移后的库上正常工作
|
|
455
458
|
const clean = store.markMirrorCleanForGeneration(0, "t");
|
|
456
459
|
assert.equal(clean.dirty, false, "迁移库上 fence clean 正常工作");
|
|
457
|
-
store.setTypeStatus("project", {
|
|
458
|
-
assert.equal(store.getTypeStatus().project.
|
|
460
|
+
store.setTypeStatus("project", { status: "failed", applied_gen: 0 });
|
|
461
|
+
assert.equal(store.getTypeStatus().project.status, "failed", "迁移库上 setTypeStatus 正常工作");
|
|
459
462
|
store.close();
|
|
460
463
|
} finally {
|
|
461
464
|
rmSync(dir, { recursive: true, force: true });
|
|
462
465
|
}
|
|
463
466
|
});
|
|
467
|
+
|
|
468
|
+
test("v0.3.9-D: mirror 逐 type 物理终态——兄弟 type 失败不误标已提交 type", () => {
|
|
469
|
+
const { dir, store, mirror, service } = setup();
|
|
470
|
+
try {
|
|
471
|
+
// 注入逐 type 故障:project 写成功、decision 抛错(模拟 EISDIR),其余 type 正常。
|
|
472
|
+
// 对应审计 D:project 文件已物理提交、decision 失败,状态必须逐 type 记录——
|
|
473
|
+
// 不能像旧逻辑那样整体批量标 failed。
|
|
474
|
+
const original = mirror.sync.bind(mirror);
|
|
475
|
+
mirror.sync = (memories) => {
|
|
476
|
+
const results = original(memories);
|
|
477
|
+
results.decision = { ok: false, error: "EISDIR: decision.md is a directory" };
|
|
478
|
+
return results;
|
|
479
|
+
};
|
|
480
|
+
// project 与 decision 都有真实记忆 → 触发逐 type 渲染
|
|
481
|
+
store.save({ id: "mem-project", type: "project", title: "已提交", content: "物理写入", importance: 3, tags: [] });
|
|
482
|
+
store.save({ id: "mem-decision", type: "decision", title: "写失败", content: "此 type 失败", importance: 3, tags: [] });
|
|
483
|
+
// 直接调 service 内部 syncMirror(通过一次写触发)
|
|
484
|
+
service.saveWithDedupe({ type: "project", title: "触发", content: "sync", importance: 3 });
|
|
485
|
+
|
|
486
|
+
const ts = store.getTypeStatus();
|
|
487
|
+
// project 物理提交 → 必须 committed,不能被 decision 失败拖成 failed
|
|
488
|
+
assert.equal(ts.project.status, "committed", "物理已提交的 type 必须标记 committed");
|
|
489
|
+
assert.equal(ts.decision.status, "failed", "失败的 type 必须标记 failed");
|
|
490
|
+
// 部分失败 = 未完全收敛 → dirty 必须持久
|
|
491
|
+
assert.equal(store.getMirrorState().dirty, true, "部分 type 失败必须持久 dirty");
|
|
492
|
+
// 镜像里 project 文件真实存在(物理终态已落地,不是整体失败)
|
|
493
|
+
const projectFile = mirror.filePath("project");
|
|
494
|
+
assert.ok(existsSync(projectFile), "project 镜像文件必须已物理写入");
|
|
495
|
+
} finally {
|
|
496
|
+
store.close();
|
|
497
|
+
rmSync(dir, { recursive: true, force: true });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { createStore } from "../src/store.js";
|
|
10
|
+
import { createMirror } from "../src/mirror.js";
|
|
11
|
+
import { createService } from "../src/service.js";
|
|
12
|
+
|
|
13
|
+
const execFileP = promisify(execFile);
|
|
14
|
+
const STORE_PATH = fileURLToPath(new URL("../src/store.js", import.meta.url));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* v0.3.8 回归测试(audit peer 6 项运行时阻断 → INSTALLATION_NOT_APPROVED)。
|
|
18
|
+
*
|
|
19
|
+
* 测试点:
|
|
20
|
+
* A. 崩溃窗口(真实语义):store.save 在业务事务内原子递增 desired generation,
|
|
21
|
+
* 崩溃在 COMMIT 后、syncMirror 前 → 重启 recoverMirror 仅凭
|
|
22
|
+
* generation > applied_generation 捕获并收敛(peer blocker 1)
|
|
23
|
+
* B. 业务写同事务原子性:INSERT 失败回滚时 generation 不得递增(peer blocker 1/3)
|
|
24
|
+
* C. 多进程并发原子递增:8 进程 × 10 次不丢增量(peer blocker 3)
|
|
25
|
+
* D. generation 上界/负数拒绝(peer blocker 6)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
function setup(dbPath) {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-peer-"));
|
|
30
|
+
const mirrorDir = join(dir, "mirror");
|
|
31
|
+
const store = createStore(dbPath ?? ":memory:");
|
|
32
|
+
const mirror = createMirror(mirrorDir);
|
|
33
|
+
const warns = [];
|
|
34
|
+
const logger = { warn: (...a) => warns.push(a.join(" ")) };
|
|
35
|
+
const service = createService({ store, mirror, config: {}, logger });
|
|
36
|
+
return { dir, mirrorDir, store, mirror, service, warns };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("peer-A: 崩溃窗口——save 后(generation 已递增)不 sync 直接关闭重开,recoverMirror 捕获并收敛", () => {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-peer-"));
|
|
41
|
+
const dbPath = join(dir, "mneme.db");
|
|
42
|
+
const mirrorDir = join(dir, "mirror");
|
|
43
|
+
try {
|
|
44
|
+
const store = createStore(dbPath);
|
|
45
|
+
const mirror = createMirror(mirrorDir);
|
|
46
|
+
const service = createService({ store, mirror, config: {}, logger: { warn() {} } });
|
|
47
|
+
try {
|
|
48
|
+
// 业务写:store.save 在事务内递增 desired generation,但不触发 mirror 渲染
|
|
49
|
+
store.save({ type: "project", title: "P", content: "x", importance: 3 });
|
|
50
|
+
const afterSave = store.getMirrorState();
|
|
51
|
+
assert.ok(afterSave.generation > afterSave.applied_generation,
|
|
52
|
+
"save 后 generation > applied(COMMIT 完成,mirror 未同步=崩溃窗口)");
|
|
53
|
+
} finally {
|
|
54
|
+
// 模拟崩溃:不调 syncMirror,直接关库
|
|
55
|
+
store.close();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 重启:重新打开同一 DB,recoverMirror 必须捕获债务并收敛
|
|
59
|
+
const store2 = createStore(dbPath);
|
|
60
|
+
const mirror2 = createMirror(mirrorDir);
|
|
61
|
+
const service2 = createService({ store: store2, mirror: mirror2, config: {}, logger: { warn() {} } });
|
|
62
|
+
try {
|
|
63
|
+
const result = service2.recoverMirror();
|
|
64
|
+
assert.equal(result.recovered, true, "崩溃窗口必须被 recover 捕获");
|
|
65
|
+
assert.equal(result.error, null);
|
|
66
|
+
const state = store2.getMirrorState();
|
|
67
|
+
assert.equal(state.dirty, false, "收敛后 dirty 清");
|
|
68
|
+
assert.ok(state.generation <= state.applied_generation, "收敛后无未应用债务");
|
|
69
|
+
} finally {
|
|
70
|
+
store2.close();
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("peer-B: 业务写同事务原子性——INSERT 失败回滚时 generation 不得递增", () => {
|
|
78
|
+
const { dir, store } = setup();
|
|
79
|
+
try {
|
|
80
|
+
store.save({ id: "dup", type: "project", title: "A", content: "x", importance: 3 });
|
|
81
|
+
const before = store.getMirrorState().generation;
|
|
82
|
+
assert.throws(
|
|
83
|
+
() => store.save({ id: "dup", type: "project", title: "B", content: "y", importance: 3 }),
|
|
84
|
+
/UNIQUE|constraint/i,
|
|
85
|
+
"重复主键 INSERT 必须抛错"
|
|
86
|
+
);
|
|
87
|
+
assert.equal(store.getMirrorState().generation, before,
|
|
88
|
+
"回滚后 generation 不递增(写与 desired generation 同事务,失败一起回滚)");
|
|
89
|
+
} finally {
|
|
90
|
+
store.close();
|
|
91
|
+
rmSync(dir, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("peer-C: 多进程并发原子递增——8 进程×10 次 incrementGeneration 不丢增量", async () => {
|
|
96
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-peer-"));
|
|
97
|
+
const dbPath = join(dir, "concurrent.db");
|
|
98
|
+
const N = 8;
|
|
99
|
+
const M = 10;
|
|
100
|
+
try {
|
|
101
|
+
// 子进程脚本:打开同一 DB 文件,原子递增 M 次
|
|
102
|
+
const worker = `
|
|
103
|
+
const { createStore } = require(process.argv[1]);
|
|
104
|
+
const store = createStore(process.argv[2]);
|
|
105
|
+
for (let i = 0; i < ${M}; i++) { store.incrementGeneration(); }
|
|
106
|
+
store.close();
|
|
107
|
+
`;
|
|
108
|
+
await Promise.all(
|
|
109
|
+
Array.from({ length: N }, () =>
|
|
110
|
+
execFileP(process.execPath, ["-e", worker, STORE_PATH, dbPath], { timeout: 30000 })
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
const store = createStore(dbPath);
|
|
114
|
+
try {
|
|
115
|
+
const state = store.getMirrorState();
|
|
116
|
+
assert.equal(state.generation, N * M,
|
|
117
|
+
`并发 ${N} 进程 × ${M} 次必须无丢失增量,得到 ${state.generation}`);
|
|
118
|
+
} finally {
|
|
119
|
+
store.close();
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("peer-D: generation 上界与负数拒绝", () => {
|
|
127
|
+
const { dir, store } = setup();
|
|
128
|
+
try {
|
|
129
|
+
// 负数拒绝
|
|
130
|
+
assert.throws(() => store.setMirrorState({ generation: -1 }), RangeError, "负数必须拒绝");
|
|
131
|
+
assert.throws(() => store.setMirrorState({ applied_generation: -5 }), RangeError, "负数 applied 必须拒绝");
|
|
132
|
+
// 超 MAX_SAFE_INTEGER 拒绝
|
|
133
|
+
assert.throws(
|
|
134
|
+
() => store.setMirrorState({ generation: Number.MAX_SAFE_INTEGER + 1 }),
|
|
135
|
+
RangeError,
|
|
136
|
+
"超出 MAX_SAFE_INTEGER 必须拒绝"
|
|
137
|
+
);
|
|
138
|
+
// 到上界后再 increment 必须抛错(读回不会 ERR_OUT_OF_RANGE)
|
|
139
|
+
store.setMirrorState({ generation: Number.MAX_SAFE_INTEGER });
|
|
140
|
+
assert.throws(() => store.incrementGeneration(), /exceeded MAX_SAFE_INTEGER/, "上界后再递增必须抛错");
|
|
141
|
+
// 正常递增仍工作
|
|
142
|
+
store.setMirrorState({ generation: 5 });
|
|
143
|
+
assert.equal(store.incrementGeneration().generation, 6, "正常递增不受影响");
|
|
144
|
+
} finally {
|
|
145
|
+
store.close();
|
|
146
|
+
rmSync(dir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("v0.3.9-A: compareAndUpdate 的 CAS UPDATE 与 generation 同事务——miss 不得递增", () => {
|
|
151
|
+
const { dir, store } = setup();
|
|
152
|
+
try {
|
|
153
|
+
const saved = store.save({ type: "project", title: "CAS 原子", content: "v0" });
|
|
154
|
+
const before = store.getById(saved.id);
|
|
155
|
+
const genBefore = store.getMirrorState().generation;
|
|
156
|
+
|
|
157
|
+
// 成功 CAS:业务写入 + generation 递增必须一次提交(同事务)
|
|
158
|
+
const updated = store.compareAndUpdate(saved.id, before.updated_at, { content: "v1" });
|
|
159
|
+
assert.ok(updated, "当前版本 CAS 必须成功");
|
|
160
|
+
const genAfterOk = store.getMirrorState().generation;
|
|
161
|
+
assert.equal(genAfterOk, genBefore + 1, "成功 CAS 必须恰好递增一次 generation");
|
|
162
|
+
|
|
163
|
+
// miss CAS:不写任何东西,generation 也不得递增
|
|
164
|
+
const stale = store.getById(saved.id).updated_at; // v1 的 token
|
|
165
|
+
store.compareAndUpdate(saved.id, before.updated_at, { content: "v2" }); // 用旧 token → miss
|
|
166
|
+
assert.equal(store.getById(saved.id).content, "v1", "miss 不得改数据");
|
|
167
|
+
assert.equal(store.getMirrorState().generation, genAfterOk,
|
|
168
|
+
"CAS miss 不得递增 generation(UPDATE 与 increment 必须同事务)");
|
|
169
|
+
} finally {
|
|
170
|
+
store.close();
|
|
171
|
+
rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("v0.3.9-F: generation 非整数必须拒绝(不得截断)", () => {
|
|
176
|
+
const { dir, store } = setup();
|
|
177
|
+
try {
|
|
178
|
+
// 审计 peer F:1.5 这类小数此前被 Math.trunc 截断 + SQLite CHECK 接受 → 静默脏值。
|
|
179
|
+
// fail-closed:JS 与 SQL 统一只接受整数。
|
|
180
|
+
assert.throws(() => store.setMirrorState({ generation: 1.5 }), RangeError, "小数 generation 必须拒绝");
|
|
181
|
+
assert.throws(() => store.setMirrorState({ applied_generation: -1.5 }), RangeError, "负数小数必须拒绝");
|
|
182
|
+
assert.throws(() => store.setMirrorState({ generation: Number.MAX_SAFE_INTEGER + 0.5 }), RangeError, "超界小数必须拒绝");
|
|
183
|
+
// 整数仍正常
|
|
184
|
+
const s = store.setMirrorState({ generation: 7 });
|
|
185
|
+
assert.equal(s.generation, 7, "整数 generation 正常");
|
|
186
|
+
} finally {
|
|
187
|
+
store.close();
|
|
188
|
+
rmSync(dir, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|