@modusensus/dsh-mneme 0.3.5 → 0.3.7

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
@@ -169,12 +169,20 @@ CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type)
169
169
  -- syncMirror 失败不再只靠瞬时 console.warn —— dirty=1 提示镜像脏了需重渲染,
170
170
  -- last_error/last_attempt 记录失败原因与最近尝试,success_at 记录最近成功。
171
171
  -- 上层可据此在启动时重试、提供人工 reconcile 入口与健康状态查询。
172
+ -- v0.3.6: 新增 generation/applied_generation/type_status —— desired-applied
173
+ -- 建模镜像债务:generation 是期望同步轮次,applied_generation 是已成功应用
174
+ -- 轮次(成功清 dirty 必须 CAS/fence 到具体轮次,旧 worker 不能清新故障),
175
+ -- type_status 逐 type 记录部分成功状态。旧库经 PRAGMA table_info 检查后
176
+ -- ALTER 补列,幂等且不丢数据。
172
177
  CREATE TABLE IF NOT EXISTS mirror_state (
173
178
  id TEXT PRIMARY KEY, -- 单一状态行(用 'main')
174
179
  dirty INTEGER NOT NULL DEFAULT 0, -- 1=镜像脏了需重渲染
175
180
  last_error TEXT, -- 最近失败原因
176
181
  last_attempt TEXT, -- 最近尝试时间(ISO)
177
- success_at TEXT -- 最近成功时间(ISO)
182
+ success_at TEXT, -- 最近成功时间(ISO)
183
+ generation INTEGER NOT NULL DEFAULT 0, -- 期望的同步轮次(desired)
184
+ applied_generation INTEGER NOT NULL DEFAULT 0, -- 已成功应用的轮次
185
+ type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
178
186
  );
