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

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,33 +88,11 @@ 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));
85
- }
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}`);
87
- }
88
- const next = await task(parsed);
89
- return next === null ? null : JSON.stringify(next, null, 2);
90
- });
91
- }
92
96
  const mutate = makeSerialQueue();
93
97
  async function loadPendingMap() {
94
98
  const [current, legacy] = await Promise.all([readJson("pending-state.json"), readJson("pending.json")]);
@@ -160,7 +164,7 @@ function apply(ctx, rawConfig) {
160
164
  },
161
165
  async saveReviewState(sessionId, record) {
162
166
  await mutate(async () => {
163
- await jsonTransact("review-state.json", (current) => ({
167
+ await jsonTransact(io, root, "review-state.json", (current) => ({
164
168
  ...current ?? {},
165
169
  [sessionId]: record
166
170
  }));
@@ -173,7 +177,7 @@ function apply(ctx, rawConfig) {
173
177
  },
174
178
  async saveCuratorState(record) {
175
179
  await mutate(async () => {
176
- await jsonTransact("curator-state.json", (current) => ({
180
+ await jsonTransact(io, root, "curator-state.json", (current) => ({
177
181
  ...current ?? {},
178
182
  primary: record
179
183
  }));
@@ -181,7 +185,7 @@ function apply(ctx, rawConfig) {
181
185
  },
182
186
  async transactCuratorState(task) {
183
187
  await mutate(async () => {
184
- await jsonTransact("curator-state.json", (current) => {
188
+ await jsonTransact(io, root, "curator-state.json", (current) => {
185
189
  const next = task(current?.primary ?? null);
186
190
  if (next === null) return current;
187
191
  return {
@@ -199,7 +203,7 @@ function apply(ctx, rawConfig) {
199
203
  },
200
204
  async savePending(record) {
201
205
  await mutate(async () => {
202
- await jsonTransact("pending-state.json", async (current) => {
206
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
203
207
  return {
204
208
  ...await readJson("pending.json") ?? {},
205
209
  ...current ?? {},
@@ -211,7 +215,7 @@ function apply(ctx, rawConfig) {
211
215
  async claimPending(id, claimId) {
212
216
  return await mutate(async () => {
213
217
  const slot = { claimed: null };
214
- await jsonTransact("pending-state.json", async (current) => {
218
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
215
219
  const map = {
216
220
  ...await readJson("pending.json") ?? {},
217
221
  ...current ?? {}
@@ -233,7 +237,7 @@ function apply(ctx, rawConfig) {
233
237
  },
234
238
  async releasePendingClaim(id, claimId) {
235
239
  await mutate(async () => {
236
- await jsonTransact("pending-state.json", async (current) => {
240
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
237
241
  const map = {
238
242
  ...await readJson("pending.json") ?? {},
239
243
  ...current ?? {}
@@ -254,7 +258,7 @@ function apply(ctx, rawConfig) {
254
258
  applied: false
255
259
  };
256
260
  let evicted = [];
257
- await jsonTransact("pending-state.json", async (current) => {
261
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
258
262
  const map = {
259
263
  ...await readJson("pending.json") ?? {},
260
264
  ...current ?? {}
@@ -289,4 +293,4 @@ function apply(ctx, rawConfig) {
289
293
  ctx.effect(() => ctx.evolutionStateStorage.registerProvider(provider), "evolution-state-json.provider");
290
294
  }
291
295
  //#endregion
292
- export { Config, apply, inject, name };
296
+ 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.28",
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.28"
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.28",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.28"
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.28",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.28"
46
46
  }
47
47
  }