@lmzhen/dsh-evolution-state-json 0.3.27 → 0.3.29
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 +89 -53
- package/lib/types/index.d.ts +12 -0
- package/package.json +6 -6
package/lib/index.js
CHANGED
|
@@ -40,21 +40,47 @@ const RECORD_MAP_FILES = new Set([
|
|
|
40
40
|
"pending-state.json",
|
|
41
41
|
"pending.json"
|
|
42
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
|
+
}
|
|
43
80
|
function apply(ctx, rawConfig) {
|
|
44
81
|
const root = (rawConfig.root ?? "").trim() || evolutionHome();
|
|
45
82
|
const io = () => ctx.evolutionIo.provider();
|
|
46
83
|
const pathOf = (file) => join(root, file);
|
|
47
|
-
/** 0.3.17 (E-9): a malformed state file used to parse to `null` and was then
|
|
48
|
-
* OVERWRITTEN by the next save — every other session's review state / the
|
|
49
|
-
* whole pending table vanished silently. Fail loud instead: preserve the
|
|
50
|
-
* original bytes beside it and throw, so the operator can rescue and the
|
|
51
|
-
* corruption is never accepted as "empty". */
|
|
52
|
-
async function quarantine(file, raw, reason) {
|
|
53
|
-
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
54
|
-
const dest = `${pathOf(file)}.corrupt-${stamp}-${Math.random().toString(36).slice(2, 6)}`;
|
|
55
|
-
await io().writeText(dest, raw).catch(() => {});
|
|
56
|
-
throw new Error(`evolution state file "${file}" is not valid JSON (${reason}); original preserved at ${dest} — inspect and fix it, then retry.`);
|
|
57
|
-
}
|
|
58
84
|
async function readJson(file) {
|
|
59
85
|
const raw = await io().readText(pathOf(file));
|
|
60
86
|
if (raw === null) return null;
|
|
@@ -62,40 +88,47 @@ function apply(ctx, rawConfig) {
|
|
|
62
88
|
try {
|
|
63
89
|
parsed = JSON.parse(raw);
|
|
64
90
|
} catch (error) {
|
|
65
|
-
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));
|
|
66
92
|
}
|
|
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}`);
|
|
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}`);
|
|
68
94
|
return parsed;
|
|
69
95
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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));
|
|
96
|
+
const mutate = makeSerialQueue();
|
|
97
|
+
let legacyMigrated = false;
|
|
98
|
+
async function retireLegacyOnce(legacy, current) {
|
|
99
|
+
if (legacyMigrated) return {};
|
|
100
|
+
try {
|
|
101
|
+
const archivedIds = /* @__PURE__ */ new Set();
|
|
102
|
+
try {
|
|
103
|
+
const rawArchive = await readJson("pending-state-archive.json");
|
|
104
|
+
if (Array.isArray(rawArchive)) {
|
|
105
|
+
for (const entry of rawArchive) if (entry && typeof entry.id === "string") archivedIds.add(entry.id);
|
|
85
106
|
}
|
|
86
|
-
|
|
107
|
+
} catch {}
|
|
108
|
+
const retired = {};
|
|
109
|
+
for (const [id, record] of Object.entries(legacy)) {
|
|
110
|
+
if (id in (current ?? {})) continue;
|
|
111
|
+
if (archivedIds.has(id)) continue;
|
|
112
|
+
retired[id] = record;
|
|
87
113
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
114
|
+
await jsonTransact(io, root, "pending-state.json", () => ({
|
|
115
|
+
...retired,
|
|
116
|
+
...current ?? {}
|
|
117
|
+
}));
|
|
118
|
+
await io().rename(pathOf("pending.json"), pathOf("pending.json.migrated"));
|
|
119
|
+
legacyMigrated = true;
|
|
120
|
+
return retired;
|
|
121
|
+
} catch {
|
|
122
|
+
return legacy;
|
|
123
|
+
}
|
|
91
124
|
}
|
|
92
|
-
const mutate = makeSerialQueue();
|
|
93
125
|
async function loadPendingMap() {
|
|
94
126
|
const [current, legacy] = await Promise.all([readJson("pending-state.json"), readJson("pending.json")]);
|
|
95
|
-
return {
|
|
96
|
-
...legacy
|
|
127
|
+
if (legacy !== null) return {
|
|
128
|
+
...await retireLegacyOnce(legacy, current),
|
|
97
129
|
...current ?? {}
|
|
98
130
|
};
|
|
131
|
+
return { ...current ?? {} };
|
|
99
132
|
}
|
|
100
133
|
/** 0.3.22 (F-336): when the live pending map holds more than
|
|
101
134
|
* `PENDING_RESOLVED_CAP` resolved records, drop the oldest (by resolvedAt,
|
|
@@ -110,12 +143,15 @@ function apply(ctx, rawConfig) {
|
|
|
110
143
|
evicted: []
|
|
111
144
|
};
|
|
112
145
|
const overflow = resolved.length - PENDING_RESOLVED_CAP;
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
const entryTime = (record) => {
|
|
147
|
+
if (!record.resolvedAt) return Number.MAX_SAFE_INTEGER;
|
|
148
|
+
const parsed = Date.parse(record.resolvedAt);
|
|
149
|
+
return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
|
|
150
|
+
};
|
|
151
|
+
const oldest = resolved.sort((a, b) => entryTime(a) - entryTime(b)).slice(0, overflow);
|
|
116
152
|
const evictIds = new Set(oldest.map((record) => record.id));
|
|
117
153
|
const kept = {};
|
|
118
|
-
for (const [key, value] of Object.entries(map)) if (!evictIds.has(
|
|
154
|
+
for (const [key, value] of Object.entries(map)) if (!evictIds.has(value.id)) kept[key] = value;
|
|
119
155
|
return {
|
|
120
156
|
map: kept,
|
|
121
157
|
evicted: oldest
|
|
@@ -144,8 +180,8 @@ function apply(ctx, rawConfig) {
|
|
|
144
180
|
if (fresh.length === 0) return current;
|
|
145
181
|
const next = [...archive, ...fresh];
|
|
146
182
|
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);
|
|
183
|
+
if (archive.length > 0) await io().writeText(pathOf("pending-state-archive.json.bak"), JSON.stringify(archive, null, 2)).catch(() => {});
|
|
184
|
+
return JSON.stringify(fresh.slice(-5e3), null, 2);
|
|
149
185
|
}
|
|
150
186
|
return JSON.stringify(next, null, 2);
|
|
151
187
|
});
|
|
@@ -160,7 +196,7 @@ function apply(ctx, rawConfig) {
|
|
|
160
196
|
},
|
|
161
197
|
async saveReviewState(sessionId, record) {
|
|
162
198
|
await mutate(async () => {
|
|
163
|
-
await jsonTransact("review-state.json", (current) => ({
|
|
199
|
+
await jsonTransact(io, root, "review-state.json", (current) => ({
|
|
164
200
|
...current ?? {},
|
|
165
201
|
[sessionId]: record
|
|
166
202
|
}));
|
|
@@ -173,7 +209,7 @@ function apply(ctx, rawConfig) {
|
|
|
173
209
|
},
|
|
174
210
|
async saveCuratorState(record) {
|
|
175
211
|
await mutate(async () => {
|
|
176
|
-
await jsonTransact("curator-state.json", (current) => ({
|
|
212
|
+
await jsonTransact(io, root, "curator-state.json", (current) => ({
|
|
177
213
|
...current ?? {},
|
|
178
214
|
primary: record
|
|
179
215
|
}));
|
|
@@ -181,7 +217,7 @@ function apply(ctx, rawConfig) {
|
|
|
181
217
|
},
|
|
182
218
|
async transactCuratorState(task) {
|
|
183
219
|
await mutate(async () => {
|
|
184
|
-
await jsonTransact("curator-state.json", (current) => {
|
|
220
|
+
await jsonTransact(io, root, "curator-state.json", (current) => {
|
|
185
221
|
const next = task(current?.primary ?? null);
|
|
186
222
|
if (next === null) return current;
|
|
187
223
|
return {
|
|
@@ -199,9 +235,9 @@ function apply(ctx, rawConfig) {
|
|
|
199
235
|
},
|
|
200
236
|
async savePending(record) {
|
|
201
237
|
await mutate(async () => {
|
|
202
|
-
await jsonTransact("pending-state.json", async (current) => {
|
|
238
|
+
await jsonTransact(io, root, "pending-state.json", async (current) => {
|
|
203
239
|
return {
|
|
204
|
-
...await readJson("pending.json") ?? {},
|
|
240
|
+
...(legacyMigrated ? null : await readJson("pending.json")) ?? {},
|
|
205
241
|
...current ?? {},
|
|
206
242
|
[record.id]: record
|
|
207
243
|
};
|
|
@@ -211,9 +247,9 @@ function apply(ctx, rawConfig) {
|
|
|
211
247
|
async claimPending(id, claimId) {
|
|
212
248
|
return await mutate(async () => {
|
|
213
249
|
const slot = { claimed: null };
|
|
214
|
-
await jsonTransact("pending-state.json", async (current) => {
|
|
250
|
+
await jsonTransact(io, root, "pending-state.json", async (current) => {
|
|
215
251
|
const map = {
|
|
216
|
-
...await readJson("pending.json") ?? {},
|
|
252
|
+
...(legacyMigrated ? null : await readJson("pending.json")) ?? {},
|
|
217
253
|
...current ?? {}
|
|
218
254
|
};
|
|
219
255
|
const record = map[id] ?? null;
|
|
@@ -233,9 +269,9 @@ function apply(ctx, rawConfig) {
|
|
|
233
269
|
},
|
|
234
270
|
async releasePendingClaim(id, claimId) {
|
|
235
271
|
await mutate(async () => {
|
|
236
|
-
await jsonTransact("pending-state.json", async (current) => {
|
|
272
|
+
await jsonTransact(io, root, "pending-state.json", async (current) => {
|
|
237
273
|
const map = {
|
|
238
|
-
...await readJson("pending.json") ?? {},
|
|
274
|
+
...(legacyMigrated ? null : await readJson("pending.json")) ?? {},
|
|
239
275
|
...current ?? {}
|
|
240
276
|
};
|
|
241
277
|
const record = map[id];
|
|
@@ -254,9 +290,9 @@ function apply(ctx, rawConfig) {
|
|
|
254
290
|
applied: false
|
|
255
291
|
};
|
|
256
292
|
let evicted = [];
|
|
257
|
-
await jsonTransact("pending-state.json", async (current) => {
|
|
293
|
+
await jsonTransact(io, root, "pending-state.json", async (current) => {
|
|
258
294
|
const map = {
|
|
259
|
-
...await readJson("pending.json") ?? {},
|
|
295
|
+
...(legacyMigrated ? null : await readJson("pending.json")) ?? {},
|
|
260
296
|
...current ?? {}
|
|
261
297
|
};
|
|
262
298
|
const record = map[id] ?? null;
|
|
@@ -289,4 +325,4 @@ function apply(ctx, rawConfig) {
|
|
|
289
325
|
ctx.effect(() => ctx.evolutionStateStorage.registerProvider(provider), "evolution-state-json.provider");
|
|
290
326
|
}
|
|
291
327
|
//#endregion
|
|
292
|
-
export { Config, apply, inject, name };
|
|
328
|
+
export { Config, apply, inject, jsonTransact, name };
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.3.29",
|
|
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.
|
|
34
|
+
"@lmzhen/dsh-evolution-core": "^0.3.29"
|
|
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.
|
|
40
|
-
"@lmzhen/dsh-evolution-state-storage": "^0.3.
|
|
39
|
+
"@lmzhen/dsh-evolution-io": "^0.3.29",
|
|
40
|
+
"@lmzhen/dsh-evolution-state-storage": "^0.3.29"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
|
|
44
|
-
"@lmzhen/dsh-evolution-io": "^0.3.
|
|
45
|
-
"@lmzhen/dsh-evolution-state-storage": "^0.3.
|
|
44
|
+
"@lmzhen/dsh-evolution-io": "^0.3.29",
|
|
45
|
+
"@lmzhen/dsh-evolution-state-storage": "^0.3.29"
|
|
46
46
|
}
|
|
47
47
|
}
|