@lmzhen/dsh-evolution-state-json 0.3.56 → 0.3.58

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 +48 -25
  2. package/package.json +7 -7
package/lib/index.js CHANGED
@@ -57,8 +57,13 @@ const QUARANTINE_ERROR_NAME = "EvolutionStateCorruptFile";
57
57
  * copy is swept after 7 days by the node backend's sweepStaleTmps (S-10). */
58
58
  async function quarantine(io, root, file, raw, reason) {
59
59
  const dest = `${join(root, file)}.corrupt`;
60
- await io().writeText(dest, raw).catch(() => {});
61
- throw Object.assign(/* @__PURE__ */ new Error(`evolution state file "${file}" is not valid JSON (${reason}); original preserved at ${dest} — inspect and fix it, then retry.`), { name: QUARANTINE_ERROR_NAME });
60
+ let preservedNote = `; original preserved at ${dest} — inspect and fix it, then retry.`;
61
+ try {
62
+ await io().writeText(dest, raw);
63
+ } catch (writeError) {
64
+ preservedNote = `; the quarantine copy at ${dest} FAILED to write (${writeError instanceof Error ? writeError.message : String(writeError)}) — the original file is left in place.`;
65
+ }
66
+ throw Object.assign(/* @__PURE__ */ new Error(`evolution state file "${file}" is not valid JSON (${reason})${preservedNote}`), { name: QUARANTINE_ERROR_NAME });
62
67
  }
63
68
  /** S-05: the V8-16 record-shape gate existed as two verbatim ~15-line
64
69
  * copies (jsonTransact + readJson); this private helper is the single owner.
@@ -91,6 +96,16 @@ const RECORD_FIELD_GATES = {
91
96
  [PENDING_STATE_FILE]: gatePendingRecord,
92
97
  [PENDING_LEGACY_FILE]: gatePendingRecord
93
98
  };
99
+ /** V11-B1 (P2-23): shared per-record field-gate scan for BOTH paths (readJson
100
+ * + jsonTransact — the transaction baseline used to skip the field gates, so
101
+ * a string `runCount` became `"x1"` through `(current?.runCount ?? 0) + 1` at
102
+ * the next save). Returns the failing [id, record] entries; empty when the
103
+ * file has no gate, the value is not a plain map, or everything passes. */
104
+ function gateScan(file, parsed) {
105
+ const gate = RECORD_FIELD_GATES[file];
106
+ if (gate === void 0 || !isPlainRecord(parsed)) return [];
107
+ return Object.entries(parsed).filter(([, record]) => !isPlainRecord(record) || !gate(record));
108
+ }
94
109
  /**
95
110
  * Cross-process JSON-file RMW (v3-audit M-8): every read-modify-write state
96
111
  * mutation runs inside the IO backend's transact lock (via transactIo) so a
@@ -116,6 +131,15 @@ async function jsonTransact(io, root, file, task) {
116
131
  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}`);
117
132
  const malformed = firstNonRecordValue(parsed);
118
133
  if (malformed !== null) return await quarantine(io, root, file, current, malformed);
134
+ const failing = gateScan(file, parsed);
135
+ if (failing.length > 0) {
136
+ const bad = {};
137
+ const good = {};
138
+ for (const [id, record] of Object.entries(parsed)) if (failing.some(([failedId]) => failedId === id)) bad[id] = record;
139
+ else good[id] = record;
140
+ await io().writeText(`${join(root, file)}.corrupt`, JSON.stringify(bad, null, 2)).catch(() => {});
141
+ parsed = good;
142
+ }
119
143
  }
120
144
  const next = await task(parsed);
121
145
  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.`);
@@ -128,6 +152,7 @@ function apply(ctx, rawConfig) {
128
152
  const io = () => ctx.evolutionIo.provider();
129
153
  const pathOf = (file) => join(root, file);
130
154
  const recordGateWarned = /* @__PURE__ */ new Set();
