@modusensus/dsh-mneme 0.3.7 → 0.4.1

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/store.js CHANGED
@@ -180,13 +180,18 @@ 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
- const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
189
+ const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
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"]);
190
195
 
191
196
  // Pure helpers: no shared module state.
192
197
 
@@ -222,7 +227,12 @@ function toRow(row) {
222
227
  archived: row.archived === 1,
223
228
  source: row.source ?? undefined,
224
229
  created_at: row.created_at,
225
- updated_at: row.updated_at
230
+ updated_at: row.updated_at,
231
+ // Sleep (v0.4.1): last_accessed_at drives the unrecalled tiering;
232
+ // _full_content holds the pre-demotion body for a memory reduced to its
233
+ // summary. Both are internal — the mirror render must not expose them.
234
+ last_accessed_at: row.last_accessed_at ?? undefined,
235
+ _full_content: row._full_content ?? undefined
226
236
  };
227
237
  }
228
238
 
@@ -243,7 +253,8 @@ function toDreamRun(row) {
243
253
  applied: row.applied,
244
254
  summary_stored: row.summary_stored === 1,
245
255
  receipt: row.receipt,
246
- policy_epoch: row.policy_epoch ?? 0
256
+ policy_epoch: row.policy_epoch ?? 0,
257
+ run_type: row.run_type ?? "auto"
247
258
  };
248
259
  }
249
260
 
