@reforgium/statum
v3.1.4
Published
Signals-first API state and query stores for Angular
Maintainers
Readme
@reforgium/statum
Signals-first query and data stores for Angular (18+).
@reforgium/statum provides API-oriented stores, cache strategies, and a
serialization layer for Angular applications that talk to HTTP backends.
Designed for request orchestration, pagination, dictionaries, and entity state. It is not a reduced framework and does not try to replace NgRx-style global event modeling.
Features
- Signals-first API (
signal()everywhere) - API-driven stores (resource / paginated / dictionaries)
- Normalized entity store (
EntityStore) - Built-in cache strategies (TTL, prefix grouping)
- Deterministic request lifecycle: loading / error / data
- Optional transport-level: dedupe, debounce/throttle, abort
- Unified retry and trace hooks in
ResourceStore - Explicit serialization / deserialization boundary
Best Fit
Use statum when you need:
- HTTP-first application state for CRUD-heavy Angular apps
- Predictable request behavior (dedupe, abort, retry, latest-wins)
- Reusable pagination and dictionary flows for tables and forms
- Lightweight entity normalization without a full reducer architecture
statum is usually a good fit between:
- plain
HttpClientservices that have started to accumulate state logic - full application state frameworks where global reducers/effects are too expensive for the problem
statum is usually not the right fit when:
- you need cross-page event sourcing, time-travel tooling, or centralized action logs
- your app is mostly local UI state with little backend orchestration
- you want a framework-agnostic data layer
Installation
npm install @reforgium/statumConfiguration
import { provideStatum } from '@reforgium/statum';
providers: [
provideStatum({
pagedQuery: {
defaultBaseUrl: '/api',
defaultMethod: 'GET',
defaultHasCache: true,
defaultCacheSize: 10,
},
}),
];API Stability
statum v3 narrows its public API around explicit store methods and object-style contracts.
Stability policy for the documented public surface:
- Existing documented methods are treated as stable within
3.x - Signature expansions should stay additive and backward-compatible
- Behavioral changes should be reflected in
CHANGELOG.mdand, when needed,MIGRATION.md - Removed or renamed APIs should go through a documented migration path first
The safest long-term entry points are:
- package exports from
@reforgium/statum - documented store methods in this README
- provider helpers such as
provideStatum(...)
Package Structure
- Cache - storage strategies for caching
- Stores - reusable state containers over HttpClient
- Serializer - configurable data transformation utility
Low-level serializer and storage primitives are shared through hidden @reforgium/internal.
statum remains the user-facing package; you do not need to install or reason about @reforgium/internal directly in normal usage.
Behavioral Guarantees
The core stores are designed around explicit, testable runtime guarantees.
ResourceStore guarantees:
- Identical in-flight requests can be deduplicated into one transport call
abort(...)/abortAll(...)cancel active or scheduled requests and clear inflight dedupe references- Retry policy can be set globally and overridden per request
- Trace hooks receive cache/request lifecycle events for observability
PagedQueryStore guarantees:
fetch(...)always starts from page0and clears page cache firstupdatePage(...)can read from cache unlessignoreCacheis enabledupdatePageSize(...)resets cache before loading the first page with the new sizeupdateByOffset(...)maps table offsets into normalizedpage + sizerequestslatest-winskeepsloadingstable during abort/restart request bursts and ignores stale async parse resultsparallelallows overlapping page requests when the source intentionally supports concurrent fetches
DictStore guarantees:
fixed: trueperforms local search over the loaded cache after initial fillfixed: falsedelegates search to the server with debounce support- Visible
optionsstay normalized to{ label, value }
Performance notes:
ResourceStore,PagedQueryStore, andDictStoreinclude stress/perf coverage in the test suite- The goal of these tests is behavioral regression detection and upper-bound sanity checks, not synthetic benchmark marketing
- For production evaluation, prefer measuring with your own API latency, payload size, and change detection profile
- Run
npm run bench:statumfor a current local timing snapshot - Run
npm run bench:statum:browserand open/test-routing/statum-benchfor browser-side render measurements - See
PERF.mdfor the latest checked-in baseline captured in this repository
Reproducible examples are covered in tests:
dedupe identical requests- many callers hitting the sameResourceStore.get(...)withdedupe: truestill produce one transport requestcache hit/miss semantics-cache-onlyfails on a cold key, thencache-firstserves the warmed key without networklatest-wins abort behavior-PagedQueryStore.updatePage(...)bursts cancel older in-flight requests and only the newest page is appliedpagination cache eviction- when the page cache is full, revisiting an evicted page performs a fresh requestwarm cache replay- replaying the hot cache window stays network-free across the full cached range
These examples are intentionally implemented as specs, not ad-hoc benchmark scripts, so they can be rerun in CI and used as regression checks.
The checked-in benchmark baseline in PERF.md also shows a practical scaling claim for ResourceStore:
- identical request fan-in collapses from
Ntransport calls to1whendedupe: true - in the current local snapshot, a
10,000caller burst is roughly an order of magnitude faster with dedupe enabled - the same
10,000caller burst reduces memory pressure from tens of MB to low single-digit MB in the deduped path
Those numbers are machine-specific and should be treated as a local envelope, not a universal SLA.
Cache
storageStrategy
storageStrategy<T>(kind) is a factory that returns a cache storage implementing StorageInterface<T>
Available strategies:
persist- browser localStoragesession- browser sessionStoragememory- in-memory storagelru- size-limited in-memory cache
StorageInterface
All cache strategies implement the same contract.
interface StorageInterface<T> {
prefix?: string;
get(key: string): T | null;
set(key: string, value: T): void;
remove(key: string): void;
clear(): void;
get length(): number;
}Example:
import { storageStrategy } from '@reforgium/statum';
type User = { id: number; name: string };
const storage = storageStrategy<User>('persist');
storage.set('user:1', { id: 1, name: 'John' });
const user = storage.get('user:1'); // User | nullResourceStore
Transport-level store over HttpClient with cache strategies, deduplication, abort, and rate-limiting.
When to use
- CRUD and "resource" endpoints
- Centralized debounce/throttle
- Abort in-flight / delayed requests (latest-wins)
- Dedupe identical calls to one HTTP request
- Use as a transport engine for other stores (
promote: false)
Signals
| Signal | Type | Description |
|---------|---------------------------|-------------------------|
| value | Signal<T \| null> | Last successful value |
| status | Signal<ResourceStatus> | Resource status |
| error | Signal<unknown \| null> | Last error |
| loading | Signal<boolean> | Request in progress |
Methods
| Method | Description | |----------|--------------------------| | get | GET request | | post | POST request | | put | PUT request | | patch | PATCH request | | delete | DELETE request | | abort | Abort a specific request | | abortAll | Abort all requests |
observe
By default, parseResponse (and the resolved Promise value) receive the response body.
Pass observe: 'response' to receive the full HttpResponse instead — useful for reading response headers or status codes inside parseResponse.
const result = await store.get(
{ params: { id: '1' }, query: {} },
{
observe: 'response',
parseResponse: (res) => ({
data: res.body,
etag: res.headers.get('ETag'),
}),
},
);onResponse (store-level hook) always fires with the full HttpResponse regardless of observe, and is intended for side-effects only (logging, header extraction).
Cache hits bypass the network entirely. When
observe: 'response'is used withstrategy: 'cache-first'or'cache-only'and a cached value exists,parseResponseis not called — the cached result is returned as-is.
Example:
import { ResourceStore } from '@reforgium/statum';
type User = { id: number; name: string };
const userStore = new ResourceStore<User>(
{ GET: '/users/:id' },
{ baseUrl: '/api', ttlMs: 60_000 }
);
const user = await userStore.get(
{ params: { id: '42' }, query: {} },
{ dedupe: true }
);Retry + trace example:
import { ResourceStore } from '@reforgium/statum';
const store = new ResourceStore<{ id: number; name: string }>(
{ GET: '/users/:id' },
{
baseUrl: '/api',
retry: { attempts: 2, delayMs: 150, backoff: 'exponential' },
onTrace: (event) => console.debug('[resource-trace]', event),
}
);
await store.get(
{ params: { id: '42' } },
{ retry: { attempts: 1 } } // per-call override
);Profiles example:
import { createResourceProfile, ResourceStore } from '@reforgium/statum';
const usersStore = new ResourceStore<{ id: number; name: string }>(
{ GET: '/users' },
createResourceProfile('table', { baseUrl: '/api' })
);EntityStore
Normalized entities store (byId + ids) for fast updates and stable list composition.
Example:
import { EntityStore } from '@reforgium/statum';
type User = { id: number; name: string };
const entities = new EntityStore<User, 'id'>({ idKey: 'id' });
entities.setAll([{ id: 1, name: 'Neo' }]);
entities.upsertOne({ id: 2, name: 'Trinity' });
entities.patchOne(2, { name: 'Trin' });
entities.removeOne(1);PagedQueryStore
Lightweight store for server-side pagination with filtering, dynamic query params, and page cache.
When to use
- Server-side pagination
- Noisy filters (debounce)
- Tables/grids (PrimeNG
onLazyLoadand similar) - Small local page cache with eviction
State
| Field / Signal | Type | Description |
|----------------|---------------------------|----------------------------------------|
| items | WritableSignal<T[]> | Current page items |
| cached | WritableSignal<T[]> | Flattened cache of cached pages |
| loading | WritableSignal<boolean> | Loading indicator |
| error | WritableSignal<unknown \| null> | Last request error |
| version | WritableSignal<number> | Increments when the dataset is reset |
| page | number | Current page (0-based) |
| pageSize | number | Page size |
| totalElements | number \| undefined | Total items on server, if known |
| filters | Partial<F> | Active filters |
| query | Record<string, unknown> | Active query params |
| sort | ReadonlyArray<{ sort: string; order: 'asc' \| 'desc' }> | Active sort state |
Reactive metadata signals are also available:
pageStatepageSizeStatetotalElementsStatefiltersStatequeryStatesortStaterouteParamsState
Methods
| Method | Description |
|----------------|-----------------------------------------------------------------------------------------|
| fetch | Clean first-page request: fetch({ filters, query, routeParams }) |
| refetchWith | Repeat request, optional merge overrides: refetchWith({ filters, query }) |
| updatePage | Change page: updatePage(page, { ignoreCache }) or updatePage({ page, ignoreCache }) |
| updatePageSize | Change page size and reset cache: updatePageSize(size) |
| setSort | Update sort state without triggering a request |
| updateSort | Apply single-sort state and load from the first page |
| updateSorts | Apply multi-sort state and load from the first page |
| updateByOffset | Table-event mapper: updateByOffset({ page/first/rows }, { query }) |
| setRouteParams | Update route params: setRouteParams(params, { reset, abort }) |
| updateConfig | Patch config: updateConfig(config) |
| copy | Copy config/meta: copy(store) |
| destroy | Manual destroying of caches and abort requests |
version is useful for consumers that keep their own local page buffer. For example, @reforgium/data-grid can use [source]="store" and clear its internal infinity buffer when fetch(), updatePageSize(), or setRouteParams(..., { reset: true }) replace the dataset.
Sorting is built into PagedQueryStore and serialized in the common backend-friendly form:
sort=name,asc
sort=name,asc&sort=createdAt,descConcurrency can be configured per store:
latest-wins- abort older in-flight requests before the next page request startsparallel- allow overlapping requests and keeploading()true until all active requests finish
Cache behavior
| Method | Cache read | Cache reset | Notes |
|----------------|------------|-------------|--------------------------------------------------|
| fetch | no | yes | Always starts clean from page 0 |
| refetchWith | no | conditional | Reloads current state; when filters/query/sort actually change, resets to page 0 and replaces the dataset |
| updatePage | yes | no | Can bypass with ignoreCache: true |
| updatePageSize | no | yes | Prevents mixed caches for different page sizes |
| updateByOffset | yes | no | Internally maps to page + size |
Example:
import { PagedQueryStore } from '@reforgium/statum';
type User = { id: number; name: string };
const store = new PagedQueryStore<User, { search?: string }>('api/users', {
baseUrl: '/api',
method: 'GET',
debounceTime: 200,
concurrency: 'latest-wins',
});
store.fetch({ filters: { search: 'neo' }, query: { tenant: 'kg' } });
store.updateByOffset({ first: 20, rows: 20 });
store.updateSort({ sort: 'name', order: 'asc' });cached() remains a bounded hot-cache view. It is useful for cache-aware revisit/export/search helpers, but it is not the right datasource for infinity scrolling once cache eviction matters. For data-grid infinity mode, prefer passing the whole store as a GridPagedDataSource ([source]="store") and let the grid keep its own page buffer.
sort and routeParams should be changed only through setSort(...) and setRouteParams(...). Direct state mutation setters for page, pageSize, filters, query, and totalElements are available for low-level integration scenarios (such as external data-grid source contracts) but prefer the explicit store methods for typical use. totalElements may be undefined when the backend does not report a total.
PagedQueryStore + DataGrid source mode
PagedQueryStore can be passed directly into @reforgium/data-grid source mode:
<re-data-grid
mode="infinity"
[columns]="columns"
[source]="usersStore"
/>This works because the store already exposes the expected source contract:
itemsloadingerrorpagepageSizetotalElementssortversionupdatePage(...)updatePageSize(...)updateSort(...)updateSorts(...)
Keep data-grid source prefetch in sequential mode when the store uses latest-wins. Switch to parallel only if the store is configured with concurrency: 'parallel' and the backend flow supports overlapping page requests.
DictStore
Helper for dictionaries/options (select/autocomplete) on top of PagedQueryStore.
When to use
- Select options / autocomplete hints
- Local search over previously loaded values (fixed mode)
- Cache across sessions in localStorage
- Need
{ label, value }mapping
Key behavior
- Two modes:
fixed: true- first load fills local cache; search happens locallyfixed: false- search goes to server (passes filtername)
- Local cache merges without duplicates and is size-limited
Public API
Signals / fields:
| Field / Signal | Type | Description |
|----------------|--------------------------------------------------------|-----------------------|
| items | Signal<T[]> | Current visible items |
| options | Signal<{ label: string; value: string \| number }[]> | Options for selects |
| searchText | Signal<string> | Current search text |
| filters | Signal<Record<string, any>> | Current filters |
Methods:
| Method | Description |
|--------------|--------------------------------------------------------------------------------------------|
| search | Set search text and trigger load (fixed=false) or local filter (fixed=true) |
| findLabel | Resolve label by value from local cache (no network) |
| restoreCache | Restore cache from the selected storage (persist/session/lru/memory). (no network) |
Config:
| Option | Type | Default | Description |
|----------------|--------------------------------------|-------------|---------------------------------------------|
| labelKey | string | 'name' | Property name to use as option label |
| valueKey | string | 'code' | Property name to use as option value |
| fixed | boolean | true | Use local search instead of server requests |
| method | 'GET' \| 'POST' | 'POST' | HTTP method for requests |
| debounceTime | number | 0 | Debounce time for search requests |
| maxOptionsSize | number | undefined | Maximum items exposed in options |
| cacheStrategy | 'persist' \| 'session' \| 'memory' | 'persist' | Cache storage strategy |
| keyPrefix | string | 're' | Prefix for storage keys |
| presetFilters | Record<string, string> | {} | Filters merged into each request |
Example:
import { DictStore } from '@reforgium/statum';
type Country = { code: string; name: string };
const dict = new DictStore<Country>('api/dictionaries/countries', 'countries', {
labelKey: 'name',
valueKey: 'code',
fixed: true,
});
dict.search(''); // initial fill
dict.search('kir'); // local search over cacheComposition Patterns
PagedQueryStore + EntityStore
import { effect } from '@angular/core';
import { EntityStore, PagedQueryStore } from '@reforgium/statum';
type User = { id: number; name: string };
const pages = new PagedQueryStore<User, { search?: string }>('/api/users');
const entities = new EntityStore<User, 'id'>({ idKey: 'id' });
effect(() => {
entities.upsertMany(pages.items());
});This keeps page-loading logic in PagedQueryStore and normalized lookup/update logic in EntityStore.
Recipes
Debounced Remote Table
Use PagedQueryStore as a reusable server-table state layer for filters, lazy paging, and cache-aware navigation.
import { PagedQueryStore } from '@reforgium/statum';
type User = { id: number; name: string; role: string };
type UserFilters = { search?: string; role?: string };
const users = new PagedQueryStore<User, UserFilters>('/api/users', {
baseUrl: '/gateway',
method: 'GET',
debounceTime: 250,
hasCache: true,
cacheSize: 5,
});
await users.fetch({
filters: { search: 'neo', role: 'admin' },
query: { tenant: 'main' },
});
await users.updateByOffset({ first: 20, rows: 20 });Use this when the component should stay thin and pagination/filter state should live outside the template layer.
Latest-Wins Details Loader
Use ResourceStore when route changes or rapid user clicks can trigger overlapping requests.
import { ResourceStore } from '@reforgium/statum';
type UserDetails = { id: number; name: string; email: string };
const details = new ResourceStore<UserDetails>(
{ GET: '/api/users/:id' },
{ ttlMs: 30_000 }
);
async function openUser(id: number) {
details.abortAll();
return details.get(
{ params: { id } },
{ dedupe: true }
);
}Use this when the newest selection should win and stale responses should not keep competing for UI state.
Autocomplete With Cached Dictionary
Use DictStore to separate option loading, debounce, and local filtering from the component.
import { DictStore } from '@reforgium/statum';
type Country = { code: string; name: string };
const countries = new DictStore<Country>('/api/dictionaries/countries', 'countries', {
fixed: true,
labelKey: 'name',
valueKey: 'code',
});
await countries.search('');
countries.search('kir');Use fixed: true when the dataset is small enough to keep locally and you want immediate repeated searches without extra network calls.
Normalize Paged Data For Detail Mutations
Use PagedQueryStore for loading and EntityStore for stable updates after edits.
import { effect } from '@angular/core';
import { EntityStore, PagedQueryStore } from '@reforgium/statum';
type User = { id: number; name: string };
const pages = new PagedQueryStore<User>('/api/users');
const entities = new EntityStore<User, 'id'>({ idKey: 'id' });
effect(() => {
entities.upsertMany(pages.items());
});
entities.patchOne(1, { name: 'Updated' });Use this when list fetching and local item mutations need different lifecycles.
Serializer
Serializer
Utility for serialization/deserialization between layers (UI -> API, objects -> query string).
statum keeps the user-facing serializer API and presets, while the low-level codec engine is shared from hidden @reforgium/internal.
Core API:
| Method | Description | |-------------|------------------------------------------------| | serialize | Prepare data for transport | | deserialize | Parse incoming data / query string | | toQuery | Build query string | | withConfig | Clone serializer with partial config overrides |
Config:
| Option | Type | Default | Description |
|-------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------|--------------------------------|
| mapString | { trim?: boolean } & ParseFormatConfig | {} | String transformation options |
| mapNumber | { fromString?: boolean } & ParseFormatConfig | {} | Number parsing options |
| mapBoolean | { true?: string; false?: string } & ParseFormatConfig | {} | Boolean parsing options |
| mapDate | { dateFormat?: string } & ParseFormatConfig | {} | Date parsing options |
| mapPeriod | { transformMode?: { mode: 'split'; dateFromKeyPostfix: string; dateToKeyPostfix: string } \| { mode: 'join'; concat: string }; dateFormat?: string } & ParseFormatConfig<[Date | null, Date | null]> | {} | Period handling options |
| mapNullable | { remove?: boolean; replaceWith?: string; includeEmptyString?: boolean } & ParseFormatConfig<null | undefined> | {} | Null/undefined handling |
| mapArray | { concatType: 'comma' \| 'multi' \| 'json'; removeNullable?: boolean } & { format?: (val: any[]) => any } | {} | Array parsing options |
| mapObject | { deep: boolean } & { format?: (val: Record<string, any>) => any } | {} | Object transformation options |
| mapFields | Record<string, { type: 'string'\|'number'\|'boolean'\|'array'\|'object'\|'date'\|'period'\|'nullable' } \| { parse: (val: any, data: Record<string, any>) => any; format: (val: any, data: Record<string, any>) => any }> | undefined | Field-specific transformations |
Example:
import { Serializer } from '@reforgium/statum';
const serializer = new Serializer({
mapString: { trim: true },
mapNullable: { remove: true },
});
const body = serializer.serialize({ name: ' Vasya ', active: null });
// => { name: 'Vasya' }Source Structure
- Cache:
src/cache - Stores:
src/stores - Serializer:
src/serializer - Migration:
MIGRATION.md
Compatibility
- Angular: 18+
- Signals: required
- Zone.js: optional
License
MIT
