Everything below is implemented in src/store.rs, with the debounce and
flush wiring in src/autosave.rs (driven by handle(Command) in
src/app.rs). The rules are also stated as intents in
../PYRAMID-OF-INTENTS.md (I1.3, I3.2, I3.4).
WorkspaceStore::default_location() returns
dirs::data_dir()/chainnotes/workspace.json, which resolves to:
| Platform | Path |
|---|---|
| Linux | $XDG_DATA_HOME/chainnotes/workspace.json, defaulting to ~/.local/share/chainnotes/workspace.json |
| macOS | ~/Library/Application Support/chainnotes/workspace.json |
| Windows | %APPDATA%\chainnotes\workspace.json |
If dirs::data_dir() returns None — an unusual platform — the app stays
in memory and the status bar says so. It never guesses a path.
The store is the only module that touches this file (pyramid I3.4). If
you want to swap in a different backend, WorkspaceStore is the seam: it
has two methods, load() and save().
One JSON object, versioned:
{
"version": 1,
"notes": [
{
"id": 1,
"title": "Welcome",
"body": "chainnotes keeps notes and the links between them.",
"links": [],
"created_at": 1760505600,
"updated_at": 1760505600
},
{
"id": 2,
"title": "How chainnotes works",
"body": "Link notes together to form chains.",
"links": [1],
"created_at": 1760505600,
"updated_at": 1760505600
}
],
"selected": 1,
"next_id": 3,
"saved_at": 1760505600
}version—SCHEMA_VERSION(currently 1). Reserved for future migrations.notes— the full list;linksholds the outgoing target ids only. Backlinks are derived at load, never stored.selected— the id of the note that was open;nullif none.next_id— the id counter. On load,normalize()raises it past the maximum existing id if a hand-edited or migrated file set it too low, so a new note can never collide.saved_at— Unix seconds of the last save. This is the merge tiebreaker (see below).
load() returns Result<Option<WorkspaceRecord>, StoreError>:
fs::metadatathe file. Not found →Ok(None)— a first run, not an error. The app seeds the demo notes and marks them dirty, so the boot debounce (~250 ms) puts the file on disk; the status bar says "Saving…" until then, never "Saved to disk" for a file that does not exist.- If the file is larger than
MAX_WORKSPACE_BYTES(4 MiB) →Err(TooLarge). The cap is checked before reading, so a runaway file cannot exhaust memory. - Read it as UTF-8. An I/O error →
Err(Io). - An empty or whitespace-only file →
Ok(None). - Parse with
serde_json. A parse error →Err(Corrupt(detail)). normalize()the record: raisenext_idpast the max id, drop links that point at ids that no longer exist (including self-links), and fix aselectedthat points nowhere.
No step panics. Every failure is an enum variant with a user-facing sentence (see Failure sentences). On a load error the app starts from the demo in memory but is not marked dirty: the unreadable file stays on disk for the user to recover, and the status label says "Not saved yet" rather than claiming the disk matches memory.
save() writes atomically:
create_dir_allthe parent directory.serde_json::to_string_prettythe record.- Write the bytes to
workspace.json.tmpin the same directory, thenflush(). fs::renamethe temp file overworkspace.json.
Because the rename is atomic on POSIX and replaces the target on Windows, a crash mid-save leaves either the old file or the new one — never a half-written file. If any step fails, the previous file is untouched.
The UI does not write on every keystroke. A record mutation runs through
handle(Command) (see architecture.md),
which calls AutoSaver::touch() (src/autosave.rs): that sets a flag and
stamps last_edit. 250 ms later, with no further edits,
AutoSaver::maybe_flush() writes. A pending write is flushed, not
dropped, on both exit paths:
App::on_exit()callssaver.flush()— the normal close.- the Quit menu runs
handle(Command::Quit), which also flushes, before sendingViewportCommand::Close.
On load, the app merges the durable file against an in-memory boot snapshot:
let boot = WorkspaceRecord::default(); // saved_at = 0
let merged = store::merge(&boot, &durable);merge returns the durable copy unless durable.saved_at < boot.saved_at — i.e. the on-disk file wins unless it is strictly older
than what is already in memory. On a single-file store the boot side is
the empty default (saved_at = 0), so the file always wins; the rule is
applied rather than assumed, and it is the same rule a second source (say,
a boot cache written more often) would use.
The test boot_wiring_restores_a_previously_saved_record locks this: it
saves a record, simulates the boot path, and asserts the saved content
survives. Reverting to a boot snapshot stamped with the current time —
the bug a naive implementation ships — fails it.
Every StoreError variant maps to one distinct status line, all of them
in src/messages.rs:
| Variant | Sentence |
|---|---|
TooLarge { bytes, cap } |
"Workspace file is N bytes, over the C byte cap; starting empty." |
Corrupt(detail) |
"Workspace file is corrupt (detail); starting empty." |
Io(detail) |
"Could not read the workspace file (detail); starting empty." |
WriteFailed(detail) |
"Could not save the workspace (detail)." |
RenameFailed(detail) |
"Could not finish saving the workspace (detail); the previous file is intact." |
Write failures are deliberately not labeled as read failures, and a rename failure tells the user their previous file is safe.
The store flushes the temp file but does not fsync it, and does
not fsync the parent directory after the rename. Rust's std::fs
exposes no fsync on a file or directory, so this is a language-level
limit, not a shortcut.
What that means in practice:
- Crash mid-write (process killed, app panics): safe. You get the old file or the new one, never a torn file.
- Power loss in the rename window: the last write may be lost, even though the app reported "Saved to disk".
No document in this project claims power-loss durability. If you need it,
the store is the one place to change: swap the write path for a crate that
can fsync (for example, fs4 or an O_SYNC open), and update this
paragraph.