Skip to content

Technical Architecture & Data Reliability Overview

Umi edited this page May 24, 2026 · 7 revisions

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.


Table of Contents

  1. High-Level Architecture
  2. Data & Reliability Layer
  3. Testing & Robustness

1. High-Level Architecture

1.1 The Tauri v2 Process Model

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.

┌─────────────────────────────────────────┐│              OS Process                 ││                                         ││  ┌───────────────┐   IPC Bridge         ││  │  Rust Backend │◄────────────────┐    ││  │  (src-tauri/) │                 │    ││  │               │  invoke()       │    ││  │  Plugins:     │  emit()         │    ││  │  plugin-fs    │                 │    ││  │  plugin-dialog│                 │    ││  └───────────────┘                 │    ││                                    │    ││  ┌──────────────────────────────── ▼──┐ ││  │         WebView (React/TS)         │ ││  │         src/                       │ ││  │                                    │ ││  │  App.tsx → Stores → Services       │ ││  │  ↕ Zustand  ↕ vaultService.ts      │ ││  │             ↕ recoveryJournal.ts   │ ││  │             ↕ fileWatcher.ts       │ ││  └────────────────────────────────────┘ │└─────────────────────────────────────────┘

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 }))
tauri::command (Rust) Frontend → Rust Custom commands (currently minimal — the greet scaffold)

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 (src-tauri/src/lib.rs) registers both plugins at startup:

tauri::Builder::default()    .plugin(tauri_plugin_opener::init())    .plugin(tauri_plugin_fs::init())         // watch feature enabled    .plugin(tauri_plugin_dialog::init())    .invoke_handler(tauri::generate_handler![])    .run(tauri::generate_context!())

The tauri-plugin-fs crate is pinned to 2.5.1 with the watch feature flag enabled in src-tauri/Cargo.toml, which is required for the filesystem event subscription (watch()) used by the file watcher:

tauri-plugin-fs = { version = "2.5.1", features = ["watch"] }

The corresponding capability grants are declared in src-tauri/capabilities/default.json:

"fs:allow-watch","fs:allow-rename"

1.2 Frontend Module Structure

src/├── App.tsx                  # Root: mounts watcher, orphan recovery, conflict dialogue├── store/│   ├── noteStore.ts         # Primary application state (Zustand)│   ├── vaultStore.ts        # Vault path store (Zustand, persisted to localStorage)│   └── mindMapStore.ts      # Mind map document state (Zustand)├── services/│   ├── vaultService.ts      # Vault scan, atomic write, note read/delete│   └── mindMapService.ts    # Mind map persistence├── 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)│   ├── pockets.ts           # Folder/pocket CRUD + defaults│   └── tauri.ts             # isTauri() environment detection├── components/│   ├── Editor.tsx           # CodeMirror editor, autosave on blur, sync indicator│   ├── ConflictDialogue.tsx # Spring-animated conflict resolution modal│   └── 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

1.3 State Management: Zustand Stores

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.

vaultStoresrc/store/vaultStore.ts

The simplest store. Holds a single string: the absolute path to the open vault directory.

interface VaultStore {  vaultPath: string | null;  setVaultPath: (path: string | null) => void;}

The vault path is persisted to localStorage under the key phing_vault_path and rehydrated on page load. This allows the app to reopen the same vault automatically on next launch without any Tauri IPC round-trip.

noteStore reads the vault path imperatively via useVaultStore.getState().vaultPath inside write-path callbacks to avoid unnecessary React re-renders.

noteStoresrc/store/noteStore.ts

The primary application store. Manages the full set of notes, pockets (folders), user profile, UI state flags, and the current sync state. Its interface spans all application-level operations:

