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-remote-store

v1.2.3

Published

A reactive store for remote data, built on top of @asaidimu/utils-cache

Readme

Reactive Remote Store

An intelligent, reactive data management library for JavaScript/TypeScript applications, designed for optimal performance, real-time data synchronization, and seamless integration with any remote data source.

npm version License TypeScript


🚀 Quick Links


📦 Overview & Features

ReactiveRemoteStore provides a robust, reactive data management solution for applications that interact with remote APIs. It intelligently handles data fetching, caching, and synchronization, ensuring your application always has access to up-to-date information while minimizing network overhead and improving user experience. By integrating with a QueryCache and a BaseStore (your API client), it abstracts away the complexities of data lifecycle management, allowing developers to focus on application logic.

Key aspects of ReactiveRemoteStore include:

  • Reactive Queries: Data is exposed through observable query results that automatically update when the underlying data changes, whether from local mutations or external server notifications.
  • Intelligent Caching: It leverages a QueryCache to store data, reducing redundant API calls and providing instant access to previously fetched information.
  • Automatic Invalidation: Mutations (create, update, delete, upload) automatically trigger intelligent cache invalidation, ensuring data consistency across your application.
  • Real-time Synchronization: Through the BaseStore's implementation of subscribe and notify (which can utilize technologies like Server-Sent Events, WebSockets, or other real-time protocols) and custom event correlators, ReactiveRemoteStore can react to external changes pushed from your backend, keeping your client-side data in sync with the server in real-time.
  • Pluggable BaseStore: The library is agnostic to your specific API client. You provide an implementation of the BaseStore interface, allowing ReactiveRemoteStore to work with any REST, GraphQL, or custom API.
  • Data Streaming: Supports consuming continuous data streams from your backend, ideal for dashboards, live feeds, or large datasets.

Key Features

  • Reactive Data Access: Consume data through observable queries (read, list, find) that automatically update when data changes.
  • Query Caching: Efficiently caches data from read, list, and find operations.
  • Automatic Invalidation: Cache entries are intelligently invalidated upon create, update, delete, and upload mutations.
  • Real-time Event Integration: Reacts to real-time data changes pushed from the BaseStore (via subscribe and notify methods, supporting various protocols like SSE or WebSockets).
  • Custom Invalidation Logic (Correlators): Define custom functions (Correlator and StoreEventCorrelator) to precisely control which cached queries are invalidated based on mutations or incoming store events.
  • Data Streaming: Provides a robust mechanism for consuming real-time data streams from the BaseStore via an AsyncIterable interface.
  • Prefetching & Refreshing: Imperative methods (prefetch, refresh) to proactively load data or force re-fetches, optimizing user experience.
  • Type-Safe: Fully written in TypeScript, providing strong typing for enhanced developer experience, compile-time safety, and autocompletion.
  • Error Handling: Centralized error handling for data operations.

🛠️ Installation & Setup

Prerequisites

  • Node.js (v18.x or higher recommended)
  • bun, npm, or yarn package manager
  • A QueryCache implementation (e.g., @core/cache if available in your project, or a custom one).
  • An implementation of the BaseStore interface that connects to your remote data source.

Installation Steps

To install ReactiveRemoteStore and its peer dependencies, use your preferred package manager:

bun add @asaidimu/erp-utils-remote-store @core/cache
# or
npm install @asaidimu/erp-utils-remote-store @core/cache
# or
yarn add @asaidimu/erp-utils-remote-store @core/cache

Configuration

ReactiveRemoteStore is initialized with a QueryCache instance and an implementation of the BaseStore interface. Optionally, you can provide correlator and storeEventCorrelator functions for advanced invalidation logic.

import { ReactiveRemoteStore } from '@asaidimu/erp-utils-remote-store';
import { QueryCache } from '@core/cache'; // Your QueryCache implementation
import { BaseStore, Record, Page, StoreEvent, ActiveQuery, MutationOperation } from '@asaidimu/erp-utils-remote-store/types';

// --- Example: Define your data types ---
interface Product extends Record {
    name: string;
    price: number;
    inStock: boolean;
}

// --- Example: Implement your BaseStore (API client) ---
// This would typically interact with your backend via fetch, axios, etc.
class MyApiBaseStore implements BaseStore<Product> {
    private baseUrl: string;
    constructor(baseUrl: string) { this.baseUrl = baseUrl; }

    async find(options: any): Promise<Page<Product>> { /* ... API call ... */ return { data: [], page: { number: 1, size: 0, count: 0, pages: 0 } }; }
    async read(options: { id: string }): Promise<Product | undefined> { /* ... API call ... */ return undefined; }
    async list(options: any): Promise<Page<Product>> { /* ... API call ... */ return { data: [], page: { number: 1, size: 0, count: 0, pages: 0 } }; }
    async create(props: { data: Omit<Product, "id">; options?: any; }): Promise<Product | undefined> { /* ... API call ... */ return { id: 'new-id', ...props.data }; }
    async update(props: { id: string; data: Partial<Omit<Product, "id">>; options?: any; }): Promise<Product | undefined> { /* ... API call ... */ return { id: props.id, ...props.data }; }
    async delete(options: { id: string; }): Promise<void> { /* ... API call ... */ }
    async upload(props: { file: File; options?: any; }): Promise<Product | undefined> { /* ... API call ... */ return { id: 'uploaded-id', name: 'uploaded', price: 0, inStock: true }; }

    // For real-time updates, your `BaseStore` implementation would establish and manage
    // a connection using technologies like Server-Sent Events (SSE), WebSockets, or polling.
    // The `ReactiveRemoteStore` simply calls these methods on your `BaseStore`.
    async subscribe(scope: string, callback: (event: StoreEvent) => void): Promise<() => void> {
        console.log(`Subscribing to ${scope}`);
        // Example: Connect to an SSE endpoint (as in test-server.ts)
        // const eventSource = new EventSource(`${this.baseUrl}/events?scope=${scope}`);
        // eventSource.onmessage = (e) => callback(JSON.parse(e.data));
        // return () => eventSource.close();

        // Example: Using WebSockets
        // const ws = new WebSocket(`${this.baseUrl}/ws?scope=${scope}`);
        // ws.onmessage = (e) => callback(JSON.parse(e.data));
        // return () => ws.close();

        return async () => { console.log(`Unsubscribed from ${scope}`); };
    }
    async notify(event: StoreEvent): Promise<void> {
        console.log('Notifying base store:', event);
        // Your implementation here to send the event to the backend
        // e.g., via a POST request, WebSocket message, etc.
        // await fetch(`${this.baseUrl}/notify`, { method: 'POST', body: JSON.stringify(event) });
    }
    stream(options: any): { stream: () => AsyncIterable<Product>; cancel: () => void; status: () => "active" | "cancelled" | "completed"; } {
        console.log('Streaming with options:', options);
        const mockStream = (async function* () {
            yield { id: 'p1', name: 'Streamed Product 1', price: 10, inStock: true };
            await new Promise(r => setTimeout(r, 100));
            yield { id: 'p2', name: 'Streamed Product 2', price: 20, inStock: false };
        })();
        return { stream: () => mockStream, cancel: () => console.log('Stream cancelled'), status: () => 'completed' };
    }
}

