@modusensus/dsh-mneme 0.3.7 → 0.3.8
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/lib/api.js +8 -0
- package/lib/service.js +48 -28
- package/lib/store.js +114 -34
- package/package.json +1 -1
- package/src/api.js +8 -0
- package/src/service.js +48 -28
- package/src/store.js +114 -34
- package/test/mirror-generation.test.js +24 -21
- package/test/peer-blockers.test.js +148 -0
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;
|
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)。
|
|
@@ -603,37 +606,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
603
606
|
// 全量渲染
|
|
604
607
|
mirror.sync(reconcileHumanEdits(list));
|
|
605
608
|
|
|
606
|
-
// 成功:CAS/fence 绑定到本地 gen,旧 worker(gen
|
|
609
|
+
// 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截。
|
|
610
|
+
// 此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
|
|
607
611
|
try {
|
|
608
612
|
store.markMirrorCleanForGeneration(gen, now);
|
|
609
613
|
} catch (stateError) {
|
|
610
614
|
logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
|
|
615
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
611
616
|
}
|
|
612
|
-
// 逐 type 标记为
|
|
617
|
+
// 逐 type 标记为 committed(peer blocker 4: per-type receipt)
|
|
613
618
|
for (const type of coveredTypes) {
|
|
614
619
|
try {
|
|
615
|
-
store.setTypeStatus(type, {
|
|
620
|
+
store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
|
|
616
621
|
} catch (stateError) {
|
|
617
|
-
logger?.warn?.(`syncMirror: setTypeStatus(${type})
|
|
622
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) committed failed:`, stateError);
|
|
618
623
|
}
|
|
619
624
|
}
|
|
625
|
+
return { success: true };
|
|
620
626
|
} catch (error) {
|
|
621
627
|
const errMsg = error?.message ?? String(error);
|
|
622
628
|
logger?.warn?.("syncMirror failed:", error);
|
|
623
629
|
try {
|
|
624
|
-
// 债务绑定到新的一轮(desired generation
|
|
630
|
+
// 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
|
|
631
|
+
// generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
|
|
625
632
|
store.markMirrorDirty(errMsg, now);
|
|
626
633
|
} catch (stateError) {
|
|
627
634
|
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
628
635
|
}
|
|
629
|
-
// 逐 type 标记为
|
|
636
|
+
// 逐 type 标记为 failed(applied_gen 不动)
|
|
630
637
|
for (const type of coveredTypes) {
|
|
631
638
|
try {
|
|
632
|
-
store.setTypeStatus(type, {
|
|
639
|
+
store.setTypeStatus(type, { status: "failed", last_error: errMsg });
|
|
633
640
|
} catch (stateError) {
|
|
634
|
-
logger?.warn?.(`syncMirror: setTypeStatus(${type})
|
|
641
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
|
|
635
642
|
}
|
|
636
643
|
}
|
|
644
|
+
return { success: false, error: errMsg };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// afterSync: run syncMirror and surface a failure to the operator instead of
|
|
649
|
+
// swallowing it (peer blocker 2). The mirror debt has already been persisted
|
|
650
|
+
// by markMirrorDirty inside syncMirror, so a restart recovers — but the
|
|
651
|
+
// calling write path must not report clean while the mirror is known-stale.
|
|
652
|
+
function afterSync(label) {
|
|
653
|
+
const r = syncMirror();
|
|
654
|
+
if (!r?.success && !r?.deferred) {
|
|
655
|
+
logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
|
|
637
656
|
}
|
|
638
657
|
}
|
|
639
658
|
|
|
@@ -718,10 +737,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
718
737
|
success_at: state.success_at ?? null
|
|
719
738
|
};
|
|
720
739
|
} catch (error) {
|
|
721
|
-
// fail-safe
|
|
740
|
+
// fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
|
|
741
|
+
// 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
|
|
722
742
|
logger?.warn?.("getMirrorHealth failed:", error);
|
|
723
743
|
return {
|
|
724
|
-
dirty:
|
|
744
|
+
dirty: null,
|
|
725
745
|
last_error: error?.message ?? String(error),
|
|
726
746
|
last_attempt: null,
|
|
727
747
|
success_at: null
|
|
@@ -780,7 +800,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
780
800
|
getById: (id) => store.getById(id),
|
|
781
801
|
remove: (id) => {
|
|
782
802
|
store.remove(id);
|
|
783
|
-
|
|
803
|
+
afterSync("write");
|
|
784
804
|
notifyWrite();
|
|
785
805
|
},
|
|
786
806
|
update: (id, p, ctx = {}) => {
|
|
@@ -806,7 +826,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
806
826
|
memory_id: id
|
|
807
827
|
});
|
|
808
828
|
}
|
|
809
|
-
|
|
829
|
+
afterSync("write");
|
|
810
830
|
notifyWrite();
|
|
811
831
|
scheduleEmbed(updated);
|
|
812
832
|
return updated;
|
|
@@ -835,19 +855,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
835
855
|
memory_id: id
|
|
836
856
|
});
|
|
837
857
|
}
|
|
838
|
-
|
|
858
|
+
afterSync("write");
|
|
839
859
|
notifyWrite();
|
|
840
860
|
scheduleEmbed(updated);
|
|
841
861
|
return updated;
|
|
842
862
|
},
|
|
843
863
|
setForget: (id, f) => {
|
|
844
864
|
const updated = store.setForget(id, f);
|
|
845
|
-
|
|
865
|
+
afterSync("write");
|
|
846
866
|
return updated;
|
|
847
867
|
},
|
|
848
868
|
setArchived: (id, f) => {
|
|
849
869
|
const updated = store.setArchived(id, f);
|
|
850
|
-
|
|
870
|
+
afterSync("write");
|
|
851
871
|
return updated;
|
|
852
872
|
},
|
|
853
873
|
// autoDream audit trail: passthroughs deliberately bypass write hooks —
|
package/lib/store.js
CHANGED
|
@@ -180,14 +180,19 @@ 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, -- 期望的同步轮次(desired)
|
|
184
|
-
applied_generation INTEGER NOT NULL DEFAULT 0, -- 已成功应用的轮次
|
|
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), -- 已成功应用的轮次
|
|
185
185
|
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
186
186
|
);
|
|
187
187
|
`;
|
|
188
188
|
|
|
189
189
|
const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
|
|
190
190
|
|
|
191
|
+
// Per-type mirror sync receipts (peer blocker 4): a type is either committed
|
|
192
|
+
// (file written + fence applied), failed (last sync round errored for it), or
|
|
193
|
+
// pending (still owed a write).
|
|
194
|
+
const VALID_TYPE_STATUS = new Set(["committed", "failed", "pending"]);
|
|
195
|
+
|
|
191
196
|
// Pure helpers: no shared module state.
|
|
192
197
|
|
|
193
198
|
function sanitizePage(limit, offset, defaultLimit) {
|
|
@@ -387,6 +392,10 @@ function parseJsonArray(raw) {
|
|
|
387
392
|
export function createStore(path) {
|
|
388
393
|
const db = new DatabaseSync(path);
|
|
389
394
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
395
|
+
// Concurrent writers (peer probe: 8 independent processes) must wait for the
|
|
396
|
+
// write lock instead of failing immediately with SQLITE_BUSY — otherwise the
|
|
397
|
+
// atomic generation increment loses whole writes, not just increments.
|
|
398
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
390
399
|
db.exec(SCHEMA);
|
|
391
400
|
|
|
392
401
|
// Schema migrations for legacy databases (idempotent).
|
|
@@ -467,10 +476,17 @@ export function createStore(path) {
|
|
|
467
476
|
const embedding = Array.isArray(memory.embedding) && memory.embedding.length
|
|
468
477
|
? JSON.stringify(memory.embedding)
|
|
469
478
|
: null;
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
479
|
+
runAtomically(() => {
|
|
480
|
+
db.prepare(
|
|
481
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
|
|
482
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
|
|
483
|
+
).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, now, now);
|
|
484
|
+
// desired generation bumped in the same transaction as the write: once
|
|
485
|
+
// this commits, generation > applied_generation, so a crash right after
|
|
486
|
+
// (before syncMirror) is caught by recoverMirror on restart (peer
|
|
487
|
+
// blocker 1). ROLLBACK on error rolls this back with the write.
|
|
488
|
+
incrementGeneration();
|
|
489
|
+
});
|
|
474
490
|
return getById(id);
|
|
475
491
|
}
|
|
476
492
|
|
|
@@ -486,24 +502,34 @@ export function createStore(path) {
|
|
|
486
502
|
const embedding = patch.embedding !== undefined
|
|
487
503
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
488
504
|
: existing.embedding ?? null;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
505
|
+
runAtomically(() => {
|
|
506
|
+
db.prepare(
|
|
507
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=? WHERE id=?`
|
|
508
|
+
).run(
|
|
509
|
+
type,
|
|
510
|
+
patch.title ?? existing.title,
|
|
511
|
+
patch.content ?? existing.content,
|
|
512
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
513
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
514
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
515
|
+
embedding,
|
|
516
|
+
now,
|
|
517
|
+
id
|
|
518
|
+
);
|
|
519
|
+
// Desired generation bumped in the same transaction as the update (peer
|
|
520
|
+
// blocker 1: crash between write and sync must still be recoverable).
|
|
521
|
+
incrementGeneration();
|
|
522
|
+
});
|
|
502
523
|
return getById(id);
|
|
503
524
|
}
|
|
504
525
|
|
|
505
526
|
function remove(id) {
|
|
506
|
-
|
|
527
|
+
runAtomically(() => {
|
|
528
|
+
db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
529
|
+
// Mirror sync must reflect the deletion; bump desired generation so a
|
|
530
|
+
// crash between the delete and syncMirror leaves a recoverable debt.
|
|
531
|
+
incrementGeneration();
|
|
532
|
+
});
|
|
507
533
|
}
|
|
508
534
|
|
|
509
535
|
/**
|
|
@@ -542,18 +568,26 @@ export function createStore(path) {
|
|
|
542
568
|
expectedUpdatedAt
|
|
543
569
|
);
|
|
544
570
|
if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
|
|
571
|
+
// Only bump desired generation on a successful CAS — a miss writes nothing.
|
|
572
|
+
runAtomically(() => { incrementGeneration(); });
|
|
545
573
|
return getById(id);
|
|
546
574
|
}
|
|
547
575
|
|
|
548
576
|
function setForget(id, forgotten) {
|
|
549
|
-
|
|
550
|
-
.
|
|
577
|
+
runAtomically(() => {
|
|
578
|
+
db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
|
|
579
|
+
.run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
|
|
580
|
+
incrementGeneration();
|
|
581
|
+
});
|
|
551
582
|
return getById(id);
|
|
552
583
|
}
|
|
553
584
|
|
|
554
585
|
function setArchived(id, archived) {
|
|
555
|
-
|
|
556
|
-
.
|
|
586
|
+
runAtomically(() => {
|
|
587
|
+
db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
|
|
588
|
+
.run(archived ? 1 : 0, nowIso(), id);
|
|
589
|
+
incrementGeneration();
|
|
590
|
+
});
|
|
557
591
|
return getById(id);
|
|
558
592
|
}
|
|
559
593
|
|
|
@@ -1189,7 +1223,10 @@ export function createStore(path) {
|
|
|
1189
1223
|
if (key === "dirty") {
|
|
1190
1224
|
value = value ? 1 : 0;
|
|
1191
1225
|
} else if (key === "generation" || key === "applied_generation") {
|
|
1192
|
-
value = Math.trunc(Number(value))
|
|
1226
|
+
value = Math.trunc(Number(value));
|
|
1227
|
+
if (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
|
1228
|
+
throw new RangeError(`mirror_state.${key} out of range: ${value}`);
|
|
1229
|
+
}
|
|
1193
1230
|
} else if (key === "type_status" && value != null && typeof value !== "string") {
|
|
1194
1231
|
value = JSON.stringify(value);
|
|
1195
1232
|
}
|
|
@@ -1220,12 +1257,15 @@ export function createStore(path) {
|
|
|
1220
1257
|
* recent as this one may.
|
|
1221
1258
|
*/
|
|
1222
1259
|
function markMirrorDirty(error, now) {
|
|
1223
|
-
|
|
1260
|
+
// Bump the desired generation atomically first — the new debt must be bound
|
|
1261
|
+
// to a fresh round so a stale worker cannot fence-clean it. Even if this
|
|
1262
|
+
// write fails (peer blocker 2), generation still advanced, so recoverMirror
|
|
1263
|
+
// sees generation > applied_generation and retries rather than false-clean.
|
|
1264
|
+
incrementGeneration();
|
|
1224
1265
|
return setMirrorState({
|
|
1225
1266
|
dirty: 1,
|
|
1226
1267
|
last_error: error,
|
|
1227
|
-
last_attempt: now ?? nowIso()
|
|
1228
|
-
generation: (current.generation || 0) + 1
|
|
1268
|
+
last_attempt: now ?? nowIso()
|
|
1229
1269
|
});
|
|
1230
1270
|
}
|
|
1231
1271
|
|
|
@@ -1262,13 +1302,23 @@ export function createStore(path) {
|
|
|
1262
1302
|
|
|
1263
1303
|
/**
|
|
1264
1304
|
* Record per-type mirror status (partial success bookkeeping). `status` is a
|
|
1265
|
-
*
|
|
1266
|
-
* entry for `type` (other types untouched).
|
|
1305
|
+
* patch {status: 'committed'|'failed'|'pending', applied_gen?, last_error?}
|
|
1306
|
+
* replacing the entry for `type` (other types untouched). Standardizing on an
|
|
1307
|
+
* explicit status gives per-type committed/failed/pending receipts — a type
|
|
1308
|
+
* whose file was written while a sibling failed is recorded as such, not
|
|
1309
|
+
* collapsed into a bulk "dirty" (peer blocker 4). Returns the updated state.
|
|
1267
1310
|
*/
|
|
1268
1311
|
function setTypeStatus(type, status) {
|
|
1312
|
+
if (!VALID_TYPE_STATUS.has(status?.status)) {
|
|
1313
|
+
throw new TypeError(`setTypeStatus: status must be one of committed|failed|pending, got ${status?.status}`);
|
|
1314
|
+
}
|
|
1269
1315
|
const current = getMirrorState();
|
|
1270
1316
|
const statuses = current.type_status || {};
|
|
1271
|
-
statuses[type] = {
|
|
1317
|
+
statuses[type] = {
|
|
1318
|
+
status: status.status,
|
|
1319
|
+
...(status.applied_gen !== undefined ? { applied_gen: status.applied_gen } : {}),
|
|
1320
|
+
...(status.last_error !== undefined ? { last_error: status.last_error } : {})
|
|
1321
|
+
};
|
|
1272
1322
|
return setMirrorState({ type_status: JSON.stringify(statuses) });
|
|
1273
1323
|
}
|
|
1274
1324
|
|
|
@@ -1278,10 +1328,40 @@ export function createStore(path) {
|
|
|
1278
1328
|
return current.type_status || {};
|
|
1279
1329
|
}
|
|
1280
1330
|
|
|
1281
|
-
/**
|
|
1331
|
+
/** Run fn atomically: when the connection is already inside a transaction
|
|
1332
|
+
* (service.transaction's BEGIN), just run it — the outer COMMIT covers us.
|
|
1333
|
+
* Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
|
|
1334
|
+
* bump commit together: a crash between them can never leave a mutated store
|
|
1335
|
+
* with generation == applied (audit peer blocker 1, "crash window"). */
|
|
1336
|
+
function runAtomically(fn) {
|
|
1337
|
+
if (db.isTransaction) return fn();
|
|
1338
|
+
db.exec("BEGIN");
|
|
1339
|
+
try {
|
|
1340
|
+
const result = fn();
|
|
1341
|
+
db.exec("COMMIT");
|
|
1342
|
+
return result;
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
try { db.exec("ROLLBACK"); } catch { /* connection may be closed */ }
|
|
1345
|
+
throw error;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Bump the desired generation atomically (SQLite single-statement increment,
|
|
1350
|
+
* no SELECT-then-UPSERT race: peer blocker 3 lost 10 of 91 concurrent
|
|
1351
|
+
* increments under an 8-process probe). Returns the new mirror state.
|
|
1352
|
+
* Guards the upper bound: generation must stay within MAX_SAFE_INTEGER so
|
|
1353
|
+
* reads never hit ERR_OUT_OF_RANGE (peer blocker 6). */
|
|
1282
1354
|
function incrementGeneration() {
|
|
1283
|
-
|
|
1284
|
-
|
|
1355
|
+
return runAtomically(() => {
|
|
1356
|
+
// Ensure the singleton row exists before incrementing (UPDATE alone would
|
|
1357
|
+
// match nothing on a fresh DB).
|
|
1358
|
+
db.prepare("INSERT OR IGNORE INTO mirror_state (id) VALUES ('main')").run();
|
|
1359
|
+
const row = db.prepare(
|
|
1360
|
+
"UPDATE mirror_state SET generation = generation + 1 WHERE id = 'main' AND generation < ? RETURNING generation"
|
|
1361
|
+
).get(Number.MAX_SAFE_INTEGER);
|
|
1362
|
+
if (!row) throw new RangeError("mirror_state.generation exceeded MAX_SAFE_INTEGER");
|
|
1363
|
+
return getMirrorState();
|
|
1364
|
+
});
|
|
1285
1365
|
}
|
|
1286
1366
|
|
|
1287
1367
|
return {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 6 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.8",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
package/src/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;
|
package/src/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)。
|
|
@@ -603,37 +606,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
603
606
|
// 全量渲染
|
|
604
607
|
mirror.sync(reconcileHumanEdits(list));
|
|
605
608
|
|
|
606
|
-
// 成功:CAS/fence 绑定到本地 gen,旧 worker(gen
|
|
609
|
+
// 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截。
|
|
610
|
+
// 此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
|
|
607
611
|
try {
|
|
608
612
|
store.markMirrorCleanForGeneration(gen, now);
|
|
609
613
|
} catch (stateError) {
|
|
610
614
|
logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
|
|
615
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
611
616
|
}
|
|
612
|
-
// 逐 type 标记为
|
|
617
|
+
// 逐 type 标记为 committed(peer blocker 4: per-type receipt)
|
|
613
618
|
for (const type of coveredTypes) {
|
|
614
619
|
try {
|
|
615
|
-
store.setTypeStatus(type, {
|
|
620
|
+
store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
|
|
616
621
|
} catch (stateError) {
|
|
617
|
-
logger?.warn?.(`syncMirror: setTypeStatus(${type})
|
|
622
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) committed failed:`, stateError);
|
|
618
623
|
}
|
|
619
624
|
}
|
|
625
|
+
return { success: true };
|
|
620
626
|
} catch (error) {
|
|
621
627
|
const errMsg = error?.message ?? String(error);
|
|
622
628
|
logger?.warn?.("syncMirror failed:", error);
|
|
623
629
|
try {
|
|
624
|
-
// 债务绑定到新的一轮(desired generation
|
|
630
|
+
// 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
|
|
631
|
+
// generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
|
|
625
632
|
store.markMirrorDirty(errMsg, now);
|
|
626
633
|
} catch (stateError) {
|
|
627
634
|
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
628
635
|
}
|
|
629
|
-
// 逐 type 标记为
|
|
636
|
+
// 逐 type 标记为 failed(applied_gen 不动)
|
|
630
637
|
for (const type of coveredTypes) {
|
|
631
638
|
try {
|
|
632
|
-
store.setTypeStatus(type, {
|
|
639
|
+
store.setTypeStatus(type, { status: "failed", last_error: errMsg });
|
|
633
640
|
} catch (stateError) {
|
|
634
|
-
logger?.warn?.(`syncMirror: setTypeStatus(${type})
|
|
641
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
|
|
635
642
|
}
|
|
636
643
|
}
|
|
644
|
+
return { success: false, error: errMsg };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// afterSync: run syncMirror and surface a failure to the operator instead of
|
|
649
|
+
// swallowing it (peer blocker 2). The mirror debt has already been persisted
|
|
650
|
+
// by markMirrorDirty inside syncMirror, so a restart recovers — but the
|
|
651
|
+
// calling write path must not report clean while the mirror is known-stale.
|
|
652
|
+
function afterSync(label) {
|
|
653
|
+
const r = syncMirror();
|
|
654
|
+
if (!r?.success && !r?.deferred) {
|
|
655
|
+
logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
|
|
637
656
|
}
|
|
638
657
|
}
|
|
639
658
|
|
|
@@ -718,10 +737,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
718
737
|
success_at: state.success_at ?? null
|
|
719
738
|
};
|
|
720
739
|
} catch (error) {
|
|
721
|
-
// fail-safe
|
|
740
|
+
// fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
|
|
741
|
+
// 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
|
|
722
742
|
logger?.warn?.("getMirrorHealth failed:", error);
|
|
723
743
|
return {
|
|
724
|
-
dirty:
|
|
744
|
+
dirty: null,
|
|
725
745
|
last_error: error?.message ?? String(error),
|
|
726
746
|
last_attempt: null,
|
|
727
747
|
success_at: null
|
|
@@ -780,7 +800,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
780
800
|
getById: (id) => store.getById(id),
|
|
781
801
|
remove: (id) => {
|
|
782
802
|
store.remove(id);
|
|
783
|
-
|
|
803
|
+
afterSync("write");
|
|
784
804
|
notifyWrite();
|
|
785
805
|
},
|
|
786
806
|
update: (id, p, ctx = {}) => {
|
|
@@ -806,7 +826,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
806
826
|
memory_id: id
|
|
807
827
|
});
|
|
808
828
|
}
|
|
809
|
-
|
|
829
|
+
afterSync("write");
|
|
810
830
|
notifyWrite();
|
|
811
831
|
scheduleEmbed(updated);
|
|
812
832
|
return updated;
|
|
@@ -835,19 +855,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
835
855
|
memory_id: id
|
|
836
856
|
});
|
|
837
857
|
}
|
|
838
|
-
|
|
858
|
+
afterSync("write");
|
|
839
859
|
notifyWrite();
|
|
840
860
|
scheduleEmbed(updated);
|
|
841
861
|
return updated;
|
|
842
862
|
},
|
|
843
863
|
setForget: (id, f) => {
|
|
844
864
|
const updated = store.setForget(id, f);
|
|
845
|
-
|
|
865
|
+
afterSync("write");
|
|
846
866
|
return updated;
|
|
847
867
|
},
|
|
848
868
|
setArchived: (id, f) => {
|
|
849
869
|
const updated = store.setArchived(id, f);
|
|
850
|
-
|
|
870
|
+
afterSync("write");
|
|
851
871
|
return updated;
|
|
852
872
|
},
|
|
853
873
|
// autoDream audit trail: passthroughs deliberately bypass write hooks —
|
package/src/store.js
CHANGED
|
@@ -180,14 +180,19 @@ 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, -- 期望的同步轮次(desired)
|
|
184
|
-
applied_generation INTEGER NOT NULL DEFAULT 0, -- 已成功应用的轮次
|
|
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), -- 已成功应用的轮次
|
|
185
185
|
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
186
186
|
);
|
|
187
187
|
`;
|
|
188
188
|
|
|
189
189
|
const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
|
|
190
190
|
|
|
191
|
+
// Per-type mirror sync receipts (peer blocker 4): a type is either committed
|
|
192
|
+
// (file written + fence applied), failed (last sync round errored for it), or
|
|
193
|
+
// pending (still owed a write).
|
|
194
|
+
const VALID_TYPE_STATUS = new Set(["committed", "failed", "pending"]);
|
|
195
|
+
|
|
191
196
|
// Pure helpers: no shared module state.
|
|
192
197
|
|
|
193
198
|
function sanitizePage(limit, offset, defaultLimit) {
|
|
@@ -387,6 +392,10 @@ function parseJsonArray(raw) {
|
|
|
387
392
|
export function createStore(path) {
|
|
388
393
|
const db = new DatabaseSync(path);
|
|
389
394
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
395
|
+
// Concurrent writers (peer probe: 8 independent processes) must wait for the
|
|
396
|
+
// write lock instead of failing immediately with SQLITE_BUSY — otherwise the
|
|
397
|
+
// atomic generation increment loses whole writes, not just increments.
|
|
398
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
390
399
|
db.exec(SCHEMA);
|
|
391
400
|
|
|
392
401
|
// Schema migrations for legacy databases (idempotent).
|
|
@@ -467,10 +476,17 @@ export function createStore(path) {
|
|
|
467
476
|
const embedding = Array.isArray(memory.embedding) && memory.embedding.length
|
|
468
477
|
? JSON.stringify(memory.embedding)
|
|
469
478
|
: null;
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
479
|
+
runAtomically(() => {
|
|
480
|
+
db.prepare(
|
|
481
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
|
|
482
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
|
|
483
|
+
).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, now, now);
|
|
484
|
+
// desired generation bumped in the same transaction as the write: once
|
|
485
|
+
// this commits, generation > applied_generation, so a crash right after
|
|
486
|
+
// (before syncMirror) is caught by recoverMirror on restart (peer
|
|
487
|
+
// blocker 1). ROLLBACK on error rolls this back with the write.
|
|
488
|
+
incrementGeneration();
|
|
489
|
+
});
|
|
474
490
|
return getById(id);
|
|
475
491
|
}
|
|
476
492
|
|
|
@@ -486,24 +502,34 @@ export function createStore(path) {
|
|
|
486
502
|
const embedding = patch.embedding !== undefined
|
|
487
503
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
488
504
|
: existing.embedding ?? null;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
505
|
+
runAtomically(() => {
|
|
506
|
+
db.prepare(
|
|
507
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=? WHERE id=?`
|
|
508
|
+
).run(
|
|
509
|
+
type,
|
|
510
|
+
patch.title ?? existing.title,
|
|
511
|
+
patch.content ?? existing.content,
|
|
512
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
513
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
514
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
515
|
+
embedding,
|
|
516
|
+
now,
|
|
517
|
+
id
|
|
518
|
+
);
|
|
519
|
+
// Desired generation bumped in the same transaction as the update (peer
|
|
520
|
+
// blocker 1: crash between write and sync must still be recoverable).
|
|
521
|
+
incrementGeneration();
|
|
522
|
+
});
|
|
502
523
|
return getById(id);
|
|
503
524
|
}
|
|
504
525
|
|
|
505
526
|
function remove(id) {
|
|
506
|
-
|
|
527
|
+
runAtomically(() => {
|
|
528
|
+
db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
529
|
+
// Mirror sync must reflect the deletion; bump desired generation so a
|
|
530
|
+
// crash between the delete and syncMirror leaves a recoverable debt.
|
|
531
|
+
incrementGeneration();
|
|
532
|
+
});
|
|
507
533
|
}
|
|
508
534
|
|
|
509
535
|
/**
|
|
@@ -542,18 +568,26 @@ export function createStore(path) {
|
|
|
542
568
|
expectedUpdatedAt
|
|
543
569
|
);
|
|
544
570
|
if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
|
|
571
|
+
// Only bump desired generation on a successful CAS — a miss writes nothing.
|
|
572
|
+
runAtomically(() => { incrementGeneration(); });
|
|
545
573
|
return getById(id);
|
|
546
574
|
}
|
|
547
575
|
|
|
548
576
|
function setForget(id, forgotten) {
|
|
549
|
-
|
|
550
|
-
.
|
|
577
|
+
runAtomically(() => {
|
|
578
|
+
db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
|
|
579
|
+
.run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
|
|
580
|
+
incrementGeneration();
|
|
581
|
+
});
|
|
551
582
|
return getById(id);
|
|
552
583
|
}
|
|
553
584
|
|
|
554
585
|
function setArchived(id, archived) {
|
|
555
|
-
|
|
556
|
-
.
|
|
586
|
+
runAtomically(() => {
|
|
587
|
+
db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
|
|
588
|
+
.run(archived ? 1 : 0, nowIso(), id);
|
|
589
|
+
incrementGeneration();
|
|
590
|
+
});
|
|
557
591
|
return getById(id);
|
|
558
592
|
}
|
|
559
593
|
|
|
@@ -1189,7 +1223,10 @@ export function createStore(path) {
|
|
|
1189
1223
|
if (key === "dirty") {
|
|
1190
1224
|
value = value ? 1 : 0;
|
|
1191
1225
|
} else if (key === "generation" || key === "applied_generation") {
|
|
1192
|
-
value = Math.trunc(Number(value))
|
|
1226
|
+
value = Math.trunc(Number(value));
|
|
1227
|
+
if (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
|
1228
|
+
throw new RangeError(`mirror_state.${key} out of range: ${value}`);
|
|
1229
|
+
}
|
|
1193
1230
|
} else if (key === "type_status" && value != null && typeof value !== "string") {
|
|
1194
1231
|
value = JSON.stringify(value);
|
|
1195
1232
|
}
|
|
@@ -1220,12 +1257,15 @@ export function createStore(path) {
|
|
|
1220
1257
|
* recent as this one may.
|
|
1221
1258
|
*/
|
|
1222
1259
|
function markMirrorDirty(error, now) {
|
|
1223
|
-
|
|
1260
|
+
// Bump the desired generation atomically first — the new debt must be bound
|
|
1261
|
+
// to a fresh round so a stale worker cannot fence-clean it. Even if this
|
|
1262
|
+
// write fails (peer blocker 2), generation still advanced, so recoverMirror
|
|
1263
|
+
// sees generation > applied_generation and retries rather than false-clean.
|
|
1264
|
+
incrementGeneration();
|
|
1224
1265
|
return setMirrorState({
|
|
1225
1266
|
dirty: 1,
|
|
1226
1267
|
last_error: error,
|
|
1227
|
-
last_attempt: now ?? nowIso()
|
|
1228
|
-
generation: (current.generation || 0) + 1
|
|
1268
|
+
last_attempt: now ?? nowIso()
|
|
1229
1269
|
});
|
|
1230
1270
|
}
|
|
1231
1271
|
|
|
@@ -1262,13 +1302,23 @@ export function createStore(path) {
|
|
|
1262
1302
|
|
|
1263
1303
|
/**
|
|
1264
1304
|
* Record per-type mirror status (partial success bookkeeping). `status` is a
|
|
1265
|
-
*
|
|
1266
|
-
* entry for `type` (other types untouched).
|
|
1305
|
+
* patch {status: 'committed'|'failed'|'pending', applied_gen?, last_error?}
|
|
1306
|
+
* replacing the entry for `type` (other types untouched). Standardizing on an
|
|
1307
|
+
* explicit status gives per-type committed/failed/pending receipts — a type
|
|
1308
|
+
* whose file was written while a sibling failed is recorded as such, not
|
|
1309
|
+
* collapsed into a bulk "dirty" (peer blocker 4). Returns the updated state.
|
|
1267
1310
|
*/
|
|
1268
1311
|
function setTypeStatus(type, status) {
|
|
1312
|
+
if (!VALID_TYPE_STATUS.has(status?.status)) {
|
|
1313
|
+
throw new TypeError(`setTypeStatus: status must be one of committed|failed|pending, got ${status?.status}`);
|
|
1314
|
+
}
|
|
1269
1315
|
const current = getMirrorState();
|
|
1270
1316
|
const statuses = current.type_status || {};
|
|
1271
|
-
statuses[type] = {
|
|
1317
|
+
statuses[type] = {
|
|
1318
|
+
status: status.status,
|
|
1319
|
+
...(status.applied_gen !== undefined ? { applied_gen: status.applied_gen } : {}),
|
|
1320
|
+
...(status.last_error !== undefined ? { last_error: status.last_error } : {})
|
|
1321
|
+
};
|
|
1272
1322
|
return setMirrorState({ type_status: JSON.stringify(statuses) });
|
|
1273
1323
|
}
|
|
1274
1324
|
|
|
@@ -1278,10 +1328,40 @@ export function createStore(path) {
|
|
|
1278
1328
|
return current.type_status || {};
|
|
1279
1329
|
}
|
|
1280
1330
|
|
|
1281
|
-
/**
|
|
1331
|
+
/** Run fn atomically: when the connection is already inside a transaction
|
|
1332
|
+
* (service.transaction's BEGIN), just run it — the outer COMMIT covers us.
|
|
1333
|
+
* Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
|
|
1334
|
+
* bump commit together: a crash between them can never leave a mutated store
|
|
1335
|
+
* with generation == applied (audit peer blocker 1, "crash window"). */
|
|
1336
|
+
function runAtomically(fn) {
|
|
1337
|
+
if (db.isTransaction) return fn();
|
|
1338
|
+
db.exec("BEGIN");
|
|
1339
|
+
try {
|
|
1340
|
+
const result = fn();
|
|
1341
|
+
db.exec("COMMIT");
|
|
1342
|
+
return result;
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
try { db.exec("ROLLBACK"); } catch { /* connection may be closed */ }
|
|
1345
|
+
throw error;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Bump the desired generation atomically (SQLite single-statement increment,
|
|
1350
|
+
* no SELECT-then-UPSERT race: peer blocker 3 lost 10 of 91 concurrent
|
|
1351
|
+
* increments under an 8-process probe). Returns the new mirror state.
|
|
1352
|
+
* Guards the upper bound: generation must stay within MAX_SAFE_INTEGER so
|
|
1353
|
+
* reads never hit ERR_OUT_OF_RANGE (peer blocker 6). */
|
|
1282
1354
|
function incrementGeneration() {
|
|
1283
|
-
|
|
1284
|
-
|
|
1355
|
+
return runAtomically(() => {
|
|
1356
|
+
// Ensure the singleton row exists before incrementing (UPDATE alone would
|
|
1357
|
+
// match nothing on a fresh DB).
|
|
1358
|
+
db.prepare("INSERT OR IGNORE INTO mirror_state (id) VALUES ('main')").run();
|
|
1359
|
+
const row = db.prepare(
|
|
1360
|
+
"UPDATE mirror_state SET generation = generation + 1 WHERE id = 'main' AND generation < ? RETURNING generation"
|
|
1361
|
+
).get(Number.MAX_SAFE_INTEGER);
|
|
1362
|
+
if (!row) throw new RangeError("mirror_state.generation exceeded MAX_SAFE_INTEGER");
|
|
1363
|
+
return getMirrorState();
|
|
1364
|
+
});
|
|
1285
1365
|
}
|
|
1286
1366
|
|
|
1287
1367
|
return {
|
|
@@ -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,8 +457,8 @@ 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 });
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
});
|