@lmzhen/dsh-evolution-state-json 0.3.62 → 0.3.64

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/README.md CHANGED
@@ -22,5 +22,6 @@ Independent of request-prefix construction. This package does not alter the asse
22
22
  ## Known Limitations and Deferred Work
23
23
 
24
24
 
25
+ - P2-4 (v15): the live pending map is BOUNDED — resolved (approved/rejected) records are kept to the most recent `PENDING_RESOLVED_CAP` (200, seam constant in `evolution-state-storage`); the oldest by `resolvedAt` rotate into the `pending-state-archive.json` sidecar (with `.bak` rotation), which is JSON-provider-specific. The DOMAIN provider enforces the same live cap but has no sidecar: past the cap its resolved records are deleted, not archived.
25
26
  - JSON provider serializes writers inside one process AND through the IO backend's cross-process transact lock (an internal transact wrapper — not public API, audit v10 S-03 — wraps every mutation, 0.3.20/0.3.27) — this provider is NOT limited to single-process safety. The caveat below is about the DSH storage-domain providers (`storage-json` documents no cross-process write locking) when the DOMAIN provider is used instead; multi-process deployments should route the evolution domain to a backend with cross-process semantics such as SQLite or remote storage.
26
27
 
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { evolutionHome, makeSerialQueue, transactIo } from "@lmzhen/dsh-evolution-core";
3
- import { CURATOR_STATE_FILE, CURATOR_STATE_KEY, PENDING_ARCHIVE_BAK_FILE, PENDING_ARCHIVE_FILE, PENDING_LEGACY_FILE, PENDING_STATE_FILE, PROVIDER_JSON, REVIEW_STATE_FILE, canClaimPending, canResolvePending, releasedStatus } from "@lmzhen/dsh-evolution-state-storage";
3
+ import { CURATOR_STATE_FILE, CURATOR_STATE_KEY, PENDING_ARCHIVE_BAK_FILE, PENDING_ARCHIVE_FILE, PENDING_LEGACY_FILE, PENDING_RESOLVED_CAP, PENDING_STATE_FILE, PROVIDER_JSON, REVIEW_STATE_FILE, canClaimPending, canResolvePending, releasedStatus } from "@lmzhen/dsh-evolution-state-storage";
4
4
  import { isAbsolute, join } from "node:path";
5
5
  //#region lib/types/index.js
6
6
  /**
@@ -15,9 +15,10 @@ const inject = ["evolutionStateStorage", "evolutionIo"];
15
15
  const Config = z.object({ root: z.string().default("") });
16
16
  /** 0.3.22 (F-336): resolved (approved/rejected) audit records are capped in
17
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;
18
+ * bound; the oldest over the cap are archived. P2-4 (v15): the number lives
19
+ * on the seam (`SEAM_PENDING_RESOLVED_CAP`) so the domain provider enforces
20
+ * the same bound; the archive sidecar below stays json-specific. */
21
+ const PENDING_RESOLVED_CAP$1 = PENDING_RESOLVED_CAP;
21
22
  /** 0.3.27 (V4-01): the audit sidecar (pending-state-archive.json) is bounded
22
23
  * at this many resolved records. Past it the oldest history rotates to a
23
24
  * `.bak` sidecar, so the file — and the full-array rewrite on every append —
@@ -90,7 +91,7 @@ const PENDING_STATUSES = new Set([
90
91
  ]);
91
92
  const gateReviewRecord = (record) => isNonNegInt(record.turnsSinceMemory) && isNonNegInt(record.turnsSinceSkill) && isNonNegInt(record.lastTurn);
92
93
  const gateCuratorRecord = (record) => typeof record.lastRunAt === "number" && Number.isFinite(record.lastRunAt) && record.lastRunAt >= 0 && isNonNegInt(record.runCount) && typeof record.lastSummary === "string" && typeof record.paused === "boolean";
93
- const gatePendingRecord = (record) => typeof record.id === "string" && typeof record.kind === "string" && PENDING_KINDS.has(record.kind) && typeof record.summary === "string" && typeof record.createdAt === "string" && typeof record.status === "string" && PENDING_STATUSES.has(record.status) && optionalString(record.resolvedAt) && optionalString(record.claimedBy) && optionalString(record.claimedAt) && optionalString(record.origin) && optionalString(record.sessionId);
94
+ const gatePendingRecord = (record) => typeof record.id === "string" && typeof record.kind === "string" && PENDING_KINDS.has(record.kind) && typeof record.summary === "string" && "args" in record && typeof record.createdAt === "string" && typeof record.status === "string" && PENDING_STATUSES.has(record.status) && optionalString(record.resolvedAt) && optionalString(record.claimedBy) && optionalString(record.claimedAt) && optionalString(record.origin) && optionalString(record.sessionId);
94
95
  const RECORD_FIELD_GATES = {
95
96
  [REVIEW_STATE_FILE]: gateReviewRecord,
96
97
  [CURATOR_STATE_FILE]: gateCuratorRecord,
@@ -123,6 +124,15 @@ function gateScan(file, parsed) {
123
124
  const recordGateWarned = /* @__PURE__ */ new Set();
