fix: disk-first writes in useEntryActions, document three-layer model

Move updateEntry() calls after handleUpdateFrontmatter/handleDeleteProperty
in handleCustomizeType, handleRenameSection, and handleToggleTypeVisibility
so React state only updates after the disk write succeeds. This prevents
state-disk divergence when writes fail.

Expand ARCHITECTURE.md "Three representations, one authority" section with
ownership rules, invariants table, and recovery mechanisms. Add
reload_vault_entry to the commands table (62 total).

Add Playwright smoke test for Reload Vault in Cmd+K palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-09 00:42:42 +01:00
parent 4012a65a73
commit dc92fd1f57
4 changed files with 123 additions and 10 deletions

View File

@@ -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/<hash>.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 |

View File

@@ -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<typeof vi.fn>) {
return renderHook(() =>

View File

@@ -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])

View File

@@ -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')
})
})