npm.io
3.3.3 • Published 3h ago

@reforgium/statum

Licence
MIT
Version
3.3.3
Deps
0
Vulns
0
Weekly
0

@reforgium/statum

npm version License: MIT

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 HttpClient services 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/statum

Configuration

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.md and, 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 page 0 and clears page cache first; omitted routeParams preserve the configured route parameters, while filters and query reset to empty objects
  • loadPage(page, { signal }) is the stateless page API for prefetching and virtualized grids; it resolves an explicit page result or rejects AbortError when canceled
  • updatePage(...) can read from cache unless ignoreCache is enabled and remains the stateful navigation API
  • updatePageSize(...) resets cache before loading the first page with the new size
  • updateByOffset(...) maps table offsets into normalized page + size requests
  • latest-wins keeps loading stable during abort/restart request bursts and ignores stale async parse results
  • parallel allows overlapping page requests when the source intentionally supports concurrent fetches

DictStore guarantees:

  • fixed: true performs local search over the loaded cache after initial fill
  • fixed: false delegates search to the server with debounce support
  • Visible options stay normalized to { label, value }

Performance notes:

  • ResourceStore, PagedQueryStore, and DictStore include 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:statum for a current local timing snapshot
  • Run npm run bench:statum:browser and open /test-routing/statum-bench for browser-side render measurements
  • See PERF.md for the latest checked-in baseline captured in this repository

Reproducible examples are covered in tests:

  • dedupe identical requests - many callers hitting the same ResourceStore.get(...) with dedupe: true still produce one transport request
  • cache hit/miss semantics - cache-only fails on a cold key, then cache-first serves the warmed key without network
  • latest-wins abort behavior - PagedQueryStore.updatePage(...) bursts cancel older in-flight requests and only the newest page is applied
  • pagination cache eviction - when the page cache is full, revisiting an evicted page performs a fresh request
  • warm 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 N transport calls to 1 when dedupe: true
  • in the current local snapshot, a 10,000 caller burst is roughly an order of magnitude faster with dedupe enabled
  • the same 10,000 caller 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 localStorage
  • session - browser sessionStorage
  • memory - in-memory storage
  • lru - 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 | null

ResourceStore

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 with strategy: 'cache-first' or 'cache-only' and a cached value exists, parseResponse is 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 onLazyLoad and similar)
  • Small local page cache with eviction
State
Field / Signal Type Description
items Signal<T[]> Current page items
cached Signal<T[]> Flattened cache of cached pages
loading Signal<boolean> Loading indicator
error `Signal<unknown null>`
version Signal<number> Increments when the dataset is reset
page number Current page (0-based)
pageSize number Page size
totalElements number | undefined Exact 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:

  • pageState
  • pageSizeState
  • totalElementsState
  • filtersState
  • queryState
  • sortState
  • routeParamsState
Methods
Method Description
fetch Clean first-page request; omitted routeParams keep current state, while routeParams: {} clears them
refetchWith Repeat request, optional merge overrides: refetchWith({ filters, query })
loadPage Stateless page read: loadPage(page, { signal, ignoreCache }) returns { page, items, pageSize, totalElements? }
updatePage Stateful navigation: 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,desc

Concurrency can be configured per store:

  • latest-wins - abort older in-flight requests before the next page request starts
  • parallel - allow overlapping requests and keep loading() 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
loadPage yes no Stateless prefetch; signal aborts the matching transport and rejects AbortError
updatePage yes no Stateful navigation; can bypass cache 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.

If the transport returns a bare array (T[]) instead of a pageable object, PagedQueryStore treats it as an unknown-total dataset and keeps totalElements === undefined. A plain array does not prove the final dataset size. If your backend knows the exact total, return it explicitly or map the response with parseResponse(...).

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:

  • items
  • loading
  • error
  • page
  • pageSize
  • totalElements
  • sort
  • version
  • updatePage(...)
  • updatePageSize(...)
  • updateSort(...)

When PagedQueryStore is fed by a flat-array response without an exact backend total, totalElements stays undefined. This is intentional: consumers such as @reforgium/data-grid can then stay in open-ended paging mode instead of trusting a fake total derived from data.length.

  • 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 locally
    • fixed: false - search goes to server (passes filter name)
  • 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 cache

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

DictStore cache freshness notes:

  • ttlMs marks persisted dictionary cache as stale after the configured window
  • stale cache is refreshed automatically when ttlMs is set
  • revalidate is deprecated and no longer changes runtime behavior
  • storage updatedAt changes when cache is actually persisted again, not just because wall-clock time passed

For controlled lazy startup, use autoLoad: 'onDemand' and trigger the first fetch explicitly:

const countries = new DictStore<Country>('/api/dictionaries/countries', 'countries', {
  fixed: true,
  autoLoad: 'onDemand',
});

countries.ensureLoaded(); // first request only when the UI actually needs the dictionary

There is also a dirtier opt-in mode for select-like consumers that want loading to start on the first read:

const countries = new DictStore<Country>('/api/dictionaries/countries', 'countries', {
  fixed: true,
  autoLoad: 'onAccess',
});

countries.options(); // first read schedules loading lazily

Use onAccess only when you explicitly accept read-triggered loading semantics. It is guarded against same-turn request storms, but it is still intentionally less pure than onDemand.

The same policy can be moved to the global provider:

provideStatum({
  dict: {
    defaultAutoLoad: 'onDemand',
  },
});

ensureLoaded() is not required for every DictStore. It is only needed when the effective auto-load mode is 'onDemand' and you want the first fetch to happen explicitly. In true, false, or 'whenEmpty' modes it remains an optional manual trigger.

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

Keywords