@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/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)。
|
|
@@ -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 —
|
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 AND generation = CAST(generation AS INTEGER)), -- 期望的同步轮次(desired)
|
|
184
|
+
applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
|
|
185
185
|
type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
|
|
186
186
|
);
|
|
187
187
|
`;
|
|
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) {
|
|
@@ -386,6 +391,13 @@ function parseJsonArray(raw) {
|
|
|
386
391
|
|
|
387
392
|
export function createStore(path) {
|
|
388
393
|
const db = new DatabaseSync(path);
|
|
394
|
+
// Set busy_timeout BEFORE the journal-mode switch (audit peer: 8-process WAL
|
|
395
|
+
// init). Switching a fresh DB to WAL takes an exclusive lock; when several
|
|
396
|
+
// processes open the same path simultaneously, that lock can fail with
|
|
397
|
+
// SQLITE_BUSY before the timeout is armed. With the timeout installed first,
|
|
398
|
+
// the WAL transition (and every later write) blocks and retries instead of
|
|
399
|
+
// failing outright, so concurrent init converges to a stable 447/447.
|
|
400
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
389
401
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
390
402
|
db.exec(SCHEMA);
|
|
391
403
|
|
|
@@ -417,6 +429,24 @@ export function createStore(path) {
|
|
|
417
429
|
db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
|
|
418
430
|
}
|
|
419
431
|
|
|
432
|
+
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
433
|
+
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
434
|
+
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
|
|
435
|
+
// round — so surface it as a hard error on open instead of silently reading it
|
|
436
|
+
// as a coherent generation. Fail-closed: the operator must repair or reset the
|
|
437
|
+
// state row rather than continue with a lie.
|
|
438
|
+
for (const col of ["generation", "applied_generation"]) {
|
|
439
|
+
const bad = db.prepare(
|
|
440
|
+
`SELECT id FROM mirror_state WHERE ${col} IS NOT NULL AND ${col} != CAST(${col} AS INTEGER) LIMIT 1`
|
|
441
|
+
).get();
|
|
442
|
+
if (bad) {
|
|
443
|
+
throw new RangeError(
|
|
444
|
+
`mirror_state.${col} holds a non-integer value (legacy dirty state); ` +
|
|
445
|
+
`repair or reset the row before opening this database`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
420
450
|
// Per-instance monotonic timestamp guard: consecutive writes within the same
|
|
421
451
|
// millisecond must still produce strictly increasing timestamps (test asserts
|
|
422
452
|
// updated_at != created_at). State lives in the store closure, not module scope.
|
|
@@ -467,10 +497,17 @@ export function createStore(path) {
|
|
|
467
497
|
const embedding = Array.isArray(memory.embedding) && memory.embedding.length
|
|
468
498
|
? JSON.stringify(memory.embedding)
|
|
469
499
|
: null;
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
500
|
+
runAtomically(() => {
|
|
501
|
+
db.prepare(
|
|
502
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
|
|
503
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
|
|
504
|
+
).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, now, now);
|
|
505
|
+
// desired generation bumped in the same transaction as the write: once
|
|
506
|
+
// this commits, generation > applied_generation, so a crash right after
|
|
507
|
+
// (before syncMirror) is caught by recoverMirror on restart (peer
|
|
508
|
+
// blocker 1). ROLLBACK on error rolls this back with the write.
|
|
509
|
+
incrementGeneration();
|
|
510
|
+
});
|
|
474
511
|
return getById(id);
|
|
475
512
|
}
|
|
476
513
|
|
|
@@ -486,24 +523,34 @@ export function createStore(path) {
|
|
|
486
523
|
const embedding = patch.embedding !== undefined
|
|
487
524
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
488
525
|
: existing.embedding ?? null;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
526
|
+
runAtomically(() => {
|
|
527
|
+
db.prepare(
|
|
528
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=? WHERE id=?`
|
|
529
|
+
).run(
|
|
530
|
+
type,
|
|
531
|
+
patch.title ?? existing.title,
|
|
532
|
+
patch.content ?? existing.content,
|
|
533
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
534
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
535
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
536
|
+
embedding,
|
|
537
|
+
now,
|
|
538
|
+
id
|
|
539
|
+
);
|
|
540
|
+
// Desired generation bumped in the same transaction as the update (peer
|
|
541
|
+
// blocker 1: crash between write and sync must still be recoverable).
|
|
542
|
+
incrementGeneration();
|
|
543
|
+
});
|
|
502
544
|
return getById(id);
|
|
503
545
|
}
|
|
504
546
|
|
|
505
547
|
function remove(id) {
|
|
506
|
-
|
|
548
|
+
runAtomically(() => {
|
|
549
|
+
db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
550
|
+
// Mirror sync must reflect the deletion; bump desired generation so a
|
|
551
|
+
// crash between the delete and syncMirror leaves a recoverable debt.
|
|
552
|
+
incrementGeneration();
|
|
553
|
+
});
|
|
507
554
|
}
|
|
508
555
|
|
|
509
556
|
/**
|
|
@@ -526,34 +573,53 @@ export function createStore(path) {
|
|
|
526
573
|
const embedding = patch.embedding !== undefined
|
|
527
574
|
? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
|
|
528
575
|
: existing.embedding ?? null;
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
576
|
+
// The CAS UPDATE and the desired-generation bump must commit together (audit
|
|
577
|
+
// peer A): if the UPDATE autocommits first and the process dies before the
|
|
578
|
+
// increment, the store is mutated while generation == applied_generation and
|
|
579
|
+
// dirty == false — recoverMirror sees no debt and the mirror stays stale.
|
|
580
|
+
// Wrapping both in one transaction means a CAS miss rolls back cleanly too
|
|
581
|
+
// (no write, no generation bump).
|
|
582
|
+
let applied = false;
|
|
583
|
+
runAtomically(() => {
|
|
584
|
+
const result = db.prepare(
|
|
585
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
|
|
586
|
+
WHERE id=? AND updated_at=?`
|
|
587
|
+
).run(
|
|
588
|
+
type,
|
|
589
|
+
patch.title ?? existing.title,
|
|
590
|
+
patch.content ?? existing.content,
|
|
591
|
+
JSON.stringify(patch.tags ?? existing.tags),
|
|
592
|
+
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
593
|
+
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
594
|
+
embedding,
|
|
595
|
+
now,
|
|
596
|
+
id,
|
|
597
|
+
expectedUpdatedAt
|
|
598
|
+
);
|
|
599
|
+
if (result.changes === 0) return; // CAS miss: a concurrent write won
|
|
600
|
+
// Only bump desired generation on a successful CAS — a miss writes nothing.
|
|
601
|
+
incrementGeneration();
|
|
602
|
+
applied = true;
|
|
603
|
+
});
|
|
604
|
+
if (!applied) return undefined;
|
|
545
605
|
return getById(id);
|
|
546
606
|
}
|
|
547
607
|
|
|
548
608
|
function setForget(id, forgotten) {
|
|
549
|
-
|
|
550
|
-
.
|
|
609
|
+
runAtomically(() => {
|
|
610
|
+
db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
|
|
611
|
+
.run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
|
|
612
|
+
incrementGeneration();
|
|
613
|
+
});
|
|
551
614
|
return getById(id);
|
|
552
615
|
}
|
|
553
616
|
|
|
554
617
|
function setArchived(id, archived) {
|
|
555
|
-
|
|
556
|
-
.
|
|
618
|
+
runAtomically(() => {
|
|
619
|
+
db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
|
|
620
|
+
.run(archived ? 1 : 0, nowIso(), id);
|
|
621
|
+
incrementGeneration();
|
|
622
|
+
});
|
|
557
623
|
return getById(id);
|
|
558
624
|
}
|
|
559
625
|
|
|
@@ -1189,7 +1255,17 @@ export function createStore(path) {
|
|
|
1189
1255
|
if (key === "dirty") {
|
|
1190
1256
|
value = value ? 1 : 0;
|
|
1191
1257
|
} else if (key === "generation" || key === "applied_generation") {
|
|
1192
|
-
|
|
1258
|
+
// Fail-closed integer enforcement (audit peer F): never truncate. A
|
|
1259
|
+
// fractional value like 1.5 previously passed the JS gate via
|
|
1260
|
+
// Math.trunc while SQLite's CHECK (>= 0) silently accepted it too, so a
|
|
1261
|
+
// dirty legacy row could carry a non-integer generation that reads as a
|
|
1262
|
+
// coherent applied round. Reject non-integers outright — the caller must
|
|
1263
|
+
// pass a whole number, and a stale dirty value stays visible instead of
|
|
1264
|
+
// being "repaired" into a misleading clean integer.
|
|
1265
|
+
value = Number(value);
|
|
1266
|
+
if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
|
|
1267
|
+
throw new RangeError(`mirror_state.${key} out of range: ${value}`);
|
|
1268
|
+
}
|
|
1193
1269
|
} else if (key === "type_status" && value != null && typeof value !== "string") {
|
|
1194
1270
|
value = JSON.stringify(value);
|
|
1195
1271
|
}
|
|
@@ -1220,12 +1296,15 @@ export function createStore(path) {
|
|
|
1220
1296
|
* recent as this one may.
|
|
1221
1297
|
*/
|
|
1222
1298
|
function markMirrorDirty(error, now) {
|
|
1223
|
-
|
|
1299
|
+
// Bump the desired generation atomically first — the new debt must be bound
|
|
1300
|
+
// to a fresh round so a stale worker cannot fence-clean it. Even if this
|
|
1301
|
+
// write fails (peer blocker 2), generation still advanced, so recoverMirror
|
|
1302
|
+
// sees generation > applied_generation and retries rather than false-clean.
|
|
1303
|
+
incrementGeneration();
|
|
1224
1304
|
return setMirrorState({
|
|
1225
1305
|
dirty: 1,
|
|
1226
1306
|
last_error: error,
|
|
1227
|
-
last_attempt: now ?? nowIso()
|
|
1228
|
-
generation: (current.generation || 0) + 1
|
|
1307
|
+
last_attempt: now ?? nowIso()
|
|
1229
1308
|
});
|
|
1230
1309
|
}
|
|
1231
1310
|
|
|
@@ -1262,13 +1341,23 @@ export function createStore(path) {
|
|
|
1262
1341
|
|
|
1263
1342
|
/**
|
|
1264
1343
|
* Record per-type mirror status (partial success bookkeeping). `status` is a
|
|
1265
|
-
*
|
|
1266
|
-
* entry for `type` (other types untouched).
|
|
1344
|
+
* patch {status: 'committed'|'failed'|'pending', applied_gen?, last_error?}
|
|
1345
|
+
* replacing the entry for `type` (other types untouched). Standardizing on an
|
|
1346
|
+
* explicit status gives per-type committed/failed/pending receipts — a type
|
|
1347
|
+
* whose file was written while a sibling failed is recorded as such, not
|
|
1348
|
+
* collapsed into a bulk "dirty" (peer blocker 4). Returns the updated state.
|
|
1267
1349
|
*/
|
|
1268
1350
|
function setTypeStatus(type, status) {
|
|
1351
|
+
if (!VALID_TYPE_STATUS.has(status?.status)) {
|
|
1352
|
+
throw new TypeError(`setTypeStatus: status must be one of committed|failed|pending, got ${status?.status}`);
|
|
1353
|
+
}
|
|
1269
1354
|
const current = getMirrorState();
|
|
1270
1355
|
const statuses = current.type_status || {};
|
|
1271
|
-
statuses[type] = {
|
|
1356
|
+
statuses[type] = {
|
|
1357
|
+
status: status.status,
|
|
1358
|
+
...(status.applied_gen !== undefined ? { applied_gen: status.applied_gen } : {}),
|
|
1359
|
+
...(status.last_error !== undefined ? { last_error: status.last_error } : {})
|
|
1360
|
+
};
|
|
1272
1361
|
return setMirrorState({ type_status: JSON.stringify(statuses) });
|
|
1273
1362
|
}
|
|
1274
1363
|
|
|
@@ -1278,10 +1367,40 @@ export function createStore(path) {
|
|
|
1278
1367
|
return current.type_status || {};
|
|
1279
1368
|
}
|
|
1280
1369
|
|
|
1281
|
-
/**
|
|
1370
|
+
/** Run fn atomically: when the connection is already inside a transaction
|
|
1371
|
+
* (service.transaction's BEGIN), just run it — the outer COMMIT covers us.
|
|
1372
|
+
* Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
|
|
1373
|
+
* bump commit together: a crash between them can never leave a mutated store
|
|
1374
|
+
* with generation == applied (audit peer blocker 1, "crash window"). */
|
|
1375
|
+
function runAtomically(fn) {
|
|
1376
|
+
if (db.isTransaction) return fn();
|
|
1377
|
+
db.exec("BEGIN");
|
|
1378
|
+
try {
|
|
1379
|
+
const result = fn();
|
|
1380
|
+
db.exec("COMMIT");
|
|
1381
|
+
return result;
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
try { db.exec("ROLLBACK"); } catch { /* connection may be closed */ }
|
|
1384
|
+
throw error;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** Bump the desired generation atomically (SQLite single-statement increment,
|
|
1389
|
+
* no SELECT-then-UPSERT race: peer blocker 3 lost 10 of 91 concurrent
|
|
1390
|
+
* increments under an 8-process probe). Returns the new mirror state.
|
|
1391
|
+
* Guards the upper bound: generation must stay within MAX_SAFE_INTEGER so
|
|
1392
|
+
* reads never hit ERR_OUT_OF_RANGE (peer blocker 6). */
|
|
1282
1393
|
function incrementGeneration() {
|
|
1283
|
-
|
|
1284
|
-
|
|
1394
|
+
return runAtomically(() => {
|
|
1395
|
+
// Ensure the singleton row exists before incrementing (UPDATE alone would
|
|
1396
|
+
// match nothing on a fresh DB).
|
|
1397
|
+
db.prepare("INSERT OR IGNORE INTO mirror_state (id) VALUES ('main')").run();
|
|
1398
|
+
const row = db.prepare(
|
|
1399
|
+
"UPDATE mirror_state SET generation = generation + 1 WHERE id = 'main' AND generation < ? RETURNING generation"
|
|
1400
|
+
).get(Number.MAX_SAFE_INTEGER);
|
|
1401
|
+
if (!row) throw new RangeError("mirror_state.generation exceeded MAX_SAFE_INTEGER");
|
|
1402
|
+
return getMirrorState();
|
|
1403
|
+
});
|
|
1285
1404
|
}
|
|
1286
1405
|
|
|
1287
1406
|
return {
|