@reforgium/statum 3.0.0-rc.1 → 3.0.1

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.
@@ -1,6 +1,6 @@
1
- import { formatDate, isNullable, isDatePeriod, parseToDate, makeQuery, isNumber, isObject, parseToDatePeriod, parseQueryArray, fillUrlWithParams, deepEqual, concatArray, debounceSignal } from '@reforgium/internal';
2
- import { HttpClient } from '@angular/common/http';
3
- import { InjectionToken, makeEnvironmentProviders, inject, signal, computed, DestroyRef, effect, untracked } from '@angular/core';
1
+ import { formatDate, isNullable, isDatePeriod, parseToDate, buildQueryParams, isNumber, isObject, parseToDatePeriod, parseQueryParamsByMode, fillUrlWithParams, mergeQueryParams, deepEqual, normalizeSortInput, sortInputToTokens, debounceSignal } from '@reforgium/internal';
2
+ import { HttpClient, HttpParams } from '@angular/common/http';
3
+ import { InjectionToken, makeEnvironmentProviders, inject, signal, computed, EnvironmentInjector, DestroyRef, runInInjectionContext, effect, untracked } from '@angular/core';
4
4
  import { Subject, filter, timer, merge, map } from 'rxjs';
5
5
  import { debounce, tap, throttle, finalize } from 'rxjs/operators';
6
6
 
