@lmzhen/dsh-evolution-state-json 0.3.26 → 0.3.28
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 +77 -47
- package/lib/types/index.d.ts +12 -0
- 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
|
|
@@ -30,51 +40,58 @@ const RECORD_MAP_FILES = new Set([
|
|
|
30
40
|
"pending-state.json",
|
|
31
41
|
"pending.json"
|
|
32
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
|
+
}
|
|
33
80
|
function apply(ctx, rawConfig) {
|
|
34
|
-
const root = rawConfig.root || evolutionHome();
|
|
81
|
+
const root = (rawConfig.root ?? "").trim() || evolutionHome();
|
|
35
82
|
const io = () => ctx.evolutionIo.provider();
|
|
36
83
|
const pathOf = (file) => join(root, file);
|
|
37
|
-
/** 0.3.17 (E-9): a malformed state file used to parse to `null` and was then
|
|
38
|
-
* OVERWRITTEN by the next save — every other session's review state / the
|
|
39
|
-
* whole pending table vanished silently. Fail loud instead: preserve the
|
|
40
|
-
* original bytes beside it and throw, so the operator can rescue and the
|
|
41
|
-
* corruption is never accepted as "empty". */
|
|
42
|
-
async function quarantine(file, raw, reason) {
|
|
43
|
-
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
44
|
-
const dest = `${pathOf(file)}.corrupt-${stamp}-${Math.random().toString(36).slice(2, 6)}`;
|
|
45
|
-
await io().writeText(dest, raw).catch(() => {});
|
|
46
|
-
throw new Error(`evolution state file "${file}" is not valid JSON (${reason}); original preserved at ${dest} — inspect and fix it, then retry.`);
|
|
47
|
-
}
|
|
48
84
|
async function readJson(file) {
|
|
49
85
|
const raw = await io().readText(pathOf(file));
|
|
50
86
|
if (raw === null) return null;
|
|
87
|
+
let parsed;
|
|
51
88
|
try {
|
|
52
|
-
|
|
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;
|
|
89
|
+
parsed = JSON.parse(raw);
|
|
55
90
|
} catch (error) {
|
|
56
|
-
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));
|
|
57
92
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
* Cross-process JSON-file RMW (v3-audit M-8): every read-modify-write state
|
|
61
|
-
* mutation runs inside the IO backend's transact lock (via transactIo) so a
|
|
62
|
-
* second process sharing DSH_HOME cannot interleave its claim/resolve.
|
|
63
|
-
* `task` returns the next value (null = delete); the legacy `pending.json`
|
|
64
|
-
* merge stays inside the task via `readJson` where relevant.
|
|
65
|
-
*/
|
|
66
|
-
async function jsonTransact(file, task) {
|
|
67
|
-
await transactIo(ctx.evolutionIo.provider(), pathOf(file), async (current) => {
|
|
68
|
-
let parsed = null;
|
|
69
|
-
if (current !== null) try {
|
|
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}`);
|
|
72
|
-
} catch (error) {
|
|
73
|
-
return await quarantine(file, current, error instanceof Error ? error.message : String(error));
|
|
74
|
-
}
|
|
75
|
-
const next = await task(parsed);
|
|
76
|
-
return next === null ? null : JSON.stringify(next, null, 2);
|
|
77
|
-
});
|
|
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}`);
|
|
94
|
+
return parsed;
|
|
78
95
|
}
|
|
79
96
|
const mutate = makeSerialQueue();
|
|
80
97
|
async function loadPendingMap() {
|
|
@@ -112,16 +129,29 @@ function apply(ctx, rawConfig) {
|
|
|
112
129
|
* (top-level array, oldest-first). This is a best-effort audit aid: a
|
|
113
130
|
* corrupt/unreadable archive is skipped and an archive write failure must
|
|
114
131
|
* NEVER fail the resolve that triggered it — the live map is already
|
|
115
|
-
* trimmed, so the audit copy is allowed to fall behind.
|
|
132
|
+
* trimmed, so the audit copy is allowed to fall behind.
|
|
133
|
+
* 0.3.27 (V4-01): dedupe by id+status+resolvedAt before appending (the
|
|
134
|
+
* read-only legacy `pending.json` re-introduces an evicted record on the
|
|
135
|
+
* next resolve) and rotate the sidecar to `.bak` past ARCHIVE_RESOLVED_CAP
|
|
136
|
+
* so neither the file nor the per-append full-array rewrite grows without
|
|
137
|
+
* bound. */
|
|
116
138
|
async function appendArchive(records) {
|
|
117
139
|
try {
|
|
118
|
-
await transactIo(io(), pathOf("pending-state-archive.json"), (current) => {
|
|
140
|
+
await transactIo(io(), pathOf("pending-state-archive.json"), async (current) => {
|
|
119
141
|
let archive = [];
|
|
120
142
|
if (current !== null) try {
|
|
121
143
|
const parsed = JSON.parse(current);
|
|
122
144
|
if (Array.isArray(parsed)) archive = parsed;
|
|
123
145
|
} catch {}
|
|
124
|
-
|
|
146
|
+
const seen = new Set(archive.map(pendingArchiveKey));
|
|
147
|
+
const fresh = records.filter((record) => !seen.has(pendingArchiveKey(record)));
|
|
148
|
+
if (fresh.length === 0) return current;
|
|
149
|
+
const next = [...archive, ...fresh];
|
|
150
|
+
if (next.length > ARCHIVE_RESOLVED_CAP) {
|
|
151
|
+
await io().writeText(pathOf("pending-state-archive.json.bak"), JSON.stringify(archive, null, 2)).catch(() => {});
|
|
152
|
+
return JSON.stringify(fresh, null, 2);
|
|
153
|
+
}
|
|
154
|
+
return JSON.stringify(next, null, 2);
|
|
125
155
|
});
|
|
126
156
|
} catch {}
|
|
127
157
|
}
|
|
@@ -134,7 +164,7 @@ function apply(ctx, rawConfig) {
|
|
|
134
164
|
},
|
|
135
165
|
async saveReviewState(sessionId, record) {
|
|
136
166
|
await mutate(async () => {
|
|
137
|
-
await jsonTransact("review-state.json", (current) => ({
|
|
167
|
+
await jsonTransact(io, root, "review-state.json", (current) => ({
|
|
138
168
|
...current ?? {},
|
|
139
169
|
[sessionId]: record
|
|
140
170
|
}));
|
|
@@ -147,7 +177,7 @@ function apply(ctx, rawConfig) {
|
|
|
147
177
|
},
|
|
148
178
|
async saveCuratorState(record) {
|
|
149
179
|
await mutate(async () => {
|
|
150
|
-
await jsonTransact("curator-state.json", (current) => ({
|
|
180
|
+
await jsonTransact(io, root, "curator-state.json", (current) => ({
|
|
151
181
|
...current ?? {},
|
|
152
182
|
primary: record
|
|
153
183
|
}));
|
|
@@ -155,7 +185,7 @@ function apply(ctx, rawConfig) {
|
|
|
155
185
|
},
|
|
156
186
|
async transactCuratorState(task) {
|
|
157
187
|
await mutate(async () => {
|
|
158
|
-
await jsonTransact("curator-state.json", (current) => {
|
|
188
|
+
await jsonTransact(io, root, "curator-state.json", (current) => {
|
|
159
189
|
const next = task(current?.primary ?? null);
|
|
160
190
|
if (next === null) return current;
|
|
161
191
|
return {
|
|
@@ -173,7 +203,7 @@ function apply(ctx, rawConfig) {
|
|
|
173
203
|
},
|
|
174
204
|
async savePending(record) {
|
|
175
205
|
await mutate(async () => {
|
|
176
|
-
await jsonTransact("pending-state.json", async (current) => {
|
|
206
|
+
await jsonTransact(io, root, "pending-state.json", async (current) => {
|
|
177
207
|
return {
|
|
178
208
|
...await readJson("pending.json") ?? {},
|
|
179
209
|
...current ?? {},
|
|
@@ -185,7 +215,7 @@ function apply(ctx, rawConfig) {
|
|
|
185
215
|
async claimPending(id, claimId) {
|
|
186
216
|
return await mutate(async () => {
|
|
187
217
|
const slot = { claimed: null };
|
|
188
|
-
await jsonTransact("pending-state.json", async (current) => {
|
|
218
|
+
await jsonTransact(io, root, "pending-state.json", async (current) => {
|
|
189
219
|
const map = {
|
|
190
220
|
...await readJson("pending.json") ?? {},
|
|
191
221
|
...current ?? {}
|
|
@@ -207,7 +237,7 @@ function apply(ctx, rawConfig) {
|
|
|
207
237
|
},
|
|
208
238
|
async releasePendingClaim(id, claimId) {
|
|
209
239
|
await mutate(async () => {
|
|
210
|
-
await jsonTransact("pending-state.json", async (current) => {
|
|
240
|
+
await jsonTransact(io, root, "pending-state.json", async (current) => {
|
|
211
241
|
const map = {
|
|
212
242
|
...await readJson("pending.json") ?? {},
|
|
213
243
|
...current ?? {}
|
|
@@ -228,7 +258,7 @@ function apply(ctx, rawConfig) {
|
|
|
228
258
|
applied: false
|
|
229
259
|
};
|
|
230
260
|
let evicted = [];
|
|
231
|
-
await jsonTransact("pending-state.json", async (current) => {
|
|
261
|
+
await jsonTransact(io, root, "pending-state.json", async (current) => {
|
|
232
262
|
const map = {
|
|
233
263
|
...await readJson("pending.json") ?? {},
|
|
234
264
|
...current ?? {}
|
|
@@ -263,4 +293,4 @@ function apply(ctx, rawConfig) {
|
|
|
263
293
|
ctx.effect(() => ctx.evolutionStateStorage.registerProvider(provider), "evolution-state-json.provider");
|
|
264
294
|
}
|
|
265
295
|
//#endregion
|
|
266
|
-
export { Config, apply, inject, name };
|
|
296
|
+
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.28",
|
|
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.28"
|
|
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.28",
|
|
40
|
+
"@lmzhen/dsh-evolution-state-storage": "^0.3.28"
|
|
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.28",
|
|
45
|
+
"@lmzhen/dsh-evolution-state-storage": "^0.3.28"
|
|
46
46
|
}
|
|
47
47
|
}
|