@metamask-previews/storage-service 1.0.0-preview-e493d3e8

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.
Files changed (4) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +20 -0
  3. package/README.md +377 -0
  4. package/package.json +73 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - Initial release of `@metamask/storage-service`
13
+ - Add `StorageService` class for platform-agnostic storage
14
+ - Add `StorageAdapter` interface for platform-specific implementations
15
+ - Add `InMemoryStorageAdapter` as default storage (for tests/dev)
16
+ - Add namespace-based key isolation
17
+ - Add support for `setItem`, `getItem`, `removeItem`, `getAllKeys`, and `clear` operations
18
+ - Add messenger integration for cross-controller communication
19
+ - Add `STORAGE_KEY_PREFIX` constant for consistent key prefixing across adapters
20
+ - Add comprehensive test coverage
21
+
22
+ [Unreleased]: https://github.com/MetaMask/core/
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 MetaMask
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
package/README.md ADDED
@@ -0,0 +1,377 @@
1
+ # `@metamask/storage-service`
2
+
3
+ A platform-agnostic service for storing large, infrequently accessed controller data outside of memory.
4
+
5
+ ## Problem
6
+
7
+ Controllers store large, infrequently-accessed data in Redux state, causing:
8
+ - **State bloat**: 10.79 MB total, with 9.94 MB (92%) in just 2 controllers
9
+ - **Slow app startup**: Parsing 10.79 MB on every launch
10
+ - **High memory usage**: All data loaded, even if rarely accessed
11
+ - **Slow persist operations**: Up to 5.95 MB written per controller change
12
+
13
+ **Production measurements** (MetaMask Mobile):
14
+ - SnapController sourceCode: 5.95 MB (55% of state)
15
+ - TokenListController cache: 3.99 MB (37% of state)
16
+ - **Combined**: 9.94 MB in just 2 controllers
17
+
18
+ ## Solution
19
+
20
+ `StorageService` provides a messenger-based API for controllers to store large data on disk instead of in memory. Data is loaded lazily only when needed.
21
+
22
+ ## Installation
23
+
24
+ `yarn add @metamask/storage-service`
25
+
26
+ or
27
+
28
+ `npm install @metamask/storage-service`
29
+
30
+ ## Architecture
31
+
32
+ The service is **platform-agnostic** and accepts an optional `StorageAdapter`:
33
+
34
+ - **With Adapter** (Production): Client provides platform-specific storage
35
+ - Mobile: FilesystemStorage adapter → Data persists
36
+ - Extension: IndexedDB adapter → Data persists
37
+
38
+ - **Without Adapter** (Default): Uses in-memory storage
39
+ - Testing: No setup needed, isolated tests
40
+ - Development: Quick start, no config
41
+ - ⚠️ Data lost on restart
42
+
43
+ ## Events
44
+
45
+ StorageService publishes events when data changes, enabling reactive patterns:
46
+
47
+ **Events published**:
48
+ - `StorageService:itemSet:{namespace}` - When data is stored
49
+ - Payload: `[value, key]`
50
+ - `StorageService:itemRemoved:{namespace}` - When data is removed
51
+ - Payload: `[key]`
52
+
53
+ **Example - Subscribe to changes**:
54
+ ```typescript
55
+ // In another controller
56
+ this.messenger.subscribe(
57
+ 'StorageService:itemSet:ControllerA',
58
+ (value, key) => {
59
+ console.log(`ControllerA stored data: ${key}`);
60
+ // React to changes without coupling
61
+ },
62
+ );
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ ### Via Messenger (Recommended)
68
+
69
+ The service is designed to be used via a messenger, allowing controllers to access storage without direct dependencies.
70
+
71
+ #### 1. Controller Setup
72
+
73
+ ```typescript
74
+ import type {
75
+ StorageServiceSetItemAction,
76
+ StorageServiceGetItemAction,
77
+ StorageServiceRemoveItemAction,
78
+ } from '@metamask/storage-service';
79
+
80
+ // Grant access to storage actions
81
+ type AllowedActions =
82
+ | StorageServiceSetItemAction
83
+ | StorageServiceGetItemAction
84
+ | StorageServiceRemoveItemAction;
85
+
86
+ type SnapControllerMessenger = Messenger<
87
+ 'SnapController',
88
+ SnapControllerActions | AllowedActions,
89
+ SnapControllerEvents
90
+ >;
91
+
92
+ class SnapController extends BaseController<
93
+ 'SnapController',
94
+ SnapControllerState,
95
+ SnapControllerMessenger
96
+ > {
97
+ async storeSnapSourceCode(snapId: string, sourceCode: string) {
98
+ // Store 3.86 MB of source code on disk, not in state
99
+ await this.messenger.call(
100
+ 'StorageService:setItem',
101
+ 'SnapController',
102
+ `${snapId}:sourceCode`,
103
+ sourceCode,
104
+ );
105
+ }
106
+
107
+ async getSnapSourceCode(snapId: string): Promise<string | null> {
108
+ // Load source code only when snap needs to execute
109
+ return await this.messenger.call(
110
+ 'StorageService:getItem',
111
+ 'SnapController',
112
+ `${snapId}:sourceCode`,
113
+ );
114
+ }
115
+ }
116
+ ```
117
+
118
+ #### 2. Service Initialization (Client)
119
+
120
+ **Mobile:**
121
+
122
+ ```typescript
123
+ import {
124
+ StorageService,
125
+ type StorageAdapter,
126
+ STORAGE_KEY_PREFIX,
127
+ } from '@metamask/storage-service';
128
+ import FilesystemStorage from 'redux-persist-filesystem-storage';
129
+
130
+ // Adapters handle key building and serialization
131
+ const mobileStorageAdapter: StorageAdapter = {
132
+ async getItem(namespace: string, key: string) {
133
+ const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;
134
+ const serialized = await FilesystemStorage.getItem(fullKey);
135
+ if (!serialized) return null;
136
+ const wrapper = JSON.parse(serialized);
137
+ return wrapper.data;
138
+ },
139
+ async setItem(namespace: string, key: string, value: unknown) {
140
+ const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;
141
+ const wrapper = { timestamp: Date.now(), data: value };
142
+ await FilesystemStorage.setItem(fullKey, JSON.stringify(wrapper), Device.isIos());
143
+ },
144
+ async removeItem(namespace: string, key: string) {
145
+ const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;
146
+ await FilesystemStorage.removeItem(fullKey);
147
+ },
148
+ async getAllKeys(namespace: string) {
149
+ const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`;
150
+ const allKeys = await FilesystemStorage.getAllKeys();
151
+ return allKeys
152
+ .filter((k: string) => k.startsWith(prefix))
153
+ .map((k: string) => k.slice(prefix.length));
154
+ },
155
+ async clear(namespace: string) {
156
+ const keys = await this.getAllKeys(namespace);
157
+ await Promise.all(keys.map((k) => this.removeItem(namespace, k)));
158
+ },
159
+ };
160
+
161
+ // Initialize service
162
+ const service = new StorageService({
163
+ messenger: storageServiceMessenger,
164
+ storage: mobileStorageAdapter,
165
+ });
166
+ ```
167
+
168
+ **Extension:**
169
+
170
+ ```typescript
171
+ import {
172
+ StorageService,
173
+ type StorageAdapter,
174
+ STORAGE_KEY_PREFIX,
175
+ } from '@metamask/storage-service';
176
+
177
+ // Adapters handle key building and serialization
178
+ const extensionStorageAdapter: StorageAdapter = {
179
+ async getItem(namespace: string, key: string) {
180
+ const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;
181
+ const db = await openDB();
182
+ const serialized = await db.get('storage-service', fullKey);
183
+ if (!serialized) return null;
184
+ const wrapper = JSON.parse(serialized);
185
+ return wrapper.data;
186
+ },
187
+ async setItem(namespace: string, key: string, value: unknown) {
188
+ const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;
189
+ const wrapper = { timestamp: Date.now(), data: value };
190
+ const db = await openDB();
191
+ await db.put('storage-service', JSON.stringify(wrapper), fullKey);
192
+ },
193
+ async removeItem(namespace: string, key: string) {
194
+ const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;
195
+ const db = await openDB();
196
+ await db.delete('storage-service', fullKey);
197
+ },
198
+ async getAllKeys(namespace: string) {
199
+ const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`;
200
+ const db = await openDB();
201
+ const allKeys = await db.getAllKeys('storage-service');
202
+ return allKeys
203
+ .filter((k: string) => k.startsWith(prefix))
204
+ .map((k: string) => k.slice(prefix.length));
205
+ },
206
+ async clear(namespace: string) {
207
+ const keys = await this.getAllKeys(namespace);
208
+ await Promise.all(keys.map((k) => this.removeItem(namespace, k)));
209
+ },
210
+ };
211
+
212
+ // Initialize service
213
+ const service = new StorageService({
214
+ messenger: storageServiceMessenger,
215
+ storage: extensionStorageAdapter,
216
+ });
217
+ ```
218
+
219
+ **Testing:**
220
+
221
+ ```typescript
222
+ import { StorageService } from '@metamask/storage-service';
223
+
224
+ // No storage adapter needed - uses in-memory by default
225
+ const service = new StorageService({
226
+ messenger: testMessenger,
227
+ // storage: undefined, // Optional - defaults to InMemoryStorageAdapter
228
+ });
229
+
230
+ // Works immediately, data isolated per test
231
+ await service.setItem('TestController', 'key', 'value');
232
+ ```
233
+
234
+ #### 3. Delegate Actions to Controllers
235
+
236
+ ```typescript
237
+ rootMessenger.delegate({
238
+ actions: [
239
+ 'StorageService:setItem',
240
+ 'StorageService:getItem',
241
+ 'StorageService:removeItem',
242
+ ],
243
+ messenger: snapControllerMessenger,
244
+ });
245
+ ```
246
+
247
+ ### Direct Usage
248
+
249
+ You can also use the service directly without a messenger:
250
+
251
+ ```typescript
252
+ import { StorageService, InMemoryStorageAdapter } from '@metamask/storage-service';
253
+
254
+ const service = new StorageService({
255
+ messenger: myMessenger,
256
+ storage: new InMemoryStorageAdapter(),
257
+ });
258
+
259
+ await service.setItem('MyController', 'myKey', { data: 'value' });
260
+ const data = await service.getItem('MyController', 'myKey');
261
+ ```
262
+
263
+ ## API
264
+
265
+ ### `StorageService`
266
+
267
+ #### `setItem<T>(namespace: string, key: string, value: T): Promise<void>`
268
+
269
+ Store data in storage.
270
+
271
+ - `namespace` - Controller namespace (e.g., 'SnapController')
272
+ - `key` - Storage key (e.g., 'npm:@metamask/bitcoin-wallet-snap:sourceCode')
273
+ - `value` - Data to store (will be JSON stringified)
274
+
275
+ ```typescript
276
+ await service.setItem('SnapController', 'snap-id:sourceCode', sourceCode);
277
+ ```
278
+
279
+ #### `getItem<T>(namespace: string, key: string): Promise<T | null>`
280
+
281
+ Retrieve data from storage.
282
+
283
+ - `namespace` - Controller namespace
284
+ - `key` - Storage key
285
+ - **Returns**: Parsed data or null if not found
286
+
287
+ ```typescript
288
+ const sourceCode = await service.getItem('SnapController', 'snap-id:sourceCode');
289
+ ```
290
+
291
+ #### `removeItem(namespace: string, key: string): Promise<void>`
292
+
293
+ Remove data from storage.
294
+
295
+ ```typescript
296
+ await service.removeItem('SnapController', 'snap-id:sourceCode');
297
+ ```
298
+
299
+ #### `getAllKeys(namespace: string): Promise<string[]>`
300
+
301
+ Get all keys for a namespace (without prefix).
302
+
303
+ ```typescript
304
+ const keys = await service.getAllKeys('SnapController');
305
+ // Returns: ['snap-id-1:sourceCode', 'snap-id-2:sourceCode', ...]
306
+ ```
307
+
308
+ #### `clear(namespace: string): Promise<void>`
309
+
310
+ Clear all data for a namespace.
311
+
312
+ ```typescript
313
+ await service.clear('SnapController');
314
+ ```
315
+
316
+ ## StorageAdapter Interface
317
+
318
+ Implement this interface to provide platform-specific storage. Adapters are responsible for:
319
+ - Building the full storage key (e.g., `storageService:namespace:key`)
320
+ - Wrapping data with metadata (timestamp) before serialization
321
+ - Serializing/deserializing data (JSON.stringify/parse)
322
+
323
+ ```typescript
324
+ export type StorageAdapter = {
325
+ getItem(namespace: string, key: string): Promise<unknown>;
326
+ setItem(namespace: string, key: string, value: unknown): Promise<void>;
327
+ removeItem(namespace: string, key: string): Promise<void>;
328
+ getAllKeys(namespace: string): Promise<string[]>;
329
+ clear(namespace: string): Promise<void>;
330
+ };
331
+ ```
332
+
333
+ ## When to Use
334
+
335
+ ✅ **Use StorageService for:**
336
+ - Large data (> 100 KB)
337
+ - Infrequently accessed data
338
+ - Data that doesn't need to be in Redux state
339
+ - Examples: Snap source code (6 MB), cached API responses (4 MB)
340
+
341
+ ❌ **Don't use for:**
342
+ - Frequently accessed data (use controller state)
343
+ - Small data (< 10 KB - overhead not worth it)
344
+ - Data needed for UI rendering
345
+ - Critical data that must be in Redux
346
+
347
+ ## Storage Key Format
348
+
349
+ Adapters build keys with prefix: `storageService:{namespace}:{key}`
350
+
351
+ Examples:
352
+ - `storageService:SnapController:npm:@metamask/bitcoin-wallet-snap:sourceCode`
353
+ - `storageService:TokenListController:cache:0x1`
354
+
355
+ This provides:
356
+ - Namespace isolation (prevents collisions)
357
+ - Easy debugging (clear key format)
358
+ - Scoped clearing (clear removes all keys for controller)
359
+
360
+ ## Real-World Impact
361
+
362
+ **Production measurements** (MetaMask Mobile):
363
+
364
+ **Per-controller**:
365
+ - SnapController: 5.95 MB sourceCode → 166 KB metadata (97% reduction)
366
+ - TokenListController: 3.99 MB cache → 61 bytes metadata (99.9% reduction)
367
+
368
+ **Combined**:
369
+ - Total state: 10.79 MB → 0.85 MB (**92% reduction**)
370
+ - App startup: 92% less data to parse
371
+ - Memory freed: 9.94 MB
372
+ - Disk I/O: Up to 9.94 MB less per persist operation
373
+
374
+ ## Contributing
375
+
376
+ This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
377
+
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@metamask-previews/storage-service",
3
+ "version": "1.0.0-preview-e493d3e8",
4
+ "description": "Platform-agnostic service for storing large, infrequently accessed controller data",
5
+ "keywords": [
6
+ "MetaMask",
7
+ "Ethereum",
8
+ "Storage",
9
+ "Service"
10
+ ],
11
+ "homepage": "https://github.com/MetaMask/core/tree/main/packages/storage-service#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/MetaMask/core/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/MetaMask/core.git"
18
+ },
19
+ "license": "MIT",
20
+ "sideEffects": false,
21
+ "exports": {
22
+ ".": {
23
+ "import": {
24
+ "types": "./dist/index.d.mts",
25
+ "default": "./dist/index.mjs"
26
+ },
27
+ "require": {
28
+ "types": "./dist/index.d.cts",
29
+ "default": "./dist/index.cjs"
30
+ }
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "main": "./dist/index.cjs",
35
+ "types": "./dist/index.d.cts",
36
+ "files": [
37
+ "dist/"
38
+ ],
39
+ "scripts": {
40
+ "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
41
+ "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean",
42
+ "build:docs": "typedoc",
43
+ "changelog:update": "../../scripts/update-changelog.sh @metamask/storage-service",
44
+ "changelog:validate": "../../scripts/validate-changelog.sh @metamask/storage-service",
45
+ "publish:preview": "yarn npm publish --tag preview",
46
+ "since-latest-release": "../../scripts/since-latest-release.sh",
47
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
48
+ "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
49
+ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
50
+ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
51
+ },
52
+ "dependencies": {
53
+ "@metamask/messenger": "^0.3.0"
54
+ },
55
+ "devDependencies": {
56
+ "@metamask/auto-changelog": "^3.4.4",
57
+ "@ts-bridge/cli": "^0.6.4",
58
+ "@types/jest": "^27.4.1",
59
+ "deepmerge": "^4.2.2",
60
+ "jest": "^27.5.1",
61
+ "ts-jest": "^27.1.4",
62
+ "typedoc": "^0.24.8",
63
+ "typedoc-plugin-missing-exports": "^2.0.0",
64
+ "typescript": "~5.3.3"
65
+ },
66
+ "engines": {
67
+ "node": "^18.18 || >=20"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public",
71
+ "registry": "https://registry.npmjs.org/"
72
+ }
73
+ }