strata-storage 2.8.5 → 3.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.
@@ -7,22 +7,84 @@ import type { StorageType, StorageCapabilities, StorageValue, ClearOptions, Size
7
7
  /**
8
8
  * Browser localStorage adapter
9
9
  */
10
+ /**
11
+ * The key prefix web adapters use unless told otherwise, as of 3.0.0.
12
+ *
13
+ * 🔴 Before 3.0.0 this was the empty string, which meant this library's keys sat
14
+ * unprefixed among every other script's in a shared storage area. 2.9.0 made that
15
+ * safe (a key is ours only if its value is a `StorageValue` envelope); this makes
16
+ * it tidy as well, so our keys are identifiable by name too.
17
+ *
18
+ * Opt out with `defineStorage({ keyPrefix: false })` — see `StrataConfig`.
19
+ */
20
+ export declare const DEFAULT_WEB_KEY_PREFIX = "strata:";
10
21
  export declare class LocalStorageAdapter extends BaseAdapter {
11
22
  readonly name: StorageType;
12
23
  readonly capabilities: StorageCapabilities;
13
24
  protected prefix: string;
14
25
  protected listeners: Map<SubscriptionCallback, (event: StorageEvent) => void>;
26
+ /**
27
+ * Whether to adopt pre-3.0 unprefixed entries on read.
28
+ *
29
+ * 🔴 Defaults to FALSE, and only `Strata` turns it on — for the adapters whose
30
+ * prefix it resolved. A directly constructed adapter must never adopt bare
31
+ * keys: `plugin/web.ts` builds a `strata_prefs_` instance beside the main one,
32
+ * and if that adopted every unprefixed entry it found, it would take them from
33
+ * the instance they belong to.
34
+ */
35
+ protected migrateLegacyKeys: boolean;
15
36
  constructor(prefix?: string);
16
37
  /**
17
38
  * Check if localStorage is available
18
39
  */
19
40
  isAvailable(): Promise<boolean>;
41
+ /**
42
+ * Apply configuration synchronously. See `BaseAdapter.configure` for why the
43
+ * prefix cannot wait for the async `initialize()`.
44
+ */
45
+ configure(config?: {
46
+ prefix?: string;
47
+ migrateLegacyKeys?: boolean;
48
+ }): void;
49
+ /**
50
+ * Adopt a pre-3.0 unprefixed entry for `key`, moving it under the current
51
+ * prefix. Returns the adopted value, or null when there is nothing to adopt.
52
+ *
53
+ * This is the whole of the 3.0.0 migration, and it is deliberately **per key,
54
+ * on read** rather than a bulk sweep. A sweep would adopt every unprefixed
55
+ * envelope on the origin — including keys belonging to another instance that
56
+ * opted out of the prefix, or to a sibling application still on 2.x. Adopting
57
+ * only what the caller actually asks for keeps the blast radius to keys this
58
+ * instance already uses.
59
+ *
60
+ * Three conditions, all required:
61
+ * 1. migration is enabled and a prefix is actually in effect (nothing to move
62
+ * data *into* otherwise);
63
+ * 2. the legacy value is **ours** — `parseOwnValue`, the 2.9.0 shape check. It
64
+ * is what makes this safe at all: with no prefix to go on, shape is the only
65
+ * way to tell our data from a third party's;
66
+ * 3. the prefixed slot is **empty**. A value already there is authoritative, so
67
+ * the legacy entry is left alone rather than overwriting newer data.
68
+ *
69
+ * It MOVES rather than copies. A copy leaves a stale duplicate that diverges the
70
+ * moment anything writes — a silent wrong answer, worse than a clean break. A
71
+ * consumer that needs the bare key to keep existing (a pre-paint script, a
72
+ * logger reading its own level) takes `keyPrefix: false` instead.
73
+ */
74
+ protected adoptLegacyKey<T = unknown>(key: string): StorageValue<T> | null;
20
75
  /**
21
76
  * Initialize the adapter
22
77
  */
23
78
  initialize(config?: {
24
79
  prefix?: string;
25
80
  }): Promise<void>;
81
+ /**
82
+ * Whether this storage area is usable RIGHT NOW, without awaiting anything.
83
+ * The synchronous API needs this: `defaultStorages` reads as an ordered
84
+ * fallback list, and without a sync probe `setSync` selects an unusable
85
+ * backend and throws instead of falling through to the next one.
86
+ */
87
+ isAvailableSync(): boolean;
26
88
  /**
27
89
  * Get the backing Storage object.
28
90
  * Subclasses (e.g. SessionStorageAdapter) override this to target a
@@ -70,6 +132,45 @@ export declare class LocalStorageAdapter extends BaseAdapter {
70
132
  * Get all keys (synchronous)
71
133
  */
72
134
  keysSync(pattern?: string | RegExp): string[];
135
+ /**
136
+ * Every key in this storage area that this adapter actually owns, with the
137
+ * envelope already parsed (one read per key, not two).
138
+ *
139
+ * 🔴 Name is not enough. With the default empty prefix `startsWith(prefix)` is
140
+ * true for EVERY key on the origin, so this method — not the prefix — is what
141
+ * keeps `keys()`, the TTL sweep and `clear()` off other scripts' data. An
142
+ * expired entry is skipped here exactly as before.
143
+ */
144
+ protected ownKeys(includeExpired?: boolean): Array<{
145
+ key: string;
146
+ fullKey: string;
147
+ value: StorageValue;
148
+ }>;
149
+ /**
150
+ * Remove an entry `ownKeys()` returned, by its PHYSICAL key.
151
+ *
152
+ * 🔴 Not `removeSync(key)`. That rebuilds the physical key as `prefix + key`,
153
+ * which is wrong for a pre-3.0 legacy entry — those live at the bare key, so
154
+ * `ownKeys()` reports `fullKey === key` for them. Rebuilding would delete
155
+ * `strata:<key>` instead and leave the legacy entry behind, so `clear()` and
156
+ * the expiry sweep would silently skip exactly the entries not yet migrated.
157
+ */
158
+ protected removeOwnedEntry(entry: {
159
+ key: string;
160
+ fullKey: string;
161
+ value: StorageValue;
162
+ }): void;
163
+ /**
164
+ * Reclaim expired entries, returning how many were removed.
165
+ *
166
+ * Overrides the base per-key sweep for two reasons. It reads each key once
167
+ * instead of twice, and — the load-bearing one — the base sweep is built on
168
+ * `keys()`, which does not surface expired entries here, so it could only ever
169
+ * report 0. Before this override the reaping happened as an undocumented side
170
+ * effect of `getSync()` deleting what it found expired during enumeration,
171
+ * while the returned count stayed 0.
172
+ */
173
+ cleanupExpired(): Promise<number>;
73
174
  /**
74
175
  * Check if key exists (synchronous)
75
176
  */
@@ -3,12 +3,23 @@
3
3
  * Provides persistent storage with 5-10MB limit
4
4
  */
5
5
  import { BaseAdapter } from "../../core/BaseAdapter.js";
6
- import { serialize, deserialize, getObjectSize } from "../../utils/index.js";
6
+ import { serialize, getObjectSize } from "../../utils/index.js";
7
7
  import { QuotaExceededError, SerializationError, StorageError } from "../../utils/errors.js";
8
8
  import { logger } from "../../utils/logger.js";
9
9
  /**
10
10
  * Browser localStorage adapter
11
11
  */
12
+ /**
13
+ * The key prefix web adapters use unless told otherwise, as of 3.0.0.
14
+ *
15
+ * 🔴 Before 3.0.0 this was the empty string, which meant this library's keys sat
16
+ * unprefixed among every other script's in a shared storage area. 2.9.0 made that
17
+ * safe (a key is ours only if its value is a `StorageValue` envelope); this makes
18
+ * it tidy as well, so our keys are identifiable by name too.
19
+ *
20
+ * Opt out with `defineStorage({ keyPrefix: false })` — see `StrataConfig`.
21
+ */
22
+ export const DEFAULT_WEB_KEY_PREFIX = 'strata:';
12
23
  export class LocalStorageAdapter extends BaseAdapter {
13
24
  name = 'localStorage';
14
25
  capabilities = {
@@ -24,7 +35,17 @@ export class LocalStorageAdapter extends BaseAdapter {
24
35
  };
25
36
  prefix;
26
37
  listeners = new Map();
27
- constructor(prefix = '') {
38
+ /**
39
+ * Whether to adopt pre-3.0 unprefixed entries on read.
40
+ *
41
+ * 🔴 Defaults to FALSE, and only `Strata` turns it on — for the adapters whose
42
+ * prefix it resolved. A directly constructed adapter must never adopt bare
43
+ * keys: `plugin/web.ts` builds a `strata_prefs_` instance beside the main one,
44
+ * and if that adopted every unprefixed entry it found, it would take them from
45
+ * the instance they belong to.
46
+ */
47
+ migrateLegacyKeys = false;
48
+ constructor(prefix = DEFAULT_WEB_KEY_PREFIX) {
28
49
  super();
29
50
  this.prefix = prefix;
30
51
  }
@@ -47,14 +68,103 @@ export class LocalStorageAdapter extends BaseAdapter {
47
68
  }
48
69
  }
49
70
  /**
50
- * Initialize the adapter
71
+ * Apply configuration synchronously. See `BaseAdapter.configure` for why the
72
+ * prefix cannot wait for the async `initialize()`.
51
73
  */
52
- async initialize(config) {
53
- if (config?.prefix) {
74
+ configure(config) {
75
+ if (config?.prefix !== undefined) {
54
76
  this.prefix = config.prefix;
55
77
  }
78
+ if (config?.migrateLegacyKeys !== undefined) {
79
+ this.migrateLegacyKeys = config.migrateLegacyKeys;
80
+ }
81
+ }
82
+ /**
83
+ * Adopt a pre-3.0 unprefixed entry for `key`, moving it under the current
84
+ * prefix. Returns the adopted value, or null when there is nothing to adopt.
85
+ *
86
+ * This is the whole of the 3.0.0 migration, and it is deliberately **per key,
87
+ * on read** rather than a bulk sweep. A sweep would adopt every unprefixed
88
+ * envelope on the origin — including keys belonging to another instance that
89
+ * opted out of the prefix, or to a sibling application still on 2.x. Adopting
90
+ * only what the caller actually asks for keeps the blast radius to keys this
91
+ * instance already uses.
92
+ *
93
+ * Three conditions, all required:
94
+ * 1. migration is enabled and a prefix is actually in effect (nothing to move
95
+ * data *into* otherwise);
96
+ * 2. the legacy value is **ours** — `parseOwnValue`, the 2.9.0 shape check. It
97
+ * is what makes this safe at all: with no prefix to go on, shape is the only
98
+ * way to tell our data from a third party's;
99
+ * 3. the prefixed slot is **empty**. A value already there is authoritative, so
100
+ * the legacy entry is left alone rather than overwriting newer data.
101
+ *
102
+ * It MOVES rather than copies. A copy leaves a stale duplicate that diverges the
103
+ * moment anything writes — a silent wrong answer, worse than a clean break. A
104
+ * consumer that needs the bare key to keep existing (a pre-paint script, a
105
+ * logger reading its own level) takes `keyPrefix: false` instead.
106
+ */
107
+ adoptLegacyKey(key) {
108
+ if (!this.migrateLegacyKeys || !this.prefix)
109
+ return null;
110
+ // A key already carrying our prefix is not a legacy key.
111
+ if (key.startsWith(this.prefix))
112
+ return null;
113
+ let storage;
114
+ try {
115
+ storage = this.getStorage();
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ const raw = storage.getItem(key);
121
+ if (raw === null)
122
+ return null;
123
+ const value = this.parseOwnValue(raw, key);
124
+ if (!value)
125
+ return null;
126
+ if (storage.getItem(this.prefix + key) !== null) {
127
+ logger.debug(`${this.name}: legacy key "${key}" not adopted — "${this.prefix}${key}" already exists and wins.`);
128
+ return null;
129
+ }
130
+ try {
131
+ storage.setItem(this.prefix + key, raw);
132
+ storage.removeItem(key);
133
+ }
134
+ catch (error) {
135
+ // Out of quota, or the area turned read-only mid-flight. The legacy entry
136
+ // is still intact and still readable, so report and return it.
137
+ logger.warn(`${this.name}: could not migrate legacy key "${key}":`, error);
138
+ return value;
139
+ }
140
+ logger.debug(`${this.name}: migrated legacy key "${key}" to "${this.prefix}${key}".`);
141
+ return value;
142
+ }
143
+ /**
144
+ * Initialize the adapter
145
+ */
146
+ async initialize(config) {
147
+ this.configure(config);
56
148
  this.startTTLCleanup();
57
149
  }
150
+ /**
151
+ * Whether this storage area is usable RIGHT NOW, without awaiting anything.
152
+ * The synchronous API needs this: `defaultStorages` reads as an ordered
153
+ * fallback list, and without a sync probe `setSync` selects an unusable
154
+ * backend and throws instead of falling through to the next one.
155
+ */
156
+ isAvailableSync() {
157
+ try {
158
+ const storage = this.getStorage();
159
+ const testKey = `${this.prefix}__test__`;
160
+ storage.setItem(testKey, 'test');
161
+ storage.removeItem(testKey);
162
+ return true;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
58
168
  /**
59
169
  * Get the backing Storage object.
60
170
  * Subclasses (e.g. SessionStorageAdapter) override this to target a
@@ -77,22 +187,28 @@ export class LocalStorageAdapter extends BaseAdapter {
77
187
  * Get a value from localStorage (synchronous)
78
188
  */
79
189
  getSync(key) {
190
+ let item;
80
191
  try {
81
- const item = this.getStorage().getItem(this.prefix + key);
82
- if (!item)
83
- return null;
84
- const value = deserialize(item);
85
- // Check TTL
86
- if (this.isExpired(value)) {
87
- this.removeSync(key);
88
- return null;
89
- }
90
- return value;
192
+ item = this.getStorage().getItem(this.prefix + key);
91
193
  }
92
194
  catch (error) {
93
- logger.error(`Failed to get key ${key} from ${this.name}:`, error);
195
+ // A genuine storage-access fault the area is blocked (private mode, a
196
+ // cookie policy, a SecurityError on an opaque origin). This one IS ours to
197
+ // report: the read we were asked for did not happen.
198
+ logger.error(`Failed to read key ${key} from ${this.name}:`, error);
94
199
  return null;
95
200
  }
201
+ // A value that is not our envelope belongs to somebody else sharing this
202
+ // area. parseOwnValue() logs it at debug and returns null — never an error.
203
+ // A miss falls through to a pre-3.0 unprefixed entry, if there is one.
204
+ const value = this.parseOwnValue(item, key) ?? this.adoptLegacyKey(key);
205
+ if (!value)
206
+ return null;
207
+ if (this.isExpired(value)) {
208
+ this.removeSync(key);
209
+ return null;
210
+ }
211
+ return value;
96
212
  }
97
213
  /**
98
214
  * Set a value in localStorage
@@ -137,15 +253,7 @@ export class LocalStorageAdapter extends BaseAdapter {
137
253
  // overflow). Only read when a listener actually needs the old value.
138
254
  let oldValue = null;
139
255
  if (this.hasChangeListeners()) {
140
- const item = this.getStorage().getItem(this.prefix + key);
141
- if (item) {
142
- try {
143
- oldValue = deserialize(item);
144
- }
145
- catch {
146
- oldValue = null;
147
- }
148
- }
256
+ oldValue = this.parseOwnValue(this.getStorage().getItem(this.prefix + key), key);
149
257
  }
150
258
  this.getStorage().removeItem(this.prefix + key);
151
259
  if (oldValue) {
@@ -164,40 +272,37 @@ export class LocalStorageAdapter extends BaseAdapter {
164
272
  clearSync(options) {
165
273
  if (!options ||
166
274
  (!options.pattern && !options.prefix && !options.tags && !options.expiredOnly)) {
167
- // Clear all with our prefix
275
+ // Clear everything WE wrote — never the whole area. With an empty prefix a
276
+ // name-only sweep here deletes every key on the origin, including another
277
+ // application's; ownKeys() bounds it to our own envelopes.
168
278
  const storage = this.getStorage();
169
- const keysToRemove = [];
170
- for (let i = 0; i < storage.length; i++) {
171
- const key = storage.key(i);
172
- if (key?.startsWith(this.prefix)) {
173
- keysToRemove.push(key);
174
- }
279
+ for (const { fullKey } of this.ownKeys(true)) {
280
+ storage.removeItem(fullKey);
175
281
  }
176
- keysToRemove.forEach((key) => storage.removeItem(key));
177
282
  this.emitChange('*', undefined, undefined, 'local');
178
283
  return;
179
284
  }
180
- // Synchronous filtered clear (mirrors BaseAdapter.clear logic)
181
- for (const key of this.keysSync()) {
285
+ // Synchronous filtered clear (mirrors BaseAdapter.clear logic).
286
+ // Iterates owned entries INCLUDING expired ones: `expiredOnly` filters on
287
+ // exactly the entries `keysSync()` leaves out, so driving this loop from
288
+ // `keysSync()` made that option a guaranteed no-op.
289
+ for (const entry of this.ownKeys(true)) {
290
+ const { key, value } = entry;
182
291
  let shouldDelete = true;
183
292
  const pattern = options.pattern || options.prefix;
184
293
  if (pattern) {
185
294
  shouldDelete = this.filterKeys([key], pattern).length > 0;
186
295
  }
187
296
  if (shouldDelete && options.tags) {
188
- const value = this.getSync(key);
189
- if (!value?.tags || !options.tags.some((tag) => value.tags?.includes(tag))) {
297
+ if (!value.tags || !options.tags.some((tag) => value.tags?.includes(tag))) {
190
298
  shouldDelete = false;
191
299
  }
192
300
  }
193
- if (shouldDelete && options.expiredOnly) {
194
- const value = this.getSync(key);
195
- if (!value || !this.isExpired(value)) {
196
- shouldDelete = false;
197
- }
301
+ if (shouldDelete && options.expiredOnly && !this.isExpired(value)) {
302
+ shouldDelete = false;
198
303
  }
199
304
  if (shouldDelete) {
200
- this.removeSync(key);
305
+ this.removeOwnedEntry(entry);
201
306
  }
202
307
  }
203
308
  }
@@ -211,20 +316,92 @@ export class LocalStorageAdapter extends BaseAdapter {
211
316
  * Get all keys (synchronous)
212
317
  */
213
318
  keysSync(pattern) {
319
+ return this.filterKeys(this.ownKeys().map((entry) => entry.key), pattern);
320
+ }
321
+ /**
322
+ * Every key in this storage area that this adapter actually owns, with the
323
+ * envelope already parsed (one read per key, not two).
324
+ *
325
+ * 🔴 Name is not enough. With the default empty prefix `startsWith(prefix)` is
326
+ * true for EVERY key on the origin, so this method — not the prefix — is what
327
+ * keeps `keys()`, the TTL sweep and `clear()` off other scripts' data. An
328
+ * expired entry is skipped here exactly as before.
329
+ */
330
+ ownKeys(includeExpired = false) {
214
331
  const storage = this.getStorage();
215
- const keys = [];
332
+ const owned = [];
333
+ const seen = new Set();
216
334
  for (let i = 0; i < storage.length; i++) {
217
335
  const fullKey = storage.key(i);
218
- if (fullKey?.startsWith(this.prefix)) {
219
- const key = fullKey.substring(this.prefix.length);
220
- // Check if not expired
221
- const value = this.getSync(key);
222
- if (value) {
223
- keys.push(key);
224
- }
336
+ if (!fullKey?.startsWith(this.prefix))
337
+ continue;
338
+ const key = fullKey.substring(this.prefix.length);
339
+ const value = this.parseOwnValue(storage.getItem(fullKey), key);
340
+ if (!value)
341
+ continue;
342
+ if (!includeExpired && this.isExpired(value))
343
+ continue;
344
+ seen.add(key);
345
+ owned.push({ key, fullKey, value });
346
+ }
347
+ // Pre-3.0 unprefixed entries are still ours and must appear here, or `keys()`,
348
+ // `clear()` and `size()` would silently omit everything not yet read back
349
+ // (adoption is per-read, so a freshly upgraded app has migrated nothing yet).
350
+ //
351
+ // 🔴 Listing is NOT adopting. Enumerating must not move data — a `keys()` call
352
+ // is a question, not a write — so these are reported at their real physical
353
+ // key and migrate only when actually read. A prefixed entry for the same
354
+ // logical key always wins, so upgraded keys are never listed twice.
355
+ if (this.migrateLegacyKeys && this.prefix) {
356
+ for (let i = 0; i < storage.length; i++) {
357
+ const fullKey = storage.key(i);
358
+ if (!fullKey || fullKey.startsWith(this.prefix) || seen.has(fullKey))
359
+ continue;
360
+ const value = this.parseOwnValue(storage.getItem(fullKey), fullKey);
361
+ if (!value)
362
+ continue;
363
+ if (!includeExpired && this.isExpired(value))
364
+ continue;
365
+ seen.add(fullKey);
366
+ owned.push({ key: fullKey, fullKey, value });
225
367
  }
226
368
  }
227
- return this.filterKeys(keys, pattern);
369
+ return owned;
370
+ }
371
+ /**
372
+ * Remove an entry `ownKeys()` returned, by its PHYSICAL key.
373
+ *
374
+ * 🔴 Not `removeSync(key)`. That rebuilds the physical key as `prefix + key`,
375
+ * which is wrong for a pre-3.0 legacy entry — those live at the bare key, so
376
+ * `ownKeys()` reports `fullKey === key` for them. Rebuilding would delete
377
+ * `strata:<key>` instead and leave the legacy entry behind, so `clear()` and
378
+ * the expiry sweep would silently skip exactly the entries not yet migrated.
379
+ */
380
+ removeOwnedEntry(entry) {
381
+ this.getStorage().removeItem(entry.fullKey);
382
+ if (this.hasChangeListeners()) {
383
+ this.emitChange(entry.key, entry.value.value, undefined, 'local');
384
+ }
385
+ }
386
+ /**
387
+ * Reclaim expired entries, returning how many were removed.
388
+ *
389
+ * Overrides the base per-key sweep for two reasons. It reads each key once
390
+ * instead of twice, and — the load-bearing one — the base sweep is built on
391
+ * `keys()`, which does not surface expired entries here, so it could only ever
392
+ * report 0. Before this override the reaping happened as an undocumented side
393
+ * effect of `getSync()` deleting what it found expired during enumeration,
394
+ * while the returned count stayed 0.
395
+ */
396
+ async cleanupExpired() {
397
+ let removed = 0;
398
+ for (const entry of this.ownKeys(true)) {
399
+ if (this.isExpired(entry.value)) {
400
+ this.removeOwnedEntry(entry);
401
+ removed++;
402
+ }
403
+ }
404
+ return removed;
228
405
  }
229
406
  /**
230
407
  * Check if key exists (synchronous)
@@ -242,20 +419,21 @@ export class LocalStorageAdapter extends BaseAdapter {
242
419
  let keySize = 0;
243
420
  let valueSize = 0;
244
421
  const byKey = {};
245
- for (let i = 0; i < window.localStorage.length; i++) {
246
- const fullKey = window.localStorage.key(i);
247
- if (fullKey?.startsWith(this.prefix)) {
248
- const item = window.localStorage.getItem(fullKey);
249
- if (item) {
250
- count++;
251
- const key = fullKey.substring(this.prefix.length);
252
- const itemSize = (fullKey.length + item.length) * 2; // UTF-16
253
- total += itemSize;
254
- if (detailed) {
255
- keySize += fullKey.length * 2;
256
- valueSize += item.length * 2;
257
- byKey[key] = itemSize;
258
- }
422
+ // `this.getStorage()`, never `window.localStorage` the subclass points at a
423
+ // different area, and hard-coding it here made every inherited method wrong
424
+ // for sessionStorage until the subclass re-implemented it. Owned keys only,
425
+ // so a shared area is not reported as this adapter's footprint.
426
+ const storage = this.getStorage();
427
+ for (const { fullKey, key } of this.ownKeys(true)) {
428
+ const item = storage.getItem(fullKey);
429
+ if (item) {
430
+ count++;
431
+ const itemSize = (fullKey.length + item.length) * 2; // UTF-16
432
+ total += itemSize;
433
+ if (detailed) {
434
+ keySize += fullKey.length * 2;
435
+ valueSize += item.length * 2;
436
+ byKey[key] = itemSize;
259
437
  }
260
438
  }
261
439
  }
@@ -278,15 +456,27 @@ export class LocalStorageAdapter extends BaseAdapter {
278
456
  const unsubscribeLocal = super.subscribe(callback);
279
457
  // Also subscribe to remote changes via storage events
280
458
  const listener = (event) => {
281
- // Only process events from other windows/tabs
282
- if (event.storageArea !== window.localStorage)
459
+ // Only process events from this adapter's own area. Comparing against
460
+ // `window.localStorage` by name meant the sessionStorage subclass could
461
+ // never match its own events.
462
+ let area;
463
+ try {
464
+ area = this.getStorage();
465
+ }
466
+ catch {
467
+ return;
468
+ }
469
+ if (event.storageArea !== area)
283
470
  return;
284
- // Check if the key belongs to us
471
+ // Check if the key belongs to us — by name AND by shape, since an empty
472
+ // prefix matches every key another script on this origin writes.
285
473
  if (!event.key || !event.key.startsWith(this.prefix))
286
474
  return;
287
475
  const key = event.key.substring(this.prefix.length);
288
- const oldValue = event.oldValue ? deserialize(event.oldValue) : null;
289
- const newValue = event.newValue ? deserialize(event.newValue) : null;
476
+ const oldValue = this.parseOwnValue(event.oldValue, key);
477
+ const newValue = this.parseOwnValue(event.newValue, key);
478
+ if (!oldValue && !newValue)
479
+ return;
290
480
  callback({
291
481
  key,
292
482
  oldValue: oldValue?.value ?? undefined,
@@ -20,6 +20,11 @@ export declare class MemoryAdapter extends BaseAdapter {
20
20
  /**
21
21
  * Initialize the adapter
22
22
  */
23
+ configure(config?: {
24
+ maxSize?: number;
25
+ }): void;
26
+ /** Memory is always usable — no environment can take it away. */
27
+ isAvailableSync(): boolean;
23
28
  initialize(config?: {
24
29
  maxSize?: number;
25
30
  }): Promise<void>;
@@ -33,8 +33,16 @@ export class MemoryAdapter extends BaseAdapter {
33
33
  /**
34
34
  * Initialize the adapter
35
35
  */
36
+ configure(config) {
37
+ if (config?.maxSize !== undefined)
38
+ this.maxSize = config.maxSize;
39
+ }
40
+ /** Memory is always usable — no environment can take it away. */
41
+ isAvailableSync() {
42
+ return true;
43
+ }
36
44
  async initialize(config) {
37
- this.maxSize = config?.maxSize;
45
+ this.configure(config);
38
46
  this.startTTLCleanup();
39
47
  }
40
48
  /**