calternal_plugin_money::store
Money files in a User’s Home and their derived, per-User index.
Layout (DESIGN §48 F2): one folder per budget, Money/<Budget>/, with
Budget.md, Accounts.md and one YYYY-MM.md per month. The Markdown
files are the source of truth. Everything else is derived and rebuildable.
Security invariants:
- Every path is built with
RelPath::user_home(user).join(..)from a folder name thatcalternal-fsvalidated; nothing is concatenated from request text, andcalternal-fsresolves beneath the data directory withopenat2RESOLVE_BENEATH(CLAUDE.md rule 4). - The index is physically per User (#435): a
UserMoneyhandle is the only way to reach cached budgets, and it can only be obtained with the User ID from an authenticated request (MoneyIndex::for_user). No function takes another User’s ID for a read. - Writes are compare-and-swap:
replace_ifsucceeds only when the file still has the bytes the edit was made from, so an edit that raced a sync client or another tab is refused (409), never merged blindly.
Cross-month moves (#462): a date change that moves a row to another month
is two file writes. UserMoney::begin_move records the move in a small
journal under .calternal/ first. If the process stops, or the source
write and the undo both fail, the next request settles the journal before
it reads or writes (UserMoney::settle_pending_move), so a row is never
counted in two month files.
Performance (DESIGN §48 F5): parsed files are cached by their kernel fingerprint (inode, size, mtime). A request re-stats the folder and re-parses only changed files, so a Money screen needs no loading state. Cache hits share immutable documents. The parse cache charges each source file’s retained parse estimate to a 16 MiB per-User cap and a 128 MiB process cap (#807); eviction drops parse data only and never replaces an active User writer handle.
Month responses carry a generation of the loaded source hashes (#984). The complete, ordered source set includes Budget.md, Accounts.md and all month names and hashes. Changed content, added files and deleted files advance the generation, including writes through Files or WebDAV. Equal bytes keep it stable. Generations start above normal pre-restart browser reports using Unix microseconds; durable API writes also advance the event sequence.
Source: crates/plugins/money/src/store.rs
Structs
Section titled “Structs”Budget
Section titled “Budget”pub struct BudgetA consistent view of one budget folder.
Fields
pub folder: String: Folder name underMoney/.pub budget: ParsedFile:Budget.md.pub accounts: ParsedFile:Accounts.md.pub months: BTreeMap<String: Month files keyed by monthYYYY-MM.pub revision: u64: Generation of these exact source bytes, not a later read’s sequence (#984).pub ledger: std::result::Result<Ledger: The projected ledger, or why the files cannot be posted.
Budget::ledger
Section titled “Budget::ledger”pub fn ledger(&self) -> Result<&Ledger>The ledger, or the Money error that stops posting.
Budget::title
Section titled “Budget::title”pub fn title(&self) -> StringThe display title: the first # heading of Budget.md, or the folder.
Budget::budget_document
Section titled “Budget::budget_document”pub fn budget_document(&self) -> Result<&Document>The parsed Budget.md.
Budget::accounts_document
Section titled “Budget::accounts_document”pub fn accounts_document(&self) -> Result<&Document>The parsed Accounts.md.
Source: crates/plugins/money/src/store.rs:133
MoneyIndex
Section titled “MoneyIndex”pub struct MoneyIndexThe process-wide Money index: a bounded LRU with active per-User handles pinned.
Implements: Default
MoneyIndex::for_user
Section titled “MoneyIndex::for_user”pub fn for_user(&self, root: &Root, user: &str) -> Result<Arc<UserMoney>>The handle for the authenticated User user (from
PluginRequestContext::data_user, never from request data).
Source: crates/plugins/money/src/store.rs:400
ParsedFile
Section titled “ParsedFile”pub struct ParsedFileOne parsed file with the identity of the bytes it came from.
Fields
pub name: String: File name inside the budget folder.pub fingerprint: FileFingerprint: Kernel identity when it was read.pub hash: String: BLAKE3 of the bytes, for compare-and-swap writes.pub document: std::result::Result<Arc<Document>: The lossless document, or the parse error.
Implements: Clone
Source: crates/plugins/money/src/store.rs:121
PendingMove
Section titled “PendingMove”pub struct PendingMoveA cross-month move in progress (#462), stored as the move journal.
Invariant: the journal exists from before the destination write until after the source write (or the undo). Before the move, the destination had no row with this ID (the move validates the new file first).
Fields
pub folder: String: Budget folder name underMoney/.pub transaction: String: The moved transaction’s block ID.pub source: String: The month (YYYY-MM) the row leaves.pub destination: String: The month (YYYY-MM) the row goes to.
Implements: Serialize, Deserialize
Source: crates/plugins/money/src/store.rs:508
UserMoney
Section titled “UserMoney”pub struct UserMoneyOne User’s Money files and their parse cache. See the module docs.
Fields
pub lock: tokio::sync::RwLock<()>: This User’s Money lock inside the process. Writes hold it exclusively and serialize; reads hold it shared, so a read never sees half of a two-file write such as a cross-month move (#462).
UserMoney::revision
Section titled “UserMoney::revision”pub fn revision(&self) -> u64Current sequence for durable event IDs and source generations under the Money lock (#984).
UserMoney::begin_move
Section titled “UserMoney::begin_move”pub async fn begin_move(&self, pending: &PendingMove) -> Result<()>Record pending before the first write of a cross-month move.
The journal is created, never replaced: an unsettled journal makes a new move fail with a conflict instead of losing the old record.
UserMoney::end_move
Section titled “UserMoney::end_move”pub fn end_move(&self) -> Result<()>Remove the move journal after the move finished or was undone.
UserMoney::settle_pending_move
Section titled “UserMoney::settle_pending_move”pub async fn settle_pending_move(&self)Settle a move that a failure or a stopped process left half done.
Every request calls this before it reads or writes. It costs one atomic load unless a journal may exist. A row in both month files is removed from the destination: the move did not report success, so the source row stays. A row only in the destination (the source write finished) or only in the source (the destination write never happened) needs nothing. When a file cannot be read, the journal stays and the next request tries again.
UserMoney::folders
Section titled “UserMoney::folders”pub fn folders(&self) -> Result<Vec<String>>Budget folder names under Money/ (directories only), sorted.
UserMoney::load
Section titled “UserMoney::load”pub fn load(&self, folder: &str) -> Result<Budget>Load and hash one source set, with serialized observations and a stable generation (#984; DESIGN §48).
UserMoney::find
Section titled “UserMoney::find”pub fn find(&self, budget_id: &str) -> Result<Budget>Find the folder whose Budget.md has budget-id: <id>.
Two folders with one ID are a conflict: writing to either would be a guess, so both are refused until the User renames one.
UserMoney::replace
Section titled “UserMoney::replace”pub async fn replace( &self, folder: &str, name: &str, expected_hash: &str, text: &str, ) -> Result<()>Replace one file of a budget if it still has expected_hash.
UserMoney::create
Section titled “UserMoney::create”pub async fn create(&self, folder: &str, name: &str, text: &str) -> Result<()>Create one new file in a budget folder; fails if it exists.
UserMoney::create_folder
Section titled “UserMoney::create_folder”pub fn create_folder(&self, name: &str) -> Result<String>Create Money/<folder>/ for a new budget. The name follows the Files
naming policy (NFC, no separators, no case-folded sibling clash).
Source: crates/plugins/money/src/store.rs:470
StoreError
Section titled “StoreError”pub enum StoreErrorWhy a store operation failed.
Variants
NotFound: No such budget, file or item.Conflict(&'static str): The file changed since it was read, or a name is taken.Money(MoneyError): The request or a file breaks a Money rule.BadName(&'static str): A name from the request is not a valid file name.TooLarge(&'static str): A file is too large or there are too many files.Fs(calternal_fs::Error): The data directory failed.
Implements: Debug, From<calternal_fs::Error>, From<MoneyError>
Source: crates/plugins/money/src/store.rs:76
Type aliases
Section titled “Type aliases”Result
Section titled “Result”pub type Result<T> = std::result::Result<T, StoreError>;Result alias for the store.
Source: crates/plugins/money/src/store.rs:117
Constants
Section titled “Constants”MAX_BUDGETS
Section titled “MAX_BUDGETS”pub const MAX_BUDGETS: usizeMost budget folders listed for one User.
Source: crates/plugins/money/src/store.rs:59
MAX_CACHED_USERS
Section titled “MAX_CACHED_USERS”pub const MAX_CACHED_USERS: usizeIdle-handle cache bound; active callers keep their shared write-lock handle.
Source: crates/plugins/money/src/store.rs:66
MAX_FILE_BYTES
Section titled “MAX_FILE_BYTES”pub const MAX_FILE_BYTES: u64Largest Money file the plugin reads (a decade of busy months is far below).
Source: crates/plugins/money/src/store.rs:55
MAX_MONTH_FILES
Section titled “MAX_MONTH_FILES”pub const MAX_MONTH_FILES: usizeMost month files one budget may hold (100 years).
Source: crates/plugins/money/src/store.rs:57
MONEY_FOLDER
Section titled “MONEY_FOLDER”pub const MONEY_FOLDER: &strThe folder under a Home that holds budgets.