@modusensus/dsh-mneme 0.3.4 → 0.3.6
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 +3 -3
- package/lib/api.js +30 -8
- package/lib/service.js +96 -24
- package/lib/store.js +142 -26
- package/package.json +1 -1
- package/src/api.js +30 -8
- package/src/service.js +96 -24
- package/src/store.js +142 -26
- package/test/fnew-03.test.js +11 -4
- package/test/mirror-dirty.test.js +12 -8
- package/test/mirror-generation.test.js +463 -0
- package/test/recall-layer.test.js +1 -0
- package/test/semantic.test.js +1 -0
- package/test/service-search.test.js +1 -0
package/src/service.js
CHANGED
|
@@ -38,9 +38,32 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
38
38
|
let txDepth = 0;
|
|
39
39
|
|
|
40
40
|
function scheduleEmbed(memory) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
try {
|
|
42
|
+
if (txDepth > 0) return; // deferred to the transaction's commit
|
|
43
|
+
if (!embedder || !memory?.id) return;
|
|
44
|
+
|
|
45
|
+
if (typeof embedder.schedule === "function") {
|
|
46
|
+
embedder.schedule(memory);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof embedder.embedSingle === "function") {
|
|
51
|
+
const text = [memory.title, memory.content].filter(Boolean).join("\n");
|
|
52
|
+
if (!text) return;
|
|
53
|
+
|
|
54
|
+
embedder
|
|
55
|
+
.embedSingle(text)
|
|
56
|
+
.then((vec) => {
|
|
57
|
+
if (Array.isArray(vec) && vec.length) {
|
|
58
|
+
store.setEmbedding(memory.id, vec);
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
.catch((err) => {
|
|
62
|
+
logger?.warn?.("scheduleEmbed embedSingle failed:", err);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
logger?.warn?.("scheduleEmbed failed:", err);
|
|
44
67
|
}
|
|
45
68
|
}
|
|
46
69
|
|
|
@@ -87,7 +110,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
87
110
|
|
|
88
111
|
/**
|
|
89
112
|
* Search for memories attached to a named entity (v0.3.0 Phase 3).
|
|
90
|
-
*
|
|
113
|
+
* 合并优先级:entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7;
|
|
91
114
|
* attr 命中不覆盖,keyword 只补充召回,最后按 _score 降序取 topK。
|
|
92
115
|
* @param {string} entityName
|
|
93
116
|
* @param {object} [options]
|
|
@@ -488,38 +511,82 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
488
511
|
* human-editable file (a human "edit" could otherwise resurrect them).
|
|
489
512
|
*/
|
|
490
513
|
// syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
514
|
+
// v0.3.6(audit peer 4 阻断):
|
|
515
|
+
// - 开始时 incrementGeneration 绑定本次期望轮次 gen;成功用
|
|
516
|
+
// markMirrorCleanForGeneration(gen, now) CAS/fence 清 dirty——旧 worker
|
|
517
|
+
// (gen 已过期)不会误清另一 worker 未恢复的故障债务;
|
|
518
|
+
// - 失败写 markMirrorDirty(递增 desired 绑定新债务),下次 recover 恢复;
|
|
519
|
+
// - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
|
|
520
|
+
// - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
|
|
494
521
|
function syncMirror() {
|
|
495
522
|
if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
|
|
496
523
|
const now = new Date().toISOString();
|
|
524
|
+
let gen;
|
|
497
525
|
try {
|
|
498
|
-
|
|
499
|
-
|
|
526
|
+
// 绑定本次期望轮次,必须在任何渲染之前,避免制造幽灵债务
|
|
527
|
+
const state = store.incrementGeneration();
|
|
528
|
+
gen = state.generation;
|
|
529
|
+
} catch (stateError) {
|
|
530
|
+
logger?.warn?.("syncMirror: incrementGeneration failed:", stateError);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
|
|
534
|
+
// 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
|
|
535
|
+
const coveredTypes = new Set();
|
|
536
|
+
try {
|
|
537
|
+
// 预先获取本次要覆盖的 type 集合(只调一次 store.list)
|
|
538
|
+
const list = store.list({ limit: 500, includeForgotten: false });
|
|
539
|
+
for (const memory of list) {
|
|
540
|
+
if (memory?.type && TYPE_FILE[memory.type]) {
|
|
541
|
+
coveredTypes.add(memory.type);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 全量渲染
|
|
546
|
+
mirror.sync(reconcileHumanEdits(list));
|
|
547
|
+
|
|
548
|
+
// 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截
|
|
500
549
|
try {
|
|
501
|
-
store.
|
|
550
|
+
store.markMirrorCleanForGeneration(gen, now);
|
|
502
551
|
} catch (stateError) {
|
|
503
|
-
|
|
504
|
-
|
|
552
|
+
logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
|
|
553
|
+
}
|
|
554
|
+
// 逐 type 标记为 clean
|
|
555
|
+
for (const type of coveredTypes) {
|
|
556
|
+
try {
|
|
557
|
+
store.setTypeStatus(type, { dirty: false, applied_gen: gen, last_error: null });
|
|
558
|
+
} catch (stateError) {
|
|
559
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) clean failed:`, stateError);
|
|
560
|
+
}
|
|
505
561
|
}
|
|
506
562
|
} catch (error) {
|
|
507
|
-
// 同步失败:写 dirty 状态
|
|
508
563
|
const errMsg = error?.message ?? String(error);
|
|
509
564
|
logger?.warn?.("syncMirror failed:", error);
|
|
510
565
|
try {
|
|
566
|
+
// 债务绑定到新的一轮(desired generation +1)
|
|
511
567
|
store.markMirrorDirty(errMsg, now);
|
|
512
568
|
} catch (stateError) {
|
|
513
|
-
// markMirrorDirty 失败同样不能向外抛出
|
|
514
569
|
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
515
570
|
}
|
|
571
|
+
// 逐 type 标记为 dirty(applied_gen 不动)
|
|
572
|
+
for (const type of coveredTypes) {
|
|
573
|
+
try {
|
|
574
|
+
store.setTypeStatus(type, { dirty: true, last_error: errMsg });
|
|
575
|
+
} catch (stateError) {
|
|
576
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) dirty failed:`, stateError);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
516
579
|
}
|
|
517
580
|
}
|
|
518
581
|
|
|
519
582
|
// recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
|
|
520
|
-
// (F-NEW-03
|
|
521
|
-
//
|
|
522
|
-
//
|
|
583
|
+
// (F-NEW-03 + v0.3.6)。触发条件不只是 dirty——还检查
|
|
584
|
+
// generation > applied_generation(有未应用的债务),这样 COMMIT→dirty 崩溃
|
|
585
|
+
// 窗口(DB 提交后、markMirrorDirty/clean 前进程退出 → dirty=false 但
|
|
586
|
+
// generation 不一致)也能被捕获。有界重试(最多 3 次)重跑 syncMirror 收敛;
|
|
587
|
+
// 某次成功后 dirty=false 且无更新债务(generation <= applied_generation)
|
|
588
|
+
// 立即停止。返回 { recovered, error } 供 index.js 启动 / api.js health 判断。
|
|
589
|
+
// 一切 fail-safe,绝不向外抛。
|
|
523
590
|
function recoverMirror() {
|
|
524
591
|
const MAX_ATTEMPTS = 3;
|
|
525
592
|
let lastError = null;
|
|
@@ -527,24 +594,29 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
527
594
|
|
|
528
595
|
try {
|
|
529
596
|
const state = store.getMirrorState();
|
|
530
|
-
|
|
597
|
+
// 崩溃窗口检测:dirty 或 generation > applied_generation(COMMIT→dirty 窗口)
|
|
598
|
+
if (!state?.dirty && !(state.generation > state.applied_generation)) {
|
|
531
599
|
// 本来就干净:无需恢复,视为成功
|
|
532
600
|
return { recovered: true, error: null };
|
|
533
601
|
}
|
|
534
602
|
|
|
535
|
-
// dirty
|
|
603
|
+
// 有 dirty 或有未应用债务:最多尝试 3 次 sync
|
|
536
604
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
537
605
|
try {
|
|
538
606
|
syncMirror(); // syncMirror 内部已 catch,不会向外抛
|
|
539
607
|
const currentState = store.getMirrorState();
|
|
540
|
-
|
|
541
|
-
|
|
608
|
+
// 成功条件:dirty 为 false 且没有更新一轮的债务
|
|
609
|
+
// (generation <= applied_generation,恢复后由 syncMirror 里
|
|
610
|
+
// markMirrorCleanForGeneration 自动把 applied 跟上)
|
|
611
|
+
if (!currentState?.dirty && currentState.generation <= currentState.applied_generation) {
|
|
542
612
|
recovered = true;
|
|
543
613
|
lastError = null;
|
|
544
614
|
break;
|
|
545
615
|
}
|
|
546
|
-
// 仍 dirty
|
|
547
|
-
|
|
616
|
+
// 仍 dirty 或仍有更新债务:记录最后一次错误供重试耗尽后上报。
|
|
617
|
+
// 注意:若别的 worker 又失败产生新债务(dirty 仍 true),这是"新债务"
|
|
618
|
+
// 不是本次失败,继续重试直到耗尽次数。
|
|
619
|
+
lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty or has pending debt`;
|
|
548
620
|
} catch (syncError) {
|
|
549
621
|
// syncMirror 理论不抛,fail-safe 兜底
|
|
550
622
|
const errMsg = syncError?.message ?? String(syncError);
|
|
@@ -556,7 +628,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
556
628
|
if (!recovered) {
|
|
557
629
|
logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
|
|
558
630
|
} else {
|
|
559
|
-
logger?.warn?.("dsh-mneme mirror: recovered from dirty state");
|
|
631
|
+
logger?.warn?.("dsh-mneme mirror: recovered from dirty/pending state");
|
|
560
632
|
}
|
|
561
633
|
} catch (error) {
|
|
562
634
|
// fail-safe:任何意外异常不向外抛
|
package/src/store.js
CHANGED
|
@@ -169,12 +169,20 @@ CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type)
|
|
|
169
169
|
-- syncMirror 失败不再只靠瞬时 console.warn —— dirty=1 提示镜像脏了需重渲染,
|
|
170
170
|
-- last_error/last_attempt 记录失败原因与最近尝试,success_at 记录最近成功。
|
|
171
171
|
-- 上层可据此在启动时重试、提供人工 reconcile 入口与健康状态查询。
|
|
172
|
+
-- v0.3.6: 新增 generation/applied_generation/type_status —— desired-applied
|
|
173
|
+
-- 建模镜像债务:generation 是期望同步轮次,applied_generation 是已成功应用
|
|
174
|
+
-- 轮次(成功清 dirty 必须 CAS/fence 到具体轮次,旧 worker 不能清新故障),
|
|
175
|
+
-- type_status 逐 type 记录部分成功状态。旧库经 PRAGMA table_info 检查后
|
|
176
|
+
-- ALTER 补列,幂等且不丢数据。
|
|
172
177
|
CREATE TABLE IF NOT EXISTS mirror_state (
|
|
173
178
|
id TEXT PRIMARY KEY, -- 单一状态行(用 'main')
|
|
174
179
|
dirty INTEGER NOT NULL DEFAULT 0, -- 1=镜像脏了需重渲染
|
|
175
180
|
last_error TEXT, -- 最近失败原因
|
|
176
181
|
last_attempt TEXT, -- 最近尝试时间(ISO)
|
|
177
|
-
success_at TEXT
|
|
182
|
+
success_at TEXT, -- 最近成功时间(ISO)
|
|
183
|
+
generation INTEGER NOT NULL DEFAULT 0, -- 期望的同步轮次(desired)
|
|
184
|
+
applied_generation INTEGER NOT NULL DEFAULT 0, -- 已成功应用的轮次
|
|
185
|
+
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
178
186
|
);
|
|
179
187
|
`;
|
|
180
188
|
|
|
@@ -337,14 +345,33 @@ function toRelation(row) {
|
|
|
337
345
|
|
|
338
346
|
function toMirrorState(row) {
|
|
339
347
|
if (!row) {
|
|
340
|
-
return {
|
|
348
|
+
return {
|
|
349
|
+
dirty: false,
|
|
350
|
+
last_error: null,
|
|
351
|
+
last_attempt: null,
|
|
352
|
+
success_at: null,
|
|
353
|
+
generation: 0,
|
|
354
|
+
applied_generation: 0,
|
|
355
|
+
type_status: {}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
let typeStatus = {};
|
|
359
|
+
if (row.type_status) {
|
|
360
|
+
try {
|
|
361
|
+
typeStatus = JSON.parse(row.type_status) || {};
|
|
362
|
+
} catch {
|
|
363
|
+
typeStatus = {};
|
|
364
|
+
}
|
|
341
365
|
}
|
|
342
366
|
return {
|
|
343
367
|
id: row.id,
|
|
344
368
|
dirty: row.dirty === 1,
|
|
345
369
|
last_error: row.last_error,
|
|
346
370
|
last_attempt: row.last_attempt,
|
|
347
|
-
success_at: row.success_at
|
|
371
|
+
success_at: row.success_at,
|
|
372
|
+
generation: Number(row.generation) || 0,
|
|
373
|
+
applied_generation: Number(row.applied_generation) || 0,
|
|
374
|
+
type_status: typeStatus
|
|
348
375
|
};
|
|
349
376
|
}
|
|
350
377
|
|
|
@@ -377,6 +404,19 @@ export function createStore(path) {
|
|
|
377
404
|
db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
378
405
|
}
|
|
379
406
|
|
|
407
|
+
// Legacy mirror_state without v0.3.6 generation columns → add each missing
|
|
408
|
+
// column idempotently (old DBs open cleanly, no data loss).
|
|
409
|
+
const mirrorCols = db.prepare("PRAGMA table_info(mirror_state)").all().map((c) => c.name);
|
|
410
|
+
if (!mirrorCols.includes("generation")) {
|
|
411
|
+
db.exec("ALTER TABLE mirror_state ADD COLUMN generation INTEGER NOT NULL DEFAULT 0");
|
|
412
|
+
}
|
|
413
|
+
if (!mirrorCols.includes("applied_generation")) {
|
|
414
|
+
db.exec("ALTER TABLE mirror_state ADD COLUMN applied_generation INTEGER NOT NULL DEFAULT 0");
|
|
415
|
+
}
|
|
416
|
+
if (!mirrorCols.includes("type_status")) {
|
|
417
|
+
db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
|
|
418
|
+
}
|
|
419
|
+
|
|
380
420
|
// Per-instance monotonic timestamp guard: consecutive writes within the same
|
|
381
421
|
// millisecond must still produce strictly increasing timestamps (test asserts
|
|
382
422
|
// updated_at != created_at). State lives in the store closure, not module scope.
|
|
@@ -1113,14 +1153,25 @@ export function createStore(path) {
|
|
|
1113
1153
|
|
|
1114
1154
|
/**
|
|
1115
1155
|
* Upsert the single mirror_state row (id='main'). patch accepts
|
|
1116
|
-
* {dirty?, last_error?, last_attempt?, success_at
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1156
|
+
* {dirty?, last_error?, last_attempt?, success_at?, generation?,
|
|
1157
|
+
* applied_generation?, type_status?} — only the keys present on the object
|
|
1158
|
+
* are written, everything else is left untouched (partial upsert). type_status
|
|
1159
|
+
* is stored as JSON text (objects are serialized on write), generation /
|
|
1160
|
+
* applied_generation are coerced to non-negative integers. Returns the freshly
|
|
1161
|
+
* read state row (default shape when absent).
|
|
1119
1162
|
*/
|
|
1120
1163
|
function setMirrorState(patch) {
|
|
1121
|
-
const ALLOWED = new Set([
|
|
1122
|
-
|
|
1123
|
-
|
|
1164
|
+
const ALLOWED = new Set([
|
|
1165
|
+
"dirty",
|
|
1166
|
+
"last_error",
|
|
1167
|
+
"last_attempt",
|
|
1168
|
+
"success_at",
|
|
1169
|
+
"generation",
|
|
1170
|
+
"applied_generation",
|
|
1171
|
+
"type_status"
|
|
1172
|
+
]);
|
|
1173
|
+
const keys = Object.keys(patch).filter(
|
|
1174
|
+
(key) => ALLOWED.has(key) && Object.prototype.hasOwnProperty.call(patch, key)
|
|
1124
1175
|
);
|
|
1125
1176
|
if (keys.length === 0) {
|
|
1126
1177
|
db.prepare(
|
|
@@ -1130,41 +1181,78 @@ export function createStore(path) {
|
|
|
1130
1181
|
}
|
|
1131
1182
|
// 列同时出现在 INSERT 与 ON CONFLICT 里(excluded.*),保证首次插入也写入
|
|
1132
1183
|
// patch 值,而不只是默认值;未传入的列保持不变(partial upsert)。
|
|
1133
|
-
const cols =
|
|
1134
|
-
const
|
|
1135
|
-
const updates =
|
|
1136
|
-
const
|
|
1137
|
-
|
|
1138
|
-
|
|
1184
|
+
const cols = [];
|
|
1185
|
+
const values = [];
|
|
1186
|
+
const updates = [];
|
|
1187
|
+
for (const key of keys) {
|
|
1188
|
+
let value = patch[key];
|
|
1189
|
+
if (key === "dirty") {
|
|
1190
|
+
value = value ? 1 : 0;
|
|
1191
|
+
} else if (key === "generation" || key === "applied_generation") {
|
|
1192
|
+
value = Math.trunc(Number(value)) || 0;
|
|
1193
|
+
} else if (key === "type_status" && value != null && typeof value !== "string") {
|
|
1194
|
+
value = JSON.stringify(value);
|
|
1195
|
+
}
|
|
1196
|
+
cols.push(key);
|
|
1197
|
+
values.push(value);
|
|
1198
|
+
updates.push(`${key} = excluded.${key}`);
|
|
1199
|
+
}
|
|
1200
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
1139
1201
|
db.prepare(
|
|
1140
|
-
`INSERT INTO mirror_state (id, ${cols}) VALUES ('main', ${placeholders})
|
|
1141
|
-
ON CONFLICT(id) DO UPDATE SET ${updates}`
|
|
1202
|
+
`INSERT INTO mirror_state (id, ${cols.join(", ")}) VALUES ('main', ${placeholders})
|
|
1203
|
+
ON CONFLICT(id) DO UPDATE SET ${updates.join(", ")}`
|
|
1142
1204
|
).run(...values);
|
|
1143
1205
|
return getMirrorState();
|
|
1144
1206
|
}
|
|
1145
1207
|
|
|
1146
|
-
/** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null} when absent. */
|
|
1208
|
+
/** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null, generation:0, applied_generation:0, type_status:{}} when absent. */
|
|
1147
1209
|
function getMirrorState() {
|
|
1148
1210
|
const row = db.prepare("SELECT * FROM mirror_state WHERE id = 'main'").get();
|
|
1149
1211
|
return toMirrorState(row);
|
|
1150
1212
|
}
|
|
1151
1213
|
|
|
1152
|
-
/**
|
|
1214
|
+
/**
|
|
1215
|
+
* Mark the mirror dirty after a failed sync (dirty=1 + last_error +
|
|
1216
|
+
* last_attempt). v0.3.6: also bumps the desired generation so the debt is
|
|
1217
|
+
* bound to a specific sync round; applied_generation is left untouched
|
|
1218
|
+
* (the round was NOT applied). A stale worker that started earlier cannot
|
|
1219
|
+
* clear this newer debt — only a clean fenced to a generation at least as
|
|
1220
|
+
* recent as this one may.
|
|
1221
|
+
*/
|
|
1153
1222
|
function markMirrorDirty(error, now) {
|
|
1223
|
+
const current = getMirrorState();
|
|
1154
1224
|
return setMirrorState({
|
|
1155
1225
|
dirty: 1,
|
|
1156
1226
|
last_error: error,
|
|
1157
|
-
last_attempt: now ?? nowIso()
|
|
1227
|
+
last_attempt: now ?? nowIso(),
|
|
1228
|
+
generation: (current.generation || 0) + 1
|
|
1158
1229
|
});
|
|
1159
1230
|
}
|
|
1160
1231
|
|
|
1161
|
-
/**
|
|
1232
|
+
/**
|
|
1233
|
+
* Fenced clean (CAS): mark the mirror clean for a specific generation.
|
|
1234
|
+
* First records that generation `gen` has been applied
|
|
1235
|
+
* (applied_generation = MAX(applied_generation, gen)), then clears dirty only
|
|
1236
|
+
* when the current desired generation has not advanced past gen — a stale
|
|
1237
|
+
* worker cleaning an older round must not wipe a newer failure's debt.
|
|
1238
|
+
* Returns the resulting state (dirty stays set when the fence holds).
|
|
1239
|
+
*/
|
|
1240
|
+
function markMirrorCleanForGeneration(gen, now) {
|
|
1241
|
+
const current = getMirrorState();
|
|
1242
|
+
const applied = Math.max(current.applied_generation || 0, gen);
|
|
1243
|
+
const patch = { applied_generation: applied };
|
|
1244
|
+
if (applied >= gen && (current.generation || 0) <= gen) {
|
|
1245
|
+
patch.dirty = 0;
|
|
1246
|
+
patch.last_error = null;
|
|
1247
|
+
patch.success_at = now ?? nowIso();
|
|
1248
|
+
}
|
|
1249
|
+
return setMirrorState(patch);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** Convenience: mark the mirror clean for the current desired generation (backward-compatible with pre-v0.3.6 callers). */
|
|
1162
1253
|
function markMirrorClean(now) {
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
last_error: null,
|
|
1166
|
-
success_at: now ?? nowIso()
|
|
1167
|
-
});
|
|
1254
|
+
const current = getMirrorState();
|
|
1255
|
+
return markMirrorCleanForGeneration(current.generation || 0, now);
|
|
1168
1256
|
}
|
|
1169
1257
|
|
|
1170
1258
|
/** Convenience: clear only the dirty flag + last_error, leaving success_at untouched (manual reconcile / retry path). */
|
|
@@ -1172,6 +1260,30 @@ export function createStore(path) {
|
|
|
1172
1260
|
return setMirrorState({ dirty: 0, last_error: null });
|
|
1173
1261
|
}
|
|
1174
1262
|
|
|
1263
|
+
/**
|
|
1264
|
+
* Record per-type mirror status (partial success bookkeeping). `status` is a
|
|
1265
|
+
* partial patch {dirty?, applied_gen?, last_error?} merged into the existing
|
|
1266
|
+
* entry for `type` (other types untouched). Returns the updated full state.
|
|
1267
|
+
*/
|
|
1268
|
+
function setTypeStatus(type, status) {
|
|
1269
|
+
const current = getMirrorState();
|
|
1270
|
+
const statuses = current.type_status || {};
|
|
1271
|
+
statuses[type] = { ...(statuses[type] || {}), ...status };
|
|
1272
|
+
return setMirrorState({ type_status: JSON.stringify(statuses) });
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** Per-type mirror status map {type: {dirty, applied_gen, last_error}}, {} when unset. */
|
|
1276
|
+
function getTypeStatus() {
|
|
1277
|
+
const current = getMirrorState();
|
|
1278
|
+
return current.type_status || {};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/** Bump the desired generation (new sync round), returning the new state. */
|
|
1282
|
+
function incrementGeneration() {
|
|
1283
|
+
const current = getMirrorState();
|
|
1284
|
+
return setMirrorState({ generation: (current.generation || 0) + 1 });
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1175
1287
|
return {
|
|
1176
1288
|
db,
|
|
1177
1289
|
count,
|
|
@@ -1224,7 +1336,11 @@ export function createStore(path) {
|
|
|
1224
1336
|
getMirrorState,
|
|
1225
1337
|
markMirrorDirty,
|
|
1226
1338
|
markMirrorClean,
|
|
1339
|
+
markMirrorCleanForGeneration,
|
|
1227
1340
|
clearMirrorDirty,
|
|
1341
|
+
setTypeStatus,
|
|
1342
|
+
getTypeStatus,
|
|
1343
|
+
incrementGeneration,
|
|
1228
1344
|
close() {
|
|
1229
1345
|
db.close();
|
|
1230
1346
|
}
|
package/test/fnew-03.test.js
CHANGED
|
@@ -144,15 +144,17 @@ test("F-NEW-03 d: dirty 记录与 DB decision receipt 物理分离——mirror_s
|
|
|
144
144
|
try {
|
|
145
145
|
const store = createStore(dbPath);
|
|
146
146
|
const cols = store.db.prepare("PRAGMA table_info(mirror_state)").all().map((c) => c.name);
|
|
147
|
-
assert.deepEqual(cols.sort(),
|
|
148
|
-
"
|
|
147
|
+
assert.deepEqual(cols.sort(),
|
|
148
|
+
["applied_generation", "dirty", "generation", "id", "last_attempt", "last_error", "success_at", "type_status"],
|
|
149
|
+
"mirror_state 只含状态列(v0.3.6 含 generation/applied_generation/type_status),不得混入 receipt/decision 字段");
|
|
149
150
|
store.markMirrorDirty("boom", "t");
|
|
150
151
|
store.saveReceipt({
|
|
151
152
|
run_id: "run-1", record_id: "rec-1", kind: "merge", input_digest: "d",
|
|
152
153
|
verdict: "live", count_before: 1, count_after: 2
|
|
153
154
|
});
|
|
154
155
|
const state = store.getMirrorState();
|
|
155
|
-
assert.deepEqual(Object.keys(state).sort(),
|
|
156
|
+
assert.deepEqual(Object.keys(state).sort(),
|
|
157
|
+
["applied_generation", "dirty", "generation", "id", "last_attempt", "last_error", "success_at", "type_status"],
|
|
156
158
|
"dirty 状态对象不得携带 receipt 内容");
|
|
157
159
|
assert.equal(state.last_error, "boom");
|
|
158
160
|
store.close();
|
|
@@ -328,7 +330,12 @@ test("F-NEW-03 j: /api/dsh-mneme/health 端点暴露 dirty 状态,恢复后自
|
|
|
328
330
|
assert.equal(resDirty.statusCode, 200);
|
|
329
331
|
assert.match(resDirty.headers["Content-Type"], /application\/json/);
|
|
330
332
|
assert.equal(JSON.parse(resDirty.body).mirror.dirty, true, "dirty 时 /health 必须暴露 dirty:true");
|
|
331
|
-
assert.equal(JSON.parse(resDirty.body).mirror.
|
|
333
|
+
assert.equal(JSON.parse(resDirty.body).mirror.status, "degraded", "dirty 时 /health 必须为 degraded");
|
|
334
|
+
// v0.3.6 脱敏:/health 只回脱敏错误码,不回显原始 last_error
|
|
335
|
+
assert.equal(JSON.parse(resDirty.body).mirror.last_error, "no-space",
|
|
336
|
+
"last_error 必须脱敏为 no-space,不得回显原始值");
|
|
337
|
+
assert.ok(!JSON.parse(resDirty.body).mirror.last_error.includes(MIRROR_ERR),
|
|
338
|
+
"原始错误串不得出现在 /health 响应中");
|
|
332
339
|
|
|
333
340
|
mock.mode = "ok";
|
|
334
341
|
service.recoverMirror();
|
|
@@ -336,7 +336,11 @@ test("F-NEW-03: health 端点 dirty 时返回 dirty=true 及 last_error/last_att
|
|
|
336
336
|
assert.equal(res.statusCode, 200);
|
|
337
337
|
const body = JSON.parse(res.body);
|
|
338
338
|
assert.equal(body.mirror.dirty, true, "health 必须反映真实 dirty(布尔)");
|
|
339
|
-
assert.equal(body.mirror.
|
|
339
|
+
assert.equal(body.mirror.status, "degraded", "dirty 时 health 必须为 degraded");
|
|
340
|
+
// v0.3.6 脱敏:只回脱敏错误码,不回显原始 last_error
|
|
341
|
+
assert.equal(body.mirror.last_error, "no-space", "last_error 必须脱敏为 no-space");
|
|
342
|
+
assert.ok(!body.mirror.last_error.includes("No space left on device"),
|
|
343
|
+
"原始错误串不得出现在 health 响应中");
|
|
340
344
|
assert.ok(body.mirror.last_attempt, "last_attempt 须返回");
|
|
341
345
|
assert.equal(body.mirror.success_at, null);
|
|
342
346
|
} finally {
|
|
@@ -392,25 +396,25 @@ test("F-NEW-03: markMirrorDirty 自身抛错 → syncMirror 不抛、只 warn",
|
|
|
392
396
|
}
|
|
393
397
|
});
|
|
394
398
|
|
|
395
|
-
test("F-NEW-03:
|
|
399
|
+
test("F-NEW-03: markMirrorCleanForGeneration 自身抛错 → syncMirror 不抛、只 warn", () => {
|
|
396
400
|
const { dir, store, mirror, service, warns } = setup();
|
|
397
401
|
try {
|
|
398
402
|
const { original } = throwingMirrorSync(mirror);
|
|
399
|
-
const origClean = store.
|
|
403
|
+
const origClean = store.markMirrorCleanForGeneration.bind(store);
|
|
400
404
|
try {
|
|
401
405
|
// 制造 dirty
|
|
402
406
|
service.saveWithDedupe({ type: "project", title: "前置脏", content: "x", importance: 3 });
|
|
403
407
|
assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
|
|
404
|
-
// 恢复 sync + 让
|
|
408
|
+
// 恢复 sync + 让 markMirrorCleanForGeneration 抛错(v0.3.6 成功路径的 CAS 写)
|
|
405
409
|
mirror.sync = original;
|
|
406
|
-
store.
|
|
410
|
+
store.markMirrorCleanForGeneration = () => { throw new Error("clean write fail"); };
|
|
407
411
|
assert.doesNotThrow(() => {
|
|
408
412
|
service.saveWithDedupe({ type: "project", title: "成功路径", content: "y", importance: 2 });
|
|
409
|
-
}, "
|
|
410
|
-
assert.ok(warns.some((w) => /
|
|
413
|
+
}, "markMirrorCleanForGeneration 失败不得使 syncMirror 外抛");
|
|
414
|
+
assert.ok(warns.some((w) => /markMirrorCleanForGeneration failed|clean write fail/.test(w)), "应走 logger.warn");
|
|
411
415
|
assert.equal(store.count(), 2, "store 写入不受影响");
|
|
412
416
|
} finally {
|
|
413
|
-
store.
|
|
417
|
+
store.markMirrorCleanForGeneration = origClean;
|
|
414
418
|
mirror.sync = original;
|
|
415
419
|
}
|
|
416
420
|
} finally {
|