Skip to content

Save‐Architecture

Umi edited this page May 26, 2026 · 2 revisions

Phing 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.


Overview

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.


The 100 ms Debounce

Why it exists

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.

How it works

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:

  1. Any existing timer for that ID is cleared.
  2. A new setTimeout is scheduled for 100 ms in the future.
  3. 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:               ▼                           ▼

Debounce constants

Store Constant Value
noteStore FLUSH_DELAY_MS 100 ms
mindMapStore FLUSH_DELAY_MS 100 ms

Atomic Writes

The problem with direct writes

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.

The .tmp → rename pattern

Every write follows this two-step pattern:

writeTextFile(path + '.tmp', content) // write to a temporary file
rename(path + '.tmp', path)           // atomically replace the target

The 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.

Implementation locations

Module Function File type
src/services/vaultService.ts atomicWriteNote .md
src/services/mindMapService.ts saveMindMapDoc .json

Dirty-State Tracking

Purpose

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.

Note 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 > 0

A note is removed from dirtyNoteIds only after its write completes successfully.

Mind map store

The mind map store uses a module-level Set<string> called dirtyMindMapIds, mirroring the notes pattern:

hasPendingWrites: () => saveTimers.size > 0 || dirtyMindMapIds.size > 0

Brute-force flush on quit

Rather 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.

Write Queue Serialisation and moveNote

The WriteQueue

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();

Why moveNote must route through the queue

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.

The fix

moveNote now:

  1. Cancels the pending debounce timer for the note ID (flushTimers.get(noteId) + clearTimeout) before doing anything else.
  2. Routes its write through noteWriteQueue.enqueue(), serialising the move behind any already-inflight flushNote for the same ID.
  3. Re-reads fresh.filePath at queue-execution time inside the run() closure. If a preceding queue entry (e.g. a title rename) has already committed and updated filePath, 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

Deletion Safety: Timer and Queue Cancellation

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 — vault-tree folder deletion

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 atomicWriteNotemkdir(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 — pocket deletion (legacy / no-vault-tree mode)

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.


Flush-on-Quit: the Quit Dialogue

Overview

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.

Two interception paths

The quit dialogue is triggered from two independent sources, both of which call setQuitPending(true) in React:

  1. The × button — handled by Tauri's onCloseRequested window event. event.preventDefault() holds the window open whilst the dialogue is shown.
  2. Cmd+Q on macOS — handled by a custom menu item (see below). The menu event fires before [NSApp terminate:] is ever called.

The confirm_quit Rust command

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.


macOS Cmd+Q Interception

Why the obvious approach fails

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 menu hijack

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.

Edit submenu

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.

Full quit lifecycle on macOS

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)

External Conflict Detection

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.

Detection algorithm

When a filesystem change event arrives for a file that matches an in-memory note:

  1. The file is read from disk.
  2. An FNV-1a 32-bit content hash is computed for both the disk version and the in-memory version (serialised via encodeNote).
  3. If the hashes differ, a ConflictInfo object is set in React state, which triggers the ConflictDialogue.

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.

Resolution options

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.

Crash Recovery

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.

Orphan detection

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.

Recovery logic

For each orphan found, recoverOrphanedTmp applies the following logic:

  1. Derive the intended final path by stripping the .tmp suffix.
  2. If the final path does not exist — the rename never completed before the crash. Rename the .tmp to the final path, completing the interrupted write.
  3. If the final path already exists — the rename completed before the crash; the .tmp is stale. Delete it.
  4. If neither step succeeds, delete the .tmp as 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.

Snapshot archive — the upstream safety net

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.tsfindOrphanedTmpFiles
Snapshot archive Bad data written successfully recoveryJournal.tscaptureSnapshot
Undo stack Accidental in-session destruction mindMapStore.tsundoStacks

Architecture at a Glance

┌─────────────────────────────────────────────────────────────┐
│ 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           │
└─────────────────────────────────────────────────────────────┘