interface NoteStore {  // Data  notes: Note[];  pockets: Pocket[];  profile: Profile;  syncState: SyncState;  dirtyNoteIds: Set<string>;
  // UI flags  selectedNoteId: string | null;  activePocket: string;  isDark: boolean;  isZen: boolean;  isLoadingVault: boolean;
  // Write path  patchNote:       (id: string, patch: Partial<Note>) => void;  scheduleFlush:   (id: string) => void;  flushNote:       (id: string) => Promise<void>;  flushActiveNote: () => Promise<void>;  reloadNote:      (noteId: string) => Promise<void>;
  // Vault lifecycle  loadVault:       (path: string) => Promise<void>;  openVaultPicker: () => Promise<string | null>;  createNote:      (folder?: string, title?: string) => Promise<Note>;  deleteNote:      (id: string) => Promise<void>;  moveNote:        (noteId: string, pocketId: string) => Promise<void>;}

The flushNote action is the critical path. It orchestrates the write queue, self-write suppression, and the atomic write, then updates syncState with the result. See §2.3 Write Queue for full detail.

mindMapStoresrc/store/mindMapStore.ts

Manages a list of MindMapDoc documents — each a first-class entity with its own id, title, folder, and a recursive MindMapNode tree. Mutations (add child, rename node, delete node) are debounced through mindMapService, which serialises documents to .phing/mindmaps/ inside the vault. The store architecture mirrors noteStore but is intentionally kept separate to avoid coupling the two document types.


2. Data & Reliability Layer

2.1 Note Serialisation: Frontmatter Codec

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-446655440000title: "My Note Title"tags: ["research", "draft"]folder: "research"emoji: "🔬"description: ""created: 2024-06-01T12:00:00.000Zupdated: 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 (esc() handles \ and " characters) 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 id falls back to the provided fallbackId; missing title falls back to 'Untitled'; missing tags / folder fall back to [] / ''.
  • stripLegacyBody is called on the body to normalise pre-unification notes that used a leading # Title heading as their title field.

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.


2.2 Atomic Writes

Source: src/services/vaultService.tsatomicWriteNote()

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 (macOS, Linux), a filesystem rename() of a fully-written source onto the target is an atomic operation at the kernel level — the destination is either the old version or the new version, never a mixture of both.

Write sequence:

1.  Compute  abs  =  /vault/Research/My Note.md            tmp  =  /vault/Research/My Note.md.tmp2.  (If note was renamed/moved) rename oldAbs → abs3.  If abs exists on disk:      read existing content      await captureSnapshot(vault, rel, existing)   ← recovery journal4.  Encode note to string:      encoded = encodeNote({ ...note, updatedAt: now() })5.  writeTextFile(tmp, encoded)      ← crash here → stale .tmp, original safe6.  rename(tmp, abs)                 ← crash here → .tmp holds new data, original safe                                       success     → .tmp gone, abs has new data7.  return rel  (the vault-relative path)
// src/services/vaultService.ts (atomicWriteNote — simplified)
const encoded = encodeNote({ ...note, updatedAt: new Date().toISOString() });await writeTextFile(tmp, encoded);await rename(tmp, abs);

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

Note on Windows: rename() on Windows is not guaranteed atomic in the POSIX sense when the destination exists. Phing still uses this approach because it is substantially safer than a direct writeTextFile overwrite. A future enhancement could use MoveFileExW with MOVEFILE_WRITE_THROUGH via a Tauri command for full Windows atomicity.


2.3 Write Queue

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 dozens of concurrent atomicWriteNote calls for the same file. This creates race conditions 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.

Data structures:

class WriteQueue {  private readonly pending  = new Map<string, PendingEntry>(); // queued but not yet running  private readonly inflight = new Set<string>();               // currently running}

Enqueue semantics:

enqueue({ id, run }) called:  ┌─ pending has id? ──YES──► replace pending.run with new run  │                           append caller to pending.subs  │                           (caller waits for the coalesced write to finish)  │  └─ NO ──► add to pending            ┌─ inflight has id? ──YES──► park; flush() will pick it up when inflight clears            └─ NO ──► dispatch flush(id) immediately

Flush execution:

private async flush(id: string): Promise<void> {  const entry = this.pending.get(id);  if (!entry) return;
  this.pending.delete(id);   // ← runs synchronously, before first await  this.inflight.add(id);     // ← runs synchronously, before first await
  try {    await entry.run();                          // ← actual disk write    entry.subs.forEach(({ res }) => res());     // resolve all subscribers  } catch (err) {    entry.subs.forEach(({ rej }) => rej(err));  // reject all subscribers  } finally {    this.inflight.delete(id);    if (this.pending.has(id)) void this.flush(id); // dispatch next generation  }}

Key invariant: because pending.delete and inflight.add execute synchronously before the first await, any new enqueue calls that arrive during the write see the inflight flag and park themselves in pending without starting a second flush. All parked subscribers are woken together when the inflight write completes. The module exports a singleton:

export const noteWriteQueue = new WriteQueue();

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 199
Total writes 2

The guarantee is not "exactly one write" but rather "the final content always lands on disk" — which is the correct invariant for a text editor.


2.4 Recovery Journal

Source: src/lib/recoveryJournal.ts

The recovery journal provides three independently callable capabilities:

2.4.1 Snapshot Capture

Before every atomicWriteNote call overwrites an existing file, the current disk content is archived to <vault>/.phing/snapshots/ as a timestamped .bak file.

/vault/.phing/snapshots/  research__My Note.md.2024-06-01T12-00-00-000Z.bak  research__My Note.md.2024-06-01T15-32-00-000Z.bak  research__My Note.md.2024-06-01T18-45-00-000Z.bak  ...
  • Folder separators in the relative path are replaced with __ to flatten it into a single-level filename.
  • Timestamps use ISO 8601 with : and . replaced by - for filesystem safety.
  • After writing, pruneSnapshots() sorts all matching .bak files lexicographically (ISO timestamps are lexicographically ordered) and deletes all but the 10 most recent.
  • Snapshot failure is non-fatal: a try/catch around the entire operation ensures a snapshot I/O error never blocks the primary write path.
export async function captureSnapshot(  vaultPath: string,  relPath: string,  content: string,): Promise<void> {  try {    // ...ensure dir, write .bak, then prune  } catch {    // Non-fatal — snapshot failure must never block a save.  }}

2.4.2 Orphan Recovery

On vault open, App.tsx calls findOrphanedTmpFiles(vaultPath), which recursively walks the vault looking for files matching *.md.tmp. 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  }}

The .phing/ directory is excluded from orphan scanning to avoid misidentifying snapshot files.

2.4.3 Safe Read

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

This function is used during vault load to ensure that even a vault with crash artefacts can be opened without a hard error.


2.5 File Watcher & Conflict Dialogue

Sources: src/lib/fileWatcher.ts, src/components/ConflictDialogue.tsx

Phing vaults are plain directories of Markdown files. A user might simultaneously edit them in Obsidian, VS Code, or via a cloud sync tool such as iCloud Drive or Dropbox. The file watcher detects these external changes and prompts the user to resolve the conflict.

VaultFileWatcher

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 before delivering it to the callback.

Inside the callback, per event:

  1. Expired suppress entries are pruned from suppressMap.
  2. Only paths ending in .md are considered — all other extensions are ignored.
  3. Paths containing /.phing/ are ignored — this prevents snapshot writes from being mistaken for external changes.
  4. If the path has an active suppress entry (i.e., Date.now() < suppressUntil), the event is silently discarded.

Self-write suppression design: suppressNext(absPath, windowMs) registers a time-based expiry rather than a one-shot flag. This is deliberate: 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.

noteStore.flushNote():  1. vaultWatcher.suppressNext(abs, 2_000)   ← register 2 s suppress window  2. noteWriteQueue.enqueue({ id, run: () => atomicWriteNote(...) })     └─ atomicWriteNote:          writeTextFile(tmp) ← FS event 1: suppressed ✓          rename(tmp → abs)  ← FS event 2: suppressed ✓

Conflict detection in App.tsx:

When a non-suppressed modify event arrives for a .md file, the app:

  1. Reads the current on-disk content via vaultService.readNote().
  2. Hashes the on-disk content using contentHash() (FNV-1a 32-bit).
  3. Hashes the in-memory content of the matching Note object.
  4. If the hashes differ → a genuine external modification has occurred → setConflict({ noteId, noteTitle, absPath }).
External change detected:  onDiskHash  = contentHash(await readNote(vault, rel))  inMemHash   = contentHash(encodeNote(liveNote))  onDiskHash ≠ inMemHash  →  raise ConflictInfo

contentHash — FNV-1a 32-bit

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 short to medium strings (typical note sizes), and its adequacy for a single-bit-change detector. It is not a cryptographic hash — its purpose is cheap equality checking, not security.

ConflictDialogue

When a conflict is raised, a spring-animated modal (framer-motion AnimatePresence) overlays the editor with three choices:

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

2.6 Sync State Machine

The editor status indicator is driven by a SyncState object stored in noteStore, not a simple boolean or timestamp:

export interface SyncState {  status: 'synced' | 'saving' | 'dirty' | 'error' | 'conflict';  lastSyncedAt: string | null;  // ISO 8601 timestamp of last confirmed disk write  errorMessage: string | null;}

State transition diagram:

    ┌──────────────────────────────────────────────────────────────┐    │                                                              │    ▼                                                              │[ synced ] ──patchNote()──► [ dirty ] ──flushNote()──► [ saving ] ─┤                                                          │         │                                                 error ◄─┤         │                                                          │         │                                              conflict ◄─┤         │                                                          │         │                                               [ synced ]◄─────────┘                                            (lastSyncedAt updated)

Editor.tsx renders the label via buildSaveLabel(sync: SyncState):

case 'saving':   return '· saving…';case 'dirty':    return '· unsaved';case 'error':    return `· sync failed`;case 'conflict': return '· conflict';case 'synced':   return sync.lastSyncedAt ? `· synced ${hh}:${mm}` : '';

The timestamp shown to the user is derived from lastSyncedAt, which 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.


3. Testing & Robustness

3.1 Test Infrastructure

Framework: Vitest v4.x
Runner mode: pool: 'forks' — each test file runs in an isolated child process, providing full module mock isolation without interference between test files.
Environment: node — no browser globals, no JSDOM overhead.
Test location: src/__tests__/*.stress.test.ts

// vitest.config.tsexport default defineConfig({  test: {    environment: 'node',    globals: true,    include: ['src/__tests__/**/*.stress.test.ts'],    pool: 'forks',    reporters: ['verbose'],  },});

Module mocking strategy: All four test files mock @tauri-apps/plugin-fs using Vitest's vi.mock(). To avoid Temporal Dead Zone (TDZ) issues with hoisted mock factories, the mock state is held inside a container object (const state = { vfs: new Map() }) that is mutated per-test rather than reassigned.

// Pattern used in atomicWrite.stress.test.tsconst state = { vfs: new Map<string, VfsEntry>() };
vi.mock('@tauri-apps/plugin-fs', () => ({  readTextFile: vi.fn(async (p: string) => {    const e = state.vfs.get(p);    if (!e) throw Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' });    return e.content;  }),  // ...}));
beforeEach(() => { state.vfs = new Map(); }); // reset per test

Current results: 38 / 38 tests passing, 0 TypeScript errors.


3.2 Stress Test Suite Overview

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

3.3 Test Coverage by Scenario

writeQueue.stress.test.ts — Write Queue Flooding

Philosophy: Verifies that the coalescing logic holds under extreme synchronous and concurrent load, that error propagation reaches the correct generation of subscribers, and that the internal Map and Set do not leak entries.

Scenario Test ID Assertion
100 synchronous enqueues coalesce to ≤ 2 writes 1a written.length ≤ 2; last write is content-99
10 enqueues while gen-1 inflight coalesce to 1 write 1b Exactly 2 total writes; gen-2 = last edit
5 notes × 20 edits each run independently 1c Every note ID appears in log; total writes < 100
Error in gen-1 reaches gen-1 subscriber only 1d p1 rejects with boom; pending subs get gen-2 error
1 000 enqueues leave no memory leaks 1e pending.size === 0, inflight.size === 0
100 distinct note IDs run without interference 1f All 100 IDs written exactly once

Notable design insight (1a): 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. Enqueue-0 fires its run; enqueues 1–99 all see the inflight flag and park in pending (replacing each other's run). Result: gen-1 runs content-0, gen-2 runs content-99. The invariant is not "minimum possible writes" but "latest content always wins on disk."


atomicWrite.stress.test.ts — Atomic Write & Crash Simulation

Philosophy: Uses an in-memory virtual filesystem (vfs) as a drop-in replacement for the real OS filesystem. vi.mocked(rename).mockRejectedValueOnce(...) injects crashes at the exact point of the rename() call, simulating a mid-write process kill or power loss.

Scenario Test ID Assertion
Normal write: tmp created then renamed to final 2a abs exists with correct content; tmp deleted
Crash during rename (new note): tmp survives 2b tmp exists; abs absent; orphan recovery restores abs
Crash during rename (existing note): final safe 2c tmp deleted; old abs content preserved
Snapshot pruning beyond 10-file limit 2d Remaining .bak files ≤ 10 after captureSnapshot
Two concurrent atomicWriteNote calls 2e Final file has valid frontmatter (startsWith('---')); no corruption
Snapshot .bak files not treated as orphans 2f Only .md.tmp files returned by findOrphanedTmpFiles

fileWatcher.stress.test.ts — External Watcher Flood

Philosophy: Captures the internal callback that watch() registers and fires synthetic filesystem events directly through it, bypassing the native debounce layer. This allows deterministic testing of suppression, filtering, and event-kind resolution at microsecond precision.

Scenario Test ID Assertion
50 events within suppress window: zero delivered 3a received.length === 0
Events resume after suppress window expires 3b First event after advanceTimersByTime(101) is delivered
50 rapid external events: no crash 3c All 50 delivered; no exception
Non-.md files silently ignored 3d .png, .json, .gitignore, .md.tmp produce no events
.phing/ snapshot paths filtered 3e Events from /.phing/ produce no callbacks
Event kind resolution (create/modify/remove) 3f Correct kind mapped from raw Tauri event type object
Mixed suppressed/unsuppressed paths in one event 3g Only the unsuppressed path is delivered
Rapid stop()/start() cycles: no leaked watchers 3h 10 cycles complete without throwing
contentHash is a 32-bit unsigned integer 0 ≤ h ≤ 0xFFFFFFFF
contentHash is deterministic Same input always returns same hash
contentHash has no collisions for 7 test inputs All 7 distinct hashes
contentHash handles empty string without throwing Returns without error
contentHash handles 100 000-char input within 32 bits 0 ≤ h ≤ 0xFFFFFFFF
contentHash distinguishes on-disk from in-memory edits Different hashes for different content

corruptedFile.stress.test.ts — Corrupted File Fallback

Philosophy: Verifies the app's complete fault tolerance for unreadable, empty, garbage, and adversarially-constructed files. No code path in the read pipeline may throw an unhandled exception. Every failure must return null or a safe default.

Scenario Test ID Assertion
Primary file throws I/O error → .tmp fallback 4a Returns .tmp content
Both primary and .tmp throw → returns null 4b result === null
Primary succeeds → .tmp never consulted 4c Returns primary content verbatim
8 garbage strings → decodeNote returns null 4d No throw; returns null for each
Valid frontmatter, empty body → partial parse 4e note.content === ''
Adversarial titles (SQL injection, XSS, backslashes, emoji, 10 000-char string) 4f encodeNote + decodeNote both complete without throwing
Missing required fields → safe defaults 4g title='Untitled', tags=[], folder=''
No id field → fallbackId used 4g variant note.id === 'my-fallback'
12 extreme encode→decode round-trips 4h decoded.content.trim() === original.content.trim() for all
Empty file is valid (not treated as corruption) 4i result === ''
1 000 randomly-mutated strings → no exception stress None of 5 000 mutation variants throw

The 1 000-iteration stress loop applies five distinct mutations to a valid encoded note per iteration: truncation to a random length, doubling, ASCII-scrambling, null-byte prepending, and full reversal. All 5 000 variants must complete without an unhandled exception — the only permissible return values are a Note object or null.


Last updated: May 2026 — reflects the Reliability & Trust Layer implementation and full stress test suite.

Clone this wiki locally