// --- Optional: Define custom correlators for invalidation ---
const myCorrelator = (
    mutation: { operation: MutationOperation; params: any },
    activeQueries: ActiveQuery[]
): string[] => {
    // Example: Invalidate 'list' queries on any create/delete operation
    if (mutation.operation === 'create' || mutation.operation === 'delete') {
        return activeQueries.filter(q => q.operation === 'list').map(q => q.queryKey);
    }
    // Example: Invalidate specific 'read' query if its ID matches the updated item
    if (mutation.operation === 'update' && mutation.params.id) {
        return activeQueries
            .filter(q => q.operation === 'read' && q.params.id === mutation.params.id)
            .map(q => q.queryKey);
    }
    return [];
};

const myStoreEventCorrelator = (
    event: StoreEvent, // { scope: string, payload?: any }
    activeQueries: ActiveQuery[]
): string[] => {
    // Example: Invalidate 'read' query if an external event updates its ID
    if (event.scope === 'product:updated:external' && event.payload?.id) {
        return activeQueries
            .filter(q => q.operation === 'read' && q.params.id === event.payload.id)
            .map(q => q.queryKey);
    }
    return [];
};

// --- Initialize ReactiveRemoteStore ---
const cache = new QueryCache();
const baseStore = new MyApiBaseStore('https://api.example.com');

const productStore = new ReactiveRemoteStore<Product>(
    cache,
    baseStore,
    myCorrelator,          // Optional: for mutation-based invalidation
    myStoreEventCorrelator // Optional: for store event-based invalidation
);

console.log('ReactiveRemoteStore initialized successfully!');

Verification

To verify that ReactiveRemoteStore is installed and initialized correctly, you can run a simple test:

import { ReactiveRemoteStore } from '@asaidimu/erp-utils-remote-store';
import { QueryCache } from '@core/cache';
import { BaseStore, Record, Page, StoreEvent } from '@asaidimu/erp-utils-remote-store/types';

// Minimal BaseStore for verification
class MinimalBaseStore implements BaseStore<Record> {
    async find(): Promise<Page<Record>> { return { data: [], page: { number: 1, size: 0, count: 0, pages: 0 } }; }
    async read(): Promise<Record | undefined> { return undefined; }
    async list(): Promise<Page<Record>> { return { data: [], page: { number: 1, size: 0, count: 0, pages: 0 } }; }
    async create(props: any): Promise<Record | undefined> { return { id: '1', ...props.data }; }
    async update(props: any): Promise<Record | undefined> { return { id: props.id, ...props.data }; }
    async delete(): Promise<void> { }
    async upload(): Promise<Record | undefined> { return undefined; }
    async subscribe(): Promise<() => void> { return () => {}; }
    async notify(): Promise<void> { }
    stream(): { stream: () => AsyncIterable<Record>; cancel: () => void; status: () => "active" | "cancelled" | "completed"; } {
        return { stream: async function*() {}, cancel: () => {}, status: () => 'completed' };
    }
}

const cache = new QueryCache();
const baseStore = new MinimalBaseStore();
const store = new ReactiveRemoteStore(cache, baseStore);

console.log('ReactiveRemoteStore instance:', store);
console.log('Store initialized successfully!');

// You can also run the project's tests to verify full functionality:
// bun run vitest --run

A Developer's Guide to BaseStore

The BaseStore interface is the heart of the ReactiveRemoteStore's extensibility. It acts as a bridge between the reactive store and your backend, allowing you to use any API, protocol, or data source. This guide provides a comprehensive overview of how to implement it.

The Role of BaseStore

The BaseStore's responsibility is to handle the direct communication with your remote data source. ReactiveRemoteStore delegates all data operations (fetching, creating, updating, etc.) to your BaseStore implementation. This separation of concerns means ReactiveRemoteStore doesn't need to know anything about your API's specifics, such as URLs, headers, or authentication.

Getting Started

First, define the data structure for the records your store will manage. This type must extend StoreRecord, which requires an id: string property.

import type { BaseStore, StoreRecord, Page, StoreEvent } from '@asaidimu/erp-utils-remote-store/types';
import { StoreError } from '@asaidimu/erp-utils-remote-store/error';

// Your record type
interface Product extends StoreRecord {
    name: string;
    price: number;
    // ... any other fields
}

// Define the option types for your API
// These are used for type-safety when calling the store's methods

// Options for reading a single product (e.g., by ID)
interface ProductReadOptions { id: string; }

// Options for listing products (e.g., with pagination and sorting)
interface ProductListOptions { page?: number; pageSize?: number; sortBy?: keyof Product; order?: 'asc' | 'desc'; }

// Options for finding products (e.g., with a search query)
interface ProductFindOptions extends ProductListOptions { query: string; }

// Now, create your class that implements the BaseStore interface
class ProductApiStore implements BaseStore<Product, ProductFindOptions, ProductReadOptions, ProductListOptions> {
    private readonly baseUrl = 'https://api.example.com';

    // Implementation of each method will go here...
}

Implementing the Methods

Here is a detailed look at each method in the BaseStore interface.

read(options: TReadOptions): Promise<T | undefined>

Purpose: Fetch a single record by its identifier.

  • options: The parameters for the read operation, typically containing the record's id.
  • Returns: A Promise that resolves to the fetched record or undefined if it's not found.
