@reforgium/statum 3.0.0 → 3.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,203 @@
1
+ ## [3.1.0]: 2026-04-01
2
+
3
+ ### Feat:
4
+ - `ResourceStore`: added `observe` option on per-call config; set `observe: 'response'` to pass the full `HttpResponse<T>` to `parseResponse` instead of just the body
5
+ - `ResourceStore`: added request-body pass-through for `FormData`, `Blob`, and `ArrayBuffer`
6
+ - `ResourceStore`: added optional retry jitter (`+-25%`) on top of constant/exponential backoff
7
+
8
+ ### Refactor:
9
+ - `PagedQueryStore`: removed `@deprecated` annotations from direct state setters (`page`, `pageSize`, `filters`, `query`, `totalElements`); setters remain available for low-level integration scenarios
10
+
11
+ ### Fix:
12
+ - `ResourceStore`: aligned query generics with actual transport behavior; query args now support booleans and arrays without reusing payload constraints
13
+ - `ResourceStore`: GET cache lookups now refresh LRU order, while `cache-only` misses no longer create empty cache entries
14
+ - `ResourceStore`: abort and abortAll now cancel underlying `HttpClient` requests via `AbortController` and correctly recognize browser `AbortError`
15
+ - `ResourceStore`: request state promotion and loading cleanup were stabilized for abort and retry flows, including non-promoted entries
16
+ - `PagedQueryStore`: `presetQuery.page` is now optional and defaults to `0`
17
+ - `PagedQueryStore`: `parseRequest` typing now accepts concrete filter dictionaries without conflicting with `AnyDict` call sites
18
+ - `Serializer`: deep object serialization now skips circular references instead of throwing
19
+
20
+ ### Test:
21
+ - `ResourceStore`: added `observe` coverage for cache-first hit, HTTP error propagation, and PUT/PATCH/DELETE methods
22
+ - `ResourceStore`: added coverage for retry backoff/jitter, payload pass-through, abort edge cases, forced eviction, and hot-key LRU behavior
23
+ - `DictStore`, `DictLocalStore`, and `EntityStore`: added regression coverage for uncovered edge scenarios
24
+ - `Serializer`: added regression coverage for direct and indirect circular references plus repeated shared-object serialization
25
+
26
+ ### Docs:
27
+ - `README`: documented `observe: 'response'` behavior and clarified direct setter usage in `PagedQueryStore`
28
+
29
+ ### Chore:
30
+ - package assets now include `CHANGELOG.md` and `LICENSE`
31
+
32
+ ---
33
+
34
+ ## [3.0.1]: 2026-03-31
35
+
36
+ ### Feat:
37
+ - `ResourceStore`: added `observe` option on per-call config - set `observe: 'response'` to pass the full `HttpResponse<T>` to `parseResponse` instead of just the body; useful for reading response headers or status codes
38
+
39
+ ### Refactor:
40
+ - `PagedQueryStore`: removed `@deprecated` annotations from direct state setters (`page`, `pageSize`, `filters`, `query`, `totalElements`); setters remain available for low-level integration scenarios (e.g., external data-grid source contracts)
41
+
42
+ ### Test:
43
+ - `ResourceStore`: added `observe` coverage for cache-first hit, HTTP error propagation, and PUT/PATCH/DELETE methods
44
+
45
+ ---
46
+
47
+ ## [3.0.0]: 2026-03-17
48
+
49
+ ### Feat:
50
+ - `PagedQueryStore`: added first-class sorting state and methods (`setSort`, `updateSort`, `updateSorts`) with standard repeated query serialization (`sort=a,asc&sort=b,desc`)
51
+ - `PagedQueryStore`: added reactive metadata signals (`pageState`, `pageSizeState`, `totalElementsState`, `filtersState`, `queryState`, `sortState`, `routeParamsState`)
52
+ - `PagedQueryStore`: added reactive `error` signal
53
+ - `PagedQueryStore`: added configurable request concurrency (`latest-wins` by default, optional `parallel`)
54
+ - `PagedQueryStore`: added `baseUrl` config option and `defaultBaseUrl` provider default for routing requests through a shared API prefix
55
+ - `PagedQueryStore`: added `disableCacheLimit` config flag to disable LRU eviction and keep all loaded pages in memory
56
+ - `PagedQueryStoreProviderConfig`: added `defaultDisableCacheLimit` global default
57
+ - `DictStore`: added cache freshness controls (`ttlMs`, `revalidate`) and provider defaults (`defaultTtlMs`, `defaultRevalidate`)
58
+ - `DictStore`: added `clearCache()`
59
+ - `Serializer`: added per-field array query serialization override via `mapFields.<key>.concatType`
60
+
61
+ ### Refactor:
62
+ - `PagedQueryStore`: route params are now exposed only via `routeParamsState`; direct `routeParams` getter was removed
63
+ - `PagedQueryStore`: direct `sort` / `routeParams` mutation setters were removed; remaining direct state setters are now deprecated
64
+ - `ResourceStore`: GET setup path no longer uses the extra local rethrow branch while preserving dedupe/cache behavior
65
+
66
+ ### Fix:
67
+ - `PagedQueryStore`: `latest-wins` request bursts now keep `loading` stable until the newest request finishes
68
+ - `PagedQueryStore`: stale async `parseResponse` results are now ignored after a newer request starts, transport is reinitialized, or the store is destroyed
69
+ - `PagedQueryStore.updateConfig(...)` / `copy(...)`: transport is now reinitialized so updated request settings apply immediately
70
+ - `ResourceStore`: query arrays are now serialized as comma-separated values (`a=1,2,3`) instead of collapsing to a single last value
71
+ - `PagedQueryStore`: cache limit resolution is now consistent across constructor/config apply/`updateConfig`, including cache-size updates at runtime
72
+ - `ResourceStore`: empty-string routes are now treated as valid and `baseUrl + ''` no longer produces a trailing slash
73
+ - `DictStore`: stale local cache can now stay visible while background refresh runs, with timestamped storage metadata
74
+ - `LruCache`: limit setter ordering/typing was normalized to keep runtime behavior predictable
75
+ - `Serializer`: query parse/build now respects per-field array concat modes
76
+
77
+ ### Test:
78
+ - added expanded `PagedQueryStore` regression coverage for sorting, cache limits, concurrency, config updates, and source-mode integration
79
+ - added `ResourceStore` regression coverage for query-array serialization and empty-string routes
80
+ - added `DictStore` regression coverage for TTL/revalidate/clear-cache flows
81
+ - added perf/stress suites and report benchmarks for main stores
82
+ - stabilized test setup / Vitest library configuration for the v3 suite
83
+
84
+ ### Docs:
85
+ - `README`: updated positioning, API stability policy, behavioral guarantees, recipes, and `PagedQueryStore` / `data-grid` integration guidance
86
+ - `MIGRATION.md`: added v3 stability note and expanded migration guidance
87
+ - `PERF.md`: added checked-in benchmark/report baseline for main stores
88
+
89
+ ---
90
+
91
+ ## [3.0.0-rc.1]: 2026-02-18
92
+
93
+ ### Feat:
94
+ - `PagedQueryStore`: normalized public API to object-style contracts (`fetch`, `refetchWith`, `updatePage`, `updatePageSize`, `updateByOffset`, `setRouteParams`, `updateConfig`, `copy`)
95
+ - `PagedQueryStore`: `fetch({ filters, query, routeParams })` performs clean first-page request with cache reset
96
+ - `ResourceStore`: added reusable option presets via `RESOURCE_PROFILES` and `createResourceProfile(...)`
97
+ - `Statum`: added `provideStatum(...)` provider helper for DI configuration
98
+
99
+ ### Refactor:
100
+ - `PagedQueryStore`: removed built-in sorting state/transport mapping from store internals (sorting stays at consumer side)
101
+ - `PagedQueryStore`: removed legacy APIs `updateFilters(...)`, `updateQuery(...)`, `setRoute(...)`
102
+ - `DictStore`: migrated internal helper calls to new `PagedQueryStore.fetch(...)` contract
103
+
104
+ ### Fix:
105
+ - `ResourceStore`: `abort(...)` / `abortAll(...)` now clear dedupe inflight references immediately
106
+ - `Serializer` tests: aligned `multi` query-array expectation with current query builder behavior
107
+
108
+ ### Docs:
109
+ - `README`: updated `PagedQueryStore` v3 API and cache behavior matrix
110
+ - `MIGRATION.md`: added v3 old-to-new mapping with concrete replacement examples
111
+
112
+ ---
113
+
114
+ ## [2.1.0]: 2026-02-14
115
+
116
+ ### Feat:
117
+ - `EntityStore`: added normalized entity store (`byId` + `ids`) with `setAll`, `upsert`, `remove`, `patch`, and computed `items`
118
+ - `ResourceStore`: added unified retry policy (`attempts`, `delayMs`, `backoff`, `shouldRetry`) on store and per-call levels
119
+ - `ResourceStore`: added trace hook (`onTrace`) for cache/request lifecycle (`cache-hit/miss/fallback/write`, `request-start/success/error/retry`, `abort`)
120
+ - `Serializer`: added presets export (`serializer.presets`) for quicker setup of common mapping configurations
121
+ - `Stores`: added `resource.scheduler` public test coverage and scheduler stability improvements
122
+ - `Stores`: added integration tests for composed flows (`ResourceStore` + `PaginatedDataStore` + `EntityStore`)
123
+ - `Stores`: added perf/stress test suite for main stores
124
+
125
+ ### Fix:
126
+ - `ResourceStore + KeyedScheduler`: stabilized cancel/reject behavior for abort/debounce scenarios in tests
127
+ - `EntityStore`: fixed generic type narrowing an edge case (`TS2677`) by removing invalid predicate narrowing in `removeMany`
128
+ - `ResourceStore`: expanded models/exports and aligned runtime behavior for retry + tracing hooks
129
+ - `PaginatedDataStore`: refined config/cache behavior and updated tests around cache/refresh flows
130
+ - `DictStore`: refined store behavior and updated coverage for edge scenarios
131
+ - `CacheStrategy` + storages (`local/session/lru`): safety and behavior fixes with updated tests
132
+ - `Serializer`: parsing/normalization fixes and stronger test coverage for edge cases
133
+
134
+ ### Docs:
135
+ - `README`: added composition patterns and updated usage examples for `EntityStore` and `ResourceStore` retry/trace
136
+
137
+ ---
138
+
139
+ ## [2.0.1]: 2026-02-06
140
+
141
+ ### Fix:
142
+ - `ResourceStore`: stable request keys now include payload; use `responseType` in HttpClient requests; avoid unhandled rejections
143
+ - `PaginatedDataStore`: initialize LRU cache size from resolved config; keep cache limit in sync after config changes; clarify `setRouteParams` reset behavior
144
+ - `DictStore`: debounce respects configured `debounceTime`; local filter handles non-string values safely
145
+ - `DictLocalStore`: respects global default `maxOptionsSize`
146
+ - `Serializer`: honor `mapString.parse` without precedence bugs; deep object deserialize; nullable handling preserves falsy values
147
+
148
+ ### Chore:
149
+ - `LruCache`: added `entries()` helper
150
+ - `README`: updated defaults and fixed broken formatting/encoding
151
+
152
+ ---
153
+
154
+ ## [2.0.0]: 2026-01-22
155
+
156
+ ### Feat:
157
+ - `PaginatedDataStore`: added route params update method
158
+ - `DictStore`: added debounce for search
159
+
160
+ ### Fix:
161
+ - `ResourceStore`: improved internal key uniqueness
162
+ - `ResourceStore`: fixed `loading` handling for concurrent requests
163
+ - `LocalStorage`/`SessionStorage`: prefix-based safety for clear operations
164
+
165
+ ---
166
+
167
+ ## [1.0.2]: 2026-01-09
168
+
169
+ ### Chore:
170
+ - improved docs
171
+
172
+ ---
173
+
174
+ ## [1.0.1]: 2026-01-09
175
+
176
+ ### Fix:
177
+ - `DictStore`: fixed search effect handling
178
+ - `PaginatedDataStore`: reordered filter caching
179
+
180
+ ### Feat:
181
+ - `PaginatedDataStore`: made `parseResponse` async
182
+
183
+ ---
184
+
185
+ ## [1.0.0]: 2025-12-24
186
+
187
+ ### Feat:
188
+ - base data grid functionality
189
+ - virtual scrolling for large datasets
190
+ - single-column sorting
191
+ - pagination and infinite scroll
192
+ - customizable cell and header templates
193
+ - pin rows to top and bottom
194
+ - sticky columns left and right
195
+ - text alignment configuration per column
196
+ - min/max column widths
197
+ - single and multiple row selection
198
+ - index column support
199
+ - empty/loading state customization
200
+ - flexible data typing system
201
+
202
+
203
+
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rtommievich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -226,6 +226,28 @@ Transport-level store over HttpClient with cache strategies, deduplication, abor
226
226
  | abort | Abort a specific request |
