diff --git a/package.json b/package.json index 630047a945f..91f75aa2c98 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,8 @@ "babel-runtime>core-js": false, "simple-git-hooks": false, "tsx>esbuild": false, - "eslint-plugin-import-x>unrs-resolver": false + "eslint-plugin-import-x>unrs-resolver": false, + "better-sqlite3": true } } } diff --git a/packages/wallet/README.md b/packages/wallet/README.md index da275a947df..bc3cf7e5c1b 100644 --- a/packages/wallet/README.md +++ b/packages/wallet/README.md @@ -10,6 +10,16 @@ or `npm install @metamask/wallet` +## Troubleshooting + +### Rebuilding `better-sqlite3` + +This package depends on `better-sqlite3`, which includes a native C addon. The prebuilt binary is downloaded automatically during `yarn install`. If you switch Node versions or branches and the binding is missing, rebuild it with: + +```sh +cd node_modules/better-sqlite3 && npx prebuild-install +``` + ## 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/wallet/package.json b/packages/wallet/package.json index 0ad8e733fae..d2668b01442 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -32,6 +32,16 @@ "default": "./dist/index.cjs" } }, + "./persistence": { + "import": { + "types": "./dist/persistence/index.d.mts", + "default": "./dist/persistence/index.mjs" + }, + "require": { + "types": "./dist/persistence/index.d.cts", + "default": "./dist/persistence/index.cjs" + } + }, "./package.json": "./package.json" }, "publishConfig": { @@ -47,7 +57,7 @@ "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", "since-latest-release": "../../scripts/since-latest-release.sh", - "test:prepare": "./scripts/install-anvil.sh", + "test:prepare": "./scripts/install-binaries.sh", "test": "yarn test:prepare && 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", @@ -65,12 +75,14 @@ "@metamask/remote-feature-flag-controller": "^4.2.0", "@metamask/scure-bip39": "^2.1.1", "@metamask/transaction-controller": "^64.3.0", - "@metamask/utils": "^11.9.0" + "@metamask/utils": "^11.9.0", + "better-sqlite3": "^12.9.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@metamask/foundryup": "^1.0.1", "@ts-bridge/cli": "^0.6.4", + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", diff --git a/packages/wallet/scripts/install-anvil.sh b/packages/wallet/scripts/install-anvil.sh deleted file mode 100755 index 80b2a68ed35..00000000000 --- a/packages/wallet/scripts/install-anvil.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -set -e -set -o pipefail - -# mm-foundryup installs anvil to `/node_modules/.bin/anvil`. Pin cwd to the -# package root so the install location is predictable regardless of how this -# script is invoked. -cd "$(cd "$(dirname "$0")/.." && pwd)" - -# Run foundryup's TypeScript entry point directly via tsx. This avoids having -# to build @metamask/foundryup first, which matters in CI where workspace deps -# aren't built before tests run. -if ! output=$(yarn tsx ../foundryup/src/cli.ts --binaries anvil 2>&1); then - echo "$output" >&2 - exit 1 -fi diff --git a/packages/wallet/scripts/install-binaries.sh b/packages/wallet/scripts/install-binaries.sh new file mode 100755 index 00000000000..cf087190a59 --- /dev/null +++ b/packages/wallet/scripts/install-binaries.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +# Pin cwd to the package root so all paths are predictable regardless of how +# this script is invoked. Also derive the monorepo root (two levels up). +PACKAGE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MONOREPO_ROOT="$(cd "${PACKAGE_ROOT}/../.." && pwd)" +cd "${PACKAGE_ROOT}" + +# Run foundryup's TypeScript entry point directly via tsx. This avoids having +# to build @metamask/foundryup first, which matters in CI where workspace deps +# aren't built before tests run. +if ! output=$(yarn tsx ../foundryup/src/cli.ts --binaries anvil 2>&1); then + echo "$output" >&2 + exit 1 +fi + +# Install the better-sqlite3 native addon if missing. Yarn has +# `enableScripts: false` globally, so install scripts never run during +# `yarn install` and the addon may be absent from the filesystem. Invoke the +# prebuild-install binary directly to fetch a matching prebuild for the active +# Node version and platform. +BETTER_SQLITE3_DIR="${MONOREPO_ROOT}/node_modules/better-sqlite3" +if [ ! -f "${BETTER_SQLITE3_DIR}/build/Release/better_sqlite3.node" ]; then + ( + cd "${BETTER_SQLITE3_DIR}" + "${MONOREPO_ROOT}/node_modules/.bin/prebuild-install" + ) +fi diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 13cc7a899d0..dc3a67ea23c 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -5,6 +5,7 @@ import { DistributionType, EnvironmentType, } from '@metamask/remote-feature-flag-controller'; +import { TransactionController } from '@metamask/transaction-controller'; import { enableNetConnect } from 'nock'; import { startAnvil } from '../test/anvil'; @@ -22,20 +23,18 @@ const TEST_PASSWORD = 'testpass'; async function setupWallet(): Promise { const wallet = new Wallet({ - options: { - infuraProjectId: 'fake-infura-project-id', - clientVersion: '1.0.0', - showApprovalRequest: (): undefined => undefined, - clientConfigApiService: new ClientConfigApiService({ - fetch: globalThis.fetch, - config: { - client: ClientType.Extension, - distribution: DistributionType.Main, - environment: EnvironmentType.Production, - }, - }), - getMetaMetricsId: (): string => 'fake-metrics-id', - }, + infuraProjectId: 'fake-infura-project-id', + clientVersion: '1.0.0', + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'fake-metrics-id', }); await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_PHRASE); @@ -134,20 +133,18 @@ describe('Wallet', () => { it('can create secret recovery phrase', async () => { wallet = new Wallet({ - options: { - infuraProjectId: 'fake-infura-project-id', - clientVersion: '1.0.0', - showApprovalRequest: (): undefined => undefined, - clientConfigApiService: new ClientConfigApiService({ - fetch: globalThis.fetch, - config: { - client: ClientType.Extension, - distribution: DistributionType.Main, - environment: EnvironmentType.Production, - }, - }), - getMetaMetricsId: (): string => 'fake-metrics-id', - }, + infuraProjectId: 'fake-infura-project-id', + clientVersion: '1.0.0', + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'fake-metrics-id', }); await createSecretRecoveryPhrase(wallet, TEST_PASSWORD); @@ -169,4 +166,75 @@ describe('Wallet', () => { vault: expect.any(String), }); }); + + describe('lifecycle', () => { + const options = { + infuraProjectId: 'fake-infura-project-id', + clientVersion: '1.0.0', + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'fake-metrics-id', + }; + + it('exposes controllerMetadata for each initialized controller', () => { + wallet = new Wallet(options); + + const names = Object.keys(wallet.controllerMetadata); + expect(names).toStrictEqual(Object.keys(wallet.state)); + for (const name of names) { + expect(wallet.controllerMetadata[name]).toBeDefined(); + } + }); + + it('publishes Root:walletDestroyed exactly once on destroy', async () => { + wallet = new Wallet(options); + + const listener = jest.fn(); + wallet.messenger.subscribe('Root:walletDestroyed', listener); + + await wallet.destroy(); + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('publishes Root:walletDestroyed even if a controller destroy throws synchronously', async () => { + wallet = new Wallet(options); + + jest + .spyOn(TransactionController.prototype, 'destroy') + .mockImplementation(() => { + throw new Error('sync destroy error'); + }); + + const listener = jest.fn(); + wallet.messenger.subscribe('Root:walletDestroyed', listener); + + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('publishes Root:walletDestroyed even if a controller destroy rejects', async () => { + wallet = new Wallet(options); + + jest + .spyOn(TransactionController.prototype, 'destroy') + .mockRejectedValue(new Error('async destroy error')); + + const listener = jest.fn(); + wallet.messenger.subscribe('Root:walletDestroyed', listener); + + await wallet.destroy(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/wallet/src/Wallet.ts b/packages/wallet/src/Wallet.ts index 3f60491b496..c50f7096d48 100644 --- a/packages/wallet/src/Wallet.ts +++ b/packages/wallet/src/Wallet.ts @@ -1,5 +1,5 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; import { Messenger } from '@metamask/messenger'; -import type { Json } from '@metamask/utils'; import type { DefaultActions, @@ -11,9 +11,8 @@ import type { import { initialize } from './initialization'; import type { WalletOptions } from './types'; -export type WalletConstructorArgs = { - state?: Record; - options: WalletOptions; +type PersistableController = { + metadata: StateMetadataConstraint; }; export class Wallet { @@ -22,12 +21,34 @@ export class Wallet { readonly #instances: DefaultInstances; - constructor({ state = {}, options }: WalletConstructorArgs) { + readonly #controllerMetadata: Readonly< + Record> + >; + + #destroyed = false; + + constructor({ state, ...options }: WalletOptions) { this.messenger = new Messenger({ namespace: 'Root', }); - this.#instances = initialize({ state, messenger: this.messenger, options }); + this.messenger.registerInitialEventPayload({ + eventType: 'Root:walletDestroyed', + getPayload: () => [], + }); + + this.#instances = initialize({ + state: state ?? {}, + messenger: this.messenger, + options, + }); + + this.#controllerMetadata = Object.fromEntries( + Object.entries(this.#instances).map(([name, instance]) => [ + name, + (instance as unknown as PersistableController).metadata, + ]), + ); } get state(): DefaultState { @@ -40,17 +61,30 @@ export class Wallet { ) as DefaultState; } + get controllerMetadata(): Readonly< + Record> + > { + return this.#controllerMetadata; + } + async destroy(): Promise { - await Promise.all( + if (this.#destroyed) { + return; + } + this.#destroyed = true; + + await Promise.allSettled( Object.values(this.#instances).map((instance) => { // @ts-expect-error Accessing protected property. if (typeof instance.destroy === 'function') { // @ts-expect-error Accessing protected property. - return instance.destroy(); + return (async (): Promise => await instance.destroy())(); } /* istanbul ignore next */ - return undefined; + return Promise.resolve(); }), ); + + this.messenger.publish('Root:walletDestroyed'); } } diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index a3db3b1b449..5fa0502ec2b 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1 +1,8 @@ export { Wallet } from './Wallet'; +export type { WalletOptions } from './types'; +export type { + DefaultActions, + DefaultEvents, + RootMessenger, + WalletDestroyedEvent, +} from './initialization'; diff --git a/packages/wallet/src/initialization/defaults.ts b/packages/wallet/src/initialization/defaults.ts index 85b02c841f4..ce0c51dabfc 100644 --- a/packages/wallet/src/initialization/defaults.ts +++ b/packages/wallet/src/initialization/defaults.ts @@ -38,7 +38,14 @@ export type DefaultInstances = { export type DefaultActions = MessengerActions; -export type DefaultEvents = MessengerEvents; +export type WalletDestroyedEvent = { + type: 'Root:walletDestroyed'; + payload: []; +}; + +export type DefaultEvents = + | MessengerEvents + | WalletDestroyedEvent; export type RootMessenger< AllowedActions extends ActionConstraint = ActionConstraint, diff --git a/packages/wallet/src/initialization/index.ts b/packages/wallet/src/initialization/index.ts index 5d17e1a18fa..757f51fc01d 100644 --- a/packages/wallet/src/initialization/index.ts +++ b/packages/wallet/src/initialization/index.ts @@ -4,5 +4,6 @@ export type { DefaultInstances, DefaultState, RootMessenger, + WalletDestroyedEvent, } from './defaults'; export { initialize } from './initialization'; diff --git a/packages/wallet/src/persistence/KeyValueStore.test.ts b/packages/wallet/src/persistence/KeyValueStore.test.ts new file mode 100644 index 00000000000..f7c3ad6445d --- /dev/null +++ b/packages/wallet/src/persistence/KeyValueStore.test.ts @@ -0,0 +1,117 @@ +import type { Json } from '@metamask/utils'; +import Sqlite from 'better-sqlite3'; +import { unlink } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { KeyValueStore } from './KeyValueStore'; + +describe('KeyValueStore', () => { + let store: KeyValueStore; + + beforeEach(() => { + store = new KeyValueStore(':memory:'); + }); + + afterEach(() => { + store.close(); + }); + + describe('set and get', () => { + it('stores and retrieves a string value', () => { + store.set('key1', 'hello'); + expect(store.get('key1')).toBe('hello'); + }); + + it('stores and retrieves a number value', () => { + store.set('key1', 42); + expect(store.get('key1')).toBe(42); + }); + + it('stores and retrieves a boolean value', () => { + store.set('key1', true); + expect(store.get('key1')).toBe(true); + }); + + it('stores and retrieves null', () => { + store.set('key1', null); + expect(store.get('key1')).toBeNull(); + }); + + it('stores and retrieves a complex object', () => { + const makeValue = (): Json => ({ + nested: { array: [1, 'two', null, { deep: true }] }, + }); + store.set('key1', makeValue()); + expect(store.get('key1')).toStrictEqual(makeValue()); + }); + + it('returns undefined for a nonexistent key', () => { + expect(store.get('missing')).toBeUndefined(); + }); + + it('overwrites an existing key', () => { + store.set('key1', 'first'); + store.set('key1', 'second'); + expect(store.get('key1')).toBe('second'); + }); + }); + + describe('getAll', () => { + it('returns an empty object when the store is empty', () => { + expect(store.getAll()).toStrictEqual({}); + }); + + it('returns all stored key-value pairs', () => { + store.set('a', 1); + store.set('b', 'two'); + store.set('c', [3]); + expect(store.getAll()).toStrictEqual({ a: 1, b: 'two', c: [3] }); + }); + }); + + describe('delete', () => { + it('removes an existing key', () => { + store.set('key1', 'value'); + store.delete('key1'); + expect(store.get('key1')).toBeUndefined(); + }); + + it('does nothing when deleting a nonexistent key', () => { + expect(() => store.delete('missing')).not.toThrow(); + }); + }); + + describe('corrupt data', () => { + let tempPath: string; + let corruptStore: KeyValueStore; + + beforeEach(() => { + tempPath = path.join(os.tmpdir(), `kv-test-${Date.now()}.db`); + corruptStore = new KeyValueStore(tempPath); + + const rawDb = new Sqlite(tempPath); + rawDb + .prepare('INSERT INTO kv (key, value) VALUES (?, ?)') + .run('bad', 'not json'); + rawDb.close(); + }); + + afterEach(async () => { + corruptStore.close(); + await unlink(tempPath); + }); + + it('throws when get() encounters a non-JSON value', () => { + expect(() => corruptStore.get('bad')).toThrow( + "Failed to parse stored value for key 'bad'", + ); + }); + + it('throws when getAll() encounters a non-JSON value', () => { + expect(() => corruptStore.getAll()).toThrow( + "Failed to parse stored value for key 'bad'", + ); + }); + }); +}); diff --git a/packages/wallet/src/persistence/KeyValueStore.ts b/packages/wallet/src/persistence/KeyValueStore.ts new file mode 100644 index 00000000000..b7b0d654596 --- /dev/null +++ b/packages/wallet/src/persistence/KeyValueStore.ts @@ -0,0 +1,73 @@ +import type { Json } from '@metamask/utils'; +import Sqlite from 'better-sqlite3'; + +/** + * A synchronous key-value store backed by better-sqlite3. + * + * Uses a single `kv` table with TEXT key (primary key) and TEXT value + * (JSON-serialized). Intended as the persistence backend for wallet + * controller state. + */ +export class KeyValueStore { + readonly #db: Sqlite.Database; + + readonly #getStmt: Sqlite.Statement<[string], { value: string } | undefined>; + + readonly #setStmt: Sqlite.Statement<[string, string], void>; + + readonly #deleteStmt: Sqlite.Statement<[string], void>; + + readonly #getAllStmt: Sqlite.Statement<[], { key: string; value: string }>; + + constructor(databasePath: string) { + this.#db = new Sqlite(databasePath); + this.#db.pragma('journal_mode = WAL'); + this.#db.exec( + 'CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)', + ); + + this.#getStmt = this.#db.prepare('SELECT value FROM kv WHERE key = ?'); + this.#setStmt = this.#db.prepare( + 'INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)', + ); + this.#deleteStmt = this.#db.prepare('DELETE FROM kv WHERE key = ?'); + this.#getAllStmt = this.#db.prepare('SELECT key, value FROM kv'); + } + + get(key: string): Json | undefined { + const row = this.#getStmt.get(key); + if (!row) { + return undefined; + } + try { + return JSON.parse(row.value); + } catch { + throw new Error(`Failed to parse stored value for key '${key}'`); + } + } + + set(key: string, value: Json): void { + this.#setStmt.run(key, JSON.stringify(value)); + } + + getAll(): Record { + const rows = this.#getAllStmt.all(); + const result: Record = {}; + for (const row of rows) { + try { + result[row.key] = JSON.parse(row.value); + } catch { + throw new Error(`Failed to parse stored value for key '${row.key}'`); + } + } + return result; + } + + delete(key: string): void { + this.#deleteStmt.run(key); + } + + close(): void { + this.#db.close(); + } +} diff --git a/packages/wallet/src/persistence/index.ts b/packages/wallet/src/persistence/index.ts new file mode 100644 index 00000000000..08081afc4b3 --- /dev/null +++ b/packages/wallet/src/persistence/index.ts @@ -0,0 +1,2 @@ +export { KeyValueStore } from './KeyValueStore'; +export { loadState, subscribeToChanges } from './persistence'; diff --git a/packages/wallet/src/persistence/persistence.test.ts b/packages/wallet/src/persistence/persistence.test.ts new file mode 100644 index 00000000000..e02f58bc019 --- /dev/null +++ b/packages/wallet/src/persistence/persistence.test.ts @@ -0,0 +1,479 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import type { Json } from '@metamask/utils'; + +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../initialization'; +import { KeyValueStore } from './KeyValueStore'; +import { loadState, subscribeToChanges } from './persistence'; + +type TestMessenger = RootMessenger; + +describe('loadState', () => { + let store: KeyValueStore; + + beforeEach(() => { + store = new KeyValueStore(':memory:'); + }); + + afterEach(() => { + store.close(); + }); + + it('returns an empty object when the store is empty', () => { + expect(loadState(store)).toStrictEqual({}); + }); + + it('groups keys by controller name', () => { + store.set('ControllerA.prop1', 'value1'); + store.set('ControllerA.prop2', 42); + store.set('ControllerB.prop1', [1, 2, 3]); + + expect(loadState(store)).toStrictEqual({ + ControllerA: { prop1: 'value1', prop2: 42 }, + ControllerB: { prop1: [1, 2, 3] }, + }); + }); + + it('splits on the first dot only', () => { + store.set('Controller.prop.with.dots', 'value'); + + expect(loadState(store)).toStrictEqual({ + Controller: { 'prop.with.dots': 'value' }, + }); + }); + + it('throws on a key without a dot separator', () => { + store.set('noDot', 'value'); + + expect(() => loadState(store)).toThrow( + "Invalid key in store: 'noDot'. Expected format 'ControllerName.propertyName'.", + ); + }); + + it('throws on a key with an empty controller name', () => { + store.set('.propName', 'value'); + + expect(() => loadState(store)).toThrow( + "Invalid key in store: '.propName'. Both controller name and property name must be non-empty.", + ); + }); + + it('throws on a key with an empty property name', () => { + store.set('ControllerName.', 'value'); + + expect(() => loadState(store)).toThrow( + "Invalid key in store: 'ControllerName.'. Both controller name and property name must be non-empty.", + ); + }); +}); + +describe('subscribeToChanges', () => { + let store: KeyValueStore; + + beforeEach(() => { + store = new KeyValueStore(':memory:'); + }); + + afterEach(() => { + store.close(); + }); + + it('writes persist-flagged properties on state change', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['persisted', true], + ['transient', false], + ]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { persisted: 'savedValue', transient: 'notSaved' }, + patches: [ + { op: 'replace', path: ['persisted'], value: 'savedValue' }, + { op: 'replace', path: ['transient'], value: 'notSaved' }, + ], + }); + + expect(store.get('TestController.persisted')).toBe('savedValue'); + expect(store.get('TestController.transient')).toBeUndefined(); + }); + + it('only writes properties that are in the patches', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['propA', true], + ['propB', true], + ]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { propA: 'changedA', propB: 'unchangedB' }, + patches: [{ op: 'replace', path: ['propA'], value: 'changedA' }], + }); + + expect(store.get('TestController.propA')).toBe('changedA'); + expect(store.get('TestController.propB')).toBeUndefined(); + }); + + it('applies StateDeriver functions before writing', () => { + const deriver = (value: never): Json => + (value as unknown as string).toUpperCase(); + + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['derived', deriver]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { derived: 'hello' }, + patches: [{ op: 'replace', path: ['derived'], value: 'hello' }], + }); + + expect(store.get('TestController.derived')).toBe('HELLO'); + }); + + it('handles nested property changes by extracting the top-level key', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['nested', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { nested: { inner: { deep: 'value' } } }, + patches: [ + { op: 'replace', path: ['nested', 'inner', 'deep'], value: 'value' }, + ], + }); + + expect(store.get('TestController.nested')).toStrictEqual({ + inner: { deep: 'value' }, + }); + }); + + it('skips controllers with no persisted properties', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['transientOnly', false]]), + }); + + const unsubscribe = subscribeToChanges( + messenger, + controllerMetadata, + store, + ); + + publishStateChanged(messenger, 'TestController', { + state: { transientOnly: 'value' }, + patches: [{ op: 'replace', path: ['transientOnly'], value: 'value' }], + }); + + expect(store.getAll()).toStrictEqual({}); + unsubscribe(); + }); + + it('returns an unsubscribe function that stops persistence', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['prop', true]]), + }); + + const unsubscribe = subscribeToChanges( + messenger, + controllerMetadata, + store, + ); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'first' }, + patches: [{ op: 'replace', path: ['prop'], value: 'first' }], + }); + + expect(store.get('TestController.prop')).toBe('first'); + + unsubscribe(); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'second' }, + patches: [{ op: 'replace', path: ['prop'], value: 'second' }], + }); + + expect(store.get('TestController.prop')).toBe('first'); + }); + + it('deletes persisted property when it is removed from state', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['removable', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + // First, persist a value + publishStateChanged(messenger, 'TestController', { + state: { removable: 'exists' }, + patches: [{ op: 'replace', path: ['removable'], value: 'exists' }], + }); + + expect(store.get('TestController.removable')).toBe('exists'); + + // Now remove it — state no longer contains the property + publishStateChanged(messenger, 'TestController', { + state: {}, + patches: [{ op: 'remove', path: ['removable'] }], + }); + + expect(store.get('TestController.removable')).toBeUndefined(); + }); + + it('persists all flagged properties on root state replacement', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['propA', true], + ['propB', true], + ['transient', false], + ]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { propA: 'newA', propB: 'newB', transient: 'skip' }, + patches: [ + { + op: 'replace', + path: [], + value: { propA: 'newA', propB: 'newB', transient: 'skip' }, + }, + ], + }); + + expect(store.get('TestController.propA')).toBe('newA'); + expect(store.get('TestController.propB')).toBe('newB'); + expect(store.get('TestController.transient')).toBeUndefined(); + }); + + it('logs and continues when store.set throws', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['propA', true], + ['propB', true], + ]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + const error = new Error('disk full'); + const originalSet = store.set.bind(store); + let callCount = 0; + jest.spyOn(store, 'set').mockImplementation((key, value) => { + callCount += 1; + if (callCount === 1) { + throw error; + } + originalSet(key, value); + }); + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + publishStateChanged(messenger, 'TestController', { + state: { propA: 'a', propB: 'b' }, + patches: [ + { op: 'replace', path: ['propA'], value: 'a' }, + { op: 'replace', path: ['propB'], value: 'b' }, + ], + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to persist state for TestController.propA', + error, + ); + // propB should still be persisted despite propA failing + expect(store.get('TestController.propB')).toBe('b'); + + consoleSpy.mockRestore(); + }); + + it('handles multiple controllers independently', () => { + const { messenger, controllerMetadata } = createMockControllers({ + ControllerA: createStateMetadata([['data', true]]), + ControllerB: createStateMetadata([['data', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'ControllerA', { + state: { data: 'fromA' }, + patches: [{ op: 'replace', path: ['data'], value: 'fromA' }], + }); + + publishStateChanged(messenger, 'ControllerB', { + state: { data: 'fromB' }, + patches: [{ op: 'replace', path: ['data'], value: 'fromB' }], + }); + + expect(store.get('ControllerA.data')).toBe('fromA'); + expect(store.get('ControllerB.data')).toBe('fromB'); + }); +}); + +describe('subscribeToChanges unsubscribe', () => { + let store: KeyValueStore; + + beforeEach(() => { + store = new KeyValueStore(':memory:'); + }); + + afterEach(() => { + store.close(); + }); + + it('self-unsubscribes when Root:walletDestroyed is published', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['prop', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'before' }, + patches: [{ op: 'replace', path: ['prop'], value: 'before' }], + }); + + expect(store.get('TestController.prop')).toBe('before'); + + messenger.publish('Root:walletDestroyed'); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'after' }, + patches: [{ op: 'replace', path: ['prop'], value: 'after' }], + }); + + expect(store.get('TestController.prop')).toBe('before'); + }); + + it('stops persistence so writes to a subsequently closed store do not throw', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['prop', true]]), + }); + + const unsubscribe = subscribeToChanges( + messenger, + controllerMetadata, + store, + ); + + unsubscribe(); + store.close(); + + // This should not throw — the handler was unsubscribed before close. + expect(() => + publishStateChanged(messenger, 'TestController', { + state: { prop: 'after-close' }, + patches: [{ op: 'replace', path: ['prop'], value: 'after-close' }], + }), + ).not.toThrow(); + }); +}); + +type MockMetadata = Record< + string, + { + persist: boolean | ((value: never) => Json); + includeInDebugSnapshot: boolean; + includeInStateLogs: boolean; + usedInUi: boolean; + } +>; + +type MockControllers = { + messenger: TestMessenger; + controllerMetadata: Record; +}; + +/** + * Creates a state metadata object for a mock controller. + * + * @param properties - An array of [property name, persist value] pairs. + * @returns A mock metadata object. + */ +function createStateMetadata( + properties: [string, boolean | ((value: never) => Json)][], +): MockMetadata { + return Object.fromEntries( + properties.map(([name, persist]) => [ + name, + { + persist, + includeInDebugSnapshot: false, + includeInStateLogs: false, + usedInUi: false, + }, + ]), + ); +} + +/** + * Creates a mock messenger and controllerMetadata map for testing persistence + * wiring. The messenger supports subscribe/unsubscribe/publish. + * + * @param controllers - Map of controller names to their metadata. + * @returns A mock messenger and a controllerMetadata map. + */ +function createMockControllers( + controllers: Record, +): MockControllers { + const handlers = new Map void>>(); + + const messenger = { + subscribe: (eventType: string, handler: (...args: unknown[]) => void) => { + if (!handlers.has(eventType)) { + handlers.set(eventType, new Set()); + } + handlers.get(eventType)?.add(handler); + }, + unsubscribe: (eventType: string, handler: (...args: unknown[]) => void) => { + handlers.get(eventType)?.delete(handler); + }, + publish: (eventType: string, ...payload: unknown[]) => { + const subs = handlers.get(eventType); + if (subs) { + for (const handler of subs) { + handler(...payload); + } + } + }, + } as unknown as TestMessenger; + + const controllerMetadata: Record = {}; + for (const [name, metadata] of Object.entries(controllers)) { + controllerMetadata[name] = metadata; + } + + return { messenger, controllerMetadata }; +} + +/** + * Publishes a stateChanged event on the mock messenger. + * + * @param messenger - The mock messenger to publish on. + * @param controllerName - The name of the controller whose state changed. + * @param options - The state and patches to publish. + * @param options.state - The new controller state. + * @param options.patches - The Immer patches describing the state change. + */ +function publishStateChanged( + messenger: RootMessenger, + controllerName: string, + { state, patches }: { state: Record; patches: unknown[] }, +): void { + // @ts-expect-error Event type is dynamically constructed, but we know it's valid. + messenger.publish(`${controllerName}:stateChanged`, state, patches); +} diff --git a/packages/wallet/src/persistence/persistence.ts b/packages/wallet/src/persistence/persistence.ts new file mode 100644 index 00000000000..7d6c7002b65 --- /dev/null +++ b/packages/wallet/src/persistence/persistence.ts @@ -0,0 +1,192 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import { hasProperty } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; +import type { Patch } from 'immer'; + +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../initialization'; +import type { KeyValueStore } from './KeyValueStore'; + +/** + * Construct a store key from a controller name and property name. + * + * @param controllerName - The controller name. + * @param propertyName - The property name. + * @returns The store key in the format `ControllerName.propertyName`. + */ +function storeKey(controllerName: string, propertyName: string): string { + return `${controllerName}.${propertyName}`; +} + +/** + * Load persisted state from the key-value store and reconstruct it as + * a record keyed by controller name. + * + * Keys in the store follow the format `ControllerName.propertyName`. + * This function groups them into `{ [controllerName]: { [propertyName]: value } }`. + * + * @param store - The key-value store to read from. + * @returns A record of controller states, suitable for passing to `initialize()`. + */ +export function loadState( + store: KeyValueStore, +): Record> { + const allPairs = store.getAll(); + const state: Record> = {}; + + for (const [key, value] of Object.entries(allPairs)) { + const dotIndex = key.indexOf('.'); + if (dotIndex === -1) { + throw new Error( + `Invalid key in store: '${key}'. Expected format 'ControllerName.propertyName'.`, + ); + } + const controllerName = key.slice(0, dotIndex); + const propertyName = key.slice(dotIndex + 1); + + if (!controllerName || !propertyName) { + throw new Error( + `Invalid key in store: '${key}'. Both controller name and property name must be non-empty.`, + ); + } + + if (!state[controllerName]) { + state[controllerName] = {}; + } + state[controllerName][propertyName] = value; + } + return state; +} + +/** + * Subscribe to all controller `stateChanged` events and persist changes + * to the key-value store. + * + * For each controller's metadata, this function determines which state + * properties are persist-flagged. When a `stateChanged` event fires, it uses + * the Immer patches to identify which top-level properties changed, filters + * to only persist-flagged properties, and writes them to the store. + * + * Also subscribes to `Root:walletDestroyed` — when the Wallet publishes that + * event, persistence tears itself down. + * + * @param messenger - The root messenger to subscribe on. + * @param controllerMetadata - A map from controller name to its state metadata. + * @param store - The key-value store to write to. + * @returns A function that unsubscribes all persistence handlers. + */ +export function subscribeToChanges( + messenger: RootMessenger, + controllerMetadata: Record, + store: KeyValueStore, +): () => void { + const unsubscribers: (() => void)[] = []; + + for (const [controllerName, metadata] of Object.entries(controllerMetadata)) { + const persistedProperties = getPersistPropertyNames(metadata); + if (persistedProperties.size === 0) { + continue; + } + + const eventType = `${controllerName}:stateChanged`; + + const handler = (state: Record, patches: Patch[]): void => { + const changed = getChangedProperties(patches, persistedProperties); + + for (const prop of changed) { + const key = storeKey(controllerName, prop); + + try { + if (!hasProperty(state, prop)) { + store.delete(key); + continue; + } + + const persistFlag = metadata[prop]?.persist; + + if (typeof persistFlag === 'function') { + store.set(key, persistFlag(state[prop] as never)); + } else { + store.set(key, state[prop]); + } + } catch (error) { + // TODO: Handle persistence failure to protect the user from data loss. + console.error(`Failed to persist state for ${key}`, error); + } + } + }; + + // @ts-expect-error Event type is dynamically constructed, but we know it's valid. + messenger.subscribe(eventType, handler); + + unsubscribers.push(() => { + // @ts-expect-error Event type is dynamically constructed, but we know it's valid. + messenger.unsubscribe(eventType, handler); + }); + } + + const unsubscribeAll = (): void => { + while (unsubscribers.length > 0) { + unsubscribers.pop()?.(); + } + }; + + const destroyedHandler = (): void => unsubscribeAll(); + messenger.subscribe('Root:walletDestroyed', destroyedHandler); + unsubscribers.push(() => { + messenger.unsubscribe('Root:walletDestroyed', destroyedHandler); + }); + + return unsubscribeAll; +} + +/** + * Get the set of property names whose `persist` metadata is truthy + * (either `true` or a `StateDeriver` function). + * + * @param metadata - The controller's state metadata. + * @returns A set of property names that should be persisted. + */ +function getPersistPropertyNames( + metadata: StateMetadataConstraint, +): ReadonlySet { + const names = new Set(); + for (const key of Object.keys(metadata)) { + if (metadata[key].persist) { + names.add(key); + } + } + return names; +} + +/** + * Extracts the set of persist-flagged top-level property names that changed + * from an array of Immer patches. + * + * If any patch has an empty path (indicating a root state replacement), + * all persist-flagged properties are returned. + * + * @param patches - Immer patches from a state update. + * @param persistedProperties - The set of persist-flagged property names. + * @returns A set of top-level property names that were modified. + */ +function getChangedProperties( + patches: Patch[], + persistedProperties: ReadonlySet, +): ReadonlySet { + const changed = new Set(); + for (const patch of patches) { + if (patch.path.length === 0) { + return persistedProperties; + } + + const prop = String(patch.path[0]); + if (persistedProperties.has(prop)) { + changed.add(prop); + } + } + return changed; +} diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index e808637e474..865d6241e47 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -1,6 +1,8 @@ import type { ClientConfigApiService } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; export type WalletOptions = { + state?: Record>; infuraProjectId: string; clientVersion: string; showApprovalRequest: () => void; diff --git a/yarn.lock b/yarn.lock index b28ccc07915..f38b0532be9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5715,7 +5715,9 @@ __metadata: "@metamask/transaction-controller": "npm:^64.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" + "@types/better-sqlite3": "npm:^7.6.13" "@types/jest": "npm:^29.5.14" + better-sqlite3: "npm:^12.9.0" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" nock: "npm:^13.3.1" @@ -6753,6 +6755,15 @@ __metadata: languageName: node linkType: hard +"@types/better-sqlite3@npm:^7.6.13": + version: 7.6.13 + resolution: "@types/better-sqlite3@npm:7.6.13" + dependencies: + "@types/node": "npm:*" + checksum: 10/c74dafa3c550ac866737870016d7b1a735c7d450c16d40962eeb54510fa150e91752bfdf678f55e91894d8853771b95f909b0062122116cddac4d80491b74411 + languageName: node + linkType: hard + "@types/bn.js@npm:*, @types/bn.js@npm:^5.1.0, @types/bn.js@npm:^5.1.5": version: 5.1.6 resolution: "@types/bn.js@npm:5.1.6" @@ -7787,6 +7798,17 @@ __metadata: languageName: node linkType: hard +"better-sqlite3@npm:^12.9.0": + version: 12.9.0 + resolution: "better-sqlite3@npm:12.9.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10/0b32b06140f2a98ce7fbcf7d30b56b46e16d0b6fbfb63157a7bb61dea7265e176ab362d439785e852716a03a130db5f0ab356b6a68cbcb23d59a362c1a8b01d4 + languageName: node + linkType: hard + "bignumber.js@npm:^9.1.2": version: 9.1.2 resolution: "bignumber.js@npm:9.1.2" @@ -7806,6 +7828,15 @@ __metadata: languageName: node linkType: hard +"bindings@npm:^1.5.0": + version: 1.5.0 + resolution: "bindings@npm:1.5.0" + dependencies: + file-uri-to-path: "npm:1.0.0" + checksum: 10/593d5ae975ffba15fbbb4788fe5abd1e125afbab849ab967ab43691d27d6483751805d98cb92f7ac24a2439a8a8678cd0131c535d5d63de84e383b0ce2786133 + languageName: node + linkType: hard + "bitcoin-address-validation@npm:^2.2.3": version: 2.2.3 resolution: "bitcoin-address-validation@npm:2.2.3" @@ -7817,6 +7848,17 @@ __metadata: languageName: node linkType: hard +"bl@npm:^4.0.3": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10/b7904e66ed0bdfc813c06ea6c3e35eafecb104369dbf5356d0f416af90c1546de3b74e5b63506f0629acf5e16a6f87c3798f16233dcff086e9129383aa02ab55 + languageName: node + linkType: hard + "blakejs@npm:^1.1.0": version: 1.2.1 resolution: "blakejs@npm:1.2.1" @@ -7994,6 +8036,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10/997434d3c6e3b39e0be479a80288875f71cd1c07d75a3855e6f08ef848a3c966023f79534e22e415ff3a5112708ce06127277ab20e527146d55c84566405c7c6 + languageName: node + linkType: hard + "buffer@npm:^6.0.3": version: 6.0.3 resolution: "buffer@npm:6.0.3" @@ -8139,6 +8191,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^1.1.1": + version: 1.1.4 + resolution: "chownr@npm:1.1.4" + checksum: 10/115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -8614,6 +8673,15 @@ __metadata: languageName: node linkType: hard +"decompress-response@npm:^6.0.0": + version: 6.0.0 + resolution: "decompress-response@npm:6.0.0" + dependencies: + mimic-response: "npm:^3.1.0" + checksum: 10/d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812 + languageName: node + linkType: hard + "dedent@npm:^1.0.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" @@ -8626,6 +8694,13 @@ __metadata: languageName: node linkType: hard +"deep-extend@npm:^0.6.0": + version: 0.6.0 + resolution: "deep-extend@npm:0.6.0" + checksum: 10/7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7 + languageName: node + linkType: hard + "deep-freeze-strict@npm:^1.1.1": version: 1.1.1 resolution: "deep-freeze-strict@npm:1.1.1" @@ -8764,6 +8839,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.0": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766 + languageName: node + linkType: hard + "detect-newline@npm:^3.0.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -8919,6 +9001,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": + version: 1.4.5 + resolution: "end-of-stream@npm:1.4.5" + dependencies: + once: "npm:^1.4.0" + checksum: 10/1e0cfa6e7f49887544e03314f9dfc56a8cb6dde910cbb445983ecc2ff426fc05946df9d75d8a21a3a64f2cecfe1bf88f773952029f46756b2ed64a24e95b1fb8 + languageName: node + linkType: hard + "enhanced-resolve@npm:^5.15.0, enhanced-resolve@npm:^5.17.1": version: 5.18.0 resolution: "enhanced-resolve@npm:5.18.0" @@ -9677,6 +9768,13 @@ __metadata: languageName: node linkType: hard +"expand-template@npm:^2.0.3": + version: 2.0.3 + resolution: "expand-template@npm:2.0.3" + checksum: 10/588c19847216421ed92befb521767b7018dc88f88b0576df98cb242f20961425e96a92cbece525ef28cc5becceae5d544ae0f5b9b5e2aa05acb13716ca5b3099 + languageName: node + linkType: hard + "expand-tilde@npm:^2.0.0, expand-tilde@npm:^2.0.2": version: 2.0.2 resolution: "expand-tilde@npm:2.0.2" @@ -9937,6 +10035,13 @@ __metadata: languageName: node linkType: hard +"file-uri-to-path@npm:1.0.0": + version: 1.0.0 + resolution: "file-uri-to-path@npm:1.0.0" + checksum: 10/b648580bdd893a008c92c7ecc96c3ee57a5e7b6c4c18a9a09b44fb5d36d79146f8e442578bc0e173dc027adf3987e254ba1dfd6e3ec998b7c282873010502144 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -10098,6 +10203,13 @@ __metadata: languageName: node linkType: hard +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 10/18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d + languageName: node + linkType: hard + "fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -10250,6 +10362,13 @@ __metadata: languageName: node linkType: hard +"github-from-package@npm:0.0.0": + version: 0.0.0 + resolution: "github-from-package@npm:0.0.0" + checksum: 10/2a091ba07fbce22205642543b4ea8aaf068397e1433c00ae0f9de36a3607baf5bcc14da97fbb798cfca6393b3c402031fca06d8b491a44206d6efef391c58537 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -10628,7 +10747,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.2.1": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10/d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4 @@ -10709,7 +10828,7 @@ __metadata: languageName: node linkType: hard -"ini@npm:^1.3.4": +"ini@npm:^1.3.4, ini@npm:~1.3.0": version: 1.3.8 resolution: "ini@npm:1.3.8" checksum: 10/314ae176e8d4deb3def56106da8002b462221c174ddb7ce0c49ee72c8cd1f9044f7b10cc555a7d8850982c3b9ca96fc212122749f5234bc2b6fb05fb942ed566 @@ -12208,6 +12327,13 @@ __metadata: languageName: node linkType: hard +"mimic-response@npm:^3.1.0": + version: 3.1.0 + resolution: "mimic-response@npm:3.1.0" + checksum: 10/7e719047612411fe071332a7498cf0448bbe43c485c0d780046c76633a771b223ff49bd00267be122cedebb897037fdb527df72335d0d0f74724604ca70b37ad + languageName: node + linkType: hard + "min-indent@npm:^1.0.0": version: 1.0.1 resolution: "min-indent@npm:1.0.1" @@ -12267,7 +12393,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.5": +"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f @@ -12374,6 +12500,13 @@ __metadata: languageName: node linkType: hard +"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": + version: 0.5.3 + resolution: "mkdirp-classic@npm:0.5.3" + checksum: 10/3f4e088208270bbcc148d53b73e9a5bd9eef05ad2cbf3b3d0ff8795278d50dd1d11a8ef1875ff5aea3fa888931f95bfcb2ad5b7c1061cfefd6284d199e6776ac + languageName: node + linkType: hard + "mkdirp@npm:^1.0.3": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" @@ -12449,6 +12582,13 @@ __metadata: languageName: node linkType: hard +"napi-build-utils@npm:^2.0.0": + version: 2.0.0 + resolution: "napi-build-utils@npm:2.0.0" + checksum: 10/69adcdb828481737f1ec64440286013f6479d5b264e24d5439ba795f65293d0bb6d962035de07c65fae525ed7d2fcd0baab6891d8e3734ea792fec43918acf83 + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -12481,6 +12621,15 @@ __metadata: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.89.0 + resolution: "node-abi@npm:3.89.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10/8fc84f775475b81256cf208c0ed79fe53eca6e5f5e1fe8c263f63f0250b9454b9f3a144f389bf4dbda258f5f916ee0bbc5bb0ebf0f13de78fd80390dac0227b7 + languageName: node + linkType: hard + "node-addon-api@npm:^2.0.0": version: 2.0.2 resolution: "node-addon-api@npm:2.0.2" @@ -12702,7 +12851,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -13124,6 +13273,28 @@ __metadata: languageName: node linkType: hard +"prebuild-install@npm:^7.1.1": + version: 7.1.3 + resolution: "prebuild-install@npm:7.1.3" + dependencies: + detect-libc: "npm:^2.0.0" + expand-template: "npm:^2.0.3" + github-from-package: "npm:0.0.0" + minimist: "npm:^1.2.3" + mkdirp-classic: "npm:^0.5.3" + napi-build-utils: "npm:^2.0.0" + node-abi: "npm:^3.3.0" + pump: "npm:^3.0.0" + rc: "npm:^1.2.7" + simple-get: "npm:^4.0.0" + tar-fs: "npm:^2.0.0" + tunnel-agent: "npm:^0.6.0" + bin: + prebuild-install: bin.js + checksum: 10/1b7e4c00d2750b532a4fc2a83ffb0c5fefa1b6f2ad071896ead15eeadc3255f5babd816949991af083cf7429e375ae8c7d1c51f73658559da36f948a020a3a11 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -13278,6 +13449,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.4 + resolution: "pump@npm:3.0.4" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10/d043c3e710c56ffd280711e98a94e863ab334f79ea43cee0fb70e1349b2355ffd2ff287c7522e4c960a247699d5b7825f00fa090b85d6179c973be13f78a6c49 + languageName: node + linkType: hard + "punycode@npm:2.1.0": version: 2.1.0 resolution: "punycode@npm:2.1.0" @@ -13364,6 +13545,20 @@ __metadata: languageName: node linkType: hard +"rc@npm:^1.2.7": + version: 1.2.8 + resolution: "rc@npm:1.2.8" + dependencies: + deep-extend: "npm:^0.6.0" + ini: "npm:~1.3.0" + minimist: "npm:^1.2.0" + strip-json-comments: "npm:~2.0.1" + bin: + rc: ./cli.js + checksum: 10/5c4d72ae7eec44357171585938c85ce066da8ca79146b5635baf3d55d74584c92575fa4e2c9eac03efbed3b46a0b2e7c30634c012b4b4fa40d654353d3c163eb + languageName: node + linkType: hard + "react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -13401,7 +13596,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": +"readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -13950,6 +14145,24 @@ __metadata: languageName: node linkType: hard +"simple-concat@npm:^1.0.0": + version: 1.0.1 + resolution: "simple-concat@npm:1.0.1" + checksum: 10/4d211042cc3d73a718c21ac6c4e7d7a0363e184be6a5ad25c8a1502e49df6d0a0253979e3d50dbdd3f60ef6c6c58d756b5d66ac1e05cda9cacd2e9fc59e3876a + languageName: node + linkType: hard + +"simple-get@npm:^4.0.0": + version: 4.0.1 + resolution: "simple-get@npm:4.0.1" + dependencies: + decompress-response: "npm:^6.0.0" + once: "npm:^1.3.1" + simple-concat: "npm:^1.0.0" + checksum: 10/93f1b32319782f78f2f2234e9ce34891b7ab6b990d19d8afefaa44423f5235ce2676aae42d6743fecac6c8dfff4b808d4c24fe5265be813d04769917a9a44f36 + languageName: node + linkType: hard + "simple-git-hooks@npm:^2.8.0": version: 2.11.1 resolution: "simple-git-hooks@npm:2.11.1" @@ -14272,6 +14485,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:~2.0.1": + version: 2.0.1 + resolution: "strip-json-comments@npm:2.0.1" + checksum: 10/1074ccb63270d32ca28edfb0a281c96b94dc679077828135141f27d52a5a398ef5e78bcf22809d23cadc2b81dfbe345eb5fd8699b385c8b1128907dec4a7d1e1 + languageName: node + linkType: hard + "strnum@npm:^2.2.2": version: 2.2.2 resolution: "strnum@npm:2.2.2" @@ -14338,6 +14558,31 @@ __metadata: languageName: node linkType: hard +"tar-fs@npm:^2.0.0": + version: 2.1.4 + resolution: "tar-fs@npm:2.1.4" + dependencies: + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.1.4" + checksum: 10/bdf7e3cb039522e39c6dae3084b1bca8d7bcc1de1906eae4a1caea6a2250d22d26dcc234118bf879b345d91ebf250a744b196e379334a4abcbb109a78db7d3be + languageName: node + linkType: hard + +"tar-stream@npm:^2.1.4": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: "npm:^4.0.3" + end-of-stream: "npm:^1.4.1" + fs-constants: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.1.1" + checksum: 10/1a52a51d240c118cbcd30f7368ea5e5baef1eac3e6b793fb1a41e6cd7319296c79c0264ccc5859f5294aa80f8f00b9239d519e627b9aade80038de6f966fec6a + languageName: node + linkType: hard + "tar-stream@npm:^3.1.7": version: 3.1.7 resolution: "tar-stream@npm:3.1.7" @@ -14568,6 +14813,15 @@ __metadata: languageName: node linkType: hard +"tunnel-agent@npm:^0.6.0": + version: 0.6.0 + resolution: "tunnel-agent@npm:0.6.0" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10/7f0d9ed5c22404072b2ae8edc45c071772affd2ed14a74f03b4e71b4dd1a14c3714d85aed64abcaaee5fec2efc79002ba81155c708f4df65821b444abb0cfade + languageName: node + linkType: hard + "tweetnacl@npm:^1.0.3": version: 1.0.3 resolution: "tweetnacl@npm:1.0.3"