155
+ const corruptWritten = /* @__PURE__ */ new Map();
131
156
  async function readJson(file) {
132
157
  const raw = await io().readText(pathOf(file));
133
158
  if (raw === null) return null;
@@ -140,30 +165,28 @@ function apply(ctx, rawConfig) {
140
165
  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}`);
141
166
  const malformed = firstNonRecordValue(parsed);
142
167
  if (malformed !== null) return await quarantine(io, root, file, raw, malformed);
143
- const gate = RECORD_FIELD_GATES[file];
144
- if (gate !== void 0 && isPlainRecord(parsed)) {
145
- const entries = Object.entries(parsed);
146
- const failing = entries.filter(([, record]) => !isPlainRecord(record) || !gate(record)).map(([id]) => id);
147
- if (failing.length > 0) {
148
- const bad = {};
149
- const good = {};
150
- for (const [id, record] of entries) if (failing.includes(id)) bad[id] = record;
151
- else good[id] = record;
168
+ const failing = gateScan(file, parsed);
169
+ if (failing.length > 0) {
170
+ const bad = {};
171
+ const good = {};
172
+ for (const [id, record] of Object.entries(parsed)) if (failing.some(([failedId]) => failedId === id)) bad[id] = record;
173
+ else good[id] = record;
174
+ const corruptKey = JSON.stringify(failing.map(([id]) => id).sort());
175
+ if (corruptWritten.get(file) !== corruptKey) {
152
176
  await io().writeText(`${pathOf(file)}.corrupt`, JSON.stringify(bad, null, 2)).catch(() => {});
153
- if (!recordGateWarned.has(file)) {
154
- recordGateWarned.add(file);
155
- ctx.logger.warn(`evolution-state-json: ${failing.length} record(s) in "${file}" failed the record schema gate and were quarantined to "${file}.corrupt": ${failing.join(", ")}`);
156
- }
157
- return good;
177
+ corruptWritten.set(file, corruptKey);
158
178
  }
179
+ if (!recordGateWarned.has(file)) {
180
+ recordGateWarned.add(file);
181
+ ctx.logger.warn(`evolution-state-json: ${failing.length} record(s) in "${file}" failed the record schema gate and were quarantined to "${file}.corrupt": ${failing.map(([id]) => id).join(", ")}`);
182
+ }
183
+ return good;
159
184
  }
160
185
  return parsed;
161
186
  }
162
187
  const mutate = makeSerialQueue();
163
188
  let legacyMigrated = false;
164
- let archivedIdsCache = null;
165
189
  async function readArchivedIds() {
166
- if (archivedIdsCache !== null) return archivedIdsCache;
167
190
  const ids = /* @__PURE__ */ new Set();
168
191
  try {
169
192
  const rawArchive = await readJson(PENDING_ARCHIVE_FILE);
@@ -177,7 +200,6 @@ function apply(ctx, rawConfig) {
177
200
  }
178
201
  } catch {}
179
202
  } catch {}
180
- archivedIdsCache = ids;
181
203
  return ids;
182
204
  }
183
205
  function filterLegacy(legacy, current, archivedIds) {
@@ -203,7 +225,7 @@ function apply(ctx, rawConfig) {
203
225
  } catch (error) {
204
226
  if (error instanceof Error && error.name === QUARANTINE_ERROR_NAME) throw error;
205
227
  ctx.logger.warn(`evolution-state-json: legacy pending retirement deferred: ${error instanceof Error ? error.message : String(error)}`);
206
- return legacy;
228
+ return {};
207
229
  }
208
230
  }
209
231
  async function loadPendingMap() {
@@ -271,8 +293,9 @@ function apply(ctx, rawConfig) {
271
293
  const parsed = JSON.parse(current);
272
294
  if (Array.isArray(parsed)) archive = parsed;
273
295
  } catch {}
296
+ const shaped = archive.filter((entry) => entry !== null && typeof entry === "object");
274
297
  const archiveKeys = /* @__PURE__ */ new Set();
275
- const collapsed = archive.filter((entry) => {
298
+ const collapsed = shaped.filter((entry) => {
276
299
  const key = pendingArchiveKey(entry);
277
300
  if (archiveKeys.has(key)) return false;
278
301
  archiveKeys.add(key);
@@ -280,19 +303,19 @@ function apply(ctx, rawConfig) {
280
303
  });
281
304
  const hadDuplicates = collapsed.length !== archive.length;
282
305
  archive = collapsed;
283
- const seen = new Set(archive.map(pendingArchiveKey));
306
+ const seen = new Set(collapsed.map(pendingArchiveKey));
284
307
  const fresh = records.filter((record) => !seen.has(pendingArchiveKey(record)));
285
308
  if (fresh.length === 0 && !hadDuplicates) return current;
286
309
  const next = [...archive, ...fresh];
287
310
  if (next.length > ARCHIVE_RESOLVED_CAP) {
288
311
  if (archive.length > 0) await io().writeText(pathOf(PENDING_ARCHIVE_BAK_FILE), JSON.stringify(archive, null, 2)).catch(() => {});
289
- archivedIdsCache = null;
290
312
  return JSON.stringify((fresh.length > 0 ? fresh : next).slice(-5e3), null, 2);
291
313
  }
292
- archivedIdsCache = null;
293
314
  return JSON.stringify(next, null, 2);
294
315
  });
295
- } catch {}
316
+ } catch (error) {
317
+ ctx.logger.warn(`evolution-state-json: archive append deferred: ${error instanceof Error ? error.message : String(error)}`);
318
+ }
296
319
  }
297
320
  const provider = {
298
321
  name: PROVIDER_JSON,
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.56",
4
+ "version": "0.3.58",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -31,18 +31,18 @@
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
33
  "@deepseek-ai/schemastery": "^3.18.1",
34
- "@lmzhen/dsh-evolution-core": "^0.3.56"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.58"
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.56",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.56"
39
+ "@lmzhen/dsh-evolution-io": "^0.3.58",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.58"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@lmzhen/dsh-evolution-io": "^0.3.56",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.56",
46
- "@lmzhen/dsh-evolution-io-node": "^0.3.56"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.58",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.58",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.3.58"
47
47
  }
48
48
  }