227
227
  | abortAll | Abort all requests |
228
228
 
229
+ ### observe
230
+
231
+ By default, `parseResponse` (and the resolved Promise value) receive the **response body**.
232
+ Pass `observe: 'response'` to receive the full `HttpResponse` instead — useful for reading response headers or status codes inside `parseResponse`.
233
+
234
+ ```ts
235
+ const result = await store.get(
236
+ { params: { id: '1' }, query: {} },
237
+ {
238
+ observe: 'response',
239
+ parseResponse: (res) => ({
240
+ data: res.body,
241
+ etag: res.headers.get('ETag'),
242
+ }),
243
+ },
244
+ );
245
+ ```
246
+
247
+ `onResponse` (store-level hook) always fires with the full `HttpResponse` regardless of `observe`, and is intended for side-effects only (logging, header extraction).
248
+
249
+ > 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.
250
+
229
251
  Example:
230
252
 
231
253
  ```ts
@@ -397,7 +419,7 @@ store.updateSort({ sort: 'name', order: 'asc' });
397
419
 
398
420
  `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
421
 
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(...)`.
422
+ `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.
401
423
 
402
424
  ### PagedQueryStore + DataGrid source mode
403
425
 
@@ -90,8 +90,11 @@ class Serializer {
90
90
  * @param obj source object
91
91
  * @returns a flat dictionary with string/primitive values
92
92
  */
93
- serialize(obj) {
93
+ serialize(obj, _seen = new WeakSet()) {
94
94
  const result = {};
95
+ if (obj != null && typeof obj === 'object') {
96
+ _seen.add(obj);
97
+ }
95
98
  for (const [key, value] of Object.entries(obj ?? {})) {
96
99
  const fields = this.config.mapFields?.[key];
97
100
  if (fields && 'format' in fields) {
@@ -117,7 +120,10 @@ class Serializer {
117
120
  continue;
118
121
  }
119
122
  }
120
- result[key] = this.serializeElement(value, key);
123
+ result[key] = this.serializeElement(value, key, _seen);
124
+ }
125
+ if (obj != null && typeof obj === 'object') {
126
+ _seen.delete(obj);
121
127
  }
122
128
  return result;
123
129
  }
@@ -206,7 +212,7 @@ class Serializer {
206
212
  withConfig(config) {
207
213
  return new Serializer(this.mergeConfig(this.config, config));
208
214
  }
209
- serializeElement(value, key) {
215
+ serializeElement(value, key, _seen = new WeakSet()) {
210
216
  const fields = this.config.mapFields?.[key || ''];
211
217
  if (fields && 'format' in fields) {
212
218
  return;
@@ -248,11 +254,14 @@ class Serializer {
248
254
  }
249
255
  }
250
256
  if (fields?.type === 'array' || Array.isArray(value)) {
251
- return value.map((it) => this.serializeElement(it));
257
+ return value.map((it) => this.serializeElement(it, undefined, _seen));
252
258
  }
253
259
  if (fields?.type === 'object' || isObject(value)) {
260
+ if (this.config.mapObject.deep && !this.config.mapObject.format && value != null && _seen.has(value)) {
261
+ return undefined;
262
+ }
254
263
  return (this.config.mapObject.format?.(value) ??
255
- (this.config.mapObject.deep ? this.serialize(value) : JSON.stringify(value)));
264
+ (this.config.mapObject.deep ? this.serialize(value, _seen) : JSON.stringify(value)));
256
265
  }
257
266
  }
258
267
  deserializeElement(value, key) {
@@ -638,6 +647,7 @@ class AbortError extends Error {
638
647
  */
639
648
  function isAbort(e) {
640
649
  return (e instanceof AbortError ||
650
+ (e instanceof DOMException && e.name === 'AbortError') ||
641
651
  (typeof e === 'object' && e != null && e?.name === 'AbortError' && e?.message === 'aborted'));
642
652
  }
643
653
  function joinUrl(base, path) {
@@ -906,15 +916,20 @@ class ResourceStore {
906
916
  catch (error) {
907
917
  return Promise.reject(error);
908
918
  }
909
- const entry = this.ensureEntry(key);
910
919
  const strategy = cfg.strategy ?? 'network-first';
911
920
  const ttlMs = cfg.ttlMs ?? this.opts.ttlMs ?? 0;
912
- const fresh = entry.updatedAt != null && Date.now() - entry.updatedAt < ttlMs;
913
- if (cfg.dedupe && entry.inflight) {
921
+ let entry = this.entries.get(key);
922
+ if (entry) {
923
+ // Keep hot keys at the end to approximate LRU order on cache reads too.
924
+ this.entries.delete(key);
925
+ this.entries.set(key, entry);
926
+ }
927
+ const fresh = entry?.updatedAt != null && Date.now() - entry.updatedAt < ttlMs;
928
+ if (cfg.dedupe && entry?.inflight) {
914
929
  return entry.inflight;
915
930
  }
916
931
  if (strategy === 'cache-only') {
917
- if (fresh && entry.data != null) {
932
+ if (fresh && entry?.data != null) {
918
933
  this.trace({ type: 'cache-hit', method: 'GET', key, strategy });
919
934
  this.promoteCurrent(entry, cfg.promote);
920
935
  return Promise.resolve(entry.data);
@@ -922,29 +937,44 @@ class ResourceStore {
922
937
  this.trace({ type: 'cache-miss', method: 'GET', key, strategy });
923
938
  return Promise.reject(new CacheMissError(key));
924
939
  }
925
- if (strategy === 'cache-first' && fresh && !cfg.revalidate && entry.data != null) {
940
+ if (strategy === 'cache-first' && fresh && !cfg.revalidate && entry?.data != null) {
926
941
  this.trace({ type: 'cache-hit', method: 'GET', key, strategy });
927
942
  this.promoteCurrent(entry, cfg.promote);
928
943
  return Promise.resolve(entry.data);
929
944
  }
945
+ entry ??= this.ensureEntry(key);
930
946
  const delay = cfg.delay ?? this.opts.delay ?? 0;
931
947
  const mode = cfg.delayMode ?? this.opts.delayMode ?? 'debounce';
932
948
  const isSWR = strategy === 'cache-first' && entry.data != null;
933
949
  entry.status = isSWR ? 'stale' : 'loading';
950
+ if (!isSWR) {
951
+ entry.error = null;
952
+ }
934
953
  this.promoteCurrent(entry, cfg.promote);
935
954
  const retry = this.resolveRetryConfig(cfg.retry);
936
955
  const taskWithRetry = this.runWithRetry((attempt) => {
937
956
  this.trace({ type: 'request-start', method: 'GET', key, attempt });
938
- return this.scheduler.schedule(key, mode, delay, this.exec$({
939
- req$: this.http.get(url, { params: query, responseType: responseType }),
957
+ entry.controller = new AbortController();
958
+ const options = {
959
+ params: query,
960
+ responseType: responseType,
961
+ observe: 'response',
962
+ signal: entry.controller.signal,
963
+ };
964
+ const scheduled = this.scheduler.schedule(key, mode, delay, this.exec$({
965
+ req$: this.http.get(url, options),
940
966
  entry,
941
967
  promote: cfg.promote,
942
968
  parseFn: cfg.parseResponse,
969
+ observe: cfg.observe,
943
970
  }));
971
+ void scheduled.catch(() => undefined);
972
+ return scheduled;
944
973
  }, retry, {
945
974
  method: 'GET',
946
975
  key,
947
976
  });
977
+ void taskWithRetry.catch(() => undefined);
948
978
  const resolvedTask = taskWithRetry
949
979
  .catch((error) => {
950
980
  if (isAbort(error)) {
@@ -1036,9 +1066,12 @@ class ResourceStore {
1036
1066
  const entry = this.entries.get(key);
1037
1067
  this.trace({ type: 'abort', method, key });
1038
1068
  if (entry) {
1069
+ entry.controller?.abort(reason instanceof Error ? reason : new AbortError(typeof reason === 'string' ? reason : undefined));
1070
+ entry.controller = undefined;
1039
1071
  entry.inflight = undefined;
1040
1072
  if (entry.status === 'loading' || entry.status === 'stale') {
1041
1073
  entry.status = 'idle';
1074
+ this.promoteCurrent(entry, entry.promoted);
1042
1075
  }
1043
1076
  }
1044
1077
  this.scheduler.cancel?.(key, reason);
@@ -1050,10 +1083,14 @@ class ResourceStore {
1050
1083
  */
1051
1084
  abortAll(reason) {
1052
1085
  this.trace({ type: 'abort', method: 'GET', key: '*' });
1086
+ const abortReason = reason instanceof Error ? reason : new AbortError(typeof reason === 'string' ? reason : undefined);
1053
1087
  this.entries.forEach((entry) => {
1088
+ entry.controller?.abort(abortReason);
1089
+ entry.controller = undefined;
1054
1090
  entry.inflight = undefined;
1055
1091
  if (entry.status === 'loading' || entry.status === 'stale') {
1056
1092
  entry.status = 'idle';
1093
+ this.promoteCurrent(entry, entry.promoted);
1057
1094
  }
1058
1095
  });
1059
1096
  this.scheduler.cancelAll?.(reason);
@@ -1076,13 +1113,18 @@ class ResourceStore {
1076
1113
  while (this.entries.size >= this.maxEntries) {
1077
1114
  let keyToDelete = null;
1078
1115
  for (const [key, value] of this.entries.entries()) {
1079
- if (!value.inflight) {
1116
+ if (!value.inflight && !value.controller) {
1080
1117
  keyToDelete = key;
1081
1118
  break;
1082
1119
  }
1083
1120
  }
1084
1121
  if (keyToDelete === null) {
1122
+ // All entries have active requests — forced eviction. Dedupe for the evicted
1123
+ // key will break: the in-flight request will finish but its result won't be cached.
1085
1124
  keyToDelete = this.entries.keys().next().value ?? null;
1125
+ if (keyToDelete !== null) {
1126
+ this.trace({ type: 'abort', method: 'GET', key: keyToDelete });
1127
+ }
1086
1128
  }
1087
1129
  if (keyToDelete === null) {
1088
1130
  break;
@@ -1114,25 +1156,35 @@ class ResourceStore {
1114
1156
  const mode = config.delayMode ?? this.opts.delayMode ?? 'debounce';
1115
1157
  const retry = this.resolveRetryConfig(config.retry);
1116
1158
  entry.status = 'loading';
1159
+ entry.error = null;
1117
1160
  this.promoteCurrent(entry, config.promote);
1118
- let req$;
1119
- if (method === 'DELETE') {
1120
- req$ = this.http.delete(url, {
1121
- body: payload,
1122
- params: query,
1123
- responseType: responseType,
1124
- });
1125
- }
1126
- else {
1127
- // @ts-ignore
1128
- req$ = this.mutationMethods[method](url, payload, {
1129
- params: query,
1130
- responseType: responseType,
1131
- });
1132
- }
1133
1161
  const task = this.runWithRetry((attempt) => {
1134
1162
  this.trace({ type: 'request-start', method, key, attempt });
1135
- return this.scheduler.schedule(key, mode, delay, this.exec$({ req$, entry, promote: config.promote, parseFn: config.parseResponse }));
1163
+ entry.controller = new AbortController();
1164
+ const signal = entry.controller.signal;
1165
+ let req$;
1166
+ if (method === 'DELETE') {
1167
+ // @ts-ignore
1168
+ req$ = this.http.delete(url, {
1169
+ body: payload,
1170
+ params: query,
1171
+ responseType: responseType,
1172
+ observe: 'response',
1173
+ signal,
1174
+ });
1175
+ }
1176
+ else {
1177
+ // @ts-ignore
1178
+ req$ = this.mutationMethods[method](url, payload, {
1179
+ params: query,
1180
+ responseType: responseType,
1181
+ observe: 'response',
1182
+ signal,
1183
+ });
1184
+ }
1185
+ const scheduled = this.scheduler.schedule(key, mode, delay, this.exec$({ req$, entry, promote: config.promote, parseFn: config.parseResponse, observe: config.observe }));
1186
+ void scheduled.catch(() => undefined);
1187
+ return scheduled;
1136
1188
  }, retry, { method, key })
1137
1189
  .then((data) => {
1138
1190
  this.trace({ type: 'cache-write', method, key });
@@ -1181,6 +1233,9 @@ class ResourceStore {
1181
1233
  });
1182
1234
  }
1183
1235
  preparePayload(payload) {
1236
+ if (payload instanceof FormData || payload instanceof Blob || payload instanceof ArrayBuffer) {
1237
+ return payload;
1238
+ }
1184
1239
  const presetPayload = this.opts.presetPayload;
1185
1240
  if (!presetPayload && !payload) {
1186
1241
  return {};
@@ -1214,6 +1269,7 @@ class ResourceStore {
1214
1269
  attempts: Math.max(0, source.attempts ?? 0),
1215
1270
  delayMs: Math.max(0, source.delayMs ?? 0),
1216
1271
  backoff: source.backoff ?? 'constant',
1272
+ jitter: source.jitter ?? false,
1217
1273
  shouldRetry: source.shouldRetry ?? this.defaultShouldRetry,
1218
1274
  };
1219
1275
  }
@@ -1228,29 +1284,41 @@ class ResourceStore {
1228
1284
  }
1229
1285
  return status === 0 || status >= 500;
1230
1286
  }
1231
- async runWithRetry(exec, retry, context) {
1232
- let attempt = 1;
1233
- while (true) {
1234
- try {
1235
- return await exec(attempt);
1287
+ runWithRetry(exec, retry, context) {
1288
+ const runAttempt = (attempt) => exec(attempt).catch((error) => {
1289
+ const canRetry = attempt <= retry.attempts && retry.shouldRetry(error, attempt);
1290
+ if (!canRetry) {
1291
+ throw error;
1236
1292
  }
1237
- catch (error) {
1238
- const canRetry = attempt <= retry.attempts && retry.shouldRetry(error, attempt);
1239
- if (!canRetry) {
1240
- throw error;
1241
- }
1242
- const delayMs = retry.backoff === 'exponential' ? retry.delayMs * 2 ** (attempt - 1) : retry.delayMs;
1243
- this.trace({ type: 'request-retry', method: context.method, key: context.key, attempt, error });
1244
- attempt++;
1245
- if (delayMs > 0) {
1246
- await new Promise((resolve) => setTimeout(resolve, delayMs));
1247
- }
1293
+ const baseDelay = retry.backoff === 'exponential' ? retry.delayMs * 2 ** (attempt - 1) : retry.delayMs;
1294
+ const jitterOffset = retry.jitter ? (Math.random() * 0.5 - 0.25) * baseDelay : 0;
1295
+ const delayMs = Math.max(0, Math.round(baseDelay + jitterOffset));
1296
+ this.trace({ type: 'request-retry', method: context.method, key: context.key, attempt, error });
1297
+ if (delayMs > 0) {
1298
+ return new Promise((resolve, reject) => {
1299
+ setTimeout(() => {
1300
+ void runAttempt(attempt + 1).then(resolve, reject);
1301
+ }, delayMs);
1302
+ });
1248
1303
  }
1249
- }
1304
+ return runAttempt(attempt + 1);
1305
+ });
1306
+ return runAttempt(1);
1250
1307
  }
1251
- exec$ = ({ req$, entry, promote = true, parseFn }) => () => {
1308
+ exec$ = ({ req$, entry, promote = true, parseFn, observe }) => () => {
1252
1309
  promote && this.#activeRequests.update((n) => n + 1);
1253
- return req$.pipe(map((data) => (parseFn ? parseFn(data) : data)), tap({
1310
+ return req$.pipe(map((httpResponse) => {
1311
+ try {
1312
+ this.opts.onResponse?.(httpResponse);
1313
+ }
1314
+ catch {
1315
+ /* noop */
1316
+ }
1317
+ const payload = observe === 'response'
1318
+ ? httpResponse
1319
+ : httpResponse.body;
1320
+ return parseFn ? parseFn(payload) : payload;
1321
+ }), tap({
1254
1322
  next: (data) => {
1255
1323
  entry.data = data;
1256
1324
  entry.status = 'success';
@@ -1265,6 +1333,7 @@ class ResourceStore {
1265
1333
  },
1266
1334
  }), finalize(() => {
1267
1335
  promote && this.#activeRequests.update((n) => Math.max(0, n - 1));
1336
+ entry.controller = undefined;
1268
1337
  this.promoteCurrent(entry, promote);
1269
1338
  }));
1270
1339
  };
@@ -1272,6 +1341,7 @@ class ResourceStore {
1272
1341
  if (!promote) {
1273
1342
  return;
1274
1343
  }
1344
+ entry.promoted = true;
1275
1345
  this.#value.set(entry.data ?? null);
1276
1346
  this.#status.set(entry.status);
1277
1347
  this.#error.set(entry.error);
@@ -1399,37 +1469,18 @@ class PagedQueryStore {
1399
1469
  get sort() {
1400
1470
  return this.#sort();
1401
1471
  }
1402
- /**
1403
- * @deprecated Prefer `fetch(...)`, `refetchWith(...)`, or a dedicated setter method.
1404
- * Direct state mutation bypasses request semantics and should be treated as legacy.
1405
- */
1406
1472
  set filters(value) {
1407
1473
  this.#filters.set(value);
1408
1474
  }
1409
- /**
1410
- * @deprecated Prefer `fetch(...)`, `refetchWith(...)`, or a dedicated setter method.
1411
- * Direct state mutation bypasses request semantics and should be treated as legacy.
1412
- */
1413
1475
  set query(value) {
1414
1476
  this.#query.set(value);
1415
1477
  }
1416
- /**
1417
- * @deprecated Prefer `updatePage(...)`, `fetch(...)`, or `refetchWith(...)`.
1418
- * Direct state mutation bypasses request semantics and should be treated as legacy.
1419
- */
1420
1478
  set page(value) {
1421
1479
  this.#page.set(value);
1422
1480
  }
1423
- /**
1424
- * @deprecated Prefer `updatePageSize(...)`.
1425
- * Direct state mutation bypasses request semantics and should be treated as legacy.
1426
- */
1427
1481
  set pageSize(value) {
1428
1482
  this.#pageSize.set(value);
1429
1483
  }
1430
- /**
1431
- * @deprecated Managed by transport responses. Direct assignment should be treated as legacy.
1432
- */
1433
1484
  set totalElements(value) {
1434
1485
  this.#totalElements.set(value);
1435
1486
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.0.0",
2
+ "version": "3.1.0",
3
3
  "name": "@reforgium/statum",
4
4
  "description": "Signals-first query and data stores for Angular",
5
5
  "author": "rtommievich",
@@ -1,6 +1,7 @@
1
1
  import { AnyType, AnyDict, RestMethods, QueryParams, PageableRequest, PageableResponse } from '@reforgium/internal';
2
2
  import * as _angular_core from '@angular/core';
3
3
  import { Signal, WritableSignal, EnvironmentProviders, InjectionToken } from '@angular/core';
4
+ import { HttpResponse } from '@angular/common/http';
4
5
  import * as _reforgium_statum from '@reforgium/statum';
5
6
 
6
7
  /**
@@ -182,7 +183,7 @@ declare class Serializer<EntityType extends DataType> {
182
183
  * @param obj source object
183
184
  * @returns a flat dictionary with string/primitive values
184
185
  */
185
- serialize(obj: EntityType): SerializedType;
186
+ serialize(obj: EntityType, _seen?: WeakSet<object>): SerializedType;
186
187
  /**
187
188
  * Parse serialized data into a domain object.
188
189
  *
@@ -283,13 +284,17 @@ declare const storageStrategy: <Key = string, Type extends AnyType = AnyDict>(st
283
284
  /**
284
285
  * Object for request body (payload).
285
286
  * Commonly used with POST/PUT/PATCH/DELETE.
287
+ * `FormData`, `Blob`, and `ArrayBuffer` are passed through as-is without serialization.
286
288
  */
287
- type PayloadData = AnyDict;
289
+ type PayloadData = AnyDict | FormData | Blob | ArrayBuffer;
288
290
  /**
289
- * Simple parameters dictionary (string/number).
290
- * Suitable for path params and query.
291
+ * Simple path parameters dictionary (string/number).
291
292
  */
292
293
  type SimpleDict = Record<string, string | number>;
294
+ /**
295
+ * Query parameters dictionary with scalar and array support.
296
+ */
297
+ type QueryDict = Record<string, string | number | boolean | ReadonlyArray<string | number | boolean>>;
293
298
  /**
294
299
  * Resource status in `ResourceStore`:
295
300
  * - `idle` — not loaded yet
@@ -310,6 +315,11 @@ type RetryConfig = {
310
315
  attempts?: number;
311
316
  delayMs?: number;
312
317
  backoff?: RetryBackoff;
318
+ /**
319
+ * Add ±25% random jitter to the retry delay to avoid synchronized retries
320
+ * across multiple clients hitting the same endpoint simultaneously.
321
+ */
322
+ jitter?: boolean;
313
323
  shouldRetry?: (error: unknown, attempt: number) => boolean;
314
324
  };
315
325
  /**
@@ -347,6 +357,12 @@ type ResourceStoreOptions = {
347
357
  retry?: RetryConfig;
348
358
  /** Trace hook for request/cache lifecycle events. */
349
359
  onTrace?: (event: ResourceTraceEvent) => void;
360
+ /**
361
+ * Side-effect hook called with the full `HttpResponse` for every completed request.
362
+ * Useful for reading response headers (e.g. `X-Total-Count`, `Link`, correlation IDs).
363
+ * Errors thrown here are silently swallowed.
364
+ */
365
+ onResponse?: (response: HttpResponse<unknown>) => void;
350
366
  serializer?: Partial<SerializerConfig>;
351
367
  };
352
368
  /**
@@ -355,7 +371,7 @@ type ResourceStoreOptions = {
355
371
  * - `query` — query string parameters
356
372
  * - `payload` — request body (for mutations)
357
373
  */
358
- type CallArgs<Params extends SimpleDict = SimpleDict, Query extends SimpleDict = SimpleDict, Payload extends PayloadData = PayloadData> = {
374
+ type CallArgs<Params extends SimpleDict = SimpleDict, Query extends QueryDict = QueryDict, Payload extends PayloadData = PayloadData> = {
359
375
  params?: Params;
360
376
  query?: Query;
361
377
  payload?: Payload;
@@ -385,6 +401,12 @@ type CallConfig<Response, Type> = {
385
401
  * - 'arraybuffer' - response as an ArrayBuffer
386
402
  */
387
403
  responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';
404
+ /**
405
+ * Controls what is passed to `parseResponse`:
406
+ * - `'body'` (default) — receives the response body (`Response`)
407
+ * - `'response'` — receives the full `HttpResponse<Response>` (headers, status, body)
408
+ */
409
+ observe?: 'body' | 'response';
388
410
  /**
389
411
  * Custom response parser.
390
412
  * Allows converting server `Response` to the domain type `Type`.
@@ -481,7 +503,7 @@ declare class ResourceStore<Data> {
481
503
  * @param cfg Call settings (strategy, ttlMs, revalidate, parseResponse, delay, delayMode, dedupe, promote)
482
504
  * @returns Deserialized `Data`
483
505
  */
484
- get<Param extends SimpleDict = SimpleDict, Query extends PayloadData = PayloadData, Response extends AnyType = Data>(args: CallArgs<Param, Query, never>, cfg?: GetCallConfig<Response, Data>): Promise<Data>;
506
+ get<Param extends SimpleDict = SimpleDict, Query extends QueryDict = QueryDict, Response extends AnyType = Data>(args: CallArgs<Param, Query, never>, cfg?: GetCallConfig<Response, Data>): Promise<Data>;
485
507
  /**
486
508
  * POST request.
487
509
  *
@@ -489,7 +511,7 @@ declare class ResourceStore<Data> {
489
511
  * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
490
512
  * @returns API response (by default — as returned by the server)
491
513
  */
492
- post<Param extends SimpleDict = SimpleDict, Payload extends PayloadData = PayloadData, Query extends PayloadData = PayloadData, Response extends AnyType = AnyType>(args: CallArgs<Param, Query, Payload>, cfg?: CallConfig<Response, Response>): Promise<Response>;
514
+ post<Param extends SimpleDict = SimpleDict, Payload extends PayloadData = PayloadData, Query extends QueryDict = QueryDict, Response extends AnyType = AnyType>(args: CallArgs<Param, Query, Payload>, cfg?: CallConfig<Response, Response>): Promise<Response>;
493
515
  /**
494
516
  * PUT request (full resource update).
495
517
  *
@@ -497,7 +519,7 @@ declare class ResourceStore<Data> {
497
519
  * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
498
520
  * @returns API response (by default — as returned by the server)
499
521
  */
500
- put<Param extends SimpleDict = SimpleDict, Payload extends PayloadData = PayloadData, Query extends SimpleDict = SimpleDict, Response extends AnyType = AnyType>(args: CallArgs<Param, Query, Partial<Payload>>, cfg?: CallConfig<Response, Response>): Promise<Response>;
522
+ put<Param extends SimpleDict = SimpleDict, Payload extends PayloadData = PayloadData, Query extends QueryDict = QueryDict, Response extends AnyType = AnyType>(args: CallArgs<Param, Query, Partial<Payload>>, cfg?: CallConfig<Response, Response>): Promise<Response>;
501
523
  /**
502
524
  * PATCH request (partial update).
503
525
  *
@@ -505,7 +527,7 @@ declare class ResourceStore<Data> {
505
527
  * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
506
528
  * @returns API response (by default — as returned by the server)
507
529
  */
508
- patch<Param extends SimpleDict = SimpleDict, Payload extends PayloadData = PayloadData, Query extends SimpleDict = SimpleDict, Response extends AnyType = AnyType>(args: CallArgs<Param, Query, Partial<Payload>>, cfg?: CallConfig<Response, Response>): Promise<Response>;
530
+ patch<Param extends SimpleDict = SimpleDict, Payload extends PayloadData = PayloadData, Query extends QueryDict = QueryDict, Response extends AnyType = AnyType>(args: CallArgs<Param, Query, Partial<Payload>>, cfg?: CallConfig<Response, Response>): Promise<Response>;
509
531
  /**
510
532
  * DELETE request.
511
533
  *
@@ -513,7 +535,7 @@ declare class ResourceStore<Data> {
513
535
  * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
514
536
  * @returns API response (by default — as returned by the server)
515
537
  */
516
- delete<Param extends SimpleDict = SimpleDict, Payload extends PayloadData = PayloadData, Query extends SimpleDict = SimpleDict, Response extends AnyType = AnyType>(args: CallArgs<Param, Query, Payload>, cfg?: CallConfig<Response, Response>): Promise<Response>;
538
+ delete<Param extends SimpleDict = SimpleDict, Payload extends PayloadData = PayloadData, Query extends QueryDict = QueryDict, Response extends AnyType = AnyType>(args: CallArgs<Param, Query, Payload>, cfg?: CallConfig<Response, Response>): Promise<Response>;
517
539
  /**
518
540
  * Cancel scheduled/running requests for a specific call.
519
541
  *
@@ -521,7 +543,7 @@ declare class ResourceStore<Data> {
521
543
  * @param args Arguments used to build the URL (params, query)
522
544
  * @param reason Cancellation reason (optional)
523
545
  */
524
- abort<Param extends SimpleDict = SimpleDict, Query extends PayloadData = PayloadData>(method: RestMethods, args: CallArgs<Param, Query>, reason?: string | Error): void;
546
+ abort<Param extends SimpleDict = SimpleDict, Query extends QueryDict = QueryDict>(method: RestMethods, args: CallArgs<Param, Query>, reason?: string | Error): void;
525
547
  /**
526
548
  * Cancel all scheduled/running requests for this store.
527
549
  *
@@ -645,13 +667,16 @@ type SetRouteParamsOptions = {
645
667
  abort?: boolean;
646
668
  };
647
669
 
670
+ type BivariantCallback<Args, Return> = {
671
+ bivarianceHack(data: Args): Return;
672
+ }['bivarianceHack'];
648
673
  /**
649
674
  * Configuration for the paginated data store.
650
675
  *
651
676
  * Controls request method, page cache, debounce delay, concurrency policy, and
652
677
  * request/response transformations.
653
678
  */
654
- type PagedQueryStoreConfig<ItemsType extends object, FilterType = unknown> = {
679
+ type PagedQueryStoreConfig<ItemsType extends object, FilterType extends AnyDict = AnyDict> = {
655
680
  /** Optional base URL prepended to the route before the request is sent. */
656
681
  baseUrl?: string;
657
682
  /** Transport HTTP method: `GET`/`POST`/`PATCH`/`PUT`/`DELETE`. Defaults to global config or `POST`. */
@@ -666,9 +691,9 @@ type PagedQueryStoreConfig<ItemsType extends object, FilterType = unknown> = {
666
691
  debounceTime?: number;
667
692
  /** Concurrency policy for overlapping requests. Defaults to `latest-wins`. */
668
693
  concurrency?: PagedQueryConcurrency;
669
- /** Initial pagination params. `page` is required. */
694
+ /** Initial pagination params. Omitted `page` defaults to `0`. */
670
695
  presetQuery?: {
671
- page: number;
696
+ page?: number;
672
697
  pageSize?: number;
673
698
  };
674
699
  /** Initial filters. Will be sent with the first request. */
@@ -680,7 +705,7 @@ type PagedQueryStoreConfig<ItemsType extends object, FilterType = unknown> = {
680
705
  * Useful for mapping `page/pageSize` and selected filter fields to API-specific query keys.
681
706
  * Returned object can contain nullable values; they are filtered before the transport call.
682
707
  */
683
- parseRequest?: (data: PageableRequest & Partial<FilterType> & AnyDict) => AnyDict;
708
+ parseRequest?: BivariantCallback<PageableRequest & Partial<FilterType> & AnyDict, AnyDict>;
684
709
  /**
685
710
  * Custom parser of API response into unified `PageableResponse<ItemsType>`.
686
711
  * Use if the server returns an array or a non-standard structure.
@@ -733,7 +758,7 @@ type PagedQueryStoreProviderConfig = {
733
758
  * effect(() => console.log(ds.items(), ds.loading()));
734
759
  * ```
735
760
  */
736
- declare class PagedQueryStore<ItemsType extends object, FilterType = unknown> {
761
+ declare class PagedQueryStore<ItemsType extends AnyDict, FilterType extends AnyDict = AnyDict> {
737
762
  #private;
738
763
  private route;
739
764
  config: PagedQueryStoreConfig<ItemsType, FilterType>;
@@ -774,29 +799,10 @@ declare class PagedQueryStore<ItemsType extends object, FilterType = unknown> {
774
799
  get totalElements(): number;
775
800
  /** Current sort rules. */
776
801
  get sort(): QuerySortRule[];
777
- /**
778
- * @deprecated Prefer `fetch(...)`, `refetchWith(...)`, or a dedicated setter method.
779
- * Direct state mutation bypasses request semantics and should be treated as legacy.
780
- */
781
802
  set filters(value: Partial<FilterType>);
782
- /**
783
- * @deprecated Prefer `fetch(...)`, `refetchWith(...)`, or a dedicated setter method.
784
- * Direct state mutation bypasses request semantics and should be treated as legacy.
785
- */
786
803
  set query(value: AnyDict);
787
- /**
788
- * @deprecated Prefer `updatePage(...)`, `fetch(...)`, or `refetchWith(...)`.
789
- * Direct state mutation bypasses request semantics and should be treated as legacy.
790
- */
791
804
  set page(value: number);
792
- /**
793
- * @deprecated Prefer `updatePageSize(...)`.
794
- * Direct state mutation bypasses request semantics and should be treated as legacy.
795
- */
796
805
  set pageSize(value: number);
797
- /**
798
- * @deprecated Managed by transport responses. Direct assignment should be treated as legacy.
799
- */
800
806
  set totalElements(value: number);
801
807
  /**
802
808
  * Fetch data with explicit filters and query params from the first page.