@trevonistrevon/pi-loop 0.5.4 → 0.5.5

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 (74) hide show
  1. package/coverage/base.css +224 -0
  2. package/coverage/block-navigation.js +87 -0
  3. package/coverage/coverage-final.json +32 -0
  4. package/coverage/favicon.png +0 -0
  5. package/coverage/index.html +176 -0
  6. package/coverage/prettify.css +1 -0
  7. package/coverage/prettify.js +2 -0
  8. package/coverage/sort-arrow-sprite.png +0 -0
  9. package/coverage/sorter.js +210 -0
  10. package/coverage/src/commands/index.html +131 -0
  11. package/coverage/src/commands/loop-command.ts.html +634 -0
  12. package/coverage/src/commands/tasks-command.ts.html +490 -0
  13. package/coverage/src/coordinator.ts.html +430 -0
  14. package/coverage/src/goal-coordinator.ts.html +259 -0
  15. package/coverage/src/goal-reducer.ts.html +982 -0
  16. package/coverage/src/goal-store.ts.html +742 -0
  17. package/coverage/src/goal-verifier.ts.html +808 -0
  18. package/coverage/src/index.html +386 -0
  19. package/coverage/src/index.ts.html +1054 -0
  20. package/coverage/src/loop-parse.ts.html +574 -0
  21. package/coverage/src/loop-reducer.ts.html +559 -0
  22. package/coverage/src/monitor-completion-coordinator.ts.html +157 -0
  23. package/coverage/src/monitor-manager.ts.html +865 -0
  24. package/coverage/src/monitor-reducer.ts.html +583 -0
  25. package/coverage/src/notification-reducer.ts.html +550 -0
  26. package/coverage/src/reducer-backed-store.ts.html +589 -0
  27. package/coverage/src/runtime/index.html +191 -0
  28. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +310 -0
  29. package/coverage/src/runtime/notification-runtime.ts.html +721 -0
  30. package/coverage/src/runtime/scope.ts.html +196 -0
  31. package/coverage/src/runtime/session-runtime.ts.html +589 -0
  32. package/coverage/src/runtime/task-backlog-runtime.ts.html +574 -0
  33. package/coverage/src/runtime/task-rpc.ts.html +535 -0
  34. package/coverage/src/scheduler.ts.html +400 -0
  35. package/coverage/src/store.ts.html +667 -0
  36. package/coverage/src/task-backlog-coordinator.ts.html +181 -0
  37. package/coverage/src/task-reducer.ts.html +550 -0
  38. package/coverage/src/task-store.ts.html +526 -0
  39. package/coverage/src/tools/index.html +146 -0
  40. package/coverage/src/tools/loop-tools.ts.html +1000 -0
  41. package/coverage/src/tools/monitor-tools.ts.html +547 -0
  42. package/coverage/src/tools/native-task-tools.ts.html +535 -0
  43. package/coverage/src/trigger-system.ts.html +547 -0
  44. package/coverage/src/ui/index.html +116 -0
  45. package/coverage/src/ui/widget.ts.html +292 -0
  46. package/dist/goal-reducer.d.ts +9 -0
  47. package/dist/goal-reducer.js +11 -2
  48. package/dist/goal-store.d.ts +4 -15
  49. package/dist/goal-store.js +32 -155
  50. package/dist/index.js +2 -1
  51. package/dist/loop-reducer.d.ts +7 -0
  52. package/dist/loop-reducer.js +9 -0
  53. package/dist/reducer-backed-store.d.ts +65 -0
  54. package/dist/reducer-backed-store.js +160 -0
  55. package/dist/store.d.ts +4 -16
  56. package/dist/store.js +25 -157
  57. package/dist/task-reducer.js +3 -0
  58. package/dist/task-store.d.ts +4 -15
  59. package/dist/task-store.js +25 -148
  60. package/dist/tools/monitor-tools.js +9 -0
  61. package/dist/trigger-system.js +2 -1
  62. package/package.json +4 -1
  63. package/src/goal-reducer.ts +20 -1
  64. package/src/goal-store.ts +35 -143
  65. package/src/index.ts +2 -1
  66. package/src/loop-reducer.ts +10 -0
  67. package/src/reducer-backed-store.ts +168 -0
  68. package/src/store.ts +28 -142
  69. package/src/task-reducer.ts +3 -0
  70. package/src/task-store.ts +28 -136
  71. package/src/tools/monitor-tools.ts +9 -0
  72. package/src/trigger-system.ts +2 -1
  73. package/vitest.config.ts +25 -0
  74. package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
