npm package: @reforgium/statum
Description: Signals-first API state and query stores for Angular
Author: rtommievich
Latest version: 3.1.4

﻿# @reforgium/statum

[![npm version](https://badge.fury.io/js/%40reforgium%2Fstatum.svg)](https://www.npmjs.com/package/@reforgium/statum)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/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

```bash
npm install @reforgium/statum
```

## Configuration

```ts
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
- `updatePage(...)` can read from cache unless `ignoreCache` is enabled
- `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.

```ts
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:

```ts
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`.

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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          | `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:

- `pageState`
- `pageSizeState`
- `totalElementsState`
- `filtersState`
- `queryState`
- `sortState`
- `routeParamsState`

### 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:

```txt
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 |
| 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:

```ts
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:

```html
<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(...)`
- `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:

```ts
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

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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>                                                                                                                                                                            | `{}`        | String transformation options  |
| mapNumber   | `{ fromString?: boolean }` & ParseFormatConfig<number>                                                                                                                                                                      | `{}`        | Number parsing options         |
| mapBoolean  | `{ true?: string; false?: string }` & ParseFormatConfig<boolean>                                                                                                                                                            | `{}`        | Boolean parsing options        |
| mapDate     | `{ dateFormat?: string }` & ParseFormatConfig<Date>                                                                                                                                                                         | `{}`        | 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:

```ts
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