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.
@@ -5,8 +5,20 @@
5
5
  import { LocalStorageAdapter } from "./LocalStorageAdapter.js";
6
6
  import type { StorageType, StorageCapabilities } from "../../types/index.js";
7
7
  /**
8
- * Browser sessionStorage adapter
9
- * Extends LocalStorageAdapter as the API is identical
8
+ * Browser sessionStorage adapter.
9
+ *
10
+ * 🔴 This class overrides ONLY what actually differs: the identity, the
11
+ * capabilities, and which `Storage` object to talk to. Everything else is
12
+ * inherited, because `LocalStorageAdapter` routes every read and write through
13
+ * `getStorage()`.
14
+ *
15
+ * It used to re-implement `get`/`set`/`remove`/`clear`/`keys`/`size`/`subscribe`
16
+ * — ~230 lines differing only by naming `window.sessionStorage`. That is how one
17
+ * defect became two issue numbers: the copy carried its own
18
+ * `logger.error('Failed to get key … from sessionStorage')`, so fixing the
19
+ * localStorage path left the sessionStorage path untouched, and the reports came
20
+ * back separately (ISSUE-01 for `localStorage`, ISSUE-09 for `sessionStorage`,
21
+ * one root cause). Keep the override surface minimal so that cannot recur.
10
22
  */
