strata-storage 2.9.0 → 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.
@@ -82,7 +82,21 @@ export const storage = defineStorage({
82
82
  });
83
83
  ```
84
84
 
85
- ## Shared storage areas how this library identifies its own keys (2.9.0)
85
+ ## Key prefix and the 3.0.0 migration
86
+
87
+ `localStorage` and `sessionStorage` keys are written under **`strata:`** as of 3.0.0
88
+ (`DEFAULT_WEB_KEY_PREFIX`, exported). Existing data migrates itself: a miss at `strata:<key>` falls back to the bare `<key>`, and if the value is one of ours it is moved under the prefix. Per key, on read — never a bulk sweep, never a value that is not our envelope, never overwriting an existing prefixed value.
89
+
90
+ 🔴 **Set `keyPrefix: false` when anything outside this library reads a physical key directly** — a pre-paint theme script, a logger reading its own level. Migration keeps the data reachable through this library; it cannot fix a hard-coded reader.
91
+
92
+ ```typescript
93
+ export const storage = defineStorage({ keyPrefix: false }); // pre-3.0 keys
94
+ export const storage = defineStorage({ migrateLegacyKeys: false }); // don't adopt 2.x entries
95
+ ```
96
+
97
+ `cookies` (already `strata_`), `indexedDB`, `cache`, `memory` and `url` are unchanged. `namespace` is a separate mechanism: the physical key is `<keyPrefix><namespace>:<key>`.
98
+
99
+ ## Shared storage areas — how this library identifies its own keys
86
100
 
87
101
  `localStorage`, `sessionStorage` and cookies are shared with every other script on the origin, and the default key prefix is empty — so a name test cannot tell our keys from theirs. **A key counts as ours only when its stored value is a `StorageValue` envelope.**
88
102
 
package/CHANGELOG.md CHANGED
@@ -5,6 +5,66 @@ All notable changes to Strata Storage will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.0] - 2026-09-01
9
+
10
+ **Breaking: the web adapters now prefix their keys with `strata:`.** One line restores the old behaviour,
11
+ and existing data migrates itself on read — but read the migration notes before upgrading, because a
12
+ consumer that reads a physical key from outside this library must opt out.
13
+
14
+ ### Breaking
15
+
16
+ - **`localStorage` and `sessionStorage` keys are now written under `strata:`.** `DEFAULT_WEB_KEY_PREFIX`
17
+ is exported. Before this release the default prefix was the empty string, so this library's keys sat
18
+ unprefixed among every other script's on the origin. 2.9.0 made that *safe* — a key is ours only if its
19
+ value is a `StorageValue` envelope — and this makes it *tidy*, so our keys are identifiable by name too.
20
+ - `cookies` are unchanged: they already defaulted to `strata_`, and moving them would break existing
21
+ cookies for no gain.
22
+ - `indexedDB` and `cache` own a named store, `memory` owns its own Map, and the URL adapter already
23
+ prefixes its params. None of them can collide with another script's keys, so none of them change.
24
+ - `namespace` is a separate mechanism and is unaffected. The physical key is
25
+ `<keyPrefix><namespace>:<key>`.
26
+
27
+ ### Added
28
+
29
+ - **`keyPrefix`** — set `false` (or `''`) for the pre-3.0 behaviour, or a string for your own prefix. A
30
+ per-adapter `adapters.localStorage.prefix` still wins over it.
31
+ - **`migrateLegacyKeys`** (default `true`) — adopt pre-3.0 unprefixed entries on read.
32
+
33
+ ### Migration
34
+
35
+ **Most consumers need to do nothing.** On a miss at `strata:<key>`, the adapter looks for the bare `<key>`
36
+ and, if the value is one of ours, moves it under the prefix and returns it. Your data stays reachable.
37
+
38
+ Three properties of that migration, each verified in a browser:
39
+
40
+ - **It is per key, on read — never a bulk sweep.** A sweep would adopt every unprefixed envelope on the
41
+ origin, including keys belonging to an instance that opted out or to a sibling app still on 2.x.
42
+ - **It never adopts a value that is not ours** — the 2.9.0 envelope check is what makes it safe at all.
43
+ A planted `_cltk` is left exactly where it is.
44
+ - **It never overwrites.** A value already at the prefixed key wins and the legacy entry is left alone.
45
+
46
+ 🔴 **Take `keyPrefix: false` if anything outside this library reads a physical key directly** — a
47
+ pre-paint theme script that runs before any module loads, or a logger reading its own level. Those
48
+ readers know the exact key name, and a prefix changes it underneath them. Migration keeps the *data*
49
+ reachable through this library; it cannot fix a hard-coded reader.
50
+
51
+ ```typescript
52
+ // Frozen physical key names, or an external reader — keep 2.x behaviour:
53
+ export const storage = defineStorage({ keyPrefix: false });
54
+ ```
55
+
56
+ 🔴 **Two applications sharing one origin, one still on 2.x:** set `migrateLegacyKeys: false` on the
57
+ upgraded one, so it does not move keys the other still reads. Better, give each app a `namespace` —
58
+ they were already colliding before this release.
59
+
60
+ ### Fixed
61
+
62
+ - **A live timer no longer keeps a Node process alive.** `setInterval` holds the event loop open, so a
63
+ short-lived script — a build step, a CLI, an SSR warmup — that created an instance never exited unless
64
+ it also called `close()`. Measured: a script doing one `set`/`get` hung until killed at 120s, and now
65
+ exits immediately. All three timers (adapter TTL, cross-adapter cleanup, auto-backup) are `unref`'d;
66
+ it is a no-op in browsers.
67
+
8
68
  ## [2.9.0] - 2026-09-01
9
69
 
10
70
  Closes the entire consumer-reported issue queue — six entries from five projects (LabFlow, ClearHire,
package/README.md CHANGED
@@ -32,7 +32,7 @@ integrity checksums and mirrored backups are opt-in per call or per instance.
32
32
 
33
33
  | | |
34
34
  |---|---|
35
- | **Version** | `2.9.0` |
35
+ | **Version** | `3.0.0` |
36
36
  | **License** | MIT |
37
37
  | **Node** | `>=24.13.0` |
38
38
  | **Platforms** | Web · iOS · Android (via Capacitor) |
@@ -306,6 +306,8 @@ Passed to `defineStorage(config)` or `new Strata(config)`:
306
306
  | Option | Type | Default | What it does |
307
307
  |---|---|---|---|
308
308
  | `defaultStorages` | `StorageType[]` | `['localStorage', 'indexedDB', 'sessionStorage', 'memory']` | Preference order for picking the default adapter, and the fallback order when one is unusable. 🔴 **Not a registration list** — operations with no explicit `storage` (`keys`, `clear`, `size`, `subscribe`) span every *registered* adapter. To leave one out entirely use `adapters: { <name>: false }`. |
309
+ | `keyPrefix` | `string \| false` | `'strata:'` | Key prefix for `localStorage`/`sessionStorage`. `false` restores pre-3.0 unprefixed keys — take it when anything outside this library reads a physical key directly. |
310
+ | `migrateLegacyKeys` | `boolean` | `true` | Adopt pre-3.0 unprefixed entries on read: never a bulk sweep, never a non-envelope, never overwriting an existing prefixed value. |
309
311
  | `adapters` | `object` | `{}` | Per-adapter settings, or `false` to leave that adapter unregistered entirely. `localStorage`/`sessionStorage` take `{ prefix }`; `indexedDB` takes `{ dbName, version }`; `cookies` takes `{ secure, sameSite }`; `cache` takes `{ cacheName }`. Settings apply immediately, so they hold for `setSync`/`getSync` issued before `initialize()` resolves. |
310
312
  | `encryption` | `{ enabled, password }` | disabled | AES-GCM encryption on every write. Async path only. |
311
313
  | `compression` | `{ enabled, threshold }` | disabled | Compress values above `threshold` bytes. Async path only. |
@@ -466,16 +468,17 @@ More: [Troubleshooting](https://stratastorage-docs.aoneahsan.com/reference/troub
466
468
  - **Node support is minimal.** Only the `memory` adapter is available outside a browser, so values do not
467
469
  persist across processes.
468
470
  - **Web adapters share their storage area, and identify their own data by shape.** `localStorage`,
469
- `sessionStorage` and cookies are shared with every other script on the origin, and the default key
470
- prefix is empty so a name test alone cannot tell our keys from theirs. Since `2.9.0` an adapter
471
- treats a key as its own only when the stored value is a `StorageValue` envelope, so `keys()`, the TTL
472
- sweep and `clear()` never touch another application's data, and a value we cannot read is skipped at
473
- `debug` rather than reported as an error. **The consequence to know about:** a key written to the same
474
- area by something other than this library is invisible to `keys()` by design. Call `setLogLevel('debug')`
475
- to see what is being skipped and why. Setting a prefix or a namespace is still worth doing for a clean
476
- keyspace `defineStorage({ namespace: 'myapp' })`, or
477
- `defineStorage({ adapters: { localStorage: { prefix: 'myapp:' } } })` but it is no longer what keeps
478
- the library off other people's keys.
471
+ `sessionStorage` and cookies are shared with every other script on the origin. An adapter treats a key
472
+ as its own only when the stored value is a `StorageValue` envelope, so `keys()`, the TTL sweep and
473
+ `clear()` never touch another application's data, and a value we cannot read is skipped at `debug`
474
+ rather than reported as an error. **The consequence to know about:** a key written to the same area by
475
+ something other than this library is invisible to `keys()` by design. Call `setLogLevel('debug')` to see
476
+ what is being skipped and why.
477
+ - **Keys are prefixed `strata:` since 3.0.0, and that is a breaking change.** Existing data migrates
478
+ itself on read, so most consumers do nothing. 🔴 **But if anything outside this library reads a physical
479
+ key directly a pre-paint theme script, a logger reading its own level set
480
+ `defineStorage({ keyPrefix: false })`.** Migration keeps the data reachable through this library; it
481
+ cannot fix a hard-coded reader. See [Changelog](https://github.com/aoneahsan/strata-storage/blob/main/CHANGELOG.md).
479
482
  - **Firebase adapter names are not in the `StorageType` union.** `'firestore'` and `'realtime'` are runtime
480
483
  names, so strict TypeScript may need a cast on the options object.
481
484
 
@@ -7,11 +7,32 @@ 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
@@ -23,7 +44,34 @@ export declare class LocalStorageAdapter extends BaseAdapter {
23
44
  */
24
45
  configure(config?: {
25
46
  prefix?: string;
47
+ migrateLegacyKeys?: boolean;
26
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;
27
75
  /**
28
76
  * Initialize the adapter
29
77
  */
@@ -98,6 +146,20 @@ export declare class LocalStorageAdapter extends BaseAdapter {
98
146
  fullKey: string;
99
147
  value: StorageValue;
100
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;
101
163
  /**
102
164
  * Reclaim expired entries, returning how many were removed.
103
165
  *
@@ -9,6 +9,17 @@ 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
  }
@@ -54,6 +75,70 @@ export class LocalStorageAdapter extends BaseAdapter {
54
75
  if (config?.prefix !== undefined) {
55
76
  this.prefix = config.prefix;
56
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;
57
142
  }
58
143
  /**
59
144
  * Initialize the adapter
@@ -115,7 +200,8 @@ export class LocalStorageAdapter extends BaseAdapter {
115
200
  }
116
201
  // A value that is not our envelope belongs to somebody else sharing this
117
202
  // area. parseOwnValue() logs it at debug and returns null — never an error.
118
- const value = this.parseOwnValue(item, key);
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);
119
205
  if (!value)
120
206
  return null;
121
207
  if (this.isExpired(value)) {
@@ -200,7 +286,8 @@ export class LocalStorageAdapter extends BaseAdapter {
200
286
  // Iterates owned entries INCLUDING expired ones: `expiredOnly` filters on
201
287
  // exactly the entries `keysSync()` leaves out, so driving this loop from
202
288
  // `keysSync()` made that option a guaranteed no-op.
203
- for (const { key, value } of this.ownKeys(true)) {
289
+ for (const entry of this.ownKeys(true)) {
290
+ const { key, value } = entry;
204
291
  let shouldDelete = true;
205
292
  const pattern = options.pattern || options.prefix;
206
293
  if (pattern) {
@@ -215,7 +302,7 @@ export class LocalStorageAdapter extends BaseAdapter {
215
302
  shouldDelete = false;
216
303
  }
217
304
  if (shouldDelete) {
218
- this.removeSync(key);
305
+ this.removeOwnedEntry(entry);
219
306
  }
220
307
  }
221
308
  }
@@ -243,6 +330,7 @@ export class LocalStorageAdapter extends BaseAdapter {
243
330
  ownKeys(includeExpired = false) {
244
331
  const storage = this.getStorage();
245
332
  const owned = [];
333
+ const seen = new Set();
246
334
  for (let i = 0; i < storage.length; i++) {
247
335
  const fullKey = storage.key(i);
248
336
  if (!fullKey?.startsWith(this.prefix))
@@ -253,10 +341,48 @@ export class LocalStorageAdapter extends BaseAdapter {
253
341
  continue;
254
342
  if (!includeExpired && this.isExpired(value))
255
343
  continue;
344
+ seen.add(key);
256
345
  owned.push({ key, fullKey, value });
257
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 });
367
+ }
368
+ }
258
369
  return owned;
259
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
+ }
260
386
  /**
261
387
  * Reclaim expired entries, returning how many were removed.
262
388
  *
@@ -269,9 +395,9 @@ export class LocalStorageAdapter extends BaseAdapter {
269
395
  */
270
396
  async cleanupExpired() {
271
397
  let removed = 0;
272
- for (const { key, value } of this.ownKeys(true)) {
273
- if (this.isExpired(value)) {
274
- this.removeSync(key);
398
+ for (const entry of this.ownKeys(true)) {
399
+ if (this.isExpired(entry.value)) {
400
+ this.removeOwnedEntry(entry);
275
401
  removed++;
276
402
  }
277
403
  }
@@ -45,6 +45,13 @@ export class BaseAdapter {
45
45
  logger.error(`TTL cleanup error in ${this.name}:`, error);
46
46
  }
47
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?.();
48
55
  }
49
56
  /**
50
57
  * Stop TTL cleanup
@@ -320,7 +320,27 @@ export declare class Strata {
320
320
  * ```
