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

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 +37 -11
  2. package/package.json +6 -6
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
@@ -31,7 +41,7 @@ const RECORD_MAP_FILES = new Set([
31
41
  "pending.json"
32
42
  ]);
33
43
  function apply(ctx, rawConfig) {
34
- const root = rawConfig.root || evolutionHome();
44
+ const root = (rawConfig.root ?? "").trim() || evolutionHome();
35
45
  const io = () => ctx.evolutionIo.provider();
36
46
  const pathOf = (file) => join(root, file);
37
47
  /** 0.3.17 (E-9): a malformed state file used to parse to `null` and was then
@@ -48,13 +58,14 @@ function apply(ctx, rawConfig) {
48
58
  async function readJson(file) {
49
59
  const raw = await io().readText(pathOf(file));
50
60
  if (raw === null) return null;
61
+ let parsed;
51
62
  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;
63
+ parsed = JSON.parse(raw);
55
64
  } catch (error) {
56
65
  return await quarantine(file, raw, error instanceof Error ? error.message : String(error));
57
66
  }
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}`);
68
+ return parsed;
58
69
  }
59
70
  /**
60
71
  * Cross-process JSON-file RMW (v3-audit M-8): every read-modify-write state
@@ -66,11 +77,13 @@ function apply(ctx, rawConfig) {
66
77
  async function jsonTransact(file, task) {
67
78
  await transactIo(ctx.evolutionIo.provider(), pathOf(file), async (current) => {
68
79
  let parsed = null;
69
- if (current !== null) try {
70
- parsed = JSON.parse(current);
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
+ }
71
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}`);
72
- } catch (error) {
73
- return await quarantine(file, current, error instanceof Error ? error.message : String(error));
74
87
  }
75
88
  const next = await task(parsed);
76
89
  return next === null ? null : JSON.stringify(next, null, 2);
@@ -112,16 +125,29 @@ function apply(ctx, rawConfig) {
112
125
  * (top-level array, oldest-first). This is a best-effort audit aid: a
113
126
  * corrupt/unreadable archive is skipped and an archive write failure must
114
127
  * NEVER fail the resolve that triggered it — the live map is already
115
- * trimmed, so the audit copy is allowed to fall behind. */
128
+ * trimmed, so the audit copy is allowed to fall behind.
129
+ * 0.3.27 (V4-01): dedupe by id+status+resolvedAt before appending (the
130
+ * read-only legacy `pending.json` re-introduces an evicted record on the
131
+ * next resolve) and rotate the sidecar to `.bak` past ARCHIVE_RESOLVED_CAP
132
+ * so neither the file nor the per-append full-array rewrite grows without
133
+ * bound. */
116
134
  async function appendArchive(records) {
117
135
  try {
118
- await transactIo(io(), pathOf("pending-state-archive.json"), (current) => {
136
+ await transactIo(io(), pathOf("pending-state-archive.json"), async (current) => {
119
137
  let archive = [];
120
138
  if (current !== null) try {
121
139
  const parsed = JSON.parse(current);
122
140
  if (Array.isArray(parsed)) archive = parsed;
123
141
  } catch {}
124
- return JSON.stringify([...archive, ...records], null, 2);
142
+ const seen = new Set(archive.map(pendingArchiveKey));
143
+ const fresh = records.filter((record) => !seen.has(pendingArchiveKey(record)));
144
+ if (fresh.length === 0) return current;
145
+ const next = [...archive, ...fresh];
146
+ 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);
149
+ }
150
+ return JSON.stringify(next, null, 2);
125
151
  });
126
152
  } catch {}
127
153
  }
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.25",
4
+ "version": "0.3.27",
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.25"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.27"
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.25",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.25"
39
+ "@lmzhen/dsh-evolution-io": "^0.3.27",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.27"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@lmzhen/dsh-evolution-io": "^0.3.25",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.25"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.27",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.27"
46
46
  }
47
47
  }