// Example implementation for `read` with error handling
async read(options: ProductReadOptions): Promise<Product | undefined> {
    try {
        const response = await fetch(`${this.baseUrl}/products/${options.id}`);
        if (response.status === 404) {
            return undefined; // Handle not found gracefully
        }
        if (!response.ok) {
            // Throw a generic error for non-404 issues
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        return await response.json();
    } catch (error: any) {
        // Convert any caught error into a standardized StoreError
        throw StoreError.fromError(error, 'read');
    }
}

list(options: TListOptions): Promise<Page<T>>

Purpose: Fetch a paginated list of records.

  • options: Parameters for listing, such as page, pageSize, sortBy, etc.
  • Returns: A Promise that resolves to a Page<T> object, which contains the data array and pagination details.
// Example implementation for `list`
async list(options: ProductListOptions): Promise<Page<Product>> {
    const params = new URLSearchParams();
    if (options.page) params.set('page', String(options.page));
    if (options.pageSize) params.set('pageSize', String(options.pageSize));
    if (options.sortBy) params.set('sortBy', options.sortBy);
    if (options.order) params.set('order', options.order);

    try {
        const response = await fetch(`${this.baseUrl}/products?${params.toString()}`);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        // Assume the API returns a body like: { data: [...], page: { number, size, count, pages } }
        return await response.json();
    } catch (error: any) {
        throw StoreError.fromError(error, 'list');
    }
}

find(options: TFindOptions): Promise<Page<T>>

Purpose: Fetch a paginated list of records based on search criteria.

  • options: Parameters for finding, which might include a search query along with pagination and sorting.
  • Returns: A Promise that resolves to a Page<T> object.
// Example implementation for `find`
async find(options: ProductFindOptions): Promise<Page<Product>> {
    const params = new URLSearchParams({ query: options.query });
    if (options.page) params.set('page', String(options.page));
    // ... other params

    try {
        const response = await fetch(`${this.baseUrl}/products/search?${params.toString()}`);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        return await response.json();
    } catch (error: any) {
        throw StoreError.fromError(error, 'find');
    }
}

create(props: { data: Partial<T> }): Promise<T | undefined>

Purpose: Create a new record.

  • props.data: The record to create.
  • Returns: A Promise that resolves to the newly created record as returned by the server (including the server-generated id).
// Example implementation for `create`
async create(props: { data: Partial<Product> }): Promise<Product | undefined> {
    try {
        const response = await fetch(`${this.baseUrl}/products`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(props.data),
        });
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        return await response.json(); // Important: return the server's response
    } catch (error: any) {
        throw StoreError.fromError(error, 'create');
    }
}

update(props: { data: Partial<T> }): Promise<T | undefined>

Purpose: Update an existing record.

  • props.data: A partial object of the fields to update, including the id.
  • Returns: A Promise that resolves to the updated record.
// Example implementation for `update`
async update(props: { data: Partial<Product> }): Promise<Product | undefined> {
    try {
        const response = await fetch(`${this.baseUrl}/products/${props.data.id}`,
        {
            method: 'PATCH', // or 'PUT'
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(props.data),
        });
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        return await response.json();
    } catch (error: any) {
        throw StoreError.fromError(error, 'update');
    }
}

delete(options: TDeleteOptions): Promise<void>

Purpose: Delete a record.

  • options: Parameters for deletion, typically { id: string }.
  • Returns: A Promise that resolves when the operation is complete.
// Example implementation for `delete`
async delete(options: { id: string }): Promise<void> {
    try {
        const response = await fetch(`${this.baseUrl}/products/${options.id}`, {
            method: 'DELETE',
        });
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
    } catch (error: any) {
        throw StoreError.fromError(error, 'delete');
    }
}

Implementing Real-Time Features

ReactiveRemoteStore supports real-time updates through the subscribe and notify methods.

subscribe(scope: string, callback: (event: StoreEvent) => void): Promise<() => void>

Purpose: Establish a connection to your backend to receive real-time events.

  • scope: A string indicating the event scope to subscribe to. ReactiveRemoteStore will call this with '*' to listen for all events.
  • callback: A function that ReactiveRemoteStore provides. Your implementation must call this function whenever a StoreEvent is received from the backend.
  • Returns: A Promise that resolves to an unsubscribe function. ReactiveRemoteStore will call this function when it's destroyed to clean up the connection.
// Example implementation for `subscribe` using Server-Sent Events (SSE)
async subscribe(scope: string, callback: (event: StoreEvent) => void): Promise<() => void> {
    console.log(`Subscribing to event scope: ${scope}`);
    const eventSource = new EventSource(`${this.baseUrl}/events?scope=${scope}`);

    eventSource.onmessage = (event) => {
        const storeEvent: StoreEvent = JSON.parse(event.data);
        callback(storeEvent); // Pass the event to the ReactiveRemoteStore
    };

    eventSource.onerror = (error) => {
        console.error('SSE Error:', error);
        // You might want to add reconnection logic here
    };

    // Return a function that closes the connection
    return () => {
        console.log('Unsubscribing from events.');
        eventSource.close();
    };
}

notify(event: StoreEvent): Promise<void>

Purpose: Send an event from the client to the server. This is less common but can be used for client-initiated notifications.

  • event: The StoreEvent to send.
  • Returns: A Promise that resolves when the event has been sent.
// Example implementation for `notify`
async notify(event: StoreEvent): Promise<void> {
    try {
        const response = await fetch(`${this.baseUrl}/notify`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(event),
        });
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
    } catch (error: any) {
        throw StoreError.fromError(error, 'notify');
    }
}

Error Handling

For robust error handling, your BaseStore methods should wrap their logic in a try...catch block and convert any caught errors into a StoreError. This provides standardized, structured error information to the ReactiveRemoteStore and your application.

The library provides a StoreError.fromError(error, operation) utility that intelligently converts various error types (network errors, HTTP errors, timeouts) into a consistent StoreError object.

Benefits of using StoreError:

  • Standardization: All errors from the data layer have a consistent shape.
  • Rich Information: Includes code, status, isRetryable, and the originalError.
  • Automatic Handling: Correctly identifies common network and HTTP issues from fetch responses.

Here is how you should structure your methods:

import { StoreError } from '@asaidimu/erp-utils-remote-store/error';

// Inside your BaseStore implementation...

async read(options: ProductReadOptions): Promise<Product | undefined> {
    try {
        const response = await fetch(`${this.baseUrl}/products/${options.id}`);

        if (response.status === 404) {
            return undefined; // Not an error, just not found
        }

        // The `fromError` utility will automatically handle non-ok responses
        // if you pass the response object to it, but throwing manually is also fine.
        if (!response.ok) {
            throw new Error(`HTTP Error: ${response.status}`);
        }

        return await response.json();

    } catch (error: any) {
        // The `fromError` factory will create the appropriate StoreError
        // based on the type of error caught (e.g., network, timeout, HTTP).
        console.error('Error in read operation:', error);
        throw StoreError.fromError(error, 'read');
    }
}

📖 Usage Documentation

Basic Usage

The ReactiveRemoteStore simplifies data interactions by providing reactive hooks into your data. Here's how to perform basic CRUD operations and observe data changes.

import { ReactiveRemoteStore } from '@asaidimu/erp-utils-remote-store';
import { QueryCache } from '@core/cache';
import { BaseStore, Record, Page, StoreEvent } from '@asaidimu/erp-utils-remote-store/types';

// Assume you have a Todo type and a TodoBaseStore implementation
interface Todo extends Record {
    title: string;
    completed: boolean;
}

class TodoBaseStore implements BaseStore<Todo> {
    // ... (implementation similar to MyApiBaseStore above)
    // For simplicity, let's use a mock in-memory store for this example
    private todos: Todo[] = [];
    private nextId = 1;

