Daniel, 2026-09-28: the embedded stores of Milestone 17 - Embedded Stores — Turso, redb, RocksDB, HelixDB — were pulled in mainly to compare them as the store for Moonkale’s own state. Everything except secrets would live in one or two of them on the machine, and once the server works, sync between devices. Databases as sources stay extensions (LadybugDB included); that is a separate concern from this page. Decision record: ADR-0014 One store for internal state (proposed).

What exists today (surveyed 2026-09-28, checked 2026-10-01)

scopewhatwhereformatwritten by
per folderworkspace settings, extension grants<folder>/.moonkale/settings.jsonJSON (SettingsFile)Workspace::update_workspace_settings
per folder, per machinelayout, open documents, active document — in the state store since 2026-10-02<config dir>/state.sqlite (desktop, phone), localStorage (web)LayoutRecord, table layoutWorkspace::update_workspace_settings
per folder, on its hostentity log (every change: who, when, what) — in the state store since 2026-10-02the state.sqlite of the machine that hosts the folder: the desktop’s or phone’s own for local folders, the server’s for the web and for remote folders (Persistence::host)EventRecord, table events, one row per event, key folder · event id; compaction swaps rows for a snapshot eventWorkspace::record*, core::graph::history
per folder, on its hostagent sessions (local) — in the state store since 2026-10-02the folder host’s state.sqlite (Persistence::host)SavedSession, table agent_sessions, key folder · "local" · id, one row per session; .moonkale/agent-sessions/local/ imported onceeditors/agent
per folder, on the serveragent sessions (server) — in the state store since 2026-10-02the server’s state.sqlitetable agent_sessions, key folder · "server" · id: a head row, then one row per transcript item (· n); .moonkale/agent-sessions/*.jsonl imported onceapi/agent_sessions.rs
per foldersaved chats<folder>/.moonkale/chats/*.mdmarkdown pageseditors/agent
per folderdeleted files<folder>/.moonkale/trash/filesproject-fs
per folderKaTeX macros<folder>/.moonkale/katex.jsonJSONthe user
per folderwasm extensions<folder>/.moonkale/extensions/*.wasmmodulesthe user
per user, desktopuser settings, recent folders, saved agents, saved SSH connections — in the state store since 2026-10-02<config dir>/moonkale/state.sqlite, table settings, key "user" (settings.json imported once, then left alone)UserSettingsRecord (the SettingsFile JSON)Workspace::update_user_settings
per user, webuser settings — stays as it is: the item already is this browser’s store, and it is the browser suites’ handlethe browser’s localStorage["moonkale.settings"]JSONweb/src/main.rs
per user, Androiduser settings — in the state store since 2026-10-02<files dir>/state.sqlite (settings.json imported once)UserSettingsRecordWorkspace::update_user_settings
per userwasm extensions<config dir>/moonkale/extensions/modulesthe user
per usersecrets<config dir>/moonkale/secrets.json (mode 600) + environmentJSONSettings → secret
derivedthe index: graph, BM25, embeddingsmemory only, rebuilt on every open—moonkale-index

Problems with this, each seen in practice:

  • Nothing syncs. A second device starts from zero; the web client’s settings are per browser.
  • App state lands in git. This repository committed its own .moonkale/history.jsonl and settings.json until 2026-10-01 (now ignored); every session that opens it changes them (merge noise in the 2026-09-26 session-pause merge), and a cloned repository’s settings are trusted more than they should be (Audit 2026-09-23 #8, #20).
  • The entity log is a JSON-lines file with an O(n²) merge and compaction that hides old renames (P-086, issue #17).
  • The index is rebuilt on every open. On this repository that is seconds; on a large folder or with embeddings it is minutes, and embeddings cost money with a hosted model.
  • Three code paths for one thing (user settings on desktop, web and Android), each with its own bugs.

The store, as a requirement list

  1. Embedded, one file or directory per scope, native on desktop, server and Android; the browser keeps going through the server (or OPFS later).
  2. Holds: settings (user, project, folder scopes), layouts, open documents, saved agents and connections, agent sessions, the entity log, projects (Projects and Sources), annotations (Annotations), and a persisted index (graph, BM25 postings, embeddings with model + version).
  3. Never holds secrets. They stay in the secret store (keychain later).
  4. Syncs through the server hub (Collaboration): append-only history merges as sets of events; settings last-writer-wins per key with the loser kept; the index is never synced (derived).
  5. Keeps the folder clean: nothing under <folder>/.moonkale/ that changes on every open. What a folder deliberately shares with its collaborators (KaTeX macros, a folder’s own wasm modules, shared annotations) stays a plain file there; everything else moves to the user’s store, keyed by the folder’s id.
  6. Content-addressed identity possible: the Sophia and Unison use cases (Julia and Lenticulum, the code-graph compiler idea) want nodes keyed by a hash with many names → one node; the schema must not assume a node is a path.

The candidates (from Milestone 17)

storekindin the tree becausefor internal state
Turso (turso 0.7)SQLite rewritten in Rust (beta), in-processM17 sourceSQL and a known file format; good for settings, projects, sessions, the log as a table; beta
redb (redb 4)pure-Rust B-tree key/value, ACID, one fileM17 sourcesmallest dependency, no C; typed tables; you write the indices yourself
RocksDB (rocksdb 0.25)LSM key/value (C++)M17 sourcefastest writes, column families; heavy C++ build; bindgen needs libclang on every CI platform
HelixDB embedded (git)graph + vector over SlateDBM17 source, spec 002graph and vectors in one store fits the index; git dependency on an unpublished crate, writes a manifest even when opened read-only (P-145)
SQLite (rusqlite, bundled)the referencethe SQLite sourcewhat every other option is compared against

A likely shape, to be confirmed by the comparison: one relational/key-value store for the state (settings, projects, sessions, the log) and, separately, a persisted index that may use a graph/vector store. Two stores at most.

What the comparison has to measure

  • Cold-open and write latency for the log (10⁵–10⁶ events) and for settings writes (every layout change).
  • Build cost (clean build seconds, binary size) on Linux, Android, Windows and macOS — RocksDB and Helix bring C++ and a git tree.
  • Android: does it build for aarch64-linux-android, and how big is the APK afterwards?
  • Crash safety: kill the process mid-write, reopen.
  • Concurrency: two windows of one user (the session bus today), the server writing while a client reads.
  • Sync: how a merge of two devices’ logs is expressed (rows by event id; a key range scan).

Migration

Read the old files once, write the store, leave the files in place for one release, then stop reading them. Done that way for layouts (phase 5.7), agent sessions (phase 5.15), user settings on desktop and phone (phase 5.18) and the entity log (phase 5.10: .moonkale/history.jsonl is imported when the store has no rows for the folder, then never written). .moonkale/history.jsonl and .moonkale/settings.json in this repository get removed from git and ignored when the store lands.

Interface built (2026-10-02): packages/state (moonkale-state) — StateStore with get, scan(prefix), atomic write(Batch); versioned records; the tables above as moonkale_state::tables; a conformance suite that MemoryStore and the redb reference backend pass. Nothing uses it yet. The comparison is done: State Store Comparison — SQLite recommended, redb runner-up; decision pending (ADR-0014 One store for internal state).

Related: Version Management (the log’s model), ADR-0012 Two histories, Milestone 18 - Library Refactor (phase 5), Database Backends.