@reforgium/statum 3.1.6 → 3.3.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 CHANGED
@@ -1,39 +1,80 @@
1
- ## [3.1.6]: 2026-04-17
1
+ ## [3.3.0]: 2026-05-29
2
2
 
3
- ### Fix:
4
- - `PagedQueryStore`: plain array responses (`T[]`) no longer synthesize `totalElements = data.length`; `parseFlatArray()` now keeps `totalElements` as `undefined` unless the backend or a custom `parseResponse(...)` provides an exact total explicitly
5
-
6
- ### Test:
7
- - updated flat-array regression coverage to assert unknown-total semantics instead of the old inferred-length behavior
8
-
9
- ### Docs:
10
- - `README`: clarified that bare array responses are treated as unknown-total datasets and that exact totals must come from the backend or a custom `parseResponse(...)`
11
-
12
- ---
13
-
14
- ## [3.1.5]: 2026-04-14
15
-
16
- ### Fix:
17
- - `PagedQueryStore`: page cache is now invalidated when `routeParams` change even if `reset: false` is used, so long-lived singleton-like stores do not replay cached pages from the previous logical dataset/session after a screen remount or route-context change
18
- - `PagedQueryStore.updateConfig(...)` / `copy(...)`: cached page window is now cleared before transport reconfiguration so reused source instances do not leak stale buffered pages into `@reforgium/data-grid` source mode
3
+ ### Feat:
4
+ - `PagedQueryLocalStore`: added a public local/in-memory counterpart of `PagedQueryStore` with the same page/sort/version surface, so consumer apps can mock paged datasets or work with locally assembled collections without losing `DataGrid` source compatibility
5
+ - `ResourceMockStore`: added a public handler-driven in-memory counterpart of `ResourceStore` with the same `value/status/error/loading` signals and `get/post/put/patch/delete` entry points for API-less feature work and service mocking
19
6
 
20
7
  ### Test:
21
- - added regression coverage for cache invalidation on route-param session changes without store destruction
8
+ - added regression coverage for local page slicing, local multi-sort, custom local resolvers, seeded resource reads, cache-first mock reads, and handler-driven mock mutations
22
9
 
23
10
  ---
24
11
 
25
- ## [3.1.4]: 2026-04-10
12
+ ## [3.2.1]: 2026-05-11
26
13
 
27
14
  ### Fix:
28
- - `PagedQueryStore`: `totalElements` now preserves `undefined` when `parseResponse` omits the field, so unknown-total datasets stay open-ended instead of being forced back to `0` after reset/refetch.
29
- - `PagedQueryStore`: `refetchWith(...)` now treats real filters/query/sort changes as dataset replacement, clearing page cache/items and bumping `version` so `@reforgium/data-grid` infinity buffers do not keep stale rows after an empty successful response.
30
-
31
- ### Test:
32
- - added regression coverage for omitted `totalElements`, reset/refetch clearing, and empty-result unknown-total flows
33
-
34
- ---
35
-
36
- ## [3.1.3]: 2026-04-06
15
+ - `PagedQueryStore`: fixed `parseRequest(...)` input typing to match actual runtime behavior; the hook now explicitly receives pre-serialization sort rules (`QuerySortRule[]`) instead of a transport-level `sort: string | string[]` contract
16
+
17
+ ### Test:
18
+ - added regression coverage proving that `parseRequest(...)` receives object sort rules while the outgoing HTTP query still serializes them as repeated `sort=name,asc` params
19
+
20
+ ---
21
+
22
+ ## [3.2.0]: 2026-04-24
23
+
24
+ ### Feat:
25
+ - `DictStore`: added `autoLoad: 'onDemand'` and `ensureLoaded()` for controlled lazy startup; dictionaries can now stay passive on construction and fetch only when a consumer explicitly needs them
26
+ - `DictStore`: added `autoLoad: 'onAccess'` as an explicitly dirty opt-in mode for UI consumers that want the first read of `items()` / `options()` to lazily schedule loading
27
+ - `DictStoreProviderConfig`: added `defaultAutoLoad` so the default startup policy can be set once at provider level instead of repeating `autoLoad` on every dictionary instance
28
+
29
+ ### Fix:
30
+ - `DictStore`: `ttlMs` now enables stale-cache revalidation by default when `revalidate` is omitted; stale dictionaries no longer look like they have TTL configured while silently skipping refresh
31
+ - `DictStore`: stale-cache refresh now depends only on `ttlMs`; deprecated `revalidate` no longer changes runtime behavior, so TTL-based freshness is no longer split across two partially overlapping flags
32
+
33
+ ### Test:
34
+ - added regression coverage for deprecated `revalidate: false` compatibility, on-demand loading, and on-access lazy activation
35
+
36
+ ### Docs:
37
+ - `README`: clarified that `ttlMs` implies background revalidation unless `revalidate` is set to `false` explicitly
38
+ - `README`: documented `autoLoad: 'onDemand'`, `autoLoad: 'onAccess'`, `ensureLoaded()`, provider-level `defaultAutoLoad`, and the deprecation of `revalidate`
39
+
40
+ ---
41
+
42
+ ## [3.1.6]: 2026-04-17
43
+
44
+ ### Fix:
45
+ - `PagedQueryStore`: plain array responses (`T[]`) no longer synthesize `totalElements = data.length`; `parseFlatArray()` now keeps `totalElements` as `undefined` unless the backend or a custom `parseResponse(...)` provides an exact total explicitly
46
+
47
+ ### Test:
48
+ - updated flat-array regression coverage to assert unknown-total semantics instead of the old inferred-length behavior
49
+
50
+ ### Docs:
51
+ - `README`: clarified that bare array responses are treated as unknown-total datasets and that exact totals must come from the backend or a custom `parseResponse(...)`
52
+
53
+ ---
54
+
55
+ ## [3.1.5]: 2026-04-14
56
+
57
+ ### Fix:
58
+ - `PagedQueryStore`: page cache is now invalidated when `routeParams` change even if `reset: false` is used, so long-lived singleton-like stores do not replay cached pages from the previous logical dataset/session after a screen remount or route-context change
59
+ - `PagedQueryStore.updateConfig(...)` / `copy(...)`: cached page window is now cleared before transport reconfiguration so reused source instances do not leak stale buffered pages into `@reforgium/data-grid` source mode
60
+
61
+ ### Test:
62
+ - added regression coverage for cache invalidation on route-param session changes without store destruction
63
+
64
+ ---
65
+
66
+ ## [3.1.4]: 2026-04-10
67
+
68
+ ### Fix:
69
+ - `PagedQueryStore`: `totalElements` now preserves `undefined` when `parseResponse` omits the field, so unknown-total datasets stay open-ended instead of being forced back to `0` after reset/refetch.
70
+ - `PagedQueryStore`: `refetchWith(...)` now treats real filters/query/sort changes as dataset replacement, clearing page cache/items and bumping `version` so `@reforgium/data-grid` infinity buffers do not keep stale rows after an empty successful response.
71
+
72
+ ### Test:
73
+ - added regression coverage for omitted `totalElements`, reset/refetch clearing, and empty-result unknown-total flows
74
+
75
+ ---
76
+
77
+ ## [3.1.3]: 2026-04-06
37
78
 
38
79
  ### Fix:
39
80
  - `PagedQueryStore`: all public state getters (`filters`, `query`, `page`, `pageSize`, `totalElements`, `sort`) and direct `#routeParams` reads now wrap the underlying signal in `untracked()`; previously calling `fetch()`, `refetchWith()`, `updatePage()`, `setRouteParams()`, etc. from inside an Angular `effect()` or `computed()` would inadvertently subscribe the reactive context to internal state signals, causing cascading re-runs; reactive subscriptions remain available exclusively through the `*State` readonly signals (`filtersState`, `sortState`, `pageState`, …)
