@asaidimu/utils-store 1.0.0 → 2.0.0
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 +327 -206
- package/package.json +4 -4
- package/LICENSE.md +0 -21
- package/index.d.mts +0 -286
- package/index.d.ts +0 -286
- package/index.js +0 -1295
- package/index.mjs +0 -1280
package/README.md
CHANGED
@@ -1,10 +1,12 @@
|
|
1
|
-
# `@asaidimu/utils-store`
|
1
|
+
# `@asaidimu/utils-store`
|
2
|
+
|
3
|
+
**A Reactive Data Store**
|
2
4
|
|
3
5
|
A comprehensive, type-safe, and reactive state management library for TypeScript applications, featuring robust middleware, transactional updates, deep observability, and an optional persistence layer.
|
4
6
|
|
5
7
|
[](https://www.npmjs.com/package/@asaidimu/utils-store)
|
6
8
|
[](LICENSE)
|
7
|
-
[](https://github.com/asaidimu/erp-utils/actions?query=workflow%3ACI)
|
8
10
|
|
9
11
|
---
|
10
12
|
|
@@ -17,7 +19,7 @@ A comprehensive, type-safe, and reactive state management library for TypeScript
|
|
17
19
|
- [Persistence Integration](#persistence-integration)
|
18
20
|
- [Middleware System](#middleware-system)
|
19
21
|
- [Transaction Support](#transaction-support)
|
20
|
-
- [Store
|
22
|
+
- [Store Observer](#store-observability)
|
21
23
|
- [Event System](#event-system)
|
22
24
|
- [Project Architecture](#project-architecture)
|
23
25
|
- [Development & Contributing](#development--contributing)
|
@@ -29,7 +31,7 @@ A comprehensive, type-safe, and reactive state management library for TypeScript
|
|
29
31
|
|
30
32
|
`@asaidimu/utils-store` is a powerful and flexible state management solution designed for modern TypeScript applications. It provides a highly performant and observable way to manage your application's data, ensuring type safety and predictability across complex state interactions. Built on principles of immutability and explicit updates, it makes state changes easy to track, debug, and extend.
|
31
33
|
|
32
|
-
|
34
|
+
This library offers tools to handle your state with confidence, enabling features like atomic transactions, a pluggable middleware pipeline, and deep runtime introspection for unparalleled debugging capabilities. It emphasizes a component-based design internally, allowing for clear separation of concerns for state management, middleware processing, persistence, and observability.
|
33
35
|
|
34
36
|
### Key Features
|
35
37
|
|
@@ -40,8 +42,8 @@ Whether you're building a simple utility or a complex application, this library
|
|
40
42
|
* **Blocking Middleware**: Implement custom validation or authorization logic to prevent invalid state changes from occurring. These middlewares return a boolean.
|
41
43
|
* 📦 **Atomic Transaction Support**: Group multiple state updates into a single, atomic operation. If any update within the transaction fails, the entire transaction is rolled back to the state before the transaction began, guaranteeing data integrity.
|
42
44
|
* 💾 **Optional Persistence Layer**: Seamlessly integrate with any `SimplePersistence<T>` implementation (e.g., for local storage, IndexedDB, or backend sync) to load and save state, ensuring data durability and synchronization across instances.
|
43
|
-
* 🔍 **Deep
|
44
|
-
* **Event History**: Keep a detailed log of all internal store events (`update:start`, `middleware:complete`, `transaction:error`, `persistence:ready`, etc.).
|
45
|
+
* 🔍 **Deep Observer & Debugging**: An optional `StoreObserver` class provides unparalleled runtime introspection:
|
46
|
+
* **Event History**: Keep a detailed log of all internal store events (`update:start`, `middleware:complete`, `transaction:error`, `persistence:ready`, `middleware:executed`, etc.).
|
45
47
|
* **State Snapshots**: Maintain a configurable history of your state over time, allowing for easy inspection of changes between updates.
|
46
48
|
* **Time-Travel Debugging**: Undo and redo state changes using the recorded state history, providing powerful capabilities for debugging complex scenarios.
|
47
49
|
* **Performance Metrics**: Track real-time performance indicators like total update count, listener executions, average update times, and largest update size to identify bottlenecks.
|
@@ -60,6 +62,12 @@ Install the entire collection using your preferred package manager. You can then
|
|
60
62
|
```bash
|
61
63
|
# Using Bun
|
62
64
|
bun add @asaidimu/utils-store
|
65
|
+
|
66
|
+
# Using npm
|
67
|
+
npm install @asaidimu/utils-store
|
68
|
+
|
69
|
+
# Using yarn
|
70
|
+
yarn add @asaidimu/utils-store
|
63
71
|
```
|
64
72
|
|
65
73
|
### Prerequisites
|
@@ -82,18 +90,31 @@ interface MyState {
|
|
82
90
|
|
83
91
|
const store = new ReactiveDataStore<MyState>({ count: 0, message: "Hello" });
|
84
92
|
|
93
|
+
// Subscribing to "count" will only log when 'count' path changes
|
85
94
|
store.subscribe("count", (state) => {
|
86
95
|
console.log(`Count changed to: ${state.count}`);
|
87
96
|
});
|
88
97
|
|
89
|
-
|
90
|
-
|
98
|
+
// Subscribing to "" (empty string) will log for any store update
|
99
|
+
store.subscribe("", (state) => {
|
100
|
+
console.log(`Store updated to: ${JSON.stringify(state)}`);
|
101
|
+
});
|
91
102
|
|
92
|
-
console.log("
|
103
|
+
console.log("Initial state:", store.get());
|
104
|
+
// Output: Initial state: { count: 0, message: "Hello" }
|
93
105
|
|
94
|
-
|
106
|
+
await store.set({ count: 1 });
|
107
|
+
// Output:
|
95
108
|
// Count changed to: 1
|
96
|
-
//
|
109
|
+
// Store updated to: {"count":1,"message":"Hello"}
|
110
|
+
|
111
|
+
await store.set({ message: "World" });
|
112
|
+
// Output:
|
113
|
+
// Store updated to: {"count":1,"message":"World"}
|
114
|
+
// (The 'count' listener won't be triggered as only 'message' changed)
|
115
|
+
|
116
|
+
console.log("Current state:", store.get());
|
117
|
+
// Output: Current state: { count: 1, message: "World" }
|
97
118
|
```
|
98
119
|
|
99
120
|
Run this file:
|
@@ -112,7 +133,7 @@ This section provides practical examples of how to use `@asaidimu/utils-store` t
|
|
112
133
|
Creating a store, getting state, and setting updates.
|
113
134
|
|
114
135
|
```typescript
|
115
|
-
import { ReactiveDataStore, DeepPartial } from '@asaidimu/utils-store';
|
136
|
+
import { ReactiveDataStore, type DeepPartial } from '@asaidimu/utils-store';
|
116
137
|
|
117
138
|
// 1. Define your state interface for type safety
|
118
139
|
interface AppState {
|
@@ -182,8 +203,8 @@ await store.set((state) => ({
|
|
182
203
|
lastUpdated: Date.now(),
|
183
204
|
}));
|
184
205
|
|
185
|
-
console.log('State after functional update:', store.get().products.length);
|
186
|
-
// Output: State after functional update: 3
|
206
|
+
console.log('State after functional update, products count:', store.get().products.length);
|
207
|
+
// Output: State after functional update, products count: 3
|
187
208
|
|
188
209
|
// 6. Subscribing to state changes
|
189
210
|
// You can subscribe to the entire state or specific paths.
|
@@ -202,19 +223,19 @@ const unsubscribeMulti = store.subscribe(['user.name', 'products'], (state) => {
|
|
202
223
|
|
203
224
|
// Subscribe to any change in the store
|
204
225
|
const unsubscribeAll = store.subscribe('', (state) => {
|
205
|
-
console.log('Store updated (any path changed).');
|
226
|
+
console.log('Store updated (any path changed). Current products count:', state.products.length);
|
206
227
|
});
|
207
228
|
|
208
229
|
await store.set({ user: { email: 'jane.smith@example.com' } });
|
209
230
|
// Output:
|
210
231
|
// User data changed: { id: '123', name: 'Jane Smith', email: 'jane.smith@example.com', isActive: false }
|
211
232
|
// User name or products changed: Jane Smith 3
|
212
|
-
// Store updated (any path changed).
|
233
|
+
// Store updated (any path changed). Current products count: 3
|
213
234
|
|
214
235
|
await store.set({ settings: { notificationsEnabled: false } });
|
215
236
|
// Output:
|
216
237
|
// Notifications setting changed: false
|
217
|
-
// Store updated (any path changed).
|
238
|
+
// Store updated (any path changed). Current products count: 3
|
218
239
|
|
219
240
|
// 7. Unsubscribe from changes
|
220
241
|
unsubscribeUser();
|
@@ -227,10 +248,11 @@ await store.set({ user: { isActive: true } });
|
|
227
248
|
|
228
249
|
// 8. Deleting properties
|
229
250
|
// Use Symbol.for("delete") to remove a property from the state.
|
230
|
-
|
251
|
+
// Note: You might need a type cast for TypeScript if strict type checking is enabled.
|
252
|
+
const DELETE_ME = Symbol.for("delete");
|
231
253
|
await store.set({
|
232
254
|
user: {
|
233
|
-
email:
|
255
|
+
email: DELETE_ME as DeepPartial<string> // Cast is needed for type inference
|
234
256
|
}
|
235
257
|
});
|
236
258
|
console.log('User email after deletion:', store.get().user.email);
|
@@ -239,18 +261,24 @@ console.log('User email after deletion:', store.get().user.email);
|
|
239
261
|
|
240
262
|
### Persistence Integration
|
241
263
|
|
242
|
-
The `ReactiveDataStore` can integrate with any persistence layer that implements the `SimplePersistence<T>` interface. This allows you to load an initial state and save subsequent changes.
|
264
|
+
The `ReactiveDataStore` can integrate with any persistence layer that implements the `SimplePersistence<T>` interface. This allows you to load an initial state and save subsequent changes. The store emits a `persistence:ready` event once the persistence layer has loaded any initial state.
|
243
265
|
|
244
266
|
```typescript
|
245
|
-
import { ReactiveDataStore, StoreEvent } from '@asaidimu/utils-store';
|
246
|
-
|
267
|
+
import { ReactiveDataStore, type StoreEvent } from '@asaidimu/utils-store';
|
268
|
+
// Assuming @core/persistence or similar is available from your utility package
|
269
|
+
import type { SimplePersistence } from '@core/persistence';
|
247
270
|
|
248
271
|
// Example: A simple in-memory persistence for demonstration
|
249
272
|
// In a real app, this would interact with localStorage, IndexedDB, a backend, etc.
|
250
273
|
class InMemoryPersistence<T> implements SimplePersistence<T> {
|
251
274
|
private data: T | null = null;
|
275
|
+
// Using a map to simulate different instances subscribing to common data
|
252
276
|
private subscribers: Map<string, (state: T) => void> = new Map();
|
253
277
|
|
278
|
+
constructor(initialData: T | null = null) {
|
279
|
+
this.data = initialData;
|
280
|
+
}
|
281
|
+
|
254
282
|
async get(): Promise<T | null> {
|
255
283
|
console.log('Persistence: Loading state...');
|
256
284
|
return this.data;
|
@@ -261,8 +289,9 @@ class InMemoryPersistence<T> implements SimplePersistence<T> {
|
|
261
289
|
this.data = state;
|
262
290
|
// Simulate external change notification for other instances
|
263
291
|
this.subscribers.forEach((callback, subId) => {
|
264
|
-
|
265
|
-
|
292
|
+
// Only notify other instances, not the one that just saved
|
293
|
+
if (subId !== instanceId) {
|
294
|
+
callback(structuredClone(this.data!)); // Pass a clone to prevent mutation
|
266
295
|
}
|
267
296
|
});
|
268
297
|
return true;
|
@@ -283,35 +312,42 @@ interface UserConfig {
|
|
283
312
|
fontSize: number;
|
284
313
|
}
|
285
314
|
|
286
|
-
// Create a persistence instance
|
287
|
-
const userConfigPersistence = new InMemoryPersistence<UserConfig>();
|
315
|
+
// Create a persistence instance, possibly with some pre-existing data
|
316
|
+
const userConfigPersistence = new InMemoryPersistence<UserConfig>({ theme: 'dark', fontSize: 18 });
|
288
317
|
|
289
|
-
//
|
290
|
-
const
|
318
|
+
// Initialize the store with persistence
|
319
|
+
const store = new ReactiveDataStore<UserConfig>(
|
320
|
+
{ theme: 'light', fontSize: 16 }, // Initial state if no persisted data found or persistence is not used
|
321
|
+
userConfigPersistence // Pass your persistence implementation here
|
322
|
+
);
|
323
|
+
|
324
|
+
// Optionally, listen for persistence readiness (important for UIs that depend on loaded state)
|
325
|
+
const storeReadyPromise = new Promise<void>(resolve => {
|
291
326
|
store.onStoreEvent('persistence:ready', () => {
|
292
327
|
console.log('Store is ready and persistence is initialized!');
|
293
328
|
resolve();
|
294
329
|
});
|
295
330
|
});
|
296
331
|
|
297
|
-
|
298
|
-
const store = new ReactiveDataStore<UserConfig>(
|
299
|
-
{ theme: 'light', fontSize: 16 }, // Initial state if no persisted data
|
300
|
-
userConfigPersistence // Pass your persistence implementation here
|
301
|
-
);
|
332
|
+
console.log('Store initial state (before persistence loads):', store.get());
|
302
333
|
|
303
|
-
|
334
|
+
await storeReadyPromise; // Wait for persistence to load/initialize
|
304
335
|
|
305
|
-
|
336
|
+
// Now, store.get() will reflect the loaded state from persistence
|
337
|
+
console.log('Store state after persistence load:', store.get());
|
338
|
+
// Output: Store state after persistence load: { theme: 'dark', fontSize: 18 } (from InMemoryPersistence)
|
306
339
|
|
307
340
|
// Now update the state, which will trigger persistence.set()
|
308
|
-
await store.set({ theme: '
|
341
|
+
await store.set({ theme: 'light' });
|
309
342
|
console.log('Current theme:', store.get().theme);
|
343
|
+
// Output: Current theme: light
|
310
344
|
|
311
345
|
// Simulate an external change (e.g., another tab updating the state)
|
312
|
-
|
313
|
-
|
346
|
+
// Note: This 'another-instance-id' ensures the current store instance gets the update via subscription.
|
347
|
+
await userConfigPersistence.set('another-instance-id', { theme: 'system', fontSize: 20 } as UserConfig); // Simulating a state update from outside the store
|
348
|
+
// The store will automatically update its state and notify its listeners due to the internal subscription.
|
314
349
|
console.log('Current theme after external update:', store.get().theme);
|
350
|
+
// Output: Current theme after external update: system
|
315
351
|
```
|
316
352
|
|
317
353
|
### Middleware System
|
@@ -323,36 +359,37 @@ Middleware functions allow you to intercept and modify state updates.
|
|
323
359
|
These middlewares can transform the `DeepPartial` update or perform side effects. They receive the current state and the incoming partial update, and can return a new partial state to be merged.
|
324
360
|
|
325
361
|
```typescript
|
326
|
-
import { ReactiveDataStore, DeepPartial } from '@asaidimu/utils-store';
|
362
|
+
import { ReactiveDataStore, type DeepPartial } from '@asaidimu/utils-store';
|
327
363
|
|
328
364
|
interface MyState {
|
329
365
|
counter: number;
|
330
366
|
logs: string[];
|
331
367
|
lastAction: string | null;
|
368
|
+
version: number;
|
332
369
|
}
|
333
370
|
|
334
371
|
const store = new ReactiveDataStore<MyState>({
|
335
372
|
counter: 0,
|
336
373
|
logs: [],
|
337
374
|
lastAction: null,
|
375
|
+
version: 0,
|
338
376
|
});
|
339
377
|
|
340
378
|
// Middleware 1: Logger
|
341
|
-
// Logs the incoming update before it's processed
|
379
|
+
// Logs the incoming update before it's processed. Does not return anything (void).
|
342
380
|
store.use({
|
343
381
|
name: 'LoggerMiddleware',
|
344
382
|
action: (state, update) => {
|
345
383
|
console.log('Middleware: Incoming update:', update);
|
346
|
-
// You don't need to return anything if you just want to observe or perform side effects
|
347
384
|
},
|
348
385
|
});
|
349
386
|
|
350
387
|
// Middleware 2: Timestamp and Action Tracker
|
351
|
-
// Modifies the update to add a timestamp and track the last action
|
388
|
+
// Modifies the update to add a timestamp and track the last action. Returns a partial state.
|
352
389
|
store.use({
|
353
390
|
name: 'TimestampActionMiddleware',
|
354
391
|
action: (state, update) => {
|
355
|
-
// This middleware transforms the update by adding `lastAction`
|
392
|
+
// This middleware transforms the update by adding `lastAction` and a log entry
|
356
393
|
const actionDescription = JSON.stringify(update);
|
357
394
|
return {
|
358
395
|
lastAction: `Updated at ${new Date().toLocaleTimeString()} with ${actionDescription}`,
|
@@ -361,12 +398,22 @@ store.use({
|
|
361
398
|
},
|
362
399
|
});
|
363
400
|
|
364
|
-
// Middleware 3:
|
365
|
-
// Increments
|
401
|
+
// Middleware 3: Version Incrementor
|
402
|
+
// Increments a version counter for every update.
|
403
|
+
store.use({
|
404
|
+
name: 'VersionIncrementMiddleware',
|
405
|
+
action: (state) => {
|
406
|
+
return { version: state.version + 1 };
|
407
|
+
},
|
408
|
+
});
|
409
|
+
|
410
|
+
// Middleware 4: Counter Incrementor
|
411
|
+
// Increments the counter by the incoming value if the update is a number.
|
366
412
|
store.use({
|
367
413
|
name: 'CounterIncrementMiddleware',
|
368
414
|
action: (state, update) => {
|
369
|
-
|
415
|
+
// Only apply if the incoming update is a number for 'counter'
|
416
|
+
if (typeof update.counter === 'number') {
|
370
417
|
return { counter: state.counter + update.counter };
|
371
418
|
}
|
372
419
|
// Return original update or void if no transformation needed
|
@@ -375,23 +422,30 @@ store.use({
|
|
375
422
|
});
|
376
423
|
|
377
424
|
await store.set({ counter: 5 }); // Will increment counter by 5, not set to 5
|
378
|
-
//
|
379
|
-
// Middleware: Incoming update: { counter: 5 }
|
425
|
+
// Expected console output from LoggerMiddleware: Middleware: Incoming update: { counter: 5 }
|
380
426
|
console.log('State after counter set:', store.get());
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
427
|
+
/* Output will show:
|
428
|
+
counter: 5 (initial) + 5 (update) = 10,
|
429
|
+
lastAction updated by TimestampActionMiddleware,
|
430
|
+
logs updated by TimestampActionMiddleware,
|
431
|
+
version: 1 (incremented by VersionIncrementMiddleware)
|
432
|
+
*/
|
433
|
+
|
434
|
+
await store.set({ lastAction: 'Manual update from outside middleware' });
|
435
|
+
// Expected console output from LoggerMiddleware: Middleware: Incoming update: { lastAction: 'Manual update from outside middleware' }
|
386
436
|
console.log('State after manual action:', store.get());
|
387
|
-
|
388
|
-
|
437
|
+
/* Output will show:
|
438
|
+
lastAction will be overwritten by TimestampActionMiddleware logic,
|
439
|
+
a new log entry will be added,
|
440
|
+
version: 2
|
441
|
+
*/
|
389
442
|
|
390
443
|
// Unuse a middleware by its ID
|
391
|
-
const
|
444
|
+
const temporaryLoggerId = store.use({ name: 'TemporaryLogger', action: (s, u) => console.log('Temporary logger saw:', u) });
|
392
445
|
await store.set({ counter: 1 });
|
393
|
-
|
394
|
-
|
446
|
+
// Output: Temporary logger saw: { counter: 1 }
|
447
|
+
store.unuse(temporaryLoggerId);
|
448
|
+
await store.set({ counter: 1 }); // TemporaryLogger will not be called now
|
395
449
|
```
|
396
450
|
|
397
451
|
#### Blocking Middleware
|
@@ -399,26 +453,28 @@ await store.set({ counter: 1 }); // AnotherLogger will not be called now
|
|
399
453
|
These middlewares can prevent an update from proceeding if certain conditions are not met. They return a boolean: `true` to allow, `false` to block. If a blocking middleware throws an error, the update is also blocked.
|
400
454
|
|
401
455
|
```typescript
|
402
|
-
import { ReactiveDataStore, DeepPartial } from '@asaidimu/utils-store';
|
456
|
+
import { ReactiveDataStore, type DeepPartial } from '@asaidimu/utils-store';
|
403
457
|
|
404
458
|
interface UserProfile {
|
405
459
|
name: string;
|
406
460
|
age: number;
|
407
461
|
isAdmin: boolean;
|
462
|
+
isVerified: boolean;
|
408
463
|
}
|
409
464
|
|
410
465
|
const store = new ReactiveDataStore<UserProfile>({
|
411
466
|
name: 'Guest',
|
412
467
|
age: 0,
|
413
468
|
isAdmin: false,
|
469
|
+
isVerified: false,
|
414
470
|
});
|
415
471
|
|
416
|
-
// Blocking middleware: Age validation
|
472
|
+
// Blocking middleware 1: Age validation
|
417
473
|
store.use({
|
418
474
|
block: true, // Mark as a blocking middleware
|
419
475
|
name: 'AgeValidationMiddleware',
|
420
476
|
action: (state, update) => {
|
421
|
-
if (update.age !== undefined && update.age < 18) {
|
477
|
+
if (update.age !== undefined && typeof update.age === 'number' && update.age < 18) {
|
422
478
|
console.warn('Blocking update: Age must be 18 or older.');
|
423
479
|
return false; // Block the update
|
424
480
|
}
|
@@ -426,15 +482,21 @@ store.use({
|
|
426
482
|
},
|
427
483
|
});
|
428
484
|
|
429
|
-
// Blocking middleware: Admin check
|
485
|
+
// Blocking middleware 2: Admin check
|
430
486
|
store.use({
|
431
487
|
block: true,
|
432
488
|
name: 'AdminRestrictionMiddleware',
|
433
489
|
action: (state, update) => {
|
490
|
+
// Cannot become admin if under 21
|
434
491
|
if (update.isAdmin === true && state.age < 21) {
|
435
492
|
console.warn('Blocking update: User must be 21+ to become admin.');
|
436
493
|
return false;
|
437
494
|
}
|
495
|
+
// Cannot become admin if not verified
|
496
|
+
if (update.isAdmin === true && !state.isVerified) {
|
497
|
+
console.warn('Blocking update: User must be verified to become admin.');
|
498
|
+
return false;
|
499
|
+
}
|
438
500
|
return true;
|
439
501
|
},
|
440
502
|
});
|
@@ -447,12 +509,17 @@ console.log('User age after valid update:', store.get().age); // Output: 25
|
|
447
509
|
await store.set({ age: 16 });
|
448
510
|
console.log('User age after invalid update attempt (should be 25):', store.get().age); // Output: 25
|
449
511
|
|
450
|
-
// Attempt to make user admin
|
451
|
-
await store.set({ age: 20 });
|
512
|
+
// Attempt to make user admin while not verified (should fail)
|
452
513
|
await store.set({ isAdmin: true });
|
453
514
|
console.log('User admin status after failed attempt (should be false):', store.get().isAdmin); // Output: false
|
454
515
|
|
455
|
-
//
|
516
|
+
// Verify user, then attempt to make admin again (should fail due to age)
|
517
|
+
await store.set({ isVerified: true });
|
518
|
+
await store.set({ age: 20 });
|
519
|
+
await store.set({ isAdmin: true });
|
520
|
+
console.log('User admin status after failed age attempt (should be false):', store.get().isAdmin); // Output: false
|
521
|
+
|
522
|
+
// Now make user old enough and verified, then try again (should succeed)
|
456
523
|
await store.set({ age: 25 });
|
457
524
|
await store.set({ isAdmin: true });
|
458
525
|
console.log('User admin status after successful attempt (should be true):', store.get().isAdmin); // Output: true
|
@@ -466,95 +533,108 @@ Use `store.transaction()` to group multiple state updates into a single atomic o
|
|
466
533
|
import { ReactiveDataStore } from '@asaidimu/utils-store';
|
467
534
|
|
468
535
|
interface BankAccount {
|
536
|
+
name: string;
|
469
537
|
balance: number;
|
470
538
|
transactions: string[];
|
471
539
|
}
|
472
540
|
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
});
|
541
|
+
// Set up two bank accounts
|
542
|
+
const accountA = new ReactiveDataStore<BankAccount>({ name: 'Account A', balance: 500, transactions: [] });
|
543
|
+
const accountB = new ReactiveDataStore<BankAccount>({ name: 'Account B', balance: 200, transactions: [] });
|
477
544
|
|
545
|
+
// A function to transfer funds using transactions
|
478
546
|
async function transferFunds(
|
479
547
|
fromStore: ReactiveDataStore<BankAccount>,
|
480
548
|
toStore: ReactiveDataStore<BankAccount>,
|
481
549
|
amount: number,
|
482
550
|
) {
|
551
|
+
// All operations inside this transaction will be atomic
|
483
552
|
await fromStore.transaction(async () => {
|
484
553
|
console.log(`Starting transfer of ${amount}. From: ${fromStore.get().balance}, To: ${toStore.get().balance}`);
|
485
554
|
|
486
555
|
// Deduct from sender
|
487
556
|
await fromStore.set((state) => {
|
488
557
|
if (state.balance < amount) {
|
558
|
+
// Throwing an error here will cause the entire transaction to roll back
|
489
559
|
throw new Error('Insufficient funds');
|
490
560
|
}
|
491
561
|
return {
|
492
562
|
balance: state.balance - amount,
|
493
|
-
transactions: [...state.transactions, `Debited ${amount}`],
|
563
|
+
transactions: [...state.transactions, `Debited ${amount} from ${state.name}`],
|
494
564
|
};
|
495
565
|
});
|
496
566
|
|
497
|
-
// Simulate a delay or another async operation
|
567
|
+
// Simulate a network delay or another async operation that might fail
|
498
568
|
await new Promise(resolve => setTimeout(resolve, 50));
|
499
569
|
|
500
570
|
// Add to receiver
|
501
571
|
await toStore.set((state) => ({
|
502
572
|
balance: state.balance + amount,
|
503
|
-
transactions: [...state.transactions, `Credited ${amount}`],
|
573
|
+
transactions: [...state.transactions, `Credited ${amount} to ${state.name}`],
|
504
574
|
}));
|
505
575
|
|
506
|
-
console.log(`Transfer
|
576
|
+
console.log(`Transfer in progress. From: ${fromStore.get().balance}, To: ${toStore.get().balance}`);
|
507
577
|
});
|
578
|
+
console.log(`Transfer successful. From: ${fromStore.get().balance}, To: ${toStore.get().balance}`);
|
508
579
|
}
|
509
580
|
|
510
|
-
|
511
|
-
|
581
|
+
console.log('--- Initial Balances ---');
|
582
|
+
console.log('Account A:', accountA.get().balance); // Expected: 500
|
583
|
+
console.log('Account B:', accountB.get().balance); // Expected: 200
|
512
584
|
|
513
|
-
// Successful transfer
|
585
|
+
// --- Scenario 1: Successful transfer ---
|
586
|
+
console.log('\n--- Attempting successful transfer (100) ---');
|
514
587
|
try {
|
515
588
|
await transferFunds(accountA, accountB, 100);
|
516
|
-
console.log('
|
517
|
-
console.log('Account A:', accountA.get()); // Expected: balance 400
|
518
|
-
console.log('Account B:', accountB.get()); // Expected: balance 300
|
589
|
+
console.log('\nTransfer 1 successful:');
|
590
|
+
console.log('Account A:', accountA.get()); // Expected: balance 400, transactions: ['Debited 100 from Account A']
|
591
|
+
console.log('Account B:', accountB.get()); // Expected: balance 300, transactions: ['Credited 100 to Account B']
|
519
592
|
} catch (error: any) {
|
520
593
|
console.error('Transfer 1 failed:', error.message);
|
521
594
|
}
|
522
595
|
|
523
|
-
// Failed transfer (insufficient funds)
|
596
|
+
// --- Scenario 2: Failed transfer (insufficient funds) ---
|
597
|
+
console.log('\n--- Attempting failed transfer (1000) ---');
|
524
598
|
try {
|
525
|
-
|
599
|
+
// Account A only has 400 now, so this should fail
|
600
|
+
await transferFunds(accountA, accountB, 1000);
|
526
601
|
} catch (error: any) {
|
527
|
-
console.error('Transfer 2 failed:', error.message);
|
602
|
+
console.error('Transfer 2 failed as expected:', error.message);
|
528
603
|
} finally {
|
529
604
|
console.log('Transfer 2 attempt, state after rollback:');
|
530
|
-
|
531
|
-
console.log('Account
|
605
|
+
// State should be rolled back to its state *before* the transaction attempt
|
606
|
+
console.log('Account A:', accountA.get());
|
607
|
+
// Expected: balance 400 (rolled back)
|
608
|
+
console.log('Account B:', accountB.get());
|
609
|
+
// Expected: balance 300 (rolled back)
|
532
610
|
}
|
533
611
|
```
|
534
612
|
|
535
|
-
### Store
|
613
|
+
### Store Observer
|
536
614
|
|
537
|
-
The `
|
615
|
+
The `StoreObserver` class provides advanced debugging and monitoring capabilities for any `ReactiveDataStore` instance. It allows you to inspect event history, state changes, and time-travel through your application's state.
|
538
616
|
|
539
617
|
```typescript
|
540
|
-
import { ReactiveDataStore,
|
618
|
+
import { ReactiveDataStore, StoreObserver, type StoreEvent, type DeepPartial } from '@asaidimu/utils-store';
|
541
619
|
|
542
620
|
interface DebuggableState {
|
543
621
|
user: { name: string; status: 'online' | 'offline' };
|
544
622
|
messages: string[];
|
545
|
-
settings: { debugMode: boolean };
|
623
|
+
settings: { debugMode: boolean; logLevel: string };
|
624
|
+
metrics: { updates: number };
|
546
625
|
}
|
547
626
|
|
548
627
|
const store = new ReactiveDataStore<DebuggableState>({
|
549
628
|
user: { name: 'Debugger', status: 'online' },
|
550
629
|
messages: [],
|
551
|
-
settings: { debugMode: true },
|
630
|
+
settings: { debugMode: true, logLevel: 'info' },
|
631
|
+
metrics: { updates: 0 },
|
552
632
|
});
|
553
633
|
|
554
634
|
// Initialize observability for the store
|
555
|
-
const
|
556
|
-
maxEvents:
|
557
|
-
maxStateHistory:
|
635
|
+
const observer = new StoreObserver(store, {
|
636
|
+
maxEvents: 50, // Keep up to 50 events in history
|
637
|
+
maxStateHistory: 5, // Keep up to 5 state snapshots for time-travel
|
558
638
|
enableConsoleLogging: true, // Log events to console for immediate feedback
|
559
639
|
logEvents: {
|
560
640
|
updates: true, // Log all update lifecycle events
|
@@ -567,70 +647,92 @@ const observability = new StoreObservability(store, {
|
|
567
647
|
},
|
568
648
|
});
|
569
649
|
|
650
|
+
// Add a simple middleware to demonstrate middleware logging
|
651
|
+
store.use({ name: 'SimpleMiddleware', action: async (state, update) => {
|
652
|
+
await new Promise(resolve => setTimeout(resolve, 10)); // Simulate work
|
653
|
+
return { metrics: { updates: state.metrics.updates + 1 } };
|
654
|
+
}});
|
655
|
+
|
570
656
|
// Perform some state updates
|
571
657
|
await store.set({ user: { status: 'offline' } });
|
572
658
|
await store.set({ messages: ['Hello World!'] });
|
573
659
|
await store.set({ settings: { debugMode: false } });
|
574
660
|
|
661
|
+
// Simulate a slow update
|
662
|
+
await new Promise(resolve => setTimeout(resolve, 60)); // Artificially delay
|
663
|
+
await store.set({ messages: ['Another message', 'And another'] });
|
664
|
+
// This last set will cause a console warning for "Slow update detected" if enableConsoleLogging is true.
|
665
|
+
|
575
666
|
// 1. Get Event History
|
576
|
-
console.log('\n--- Event History ---');
|
577
|
-
const events =
|
667
|
+
console.log('\n--- Event History (Most Recent First) ---');
|
668
|
+
const events = observer.getEventHistory();
|
578
669
|
// Events will include: update:start, update:complete (multiple times), middleware:start, middleware:complete, etc.
|
579
|
-
events.forEach(event => console.log(
|
670
|
+
events.slice(0, 5).forEach(event => console.log(`Type: ${event.type}, Data: ${JSON.stringify(event.data).substring(0, 70)}...`));
|
580
671
|
|
581
672
|
// 2. Get State History
|
582
673
|
console.log('\n--- State History (Most Recent First) ---');
|
583
|
-
const stateSnapshots =
|
584
|
-
stateSnapshots.forEach((snapshot, index) => console.log(`State #${index}:`, snapshot));
|
674
|
+
const stateSnapshots = observer.getStateHistory();
|
675
|
+
stateSnapshots.forEach((snapshot, index) => console.log(`State #${index}:`, snapshot.messages, snapshot.user.status));
|
585
676
|
|
586
677
|
// 3. Get Recent Changes
|
587
|
-
console.log('\n--- Recent State Changes (
|
588
|
-
const recentChanges =
|
678
|
+
console.log('\n--- Recent State Changes (Diffs) ---');
|
679
|
+
const recentChanges = observer.getRecentChanges(3); // Show diffs for last 3 changes
|
589
680
|
recentChanges.forEach((change, index) => {
|
590
|
-
console.log(
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
});
|
681
|
+
console.log(`\nChange #${index}:`);
|
682
|
+
console.log(` Timestamp: ${new Date(change.timestamp).toLocaleTimeString()}`);
|
683
|
+
console.log(` Changed Paths: ${change.changedPaths.join(', ')}`);
|
684
|
+
console.log(` From:`, change.from);
|
685
|
+
console.log(` To:`, change.to);
|
596
686
|
});
|
597
687
|
|
598
688
|
// 4. Time-Travel Debugging
|
599
689
|
console.log('\n--- Time-Travel ---');
|
600
|
-
const timeTravel =
|
690
|
+
const timeTravel = observer.createTimeTravel();
|
601
691
|
|
602
|
-
|
603
|
-
await store.set({
|
604
|
-
await store.set({ messages: ['
|
692
|
+
// Add more states to the history for time-travel
|
693
|
+
await store.set({ user: { status: 'online' } }); // State A (latest)
|
694
|
+
await store.set({ messages: ['First message'] }); // State B
|
695
|
+
await store.set({ messages: ['Second message'] }); // State C
|
605
696
|
|
606
|
-
console.log('Current state (latest):', store.get().messages);
|
697
|
+
console.log('Current state (latest):', store.get().messages); // Output: ['Second message']
|
607
698
|
|
608
699
|
if (timeTravel.canUndo()) {
|
609
|
-
await timeTravel.undo(); // Go back to State
|
610
|
-
console.log('After undo 1:', store.get().messages);
|
700
|
+
await timeTravel.undo(); // Go back to State B
|
701
|
+
console.log('After undo 1:', store.get().messages); // Output: ['First message']
|
611
702
|
}
|
612
703
|
|
613
704
|
if (timeTravel.canUndo()) {
|
614
|
-
await timeTravel.undo(); // Go back to State
|
615
|
-
console.log('After undo 2:', store.get().messages);
|
705
|
+
await timeTravel.undo(); // Go back to State A
|
706
|
+
console.log('After undo 2:', store.get().messages); // Output: [] (original initial state)
|
616
707
|
}
|
617
708
|
|
618
709
|
if (timeTravel.canRedo()) {
|
619
|
-
await timeTravel.redo(); // Go forward to State
|
620
|
-
console.log('After redo 1:', store.get().messages);
|
710
|
+
await timeTravel.redo(); // Go forward to State B
|
711
|
+
console.log('After redo 1:', store.get().messages); // Output: ['First message']
|
621
712
|
}
|
622
713
|
|
623
|
-
// 5
|
624
|
-
|
625
|
-
|
714
|
+
console.log('Time-Travel history length:', timeTravel.getHistoryLength()); // Will be `maxStateHistory` (5 in this case) + any initial states.
|
715
|
+
|
716
|
+
// 5. Custom Debugging Middleware (provided by StoreObserver for convenience)
|
717
|
+
const loggingMiddleware = observer.createLoggingMiddleware({
|
718
|
+
logLevel: 'info', // 'debug', 'info', 'warn'
|
626
719
|
logUpdates: true,
|
627
720
|
});
|
628
|
-
store.use({ name: 'DebugLogging', action: loggingMiddleware });
|
721
|
+
const loggingMiddlewareId = store.use({ name: 'DebugLogging', action: loggingMiddleware });
|
722
|
+
|
723
|
+
await store.set({ user: { name: 'New User Via Debug Logger' } }); // This update will be logged by the created middleware.
|
724
|
+
// Expected console output: "State Update: { user: { name: 'New User Via Debug Logger' } }"
|
629
725
|
|
630
|
-
|
726
|
+
// 6. Clear history
|
727
|
+
observer.clearHistory();
|
728
|
+
console.log('\nHistory cleared. Events:', observer.getEventHistory().length, 'State snapshots:', observer.getStateHistory().length);
|
729
|
+
// Output: History cleared. Events: 0 State snapshots: 1 (keeps current state)
|
631
730
|
|
632
|
-
//
|
633
|
-
|
731
|
+
// 7. Disconnect observer when no longer needed to prevent memory leaks
|
732
|
+
observer.disconnect();
|
733
|
+
console.log('\nObserver disconnected. No more events or state changes will be tracked.');
|
734
|
+
// After disconnect, new updates won't be logged or tracked by Observer
|
735
|
+
await store.set({ messages: ['Final message after disconnect'] });
|
634
736
|
```
|
635
737
|
|
636
738
|
### Event System
|
@@ -642,75 +744,104 @@ import { ReactiveDataStore, StoreEvent } from '@asaidimu/utils-store';
|
|
642
744
|
|
643
745
|
interface MyState {
|
644
746
|
value: number;
|
747
|
+
status: string;
|
645
748
|
}
|
646
749
|
|
647
|
-
const store = new ReactiveDataStore<MyState>({ value: 0 });
|
750
|
+
const store = new ReactiveDataStore<MyState>({ value: 0, status: 'idle' });
|
648
751
|
|
649
752
|
// Subscribe to 'update:start' event
|
650
753
|
store.onStoreEvent('update:start', (data) => {
|
651
|
-
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] Update started.`);
|
754
|
+
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ⚡ Update started.`);
|
652
755
|
});
|
653
756
|
|
654
757
|
// Subscribe to 'update:complete' event
|
655
758
|
store.onStoreEvent('update:complete', (data) => {
|
656
759
|
if (data.blocked) {
|
657
|
-
console.warn(`[${new Date(data.timestamp).toLocaleTimeString()}] Update blocked. Error:`, data.error?.message);
|
760
|
+
console.warn(`[${new Date(data.timestamp).toLocaleTimeString()}] ✋ Update blocked. Error:`, data.error?.message);
|
658
761
|
} else {
|
659
|
-
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] Update complete. Changed paths: ${data.changedPaths?.join(', ')} (took ${data.duration?.toFixed(2)}ms)`);
|
762
|
+
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ✅ Update complete. Changed paths: ${data.changedPaths?.join(', ')} (took ${data.duration?.toFixed(2)}ms)`);
|
660
763
|
}
|
661
764
|
});
|
662
765
|
|
663
766
|
// Subscribe to middleware events
|
664
767
|
store.onStoreEvent('middleware:start', (data) => {
|
665
|
-
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] Middleware "${data.name}" (${data.type}) started.`);
|
768
|
+
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ▶ Middleware "${data.name}" (${data.type}) started.`);
|
666
769
|
});
|
667
770
|
|
668
771
|
store.onStoreEvent('middleware:complete', (data) => {
|
669
|
-
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] Middleware "${data.name}" (${data.type}) completed in ${data.duration?.toFixed(2)}ms.`);
|
772
|
+
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ◀ Middleware "${data.name}" (${data.type}) completed in ${data.duration?.toFixed(2)}ms.`);
|
670
773
|
});
|
671
774
|
|
672
775
|
store.onStoreEvent('middleware:error', (data) => {
|
673
|
-
console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] Middleware "${data.name}" failed:`, data.error);
|
776
|
+
console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] ❌ Middleware "${data.name}" failed:`, data.error);
|
777
|
+
});
|
778
|
+
|
779
|
+
store.onStoreEvent('middleware:blocked', (data) => {
|
780
|
+
console.warn(`[${new Date(data.timestamp).toLocaleTimeString()}] 🛑 Middleware "${data.name}" blocked an update.`);
|
781
|
+
});
|
782
|
+
|
783
|
+
store.onStoreEvent('middleware:executed', (data) => {
|
784
|
+
// This event captures detailed execution info for all middlewares
|
785
|
+
console.debug(`[${new Date(data.timestamp).toLocaleTimeString()}] 📊 Middleware executed: "${data.name}" - Duration: ${data.duration?.toFixed(2)}ms, Blocked: ${data.blocked}`);
|
674
786
|
});
|
675
787
|
|
676
788
|
// Subscribe to transaction events
|
677
789
|
store.onStoreEvent('transaction:start', (data) => {
|
678
|
-
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] Transaction started.`);
|
790
|
+
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] 📦 Transaction started.`);
|
679
791
|
});
|
680
792
|
|
681
793
|
store.onStoreEvent('transaction:complete', (data) => {
|
682
|
-
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] Transaction complete.`);
|
794
|
+
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] 📦 Transaction complete.`);
|
683
795
|
});
|
684
796
|
|
685
797
|
store.onStoreEvent('transaction:error', (data) => {
|
686
|
-
console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] Transaction failed:`, data.error);
|
798
|
+
console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] 📦 Transaction failed:`, data.error);
|
687
799
|
});
|
688
800
|
|
689
801
|
// Subscribe to persistence events
|
690
802
|
store.onStoreEvent('persistence:ready', (data) => {
|
691
|
-
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] Persistence layer is ready.`);
|
803
|
+
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] 💾 Persistence layer is ready.`);
|
692
804
|
});
|
693
805
|
|
694
806
|
|
695
|
-
// Add a middleware to demonstrate
|
807
|
+
// Add a transform middleware to demonstrate
|
696
808
|
store.use({
|
697
|
-
name: '
|
809
|
+
name: 'ValueIncrementMiddleware',
|
698
810
|
action: (state, update) => {
|
699
811
|
return { value: state.value + (update.value || 0) };
|
700
812
|
},
|
701
813
|
});
|
702
814
|
|
815
|
+
// Add a blocking middleware to demonstrate
|
816
|
+
store.use({
|
817
|
+
name: 'StatusValidationMiddleware',
|
818
|
+
block: true,
|
819
|
+
action: (state, update) => {
|
820
|
+
if (update.status === 'error' && state.value < 10) {
|
821
|
+
throw new Error('Cannot set status to error if value is too low!');
|
822
|
+
}
|
823
|
+
return true;
|
824
|
+
},
|
825
|
+
});
|
826
|
+
|
703
827
|
// Perform operations
|
704
|
-
|
828
|
+
console.log('\n--- Perform Initial Update ---');
|
829
|
+
await store.set({ value: 5, status: 'active' }); // Will increment value by 5
|
705
830
|
|
831
|
+
console.log('\n--- Perform Transactional Update (Success) ---');
|
706
832
|
await store.transaction(async () => {
|
707
|
-
await store.set({ value:
|
708
|
-
|
709
|
-
throw new Error('Value too high!');
|
710
|
-
}
|
833
|
+
await store.set({ value: 3 }); // Inside transaction, value becomes 5 + 3 = 8
|
834
|
+
await store.set({ status: 'processing' });
|
711
835
|
});
|
712
836
|
|
713
|
-
console.log('
|
837
|
+
console.log('\n--- Perform Update (Blocked by Middleware) ---');
|
838
|
+
try {
|
839
|
+
await store.set({ status: 'error' }); // This should be blocked by StatusValidationMiddleware (value is 8, which is < 10)
|
840
|
+
} catch (e: any) {
|
841
|
+
console.log(`Caught expected error: ${e.message}`);
|
842
|
+
}
|
843
|
+
|
844
|
+
console.log('Final value:', store.get().value, 'Final status:', store.get().status);
|
714
845
|
```
|
715
846
|
|
716
847
|
---
|
@@ -719,58 +850,47 @@ console.log('Final value:', store.get().value);
|
|
719
850
|
|
720
851
|
The `@asaidimu/utils-store` library is built with a modular, component-based architecture to promote maintainability, testability, and extensibility.
|
721
852
|
|
722
|
-
```
|
723
|
-
src/store/
|
724
|
-
├─── diff.ts # Efficient change detection and path derivation
|
725
|
-
├─── metrics.ts # Gathers performance metrics from store events
|
726
|
-
├─── middleware.ts # Manages and executes state transformation and blocking middlewares
|
727
|
-
├─── observability.ts # Optional module for deep debugging, state history, and time-travel
|
728
|
-
├─── persistence.ts # Handles integration with external SimplePersistence implementations
|
729
|
-
├─── state.ts # Core immutable state management and internal change notifications
|
730
|
-
├─── store.ts # The main ReactiveDataStore class, orchestrates all components
|
731
|
-
├─── transactions.ts # Provides atomic state update transactions with rollback
|
732
|
-
├─── types.ts # Central TypeScript type definitions for the entire store
|
733
|
-
└─── index.ts # Export barrel file for the store module
|
734
|
-
```
|
735
|
-
|
736
853
|
### Core Components
|
737
854
|
|
738
855
|
* **`ReactiveDataStore<T>` (store.ts)**: The primary entry point and public API for the state management system. It acts as an orchestrator, delegating tasks to specialized internal components. It manages the update queue and public interfaces like `set`, `get`, `subscribe`, `transaction`, `use`, and `onStoreEvent`.
|
739
856
|
* **`CoreStateManager<T>` (state.ts)**: Encapsulates the actual, immutable state (`cache`). It's responsible for applying incoming state changes, performing efficient `diff`ing to identify modified paths, and notifying internal listeners (via an `updateBus`) about concrete state changes.
|
740
|
-
* **`MiddlewareEngine<T>` (middleware.ts)**: Manages the registration and execution of middleware functions. It differentiates between `blocking` middleware (which can halt an update) and `transform` middleware (which can modify the update payload), ensuring they run in the correct order.
|
857
|
+
* **`MiddlewareEngine<T>` (middleware.ts)**: Manages the registration and execution of middleware functions. It differentiates between `blocking` middleware (which can halt an update) and `transform` middleware (which can modify the update payload), ensuring they run in the correct order. It also emits detailed lifecycle events for observability.
|
741
858
|
* **`PersistenceHandler<T>` (persistence.ts)**: Deals with external data persistence. It loads initial state from a provided `SimplePersistence` implementation and saves subsequent state changes. It also listens for external updates from the persistence layer to keep the in-memory state synchronized.
|
742
|
-
* **`TransactionManager<T>` (transactions.ts)**: Provides atomic state operations. It creates a snapshot of the state before an `operation` and, if the operation fails, it ensures the state is reverted to this snapshot, guaranteeing data integrity.
|
743
|
-
* **`MetricsCollector` (metrics.ts)**: Observes the internal `eventBus` to gather and expose performance metrics of the store, such as update counts, listener executions, and average update times.
|
744
|
-
* **`
|
745
|
-
* **`
|
746
|
-
* **`
|
859
|
+
* **`TransactionManager<T>` (transactions.ts)**: Provides atomic state operations. It creates a snapshot of the state before an `operation` and, if the operation fails, it ensures the state is reverted to this snapshot, guaranteeing data integrity. It integrates closely with the store's event system for tracking.
|
860
|
+
* **`MetricsCollector` (metrics.ts)**: Observes the internal `eventBus` to gather and expose real-time performance metrics of the store, such as update counts, listener executions, and average update times.
|
861
|
+
* **`StoreObserver<T>` (observability.ts)**: An optional, but highly recommended, debugging companion. It taps into the `ReactiveDataStore`'s events and state changes to build a comprehensive history of events and state snapshots, enabling time-travel debugging, detailed console logging, and performance monitoring.
|
862
|
+
* **`createMerge` (merge.ts)**: A factory function that returns a configurable deep merging utility. This utility preserves immutability and specifically handles `Symbol.for("delete")` for explicit property removal.
|
863
|
+
* **`createDiff` / `createDerivePaths` (diff.ts)**: Factory functions returning utilities for efficient comparison between two objects (original vs. changes) to identify changed paths (`diff`) and for deriving all parent paths from a set of changes (`derivePaths`). This is crucial for optimizing listener notifications and change detection.
|
747
864
|
|
748
865
|
### Data Flow
|
749
866
|
|
750
867
|
The `ReactiveDataStore` handles state updates in a robust, queued manner.
|
751
868
|
|
752
869
|
1. **`store.set(update)` call**:
|
753
|
-
* If an update is already in progress (`isUpdating`), the new `update` is queued in `pendingUpdates
|
870
|
+
* If an update is already in progress (`isUpdating`), the new `update` is queued in `pendingUpdates` (available via `store.state().pendingChanges`). This ensures sequential processing and prevents race conditions.
|
754
871
|
* An `update:start` event is emitted.
|
755
872
|
2. **Middleware Execution**:
|
756
|
-
* The `MiddlewareEngine` first runs all `blocking` middlewares
|
757
|
-
* If not blocked, `transform` middlewares are executed sequentially. Each transform middleware can
|
873
|
+
* The `MiddlewareEngine` first runs all `blocking` middlewares (registered via `store.use({ block: true, ... })`). If any blocking middleware returns `false` or throws an error, the update is halted, an `update:complete` (blocked) event is emitted, and the process stops.
|
874
|
+
* If not blocked, `transform` middlewares (registered via `store.use({ action: ... })`) are executed sequentially. Each transform middleware receives the current state and the incoming partial update, and can return a new partial state that is merged into the effective update payload.
|
875
|
+
* Lifecycle events (`middleware:start`, `middleware:complete`, `middleware:error`, `middleware:blocked`, `middleware:executed`) are emitted during this phase for observability.
|
758
876
|
3. **State Application**:
|
759
|
-
* The `CoreStateManager` receives the (potentially transformed) update.
|
760
|
-
* It
|
761
|
-
*
|
877
|
+
* The `CoreStateManager` receives the (potentially transformed) final `DeepPartial` update.
|
878
|
+
* It internally uses the `merge` function to create the new full state object immutably.
|
879
|
+
* It then performs a `diff` comparison between the *previous* state and the *new* state to identify all changed paths (`string[]`).
|
880
|
+
* If changes are detected, the `CoreStateManager` updates its internal immutable `cache` to the `newState` and then emits an `update` event for each granular `changedPath` on its internal `updateBus`.
|
762
881
|
4. **Listener Notification**:
|
763
|
-
* Any external subscribers (
|
882
|
+
* Any external subscribers (registered with `store.subscribe()`) whose registered paths match or are parent paths of the `changedPaths` are notified with the latest state. The `MetricsCollector` tracks `listenerExecutions`.
|
764
883
|
5. **Persistence Handling**:
|
765
|
-
* The `PersistenceHandler` receives the `changedPaths` and the new state. If a `SimplePersistence` implementation
|
884
|
+
* The `PersistenceHandler` receives the `changedPaths` and the new state. If a `SimplePersistence` implementation was configured during store initialization, it attempts to save the new state using `persistence.set()`.
|
885
|
+
* The `PersistenceHandler` also manages loading initial state and reacting to external state changes (e.g., from other browser tabs or processes) through `persistence.subscribe()`.
|
766
886
|
6. **Completion & Queue Processing**:
|
767
887
|
* An `update:complete` event is emitted, containing information about the update's duration, changed paths, and any blocking errors.
|
768
|
-
* The `isUpdating` flag is reset, and if there are `pendingUpdates
|
888
|
+
* The `isUpdating` flag is reset, and if there are `pendingUpdates` in the queue, the next update is immediately pulled and processed, ensuring sequential updates.
|
769
889
|
|
770
890
|
### Extension Points
|
771
891
|
|
772
|
-
* **Custom Middleware**: Users can inject their own `Middleware` and `BlockingMiddleware` functions using `store.use()` to customize update logic, add logging, validation, or side effects.
|
773
|
-
* **Custom Persistence**: The `SimplePersistence<T>` interface allows developers to integrate the store with any storage solution, whether it's local storage, IndexedDB, a backend API, or a WebSocket connection.
|
892
|
+
* **Custom Middleware**: Users can inject their own `Middleware` and `BlockingMiddleware` functions using `store.use()` to customize update logic, add logging, validation, authorization, or trigger side effects.
|
893
|
+
* **Custom Persistence**: The `SimplePersistence<T>` interface allows developers to integrate the store with any storage solution, whether it's local storage, IndexedDB, a backend API, or a WebSocket connection, providing full control over data durability.
|
774
894
|
|
775
895
|
---
|
776
896
|
|
@@ -782,35 +902,33 @@ Contributions are welcome! Follow these guidelines to get started.
|
|
782
902
|
|
783
903
|
1. **Clone the repository:**
|
784
904
|
```bash
|
785
|
-
git clone https://github.com/asaidimu/utils.git
|
786
|
-
cd utils
|
905
|
+
git clone https://github.com/asaidimu/erp-utils.git
|
906
|
+
cd erp-utils
|
787
907
|
```
|
788
908
|
2. **Install dependencies:**
|
789
|
-
The `@asaidimu/utils-store` module is part of a monorepo
|
909
|
+
The `@asaidimu/utils-store` module is part of a monorepo managed with `pnpm` workspaces.
|
790
910
|
```bash
|
791
911
|
pnpm install
|
792
|
-
#
|
793
|
-
# npm install -g pnpm
|
794
|
-
# pnpm install
|
912
|
+
# If you don't have pnpm installed globally: npm install -g pnpm
|
795
913
|
```
|
796
914
|
3. **Build the project:**
|
797
|
-
Navigate to the `store` package directory and run the build script.
|
915
|
+
Navigate to the `store` package directory and run the build script, or build the entire monorepo from the root.
|
798
916
|
```bash
|
799
|
-
cd
|
917
|
+
cd src/store # (from the monorepo root)
|
800
918
|
pnpm build # or npm run build
|
801
|
-
|
802
|
-
|
803
|
-
```bash
|
804
|
-
pnpm build # (from the monorepo root)
|
919
|
+
# Or, from the monorepo root:
|
920
|
+
# pnpm build
|
805
921
|
```
|
806
922
|
|
807
923
|
### Scripts
|
808
924
|
|
925
|
+
From the `src/store` directory:
|
926
|
+
|
809
927
|
* `pnpm test`: Runs all unit tests using Vitest.
|
810
|
-
* `pnpm test:watch`: Runs tests in watch mode.
|
928
|
+
* `pnpm test:watch`: Runs tests in watch mode for continuous development.
|
811
929
|
* `pnpm lint`: Lints the codebase using ESLint.
|
812
930
|
* `pnpm format`: Formats the code using Prettier.
|
813
|
-
* `pnpm build`: Compiles TypeScript to JavaScript.
|
931
|
+
* `pnpm build`: Compiles TypeScript to JavaScript and generates declaration files.
|
814
932
|
|
815
933
|
### Testing
|
816
934
|
|
@@ -819,12 +937,14 @@ All tests are written with [Vitest](https://vitest.dev/).
|
|
819
937
|
To run tests:
|
820
938
|
|
821
939
|
```bash
|
940
|
+
cd src/store
|
822
941
|
pnpm test
|
823
942
|
```
|
824
943
|
|
825
944
|
To run tests and watch for changes during development:
|
826
945
|
|
827
946
|
```bash
|
947
|
+
cd src/store
|
828
948
|
pnpm test:watch
|
829
949
|
```
|
830
950
|
|
@@ -836,11 +956,11 @@ Ensure all new features have comprehensive test coverage and existing tests pass
|
|
836
956
|
2. **Ensure code quality**: Write clean, readable, and maintainable code. Adhere to existing coding styles.
|
837
957
|
3. **Tests**: Add unit and integration tests for new features or bug fixes. Ensure all existing tests pass.
|
838
958
|
4. **Commit messages**: Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for clear and consistent commit history (e.g., `feat: add new feature`, `fix: resolve bug`).
|
839
|
-
5. **Pull Requests**: Open a pull request to the `main` branch. Describe your changes clearly and link to any relevant issues.
|
959
|
+
5. **Pull Requests**: Open a pull request to the `main` branch of the `erp-utils` repository. Describe your changes clearly and link to any relevant issues.
|
840
960
|
|
841
961
|
### Issue Reporting
|
842
962
|
|
843
|
-
If you find a bug or have a feature request, please open an issue on the [GitHub repository](https://github.com/asaidimu/utils
|
963
|
+
If you find a bug or have a feature request, please open an issue on the [GitHub repository](https://github.com/asaidimu/erp-utils/issues).
|
844
964
|
* For bugs, include steps to reproduce, expected behavior, and actual behavior.
|
845
965
|
* For feature requests, describe the use case and proposed solution.
|
846
966
|
|
@@ -851,47 +971,48 @@ If you find a bug or have a feature request, please open an issue on the [GitHub
|
|
851
971
|
### Troubleshooting
|
852
972
|
|
853
973
|
* **"Update not triggering listeners"**:
|
854
|
-
* Ensure you are subscribing to the correct path. `store.subscribe('user.name', ...)` will not trigger if you update `user.email
|
855
|
-
* If the new value is strictly equal (`===`) to the old value, no change will be detected, and listeners will not be notified.
|
974
|
+
* Ensure you are subscribing to the correct path. `store.subscribe('user.name', ...)` will not trigger if you update `user.email` (unless you also subscribe to `user` or the root `''`).
|
975
|
+
* If the new value is strictly equal (`===`) to the old value, no change will be detected by the internal `diff` function, and listeners will not be notified.
|
856
976
|
* Verify your `DeepPartial` update correctly targets the intended part of the state.
|
857
977
|
* **"State not rolling back after transaction error"**:
|
858
|
-
* Ensure the error is thrown *within* the `transaction` callback. Errors outside will not
|
859
|
-
* Promises within the transaction *must* be `await`ed so the transaction manager can capture potential rejections.
|
978
|
+
* Ensure the error is thrown *within* the `transaction` callback. Errors caught and handled inside, or thrown outside, will not trigger the rollback mechanism.
|
979
|
+
* Promises within the transaction *must* be `await`ed so the transaction manager can capture potential rejections and manage the atomic operation.
|
860
980
|
* **"Middleware not being applied"**:
|
861
981
|
* Verify the middleware is registered with `store.use()` *before* the `set` operation it should affect.
|
862
|
-
* Check middleware `name` if
|
863
|
-
* Ensure your middleware returns `DeepPartial<T>` or `void`/`Promise<void
|
982
|
+
* Check middleware `name` in console logs (if `StoreObserver` is enabled) to ensure it's being hit.
|
983
|
+
* Ensure your `transform` middleware returns a `DeepPartial<T>` or `void`/`Promise<void>`, and `blocking` middleware returns `boolean`/`Promise<boolean>`.
|
864
984
|
* **"Performance warnings in console"**:
|
865
|
-
* If `
|
985
|
+
* If `StoreObserver` is enabled with `enableConsoleLogging: true`, it will warn about updates or middlewares exceeding defined `performanceThresholds`. This is an informational warning, not an error, indicating a potentially slow operation that could be optimized.
|
866
986
|
|
867
987
|
### FAQ
|
868
988
|
|
869
989
|
**Q: How are arrays handled during updates?**
|
870
|
-
A: Arrays are treated as primitive values. When you provide an array in a `DeepPartial` update, the entire array at that path is replaced, not merged. This ensures predictable behavior and prevents complex partial array merging logic.
|
990
|
+
A: Arrays are treated as primitive values. When you provide an array in a `DeepPartial` update (e.g., `set({ items: [new Array] })`), the entire array at that path is replaced, not merged. This ensures predictable behavior and prevents complex partial array merging logic.
|
871
991
|
|
872
992
|
**Q: What is `Symbol.for("delete")`?**
|
873
|
-
A: It's a special symbol used to explicitly remove a property from the state during a `set` operation. If you pass `Symbol.for("delete")` as the value for a key in your `DeepPartial` update, that key will be removed from the state.
|
993
|
+
A: It's a special global symbol (`Symbol.for("delete")` evaluates to the same symbol across realms) used to explicitly remove a property from the state during a `set` operation. If you pass `Symbol.for("delete")` as the value for a key in your `DeepPartial` update, that key will be removed from the state object.
|
874
994
|
|
875
995
|
**Q: How do I debug my store's state changes?**
|
876
|
-
A: The `
|
996
|
+
A: The `StoreObserver` class is your primary tool. Instantiate it with your `ReactiveDataStore` instance. It provides methods to get event history, state snapshots, and even time-travel debugging capabilities. Enabling `enableConsoleLogging: true` in `StoreObserver` options provides immediate, formatted console output during development.
|
877
997
|
|
878
998
|
**Q: What is `SimplePersistence<T>`?**
|
879
|
-
A: It's a simple interface (`{ get(): Promise<T | null>; set(instanceId: string, state: T): Promise<boolean>; subscribe(instanceId: string, listener: (state: T) => void): () => void; }`) that defines the contract for any persistence layer. You need to provide an implementation of this interface to enable state saving and loading.
|
999
|
+
A: It's a simple interface (`{ get(): Promise<T | null>; set(instanceId: string, state: T): Promise<boolean>; subscribe(instanceId: string, listener: (state: T) => void): () => void; }`) that defines the contract for any persistence layer. You need to provide an implementation of this interface to enable state saving and loading. An example can be found in the `InMemoryPersistence` example above.
|
880
1000
|
|
881
1001
|
**Q: Can I use this with React/Vue/Angular?**
|
882
|
-
A: Yes. This is a framework-agnostic state management library. You would typically use the `subscribe` method within your framework's lifecycle hooks (e.g., `useEffect` in React) to react to state changes and update your UI components.
|
1002
|
+
A: Yes. This is a framework-agnostic state management library. You would typically use the `subscribe` method within your framework's lifecycle hooks (e.g., `useEffect` in React, `onMounted` in Vue) to react to state changes and update your UI components.
|
883
1003
|
|
884
1004
|
### Changelog / Roadmap
|
885
1005
|
|
886
|
-
* **Changelog**: For detailed version history, please refer to the [CHANGELOG.md](CHANGELOG.md) file.
|
1006
|
+
* **Changelog**: For detailed version history, please refer to the [CHANGELOG.md](https://github.com/asaidimu/erp-utils/blob/main/src/store/CHANGELOG.md) file.
|
887
1007
|
* **Roadmap**: Future plans may include:
|
888
|
-
*
|
889
|
-
* More advanced query/selector capabilities.
|
890
|
-
* Built-in serialization options for persistence.
|
1008
|
+
* Official framework integrations (e.g., React hooks library).
|
1009
|
+
* More advanced query/selector capabilities with memoization.
|
1010
|
+
* Built-in serialization/deserialization options for persistence.
|
1011
|
+
* Middleware for common patterns (e.g., async data fetching).
|
891
1012
|
|
892
1013
|
### License
|
893
1014
|
|
894
|
-
This project is licensed under the [MIT License](LICENSE).
|
1015
|
+
This project is licensed under the [MIT License](https://github.com/asaidimu/erp-utils/blob/main/LICENSE).
|
895
1016
|
|
896
1017
|
### Acknowledgments
|
897
1018
|
|