@asaidimu/utils-store v2.1.3
@asaidimu/utils-store
A Reactive Data Store for TypeScript Applications
A comprehensive, type-safe, and reactive state management library for TypeScript applications, featuring robust middleware, transactional updates, deep observability, and an optional persistence layer. It simplifies complex state interactions by promoting immutability, explicit updates, and a modular design.
Quick Links
- Overview & Features
- Installation & Setup
- Usage Documentation
- Project Architecture
- Development & Contributing
- Additional Information
Overview & Features
@asaidimu/utils-store
is a powerful and flexible state management solution designed for modern TypeScript applications. It provides a highly performant and observable way to manage your application's data, ensuring type safety and predictability across complex state interactions. Built on principles of immutability and explicit updates, it makes state changes easy to track, debug, and extend.
This library offers robust tools to handle your state with confidence, enabling features like atomic transactions, a pluggable middleware pipeline, and deep runtime introspection for unparalleled debugging capabilities. It emphasizes a component-based design internally, allowing for clear separation of concerns for core state management, middleware processing, persistence, and observability.
Key Features
- š 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. - š 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. - š§ Composable Middleware System:
- Transform Middleware: Intercept, modify, normalize, or enrich state updates before they are applied. These can return a
DeepPartial<T>
to apply further changes orvoid
for side effects. - 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.
- Transform Middleware: Intercept, modify, normalize, or enrich state updates before they are applied. These can return a
- š¦ 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.
- š¾ 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 apersistence:ready
event and listens for external updates. - š Deep Observer & Debugging (
StoreObserver
): An optional but highly recommended class for unparalleled runtime introspection and debugging:- Comprehensive Event History: Captures a detailed log of all internal store events (
update:start
,middleware:complete
,transaction:error
,persistence:ready
,middleware:executed
, etc.). - 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.
- Time-Travel Debugging: Leverage the recorded state history to
undo
andredo
state changes, providing powerful capabilities for debugging complex asynchronous flows and state transitions. - 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.
- Configurable Console Logging: Provides human-readable, color-coded logging of store events directly to the browser console for immediate feedback during development.
- Pre-built Debugging Middlewares: Includes helper methods to easily create a generic logging middleware and a validation middleware for immediate use.
- Comprehensive Event History: Captures a detailed log of all internal store events (
- šļø Property Deletion: Supports explicit property deletion within partial updates using the global
Symbol.for("delete")
or a custom marker. - ā” Concurrency Handling: Automatically queues and processes
set
updates to prevent race conditions during concurrent calls, ensuring updates are applied in a predictable, sequential order.
Installation & Setup
Install @asaidimu/utils-store
using your preferred package manager. This library is designed for browser and Node.js environments, providing both CommonJS and ES Module exports.
# Using Bun
bun add @asaidimu/utils-store
# Using npm
npm install @asaidimu/utils-store
# Using yarn
yarn add @asaidimu/utils-store
Prerequisites
- Node.js: (LTS version recommended) for development and compilation.
- TypeScript: (v4.0+ recommended) for full type-safety during development.
Verification
To verify that the library is installed and working correctly, create a small TypeScript file (e.g., verify.ts
) and run it.
// verify.ts
import { ReactiveDataStore } from '@asaidimu/utils-store';
interface MyState {
count: number;
message: string;
}
const store = new ReactiveDataStore<MyState>({ count: 0, message: "Hello" });
// Subscribing to "count" will only log when 'count' path changes
store.subscribe("count", (state) => {
console.log(`Count changed to: ${state.count}`);
});
// Subscribing to "" (empty string) will log for any store update
store.subscribe("", (state) => {
console.log(`Store updated to: ${JSON.stringify(state)}`);
});
console.log("Initial state:", store.get());
await store.set({ count: 1 });
// Expected Output:
// Count changed to: 1
// Store updated to: {"count":1,"message":"Hello"}
await store.set({ message: "World" });
// Expected Output:
// Store updated to: {"count":1,"message":"World"}
// (The 'count' listener won't be triggered as only 'message' changed)
console.log("Current state:", store.get());
// Expected Output: Current state: { count: 1, message: "World" }
Run this file using ts-node
or compile it first:
# Install ts-node if you don't have it: npm install -g ts-node
npx ts-node verify.ts
Usage Documentation
This section provides practical examples and detailed explanations of how to use @asaidimu/utils-store
to manage your application state effectively.
Basic Usage
Learn how to create a store, read state, and update state with partial objects or functions.
import { ReactiveDataStore, type DeepPartial } from '@asaidimu/utils-store';
// 1. Define your state interface for type safety
interface AppState {
user: {
id: string;
name: string;
email: string;
isActive: boolean;
};
products: Array<{ id: string; name: string; price: number }>;
settings: {
theme: 'light' | 'dark';
notificationsEnabled: boolean;
};
lastUpdated: number;
}
// 2. Initialize the store with an initial state
const initialState: AppState = {
user: {
id: '123',
name: 'Jane Doe',
email: 'jane@example.com',
isActive: true,
},
products: [
{ id: 'p1', name: 'Laptop', price: 1200 },
{ id: 'p2', name: 'Mouse', price: 25 },
],
settings: {
theme: 'light',
notificationsEnabled: true,
},
lastUpdated: Date.now(),
};
const store = new ReactiveDataStore<AppState>(initialState);
// 3. Get the current state
// `store.get()` returns a reference to the internal state.
// Use `store.get(true)` to get a deep clone, ensuring immutability if you modify it directly.
const currentState = store.get();
console.log('Initial state:', currentState);
/* Output:
Initial state: {
user: { id: '123', name: 'Jane Doe', email: 'jane@example.com', isActive: true },
products: [ { id: 'p1', name: 'Laptop', price: 1200 }, { id: 'p2', name: 'Mouse', price: 25 } ],
settings: { theme: 'light', notificationsEnabled: true },
lastUpdated: <timestamp>
}
*/
// 4. Update the state using a partial object (`DeepPartial<T>`)
// You can update deeply nested properties without affecting siblings.
await store.set({
user: {
name: 'Jane Smith', // Changes user's name
isActive: false, // Changes user's active status
},
settings: {
theme: 'dark', // Changes theme
},
});
console.log('State after partial update:', store.get());
/* Output:
State after partial update: {
user: { id: '123', name: 'Jane Smith', email: 'jane@example.com', isActive: false },
products: [ { id: 'p1', name: 'Laptop', price: 1200 }, { id: 'p2', name: 'Mouse', price: 25 } ],
settings: { theme: 'dark', notificationsEnabled: true },
lastUpdated: <timestamp> // Email and products remain unchanged.
}
*/
// 5. Update the state using a function (StateUpdater)
// This is useful when the new state depends on the current state.
await store.set((state) => ({
products: [
...state.products, // Keep existing products
{ id: 'p3', name: 'Keyboard', price: 75 }, // Add a new product
],
lastUpdated: Date.now(), // Update timestamp
}));
console.log('State after functional update, products count:', store.get().products.length);
// Output: State after functional update, products count: 3
// 6. Subscribing to state changes
// You can subscribe to the entire state (path: '') or specific paths (e.g., 'user.name', 'settings.notificationsEnabled').
const unsubscribeUser = store.subscribe('user', (state) => {
console.log('User data changed:', state.user);
});
const unsubscribeNotifications = store.subscribe('settings.notificationsEnabled', (state) => {
console.log('Notifications setting changed:', state.settings.notificationsEnabled);
});
// Subscribe to multiple paths at once
const unsubscribeMulti = store.subscribe(['user.name', 'products'], (state) => {
console.log('User name or products changed:', state.user.name, state.products.length);
});
// Subscribe to any change in the store (root listener)
const unsubscribeAll = store.subscribe('', (state) => {
console.log('Store updated (any path changed). Current products count:', state.products.length);
});
await store.set({ user: { email: 'jane.smith@example.com' } });
/* Output (order may vary slightly depending on async operations):
User data changed: { id: '123', name: 'Jane Smith', email: 'jane.smith@example.com', isActive: false }
User name or products changed: Jane Smith 3
Store updated (any path changed). Current products count: 3
*/
await store.set({ settings: { notificationsEnabled: false } });
/* Output:
Notifications setting changed: false
Store updated (any path changed). Current products count: 3
*/
// 7. Unsubscribe from changes
unsubscribeUser();
unsubscribeNotifications();
unsubscribeMulti();
unsubscribeAll();
await store.set({ user: { isActive: true } });
// No console output from the above listeners after unsubscribing.
// 8. Deleting properties
// Use `Symbol.for("delete")` to explicitly remove a property from the state.
// Note: You might need a type cast (e.g., `as DeepPartial<string>`) for TypeScript if strict type checking is enabled.
const DELETE_ME = Symbol.for("delete");
await store.set({
user: {
email: DELETE_ME as DeepPartial<string> // Cast is needed for type inference
}
});
console.log('User email after deletion:', store.get().user.email);
// Output: User email after deletion: undefined
Persistence Integration
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.
import { ReactiveDataStore, type StoreEvent } from '@asaidimu/utils-store';
// Define the SimplePersistence interface (from `@asaidimu/utils-persistence` or your own)
interface SimplePersistence<T extends object> {
get(): Promise<T | null>;
set(instanceId: string, state: T): Promise<boolean>;
subscribe(instanceId: string, listener: (state: T) => void): () => void;
// An unsubscribe method for the persistence layer is recommended but not enforced by this interface
}
// Example: A simple in-memory persistence for demonstration
// In a real application, this would interact with localStorage, IndexedDB, a backend API, etc.
class InMemoryPersistence<T extends object> implements SimplePersistence<T> {
private data: T | null = null;
// Using a map to simulate different instances subscribing to common data
private subscribers: Map<string, (state: T) => void> = new Map();
constructor(initialData: T | null = null) {
this.data = initialData;
}
async get(): Promise<T | null> {
console.log('Persistence: Loading state...');
return this.data ? structuredClone(this.data) : null;
}
async set(instanceId: string, state: T): Promise<boolean> {
console.log(`Persistence: Saving state for instance ${instanceId}...`);
this.data = structuredClone(state); // Store a clone
// Simulate external change notification for *other* instances
this.subscribers.forEach((callback, subId) => {
// Only notify other instances, not the one that just saved
if (subId !== instanceId) {
callback(structuredClone(this.data!)); // Pass a clone to prevent mutation
}
});
return true;
}
subscribe(instanceId: string, callback: (state: T) => void): () => void {
console.log(`Persistence: Subscribing to external changes for instance ${instanceId}`);
this.subscribers.set(instanceId, callback);
return () => {
console.log(`Persistence: Unsubscribing for instance ${instanceId}`);
this.subscribers.delete(instanceId);
};
}
}
interface UserConfig {
theme: 'light' | 'dark';
fontSize: number;
}
// Create a persistence instance, possibly with some pre-existing data
const userConfigPersistence = new InMemoryPersistence<UserConfig>({ theme: 'dark', fontSize: 18 });
// Initialize the store with persistence
const store = new ReactiveDataStore<UserConfig>(
{ theme: 'light', fontSize: 16 }, // Initial state if no persisted data found (or if persistence is not used)
userConfigPersistence // Pass your persistence implementation here
);
// Optionally, listen for persistence readiness (important for UIs that depend on loaded state)
const storeReadyPromise = new Promise<void>(resolve => {
store.onStoreEvent('persistence:ready', (data) => {
console.log(`Store is ready and persistence is initialized! Timestamp: ${new Date(data.timestamp).toLocaleTimeString()}`);
resolve();
});
});
console.log('Store initial state (before persistence loads):', store.get());
// Output: Store initial state (before persistence loads): { theme: 'light', fontSize: 16 } (initial state provided to constructor)
await storeReadyPromise; // Wait for persistence to load/initialize
// Now, store.get() will reflect the loaded state from persistence
console.log('Store state after persistence load:', store.get());
// Output: Store state after persistence load: { theme: 'dark', fontSize: 18 } (from InMemoryPersistence)
// Now update the state, which will trigger persistence.set()
await store.set({ theme: 'light' });
console.log('Current theme:', store.get().theme);
// Output: Current theme: light
// Persistence: Saving state for instance <uuid>...
// Simulate an external change (e.g., another tab or process updating the state)
// Note: The `instanceId` here should be different from the store's `store.id()`
// to simulate an external change and trigger the store's internal persistence subscription.
await userConfigPersistence.set('another-instance-id-123', { theme: 'system', fontSize: 20 });
// The store will automatically update its state and notify its listeners due to the internal subscription.
console.log('Current theme after external update:', store.get().theme);
// Output: Current theme after external update: system
Middleware System
Middleware functions allow you to intercept and modify state updates or prevent them from proceeding.
Transform Middleware
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.
import { ReactiveDataStore, type DeepPartial } from '@asaidimu/utils-store';
interface MyState {
counter: number;
logs: string[];
lastAction: string | null;
version: number;
}
const store = new ReactiveDataStore<MyState>({
counter: 0,
logs: [],
lastAction: null,
version: 0,
});
// Middleware 1: Logger
// Logs the incoming update before it's processed. Does not return anything (void), so it doesn't modify the update.
store.use({
name: 'LoggerMiddleware',
action: (state, update) => {
console.log('Middleware: Incoming update:', update);
},
});
// Middleware 2: Timestamp and Action Tracker
// Modifies the update to add a timestamp and track the last action. Returns a partial state that gets merged.
store.use({
name: 'TimestampActionMiddleware',
action: (state, update) => {
const actionDescription = JSON.stringify(update);
return {
lastAction: `Updated at ${new Date().toLocaleTimeString()} with ${actionDescription}`,
logs: [...state.logs, `Update processed: ${actionDescription}`],
};
},
});
// Middleware 3: Version Incrementor
// Increments a version counter for every successful update.
store.use({
name: 'VersionIncrementMiddleware',
action: (state) => {
return { version: state.version + 1 };
},
});
// Middleware 4: Counter Incrementor
// This middleware intercepts updates to 'counter' and increments it by the value provided,
// instead of setting it directly.
store.use({
name: 'CounterIncrementMiddleware',
action: (state, update) => {
// Only apply if the incoming update is a number for 'counter'
if (typeof update.counter === 'number') {
return { counter: state.counter + update.counter };
}
// Return the original update or void if no transformation is needed for other paths
return update;
},
});
await store.set({ counter: 5 }); // Will increment counter by 5, not set to 5
/* Expected console output from LoggerMiddleware:
Middleware: Incoming update: { counter: 5 }
*/
console.log('State after counter set:', store.get());
/* Output will show:
counter: 5 (initial) + 5 (update) = 10,
lastAction updated by TimestampActionMiddleware,
logs updated by TimestampActionMiddleware,
version: 1 (incremented by VersionIncrementMiddleware)
*/
await store.set({ lastAction: 'Manual update from outside middleware' });
/* Expected console output from LoggerMiddleware:
Middleware: Incoming update: { lastAction: 'Manual update from outside middleware' }
*/
console.log('State after manual action:', store.get());
/* Output will show:
lastAction will be overwritten by TimestampActionMiddleware logic,
a new log entry will be added,
version: 2
*/
// Unuse a middleware by its ID
const temporaryLoggerId = store.use({ name: 'TemporaryLogger', action: (s, u) => console.log('Temporary logger saw:', u) });
await store.set({ counter: 1 });
// Output: Temporary logger saw: { counter: 1 }
store.unuse(temporaryLoggerId); // Remove the temporary logger
await store.set({ counter: 1 }); // TemporaryLogger will not be called now
Blocking Middleware
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.
import { ReactiveDataStore, type DeepPartial } from '@asaidimu/utils-store';
interface UserProfile {
name: string;
age: number;
isAdmin: boolean;
isVerified: boolean;
}
const store = new ReactiveDataStore<UserProfile>({
name: 'Guest',
age: 0,
isAdmin: false,
isVerified: false,
});
// Blocking middleware 1: Age validation
store.use({
block: true, // Mark as a blocking middleware
name: 'AgeValidationMiddleware',
action: (state, update) => {
if (update.age !== undefined && typeof update.age === 'number' && update.age < 18) {
console.warn('Blocking update: Age must be 18 or older.');
return false; // Block the update
}
return true; // Allow the update
},
});
// Blocking middleware 2: Admin check
store.use({
block: true,
name: 'AdminRestrictionMiddleware',
action: (state, update) => {
// If attempting to become admin, check conditions
if (update.isAdmin === true) {
if (state.age < 21) {
console.warn('Blocking update: User must be 21+ to become admin.');
return false;
}
if (!state.isVerified) {
console.warn('Blocking update: User must be verified to become admin.');
return false;
}
}
return true; // Allow the update
},
});
// Attempt to set a valid age
await store.set({ age: 25 });
console.log('User age after valid update:', store.get().age); // Output: 25
// Attempt to set an invalid age (will be blocked)
await store.set({ age: 16 });
console.log('User age after invalid update attempt (should be 25):', store.get().age); // Output: 25
// Attempt to make user admin while not verified (will be blocked)
await store.set({ isAdmin: true });
console.log('User admin status after failed attempt (should be false):', store.get().isAdmin); // Output: false
// Verify user, then attempt to make admin again (will still be blocked due to age)
await store.set({ isVerified: true });
await store.set({ age: 20 });
await store.set({ isAdmin: true });
console.log('User admin status after failed age attempt (should be false):', store.get().isAdmin); // Output: false
// Now make user old enough and verified, then try again (should succeed)
await store.set({ age: 25 });
await store.set({ isAdmin: true });
console.log('User admin status after successful attempt (should be true):', store.get().isAdmin); // Output: true
Transaction Support
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.
import { ReactiveDataStore } from '@asaidimu/utils-store';
interface BankAccount {
name: string;
balance: number;
transactions: string[];
}
// Set up two bank accounts
const accountA = new ReactiveDataStore<BankAccount>({ name: 'Account A', balance: 500, transactions: [] });
const accountB = new ReactiveDataStore<BankAccount>({ name: 'Account B', balance: 200, transactions: [] });
// A function to transfer funds using transactions
async function transferFunds(
fromStore: ReactiveDataStore<BankAccount>,
toStore: ReactiveDataStore<BankAccount>,
amount: number,
) {
// All operations inside this transaction will be atomic.
// If `operation()` throws an error, the state will revert.
await fromStore.transaction(async () => {
console.log(`Starting transfer of ${amount}. From: ${fromStore.get().balance}, To: ${toStore.get().balance}`);
// Deduct from sender
await fromStore.set((state) => {
if (state.balance < amount) {
// Throwing an error here will cause the entire transaction to roll back
throw new Error('Insufficient funds');
}
return {
balance: state.balance - amount,
transactions: [...state.transactions, `Debited ${amount} from ${state.name}`],
};
});
// Simulate a network delay or another async operation that might fail
// If an error happens here, the state will still roll back.
await new Promise(resolve => setTimeout(resolve, 50));
// Add to receiver
await toStore.set((state) => ({
balance: state.balance + amount,
transactions: [...state.transactions, `Credited ${amount} to ${state.name}`],
}));
console.log(`Transfer in progress. From: ${fromStore.get().balance}, To: ${toStore.get().balance}`);
});
console.log(`Transfer successful. From: ${fromStore.get().balance}, To: ${toStore.get().balance}`);
}
console.log('--- Initial Balances ---');
console.log('Account A:', accountA.get().balance); // Expected: 500
console.log('Account B:', accountB.get().balance); // Expected: 200
// --- Scenario 1: Successful transfer ---
console.log('\n--- Attempting successful transfer (100) ---');
try {
await transferFunds(accountA, accountB, 100);
console.log('\nTransfer 1 successful:');
console.log('Account A:', accountA.get()); // Expected: balance 400, transactions: ['Debited 100 from Account A']
console.log('Account B:', accountB.get()); // Expected: balance 300, transactions: ['Credited 100 to Account B']
} catch (error: any) {
console.error('Transfer 1 failed unexpectedly:', error.message);
}
// --- Scenario 2: Failed transfer (insufficient funds) ---
console.log('\n--- Attempting failed transfer (1000) ---');
try {
// Account A now has 400, so this should fail
await transferFunds(accountA, accountB, 1000);
} catch (error: any) {
console.error('Transfer 2 failed as expected:', error.message);
} finally {
console.log('Transfer 2 attempt, state after rollback:');
// State should be rolled back to its state *before* the transaction attempt
console.log('Account A:', accountA.get());
// Expected: balance 400 (rolled back to state before this transaction)
console.log('Account B:', accountB.get());
// Expected: balance 300 (rolled back to state before this transaction)
}
Store Observer (Debugging & Observability)
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.
import { ReactiveDataStore, StoreObserver, type StoreEvent, type DeepPartial } from '@asaidimu/utils-store';
interface DebuggableState {
user: { name: string; status: 'online' | 'offline' };
messages: string[];
settings: { debugMode: boolean; logLevel: string };
metrics: { updates: number };
}
const store = new ReactiveDataStore<DebuggableState>({
user: { name: 'Debugger', status: 'online' },
messages: [],
settings: { debugMode: true, logLevel: 'info' },
metrics: { updates: 0 },
});
// Initialize observability for the store
// Options allow granular control over what is tracked and logged.
const observer = new StoreObserver(store, {
maxEvents: 50, // Keep up to 50 internal store events in history
maxStateHistory: 5, // Keep up to 5 state snapshots for time-travel
enableConsoleLogging: true, // Log events to browser console for immediate feedback
logEvents: {
updates: true, // Log all update lifecycle events (start/complete)
middleware: true, // Log middleware start/complete/error/blocked/executed events
transactions: true, // Log transaction start/complete/error events
},
performanceThresholds: {
updateTime: 50, // Warn in console if an update takes > 50ms
middlewareTime: 20, // Warn if a middleware takes > 20ms
},
});
// Add a simple middleware to demonstrate middleware logging and metrics update
store.use({ name: 'UpdateMetricsMiddleware', action: async (state, update) => {
await new Promise(resolve => setTimeout(resolve, 10)); // Simulate work
return { metrics: { updates: state.metrics.updates + 1 } };
}});
// Perform some state updates
await store.set({ user: { status: 'offline' } });
await store.set({ messages: ['Hello World!'] });
await store.set({ settings: { debugMode: false } });
// Simulate a slow update to trigger performance warning
await new Promise(resolve => setTimeout(resolve, 60)); // Artificially delay
await store.set({ messages: ['Another message', 'And another'] });
// This last set will cause a console warning for "Slow update detected" if enableConsoleLogging is true.
// 1. Get Event History
console.log('\n--- Event History (Most Recent First) ---');
const events = observer.getEventHistory();
// Events will include: update:start, update:complete (multiple times), middleware:start, middleware:complete, etc.
events.slice(0, 5).forEach(event => console.log(`Type: ${event.type}, Data: ${JSON.stringify(event.data).substring(0, 70)}...`));
// 2. Get State History
console.log('\n--- State History (Most Recent First) ---');
const stateSnapshots = observer.getStateHistory();
stateSnapshots.forEach((snapshot, index) => console.log(`State #${index}: Messages: ${snapshot.messages.join(', ')}, User Status: ${snapshot.user.status}`));
// 3. Get Recent Changes (Diffs)
console.log('\n--- Recent State Changes (Diffs) ---');
const recentChanges = observer.getRecentChanges(3); // Show diffs for last 3 changes
recentChanges.forEach((change, index) => {
console.log(`\nChange #${index}:`);
console.log(` Timestamp: ${new Date(change.timestamp).toLocaleTimeString()}`);
console.log(` Changed Paths: ${change.changedPaths.join(', ')}`);
console.log(` From (partial):`, change.from); // Only changed parts of the state
console.log(` To (partial):`, change.to); // Only changed parts of the state
});
// 4. Time-Travel Debugging
console.log('\n--- Time-Travel ---');
const timeTravel = observer.createTimeTravel();
// Add more states to the history for time-travel demonstration
await store.set({ user: { status: 'online' } }); // Current State (A)
await store.set({ messages: ['First message'] }); // Previous State (B)
await store.set({ messages: ['Second message'] }); // Previous State (C)
console.log('Current state (latest):', store.get().messages); // Output: ['Second message']
if (timeTravel.canUndo()) {
await timeTravel.undo(); // Go back to State B
console.log('After undo 1:', store.get().messages); // Output: ['First message']
}
if (timeTravel.canUndo()) {
await timeTravel.undo(); // Go back to State A
console.log('After undo 2:', store.get().messages); // Output: [] (the state before 'First message' was added)
}
if (timeTravel.canRedo()) {
await timeTravel.redo(); // Go forward to State B
console.log('After redo 1:', store.get().messages); // Output: ['First message']
}
console.log('Time-Travel history length:', timeTravel.getHistoryLength()); // Reflects `maxStateHistory` + initial state
// 5. Custom Debugging Middleware (provided by StoreObserver for convenience)
// Example: A logging middleware that logs every update
const loggingMiddleware = observer.createLoggingMiddleware({
logLevel: 'info', // Can be 'debug', 'info', 'warn'
logUpdates: true, // Whether to log the update payload itself
});
const loggingMiddlewareId = store.use({ name: 'DebugLogging', action: loggingMiddleware });
await store.set({ user: { name: 'New User Via Debug Logger' } }); // This update will be logged by the created middleware.
// Expected console output: "State Update: { user: { name: 'New User Via Debug Logger' } }"
// Example: A validation middleware (blocking)
const validationMiddleware = observer.createValidationMiddleware((state, update) => {
if (update.messages && update.messages.length > 5) {
return { valid: false, reason: "Too many messages!" };
}
return true;
});
const validationMiddlewareId = store.use({ name: 'DebugValidation', block: true, action: validationMiddleware });
try {
await store.set({ messages: ['m1','m2','m3','m4','m5','m6'] }); // This will be blocked
} catch (e) {
console.warn(`Caught expected error from validation middleware: ${e.message}`);
}
console.log('Current messages after failed validation:', store.get().messages); // Should be the state before this set.
// 6. Clear history
observer.clearHistory();
console.log('\nHistory cleared. Events:', observer.getEventHistory().length, 'State snapshots:', observer.getStateHistory().length);
// Output: History cleared. Events: 0 State snapshots: 1 (keeps current state)
// 7. Disconnect observer when no longer needed to prevent memory leaks
observer.disconnect();
console.log('\nObserver disconnected. No more events or state changes will be tracked.');
// After disconnect, new updates won't be logged or tracked by Observer
await store.set({ messages: ['Final message after disconnect'] });
Event System
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.onStoreEvent(eventName, listener)
. The StoreObserver
leverages this event system internally to provide its rich debugging capabilities.
import { ReactiveDataStore, type StoreEvent } from '@asaidimu/utils-store';
interface MyState {
value: number;
status: string;
}
const store = new ReactiveDataStore<MyState>({ value: 0, status: 'idle' });
// Subscribe to 'update:start' event - triggered before an update begins processing.
store.onStoreEvent('update:start', (data) => {
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ā” Update started.`);
});
// Subscribe to 'update:complete' event - triggered after an update is fully applied or blocked.
store.onStoreEvent('update:complete', (data) => {
if (data.blocked) {
console.warn(`[${new Date(data.timestamp).toLocaleTimeString()}] ā Update blocked. Error:`, data.error?.message);
} else {
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ā
Update complete. Changed paths: ${data.changedPaths?.join(', ')} (took ${data.duration?.toFixed(2)}ms)`);
}
});
// Subscribe to middleware lifecycle events
store.onStoreEvent('middleware:start', (data) => {
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ā¶ Middleware "${data.name}" (${data.type}) started.`);
});
store.onStoreEvent('middleware:complete', (data) => {
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ā Middleware "${data.name}" (${data.type}) completed in ${data.duration?.toFixed(2)}ms.`);
});
store.onStoreEvent('middleware:error', (data) => {
console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] ā Middleware "${data.name}" failed:`, data.error);
});
store.onStoreEvent('middleware:blocked', (data) => {
console.warn(`[${new Date(data.timestamp).toLocaleTimeString()}] š Middleware "${data.name}" blocked an update.`);
});
store.onStoreEvent('middleware:executed', (data) => {
// This event captures detailed execution info for all middlewares, useful for aggregate metrics.
console.debug(`[${new Date(data.timestamp).toLocaleTimeString()}] š Middleware executed: "${data.name}" - Duration: ${data.duration?.toFixed(2)}ms, Blocked: ${data.blocked}`);
});
// Subscribe to transaction lifecycle events
store.onStoreEvent('transaction:start', (data) => {
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] š¦ Transaction started.`);
});
store.onStoreEvent('transaction:complete', (data) => {
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] š¦ Transaction complete.`);
});
store.onStoreEvent('transaction:error', (data) => {
console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] š¦ Transaction failed:`, data.error);
});
// Subscribe to persistence events
store.onStoreEvent('persistence:ready', (data) => {
console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] š¾ Persistence layer is ready.`);
});
// Add a transform middleware to demonstrate `middleware:start/complete/executed`
store.use({
name: 'ValueIncrementMiddleware',
action: (state, update) => {
return { value: state.value + (update.value || 0) };
},
});
// Add a blocking middleware to demonstrate `middleware:error` and `update:complete` (blocked)
store.use({
name: 'StatusValidationMiddleware',
block: true,
action: (state, update) => {
if (update.status === 'error' && state.value < 10) {
throw new Error('Cannot set status to error if value is too low!');
}
return true;
},
});
// Perform operations to trigger events
console.log('\n--- Perform Initial Update ---');
await store.set({ value: 5, status: 'active' }); // Will increment value by 5 (due to middleware)
console.log('\n--- Perform Transactional Update (Success) ---');
await store.transaction(async () => {
await store.set({ value: 3 }); // Inside transaction, value becomes 5 + 3 = 8
await store.set({ status: 'processing' });
});
console.log('\n--- Perform Update (Blocked by Middleware) ---');
try {
await store.set({ status: 'error' }); // This should be blocked by StatusValidationMiddleware (current value is 8, which is < 10)
} catch (e: any) {
console.log(`Caught expected error: ${e.message}`);
}
console.log('Final value:', store.get().value, 'Final status:', store.get().status);
Project Architecture
The @asaidimu/utils-store
library is built with a modular, component-based architecture to promote maintainability, testability, and extensibility. Each core concern is encapsulated within its own class, with ReactiveDataStore
acting as the central coordinator.
src/store/
āāā index.ts # Main entry point (exports all public APIs)
āāā types.ts # TypeScript interfaces and types for the store
āāā store.ts # `ReactiveDataStore` - The main orchestrator
āāā state.ts # `CoreStateManager` - Manages the immutable state and diffing
āāā merge.ts # `createMerge` - Deep merging utility with deletion support
āāā diff.ts # `createDiff`, `createDerivePaths` - Change detection utilities
āāā middleware.ts # `MiddlewareEngine` - Manages and executes middleware pipeline
āāā transactions.ts # `TransactionManager` - Handles atomic state transactions
āāā persistence.ts # `PersistenceHandler` - Integrates with `SimplePersistence` layer
āāā metrics.ts # `MetricsCollector` - Gathers runtime performance metrics
āāā observability.ts # `StoreObserver` - Debugging and introspection utilities
āāā ... # (Other test files like *.test.ts, internal helpers)
Core Components
ReactiveDataStore<T>
(store.ts
): The public API and primary entry point. It orchestrates interactions between all other internal components. It manages the update queue, ensures sequential processing ofset
calls, and exposes public methods likeget
,set
,subscribe
,transaction
,use
,unuse
, andonStoreEvent
.CoreStateManager<T>
(state.ts
): Responsible for the direct management of the immutable state (cache
). It applies incoming state changes, performs efficient objectdiff
ing to identify modified paths, and notifies internal listeners (via anupdateBus
) about granular state changes.MiddlewareEngine<T>
(middleware.ts
): Manages the registration and execution of bothblocking
andtransform
middleware functions. It ensures middleware execution order, handles potential errors, and emits detailed lifecycle events for observability.PersistenceHandler<T>
(persistence.ts
): Handles integration with an external persistence layer via theSimplePersistence
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).TransactionManager<T>
(transactions.ts
): Provides atomic state operations. It creates a snapshot of the state before anoperation
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.MetricsCollector
(metrics.ts
): Observes the internaleventBus
to gather and expose real-time performance metrics of the store, such as update counts, listener executions, average update times, and the largest update size.StoreObserver<T>
(observability.ts
): An optional, yet highly valuable, debugging companion. It taps into theReactiveDataStore
'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.createMerge
(merge.ts
): A factory function that returns a configurable deep merging utility (MergeFunction
). This utility is crucial for immutably applying partial updates and specifically handlesSymbol.for("delete")
for explicit property removal.createDiff
/createDerivePaths
(diff.ts
): Factory functions returning utilities for efficient comparison between two objects (diff
) to identify changed paths, and for deriving all parent paths from a set of changes (derivePaths
). These are fundamental for optimizing listener notifications and internal change detection.
Data Flow
The ReactiveDataStore
handles state updates in a robust, queued, and event-driven manner:
store.set(update)
call:- If another update is already in progress (
isUpdating
), the newupdate
is queued inpendingUpdates
. This ensures sequential processing and prevents race conditions, and thestore.state().pendingChanges
reflects the queue. - An
update:start
event is immediately emitted.
- If another update is already in progress (
- Middleware Execution:
- The
MiddlewareEngine
first executes allblocking
middlewares (registered viastore.use({ block: true, ... })
). If any blocking middleware returnsfalse
or throws an error, the update is immediately halted. Anupdate:complete
event withblocked: true
is emitted, and the process stops, with the state remaining unchanged. - If not blocked,
transform
middlewares (registered viastore.use({ action: ... })
) are executed sequentially. Each transform middleware receives the current state and the incoming partial update, and can return a newDeepPartial<T>
that is then merged into the effective update payload. - Detailed lifecycle events (
middleware:start
,middleware:complete
,middleware:error
,middleware:blocked
,middleware:executed
) are emitted during this phase, providing granular insight into middleware behavior.
- The
- State Application:
- The
CoreStateManager
receives the (potentially transformed) finalDeepPartial
update. - It internally uses the
createMerge
utility to immutably construct the new full state object. - It then performs a
createDiff
comparison between the previous state and the new state to precisely identify allchangedPaths
(an array of strings). - If changes are detected, the
CoreStateManager
updates its internal immutablecache
to thenewState
and then emits an internalupdate
event for each granularchangedPath
on itsupdateBus
.
- The
- Listener Notification:
- Any external subscribers (registered with
store.subscribe()
) whose registered paths match or are parent paths of thechangedPaths
are efficiently notified with the latest state. TheMetricsCollector
trackslistenerExecutions
during this phase.
- Any external subscribers (registered with
- Persistence Handling:
- The
PersistenceHandler
receives thechangedPaths
and the new state. If aSimplePersistence
implementation was configured during store initialization, it attempts to save the new state usingpersistence.set()
. - The
PersistenceHandler
also manages loading initial state and reacting to external state changes (e.g., from other browser tabs or processes) throughpersistence.subscribe()
.
- The
- Completion & Queue Processing:
- An
update:complete
event is emitted, containing crucial information about the update's duration, thechangedPaths
, and any blocking errors. - The
isUpdating
flag is reset. If there are anypendingUpdates
in the queue, the next update is immediately pulled and processed, ensuring all queued updates are eventually applied in order.
- An
Extension Points
- Custom Middleware: Developers can inject their own
Middleware
(for transformation) andBlockingMiddleware
(for validation/prevention) functions usingstore.use()
. This allows for highly customizable update logic, centralized logging, complex validation, authorization, or triggering specific side effects. - 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.
Development & Contributing
Contributions are welcome! Follow these guidelines to get started with local development and contribute to the project.
Development Setup
- Clone the repository:
git clone https://github.com/asaidimu/erp-utils.git cd erp-utils
- Install dependencies:
The
@asaidimu/utils-store
module is part of a monorepo managed withpnpm
workspaces. Ensure you havepnpm
installed globally.pnpm install # If you don't have pnpm installed globally: npm install -g pnpm
Build the project: Navigate to the
store
package directory and run the build script, or build the entire monorepo from the root.# From the monorepo root: pnpm build # Builds all packages in the monorepo # Or, from the `src/store` directory: cd src/store pnpm build
Scripts
From the src/store
directory, the following pnpm
scripts are available:
pnpm test
: Runs all unit tests using Vitest.pnpm test:watch
: Runs tests in watch mode for continuous development.pnpm lint
: Lints the codebase using ESLint.pnpm format
: Formats the code using Prettier.pnpm build
: Compiles TypeScript to JavaScript (CommonJS and ES Modules) and generates declaration files (.d.ts
).
Testing
All tests are written using Vitest, a fast unit test framework powered by Vite.
To run tests:
cd src/store
pnpm test
To run tests and watch for changes during development:
cd src/store
pnpm test:watch
Please ensure all new features have comprehensive test coverage and all existing tests pass before submitting a pull request.
Contributing Guidelines
- Fork the repository and create your branch from
main
. - Ensure code quality: Write clean, readable, and maintainable code. Adhere to existing coding styles, which are enforced by ESLint and Prettier.
- Tests: Add comprehensive unit and integration tests for new features or bug fixes. Ensure all existing tests pass.
- Commit messages: Use Conventional Commits for clear and consistent commit history (e.g.,
feat: add new feature
,fix: resolve bug
,chore: update dependencies
). - Pull Requests: Open a pull request to the
main
branch of theasaidimu/erp-utils
repository. Clearly describe your changes, provide context, and link to any relevant issues.
Issue Reporting
If you find a bug or have a feature request, please open an issue on the GitHub repository.
- For bug reports, include steps to reproduce, expected behavior, and actual behavior. Provide relevant code snippets or a minimal reproducible example.
- For feature requests, describe the use case, the problem it solves, and your proposed solution or ideas.
Additional Information
Troubleshooting
- "Update not triggering listeners":
- Ensure you are subscribing to the correct path.
store.subscribe('user.name', ...)
will not trigger if you updateuser.email
(unless you also subscribe touser
or the root''
path). - If the new value is strictly equal (
===
) to the old value, no change will be detected by the internaldiff
function, and listeners will not be notified. - Verify your
DeepPartial
update correctly targets the intended part of the state.
- Ensure you are subscribing to the correct path.
- "State not rolling back after transaction error":
- 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. - Promises within the transaction must be
await
ed so theTransactionManager
can capture potential rejections and manage the atomic operation correctly.
- Ensure the error is thrown within the
- "Middleware not being applied":
- Verify the middleware is registered with
store.use()
before theset
operation it should affect. Middleware functions are applied in the order they are registered. - Check middleware
name
in console logs (ifStoreObserver
is enabled withenableConsoleLogging: true
) to confirm it's being hit. - Ensure your
transform
middleware returns aDeepPartial<T>
orvoid
/Promise<void>
, andblocking
middleware returnsboolean
/Promise<boolean>
.
- Verify the middleware is registered with
- "Performance warnings in console":
- If
StoreObserver
is enabled withenableConsoleLogging: true
, it will warn about updates or middlewares exceeding definedperformanceThresholds
. This is an informational warning, not an error, indicating a potentially slow operation that could be optimized in your application.
- If
FAQ
Q: How are arrays handled during updates?
A: Arrays are treated as primitive values. When you provide an array in a DeepPartial
update (e.g., set({ items: [new Array] })
), the entire array at that path is replaced with the new array, not merged element-by-element. This ensures predictable behavior and avoids complex, potentially ambiguous partial array merging logic.
Q: What is Symbol.for("delete")
?
A: Symbol.for("delete")
is a special global symbol used to explicitly remove a property from the state during a set
operation. If you pass Symbol.for("delete")
as the value for a key in your DeepPartial
update, that key will be removed from the resulting state object. This provides a clear semantic for deletion.
Q: How do I debug my store's state changes?
A: The StoreObserver
class is your primary tool. Instantiate it with your ReactiveDataStore
instance. It provides methods to get event history, state snapshots, and even time-travel debugging capabilities. Enabling enableConsoleLogging: true
in StoreObserver
options provides immediate, formatted console output during development, showing update lifecycles, middleware execution, and transaction events.
Q: What is SimplePersistence<T>
?
A: It's a minimal interface that defines the contract for any persistence layer you wish to integrate. It requires get()
, set(instanceId, state)
, and subscribe(instanceId, listener)
methods. You need to provide an implementation of this interface to enable state saving and loading. An example InMemoryPersistence
implementation is provided in the Persistence Integration section.
Q: Can I use this with React/Vue/Angular or other UI frameworks?
A: Yes, absolutely. This is a framework-agnostic state management library. You would typically use the subscribe
method within your framework's lifecycle hooks (e.g., useEffect
in React, onMounted
in Vue) to react to state changes and update your UI components. The library's reactivity model is independent of any specific framework.
Changelog / Roadmap
- Changelog: For detailed version history, including new features, bug fixes, and breaking changes, please refer to the project's CHANGELOG.md file.
- Roadmap: Future plans for
@asaidimu/utils-store
may include:- Official framework-specific integrations (e.g., React hooks library for easier consumption).
- More advanced query/selector capabilities with built-in memoization for derived state.
- Built-in serialization/deserialization options for persistence, perhaps with schema validation.
- Higher-order middlewares for common patterns (e.g., async data fetching, debouncing updates).
- Further performance optimizations for very large states or high update frequencies.
License
This project is licensed under the MIT License.
Acknowledgments
- Inspired by modern state management patterns such as Redux, Zustand, and Vuex, emphasizing immutability and explicit state changes.
- Leverages the
@asaidimu/events
package for robust internal event bus capabilities. - Utilizes the
uuid
library for generating unique instance IDs.