    async create(props: { data: Omit<Todo, "id">; options?: any; }): Promise<Todo | undefined> {
        const newTodo = { id: String(this.nextId++), ...props.data };
        this.todos.push(newTodo);
        return newTodo;
    }
    async read(options: { id: string }): Promise<Todo | undefined> {
        return this.todos.find(t => t.id === options.id);
    }
    async list(options: any): Promise<Page<Todo>> {
        return { data: this.todos, page: { number: 1, size: this.todos.length, count: this.todos.length, pages: 1 } };
    }
    async update(props: { id: string; data: Partial<Omit<Todo, "id">>; options?: any; }): Promise<Todo | undefined> {
        const todo = this.todos.find(t => t.id === props.id);
        if (todo) {
            Object.assign(todo, props.data);
            return todo;
        }
        return undefined;
    }
    async delete(options: { id: string; }): Promise<void> {
        this.todos = this.todos.filter(t => t.id !== options.id);
    }
    async upload(): Promise<Todo | undefined> { return undefined; }
    async subscribe(): Promise<() => void> { return () => {}; }
    async notify(): Promise<void> { }
    stream(): { stream: () => AsyncIterable<Todo>; cancel: () => void; status: () => "active" | "cancelled" | "completed"; } {
        return { stream: async function*() {}, cancel: () => {}, status: () => 'completed' };
    }
}

const cache = new QueryCache();
const todoBaseStore = new TodoBaseStore();
const todoStore = new ReactiveRemoteStore<Todo>(cache, todoBaseStore);

async function runBasicUsage() {
    console.log('\n--- Basic Usage Examples ---');

    // 1. Create a Todo
    const createdTodo = await todoStore.create({ data: { title: 'Buy groceries', completed: false } });
    console.log('Created Todo:', createdTodo);

    // 2. Read a Todo and subscribe to changes
    if (createdTodo) {
        const { value: selectRead, onValueChange: subscribeRead } = todoStore.read({ id: createdTodo.id });
        const unsubscribeRead = subscribeRead(() => {
            const result = selectRead();
            console.log('Read Todo (reactive update):', result.data, 'loading:', result.loading);
        });

        console.log('Initial Read:', selectRead().data);

        // 3. List all Todos and subscribe to changes
        const { value , onValueChange: subscribeToList } = todoStore.list({});
        let result = value() 
        const unsubscribeList = subscribeToList(() => {
            result = value();
            console.log('List Todos (reactive update):', result.page?.data, 'loading:', result.loading);
        });

        console.log('Initial List:', result.page?.data);

        // 4. Update the Todo (this will trigger reactive updates in read and list queries)
        await todoStore.update({ id: createdTodo.id, data: { completed: true } });
        console.log('Updated Todo to completed.');

        // 5. Delete the Todo (this will trigger reactive updates)
        await todoStore.delete({ id: createdTodo.id });
        console.log('Deleted Todo.');

        // Clean up subscriptions
        unsubscribeRead();
        unsubscribeList();
    }

    // Don't forget to destroy the store when your application shuts down
    todoStore.destroy();
}

runBasicUsage();

API Usage

ReactiveRemoteStore exposes a comprehensive API for managing your data. Below are detailed descriptions of its public methods.

constructor(cache: QueryCache, baseStore: BaseStore<T, ...>, correlator?: Correlator, storeEventCorrelator?: StoreEventCorrelator)

Creates a new instance of ReactiveRemoteStore.

  • cache: An instance of QueryCache (or compatible interface) used for caching data.
  • baseStore: Your implementation of the BaseStore interface, responsible for direct interaction with the remote API.
  • correlator (optional): A function (Correlator type) that defines how mutations (create, update, delete, upload) should invalidate active queries in the QueryCache. If not provided, a default invalidation strategy (invalidating all list and find queries) is used.
  • storeEventCorrelator (optional): A function (StoreEventCorrelator type) that defines how incoming StoreEvents (e.g., from SSE) should invalidate active queries. If not provided, external events will be logged but won't trigger invalidation.

read(params: TReadOptions): ReactiveQueryResult<T>

Retrieves a single record from the store. This method returns an object containing a selector function and an onValueChange function, enabling reactive data access.

  • params: Options specific to your BaseStore's read operation (e.g., { id: string }).

Returns:

  • { value, onValueChange }: An object.
    • value: A function () => QueryResult<T> that returns the current state of the query, including data, loading status, error, stale status, and updated timestamp.
    • onValueChange: A function (callback: () => void) => () => void that allows you to register a callback to be notified when the query result changes. It returns an unsubscribe function.
// Example: Reading a user profile
interface UserProfile extends Record { name: string; email: string; }
// Assume userStore is ReactiveRemoteStore<UserProfile>

const { value: selectUser, onValueChange: subscribeUser } = userStore.read({ id: 'user-123' });

// Initial access
console.log('Initial User Data:', selectUser().data);

// Subscribe to changes
const unsubscribe = subscribeUser(() => {
    const result = selectUser();
    console.log('User Data Updated:', result.data, 'Loading:', result.loading, 'Stale:', result.stale);
    if (result.error) console.error('Error fetching user:', result.error);
});

// Later, when no longer needed
// unsubscribe();

list(params: TListOptions): ReactivePagedQueryResult<T>

Retrieves a paginated list of records from the store. This method returns an object containing a selector function and an onValueChange function, enabling reactive data access and pagination controls.

  • params: Options specific to your BaseStore's list operation (e.g., { page: number, pageSize: number, filter?: string }).

Returns:

  • { value, onValueChange }: An object.
    • value: A function () => PagedQueryResult<T> that returns the current state of the paginated query, including page data, loading status, error, stale status, updated timestamp, and pagination helpers (hasNext, hasPrevious, next(), previous(), fetch(page)).
    • onValueChange: A function (callback: () => void) => () => void that allows you to register a callback to be notified when the query result changes. It returns an unsubscribe function.
// Example: Listing products with pagination
interface Product extends Record { name: string; price: number; }
// Assume productStore is ReactiveRemoteStore<Product>

const { value: selectProducts, onValueChange: subscribeProducts } = productStore.list({ page: 1, pageSize: 10 });

const unsubscribeProducts = subscribeProducts(() => {
    const result = selectProducts();
    console.log('Products List Updated:', result.page?.data, 'Loading:', result.loading);
    if (result.page) {
        console.log(`Page ${result.page.page.number} of ${result.page.page.pages}`);
    }
});

// Navigate to the next page
const currentProducts = selectProducts();
if (currentProducts.hasNext) {
    await currentProducts.next();
}

// Fetch a specific page
await selectProducts().fetch(3);

// unsubscribeProducts();

find(params: TFindOptions): ReactivePagedQueryResult<T>

Retrieves a paginated list of records based on search criteria. Similar to list, but typically used for more complex search queries.

  • params: Options specific to your BaseStore's find operation (e.g., { query: string, category: string }).

Returns:

  • { value, onValueChange }: An object identical in structure and behavior to the list method's return value.
// Example: Finding orders by customer ID
interface Order extends Record { customerId: string; amount: number; }
// Assume orderStore is ReactiveRemoteStore<Order>

const { value: selectOrders, onValueChange: subscribeOrders } = orderStore.find({ customerId: 'cust-456', status: 'pending' });