124
125
  const corruptWritten = /* @__PURE__ */ new Map();
125
126
  const corruptWriteWarned = /* @__PURE__ */ new Set();
127
+ /** P3 (v15): name carried by the two fail-loud write gates in `jsonTransact`
128
+ * (task-return shape, write-back field gate) so best-effort wrappers —
129
+ * `retireLegacyOnce` is the one that matters — rethrow instead of deferring. */
130
+ const WRITE_GATE_ERROR_NAME = "EvolutionStateWriteGate";
131
+ function writeGateError(message) {
132
+ const error = new Error(message);
133
+ error.name = WRITE_GATE_ERROR_NAME;
134
+ return error;
135
+ }
126
136
  function reportGateViolation(ctx, file, failing) {
127
137
  if (recordGateWarned.has(file)) return;
128
138
  recordGateWarned.add(file);
@@ -130,7 +140,10 @@ function reportGateViolation(ctx, file, failing) {
130
140
  }
131
141
  async function ensureCorruptCopy(ctx, io, root, file, bad) {
132
142
  const corruptPath = `${join(root, file)}.corrupt`;
133
- const corruptKey = JSON.stringify(Object.keys(bad).sort());
143
+ const corruptKey = JSON.stringify(Object.entries(bad).map(([id, record]) => ({
144
+ id,
145
+ fields: typeof record === "object" && record !== null ? Object.entries(record).map(([field, value]) => `${field}:${Array.isArray(value) ? "array" : typeof value}`).sort() : [typeof record]
146
+ })).sort((a, b) => a.id.localeCompare(b.id)));
134
147
  if (corruptWritten.get(file) === corruptKey && await io().exists(corruptPath)) return;
135
148
  if (await io().writeText(corruptPath, JSON.stringify(bad, null, 2)).then(() => true).catch(() => false)) {
136
149
  corruptWritten.set(file, corruptKey);
@@ -164,15 +177,15 @@ async function jsonTransact(ctx, io, root, file, task) {
164
177
  }
165
178
  }
166
179
  const next = await task(parsed);
167
- 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.`);
180
+ if (next !== null && RECORD_MAP_FILES.has(file) && !isPlainRecord(next)) throw writeGateError(`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.`);
168
181
  if (next !== null && RECORD_MAP_FILES.has(file)) {
169
182
  const failing = gateScan(file, next);
170
- if (failing.length > 0) throw new Error(`evolution state file "${file}" write-back carries ${failing.length} record(s) that fail the field gate (${failing.map(([id]) => id).join(", ")}); not written.`);
183
+ if (failing.length > 0) throw writeGateError(`evolution state file "${file}" write-back carries ${failing.length} record(s) that fail the field gate (${failing.map(([id]) => id).join(", ")}); not written.`);
171
184
  }
172
185
  return next === null ? null : JSON.stringify(next, null, 2);
173
186
  });
174
187
  }
175
- function apply(ctx, rawConfig) {
188
+ function apply(ctx, rawConfig = {}) {
176
189
  const root = (rawConfig.root ?? "").trim() || evolutionHome();
177
190
  if (rawConfig.root !== void 0 && rawConfig.root.trim() !== "" && !isAbsolute(rawConfig.root)) ctx.logger.warn(`evolution-state-json: config.root "${rawConfig.root}" is a relative path and resolves against the process CWD ("${root}") — pass an absolute path to make the store location launch-independent`);
178
191
  const io = () => ctx.evolutionIo.provider();
@@ -240,7 +253,7 @@ function apply(ctx, rawConfig) {
240
253
  legacyMigrated = true;
241
254
  return retired;
242
255
  } catch (error) {
243
- if (error instanceof Error && error.name === QUARANTINE_ERROR_NAME) throw error;
256
+ if (error instanceof Error && (error.name === QUARANTINE_ERROR_NAME || error.name === WRITE_GATE_ERROR_NAME)) throw error;
244
257
  ctx.logger.warn(`evolution-state-json: legacy pending retirement deferred: ${error instanceof Error ? error.message : String(error)}`);
245
258
  return {};
246
259
  }
@@ -272,11 +285,11 @@ function apply(ctx, rawConfig) {
272
285
  * mutating in place) plus the evicted records. */
273
286
  function enforceResolvedCap(map) {
274
287
  const resolved = Object.values(map).filter((record) => record.status === "approved" || record.status === "rejected");
275
- if (resolved.length <= PENDING_RESOLVED_CAP) return {
288
+ if (resolved.length <= PENDING_RESOLVED_CAP$1) return {
276
289
  map,
277
290
  evicted: []
278
291
  };
279
- const overflow = resolved.length - PENDING_RESOLVED_CAP;
292
+ const overflow = resolved.length - PENDING_RESOLVED_CAP$1;
280
293
  const entryTime = (record) => {
281
294
  if (!record.resolvedAt) return Number.MAX_SAFE_INTEGER;
282
295
  const parsed = Date.parse(record.resolvedAt);
@@ -309,7 +322,9 @@ function apply(ctx, rawConfig) {
309
322
  if (current !== null) try {
310
323
  const parsed = JSON.parse(current);
311
324
  if (Array.isArray(parsed)) archive = parsed;
312
- } catch {}
325
+ } catch {
326
+ await io().writeText(`${pathOf(PENDING_ARCHIVE_FILE)}.corrupt`, current).catch(() => {});
327
+ }
313
328
  const shaped = archive.filter((entry) => entry !== null && typeof entry === "object");
314
329
  const archiveKeys = /* @__PURE__ */ new Set();
315
330
  const collapsed = shaped.filter((entry) => {
@@ -15,5 +15,5 @@ export interface Config {
15
15
  }
16
16
  export declare const Config: z<Config>;
17
17
  export declare function jsonTransact<T>(ctx: Context, io: () => EvolutionIoLike, root: string, file: string, task: (current: T | null) => T | null | Promise<T | null>): Promise<void>;
18
- export declare function apply(ctx: Context, rawConfig: Config): void;
18
+ export declare function apply(ctx: Context, rawConfig?: Config): void;
19
19
  //# 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.62",
4
+ "version": "0.3.64",
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.62"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.64"
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.62",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.62"
39
+ "@lmzhen/dsh-evolution-io": "^0.3.64",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.64"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@lmzhen/dsh-evolution-io": "^0.3.62",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.62",
46
- "@lmzhen/dsh-evolution-io-node": "^0.3.62"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.64",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.64",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.3.64"
47
47
  }
48
48
  }