@@ -387,6 +398,10 @@ function parseJsonArray(raw) {
387
398
  export function createStore(path) {
388
399
  const db = new DatabaseSync(path);
389
400
  db.exec("PRAGMA journal_mode = WAL;");
401
+ // Concurrent writers (peer probe: 8 independent processes) must wait for the
402
+ // write lock instead of failing immediately with SQLITE_BUSY — otherwise the
403
+ // atomic generation increment loses whole writes, not just increments.
404
+ db.exec("PRAGMA busy_timeout = 5000;");
390
405
  db.exec(SCHEMA);
391
406
 
392
407
  // Schema migrations for legacy databases (idempotent).
@@ -403,6 +418,19 @@ export function createStore(path) {
403
418
  if (!dreamCols.includes("policy_epoch")) {
404
419
  db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
405
420
  }
421
+ if (!dreamCols.includes("run_type")) {
422
+ db.exec("ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
423
+ }
424
+
425
+ // Sleep (v0.4.1) columns: last_accessed_at tracks recall/inject touch for the
426
+ // "unrecalled N days → demote/archive" tiering; _full_content holds the pre-demotion
427
+ // body when a memory is reduced to its summary. Both are additive-only.
428
+ if (!columns.includes("last_accessed_at")) {
429
+ db.exec("ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
430
+ }
431
+ if (!columns.includes("_full_content")) {
432
+ db.exec("ALTER TABLE memories ADD COLUMN _full_content TEXT");
433
+ }
406
434
 
407
435
  // Legacy mirror_state without v0.3.6 generation columns → add each missing
408
436
  // column idempotently (old DBs open cleanly, no data loss).
@@ -467,10 +495,17 @@ export function createStore(path) {
467
495
  const embedding = Array.isArray(memory.embedding) && memory.embedding.length
468
496
  ? JSON.stringify(memory.embedding)
469
497
  : 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);
498
+ runAtomically(() => {
499
+ db.prepare(
500
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, source, embedding, created_at, updated_at)
501
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
502
+ ).run(id, type, memory.title, memory.content, tags, importance, memory.source ?? null, embedding, now, now);
503
+ // desired generation bumped in the same transaction as the write: once
504
+ // this commits, generation > applied_generation, so a crash right after
505
+ // (before syncMirror) is caught by recoverMirror on restart (peer
506
+ // blocker 1). ROLLBACK on error rolls this back with the write.
507
+ incrementGeneration();
508
+ });
474
509
  return getById(id);
475
510
  }
476
511
 
@@ -486,24 +521,34 @@ export function createStore(path) {
486
521
  const embedding = patch.embedding !== undefined
487
522
  ? (Array.isArray(patch.embedding) && patch.embedding.length ? JSON.stringify(patch.embedding) : null)
488
523
  : 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
- );
524
+ runAtomically(() => {
525
+ db.prepare(
526
+ `UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, embedding=?, updated_at=? WHERE id=?`
527
+ ).run(
528
+ type,
529
+ patch.title ?? existing.title,
530
+ patch.content ?? existing.content,
531
+ JSON.stringify(patch.tags ?? existing.tags),
532
+ Number.isInteger(patch.importance) ? patch.importance : existing.importance,
533
+ patch.source !== undefined ? patch.source : (existing.source ?? null),
534
+ embedding,
535
+ now,
536
+ id
537
+ );
538
+ // Desired generation bumped in the same transaction as the update (peer
539
+ // blocker 1: crash between write and sync must still be recoverable).
540
+ incrementGeneration();
541
+ });
502
542
  return getById(id);
503
543
  }
504
544
 
505
545
  function remove(id) {
506
- db.prepare("DELETE FROM memories WHERE id = ?").run(id);
546
+ runAtomically(() => {
547
+ db.prepare("DELETE FROM memories WHERE id = ?").run(id);
548
+ // Mirror sync must reflect the deletion; bump desired generation so a
549
+ // crash between the delete and syncMirror leaves a recoverable debt.
550
+ incrementGeneration();
551
+ });
507
552
  }
508
553
 
509
554
  /**
@@ -542,18 +587,68 @@ export function createStore(path) {
542
587
  expectedUpdatedAt
543
588
  );
544
589
  if (result.changes === 0) return undefined; // CAS miss: a concurrent write won
590
+ // Only bump desired generation on a successful CAS — a miss writes nothing.
591
+ runAtomically(() => { incrementGeneration(); });
545
592
  return getById(id);
546
593
  }
547
594
 
548
595
  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);
596
+ runAtomically(() => {
597
+ db.prepare("UPDATE memories SET forgotten = ?, updated_at = ? WHERE id = ?")
598
+ .run(forgotten === true || forgotten === 1 ? 1 : 0, nowIso(), id);
599
+ incrementGeneration();
600
+ });
551
601
  return getById(id);
552
602
  }
553
603
 
554
604
  function setArchived(id, archived) {
555
- db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
556
- .run(archived ? 1 : 0, nowIso(), id);
605
+ runAtomically(() => {
606
+ db.prepare("UPDATE memories SET archived = ?, updated_at = ? WHERE id = ?")
607
+ .run(archived ? 1 : 0, nowIso(), id);
608
+ incrementGeneration();
609
+ });
610
+ return getById(id);
611
+ }
612
+
613
+ /** Recall/inject touch. Records last_accessed_at WITHOUT bumping the mirror
614
+ * generation — sleep's "unrecalled N days → demote/archive" tiering must not
615
+ * spin the desired generation (that would mislead the mirror peer into
616
+ * thinking the touched memory's content changed). Only bumps a timestamp,
617
+ * so it is safe on hot recall paths. */
618
+ function touchAccess(id) {
619
+ const result = db.prepare(
620
+ "UPDATE memories SET last_accessed_at = ? WHERE id = ?"
621
+ ).run(nowIso(), id);
622
+ return result.changes > 0;
623
+ }
624
+
625
+ /** Sleep demotion (v0.4.1): move the full body into _full_content and replace
626
+ * content with a one-line summary. Skips when already demoted (_full_content
627
+ * present) so a replayed sleep run never double-wraps. Bumps the mirror
628
+ * generation because content visibly changes in the mirror file.
629
+ *
630
+ * minRefTimeMs (optional): the sleep tiering's freshness cutoff. A memory
631
+ * whose reference time (last_accessed_at ?? updated_at ?? created_at) is
632
+ * newer than the cutoff is skipped — phaseDemotion snapshots ref times up
633
+ * front, and a recall touch landing between the snapshot and this call must
634
+ * not demote a freshly-accessed memory. The check and the update run in the
635
+ * same synchronous transaction, so the read-then-write is atomic. */
636
+ function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
637
+ runAtomically(() => {
638
+ const row = db.prepare(
639
+ "SELECT content, _full_content, last_accessed_at, updated_at, created_at FROM memories WHERE id = ?"
640
+ ).get(id);
641
+ if (!row || row._full_content) return; // idempotent: never double-wrap
642
+ if (minRefTimeMs != null) {
643
+ const ref = row.last_accessed_at ?? row.updated_at ?? row.created_at;
644
+ const t = ref ? new Date(ref).getTime() : NaN;
645
+ if (!Number.isNaN(t) && t >= minRefTimeMs) return; // freshly touched: keep full
646
+ }
647
+ db.prepare(
648
+ "UPDATE memories SET _full_content = ?, content = ?, updated_at = ? WHERE id = ?"
649
+ ).run(row.content ?? "", summary ?? "", nowIso(), id);
650
+ incrementGeneration();
651
+ });
557
652
  return getById(id);
558
653
  }
559
654
 
@@ -682,16 +777,17 @@ export function createStore(path) {
682
777
  const id = run.id ?? randomUUID();
683
778
  const now = nowIso();
684
779
  const policyEpoch = Number.isInteger(run.policy_epoch) ? run.policy_epoch : 0;
780
+ const runType = run.run_type ?? "auto";
685
781
  db.prepare(
686
782
  `INSERT INTO dream_runs (id, created_at, status, error, provider, model, snapshot_hash,
687
- input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch)
688
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
783
+ input_count, input, decisions, outcome, applied, summary_stored, receipt, policy_epoch, run_type)
784
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
689
785
  ON CONFLICT(id) DO UPDATE SET
690
786
  created_at=excluded.created_at, status=excluded.status, error=excluded.error,
691
787
  provider=excluded.provider, model=excluded.model, snapshot_hash=excluded.snapshot_hash,
692
788
  input_count=excluded.input_count, input=excluded.input, decisions=excluded.decisions,
693
789
  outcome=excluded.outcome, applied=excluded.applied, summary_stored=excluded.summary_stored,
694
- receipt=excluded.receipt, policy_epoch=excluded.policy_epoch`
790
+ receipt=excluded.receipt, policy_epoch=excluded.policy_epoch, run_type=excluded.run_type`
695
791
  ).run(
696
792
  id,
697
793
  run.created_at ?? now,
@@ -707,7 +803,8 @@ export function createStore(path) {
707
803
  run.applied ?? 0,
708
804
  run.summary_stored ? 1 : 0,
709
805
  run.receipt,
710
- policyEpoch
806
+ policyEpoch,
807
+ runType
711
808
  );
712
809
  return getDreamRun(id);
713
810
  }
@@ -1189,7 +1286,10 @@ export function createStore(path) {
1189
1286
  if (key === "dirty") {
1190
1287
  value = value ? 1 : 0;
1191
1288
  } else if (key === "generation" || key === "applied_generation") {
1192
- value = Math.trunc(Number(value)) || 0;
1289
+ value = Math.trunc(Number(value));
1290
+ if (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
1291
+ throw new RangeError(`mirror_state.${key} out of range: ${value}`);
1292
+ }
1193
1293
  } else if (key === "type_status" && value != null && typeof value !== "string") {
1194
1294
  value = JSON.stringify(value);
1195
1295
  }
@@ -1220,12 +1320,15 @@ export function createStore(path) {
1220
1320
  * recent as this one may.
1221
1321
  */
1222
1322
  function markMirrorDirty(error, now) {
1223
- const current = getMirrorState();
1323
+ // Bump the desired generation atomically first — the new debt must be bound
1324
+ // to a fresh round so a stale worker cannot fence-clean it. Even if this
1325
+ // write fails (peer blocker 2), generation still advanced, so recoverMirror
1326
+ // sees generation > applied_generation and retries rather than false-clean.
1327
+ incrementGeneration();
1224
1328
  return setMirrorState({
1225
1329
  dirty: 1,
1226
1330
  last_error: error,
1227
- last_attempt: now ?? nowIso(),
1228
- generation: (current.generation || 0) + 1
1331
+ last_attempt: now ?? nowIso()
1229
1332
  });
1230
1333
  }
1231
1334
 
@@ -1262,13 +1365,23 @@ export function createStore(path) {
1262
1365
 
1263
1366
  /**
1264
1367
  * 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.
1368
+ * patch {status: 'committed'|'failed'|'pending', applied_gen?, last_error?}
1369
+ * replacing the entry for `type` (other types untouched). Standardizing on an
1370
+ * explicit status gives per-type committed/failed/pending receipts — a type
1371
+ * whose file was written while a sibling failed is recorded as such, not
1372
+ * collapsed into a bulk "dirty" (peer blocker 4). Returns the updated state.
1267
1373
  */
1268
1374
  function setTypeStatus(type, status) {
1375
+ if (!VALID_TYPE_STATUS.has(status?.status)) {
1376
+ throw new TypeError(`setTypeStatus: status must be one of committed|failed|pending, got ${status?.status}`);
1377
+ }
1269
1378
  const current = getMirrorState();
1270
1379
  const statuses = current.type_status || {};
1271
- statuses[type] = { ...(statuses[type] || {}), ...status };
1380
+ statuses[type] = {
1381
+ status: status.status,
1382
+ ...(status.applied_gen !== undefined ? { applied_gen: status.applied_gen } : {}),
1383
+ ...(status.last_error !== undefined ? { last_error: status.last_error } : {})
1384
+ };
1272
1385
  return setMirrorState({ type_status: JSON.stringify(statuses) });
1273
1386
  }
1274
1387
 
@@ -1278,10 +1391,40 @@ export function createStore(path) {
1278
1391
  return current.type_status || {};
1279
1392
  }
1280
1393
 
1281
- /** Bump the desired generation (new sync round), returning the new state. */
1394
+ /** Run fn atomically: when the connection is already inside a transaction
1395
+ * (service.transaction's BEGIN), just run it — the outer COMMIT covers us.
1396
+ * Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
1397
+ * bump commit together: a crash between them can never leave a mutated store
1398
+ * with generation == applied (audit peer blocker 1, "crash window"). */
1399
+ function runAtomically(fn) {
1400
+ if (db.isTransaction) return fn();
1401
+ db.exec("BEGIN");
1402
+ try {
1403
+ const result = fn();
1404
+ db.exec("COMMIT");
1405
+ return result;
1406
+ } catch (error) {
1407
+ try { db.exec("ROLLBACK"); } catch { /* connection may be closed */ }
1408
+ throw error;
1409
+ }
1410
+ }
1411
+
1412
+ /** Bump the desired generation atomically (SQLite single-statement increment,
1413
+ * no SELECT-then-UPSERT race: peer blocker 3 lost 10 of 91 concurrent
1414
+ * increments under an 8-process probe). Returns the new mirror state.
1415
+ * Guards the upper bound: generation must stay within MAX_SAFE_INTEGER so
1416
+ * reads never hit ERR_OUT_OF_RANGE (peer blocker 6). */
1282
1417
  function incrementGeneration() {
1283
- const current = getMirrorState();
1284
- return setMirrorState({ generation: (current.generation || 0) + 1 });
1418
+ return runAtomically(() => {
1419
+ // Ensure the singleton row exists before incrementing (UPDATE alone would
1420
+ // match nothing on a fresh DB).
1421
+ db.prepare("INSERT OR IGNORE INTO mirror_state (id) VALUES ('main')").run();
1422
+ const row = db.prepare(
1423
+ "UPDATE mirror_state SET generation = generation + 1 WHERE id = 'main' AND generation < ? RETURNING generation"
1424
+ ).get(Number.MAX_SAFE_INTEGER);
1425
+ if (!row) throw new RangeError("mirror_state.generation exceeded MAX_SAFE_INTEGER");
1426
+ return getMirrorState();
1427
+ });
1285
1428
  }
1286
1429
 
1287
1430
  return {
@@ -1294,6 +1437,8 @@ export function createStore(path) {
1294
1437
  remove,
1295
1438
  setForget,
1296
1439
  setArchived,
1440
+ touchAccess,
1441
+ demoteToSummary,
1297
1442
  list,
1298
1443
  all,
1299
1444
  search,
@@ -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
+ });