const unsubscribeOrders = subscribeOrders(() => {
    const result = selectOrders();
    console.log('Orders Found Updated:', result.page?.data, 'Loading:', result.loading);
});

// unsubscribeOrders();

create(params: { data: Omit<T, 'id'>, options?: TCreateOptions }): Promise<T | undefined>

Creates a new record in the remote store. This operation automatically invalidates relevant cached queries (e.g., list or find queries) to ensure data consistency.

  • params.data: The data for the new record, excluding the id (which is typically generated by the backend).
  • params.options (optional): Options specific to your BaseStore's create operation.

Returns: A promise that resolves to the newly created record (including its id) or undefined if creation failed.

// Example: Creating a new task
interface Task extends Record { description: string; dueDate: string; }
// Assume taskStore is ReactiveRemoteStore<Task>

const newTask = await taskStore.create({ data: { description: 'Write documentation', dueDate: '2025-12-31' } });
console.log('New Task Created:', newTask);

update(params: { id: string; data: Partial<Omit<T, 'id'>>; options?: TUpdateOptions }): Promise<T | undefined>

Updates an existing record in the remote store. This operation automatically invalidates relevant cached queries (e.g., the specific read query for the updated ID, or list/find queries).

  • params.id: The id of the record to update.
  • params.data: A partial object containing the fields to update.
  • params.options (optional): Options specific to your BaseStore's update operation.

Returns: A promise that resolves to the updated record or undefined if the record was not found or the update failed.

// Example: Marking a task as completed
// Assume taskStore is ReactiveRemoteStore<Task> and taskId is known

const updatedTask = await taskStore.update({ id: taskId, data: { completed: true } });
console.log('Task Updated:', updatedTask);

delete(params: TDeleteOptions): Promise<void>

Deletes a record from the remote store. This operation automatically invalidates relevant cached queries.

  • params: Options specific to your BaseStore's delete operation (e.g., { id: string }).

Returns: A promise that resolves when the deletion is complete.

// Example: Deleting a task
// Assume taskStore is ReactiveRemoteStore<Task> and taskId is known

await taskStore.delete({ id: taskId });
console.log('Task Deleted.');

upload(params: { file: File; options?: TUploadOptions }): Promise<T | undefined>

Uploads a file associated with a record. This operation automatically invalidates relevant cached queries.

  • params.file: The File object to upload.
  • params.options (optional): Options specific to your BaseStore's upload operation (e.g., { id: string, fieldName: string }).

Returns: A promise that resolves to the updated record (if the upload modifies the record) or undefined if upload failed.

// Example: Uploading an avatar for a user
interface User extends Record { name: string; avatarUrl?: string; }
// Assume userStore is ReactiveRemoteStore<User> and userId is known

const avatarFile = new File(['...'], 'avatar.png', { type: 'image/png' });
const updatedUser = await userStore.upload({ file: avatarFile, options: { id: userId, fieldName: 'avatar' } });
console.log('User Avatar Uploaded:', updatedUser?.avatarUrl);

notify(event: StoreEvent): Promise<void>

Manually notifies the ReactiveRemoteStore of a StoreEvent. This can be used to simulate external events or trigger custom invalidation logic defined by storeEventCorrelator.

  • event: The StoreEvent to process. It should have a scope and an optional payload.

Returns: A promise that resolves when the notification has been processed.

// Example: Notifying the store of an external product price change
// Assume productStore is ReactiveRemoteStore<Product>

await productStore.notify({
    scope: 'product:price:changed',
    payload: { id: 'prod-789', newPrice: 99.99 }
});
console.log('Notified store about product price change.');

stream(options: TStreamOptions): Promise<{ stream: () => AsyncIterable<T>; cancel: () => void; status: () => 'active' | 'cancelled' | 'completed'; }>

Establishes a real-time data stream from the remote store. This method returns an object containing an AsyncIterable for consuming data, a cancel function to stop the stream, and a status getter to check the stream's current state.

  • options: Options specific to your BaseStore's stream operation (e.g., { filter: string, batchSize: number }).

Returns: An object with:

  • stream(): A function that returns an AsyncIterable<T> which yields records as they arrive.
  • cancel(): A function to call to terminate the stream.
  • status(): A getter function that returns the current state of the stream ('active', 'cancelled', or 'completed').
// Example: Streaming live stock prices
interface StockPrice extends Record { symbol: string; price: number; timestamp: number; }
// Assume stockStore is ReactiveRemoteStore<StockPrice>

async function consumeStockStream() {
    console.log('Starting stock price stream...');
    const { stream, cancel, status } = await stockStore.stream({ symbols: ['AAPL', 'GOOG'], interval: 1000 });

    const streamInterval = setInterval(() => {
        console.log('Stream Status:', status());
    }, 500);

    try {
        for await (const priceUpdate of stream()) {
            console.log(`Received: ${priceUpdate.symbol} - $${priceUpdate.price.toFixed(2)}`);
        }
        console.log('Stock stream completed.');
    } catch (error) {
        console.error('Stock stream error:', error);
    } finally {
        clearInterval(streamInterval);
        cancel(); // Ensure stream is cancelled
        console.log('Stock stream cleanup.');
    }
}

// consumeStockStream();

refresh(operation: 'read' | 'list' | 'find', params: TReadOptions | TListOptions | TFindOptions): Promise<T | Page<T> | undefined>

Forces a re-fetch of data for a specific query, bypassing staleness checks. This ensures you get the absolute latest data from the BaseStore.

  • operation: The type of operation to refresh ('read', 'list', or 'find').
  • params: The parameters for the query to refresh.

Returns: A promise that resolves to the refreshed data (single record or page) or undefined if the fetch fails.

// Example: Refreshing a user's session data after an action
// Assume userStore is ReactiveRemoteStore<UserProfile> and userId is known

const freshUserData = await userStore.refresh('read', { id: userId });
console.log('Refreshed User Data:', freshUserData);

// Example: Refreshing a list of notifications
// Assume notificationStore is ReactiveRemoteStore<Notification>

const freshNotifications = await notificationStore.refresh('list', { status: 'unread' });
console.log('Refreshed Notifications:', freshNotifications?.data);

prefetch(operation: 'read' | 'list' | 'find', params: TReadOptions | TListOptions | TFindOptions): void

Triggers a background fetch for a specific query if it's not already in cache or is stale. Useful for loading data proactively before it's explicitly requested by the UI.

  • operation: The type of operation to prefetch ('read', 'list', or 'find').
  • params: The parameters for the query to prefetch.

Returns: void (the operation runs in the background).

// Example: Prefetching related product details when a user hovers over a product card
// Assume productStore is ReactiveRemoteStore<Product>

productStore.prefetch('read', { id: 'prod-hovered-id' });
console.log('Prefetched product details.');

// Example: Prefetching the next page of a list
// Assume currentListParams has page: 1, pageSize: 10

