@reforgium/statum 3.1.3 → 3.1.5

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,4 +1,26 @@
1
- ## [3.1.3]: 2026-04-06
1
+ ## [3.1.5]: 2026-04-14
2
+
3
+ ### Fix:
4
+ - `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
5
+ - `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
6
+
7
+ ### Test:
8
+ - added regression coverage for cache invalidation on route-param session changes without store destruction
9
+
10
+ ---
11
+
12
+ ## [3.1.4]: 2026-04-10
13
+
14
+ ### Fix:
15
+ - `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.
16
+ - `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.
17
+
18
+ ### Test:
19
+ - added regression coverage for omitted `totalElements`, reset/refetch clearing, and empty-result unknown-total flows
20
+
21
+ ---
22
+
23
+ ## [3.1.3]: 2026-04-06
2
24
 
3
25
  ### Fix:
4
26
  - `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
@@ -345,7 +345,7 @@ Lightweight store for server-side pagination with filtering, dynamic query param
345
345
  | version | `WritableSignal<number>` | Increments when the dataset is reset |
346
346
  | page | `number` | Current page (0-based) |
347
347
  | pageSize | `number` | Page size |
348
- | totalElements | `number` | Total items on server |
348
+ | totalElements | `number \| undefined` | Total items on server, if known |
349
349
  | filters | `Partial<F>` | Active filters |
350
350
  | query | `Record<string, unknown>` | Active query params |
351
351
  | sort | `ReadonlyArray<{ sort: string; order: 'asc' \| 'desc' }>` | Active sort state |
@@ -396,7 +396,7 @@ Concurrency can be configured per store:
396
396
  | Method | Cache read | Cache reset | Notes |
397
397
  |----------------|------------|-------------|--------------------------------------------------|
398
398
  | fetch | no | yes | Always starts clean from page `0` |
399
- | refetchWith | no | no | Uses active page/filters/query, merges overrides |
399
+ | refetchWith | no | conditional | Reloads current state; when filters/query/sort actually change, resets to page `0` and replaces the dataset |
400
400
  | updatePage | yes | no | Can bypass with `ignoreCache: true` |
401
401
  | updatePageSize | no | yes | Prevents mixed caches for different page sizes |
402
402
  | updateByOffset | yes | no | Internally maps to `page + size` |
@@ -422,7 +422,7 @@ store.updateSort({ sort: 'name', order: 'asc' });
422
422
 
423
423
  `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.
424
424
 
