@warp-drive-mirror/experiments 0.2.7-alpha.14 → 0.2.7-alpha.16

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,1169 @@
1
+ import { defineSignal as defineSignal$1, makeInitializer, memoized } from '@warp-drive-mirror/core/signals/-leaked';
2
+ import { defineSignal, withSignalStore, entangleSignal, getOrCreateInternalSignal, notifyInternalSignal } from '@warp-drive-mirror/core/signals/-private';
3
+
4
+ /**
5
+ * A reactive wrapper around the browser's Map API that provides
6
+ * granular per-key reactivity via WarpDrive's signal system.
7
+ */
8
+ class SignalMap {
9
+ _map = {};
10
+ // _size is signal-backed via defineSignal below
11
+
12
+ _signals = withSignalStore(this._map);
13
+ subscribe(key) {
14
+ (test => {
15
+ if (!test) {
16
+ throw new Error(`ReactiveMap keys must be strings, got ${typeof key}`);
17
+ }
18
+ })(typeof key === 'string');
19
+ const existing = this._signals.has(key);
20
+ entangleSignal(this._signals, this._map, key, undefined);
21
+ const size = this._size;
22
+ this._size = existing ? size : size + 1;
23
+ return existing;
24
+ }
25
+ notify(key) {
26
+ (test => {
27
+ if (!test) {
28
+ throw new Error(`ReactiveMap keys must be strings, got ${typeof key}`);
29
+ }
30
+ })(typeof key === 'string');
31
+ const signal = getOrCreateInternalSignal(this._signals, this._map, key, 0);
32
+ notifyInternalSignal(signal);
33
+ }
34
+ clear() {
35
+ for (const value of this._signals.values()) {
36
+ notifyInternalSignal(value);
37
+ }
38
+ this._size = 0;
39
+ }
40
+ get size() {
41
+ return this._size;
42
+ }
43
+ }
44
+ defineSignal(SignalMap.prototype, '_size', 0);
45
+
46
+ const DEFAULT_CACHE_ID = 'reactive-cache';
47
+ const CACHES = new Map();
48
+ const StorageEvents = new BroadcastChannel('reactive-cache-events');
49
+ const CACHE_URL = `${location.origin}/api/reactive-cache.json`;
50
+ /**
51
+ * Emits a {@link StorageEvent} on the local context to
52
+ * match the broadcast event.
53
+ */
54
+ function emitStorageEvent(event) {
55
+ window.dispatchEvent(new CustomEvent('storage', {
56
+ detail: event
57
+ }));
58
+ }
59
+
60
+ /**
61
+ * Updates the cache and re-emits the event on the local context as a StorageEvent
62
+ * This is necessary to trigger the reactive updates in the current tab.
63
+ */
64
+ function handleCacheEvent(event) {
65
+ const {
66
+ data
67
+ } = event;
68
+ const cache = CACHES.get(data.storageArea);
69
+ // if we don't have an instance of the cache in this context
70
+ // then we don't need to do anything since there are no reactive
71
+ // subscribers to update
72
+ if (!cache) {
73
+ return;
74
+ }
75
+
76
+ // a StorageEvent with a key of null
77
+ // is a signal that the entire cache should be cleared.
78
+ if (data.key === null) {
79
+ cache._data = new Map();
80
+ emitStorageEvent({
81
+ storageArea: cache,
82
+ key: null,
83
+ oldValue: null,
84
+ newValue: null
85
+ });
86
+ return;
87
+ }
88
+ if (data.newValue === null) {
89
+ // remove the key from the cache
90
+ cache._data.delete(data.key);
91
+ } else {
92
+ // update the cache with the new value
93
+ cache._data.set(data.key, data.newValue);
94
+ }
95
+
96
+ // emit a StorageEvent to trigger reactive updates in this context
97
+ emitStorageEvent({
98
+ storageArea: cache,
99
+ key: data.key,
100
+ oldValue: data.oldValue,
101
+ newValue: data.newValue
102
+ });
103
+ }
104
+
105
+ // subscribe to all storage events from other contexts to
106
+ // keep caches in sync
107
+ StorageEvents.addEventListener('message', handleCacheEvent);
108
+
109
+ /**
110
+ * A reactive interface for json stored in the browser [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) API.
111
+ *
112
+ * This is a good option for larger data sets than can be efficiently stored in localStorage
113
+ * but should not be used as a permanent DB or storage solution.
114
+ */
115
+ class CacheStorage {
116
+ #cache;
117
+ #cacheId;
118
+ #inititalization;
119
+ _data = new Map();
120
+ _nextUpdate = null;
121
+ _bufferedEvents = [];
122
+ async #initialize() {
123
+ const cache = await caches.open(this.#cacheId);
124
+ this.#cache = cache;
125
+
126
+ // Load all existing entries into memory
127
+ this._data = await deserializeFromCache(cache);
128
+ return this;
129
+ }
130
+ constructor(cacheId) {
131
+ this.#cacheId = cacheId;
132
+ this.#cache = null;
133
+ this.#inititalization = this.#initialize();
134
+ }
135
+ get length() {
136
+ return this._data.size;
137
+ }
138
+ #update(key, oldValue, newValue) {
139
+ const cache = this.#cache;
140
+ if (!cache) {
141
+ throw new Error('Cache not initialized');
142
+ }
143
+ this._bufferedEvents.push({
144
+ storageArea: this.#cacheId,
145
+ key: key,
146
+ oldValue: oldValue,
147
+ newValue: newValue
148
+ });
149
+ if (!this._nextUpdate) {
150
+ this._nextUpdate = window.setTimeout(() => {
151
+ const events = this._bufferedEvents;
152
+ this._bufferedEvents = [];
153
+ this._nextUpdate = null;
154
+ void serializeToCache(cache, this._data);
155
+ for (const event of events) {
156
+ StorageEvents.postMessage(event);
157
+ }
158
+ }, 0);
159
+ }
160
+ }
161
+ clear() {
162
+ this._data = new Map();
163
+ this.#update(null, null, null);
164
+ }
165
+ getItem(key) {
166
+ return this._data.get(key) ?? null;
167
+ }
168
+ key(index) {
169
+ (test => {
170
+ if (!test) {
171
+ throw new Error('ReactiveStorage.key: index must be a number');
172
+ }
173
+ })(typeof index === 'number');
174
+ (test => {
175
+ if (!test) {
176
+ throw new Error('ReactiveStorage.key: index must be a non-negative integer');
177
+ }
178
+ })(index >= 0 && Number.isInteger(index));
179
+ (test => {
180
+ if (!test) {
181
+ throw new Error('ReactiveStorage.key: index must be less than the number of items in storage');
182
+ }
183
+ })(index < this.length);
184
+ let i = 0;
185
+ for (const value of this._data.keys()) {
186
+ if (i === index) {
187
+ return value;
188
+ }
189
+ i++;
190
+ }
191
+ return null;
192
+ }
193
+ removeItem(key) {
194
+ if (this._data.has(key)) {
195
+ const oldValue = this._data.get(key) ?? null;
196
+ this._data.delete(key);
197
+ this.#update(key, oldValue, null);
198
+ }
199
+ }
200
+ setItem(key, value) {
201
+ (test => {
202
+ if (!test) {
203
+ throw new Error('ReactiveStorage.setItem: key must be a string');
204
+ }
205
+ })(typeof key === 'string');
206
+ (test => {
207
+ if (!test) {
208
+ throw new Error('ReactiveStorage.setItem: value must be a string');
209
+ }
210
+ })(typeof value === 'string');
211
+ const oldValue = this._data.get(key) ?? null;
212
+ if (oldValue === value) {
213
+ return;
214
+ }
215
+ this._data.set(key, value);
216
+ this.#update(key, oldValue, value);
217
+ }
218
+
219
+ /**
220
+ * Get the singleton CacheStorage instance.
221
+ *
222
+ */
223
+ static get(cacheId = DEFAULT_CACHE_ID) {
224
+ let cache = CACHES.get(cacheId);
225
+ if (!cache) {
226
+ cache = new CacheStorage(cacheId);
227
+ CACHES.set(cacheId, cache);
228
+ }
229
+ return cache.#inititalization;
230
+ }
231
+ static expectCache(cacheId = DEFAULT_CACHE_ID) {
232
+ const cache = CACHES.get(cacheId);
233
+ if (!cache) {
234
+ throw new Error(`Cache with id ${cacheId} not found. Make sure to call CacheStorage.get(${cacheId}) first.`);
235
+ }
236
+ return cache;
237
+ }
238
+
239
+ /**
240
+ * Returns the IDs of all {@link CacheStorage} instances that have been
241
+ * opened in this context via {@link CacheStorage.get}.
242
+ */
243
+ static getAllCacheIds() {
244
+ return Array.from(CACHES.keys());
245
+ }
246
+ }
247
+ function serializeToCache(cache, data) {
248
+ const entries = Array.from(data.entries());
249
+ const blob = JSON.stringify({
250
+ version: 1,
251
+ data: entries
252
+ });
253
+ const response = new Response(blob);
254
+ return cache.put(CACHE_URL, response);
255
+ }
256
+ function deserializeFromCache(cache) {
257
+ return cache.match(CACHE_URL).then(response => {
258
+ if (!response) {
259
+ return new Map();
260
+ }
261
+ return response.json().then(json => {
262
+ if (json.version !== 1) {
263
+ throw new Error(`Unsupported cache version: ${json.version}`);
264
+ }
265
+ return new Map(json.data);
266
+ });
267
+ });
268
+ }
269
+
270
+ let localStorageInstance = null;
271
+ let sessionStorageInstance = null;
272
+ let localStorageOptions = {};
273
+ let sessionStorageOptions = {};
274
+
275
+ // This pattern enables us to have a singleton service-like
276
+ // instance that also functions as a global singleton that
277
+ // can be imported and used outside of Ember's DI system.
278
+ /**
279
+ * Retrieves the singleton instance of the LocalStorage service.
280
+ *
281
+ * If the instance does not already exist, it is created.
282
+ */
283
+ function getLocalStorage() {
284
+ if (!localStorageInstance) {
285
+ localStorageInstance = new ReactiveStorage(localStorage, localStorageOptions);
286
+ }
287
+ return localStorageInstance;
288
+ }
289
+ function getSessionStorage() {
290
+ if (!sessionStorageInstance) {
291
+ sessionStorageInstance = new ReactiveStorage(sessionStorage, sessionStorageOptions);
292
+ }
293
+ return sessionStorageInstance;
294
+ }
295
+ const CacheStorages = new Map();
296
+ function getCacheStorage(namespace = null) {
297
+ const key = namespace ?? DEFAULT_CACHE_ID;
298
+ let cache = CacheStorages.get(key);
299
+ if (!cache) {
300
+ const storage = CacheStorage.expectCache(key);
301
+ cache = new ReactiveStorage(storage);
302
+ CacheStorages.set(key, cache);
303
+ }
304
+ return cache;
305
+ }
306
+
307
+ /**
308
+ * Configure options for the localStorage singleton.
309
+ * Must be called before getLocalStorage() is first invoked.
310
+ */
311
+ function configureLocalStorage(options) {
312
+ localStorageOptions = options;
313
+ }
314
+
315
+ /**
316
+ * Configure options for the sessionStorage singleton.
317
+ * Must be called before getSessionStorage() is first invoked.
318
+ */
319
+ function configureSessionStorage(options) {
320
+ sessionStorageOptions = options;
321
+ }
322
+
323
+ /**
324
+ * Check if an error is a quota exceeded error
325
+ */
326
+ function isQuotaExceededError(error) {
327
+ if (error instanceof DOMException) {
328
+ // Most browsers
329
+ if (error.name === 'QuotaExceededError') return true;
330
+ // Firefox
331
+ if (error.name === 'NS_ERROR_DOM_QUOTA_REACHED') return true;
332
+ }
333
+ return false;
334
+ }
335
+
336
+ /**
337
+ * Check if an error indicates storage is unavailable (private browsing, etc.)
338
+ */
339
+ function isStorageUnavailableError(error) {
340
+ if (error instanceof DOMException) {
341
+ // Safari private browsing, some security restrictions
342
+ if (error.name === 'SecurityError') return true;
343
+ // Some browsers throw this in private mode
344
+ if (error.name === 'InvalidStateError') return true;
345
+ }
346
+ return false;
347
+ }
348
+ /**
349
+ * A reactive wrapper around the Web Storage API (localStorage/sessionStorage)
350
+ * that provides signal-based access to storage items and length.
351
+ *
352
+ * Will automatically update when storage events occur in other tabs/windows.
353
+ */
354
+ class ReactiveStorage {
355
+ _storage;
356
+ _options;
357
+ _memoryOnly = false;
358
+ _values = {};
359
+ _effects = new Map();
360
+ setEffect(key, fn) {
361
+ (test => {
362
+ if (!test) {
363
+ throw new Error(`Can only set effect once for key: ${key}`);
364
+ }
365
+ })(!this._effects.has(key));
366
+ this._effects.set(key, fn);
367
+ }
368
+ constructor(storage, options = {}) {
369
+ this._storage = storage;
370
+ this._options = options;
371
+ if (!(storage instanceof CacheStorage)) {
372
+ // Test if storage is accessible
373
+ try {
374
+ const testKey = '__storage_test__';
375
+ storage.setItem(testKey, testKey);
376
+ storage.removeItem(testKey);
377
+ this._length = storage.length;
378
+ } catch (error) {
379
+ if (options.fallbackToMemory && isStorageUnavailableError(error)) {
380
+ this._memoryOnly = true;
381
+ this._length = 0;
382
+ } else {
383
+ throw error;
384
+ }
385
+ }
386
+ }
387
+
388
+ // bind to localStorage events to trigger reactivity
389
+ if (!this._memoryOnly) {
390
+ window.addEventListener('storage', event => {
391
+ const data = 'detail' in event ? event.detail : event;
392
+
393
+ // Only react to changes in the same storage area
394
+ if (data.storageArea === storage) {
395
+ this._values[data.key] = data.newValue;
396
+ this._signals.notify(data.key);
397
+ this._length = this._storage.length;
398
+ const effect = this._effects.get(data.key);
399
+ if (effect) {
400
+ effect(data);
401
+ }
402
+ }
403
+ });
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Reactive access to the number of keys in Storage
409
+ */
410
+ get length() {
411
+ return this._length;
412
+ }
413
+
414
+ /**
415
+ * Non-reactive way to peek the current value of a key in Storage
416
+ */
417
+ peekItem(key) {
418
+ (test => {
419
+ if (!test) {
420
+ throw new Error('ReactiveStorage.peekItem: key must be a string');
421
+ }
422
+ })(typeof key === 'string');
423
+ const keyStr = String(key);
424
+ if (this._memoryOnly) {
425
+ return this._values[keyStr] ?? null;
426
+ }
427
+ const value = this._values[keyStr];
428
+ if (value !== undefined) {
429
+ return value;
430
+ }
431
+
432
+ // Not yet loaded, fetch from storage, but don't track access
433
+ const item = this._storage.getItem(keyStr);
434
+ // this._values[keyStr] = item;
435
+ return item;
436
+ }
437
+
438
+ /**
439
+ * Reactive access to Storage contents
440
+ */
441
+ getItem(key) {
442
+ (test => {
443
+ if (!test) {
444
+ throw new Error('ReactiveStorage.getItem: key must be a string');
445
+ }
446
+ })(typeof key === 'string');
447
+ const keyStr = String(key);
448
+ if (this._memoryOnly) {
449
+ this._signals.subscribe(keyStr);
450
+ return this._values[keyStr] ?? null;
451
+ }
452
+ const value = this._values[keyStr];
453
+ if (value !== undefined) {
454
+ this._signals.subscribe(keyStr);
455
+ return value;
456
+ }
457
+
458
+ // Not yet loaded, fetch from storage
459
+ const item = this._storage.getItem(keyStr);
460
+ this._values[keyStr] = item;
461
+ this._signals.subscribe(keyStr);
462
+ return item;
463
+ }
464
+
465
+ /**
466
+ * Set a value in Storage, triggering reactivity
467
+ */
468
+ setItem(key, value) {
469
+ (test => {
470
+ if (!test) {
471
+ throw new Error('ReactiveStorage.setItem: key must be a string');
472
+ }
473
+ })(typeof key === 'string');
474
+ (test => {
475
+ if (!test) {
476
+ throw new Error('ReactiveStorage.setItem: value must be a string');
477
+ }
478
+ })(typeof value === 'string');
479
+
480
+ // Coerce to strings to match Storage API behavior
481
+ const keyStr = String(key);
482
+ const valueStr = String(value);
483
+ const current = this._values[keyStr];
484
+ if (current === valueStr) {
485
+ // No change
486
+ return;
487
+ }
488
+
489
+ // console.trace(`set field ${key} => ${String(value)}`);
490
+
491
+ if (this._memoryOnly) {
492
+ const currentValue = this._values[keyStr];
493
+ if (currentValue === null || currentValue === undefined) {
494
+ this._length += 1;
495
+ }
496
+ this._values[keyStr] = valueStr;
497
+ this._signals.notify(keyStr);
498
+ return;
499
+ }
500
+ try {
501
+ this._storage.setItem(keyStr, valueStr);
502
+ this._values[keyStr] = valueStr;
503
+ this._signals.notify(keyStr);
504
+ this._length = this._storage.length;
505
+ } catch (error) {
506
+ if (isQuotaExceededError(error)) {
507
+ if (this._options.updateOnQuotaExceeded) {
508
+ // Update signal state even though write failed
509
+ this._values[keyStr] = valueStr;
510
+ this._signals.notify(keyStr);
511
+ }
512
+ if (this._options.onQuotaExceeded) {
513
+ const result = this._options.onQuotaExceeded(keyStr, valueStr);
514
+ const retry = result instanceof Promise ? false : result;
515
+ if (retry) {
516
+ // Retry the write after callback freed space
517
+ this._storage.setItem(keyStr, valueStr);
518
+ this._length = this._storage.length;
519
+ }
520
+ } else {
521
+ throw error;
522
+ }
523
+ } else {
524
+ throw error;
525
+ }
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Remove a value from Storage, triggering reactivity
531
+ */
532
+ removeItem(key) {
533
+ (test => {
534
+ if (!test) {
535
+ throw new Error('ReactiveStorage.removeItem: key must be a string');
536
+ }
537
+ })(typeof key === 'string');
538
+ const keyStr = String(key);
539
+ if (this._memoryOnly) {
540
+ const value = this._values[keyStr];
541
+ if (value !== null && value !== undefined) {
542
+ this._length -= 1;
543
+ }
544
+ this._values[keyStr] = null;
545
+ return;
546
+ }
547
+ this._storage.removeItem(keyStr);
548
+ this._values[keyStr] = null;
549
+ this._signals.notify(keyStr);
550
+ this._length = this._storage.length;
551
+ }
552
+
553
+ /**
554
+ * Clears all keys from Storage, triggering reactivity
555
+ */
556
+ clear() {
557
+ this._values = {};
558
+ this._signals.clear();
559
+ this._length = 0;
560
+ if (this._memoryOnly) {
561
+ return;
562
+ }
563
+ this._storage.clear();
564
+ }
565
+
566
+ /**
567
+ * Reactive access to the key at the given index
568
+ */
569
+ key(index) {
570
+ (test => {
571
+ if (!test) {
572
+ throw new Error('ReactiveStorage.key: index must be a number');
573
+ }
574
+ })(typeof index === 'number');
575
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
576
+ this._length; // track length access
577
+
578
+ if (this._memoryOnly) {
579
+ const keys = Object.keys(this._values).filter(k => this._values[k] !== null);
580
+ return keys[index] ?? null;
581
+ }
582
+ return this._storage.key(index);
583
+ }
584
+ }
585
+ defineSignal$1(ReactiveStorage.prototype, '_length', 0);
586
+ defineSignal$1(ReactiveStorage.prototype, '_signals', makeInitializer(() => new SignalMap()));
587
+
588
+ /**
589
+ * Configuration options for fields that are also query parameters
590
+ */
591
+
592
+ /**
593
+ * Metadata attached to persisted resource classes
594
+ */
595
+ /**
596
+ * A function which generates a unique primary-key
597
+ * string for a given LocalResource or SessionResource
598
+ * instance.
599
+ *
600
+ * Use functions when you want to create more than
601
+ * one instance of a resource type, each with its own
602
+ * persisted data.
603
+ */
604
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
605
+ /**
606
+ * Symbol used to store persistence metadata on classes
607
+ */
608
+ const PERSISTED_RESOURCE_META = Symbol('StorageResourceMeta');
609
+
610
+ /**
611
+ * Setup persisted resource metadata on target
612
+ * if not already present
613
+ *
614
+ * @private
615
+ */
616
+ function initMeta(target) {
617
+ let meta = target[PERSISTED_RESOURCE_META];
618
+ if (!meta) {
619
+ meta = {
620
+ id: '',
621
+ namespace: null,
622
+ pkFn: null,
623
+ type: '',
624
+ fields: new Map(),
625
+ initializers: new Map(),
626
+ paramConfigs: null,
627
+ paramCompanion: null,
628
+ instances: null,
629
+ typeOverrides: null
630
+ };
631
+ target[PERSISTED_RESOURCE_META] = meta;
632
+ }
633
+ return meta;
634
+ }
635
+ function useMeta(meta, instance) {
636
+ // If a primary key function is defined, use it to generate
637
+ // a unique ID for this instance
638
+ if (meta.id === '' && meta.pkFn) {
639
+ // we are in an instance context
640
+ meta.instances = meta.instances ?? new WeakMap();
641
+ let instanceMeta = meta.instances.get(instance);
642
+ if (!instanceMeta) {
643
+ instanceMeta = {
644
+ ...meta
645
+ };
646
+ const pk = meta.pkFn(instance);
647
+ (test => {
648
+ if (!test) {
649
+ throw new Error('Primary key function must return a non-empty string.');
650
+ }
651
+ })(typeof pk === 'string' && pk.length > 0);
652
+ instanceMeta.id = pk;
653
+ meta.instances.set(instance, instanceMeta);
654
+ }
655
+ return instanceMeta;
656
+ }
657
+ (test => {
658
+ if (!test) {
659
+ throw new Error('StorageResourceMeta must have a valid id.');
660
+ }
661
+ })(typeof meta.id === 'string' && meta.id.length > 0);
662
+ return meta;
663
+ }
664
+
665
+ /**
666
+ * Load storage field
667
+ */
668
+ function getField(instance, meta, key, overrideType) {
669
+ const type = overrideType || meta.type;
670
+ const storage = getStorage(type, meta.namespace);
671
+ const stored = storage.getItem(keyFor(meta, key));
672
+ if (stored) {
673
+ return JSON.parse(stored);
674
+ } else {
675
+ // No stored value, check for initializer
676
+ const initializer = meta.initializers.get(key);
677
+ if (initializer) {
678
+ const value = initializer.call(instance);
679
+ if (value !== undefined) {
680
+ return value;
681
+ }
682
+ }
683
+ }
684
+ return null;
685
+ }
686
+ function keyFor(meta, key) {
687
+ return `persisted:${meta.id}:${key}`;
688
+ }
689
+ function getStorage(type, namespace) {
690
+ if (type === 'local-resource') {
691
+ return getLocalStorage();
692
+ } else if (type === 'session-resource') {
693
+ return getSessionStorage();
694
+ } else {
695
+ return getCacheStorage(namespace);
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Update storage field
701
+ */
702
+ function setField(meta, key, value, overrideType) {
703
+ const type = overrideType || meta.type;
704
+ const storage = getStorage(type, meta.namespace);
705
+ storage.setItem(keyFor(meta, key), JSON.stringify(value));
706
+ }
707
+ function getResourceMeta(target) {
708
+ const meta = target[PERSISTED_RESOURCE_META];
709
+ (test => {
710
+ if (!test) {
711
+ throw new Error('StorageResourceMeta not found on target. Did you forget to use the @LocalResource() or @SessionResource() decorator?');
712
+ }
713
+ })(meta !== undefined);
714
+ return meta;
715
+ }
716
+ function _createStorageResource(id, type, namespace) {
717
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
718
+ return function (target) {
719
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
720
+ const meta = getResourceMeta(target.prototype);
721
+ meta.id = typeof id === 'string' ? id : '';
722
+ meta.pkFn = typeof id === 'function' ? id : null;
723
+ meta.type = type;
724
+ meta.namespace = namespace;
725
+ if (meta.pkFn !== null) {
726
+ // for dynamic instances, we install effects only once
727
+ // fields have been defined and a key created for the
728
+ // instance.
729
+ // for this, we defer effect installation until
730
+ // until object instantiation by wrapping the constructor
731
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
732
+ const originalConstructor = target.prototype.constructor;
733
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
734
+ target.prototype.constructor = function DynamicStorageInitializer(...args) {
735
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, new-cap
736
+ const instance = new originalConstructor(...args);
737
+ void installEffectsForDynamicInstance(meta, instance);
738
+ return instance;
739
+ };
740
+ }
741
+ if (!('toJSON' in target.prototype)) {
742
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
743
+ target.prototype.toJSON = function () {
744
+ const instanceMeta = useMeta(meta, this);
745
+ // we only serialize the id and any localStorage fields, not sessionStorage fields.
746
+ const isLocalResource = meta.type === 'local-resource' || meta.type === 'cache-resource';
747
+ const fields = isLocalResource ? new Set(meta.fields.keys()) : new Set();
748
+ for (const [key, overrideType] of meta.typeOverrides?.entries() ?? []) {
749
+ if (overrideType === 'local-storage') {
750
+ fields.add(key);
751
+ } else if (overrideType === 'session-storage' && isLocalResource) {
752
+ fields.delete(key);
753
+ }
754
+ }
755
+ let idKey = '$key';
756
+ while (fields.has(idKey)) {
757
+ idKey = `_${idKey}`;
758
+ }
759
+ const data = {
760
+ [idKey]: instanceMeta.id
761
+ };
762
+ for (const key of fields) {
763
+ const value = getField(this, instanceMeta, key, meta.typeOverrides?.get(key) ?? null);
764
+ if (value !== null) {
765
+ data[key] = value;
766
+ }
767
+ }
768
+ return data;
769
+ };
770
+ }
771
+ };
772
+ }
773
+ async function installEffectsForDynamicInstance(meta, instance) {
774
+ await Promise.resolve();
775
+ for (const [key, value] of meta.fields.entries()) {
776
+ if (typeof value === 'function') {
777
+ void installEffect(useMeta(meta, instance), key);
778
+ }
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Returns the descriptor but is cast
784
+ * to void to satisfy Typescript's incorrect
785
+ * typing for legacy decorators.
786
+ */
787
+ function setupField(target, key, orgDesc, type) {
788
+ const meta = initMeta(target);
789
+ meta.fields.set(key, null);
790
+ const overrideType = type === 'local' ? 'local-storage' : type === 'session' ? 'session-storage' : type === 'cache' ? 'cache-storage' : null;
791
+ if (overrideType) {
792
+ meta.typeOverrides = meta.typeOverrides || new Map();
793
+ meta.typeOverrides.set(key, overrideType);
794
+ }
795
+ // @ts-expect-error initializer does exist
796
+ const initializer = orgDesc?.initializer || null;
797
+ meta.initializers.set(key, initializer);
798
+ const desc = {
799
+ configurable: true,
800
+ enumerable: true,
801
+ get() {
802
+ return getField(this, useMeta(meta, this), key, overrideType);
803
+ },
804
+ set(value) {
805
+ setField(useMeta(meta, this), key, value, overrideType);
806
+ }
807
+ };
808
+ return memoized(target, key, desc);
809
+ }
810
+ async function installEffect(meta, key) {
811
+ await Promise.resolve();
812
+ const effect = meta.fields.get(key);
813
+ const storage = getStorage(meta.type, meta.namespace);
814
+ storage.setEffect(keyFor(meta, key), event => {
815
+ const oldValue = event.oldValue ? JSON.parse(event.oldValue) : null;
816
+ const newValue = event.newValue ? JSON.parse(event.newValue) : null;
817
+ effect({
818
+ key,
819
+ from: oldValue,
820
+ to: newValue
821
+ });
822
+ });
823
+ }
824
+
825
+ /**
826
+ * Get or create the param companion object for a StorageResource instance.
827
+ *
828
+ * The companion object contains URL-serialized versions of all @param decorated fields.
829
+ * Each param field gets a corresponding property on the companion that:
830
+ * - Reads: Serializes the storage value to a URL string (returns null if not active)
831
+ * - Writes: Deserializes the URL string and updates the storage value
832
+ *
833
+ * The companion object is reactive using trackedObject, ensuring that changes
834
+ * to the underlying storage fields trigger updates in the query param system.
835
+ *
836
+ * This companion object is what QPRoute will bind to for URL query params.
837
+ *
838
+ * @private
839
+ * @param instance - The StorageResource instance
840
+ * @param groupControlMap - Optional map of fieldName -> controlFieldName for grouped params
841
+ * @returns The companion object with serialized param properties
842
+ */
843
+ function getParamCompanion(instance, groupControlMap) {
844
+ const meta = getResourceMeta(instance);
845
+ const instanceMeta = useMeta(meta, instance);
846
+
847
+ // Return existing companion if already created
848
+ if (instanceMeta.paramCompanion) {
849
+ return instanceMeta.paramCompanion;
850
+ }
851
+
852
+ // Create new companion object
853
+ const companion = {};
854
+ instanceMeta.paramCompanion = companion;
855
+
856
+ // Install a property for each param
857
+ const paramConfigs = instanceMeta.paramConfigs;
858
+ if (!paramConfigs || paramConfigs.size === 0) {
859
+ return companion;
860
+ }
861
+ for (const [fieldName, config] of paramConfigs.entries()) {
862
+ const controlFieldName = groupControlMap?.[fieldName];
863
+ createParamField(companion, fieldName, config, controlFieldName, instance, meta);
864
+ }
865
+ return companion;
866
+ }
867
+ function isActiveGroupParam(companion, controlFieldName) {
868
+ // if the url value for the control param is `null` we are inactive
869
+ const controlValue = companion[controlFieldName];
870
+ return controlValue !== null;
871
+ }
872
+
873
+ /**
874
+ * The default value of a param is determined by:
875
+ * - the getDefault() function if provided, and its return is not undefined
876
+ * - the initializer value provided to the field, if not undefined
877
+ * - null otherwise
878
+ *
879
+ * The value is the deserialized form (i.e., the local storage value type)
880
+ */
881
+ function getParamFieldDefaultValue(meta, config, fieldName, instance) {
882
+ const defaultValue = config.getDefault?.(instance);
883
+ if (defaultValue !== undefined) {
884
+ return defaultValue;
885
+ }
886
+ const initializer = meta.initializers.get(fieldName);
887
+ if (initializer) {
888
+ const initValue = initializer.call(instance);
889
+ if (initValue !== undefined) {
890
+ return initValue;
891
+ }
892
+ }
893
+ return null;
894
+ }
895
+ function createParamField(companion, fieldName, config, controlFieldName, instance, meta) {
896
+ const desc = {
897
+ configurable: true,
898
+ enumerable: true,
899
+ get() {
900
+ // Mark as initialized on first access, but continue with normal logic
901
+ if (!config.initialized) {
902
+ config.initialized = true;
903
+ return null;
904
+ }
905
+
906
+ // Next, check if this param is part of a group and if its control param is active
907
+ // If not active, return null to indicate inactive state
908
+ if (controlFieldName && !isActiveGroupParam(companion, controlFieldName)) {
909
+ return null;
910
+ }
911
+
912
+ // Next, check if the currently stored value is the default
913
+ // value. If so, return null to indicate inactive state
914
+ const defaultValue = getParamFieldDefaultValue(meta, config, fieldName, instance);
915
+ const rawValue = instance[fieldName];
916
+ if (rawValue === defaultValue) {
917
+ return null;
918
+ }
919
+
920
+ // Serialize to URL format
921
+ const serialized = config.serialize(rawValue, instance);
922
+
923
+ // Return null if serialization returns empty, indicating no URL representation
924
+ if (!serialized) {
925
+ return null;
926
+ }
927
+ return serialized;
928
+ },
929
+ set(urlValue) {
930
+ /**
931
+ * set will never come from anything except URL deserialization.
932
+ * which is managed by Ember.
933
+ *
934
+ * Our job is to not unnecessarily update the storage resource
935
+ * so we need to run the compare function to see if the
936
+ * incoming URL value is different from the existing local value.
937
+ */
938
+
939
+ // If null or empty, skip deserialization
940
+ if (!urlValue) {
941
+ return;
942
+ }
943
+ const rawValue = instance[fieldName];
944
+
945
+ // Compare incoming URL value with existing local value
946
+ const rawValueSerialized = config.serialize(rawValue, instance);
947
+ if (rawValueSerialized === urlValue) {
948
+ // No change
949
+ return;
950
+ }
951
+
952
+ // Deserialize from URL format
953
+ const newRawValue = config.deserialize(urlValue, instance);
954
+
955
+ // Update the storage resource
956
+ instance[fieldName] = newRawValue;
957
+ }
958
+ };
959
+ memoized(companion, fieldName, desc);
960
+ Object.defineProperty(companion, fieldName, desc);
961
+ }
962
+
963
+ /**
964
+ * Decorator which transforms a class into a StorageResource
965
+ * persisted in localStorage.
966
+ *
967
+ * LocalResources must either be singletons or expect all instances
968
+ * to share state unless a primary key function is provided.
969
+ *
970
+ * When a primary key function is provided, each instance
971
+ * will have its own persisted data based on the key generated
972
+ * by the function.
973
+ *
974
+ * The function will be called once per instance during
975
+ * initialization to determine the unique ID for that instance.
976
+ */
977
+ function LocalResource(id) {
978
+ return _createStorageResource(id, 'local-resource', null);
979
+ }
980
+
981
+ /**
982
+ * Decorator which transforms a class into a StorageResource
983
+ * persisted in sessionStorage.
984
+ *
985
+ * SessionResources must either be singletons or expect all instances
986
+ * to share state unless a primary key function is provided.
987
+ *
988
+ * When a primary key function is provided, each instance
989
+ * will have its own persisted data based on the key generated
990
+ * by the function.
991
+ *
992
+ * The function will be called once per instance during
993
+ * initialization to determine the unique ID for that instance.
994
+ */
995
+ function SessionResource(id) {
996
+ return _createStorageResource(id, 'session-resource', null);
997
+ }
998
+
999
+ /**
1000
+ * Decorator which transforms a class into a StorageResource
1001
+ * persisted via the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache).
1002
+ * api and shared across all tabs/windows under the same origin.
1003
+ *
1004
+ * CacheResources must either be singletons or expect all instances
1005
+ * to share state unless a primary key function is provided.
1006
+ *
1007
+ * When a primary key function is provided, each instance
1008
+ * will have its own persisted data based on the key generated
1009
+ * by the function.
1010
+ *
1011
+ * The function will be called once per instance during
1012
+ * initialization to determine the unique ID for that instance.
1013
+ *
1014
+ * All object cached in the same `namespace` share the namespace's storage context,
1015
+ * so partitioning can be achieved by using different namespaces for different groups
1016
+ * of data.
1017
+ */
1018
+ function CacheResource(id, namespace = null) {
1019
+ return _createStorageResource(id, 'cache-resource', namespace);
1020
+ }
1021
+
1022
+ /**
1023
+ * Decorator which marks a property as a field on
1024
+ * a LocalResource or SessionResource
1025
+ *
1026
+ * The field's value will be initialized from the persisted resource data
1027
+ * if available, falling back to the property's default value otherwise.
1028
+ *
1029
+ * Fields can be of any type that is serializable to and restorable from JSON,
1030
+ * but complex types (like objects or arrays) should be handled with care to avoid
1031
+ * unintended mutations or reactivity issues.
1032
+ *
1033
+ * By default, fields are persisted in the storage type defined by the resource decorator
1034
+ * (@LocalResource or @SessionResource). However, you can override this behavior
1035
+ * by passing 'local' or 'session' as an argument to the decorator.
1036
+ *
1037
+ * ---
1038
+ *
1039
+ * **Example:**
1040
+ *
1041
+ * ```ts
1042
+ * @LocalResource('user-settings')
1043
+ * class UserSettings {
1044
+ * @field
1045
+ * theme: 'light' | 'dark' = 'light';
1046
+ *
1047
+ * @field('session')
1048
+ * sessionToken: string | null = null;
1049
+ * }
1050
+ * ```
1051
+ *
1052
+ */
1053
+
1054
+ function field(...args) {
1055
+ if (args.length === 0) {
1056
+ return setupField;
1057
+ }
1058
+ if (args.length === 1) {
1059
+ const type = args[0];
1060
+ return (target, key, desc) => setupField(target, key, desc, type);
1061
+ }
1062
+ if (args.length === 2 || args.length === 3) {
1063
+ const [target, key, descriptor] = args;
1064
+ return setupField(target, key, descriptor);
1065
+ }
1066
+ }
1067
+ function input(type) {
1068
+ (test => {
1069
+ if (!test) {
1070
+ throw new Error('input decorator requires a valid type argument');
1071
+ }
1072
+ })(['number', 'boolean', 'float'].includes(type));
1073
+ return function (target, key, desc) {
1074
+ // eslint-disable-next-line @typescript-eslint/unbound-method
1075
+ const originalSet = desc.set;
1076
+ desc.set = function (value) {
1077
+ switch (type) {
1078
+ case 'number':
1079
+ value = Number(value);
1080
+ break;
1081
+ case 'boolean':
1082
+ value = value === 'false' || value === '' || value === '0' || value === 'undefined' || value === 'null' || value === false || value === 0 || value === null || value === undefined ? 0 : 1;
1083
+ break;
1084
+ default:
1085
+ (test => {
1086
+ {
1087
+ throw new Error(`Unsupported input type: ${type}`);
1088
+ }
1089
+ })();
1090
+ }
1091
+ originalSet.call(this, value);
1092
+ };
1093
+ return desc;
1094
+ };
1095
+ }
1096
+
1097
+ /**
1098
+ * Effects are fields that run a side-effecting function.
1099
+ *
1100
+ * Effects are intended to enable synchronizing get states between
1101
+ * tabs or windows that result in needing to synchronize other
1102
+ * non-reactive state.
1103
+ *
1104
+ * For example, when a user selects a light/dark mode theme preference
1105
+ * that differs from the system preference, effects can be used to trigger
1106
+ * DOM updates on the documentElement necessary to ensure its state is
1107
+ * consistent with the persisted resource state and reactive application state.
1108
+ *
1109
+ * To do this without an effect would require either setting up your own
1110
+ * storage event listeners or consuming the reactive state of the property
1111
+ * in another effect-like API such as an Ember modifier or React useEffect,
1112
+ *
1113
+ * Effects *only* run when the stored value changes due to storage events
1114
+ * emitted from other tabs or windows. They do not run when the property
1115
+ * is updated in the same context.
1116
+ *
1117
+ * ---
1118
+ *
1119
+ * **Example:**
1120
+ *
1121
+ * ```ts
1122
+ * @LocalResource('user-preferences')
1123
+ * class UserPreferences {
1124
+ * @effect(syncThemeToDOM)
1125
+ * explicitThemePreference: 'light' | 'dark' | null = null;
1126
+ *
1127
+ * @matchMedia('(prefers-color-scheme: dark)')
1128
+ * systemPrefersDarkMode: boolean = false;
1129
+ * }
1130
+ *
1131
+ * function syncThemeToDOM(update: ValueTransition<'light' | 'dark' | null>): void {
1132
+ * const newTheme = update.to;
1133
+ * document.documentElement.style.colorScheme = newTheme ?? 'light dark';
1134
+ *
1135
+ * if (newTheme === 'dark') {
1136
+ * document.documentElement.classList.add('dark-theme');
1137
+ * document.documentElement.classList.remove('light-theme');
1138
+ * } else if (newTheme === 'light') {
1139
+ * document.documentElement.classList.add('light-theme');
1140
+ * document.documentElement.classList.remove('dark-theme');
1141
+ * } else {
1142
+ * document.documentElement.classList.remove('light-theme');
1143
+ * document.documentElement.classList.remove('dark-theme');
1144
+ * }
1145
+ * }
1146
+ * ```
1147
+ */
1148
+ function effect(fn, type) {
1149
+ const overrideType = type === 'local' ? 'local-storage' : type === 'session' ? 'session-storage' : null;
1150
+ return function effectField(target, key, _descriptor) {
1151
+ const meta = initMeta(target);
1152
+ meta.fields.set(key, fn);
1153
+
1154
+ // only install the effect if we are not in a primary-keyed instance context
1155
+ if (meta.pkFn === null) void installEffect(meta, key);
1156
+ return {
1157
+ configurable: true,
1158
+ enumerable: true,
1159
+ get() {
1160
+ return getField(this, useMeta(meta, this), key, overrideType);
1161
+ },
1162
+ set(value) {
1163
+ setField(useMeta(meta, this), key, value, overrideType);
1164
+ }
1165
+ };
1166
+ };
1167
+ }
1168
+
1169
+ export { CacheResource, CacheStorage, DEFAULT_CACHE_ID, LocalResource, SessionResource, getParamCompanion as _getParamCompanion, initMeta as _initMeta, configureLocalStorage, configureSessionStorage, effect, field, getCacheStorage, getLocalStorage, getSessionStorage, input };