@reforgium/statum 3.0.0-rc.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,12 +3,13 @@
3
3
  [![npm version](https://badge.fury.io/js/%40reforgium%2Fstatum.svg)](https://www.npmjs.com/package/@reforgium/statum)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- **Signals-first state stores and caching utilities for Angular (18+).**
6
+ **Signals-first query and data stores for Angular (18+).**
7
7
 
8
- `@reforgium/statum` provides a set of **API-oriented stores**, **cache strategies**, and a
9
- **serialization layer** for building predictable data flows in Angular applications.
8
+ `@reforgium/statum` provides **API-oriented stores**, **cache strategies**, and a
9
+ **serialization layer** for Angular applications that talk to HTTP backends.
10
10
 
11
- Designed for **application state**, not abstract reducers.
11
+ Designed for **request orchestration, pagination, dictionaries, and entity state**.
12
+ It is not a reduced framework and does not try to replace NgRx-style global event modeling.
12
13
 
13
14
  ---
14
15
 
@@ -23,31 +24,69 @@ Designed for **application state**, not abstract reducers.
23
24
  - Unified retry and trace hooks in `ResourceStore`
24
25
  - Explicit serialization / deserialization boundary
25
26
 
27
+ ## Best Fit
28
+
29
+ Use `statum` when you need:
30
+
31
+ - HTTP-first application state for CRUD-heavy Angular apps
32
+ - Predictable request behavior (dedupe, abort, retry, latest-wins)
33
+ - Reusable pagination and dictionary flows for tables and forms
34
+ - Lightweight entity normalization without a full reducer architecture
35
+
36
+ `statum` is usually a good fit between:
37
+
38
+ - plain `HttpClient` services that have started to accumulate state logic
39
+ - full application state frameworks where global reducers/effects are too expensive for the problem
40
+
41
+ `statum` is usually not the right fit when:
42
+
43
+ - you need cross-page event sourcing, time-travel tooling, or centralized action logs
44
+ - your app is mostly local UI state with little backend orchestration
45
+ - you want a framework-agnostic data layer
46
+
26
47
  ---
27
48
 
28
- ## Installation
29
-
30
- ```bash
31
- npm install @reforgium/statum
32
- ```
33
-
34
- ## Configuration
35
-
36
- ```ts
37
- import { provideStatum } from '@reforgium/statum';
38
-
39
- providers: [
40
- provideStatum({
41
- pagedQuery: {
42
- defaultMethod: 'GET',
43
- defaultHasCache: true,
44
- defaultCacheSize: 10,
45
- },
46
- }),
47
- ];
48
- ```
49
-
50
- ---
49
+ ## Installation
50
+
51
+ ```bash
52
+ npm install @reforgium/statum
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ ```ts
58
+ import { provideStatum } from '@reforgium/statum';
59
+
60
+ providers: [
61
+ provideStatum({
62
+ pagedQuery: {
63
+ defaultBaseUrl: '/api',
64
+ defaultMethod: 'GET',
65
+ defaultHasCache: true,
66
+ defaultCacheSize: 10,
67
+ },
68
+ }),
69
+ ];
70
+ ```
71
+
72
+ ## API Stability
73
+
74
+ `statum` v3 narrows its public API around explicit store methods and object-style contracts.
75
+
76
+ Stability policy for the documented public surface:
77
+
78
+ - Existing documented methods are treated as stable within `3.x`
79
+ - Signature expansions should stay additive and backward-compatible
80
+ - Behavioral changes should be reflected in `CHANGELOG.md` and, when needed, `MIGRATION.md`
81
+ - Removed or renamed APIs should go through a documented migration path first
82
+
83
+ The safest long-term entry points are:
84
+
85
+ - package exports from `@reforgium/statum`
86
+ - documented store methods in this README
87
+ - provider helpers such as `provideStatum(...)`
88
+
89
+ ---
51
90
 
52
91
  ## Package Structure
53
92
 
@@ -55,6 +94,59 @@ providers: [
55
94
  - **Stores** - reusable state containers over HttpClient
56
95
  - **Serializer** - configurable data transformation utility
57
96
 
97
+ ## Behavioral Guarantees
98
+
99
+ The core stores are designed around explicit, testable runtime guarantees.
100
+
101
+ `ResourceStore` guarantees:
102
+
103
+ - Identical in-flight requests can be deduplicated into one transport call
104
+ - `abort(...)` / `abortAll(...)` cancel active or scheduled requests and clear inflight dedupe references
105
+ - Retry policy can be set globally and overridden per request
106
+ - Trace hooks receive cache/request lifecycle events for observability
107
+
108
+ `PagedQueryStore` guarantees:
109
+
110
+ - `fetch(...)` always starts from page `0` and clears page cache first
111
+ - `updatePage(...)` can read from cache unless `ignoreCache` is enabled
112
+ - `updatePageSize(...)` resets cache before loading the first page with the new size
113
+ - `updateByOffset(...)` maps table offsets into normalized `page + size` requests
114
+ - `latest-wins` keeps `loading` stable during abort/restart request bursts and ignores stale async parse results
115
+ - `parallel` allows overlapping page requests when the source intentionally supports concurrent fetches
116
+
117
+ `DictStore` guarantees:
118
+
119
+ - `fixed: true` performs local search over the loaded cache after initial fill
120
+ - `fixed: false` delegates search to the server with debounce support
121
+ - Visible `options` stay normalized to `{ label, value }`
122
+
123
+ Performance notes:
124
+
125
+ - `ResourceStore`, `PagedQueryStore`, and `DictStore` include stress/perf coverage in the test suite
126
+ - The goal of these tests is behavioral regression detection and upper-bound sanity checks, not synthetic benchmark marketing
127
+ - For production evaluation, prefer measuring with your own API latency, payload size, and change detection profile
128
+ - Run `npm run bench:statum` for a current local timing snapshot
129
+ - Run `npm run bench:statum:browser` and open `/test-routing/statum-bench` for browser-side render measurements
130
+ - See `PERF.md` for the latest checked-in baseline captured in this repository
131
+
132
+ Reproducible examples are covered in tests:
133
+
134
+ - `dedupe identical requests` - many callers hitting the same `ResourceStore.get(...)` with `dedupe: true` still produce one transport request
135
+ - `cache hit/miss semantics` - `cache-only` fails on a cold key, then `cache-first` serves the warmed key without network
136
+ - `latest-wins abort behavior` - `PagedQueryStore.updatePage(...)` bursts cancel older in-flight requests and only the newest page is applied
137
+ - `pagination cache eviction` - when the page cache is full, revisiting an evicted page performs a fresh request
138
+ - `warm cache replay` - replaying the hot cache window stays network-free across the full cached range
139
+
140
+ These examples are intentionally implemented as specs, not ad-hoc benchmark scripts, so they can be rerun in CI and used as regression checks.
141
+
142
+ The checked-in benchmark baseline in `PERF.md` also shows a practical scaling claim for `ResourceStore`:
143
+
144
+ - identical request fan-in collapses from `N` transport calls to `1` when `dedupe: true`
145
+ - in the current local snapshot, a `10,000` caller burst is roughly an order of magnitude faster with dedupe enabled
146
+ - the same `10,000` caller burst reduces memory pressure from tens of MB to low single-digit MB in the deduped path
147
+
148
+ Those numbers are machine-specific and should be treated as a local envelope, not a universal SLA.
149
+
58
150
  ---
59
151
 
60
152
  ## Cache
@@ -152,7 +244,7 @@ const user = await userStore.get(
152
244
  );
153
245
  ```
154
246
 
155
- Retry + trace example:
247
+ Retry + trace example:
156
248
 
157
249
  ```ts
158
250
  import { ResourceStore } from '@reforgium/statum';
@@ -166,22 +258,22 @@ const store = new ResourceStore<{ id: number; name: string }>(
166
258
  }
167
259
  );
168
260
 
169
- await store.get(
170
- { params: { id: '42' } },
171
- { retry: { attempts: 1 } } // per-call override
172
- );
173
- ```
174
-
175
- Profiles example:
176
-
177
- ```ts
178
- import { createResourceProfile, ResourceStore } from '@reforgium/statum';
179
-
180
- const usersStore = new ResourceStore<{ id: number; name: string }>(
181
- { GET: '/users' },
182
- createResourceProfile('table', { baseUrl: '/api' })
183
- );
184
- ```
261
+ await store.get(
262
+ { params: { id: '42' } },
263
+ { retry: { attempts: 1 } } // per-call override
264
+ );
265
+ ```
266
+
267
+ Profiles example:
268
+
269
+ ```ts
270
+ import { createResourceProfile, ResourceStore } from '@reforgium/statum';
271
+
272
+ const usersStore = new ResourceStore<{ id: number; name: string }>(
273
+ { GET: '/users' },
274
+ createResourceProfile('table', { baseUrl: '/api' })
275
+ );
276
+ ```
185
277
 
186
278
  ---
187
279
 
@@ -206,9 +298,9 @@ entities.removeOne(1);
206
298
 
207
299
  ---
208
300
 
209
- ## PagedQueryStore
210
-
211
- Lightweight store for server-side pagination with filtering, dynamic query params, and page cache.
301
+ ## PagedQueryStore
302
+
303
+ Lightweight store for server-side pagination with filtering, dynamic query params, and page cache.
212
304
 
213
305
  ### When to use
214
306
 
@@ -224,35 +316,65 @@ Lightweight store for server-side pagination with filtering, dynamic query param
224
316
  | items | `WritableSignal<T[]>` | Current page items |
225
317
  | cached | `WritableSignal<T[]>` | Flattened cache of cached pages |
226
318
  | loading | `WritableSignal<boolean>` | Loading indicator |
319
+ | error | `WritableSignal<unknown \| null>` | Last request error |
320
+ | version | `WritableSignal<number>` | Increments when the dataset is reset |
227
321
  | page | `number` | Current page (0-based) |
228
322
  | pageSize | `number` | Page size |
229
323
  | totalElements | `number` | Total items on server |
230
- | filters | `Partial<F>` | Active filters |
231
- | query | `Record<string, unknown>` | Active query params |
324
+ | filters | `Partial<F>` | Active filters |
325
+ | query | `Record<string, unknown>` | Active query params |
326
+ | sort | `ReadonlyArray<{ sort: string; order: 'asc' \| 'desc' }>` | Active sort state |
327
+
328
+ Reactive metadata signals are also available:
329
+
330
+ - `pageState`
331
+ - `pageSizeState`
332
+ - `totalElementsState`
333
+ - `filtersState`
334
+ - `queryState`
335
+ - `sortState`
336
+ - `routeParamsState`
232
337
 
233
338
  ### Methods
234
339
 
235
- | Method | Description |
236
- |----------------|---------------------------------------------------------------------------|
237
- | fetch | Clean first-page request: `fetch({ filters, query, routeParams })` |
238
- | refetchWith | Repeat request, optional merge overrides: `refetchWith({ filters, query })` |
239
- | updatePage | Change page: `updatePage(page, { ignoreCache })` |
240
- | updatePageSize | Change page size and reset cache: `updatePageSize(size)` |
241
- | updateByOffset | Table-event mapper: `updateByOffset({ page/first/rows }, { query })` |
242
- | setRouteParams | Update route params: `setRouteParams(params, { reset, abort })` |
243
- | updateConfig | Patch config: `updateConfig(config)` |
244
- | copy | Copy config/meta: `copy(store)` |
245
- | destroy | Manual destroying of caches and abort requests |
246
-
247
- ### Cache behavior
248
-
249
- | Method | Cache read | Cache reset | Notes |
250
- |----------------|------------|-------------|-------|
251
- | fetch | no | yes | Always starts clean from page `0` |
252
- | refetchWith | no | no | Uses active page/filters/query, merges overrides |
253
- | updatePage | yes | no | Can bypass with `ignoreCache: true` |
254
- | updatePageSize | no | yes | Prevents mixed caches for different page sizes |
255
- | updateByOffset | yes | no | Internally maps to `page + size` |
340
+ | Method | Description |
341
+ |----------------|-----------------------------------------------------------------------------------------|
342
+ | fetch | Clean first-page request: `fetch({ filters, query, routeParams })` |
343
+ | refetchWith | Repeat request, optional merge overrides: `refetchWith({ filters, query })` |
344
+ | updatePage | Change page: `updatePage(page, { ignoreCache })` or `updatePage({ page, ignoreCache })` |
345
+ | updatePageSize | Change page size and reset cache: `updatePageSize(size)` |
346
+ | setSort | Update sort state without triggering a request |
347
+ | updateSort | Apply single-sort state and load from the first page |
348
+ | updateSorts | Apply multi-sort state and load from the first page |
349
+ | updateByOffset | Table-event mapper: `updateByOffset({ page/first/rows }, { query })` |
350
+ | setRouteParams | Update route params: `setRouteParams(params, { reset, abort })` |
351
+ | updateConfig | Patch config: `updateConfig(config)` |
352
+ | copy | Copy config/meta: `copy(store)` |
353
+ | destroy | Manual destroying of caches and abort requests |
354
+
355
+ `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.
356
+
357
+ Sorting is built into `PagedQueryStore` and serialized in the common backend-friendly form:
358
+
359
+ ```txt
360
+ sort=name,asc
361
+ sort=name,asc&sort=createdAt,desc
362
+ ```
363
+
364
+ Concurrency can be configured per store:
365
+
366
+ - `latest-wins` - abort older in-flight requests before the next page request starts
367
+ - `parallel` - allow overlapping requests and keep `loading()` true until all active requests finish
368
+
369
+ ### Cache behavior
370
+
371
+ | Method | Cache read | Cache reset | Notes |
372
+ |----------------|------------|-------------|--------------------------------------------------|
373
+ | fetch | no | yes | Always starts clean from page `0` |
374
+ | refetchWith | no | no | Uses active page/filters/query, merges overrides |
375
+ | updatePage | yes | no | Can bypass with `ignoreCache: true` |
376
+ | updatePageSize | no | yes | Prevents mixed caches for different page sizes |
377
+ | updateByOffset | yes | no | Internally maps to `page + size` |
256
378
 
257
379
  Example:
258
380
 
@@ -261,15 +383,51 @@ import { PagedQueryStore } from '@reforgium/statum';
261
383
 
262
384
  type User = { id: number; name: string };
263
385
 
264
- const store = new PagedQueryStore<User, { search?: string }>('api/users', {
265
- method: 'GET',
266
- debounceTime: 200,
267
- });
268
-
269
- store.fetch({ filters: { search: 'neo' }, query: { tenant: 'kg' } });
270
- store.updateByOffset({ first: 20, rows: 20 });
386
+ const store = new PagedQueryStore<User, { search?: string }>('api/users', {
387
+ baseUrl: '/api',
388
+ method: 'GET',
389
+ debounceTime: 200,
390
+ concurrency: 'latest-wins',
391
+ });
392
+
393
+ store.fetch({ filters: { search: 'neo' }, query: { tenant: 'kg' } });
394
+ store.updateByOffset({ first: 20, rows: 20 });
395
+ store.updateSort({ sort: 'name', order: 'asc' });
271
396
  ```
272
397
 
398
+ `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.
399
+
400
+ Direct state mutation setters for `page`, `pageSize`, `filters`, `query`, and `totalElements` still exist for backward compatibility, but they are deprecated in favor of explicit store methods. `sort` and `routeParams` should be changed only through `setSort(...)` and `setRouteParams(...)`.
401
+
402
+ ### PagedQueryStore + DataGrid source mode
403
+
404
+ `PagedQueryStore` can be passed directly into `@reforgium/data-grid` `source` mode:
405
+
406
+ ```html
407
+ <re-data-grid
408
+ mode="infinity"
409
+ [columns]="columns"
410
+ [source]="usersStore"
411
+ />
412
+ ```
413
+
414
+ This works because the store already exposes the expected source contract:
415
+
416
+ - `items`
417
+ - `loading`
418
+ - `error`
419
+ - `page`
420
+ - `pageSize`
421
+ - `totalElements`
422
+ - `sort`
423
+ - `version`
424
+ - `updatePage(...)`
425
+ - `updatePageSize(...)`
426
+ - `updateSort(...)`
427
+ - `updateSorts(...)`
428
+
429
+ 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.
430
+
273
431
  ---
274
432
 
275
433
  ## DictStore
@@ -364,6 +522,107 @@ This keeps page-loading logic in `PagedQueryStore` and normalized lookup/update
364
522
 
365
523
  ---
366
524
 
525
+ ## Recipes
526
+
527
+ ### Debounced Remote Table
528
+
529
+ Use `PagedQueryStore` as a reusable server-table state layer for filters, lazy paging, and cache-aware navigation.
530
+
531
+ ```ts
532
+ import { PagedQueryStore } from '@reforgium/statum';
533
+
534
+ type User = { id: number; name: string; role: string };
535
+ type UserFilters = { search?: string; role?: string };
536
+
537
+ const users = new PagedQueryStore<User, UserFilters>('/api/users', {
538
+ baseUrl: '/gateway',
539
+ method: 'GET',
540
+ debounceTime: 250,
541
+ hasCache: true,
542
+ cacheSize: 5,
543
+ });
544
+
545
+ await users.fetch({
546
+ filters: { search: 'neo', role: 'admin' },
547
+ query: { tenant: 'main' },
548
+ });
549
+
550
+ await users.updateByOffset({ first: 20, rows: 20 });
551
+ ```
552
+
553
+ Use this when the component should stay thin and pagination/filter state should live outside the template layer.
554
+
555
+ ### Latest-Wins Details Loader
556
+
557
+ Use `ResourceStore` when route changes or rapid user clicks can trigger overlapping requests.
558
+
559
+ ```ts
560
+ import { ResourceStore } from '@reforgium/statum';
561
+
562
+ type UserDetails = { id: number; name: string; email: string };
563
+
564
+ const details = new ResourceStore<UserDetails>(
565
+ { GET: '/api/users/:id' },
566
+ { ttlMs: 30_000 }
567
+ );
568
+
569
+ async function openUser(id: number) {
570
+ details.abortAll();
571
+
572
+ return details.get(
573
+ { params: { id } },
574
+ { dedupe: true }
575
+ );
576
+ }
577
+ ```
578
+
579
+ Use this when the newest selection should win and stale responses should not keep competing for UI state.
580
+
581
+ ### Autocomplete With Cached Dictionary
582
+
583
+ Use `DictStore` to separate option loading, debounce, and local filtering from the component.
584
+
585
+ ```ts
586
+ import { DictStore } from '@reforgium/statum';
587
+
588
+ type Country = { code: string; name: string };
589
+
590
+ const countries = new DictStore<Country>('/api/dictionaries/countries', 'countries', {
591
+ fixed: true,
592
+ labelKey: 'name',
593
+ valueKey: 'code',
594
+ });
595
+
596
+ await countries.search('');
597
+ countries.search('kir');
598
+ ```
599
+
600
+ Use `fixed: true` when the dataset is small enough to keep locally and you want immediate repeated searches without extra network calls.
601
+
602
+ ### Normalize Paged Data For Detail Mutations
603
+
604
+ Use `PagedQueryStore` for loading and `EntityStore` for stable updates after edits.
605
+
606
+ ```ts
607
+ import { effect } from '@angular/core';
608
+ import { EntityStore, PagedQueryStore } from '@reforgium/statum';
609
+
610
+ type User = { id: number; name: string };
611
+
612
+ const pages = new PagedQueryStore<User>('/api/users');
613
+ const entities = new EntityStore<User, 'id'>({ idKey: 'id' });
614
+
615
+ effect(() => {
616
+ entities.upsertMany(pages.items());
617
+ });
618
+
619
+ entities.patchOne(1, { name: 'Updated' });
620
+ ```
621
+
622
+ Use this when list fetching and local item mutations need different lifecycles.
623
+
624
+ ---
625
+
367
626
  ## Serializer
368
627
 
369
628
  ### Serializer
@@ -409,12 +668,12 @@ const body = serializer.serialize({ name: ' Vasya ', active: null });
409
668
 
410
669
  ---
411
670
 
412
- ## Source Structure
413
-
414
- - Cache: `src/cache`
415
- - Stores: `src/stores`
416
- - Serializer: `src/serializer`
417
- - Migration: `MIGRATION.md`
671
+ ## Source Structure
672
+
673
+ - Cache: `src/cache`
674
+ - Stores: `src/stores`
675
+ - Serializer: `src/serializer`
676
+ - Migration: `MIGRATION.md`
418
677
 
419
678
  ---
420
679