Skip to content

feat(wallet): Add split persistence backed by better-sqlite3#8480

Open
rekmarks wants to merge 18 commits intofeat/wallet-libraryfrom
rekm/wallet-sqlite
Open

feat(wallet): Add split persistence backed by better-sqlite3#8480
rekmarks wants to merge 18 commits intofeat/wallet-libraryfrom
rekm/wallet-sqlite

Conversation

@rekmarks
Copy link
Copy Markdown
Member

@rekmarks rekmarks commented Apr 16, 2026

Summary

  • Add synchronous, per-property SQLite persistence to the wallet package using better-sqlite3
  • Each controller state property with persist: true metadata gets its own row in a kv table (key format: ControllerName.propertyName)
  • Writes happen synchronously within the same call stack as controller.update() via stateChanged event subscriptions, eliminating data loss windows
  • Defaults to :memory: when no database path is provided

Test plan

  • yarn workspace @metamask/wallet exec jest --no-coverage --watchman=false src/persistence/ — 22 unit tests covering KeyValueStore CRUD, loadState grouping, persist filtering, StateDeriver application, patch-based diffing, unsubscribe, and multi-controller scenarios
  • yarn workspace @metamask/wallet exec tsc --noEmit — no new type errors
  • Integration test with file-backed DB: create Wallet with a file path, perform operations, create second Wallet from same path, verify state restoration

🤖 Generated with Claude Code


Note

Medium Risk
Adds a native better-sqlite3 dependency plus new persistence wiring based on controller metadata and state patches, which can affect state durability and requires platform-specific binaries in CI/dev environments.

Overview
Adds a new @metamask/wallet/persistence entrypoint that provides a synchronous SQLite KeyValueStore plus loadState/subscribeToChanges utilities to persist only controller state properties marked persist in metadata, keyed as ControllerName.property and driven by stateChanged Immer patches.

Updates Wallet construction/typing to accept optional initial state, expose controllerMetadata, and publish a new Root:walletDestroyed event; destroy() is now idempotent and uses Promise.allSettled so teardown continues even if a controller destroy fails.

Build/test plumbing is adjusted to include better-sqlite3 (and types), allow its install script via LavaMoat, and extend the wallet test prepare script to install Anvil and fetch the native better-sqlite3 prebuild; docs add troubleshooting steps for rebuilding the addon.

Reviewed by Cursor Bugbot for commit bd9c504. Bugbot is set up for automated code reviews on this repo. Configure here.

@socket-security
Copy link
Copy Markdown

socket-security bot commented Apr 16, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​better-sqlite3@​7.6.131001007180100
Addedbetter-sqlite3@​12.9.010010010092100

View full report

@socket-security

This comment was marked as resolved.

@rekmarks
Copy link
Copy Markdown
Member Author

@SocketSecurity ignore npm/better-sqlite3@11.10.0

Yes, it has native binaries.

@rekmarks
Copy link
Copy Markdown
Member Author

rekmarks commented Apr 16, 2026

@SocketSecurity ignore-all

All alerts are due to better-sqlite3 or its transitives. It is a reputable package that we use elsewhere.

@rekmarks rekmarks force-pushed the rekm/wallet-sqlite branch 6 times, most recently from 8f58b8c to f66328d Compare April 16, 2026 20:30
@rekmarks rekmarks force-pushed the feat/wallet-library branch 2 times, most recently from 623f9af to 3fb60ee Compare April 16, 2026 21:13
@rekmarks rekmarks force-pushed the rekm/wallet-sqlite branch 4 times, most recently from 1d6d1fc to 937ba80 Compare April 16, 2026 21:52
@rekmarks rekmarks marked this pull request as ready for review April 16, 2026 21:59
@rekmarks rekmarks requested a review from a team as a code owner April 16, 2026 21:59
@rekmarks rekmarks force-pushed the rekm/wallet-sqlite branch from 937ba80 to 29c6ac6 Compare April 16, 2026 21:59
Comment thread packages/wallet/src/persistence/persistence.ts
Comment thread packages/wallet/src/Wallet.ts Outdated
Comment thread packages/wallet/src/Wallet.ts Outdated

