@lmzhen/dsh-evolution-state-json 0.3.26 → 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
@@ -18,6 +18,16 @@ const Config = z.object({ root: z.string().default("") });
18
18
  * bound; the oldest over the cap are archived (made package-private so the
19
19
  * archive sidecar and the provider enforce one number). */
20
20
  const PENDING_RESOLVED_CAP = 200;
21
+ /** 0.3.27 (V4-01): the audit sidecar (pending-state-archive.json) is bounded
22
+ * at this many resolved records. Past it the oldest history rotates to a
23
+ * `.bak` sidecar, so the file — and the full-array rewrite on every append —
24
+ * never grows without bound. */
25
+ const ARCHIVE_RESOLVED_CAP = 5e3;
26
+ /** 0.3.27 (V4-01): an archive entry's dedupe identity. The same audit record
27
+ * (id + status + resolvedAt) must never appear twice; the read-only legacy
28
+ * `pending.json` merge used to re-introduce an evicted record on the next
29
+ * resolve and archive it again, growing the sidecar without bound. */
30
+ const pendingArchiveKey = (record) => `${record.id}\u0000${record.status}\u0000${record.resolvedAt ?? ""}`;
21
31
  /** 0.3.22 (F-215): a record-map state file must parse to a non-null plain
22
32
  * object (a map of records) — valid JSON that is `null`/array/scalar is a
23
33
  * corrupt map that used to read as "empty" and was silently overwritten by
@@ -30,51 +40,58 @@ const RECORD_MAP_FILES = new Set([
30
40
  "pending-state.json",
31
41
  "pending.json"
32
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
+ }
33
80
  function apply(ctx, rawConfig) {
34
- const root = rawConfig.root || evolutionHome();
81
+ const root = (rawConfig.root ?? "").trim() || evolutionHome();
35
82
  const io = () => ctx.evolutionIo.provider();
36
83
  const pathOf = (file) => join(root, file);
37
- /** 0.3.17 (E-9): a malformed state file used to parse to `null` and was then
38
- * OVERWRITTEN by the next save — every other session's review state / the
39
- * whole pending table vanished silently. Fail loud instead: preserve the
40
- * original bytes beside it and throw, so the operator can rescue and the
41
- * corruption is never accepted as "empty". */
42
- async function quarantine(file, raw, reason) {
43
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
44
- const dest = `${pathOf(file)}.corrupt-${stamp}-${Math.random().toString(36).slice(2, 6)}`;
45
- await io().writeText(dest, raw).catch(() => {});
46
- throw new Error(`evolution state file "${file}" is not valid JSON (${reason}); original preserved at ${dest} — inspect and fix it, then retry.`);
47
- }
48
84
  async function readJson(file) {
49
85
  const raw = await io().readText(pathOf(file));
50
86
  if (raw === null) return null;
87
+ let parsed;
51
88
  try {
52
- const parsed = JSON.parse(raw);
53
- 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}`);
54
- return parsed;
89
+ parsed = JSON.parse(raw);
55
90
  } catch (error) {
56
- 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));
57
92
  }
58
- }
59
- /**
60
- * Cross-process JSON-file RMW (v3-audit M-8): every read-modify-write state
61
- * mutation runs inside the IO backend's transact lock (via transactIo) so a
62
- * second process sharing DSH_HOME cannot interleave its claim/resolve.
63
- * `task` returns the next value (null = delete); the legacy `pending.json`
64
- * merge stays inside the task via `readJson` where relevant.
65
- */
66
- async function jsonTransact(file, task) {
67
- await transactIo(ctx.evolutionIo.provider(), pathOf(file), async (current) => {
68
- let parsed = null;
69
- if (current !== null) try {
70
- parsed = JSON.parse(current);
71
- 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}`);
72
- } catch (error) {
73
- return await quarantine(file, current, error instanceof Error ? error.message : String(error));
74
- }
75
- const next = await task(parsed);
76
- return next === null ? null : JSON.stringify(next, null, 2);
77
- });
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}`);
94
+ return parsed;
78
95
  }
79
96
  const mutate = makeSerialQueue();
80
97
  async function loadPendingMap() {
@@ -112,16 +129,29 @@ function apply(ctx, rawConfig) {
112
129
  * (top-level array, oldest-first). This is a best-effort audit aid: a
113
130
  * corrupt/unreadable archive is skipped and an archive write failure must
114
131
  * NEVER fail the resolve that triggered it — the live map is already
115
- * trimmed, so the audit copy is allowed to fall behind. */
132
+ * trimmed, so the audit copy is allowed to fall behind.
133
+ * 0.3.27 (V4-01): dedupe by id+status+resolvedAt before appending (the
134
+ * read-only legacy `pending.json` re-introduces an evicted record on the
135
+ * next resolve) and rotate the sidecar to `.bak` past ARCHIVE_RESOLVED_CAP
136
+ * so neither the file nor the per-append full-array rewrite grows without
137
+ * bound. */
116
138
  async function appendArchive(records) {
117
139
  try {
118
- await transactIo(io(), pathOf("pending-state-archive.json"), (current) => {
140
+ await transactIo(io(), pathOf("pending-state-archive.json"), async (current) => {
119
141
  let archive = [];
120
142
  if (current !== null) try {
121
143
  const parsed = JSON.parse(current);
122
144
  if (Array.isArray(parsed)) archive = parsed;
123
145
  } catch {}
124
- return JSON.stringify([...archive, ...records], null, 2);
146
+ const seen = new Set(archive.map(pendingArchiveKey));
147
+ const fresh = records.filter((record) => !seen.has(pendingArchiveKey(record)));
148
+ if (fresh.length === 0) return current;
149
+ const next = [...archive, ...fresh];
150
+ if (next.length > ARCHIVE_RESOLVED_CAP) {
151
+ await io().writeText(pathOf("pending-state-archive.json.bak"), JSON.stringify(archive, null, 2)).catch(() => {});
152
+ return JSON.stringify(fresh, null, 2);
153
+ }
154
+ return JSON.stringify(next, null, 2);
125
155
  });
126
156
  } catch {}
127
157
  }
@@ -134,7 +164,7 @@ function apply(ctx, rawConfig) {
134
164
  },
135
165
  async saveReviewState(sessionId, record) {
136
166
  await mutate(async () => {
137
- await jsonTransact("review-state.json", (current) => ({
167
+ await jsonTransact(io, root, "review-state.json", (current) => ({
138
168
  ...current ?? {},
139
169
  [sessionId]: record
140
170
  }));
@@ -147,7 +177,7 @@ function apply(ctx, rawConfig) {
147
177
  },
148
178
  async saveCuratorState(record) {
149
179
  await mutate(async () => {
150
- await jsonTransact("curator-state.json", (current) => ({
180
+ await jsonTransact(io, root, "curator-state.json", (current) => ({
151
181
  ...current ?? {},
152
182
  primary: record
153
183
  }));
@@ -155,7 +185,7 @@ function apply(ctx, rawConfig) {
155
185
  },
156
186
  async transactCuratorState(task) {
157
187
  await mutate(async () => {
158
- await jsonTransact("curator-state.json", (current) => {
188
+ await jsonTransact(io, root, "curator-state.json", (current) => {
159
189
  const next = task(current?.primary ?? null);
160
190
  if (next === null) return current;
161
191
  return {
@@ -173,7 +203,7 @@ function apply(ctx, rawConfig) {
173
203
  },
174
204
  async savePending(record) {
175
205
  await mutate(async () => {
176
- await jsonTransact("pending-state.json", async (current) => {
206
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
177
207
  return {
178
208
  ...await readJson("pending.json") ?? {},
179
209
  ...current ?? {},
@@ -185,7 +215,7 @@ function apply(ctx, rawConfig) {
185
215
  async claimPending(id, claimId) {
186
216
  return await mutate(async () => {
187
217
  const slot = { claimed: null };
188
- await jsonTransact("pending-state.json", async (current) => {
218
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
189
219
  const map = {
190
220
  ...await readJson("pending.json") ?? {},
191
221
  ...current ?? {}
@@ -207,7 +237,7 @@ function apply(ctx, rawConfig) {
207
237
  },
208
238
  async releasePendingClaim(id, claimId) {
209
239
  await mutate(async () => {
210
- await jsonTransact("pending-state.json", async (current) => {
240
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
211
241
  const map = {
212
242
  ...await readJson("pending.json") ?? {},
213
243
  ...current ?? {}
@@ -228,7 +258,7 @@ function apply(ctx, rawConfig) {
228
258
  applied: false
229
259
  };
230
260
  let evicted = [];
231
- await jsonTransact("pending-state.json", async (current) => {
261
+ await jsonTransact(io, root, "pending-state.json", async (current) => {
232
262
  const map = {
233
263
  ...await readJson("pending.json") ?? {},
234
264
  ...current ?? {}
@@ -263,4 +293,4 @@ function apply(ctx, rawConfig) {
263
293
  ctx.effect(() => ctx.evolutionStateStorage.registerProvider(provider), "evolution-state-json.provider");
264
294
  }
265
295
  //#endregion
266
- 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.26",
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.26"
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.26",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.26"
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.26",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.26"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.28",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.28"
46
46
  }
47
47
  }