@reforgium/statum 1.0.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.
@@ -0,0 +1,1401 @@
1
+ import { formatDate, isNullable, isDatePeriod, parseToDate, makeQuery, isNumber, isObject, parseToDatePeriod, parseQueryArray, fillUrlWithParams, concatArray } from '@reforgium/internal';
2
+ import { InjectionToken, inject, signal, computed, DestroyRef, effect, untracked } from '@angular/core';
3
+ import { HttpClient } from '@angular/common/http';
4
+ import { Subject, filter, timer, merge, map } from 'rxjs';
5
+ import { debounce, tap, throttle, finalize } from 'rxjs/operators';
6
+
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
+ /**
13
+ * Universal serializer/deserializer for values used in forms, filters, and DTOs.
14
+ *
15
+ * Supports types: `string | number | boolean | Date | [Date, Date] (period) | array | object | nullable`.
16
+ * Capabilities:
17
+ * - normalize values according to config (trim strings, parse numbers from strings, boolean strings, etc.),
18
+ * - transform date periods into paired keys (`from/to`) or a single joined string,
19
+ * - build/parse a query string (or JSON) to and from an object.
20
+ *
21
+ * Example:
22
+ * ```ts
23
+ * type Filters = { q?: string; active?: boolean; created?: [Date, Date] | null };
24
+ * const s = new Serializer<Filters>({
25
+ * mapPeriod: { transformMode: { mode: 'split', dateFromKeyPostfix: 'From', dateToKeyPostfix: 'To' } }
26
+ * });
27
+ *
28
+ * // -> { q: 'john', createdFrom: '2025-01-01', createdTo: '2025-01-31' }
29
+ * const plain = s.serialize({
30
+ * q: ' john ',
31
+ * active: undefined,
32
+ * created: [new Date('2025-01-01'), new Date('2025-01-31')]
33
+ * });
34
+ *
35
+ * // -> 'q=john&createdFrom=2025-01-01&createdTo=2025-01-31'
36
+ * const qs = s.toQuery({ q: 'john', created: [new Date('2025-01-01'), new Date('2025-01-31')] });
37
+ *
38
+ * // <- { q: 'john', created: [Date, Date] }
39
+ * const parsed = s.deserialize('q=john&createdFrom=2025-01-01&createdTo=2025-01-31');
40
+ * ```
41
+ */
42
+ class Serializer {
43
+ config;
44
+ /**
45
+ * Creates a serializer with a partially overridden configuration.
46
+ * Provide only the options you want to change (the rest are taken from defaults).
47
+ *
48
+ * @param config partial transformation configuration
49
+ */
50
+ constructor(config) {
51
+ this.config = {
52
+ mapString: { trim: true },
53
+ mapNumber: { fromString: false },
54
+ mapBoolean: {},
55
+ mapArray: { concatType: 'comma' },
56
+ mapObject: { deep: true },
57
+ mapDate: { dateFormat: 'yyyy-MM-dd' },
58
+ mapPeriod: {
59
+ dateFormat: 'yyyy-MM-dd',
60
+ transformMode: { mode: 'split', dateFromKeyPostfix: 'From', dateToKeyPostfix: 'To' },
61
+ },
62
+ mapNullable: { remove: true, includeEmptyString: false },
63
+ ...config,
64
+ };
65
+ }
66
+ /**
67
+ * Converts a domain object into a flat serialized representation
68
+ * (ready to send to an API or build a query string).
69
+ *
70
+ * Rules are taken from `config`:
71
+ * — strings can be trimmed (if enabled),
72
+ * — numbers can be converted from strings/numbers,
73
+ * — boolean supports custom true/false representations,
74
+ * — dates are formatted by `dateFormat`,
75
+ * — date periods can be split/joined,
76
+ * — `nullable` can be removed from the result (`remove`) or formatted.
77
+ *
78
+ * @param obj source object
79
+ * @returns a flat dictionary with string/primitive values
80
+ */
81
+ serialize(obj) {
82
+ const result = {};
83
+ for (const [key, value] of Object.entries(obj ?? {})) {
84
+ const fields = this.config.mapFields?.[key];
85
+ if (fields && 'format' in fields) {
86
+ result[key] = fields.format(value, obj);
87
+ continue;
88
+ }
89
+ if (fields?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
90
+ if (this.config.mapNullable.remove) {
91
+ continue;
92
+ }
93
+ }
94
+ if (fields?.type === 'period' || isDatePeriod(value)) {
95
+ const transform = this.config.mapPeriod.transformMode;
96
+ const [from, to] = value;
97
+ if (transform?.mode === 'split') {
98
+ result[`${key}${transform.dateFromKeyPostfix}`] = formatDate(from, this.config.mapPeriod.dateFormat);
99
+ result[`${key}${transform.dateToKeyPostfix}`] = formatDate(to, this.config.mapPeriod.dateFormat);
100
+ continue;
101
+ }
102
+ }
103
+ result[key] = this.serializeElement(value, key);
104
+ }
105
+ return result;
106
+ }
107
+ /**
108
+ * Parse serialized data into a domain object.
109
+ *
110
+ * Source can be:
111
+ * — a query string (`key=value&arr=1,2`) or `JSON.stringify(obj)`,
112
+ * — an already prepared flat object.
113
+ *
114
+ * Transformations are reverse of `serialize`: strings → number/boolean/Date/period,
115
+ * arrays are collected according to strategy (`comma`/`pipe`/`multi`), objects — deeply or as JSON.
116
+ *
117
+ * @param val query string or object
118
+ * @returns a domain object of the specified type
119
+ */
120
+ deserialize = (val) => {
121
+ const data = typeof val === 'string' ? this.parseQuery(val) : val;
122
+ const result = {};
123
+ for (const [key, value] of Object.entries(data ?? {})) {
124
+ const field = this.config.mapFields?.[key];
125
+ if (field && 'parse' in field) {
126
+ result[key] = field.parse(value, data);
127
+ continue;
128
+ }
129
+ if (field?.type === 'nullable' ||
130
+ (field?.type !== 'array' && isNullable(value, this.config.mapNullable?.includeEmptyString))) {
131
+ if (this.config.mapNullable.remove) {
132
+ continue;
133
+ }
134
+ }
135
+ const periodTransform = this.config.mapPeriod.transformMode;
136
+ if (periodTransform.mode === 'split') {
137
+ const isFrom = (key || '').endsWith(periodTransform.dateFromKeyPostfix);
138
+ const isTo = (key || '').endsWith(periodTransform.dateToKeyPostfix);
139
+ const keyJoint = (key || '')
140
+ .replace(periodTransform.dateFromKeyPostfix, '')
141
+ .replace(periodTransform.dateToKeyPostfix, '');
142
+ const field = this.config.mapFields?.[keyJoint];
143
+ // @ts-ignore
144
+ if (field?.['type'] === 'period' && (isFrom || isTo)) {
145
+ result[keyJoint] ??= [null, null];
146
+ if (isFrom) {
147
+ result[keyJoint][0] = parseToDate(value, this.config.mapPeriod.dateFormat);
148
+ }
149
+ else if (isTo) {
150
+ result[keyJoint][1] = parseToDate(value, this.config.mapPeriod.dateFormat);
151
+ }
152
+ continue;
153
+ }
154
+ }
155
+ result[key] = this.deserializeElement(value, key);
156
+ }
157
+ return result;
158
+ };
159
+ /**
160
+ * Build a query string from a domain object using `serialize` rules
161
+ * and the array joining strategy (`concatType`).
162
+ *
163
+ * @param val domain object
164
+ * @returns query string (suitable for URL or history API)
165
+ */
166
+ toQuery = (val) => {
167
+ return makeQuery(this.serialize(val), this.config.mapArray.concatType);
168
+ };
169
+ /**
170
+ * Returns a new serializer instance with a merged configuration.
171
+ * Useful for ad-hoc overrides for a specific call.
172
+ *
173
+ * @param config partial config changes
174
+ * @returns new `Serializer` with the provided `config` applied
175
+ */
176
+ withConfig(config) {
177
+ return new Serializer({ ...this.config, ...config });
178
+ }
179
+ serializeElement(value, key) {
180
+ const fields = this.config.mapFields?.[key || ''];
181
+ if (fields && 'format' in fields) {
182
+ return;
183
+ }
184
+ if (fields?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
185
+ const nullableVal = value || null;
186
+ return this.config.mapNullable.format?.(nullableVal) || this.config.mapNullable.replaceWith || nullableVal;
187
+ }
188
+ if (fields?.type === 'string') {
189
+ return serializeString(this.config, value);
190
+ }
191
+ if (fields?.type === 'number' || isNumber(value, this.config.mapNumber.fromString)) {
192
+ return serializeNumber(this.config, value);
193
+ }
194
+ if (typeof value === 'string') {
195
+ return serializeString(this.config, value);
196
+ }
197
+ if (fields?.type === 'boolean' || typeof value === 'boolean') {
198
+ return serializeBoolean(this.config, value);
199
+ }
200
+ if (fields?.type === 'date' || value instanceof Date) {
201
+ return serializeDate(this.config, value);
202
+ }
203
+ if (fields?.type === 'period' || isDatePeriod(value)) {
204
+ const mapPeriod = this.config.mapPeriod;
205
+ if (mapPeriod.format) {
206
+ return mapPeriod.format(value);
207
+ }
208
+ else {
209
+ const [from, to] = value;
210
+ const transform = mapPeriod.transformMode;
211
+ if (transform.mode === 'join') {
212
+ const period = [
213
+ formatDate(from, this.config.mapPeriod.dateFormat),
214
+ formatDate(to, this.config.mapPeriod.dateFormat),
215
+ ];
216
+ return period.join(transform.concat);
217
+ }
218
+ }
219
+ }
220
+ if (fields?.type === 'array' || Array.isArray(value)) {
221
+ return value.map((it) => this.serializeElement(it));
222
+ }
223
+ if (fields?.type === 'object' || isObject(value)) {
224
+ return (this.config.mapObject.format?.(value) ??
225
+ (this.config.mapObject.deep ? this.serialize(value) : JSON.stringify(value)));
226
+ }
227
+ }
228
+ deserializeElement(value, key) {
229
+ const field = this.config.mapFields?.[key || ''];
230
+ if (field && 'format' in field) {
231
+ return;
232
+ }
233
+ if (field?.type === 'array' || Array.isArray(value)) {
234
+ const array = Array.isArray(value) ? value : [value];
235
+ if (this.config.mapArray.removeNullable) {
236
+ if (!isNullable(value, this.config.mapNullable?.includeEmptyString)) {
237
+ return array.map((it) => this.deserializeElement(it));
238
+ }
239
+ else {
240
+ return;
241
+ }
242
+ }
243
+ else {
244
+ return !value ? [] : array.map((it) => this.deserializeElement(it));
245
+ }
246
+ }
247
+ if (field?.type === 'object') {
248
+ try {
249
+ if (this.config.mapObject.deep) {
250
+ // @ts-ignore
251
+ return this.deserializeElement(value);
252
+ }
253
+ else {
254
+ // @ts-ignore
255
+ return JSON.parse(value);
256
+ }
257
+ }
258
+ catch {
259
+ return value;
260
+ }
261
+ }
262
+ if (field?.type === 'nullable' || isNullable(value, this.config.mapNullable?.includeEmptyString)) {
263
+ return this.config.mapNullable.parse?.(value) || value;
264
+ }
265
+ if (field?.type === 'boolean' ||
266
+ typeof value === 'boolean' ||
267
+ value === this.config.mapBoolean.true ||
268
+ value === this.config.mapBoolean.false) {
269
+ return this.config.mapBoolean.parse?.(value) ?? (value === this.config.mapBoolean.true || value === true);
270
+ }
271
+ const maybeDate = parseToDate(value, this.config.mapDate.dateFormat);
272
+ if (field?.type === 'date' || maybeDate) {
273
+ return this.config.mapDate.parse?.(value) || maybeDate;
274
+ }
275
+ const periodTransform = this.config.mapPeriod.transformMode;
276
+ if (periodTransform.mode === 'join') {
277
+ const maybePeriod = parseToDatePeriod(value, this.config.mapPeriod.dateFormat);
278
+ if (field?.type === 'period' || (maybePeriod || []).some(Boolean)) {
279
+ return this.config.mapPeriod.parse?.(value) || maybePeriod;
280
+ }
281
+ }
282
+ if (field?.type === 'number' || isNumber(value, this.config.mapNumber.fromString)) {
283
+ return this.config.mapNumber.parse?.(value) || Number(String(value).trim());
284
+ }
285
+ if (field?.type === 'string' || typeof value === 'string') {
286
+ return this.config.mapString.parse?.(value) || this.config.mapString.trim ? String(value).trim() : value;
287
+ }
288
+ return value;
289
+ }
290
+ parseQuery(val) {
291
+ try {
292
+ return JSON.parse(val);
293
+ }
294
+ 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;
310
+ }
311
+ }
312
+ }
313
+
314
+ const SERIALIZER_CONFIG = new InjectionToken('SERIALIZER_CONFIG');
315
+
316
+ class LruCache {
317
+ limit;
318
+ map = new Map();
319
+ constructor(limit = 100) {
320
+ this.limit = limit;
321
+ }
322
+ get length() {
323
+ return this.map.size;
324
+ }
325
+ get(key) {
326
+ if (!this.map.has(key)) {
327
+ return null;
328
+ }
329
+ const val = this.map.get(key);
330
+ this.map.delete(key);
331
+ this.map.set(key, val);
332
+ return val;
333
+ }
334
+ set(key, value) {
335
+ if (this.map.has(key)) {
336
+ this.map.delete(key);
337
+ }
338
+ else if (this.map.size === this.limit) {
339
+ const oldest = this.map.keys().next().value;
340
+ oldest !== undefined && this.map.delete(oldest);
341
+ }
342
+ this.map.set(key, value);
343
+ }
344
+ remove(key) {
345
+ return this.map.delete(key);
346
+ }
347
+ clear() {
348
+ this.map.clear();
349
+ }
350
+ has(key) {
351
+ return this.map.has(key);
352
+ }
353
+ keys() {
354
+ return Array.from(this.map.keys());
355
+ }
356
+ values() {
357
+ return Array.from(this.map.values());
358
+ }
359
+ toArray() {
360
+ return Array.from(this.map.values());
361
+ }
362
+ fromArray(entries) {
363
+ this.map.clear();
364
+ for (const [k, v] of entries) {
365
+ this.set(k, v);
366
+ }
367
+ }
368
+ }
369
+
370
+ class LocalStorage {
371
+ get length() {
372
+ return localStorage.length;
373
+ }
374
+ get(key) {
375
+ const raw = localStorage.getItem(String(key));
376
+ const parsed = JSON.parse(raw ?? 'null');
377
+ return parsed ?? null;
378
+ }
379
+ set(key, value) {
380
+ const str = JSON.stringify(value);
381
+ localStorage.setItem(String(key), str);
382
+ }
383
+ remove(key) {
384
+ return localStorage.removeItem(String(key));
385
+ }
386
+ clear() {
387
+ return localStorage.clear();
388
+ }
389
+ }
390
+
391
+ class MemoryStorage {
392
+ cache = new Map();
393
+ get length() {
394
+ return this.cache.size;
395
+ }
396
+ get(key) {
397
+ return this.cache.get(key) ?? null;
398
+ }
399
+ set(key, value) {
400
+ this.cache.set(key, value);
401
+ }
402
+ remove(key) {
403
+ this.cache.delete(key);
404
+ }
405
+ clear() {
406
+ this.cache.clear();
407
+ }
408
+ }
409
+
410
+ class SessionStorage {
411
+ get length() {
412
+ return sessionStorage.length;
413
+ }
414
+ get(key) {
415
+ const raw = sessionStorage.getItem(String(key));
416
+ const parsed = JSON.parse(raw ?? 'null');
417
+ return parsed ?? null;
418
+ }
419
+ set(key, value) {
420
+ const str = JSON.stringify(value);
421
+ sessionStorage.setItem(String(key), str);
422
+ }
423
+ remove(key) {
424
+ return sessionStorage.removeItem(String(key));
425
+ }
426
+ clear() {
427
+ return sessionStorage.clear();
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Factory for data storage strategies.
433
+ *
434
+ * Returns a `StorageInterface` implementation depending on the selected strategy:
435
+ * - `'memory'` — in-memory storage (for the session lifetime);
436
+ * - `'session'` — `sessionStorage`, lives until the tab is closed;
437
+ * - `'persist'` — `localStorage`, persists between sessions;
438
+ * - `'lru'` — size-limited cache (Least Recently Used).
439
+ *
440
+ * Used to choose an appropriate storage implementation
441
+ * depending on the scenario: temporary data, long-term, cache, etc.
442
+ *
443
+ * @param strategy storage strategy type (`memory`, `session`, `persist`, `lru`)
444
+ * @returns instance implementing `StorageInterface<Key, Type>`
445
+ */
446
+ const storageStrategy = (strategy) => {
447
+ const fabrics = {
448
+ memory: () => new MemoryStorage(),
449
+ session: () => new SessionStorage(),
450
+ persist: () => new LocalStorage(),
451
+ lru: () => new LruCache(),
452
+ };
453
+ return fabrics[strategy]();
454
+ };
455
+
456
+ /**
457
+ * Error thrown when requested data is missing in the cache.
458
+ *
459
+ * Used by the `cache-only` strategy when data is not found
460
+ * and a network request is not allowed.
461
+ *
462
+ * Example:
463
+ * ```ts
464
+ * try {
465
+ * await store.get({ query }, { strategy: 'cache-only' });
466
+ * } catch (e) {
467
+ * if (e instanceof CacheMissError) console.warn(e.key, 'not found in cache');
468
+ * }
469
+ * ```
470
+ */
471
+ class CacheMissError extends Error {
472
+ key;
473
+ constructor(key) {
474
+ super(`Cache miss for key: ${key}`);
475
+ this.key = key;
476
+ this.name = 'CacheMissError';
477
+ }
478
+ }
479
+ /**
480
+ * Error indicating an aborted (canceled) request.
481
+ *
482
+ * May be thrown by `abort()` or `abortAll()` in `ResourceStore`.
483
+ * Usually does not require handling as an error — used to ignore canceled operations.
484
+ */
485
+ class AbortError extends Error {
486
+ constructor(message = 'aborted') {
487
+ super(message);
488
+ this.name = 'AbortError';
489
+ }
490
+ }
491
+ /**
492
+ * Checks whether the exception is an `AbortError` (including compatible objects).
493
+ *
494
+ * @param e — any value that may be an error
495
+ * @returns `true` if it's an `AbortError`
496
+ */
497
+ function isAbort(e) {
498
+ return (e instanceof AbortError ||
499
+ (typeof e === 'object' && e != null && e?.name === 'AbortError' && e?.message === 'aborted'));
500
+ }
501
+ function joinUrl(base, path) {
502
+ return base ? `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}` : path;
503
+ }
504
+ function buildKey(method, path) {
505
+ return `${method}|${path}`;
506
+ }
507
+
508
+ /**
509
+ * Per-key task scheduler with `debounce`/`throttle` and result deduplication.
510
+ *
511
+ * Allows applying delays and rate limiting separately for each key.
512
+ * All `schedule` calls for the same key are coalesced: consumers receive a single shared `Promise`
513
+ * that resolves with the value of the last started task.
514
+ *
515
+ * Suitable for API requests, auto-saving forms, incremental search, etc.
516
+ *
517
+ * Example:
518
+ * ```ts
519
+ * const ks = new KeyedScheduler();
520
+ * // these three calls collapse into one request after 300ms
521
+ * ks.schedule('users:list', 'debounce', 300, () => http.get<User[]>('/api/users'));
522
+ * ks.schedule('users:list', 'debounce', 300, () => http.get<User[]>('/api/users'));
523
+ * const users = await ks.schedule('users:list', 'debounce', 300, () => http.get<User[]>('/api/users'));
524
+ * ```
525
+ */
526
+ class KeyedScheduler {
527
+ channels = new Map();
528
+ /**
529
+ * Schedule task execution for the specified key.
530
+ *
531
+ * Multiple calls with the same key are merged according to the mode:
532
+ * `debounce` — runs the last task after a pause;
533
+ * `throttle` — runs no more often than the specified delay (uses the last accumulated).
534
+ *
535
+ * All waiters receive the result of a single execution.
536
+ *
537
+ * @param key logical channel key (e.g., `'users:list'`)
538
+ * @param mode delay mode (`'debounce' | 'throttle'`)
539
+ * @param delay delay in milliseconds
540
+ * @param exec Observable factory with the actual work (HTTP request, etc.)
541
+ * @returns Promise with the result of `exec`
542
+ */
543
+ schedule(key, mode, delay, exec) {
544
+ const channel = this.ensureChannel(key);
545
+ const promise = new Promise((resolve, reject) => {
546
+ channel.waiters.push({ resolve, reject });
547
+ });
548
+ channel.lastExec = exec;
549
+ if (delay <= 0) {
550
+ this.startExecution(channel);
551
+ }
552
+ else {
553
+ channel.subject.next({ mode, delay, exec });
554
+ }
555
+ return promise;
556
+ }
557
+ /**
558
+ * Cancels scheduled/running tasks for a key.
559
+ * All pending `Promise`s will be rejected with `AbortError` (or the provided reason).
560
+ *
561
+ * @param key channel key
562
+ * @param reason cancellation reason (defaults to `AbortError('aborted')`)
563
+ */
564
+ cancel(key, reason = new AbortError()) {
565
+ const ch = this.channels.get(key);
566
+ if (!ch) {
567
+ return;
568
+ }
569
+ ch.timerSub?.unsubscribe();
570
+ ch.inflight?.unsubscribe();
571
+ ch.inflight = undefined;
572
+ ch.subject.complete();
573
+ const waiters = ch.waiters.splice(0);
574
+ waiters.forEach((w) => w.reject(reason));
575
+ this.channels.delete(key);
576
+ }
577
+ /**
578
+ * Cancels all channels and their pending tasks.
579
+ *
580
+ * @param reason cancellation reason (defaults to `AbortError('aborted')`)
581
+ */
582
+ cancelAll(reason = new AbortError()) {
583
+ for (const key of Array.from(this.channels.keys())) {
584
+ this.cancel(key, reason);
585
+ }
586
+ }
587
+ ensureChannel(key) {
588
+ let ch = this.channels.get(key);
589
+ if (ch) {
590
+ return ch;
591
+ }
592
+ const subject = new Subject();
593
+ ch = { subject, waiters: [] };
594
+ this.channels.set(key, ch);
595
+ const debounced$ = subject.pipe(filter((t) => t.mode === 'debounce'), debounce((t) => timer(t.delay)), tap(() => this.startExecution(ch)));
596
+ const throttled$ = subject.pipe(filter((t) => t.mode === 'throttle'), throttle((t) => timer(t.delay), { leading: false, trailing: true }), tap(() => this.startExecution(ch)));
597
+ ch.timerSub = merge(debounced$, throttled$)
598
+ .pipe(finalize(() => this.channels.delete(key)))
599
+ .subscribe();
600
+ return ch;
601
+ }
602
+ startExecution(ch) {
603
+ const exec = ch.lastExec;
604
+ if (!exec) {
605
+ return;
606
+ }
607
+ ch.inflight?.unsubscribe();
608
+ ch.inflight = exec().subscribe({
609
+ next: (val) => {
610
+ const waiters = ch.waiters.splice(0);
611
+ waiters.forEach((w) => w.resolve(val));
612
+ },
613
+ error: (err) => {
614
+ const waiters = ch.waiters.splice(0);
615
+ waiters.forEach((w) => w.reject(err));
616
+ },
617
+ complete: () => (ch.inflight = undefined),
618
+ });
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Store for REST resources with caching and request deduplication.
624
+ *
625
+ * Provides reactive access to current value, status, and error;
626
+ * supports cache strategies (cache-first / cache-only / network-first),
627
+ * TTL, delays (debounce/throttle), request cancellation, and auto-serialization of query/payload.
628
+ *
629
+ * Example:
630
+ * ```ts
631
+ * const store = new ResourceStore({ GET: '/users/:id' }, { baseUrl: '/api', ttlMs: 30_000 });
632
+ *
633
+ * effect(() => {
634
+ * if (store.loading()) showSpinner();
635
+ * if (store.error()) showError(store.error());
636
+ * const user = store.value();
637
+ * });
638
+ *
639
+ * await store.get({ params: { id: '42' }, query: { expand: ['roles'] } }, { strategy: 'cache-first', dedupe: true });
640
+ * ```
641
+ */
642
+ class ResourceStore {
643
+ http = inject(HttpClient);
644
+ serializer = new Serializer(inject(SERIALIZER_CONFIG, { optional: true }) ?? {});
645
+ #value = signal(null, ...(ngDevMode ? [{ debugName: "#value" }] : []));
646
+ #status = signal('idle', ...(ngDevMode ? [{ debugName: "#status" }] : []));
647
+ #error = signal(null, ...(ngDevMode ? [{ debugName: "#error" }] : []));
648
+ /**
649
+ * Current resource value.
650
+ * Returns `null` if no data yet or the request failed.
651
+ */
652
+ value = this.#value.asReadonly();
653
+ /**
654
+ * Current loading status of the resource: `idle | loading | stale | success | error`.
655
+ */
656
+ status = this.#status.asReadonly();
657
+ /**
658
+ * Last error (if any). Otherwise `null`.
659
+ */
660
+ error = this.#error.asReadonly();
661
+ /**
662
+ * Convenience loading flag: `true` when `loading` or `stale`.
663
+ * Useful for spinners and disabling buttons.
664
+ */
665
+ loading = computed(() => ['loading', 'stale'].includes(this.#status()), ...(ngDevMode ? [{ debugName: "loading" }] : []));
666
+ routes;
667
+ opts;
668
+ entries = new Map();
669
+ scheduler = new KeyedScheduler();
670
+ /**
671
+ * @param routes Map of path templates for methods (`GET/POST/...` → `/users/:id`)
672
+ * @param opts Global options (baseUrl, ttlMs, delay, delayMode, presetQueries/payload, etc.)
673
+ */
674
+ constructor(routes, opts = {}) {
675
+ this.routes = routes;
676
+ this.opts = opts;
677
+ }
678
+ /**
679
+ * Perform a GET request.
680
+ *
681
+ * Supports cache strategies (`strategy`), TTL (`ttlMs`), revalidation,
682
+ * deduplication (`dedupe`), and response parsing (`parseResponse`).
683
+ *
684
+ * @param args { params, query }
685
+ * @param cfg Call settings (strategy, ttlMs, revalidate, parseResponse, delay, delayMode, dedupe, promote)
686
+ * @returns Deserialized `Data`
687
+ */
688
+ async get(args, cfg = {}) {
689
+ const url = this.buildUrl('GET', args);
690
+ const key = buildKey('GET', url);
691
+ const query = this.prepareQuery(args);
692
+ const entry = this.ensureEntry(key);
693
+ const strategy = cfg.strategy ?? 'network-first';
694
+ const ttlMs = cfg.ttlMs ?? this.opts.ttlMs ?? 0;
695
+ const fresh = entry.updatedAt != null && Date.now() - entry.updatedAt < ttlMs;
696
+ if (cfg.dedupe && entry.inflight) {
697
+ return entry.inflight;
698
+ }
699
+ if (strategy === 'cache-only') {
700
+ if (fresh && entry.data != null) {
701
+ this.promoteCurrent(entry, cfg.promote);
702
+ return entry.data;
703
+ }
704
+ throw new CacheMissError(key);
705
+ }
706
+ if (strategy === 'cache-first' && fresh && !cfg.revalidate && entry.data != null) {
707
+ this.promoteCurrent(entry, cfg.promote);
708
+ return entry.data;
709
+ }
710
+ const delay = cfg.delay ?? this.opts.delay ?? 0;
711
+ const mode = cfg.delayMode ?? this.opts.delayMode ?? 'debounce';
712
+ entry.status = strategy === 'cache-first' && fresh && entry.data != null ? 'stale' : 'loading';
713
+ this.promoteCurrent(entry, cfg.promote);
714
+ const task = this.scheduler.schedule(key, mode, delay, this.exec$({
715
+ req$: this.http.get(url, { params: query }),
716
+ entry,
717
+ promote: cfg.promote,
718
+ parseFn: cfg.parseResponse,
719
+ }));
720
+ cfg.dedupe && (entry.inflight = task);
721
+ void task.catch((e) => {
722
+ if (!isAbort(e)) {
723
+ throw e;
724
+ }
725
+ });
726
+ return task;
727
+ }
728
+ /**
729
+ * POST request.
730
+ *
731
+ * @param args { params, query, payload }
732
+ * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
733
+ * @returns API response (by default — as returned by the server)
734
+ */
735
+ async post(args, cfg = {}) {
736
+ return await this.callApi('POST', args, cfg);
737
+ }
738
+ /**
739
+ * PUT request (full resource update).
740
+ *
741
+ * @param args { params, query, payload }
742
+ * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
743
+ * @returns API response (by default — as returned by the server)
744
+ */
745
+ async put(args, cfg = {}) {
746
+ return await this.callApi('PUT', args, cfg);
747
+ }
748
+ /**
749
+ * PATCH request (partial update).
750
+ *
751
+ * @param args { params, query, payload }
752
+ * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
753
+ * @returns API response (by default — as returned by the server)
754
+ */
755
+ async patch(args, cfg = {}) {
756
+ return await this.callApi('PATCH', args, cfg);
757
+ }
758
+ /**
759
+ * DELETE request.
760
+ *
761
+ * @param args { params, query, payload }
762
+ * @param cfg Call settings (parseResponse, delay, delayMode, dedupe, promote)
763
+ * @returns API response (by default — as returned by the server)
764
+ */
765
+ async delete(args, cfg = {}) {
766
+ return await this.callApi('DELETE', args, cfg);
767
+ }
768
+ /**
769
+ * Cancel scheduled/running requests for a specific call.
770
+ *
771
+ * @param method HTTP method
772
+ * @param args Arguments used to build the URL (params, query)
773
+ * @param reason Cancellation reason (optional)
774
+ */
775
+ abort(method, args, reason) {
776
+ const url = this.buildUrl(method, args);
777
+ const key = buildKey(method, url);
778
+ this.scheduler.cancel?.(key, reason);
779
+ }
780
+ /**
781
+ * Cancel all scheduled/running requests for this store.
782
+ *
783
+ * @param reason Cancellation reason (optional)
784
+ */
785
+ abortAll(reason) {
786
+ this.scheduler.cancelAll?.(reason);
787
+ }
788
+ ensureEntry(key) {
789
+ let entry = this.entries.get(key);
790
+ if (!entry) {
791
+ entry = { data: null, status: 'idle', error: null, updatedAt: null };
792
+ this.entries.set(key, entry);
793
+ }
794
+ return entry;
795
+ }
796
+ async callApi(method, args, config = {}) {
797
+ const url = this.buildUrl(method, args);
798
+ const key = buildKey(method, url);
799
+ const query = this.prepareQuery(args);
800
+ const payload = { ...(this.opts.presetPayload || {}), ...(args.payload || {}) };
801
+ const serializedPayload = this.serializer.serialize(payload);
802
+ const entry = this.ensureEntry(key);
803
+ if (config.dedupe && entry.inflight) {
804
+ return entry.inflight;
805
+ }
806
+ const delay = config.delay ?? this.opts.delay ?? 0;
807
+ const mode = config.delayMode ?? this.opts.delayMode ?? 'debounce';
808
+ entry.status = 'loading';
809
+ this.promoteCurrent(entry, config.promote);
810
+ let req$;
811
+ if (method === 'DELETE') {
812
+ req$ = this.http.delete(url, { body: serializedPayload, params: query });
813
+ }
814
+ else {
815
+ // @ts-ignore
816
+ req$ = this.http[method.toLowerCase()](url, serializedPayload, { params: query });
817
+ }
818
+ const task = this.scheduler.schedule(key, mode, delay, this.exec$({ req$, entry, promote: config.promote, parseFn: config.parseResponse }));
819
+ config.dedupe && (entry.inflight = task);
820
+ void task.catch((e) => {
821
+ if (!isAbort(e)) {
822
+ throw e;
823
+ }
824
+ });
825
+ return task;
826
+ }
827
+ buildUrl(method, args) {
828
+ const tpl = this.routes[method];
829
+ if (!tpl) {
830
+ throw new Error(`${method} route not configured`);
831
+ }
832
+ const path = fillUrlWithParams(tpl, args.params);
833
+ return joinUrl(this.opts.baseUrl, path);
834
+ }
835
+ prepareQuery(args) {
836
+ const mergedQuery = { ...(this.opts.presetQueries || {}), ...(args.query || {}) };
837
+ return this.serializer.serialize(mergedQuery);
838
+ }
839
+ exec$ = ({ req$, entry, promote = true, parseFn }) => () => req$.pipe(map((data) => (parseFn ? parseFn(data) : data)), tap({
840
+ next: (data) => {
841
+ promote && (entry.data = data);
842
+ entry.status = 'success';
843
+ entry.updatedAt = Date.now();
844
+ entry.error = null;
845
+ this.promoteCurrent(entry, promote);
846
+ },
847
+ error: (err) => {
848
+ entry.error = err;
849
+ entry.status = 'error';
850
+ this.promoteCurrent(entry, promote);
851
+ },
852
+ }), finalize(() => {
853
+ entry.inflight = undefined;
854
+ this.promoteCurrent(entry, promote);
855
+ }));
856
+ promoteCurrent(entry, promote = true) {
857
+ if (!promote) {
858
+ return;
859
+ }
860
+ this.#value.set(entry.data ?? null);
861
+ this.#status.set(entry.status);
862
+ this.#error.set(entry.error);
863
+ }
864
+ }
865
+
866
+ const PDS_CONFIG = new InjectionToken('RE_PDS_CONFIG');
867
+
868
+ // noinspection ES6PreferShortImport
869
+ /**
870
+ * Store for paginated data (tables/lists) with per-page cache and unified requests.
871
+ *
872
+ * Provides:
873
+ * - reactive signals: `items`, `loading`, `cached`;
874
+ * - methods to control page/size/filters/sorting;
875
+ * - optional LRU cache by pages;
876
+ * - configurable transport (GET/POST/PATCH/…).
877
+ *
878
+ * Example:
879
+ * ```ts
880
+ * const ds = new PaginatedDataStore<User>('/users', { method: 'GET', hasCache: true });
881
+ * await ds.updatePage(0); // load the first page
882
+ * effect(() => console.log(ds.items(), ds.loading()));
883
+ * ```
884
+ */
885
+ class PaginatedDataStore {
886
+ route;
887
+ config;
888
+ defaultConfig = inject(PDS_CONFIG);
889
+ #transport;
890
+ #cache;
891
+ /** Current page data (reactive). */
892
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
893
+ /** Merged cache of pages (flat list) — handy for search/export. */
894
+ cached = signal([], ...(ngDevMode ? [{ debugName: "cached" }] : []));
895
+ /** Loading flag of the current operation. */
896
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
897
+ /** Current filters (applied to requests). */
898
+ filters = {};
899
+ /** Current sorting (`field,asc|desc`). */
900
+ sort;
901
+ /** Current page index (0-based). */
902
+ page = 0;
903
+ /** Default page size. */
904
+ pageSize = 20;
905
+ /** Total number of elements reported by the server. */
906
+ totalElements = 0;
907
+ /**
908
+ * @param route Resource URL pattern (e.g., `'/users'`)
909
+ * @param config Store behavior: method, cache, request/response parsers, debounce, presets, etc.
910
+ */
911
+ constructor(route, config = {}) {
912
+ this.route = route;
913
+ this.config = config;
914
+ this.#cache = new LruCache(config.cacheSize);
915
+ this.applyConfig(config);
916
+ this.applyPresetMeta();
917
+ this.initTransport();
918
+ inject(DestroyRef).onDestroy(() => this.destroy());
919
+ }
920
+ /** Force reload current data (with the same page/filters/sort). */
921
+ refresh() {
922
+ return this.#fetchItems({});
923
+ }
924
+ /**
925
+ * Switch page with a request.
926
+ * If cache is enabled and the page is present in LRU — returns it from cache.
927
+ * @param page page index (0-based)
928
+ * @param ignoreCache ignore cache and fetch from network
929
+ */
930
+ updatePage = (page = this.page, ignoreCache = false) => {
931
+ if (this.config.hasCache && this.#cache.has(page) && !ignoreCache) {
932
+ const cached = this.#cache.get(page);
933
+ this.items.set(cached);
934
+ this.page = page;
935
+ return Promise.resolve(cached);
936
+ }
937
+ else {
938
+ return this.#fetchItems({ page });
939
+ }
940
+ };
941
+ /**
942
+ * Change page size (will go to the first page) and fetch.
943
+ * @param size new size (rows per page)
944
+ */
945
+ updatePageSize = (size = this.pageSize) => {
946
+ return this.#fetchItems({ page: 0, size });
947
+ };
948
+ /**
949
+ * Update filters (goes to the first page) and fetch.
950
+ * Previous cache is cleared.
951
+ */
952
+ updateFilters = (filters) => {
953
+ this.#cache.clear();
954
+ this.cached.set([]);
955
+ return this.#fetchItems({ page: 0, filters: { ...this.filters, ...filters } });
956
+ };
957
+ /**
958
+ * Update state from table events (PrimeNG, etc.) and fetch.
959
+ * Supports `page/first/rows/sortField/sortOrder`.
960
+ */
961
+ updateQuery = ({ page: pageNum, first = 0, rows = 0, sortOrder, sortField }) => {
962
+ const page = (pageNum ?? (first && rows && Math.floor(first / rows))) || 0;
963
+ const sort = sortField ? `${sortField},${sortOrder === 1 ? 'asc' : 'desc'}` : '';
964
+ return this.#fetchItems({ page, sort });
965
+ };
966
+ /**
967
+ * Set filters from scratch (goes to the first page and resets sorting) and fetch.
968
+ * Useful for quick presets.
969
+ */
970
+ setFilters = (filters = {}) => {
971
+ this.#cache.clear();
972
+ this.cached.set([]);
973
+ return this.#fetchItems({ page: 0, filters, sort: undefined });
974
+ };
975
+ /**
976
+ * Change resource route (resets page, cache, and presets) without fetching.
977
+ * Useful when one store should work with different endpoints.
978
+ */
979
+ updateRoute = (route) => {
980
+ this.route = route;
981
+ this.page = 0;
982
+ this.pageSize = 20;
983
+ this.totalElements = 0;
984
+ this.#cache.clear();
985
+ this.cached.set([]);
986
+ this.initTransport();
987
+ };
988
+ /**
989
+ * Update store config on the fly (without re-creation).
990
+ * Does not trigger loading automatically.
991
+ */
992
+ updateConfig = (config) => {
993
+ this.config = { ...this.config, ...config };
994
+ this.applyPresetMeta();
995
+ };
996
+ /**
997
+ * Copy configuration and presets from another store of the same type.
998
+ */
999
+ copy(helper) {
1000
+ this.applyConfig(helper.config);
1001
+ this.applyPresetMeta();
1002
+ }
1003
+ /** Clean up resources and cancel active requests. Called automatically onDestroy. */
1004
+ destroy() {
1005
+ try {
1006
+ this.#transport?.abortAll?.('PaginatedDataStore destroyed');
1007
+ }
1008
+ catch (e) {
1009
+ if (!isAbort(e)) {
1010
+ throw e;
1011
+ }
1012
+ }
1013
+ }
1014
+ #fetchItems = async ({ page = this.page, size = this.pageSize, sort = this.sort, filters = this.filters, }) => {
1015
+ const query = this.parseQuery({ page, size, sort, filters });
1016
+ this.loading.set(true);
1017
+ this.#transport.abortAll();
1018
+ try {
1019
+ const response = await this.runTransport(filters, query);
1020
+ const parsed = this.parseResponseData(response);
1021
+ this.page = page;
1022
+ this.sort = sort;
1023
+ this.filters = filters;
1024
+ this.pageSize = parsed.pageable?.pageSize || size;
1025
+ this.totalElements = parsed.totalElements;
1026
+ this.items.set(parsed.content);
1027
+ this.updateCache(parsed.content);
1028
+ return parsed.content;
1029
+ }
1030
+ catch (e) {
1031
+ if (isAbort(e)) {
1032
+ return;
1033
+ }
1034
+ throw e;
1035
+ }
1036
+ finally {
1037
+ this.loading.set(false);
1038
+ }
1039
+ };
1040
+ initTransport() {
1041
+ this.#transport = new ResourceStore({ [this.config.method || 'GET']: this.route }, {
1042
+ delay: this.config.debounceTime,
1043
+ delayMode: 'debounce',
1044
+ presetQueries: { page: this.page, size: this.pageSize, sort: this.sort },
1045
+ presetPayload: this.filters,
1046
+ });
1047
+ }
1048
+ async runTransport(payload, query) {
1049
+ if (this.config.method === 'GET') {
1050
+ return this.#transport.get({ query }, { promote: false, dedupe: true });
1051
+ }
1052
+ const method = this.config.method?.toLowerCase() || 'post';
1053
+ // @ts-ignore
1054
+ return this.#transport[method]({ query, payload }, { promote: false, dedupe: true });
1055
+ }
1056
+ parseQuery({ page = 0, size, sort, filters }) {
1057
+ const method = this.config.method || 'GET';
1058
+ const requestPayload = { page, size, ...(method === 'GET' ? filters : {}) };
1059
+ const rawQueries = this.config.parseRequest?.({ ...requestPayload, sort }) || requestPayload;
1060
+ const queries = {};
1061
+ Object.entries(rawQueries).forEach(([key, value]) => {
1062
+ if (Array.isArray(value)) {
1063
+ queries[key] = concatArray(value, 'comma');
1064
+ }
1065
+ else if (!isNullable(value)) {
1066
+ queries[key] = value;
1067
+ }
1068
+ });
1069
+ sort && (queries['sort'] = sort);
1070
+ return queries;
1071
+ }
1072
+ parseResponseData = (data) => {
1073
+ if (this.config?.parseResponse) {
1074
+ return this.config.parseResponse(data);
1075
+ }
1076
+ if (Array.isArray(data)) {
1077
+ return this.parseFlatArray(data);
1078
+ }
1079
+ return data;
1080
+ };
1081
+ updateCache = (data) => {
1082
+ if (this.config.hasCache) {
1083
+ this.#cache.set(this.page, data);
1084
+ }
1085
+ this.cached.set(Array.from(this.#cache.values()).flat());
1086
+ };
1087
+ parseFlatArray(data) {
1088
+ return { content: data, totalElements: data.length };
1089
+ }
1090
+ applyConfig(config) {
1091
+ this.config = {
1092
+ ...config,
1093
+ method: config.method || this.defaultConfig.defaultMethod || 'POST',
1094
+ presetQuery: config.presetQuery || this.defaultConfig.defaultQuery,
1095
+ parseRequest: config.parseRequest || this.defaultConfig.defaultParseRequest,
1096
+ debounceTime: config.debounceTime || 0,
1097
+ hasCache: config.hasCache === undefined ? this.defaultConfig.defaultHasCache : config.hasCache,
1098
+ cacheSize: config.cacheSize || this.defaultConfig.defaultCacheSize || 5,
1099
+ };
1100
+ }
1101
+ applyPresetMeta() {
1102
+ this.filters = this.config.presetFilters || {};
1103
+ this.pageSize = this.config.presetQuery?.pageSize || 20;
1104
+ this.page = this.config.presetQuery?.page || 0;
1105
+ this.sort = this.config.presetQuery?.sort;
1106
+ }
1107
+ }
1108
+
1109
+ var _a;
1110
+ /**
1111
+ * Dictionary store (select/options) with local LRU cache and optional persistence.
1112
+ *
1113
+ * Provides:
1114
+ * - reactive `items` (current list) and `options` (label/value),
1115
+ * - local cache filtering (`fixed: true`) or server-side search by name (`fixed: false`),
1116
+ * - preload and restore cache from `storage` (`localStorage/session/LRU`),
1117
+ * - smooth integration with `PaginatedDataStore` for server fetching.
1118
+ *
1119
+ * Example:
1120
+ * ```ts
1121
+ * const dict = new DictStore<Country>('/countries', 'countries', { labelKey: 'name', valueKey: 'code' });
1122
+ * dict.search('ki'); // filters locally (fixed=true) or goes to server (fixed=false)
1123
+ * effect(() => console.log(dict.options())); // [{label:'Kyrgyzstan', value:'KG'}, ...]
1124
+ * ```
1125
+ */
1126
+ class DictStore {
1127
+ apiUrl;
1128
+ storageKey;
1129
+ static MAX_CACHE_SIZE = 400;
1130
+ #helper;
1131
+ #lru = new LruCache(_a.MAX_CACHE_SIZE);
1132
+ #effectRefs = [];
1133
+ fixed = true;
1134
+ labelKey;
1135
+ valueKey;
1136
+ maxOptionsSize;
1137
+ storage;
1138
+ /**
1139
+ * Search text.
1140
+ * With `fixed: true` filters the local cache; with `fixed: false` triggers server search.
1141
+ */
1142
+ searchText = signal('', ...(ngDevMode ? [{ debugName: "searchText" }] : []));
1143
+ /**
1144
+ * Additional filters for server request (or presets).
1145
+ */
1146
+ filters = signal({}, ...(ngDevMode ? [{ debugName: "filters" }] : []));
1147
+ cachedItems = signal([], ...(ngDevMode ? [{ debugName: "cachedItems" }] : []));
1148
+ /**
1149
+ * Current list of dictionary items.
1150
+ * Source — local cache (fixed=true) or data from `PaginatedDataStore`.
1151
+ */
1152
+ items = computed(() => {
1153
+ const cached = this.cachedItems();
1154
+ if (!this.fixed) {
1155
+ return this.searchText() || !cached.length ? this.#helper.items() : cached;
1156
+ }
1157
+ return cached.length ? this.filterLocal() : this.#helper.items();
1158
+ }, ...(ngDevMode ? [{ debugName: "items" }] : []));
1159
+ /**
1160
+ * Ready-to-use dropdown options: `{ label, value }`.
1161
+ * Respects `maxOptionsSize` for truncating the list.
1162
+ */
1163
+ options = computed(() => {
1164
+ const options = this.items().map((it) => ({ label: String(it[this.labelKey] ?? ''), value: it[this.valueKey] }));
1165
+ return this.maxOptionsSize ? options.slice(0, this.maxOptionsSize) : options;
1166
+ }, ...(ngDevMode ? [{ debugName: "options" }] : []));
1167
+ _lastPromise = null;
1168
+ _armed = false;
1169
+ // todo add i18n support
1170
+ /**
1171
+ * @param apiUrl dictionary endpoint (e.g., `'/api/dicts/countries'`)
1172
+ * @param storageKey key for saving cache in the selected strategy
1173
+ * @param cfg behavior (fixed/search, parsers, label/value keys, cache strategy, etc.)
1174
+ */
1175
+ constructor(apiUrl, storageKey, { method, autoLoad = true, presetFilters, parseResponse, parseRequest, debounceTime, fixed = true, maxOptionsSize, labelKey = 'name', valueKey = 'code', cacheStrategy = 'persist', }) {
1176
+ this.apiUrl = apiUrl;
1177
+ this.storageKey = storageKey;
1178
+ this.#helper = new PaginatedDataStore(this.apiUrl, {
1179
+ method: method,
1180
+ hasCache: false,
1181
+ presetFilters: { name: '', ...presetFilters },
1182
+ parseResponse: parseResponse,
1183
+ parseRequest: parseRequest,
1184
+ debounceTime: debounceTime,
1185
+ });
1186
+ this.fixed = fixed;
1187
+ this.labelKey = labelKey;
1188
+ this.valueKey = valueKey;
1189
+ this.maxOptionsSize = maxOptionsSize;
1190
+ this._armed = autoLoad;
1191
+ if (cacheStrategy !== 'memory') {
1192
+ this.storage = storageStrategy(cacheStrategy);
1193
+ }
1194
+ this.restoreFromStorage();
1195
+ this.#effectRefs.push(effect(() => {
1196
+ const incoming = this.#helper.items();
1197
+ if (incoming.length === 0) {
1198
+ return;
1199
+ }
1200
+ untracked(() => this.mergeIntoCache(incoming));
1201
+ }));
1202
+ this.#effectRefs.push(effect(() => {
1203
+ if (!this._armed) {
1204
+ return;
1205
+ }
1206
+ const rest = this.filters();
1207
+ if (!this.fixed) {
1208
+ const query = this.searchText().trim();
1209
+ this._lastPromise = untracked(() => this.#helper.updateFilters({ name: query, ...rest }));
1210
+ }
1211
+ else if (!this.cachedItems().length) {
1212
+ this._lastPromise = untracked(() => this.#helper.updateFilters({ name: '', ...rest }));
1213
+ }
1214
+ }));
1215
+ }
1216
+ /** Восстановить кэш из выбранного хранилища (`persist`/`session`/`lru`/`memory`). */
1217
+ restoreCache() {
1218
+ this.restoreFromStorage();
1219
+ }
1220
+ /**
1221
+ * Установить запрос и фильтры.
1222
+ * При `fixed: false` инициирует серверный поиск; при `fixed: true` — локальную фильтрацию.
1223
+ */
1224
+ search = (name = '', filters = {}) => {
1225
+ this._armed = true;
1226
+ this.searchText.set(name);
1227
+ this.filters.set(filters);
1228
+ };
1229
+ /**
1230
+ * Найти отображаемую метку по значению (обычно для обратного биндинга).
1231
+ * @returns строка метки или `undefined`, если не найдено
1232
+ */
1233
+ findLabel = (value) => {
1234
+ for (const item of this.cachedItems()) {
1235
+ if (item[this.valueKey] === value) {
1236
+ return String(item[this.labelKey] ?? undefined);
1237
+ }
1238
+ }
1239
+ return undefined;
1240
+ };
1241
+ /**
1242
+ * Предзагрузить элементы словаря в локальный кэш.
1243
+ * Удобно для SSR/статических списков/быстрых пресетов.
1244
+ *
1245
+ * @param items список элементов
1246
+ * @param opts `{ replace?: true }` — полностью заменить текущий кэш
1247
+ */
1248
+ preload = (items, opts) => {
1249
+ opts?.replace && this.#lru.clear();
1250
+ let changed = false;
1251
+ for (const it of items) {
1252
+ const key = this.keyOf(it);
1253
+ if (!key) {
1254
+ continue;
1255
+ }
1256
+ const existed = this.#lru.has(key);
1257
+ this.#lru.set(key, it);
1258
+ !existed && (changed = true);
1259
+ }
1260
+ if (changed || opts?.replace) {
1261
+ const arr = this.#lru.toArray();
1262
+ this.cachedItems.set(arr);
1263
+ this.storage?.set(this.storageKey, arr.slice(0, _a.MAX_CACHE_SIZE));
1264
+ }
1265
+ };
1266
+ /** Освободить ресурсы и остановить внутренние эффекты. */
1267
+ dispose() {
1268
+ this.#effectRefs.forEach((e) => e.destroy());
1269
+ this.#effectRefs = [];
1270
+ }
1271
+ /** Синтаксический сахар для `using`/`Symbol.dispose`. */
1272
+ [Symbol.dispose]() {
1273
+ this.dispose();
1274
+ }
1275
+ mergeIntoCache(items) {
1276
+ let changed = 0;
1277
+ for (const it of items) {
1278
+ const key = this.keyOf(it);
1279
+ const before = this.#lru.has(key);
1280
+ this.#lru.set(key, it);
1281
+ !before && changed++;
1282
+ }
1283
+ if (changed > 0) {
1284
+ const arr = this.#lru.toArray();
1285
+ this.cachedItems.set(arr);
1286
+ this.storage?.set(this.storageKey, arr.slice(0, _a.MAX_CACHE_SIZE));
1287
+ }
1288
+ }
1289
+ restoreFromStorage() {
1290
+ try {
1291
+ const array = this.storage?.get(this.storageKey);
1292
+ if (!array) {
1293
+ return;
1294
+ }
1295
+ for (const it of array) {
1296
+ const key = this.keyOf(it);
1297
+ this.#lru.set(key, it);
1298
+ }
1299
+ this.cachedItems.set(this.#lru.toArray());
1300
+ }
1301
+ catch {
1302
+ try {
1303
+ this.storage?.remove(this.storageKey);
1304
+ }
1305
+ catch {
1306
+ /* noop */
1307
+ }
1308
+ }
1309
+ }
1310
+ filterLocal() {
1311
+ const list = this.cachedItems();
1312
+ const query = this.searchText().toLowerCase();
1313
+ if (!query) {
1314
+ return list;
1315
+ }
1316
+ const key = this.labelKey;
1317
+ const out = [];
1318
+ for (let i = 0; i < list.length; i++) {
1319
+ const val = list[i][key];
1320
+ val.toLowerCase().includes(query) && out.push(list[i]);
1321
+ }
1322
+ return out;
1323
+ }
1324
+ keyOf(it) {
1325
+ const raw = it[this.valueKey];
1326
+ if (raw == null) {
1327
+ return '';
1328
+ }
1329
+ return typeof raw === 'string' ? raw : String(raw);
1330
+ }
1331
+ }
1332
+ _a = DictStore;
1333
+
1334
+ class DictLocalStore {
1335
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : []));
1336
+ #draftItems = signal([], ...(ngDevMode ? [{ debugName: "#draftItems" }] : []));
1337
+ /**
1338
+ * Ready-to-use options for dropdowns: `{ label, value }`.
1339
+ * Respects `maxOptionsSize` to truncate the list.
1340
+ */
1341
+ options = computed(() => {
1342
+ const options = this.#draftItems().map((it) => ({
1343
+ label: String(it[this.labelKey] ?? ''),
1344
+ value: it[this.valueKey],
1345
+ }));
1346
+ return this.maxOptionsSize ? options.slice(0, this.maxOptionsSize) : options;
1347
+ }, ...(ngDevMode ? [{ debugName: "options" }] : []));
1348
+ labelKey;
1349
+ valueKey;
1350
+ maxOptionsSize;
1351
+ constructor(items, config) {
1352
+ this.labelKey = config?.labelKey ?? 'label';
1353
+ this.valueKey = config?.valueKey ?? 'value';
1354
+ this.maxOptionsSize = config?.maxOptionsSize ?? 1000;
1355
+ this.items.set(items);
1356
+ this.#draftItems.set(items);
1357
+ }
1358
+ /**
1359
+ * No-op method for API compatibility with DictStore.
1360
+ */
1361
+ restoreCache() { }
1362
+ /**
1363
+ * Find display label by value (usually for reverse binding).
1364
+ * @returns label string or `undefined` if not found
1365
+ */
1366
+ findLabel = (value) => {
1367
+ for (const item of this.items()) {
1368
+ if (item[this.valueKey] === value) {
1369
+ return String(item[this.labelKey] ?? undefined);
1370
+ }
1371
+ }
1372
+ return undefined;
1373
+ };
1374
+ search = (name = '') => {
1375
+ const items = this.items();
1376
+ if (name?.length) {
1377
+ this.#draftItems.set(items.filter((item) => String(item[this.labelKey]).toLowerCase().includes(name.toLowerCase())));
1378
+ }
1379
+ else {
1380
+ this.#draftItems.set(items);
1381
+ }
1382
+ };
1383
+ preload = (items, opts) => {
1384
+ if (opts?.replace) {
1385
+ this.items.set(items);
1386
+ this.#draftItems.set(items);
1387
+ }
1388
+ else {
1389
+ const patch = [...this.items(), ...items];
1390
+ this.items.set(patch);
1391
+ this.#draftItems.set(patch);
1392
+ }
1393
+ };
1394
+ }
1395
+
1396
+ /**
1397
+ * Generated bundle index. Do not edit.
1398
+ */
1399
+
1400
+ export { AbortError, CacheMissError, DictLocalStore, DictStore, LruCache, PDS_CONFIG, PaginatedDataStore, ResourceStore, SERIALIZER_CONFIG, Serializer, isAbort, storageStrategy };
1401
+ //# sourceMappingURL=reforgium-statum.mjs.map