@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.
Files changed (2) hide show
  1. package/README.md +137 -185
  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, and graceful degradation to in-memory mode for TypeScript.
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: "user-settings",
17
+ key: "app-config",
18
18
  default: { theme: "light", notifications: true },
19
- autoSaveMs: 1000,
19
+ autoSaveMs: 500,
20
20
  });
21
21
 
22
- // Load persisted state (sync or async)
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 a robust interface for managing persistent application state.
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 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) |
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
- ### State Accessors
42
+ ### Instance Properties
48
43
 
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()`).
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
- ```typescript
53
- const tracker = new StateTracker({
54
- key: "counter",
55
- default: 0,
56
- stateDirectory: "./data",
57
- });
48
+ ### Instance Methods
58
49
 
59
- console.log(tracker.state); // 0
60
- await tracker.loadAsync();
61
- console.log(tracker.isPersistent); // true if disk write succeeded
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 Operations
58
+ ### Async Persistence API
65
59
 
66
- #### `load(): T`
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
- ```typescript
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
- #### `loadOrDefault(options?): T`
78
- Explicit "safe load" convenience. Behaves like `load()` and supports typed legacy migrations.
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
- ```typescript
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: "sync-state",
90
- default: { cursor: 0, done: [] as string[] },
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
- const state = tracker.loadOrDefault({ migrations: [legacyMigration] });
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
- ```typescript
119
- tracker.save({ version: 2 });
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
- #### `saveWithMeta(value: T, meta?: Record<string, unknown>): void`
124
- Synchronous atomic save with optional metadata in the envelope.
84
+ ## Auto-Save with Debouncing
125
85
 
126
- ```typescript
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: "preferences",
139
- default: { darkMode: false },
90
+ key: "editor",
91
+ default: { text: "", cursor: 0 },
92
+ autoSaveMs: 300,
140
93
  });
141
94
 
142
- await tracker.loadAsync();
143
- if (!tracker.isPersistent) {
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
- ### State Mutations
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
- #### `set(newState: T): void`
159
- Replace entire state and schedule auto-save.
103
+ ## Typed Migrations
160
104
 
161
- ```typescript
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
- #### `update(changes: Partial<T>): void`
167
- Shallow merge for object types and schedule auto-save.
107
+ ### Migration Interface
168
108
 
169
109
  ```typescript
170
- tracker.update({ theme: "dark" }); // Preserves darkMode: true
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
- > **Note:** Throws at runtime if state is not an object (array/primitive).
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
- ### File Management
119
+ - `defineStateMigration<TCurrent, TLegacy>(migration)` — Type-safe migration builder
183
120
 
184
- #### `getFilePath(): string`
185
- Returns the full path to the state file.
121
+ ### Usage
186
122
 
187
123
  ```typescript
188
- console.log(tracker.getFilePath()); // "/home/user/.app-state/counter.json"
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
- | Level | Description |
196
- |-------|-------------|
197
- | `debug` | Low-level operations (e.g., save completion) |
198
- | `info` | Normal operations (e.g., file read/write) |
199
- | `warn` | Recoverable failures (e.g., disk I/O errors) |
200
- | `error` | Non-recoverable failures (e.g., permission issues) |
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: "app",
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
- await tracker.loadAsync();
212
- // Outputs: [info] Loaded state from disk { path: ".../app.json" }
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
- ## Persistence Format
155
+ ### Envelope Format
156
+
157
+ Saved state uses a JSON envelope:
216
158
 
217
- ### v2 (Envelope) Format
218
159
  ```json
219
160
  {
220
- "value": { "theme": "dark", "notifications": true },
221
- "lastUpdated": "2024-05-01T12:00:00.000Z",
222
- "meta": { "source": "sync-script" }
161
+ "value": { /* your state */ },
162
+ "lastUpdated": "2025-04-05T12:34:56.789Z",
163
+ "meta": { /* optional metadata */ }
223
164
  }
224
165
  ```
225
166
 
226
- ### Legacy (PersistentStore) Migration
227
- The tracker automatically detects and merges legacy raw JSON objects with defaults on load.
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
- After the first `saveAsync()`, files are rewritten in the v2 envelope format.
170
+ ## Event Logging
236
171
 
237
- For custom legacy formats, use typed migrations with `defineStateMigration(...)`
238
- and pass them to `loadOrDefault({ migrations })`.
172
+ Optionally track runtime events via the `onEvent` callback.
239
173
 
240
- ## Auto-Save Behavior
174
+ ### Event Types
241
175
 
242
- When `autoSaveMs > 0`, state changes are debounced:
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
- 1. `set()` or `update()` triggers a timer.
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: "debounced",
251
- default: { x: 0 },
252
- autoSaveMs: 500,
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.set({ x: 1 });
256
- tracker.set({ x: 2 }); // Timer resets
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
- Calling `save()` or `saveAsync()` cancels pending auto-saves and writes immediately.
196
+ ## Appendix: Key Sanitization
264
197
 
265
- ## Types
198
+ Keys must match `^[A-Za-z0-9_-]+$`. Invalid keys throw at construction:
266
199
 
267
200
  ```typescript
268
- export type StateTrackerEventLevel = "debug" | "info" | "warn" | "error";
201
+ new StateTracker({ key: "../evil", default: 0 });
202
+ // Error: StateTracker key contains invalid characters
269
203
 
270
- export interface StateTrackerEvent {
271
- level: StateTrackerEventLevel;
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
- - **Optional save metadata** — annotate saved state with `saveWithMeta(...)`
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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardlydifficult/state-tracker",
3
- "version": "2.0.12",
3
+ "version": "2.0.14",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [