Skip to content

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 that calternal-fs validated; nothing is concatenated from request text, and calternal-fs resolves beneath the data directory with openat2 RESOLVE_BENEATH (CLAUDE.md rule 4).
  • The index is physically per User (#435): a UserMoney handle 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_if succeeds 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

pub struct Budget

A consistent view of one budget folder.

Fields

  • pub folder: String: Folder name under Money/.
  • pub budget: ParsedFile: Budget.md.
  • pub accounts: ParsedFile: Accounts.md.
  • pub months: BTreeMap<String: Month files keyed by month YYYY-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.
pub fn ledger(&self) -> Result<&Ledger>

The ledger, or the Money error that stops posting.

pub fn title(&self) -> String

The display title: the first # heading of Budget.md, or the folder.

pub fn budget_document(&self) -> Result<&Document>

The parsed Budget.md.

pub fn accounts_document(&self) -> Result<&Document>

The parsed Accounts.md.

Source: crates/plugins/money/src/store.rs:133

pub struct MoneyIndex

The process-wide Money index: a bounded LRU with active per-User handles pinned.

Implements: Default

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

pub struct ParsedFile

One 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

pub struct PendingMove

A 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 under Money/.
  • 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

pub struct UserMoney

One 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).
pub fn revision(&self) -> u64

Current sequence for durable event IDs and source generations under the Money lock (#984).

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.

pub fn end_move(&self) -> Result<()>

Remove the move journal after the move finished or was undone.

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.

pub fn folders(&self) -> Result<Vec<String>>

Budget folder names under Money/ (directories only), sorted.

pub fn load(&self, folder: &str) -> Result<Budget>

Load and hash one source set, with serialized observations and a stable generation (#984; DESIGN §48).

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.

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.

pub async fn create(&self, folder: &str, name: &str, text: &str) -> Result<()>

Create one new file in a budget folder; fails if it exists.

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

pub enum StoreError

Why 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

pub type Result<T> = std::result::Result<T, StoreError>;

Result alias for the store.

Source: crates/plugins/money/src/store.rs:117

pub const MAX_BUDGETS: usize

Most budget folders listed for one User.

Source: crates/plugins/money/src/store.rs:59

pub const MAX_CACHED_USERS: usize

Idle-handle cache bound; active callers keep their shared write-lock handle.

Source: crates/plugins/money/src/store.rs:66

pub const MAX_FILE_BYTES: u64

Largest Money file the plugin reads (a decade of busy months is far below).

Source: crates/plugins/money/src/store.rs:55

pub const MAX_MONTH_FILES: usize

Most month files one budget may hold (100 years).

Source: crates/plugins/money/src/store.rs:57

pub const MONEY_FOLDER: &str

The folder under a Home that holds budgets.

Source: crates/plugins/money/src/store.rs:53