@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 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
- 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)。
@@ -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 标记为 clean
617
+ // 逐 type 标记为 committed(peer blocker 4: per-type receipt)
613
618
  for (const type of coveredTypes) {
614
619
  try {
615
- store.setTypeStatus(type, { dirty: false, applied_gen: gen, last_error: null });
620
+ store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
616
621
  } catch (stateError) {
617
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) clean failed:`, stateError);
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 +1)
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 标记为 dirty(applied_gen 不动)
636
+ // 逐 type 标记为 failed(applied_gen 不动)
630
637
  for (const type of coveredTypes) {
631
638
  try {
632
- store.setTypeStatus(type, { dirty: true, last_error: errMsg });
639
+ store.setTypeStatus(type, { status: "failed", last_error: errMsg });
633
640
  } catch (stateError) {
634
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) dirty failed:`, stateError);
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: false,
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
- syncMirror();
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
- syncMirror();
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
- syncMirror();
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
- syncMirror();
865
+ afterSync("write");
846
866
  return updated;
847
867
  },
848
868
  setArchived: (id, f) => {
849
869
  const updated = store.setArchived(id, f);
850
- syncMirror();
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
- 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);
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
- 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
- );
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
- db.prepare("DELETE FROM memories WHERE id = ?").run(id);
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
- db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
550
- .run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
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
- db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
556
- .run(archived ? 1 : 0, nowIso(), id);
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)) || 0;
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
- const current = getMirrorState();
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
- * partial patch {dirty?, applied_gen?, last_error?} merged into the existing
1266
- * entry for `type` (other types untouched). Returns the updated full state.
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] = { ...(statuses[type] || {}), ...status };
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
- /** Bump the desired generation (new sync round), returning the new state. */
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
- const current = getMirrorState();
1284
- return setMirrorState({ generation: (current.generation || 0) + 1 });
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.7",
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
- 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)。
@@ -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 标记为 clean
617
+ // 逐 type 标记为 committed(peer blocker 4: per-type receipt)
613
618
  for (const type of coveredTypes) {
614
619
  try {
615
- store.setTypeStatus(type, { dirty: false, applied_gen: gen, last_error: null });
620
+ store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
616
621
  } catch (stateError) {
617
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) clean failed:`, stateError);
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 +1)
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 标记为 dirty(applied_gen 不动)
636
+ // 逐 type 标记为 failed(applied_gen 不动)
630
637
  for (const type of coveredTypes) {
631
638
  try {
632
- store.setTypeStatus(type, { dirty: true, last_error: errMsg });
639
+ store.setTypeStatus(type, { status: "failed", last_error: errMsg });
633
640
  } catch (stateError) {
634
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) dirty failed:`, stateError);
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: false,
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
- syncMirror();
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
- syncMirror();
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
- syncMirror();
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
- syncMirror();
865
+ afterSync("write");
846
866
  return updated;
847
867
  },
848
868
  setArchived: (id, f) => {
849
869
  const updated = store.setArchived(id, f);
850
- syncMirror();
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
- 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);
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
- 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
- );
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
- db.prepare("DELETE FROM memories WHERE id = ?").run(id);
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
- db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
550
- .run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
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
- db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
556
- .run(archived ? 1 : 0, nowIso(), id);
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)) || 0;
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
- const current = getMirrorState();
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
- * partial patch {dirty?, applied_gen?, last_error?} merged into the existing
1266
- * entry for `type` (other types untouched). Returns the updated full state.
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] = { ...(statuses[type] || {}), ...status };
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
- /** Bump the desired generation (new sync round), returning the new state. */
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
- const current = getMirrorState();
1284
- return setMirrorState({ generation: (current.generation || 0) + 1 });
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 type 合并,partial patch 保留未传字段", () => {
144
+ test("V0.3.6-D1: setTypeStatus 记录逐 type committed/failed/pending 回执(peer blocker 4)", () => {
145
145
  const { dir, store } = setup();
146
146
  try {
147
- const s1 = store.setTypeStatus("project", { dirty: true, last_error: "e1" });
148
- assert.equal(s1.type_status.project.dirty, true);
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.applied_gen, 7, "partial 合并到同 type");
153
- assert.equal(s2.type_status.project.dirty, true, "未传字段保留");
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", { dirty: false, applied_gen: 7 });
157
- assert.equal(s3.type_status.decision.applied_gen, 7, "另一 type 独立");
158
- assert.equal(s3.type_status.project.dirty, true, "同 type 不受影响");
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: { dirty: true, last_error: "e1", applied_gen: 7 },
163
- decision: { dirty: false, applied_gen: 7 }
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.applied_gen === 7, "type_status 必须以 JSON 落库");
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 记录 clean(applied_gen=gen)", () => {
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.dirty, false);
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 记录 dirty,recoverMirror 后追到最新 applied", () => {
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
- // 第一次成功建立 clean type_status
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.dirty, false);
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.dirty, false);
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", { dirty: true, applied_gen: 0 });
458
- assert.equal(store.getTypeStatus().project.dirty, true, "迁移库上 setTypeStatus 正常工作");
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
+ });