const nextListPageParams = { ...currentListParams, page: currentListParams.page + 1 };
productStore.prefetch('list', nextListPageParams);
console.log('Prefetched next page of products.');

invalidate(operation: string, params: any): Promise<void>

Manually invalidates a specific query in the cache, marking it as stale. The next time this query is accessed, it will trigger a re-fetch from the BaseStore.

  • operation: The operation type of the query to invalidate (e.g., 'read', 'list', 'find').
  • params: The parameters of the query to invalidate.

Returns: A promise that resolves when the invalidation is complete.

// Example: Invalidate a specific user's cached data after a direct API call outside the store
// Assume userStore is ReactiveRemoteStore<UserProfile>

await userStore.invalidate('read', { id: 'user-123' });
console.log('User-123 data invalidated.');

invalidateAll(): Promise<void>

Invalidates all active queries currently managed by the ReactiveRemoteStore.

Returns: A promise that resolves when all active queries have been invalidated.

// Example: Invalidate all cached data after a global data reset or logout
// Assume anyStore is ReactiveRemoteStore<any>

await anyStore.invalidateAll();
console.log('All cached data invalidated.');

getStats(): { size: number; metrics: CacheMetrics; hitRate: number; staleHitRate: number; entries: Array<{ key: string; lastAccessed: number; lastUpdated: number; accessCount: number; isStale: boolean; isLoading?: boolean; error?: boolean }> }

Retrieves current statistics about the underlying QueryCache and the number of active subscriptions within ReactiveRemoteStore.

Returns: An object containing:

  • size: Number of active entries in the cache.
  • metrics: An object containing raw counts (hits, misses, fetches, errors, evictions, staleHits).
  • hitRate: Ratio of hits to total requests (hits + misses).
  • staleHitRate: Ratio of stale hits to total hits.
  • entries: An array of objects providing details for each cached item (key, lastAccessed, lastUpdated, accessCount, isStale, isLoading, error status).
  • activeSubscriptions: Number of currently active query subscriptions in ReactiveRemoteStore.
// Example: Logging store statistics
const stats = productStore.getStats();
console.log('Store Stats:', stats);
console.log('Active Subscriptions:', stats.activeSubscriptions);

destroy(): void

Cleans up all active subscriptions, internal timers, and resources held by the ReactiveRemoteStore instance. This method should be called when the store instance is no longer needed (e.g., on application shutdown or component unmount) to prevent memory leaks.

Returns: void.

// Example: Cleaning up the store on application exit
// Assume productStore is ReactiveRemoteStore<Product>

productStore.destroy();
console.log('ReactiveRemoteStore instance destroyed.');

Configuration Examples

Custom Correlators for Invalidation

Correlators allow you to define precise rules for cache invalidation based on mutations or external events. This is crucial for maintaining data consistency in complex applications.

import { ReactiveRemoteStore } from '@asaidimu/erp-utils-remote-store';
import { QueryCache } from '@core/cache';
import { BaseStore, Record, Page, StoreEvent, ActiveQuery, MutationOperation, Correlator, StoreEventCorrelator } from '@asaidimu/erp-utils-remote-store/types';

interface Post extends Record { title: string; authorId: string; tags: string[]; }

class PostBaseStore implements BaseStore<Post> { /* ... implementation ... */
    async find(): Promise<Page<Post>> { return { data: [], page: { number: 1, size: 0, count: 0, pages: 0 } }; }
    async read(): Promise<Post | undefined> { return undefined; }
    async list(): Promise<Page<Post>> { return { data: [], page: { number: 1, size: 0, count: 0, pages: 0 } }; }
    async create(props: any): Promise<Post | undefined> { return { id: 'new-post', ...props.data }; }
    async update(props: any): Promise<Post | undefined> { return { id: props.id, ...props.data }; }
    async delete(): Promise<void> { }
    async upload(): Promise<Post | undefined> { return undefined; }
    async subscribe(): Promise<() => void> { return () => {}; }
    async notify(): Promise<void> { }
    stream(): { stream: () => AsyncIterable<Post>; cancel: () => void; status: () => "active" | "cancelled" | "completed"; } {
        return { stream: async function*() {}, cancel: () => {}, status: () => 'completed' };
    }
}

// Correlator for mutations (create, update, delete, upload)
const postMutationCorrelator: Correlator = (
    mutation, // { operation: 'create' | 'update' | 'delete' | 'upload', params: any }
    activeQueries // Array of { queryKey: string, operation: string, params: any }
) => {
    const invalidatedKeys: string[] = [];

    // Invalidate all 'list' queries for posts on any post mutation
    if (mutation.operation === 'create' || mutation.operation === 'delete') {
        activeQueries.filter(q => q.operation === 'list').forEach(q => invalidatedKeys.push(q.queryKey));
    }

    // If a post is updated, invalidate its specific 'read' query
    if (mutation.operation === 'update' && mutation.params.id) {
        activeQueries
            .filter(q => q.operation === 'read' && q.params.id === mutation.params.id)
            .forEach(q => invalidatedKeys.push(q.queryKey));
    }

    // Example: Invalidate 'find' queries based on tags if a post's tags change
    if (mutation.operation === 'update' && mutation.params.id && mutation.params.data?.tags) {
        activeQueries
            .filter(q => q.operation === 'find' && q.params.tags &&
                          (mutation.params.data.tags as string[]).some(tag => q.params.tags.includes(tag)))
            .forEach(q => invalidatedKeys.push(q.queryKey));
    }

    return invalidatedKeys;
};

// Correlator for external StoreEvents (e.g., from SSE)
const postEventCorrelator: StoreEventCorrelator = (
    event, // { scope: string, payload?: any }
    activeQueries // Array of { queryKey: string, operation: string, params: any }
) => {
    const invalidatedKeys: string[] = [];

    // If an external event indicates a specific post was updated, invalidate its 'read' query
    if (event.scope === 'post:external:updated' && event.payload?.id) {
        activeQueries
            .filter(q => q.operation === 'read' && q.params.id === event.payload.id)
            .forEach(q => invalidatedKeys.push(q.queryKey));
    }

    // If an external event indicates a new post was created, invalidate all 'list' queries
    if (event.scope === 'post:external:created') {
        activeQueries.filter(q => q.operation === 'list').forEach(q => invalidatedKeys.push(q.queryKey));
    }

    return invalidatedKeys;
};

const cache = new QueryCache();
const postBaseStore = new PostBaseStore();

const postStore = new ReactiveRemoteStore<Post>(
    cache,
    postBaseStore,
    postMutationCorrelator, // Pass your custom mutation correlator
    postEventCorrelator     // Pass your custom store event correlator
);

console.log('ReactiveRemoteStore with custom correlators initialized.');

Common Use Cases

Reactive UI Updates with read and list

Automatically update your UI components when data changes, without manual re-fetching.

import { ReactiveRemoteStore } from '@asaidimu/erp-utils-remote-store';
import { QueryCache } from '@core/cache';
import { BaseStore, Record, Page, StoreEvent } from '@asaidimu/erp-utils-remote-store/types';

