diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 653044d9..498e5e85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,12 +25,28 @@ Notes are not just documents — they are nodes in a structured graph of people, ### Three representations, one authority Vault data exists in three forms simultaneously: -1. **Filesystem** — the `.md` files. This is the authority. -2. **Cache** — an index for fast startup. Always reconstructible from the filesystem. -3. **React state** — the in-memory view during a session. Always derived from the cache. +1. **Filesystem** — the `.md` files on disk. This is the single source of truth. +2. **Cache** — `~/.laputa/cache/.json`, an index for fast startup. Always reconstructible from the filesystem. +3. **React state** — the in-memory `VaultEntry[]` during a session. Always derived from the cache or filesystem. These must never diverge permanently. If they do, the filesystem wins and the cache/state are rebuilt. +#### Ownership rules + +| Layer | Owner | Writes to | Reads from | +|-------|-------|-----------|------------| +| Filesystem | Tauri Rust commands (`save_note_content`, `update_frontmatter`, etc.) | Disk | — | +| Cache | `scan_vault_cached()` in `vault/cache.rs` | `~/.laputa/cache/` | Filesystem + git diff | +| React state | `useVaultLoader` + `useEntryActions` + `useNoteActions` | In-memory `entries` | Cache (on load), filesystem (on reload) | + +#### Invariants + +1. **Disk-first writes**: All functions that change vault data must write to disk (via Tauri IPC) *before* updating React state. This ensures that if the disk write fails, React state remains consistent with what's actually on disk. +2. **Optimistic UI with rollback**: Where responsiveness matters (e.g. `persistOptimistic` in `useNoteActions`), state may update before disk confirmation — but a failure callback must revert the optimistic state. +3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update. +4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") re-scans the filesystem and replaces all React state. The `reload_vault_entry` Tauri command can also re-read a single file. +5. **Cache is disposable**: Deleting the cache file forces a full rescan on next startup. The cache never contains data that doesn't exist on the filesystem. + ## Tech Stack | Layer | Technology | Version | @@ -79,7 +95,7 @@ These must never diverge permanently. If they do, the filesystem wins and the ca │ Tauri IPC│ Vite Proxy / WS │ │ ┌──────────────▼────┐ ┌──▼────────────────────────────────┐ │ │ │ Rust Backend │ │ External Services │ │ -│ │ lib.rs → 61 cmds │ │ Anthropic API (Claude chat) │ │ +│ │ lib.rs → 62 cmds │ │ Anthropic API (Claude chat) │ │ │ │ vault/ │ │ Claude CLI (agent subprocess) │ │ │ │ frontmatter/ │ │ MCP Server (ws://9710, 9711) │ │ │ │ git/ │ │ qmd (search/indexing engine) │ │ @@ -498,13 +514,13 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing | | `ai_chat.rs` | Direct Anthropic API client (non-streaming, for Tauri builds) | | `mcp.rs` | MCP server spawning + config registration | -| `commands.rs` | All 61 Tauri command handlers | +| `commands.rs` | All 62 Tauri command handlers | | `settings.rs` | App settings persistence | | `vault_config.rs` | Per-vault UI config | | `vault_list.rs` | Vault list persistence | | `menu.rs` | Native macOS menu bar | -## Tauri IPC Commands (61 total) +## Tauri IPC Commands (62 total) ### Vault Operations @@ -518,6 +534,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `batch_archive_notes` | Archive multiple notes | | `batch_trash_notes` | Trash multiple notes | | `purge_trash` | Delete notes trashed >30 days ago | +| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` | | `check_vault_exists` | Check if vault path exists | | `create_getting_started_vault` | Bootstrap demo vault | diff --git a/src/hooks/useEntryActions.test.ts b/src/hooks/useEntryActions.test.ts index 955e0292..5e8ed230 100644 --- a/src/hooks/useEntryActions.test.ts +++ b/src/hooks/useEntryActions.test.ts @@ -343,6 +343,68 @@ describe('useEntryActions', () => { }) }) + describe('failed disk writes do not update React state', () => { + it('handleCustomizeType does not update entry when frontmatter write fails', async () => { + const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' }) + handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full')) + const { result } = setup([typeEntry]) + + await expect( + act(() => result.current.handleCustomizeType('Recipe', 'star', 'red')) + ).rejects.toThrow('disk full') + + expect(updateEntry).not.toHaveBeenCalled() + }) + + it('handleRenameSection does not update entry when frontmatter write fails', async () => { + const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' }) + handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full')) + const { result } = setup([typeEntry]) + + await expect( + act(() => result.current.handleRenameSection('Recipe', 'Dishes')) + ).rejects.toThrow('disk full') + + expect(updateEntry).not.toHaveBeenCalled() + }) + + it('handleRenameSection does not update entry when delete property fails', async () => { + const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: 'Dishes' }) + handleDeleteProperty.mockRejectedValueOnce(new Error('disk full')) + const { result } = setup([typeEntry]) + + await expect( + act(() => result.current.handleRenameSection('Recipe', '')) + ).rejects.toThrow('disk full') + + expect(updateEntry).not.toHaveBeenCalled() + }) + + it('handleToggleTypeVisibility does not update entry when frontmatter write fails (hide)', async () => { + const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: null }) + handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full')) + const { result } = setup([typeEntry]) + + await expect( + act(() => result.current.handleToggleTypeVisibility('Journal')) + ).rejects.toThrow('disk full') + + expect(updateEntry).not.toHaveBeenCalled() + }) + + it('handleToggleTypeVisibility does not update entry when delete property fails (show)', async () => { + const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: false }) + handleDeleteProperty.mockRejectedValueOnce(new Error('disk full')) + const { result } = setup([typeEntry]) + + await expect( + act(() => result.current.handleToggleTypeVisibility('Journal')) + ).rejects.toThrow('disk full') + + expect(updateEntry).not.toHaveBeenCalled() + }) + }) + describe('onBeforeAction callback', () => { function setupWithBeforeAction(onBeforeAction: ReturnType) { return renderHook(() => diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index 1de9fe3d..e3e36d4b 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -61,9 +61,9 @@ export function useEntryActions({ const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => { const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry) - updateEntry(typeEntry.path, { icon, color }) await handleUpdateFrontmatter(typeEntry.path, 'icon', icon) await handleUpdateFrontmatter(typeEntry.path, 'color', color) + updateEntry(typeEntry.path, { icon, color }) onFrontmatterPersisted?.() }, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted]) @@ -86,23 +86,23 @@ export function useEntryActions({ const handleRenameSection = useCallback(async (typeName: string, label: string) => { const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry) const trimmed = label.trim() - updateEntry(typeEntry.path, { sidebarLabel: trimmed || null }) if (trimmed) { await handleUpdateFrontmatter(typeEntry.path, 'sidebar label', trimmed) } else { await handleDeleteProperty(typeEntry.path, 'sidebar label') } + updateEntry(typeEntry.path, { sidebarLabel: trimmed || null }) onFrontmatterPersisted?.() }, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted]) const handleToggleTypeVisibility = useCallback(async (typeName: string) => { const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry) if (typeEntry.visible === false) { - updateEntry(typeEntry.path, { visible: null }) await handleDeleteProperty(typeEntry.path, 'visible') + updateEntry(typeEntry.path, { visible: null }) } else { - updateEntry(typeEntry.path, { visible: false }) await handleUpdateFrontmatter(typeEntry.path, 'visible', false) + updateEntry(typeEntry.path, { visible: false }) } onFrontmatterPersisted?.() }, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted]) diff --git a/tests/smoke/three-source-of-truth.spec.ts b/tests/smoke/three-source-of-truth.spec.ts new file mode 100644 index 00000000..2b5f8003 --- /dev/null +++ b/tests/smoke/three-source-of-truth.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test' +import { openCommandPalette, findCommand, executeCommand } from './helpers' + +test.describe('Three source of truth', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('Reload Vault command appears in Cmd+K palette', async ({ page }) => { + await openCommandPalette(page) + const found = await findCommand(page, 'reload') + expect(found).toBe(true) + }) + + test('Reload Vault command is executable', async ({ page }) => { + await openCommandPalette(page) + await executeCommand(page, 'reload vault') + // After execution, palette should close and app should still render + await expect(page.locator('input[placeholder="Type a command..."]')).not.toBeVisible() + // Sidebar should still be visible after reload + await expect(page.locator('[data-testid="sidebar"], nav, aside').first()).toBeVisible() + }) + + test('Reload Vault findable by keyword "rescan"', async ({ page }) => { + await openCommandPalette(page) + // Type a keyword synonym — the command should appear as selected result + await page.locator('input[placeholder="Type a command..."]').fill('rescan') + const match = page.locator('[data-selected="true"]').first() + await match.waitFor({ timeout: 2_000 }) + const text = await match.textContent() + expect(text?.toLowerCase()).toContain('reload') + }) +})