diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a064f8bdc12..f9058e61f60 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -100,6 +100,7 @@ /packages/permission-controller @MetaMask/wallet-integrations @MetaMask/core-platform /packages/permission-log-controller @MetaMask/wallet-integrations @MetaMask/core-platform /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform @@ -168,6 +169,8 @@ /packages/bridge-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform /packages/remote-feature-flag-controller/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/remote-feature-flag-controller/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/bridge-status-controller/package.json @MetaMask/swaps-engineers @MetaMask/core-platform /packages/bridge-status-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform /packages/app-metadata-controller/package.json @MetaMask/mobile-platform @MetaMask/core-platform diff --git a/packages/storage-service/CHANGELOG.md b/packages/storage-service/CHANGELOG.md new file mode 100644 index 00000000000..9370419d25d --- /dev/null +++ b/packages/storage-service/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/storage-service` ([#7192](https://github.com/MetaMask/core/pull/7192)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/storage-service/LICENSE b/packages/storage-service/LICENSE new file mode 100644 index 00000000000..f9f85c6d4ec --- /dev/null +++ b/packages/storage-service/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/storage-service/LICENSE.APACHE2 b/packages/storage-service/LICENSE.APACHE2 new file mode 100644 index 00000000000..cd780528412 --- /dev/null +++ b/packages/storage-service/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 MetaMask + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/storage-service/LICENSE.MIT b/packages/storage-service/LICENSE.MIT new file mode 100644 index 00000000000..97a3ce1c090 --- /dev/null +++ b/packages/storage-service/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/storage-service/README.md b/packages/storage-service/README.md new file mode 100644 index 00000000000..f38f4aadb78 --- /dev/null +++ b/packages/storage-service/README.md @@ -0,0 +1,131 @@ +# `@metamask/storage-service` + +A platform-agnostic service for storing large, infrequently accessed controller data outside of memory. + +## When to Use + +✅ **Use StorageService for:** + +- Large data (> 100 KB) +- Infrequently accessed data +- Data that doesn't need to be in Redux state +- Examples: Snap source code, cached API responses + +❌ **Don't use for:** + +- Frequently accessed data (use controller state) +- Small data (< 10 KB - overhead not worth it) +- Data needed for UI rendering + +## Installation + +`yarn add @metamask/storage-service` + +or + +`npm install @metamask/storage-service` + +## Usage + +### Controller Setup + +```typescript +import type { + StorageServiceSetItemAction, + StorageServiceGetItemAction, +} from '@metamask/storage-service'; + +// Grant access to storage actions +type AllowedActions = + | StorageServiceSetItemAction + | StorageServiceGetItemAction; + +class MyController extends BaseController<...> { + async storeData(id: string, data: string) { + await this.messenger.call( + 'StorageService:setItem', + 'MyController', + `${id}:data`, + data, + ); + } + + async getData(id: string): Promise { + const { result, error } = await this.messenger.call( + 'StorageService:getItem', + 'MyController', + `${id}:data`, + ); + if (error) { + throw error; + } + // result is undefined if key doesn't exist + return result as string | undefined; + } +} +``` + +### Service Initialization + +The service accepts an optional `StorageAdapter` for platform-specific storage: + +```typescript +import { StorageService, type StorageAdapter } from '@metamask/storage-service'; + +// Production: Provide a platform-specific adapter +const service = new StorageService({ + messenger: storageServiceMessenger, + storage: myPlatformAdapter, // FilesystemStorage, IndexedDB, etc. +}); + +// Testing: Uses in-memory storage by default +const testService = new StorageService({ + messenger: testMessenger, + // No adapter needed - data isolated per test +}); +``` + +### Events + +Subscribe to storage changes: + +```typescript +this.messenger.subscribe( + 'StorageService:itemSet:MyController', + (key, value) => { + console.log(`Data stored: ${key}`); + }, +); +``` + +## StorageAdapter Interface + +Implement this interface to provide platform-specific storage: + +```typescript +import type { Json } from '@metamask/utils'; + +// Response type for getItem - distinguishes found, not found, and error +type StorageGetResult = + | { result: Json; error?: never } // Data found + | { result?: never; error: Error } // Error occurred + | Record; // Key doesn't exist (empty object) + +export type StorageAdapter = { + getItem(namespace: string, key: string): Promise; + setItem(namespace: string, key: string, value: Json): Promise; + removeItem(namespace: string, key: string): Promise; + getAllKeys(namespace: string): Promise; + clear(namespace: string): Promise; +}; +``` + +Adapters are responsible for: + +- Building the full storage key (e.g., `storageService:namespace:key`) +- Serializing/deserializing JSON data +- Returning the correct response format for getItem + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/storage-service/jest.config.js b/packages/storage-service/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/storage-service/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/storage-service/package.json b/packages/storage-service/package.json new file mode 100644 index 00000000000..0581b748a83 --- /dev/null +++ b/packages/storage-service/package.json @@ -0,0 +1,72 @@ +{ + "name": "@metamask/storage-service", + "version": "0.0.0", + "description": "Platform-agnostic service for storing large, infrequently accessed controller data", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/storage-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "(MIT OR Apache-2.0)", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/storage-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/storage-service", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/messenger": "^0.3.0", + "@metamask/utils": "^11.8.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/storage-service/src/InMemoryStorageAdapter.test.ts b/packages/storage-service/src/InMemoryStorageAdapter.test.ts new file mode 100644 index 00000000000..2ff088c9b00 --- /dev/null +++ b/packages/storage-service/src/InMemoryStorageAdapter.test.ts @@ -0,0 +1,233 @@ +import { InMemoryStorageAdapter } from './InMemoryStorageAdapter'; + +describe('InMemoryStorageAdapter', () => { + describe('getItem', () => { + it('returns empty object {} for non-existent keys', async () => { + const adapter = new InMemoryStorageAdapter(); + + const response = await adapter.getItem('TestNamespace', 'nonExistent'); + + expect(response).toStrictEqual({}); + }); + + it('returns { result } with previously stored values', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'testKey', 'testValue'); + const response = await adapter.getItem('TestNamespace', 'testKey'); + + expect(response).toStrictEqual({ result: 'testValue' }); + }); + + it('returns { result: null } when null was explicitly stored', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'nullKey', null); + const response = await adapter.getItem('TestNamespace', 'nullKey'); + + // This is different from {} - data WAS found, and it was null + expect(response).toStrictEqual({ result: null }); + }); + + it('returns { error } when stored data is corrupted', async () => { + const adapter = new InMemoryStorageAdapter(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const parseError = new SyntaxError('Unexpected token'); + + // Store valid data first + await adapter.setItem('TestNamespace', 'corruptKey', 'validValue'); + + // Mock JSON.parse to throw on the next call (simulating corruption) + const originalParse = JSON.parse; + jest.spyOn(JSON, 'parse').mockImplementationOnce(() => { + throw parseError; + }); + + const response = await adapter.getItem('TestNamespace', 'corruptKey'); + + expect(response).toStrictEqual({ error: parseError }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to parse stored data'), + parseError, + ); + + // Restore + JSON.parse = originalParse; + consoleErrorSpy.mockRestore(); + }); + }); + + describe('setItem', () => { + it('stores a value that can be retrieved', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 'value'); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: 'value' }); + }); + + it('stores objects', async () => { + const adapter = new InMemoryStorageAdapter(); + const obj = { foo: 'bar', num: 123 }; + + await adapter.setItem('TestNamespace', 'key', obj); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: obj }); + }); + + it('stores strings', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 'string value'); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: 'string value' }); + }); + + it('stores numbers', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 42); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: 42 }); + }); + + it('stores booleans', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', true); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: true }); + }); + + it('overwrites existing values', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 'oldValue'); + await adapter.setItem('TestNamespace', 'key', 'newValue'); + const response = await adapter.getItem('TestNamespace', 'key'); + + expect(response).toStrictEqual({ result: 'newValue' }); + }); + }); + + describe('removeItem', () => { + it('removes a stored item', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key', 'value'); + await adapter.removeItem('TestNamespace', 'key'); + const response = await adapter.getItem('TestNamespace', 'key'); + + // After removal, key doesn't exist - returns empty object + expect(response).toStrictEqual({}); + }); + + it('does not throw when removing non-existent key', async () => { + const adapter = new InMemoryStorageAdapter(); + + const result = await adapter.removeItem('TestNamespace', 'nonExistent'); + expect(result).toBeUndefined(); + }); + }); + + describe('getAllKeys', () => { + it('returns keys for a namespace', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key1', 'value1'); + await adapter.setItem('TestNamespace', 'key2', 'value2'); + await adapter.setItem('OtherNamespace', 'key3', 'value3'); + + const keys = await adapter.getAllKeys('TestNamespace'); + + expect(keys).toStrictEqual(expect.arrayContaining(['key1', 'key2'])); + expect(keys).toHaveLength(2); + }); + + it('returns empty array when no keys for namespace', async () => { + const adapter = new InMemoryStorageAdapter(); + + const keys = await adapter.getAllKeys('EmptyNamespace'); + + expect(keys).toStrictEqual([]); + }); + + it('strips prefix from returned keys', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'my-key', 'value'); + + const keys = await adapter.getAllKeys('TestNamespace'); + + expect(keys).toStrictEqual(['my-key']); + expect(keys[0]).not.toContain('storageService:'); + expect(keys[0]).not.toContain('TestNamespace:'); + }); + }); + + describe('clear', () => { + it('removes all items for a namespace', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('TestNamespace', 'key1', 'value1'); + await adapter.setItem('TestNamespace', 'key2', 'value2'); + await adapter.setItem('OtherNamespace', 'key3', 'value3'); + + await adapter.clear('TestNamespace'); + + const testKeys = await adapter.getAllKeys('TestNamespace'); + const otherKeys = await adapter.getAllKeys('OtherNamespace'); + + expect(testKeys).toStrictEqual([]); + expect(otherKeys).toStrictEqual(['key3']); + }); + + it('does not affect other namespaces', async () => { + const adapter = new InMemoryStorageAdapter(); + + await adapter.setItem('Namespace1', 'key', 'value1'); + await adapter.setItem('Namespace2', 'key', 'value2'); + + await adapter.clear('Namespace1'); + + expect(await adapter.getItem('Namespace1', 'key')).toStrictEqual({}); + expect(await adapter.getItem('Namespace2', 'key')).toStrictEqual({ + result: 'value2', + }); + }); + }); + + describe('data isolation', () => { + it('different instances have isolated storage', async () => { + const adapter1 = new InMemoryStorageAdapter(); + const adapter2 = new InMemoryStorageAdapter(); + + await adapter1.setItem('TestNamespace', 'key', 'value1'); + await adapter2.setItem('TestNamespace', 'key', 'value2'); + + expect(await adapter1.getItem('TestNamespace', 'key')).toStrictEqual({ + result: 'value1', + }); + expect(await adapter2.getItem('TestNamespace', 'key')).toStrictEqual({ + result: 'value2', + }); + }); + }); + + describe('implements StorageAdapter interface', () => { + it('implements all required methods', () => { + const adapter = new InMemoryStorageAdapter(); + + expect(typeof adapter.getItem).toBe('function'); + expect(typeof adapter.setItem).toBe('function'); + expect(typeof adapter.removeItem).toBe('function'); + expect(typeof adapter.getAllKeys).toBe('function'); + expect(typeof adapter.clear).toBe('function'); + }); + }); +}); diff --git a/packages/storage-service/src/InMemoryStorageAdapter.ts b/packages/storage-service/src/InMemoryStorageAdapter.ts new file mode 100644 index 00000000000..07ef4c80514 --- /dev/null +++ b/packages/storage-service/src/InMemoryStorageAdapter.ts @@ -0,0 +1,119 @@ +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter, StorageGetResult } from './types'; +import { STORAGE_KEY_PREFIX } from './types'; + +/** + * In-memory storage adapter (default fallback). + * Implements the {@link StorageAdapter} interface using a Map. + * + * ⚠️ **Warning**: Data is NOT persisted - lost on restart. + * + * **Suitable for:** + * - Testing (isolated, no mocking needed) + * - Development (quick start, zero config) + * - Temporary/ephemeral data + * + * **Not suitable for:** + * - Production (unless data is truly ephemeral) + * - Data that needs to persist across restarts + * + * @example + * ```typescript + * const adapter = new InMemoryStorageAdapter(); + * await adapter.setItem('SnapController', 'snap-id:sourceCode', 'const x = 1;'); + * const value = await adapter.getItem('SnapController', 'snap-id:sourceCode'); // 'const x = 1;' + * // After restart: data is lost + * ``` + */ +export class InMemoryStorageAdapter implements StorageAdapter { + // Explicitly implement StorageAdapter interface + /** + * Internal storage map. + */ + readonly #storage: Map; + + /** + * Constructs a new InMemoryStorageAdapter. + */ + constructor() { + this.#storage = new Map(); + } + + /** + * Retrieve an item from in-memory storage. + * Deserializes JSON data from storage. + * + * @param namespace - The controller namespace. + * @param key - The data key. + * @returns StorageGetResult: { result } if found, {} if not found, { error } on failure. + */ + async getItem(namespace: string, key: string): Promise { + const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`; + const serialized = this.#storage.get(fullKey); + + // Key not found - return empty object + if (serialized === undefined) { + return {}; + } + + try { + const result = JSON.parse(serialized); + return { result }; + } catch (error) { + console.error(`Failed to parse stored data for ${fullKey}:`, error); + return { error: error as Error }; + } + } + + /** + * Store an item in in-memory storage. + * Serializes JSON data to string. + * + * @param namespace - The controller namespace. + * @param key - The data key. + * @param value - The JSON value to store. + */ + async setItem(namespace: string, key: string, value: Json): Promise { + const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`; + this.#storage.set(fullKey, JSON.stringify(value)); + } + + /** + * Remove an item from in-memory storage. + * + * @param namespace - The controller namespace. + * @param key - The data key. + */ + async removeItem(namespace: string, key: string): Promise { + const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`; + this.#storage.delete(fullKey); + } + + /** + * Get all keys for a namespace. + * Returns keys without the 'storage:namespace:' prefix. + * + * @param namespace - The namespace to get keys for. + * @returns Array of keys (without prefix) for this namespace. + */ + async getAllKeys(namespace: string): Promise { + const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`; + return Array.from(this.#storage.keys()) + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)); + } + + /** + * Clear all items for a namespace. + * + * @param namespace - The namespace to clear. + */ + async clear(namespace: string): Promise { + const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`; + const keysToDelete = Array.from(this.#storage.keys()).filter((key) => + key.startsWith(prefix), + ); + keysToDelete.forEach((key) => this.#storage.delete(key)); + } +} diff --git a/packages/storage-service/src/StorageService-method-action-types.ts b/packages/storage-service/src/StorageService-method-action-types.ts new file mode 100644 index 00000000000..e6a033d0249 --- /dev/null +++ b/packages/storage-service/src/StorageService-method-action-types.ts @@ -0,0 +1,89 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { StorageService } from './StorageService'; + +/** + * Store large JSON data in storage. + * + * ⚠️ **Designed for large values (100KB+), not many small ones.** + * Each storage operation has I/O overhead. For best performance, + * store one large object rather than many small key-value pairs. + * + * @example Good: Store entire cache as one value + * ```typescript + * await service.setItem('TokenList', 'cache', { '0x1': [...], '0x38': [...] }); + * ``` + * + * @example Avoid: Many small values + * ```typescript + * // ❌ Don't do this - too many small writes + * await service.setItem('TokenList', 'cache:0x1', [...]); + * await service.setItem('TokenList', 'cache:0x38', [...]); + * ``` + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + * @param value - JSON data to store (should be 100KB+ for optimal use). + */ +export type StorageServiceSetItemAction = { + type: `StorageService:setItem`; + handler: StorageService['setItem']; +}; + +/** + * Retrieve JSON data from storage. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + * @returns StorageGetResult: { result } if found, {} if not found, { error } on failure. + */ +export type StorageServiceGetItemAction = { + type: `StorageService:getItem`; + handler: StorageService['getItem']; +}; + +/** + * Remove data from storage. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + */ +export type StorageServiceRemoveItemAction = { + type: `StorageService:removeItem`; + handler: StorageService['removeItem']; +}; + +/** + * Get all keys for a namespace. + * Delegates to storage adapter which handles filtering. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @returns Array of keys (without prefix) for this namespace. + */ +export type StorageServiceGetAllKeysAction = { + type: `StorageService:getAllKeys`; + handler: StorageService['getAllKeys']; +}; + +/** + * Clear all data for a namespace. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + */ +export type StorageServiceClearAction = { + type: `StorageService:clear`; + handler: StorageService['clear']; +}; + +/** + * Union of all StorageService action types. + */ +export type StorageServiceMethodActions = + | StorageServiceSetItemAction + | StorageServiceGetItemAction + | StorageServiceRemoveItemAction + | StorageServiceGetAllKeysAction + | StorageServiceClearAction; diff --git a/packages/storage-service/src/StorageService.test.ts b/packages/storage-service/src/StorageService.test.ts new file mode 100644 index 00000000000..e25ae87f3d4 --- /dev/null +++ b/packages/storage-service/src/StorageService.test.ts @@ -0,0 +1,630 @@ +import { + Messenger, + MOCK_ANY_NAMESPACE, + type MockAnyNamespace, + type MessengerActions, + type MessengerEvents, +} from '@metamask/messenger'; + +import { StorageService } from './StorageService'; +import type { StorageServiceMessenger, StorageAdapter } from './types'; + +describe('StorageService', () => { + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + describe('constructor', () => { + it('uses provided storage adapter', () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + expect(service).toBeInstanceOf(StorageService); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('defaults to InMemoryStorageAdapter when no storage provided', () => { + const { service } = getService(); + + expect(service).toBeInstanceOf(StorageService); + }); + + it('logs warning when using in-memory storage', () => { + getService(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('No storage adapter provided'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Data will be lost on restart'), + ); + }); + }); + + describe('setItem', () => { + it('delegates to adapter with namespace and key', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + await service.setItem('TestController', 'testKey', 'testValue'); + + // Adapter receives namespace and key separately (adapter handles key building) + expect(mockStorage.setItem).toHaveBeenCalledWith( + 'TestController', + 'testKey', + 'testValue', + ); + }); + + it('passes complex objects to adapter', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + const complexObject = { foo: 'bar', nested: { value: 123 } }; + + await service.setItem('TestController', 'complex', complexObject); + + // Adapter handles serialization + expect(mockStorage.setItem).toHaveBeenCalledWith( + 'TestController', + 'complex', + complexObject, + ); + }); + + it('handles storage errors gracefully', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest + .fn() + .mockRejectedValue(new Error('Storage quota exceeded')), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + await expect( + service.setItem('TestController', 'key', 'value'), + ).rejects.toThrow('Storage quota exceeded'); + }); + + it('publishes itemSet event with key and value', async () => { + const { service, rootMessenger } = getService(); + const eventHandler = jest.fn(); + + rootMessenger.subscribe( + 'StorageService:itemSet:TestController' as `StorageService:itemSet:${string}`, + eventHandler, + ); + + await service.setItem('TestController', 'myKey', { data: 'test' }); + + expect(eventHandler).toHaveBeenCalledTimes(1); + expect(eventHandler).toHaveBeenCalledWith('myKey', { data: 'test' }); + }); + + it('publishes itemSet event only for matching namespace', async () => { + const { service, rootMessenger } = getService(); + const controller1Handler = jest.fn(); + + rootMessenger.subscribe( + 'StorageService:itemSet:Controller1' as `StorageService:itemSet:${string}`, + controller1Handler, + ); + + await service.setItem('Controller1', 'key', 'value1'); + await service.setItem('Controller2', 'key', 'value2'); + + expect(controller1Handler).toHaveBeenCalledTimes(1); + expect(controller1Handler).toHaveBeenCalledWith('key', 'value1'); + }); + }); + + describe('getItem', () => { + it('returns { result } with stored data when key exists', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'testKey', { data: 'test' }); + const response = await service.getItem('TestController', 'testKey'); + + expect(response).toStrictEqual({ result: { data: 'test' } }); + }); + + it('returns empty object {} for non-existent keys', async () => { + const { service } = getService(); + + const response = await service.getItem('TestController', 'nonExistent'); + + expect(response).toStrictEqual({}); + }); + + it('returns empty object {} when adapter returns not found', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn().mockResolvedValue({}), // Adapter returns {} for not found + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const response = await service.getItem('TestController', 'missing'); + + expect(response).toStrictEqual({}); + expect(mockStorage.getItem).toHaveBeenCalledWith( + 'TestController', + 'missing', + ); + }); + + it('returns { error } when adapter returns error', async () => { + const testError = new Error('Parse error'); + const mockStorage: StorageAdapter = { + getItem: jest.fn().mockResolvedValue({ error: testError }), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const response = await service.getItem('TestController', 'corrupt'); + + expect(response).toStrictEqual({ error: testError }); + }); + + it('returns { result } with string values', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'string', 'simple string'); + const response = await service.getItem('TestController', 'string'); + + expect(response).toStrictEqual({ result: 'simple string' }); + }); + + it('returns { result } with number values', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'number', 42); + const response = await service.getItem('TestController', 'number'); + + expect(response).toStrictEqual({ result: 42 }); + }); + + it('returns { result } with array values', async () => { + const { service } = getService(); + const array = [1, 2, 3]; + + await service.setItem('TestController', 'array', array); + const response = await service.getItem('TestController', 'array'); + + expect(response).toStrictEqual({ result: array }); + }); + + it('returns { result: null } when null was explicitly stored', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'nullValue', null); + const response = await service.getItem('TestController', 'nullValue'); + + // This is different from {} - data WAS found, and it was null + expect(response).toStrictEqual({ result: null }); + }); + }); + + describe('removeItem', () => { + it('removes data from storage', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'toRemove', 'value'); + await service.removeItem('TestController', 'toRemove'); + const response = await service.getItem('TestController', 'toRemove'); + + // After removal, key doesn't exist - returns empty object + expect(response).toStrictEqual({}); + }); + + it('removes key from registry', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + await service.removeItem('TestController', 'key1'); + const keys = await service.getAllKeys('TestController'); + + expect(keys).toStrictEqual(['key2']); + }); + }); + + describe('getAllKeys', () => { + it('delegates to storage adapter with namespace', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue(['key1', 'key2']), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const keys = await service.getAllKeys('TestController'); + + expect(mockStorage.getAllKeys).toHaveBeenCalledWith('TestController'); + expect(keys).toStrictEqual(['key1', 'key2']); + }); + + it('returns keys from default in-memory adapter', async () => { + const { service } = getService(); // Uses InMemoryAdapter + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + await service.setItem('OtherController', 'key3', 'value3'); + + const keys = await service.getAllKeys('TestController'); + + expect(keys).toStrictEqual(expect.arrayContaining(['key1', 'key2'])); + expect(keys).toHaveLength(2); + }); + + it('returns empty array for namespace with no keys', async () => { + const { service } = getService(); + + const keys = await service.getAllKeys('EmptyController'); + + expect(keys).toStrictEqual([]); + }); + + it('delegates to adapter for namespace filtering', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const keys = await service.getAllKeys('NonExistentController'); + + expect(mockStorage.getAllKeys).toHaveBeenCalledWith( + 'NonExistentController', + ); + expect(keys).toStrictEqual([]); + }); + }); + + describe('clear', () => { + it('delegates to storage adapter', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockResolvedValue([]), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + await service.clear('TestController'); + + expect(mockStorage.clear).toHaveBeenCalledWith('TestController'); + }); + + it('clears namespace using default in-memory adapter', async () => { + const { service } = getService(); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + await service.setItem('OtherController', 'key3', 'value3'); + + await service.clear('TestController'); + + const testKeys = await service.getAllKeys('TestController'); + const otherKeys = await service.getAllKeys('OtherController'); + + expect(testKeys).toStrictEqual([]); + expect(otherKeys).toStrictEqual(['key3']); + }); + + it('does not affect other namespaces', async () => { + const { service } = getService(); + + await service.setItem('Controller1', 'key', 'value1'); + await service.setItem('Controller2', 'key', 'value2'); + await service.setItem('Controller3', 'key', 'value3'); + + await service.clear('Controller2'); + + expect(await service.getItem('Controller1', 'key')).toStrictEqual({ + result: 'value1', + }); + expect(await service.getItem('Controller2', 'key')).toStrictEqual({}); + expect(await service.getItem('Controller3', 'key')).toStrictEqual({ + result: 'value3', + }); + }); + }); + + describe('namespace isolation', () => { + it('prevents key collisions between namespaces', async () => { + const { service } = getService(); + + await service.setItem('Controller1', 'sameKey', 'value1'); + await service.setItem('Controller2', 'sameKey', 'value2'); + + const response1 = await service.getItem('Controller1', 'sameKey'); + const response2 = await service.getItem('Controller2', 'sameKey'); + + expect(response1).toStrictEqual({ result: 'value1' }); + expect(response2).toStrictEqual({ result: 'value2' }); + }); + + it('getAllKeys only returns keys for specified namespace', async () => { + const { service } = getService(); + + await service.setItem('SnapController', 'snap1', 'data1'); + await service.setItem('SnapController', 'snap2', 'data2'); + await service.setItem('TokensController', 'token1', 'data3'); + + const snapKeys = await service.getAllKeys('SnapController'); + const tokenKeys = await service.getAllKeys('TokensController'); + + expect(snapKeys).toStrictEqual( + expect.arrayContaining(['snap1', 'snap2']), + ); + expect(snapKeys).toHaveLength(2); + expect(tokenKeys).toStrictEqual(['token1']); + }); + }); + + describe('messenger actions', () => { + it('exposes setItem as messenger action', async () => { + const { rootMessenger } = getService(); + + await rootMessenger.call( + 'StorageService:setItem', + 'TestController', + 'key', + 'value', + ); + + const response = await rootMessenger.call( + 'StorageService:getItem', + 'TestController', + 'key', + ); + + expect(response).toStrictEqual({ result: 'value' }); + }); + + it('exposes getItem as messenger action', async () => { + const { service, rootMessenger } = getService(); + + await service.setItem('TestController', 'key', 'value'); + + const response = await rootMessenger.call( + 'StorageService:getItem', + 'TestController', + 'key', + ); + + expect(response).toStrictEqual({ result: 'value' }); + }); + + it('exposes removeItem as messenger action', async () => { + const { service, rootMessenger } = getService(); + + await service.setItem('TestController', 'key', 'value'); + await rootMessenger.call( + 'StorageService:removeItem', + 'TestController', + 'key', + ); + + const response = await service.getItem('TestController', 'key'); + + expect(response).toStrictEqual({}); + }); + + it('exposes getAllKeys as messenger action', async () => { + const { service, rootMessenger } = getService(); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + + const keys = await rootMessenger.call( + 'StorageService:getAllKeys', + 'TestController', + ); + + expect(keys).toStrictEqual(expect.arrayContaining(['key1', 'key2'])); + }); + + it('exposes clear as messenger action', async () => { + const { service, rootMessenger } = getService(); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + + await rootMessenger.call('StorageService:clear', 'TestController'); + + const keys = await service.getAllKeys('TestController'); + + expect(keys).toStrictEqual([]); + }); + }); + + describe('real-world usage scenario', () => { + it('simulates SnapController storing and retrieving source code', async () => { + const { service } = getService(); + + // Simulate storing 5 snap source codes (like production) + const snaps = { + 'npm:@metamask/bitcoin-wallet-snap': { + sourceCode: 'a'.repeat(3864960), + }, // ~3.86 MB + 'npm:@metamask/tron-wallet-snap': { sourceCode: 'b'.repeat(1089930) }, // ~1.09 MB + 'npm:@metamask/solana-wallet-snap': { sourceCode: 'c'.repeat(603890) }, // ~603 KB + 'npm:@metamask/ens-resolver-snap': { sourceCode: 'd'.repeat(371590) }, // ~371 KB + 'npm:@metamask/message-signing-snap': { + sourceCode: 'e'.repeat(159030), + }, // ~159 KB + }; + + // Store all source codes + for (const [snapId, snap] of Object.entries(snaps)) { + await service.setItem( + 'SnapController', + `${snapId}:sourceCode`, + snap.sourceCode, + ); + } + + // Verify all keys are tracked + const keys = await service.getAllKeys('SnapController'); + expect(keys).toHaveLength(5); + + // Retrieve specific snap source code + const response = await service.getItem( + 'SnapController', + 'npm:@metamask/bitcoin-wallet-snap:sourceCode', + ); + + expect(response).toStrictEqual({ + result: snaps['npm:@metamask/bitcoin-wallet-snap'].sourceCode, + }); + + // Clear all snap data + await service.clear('SnapController'); + const keysAfterClear = await service.getAllKeys('SnapController'); + + expect(keysAfterClear).toStrictEqual([]); + }); + + it('delegates getAllKeys to adapter', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn().mockResolvedValue({}), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest + .fn() + .mockResolvedValue(['snap1:sourceCode', 'snap2:sourceCode']), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + const keys = await service.getAllKeys('SnapController'); + + expect(mockStorage.getAllKeys).toHaveBeenCalledWith('SnapController'); + expect(keys).toStrictEqual(['snap1:sourceCode', 'snap2:sourceCode']); + }); + + it('adapter handles namespace filtering', async () => { + const mockStorage: StorageAdapter = { + getItem: jest.fn().mockResolvedValue({}), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn().mockImplementation((namespace) => { + // Adapter filters by namespace + if (namespace === 'TestController') { + return Promise.resolve(['key1', 'key2']); + } + return Promise.resolve([]); + }), + clear: jest.fn().mockResolvedValue(undefined), + }; + const { service } = getService({ storage: mockStorage }); + + await service.setItem('TestController', 'key1', 'value1'); + await service.setItem('TestController', 'key2', 'value2'); + + const keys = await service.getAllKeys('TestController'); + + expect(mockStorage.getAllKeys).toHaveBeenCalledWith('TestController'); + expect(keys).toStrictEqual(['key1', 'key2']); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the messenger populated with all external actions and events + * required by the service under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the service's messenger. + * @returns The service-specific messenger. + */ +function getMessenger(rootMessenger: RootMessenger): StorageServiceMessenger { + return new Messenger({ + namespace: 'StorageService', + parent: rootMessenger, + }); +} + +/** + * Constructs the service under test. + * + * @param args - The arguments to this function. + * @param args.storage - Optional storage adapter to use. + * @returns The new service, root messenger, and service messenger. + */ +function getService({ + storage, +}: { + storage?: StorageAdapter; +} = {}): { + service: StorageService; + rootMessenger: RootMessenger; + messenger: StorageServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const service = new StorageService({ + messenger, + storage, + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/storage-service/src/StorageService.ts b/packages/storage-service/src/StorageService.ts new file mode 100644 index 00000000000..dfb5381e624 --- /dev/null +++ b/packages/storage-service/src/StorageService.ts @@ -0,0 +1,220 @@ +import type { Json } from '@metamask/utils'; + +import { InMemoryStorageAdapter } from './InMemoryStorageAdapter'; +import type { + StorageAdapter, + StorageGetResult, + StorageServiceMessenger, + StorageServiceOptions, +} from './types'; +import { SERVICE_NAME } from './types'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'setItem', + 'getItem', + 'removeItem', + 'getAllKeys', + 'clear', +] as const; + +/** + * StorageService provides a platform-agnostic way for controllers to store + * large, infrequently accessed data outside of memory/Redux state. + * + * **Use cases:** + * - Snap source code (6+ MB that's rarely accessed) + * - Token metadata caches (4+ MB of cached data) + * - Large cached responses from APIs + * - Any data > 100 KB that's not frequently accessed + * + * **Benefits:** + * - Reduces memory usage (data stays on disk) + * - Faster Redux persist (less data to serialize) + * - Faster app startup (less data to parse) + * - Lazy loading (data loaded only when needed) + * + * **Platform Support:** + * - Mobile: FilesystemStorage adapter + * - Extension: IndexedDB adapter + * - Tests/Dev: InMemoryStorageAdapter (default) + * + * @example Using the service via messenger + * + * ```typescript + * // In a controller + * type AllowedActions = + * | StorageServiceSetItemAction + * | StorageServiceGetItemAction; + * + * class SnapController extends BaseController { + * async storeSnapSourceCode(snapId: string, sourceCode: string) { + * await this.messenger.call( + * 'StorageService:setItem', + * 'SnapController', + * `${snapId}:sourceCode`, + * sourceCode, + * ); + * } + * + * async getSnapSourceCode(snapId: string): Promise { + * const { result, error } = await this.messenger.call( + * 'StorageService:getItem', + * 'SnapController', + * `${snapId}:sourceCode`, + * ); + * if (error) { + * throw error; // Handle error + * } + * return result as string | undefined; // undefined if not found + * } + * } + * ``` + * + * @example Initializing in a client + * + * ```typescript + * // Mobile + * const service = new StorageService({ + * messenger: storageServiceMessenger, + * storage: filesystemStorageAdapter, // Platform-specific + * }); + * + * // Extension + * const service = new StorageService({ + * messenger: storageServiceMessenger, + * storage: indexedDBAdapter, // Platform-specific + * }); + * + * // Tests (uses in-memory by default) + * const service = new StorageService({ + * messenger: storageServiceMessenger, + * // No storage - uses InMemoryStorageAdapter + * }); + * ``` + */ +export class StorageService { + /** + * The name of the service. + */ + readonly name: typeof SERVICE_NAME; + + /** + * The messenger suited for this service. + */ + readonly #messenger: StorageServiceMessenger; + + /** + * The storage adapter for persisting data. + */ + readonly #storage: StorageAdapter; + + /** + * Constructs a new StorageService. + * + * @param options - The options. + * @param options.messenger - The messenger suited for this service. + * @param options.storage - Storage adapter for persisting data. + * If not provided, uses InMemoryStorageAdapter (data lost on restart). + */ + constructor({ messenger, storage }: StorageServiceOptions) { + this.name = SERVICE_NAME; + this.#messenger = messenger; + this.#storage = storage ?? new InMemoryStorageAdapter(); + + // Warn if using in-memory storage (data won't persist) + if (!storage) { + console.warn( + `${SERVICE_NAME}: No storage adapter provided. Using in-memory storage. ` + + 'Data will be lost on restart. Provide a storage adapter for persistence.', + ); + } + + // Register messenger actions + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Store large JSON data in storage. + * + * ⚠️ **Designed for large values (100KB+), not many small ones.** + * Each storage operation has I/O overhead. For best performance, + * store one large object rather than many small key-value pairs. + * + * @example Good: Store entire cache as one value + * ```typescript + * await service.setItem('TokenList', 'cache', { '0x1': [...], '0x38': [...] }); + * ``` + * + * @example Avoid: Many small values + * ```typescript + * // ❌ Don't do this - too many small writes + * await service.setItem('TokenList', 'cache:0x1', [...]); + * await service.setItem('TokenList', 'cache:0x38', [...]); + * ``` + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + * @param value - JSON data to store (should be 100KB+ for optimal use). + */ + async setItem(namespace: string, key: string, value: Json): Promise { + // Adapter handles serialization and wrapping with metadata + await this.#storage.setItem(namespace, key, value); + + // Publish event so other controllers can react to changes + // Event type: StorageService:itemSet:namespace + // Payload: [key, value] + this.#messenger.publish( + `${SERVICE_NAME}:itemSet:${namespace}` as const, + key, + value, + ); + } + + /** + * Retrieve JSON data from storage. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + * @returns StorageGetResult: { result } if found, {} if not found, { error } on failure. + */ + async getItem(namespace: string, key: string): Promise { + // Adapter handles deserialization and unwrapping + return await this.#storage.getItem(namespace, key); + } + + /** + * Remove data from storage. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @param key - Storage key (e.g., 'npm:@metamask/example-snap:sourceCode'). + */ + async removeItem(namespace: string, key: string): Promise { + // Adapter builds full storage key (e.g., mobile: 'storageService:namespace:key') + await this.#storage.removeItem(namespace, key); + } + + /** + * Get all keys for a namespace. + * Delegates to storage adapter which handles filtering. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + * @returns Array of keys (without prefix) for this namespace. + */ + async getAllKeys(namespace: string): Promise { + return await this.#storage.getAllKeys(namespace); + } + + /** + * Clear all data for a namespace. + * + * @param namespace - Controller namespace (e.g., 'SnapController'). + */ + async clear(namespace: string): Promise { + await this.#storage.clear(namespace); + } +} diff --git a/packages/storage-service/src/index.ts b/packages/storage-service/src/index.ts new file mode 100644 index 00000000000..d17a8f8fa5c --- /dev/null +++ b/packages/storage-service/src/index.ts @@ -0,0 +1,28 @@ +// Export service class +export { StorageService } from './StorageService'; + +// Export adapters +export { InMemoryStorageAdapter } from './InMemoryStorageAdapter'; + +// Export types from types.ts +export type { + StorageAdapter, + StorageGetResult, + StorageServiceOptions, + StorageServiceActions, + StorageServiceEvents, + StorageServiceMessenger, + StorageServiceItemSetEvent, +} from './types'; + +// Export individual action types from generated file +export type { + StorageServiceSetItemAction, + StorageServiceGetItemAction, + StorageServiceRemoveItemAction, + StorageServiceGetAllKeysAction, + StorageServiceClearAction, +} from './StorageService-method-action-types'; + +// Export service name and storage key prefix constants +export { SERVICE_NAME, STORAGE_KEY_PREFIX } from './types'; diff --git a/packages/storage-service/src/types.ts b/packages/storage-service/src/types.ts new file mode 100644 index 00000000000..4ecfc0da2bb --- /dev/null +++ b/packages/storage-service/src/types.ts @@ -0,0 +1,180 @@ +import type { Messenger } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; + +import type { StorageServiceMethodActions } from './StorageService-method-action-types'; + +/** + * Result type for getItem operations. + * Distinguishes between: found data, not found, and error conditions. + * + * - `{ result: Json }` - Data was found and successfully retrieved + * - `{}` (empty object) - No data stored with that key + * - `{ error: Error }` - Error occurred during retrieval + */ +export type StorageGetResult = + | { result: Json; error?: never } + | { result?: never; error: Error } + | Record; + +/** + * Platform-agnostic storage adapter interface. + * Each client (mobile, extension) implements this interface + * with their preferred storage mechanism. + * + * ⚠️ **Designed for large, infrequently accessed data (100KB+)** + * + * ✅ **Use for:** + * - Snap source code (~6 MB per snap) + * - Token metadata caches (~4 MB) + * - Large API response caches + * + * ❌ **Avoid for:** + * - Small values (< 10 KB) - use controller state instead + * - Frequently accessed data - use controller state instead + * - Many small key-value pairs - use a single large object instead + * + * @example Mobile implementation using FilesystemStorage + * @example Extension implementation using IndexedDB + * @example Tests using InMemoryStorageAdapter + */ +export type StorageAdapter = { + /** + * Retrieve an item from storage. + * Adapter is responsible for building the full storage key. + * + * @param namespace - The controller namespace (e.g., 'SnapController'). + * @param key - The data key (e.g., 'snap-id:sourceCode'). + * @returns StorageGetResult: { result } if found, {} if not found, { error } on failure. + */ + getItem(namespace: string, key: string): Promise; + + /** + * Store a large JSON value in storage. + * + * ⚠️ **Store large values, not many small ones.** + * Each storage operation has I/O overhead. For best performance: + * - Store one large object rather than many small key-value pairs + * - Minimum recommended size: 100 KB per value + * + * Adapter is responsible for: + * - Building the full storage key + * - Serializing to string (JSON.stringify) + * + * @param namespace - The controller namespace (e.g., 'SnapController'). + * @param key - The data key (e.g., 'snap-id:sourceCode'). + * @param value - The JSON value to store. + */ + setItem(namespace: string, key: string, value: Json): Promise; + + /** + * Remove an item from storage. + * Adapter is responsible for building the full storage key. + * + * @param namespace - The controller namespace (e.g., 'SnapController'). + * @param key - The data key (e.g., 'snap-id:sourceCode'). + */ + removeItem(namespace: string, key: string): Promise; + + /** + * Get all keys for a specific namespace. + * Should return keys without the 'storage:namespace:' prefix. + * + * Adapter is responsible for: + * - Filtering keys by prefix: 'storage:{namespace}:' + * - Stripping the prefix from returned keys + * - Returning only the key portion after the prefix + * + * @param namespace - The namespace to get keys for (e.g., 'SnapController'). + * @returns Array of keys without prefix (e.g., ['snap1:sourceCode', 'snap2:sourceCode']). + */ + getAllKeys(namespace: string): Promise; + + /** + * Clear all items for a specific namespace. + * + * Adapter is responsible for: + * - Finding all keys with prefix: 'storageService:{namespace}:' + * - Removing all matching keys + * + * @param namespace - The namespace to clear (e.g., 'SnapController'). + */ + clear(namespace: string): Promise; +}; + +/** + * Options for constructing a {@link StorageService}. + */ +export type StorageServiceOptions = { + /** + * The messenger suited for this service. + */ + messenger: StorageServiceMessenger; + + /** + * Storage adapter for persisting data. + * If not provided, uses in-memory storage (data lost on restart). + * Production clients MUST provide a persistent storage adapter. + */ + storage?: StorageAdapter; +}; + +// Service name constant +export const SERVICE_NAME = 'StorageService'; + +/** + * Storage key prefix for all keys managed by StorageService. + * Keys are formatted as: {STORAGE_KEY_PREFIX}{namespace}:{key} + * Example: 'storageService:SnapController:snap-id:sourceCode' + */ +export const STORAGE_KEY_PREFIX = 'storageService:'; + +/** + * All actions that {@link StorageService} exposes to other consumers. + * Action types are auto-generated from the service methods. + */ +export type StorageServiceActions = StorageServiceMethodActions; + +/** + * Event published when a storage item is set. + * Event type includes namespace only, key passed in payload. + * + * @example + * Subscribe to all changes in TokenListController: + * messenger.subscribe('StorageService:itemSet:TokenListController', (key, value) => { + * // key = 'cache:0x1', 'cache:0x38', etc. + * // value = the data that was set + * if (key.startsWith('cache:')) { + * const chainId = key.replace('cache:', ''); + * // React to cache change for specific chain + * } + * }); + */ +export type StorageServiceItemSetEvent = { + type: `${typeof SERVICE_NAME}:itemSet:${string}`; + payload: [key: string, value: Json]; +}; + +/** + * All events that {@link StorageService} publishes. + */ +export type StorageServiceEvents = StorageServiceItemSetEvent; + +/** + * Actions from other messengers that {@link StorageService} calls. + */ +type AllowedActions = never; + +/** + * Events from other messengers that {@link StorageService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events that + * {@link StorageService} needs to access. + */ +export type StorageServiceMessenger = Messenger< + typeof SERVICE_NAME, + StorageServiceActions | AllowedActions, + StorageServiceEvents | AllowedEvents +>; diff --git a/packages/storage-service/tsconfig.build.json b/packages/storage-service/tsconfig.build.json new file mode 100644 index 00000000000..57f3ffc0f9b --- /dev/null +++ b/packages/storage-service/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [{ "path": "../messenger/tsconfig.build.json" }], + "include": ["../../types", "./src"] +} diff --git a/packages/storage-service/tsconfig.json b/packages/storage-service/tsconfig.json new file mode 100644 index 00000000000..77e4d580465 --- /dev/null +++ b/packages/storage-service/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../messenger" }], + "include": ["../../types", "./src"] +} diff --git a/packages/storage-service/typedoc.json b/packages/storage-service/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/storage-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index c8bc18ff0b9..59988b2572a 100644 --- a/teams.json +++ b/teams.json @@ -61,5 +61,6 @@ "metamask/permission-controller": "team-wallet-integrations,team-core-platform", "metamask/permission-log-controller": "team-wallet-integrations,team-core-platform", "metamask/analytics-controller": "team-extension-platform,team-mobile-platform", - "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform" + "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", + "metamask/storage-service": "team-extension-platform,team-mobile-platform" } diff --git a/tsconfig.build.json b/tsconfig.build.json index bba86b318b1..a666d98376b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -64,6 +64,7 @@ { "path": "./packages/selected-network-controller/tsconfig.build.json" }, { "path": "./packages/shield-controller/tsconfig.build.json" }, { "path": "./packages/signature-controller/tsconfig.build.json" }, + { "path": "./packages/storage-service/tsconfig.build.json" }, { "path": "./packages/subscription-controller/tsconfig.build.json" }, { "path": "./packages/token-search-discovery-controller/tsconfig.build.json" diff --git a/yarn.lock b/yarn.lock index 15b4e3c3de1..d0e735663e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4919,6 +4919,24 @@ __metadata: languageName: node linkType: hard +"@metamask/storage-service@workspace:packages/storage-service": + version: 0.0.0-use.local + resolution: "@metamask/storage-service@workspace:packages/storage-service" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/subscription-controller@workspace:packages/subscription-controller": version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller"