@reforgium/statum 3.1.3 → 3.1.4

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,15 @@
1
- ## [3.1.3]: 2026-04-06
1
+ ## [3.1.4]: 2026-04-10
2
+
3
+ ### Fix:
4
+ - `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.
5
+ - `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.
6
+
7
+ ### Test:
8
+ - added regression coverage for omitted `totalElements`, reset/refetch clearing, and empty-result unknown-total flows
9
+
10
+ ---
11
+
12
+ ## [3.1.3]: 2026-04-06
2
13
 
3
14
  ### Fix:
4
15
  - `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
  /**
@@ -1169,7 +1174,7 @@ class PagedQueryStore {
1169
1174
  }
1170
1175
  resetDataset() {
1171
1176
  this.page = 0;
1172
- this.totalElements = 0;
1177
+ this.totalElements = undefined;
1173
1178
  this.#cache.clear();
1174
1179
  this.cached.set([]);
1175
1180
  this.items.set([]);
@@ -1285,19 +1290,19 @@ class DictStore {
1285
1290
  metaStorage;
1286
1291
  ttlMs;
1287
1292
  revalidate;
1288
- cacheUpdatedAt = signal(null, ...(ngDevMode ? [{ debugName: "cacheUpdatedAt" }] : []));
1293
+ cacheUpdatedAt = signal(null, ...(ngDevMode ? [{ debugName: "cacheUpdatedAt" }] : /* istanbul ignore next */ []));
1289
1294
  presetFilters = {};
1290
1295
  /**
1291
1296
  * Search text.
1292
1297
  * With `fixed: true` filters the local cache; with `fixed: false` triggers server search.
1293
1298
  */
1294
- searchText = signal('', ...(ngDevMode ? [{ debugName: "searchText" }] : []));
1299
+ searchText = signal('', ...(ngDevMode ? [{ debugName: "searchText" }] : /* istanbul ignore next */ []));
1295
1300
  debouncedSearchText;
1296
1301
  /**
1297
1302
  * Additional filters for server request (or presets).
1298
1303
  */
1299
- filters = signal({}, ...(ngDevMode ? [{ debugName: "filters" }] : []));
1300
- cachedItems = signal([], ...(ngDevMode ? [{ debugName: "cachedItems" }] : []));
1304
+ filters = signal({}, ...(ngDevMode ? [{ debugName: "filters" }] : /* istanbul ignore next */ []));
1305
+ cachedItems = signal([], ...(ngDevMode ? [{ debugName: "cachedItems" }] : /* istanbul ignore next */ []));
1301
1306
  /**
1302
1307
  * Current list of dictionary items.
1303
1308
  * Source — local cache (fixed=true) or data from `PagedQueryStore`.
@@ -1308,7 +1313,7 @@ class DictStore {
1308
1313
  return this.debouncedSearchText() || !cached.length ? this.#helper.items() : cached;
1309
1314
  }
1310
1315
  return cached.length ? this.filterLocal() : this.#helper.items();
1311
- }, ...(ngDevMode ? [{ debugName: "items" }] : []));
1316
+ }, ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1312
1317
  /**
1313
1318
  * Ready-to-use dropdown options: `{ label, value }`.
1314
1319
  * Respects `maxOptionsSize` for truncating the list.
@@ -1316,9 +1321,9 @@ class DictStore {
1316
1321
  options = computed(() => {
1317
1322
  const options = this.items().map((it) => ({ label: String(it[this.labelKey] ?? ''), value: it[this.valueKey] }));
1318
1323
  return this.maxOptionsSize ? options.slice(0, this.maxOptionsSize) : options;
1319
- }, ...(ngDevMode ? [{ debugName: "options" }] : []));
1324
+ }, ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
1320
1325
  _lastPromise = null;
1321
- _armed = signal(false, ...(ngDevMode ? [{ debugName: "_armed" }] : []));
1326
+ _armed = signal(false, ...(ngDevMode ? [{ debugName: "_armed" }] : /* istanbul ignore next */ []));
1322
1327
  // todo add i18n support
1323
1328
  /**
1324
1329
  * @param apiUrl dictionary endpoint (e.g., `'/api/dicts/countries'`)
@@ -1574,8 +1579,8 @@ class DictLocalStore {
1574
1579
  * Represents the full, unfiltered list of dictionary entries.
1575
1580
  * Used as the base data set for search and option generation.
1576
1581
  */
1577
- items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
1578
- #draftItems = signal([], ...(ngDevMode ? [{ debugName: "#draftItems" }] : []));
1582
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1583
+ #draftItems = signal([], ...(ngDevMode ? [{ debugName: "#draftItems" }] : /* istanbul ignore next */ []));
1579
1584
  /**
1580
1585
  * Computed list of options in `{ label, value }` format.
1581
1586
  *
@@ -1590,7 +1595,7 @@ class DictLocalStore {
1590
1595
  value: it[this.valueKey],
1591
1596
  }));
1592
1597
  return this.maxOptionsSize ? options.slice(0, this.maxOptionsSize) : options;
1593
- }, ...(ngDevMode ? [{ debugName: "options" }] : []));
1598
+ }, ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
1594
1599
  labelKey;
1595
1600
  valueKey;
1596
1601
  maxOptionsSize;
@@ -1675,14 +1680,14 @@ class DictLocalStore {
1675
1680
  class EntityStore {
1676
1681
  idKey;
1677
1682
  sortIds;
1678
- byId = signal({}, ...(ngDevMode ? [{ debugName: "byId" }] : []));
1679
- ids = signal([], ...(ngDevMode ? [{ debugName: "ids" }] : []));
1683
+ byId = signal({}, ...(ngDevMode ? [{ debugName: "byId" }] : /* istanbul ignore next */ []));
1684
+ ids = signal([], ...(ngDevMode ? [{ debugName: "ids" }] : /* istanbul ignore next */ []));
1680
1685
  items = computed(() => {
1681
1686
  const byId = this.byId();
1682
1687
  return this.ids()
1683
1688
  .map((id) => byId[String(id)])
1684
1689
  .filter((item) => item !== undefined);
1685
- }, ...(ngDevMode ? [{ debugName: "items" }] : []));
1690
+ }, ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1686
1691
  constructor(config) {
1687
1692
  this.idKey = config.idKey;
1688
1693
  this.sortIds = config.sortIds;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.1.3",
2
+ "version": "3.1.4",
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`.