@hardlydifficult/state-tracker 2.0.13 → 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.
Files changed (2) hide show
  1. package/README.md +126 -275
  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 debouncing, typed migrations, key sanitization, and graceful fallback to in-memory mode.
3
+ TypeScript state tracker with atomic JSON persistence, auto-save debouncing, typed migrations, and graceful fallback.
4
4
 
5
5
  ## Installation
6
6
 
@@ -13,358 +13,210 @@ npm install @hardlydifficult/state-tracker
13
13
  ```typescript
14
14
  import { StateTracker } from "@hardlydifficult/state-tracker";
15
15
 
16
- interface AppConfig {
17
- version: string;
18
- theme: "light" | "dark";
19
- }
20
-
21
- const tracker = new StateTracker<AppConfig>({
16
+ const tracker = new StateTracker({
22
17
  key: "app-config",
23
- default: { version: "1.0.0", theme: "light" },
24
- autoSaveMs: 1000,
18
+ default: { theme: "light", notifications: true },
19
+ autoSaveMs: 500,
25
20
  });
26
21
 
27
- await tracker.loadAsync(); // Loads from disk, enables isPersistent tracking
28
- tracker.update({ theme: "dark" }); // Auto-saves after 1s of inactivity
29
- console.log(tracker.state); // { version: "1.0.0", theme: "dark" }
30
- ```
31
-
32
- ## Core Features
33
-
34
- ### State Persistence
35
-
36
- The `StateTracker` class provides atomic JSON-based state persistence with auto-save support.
37
-
38
- | Option | Type | Description |
39
- |--------|------|-------------|
40
- | `key` | `string` | Unique identifier for the state file (alphanumeric, hyphens, underscores only) |
41
- | `default` | `T` | Default state if no persisted data exists |
42
- | `stateDirectory` | `string` (default: `~/.app-state` or `$STATE_TRACKER_DIR`) | Directory to store state files |
43
- | `autoSaveMs` | `number` (default: `0`) | Debounce delay in ms for auto-save |
44
- | `migration` | `Migration<T>` (deprecated) | Optional migration function (use `loadOrDefault()` with `defineStateMigration` instead) |
45
- | `onEvent` | `(event: StateTrackerEvent) => void` | Callback for internal events (debug/info/warn/error) |
46
-
47
- #### Async vs Sync Persistence
48
-
49
- ```typescript
50
- // Async (recommended for production)
51
- await tracker.loadAsync();
52
- await tracker.saveAsync();
22
+ tracker.set({ theme: "dark" }); // saves debounced
23
+ console.log(tracker.state); // { theme: "dark", notifications: true }
53
24
 
54
- // Sync fallback (throws if file access fails)
55
- tracker.load();
56
- tracker.save({ theme: "dark" });
25
+ tracker.reset(); // restores default
57
26
  ```
58
27
 
59
- ### Key Sanitization
28
+ ## Core State Management
60
29
 
61
- Invalid keys (including `__proto__`, `constructor`, `prototype`) are sanitized automatically to prevent prototype pollution and path traversal.
30
+ The `StateTracker` class provides type-safe state persistence with automatic atomic writes and in-memory caching.
62
31
 
63
- ```typescript
64
- tracker.set("__proto__", { malicious: true }); // ignored
65
- tracker.set("normalKey", "value"); // works as expected
66
- ```
32
+ ### Constructor Options
67
33
 
68
- ### Typed Migrations
34
+ | Option | Type | Default | Description |
35
+ |--------|------|---------|-------------|
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 |
69
41
 
70
- Support for typed migrations from older state formats using `loadOrDefault()` and `defineStateMigration()`.
42
+ ### Instance Properties
71
43
 
72
- ```typescript
73
- import { defineStateMigration } from "@hardlydifficult/state-tracker";
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
74
47
 
75
- interface LegacyConfig {
76
- theme: "light" | "dark";
77
- }
48
+ ### Instance Methods
78
49
 
79
- interface Config {
80
- version: string;
81
- theme: "light" | "dark";
82
- }
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 |
83
57
 
84
- const legacyMigration = defineStateMigration<Config, LegacyConfig>({
85
- name: "legacy-config",
86
- isLegacy(input): input is LegacyConfig {
87
- return (
88
- typeof input === "object" &&
89
- input !== null &&
90
- !Array.isArray(input) &&
91
- "theme" in input &&
92
- !("version" in input)
93
- );
94
- },
95
- migrate(legacy) {
96
- return { version: "1.0.0", ...legacy };
97
- },
98
- });
58
+ ### Async Persistence API
99
59
 
100
- const tracker = new StateTracker<Config>({
101
- key: "config",
102
- default: { version: "1.1.0", theme: "dark" },
103
- });
60
+ Async methods support graceful degradation when file system access fails.
104
61
 
105
- const state = tracker.loadOrDefault({ migrations: [legacyMigration] });
106
- ```
62
+ #### Methods
107
63
 