321
321
  */
322
322
  registerAdapter(adapter: StorageAdapter): void;
323
- /** The configured options for one adapter, or undefined when it has none. */
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
+ */
324
344
  private adapterConfigFor;
325
345
  /** Push the configured options into an adapter synchronously. */
326
346
  private applyAdapterConfig;
@@ -116,6 +116,9 @@ export class Strata {
116
116
  this._ttlCleanupTimer = setInterval(() => {
117
117
  void this.cleanupAllAdapters();
118
118
  }, interval);
119
+ // See BaseAdapter.startTTLCleanup — a live interval must not keep a Node
120
+ // process alive. No-op in browsers.
121
+ this._ttlCleanupTimer.unref?.();
119
122
  }
120
123
  // Start periodic auto-backup if configured
121
124
  if (this.config.autoBackup?.interval) {
@@ -1304,10 +1307,49 @@ export class Strata {
1304
1307
  // to it did not reach the sync path.
1305
1308
  this.applyAdapterConfig(adapter);
1306
1309
  }
1307
- /** The configured options for one adapter, or undefined when it has none. */
1310
+ /**
1311
+ * Web adapters that share their storage area with every other script on the
1312
+ * origin, and therefore take the instance-wide `keyPrefix`.
1313
+ *
1314
+ * Cookies are excluded deliberately: they already default to `strata_`, so
1315
+ * moving them would break existing cookies for no gain. IndexedDB and the Cache
1316
+ * API own a named store, memory owns its own Map, and the URL adapter already
1317
+ * prefixes its params — none of them can collide with another script's keys.
1318
+ */
1319
+ static PREFIXED_WEB_ADAPTERS = new Set([
1320
+ 'localStorage',
1321
+ 'sessionStorage',
1322
+ ]);
1323
+ /**
1324
+ * The configured options for one adapter, or undefined when it has none.
1325
+ *
1326
+ * For the shared-area web adapters this also resolves the 3.0.0 key prefix and
1327
+ * decides whether that adapter may adopt pre-3.0 unprefixed entries. Precedence,
1328
+ * and it lives only here:
1329
+ *
1330
+ * 1. `adapters.<name>.prefix` — the most specific thing the caller wrote
1331
+ * 2. `keyPrefix` — the instance-wide switch (`false` restores 2.x behaviour)
1332
+ * 3. the adapter's own constructor default (`DEFAULT_WEB_KEY_PREFIX`)
1333
+ */
1308
1334
  adapterConfigFor(name) {
1309
1335
  const raw = this.config.adapters?.[name];
1310
- return typeof raw === 'object' && raw !== null ? raw : undefined;
1336
+ const explicit = typeof raw === 'object' && raw !== null ? { ...raw } : undefined;
1337
+ if (!Strata.PREFIXED_WEB_ADAPTERS.has(name))
1338
+ return explicit;
1339
+ const resolved = explicit ?? {};
1340
+ // Only fill in the prefix when the caller did not name one for this adapter.
1341
+ if (resolved.prefix === undefined && this.config.keyPrefix !== undefined) {
1342
+ resolved.prefix = this.config.keyPrefix === false ? '' : this.config.keyPrefix;
1343
+ }
1344
+ // 🔴 Migration is enabled HERE and nowhere else — only for an adapter whose
1345
+ // prefix this instance resolved. A directly constructed adapter must never
1346
+ // adopt bare keys: `plugin/web.ts` builds a `strata_prefs_` instance beside
1347
+ // the main one, and if that adopted every unprefixed entry it found it would
1348
+ // take them from the instance they belong to.
1349
+ if (resolved.migrateLegacyKeys === undefined) {
1350
+ resolved.migrateLegacyKeys = this.config.migrateLegacyKeys !== false;
1351
+ }
1352
+ return resolved;
1311
1353
  }
1312
1354
  /** Push the configured options into an adapter synchronously. */
1313
1355
  applyAdapterConfig(adapter) {
@@ -1678,6 +1720,7 @@ export class Strata {
1678
1720
  }
1679
1721
  })();
