@lmzhen/dsh-evolution-state-json 0.3.21 → 0.3.23

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.
Files changed (2) hide show
  1. package/lib/index.js +68 -6
  2. package/package.json +6 -6
package/lib/index.js CHANGED
@@ -13,6 +13,23 @@ import { join } from "node:path";
13
13
  const name = "evolution-state-json";
14
14
  const inject = ["evolutionStateStorage", "evolutionIo"];
15
15
  const Config = z.object({ root: z.string().default("") });
16
+ /** 0.3.22 (F-336): resolved (approved/rejected) audit records are capped in
17
+ * the LIVE pending map so a long-running deployment never grows it without
18
+ * bound; the oldest over the cap are archived (made package-private so the
19
+ * archive sidecar and the provider enforce one number). */
20
+ const PENDING_RESOLVED_CAP = 200;
21
+ /** 0.3.22 (F-215): a record-map state file must parse to a non-null plain
22
+ * object (a map of records) — valid JSON that is `null`/array/scalar is a
23
+ * corrupt map that used to read as "empty" and was silently overwritten by
24
+ * the next save. Only these four files are record maps; the archive sidecar
25
+ * is a top-level ARRAY and must NOT be gated by this predicate. */
26
+ const isPlainRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
27
+ const RECORD_MAP_FILES = new Set([
28
+ "review-state.json",
29
+ "curator-state.json",
30
+ "pending-state.json",
31
+ "pending.json"
32
+ ]);
16
33
  function apply(ctx, rawConfig) {
17
34
  const root = rawConfig.root || evolutionHome();
18
35
  const io = () => ctx.evolutionIo.provider();
@@ -32,7 +49,9 @@ function apply(ctx, rawConfig) {
32
49
  const raw = await io().readText(pathOf(file));
33
50
  if (raw === null) return null;
34
51
  try {
35
- return JSON.parse(raw);
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;
36
55
  } catch (error) {
37
56
  return await quarantine(file, raw, error instanceof Error ? error.message : String(error));
38
57
  }
@@ -49,6 +68,7 @@ function apply(ctx, rawConfig) {
49
68
  let parsed = null;
50
69
  if (current !== null) try {
51
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}`);
52
72
  } catch (error) {
53
73
  return await quarantine(file, current, error instanceof Error ? error.message : String(error));
54
74
  }
@@ -64,6 +84,47 @@ function apply(ctx, rawConfig) {
64
84
  ...current ?? {}
65
85
  };
66
86
  }
87
+ /** 0.3.22 (F-336): when the live pending map holds more than
88
+ * `PENDING_RESOLVED_CAP` resolved records, drop the oldest (by resolvedAt,
89
+ * then insertion order on ties) from the map and return them for archiving.
90
+ * Only approved/rejected records are candidates — pending/executing are
91
+ * live work and are never trimmed. Returns the pruned map (rather than
92
+ * mutating in place) plus the evicted records. */
93
+ function enforceResolvedCap(map) {
94
+ const resolved = Object.values(map).filter((record) => record.status === "approved" || record.status === "rejected");
95
+ if (resolved.length <= PENDING_RESOLVED_CAP) return {
96
+ map,
97
+ evicted: []
98
+ };
99
+ const overflow = resolved.length - PENDING_RESOLVED_CAP;
100
+ const oldest = resolved.sort((a, b) => {
101
+ return (a.resolvedAt ? Date.parse(a.resolvedAt) : Number.MAX_SAFE_INTEGER) - (b.resolvedAt ? Date.parse(b.resolvedAt) : Number.MAX_SAFE_INTEGER);
102
+ }).slice(0, overflow);
103
+ const evictIds = new Set(oldest.map((record) => record.id));
104
+ const kept = {};
105
+ for (const [key, value] of Object.entries(map)) if (!evictIds.has(key)) kept[key] = value;
106
+ return {
107
+ map: kept,
108
+ evicted: oldest
109
+ };
110
+ }
111
+ /** 0.3.22 (F-336): append evicted resolved records to an audit sidecar
112
+ * (top-level array, oldest-first). This is a best-effort audit aid: a
113
+ * corrupt/unreadable archive is skipped and an archive write failure must
114
+ * NEVER fail the resolve that triggered it — the live map is already
115
+ * trimmed, so the audit copy is allowed to fall behind. */
116
+ async function appendArchive(records) {
117
+ try {
118
+ await transactIo(io(), pathOf("pending-state-archive.json"), (current) => {
119
+ let archive = [];
120
+ if (current !== null) try {
121
+ const parsed = JSON.parse(current);
122
+ if (Array.isArray(parsed)) archive = parsed;
123
+ } catch {}
124
+ return JSON.stringify([...archive, ...records], null, 2);
125
+ });
126
+ } catch {}
127
+ }
67
128
  const provider = {
68
129
  name: "json",
69
130
  async loadReviewState(sessionId) {
@@ -96,10 +157,7 @@ function apply(ctx, rawConfig) {
96
157
  await mutate(async () => {
97
158
  await jsonTransact("curator-state.json", (current) => {
98
159
  const next = task(current?.primary ?? null);
99
- if (next === null) {
100
- if (current !== null) delete current.primary;
101
- return current ?? {};
102
- }
160
+ if (next === null) return current;
103
161
  return {
104
162
  ...current ?? {},
105
163
  primary: next
@@ -171,6 +229,7 @@ function apply(ctx, rawConfig) {
171
229
  record: null,
172
230
  applied: false
173
231
  };
232
+ let evicted = [];
174
233
  await jsonTransact("pending-state.json", async (current) => {
175
234
  const map = {
176
235
  ...await readJson("pending.json") ?? {},
@@ -194,8 +253,11 @@ function apply(ctx, rawConfig) {
194
253
  record: resolved,
195
254
  applied: true
196
255
  };
197
- return map;
256
+ const pruned = enforceResolvedCap(map);
257
+ evicted = pruned.evicted;
258
+ return pruned.map;
198
259
  });
260
+ if (evicted.length > 0) await appendArchive(evicted);
199
261
  return result;
200
262
  });
201
263
  }
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.21",
4
+ "version": "0.3.23",
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.21"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.23"
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.21",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.21"
39
+ "@lmzhen/dsh-evolution-io": "^0.3.23",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.23"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@lmzhen/dsh-evolution-io": "^0.3.21",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.21"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.23",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.23"
46
46
  }
47
47
  }