108
- ### Graceful Degradation
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
109
67
 
110
- If persistent storage fails (e.g., due to permissions or path issues), the tracker falls back to in-memory mode without throwing errors.
68
+ #### Example
111
69
 
112
70
  ```typescript
113
- const tracker = new StateTracker<AppConfig>({
114
- key: "config",
115
- default: { version: "1.0.0", theme: "light" },
71
+ const tracker = new StateTracker({
72
+ key: "settings",
73
+ default: { fontSize: 14 },
74
+ stateDirectory: "/app/state",
116
75
  });
117
76
 
118
77
  await tracker.loadAsync();
119
- if (!tracker.isPersistent) {
120
- console.warn("Running in-memory mode");
121
- }
122
- ```
78
+ console.log(tracker.isPersistent); // true (or false if directory not writable)
123
79
 
124
- ## State Management
125
-
126
- The `StateTracker` class provides a robust interface for managing persistent application state.
127
-
128
- ### Constructor Options
129
-
130
- | Option | Type | Default | Description |
131
- |--------|------|---------|-------------|
132
- | `key` | `string` | — | Unique identifier for the state file (alphanumeric, hyphens, underscores only) |
133
- | `default` | `T` | — | Default state value used if no persisted state exists |
134
- | `stateDirectory` | `string` | `~/.app-state` or `$STATE_TRACKER_DIR` | Directory to store state files |
135
- | `autoSaveMs` | `number` | `0` | Debounce delay (ms) for auto-save after state changes |
136
- | `migration` | `Migration<T>` | — | **Deprecated:** Use `defineStateMigration` with `loadOrDefault` instead |
137
- | `onEvent` | `(event: StateTrackerEvent) => void` | `undefined` | Callback for internal events (debug/info/warn/error) |
80
+ tracker.set({ fontSize: 16 });
81
+ await tracker.saveAsync();
82
+ ```
138
83
 
139
- ### State Accessors
84
+ ## Auto-Save with Debouncing
140
85
 
141
- - **`state: Readonly<T>`** Read-only getter for the current in-memory state.
142
- - **`isPersistent: boolean`** — Indicates whether disk persistence is available (set after `loadAsync()`).
86
+ Set `autoSaveMs` in the constructor to debounce writes after state changes via `set()` or `update()`.
143
87
 
144
88
  ```typescript
145
89
  const tracker = new StateTracker({
146
- key: "counter",
147
- default: 0,
148
- stateDirectory: "./data",
90
+ key: "editor",
91
+ default: { text: "", cursor: 0 },
92
+ autoSaveMs: 300,
149
93
  });
150
94
 
151
- console.log(tracker.state); // 0
152
- await tracker.loadAsync();
153
- console.log(tracker.isPersistent); // true if disk write succeeded
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
- ### Persistence Operations
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
102
+
103
+ ## Typed Migrations
104
+
105
+ Support legacy state formats with typed migrations.
157
106
 
158
- #### `load(): T`
159
- Synchronous state load from disk. Returns the current state (default if missing or corrupted).
107
+ ### Migration Interface
160
108
 
161
109
  ```typescript
162
- const tracker = new StateTracker({
163
- key: "config",
164
- default: { version: 1 },
165
- });
166
- const config = tracker.load(); // Loads from disk or uses default
110
+ interface StateTrackerMigration<TCurrent, TLegacy = unknown> {
111
+ name?: string;
112
+ isLegacy(input: unknown): input is TLegacy;
113
+ migrate(legacy: TLegacy): TCurrent;
114
+ }
167
115
  ```
168
116
 
169
- #### `loadOrDefault(options?): T`
170
- Explicit "safe load" convenience. Behaves like `load()` and supports typed legacy migrations.
117
+ ### Helper
171
118
 
172
- ```typescript
173
- import { defineStateMigration, StateTracker } from "@hardlydifficult/state-tracker";
119
+ - `defineStateMigration<TCurrent, TLegacy>(migration)` — Type-safe migration builder
174
120
 
175
- interface LegacySyncState {
176
- offset: number;
177
- completedIds: string[];
178
- }
121
+ ### Usage
179
122
 
180
- const tracker = new StateTracker({
181
- key: "sync-state",
182
- default: { cursor: 0, done: [] as string[] },
183
- });
123
+ ```typescript
124
+ interface LegacyState { offset: number; completedIds: string[] }
125
+ interface CurrentState { cursor: number; done: string[] }
184
126
 