package/src/goal-store.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
1
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join } from "node:path";
2
+ import { join } from "node:path";
4
3
  import { type GoalReducerEvent, reduceGoalState } from "./goal-reducer.js";
5
4
  import type {
6
5
  GoalCriteria,
@@ -10,121 +9,24 @@ import type {
10
9
  GoalScope,
11
10
  GoalStoreData,
12
11
  } from "./goal-types.js";
12
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
13
13
 
14
14
  const GOALS_DIR = join(homedir(), ".pi", "goals");
15
- const LOCK_RETRY_MS = 50;
16
- const LOCK_MAX_RETRIES = 100;
17
15
  const MAX_GOALS = 200;
18
16
 
19
- function acquireLock(lockPath: string): void {
20
- mkdirSync(dirname(lockPath), { recursive: true });
21
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
22
- try {
23
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
24
- return;
25
- } catch (e: any) {
26
- if (e.code === "EEXIST") {
27
- try {
28
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
29
- if (!pid || !isProcessRunning(pid)) {
30
- try { unlinkSync(lockPath); } catch { /* ignore */ }
31
- continue;
32
- }
33
- } catch { /* ignore read errors */ }
34
- const start = Date.now();
35
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
36
- continue;
37
- }
38
- throw e;
39
- }
40
- }
41
- throw new Error(`Failed to acquire lock: ${lockPath}`);
42
- }
43
-
44
- function releaseLock(lockPath: string): void {
45
- try { unlinkSync(lockPath); } catch { /* ignore */ }
46
- }
47
-
48
- function isProcessRunning(pid: number): boolean {
49
- try { process.kill(pid, 0); return true; } catch { return false; }
50
- }
51
-
52
- export class GoalStore {
53
- private filePath: string | undefined;
54
- private lockPath: string | undefined;
55
- private lastLoadedSignature: string | undefined;
56
-
57
- private nextId = 1;
58
- private goals = new Map<string, GoalEntry>();
59
-
17
+ export class GoalStore extends ReducerBackedStore<GoalEntry, GoalReducerState, GoalReducerEvent, GoalStoreData> {
60
18
  constructor(listIdOrPath?: string) {
61
- if (!listIdOrPath) return;
62
- const isAbsPath = isAbsolute(listIdOrPath);
63
- const filePath = isAbsPath ? listIdOrPath : join(GOALS_DIR, `${listIdOrPath}.json`);
64
- mkdirSync(dirname(filePath), { recursive: true });
65
- this.filePath = filePath;
66
- this.lockPath = filePath + ".lock";
67
- this.load();
68
- }
69
-
70
- private getFileSignature(): string | undefined {
71
- if (!this.filePath || !existsSync(this.filePath)) return undefined;
72
- const stat = statSync(this.filePath);
73
- return `${stat.mtimeMs}:${stat.size}`;
74
- }
75
-
76
- private load(force = false): void {
77
- if (!this.filePath) return;
78
- const signature = this.getFileSignature();
79
- if (!signature) return;
80
- if (!force && signature === this.lastLoadedSignature) return;
81
- try {
82
- const data: GoalStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
83
- this.nextId = data.nextId;
84
- this.goals.clear();
85
- for (const goal of data.goals) {
86
- this.goals.set(goal.id, goal);
87
- }
88
- this.lastLoadedSignature = signature;
89
- } catch { /* corrupt file — start fresh */ }
90
- }
91
-
92
- private save(): void {
93
- if (!this.filePath) return;
94
- const data: GoalStoreData = {
95
- nextId: this.nextId,
96
- goals: Array.from(this.goals.values()),
97
- };
98
- const tmpPath = this.filePath + ".tmp";
99
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
100
- renameSync(tmpPath, this.filePath);
101
- this.lastLoadedSignature = this.getFileSignature();
102
- }
103
-
104
- private withLock<T>(fn: () => T): T {
105
- if (!this.lockPath) return fn();
106
- acquireLock(this.lockPath);
107
- try {
108
- this.load(true);
109
- const result = fn();
110
- this.save();
111
- return result;
112
- } finally {
113
- releaseLock(this.lockPath);
114
- }
115
- }
116
-
117
- private toReducerState(): GoalReducerState {
118
- return {
119
- nextId: this.nextId,
120
- goalsById: Object.fromEntries(this.goals.entries()),
121
- };
122
- }
123
-
124
- private applyReducerEvent(event: GoalReducerEvent): void {
125
- const result = reduceGoalState(this.toReducerState(), event);
126
- this.nextId = result.state.nextId;
127
- this.goals = new Map(Object.entries(result.state.goalsById));
19
+ super(
20
+ {
21
+ baseDir: GOALS_DIR,
22
+ reduce: (state, event) => reduceGoalState(state, event),
23
+ toReducerState: (nextId, entries) => ({ nextId, goalsById: Object.fromEntries(entries.entries()) }),
24
+ fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.goalsById)) }),
25
+ serialize: (nextId, entries) => ({ nextId, goals: Array.from(entries.values()) }),
26
+ deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.goals.map((g) => [g.id, g])) }),
27
+ },
28
+ listIdOrPath,
29
+ );
128
30
  }