interface Item extends Record { name: string; status: 'active' | 'inactive'; }

class ItemBaseStore implements BaseStore<Item> { /* ... mock implementation ... */
    private items: Item[] = [];
    private nextId = 1;

    async create(props: { data: Omit<Item, "id">; options?: any; }): Promise<Item | undefined> {
        const newItem = { id: String(this.nextId++), ...props.data };
        this.items.push(newItem);
        return newItem;
    }
    async read(options: { id: string }): Promise<Item | undefined> {
        return this.items.find(i => i.id === options.id);
    }
    async list(options: any): Promise<Page<Item>> {
        return { data: this.items, page: { number: 1, size: this.items.length, count: this.items.length, pages: 1 } };
    }
    async update(props: { id: string; data: Partial<Omit<Item, "id">>; options?: any; }): Promise<Item | undefined> {
        const item = this.items.find(i => i.id === props.id);
        if (item) {
            Object.assign(item, props.data);
            return item;
        }
        return undefined;
    }
    async delete(options: { id: string; }): Promise<void> {
        this.items = this.items.filter(i => i.id !== options.id);
    }
    async upload(): Promise<Item | undefined> { return undefined; }
    async subscribe(): Promise<() => void> { return () => {}; }
    async notify(): Promise<void> { }
    stream(): { stream: () => AsyncIterable<Item>; cancel: () => void; status: () => "active" | "cancelled" | "completed"; } {
        return { stream: async function*() {}, cancel: () => {}, status: () => 'completed' };
    }
}

const cache = new QueryCache();
const itemBaseStore = new ItemBaseStore();
const itemStore = new ReactiveRemoteStore<Item>(cache, itemBaseStore);

async function runReactiveUIExample() {
    console.log('\n--- Reactive UI Updates Example ---');

    // Create some initial items
    const item1 = await itemStore.create({ data: { name: 'Item A', status: 'active' } });
    const item2 = await itemStore.create({ data: { name: 'Item B', status: 'inactive' } });

    // Subscribe to a list of all items
    const [subscribeAllItems, selectAllItems] = itemStore.list({});
    const unsubscribeAllItems = subscribeAllItems(() => {
        console.log('All Items (UI Update):', selectAllItems().page?.data.map(i => `${i.name} (${i.status})`));
    });

    // Subscribe to a single item
    if (item1) {
        const [subscribeItem1, selectItem1] = itemStore.read({ id: item1.id });
        const unsubscribeItem1 = subscribeItem1(() => {
            console.log('Item A (UI Update):', selectItem1().data ? `${selectItem1().data?.name} (${selectItem1().data?.status})` : 'Deleted');
        });

        // Simulate an update to Item A
        await new Promise(r => setTimeout(r, 1000));
        await itemStore.update({ id: item1.id, data: { status: 'inactive' } });
        console.log('Simulated update to Item A.');

        // Simulate deleting Item B
        await new Promise(r => setTimeout(r, 1000));
        if (item2) {
            await itemStore.delete({ id: item2.id });
            console.log('Simulated deletion of Item B.');
        }

        unsubscribeItem1();
    }

    unsubscribeAllItems();
    itemStore.destroy();
}

// runReactiveUIExample();

Handling Real-time Data Streams

Consume continuous data flows from your backend for live dashboards, chat applications, or sensor data.

import { ReactiveRemoteStore } from '@asaidimu/erp-utils-remote-store';
import { QueryCache } from '@core/cache';
import { BaseStore, Record, Page, StoreEvent } from '@asaidimu/erp-utils-remote-store/types';

interface SensorReading extends Record { temperature: number; humidity: number; timestamp: number; }

class SensorBaseStore implements BaseStore<SensorReading> { /* ... mock implementation ... */
    async find(): Promise<Page<SensorReading>> { return { data: [], page: { number: 1, size: 0, count: 0, pages: 0 } }; }
    async read(): Promise<SensorReading | undefined> { return undefined; }
    async list(): Promise<Page<SensorReading>> { return { data: [], page: { number: 1, size: 0, count: 0, pages: 0 } }; }
    async create(props: any): Promise<SensorReading | undefined> { return undefined; }
    async update(props: any): Promise<SensorReading | undefined> { return undefined; }
    async delete(): Promise<void> { }
    async upload(): Promise<SensorReading | undefined> { return undefined; }
    async subscribe(): Promise<() => void> { return () => {}; }
    async notify(): Promise<void> { }
    stream(options: any): { stream: () => AsyncIterable<SensorReading>; cancel: () => void; status: () => "active" | "cancelled" | "completed"; } {
        let counter = 0;
        const intervalId = setInterval(() => {
            // Simulate new readings every second
            const newReading = { id: `s${counter++}`, temperature: Math.random() * 20 + 20, humidity: Math.random() * 30 + 50, timestamp: Date.now() };
            // In a real scenario, this would push to an internal buffer consumed by the async generator
            // For this example, we'll just yield directly.
            // This mock doesn't perfectly simulate a real async generator being pushed to.
            // A real implementation would involve a queue and a way to push items into it.
            // For now, assume the BaseStore's stream method correctly provides the AsyncIterable.
        }, 1000);

        const mockAsyncIterable = (async function* () {
            for (let i = 0; i < 5; i++) { // Yield 5 readings for example
                await new Promise(r => setTimeout(r, 1000));
                yield { id: `mock-${i}`, temperature: Math.random() * 10 + 20, humidity: Math.random() * 10 + 50, timestamp: Date.now() };
            }
        })();

        return { 
            stream: () => mockAsyncIterable, 
            cancel: () => clearInterval(intervalId), 
            status: () => 'completed' // Simplified status for mock
        };
    }
}

const cache = new QueryCache();
const sensorBaseStore = new SensorBaseStore();
const sensorStore = new ReactiveRemoteStore<SensorReading>(cache, sensorBaseStore);

async function runStreamExample() {
    console.log('\n--- Real-time Data Stream Example ---');

    const { stream, cancel, status } = sensorStore.stream({});

    const statusInterval = setInterval(() => {
        console.log('Stream Status:', status());
    }, 500);

    try {
        for await (const reading of stream()) {
            console.log(`Sensor Reading: Temp=${reading.temperature.toFixed(2)}°C, Humidity=${reading.humidity.toFixed(2)}%`);
        }
        console.log('Sensor stream completed.');
    } catch (error) {
        console.error('Sensor stream error:', error);
    } finally {
        clearInterval(statusInterval);
        cancel(); // Ensure stream is cancelled
        console.log('Stream cleanup.');
    }
}

// runStreamExample();

🏗️ Project Architecture

ReactiveRemoteStore is designed with modularity and extensibility in mind, separating concerns to allow for flexible integration with various caching strategies and backend communication methods.

Directory Structure

