npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@asaidimu/utils-store

v7.0.1

Published

A reactive data store

Readme

@asaidimu/utils-store

A Type-Safe Reactive Data Store for TypeScript Applications

A comprehensive, type-safe, and reactive state management library for TypeScript applications, featuring robust middleware, atomic transactions, dependency injection for services (artifacts), deep observability, and an optional persistence layer. It simplifies complex state interactions by promoting immutability, explicit updates, and a modular design.

npm version License Build Status


Quick Links


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, a powerful dependency injection system for services (artifacts), 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.
  • 🚀 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.
  • 🔍 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.
  • 🧠 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 or void 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.
  • 📦 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 a persistence:ready event and listens for external updates, handling persistence queueing and retries.
  • 🧩 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.
  • 👀 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, action:start, selector:accessed, 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 and redo 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.
    • Session Management: Save and load observer sessions for offline analysis or sharing bug reproductions.
  • 🗑️ 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. Modern TS features (ES2017+ for async/await, ES2020+ for Symbol.for() and structuredClone()) are utilized. moduleResolution: Node16 or Bundler is recommended in tsconfig.json.

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.watch("count", (state) => {
  console.log(`Count changed to: ${state.count}`);
});

// Subscribing to "" (empty string) will log for any store update
store.watch("", (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, DELETE_SYMBOL, type DeepPartial } from '@asaidimu/utils-store';

// 1. Define your state interface for type safety
interface AppState {
  user: {
    id: string;
    name: string;
    email?: string; // email is optional as we'll delete it
    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: '[email protected]',
    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: '[email protected]', 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: '[email protected]', 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.watch('user', (state) => {
  console.log('User data changed:', state.user);
});

const unsubscribeNotifications = store.watch('settings.notificationsEnabled', (state) => {
  console.log('Notifications setting changed:', state.settings.notificationsEnabled);
});

// Subscribe to multiple paths at once
const unsubscribeMulti = store.watch(['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.watch('', (state) => {
  console.log('Store updated (any path changed). Current products count:', state.products.length);
});

await store.set({ user: { email: '[email protected]' } });
/* Output (order may vary slightly depending on async operations):
User data changed: { id: '123', name: 'Jane Smith', email: '[email protected]', 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 `DELETE_SYMBOL` (exported from the library) to explicitly remove a property from the state.
await store.set({
  user: {
    email: DELETE_SYMBOL as DeepPartial<string> // Type cast is needed for strict TypeScript environments
  }
});
console.log('User email after deletion:', store.get().user.email);
// Output: User email after deletion: undefined

Actions & Dispatch

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.

import { ReactiveDataStore } from '@asaidimu/utils-store';

interface CounterState {
  value: number;
  history: string[];
}

const store = new ReactiveDataStore<CounterState>({ value: 0, history: [] });

// 1. Register an action
store.register({
  name: 'incrementCounter',
  fn: (state, amount: number) => ({ // Action function receives state and dispatched parameters
    value: state.value + amount,
    history: [...state.history, `Incremented by ${amount} at ${new Date().toLocaleTimeString()}`],
  }),
});

// 2. Register an action with debounce
store.register({
  name: 'incrementCounterDebounced',
  fn: (state, amount: number) => ({
    value: state.value + amount,
    history: [...state.history, `Debounced Increment by ${amount} at ${new Date().toLocaleTimeString()}`],
  }),
  debounce: {
    delay: 100, // Debounce for 100ms
    // Optional: condition to decide whether to debounce.
    // Here, we always debounce if `amount` is different from previous.
    condition: (previousArgs, currentArgs) => previousArgs?.[0] !== currentArgs[0],
  }
});

// 3. Register an async action
store.register({
  name: 'loadInitialValue',
  fn: async (state, userId: string) => {
    console.log(`Simulating loading initial value for user: ${userId}`);
    await new Promise(resolve => setTimeout(resolve, 200)); // Simulate API call
    return {
      value: 100, // Fetched value
      history: [...state.history, `Loaded initial value for ${userId}`],
    };
  },
});

// 4. Dispatch actions
console.log('Initial value:', store.get().value); // 0

await store.dispatch('incrementCounter', 5);
console.log('Value after incrementCounter(5):', store.get().value); // 5

await store.dispatch('incrementCounter', 10);
console.log('Value after incrementCounter(10):', store.get().value); // 15

// Dispatch debounced actions multiple times quickly
store.dispatch('incrementCounterDebounced', 1);
store.dispatch('incrementCounterDebounced', 2);
store.dispatch('incrementCounterDebounced', 3);
console.log('Value after immediate debounced dispatches (still 15, waiting for debounce):', store.get().value);

// Wait for the debounce to complete
await new Promise(resolve => setTimeout(150, resolve));
console.log('Value after debounced dispatches settled (only last one applied):', store.get().value); // 15 + 3 = 18

// Dispatch an async action
await store.dispatch('loadInitialValue', 'user-abc');
console.log('Value after loadInitialValue:', store.get().value); // 100 (overwritten by fetched value)
console.log('History:', store.get().history);

// 5. Deregister an action
const deregisterRisky = store.register({
  name: 'riskyAction',
  fn: () => { throw new Error('Risky action!'); }
});

deregisterRisky(); // Action is now removed

try {
  await store.dispatch('riskyAction');
} catch (error: any) {
  console.error('Expected error when dispatching deregistered action:', error.message);
}

Reactive Selectors

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.

import { ReactiveDataStore } from '@asaidimu/utils-store';

interface UserProfile {
  firstName: string;
  lastName: string;
  email: string;
  address: {
    city: string;
    zipCode: string;
  };
  isActive: boolean;
  friends: string[];
}

const store = new ReactiveDataStore<UserProfile>({
  firstName: 'John',
  lastName: 'Doe',
  email: '[email protected]',
  address: {
    city: 'New York',
    zipCode: '10001',
  },
  isActive: true,
  friends: ['Alice', 'Bob'],
});

// Create a reactive selector for the full name
const selectFullName = store.select((state) => `${state.firstName} ${state.lastName}`);

// Create a reactive selector for user's location
const selectLocation = store.select((state) => `${state.address.city}, ${state.address.zipCode}`);

// Create a reactive selector for user's active status
const selectIsActive = store.select((state) => state.isActive);

// Create a reactive selector for the number of friends
const selectFriendCount = store.select((state) => state.friends.length);

let lastFullName = '';
let lastLocation = '';
let lastIsActive = false;
let lastFriendCount = 0;

// Subscribe to the full name selector
const unsubscribeFullName = selectFullName.subscribe((fullName) => {
  lastFullName = fullName;
  console.log('Full Name Changed:', lastFullName);
});

// Subscribe to the location selector
const unsubscribeLocation = selectLocation.subscribe((location) => {
  lastLocation = location;
  console.log('Location Changed:', lastLocation);
});

// Subscribe to the active status selector
const unsubscribeIsActive = selectIsActive.subscribe((isActive) => {
  lastIsActive = isActive;
  console.log('Is Active Changed:', lastIsActive);
});

// Subscribe to the friend count selector
const unsubscribeFriendCount = selectFriendCount.subscribe((count) => {
  lastFriendCount = count;
  console.log('Friend Count Changed:', lastFriendCount);
});


// Get initial values
lastFullName = selectFullName.get();
lastLocation = selectLocation.get();
lastIsActive = selectIsActive.get();
lastFriendCount = selectFriendCount.get();
console.log('Initial Full Name:', lastFullName);
console.log('Initial Location:', lastLocation);
console.log('Initial Is Active:', lastIsActive);
console.log('Initial Friend Count:', lastFriendCount);


console.log('\n--- Performing Updates ---');

// Update first name (should trigger selectFullName)
await store.set({ firstName: 'Jane' });
// Output: Full Name Changed: Jane Doe

await store.set({ lastName: 'Smith' }); // Should trigger selectFullName
// Output: Full Name Changed: Jane Smith

await store.set({ address: { city: 'Los Angeles' } }); // Should trigger selectLocation
// Output: Location Changed: Los Angeles, 10001

await store.set({ isActive: false }); // Should trigger selectIsActive
// Output: Is Active Changed: false

await store.set({ email: '[email protected]' }); // Should NOT trigger any of the above selectors directly
// No output from subscribed selectors

await store.set((state) => ({ friends: [...state.friends, 'Charlie'] })); // Should trigger selectFriendCount
// Output: Friend Count Changed: 3

console.log('\n--- Current Values ---');
console.log('Current Full Name:', selectFullName.get());
console.log('Current Location:', selectLocation.get());
console.log('Current Is Active:', selectIsActive.get());
console.log('Current Friend Count:', selectFriendCount.get());

// Cleanup
unsubscribeFullName();
unsubscribeLocation();
unsubscribeIsActive();
unsubscribeFriendCount();

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. You can use @asaidimu/utils-persistence for concrete implementations or provide your own.

import { ReactiveDataStore, type StoreEvent } from '@asaidimu/utils-store';
import { v4 as uuidv4 } from 'uuid'; // For generating unique instance IDs

// Define the SimplePersistence interface (from `@asaidimu/utils-persistence` or your own)
interface SimplePersistence<T extends object> {
  get(): T | null | Promise<T | null>; // Can be synchronous or asynchronous
  set(instanceId: string, state: T): boolean | Promise<boolean>; // Can be synchronous or asynchronous
  subscribe?(instanceId: string, listener: (state: T) => void): () => void; // Optional: for external change detection
  clear?(): boolean | Promise<boolean>; // Optional: for clearing persisted data
}

// 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();
  private uniqueStoreId: string; // Acts as the storageKey/store identifier

  constructor(uniqueStoreId: string, initialData: T | null = null) {
      this.uniqueStoreId = uniqueStoreId;
      this.data = initialData;
  }

  get(): T | null { // Synchronous get for simplicity
    console.log(`Persistence [${this.uniqueStoreId}]: Loading state...`);
    return this.data ? structuredClone(this.data) : null;
  }

  async set(instanceId: string, state: T): Promise<boolean> {
    console.log(`Persistence [${this.uniqueStoreId}]: 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) {
        // Ensure to queue the microtask for async notification to prevent synchronous re-entry issues
        queueMicrotask(() => callback(structuredClone(this.data!))); // Pass a clone to prevent mutation
      }
    });
    return true;
  }

  subscribe(instanceId: string, callback: (state: T) => void): () => void {
    console.log(`Persistence [${this.uniqueStoreId}]: Subscribing to external changes for instance ${instanceId}`);
    this.subscribers.set(instanceId, callback);
    return () => {
      console.log(`Persistence [${this.uniqueStoreId}]: Unsubscribing for instance ${instanceId}`);
      this.subscribers.delete(instanceId);
    };
  }
}

interface UserConfig {
  theme: 'light' | 'dark' | 'system';
  fontSize: number;
}

// Create a persistence instance, possibly with some pre-existing data
// The 'my-user-config' string acts as the unique identifier for this particular data set in persistence
const userConfigPersistence = new InMemoryPersistence<UserConfig>('my-user-config', { 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
  // You can also pass persistence options like retries and delay
  undefined, // Use default DELETE_SYMBOL
  { persistenceMaxRetries: 5, persistenceRetryDelay: 2000 }
);

// Optionally, listen for persistence readiness (important for UIs that depend on loaded state)
const storeReadyPromise = new Promise<void>(resolve => {
  store.on('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(uuidv4(), { 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 undefined if no transformation is needed for other paths
    return undefined;
  },
});

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: 0 (initial) + 5 (update) = 5 (due to CounterIncrementMiddleware)
  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 }
const removed = temporaryLoggerId(); // Remove the temporary logger
console.log('Middleware removed:', removed);
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)
}

Artifacts (Dependency Injection)

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.

import { ReactiveDataStore, ArtifactContainer, ArtifactScope } from '@asaidimu/utils-store';

interface AppState {
    user: { id: string; name: string; };
    config: { apiUrl: string; logLevel: string; };
}

// Mock DataStore interface for standalone ArtifactContainer usage
const store = new ReactiveDataStore<AppState>({
    user: { id: 'user-1', name: 'Alice' },
    config: { apiUrl: '/api/v1', logLevel: 'info' }
});

const container = new ArtifactContainer(store);

// --- Artifact Definitions ---

// Artifact 1: Simple Logger (Transient) - new instance every time
container.register({
    key: 'logger',
    scope: ArtifactScope.Transient,
    factory: () => {
        console.log('Logger artifact created (Transient)');
        return {
            log: (message: string) => console.log(`[LOG] ${message}`)
        };
    }
});

// Artifact 2: API Client (Singleton) - depends on state.config.apiUrl
const apiClientCleanup = () => console.log('API Client connection closed.');
container.register({
    key: 'apiClient',
    scope: ArtifactScope.Singleton,
    factory: async ({ use, onCleanup, current }) => {
        const apiUrl = await use(({ select }) => select((state: AppState) => state.config.apiUrl));
        console.log(`API Client created/re-created for URL: ${apiUrl}`);
        if (current) {
            console.log('Re-creating API client. Old instance:', current);
        }
        onCleanup(apiClientCleanup); // Register cleanup for this instance
        return {
            fetchUser: (id: string) => `Fetching ${id} from ${apiUrl}`,
            sendData: (data: any) => `Sending ${JSON.stringify(data)} to ${apiUrl}`
        };
    }
});

// Artifact 3: User Service (Singleton) - depends on 'apiClient' and state.user.name
const userServiceCleanup = () => console.log('User Service resources released.');
container.register({
    key: 'userService',
    scope: ArtifactScope.Singleton,
    factory: async ({ use, onCleanup, current }) => {
        const apiClient = await use(({ resolve }) => resolve('apiClient'));
        const userName = await use(({ select }) => select((state: AppState) => state.user.name));
        console.log(`User Service created/re-created for user: ${userName}`);
        if (current) {
            console.log('Re-creating User Service. Old instance:', current);
        }
        onCleanup(userServiceCleanup); // Register cleanup for this instance
        return {
            getUserProfile: () => apiClient.instance!.fetchUser(store.get().user.id),
            updateUserName: (newName: string) => `Updating user name to ${newName} via API. Current: ${userName}`
        };
    }
});

// --- Usage ---

async function runDemo() {
    console.log('\n--- Initial Artifact Resolution ---');
    const logger1 = await container.resolve('logger');
    logger1.instance!.log('Application started.');

    const apiClient1 = await container.resolve('apiClient');
    console.log(apiClient1.instance!.fetchUser('123'));

    const userService1 = await container.resolve('userService');
    console.log(userService1.instance!.getUserProfile());

    // Transient artifact resolves a new instance
    const logger2 = await container.resolve('logger');
    console.log('Logger instances are different:', logger1.instance !== logger2.instance);

    // Singleton artifacts resolve the same instance initially
    const apiClient2 = await container.resolve('apiClient');
    console.log('API Client instances are same:', apiClient1.instance === apiClient2.instance);

    console.log('\n--- Simulate State Change (config.apiUrl) ---');
    await store.set({ config:{ apiUrl: '/api/v2'}})
    // This state change invalidates 'apiClient' which depends on 'config.apiUrl'
    // and then invalidates 'userService' which depends on 'apiClient'.

    // After config.apiUrl changes, apiClient (and userService) should be re-created
    const apiClient3 = await container.resolve('apiClient'); // This will trigger re-creation
    console.log(apiClient3.instance!.fetchUser('123'));
    console.log('API Client instances are different:', apiClient3.instance !== apiClient1.instance); // Should be a new instance

    // userService should also be re-created because apiClient, its dependency, was re-created
    const userService2 = await container.resolve('userService');
    console.log(userService2.instance!.getUserProfile());
    console.log('User Service instances are different:', userService2.instance !== userService1.instance); // Should be a new instance

    console.log('\n--- Simulate State Change (user.name) ---');
    await store.set({ user: { name: 'Bob' } });

    // Only userService (which depends on user.name) should be re-created this time, not apiClient
    const apiClient4 = await container.resolve('apiClient');
    console.log('API Client instances are same:', apiClient4.instance === apiClient3.instance); // Still the same API client instance

    const userService3 = await container.resolve('userService');
    console.log(userService3.instance!.getUserProfile());
    console.log('User Service instances are different:', userService3.instance !== userService2.instance); // New user service instance

    console.log('\n--- Unregistering Artifacts ---');
    await container.unregister('userService');
    // Output: User Service resources released. (cleanup called)
    await container.unregister('apiClient');
    // Output: API Client connection closed. (cleanup called)

    try {
        await container.resolve('userService');
    } catch (e: any) {
        console.error('Expected error:', e.message); // Artifact not found
    }
}

runDemo();

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
    actions: true,           // Log action start/complete/error events
    selectors: true,         // Log selector accessed/changed 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.state.messages.join(', ')}, User Status: ${snapshot.state.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.length()); // 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: any) {
    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.on(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.on('update:start', (data) => {
  console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ⚡ Update started. Action ID: ${data.actionId || 'N/A'}`);
});

// Subscribe to 'update:complete' event - triggered after an update is fully applied or blocked.
store.on('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.deltas?.map((d:any) => d.path).join(', ')} (took ${data.duration?.toFixed(2)}ms)`);
  }
});

// Subscribe to middleware lifecycle events
store.on('middleware:start', (data) => {
  console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ▶ Middleware "${data.name}" (${data.type || 'transform'}) started.`);
});

store.on('middleware:complete', (data) => {
  console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ◀ Middleware "${data.name}" (${data.type || 'transform'}) completed in ${data.duration?.toFixed(2)}ms.`);
});

store.on('middleware:error', (data) => {
  console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] ❌ Middleware "${data.name}" failed:`, data.error);
});

store.on('middleware:blocked', (data) => {
  console.warn(`[${new Date(data.timestamp).toLocaleTimeString()}] 🛑 Middleware "${data.name}" blocked an update.`);
});

store.on('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.on('transaction:start', (data) => {
  console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] 📦 Transaction started.`);
});

store.on('transaction:complete', (data) => {
  console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] 📦 Transaction complete.`);
});

store.on('transaction:error', (data) => {
  console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] 📦 Transaction failed:`, data.error);
});

// Subscribe to persistence events
store.on('persistence:ready', (data) => {
  console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] 💾 Persistence layer is ready.`);
});
store.on('persistence:queued', (data) => {
    console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ⏳ Persistence task queued: ${data.taskId}, queue size: ${data.queueSize}`);
});
store.on('persistence:success', (data) => {
    console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ✅ Persistence success for task: ${data.taskId}, took ${data.duration?.toFixed(2)}ms`);
});
store.on('persistence:retry', (data) => {
    console.warn(`[${new Date(data.timestamp).toLocaleTimeString()}] 🔄 Persistence retry for task: ${data.taskId}, attempt ${data.attempt}/${data.maxRetries}`);
});
store.on('persistence:failed', (data) => {
    console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] ❌ Persistence failed permanently for task: ${data.taskId}`, data.error);
});


// Subscribe to action events
store.on('action:start', (data) => {
  console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] 🚀 Action "${data.name}" (ID: ${data.actionId}) started with params:`, data.params);
});

store.on('action:complete', (data) => {
  console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] ✔️ Action "${data.name}" (ID: ${data.actionId}) completed in ${data.duration?.toFixed(2)}ms.`);
});

store.on('action:error', (data) => {
  console.error(`[${new Date(data.timestamp).toLocaleTimeString()}] 🔥 Action "${data.name}" (ID: ${data.actionId}) failed:`, data.error);
});

// Subscribe to selector events
store.on('selector:accessed', (data) => {
    console.debug(`[${new Date(data.timestamp).toLocaleTimeString()}] 👀 Selector (ID: ${data.selectorId}) accessed paths: ${data.accessedPaths.join(', ')}`);
});

store.on('selector:changed', (data) => {
    console.log(`[${new Date(data.timestamp).toLocaleTimeString()}] 📢 Selector (ID: ${data.selectorId}) changed. New result:`, data.newResult);
});


// 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.

Core Components

  • 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.
  • StateManager<T>: Responsible for the direct management of the immutable state (cache). It applies incoming state changes, performs efficient object diffing to identify modified paths, and notifies internal listeners (via an updateBus) about granular state changes.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

Data Flow

The ReactiveDataStore handles state updates in a robust, queued, and event-driven manner:

  1. store.set(update) or store.dispatch(actionName, actionFn, ...) call:
    • 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.
    • All set calls are automatically queued to prevent race conditions during concurrent updates, ensuring sequential processing. The store.state().pendingChanges reflects the queue.
    • An update:start event is immediately emitted.
  2. Middleware Execution:
    • 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.
    • 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.
    • Detailed lifecycle events (middleware:start, middleware:complete, middleware:error, middleware:blocked, middleware:executed) are emitted during this phase, providing granular insight into middleware behavior.
  3. State Application:
    • The StateManager receives the (potentially transformed) final DeepPartial 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 all changedPaths (an array of StateDelta objects).
    • 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.
  4. Listener Notification:
    • 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.
    • 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.
    • ArtifactContainer also receives change notifications for state paths its artifacts depend on, triggering re-evaluation for Singleton scoped artifacts.
  5. Persistence Handling:
    • 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.
    • The PersistenceHandler also manages loading initial state and reacting to external state changes (e.g., from other browser tabs or processes) through persistence.subscribe().
  6. Completion & Queue Processing:
    • An update:complete event is emitted, containing crucial information about the update's duration, the StateDelta[], and any blocking errors.
    • 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.
    • The update queue automatically processes the next pending update.

Extension Points

  • 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.
  • 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.
  • 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.

Development & Contributing

Contributions are welcome! Follow these guidelines to get started with local development and contribute to the project.

Development Setup

  1. Clone the repository:
    git clone https://github.com/asaidimu/erp-utils.git
    cd erp-utils
  2. Install dependencies: The @asaidimu/utils-store module is part of a monorepo managed with pnpm workspaces. Ensure you have pnpm installed globally.
    pnpm install
    # If you don't have pnpm installed globally: npm install -g pnpm
  3. 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 b