@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/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
- try {
388
- syncMirror();
389
- } catch (error) {
390
- logger?.warn?.("syncMirror failed after transaction:", error);
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
- syncMirror();
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
- syncMirror();
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
- syncMirror();
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
- const state = store.incrementGeneration();
586
- gen = state.generation;
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: incrementGeneration failed:", stateError);
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
- mirror.sync(reconcileHumanEdits(list));
605
-
606
- // 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截
607
- try {
608
- store.markMirrorCleanForGeneration(gen, now);
609
- } catch (stateError) {
610
- logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
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
- // 逐 type 标记为 clean
613
- for (const type of coveredTypes) {
629
+
630
+ // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
631
+ // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
632
+ if (allOk) {
614
633
  try {
615
- store.setTypeStatus(type, { dirty: false, applied_gen: gen, last_error: null });
634
+ store.markMirrorCleanForGeneration(gen, now);
616
635
  } catch (stateError) {
617
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) clean failed:`, stateError);
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 +1)
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 标记为 dirty(applied_gen 不动)
663
+ // 逐 type 标记为 failed(applied_gen 不动)
630
664
  for (const type of coveredTypes) {
631
665
  try {
632
- store.setTypeStatus(type, { dirty: true, last_error: errMsg });
666
+ store.setTypeStatus(type, { status: "failed", last_error: errMsg });
633
667
  } catch (stateError) {
634
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) dirty failed:`, stateError);
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: false,
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
- syncMirror();
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
- syncMirror();
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
- syncMirror();
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
- syncMirror();
914
+ afterSync("write");
846
915
  return updated;
847
916
  },
848
917
  setArchived: (id, f) => {
849
918
  const updated = store.setArchived(id, f);
850
- syncMirror();
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
- db.prepare(
471
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
472
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
473
- ).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, now, now);
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
- db.prepare(
490
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=? WHERE id=?`
491
- ).run(
492
- type,
493
- patch.title ?? existing.title,
494
- patch.content ?? existing.content,
495
- JSON.stringify(patch.tags ?? existing.tags),
496
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
497
- patch.source !== undefined ? patch.source : (existing.source ?? null),
498
- embedding,
499
- now,
500
- id
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
- db.prepare("DELETE FROM memories WHERE id = ?").run(id);
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
- const result = db.prepare(
530
- `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=?
531
- WHERE id=? AND updated_at=?`
532
- ).run(
533
- type,
534
- patch.title ?? existing.title,
535
- patch.content ?? existing.content,
536
- JSON.stringify(patch.tags ?? existing.tags),
537
- Number.isInteger(patch.importance) ? patch.importance : existing.importance,
538
- patch.source !== undefined ? patch.source : (existing.source ?? null),
539
- embedding,
540
- now,
541
- id,
542
- expectedUpdatedAt
543
- );
544
- if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
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
- db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
550
- .run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
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
- db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
556
- .run(archived ? 1 : 0, nowIso(), id);
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
- value = Math.trunc(Number(value)) || 0;
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
- const current = getMirrorState();
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
- * partial patch {dirty?, applied_gen?, last_error?} merged into the existing
1266
- * entry for `type` (other types untouched). Returns the updated full state.
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] = { ...(statuses[type] || {}), ...status };
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
- /** Bump the desired generation (new sync round), returning the new state. */
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
- const current = getMirrorState();
1284
- return setMirrorState({ generation: (current.generation || 0) + 1 });
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 {