package/README.md CHANGED
@@ -18,11 +18,12 @@ It is not a reduced framework and does not try to replace NgRx-style global even
18
18
  - Signals-first API (`signal()` everywhere)
19
19
  - API-driven stores (resource / paginated / dictionaries)
20
20
  - Normalized entity store (`EntityStore`)
21
- - Built-in cache strategies (TTL, prefix grouping)
22
- - Deterministic request lifecycle: loading / error / data
23
- - Optional transport-level: dedupe, debounce/throttle, abort
24
- - Unified retry and trace hooks in `ResourceStore`
25
- - Explicit serialization / deserialization boundary
21
+ - Built-in cache strategies (TTL, prefix grouping)
22
+ - Deterministic request lifecycle: loading / error / data
23
+ - Optional transport-level: dedupe, debounce/throttle, abort
24
+ - Unified retry and trace hooks in `ResourceStore`
25
+ - Public local/mock store counterparts for API-less flows
26
+ - Explicit serialization / deserialization boundary
26
27
 
27
28
  ## Best Fit
28
29
 
@@ -38,13 +39,18 @@ Use `statum` when you need:
38
39
  - plain `HttpClient` services that have started to accumulate state logic
39
40
  - full application state frameworks where global reducers/effects are too expensive for the problem
40
41
 
41
- `statum` is usually not the right fit when:
42
+ `statum` is usually not the right fit when:
42
43
 
43
44
  - you need cross-page event sourcing, time-travel tooling, or centralized action logs
44
45
  - your app is mostly local UI state with little backend orchestration
45
- - you want a framework-agnostic data layer
46
-
47
- ---
46
+ - you want a framework-agnostic data layer
47
+
48
+ For API-less feature work and staged backend delivery:
49
+
50
+ - use `PagedQueryLocalStore` when the consumer still wants a `PagedQueryStore`-like source for `DataGrid`
51
+ - use `ResourceMockStore` when the consumer wants `ResourceStore`-like signals and CRUD entry points backed by local handlers instead of HTTP
52
+
53
+ ---
48
54
 
49
55
  ## Installation
50
56
 
@@ -616,17 +622,60 @@ import { DictStore } from '@reforgium/statum';
616
622
 
617
623
  type Country = { code: string; name: string };
618
624
 
619
- const countries = new DictStore<Country>('/api/dictionaries/countries', 'countries', {
620
- fixed: true,
621
- labelKey: 'name',
622
- valueKey: 'code',
623
- });
625
+ const countries = new DictStore<Country>('/api/dictionaries/countries', 'countries', {
626
+ fixed: true,
627
+ labelKey: 'name',
628
+ valueKey: 'code',
629
+ });
624
630
 
625
631
  await countries.search('');
626
632
  countries.search('kir');
