@reforgium/statum 3.0.1 → 3.1.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,380 +1,10 @@
1
- import { formatDate, isNullable, isDatePeriod, parseToDate, buildQueryParams, isNumber, isObject, parseToDatePeriod, parseQueryParamsByMode, fillUrlWithParams, mergeQueryParams, deepEqual, normalizeSortInput, sortInputToTokens, debounceSignal } from '@reforgium/internal';
1
+ import { Serializer, fillUrlWithParams, mergeQueryParams, LruCache, deepEqual, isNullable, normalizeSortInput, sortInputToTokens, debounceSignal, storageStrategy } from '@reforgium/internal';
2
+ export { LocalStorage, LruCache, MemoryStorage, Serializer, SerializerFieldError, SessionStorage, storageStrategy } from '@reforgium/internal';
2
3
  import { HttpClient, HttpParams } from '@angular/common/http';
3
4
  import { InjectionToken, makeEnvironmentProviders, inject, signal, computed, EnvironmentInjector, DestroyRef, runInInjectionContext, effect, untracked } from '@angular/core';
4
5
  import { Subject, filter, timer, merge, map } from 'rxjs';
5
6
  import { debounce, tap, throttle, finalize } from 'rxjs/operators';
6
7
 
7
- const serializeString = (config, value) => config.mapString.format?.(value) ?? (config.mapString.trim ? value.trim() : value);
8
- const serializeNumber = (config, value) => config.mapNumber.format?.(value) ?? Number(value);
9
- const serializeBoolean = (config, value) => config.mapBoolean.format?.(value) ?? (value ? (config.mapBoolean.true ?? true) : (config.mapBoolean.false ?? false));
10
- const serializeDate = (config, value) => config.mapDate.format?.(value) ?? formatDate(value, config.mapDate.dateFormat);
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
- }
25
- /**
26
- * Universal serializer/deserializer for values used in forms, filters, and DTOs.
27
- *
28
- * Supports types: `string | number | boolean | Date | [Date, Date] (period) | array | object | nullable`.
29
- * Capabilities:
30
- * - normalize values according to config (trim strings, parse numbers from strings, boolean strings, etc.),
31
- * - transform date periods into paired keys (`from/to`) or a single joined string,
32
- * - build/parse a query string (or JSON) to and from an object.
33
- *
34
- * Example:
35
- * ```ts
36
- * type Filters = { q?: string; active?: boolean; created?: [Date, Date] | null };
37
- * const s = new Serializer<Filters>({
38
- * mapPeriod: { transformMode: { mode: 'split', dateFromKeyPostfix: 'From', dateToKeyPostfix: 'To' } }
39
- * });
40
- *
41
- * // -> { q: 'john', createdFrom: '2025-01-01', createdTo: '2025-01-31' }
42
- * const plain = s.serialize({
43
- * q: ' john ',
44
- * active: undefined,
45
- * created: [new Date('2025-01-01'), new Date('2025-01-31')]
46
- * });
47
- *
48
- * // -> 'q=john&createdFrom=2025-01-01&createdTo=2025-01-31'
49
- * const qs = s.toQuery({ q: 'john', created: [new Date('2025-01-01'), new Date('2025-01-31')] });
50
- *
51
- * // <- { q: 'john', created: [Date, Date] }
52
- * const parsed = s.deserialize('q=john&createdFrom=2025-01-01&createdTo=2025-01-31');
53
- * ```
54
- */
55
- class Serializer {
56
- config;
57
- /**
58
- * Creates a serializer with a partially overridden configuration.
59
- * Provide only the options you want to change (the rest are taken from defaults).
60
- *
61
- * @param config partial transformation configuration
62
- */
63
- constructor(config = {}) {
64
- this.config = this.mergeConfig({
65
- mapString: { trim: true },
66
- mapNumber: { fromString: false },
67
- mapBoolean: {},
68
- mapArray: { concatType: 'comma' },
69
- mapObject: { deep: true },
70
- mapDate: { dateFormat: 'yyyy-MM-dd' },
71
- mapPeriod: {
72
- dateFormat: 'yyyy-MM-dd',
73
- transformMode: { mode: 'split', dateFromKeyPostfix: 'From', dateToKeyPostfix: 'To' },
74
- },
75
- mapNullable: { remove: true, includeEmptyString: false },
76
- }, config);
77
- }
78
- /**
79
- * Converts a domain object into a flat serialized representation
80
- * (ready to send to an API or build a query string).
81
- *
82
- * Rules are taken from `config`:
83
- * — strings can be trimmed (if enabled),
84
- * — numbers can be converted from strings/numbers,
85
- * — boolean supports custom true/false representations,
86
- * — dates are formatted by `dateFormat`,
87
- * — date periods can be split/joined,
88
- * — `nullable` can be removed from the result (`remove`) or formatted.
89
- *
90
- * @param obj source object
91
- * @returns a flat dictionary with string/primitive values
92
- */
93
- serialize(obj) {
94
- const result = {};
95
- for (const [key, value] of Object.entries(obj ?? {})) {
96
- const fields = this.config.mapFields?.[key];
97
- if (fields && 'format' in fields) {
98
- try {
99
- result[key] = fields.format(value, obj);
100
- }
101
- catch (error) {
102
- throw new SerializerFieldError(key, 'format', error);
103
- }
104
- continue;
105
- }
106
- if (fields?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
107
- if (this.config.mapNullable.remove) {
108
- continue;
109
- }
110
- }
111
- if (fields?.type === 'period' || isDatePeriod(value)) {
112
- const transform = this.config.mapPeriod.transformMode;
113
- const [from, to] = value;
114
- if (transform?.mode === 'split') {
115
- result[`${key}${transform.dateFromKeyPostfix}`] = formatDate(from, this.config.mapPeriod.dateFormat);
116
- result[`${key}${transform.dateToKeyPostfix}`] = formatDate(to, this.config.mapPeriod.dateFormat);
117
- continue;
118
- }
119
- }
120
- result[key] = this.serializeElement(value, key);
121
- }
122
- return result;
123
- }
124
- /**
125
- * Parse serialized data into a domain object.
126
- *
127
- * Source can be:
128
- * — a query string (`key=value&arr=1,2`) or `JSON.stringify(obj)`,
129
- * — an already prepared flat object.
130
- *
131
- * Transformations are reverse of `serialize`: strings → number/boolean/Date/period,
132
- * arrays are collected according to strategy (`comma`/`pipe`/`multi`), objects — deeply or as JSON.
133
- *
134
- * @param val query string or object
135
- * @returns a domain object of the specified type
136
- */
137
- deserialize = (val) => {
138
- const data = typeof val === 'string' ? this.parseInputString(val) : val;
139
- const result = {};
140
- for (const [key, value] of Object.entries(data ?? {})) {
141
- const field = this.config.mapFields?.[key];
142
- if (field && 'parse' in field) {
143
- try {
144
- result[key] = field.parse(value, data);
145
- }
146
- catch (error) {
147
- throw new SerializerFieldError(key, 'parse', error);
148
- }
149
- continue;
150
- }
151
- if (field?.type === 'nullable' ||
152
- (field?.type !== 'array' && isNullable(value, this.config.mapNullable?.includeEmptyString))) {
153
- if (this.config.mapNullable.remove) {
154
- continue;
155
- }
156
- }
157
- const periodTransform = this.config.mapPeriod.transformMode;
158
- if (periodTransform.mode === 'split') {
159
- const isFrom = (key || '').endsWith(periodTransform.dateFromKeyPostfix);
160
- const isTo = (key || '').endsWith(periodTransform.dateToKeyPostfix);
161
- const keyJoint = (key || '')
162
- .replace(periodTransform.dateFromKeyPostfix, '')
163
- .replace(periodTransform.dateToKeyPostfix, '');
164
- const field = this.config.mapFields?.[keyJoint];
165
- const fieldType = field && 'type' in field ? field.type : undefined;
166
- if (fieldType === 'period' && (isFrom || isTo)) {
167
- result[keyJoint] ??= [null, null];
168
- if (isFrom) {
169
- result[keyJoint][0] = parseToDate(value, this.config.mapPeriod.dateFormat);
170
- }
171
- else if (isTo) {
172
- result[keyJoint][1] = parseToDate(value, this.config.mapPeriod.dateFormat);
173
- }
174
- continue;
175
- }
176
- }
177
- result[key] = this.deserializeElement(value, key);
178
- }
179
- return result;
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
- };
189
- /**
190
- * Build a query string from a domain object using `serialize` rules
191
- * and the array joining strategy (`concatType`).
192
- *
193
- * @param val domain object
194
- * @returns query string (suitable for URL or history API)
195
- */
196
- toQuery = (val) => {
197
- return buildQueryParams(this.serialize(val), this.config.mapArray.concatType, this.resolveArrayFieldModes());
198
- };
199
- /**
200
- * Returns a new serializer instance with a merged configuration.
201
- * Useful for ad-hoc overrides for a specific call.
202
- *
203
- * @param config partial config changes
204
- * @returns new `Serializer` with the provided `config` applied
205
- */
206
- withConfig(config) {
207
- return new Serializer(this.mergeConfig(this.config, config));
208
- }
209
- serializeElement(value, key) {
210
- const fields = this.config.mapFields?.[key || ''];
211
- if (fields && 'format' in fields) {
212
- return;
213
- }
214
- if (fields?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
215
- const nullableVal = value ?? null;
216
- return this.config.mapNullable.format?.(nullableVal) || this.config.mapNullable.replaceWith || nullableVal;
217
- }
218
- if (fields?.type === 'string') {
219
- return serializeString(this.config, value);
220
- }
221
- if (fields?.type === 'number' || isNumber(value, this.config.mapNumber.fromString)) {
222
- return serializeNumber(this.config, value);
223
- }
224
- if (typeof value === 'string') {
225
- return serializeString(this.config, value);
226
- }
227
- if (fields?.type === 'boolean' || typeof value === 'boolean') {
228
- return serializeBoolean(this.config, value);
229
- }
230
- if (fields?.type === 'date' || value instanceof Date) {
231
- return serializeDate(this.config, value);
232
- }
233
- if (fields?.type === 'period' || isDatePeriod(value)) {
234
- const mapPeriod = this.config.mapPeriod;
235
- if (mapPeriod.format) {
236
- return mapPeriod.format(value);
237
- }
238
- else {
239
- const [from, to] = value;
240
- const transform = mapPeriod.transformMode;
241
- if (transform.mode === 'join') {
242
- const period = [
243
- formatDate(from, this.config.mapPeriod.dateFormat),
244
- formatDate(to, this.config.mapPeriod.dateFormat),
245
- ];
246
- return period.join(transform.concat);
247
- }
248
- }
249
- }
250
- if (fields?.type === 'array' || Array.isArray(value)) {
251
- return value.map((it) => this.serializeElement(it));
252
- }
253
- if (fields?.type === 'object' || isObject(value)) {
254
- return (this.config.mapObject.format?.(value) ??
255
- (this.config.mapObject.deep ? this.serialize(value) : JSON.stringify(value)));
256
- }
257
- }
258
- deserializeElement(value, key) {
259
- const field = this.config.mapFields?.[key || ''];
260
- if (field && 'format' in field) {
261
- return;
262
- }
263
- if (field?.type === 'array' || Array.isArray(value)) {
264
- const array = Array.isArray(value) ? value : [value];
265
- if (this.config.mapArray.removeNullable) {
266
- if (!isNullable(value, this.config.mapNullable?.includeEmptyString)) {
267
- return array.map((it) => this.deserializeElement(it));
268
- }
269
- else {
270
- return;
271
- }
272
- }
273
- else {
274
- return !value ? [] : array.map((it) => this.deserializeElement(it));
275
- }
276
- }
277
- if (field?.type === 'object') {
278
- try {
279
- if (this.config.mapObject.deep) {
280
- return isObject(value) ? this.deserialize(value) : value;
281
- }
282
- else {
283
- return typeof value === 'string' ? JSON.parse(value) : value;
284
- }
285
- }
286
- catch {
287
- return value;
288
- }
289
- }
290
- if (field?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
291
- return this.config.mapNullable.parse?.(value) || value;
292
- }
293
- if (field?.type === 'boolean' ||
294
- typeof value === 'boolean' ||
295
- value === this.config.mapBoolean.true ||
296
- value === this.config.mapBoolean.false) {
297
- return this.config.mapBoolean.parse?.(value) ?? (value === this.config.mapBoolean.true || value === true);
298
- }
299
- const maybeDate = parseToDate(value, this.config.mapDate.dateFormat);
300
- if (field?.type === 'date' || maybeDate) {
301
- return this.config.mapDate.parse?.(value) || maybeDate;
302
- }
303
- const periodTransform = this.config.mapPeriod.transformMode;
304
- if (periodTransform.mode === 'join') {
305
- const maybePeriod = parseToDatePeriod(value, this.config.mapPeriod.dateFormat);
306
- if (field?.type === 'period' || (maybePeriod || []).some(Boolean)) {
307
- return this.config.mapPeriod.parse?.(value) || maybePeriod;
308
- }
309
- }
310
- if (field?.type === 'number' || isNumber(value, this.config.mapNumber.fromString)) {
311
- return this.config.mapNumber.parse?.(value) || Number(String(value).trim());
312
- }
313
- if (field?.type === 'string' || typeof value === 'string') {
314
- const parsed = this.config.mapString.parse?.(value);
315
- if (parsed !== undefined) {
316
- return parsed;
317
- }
318
- return this.config.mapString.trim ? String(value).trim() : value;
319
- }
320
- return value;
321
- }
322
- parseQuery(val) {
323
- return parseQueryParamsByMode(val, undefined, this.resolveArrayFieldModes());
324
- }
325
- parseJsonObject(val) {
326
- try {
327
- const parsed = JSON.parse(val);
328
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
329
- return parsed;
330
- }
331
- }
332
- catch (error) {
333
- throw new Error(`Invalid JSON input: ${error instanceof Error ? error.message : String(error)}`);
334
- }
335
- throw new Error('Invalid JSON input: expected a JSON object');
336
- }
337
- parseInputString(val) {
338
- try {
339
- return this.parseJsonObject(val);
340
- }
341
- catch {
342
- return this.parseQuery(val);
343
- }
344
- }
345
- mergeConfig(base, override) {
346
- return {
347
- ...base,
348
- ...override,
349
- mapString: { ...base.mapString, ...override.mapString },
350
- mapNumber: { ...base.mapNumber, ...override.mapNumber },
351
- mapBoolean: { ...base.mapBoolean, ...override.mapBoolean },
352
- mapDate: { ...base.mapDate, ...override.mapDate },
353
- mapPeriod: {
354
- ...base.mapPeriod,
355
- ...override.mapPeriod,
356
- transformMode: {
357
- ...base.mapPeriod.transformMode,
358
- ...override.mapPeriod?.transformMode,
359
- },
360
- },
361
- mapNullable: { ...base.mapNullable, ...override.mapNullable },
362
- mapArray: { ...base.mapArray, ...override.mapArray },
363
- mapObject: { ...base.mapObject, ...override.mapObject },
364
- mapFields: { ...(base.mapFields || {}), ...(override.mapFields || {}) },
365
- };
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
- }
376
- }
377
-
378
8
  const createQuerySerializer = (config = {}) => {
379
9
  return new Serializer({
380
10
  mapArray: { concatType: 'comma' },
@@ -400,197 +30,6 @@ const createStrictSerializer = (config = {}) => {
400
30
  });
401
31
  };
402
32
 
403
- class LruCache {
404
- map = new Map();
405
- _limit = 100;
406
- constructor(limit = 100) {
407
- this.limit = limit;
408
- }
409
- get limit() {
410
- return this._limit;
411
- }
412
- get length() {
413
- return this.map.size;
414
- }
415
- set limit(value) {
416
- this._limit = Math.max(1, Math.floor(value || 0));
417
- }
418
- get(key) {
419
- if (!this.map.has(key)) {
420
- return null;
421
- }
422
- const val = this.map.get(key);
423
- this.map.delete(key);
424
- this.map.set(key, val);
425
- return val;
426
- }
427
- set(key, value) {
428
- if (this.map.has(key)) {
429
- this.map.delete(key);
430
- }
431
- else if (this.map.size >= this.limit) {
432
- const oldest = this.map.keys().next().value;
433
- oldest !== undefined && this.map.delete(oldest);
434
- }
435
- this.map.set(key, value);
436
- }
437
- remove(key) {
438
- return this.map.delete(key);
439
- }
440
- clear() {
441
- this.map.clear();
442
- }
443
- has(key) {
444
- return this.map.has(key);
445
- }
446
- keys() {
447
- return Array.from(this.map.keys());
448
- }
449
- values() {
450
- return Array.from(this.map.values());
451
- }
452
- entries() {
453
- return Array.from(this.map.entries());
454
- }
455
- toArray() {
456
- return Array.from(this.map.values());
457
- }
458
- fromArray(entries) {
459
- this.map.clear();
460
- for (const [k, v] of entries) {
461
- this.set(k, v);
462
- }
463
- }
464
- }
465
-
466
- class LocalStorage {
467
- prefix;
468
- constructor(prefix = 're') {
469
- this.prefix = prefix;
470
- }
471
- get length() {
472
- return Object.keys(localStorage).filter((key) => key.startsWith(this.safePrefix())).length;
473
- }
474
- get(key) {
475
- const storageKey = this.getSafePrefix(key);
476
- const raw = localStorage.getItem(storageKey);
477
- if (raw == null) {
478
- return null;
479
- }
480
- try {
481
- const parsed = JSON.parse(raw);
482
- return parsed ?? null;
483
- }
484
- catch {
485
- localStorage.removeItem(storageKey);
486
- return null;
487
- }
488
- }
489
- set(key, value) {
490
- const str = JSON.stringify(value);
491
- localStorage.setItem(this.getSafePrefix(key), str);
492
- }
493
- remove(key) {
494
- return localStorage.removeItem(this.getSafePrefix(key));
495
- }
496
- clear() {
497
- const keys = Object.keys(localStorage).filter((key) => key.startsWith(this.safePrefix()));
498
- keys.forEach((key) => localStorage.removeItem(key));
499
- }
500
- getSafePrefix(key) {
501
- return this.prefix ? `${this.prefix}:${key}` : String(key);
502
- }
503
- safePrefix() {
504
- return this.prefix ? `${this.prefix}:` : '';
505
- }
506
- }
507
-
508
- class MemoryStorage {
509
- cache = new Map();
510
- get length() {
511
- return this.cache.size;
512
- }
513
- get(key) {
514
- return this.cache.get(key) ?? null;
515
- }
516
- set(key, value) {
517
- this.cache.set(key, value);
518
- }
519
- remove(key) {
520
- this.cache.delete(key);
521
- }
522
- clear() {
523
- this.cache.clear();
524
- }
525
- }
526
-
527
- class SessionStorage {
528
- prefix;
529
- constructor(prefix = 're') {
530
- this.prefix = prefix;
531
- }
532
- get length() {
533
- return Object.keys(sessionStorage).filter((key) => key.startsWith(this.safePrefix())).length;
534
- }
535
- get(key) {
536
- const storageKey = this.getSafePrefix(key);
537
- const raw = sessionStorage.getItem(storageKey);
538
- if (raw == null) {
539
- return null;
540
- }
541
- try {
542
- const parsed = JSON.parse(raw);
543
- return parsed ?? null;
544
- }
545
- catch {
546
- sessionStorage.removeItem(storageKey);
547
- return null;
548
- }
549
- }
550
- set(key, value) {
551
- const str = JSON.stringify(value);
552
- sessionStorage.setItem(this.getSafePrefix(key), str);
553
- }
554
- remove(key) {
555
- return sessionStorage.removeItem(this.getSafePrefix(key));
556
- }
557
- clear() {
558
- const keys = Object.keys(sessionStorage).filter((key) => key.startsWith(this.safePrefix()));
559
- keys.forEach((key) => sessionStorage.removeItem(key));
560
- }
561
- getSafePrefix(key) {
562
- return this.prefix ? `${this.prefix}:${key}` : String(key);
563
- }
564
- safePrefix() {
565
- return this.prefix ? `${this.prefix}:` : '';
566
- }
567
- }
568
-
569
- /**
570
- * Factory for data storage strategies.
571
- *
572
- * Returns a `StorageInterface` implementation depending on the selected strategy:
573
- * - `'memory'` — in-memory storage (for the session lifetime);
574
- * - `'session'` — `sessionStorage`, lives until the tab is closed;
575
- * - `'persist'` — `localStorage`, persists between sessions;
576
- * - `'lru'` — size-limited cache (Least Recently Used).
577
- *
578
- * Used to choose an appropriate storage implementation
579
- * depending on the scenario: temporary data, long-term, cache, etc.
580
- *
581
- * @param strategy storage strategy type (`memory`, `session`, `persist`, `lru`)
582
- * @returns instance implementing `StorageInterface<Key, Type>`
583
- */
584
- const storageStrategy = (strategy, options = {}) => {
585
- const fabrics = {
586
- memory: () => new MemoryStorage(),
587
- session: () => new SessionStorage(),
588
- persist: () => new LocalStorage(),
589
- lru: () => new LruCache(options.lruLimit ?? 100),
590
- };
591
- return fabrics[strategy]();
592
- };
593
-
594
33
  // noinspection ES6PreferShortImport
595
34
  const STATUM_CONFIG = new InjectionToken('RE_STATUM_CONFIG');
596
35
  const provideStatum = (config) => makeEnvironmentProviders([{ provide: STATUM_CONFIG, useValue: config }]);
@@ -638,6 +77,7 @@ class AbortError extends Error {
638
77
  */
639
78
  function isAbort(e) {
640
79
  return (e instanceof AbortError ||
80
+ (e instanceof DOMException && e.name === 'AbortError') ||
641
81
  (typeof e === 'object' && e != null && e?.name === 'AbortError' && e?.message === 'aborted'));
642
82
  }
643
83
  function joinUrl(base, path) {
@@ -837,10 +277,10 @@ class KeyedScheduler {
837
277
  class ResourceStore {
838
278
  http = inject(HttpClient);
839
279
  serializer;
840
- #value = signal(null, ...(ngDevMode ? [{ debugName: "#value" }] : []));
841
- #status = signal('idle', ...(ngDevMode ? [{ debugName: "#status" }] : []));
842
- #error = signal(null, ...(ngDevMode ? [{ debugName: "#error" }] : []));
843
- #activeRequests = signal(0, ...(ngDevMode ? [{ debugName: "#activeRequests" }] : []));
280
+ #value = signal(null, ...(ngDevMode ? [{ debugName: "#value" }] : /* istanbul ignore next */ []));
281
+ #status = signal('idle', ...(ngDevMode ? [{ debugName: "#status" }] : /* istanbul ignore next */ []));
282
+ #error = signal(null, ...(ngDevMode ? [{ debugName: "#error" }] : /* istanbul ignore next */ []));
283
+ #activeRequests = signal(0, ...(ngDevMode ? [{ debugName: "#activeRequests" }] : /* istanbul ignore next */ []));
844
284
  /**
845
285
  * Current resource value.
846
286
  * Returns `null` if no data yet or the request failed.
@@ -858,7 +298,7 @@ class ResourceStore {
858
298
  * Convenience loading flag: `true` when `loading` or `stale`.
859
299
  * Useful for spinners and disabling buttons.
860
300
  */
861
- loading = computed(() => this.#activeRequests() > 0 || this.#status() === 'stale', ...(ngDevMode ? [{ debugName: "loading" }] : []));
301
+ loading = computed(() => this.#activeRequests() > 0 || this.#status() === 'stale', ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
862
302
  routes;
863
303
  opts;
864
304
  maxEntries;
@@ -906,15 +346,20 @@ class ResourceStore {
906
346
  catch (error) {
907
347
  return Promise.reject(error);
908
348
  }
909
- const entry = this.ensureEntry(key);
910
349
  const strategy = cfg.strategy ?? 'network-first';
911
350
  const ttlMs = cfg.ttlMs ?? this.opts.ttlMs ?? 0;
912
- const fresh = entry.updatedAt != null && Date.now() - entry.updatedAt < ttlMs;
913
- if (cfg.dedupe && entry.inflight) {
351
+ let entry = this.entries.get(key);
352
+ if (entry) {
353
+ // Keep hot keys at the end to approximate LRU order on cache reads too.
354
+ this.entries.delete(key);
355
+ this.entries.set(key, entry);
356
+ }
357
+ const fresh = entry?.updatedAt != null && Date.now() - entry.updatedAt < ttlMs;
358
+ if (cfg.dedupe && entry?.inflight) {
914
359
  return entry.inflight;
915
360
  }
916
361
  if (strategy === 'cache-only') {
917
- if (fresh && entry.data != null) {
362
+ if (fresh && entry?.data != null) {
918
363
  this.trace({ type: 'cache-hit', method: 'GET', key, strategy });
919
364
  this.promoteCurrent(entry, cfg.promote);
920
365
  return Promise.resolve(entry.data);
@@ -922,29 +367,44 @@ class ResourceStore {
922
367
  this.trace({ type: 'cache-miss', method: 'GET', key, strategy });
923
368
  return Promise.reject(new CacheMissError(key));
924
369
  }
925
- if (strategy === 'cache-first' && fresh && !cfg.revalidate && entry.data != null) {
370
+ if (strategy === 'cache-first' && fresh && !cfg.revalidate && entry?.data != null) {
926
371
  this.trace({ type: 'cache-hit', method: 'GET', key, strategy });
927
372
  this.promoteCurrent(entry, cfg.promote);
928
373
  return Promise.resolve(entry.data);
929
374
  }
375
+ entry ??= this.ensureEntry(key);
930
376
  const delay = cfg.delay ?? this.opts.delay ?? 0;
931
377
  const mode = cfg.delayMode ?? this.opts.delayMode ?? 'debounce';
932
378
  const isSWR = strategy === 'cache-first' && entry.data != null;
933
379
  entry.status = isSWR ? 'stale' : 'loading';
380
+ if (!isSWR) {
381
+ entry.error = null;
382
+ }
934
383
  this.promoteCurrent(entry, cfg.promote);
935
384
  const retry = this.resolveRetryConfig(cfg.retry);
936
385
  const taskWithRetry = this.runWithRetry((attempt) => {
937
386
  this.trace({ type: 'request-start', method: 'GET', key, attempt });
938
- return this.scheduler.schedule(key, mode, delay, this.exec$({
939
- req$: this.http.get(url, { params: query, responseType: responseType }),
387
+ entry.controller = new AbortController();
388
+ const options = {
389
+ params: query,
390
+ responseType: responseType,
391
+ observe: 'response',
392
+ signal: entry.controller.signal,
393
+ };
394
+ const scheduled = this.scheduler.schedule(key, mode, delay, this.exec$({
395
+ req$: this.http.get(url, options),
940
396
  entry,
941
397
  promote: cfg.promote,
942
398
  parseFn: cfg.parseResponse,
399
+ observe: cfg.observe,
943
400
  }));
401
+ void scheduled.catch(() => undefined);
402
+ return scheduled;
944
403
  }, retry, {
945
404
  method: 'GET',
946
405
  key,
947
406
  });
407
+ void taskWithRetry.catch(() => undefined);
948
408
  const resolvedTask = taskWithRetry
949
409
  .catch((error) => {
950
410
  if (isAbort(error)) {
@@ -1036,9 +496,12 @@ class ResourceStore {
1036
496
  const entry = this.entries.get(key);
1037
497
  this.trace({ type: 'abort', method, key });
1038
498
  if (entry) {
499
+ entry.controller?.abort(reason instanceof Error ? reason : new AbortError(typeof reason === 'string' ? reason : undefined));
500
+ entry.controller = undefined;
1039
501
  entry.inflight = undefined;
1040
502
  if (entry.status === 'loading' || entry.status === 'stale') {
1041
503
  entry.status = 'idle';
504
+ this.promoteCurrent(entry, entry.promoted);
1042
505
  }
1043
506
  }
1044
507
  this.scheduler.cancel?.(key, reason);
@@ -1050,10 +513,14 @@ class ResourceStore {
1050
513
  */
1051
514
  abortAll(reason) {
1052
515
  this.trace({ type: 'abort', method: 'GET', key: '*' });
516
+ const abortReason = reason instanceof Error ? reason : new AbortError(typeof reason === 'string' ? reason : undefined);
1053
517
  this.entries.forEach((entry) => {
518
+ entry.controller?.abort(abortReason);
519
+ entry.controller = undefined;
1054
520
  entry.inflight = undefined;
1055
521
  if (entry.status === 'loading' || entry.status === 'stale') {
1056
522
  entry.status = 'idle';
523
+ this.promoteCurrent(entry, entry.promoted);
1057
524
  }
1058
525
  });
1059
526
  this.scheduler.cancelAll?.(reason);
@@ -1076,13 +543,18 @@ class ResourceStore {
1076
543
  while (this.entries.size >= this.maxEntries) {
1077
544
  let keyToDelete = null;
1078
545
  for (const [key, value] of this.entries.entries()) {
1079
- if (!value.inflight) {
546
+ if (!value.inflight && !value.controller) {
1080
547
  keyToDelete = key;
1081
548
  break;
1082
549
  }
1083
550
  }
1084
551
  if (keyToDelete === null) {
552
+ // All entries have active requests — forced eviction. Dedupe for the evicted
553
+ // key will break: the in-flight request will finish but its result won't be cached.
1085
554
  keyToDelete = this.entries.keys().next().value ?? null;
555
+ if (keyToDelete !== null) {
556
+ this.trace({ type: 'abort', method: 'GET', key: keyToDelete });
557
+ }
1086
558
  }
1087
559
  if (keyToDelete === null) {
1088
560
  break;
@@ -1114,25 +586,35 @@ class ResourceStore {
1114
586
  const mode = config.delayMode ?? this.opts.delayMode ?? 'debounce';
1115
587
  const retry = this.resolveRetryConfig(config.retry);
1116
588
  entry.status = 'loading';
589
+ entry.error = null;
1117
590
  this.promoteCurrent(entry, config.promote);
1118
- let req$;
1119
- if (method === 'DELETE') {
1120
- req$ = this.http.delete(url, {
1121
- body: payload,
1122
- params: query,
1123
- responseType: responseType,
1124
- });
1125
- }
1126
- else {
1127
- // @ts-ignore
1128
- req$ = this.mutationMethods[method](url, payload, {
1129
- params: query,
1130
- responseType: responseType,
1131
- });
1132
- }
1133
591
  const task = this.runWithRetry((attempt) => {
1134
592
  this.trace({ type: 'request-start', method, key, attempt });
1135
- return this.scheduler.schedule(key, mode, delay, this.exec$({ req$, entry, promote: config.promote, parseFn: config.parseResponse }));
593
+ entry.controller = new AbortController();
594
+ const signal = entry.controller.signal;
595
+ let req$;
596
+ if (method === 'DELETE') {
597
+ // @ts-ignore
598
+ req$ = this.http.delete(url, {
599
+ body: payload,
600
+ params: query,
601
+ responseType: responseType,
602
+ observe: 'response',
603
+ signal,
604
+ });
605
+ }
606
+ else {
607
+ // @ts-ignore
608
+ req$ = this.mutationMethods[method](url, payload, {
609
+ params: query,
610
+ responseType: responseType,
611
+ observe: 'response',
612
+ signal,
613
+ });
614
+ }
615
+ const scheduled = this.scheduler.schedule(key, mode, delay, this.exec$({ req$, entry, promote: config.promote, parseFn: config.parseResponse, observe: config.observe }));
616
+ void scheduled.catch(() => undefined);
617
+ return scheduled;
1136
618
  }, retry, { method, key })
1137
619
  .then((data) => {
1138
620
  this.trace({ type: 'cache-write', method, key });
@@ -1181,6 +663,9 @@ class ResourceStore {
1181
663
  });
1182
664
  }
1183
665
  preparePayload(payload) {
666
+ if (payload instanceof FormData || payload instanceof Blob || payload instanceof ArrayBuffer) {
667
+ return payload;
668
+ }
1184
669
  const presetPayload = this.opts.presetPayload;
1185
670
  if (!presetPayload && !payload) {
1186
671
  return {};
@@ -1214,6 +699,7 @@ class ResourceStore {
1214
699
  attempts: Math.max(0, source.attempts ?? 0),
1215
700
  delayMs: Math.max(0, source.delayMs ?? 0),
1216
701
  backoff: source.backoff ?? 'constant',
702
+ jitter: source.jitter ?? false,
1217
703
  shouldRetry: source.shouldRetry ?? this.defaultShouldRetry,
1218
704
  };
1219
705
  }
@@ -1228,29 +714,41 @@ class ResourceStore {
1228
714
  }
1229
715
  return status === 0 || status >= 500;
1230
716
  }
1231
- async runWithRetry(exec, retry, context) {
1232
- let attempt = 1;
1233
- while (true) {
1234
- try {
1235
- return await exec(attempt);
717
+ runWithRetry(exec, retry, context) {
718
+ const runAttempt = (attempt) => exec(attempt).catch((error) => {
719
+ const canRetry = attempt <= retry.attempts && retry.shouldRetry(error, attempt);
720
+ if (!canRetry) {
721
+ throw error;
1236
722
  }
1237
- catch (error) {
1238
- const canRetry = attempt <= retry.attempts && retry.shouldRetry(error, attempt);
1239
- if (!canRetry) {
1240
- throw error;
1241
- }
1242
- const delayMs = retry.backoff === 'exponential' ? retry.delayMs * 2 ** (attempt - 1) : retry.delayMs;
1243
- this.trace({ type: 'request-retry', method: context.method, key: context.key, attempt, error });
1244
- attempt++;
1245
- if (delayMs > 0) {
1246
- await new Promise((resolve) => setTimeout(resolve, delayMs));
1247
- }
723
+ const baseDelay = retry.backoff === 'exponential' ? retry.delayMs * 2 ** (attempt - 1) : retry.delayMs;
724
+ const jitterOffset = retry.jitter ? (Math.random() * 0.5 - 0.25) * baseDelay : 0;
725
+ const delayMs = Math.max(0, Math.round(baseDelay + jitterOffset));
726
+ this.trace({ type: 'request-retry', method: context.method, key: context.key, attempt, error });
727
+ if (delayMs > 0) {
728
+ return new Promise((resolve, reject) => {
729
+ setTimeout(() => {
730
+ void runAttempt(attempt + 1).then(resolve, reject);
731
+ }, delayMs);
732
+ });
1248
733
  }
1249
- }
734
+ return runAttempt(attempt + 1);
735
+ });
736
+ return runAttempt(1);
1250
737
  }
1251
- exec$ = ({ req$, entry, promote = true, parseFn }) => () => {
738
+ exec$ = ({ req$, entry, promote = true, parseFn, observe }) => () => {
1252
739
  promote && this.#activeRequests.update((n) => n + 1);
1253
- return req$.pipe(map((data) => (parseFn ? parseFn(data) : data)), tap({
740
+ return req$.pipe(map((httpResponse) => {
741
+ try {
742
+ this.opts.onResponse?.(httpResponse);
743
+ }
744
+ catch {
745
+ /* noop */
746
+ }
747
+ const payload = observe === 'response'
748
+ ? httpResponse
749
+ : httpResponse.body;
750
+ return parseFn ? parseFn(payload) : payload;
751
+ }), tap({
1254
752
  next: (data) => {
1255
753
  entry.data = data;
1256
754
  entry.status = 'success';
@@ -1265,6 +763,7 @@ class ResourceStore {
1265
763
  },
1266
764
  }), finalize(() => {
1267
765
  promote && this.#activeRequests.update((n) => Math.max(0, n - 1));
766
+ entry.controller = undefined;
1268
767
  this.promoteCurrent(entry, promote);
1269
768
  }));
1270
769
  };
@@ -1272,6 +771,7 @@ class ResourceStore {
1272
771
  if (!promote) {
1273
772
  return;
1274
773
  }
774
+ entry.promoted = true;
1275
775
  this.#value.set(entry.data ?? null);
1276
776
  this.#status.set(entry.status);
1277
777
  this.#error.set(entry.error);
@@ -1339,22 +839,22 @@ class PagedQueryStore {
1339
839
  #transport;
1340
840
  #cache;
1341
841
  /** Current page data (reactive). */
1342
- items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
842
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1343
843
  /** Merged cache of pages (flat list) handy for search/export. */
1344
- cached = signal([], ...(ngDevMode ? [{ debugName: "cached" }] : []));
844
+ cached = signal([], ...(ngDevMode ? [{ debugName: "cached" }] : /* istanbul ignore next */ []));
1345
845
  /** Loading flag of the current operation. */
1346
- loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
846
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
1347
847
  /** Last request error (if any). */
1348
- error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
848
+ error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
1349
849
  /** 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" }] : []));
850
+ version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ []));
851
+ #page = signal(0, ...(ngDevMode ? [{ debugName: "#page" }] : /* istanbul ignore next */ []));
852
+ #pageSize = signal(20, ...(ngDevMode ? [{ debugName: "#pageSize" }] : /* istanbul ignore next */ []));
853
+ #totalElements = signal(0, ...(ngDevMode ? [{ debugName: "#totalElements" }] : /* istanbul ignore next */ []));
854
+ #filters = signal({}, ...(ngDevMode ? [{ debugName: "#filters" }] : /* istanbul ignore next */ []));
855
+ #query = signal({}, ...(ngDevMode ? [{ debugName: "#query" }] : /* istanbul ignore next */ []));
856
+ #sort = signal([], ...(ngDevMode ? [{ debugName: "#sort" }] : /* istanbul ignore next */ []));
857
+ #routeParams = signal({}, ...(ngDevMode ? [{ debugName: "#routeParams" }] : /* istanbul ignore next */ []));
1358
858
  pageState = this.#page.asReadonly();
1359
859
  pageSizeState = this.#pageSize.asReadonly();
1360
860
  totalElementsState = this.#totalElements.asReadonly();
@@ -1399,37 +899,18 @@ class PagedQueryStore {
1399
899
  get sort() {
1400
900
  return this.#sort();
1401
901
  }
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
902
  set filters(value) {
1407
903
  this.#filters.set(value);
1408
904
  }
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
905
  set query(value) {
1414
906
  this.#query.set(value);
1415
907
  }
1416
- /**
1417
- * @deprecated Prefer `updatePage(...)`, `fetch(...)`, or `refetchWith(...)`.
1418
- * Direct state mutation bypasses request semantics and should be treated as legacy.
1419
- */
1420
908
  set page(value) {
1421
909
  this.#page.set(value);
1422
910
  }
1423
- /**
1424
- * @deprecated Prefer `updatePageSize(...)`.
1425
- * Direct state mutation bypasses request semantics and should be treated as legacy.
1426
- */
1427
911
  set pageSize(value) {
1428
912
  this.#pageSize.set(value);
1429
913
  }
1430
- /**
1431
- * @deprecated Managed by transport responses. Direct assignment should be treated as legacy.
1432
- */
1433
914
  set totalElements(value) {
1434
915
  this.#totalElements.set(value);
1435
916
  }
@@ -1804,18 +1285,18 @@ class DictStore {
1804
1285
  metaStorage;
1805
1286
  ttlMs;
1806
1287
  revalidate;
1807
- cacheUpdatedAt = signal(null, ...(ngDevMode ? [{ debugName: "cacheUpdatedAt" }] : []));
1288
+ cacheUpdatedAt = signal(null, ...(ngDevMode ? [{ debugName: "cacheUpdatedAt" }] : /* istanbul ignore next */ []));
1808
1289
  /**
1809
1290
  * Search text.
1810
1291
  * With `fixed: true` filters the local cache; with `fixed: false` triggers server search.
1811
1292
  */
1812
- searchText = signal('', ...(ngDevMode ? [{ debugName: "searchText" }] : []));
1293
+ searchText = signal('', ...(ngDevMode ? [{ debugName: "searchText" }] : /* istanbul ignore next */ []));
1813
1294
  debouncedSearchText;
1814
1295
  /**
1815
1296
  * Additional filters for server request (or presets).
1816
1297
  */
1817
- filters = signal({}, ...(ngDevMode ? [{ debugName: "filters" }] : []));
1818
- cachedItems = signal([], ...(ngDevMode ? [{ debugName: "cachedItems" }] : []));
1298
+ filters = signal({}, ...(ngDevMode ? [{ debugName: "filters" }] : /* istanbul ignore next */ []));
1299
+ cachedItems = signal([], ...(ngDevMode ? [{ debugName: "cachedItems" }] : /* istanbul ignore next */ []));
1819
1300
  /**
1820
1301
  * Current list of dictionary items.
1821
1302
  * Source — local cache (fixed=true) or data from `PagedQueryStore`.
@@ -1826,7 +1307,7 @@ class DictStore {
1826
1307
  return this.debouncedSearchText() || !cached.length ? this.#helper.items() : cached;
1827
1308
  }
1828
1309
  return cached.length ? this.filterLocal() : this.#helper.items();
1829
- }, ...(ngDevMode ? [{ debugName: "items" }] : []));
1310
+ }, ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1830
1311
  /**
1831
1312
  * Ready-to-use dropdown options: `{ label, value }`.
1832
1313
  * Respects `maxOptionsSize` for truncating the list.
@@ -1834,9 +1315,9 @@ class DictStore {
1834
1315
  options = computed(() => {
1835
1316
  const options = this.items().map((it) => ({ label: String(it[this.labelKey] ?? ''), value: it[this.valueKey] }));
1836
1317
  return this.maxOptionsSize ? options.slice(0, this.maxOptionsSize) : options;
1837
- }, ...(ngDevMode ? [{ debugName: "options" }] : []));
1318
+ }, ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
1838
1319
  _lastPromise = null;
1839
- _armed = signal(false, ...(ngDevMode ? [{ debugName: "_armed" }] : []));
1320
+ _armed = signal(false, ...(ngDevMode ? [{ debugName: "_armed" }] : /* istanbul ignore next */ []));
1840
1321
  // todo add i18n support
1841
1322
  /**
1842
1323
  * @param apiUrl dictionary endpoint (e.g., `'/api/dicts/countries'`)
@@ -2090,8 +1571,8 @@ class DictLocalStore {
2090
1571
  * Represents the full, unfiltered list of dictionary entries.
2091
1572
  * Used as the base data set for search and option generation.
2092
1573
  */
2093
- items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
2094
- #draftItems = signal([], ...(ngDevMode ? [{ debugName: "#draftItems" }] : []));
1574
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1575
+ #draftItems = signal([], ...(ngDevMode ? [{ debugName: "#draftItems" }] : /* istanbul ignore next */ []));
2095
1576
  /**
2096
1577
  * Computed list of options in `{ label, value }` format.
2097
1578
  *
@@ -2106,7 +1587,7 @@ class DictLocalStore {
2106
1587
  value: it[this.valueKey],
2107
1588
  }));
2108
1589
  return this.maxOptionsSize ? options.slice(0, this.maxOptionsSize) : options;
2109
- }, ...(ngDevMode ? [{ debugName: "options" }] : []));
1590
+ }, ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
2110
1591
  labelKey;
2111
1592
  valueKey;
2112
1593
  maxOptionsSize;
@@ -2191,14 +1672,14 @@ class DictLocalStore {
2191
1672
  class EntityStore {
2192
1673
  idKey;
2193
1674
  sortIds;
2194
- byId = signal({}, ...(ngDevMode ? [{ debugName: "byId" }] : []));
2195
- ids = signal([], ...(ngDevMode ? [{ debugName: "ids" }] : []));
1675
+ byId = signal({}, ...(ngDevMode ? [{ debugName: "byId" }] : /* istanbul ignore next */ []));
1676
+ ids = signal([], ...(ngDevMode ? [{ debugName: "ids" }] : /* istanbul ignore next */ []));
2196
1677
  items = computed(() => {
2197
1678
  const byId = this.byId();
2198
1679
  return this.ids()
2199
1680
  .map((id) => byId[String(id)])
2200
1681
  .filter((item) => item !== undefined);
2201
- }, ...(ngDevMode ? [{ debugName: "items" }] : []));
1682
+ }, ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
2202
1683
  constructor(config) {
2203
1684
  this.idKey = config.idKey;
2204
1685
  this.sortIds = config.sortIds;
@@ -2321,5 +1802,5 @@ class EntityStore {
2321
1802
  * Generated bundle index. Do not edit.
2322
1803
  */
2323
1804
 
2324
- export { AbortError, CacheMissError, DictLocalStore, DictStore, EntityStore, LruCache, PagedQueryStore, RESOURCE_PROFILES, ResourceStore, STATUM_CONFIG, Serializer, SerializerFieldError, createBodySerializer, createQuerySerializer, createResourceProfile, createStrictSerializer, isAbort, provideStatum, storageStrategy };
1805
+ export { AbortError, CacheMissError, DictLocalStore, DictStore, EntityStore, PagedQueryStore, RESOURCE_PROFILES, ResourceStore, STATUM_CONFIG, createBodySerializer, createQuerySerializer, createResourceProfile, createStrictSerializer, isAbort, provideStatum };
2325
1806
  //# sourceMappingURL=reforgium-statum.mjs.map