1680
1722
  }, cfg.interval);
1723
+ this._autoBackupTimer.unref?.();
1681
1724
  }
1682
1725
  async selectAdapter(storage) {
1683
1726
  await this.ensureReady();
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export { CookieAdapter } from "./adapters/web/CookieAdapter.js";
8
8
  export { CacheAdapter } from "./adapters/web/CacheAdapter.js";
9
9
  export { MemoryAdapter } from "./adapters/web/MemoryAdapter.js";
10
10
  export { URLAdapter, type URLAdapterConfig } from "./adapters/web/URLAdapter.js";
11
+ export { DEFAULT_WEB_KEY_PREFIX } from "./adapters/web/LocalStorageAdapter.js";
11
12
  export { EncryptionManager } from "./features/encryption.js";
12
13
  export { CompressionManager } from "./features/compression.js";
13
14
  export { TTLManager } from "./features/ttl.js";
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { CookieAdapter } from "./adapters/web/CookieAdapter.js";
10
10
  export { CacheAdapter } from "./adapters/web/CacheAdapter.js";
11
11
  export { MemoryAdapter } from "./adapters/web/MemoryAdapter.js";
12
12
  export { URLAdapter } from "./adapters/web/URLAdapter.js";
13
+ export { DEFAULT_WEB_KEY_PREFIX } from "./adapters/web/LocalStorageAdapter.js";
13
14
  // Core features
14
15
  export { EncryptionManager } from "./features/encryption.js";
15
16
  export { CompressionManager } from "./features/compression.js";
@@ -308,6 +308,38 @@ export interface StrataConfig {
308
308
  * Default storage types in order of preference
309
309
  */
310
310
  defaultStorages?: StorageType[];
311
+ /**
312
+ * Key prefix for the web adapters that share a storage area with every other
313
+ * script on the origin (`localStorage`, `sessionStorage`).
314
+ *
315
+ * Defaults to `'strata:'` as of 3.0.0. Set `false` (or `''`) for the pre-3.0
316
+ * behaviour of writing to the bare key.
317
+ *
318
+ * 🔴 **Take the opt-out when anything outside this library reads a physical key
319
+ * directly** — a pre-paint theme script that runs before any module loads, or a
320
+ * logger reading its own level. Those readers know the exact key name, and a
321
+ * prefix changes it underneath them. `migrateLegacyKeys` keeps the *data*
322
+ * reachable through this library, but it cannot fix a hard-coded reader.
323
+ *
324
+ * Composes with `namespace`, which is a separate mechanism and unaffected:
325
+ * the physical key is `<keyPrefix><namespace>:<key>`. A per-adapter
326
+ * `adapters.localStorage.prefix` overrides this for that adapter.
327
+ */
328
+ keyPrefix?: string | false;
329
+ /**
330
+ * Whether the shared-area web adapters may adopt pre-3.0 unprefixed entries.
331
+ * Default `true`.
332
+ *
333
+ * Migration is per key and happens on read: a miss at the prefixed key falls
334
+ * back to the bare key, and if the value is one of ours it is moved under the
335
+ * prefix. It never overwrites an existing prefixed value, and it never adopts a
336
+ * value that is not a `StorageValue` envelope — which is what stops it taking
337
+ * another application's keys.
338
+ *
339
+ * Set `false` when two applications share an origin and one of them is still on
340
+ * 2.x, so that upgrading one does not move keys the other still reads.
341
+ */
342
+ migrateLegacyKeys?: boolean;
311
343
  /**
312
344
  * Adapter configuration
313
345
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strata-storage",
3
- "version": "2.9.0",
3
+ "version": "3.0.0",
4
4
  "description": "One storage API across web, iOS and Android — zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "packageManager": "yarn@4.17.0",