129
31
 
130
32
  create(
@@ -135,7 +37,7 @@ export class GoalStore {
135
37
  metadata?: Record<string, unknown>,
136
38
  ): GoalEntry {
137
39
  return this.withLock(() => {
138
- if (this.goals.size >= MAX_GOALS) {
40
+ if (this.entries.size >= MAX_GOALS) {
139
41
  throw new Error(`Maximum of ${MAX_GOALS} goals reached. Archive some before creating new ones.`);
140
42
  }
141
43
  this.applyReducerEvent({
@@ -151,23 +53,13 @@ export class GoalStore {
151
53
  metadata,
152
54
  },
153
55
  });
154
- return this.goals.get(String(this.nextId - 1))!;
56
+ return this.entries.get(String(this.nextId - 1))!;
155
57
  });
156
58
  }
157
59
 
158
- get(id: string): GoalEntry | undefined {
159
- if (this.filePath) this.load();
160
- return this.goals.get(id);
161
- }
162
-
163
- list(): GoalEntry[] {
164
- if (this.filePath) this.load();
165
- return Array.from(this.goals.values()).sort((a, b) => Number(a.id) - Number(b.id));
166
- }
167
-
168
60
  activate(id: string): GoalEntry | undefined {
169
61
  return this.withLock(() => {
170
- if (!this.goals.has(id)) return undefined;
62
+ if (!this.entries.has(id)) return undefined;
171
63
  this.applyReducerEvent({
172
64
  type: "GOAL_ACTIVATED",
173
65
  at: Date.now(),
@@ -176,13 +68,13 @@ export class GoalStore {
176
68
  entityId: id,
177
69
  payload: { id },
178
70
  });
179
- return this.goals.get(id);
71
+ return this.entries.get(id);
180
72
  });
181
73
  }
182
74
 
183
75
  recordProgress(id: string, progress: GoalProgressSnapshot): GoalEntry | undefined {
184
76
  return this.withLock(() => {
185
- if (!this.goals.has(id)) return undefined;
77
+ if (!this.entries.has(id)) return undefined;
186
78
  this.applyReducerEvent({
187
79
  type: "GOAL_PROGRESS_RECORDED",
188
80
  at: Date.now(),
@@ -191,13 +83,13 @@ export class GoalStore {
191
83
  entityId: id,
192
84
  payload: { id, progress },
193
85
  });
194
- return this.goals.get(id);
86
+ return this.entries.get(id);
195
87
  });
196
88
  }
197
89
 
198
90
  markVerificationStarted(id: string): GoalEntry | undefined {
199
91
  return this.withLock(() => {
200
- if (!this.goals.has(id)) return undefined;
92
+ if (!this.entries.has(id)) return undefined;
201
93
  this.applyReducerEvent({
202
94
  type: "GOAL_VERIFICATION_STARTED",
203
95
  at: Date.now(),
@@ -206,13 +98,13 @@ export class GoalStore {
206
98
  entityId: id,
207
99
  payload: { id },
208
100
  });
209
- return this.goals.get(id);
101
+ return this.entries.get(id);
210
102
  });
211
103
  }
212
104
 
213
105
  markVerified(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
214
106
  return this.withLock(() => {
215
- if (!this.goals.has(id)) return undefined;
107
+ if (!this.entries.has(id)) return undefined;
216
108
  this.applyReducerEvent({
217
109
  type: "GOAL_VERIFICATION_PASSED",
218
110
  at: Date.now(),
@@ -221,13 +113,13 @@ export class GoalStore {
221
113
  entityId: id,
222
114
  payload: { id, reason, progress },
223
115
  });
224
- return this.goals.get(id);
116
+ return this.entries.get(id);
225
117
  });
226
118
  }
227
119
 
228
120
  markFailed(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
229
121
  return this.withLock(() => {
230
- if (!this.goals.has(id)) return undefined;
122
+ if (!this.entries.has(id)) return undefined;
231
123
  this.applyReducerEvent({
232
124
  type: "GOAL_FAILED",
233
125
  at: Date.now(),
@@ -236,13 +128,13 @@ export class GoalStore {
236
128
  entityId: id,
237
129
  payload: { id, reason, progress },
238
130
  });
239
- return this.goals.get(id);
131
+ return this.entries.get(id);
240
132
  });
241
133
  }
242
134
 
243
135
  markBlocked(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
244
136
  return this.withLock(() => {
245
- if (!this.goals.has(id)) return undefined;
137
+ if (!this.entries.has(id)) return undefined;
246
138
  this.applyReducerEvent({
247
139
  type: "GOAL_BLOCKED",
248
140
  at: Date.now(),
@@ -251,13 +143,13 @@ export class GoalStore {
251
143
  entityId: id,
252
144
  payload: { id, reason, progress },
253
145
  });
254
- return this.goals.get(id);
146
+ return this.entries.get(id);
255
147
  });
256
148
  }
257
149
 
258
150
  unblock(id: string): GoalEntry | undefined {
259
151
  return this.withLock(() => {
260
- if (!this.goals.has(id)) return undefined;
152
+ if (!this.entries.has(id)) return undefined;
261
153
  this.applyReducerEvent({
262
154
  type: "GOAL_UNBLOCKED",
263
155
  at: Date.now(),
@@ -266,7 +158,7 @@ export class GoalStore {
266
158
  entityId: id,
267
159
  payload: { id },
268
160
  });
269
- return this.goals.get(id);
161
+ return this.entries.get(id);
270
162
  });
271
163
  }
272
164
 
@@ -281,7 +173,7 @@ export class GoalStore {
281
173
  },
282
174
  ): GoalEntry | undefined {
283
175
  return this.withLock(() => {
284
- if (!this.goals.has(id)) return undefined;
176
+ if (!this.entries.has(id)) return undefined;
285
177
  if (
286
178
  fields.title === undefined
287
179
  && fields.description === undefined
@@ -289,7 +181,7 @@ export class GoalStore {
289
181
  && fields.criteria === undefined
290
182
  && fields.metadata === undefined
291
183
  ) {
292
- return this.goals.get(id);
184
+ return this.entries.get(id);
293
185
  }
294
186
  this.applyReducerEvent({
295
187
  type: "GOAL_UPDATED",
@@ -306,13 +198,13 @@ export class GoalStore {
306
198
  metadata: fields.metadata,
307
199
  },
308
200
  });
309
- return this.goals.get(id);
201
+ return this.entries.get(id);
310
202
  });
311
203
  }
312
204
 
313
205
  archive(id: string, reason?: string): GoalEntry | undefined {
314
206
  return this.withLock(() => {
315
- if (!this.goals.has(id)) return undefined;
207
+ if (!this.entries.has(id)) return undefined;
316
208
  this.applyReducerEvent({
317
209
  type: "GOAL_ARCHIVED",
318
210
  at: Date.now(),
@@ -321,7 +213,7 @@ export class GoalStore {
321
213
  entityId: id,
322
214
  payload: { id, reason },
323
215
  });
324
- return this.goals.get(id);
216
+ return this.entries.get(id);
325
217
  });
326
218
  }
327
219
  }
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { registerLoopCommand } from "./commands/loop-command.js";
19
19
  import { registerTasksCommand } from "./commands/tasks-command.js";
20
+ import { atMaxFires } from "./loop-reducer.js";
20
21
  import { MonitorManager } from "./monitor-manager.js";
21
22
  import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
22
23
  import {
@@ -178,7 +179,7 @@ export default function (pi: ExtensionAPI) {
178
179
  function onLoopFire(entry: LoopEntry): void {
179
180
  debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
180
181
 
181
- if (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
182
+ if (atMaxFires(entry)) {
182
183
  debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
183
184
  store.delete(entry.id);
184
185
  return;
@@ -2,6 +2,16 @@ import type { LoopEntry, Trigger } from "./types.js";
2
2
 
3
3
  export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
4
4
 
5
+ /**
6
+ * Whether a loop has reached its fire cap. Single source of truth for the
7
+ * `maxFires` check shared by the fire callbacks (`onLoopFire` pre-fire guard and
8
+ * `TriggerSystem.fireLoop` post-fire cleanup). Each caller keeps its own timing;
9
+ * only the predicate is shared.
10
+ */
11
+ export function atMaxFires(loop: Pick<LoopEntry, "maxFires" | "fireCount">): boolean {
12
+ return !!loop.maxFires && (loop.fireCount ?? 0) >= loop.maxFires;
13
+ }
14
+
5
15
  type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
6
16
 
7
17
  export interface LoopReducerState {
@@ -0,0 +1,168 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import type { AnyReducerEffect } from "./coordinator.js";
4
+
5
+ const LOCK_RETRY_MS = 50;
6
+ const LOCK_MAX_RETRIES = 100;
7
+
8
+ function acquireLock(lockPath: string): void {
9
+ mkdirSync(dirname(lockPath), { recursive: true });
10
+ for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
11
+ try {
12
+ writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
13
+ return;
14
+ } catch (e: any) {
15
+ if (e.code === "EEXIST") {
16
+ try {
17
+ const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
18
+ if (!pid || !isProcessRunning(pid)) {
19
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
20
+ continue;
21
+ }
22
+ } catch { /* ignore read errors */ }
23
+ const start = Date.now();
24
+ while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
25
+ continue;
26
+ }
27
+ throw e;
28
+ }
29
+ }
30
+ throw new Error(`Failed to acquire lock: ${lockPath}`);
31
+ }
32
+
33
+ function releaseLock(lockPath: string): void {
34
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
35
+ }
36
+
37
+ function isProcessRunning(pid: number): boolean {
38
+ try { process.kill(pid, 0); return true; } catch { return false; }
39
+ }
40
+
41
+ export interface ReducerResult<TState> {
42
+ state: TState;
43
+ effects: AnyReducerEffect[];
44
+ }
45
+
46
+ /**
47
+ * Per-store glue the base needs to translate between its internal
48
+ * `{ nextId, entries }` representation, the pure reducer's state shape, and the
49
+ * on-disk JSON shape. Each is a small, allocation-only function.
50
+ */
51
+ export interface ReducerBackedStoreConfig<TEntry, TState, TEvent, TData> {
52
+ /** Directory for `<listId>.json` when constructed with a bare list id. */
53
+ baseDir: string;
54
+ reduce: (state: TState, event: TEvent) => ReducerResult<TState>;
55
+ toReducerState: (nextId: number, entries: Map<string, TEntry>) => TState;
56
+ fromReducerState: (state: TState) => { nextId: number; entries: Map<string, TEntry> };
57
+ serialize: (nextId: number, entries: Map<string, TEntry>) => TData;
58
+ deserialize: (data: TData) => { nextId: number; entries: Map<string, TEntry> };
59
+ }
60
+
61
+ /**
62
+ * Shared persistence + reducer-dispatch machinery for the file-backed entity
63
+ * stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
64
+ * save, and reducer application; subclasses add only their entity-specific
65
+ * command methods.
66
+ *
67
+ * Durability boundary: every mutation runs inside {@link withLock}, which saves
68
+ * the whole file unconditionally after the callback. Reducer effects are
69
+ * therefore *not* the persistence mechanism — they are surfaced to
70
+ * {@link onEffects} (default: no-op) so cross-entity effects (e.g.
71
+ * `DISPATCH_EVENT`, `REQUEST_GOAL_VERIFICATION`) can be forwarded by the runtime
72
+ * without being silently dropped at the reducer call site.
73
+ */
74
+ export abstract class ReducerBackedStore<TEntry extends { id: string }, TState, TEvent, TData> {
75
+ protected filePath: string | undefined;
76
+ protected lockPath: string | undefined;
77
+ private lastLoadedSignature: string | undefined;
78
+
79
+ protected nextId = 1;
80
+ protected entries = new Map<string, TEntry>();
81
+
82
+ private readonly config: ReducerBackedStoreConfig<TEntry, TState, TEvent, TData>;
83
+
84
+ constructor(config: ReducerBackedStoreConfig<TEntry, TState, TEvent, TData>, listIdOrPath?: string) {
85
+ this.config = config;
86
+ if (!listIdOrPath) return;
87
+ const filePath = isAbsolute(listIdOrPath) ? listIdOrPath : join(config.baseDir, `${listIdOrPath}.json`);
88
+ mkdirSync(dirname(filePath), { recursive: true });
89
+ this.filePath = filePath;
90
+ this.lockPath = `${filePath}.lock`;
91
+ this.load();
92
+ }
93
+
94
+ private getFileSignature(): string | undefined {
95
+ if (!this.filePath || !existsSync(this.filePath)) return undefined;
96
+ const stat = statSync(this.filePath);
97
+ return `${stat.mtimeMs}:${stat.size}`;
98
+ }
99
+
100
+ private load(force = false): void {
101
+ if (!this.filePath) return;
102
+ const signature = this.getFileSignature();
103
+ if (!signature) return;
104
+ if (!force && signature === this.lastLoadedSignature) return;
105
+ try {
106
+ const data: TData = JSON.parse(readFileSync(this.filePath, "utf-8"));
107
+ const { nextId, entries } = this.config.deserialize(data);
108
+ this.nextId = nextId;
109
+ this.entries = entries;
110
+ this.lastLoadedSignature = signature;
111
+ } catch { /* corrupt file — start fresh */ }
112
+ }
113
+
114
+ private save(): void {
115
+ if (!this.filePath) return;
116
+ const data = this.config.serialize(this.nextId, this.entries);
117
+ const tmpPath = `${this.filePath}.tmp`;
118
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2));
119
+ renameSync(tmpPath, this.filePath);
120
+ this.lastLoadedSignature = this.getFileSignature();
121
+ }
122
+
123
+ protected withLock<T>(fn: () => T): T {
124
+ if (!this.lockPath) return fn();
125
+ acquireLock(this.lockPath);
126
+ try {
127
+ this.load(true);
128
+ const result = fn();
129
+ this.save();
130
+ return result;
131
+ } finally {
132
+ releaseLock(this.lockPath);
133
+ }
134
+ }
135
+
136
+ protected applyReducerEvent(event: TEvent): void {
137
+ const result = this.config.reduce(this.config.toReducerState(this.nextId, this.entries), event);
138
+ const { nextId, entries } = this.config.fromReducerState(result.state);
139
+ this.nextId = nextId;
140
+ this.entries = entries;
141
+ if (result.effects.length > 0) this.onEffects(result.effects);
142
+ }
143
+
144
+ /**
145
+ * Hook for reducer effects. Default no-op: durability is owned by
146
+ * {@link withLock}. Override to forward non-persistence effects.
147
+ */
148
+ protected onEffects(_effects: AnyReducerEffect[]): void { /* no-op by default */ }
149
+
150
+ /** Reload (signature-gated) and return the entry, or undefined. */
151
+ get(id: string): TEntry | undefined {
152
+ if (this.filePath) this.load();
153
+ return this.entries.get(id);
154
+ }
155
+
156
+ /** Reload (signature-gated) and return all entries sorted by numeric id. */
157
+ list(): TEntry[] {
158
+ if (this.filePath) this.load();
159
+ return Array.from(this.entries.values()).sort((a, b) => Number(a.id) - Number(b.id));
160
+ }
161
+
162
+ /** Remove the backing file when the store is empty. No-op for memory stores. */
163
+ deleteFileIfEmpty(): boolean {
164
+ if (!this.filePath || this.entries.size > 0) return false;
165
+ try { unlinkSync(this.filePath); } catch { /* ignore */ }
166
+ return true;
167
+ }
168
+ }