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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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])
|
||||
|
||||
34
tests/smoke/three-source-of-truth.spec.ts
Normal file
34
tests/smoke/three-source-of-truth.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user