@hardlydifficult/state-tracker 2.0.8 → 2.0.9

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 +129 -67
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -34,13 +34,33 @@ store.set({ requestCount: 0, lastActiveAt: new Date().toISOString() }); // full
34
34
  await store.saveAsync(); // force immediate save
35
35
  ```
36
36
 
37
- ## State Persistence
37
+ ## Key Sanitization
38
38
 
39
- The `StateTracker` class provides atomic JSON state persistence using file-based storage with graceful fallback to in-memory mode when disk access fails.
39
+ StateTracker enforces strict key sanitization to prevent path traversal and invalid characters. Keys must match `/^[A-Za-z0-9_-]+$/`.
40
40
 
41
- ### Sync API
41
+ ```typescript
42
+ new StateTracker({ key: "../evil", default: 0 }); // throws: "invalid characters"
43
+ new StateTracker({ key: "foo/bar", default: 0 }); // throws: "invalid characters"
44
+ ```
45
+
46
+ ## Persistence & Graceful Degradation
42
47
 
43
- For tools and scripts, use the synchronous API:
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.
49
+
50
+ ```typescript
51
+ const tracker = new StateTracker({
52
+ key: "failing",
53
+ default: { x: 1 },
54
+ });
55
+
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
+ ```
60
+
61
+ ## Sync API: `load()` and `save()`
62
+
63
+ V1-compatible synchronous operations for environments where async is not preferred or available.
44
64
 
45
65
  ```typescript
46
66
  import { StateTracker } from "@hardlydifficult/state-tracker";
@@ -54,9 +74,9 @@ const count = store.load(); // returns current state
54
74
  store.save(count + 1); // writes entire state atomically
55
75
  ```
56
76
 
57
- ### Async API
77
+ ## Async API: `loadAsync()` and `saveAsync()`
58
78
 
59
- For long-running servers, use the async API with auto-save:
79
+ Async operations that gracefully degrade to in-memory mode on errors.
60
80
 
61
81
  ```typescript
