@hardlydifficult/state-tracker 2.0.9 → 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.
Files changed (2) hide show
  1. package/README.md +142 -141
  2. 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 applications.
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
- interface AppState {
17
- requestCount: number;
18
- lastActiveAt: string;
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
- await store.loadAsync();
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
- ## Key Sanitization
25
+ // Update state and auto-save
26
+ tracker.update({ theme: "dark" });
27
+ // State is saved automatically after 1 second of inactivity
38
28
 
39
- StateTracker enforces strict key sanitization to prevent path traversal and invalid characters. Keys must match `/^[A-Za-z0-9_-]+$/`.
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
- ## Persistence & Graceful Degradation
33
+ ## State Management
47
34
 
48
- If storage is unavailable (e.g., due to permissions or read-only filesystem), StateTracker automatically falls back to in-memory mode without throwing errors.
35
+ The `StateTracker` class provides a robust interface for managing persistent application state.
49
36
 
50
- ```typescript
51
- const tracker = new StateTracker({
52
- key: "failing",
53
- default: { x: 1 },
54
- });
37
+ ### Constructor
55
38
 
56
- await tracker.loadAsync(); // fails silently on unreadable dir
57
- console.log(tracker.isPersistent); // false
58
- console.log(tracker.state); // uses in-memory default
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
- ## Sync API: `load()` and `save()`
47
+ ### State Accessors
62
48
 
63
- V1-compatible synchronous operations for environments where async is not preferred or available.
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
- import { StateTracker } from "@hardlydifficult/state-tracker";
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
- const count = store.load(); // returns current state
74
- store.save(count + 1); // writes entire state atomically
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
- ## Async API: `loadAsync()` and `saveAsync()`
64
+ ### Persistence Operations
78
65
 
79
- Async operations that gracefully degrade to in-memory mode on errors.
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 store = new StateTracker<AppState>({
83
- key: "app",
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
- ## State Manipulation
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
- | Method | Description | Example |
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: "manip",
107
- default: { a: 1, b: 2 },
90
+ key: "preferences",
91
+ default: { darkMode: false },
108
92
  });
109
93
 
110
- tracker.set({ a: 10, b: 20 }); // replaces all
111
- tracker.update({ b: 200 }); // merges: { a: 10, b: 200 }
112
- tracker.reset(); // back to { a: 1, b: 2 }
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
- ### `update()` on Primitive Types
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
- Calling `update()` on a primitive or array state throws:
118
+ #### `update(changes: Partial<T>): void`
119
+ Shallow merge for object types and schedule auto-save.
118
120
 
119
121
  ```typescript
120
- const primitive = new StateTracker<number>({ key: "num", default: 0 });
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
- ## Auto-Save with Debouncing
125
+ > **Note:** Throws at runtime if state is not an object (array/primitive).
125
126
 
126
- Configure automatic debounced saving via `autoSaveMs`. Any state change (`set`, `update`, `reset`) will schedule a save after the delay; rapid changes only trigger one save.
127
+ #### `reset(): void`
128
+ Restore state to the default value and schedule auto-save.
127
129
 
128
130
  ```typescript
129
- const tracker = new StateTracker({
130
- key: "auto",
131
- default: { count: 0 },
132
- autoSaveMs: 500,
133
- });
131
+ tracker.reset(); // Reverts to default state
132
+ ```
133
+
134
+ ### File Management
134
135
 
135
- tracker.set({ count: 1 }); // schedules save in 500ms
136
- tracker.set({ count: 2 }); // cancels previous, schedules new save
137
- tracker.set({ count: 3 }); // cancels again, only this save triggers after 500ms
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 Logging
143
+ ## Event System
141
144
 
142
- Optionally receive events for debugging, monitoring, or diagnostics.
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: "events",
147
- default: { x: 1 },
156
+ key: "app",
157
+ default: {},
148
158
  onEvent: (event) => {
149
- console.log(`[${event.level}] ${event.message}`, event.context);
159
+ console[event.level](`[${event.level}] ${event.message}`, event.context);
150
160
  },
151
161
  });
152
162
 
153
163
  await tracker.loadAsync();
154
- // Outputs: [info] No existing state file, using defaults { path: ".../x.json" }
164
+ // Outputs: [info] Loaded state from disk { path: ".../app.json" }
155
165
  ```
156
166
 
157
- ### Event Type Reference
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": <your-state>,
172
- "lastUpdated": "2025-04-05T12:34:56.789Z"
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
- Legacy raw JSON objects (without envelope) are automatically loaded and merged with defaults, then rewritten in envelope format on next `saveAsync()`.
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
- // Old format: { "count": 10 }
182
- await tracker.loadAsync(); // merges with defaults
183
- await tracker.saveAsync(); // writes: { "value": { "count": 10 }, "lastUpdated": "..." }
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
- ## Options
211
+ Calling `save()` or `saveAsync()` cancels pending auto-saves and writes immediately.
187
212
 
188
- | Option | Type | Default | Description |
189
- |--------|------|---------|-------------|
190
- | `key` | `string` | — | Unique identifier (alphanumeric, hyphens, underscores only) |
191
- | `default` | `T` | | Default value used on missing/corrupt storage |
192
- | `stateDirectory`? | `string` | `~/.app-state` | Custom directory for state files |
193
- | `autoSaveMs`? | `number` | `0` | Debounce delay for auto-save (ms); `0` disables |
194
- | `onEvent`? | `(e: StateTrackerEvent) => void` | — | Event callback for logging |
195
-
196
- ## Properties
197
-
198
- | Property | Type | Description |
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardlydifficult/state-tracker",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [