@asaidimu/utils-store 9.0.2 → 10.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 +666 -454
- package/index.d.mts +3 -3
- package/index.d.ts +3 -3
- package/index.js +1 -1
- package/index.mjs +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -38,26 +38,26 @@ This library offers robust tools to handle your state with confidence, enabling
|
|
|
38
38
|
|
|
39
39
|
### Key Features
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
41
|
+
- 📊 **Type-safe State Management**: Full TypeScript support for defining and interacting with your application state, leveraging `DeepPartial<T>` for precise, structural updates while maintaining strong type inference.
|
|
42
|
+
- 🔄 **Reactive Updates & Granular Subscriptions**: Subscribe to granular changes at specific paths within your state or listen for any change, ensuring efficient re-renders or side effects. The internal `diff` algorithm optimizes notifications by identifying only truly changed paths.
|
|
43
|
+
- 🚀 **Action System**: Dispatch named actions to encapsulate state updates, improving code organization and enabling detailed logging and observability for each logical operation, including debouncing capabilities.
|
|
44
|
+
- 🔍 **Reactive Selectors with Memoization**: Efficiently derive computed state from your store using reactive selectors that re-evaluate and notify subscribers only when their accessed paths actually change, preventing unnecessary renders.
|
|
45
|
+
- 🧠 **Composable Middleware System**:
|
|
46
|
+
- **Transform Middleware**: Intercept, modify, normalize, or enrich state updates before they are applied. These can return a `DeepPartial<T>` to apply further changes or `void` for side effects.
|
|
47
|
+
- **Blocking Middleware**: Implement custom validation, authorization, or other conditional logic to prevent invalid state changes from occurring. If a blocking middleware returns `false` or throws an error, the update is immediately halted and rolled back.
|
|
48
|
+
- 📦 **Atomic Transaction Support**: Group multiple state updates into a single, atomic operation. If any update within the transaction fails or an error is thrown, the entire transaction is rolled back to the state before the transaction began, guaranteeing data integrity. Supports both synchronous and asynchronous operations.
|
|
49
|
+
- 💾 **Optional Persistence Layer**: Seamlessly integrate with any `SimplePersistence<T>` implementation (e.g., for local storage, IndexedDB, or backend synchronization) to load an initial state and save subsequent changes. The store emits a `persistence:ready` event and listens for external updates, handling persistence queueing and retries.
|
|
50
|
+
- 🧩 **Artifacts (Dependency Injection)**: A flexible dependency injection system to manage services, utilities, or complex objects that your actions and other artifacts depend on. Supports `Singleton` (re-evaluated on dependencies change) and `Transient` (new instance every time) scopes, and reactive resolution using `use(({select, resolve}) => ...)` context. Handles dependency graphs and circular dependency detection.
|
|
51
|
+
- 👀 **Deep Observer & Debugging (`StoreObserver`)**: An optional but highly recommended class for unparalleled runtime introspection and debugging:
|
|
52
|
+
- **Comprehensive Event History**: Captures a detailed log of all internal store events (`update:start`, `middleware:complete`, `transaction:error`, `persistence:ready`, `middleware:executed`, `action:start`, `selector:accessed`, etc.).
|
|
53
|
+
- **State Snapshots**: Maintains a configurable history of your application's state over time, allowing for easy inspection of changes between updates and post-mortem analysis.
|
|
54
|
+
- **Time-Travel Debugging**: Leverage the recorded state history to `undo` and `redo` state changes, providing powerful capabilities for debugging complex asynchronous flows and state transitions.
|
|
55
|
+
- **Performance Metrics**: Track real-time performance indicators like total update count, listener executions, average update times, largest update size, and slow operation warnings to identify bottlenecks.
|
|
56
|
+
- **Configurable Console Logging**: Provides human-readable, color-coded logging of store events directly to the browser console for immediate feedback during development.
|
|
57
|
+
- **Pre-built Debugging Middlewares**: Includes helper methods to easily create a generic logging middleware and a validation middleware for immediate use.
|
|
58
|
+
- **Session Management**: Save and load observer sessions for offline analysis or sharing bug reproductions.
|
|
59
|
+
- 🗑️ **Property Deletion**: Supports explicit property deletion within partial updates using the global `Symbol.for("delete")` or a custom marker.
|
|
60
|
+
- ⚡ **Concurrency Handling**: Automatically queues and processes `set` updates to prevent race conditions during concurrent calls, ensuring updates are applied in a predictable, sequential order.
|
|
61
61
|
|
|
62
62
|
---
|
|
63
63
|
|
|
@@ -78,8 +78,8 @@ yarn add @asaidimu/utils-store
|
|
|
78
78
|
|
|
79
79
|
### Prerequisites
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
- **Node.js**: (LTS version recommended) for development and compilation.
|
|
82
|
+
- **TypeScript**: (v4.0+ recommended) for full type-safety during development. Modern TS features (ES2017+ for `async/await`, ES2020+ for `Symbol.for()` and `structuredClone()`) are utilized. `moduleResolution`: `Node16` or `Bundler` is recommended in `tsconfig.json`.
|
|
83
83
|
|
|
84
84
|
### Verification
|
|
85
85
|
|
|
@@ -87,7 +87,7 @@ To verify that the library is installed and working correctly, create a small Ty
|
|
|
87
87
|
|
|
88
88
|
```typescript
|
|
89
89
|
// verify.ts
|
|
90
|
-
import { ReactiveDataStore } from
|
|
90
|
+
import { ReactiveDataStore } from "@asaidimu/utils-store";
|
|
91
91
|
|
|
92
92
|
interface MyState {
|
|
93
93
|
count: number;
|
|
@@ -103,7 +103,7 @@ store.watch("count", (state) => {
|
|
|
103
103
|
|
|
104
104
|
// Subscribing to "" (empty string) will log for any store update
|
|
105
105
|
store.watch("", (state) => {
|
|
106
|
-
|
|
106
|
+
console.log(`Store updated to: ${JSON.stringify(state)}`);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
console.log("Initial state:", store.get());
|
|
@@ -140,7 +140,11 @@ This section provides practical examples and detailed explanations of how to use
|
|
|
140
140
|
Learn how to create a store, read state, and update state with partial objects or functions.
|
|
141
141
|
|
|
142
142
|
```typescript
|
|
143
|
-
import {
|
|
143
|
+
import {
|
|
144
|
+
ReactiveDataStore,
|
|
145
|
+
DELETE_SYMBOL,
|
|
146
|
+
type DeepPartial,
|
|
147
|
+
} from "@asaidimu/utils-store";
|
|
144
148
|
|
|
145
149
|
// 1. Define your state interface for type safety
|
|
146
150
|
interface AppState {
|
|
@@ -152,7 +156,7 @@ interface AppState {
|
|
|
152
156
|
};
|
|
153
157
|
products: Array<{ id: string; name: string; price: number }>;
|
|
154
158
|
settings: {
|
|
155
|
-
theme:
|
|
159
|
+
theme: "light" | "dark";
|
|
156
160
|
notificationsEnabled: boolean;
|
|
157
161
|
};
|
|
158
162
|
lastUpdated: number;
|
|
@@ -161,17 +165,17 @@ interface AppState {
|
|
|
161
165
|
// 2. Initialize the store with an initial state
|
|
162
166
|
const initialState: AppState = {
|
|
163
167
|
user: {
|
|
164
|
-
id:
|
|
165
|
-
name:
|
|
166
|
-
email:
|
|
168
|
+
id: "123",
|
|
169
|
+
name: "Jane Doe",
|
|
170
|
+
email: "jane@example.com",
|
|
167
171
|
isActive: true,
|
|
168
172
|
},
|
|
169
173
|
products: [
|
|
170
|
-
{ id:
|
|
171
|
-
{ id:
|
|
174
|
+
{ id: "p1", name: "Laptop", price: 1200 },
|
|
175
|
+
{ id: "p2", name: "Mouse", price: 25 },
|
|
172
176
|
],
|
|
173
177
|
settings: {
|
|
174
|
-
theme:
|
|
178
|
+
theme: "light",
|
|
175
179
|
notificationsEnabled: true,
|
|
176
180
|
},
|
|
177
181
|
lastUpdated: Date.now(),
|
|
@@ -183,7 +187,7 @@ const store = new ReactiveDataStore<AppState>(initialState);
|
|
|
183
187
|
// `store.get()` returns a reference to the internal state.
|
|
184
188
|
// Use `store.get(true)` to get a deep clone, ensuring immutability if you modify it directly.
|
|
185
189
|
const currentState = store.get();
|
|
186
|
-
console.log(
|
|
190
|
+
console.log("Initial state:", currentState);
|
|
187
191
|
/* Output:
|
|
188
192
|
Initial state: {
|
|
189
193
|
user: { id: '123', name: 'Jane Doe', email: 'jane@example.com', isActive: true },
|
|
@@ -197,15 +201,15 @@ Initial state: {
|
|
|
197
201
|
// You can update deeply nested properties without affecting siblings.
|
|
198
202
|
await store.set({
|
|
199
203
|
user: {
|
|
200
|
-
name:
|
|
201
|
-
isActive: false,
|
|
204
|
+
name: "Jane Smith", // Changes user's name
|
|
205
|
+
isActive: false, // Changes user's active status
|
|
202
206
|
},
|
|
203
207
|
settings: {
|
|
204
|
-
theme:
|
|
208
|
+
theme: "dark", // Changes theme
|
|
205
209
|
},
|
|
206
210
|
});
|
|
207
211
|
|
|
208
|
-
console.log(
|
|
212
|
+
console.log("State after partial update:", store.get());
|
|
209
213
|
/* Output:
|
|
210
214
|
State after partial update: {
|
|
211
215
|
user: { id: '123', name: 'Jane Smith', email: 'jane@example.com', isActive: false },
|
|
@@ -220,35 +224,51 @@ State after partial update: {
|
|
|
220
224
|
await store.set((state) => ({
|
|
221
225
|
products: [
|
|
222
226
|
...state.products, // Keep existing products
|
|
223
|
-
{ id:
|
|
227
|
+
{ id: "p3", name: "Keyboard", price: 75 }, // Add a new product
|
|
224
228
|
],
|
|
225
229
|
lastUpdated: Date.now(), // Update timestamp
|
|
226
230
|
}));
|
|
227
231
|
|
|
228
|
-
console.log(
|
|
232
|
+
console.log(
|
|
233
|
+
"State after functional update, products count:",
|
|
234
|
+
store.get().products.length,
|
|
235
|
+
);
|
|
229
236
|
// Output: State after functional update, products count: 3
|
|
230
237
|
|
|
231
238
|
// 6. Subscribing to state changes
|
|
232
239
|
// You can subscribe to the entire state (path: '') or specific paths (e.g., 'user.name', 'settings.notificationsEnabled').
|
|
233
|
-
const unsubscribeUser = store.watch(
|
|
234
|
-
console.log(
|
|
240
|
+
const unsubscribeUser = store.watch("user", (state) => {
|
|
241
|
+
console.log("User data changed:", state.user);
|
|
235
242
|
});
|
|
236
243
|
|
|
237
|
-
const unsubscribeNotifications = store.watch(
|
|
238
|
-
|
|
239
|
-
|
|
244
|
+
const unsubscribeNotifications = store.watch(
|
|
245
|
+
"settings.notificationsEnabled",
|
|
246
|
+
(state) => {
|
|
247
|
+
console.log(
|
|
248
|
+
"Notifications setting changed:",
|
|
249
|
+
state.settings.notificationsEnabled,
|
|
250
|
+
);
|
|
251
|
+
},
|
|
252
|
+
);
|
|
240
253
|
|
|
241
254
|
// Subscribe to multiple paths at once
|
|
242
|
-
const unsubscribeMulti = store.watch([
|
|
243
|
-
console.log(
|
|
255
|
+
const unsubscribeMulti = store.watch(["user.name", "products"], (state) => {
|
|
256
|
+
console.log(
|
|
257
|
+
"User name or products changed:",
|
|
258
|
+
state.user.name,
|
|
259
|
+
state.products.length,
|
|
260
|
+
);
|
|
244
261
|
});
|
|
245
262
|
|
|
246
263
|
// Subscribe to any change in the store (root listener)
|
|
247
|
-
const unsubscribeAll = store.watch(
|
|
248
|
-
console.log(
|
|
264
|
+
const unsubscribeAll = store.watch("", (state) => {
|
|
265
|
+
console.log(
|
|
266
|
+
"Store updated (any path changed). Current products count:",
|
|
267
|
+
state.products.length,
|
|
268
|
+
);
|
|
249
269
|
});
|
|
250
270
|
|
|
251
|
-
await store.set({ user: { email:
|
|
271
|
+
await store.set({ user: { email: "jane.smith@example.com" } });
|
|
252
272
|
/* Output (order may vary slightly depending on async operations):
|
|
253
273
|
User data changed: { id: '123', name: 'Jane Smith', email: 'jane.smith@example.com', isActive: false }
|
|
254
274
|
User name or products changed: Jane Smith 3
|
|
@@ -274,10 +294,10 @@ await store.set({ user: { isActive: true } });
|
|
|
274
294
|
// Use `DELETE_SYMBOL` (exported from the library) to explicitly remove a property from the state.
|
|
275
295
|
await store.set({
|
|
276
296
|
user: {
|
|
277
|
-
email: DELETE_SYMBOL as DeepPartial<string
|
|
278
|
-
}
|
|
297
|
+
email: DELETE_SYMBOL as DeepPartial<string>, // Type cast is needed for strict TypeScript environments
|
|
298
|
+
},
|
|
279
299
|
});
|
|
280
|
-
console.log(
|
|
300
|
+
console.log("User email after deletion:", store.get().user.email);
|
|
281
301
|
// Output: User email after deletion: undefined
|
|
282
302
|
```
|
|
283
303
|
|
|
@@ -286,7 +306,7 @@ console.log('User email after deletion:', store.get().user.email);
|
|
|
286
306
|
The `dispatch` method allows you to encapsulate state updates into named actions. This improves code organization, provides clear semantic meaning to updates, and enables detailed logging and observability through `StoreObserver` and the event system. Actions can also be debounced to prevent excessive updates.
|
|
287
307
|
|
|
288
308
|
```typescript
|
|
289
|
-
import { ReactiveDataStore } from
|
|
309
|
+
import { ReactiveDataStore } from "@asaidimu/utils-store";
|
|
290
310
|
|
|
291
311
|
interface CounterState {
|
|
292
312
|
value: number;
|
|
@@ -297,34 +317,42 @@ const store = new ReactiveDataStore<CounterState>({ value: 0, history: [] });
|
|
|
297
317
|
|
|
298
318
|
// 1. Register an action
|
|
299
319
|
store.register({
|
|
300
|
-
name:
|
|
301
|
-
fn: (state, amount: number) => ({
|
|
320
|
+
name: "incrementCounter",
|
|
321
|
+
fn: (state, amount: number) => ({
|
|
322
|
+
// Action function receives state and dispatched parameters
|
|
302
323
|
value: state.value + amount,
|
|
303
|
-
history: [
|
|
324
|
+
history: [
|
|
325
|
+
...state.history,
|
|
326
|
+
`Incremented by ${amount} at ${new Date().toLocaleTimeString()}`,
|
|
327
|
+
],
|
|
304
328
|
}),
|
|
305
329
|
});
|
|
306
330
|
|
|
307
331
|
// 2. Register an action with debounce
|
|
308
332
|
store.register({
|
|
309
|
-
name:
|
|
333
|
+
name: "incrementCounterDebounced",
|
|
310
334
|
fn: (state, amount: number) => ({
|
|
311
335
|
value: state.value + amount,
|
|
312
|
-
history: [
|
|
336
|
+
history: [
|
|
337
|
+
...state.history,
|
|
338
|
+
`Debounced Increment by ${amount} at ${new Date().toLocaleTimeString()}`,
|
|
339
|
+
],
|
|
313
340
|
}),
|
|
314
341
|
debounce: {
|
|
315
342
|
delay: 100, // Debounce for 100ms
|
|
316
343
|
// Optional: condition to decide whether to debounce.
|
|
317
344
|
// Here, we always debounce if `amount` is different from previous.
|
|
318
|
-
condition: (previousArgs, currentArgs) =>
|
|
319
|
-
|
|
345
|
+
condition: (previousArgs, currentArgs) =>
|
|
346
|
+
previousArgs?.[0] !== currentArgs[0],
|
|
347
|
+
},
|
|
320
348
|
});
|
|
321
349
|
|
|
322
350
|
// 3. Register an async action
|
|
323
351
|
store.register({
|
|
324
|
-
name:
|
|
352
|
+
name: "loadInitialValue",
|
|
325
353
|
fn: async (state, userId: string) => {
|
|
326
354
|
console.log(`Simulating loading initial value for user: ${userId}`);
|
|
327
|
-
await new Promise(resolve => setTimeout(resolve, 200)); // Simulate API call
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, 200)); // Simulate API call
|
|
328
356
|
return {
|
|
329
357
|
value: 100, // Fetched value
|
|
330
358
|
history: [...state.history, `Loaded initial value for ${userId}`],
|
|
@@ -333,41 +361,52 @@ store.register({
|
|
|
333
361
|
});
|
|
334
362
|
|
|
335
363
|
// 4. Dispatch actions
|
|
336
|
-
console.log(
|
|
364
|
+
console.log("Initial value:", store.get().value); // 0
|
|
337
365
|
|
|
338
|
-
await store.dispatch(
|
|
339
|
-
console.log(
|
|
366
|
+
await store.dispatch("incrementCounter", 5);
|
|
367
|
+
console.log("Value after incrementCounter(5):", store.get().value); // 5
|
|
340
368
|
|
|
341
|
-
await store.dispatch(
|
|
342
|
-
console.log(
|
|
369
|
+
await store.dispatch("incrementCounter", 10);
|
|
370
|
+
console.log("Value after incrementCounter(10):", store.get().value); // 15
|
|
343
371
|
|
|
344
372
|
// Dispatch debounced actions multiple times quickly
|
|
345
|
-
store.dispatch(
|
|
346
|
-
store.dispatch(
|
|
347
|
-
store.dispatch(
|
|
348
|
-
console.log(
|
|
373
|
+
store.dispatch("incrementCounterDebounced", 1);
|
|
374
|
+
store.dispatch("incrementCounterDebounced", 2);
|
|
375
|
+
store.dispatch("incrementCounterDebounced", 3);
|
|
376
|
+
console.log(
|
|
377
|
+
"Value after immediate debounced dispatches (still 15, waiting for debounce):",
|
|
378
|
+
store.get().value,
|
|
379
|
+
);
|
|
349
380
|
|
|
350
381
|
// Wait for the debounce to complete
|
|
351
|
-
await new Promise(resolve => setTimeout(150, resolve));
|
|
352
|
-
console.log(
|
|
382
|
+
await new Promise((resolve) => setTimeout(150, resolve));
|
|
383
|
+
console.log(
|
|
384
|
+
"Value after debounced dispatches settled (only last one applied):",
|
|
385
|
+
store.get().value,
|
|
386
|
+
); // 15 + 3 = 18
|
|
353
387
|
|
|
354
388
|
// Dispatch an async action
|
|
355
|
-
await store.dispatch(
|
|
356
|
-
console.log(
|
|
357
|
-
console.log(
|
|
389
|
+
await store.dispatch("loadInitialValue", "user-abc");
|
|
390
|
+
console.log("Value after loadInitialValue:", store.get().value); // 100 (overwritten by fetched value)
|
|
391
|
+
console.log("History:", store.get().history);
|
|
358
392
|
|
|
359
393
|
// 5. Deregister an action
|
|
360
394
|
const deregisterRisky = store.register({
|
|
361
|
-
name:
|
|
362
|
-
fn: () => {
|
|
395
|
+
name: "riskyAction",
|
|
396
|
+
fn: () => {
|
|
397
|
+
throw new Error("Risky action!");
|
|
398
|
+
},
|
|
363
399
|
});
|
|
364
400
|
|
|
365
401
|
deregisterRisky(); // Action is now removed
|
|
366
402
|
|
|
367
403
|
try {
|
|
368
|
-
await store.dispatch(
|
|
404
|
+
await store.dispatch("riskyAction");
|
|
369
405
|
} catch (error: any) {
|
|
370
|
-
console.error(
|
|
406
|
+
console.error(
|
|
407
|
+
"Expected error when dispatching deregistered action:",
|
|
408
|
+
error.message,
|
|
409
|
+
);
|
|
371
410
|
}
|
|
372
411
|
```
|
|
373
412
|
|
|
@@ -376,7 +415,7 @@ try {
|
|
|
376
415
|
Reactive selectors provide an efficient way to derive computed data from your store. They automatically track their dependencies and will only re-evaluate and notify subscribers if the underlying data they access changes. This prevents unnecessary re-renders in UI frameworks.
|
|
377
416
|
|
|
378
417
|
```typescript
|
|
379
|
-
import { ReactiveDataStore } from
|
|
418
|
+
import { ReactiveDataStore } from "@asaidimu/utils-store";
|
|
380
419
|
|
|
381
420
|
interface UserProfile {
|
|
382
421
|
firstName: string;
|
|
@@ -391,22 +430,26 @@ interface UserProfile {
|
|
|
391
430
|
}
|
|
392
431
|
|
|
393
432
|
const store = new ReactiveDataStore<UserProfile>({
|
|
394
|
-
firstName:
|
|
395
|
-
lastName:
|
|
396
|
-
email:
|
|
433
|
+
firstName: "John",
|
|
434
|
+
lastName: "Doe",
|
|
435
|
+
email: "john.doe@example.com",
|
|
397
436
|
address: {
|
|
398
|
-
city:
|
|
399
|
-
zipCode:
|
|
437
|
+
city: "New York",
|
|
438
|
+
zipCode: "10001",
|
|
400
439
|
},
|
|
401
440
|
isActive: true,
|
|
402
|
-
friends: [
|
|
441
|
+
friends: ["Alice", "Bob"],
|
|
403
442
|
});
|
|
404
443
|
|
|
405
444
|
// Create a reactive selector for the full name
|
|
406
|
-
const selectFullName = store.select(
|
|
445
|
+
const selectFullName = store.select(
|
|
446
|
+
(state) => `${state.firstName} ${state.lastName}`,
|
|
447
|
+
);
|
|
407
448
|
|
|
408
449
|
// Create a reactive selector for user's location
|
|
409
|
-
const selectLocation = store.select(
|
|
450
|
+
const selectLocation = store.select(
|
|
451
|
+
(state) => `${state.address.city}, ${state.address.zipCode}`,
|
|
452
|
+
);
|
|
410
453
|
|
|
411
454
|
// Create a reactive selector for user's active status
|
|
412
455
|
const selectIsActive = store.select((state) => state.isActive);
|
|
@@ -414,73 +457,71 @@ const selectIsActive = store.select((state) => state.isActive);
|
|
|
414
457
|
// Create a reactive selector for the number of friends
|
|
415
458
|
const selectFriendCount = store.select((state) => state.friends.length);
|
|
416
459
|
|
|
417
|
-
let lastFullName =
|
|
418
|
-
let lastLocation =
|
|
460
|
+
let lastFullName = "";
|
|
461
|
+
let lastLocation = "";
|
|
419
462
|
let lastIsActive = false;
|
|
420
463
|
let lastFriendCount = 0;
|
|
421
464
|
|
|
422
465
|
// Subscribe to the full name selector
|
|
423
466
|
const unsubscribeFullName = selectFullName.subscribe((fullName) => {
|
|
424
467
|
lastFullName = fullName;
|
|
425
|
-
console.log(
|
|
468
|
+
console.log("Full Name Changed:", lastFullName);
|
|
426
469
|
});
|
|
427
470
|
|
|
428
471
|
// Subscribe to the location selector
|
|
429
472
|
const unsubscribeLocation = selectLocation.subscribe((location) => {
|
|
430
473
|
lastLocation = location;
|
|
431
|
-
console.log(
|
|
474
|
+
console.log("Location Changed:", lastLocation);
|
|
432
475
|
});
|
|
433
476
|
|
|
434
477
|
// Subscribe to the active status selector
|
|
435
478
|
const unsubscribeIsActive = selectIsActive.subscribe((isActive) => {
|
|
436
479
|
lastIsActive = isActive;
|
|
437
|
-
console.log(
|
|
480
|
+
console.log("Is Active Changed:", lastIsActive);
|
|
438
481
|
});
|
|
439
482
|
|
|
440
483
|
// Subscribe to the friend count selector
|
|
441
484
|
const unsubscribeFriendCount = selectFriendCount.subscribe((count) => {
|
|
442
485
|
lastFriendCount = count;
|
|
443
|
-
console.log(
|
|
486
|
+
console.log("Friend Count Changed:", lastFriendCount);
|
|
444
487
|
});
|
|
445
488
|
|
|
446
|
-
|
|
447
489
|
// Get initial values
|
|
448
490
|
lastFullName = selectFullName.get();
|
|
449
491
|
lastLocation = selectLocation.get();
|
|
450
492
|
lastIsActive = selectIsActive.get();
|
|
451
493
|
lastFriendCount = selectFriendCount.get();
|
|
452
|
-
console.log(
|
|
453
|
-
console.log(
|
|
454
|
-
console.log(
|
|
455
|
-
console.log(
|
|
456
|
-
|
|
494
|
+
console.log("Initial Full Name:", lastFullName);
|
|
495
|
+
console.log("Initial Location:", lastLocation);
|
|
496
|
+
console.log("Initial Is Active:", lastIsActive);
|
|
497
|
+
console.log("Initial Friend Count:", lastFriendCount);
|
|
457
498
|
|
|
458
|
-
console.log(
|
|
499
|
+
console.log("\n--- Performing Updates ---");
|
|
459
500
|
|
|
460
501
|
// Update first name (should trigger selectFullName)
|
|
461
|
-
await store.set({ firstName:
|
|
502
|
+
await store.set({ firstName: "Jane" });
|
|
462
503
|
// Output: Full Name Changed: Jane Doe
|
|
463
504
|
|
|
464
|
-
await store.set({ lastName:
|
|
505
|
+
await store.set({ lastName: "Smith" }); // Should trigger selectFullName
|
|
465
506
|
// Output: Full Name Changed: Jane Smith
|
|
466
507
|
|
|
467
|
-
await store.set({ address: { city:
|
|
508
|
+
await store.set({ address: { city: "Los Angeles" } }); // Should trigger selectLocation
|
|
468
509
|
// Output: Location Changed: Los Angeles, 10001
|
|
469
510
|
|
|
470
511
|
await store.set({ isActive: false }); // Should trigger selectIsActive
|
|
471
512
|
// Output: Is Active Changed: false
|
|
472
513
|
|
|
473
|
-
await store.set({ email:
|
|
514
|
+
await store.set({ email: "jane.smith@example.com" }); // Should NOT trigger any of the above selectors directly
|
|
474
515
|
// No output from subscribed selectors
|
|
475
516
|
|
|
476
|
-
await store.set((state) => ({ friends: [...state.friends,
|
|
517
|
+
await store.set((state) => ({ friends: [...state.friends, "Charlie"] })); // Should trigger selectFriendCount
|
|
477
518
|
// Output: Friend Count Changed: 3
|
|
478
519
|
|
|
479
|
-
console.log(
|
|
480
|
-
console.log(
|
|
481
|
-
console.log(
|
|
482
|
-
console.log(
|
|
483
|
-
console.log(
|
|
520
|
+
console.log("\n--- Current Values ---");
|
|
521
|
+
console.log("Current Full Name:", selectFullName.get());
|
|
522
|
+
console.log("Current Location:", selectLocation.get());
|
|
523
|
+
console.log("Current Is Active:", selectIsActive.get());
|
|
524
|
+
console.log("Current Friend Count:", selectFriendCount.get());
|
|
484
525
|
|
|
485
526
|
// Cleanup
|
|
486
527
|
unsubscribeFullName();
|
|
@@ -494,8 +535,8 @@ unsubscribeFriendCount();
|
|
|
494
535
|
The `ReactiveDataStore` can integrate with any persistence layer that implements the `SimplePersistence<T>` interface. This allows you to load an initial state and automatically save subsequent changes. The store emits a `persistence:ready` event once the persistence layer has loaded any initial state. You can use `@asaidimu/utils-persistence` for concrete implementations or provide your own.
|
|
495
536
|
|
|
496
537
|
```typescript
|
|
497
|
-
import { ReactiveDataStore, type StoreEvent } from
|
|
498
|
-
import { v4 as uuidv4 } from
|
|
538
|
+
import { ReactiveDataStore, type StoreEvent } from "@asaidimu/utils-store";
|
|
539
|
+
import { v4 as uuidv4 } from "uuid"; // For generating unique instance IDs
|
|
499
540
|
|
|
500
541
|
// Define the SimplePersistence interface (from `@asaidimu/utils-persistence` or your own)
|
|
501
542
|
interface SimplePersistence<T extends object> {
|
|
@@ -514,17 +555,20 @@ class InMemoryPersistence<T extends object> implements SimplePersistence<T> {
|
|
|
514
555
|
private uniqueStoreId: string; // Acts as the storageKey/store identifier
|
|
515
556
|
|
|
516
557
|
constructor(uniqueStoreId: string, initialData: T | null = null) {
|
|
517
|
-
|
|
518
|
-
|
|
558
|
+
this.uniqueStoreId = uniqueStoreId;
|
|
559
|
+
this.data = initialData;
|
|
519
560
|
}
|
|
520
561
|
|
|
521
|
-
get(): T | null {
|
|
562
|
+
get(): T | null {
|
|
563
|
+
// Synchronous get for simplicity
|
|
522
564
|
console.log(`Persistence [${this.uniqueStoreId}]: Loading state...`);
|
|
523
565
|
return this.data ? structuredClone(this.data) : null;
|
|
524
566
|
}
|
|
525
567
|
|
|
526
568
|
async set(instanceId: string, state: T): Promise<boolean> {
|
|
527
|
-
console.log(
|
|
569
|
+
console.log(
|
|
570
|
+
`Persistence [${this.uniqueStoreId}]: Saving state for instance ${instanceId}...`,
|
|
571
|
+
);
|
|
528
572
|
this.data = structuredClone(state); // Store a clone
|
|
529
573
|
// Simulate external change notification for *other* instances
|
|
530
574
|
this.subscribers.forEach((callback, subId) => {
|
|
@@ -538,62 +582,71 @@ class InMemoryPersistence<T extends object> implements SimplePersistence<T> {
|
|
|
538
582
|
}
|
|
539
583
|
|
|
540
584
|
subscribe(instanceId: string, callback: (state: T) => void): () => void {
|
|
541
|
-
console.log(
|
|
585
|
+
console.log(
|
|
586
|
+
`Persistence [${this.uniqueStoreId}]: Subscribing to external changes for instance ${instanceId}`,
|
|
587
|
+
);
|
|
542
588
|
this.subscribers.set(instanceId, callback);
|
|
543
589
|
return () => {
|
|
544
|
-
console.log(
|
|
590
|
+
console.log(
|
|
591
|
+
`Persistence [${this.uniqueStoreId}]: Unsubscribing for instance ${instanceId}`,
|
|
592
|
+
);
|
|
545
593
|
this.subscribers.delete(instanceId);
|
|
546
594
|
};
|
|
547
595
|
}
|
|
548
596
|
}
|
|
549
597
|
|
|
550
598
|
interface UserConfig {
|
|
551
|
-
theme:
|
|
599
|
+
theme: "light" | "dark" | "system";
|
|
552
600
|
fontSize: number;
|
|
553
601
|
}
|
|
554
602
|
|
|
555
603
|
// Create a persistence instance, possibly with some pre-existing data
|
|
556
604
|
// The 'my-user-config' string acts as the unique identifier for this particular data set in persistence
|
|
557
|
-
const userConfigPersistence = new InMemoryPersistence<UserConfig>(
|
|
605
|
+
const userConfigPersistence = new InMemoryPersistence<UserConfig>(
|
|
606
|
+
"my-user-config",
|
|
607
|
+
{ theme: "dark", fontSize: 18 },
|
|
608
|
+
);
|
|
558
609
|
|
|
559
610
|
// Initialize the store with persistence
|
|
560
611
|
const store = new ReactiveDataStore<UserConfig>(
|
|
561
|
-
{ theme:
|
|
612
|
+
{ theme: "light", fontSize: 16 }, // Initial state if no persisted data found (or if persistence is not used)
|
|
562
613
|
userConfigPersistence, // Pass your persistence implementation here
|
|
563
614
|
// You can also pass persistence options like retries and delay
|
|
564
615
|
undefined, // Use default DELETE_SYMBOL
|
|
565
|
-
{ persistenceMaxRetries: 5, persistenceRetryDelay: 2000 }
|
|
616
|
+
{ persistenceMaxRetries: 5, persistenceRetryDelay: 2000 },
|
|
566
617
|
);
|
|
567
618
|
|
|
568
619
|
// Optionally, listen for persistence readiness (important for UIs that depend on loaded state)
|
|
569
|
-
const storeReadyPromise = new Promise<void>(resolve => {
|
|
570
|
-
store.on(
|
|
571
|
-
console.log(
|
|
620
|
+
const storeReadyPromise = new Promise<void>((resolve) => {
|
|
621
|
+
store.on("persistence:ready", (data) => {
|
|
622
|
+
console.log(
|
|
623
|
+
`Store is ready and persistence is initialized! Timestamp: ${new Date(data.timestamp).toLocaleTimeString()}`,
|
|
624
|
+
);
|
|
572
625
|
resolve();
|
|
573
626
|
});
|
|
574
627
|
});
|
|
575
628
|
|
|
576
|
-
console.log(
|
|
629
|
+
console.log("Store initial state (before persistence loads):", store.get());
|
|
577
630
|
// Output: Store initial state (before persistence loads): { theme: 'light', fontSize: 16 } (initial state provided to constructor)
|
|
578
631
|
|
|
579
632
|
await storeReadyPromise; // Wait for persistence to load/initialize
|
|
580
633
|
|
|
581
634
|
// Now, store.get() will reflect the loaded state from persistence
|
|
582
|
-
console.log(
|
|
635
|
+
console.log("Store state after persistence load:", store.get());
|
|
583
636
|
// Output: Store state after persistence load: { theme: 'dark', fontSize: 18 } (from InMemoryPersistence)
|
|
584
637
|
|
|
585
638
|
// Now update the state, which will trigger persistence.set()
|
|
586
|
-
await store.set({ theme:
|
|
587
|
-
console.log(
|
|
639
|
+
await store.set({ theme: "light" });
|
|
640
|
+
console.log("Current theme:", store.get().theme);
|
|
588
641
|
// Output: Current theme: light
|
|
589
642
|
// Persistence: Saving state for instance <uuid>...
|
|
590
643
|
|
|
591
644
|
// Simulate an external change (e.g., another tab or process updating the state)
|
|
592
645
|
// Note: The `instanceId` here should be different from the store's `store.id()`
|
|
593
646
|
// to simulate an external change and trigger the store's internal persistence subscription.
|
|
594
|
-
await userConfigPersistence.set(uuidv4(), { theme:
|
|
647
|
+
await userConfigPersistence.set(uuidv4(), { theme: "system", fontSize: 20 });
|
|
595
648
|
// The store will automatically update its state and notify its listeners due to the internal subscription.
|
|
596
|
-
console.log(
|
|
649
|
+
console.log("Current theme after external update:", store.get().theme);
|
|
597
650
|
// Output: Current theme after external update: system
|
|
598
651
|
```
|
|
599
652
|
|
|
@@ -606,7 +659,7 @@ Middleware functions allow you to intercept and modify state updates or prevent
|
|
|
606
659
|
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. If they don't return anything (`void`), the update proceeds as is.
|
|
607
660
|
|
|
608
661
|
```typescript
|
|
609
|
-
import { ReactiveDataStore, type DeepPartial } from
|
|
662
|
+
import { ReactiveDataStore, type DeepPartial } from "@asaidimu/utils-store";
|
|
610
663
|
|
|
611
664
|
interface MyState {
|
|
612
665
|
counter: number;
|
|
@@ -625,16 +678,16 @@ const store = new ReactiveDataStore<MyState>({
|
|
|
625
678
|
// Middleware 1: Logger
|
|
626
679
|
// Logs the incoming update before it's processed. Does not return anything (void), so it doesn't modify the update.
|
|
627
680
|
store.use({
|
|
628
|
-
name:
|
|
681
|
+
name: "LoggerMiddleware",
|
|
629
682
|
action: (state, update) => {
|
|
630
|
-
console.log(
|
|
683
|
+
console.log("Middleware: Incoming update:", update);
|
|
631
684
|
},
|
|
632
685
|
});
|
|
633
686
|
|
|
634
687
|
// Middleware 2: Timestamp and Action Tracker
|
|
635
688
|
// Modifies the update to add a timestamp and track the last action. Returns a partial state that gets merged.
|
|
636
689
|
store.use({
|
|
637
|
-
name:
|
|
690
|
+
name: "TimestampActionMiddleware",
|
|
638
691
|
action: (state, update) => {
|
|
639
692
|
const actionDescription = JSON.stringify(update);
|
|
640
693
|
return {
|
|
@@ -647,7 +700,7 @@ store.use({
|
|
|
647
700
|
// Middleware 3: Version Incrementor
|
|
648
701
|
// Increments a version counter for every successful update.
|
|
649
702
|
store.use({
|
|
650
|
-
name:
|
|
703
|
+
name: "VersionIncrementMiddleware",
|
|
651
704
|
action: (state) => {
|
|
652
705
|
return { version: state.version + 1 };
|
|
653
706
|
},
|
|
@@ -657,10 +710,10 @@ store.use({
|
|
|
657
710
|
// This middleware intercepts updates to 'counter' and increments it by the value provided,
|
|
658
711
|
// instead of setting it directly.
|
|
659
712
|
store.use({
|
|
660
|
-
name:
|
|
713
|
+
name: "CounterIncrementMiddleware",
|
|
661
714
|
action: (state, update) => {
|
|
662
715
|
// Only apply if the incoming update is a number for 'counter'
|
|
663
|
-
if (typeof update.counter ===
|
|
716
|
+
if (typeof update.counter === "number") {
|
|
664
717
|
return { counter: state.counter + update.counter };
|
|
665
718
|
}
|
|
666
719
|
// Return the original update or undefined if no transformation is needed for other paths
|
|
@@ -672,7 +725,7 @@ await store.set({ counter: 5 }); // Will increment counter by 5, not set to 5
|
|
|
672
725
|
/* Expected console output from LoggerMiddleware:
|
|
673
726
|
Middleware: Incoming update: { counter: 5 }
|
|
674
727
|
*/
|
|
675
|
-
console.log(
|
|
728
|
+
console.log("State after counter set:", store.get());
|
|
676
729
|
/* Output will show:
|
|
677
730
|
counter: 0 (initial) + 5 (update) = 5 (due to CounterIncrementMiddleware)
|
|
678
731
|
lastAction updated by TimestampActionMiddleware,
|
|
@@ -680,11 +733,11 @@ console.log('State after counter set:', store.get());
|
|
|
680
733
|
version: 1 (incremented by VersionIncrementMiddleware)
|
|
681
734
|
*/
|
|
682
735
|
|
|
683
|
-
await store.set({ lastAction:
|
|
736
|
+
await store.set({ lastAction: "Manual update from outside middleware" });
|
|
684
737
|
/* Expected console output from LoggerMiddleware:
|
|
685
738
|
Middleware: Incoming update: { lastAction: 'Manual update from outside middleware' }
|
|
686
739
|
*/
|
|
687
|
-
console.log(
|
|
740
|
+
console.log("State after manual action:", store.get());
|
|
688
741
|
/* Output will show:
|
|
689
742
|
lastAction will be overwritten by TimestampActionMiddleware logic,
|
|
690
743
|
a new log entry will be added,
|
|
@@ -692,11 +745,14 @@ console.log('State after manual action:', store.get());
|
|
|
692
745
|
*/
|
|
693
746
|
|
|
694
747
|
// Unuse a middleware by its ID
|
|
695
|
-
const temporaryLoggerId = store.use({
|
|
748
|
+
const temporaryLoggerId = store.use({
|
|
749
|
+
name: "TemporaryLogger",
|
|
750
|
+
action: (s, u) => console.log("Temporary logger saw:", u),
|
|
751
|
+
});
|
|
696
752
|
await store.set({ counter: 1 });
|
|
697
753
|
// Output: Temporary logger saw: { counter: 1 }
|
|
698
754
|
const removed = temporaryLoggerId(); // Remove the temporary logger
|
|
699
|
-
console.log(
|
|
755
|
+
console.log("Middleware removed:", removed);
|
|
700
756
|
await store.set({ counter: 1 }); // TemporaryLogger will not be called now
|
|
701
757
|
```
|
|
702
758
|
|
|
@@ -705,7 +761,7 @@ await store.set({ counter: 1 }); // TemporaryLogger will not be called now
|
|
|
705
761
|
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. When an update is blocked, the `update:complete` event will contain `blocked: true` and an `error` property.
|
|
706
762
|
|
|
707
763
|
```typescript
|
|
708
|
-
import { ReactiveDataStore, type DeepPartial } from
|
|
764
|
+
import { ReactiveDataStore, type DeepPartial } from "@asaidimu/utils-store";
|
|
709
765
|
|
|
710
766
|
interface UserProfile {
|
|
711
767
|
name: string;
|
|
@@ -715,7 +771,7 @@ interface UserProfile {
|
|
|
715
771
|
}
|
|
716
772
|
|
|
717
773
|
const store = new ReactiveDataStore<UserProfile>({
|
|
718
|
-
name:
|
|
774
|
+
name: "Guest",
|
|
719
775
|
age: 0,
|
|
720
776
|
isAdmin: false,
|
|
721
777
|
isVerified: false,
|
|
@@ -724,10 +780,14 @@ const store = new ReactiveDataStore<UserProfile>({
|
|
|
724
780
|
// Blocking middleware 1: Age validation
|
|
725
781
|
store.use({
|
|
726
782
|
block: true, // Mark as a blocking middleware
|
|
727
|
-
name:
|
|
783
|
+
name: "AgeValidationMiddleware",
|
|
728
784
|
action: (state, update) => {
|
|
729
|
-
if (
|
|
730
|
-
|
|
785
|
+
if (
|
|
786
|
+
update.age !== undefined &&
|
|
787
|
+
typeof update.age === "number" &&
|
|
788
|
+
update.age < 18
|
|
789
|
+
) {
|
|
790
|
+
console.warn("Blocking update: Age must be 18 or older.");
|
|
731
791
|
return false; // Block the update
|
|
732
792
|
}
|
|
733
793
|
return true; // Allow the update
|
|
@@ -737,17 +797,17 @@ store.use({
|
|
|
737
797
|
// Blocking middleware 2: Admin check
|
|
738
798
|
store.use({
|
|
739
799
|
block: true,
|
|
740
|
-
name:
|
|
800
|
+
name: "AdminRestrictionMiddleware",
|
|
741
801
|
action: (state, update) => {
|
|
742
802
|
// If attempting to become admin, check conditions
|
|
743
803
|
if (update.isAdmin === true) {
|
|
744
804
|
if (state.age < 21) {
|
|
745
|
-
console.warn(
|
|
805
|
+
console.warn("Blocking update: User must be 21+ to become admin.");
|
|
746
806
|
return false;
|
|
747
807
|
}
|
|
748
808
|
if (!state.isVerified) {
|
|
749
|
-
|
|
750
|
-
|
|
809
|
+
console.warn("Blocking update: User must be verified to become admin.");
|
|
810
|
+
return false;
|
|
751
811
|
}
|
|
752
812
|
}
|
|
753
813
|
return true; // Allow the update
|
|
@@ -756,34 +816,46 @@ store.use({
|
|
|
756
816
|
|
|
757
817
|
// Attempt to set a valid age
|
|
758
818
|
await store.set({ age: 25 });
|
|
759
|
-
console.log(
|
|
819
|
+
console.log("User age after valid update:", store.get().age); // Output: 25
|
|
760
820
|
|
|
761
821
|
// Attempt to set an invalid age (will be blocked)
|
|
762
822
|
await store.set({ age: 16 });
|
|
763
|
-
console.log(
|
|
823
|
+
console.log(
|
|
824
|
+
"User age after invalid update attempt (should be 25):",
|
|
825
|
+
store.get().age,
|
|
826
|
+
); // Output: 25
|
|
764
827
|
|
|
765
828
|
// Attempt to make user admin while not verified (will be blocked)
|
|
766
829
|
await store.set({ isAdmin: true });
|
|
767
|
-
console.log(
|
|
830
|
+
console.log(
|
|
831
|
+
"User admin status after failed attempt (should be false):",
|
|
832
|
+
store.get().isAdmin,
|
|
833
|
+
); // Output: false
|
|
768
834
|
|
|
769
835
|
// Verify user, then attempt to make admin again (will still be blocked due to age)
|
|
770
836
|
await store.set({ isVerified: true });
|
|
771
837
|
await store.set({ age: 20 });
|
|
772
838
|
await store.set({ isAdmin: true });
|
|
773
|
-
console.log(
|
|
839
|
+
console.log(
|
|
840
|
+
"User admin status after failed age attempt (should be false):",
|
|
841
|
+
store.get().isAdmin,
|
|
842
|
+
); // Output: false
|
|
774
843
|
|
|
775
844
|
// Now make user old enough and verified, then try again (should succeed)
|
|
776
845
|
await store.set({ age: 25 });
|
|
777
846
|
await store.set({ isAdmin: true });
|
|
778
|
-
console.log(
|
|
847
|
+
console.log(
|
|
848
|
+
"User admin status after successful attempt (should be true):",
|
|
849
|
+
store.get().isAdmin,
|
|
850
|
+
); // Output: true
|
|
779
851
|
```
|
|
780
852
|
|
|
781
853
|
### Transaction Support
|
|
782
854
|
|
|
783
|
-
Use `store.transaction()` to group multiple state updates into a single atomic operation. If an error occurs during the transaction (either thrown by your `operation` function or by an internal `store.set` call), all changes made within that transaction will be rolled back to the state
|
|
855
|
+
Use `store.transaction()` to group multiple state updates into a single atomic operation. If an error occurs during the transaction (either thrown by your `operation` function or by an internal `store.set` call), all changes made within that transaction will be rolled back to the state _before_ the transaction began. This guarantees data integrity for complex, multi-step operations.
|
|
784
856
|
|
|
785
857
|
```typescript
|
|
786
|
-
import { ReactiveDataStore } from
|
|
858
|
+
import { ReactiveDataStore } from "@asaidimu/utils-store";
|
|
787
859
|
|
|
788
860
|
interface BankAccount {
|
|
789
861
|
name: string;
|
|
@@ -792,8 +864,16 @@ interface BankAccount {
|
|
|
792
864
|
}
|
|
793
865
|
|
|
794
866
|
// Set up two bank accounts
|
|
795
|
-
const accountA = new ReactiveDataStore<BankAccount>({
|
|
796
|
-
|
|
867
|
+
const accountA = new ReactiveDataStore<BankAccount>({
|
|
868
|
+
name: "Account A",
|
|
869
|
+
balance: 500,
|
|
870
|
+
transactions: [],
|
|
871
|
+
});
|
|
872
|
+
const accountB = new ReactiveDataStore<BankAccount>({
|
|
873
|
+
name: "Account B",
|
|
874
|
+
balance: 200,
|
|
875
|
+
transactions: [],
|
|
876
|
+
});
|
|
797
877
|
|
|
798
878
|
// A function to transfer funds using transactions
|
|
799
879
|
async function transferFunds(
|
|
@@ -804,63 +884,75 @@ async function transferFunds(
|
|
|
804
884
|
// All operations inside this transaction will be atomic.
|
|
805
885
|
// If `operation()` throws an error, the state will revert.
|
|
806
886
|
await fromStore.transaction(async () => {
|
|
807
|
-
console.log(
|
|
887
|
+
console.log(
|
|
888
|
+
`Starting transfer of ${amount}. From: ${fromStore.get().balance}, To: ${toStore.get().balance}`,
|
|
889
|
+
);
|
|
808
890
|
|
|
809
891
|
// Deduct from sender
|
|
810
892
|
await fromStore.set((state) => {
|
|
811
893
|
if (state.balance < amount) {
|
|
812
894
|
// Throwing an error here will cause the entire transaction to roll back
|
|
813
|
-
throw new Error(
|
|
895
|
+
throw new Error("Insufficient funds");
|
|
814
896
|
}
|
|
815
897
|
return {
|
|
816
898
|
balance: state.balance - amount,
|
|
817
|
-
transactions: [
|
|
899
|
+
transactions: [
|
|
900
|
+
...state.transactions,
|
|
901
|
+
`Debited ${amount} from ${state.name}`,
|
|
902
|
+
],
|
|
818
903
|
};
|
|
819
904
|
});
|
|
820
905
|
|
|
821
906
|
// Simulate a network delay or another async operation that might fail
|
|
822
907
|
// If an error happens here, the state will still roll back.
|
|
823
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
908
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
824
909
|
|
|
825
910
|
// Add to receiver
|
|
826
911
|
await toStore.set((state) => ({
|
|
827
912
|
balance: state.balance + amount,
|
|
828
|
-
transactions: [
|
|
913
|
+
transactions: [
|
|
914
|
+
...state.transactions,
|
|
915
|
+
`Credited ${amount} to ${state.name}`,
|
|
916
|
+
],
|
|
829
917
|
}));
|
|
830
918
|
|
|
831
|
-
console.log(
|
|
919
|
+
console.log(
|
|
920
|
+
`Transfer in progress. From: ${fromStore.get().balance}, To: ${toStore.get().balance}`,
|
|
921
|
+
);
|
|
832
922
|
});
|
|
833
|
-
console.log(
|
|
923
|
+
console.log(
|
|
924
|
+
`Transfer successful. From: ${fromStore.get().balance}, To: ${toStore.get().balance}`,
|
|
925
|
+
);
|
|
834
926
|
}
|
|
835
927
|
|
|
836
|
-
console.log(
|
|
837
|
-
console.log(
|
|
838
|
-
console.log(
|
|
928
|
+
console.log("--- Initial Balances ---");
|
|
929
|
+
console.log("Account A:", accountA.get().balance); // Expected: 500
|
|
930
|
+
console.log("Account B:", accountB.get().balance); // Expected: 200
|
|
839
931
|
|
|
840
932
|
// --- Scenario 1: Successful transfer ---
|
|
841
|
-
console.log(
|
|
933
|
+
console.log("\n--- Attempting successful transfer (100) ---");
|
|
842
934
|
try {
|
|
843
935
|
await transferFunds(accountA, accountB, 100);
|
|
844
|
-
console.log(
|
|
845
|
-
console.log(
|
|
846
|
-
console.log(
|
|
936
|
+
console.log("\nTransfer 1 successful:");
|
|
937
|
+
console.log("Account A:", accountA.get()); // Expected: balance 400, transactions: ['Debited 100 from Account A']
|
|
938
|
+
console.log("Account B:", accountB.get()); // Expected: balance 300, transactions: ['Credited 100 to Account B']
|
|
847
939
|
} catch (error: any) {
|
|
848
|
-
console.error(
|
|
940
|
+
console.error("Transfer 1 failed unexpectedly:", error.message);
|
|
849
941
|
}
|
|
850
942
|
|
|
851
943
|
// --- Scenario 2: Failed transfer (insufficient funds) ---
|
|
852
|
-
console.log(
|
|
944
|
+
console.log("\n--- Attempting failed transfer (1000) ---");
|
|
853
945
|
try {
|
|
854
946
|
// Account A now has 400, so this should fail
|
|
855
947
|
await transferFunds(accountA, accountB, 1000);
|
|
856
948
|
} catch (error: any) {
|
|
857
|
-
console.error(
|
|
949
|
+
console.error("Transfer 2 failed as expected:", error.message);
|
|
858
950
|
} finally {
|
|
859
|
-
console.log(
|
|
951
|
+
console.log("Transfer 2 attempt, state after rollback:");
|
|
860
952
|
// State should be rolled back to its state *before* the transaction attempt
|
|
861
|
-
console.log(
|
|
953
|
+
console.log("Account A:", accountA.get());
|
|
862
954
|
// Expected: balance 400 (rolled back to state before this transaction)
|
|
863
|
-
console.log(
|
|
955
|
+
console.log("Account B:", accountB.get());
|
|
864
956
|
// Expected: balance 300 (rolled back to state before this transaction)
|
|
865
957
|
}
|
|
866
958
|
```
|
|
@@ -870,17 +962,21 @@ try {
|
|
|
870
962
|
The Artifact system provides a powerful way to manage external dependencies, services, or complex objects that your actions or other artifacts might need. It supports `Singleton` (created once, reactive to dependencies) and `Transient` (new instance every time) scopes, and allows artifacts to depend on state changes and other artifacts.
|
|
871
963
|
|
|
872
964
|
```typescript
|
|
873
|
-
import {
|
|
965
|
+
import {
|
|
966
|
+
ReactiveDataStore,
|
|
967
|
+
ArtifactContainer,
|
|
968
|
+
ArtifactScope,
|
|
969
|
+
} from "@asaidimu/utils-store";
|
|
874
970
|
|
|
875
971
|
interface AppState {
|
|
876
|
-
|
|
877
|
-
|
|
972
|
+
user: { id: string; name: string };
|
|
973
|
+
config: { apiUrl: string; logLevel: string };
|
|
878
974
|
}
|
|
879
975
|
|
|
880
976
|
// Mock DataStore interface for standalone ArtifactContainer usage
|
|
881
977
|
const store = new ReactiveDataStore<AppState>({
|
|
882
|
-
|
|
883
|
-
|
|
978
|
+
user: { id: "user-1", name: "Alice" },
|
|
979
|
+
config: { apiUrl: "/api/v1", logLevel: "info" },
|
|
884
980
|
});
|
|
885
981
|
|
|
886
982
|
const container = new ArtifactContainer(store);
|
|
@@ -889,113 +985,137 @@ const container = new ArtifactContainer(store);
|
|
|
889
985
|
|
|
890
986
|
// Artifact 1: Simple Logger (Transient) - new instance every time
|
|
891
987
|
container.register({
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
988
|
+
key: "logger",
|
|
989
|
+
scope: ArtifactScope.Transient,
|
|
990
|
+
factory: () => {
|
|
991
|
+
console.log("Logger artifact created (Transient)");
|
|
992
|
+
return {
|
|
993
|
+
log: (message: string) => console.log(`[LOG] ${message}`),
|
|
994
|
+
};
|
|
995
|
+
},
|
|
900
996
|
});
|
|
901
997
|
|
|
902
998
|
// Artifact 2: API Client (Singleton) - depends on state.config.apiUrl
|
|
903
|
-
const apiClientCleanup = () => console.log(
|
|
999
|
+
const apiClientCleanup = () => console.log("API Client connection closed.");
|
|
904
1000
|
container.register({
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
return {
|
|
915
|
-
fetchUser: (id: string) => `Fetching ${id} from ${apiUrl}`,
|
|
916
|
-
sendData: (data: any) => `Sending ${JSON.stringify(data)} to ${apiUrl}`
|
|
917
|
-
};
|
|
1001
|
+
key: "apiClient",
|
|
1002
|
+
scope: ArtifactScope.Singleton,
|
|
1003
|
+
factory: async ({ use, onCleanup, current }) => {
|
|
1004
|
+
const apiUrl = await use(({ select }) =>
|
|
1005
|
+
select((state: AppState) => state.config.apiUrl),
|
|
1006
|
+
);
|
|
1007
|
+
console.log(`API Client created/re-created for URL: ${apiUrl}`);
|
|
1008
|
+
if (current) {
|
|
1009
|
+
console.log("Re-creating API client. Old instance:", current);
|
|
918
1010
|
}
|
|
1011
|
+
onCleanup(apiClientCleanup); // Register cleanup for this instance
|
|
1012
|
+
return {
|
|
1013
|
+
fetchUser: (id: string) => `Fetching ${id} from ${apiUrl}`,
|
|
1014
|
+
sendData: (data: any) => `Sending ${JSON.stringify(data)} to ${apiUrl}`,
|
|
1015
|
+
};
|
|
1016
|
+
},
|
|
919
1017
|
});
|
|
920
1018
|
|
|
921
1019
|
// Artifact 3: User Service (Singleton) - depends on 'apiClient' and state.user.name
|
|
922
|
-
const userServiceCleanup = () =>
|
|
1020
|
+
const userServiceCleanup = () =>
|
|
1021
|
+
console.log("User Service resources released.");
|
|
923
1022
|
container.register({
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
return {
|
|
935
|
-
getUserProfile: () => apiClient.instance!.fetchUser(store.get().user.id),
|
|
936
|
-
updateUserName: (newName: string) => `Updating user name to ${newName} via API. Current: ${userName}`
|
|
937
|
-
};
|
|
1023
|
+
key: "userService",
|
|
1024
|
+
scope: ArtifactScope.Singleton,
|
|
1025
|
+
factory: async ({ use, onCleanup, current }) => {
|
|
1026
|
+
const apiClient = await use(({ resolve }) => resolve("apiClient"));
|
|
1027
|
+
const userName = await use(({ select }) =>
|
|
1028
|
+
select((state: AppState) => state.user.name),
|
|
1029
|
+
);
|
|
1030
|
+
console.log(`User Service created/re-created for user: ${userName}`);
|
|
1031
|
+
if (current) {
|
|
1032
|
+
console.log("Re-creating User Service. Old instance:", current);
|
|
938
1033
|
}
|
|
1034
|
+
onCleanup(userServiceCleanup); // Register cleanup for this instance
|
|
1035
|
+
return {
|
|
1036
|
+
getUserProfile: () => apiClient.instance!.fetchUser(store.get().user.id),
|
|
1037
|
+
updateUserName: (newName: string) =>
|
|
1038
|
+
`Updating user name to ${newName} via API. Current: ${userName}`,
|
|
1039
|
+
};
|
|
1040
|
+
},
|
|
939
1041
|
});
|
|
940
1042
|
|
|
941
1043
|
// --- Usage ---
|
|
942
1044
|
|
|
943
1045
|
async function runDemo() {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1046
|
+
console.log("\n--- Initial Artifact Resolution ---");
|
|
1047
|
+
const logger1 = await container.resolve("logger");
|
|
1048
|
+
logger1.instance!.log("Application started.");
|
|
1049
|
+
|
|
1050
|
+
const apiClient1 = await container.resolve("apiClient");
|
|
1051
|
+
console.log(apiClient1.instance!.fetchUser("123"));
|
|
1052
|
+
|
|
1053
|
+
const userService1 = await container.resolve("userService");
|
|
1054
|
+
console.log(userService1.instance!.getUserProfile());
|
|
1055
|
+
|
|
1056
|
+
// Transient artifact resolves a new instance
|
|
1057
|
+
const logger2 = await container.resolve("logger");
|
|
1058
|
+
console.log(
|
|
1059
|
+
"Logger instances are different:",
|
|
1060
|
+
logger1.instance !== logger2.instance,
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
// Singleton artifacts resolve the same instance initially
|
|
1064
|
+
const apiClient2 = await container.resolve("apiClient");
|
|
1065
|
+
console.log(
|
|
1066
|
+
"API Client instances are same:",
|
|
1067
|
+
apiClient1.instance === apiClient2.instance,
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
console.log("\n--- Simulate State Change (config.apiUrl) ---");
|
|
1071
|
+
await store.set({ config: { apiUrl: "/api/v2" } });
|
|
1072
|
+
// This state change invalidates 'apiClient' which depends on 'config.apiUrl'
|
|
1073
|
+
// and then invalidates 'userService' which depends on 'apiClient'.
|
|
1074
|
+
|
|
1075
|
+
// After config.apiUrl changes, apiClient (and userService) should be re-created
|
|
1076
|
+
const apiClient3 = await container.resolve("apiClient"); // This will trigger re-creation
|
|
1077
|
+
console.log(apiClient3.instance!.fetchUser("123"));
|
|
1078
|
+
console.log(
|
|
1079
|
+
"API Client instances are different:",
|
|
1080
|
+
apiClient3.instance !== apiClient1.instance,
|
|
1081
|
+
); // Should be a new instance
|
|
1082
|
+
|
|
1083
|
+
// userService should also be re-created because apiClient, its dependency, was re-created
|
|
1084
|
+
const userService2 = await container.resolve("userService");
|
|
1085
|
+
console.log(userService2.instance!.getUserProfile());
|
|
1086
|
+
console.log(
|
|
1087
|
+
"User Service instances are different:",
|
|
1088
|
+
userService2.instance !== userService1.instance,
|
|
1089
|
+
); // Should be a new instance
|
|
1090
|
+
|
|
1091
|
+
console.log("\n--- Simulate State Change (user.name) ---");
|
|
1092
|
+
await store.set({ user: { name: "Bob" } });
|
|
1093
|
+
|
|
1094
|
+
// Only userService (which depends on user.name) should be re-created this time, not apiClient
|
|
1095
|
+
const apiClient4 = await container.resolve("apiClient");
|
|
1096
|
+
console.log(
|
|
1097
|
+
"API Client instances are same:",
|
|
1098
|
+
apiClient4.instance === apiClient3.instance,
|
|
1099
|
+
); // Still the same API client instance
|
|
1100
|
+
|
|
1101
|
+
const userService3 = await container.resolve("userService");
|
|
1102
|
+
console.log(userService3.instance!.getUserProfile());
|
|
1103
|
+
console.log(
|
|
1104
|
+
"User Service instances are different:",
|
|
1105
|
+
userService3.instance !== userService2.instance,
|
|
1106
|
+
); // New user service instance
|
|
1107
|
+
|
|
1108
|
+
console.log("\n--- Unregistering Artifacts ---");
|
|
1109
|
+
await container.unregister("userService");
|
|
1110
|
+
// Output: User Service resources released. (cleanup called)
|
|
1111
|
+
await container.unregister("apiClient");
|
|
1112
|
+
// Output: API Client connection closed. (cleanup called)
|
|
1113
|
+
|
|
1114
|
+
try {
|
|
1115
|
+
await container.resolve("userService");
|
|
1116
|
+
} catch (e: any) {
|
|
1117
|
+
console.error("Expected error:", e.message); // Artifact not found
|
|
1118
|
+
}
|
|
999
1119
|
}
|
|
1000
1120
|
|
|
1001
1121
|
runDemo();
|
|
@@ -1006,50 +1126,58 @@ runDemo();
|
|
|
1006
1126
|
The `StoreObserver` class provides advanced debugging and monitoring capabilities for any `ReactiveDataStore` instance. It allows you to inspect event history, state changes, and even time-travel through your application's state. It's an invaluable tool for understanding complex state flows.
|
|
1007
1127
|
|
|
1008
1128
|
```typescript
|
|
1009
|
-
import {
|
|
1129
|
+
import {
|
|
1130
|
+
ReactiveDataStore,
|
|
1131
|
+
StoreObserver,
|
|
1132
|
+
type StoreEvent,
|
|
1133
|
+
type DeepPartial,
|
|
1134
|
+
} from "@asaidimu/utils-store";
|
|
1010
1135
|
|
|
1011
1136
|
interface DebuggableState {
|
|
1012
|
-
user: { name: string; status:
|
|
1137
|
+
user: { name: string; status: "online" | "offline" };
|
|
1013
1138
|
messages: string[];
|
|
1014
1139
|
settings: { debugMode: boolean; logLevel: string };
|
|
1015
1140
|
metrics: { updates: number };
|
|
1016
1141
|
}
|
|
1017
1142
|
|
|
1018
1143
|
const store = new ReactiveDataStore<DebuggableState>({
|
|
1019
|
-
user: { name:
|
|
1144
|
+
user: { name: "Debugger", status: "online" },
|
|
1020
1145
|
messages: [],
|
|
1021
|
-
settings: { debugMode: true, logLevel:
|
|
1146
|
+
settings: { debugMode: true, logLevel: "info" },
|
|
1022
1147
|
metrics: { updates: 0 },
|
|
1023
1148
|
});
|
|
1024
1149
|
|
|
1025
1150
|
// Initialize observability for the store
|
|
1026
1151
|
// Options allow granular control over what is tracked and logged.
|
|
1027
1152
|
const observer = new StoreObserver(store, {
|
|
1028
|
-
maxEvents: 50,
|
|
1029
|
-
maxStateHistory: 5,
|
|
1153
|
+
maxEvents: 50, // Keep up to 50 internal store events in history
|
|
1154
|
+
maxStateHistory: 5, // Keep up to 5 state snapshots for time-travel
|
|
1030
1155
|
enableConsoleLogging: true, // Log events to browser console for immediate feedback
|
|
1031
1156
|
logEvents: {
|
|
1032
|
-
updates: true,
|
|
1033
|
-
middleware: true,
|
|
1034
|
-
transactions: true,
|
|
1035
|
-
actions: true,
|
|
1036
|
-
selectors: true,
|
|
1157
|
+
updates: true, // Log all update lifecycle events (start/complete)
|
|
1158
|
+
middleware: true, // Log middleware start/complete/error/blocked/executed events
|
|
1159
|
+
transactions: true, // Log transaction start/complete/error events
|
|
1160
|
+
actions: true, // Log action start/complete/error events
|
|
1161
|
+
selectors: true, // Log selector accessed/changed events
|
|
1037
1162
|
},
|
|
1038
1163
|
performanceThresholds: {
|
|
1039
|
-
updateTime: 50,
|
|
1040
|
-
middlewareTime: 20,
|
|
1164
|
+
updateTime: 50, // Warn in console if an update takes > 50ms
|
|
1165
|
+
middlewareTime: 20, // Warn if a middleware takes > 20ms
|
|
1041
1166
|
},
|
|
1042
1167
|
});
|
|
1043
1168
|
|
|
1044
1169
|
// Add a simple middleware to demonstrate middleware logging and metrics update
|
|
1045
|
-
store.use({
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1170
|
+
store.use({
|
|
1171
|
+
name: "UpdateMetricsMiddleware",
|
|
1172
|
+
action: async (state, update) => {
|
|
1173
|
+
await new Promise((resolve) => setTimeout(resolve, 10)); // Simulate work
|
|
1174
|
+
return { metrics: { updates: state.metrics.updates + 1 } };
|
|
1175
|
+
},
|
|
1176
|
+
});
|
|
1049
1177
|
|
|
1050
1178
|
// Perform some state updates
|
|
1051
|
-
await store.set({ user: { status:
|
|
1052
|
-
await store.set({ messages: [
|
|
1179
|
+
await store.set({ user: { status: "offline" } });
|
|
1180
|
+
await store.set({ messages: ["Hello World!"] });
|
|
1053
1181
|
await store.set({ settings: { debugMode: false } });
|
|
1054
1182
|
|
|
1055
1183
|
// Simulate a slow update to trigger performance warning
|
|
@@ -1058,92 +1186,122 @@ await store.set({ settings: { debugMode: false } });
|
|
|
1058
1186
|
// This last set will cause a console warning for "Slow update detected" if enableConsoleLogging is true.
|
|
1059
1187
|
|
|
1060
1188
|
// 1. Get Event History
|
|
1061
|
-
console.log(
|
|
1189
|
+
console.log("\n--- Event History (Most Recent First) ---");
|
|
1062
1190
|
const events = observer.getEventHistory();
|
|
1063
1191
|
// Events will include: update:start, update:complete (multiple times), middleware:start, middleware:complete, etc.
|
|
1064
|
-
events
|
|
1192
|
+
events
|
|
1193
|
+
.slice(0, 5)
|
|
1194
|
+
.forEach((event) =>
|
|
1195
|
+
console.log(
|
|
1196
|
+
`Type: ${event.type}, Data: ${JSON.stringify(event.data).substring(0, 70)}...`,
|
|
1197
|
+
),
|
|
1198
|
+
);
|
|
1065
1199
|
|
|
1066
1200
|
// 2. Get State History
|
|
1067
|
-
console.log(
|
|
1201
|
+
console.log("\n--- State History (Most Recent First) ---");
|
|
1068
1202
|
const stateSnapshots = observer.getStateHistory();
|
|
1069
|
-
stateSnapshots.forEach((snapshot, index) =>
|
|
1203
|
+
stateSnapshots.forEach((snapshot, index) =>
|
|
1204
|
+
console.log(
|
|
1205
|
+
`State #${index}: Messages: ${snapshot.state.messages.join(", ")}, User Status: ${snapshot.state.user.status}`,
|
|
1206
|
+
),
|
|
1207
|
+
);
|
|
1070
1208
|
|
|
1071
1209
|
// 3. Get Recent Changes (Diffs)
|
|
1072
|
-
console.log(
|
|
1210
|
+
console.log("\n--- Recent State Changes (Diffs) ---");
|
|
1073
1211
|
const recentChanges = observer.getRecentChanges(3); // Show diffs for last 3 changes
|
|
1074
1212
|
recentChanges.forEach((change, index) => {
|
|
1075
1213
|
console.log(`\nChange #${index}:`);
|
|
1076
|
-
console.log(
|
|
1077
|
-
|
|
1214
|
+
console.log(
|
|
1215
|
+
` Timestamp: ${new Date(change.timestamp).toLocaleTimeString()}`,
|
|
1216
|
+
);
|
|
1217
|
+
console.log(` Changed Paths: ${change.changedPaths.join(", ")}`);
|
|
1078
1218
|
console.log(` From (partial):`, change.from); // Only changed parts of the state
|
|
1079
|
-
console.log(` To (partial):`, change.to);
|
|
1219
|
+
console.log(` To (partial):`, change.to); // Only changed parts of the state
|
|
1080
1220
|
});
|
|
1081
1221
|
|
|
1082
1222
|
// 4. Time-Travel Debugging
|
|
1083
|
-
console.log(
|
|
1223
|
+
console.log("\n--- Time-Travel ---");
|
|
1084
1224
|
const timeTravel = observer.createTimeTravel();
|
|
1085
1225
|
|
|
1086
1226
|
// Add more states to the history for time-travel demonstration
|
|
1087
|
-
await store.set({ user: { status:
|
|
1088
|
-
await store.set({ messages: [
|
|
1089
|
-
await store.set({ messages: [
|
|
1227
|
+
await store.set({ user: { status: "online" } }); // Current State (A)
|
|
1228
|
+
await store.set({ messages: ["First message"] }); // Previous State (B)
|
|
1229
|
+
await store.set({ messages: ["Second message"] }); // Previous State (C)
|
|
1090
1230
|
|
|
1091
|
-
console.log(
|
|
1231
|
+
console.log("Current state (latest):", store.get().messages); // Output: ['Second message']
|
|
1092
1232
|
|
|
1093
1233
|
if (timeTravel.canUndo()) {
|
|
1094
1234
|
await timeTravel.undo(); // Go back to State B
|
|
1095
|
-
console.log(
|
|
1235
|
+
console.log("After undo 1:", store.get().messages); // Output: ['First message']
|
|
1096
1236
|
}
|
|
1097
1237
|
|
|
1098
1238
|
if (timeTravel.canUndo()) {
|
|
1099
1239
|
await timeTravel.undo(); // Go back to State A
|
|
1100
|
-
console.log(
|
|
1240
|
+
console.log("After undo 2:", store.get().messages); // Output: [] (the state before 'First message' was added)
|
|
1101
1241
|
}
|
|
1102
1242
|
|
|
1103
1243
|
if (timeTravel.canRedo()) {
|
|
1104
1244
|
await timeTravel.redo(); // Go forward to State B
|
|
1105
|
-
console.log(
|
|
1245
|
+
console.log("After redo 1:", store.get().messages); // Output: ['First message']
|
|
1106
1246
|
}
|
|
1107
1247
|
|
|
1108
|
-
console.log(
|
|
1248
|
+
console.log("Time-Travel history length:", timeTravel.length()); // Reflects `maxStateHistory` + initial state
|
|
1109
1249
|
|
|
1110
1250
|
// 5. Custom Debugging Middleware (provided by StoreObserver for convenience)
|
|
1111
1251
|
// Example: A logging middleware that logs every update
|
|
1112
1252
|
const loggingMiddleware = observer.createLoggingMiddleware({
|
|
1113
|
-
logLevel:
|
|
1253
|
+
logLevel: "info", // Can be 'debug', 'info', 'warn'
|
|
1114
1254
|
logUpdates: true, // Whether to log the update payload itself
|
|
1115
1255
|
});
|
|
1116
|
-
const loggingMiddlewareId = store.use({
|
|
1256
|
+
const loggingMiddlewareId = store.use({
|
|
1257
|
+
name: "DebugLogging",
|
|
1258
|
+
action: loggingMiddleware,
|
|
1259
|
+
});
|
|
1117
1260
|
|
|
1118
|
-
await store.set({ user: { name:
|
|
1261
|
+
await store.set({ user: { name: "New User Via Debug Logger" } }); // This update will be logged by the created middleware.
|
|
1119
1262
|
// Expected console output: "State Update: { user: { name: 'New User Via Debug Logger' } }"
|
|
1120
1263
|
|
|
1121
1264
|
// Example: A validation middleware (blocking)
|
|
1122
|
-
const validationMiddleware = observer.createValidationMiddleware(
|
|
1265
|
+
const validationMiddleware = observer.createValidationMiddleware(
|
|
1266
|
+
(state, update) => {
|
|
1123
1267
|
if (update.messages && update.messages.length > 5) {
|
|
1124
|
-
|
|
1268
|
+
return { valid: false, reason: "Too many messages!" };
|
|
1125
1269
|
}
|
|
1126
1270
|
return true;
|
|
1271
|
+
},
|
|
1272
|
+
);
|
|
1273
|
+
const validationMiddlewareId = store.use({
|
|
1274
|
+
name: "DebugValidation",
|
|
1275
|
+
block: true,
|
|
1276
|
+
action: validationMiddleware,
|
|
1127
1277
|
});
|
|
1128
|
-
const validationMiddlewareId = store.use({ name: 'DebugValidation', block: true, action: validationMiddleware });
|
|
1129
1278
|
|
|
1130
1279
|
try {
|
|
1131
|
-
|
|
1280
|
+
await store.set({ messages: ["m1", "m2", "m3", "m4", "m5", "m6"] }); // This will be blocked
|
|
1132
1281
|
} catch (e: any) {
|
|
1133
|
-
|
|
1282
|
+
console.warn(
|
|
1283
|
+
`Caught expected error from validation middleware: ${e.message}`,
|
|
1284
|
+
);
|
|
1134
1285
|
}
|
|
1135
|
-
console.log(
|
|
1286
|
+
console.log("Current messages after failed validation:", store.get().messages); // Should be the state before this set.
|
|
1136
1287
|
|
|
1137
1288
|
// 6. Clear history
|
|
1138
1289
|
observer.clearHistory();
|
|
1139
|
-
console.log(
|
|
1290
|
+
console.log(
|
|
1291
|
+
"\nHistory cleared. Events:",
|
|
1292
|
+
observer.getEventHistory().length,
|
|
1293
|
+
"State snapshots:",
|
|
1294
|
+
observer.getStateHistory().length,
|
|
1295
|
+
);
|
|
1140
1296
|
// Output: History cleared. Events: 0 State snapshots: 1 (keeps current state)
|
|
1141
1297
|
|
|
1142
1298
|
// 7. Disconnect observer when no longer needed to prevent memory leaks
|
|
1143
1299
|
observer.disconnect();
|
|
1144
|
-
console.log(
|
|
1300
|
+
console.log(
|
|
1301
|
+
"\nObserver disconnected. No more events or state changes will be tracked.",
|
|
1302
|
+
);
|
|
1145
1303
|
// After disconnect, new updates won't be logged or tracked by Observer
|
|
1146
|
-
await store.set({ messages: [
|
|
1304
|
+
await store.set({ messages: ["Final message after disconnect"] });
|
|
1147
1305
|
```
|
|
1148
1306
|
|
|
1149
1307
|
### Event System
|
|
@@ -1151,108 +1309,155 @@ await store.set({ messages: ['Final message after disconnect'] });
|
|
|
1151
1309
|
The store emits various events during its lifecycle, allowing for advanced monitoring, logging, and integration with external systems. You can subscribe to these events using `store.on(eventName, listener)`. The `StoreObserver` leverages this event system internally to provide its rich debugging capabilities.
|
|
1152
1310
|
|
|
1153
1311
|
```typescript
|
|
1154
|
-
import { ReactiveDataStore, type StoreEvent } from
|
|
1312
|
+
import { ReactiveDataStore, type StoreEvent } from "@asaidimu/utils-store";
|
|
1155
1313
|
|
|
1156
1314
|
interface MyState {
|
|
1157
1315
|
value: number;
|
|
1158
1316
|
status: string;
|
|
1159
1317
|
}
|
|
1160
1318
|
|
|
1161
|
-
const store = new ReactiveDataStore<MyState>({ value: 0, status:
|
|
1319
|
+
const store = new ReactiveDataStore<MyState>({ value: 0, status: "idle" });
|
|
1162
1320
|
|
|
1163
1321
|
// Subscribe to 'update:start' event - triggered before an update begins processing.
|
|
1164
|
-
store.on(
|
|
1165
|
-
console.log(
|
|
1322
|
+
store.on("update:start", (data) => {
|
|
1323
|
+
console.log(
|
|
1324
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ⚡ Update started. Action ID: ${data.actionId || "N/A"}`,
|
|
1325
|
+
);
|
|
1166
1326
|
});
|
|
1167
1327
|
|
|
1168
1328
|
// Subscribe to 'update:complete' event - triggered after an update is fully applied or blocked.
|
|
1169
|
-
store.on(
|
|
1329
|
+
store.on("update:complete", (data) => {
|
|
1170
1330
|
if (data.blocked) {
|
|
1171
|
-
console.warn(
|
|
1331
|
+
console.warn(
|
|
1332
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ✋ Update blocked. Error:`,
|
|
1333
|
+
data.error?.message,
|
|
1334
|
+
);
|
|
1172
1335
|
} else {
|
|
1173
|
-
console.log(
|
|
1336
|
+
console.log(
|
|
1337
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ✅ Update complete. Changed paths: ${data.deltas?.map((d: any) => d.path).join(", ")} (took ${data.duration?.toFixed(2)}ms)`,
|
|
1338
|
+
);
|
|
1174
1339
|
}
|
|
1175
1340
|
});
|
|
1176
1341
|
|
|
1177
1342
|
// Subscribe to middleware lifecycle events
|
|
1178
|
-
store.on(
|
|
1179
|
-
console.log(
|
|
1343
|
+
store.on("middleware:start", (data) => {
|
|
1344
|
+
console.log(
|
|
1345
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ▶ Middleware "${data.name}" (${data.type || "transform"}) started.`,
|
|
1346
|
+
);
|
|
1180
1347
|
});
|
|
1181
1348
|
|
|
1182
|
-
store.on(
|
|
1183
|
-
console.log(
|
|
1349
|
+
store.on("middleware:complete", (data) => {
|
|
1350
|
+
console.log(
|
|
1351
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ◀ Middleware "${data.name}" (${data.type || "transform"}) completed in ${data.duration?.toFixed(2)}ms.`,
|
|
1352
|
+
);
|
|
1184
1353
|
});
|
|
1185
1354
|
|
|
1186
|
-
store.on(
|
|
1187
|
-
console.error(
|
|
1355
|
+
store.on("middleware:error", (data) => {
|
|
1356
|
+
console.error(
|
|
1357
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ❌ Middleware "${data.name}" failed:`,
|
|
1358
|
+
data.error,
|
|
1359
|
+
);
|
|
1188
1360
|
});
|
|
1189
1361
|
|
|
1190
|
-
store.on(
|
|
1191
|
-
console.warn(
|
|
1362
|
+
store.on("middleware:blocked", (data) => {
|
|
1363
|
+
console.warn(
|
|
1364
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 🛑 Middleware "${data.name}" blocked an update.`,
|
|
1365
|
+
);
|
|
1192
1366
|
});
|
|
1193
1367
|
|
|
1194
|
-
store.on(
|
|
1368
|
+
store.on("middleware:executed", (data) => {
|
|
1195
1369
|
// This event captures detailed execution info for all middlewares, useful for aggregate metrics.
|
|
1196
|
-
console.debug(
|
|
1370
|
+
console.debug(
|
|
1371
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 📊 Middleware executed: "${data.name}" - Duration: ${data.duration?.toFixed(2)}ms, Blocked: ${data.blocked}`,
|
|
1372
|
+
);
|
|
1197
1373
|
});
|
|
1198
1374
|
|
|
1199
1375
|
// Subscribe to transaction lifecycle events
|
|
1200
|
-
store.on(
|
|
1201
|
-
console.log(
|
|
1376
|
+
store.on("transaction:start", (data) => {
|
|
1377
|
+
console.log(
|
|
1378
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 📦 Transaction started.`,
|
|
1379
|
+
);
|
|
1202
1380
|
});
|
|
1203
1381
|
|
|
1204
|
-
store.on(
|
|
1205
|
-
console.log(
|
|
1382
|
+
store.on("transaction:complete", (data) => {
|
|
1383
|
+
console.log(
|
|
1384
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 📦 Transaction complete.`,
|
|
1385
|
+
);
|
|
1206
1386
|
});
|
|
1207
1387
|
|
|
1208
|
-
store.on(
|
|
1209
|
-
console.error(
|
|
1388
|
+
store.on("transaction:error", (data) => {
|
|
1389
|
+
console.error(
|
|
1390
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 📦 Transaction failed:`,
|
|
1391
|
+
data.error,
|
|
1392
|
+
);
|
|
1210
1393
|
});
|
|
1211
1394
|
|
|
1212
1395
|
// Subscribe to persistence events
|
|
1213
|
-
store.on(
|
|
1214
|
-
console.log(
|
|
1396
|
+
store.on("persistence:ready", (data) => {
|
|
1397
|
+
console.log(
|
|
1398
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 💾 Persistence layer is ready.`,
|
|
1399
|
+
);
|
|
1215
1400
|
});
|
|
1216
|
-
store.on(
|
|
1217
|
-
|
|
1401
|
+
store.on("persistence:queued", (data) => {
|
|
1402
|
+
console.log(
|
|
1403
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ⏳ Persistence task queued: ${data.taskId}, queue size: ${data.queueSize}`,
|
|
1404
|
+
);
|
|
1218
1405
|
});
|
|
1219
|
-
store.on(
|
|
1220
|
-
|
|
1406
|
+
store.on("persistence:success", (data) => {
|
|
1407
|
+
console.log(
|
|
1408
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ✅ Persistence success for task: ${data.taskId}, took ${data.duration?.toFixed(2)}ms`,
|
|
1409
|
+
);
|
|
1221
1410
|
});
|
|
1222
|
-
store.on(
|
|
1223
|
-
|
|
1411
|
+
store.on("persistence:retry", (data) => {
|
|
1412
|
+
console.warn(
|
|
1413
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 🔄 Persistence retry for task: ${data.taskId}, attempt ${data.attempt}/${data.maxRetries}`,
|
|
1414
|
+
);
|
|
1224
1415
|
});
|
|
1225
|
-
store.on(
|
|
1226
|
-
|
|
1416
|
+
store.on("persistence:failed", (data) => {
|
|
1417
|
+
console.error(
|
|
1418
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ❌ Persistence failed permanently for task: ${data.taskId}`,
|
|
1419
|
+
data.error,
|
|
1420
|
+
);
|
|
1227
1421
|
});
|
|
1228
1422
|
|
|
1229
|
-
|
|
1230
1423
|
// Subscribe to action events
|
|
1231
|
-
store.on(
|
|
1232
|
-
console.log(
|
|
1424
|
+
store.on("action:start", (data) => {
|
|
1425
|
+
console.log(
|
|
1426
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 🚀 Action "${data.name}" (ID: ${data.actionId}) started with params:`,
|
|
1427
|
+
data.params,
|
|
1428
|
+
);
|
|
1233
1429
|
});
|
|
1234
1430
|
|
|
1235
|
-
store.on(
|
|
1236
|
-
console.log(
|
|
1431
|
+
store.on("action:complete", (data) => {
|
|
1432
|
+
console.log(
|
|
1433
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] ✔️ Action "${data.name}" (ID: ${data.actionId}) completed in ${data.duration?.toFixed(2)}ms.`,
|
|
1434
|
+
);
|
|
1237
1435
|
});
|
|
1238
1436
|
|
|
1239
|
-
store.on(
|
|
1240
|
-
console.error(
|
|
1437
|
+
store.on("action:error", (data) => {
|
|
1438
|
+
console.error(
|
|
1439
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 🔥 Action "${data.name}" (ID: ${data.actionId}) failed:`,
|
|
1440
|
+
data.error,
|
|
1441
|
+
);
|
|
1241
1442
|
});
|
|
1242
1443
|
|
|
1243
1444
|
// Subscribe to selector events
|
|
1244
|
-
store.on(
|
|
1245
|
-
|
|
1445
|
+
store.on("selector:accessed", (data) => {
|
|
1446
|
+
console.debug(
|
|
1447
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 👀 Selector (ID: ${data.selectorId}) accessed paths: ${data.accessedPaths.join(", ")}`,
|
|
1448
|
+
);
|
|
1246
1449
|
});
|
|
1247
1450
|
|
|
1248
|
-
store.on(
|
|
1249
|
-
|
|
1451
|
+
store.on("selector:changed", (data) => {
|
|
1452
|
+
console.log(
|
|
1453
|
+
`[${new Date(data.timestamp).toLocaleTimeString()}] 📢 Selector (ID: ${data.selectorId}) changed. New result:`,
|
|
1454
|
+
data.newResult,
|
|
1455
|
+
);
|
|
1250
1456
|
});
|
|
1251
1457
|
|
|
1252
|
-
|
|
1253
1458
|
// Add a transform middleware to demonstrate `middleware:start/complete/executed`
|
|
1254
1459
|
store.use({
|
|
1255
|
-
name:
|
|
1460
|
+
name: "ValueIncrementMiddleware",
|
|
1256
1461
|
action: (state, update) => {
|
|
1257
1462
|
return { value: state.value + (update.value || 0) };
|
|
1258
1463
|
},
|
|
@@ -1260,34 +1465,39 @@ store.use({
|
|
|
1260
1465
|
|
|
1261
1466
|
// Add a blocking middleware to demonstrate `middleware:error` and `update:complete` (blocked)
|
|
1262
1467
|
store.use({
|
|
1263
|
-
name:
|
|
1468
|
+
name: "StatusValidationMiddleware",
|
|
1264
1469
|
block: true,
|
|
1265
1470
|
action: (state, update) => {
|
|
1266
|
-
if (update.status ===
|
|
1267
|
-
throw new Error(
|
|
1471
|
+
if (update.status === "error" && state.value < 10) {
|
|
1472
|
+
throw new Error("Cannot set status to error if value is too low!");
|
|
1268
1473
|
}
|
|
1269
1474
|
return true;
|
|
1270
1475
|
},
|
|
1271
1476
|
});
|
|
1272
1477
|
|
|
1273
1478
|
// Perform operations to trigger events
|
|
1274
|
-
console.log(
|
|
1275
|
-
await store.set({ value: 5, status:
|
|
1479
|
+
console.log("\n--- Perform Initial Update ---");
|
|
1480
|
+
await store.set({ value: 5, status: "active" }); // Will increment value by 5 (due to middleware)
|
|
1276
1481
|
|
|
1277
|
-
console.log(
|
|
1482
|
+
console.log("\n--- Perform Transactional Update (Success) ---");
|
|
1278
1483
|
await store.transaction(async () => {
|
|
1279
1484
|
await store.set({ value: 3 }); // Inside transaction, value becomes 5 + 3 = 8
|
|
1280
|
-
await store.set({ status:
|
|
1485
|
+
await store.set({ status: "processing" });
|
|
1281
1486
|
});
|
|
1282
1487
|
|
|
1283
|
-
console.log(
|
|
1488
|
+
console.log("\n--- Perform Update (Blocked by Middleware) ---");
|
|
1284
1489
|
try {
|
|
1285
|
-
await store.set({ status:
|
|
1490
|
+
await store.set({ status: "error" }); // This should be blocked by StatusValidationMiddleware (current value is 8, which is < 10)
|
|
1286
1491
|
} catch (e: any) {
|
|
1287
1492
|
console.log(`Caught expected error: ${e.message}`);
|
|
1288
1493
|
}
|
|
1289
1494
|
|
|
1290
|
-
console.log(
|
|
1495
|
+
console.log(
|
|
1496
|
+
"Final value:",
|
|
1497
|
+
store.get().value,
|
|
1498
|
+
"Final status:",
|
|
1499
|
+
store.get().status,
|
|
1500
|
+
);
|
|
1291
1501
|
```
|
|
1292
1502
|
|
|
1293
1503
|
---
|
|
@@ -1298,53 +1508,53 @@ The `@asaidimu/utils-store` library is built with a modular, component-based arc
|
|
|
1298
1508
|
|
|
1299
1509
|
### Core Components
|
|
1300
1510
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1511
|
+
- **`ReactiveDataStore<T>`**: The public API and primary entry point. It orchestrates interactions between all other internal components. It manages the update queue, ensures sequential processing of `set` calls, and exposes public methods like `get`, `dispatch`, `select`, `set`, `watch`, `transaction`, `use`, and `on`.
|
|
1512
|
+
- **`StateManager<T>`**: Responsible for the direct management of the immutable state (`cache`). It applies incoming state changes, performs efficient object `diff`ing to identify modified paths, and notifies internal listeners (via an `updateBus`) about granular state changes.
|
|
1513
|
+
- **`MiddlewareEngine<T>`**: Manages the registration and execution of both `blocking` and `transform` middleware functions. It ensures middleware execution order, handles potential errors, and emits detailed lifecycle events for observability.
|
|
1514
|
+
- **`PersistenceHandler<T>`**: Handles integration with an external persistence layer via the `SimplePersistence` interface. It loads initial state, saves subsequent changes, and listens for external updates from the persistence layer to keep the in-memory state synchronized across multiple instances (e.g., browser tabs). It also manages a background queue for persistence tasks with retries and exponential backoff.
|
|
1515
|
+
- **`TransactionManager<T>`**: Provides atomic state operations. It creates a snapshot of the state before an `operation` begins and, if the operation fails, ensures the state is reverted to this snapshot, guaranteeing data integrity. It integrates closely with the store's event system for tracking transaction status.
|
|
1516
|
+
- **`MetricsCollector`**: Observes the internal `eventBus` to gather and expose real-time performance metrics of the store, such as update counts, listener executions, average update times, largest update size, total events fired, and transaction/middleware execution counts.
|
|
1517
|
+
- **`SelectorManager<T>`**: Manages the creation, memoization, and reactivity of selectors. It tracks the paths accessed by each selector and re-evaluates them efficiently only when relevant parts of the state change, notifying their subscribers.
|
|
1518
|
+
- **`StoreObserver<T>`**: An optional, yet highly valuable, debugging companion. It taps into the `ReactiveDataStore`'s extensive event stream and state changes to build a comprehensive history of events and state snapshots, enabling powerful features like time-travel debugging, detailed console logging, and performance monitoring. It also supports saving/loading and exporting observer sessions.
|
|
1519
|
+
- **`ArtifactContainer<T>`**: Implements a dependency injection system for managing services (artifacts) with different lifecycles (Singleton, Transient) and complex dependencies on state paths and other artifacts. It handles lazy initialization, reactive re-evaluation, and resource cleanup.
|
|
1520
|
+
- **`ActionManager<T>`**: Manages the registration of named actions and their dispatch. It handles action lifecycle events, debouncing logic, and ensures actions interact correctly with the core `set` method.
|
|
1521
|
+
- **`createMerge`**: A factory function that returns a configurable deep merging utility (`MergeFunction`). This utility is crucial for immutably applying partial updates and specifically handles `Symbol.for("delete")` for explicit property removal.
|
|
1522
|
+
- **`createDiff` / `createDerivePaths`**: Factory functions returning utilities for efficient comparison between two objects (`createDiff`) to identify changed paths, and for deriving all parent paths from a set of changes (`createDerivePaths`). These are fundamental for optimizing listener notifications and internal change detection.
|
|
1313
1523
|
|
|
1314
1524
|
### Data Flow
|
|
1315
1525
|
|
|
1316
1526
|
The `ReactiveDataStore` handles state updates in a robust, queued, and event-driven manner:
|
|
1317
1527
|
|
|
1318
1528
|
1. **`store.set(update)` or `store.dispatch(actionName, actionFn, ...)` call**:
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1529
|
+
- If `dispatch` is used, an `action:start` event is emitted. The `actionFn` (which can be `async`) is executed to produce a `DeepPartial` update which is then passed to `store.set`. Actions can be debounced, delaying their `set` call and potentially cancelling previous in-flight actions.
|
|
1530
|
+
- All `set` calls are automatically queued to prevent race conditions during concurrent updates, ensuring sequential processing. The `store.state().pendingChanges` reflects the queue.
|
|
1531
|
+
- An `update:start` event is immediately emitted.
|
|
1322
1532
|
2. **Middleware Execution**:
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1533
|
+
- The `MiddlewareEngine` first executes all `blocking` middlewares (registered via `store.use({ block: true, ... })`). If any blocking middleware returns `false` or throws an error, the update is immediately halted. An `update:complete` event with `blocked: true` and an `error` property is emitted, and the process stops, with the state remaining unchanged.
|
|
1534
|
+
- 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 `DeepPartial<T>` that is then merged into the effective update payload.
|
|
1535
|
+
- Detailed lifecycle events (`middleware:start`, `middleware:complete`, `middleware:error`, `middleware:blocked`, `middleware:executed`) are emitted during this phase, providing granular insight into middleware behavior.
|
|
1326
1536
|
3. **State Application**:
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1537
|
+
- The `StateManager` receives the (potentially transformed) final `DeepPartial` update.
|
|
1538
|
+
- It internally uses the `createMerge` utility to immutably construct the new full state object.
|
|
1539
|
+
- It then performs a `createDiff` comparison between the _previous_ state and the _new_ state to precisely identify all `changedPaths` (an array of `StateDelta` objects).
|
|
1540
|
+
- If changes are detected, the `StateManager` updates its internal immutable `cache` to the `newState` and then emits an internal `update` event for each granular `changedPath` on its `updateBus`.
|
|
1331
1541
|
4. **Listener Notification**:
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1542
|
+
- Any external subscribers (registered with `store.watch()` or `store.subscribe()`) whose registered paths match or are parent paths of the `changedPaths` are efficiently notified with the latest state. The `MetricsCollector` tracks `listenerExecutions` during this phase.
|
|
1543
|
+
- The `SelectorManager` re-evaluates reactive selectors whose `accessedPaths` are affected by the `changedPaths`. If a selector's result changes, it notifies its own subscribers and emits a `selector:changed` event.
|
|
1544
|
+
- `ArtifactContainer` also receives change notifications for state paths its artifacts depend on, triggering re-evaluation for `Singleton` scoped artifacts.
|
|
1335
1545
|
5. **Persistence Handling**:
|
|
1336
|
-
|
|
1337
|
-
|
|
1546
|
+
- 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()`. This is done in the background via a queue, emitting `persistence:queued`, `persistence:success`, `persistence:retry`, and `persistence:failed` events.
|
|
1547
|
+
- The `PersistenceHandler` also manages loading initial state and reacting to external state changes (e.g., from other browser tabs or processes) through `persistence.subscribe()`.
|
|
1338
1548
|
6. **Completion & Queue Processing**:
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1549
|
+
- An `update:complete` event is emitted, containing crucial information about the update's duration, the `StateDelta[]`, and any blocking errors.
|
|
1550
|
+
- If the update originated from a `dispatch` call, an `action:complete` or `action:error` event is emitted, correlating with the `action:start` event via a shared `actionId`.
|
|
1551
|
+
- The update queue automatically processes the next pending update.
|
|
1342
1552
|
|
|
1343
1553
|
### Extension Points
|
|
1344
1554
|
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1555
|
+
- **Custom Middleware**: Developers can inject their own `Middleware` (for transformation) and `BlockingMiddleware` (for validation/prevention) functions using `store.use()`. This allows for highly customizable update logic, centralized logging, complex validation, authorization, or triggering specific side effects.
|
|
1556
|
+
- **Custom Persistence**: The `SimplePersistence<T>` interface provides a clear contract for developers to integrate the store with any storage solution, whether it's local storage, IndexedDB, a backend API, or a WebSocket connection. This offers complete control over data durability and synchronization.
|
|
1557
|
+
- **Custom Artifacts**: The `ArtifactContainer` (directly or via React integration) allows defining and managing any custom services, utilities, or dependencies with defined scopes and lifecycle management.
|
|
1348
1558
|
|
|
1349
1559
|
---
|
|
1350
1560
|
|
|
@@ -1367,6 +1577,7 @@ Contributions are welcome! Follow these guidelines to get started with local dev
|
|
|
1367
1577
|
```
|
|
1368
1578
|
3. **Build the project:**
|
|
1369
1579
|
Navigate to the `store` package directory and run the build script, or build the entire monorepo from the root.
|
|
1580
|
+
|
|
1370
1581
|
```bash
|
|
1371
1582
|
# From the monorepo root:
|
|
1372
1583
|
pnpm build # Builds all packages in the monorepo
|
|
@@ -1380,10 +1591,10 @@ Contributions are welcome! Follow these guidelines to get started with local dev
|
|
|
1380
1591
|
|
|
1381
1592
|
From the `src/store` directory, the following `pnpm` scripts are available:
|
|
1382
1593
|
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1594
|
+
- `pnpm test`: Runs all unit tests using [Vitest](https://vitest.dev/).
|
|
1595
|
+
- `pnpm test:watch`: Runs tests in watch mode for continuous development.
|
|
1596
|
+
- `pnpm dev`: Starts the Vite development server for the UI example.
|
|
1597
|
+
- `pnpm lint`: Lints the codebase using [ESLint](https://eslint.org/).
|
|
1387
1598
|
|
|
1388
1599
|
### Testing
|
|
1389
1600
|
|
|
@@ -1416,8 +1627,9 @@ Please ensure all new features have comprehensive test coverage and all existing
|
|
|
1416
1627
|
### Issue Reporting
|
|
1417
1628
|
|
|
1418
1629
|
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).
|
|
1419
|
-
|
|
1420
|
-
|
|
1630
|
+
|
|
1631
|
+
- For **bug reports**, include steps to reproduce, expected behavior, and actual behavior. Provide relevant code snippets or a minimal reproducible example.
|
|
1632
|
+
- For **feature requests**, describe the use case, the problem it solves, and your proposed solution or ideas.
|
|
1421
1633
|
|
|
1422
1634
|
---
|
|
1423
1635
|
|
|
@@ -1425,28 +1637,28 @@ If you find a bug or have a feature request, please open an issue on the [GitHub
|
|
|
1425
1637
|
|
|
1426
1638
|
### Troubleshooting
|
|
1427
1639
|
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1640
|
+
- **"Update not triggering listeners"**:
|
|
1641
|
+
- Ensure you are subscribing to the correct path. `store.watch('user.name', ...)` will not trigger if you update `user.email` (unless you also subscribe to `user` or the root `''` path).
|
|
1642
|
+
- 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.
|
|
1643
|
+
- Verify your `DeepPartial` update correctly targets the intended part of the state.
|
|
1644
|
+
- **"Reactive Selector not re-evaluating"**:
|
|
1645
|
+
- Ensure the state path(s) accessed by the selector are actually changing. The selector re-evaluates only when its dependencies change.
|
|
1646
|
+
- If using `select(() => state.someArray.length)`, it might not re-evaluate if array elements change but `length` remains the same.
|
|
1647
|
+
- Check for strict equality issues: if the new computed value is strictly equal to the old one, no re-evaluation notification will occur.
|
|
1648
|
+
- Avoid complex operations (array methods, conditionals, arithmetic) within selectors as they might bypass the static path analysis and lead to unexpected behavior or errors.
|
|
1649
|
+
- **"State not rolling back after transaction error"**:
|
|
1650
|
+
- Ensure the error is thrown _within_ the `transaction` callback function. Errors caught and handled inside the callback, or thrown outside of it, will not trigger the rollback mechanism.
|
|
1651
|
+
- Promises within the transaction _must_ be `await`ed so the `TransactionManager` can capture potential rejections and manage the atomic operation correctly.
|
|
1652
|
+
- **"Middleware not being applied"**:
|
|
1653
|
+
- Verify the middleware is registered with `store.use()` _before_ the `set` operation it should affect. Middleware functions are applied in the order they are registered.
|
|
1654
|
+
- Check middleware `name` in console logs (if `StoreObserver` is enabled with `enableConsoleLogging: true`) to confirm it's being hit.
|
|
1655
|
+
- Ensure your `transform` middleware returns a `DeepPartial<T>` or `void`/`Promise<void>`, and `blocking` middleware returns `boolean`/`Promise<boolean>`.
|
|
1656
|
+
- **"Performance warnings in console"**:
|
|
1657
|
+
- 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 in your application.
|
|
1658
|
+
- **"Artifact not resolving or re-evaluating"**:
|
|
1659
|
+
- Check for `Circular dependency` errors in the console during `resolve`.
|
|
1660
|
+
- For `Singleton` artifacts, ensure its `state` or `artifact` dependencies are actually changing to trigger re-evaluation. If a dependency changes, the artifact (and its downstream dependents) will be invalidated and re-created on next `resolve`.
|
|
1661
|
+
- For `Transient` artifacts, a new instance is created on every `resolve`.
|
|
1450
1662
|
|
|
1451
1663
|
### FAQ
|
|
1452
1664
|
|
|
@@ -1470,12 +1682,12 @@ A: `set` is the foundational method for updating the state, handling the core lo
|
|
|
1470
1682
|
|
|
1471
1683
|
### Changelog / Roadmap
|
|
1472
1684
|
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1685
|
+
- **Changelog**: For detailed version history, including new features, bug fixes, and breaking changes, please refer to the project's [CHANGELOG.md](https://github.com/asaidimu/erp-utils/blob/main/src/store/CHANGELOG.md) file.
|
|
1686
|
+
- **Roadmap**: Future plans for `@asaidimu/utils-store` may include:
|
|
1687
|
+
- More advanced query/selector capabilities with built-in memoization for derived state.
|
|
1688
|
+
- Built-in serialization/deserialization options for persistence, perhaps with schema validation.
|
|
1689
|
+
- Higher-order middlewares for common patterns (e.g., async data fetching, debouncing updates).
|
|
1690
|
+
- Further performance optimizations for very large states or high update frequencies.
|
|
1479
1691
|
|
|
1480
1692
|
### License
|
|
1481
1693
|
|
|
@@ -1483,8 +1695,8 @@ This project is licensed under the [MIT License](https://github.com/asaidimu/erp
|
|
|
1483
1695
|
|
|
1484
1696
|
### Acknowledgments
|
|
1485
1697
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1698
|
+
- Inspired by modern state management patterns such as Redux, Zustand, and Vuex, emphasizing immutability and explicit state changes.
|
|
1699
|
+
- Leverages the `@asaidimu/events` package for robust internal event bus capabilities.
|
|
1700
|
+
- Utilizes the `uuid` library for generating unique instance IDs.
|
|
1489
1701
|
|
|
1490
1702
|
---
|