627
633
  ```
628
634
 
629
- Use `fixed: true` when the dataset is small enough to keep locally and you want immediate repeated searches without extra network calls.
635
+ Use `fixed: true` when the dataset is small enough to keep locally and you want immediate repeated searches without extra network calls.
636
+
637
+ `DictStore` cache freshness notes:
638
+
639
+ - `ttlMs` marks persisted dictionary cache as stale after the configured window
640
+ - stale cache is refreshed automatically when `ttlMs` is set
641
+ - `revalidate` is deprecated and no longer changes runtime behavior
642
+ - storage `updatedAt` changes when cache is actually persisted again, not just because wall-clock time passed
643
+
644
+ For controlled lazy startup, use `autoLoad: 'onDemand'` and trigger the first fetch explicitly:
645
+
646
+ ```ts
647
+ const countries = new DictStore<Country>('/api/dictionaries/countries', 'countries', {
648
+ fixed: true,
649
+ autoLoad: 'onDemand',
650
+ });
651
+
652
+ countries.ensureLoaded(); // first request only when the UI actually needs the dictionary
653
+ ```
654
+
655
+ There is also a dirtier opt-in mode for select-like consumers that want loading to start on the first read:
656
+
657
+ ```ts
658
+ const countries = new DictStore<Country>('/api/dictionaries/countries', 'countries', {
659
+ fixed: true,
660
+ autoLoad: 'onAccess',
661
+ });
662
+
663
+ countries.options(); // first read schedules loading lazily
664
+ ```
665
+
666
+ Use `onAccess` only when you explicitly accept read-triggered loading semantics. It is guarded against same-turn request storms, but it is still intentionally less pure than `onDemand`.
667
+
668
+ The same policy can be moved to the global provider:
669
+
670
+ ```ts
671
+ provideStatum({
672
+ dict: {
673
+ defaultAutoLoad: 'onDemand',
674
+ },
675
+ });
676
+ ```
677
+
678
+ `ensureLoaded()` is not required for every `DictStore`. It is only needed when the effective auto-load mode is `'onDemand'` and you want the first fetch to happen explicitly. In `true`, `false`, or `'whenEmpty'` modes it remains an optional manual trigger.
630
679
 
631
680
  ### Normalize Paged Data For Detail Mutations
632
681
 
@@ -1,7 +1,7 @@
1
- import { Serializer, fillUrlWithParams, mergeQueryParams, LruCache, deepEqual, isNullable, normalizeSortInput, sortInputToTokens, debounceSignal, storageStrategy } from '@reforgium/internal';
1
+ import { Serializer, fillUrlWithParams, mergeQueryParams, deepEqual, normalizeSortInput, LruCache, isNullable, sortInputToTokens, debounceSignal, storageStrategy } from '@reforgium/internal';
2
2
  export { LocalStorage, LruCache, MemoryStorage, Serializer, SerializerFieldError, SessionStorage, storageStrategy } from '@reforgium/internal';
3
+ import { signal, computed, InjectionToken, makeEnvironmentProviders, inject, untracked, EnvironmentInjector, DestroyRef, runInInjectionContext, effect } from '@angular/core';
3
4
  import { HttpClient, HttpParams } from '@angular/common/http';
4
- import { InjectionToken, makeEnvironmentProviders, inject, signal, computed, EnvironmentInjector, DestroyRef, untracked, runInInjectionContext, effect } from '@angular/core';
5
5
  import { Subject, filter, timer, merge, map } from 'rxjs';
6
6
  import { debounce, tap, throttle, finalize } from 'rxjs/operators';
7
7
 
@@ -30,10 +30,6 @@ const createStrictSerializer = (config = {}) => {
30
30
  });
31
31
  };
32
32
 
33
- // noinspection ES6PreferShortImport
34
- const STATUM_CONFIG = new InjectionToken('RE_STATUM_CONFIG');
35
- const provideStatum = (config) => makeEnvironmentProviders([{ provide: STATUM_CONFIG, useValue: config }]);
36
-
37
33
  /**
38
34
  * Error thrown when requested data is missing in the cache.
39
35
  *
@@ -119,6 +115,170 @@ function stableStringify(value) {
119
115
  return String(value);
120
116
  }
121
117
 
118
+ /**
119
+ * Lightweight in-memory counterpart of `ResourceStore`.
120
+ *
121
+ * It preserves the result-facing API (`value/status/error/loading` + CRUD methods),
122
+ * but does not try to reproduce transport, retry, or per-key cache internals.
123
+ */
124
+ class ResourceMockStore {
125
+ #value = signal(null, ...(ngDevMode ? [{ debugName: "#value" }] : /* istanbul ignore next */ []));
126
+ #status = signal('idle', ...(ngDevMode ? [{ debugName: "#status" }] : /* istanbul ignore next */ []));
127
+ #error = signal(null, ...(ngDevMode ? [{ debugName: "#error" }] : /* istanbul ignore next */ []));
128
+ #activeRequests = signal(0, ...(ngDevMode ? [{ debugName: "#activeRequests" }] : /* istanbul ignore next */ []));
129
+ value = this.#value.asReadonly();
130
+ status = this.#status.asReadonly();
131
+ error = this.#error.asReadonly();
132
+ loading = computed(() => this.#activeRequests() > 0 || this.#status() === 'stale', ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
133
+ routes;
134
+ opts;
135
+ constructor(routes = {}, opts = {}) {
136
+ this.routes = routes;
137
+ this.opts = opts;
138
+ if (opts.seed !== undefined) {
139
+ this.#value.set(opts.seed);
140
+ this.#status.set(opts.seed == null ? 'idle' : 'success');
141
+ }
142
+ }
143
+ setValue(value) {
144
+ this.#value.set(value);
145
+ this.#status.set(value == null ? 'idle' : 'success');
146
+ this.#error.set(null);
147
+ }
148
+ patchValue(patch) {
149
+ const current = this.#value();
150
+ if (!current || typeof current !== 'object') {
151
+ this.#value.set(patch);
152
+ }
153
+ else {
154
+ this.#value.set({ ...current, ...patch });
155
+ }
156
+ this.#status.set('success');
157
+ this.#error.set(null);
158
+ }
159
+ reset(value = this.opts.seed ?? null) {
160
+ this.#value.set(value);
161
+ this.#status.set(value == null ? 'idle' : 'success');
162
+ this.#error.set(null);
163
+ }
164
+ get(args, cfg = {}) {
165
+ const strategy = cfg.strategy ?? 'network-first';
166
+ if (strategy === 'cache-only' && this.#value() == null && !this.opts.handlers?.GET) {
167
+ return Promise.reject(new CacheMissError(this.buildUrl('GET', args)));
168
+ }
169
+ if (strategy === 'cache-first' && this.#value() != null && !cfg.revalidate) {
170
+ return Promise.resolve(this.#value());
171
+ }
172
+ return this.run('GET', args, async () => {
173
+ const url = this.buildUrl('GET', args);
174
+ const handler = this.opts.handlers?.GET;
175
+ const result = handler
176
+ ? await handler({ method: 'GET', route: this.routes.GET, url, args, current: this.#value() })
177
+ : this.#value();
178
+ if (result == null) {
179
+ throw new Error('ResourceMockStore GET has no handler result and no current value.');
180
+ }
181
+ const parsed = cfg.parseResponse ? cfg.parseResponse(result) : result;
182
+ this.#value.set(parsed);
183
+ this.#status.set('success');
184
+ this.#error.set(null);
185
+ return parsed;
186
+ });
187
+ }
188
+ post(args, cfg = {}) {
189
+ return this.runMutation('POST', args, cfg);
190
+ }
191
+ put(args, cfg = {}) {
192
+ return this.runMutation('PUT', args, cfg);
193
+ }
194
+ patch(args, cfg = {}) {
195
+ return this.runMutation('PATCH', args, cfg);
196
+ }
197
+ delete(args, cfg = {}) {
198
+ return this.runMutation('DELETE', args, cfg);
199
+ }
200
+ abort() { }
201
+ abortAll() { }
202
+ runMutation(method, args, cfg) {
203
+ return this.run(method, args, async () => {
204
+ const result = await this.resolveMutation(method, args);
205
+ const parsed = cfg.parseResponse ? cfg.parseResponse(result) : result;
206
+ this.#value.set(parsed ?? null);
207
+ this.#status.set('success');
208
+ this.#error.set(null);
209
+ return parsed;
210
+ });
211
+ }
212
+ async resolveMutation(method, args) {
213
+ const url = this.buildUrl(method, args);
214
+ const handler = this.opts.handlers?.[method];
215
+ if (handler) {
216
+ return handler({
217
+ method,
218
+ route: this.routes[method],
219
+ url,
220
+ args: args,
221
+ current: this.#value(),
222
+ });
223
+ }
224
+ if (method === 'DELETE') {
225
+ return null;
226
+ }
227
+ if (method === 'PATCH') {
228
+ const current = this.#value();
229
+ const patch = args.payload;
230
+ if (current && typeof current === 'object' && patch && typeof patch === 'object' && !Array.isArray(patch)) {
231
+ return { ...current, ...patch };
232
+ }
233
+ }
234
+ return args.payload ?? this.#value();
235
+ }
236
+ run(method, args, exec) {
237
+ const url = this.buildUrl(method, args);
238
+ const delay = this.opts.delay ?? 0;
239
+ this.#activeRequests.update((count) => count + 1);
240
+ this.#status.set(this.#value() == null ? 'loading' : 'stale');
241
+ this.trace({ type: 'request-start', method, key: url, attempt: 1 });
242
+ return new Promise((resolve, reject) => {
243
+ setTimeout(() => {
244
+ void exec()
245
+ .then((result) => {
246
+ this.trace({ type: 'request-success', method, key: url });
247
+ resolve(result);
248
+ })
249
+ .catch((error) => {
250
+ this.#status.set('error');
251
+ this.#error.set(error);
252
+ this.trace({ type: 'request-error', method, key: url, error });
253
+ reject(error);
254
+ })
255
+ .finally(() => {
256
+ this.#activeRequests.update((count) => Math.max(0, count - 1));
257
+ });
258
+ }, delay);
259
+ });
260
+ }
261
+ buildUrl(method, args) {
262
+ const template = this.routes[method];
263
+ if (template == null) {
264
+ throw new Error(`${method} route not configured`);
265
+ }
266
+ return joinUrl(this.opts.baseUrl, fillUrlWithParams(template, args.params));
267
+ }
268
+ trace(event) {
269
+ try {
270
+ this.opts.onTrace?.(event);
271
+ }
272
+ catch {
273
+ /* noop */
274
+ }
275
+ }
276
+ }
277
+
278
+ // noinspection ES6PreferShortImport
279
+ const STATUM_CONFIG = new InjectionToken('RE_STATUM_CONFIG');
280
+ const provideStatum = (config) => makeEnvironmentProviders([{ provide: STATUM_CONFIG, useValue: config }]);
281
+
122
282
  /**
123
283
  * Per-key task scheduler with `debounce`/`throttle` and result deduplication.
124
284
  *
@@ -811,6 +971,251 @@ const createResourceProfile = (profile, overrides = {}) => ({
811
971
  ...overrides,
812
972
  });
813
973
 
974
+ /**
975
+ * Lightweight local counterpart of `PagedQueryStore`.
976
+ *
977
+ * It keeps the same result-facing shape needed by consumers and `DataGrid source`,
978
+ * but resolves pages from an in-memory collection instead of HTTP.
979
+ */
980
+ class PagedQueryLocalStore {
981
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
982
+ cached = signal([], ...(ngDevMode ? [{ debugName: "cached" }] : /* istanbul ignore next */ []));
983
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
984
+ error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
985
+ version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ []));
986
+ allItemsState = signal([], ...(ngDevMode ? [{ debugName: "allItemsState" }] : /* istanbul ignore next */ []));
987
+ pageState = signal(0, ...(ngDevMode ? [{ debugName: "pageState" }] : /* istanbul ignore next */ []));
988
+ pageSizeState = signal(20, ...(ngDevMode ? [{ debugName: "pageSizeState" }] : /* istanbul ignore next */ []));
989
+ totalElementsState = signal(undefined, ...(ngDevMode ? [{ debugName: "totalElementsState" }] : /* istanbul ignore next */ []));
990
+ filtersState = signal({}, ...(ngDevMode ? [{ debugName: "filtersState" }] : /* istanbul ignore next */ []));
991
+ queryState = signal({}, ...(ngDevMode ? [{ debugName: "queryState" }] : /* istanbul ignore next */ []));
992
+ sortState = signal([], ...(ngDevMode ? [{ debugName: "sortState" }] : /* istanbul ignore next */ []));
993
+ routeParamsState = signal({}, ...(ngDevMode ? [{ debugName: "routeParamsState" }] : /* istanbul ignore next */ []));
994
+ config;
995
+ prefetchMode;
996
+ constructor(items = [], config = {}) {
997
+ this.config = config;
998
+ this.prefetchMode = config.prefetchMode ?? 'sequential';
999
+ this.allItemsState.set(items);
1000
+ this.filtersState.set(config.presetFilters ?? {});
1001
+ this.sortState.set(this.normalizeSort(config.presetSort));
1002
+ this.routeParamsState.set(config.presetRouteParams ?? {});
1003
+ this.pageState.set(config.presetQuery?.page ?? 0);
1004
+ this.pageSizeState.set(config.presetQuery?.pageSize ?? 20);
1005
+ void this.refresh();
1006
+ }
1007
+ get page() {
1008
+ return untracked(this.pageState);
1009
+ }
1010
+ get pageSize() {
1011
+ return untracked(this.pageSizeState);
1012
+ }
1013
+ get totalElements() {
1014
+ return untracked(this.totalElementsState);
1015
+ }
1016
+ get filters() {
1017
+ return untracked(this.filtersState);
1018
+ }
1019
+ get query() {
1020
+ return untracked(this.queryState);
1021
+ }
1022
+ get sort() {
1023
+ return untracked(this.sortState);
1024
+ }
1025
+ get routeParams() {
1026
+ return untracked(this.routeParamsState);
1027
+ }
1028
+ set page(value) {
1029
+ this.pageState.set(value);
1030
+ }
1031
+ set pageSize(value) {
1032
+ this.pageSizeState.set(value);
1033
+ }
1034
+ set totalElements(value) {
1035
+ this.totalElementsState.set(value);
1036
+ }
1037
+ set filters(value) {
1038
+ this.filtersState.set(value);
1039
+ }
1040
+ set query(value) {
1041
+ this.queryState.set(value);
1042
+ }
1043
+ setData = (items, opts) => {
1044
+ const nextItems = opts?.replace === false ? [...this.allItemsState(), ...items] : [...items];
1045
+ this.allItemsState.set(nextItems);
1046
+ this.bumpVersion();
1047
+ return this.refresh({ page: 0 });
1048
+ };
1049
+ preload = this.setData;
1050
+ fetch = ({ filters = {}, query = {}, routeParams = {}, sort = this.sort } = {}) => {
1051
+ this.filtersState.set(filters);
1052
+ this.queryState.set(query);
1053
+ this.routeParamsState.set(routeParams);
1054
+ this.sortState.set(this.normalizeSort(sort));
1055
+ this.pageState.set(0);
1056
+ this.bumpVersion();
1057
+ return this.refresh();
1058
+ };
1059
+ refetchWith({ filters, query, sort } = {}) {
1060
+ const nextFilters = filters == null ? this.filters : { ...this.filters, ...filters };
1061
+ const nextQuery = query == null ? this.query : { ...this.query, ...query };
1062
+ const nextSort = sort == null ? this.sort : this.normalizeSort(sort);
1063
+ if (!deepEqual(this.filters, nextFilters) || !deepEqual(this.query, nextQuery) || !deepEqual(this.sort, nextSort)) {
1064
+ this.pageState.set(0);
1065
+ this.bumpVersion();
1066
+ }
1067
+ this.filtersState.set(nextFilters);
1068
+ this.queryState.set(nextQuery);
1069
+ this.sortState.set(nextSort);
1070
+ return this.refresh();
1071
+ }
1072
+ updatePage = (page = this.page, _options) => {
1073
+ this.pageState.set(typeof page === 'number' ? page : page.page);
1074
+ return this.refresh();
1075
+ };
1076
+ updatePageSize = (size = this.pageSize) => {
1077
+ this.pageSizeState.set(size);
1078
+ this.pageState.set(0);
1079
+ this.bumpVersion();
1080
+ return this.refresh();
1081
+ };
1082
+ updateByOffset = ({ page: pageNum, first = 0, rows = 0 } = {}, { query = this.query, sort = this.sort } = {}) => {
1083
+ const page = (pageNum ?? (first && rows && Math.floor(first / rows))) || 0;
1084
+ this.pageState.set(page);
1085
+ this.pageSizeState.set(rows || this.pageSize);
1086
+ this.queryState.set(query);
1087
+ this.sortState.set(this.normalizeSort(sort));
1088
+ return this.refresh();
1089
+ };
1090
+ setSort = (sort) => {
1091
+ this.sortState.set(this.normalizeSort(sort));
1092
+ };
1093
+ updateSort = (sort) => this.updateSorts(sort ? [sort] : []);
1094
+ updateSorts = (sort) => {
1095
+ this.sortState.set(this.normalizeSort(sort));
1096
+ this.pageState.set(0);
1097
+ this.bumpVersion();
1098
+ return this.refresh();
1099
+ };
1100
+ setRouteParams = (params = {}, opts = {}) => {
1101
+ this.routeParamsState.set(params);
1102
+ if (opts.reset) {
1103
+ this.pageState.set(0);
1104
+ this.bumpVersion();
1105
+ }
1106
+ };
1107
+ updateConfig = (config) => {
1108
+ this.config = { ...this.config, ...config };
1109
+ this.prefetchMode = this.config.prefetchMode ?? 'sequential';
1110
+ if (config.presetFilters) {
1111
+ this.filtersState.set(config.presetFilters);
1112
+ }
1113
+ if (config.presetSort) {
1114
+ this.sortState.set(this.normalizeSort(config.presetSort));
1115
+ }
1116
+ if (config.presetRouteParams) {
1117
+ this.routeParamsState.set(config.presetRouteParams);
1118
+ }
1119
+ if (config.presetQuery?.page != null) {
1120
+ this.pageState.set(config.presetQuery.page);
1121
+ }
1122
+ if (config.presetQuery?.pageSize != null) {
1123
+ this.pageSizeState.set(config.presetQuery.pageSize);
1124
+ }
1125
+ void this.refresh();
1126
+ };
1127
+ copy(store) {
1128
+ this.config = { ...store.config };
1129
+ this.prefetchMode = store.prefetchMode;
1130
+ this.allItemsState.set(store.allItemsState());
1131
+ this.filtersState.set(store.filters);
1132
+ this.queryState.set(store.query);
1133
+ this.sortState.set([...store.sort]);
1134
+ this.routeParamsState.set(store.routeParams);
1135
+ this.pageState.set(store.page);
1136
+ this.pageSizeState.set(store.pageSize);
1137
+ this.totalElementsState.set(store.totalElements);
1138
+ this.cached.set(store.cached());
1139
+ this.items.set(store.items());
1140
+ this.bumpVersion();
1141
+ }
1142
+ destroy() { }
1143
+ async refresh(override) {
1144
+ const page = override?.page ?? this.page;
1145
+ const size = this.pageSize;
1146
+ const sort = this.sort;
1147
+ try {
1148
+ const resolved = await this.resolveItems({
1149
+ page,
1150
+ size,
1151
+ ...this.filters,
1152
+ ...this.query,
1153
+ sort,
1154
+ items: this.allItemsState(),
1155
+ routeParams: this.routeParams,
1156
+ });
1157
+ const sorted = this.applyClientSort(resolved, sort);
1158
+ const start = Math.max(0, page) * Math.max(1, size);
1159
+ const pageItems = sorted.slice(start, start + Math.max(1, size));
1160
+ this.pageState.set(page);
1161
+ this.totalElementsState.set(sorted.length);
1162
+ this.cached.set(sorted);
1163
+ this.items.set(pageItems);
1164
+ return pageItems;
1165
+ }
1166
+ catch (error) {
1167
+ this.error.set(error);
1168
+ throw error;
1169
+ }
1170
+ }
1171
+ resolveItems(input) {
1172
+ if (this.config.resolveItems) {
1173
+ return this.config.resolveItems(input);
1174
+ }
1175
+ return input.items;
1176
+ }
1177
+ applyClientSort(items, sort) {
1178
+ const clone = [...items];
1179
+ if (!sort.length) {
1180
+ return clone;
1181
+ }
1182
+ clone.sort((left, right) => {
1183
+ for (const rule of sort) {
1184
+ const result = this.compareValues(left[rule.sort], right[rule.sort]);
1185
+ if (result !== 0) {
1186
+ return rule.order === 'desc' ? -result : result;
1187
+ }
1188
+ }
1189
+ return 0;
1190
+ });
1191
+ return clone;
1192
+ }
1193
+ compareValues(left, right) {
1194
+ if (left == null && right == null) {
1195
+ return 0;
1196
+ }
1197
+ if (left == null) {
1198
+ return -1;
1199
+ }
1200
+ if (right == null) {
1201
+ return 1;
1202
+ }
1203
+ if (typeof left === 'number' && typeof right === 'number') {
1204
+ return left - right;
1205
+ }
1206
+ if (left instanceof Date && right instanceof Date) {
1207
+ return left.getTime() - right.getTime();
1208
+ }
1209
+ return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
1210
+ }
1211
+ normalizeSort(sort) {
1212
+ return normalizeSortInput(sort);
1213
+ }
1214
+ bumpVersion() {
1215
+ this.version.update((current) => current + 1);
1216
+ }
1217
+ }
1218
+
814
1219
  // noinspection ES6PreferShortImport
815
1220
  /**
816
1221
  * Store for paginated data (tables/lists) with per-page cache and unified requests.
@@ -1126,6 +1531,7 @@ class PagedQueryStore {
1126
1531
  const method = this.config.method || 'GET';
1127
1532
  const normalizedSort = this.normalizeSort(sort);
1128
1533
  const sortQueryKey = this.resolveSortQueryKey();
1534
+ // parseRequest works on the pre-serialization request model, so sort is still object-based here.
1129
1535
  const requestPayload = mergeQueryParams(mergeQueryParams({ page, size }, filters), mergeQueryParams(query, normalizedSort.length ? { [sortQueryKey]: normalizedSort } : {}));
1130
1536
  const fallbackQuery = method === 'GET' ? requestPayload : mergeQueryParams({ page, size }, query);
1131
1537
  const rawQueries = this.config.parseRequest?.(requestPayload) || fallbackQuery;
@@ -1296,9 +1702,10 @@ class DictStore {
1296
1702
  storage;
1297
1703
  metaStorage;
1298
1704
  ttlMs;
1299
- revalidate;
1705
+ accessAutoLoadMode;
1300
1706
  cacheUpdatedAt = signal(null, ...(ngDevMode ? [{ debugName: "cacheUpdatedAt" }] : /* istanbul ignore next */ []));
1301
1707
  presetFilters = {};
1708
+ accessLoadQueued = false;
1302
1709
  /**
1303
1710
  * Search text.
1304
1711
  * With `fixed: true` filters the local cache; with `fixed: false` triggers server search.
@@ -1315,6 +1722,7 @@ class DictStore {
1315
1722
  * Source — local cache (fixed=true) or data from `PagedQueryStore`.
1316
1723
  */
1317
1724
  items = computed(() => {
1725
+ this.maybeScheduleAccessLoad();
1318
1726
  const cached = this.cachedItems();
1319
1727
  if (!this.fixed) {
1320
1728
  return this.debouncedSearchText() || !cached.length ? this.#helper.items() : cached;
@@ -1337,7 +1745,7 @@ class DictStore {
1337
1745
  * @param storageKey key for saving cache in the selected strategy
1338
1746
  * @param cfg behavior (fixed/search, parsers, label/value keys, cache strategy, etc.)
1339
1747
  */
1340
- constructor(apiUrl, storageKey, { autoLoad = true, method = this.defaultConfig.defaultRestMethod, presetFilters = this.defaultConfig.defaultPresetFilters, parseResponse, parseRequest, debounceTime = this.defaultConfig.defaultDebounceTime, ttlMs = this.defaultConfig.defaultTtlMs, revalidate = this.defaultConfig.defaultRevalidate ?? false, fixed = true, maxOptionsSize = this.defaultConfig.defaultMaxOptionsSize, labelKey = this.defaultConfig.defaultLabelKey || 'name', valueKey = this.defaultConfig.defaultValueKey || 'code', keyPrefix = this.defaultConfig.defaultPrefix || 're', cacheStrategy = this.defaultConfig.defaultCacheStrategy || 'persist', }) {
1748
+ constructor(apiUrl, storageKey, { autoLoad = this.defaultConfig.defaultAutoLoad ?? true, method = this.defaultConfig.defaultRestMethod, presetFilters = this.defaultConfig.defaultPresetFilters, parseResponse, parseRequest, debounceTime = this.defaultConfig.defaultDebounceTime, ttlMs = this.defaultConfig.defaultTtlMs, fixed = true, maxOptionsSize = this.defaultConfig.defaultMaxOptionsSize, labelKey = this.defaultConfig.defaultLabelKey || 'name', valueKey = this.defaultConfig.defaultValueKey || 'code', keyPrefix = this.defaultConfig.defaultPrefix || 're', cacheStrategy = this.defaultConfig.defaultCacheStrategy || 'persist', }) {
1341
1749
  this.apiUrl = apiUrl;
1342
1750
  this.storageKey = storageKey;
1343
1751
  const searchDebounce = debounceTime ?? 300;
@@ -1352,7 +1760,7 @@ class DictStore {
1352
1760
  });
1353
1761
  this.fixed = fixed;
1354
1762
  this.ttlMs = ttlMs;
1355
- this.revalidate = revalidate;
1763
+ this.accessAutoLoadMode = autoLoad === 'onAccess';
1356
1764
  this.labelKey = labelKey;
1357
1765
  this.valueKey = valueKey;
1358
1766
  this.maxOptionsSize = maxOptionsSize;
@@ -1414,6 +1822,16 @@ class DictStore {
1414
1822
  this.searchText.set(name);
1415
1823
  this.filters.set(filters);
1416
1824
  };
1825
+ /**
1826
+ * Explicitly arms the store and loads data only when it is actually needed.
1827
+ *
1828
+ * Useful for dropdown/popup flows where eager constructor-time requests would
1829
+ * create too many parallel dictionary loads.
1830
+ */
1831
+ ensureLoaded = (filters = this.filters()) => {
1832
+ this._armed.set(true);
1833
+ this.filters.set(filters);
1834
+ };
1417
1835
  /**
1418
1836
  * Find display label by value (typically for reverse binding).
1419
1837
  * @returns label string or `undefined` if not found
@@ -1538,17 +1956,45 @@ class DictStore {
1538
1956
  setAutoload(autoLoad) {
1539
1957
  if (autoLoad === 'whenEmpty') {
1540
1958
  const isEmpty = !this.cachedItems().length;
1541
- this._armed.set(isEmpty || this.shouldRevalidateCache());
1959
+ this._armed.set(isEmpty || this.isCacheStale());
1960
+ return;
1961
+ }
1962
+ if (autoLoad === 'onDemand' || autoLoad === 'onAccess') {
1963
+ this._armed.set(false);
1964
+ return;
1542
1965
  }
1543
1966
  else {
1544
1967
  this._armed.set(autoLoad);
1545
1968
  }
1546
1969
  }
1547
1970
  shouldFetchFixedCache() {
1548
- return !this.cachedItems().length || this.shouldRevalidateCache();
1971
+ return !this.cachedItems().length || this.isCacheStale();
1549
1972
  }
1550
- shouldRevalidateCache() {
1551
- return this.revalidate && this.isCacheStale();
1973
+ maybeScheduleAccessLoad() {
1974
+ if (!this.accessAutoLoadMode || this._armed() || this.accessLoadQueued) {
1975
+ return;
1976
+ }
1977
+ if (!this.shouldFetchOnAccessRead()) {
1978
+ return;
1979
+ }
1980
+ this.accessLoadQueued = true;
1981
+ // Note: this is an intentionally dirty opt-in mode.
1982
+ // We never mutate `_armed` synchronously inside `computed()`, because reads should stay pure by default.
1983
+ // Instead, `autoLoad: 'onAccess'` schedules a single deferred activation after the first read of
1984
+ // `items()` / `options()`. This keeps the hack isolated and prevents request storms from repeated reads
1985
+ // during the same render/effect turn.
1986
+ queueMicrotask(() => {
1987
+ this.accessLoadQueued = false;
1988
+ if (!this._armed() && this.shouldFetchOnAccessRead()) {
1989
+ this.ensureLoaded();
1990
+ }
1991
+ });
1992
+ }
1993
+ shouldFetchOnAccessRead() {
1994
+ if (this.fixed) {
1995
+ return this.shouldFetchFixedCache();
1996
+ }
1997
+ return this.#helper.items().length === 0 && !this.cachedItems().length;
1552
1998
  }
1553
1999
  isCacheStale() {
1554
2000
  if (!this.cachedItems().length) {
@@ -1817,5 +2263,5 @@ class EntityStore {
1817
2263
  * Generated bundle index. Do not edit.
1818
2264
  */
1819
2265
 
1820
- export { AbortError, CacheMissError, DictLocalStore, DictStore, EntityStore, PagedQueryStore, RESOURCE_PROFILES, ResourceStore, STATUM_CONFIG, createBodySerializer, createQuerySerializer, createResourceProfile, createStrictSerializer, isAbort, provideStatum };
2266
+ export { AbortError, CacheMissError, DictLocalStore, DictStore, EntityStore, PagedQueryLocalStore, PagedQueryStore, RESOURCE_PROFILES, ResourceMockStore, ResourceStore, STATUM_CONFIG, createBodySerializer, createQuerySerializer, createResourceProfile, createStrictSerializer, isAbort, provideStatum };
1821
2267
  //# sourceMappingURL=reforgium-statum.mjs.map
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.1.6",
2
+ "version": "3.3.0",
3
3
  "name": "@reforgium/statum",
4
4
  "description": "Signals-first API state and query stores for Angular",
5
5
  "author": "rtommievich",
@@ -1,4 +1,4 @@
1
- import { DataType, SerializerConfig, Serializer, RestMethods, AnyDict, AnyType, QueryParams, PageableRequest, PageableResponse, StorageStrategy } from '@reforgium/internal';
1
+ import { DataType, SerializerConfig, Serializer, RestMethods, AnyDict, AnyType, QueryParams, PageableResponse, StorageStrategy } from '@reforgium/internal';
2
2
  export { DataType, FieldConcatType, FieldConfig, FormatConfig, LocalStorage, LruCache, MemoryStorage, ParseFormatConfig, SerializedType, Serializer, SerializerConfig, SerializerFieldError, SessionStorage, StorageInterface, StorageStrategy, StorageStrategyOptions, Types, storageStrategy } from '@reforgium/internal';
3
3
  import * as _angular_core from '@angular/core';
4
4
  import { Signal, WritableSignal, EnvironmentProviders, InjectionToken } from '@angular/core';
@@ -168,6 +168,51 @@ type ResourceTraceEvent = {
168
168
  error?: unknown;
169
169
  };
170
170
 
171
+ type ResourceMockHandlerContext<Data, Param extends SimpleDict = SimpleDict, Query extends QueryDict = QueryDict, Payload extends PayloadData = PayloadData> = {
172
+ method: RestMethods;
173
+ route?: string;
174
+ url: string;
175
+ args: CallArgs<Param, Query, Payload>;
176
+ current: Data | null;
177
+ };
178
+ type ResourceMockHandler<Data, Result = Data, Param extends SimpleDict = SimpleDict, Query extends QueryDict = QueryDict, Payload extends PayloadData = PayloadData> = (ctx: ResourceMockHandlerContext<Data, Param, Query, Payload>) => Result | Promise<Result>;
179
+ type ResourceMockHandlers<Data> = Partial<Record<RestMethods, ResourceMockHandler<Data, AnyType>>>;
180
+ type ResourceMockStoreOptions<Data> = Pick<ResourceStoreOptions, 'baseUrl' | 'delay' | 'onTrace'> & {
181
+ seed?: Data | null;
182
+ handlers?: ResourceMockHandlers<Data>;
183
+ };
184
+ /**
185
+ * Lightweight in-memory counterpart of `ResourceStore`.
186
+ *
187
+ * It preserves the result-facing API (`value/status/error/loading` + CRUD methods),
188
+ * but does not try to reproduce transport, retry, or per-key cache internals.
189
+ */
190
+ declare class ResourceMockStore<Data> {
191
+ #private;
192
+ readonly value: Signal<Data | null>;
193
+ readonly status: Signal<ResourceStatus>;
194
+ readonly error: Signal<unknown | null>;
195
+ readonly loading: Signal<boolean>;
196
+ private readonly routes;
197
+ private readonly opts;
198
+ constructor(routes?: ResourceRoutesMap, opts?: ResourceMockStoreOptions<Data>);
199
+ setValue(value: Data | null): void;
200
+ patchValue(patch: Partial<Data>): void;
201
+ reset(value?: Data | null): void;
202
+ get<Param extends SimpleDict = SimpleDict, Query extends QueryDict = QueryDict, Response extends AnyType = Data>(args: CallArgs<Param, Query, never>, cfg?: GetCallConfig<Response, Data>): Promise<Data>;
203
+ 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>;
204
+ 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>;
205
+ 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>;
206
+ 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>;
207
+ abort(): void;
208
+ abortAll(): void;
209
+ private runMutation;
210
+ private resolveMutation;
211
+ private run;
212
+ private buildUrl;
213
+ private trace;
214
+ }
215
+
171
216
  /**
172
217
  * Store for REST resources with caching and request deduplication.
173
218
  *
@@ -358,6 +403,10 @@ type QuerySortRule<Key extends string = string> = {
358
403
  order: QuerySortOrder;
359
404
  };
360
405
  type QuerySortInput<Key extends string = string> = QuerySortRule<Key> | ReadonlyArray<QuerySortRule<Key>> | null | undefined;
406
+ type PagedQueryRequestInput<FilterType extends AnyDict = AnyDict> = Omit<QueryParams<FilterType>, 'page' | 'sort'> & {
407
+ page: number;
408
+ sort?: QuerySortInput;
409
+ } & Partial<FilterType> & AnyDict;
361
410
  type PagedQueryConcurrency = 'latest-wins' | 'parallel';
362
411
  type OffsetPaginationType = {
363
412
  page?: number;
@@ -433,7 +482,7 @@ type PagedQueryStoreConfig<ItemsType extends object, FilterType extends AnyDict
433
482
  * Useful for mapping `page/pageSize` and selected filter fields to API-specific query keys.
434
483
  * Returned object can contain nullable values; they are filtered before the transport call.
435
484
  */
436
- parseRequest?: BivariantCallback<PageableRequest & Partial<FilterType> & AnyDict, AnyDict>;
485
+ parseRequest?: BivariantCallback<PagedQueryRequestInput<FilterType>, AnyDict>;
437
486
  /**
438
487
  * Custom parser of API response into unified `PageableResponse<ItemsType>`.
439
488
  * Use if the server returns an array or a non-standard structure.
@@ -470,6 +519,85 @@ type PagedQueryStoreProviderConfig = {
470
519
  defaultDisableCacheLimit?: boolean;
471
520
  };
472
521
 
522
+ type PagedQueryLocalResolverInput<ItemsType extends AnyDict, FilterType extends AnyDict = AnyDict> = PagedQueryRequestInput<FilterType> & {
523
+ items: readonly ItemsType[];
524
+ routeParams: AnyDict;
525
+ };
526
+ type PagedQueryLocalResolverResult<ItemsType extends AnyDict> = readonly ItemsType[] | Promise<readonly ItemsType[]>;
527
+ type PagedQueryLocalStoreConfig<ItemsType extends AnyDict, FilterType extends AnyDict = AnyDict> = {
528
+ presetQuery?: {
529
+ page?: number;
530
+ pageSize?: number;
531
+ };
532
+ presetFilters?: Partial<FilterType>;
533
+ presetSort?: ReadonlyArray<QuerySortRule>;
534
+ presetRouteParams?: AnyDict;
535
+ prefetchMode?: 'sequential' | 'parallel';
536
+ resolveItems?: (input: PagedQueryLocalResolverInput<ItemsType, FilterType>) => PagedQueryLocalResolverResult<ItemsType>;
537
+ };
538
+ /**
539
+ * Lightweight local counterpart of `PagedQueryStore`.
540
+ *
541
+ * It keeps the same result-facing shape needed by consumers and `DataGrid source`,
542
+ * but resolves pages from an in-memory collection instead of HTTP.
543
+ */
544
+ declare class PagedQueryLocalStore<ItemsType extends AnyDict, FilterType extends AnyDict = AnyDict> {
545
+ readonly items: _angular_core.WritableSignal<ItemsType[]>;
546
+ readonly cached: _angular_core.WritableSignal<ItemsType[]>;
547
+ readonly loading: _angular_core.WritableSignal<boolean>;
548
+ readonly error: _angular_core.WritableSignal<unknown>;
549
+ readonly version: _angular_core.WritableSignal<number>;
550
+ readonly allItemsState: _angular_core.WritableSignal<readonly ItemsType[]>;
551
+ readonly pageState: _angular_core.WritableSignal<number>;
552
+ readonly pageSizeState: _angular_core.WritableSignal<number>;
553
+ readonly totalElementsState: _angular_core.WritableSignal<number | undefined>;
554
+ readonly filtersState: _angular_core.WritableSignal<Partial<FilterType>>;
555
+ readonly queryState: _angular_core.WritableSignal<AnyDict>;
556
+ readonly sortState: _angular_core.WritableSignal<QuerySortRule[]>;
557
+ readonly routeParamsState: _angular_core.WritableSignal<AnyDict>;
558
+ config: PagedQueryLocalStoreConfig<ItemsType, FilterType>;
559
+ prefetchMode: 'sequential' | 'parallel';
560
+ constructor(items?: readonly ItemsType[], config?: PagedQueryLocalStoreConfig<ItemsType, FilterType>);
561
+ get page(): number;
562
+ get pageSize(): number;
563
+ get totalElements(): number | undefined;
564
+ get filters(): Partial<FilterType>;
565
+ get query(): AnyDict;
566
+ get sort(): QuerySortRule[];
567
+ get routeParams(): AnyDict;
568
+ set page(value: number);
569
+ set pageSize(value: number);
570
+ set totalElements(value: number | undefined);
571
+ set filters(value: Partial<FilterType>);
572
+ set query(value: AnyDict);
573
+ setData: (items: readonly ItemsType[], opts?: {
574
+ replace?: boolean;
575
+ }) => Promise<ItemsType[]>;
576
+ preload: (items: readonly ItemsType[], opts?: {
577
+ replace?: boolean;
578
+ }) => Promise<ItemsType[]>;
579
+ fetch: ({ filters, query, routeParams, sort }?: FetchInput<FilterType>) => Promise<ItemsType[]>;
580
+ refetchWith({ filters, query, sort }?: RefetchWithInput<FilterType>): Promise<ItemsType[]>;
581
+ updatePage: (page?: number | {
582
+ page: number;
583
+ }, _options?: UpdatePageOptions) => Promise<ItemsType[]>;
584
+ updatePageSize: (size?: number) => Promise<ItemsType[]>;
585
+ updateByOffset: ({ page: pageNum, first, rows }?: OffsetPaginationType, { query, sort }?: UpdateByOffsetOptions) => Promise<ItemsType[]>;
586
+ setSort: (sort?: QuerySortInput) => void;
587
+ updateSort: (sort?: QuerySortRule | null) => Promise<ItemsType[]>;
588
+ updateSorts: (sort?: ReadonlyArray<QuerySortRule> | null) => Promise<ItemsType[]>;
589
+ setRouteParams: (params?: AnyDict, opts?: SetRouteParamsOptions) => void;
590
+ updateConfig: (config: PagedQueryLocalStoreConfig<ItemsType, FilterType>) => void;
591
+ copy(store: PagedQueryLocalStore<ItemsType, FilterType>): void;
592
+ destroy(): void;
593
+ private refresh;
594
+ private resolveItems;
595
+ private applyClientSort;
596
+ private compareValues;
597
+ private normalizeSort;
598
+ private bumpVersion;
599
+ }
600
+
473
601
  /**
474
602
  * Store for paginated data (tables/lists) with per-page cache and unified requests.
475
603
  *
@@ -644,13 +772,21 @@ type DictStoreConfig<ItemsType extends object> = {
644
772
  keyPrefix?: string;
645
773
  /** Initial filters (added to the first request/filtering). */
646
774
  presetFilters?: Record<string, string>;
647
- /** Autoload data on initialization (`true` by default). */
648
- autoLoad?: boolean | 'whenEmpty';
775
+ /**
776
+ * Autoload data on initialization (`true` by default).
777
+ *
778
+ * - `true`: arm immediately on construction
779
+ * - `false`: stay passive until `search(...)` or `ensureLoaded()`
780
+ * - `'whenEmpty'`: arm on init only when cache is empty or stale
781
+ * - `'onDemand'`: never arm on init; load only after an explicit `ensureLoaded()` / `search(...)`
782
+ * - `'onAccess'`: dirty opt-in mode; first read of `items()` / `options()` lazily schedules loading
783
+ */
784
+ autoLoad?: boolean | 'whenEmpty' | 'onDemand' | 'onAccess';
649
785
  /**
650
786
  * Custom mapper of pagination/sort request into query params.
651
787
  * Useful if the API expects non-standard field names.
652
788
  */
653
- parseRequest?: (data: PageableRequest) => AnyDict;
789
+ parseRequest?: (data: PagedQueryRequestInput<FilterType>) => AnyDict;
654
790
  /**
655
791
  * Custom parser of server response into a unified PageableResponse.
656
792
  * Needed if the API returns an array or a "non-standard" structure.
@@ -662,7 +798,10 @@ type DictStoreConfig<ItemsType extends object> = {
662
798
  debounceTime?: number;
663
799
  /** Cache freshness window (ms). Undefined means restored cache does not expire. */
664
800
  ttlMs?: number;
665
- /** When cache is stale, keep visible items and refresh them in the background. */
801
+ /**
802
+ * @deprecated `DictStore` now revalidates stale cache whenever `ttlMs` is set.
803
+ * Pass `ttlMs: undefined` to disable time-based refresh semantics entirely.
804
+ */
666
805
  revalidate?: boolean;
667
806
  /** Maximum number of options exposed (truncates `options`). */
668
807
  maxOptionsSize?: number;
@@ -708,13 +847,18 @@ type DictStoreProviderConfig = {
708
847
  defaultPrefix?: string;
709
848
  /** Initial filters (added to the first request/filtering). */
710
849
  defaultPresetFilters?: DictStoreConfig<AnyType>['presetFilters'];
850
+ /** Default autoload policy for dictionary stores. */
851
+ defaultAutoLoad?: DictStoreConfig<AnyType>['autoLoad'];
711
852
  /** Transport method for fetching the dictionary. Defaults to 'POST'. */
712
853
  defaultRestMethod?: DictStoreConfig<AnyType>['method'];
713
854
  /** Debounce delay before request (ms) — for frequent input/search. */
714
855
  defaultDebounceTime?: DictStoreConfig<AnyType>['debounceTime'];
715
856
  /** Cache freshness window (ms). Undefined means restored cache does not expire. */
716
857
  defaultTtlMs?: DictStoreConfig<AnyType>['ttlMs'];
717
- /** When cache is stale, keep visible items and refresh them in the background. */
858
+ /**
859
+ * @deprecated `DictStore` now revalidates stale cache whenever `defaultTtlMs` is set.
860
+ * Keep this field only for backward compatibility.
861
+ */
718
862
  defaultRevalidate?: DictStoreConfig<AnyType>['revalidate'];
719
863
  /** Maximum number of options exposed (truncates `options`). */
720
864
  defaultMaxOptionsSize?: DictStoreConfig<AnyType>['maxOptionsSize'];
@@ -724,6 +868,9 @@ type DictStoreProviderConfig = {
724
868
  defaultValueKey?: DictStoreConfig<AnyType>['valueKey'];
725
869
  };
726
870
  type ValueType = string[] | string | number | null | undefined;
871
+ type FilterType = {
872
+ name: string;
873
+ };
727
874
 
728
875
  /**
729
876
  * Dictionary store (select/options) with local LRU cache and optional persistence.
@@ -760,9 +907,10 @@ declare class DictStore<Type extends AnyDict> {
760
907
  private readonly storage?;
761
908
  private readonly metaStorage?;
762
909
  private readonly ttlMs?;
763
- private readonly revalidate;
910
+ private readonly accessAutoLoadMode;
764
911
  private readonly cacheUpdatedAt;
765
912
  private readonly presetFilters;
913
+ private accessLoadQueued;
766
914
  /**
767
915
  * Search text.
768
916
  * With `fixed: true` filters the local cache; with `fixed: false` triggers server search.
@@ -794,7 +942,7 @@ declare class DictStore<Type extends AnyDict> {
794
942
  * @param storageKey key for saving cache in the selected strategy
795
943
  * @param cfg behavior (fixed/search, parsers, label/value keys, cache strategy, etc.)
796
944
  */
797
- constructor(apiUrl: string, storageKey: string, { autoLoad, method, presetFilters, parseResponse, parseRequest, debounceTime, ttlMs, revalidate, fixed, maxOptionsSize, labelKey, valueKey, keyPrefix, cacheStrategy, }: DictStoreConfig<Type>);
945
+ constructor(apiUrl: string, storageKey: string, { autoLoad, method, presetFilters, parseResponse, parseRequest, debounceTime, ttlMs, fixed, maxOptionsSize, labelKey, valueKey, keyPrefix, cacheStrategy, }: DictStoreConfig<Type>);
798
946
  /** Restore cache from the selected storage (`persist`/`session`/`lru`/`memory`). */
799
947
  restoreCache(): void;
800
948
  clearCache(): void;
@@ -803,6 +951,13 @@ declare class DictStore<Type extends AnyDict> {
803
951
  * With `fixed: false` initiates server search; with `fixed: true` — local filtering.
804
952
  */
805
953
  search: (name?: string, filters?: AnyDict) => void;
954
+ /**
955
+ * Explicitly arms the store and loads data only when it is actually needed.
956
+ *
957
+ * Useful for dropdown/popup flows where eager constructor-time requests would
958
+ * create too many parallel dictionary loads.
959
+ */
960
+ ensureLoaded: (filters?: AnyDict) => void;
806
961
  /**
807
962
  * Find display label by value (typically for reverse binding).
808
963
  * @returns label string or `undefined` if not found
@@ -829,7 +984,8 @@ declare class DictStore<Type extends AnyDict> {
829
984
  private keyOf;
830
985
  private setAutoload;
831
986
  private shouldFetchFixedCache;
832
- private shouldRevalidateCache;
987
+ private maybeScheduleAccessLoad;
988
+ private shouldFetchOnAccessRead;
833
989
  private isCacheStale;
834
990
  private metaKey;
835
991
  }
@@ -951,6 +1107,6 @@ type StatumConfig = PagedQueryProviderConfig & SerializerProviderConfig & DictPr
951
1107
  declare const STATUM_CONFIG: InjectionToken<StatumConfig>;
952
1108
  declare const provideStatum: (config: StatumConfig) => EnvironmentProviders;
953
1109
 
954
- export { AbortError, CacheMissError, DictLocalStore, DictStore, EntityStore, PagedQueryStore, RESOURCE_PROFILES, ResourceStore, STATUM_CONFIG, createBodySerializer, createQuerySerializer, createResourceProfile, createStrictSerializer, isAbort, provideStatum };
955
- export type { DictLocalConfig, DictStoreConfig, DictStoreProviderConfig, EntityId, EntityStoreConfig, FetchInput, FetchParams, OffsetPaginationType, PagedQueryStoreConfig, PagedQueryStoreProviderConfig, QuerySortInput, QuerySortOrder, QuerySortRule, RefetchWithInput, ResourceProfileName, ResourceRoutesMap, ResourceStatus, ResourceStoreOptions, ResourceTraceEvent, RetryConfig, SetRouteParamsOptions, StatumConfig, UpdateByOffsetOptions, UpdatePageInput, UpdatePageOptions };
1110
+ export { AbortError, CacheMissError, DictLocalStore, DictStore, EntityStore, PagedQueryLocalStore, PagedQueryStore, RESOURCE_PROFILES, ResourceMockStore, ResourceStore, STATUM_CONFIG, createBodySerializer, createQuerySerializer, createResourceProfile, createStrictSerializer, isAbort, provideStatum };
1111
+ export type { DictLocalConfig, DictStoreConfig, DictStoreProviderConfig, EntityId, EntityStoreConfig, FetchInput, FetchParams, OffsetPaginationType, PagedQueryLocalResolverInput, PagedQueryLocalResolverResult, PagedQueryLocalStoreConfig, PagedQueryRequestInput, PagedQueryStoreConfig, PagedQueryStoreProviderConfig, QuerySortInput, QuerySortOrder, QuerySortRule, RefetchWithInput, ResourceMockHandler, ResourceMockHandlerContext, ResourceMockHandlers, ResourceMockStoreOptions, ResourceProfileName, ResourceRoutesMap, ResourceStatus, ResourceStoreOptions, ResourceTraceEvent, RetryConfig, SetRouteParamsOptions, StatumConfig, UpdateByOffsetOptions, UpdatePageInput, UpdatePageOptions };
956
1112
  //# sourceMappingURL=reforgium-statum.d.ts.map