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.
- package/AI-INTEGRATION-GUIDE.md +43 -0
- package/CHANGELOG.md +151 -0
- package/README.md +17 -9
- package/dist/adapters/web/CacheAdapter.d.ts +4 -0
- package/dist/adapters/web/CacheAdapter.js +4 -1
- package/dist/adapters/web/CookieAdapter.d.ts +20 -1
- package/dist/adapters/web/CookieAdapter.js +89 -39
- package/dist/adapters/web/IndexedDBAdapter.d.ts +5 -0
- package/dist/adapters/web/IndexedDBAdapter.js +4 -1
- package/dist/adapters/web/LocalStorageAdapter.d.ts +101 -0
- package/dist/adapters/web/LocalStorageAdapter.js +261 -71
- package/dist/adapters/web/MemoryAdapter.d.ts +5 -0
- package/dist/adapters/web/MemoryAdapter.js +9 -1
- package/dist/adapters/web/SessionStorageAdapter.d.ts +16 -56
- package/dist/adapters/web/SessionStorageAdapter.js +19 -223
- package/dist/adapters/web/URLAdapter.d.ts +3 -0
- package/dist/adapters/web/URLAdapter.js +8 -1
- package/dist/core/BaseAdapter.d.ts +32 -0
- package/dist/core/BaseAdapter.js +60 -1
- package/dist/core/Strata.d.ts +30 -0
- package/dist/core/Strata.js +140 -7
- package/dist/index.d.ts +4 -2
- package/dist/index.js +33 -8
- package/dist/types/index.d.ts +45 -0
- package/dist/utils/index.d.ts +19 -0
- package/dist/utils/index.js +29 -0
- package/package.json +1 -1
- package/scripts/build.js +93 -0
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
*
|
|
71
|
+
* Apply configuration synchronously. See `BaseAdapter.configure` for why the
|
|
72
|
+
* prefix cannot wait for the async `initialize()`.
|
|
51
73
|
*/
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
282
|
-
|
|
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 =
|
|
289
|
-
const newValue =
|
|
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.
|
|
45
|
+
this.configure(config);
|
|
38
46
|
this.startTTLCleanup();
|
|
39
47
|
}
|
|
40
48
|
/**
|