src/remote-store/
├── error.ts            # Custom error classes for store operations.
├── hash.ts             # Utility for hashing query parameters to create unique cache keys.
├── store.ts            # The core ReactiveRemoteStore class implementation.
├── types.ts            # TypeScript interfaces and types for the store, events, and options.
├── test-server.ts      # A mock backend implementation used for e2e testing and examples.
├── package.json        # Package metadata and dependencies for this specific module.
├── README.md           # This documentation file.
└── ...                 # Other related files (e.g., test files, build configs)

Core Components

  • ReactiveRemoteStore (store.ts): The main class providing the reactive data access layer. It orchestrates data flow between the UI, the QueryCache, and the BaseStore. It manages query subscriptions, triggers fetches, handles invalidation, and processes real-time events.
  • BaseStore<T> (types.ts): An interface that defines the fundamental CRUD (Create, Read, Update, Delete), Upload, Subscribe, Notify, and Stream operations that your remote data source must implement. This makes ReactiveRemoteStore backend-agnostic.
  • QueryCache (from @core/cache): An external dependency responsible for the actual in-memory caching logic. ReactiveRemoteStore interacts with it to store, retrieve, and invalidate data. It handles cache policies like staleness, eviction, and background re-fetching.
  • StoreEvent (types.ts): Represents a data change event, typically originating from the BaseStore (e.g., via SSE) or triggered by local mutations. It has a scope (e.g., product:created:success) and an optional payload.
  • Correlator (types.ts): A function type that allows you to define custom logic for how local mutations (create, update, delete, upload) should affect the invalidation of active queries in the QueryCache.
  • StoreEventCorrelator (types.ts): A function type that allows you to define custom logic for how incoming StoreEvents (e.g., from SSE) should affect the invalidation of active queries in the QueryCache.

Data Flow

  1. Query Initiation (e.g., store.read(), store.list()):

    • A component requests data via ReactiveRemoteStore's reactive methods (read, list, find).
    • ReactiveRemoteStore generates a unique queryKey for the request.
    • It checks the QueryCache for existing data. If data is fresh, it's returned immediately.
    • If data is stale or not present, ReactiveRemoteStore registers a fetchFunction with the QueryCache (which then calls the appropriate BaseStore method) to fetch the data.
    • The QueryCache manages the actual fetching, retries, and updates its internal state.
    • ReactiveRemoteStore provides a selector function that components use to get the current state of the data (including loading, stale, error).
    • A subscribe function is also provided, allowing components to register callbacks that are notified whenever the query result changes in the cache.
  2. Mutations (e.g., store.create(), store.update()):

    • When a mutation operation is performed, ReactiveRemoteStore first calls the corresponding method on the BaseStore to update the remote data.
    • Upon successful completion, it uses the configured Correlator (or a default strategy) to identify which active queries in the QueryCache should be invalidated.
    • Invalidated queries are marked stale, ensuring that subsequent accesses will trigger a re-fetch (or background re-fetch if SWR is enabled by the QueryCache).
  3. Real-time Events (BaseStore-managed Connection):

    • ReactiveRemoteStore subscribes to a wildcard scope (*) on the BaseStore's subscribe method, listening for all incoming StoreEvents.
    • Your BaseStore implementation is responsible for establishing and managing the actual real-time connection (e.g., via Server-Sent Events, WebSockets, or other protocols) and pushing StoreEvents to the ReactiveRemoteStore via the callback provided to subscribe.
    • When a StoreEvent is received, ReactiveRemoteStore uses the configured StoreEventCorrelator to determine which active queries should be invalidated based on the event's scope and payload.
    • This ensures that client-side data is automatically synchronized with server-side changes.
  4. Data Streaming (store.stream()):

    • When store.stream() is called, ReactiveRemoteStore delegates the request to the BaseStore's stream method.
    • The BaseStore is responsible for establishing and managing the actual stream (e.g., HTTP streaming, WebSockets) and returning an AsyncIterable.
    • ReactiveRemoteStore provides this AsyncIterable directly to the consumer, along with cancel and status controls.

Extension Points

ReactiveRemoteStore is designed to be highly extensible, allowing you to tailor its behavior to your specific application and backend needs:

  • Custom BaseStore Implementation: The most significant extension point. By implementing the BaseStore interface, you can integrate ReactiveRemoteStore with any type of backend API (REST, GraphQL, custom RPC) and any communication library (Fetch API, Axios, gRPC clients).
    • Crucially, your BaseStore implementation also dictates the real-time communication method for subscribe and notify (e.g., Server-Sent Events, WebSockets, long polling, etc.), allowing you to choose the best fit for your backend and application needs.
  • Custom QueryCache: While @core/cache is assumed, you can swap it out for any caching library that adheres to the expected QueryCache interface, allowing you to control caching policies, persistence, and memory management.
  • Custom Correlator and StoreEventCorrelator: These functions provide powerful control over cache invalidation. You can implement complex logic to precisely invalidate only the necessary queries based on the specifics of your data model and backend events, optimizing performance and consistency.
  • Custom TFindOptions, TReadOptions, etc.: The generic type parameters for options (TFindOptions, TReadOptions, etc.) allow you to define strongly-typed options objects that are specific to your API's query capabilities, enhancing type safety throughout your data layer.

🤝 Development & Contributing

We welcome contributions to ReactiveRemoteStore! Whether it's a bug fix, a new feature, or an improvement to the documentation, your help is appreciated.

Development Setup

To set up the development environment for ReactiveRemoteStore:

  1. Clone the monorepo (assuming this is part of a larger erp-utils monorepo):
    git clone https://github.com/your-org/erp-utils.git # Replace with actual repo URL
    cd erp-utils
  2. Navigate to the remote-store package:
    cd src/remote-store
  3. Install dependencies:
    bun install
    # or
    npm install
    # or
    yarn install
  4. Build the project:
    bun run build
    # or
    npm run build
    # or
    yarn build

Scripts

The following bun/npm scripts are available in this project:

  • bun run build: Compiles TypeScript source files from src/ to JavaScript output.
  • bun run test: Runs the test suite using Vitest.
  • bun run test:watch: Runs tests in watch mode for continuous feedback during development.
  • bun run test-server: Starts a mock backend server used for e2e tests and examples.
  • bun run lint: Runs ESLint to check for code style and potential errors.
  • bun run format: Formats code using Prettier according to the project's style guidelines.

Testing

This project includes comprehensive unit, integration, and end-to-end tests to ensure reliability and correctness.

  • Unit Tests: Test individual functions and classes in isolation (e.g., store.test.ts).
  • Integration Tests: Verify the interaction between ReactiveRemoteStore and a mock BaseStore (e.g., store.integration.test.ts).
  • End-to-End (e2e) Tests: Test the entire system, including ReactiveRemoteStore, QueryCache, and the test-server mock backend, simulating real-world scenarios (e.g., store.e2e.test.ts).

To run all tests:

bun run test

To run the mock test server (required for e2e tests):

bun run test-server

W