@reforgium/statum 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,19 @@ const serializeNumber = (config, value) => config.mapNumber.format?.(value) ?? N
9
9
  const serializeBoolean = (config, value) => config.mapBoolean.format?.(value) ?? (value ? (config.mapBoolean.true ?? true) : (config.mapBoolean.false ?? false));
10
10
  const serializeDate = (config, value) => config.mapDate.format?.(value) ?? formatDate(value, config.mapDate.dateFormat);
11
11
 
12
+ class SerializerFieldError extends Error {
13
+ field;
14
+ stage;
15
+ originalError;
16
+ constructor(field, stage, originalError) {
17
+ const originalMessage = originalError instanceof Error ? originalError.message : String(originalError);
18
+ super(`Serializer ${stage} error for field "${field}": ${originalMessage}`);
19
+ this.field = field;
20
+ this.stage = stage;
21
+ this.originalError = originalError;
22
+ this.name = 'SerializerFieldError';
23
+ }
24
+ }
12
25
  /**
13
26
  * Universal serializer/deserializer for values used in forms, filters, and DTOs.
14
27
  *
@@ -47,8 +60,8 @@ class Serializer {
47
60
  *
48
61
  * @param config partial transformation configuration
49
62
  */
50
- constructor(config) {
51
- this.config = {
63
+ constructor(config = {}) {
64
+ this.config = this.mergeConfig({
52
65
  mapString: { trim: true },
53
66
  mapNumber: { fromString: false },
54
67
  mapBoolean: {},
@@ -60,8 +73,7 @@ class Serializer {
60
73
  transformMode: { mode: 'split', dateFromKeyPostfix: 'From', dateToKeyPostfix: 'To' },
61
74
  },
62
75
  mapNullable: { remove: true, includeEmptyString: false },
63
- ...config,
64
- };
76
+ }, config);
65
77
  }
66
78
  /**
67
79
  * Converts a domain object into a flat serialized representation
@@ -83,7 +95,12 @@ class Serializer {
83
95
  for (const [key, value] of Object.entries(obj ?? {})) {
84
96
  const fields = this.config.mapFields?.[key];
85
97
  if (fields && 'format' in fields) {
86
- result[key] = fields.format(value, obj);
98
+ try {
99
+ result[key] = fields.format(value, obj);
100
+ }
101
+ catch (error) {
102
+ throw new SerializerFieldError(key, 'format', error);
103
+ }
87
104
  continue;
88
105
  }
89
106
  if (fields?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
@@ -118,12 +135,17 @@ class Serializer {
118
135
  * @returns a domain object of the specified type
119
136
  */
120
137
  deserialize = (val) => {
121
- const data = typeof val === 'string' ? this.parseQuery(val) : val;
138
+ const data = typeof val === 'string' ? this.parseInputString(val) : val;
122
139
  const result = {};
123
140
  for (const [key, value] of Object.entries(data ?? {})) {
124
141
  const field = this.config.mapFields?.[key];
125
142
  if (field && 'parse' in field) {
126
- result[key] = field.parse(value, data);
143
+ try {
144
+ result[key] = field.parse(value, data);
145
+ }
146
+ catch (error) {
147
+ throw new SerializerFieldError(key, 'parse', error);
148
+ }
127
149
  continue;
128
150
  }
129
151
  if (field?.type === 'nullable' ||
@@ -156,6 +178,14 @@ class Serializer {
156
178
  }
157
179
  return result;
158
180
  };
181
+ /** Parse only query-string input. */
182
+ deserializeQuery = (query) => {
183
+ return this.deserialize(this.parseQuery(query));
184
+ };
185
+ /** Parse only JSON object input. */
186
+ deserializeJson = (json) => {
187
+ return this.deserialize(this.parseJsonObject(json));
188
+ };
159
189
  /**
160
190
  * Build a query string from a domain object using `serialize` rules
161
191
  * and the array joining strategy (`concatType`).
@@ -174,7 +204,7 @@ class Serializer {
174
204
  * @returns new `Serializer` with the provided `config` applied
175
205
  */
176
206
  withConfig(config) {
177
- return new Serializer({ ...this.config, ...config });
207
+ return new Serializer(this.mergeConfig(this.config, config));
178
208
  }
179
209
  serializeElement(value, key) {
180
210
  const fields = this.config.mapFields?.[key || ''];
@@ -182,7 +212,7 @@ class Serializer {
182
212
  return;
183
213
  }
184
214
  if (fields?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
185
- const nullableVal = value || null;
215
+ const nullableVal = value ?? null;
186
216
  return this.config.mapNullable.format?.(nullableVal) || this.config.mapNullable.replaceWith || nullableVal;
187
217
  }
188
218
  if (fields?.type === 'string') {
@@ -247,8 +277,7 @@ class Serializer {
247
277
  if (field?.type === 'object') {
248
278
  try {
249
279
  if (this.config.mapObject.deep) {
250
- // @ts-ignore
251
- return this.deserializeElement(value);
280
+ return isObject(value) ? this.deserialize(value) : value;
252
281
  }
253
282
  else {
254
283
  // @ts-ignore
@@ -283,40 +312,112 @@ class Serializer {
283
312
  return this.config.mapNumber.parse?.(value) || Number(String(value).trim());
284
313
  }
285
314
  if (field?.type === 'string' || typeof value === 'string') {
286
- return this.config.mapString.parse?.(value) || this.config.mapString.trim ? String(value).trim() : value;
315
+ const parsed = this.config.mapString.parse?.(value);
316
+ if (parsed !== undefined) {
317
+ return parsed;
318
+ }
319
+ return this.config.mapString.trim ? String(value).trim() : value;
287
320
  }
288
321
  return value;
289
322
  }
290
323
  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;
339
+ }
340
+ parseJsonObject(val) {
341
+ try {
342
+ const parsed = JSON.parse(val);
343
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
344
+ return parsed;
345
+ }
346
+ }
347
+ catch (error) {
348
+ throw new Error(`Invalid JSON input: ${error instanceof Error ? error.message : String(error)}`);
349
+ }
350
+ throw new Error('Invalid JSON input: expected a JSON object');
351
+ }
352
+ parseInputString(val) {
291
353
  try {
292
- return JSON.parse(val);
354
+ return this.parseJsonObject(val);
293
355
  }
294
356
  catch {
295
- const params = new URLSearchParams(val);
296
- const data = {};
297
- params.forEach((value, key) => {
298
- const field = this.config.mapFields?.[key] || {};
299
- const concatType = this.config.mapArray.concatType;
300
- if ('type' in field && field.type === 'array') {
301
- data[key] ??= [];
302
- concatType !== 'multi' && (data[key] = parseQueryArray(value, concatType, key));
303
- concatType === 'multi' && data[key].push(value);
304
- }
305
- else {
306
- data[key] = value;
307
- }
308
- });
309
- return data;
357
+ return this.parseQuery(val);
310
358
  }
311
359
  }
360
+ mergeConfig(base, override) {
361
+ return {
362
+ ...base,
363
+ ...override,
364
+ mapString: { ...base.mapString, ...override.mapString },
365
+ mapNumber: { ...base.mapNumber, ...override.mapNumber },
366
+ mapBoolean: { ...base.mapBoolean, ...override.mapBoolean },
367
+ mapDate: { ...base.mapDate, ...override.mapDate },
368
+ mapPeriod: {
369
+ ...base.mapPeriod,
370
+ ...override.mapPeriod,
371
+ transformMode: {
372
+ ...base.mapPeriod.transformMode,
373
+ ...override.mapPeriod?.transformMode,
374
+ },
375
+ },
376
+ mapNullable: { ...base.mapNullable, ...override.mapNullable },
377
+ mapArray: { ...base.mapArray, ...override.mapArray },
378
+ mapObject: { ...base.mapObject, ...override.mapObject },
379
+ mapFields: { ...(base.mapFields || {}), ...(override.mapFields || {}) },
380
+ };
381
+ }
312
382
  }
313
383
 
384
+ const createQuerySerializer = (config = {}) => {
385
+ return new Serializer({
386
+ mapArray: { concatType: 'comma' },
387
+ mapNullable: { remove: true, includeEmptyString: false },
388
+ mapObject: { deep: false },
389
+ ...config,
390
+ });
391
+ };
392
+ const createBodySerializer = (config = {}) => {
393
+ return new Serializer({
394
+ mapArray: { concatType: 'json' },
395
+ mapNullable: { remove: false },
396
+ mapObject: { deep: true },
397
+ ...config,
398
+ });
399
+ };
400
+ const createStrictSerializer = (config = {}) => {
401
+ return new Serializer({
402
+ mapString: { trim: true },
403
+ mapNumber: { fromString: true },
404
+ mapNullable: { remove: true, includeEmptyString: true },
405
+ ...config,
406
+ });
407
+ };
408
+
314
409
  class LruCache {
315
- limit;
316
410
  map = new Map();
317
411
  constructor(limit = 100) {
318
412
  this.limit = limit;
319
413
  }
414
+ _limit = 100;
415
+ get limit() {
416
+ return this._limit;
417
+ }
418
+ set limit(value) {
419
+ this._limit = Math.max(1, Math.floor(value || 0));
420
+ }
320
421
  get length() {
321
422
  return this.map.size;
322
423
  }
@@ -333,7 +434,7 @@ class LruCache {
333
434
  if (this.map.has(key)) {
334
435
  this.map.delete(key);
335
436
  }
336
- else if (this.map.size === this.limit) {
437
+ else if (this.map.size >= this.limit) {
337
438
  const oldest = this.map.keys().next().value;
338
439
  oldest !== undefined && this.map.delete(oldest);
339
440
  }
@@ -354,6 +455,9 @@ class LruCache {
354
455
  values() {
355
456
  return Array.from(this.map.values());
356
457
  }
458
+ entries() {
459
+ return Array.from(this.map.entries());
460
+ }
357
461
  toArray() {
358
462
  return Array.from(this.map.values());
359
463
  }
@@ -371,12 +475,22 @@ class LocalStorage {
371
475
  this.prefix = prefix;
372
476
  }
373
477
  get length() {
374
- return Object.keys(localStorage).filter((key) => key.startsWith(this.prefix || '')).length;
478
+ return Object.keys(localStorage).filter((key) => key.startsWith(this.safePrefix())).length;
375
479
  }
376
480
  get(key) {
377
- const raw = localStorage.getItem(this.getSafePrefix(key));
378
- const parsed = JSON.parse(raw ?? 'null');
379
- return parsed ?? null;
481
+ const storageKey = this.getSafePrefix(key);
482
+ const raw = localStorage.getItem(storageKey);
483
+ if (raw == null) {
484
+ return null;
485
+ }
486
+ try {
487
+ const parsed = JSON.parse(raw);
488
+ return parsed ?? null;
489
+ }
490
+ catch {
491
+ localStorage.removeItem(storageKey);
492
+ return null;
493
+ }
380
494
  }
381
495
  set(key, value) {
382
496
  const str = JSON.stringify(value);
@@ -386,12 +500,15 @@ class LocalStorage {
386
500
  return localStorage.removeItem(this.getSafePrefix(key));
387
501
  }
388
502
  clear() {
389
- const keys = Object.keys(localStorage).filter((key) => key.startsWith(this.prefix || ''));
503
+ const keys = Object.keys(localStorage).filter((key) => key.startsWith(this.safePrefix()));
390
504
  keys.forEach((key) => localStorage.removeItem(key));
391
505
  }
392
506
  getSafePrefix(key) {
393
507
  return this.prefix ? `${this.prefix}:${key}` : String(key);
394
508
  }
509
+ safePrefix() {
510
+ return this.prefix ? `${this.prefix}:` : '';
511
+ }
395
512
  }
396
513
 
397
514
  class MemoryStorage {
@@ -419,12 +536,22 @@ class SessionStorage {
419
536
  this.prefix = prefix;
420
537
  }
421
538
  get length() {
422
- return Object.keys(sessionStorage).filter((key) => key.startsWith(this.prefix || '')).length;
539
+ return Object.keys(sessionStorage).filter((key) => key.startsWith(this.safePrefix())).length;
423
540
  }
424
541
  get(key) {
425
- const raw = sessionStorage.getItem(this.getSafePrefix(key));
426
- const parsed = JSON.parse(raw ?? 'null');
427
- return parsed ?? null;
542
+ const storageKey = this.getSafePrefix(key);
543
+ const raw = sessionStorage.getItem(storageKey);
544
+ if (raw == null) {
545
+ return null;
546
+ }
547
+ try {
548
+ const parsed = JSON.parse(raw);
549
+ return parsed ?? null;
550
+ }
551
+ catch {
552
+ sessionStorage.removeItem(storageKey);
553
+ return null;
554
+ }
428
555
  }
429
556
  set(key, value) {
430
557
  const str = JSON.stringify(value);
@@ -434,12 +561,15 @@ class SessionStorage {
434
561
  return sessionStorage.removeItem(this.getSafePrefix(key));
435
562
  }
436
563
  clear() {
437
- const keys = Object.keys(sessionStorage).filter((key) => key.startsWith(this.prefix || ''));
564
+ const keys = Object.keys(sessionStorage).filter((key) => key.startsWith(this.safePrefix()));
438
565
  keys.forEach((key) => sessionStorage.removeItem(key));
439
566
  }
440
567
  getSafePrefix(key) {
441
568
  return this.prefix ? `${this.prefix}:${key}` : String(key);
442
569
  }
570
+ safePrefix() {
571
+ return this.prefix ? `${this.prefix}:` : '';
572
+ }
443
573
  }
444
574
 
445
575
  /**
@@ -457,12 +587,12 @@ class SessionStorage {
457
587
  * @param strategy storage strategy type (`memory`, `session`, `persist`, `lru`)
458
588
  * @returns instance implementing `StorageInterface<Key, Type>`
459
589
  */
460
- const storageStrategy = (strategy) => {
590
+ const storageStrategy = (strategy, options = {}) => {
461
591
  const fabrics = {
462
592
  memory: () => new MemoryStorage(),
463
593
  session: () => new SessionStorage(),
464
594
  persist: () => new LocalStorage(),
465
- lru: () => new LruCache(),
595
+ lru: () => new LruCache(options.lruLimit ?? 100),
466
596
  };
467
597
  return fabrics[strategy]();
468
598
  };
@@ -519,13 +649,32 @@ function joinUrl(base, path) {
519
649
  return base ? `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}` : path;
520
650
  }
521
651
  function buildKey(method, path, args) {
522
- const params = Object.entries(args.params || {})
523
- .map(([key, value]) => `${key}=${value}`)
524
- .join('&');
525
- const query = Object.entries(args.query || {})
526
- .map(([key, value]) => `${key}=${value}`)
527
- .join('&');
528
- return `${method}|${path}|${params}|${query}`;
652
+ const params = stableStringify(args.params || {});
653
+ const query = stableStringify(args.query || {});
654
+ const payload = stableStringify(args.payload || {});
655
+ return `${method}|${path}|${params}|${query}|${payload}`;
656
+ }
657
+ function stableStringify(value) {
658
+ if (value === null || value === undefined) {
659
+ return '';
660
+ }
661
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
662
+ return String(value);
663
+ }
664
+ if (value instanceof Date) {
665
+ return value.toISOString();
666
+ }
667
+ if (Array.isArray(value)) {
668
+ return `[${value.map((entry) => stableStringify(entry)).join(',')}]`;
669
+ }
670
+ if (typeof value === 'object') {
671
+ const entries = Object.entries(value)
672
+ .filter(([, v]) => v !== undefined)
673
+ .sort(([a], [b]) => a.localeCompare(b))
674
+ .map(([k, v]) => `${k}:${stableStringify(v)}`);
675
+ return `{${entries.join(',')}}`;
676
+ }
677
+ return String(value);
529
678
  }
530
679
 
531
680
  /**
@@ -568,6 +717,10 @@ class KeyedScheduler {
568
717
  const promise = new Promise((resolve, reject) => {
569
718
  channel.waiters.push({ resolve, reject });
570
719
  });
720
+ // The scheduler can reject synchronously from `cancel()`.
721
+ // Mark the promise as handled to avoid host-level unhandled rejection noise
722
+ // when consumer handlers are attached on chained promises.
723
+ void promise.catch(() => undefined);
571
724
  channel.lastExec = exec;
572
725
  if (delay <= 0) {
573
726
  this.startExecution(channel);
@@ -589,13 +742,9 @@ class KeyedScheduler {
589
742
  if (!ch) {
590
743
  return;
591
744
  }
592
- ch.timerSub?.unsubscribe();
593
- ch.inflight?.unsubscribe();
594
- ch.inflight = undefined;
595
- ch.subject.complete();
596
745
  const waiters = ch.waiters.splice(0);
597
746
  waiters.forEach((w) => w.reject(reason));
598
- this.channels.delete(key);
747
+ this.cleanupChannel(ch);
599
748
  }
600
749
  /**
601
750
  * Cancels all channels and their pending tasks.
@@ -613,7 +762,7 @@ class KeyedScheduler {
613
762
  return ch;
614
763
  }
615
764
  const subject = new Subject();
616
- ch = { subject, waiters: [] };
765
+ ch = { key, subject, waiters: [] };
617
766
  this.channels.set(key, ch);
618
767
  const debounced$ = subject.pipe(filter((t) => t.mode === 'debounce'), debounce((t) => timer(t.delay)), tap(() => this.startExecution(ch)));
619
768
  const throttled$ = subject.pipe(filter((t) => t.mode === 'throttle'), throttle((t) => timer(t.delay), { leading: false, trailing: true }), tap(() => this.startExecution(ch)));
@@ -636,10 +785,31 @@ class KeyedScheduler {
636
785
  error: (err) => {
637
786
  const waiters = ch.waiters.splice(0);
638
787
  waiters.forEach((w) => w.reject(err));
788
+ ch.inflight = undefined;
789
+ this.cleanupIfIdle(ch);
790
+ },
791
+ complete: () => {
792
+ ch.inflight = undefined;
793
+ this.cleanupIfIdle(ch);
639
794
  },
640
- complete: () => (ch.inflight = undefined),
641
795
  });
642
796
  }
797
+ cleanupIfIdle(ch) {
798
+ if (ch.waiters.length > 0 || ch.inflight) {
799
+ return;
800
+ }
801
+ this.cleanupChannel(ch);
802
+ }
803
+ cleanupChannel(ch) {
804
+ if (this.channels.get(ch.key) !== ch) {
805
+ return;
806
+ }
807
+ ch.timerSub?.unsubscribe();
808
+ ch.inflight?.unsubscribe();
809
+ ch.inflight = undefined;
810
+ ch.subject.complete();
811
+ this.channels.delete(ch.key);
812
+ }
643
813
  }
644
814
 
645
815
  /**
@@ -689,6 +859,7 @@ class ResourceStore {
689
859
  loading = computed(() => this.#activeRequests() > 0 || this.#status() === 'stale', ...(ngDevMode ? [{ debugName: "loading" }] : []));
690
860
  routes;
691
861
  opts;
862
+ maxEntries;
692
863
  entries = new Map();
693
864
  scheduler = new KeyedScheduler();
694
865
  /**
@@ -698,6 +869,7 @@ class ResourceStore {
698
869
  constructor(routes, opts = {}) {
699
870
  this.routes = routes;
700
871
  this.opts = opts;
872
+ this.maxEntries = Math.max(1, opts.maxEntries ?? 1000);
701
873
  }
702
874
  /**
703
875
  * Perform a GET request.
@@ -713,6 +885,7 @@ class ResourceStore {
713
885
  const url = this.buildUrl('GET', args);
714
886
  const key = buildKey('GET', url, args);
715
887
  const query = this.prepareQuery(args);
888
+ const responseType = cfg.responseType ?? 'json';
716
889
  const entry = this.ensureEntry(key);
717
890
  const strategy = cfg.strategy ?? 'network-first';
718
891
  const ttlMs = cfg.ttlMs ?? this.opts.ttlMs ?? 0;
@@ -722,12 +895,15 @@ class ResourceStore {
722
895
  }
723
896
  if (strategy === 'cache-only') {
724
897
  if (fresh && entry.data != null) {
898
+ this.trace({ type: 'cache-hit', method: 'GET', key, strategy });
725
899
  this.promoteCurrent(entry, cfg.promote);
726
900
  return entry.data;
727
901
  }
902
+ this.trace({ type: 'cache-miss', method: 'GET', key, strategy });
728
903
  throw new CacheMissError(key);
729
904
  }
730
905
  if (strategy === 'cache-first' && fresh && !cfg.revalidate && entry.data != null) {
906
+ this.trace({ type: 'cache-hit', method: 'GET', key, strategy });
731
907
  this.promoteCurrent(entry, cfg.promote);
732
908
  return entry.data;
733
909
  }
@@ -736,19 +912,56 @@ class ResourceStore {
736
912
  const isSWR = strategy === 'cache-first' && entry.data != null;
737
913
  entry.status = isSWR ? 'stale' : 'loading';
738
914
  this.promoteCurrent(entry, cfg.promote);
739
- const task = this.scheduler.schedule(key, mode, delay, this.exec$({
740
- req$: this.http.get(url, { params: query }),
741
- entry,
742
- promote: cfg.promote,
743
- parseFn: cfg.parseResponse,
744
- }));
745
- cfg.dedupe && (entry.inflight = task);
746
- void task.catch((e) => {
747
- if (!isAbort(e)) {
748
- throw e;
915
+ const retry = this.resolveRetryConfig(cfg.retry);
916
+ const taskWithRetry = this.runWithRetry((attempt) => {
917
+ this.trace({ type: 'request-start', method: 'GET', key, attempt });
918
+ return this.scheduler.schedule(key, mode, delay, this.exec$({
919
+ req$: this.http.get(url, { params: query, responseType: responseType }),
920
+ entry,
921
+ promote: cfg.promote,
922
+ parseFn: cfg.parseResponse,
923
+ }));
924
+ }, retry, {
925
+ method: 'GET',
926
+ key,
927
+ });
928
+ const resolvedTask = taskWithRetry
929
+ .catch((error) => {
930
+ if (isAbort(error)) {
931
+ throw error;
749
932
  }
933
+ if (strategy === 'network-first' && entry.data != null) {
934
+ entry.status = 'success';
935
+ entry.error = null;
936
+ this.promoteCurrent(entry, cfg.promote);
937
+ this.trace({ type: 'cache-fallback', method: 'GET', key, strategy });
938
+ return entry.data;
939
+ }
940
+ throw error;
941
+ })
942
+ .then((data) => {
943
+ this.trace({ type: 'cache-write', method: 'GET', key, strategy });
944
+ this.trace({ type: 'request-success', method: 'GET', key });
945
+ return data;
946
+ })
947
+ .catch((error) => {
948
+ if (!isAbort(error)) {
949
+ this.trace({ type: 'request-error', method: 'GET', key, error });
950
+ }
951
+ throw error;
750
952
  });
751
- return task;
953
+ if (cfg.dedupe) {
954
+ entry.inflight = resolvedTask;
955
+ void resolvedTask.finally(() => {
956
+ entry.inflight === resolvedTask && (entry.inflight = undefined);
957
+ });
958
+ }
959
+ void resolvedTask.catch((error) => {
960
+ if (isAbort(error)) {
961
+ return;
962
+ }
963
+ });
964
+ return resolvedTask;
752
965
  }
753
966
  /**
754
967
  * POST request.
@@ -800,6 +1013,7 @@ class ResourceStore {
800
1013
  abort(method, args, reason) {
801
1014
  const url = this.buildUrl(method, args);
802
1015
  const key = buildKey(method, url, args);
1016
+ this.trace({ type: 'abort', method, key });
803
1017
  this.scheduler.cancel?.(key, reason);
804
1018
  }
805
1019
  /**
@@ -808,43 +1022,96 @@ class ResourceStore {
808
1022
  * @param reason Cancellation reason (optional)
809
1023
  */
810
1024
  abortAll(reason) {
1025
+ this.trace({ type: 'abort', method: 'GET', key: '*' });
811
1026
  this.scheduler.cancelAll?.(reason);
812
1027
  }
813
1028
  ensureEntry(key) {
814
1029
  let entry = this.entries.get(key);
815
1030
  if (!entry) {
1031
+ this.evictEntriesIfNeeded();
816
1032
  entry = { data: null, status: 'idle', error: null, updatedAt: null };
817
1033
  this.entries.set(key, entry);
818
1034
  }
1035
+ else {
1036
+ // Keep hot keys at the end to approximate LRU order.
1037
+ this.entries.delete(key);
1038
+ this.entries.set(key, entry);
1039
+ }
819
1040
  return entry;
820
1041
  }
1042
+ evictEntriesIfNeeded() {
1043
+ while (this.entries.size >= this.maxEntries) {
1044
+ let keyToDelete = null;
1045
+ for (const [key, value] of this.entries.entries()) {
1046
+ if (!value.inflight) {
1047
+ keyToDelete = key;
1048
+ break;
1049
+ }
1050
+ }
1051
+ if (keyToDelete === null) {
1052
+ keyToDelete = this.entries.keys().next().value ?? null;
1053
+ }
1054
+ if (keyToDelete === null) {
1055
+ break;
1056
+ }
1057
+ this.entries.delete(keyToDelete);
1058
+ }
1059
+ }
821
1060
  async callApi(method, args, config = {}) {
822
1061
  const url = this.buildUrl(method, args);
823
1062
  const key = buildKey(method, url, args);
824
1063
  const query = this.prepareQuery(args);
825
1064
  const payload = { ...(this.opts.presetPayload || {}), ...(args.payload || {}) };
826
1065
  const serializedPayload = this.serializer.serialize(payload);
1066
+ const responseType = config.responseType ?? 'json';
827
1067
  const entry = this.ensureEntry(key);
828
1068
  if (config.dedupe && entry.inflight) {
829
1069
  return entry.inflight;
830
1070
  }
831
1071
  const delay = config.delay ?? this.opts.delay ?? 0;
832
1072
  const mode = config.delayMode ?? this.opts.delayMode ?? 'debounce';
1073
+ const retry = this.resolveRetryConfig(config.retry);
833
1074
  entry.status = 'loading';
834
1075
  this.promoteCurrent(entry, config.promote);
835
1076
  let req$;
836
1077
  if (method === 'DELETE') {
837
- req$ = this.http.delete(url, { body: serializedPayload, params: query });
1078
+ req$ = this.http.delete(url, {
1079
+ body: serializedPayload,
1080
+ params: query,
1081
+ responseType: responseType,
1082
+ });
838
1083
  }
839
1084
  else {
840
1085
  // @ts-ignore
841
- req$ = this.http[method.toLowerCase()](url, serializedPayload, { params: query });
1086
+ req$ = this.http[method.toLowerCase()](url, serializedPayload, {
1087
+ params: query,
1088
+ responseType: responseType,
1089
+ });
842
1090
  }
843
- const task = this.scheduler.schedule(key, mode, delay, this.exec$({ req$, entry, promote: config.promote, parseFn: config.parseResponse }));
844
- config.dedupe && (entry.inflight = task);
845
- void task.catch((e) => {
846
- if (!isAbort(e)) {
847
- throw e;
1091
+ const task = this.runWithRetry((attempt) => {
1092
+ this.trace({ type: 'request-start', method, key, attempt });
1093
+ return this.scheduler.schedule(key, mode, delay, this.exec$({ req$, entry, promote: config.promote, parseFn: config.parseResponse }));
1094
+ }, retry, { method, key })
1095
+ .then((data) => {
1096
+ this.trace({ type: 'cache-write', method, key });
1097
+ this.trace({ type: 'request-success', method, key });
1098
+ return data;
1099
+ })
1100
+ .catch((error) => {
1101
+ if (!isAbort(error)) {
1102
+ this.trace({ type: 'request-error', method, key, error });
1103
+ }
1104
+ throw error;
1105
+ });
1106
+ if (config.dedupe) {
1107
+ entry.inflight = task;
1108
+ void task.finally(() => {
1109
+ entry.inflight === task && (entry.inflight = undefined);
1110
+ });
1111
+ }
1112
+ void task.catch((error) => {
1113
+ if (isAbort(error)) {
1114
+ return;
848
1115
  }
849
1116
  });
850
1117
  return task;
@@ -861,11 +1128,59 @@ class ResourceStore {
861
1128
  const mergedQuery = { ...(this.opts.presetQueries || {}), ...(args.query || {}) };
862
1129
  return this.serializer.serialize(mergedQuery);
863
1130
  }
1131
+ trace(event) {
1132
+ try {
1133
+ this.opts.onTrace?.(event);
1134
+ }
1135
+ catch {
1136
+ /* noop */
1137
+ }
1138
+ }
1139
+ resolveRetryConfig(override) {
1140
+ const source = { ...(this.opts.retry || {}), ...(override || {}) };
1141
+ return {
1142
+ attempts: Math.max(0, source.attempts ?? 0),
1143
+ delayMs: Math.max(0, source.delayMs ?? 0),
1144
+ backoff: source.backoff ?? 'constant',
1145
+ shouldRetry: source.shouldRetry ?? this.defaultShouldRetry,
1146
+ };
1147
+ }
1148
+ defaultShouldRetry(error) {
1149
+ if (isAbort(error)) {
1150
+ return false;
1151
+ }
1152
+ const maybe = error;
1153
+ const status = maybe?.status;
1154
+ if (status == null) {
1155
+ return true;
1156
+ }
1157
+ return status === 0 || status >= 500;
1158
+ }
1159
+ async runWithRetry(exec, retry, context) {
1160
+ let attempt = 1;
1161
+ while (true) {
1162
+ try {
1163
+ return await exec(attempt);
1164
+ }
1165
+ catch (error) {
1166
+ const canRetry = attempt <= retry.attempts && retry.shouldRetry(error, attempt);
1167
+ if (!canRetry) {
1168
+ throw error;
1169
+ }
1170
+ const delayMs = retry.backoff === 'exponential' ? retry.delayMs * 2 ** (attempt - 1) : retry.delayMs;
1171
+ this.trace({ type: 'request-retry', method: context.method, key: context.key, attempt, error });
1172
+ attempt++;
1173
+ if (delayMs > 0) {
1174
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1175
+ }
1176
+ }
1177
+ }
1178
+ }
864
1179
  exec$ = ({ req$, entry, promote = true, parseFn }) => () => {
865
1180
  promote && this.#activeRequests.update((n) => n + 1);
866
1181
  return req$.pipe(map((data) => (parseFn ? parseFn(data) : data)), tap({
867
1182
  next: (data) => {
868
- promote && (entry.data = data);
1183
+ entry.data = data;
869
1184
  entry.status = 'success';
870
1185
  entry.updatedAt = Date.now();
871
1186
  entry.error = null;
@@ -877,7 +1192,6 @@ class ResourceStore {
877
1192
  this.promoteCurrent(entry, promote);
878
1193
  },
879
1194
  }), finalize(() => {
880
- entry.inflight = undefined;
881
1195
  promote && this.#activeRequests.update((n) => Math.max(0, n - 1));
882
1196
  this.promoteCurrent(entry, promote);
883
1197
  }));
@@ -939,8 +1253,8 @@ class PaginatedDataStore {
939
1253
  constructor(route, config = {}) {
940
1254
  this.route = route;
941
1255
  this.config = config;
942
- this.#cache = new LruCache(config.cacheSize);
943
1256
  this.applyConfig(config);
1257
+ this.#cache = new LruCache(this.config.cacheSize);
944
1258
  this.applyPresetMeta();
945
1259
  this.initTransport();
946
1260
  inject(DestroyRef).onDestroy(() => this.destroy());
@@ -1019,7 +1333,7 @@ class PaginatedDataStore {
1019
1333
  *
1020
1334
  * @param params Dictionary of route parameters (e.g., `{ id: '123' }`)
1021
1335
  * @param opts Options object
1022
- * @param opts.reset If `true` (default), resets page to 0, clears cache, total elements count, and items
1336
+ * @param opts.reset If `true`, resets page to 0, clears cache, total elements count, and items
1023
1337
  * @param opts.abort If `true`, aborts all active transport requests and sets loading to false
1024
1338
  *
1025
1339
  * @example
@@ -1059,6 +1373,7 @@ class PaginatedDataStore {
1059
1373
  */
1060
1374
  updateConfig = (config) => {
1061
1375
  this.config = { ...this.config, ...config };
1376
+ this.#cache.limit = this.config.cacheSize || this.defaultConfig.defaultCacheSize || 5;
1062
1377
  this.applyPresetMeta();
1063
1378
  };
1064
1379
  /**
@@ -1149,10 +1464,16 @@ class PaginatedDataStore {
1149
1464
  return data;
1150
1465
  };
1151
1466
  updateCache = (data) => {
1152
- if (this.config.hasCache) {
1153
- this.#cache.set(this.page, data);
1467
+ if (!this.config.hasCache) {
1468
+ this.cached.set([...data]);
1469
+ return;
1154
1470
  }
1155
- this.cached.set(Array.from(this.#cache.values()).flat());
1471
+ this.#cache.set(this.page, data);
1472
+ const flat = [];
1473
+ for (const pageItems of this.#cache.values()) {
1474
+ flat.push(...pageItems);
1475
+ }
1476
+ this.cached.set(flat);
1156
1477
  };
1157
1478
  parseFlatArray(data) {
1158
1479
  return { content: data, totalElements: data.length };
@@ -1167,6 +1488,9 @@ class PaginatedDataStore {
1167
1488
  hasCache: config.hasCache === undefined ? this.defaultConfig.defaultHasCache : config.hasCache,
1168
1489
  cacheSize: config.cacheSize || this.defaultConfig.defaultCacheSize || 5,
1169
1490
  };
1491
+ if (this.#cache) {
1492
+ this.#cache.limit = this.config.cacheSize;
1493
+ }
1170
1494
  }
1171
1495
  applyPresetMeta() {
1172
1496
  this.filters = this.config.presetFilters || {};
@@ -1216,7 +1540,7 @@ class DictStore {
1216
1540
  * With `fixed: true` filters the local cache; with `fixed: false` triggers server search.
1217
1541
  */
1218
1542
  searchText = signal('', ...(ngDevMode ? [{ debugName: "searchText" }] : []));
1219
- debouncedSearchText = debounceSignal(this.searchText, 300);
1543
+ debouncedSearchText;
1220
1544
  /**
1221
1545
  * Additional filters for server request (or presets).
1222
1546
  */
@@ -1252,6 +1576,8 @@ class DictStore {
1252
1576
  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', }) {
1253
1577
  this.apiUrl = apiUrl;
1254
1578
  this.storageKey = storageKey;
1579
+ const searchDebounce = debounceTime ?? 300;
1580
+ this.debouncedSearchText = debounceSignal(this.searchText, searchDebounce);
1255
1581
  this.#helper = new PaginatedDataStore(this.apiUrl, {
1256
1582
  method: method,
1257
1583
  hasCache: false,
@@ -1313,19 +1639,22 @@ class DictStore {
1313
1639
  * @returns label string or `undefined` if not found
1314
1640
  */
1315
1641
  findLabel = (value) => {
1316
- for (const item of this.cachedItems()) {
1317
- if (item[this.valueKey] === value) {
1318
- return String(item[this.labelKey] ?? undefined);
1319
- }
1642
+ if (value == null) {
1643
+ return undefined;
1320
1644
  }
1321
- return undefined;
1645
+ const key = typeof value === 'string' ? value : String(value);
1646
+ if (!key) {
1647
+ return undefined;
1648
+ }
1649
+ const item = this.#lru.get(key);
1650
+ return item ? String(item[this.labelKey] ?? undefined) : undefined;
1322
1651
  };
1323
1652
  /**
1324
1653
  * Preload dictionary items into a local cache.
1325
1654
  * Useful for SSR/static lists/quick presets.
1326
1655
  *
1327
1656
  * @param items list of items
1328
- * @param opts `{ replace?: true }` completely replace current cache
1657
+ * @param opts `{ replace?: true }` - completely replace current cache
1329
1658
  */
1330
1659
  preload = (items, opts) => {
1331
1660
  opts?.replace && this.#lru.clear();
@@ -1335,9 +1664,12 @@ class DictStore {
1335
1664
  if (!key) {
1336
1665
  continue;
1337
1666
  }
1338
- const existed = this.#lru.has(key);
1667
+ const prev = this.#lru.get(key);
1668
+ const existed = prev !== null;
1339
1669
  this.#lru.set(key, it);
1340
- !existed && (changed = true);
1670
+ if (!existed || prev !== it) {
1671
+ changed = true;
1672
+ }
1341
1673
  }
1342
1674
  if (changed || opts?.replace) {
1343
1675
  const arr = this.#lru.toArray();
@@ -1358,9 +1690,12 @@ class DictStore {
1358
1690
  let changed = 0;
1359
1691
  for (const it of items) {
1360
1692
  const key = this.keyOf(it);
1361
- const before = this.#lru.has(key);
1693
+ const prev = this.#lru.get(key);
1694
+ const before = prev !== null;
1362
1695
  this.#lru.set(key, it);
1363
- !before && changed++;
1696
+ if (!before || prev !== it) {
1697
+ changed++;
1698
+ }
1364
1699
  }
1365
1700
  if (changed > 0) {
1366
1701
  const arr = this.#lru.toArray();
@@ -1399,7 +1734,9 @@ class DictStore {
1399
1734
  const out = [];
1400
1735
  for (let i = 0; i < list.length; i++) {
1401
1736
  const val = list[i][key];
1402
- val.toLowerCase().includes(query) && out.push(list[i]);
1737
+ String(val ?? '')
1738
+ .toLowerCase()
1739
+ .includes(query) && out.push(list[i]);
1403
1740
  }
1404
1741
  return out;
1405
1742
  }
@@ -1459,7 +1796,7 @@ class DictLocalStore {
1459
1796
  constructor(items, config) {
1460
1797
  this.labelKey = config?.labelKey ?? (this.defaultConfig.defaultLabelKey || 'name');
1461
1798
  this.valueKey = config?.valueKey ?? (this.defaultConfig.defaultValueKey || 'code');
1462
- this.maxOptionsSize = config?.maxOptionsSize ?? 1000;
1799
+ this.maxOptionsSize = config?.maxOptionsSize ?? this.defaultConfig.defaultMaxOptionsSize ?? 1000;
1463
1800
  this.items.set(items);
1464
1801
  this.#draftItems.set(items);
1465
1802
  }
@@ -1534,9 +1871,138 @@ class DictLocalStore {
1534
1871
  };
1535
1872
  }
1536
1873
 
1874
+ class EntityStore {
1875
+ idKey;
1876
+ sortIds;
1877
+ byId = signal({}, ...(ngDevMode ? [{ debugName: "byId" }] : []));
1878
+ ids = signal([], ...(ngDevMode ? [{ debugName: "ids" }] : []));
1879
+ items = computed(() => {
1880
+ const byId = this.byId();
1881
+ return this.ids()
1882
+ .map((id) => byId[String(id)])
1883
+ .filter((item) => item !== undefined);
1884
+ }, ...(ngDevMode ? [{ debugName: "items" }] : []));
1885
+ constructor(config) {
1886
+ this.idKey = config.idKey;
1887
+ this.sortIds = config.sortIds;
1888
+ }
1889
+ clear() {
1890
+ this.byId.set({});
1891
+ this.ids.set([]);
1892
+ }
1893
+ setAll(items) {
1894
+ const byId = {};
1895
+ const ids = [];
1896
+ for (const item of items) {
1897
+ const id = item[this.idKey];
1898
+ if (!this.isValidId(id)) {
1899
+ continue;
1900
+ }
1901
+ byId[String(id)] = item;
1902
+ ids.push(id);
1903
+ }
1904
+ this.byId.set(byId);
1905
+ this.ids.set(this.sortIds ? [...ids].sort(this.sortIds) : ids);
1906
+ }
1907
+ upsertOne(item) {
1908
+ const id = item[this.idKey];
1909
+ if (!this.isValidId(id)) {
1910
+ return;
1911
+ }
1912
+ const key = String(id);
1913
+ this.byId.update((prev) => ({ ...prev, [key]: item }));
1914
+ this.ids.update((prev) => {
1915
+ const has = prev.some((currentId) => currentId === id);
1916
+ const next = has ? [...prev] : [...prev, id];
1917
+ return this.sortIds ? next.sort(this.sortIds) : next;
1918
+ });
1919
+ }
1920
+ upsertMany(items) {
1921
+ if (!items.length) {
1922
+ return;
1923
+ }
1924
+ this.byId.update((prev) => {
1925
+ const next = { ...prev };
1926
+ for (const item of items) {
1927
+ const id = item[this.idKey];
1928
+ if (!this.isValidId(id)) {
1929
+ continue;
1930
+ }
1931
+ next[String(id)] = item;
1932
+ }
1933
+ return next;
1934
+ });
1935
+ this.ids.update((prev) => {
1936
+ const next = [...prev];
1937
+ const keySet = new Set(next.map((id) => String(id)));
1938
+ for (const item of items) {
1939
+ const id = item[this.idKey];
1940
+ if (!this.isValidId(id) || keySet.has(String(id))) {
1941
+ continue;
1942
+ }
1943
+ keySet.add(String(id));
1944
+ next.push(id);
1945
+ }
1946
+ return this.sortIds ? next.sort(this.sortIds) : next;
1947
+ });
1948
+ }
1949
+ removeOne(id) {
1950
+ if (!this.isValidId(id)) {
1951
+ return;
1952
+ }
1953
+ const key = String(id);
1954
+ this.byId.update((prev) => {
1955
+ const next = { ...prev };
1956
+ delete next[key];
1957
+ return next;
1958
+ });
1959
+ this.ids.update((prev) => prev.filter((it) => it !== id));
1960
+ }
1961
+ removeMany(ids) {
1962
+ if (!ids.length) {
1963
+ return;
1964
+ }
1965
+ const idSet = new Set(ids.map((id) => String(id)));
1966
+ this.byId.update((prev) => {
1967
+ const next = { ...prev };
1968
+ for (const id of idSet) {
1969
+ delete next[id];
1970
+ }
1971
+ return next;
1972
+ });
1973
+ this.ids.update((prev) => prev.filter((id) => !idSet.has(String(id))));
1974
+ }
1975
+ patchOne(id, patch) {
1976
+ if (!this.isValidId(id)) {
1977
+ return;
1978
+ }
1979
+ const key = String(id);
1980
+ const current = this.byId()[key];
1981
+ if (!current) {
1982
+ return;
1983
+ }
1984
+ this.byId.update((prev) => ({
1985
+ ...prev,
1986
+ [key]: { ...current, ...patch },
1987
+ }));
1988
+ }
1989
+ getById(id) {
1990
+ if (!this.isValidId(id)) {
1991
+ return null;
1992
+ }
1993
+ return this.byId()[String(id)] ?? null;
1994
+ }
1995
+ has(id) {
1996
+ return this.getById(id) !== null;
1997
+ }
1998
+ isValidId(value) {
1999
+ return typeof value === 'string' || typeof value === 'number';
2000
+ }
2001
+ }
2002
+
1537
2003
  /**
1538
2004
  * Generated bundle index. Do not edit.
1539
2005
  */
1540
2006
 
1541
- export { AbortError, CacheMissError, DictLocalStore, DictStore, LruCache, PaginatedDataStore, ResourceStore, STATUM_CONFIG, Serializer, isAbort, storageStrategy };
2007
+ export { AbortError, CacheMissError, DictLocalStore, DictStore, EntityStore, LruCache, PaginatedDataStore, ResourceStore, STATUM_CONFIG, Serializer, SerializerFieldError, createBodySerializer, createQuerySerializer, createStrictSerializer, isAbort, storageStrategy };
1542
2008
  //# sourceMappingURL=reforgium-statum.mjs.map