@hardlydifficult/state-tracker 2.0.12 → 2.0.14
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 +137 -185
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @hardlydifficult/state-tracker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
TypeScript state tracker with atomic JSON persistence, auto-save debouncing, typed migrations, and graceful fallback.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -14,266 +14,210 @@ npm install @hardlydifficult/state-tracker
|
|
|
14
14
|
import { StateTracker } from "@hardlydifficult/state-tracker";
|
|
15
15
|
|
|
16
16
|
const tracker = new StateTracker({
|
|
17
|
-
key: "
|
|
17
|
+
key: "app-config",
|
|
18
18
|
default: { theme: "light", notifications: true },
|
|
19
|
-
autoSaveMs:
|
|
19
|
+
autoSaveMs: 500,
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
tracker.load(); // or await tracker.loadAsync();
|
|
24
|
-
|
|
25
|
-
// Update state and auto-save
|
|
26
|
-
tracker.update({ theme: "dark" });
|
|
27
|
-
// State is saved automatically after 1 second of inactivity
|
|
28
|
-
|
|
29
|
-
// Read current state
|
|
22
|
+
tracker.set({ theme: "dark" }); // saves debounced
|
|
30
23
|
console.log(tracker.state); // { theme: "dark", notifications: true }
|
|
24
|
+
|
|
25
|
+
tracker.reset(); // restores default
|
|
31
26
|
```
|
|
32
27
|
|
|
33
|
-
## State Management
|
|
28
|
+
## Core State Management
|
|
34
29
|
|
|
35
|
-
The `StateTracker` class provides
|
|
30
|
+
The `StateTracker` class provides type-safe state persistence with automatic atomic writes and in-memory caching.
|
|
36
31
|
|
|
37
|
-
### Constructor
|
|
32
|
+
### Constructor Options
|
|
38
33
|
|
|
39
34
|
| Option | Type | Default | Description |
|
|
40
35
|
|--------|------|---------|-------------|
|
|
41
|
-
| `key` | `string` | — | Unique identifier
|
|
42
|
-
| `default` | `T` | — |
|
|
43
|
-
| `stateDirectory
|
|
44
|
-
| `autoSaveMs
|
|
45
|
-
| `onEvent
|
|
36
|
+
| `key` | `string` | — | Unique identifier; must contain only alphanumeric characters, hyphens, underscores |
|
|
37
|
+
| `default` | `T` | — | Initial state used if no saved state is found |
|
|
38
|
+
| `stateDirectory?` | `string` | `~/.app-state` | Override storage directory (or `STATE_TRACKER_DIR` env var) |
|
|
39
|
+
| `autoSaveMs?` | `number` | `0` | Debounce delay (ms) after state changes |
|
|
40
|
+
| `onEvent?` | `(event) => void` | — | Optional callback for debug/info/warn/error events |
|
|
46
41
|
|
|
47
|
-
###
|
|
42
|
+
### Instance Properties
|
|
48
43
|
|
|
49
|
-
-
|
|
50
|
-
-
|
|
44
|
+
- `state: Readonly<T>` — Current in-memory state (read-only)
|
|
45
|
+
- `isPersistent: boolean` — Whether disk storage is available
|
|
46
|
+
- `getFilePath(): string` — Full path to the JSON file
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
const tracker = new StateTracker({
|
|
54
|
-
key: "counter",
|
|
55
|
-
default: 0,
|
|
56
|
-
stateDirectory: "./data",
|
|
57
|
-
});
|
|
48
|
+
### Instance Methods
|
|
58
49
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
50
|
+
| Method | Signature | Description |
|
|
51
|
+
|--------|-----------|-------------|
|
|
52
|
+
| `load()` / `loadSync()` | `(): T` | Sync load (uses defaults on error) |
|
|
53
|
+
| `save(value)` | `(value: T): void` | Sync save to disk |
|
|
54
|
+
| `set(newState)` | `(newState: T): void` | Replace state, triggers auto-save |
|
|
55
|
+
| `update(changes)` | `(changes: Partial<T>): void` | Shallow merge (object state only) |
|
|
56
|
+
| `reset()` | `(): void` | Restore to default value |
|
|
63
57
|
|
|
64
|
-
### Persistence
|
|
58
|
+
### Async Persistence API
|
|
65
59
|
|
|
66
|
-
|
|
67
|
-
Synchronous state load from disk. Returns the current state (default if missing or corrupted).
|
|
60
|
+
Async methods support graceful degradation when file system access fails.
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
const tracker = new StateTracker({
|
|
71
|
-
key: "config",
|
|
72
|
-
default: { version: 1 },
|
|
73
|
-
});
|
|
74
|
-
const config = tracker.load(); // Loads from disk or uses default
|
|
75
|
-
```
|
|
62
|
+
#### Methods
|
|
76
63
|
|
|
77
|
-
|
|
78
|
-
|
|
64
|
+
- `loadAsync(): Promise<void>` — Loads state from disk; sets `isPersistent` to `false` on failure
|
|
65
|
+
- `saveAsync(): Promise<void>` — Atomic async save using temp file + rename
|
|
66
|
+
- Both are idempotent: subsequent calls after first `loadAsync` are no-ops
|
|
79
67
|
|
|
80
|
-
|
|
81
|
-
import { defineStateMigration, StateTracker } from "@hardlydifficult/state-tracker";
|
|
82
|
-
|
|
83
|
-
interface LegacySyncState {
|
|
84
|
-
offset: number;
|
|
85
|
-
completedIds: string[];
|
|
86
|
-
}
|
|
68
|
+
#### Example
|
|
87
69
|
|
|
70
|
+
```typescript
|
|
88
71
|
const tracker = new StateTracker({
|
|
89
|
-
key: "
|
|
90
|
-
default: {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const legacyMigration = defineStateMigration<
|
|
94
|
-
{ cursor: number; done: string[] },
|
|
95
|
-
LegacySyncState
|
|
96
|
-
>({
|
|
97
|
-
name: "sync-state-v0",
|
|
98
|
-
isLegacy(input): input is LegacySyncState {
|
|
99
|
-
return (
|
|
100
|
-
input !== null &&
|
|
101
|
-
typeof input === "object" &&
|
|
102
|
-
!Array.isArray(input) &&
|
|
103
|
-
typeof (input as Record<string, unknown>).offset === "number" &&
|
|
104
|
-
Array.isArray((input as Record<string, unknown>).completedIds)
|
|
105
|
-
);
|
|
106
|
-
},
|
|
107
|
-
migrate(legacy) {
|
|
108
|
-
return { cursor: legacy.offset, done: legacy.completedIds };
|
|
109
|
-
},
|
|
72
|
+
key: "settings",
|
|
73
|
+
default: { fontSize: 14 },
|
|
74
|
+
stateDirectory: "/app/state",
|
|
110
75
|
});
|
|
111
76
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
#### `save(value: T): void`
|
|
116
|
-
Synchronous atomic save using temp file + rename.
|
|
77
|
+
await tracker.loadAsync();
|
|
78
|
+
console.log(tracker.isPersistent); // true (or false if directory not writable)
|
|
117
79
|
|
|
118
|
-
|
|
119
|
-
tracker.
|
|
120
|
-
// File is updated atomically; previous state preserved if crash occurs mid-write
|
|
80
|
+
tracker.set({ fontSize: 16 });
|
|
81
|
+
await tracker.saveAsync();
|
|
121
82
|
```
|
|
122
83
|
|
|
123
|
-
|
|
124
|
-
Synchronous atomic save with optional metadata in the envelope.
|
|
84
|
+
## Auto-Save with Debouncing
|
|
125
85
|
|
|
126
|
-
|
|
127
|
-
tracker.saveWithMeta(
|
|
128
|
-
{ version: 3 },
|
|
129
|
-
{ source: "sync-script", reason: "manual-run" }
|
|
130
|
-
);
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
#### `loadAsync(): Promise<void>`
|
|
134
|
-
Async state load with graceful degradation. Sets `isPersistent = false` on failure instead of throwing.
|
|
86
|
+
Set `autoSaveMs` in the constructor to debounce writes after state changes via `set()` or `update()`.
|
|
135
87
|
|
|
136
88
|
```typescript
|
|
137
89
|
const tracker = new StateTracker({
|
|
138
|
-
key: "
|
|
139
|
-
default: {
|
|
90
|
+
key: "editor",
|
|
91
|
+
default: { text: "", cursor: 0 },
|
|
92
|
+
autoSaveMs: 300,
|
|
140
93
|
});
|
|
141
94
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
console.warn("Running in-memory mode (disk unavailable)");
|
|
145
|
-
}
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
#### `saveAsync(): Promise<void>`
|
|
149
|
-
Async atomic save (temp file + rename). Cancels any pending auto-save before writing.
|
|
150
|
-
|
|
151
|
-
```typescript
|
|
152
|
-
tracker.set({ darkMode: true });
|
|
153
|
-
await tracker.saveAsync(); // Immediate save, bypassing debounce
|
|
95
|
+
tracker.set({ text: "Hello" }); // will auto-save after 300ms
|
|
96
|
+
tracker.update({ cursor: 5 }); // cancels pending auto-save, re-schedules
|
|
154
97
|
```
|
|
155
98
|
|
|
156
|
-
|
|
99
|
+
- `save()` and `saveAsync()` cancel pending auto-saves and write immediately
|
|
100
|
+
- If `autoSaveMs <= 0`, no debounced saves are scheduled
|
|
101
|
+
- On persistent storage failure, auto-save is disabled until `loadAsync` succeeds
|
|
157
102
|
|
|
158
|
-
|
|
159
|
-
Replace entire state and schedule auto-save.
|
|
103
|
+
## Typed Migrations
|
|
160
104
|
|
|
161
|
-
|
|
162
|
-
tracker.set({ darkMode: true, theme: "midnight" });
|
|
163
|
-
// Auto-saves after configured delay (if autoSaveMs > 0)
|
|
164
|
-
```
|
|
105
|
+
Support legacy state formats with typed migrations.
|
|
165
106
|
|
|
166
|
-
|
|
167
|
-
Shallow merge for object types and schedule auto-save.
|
|
107
|
+
### Migration Interface
|
|
168
108
|
|
|
169
109
|
```typescript
|
|
170
|
-
|
|
110
|
+
interface StateTrackerMigration<TCurrent, TLegacy = unknown> {
|
|
111
|
+
name?: string;
|
|
112
|
+
isLegacy(input: unknown): input is TLegacy;
|
|
113
|
+
migrate(legacy: TLegacy): TCurrent;
|
|
114
|
+
}
|
|
171
115
|
```
|
|
172
116
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
#### `reset(): void`
|
|
176
|
-
Restore state to the default value and schedule auto-save.
|
|
177
|
-
|
|
178
|
-
```typescript
|
|
179
|
-
tracker.reset(); // Reverts to default state
|
|
180
|
-
```
|
|
117
|
+
### Helper
|
|
181
118
|
|
|
182
|
-
|
|
119
|
+
- `defineStateMigration<TCurrent, TLegacy>(migration)` — Type-safe migration builder
|
|
183
120
|
|
|
184
|
-
|
|
185
|
-
Returns the full path to the state file.
|
|
121
|
+
### Usage
|
|
186
122
|
|
|
187
123
|
```typescript
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
## Event System
|
|
192
|
-
|
|
193
|
-
Events are emitted for internal operations via the optional `onEvent` callback.
|
|
124
|
+
interface LegacyState { offset: number; completedIds: string[] }
|
|
125
|
+
interface CurrentState { cursor: number; done: string[] }
|
|
194
126
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
127
|
+
const migration = defineStateMigration<CurrentState, LegacyState>({
|
|
128
|
+
name: "cursor-migration",
|
|
129
|
+
isLegacy(input): input is LegacyState {
|
|
130
|
+
return (
|
|
131
|
+
input &&
|
|
132
|
+
typeof input === "object" &&
|
|
133
|
+
"offset" in input &&
|
|
134
|
+
"completedIds" in input
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
migrate(legacy) {
|
|
138
|
+
return {
|
|
139
|
+
cursor: legacy.offset,
|
|
140
|
+
done: legacy.completedIds,
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
});
|
|
201
144
|
|
|
202
|
-
```typescript
|
|
203
145
|
const tracker = new StateTracker({
|
|
204
|
-
key: "
|
|
205
|
-
default: {},
|
|
206
|
-
onEvent: (event) => {
|
|
207
|
-
console[event.level](`[${event.level}] ${event.message}`, event.context);
|
|
208
|
-
},
|
|
146
|
+
key: "tasks",
|
|
147
|
+
default: { cursor: 0, done: [] } as CurrentState,
|
|
209
148
|
});
|
|
210
149
|
|
|
211
|
-
|
|
212
|
-
|
|
150
|
+
// Load with migration
|
|
151
|
+
const value = tracker.loadOrDefault({ migrations: [migration] });
|
|
152
|
+
// Legacy { offset: 3, completedIds: ["a", "b"] } → { cursor: 3, done: ["a", "b"] }
|
|
213
153
|
```
|
|
214
154
|
|
|
215
|
-
|
|
155
|
+
### Envelope Format
|
|
156
|
+
|
|
157
|
+
Saved state uses a JSON envelope:
|
|
216
158
|
|
|
217
|
-
### v2 (Envelope) Format
|
|
218
159
|
```json
|
|
219
160
|
{
|
|
220
|
-
"value": {
|
|
221
|
-
"lastUpdated": "
|
|
222
|
-
"meta": {
|
|
161
|
+
"value": { /* your state */ },
|
|
162
|
+
"lastUpdated": "2025-04-05T12:34:56.789Z",
|
|
163
|
+
"meta": { /* optional metadata */ }
|
|
223
164
|
}
|
|
224
165
|
```
|
|
225
166
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
```typescript
|
|
230
|
-
// If disk contains: { "count": 42 }
|
|
231
|
-
// And default is: { "count": 0, "name": "default" }
|
|
232
|
-
// Loaded state becomes: { "count": 42, "name": "default" }
|
|
233
|
-
```
|
|
167
|
+
- `load()` and `loadAsync()` extract `value` and merge missing keys from `default`
|
|
168
|
+
- Supports raw legacy objects (without envelope) with default-merge
|
|
234
169
|
|
|
235
|
-
|
|
170
|
+
## Event Logging
|
|
236
171
|
|
|
237
|
-
|
|
238
|
-
and pass them to `loadOrDefault({ migrations })`.
|
|
172
|
+
Optionally track runtime events via the `onEvent` callback.
|
|
239
173
|
|
|
240
|
-
|
|
174
|
+
### Event Types
|
|
241
175
|
|
|
242
|
-
|
|
176
|
+
- `debug`: Internal behavior (e.g., auto-save completion)
|
|
177
|
+
- `info`: Startup/loading (e.g., no existing state, directory creation)
|
|
178
|
+
- `warn`: recoverable issues (e.g., storage unavailable, migration failure)
|
|
179
|
+
- `error`: unrecoverable errors (e.g., disk write failure)
|
|
243
180
|
|
|
244
|
-
|
|
245
|
-
2. Subsequent changes within the window reset the timer.
|
|
246
|
-
3. After `autoSaveMs` ms of inactivity, the state is saved.
|
|
181
|
+
### Example
|
|
247
182
|
|
|
248
183
|
```typescript
|
|
249
184
|
const tracker = new StateTracker({
|
|
250
|
-
key: "
|
|
251
|
-
default: {
|
|
252
|
-
|
|
185
|
+
key: "my-state",
|
|
186
|
+
default: {},
|
|
187
|
+
onEvent: (event) => {
|
|
188
|
+
if (event.level === "error") console.error(event.message, event.context);
|
|
189
|
+
},
|
|
253
190
|
});
|
|
254
191
|
|
|
255
|
-
tracker.
|
|
256
|
-
|
|
257
|
-
await new Promise(r => setTimeout(r, 100));
|
|
258
|
-
tracker.set({ x: 3 }); // Timer resets again
|
|
259
|
-
|
|
260
|
-
// Only saved once after 500ms of inactivity with final value { x: 3 }
|
|
192
|
+
await tracker.loadAsync();
|
|
193
|
+
// emits info: "No existing state file" or "Loaded state from disk"
|
|
261
194
|
```
|
|
262
195
|
|
|
263
|
-
|
|
196
|
+
## Appendix: Key Sanitization
|
|
264
197
|
|
|
265
|
-
|
|
198
|
+
Keys must match `^[A-Za-z0-9_-]+$`. Invalid keys throw at construction:
|
|
266
199
|
|
|
267
200
|
```typescript
|
|
268
|
-
|
|
201
|
+
new StateTracker({ key: "../evil", default: 0 });
|
|
202
|
+
// Error: StateTracker key contains invalid characters
|
|
269
203
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
message: string;
|
|
273
|
-
context?: Record<string, unknown>;
|
|
274
|
-
}
|
|
204
|
+
new StateTracker({ key: "", default: 0 });
|
|
205
|
+
// Error: StateTracker key must be a non-empty string
|
|
275
206
|
```
|
|
276
207
|
|
|
208
|
+
State files are written as `<key>.json` inside the state directory.
|
|
209
|
+
|
|
210
|
+
## Exported API
|
|
211
|
+
|
|
212
|
+
- `StateTracker<T>` — Main persistence class
|
|
213
|
+
- `defineStateMigration<TCurrent, TLegacy>` — Migration builder
|
|
214
|
+
- `StateTrackerOptions<T>` — Constructor options
|
|
215
|
+
- `StateTrackerEvent` — Event payload
|
|
216
|
+
- `StateTrackerEventLevel` — `"debug" \| "info" \| "warn" \| "error"`
|
|
217
|
+
- `StateTrackerLoadOrDefaultOptions<T>` — Options for `loadOrDefault`
|
|
218
|
+
- `StateTrackerMigration<TCurrent, TLegacy>` — Migration definition
|
|
219
|
+
- `StateTrackerSaveMeta` — Optional metadata in save envelope
|
|
220
|
+
|
|
277
221
|
## Environment Variable
|
|
278
222
|
|
|
279
223
|
- `STATE_TRACKER_DIR`: Overrides default state directory (`~/.app-state`)
|
|
@@ -291,4 +235,12 @@ STATE_TRACKER_DIR=/custom/path npm start
|
|
|
291
235
|
- **Auto-save** — debounced saves after state mutations
|
|
292
236
|
- **Legacy format support** — reads both v1 envelope format and legacy PersistentStore formats
|
|
293
237
|
- **Typed migration helper** — declarative migration rules for old JSON shapes
|
|
294
|
-
- **
|
|
238
|
+
- **API consistency** — all operations work seamlessly across sync/async modes
|
|
239
|
+
|
|
240
|
+
## Platform Behavior
|
|
241
|
+
|
|
242
|
+
| Environment | Persistence | Fallback Behavior |
|
|
243
|
+
|-------------|-------------|-------------------|
|
|
244
|
+
| Node.js | ✅ File system access | Falls back to memory on errors |
|
|
245
|
+
| Browser | ❌ No file system access | Always in-memory only |
|
|
246
|
+
| Bun/Deno | ⚠️ Experimental support | Depends on environment capabilities |
|