@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
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
|
@@ -289,6 +289,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
289
289
|
sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
|
|
290
290
|
return;
|
|
291
291
|
}
|
|
292
|
+
// Real read failure surfaces as dirty === null (peer blocker 5): report
|
|
293
|
+
// unknown explicitly instead of collapsing into a false "ok"/"degraded".
|
|
294
|
+
if (state.dirty === null) {
|
|
295
|
+
sendJson(res, 200, {
|
|
296
|
+
mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
292
300
|
// Sanitized: boolean dirty + coarse status only; error string is mapped to
|
|
293
301
|
// a bounded code, never echoed verbatim.
|
|
294
302
|
let code = null;
|
|
@@ -346,7 +354,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
346
354
|
});
|
|
347
355
|
|
|
348
356
|
return {
|
|
349
|
-
routes:
|
|
357
|
+
routes: 9,
|
|
350
358
|
dispose: () => {
|
|
351
359
|
for (const dispose of disposers) dispose();
|
|
352
360
|
}
|
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
|
@@ -384,10 +384,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
384
384
|
throw error;
|
|
385
385
|
} finally {
|
|
386
386
|
txDepth--;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
387
|
+
// Sync failures are surfaced, not swallowed (peer blocker 2): the mirror
|
|
388
|
+
// debt was already recorded by markMirrorDirty inside syncMirror, so a
|
|
389
|
+
// restart recovers — but the operator must see it now, not after restart.
|
|
390
|
+
const syncResult = syncMirror();
|
|
391
|
+
if (!syncResult?.success && !syncResult?.deferred) {
|
|
392
|
+
logger?.warn?.("mirror sync failed after transaction:", syncResult?.error);
|
|
391
393
|
}
|
|
392
394
|
notifyWrite();
|
|
393
395
|
}
|
|
@@ -408,7 +410,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
408
410
|
tags: memory.tags ?? existing.tags,
|
|
409
411
|
title: memory.title ?? existing.title
|
|
410
412
|
});
|
|
411
|
-
|
|
413
|
+
afterSync("write");
|
|
412
414
|
notifyWrite();
|
|
413
415
|
scheduleEmbed(merged);
|
|
414
416
|
return { action: "merged", memory: merged };
|
|
@@ -421,7 +423,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
421
423
|
importance: memory.importance ?? 3,
|
|
422
424
|
source: memory.source ?? "manual"
|
|
423
425
|
});
|
|
424
|
-
|
|
426
|
+
afterSync("write");
|
|
425
427
|
notifyWrite();
|
|
426
428
|
scheduleEmbed(created);
|
|
427
429
|
scheduleEntityExtraction(created);
|
|
@@ -481,7 +483,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
481
483
|
}
|
|
482
484
|
}
|
|
483
485
|
if (applied) {
|
|
484
|
-
|
|
486
|
+
afterSync("write");
|
|
485
487
|
notifyWrite();
|
|
486
488
|
}
|
|
487
489
|
return applied;
|
|
@@ -577,16 +579,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
577
579
|
// - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
|
|
578
580
|
// - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
|
|
579
581
|
function syncMirror() {
|
|
580
|
-
if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
|
|
582
|
+
if (txDepth > 0 || !mirror) return { success: true, deferred: true }; // deferred to the transaction's commit
|
|
581
583
|
const now = new Date().toISOString();
|
|
582
584
|
let gen;
|
|
583
585
|
try {
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
586
|
+
// desired generation 已在业务写事务中原子递增(peer blocker 1);这里
|
|
587
|
+
// 直接读当前值作为本次同步的目标轮次,不再自行 incrementGeneration。
|
|
588
|
+
const state = store.getMirrorState();
|
|
589
|
+
gen = state?.generation ?? 0;
|
|
587
590
|
} catch (stateError) {
|
|
588
|
-
logger?.warn?.("syncMirror:
|
|
589
|
-
return;
|
|
591
|
+
logger?.warn?.("syncMirror: getMirrorState failed:", stateError);
|
|
592
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
590
593
|
}
|
|
591
594
|
// coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
|
|
592
595
|
// 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
|
|
@@ -600,43 +603,89 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
600
603
|
}
|
|
601
604
|
}
|
|
602
605
|
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
+
}
|
|
611
628
|
}
|
|
612
|
-
|
|
613
|
-
|
|
629
|
+
|
|
630
|
+
// 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
|
|
631
|
+
// 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
|
|
632
|
+
if (allOk) {
|
|
614
633
|
try {
|
|
615
|
-
store.
|
|
634
|
+
store.markMirrorCleanForGeneration(gen, now);
|
|
616
635
|
} catch (stateError) {
|
|
617
|
-
logger?.warn?.(
|
|
636
|
+
logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
|
|
637
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
618
638
|
}
|
|
639
|
+
return { success: true };
|
|
619
640
|
}
|
|
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(", ")}` };
|
|
620
653
|
} catch (error) {
|
|
621
654
|
const errMsg = error?.message ?? String(error);
|
|
622
655
|
logger?.warn?.("syncMirror failed:", error);
|
|
623
656
|
try {
|
|
624
|
-
// 债务绑定到新的一轮(desired generation
|
|
657
|
+
// 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
|
|
658
|
+
// generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
|
|
625
659
|
store.markMirrorDirty(errMsg, now);
|
|
626
660
|
} catch (stateError) {
|
|
627
661
|
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
628
662
|
}
|
|
629
|
-
// 逐 type 标记为
|
|
663
|
+
// 逐 type 标记为 failed(applied_gen 不动)
|
|
630
664
|
for (const type of coveredTypes) {
|
|
631
665
|
try {
|
|
632
|
-
store.setTypeStatus(type, {
|
|
666
|
+
store.setTypeStatus(type, { status: "failed", last_error: errMsg });
|
|
633
667
|
} catch (stateError) {
|
|
634
|
-
logger?.warn?.(`syncMirror: setTypeStatus(${type})
|
|
668
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
|
|
635
669
|
}
|
|
636
670
|
}
|
|
671
|
+
return { success: false, error: errMsg };
|
|
637
672
|
}
|
|
638
673
|
}
|
|
639
674
|
|
|
675
|
+
// afterSync: run syncMirror and surface a failure to the operator instead of
|
|
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.
|
|
681
|
+
function afterSync(label) {
|
|
682
|
+
const r = syncMirror();
|
|
683
|
+
if (!r?.success && !r?.deferred) {
|
|
684
|
+
logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
|
|
685
|
+
}
|
|
686
|
+
return r;
|
|
687
|
+
}
|
|
688
|
+
|
|
640
689
|
// recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
|
|
641
690
|
// (F-NEW-03 + v0.3.6)。触发条件不只是 dirty——还检查
|
|
642
691
|
// generation > applied_generation(有未应用的债务),这样 COMMIT→dirty 崩溃
|
|
@@ -718,10 +767,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
718
767
|
success_at: state.success_at ?? null
|
|
719
768
|
};
|
|
720
769
|
} catch (error) {
|
|
721
|
-
// fail-safe
|
|
770
|
+
// fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
|
|
771
|
+
// 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
|
|
722
772
|
logger?.warn?.("getMirrorHealth failed:", error);
|
|
723
773
|
return {
|
|
724
|
-
dirty:
|
|
774
|
+
dirty: null,
|
|
725
775
|
last_error: error?.message ?? String(error),
|
|
726
776
|
last_attempt: null,
|
|
727
777
|
success_at: null
|
|
@@ -780,7 +830,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
780
830
|
getById: (id) => store.getById(id),
|
|
781
831
|
remove: (id) => {
|
|
782
832
|
store.remove(id);
|
|
783
|
-
|
|
833
|
+
afterSync("write");
|
|
784
834
|
notifyWrite();
|
|
785
835
|
},
|
|
786
836
|
update: (id, p, ctx = {}) => {
|
|
@@ -806,9 +856,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
806
856
|
memory_id: id
|
|
807
857
|
});
|
|
808
858
|
}
|
|
809
|
-
|
|
859
|
+
const sync = afterSync("write");
|
|
810
860
|
notifyWrite();
|
|
811
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
|
+
}
|
|
812
873
|
return updated;
|
|
813
874
|
},
|
|
814
875
|
// Compare-and-set update: applies the patch only when the row still carries
|
|
@@ -835,19 +896,27 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
835
896
|
memory_id: id
|
|
836
897
|
});
|
|
837
898
|
}
|
|
838
|
-
|
|
899
|
+
const sync = afterSync("write");
|
|
839
900
|
notifyWrite();
|
|
840
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
|
+
}
|
|
841
910
|
return updated;
|
|
842
911
|
},
|
|
843
912
|
setForget: (id, f) => {
|
|
844
913
|
const updated = store.setForget(id, f);
|
|
845
|
-
|
|
914
|
+
afterSync("write");
|
|
846
915
|
return updated;
|
|
847
916
|
},
|
|
848
917
|
setArchived: (id, f) => {
|
|
849
918
|
const updated = store.setArchived(id, f);
|
|
850
|
-
|
|
919
|
+
afterSync("write");
|
|
851
920
|
return updated;
|
|
852
921
|
},
|
|
853
922
|
// autoDream audit trail: passthroughs deliberately bypass write hooks —
|