readonly #unsubscribePersistence: () => void;

constructor({ options, databasePath = ':memory:' }: WalletConstructorArgs) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Wallet is to be used in all clients it should be agnostic to persistence. I think these changes should target the CLI branch and be part of the CLI class.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we can come up with an interface where each client can pass in their own persistence, but it may be simpler to not deal with it at all in Wallet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made some benign modifications to the Wallet interface and made it agnostic of persistence. I kept the persistence files in the wallet package for now to avoid creating a complicated PR stack (or bloating the CLI PR). We can move the persistence stuff out of the wallet package once this is merged.

Comment on lines +102 to +103
const key = `${controllerName}.${prop}`;
const persistFlag = metadata[prop]?.persist;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use deriveStateFromMetadata

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're similar but I don't think it's a good fit. deriveStateFromMetadata iterates over every top-level state property and errors on missing properties. The subscribeToChanges handlers iterate over changed properties and deletes missing properties from persistence, which shouldn't happen in practice but that is not for the persistence layer to know. Then there's also the complex error handling logic in deriveStateFromMetadata which doesn't buy us anything here.

rekmarks and others added 4 commits April 17, 2026 11:50
Persist controller state to SQLite using a per-property key-value
scheme (one row per ControllerName.propertyName). Writes are
synchronous within the same call stack as controller state updates,
using stateChanged events and Immer patches to write only changed,
persist-flagged properties. Defaults to ':memory:' when no database
path is provided.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The build tsconfig is stricter than the dev tsconfig — the union type
of controller instances does not expose the protected `destroy` method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
rekmarks and others added 8 commits April 17, 2026 11:50
Address review findings: add constructor cleanup on failure, make
destroy() idempotent with Promise.allSettled and finally, handle
remove patches via store.delete(), catch and log handler write
failures, validate degenerate store keys, handle root state
replacement patches, add contextful JSON.parse errors, tighten
loadState return type, extract PersistableController type and
storeKey utility, remove premature KeyValueStore public export,
and add tests for new behaviors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rekmarks rekmarks force-pushed the rekm/wallet-sqlite branch from 520a971 to cfad387 Compare April 17, 2026 18:50
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cfad387. Configure here.

Comment thread packages/wallet/src/Wallet.ts Outdated
Make `@metamask/wallet` platform-agnostic by removing the SQLite-backed
`KeyValueStore` from the `Wallet` constructor. `Wallet` now accepts a
flat `WalletOptions` (including an optional `state` payload) and owns no
persistence state. Consumers arrange persistence themselves.

- `Wallet` exposes a `controllerMetadata` getter so persistence can
  operate on metadata alone, without reaching into controller instances.
- A new `Root:walletDestroyed` event is published after controllers tear
  down during `destroy()`. `subscribeToChanges` listens for it and
  self-unsubscribes, eliminating the need for callers to hold an
  unsubscribe handle.
- `subscribeToChanges` now takes a `Record<string, StateMetadataConstraint>`
  instead of the controller-instance map, and specializes its messenger
  type to `RootMessenger<DefaultActions, DefaultEvents>` so subscribing
  to `Root:walletDestroyed` type-checks without suppression.
- `KeyValueStore`, `loadState`, and `subscribeToChanges` are exposed via
  a new `@metamask/wallet/persistence` subpath export; they'll move to
  their own package later.
So that @metamask/wallet/persistence can be moved to its own package
without needing to deep-import from the wallet package.
@rekmarks rekmarks force-pushed the rekm/wallet-sqlite branch from 06b91f1 to f4e8dfa Compare April 17, 2026 22:00
rekmarks and others added 3 commits April 17, 2026 15:07
Renames install-anvil.sh to install-binaries.sh and adds a
better-sqlite3 native addon rebuild step for CI environments where
the prebuilt binding is absent or incompatible with the Node version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Check for the compiled addon at
`node_modules/better-sqlite3/build/Release/better_sqlite3.node` and skip
`prebuild-install` when it's already present. Avoids unnecessary network
calls on every `test:prepare` run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants