@reforgium/statum 1.1.0 → 2.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
1
  import { formatDate, isNullable, isDatePeriod, parseToDate, makeQuery, isNumber, isObject, parseToDatePeriod, parseQueryArray, fillUrlWithParams, deepEqual, concatArray, debounceSignal } from '@reforgium/internal';
2
- import { InjectionToken, inject, signal, computed, DestroyRef, effect, untracked } from '@angular/core';
3
2
  import { HttpClient } from '@angular/common/http';
3
+ import { InjectionToken, inject, signal, computed, DestroyRef, 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
 
@@ -182,7 +182,7 @@ class Serializer {
182
182
  return;
183
183
  }
184
184
  if (fields?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
185
- const nullableVal = value || null;
185
+ const nullableVal = value ?? null;
186
186
  return this.config.mapNullable.format?.(nullableVal) || this.config.mapNullable.replaceWith || nullableVal;
187
187
  }
188
188
  if (fields?.type === 'string') {
@@ -247,8 +247,7 @@ class Serializer {
247
247
  if (field?.type === 'object') {
248
248
  try {
249
249
  if (this.config.mapObject.deep) {
250
- // @ts-ignore
251
- return this.deserializeElement(value);
250
+ return isObject(value) ? this.deserialize(value) : value;
252
251
  }
253
252
  else {
254
253
  // @ts-ignore
@@ -283,7 +282,11 @@ class Serializer {
283
282
  return this.config.mapNumber.parse?.(value) || Number(String(value).trim());
284
283
  }
285
284
  if (field?.type === 'string' || typeof value === 'string') {
286
- return this.config.mapString.parse?.(value) || this.config.mapString.trim ? String(value).trim() : value;
285
+ const parsed = this.config.mapString.parse?.(value);
286
+ if (parsed !== undefined) {
287
+ return parsed;
288
+ }
289
+ return this.config.mapString.trim ? String(value).trim() : value;
287
290
  }
288
291
  return value;
289
292
  }
@@ -311,8 +314,6 @@ class Serializer {
311
314
  }
312
315
  }
313
316
 
314
- const SERIALIZER_CONFIG = new InjectionToken('SERIALIZER_CONFIG');
315
-
316
317
  class LruCache {
317
318
  limit;
318
319
  map = new Map();
@@ -356,6 +357,9 @@ class LruCache {
356
357
  values() {
357
358
  return Array.from(this.map.values());
358
359
  }
360
+ entries() {
361
+ return Array.from(this.map.entries());
362
+ }
359
363
  toArray() {
360
364
  return Array.from(this.map.values());
361
365
  }
@@ -368,23 +372,31 @@ class LruCache {
368
372
  }
369
373
 
370
374
  class LocalStorage {
375
+ prefix;
376
+ constructor(prefix = 're') {
377
+ this.prefix = prefix;
378
+ }
371
379
  get length() {
372
- return localStorage.length;
380
+ return Object.keys(localStorage).filter((key) => key.startsWith(this.prefix || '')).length;
373
381
  }
374
382
  get(key) {
375
- const raw = localStorage.getItem(String(key));
383
+ const raw = localStorage.getItem(this.getSafePrefix(key));
376
384
  const parsed = JSON.parse(raw ?? 'null');
377
385
  return parsed ?? null;
378
386
  }
379
387
  set(key, value) {
380
388
  const str = JSON.stringify(value);
381
- localStorage.setItem(String(key), str);
389
+ localStorage.setItem(this.getSafePrefix(key), str);
382
390
  }
383
391
  remove(key) {
384
- return localStorage.removeItem(String(key));
392
+ return localStorage.removeItem(this.getSafePrefix(key));
385
393
  }
386
394
  clear() {
387
- return localStorage.clear();
395
+ const keys = Object.keys(localStorage).filter((key) => key.startsWith(this.prefix || ''));
396
+ keys.forEach((key) => localStorage.removeItem(key));
397
+ }
398
+ getSafePrefix(key) {
399
+ return this.prefix ? `${this.prefix}:${key}` : String(key);
388
400
  }
389
401
  }
390
402
 
@@ -408,23 +420,31 @@ class MemoryStorage {
408
420
  }
409
421
 
410
422
  class SessionStorage {
423
+ prefix;
424
+ constructor(prefix = 're') {
425
+ this.prefix = prefix;
426
+ }
411
427
  get length() {
412
- return sessionStorage.length;
428
+ return Object.keys(sessionStorage).filter((key) => key.startsWith(this.prefix || '')).length;
413
429
  }
414
430
  get(key) {
415
- const raw = sessionStorage.getItem(String(key));
431
+ const raw = sessionStorage.getItem(this.getSafePrefix(key));
416
432
  const parsed = JSON.parse(raw ?? 'null');
417
433
  return parsed ?? null;
418
434
  }
419
435
  set(key, value) {
420
436
  const str = JSON.stringify(value);
421
- sessionStorage.setItem(String(key), str);
437
+ sessionStorage.setItem(this.getSafePrefix(key), str);
422
438
  }
423
439
  remove(key) {
424
- return sessionStorage.removeItem(String(key));
440
+ return sessionStorage.removeItem(this.getSafePrefix(key));
425
441
  }
426
442
  clear() {
427
- return sessionStorage.clear();
443
+ const keys = Object.keys(sessionStorage).filter((key) => key.startsWith(this.prefix || ''));
444
+ keys.forEach((key) => sessionStorage.removeItem(key));
445
+ }
446
+ getSafePrefix(key) {
447
+ return this.prefix ? `${this.prefix}:${key}` : String(key);
428
448
  }
429
449
  }
430
450
 
@@ -453,6 +473,9 @@ const storageStrategy = (strategy) => {
453
473
  return fabrics[strategy]();
454
474
  };
455
475
 
476
+ // noinspection ES6PreferShortImport
477
+ const STATUM_CONFIG = new InjectionToken('RE_STATUM_CONFIG');
478
+
456
479
  /**
457
480
  * Error thrown when requested data is missing in the cache.
458
481
  *
@@ -502,13 +525,32 @@ function joinUrl(base, path) {
502
525
  return base ? `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}` : path;
503
526
  }
504
527
  function buildKey(method, path, args) {
505
- const params = Object.entries(args.params || {})
506
- .map(([key, value]) => `${key}=${value}`)
507
- .join('&');
508
- const query = Object.entries(args.query || {})
509
- .map(([key, value]) => `${key}=${value}`)
510
- .join('&');
511
- return `${method}|${path}|${params}|${query}`;
528
+ const params = stableStringify(args.params || {});
529
+ const query = stableStringify(args.query || {});
530
+ const payload = stableStringify(args.payload || {});
531
+ return `${method}|${path}|${params}|${query}|${payload}`;
532
+ }
533
+ function stableStringify(value) {
534
+ if (value === null || value === undefined) {
535
+ return '';
536
+ }
537
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
538
+ return String(value);
539
+ }
540
+ if (value instanceof Date) {
541
+ return value.toISOString();
542
+ }
543
+ if (Array.isArray(value)) {
544
+ return `[${value.map((entry) => stableStringify(entry)).join(',')}]`;
545
+ }
546
+ if (typeof value === 'object') {
547
+ const entries = Object.entries(value)
548
+ .filter(([, v]) => v !== undefined)
549
+ .sort(([a], [b]) => a.localeCompare(b))
550
+ .map(([k, v]) => `${k}:${stableStringify(v)}`);
551
+ return `{${entries.join(',')}}`;
552
+ }
553
+ return String(value);
512
554
  }
513
555
 
514
556
  /**
@@ -647,7 +689,7 @@ class KeyedScheduler {
647
689
  */
648
690
  class ResourceStore {
649
691
  http = inject(HttpClient);
650
- serializer = new Serializer(inject(SERIALIZER_CONFIG, { optional: true }) ?? {});
692
+ serializer = new Serializer(inject(STATUM_CONFIG, { optional: true })?.serializer ?? {});
651
693
  #value = signal(null, ...(ngDevMode ? [{ debugName: "#value" }] : []));
652
694
  #status = signal('idle', ...(ngDevMode ? [{ debugName: "#status" }] : []));
653
695
  #error = signal(null, ...(ngDevMode ? [{ debugName: "#error" }] : []));
@@ -696,6 +738,7 @@ class ResourceStore {
696
738
  const url = this.buildUrl('GET', args);
697
739
  const key = buildKey('GET', url, args);
698
740
  const query = this.prepareQuery(args);
741
+ const responseType = cfg.responseType ?? 'json';
699
742
  const entry = this.ensureEntry(key);
700
743
  const strategy = cfg.strategy ?? 'network-first';
701
744
  const ttlMs = cfg.ttlMs ?? this.opts.ttlMs ?? 0;
@@ -720,15 +763,15 @@ class ResourceStore {
720
763
  entry.status = isSWR ? 'stale' : 'loading';
721
764
  this.promoteCurrent(entry, cfg.promote);
722
765
  const task = this.scheduler.schedule(key, mode, delay, this.exec$({
723
- req$: this.http.get(url, { params: query }),
766
+ req$: this.http.get(url, { params: query, responseType: responseType }),
724
767
  entry,
725
768
  promote: cfg.promote,
726
769
  parseFn: cfg.parseResponse,
727
770
  }));
728
771
  cfg.dedupe && (entry.inflight = task);
729
772
  void task.catch((e) => {
730
- if (!isAbort(e)) {
731
- throw e;
773
+ if (isAbort(e)) {
774
+ return;
732
775
  }
733
776
  });
734
777
  return task;
@@ -807,6 +850,7 @@ class ResourceStore {
807
850
  const query = this.prepareQuery(args);
808
851
  const payload = { ...(this.opts.presetPayload || {}), ...(args.payload || {}) };
809
852
  const serializedPayload = this.serializer.serialize(payload);
853
+ const responseType = config.responseType ?? 'json';
810
854
  const entry = this.ensureEntry(key);
811
855
  if (config.dedupe && entry.inflight) {
812
856
  return entry.inflight;
@@ -817,17 +861,24 @@ class ResourceStore {
817
861
  this.promoteCurrent(entry, config.promote);
818
862
  let req$;
819
863
  if (method === 'DELETE') {
820
- req$ = this.http.delete(url, { body: serializedPayload, params: query });
864
+ req$ = this.http.delete(url, {
865
+ body: serializedPayload,
866
+ params: query,
867
+ responseType: responseType,
868
+ });
821
869
  }
822
870
  else {
823
871
  // @ts-ignore
824
- req$ = this.http[method.toLowerCase()](url, serializedPayload, { params: query });
872
+ req$ = this.http[method.toLowerCase()](url, serializedPayload, {
873
+ params: query,
874
+ responseType: responseType,
875
+ });
825
876
  }
826
877
  const task = this.scheduler.schedule(key, mode, delay, this.exec$({ req$, entry, promote: config.promote, parseFn: config.parseResponse }));
827
878
  config.dedupe && (entry.inflight = task);
828
879
  void task.catch((e) => {
829
- if (!isAbort(e)) {
830
- throw e;
880
+ if (isAbort(e)) {
881
+ return;
831
882
  }
832
883
  });
833
884
  return task;
@@ -875,8 +926,6 @@ class ResourceStore {
875
926
  }
876
927
  }
877
928
 
878
- const PDS_CONFIG = new InjectionToken('RE_PDS_CONFIG');
879
-
880
929
  // noinspection ES6PreferShortImport
881
930
  /**
882
931
  * Store for paginated data (tables/lists) with per-page cache and unified requests.
@@ -897,7 +946,7 @@ const PDS_CONFIG = new InjectionToken('RE_PDS_CONFIG');
897
946
  class PaginatedDataStore {
898
947
  route;
899
948
  config;
900
- defaultConfig = inject(PDS_CONFIG, { optional: true }) || {};
949
+ defaultConfig = inject(STATUM_CONFIG, { optional: true })?.paginatedData || {};
901
950
  #transport;
902
951
  #cache;
903
952
  /** Current page data (reactive). */
@@ -924,8 +973,8 @@ class PaginatedDataStore {
924
973
  constructor(route, config = {}) {
925
974
  this.route = route;
926
975
  this.config = config;
927
- this.#cache = new LruCache(config.cacheSize);
928
976
  this.applyConfig(config);
977
+ this.#cache = new LruCache(this.config.cacheSize);
929
978
  this.applyPresetMeta();
930
979
  this.initTransport();
931
980
  inject(DestroyRef).onDestroy(() => this.destroy());
@@ -1004,7 +1053,7 @@ class PaginatedDataStore {
1004
1053
  *
1005
1054
  * @param params Dictionary of route parameters (e.g., `{ id: '123' }`)
1006
1055
  * @param opts Options object
1007
- * @param opts.reset If `true` (default), resets page to 0, clears cache, total elements count, and items
1056
+ * @param opts.reset If `true`, resets page to 0, clears cache, total elements count, and items
1008
1057
  * @param opts.abort If `true`, aborts all active transport requests and sets loading to false
1009
1058
  *
1010
1059
  * @example
@@ -1044,6 +1093,7 @@ class PaginatedDataStore {
1044
1093
  */
1045
1094
  updateConfig = (config) => {
1046
1095
  this.config = { ...this.config, ...config };
1096
+ this.#cache.limit = this.config.cacheSize || this.defaultConfig.defaultCacheSize || 5;
1047
1097
  this.applyPresetMeta();
1048
1098
  };
1049
1099
  /**
@@ -1152,6 +1202,9 @@ class PaginatedDataStore {
1152
1202
  hasCache: config.hasCache === undefined ? this.defaultConfig.defaultHasCache : config.hasCache,
1153
1203
  cacheSize: config.cacheSize || this.defaultConfig.defaultCacheSize || 5,
1154
1204
  };
1205
+ if (this.#cache) {
1206
+ this.#cache.limit = this.config.cacheSize;
1207
+ }
1155
1208
  }
1156
1209
  applyPresetMeta() {
1157
1210
  this.filters = this.config.presetFilters || {};
@@ -1182,6 +1235,12 @@ class DictStore {
1182
1235
  apiUrl;
1183
1236
  storageKey;
1184
1237
  static MAX_CACHE_SIZE = 400;
1238
+ /**
1239
+ * Default dictionary configuration resolved from {@link STATUM_CONFIG}.
1240
+ *
1241
+ * Used as a global fallback when local configuration does not explicitly
1242
+ */
1243
+ defaultConfig = inject(STATUM_CONFIG, { optional: true })?.dict || {};
1185
1244
  #helper;
1186
1245
  #lru = new LruCache(_a.MAX_CACHE_SIZE);
1187
1246
  #effectRefs = [];
@@ -1195,7 +1254,7 @@ class DictStore {
1195
1254
  * With `fixed: true` filters the local cache; with `fixed: false` triggers server search.
1196
1255
  */
1197
1256
  searchText = signal('', ...(ngDevMode ? [{ debugName: "searchText" }] : []));
1198
- debouncedSearchText = debounceSignal(this.searchText, 300);
1257
+ debouncedSearchText;
1199
1258
  /**
1200
1259
  * Additional filters for server request (or presets).
1201
1260
  */
@@ -1228,9 +1287,11 @@ class DictStore {
1228
1287
  * @param storageKey key for saving cache in the selected strategy
1229
1288
  * @param cfg behavior (fixed/search, parsers, label/value keys, cache strategy, etc.)
1230
1289
  */
1231
- constructor(apiUrl, storageKey, { method, autoLoad = true, presetFilters, parseResponse, parseRequest, debounceTime, fixed = true, maxOptionsSize, labelKey = 'name', valueKey = 'code', cacheStrategy = 'persist', }) {
1290
+ 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', }) {
1232
1291
  this.apiUrl = apiUrl;
1233
1292
  this.storageKey = storageKey;
1293
+ const searchDebounce = debounceTime ?? 300;
1294
+ this.debouncedSearchText = debounceSignal(this.searchText, searchDebounce);
1234
1295
  this.#helper = new PaginatedDataStore(this.apiUrl, {
1235
1296
  method: method,
1236
1297
  hasCache: false,
@@ -1243,10 +1304,11 @@ class DictStore {
1243
1304
  this.labelKey = labelKey;
1244
1305
  this.valueKey = valueKey;
1245
1306
  this.maxOptionsSize = maxOptionsSize;
1246
- this._armed.set(autoLoad);
1247
1307
  if (cacheStrategy !== 'memory') {
1248
1308
  this.storage = storageStrategy(cacheStrategy);
1309
+ this.storage.prefix = keyPrefix;
1249
1310
  }
1311
+ this.setAutoload(autoLoad);
1250
1312
  this.restoreFromStorage();
1251
1313
  this.#effectRefs.push(effect(() => {
1252
1314
  const incoming = this.#helper.items();
@@ -1377,7 +1439,9 @@ class DictStore {
1377
1439
  const out = [];
1378
1440
  for (let i = 0; i < list.length; i++) {
1379
1441
  const val = list[i][key];
1380
- val.toLowerCase().includes(query) && out.push(list[i]);
1442
+ String(val ?? '')
1443
+ .toLowerCase()
1444
+ .includes(query) && out.push(list[i]);
1381
1445
  }
1382
1446
  return out;
1383
1447
  }
@@ -1388,15 +1452,41 @@ class DictStore {
1388
1452
  }
1389
1453
  return typeof raw === 'string' ? raw : String(raw);
1390
1454
  }
1455
+ setAutoload(autoLoad) {
1456
+ if (autoLoad === 'whenEmpty') {
1457
+ const isEmpty = !this.storage?.get(this.storageKey)?.length;
1458
+ this._armed.set(isEmpty);
1459
+ }
1460
+ else {
1461
+ this._armed.set(autoLoad);
1462
+ }
1463
+ }
1391
1464
  }
1392
1465
  _a = DictStore;
1393
1466
 
1394
1467
  class DictLocalStore {
1468
+ /**
1469
+ * Default dictionary configuration resolved from {@link STATUM_CONFIG}.
1470
+ *
1471
+ * Used as a global fallback when local configuration does not explicitly
1472
+ * define dictionary keys.
1473
+ */
1474
+ defaultConfig = inject(STATUM_CONFIG, { optional: true })?.dict || {};
1475
+ /**
1476
+ * Source dictionary items.
1477
+ *
1478
+ * Represents the full, unfiltered list of dictionary entries.
1479
+ * Used as the base data set for search and option generation.
1480
+ */
1395
1481
  items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
1396
1482
  #draftItems = signal([], ...(ngDevMode ? [{ debugName: "#draftItems" }] : []));
1397
1483
  /**
1398
- * Ready-to-use options for dropdowns: `{ label, value }`.
1399
- * Respects `maxOptionsSize` to truncate the list.
1484
+ * Computed list of options in `{ label, value }` format.
1485
+ *
1486
+ * Based on the current draft items (after search/filtering).
1487
+ * Respects `maxOptionsSize` to limit the number of generated options.
1488
+ *
1489
+ * Intended for direct use in dropdowns and select-like components.
1400
1490
  */
1401
1491
  options = computed(() => {
1402
1492
  const options = this.#draftItems().map((it) => ({
@@ -1409,19 +1499,30 @@ class DictLocalStore {
1409
1499
  valueKey;
1410
1500
  maxOptionsSize;
1411
1501
  constructor(items, config) {
1412
- this.labelKey = config?.labelKey ?? 'label';
1413
- this.valueKey = config?.valueKey ?? 'value';
1414
- this.maxOptionsSize = config?.maxOptionsSize ?? 1000;
1502
+ this.labelKey = config?.labelKey ?? (this.defaultConfig.defaultLabelKey || 'name');
1503
+ this.valueKey = config?.valueKey ?? (this.defaultConfig.defaultValueKey || 'code');
1504
+ this.maxOptionsSize = config?.maxOptionsSize ?? this.defaultConfig.defaultMaxOptionsSize ?? 1000;
1415
1505
  this.items.set(items);
1416
1506
  this.#draftItems.set(items);
1417
1507
  }
1508
+ // noinspection JSUnusedGlobalSymbols
1418
1509
  /**
1419
- * No-op method for API compatibility with DictStore.
1510
+ * Restore cached dictionary state.
1511
+ *
1512
+ * This implementation is a no-op and exists only for API compatibility
1513
+ * with {@link DictStore}.
1420
1514
  */
1421
1515
  restoreCache() { }
1422
1516
  /**
1423
- * Find display label by value (usually for reverse binding).
1424
- * @returns label string or `undefined` if not found
1517
+ * Resolve display label by value.
1518
+ *
1519
+ * Performs a linear search over dictionary items and returns
1520
+ * the corresponding label for the given value.
1521
+ *
1522
+ * Commonly used for reverse binding (value → label).
1523
+ *
1524
+ * @param value Dictionary value to look up
1525
+ * @returns Resolved label string, or `undefined` if not found
1425
1526
  */
1426
1527
  findLabel = (value) => {
1427
1528
  for (const item of this.items()) {
@@ -1431,6 +1532,16 @@ class DictLocalStore {
1431
1532
  }
1432
1533
  return undefined;
1433
1534
  };
1535
+ /**
1536
+ * Filter dictionary items by label.
1537
+ *
1538
+ * Applies a case-insensitive substring match against the label field
1539
+ * and updates the internal draft items used for option generation.
1540
+ *
1541
+ * Passing an empty string resets the filter and restores all items.
1542
+ *
1543
+ * @param name Search query string
1544
+ */
1434
1545
  search = (name = '') => {
1435
1546
  const items = this.items();
1436
1547
  if (name?.length) {
@@ -1440,6 +1551,18 @@ class DictLocalStore {
1440
1551
  this.#draftItems.set(items);
1441
1552
  }
1442
1553
  };
1554
+ /**
1555
+ * Preload additional dictionary items.
1556
+ *
1557
+ * Can either replace the current items completely or append
1558
+ * new entries to the existing list.
1559
+ *
1560
+ * Updates both the source items and the current draft items.
1561
+ *
1562
+ * @param items Items to preload
1563
+ * @param opts Preload options
1564
+ * @param opts.replace When `true`, replaces existing items instead of appending
1565
+ */
1443
1566
  preload = (items, opts) => {
1444
1567
  if (opts?.replace) {
1445
1568
  this.items.set(items);
@@ -1457,5 +1580,5 @@ class DictLocalStore {
1457
1580
  * Generated bundle index. Do not edit.
1458
1581
  */
1459
1582
 
1460
- export { AbortError, CacheMissError, DictLocalStore, DictStore, LruCache, PDS_CONFIG, PaginatedDataStore, ResourceStore, SERIALIZER_CONFIG, Serializer, isAbort, storageStrategy };
1583
+ export { AbortError, CacheMissError, DictLocalStore, DictStore, LruCache, PaginatedDataStore, ResourceStore, STATUM_CONFIG, Serializer, isAbort, storageStrategy };
1461
1584
  //# sourceMappingURL=reforgium-statum.mjs.map