@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.
🚀 Quick Links
- Overview & Features
- Installation & Setup
- Usage Documentation
- Project Architecture
- Development & Contributing
- Additional Information
📦 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
QueryCacheto 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 ofsubscribeandnotify(which can utilize technologies like Server-Sent Events, WebSockets, or other real-time protocols) and custom event correlators,ReactiveRemoteStorecan 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 theBaseStoreinterface, allowingReactiveRemoteStoreto 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, andfindoperations. - Automatic Invalidation: Cache entries are intelligently invalidated upon
create,update,delete, anduploadmutations. - Real-time Event Integration: Reacts to real-time data changes pushed from the
BaseStore(viasubscribeandnotifymethods, supporting various protocols like SSE or WebSockets). - Custom Invalidation Logic (Correlators): Define custom functions (
CorrelatorandStoreEventCorrelator) 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
BaseStorevia anAsyncIterableinterface. - 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, oryarnpackage manager- A
QueryCacheimplementation (e.g.,@core/cacheif available in your project, or a custom one). - An implementation of the
BaseStoreinterface 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/cacheConfiguration
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 --runA 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'sid.- Returns: A
Promisethat resolves to the fetched record orundefinedif 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 aspage,pageSize,sortBy, etc.- Returns: A
Promisethat resolves to aPage<T>object, which contains thedataarray 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 searchqueryalong with pagination and sorting.- Returns: A
Promisethat resolves to aPage<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
Promisethat resolves to the newly created record as returned by the server (including the server-generatedid).
// 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 theid.- Returns: A
Promisethat 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
Promisethat 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.ReactiveRemoteStorewill call this with'*'to listen for all events.callback: A function thatReactiveRemoteStoreprovides. Your implementation must call this function whenever aStoreEventis received from the backend.- Returns: A
Promisethat resolves to anunsubscribefunction.ReactiveRemoteStorewill 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: TheStoreEventto send.- Returns: A
Promisethat 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 theoriginalError. - Automatic Handling: Correctly identifies common network and HTTP issues from
fetchresponses.
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 ofQueryCache(or compatible interface) used for caching data.baseStore: Your implementation of theBaseStoreinterface, responsible for direct interaction with the remote API.correlator(optional): A function (Correlatortype) that defines how mutations (create, update, delete, upload) should invalidate active queries in theQueryCache. If not provided, a default invalidation strategy (invalidating alllistandfindqueries) is used.storeEventCorrelator(optional): A function (StoreEventCorrelatortype) that defines how incomingStoreEvents (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 yourBaseStore'sreadoperation (e.g.,{ id: string }).
Returns:
{ value, onValueChange }: An object.value: A function() => QueryResult<T>that returns the current state of the query, includingdata,loadingstatus,error,stalestatus, andupdatedtimestamp.onValueChange: A function(callback: () => void) => () => voidthat allows you to register a callback to be notified when the query result changes. It returns anunsubscribefunction.
// 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 yourBaseStore'slistoperation (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, includingpagedata,loadingstatus,error,stalestatus,updatedtimestamp, and pagination helpers (hasNext,hasPrevious,next(),previous(),fetch(page)).onValueChange: A function(callback: () => void) => () => voidthat allows you to register a callback to be notified when the query result changes. It returns anunsubscribefunction.
// 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 yourBaseStore'sfindoperation (e.g.,{ query: string, category: string }).
Returns:
{ value, onValueChange }: An object identical in structure and behavior to thelistmethod'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 theid(which is typically generated by the backend).params.options(optional): Options specific to yourBaseStore'screateoperation.
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: Theidof the record to update.params.data: A partial object containing the fields to update.params.options(optional): Options specific to yourBaseStore'supdateoperation.
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 yourBaseStore'sdeleteoperation (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: TheFileobject to upload.params.options(optional): Options specific to yourBaseStore'suploadoperation (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: TheStoreEventto process. It should have ascopeand an optionalpayload.
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 yourBaseStore'sstreamoperation (e.g.,{ filter: string, batchSize: number }).
Returns: An object with:
stream(): A function that returns anAsyncIterable<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 inReactiveRemoteStore.
// 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, theQueryCache, and theBaseStore. 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 makesReactiveRemoteStorebackend-agnostic.QueryCache(from@core/cache): An external dependency responsible for the actual in-memory caching logic.ReactiveRemoteStoreinteracts 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 theBaseStore(e.g., via SSE) or triggered by local mutations. It has ascope(e.g.,product:created:success) and an optionalpayload.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 theQueryCache.StoreEventCorrelator(types.ts): A function type that allows you to define custom logic for how incomingStoreEvents (e.g., from SSE) should affect the invalidation of active queries in theQueryCache.
Data Flow
Query Initiation (e.g.,
store.read(),store.list()):- A component requests data via
ReactiveRemoteStore's reactive methods (read,list,find). ReactiveRemoteStoregenerates a uniquequeryKeyfor the request.- It checks the
QueryCachefor existing data. If data is fresh, it's returned immediately. - If data is stale or not present,
ReactiveRemoteStoreregisters afetchFunctionwith theQueryCache(which then calls the appropriateBaseStoremethod) to fetch the data. - The
QueryCachemanages the actual fetching, retries, and updates its internal state. ReactiveRemoteStoreprovides aselectorfunction that components use to get the current state of the data (includingloading,stale,error).- A
subscribefunction is also provided, allowing components to register callbacks that are notified whenever the query result changes in the cache.
- A component requests data via
Mutations (e.g.,
store.create(),store.update()):- When a mutation operation is performed,
ReactiveRemoteStorefirst calls the corresponding method on theBaseStoreto update the remote data. - Upon successful completion, it uses the configured
Correlator(or a default strategy) to identify which active queries in theQueryCacheshould 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).
- When a mutation operation is performed,
Real-time Events (BaseStore-managed Connection):
ReactiveRemoteStoresubscribes to a wildcard scope (*) on theBaseStore'ssubscribemethod, listening for all incomingStoreEvents.- Your
BaseStoreimplementation is responsible for establishing and managing the actual real-time connection (e.g., via Server-Sent Events, WebSockets, or other protocols) and pushingStoreEvents to theReactiveRemoteStorevia thecallbackprovided tosubscribe. - When a
StoreEventis received,ReactiveRemoteStoreuses the configuredStoreEventCorrelatorto determine which active queries should be invalidated based on the event'sscopeandpayload. - This ensures that client-side data is automatically synchronized with server-side changes.
Data Streaming (
store.stream()):- When
store.stream()is called,ReactiveRemoteStoredelegates the request to theBaseStore'sstreammethod. - The
BaseStoreis responsible for establishing and managing the actual stream (e.g., HTTP streaming, WebSockets) and returning anAsyncIterable. ReactiveRemoteStoreprovides thisAsyncIterabledirectly to the consumer, along withcancelandstatuscontrols.
- When
Extension Points
ReactiveRemoteStore is designed to be highly extensible, allowing you to tailor its behavior to your specific application and backend needs:
- Custom
BaseStoreImplementation: The most significant extension point. By implementing theBaseStoreinterface, you can integrateReactiveRemoteStorewith any type of backend API (REST, GraphQL, custom RPC) and any communication library (Fetch API, Axios, gRPC clients).- Crucially, your
BaseStoreimplementation also dictates the real-time communication method forsubscribeandnotify(e.g., Server-Sent Events, WebSockets, long polling, etc.), allowing you to choose the best fit for your backend and application needs.
- Crucially, your
- Custom
QueryCache: While@core/cacheis assumed, you can swap it out for any caching library that adheres to the expectedQueryCacheinterface, allowing you to control caching policies, persistence, and memory management. - Custom
CorrelatorandStoreEventCorrelator: 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:
- Clone the monorepo (assuming this is part of a larger
erp-utilsmonorepo):git clone https://github.com/your-org/erp-utils.git # Replace with actual repo URL cd erp-utils - Navigate to the
remote-storepackage:cd src/remote-store - Install dependencies:
bun install # or npm install # or yarn install - 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 fromsrc/to JavaScript output.bun run test: Runs the test suite usingVitest.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
ReactiveRemoteStoreand a mockBaseStore(e.g.,store.integration.test.ts). - End-to-End (e2e) Tests: Test the entire system, including
ReactiveRemoteStore,QueryCache, and thetest-servermock backend, simulating real-world scenarios (e.g.,store.e2e.test.ts).
To run all tests:
bun run testTo run the mock test server (required for e2e tests):
bun run test-serverW