62
82
  const store = new StateTracker<AppState>({
@@ -71,15 +91,107 @@ store.set({ version: 2 });
71
91
  await store.saveAsync(); // Force immediate save
72
92
  ```
73
93
 
94
+ ## State Manipulation
95
+
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` |
103
+
104
+ ```typescript
105
+ const tracker = new StateTracker({
106
+ key: "manip",
107
+ default: { a: 1, b: 2 },
108
+ });
109
+
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 }
113
+ ```
114
+
115
+ ### `update()` on Primitive Types
116
+
117
+ Calling `update()` on a primitive or array state throws:
118
+
119
+ ```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
+ ```
123
+
124
+ ## Auto-Save with Debouncing
125
+
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
+
128
+ ```typescript
129
+ const tracker = new StateTracker({
130
+ key: "auto",
131
+ default: { count: 0 },
132
+ autoSaveMs: 500,
133
+ });
134
+
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
138
+ ```
139
+
140
+ ## Event Logging
141
+
142
+ Optionally receive events for debugging, monitoring, or diagnostics.
143
+
144
+ ```typescript
145
+ const tracker = new StateTracker({
146
+ key: "events",
147
+ default: { x: 1 },
148
+ onEvent: (event) => {
149
+ console.log(`[${event.level}] ${event.message}`, event.context);
150
+ },
151
+ });
152
+
153
+ await tracker.loadAsync();
154
+ // Outputs: [info] No existing state file, using defaults { path: ".../x.json" }
155
+ ```
156
+
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:
168
+
169
+ ```json
170
+ {
171
+ "value": <your-state>,
172
+ "lastUpdated": "2025-04-05T12:34:56.789Z"
173
+ }
174
+ ```
175
+
176
+ ### Migration
177
+
178
+ Legacy raw JSON objects (without envelope) are automatically loaded and merged with defaults, then rewritten in envelope format on next `saveAsync()`.
179
+
180
+ ```typescript
181
+ // Old format: { "count": 10 }
182
+ await tracker.loadAsync(); // merges with defaults
183
+ await tracker.saveAsync(); // writes: { "value": { "count": 10 }, "lastUpdated": "..." }
184
+ ```
185
+
74
186
  ## Options
75
187
 
76
- | Option | Type | Description |
77
- |--------|------|-------------|
78
- | `key` | `string` | Unique identifier for the state file (required) |
79
- | `default` | `T` | Default value when no state file exists (required) |
80
- | `stateDirectory` | `string` | Directory for state files (default: `$STATE_TRACKER_DIR` or `~/.app-state`) |
81
- | `autoSaveMs` | `number` | Auto-save interval after `update()`/`set()`/`reset()` (default: 0 = disabled) |
82
- | `onEvent` | `(event: StateTrackerEvent) => void` | Event callback for logging (`{ level, message, context }`) |
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 |
83
195
 
84
196
  ## Properties
85
197
 
@@ -101,50 +213,14 @@ await store.saveAsync(); // Force immediate save
101
213
  | `reset()` | Restore to defaults, triggers auto-save |
102
214
  | `getFilePath()` | Returns the full path to the state file |
103
215
 
104
- ### `update()`
105
-
106
- For object state types, `update()` merges partial changes:
107
-
108
- ```typescript
109
- const store = new StateTracker<{ count: number; name: string }>({
110
- key: "demo",
111
- default: { count: 0, name: "initial" },
112
- });
113
-
114
- store.update({ count: 42 });
115
- console.log(store.state); // { count: 42, name: "initial" }
116
- ```
117
-
118
- Calling `update()` on a primitive state throws:
119
-
120
- ```typescript
121
- const primitive = new StateTracker<number>({ key: "num", default: 0 });
122
- primitive.update(100 as never); // throws: "update() can only be used when state is a non-array object"
123
- ```
216
+ ## Environment Variable
124
217
 
125
- ## Event Handling
218
+ - `STATE_TRACKER_DIR`: Overrides default state directory (`~/.app-state`)
126
219
 
127
- Events are emitted for key lifecycle operations with configurable logging:
128
-
129
- ```typescript
130
- const store = new StateTracker<AppState>({
131
- key: "app",
132
- default: { version: 1 },
133
- onEvent: ({ level, message, context }) => {
134
- console.log(`[${level}] ${message}`, context);
135
- },
136
- });
137
-
138
- await store.loadAsync();
139
- // Example output: [info] No existing state file, using defaults { path: ".../app.json" }
140
-
141
- store.set({ version: 2 });
142
- await store.saveAsync();
143
- // Example output: [debug] Saved state to disk { path: ".../app.json" }
220
+ ```bash
221
+ STATE_TRACKER_DIR=/custom/path npm start
144
222
  ```
145
223
 
146
- Event levels: `"debug"`, `"info"`, `"warn"`, `"error"`
147
-
148
224
  ## Features
149
225
 
150
226
  - **Type inference** from the default value
@@ -154,20 +230,6 @@ Event levels: `"debug"`, `"info"`, `"warn"`, `"error"`
154
230
  - **Auto-save** — debounced saves after state mutations
155
231
  - **Legacy format support** — reads both v1 envelope format and legacy PersistentStore formats
156
232
 
157
- ## Legacy Format Migration
158
-
159
- The library transparently handles migration from legacy formats:
160
-
161
- ```typescript
162
- // If disk contains legacy format: { count: 42 }
163
- // Load merges with defaults: { count: 42, extra: true }
164
- await store.loadAsync();
165
-
166
- // Subsequent save writes new envelope format:
167
- // { value: { count: 42, extra: true }, lastUpdated: "2025-01-01T..." }
168
- await store.saveAsync();
169
- ```
170
-
171
233
  ## Exported Types
172
234
 
173
235
  The package also exports the following types for advanced usage:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardlydifficult/state-tracker",
3
- "version": "2.0.8",
3
+ "version": "2.0.9",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [