diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index 7035a90e6e..e3381b335d 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -143,6 +143,11 @@ jobs: matrix: node-version: [18.x, 20.x, 22.x] package-name: ${{ fromJson(needs.prepare.outputs.child-workspace-package-names) }} + exclude: + # @metamask/wallet-cli depends on better-sqlite3, which only ships + # prebuilt binaries for Node 20+. + - node-version: 18.x + package-name: '@metamask/wallet-cli' steps: - name: Checkout and setup environment uses: MetaMask/action-checkout-and-setup@v2 diff --git a/README.md b/README.md index f4a317c926..a8508eaa98 100644 --- a/README.md +++ b/README.md @@ -596,6 +596,8 @@ linkStyle default opacity:0.5 wallet --> messenger; wallet --> remote_feature_flag_controller; wallet --> storage_service; + wallet_cli --> base_controller; + wallet_cli --> wallet; ``` diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index 455dbb1d4a..587b49c08f 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add SQLite-backed persistence for wallet controller state ([#9067](https://github.com/MetaMask/core/pull/9067)) + - A `KeyValueStore` backed by `better-sqlite3` for synchronous reads and writes. + - `loadState` to rehydrate persist-flagged controller state from the store and `subscribeToChanges` to write persist-flagged controller state through to disk on every `stateChanged` event. - Initial package scaffold for `@metamask/wallet-cli`, an [oclif](https://oclif.io)-based `mm` CLI for `@metamask/wallet` ([#9065](https://github.com/MetaMask/core/pull/9065)). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md index 1de99a32fa..ddb25c377d 100644 --- a/packages/wallet-cli/README.md +++ b/packages/wallet-cli/README.md @@ -10,6 +10,24 @@ or `npm install @metamask/wallet-cli` +## Troubleshooting + +### Rebuilding `better-sqlite3` + +This package depends on `better-sqlite3`, which ships a native C addon. The monorepo runs Yarn with `enableScripts: false`, so the addon is **not** fetched automatically during `yarn install`. Instead, the package's `test:prepare` script (`scripts/install-binaries.sh`) downloads the matching prebuild on demand the first time you run tests, falling back to compiling the addon from source (via `node-gyp`) when no prebuild is published for your Node ABI/platform. + +If you switch Node versions or branches and the binding is missing, re-run: + +```sh +yarn workspace @metamask/wallet-cli run test:prepare +``` + +Or invoke `prebuild-install` directly from the monorepo root (where `better-sqlite3` is hoisted): + +```sh +cd node_modules/better-sqlite3 && node ../.bin/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-cli/package.json b/packages/wallet-cli/package.json index a1a88bf3df..aa5d527127 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -36,17 +36,24 @@ "changelog:update": "../../scripts/update-changelog.sh @metamask/wallet-cli", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/wallet-cli", "since-latest-release": "../../scripts/since-latest-release.sh", - "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "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", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@oclif/core": "^4.10.5" + "@metamask/base-controller": "^9.1.0", + "@metamask/utils": "^11.11.0", + "@metamask/wallet": "^3.0.0", + "@oclif/core": "^4.10.5", + "better-sqlite3": "^12.9.0", + "immer": "^9.0.6" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@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", @@ -60,6 +67,6 @@ "topicSeparator": " " }, "engines": { - "node": "^18.18 || >=20" + "node": ">=20" } } diff --git a/packages/wallet-cli/scripts/install-binaries.sh b/packages/wallet-cli/scripts/install-binaries.sh new file mode 100755 index 0000000000..79a7f88bbf --- /dev/null +++ b/packages/wallet-cli/scripts/install-binaries.sh @@ -0,0 +1,28 @@ +#!/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}" + +# 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. Reproduce +# better-sqlite3's own install step (`prebuild-install || node-gyp rebuild +# --release`): fetch a matching prebuild for the active Node version and +# platform, and fall back to compiling from source when no prebuild is +# published for that ABI/libc combination (e.g. some Linux CI runners). +BETTER_SQLITE3_DIR="${MONOREPO_ROOT}/node_modules/better-sqlite3" +if [ ! -f "${BETTER_SQLITE3_DIR}/build/Release/better_sqlite3.node" ]; then + ( + cd "${BETTER_SQLITE3_DIR}" + if ! "${MONOREPO_ROOT}/node_modules/.bin/prebuild-install"; then + echo "wallet-cli: prebuild-install failed (see its output above); compiling better-sqlite3 from source. This needs a C/C++ toolchain and Python." >&2 + "${MONOREPO_ROOT}/node_modules/.bin/node-gyp" rebuild --release + fi + ) +fi diff --git a/packages/wallet-cli/src/persistence/KeyValueStore.test.ts b/packages/wallet-cli/src/persistence/KeyValueStore.test.ts new file mode 100644 index 0000000000..f7c3ad6445 --- /dev/null +++ b/packages/wallet-cli/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-cli/src/persistence/KeyValueStore.ts b/packages/wallet-cli/src/persistence/KeyValueStore.ts new file mode 100644 index 0000000000..b7b0d65459 --- /dev/null +++ b/packages/wallet-cli/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-cli/src/persistence/persistence.test.ts b/packages/wallet-cli/src/persistence/persistence.test.ts new file mode 100644 index 0000000000..7d26e5520c --- /dev/null +++ b/packages/wallet-cli/src/persistence/persistence.test.ts @@ -0,0 +1,606 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import type { Json } from '@metamask/utils'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '@metamask/wallet'; + +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]); + + const controllerMetadata = createControllerMetadata({ + ControllerA: [ + ['prop1', true], + ['prop2', true], + ], + ControllerB: [['prop1', true]], + }); + + expect(loadState(store, controllerMetadata)).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'); + + const controllerMetadata = createControllerMetadata({ + Controller: [['prop.with.dots', true]], + }); + + expect(loadState(store, controllerMetadata)).toStrictEqual({ + Controller: { 'prop.with.dots': 'value' }, + }); + }); + + it('rehydrates properties whose persist flag is a deriver function', () => { + store.set('TestController.derived', 'value'); + + const controllerMetadata = createControllerMetadata({ + TestController: [['derived', (value: never): Json => value]], + }); + + expect(loadState(store, controllerMetadata)).toStrictEqual({ + TestController: { derived: 'value' }, + }); + }); + + it('skips properties whose persist flag is disabled', () => { + store.set('TestController.kept', 'keepMe'); + store.set('TestController.dropped', 'staleValue'); + + const controllerMetadata = createControllerMetadata({ + TestController: [ + ['kept', true], + ['dropped', false], + ], + }); + + expect(loadState(store, controllerMetadata)).toStrictEqual({ + TestController: { kept: 'keepMe' }, + }); + }); + + it('skips properties absent from the controller metadata', () => { + store.set('TestController.kept', 'keepMe'); + store.set('TestController.removed', 'staleValue'); + + const controllerMetadata = createControllerMetadata({ + TestController: [['kept', true]], + }); + + expect(loadState(store, controllerMetadata)).toStrictEqual({ + TestController: { kept: 'keepMe' }, + }); + }); + + it('skips keys for controllers absent from the metadata', () => { + store.set('RemovedController.prop', 'staleValue'); + + expect(loadState(store, {})).toStrictEqual({}); + }); + + 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('logs and skips the write when a deriver result serializes to undefined', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['derived', (): Json => undefined as unknown as Json], + ]), + }); + + const log = jest.fn(); + subscribeToChanges(messenger, controllerMetadata, store, log); + + publishStateChanged(messenger, 'TestController', { + state: { derived: 'anything' }, + patches: [{ op: 'replace', path: ['derived'], value: 'anything' }], + }); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to persist state for TestController.derived', + ), + ); + expect(store.get('TestController.derived')).toBeUndefined(); + }); + + it('does not swallow errors thrown by a deriver function', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + [ + 'derived', + (): Json => { + throw new Error('deriver boom'); + }, + ], + ]), + }); + + const log = jest.fn(); + subscribeToChanges(messenger, controllerMetadata, store, log); + + expect(() => + publishStateChanged(messenger, 'TestController', { + state: { derived: 'value' }, + patches: [{ op: 'replace', path: ['derived'], value: 'value' }], + }), + ).toThrow('deriver boom'); + + expect(log).not.toHaveBeenCalled(); + }); + + 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('routes store.set failures through the supplied log callback', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([ + ['propA', true], + ['propB', true], + ]), + }); + + const log = jest.fn(); + subscribeToChanges(messenger, controllerMetadata, store, log); + + 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); + }); + + publishStateChanged(messenger, 'TestController', { + state: { propA: 'a', propB: 'b' }, + patches: [ + { op: 'replace', path: ['propA'], value: 'a' }, + { op: 'replace', path: ['propB'], value: 'b' }, + ], + }); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to persist state for TestController.propA', + ), + ); + // propB should still be persisted despite propA failing + expect(store.get('TestController.propB')).toBe('b'); + }); + + it('falls back to console.error when no log callback is supplied', () => { + const { messenger, controllerMetadata } = createMockControllers({ + TestController: createStateMetadata([['prop', true]]), + }); + + subscribeToChanges(messenger, controllerMetadata, store); + + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + const error = new Error('disk full'); + jest.spyOn(store, 'set').mockImplementationOnce(() => { + throw error; + }); + + publishStateChanged(messenger, 'TestController', { + state: { prop: 'value' }, + patches: [{ op: 'replace', path: ['prop'], value: 'value' }], + }); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to persist state for TestController.prop', + ), + ); + + 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('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, + }, + ]), + ); +} + +/** + * Builds a `controllerMetadata` map for `loadState` tests. + * + * @param controllers - Map of controller names to an array of + * [property name, persist value] pairs. + * @returns A `controllerMetadata` map keyed by controller name. + */ +function createControllerMetadata( + controllers: Record Json)][]>, +): Record { + return Object.fromEntries( + Object.entries(controllers).map(([name, properties]) => [ + name, + createStateMetadata(properties), + ]), + ); +} + +/** + * 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-cli/src/persistence/persistence.ts b/packages/wallet-cli/src/persistence/persistence.ts new file mode 100644 index 0000000000..ad9d3be64b --- /dev/null +++ b/packages/wallet-cli/src/persistence/persistence.ts @@ -0,0 +1,269 @@ +import type { StateMetadataConstraint } from '@metamask/base-controller'; +import { hasProperty } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '@metamask/wallet'; +import type { Patch } from 'immer'; + +import type { KeyValueStore } from './KeyValueStore'; + +/** + * Handler for a controller's `stateChanged` event: the new controller state and + * the Immer patches describing what changed. + */ +type StateChangedHandler = ( + state: Record, + patches: Patch[], +) => void; + +/** + * 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 } }`. + * + * Only properties that are currently persist-flagged in `controllerMetadata` + * are rehydrated. Rows for controllers or properties that no longer exist — or + * whose `persist` flag has since been disabled — are ignored. This keeps + * loading symmetric with {@link subscribeToChanges}, which only ever writes + * persist-flagged properties: without the filter, a migration that stops + * persisting a property would leave its stale row on disk to be resurrected + * into the `Wallet` constructor state on the next restart. + * + * @param store - The key-value store to read from. + * @param controllerMetadata - A map from controller name to its state metadata, + * used to filter out keys that are no longer persist-flagged. + * @returns A record of controller states, keyed by controller name, suitable + * for the `state` option of the `Wallet` constructor. + */ +export function loadState( + store: KeyValueStore, + controllerMetadata: Readonly< + Record> + >, +): 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 (!isPersisted(controllerMetadata[controllerName], propertyName)) { + continue; + } + + 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. + * + * @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. + * @param log - Optional logger for persistence-write failures. Defaults to + * `console.error` when omitted. A daemon host should supply its own logger, + * since a backgrounded daemon's stdio may be discarded. + * @returns A function that unsubscribes all persistence handlers. + */ +export function subscribeToChanges( + messenger: RootMessenger, + controllerMetadata: Readonly< + Record> + >, + store: KeyValueStore, + log?: (message: string) => void, +): () => void { + const unsubscribers: (() => void)[] = []; + const logFn = + log ?? + ((message: string): void => { + console.error(message); + }); + + for (const [controllerName, metadata] of Object.entries(controllerMetadata)) { + const persistedProperties = getPersistPropertyNames(metadata); + if (persistedProperties.size === 0) { + continue; + } + + const eventType = `${controllerName}:stateChanged`; + + const handler: StateChangedHandler = (state, patches) => { + const changed = getChangedProperties(patches, persistedProperties); + + for (const prop of changed) { + const key = storeKey(controllerName, prop); + const removed = !hasProperty(state, prop); + + // Derive the value before the try/catch so a throwing `StateDeriver` + // surfaces as its own error instead of a misreported write failure. + const persistFlag = metadata[prop]?.persist; + const value = + !removed && typeof persistFlag === 'function' + ? persistFlag(state[prop] as never) + : state[prop]; + + try { + if (removed) { + store.delete(key); + } else { + store.set(key, value); + } + } catch (error) { + // TODO: Surface persistence-write failures up the stack so callers + // can decide to halt rather than continue with diverging in-memory + // and on-disk state. For now, log and continue. + logFn(`Failed to persist state for ${key}: ${String(error)}`); + } + } + }; + + unsubscribers.push(subscribeToStateChanged(messenger, eventType, handler)); + } + + const unsubscribeAll = (): void => { + while (unsubscribers.length > 0) { + unsubscribers.pop()?.(); + } + }; + + return unsubscribeAll; +} + +/** + * Subscribe a handler to a controller's `stateChanged` event. + * + * The event name is built from a runtime controller name, so it widens to + * `string` and cannot be proven to be a literal member of the messenger's event + * union at compile time. This helper localizes that single unavoidable cast + * behind a typed {@link StateChangedHandler}, so the `(state, patches)` payload + * shape stays compile-checked at every call site instead of being erased by a + * statement-level `@ts-expect-error`. + * + * @param messenger - The root messenger to subscribe on. + * @param eventType - The `${controllerName}:stateChanged` event name. + * @param handler - The state-change handler to register. + * @returns A function that unsubscribes the handler. + */ +function subscribeToStateChanged( + messenger: RootMessenger, + eventType: string, + handler: StateChangedHandler, +): () => void { + const subscriber = messenger as unknown as { + subscribe: (eventType: string, handler: StateChangedHandler) => void; + unsubscribe: (eventType: string, handler: StateChangedHandler) => void; + }; + subscriber.subscribe(eventType, handler); + return () => { + subscriber.unsubscribe(eventType, handler); + }; +} + +/** + * Determine whether a property is currently persist-flagged. + * + * The `persist` flag is truthy when it is `true` or a `StateDeriver` function, + * and falsy when it is `false` or when the controller or property is absent + * from the metadata. `loadState` and `subscribeToChanges` share this predicate + * so the read and write paths can never disagree on what counts as persisted. + * + * @param metadata - The controller's state metadata, or `undefined` when the + * controller is absent from the metadata map. + * @param propertyName - The property name to check. + * @returns `true` if the property should be persisted. + */ +function isPersisted( + metadata: Readonly | undefined, + propertyName: string, +): boolean { + return Boolean(metadata?.[propertyName]?.persist); +} + +/** + * 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 (isPersisted(metadata, key)) { + 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-cli/tsconfig.build.json b/packages/wallet-cli/tsconfig.build.json index 02a0eea03f..9c2e2b623e 100644 --- a/packages/wallet-cli/tsconfig.build.json +++ b/packages/wallet-cli/tsconfig.build.json @@ -5,6 +5,9 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../wallet/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json index 6b19ba8bbc..f648b01038 100644 --- a/packages/wallet-cli/tsconfig.json +++ b/packages/wallet-cli/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [ + { "path": "../base-controller/tsconfig.json" }, + { "path": "../wallet/tsconfig.json" } + ], "include": ["../../types", "./bin", "./src"] } diff --git a/yarn.config.cjs b/yarn.config.cjs index 4a79ab2903..07a883e90f 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -176,11 +176,16 @@ module.exports = defineConfig({ ); // All non-root packages must have the same "test" script. - expectWorkspaceField( - workspace, - 'scripts.test', - 'NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter', - ); + // @metamask/wallet-cli prepends a better-sqlite3 prebuild fetch to its + // "test" script because the native addon isn't built during + // `yarn install` (Yarn runs with `enableScripts: false`). + if (workspace.ident !== '@metamask/wallet-cli') { + expectWorkspaceField( + workspace, + 'scripts.test', + 'NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter', + ); + } // All non-root packages must have the same "test:clean" script. expectWorkspaceField( @@ -262,7 +267,14 @@ module.exports = defineConfig({ } // All packages must specify a minimum Node.js version of 18.18. - expectWorkspaceField(workspace, 'engines.node', '^18.18 || >=20'); + // @metamask/wallet-cli depends on `better-sqlite3`, which only ships + // prebuilt binaries for Node 20+; bumping its declared minimum keeps the + // engines field honest. + if (workspace.ident === '@metamask/wallet-cli') { + expectWorkspaceField(workspace, 'engines.node', '>=20'); + } else { + expectWorkspaceField(workspace, 'engines.node', '^18.18 || >=20'); + } // All non-root public packages should be published to the NPM registry; // all non-root private packages should not. diff --git a/yarn.lock b/yarn.lock index 0d2a2b7890..ad04761e4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8764,10 +8764,16 @@ __metadata: resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/utils": "npm:^11.11.0" + "@metamask/wallet": "npm:^3.0.0" "@oclif/core": "npm:^4.10.5" "@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" + immer: "npm:^9.0.6" jest: "npm:^29.7.0" ts-jest: "npm:^29.2.5" typescript: "npm:~5.3.3" @@ -8802,7 +8808,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/wallet@workspace:packages/wallet": +"@metamask/wallet@npm:^3.0.0, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: @@ -10729,6 +10735,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" @@ -12432,6 +12447,17 @@ __metadata: languageName: node linkType: hard +"better-sqlite3@npm:^12.9.0": + version: 12.10.0 + resolution: "better-sqlite3@npm:12.10.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10/99e213e78f15a7f40d5cb666b56781223a6b83ffc317c93846e2b63b694592978c1e3762e81afd6ce851e0e3c24ebc9d0c42341158ea93c8a39987e5e19602f8 + languageName: node + linkType: hard + "big.js@npm:^5.2.2": version: 5.2.2 resolution: "big.js@npm:5.2.2" @@ -12465,6 +12491,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" @@ -12476,6 +12511,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" @@ -12712,6 +12758,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" @@ -13016,6 +13072,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" @@ -14148,6 +14211,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" @@ -14473,6 +14543,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, enhanced-resolve@npm:^5.22.0": version: 5.22.0 resolution: "enhanced-resolve@npm:5.22.0" @@ -15378,6 +15457,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 + "expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" @@ -15691,6 +15777,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 + "filelist@npm:^1.0.4": version: 1.0.6 resolution: "filelist@npm:1.0.6" @@ -15910,6 +16003,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.0.0, fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -16079,6 +16179,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 + "github-slugger@npm:^1.5.0": version: 1.5.0 resolution: "github-slugger@npm:1.5.0" @@ -16861,7 +16968,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 @@ -19679,7 +19786,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, 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 @@ -19786,6 +19893,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" @@ -19860,6 +19974,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" @@ -19909,6 +20030,15 @@ __metadata: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.92.0 + resolution: "node-abi@npm:3.92.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10/b57a8eaa3e0f0531688b7f9c85ca0831e8b1195c9c331205f8a5ec3aa4e0a898671b85c8a0a0f4469ce550ce2cd32df1a4ccf437a7518bbff6459dc88f59d3a5 + languageName: node + linkType: hard + "node-addon-api@npm:^2.0.0": version: 2.0.2 resolution: "node-addon-api@npm:2.0.2" @@ -20195,7 +20325,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: @@ -21750,6 +21880,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" @@ -21965,6 +22117,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" @@ -22095,7 +22257,7 @@ __metadata: languageName: node linkType: hard -"rc@npm:1.2.8": +"rc@npm:1.2.8, rc@npm:^1.2.7": version: 1.2.8 resolution: "rc@npm:1.2.8" dependencies: @@ -22251,7 +22413,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.0.6, 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.0.6, 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: @@ -23243,6 +23405,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" @@ -23856,6 +24036,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" @@ -24198,6 +24403,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"