-
-
Notifications
You must be signed in to change notification settings - Fork 287
feat(wallet-cli): add SQLite-backed controller-state persistence #9067
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sirtimid
wants to merge
4
commits into
main
Choose a base branch
from
sirtimid/wallet-cli-persistence
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
4943f60
feat(wallet-cli): add SQLite-backed controller-state persistence
sirtimid 95177b0
docs(wallet-cli): link PR #9067 in persistence changelog entry
sirtimid fb00cf0
fix(wallet-cli): fall back to source build when no better-sqlite3 pre…
sirtimid b9854de
fix(wallet-cli): rehydrate only persist-flagged state in loadState
sirtimid File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
117
packages/wallet-cli/src/persistence/KeyValueStore.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'", | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of this we can just install
better-sqlite3into the root package and enable it to run the postinstall script there.A better alternative would be to find a package that doesn't need postinstall scripts, though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Two parts to this:
On switching to a package without postinstall scripts: we're staying on
better-sqlite3on purpose. Theocap-kernel, thatwallet-cliwill host, already uses it, so picking a different engine here would mean two SQLite implementations in one process.On installing it at the root + allow-scripts: fair point, that's the idiomatic mechanism (
secp256k1already goes through it). The reason I kept it as a scopedtest:preparestep is to avoid runningbetter-sqlite3native build on every monorepoyarn install. Also v12 only ships prebuilds for Node 20+, so contributors on Node 18 (still supported repo-wide) would fall back to a source compile, and anallow-scriptsfailure exits the whole install, for packages that have nothing to do withwallet-cli.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Once we can raise the nodejs floor to >= 22.5 and once it's not anymore experimental we can switch to
node:sqlitebut we researched all the alternatives at theocap-kernelteam and concluded thatbetter-sqlite3is better, for now.