Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/lint-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
```

<!-- end dependency graph -->
Expand Down
3 changes: 3 additions & 0 deletions packages/wallet-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
18 changes: 18 additions & 0 deletions packages/wallet-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
13 changes: 10 additions & 3 deletions packages/wallet-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -60,6 +67,6 @@
"topicSeparator": " "
},
"engines": {
"node": "^18.18 || >=20"
"node": ">=20"
}
}
28 changes: 28 additions & 0 deletions packages/wallet-cli/scripts/install-binaries.sh
Original file line number Diff line number Diff line change
@@ -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
117 changes: 117 additions & 0 deletions packages/wallet-cli/src/persistence/KeyValueStore.test.ts
Original file line number Diff line number Diff line change
@@ -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'",
);
});
});
});
73 changes: 73 additions & 0 deletions packages/wallet-cli/src/persistence/KeyValueStore.ts
Original file line number Diff line number Diff line change
@@ -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<string, Json> {
const rows = this.#getAllStmt.all();
const result: Record<string, Json> = {};
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();
}
}
Loading
Loading