425
- `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.
425
+ `sort` and `routeParams` should be changed only through `setSort(...)` and `setRouteParams(...)`. Direct state mutation setters for `page`, `pageSize`, `filters`, `query`, and `totalElements` are available for low-level integration scenarios (such as external data-grid source contracts) but prefer the explicit store methods for typical use. `totalElements` may be `undefined` when the backend does not report a total.
426
426
 
427
427
  ### PagedQueryStore + DataGrid source mode
428
428
 
@@ -277,10 +277,10 @@ class KeyedScheduler {
277
277
  class ResourceStore {
278
278
  http = inject(HttpClient);
279
279
  serializer;
280
- #value = signal(null, ...(ngDevMode ? [{ debugName: "#value" }] : []));
281
- #status = signal('idle', ...(ngDevMode ? [{ debugName: "#status" }] : []));
282
- #error = signal(null, ...(ngDevMode ? [{ debugName: "#error" }] : []));
283
- #activeRequests = signal(0, ...(ngDevMode ? [{ debugName: "#activeRequests" }] : []));
280
+ #value = signal(null, ...(ngDevMode ? [{ debugName: "#value" }] : /* istanbul ignore next */ []));
281
+ #status = signal('idle', ...(ngDevMode ? [{ debugName: "#status" }] : /* istanbul ignore next */ []));
282
+ #error = signal(null, ...(ngDevMode ? [{ debugName: "#error" }] : /* istanbul ignore next */ []));
283
+ #activeRequests = signal(0, ...(ngDevMode ? [{ debugName: "#activeRequests" }] : /* istanbul ignore next */ []));
284
284
  /**
285
285
  * Current resource value.
286
286
  * Returns `null` if no data yet or the request failed.
@@ -298,7 +298,7 @@ class ResourceStore {
298
298
  * Convenience loading flag: `true` when `loading` or `stale`.
299
299
  * Useful for spinners and disabling buttons.
300
300
  */
301
- loading = computed(() => this.#activeRequests() > 0 || this.#status() === 'stale', ...(ngDevMode ? [{ debugName: "loading" }] : []));
301
+ loading = computed(() => this.#activeRequests() > 0 || this.#status() === 'stale', ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
302
302
  routes;
303
303
  opts;
304
304
  maxEntries;
@@ -839,22 +839,22 @@ class PagedQueryStore {
839
839
  #transport;
840
840
  #cache;
841
841
  /** Current page data (reactive). */
842
- items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
842
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
843
843
  /** Merged cache of pages (flat list) handy for search/export. */
844
- cached = signal([], ...(ngDevMode ? [{ debugName: "cached" }] : []));
844
+ cached = signal([], ...(ngDevMode ? [{ debugName: "cached" }] : /* istanbul ignore next */ []));
845
845
  /** Loading flag of the current operation. */
846
- loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
846
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
847
847
  /** Last request error (if any). */
848
- error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
848
+ error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
849
849
  /** Increments when the current dataset is reset/replaced. Useful for external consumers with local buffers. */
850
- version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : []));
851
- #page = signal(0, ...(ngDevMode ? [{ debugName: "#page" }] : []));
852
- #pageSize = signal(20, ...(ngDevMode ? [{ debugName: "#pageSize" }] : []));
853
- #totalElements = signal(0, ...(ngDevMode ? [{ debugName: "#totalElements" }] : []));
854
- #filters = signal({}, ...(ngDevMode ? [{ debugName: "#filters" }] : []));
855
- #query = signal({}, ...(ngDevMode ? [{ debugName: "#query" }] : []));
856
- #sort = signal([], ...(ngDevMode ? [{ debugName: "#sort" }] : []));
857
- #routeParams = signal({}, ...(ngDevMode ? [{ debugName: "#routeParams" }] : []));
850
+ version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ []));
851
+ #page = signal(0, ...(ngDevMode ? [{ debugName: "#page" }] : /* istanbul ignore next */ []));
852
+ #pageSize = signal(20, ...(ngDevMode ? [{ debugName: "#pageSize" }] : /* istanbul ignore next */ []));
853
+ #totalElements = signal(undefined, ...(ngDevMode ? [{ debugName: "#totalElements" }] : /* istanbul ignore next */ []));
854
+ #filters = signal({}, ...(ngDevMode ? [{ debugName: "#filters" }] : /* istanbul ignore next */ []));
855
+ #query = signal({}, ...(ngDevMode ? [{ debugName: "#query" }] : /* istanbul ignore next */ []));
856
+ #sort = signal([], ...(ngDevMode ? [{ debugName: "#sort" }] : /* istanbul ignore next */ []));
857
+ #routeParams = signal({}, ...(ngDevMode ? [{ debugName: "#routeParams" }] : /* istanbul ignore next */ []));
858
858
  pageState = this.#page.asReadonly();
859
859
  pageSizeState = this.#pageSize.asReadonly();
860
860
  totalElementsState = this.#totalElements.asReadonly();
@@ -891,7 +891,7 @@ class PagedQueryStore {
891
891
  get pageSize() {
892
892
  return untracked(this.#pageSize);
893
893
  }
894
- /** Total number of elements reported by the server. */
894
+ /** Total number of elements reported by the server, or `undefined` when the total is unknown. */
895
895
  get totalElements() {
896
896
  return untracked(this.#totalElements);
897
897
  }
@@ -931,6 +931,11 @@ class PagedQueryStore {
931
931
  const nextFilters = filters == null ? this.filters : { ...this.filters, ...filters };
932
932
  const nextQuery = query == null ? this.query : mergeQueryParams(this.query, query);
933
933
  const nextSort = sort == null ? this.sort : this.normalizeSort(sort);
934
+ const shouldResetDataset = !deepEqual(this.filters, nextFilters) || !deepEqual(this.query, nextQuery) || !deepEqual(this.sort, nextSort);
935
+ if (shouldResetDataset) {
936
+ this.resetDataset();
937
+ return this.#fetchItems({ page: 0, filters: nextFilters, query: nextQuery, sort: nextSort });
938
+ }
934
939
  return this.#fetchItems({ filters: nextFilters, query: nextQuery, sort: nextSort });
935
940
  }
936
941
  /**
@@ -1002,6 +1007,7 @@ class PagedQueryStore {
1002
1007
  if (!isChanged) {
1003
1008
  return;
1004
1009
  }
1010
+ this.invalidatePageCache();
1005
1011
  this.#routeParams.set(params);
1006
1012
  if (opts.reset) {
1007
1013
  this.resetDataset();
@@ -1023,6 +1029,7 @@ class PagedQueryStore {
1023
1029
  updateConfig = (config) => {
1024
1030
  this.config = { ...this.config, ...config };
1025
1031
  this.#cache.limit = this.resolveCacheLimit();
1032
+ this.invalidatePageCache();
1026
1033
  this.applyPresetMeta();
1027
1034
  this.reinitTransport();
1028
1035
  };
@@ -1031,6 +1038,7 @@ class PagedQueryStore {
1031
1038
  */
1032
1039
  copy(store) {
1033
1040
  this.applyConfig(store.config);
1041
+ this.invalidatePageCache();
1034
1042
  this.applyPresetMeta();
1035
1043
  this.reinitTransport();
1036
1044
  }
@@ -1164,12 +1172,16 @@ class PagedQueryStore {
1164
1172
  }
1165
1173
  this.cached.set(flat);
1166
1174
  };
1175
+ invalidatePageCache() {
1176
+ this.#cache.clear();
1177
+ this.cached.set([]);
1178
+ }
1167
1179
  parseFlatArray(data) {
1168
1180
  return { content: data, totalElements: data.length };
1169
1181
  }
1170
1182
  resetDataset() {
1171
1183
  this.page = 0;
1172
- this.totalElements = 0;
1184
+ this.totalElements = undefined;
1173
1185
  this.#cache.clear();
1174
1186
  this.cached.set([]);
1175
1187
  this.items.set([]);
@@ -1285,19 +1297,19 @@ class DictStore {
1285
1297
  metaStorage;
1286
1298
  ttlMs;
1287
1299
  revalidate;
1288
- cacheUpdatedAt = signal(null, ...(ngDevMode ? [{ debugName: "cacheUpdatedAt" }] : []));
1300
+ cacheUpdatedAt = signal(null, ...(ngDevMode ? [{ debugName: "cacheUpdatedAt" }] : /* istanbul ignore next */ []));
1289
1301
  presetFilters = {};
1290
1302
  /**
1291
1303
  * Search text.
1292
1304
  * With `fixed: true` filters the local cache; with `fixed: false` triggers server search.
1293
1305
  */
1294
- searchText = signal('', ...(ngDevMode ? [{ debugName: "searchText" }] : []));
1306
+ searchText = signal('', ...(ngDevMode ? [{ debugName: "searchText" }] : /* istanbul ignore next */ []));
1295
1307
  debouncedSearchText;
1296
1308
  /**
1297
1309
  * Additional filters for server request (or presets).
1298
1310
  */
1299
- filters = signal({}, ...(ngDevMode ? [{ debugName: "filters" }] : []));
1300
- cachedItems = signal([], ...(ngDevMode ? [{ debugName: "cachedItems" }] : []));
1311
+ filters = signal({}, ...(ngDevMode ? [{ debugName: "filters" }] : /* istanbul ignore next */ []));
1312
+ cachedItems = signal([], ...(ngDevMode ? [{ debugName: "cachedItems" }] : /* istanbul ignore next */ []));
1301
1313
  /**
1302
1314
  * Current list of dictionary items.
1303
1315
  * Source — local cache (fixed=true) or data from `PagedQueryStore`.
@@ -1308,7 +1320,7 @@ class DictStore {
1308
1320
  return this.debouncedSearchText() || !cached.length ? this.#helper.items() : cached;
1309
1321
  }
1310
1322
  return cached.length ? this.filterLocal() : this.#helper.items();
1311
- }, ...(ngDevMode ? [{ debugName: "items" }] : []));
1323
+ }, ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1312
1324
  /**
1313
1325
  * Ready-to-use dropdown options: `{ label, value }`.
1314
1326
  * Respects `maxOptionsSize` for truncating the list.
@@ -1316,9 +1328,9 @@ class DictStore {
1316
1328
  options = computed(() => {
1317
1329
  const options = this.items().map((it) => ({ label: String(it[this.labelKey] ?? ''), value: it[this.valueKey] }));
1318
1330
  return this.maxOptionsSize ? options.slice(0, this.maxOptionsSize) : options;
1319
- }, ...(ngDevMode ? [{ debugName: "options" }] : []));
1331
+ }, ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
1320
1332
  _lastPromise = null;
1321
- _armed = signal(false, ...(ngDevMode ? [{ debugName: "_armed" }] : []));
1333
+ _armed = signal(false, ...(ngDevMode ? [{ debugName: "_armed" }] : /* istanbul ignore next */ []));
1322
1334
  // todo add i18n support
1323
1335
  /**
1324
1336
  * @param apiUrl dictionary endpoint (e.g., `'/api/dicts/countries'`)
@@ -1574,8 +1586,8 @@ class DictLocalStore {
1574
1586
  * Represents the full, unfiltered list of dictionary entries.
1575
1587
  * Used as the base data set for search and option generation.
1576
1588
  */
1577
- items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
1578
- #draftItems = signal([], ...(ngDevMode ? [{ debugName: "#draftItems" }] : []));
1589
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1590
+ #draftItems = signal([], ...(ngDevMode ? [{ debugName: "#draftItems" }] : /* istanbul ignore next */ []));
1579
1591
  /**
1580
1592
  * Computed list of options in `{ label, value }` format.
1581
1593
  *
@@ -1590,7 +1602,7 @@ class DictLocalStore {
1590
1602
  value: it[this.valueKey],
1591
1603
  }));
1592
1604
  return this.maxOptionsSize ? options.slice(0, this.maxOptionsSize) : options;
1593
- }, ...(ngDevMode ? [{ debugName: "options" }] : []));
1605
+ }, ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
1594
1606
  labelKey;
1595
1607
  valueKey;
1596
1608
  maxOptionsSize;
@@ -1675,14 +1687,14 @@ class DictLocalStore {
1675
1687
  class EntityStore {
1676
1688
  idKey;
1677
1689
  sortIds;
1678
- byId = signal({}, ...(ngDevMode ? [{ debugName: "byId" }] : []));
1679
- ids = signal([], ...(ngDevMode ? [{ debugName: "ids" }] : []));
1690
+ byId = signal({}, ...(ngDevMode ? [{ debugName: "byId" }] : /* istanbul ignore next */ []));
1691
+ ids = signal([], ...(ngDevMode ? [{ debugName: "ids" }] : /* istanbul ignore next */ []));
1680
1692
  items = computed(() => {
1681
1693
  const byId = this.byId();
1682
1694
  return this.ids()
1683
1695
  .map((id) => byId[String(id)])
1684
1696
  .filter((item) => item !== undefined);
1685
- }, ...(ngDevMode ? [{ debugName: "items" }] : []));
1697
+ }, ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1686
1698
  constructor(config) {
1687
1699
  this.idKey = config.idKey;
1688
1700
  this.sortIds = config.sortIds;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.1.3",
2
+ "version": "3.1.5",
3
3
  "name": "@reforgium/statum",
4
4
  "description": "Signals-first API state and query stores for Angular",
5
5
  "author": "rtommievich",
@@ -505,7 +505,7 @@ declare class PagedQueryStore<ItemsType extends AnyDict, FilterType extends AnyD
505
505
  version: WritableSignal<number>;
506
506
  readonly pageState: _angular_core.Signal<number>;
507
507
  readonly pageSizeState: _angular_core.Signal<number>;
508
- readonly totalElementsState: _angular_core.Signal<number>;
508
+ readonly totalElementsState: _angular_core.Signal<number | undefined>;
509
509
  readonly filtersState: _angular_core.Signal<Partial<FilterType>>;
510
510
  readonly queryState: _angular_core.Signal<AnyDict>;
511
511
  readonly sortState: _angular_core.Signal<QuerySortRule[]>;
@@ -523,15 +523,15 @@ declare class PagedQueryStore<ItemsType extends AnyDict, FilterType extends AnyD
523
523
  get page(): number;
524
524
  /** Default page size. */
525
525
  get pageSize(): number;
526
- /** Total number of elements reported by the server. */
527
- get totalElements(): number;
526
+ /** Total number of elements reported by the server, or `undefined` when the total is unknown. */
527
+ get totalElements(): number | undefined;
528
528
  /** Current sort rules. */
529
529
  get sort(): QuerySortRule[];
530
530
  set filters(value: Partial<FilterType>);
531
531
  set query(value: AnyDict);
532
532
  set page(value: number);
533
533
  set pageSize(value: number);
534
- set totalElements(value: number);
534
+ set totalElements(value: number | undefined);
535
535
  /**
536
536
  * Fetch data with explicit filters and query params from the first page.
537
537
  * Replaces legacy `setFilters`.
@@ -600,6 +600,7 @@ declare class PagedQueryStore<ItemsType extends AnyDict, FilterType extends AnyD
600
600
  private parseQuery;
601
601
  private parseResponseData;
602
602
  private updateCache;
603
+ private invalidatePageCache;
603
604
  private parseFlatArray;
604
605
  private resetDataset;
605
606
  private bumpVersion;