@@ -162,8 +162,8 @@ class Serializer {
162
162
  .replace(periodTransform.dateFromKeyPostfix, '')
163
163
  .replace(periodTransform.dateToKeyPostfix, '');
164
164
  const field = this.config.mapFields?.[keyJoint];
165
- // @ts-ignore
166
- if (field?.['type'] === 'period' && (isFrom || isTo)) {
165
+ const fieldType = field && 'type' in field ? field.type : undefined;
166
+ if (fieldType === 'period' && (isFrom || isTo)) {
167
167
  result[keyJoint] ??= [null, null];
168
168
  if (isFrom) {
169
169
  result[keyJoint][0] = parseToDate(value, this.config.mapPeriod.dateFormat);
@@ -194,7 +194,7 @@ class Serializer {
194
194
  * @returns query string (suitable for URL or history API)
195
195
  */
196
196
  toQuery = (val) => {
197
- return makeQuery(this.serialize(val), this.config.mapArray.concatType);
197
+ return buildQueryParams(this.serialize(val), this.config.mapArray.concatType, this.resolveArrayFieldModes());
198
198
  };
199
199
  /**
200
200
  * Returns a new serializer instance with a merged configuration.
@@ -280,8 +280,7 @@ class Serializer {
280
280
  return isObject(value) ? this.deserialize(value) : value;
281
281
  }
282
282
  else {
283
- // @ts-ignore
284
- return JSON.parse(value);
283
+ return typeof value === 'string' ? JSON.parse(value) : value;
285
284
  }
286
285
  }
287
286
  catch {
@@ -321,21 +320,7 @@ class Serializer {
321
320
  return value;
322
321
  }
323
322
  parseQuery(val) {
324
- const params = new URLSearchParams(val);
325
- const data = {};
326
- params.forEach((value, key) => {
327
- const field = this.config.mapFields?.[key] || {};
328
- const concatType = this.config.mapArray.concatType;
329
- if ('type' in field && field.type === 'array') {
330
- data[key] ??= [];
331
- concatType !== 'multi' && (data[key] = parseQueryArray(value, concatType, key));
332
- concatType === 'multi' && data[key].push(value);
333
- }
334
- else {
335
- data[key] = value;
336
- }
337
- });
338
- return data;
323
+ return parseQueryParamsByMode(val, undefined, this.resolveArrayFieldModes());
339
324
  }
340
325
  parseJsonObject(val) {
341
326
  try {
@@ -379,6 +364,15 @@ class Serializer {
379
364
  mapFields: { ...(base.mapFields || {}), ...(override.mapFields || {}) },
380
365
  };
381
366
  }
367
+ resolveArrayFieldModes() {
368
+ const result = {};
369
+ Object.entries(this.config.mapFields || {}).forEach(([key, field]) => {
370
+ if ('type' in field && field.type === 'array') {
371
+ result[key] = field.concatType ?? this.config.mapArray.concatType;
372
+ }
373
+ });
374
+ return result;
375
+ }
382
376
  }
383
377
 
384
378
  const createQuerySerializer = (config = {}) => {
@@ -408,19 +402,19 @@ const createStrictSerializer = (config = {}) => {
408
402
 
409
403
  class LruCache {
410
404
  map = new Map();
405
+ _limit = 100;
411
406
  constructor(limit = 100) {
412
407
  this.limit = limit;
413
408
  }
414
- _limit = 100;
415
409
  get limit() {
416
410
  return this._limit;
417
411
  }
418
- set limit(value) {
419
- this._limit = Math.max(1, Math.floor(value || 0));
420
- }
421
412
  get length() {
422
413
  return this.map.size;
423
414
  }
415
+ set limit(value) {
416
+ this._limit = Math.max(1, Math.floor(value || 0));
417
+ }
424
418
  get(key) {
425
419
  if (!this.map.has(key)) {
426
420
  return null;
@@ -647,7 +641,14 @@ function isAbort(e) {
647
641
  (typeof e === 'object' && e != null && e?.name === 'AbortError' && e?.message === 'aborted'));
648
642
  }
649
643
  function joinUrl(base, path) {
650
- return base ? `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}` : path;
644
+ if (!base) {
645
+ return path;
646
+ }
647
+ const normalizedBase = base.replace(/\/$/, '');
648
+ if (!path) {
649
+ return normalizedBase;
650
+ }
651
+ return `${normalizedBase}/${path.replace(/^\//, '')}`;
651
652
  }
652
653
  function buildKey(method, path, args) {
653
654
  const params = stableStringify(args.params || {});
@@ -835,7 +836,7 @@ class KeyedScheduler {
835
836
  */
836
837
  class ResourceStore {
837
838
  http = inject(HttpClient);
838
- serializer = new Serializer(inject(STATUM_CONFIG, { optional: true })?.serializer ?? {});
839
+ serializer;
839
840
  #value = signal(null, ...(ngDevMode ? [{ debugName: "#value" }] : []));
840
841
  #status = signal('idle', ...(ngDevMode ? [{ debugName: "#status" }] : []));
841
842
  #error = signal(null, ...(ngDevMode ? [{ debugName: "#error" }] : []));
@@ -863,6 +864,11 @@ class ResourceStore {
863
864
  maxEntries;
864
865
  entries = new Map();
865
866
  scheduler = new KeyedScheduler();
867
+ mutationMethods = {
868
+ PATCH: this.http.patch.bind(this.http),
869
+ POST: this.http.post.bind(this.http),
870
+ PUT: this.http.put.bind(this.http),
871
+ };
866
872
  /**
867
873
  * @param routes Map of path templates for methods (`GET/POST/...` → `/users/:id`)
868
874
  * @param opts Global options (baseUrl, ttlMs, delay, delayMode, presetQueries/payload, etc.)
@@ -871,6 +877,10 @@ class ResourceStore {
871
877
  this.routes = routes;
872
878
  this.opts = opts;
873
879
  this.maxEntries = Math.max(1, opts.maxEntries ?? 1000);
880
+ this.serializer = new Serializer({
881
+ ...(inject(STATUM_CONFIG, { optional: true })?.serializer ?? {}),
882
+ ...(opts.serializer ?? {}),
883
+ });
874
884
  }
875
885
  /**
876
886
  * Perform a GET request.
@@ -882,11 +892,20 @@ class ResourceStore {
882
892
  * @param cfg Call settings (strategy, ttlMs, revalidate, parseResponse, delay, delayMode, dedupe, promote)
883
893
  * @returns Deserialized `Data`
884
894
  */
885
- async get(args, cfg = {}) {
886
- const url = this.buildUrl('GET', args);
887
- const key = buildKey('GET', url, args);
888
- const query = this.prepareQuery(args);
889
- const responseType = cfg.responseType ?? 'json';
895
+ get(args, cfg = {}) {
896
+ let url;
897
+ let key;
898
+ let query;
899
+ let responseType;
900
+ try {
901
+ url = this.buildUrl('GET', args);
902
+ key = buildKey('GET', url, args);
903
+ query = this.prepareQuery(args);
904
+ responseType = cfg.responseType ?? 'json';
905
+ }
906
+ catch (error) {
907
+ return Promise.reject(error);
908
+ }
890
909
  const entry = this.ensureEntry(key);
891
910
  const strategy = cfg.strategy ?? 'network-first';
892
911
  const ttlMs = cfg.ttlMs ?? this.opts.ttlMs ?? 0;
@@ -898,15 +917,15 @@ class ResourceStore {
898
917
  if (fresh && entry.data != null) {
899
918
  this.trace({ type: 'cache-hit', method: 'GET', key, strategy });
900
919
  this.promoteCurrent(entry, cfg.promote);
901
- return entry.data;
920
+ return Promise.resolve(entry.data);
902
921
  }
903
922
  this.trace({ type: 'cache-miss', method: 'GET', key, strategy });
904
- throw new CacheMissError(key);
923
+ return Promise.reject(new CacheMissError(key));
905
924
  }
906
925
  if (strategy === 'cache-first' && fresh && !cfg.revalidate && entry.data != null) {
907
926
  this.trace({ type: 'cache-hit', method: 'GET', key, strategy });
908
927
  this.promoteCurrent(entry, cfg.promote);
909
- return entry.data;
928
+ return Promise.resolve(entry.data);
910
929
  }
911
930
  const delay = cfg.delay ?? this.opts.delay ?? 0;
912
931
  const mode = cfg.delayMode ?? this.opts.delayMode ?? 'debounce';
@@ -971,8 +990,8 @@ class ResourceStore {
971
990
  * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
972
991
  * @returns API response (by default — as returned by the server)
973
992
  */
974
- async post(args, cfg = {}) {
975
- return await this.callApi('POST', args, cfg);
993
+ post(args, cfg = {}) {
994
+ return this.callApi('POST', args, cfg);
976
995
  }
977
996
  /**
978
997
  * PUT request (full resource update).
@@ -981,8 +1000,8 @@ class ResourceStore {
981
1000
  * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
982
1001
  * @returns API response (by default — as returned by the server)
983
1002
  */
984
- async put(args, cfg = {}) {
985
- return await this.callApi('PUT', args, cfg);
1003
+ put(args, cfg = {}) {
1004
+ return this.callApi('PUT', args, cfg);
986
1005
  }
987
1006
  /**
988
1007
  * PATCH request (partial update).
@@ -991,8 +1010,8 @@ class ResourceStore {
991
1010
  * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
992
1011
  * @returns API response (by default — as returned by the server)
993
1012
  */
994
- async patch(args, cfg = {}) {
995
- return await this.callApi('PATCH', args, cfg);
1013
+ patch(args, cfg = {}) {
1014
+ return this.callApi('PATCH', args, cfg);
996
1015
  }
997
1016
  /**
998
1017
  * DELETE request.
@@ -1001,8 +1020,8 @@ class ResourceStore {
1001
1020
  * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
1002
1021
  * @returns API response (by default — as returned by the server)
1003
1022
  */
1004
- async delete(args, cfg = {}) {
1005
- return await this.callApi('DELETE', args, cfg);
1023
+ delete(args, cfg = {}) {
1024
+ return this.callApi('DELETE', args, cfg);
1006
1025
  }
1007
1026
  /**
1008
1027
  * Cancel scheduled/running requests for a specific call.
@@ -1071,13 +1090,22 @@ class ResourceStore {
1071
1090
  this.entries.delete(keyToDelete);
1072
1091
  }
1073
1092
  }
1074
- async callApi(method, args, config = {}) {
1075
- const url = this.buildUrl(method, args);
1076
- const key = buildKey(method, url, args);
1077
- const query = this.prepareQuery(args);
1078
- const payload = { ...(this.opts.presetPayload || {}), ...(args.payload || {}) };
1079
- const serializedPayload = this.serializer.serialize(payload);
1080
- const responseType = config.responseType ?? 'json';
1093
+ callApi(method, args, config = {}) {
1094
+ let url;
1095
+ let key;
1096
+ let query;
1097
+ let payload;
1098
+ let responseType;
1099
+ try {
1100
+ url = this.buildUrl(method, args);
1101
+ key = buildKey(method, url, args);
1102
+ query = this.prepareQuery(args);
1103
+ payload = this.preparePayload(args.payload);
1104
+ responseType = config.responseType ?? 'json';
1105
+ }
1106
+ catch (error) {
1107
+ return Promise.reject(error);
1108
+ }
1081
1109
  const entry = this.ensureEntry(key);
1082
1110
  if (config.dedupe && entry.inflight) {
1083
1111
  return entry.inflight;
@@ -1090,14 +1118,14 @@ class ResourceStore {
1090
1118
  let req$;
1091
1119
  if (method === 'DELETE') {
1092
1120
  req$ = this.http.delete(url, {
1093
- body: serializedPayload,
1121
+ body: payload,
1094
1122
  params: query,
1095
1123
  responseType: responseType,
1096
1124
  });
1097
1125
  }
1098
1126
  else {
1099
1127
  // @ts-ignore
1100
- req$ = this.http[method.toLowerCase()](url, serializedPayload, {
1128
+ req$ = this.mutationMethods[method](url, payload, {
1101
1129
  params: query,
1102
1130
  responseType: responseType,
1103
1131
  });
@@ -1132,15 +1160,45 @@ class ResourceStore {
1132
1160
  }
1133
1161
  buildUrl(method, args) {
1134
1162
  const tpl = this.routes[method];
1135
- if (!tpl) {
1163
+ if (tpl == null) {
1136
1164
  throw new Error(`${method} route not configured`);
1137
1165
  }
1138
1166
  const path = fillUrlWithParams(tpl, args.params);
1139
1167
  return joinUrl(this.opts.baseUrl, path);
1140
1168
  }
1141
1169
  prepareQuery(args) {
1142
- const mergedQuery = { ...(this.opts.presetQueries || {}), ...(args.query || {}) };
1143
- return this.serializer.serialize(mergedQuery);
1170
+ const presetQuery = this.opts.presetQueries;
1171
+ const requestQuery = args.query;
1172
+ if (!presetQuery && !requestQuery) {
1173
+ return new HttpParams();
1174
+ }
1175
+ const mergedQuery = mergeQueryParams(presetQuery, requestQuery);
1176
+ if (this.isEmptyObject(mergedQuery)) {
1177
+ return new HttpParams();
1178
+ }
1179
+ return new HttpParams({
1180
+ fromString: this.serializer.toQuery(mergedQuery),
1181
+ });
1182
+ }
1183
+ preparePayload(payload) {
1184
+ const presetPayload = this.opts.presetPayload;
1185
+ if (!presetPayload && !payload) {
1186
+ return {};
1187
+ }
1188
+ let mergedPayload;
1189
+ if (!presetPayload) {
1190
+ mergedPayload = payload ?? {};
1191
+ }
1192
+ else if (!payload) {
1193
+ mergedPayload = presetPayload;
1194
+ }
1195
+ else {
1196
+ mergedPayload = { ...presetPayload, ...payload };
1197
+ }
1198
+ if (this.isEmptyObject(mergedPayload)) {
1199
+ return {};
1200
+ }
1201
+ return this.serializer.serialize(mergedPayload);
1144
1202
  }
1145
1203
  trace(event) {
1146
1204
  try {
@@ -1218,6 +1276,14 @@ class ResourceStore {
1218
1276
  this.#status.set(entry.status);
1219
1277
  this.#error.set(entry.error);
1220
1278
  }
1279
+ isEmptyObject(value) {
1280
+ for (const _key in value) {
1281
+ if (Object.prototype.hasOwnProperty.call(value, _key)) {
1282
+ return false;
1283
+ }
1284
+ }
1285
+ return true;
1286
+ }
1221
1287
  }
1222
1288
 
1223
1289
  const RESOURCE_PROFILES = {
@@ -1265,26 +1331,37 @@ const createResourceProfile = (profile, overrides = {}) => ({
1265
1331
  class PagedQueryStore {
1266
1332
  route;
1267
1333
  config;
1334
+ static DISABLED_CACHE_LIMIT = Number.MAX_SAFE_INTEGER;
1268
1335
  defaultConfig = inject(STATUM_CONFIG, { optional: true })?.pagedQuery || {};
1336
+ injector = inject(EnvironmentInjector);
1337
+ #requestId = 0;
1338
+ #activeRequests = 0;
1269
1339
  #transport;
1270
1340
  #cache;
1271
1341
  /** Current page data (reactive). */
1272
1342
  items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
1273
- /** Merged cache of pages (flat list) — handy for search/export. */
1343
+ /** Merged cache of pages (flat list) handy for search/export. */
1274
1344
  cached = signal([], ...(ngDevMode ? [{ debugName: "cached" }] : []));
1275
1345
  /** Loading flag of the current operation. */
1276
1346
  loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
1277
- /** Current filters (applied to requests). */
1278
- filters = {};
1279
- /** Additional query params sent to transport requests. */
1280
- query = {};
1281
- /** Current page index (0-based). */
1282
- page = 0;
1283
- /** Default page size. */
1284
- pageSize = 20;
1285
- /** Total number of elements reported by the server. */
1286
- totalElements = 0;
1287
- routeParams = {};
1347
+ /** Last request error (if any). */
1348
+ error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
1349
+ /** Increments when the current dataset is reset/replaced. Useful for external consumers with local buffers. */
1350
+ version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : []));
1351
+ #page = signal(0, ...(ngDevMode ? [{ debugName: "#page" }] : []));
1352
+ #pageSize = signal(20, ...(ngDevMode ? [{ debugName: "#pageSize" }] : []));
1353
+ #totalElements = signal(0, ...(ngDevMode ? [{ debugName: "#totalElements" }] : []));
1354
+ #filters = signal({}, ...(ngDevMode ? [{ debugName: "#filters" }] : []));
1355
+ #query = signal({}, ...(ngDevMode ? [{ debugName: "#query" }] : []));
1356
+ #sort = signal([], ...(ngDevMode ? [{ debugName: "#sort" }] : []));
1357
+ #routeParams = signal({}, ...(ngDevMode ? [{ debugName: "#routeParams" }] : []));
1358
+ pageState = this.#page.asReadonly();
1359
+ pageSizeState = this.#pageSize.asReadonly();
1360
+ totalElementsState = this.#totalElements.asReadonly();
1361
+ filtersState = this.#filters.asReadonly();
1362
+ queryState = this.#query.asReadonly();
1363
+ sortState = this.#sort.asReadonly();
1364
+ routeParamsState = this.#routeParams.asReadonly();
1288
1365
  /**
1289
1366
  * @param route Resource URL pattern (e.g., `'/users'`)
1290
1367
  * @param config Store behavior: method, cache, request/response parsers, debounce, presets, etc.
@@ -1293,45 +1370,105 @@ class PagedQueryStore {
1293
1370
  this.route = route;
1294
1371
  this.config = config;
1295
1372
  this.applyConfig(config);
1296
- this.#cache = new LruCache(this.config.cacheSize);
1373
+ this.#cache = new LruCache(this.resolveCacheLimit());
1297
1374
  this.applyPresetMeta();
1298
1375
  this.initTransport();
1299
1376
  inject(DestroyRef).onDestroy(() => this.destroy());
1300
1377
  }
1378
+ /** Current filters (applied to requests). */
1379
+ get filters() {
1380
+ return this.#filters();
1381
+ }
1382
+ /** Additional query params sent to transport requests. */
1383
+ get query() {
1384
+ return this.#query();
1385
+ }
1386
+ /** Current page index (0-based). */
1387
+ get page() {
1388
+ return this.#page();
1389
+ }
1390
+ /** Default page size. */
1391
+ get pageSize() {
1392
+ return this.#pageSize();
1393
+ }
1394
+ /** Total number of elements reported by the server. */
1395
+ get totalElements() {
1396
+ return this.#totalElements();
1397
+ }
1398
+ /** Current sort rules. */
1399
+ get sort() {
1400
+ return this.#sort();
1401
+ }
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
+ set filters(value) {
1407
+ this.#filters.set(value);
1408
+ }
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
+ set query(value) {
1414
+ this.#query.set(value);
1415
+ }
1416
+ /**
1417
+ * @deprecated Prefer `updatePage(...)`, `fetch(...)`, or `refetchWith(...)`.
1418
+ * Direct state mutation bypasses request semantics and should be treated as legacy.
1419
+ */
1420
+ set page(value) {
1421
+ this.#page.set(value);
1422
+ }
1423
+ /**
1424
+ * @deprecated Prefer `updatePageSize(...)`.
1425
+ * Direct state mutation bypasses request semantics and should be treated as legacy.
1426
+ */
1427
+ set pageSize(value) {
1428
+ this.#pageSize.set(value);
1429
+ }
1430
+ /**
1431
+ * @deprecated Managed by transport responses. Direct assignment should be treated as legacy.
1432
+ */
1433
+ set totalElements(value) {
1434
+ this.#totalElements.set(value);
1435
+ }
1301
1436
  /**
1302
1437
  * Fetch data with explicit filters and query params from the first page.
1303
1438
  * Replaces legacy `setFilters`.
1304
1439
  */
1305
- fetch = ({ filters = {}, query = {}, routeParams = {} } = {}) => {
1306
- this.#cache.clear();
1307
- this.cached.set([]);
1308
- return this.#fetchItems({ page: 0, filters, query, routeParams });
1440
+ fetch = ({ filters = {}, query = {}, routeParams = {}, sort = this.sort } = {}) => {
1441
+ this.resetDataset();
1442
+ return this.#fetchItems({ page: 0, filters, query, routeParams, sort });
1309
1443
  };
1310
1444
  /**
1311
1445
  * Force reload current data.
1312
1446
  * Optional args merge into active filters/query.
1313
1447
  * Route params are intentionally not changed from `refetchWith`.
1314
1448
  */
1315
- refetchWith({ filters, query } = {}) {
1449
+ refetchWith({ filters, query, sort } = {}) {
1316
1450
  const nextFilters = filters == null ? this.filters : { ...this.filters, ...filters };
1317
- const nextQuery = query == null ? this.query : { ...this.query, ...query };
1318
- return this.#fetchItems({ filters: nextFilters, query: nextQuery });
1451
+ const nextQuery = query == null ? this.query : mergeQueryParams(this.query, query);
1452
+ const nextSort = sort == null ? this.sort : this.normalizeSort(sort);
1453
+ return this.#fetchItems({ filters: nextFilters, query: nextQuery, sort: nextSort });
1319
1454
  }
1320
1455
  /**
1321
1456
  * Switch page with a request.
1322
- * If cache is enabled and the page is present in LRU — returns it from cache.
1323
- * @param page page index (0-based)
1324
- * @param ignoreCache ignore cache and fetch from network
1457
+ * If cache is enabled and the page is present in LRU returns it from cache.
1458
+ * @param page page index (0-based) or object form: `{ page }`
1459
+ * @param options request options
1460
+ * @param options.ignoreCache ignore cache and fetch from network
1325
1461
  */
1326
1462
  updatePage = (page = this.page, { ignoreCache = false } = {}) => {
1327
- if (this.config.hasCache && this.#cache.has(page) && !ignoreCache) {
1328
- const cached = this.#cache.get(page);
1463
+ const normalizedPage = typeof page === 'number' ? page : page.page;
1464
+ if (this.config.hasCache && this.#cache.has(normalizedPage) && !ignoreCache) {
1465
+ const cached = this.#cache.get(normalizedPage);
1329
1466
  this.items.set(cached);
1330
- this.page = page;
1467
+ this.page = normalizedPage;
1331
1468
  return Promise.resolve(cached);
1332
1469
  }
1333
1470
  else {
1334
- return this.#fetchItems({ page });
1471
+ return this.#fetchItems({ page: normalizedPage });
1335
1472
  }
1336
1473
  };
1337
1474
  /**
@@ -1339,17 +1476,27 @@ class PagedQueryStore {
1339
1476
  * @param size new size (rows per page)
1340
1477
  */
1341
1478
  updatePageSize = (size = this.pageSize) => {
1342
- this.#cache.clear();
1343
- this.cached.set([]);
1479
+ this.resetDataset();
1344
1480
  return this.#fetchItems({ page: 0, size });
1345
1481
  };
1346
1482
  /**
1347
1483
  * Helper for offset-based table events (PrimeNG, etc.) with `page/first/rows`.
1348
1484
  */
1349
- updateByOffset = ({ page: pageNum, first = 0, rows = 0 } = {}, { query = this.query } = {}) => {
1485
+ updateByOffset = ({ page: pageNum, first = 0, rows = 0 } = {}, { query = this.query, sort = this.sort } = {}) => {
1350
1486
  const page = (pageNum ?? (first && rows && Math.floor(first / rows))) || 0;
1351
1487
  const size = rows || this.pageSize;
1352
- return this.#fetchItems({ page, size, query });
1488
+ return this.#fetchItems({ page, size, query, sort });
1489
+ };
1490
+ setSort = (sort) => {
1491
+ this.#sort.set(this.normalizeSort(sort));
1492
+ };
1493
+ updateSort = (sort) => {
1494
+ return this.updateSorts(sort ? [sort] : []);
1495
+ };
1496
+ updateSorts = (sort) => {
1497
+ const normalizedSort = this.normalizeSort(sort);
1498
+ this.resetDataset();
1499
+ return this.#fetchItems({ page: 0, sort: normalizedSort });
1353
1500
  };
1354
1501
  /**
1355
1502
  * Set route parameters (path variables) for the resource URL.
@@ -1370,20 +1517,17 @@ class PagedQueryStore {
1370
1517
  * ```
1371
1518
  */
1372
1519
  setRouteParams = (params = {}, opts = {}) => {
1373
- const isChanged = !deepEqual(this.routeParams, params);
1520
+ const isChanged = !deepEqual(this.#routeParams(), params);
1374
1521
  if (!isChanged) {
1375
1522
  return;
1376
1523
  }
1377
- this.routeParams = params;
1524
+ this.#routeParams.set(params);
1378
1525
  if (opts.reset) {
1379
- this.page = 0;
1380
- this.totalElements = 0;
1381
- this.#cache.clear();
1382
- this.cached.set([]);
1383
- this.items.set([]);
1526
+ this.resetDataset();
1384
1527
  }
1385
1528
  if (opts.abort) {
1386
1529
  try {
1530
+ this.invalidateRequests();
1387
1531
  this.#transport?.abortAll?.('Route params changed');
1388
1532
  }
1389
1533
  finally {
@@ -1397,8 +1541,9 @@ class PagedQueryStore {
1397
1541
  */
1398
1542
  updateConfig = (config) => {
1399
1543
  this.config = { ...this.config, ...config };
1400
- this.#cache.limit = this.config.cacheSize || this.defaultConfig.defaultCacheSize || 5;
1544
+ this.#cache.limit = this.resolveCacheLimit();
1401
1545
  this.applyPresetMeta();
1546
+ this.reinitTransport();
1402
1547
  };
1403
1548
  /**
1404
1549
  * Copy configuration and presets from another store of the same type.
@@ -1406,10 +1551,12 @@ class PagedQueryStore {
1406
1551
  copy(store) {
1407
1552
  this.applyConfig(store.config);
1408
1553
  this.applyPresetMeta();
1554
+ this.reinitTransport();
1409
1555
  }
1410
1556
  /** Clean up resources and cancel active requests. Automatically called onDestroy. */
1411
1557
  destroy() {
1412
1558
  try {
1559
+ this.invalidateRequests();
1413
1560
  this.#transport?.abortAll?.('PagedQueryStore destroyed');
1414
1561
  }
1415
1562
  catch (e) {
@@ -1418,17 +1565,29 @@ class PagedQueryStore {
1418
1565
  }
1419
1566
  }
1420
1567
  }
1421
- #fetchItems = async ({ page = this.page, size = this.pageSize, filters = this.filters, query = this.query, routeParams = this.routeParams, }) => {
1422
- const builtQuery = this.parseQuery({ page, size, filters, query });
1568
+ #fetchItems = async ({ page = this.page, size = this.pageSize, filters = this.filters, query = this.query, routeParams = this.#routeParams(), sort = this.sort, }) => {
1569
+ const builtQuery = this.parseQuery({ page, size, filters, query, sort });
1570
+ const requestId = ++this.#requestId;
1571
+ this.#activeRequests++;
1423
1572
  this.loading.set(true);
1424
- this.routeParams = routeParams;
1425
- this.#transport.abortAll();
1573
+ this.error.set(null);
1574
+ this.#routeParams.set(routeParams);
1575
+ if (this.resolveConcurrency() === 'latest-wins') {
1576
+ this.#transport.abortAll();
1577
+ }
1426
1578
  try {
1427
1579
  const response = await this.runTransport(filters, builtQuery, routeParams);
1580
+ if (this.shouldIgnoreRequestResult(requestId)) {
1581
+ return;
1582
+ }
1428
1583
  this.page = page;
1429
1584
  this.filters = filters;
1430
1585
  this.query = query;
1586
+ this.#sort.set(this.normalizeSort(sort));
1431
1587
  const parsed = await this.parseResponseData(response);
1588
+ if (this.shouldIgnoreRequestResult(requestId)) {
1589
+ return;
1590
+ }
1432
1591
  this.pageSize = parsed.pageable?.pageSize || size;
1433
1592
  this.totalElements = parsed.totalElements;
1434
1593
  this.items.set(parsed.content);
@@ -1439,42 +1598,62 @@ class PagedQueryStore {
1439
1598
  if (isAbort(e)) {
1440
1599
  return;
1441
1600
  }
1601
+ if (!this.shouldIgnoreRequestResult(requestId)) {
1602
+ this.error.set(e);
1603
+ }
1442
1604
  throw e;
1443
1605
  }
1444
1606
  finally {
1445
- this.loading.set(false);
1607
+ this.#activeRequests = Math.max(0, this.#activeRequests - 1);
1608
+ this.loading.set(this.#activeRequests > 0);
1446
1609
  }
1447
1610
  };
1448
1611
  initTransport() {
1449
- this.#transport = new ResourceStore({ [this.config.method || 'GET']: this.route }, {
1612
+ this.#transport = runInInjectionContext(this.injector, () => new ResourceStore({ [this.config.method || 'GET']: this.route }, {
1613
+ baseUrl: this.config.baseUrl,
1450
1614
  delay: this.config.debounceTime,
1451
1615
  delayMode: 'debounce',
1452
1616
  presetQueries: { page: this.page, size: this.pageSize },
1453
1617
  presetPayload: this.filters,
1454
- });
1618
+ serializer: {
1619
+ mapFields: {
1620
+ [this.resolveSortQueryKey()]: {
1621
+ type: 'array',
1622
+ concatType: 'multi',
1623
+ },
1624
+ },
1625
+ },
1626
+ }));
1455
1627
  }
1456
1628
  async runTransport(payload, query, params) {
1457
1629
  if (this.config.method === 'GET') {
1458
1630
  return this.#transport.get({ query, params }, { promote: false, dedupe: true });
1459
1631
  }
1460
- const method = this.config.method?.toLowerCase() || 'post';
1632
+ const method = (this.config.method?.toLowerCase() || 'post');
1461
1633
  // @ts-ignore
1462
1634
  return this.#transport[method]({ query, payload, params }, { promote: false, dedupe: true });
1463
1635
  }
1464
- parseQuery({ page = 0, size, filters, query = {} }) {
1636
+ parseQuery({ page = 0, size, filters, query = {}, sort = this.sort }) {
1465
1637
  const method = this.config.method || 'GET';
1466
- const requestPayload = {
1467
- page,
1468
- size,
1469
- ...filters,
1470
- ...query,
1471
- };
1472
- const fallbackQuery = method === 'GET' ? requestPayload : { page, size, ...query };
1638
+ const normalizedSort = this.normalizeSort(sort);
1639
+ const sortQueryKey = this.resolveSortQueryKey();
1640
+ const requestPayload = mergeQueryParams(mergeQueryParams({ page, size }, filters), mergeQueryParams(query, normalizedSort.length ? { [sortQueryKey]: normalizedSort } : {}));
1641
+ const fallbackQuery = method === 'GET' ? requestPayload : mergeQueryParams({ page, size }, query);
1473
1642
  const rawQueries = this.config.parseRequest?.(requestPayload) || fallbackQuery;
1474
1643
  const queries = {};
1475
1644
  Object.entries(rawQueries).forEach(([key, value]) => {
1476
- if (Array.isArray(value)) {
1477
- queries[key] = concatArray(value, 'comma');
1645
+ if (key === sortQueryKey && Array.isArray(value)) {
1646
+ if (value.every((item) => typeof item === 'string')) {
1647
+ queries[key] = value;
1648
+ return;
1649
+ }
1650
+ const sortRules = this.normalizeSort(value);
1651
+ if (sortRules.length) {
1652
+ queries[key] = this.sortRulesToTokens(sortRules);
1653
+ }
1654
+ }
1655
+ else if (Array.isArray(value)) {
1656
+ queries[key] = value;
1478
1657
  }
1479
1658
  else if (!isNullable(value)) {
1480
1659
  queries[key] = value;
@@ -1507,25 +1686,83 @@ class PagedQueryStore {
1507
1686
  parseFlatArray(data) {
1508
1687
  return { content: data, totalElements: data.length };
1509
1688
  }
1689
+ resetDataset() {
1690
+ this.page = 0;
1691
+ this.totalElements = 0;
1692
+ this.#cache.clear();
1693
+ this.cached.set([]);
1694
+ this.items.set([]);
1695
+ this.error.set(null);
1696
+ this.bumpVersion();
1697
+ }
1698
+ bumpVersion() {
1699
+ this.version.update((current) => current + 1);
1700
+ }
1701
+ invalidateRequests() {
1702
+ this.#requestId++;
1703
+ }
1704
+ resolveConcurrency() {
1705
+ return this.config.concurrency || this.defaultConfig.defaultConcurrency || 'latest-wins';
1706
+ }
1707
+ shouldIgnoreRequestResult(requestId) {
1708
+ return this.resolveConcurrency() === 'latest-wins' && requestId !== this.#requestId;
1709
+ }
1510
1710
  applyConfig(config) {
1511
1711
  this.config = {
1512
1712
  ...config,
1713
+ baseUrl: config.baseUrl ?? this.defaultConfig.defaultBaseUrl,
1513
1714
  method: config.method || this.defaultConfig.defaultMethod || 'POST',
1514
1715
  presetQuery: config.presetQuery || this.defaultConfig.defaultQuery,
1515
1716
  parseRequest: config.parseRequest || this.defaultConfig.defaultParseRequest,
1717
+ sorting: config.sorting || this.defaultConfig.defaultSorting,
1718
+ concurrency: config.concurrency || this.defaultConfig.defaultConcurrency || 'latest-wins',
1516
1719
  debounceTime: config.debounceTime || 0,
1517
1720
  hasCache: config.hasCache === undefined ? this.defaultConfig.defaultHasCache : config.hasCache,
1518
- cacheSize: config.cacheSize || this.defaultConfig.defaultCacheSize || 5,
1721
+ cacheSize: config.cacheSize ?? this.defaultConfig.defaultCacheSize ?? 5,
1722
+ disableCacheLimit: config.disableCacheLimit === undefined
1723
+ ? (this.defaultConfig.defaultDisableCacheLimit ?? false)
1724
+ : config.disableCacheLimit,
1519
1725
  };
1520
1726
  if (this.#cache) {
1521
- this.#cache.limit = this.config.cacheSize;
1727
+ this.#cache.limit = this.resolveCacheLimit();
1522
1728
  }
1523
1729
  }
1730
+ resolveCacheLimit() {
1731
+ if (this.config.disableCacheLimit) {
1732
+ return PagedQueryStore.DISABLED_CACHE_LIMIT;
1733
+ }
1734
+ return this.config.cacheSize ?? this.defaultConfig.defaultCacheSize ?? 5;
1735
+ }
1524
1736
  applyPresetMeta() {
1525
1737
  this.filters = this.config.presetFilters || {};
1738
+ this.#sort.set(this.normalizeSort(this.config.presetSort));
1526
1739
  this.pageSize = this.config.presetQuery?.pageSize || 20;
1527
1740
  this.page = this.config.presetQuery?.page || 0;
1528
1741
  }
1742
+ normalizeSort(sort) {
1743
+ return normalizeSortInput(sort);
1744
+ }
1745
+ resolveSortQueryKey() {
1746
+ return this.config.sorting?.queryKey || 'sort';
1747
+ }
1748
+ sortRulesToTokens(sort) {
1749
+ if (this.config.sorting?.serialize) {
1750
+ return sort.map((item) => this.config.sorting.serialize(item));
1751
+ }
1752
+ return sortInputToTokens(sort);
1753
+ }
1754
+ reinitTransport() {
1755
+ try {
1756
+ this.invalidateRequests();
1757
+ this.#transport?.abortAll?.('PagedQueryStore transport reconfigured');
1758
+ }
1759
+ catch (e) {
1760
+ if (!isAbort(e)) {
1761
+ throw e;
1762
+ }
1763
+ }
1764
+ this.initTransport();
1765
+ }
1529
1766
  }
1530
1767
 
1531
1768
  var _a;
@@ -1549,6 +1786,7 @@ class DictStore {
1549
1786
  apiUrl;
1550
1787
  storageKey;
1551
1788
  static MAX_CACHE_SIZE = 400;
1789
+ static META_SUFFIX = ':meta';
1552
1790
  /**
1553
1791
  * Default dictionary configuration resolved from {@link STATUM_CONFIG}.
1554
1792
  *
@@ -1563,6 +1801,10 @@ class DictStore {
1563
1801
  valueKey;
1564
1802
  maxOptionsSize;
1565
1803
  storage;
1804
+ metaStorage;
1805
+ ttlMs;
1806
+ revalidate;
1807
+ cacheUpdatedAt = signal(null, ...(ngDevMode ? [{ debugName: "cacheUpdatedAt" }] : []));
1566
1808
  /**
1567
1809
  * Search text.
1568
1810
  * With `fixed: true` filters the local cache; with `fixed: false` triggers server search.
@@ -1601,7 +1843,7 @@ class DictStore {
1601
1843
  * @param storageKey key for saving cache in the selected strategy
1602
1844
  * @param cfg behavior (fixed/search, parsers, label/value keys, cache strategy, etc.)
1603
1845
  */
1604
- constructor(apiUrl, storageKey, { autoLoad = true, method = this.defaultConfig.defaultRestMethod, presetFilters = this.defaultConfig.defaultPresetFilters, parseResponse, parseRequest, debounceTime = this.defaultConfig.defaultDebounceTime, 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', }) {
1846
+ 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', }) {
1605
1847
  this.apiUrl = apiUrl;
1606
1848
  this.storageKey = storageKey;
1607
1849
  const searchDebounce = debounceTime ?? 300;
@@ -1615,15 +1857,19 @@ class DictStore {
1615
1857
  debounceTime: debounceTime,
1616
1858
  });
1617
1859
  this.fixed = fixed;
1860
+ this.ttlMs = ttlMs;
1861
+ this.revalidate = revalidate;
1618
1862
  this.labelKey = labelKey;
1619
1863
  this.valueKey = valueKey;
1620
1864
  this.maxOptionsSize = maxOptionsSize;
1621
1865
  if (cacheStrategy !== 'memory') {
1622
1866
  this.storage = storageStrategy(cacheStrategy);
1623
1867
  this.storage.prefix = keyPrefix;
1868
+ this.metaStorage = storageStrategy(cacheStrategy);
1869
+ this.metaStorage.prefix = keyPrefix;
1624
1870
  }
1625
- this.setAutoload(autoLoad);
1626
1871
  this.restoreFromStorage();
1872
+ this.setAutoload(autoLoad);
1627
1873
  this.#effectRefs.push(effect(() => {
1628
1874
  const incoming = this.#helper.items();
1629
1875
  if (incoming.length === 0) {
@@ -1642,9 +1888,12 @@ class DictStore {
1642
1888
  this._lastPromise = this.#helper.fetch({ filters: { name: query, ...rest } });
1643
1889
  });
1644
1890
  }
1645
- else if (!this.cachedItems().length) {
1891
+ else if (this.shouldFetchFixedCache()) {
1646
1892
  untracked(() => {
1647
- this._lastPromise = this.#helper.fetch({ filters: { name: '', ...rest } });
1893
+ this._lastPromise = this.#helper.fetch({ filters: { name: '', ...rest } }).then((items) => {
1894
+ items?.length && this.mergeIntoCache(items);
1895
+ return items;
1896
+ });
1648
1897
  });
1649
1898
  }
1650
1899
  }));
@@ -1653,6 +1902,13 @@ class DictStore {
1653
1902
  restoreCache() {
1654
1903
  this.restoreFromStorage();
1655
1904
  }
1905
+ clearCache() {
1906
+ this.#lru.clear();
1907
+ this.cachedItems.set([]);
1908
+ this.cacheUpdatedAt.set(null);
1909
+ this.storage?.remove(this.storageKey);
1910
+ this.metaStorage?.remove(this.metaKey());
1911
+ }
1656
1912
  /**
1657
1913
  * Set a search query and filters.
1658
1914
  * With `fixed: false` initiates server search; with `fixed: true` — local filtering.
@@ -1701,8 +1957,7 @@ class DictStore {
1701
1957
  }
1702
1958
  if (changed || opts?.replace) {
1703
1959
  const arr = this.#lru.toArray();
1704
- this.cachedItems.set(arr);
1705
- this.storage?.set(this.storageKey, arr.slice(0, _a.MAX_CACHE_SIZE));
1960
+ this.persistCache(arr);
1706
1961
  }
1707
1962
  };
1708
1963
  /** Release resources and stop internal effects. */
@@ -1727,8 +1982,7 @@ class DictStore {
1727
1982
  }
1728
1983
  if (changed > 0) {
1729
1984
  const arr = this.#lru.toArray();
1730
- this.cachedItems.set(arr);
1731
- this.storage?.set(this.storageKey, arr.slice(0, _a.MAX_CACHE_SIZE));
1985
+ this.persistCache(arr);
1732
1986
  }
1733
1987
  }
1734
1988
  restoreFromStorage() {
@@ -1742,19 +1996,29 @@ class DictStore {
1742
1996
  this.#lru.set(key, it);
1743
1997
  }
1744
1998
  this.cachedItems.set(this.#lru.toArray());
1999
+ this.cacheUpdatedAt.set(this.metaStorage?.get(this.metaKey())?.updatedAt ?? null);
1745
2000
  }
1746
2001
  catch {
1747
2002
  try {
1748
2003
  this.storage?.remove(this.storageKey);
2004
+ this.metaStorage?.remove(this.metaKey());
1749
2005
  }
1750
2006
  catch {
1751
2007
  /* noop */
1752
2008
  }
1753
2009
  }
1754
2010
  }
2011
+ persistCache(items) {
2012
+ const capped = items.slice(0, _a.MAX_CACHE_SIZE);
2013
+ const updatedAt = Date.now();
2014
+ this.cachedItems.set(items);
2015
+ this.cacheUpdatedAt.set(updatedAt);
2016
+ this.storage?.set(this.storageKey, capped);
2017
+ this.metaStorage?.set(this.metaKey(), { updatedAt });
2018
+ }
1755
2019
  filterLocal() {
1756
2020
  const list = this.cachedItems();
1757
- const query = (this.fixed ? this.searchText() : this.debouncedSearchText()).toLowerCase();
2021
+ const query = this.searchText().toLowerCase();
1758
2022
  if (!query) {
1759
2023
  return list;
1760
2024
  }
@@ -1777,13 +2041,38 @@ class DictStore {
1777
2041
  }
1778
2042
  setAutoload(autoLoad) {
1779
2043
  if (autoLoad === 'whenEmpty') {
1780
- const isEmpty = !this.storage?.get(this.storageKey)?.length;
1781
- this._armed.set(isEmpty);
2044
+ const isEmpty = !this.cachedItems().length;
2045
+ this._armed.set(isEmpty || this.shouldRevalidateCache());
1782
2046
  }
1783
2047
  else {
1784
2048
  this._armed.set(autoLoad);
1785
2049
  }
1786
2050
  }
2051
+ shouldFetchFixedCache() {
2052
+ return !this.cachedItems().length || this.shouldRevalidateCache();
2053
+ }
2054
+ shouldRevalidateCache() {
2055
+ return this.revalidate && this.isCacheStale();
2056
+ }
2057
+ isCacheStale() {
2058
+ if (!this.cachedItems().length) {
2059
+ return false;
2060
+ }
2061
+ if (this.ttlMs == null) {
2062
+ return false;
2063
+ }
2064
+ if (this.ttlMs <= 0) {
2065
+ return true;
2066
+ }
2067
+ const updatedAt = this.cacheUpdatedAt();
2068
+ if (updatedAt == null) {
2069
+ return true;
2070
+ }
2071
+ return Date.now() - updatedAt >= this.ttlMs;
2072
+ }
2073
+ metaKey() {
2074
+ return `${this.storageKey}${_a.META_SUFFIX}`;
2075
+ }
1787
2076
  }
1788
2077
  _a = DictStore;
1789
2078