@modusensus/dsh-mneme 0.3.8 → 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 +1 -1
- package/lib/mirror.js +24 -12
- package/lib/service.js +69 -20
- package/lib/store.js +65 -26
- package/package.json +2 -2
- package/scripts/e2e-dsh.js +4 -2
- package/src/api.js +1 -1
- package/src/mirror.js +24 -12
- package/src/service.js +69 -20
- package/src/store.js +65 -26
- package/test/mirror-generation.test.js +34 -1
- package/test/peer-blockers.test.js +42 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/modusensus/dsh-mneme)
|
|
9
9
|
|
|
10
10
|
> 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
|
|
11
11
|
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
- **SQLite 主存储**:`~/.dsh/memory/memory.db`,`node:sqlite` 内置,零原生依赖
|
|
19
19
|
- **Markdown 镜像**:`preferences.md` / `projects.md` / `decisions.md` / `history.md` / `summary.md`,人类可读、可手工编辑(**人工修改优先**合并回库)
|
|
20
20
|
- **4+1 种记忆类型**:`preference`(偏好)/ `project`(项目)/ `decision`(决策)/ `history`(历史)/ `summary`(总览)
|
|
21
|
+
- **镜像同步状态机(v0.3.6+)**:mirror 与主库强一致,用 `generation`(期望轮次)/ `applied_generation`(已应用轮次)建模同步债务
|
|
22
|
+
- 业务写操作在**自身事务内原子递增** desired generation——崩溃在 COMMIT 后、渲染前,重启也能凭 durable 债务恢复,绝不静默跳过(v0.3.8)
|
|
23
|
+
- `generation` 用 SQLite 原子语句递增,多进程并发零丢失;带 `CHECK` 上界,负数/溢出拒绝
|
|
24
|
+
- 逐 type 记录 `committed / failed / pending` 回执,健康端点区分 `ok / degraded / unknown`
|
|
25
|
+
- 状态写失败不静默:同步失败落日志并留债务,重启自动收敛
|
|
21
26
|
|
|
22
27
|
### 模型工具(7 个)
|
|
23
28
|
|
|
@@ -106,6 +111,29 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
106
111
|
|
|
107
112
|
> 📖 详见 [实体结构化记忆设计](docs/ENTITIES.md) · [语义增强架构](docs/SEMANTIC.md) · [本地模型部署指南](docs/LOCAL_MODEL.md) · [从 v0.1 升级说明](docs/MIGRATION.md)
|
|
108
113
|
|
|
114
|
+
## 🆕 最近版本亮点
|
|
115
|
+
|
|
116
|
+
| 版本 | 亮点 |
|
|
117
|
+
|------|------|
|
|
118
|
+
| **v0.3.9** | 修复第三方审计 4 项 FAIL:CAS 同事务原子化、Mirror 降级回执透传、逐 type 物理终态收敛、Generation 强整数校验与并发初始化稳定化 |
|
|
119
|
+
| **v0.3.8** | audit peer 复验 6 项运行时阻断全部修复:desired generation 同事务原子递增(崩溃窗口不再静默跳过)、同步失败不静默、原子 generation 增量(多进程零丢失)、逐 type committed/failed/pending 回执、读取失败显式 unknown、generation 上界/负数 CHECK |
|
|
120
|
+
| **v0.3.7** | 启动竞态修复:人工编辑 md 镜像后重启向量重建失败(回灌移入 init 就绪后 + scheduleEmbed 就绪门) |
|
|
121
|
+
| **v0.3.6** | mirror 同步状态机:generation/applied_generation 债务建模、F-NEW-03 mirror 健康状态、持久 dirty + 启动 recoverMirror |
|
|
122
|
+
| **v0.3.0** | 记忆基因:实体/属性/关系三表 + 时间轴 + 实体搜索 + autoDream supersedes |
|
|
123
|
+
|
|
124
|
+
## 🗺️ 进化路线图
|
|
125
|
+
|
|
126
|
+
| 版本 | 状态 | 主题 | 说明 |
|
|
127
|
+
|------|------|------|------|
|
|
128
|
+
| v0.2.x | ✅ 完成 | 语义增强 + 反思更新 | 本地 Embedding/Rerank/聚类、`failure_memories` 失败追踪 |
|
|
129
|
+
| v0.3.0 | ✅ 完成 | 记忆基因 | entities/attrs/relations 三表 + 时间轴 + 实体搜索 |
|
|
130
|
+
| v0.3.6–0.3.8 | ✅ 完成 | 镜像一致性 + 审计加固 | generation 同步状态机、audit peer 6 项运行时阻断修复、450 测试全绿 |
|
|
131
|
+
| v0.3.9 | ✅ 完成 | 审计加固 A/B/D/F | compareAndUpdate 同事务原子性、degraded 回执、逐 type 物理终态、整数 fail-closed、并发初始化稳定 |
|
|
132
|
+
| **v0.4.0** | ⏳ 规划 | 反思性成长 | 纠错双向回流(改记忆同时反思"为什么记错/召回错")+ 规则演进(从 failure 提炼规律注入系统提示)+ 自适应参数 |
|
|
133
|
+
| **v0.5.0+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + 跨 workspace 记忆共享(等 DSH 支持) |
|
|
134
|
+
|
|
135
|
+
> 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为 v0.4.0 铺好路。
|
|
136
|
+
|
|
109
137
|
## 📦 安装
|
|
110
138
|
|
|
111
139
|
### 前置条件
|
|
@@ -243,7 +271,7 @@ src/
|
|
|
243
271
|
lib/
|
|
244
272
|
├── client.js # Web 面板(手写 ModuleLoader bundle)
|
|
245
273
|
└── *.js # src 的同步分发产物
|
|
246
|
-
test/ #
|
|
274
|
+
test/ # 450 个 node:test 测试(含审计与三轴线压测不变量)
|
|
247
275
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
|
|
248
276
|
```
|
|
249
277
|
|
|
@@ -252,7 +280,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
252
280
|
```bash
|
|
253
281
|
cd dsh-mneme
|
|
254
282
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
255
|
-
npm test # 运行
|
|
283
|
+
npm test # 运行 450 个测试
|
|
256
284
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
257
285
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
258
286
|
```
|
package/lib/api.js
CHANGED
package/lib/mirror.js
CHANGED
|
@@ -128,21 +128,33 @@ export function createMirror(dir) {
|
|
|
128
128
|
for (const m of memories) {
|
|
129
129
|
(byType[m.type] ??= []).push(m);
|
|
130
130
|
}
|
|
131
|
+
// Per-type physical outcomes (audit peer D): a failed write for one type
|
|
132
|
+
// must not abort the whole render. Each type is written (or pruned) in its
|
|
133
|
+
// own try/catch and the result reported so the caller can persist per-type
|
|
134
|
+
// committed/failed receipts — a file that was already written is a real
|
|
135
|
+
// physical commit even when a sibling type errors.
|
|
136
|
+
const results = {};
|
|
131
137
|
for (const type of Object.keys(TYPE_FILE)) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
try {
|
|
139
|
+
const file = filePath(type);
|
|
140
|
+
const items = (byType[type] ?? [])
|
|
141
|
+
.slice()
|
|
142
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
143
|
+
if (items.length === 0) {
|
|
144
|
+
// no memories of this type: drop any stale mirror file so deleted
|
|
145
|
+
// memories do not "resurrect" via readHumanEdits
|
|
146
|
+
rmSync(file, { force: true });
|
|
147
|
+
} else {
|
|
148
|
+
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
149
|
+
const body = items.map(renderMemory).join("\n");
|
|
150
|
+
writeFileSync(file, header + body, "utf8");
|
|
151
|
+
}
|
|
152
|
+
results[type] = { ok: true };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
results[type] = { ok: false, error: error?.message ?? String(error) };
|
|
141
155
|
}
|
|
142
|
-
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
143
|
-
const body = items.map(renderMemory).join("\n");
|
|
144
|
-
writeFileSync(file, header + body, "utf8");
|
|
145
156
|
}
|
|
157
|
+
return results;
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
return { filePath, sync, readHumanEdits };
|
package/lib/service.js
CHANGED
|
@@ -603,26 +603,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
603
603
|
}
|
|
604
604
|
}
|
|
605
605
|
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
606
|
+
// Per-type physical outcome (audit peer D): mirror.sync writes each type
|
|
607
|
+
// file independently and reports per-type success/failure. A type whose
|
|
608
|
+
// file was physically committed must be marked committed even when a
|
|
609
|
+
// sibling type errors — the old code batch-failed every type on any error,
|
|
610
|
+
// leaving committed files mislabeled as failed and masking partial state.
|
|
611
|
+
// Absent entries (a type with no memories) count as success: sync prunes
|
|
612
|
+
// the stale file, which is itself a completed physical state.
|
|
613
|
+
let allOk = true;
|
|
614
|
+
const results = mirror.sync(reconcileHumanEdits(list)) ?? {};
|
|
615
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
616
|
+
const r = results[type];
|
|
617
|
+
const ok = !r || r.ok === true;
|
|
618
|
+
if (!ok) allOk = false;
|
|
619
|
+
try {
|
|
620
|
+
if (ok) {
|
|
621
|
+
store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
|
|
622
|
+
} else {
|
|
623
|
+
store.setTypeStatus(type, { status: "failed", last_error: r.error ?? "mirror sync failed" });
|
|
624
|
+
}
|
|
625
|
+
} catch (stateError) {
|
|
626
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
|
|
627
|
+
}
|
|
616
628
|
}
|
|
617
|
-
|
|
618
|
-
|
|
629
|
+
|
|
630
|
+
// 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
|
|
631
|
+
// 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
|
|
632
|
+
if (allOk) {
|
|
619
633
|
try {
|
|
620
|
-
store.
|
|
634
|
+
store.markMirrorCleanForGeneration(gen, now);
|
|
621
635
|
} catch (stateError) {
|
|
622
|
-
logger?.warn?.(
|
|
636
|
+
logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
|
|
637
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
623
638
|
}
|
|
639
|
+
return { success: true };
|
|
624
640
|
}
|
|
625
|
-
|
|
641
|
+
|
|
642
|
+
// 部分 type 失败:持久 dirty(债务绑定到新轮次),下次 recover 只补未收敛
|
|
643
|
+
// 的 type。committed 的 type 已应用本轮 gen,不因兄弟失败被回滚。
|
|
644
|
+
const failedTypes = Object.entries(results)
|
|
645
|
+
.filter(([, r]) => r && r.ok === false)
|
|
646
|
+
.map(([t]) => t);
|
|
647
|
+
try {
|
|
648
|
+
store.markMirrorDirty(`mirror sync failed for: ${failedTypes.join(", ")}`, now);
|
|
649
|
+
} catch (stateError) {
|
|
650
|
+
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
651
|
+
}
|
|
652
|
+
return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
|
|
626
653
|
} catch (error) {
|
|
627
654
|
const errMsg = error?.message ?? String(error);
|
|
628
655
|
logger?.warn?.("syncMirror failed:", error);
|
|
@@ -646,14 +673,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
646
673
|
}
|
|
647
674
|
|
|
648
675
|
// afterSync: run syncMirror and surface a failure to the operator instead of
|
|
649
|
-
// swallowing it (peer blocker 2). The mirror debt has already
|
|
650
|
-
// by markMirrorDirty inside syncMirror, so a restart recovers —
|
|
651
|
-
// calling write path must not report clean while the mirror is
|
|
676
|
+
// swallowing it (peer blocker 2 + audit peer B). The mirror debt has already
|
|
677
|
+
// been persisted by markMirrorDirty inside syncMirror, so a restart recovers —
|
|
678
|
+
// but the calling write path must not report clean while the mirror is
|
|
679
|
+
// known-stale. Returns the sync result so the caller can attach an explicit
|
|
680
|
+
// degraded/pending receipt to its return value instead of faking success.
|
|
652
681
|
function afterSync(label) {
|
|
653
682
|
const r = syncMirror();
|
|
654
683
|
if (!r?.success && !r?.deferred) {
|
|
655
684
|
logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
|
|
656
685
|
}
|
|
686
|
+
return r;
|
|
657
687
|
}
|
|
658
688
|
|
|
659
689
|
// recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
|
|
@@ -826,9 +856,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
826
856
|
memory_id: id
|
|
827
857
|
});
|
|
828
858
|
}
|
|
829
|
-
afterSync("write");
|
|
859
|
+
const sync = afterSync("write");
|
|
830
860
|
notifyWrite();
|
|
831
861
|
scheduleEmbed(updated);
|
|
862
|
+
// Audit peer B: when the mirror sync failed, the store write landed but
|
|
863
|
+
// the mirror did not converge — return an explicit degraded receipt rather
|
|
864
|
+
// than a plain success. Non-enumerable so existing deepEqual assertions on
|
|
865
|
+
// the memory shape keep passing.
|
|
866
|
+
if (!sync?.success && !sync?.deferred) {
|
|
867
|
+
Object.defineProperty(updated, "_mirror", {
|
|
868
|
+
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
869
|
+
enumerable: false,
|
|
870
|
+
configurable: true
|
|
871
|
+
});
|
|
872
|
+
}
|
|
832
873
|
return updated;
|
|
833
874
|
},
|
|
834
875
|
// Compare-and-set update: applies the patch only when the row still carries
|
|
@@ -855,9 +896,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
855
896
|
memory_id: id
|
|
856
897
|
});
|
|
857
898
|
}
|
|
858
|
-
afterSync("write");
|
|
899
|
+
const sync = afterSync("write");
|
|
859
900
|
notifyWrite();
|
|
860
901
|
scheduleEmbed(updated);
|
|
902
|
+
// Audit peer B: mirror sync failure on a CAS write must surface too.
|
|
903
|
+
if (!sync?.success && !sync?.deferred) {
|
|
904
|
+
Object.defineProperty(updated, "_mirror", {
|
|
905
|
+
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
906
|
+
enumerable: false,
|
|
907
|
+
configurable: true
|
|
908
|
+
});
|
|
909
|
+
}
|
|
861
910
|
return updated;
|
|
862
911
|
},
|
|
863
912
|
setForget: (id, f) => {
|
package/lib/store.js
CHANGED
|
@@ -180,8 +180,8 @@ CREATE TABLE IF NOT EXISTS mirror_state (
|
|
|
180
180
|
last_error TEXT, -- 最近失败原因
|
|
181
181
|
last_attempt TEXT, -- 最近尝试时间(ISO)
|
|
182
182
|
success_at TEXT, -- 最近成功时间(ISO)
|
|
183
|
-
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991), -- 期望的同步轮次(desired)
|
|
184
|
-
applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991), -- 已成功应用的轮次
|
|
183
|
+
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
|
|
184
|
+
applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
|
|
185
185
|
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
186
186
|
);
|
|
187
187
|
`;
|
|
@@ -391,11 +391,14 @@ function parseJsonArray(raw) {
|
|
|
391
391
|
|
|
392
392
|
export function createStore(path) {
|
|
393
393
|
const db = new DatabaseSync(path);
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
394
|
+
// Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
|
|
395
|
+
// init). Switching a fresh DB to WAL takes an exclusive lock; when several
|
|
396
|
+
// processes open the same path simultaneously, that lock can fail with
|
|
397
|
+
// SQLITE_BUSY before the timeout is armed. With the timeout installed first,
|
|
398
|
+
// the WAL transition (and every later write) blocks and retries instead of
|
|
399
|
+
// failing outright, so concurrent init converges to a stable 447/447.
|
|
398
400
|
db.exec("PRAGMA busy_timeout = 5000;");
|
|
401
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
399
402
|
db.exec(SCHEMA);
|
|
400
403
|
|
|
401
404
|
// Schema migrations for legacy databases (idempotent).
|
|
@@ -426,6 +429,24 @@ export function createStore(path) {
|
|
|
426
429
|
db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
|
|
427
430
|
}
|
|
428
431
|
|
|
432
|
+
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
433
|
+
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
434
|
+
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
|
|
435
|
+
// round — so surface it as a hard error on open instead of silently reading it
|
|
436
|
+
// as a coherent generation. Fail-closed: the operator must repair or reset the
|
|
437
|
+
// state row rather than continue with a lie.
|
|
438
|
+
for (const col of ["generation", "applied_generation"]) {
|
|
439
|
+
const bad = db.prepare(
|
|
440
|
+
`SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
|
|
441
|
+
).get();
|
|
442
|
+
if (bad) {
|
|
443
|
+
throw new RangeError(
|
|
444
|
+
`mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
|
|
445
|
+
`repair or reset the row before opening this database`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
429
450
|
// Per-instance monotonic timestamp guard: consecutive writes within the same
|
|
430
451
|
// millisecond must still produce strictly increasing timestamps (test asserts
|
|
431
452
|
// updated_at != created_at). State lives in the store closure, not module scope.
|
|
@@ -552,24 +573,35 @@ export function createStore(path) {
|
|
|
552
573
|
const embedding = patch.embedding !== undefined
|
|
553
574
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
554
575
|
: existing.embedding ?? null;
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
576
|
+
// The CAS UPDATE and the desired-generation bump must commit together (audit
|
|
577
|
+
// peer A): if the UPDATE autocommits first and the process dies before the
|
|
578
|
+
// increment, the store is mutated while generation == applied_generation and
|
|
579
|
+
// dirty == false — recoverMirror sees no debt and the mirror stays stale.
|
|
580
|
+
// Wrapping both in one transaction means a CAS miss rolls back cleanly too
|
|
581
|
+
// (no write, no generation bump).
|
|
582
|
+
let applied = false;
|
|
583
|
+
runAtomically(() => {
|
|
584
|
+
const result = db.prepare(
|
|
585
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
|
|
586
|
+
WHERE id=? AND updated_at=?`
|
|
587
|
+
).run(
|
|
588
|
+
type,
|
|
589
|
+
patch.title ?? existing.title,
|
|
590
|
+
patch.content ?? existing.content,
|
|
591
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
592
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
593
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
594
|
+
embedding,
|
|
595
|
+
now,
|
|
596
|
+
id,
|
|
597
|
+
expectedUpdatedAt
|
|
598
|
+
);
|
|
599
|
+
if (result.changes === 0) return; // CAS miss: a concurrent write won
|
|
600
|
+
// Only bump desired generation on a successful CAS — a miss writes nothing.
|
|
601
|
+
incrementGeneration();
|
|
602
|
+
applied = true;
|
|
603
|
+
});
|
|
604
|
+
if (!applied) return undefined;
|
|
573
605
|
return getById(id);
|
|
574
606
|
}
|
|
575
607
|
|
|
@@ -1223,8 +1255,15 @@ export function createStore(path) {
|
|
|
1223
1255
|
if (key === "dirty") {
|
|
1224
1256
|
value = value ? 1 : 0;
|
|
1225
1257
|
} else if (key === "generation" || key === "applied_generation") {
|
|
1226
|
-
|
|
1227
|
-
|
|
1258
|
+
// Fail-closed integer enforcement (audit peer F): never truncate. A
|
|
1259
|
+
// fractional value like 1.5 previously passed the JS gate via
|
|
1260
|
+
// Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
|
|
1261
|
+
// dirty legacy row could carry a non-integer generation that reads as a
|
|
1262
|
+
// coherent applied round. Reject non-integers outright — the caller must
|
|
1263
|
+
// pass a whole number, and a stale dirty value stays visible instead of
|
|
1264
|
+
// being "repaired" into a misleading clean integer.
|
|
1265
|
+
value = Number(value);
|
|
1266
|
+
if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
|
1228
1267
|
throw new RangeError(`mirror_state.${key} out of range: ${value}`);
|
|
1229
1268
|
}
|
|
1230
1269
|
} else if (key === "type_status" && value != null && typeof value !== "string") {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
|
-
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors,
|
|
4
|
-
"version": "0.3.
|
|
3
|
+
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
+
"version": "0.3.9",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
package/scripts/e2e-dsh.js
CHANGED
|
@@ -104,9 +104,11 @@ console.log(`记忆目录:${memDir}\n`);
|
|
|
104
104
|
// 1. 装载检查
|
|
105
105
|
console.log("【1】插件装载");
|
|
106
106
|
const checks = [];
|
|
107
|
-
checks.push(["注册
|
|
107
|
+
checks.push(["注册 7 个模型工具", registeredTools.length === 7]);
|
|
108
108
|
checks.push(["注册 2 个注入上下文", injectContexts.length === 2 && injectContexts[0].name === "memory"]);
|
|
109
|
-
|
|
109
|
+
// 契约是 9 条 exact 路由;prefix fallback(/api/dsh-mneme → 404)是兜底,
|
|
110
|
+
// 不计入路由数。
|
|
111
|
+
checks.push(["注册 9 条 API 路由", apiRoutes.filter((r) => r.kind === "exact").length === 9]);
|
|
110
112
|
for (const [label, ok] of checks) console.log(` ${ok ? "✅" : "❌"} ${label}`);
|
|
111
113
|
if (!checks.every(([, ok]) => ok)) { console.log("\n装载检查失败,中止。"); process.exit(1); }
|
|
112
114
|
console.log(` 工具:${registeredTools.map((t) => t.name).join(", ")}\n`);
|
package/src/api.js
CHANGED
package/src/mirror.js
CHANGED
|
@@ -128,21 +128,33 @@ export function createMirror(dir) {
|
|
|
128
128
|
for (const m of memories) {
|
|
129
129
|
(byType[m.type] ??= []).push(m);
|
|
130
130
|
}
|
|
131
|
+
// Per-type physical outcomes (audit peer D): a failed write for one type
|
|
132
|
+
// must not abort the whole render. Each type is written (or pruned) in its
|
|
133
|
+
// own try/catch and the result reported so the caller can persist per-type
|
|
134
|
+
// committed/failed receipts — a file that was already written is a real
|
|
135
|
+
// physical commit even when a sibling type errors.
|
|
136
|
+
const results = {};
|
|
131
137
|
for (const type of Object.keys(TYPE_FILE)) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
try {
|
|
139
|
+
const file = filePath(type);
|
|
140
|
+
const items = (byType[type] ?? [])
|
|
141
|
+
.slice()
|
|
142
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
143
|
+
if (items.length === 0) {
|
|
144
|
+
// no memories of this type: drop any stale mirror file so deleted
|
|
145
|
+
// memories do not "resurrect" via readHumanEdits
|
|
146
|
+
rmSync(file, { force: true });
|
|
147
|
+
} else {
|
|
148
|
+
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
149
|
+
const body = items.map(renderMemory).join("\n");
|
|
150
|
+
writeFileSync(file, header + body, "utf8");
|
|
151
|
+
}
|
|
152
|
+
results[type] = { ok: true };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
results[type] = { ok: false, error: error?.message ?? String(error) };
|
|
141
155
|
}
|
|
142
|
-
const header = `# ${TYPE_FILE[type]} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`;
|
|
143
|
-
const body = items.map(renderMemory).join("\n");
|
|
144
|
-
writeFileSync(file, header + body, "utf8");
|
|
145
156
|
}
|
|
157
|
+
return results;
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
return { filePath, sync, readHumanEdits };
|
package/src/service.js
CHANGED
|
@@ -603,26 +603,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
603
603
|
}
|
|
604
604
|
}
|
|
605
605
|
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
606
|
+
// Per-type physical outcome (audit peer D): mirror.sync writes each type
|
|
607
|
+
// file independently and reports per-type success/failure. A type whose
|
|
608
|
+
// file was physically committed must be marked committed even when a
|
|
609
|
+
// sibling type errors — the old code batch-failed every type on any error,
|
|
610
|
+
// leaving committed files mislabeled as failed and masking partial state.
|
|
611
|
+
// Absent entries (a type with no memories) count as success: sync prunes
|
|
612
|
+
// the stale file, which is itself a completed physical state.
|
|
613
|
+
let allOk = true;
|
|
614
|
+
const results = mirror.sync(reconcileHumanEdits(list)) ?? {};
|
|
615
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
616
|
+
const r = results[type];
|
|
617
|
+
const ok = !r || r.ok === true;
|
|
618
|
+
if (!ok) allOk = false;
|
|
619
|
+
try {
|
|
620
|
+
if (ok) {
|
|
621
|
+
store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
|
|
622
|
+
} else {
|
|
623
|
+
store.setTypeStatus(type, { status: "failed", last_error: r.error ?? "mirror sync failed" });
|
|
624
|
+
}
|
|
625
|
+
} catch (stateError) {
|
|
626
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
|
|
627
|
+
}
|
|
616
628
|
}
|
|
617
|
-
|
|
618
|
-
|
|
629
|
+
|
|
630
|
+
// 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
|
|
631
|
+
// 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
|
|
632
|
+
if (allOk) {
|
|
619
633
|
try {
|
|
620
|
-
store.
|
|
634
|
+
store.markMirrorCleanForGeneration(gen, now);
|
|
621
635
|
} catch (stateError) {
|
|
622
|
-
logger?.warn?.(
|
|
636
|
+
logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
|
|
637
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
623
638
|
}
|
|
639
|
+
return { success: true };
|
|
624
640
|
}
|
|
625
|
-
|
|
641
|
+
|
|
642
|
+
// 部分 type 失败:持久 dirty(债务绑定到新轮次),下次 recover 只补未收敛
|
|
643
|
+
// 的 type。committed 的 type 已应用本轮 gen,不因兄弟失败被回滚。
|
|
644
|
+
const failedTypes = Object.entries(results)
|
|
645
|
+
.filter(([, r]) => r && r.ok === false)
|
|
646
|
+
.map(([t]) => t);
|
|
647
|
+
try {
|
|
648
|
+
store.markMirrorDirty(`mirror sync failed for: ${failedTypes.join(", ")}`, now);
|
|
649
|
+
} catch (stateError) {
|
|
650
|
+
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
651
|
+
}
|
|
652
|
+
return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
|
|
626
653
|
} catch (error) {
|
|
627
654
|
const errMsg = error?.message ?? String(error);
|
|
628
655
|
logger?.warn?.("syncMirror failed:", error);
|
|
@@ -646,14 +673,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
646
673
|
}
|
|
647
674
|
|
|
648
675
|
// afterSync: run syncMirror and surface a failure to the operator instead of
|
|
649
|
-
// swallowing it (peer blocker 2). The mirror debt has already
|
|
650
|
-
// by markMirrorDirty inside syncMirror, so a restart recovers —
|
|
651
|
-
// calling write path must not report clean while the mirror is
|
|
676
|
+
// swallowing it (peer blocker 2 + audit peer B). The mirror debt has already
|
|
677
|
+
// been persisted by markMirrorDirty inside syncMirror, so a restart recovers —
|
|
678
|
+
// but the calling write path must not report clean while the mirror is
|
|
679
|
+
// known-stale. Returns the sync result so the caller can attach an explicit
|
|
680
|
+
// degraded/pending receipt to its return value instead of faking success.
|
|
652
681
|
function afterSync(label) {
|
|
653
682
|
const r = syncMirror();
|
|
654
683
|
if (!r?.success && !r?.deferred) {
|
|
655
684
|
logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
|
|
656
685
|
}
|
|
686
|
+
return r;
|
|
657
687
|
}
|
|
658
688
|
|
|
659
689
|
// recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
|
|
@@ -826,9 +856,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
826
856
|
memory_id: id
|
|
827
857
|
});
|
|
828
858
|
}
|
|
829
|
-
afterSync("write");
|
|
859
|
+
const sync = afterSync("write");
|
|
830
860
|
notifyWrite();
|
|
831
861
|
scheduleEmbed(updated);
|
|
862
|
+
// Audit peer B: when the mirror sync failed, the store write landed but
|
|
863
|
+
// the mirror did not converge — return an explicit degraded receipt rather
|
|
864
|
+
// than a plain success. Non-enumerable so existing deepEqual assertions on
|
|
865
|
+
// the memory shape keep passing.
|
|
866
|
+
if (!sync?.success && !sync?.deferred) {
|
|
867
|
+
Object.defineProperty(updated, "_mirror", {
|
|
868
|
+
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
869
|
+
enumerable: false,
|
|
870
|
+
configurable: true
|
|
871
|
+
});
|
|
872
|
+
}
|
|
832
873
|
return updated;
|
|
833
874
|
},
|
|
834
875
|
// Compare-and-set update: applies the patch only when the row still carries
|
|
@@ -855,9 +896,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
855
896
|
memory_id: id
|
|
856
897
|
});
|
|
857
898
|
}
|
|
858
|
-
afterSync("write");
|
|
899
|
+
const sync = afterSync("write");
|
|
859
900
|
notifyWrite();
|
|
860
901
|
scheduleEmbed(updated);
|
|
902
|
+
// Audit peer B: mirror sync failure on a CAS write must surface too.
|
|
903
|
+
if (!sync?.success && !sync?.deferred) {
|
|
904
|
+
Object.defineProperty(updated, "_mirror", {
|
|
905
|
+
value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
|
|
906
|
+
enumerable: false,
|
|
907
|
+
configurable: true
|
|
908
|
+
});
|
|
909
|
+
}
|
|
861
910
|
return updated;
|
|
862
911
|
},
|
|
863
912
|
setForget: (id, f) => {
|
package/src/store.js
CHANGED
|
@@ -180,8 +180,8 @@ CREATE TABLE IF NOT EXISTS mirror_state (
|
|
|
180
180
|
last_error TEXT, -- 最近失败原因
|
|
181
181
|
last_attempt TEXT, -- 最近尝试时间(ISO)
|
|
182
182
|
success_at TEXT, -- 最近成功时间(ISO)
|
|
183
|
-
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991), -- 期望的同步轮次(desired)
|
|
184
|
-
applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991), -- 已成功应用的轮次
|
|
183
|
+
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0 AND generation <= 9007199254740991 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
|
|
184
|
+
applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
|
|
185
185
|
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
186
186
|
);
|
|
187
187
|
`;
|
|
@@ -391,11 +391,14 @@ function parseJsonArray(raw) {
|
|
|
391
391
|
|
|
392
392
|
export function createStore(path) {
|
|
393
393
|
const db = new DatabaseSync(path);
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
394
|
+
// Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
|
|
395
|
+
// init). Switching a fresh DB to WAL takes an exclusive lock; when several
|
|
396
|
+
// processes open the same path simultaneously, that lock can fail with
|
|
397
|
+
// SQLITE_BUSY before the timeout is armed. With the timeout installed first,
|
|
398
|
+
// the WAL transition (and every later write) blocks and retries instead of
|
|
399
|
+
// failing outright, so concurrent init converges to a stable 447/447.
|
|
398
400
|
db.exec("PRAGMA busy_timeout = 5000;");
|
|
401
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
399
402
|
db.exec(SCHEMA);
|
|
400
403
|
|
|
401
404
|
// Schema migrations for legacy databases (idempotent).
|
|
@@ -426,6 +429,24 @@ export function createStore(path) {
|
|
|
426
429
|
db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
|
|
427
430
|
}
|
|
428
431
|
|
|
432
|
+
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
433
|
+
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
434
|
+
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
|
|
435
|
+
// round — so surface it as a hard error on open instead of silently reading it
|
|
436
|
+
// as a coherent generation. Fail-closed: the operator must repair or reset the
|
|
437
|
+
// state row rather than continue with a lie.
|
|
438
|
+
for (const col of ["generation", "applied_generation"]) {
|
|
439
|
+
const bad = db.prepare(
|
|
440
|
+
`SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
|
|
441
|
+
).get();
|
|
442
|
+
if (bad) {
|
|
443
|
+
throw new RangeError(
|
|
444
|
+
`mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
|
|
445
|
+
`repair or reset the row before opening this database`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
429
450
|
// Per-instance monotonic timestamp guard: consecutive writes within the same
|
|
430
451
|
// millisecond must still produce strictly increasing timestamps (test asserts
|
|
431
452
|
// updated_at != created_at). State lives in the store closure, not module scope.
|
|
@@ -552,24 +573,35 @@ export function createStore(path) {
|
|
|
552
573
|
const embedding = patch.embedding !== undefined
|
|
553
574
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
554
575
|
: existing.embedding ?? null;
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
576
|
+
// The CAS UPDATE and the desired-generation bump must commit together (audit
|
|
577
|
+
// peer A): if the UPDATE autocommits first and the process dies before the
|
|
578
|
+
// increment, the store is mutated while generation == applied_generation and
|
|
579
|
+
// dirty == false — recoverMirror sees no debt and the mirror stays stale.
|
|
580
|
+
// Wrapping both in one transaction means a CAS miss rolls back cleanly too
|
|
581
|
+
// (no write, no generation bump).
|
|
582
|
+
let applied = false;
|
|
583
|
+
runAtomically(() => {
|
|
584
|
+
const result = db.prepare(
|
|
585
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
|
|
586
|
+
WHERE id=? AND updated_at=?`
|
|
587
|
+
).run(
|
|
588
|
+
type,
|
|
589
|
+
patch.title ?? existing.title,
|
|
590
|
+
patch.content ?? existing.content,
|
|
591
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
592
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
593
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
594
|
+
embedding,
|
|
595
|
+
now,
|
|
596
|
+
id,
|
|
597
|
+
expectedUpdatedAt
|
|
598
|
+
);
|
|
599
|
+
if (result.changes === 0) return; // CAS miss: a concurrent write won
|
|
600
|
+
// Only bump desired generation on a successful CAS — a miss writes nothing.
|
|
601
|
+
incrementGeneration();
|
|
602
|
+
applied = true;
|
|
603
|
+
});
|
|
604
|
+
if (!applied) return undefined;
|
|
573
605
|
return getById(id);
|
|
574
606
|
}
|
|
575
607
|
|
|
@@ -1223,8 +1255,15 @@ export function createStore(path) {
|
|
|
1223
1255
|
if (key === "dirty") {
|
|
1224
1256
|
value = value ? 1 : 0;
|
|
1225
1257
|
} else if (key === "generation" || key === "applied_generation") {
|
|
1226
|
-
|
|
1227
|
-
|
|
1258
|
+
// Fail-closed integer enforcement (audit peer F): never truncate. A
|
|
1259
|
+
// fractional value like 1.5 previously passed the JS gate via
|
|
1260
|
+
// Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
|
|
1261
|
+
// dirty legacy row could carry a non-integer generation that reads as a
|
|
1262
|
+
// coherent applied round. Reject non-integers outright — the caller must
|
|
1263
|
+
// pass a whole number, and a stale dirty value stays visible instead of
|
|
1264
|
+
// being "repaired" into a misleading clean integer.
|
|
1265
|
+
value = Number(value);
|
|
1266
|
+
if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
|
1228
1267
|
throw new RangeError(`mirror_state.${key} out of range: ${value}`);
|
|
1229
1268
|
}
|
|
1230
1269
|
} else if (key === "type_status" && value != null && typeof value !== "string") {
|
|
@@ -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";
|
|
@@ -464,3 +464,36 @@ test("V0.3.6-F1: 旧库(v0.3.5 5 列)打开自动 ALTER 加 3 列,不丢
|
|
|
464
464
|
rmSync(dir, { recursive: true, force: true });
|
|
465
465
|
}
|
|
466
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
|
+
});
|
|
@@ -146,3 +146,45 @@ test("peer-D: generation 上界与负数拒绝", () => {
|
|
|
146
146
|
rmSync(dir, { recursive: true, force: true });
|
|
147
147
|
}
|
|
148
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
|
+
});
|