-
Notifications
You must be signed in to change notification settings - Fork 0
Save‐Architecture
This page documents how Phing persists data to disk — covering the debounce pipeline, atomic writes, dirty-state tracking, write queue serialisation, deletion safety, crash recovery, the quit dialogue, the macOS Cmd+Q interception, and external conflict detection. It is intended for contributors and anyone curious about why the code is structured the way it is.
Phing stores every note as a plain Markdown file and every mind map as a JSON file inside the user's vault directory. All disk I/O flows through two Zustand stores — noteStore and mindMapStore — which share the same hardened pipeline:
user keystroke
│
▼
store mutation (synchronous, in-memory)
│
▼
100 ms debounce (coalesces rapid bursts)
│
▼
WriteQueue (per-ID serialisation + coalescing)
│
▼
captureSnapshot (.phing/snapshots/*.bak)
│
▼
atomic write (.tmp → rename)
│
▼
vault on disk
No write ever reaches disk synchronously. This ensures the UI stays responsive whilst typing and that a crash mid-write can never corrupt an existing file.
A user typing at 60 WPM produces roughly one keystroke every 170 ms. Without debouncing, each character would schedule a separate disk write, causing hundreds of IPC calls per minute and saturating Tauri's inter-process channel.
Each store maintains a module-level Map<string, ReturnType<typeof setTimeout>> called saveTimers (mind maps) or flushTimers (notes). When a mutation arrives for a document ID:
- Any existing timer for that ID is cleared.
- A new
setTimeoutis scheduled for 100 ms in the future. - When the timer fires, the current in-memory state for that ID is serialised and handed to the
WriteQueue.
The net effect is that no matter how many mutations arrive within a 100 ms window, only one disk write is issued — carrying the very latest state.
mutations: ──●──●──●──●──────────────────●──
timers: [ 100ms ] [ 100ms ]
writes: ▼ ▼
| Store | Constant | Value |
|---|---|---|
noteStore |
FLUSH_DELAY_MS |
100 ms |
mindMapStore |
FLUSH_DELAY_MS |
100 ms |
A direct writeTextFile(path, content) call is not atomic. If Phing crashes or is force-killed mid-write, the target file can be left truncated or entirely empty. For a Markdown note vault, a truncated file means data loss.
Every write follows this two-step pattern:
writeTextFile(path + '.tmp', content) // write to a temporary file
rename(path + '.tmp', path) // atomically replace the targetThe rename system call on macOS and Linux is atomic with respect to the filesystem: any reader of path sees either the old complete file or the new complete file — never a partial state. On macOS specifically, APFS guarantees this even across power failures.
If Phing crashes after the writeTextFile but before the rename, a .tmp file is left on disk. This is handled by the crash recovery system described below.
| Module | Function | File type |
|---|---|---|
src/services/vaultService.ts |
atomicWriteNote |
.md |
src/services/mindMapService.ts |
saveMindMapDoc |
.json |
Before Phing closes, it needs to know whether any writes are still in-flight so it can offer the user a chance to save. This is handled by a synchronous hasPendingWrites() function on each store.
noteStore tracks two collections:
-
dirtyNoteIds— IDs of notes whose debounce timer has fired and whose write is queued or in-flight. -
flushTimers— IDs of notes whose debounce timer has not yet fired (mutations arrived but 100 ms has not elapsed).
hasPendingWrites: () => flushTimers.size > 0 || get().dirtyNoteIds.size > 0A note is removed from dirtyNoteIds only after its write completes successfully.
The mind map store uses a module-level Set<string> called dirtyMindMapIds, mirroring the notes pattern:
hasPendingWrites: () => saveTimers.size > 0 || dirtyMindMapIds.size > 0Rather than replaying only the dirty IDs, flushAllPending() saves every loaded document unconditionally when called at quit time. This is safe because:
- It runs after the user confirms they wish to save.
- Writing an unchanged document is idempotent.
- It eliminates any edge case where a document's dirty flag was incorrectly cleared.
The WriteQueue class (src/lib/writeQueue.ts) serialises disk writes per document ID. When a new write is enqueued for an ID that already has a pending entry, the pending entry's run function is replaced with the latest content — the call coalesces. Callers that arrive whilst an inflight write is running park themselves in pending and are woken when the write completes.
The module exports a singleton used by both noteStore and the move path:
export const noteWriteQueue = new WriteQueue();Before this was hardened, moveNote called atomicWriteNote directly, bypassing the WriteQueue entirely. This created a concrete race:
t=0ms User commits a title rename → patchNote fires → debounce timer starts
t=90ms User drags the card to a new pocket → moveNote called
t=90ms moveNote calls atomicWriteNote directly (not via queue)
t=100ms debounce timer fires → flushNote enqueues write → WriteQueue also calls atomicWriteNote
↑ two concurrent writes to the same file path
The two concurrent writes could interleave, corrupting the on-disk content. Worse, both writes computed the destination path independently — if the title rename had already landed, one write would target the old path and one the new, leaving a stale filePath in the store.
moveNote now:
-
Cancels the pending debounce timer for the note ID (
flushTimers.get(noteId)+clearTimeout) before doing anything else. -
Routes its write through
noteWriteQueue.enqueue(), serialising the move behind any already-inflightflushNotefor the same ID. -
Re-reads
fresh.filePathat queue-execution time inside therun()closure. If a preceding queue entry (e.g. a title rename) has already committed and updatedfilePath, the move renames from the file's actual current on-disk location.
t=0ms Title rename debounced → enqueued in WriteQueue
t=90ms moveNote cancels debounce timer, enqueues move in WriteQueue
↓ queue serialises: rename runs first, then move
t=100ms WriteQueue: flushNote runs → writes renamed file, updates filePath
t=100ms WriteQueue: moveNote's run() reads fresh.filePath → moves the already-renamed file to its new pocket folder
↑ no concurrent writes; correct path at every step
Deleting a folder or pocket does not merely remove files from disk — it must also prevent any pending debounced or queued writes from firing afterwards and silently undoing the deletion. Two deletion paths exist, each with its own failure mode.
deleteTreeFolder is called when the user deletes a folder via the file-tree UI. It moves the folder to the OS Trash via the trash_file Rust command.
The ghost-folder bug (fixed):
Without cancellation, a debounce timer armed 90 ms before the delete fires after trash_file returns. The timer calls atomicWriteNote → mkdir(parent, { recursive: true }), which silently recreates the deleted folder on disk as an empty directory containing one orphaned file.
The fix:
Before invoking trash_file, all notes whose folder matches the target path (or any subfolder) are enumerated. For each:
const timer = flushTimers.get(n.id);
if (timer) {
clearTimeout(timer);
flushTimers.delete(n.id);
}
noteWriteQueue.cancel(n.id);Both the debounce timer and any queued-but-not-yet-inflight write entry are cancelled before trash_file is invoked.
Before fix:
trash_file(folder) ──── ✓ folder gone
│ 90 ms later
▼
flushNote fires → mkdir → folder silently recreated 👻
After fix:
clearTimeout + cancel ── flushNote will never fire
trash_file(folder) ── ✓ folder gone, stays gone
deleteFolder removes a pocket from the flat pocket list. It does not call trash_file; instead it resets the folder field of every note in the pocket to '' (root) and removes the pocket entry.
The silent-migration bug (fixed):
Without cancellation, a queued write for a note in the deleted pocket fires after folder has been reset to ''. The write serialises the note to the vault root path, silently moving the file on disk without the user asking.
The fix:
Before calling persistPockets, every note in the pocket has its flushTimers entry cleared and its noteWriteQueue entry cancelled — the same pattern as deleteTreeFolder.
When the user tries to close Phing, a modal dialogue — QuitDialogue — is shown with three options:
| Button | Behaviour |
|---|---|
| Save & Quit | Calls flushAll() to flush both stores, then invokes confirm_quit in Rust to exit. A 5-second bail timer ensures the process always terminates even if a write hangs. |
| Discard | Invokes confirm_quit immediately, abandoning any unsaved changes. |
| Cancel | Dismisses the dialogue; the window remains open. |
The quit dialogue is triggered from two independent sources, both of which call setQuitPending(true) in React:
-
The × button — handled by Tauri's
onCloseRequestedwindow event.event.preventDefault()holds the window open whilst the dialogue is shown. -
Cmd+Q on macOS — handled by a custom menu item (see below). The menu event fires before
[NSApp terminate:]is ever called.
Once the user confirms, the frontend invokes confirm_quit:
#[tauri::command]
fn confirm_quit(app: tauri::AppHandle) {
app.state::<QuitConfirmed>().0.store(true, Ordering::SeqCst);
app.exit(0);
}The QuitConfirmed flag is checked by the RunEvent::ExitRequested fallback handler (used on Windows and Linux) to prevent it from intercepting the app's own exit call and showing the dialogue a second time.
The natural Tauri approach for intercepting application-level quit is RunEvent::ExitRequested with api.prevent_exit(). On Windows and Linux, this works correctly. On macOS, it does not.
The root cause is in tao (the window framework underlying Tauri): tao's macOS NSApplicationDelegate has no implementation of applicationShouldTerminate:. When the user presses Cmd+Q, macOS sends this selector to the app delegate, receives the default NSTerminateNow response from NSObject, and proceeds immediately with app termination — tearing down the WKWebView as part of the Cocoa shutdown sequence. Only after the webview has been suspended does RunEvent::ExitRequested arrive in the Rust event loop. At that point, win.emit('phing://close-requested', ()) is sent to a context that can no longer process JavaScript, so the dialogue never appears.
The fix exploits a macOS UI invariant: Cmd+Q is always bound to the 'Quit' item in the first application submenu. By replacing PredefinedMenuItem::quit — which calls [NSApp terminate:] when activated — with a plain MenuItem carrying the same Cmd+Q accelerator, we intercept the key binding at the menu-selection stage, before any Cocoa termination sequence has started.
let quit_item = MenuItem::with_id(
app,
"phing-quit",
"Quit Phing",
true,
Some("Cmd+Q")
)?;When this item is selected (whether via the menu bar or the keyboard shortcut), Tauri fires on_menu_event. At this point the application is in a completely normal running state, the WKWebView is fully alive, and the emitted event reliably reaches the JS listener:
app.on_menu_event(|app, event| {
if event.id() == "phing-quit" {
if let Some(win) = app.get_webview_window("main") {
let _ = win.emit("phing://close-requested", ());
}
}
});The frontend's listen('phing://close-requested', ...) handler calls setQuitPending(true), and the QuitDialogue appears as normal.
Building a custom application menu requires that all desired submenus be declared explicitly. The Edit submenu is included because WKWebView relies on the macOS first-responder chain to implement Undo, Redo, Cut, Copy, Paste, and Select All. Without the corresponding PredefinedMenuItem entries, these actions have no menu-level target and their keyboard shortcuts stop working in the editor.
User presses Cmd+Q
│
▼
macOS activates 'Quit Phing' menu item
│ (custom MenuItem — no [NSApp terminate:])
▼
on_menu_event fires in Rust
│
▼
win.emit('phing://close-requested')
│ (webview fully alive)
▼
JS listen() handler: setQuitPending(true)
│
▼
QuitDialogue renders
│
├── Save & Quit → flushAll() → invoke('confirm_quit')
│ │
│ ▼
│ Rust: QuitConfirmed = true
│ Rust: app.exit(0)
│
├── Discard → invoke('confirm_quit') (same path, no flush)
│
└── Cancel → setQuitPending(false) (nothing exits)
Phing uses vaultWatcher — a thin wrapper around @tauri-apps/plugin-fs's file-watcher — to detect when an external process (another editor, a sync daemon such as iCloud Drive or Dropbox) modifies a file that Phing currently has open in memory.
When a filesystem change event arrives for a file that matches an in-memory note:
- The file is read from disk.
- An FNV-1a 32-bit content hash is computed for both the disk version and the in-memory version (serialised via
encodeNote). - If the hashes differ, a
ConflictInfoobject is set in React state, which triggers theConflictDialogue.
The hash comparison means that Phing's own writes do not spuriously trigger the dialogue. A time-based suppression window registered on vaultWatcher immediately before each atomic write (suppressNext(abs)) provides additional protection against filesystem event latency.
| Action | Behaviour |
|---|---|
| Reload from disk | The in-memory note is replaced with the on-disk version via reloadNote. |
| Keep local | The in-memory version is force-flushed to disk via flushNote, overwriting the external change. |
| Dismiss | The dialogue is closed; neither version is changed. The conflict remains unresolved until the next save. |
When Phing writes a file it first creates a .tmp sibling, then renames it over the target. If the process is killed between those two steps, a .md.tmp (note) or .json.tmp (mind-map document) file is left on disk.
On the next vault open, findOrphanedTmpFiles walks the vault recursively via collectTmpFiles. The walker matches files ending in .md.tmp or .json.tmp — both extensions are covered, so notes and mind-map documents are recovered by the same pass.
For each orphan found, recoverOrphanedTmp applies the following logic:
- Derive the intended final path by stripping the
.tmpsuffix. -
If the final path does not exist — the rename never completed before the crash. Rename the
.tmpto the final path, completing the interrupted write. -
If the final path already exists — the rename completed before the crash; the
.tmpis stale. Delete it. - If neither step succeeds, delete the
.tmpas a last resort.
This ensures a hard crash mid-write results in at most one revision of lost work, never a corrupted or empty file.
Crash recovery handles a failure during a write. A complementary layer handles failures caused by what was written. Before every atomic overwrite, both atomicWriteNote and saveMindMapDoc call captureSnapshot, which archives the current on-disk content to .phing/snapshots/ as a timestamped .bak file (up to 10 per document, oldest pruned first).
This means even if a user resets a mind-map board and then quits — so the undo stack is gone — the pre-reset state is still recoverable from the snapshot archive cross-session.
| Layer | Protects against | Location |
|---|---|---|
.tmp orphan recovery |
Crash mid-write |
recoveryJournal.ts → findOrphanedTmpFiles
|
| Snapshot archive | Bad data written successfully |
recoveryJournal.ts → captureSnapshot
|
| Undo stack | Accidental in-session destruction |
mindMapStore.ts → undoStacks
|
┌─────────────────────────────────────────────────────────────┐
│ React / TypeScript │
│ │
│ keystroke → patchNote / addChild / commitEdit │
│ │ │
│ 100 ms debounce (flushTimers / saveTimers) │
│ │ │
│ ┌──────────────┤ deleteFolder / deleteTreeFolder │
│ │ cancel timers + queue entries │
│ └──────────────┤ before trash_file │
│ │ │
│ WriteQueue (per-ID serialisation) │
│ moveNote routed here — never direct │
│ │ │
│ captureSnapshot → .phing/snapshots/*.bak │
│ │ │
│ Tauri IPC (invoke / plugin-fs) │
└──────┬──────────────────────────────────────────────────────┘
│
┌──────▼──────────────────────────────────────────────────────┐
│ Rust / Tauri │
│ │
│ tauri_plugin_fs::writeTextFile(.tmp) │
│ tauri_plugin_fs::rename(.tmp → target) ← atomic │
│ │
│ trash_file — moves files/folders to OS Trash │
│ scan_vault_tree — returns a FileNode tree for the sidebar │
│ rename_path — renames files or directories │
│ │
│ on_menu_event('phing-quit') │
│ └─ win.emit('phing://close-requested') │
│ confirm_quit command │
│ └─ QuitConfirmed = true → app.exit(0) │
└──────┬──────────────────────────────────────────────────────┘
│
┌──────▼──────────────────────────────────────────────────────┐
│ Vault on disk │
│ │
│ notes/*.md │
│ .phing/mindmaps/*.json │
│ .phing/snapshots/*.bak (timestamped, ≤ 10 per file) │
│ (transient) *.md.tmp, *.json.tmp → crash recovery │
└─────────────────────────────────────────────────────────────┘
Back to Home | Report an Issue | Official Repository | Technical Architecture & Data Reliability Overview | Save-Architecture
© 2026 Umi. All rights reserved.