11
23
  export declare class SessionStorageAdapter extends LocalStorageAdapter {
12
24
  readonly name: StorageType;
@@ -17,60 +29,8 @@ export declare class SessionStorageAdapter extends LocalStorageAdapter {
17
29
  */
18
30
  isAvailable(): Promise<boolean>;
19
31
  /**
20
- * Override all methods to use sessionStorage instead of localStorage
32
+ * The backing Storage object. This is the ONLY thing that differs from the
33
+ * parent — every inherited method reads and writes through it.
21
34
  */
22
35
  protected getStorage(): Storage;
23
- /**
24
- * Get a value from sessionStorage
25
- */
26
- get<T = unknown>(key: string): Promise<import("../../types/index.js").StorageValue<T> | null>;
27
- /**
28
- * Get a value from sessionStorage (synchronous)
29
- */
30
- getSync<T = unknown>(key: string): import("../../types/index.js").StorageValue<T> | null;
31
- /**
32
- * Set a value in sessionStorage
33
- */
34
- set<T = unknown>(key: string, value: import("../../types/index.js").StorageValue<T>): Promise<void>;
35
- /**
36
- * Set a value in sessionStorage (synchronous)
37
- */
38
- setSync<T = unknown>(key: string, value: import("../../types/index.js").StorageValue<T>): void;
39
- /**
40
- * Remove a value from sessionStorage
41
- */
42
- remove(key: string): Promise<void>;
43
- /**
44
- * Remove a value from sessionStorage (synchronous)
45
- */
46
- removeSync(key: string): void;
47
- /**
48
- * Clear sessionStorage
49
- */
50
- clear(options?: import("../../types/index.js").ClearOptions): Promise<void>;
51
- /**
52
- * Clear sessionStorage (synchronous)
53
- */
54
- clearSync(options?: import("../../types/index.js").ClearOptions): void;
55
- /**
56
- * Get all keys
57
- */
58
- keys(pattern?: string | RegExp): Promise<string[]>;
59
- /**
60
- * Get all keys (synchronous)
61
- */
62
- keysSync(pattern?: string | RegExp): string[];
63
- /**
64
- * Check if key exists (synchronous)
65
- */
66
- hasSync(key: string): boolean;
67
- /**
68
- * Get storage size
69
- */
70
- size(detailed?: boolean): Promise<import("../../types/index.js").SizeInfo>;
71
- /**
72
- * Subscribe to storage changes
73
- * Note: sessionStorage doesn't fire storage events in the same tab
74
- */
75
- subscribe(callback: import("../../types/index.js").SubscriptionCallback): import("../../types/index.js").UnsubscribeFunction;
76
36
  }
@@ -3,12 +3,22 @@
3
3
  * Provides session-scoped storage with 5-10MB limit
4
4
  */
5
5
  import { LocalStorageAdapter } from "./LocalStorageAdapter.js";
6
- import { serialize, deserialize } from "../../utils/index.js";
7
- import { QuotaExceededError, SerializationError, StorageError } from "../../utils/errors.js";
8
- import { logger } from "../../utils/logger.js";
6
+ import { StorageError } from "../../utils/errors.js";
9
7
  /**
10
- * Browser sessionStorage adapter
11
- * Extends LocalStorageAdapter as the API is identical
8
+ * Browser sessionStorage adapter.
9
+ *
10
+ * 🔴 This class overrides ONLY what actually differs: the identity, the
11
+ * capabilities, and which `Storage` object to talk to. Everything else is
12
+ * inherited, because `LocalStorageAdapter` routes every read and write through
13
+ * `getStorage()`.
14
+ *
15
+ * It used to re-implement `get`/`set`/`remove`/`clear`/`keys`/`size`/`subscribe`
16
+ * — ~230 lines differing only by naming `window.sessionStorage`. That is how one
17
+ * defect became two issue numbers: the copy carried its own
18
+ * `logger.error('Failed to get key … from sessionStorage')`, so fixing the
19
+ * localStorage path left the sessionStorage path untouched, and the reports came
20
+ * back separately (ISSUE-01 for `localStorage`, ISSUE-09 for `sessionStorage`,
21
+ * one root cause). Keep the override surface minimal so that cannot recur.
12
22
  */
13
23
  export class SessionStorageAdapter extends LocalStorageAdapter {
14
24
  name = 'sessionStorage';
@@ -30,22 +40,14 @@ export class SessionStorageAdapter extends LocalStorageAdapter {
30
40
  * Check if sessionStorage is available
31
41
  */
32
42
  async isAvailable() {
33
- try {
34
- if (typeof window === 'undefined' || !window.sessionStorage) {
35
- return false;
36
- }
37
- // Test if we can actually use it
38
- const testKey = `${this.prefix}__test__`;
39
- window.sessionStorage.setItem(testKey, 'test');
40
- window.sessionStorage.removeItem(testKey);
41
- return true;
42
- }
43
- catch {
43
+ if (typeof window === 'undefined' || !window.sessionStorage) {
44
44
  return false;
45
45
  }
46
+ return this.isAvailableSync();
46
47
  }
47
48
  /**
48
- * Override all methods to use sessionStorage instead of localStorage
49
+ * The backing Storage object. This is the ONLY thing that differs from the
50
+ * parent — every inherited method reads and writes through it.
49
51
  */
50
52
  getStorage() {
51
53
  if (typeof window === 'undefined' || !window.sessionStorage) {
@@ -53,210 +55,4 @@ export class SessionStorageAdapter extends LocalStorageAdapter {
53
55
  }
54
56
  return window.sessionStorage;
55
57
  }
56
- /**
57
- * Get a value from sessionStorage
58
- */
59
- async get(key) {
60
- return this.getSync(key);
61
- }
62
- /**
63
- * Get a value from sessionStorage (synchronous)
64
- */
65
- getSync(key) {
66
- try {
67
- const item = window.sessionStorage.getItem(this.prefix + key);
68
- if (!item)
69
- return null;
70
- const value = deserialize(item);
71
- // Check TTL
72
- if (this.isExpired(value)) {
73
- this.removeSync(key);
74
- return null;
75
- }
76
- return value;
77
- }
78
- catch (error) {
79
- logger.error(`Failed to get key ${key} from sessionStorage:`, error);
80
- return null;
81
- }
82
- }
83
- /**
84
- * Set a value in sessionStorage
85
- */
86
- async set(key, value) {
87
- this.setSync(key, value);
88
- }
89
- /**
90
- * Set a value in sessionStorage (synchronous)
91
- */
92
- setSync(key, value) {
93
- const fullKey = this.prefix + key;
94
- const oldValue = this.getSync(key);
95
- try {
96
- const serialized = serialize(value);
97
- window.sessionStorage.setItem(fullKey, serialized);
98
- }
99
- catch (error) {
100
- if (this.isQuotaError(error)) {
101
- throw new QuotaExceededError('SessionStorage quota exceeded', { key, error });
102
- }
103
- throw new SerializationError(`Failed to store key ${key} in sessionStorage`, error);
104
- }
105
- // Emit change event
106
- this.emitChange(key, oldValue?.value, value.value, 'local');
107
- }
108
- /**
109
- * Remove a value from sessionStorage
110
- */
111
- async remove(key) {
112
- this.removeSync(key);
113
- }
114
- /**
115
- * Remove a value from sessionStorage (synchronous)
116
- */
117
- removeSync(key) {
118
- // Read raw — NOT via getSync(), which deletes expired entries by calling
119
- // removeSync() and would recurse here forever. Only read with a listener.
120
- let oldValue = null;
121
- if (this.hasChangeListeners()) {
122
- const item = window.sessionStorage.getItem(this.prefix + key);
123
- if (item) {
124
- try {
125
- oldValue = deserialize(item);
126
- }
127
- catch {
128
- oldValue = null;
129
- }
130
- }
131
- }
132
- window.sessionStorage.removeItem(this.prefix + key);
133
- if (oldValue) {
134
- this.emitChange(key, oldValue.value, undefined, 'local');
135
- }
136
- }
137
- /**
138
- * Clear sessionStorage
139
- */
140
- async clear(options) {
141
- this.clearSync(options);
142
- }
143
- /**
144
- * Clear sessionStorage (synchronous)
145
- */
146
- clearSync(options) {
147
- if (!options ||
148
- (!options.pattern && !options.prefix && !options.tags && !options.expiredOnly)) {
149
- // Clear all with our prefix
150
- const keysToRemove = [];
151
- for (let i = 0; i < window.sessionStorage.length; i++) {
152
- const key = window.sessionStorage.key(i);
153
- if (key?.startsWith(this.prefix)) {
154
- keysToRemove.push(key);
155
- }
156
- }
157
- keysToRemove.forEach((key) => window.sessionStorage.removeItem(key));
158
- this.emitChange('*', undefined, undefined, 'local');
159
- return;
160
- }
161
- // Synchronous filtered clear (mirrors BaseAdapter.clear logic)
162
- for (const key of this.keysSync()) {
163
- let shouldDelete = true;
164
- const pattern = options.pattern || options.prefix;
165
- if (pattern) {
166
- shouldDelete = this.filterKeys([key], pattern).length > 0;
167
- }
168
- if (shouldDelete && options.tags) {
169
- const value = this.getSync(key);
170
- if (!value?.tags || !options.tags.some((tag) => value.tags?.includes(tag))) {
171
- shouldDelete = false;
172
- }
173
- }
174
- if (shouldDelete && options.expiredOnly) {
175
- const value = this.getSync(key);
176
- if (!value || !this.isExpired(value)) {
177
- shouldDelete = false;
178
- }
179
- }
180
- if (shouldDelete) {
181
- this.removeSync(key);
182
- }
183
- }
184
- }
185
- /**
186
- * Get all keys
187
- */
188
- async keys(pattern) {
189
- return this.keysSync(pattern);
190
- }
191
- /**
192
- * Get all keys (synchronous)
193
- */
194
- keysSync(pattern) {
195
- const keys = [];
196
- for (let i = 0; i < window.sessionStorage.length; i++) {
197
- const fullKey = window.sessionStorage.key(i);
198
- if (fullKey?.startsWith(this.prefix)) {
199
- const key = fullKey.substring(this.prefix.length);
200
- // Check if not expired
201
- const value = this.getSync(key);
202
- if (value) {
203
- keys.push(key);
204
- }
205
- }
206
- }
207
- return this.filterKeys(keys, pattern);
208
- }
209
- /**
210
- * Check if key exists (synchronous)
211
- */
212
- hasSync(key) {
213
- const value = this.getSync(key);
214
- return value !== null && !this.isExpired(value);
215
- }
216
- /**
217
- * Get storage size
218
- */
219
- async size(detailed) {
220
- let total = 0;
221
- let count = 0;
222
- let keySize = 0;
223
- let valueSize = 0;
224
- const byKey = {};
225
- for (let i = 0; i < window.sessionStorage.length; i++) {
226
- const fullKey = window.sessionStorage.key(i);
227
- if (fullKey?.startsWith(this.prefix)) {
228
- const item = window.sessionStorage.getItem(fullKey);
229
- if (item) {
230
- count++;
231
- const key = fullKey.substring(this.prefix.length);
232
- const itemSize = (fullKey.length + item.length) * 2; // UTF-16
233
- total += itemSize;
234
- if (detailed) {
235
- keySize += fullKey.length * 2;
236
- valueSize += item.length * 2;
237
- byKey[key] = itemSize;
238
- }
239
- }
240
- }
241
- }
242
- const result = { total, count };
243
- if (detailed) {
244
- result.byKey = byKey;
245
- result.detailed = {
246
- keys: keySize,
247
- values: valueSize,
248
- metadata: 0,
249
- };
250
- }
251
- return result;
252
- }
253
- /**
254
- * Subscribe to storage changes
255
- * Note: sessionStorage doesn't fire storage events in the same tab
256
- */
257
- subscribe(callback) {
258
- // For sessionStorage, we only get local changes, not cross-tab
259
- // Use the base class subscription for local changes
260
- return super.subscribe(callback);
261
- }
262
58
  }
@@ -35,6 +35,9 @@ export declare class URLAdapter extends BaseAdapter {
35
35
  private snapshot;
36
36
  private changeListener?;
37
37
  isAvailable(): Promise<boolean>;
38
+ configure(config?: URLAdapterConfig): void;
39
+ /** The URL is usable whenever `window.location` is. */
40
+ isAvailableSync(): boolean;
38
41
  initialize(config?: URLAdapterConfig): Promise<void>;
39
42
  getSync<T = unknown>(key: string): StorageValue<T> | null;
40
43
  setSync<T = unknown>(key: string, value: StorageValue<T>): void;
@@ -39,7 +39,7 @@ export class URLAdapter extends BaseAdapter {
39
39
  typeof window.location !== 'undefined' &&
40
40
  typeof window.history !== 'undefined');
41
41
  }
42
- async initialize(config) {
42
+ configure(config) {
43
43
  if (config?.mode)
44
44
  this.mode = config.mode;
45
45
  if (config?.prefix !== undefined)
@@ -48,6 +48,13 @@ export class URLAdapter extends BaseAdapter {
48
48
  this.historyMode = config.history;
49
49
  if (typeof config?.maxLength === 'number')
50
50
  this.maxLength = config.maxLength;
51
+ }
52
+ /** The URL is usable whenever `window.location` is. */
53
+ isAvailableSync() {
54
+ return typeof window !== 'undefined' && typeof window.location !== 'undefined';
55
+ }
56
+ async initialize(config) {
57
+ this.configure(config);
51
58
  if (typeof window === 'undefined')
52
59
  return;
53
60
  // Seed the snapshot and listen for external navigation (back/forward, manual
@@ -14,6 +14,22 @@ export declare abstract class BaseAdapter implements StorageAdapter {
14
14
  protected queryEngine: QueryEngine;
15
15
  protected ttlCleanupInterval?: ReturnType<typeof setInterval>;
16
16
  protected ttlCheckInterval: number;
17
+ /**
18
+ * Apply adapter configuration SYNCHRONOUSLY, before any operation runs.
19
+ *
20
+ * 🔴 Config that changes the PHYSICAL KEY (`prefix`) cannot wait for the async
21
+ * `initialize()`. The synchronous API is deliberately usable before
22
+ * initialization completes — `Strata.selectAdapterSync()` falls back to the
23
+ * registry — so a `prefix` applied only at `initialize()` is silently absent
24
+ * for every `setSync`/`getSync` issued in that window, and the value lands at
25
+ * the bare key. That is the whole of ISSUE-08: `initialize({ prefix })` worked,
26
+ * and the config path to it did not.
27
+ *
28
+ * `initialize()` calls this first, so both paths agree. Default is a no-op;
29
+ * adapters with configurable fields override it with pure assignment only —
30
+ * never I/O, never a timer.
31
+ */
32
+ configure(_config?: unknown): void;
17
33
  /**
18
34
  * Initialize TTL cleanup if needed
19
35
  */
@@ -33,6 +49,22 @@ export declare abstract class BaseAdapter implements StorageAdapter {
33
49
  * `Strata.cleanupExpired()`.
34
50
  */
35
51
  cleanupExpired(): Promise<number>;
52
+ /**
53
+ * Parse a raw stored string into a value THIS adapter owns, or null.
54
+ *
55
+ * 🔴 The classification is the point, not the parse. Web adapters share their
56
+ * storage area with every other script on the origin, so a raw value that is
57
+ * not our envelope means the key belongs to somebody else — the ordinary case,
58
+ * not a failure. Reporting it through `logger.error` asserts something untrue
59
+ * about the consumer's own data, and on any origin running a third-party
60
+ * script that stores a raw string (Microsoft Clarity's `_cltk` is the usual
61
+ * one) it produces a permanent error stream that reaches their error tracker.
62
+ *
63
+ * `logger.error` is therefore reserved for a key carrying OUR envelope that
64
+ * still fails — which is a real defect, and currently cannot be distinguished
65
+ * from somebody else's data at all.
66
+ */
67
+ protected parseOwnValue<T = unknown>(raw: string | null, key: string): StorageValue<T> | null;
36
68
  /**
37
69
  * Check if value is expired
38
70
  */
@@ -2,7 +2,7 @@
2
2
  * Base adapter implementation with common functionality
3
3
  */
4
4
  import { NotSupportedError } from "../utils/errors.js";
5
- import { EventEmitter, matchGlob, getObjectSize } from "../utils/index.js";
5
+ import { EventEmitter, matchGlob, getObjectSize, deserialize, isStorageEnvelope } from "../utils/index.js";
6
6
  import { logger } from "../utils/logger.js";
7
7
  import { QueryEngine } from "../features/query.js";
8
8
  /**
@@ -13,6 +13,24 @@ export class BaseAdapter {
13
13
  queryEngine = new QueryEngine();
14
14
  ttlCleanupInterval;
15
15
  ttlCheckInterval = 60000; // Check every minute
16
+ /**
17
+ * Apply adapter configuration SYNCHRONOUSLY, before any operation runs.
18
+ *
19
+ * 🔴 Config that changes the PHYSICAL KEY (`prefix`) cannot wait for the async
20
+ * `initialize()`. The synchronous API is deliberately usable before
21
+ * initialization completes — `Strata.selectAdapterSync()` falls back to the
22
+ * registry — so a `prefix` applied only at `initialize()` is silently absent
23
+ * for every `setSync`/`getSync` issued in that window, and the value lands at
24
+ * the bare key. That is the whole of ISSUE-08: `initialize({ prefix })` worked,
25
+ * and the config path to it did not.
26
+ *
27
+ * `initialize()` calls this first, so both paths agree. Default is a no-op;
28
+ * adapters with configurable fields override it with pure assignment only —
29
+ * never I/O, never a timer.
30
+ */
31
+ configure(_config) {
32
+ // No configurable fields by default.
33
+ }
16
34
  /**
17
35
  * Initialize TTL cleanup if needed
18
36
  */
@@ -27,6 +45,13 @@ export class BaseAdapter {
27
45
  logger.error(`TTL cleanup error in ${this.name}:`, error);
28
46
  }
29
47
  }, this.ttlCheckInterval);
48
+ // 🔴 Do not hold a Node process open. An outstanding interval keeps the event
49
+ // loop alive, so a short-lived script — a build step, a CLI, an SSR warmup —
50
+ // that creates an instance and finishes its work never exits unless it also
51
+ // calls close(). `unref` does not exist in browsers (setInterval returns a
52
+ // number there), so the optional call is simply a no-op; in Node the timer
53
+ // keeps working for as long as the process does.
54
+ this.ttlCleanupInterval.unref?.();
30
55
  }
31
56
  /**
32
57
  * Stop TTL cleanup
@@ -60,6 +85,40 @@ export class BaseAdapter {
60
85
  }
61
86
  return removed;
62
87
  }
88
+ /**
89
+ * Parse a raw stored string into a value THIS adapter owns, or null.
90
+ *
91
+ * 🔴 The classification is the point, not the parse. Web adapters share their
92
+ * storage area with every other script on the origin, so a raw value that is
93
+ * not our envelope means the key belongs to somebody else — the ordinary case,
94
+ * not a failure. Reporting it through `logger.error` asserts something untrue
95
+ * about the consumer's own data, and on any origin running a third-party
96
+ * script that stores a raw string (Microsoft Clarity's `_cltk` is the usual
97
+ * one) it produces a permanent error stream that reaches their error tracker.
98
+ *
99
+ * `logger.error` is therefore reserved for a key carrying OUR envelope that
100
+ * still fails — which is a real defect, and currently cannot be distinguished
101
+ * from somebody else's data at all.
102
+ */
103
+ parseOwnValue(raw, key) {
104
+ if (raw === null || raw === '')
105
+ return null;
106
+ let parsed;
107
+ try {
108
+ parsed = deserialize(raw);
109
+ }
110
+ catch {
111
+ logger.debug(`${this.name}: skipping key "${key}" — value is not readable as ${this.name} data ` +
112
+ `(not written by this adapter).`);
113
+ return null;
114
+ }
115
+ if (!isStorageEnvelope(parsed)) {
116
+ logger.debug(`${this.name}: skipping key "${key}" — value carries no storage envelope ` +
117
+ `(not written by this adapter).`);
118
+ return null;
119
+ }
120
+ return parsed;
121
+ }
63
122
  /**
64
123
  * Check if value is expired
65
124
  */
@@ -222,6 +222,12 @@ export declare class Strata {
222
222
  clearSync(options?: ClearOptions & StorageOptions): void;
223
223
  private requireSyncAdapter;
224
224
  private selectAdapterSync;
225
+ /**
226
+ * Whether an adapter can serve a synchronous operation right now. An adapter
227
+ * with no `isAvailableSync` is assumed usable — absence of a probe is not
228
+ * evidence of unavailability.
229
+ */
230
+ private static isUsableSync;
225
231
  private syncCapableAdapters;
226
232
  private computeExpiration;
227
233
  /**
@@ -314,6 +320,30 @@ export declare class Strata {
314
320
  * ```
315
321
  */
316
322
  registerAdapter(adapter: StorageAdapter): void;
323
+ /**
324
+ * Web adapters that share their storage area with every other script on the
325
+ * origin, and therefore take the instance-wide `keyPrefix`.
326
+ *
327
+ * Cookies are excluded deliberately: they already default to `strata_`, so
328
+ * moving them would break existing cookies for no gain. IndexedDB and the Cache
329
+ * API own a named store, memory owns its own Map, and the URL adapter already
330
+ * prefixes its params — none of them can collide with another script's keys.
331
+ */
332
+ private static readonly PREFIXED_WEB_ADAPTERS;
333
+ /**
334
+ * The configured options for one adapter, or undefined when it has none.
335
+ *
336
+ * For the shared-area web adapters this also resolves the 3.0.0 key prefix and
337
+ * decides whether that adapter may adopt pre-3.0 unprefixed entries. Precedence,
338
+ * and it lives only here:
339
+ *
340
+ * 1. `adapters.<name>.prefix` — the most specific thing the caller wrote
341
+ * 2. `keyPrefix` — the instance-wide switch (`false` restores 2.x behaviour)
342
+ * 3. the adapter's own constructor default (`DEFAULT_WEB_KEY_PREFIX`)
343
+ */
344
+ private adapterConfigFor;
345
+ /** Push the configured options into an adapter synchronously. */
346
+ private applyAdapterConfig;
317
347
  /**
318
348
  * Initialize and attach any registered adapters that are not yet active, then
319
349
  * re-pick the default adapter. Call this after `registerAdapter()` on an