@lmzhen/dsh-evolution-state-json 0.3.27 → 0.3.29

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/index.js CHANGED
@@ -40,21 +40,47 @@ const RECORD_MAP_FILES = new Set([
40
40
  "pending-state.json",
41
41
  "pending.json"
42
42
  ]);
43
+ /** 0.3.17 (E-9): a malformed state file used to parse to `null` and was then
44
+ * OVERWRITTEN by the next save — every other session's review state / the
45
+ * whole pending table vanished silently. Fail loud instead: preserve the
46
+ * original bytes beside it and throw, so the operator can rescue and the
47
+ * corruption is never accepted as "empty". */
48
+ async function quarantine(io, root, file, raw, reason) {
49
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
50
+ const dest = `${join(root, file)}.corrupt-${stamp}-${Math.random().toString(36).slice(2, 6)}`;
51
+ await io().writeText(dest, raw).catch(() => {});
52
+ throw new Error(`evolution state file "${file}" is not valid JSON (${reason}); original preserved at ${dest} — inspect and fix it, then retry.`);
53
+ }
54
+ /**
55
+ * Cross-process JSON-file RMW (v3-audit M-8): every read-modify-write state
56
+ * mutation runs inside the IO backend's transact lock (via transactIo) so a
57
+ * second process sharing DSH_HOME cannot interleave its claim/resolve.
58
+ * `task` returns the next value (null = keep); for a record-map file the
59
+ * return must be null or a plain object map of records, and an array/scalar
60
+ * would be persisted as a corrupt map — so it fails loud before any write
61
+ * (0.3.28, V4-08). The legacy `pending.json` merge stays inside the task via
62
+ * `readJson` where relevant.
63
+ */
64
+ async function jsonTransact(io, root, file, task) {
65
+ await transactIo(io(), join(root, file), async (current) => {
66
+ let parsed = null;
67
+ if (current !== null) {
68
+ try {
69
+ parsed = JSON.parse(current);
70
+ } catch (error) {
71
+ return await quarantine(io, root, file, current, error instanceof Error ? error.message : String(error));
72
+ }
73
+ if (RECORD_MAP_FILES.has(file) && !isPlainRecord(parsed)) return await quarantine(io, root, file, current, `expected a plain JSON object (map of records), got ${Array.isArray(parsed) ? "an array" : parsed === null ? "null" : typeof parsed}`);
74
+ }
75
+ const next = await task(parsed);
76
+ if (next !== null && RECORD_MAP_FILES.has(file) && !isPlainRecord(next)) throw new Error(`evolution state file "${file}" task returned ${Array.isArray(next) ? "an array" : typeof next} (expected null or a plain JSON object map of records); not written.`);
77
+ return next === null ? null : JSON.stringify(next, null, 2);
78
+ });
79
+ }
43
80
  function apply(ctx, rawConfig) {
44
81
  const root = (rawConfig.root ?? "").trim() || evolutionHome();
45
82
  const io = () => ctx.evolutionIo.provider();
46
83
  const pathOf = (file) => join(root, file);
47
- /** 0.3.17 (E-9): a malformed state file used to parse to `null` and was then
48
- * OVERWRITTEN by the next save — every other session's review state / the
49
- * whole pending table vanished silently. Fail loud instead: preserve the
50
- * original bytes beside it and throw, so the operator can rescue and the
51
- * corruption is never accepted as "empty". */
52
- async function quarantine(file, raw, reason) {
53
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
54
- const dest = `${pathOf(file)}.corrupt-${stamp}-${Math.random().toString(36).slice(2, 6)}`;
55
- await io().writeText(dest, raw).catch(() => {});
56
- throw new Error(`evolution state file "${file}" is not valid JSON (${reason}); original preserved at ${dest} — inspect and fix it, then retry.`);
57
- }
58
84
  async function readJson(file) {
59
85
  const raw = await io().readText(pathOf(file));
60
86
  if (raw === null) return null;
@@ -62,40 +88,47 @@ function apply(ctx, rawConfig) {
62
88
  try {
63
89
  parsed = JSON.parse(raw);
64
90
  } catch (error) {
65
- return await quarantine(file, raw, error instanceof Error ? error.message : String(error));
91
+ return await quarantine(io, root, file, raw, error instanceof Error ? error.message : String(error));
66
92
  }
67
- if (RECORD_MAP_FILES.has(file) && !isPlainRecord(parsed)) return await quarantine(file, raw, `expected a plain JSON object (map of records), got ${Array.isArray(parsed) ? "an array" : parsed === null ? "null" : typeof parsed}`);
93
+ if (RECORD_MAP_FILES.has(file) && !isPlainRecord(parsed)) return await quarantine(io, root, file, raw, `expected a plain JSON object (map of records), got ${Array.isArray(parsed) ? "an array" : parsed === null ? "null" : typeof parsed}`);
68
94
  return parsed;
69
95
  }
70
- /**
71
- * Cross-process JSON-file RMW (v3-audit M-8): every read-modify-write state
72
- * mutation runs inside the IO backend's transact lock (via transactIo) so a
73
- * second process sharing DSH_HOME cannot interleave its claim/resolve.
74
- * `task` returns the next value (null = delete); the legacy `pending.json`
75
- * merge stays inside the task via `readJson` where relevant.
76
- */
77
- async function jsonTransact(file, task) {
78
- await transactIo(ctx.evolutionIo.provider(), pathOf(file), async (current) => {
79
- let parsed = null;
80
- if (current !== null) {
81
- try {
82
- parsed = JSON.parse(current);
83
- } catch (error) {
84
- return await quarantine(file, current, error instanceof Error ? error.message : String(error));
96
+ const mutate = makeSerialQueue();
97
+ let legacyMigrated = false;
98
+ async function retireLegacyOnce(legacy, current) {
99
+ if (legacyMigrated) return {};
100
+ try {
101
+ const archivedIds = /* @__PURE__ */ new Set();
102
+ try {
103
+ const rawArchive = await readJson("pending-state-archive.json");
104
+ if (Array.isArray(rawArchive)) {
105
+ for (const entry of rawArchive) if (entry && typeof entry.id === "string") archivedIds.add(entry.id);
85
106
  }
86
- if (RECORD_MAP_FILES.has(file) && !isPlainRecord(parsed)) return await quarantine(file, current, `expected a plain JSON object (map of records), got ${Array.isArray(parsed) ? "an array" : parsed === null ? "null" : typeof parsed}`);
107
+ } catch {}
108
+ const retired = {};
109
+ for (const [id, record] of Object.entries(legacy)) {
110
+ if (id in (current ?? {})) continue;
111
+ if (archivedIds.has(id)) continue;
112
+ retired[id] = record;
87
113
  }
88
- const next = await task(parsed);
89
- return next === null ? null : JSON.stringify(next, null, 2);
90
- });
114
+ await jsonTransact(io, root, "pending-state.json", () => ({
115
+ ...retired,
116
+ ...current ?? {}
117
+ }));
118
+ await io().rename(pathOf("pending.json"), pathOf("pending.json.migrated"));
119
+ legacyMigrated = true;
120
+ return retired;
121
+ } catch {
122
+ return legacy;
123
+ }
91
124
  }
92
- const mutate = makeSerialQueue();
93
125
  async function loadPendingMap() {
94
126
  const [current, legacy] = await Promise.all([readJson("pending-state.json"), readJson("pending.json")]);
95
- return {
96
- ...legacy ?? {},
127
+ if (legacy !== null) return {
128
+ ...await retireLegacyOnce(legacy, current),
97
129
  ...current ?? {}
98
130
  };
131
+ return { ...current ?? {} };
99
132
  }
100
133
  /** 0.3.22 (F-336): when the live pending map holds more than
101
134
  * `PENDING_RESOLVED_CAP` resolved records, drop the oldest (by resolvedAt,
@@ -110,12 +143,15 @@ function apply(ctx, rawConfig) {
110
143
  evicted: []
111
144
  };
112
145
  const overflow = resolved.length - PENDING_RESOLVED_CAP;
113
- const oldest = resolved.sort((a, b) => {
114
- return (a.resolvedAt ? Date.parse(a.resolvedAt) : Number.MAX_SAFE_INTEGER) - (b.resolvedAt ? Date.parse(b.resolvedAt) : Number.MAX_SAFE_INTEGER);
115
- }).slice(0, overflow);
146
+ const entryTime = (record) => {
147
+ if (!record.resolvedAt) return Number.MAX_SAFE_INTEGER;
148
+ const parsed = Date.parse(record.resolvedAt);
149
+ return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
150
+ };
151
+ const oldest = resolved.sort((a, b) => entryTime(a) - entryTime(b)).slice(0, overflow);
116
152
  const evictIds = new Set(oldest.map((record) => record.id));
117
153
  const kept = {};
118
- for (const [key, value] of Object.entries(map)) if (!evictIds.has(key)) kept[key] = value;
154
+ for (const [key, value] of Object.entries(map)) if (!evictIds.has(value.id)) kept[key] = value;
119
155
  return {
120
156
  map: kept,
121
157
  evicted: oldest
@@ -144,8 +180,8 @@ function apply(ctx, rawConfig) {
144
180
  if (fresh.length === 0) return current;
145
181
  const next = [...archive, ...fresh];
146
182
  if (next.length > ARCHIVE_RESOLVED_CAP) {
147
- await io().writeText(pathOf("pending-state-archive.json.bak"), JSON.stringify(archive, null, 2)).catch(() => {});
148
- return JSON.stringify(fresh, null, 2);
183
+ if (archive.length > 0) await io().writeText(pathOf("pending-state-archive.json.bak"), JSON.stringify(archive, null, 2)).catch(() => {});
184
+ return JSON.stringify(fresh.slice(-5e3), null, 2);
149
185
  }
150
186
  return JSON.stringify(next, null, 2);
151
187
  });
@@ -160,7 +196,7 @@ function apply(ctx, rawConfig) {
160
196
  },
161
197
  async saveReviewState(sessionId, record) {
162
198
  await mutate(async () => {
163
- await jsonTransact("review-state.json", (current) => ({
199
+ await jsonTransact(io, root, "review-state.json", (current) => ({
164
200
  ...current ?? {},
165
201
  [sessionId]: record
166
202
  }));
@@ -173,7 +209,7 @@ function apply(ctx, rawConfig) {
173
209
  },
174
210
  async saveCuratorState(record) {
175
211
  await mutate(async () => {
176
- await jsonTransact("curator-state.json", (current) => ({
212
+ await jsonTransact(io, root, "curator-state.json", (current) => ({
177
213
  ...current ?? {},
178
214
  primary: record
179
215
  }));
@@ -181,7 +217,7 @@ function apply(ctx, rawConfig) {
181
217
  },
182
218
  async transactCuratorState(task) {
183
219
  await mutate(async () => {
184
- await jsonTransact("curator-state.json", (current) => {
220
+ await jsonTransact(io, root, "curator-state.json", (current) => {
185
221
  const next = task(current?.primary ?? null);
186
222
  if (next === null) return current;
187
223
  return {
@@ -199,9 +235,9 @@ function apply(ctx, rawConfig) {
199
235
  },
200
236
  async savePending(record) {
201
237
  await mutate(async () => {
202
- await jsonTransact("pending-state.json", async (current) => {
238
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
203
239
  return {
204
- ...await readJson("pending.json") ?? {},
240
+ ...(legacyMigrated ? null : await readJson("pending.json")) ?? {},
205
241
  ...current ?? {},
206
242
  [record.id]: record
207
243
  };
@@ -211,9 +247,9 @@ function apply(ctx, rawConfig) {
211
247
  async claimPending(id, claimId) {
212
248
  return await mutate(async () => {
213
249
  const slot = { claimed: null };
214
- await jsonTransact("pending-state.json", async (current) => {
250
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
215
251
  const map = {
216
- ...await readJson("pending.json") ?? {},
252
+ ...(legacyMigrated ? null : await readJson("pending.json")) ?? {},
217
253
  ...current ?? {}
218
254
  };
219
255
  const record = map[id] ?? null;
@@ -233,9 +269,9 @@ function apply(ctx, rawConfig) {
233
269
  },
234
270
  async releasePendingClaim(id, claimId) {
235
271
  await mutate(async () => {
236
- await jsonTransact("pending-state.json", async (current) => {
272
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
237
273
  const map = {
238
- ...await readJson("pending.json") ?? {},
274
+ ...(legacyMigrated ? null : await readJson("pending.json")) ?? {},
239
275
  ...current ?? {}
240
276
  };
241
277
  const record = map[id];
@@ -254,9 +290,9 @@ function apply(ctx, rawConfig) {
254
290
  applied: false
255
291
  };
256
292
  let evicted = [];
257
- await jsonTransact("pending-state.json", async (current) => {
293
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
258
294
  const map = {
259
- ...await readJson("pending.json") ?? {},
295
+ ...(legacyMigrated ? null : await readJson("pending.json")) ?? {},
260
296
  ...current ?? {}
261
297
  };
262
298
  const record = map[id] ?? null;
@@ -289,4 +325,4 @@ function apply(ctx, rawConfig) {
289
325
  ctx.effect(() => ctx.evolutionStateStorage.registerProvider(provider), "evolution-state-json.provider");
290
326
  }
291
327
  //#endregion
292
- export { Config, apply, inject, name };
328
+ export { Config, apply, inject, jsonTransact, name };
@@ -7,11 +7,23 @@
7
7
  */
8
8
  import type { Context } from '@deepseek-ai/cordis';
9
9
  import z from '@deepseek-ai/schemastery';
10
+ import { type EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
10
11
  export declare const name = "evolution-state-json";
11
12
  export declare const inject: string[];
12
13
  export interface Config {
13
14
  root?: string;
14
15
  }
15
16
  export declare const Config: z<Config>;
17
+ /**
18
+ * Cross-process JSON-file RMW (v3-audit M-8): every read-modify-write state
19
+ * mutation runs inside the IO backend's transact lock (via transactIo) so a
20
+ * second process sharing DSH_HOME cannot interleave its claim/resolve.
21
+ * `task` returns the next value (null = keep); for a record-map file the
22
+ * return must be null or a plain object map of records, and an array/scalar
23
+ * would be persisted as a corrupt map — so it fails loud before any write
24
+ * (0.3.28, V4-08). The legacy `pending.json` merge stays inside the task via
25
+ * `readJson` where relevant.
26
+ */
27
+ export declare function jsonTransact<T>(io: () => EvolutionIoLike, root: string, file: string, task: (current: T | null) => T | null | Promise<T | null>): Promise<void>;
16
28
  export declare function apply(ctx: Context, rawConfig: Config): void;
17
29
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-state-json",
3
3
  "description": "JSON-file evolution state provider over the IO seam (community build)",
4
- "version": "0.3.27",
4
+ "version": "0.3.29",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -31,17 +31,17 @@
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
33
  "@deepseek-ai/schemastery": "^3.18.1",
34
- "@lmzhen/dsh-evolution-core": "^0.3.27"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.29"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
38
38
  "@deepseek-ai/cordis": "^4.0.1",
39
- "@lmzhen/dsh-evolution-io": "^0.3.27",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.27"
39
+ "@lmzhen/dsh-evolution-io": "^0.3.29",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.29"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@lmzhen/dsh-evolution-io": "^0.3.27",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.27"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.29",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.29"
46
46
  }
47
47
  }