185
- const legacyMigration = defineStateMigration<
186
- { cursor: number; done: string[] },
187
- LegacySyncState
188
- >({
189
- name: "sync-state-v0",
190
- isLegacy(input): input is LegacySyncState {
127
+ const migration = defineStateMigration<CurrentState, LegacyState>({
128
+ name: "cursor-migration",
129
+ isLegacy(input): input is LegacyState {
191
130
  return (
192
- input !== null &&
131
+ input &&
193
132
  typeof input === "object" &&
194
- !Array.isArray(input) &&
195
- typeof (input as Record<string, unknown>).offset === "number" &&
196
- Array.isArray((input as Record<string, unknown>).completedIds)
133
+ "offset" in input &&
134
+ "completedIds" in input
197
135
  );
198
136
  },
199
137
  migrate(legacy) {
200
- return { cursor: legacy.offset, done: legacy.completedIds };
138
+ return {
139
+ cursor: legacy.offset,
140
+ done: legacy.completedIds,
141
+ };
201
142
  },
202
143
  });
203
144
 
204
- const state = tracker.loadOrDefault({ migrations: [legacyMigration] });
205
- ```
206
-
207
- #### `save(value: T): void`
208
- Synchronous atomic save using temp file + rename.
209
-
210
- ```typescript
211
- tracker.save({ version: 2 });
212
- // File is updated atomically; previous state preserved if crash occurs mid-write
213
- ```
214
-
215
- #### `saveWithMeta(value: T, meta?: Record<string, unknown>): void`
216
- Synchronous atomic save with optional metadata in the envelope.
217
-
218
- ```typescript
219
- tracker.saveWithMeta(
220
- { version: 3 },
221
- { source: "sync-script", reason: "manual-run" }
222
- );
223
- ```
224
-
225
- #### `loadAsync(): Promise<void>`
226
- Async state load with graceful degradation. Sets `isPersistent = false` on failure instead of throwing.
227
-
228
- ```typescript
229
145
  const tracker = new StateTracker({
230
- key: "preferences",
231
- default: { darkMode: false },
146
+ key: "tasks",
147
+ default: { cursor: 0, done: [] } as CurrentState,
232
148
  });
233
149
 
234
- await tracker.loadAsync();
235
- if (!tracker.isPersistent) {
236
- console.warn("Running in-memory mode (disk unavailable)");
237
- }
150
+ // Load with migration
151
+ const value = tracker.loadOrDefault({ migrations: [migration] });
152
+ // Legacy { offset: 3, completedIds: ["a", "b"] } { cursor: 3, done: ["a", "b"] }
238
153
  ```
239
154
 
240
- #### `saveAsync(): Promise<void>`
241
- Async atomic save (temp file + rename). Cancels any pending auto-save before writing.
155
+ ### Envelope Format
242
156
 
243
- ```typescript
244
- tracker.set({ darkMode: true });
245
- await tracker.saveAsync(); // Immediate save, bypassing debounce
246
- ```
247
-
248
- ### State Mutations
249
-
250
- #### `set(newState: T): void`
251
- Replace entire state and schedule auto-save.
252
-
253
- ```typescript
254
- tracker.set({ darkMode: true, theme: "midnight" });
255
- // Auto-saves after configured delay (if autoSaveMs > 0)
256
- ```
257
-
258
- #### `update(changes: Partial<T>): void`
259
- Shallow merge for object types and schedule auto-save.
157
+ Saved state uses a JSON envelope:
260
158
 
261
- ```typescript
262
- tracker.update({ theme: "dark" }); // Preserves darkMode: true
263
- ```
264
-
265
- > **Note:** Throws at runtime if state is not an object (array/primitive).
266
-
267
- #### `reset(): void`
268
- Restore state to the default value and schedule auto-save.
269
-
270
- ```typescript
271
- tracker.reset(); // Reverts to default state
159
+ ```json
160
+ {
161
+ "value": { /* your state */ },
162
+ "lastUpdated": "2025-04-05T12:34:56.789Z",
163
+ "meta": { /* optional metadata */ }
164
+ }
272
165
  ```
273
166
 
274
- ### File Management
167
+ - `load()` and `loadAsync()` extract `value` and merge missing keys from `default`
168
+ - Supports raw legacy objects (without envelope) with default-merge
275
169
 
276
- #### `getFilePath(): string`
277
- Returns the full path to the state file.
170
+ ## Event Logging
278
171
 
279
- ```typescript
280
- console.log(tracker.getFilePath()); // "/home/user/.app-state/counter.json"
281
- ```
172
+ Optionally track runtime events via the `onEvent` callback.
282
173
 
283
- ## Event System
174
+ ### Event Types
284
175
 
285
- Events are emitted for internal operations via the optional `onEvent` callback.
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)
286
180
 
287
- | Level | Description |
288
- |-------|-------------|
289
- | `debug` | Low-level operations (e.g., save completion) |
290
- | `info` | Normal operations (e.g., file read/write) |
291
- | `warn` | Recoverable failures (e.g., disk I/O errors) |
292
- | `error` | Non-recoverable failures (e.g., permission issues) |
181
+ ### Example
293
182
 
294
183
  ```typescript
295
184
  const tracker = new StateTracker({
296
- key: "app",
185
+ key: "my-state",
297
186
  default: {},
298
187
  onEvent: (event) => {
299
- console[event.level](`[${event.level}] ${event.message}`, event.context);
188
+ if (event.level === "error") console.error(event.message, event.context);
300
189
  },
301
190
  });
302
191
 
303
192
  await tracker.loadAsync();
304
- // Outputs: [info] Loaded state from disk { path: ".../app.json" }
305
- ```
306
-
307
- ## Persistence Format
308
-
309
- ### v2 (Envelope) Format
310
- ```json
311
- {
312
- "value": { "theme": "dark", "notifications": true },
313
- "lastUpdated": "2024-05-01T12:00:00.000Z",
314
- "meta": { "source": "sync-script" }
315
- }
316
- ```
317
-
318
- ### Legacy (PersistentStore) Migration
319
- The tracker automatically detects and merges legacy raw JSON objects with defaults on load.
320
-
321
- ```typescript
322
- // If disk contains: { "count": 42 }
323
- // And default is: { "count": 0, "name": "default" }
324
- // Loaded state becomes: { "count": 42, "name": "default" }
193
+ // emits info: "No existing state file" or "Loaded state from disk"
325
194
  ```
326
195
 
327
- After the first `saveAsync()`, files are rewritten in the v2 envelope format.
328
-
329
- For custom legacy formats, use typed migrations with `defineStateMigration(...)`
330
- and pass them to `loadOrDefault({ migrations })`.
331
-
332
- ## Auto-Save Behavior
196
+ ## Appendix: Key Sanitization
333
197
 
334
- When `autoSaveMs > 0`, state changes are debounced:
335
-
336
- 1. `set()` or `update()` triggers a timer.
337
- 2. Subsequent changes within the window reset the timer.
338
- 3. After `autoSaveMs` ms of inactivity, the state is saved.
198
+ Keys must match `^[A-Za-z0-9_-]+$`. Invalid keys throw at construction:
339
199
 
340
200
  ```typescript
341
- const tracker = new StateTracker({
342
- key: "debounced",
343
- default: { x: 0 },
344
- autoSaveMs: 500,
345
- });
346
-
347
- tracker.set({ x: 1 });
348
- tracker.set({ x: 2 }); // Timer resets
349
- await new Promise(r => setTimeout(r, 100));
350
- tracker.set({ x: 3 }); // Timer resets again
201
+ new StateTracker({ key: "../evil", default: 0 });
202
+ // Error: StateTracker key contains invalid characters
351
203
 
352
- // Only saved once after 500ms of inactivity with final value { x: 3 }
204
+ new StateTracker({ key: "", default: 0 });
205
+ // Error: StateTracker key must be a non-empty string
353
206
  ```
354
207
 
355
- Calling `save()` or `saveAsync()` cancels pending auto-saves and writes immediately.
356
-
357
- ## Types
208
+ State files are written as `<key>.json` inside the state directory.
358
209
 
359
- ```typescript
360
- export type StateTrackerEventLevel = "debug" | "info" | "warn" | "error";
210
+ ## Exported API
361
211
 
362
- export interface StateTrackerEvent {
363
- level: StateTrackerEventLevel;
364
- message: string;
365
- context?: Record<string, unknown>;
366
- }
367
- ```
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
368
220
 
369
221
  ## Environment Variable
370
222
 
@@ -383,13 +235,12 @@ STATE_TRACKER_DIR=/custom/path npm start
383
235
  - **Auto-save** — debounced saves after state mutations
384
236
  - **Legacy format support** — reads both v1 envelope format and legacy PersistentStore formats
385
237
  - **Typed migration helper** — declarative migration rules for old JSON shapes
386
- - **Optional save metadata** — annotate saved state with `saveWithMeta(...)`
387
238
  - **API consistency** — all operations work seamlessly across sync/async modes
388
239
 
389
- ## Appendix: Platform Behavior
240
+ ## Platform Behavior
390
241
 
391
242
  | Environment | Persistence | Fallback Behavior |
392
243
  |-------------|-------------|-------------------|
393
- | Node.js | ✅ File system access | Falls back to memory on errors |
394
- | Browser | ❌ No file system access | Always in-memory only |
395
- | Bun/Deno | ⚠️ Experimental support | Depends on environment capabilities |
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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardlydifficult/state-tracker",
3
- "version": "2.0.13",
3
+ "version": "2.0.14",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [