-
Notifications
You must be signed in to change notification settings - Fork 0
Technical Architecture & Data Reliability Overview
Phing is a local-first Markdown note-taking application built on Tauri v2 + React + TypeScript. This document describes the system's internal architecture, with particular focus on the reliability and trust layer that guarantees zero data loss under crash, race, and corruption conditions.
Phing runs as two cooperating processes: a Rust backend (src-tauri/) and a React/TypeScript frontend (src/). Communication between them is handled entirely through the Tauri IPC bridge — no custom REST server or WebSocket is involved.
Key IPC mechanisms in use:
| Mechanism | Direction | Purpose |
|---|---|---|
@tauri-apps/plugin-fs |
Frontend → OS | All filesystem I/O: read, write, rename, remove, watch |
@tauri-apps/plugin-dialog |
Frontend → OS | Native folder picker (open({ directory: true })) and file-save dialogue |
trash_file (Rust command) |
Frontend → Rust | Moves a file or folder to the OS Trash via the trash crate |
scan_vault_tree (Rust command) |
Frontend → Rust | Returns a typed FileNode tree for the sidebar file tree |
rename_path (Rust command) |
Frontend → Rust | Renames files or directories (covers what plugin-fs rename cannot) |
confirm_quit (Rust command) |
Frontend → Rust | Sets QuitConfirmed flag and calls app.exit(0)
|
All filesystem work (atomic writes, snapshot capture, directory scanning) is initiated from TypeScript via the plugin-fs IPC bridge and executes natively through the Rust plugin layer.
The Rust backend registers both plugins and all custom commands at startup:
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
trash_file,
confirm_quit,
scan_vault_tree,
rename_path,
])
.run(tauri::generate_context!())tauri-plugin-fs is pinned to 2.5.1 with the watch feature flag enabled in Cargo.toml, which is required for the filesystem event subscription used by the file watcher:
tauri-plugin-fs = { version = "2.5.1", features = ["watch"] }src/
├── App.tsx # Root: mounts watcher, orphan recovery, conflict & quit dialogues
├── store/
│ ├── noteStore.ts # Primary application state (Zustand)
│ ├── vaultStore.ts # Vault path store (Zustand, persisted to localStorage)
│ └── mindMapStore.ts # Mind map document state + per-document undo/redo (Zustand)
├── services/
│ ├── vaultService.ts # Vault scan, atomic write, note read/delete, path helpers
│ └── mindMapService.ts # Mind map persistence (atomic write + snapshot capture)
├── lib/
│ ├── writeQueue.ts # Per-ID write serialiser / coalescer
│ ├── recoveryJournal.ts # Crash-safety: snapshots, orphan recovery, safe read
│ ├── fileWatcher.ts # VaultFileWatcher + contentHash (FNV-1a)
│ ├── frontmatter.ts # Note encode/decode (YAML-like frontmatter codec)
│ ├── markdown.ts # Tiptap extension factory + markdown serialisation helpers
│ ├── mindmap.ts # MindMapNode tree helpers (add, delete, layout)
│ ├── pockets.ts # Folder/pocket CRUD + defaults
│ ├── pdfExport.ts # html2canvas → jsPDF export for notes and mind maps
│ ├── fileTree.ts # FileNode tree helpers (insert, remove, relPath)
│ └── tauri.ts # isTauri() environment detection
├── extensions/
│ ├── phingKeyboard.ts # Custom Tiptap keyboard shortcuts (Cmd+1–5, Cmd+-)
│ ├── wikiLink.tsx # [[ wiki-link ]] suggestion extension
│ ├── footnote.ts # Footnote rendering extension
│ └── ... # Heading markers, syntax visibility, Zen mode
├── components/
│ ├── Editor.tsx # Tiptap rich-text editor, autosave, sync indicator, TOC
│ ├── MindMapBoard.tsx # React Flow board, per-document undo/redo, PDF export
│ ├── Sidebar.tsx # Pocket list, note list, profile
│ ├── FileTree.tsx # Vault directory tree with inline rename
│ ├── NoteList.tsx # Note cards with drag-to-pocket
│ ├── ConfirmDialogue.tsx # Generic reusable confirmation modal (danger-variant aware)
│ ├── ConflictDialogue.tsx # External file conflict resolution modal
│ ├── QuitDialogue.tsx # Save-or-discard quit modal
│ ├── CommandPalette.tsx # Cmd+K command palette
│ └── Onboarding.tsx # First-run setup (name, avatar, vault path)
└── __tests__/
├── writeQueue.stress.test.ts
├── atomicWrite.stress.test.ts
├── fileWatcher.stress.test.ts
└── corruptedFile.stress.test.ts
Phing uses three independent Zustand stores. There is no Redux, no Context Provider tree, and no global singleton beyond the Zustand instances themselves. Each store is consumed via its own use*Store hook and can be read imperatively (via .getState()) in non-React contexts such as service callbacks.
The simplest store. Holds a single string: the absolute path to the open vault directory. Persisted to localStorage under phing_vault_path so the app reopens the same vault automatically on next launch without any Tauri IPC round-trip.
The primary application store. Manages the full set of notes, pockets (folders), the vault file-tree, user profile, UI state flags, and the current sync state.
interface NoteStore {
// Data
notes: Note[];
pockets: Pocket[];
vaultTree: FileNode | null;
profile: Profile;
syncState: SyncState;
dirtyNoteIds: Set<string>;
// UI flags
selectedNoteId: string | null;
activePocket: string;
isDark: boolean;
isZen: boolean;
isAcademic: boolean;
isLoadingVault: boolean;
// Write path
patchNote: (id: string, patch: Partial<Note>) => void;
flushNote: (id: string) => Promise<void>;
flushActiveNote: () => Promise<void>;
reloadNote: (noteId: string) => Promise<void>;
moveNote: (noteId: string, pocketId: string) => Promise<void>;
// Vault lifecycle
loadVault: (path: string) => Promise<void>;
createNote: (folder?: string, title?: string) => Promise<Note>;
deleteNote: (id: string) => Promise<void>;
deleteFolder: (id: string) => void;
deleteTreeFolder: (absPath: string) => Promise<void>;
renameTreeFolder: (absOldPath: string, newName: string) => Promise<boolean>;
}flushNote is the critical path. It orchestrates the write queue, self-write suppression, snapshot capture, and the atomic write, then updates syncState with the result. moveNote is explicitly routed through the WriteQueue to prevent concurrent-write races with any inflight flushNote for the same note — see §2.3.
Manages a list of MindMapDoc documents — each a first-class entity with its own id, title, folder, and a recursive MindMapNode tree. Mutations are debounced through mindMapService, which serialises documents to .phing/mindmaps/ inside the vault using the same atomic write + snapshot pattern as notes.
The store also maintains per-document undo/redo history in two module-level Map<string, UndoEntry[]> structures, keyed by MindMapDoc.id — see §2.6.
Every note is stored on disk as a plain Markdown file with a YAML-like frontmatter header. The codec lives in src/lib/frontmatter.ts and consists of two pure functions: encodeNote and decodeNote.
On-disk format:
---
id: 550e8400-e29b-41d4-a716-446655440000
title: "My Note Title"
tags: ["research", "draft"]
folder: "research"
emoji: "🔬"
description: ""
created: 2024-06-01T12:00:00.000Z
updated: 2024-06-01T15:32:00.000Z
---
Note body content in standard Markdown.
Encoding (encodeNote) serialises a Note object to this format. All string values are double-quote-escaped to ensure the format survives adversarial content such as embedded quotes and backslashes.
Decoding (decodeNote) is deliberately defensive:
- Returns
null(never throws) if the input does not start with---or lacks a closing delimiter. - All fields default gracefully: missing
idfalls back to the providedfallbackId; missingtitlefalls back to"Untitled"; missingtags/folderfall back to[]/"". -
stripLegacyBodyis called on the body to normalise pre-unification notes. Critically, it only strips the legacy# Title+*italic description*pattern when both markers are present — a lone# Headingat the start of a modern note body is never touched.
This defence-in-depth approach is what makes the corrupted-file fallback tests (corruptedFile.stress.test.ts) pass for all 1,000+ randomised mutation inputs.
Sources: src/services/vaultService.ts → atomicWriteNote() · src/services/mindMapService.ts → saveMindMapDoc()
A direct writeTextFile(abs, content) call is not safe: if the application crashes, loses power, or the OS interrupts the write mid-stream, the file on disk will contain a partial payload. On POSIX systems, a filesystem rename() of a fully-written source onto the target is atomic at the kernel level — the destination is either the old version or the new version, never a mixture of both.
Write sequence (notes):
1. Compute abs = /vault/Research/My Note.md
tmp = /vault/Research/My Note.md.<uuid8>.tmp
2. (If note was renamed/moved) rename oldAbs → abs
3. If abs exists on disk:
read existing content
await captureSnapshot(vault, rel, existing) ← recovery journal
4. Encode note to string:
encoded = encodeNote({ ...note, updatedAt: now() })
5. writeTextFile(tmp, encoded) ← crash here → stale .tmp, original safe
6. rename(tmp, abs) ← crash here → .tmp holds new data, original safe
success → .tmp gone, abs has new data
7. return rel
The .tmp path includes a short UUID fragment (crypto.randomUUID().slice(0, 8)) to prevent concurrent writes for different notes from colliding on the temp file — even if two notes happen to share the same title.
Crash safety matrix:
| Crash point | State on disk | Recovery path |
|---|---|---|
Before writeTextFile(tmp)
|
Only abs with original content |
No action needed |
After writeTextFile(tmp), before rename
|
abs = original, tmp = new data |
Orphan recovery renames tmp → abs
|
After rename completes |
abs = new data, tmp deleted |
No action needed |
abs never existed (new note), crash mid-rename |
No abs, tmp = new data |
Orphan recovery renames tmp → abs
|
Source: src/lib/writeQueue.ts
A rapid typist editing a note may produce dozens of patchNote calls per second. Without serialisation, each patch would schedule a flushNote timer, potentially launching concurrent atomicWriteNote calls for the same file and creating races where an older write's rename() could land after a newer one's, silently reverting the file to a stale state.
WriteQueue eliminates this by serialising writes per note ID and coalescing pending writes so that only the most recently enqueued content reaches disk.
class WriteQueue {
private readonly pending = new Map<string, PendingEntry>(); // queued but not yet running
private readonly inflight = new Set<string>(); // currently running
cancel(id: string): void {
this.pending.delete(id); // drops a queued-but-not-inflight entry safely
}
}Because pending.delete and inflight.add execute synchronously before the first await in flush(), any new enqueue calls that arrive during a write see the inflight flag and park themselves in pending without starting a second flush.
moveNote is also routed through this queue — see the Save Architecture page for the full race-condition analysis and timeline.
Coalescing behaviour under a 100-call synchronous flood:
| Generation |
run function used |
Subscribers resolved |
|---|---|---|
| Gen 1 |
run from call 0 (dispatched immediately) |
call 0 only |
| Gen 2 |
run from call 99 (last replacement) |
calls 1–99
|
| Total writes | 2 | — |
The invariant is not 'minimum possible writes' but 'the latest content always wins on disk'.
Before any folder or pocket is sent to the OS Trash (or removed from the pocket list), Phing pre-emptively disarms all pending writes for notes inside it:
for (const n of notesInFolder) {
const timer = flushTimers.get(n.id);
if (timer) { clearTimeout(timer); flushTimers.delete(n.id); }
noteWriteQueue.cancel(n.id);
}This pattern is applied in both deleteTreeFolder (vault file-tree path, calls trash_file) and deleteFolder (pocket/legacy path, calls persistPockets). Without it, a debounce timer firing after the deletion would call mkdir() on the trashed parent directory, silently recreating it on disk. See the Save Architecture page for the detailed before/after timeline.
Source: src/lib/recoveryJournal.ts
The recovery journal provides three independently callable capabilities.
Before every atomicWriteNote or saveMindMapDoc call overwrites an existing file, the current disk content is archived to <vault>/.phing/snapshots/ as a timestamped .bak file. This covers both notes (.md) and mind-map documents (.json).
/vault/.phing/snapshots/
research__My Note.md.2024-06-01T12-00-00-000Z.bak
.phing__mindmaps__<uuid>.json.2024-06-01T15-32-00-000Z.bak
...
- Up to 10 snapshots are retained per document; older files are pruned automatically.
- Snapshot failure is non-fatal: the entire operation is wrapped in
try/catchso a snapshot I/O error never blocks the primary write path.
On vault open, App.tsx calls findOrphanedTmpFiles(vaultPath), which recursively walks the vault looking for files ending in .md.tmp or .json.tmp — covering both note and mind-map atomic writes. Each orphan is then passed to recoverOrphanedTmp():
export async function recoverOrphanedTmp(tmpPath: string): Promise<void> {
const finalPath = tmpPath.replace(/\.tmp$/, '');
try {
if (!(await exists(finalPath))) {
await rename(tmpPath, finalPath); // finalise the interrupted write
} else {
await remove(tmpPath); // rename completed; stale .tmp is safe to remove
}
} catch {
await remove(tmpPath).catch(() => {}); // last resort: remove the orphan
}
}safeReadNote(absPath) provides a graceful fallback path for opening notes in a partially-recovered vault:
readTextFile(abs) succeeds? → return content
↓ throws
.tmp sibling exists? → readTextFile(abs.tmp) succeeds? → return content
↓ throws
return null
Source: src/store/mindMapStore.ts
The standard Zustand undo middleware (zundo) was considered and explicitly rejected. The undoable data — the MindMapNode tree — is nested inside mindMaps[] at index selectedMindMapId. A partialized selector for { activeTree } does not map to a real store field, making restoration a silent no-op. More critically, middleware-level undo would snapshot the entire store slice, including UI state such as editingId and notePopupId, causing incorrect node selections to be restored on undo.
Two module-level Maps provide per-document, isolated history:
interface UndoEntry {
tree: MindMapNode; // full tree snapshot
selectedId: string | null; // node selection at time of snapshot
}
const undoStacks = new Map<string, UndoEntry[]>(); // docId → past states
const redoStacks = new Map<string, UndoEntry[]>(); // docId → future states
const MAX_UNDO = 50;Stacks are keyed by MindMapDoc.id so switching between maps never bleeds history. Stacks for a deleted document are cleaned up in deleteMindMap to prevent memory leaks. Any forward mutation calls pushUndo, which also clears the redo stack for that document.
pushUndo is called at the top of every structural mutation, immediately before the mutation runs:
| Mutation | Undo behaviour |
|---|---|
addChild |
Removes the new node; restores parent selection |
addSibling |
Removes the new node; restores prior sibling selection |
deleteSelected |
Restores the deleted node and its entire subtree |
commitEdit |
Restores the previous label text |
resetBoard |
Restores the entire pre-reset tree |
updateNote (note-pane content edits) is intentionally excluded — the note pane uses the browser's native text-input undo, which is already correctly scoped.
On undo, if the previously-selected node ID no longer exists in the restored tree (for example, undoing an addChild removes the node that was being edited), selection falls back to the root:
const restoredSelected =
entry.selectedId && findNode(entry.tree, entry.selectedId)
? entry.selectedId
: entry.tree.id;| Shortcut | Action |
|---|---|
Cmd+Z / Ctrl+Z
|
undo() |
Cmd+Shift+Z / Ctrl+Shift+Z
|
redo() |
Both shortcuts are handled in the board's onKeyDownCapture handler before the note-popup bail-out, so they remain active whilst the note pane is open.
Invariant: no operation that permanently destroys document state may be triggered by a single click.
src/components/ConfirmDialogue.tsx is a generic reusable confirmation modal with open, title, message, confirmLabel, onConfirm, onCancel, and danger? props. When danger is true, the confirm button renders in a red accent and the decorative mark switches from ✦ to ⚠. The Escape key dismisses via a capture-phase keydown listener.
The ↺ reset toolbar button previously called resetBoard() directly — a single click that irreversibly erased the entire canvas. It now calls setConfirmReset(true), rendering a ConfirmDialogue that names the specific map. resetBoard() is only ever called from onConfirm.
Combined with the undo stack (§2.6) and the snapshot archive (§2.5), a user who accidentally confirms a reset has three independent recovery paths: Cmd+Z (instant, in-session), the undo stack (until the session ends), and .phing/snapshots/ (cross-session, durable).
Sources: src/lib/fileWatcher.ts · src/components/ConflictDialogue.tsx
export class VaultFileWatcher {
private unwatchFn: UnwatchFn | null = null;
private readonly suppressMap = new Map<string, number>(); // path → suppress-until ms
suppressNext(absPath: string, windowMs = 2_000): void {
this.suppressMap.set(absPath, Date.now() + windowMs);
}
async start(vaultPath: string, onEvent: FileChangeHandler): Promise<void> { ... }
async stop(): Promise<void> { ... }
}start() calls watch(vaultPath, callback, { recursive: true, delayMs: 400 }). The delayMs: 400 is handled by the native plugin — it coalesces rapid filesystem noise (e.g. a sync client writing then renaming) into a single event.
Inside the callback, per event:
- Only paths ending in
.mdare considered — all other extensions are ignored. - Paths containing
/.phing/are ignored — this prevents snapshot writes triggering false conflicts. - If the path has an active suppress entry (
Date.now() < suppressUntil), the event is silently discarded.
Self-write suppression design: suppressNext registers a time-based expiry rather than a one-shot flag. An atomic write produces two OS-level filesystem events on some platforms (one for writeTextFile(tmp) and one for rename(tmp → abs)). A time window suppresses both without needing to track how many events to expect.
export function contentHash(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}FNV-1a was chosen for its simplicity (zero dependencies, pure TypeScript), speed on typical note sizes, and adequacy for a single-bit-change detector. It is not a cryptographic hash — its sole purpose is cheap equality checking.
When a non-suppressed modify event arrives for a .md file, the app reads the on-disk version, hashes both it and the in-memory version via contentHash, and if the hashes differ raises a ConflictInfo object:
| Option | Action |
|---|---|
| Reload from disk |
reloadNote(noteId) — replaces in-memory state with the on-disk version |
| Keep local |
flushNote(noteId) — overwrites the on-disk version with the in-memory version |
| Dismiss | Clears the conflict indicator; no data movement |
The editor status indicator is driven by a SyncState object stored in noteStore:
export interface SyncState {
status: 'synced' | 'saving' | 'dirty' | 'error' | 'conflict';
lastSyncedAt: string | null; // ISO 8601 timestamp of last confirmed disk write
errorMessage: string | null;
}State transitions:
[ synced ] ──patchNote()──► [ dirty ] ──flushNote()──► [ saving ] ──success──► [ synced ]
│ (lastSyncedAt updated)
error ──────────────► [ error ]
conflict ──────────────► [ conflict ]
lastSyncedAt is only set when atomicWriteNote has completed successfully — not when the write is merely enqueued. This ensures the indicator accurately reflects what is actually on disk.
Framework: Vitest v4.x
Runner mode: pool: 'forks' — each test file runs in an isolated child process.
Environment: node — no browser globals, no JSDOM overhead.
Test location: src/__tests__/*.stress.test.ts
Module mocking strategy: All four test files mock @tauri-apps/plugin-fs using Vitest's vi.mock(). Mock state is held inside a container object (const state = { vfs: new Map() }) that is mutated per-test via beforeEach rather than reassigned, avoiding Temporal Dead Zone issues with hoisted mock factories.
| File | Tests | Primary subsystem under test |
|---|---|---|
writeQueue.stress.test.ts |
6 |
WriteQueue coalescing, error propagation, memory |
atomicWrite.stress.test.ts |
6 |
atomicWriteNote, recoveryJournal, captureSnapshot
|
fileWatcher.stress.test.ts |
14 |
VaultFileWatcher, contentHash
|
corruptedFile.stress.test.ts |
12 |
safeReadNote, decodeNote, encode/decode round-trips |
| Scenario | Assertion |
|---|---|
| 100 synchronous enqueues coalesce to ≤ 2 writes |
written.length ≤ 2; last write is content-99
|
| 10 enqueues whilst gen-1 inflight coalesce to 1 further write | Exactly 2 total writes; gen-2 = last edit |
| 5 notes × 20 edits each run independently | Every note ID appears in log; total writes < 100 |
| Error in gen-1 reaches gen-1 subscriber only |
p1 rejects with boom; pending subs get gen-2 error |
| 1,000 enqueues leave no memory leaks |
pending.size === 0, inflight.size === 0
|
| 100 distinct note IDs run without interference | All 100 IDs written exactly once |
Notable design insight: A synchronous flood produces exactly 2 writes — not 1. Because flush() runs synchronously up to its first await entry.run(), the inflight flag is set before control returns to the enqueue loop. The invariant is not 'minimum possible writes' but 'latest content always wins on disk.'
Uses an in-memory virtual filesystem as a drop-in replacement for the real OS. vi.mocked(rename).mockRejectedValueOnce(...) injects crashes at the exact point of the rename() call, simulating a mid-write process kill or power loss.
| Scenario | Assertion |
|---|---|
| Normal write: tmp created then renamed to final |
abs exists with correct content; tmp deleted |
| Crash during rename (new note): tmp survives |
tmp exists; abs absent; orphan recovery restores abs
|
| Crash during rename (existing note): final safe |
tmp deleted; old abs content preserved |
| Snapshot pruning beyond 10-file limit | Remaining .bak files ≤ 10 after captureSnapshot
|
Two concurrent atomicWriteNote calls |
Final file has valid frontmatter; no corruption |
Snapshot .bak files not treated as orphans |
Only .md.tmp / .json.tmp files returned by findOrphanedTmpFiles
|
Captures the internal callback that watch() registers and fires synthetic filesystem events directly through it, bypassing the native debounce layer for deterministic testing.
| Scenario | Assertion |
|---|---|
| 50 events within suppress window | Zero delivered |
| Events resume after suppress window expires | First event after expiry is delivered |
| 50 rapid external events | All 50 delivered; no exception |
Non-.md files silently ignored |
.png, .json, .gitignore, .md.tmp produce no events |
.phing/ snapshot paths filtered |
Events from /.phing/ produce no callbacks |
| Event kind resolution (create / modify / remove) | Correct kind mapped from raw Tauri event type |
| Mixed suppressed / unsuppressed paths in one event | Only the unsuppressed path is delivered |
Rapid stop() / start() cycles |
10 cycles complete without throwing |
contentHash properties |
32-bit unsigned, deterministic, no collisions for 7 test inputs, handles empty string and 100,000-char input |
Verifies complete fault tolerance for unreadable, empty, garbage, and adversarially-constructed files. No code path in the read pipeline may throw an unhandled exception.
| Scenario | Assertion |
|---|---|
Primary file throws I/O error → .tmp fallback |
Returns .tmp content |
Both primary and .tmp throw |
result === null |
| Primary succeeds | Returns primary content verbatim; .tmp never consulted |
8 garbage strings → decodeNote
|
No throw; returns null for each |
| Valid frontmatter, empty body | note.content === '' |
| Adversarial titles (SQL injection, XSS, backslashes, emoji, 10,000-char string) |
encodeNote + decodeNote both complete without throwing |
| Missing required fields |
title="Untitled", tags=[], folder=""
|
No id field |
note.id === 'my-fallback' |
| 12 extreme encode → decode round-trips |
decoded.content.trim() === original.content.trim() for all |
| 1,000 randomly-mutated strings | None of 5,000 mutation variants throw |
Last updated: May 2026 — reflects the Reliability & Trust Layer, Mind Map Undo/Redo, Guarded Destructive Operations, and Deletion Safety implementations.
Back to Home | Report an Issue | Official Repository | Technical Architecture & Data Reliability Overview | Save-Architecture
© 2026 Umi. All rights reserved.