179
187
  `;
180
188
 
@@ -337,14 +345,33 @@ function toRelation(row) {
337
345
 
338
346
  function toMirrorState(row) {
339
347
  if (!row) {
340
- return { dirty: false, last_error: null, last_attempt: null, success_at: null };
348
+ return {
349
+ dirty: false,
350
+ last_error: null,
351
+ last_attempt: null,
352
+ success_at: null,
353
+ generation: 0,
354
+ applied_generation: 0,
355
+ type_status: {}
356
+ };
357
+ }
358
+ let typeStatus = {};
359
+ if (row.type_status) {
360
+ try {
361
+ typeStatus = JSON.parse(row.type_status) || {};
362
+ } catch {
363
+ typeStatus = {};
364
+ }
341
365
  }
342
366
  return {
343
367
  id: row.id,
344
368
  dirty: row.dirty === 1,
345
369
  last_error: row.last_error,
346
370
  last_attempt: row.last_attempt,
347
- success_at: row.success_at
371
+ success_at: row.success_at,
372
+ generation: Number(row.generation) || 0,
373
+ applied_generation: Number(row.applied_generation) || 0,
374
+ type_status: typeStatus
348
375
  };
349
376
  }
350
377
 
@@ -377,6 +404,19 @@ export function createStore(path) {
377
404
  db.exec("ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
378
405
  }
379
406
 
407
+ // Legacy mirror_state without v0.3.6 generation columns → add each missing
408
+ // column idempotently (old DBs open cleanly, no data loss).
409
+ const mirrorCols = db.prepare("PRAGMA table_info(mirror_state)").all().map((c) => c.name);
410
+ if (!mirrorCols.includes("generation")) {
411
+ db.exec("ALTER TABLE mirror_state ADD COLUMN generation INTEGER NOT NULL DEFAULT 0");
412
+ }
413
+ if (!mirrorCols.includes("applied_generation")) {
414
+ db.exec("ALTER TABLE mirror_state ADD COLUMN applied_generation INTEGER NOT NULL DEFAULT 0");
415
+ }
416
+ if (!mirrorCols.includes("type_status")) {
417
+ db.exec("ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
418
+ }
419
+
380
420
  // Per-instance monotonic timestamp guard: consecutive writes within the same
381
421
  // millisecond must still produce strictly increasing timestamps (test asserts
382
422
  // updated_at != created_at). State lives in the store closure, not module scope.
@@ -1113,14 +1153,25 @@ export function createStore(path) {
1113
1153
 
1114
1154
  /**
1115
1155
  * Upsert the single mirror_state row (id='main'). patch accepts
1116
- * {dirty?, last_error?, last_attempt?, success_at?} — only the keys present
1117
- * on the object are written, everything else is left untouched. Returns the
1118
- * freshly read state row (default shape when absent).
1156
+ * {dirty?, last_error?, last_attempt?, success_at?, generation?,
1157
+ * applied_generation?, type_status?} only the keys present on the object
1158
+ * are written, everything else is left untouched (partial upsert). type_status
1159
+ * is stored as JSON text (objects are serialized on write), generation /
1160
+ * applied_generation are coerced to non-negative integers. Returns the freshly
1161
+ * read state row (default shape when absent).
1119
1162
  */
1120
1163
  function setMirrorState(patch) {
1121
- const ALLOWED = new Set(["dirty", "last_error", "last_attempt", "success_at"]);
1122
- const keys = Object.keys(patch).filter((key) =>
1123
- ALLOWED.has(key) && Object.prototype.hasOwnProperty.call(patch, key)
1164
+ const ALLOWED = new Set([
1165
+ "dirty",
1166
+ "last_error",
1167
+ "last_attempt",
1168
+ "success_at",
1169
+ "generation",
1170
+ "applied_generation",
1171
+ "type_status"
1172
+ ]);
1173
+ const keys = Object.keys(patch).filter(
1174
+ (key) => ALLOWED.has(key) && Object.prototype.hasOwnProperty.call(patch, key)
1124
1175
  );
1125
1176
  if (keys.length === 0) {
1126
1177
  db.prepare(
@@ -1130,41 +1181,78 @@ export function createStore(path) {
1130
1181
  }
1131
1182
  // 列同时出现在 INSERT 与 ON CONFLICT 里(excluded.*),保证首次插入也写入
1132
1183
  // patch 值,而不只是默认值;未传入的列保持不变(partial upsert)。
1133
- const cols = keys.join(", ");
1134
- const placeholders = keys.map(() => "?").join(", ");
1135
- const updates = keys.map((k) => `${k} = excluded.${k}`).join(", ");
1136
- const values = keys.map((key) =>
1137
- key === "dirty" ? (patch[key] ? 1 : 0) : patch[key]
1138
- );
1184
+ const cols = [];
1185
+ const values = [];
1186
+ const updates = [];
1187
+ for (const key of keys) {
1188
+ let value = patch[key];
1189
+ if (key === "dirty") {
1190
+ value = value ? 1 : 0;
1191
+ } else if (key === "generation" || key === "applied_generation") {
1192
+ value = Math.trunc(Number(value)) || 0;
1193
+ } else if (key === "type_status" && value != null && typeof value !== "string") {
1194
+ value = JSON.stringify(value);
1195
+ }
1196
+ cols.push(key);
1197
+ values.push(value);
1198
+ updates.push(`${key} = excluded.${key}`);
1199
+ }
1200
+ const placeholders = cols.map(() => "?").join(", ");
1139
1201
  db.prepare(
1140
- `INSERT INTO mirror_state (id, ${cols}) VALUES ('main', ${placeholders})
1141
- ON CONFLICT(id) DO UPDATE SET ${updates}`
1202
+ `INSERT INTO mirror_state (id, ${cols.join(", ")}) VALUES ('main', ${placeholders})
1203
+ ON CONFLICT(id) DO UPDATE SET ${updates.join(", ")}`
1142
1204
  ).run(...values);
1143
1205
  return getMirrorState();
1144
1206
  }
1145
1207
 
1146
- /** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null} when absent. */
1208
+ /** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null, generation:0, applied_generation:0, type_status:{}} when absent. */
1147
1209
  function getMirrorState() {
1148
1210
  const row = db.prepare("SELECT * FROM mirror_state WHERE id = 'main'").get();
1149
1211
  return toMirrorState(row);
1150
1212
  }
1151
1213
 
1152
- /** Convenience: mark the mirror dirty after a failed sync (dirty=1 + last_error + last_attempt). */
1214
+ /**
1215
+ * Mark the mirror dirty after a failed sync (dirty=1 + last_error +
1216
+ * last_attempt). v0.3.6: also bumps the desired generation so the debt is
1217
+ * bound to a specific sync round; applied_generation is left untouched
1218
+ * (the round was NOT applied). A stale worker that started earlier cannot
1219
+ * clear this newer debt — only a clean fenced to a generation at least as
1220
+ * recent as this one may.
1221
+ */
1153
1222
  function markMirrorDirty(error, now) {
1223
+ const current = getMirrorState();
1154
1224
  return setMirrorState({
1155
1225
  dirty: 1,
1156
1226
  last_error: error,
1157
- last_attempt: now ?? nowIso()
1227
+ last_attempt: now ?? nowIso(),
1228
+ generation: (current.generation || 0) + 1
1158
1229
  });
1159
1230
  }
1160
1231
 
1161
- /** Convenience: mark the mirror clean after a successful sync (dirty=0 + last_error=null + success_at). */
1232
+ /**
1233
+ * Fenced clean (CAS): mark the mirror clean for a specific generation.
1234
+ * First records that generation `gen` has been applied
1235
+ * (applied_generation = MAX(applied_generation, gen)), then clears dirty only
1236
+ * when the current desired generation has not advanced past gen — a stale
1237
+ * worker cleaning an older round must not wipe a newer failure's debt.
1238
+ * Returns the resulting state (dirty stays set when the fence holds).
1239
+ */
1240
+ function markMirrorCleanForGeneration(gen, now) {
1241
+ const current = getMirrorState();
1242
+ const applied = Math.max(current.applied_generation || 0, gen);
1243
+ const patch = { applied_generation: applied };
1244
+ if (applied >= gen && (current.generation || 0) <= gen) {
1245
+ patch.dirty = 0;
1246
+ patch.last_error = null;
1247
+ patch.success_at = now ?? nowIso();
1248
+ }
1249
+ return setMirrorState(patch);
1250
+ }
1251
+
1252
+ /** Convenience: mark the mirror clean for the current desired generation (backward-compatible with pre-v0.3.6 callers). */
1162
1253
  function markMirrorClean(now) {
1163
- return setMirrorState({
1164
- dirty: 0,
1165
- last_error: null,
1166
- success_at: now ?? nowIso()
1167
- });
1254
+ const current = getMirrorState();
1255
+ return markMirrorCleanForGeneration(current.generation || 0, now);
1168
1256
  }
1169
1257
 
1170
1258
  /** Convenience: clear only the dirty flag + last_error, leaving success_at untouched (manual reconcile / retry path). */
@@ -1172,6 +1260,30 @@ export function createStore(path) {
1172
1260
  return setMirrorState({ dirty: 0, last_error: null });
1173
1261
  }
1174
1262
 
1263
+ /**
1264
+ * 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.
1267
+ */
1268
+ function setTypeStatus(type, status) {
1269
+ const current = getMirrorState();
1270
+ const statuses = current.type_status || {};
1271
+ statuses[type] = { ...(statuses[type] || {}), ...status };
1272
+ return setMirrorState({ type_status: JSON.stringify(statuses) });
1273
+ }
1274
+
1275
+ /** Per-type mirror status map {type: {dirty, applied_gen, last_error}}, {} when unset. */
1276
+ function getTypeStatus() {
1277
+ const current = getMirrorState();
1278
+ return current.type_status || {};
1279
+ }
1280
+
1281
+ /** Bump the desired generation (new sync round), returning the new state. */
1282
+ function incrementGeneration() {
1283
+ const current = getMirrorState();
1284
+ return setMirrorState({ generation: (current.generation || 0) + 1 });
1285
+ }
1286
+
1175
1287
  return {
1176
1288
  db,
1177
1289
  count,
@@ -1224,7 +1336,11 @@ export function createStore(path) {
1224
1336
  getMirrorState,
1225
1337
  markMirrorDirty,
1226
1338
  markMirrorClean,
1339
+ markMirrorCleanForGeneration,
1227
1340
  clearMirrorDirty,
1341
+ setTypeStatus,
1342
+ getTypeStatus,
1343
+ incrementGeneration,
1228
1344
  close() {
1229
1345
  db.close();
1230
1346
  }
@@ -144,15 +144,17 @@ test("F-NEW-03 d: dirty 记录与 DB decision receipt 物理分离——mirror_s
144
144
  try {
145
145
  const store = createStore(dbPath);
146
146
  const cols = store.db.prepare("PRAGMA table_info(mirror_state)").all().map((c) => c.name);
147
- assert.deepEqual(cols.sort(), ["dirty", "id", "last_attempt", "last_error", "success_at"],
148
- "mirror_state 只含状态列,不得混入 receipt/decision 字段");
147
+ assert.deepEqual(cols.sort(),
148
+ ["applied_generation", "dirty", "generation", "id", "last_attempt", "last_error", "success_at", "type_status"],
149
+ "mirror_state 只含状态列(v0.3.6 含 generation/applied_generation/type_status),不得混入 receipt/decision 字段");
149
150
  store.markMirrorDirty("boom", "t");
150
151
  store.saveReceipt({
151
152
  run_id: "run-1", record_id: "rec-1", kind: "merge", input_digest: "d",
152
153
  verdict: "live", count_before: 1, count_after: 2
153
154
  });
154
155
  const state = store.getMirrorState();
155
- assert.deepEqual(Object.keys(state).sort(), ["dirty", "id", "last_attempt", "last_error", "success_at"],
156
+ assert.deepEqual(Object.keys(state).sort(),
157
+ ["applied_generation", "dirty", "generation", "id", "last_attempt", "last_error", "success_at", "type_status"],
156
158
  "dirty 状态对象不得携带 receipt 内容");
157
159
  assert.equal(state.last_error, "boom");
158
160
  store.close();
@@ -328,7 +330,12 @@ test("F-NEW-03 j: /api/dsh-mneme/health 端点暴露 dirty 状态,恢复后自
328
330
  assert.equal(resDirty.statusCode, 200);
329
331
  assert.match(resDirty.headers["Content-Type"], /application\/json/);
330
332
  assert.equal(JSON.parse(resDirty.body).mirror.dirty, true, "dirty 时 /health 必须暴露 dirty:true");
331
- assert.equal(JSON.parse(resDirty.body).mirror.last_error, MIRROR_ERR);
333
+ assert.equal(JSON.parse(resDirty.body).mirror.status, "degraded", "dirty 时 /health 必须为 degraded");
334
+ // v0.3.6 脱敏:/health 只回脱敏错误码,不回显原始 last_error
335
+ assert.equal(JSON.parse(resDirty.body).mirror.last_error, "no-space",
336
+ "last_error 必须脱敏为 no-space,不得回显原始值");
337
+ assert.ok(!JSON.parse(resDirty.body).mirror.last_error.includes(MIRROR_ERR),
338
+ "原始错误串不得出现在 /health 响应中");
332
339
 
333
340
  mock.mode = "ok";
334
341
  service.recoverMirror();
@@ -336,7 +336,11 @@ test("F-NEW-03: health 端点 dirty 时返回 dirty=true 及 last_error/last_att
336
336
  assert.equal(res.statusCode, 200);
337
337
  const body = JSON.parse(res.body);
338
338
  assert.equal(body.mirror.dirty, true, "health 必须反映真实 dirty(布尔)");
339
- assert.equal(body.mirror.last_error, "No space left on device");
339
+ assert.equal(body.mirror.status, "degraded", "dirty health 必须为 degraded");
340
+ // v0.3.6 脱敏:只回脱敏错误码,不回显原始 last_error
341
+ assert.equal(body.mirror.last_error, "no-space", "last_error 必须脱敏为 no-space");
342
+ assert.ok(!body.mirror.last_error.includes("No space left on device"),
343
+ "原始错误串不得出现在 health 响应中");
340
344
  assert.ok(body.mirror.last_attempt, "last_attempt 须返回");
341
345
  assert.equal(body.mirror.success_at, null);
342
346
  } finally {
@@ -392,25 +396,25 @@ test("F-NEW-03: markMirrorDirty 自身抛错 → syncMirror 不抛、只 warn",
392
396
  }
393
397
  });
394
398
 
395
- test("F-NEW-03: markMirrorClean 自身抛错 → syncMirror 不抛、只 warn", () => {
399
+ test("F-NEW-03: markMirrorCleanForGeneration 自身抛错 → syncMirror 不抛、只 warn", () => {
396
400
  const { dir, store, mirror, service, warns } = setup();
397
401
  try {
398
402
  const { original } = throwingMirrorSync(mirror);
399
- const origClean = store.markMirrorClean.bind(store);
403
+ const origClean = store.markMirrorCleanForGeneration.bind(store);
400
404
  try {
401
405
  // 制造 dirty
402
406
  service.saveWithDedupe({ type: "project", title: "前置脏", content: "x", importance: 3 });
403
407
  assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
404
- // 恢复 sync + 让 markMirrorClean 抛错
408
+ // 恢复 sync + 让 markMirrorCleanForGeneration 抛错(v0.3.6 成功路径的 CAS 写)
405
409
  mirror.sync = original;
406
- store.markMirrorClean = () => { throw new Error("clean write fail"); };
410
+ store.markMirrorCleanForGeneration = () => { throw new Error("clean write fail"); };
407
411
  assert.doesNotThrow(() => {
408
412
  service.saveWithDedupe({ type: "project", title: "成功路径", content: "y", importance: 2 });
409
- }, "markMirrorClean 失败不得使 syncMirror 外抛");
410
- assert.ok(warns.some((w) => /markMirrorClean failed|clean write fail/.test(w)), "应走 logger.warn");
413
+ }, "markMirrorCleanForGeneration 失败不得使 syncMirror 外抛");
414
+ assert.ok(warns.some((w) => /markMirrorCleanForGeneration failed|clean write fail/.test(w)), "应走 logger.warn");
411
415
  assert.equal(store.count(), 2, "store 写入不受影响");
412
416
  } finally {
413
- store.markMirrorClean = origClean;
417
+ store.markMirrorCleanForGeneration = origClean;
414
418
  mirror.sync = original;
415
419
  }
416
420
  } finally {