@hardlydifficult/state-tracker 2.0.10 → 2.0.11
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 +142 -141
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @hardlydifficult/state-tracker
|
|
2
2
|
|
|
3
|
-
Atomic JSON state persistence with sync/async APIs, auto-save, and graceful degradation for TypeScript
|
|
3
|
+
Atomic JSON state persistence with sync/async APIs, auto-save debouncing, and graceful degradation to in-memory mode for TypeScript.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -13,205 +13,214 @@ npm install @hardlydifficult/state-tracker
|
|
|
13
13
|
```typescript
|
|
14
14
|
import { StateTracker } from "@hardlydifficult/state-tracker";
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const store = new StateTracker<AppState>({
|
|
22
|
-
key: "my-service",
|
|
23
|
-
default: { requestCount: 0, lastActiveAt: "" },
|
|
24
|
-
stateDirectory: "/var/data",
|
|
25
|
-
autoSaveMs: 5000,
|
|
26
|
-
onEvent: ({ level, message }) => console.log(`[${level}] ${message}`),
|
|
16
|
+
const tracker = new StateTracker({
|
|
17
|
+
key: "user-settings",
|
|
18
|
+
default: { theme: "light", notifications: true },
|
|
19
|
+
autoSaveMs: 1000,
|
|
27
20
|
});
|
|
28
21
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
store.state.requestCount; // read current state
|
|
32
|
-
store.update({ requestCount: store.state.requestCount + 1 }); // partial update
|
|
33
|
-
store.set({ requestCount: 0, lastActiveAt: new Date().toISOString() }); // full replace
|
|
34
|
-
await store.saveAsync(); // force immediate save
|
|
35
|
-
```
|
|
22
|
+
// Load persisted state (sync or async)
|
|
23
|
+
tracker.load(); // or await tracker.loadAsync();
|
|
36
24
|
|
|
37
|
-
|
|
25
|
+
// Update state and auto-save
|
|
26
|
+
tracker.update({ theme: "dark" });
|
|
27
|
+
// State is saved automatically after 1 second of inactivity
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
```typescript
|
|
42
|
-
new StateTracker({ key: "../evil", default: 0 }); // throws: "invalid characters"
|
|
43
|
-
new StateTracker({ key: "foo/bar", default: 0 }); // throws: "invalid characters"
|
|
29
|
+
// Read current state
|
|
30
|
+
console.log(tracker.state); // { theme: "dark", notifications: true }
|
|
44
31
|
```
|
|
45
32
|
|
|
46
|
-
##
|
|
33
|
+
## State Management
|
|
47
34
|
|
|
48
|
-
|
|
35
|
+
The `StateTracker` class provides a robust interface for managing persistent application state.
|
|
49
36
|
|
|
50
|
-
|
|
51
|
-
const tracker = new StateTracker({
|
|
52
|
-
key: "failing",
|
|
53
|
-
default: { x: 1 },
|
|
54
|
-
});
|
|
37
|
+
### Constructor
|
|
55
38
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
39
|
+
| Option | Type | Default | Description |
|
|
40
|
+
|--------|------|---------|-------------|
|
|
41
|
+
| `key` | `string` | — | Unique identifier for the state file (alphanumeric, hyphens, underscores only) |
|
|
42
|
+
| `default` | `T` | — | Default state value used if no persisted state exists |
|
|
43
|
+
| `stateDirectory` | `string` | `~/.app-state` or `$STATE_TRACKER_DIR` | Directory to store state files |
|
|
44
|
+
| `autoSaveMs` | `number` | `0` | Debounce delay (ms) for auto-save after state changes |
|
|
45
|
+
| `onEvent` | `(event: StateTrackerEvent) => void` | `undefined` | Callback for internal events (debug/info/warn/error) |
|
|
60
46
|
|
|
61
|
-
|
|
47
|
+
### State Accessors
|
|
62
48
|
|
|
63
|
-
|
|
49
|
+
- **`state: Readonly<T>`** — Read-only getter for the current in-memory state.
|
|
50
|
+
- **`isPersistent: boolean`** — Indicates whether disk persistence is available (set after `loadAsync()`).
|
|
64
51
|
|
|
65
52
|
```typescript
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const store = new StateTracker<number>({
|
|
53
|
+
const tracker = new StateTracker({
|
|
69
54
|
key: "counter",
|
|
70
55
|
default: 0,
|
|
56
|
+
stateDirectory: "./data",
|
|
71
57
|
});
|
|
72
58
|
|
|
73
|
-
|
|
74
|
-
|
|
59
|
+
console.log(tracker.state); // 0
|
|
60
|
+
await tracker.loadAsync();
|
|
61
|
+
console.log(tracker.isPersistent); // true if disk write succeeded
|
|
75
62
|
```
|
|
76
63
|
|
|
77
|
-
|
|
64
|
+
### Persistence Operations
|
|
78
65
|
|
|
79
|
-
|
|
66
|
+
#### `load(): T`
|
|
67
|
+
Synchronous state load from disk. Returns the current state (default if missing or corrupted).
|
|
80
68
|
|
|
81
69
|
```typescript
|
|
82
|
-
const
|
|
83
|
-
key: "
|
|
70
|
+
const tracker = new StateTracker({
|
|
71
|
+
key: "config",
|
|
84
72
|
default: { version: 1 },
|
|
85
|
-
stateDirectory: "/var/state",
|
|
86
|
-
autoSaveMs: 5000,
|
|
87
73
|
});
|
|
88
|
-
|
|
89
|
-
await store.loadAsync();
|
|
90
|
-
store.set({ version: 2 });
|
|
91
|
-
await store.saveAsync(); // Force immediate save
|
|
74
|
+
const config = tracker.load(); // Loads from disk or uses default
|
|
92
75
|
```
|
|
93
76
|
|
|
94
|
-
|
|
77
|
+
#### `save(value: T): void`
|
|
78
|
+
Synchronous atomic save using temp file + rename.
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
tracker.save({ version: 2 });
|
|
82
|
+
// File is updated atomically; previous state preserved if crash occurs mid-write
|
|
83
|
+
```
|
|
95
84
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
| `set(newState)` | Replace entire state, triggers auto-save | `tracker.set({ count: 5 })` |
|
|
99
|
-
| `update(changes)` | Shallow merge (object state only), triggers auto-save | `tracker.update({ count: 5 })` |
|
|
100
|
-
| `reset()` | Restore to default, triggers auto-save | `tracker.reset()` |
|
|
101
|
-
| `state` (getter) | Read-only current state | `tracker.state` |
|
|
102
|
-
| `isPersistent` (getter) | Whether storage is available | `tracker.isPersistent` |
|
|
85
|
+
#### `loadAsync(): Promise<void>`
|
|
86
|
+
Async state load with graceful degradation. Sets `isPersistent = false` on failure instead of throwing.
|
|
103
87
|
|
|
104
88
|
```typescript
|
|
105
89
|
const tracker = new StateTracker({
|
|
106
|
-
key: "
|
|
107
|
-
default: {
|
|
90
|
+
key: "preferences",
|
|
91
|
+
default: { darkMode: false },
|
|
108
92
|
});
|
|
109
93
|
|
|
110
|
-
tracker.
|
|
111
|
-
tracker.
|
|
112
|
-
|
|
94
|
+
await tracker.loadAsync();
|
|
95
|
+
if (!tracker.isPersistent) {
|
|
96
|
+
console.warn("Running in-memory mode (disk unavailable)");
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
#### `saveAsync(): Promise<void>`
|
|
101
|
+
Async atomic save (temp file + rename). Cancels any pending auto-save before writing.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
tracker.set({ darkMode: true });
|
|
105
|
+
await tracker.saveAsync(); // Immediate save, bypassing debounce
|
|
113
106
|
```
|
|
114
107
|
|
|
115
|
-
###
|
|
108
|
+
### State Mutations
|
|
109
|
+
|
|
110
|
+
#### `set(newState: T): void`
|
|
111
|
+
Replace entire state and schedule auto-save.
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
tracker.set({ darkMode: true, theme: "midnight" });
|
|
115
|
+
// Auto-saves after configured delay (if autoSaveMs > 0)
|
|
116
|
+
```
|
|
116
117
|
|
|
117
|
-
|
|
118
|
+
#### `update(changes: Partial<T>): void`
|
|
119
|
+
Shallow merge for object types and schedule auto-save.
|
|
118
120
|
|
|
119
121
|
```typescript
|
|
120
|
-
|
|
121
|
-
primitive.update(100 as never); // throws: "update() can only be used when state is a non-array object"
|
|
122
|
+
tracker.update({ theme: "dark" }); // Preserves darkMode: true
|
|
122
123
|
```
|
|
123
124
|
|
|
124
|
-
|
|
125
|
+
> **Note:** Throws at runtime if state is not an object (array/primitive).
|
|
125
126
|
|
|
126
|
-
|
|
127
|
+
#### `reset(): void`
|
|
128
|
+
Restore state to the default value and schedule auto-save.
|
|
127
129
|
|
|
128
130
|
```typescript
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
});
|
|
131
|
+
tracker.reset(); // Reverts to default state
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### File Management
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
#### `getFilePath(): string`
|
|
137
|
+
Returns the full path to the state file.
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
console.log(tracker.getFilePath()); // "/home/user/.app-state/counter.json"
|
|
138
141
|
```
|
|
139
142
|
|
|
140
|
-
## Event
|
|
143
|
+
## Event System
|
|
141
144
|
|
|
142
|
-
|
|
145
|
+
Events are emitted for internal operations via the optional `onEvent` callback.
|
|
146
|
+
|
|
147
|
+
| Level | Description |
|
|
148
|
+
|-------|-------------|
|
|
149
|
+
| `debug` | Low-level operations (e.g., save completion) |
|
|
150
|
+
| `info` | Normal operations (e.g., file read/write) |
|
|
151
|
+
| `warn` | Recoverable failures (e.g., disk I/O errors) |
|
|
152
|
+
| `error` | Non-recoverable failures (e.g., permission issues) |
|
|
143
153
|
|
|
144
154
|
```typescript
|
|
145
155
|
const tracker = new StateTracker({
|
|
146
|
-
key: "
|
|
147
|
-
default: {
|
|
156
|
+
key: "app",
|
|
157
|
+
default: {},
|
|
148
158
|
onEvent: (event) => {
|
|
149
|
-
console.
|
|
159
|
+
console[event.level](`[${event.level}] ${event.message}`, event.context);
|
|
150
160
|
},
|
|
151
161
|
});
|
|
152
162
|
|
|
153
163
|
await tracker.loadAsync();
|
|
154
|
-
// Outputs: [info]
|
|
164
|
+
// Outputs: [info] Loaded state from disk { path: ".../app.json" }
|
|
155
165
|
```
|
|
156
166
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
| Field | Type | Description |
|
|
160
|
-
|-------|------|-------------|
|
|
161
|
-
| `level` | `"debug" \| "info" \| "warn" \| "error"` | Severity level |
|
|
162
|
-
| `message` | `string` | Human-readable message |
|
|
163
|
-
| `context`? | `Record<string, unknown>` | Additional metadata (e.g., `path`, `error`) |
|
|
164
|
-
|
|
165
|
-
## File Format
|
|
166
|
-
|
|
167
|
-
StateTracker uses an envelope format for storage:
|
|
167
|
+
## Persistence Format
|
|
168
168
|
|
|
169
|
+
### v2 (Envelope) Format
|
|
169
170
|
```json
|
|
170
171
|
{
|
|
171
|
-
"value":
|
|
172
|
-
"lastUpdated": "
|
|
172
|
+
"value": { "theme": "dark", "notifications": true },
|
|
173
|
+
"lastUpdated": "2024-05-01T12:00:00.000Z"
|
|
173
174
|
}
|
|
174
175
|
```
|
|
175
176
|
|
|
176
|
-
### Migration
|
|
177
|
+
### Legacy (PersistentStore) Migration
|
|
178
|
+
The tracker automatically detects and merges legacy raw JSON objects with defaults on load.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
// If disk contains: { "count": 42 }
|
|
182
|
+
// And default is: { "count": 0, "name": "default" }
|
|
183
|
+
// Loaded state becomes: { "count": 42, "name": "default" }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
After the first `saveAsync()`, files are rewritten in the v2 envelope format.
|
|
187
|
+
|
|
188
|
+
## Auto-Save Behavior
|
|
177
189
|
|
|
178
|
-
|
|
190
|
+
When `autoSaveMs > 0`, state changes are debounced:
|
|
191
|
+
|
|
192
|
+
1. `set()` or `update()` triggers a timer.
|
|
193
|
+
2. Subsequent changes within the window reset the timer.
|
|
194
|
+
3. After `autoSaveMs` ms of inactivity, the state is saved.
|
|
179
195
|
|
|
180
196
|
```typescript
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
197
|
+
const tracker = new StateTracker({
|
|
198
|
+
key: "debounced",
|
|
199
|
+
default: { x: 0 },
|
|
200
|
+
autoSaveMs: 500,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
tracker.set({ x: 1 });
|
|
204
|
+
tracker.set({ x: 2 }); // Timer resets
|
|
205
|
+
await new Promise(r => setTimeout(r, 100));
|
|
206
|
+
tracker.set({ x: 3 }); // Timer resets again
|
|
207
|
+
|
|
208
|
+
// Only saved once after 500ms of inactivity with final value { x: 3 }
|
|
184
209
|
```
|
|
185
210
|
|
|
186
|
-
|
|
211
|
+
Calling `save()` or `saveAsync()` cancels pending auto-saves and writes immediately.
|
|
187
212
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|----------|------|-------------|
|
|
200
|
-
| `state` | `Readonly<T>` | Current in-memory state |
|
|
201
|
-
| `isPersistent` | `boolean` | Whether disk storage is available |
|
|
202
|
-
|
|
203
|
-
## Methods
|
|
204
|
-
|
|
205
|
-
| Method | Description |
|
|
206
|
-
|--------|-------------|
|
|
207
|
-
| `loadAsync()` | Async load with graceful degradation (safe to call multiple times) |
|
|
208
|
-
| `saveAsync()` | Async atomic save (temp file + rename) |
|
|
209
|
-
| `load()` | Sync load (v1 compatible envelope format) |
|
|
210
|
-
| `save(value)` | Sync save (overwrites entire state, v1 compatible) |
|
|
211
|
-
| `update(changes)` | Shallow merge for object state, triggers auto-save |
|
|
212
|
-
| `set(newState)` | Replace entire state, triggers auto-save |
|
|
213
|
-
| `reset()` | Restore to defaults, triggers auto-save |
|
|
214
|
-
| `getFilePath()` | Returns the full path to the state file |
|
|
213
|
+
## Types
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
export type StateTrackerEventLevel = "debug" | "info" | "warn" | "error";
|
|
217
|
+
|
|
218
|
+
export interface StateTrackerEvent {
|
|
219
|
+
level: StateTrackerEventLevel;
|
|
220
|
+
message: string;
|
|
221
|
+
context?: Record<string, unknown>;
|
|
222
|
+
}
|
|
223
|
+
```
|
|
215
224
|
|
|
216
225
|
## Environment Variable
|
|
217
226
|
|
|
@@ -228,12 +237,4 @@ STATE_TRACKER_DIR=/custom/path npm start
|
|
|
228
237
|
- **Key sanitization** to prevent path traversal (alphanumeric, hyphens, underscores only)
|
|
229
238
|
- **Graceful degradation** — runs in-memory when disk is unavailable
|
|
230
239
|
- **Auto-save** — debounced saves after state mutations
|
|
231
|
-
- **Legacy format support** — reads both v1 envelope format and legacy PersistentStore formats
|
|
232
|
-
|
|
233
|
-
## Exported Types
|
|
234
|
-
|
|
235
|
-
The package also exports the following types for advanced usage:
|
|
236
|
-
|
|
237
|
-
- `StateTrackerOptions<T>` — Constructor options interface
|
|
238
|
-
- `StateTrackerEvent` — Event payload interface `{ level, message, context? }`
|
|
239
|
-
- `StateTrackerEventLevel` — Event level union type `"debug" \| "info" \| "warn" \| "error"`
|
|
240
|
+
- **Legacy format support** — reads both v1 envelope format and legacy PersistentStore formats
|