diff --git a/apps/desktop-tauri/package.json b/apps/desktop-tauri/package.json index 790f74ad7..9f4192ccc 100644 --- a/apps/desktop-tauri/package.json +++ b/apps/desktop-tauri/package.json @@ -7,6 +7,8 @@ "type": "module", "scripts": { "dev": "tauri dev", + "dev:a": "MEMRY_DEVICE=A tauri dev", + "dev:b": "MEMRY_DEVICE=B tauri dev", "build": "tauri build", "preview": "vite preview", "lint": "pnpm exec eslint --cache .", @@ -17,12 +19,16 @@ "test:e2e": "playwright test", "cargo:check": "cd src-tauri && cargo check", "cargo:clippy": "cd src-tauri && cargo clippy -- -D warnings", - "cargo:test": "cd src-tauri && cargo test", + "cargo:test": "cd src-tauri && cargo test --features test-helpers", "cargo:fmt": "cd src-tauri && cargo fmt", "bindings:generate": "tsx scripts/generate-bindings.ts", "bindings:check": "tsx scripts/check-bindings.ts", "capability:check": "tsx scripts/capability-sanity-check.ts", - "port:audit": "tsx scripts/port-audit.ts" + "port:audit": "tsx scripts/port-audit.ts", + "command:parity": "tsx scripts/command-parity-audit.ts", + "db:reset": "bash scripts/dev-reset.sh", + "db:new-migration": "tsx scripts/new-migration.ts", + "db:schema-diff": "tsx scripts/schema-diff.ts" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.58", @@ -147,6 +153,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.2.2", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", @@ -154,6 +161,7 @@ "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", "autoprefixer": "^10.4.24", + "better-sqlite3": "^12.6.2", "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/apps/desktop-tauri/scripts/command-parity-audit.test.ts b/apps/desktop-tauri/scripts/command-parity-audit.test.ts new file mode 100644 index 000000000..c362de7dd --- /dev/null +++ b/apps/desktop-tauri/scripts/command-parity-audit.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' + +import { + extractBindingsCommands, + extractElectronChannels, + extractForwarderDomains, + extractGenerateHandlerCommands, + extractInvokeLiterals, + extractMockRouteKeys, +} from './command-parity-audit' + +describe('extractInvokeLiterals', () => { + it('returns the literal string passed to invoke()', () => { + // #given a renderer file + const src = "await invoke('settings_get', { key: 'a' })" + + // #when scanning for literals + const found = extractInvokeLiterals(src) + + // #then the command name is captured + expect(found).toEqual(['settings_get']) + }) + + it('captures generic-typed invoke(name)', () => { + // #given a renderer file using the typed invoke + const src = "const x = await invoke('notes_list', { folder })" + + // #when scanning for literals + const found = extractInvokeLiterals(src) + + // #then the command name is captured + expect(found).toEqual(['notes_list']) + }) + + it('ignores non-string-literal invoke calls', () => { + // #given dynamic invoke usage + const src = 'await invoke(name, args)' + + // #when scanning for literals + const found = extractInvokeLiterals(src) + + // #then dynamic calls do not produce a literal + expect(found).toEqual([]) + }) +}) + +describe('extractForwarderDomains', () => { + it('captures the domain prefix from createInvokeForwarder', () => { + // #given a service module wires a forwarder + const src = "createInvokeForwarder('notes')" + + // #when scanning for forwarder domains + const found = extractForwarderDomains(src) + + // #then the domain prefix is captured + expect(found).toEqual(['notes']) + }) +}) + +describe('extractMockRouteKeys', () => { + it('returns top-level route keys from a Routes object literal', () => { + // #given a mock module with two top-level routes and one nested object + const src = [ + "export const updaterRoutes: MockRouteMap = {", + ' updater_get_state: async () => state,', + ' updater_check_for_updates: async () => state,', + ' meta: { unrelated: 1 }', + '}', + ].join('\n') + + // #when extracting keys + const found = extractMockRouteKeys(src) + + // #then only the top-level keys with the route shape come back + expect(found.sort()).toEqual(['meta', 'updater_check_for_updates', 'updater_get_state']) + }) +}) + +describe('extractGenerateHandlerCommands', () => { + it('extracts the last segment of every entry in generate_handler!', () => { + // #given a Tauri lib.rs handler list + const src = [ + 'tauri::generate_handler![', + ' commands::settings::settings_get,', + ' commands::settings::settings_set,', + ' commands::lifecycle::notify_flush_done,', + ']', + ].join('\n') + + // #when parsing the macro body + const found = extractGenerateHandlerCommands(src) + + // #then we get the bare command names + expect(found.sort()).toEqual(['notify_flush_done', 'settings_get', 'settings_set']) + }) +}) + +describe('extractBindingsCommands', () => { + it('captures __TAURI_INVOKE("name") calls from the generated bindings', () => { + // #given a specta-generated bindings snippet + const src = ` + settingsGet: () => __TAURI_INVOKE("settings_get", { input }), + notifyFlushDone: () => __TAURI_INVOKE("notify_flush_done"), + ` + + // #when parsing it + const found = extractBindingsCommands(src) + + // #then both names are reported + expect(found.sort()).toEqual(['notify_flush_done', 'settings_get']) + }) +}) + +describe('extractElectronChannels', () => { + it('captures kebab-style "domain:method" entries from generated-ipc-invoke-map.ts', () => { + // #given a snippet of the Electron generated map + const src = [ + 'export interface MainIpcInvokeHandlers {', + ' "auth:request-otp": (...args: []) => Awaited', + ' "calendar:list-events": (...args: []) => Awaited', + '}', + ].join('\n') + + // #when extracting channels + const found = extractElectronChannels(src) + + // #then both channels surface + expect(found.sort()).toEqual(['auth:request-otp', 'calendar:list-events']) + }) +}) diff --git a/apps/desktop-tauri/scripts/command-parity-audit.ts b/apps/desktop-tauri/scripts/command-parity-audit.ts new file mode 100644 index 000000000..d8841d049 --- /dev/null +++ b/apps/desktop-tauri/scripts/command-parity-audit.ts @@ -0,0 +1,540 @@ +#!/usr/bin/env tsx +/** + * Command parity audit — classifies every Tauri renderer command and reports + * gaps relative to Electron's IPC surface. + * + * Inputs scanned: + * - Electron contract surfaces: + * packages/contracts/src/** (channel constant unions) + * apps/desktop/src/preload/** (preload api shape — not enumerated keys) + * apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts (every Electron channel) + * - Tauri renderer: + * apps/desktop-tauri/src/** literal `invoke('xxx', ...)` calls + * apps/desktop-tauri/src/** `createInvokeForwarder('domain')` use + * - Tauri mocks: + * apps/desktop-tauri/src/lib/ipc/mocks/.ts route maps + * - Real Rust: + * apps/desktop-tauri/src-tauri/src/commands/** (#[tauri::command] fns) + * apps/desktop-tauri/src-tauri/src/lib.rs (generate_handler![]) + * apps/desktop-tauri/src/generated/bindings.ts (specta export) + * + * Classification per Tauri-renderer command: + * - real — Rust handler + generated binding (production path) + * - mocked — served by mock router until Rust lands + * - renderer-only — invoked by renderer but no mock + no real (FAIL) + * - retired — known deferred name with no live call site + * - deferred: — explicit deferral entry in DEFERRED ledger + * + * M2 invariants: + * - settings_get / settings_set / settings_list MUST be `real`. + * - notify_flush_done MUST be `real` or explicitly `deferred:M8.0`. + * - Updater renderer commands (updater_get_state, updater_check_for_updates, + * updater_download_update, updater_quit_and_install) MUST have matching + * mock routes — old `updater_check`/`updater_download`/`updater_install` + * names are forbidden unless listed in DEFERRED. + * - No literal renderer invoke can be unclassified. + */ +import { readFileSync, readdirSync, statSync, globSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const TAURI_ROOT = resolve(__dirname, '..') +const REPO_ROOT = resolve(TAURI_ROOT, '../..') +const RENDERER_SRC = resolve(TAURI_ROOT, 'src') +const MOCKS_DIR = resolve(RENDERER_SRC, 'lib/ipc/mocks') +const RUST_SRC = resolve(TAURI_ROOT, 'src-tauri/src') +const ELECTRON_INVOKE_MAP = resolve( + REPO_ROOT, + 'apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts', +) +const BINDINGS_FILE = resolve(RENDERER_SRC, 'generated/bindings.ts') + +/** + * M2 deferral ledger — every renderer literal-invoke that has no mock route + * yet AND no Rust handler. Tagging each entry with a milestone keeps the + * carry-forward bookkeeping honest: as later milestones land mocks/Rust + * handlers, entries here graduate out, and the audit catches new renderer + * calls that lack any classification. + * + * Format: command → milestone string (informational; rendered in report). + */ +const DEFERRED: Record = { + // Logging forwarding lives behind a thin Rust shim in M8.0 lifecycle work. + logging_forward: 'M8.0', + + // M3 — vault FS + per-domain CRUD that wasn't covered by M1 mocks. + bookmarks_toggle: 'M3', + bookmarks_is_bookmarked: 'M3', + bookmarks_reorder: 'M3', + inbox_bulk_snooze: 'M3', + inbox_bulk_tag: 'M3', + inbox_preview_link: 'M3', + inbox_track_suggestion: 'M3', + folder_view_delete_view: 'M3', + folder_view_folder_exists: 'M3', + folder_view_get_folder_suggestions: 'M3', + folder_view_get_views: 'M3', + folder_view_list_with_properties: 'M3', + folder_view_set_config: 'M3', + folder_view_set_view: 'M3', + graph_get_data: 'M3', + graph_get_local: 'M3', + notes_create_folder: 'M3', + notes_open_external: 'M3', + notes_reveal_in_finder: 'M3', + reminders_bulk_dismiss: 'M3', + reminders_count_pending: 'M3', + reminders_dismiss: 'M3', + reminders_get_due: 'M3', + reminders_get_for_target: 'M3', + reminders_get_upcoming: 'M3', + search_add_reason: 'M3', + search_clear_reasons: 'M3', + search_get_all_tags: 'M3', + search_get_reasons: 'M3', + search_get_stats: 'M3', + search_quick: 'M3', + search_rebuild_index: 'M3', + tags_delete_tag: 'M3', + tags_merge_tag: 'M3', + tags_rename_tag: 'M3', + tags_update_tag_color: 'M3', + templates_duplicate: 'M3', + + // M3 — sub-domain settings keys carry forward to per-section settings work. + settings_download_voice_model: 'M3', + settings_get_ai_model_status: 'M3', + settings_get_calendar_google_settings: 'M3', + settings_get_editor_settings: 'M3', + settings_get_graph_settings: 'M3', + settings_get_keyboard_settings: 'M3', + settings_get_note_editor_settings: 'M3', + settings_get_task_settings: 'M3', + settings_get_voice_model_status: 'M3', + settings_get_voice_recording_readiness: 'M3', + settings_get_voice_transcription_open_ai_key_status: 'M3', + settings_get_voice_transcription_settings: 'M3', + settings_load_ai_model: 'M3', + settings_register_global_capture: 'M3', + settings_reindex_embeddings: 'M3', + settings_reset_keyboard_settings: 'M3', + settings_set_calendar_google_settings: 'M3', + settings_set_editor_settings: 'M3', + settings_set_graph_settings: 'M3', + settings_set_keyboard_settings: 'M3', + settings_set_note_editor_settings: 'M3', + settings_set_task_settings: 'M3', + settings_set_voice_transcription_open_ai_key: 'M3', + settings_set_voice_transcription_settings: 'M3', + + // M4 — auth + crypto + linking lifecycle. + account_get_recovery_key: 'M4', + crypto_rotate_keys: 'M4', + sync_auth_logout: 'M4', + sync_linking_approve_linking: 'M4', + sync_linking_complete_linking_qr: 'M4', + sync_linking_generate_linking_qr: 'M4', + sync_linking_get_linking_sas: 'M4', + sync_linking_link_via_qr: 'M4', + sync_linking_link_via_recovery: 'M4', + sync_setup_get_recovery_phrase: 'M4', + + // M5 — CRDT engine. + sync_crdt_apply_update: 'M5', + sync_crdt_close_doc: 'M5', + sync_crdt_open_doc: 'M5', + sync_crdt_sync_step1: 'M5', + sync_crdt_sync_step2: 'M5', + + // M6 — sync ops orchestration. + sync_ops_get_history: 'M6', + sync_ops_get_storage_breakdown: 'M6', + sync_ops_pause: 'M6', + sync_ops_resume: 'M6', + sync_ops_trigger_sync: 'M6', + + // M8.0 — lifecycle / native quick-capture window. + quick_capture_close: 'M8.0', + quick_capture_get_clipboard: 'M8.0', + quick_capture_open_settings: 'M8.0', + quick_capture_resize: 'M8.0', + show_context_menu: 'M8.0', +} + +/** Forbid these legacy renderer/mock names — Phase G rename enforcement. */ +const FORBIDDEN_NAMES = new Set(['updater_check', 'updater_download', 'updater_install']) + +/** M2 hard requirements: these MUST resolve to `real`. */ +const REQUIRED_REAL = new Set(['settings_get', 'settings_set', 'settings_list']) + +/** M2 shell-neutral wrappers: real or deferred-with-classification. */ +const SHELL_NEUTRAL_REAL_OR_DEFERRED = new Set(['notify_flush_done']) + +/** M2 updater renderer surface — every name here MUST be present as a mock. */ +const REQUIRED_UPDATER_MOCKS = new Set([ + 'updater_get_state', + 'updater_check_for_updates', + 'updater_download_update', + 'updater_quit_and_install', +]) + +type Classification = + | { kind: 'real' } + | { kind: 'mocked' } + | { kind: 'deferred'; milestone: string } + | { kind: 'renderer-only' } + | { kind: 'retired' } + +export interface AuditResult { + rendererCalls: Set + mockedCommands: Set + realCommands: Set + bindingsCommands: Set + forwarderDomains: Set + electronChannels: Set + classifications: Map + errors: string[] + warnings: string[] +} + +function readUtf8(path: string): string { + return readFileSync(path, 'utf8') +} + +function listFilesRec(dir: string, exts: string[]): string[] { + const out: string[] = [] + const stack: string[] = [dir] + while (stack.length) { + const cur = stack.pop()! + let entries: string[] + try { + entries = readdirSync(cur) + } catch { + continue + } + for (const e of entries) { + const full = resolve(cur, e) + let st + try { + st = statSync(full) + } catch { + continue + } + if (st.isDirectory()) { + if (e === 'node_modules') continue + stack.push(full) + continue + } + if (exts.some((x) => full.endsWith(x))) out.push(full) + } + } + return out +} + +function isProductionFile(path: string): boolean { + return !/\.test\.(ts|tsx)$/.test(path) +} + +/** Extract every literal-string `invoke('cmd', ...)` argument. */ +export function extractInvokeLiterals(source: string): string[] { + const out = new Set() + const re = /\binvoke\s*(?:<[^>]+>)?\s*\(\s*['"]([a-z][a-z0-9_]+)['"]/g + for (const m of source.matchAll(re)) out.add(m[1]) + return [...out] +} + +/** Extract `createInvokeForwarder('domain')` domain prefixes. */ +export function extractForwarderDomains(source: string): string[] { + const out = new Set() + const re = /createInvokeForwarder\s*<[^>]+>\s*\(\s*['"]([a-z][a-z0-9_]+)['"]/g + for (const m of source.matchAll(re)) out.add(m[1]) + return [...out] +} + +/** + * Pull route keys from a mock module. Looks for the `Routes: + * MockRouteMap = { ... }` literal and extracts identifier-like keys at the top + * level. Conservative: only keys with `name: async`, `name: (` or `name,` + * forms count. + */ +export function extractMockRouteKeys(source: string): string[] { + const out = new Set() + const objMatch = source.match(/Routes\s*:\s*MockRouteMap\s*=\s*\{([\s\S]*?)\n\}/) + if (!objMatch) return [] + const body = objMatch[1] + let depth = 0 + // Strip nested braces so only top-level keys leak through. + const flat: string[] = [] + for (const ch of body) { + if (ch === '{') depth++ + else if (ch === '}') depth-- + if (depth === 0) flat.push(ch) + } + const cleaned = flat.join('') + const keyRe = /(?:^|[\n,])\s*([a-z][a-z0-9_]+)\s*:/g + for (const m of cleaned.matchAll(keyRe)) out.add(m[1]) + return [...out] +} + +/** Parse `tauri::generate_handler![path::name, ...]` into command names. */ +export function extractGenerateHandlerCommands(source: string): string[] { + const out = new Set() + const macroMatch = source.match(/generate_handler!\s*\[([\s\S]*?)\]/) + if (!macroMatch) return [] + const body = macroMatch[1] + for (const tok of body.split(',')) { + const trimmed = tok.trim().replace(/[\s;]+$/, '') + if (!trimmed) continue + const last = trimmed.split('::').pop()! + if (/^[a-z][a-z0-9_]+$/.test(last)) out.add(last) + } + return [...out] +} + +/** Parse `__TAURI_INVOKE("name"` from the specta-generated bindings file. */ +export function extractBindingsCommands(source: string): string[] { + const out = new Set() + const re = /__TAURI_INVOKE\(\s*"([a-z][a-z0-9_]+)"/g + for (const m of source.matchAll(re)) out.add(m[1]) + return [...out] +} + +/** Pull Electron channel names (kebab-style) from generated-ipc-invoke-map.ts. */ +export function extractElectronChannels(source: string): string[] { + const out = new Set() + const re = /^\s*"([a-z][a-z0-9-]*:[a-z0-9-]+)"\s*:/gm + for (const m of source.matchAll(re)) out.add(m[1]) + return [...out] +} + +function gatherRendererSurfaces(): { + invokes: Set + forwarders: Set +} { + const files = listFilesRec(RENDERER_SRC, ['.ts', '.tsx']).filter(isProductionFile) + const invokes = new Set() + const forwarders = new Set() + for (const f of files) { + const src = readUtf8(f) + for (const c of extractInvokeLiterals(src)) invokes.add(c) + for (const d of extractForwarderDomains(src)) forwarders.add(d) + } + return { invokes, forwarders } +} + +function gatherMockCommands(): Set { + const out = new Set() + const files = readdirSync(MOCKS_DIR) + .filter((f) => f.endsWith('.ts') && !/\.test\.ts$/.test(f) && f !== 'types.ts' && f !== 'index.ts') + .map((f) => resolve(MOCKS_DIR, f)) + for (const f of files) { + const src = readUtf8(f) + for (const k of extractMockRouteKeys(src)) out.add(k) + } + return out +} + +function gatherRealCommands(): { real: Set; bindings: Set } { + const real = new Set() + const libRsPath = resolve(RUST_SRC, 'lib.rs') + if (statSync(libRsPath).isFile()) { + for (const c of extractGenerateHandlerCommands(readUtf8(libRsPath))) real.add(c) + } + const bindings = new Set() + if (globSync(BINDINGS_FILE).length > 0) { + for (const c of extractBindingsCommands(readUtf8(BINDINGS_FILE))) bindings.add(c) + } + return { real, bindings } +} + +function classify(input: { + rendererCalls: Set + mocked: Set + real: Set +}): Map { + const out = new Map() + for (const cmd of input.rendererCalls) { + if (input.real.has(cmd)) { + out.set(cmd, { kind: 'real' }) + continue + } + if (DEFERRED[cmd]) { + out.set(cmd, { kind: 'deferred', milestone: DEFERRED[cmd] }) + continue + } + if (input.mocked.has(cmd)) { + out.set(cmd, { kind: 'mocked' }) + continue + } + out.set(cmd, { kind: 'renderer-only' }) + } + // Mock-only entries (no renderer call site) — useful for catching dead routes + // but not failing. + for (const cmd of input.mocked) { + if (out.has(cmd)) continue + out.set(cmd, { kind: 'mocked' }) + } + // Deferred entries that may not be called by renderer yet (e.g. logging_forward). + for (const cmd of Object.keys(DEFERRED)) { + if (out.has(cmd)) continue + out.set(cmd, { kind: 'deferred', milestone: DEFERRED[cmd] }) + } + return out +} + +export function runAudit(): AuditResult { + const { invokes, forwarders } = gatherRendererSurfaces() + const mocked = gatherMockCommands() + const { real, bindings } = gatherRealCommands() + const electron = (() => { + try { + return new Set(extractElectronChannels(readUtf8(ELECTRON_INVOKE_MAP))) + } catch { + return new Set() + } + })() + + const classifications = classify({ rendererCalls: invokes, mocked, real }) + const errors: string[] = [] + const warnings: string[] = [] + + for (const cmd of REQUIRED_REAL) { + const c = classifications.get(cmd) + if (!c || c.kind !== 'real') { + errors.push(`required-real "${cmd}" is ${c?.kind ?? 'absent'} (M2 invariant)`) + } + if (!bindings.has(cmd)) { + errors.push(`required-real "${cmd}" missing from generated bindings`) + } + } + + for (const cmd of SHELL_NEUTRAL_REAL_OR_DEFERRED) { + const c = classifications.get(cmd) + if (!c) { + errors.push(`shell-neutral wrapper "${cmd}" not found in any surface`) + continue + } + if (c.kind === 'real') continue + if (c.kind === 'deferred') continue + errors.push( + `shell-neutral wrapper "${cmd}" must be real or deferred, got ${c.kind}`, + ) + } + + for (const cmd of REQUIRED_UPDATER_MOCKS) { + if (!mocked.has(cmd) && !real.has(cmd)) { + errors.push(`updater renderer surface "${cmd}" missing from mocks and real handlers`) + } + } + + for (const cmd of FORBIDDEN_NAMES) { + if (mocked.has(cmd)) { + errors.push(`forbidden legacy mock route "${cmd}" still registered`) + } + if (invokes.has(cmd)) { + errors.push(`forbidden legacy command "${cmd}" still invoked from renderer`) + } + } + + for (const [cmd, c] of classifications) { + if (c.kind === 'renderer-only') { + errors.push(`unclassified renderer command "${cmd}" — add a mock or deferral entry`) + } + } + + // Forwarder domains MUST have a corresponding mock module (the forwarder + // proxies any method on the domain to invoke('domain_method'), so without a + // mock module those calls 404 at runtime). + for (const domain of forwarders) { + const mockFile = resolve(MOCKS_DIR, `${domain.replace(/_/g, '-')}.ts`) + const altFile = resolve(MOCKS_DIR, `${domain}.ts`) + if (!fileExists(mockFile) && !fileExists(altFile)) { + warnings.push( + `forwarder domain "${domain}" has no mocks/${domain}.ts module — runtime 404 risk`, + ) + } + } + + // Mock routes that don't correspond to any Tauri renderer call site or + // Rust command — informational, not failure. + for (const cmd of mocked) { + if (real.has(cmd)) continue + if (invokes.has(cmd)) continue + // A mock keyed by a forwarder method call name like `tasks_list` is + // exercised through the forwarder proxy; presence of the matching + // forwarder domain is enough to consider it live. + const domainPrefix = [...forwarders].find((d) => cmd.startsWith(`${d}_`) || cmd === d) + if (domainPrefix) continue + warnings.push(`mock route "${cmd}" has no renderer call site or forwarder domain`) + } + + return { + rendererCalls: invokes, + mockedCommands: mocked, + realCommands: real, + bindingsCommands: bindings, + forwarderDomains: forwarders, + electronChannels: electron, + classifications, + errors, + warnings, + } +} + +function fileExists(path: string): boolean { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +function summarize(result: AuditResult): void { + const counts = { real: 0, mocked: 0, deferred: 0, 'renderer-only': 0, retired: 0 } + for (const c of result.classifications.values()) { + counts[c.kind] = (counts[c.kind] ?? 0) + 1 + } + + console.log('Command parity audit') + console.log('--------------------') + console.log(`Renderer literal invokes : ${result.rendererCalls.size}`) + console.log(`Forwarder domains : ${result.forwarderDomains.size}`) + console.log(`Mock routes : ${result.mockedCommands.size}`) + console.log(`Rust real commands : ${result.realCommands.size}`) + console.log(`Generated bindings : ${result.bindingsCommands.size}`) + console.log(`Electron channels (ref) : ${result.electronChannels.size}`) + console.log('') + console.log( + `Classifications: real=${counts.real} mocked=${counts.mocked} ` + + `deferred=${counts.deferred} renderer-only=${counts['renderer-only']} ` + + `retired=${counts.retired}`, + ) + + if (result.warnings.length) { + console.log('\nWarnings:') + for (const w of result.warnings) console.log(` • ${w}`) + } + if (result.errors.length) { + console.log('\nErrors:') + for (const e of result.errors) console.log(` ✘ ${e}`) + } +} + +function main(): void { + const result = runAudit() + summarize(result) + if (result.errors.length > 0) { + console.log(`\nFAIL: ${result.errors.length} error(s) — see above.`) + process.exit(1) + } + console.log('\nOK: command parity audit clean.') +} + +const invokedAsScript = process.argv[1] + ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false + +if (invokedAsScript) { + main() +} diff --git a/apps/desktop-tauri/scripts/dev-reset.sh b/apps/desktop-tauri/scripts/dev-reset.sh new file mode 100755 index 000000000..0e49c79b8 --- /dev/null +++ b/apps/desktop-tauri/scripts/dev-reset.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wipe memry app data for the selected device profile (or all profiles). +# +# Usage: +# ./scripts/dev-reset.sh # resets default profile +# ./scripts/dev-reset.sh A # resets profile A +# ./scripts/dev-reset.sh --all # resets every memry-* profile + +BASE="$HOME/Library/Application Support/com.memry.memry" + +if [[ "${1:-}" == "--all" ]]; then + echo "Wiping $BASE" + rm -rf "$BASE" + exit 0 +fi + +DEVICE="${1:-default}" +TARGET="$BASE/memry-$DEVICE" +echo "Wiping $TARGET" +rm -rf "$TARGET" +echo "Done. Next app launch will re-apply migrations." diff --git a/apps/desktop-tauri/scripts/new-migration.ts b/apps/desktop-tauri/scripts/new-migration.ts new file mode 100644 index 000000000..ca1bd227b --- /dev/null +++ b/apps/desktop-tauri/scripts/new-migration.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env tsx +/** + * Create a new migration file with the next sequential number. + * + * Usage: + * pnpm db:new-migration "add_widgets_table" + * + * NOTE: After creating the file, you must manually add an entry to the + * EMBEDDED list in `src-tauri/src/db/migrations.rs` — this script prints + * the exact line to insert. + */ +import { existsSync, readdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const MIGRATIONS_DIR = resolve(__dirname, '../src-tauri/migrations') +const MANIFEST_PATH = resolve(__dirname, '../src-tauri/src/db/migrations.rs') + +function nextNumber(): string { + const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')) + if (files.length === 0) return '0001' + const max = Math.max(...files.map((f) => Number(f.slice(0, 4)))) + return String(max + 1).padStart(4, '0') +} + +function main() { + const name = process.argv[2] + if (!name) { + console.error('Usage: pnpm db:new-migration ""') + process.exit(1) + } + if (!/^[a-z0-9_]+$/.test(name)) { + console.error('Name must be snake_case, lowercase, digits or underscores only.') + process.exit(1) + } + + const num = nextNumber() + const filename = `${num}_${name}.sql` + const target = resolve(MIGRATIONS_DIR, filename) + + if (existsSync(target)) { + console.error(`${filename} already exists.`) + process.exit(1) + } + + writeFileSync( + target, + `-- ${filename}\n-- TODO: describe what this migration changes\n\n`, + ) + + console.log(`Created ${filename}`) + console.log( + `\nNext: add an entry to EMBEDDED in:\n ${MANIFEST_PATH}\n\n` + + ` ("${filename}", include_str!("../../migrations/${filename}")),\n\n` + + `Also bump the array length in the migration_manifest module.`, + ) +} + +main() diff --git a/apps/desktop-tauri/scripts/port-audit.test.ts b/apps/desktop-tauri/scripts/port-audit.test.ts index bb5269f82..6c84d466c 100644 --- a/apps/desktop-tauri/scripts/port-audit.test.ts +++ b/apps/desktop-tauri/scripts/port-audit.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { scanContent, isTestFile } from './port-audit' +import { scanContent, isTestFile, countMemryRefs } from './port-audit' describe('scanContent', () => { it('reports 1 window.api hit with correct line number', () => { @@ -20,17 +20,16 @@ describe('scanContent', () => { ]) }) - it('reports 1 ipcRenderer hit', () => { + it('reports both ipcRenderer and electron-import hits on the same import line', () => { // #given const content = "import { ipcRenderer } from 'electron'" // #when const hits = scanContent(content, 'fake.ts') - // #then - expect(hits).toHaveLength(1) - expect(hits[0].kind).toBe('ipcRenderer') - expect(hits[0].line).toBe(1) + // #then both Electron-era patterns surface so neither slips through audit + expect(hits).toHaveLength(2) + expect(hits.map((h) => h.kind).sort()).toEqual(['electron-import', 'ipcRenderer']) }) it('reports 1 electron-toolkit hit', () => { @@ -99,15 +98,16 @@ describe('scanContent', () => { // #when const hits = scanContent(content, 'multi.ts') - // #then - expect(hits).toHaveLength(4) + // #then line 1 matches ipcRenderer + electron-import simultaneously + expect(hits).toHaveLength(5) expect(hits.map((h) => h.kind)).toEqual([ 'ipcRenderer', + 'electron-import', 'electron-toolkit', 'window.api', 'window.electron' ]) - expect(hits.map((h) => h.line)).toEqual([1, 2, 3, 4]) + expect(hits.map((h) => h.line)).toEqual([1, 1, 2, 3, 4]) }) it('ignores ipcRenderer substring that is not a whole word', () => { @@ -120,6 +120,81 @@ describe('scanContent', () => { // #then expect(hits.filter((h) => h.kind === 'ipcRenderer')).toEqual([]) }) + + it('reports 1 electron-log hit on bare import', () => { + // #given + const content = "import log from 'electron-log/renderer'" + + // #when + const hits = scanContent(content, 'fake.ts') + + // #then + expect(hits).toHaveLength(1) + expect(hits[0].kind).toBe('electron-log') + }) + + it('reports 1 electron-log hit on plain electron-log import', () => { + // #given + const content = "import log from 'electron-log'" + + // #when + const hits = scanContent(content, 'fake.ts') + + // #then + expect(hits).toHaveLength(1) + expect(hits[0].kind).toBe('electron-log') + }) + + it('reports 1 electron-import hit for `from "electron"`', () => { + // #given + const content = "import { app } from 'electron'" + + // #when + const hits = scanContent(content, 'fake.ts') + + // #then + expect(hits).toHaveLength(1) + expect(hits[0].kind).toBe('electron-import') + }) + + it('does not flag the Tauri-safe logger module name', () => { + // #given + const content = "import { createLogger } from '@/lib/logger'" + + // #when + const hits = scanContent(content, 'fake.ts') + + // #then + expect(hits).toEqual([]) + }) +}) + +describe('countMemryRefs', () => { + it('counts every @memry/ occurrence', () => { + // #given + const content = [ + "import type { Foo } from '@memry/contracts/notes-api'", + "import { Bar } from '@memry/shared/utils'", + 'const x = 1' + ].join('\n') + + // #when + const total = countMemryRefs(content) + + // #then + expect(total).toBe(2) + }) + + it('returns 0 for clean Tauri code', () => { + // #given + const content = "import { invoke } from '@/lib/ipc/invoke'" + + // #when + const total = countMemryRefs(content) + + // #then + expect(total).toBe(0) + }) }) describe('isTestFile', () => { diff --git a/apps/desktop-tauri/scripts/port-audit.ts b/apps/desktop-tauri/scripts/port-audit.ts index 0919f506f..ed7a41d2a 100644 --- a/apps/desktop-tauri/scripts/port-audit.ts +++ b/apps/desktop-tauri/scripts/port-audit.ts @@ -2,7 +2,13 @@ import { readFileSync, globSync } from 'node:fs' import { resolve, relative, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -export type HitKind = 'window.api' | 'ipcRenderer' | 'electron-toolkit' | 'window.electron' +export type HitKind = + | 'window.api' + | 'ipcRenderer' + | 'electron-toolkit' + | 'window.electron' + | 'electron-log' + | 'electron-import' export interface Hit { file: string @@ -15,7 +21,11 @@ const PATTERNS: Array<{ kind: HitKind; regex: RegExp }> = [ { kind: 'window.api', regex: /window\.api\./ }, { kind: 'ipcRenderer', regex: /\bipcRenderer\b/ }, { kind: 'electron-toolkit', regex: /@electron-toolkit/ }, - { kind: 'window.electron', regex: /window\.electron\b/ } + { kind: 'window.electron', regex: /window\.electron\b/ }, + // Phase G hardening: catch leftover electron-log/* renderer imports. + { kind: 'electron-log', regex: /['"]electron-log(?:\/[a-z]+)?['"]/ }, + // Phase G hardening: bare `from 'electron'` / `from "electron"` imports. + { kind: 'electron-import', regex: /from\s+['"]electron['"]/ } ] /** @@ -42,6 +52,19 @@ export function isTestFile(relPath: string): boolean { return /\.test\.(ts|tsx)$/.test(relPath) } +/** + * Counts how many lines reference a `@memry/` workspace import. + * + * Phase G doesn't drive these to zero (legitimate `@memry/contracts` uses + * remain), but we track totals as a carry-forward ledger for the PR — later + * milestones graduate `@memry/rpc/*`, `@memry/db-schema/*`, and + * `@memry/shared/*` references away from the renderer. + */ +export function countMemryRefs(content: string): number { + const re = /@memry\//g + return [...content.matchAll(re)].length +} + function runCli(): void { const here = dirname(fileURLToPath(import.meta.url)) const root = resolve(here, '../src') @@ -55,9 +78,11 @@ function runCli(): void { .map((relPath) => resolve(root, relPath)) const hits: Hit[] = [] + let memryRefs = 0 for (const file of files) { const content = readFileSync(file, 'utf-8') hits.push(...scanContent(content, file)) + memryRefs += countMemryRefs(content) } console.log(`Total hits: ${hits.length}`) @@ -80,6 +105,8 @@ function runCli(): void { .forEach(([f, c]) => console.log(` ${c.toString().padStart(4)} ${relative(root, f)}`)) } + console.log(`\n@memry/* references (informational): ${memryRefs}`) + if (hits.length > 0) { process.exitCode = 1 } diff --git a/apps/desktop-tauri/scripts/schema-diff.ts b/apps/desktop-tauri/scripts/schema-diff.ts new file mode 100644 index 000000000..34c9dbf69 --- /dev/null +++ b/apps/desktop-tauri/scripts/schema-diff.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env tsx +/** + * Compare Electron's applied schema against Tauri's applied schema. + * + * Run Tauri once (in any MEMRY_DEVICE profile) so it produces a freshly + * migrated DB, then point this script at both DBs. Reports any differences + * in table set, column set per table, or index set per table. + * + * Usage: + * pnpm db:schema-diff + */ +import Database from 'better-sqlite3' + +type TableInfo = { + name: string + columns: Set + indexes: Set +} + +function introspect(path: string): Map { + const db = new Database(path, { readonly: true }) + const tables = db + .prepare( + `SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%'`, + ) + .all() as Array<{ name: string }> + + const out = new Map() + for (const { name } of tables) { + const cols = db.prepare(`PRAGMA table_info(${name})`).all() as Array<{ name: string }> + const idx = db.prepare(`PRAGMA index_list(${name})`).all() as Array<{ name: string }> + out.set(name, { + name, + columns: new Set(cols.map((c) => c.name)), + indexes: new Set(idx.map((i) => i.name)), + }) + } + db.close() + return out +} + +function diffSet(a: Set, b: Set): { onlyA: T[]; onlyB: T[] } { + return { + onlyA: [...a].filter((x) => !b.has(x)), + onlyB: [...b].filter((x) => !a.has(x)), + } +} + +function main() { + const [electronPath, tauriPath] = process.argv.slice(2) + if (!electronPath || !tauriPath) { + console.error('Usage: pnpm db:schema-diff ') + process.exit(1) + } + + const electron = introspect(electronPath) + const tauri = introspect(tauriPath) + + const tableDiff = diffSet(new Set(electron.keys()), new Set(tauri.keys())) + + let failed = false + if (tableDiff.onlyA.length > 0) { + console.log(`Tables only in Electron: ${tableDiff.onlyA.join(', ')}`) + failed = true + } + if (tableDiff.onlyB.length > 0) { + console.log(`Tables only in Tauri: ${tableDiff.onlyB.join(', ')}`) + failed = true + } + + for (const [name, tInfo] of tauri) { + const eInfo = electron.get(name) + if (!eInfo) continue + const colDiff = diffSet(eInfo.columns, tInfo.columns) + if (colDiff.onlyA.length || colDiff.onlyB.length) { + console.log(`\nTable ${name} column diff:`) + if (colDiff.onlyA.length) console.log(` only in Electron: ${colDiff.onlyA.join(', ')}`) + if (colDiff.onlyB.length) console.log(` only in Tauri: ${colDiff.onlyB.join(', ')}`) + failed = true + } + const idxDiff = diffSet(eInfo.indexes, tInfo.indexes) + if (idxDiff.onlyA.length || idxDiff.onlyB.length) { + console.log(`\nTable ${name} index diff:`) + if (idxDiff.onlyA.length) console.log(` only in Electron: ${idxDiff.onlyA.join(', ')}`) + if (idxDiff.onlyB.length) console.log(` only in Tauri: ${idxDiff.onlyB.join(', ')}`) + failed = true + } + } + + if (failed) { + console.log('\nFAIL: schemas diverge.') + process.exit(1) + } + console.log('OK: schemas identical.') +} + +main() diff --git a/apps/desktop-tauri/src-tauri/Cargo.toml b/apps/desktop-tauri/src-tauri/Cargo.toml index 70335dac2..f3805c146 100644 --- a/apps/desktop-tauri/src-tauri/Cargo.toml +++ b/apps/desktop-tauri/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ tauri = { version = "2.10", features = [] } tauri-plugin-shell = "2" # Declared for later milestones — unused at M1 but compile-verified -rusqlite = { version = "0.32", features = ["bundled", "load_extension"], default-features = false } +rusqlite = { version = "0.32", features = ["bundled", "load_extension", "chrono", "serde_json", "hooks"] } yrs = "0.21" dryoc = "0.7" tokio = { version = "1.41", features = ["full"] } @@ -29,12 +29,31 @@ tracing-appender = "0.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" +directories = "5" specta = { version = "2.0.0-rc.20", features = ["derive"] } +specta-typescript = "0.0.11" tauri-specta = { version = "2.0.0-rc.21", features = ["typescript"] } [features] custom-protocol = ["tauri/custom-protocol"] +test-helpers = [] + +[dev-dependencies] +tempfile = "3" [[bin]] name = "generate_bindings" path = "src/bin/generate_bindings.rs" + +[[bin]] +name = "bench_m2" +path = "src/bin/bench_m2.rs" +required-features = ["test-helpers"] + +[[test]] +name = "migrations_test" +required-features = ["test-helpers"] + +[[test]] +name = "settings_test" +required-features = ["test-helpers"] diff --git a/apps/desktop-tauri/src-tauri/migrations/0000_thankful_luke_cage.sql b/apps/desktop-tauri/src-tauri/migrations/0000_thankful_luke_cage.sql new file mode 100644 index 000000000..7117c8b0e --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0000_thankful_luke_cage.sql @@ -0,0 +1,101 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0000_thankful_luke_cage.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd; +-- boolean DEFAULT literals normalized to integer 0/1 for rusqlite. + +CREATE TABLE IF NOT EXISTS projects ( + id text PRIMARY KEY NOT NULL, + name text NOT NULL, + description text, + color text DEFAULT '#6366f1' NOT NULL, + icon text, + position integer DEFAULT 0 NOT NULL, + is_inbox integer DEFAULT 0 NOT NULL, + created_at text DEFAULT (datetime('now')) NOT NULL, + modified_at text DEFAULT (datetime('now')) NOT NULL, + archived_at text +); + +CREATE TABLE IF NOT EXISTS statuses ( + id text PRIMARY KEY NOT NULL, + project_id text NOT NULL, + name text NOT NULL, + color text DEFAULT '#6b7280' NOT NULL, + position integer DEFAULT 0 NOT NULL, + is_default integer DEFAULT 0 NOT NULL, + is_done integer DEFAULT 0 NOT NULL, + created_at text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE no action ON DELETE cascade +); + +CREATE INDEX IF NOT EXISTS idx_statuses_project ON statuses (project_id); + +CREATE TABLE IF NOT EXISTS tasks ( + id text PRIMARY KEY NOT NULL, + project_id text NOT NULL, + status_id text, + parent_id text, + title text NOT NULL, + description text, + priority integer DEFAULT 0 NOT NULL, + position integer DEFAULT 0 NOT NULL, + due_date text, + due_time text, + start_date text, + repeat_config text, + repeat_from text, + source_note_id text, + completed_at text, + archived_at text, + created_at text DEFAULT (datetime('now')) NOT NULL, + modified_at text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (status_id) REFERENCES statuses(id) ON UPDATE no action ON DELETE set null +); + +CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks (project_id); +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status_id); +CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks (parent_id); +CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks (due_date); +CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks (completed_at); + +CREATE TABLE IF NOT EXISTS task_notes ( + task_id text NOT NULL, + note_id text NOT NULL, + created_at text DEFAULT (datetime('now')) NOT NULL, + PRIMARY KEY(task_id, note_id), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON UPDATE no action ON DELETE cascade +); + +CREATE TABLE IF NOT EXISTS task_tags ( + task_id text NOT NULL, + tag text NOT NULL, + PRIMARY KEY(task_id, tag), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON UPDATE no action ON DELETE cascade +); + +CREATE INDEX IF NOT EXISTS idx_task_tags_tag ON task_tags (tag); + +CREATE TABLE IF NOT EXISTS inbox_items ( + id text PRIMARY KEY NOT NULL, + type text NOT NULL, + content text NOT NULL, + metadata text, + created_at text DEFAULT (datetime('now')) NOT NULL, + filed_at text +); + +CREATE INDEX IF NOT EXISTS idx_inbox_type ON inbox_items (type); + +CREATE TABLE IF NOT EXISTS saved_filters ( + id text PRIMARY KEY NOT NULL, + name text NOT NULL, + config text NOT NULL, + position integer DEFAULT 0 NOT NULL, + created_at text DEFAULT (datetime('now')) NOT NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + key text PRIMARY KEY NOT NULL, + value text NOT NULL, + modified_at text DEFAULT (datetime('now')) NOT NULL +); diff --git a/apps/desktop-tauri/src-tauri/migrations/0001_married_shadow_king.sql b/apps/desktop-tauri/src-tauri/migrations/0001_married_shadow_king.sql new file mode 100644 index 000000000..76147138f --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0001_married_shadow_king.sql @@ -0,0 +1,15 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0001_married_shadow_king.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. + +CREATE TABLE bookmarks ( + id text PRIMARY KEY NOT NULL, + item_type text NOT NULL, + item_id text NOT NULL, + position integer DEFAULT 0 NOT NULL, + created_at text DEFAULT (datetime('now')) NOT NULL +); + +CREATE UNIQUE INDEX idx_bookmarks_unique_item ON bookmarks (item_type, item_id); +CREATE INDEX idx_bookmarks_item_type ON bookmarks (item_type); +CREATE INDEX idx_bookmarks_position ON bookmarks (position); +CREATE INDEX idx_bookmarks_created ON bookmarks (created_at); diff --git a/apps/desktop-tauri/src-tauri/migrations/0002_broken_sleeper.sql b/apps/desktop-tauri/src-tauri/migrations/0002_broken_sleeper.sql new file mode 100644 index 000000000..dc3dff8e8 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0002_broken_sleeper.sql @@ -0,0 +1,86 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0002_broken_sleeper.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. +-- Note: PRAGMA foreign_keys=OFF/ON inside a transaction is silently no-op'd +-- by SQLite, but kept verbatim because Drizzle production runs it that way. + +CREATE TABLE filing_history ( + id text PRIMARY KEY NOT NULL, + item_type text NOT NULL, + item_content text, + filed_to text NOT NULL, + filed_action text NOT NULL, + tags text, + filed_at text DEFAULT (datetime('now')) NOT NULL +); + +CREATE INDEX idx_filing_history_type ON filing_history (item_type); +CREATE INDEX idx_filing_history_filed_at ON filing_history (filed_at); + +CREATE TABLE inbox_item_tags ( + id text PRIMARY KEY NOT NULL, + item_id text NOT NULL, + tag text NOT NULL, + created_at text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (item_id) REFERENCES inbox_items(id) ON UPDATE no action ON DELETE cascade +); + +CREATE INDEX idx_inbox_tags_item ON inbox_item_tags (item_id); +CREATE INDEX idx_inbox_tags_tag ON inbox_item_tags (tag); + +CREATE TABLE inbox_stats ( + id text PRIMARY KEY NOT NULL, + date text NOT NULL, + capture_count_link integer DEFAULT 0, + capture_count_note integer DEFAULT 0, + capture_count_image integer DEFAULT 0, + capture_count_voice integer DEFAULT 0, + capture_count_clip integer DEFAULT 0, + capture_count_pdf integer DEFAULT 0, + capture_count_social integer DEFAULT 0, + processed_count integer DEFAULT 0, + archived_count integer DEFAULT 0 +); + +CREATE UNIQUE INDEX inbox_stats_date_unique ON inbox_stats (date); +CREATE INDEX idx_inbox_stats_date ON inbox_stats (date); + +PRAGMA foreign_keys=OFF; + +CREATE TABLE __new_inbox_items ( + id text PRIMARY KEY NOT NULL, + type text NOT NULL, + title text NOT NULL, + content text, + created_at text DEFAULT (datetime('now')) NOT NULL, + modified_at text DEFAULT (datetime('now')) NOT NULL, + filed_at text, + filed_to text, + filed_action text, + snoozed_until text, + snooze_reason text, + processing_status text DEFAULT 'complete', + processing_error text, + metadata text, + attachment_path text, + thumbnail_path text, + transcription text, + transcription_status text, + source_url text, + source_title text, + archived_at text +); + +INSERT INTO __new_inbox_items("id", "type", "title", "content", "created_at", "modified_at", "filed_at", "metadata") SELECT "id", "type", COALESCE("content", 'Untitled'), "content", "created_at", "created_at", "filed_at", "metadata" FROM inbox_items; + +DROP TABLE inbox_items; + +ALTER TABLE __new_inbox_items RENAME TO inbox_items; + +PRAGMA foreign_keys=ON; + +CREATE INDEX idx_inbox_items_type ON inbox_items (type); +CREATE INDEX idx_inbox_items_created ON inbox_items (created_at); +CREATE INDEX idx_inbox_items_filed ON inbox_items (filed_at); +CREATE INDEX idx_inbox_items_snoozed ON inbox_items (snoozed_until); +CREATE INDEX idx_inbox_items_processing ON inbox_items (processing_status); +CREATE INDEX idx_inbox_items_archived ON inbox_items (archived_at); diff --git a/apps/desktop-tauri/src-tauri/migrations/0003_shallow_gladiator.sql b/apps/desktop-tauri/src-tauri/migrations/0003_shallow_gladiator.sql new file mode 100644 index 000000000..a7891845d --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0003_shallow_gladiator.sql @@ -0,0 +1,19 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0003_shallow_gladiator.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. + +CREATE TABLE suggestion_feedback ( + id text PRIMARY KEY NOT NULL, + item_id text NOT NULL, + item_type text NOT NULL, + suggested_to text NOT NULL, + actual_to text NOT NULL, + accepted integer NOT NULL, + confidence integer NOT NULL, + suggested_tags text, + actual_tags text, + created_at text DEFAULT (datetime('now')) NOT NULL +); + +CREATE INDEX idx_suggestion_feedback_item_type ON suggestion_feedback (item_type); +CREATE INDEX idx_suggestion_feedback_accepted ON suggestion_feedback (accepted); +CREATE INDEX idx_suggestion_feedback_created ON suggestion_feedback (created_at); diff --git a/apps/desktop-tauri/src-tauri/migrations/0004_odd_silver_sable.sql b/apps/desktop-tauri/src-tauri/migrations/0004_odd_silver_sable.sql new file mode 100644 index 000000000..31cf0e24e --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0004_odd_silver_sable.sql @@ -0,0 +1,24 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0004_odd_silver_sable.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. + +CREATE TABLE reminders ( + id text PRIMARY KEY NOT NULL, + target_type text NOT NULL, + target_id text NOT NULL, + remind_at text NOT NULL, + highlight_text text, + highlight_start integer, + highlight_end integer, + title text, + note text, + status text DEFAULT 'pending' NOT NULL, + triggered_at text, + dismissed_at text, + snoozed_until text, + created_at text DEFAULT (datetime('now')) NOT NULL, + modified_at text DEFAULT (datetime('now')) NOT NULL +); + +CREATE INDEX idx_reminders_target ON reminders (target_type, target_id); +CREATE INDEX idx_reminders_remind_at ON reminders (remind_at); +CREATE INDEX idx_reminders_status ON reminders (status); diff --git a/apps/desktop-tauri/src-tauri/migrations/0005_old_mac_gargan.sql b/apps/desktop-tauri/src-tauri/migrations/0005_old_mac_gargan.sql new file mode 100644 index 000000000..f1a1d3398 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0005_old_mac_gargan.sql @@ -0,0 +1,6 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0005_old_mac_gargan.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. + +ALTER TABLE inbox_items ADD viewed_at text; + +ALTER TABLE inbox_stats ADD capture_count_reminder integer DEFAULT 0; diff --git a/apps/desktop-tauri/src-tauri/migrations/0006_late_infant_terrible.sql b/apps/desktop-tauri/src-tauri/migrations/0006_late_infant_terrible.sql new file mode 100644 index 000000000..55e7d52a3 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0006_late_infant_terrible.sql @@ -0,0 +1,11 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0006_late_infant_terrible.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. + +CREATE TABLE note_positions ( + path text PRIMARY KEY NOT NULL, + folder_path text NOT NULL, + position integer DEFAULT 0 NOT NULL +); + +CREATE INDEX idx_note_positions_folder ON note_positions (folder_path); +CREATE INDEX idx_note_positions_order ON note_positions (folder_path, position); diff --git a/apps/desktop-tauri/src-tauri/migrations/0007_safe_sunspot.sql b/apps/desktop-tauri/src-tauri/migrations/0007_safe_sunspot.sql new file mode 100644 index 000000000..97fe9c99f --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0007_safe_sunspot.sql @@ -0,0 +1,8 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0007_safe_sunspot.sql +-- Identifiers unbacktick'd. + +CREATE TABLE tag_definitions ( + name text PRIMARY KEY NOT NULL, + color text NOT NULL, + created_at text DEFAULT (datetime('now')) NOT NULL +); diff --git a/apps/desktop-tauri/src-tauri/migrations/0008_blushing_magma.sql b/apps/desktop-tauri/src-tauri/migrations/0008_blushing_magma.sql new file mode 100644 index 000000000..885bb591e --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0008_blushing_magma.sql @@ -0,0 +1,44 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0008_blushing_magma.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. + +CREATE TABLE sync_devices ( + id text PRIMARY KEY NOT NULL, + name text NOT NULL, + platform text NOT NULL, + os_version text, + app_version text NOT NULL, + linked_at integer NOT NULL, + last_sync_at integer, + is_current_device integer DEFAULT 0 NOT NULL +); + +CREATE TABLE sync_queue ( + id text PRIMARY KEY NOT NULL, + type text NOT NULL, + item_id text NOT NULL, + operation text NOT NULL, + payload text NOT NULL, + priority integer DEFAULT 0 NOT NULL, + attempts integer DEFAULT 0 NOT NULL, + last_attempt integer, + error_message text, + created_at integer NOT NULL +); + +CREATE TABLE sync_state ( + key text PRIMARY KEY NOT NULL, + value text NOT NULL, + updated_at integer NOT NULL +); + +CREATE TABLE sync_history ( + id text PRIMARY KEY NOT NULL, + type text NOT NULL, + item_count integer NOT NULL, + direction text, + details text, + duration_ms integer, + created_at integer NOT NULL +); + +CREATE INDEX idx_sync_history_created ON sync_history (created_at); diff --git a/apps/desktop-tauri/src-tauri/migrations/0009_lumpy_gladiator.sql b/apps/desktop-tauri/src-tauri/migrations/0009_lumpy_gladiator.sql new file mode 100644 index 000000000..87381bc0b --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0009_lumpy_gladiator.sql @@ -0,0 +1,27 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0009_lumpy_gladiator.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd; +-- boolean DEFAULT literal `false` normalized to integer 0. + +PRAGMA foreign_keys=OFF; + +CREATE TABLE __new_sync_devices ( + id text PRIMARY KEY NOT NULL, + name text NOT NULL, + platform text NOT NULL, + os_version text, + app_version text NOT NULL, + linked_at integer NOT NULL, + last_sync_at integer, + is_current_device integer DEFAULT 0 NOT NULL +); + +INSERT INTO __new_sync_devices("id", "name", "platform", "os_version", "app_version", "linked_at", "last_sync_at", "is_current_device") SELECT "id", "name", "platform", "os_version", "app_version", "linked_at", "last_sync_at", "is_current_device" FROM sync_devices; + +DROP TABLE sync_devices; + +ALTER TABLE __new_sync_devices RENAME TO sync_devices; + +PRAGMA foreign_keys=ON; + +CREATE INDEX idx_sync_queue_type ON sync_queue (type); +CREATE INDEX idx_sync_queue_created ON sync_queue (created_at); diff --git a/apps/desktop-tauri/src-tauri/migrations/0010_dizzy_natasha_romanoff.sql b/apps/desktop-tauri/src-tauri/migrations/0010_dizzy_natasha_romanoff.sql new file mode 100644 index 000000000..e8680644b --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0010_dizzy_natasha_romanoff.sql @@ -0,0 +1,5 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0010_dizzy_natasha_romanoff.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. + +ALTER TABLE tasks ADD clock text; +ALTER TABLE tasks ADD synced_at text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0011_silent_shooting_star.sql b/apps/desktop-tauri/src-tauri/migrations/0011_silent_shooting_star.sql new file mode 100644 index 000000000..977818537 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0011_silent_shooting_star.sql @@ -0,0 +1,9 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0011_silent_shooting_star.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd; +-- boolean DEFAULT literal `false` normalized to integer 0. + +ALTER TABLE inbox_items ADD clock text; +ALTER TABLE inbox_items ADD synced_at text; +ALTER TABLE inbox_items ADD local_only integer DEFAULT 0; +ALTER TABLE saved_filters ADD clock text; +ALTER TABLE saved_filters ADD synced_at text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0012_lush_veda.sql b/apps/desktop-tauri/src-tauri/migrations/0012_lush_veda.sql new file mode 100644 index 000000000..cb70ae964 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0012_lush_veda.sql @@ -0,0 +1,4 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0012_lush_veda.sql +-- Identifiers unbacktick'd. + +ALTER TABLE sync_devices ADD signing_public_key text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0013_last_guardian.sql b/apps/desktop-tauri/src-tauri/migrations/0013_last_guardian.sql new file mode 100644 index 000000000..92be7e729 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0013_last_guardian.sql @@ -0,0 +1,25 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0013_last_guardian.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd; +-- boolean DEFAULT literal `false` normalized to integer 0. + +PRAGMA foreign_keys=OFF; + +CREATE TABLE __new_sync_devices ( + id text PRIMARY KEY NOT NULL, + name text NOT NULL, + platform text NOT NULL, + os_version text, + app_version text NOT NULL, + linked_at integer NOT NULL, + last_sync_at integer, + is_current_device integer DEFAULT 0 NOT NULL, + signing_public_key text NOT NULL +); + +INSERT INTO __new_sync_devices("id", "name", "platform", "os_version", "app_version", "linked_at", "last_sync_at", "is_current_device", "signing_public_key") SELECT "id", "name", "platform", "os_version", "app_version", "linked_at", "last_sync_at", "is_current_device", "signing_public_key" FROM sync_devices; + +DROP TABLE sync_devices; + +ALTER TABLE __new_sync_devices RENAME TO sync_devices; + +PRAGMA foreign_keys=ON; diff --git a/apps/desktop-tauri/src-tauri/migrations/0014_dazzling_leopardon.sql b/apps/desktop-tauri/src-tauri/migrations/0014_dazzling_leopardon.sql new file mode 100644 index 000000000..9621af55c --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0014_dazzling_leopardon.sql @@ -0,0 +1,4 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0014_dazzling_leopardon.sql +-- Identifiers unbacktick'd. + +CREATE UNIQUE INDEX idx_unique_current_device ON sync_devices (is_current_device) WHERE is_current_device = 1; diff --git a/apps/desktop-tauri/src-tauri/migrations/0015_brief_hex.sql b/apps/desktop-tauri/src-tauri/migrations/0015_brief_hex.sql new file mode 100644 index 000000000..e2c97586b --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0015_brief_hex.sql @@ -0,0 +1,5 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0015_brief_hex.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. + +ALTER TABLE projects ADD clock text; +ALTER TABLE projects ADD synced_at text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0016_lovely_mastermind.sql b/apps/desktop-tauri/src-tauri/migrations/0016_lovely_mastermind.sql new file mode 100644 index 000000000..c7e58b270 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0016_lovely_mastermind.sql @@ -0,0 +1,4 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0016_lovely_mastermind.sql +-- Identifiers unbacktick'd. + +ALTER TABLE tag_definitions ADD clock text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0017_spotty_mongu.sql b/apps/desktop-tauri/src-tauri/migrations/0017_spotty_mongu.sql new file mode 100644 index 000000000..6f2110fce --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0017_spotty_mongu.sql @@ -0,0 +1,6 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0017_spotty_mongu.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd. +-- Phase 8 field-level vector clocks: adds field_clocks JSON column to projects + tasks. + +ALTER TABLE projects ADD field_clocks text; +ALTER TABLE tasks ADD field_clocks text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0018_greedy_stepford_cuckoos.sql b/apps/desktop-tauri/src-tauri/migrations/0018_greedy_stepford_cuckoos.sql new file mode 100644 index 000000000..73ea45679 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0018_greedy_stepford_cuckoos.sql @@ -0,0 +1,299 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0018_greedy_stepford_cuckoos.sql +-- Drizzle `--> statement-breakpoint` markers stripped; identifiers unbacktick'd; +-- boolean DEFAULT literal `false` normalized to integer 0. +-- Massive table-rebuild migration: adds recent_searches; switches default +-- timestamp expression from datetime('now') to strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); +-- backfills sync columns (clock/field_clocks/synced_at) on remaining tables. + +CREATE TABLE recent_searches ( + id text PRIMARY KEY NOT NULL, + query text NOT NULL, + result_count integer DEFAULT 0 NOT NULL, + searched_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +PRAGMA foreign_keys=OFF; + +CREATE TABLE __new_projects ( + id text PRIMARY KEY NOT NULL, + name text NOT NULL, + description text, + color text DEFAULT '#6366f1' NOT NULL, + icon text, + position integer DEFAULT 0 NOT NULL, + is_inbox integer DEFAULT 0 NOT NULL, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + archived_at text, + clock text, + field_clocks text, + synced_at text +); + +INSERT INTO __new_projects("id", "name", "description", "color", "icon", "position", "is_inbox", "created_at", "modified_at", "archived_at", "clock", "field_clocks", "synced_at") SELECT "id", "name", "description", "color", "icon", "position", "is_inbox", "created_at", "modified_at", "archived_at", "clock", "field_clocks", "synced_at" FROM projects; + +DROP TABLE projects; + +ALTER TABLE __new_projects RENAME TO projects; + +PRAGMA foreign_keys=ON; + +CREATE TABLE __new_statuses ( + id text PRIMARY KEY NOT NULL, + project_id text NOT NULL, + name text NOT NULL, + color text DEFAULT '#6b7280' NOT NULL, + position integer DEFAULT 0 NOT NULL, + is_default integer DEFAULT 0 NOT NULL, + is_done integer DEFAULT 0 NOT NULL, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE no action ON DELETE cascade +); + +INSERT INTO __new_statuses("id", "project_id", "name", "color", "position", "is_default", "is_done", "created_at") SELECT "id", "project_id", "name", "color", "position", "is_default", "is_done", "created_at" FROM statuses; + +DROP TABLE statuses; + +ALTER TABLE __new_statuses RENAME TO statuses; + +CREATE INDEX idx_statuses_project ON statuses (project_id); + +CREATE TABLE __new_tasks ( + id text PRIMARY KEY NOT NULL, + project_id text NOT NULL, + status_id text, + parent_id text, + title text NOT NULL, + description text, + priority integer DEFAULT 0 NOT NULL, + position integer DEFAULT 0 NOT NULL, + due_date text, + due_time text, + start_date text, + repeat_config text, + repeat_from text, + source_note_id text, + completed_at text, + archived_at text, + clock text, + field_clocks text, + synced_at text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (status_id) REFERENCES statuses(id) ON UPDATE no action ON DELETE set null +); + +INSERT INTO __new_tasks("id", "project_id", "status_id", "parent_id", "title", "description", "priority", "position", "due_date", "due_time", "start_date", "repeat_config", "repeat_from", "source_note_id", "completed_at", "archived_at", "clock", "field_clocks", "synced_at", "created_at", "modified_at") SELECT "id", "project_id", "status_id", "parent_id", "title", "description", "priority", "position", "due_date", "due_time", "start_date", "repeat_config", "repeat_from", "source_note_id", "completed_at", "archived_at", "clock", "field_clocks", "synced_at", "created_at", "modified_at" FROM tasks; + +DROP TABLE tasks; + +ALTER TABLE __new_tasks RENAME TO tasks; + +CREATE INDEX idx_tasks_project ON tasks (project_id); +CREATE INDEX idx_tasks_status ON tasks (status_id); +CREATE INDEX idx_tasks_parent ON tasks (parent_id); +CREATE INDEX idx_tasks_due_date ON tasks (due_date); +CREATE INDEX idx_tasks_completed ON tasks (completed_at); + +CREATE TABLE __new_task_notes ( + task_id text NOT NULL, + note_id text NOT NULL, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + PRIMARY KEY(task_id, note_id), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON UPDATE no action ON DELETE cascade +); + +INSERT INTO __new_task_notes("task_id", "note_id", "created_at") SELECT "task_id", "note_id", "created_at" FROM task_notes; + +DROP TABLE task_notes; + +ALTER TABLE __new_task_notes RENAME TO task_notes; + +CREATE TABLE __new_filing_history ( + id text PRIMARY KEY NOT NULL, + item_type text NOT NULL, + item_content text, + filed_to text NOT NULL, + filed_action text NOT NULL, + tags text, + filed_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +INSERT INTO __new_filing_history("id", "item_type", "item_content", "filed_to", "filed_action", "tags", "filed_at") SELECT "id", "item_type", "item_content", "filed_to", "filed_action", "tags", "filed_at" FROM filing_history; + +DROP TABLE filing_history; + +ALTER TABLE __new_filing_history RENAME TO filing_history; + +CREATE INDEX idx_filing_history_type ON filing_history (item_type); +CREATE INDEX idx_filing_history_filed_at ON filing_history (filed_at); + +CREATE TABLE __new_inbox_item_tags ( + id text PRIMARY KEY NOT NULL, + item_id text NOT NULL, + tag text NOT NULL, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (item_id) REFERENCES inbox_items(id) ON UPDATE no action ON DELETE cascade +); + +INSERT INTO __new_inbox_item_tags("id", "item_id", "tag", "created_at") SELECT "id", "item_id", "tag", "created_at" FROM inbox_item_tags; + +DROP TABLE inbox_item_tags; + +ALTER TABLE __new_inbox_item_tags RENAME TO inbox_item_tags; + +CREATE INDEX idx_inbox_tags_item ON inbox_item_tags (item_id); +CREATE INDEX idx_inbox_tags_tag ON inbox_item_tags (tag); + +CREATE TABLE __new_inbox_items ( + id text PRIMARY KEY NOT NULL, + type text NOT NULL, + title text NOT NULL, + content text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + filed_at text, + filed_to text, + filed_action text, + snoozed_until text, + snooze_reason text, + viewed_at text, + processing_status text DEFAULT 'complete', + processing_error text, + metadata text, + attachment_path text, + thumbnail_path text, + transcription text, + transcription_status text, + source_url text, + source_title text, + archived_at text, + clock text, + synced_at text, + local_only integer DEFAULT 0 +); + +INSERT INTO __new_inbox_items("id", "type", "title", "content", "created_at", "modified_at", "filed_at", "filed_to", "filed_action", "snoozed_until", "snooze_reason", "viewed_at", "processing_status", "processing_error", "metadata", "attachment_path", "thumbnail_path", "transcription", "transcription_status", "source_url", "source_title", "archived_at", "clock", "synced_at", "local_only") SELECT "id", "type", "title", "content", "created_at", "modified_at", "filed_at", "filed_to", "filed_action", "snoozed_until", "snooze_reason", "viewed_at", "processing_status", "processing_error", "metadata", "attachment_path", "thumbnail_path", "transcription", "transcription_status", "source_url", "source_title", "archived_at", "clock", "synced_at", "local_only" FROM inbox_items; + +DROP TABLE inbox_items; + +ALTER TABLE __new_inbox_items RENAME TO inbox_items; + +CREATE INDEX idx_inbox_items_type ON inbox_items (type); +CREATE INDEX idx_inbox_items_created ON inbox_items (created_at); +CREATE INDEX idx_inbox_items_filed ON inbox_items (filed_at); +CREATE INDEX idx_inbox_items_snoozed ON inbox_items (snoozed_until); +CREATE INDEX idx_inbox_items_processing ON inbox_items (processing_status); +CREATE INDEX idx_inbox_items_archived ON inbox_items (archived_at); + +CREATE TABLE __new_suggestion_feedback ( + id text PRIMARY KEY NOT NULL, + item_id text NOT NULL, + item_type text NOT NULL, + suggested_to text NOT NULL, + actual_to text NOT NULL, + accepted integer NOT NULL, + confidence integer NOT NULL, + suggested_tags text, + actual_tags text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +INSERT INTO __new_suggestion_feedback("id", "item_id", "item_type", "suggested_to", "actual_to", "accepted", "confidence", "suggested_tags", "actual_tags", "created_at") SELECT "id", "item_id", "item_type", "suggested_to", "actual_to", "accepted", "confidence", "suggested_tags", "actual_tags", "created_at" FROM suggestion_feedback; + +DROP TABLE suggestion_feedback; + +ALTER TABLE __new_suggestion_feedback RENAME TO suggestion_feedback; + +CREATE INDEX idx_suggestion_feedback_item_type ON suggestion_feedback (item_type); +CREATE INDEX idx_suggestion_feedback_accepted ON suggestion_feedback (accepted); +CREATE INDEX idx_suggestion_feedback_created ON suggestion_feedback (created_at); + +CREATE TABLE __new_saved_filters ( + id text PRIMARY KEY NOT NULL, + name text NOT NULL, + config text NOT NULL, + position integer DEFAULT 0 NOT NULL, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + clock text, + synced_at text +); + +INSERT INTO __new_saved_filters("id", "name", "config", "position", "created_at", "clock", "synced_at") SELECT "id", "name", "config", "position", "created_at", "clock", "synced_at" FROM saved_filters; + +DROP TABLE saved_filters; + +ALTER TABLE __new_saved_filters RENAME TO saved_filters; + +CREATE TABLE __new_settings ( + key text PRIMARY KEY NOT NULL, + value text NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +INSERT INTO __new_settings("key", "value", "modified_at") SELECT "key", "value", "modified_at" FROM settings; + +DROP TABLE settings; + +ALTER TABLE __new_settings RENAME TO settings; + +CREATE TABLE __new_bookmarks ( + id text PRIMARY KEY NOT NULL, + item_type text NOT NULL, + item_id text NOT NULL, + position integer DEFAULT 0 NOT NULL, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +INSERT INTO __new_bookmarks("id", "item_type", "item_id", "position", "created_at") SELECT "id", "item_type", "item_id", "position", "created_at" FROM bookmarks; + +DROP TABLE bookmarks; + +ALTER TABLE __new_bookmarks RENAME TO bookmarks; + +CREATE UNIQUE INDEX idx_bookmarks_unique_item ON bookmarks (item_type, item_id); +CREATE INDEX idx_bookmarks_item_type ON bookmarks (item_type); +CREATE INDEX idx_bookmarks_position ON bookmarks (position); +CREATE INDEX idx_bookmarks_created ON bookmarks (created_at); + +CREATE TABLE __new_reminders ( + id text PRIMARY KEY NOT NULL, + target_type text NOT NULL, + target_id text NOT NULL, + remind_at text NOT NULL, + highlight_text text, + highlight_start integer, + highlight_end integer, + title text, + note text, + status text DEFAULT 'pending' NOT NULL, + triggered_at text, + dismissed_at text, + snoozed_until text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +INSERT INTO __new_reminders("id", "target_type", "target_id", "remind_at", "highlight_text", "highlight_start", "highlight_end", "title", "note", "status", "triggered_at", "dismissed_at", "snoozed_until", "created_at", "modified_at") SELECT "id", "target_type", "target_id", "remind_at", "highlight_text", "highlight_start", "highlight_end", "title", "note", "status", "triggered_at", "dismissed_at", "snoozed_until", "created_at", "modified_at" FROM reminders; + +DROP TABLE reminders; + +ALTER TABLE __new_reminders RENAME TO reminders; + +CREATE INDEX idx_reminders_target ON reminders (target_type, target_id); +CREATE INDEX idx_reminders_remind_at ON reminders (remind_at); +CREATE INDEX idx_reminders_status ON reminders (status); + +CREATE TABLE __new_tag_definitions ( + name text PRIMARY KEY NOT NULL, + color text NOT NULL, + clock text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +INSERT INTO __new_tag_definitions("name", "color", "clock", "created_at") SELECT "name", "color", "clock", "created_at" FROM tag_definitions; + +DROP TABLE tag_definitions; + +ALTER TABLE __new_tag_definitions RENAME TO tag_definitions; diff --git a/apps/desktop-tauri/src-tauri/migrations/0019_material_lethal_legion.sql b/apps/desktop-tauri/src-tauri/migrations/0019_material_lethal_legion.sql new file mode 100644 index 000000000..833dc5bf6 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0019_material_lethal_legion.sql @@ -0,0 +1,4 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0019_material_lethal_legion.sql +-- Identifiers unbacktick'd. + +ALTER TABLE inbox_items ADD capture_source text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0020_search_reasons.sql b/apps/desktop-tauri/src-tauri/migrations/0020_search_reasons.sql new file mode 100644 index 000000000..60469b30c --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0020_search_reasons.sql @@ -0,0 +1,18 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0020_search_reasons.sql +-- Hand-written Electron migration; identifiers unbacktick'd. + +DROP TABLE IF EXISTS recent_searches; + +CREATE TABLE search_reasons ( + id text PRIMARY KEY NOT NULL, + item_id text NOT NULL, + item_type text NOT NULL, + item_title text NOT NULL, + item_icon text, + search_query text NOT NULL, + visited_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +CREATE UNIQUE INDEX idx_search_reasons_item ON search_reasons (item_type, item_id); + +CREATE INDEX idx_search_reasons_visited ON search_reasons (visited_at); diff --git a/apps/desktop-tauri/src-tauri/migrations/0021_inbox_jobs.sql b/apps/desktop-tauri/src-tauri/migrations/0021_inbox_jobs.sql new file mode 100644 index 000000000..bb7d32c70 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0021_inbox_jobs.sql @@ -0,0 +1,28 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0021_inbox_jobs.sql +-- Hand-written Electron migration; identifiers unbacktick'd. + +CREATE TABLE inbox_jobs ( + id text PRIMARY KEY NOT NULL, + item_id text NOT NULL, + type text NOT NULL, + status text DEFAULT 'pending' NOT NULL, + run_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + attempts integer DEFAULT 0 NOT NULL, + max_attempts integer DEFAULT 1 NOT NULL, + payload text, + result text, + last_error text, + started_at text, + completed_at text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + updated_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (item_id) REFERENCES inbox_items(id) ON UPDATE no action ON DELETE cascade +); + +CREATE INDEX idx_inbox_jobs_item ON inbox_jobs (item_id); + +CREATE INDEX idx_inbox_jobs_status ON inbox_jobs (status); + +CREATE INDEX idx_inbox_jobs_run_at ON inbox_jobs (run_at); + +CREATE INDEX idx_inbox_jobs_item_type ON inbox_jobs (item_id, type); diff --git a/apps/desktop-tauri/src-tauri/migrations/0022_notes_journal_vault.sql b/apps/desktop-tauri/src-tauri/migrations/0022_notes_journal_vault.sql new file mode 100644 index 000000000..525899613 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0022_notes_journal_vault.sql @@ -0,0 +1,41 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0022_notes_journal_vault.sql +-- Hand-written Electron migration; identifiers unbacktick'd; +-- boolean DEFAULT literal `false` normalized to integer 0. + +CREATE TABLE note_metadata ( + id text PRIMARY KEY NOT NULL, + path text NOT NULL, + title text NOT NULL, + emoji text, + file_type text DEFAULT 'markdown' NOT NULL, + mime_type text, + file_size integer, + attachment_id text, + attachment_references text, + local_only integer DEFAULT 0 NOT NULL, + sync_policy text DEFAULT 'sync' NOT NULL, + journal_date text, + property_definition_names text, + clock text, + synced_at text, + created_at text NOT NULL, + modified_at text NOT NULL, + stored_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +CREATE UNIQUE INDEX idx_note_metadata_path ON note_metadata (path); + +CREATE INDEX idx_note_metadata_modified ON note_metadata (modified_at); + +CREATE INDEX idx_note_metadata_journal_date ON note_metadata (journal_date); + +CREATE INDEX idx_note_metadata_local_only ON note_metadata (local_only); + +CREATE TABLE property_definitions ( + name text PRIMARY KEY NOT NULL, + type text NOT NULL, + options text, + default_value text, + color text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); diff --git a/apps/desktop-tauri/src-tauri/migrations/0023_folder_configs.sql b/apps/desktop-tauri/src-tauri/migrations/0023_folder_configs.sql new file mode 100644 index 000000000..8d920970d --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0023_folder_configs.sql @@ -0,0 +1,10 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0023_folder_configs.sql +-- Hand-written Electron migration; identifiers unbacktick'd. + +CREATE TABLE folder_configs ( + path text PRIMARY KEY NOT NULL, + icon text, + clock text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); diff --git a/apps/desktop-tauri/src-tauri/migrations/0024_google_calendar_foundation.sql b/apps/desktop-tauri/src-tauri/migrations/0024_google_calendar_foundation.sql new file mode 100644 index 000000000..2df1e98ee --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0024_google_calendar_foundation.sql @@ -0,0 +1,110 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0024_google_calendar_foundation.sql +-- Hand-written Electron migration; Drizzle `--> statement-breakpoint` markers stripped; +-- identifiers unbacktick'd; boolean DEFAULT literal `false` normalized to integer 0. + +CREATE TABLE calendar_events ( + id text PRIMARY KEY NOT NULL, + title text NOT NULL, + description text, + location text, + start_at text NOT NULL, + end_at text, + timezone text DEFAULT 'UTC' NOT NULL, + is_all_day integer DEFAULT 0 NOT NULL, + recurrence_rule text, + recurrence_exceptions text, + archived_at text, + clock text, + synced_at text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +CREATE INDEX idx_calendar_events_start_at ON calendar_events (start_at); + +CREATE INDEX idx_calendar_events_archived_at ON calendar_events (archived_at); + +CREATE TABLE calendar_sources ( + id text PRIMARY KEY NOT NULL, + provider text NOT NULL, + kind text NOT NULL, + account_id text, + remote_id text NOT NULL, + title text NOT NULL, + timezone text, + color text, + is_primary integer DEFAULT 0 NOT NULL, + is_selected integer DEFAULT 0 NOT NULL, + is_memry_managed integer DEFAULT 0 NOT NULL, + sync_cursor text, + sync_status text DEFAULT 'idle' NOT NULL, + last_synced_at text, + metadata text, + archived_at text, + clock text, + synced_at text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +CREATE UNIQUE INDEX idx_calendar_sources_provider_remote ON calendar_sources (provider, kind, remote_id); + +CREATE INDEX idx_calendar_sources_account ON calendar_sources (account_id); + +CREATE INDEX idx_calendar_sources_selected ON calendar_sources (is_selected); + +CREATE TABLE calendar_external_events ( + id text PRIMARY KEY NOT NULL, + source_id text NOT NULL, + remote_event_id text NOT NULL, + remote_etag text, + remote_updated_at text, + title text NOT NULL, + description text, + location text, + start_at text NOT NULL, + end_at text, + timezone text, + is_all_day integer DEFAULT 0 NOT NULL, + status text DEFAULT 'confirmed' NOT NULL, + recurrence_rule text, + raw_payload text, + archived_at text, + clock text, + synced_at text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (source_id) REFERENCES calendar_sources(id) ON UPDATE no action ON DELETE cascade +); + +CREATE UNIQUE INDEX idx_calendar_external_events_source_remote ON calendar_external_events (source_id, remote_event_id); + +CREATE INDEX idx_calendar_external_events_start_at ON calendar_external_events (start_at); + +CREATE INDEX idx_calendar_external_events_archived_at ON calendar_external_events (archived_at); + +CREATE TABLE calendar_bindings ( + id text PRIMARY KEY NOT NULL, + source_type text NOT NULL, + source_id text NOT NULL, + provider text NOT NULL, + remote_calendar_id text NOT NULL, + remote_event_id text NOT NULL, + ownership_mode text NOT NULL, + writeback_mode text NOT NULL, + remote_version text, + last_local_snapshot text, + archived_at text, + clock text, + synced_at text, + created_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + modified_at text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); + +CREATE UNIQUE INDEX idx_calendar_bindings_source ON calendar_bindings (source_type, source_id, provider); + +CREATE UNIQUE INDEX idx_calendar_bindings_remote ON calendar_bindings (provider, remote_calendar_id, remote_event_id); + +CREATE INDEX idx_calendar_bindings_source_type ON calendar_bindings (source_type); + +CREATE INDEX idx_calendar_bindings_archived_at ON calendar_bindings (archived_at); diff --git a/apps/desktop-tauri/src-tauri/migrations/0025_event_target_calendar.sql b/apps/desktop-tauri/src-tauri/migrations/0025_event_target_calendar.sql new file mode 100644 index 000000000..aa10abecc --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0025_event_target_calendar.sql @@ -0,0 +1,4 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0025_event_target_calendar.sql +-- Hand-written Electron migration; identifiers unbacktick'd. + +ALTER TABLE calendar_events ADD COLUMN target_calendar_id text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0026_calendar_field_clocks.sql b/apps/desktop-tauri/src-tauri/migrations/0026_calendar_field_clocks.sql new file mode 100644 index 000000000..e2a18c03f --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0026_calendar_field_clocks.sql @@ -0,0 +1,4 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0026_calendar_field_clocks.sql +-- Hand-written Electron migration; identifiers unbacktick'd. + +ALTER TABLE calendar_events ADD COLUMN field_clocks text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0027_calendar_rich_fields.sql b/apps/desktop-tauri/src-tauri/migrations/0027_calendar_rich_fields.sql new file mode 100644 index 000000000..69241f6fd --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0027_calendar_rich_fields.sql @@ -0,0 +1,16 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0027_calendar_rich_fields.sql +-- Hand-written Electron migration; Drizzle `--> statement-breakpoint` markers stripped; +-- identifiers unbacktick'd. + +ALTER TABLE calendar_events ADD COLUMN attendees text; +ALTER TABLE calendar_events ADD COLUMN reminders text; +ALTER TABLE calendar_events ADD COLUMN visibility text; +ALTER TABLE calendar_events ADD COLUMN color_id text; +ALTER TABLE calendar_events ADD COLUMN conference_data text; +ALTER TABLE calendar_events ADD COLUMN parent_event_id text; +ALTER TABLE calendar_events ADD COLUMN original_start_time text; +ALTER TABLE calendar_external_events ADD COLUMN attendees text; +ALTER TABLE calendar_external_events ADD COLUMN reminders text; +ALTER TABLE calendar_external_events ADD COLUMN visibility text; +ALTER TABLE calendar_external_events ADD COLUMN color_id text; +ALTER TABLE calendar_external_events ADD COLUMN conference_data text; diff --git a/apps/desktop-tauri/src-tauri/migrations/0028_calendar_source_last_error.sql b/apps/desktop-tauri/src-tauri/migrations/0028_calendar_source_last_error.sql new file mode 100644 index 000000000..8549334b5 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/migrations/0028_calendar_source_last_error.sql @@ -0,0 +1,4 @@ +-- Port of apps/desktop/src/main/database/drizzle-data/0028_calendar_source_last_error.sql +-- Hand-written Electron migration; identifiers unbacktick'd. + +ALTER TABLE calendar_sources ADD COLUMN last_error text; diff --git a/apps/desktop-tauri/src-tauri/src/app_state.rs b/apps/desktop-tauri/src-tauri/src/app_state.rs new file mode 100644 index 000000000..f4b96213c --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/app_state.rs @@ -0,0 +1,13 @@ +//! Global runtime state shared across commands. + +use crate::db::Db; + +pub struct AppState { + pub db: Db, +} + +impl AppState { + pub fn new(db: Db) -> Self { + Self { db } + } +} diff --git a/apps/desktop-tauri/src-tauri/src/bin/bench_m2.rs b/apps/desktop-tauri/src-tauri/src/bin/bench_m2.rs new file mode 100644 index 000000000..4e4aadbf1 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/bin/bench_m2.rs @@ -0,0 +1,94 @@ +//! M2 acceptance bench — 1000-row list query p50 < 20ms. +//! +//! Spec §5.5: every M2 ship-gate sweep includes a release-build run of this +//! bench so SQLite-path regressions surface before merge. Debug builds are +//! ~10× slower on rusqlite (Risk #7) — use `cargo run --release`. +//! +//! Strategy: +//! 1. Open an in-memory DB via `Db::open_memory` (same migration runner + +//! WAL/foreign-key PRAGMAs as production minus journal_mode). +//! 2. Seed a single project, then 1000 tasks under it inside one +//! `unchecked_transaction` so all rows commit atomically. +//! 3. Warm-up: 5 list iterations (statement-cache priming). +//! 4. Measure: 100 iterations of the SELECT, recording per-iteration +//! `Instant` deltas in microseconds. +//! 5. Sort, report p50/p95, assert p50 < 20_000µs. +//! +//! Run: +//! cargo run --release --bin bench_m2 --features test-helpers + +use memry_desktop_tauri_lib::db::Db; +use std::time::Instant; + +const ROW_COUNT: usize = 1000; +const WARMUP_ITERS: usize = 5; +const MEASURE_ITERS: usize = 100; +const P50_THRESHOLD_US: u64 = 20_000; +const SELECT_QUERY: &str = "SELECT id, title, priority, position FROM tasks + WHERE project_id = 'bench-p' ORDER BY position + LIMIT 1000"; + +fn main() { + let db = Db::open_memory().expect("open memory db"); + let conn = db.conn().expect("acquire connection guard"); + + conn.execute( + "INSERT INTO projects (id, name, color) VALUES (?1, ?2, ?3)", + rusqlite::params!["bench-p", "Bench", "#000"], + ) + .expect("seed project"); + + { + let tx = conn.unchecked_transaction().expect("open seed tx"); + for i in 0..ROW_COUNT { + tx.execute( + "INSERT INTO tasks (id, project_id, title, priority, position) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + format!("t{i}"), + "bench-p", + format!("Task {i}"), + 0, + i as i64 + ], + ) + .expect("seed task"); + } + tx.commit().expect("commit seed tx"); + } + + for _ in 0..WARMUP_ITERS { + let mut stmt = conn.prepare(SELECT_QUERY).expect("prepare warmup"); + let rows: Vec = stmt + .query_map([], |r| r.get::<_, String>(0)) + .expect("query warmup") + .filter_map(Result::ok) + .collect(); + assert_eq!(rows.len(), ROW_COUNT, "warmup row count"); + } + + let mut samples: Vec = Vec::with_capacity(MEASURE_ITERS); + for _ in 0..MEASURE_ITERS { + let start = Instant::now(); + let mut stmt = conn.prepare(SELECT_QUERY).expect("prepare measure"); + let rows: Vec = stmt + .query_map([], |r| r.get::<_, String>(0)) + .expect("query measure") + .filter_map(Result::ok) + .collect(); + assert_eq!(rows.len(), ROW_COUNT, "measured row count"); + samples.push(start.elapsed().as_micros() as u64); + } + + samples.sort_unstable(); + let p50 = samples[samples.len() / 2]; + let p95 = samples[(samples.len() * 95) / 100]; + println!("1000-row list: p50 = {}µs, p95 = {}µs", p50, p95); + + assert!( + p50 < P50_THRESHOLD_US, + "p50 {p50}µs exceeds {P50_THRESHOLD_US}µs (20ms) threshold" + ); + + println!("OK: M2 bench within budget."); +} diff --git a/apps/desktop-tauri/src-tauri/src/bin/generate_bindings.rs b/apps/desktop-tauri/src-tauri/src/bin/generate_bindings.rs index 29d096d6b..b695ee4b3 100644 --- a/apps/desktop-tauri/src-tauri/src/bin/generate_bindings.rs +++ b/apps/desktop-tauri/src-tauri/src/bin/generate_bindings.rs @@ -1,25 +1,52 @@ -//! Writes TypeScript bindings for Tauri commands into the renderer. +//! Regenerate `src/generated/bindings.ts` from Rust command signatures +//! and domain struct derives. Run via `pnpm bindings:generate`. //! -//! At M1 there are no commands; this binary is a no-op stub. M2+ updates -//! this with `specta::ts::export_named_datatypes` once domain structs and -//! commands exist. +//! The Phase F surface is a stress test: every `db/*` struct that derives +//! `specta::Type` is registered here so a typo or missing rename_all in any +//! domain module surfaces in the generated TS file. The 3 Tauri commands +//! exposed today (`settings_get`/`settings_set`/`settings_list`) are +//! collected via `collect_commands!`; subsequent milestones extend both +//! lists as their Rust implementations land. -use std::fs; -use std::path::PathBuf; +use memry_desktop_tauri_lib::commands; +use memry_desktop_tauri_lib::db; +use memry_desktop_tauri_lib::error::AppError; +use specta_typescript::Typescript; +use tauri_specta::{collect_commands, Builder}; -fn main() { - let output = PathBuf::from("../src/generated/bindings.ts"); +fn main() -> Result<(), Box> { + let builder = Builder::::new() + .commands(collect_commands![ + commands::settings::settings_get, + commands::settings::settings_set, + commands::settings::settings_list, + commands::lifecycle::notify_flush_done, + ]) + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::(); - let contents = r#"// !! AUTO-GENERATED BY `pnpm bindings:generate` !! -// Do not edit this file manually. It is regenerated from Rust command -// signatures in apps/desktop-tauri/src-tauri/src/commands/. -// -// At M1 there are no commands yet; this file is an empty export to preserve -// the import path for consumers. Subsequent milestones replace contents. + builder.export(Typescript::default(), "../src/generated/bindings.ts")?; -export {} -"#; - - fs::write(&output, contents).expect("failed to write bindings"); - println!("Wrote bindings to {}", output.display()); + Ok(()) } diff --git a/apps/desktop-tauri/src-tauri/src/commands/lifecycle.rs b/apps/desktop-tauri/src-tauri/src/commands/lifecycle.rs new file mode 100644 index 000000000..2b05c0911 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/lifecycle.rs @@ -0,0 +1,15 @@ +use crate::error::AppResult; + +/// Renderer→main signal that pending save flushes finished. +/// +/// At M2 there is no quit-orchestration coordinator on the Rust side; the M8.0 +/// lifecycle milestone introduces a flush coordinator that gates window close +/// on this notification. Until then the command is a thin no-op acknowledgement +/// so the renderer's `useFlushOnQuit` hook can keep its existing contract +/// without 404s through the mock router. +#[tauri::command] +#[specta::specta] +pub async fn notify_flush_done() -> AppResult<()> { + tracing::debug!("notify_flush_done received (M2 no-op)"); + Ok(()) +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 9fe626c55..62b4a8205 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -1,13 +1,14 @@ //! IPC command surface exposed to the renderer. //! -//! At M1 no commands are implemented — the renderer is fed by the JS-side -//! mock router in `src/lib/ipc/mocks/`. M2+ introduces real commands per -//! domain (notes, tasks, crypto, sync, etc.). When a domain's Rust -//! implementation lands, entries are added here and the corresponding -//! mock entry is removed from `src/lib/ipc/invoke.ts`'s realCommands set. +//! Phase F (M2) introduces the first real feature-domain slice — settings — +//! crossing the boundary via `settings_get` / `settings_set` / `settings_list`. +//! Phase G adds a single shell-neutral wrapper (`notify_flush_done`) so the +//! renderer's quit-flush hook does not 404 through the mock router. +//! Every other domain still serves data through the JS-side mock router in +//! `src/lib/ipc/mocks/`. As each domain's Rust implementation lands, declare +//! its module here and add the command names to the `generate_handler!` macro +//! invocation in `lib.rs::run` plus `realCommands` in +//! `src/lib/ipc/invoke.ts`. -use tauri::Builder; - -pub fn register(builder: Builder) -> Builder { - builder.invoke_handler(tauri::generate_handler![]) -} +pub mod lifecycle; +pub mod settings; diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs new file mode 100644 index 000000000..6a1b30fd3 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -0,0 +1,41 @@ +use crate::app_state::AppState; +use crate::db::settings::{self, Setting}; +use crate::error::AppResult; +use serde::Deserialize; + +#[derive(Debug, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SettingsGetInput { + pub key: String, +} + +#[derive(Debug, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SettingsSetInput { + pub key: String, + pub value: String, +} + +#[tauri::command] +#[specta::specta] +pub async fn settings_get( + state: tauri::State<'_, AppState>, + input: SettingsGetInput, +) -> AppResult> { + settings::get(&state.db, &input.key) +} + +#[tauri::command] +#[specta::specta] +pub async fn settings_set( + state: tauri::State<'_, AppState>, + input: SettingsSetInput, +) -> AppResult<()> { + settings::set(&state.db, &input.key, &input.value) +} + +#[tauri::command] +#[specta::specta] +pub async fn settings_list(state: tauri::State<'_, AppState>) -> AppResult> { + settings::list(&state.db) +} diff --git a/apps/desktop-tauri/src-tauri/src/db/bookmarks.rs b/apps/desktop-tauri/src-tauri/src/db/bookmarks.rs new file mode 100644 index 000000000..6e813d151 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/bookmarks.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Bookmark { + pub id: String, + pub item_type: String, + pub item_id: String, + pub position: i64, + pub created_at: String, +} + +impl Bookmark { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + item_type: row.get("item_type")?, + item_id: row.get("item_id")?, + position: row.get("position")?, + created_at: row.get("created_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/calendar_bindings.rs b/apps/desktop-tauri/src-tauri/src/db/calendar_bindings.rs new file mode 100644 index 000000000..bdedac9dd --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/calendar_bindings.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct CalendarBinding { + pub id: String, + pub source_type: String, + pub source_id: String, + pub provider: String, + pub remote_calendar_id: String, + pub remote_event_id: String, + pub ownership_mode: String, + pub writeback_mode: String, + pub remote_version: Option, + pub last_local_snapshot: Option, + pub archived_at: Option, + pub clock: Option, + pub synced_at: Option, + pub created_at: String, + pub modified_at: String, +} + +impl CalendarBinding { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + source_type: row.get("source_type")?, + source_id: row.get("source_id")?, + provider: row.get("provider")?, + remote_calendar_id: row.get("remote_calendar_id")?, + remote_event_id: row.get("remote_event_id")?, + ownership_mode: row.get("ownership_mode")?, + writeback_mode: row.get("writeback_mode")?, + remote_version: row.get("remote_version")?, + last_local_snapshot: row.get("last_local_snapshot")?, + archived_at: row.get("archived_at")?, + clock: row.get("clock")?, + synced_at: row.get("synced_at")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/calendar_events.rs b/apps/desktop-tauri/src-tauri/src/db/calendar_events.rs new file mode 100644 index 000000000..12bfb9ba5 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/calendar_events.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct CalendarEvent { + pub id: String, + pub title: String, + pub description: Option, + pub location: Option, + pub start_at: String, + pub end_at: Option, + pub timezone: String, + pub is_all_day: bool, + pub recurrence_rule: Option, + pub recurrence_exceptions: Option, + pub archived_at: Option, + pub clock: Option, + pub synced_at: Option, + pub created_at: String, + pub modified_at: String, + pub target_calendar_id: Option, + pub field_clocks: Option, + pub attendees: Option, + pub reminders: Option, + pub visibility: Option, + pub color_id: Option, + pub conference_data: Option, + pub parent_event_id: Option, + pub original_start_time: Option, +} + +impl CalendarEvent { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + title: row.get("title")?, + description: row.get("description")?, + location: row.get("location")?, + start_at: row.get("start_at")?, + end_at: row.get("end_at")?, + timezone: row.get("timezone")?, + is_all_day: row.get::<_, i64>("is_all_day")? != 0, + recurrence_rule: row.get("recurrence_rule")?, + recurrence_exceptions: row.get("recurrence_exceptions")?, + archived_at: row.get("archived_at")?, + clock: row.get("clock")?, + synced_at: row.get("synced_at")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + target_calendar_id: row.get("target_calendar_id")?, + field_clocks: row.get("field_clocks")?, + attendees: row.get("attendees")?, + reminders: row.get("reminders")?, + visibility: row.get("visibility")?, + color_id: row.get("color_id")?, + conference_data: row.get("conference_data")?, + parent_event_id: row.get("parent_event_id")?, + original_start_time: row.get("original_start_time")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/calendar_external_events.rs b/apps/desktop-tauri/src-tauri/src/db/calendar_external_events.rs new file mode 100644 index 000000000..cd8af4504 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/calendar_external_events.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct CalendarExternalEvent { + pub id: String, + pub source_id: String, + pub remote_event_id: String, + pub remote_etag: Option, + pub remote_updated_at: Option, + pub title: String, + pub description: Option, + pub location: Option, + pub start_at: String, + pub end_at: Option, + pub timezone: Option, + pub is_all_day: bool, + pub status: String, + pub recurrence_rule: Option, + pub raw_payload: Option, + pub archived_at: Option, + pub clock: Option, + pub synced_at: Option, + pub created_at: String, + pub modified_at: String, + pub attendees: Option, + pub reminders: Option, + pub visibility: Option, + pub color_id: Option, + pub conference_data: Option, +} + +impl CalendarExternalEvent { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + source_id: row.get("source_id")?, + remote_event_id: row.get("remote_event_id")?, + remote_etag: row.get("remote_etag")?, + remote_updated_at: row.get("remote_updated_at")?, + title: row.get("title")?, + description: row.get("description")?, + location: row.get("location")?, + start_at: row.get("start_at")?, + end_at: row.get("end_at")?, + timezone: row.get("timezone")?, + is_all_day: row.get::<_, i64>("is_all_day")? != 0, + status: row.get("status")?, + recurrence_rule: row.get("recurrence_rule")?, + raw_payload: row.get("raw_payload")?, + archived_at: row.get("archived_at")?, + clock: row.get("clock")?, + synced_at: row.get("synced_at")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + attendees: row.get("attendees")?, + reminders: row.get("reminders")?, + visibility: row.get("visibility")?, + color_id: row.get("color_id")?, + conference_data: row.get("conference_data")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/calendar_sources.rs b/apps/desktop-tauri/src-tauri/src/db/calendar_sources.rs new file mode 100644 index 000000000..b1f2963cf --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/calendar_sources.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct CalendarSource { + pub id: String, + pub provider: String, + pub kind: String, + pub account_id: Option, + pub remote_id: String, + pub title: String, + pub timezone: Option, + pub color: Option, + pub is_primary: bool, + pub is_selected: bool, + pub is_memry_managed: bool, + pub sync_cursor: Option, + pub sync_status: String, + pub last_synced_at: Option, + pub metadata: Option, + pub archived_at: Option, + pub clock: Option, + pub synced_at: Option, + pub created_at: String, + pub modified_at: String, + pub last_error: Option, +} + +impl CalendarSource { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + provider: row.get("provider")?, + kind: row.get("kind")?, + account_id: row.get("account_id")?, + remote_id: row.get("remote_id")?, + title: row.get("title")?, + timezone: row.get("timezone")?, + color: row.get("color")?, + is_primary: row.get::<_, i64>("is_primary")? != 0, + is_selected: row.get::<_, i64>("is_selected")? != 0, + is_memry_managed: row.get::<_, i64>("is_memry_managed")? != 0, + sync_cursor: row.get("sync_cursor")?, + sync_status: row.get("sync_status")?, + last_synced_at: row.get("last_synced_at")?, + metadata: row.get("metadata")?, + archived_at: row.get("archived_at")?, + clock: row.get("clock")?, + synced_at: row.get("synced_at")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + last_error: row.get("last_error")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/folder_configs.rs b/apps/desktop-tauri/src-tauri/src/db/folder_configs.rs new file mode 100644 index 000000000..c673bd85c --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/folder_configs.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct FolderConfig { + pub path: String, + pub icon: Option, + pub clock: Option, + pub created_at: String, + pub modified_at: String, +} + +impl FolderConfig { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + path: row.get("path")?, + icon: row.get("icon")?, + clock: row.get("clock")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/inbox.rs b/apps/desktop-tauri/src-tauri/src/db/inbox.rs new file mode 100644 index 000000000..6b764ebb4 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/inbox.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct InboxItem { + pub id: String, + #[serde(rename = "type")] + pub r#type: String, + pub title: String, + pub content: Option, + pub created_at: String, + pub modified_at: String, + pub filed_at: Option, + pub filed_to: Option, + pub filed_action: Option, + pub snoozed_until: Option, + pub snooze_reason: Option, + pub viewed_at: Option, + pub processing_status: Option, + pub processing_error: Option, + pub metadata: Option, + pub attachment_path: Option, + pub thumbnail_path: Option, + pub transcription: Option, + pub transcription_status: Option, + pub source_url: Option, + pub source_title: Option, + pub archived_at: Option, + pub clock: Option, + pub synced_at: Option, + pub local_only: Option, + pub capture_source: Option, +} + +impl InboxItem { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + r#type: row.get("type")?, + title: row.get("title")?, + content: row.get("content")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + filed_at: row.get("filed_at")?, + filed_to: row.get("filed_to")?, + filed_action: row.get("filed_action")?, + snoozed_until: row.get("snoozed_until")?, + snooze_reason: row.get("snooze_reason")?, + viewed_at: row.get("viewed_at")?, + processing_status: row.get("processing_status")?, + processing_error: row.get("processing_error")?, + metadata: row.get("metadata")?, + attachment_path: row.get("attachment_path")?, + thumbnail_path: row.get("thumbnail_path")?, + transcription: row.get("transcription")?, + transcription_status: row.get("transcription_status")?, + source_url: row.get("source_url")?, + source_title: row.get("source_title")?, + archived_at: row.get("archived_at")?, + clock: row.get("clock")?, + synced_at: row.get("synced_at")?, + local_only: row.get::<_, Option>("local_only")?.map(|v| v != 0), + capture_source: row.get("capture_source")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/migrations.rs b/apps/desktop-tauri/src-tauri/src/db/migrations.rs new file mode 100644 index 000000000..6a4e53356 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/migrations.rs @@ -0,0 +1,247 @@ +use crate::error::{AppError, AppResult}; +use rusqlite::{params, Connection}; +use std::collections::HashSet; + +pub static EMBEDDED: &[(&str, &str)] = &MIGRATIONS; + +static MIGRATIONS: [(&str, &str); 29] = [ + ( + "0000_thankful_luke_cage.sql", + include_str!("../../migrations/0000_thankful_luke_cage.sql"), + ), + ( + "0001_married_shadow_king.sql", + include_str!("../../migrations/0001_married_shadow_king.sql"), + ), + ( + "0002_broken_sleeper.sql", + include_str!("../../migrations/0002_broken_sleeper.sql"), + ), + ( + "0003_shallow_gladiator.sql", + include_str!("../../migrations/0003_shallow_gladiator.sql"), + ), + ( + "0004_odd_silver_sable.sql", + include_str!("../../migrations/0004_odd_silver_sable.sql"), + ), + ( + "0005_old_mac_gargan.sql", + include_str!("../../migrations/0005_old_mac_gargan.sql"), + ), + ( + "0006_late_infant_terrible.sql", + include_str!("../../migrations/0006_late_infant_terrible.sql"), + ), + ( + "0007_safe_sunspot.sql", + include_str!("../../migrations/0007_safe_sunspot.sql"), + ), + ( + "0008_blushing_magma.sql", + include_str!("../../migrations/0008_blushing_magma.sql"), + ), + ( + "0009_lumpy_gladiator.sql", + include_str!("../../migrations/0009_lumpy_gladiator.sql"), + ), + ( + "0010_dizzy_natasha_romanoff.sql", + include_str!("../../migrations/0010_dizzy_natasha_romanoff.sql"), + ), + ( + "0011_silent_shooting_star.sql", + include_str!("../../migrations/0011_silent_shooting_star.sql"), + ), + ( + "0012_lush_veda.sql", + include_str!("../../migrations/0012_lush_veda.sql"), + ), + ( + "0013_last_guardian.sql", + include_str!("../../migrations/0013_last_guardian.sql"), + ), + ( + "0014_dazzling_leopardon.sql", + include_str!("../../migrations/0014_dazzling_leopardon.sql"), + ), + ( + "0015_brief_hex.sql", + include_str!("../../migrations/0015_brief_hex.sql"), + ), + ( + "0016_lovely_mastermind.sql", + include_str!("../../migrations/0016_lovely_mastermind.sql"), + ), + ( + "0017_spotty_mongu.sql", + include_str!("../../migrations/0017_spotty_mongu.sql"), + ), + ( + "0018_greedy_stepford_cuckoos.sql", + include_str!("../../migrations/0018_greedy_stepford_cuckoos.sql"), + ), + ( + "0019_material_lethal_legion.sql", + include_str!("../../migrations/0019_material_lethal_legion.sql"), + ), + ( + "0020_search_reasons.sql", + include_str!("../../migrations/0020_search_reasons.sql"), + ), + ( + "0021_inbox_jobs.sql", + include_str!("../../migrations/0021_inbox_jobs.sql"), + ), + ( + "0022_notes_journal_vault.sql", + include_str!("../../migrations/0022_notes_journal_vault.sql"), + ), + ( + "0023_folder_configs.sql", + include_str!("../../migrations/0023_folder_configs.sql"), + ), + ( + "0024_google_calendar_foundation.sql", + include_str!("../../migrations/0024_google_calendar_foundation.sql"), + ), + ( + "0025_event_target_calendar.sql", + include_str!("../../migrations/0025_event_target_calendar.sql"), + ), + ( + "0026_calendar_field_clocks.sql", + include_str!("../../migrations/0026_calendar_field_clocks.sql"), + ), + ( + "0027_calendar_rich_fields.sql", + include_str!("../../migrations/0027_calendar_rich_fields.sql"), + ), + ( + "0028_calendar_source_last_error.sql", + include_str!("../../migrations/0028_calendar_source_last_error.sql"), + ), +]; + +pub fn bootstrap(conn: &mut Connection) -> AppResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY NOT NULL, + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + );", + )?; + + Ok(()) +} + +pub fn apply_pending(conn: &mut Connection) -> AppResult<()> { + bootstrap(conn)?; + + // Cheap upfront check: if every embedded migration is already recorded + // we skip the FK toggle entirely. The authoritative check happens again + // inside each per-migration IMMEDIATE transaction below — that is what + // makes concurrent runners safe; this is just an optimization. + let any_pending = { + let applied = applied_migrations(conn)?; + EMBEDDED.iter().any(|(name, _)| !applied.contains(*name)) + }; + if !any_pending { + return Ok(()); + } + + // Several Drizzle ports (e.g. 0002, 0009, 0013, 0018) rebuild tables via + // the `CREATE __new_X / INSERT / DROP X / RENAME` pattern. SQLite silently + // no-ops `PRAGMA foreign_keys = OFF` inside a transaction, so the in-SQL + // pragma is not enough. If the caller opened the connection with FK + // enforcement on (Db::open / Db::open_memory both do), `DROP TABLE X` + // would cascade-delete rows in tables that reference X *before* the + // migration copies them, silently corrupting populated databases on + // upgrade. Toggle FK off at connection scope around the replay, then + // re-enable and verify integrity afterwards. + let prev_fk: i64 = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?; + if prev_fk != 0 { + conn.execute_batch("PRAGMA foreign_keys = OFF")?; + } + + let result = replay_migrations(conn); + + // Restore FK enforcement on BOTH success and failure paths. If we don't, + // a mid-replay failure leaves the connection (which Db wraps in + // Arc> and reuses across the app) with FK checks + // permanently off. Best-effort: if the restore PRAGMA itself errors, we + // still propagate the original migration error. + if prev_fk != 0 { + let _ = conn.execute_batch("PRAGMA foreign_keys = ON"); + } + + result?; + + if prev_fk != 0 { + let violations = collect_fk_violations(conn)?; + if !violations.is_empty() { + return Err(AppError::Database(format!( + "foreign_key_check failed after migration replay: {violations:?}" + ))); + } + } + + Ok(()) +} + +/// Replay every embedded migration that has not yet been recorded. +/// +/// Each migration runs in its own `BEGIN IMMEDIATE` transaction so that +/// concurrent runners (two app processes against the same data DB) serialize +/// on the SQLite write lock instead of racing each other. Inside the tx we +/// re-check `schema_migrations` under that lock — if a sibling runner already +/// recorded this migration, we commit the empty tx and continue. This makes +/// the replay convergent: whichever runner gets the lock first applies; the +/// rest skip without crashing on `CREATE TABLE` (no `IF NOT EXISTS`) or on +/// the `schema_migrations.name` PRIMARY KEY. +fn replay_migrations(conn: &mut Connection) -> AppResult<()> { + for (name, sql) in EMBEDDED.iter() { + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + + let already_applied: i64 = tx.query_row( + "SELECT COUNT(*) FROM schema_migrations WHERE name = ?1", + params![name], + |row| row.get(0), + )?; + if already_applied > 0 { + tx.commit()?; + continue; + } + + tx.execute_batch(sql) + .map_err(|err| AppError::Database(format!("migration {name} failed: {err}")))?; + tx.execute( + "INSERT INTO schema_migrations (name) VALUES (?1)", + params![name], + )?; + tx.commit()?; + } + Ok(()) +} + +fn applied_migrations(conn: &Connection) -> AppResult> { + let mut stmt = conn.prepare("SELECT name FROM schema_migrations")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + let applied = rows.collect::, _>>()?; + + Ok(applied) +} + +fn collect_fk_violations(conn: &Connection) -> AppResult> { + let mut stmt = conn.prepare("PRAGMA foreign_key_check")?; + let rows = stmt.query_map([], |row| { + let table: String = row.get(0)?; + let rowid: Option = row.get(1)?; + let parent: String = row.get(2)?; + Ok(format!( + "{table}#{} -> {parent}", + rowid.map(|r| r.to_string()).unwrap_or_else(|| "?".into()) + )) + })?; + + Ok(rows.filter_map(Result::ok).collect()) +} diff --git a/apps/desktop-tauri/src-tauri/src/db/mod.rs b/apps/desktop-tauri/src-tauri/src/db/mod.rs new file mode 100644 index 000000000..4c775c7ad --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/mod.rs @@ -0,0 +1,92 @@ +//! SQLite DB layer. Owns the single process-wide data DB connection. + +use crate::error::{AppError, AppResult}; +use rusqlite::Connection; +use std::{ + path::Path, + sync::{Arc, Mutex, MutexGuard}, +}; + +pub mod migrations; + +pub mod bookmarks; +pub mod calendar_bindings; +pub mod calendar_events; +pub mod calendar_external_events; +pub mod calendar_sources; +pub mod folder_configs; +pub mod inbox; +pub mod note_metadata; +pub mod note_positions; +pub mod notes_cache; +pub mod projects; +pub mod reminders; +pub mod saved_filters; +pub mod search_reasons; +pub mod settings; +pub mod statuses; +pub mod sync_devices; +pub mod sync_history; +pub mod sync_queue; +pub mod sync_state; +pub mod tag_definitions; +pub mod tasks; + +pub type DbGuard<'a> = MutexGuard<'a, Connection>; + +#[derive(Clone)] +pub struct Db { + conn: Arc>, +} + +impl Db { + pub fn open(path: impl AsRef) -> AppResult { + let path = path.as_ref(); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let db = Self::with_init(Connection::open(path)?, |conn| { + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000;", + ) + })?; + db.with_conn(migrations::apply_pending)?; + Ok(db) + } + + #[cfg(any(test, feature = "test-helpers"))] + pub fn open_memory() -> AppResult { + let db = Self::with_init(Connection::open_in_memory()?, |conn| { + conn.execute_batch( + "PRAGMA journal_mode = MEMORY; + PRAGMA foreign_keys = ON;", + ) + })?; + db.with_conn(migrations::apply_pending)?; + Ok(db) + } + + pub fn with_conn(&self, f: impl FnOnce(&mut Connection) -> AppResult) -> AppResult { + let mut conn = self.conn()?; + f(&mut conn) + } + + pub fn conn(&self) -> AppResult> { + self.conn.lock().map_err(AppError::from) + } + + fn with_init( + conn: Connection, + init: impl FnOnce(&Connection) -> rusqlite::Result<()>, + ) -> AppResult { + init(&conn)?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/note_metadata.rs b/apps/desktop-tauri/src-tauri/src/db/note_metadata.rs new file mode 100644 index 000000000..169877601 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/note_metadata.rs @@ -0,0 +1,49 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct NoteMetadata { + pub id: String, + pub path: String, + pub title: String, + pub emoji: Option, + pub file_type: String, + pub mime_type: Option, + pub file_size: Option, + pub attachment_id: Option, + pub attachment_references: Option, + pub local_only: bool, + pub sync_policy: String, + pub journal_date: Option, + pub property_definition_names: Option, + pub clock: Option, + pub synced_at: Option, + pub created_at: String, + pub modified_at: String, + pub stored_at: String, +} + +impl NoteMetadata { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + path: row.get("path")?, + title: row.get("title")?, + emoji: row.get("emoji")?, + file_type: row.get("file_type")?, + mime_type: row.get("mime_type")?, + file_size: row.get("file_size")?, + attachment_id: row.get("attachment_id")?, + attachment_references: row.get("attachment_references")?, + local_only: row.get::<_, i64>("local_only")? != 0, + sync_policy: row.get("sync_policy")?, + journal_date: row.get("journal_date")?, + property_definition_names: row.get("property_definition_names")?, + clock: row.get("clock")?, + synced_at: row.get("synced_at")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + stored_at: row.get("stored_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/note_positions.rs b/apps/desktop-tauri/src-tauri/src/db/note_positions.rs new file mode 100644 index 000000000..91852f5f3 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/note_positions.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct NotePosition { + pub path: String, + pub folder_path: String, + pub position: i64, +} + +impl NotePosition { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + path: row.get("path")?, + folder_path: row.get("folder_path")?, + position: row.get("position")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/notes_cache.rs b/apps/desktop-tauri/src-tauri/src/db/notes_cache.rs new file mode 100644 index 000000000..257927d8b --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/notes_cache.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct PropertyDefinition { + pub name: String, + #[serde(rename = "type")] + pub r#type: String, + pub options: Option, + pub default_value: Option, + pub color: Option, + pub created_at: String, +} + +impl PropertyDefinition { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + name: row.get("name")?, + r#type: row.get("type")?, + options: row.get("options")?, + default_value: row.get("default_value")?, + color: row.get("color")?, + created_at: row.get("created_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/projects.rs b/apps/desktop-tauri/src-tauri/src/db/projects.rs new file mode 100644 index 000000000..19c7d5742 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/projects.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Project { + pub id: String, + pub name: String, + pub description: Option, + pub color: String, + pub icon: Option, + pub position: i64, + pub is_inbox: bool, + pub created_at: String, + pub modified_at: String, + pub archived_at: Option, + pub clock: Option, + pub field_clocks: Option, + pub synced_at: Option, +} + +impl Project { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + name: row.get("name")?, + description: row.get("description")?, + color: row.get("color")?, + icon: row.get("icon")?, + position: row.get("position")?, + is_inbox: row.get::<_, i64>("is_inbox")? != 0, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + archived_at: row.get("archived_at")?, + clock: row.get("clock")?, + field_clocks: row.get("field_clocks")?, + synced_at: row.get("synced_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/reminders.rs b/apps/desktop-tauri/src-tauri/src/db/reminders.rs new file mode 100644 index 000000000..474568a8d --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/reminders.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Reminder { + pub id: String, + pub target_type: String, + pub target_id: String, + pub remind_at: String, + pub highlight_text: Option, + pub highlight_start: Option, + pub highlight_end: Option, + pub title: Option, + pub note: Option, + pub status: String, + pub triggered_at: Option, + pub dismissed_at: Option, + pub snoozed_until: Option, + pub created_at: String, + pub modified_at: String, +} + +impl Reminder { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + target_type: row.get("target_type")?, + target_id: row.get("target_id")?, + remind_at: row.get("remind_at")?, + highlight_text: row.get("highlight_text")?, + highlight_start: row.get("highlight_start")?, + highlight_end: row.get("highlight_end")?, + title: row.get("title")?, + note: row.get("note")?, + status: row.get("status")?, + triggered_at: row.get("triggered_at")?, + dismissed_at: row.get("dismissed_at")?, + snoozed_until: row.get("snoozed_until")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/saved_filters.rs b/apps/desktop-tauri/src-tauri/src/db/saved_filters.rs new file mode 100644 index 000000000..380432b19 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/saved_filters.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SavedFilter { + pub id: String, + pub name: String, + pub config: String, + pub position: i64, + pub created_at: String, + pub clock: Option, + pub synced_at: Option, +} + +impl SavedFilter { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + name: row.get("name")?, + config: row.get("config")?, + position: row.get("position")?, + created_at: row.get("created_at")?, + clock: row.get("clock")?, + synced_at: row.get("synced_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/search_reasons.rs b/apps/desktop-tauri/src-tauri/src/db/search_reasons.rs new file mode 100644 index 000000000..473460d5e --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/search_reasons.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SearchReason { + pub id: String, + pub item_id: String, + pub item_type: String, + pub item_title: String, + pub item_icon: Option, + pub search_query: String, + pub visited_at: String, +} + +impl SearchReason { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + item_id: row.get("item_id")?, + item_type: row.get("item_type")?, + item_title: row.get("item_title")?, + item_icon: row.get("item_icon")?, + search_query: row.get("search_query")?, + visited_at: row.get("visited_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/settings.rs b/apps/desktop-tauri/src-tauri/src/db/settings.rs new file mode 100644 index 000000000..841025c77 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/settings.rs @@ -0,0 +1,57 @@ +use crate::db::Db; +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Setting { + pub key: String, + pub value: String, + pub modified_at: String, +} + +impl Setting { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + key: row.get("key")?, + value: row.get("value")?, + modified_at: row.get("modified_at")?, + }) + } +} + +pub fn get(db: &Db, key: &str) -> AppResult> { + let conn = db.conn()?; + let result = conn.query_row( + "SELECT value FROM settings WHERE key = ?1", + rusqlite::params![key], + |row| row.get::<_, String>(0), + ); + match result { + Ok(v) => Ok(Some(v)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::from(e)), + } +} + +pub fn set(db: &Db, key: &str, value: &str) -> AppResult<()> { + let conn = db.conn()?; + conn.execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + modified_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", + rusqlite::params![key, value], + )?; + Ok(()) +} + +pub fn list(db: &Db) -> AppResult> { + let conn = db.conn()?; + let mut stmt = conn.prepare("SELECT key, value, modified_at FROM settings ORDER BY key")?; + let items = stmt + .query_map([], Setting::from_row)? + .filter_map(Result::ok) + .collect(); + Ok(items) +} diff --git a/apps/desktop-tauri/src-tauri/src/db/statuses.rs b/apps/desktop-tauri/src-tauri/src/db/statuses.rs new file mode 100644 index 000000000..b6f8b10e7 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/statuses.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Status { + pub id: String, + pub project_id: String, + pub name: String, + pub color: String, + pub position: i64, + pub is_default: bool, + pub is_done: bool, + pub created_at: String, +} + +impl Status { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + project_id: row.get("project_id")?, + name: row.get("name")?, + color: row.get("color")?, + position: row.get("position")?, + is_default: row.get::<_, i64>("is_default")? != 0, + is_done: row.get::<_, i64>("is_done")? != 0, + created_at: row.get("created_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/sync_devices.rs b/apps/desktop-tauri/src-tauri/src/db/sync_devices.rs new file mode 100644 index 000000000..dd4f529dd --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/sync_devices.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SyncDevice { + pub id: String, + pub name: String, + pub platform: String, + pub os_version: Option, + pub app_version: String, + pub linked_at: i64, + pub last_sync_at: Option, + pub is_current_device: bool, + pub signing_public_key: String, +} + +impl SyncDevice { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + name: row.get("name")?, + platform: row.get("platform")?, + os_version: row.get("os_version")?, + app_version: row.get("app_version")?, + linked_at: row.get("linked_at")?, + last_sync_at: row.get("last_sync_at")?, + is_current_device: row.get::<_, i64>("is_current_device")? != 0, + signing_public_key: row.get("signing_public_key")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/sync_history.rs b/apps/desktop-tauri/src-tauri/src/db/sync_history.rs new file mode 100644 index 000000000..d95532a17 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/sync_history.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SyncHistoryEntry { + pub id: String, + #[serde(rename = "type")] + pub r#type: String, + pub item_count: i64, + pub direction: Option, + pub details: Option, + pub duration_ms: Option, + pub created_at: i64, +} + +impl SyncHistoryEntry { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + r#type: row.get("type")?, + item_count: row.get("item_count")?, + direction: row.get("direction")?, + details: row.get("details")?, + duration_ms: row.get("duration_ms")?, + created_at: row.get("created_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/sync_queue.rs b/apps/desktop-tauri/src-tauri/src/db/sync_queue.rs new file mode 100644 index 000000000..3526bdc23 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/sync_queue.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SyncQueueItem { + pub id: String, + #[serde(rename = "type")] + pub r#type: String, + pub item_id: String, + pub operation: String, + pub payload: String, + pub priority: i64, + pub attempts: i64, + pub last_attempt: Option, + pub error_message: Option, + pub created_at: i64, +} + +impl SyncQueueItem { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + r#type: row.get("type")?, + item_id: row.get("item_id")?, + operation: row.get("operation")?, + payload: row.get("payload")?, + priority: row.get("priority")?, + attempts: row.get("attempts")?, + last_attempt: row.get("last_attempt")?, + error_message: row.get("error_message")?, + created_at: row.get("created_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/sync_state.rs b/apps/desktop-tauri/src-tauri/src/db/sync_state.rs new file mode 100644 index 000000000..cadabdbd9 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/sync_state.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SyncState { + pub key: String, + pub value: String, + pub updated_at: i64, +} + +impl SyncState { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + key: row.get("key")?, + value: row.get("value")?, + updated_at: row.get("updated_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/tag_definitions.rs b/apps/desktop-tauri/src-tauri/src/db/tag_definitions.rs new file mode 100644 index 000000000..9c9181645 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/tag_definitions.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct TagDefinition { + pub name: String, + pub color: String, + pub clock: Option, + pub created_at: String, +} + +impl TagDefinition { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + name: row.get("name")?, + color: row.get("color")?, + clock: row.get("clock")?, + created_at: row.get("created_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/db/tasks.rs b/apps/desktop-tauri/src-tauri/src/db/tasks.rs new file mode 100644 index 000000000..a0addb373 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/db/tasks.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Task { + pub id: String, + pub project_id: String, + pub status_id: Option, + pub parent_id: Option, + pub title: String, + pub description: Option, + pub priority: i64, + pub position: i64, + pub due_date: Option, + pub due_time: Option, + pub start_date: Option, + pub repeat_config: Option, + pub repeat_from: Option, + pub source_note_id: Option, + pub completed_at: Option, + pub archived_at: Option, + pub clock: Option, + pub field_clocks: Option, + pub synced_at: Option, + pub created_at: String, + pub modified_at: String, +} + +impl Task { + pub fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + project_id: row.get("project_id")?, + status_id: row.get("status_id")?, + parent_id: row.get("parent_id")?, + title: row.get("title")?, + description: row.get("description")?, + priority: row.get("priority")?, + position: row.get("position")?, + due_date: row.get("due_date")?, + due_time: row.get("due_time")?, + start_date: row.get("start_date")?, + repeat_config: row.get("repeat_config")?, + repeat_from: row.get("repeat_from")?, + source_note_id: row.get("source_note_id")?, + completed_at: row.get("completed_at")?, + archived_at: row.get("archived_at")?, + clock: row.get("clock")?, + field_clocks: row.get("field_clocks")?, + synced_at: row.get("synced_at")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + }) + } +} diff --git a/apps/desktop-tauri/src-tauri/src/error.rs b/apps/desktop-tauri/src-tauri/src/error.rs new file mode 100644 index 000000000..7125940e5 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/error.rs @@ -0,0 +1,50 @@ +use serde::Serialize; + +#[derive(Debug, thiserror::Error, Serialize, specta::Type)] +#[serde(tag = "kind", content = "message")] +pub enum AppError { + #[error("database error: {0}")] + Database(String), + #[error("crypto error: {0}")] + Crypto(String), + #[error("vault locked")] + VaultLocked, + #[error("invalid password")] + InvalidPassword, + #[error("not found: {0}")] + NotFound(String), + #[error("network error: {0}")] + Network(String), + #[error("conflict: {0}")] + Conflict(String), + #[error("validation error: {0}")] + Validation(String), + #[error("internal error: {0}")] + Internal(String), +} + +impl From for AppError { + fn from(err: rusqlite::Error) -> Self { + AppError::Database(err.to_string()) + } +} + +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::Internal(err.to_string()) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> Self { + AppError::Validation(err.to_string()) + } +} + +impl From> for AppError { + fn from(err: std::sync::PoisonError) -> Self { + AppError::Internal(err.to_string()) + } +} + +pub type AppResult = Result; diff --git a/apps/desktop-tauri/src-tauri/src/lib.rs b/apps/desktop-tauri/src-tauri/src/lib.rs index 3bd384d2e..c1fca3699 100644 --- a/apps/desktop-tauri/src-tauri/src/lib.rs +++ b/apps/desktop-tauri/src-tauri/src/lib.rs @@ -1,9 +1,37 @@ +pub mod app_state; pub mod commands; +pub mod db; +pub mod error; + +use app_state::AppState; +use db::Db; +use directories::ProjectDirs; +use error::{AppError, AppResult}; +use std::path::PathBuf; + +fn resolve_db_path() -> AppResult { + let device = std::env::var("MEMRY_DEVICE").unwrap_or_else(|_| "default".to_string()); + let project_dirs = ProjectDirs::from("com", "memry", "memry") + .ok_or_else(|| AppError::Internal("could not determine OS project dirs".to_string()))?; + + Ok(project_dirs + .data_dir() + .join(format!("memry-{device}")) + .join("data.db")) +} + +fn init_app_state() -> AppResult { + let db_path = resolve_db_path()?; + let db = Db::open(db_path)?; + Ok(AppState::new(db)) +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let builder = tauri::Builder::default() + let app_state = init_app_state().expect("failed to initialize app state"); + tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .manage(app_state) .setup(|_app| { tracing_subscriber::fmt() .with_env_filter( @@ -12,11 +40,15 @@ pub fn run() { ) .json() .init(); - tracing::info!("memry desktop-tauri booting (m1 scaffold)"); + tracing::info!("memry desktop-tauri booting (m2 settings slice)"); Ok(()) - }); - - commands::register(builder) + }) + .invoke_handler(tauri::generate_handler![ + commands::settings::settings_get, + commands::settings::settings_set, + commands::settings::settings_list, + commands::lifecycle::notify_flush_done, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/desktop-tauri/src-tauri/tests/migrations_test.rs b/apps/desktop-tauri/src-tauri/tests/migrations_test.rs new file mode 100644 index 000000000..eac762a70 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/tests/migrations_test.rs @@ -0,0 +1,952 @@ +use memry_desktop_tauri_lib::db::migrations; +use rusqlite::Connection; + +#[test] +fn bootstraps_schema_migrations_table() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::bootstrap(&mut conn).unwrap(); + + let exists: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'", + [], + |row| row.get(0), + ) + .unwrap(); + + assert_eq!(exists, 1); +} + +#[test] +fn bootstrap_is_idempotent() { + let mut conn = Connection::open_in_memory().unwrap(); + + migrations::bootstrap(&mut conn).unwrap(); + migrations::bootstrap(&mut conn).unwrap(); +} + +#[test] +fn applies_embedded_migrations_in_order_and_records_them() { + let mut conn = Connection::open_in_memory().unwrap(); + + migrations::apply_pending(&mut conn).unwrap(); + + let applied: Vec = { + let mut stmt = conn + .prepare("SELECT name FROM schema_migrations ORDER BY applied_at, name") + .unwrap(); + stmt.query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap() + }; + let expected: Vec = migrations::EMBEDDED + .iter() + .map(|(name, _sql)| (*name).to_string()) + .collect(); + + assert_eq!(applied, expected); + + migrations::apply_pending(&mut conn).unwrap(); + + let still_applied: i64 = conn + .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| { + row.get(0) + }) + .unwrap(); + + assert_eq!(still_applied, expected.len() as i64); +} + +#[test] +fn migration_0000_creates_core_tables() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::apply_pending(&mut conn).unwrap(); + + for table in [ + "projects", + "statuses", + "tasks", + "task_notes", + "task_tags", + "inbox_items", + "saved_filters", + "settings", + ] { + let exists: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + [table], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(exists, 1, "table {table} missing after migration 0000"); + } +} + +#[test] +fn full_migration_produces_expected_table_set() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::apply_pending(&mut conn).unwrap(); + + let mut stmt = conn + .prepare( + "SELECT name FROM sqlite_master \ + WHERE type='table' AND name NOT LIKE 'sqlite_%' \ + ORDER BY name", + ) + .unwrap(); + let tables: Vec = stmt + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .filter_map(Result::ok) + .collect(); + + // Required tables traced to the migration that creates them (final state + // after all 29 ports apply). Any absence indicates a missing or wrongly + // ordered port. Update this list only if a port faithfully removed the + // table — never soften the assertion. + let required: &[&str] = &[ + // bootstrap + "schema_migrations", + // 0000 + "projects", + "statuses", + "tasks", + "task_notes", + "task_tags", + "inbox_items", + "saved_filters", + "settings", + // 0001 + "bookmarks", + // 0002 + "filing_history", + "inbox_item_tags", + "inbox_stats", + // 0003 + "suggestion_feedback", + // 0004 + "reminders", + // 0006 + "note_positions", + // 0007 + "tag_definitions", + // 0008 + "sync_devices", + "sync_queue", + "sync_state", + "sync_history", + // 0020 + "search_reasons", + // 0021 + "inbox_jobs", + // 0022 + "note_metadata", + "property_definitions", + // 0023 + "folder_configs", + // 0024 + "calendar_events", + "calendar_sources", + "calendar_external_events", + "calendar_bindings", + ]; + for name in required { + assert!( + tables.contains(&name.to_string()), + "required table {name} missing; present tables: {tables:?}" + ); + } + + // recent_searches was added in 0018 and dropped in 0020 — it MUST NOT + // remain in the final schema. Guards against accidentally re-adding it. + assert!( + !tables.contains(&"recent_searches".to_string()), + "recent_searches should have been dropped by 0020; present tables: {tables:?}" + ); +} + +#[test] +fn tasks_and_projects_have_field_clocks_column() { + let mut conn = Connection::open_in_memory().unwrap(); + migrations::apply_pending(&mut conn).unwrap(); + + for table in ["tasks", "projects"] { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .unwrap(); + let cols: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .filter_map(Result::ok) + .collect(); + assert!( + cols.contains(&"field_clocks".to_string()), + "{table} missing field_clocks column; cols: {cols:?}" + ); + } +} + +#[test] +fn rebuild_migrations_preserve_rows_when_fk_enforcement_is_on() { + // Regression test for a bug where Db::open enabled FK enforcement before + // running apply_pending. Drizzle's big rebuild migration (0018) uses the + // CREATE __new_X / INSERT / DROP X / RENAME pattern; SQLite silently + // no-ops the in-SQL `PRAGMA foreign_keys = OFF` inside a transaction, so + // DROP TABLE cascades through ON DELETE CASCADE relationships and wipes + // child rows before the migration copies them. apply_pending must toggle + // FK off at connection scope around the replay. + // + // Simulates the upgrade path: a developer at schema version 0017 with + // populated rows, then apply_pending runs 0018-0028. + let mut conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys = ON").unwrap(); + + migrations::bootstrap(&mut conn).unwrap(); + let upgrade_boundary = migrations::EMBEDDED + .iter() + .position(|(name, _)| name.starts_with("0018_")) + .expect("EMBEDDED must contain 0018_*"); + for (name, sql) in migrations::EMBEDDED.iter().take(upgrade_boundary) { + let tx = conn.transaction().unwrap(); + tx.execute_batch(sql).unwrap(); + tx.execute( + "INSERT INTO schema_migrations (name) VALUES (?1)", + rusqlite::params![name], + ) + .unwrap(); + tx.commit().unwrap(); + } + + conn.execute( + "INSERT INTO projects (id, name, color, position) VALUES ('p1', 'Inbox', '#000', 0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO statuses (id, project_id, name, color, position, is_default, is_done) \ + VALUES ('s1', 'p1', 'Todo', '#abc', 0, 1, 0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO tasks (id, project_id, status_id, title, priority, position) \ + VALUES ('t1', 'p1', 's1', 'first', 0, 0)", + [], + ) + .unwrap(); + + migrations::apply_pending(&mut conn).unwrap(); + + let project_count: i64 = conn + .query_row("SELECT COUNT(*) FROM projects", [], |row| row.get(0)) + .unwrap(); + let status_count: i64 = conn + .query_row("SELECT COUNT(*) FROM statuses", [], |row| row.get(0)) + .unwrap(); + let task_count: i64 = conn + .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get(0)) + .unwrap(); + + assert_eq!(project_count, 1, "projects row lost during rebuild replay"); + assert_eq!(status_count, 1, "statuses row lost during rebuild replay"); + assert_eq!(task_count, 1, "tasks row lost during rebuild replay"); + + // FK enforcement must be back on after apply_pending returns so the + // application sees referential integrity for subsequent writes. + let fk_state: i64 = conn + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(fk_state, 1, "apply_pending must restore FK enforcement"); +} + +#[test] +fn projects_and_tasks_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO projects (id, name, color, position) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params!["p1", "Inbox", "#000", 0], + ) + .unwrap(); + conn.execute( + "INSERT INTO statuses (id, project_id, name, color, position, is_default, is_done) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params!["s1", "p1", "Todo", "#abc", 0, 1, 0], + ) + .unwrap(); + conn.execute( + "INSERT INTO tasks (id, project_id, status_id, title, priority, position) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["t1", "p1", "s1", "first", 0, 0], + ) + .unwrap(); + + let project = conn + .query_row( + "SELECT * FROM projects WHERE id = 'p1'", + [], + memry_desktop_tauri_lib::db::projects::Project::from_row, + ) + .unwrap(); + assert_eq!(project.id, "p1"); + assert_eq!(project.name, "Inbox"); + assert!(!project.is_inbox); + + let status = conn + .query_row( + "SELECT * FROM statuses WHERE id = 's1'", + [], + memry_desktop_tauri_lib::db::statuses::Status::from_row, + ) + .unwrap(); + assert_eq!(status.project_id, "p1"); + assert!(status.is_default); + assert!(!status.is_done); + + let task = conn + .query_row( + "SELECT * FROM tasks WHERE id = 't1'", + [], + memry_desktop_tauri_lib::db::tasks::Task::from_row, + ) + .unwrap(); + assert_eq!(task.project_id, "p1"); + assert_eq!(task.status_id.as_deref(), Some("s1")); + assert_eq!(task.title, "first"); + assert!(task.clock.is_none()); + assert!(task.field_clocks.is_none()); +} + +#[test] +fn note_positions_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO note_positions (path, folder_path, position) VALUES (?1, ?2, ?3)", + rusqlite::params!["notes/foo.md", "notes", 3], + ) + .unwrap(); + + let pos = conn + .query_row( + "SELECT * FROM note_positions WHERE path = 'notes/foo.md'", + [], + memry_desktop_tauri_lib::db::note_positions::NotePosition::from_row, + ) + .unwrap(); + assert_eq!(pos.path, "notes/foo.md"); + assert_eq!(pos.folder_path, "notes"); + assert_eq!(pos.position, 3); +} + +#[test] +fn note_metadata_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO note_metadata \ + (id, path, title, file_type, local_only, sync_policy, created_at, modified_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + "n1", + "notes/hello.md", + "Hello", + "markdown", + 1, + "sync", + "2026-04-25T00:00:00.000Z", + "2026-04-25T00:00:00.000Z", + ], + ) + .unwrap(); + + let meta = conn + .query_row( + "SELECT * FROM note_metadata WHERE id = 'n1'", + [], + memry_desktop_tauri_lib::db::note_metadata::NoteMetadata::from_row, + ) + .unwrap(); + assert_eq!(meta.path, "notes/hello.md"); + assert_eq!(meta.title, "Hello"); + assert_eq!(meta.file_type, "markdown"); + assert!(meta.local_only); + assert_eq!(meta.sync_policy, "sync"); + assert!(meta.emoji.is_none()); + assert!(meta.clock.is_none()); + assert!(!meta.stored_at.is_empty()); +} + +#[test] +fn property_definitions_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO property_definitions (name, type) VALUES (?1, ?2)", + rusqlite::params!["status", "select"], + ) + .unwrap(); + + let prop = conn + .query_row( + "SELECT * FROM property_definitions WHERE name = 'status'", + [], + memry_desktop_tauri_lib::db::notes_cache::PropertyDefinition::from_row, + ) + .unwrap(); + assert_eq!(prop.name, "status"); + assert_eq!(prop.r#type, "select"); + assert!(prop.options.is_none()); + assert!(prop.color.is_none()); + assert!(!prop.created_at.is_empty()); +} + +#[test] +fn calendar_event_roundtrip() { + // Turkish-character roundtrip is embedded here (Phase D gotcha #3). + // If SQLite or rusqlite mangles UTF-8, the title compare will fail. + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + let title = "Toplantı: çay & kahve — ÖĞRENME günü"; + conn.execute( + "INSERT INTO calendar_events (id, title, start_at) VALUES (?1, ?2, ?3)", + rusqlite::params!["e1", title, "2026-04-25T10:00:00.000Z"], + ) + .unwrap(); + + let ev = conn + .query_row( + "SELECT * FROM calendar_events WHERE id = 'e1'", + [], + memry_desktop_tauri_lib::db::calendar_events::CalendarEvent::from_row, + ) + .unwrap(); + assert_eq!(ev.id, "e1"); + assert_eq!(ev.title, title, "Turkish UTF-8 must roundtrip byte-identical"); + assert_eq!(ev.start_at, "2026-04-25T10:00:00.000Z"); + assert_eq!(ev.timezone, "UTC"); + assert!(!ev.is_all_day); + assert!(ev.field_clocks.is_none()); + assert!(ev.target_calendar_id.is_none()); + assert!(ev.attendees.is_none()); +} + +#[test] +fn calendar_source_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO calendar_sources (id, provider, kind, remote_id, title) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params!["src1", "google", "calendar", "primary@example.com", "Primary"], + ) + .unwrap(); + + let src = conn + .query_row( + "SELECT * FROM calendar_sources WHERE id = 'src1'", + [], + memry_desktop_tauri_lib::db::calendar_sources::CalendarSource::from_row, + ) + .unwrap(); + assert_eq!(src.id, "src1"); + assert_eq!(src.provider, "google"); + assert_eq!(src.kind, "calendar"); + assert_eq!(src.remote_id, "primary@example.com"); + assert_eq!(src.title, "Primary"); + assert!(!src.is_primary); + assert!(!src.is_selected); + assert!(!src.is_memry_managed); + assert_eq!(src.sync_status, "idle"); + assert!(src.last_error.is_none()); +} + +#[test] +fn calendar_external_event_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO calendar_sources (id, provider, kind, remote_id, title) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params!["src1", "google", "calendar", "primary@example.com", "Primary"], + ) + .unwrap(); + conn.execute( + "INSERT INTO calendar_external_events \ + (id, source_id, remote_event_id, title, start_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params!["xev1", "src1", "remote-abc", "Standup", "2026-04-25T09:00:00.000Z"], + ) + .unwrap(); + + let xev = conn + .query_row( + "SELECT * FROM calendar_external_events WHERE id = 'xev1'", + [], + memry_desktop_tauri_lib::db::calendar_external_events::CalendarExternalEvent::from_row, + ) + .unwrap(); + assert_eq!(xev.id, "xev1"); + assert_eq!(xev.source_id, "src1"); + assert_eq!(xev.remote_event_id, "remote-abc"); + assert_eq!(xev.title, "Standup"); + assert_eq!(xev.status, "confirmed"); + assert!(!xev.is_all_day); + assert!(xev.attendees.is_none()); +} + +#[test] +fn inbox_item_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO inbox_items (id, type, title) VALUES (?1, ?2, ?3)", + rusqlite::params!["i1", "note", "Captured thought"], + ) + .unwrap(); + + let item = conn + .query_row( + "SELECT * FROM inbox_items WHERE id = 'i1'", + [], + memry_desktop_tauri_lib::db::inbox::InboxItem::from_row, + ) + .unwrap(); + assert_eq!(item.id, "i1"); + assert_eq!(item.r#type, "note"); + assert_eq!(item.title, "Captured thought"); + assert!(item.content.is_none()); + assert!(item.archived_at.is_none()); + assert!(item.clock.is_none()); + assert!(item.synced_at.is_none()); + assert!(item.capture_source.is_none()); + // local_only: ALTER ADD COLUMN INTEGER DEFAULT 0; on a fresh insert the + // default applies → Some(false). The Option mapping captures the + // Phase D gotcha that the column is nullable for backfilled rows. + assert_eq!(item.local_only, Some(false)); + assert_eq!(item.processing_status.as_deref(), Some("complete")); +} + +#[test] +fn bookmark_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO bookmarks (id, item_type, item_id, position) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params!["bm1", "note", "n42", 5], + ) + .unwrap(); + + let bm = conn + .query_row( + "SELECT * FROM bookmarks WHERE id = 'bm1'", + [], + memry_desktop_tauri_lib::db::bookmarks::Bookmark::from_row, + ) + .unwrap(); + assert_eq!(bm.id, "bm1"); + assert_eq!(bm.item_type, "note"); + assert_eq!(bm.item_id, "n42"); + assert_eq!(bm.position, 5); + assert!(!bm.created_at.is_empty()); +} + +#[test] +fn reminder_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO reminders (id, target_type, target_id, remind_at) \ + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params!["rem1", "note", "n42", "2026-04-25T15:00:00.000Z"], + ) + .unwrap(); + + let rem = conn + .query_row( + "SELECT * FROM reminders WHERE id = 'rem1'", + [], + memry_desktop_tauri_lib::db::reminders::Reminder::from_row, + ) + .unwrap(); + assert_eq!(rem.id, "rem1"); + assert_eq!(rem.target_type, "note"); + assert_eq!(rem.target_id, "n42"); + assert_eq!(rem.remind_at, "2026-04-25T15:00:00.000Z"); + assert_eq!(rem.status, "pending"); + assert!(rem.title.is_none()); + assert!(rem.highlight_text.is_none()); + assert!(rem.triggered_at.is_none()); +} + +#[test] +fn tag_definition_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO tag_definitions (name, color) VALUES (?1, ?2)", + rusqlite::params!["work", "#3b82f6"], + ) + .unwrap(); + + let tag = conn + .query_row( + "SELECT * FROM tag_definitions WHERE name = 'work'", + [], + memry_desktop_tauri_lib::db::tag_definitions::TagDefinition::from_row, + ) + .unwrap(); + assert_eq!(tag.name, "work"); + assert_eq!(tag.color, "#3b82f6"); + assert!(tag.clock.is_none()); + assert!(!tag.created_at.is_empty()); +} + +#[test] +fn folder_config_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO folder_configs (path, icon) VALUES (?1, ?2)", + rusqlite::params!["notes/projects", "📂"], + ) + .unwrap(); + + let cfg = conn + .query_row( + "SELECT * FROM folder_configs WHERE path = 'notes/projects'", + [], + memry_desktop_tauri_lib::db::folder_configs::FolderConfig::from_row, + ) + .unwrap(); + assert_eq!(cfg.path, "notes/projects"); + assert_eq!(cfg.icon.as_deref(), Some("📂")); + assert!(cfg.clock.is_none()); + assert!(!cfg.created_at.is_empty()); + assert!(!cfg.modified_at.is_empty()); +} + +#[test] +fn calendar_binding_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO calendar_bindings \ + (id, source_type, source_id, provider, remote_calendar_id, remote_event_id, \ + ownership_mode, writeback_mode) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + "b1", + "task", + "t1", + "google", + "primary@example.com", + "remote-event-1", + "memry-owned", + "two-way", + ], + ) + .unwrap(); + + let b = conn + .query_row( + "SELECT * FROM calendar_bindings WHERE id = 'b1'", + [], + memry_desktop_tauri_lib::db::calendar_bindings::CalendarBinding::from_row, + ) + .unwrap(); + assert_eq!(b.id, "b1"); + assert_eq!(b.source_type, "task"); + assert_eq!(b.source_id, "t1"); + assert_eq!(b.provider, "google"); + assert_eq!(b.remote_calendar_id, "primary@example.com"); + assert_eq!(b.remote_event_id, "remote-event-1"); + assert_eq!(b.ownership_mode, "memry-owned"); + assert_eq!(b.writeback_mode, "two-way"); + assert!(b.remote_version.is_none()); +} + +#[test] +fn sync_queue_item_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO sync_queue (id, type, item_id, operation, payload, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["q1", "task", "t1", "update", "{\"title\":\"x\"}", 1714003200000_i64], + ) + .unwrap(); + + let item = conn + .query_row( + "SELECT * FROM sync_queue WHERE id = 'q1'", + [], + memry_desktop_tauri_lib::db::sync_queue::SyncQueueItem::from_row, + ) + .unwrap(); + assert_eq!(item.id, "q1"); + assert_eq!(item.r#type, "task"); + assert_eq!(item.item_id, "t1"); + assert_eq!(item.operation, "update"); + assert_eq!(item.payload, "{\"title\":\"x\"}"); + assert_eq!(item.priority, 0); + assert_eq!(item.attempts, 0); + assert_eq!(item.created_at, 1714003200000); + assert!(item.last_attempt.is_none()); + assert!(item.error_message.is_none()); +} + +#[test] +fn sync_device_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO sync_devices \ + (id, name, platform, app_version, linked_at, is_current_device, signing_public_key) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + "d1", + "Kaan's MacBook", + "macos", + "0.1.0", + 1714003200000_i64, + 1, + "ed25519-pubkey-base64", + ], + ) + .unwrap(); + + let dev = conn + .query_row( + "SELECT * FROM sync_devices WHERE id = 'd1'", + [], + memry_desktop_tauri_lib::db::sync_devices::SyncDevice::from_row, + ) + .unwrap(); + assert_eq!(dev.id, "d1"); + assert_eq!(dev.name, "Kaan's MacBook"); + assert_eq!(dev.platform, "macos"); + assert_eq!(dev.app_version, "0.1.0"); + assert_eq!(dev.linked_at, 1714003200000); + assert!(dev.is_current_device); + assert_eq!(dev.signing_public_key, "ed25519-pubkey-base64"); + assert!(dev.os_version.is_none()); + assert!(dev.last_sync_at.is_none()); +} + +#[test] +fn sync_state_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO sync_state (key, value, updated_at) VALUES (?1, ?2, ?3)", + rusqlite::params!["server_cursor", "abc123", 1714003200000_i64], + ) + .unwrap(); + + let s = conn + .query_row( + "SELECT * FROM sync_state WHERE key = 'server_cursor'", + [], + memry_desktop_tauri_lib::db::sync_state::SyncState::from_row, + ) + .unwrap(); + assert_eq!(s.key, "server_cursor"); + assert_eq!(s.value, "abc123"); + assert_eq!(s.updated_at, 1714003200000); +} + +#[test] +fn sync_history_entry_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO sync_history (id, type, item_count, direction, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params!["h1", "tasks", 42, "pull", 1714003200000_i64], + ) + .unwrap(); + + let h = conn + .query_row( + "SELECT * FROM sync_history WHERE id = 'h1'", + [], + memry_desktop_tauri_lib::db::sync_history::SyncHistoryEntry::from_row, + ) + .unwrap(); + assert_eq!(h.id, "h1"); + assert_eq!(h.r#type, "tasks"); + assert_eq!(h.item_count, 42); + assert_eq!(h.direction.as_deref(), Some("pull")); + assert_eq!(h.created_at, 1714003200000); + assert!(h.details.is_none()); + assert!(h.duration_ms.is_none()); +} + +#[test] +fn search_reason_roundtrip() { + let db = memry_desktop_tauri_lib::db::Db::open_memory().unwrap(); + let conn = db.conn().unwrap(); + + conn.execute( + "INSERT INTO search_reasons \ + (id, item_id, item_type, item_title, search_query) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params!["sr1", "n42", "note", "Hello world", "hello"], + ) + .unwrap(); + + let r = conn + .query_row( + "SELECT * FROM search_reasons WHERE id = 'sr1'", + [], + memry_desktop_tauri_lib::db::search_reasons::SearchReason::from_row, + ) + .unwrap(); + assert_eq!(r.id, "sr1"); + assert_eq!(r.item_id, "n42"); + assert_eq!(r.item_type, "note"); + assert_eq!(r.item_title, "Hello world"); + assert_eq!(r.search_query, "hello"); + assert!(r.item_icon.is_none()); + assert!(!r.visited_at.is_empty()); +} + +#[test] +fn apply_pending_restores_fk_state_on_migration_failure() { + // Regression test for bug where a mid-replay migration error returned + // early without restoring the connection-scoped FK pragma. apply_pending + // toggles foreign_keys = OFF for the rebuild migrations (see + // rebuild_migrations_preserve_rows_when_fk_enforcement_is_on); if a + // migration fails after that toggle, the early `?` return left the + // connection with FK enforcement off. Subsequent application code + // sharing the same connection (Db wraps it in Arc>) + // would silently write child rows that violate referential integrity. + let mut conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys = ON").unwrap(); + + // Apply migration 0000 directly so the schema is at v0; we want 0001 + // to fail when apply_pending tries to replay it. + migrations::bootstrap(&mut conn).unwrap(); + let (name_0000, sql_0000) = migrations::EMBEDDED[0]; + let tx = conn.transaction().unwrap(); + tx.execute_batch(sql_0000).unwrap(); + tx.execute( + "INSERT INTO schema_migrations (name) VALUES (?1)", + rusqlite::params![name_0000], + ) + .unwrap(); + tx.commit().unwrap(); + + // Pre-create `bookmarks` with a conflicting schema. Migration 0001's + // `CREATE TABLE bookmarks` (no IF NOT EXISTS) will fail. + conn.execute_batch("CREATE TABLE bookmarks (foo TEXT)") + .unwrap(); + + let fk_before: i64 = conn + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(fk_before, 1, "precondition: FK enforcement must be on"); + + let result = migrations::apply_pending(&mut conn); + assert!( + result.is_err(), + "expected migration 0001 to fail due to schema collision, got {result:?}" + ); + + let fk_after: i64 = conn + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!( + fk_after, 1, + "PRAGMA foreign_keys must be restored to ON after a failed migration replay" + ); +} + +#[test] +fn apply_pending_is_safe_under_concurrent_runners() { + // Regression test for a race where two concurrent app processes + // (e.g. dev hot-reload, double-launch) both compute the same `pending` + // list before either records its writes. Without serialization, the + // second runner replays already-applied migrations and crashes on + // non-`IF NOT EXISTS` DDL (0001 `CREATE TABLE bookmarks`) or on the + // PRIMARY KEY conflict in `schema_migrations`. The fix re-checks + // applied state inside an IMMEDIATE per-migration transaction. + use std::sync::{Arc, Barrier}; + use std::thread; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("data.db"); + + // Bring the file into existence and set WAL on a single setup + // connection so worker threads don't race on the PRAGMA write. + { + let setup = Connection::open(&path).unwrap(); + setup + .execute_batch("PRAGMA journal_mode = WAL;") + .unwrap(); + } + + let barrier = Arc::new(Barrier::new(2)); + + let path1 = path.clone(); + let b1 = barrier.clone(); + let h1 = thread::spawn(move || { + let mut conn = Connection::open(&path1).unwrap(); + conn.execute_batch("PRAGMA busy_timeout = 5000;").unwrap(); + b1.wait(); + migrations::apply_pending(&mut conn) + }); + + let path2 = path.clone(); + let b2 = barrier.clone(); + let h2 = thread::spawn(move || { + let mut conn = Connection::open(&path2).unwrap(); + conn.execute_batch("PRAGMA busy_timeout = 5000;").unwrap(); + b2.wait(); + migrations::apply_pending(&mut conn) + }); + + let r1 = h1.join().unwrap(); + let r2 = h2.join().unwrap(); + + assert!(r1.is_ok(), "thread 1 apply_pending failed: {r1:?}"); + assert!(r2.is_ok(), "thread 2 apply_pending failed: {r2:?}"); + + let conn = Connection::open(&path).unwrap(); + let names: Vec = conn + .prepare("SELECT name FROM schema_migrations ORDER BY name") + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::>() + .unwrap(); + + let expected_count = migrations::EMBEDDED.len(); + assert_eq!( + names.len(), + expected_count, + "schema_migrations should have {expected_count} entries (one per migration), \ + got {} — duplicates indicate a concurrent insert slipped through: {names:?}", + names.len() + ); +} diff --git a/apps/desktop-tauri/src-tauri/tests/settings_test.rs b/apps/desktop-tauri/src-tauri/tests/settings_test.rs new file mode 100644 index 000000000..ef14b5106 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/tests/settings_test.rs @@ -0,0 +1,44 @@ +use memry_desktop_tauri_lib::db::{settings, Db}; + +#[test] +fn get_missing_key_returns_none() { + let db = Db::open_memory().unwrap(); + let v = settings::get(&db, "nonexistent").unwrap(); + assert_eq!(v, None); +} + +#[test] +fn set_then_get_roundtrip() { + let db = Db::open_memory().unwrap(); + settings::set(&db, "theme", "dark").unwrap(); + let v = settings::get(&db, "theme").unwrap(); + assert_eq!(v.as_deref(), Some("dark")); +} + +#[test] +fn set_upserts_existing_key() { + let db = Db::open_memory().unwrap(); + settings::set(&db, "theme", "dark").unwrap(); + settings::set(&db, "theme", "light").unwrap(); + let v = settings::get(&db, "theme").unwrap(); + assert_eq!(v.as_deref(), Some("light")); +} + +#[test] +fn list_returns_sorted_entries() { + let db = Db::open_memory().unwrap(); + settings::set(&db, "b-key", "2").unwrap(); + settings::set(&db, "a-key", "1").unwrap(); + settings::set(&db, "c-key", "3").unwrap(); + let items = settings::list(&db).unwrap(); + let keys: Vec<&str> = items.iter().map(|s| s.key.as_str()).collect(); + assert_eq!(keys, vec!["a-key", "b-key", "c-key"]); +} + +#[test] +fn set_and_get_preserve_utf8_multibyte_value() { + let db = Db::open_memory().unwrap(); + settings::set(&db, "general.label", "çalışma modu").unwrap(); + let v = settings::get(&db, "general.label").unwrap(); + assert_eq!(v.as_deref(), Some("çalışma modu")); +} diff --git a/apps/desktop-tauri/src/generated/bindings.ts b/apps/desktop-tauri/src/generated/bindings.ts index dd2741789..2e1f3e89e 100644 --- a/apps/desktop-tauri/src/generated/bindings.ts +++ b/apps/desktop-tauri/src/generated/bindings.ts @@ -1,8 +1,364 @@ -// !! AUTO-GENERATED BY `pnpm bindings:generate` !! -// Do not edit this file manually. It is regenerated from Rust command -// signatures in apps/desktop-tauri/src-tauri/src/commands/. -// -// At M1 there are no commands yet; this file is an empty export to preserve -// the import path for consumers. Subsequent milestones replace contents. - -export {} +// This file has been generated by Tauri Specta. Do not edit this file manually. + +import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core"; + +/** Commands */ +export const commands = { + settingsGet: (input: SettingsGetInput) => typedError(__TAURI_INVOKE("settings_get", { input })), + settingsSet: (input: SettingsSetInput) => typedError(__TAURI_INVOKE("settings_set", { input })), + settingsList: () => typedError(__TAURI_INVOKE("settings_list")), + /** + * Renderer→main signal that pending save flushes finished. + * + * At M2 there is no quit-orchestration coordinator on the Rust side; the M8.0 + * lifecycle milestone introduces a flush coordinator that gates window close + * on this notification. Until then the command is a thin no-op acknowledgement + * so the renderer's `useFlushOnQuit` hook can keep its existing contract + * without 404s through the mock router. + */ + notifyFlushDone: () => typedError(__TAURI_INVOKE("notify_flush_done")), +}; + +/* Types */ +export type AppError = { kind: "Database"; message: string } | { kind: "Crypto"; message: string } | { kind: "VaultLocked" } | { kind: "InvalidPassword" } | { kind: "NotFound"; message: string } | { kind: "Network"; message: string } | { kind: "Conflict"; message: string } | { kind: "Validation"; message: string } | { kind: "Internal"; message: string }; + +export type Bookmark = { + id: string, + itemType: string, + itemId: string, + position: number, + createdAt: string, +}; + +export type CalendarBinding = { + id: string, + sourceType: string, + sourceId: string, + provider: string, + remoteCalendarId: string, + remoteEventId: string, + ownershipMode: string, + writebackMode: string, + remoteVersion: string | null, + lastLocalSnapshot: string | null, + archivedAt: string | null, + clock: string | null, + syncedAt: string | null, + createdAt: string, + modifiedAt: string, +}; + +export type CalendarEvent = { + id: string, + title: string, + description: string | null, + location: string | null, + startAt: string, + endAt: string | null, + timezone: string, + isAllDay: boolean, + recurrenceRule: string | null, + recurrenceExceptions: string | null, + archivedAt: string | null, + clock: string | null, + syncedAt: string | null, + createdAt: string, + modifiedAt: string, + targetCalendarId: string | null, + fieldClocks: string | null, + attendees: string | null, + reminders: string | null, + visibility: string | null, + colorId: string | null, + conferenceData: string | null, + parentEventId: string | null, + originalStartTime: string | null, +}; + +export type CalendarExternalEvent = { + id: string, + sourceId: string, + remoteEventId: string, + remoteEtag: string | null, + remoteUpdatedAt: string | null, + title: string, + description: string | null, + location: string | null, + startAt: string, + endAt: string | null, + timezone: string | null, + isAllDay: boolean, + status: string, + recurrenceRule: string | null, + rawPayload: string | null, + archivedAt: string | null, + clock: string | null, + syncedAt: string | null, + createdAt: string, + modifiedAt: string, + attendees: string | null, + reminders: string | null, + visibility: string | null, + colorId: string | null, + conferenceData: string | null, +}; + +export type CalendarSource = { + id: string, + provider: string, + kind: string, + accountId: string | null, + remoteId: string, + title: string, + timezone: string | null, + color: string | null, + isPrimary: boolean, + isSelected: boolean, + isMemryManaged: boolean, + syncCursor: string | null, + syncStatus: string, + lastSyncedAt: string | null, + metadata: string | null, + archivedAt: string | null, + clock: string | null, + syncedAt: string | null, + createdAt: string, + modifiedAt: string, + lastError: string | null, +}; + +export type FolderConfig = { + path: string, + icon: string | null, + clock: string | null, + createdAt: string, + modifiedAt: string, +}; + +export type InboxItem = { + id: string, + type: string, + title: string, + content: string | null, + createdAt: string, + modifiedAt: string, + filedAt: string | null, + filedTo: string | null, + filedAction: string | null, + snoozedUntil: string | null, + snoozeReason: string | null, + viewedAt: string | null, + processingStatus: string | null, + processingError: string | null, + metadata: string | null, + attachmentPath: string | null, + thumbnailPath: string | null, + transcription: string | null, + transcriptionStatus: string | null, + sourceUrl: string | null, + sourceTitle: string | null, + archivedAt: string | null, + clock: string | null, + syncedAt: string | null, + localOnly: boolean | null, + captureSource: string | null, +}; + +export type NoteMetadata = { + id: string, + path: string, + title: string, + emoji: string | null, + fileType: string, + mimeType: string | null, + fileSize: number | null, + attachmentId: string | null, + attachmentReferences: string | null, + localOnly: boolean, + syncPolicy: string, + journalDate: string | null, + propertyDefinitionNames: string | null, + clock: string | null, + syncedAt: string | null, + createdAt: string, + modifiedAt: string, + storedAt: string, +}; + +export type NotePosition = { + path: string, + folderPath: string, + position: number, +}; + +export type Project = { + id: string, + name: string, + description: string | null, + color: string, + icon: string | null, + position: number, + isInbox: boolean, + createdAt: string, + modifiedAt: string, + archivedAt: string | null, + clock: string | null, + fieldClocks: string | null, + syncedAt: string | null, +}; + +export type PropertyDefinition = { + name: string, + type: string, + options: string | null, + defaultValue: string | null, + color: string | null, + createdAt: string, +}; + +export type Reminder = { + id: string, + targetType: string, + targetId: string, + remindAt: string, + highlightText: string | null, + highlightStart: number | null, + highlightEnd: number | null, + title: string | null, + note: string | null, + status: string, + triggeredAt: string | null, + dismissedAt: string | null, + snoozedUntil: string | null, + createdAt: string, + modifiedAt: string, +}; + +export type SavedFilter = { + id: string, + name: string, + config: string, + position: number, + createdAt: string, + clock: string | null, + syncedAt: string | null, +}; + +export type SearchReason = { + id: string, + itemId: string, + itemType: string, + itemTitle: string, + itemIcon: string | null, + searchQuery: string, + visitedAt: string, +}; + +export type Setting = { + key: string, + value: string, + modifiedAt: string, +}; + +export type SettingsGetInput = { + key: string, +}; + +export type SettingsSetInput = { + key: string, + value: string, +}; + +export type Status = { + id: string, + projectId: string, + name: string, + color: string, + position: number, + isDefault: boolean, + isDone: boolean, + createdAt: string, +}; + +export type SyncDevice = { + id: string, + name: string, + platform: string, + osVersion: string | null, + appVersion: string, + linkedAt: number, + lastSyncAt: number | null, + isCurrentDevice: boolean, + signingPublicKey: string, +}; + +export type SyncHistoryEntry = { + id: string, + type: string, + itemCount: number, + direction: string | null, + details: string | null, + durationMs: number | null, + createdAt: number, +}; + +export type SyncQueueItem = { + id: string, + type: string, + itemId: string, + operation: string, + payload: string, + priority: number, + attempts: number, + lastAttempt: number | null, + errorMessage: string | null, + createdAt: number, +}; + +export type SyncState = { + key: string, + value: string, + updatedAt: number, +}; + +export type TagDefinition = { + name: string, + color: string, + clock: string | null, + createdAt: string, +}; + +export type Task = { + id: string, + projectId: string, + statusId: string | null, + parentId: string | null, + title: string, + description: string | null, + priority: number, + position: number, + dueDate: string | null, + dueTime: string | null, + startDate: string | null, + repeatConfig: string | null, + repeatFrom: string | null, + sourceNoteId: string | null, + completedAt: string | null, + archivedAt: string | null, + clock: string | null, + fieldClocks: string | null, + syncedAt: string | null, + createdAt: string, + modifiedAt: string, +}; + +/* Tauri Specta runtime */ +async function typedError(result: Promise): Promise<{ status: "ok"; data: T } | { status: "error"; error: E }> { + try { + return { status: "ok", data: await result }; + } catch (e) { + if (e instanceof Error) throw e; + return { status: "error", error: e as any }; + } +} + diff --git a/apps/desktop-tauri/src/hooks/use-general-settings.test.tsx b/apps/desktop-tauri/src/hooks/use-general-settings.test.tsx new file mode 100644 index 000000000..3818d8f07 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/use-general-settings.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' + +// The global setup in tests/setup-dom.ts auto-mocks '@/lib/ipc/invoke' so most +// hook tests can keep mocking through window.api. Phase F's regression test +// for the onboarding persistence path needs the *real* invoke wrapper so we +// can prove the fix actually routes through settings_get / settings_set +// against a stubbed Tauri core. +vi.unmock('@/lib/ipc/invoke') + +vi.mock('@tauri-apps/api/core', () => { + const store = new Map() + + function readKey(args: unknown): string { + const payload = (args as { input?: { key?: string } } | undefined)?.input + return payload?.key ?? '' + } + + function readValue(args: unknown): string { + const payload = (args as { input?: { value?: string } } | undefined)?.input + return payload?.value ?? '' + } + + return { + invoke: vi.fn(async (cmd: string, args: unknown) => { + switch (cmd) { + case 'settings_set': + store.set(readKey(args), readValue(args)) + return undefined + case 'settings_get': + return store.get(readKey(args)) ?? null + case 'settings_list': + return Array.from(store.entries()).map(([key, value]) => ({ + key, + value, + modifiedAt: '2026-04-26T00:00:00.000Z' + })) + default: + throw new Error(`unmocked tauri invoke: ${cmd}`) + } + }), + __resetStore: () => { + store.clear() + } + } +}) + +import * as TauriCore from '@tauri-apps/api/core' +import { useGeneralSettings } from './use-general-settings' + +const tauriCoreMock = TauriCore as unknown as { __resetStore?: () => void } +const tauriInvokeFn = TauriCore.invoke as unknown as { + mock: { calls: [string, unknown][] } +} + +function calledCommands(): string[] { + return tauriInvokeFn.mock.calls.map(([cmd]) => cmd) +} + +describe('useGeneralSettings (Phase F regression)', () => { + beforeEach(() => { + tauriCoreMock.__resetStore?.() + vi.clearAllMocks() + }) + + it('routes load+save through the real settings_get/settings_set commands', async () => { + const { result } = renderHook(() => useGeneralSettings()) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await act(async () => { + await result.current.updateSettings({ onboardingCompleted: true }) + }) + + expect(calledCommands()).toContain('settings_get') + expect(calledCommands()).toContain('settings_set') + // No legacy domain-specific commands should be invoked through real Tauri. + expect(calledCommands()).not.toContain('settings_get_general_settings') + expect(calledCommands()).not.toContain('settings_set_general_settings') + }) + + it('persists onboardingCompleted through the real settings_get/set path', async () => { + const first = renderHook(() => useGeneralSettings()) + await waitFor(() => expect(first.result.current.isLoading).toBe(false)) + expect(first.result.current.settings.onboardingCompleted).toBe(false) + + await act(async () => { + const ok = await first.result.current.updateSettings({ onboardingCompleted: true }) + expect(ok).toBe(true) + }) + expect(first.result.current.settings.onboardingCompleted).toBe(true) + + first.unmount() + + const second = renderHook(() => useGeneralSettings()) + await waitFor(() => expect(second.result.current.isLoading).toBe(false)) + expect(second.result.current.settings.onboardingCompleted).toBe(true) + }) + + it('round-trips a partial appearance update without losing prior fields', async () => { + const first = renderHook(() => useGeneralSettings()) + await waitFor(() => expect(first.result.current.isLoading).toBe(false)) + + await act(async () => { + const ok = await first.result.current.updateSettings({ accentColor: '#abcdef' }) + expect(ok).toBe(true) + }) + await act(async () => { + const ok = await first.result.current.updateSettings({ fontFamily: 'inter' }) + expect(ok).toBe(true) + }) + + first.unmount() + + const second = renderHook(() => useGeneralSettings()) + await waitFor(() => expect(second.result.current.isLoading).toBe(false)) + expect(second.result.current.settings.accentColor).toBe('#abcdef') + expect(second.result.current.settings.fontFamily).toBe('inter') + expect(second.result.current.settings.theme).toBe('system') + }) +}) diff --git a/apps/desktop-tauri/src/hooks/use-general-settings.ts b/apps/desktop-tauri/src/hooks/use-general-settings.ts index 87eab54d1..5e22a6663 100644 --- a/apps/desktop-tauri/src/hooks/use-general-settings.ts +++ b/apps/desktop-tauri/src/hooks/use-general-settings.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { extractErrorMessage } from '@/lib/ipc-error' import { invoke } from '@/lib/ipc/invoke' import { subscribeEvent } from '@/lib/ipc/forwarder' @@ -9,6 +9,8 @@ interface SettingsChangedEvent { value: unknown } +const GENERAL_SETTINGS_KEY = 'general' + const DEFAULTS: GeneralSettingsDTO = { theme: 'system', fontSize: 'medium', @@ -28,17 +30,37 @@ interface UseGeneralSettingsReturn { updateSettings: (updates: Partial) => Promise } +function parseStoredSettings(raw: string | null): GeneralSettingsDTO { + if (!raw) return DEFAULTS + try { + const parsed = JSON.parse(raw) as Partial + return { ...DEFAULTS, ...parsed } + } catch { + return DEFAULTS + } +} + export function useGeneralSettings(): UseGeneralSettingsReturn { const [settings, setSettings] = useState(DEFAULTS) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) + const settingsRef = useRef(DEFAULTS) + + useEffect(() => { + settingsRef.current = settings + }, [settings]) useEffect(() => { let mounted = true const load = async (): Promise => { try { - const result = await invoke('settings_get_general_settings') - if (mounted) setSettings(result) + const raw = await invoke('settings_get', { + input: { key: GENERAL_SETTINGS_KEY } + }) + if (!mounted) return + const merged = parseStoredSettings(raw) + setSettings(merged) + settingsRef.current = merged } catch (err) { if (mounted) setError(extractErrorMessage(err, 'Failed to load general settings')) } finally { @@ -53,25 +75,26 @@ export function useGeneralSettings(): UseGeneralSettingsReturn { useEffect(() => { return subscribeEvent('settings-changed', (event) => { - if (event.key === 'general') { - setSettings((prev) => ({ ...prev, ...(event.value as Partial) })) + if (event.key === GENERAL_SETTINGS_KEY) { + setSettings((prev) => { + const next = { ...prev, ...(event.value as Partial) } + settingsRef.current = next + return next + }) } }) }, []) const updateSettings = useCallback( async (updates: Partial): Promise => { + const next = { ...settingsRef.current, ...updates } try { - const result = await invoke<{ success: boolean; error?: string }>( - 'settings_set_general_settings', - updates as unknown as Record - ) - if (result.success) { - setSettings((prev) => ({ ...prev, ...updates })) - return true - } - setError(result.error ?? 'Update failed') - return false + await invoke('settings_set', { + input: { key: GENERAL_SETTINGS_KEY, value: JSON.stringify(next) } + }) + setSettings(next) + settingsRef.current = next + return true } catch (err) { setError(extractErrorMessage(err, 'Failed to update general settings')) return false diff --git a/apps/desktop-tauri/src/hooks/useSettings.ts b/apps/desktop-tauri/src/hooks/useSettings.ts new file mode 100644 index 000000000..aefdc838e --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useSettings.ts @@ -0,0 +1,32 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { invoke } from '@/lib/ipc/invoke' +import type { Setting } from '@/generated/bindings' + +const settingsKey = (key: string) => ['settings', key] as const +const settingsListKey = ['settings', 'list'] as const + +export function useSetting(key: string) { + return useQuery({ + queryKey: settingsKey(key), + queryFn: () => invoke('settings_get', { input: { key } }) + }) +} + +export function useSettings() { + return useQuery({ + queryKey: settingsListKey, + queryFn: () => invoke('settings_list') + }) +} + +export function useSetSetting() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ key, value }: { key: string; value: string }) => + invoke('settings_set', { input: { key, value } }), + onSuccess: (_, { key }) => { + qc.invalidateQueries({ queryKey: settingsKey(key) }) + qc.invalidateQueries({ queryKey: settingsListKey }) + } + }) +} diff --git a/apps/desktop-tauri/src/lib/ipc/invoke.ts b/apps/desktop-tauri/src/lib/ipc/invoke.ts index 22033b9ea..6e296546f 100644 --- a/apps/desktop-tauri/src/lib/ipc/invoke.ts +++ b/apps/desktop-tauri/src/lib/ipc/invoke.ts @@ -22,12 +22,15 @@ export async function invoke( /** * Decides whether a command should be served by the mock router or routed to - * the real Tauri backend. At M1, every command uses mock. Future milestones - * extend the `realCommands` set with commands whose Rust implementation has - * landed. + * the real Tauri backend. M2 Phase F lights up the settings KV slice; every + * other domain still serves data through the JS-side mocks until its Rust + * implementation lands. Add a command here once its Rust handler ships. */ const realCommands = new Set([ - // No real commands at M1 — all mocked. M2+ adds entries here. + 'settings_get', + 'settings_set', + 'settings_list', + 'notify_flush_done' ]) function shouldUseMock(cmd: string): boolean { diff --git a/apps/desktop-tauri/src/lib/ipc/mocks/updater.test.ts b/apps/desktop-tauri/src/lib/ipc/mocks/updater.test.ts index 57d2db837..32ef7a140 100644 --- a/apps/desktop-tauri/src/lib/ipc/mocks/updater.test.ts +++ b/apps/desktop-tauri/src/lib/ipc/mocks/updater.test.ts @@ -1,49 +1,60 @@ -import { describe, it, expect } from 'vitest' +import type { AppUpdateState } from '@memry/contracts/ipc-updater' +import { describe, expect, it } from 'vitest' import { updaterRoutes } from './updater' +async function call(name: keyof typeof updaterRoutes, args?: unknown): Promise { + const handler = updaterRoutes[name] + if (!handler) throw new Error(`route ${String(name)} not registered`) + return handler(args) +} + describe('updaterRoutes', () => { - it('updater_check returns a no-update-available response by default', async () => { - const res = (await updaterRoutes.updater_check!(undefined)) as { - available: boolean - currentVersion: string - latestVersion: string | null - } - expect(res.available).toBe(false) - expect(typeof res.currentVersion).toBe('string') + it('updater_get_state returns the AppUpdateState shape with status="unavailable"', async () => { + // #given the M2 mock has no real update surface + // #when the renderer asks for current state + const res = (await call('updater_get_state')) as AppUpdateState + + // #then it gets a fully populated AppUpdateState + expect(res).toMatchObject({ + currentVersion: '2.0.0-alpha.1', + updateSupported: false, + availableVersion: null + }) + expect(['unavailable', 'idle', 'up-to-date']).toContain(res.status) }) - it('updater_download returns ok with a mocked progress report', async () => { - const res = (await updaterRoutes.updater_download!(undefined)) as { - ok: boolean - progress: number - } - expect(res.ok).toBe(true) - expect(typeof res.progress).toBe('number') + it('updater_check_for_updates marks state up-to-date and stamps lastCheckedAt', async () => { + // #when the renderer triggers a check + const res = (await call('updater_check_for_updates')) as AppUpdateState + + // #then the mock reports up-to-date and records the check time + expect(res.status).toBe('up-to-date') + expect(typeof res.lastCheckedAt).toBe('number') + expect(res.error).toBeNull() }) - it('updater_install returns ok but notes m1 cannot actually install', async () => { - const res = (await updaterRoutes.updater_install!(undefined)) as { - ok: boolean - reason: string - } - expect(res.ok).toBe(false) - expect(res.reason).toMatch(/m1/i) + it('updater_download_update returns a no-op state because updates are unsupported in M2', async () => { + // #when the renderer asks to download + const res = (await call('updater_download_update')) as AppUpdateState + + // #then the mock returns the AppUpdateState shape unchanged + expect(res.updateSupported).toBe(false) + expect(res.availableVersion).toBeNull() }) - it('updater_settings_get returns the updater settings', async () => { - const settings = (await updaterRoutes.updater_settings_get!(undefined)) as { - autoCheck: boolean - channel: string - } - expect(typeof settings.autoCheck).toBe('boolean') - expect(typeof settings.channel).toBe('string') + it('updater_quit_and_install resolves to undefined because there is nothing to install', async () => { + // #when the renderer asks to install + const res = await call('updater_quit_and_install') + + // #then the route resolves without payload + expect(res).toBeUndefined() }) - it('updater_settings_update merges the patch', async () => { - const settings = (await updaterRoutes.updater_settings_update!({ - autoCheck: false - })) as { autoCheck: boolean } - expect(settings.autoCheck).toBe(false) + it('legacy updater_check / updater_download / updater_install routes are removed', () => { + // #then the M2 mocks no longer expose the pre-M1 names + expect(updaterRoutes).not.toHaveProperty('updater_check') + expect(updaterRoutes).not.toHaveProperty('updater_download') + expect(updaterRoutes).not.toHaveProperty('updater_install') }) }) diff --git a/apps/desktop-tauri/src/lib/ipc/mocks/updater.ts b/apps/desktop-tauri/src/lib/ipc/mocks/updater.ts index 1ad5c546f..904d968dc 100644 --- a/apps/desktop-tauri/src/lib/ipc/mocks/updater.ts +++ b/apps/desktop-tauri/src/lib/ipc/mocks/updater.ts @@ -1,40 +1,45 @@ +import type { AppUpdateState } from '@memry/contracts/ipc-updater' import type { MockRouteMap } from './types' -interface UpdaterSettings { - autoCheck: boolean - channel: 'stable' | 'beta' | 'nightly' - lastCheckedAt: number | null -} +const CURRENT_VERSION = '2.0.0-alpha.1' -let settings: UpdaterSettings = { - autoCheck: true, - channel: 'stable', - lastCheckedAt: null +const baseState: AppUpdateState = { + currentVersion: CURRENT_VERSION, + status: 'unavailable', + updateSupported: false, + availableVersion: null, + releaseName: null, + releaseDate: null, + releaseNotes: null, + downloadProgressPercent: null, + lastCheckedAt: null, + error: null } -const currentVersion = '2.0.0-alpha.1' +let state: AppUpdateState = { ...baseState } + +function snapshot(): AppUpdateState { + return { ...state } +} +/** + * Mock surface for the auto-updater. The real Tauri command shim lands in M9 + * (auto-update milestone); until then `useAppUpdater` calls these mocked + * routes and observes that updates are not supported. The shape MUST match + * `AppUpdateState` from `@memry/contracts/ipc-updater` so future renderer + * tightening (e.g. discriminated union changes) breaks tests instead of + * silently disagreeing with the hook. + */ export const updaterRoutes: MockRouteMap = { - updater_check: async () => { - settings = { ...settings, lastCheckedAt: Date.now() } - return { - available: false, - currentVersion, - latestVersion: null, - releaseNotes: null, - checkedAt: settings.lastCheckedAt - } + updater_get_state: async () => snapshot(), + updater_check_for_updates: async () => { + state = { ...state, status: 'up-to-date', lastCheckedAt: Date.now(), error: null } + return snapshot() + }, + updater_download_update: async () => { + // Updater-not-supported in M2; returning the same state keeps the renderer + // banner inert without surfacing an error. + return snapshot() }, - updater_download: async () => ({ ok: true, progress: 0 }), - updater_install: async () => ({ - ok: false, - reason: 'updater-not-implemented-in-m1' - }), - updater_cancel: async () => ({ ok: true }), - updater_settings_get: async () => settings, - updater_settings_update: async (args) => { - const patch = args as Partial - settings = { ...settings, ...patch } - return settings - } + updater_quit_and_install: async () => undefined } diff --git a/apps/desktop-tauri/src/lib/logger.ts b/apps/desktop-tauri/src/lib/logger.ts index 2a25f9ba4..39d06c46f 100644 --- a/apps/desktop-tauri/src/lib/logger.ts +++ b/apps/desktop-tauri/src/lib/logger.ts @@ -1,7 +1,56 @@ -import log from 'electron-log/renderer' +/** + * Renderer-side logger for the Tauri build. + * + * Replaces the Electron-era `electron-log/renderer` import. M2 keeps the API + * surface identical (`createLogger(scope).info/.warn/.error/.debug`) so the + * 80+ call sites do not need to change. The implementation is a thin wrapper + * around `console.*` that prefixes each line with `[scope]` and gates + * `info` / `debug` behind dev mode so production builds stay quiet. + * + * Full Rust log forwarding (the `logging_forward` command shape from the + * design review) is deferred to M8.0 alongside the lifecycle/logging work; + * doing it earlier would require shipping a logging IPC channel before the + * crash-handler infrastructure that decides which lines to surface. + */ -function createLogger(scope: string) { - return log.scope(scope) +const isDev = (() => { + try { + return import.meta.env?.DEV === true + } catch { + return false + } +})() + +export interface ScopedLogger { + debug: (...args: unknown[]) => void + info: (...args: unknown[]) => void + warn: (...args: unknown[]) => void + error: (...args: unknown[]) => void +} + +function format(scope: string, level: string): string { + return `[${scope}] [${level}]` } -export { log, createLogger } +function createLogger(scope: string): ScopedLogger { + return { + debug: (...args) => { + if (!isDev) return + console.debug(format(scope, 'debug'), ...args) + }, + info: (...args) => { + if (!isDev) return + console.info(format(scope, 'info'), ...args) + }, + warn: (...args) => { + console.warn(format(scope, 'warn'), ...args) + }, + error: (...args) => { + console.error(format(scope, 'error'), ...args) + } + } +} + +const log: ScopedLogger = createLogger('app') + +export { createLogger, log } diff --git a/apps/desktop-tauri/src/lib/save-registry.test.ts b/apps/desktop-tauri/src/lib/save-registry.test.ts index b87b02ff6..2d77ef655 100644 --- a/apps/desktop-tauri/src/lib/save-registry.test.ts +++ b/apps/desktop-tauri/src/lib/save-registry.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -vi.mock('electron-log/renderer', () => ({ - default: { scope: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }) } -})) - import { registerPendingSave, unregisterPendingSave, diff --git a/apps/desktop-tauri/src/pages/settings/calendar-section.tsx b/apps/desktop-tauri/src/pages/settings/calendar-section.tsx index 143c4fdad..66e09e4a7 100644 --- a/apps/desktop-tauri/src/pages/settings/calendar-section.tsx +++ b/apps/desktop-tauri/src/pages/settings/calendar-section.tsx @@ -14,7 +14,7 @@ import { SettingRow, COMPACT_SELECT } from '@/components/settings/settings-primitives' -import type { CalendarSettings } from '@memry/contracts/settings-schemas' +import type { CalendarSettings } from '@/types/settings-schemas' const GLOBAL_CLICK_OPTIONS = [ { value: 'journal', label: 'Open Journal' }, diff --git a/apps/desktop-tauri/src/pages/settings/shortcuts-section.tsx b/apps/desktop-tauri/src/pages/settings/shortcuts-section.tsx index 31f3852b8..61ec40845 100644 --- a/apps/desktop-tauri/src/pages/settings/shortcuts-section.tsx +++ b/apps/desktop-tauri/src/pages/settings/shortcuts-section.tsx @@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge' import { Search, RotateCcw, X, AlertTriangle, Info } from '@/lib/icons' import { useKeyboardSettings } from '@/hooks/use-keyboard-settings' import { toast } from 'sonner' -import type { ShortcutBinding } from '@memry/contracts/settings-schemas' +import type { ShortcutBinding } from '@/types/settings-schemas' import type { ShortcutBindingDTO } from '@/types/preload-types' import { SHORTCUT_REGISTRY, diff --git a/apps/desktop-tauri/src/types/settings-schemas.ts b/apps/desktop-tauri/src/types/settings-schemas.ts new file mode 100644 index 000000000..6edf95688 --- /dev/null +++ b/apps/desktop-tauri/src/types/settings-schemas.ts @@ -0,0 +1,29 @@ +/** + * Settings type definitions used by the Tauri renderer. + * + * Phase F (M2) rehomes the narrow set of @memry/contracts/settings-schemas + * types directly touched by the real settings_* IPC slice (`useSettings` + * hook + acceptance grep paths). Other consumers in `src/lib/`, `src/hooks/`, + * and `src/types/preload-types.ts` still import from `@memry/contracts/*`; + * those references travel forward via the Phase G carry-forward ledger and + * land alongside their respective domain slices in later milestones. + * + * Types kept structurally identical to the upstream Zod-inferred shapes in + * packages/contracts/src/settings-schemas.ts so the local + package copies + * remain interchangeable wherever both flow through the same component tree. + */ + +export interface ShortcutBinding { + key: string + modifiers: { + meta?: boolean + ctrl?: boolean + shift?: boolean + alt?: boolean + } +} + +export interface CalendarSettings { + dayCellClickBehavior: 'journal' | 'calendar' + calendarPageClickOverride: 'inherit' | 'journal' | 'calendar' +} diff --git a/apps/desktop-tauri/tests/setup-dom.ts b/apps/desktop-tauri/tests/setup-dom.ts index e3a408514..6b9a9dbaa 100644 --- a/apps/desktop-tauri/tests/setup-dom.ts +++ b/apps/desktop-tauri/tests/setup-dom.ts @@ -8,7 +8,11 @@ import { vi } from 'vitest' import { cleanup } from '@testing-library/react' import { afterEach } from 'vitest' -vi.mock('electron-log/renderer', () => { +// Tauri-safe logger replacement (was electron-log/renderer in M1). +// Tests assert against `.error.toHaveBeenCalledWith(...)`, so every method +// must be a spy. The real `@/lib/logger` impl wraps `console.*`; mocking it +// here keeps log noise out of test output and lets tests inspect calls. +vi.mock('@/lib/logger', () => { const createScopedLogger = () => ({ debug: vi.fn(), info: vi.fn(), @@ -17,13 +21,8 @@ vi.mock('electron-log/renderer', () => { }) return { - default: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - scope: vi.fn(() => createScopedLogger()) - } + createLogger: vi.fn(() => createScopedLogger()), + log: createScopedLogger() } }) diff --git a/apps/desktop-tauri/tests/useSettings.test.tsx b/apps/desktop-tauri/tests/useSettings.test.tsx new file mode 100644 index 000000000..e9ece8aba --- /dev/null +++ b/apps/desktop-tauri/tests/useSettings.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' + +// Global setup in tests/setup-dom.ts auto-mocks '@/lib/ipc/invoke' so the rest +// of the renderer test suite can keep mocking through window.api. This file +// exercises the real invoke wrapper end-to-end against a stubbed Tauri core, +// so we unmock both module ids the wrapper may be registered under and then +// install a fake @tauri-apps/api/core that holds an in-memory KV store. +vi.unmock('@/lib/ipc/invoke') +vi.unmock('@/hooks/useSettings') + +vi.mock('@tauri-apps/api/core', () => { + const store = new Map() + + function readKey(args: unknown): string { + const payload = (args as { input?: { key?: string } } | undefined)?.input + return payload?.key ?? '' + } + + function readValue(args: unknown): string { + const payload = (args as { input?: { value?: string } } | undefined)?.input + return payload?.value ?? '' + } + + return { + invoke: vi.fn(async (cmd: string, args: unknown) => { + switch (cmd) { + case 'settings_set': + store.set(readKey(args), readValue(args)) + return undefined + case 'settings_get': + return store.get(readKey(args)) ?? null + case 'settings_list': + return Array.from(store.entries()).map(([key, value]) => ({ + key, + value, + modifiedAt: '2026-04-25T00:00:00.000Z' + })) + default: + throw new Error(`unmocked tauri invoke: ${cmd}`) + } + }) + } +}) + +import { useSetSetting, useSetting, useSettings } from '@/hooks/useSettings' + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } } + }) + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +describe('useSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('round-trips a value through the invoke boundary', async () => { + const wrapper = makeWrapper() + const { result } = renderHook( + () => { + const setSetting = useSetSetting() + const getSetting = useSetting('theme') + return { setSetting, getSetting } + }, + { wrapper } + ) + + result.current.setSetting.mutate({ key: 'theme', value: 'dark' }) + await waitFor(() => expect(result.current.setSetting.isSuccess).toBe(true)) + await waitFor(() => expect(result.current.getSetting.data).toBe('dark')) + }) + + it('reflects updates in the list query after mutation', async () => { + const wrapper = makeWrapper() + const { result } = renderHook( + () => { + const setSetting = useSetSetting() + const list = useSettings() + return { setSetting, list } + }, + { wrapper } + ) + + result.current.setSetting.mutate({ key: 'fontFamily', value: 'inter' }) + await waitFor(() => expect(result.current.setSetting.isSuccess).toBe(true)) + await waitFor(() => { + const items = result.current.list.data ?? [] + expect(items.some((s) => s.key === 'fontFamily' && s.value === 'inter')).toBe(true) + }) + }) + + it('returns null for an unset key', async () => { + const wrapper = makeWrapper() + const { result } = renderHook(() => useSetting('nonexistent'), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + }) +}) diff --git a/docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md b/docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md new file mode 100644 index 000000000..f28f52633 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md @@ -0,0 +1,4639 @@ +# M3 — Vault FS + File Watcher Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land vault filesystem in Rust — atomic `.md` read/write, YAML frontmatter parse/serialize, `notify` watcher emitting `vault-changed` events, vault-rooted JSON preferences, multi-vault registry, and the full vault Tauri command surface (`vault_open` / `vault_close` / `vault_list_notes` / `vault_read_note` / `vault_write_note` / status / config / switch / remove / reveal). Settle the Tauri replacement for `memry-file://` (allowlisted, byte-range, missing-image fallback). Ship the native dialog/shell command set (folder picker, file picker, reveal in Finder, open external URL, open local attachment) replacing Electron `shell.*` and `dialog.*`. Run a drag-drop path-resolution spike so file imports work without the Electron-only `File.path`. Swap the renderer mock IPC over to real Rust for every command this milestone implements. The data DB stays at the OS app-data path established in M2 — vaults are file-tree-only. + +**Architecture:** New `src-tauri/src/vault/` module owns the runtime: `paths` (canonicalize + escape guard), `fs` (atomic write + safe read + list), `frontmatter` (serde_yaml_ng parse / serialize / property extraction), `notes_io` (high-level `read_note_from_disk` / `write_note_to_disk`), `preferences` (per-vault JSON config under `/.memry/config.json`), `registry` (multi-vault list persisted under `/memry-{device}/vaults.json`), `state` (current vault + status + watcher handle behind interior mutability), `watcher` (notify v6 with 150ms path-keyed debounce, emits via `AppHandle`). `AppState` extends from `{ db }` to `{ db, vault }`. Vault commands (`commands/vault.rs`) are thin async wrappers; native commands (`commands/shell.rs`, `commands/dialog.rs`) wrap `tauri-plugin-shell` and `tauri-plugin-dialog`. The `memry-file://` custom URI scheme is implemented as a Tauri URI scheme protocol handler in `lib.rs::run` with vault-allowlisted reads, byte-range support, and 1×1 transparent-PNG fallback for missing images. + +**Tech Stack:** notify 6.x (FSEvents on macOS), serde_yaml_ng 0.10 (maintained fork of serde_yaml), mime_guess 2.x, sha2 0.10 (content hash for change detection), dunce 1.x (path canonicalization), tauri-plugin-dialog 2.x, tauri-plugin-shell 2.x (already in deps), tokio fs/sync, tempfile (dev-deps already), Vitest, Playwright WebKit. + +**Parent spec:** `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` (§4 M3, §5 cross-cutting conventions) + +**Predecessor plan:** `docs/superpowers/plans/2026-04-25-m2-db-schemas-migrations.md` (must be merged before M3 starts) + +--- + +## Pre-flight checks (do these before Task 1) + +- [ ] M2 PR merged to `main`: `git log --oneline main | head -10` shows the `m2(*)` series ending with `m2(devx): add MEMRY_DEVICE=A/B dev scripts` +- [ ] Rust toolchain: `rustc --version` returns 1.95+; `cargo --version` works +- [ ] Node 24.x active: `node --version` returns v24.x +- [ ] pnpm 10.x active: `pnpm --version` returns 10.x +- [ ] `apps/desktop-tauri/` boots on M2 baseline: `pnpm --filter @memry/desktop-tauri dev` opens a window +- [ ] Cargo green on `main`: `pnpm --filter @memry/desktop-tauri cargo:check && pnpm --filter @memry/desktop-tauri cargo:clippy && pnpm --filter @memry/desktop-tauri cargo:test` all exit 0 +- [ ] Bindings stable: `pnpm --filter @memry/desktop-tauri bindings:check` exits 0 against `main` +- [ ] Settings round-trip works against the M2 real-IPC slice (manual smoke from settings UI) +- [ ] Create a worktree for M3 isolation (per user preference — feedback_worktree.md): + +```bash +git worktree add ../spike-tauri-m3 -b m3/vault-fs-and-watcher main +cd ../spike-tauri-m3 +``` + +From this point, every path is relative to `../spike-tauri-m3`. + +- [ ] Confirm a scratch test vault path is available (or create one): + +```bash +mkdir -p ~/memry-test-vault-m3/notes ~/memry-test-vault-m3/journal ~/memry-test-vault-m3/attachments +``` + +The plan reuses this folder for manual smokes and the Task 16 100-note bench. + +--- + +## File Structure + +Files created or modified in M3: + +``` +apps/desktop-tauri/ +├── src-tauri/ +│ ├── Cargo.toml Task 1 (deps added) +│ ├── tauri.conf.json Task 13 (memry-file uri scheme) +│ ├── capabilities/ +│ │ └── default.json Task 12, 14 (dialog + shell + drag-drop) +│ │ +│ └── src/ +│ ├── lib.rs Task 9, 11, 13, 14 (vault wiring + protocol + plugins) +│ ├── error.rs Task 2 (AppError vault variants + From impls) +│ ├── app_state.rs Task 9 (add VaultRuntime field) +│ │ +│ ├── vault/ +│ │ ├── mod.rs Task 2 (module skeleton + re-exports) +│ │ ├── paths.rs Task 3 (new — canonicalize + traversal guard) +│ │ ├── fs.rs Task 4 (new — atomic write + safe read + list) +│ │ ├── frontmatter.rs Task 5 (new — parse / serialize / properties) +│ │ ├── notes_io.rs Task 6 (new — high-level read/write note) +│ │ ├── preferences.rs Task 7 (new — vault-root JSON config) +│ │ ├── registry.rs Task 8 (new — multi-vault list at OS data dir) +│ │ ├── state.rs Task 9 (new — VaultRuntime + status) +│ │ └── watcher.rs Task 10 (new — notify with debounce) +│ │ +│ └── commands/ +│ ├── mod.rs Task 11, 12 (register vault + shell + dialog) +│ ├── vault.rs Task 11 (new — 13 vault_* commands) +│ ├── shell.rs Task 12 (new — open / reveal commands) +│ └── dialog.rs Task 12 (new — folder / file picker) +│ +├── src/ +│ ├── lib/ +│ │ ├── ipc/ +│ │ │ ├── invoke.ts Task 15 (vault_*/shell_*/dialog_* swap) +│ │ │ └── mocks/ +│ │ │ └── vault.ts Task 15 (kept for vault_reindex deferred mock) +│ │ └── memry-file.ts Task 13 (new — toMemryFileUrl helper) +│ ├── services/ +│ │ └── vault-service.ts Task 15 (real-IPC swap; same exports) +│ └── generated/ +│ └── bindings.ts Task 15 (regenerated) +│ +├── e2e/ +│ └── specs/ +│ └── m3-vault-smoke.spec.ts Task 15 (new — open/list/read/write smoke) +│ +└── src-tauri/ + └── tests/ + ├── vault_paths_test.rs Task 3 (new — traversal + symlink guard) + ├── vault_fs_test.rs Task 4 (new — atomic write + safe read) + ├── vault_frontmatter_test.rs Task 5 (new — Turkish + multiline YAML) + ├── vault_notes_io_test.rs Task 6 (new — round-trip + ID auto-generation) + ├── vault_preferences_test.rs Task 7 (new — read/write/migration) + ├── vault_registry_test.rs Task 8 (new — add/remove/switch) + ├── vault_watcher_test.rs Task 10 (new — debounce + event emission) + └── vault_bench.rs Task 16 (new — 100-note <500ms bench) +``` + +--- + +## Task 1: Add notify, serde_yaml_ng, mime_guess, sha2, dunce, dialog plugin + +**Files:** +- Modify: `apps/desktop-tauri/src-tauri/Cargo.toml` + +- [ ] **Step 1.1: Inspect current `[dependencies]`** + +Run: + +```bash +grep -nE '^(rusqlite|tauri-plugin-shell|tokio|serde|serde_json|thiserror)' apps/desktop-tauri/src-tauri/Cargo.toml +``` + +Expected: hits for each line listed in the regex. Confirms M2 deps present and you do not duplicate keys. + +- [ ] **Step 1.2: Append M3 deps to `[dependencies]`** + +Edit `apps/desktop-tauri/src-tauri/Cargo.toml`. Locate the `# Declared for later milestones — unused at M1 but compile-verified` comment block and add directly after it: + +```toml +# Vault FS + watcher (M3) +notify = { version = "6.1", default-features = false, features = ["macos_fsevents"] } +serde_yaml_ng = "0.10" +mime_guess = "2.0" +sha2 = "0.10" +dunce = "1.0" + +# Native dialog + shell plugins (M3) +tauri-plugin-dialog = "2" +``` + +Notes: +- `notify` v6 with `macos_fsevents` keeps the dep tree narrow; the `crossbeam-channel` default feature is dropped so we use Tokio's mpsc instead. +- `serde_yaml_ng` is the maintained fork of `serde_yaml` (the original was archived in 2024). YAML 1.1 frontmatter compatibility with Electron's `gray-matter` output is unchanged. +- `mime_guess` resolves `.md`/`.png`/`.jpg`/`.pdf`/`.mp3`/`.mp4` MIME types for the `memry-file://` protocol (Task 13). +- `sha2` powers `generate_content_hash` for change-detection parity with Electron's djb2 (we upgrade to SHA-256 — pre-production, free hash swap). +- `dunce` is a tiny crate that strips Windows `\\?\` prefixes from canonicalized paths. Even though M3 is macOS-only, it handles macOS edge cases where `std::fs::canonicalize` returns `/private/var/...` instead of `/var/...` after symlink resolution. Using `dunce::canonicalize` consistently makes the path-comparison guard in Task 3 resilient. +- `tauri-plugin-dialog` is the Tauri 2 native folder/file picker. Already vendored by the Tauri 2 install but the crate must be a Cargo dep. + +- [ ] **Step 1.3: Verify lockfile updates and compiles** + +```bash +cd apps/desktop-tauri/src-tauri && cargo check +``` + +Expected: `Finished `dev` profile`. New deps are declared but unused; no warnings. + +- [ ] **Step 1.4: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/Cargo.toml apps/desktop-tauri/src-tauri/Cargo.lock +git commit -m "m3(deps): add notify, serde_yaml_ng, mime_guess, sha2, dunce, dialog plugin" +``` + +--- + +## Task 2: Extend `AppError` + scaffold `vault/` module skeleton + +**Files:** +- Modify: `apps/desktop-tauri/src-tauri/src/error.rs` +- Create: `apps/desktop-tauri/src-tauri/src/vault/mod.rs` +- Modify: `apps/desktop-tauri/src-tauri/src/lib.rs` + +- [ ] **Step 2.1: Add vault-specific error variants** + +Open `apps/desktop-tauri/src-tauri/src/error.rs`. Inside the `AppError` enum, after the `Validation(String)` variant, add: + +```rust + #[error("vault error: {0}")] + Vault(String), + #[error("path escape: {0}")] + PathEscape(String), + #[error("io error: {0}")] + Io(String), +``` + +Then below the existing `From` impl, replace it (the existing impl maps to `Internal`, which is wrong for filesystem context — vault paths surface IO errors all over the place): + +```rust +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::Io(err.to_string()) + } +} +``` + +And add new `From` impls below `From`: + +```rust +impl From for AppError { + fn from(err: serde_yaml_ng::Error) -> Self { + AppError::Validation(format!("yaml: {err}")) + } +} + +impl From for AppError { + fn from(err: notify::Error) -> Self { + AppError::Vault(format!("watcher: {err}")) + } +} +``` + +Verify the file compiles standalone: + +```bash +cd apps/desktop-tauri/src-tauri && cargo check +``` + +Expected: still passes (the new variants are unused but valid). + +- [ ] **Step 2.2: Create the vault module skeleton** + +Create `apps/desktop-tauri/src-tauri/src/vault/mod.rs`: + +```rust +//! Vault filesystem layer. +//! +//! Owns `.md` IO, frontmatter parse/serialize, the per-vault JSON +//! preferences blob, the multi-vault registry, and the live `notify` +//! watcher. Vault state (current path, watcher handle, status) lives in +//! `state::VaultRuntime`, held as `AppState.vault`. All commands in +//! `commands/vault.rs` are thin async wrappers that delegate here. +//! +//! Path discipline: every external path crosses through `paths.rs` +//! before any `fs.rs` call. There are no `tokio::fs` or `std::fs` calls +//! outside `fs.rs` and `preferences.rs`, so the canonicalize+escape +//! guard cannot be bypassed by accident. + +pub mod fs; +pub mod frontmatter; +pub mod notes_io; +pub mod paths; +pub mod preferences; +pub mod registry; +pub mod state; +pub mod watcher; + +pub use frontmatter::{NoteFrontmatter, ParsedNote}; +pub use notes_io::{NoteOnDisk, ReadNoteResult}; +pub use preferences::{VaultConfig, VaultPreferences}; +pub use registry::{VaultInfo, VaultRegistry}; +pub use state::{VaultRuntime, VaultStatus}; +``` + +- [ ] **Step 2.3: Wire the module into `lib.rs`** + +Open `apps/desktop-tauri/src-tauri/src/lib.rs`. After the existing `pub mod` declarations near the top, add: + +```rust +pub mod vault; +``` + +Verify: + +```bash +cd apps/desktop-tauri/src-tauri && cargo check +``` + +Expected: `error[E0583]: file not found for module` for each empty submodule. That is correct — the next tasks create them. Move on. + +- [ ] **Step 2.4: Stub each submodule so `cargo check` passes between tasks** + +Create each of these as a one-line empty file so the module tree compiles. Each gets fleshed out in Task 3-10. + +```bash +for name in paths fs frontmatter notes_io preferences registry state watcher; do + echo '//! Stubbed in Task 2; populated in later tasks.' \ + > apps/desktop-tauri/src-tauri/src/vault/$name.rs +done +``` + +- [ ] **Step 2.5: Stub re-exports do not exist yet — comment them out** + +Re-open `apps/desktop-tauri/src-tauri/src/vault/mod.rs` and comment out the `pub use` block until later tasks export the symbols: + +```rust +// Re-exports flesh out in later tasks: +// pub use frontmatter::{NoteFrontmatter, ParsedNote}; +// pub use notes_io::{NoteOnDisk, ReadNoteResult}; +// pub use preferences::{VaultConfig, VaultPreferences}; +// pub use registry::{VaultInfo, VaultRegistry}; +// pub use state::{VaultRuntime, VaultStatus}; +``` + +```bash +cd apps/desktop-tauri/src-tauri && cargo check +``` + +Expected: `Finished` with one or two `unused module` warnings. Acceptable for the scaffold step. + +- [ ] **Step 2.6: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/error.rs apps/desktop-tauri/src-tauri/src/lib.rs apps/desktop-tauri/src-tauri/src/vault +git commit -m "m3(vault): scaffold module + extend AppError with Vault/PathEscape/Io" +``` + +--- + +## Task 3: Path safety helpers — canonicalize + traversal guard + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/src/vault/paths.rs` +- Create: `apps/desktop-tauri/src-tauri/tests/vault_paths_test.rs` +- Modify: `apps/desktop-tauri/src-tauri/Cargo.toml` (add `[[test]]` entry) + +- [ ] **Step 3.1: Write the failing test file first** + +Create `apps/desktop-tauri/src-tauri/tests/vault_paths_test.rs`: + +```rust +use memry_desktop_tauri_lib::vault::paths; +use std::fs; +use std::os::unix::fs as unix_fs; + +fn make_vault() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("notes")).unwrap(); + fs::create_dir_all(dir.path().join("journal")).unwrap(); + dir +} + +#[test] +fn rejects_dotdot_escape() { + let vault = make_vault(); + let bad = "../outside.md"; + let err = paths::resolve_in_vault(vault.path(), bad).unwrap_err(); + assert!(matches!(err, memry_desktop_tauri_lib::error::AppError::PathEscape(_))); +} + +#[test] +fn rejects_absolute_outside_vault() { + let vault = make_vault(); + let bad = "/etc/passwd"; + let err = paths::resolve_in_vault(vault.path(), bad).unwrap_err(); + assert!(matches!(err, memry_desktop_tauri_lib::error::AppError::PathEscape(_))); +} + +#[test] +fn allows_normal_relative_path() { + let vault = make_vault(); + let ok = paths::resolve_in_vault(vault.path(), "notes/hello.md").unwrap(); + assert!(ok.starts_with(vault.path())); + assert!(ok.ends_with("notes/hello.md")); +} + +#[test] +fn rejects_symlink_escape() { + let vault = make_vault(); + let outside = tempfile::tempdir().unwrap(); + let secret = outside.path().join("secret.md"); + fs::write(&secret, "secret").unwrap(); + let link_path = vault.path().join("notes").join("link.md"); + unix_fs::symlink(&secret, &link_path).unwrap(); + let err = paths::resolve_in_vault(vault.path(), "notes/link.md").unwrap_err(); + assert!(matches!(err, memry_desktop_tauri_lib::error::AppError::PathEscape(_))); +} + +#[test] +fn rejects_hidden_dot_memry_directory() { + let vault = make_vault(); + let err = paths::resolve_in_vault(vault.path(), ".memry/data.db").unwrap_err(); + assert!(matches!(err, memry_desktop_tauri_lib::error::AppError::PathEscape(_))); +} + +#[test] +fn rejects_unsupported_extension() { + let vault = make_vault(); + let err = paths::resolve_supported(vault.path(), "notes/hello.exe").unwrap_err(); + assert!(matches!(err, memry_desktop_tauri_lib::error::AppError::Validation(_))); +} + +#[test] +fn allows_supported_extensions() { + let vault = make_vault(); + for ext in ["md", "png", "jpg", "jpeg", "gif", "webp", "pdf", "mp3", "mp4", "wav", "mov"] { + let rel = format!("notes/hello.{ext}"); + paths::resolve_supported(vault.path(), &rel) + .unwrap_or_else(|e| panic!("{ext} should be supported: {e}")); + } +} + +#[test] +fn to_relative_path_normalizes_separators() { + let vault = make_vault(); + let abs = vault.path().join("notes").join("foo.md"); + let rel = paths::to_relative_path(vault.path(), &abs).unwrap(); + assert_eq!(rel, "notes/foo.md"); +} + +#[test] +fn to_relative_path_rejects_outside_vault() { + let vault = make_vault(); + let outside = std::path::PathBuf::from("/etc/passwd"); + assert!(paths::to_relative_path(vault.path(), &outside).is_none()); +} +``` + +- [ ] **Step 3.2: Register the test binary in `Cargo.toml`** + +Open `apps/desktop-tauri/src-tauri/Cargo.toml`. Below the existing `[[test]]` blocks (`migrations_test`, `settings_test`), add: + +```toml +[[test]] +name = "vault_paths_test" +required-features = ["test-helpers"] +``` + +- [ ] **Step 3.3: Run the test to confirm it fails (no implementation yet)** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_paths_test +``` + +Expected: `error[E0432]: unresolved import `memry_desktop_tauri_lib::vault::paths`` plus undefined `resolve_in_vault` / `resolve_supported` / `to_relative_path` functions. RED. + +- [ ] **Step 3.4: Implement `vault/paths.rs`** + +Replace the stub `apps/desktop-tauri/src-tauri/src/vault/paths.rs` with: + +```rust +//! Vault path normalization + escape-guard. +//! +//! Every external path that crosses into vault FS goes through +//! `resolve_in_vault`. The function canonicalizes the input under the +//! vault root, refuses paths that escape via `..` or symlinks, and +//! refuses paths that touch the hidden `.memry/` app-internal folder. +//! `resolve_supported` adds an extension allowlist that matches the +//! Electron `isSupportedPath` check. +//! +//! Path strings inside the renderer are always vault-relative with +//! forward slashes (matches Electron's `normalizeRelativePath`). The +//! conversion to absolute happens here and stays internal to Rust. + +use crate::error::{AppError, AppResult}; +use std::path::{Component, Path, PathBuf}; + +const HIDDEN_APP_DIR: &str = ".memry"; + +const SUPPORTED_EXT: &[&str] = &[ + "md", "markdown", + "png", "jpg", "jpeg", "gif", "webp", "svg", + "pdf", + "mp3", "wav", "m4a", "ogg", + "mp4", "mov", "webm", +]; + +/// Normalize a vault-relative path to forward slashes. +pub fn normalize_relative(path: &str) -> String { + path.replace('\\', "/") +} + +/// Convert an absolute path inside `vault_root` to a forward-slashed +/// vault-relative path. Returns `None` if the path is outside the vault. +pub fn to_relative_path(vault_root: &Path, abs: &Path) -> Option { + let canonical_root = dunce::canonicalize(vault_root).ok()?; + let canonical_abs = dunce::canonicalize(abs).ok()?; + let stripped = canonical_abs.strip_prefix(&canonical_root).ok()?; + let s = stripped.to_string_lossy().replace('\\', "/"); + if s.is_empty() { + None + } else { + Some(s) + } +} + +/// Resolve a vault-relative path to an absolute path under `vault_root`. +/// Rejects: +/// - `..` segments anywhere in the input +/// - absolute inputs (must be vault-relative) +/// - paths whose first segment is `.memry/` +/// - paths whose canonical resolution lands outside the vault root +/// (catches symlink escapes; the symlink target is resolved before +/// the `starts_with` check) +pub fn resolve_in_vault(vault_root: &Path, rel: &str) -> AppResult { + let cleaned = normalize_relative(rel); + let candidate = Path::new(&cleaned); + + if candidate.is_absolute() { + return Err(AppError::PathEscape(format!( + "absolute path not allowed: {rel}" + ))); + } + + let mut depth: i32 = 0; + for component in candidate.components() { + match component { + Component::ParentDir => { + depth -= 1; + if depth < 0 { + return Err(AppError::PathEscape(format!("dotdot escape: {rel}"))); + } + } + Component::Normal(seg) => { + if depth == 0 && seg == HIDDEN_APP_DIR { + return Err(AppError::PathEscape(format!( + "hidden app dir not addressable: {rel}" + ))); + } + depth += 1; + } + Component::CurDir => {} + Component::RootDir | Component::Prefix(_) => { + return Err(AppError::PathEscape(format!( + "root component in relative path: {rel}" + ))); + } + } + } + + let vault_canonical = dunce::canonicalize(vault_root) + .map_err(|e| AppError::Vault(format!("vault root unreadable: {e}")))?; + let joined = vault_canonical.join(candidate); + + // Canonicalize if the file already exists; otherwise canonicalize + // the parent and re-attach the leaf. This handles "write a new file + // at notes/foo.md" while still catching symlink escapes on the + // existing parent. + let resolved = if joined.exists() { + dunce::canonicalize(&joined)? + } else if let Some(parent) = joined.parent() { + if parent.exists() { + let canon_parent = dunce::canonicalize(parent)?; + canon_parent.join(joined.file_name().ok_or_else(|| { + AppError::PathEscape(format!("missing file name: {rel}")) + })?) + } else { + // Parent does not exist either — assume the caller is + // about to create the directory tree. Use the joined path + // as-is; the caller's `create_dir_all` is responsible. + joined.clone() + } + } else { + joined.clone() + }; + + if !resolved.starts_with(&vault_canonical) { + return Err(AppError::PathEscape(format!( + "resolved path escaped vault: {rel}" + ))); + } + + Ok(resolved) +} + +/// `resolve_in_vault` plus an extension allowlist. Returns the absolute +/// path on success. +pub fn resolve_supported(vault_root: &Path, rel: &str) -> AppResult { + let resolved = resolve_in_vault(vault_root, rel)?; + let ext = resolved + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .ok_or_else(|| AppError::Validation(format!("missing extension: {rel}")))?; + if !SUPPORTED_EXT.contains(&ext.as_str()) { + return Err(AppError::Validation(format!( + "unsupported extension: .{ext}" + ))); + } + Ok(resolved) +} + +/// Heuristic: is this a markdown file by extension? +pub fn is_markdown(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| matches!(e.to_lowercase().as_str(), "md" | "markdown")) + .unwrap_or(false) +} +``` + +- [ ] **Step 3.5: Re-run the test to verify it passes** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_paths_test +``` + +Expected: `9 passed`. + +- [ ] **Step 3.6: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/vault/paths.rs \ + apps/desktop-tauri/src-tauri/tests/vault_paths_test.rs \ + apps/desktop-tauri/src-tauri/Cargo.toml +git commit -m "m3(vault): paths.rs canonicalize + traversal/symlink/hidden-dir guard" +``` + +--- + +## Task 4: Atomic write + safe read + list (`vault/fs.rs`) + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/src/vault/fs.rs` +- Create: `apps/desktop-tauri/src-tauri/tests/vault_fs_test.rs` +- Modify: `apps/desktop-tauri/src-tauri/Cargo.toml` (add `[[test]]` entry) + +- [ ] **Step 4.1: Write the failing test** + +Create `apps/desktop-tauri/src-tauri/tests/vault_fs_test.rs`: + +```rust +use memry_desktop_tauri_lib::vault::fs as vfs; +use memry_desktop_tauri_lib::vault::paths; +use std::fs; + +fn make_vault() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("notes")).unwrap(); + dir +} + +#[tokio::test] +async fn atomic_write_creates_file() { + let vault = make_vault(); + let abs = paths::resolve_in_vault(vault.path(), "notes/hello.md").unwrap(); + vfs::atomic_write(&abs, "# hello\n").await.unwrap(); + let read_back = fs::read_to_string(&abs).unwrap(); + assert_eq!(read_back, "# hello\n"); +} + +#[tokio::test] +async fn atomic_write_replaces_existing_file() { + let vault = make_vault(); + let abs = paths::resolve_in_vault(vault.path(), "notes/hello.md").unwrap(); + vfs::atomic_write(&abs, "first").await.unwrap(); + vfs::atomic_write(&abs, "second").await.unwrap(); + let read_back = fs::read_to_string(&abs).unwrap(); + assert_eq!(read_back, "second"); +} + +#[tokio::test] +async fn atomic_write_creates_parent_dirs() { + let vault = make_vault(); + let abs = paths::resolve_in_vault(vault.path(), "notes/sub/deep/foo.md").unwrap(); + vfs::atomic_write(&abs, "x").await.unwrap(); + assert!(abs.exists()); +} + +#[tokio::test] +async fn atomic_write_cleans_up_temp_on_failure() { + let vault = make_vault(); + // Write a directory at the target path, then try to atomic-write a + // file there. The rename must fail and leave no `.tmp.` files + // in the parent. + let target_dir = vault.path().join("notes").join("blocking"); + fs::create_dir_all(&target_dir).unwrap(); + + let abs = vault.path().join("notes").join("blocking"); + let result = vfs::atomic_write(&abs, "x").await; + assert!(result.is_err()); + + let leftover: Vec<_> = fs::read_dir(vault.path().join("notes")) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with('.')) + .filter(|e| e.file_name().to_string_lossy().contains(".tmp.")) + .collect(); + assert!(leftover.is_empty(), "temp file leaked: {leftover:?}"); +} + +#[tokio::test] +async fn safe_read_returns_none_for_missing() { + let vault = make_vault(); + let abs = vault.path().join("notes").join("missing.md"); + let result = vfs::safe_read(&abs).await.unwrap(); + assert!(result.is_none()); +} + +#[tokio::test] +async fn safe_read_returns_content_for_existing() { + let vault = make_vault(); + let abs = vault.path().join("notes").join("hi.md"); + fs::write(&abs, "hi").unwrap(); + let result = vfs::safe_read(&abs).await.unwrap(); + assert_eq!(result.as_deref(), Some("hi")); +} + +#[tokio::test] +async fn list_supported_files_skips_hidden_and_unsupported() { + let vault = make_vault(); + fs::write(vault.path().join("notes/keep.md"), "k").unwrap(); + fs::write(vault.path().join("notes/.hidden.md"), "h").unwrap(); + fs::write(vault.path().join("notes/skip.exe"), "x").unwrap(); + fs::create_dir_all(vault.path().join(".memry")).unwrap(); + fs::write(vault.path().join(".memry/data.db"), "db").unwrap(); + let entries = vfs::list_supported_files(vault.path()).await.unwrap(); + let names: Vec<&str> = entries.iter().map(String::as_str).collect(); + assert!(names.contains(&"notes/keep.md")); + assert!(!names.iter().any(|n| n.contains(".hidden"))); + assert!(!names.iter().any(|n| n.ends_with(".exe"))); + assert!(!names.iter().any(|n| n.starts_with(".memry"))); +} + +#[test] +fn content_hash_is_stable_for_same_content() { + let h1 = vfs::content_hash("hello world"); + let h2 = vfs::content_hash("hello world"); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); // sha256 hex +} + +#[test] +fn content_hash_differs_for_different_content() { + assert_ne!(vfs::content_hash("a"), vfs::content_hash("b")); +} +``` + +- [ ] **Step 4.2: Register the test in `Cargo.toml`** + +Append below the existing `[[test]]` blocks: + +```toml +[[test]] +name = "vault_fs_test" +required-features = ["test-helpers"] +``` + +- [ ] **Step 4.3: Run the test (RED)** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_fs_test +``` + +Expected: unresolved imports for `atomic_write`, `safe_read`, `list_supported_files`, `content_hash`. RED. + +- [ ] **Step 4.4: Implement `vault/fs.rs`** + +Replace the stub `apps/desktop-tauri/src-tauri/src/vault/fs.rs` with: + +```rust +//! Filesystem ops for the vault layer. +//! +//! - `atomic_write`: temp-file-then-rename pattern. Survives mid-write +//! crash because the partial temp file is never visible at the final +//! path. POSIX `rename(2)` is atomic on the same filesystem; on +//! macOS the .memry-internal data DB and the vault tree are usually +//! on the same APFS volume. +//! - `safe_read`: returns `None` for missing files, errors otherwise. +//! - `list_supported_files`: depth-first walk that skips hidden +//! entries (`.foo`), `.memry` app-internal dir, and unsupported +//! extensions. Output is forward-slashed vault-relative paths. +//! - `content_hash`: SHA-256 hex of UTF-8 bytes for change detection. +//! +//! All functions receive already-canonicalized absolute paths from +//! `vault::paths::resolve_in_vault` — they do NOT re-validate. + +use crate::error::{AppError, AppResult}; +use crate::vault::paths; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +const TEMP_PREFIX: &str = ".tmp."; + +/// Atomically write `content` to `path`. Creates parent dirs as needed. +/// On failure, removes any leftover temp file in the same parent. +pub async fn atomic_write(path: &Path, content: &str) -> AppResult<()> { + let parent = path.parent().ok_or_else(|| { + AppError::Vault(format!("path has no parent: {}", path.display())) + })?; + fs::create_dir_all(parent).await?; + + let suffix = nanoid::nanoid!(12); + let temp_name = format!("{TEMP_PREFIX}{suffix}"); + let temp_path = parent.join(temp_name); + + let write_then_rename = async { + fs::write(&temp_path, content).await?; + fs::rename(&temp_path, path).await?; + Ok::<_, AppError>(()) + }; + + match write_then_rename.await { + Ok(()) => Ok(()), + Err(e) => { + let _ = fs::remove_file(&temp_path).await; + Err(e) + } + } +} + +/// Read a file as UTF-8. Returns `Ok(None)` if the file does not exist. +/// Other IO errors propagate as `AppError::Io`. +pub async fn safe_read(path: &Path) -> AppResult> { + match fs::read_to_string(path).await { + Ok(s) => Ok(Some(s)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(AppError::from(e)), + } +} + +/// Read a file or error if missing. +pub async fn read_required(path: &Path) -> AppResult { + safe_read(path).await?.ok_or_else(|| { + AppError::NotFound(format!("file not found: {}", path.display())) + }) +} + +/// Delete a file. No-op if it does not exist. +pub async fn delete_file(path: &Path) -> AppResult<()> { + match fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(AppError::from(e)), + } +} + +/// List every supported file under `vault_root`, returning forward- +/// slashed vault-relative paths sorted lexicographically. Skips: +/// - any directory or file whose basename starts with `.` +/// - the `.memry/` app-internal dir at the vault root +/// - unsupported file extensions (matches `paths::resolve_supported`) +/// - symlinks (defense in depth — `paths::resolve_in_vault` already +/// rejects symlink targets that escape, but the watcher walk skips +/// symlinks entirely so a symlink loop cannot DOS the scan) +pub async fn list_supported_files(vault_root: &Path) -> AppResult> { + let canonical_root = dunce::canonicalize(vault_root)?; + let mut out: Vec = Vec::new(); + let mut stack: Vec = vec![canonical_root.clone()]; + + while let Some(dir) = stack.pop() { + let mut entries = match fs::read_dir(&dir).await { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => continue, + Err(e) => return Err(AppError::from(e)), + }; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') { + continue; + } + let metadata = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + let path = entry.path(); + if metadata.file_type().is_symlink() { + continue; + } + if metadata.is_dir() { + stack.push(path); + } else if metadata.is_file() { + let lower_ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + if lower_ext.is_empty() { + continue; + } + if !paths::resolve_supported(&canonical_root, &format!("dummy.{lower_ext}")) + .is_ok() + && !paths::is_markdown(&path) + && !is_supported_attachment(&lower_ext) + { + continue; + } + if let Some(rel) = paths::to_relative_path(&canonical_root, &path) { + out.push(rel); + } + } + } + } + + out.sort(); + Ok(out) +} + +fn is_supported_attachment(ext: &str) -> bool { + matches!( + ext, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | + "pdf" | "mp3" | "wav" | "m4a" | "ogg" | "mp4" | "mov" | "webm" + ) +} + +/// SHA-256 hex of `content`. Used by the watcher and notes_io to skip +/// no-op rewrites and emit `vault-changed` only on real diffs. +pub fn content_hash(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + let digest = hasher.finalize(); + let mut hex = String::with_capacity(64); + for byte in digest { + hex.push_str(&format!("{byte:02x}")); + } + hex +} +``` + +- [ ] **Step 4.5: Add `nanoid` to Cargo.toml** + +`atomic_write` uses `nanoid::nanoid!` for temp-file suffixes. Add to `[dependencies]`: + +```toml +nanoid = "0.4" +``` + +- [ ] **Step 4.6: Run tests until green** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_fs_test +``` + +Expected: `9 passed`. If `atomic_write_cleans_up_temp_on_failure` is flaky, ensure the cleanup branch runs unconditionally on `Err` regardless of which step failed. + +- [ ] **Step 4.7: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/vault/fs.rs \ + apps/desktop-tauri/src-tauri/tests/vault_fs_test.rs \ + apps/desktop-tauri/src-tauri/Cargo.toml \ + apps/desktop-tauri/src-tauri/Cargo.lock +git commit -m "m3(vault): fs.rs atomic_write + safe_read + list + sha256 content_hash" +``` + +--- + +## Task 5: Frontmatter parse + serialize (`vault/frontmatter.rs`) + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/src/vault/frontmatter.rs` +- Create: `apps/desktop-tauri/src-tauri/tests/vault_frontmatter_test.rs` +- Modify: `apps/desktop-tauri/src-tauri/Cargo.toml` (add `[[test]]` entry, add `nanoid` reuse) + +- [ ] **Step 5.1: Write the failing test** + +Create `apps/desktop-tauri/src-tauri/tests/vault_frontmatter_test.rs`: + +```rust +use memry_desktop_tauri_lib::vault::frontmatter::{ + create_frontmatter, parse_note, serialize_note, NoteFrontmatter, +}; + +#[test] +fn parses_minimal_frontmatter() { + let raw = "---\nid: abc123\ntitle: Hello\ncreated: 2026-04-26T00:00:00Z\nmodified: 2026-04-26T00:00:00Z\n---\nbody"; + let parsed = parse_note(raw, Some("notes/hello.md")).unwrap(); + assert_eq!(parsed.frontmatter.id, "abc123"); + assert_eq!(parsed.frontmatter.title.as_deref(), Some("Hello")); + assert_eq!(parsed.content, "body"); + assert!(parsed.had_frontmatter); + assert!(!parsed.was_modified); +} + +#[test] +fn auto_generates_missing_required_fields() { + let raw = "no frontmatter here\n"; + let parsed = parse_note(raw, Some("notes/x.md")).unwrap(); + assert!(!parsed.frontmatter.id.is_empty()); + assert!(!parsed.frontmatter.created.is_empty()); + assert!(!parsed.frontmatter.modified.is_empty()); + assert!(parsed.was_modified); + assert_eq!(parsed.frontmatter.title.as_deref(), Some("X")); +} + +#[test] +fn extracts_title_from_filename_when_missing() { + let raw = "---\nid: x\ncreated: 2026-04-26T00:00:00Z\nmodified: 2026-04-26T00:00:00Z\n---\nbody"; + let parsed = parse_note(raw, Some("notes/my-cool-thought.md")).unwrap(); + assert_eq!(parsed.frontmatter.title.as_deref(), Some("My Cool Thought")); +} + +#[test] +fn turkish_chars_roundtrip_byte_identical() { + let title = "Toplantı: çay & kahve — ÖĞRENME günü"; + let body = "İçerik düzenlendi: çalışma & öğrenme."; + let mut fm = create_frontmatter(title, &["work".to_string()]); + fm.id = "fixed-id".to_string(); + let serialized = serialize_note(&fm, body).unwrap(); + let parsed = parse_note(&serialized, None).unwrap(); + assert_eq!(parsed.frontmatter.title.as_deref(), Some(title)); + assert_eq!(parsed.content, body); +} + +#[test] +fn multiline_yaml_string_roundtrip() { + let raw = "---\nid: x\ntitle: With Block\ncreated: 2026-04-26T00:00:00Z\nmodified: 2026-04-26T00:00:00Z\nproperties:\n description: |\n line one\n line two\n---\nbody"; + let parsed = parse_note(raw, None).unwrap(); + let props = parsed.frontmatter.properties.as_ref().unwrap(); + let desc = props.get("description").unwrap(); + assert!(desc.as_str().unwrap().contains("line one")); + assert!(desc.as_str().unwrap().contains("line two")); +} + +#[test] +fn date_field_preserved_as_string() { + let raw = "---\nid: x\ntitle: Dated\ncreated: 2026-04-26\nmodified: 2026-04-26T10:30:00Z\n---\nbody"; + let parsed = parse_note(raw, None).unwrap(); + assert_eq!(parsed.frontmatter.created, "2026-04-26"); + assert_eq!(parsed.frontmatter.modified, "2026-04-26T10:30:00Z"); +} + +#[test] +fn tags_normalized_to_vec_strings() { + let raw = "---\nid: x\ntitle: T\ncreated: 2026-04-26T00:00:00Z\nmodified: 2026-04-26T00:00:00Z\ntags:\n - work\n - life\n - WORK\n---\nbody"; + let parsed = parse_note(raw, None).unwrap(); + assert_eq!(parsed.frontmatter.tags, vec!["work", "life", "WORK"]); +} + +#[test] +fn preserves_non_reserved_properties_through_roundtrip() { + let raw = "---\nid: x\ntitle: T\ncreated: 2026-04-26T00:00:00Z\nmodified: 2026-04-26T00:00:00Z\nstatus: active\npriority: 3\n---\nbody"; + let parsed = parse_note(raw, None).unwrap(); + let serialized = serialize_note(&parsed.frontmatter, &parsed.content).unwrap(); + assert!(serialized.contains("status: active")); + assert!(serialized.contains("priority: 3")); +} + +#[test] +fn extract_properties_skips_reserved_keys() { + let raw = "---\nid: x\ntitle: T\ncreated: 2026-04-26T00:00:00Z\nmodified: 2026-04-26T00:00:00Z\ntags:\n - work\nstatus: active\nflag: true\n---\nbody"; + let parsed = parse_note(raw, None).unwrap(); + let props = parsed.frontmatter.extract_properties(); + assert!(props.contains_key("status")); + assert!(props.contains_key("flag")); + assert!(!props.contains_key("id")); + assert!(!props.contains_key("title")); + assert!(!props.contains_key("tags")); +} +``` + +- [ ] **Step 5.2: Register the test in `Cargo.toml`** + +```toml +[[test]] +name = "vault_frontmatter_test" +required-features = ["test-helpers"] +``` + +- [ ] **Step 5.3: Run RED** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_frontmatter_test +``` + +Expected: unresolved imports. + +- [ ] **Step 5.4: Implement `vault/frontmatter.rs`** + +Replace the stub with: + +```rust +//! YAML frontmatter parser, serializer, and property extractor. +//! +//! Parser is gray-matter-compatible enough for Electron-authored +//! `.md` files: `---` fence at the start, YAML 1.1 between fences, +//! body after. Reserved frontmatter keys (id/title/created/modified/ +//! tags/aliases/emoji/local_only/properties) are pulled into the +//! typed `NoteFrontmatter` struct; everything else is extracted by +//! `extract_properties()` for the renderer's property-definitions +//! system. +//! +//! Required field auto-fill matches Electron's `parseNote`: missing +//! `id` → fresh nanoid; missing `created`/`modified` → now ISO; the +//! caller checks `was_modified` to know when to re-serialize. + +use crate::error::{AppError, AppResult}; +use serde_yaml_ng as yaml; +use serde_yaml_ng::Value; +use std::collections::BTreeMap; +use std::path::Path; + +const RESERVED_KEYS: &[&str] = &[ + "id", "title", "created", "modified", "tags", "aliases", "emoji", + "localOnly", "properties", +]; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct NoteFrontmatter { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + pub created: String, + pub modified: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub emoji: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_only: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub properties: Option>, + /// Catch-all for non-reserved keys (matches Electron's + /// "top-level keys are properties unless reserved"). + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl NoteFrontmatter { + pub fn extract_properties(&self) -> BTreeMap { + if let Some(p) = &self.properties { + return p.clone(); + } + let mut out = BTreeMap::new(); + for (k, v) in &self.extra { + if !RESERVED_KEYS.contains(&k.as_str()) { + out.insert(k.clone(), v.clone()); + } + } + out + } +} + +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ParsedNote { + pub frontmatter: NoteFrontmatter, + pub content: String, + pub had_frontmatter: bool, + pub was_modified: bool, +} + +/// Parse a markdown file with optional YAML frontmatter. Auto-fills +/// required fields when missing. +pub fn parse_note(raw: &str, file_path: Option<&str>) -> AppResult { + let (mut yaml_text, body, had_frontmatter) = split_frontmatter(raw); + + let mut data: BTreeMap = if yaml_text.is_empty() { + BTreeMap::new() + } else { + yaml::from_str(&yaml_text).unwrap_or_default() + }; + let _ = &mut yaml_text; + + let now = current_iso(); + let mut was_modified = false; + + let id = match data.get("id").and_then(Value::as_str) { + Some(v) if !v.is_empty() => v.to_string(), + _ => { + was_modified = true; + generate_note_id() + } + }; + + let created = match data.get("created") { + Some(Value::String(s)) => s.clone(), + Some(other) => yaml_value_to_string(other), + None => { + was_modified = true; + now.clone() + } + }; + let modified = match data.get("modified") { + Some(Value::String(s)) => s.clone(), + Some(other) => yaml_value_to_string(other), + None => { + was_modified = true; + now.clone() + } + }; + + let title = data.get("title").and_then(Value::as_str).map(|s| s.to_string()).or_else(|| { + file_path.map(extract_title_from_path) + }); + + let tags = data + .get("tags") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let aliases = data + .get("aliases") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let emoji = data.get("emoji").and_then(Value::as_str).map(String::from); + let local_only = data.get("localOnly").and_then(Value::as_bool); + let properties = data + .get("properties") + .and_then(|v| v.as_mapping()) + .map(|map| { + let mut out = BTreeMap::new(); + for (k, v) in map.iter() { + if let Some(key) = k.as_str() { + out.insert(key.to_string(), v.clone()); + } + } + out + }); + + let mut extra = BTreeMap::new(); + for (k, v) in data.into_iter() { + if !RESERVED_KEYS.contains(&k.as_str()) { + extra.insert(k, v); + } + } + + let frontmatter = NoteFrontmatter { + id, + title, + created, + modified, + tags, + aliases, + emoji, + local_only, + properties, + extra, + }; + + Ok(ParsedNote { + frontmatter, + content: body.trim().to_string(), + had_frontmatter, + was_modified, + }) +} + +/// Serialize frontmatter + body back to a markdown file. Bumps +/// `modified` to now. +pub fn serialize_note(fm: &NoteFrontmatter, content: &str) -> AppResult { + let mut out = NoteFrontmatter { + modified: current_iso(), + ..fm.clone() + }; + let _ = &mut out; + + let mut map = BTreeMap::::new(); + map.insert("id".into(), Value::String(out.id.clone())); + if let Some(title) = &out.title { + map.insert("title".into(), Value::String(title.clone())); + } + map.insert("created".into(), Value::String(out.created.clone())); + map.insert("modified".into(), Value::String(out.modified.clone())); + if !out.tags.is_empty() { + map.insert( + "tags".into(), + Value::Sequence(out.tags.iter().map(|t| Value::String(t.clone())).collect()), + ); + } + if !out.aliases.is_empty() { + map.insert( + "aliases".into(), + Value::Sequence( + out.aliases.iter().map(|t| Value::String(t.clone())).collect(), + ), + ); + } + if let Some(emoji) = &out.emoji { + map.insert("emoji".into(), Value::String(emoji.clone())); + } + if let Some(b) = out.local_only { + map.insert("localOnly".into(), Value::Bool(b)); + } + if let Some(props) = &out.properties { + let mapping: yaml::Mapping = props + .iter() + .map(|(k, v)| (Value::String(k.clone()), v.clone())) + .collect(); + map.insert("properties".into(), Value::Mapping(mapping)); + } + for (k, v) in &out.extra { + if !map.contains_key(k) { + map.insert(k.clone(), v.clone()); + } + } + + let yaml_text = yaml::to_string(&map)?; + let body = content.trim_end_matches(['\n']).to_string(); + Ok(format!("---\n{yaml_text}---\n{body}")) +} + +/// Convenience: build a fresh frontmatter for a new note. +pub fn create_frontmatter(title: &str, tags: &[String]) -> NoteFrontmatter { + let now = current_iso(); + NoteFrontmatter { + id: generate_note_id(), + title: Some(title.to_string()), + created: now.clone(), + modified: now, + tags: tags.to_vec(), + aliases: Vec::new(), + emoji: None, + local_only: None, + properties: None, + extra: BTreeMap::new(), + } +} + +fn split_frontmatter(raw: &str) -> (String, String, bool) { + let trimmed = raw.trim_start_matches('\u{FEFF}'); + if !trimmed.starts_with("---") { + return (String::new(), trimmed.to_string(), false); + } + let after_open = &trimmed[3..]; + let after_open = after_open.trim_start_matches('\r').trim_start_matches('\n'); + if let Some(end_idx) = find_closing_fence(after_open) { + let yaml_text = after_open[..end_idx].to_string(); + let mut rest = &after_open[end_idx..]; + rest = rest.trim_start_matches("---"); + rest = rest.trim_start_matches('\r').trim_start_matches('\n'); + (yaml_text, rest.to_string(), true) + } else { + (String::new(), trimmed.to_string(), false) + } +} + +fn find_closing_fence(text: &str) -> Option { + let mut start = 0usize; + while let Some(idx) = text[start..].find("\n---") { + let candidate = start + idx + 1; + let after = &text[candidate + 3..]; + if after.is_empty() || after.starts_with('\r') || after.starts_with('\n') { + return Some(candidate); + } + start = candidate + 1; + } + None +} + +fn extract_title_from_path(path: &str) -> String { + let leaf = Path::new(path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(path); + leaf.replace(['-', '_'], " ") + .split_whitespace() + .map(capitalize) + .collect::>() + .join(" ") +} + +fn capitalize(word: &str) -> String { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => { + let mut s = first.to_uppercase().collect::(); + s.push_str(chars.as_str()); + s + } + } +} + +fn current_iso() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Minimal ISO-8601 formatter — avoids pulling in chrono just here. + let datetime = unix_secs_to_iso(secs); + datetime +} + +fn unix_secs_to_iso(secs: u64) -> String { + let days_per_month = |y: u64, m: u64| -> u64 { + let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; + match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => if leap { 29 } else { 28 }, + _ => 0, + } + }; + let mut s = secs; + let secs_part = s % 60; + s /= 60; + let mins_part = s % 60; + s /= 60; + let hours_part = s % 24; + s /= 24; + let mut year: u64 = 1970; + loop { + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let yd = if leap { 366 } else { 365 }; + if s < yd { break; } + s -= yd; + year += 1; + } + let mut month: u64 = 1; + while month <= 12 { + let md = days_per_month(year, month); + if s < md { break; } + s -= md; + month += 1; + } + let day = s + 1; + format!("{year:04}-{month:02}-{day:02}T{hours_part:02}:{mins_part:02}:{secs_part:02}Z") +} + +fn generate_note_id() -> String { + nanoid::nanoid!(21) +} + +fn yaml_value_to_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + other => yaml::to_string(other).unwrap_or_default().trim().to_string(), + } +} +``` + +- [ ] **Step 5.5: Run tests until green** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_frontmatter_test +``` + +Expected: `9 passed`. Common failure modes: +- `serde_yaml_ng` produces booleans as `true`/`false` (lowercase) — matches expected behavior. +- The hand-rolled ISO helper assumes UTC. Acceptable for M3; M4+ can swap to chrono. + +- [ ] **Step 5.6: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/vault/frontmatter.rs \ + apps/desktop-tauri/src-tauri/tests/vault_frontmatter_test.rs \ + apps/desktop-tauri/src-tauri/Cargo.toml +git commit -m "m3(vault): frontmatter.rs serde_yaml_ng parse/serialize + Turkish roundtrip" +``` + +--- + +## Task 6: High-level note IO (`vault/notes_io.rs`) + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/src/vault/notes_io.rs` +- Create: `apps/desktop-tauri/src-tauri/tests/vault_notes_io_test.rs` +- Modify: `apps/desktop-tauri/src-tauri/Cargo.toml` (add `[[test]]` entry) + +- [ ] **Step 6.1: Write the failing test** + +Create `apps/desktop-tauri/src-tauri/tests/vault_notes_io_test.rs`: + +```rust +use memry_desktop_tauri_lib::vault::notes_io; +use memry_desktop_tauri_lib::vault::frontmatter::create_frontmatter; +use std::fs; + +fn make_vault() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("notes")).unwrap(); + dir +} + +#[tokio::test] +async fn write_then_read_roundtrip() { + let vault = make_vault(); + let mut fm = create_frontmatter("Hello", &["work".to_string()]); + fm.id = "fixed-id-1".to_string(); + let written = notes_io::write_note_to_disk( + vault.path(), + "notes/hello.md", + &fm, + "body text", + ) + .await + .unwrap(); + assert_eq!(written.relative_path, "notes/hello.md"); + + let read = notes_io::read_note_from_disk(vault.path(), "notes/hello.md") + .await + .unwrap() + .expect("note must exist"); + assert_eq!(read.parsed.frontmatter.id, "fixed-id-1"); + assert_eq!(read.parsed.frontmatter.title.as_deref(), Some("Hello")); + assert_eq!(read.parsed.content, "body text"); +} + +#[tokio::test] +async fn read_returns_none_for_missing_path() { + let vault = make_vault(); + let result = notes_io::read_note_from_disk(vault.path(), "notes/missing.md") + .await + .unwrap(); + assert!(result.is_none()); +} + +#[tokio::test] +async fn read_auto_repairs_missing_frontmatter_and_writes_back() { + let vault = make_vault(); + let abs = vault.path().join("notes").join("plain.md"); + fs::write(&abs, "no fm here\n").unwrap(); + let read = notes_io::read_note_from_disk(vault.path(), "notes/plain.md") + .await + .unwrap() + .unwrap(); + assert!(read.parsed.was_modified); + let on_disk = fs::read_to_string(&abs).unwrap(); + assert!(on_disk.starts_with("---\n")); + assert!(on_disk.contains(&format!("id: {}", read.parsed.frontmatter.id))); +} + +#[tokio::test] +async fn write_skips_no_op_when_hash_matches() { + let vault = make_vault(); + let mut fm = create_frontmatter("X", &[]); + fm.id = "id-2".to_string(); + let first = notes_io::write_note_to_disk(vault.path(), "notes/x.md", &fm, "same") + .await + .unwrap(); + let second = notes_io::write_note_to_disk(vault.path(), "notes/x.md", &fm, "same") + .await + .unwrap(); + // Hash equality means the second call did NOT bump the file mtime. + assert_eq!(first.content_hash, second.content_hash); +} + +#[tokio::test] +async fn read_rejects_path_traversal() { + let vault = make_vault(); + let err = notes_io::read_note_from_disk(vault.path(), "../escape.md") + .await + .unwrap_err(); + assert!(matches!( + err, + memry_desktop_tauri_lib::error::AppError::PathEscape(_) + )); +} +``` + +- [ ] **Step 6.2: Register the test** + +```toml +[[test]] +name = "vault_notes_io_test" +required-features = ["test-helpers"] +``` + +- [ ] **Step 6.3: Run RED** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_notes_io_test +``` + +Expected: unresolved imports. + +- [ ] **Step 6.4: Implement `vault/notes_io.rs`** + +Replace the stub with: + +```rust +//! High-level note IO. Composes path resolution, atomic write, and +//! frontmatter parse/serialize into the operations Tauri commands +//! call directly. +//! +//! - `read_note_from_disk` returns `None` on missing path. If the +//! on-disk file is missing required frontmatter fields, this writes +//! the repaired version back via atomic_write so subsequent reads +//! do not have to repeat the auto-fill — matches Electron's behavior +//! in `vault/notes.ts::ensureFrontmatter`. +//! - `write_note_to_disk` always serializes via `frontmatter::serialize_note` +//! (which bumps `modified`), then atomic-writes. The returned +//! `NoteOnDisk` includes a SHA-256 content hash so the watcher / +//! sync queue can detect no-op rewrites cheaply. +//! - All paths are vault-relative and forward-slashed. + +use crate::error::AppResult; +use crate::vault::fs as vfs; +use crate::vault::frontmatter::{self, NoteFrontmatter, ParsedNote}; +use crate::vault::paths; +use std::path::Path; + +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct NoteOnDisk { + pub relative_path: String, + pub content_hash: String, +} + +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ReadNoteResult { + pub relative_path: String, + pub raw: String, + pub content_hash: String, + pub parsed: ParsedNote, +} + +pub async fn read_note_from_disk( + vault_root: &Path, + relative_path: &str, +) -> AppResult> { + let abs = paths::resolve_supported(vault_root, relative_path)?; + let raw = match vfs::safe_read(&abs).await? { + Some(s) => s, + None => return Ok(None), + }; + + let parsed = frontmatter::parse_note(&raw, Some(relative_path))?; + + if parsed.was_modified { + let serialized = + frontmatter::serialize_note(&parsed.frontmatter, &parsed.content)?; + vfs::atomic_write(&abs, &serialized).await?; + let hash = vfs::content_hash(&serialized); + return Ok(Some(ReadNoteResult { + relative_path: relative_path.to_string(), + raw: serialized, + content_hash: hash, + parsed, + })); + } + + let hash = vfs::content_hash(&raw); + Ok(Some(ReadNoteResult { + relative_path: relative_path.to_string(), + raw, + content_hash: hash, + parsed, + })) +} + +pub async fn write_note_to_disk( + vault_root: &Path, + relative_path: &str, + frontmatter_in: &NoteFrontmatter, + content: &str, +) -> AppResult { + let abs = paths::resolve_supported(vault_root, relative_path)?; + let serialized = frontmatter::serialize_note(frontmatter_in, content)?; + let new_hash = vfs::content_hash(&serialized); + + if let Some(existing) = vfs::safe_read(&abs).await? { + if vfs::content_hash(&existing) == new_hash { + return Ok(NoteOnDisk { + relative_path: relative_path.to_string(), + content_hash: new_hash, + }); + } + } + + vfs::atomic_write(&abs, &serialized).await?; + Ok(NoteOnDisk { + relative_path: relative_path.to_string(), + content_hash: new_hash, + }) +} + +pub async fn delete_note_from_disk( + vault_root: &Path, + relative_path: &str, +) -> AppResult<()> { + let abs = paths::resolve_supported(vault_root, relative_path)?; + vfs::delete_file(&abs).await +} +``` + +> **Note** — the `write_note_to_disk` no-op short-circuit compares the +> already-serialized form (which includes the bumped `modified` +> timestamp) against the on-disk content. Two back-to-back writes with +> identical input WILL produce identical hashes only if the second +> write happens within the same second AND `serialize_note` is +> deterministic for the input. The test in 6.1 uses a fixed-tag +> frontmatter and writes within the same test invocation, so this +> works in practice. M5 will revisit if real-world callers hit +> false-positive rewrites. + +- [ ] **Step 6.5: Run tests** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_notes_io_test +``` + +Expected: `5 passed`. + +- [ ] **Step 6.6: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/vault/notes_io.rs \ + apps/desktop-tauri/src-tauri/tests/vault_notes_io_test.rs \ + apps/desktop-tauri/src-tauri/Cargo.toml +git commit -m "m3(vault): notes_io.rs read_note_from_disk + write_note_to_disk + content-hash skip" +``` + +--- + +## Task 7: Vault preferences (`vault/preferences.rs`) + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/src/vault/preferences.rs` +- Create: `apps/desktop-tauri/src-tauri/tests/vault_preferences_test.rs` +- Modify: `apps/desktop-tauri/src-tauri/Cargo.toml` (add `[[test]]` entry) + +- [ ] **Step 7.1: Write the failing test** + +Create `apps/desktop-tauri/src-tauri/tests/vault_preferences_test.rs`: + +```rust +use memry_desktop_tauri_lib::vault::preferences::{self, VaultConfig, VaultPreferences}; + +fn make_vault() -> tempfile::TempDir { + tempfile::tempdir().unwrap() +} + +#[test] +fn init_creates_dot_memry_with_default_config() { + let vault = make_vault(); + preferences::init_vault(vault.path()).unwrap(); + assert!(vault.path().join(".memry").exists()); + assert!(vault.path().join("notes").exists()); + assert!(vault.path().join("journal").exists()); + assert!(vault.path().join("attachments").exists()); + assert!(vault.path().join(".memry/config.json").exists()); +} + +#[test] +fn init_is_idempotent() { + let vault = make_vault(); + preferences::init_vault(vault.path()).unwrap(); + preferences::init_vault(vault.path()).unwrap(); +} + +#[test] +fn read_config_returns_defaults_when_missing() { + let vault = make_vault(); + let cfg = preferences::read_config(vault.path()).unwrap(); + assert_eq!(cfg.default_note_folder, "notes"); + assert_eq!(cfg.journal_folder, "journal"); + assert_eq!(cfg.attachments_folder, "attachments"); + assert!(cfg.exclude_patterns.contains(&".git".to_string())); +} + +#[test] +fn write_config_round_trips() { + let vault = make_vault(); + preferences::init_vault(vault.path()).unwrap(); + let updated = preferences::update_config(vault.path(), &VaultConfig { + exclude_patterns: vec!["custom".into(), "node_modules".into()], + default_note_folder: "Notes".into(), + journal_folder: "Daily".into(), + attachments_folder: "files".into(), + }) + .unwrap(); + let read = preferences::read_config(vault.path()).unwrap(); + assert_eq!(read.default_note_folder, "Notes"); + assert_eq!(read.journal_folder, "Daily"); + assert_eq!(updated.exclude_patterns, read.exclude_patterns); +} + +#[test] +fn read_preferences_returns_defaults_when_missing() { + let vault = make_vault(); + let prefs = preferences::read_preferences(vault.path()).unwrap(); + assert_eq!(prefs.theme, "system"); + assert_eq!(prefs.font_size, "medium"); +} + +#[test] +fn update_preferences_merges_partial() { + let vault = make_vault(); + preferences::init_vault(vault.path()).unwrap(); + let mut updates = serde_json::Map::new(); + updates.insert("theme".into(), serde_json::Value::String("dark".into())); + let merged = preferences::update_preferences(vault.path(), &updates).unwrap(); + assert_eq!(merged.theme, "dark"); + assert_eq!(merged.font_size, "medium"); // unchanged default +} + +#[test] +fn count_markdown_files_skips_hidden_and_excluded() { + let vault = make_vault(); + preferences::init_vault(vault.path()).unwrap(); + std::fs::write(vault.path().join("notes/a.md"), "x").unwrap(); + std::fs::write(vault.path().join("notes/b.md"), "x").unwrap(); + std::fs::create_dir_all(vault.path().join("node_modules")).unwrap(); + std::fs::write(vault.path().join("node_modules/c.md"), "x").unwrap(); + std::fs::write(vault.path().join("notes/.hidden.md"), "x").unwrap(); + let count = preferences::count_markdown_files( + vault.path(), + &["node_modules".to_string(), ".git".to_string()], + ); + assert_eq!(count, 2); +} + +#[test] +fn turkish_chars_in_config_roundtrip() { + let vault = make_vault(); + preferences::init_vault(vault.path()).unwrap(); + let cfg = VaultConfig { + exclude_patterns: vec![".git".into()], + default_note_folder: "Çalışma".into(), + journal_folder: "Günlük".into(), + attachments_folder: "Ekler".into(), + }; + preferences::update_config(vault.path(), &cfg).unwrap(); + let read = preferences::read_config(vault.path()).unwrap(); + assert_eq!(read.default_note_folder, "Çalışma"); + assert_eq!(read.journal_folder, "Günlük"); +} +``` + +- [ ] **Step 7.2: Register the test** + +```toml +[[test]] +name = "vault_preferences_test" +required-features = ["test-helpers"] +``` + +- [ ] **Step 7.3: Run RED** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_preferences_test +``` + +- [ ] **Step 7.4: Implement `vault/preferences.rs`** + +Replace the stub with: + +```rust +//! Per-vault JSON config + UI preferences. +//! +//! Layout matches Electron's vault format: +//! +//! ```text +//! / +//! ├── .memry/ +//! │ └── config.json { excludePatterns, defaultNoteFolder, journalFolder, +//! │ attachmentsFolder, preferences: { theme, ... } } +//! ├── notes/ +//! ├── journal/ +//! └── attachments/ +//! ``` +//! +//! `init_vault` creates the directory tree and writes the default +//! config if it does not exist (idempotent). `read_config` and +//! `read_preferences` return defaults when the file is missing or +//! corrupt — they never error on a brand-new vault. `update_*` +//! merges the partial input into the existing config and writes it +//! atomically. + +use crate::error::{AppError, AppResult}; +use crate::vault::fs as vfs; +use serde_json::Map; +use std::fs; +use std::path::{Path, PathBuf}; + +const MEMRY_DIR: &str = ".memry"; +const CONFIG_FILE: &str = "config.json"; + +const VAULT_FOLDERS: &[&str] = &[ + "notes", + "journal", + "attachments", + "attachments/images", + "attachments/files", +]; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultConfig { + pub exclude_patterns: Vec, + pub default_note_folder: String, + pub journal_folder: String, + pub attachments_folder: String, +} + +impl Default for VaultConfig { + fn default() -> Self { + Self { + exclude_patterns: vec![ + ".git".into(), + "node_modules".into(), + ".trash".into(), + ], + default_note_folder: "notes".into(), + journal_folder: "journal".into(), + attachments_folder: "attachments".into(), + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct EditorPreferences { + pub width: String, + pub spell_check: bool, + pub auto_save_delay: u32, + pub show_word_count: bool, + pub toolbar_mode: String, +} + +impl Default for EditorPreferences { + fn default() -> Self { + Self { + width: "medium".into(), + spell_check: true, + auto_save_delay: 1000, + show_word_count: true, + toolbar_mode: "floating".into(), + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultPreferences { + pub theme: String, + pub font_size: String, + pub font_family: String, + pub accent_color: String, + pub language: String, + pub create_in_selected_folder: bool, + pub editor: EditorPreferences, +} + +impl Default for VaultPreferences { + fn default() -> Self { + Self { + theme: "system".into(), + font_size: "medium".into(), + font_family: "system".into(), + accent_color: "#2563eb".into(), + language: "en".into(), + create_in_selected_folder: true, + editor: EditorPreferences::default(), + } + } +} + +pub fn memry_dir(vault_path: &Path) -> PathBuf { + vault_path.join(MEMRY_DIR) +} + +pub fn config_path(vault_path: &Path) -> PathBuf { + memry_dir(vault_path).join(CONFIG_FILE) +} + +pub fn is_initialized(vault_path: &Path) -> bool { + memry_dir(vault_path).exists() +} + +pub fn init_vault(vault_path: &Path) -> AppResult<()> { + fs::create_dir_all(memry_dir(vault_path))?; + for folder in VAULT_FOLDERS { + fs::create_dir_all(vault_path.join(folder))?; + } + let cfg_path = config_path(vault_path); + if !cfg_path.exists() { + let default = build_initial_config_blob(); + fs::write(&cfg_path, serde_json::to_string_pretty(&default)?)?; + } + Ok(()) +} + +fn build_initial_config_blob() -> serde_json::Value { + serde_json::json!({ + "excludePatterns": VaultConfig::default().exclude_patterns, + "defaultNoteFolder": VaultConfig::default().default_note_folder, + "journalFolder": VaultConfig::default().journal_folder, + "attachmentsFolder": VaultConfig::default().attachments_folder, + "preferences": serde_json::to_value(VaultPreferences::default()).unwrap(), + }) +} + +fn read_config_blob(vault_path: &Path) -> AppResult { + let path = config_path(vault_path); + if !path.exists() { + return Ok(build_initial_config_blob()); + } + let raw = fs::read_to_string(&path)?; + let parsed: serde_json::Value = + serde_json::from_str(&raw).unwrap_or_else(|_| build_initial_config_blob()); + Ok(parsed) +} + +pub fn read_config(vault_path: &Path) -> AppResult { + let blob = read_config_blob(vault_path)?; + Ok(VaultConfig { + exclude_patterns: blob + .get("excludePatterns") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_else(|| VaultConfig::default().exclude_patterns), + default_note_folder: blob + .get("defaultNoteFolder") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| VaultConfig::default().default_note_folder), + journal_folder: blob + .get("journalFolder") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| VaultConfig::default().journal_folder), + attachments_folder: blob + .get("attachmentsFolder") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| VaultConfig::default().attachments_folder), + }) +} + +pub fn update_config(vault_path: &Path, updates: &VaultConfig) -> AppResult { + let mut blob = read_config_blob(vault_path)?; + let obj = blob + .as_object_mut() + .ok_or_else(|| AppError::Validation("config.json is not an object".into()))?; + obj.insert( + "excludePatterns".into(), + serde_json::to_value(&updates.exclude_patterns)?, + ); + obj.insert( + "defaultNoteFolder".into(), + serde_json::Value::String(updates.default_note_folder.clone()), + ); + obj.insert( + "journalFolder".into(), + serde_json::Value::String(updates.journal_folder.clone()), + ); + obj.insert( + "attachmentsFolder".into(), + serde_json::Value::String(updates.attachments_folder.clone()), + ); + write_config_blob_atomic(vault_path, &blob)?; + Ok(updates.clone()) +} + +pub fn read_preferences(vault_path: &Path) -> AppResult { + let blob = read_config_blob(vault_path)?; + let prefs_val = blob + .get("preferences") + .cloned() + .unwrap_or_else(|| serde_json::to_value(VaultPreferences::default()).unwrap()); + Ok(serde_json::from_value(prefs_val).unwrap_or_default()) +} + +pub fn update_preferences( + vault_path: &Path, + partial: &Map, +) -> AppResult { + let mut blob = read_config_blob(vault_path)?; + let obj = blob + .as_object_mut() + .ok_or_else(|| AppError::Validation("config.json is not an object".into()))?; + let mut current = obj + .get("preferences") + .cloned() + .unwrap_or_else(|| serde_json::to_value(VaultPreferences::default()).unwrap()); + if let Some(map) = current.as_object_mut() { + for (k, v) in partial { + map.insert(k.clone(), v.clone()); + } + } + obj.insert("preferences".into(), current); + write_config_blob_atomic(vault_path, &blob)?; + read_preferences(vault_path) +} + +fn write_config_blob_atomic( + vault_path: &Path, + blob: &serde_json::Value, +) -> AppResult<()> { + let path = config_path(vault_path); + let serialized = serde_json::to_string_pretty(blob)?; + let runtime = tokio::runtime::Handle::try_current(); + match runtime { + Ok(handle) => { + let path = path.clone(); + handle.block_on(vfs::atomic_write(&path, &serialized))?; + } + Err(_) => { + // Outside Tokio context (e.g. setup callbacks). Fall back to + // sync write — config is small (<10KB) so torn writes are + // unlikely. + fs::write(&path, serialized)?; + } + } + Ok(()) +} + +pub fn count_markdown_files(vault_path: &Path, exclude: &[String]) -> i64 { + let mut count: i64 = 0; + let mut stack = vec![vault_path.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') { + continue; + } + if exclude.iter().any(|p| p == &*name_str) { + continue; + } + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.is_file() && crate::vault::paths::is_markdown(&path) { + count += 1; + } + } + } + count +} + +pub fn vault_name(vault_path: &Path) -> String { + vault_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("vault") + .to_string() +} +``` + +- [ ] **Step 7.5: Run tests** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_preferences_test +``` + +Expected: `8 passed`. + +- [ ] **Step 7.6: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/vault/preferences.rs \ + apps/desktop-tauri/src-tauri/tests/vault_preferences_test.rs \ + apps/desktop-tauri/src-tauri/Cargo.toml +git commit -m "m3(vault): preferences.rs config.json + theme/editor preferences with Turkish roundtrip" +``` + +--- + +## Task 8: Vault registry — multi-vault list (`vault/registry.rs`) + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/src/vault/registry.rs` +- Create: `apps/desktop-tauri/src-tauri/tests/vault_registry_test.rs` +- Modify: `apps/desktop-tauri/src-tauri/Cargo.toml` (add `[[test]]` entry) + +- [ ] **Step 8.1: Write the failing test** + +Create `apps/desktop-tauri/src-tauri/tests/vault_registry_test.rs`: + +```rust +use memry_desktop_tauri_lib::vault::registry::{self, VaultInfo, VaultRegistry}; + +fn registry_path(dir: &tempfile::TempDir) -> std::path::PathBuf { + dir.path().join("vaults.json") +} + +fn make_info(path: &std::path::Path, name: &str, is_default: bool) -> VaultInfo { + VaultInfo { + path: path.to_string_lossy().into_owned(), + name: name.into(), + note_count: 0, + task_count: 0, + last_opened: "2026-04-26T00:00:00Z".into(), + is_default, + } +} + +#[test] +fn empty_registry_when_file_missing() { + let dir = tempfile::tempdir().unwrap(); + let reg = VaultRegistry::load(®istry_path(&dir)).unwrap(); + assert!(reg.vaults.is_empty()); + assert!(reg.current.is_none()); +} + +#[test] +fn upsert_then_persist_then_reload() { + let dir = tempfile::tempdir().unwrap(); + let path = registry_path(&dir); + let mut reg = VaultRegistry::load(&path).unwrap(); + + let v_dir = tempfile::tempdir().unwrap(); + reg.upsert(make_info(v_dir.path(), "Primary", true)); + reg.set_current(Some(v_dir.path().to_string_lossy().into_owned())); + reg.save(&path).unwrap(); + + let reloaded = VaultRegistry::load(&path).unwrap(); + assert_eq!(reloaded.vaults.len(), 1); + assert_eq!(reloaded.vaults[0].name, "Primary"); + assert_eq!(reloaded.current.as_deref(), Some(v_dir.path().to_string_lossy().as_ref())); +} + +#[test] +fn upsert_replaces_by_path_not_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let v_dir = tempfile::tempdir().unwrap(); + let mut reg = VaultRegistry::load(®istry_path(&dir)).unwrap(); + reg.upsert(make_info(v_dir.path(), "First", true)); + reg.upsert(make_info(v_dir.path(), "Renamed", false)); + assert_eq!(reg.vaults.len(), 1); + assert_eq!(reg.vaults[0].name, "Renamed"); + assert!(!reg.vaults[0].is_default); +} + +#[test] +fn remove_drops_vault_and_clears_current_if_match() { + let dir = tempfile::tempdir().unwrap(); + let v1 = tempfile::tempdir().unwrap(); + let v2 = tempfile::tempdir().unwrap(); + let mut reg = VaultRegistry::load(®istry_path(&dir)).unwrap(); + reg.upsert(make_info(v1.path(), "v1", true)); + reg.upsert(make_info(v2.path(), "v2", false)); + reg.set_current(Some(v1.path().to_string_lossy().into_owned())); + + reg.remove(&v1.path().to_string_lossy()); + assert_eq!(reg.vaults.len(), 1); + assert_eq!(reg.vaults[0].name, "v2"); + assert!(reg.current.is_none()); +} + +#[test] +fn touch_updates_last_opened() { + let dir = tempfile::tempdir().unwrap(); + let v = tempfile::tempdir().unwrap(); + let mut reg = VaultRegistry::load(®istry_path(&dir)).unwrap(); + reg.upsert(make_info(v.path(), "v", true)); + let before = reg.vaults[0].last_opened.clone(); + std::thread::sleep(std::time::Duration::from_millis(10)); + reg.touch(&v.path().to_string_lossy()); + let after = reg.vaults[0].last_opened.clone(); + assert_ne!(before, after, "touch should bump last_opened"); +} + +#[test] +fn corrupt_file_falls_back_to_empty_registry() { + let dir = tempfile::tempdir().unwrap(); + let path = registry_path(&dir); + std::fs::write(&path, "{not valid json}").unwrap(); + let reg = VaultRegistry::load(&path).unwrap(); + assert!(reg.vaults.is_empty()); + assert!(reg.current.is_none()); +} +``` + +- [ ] **Step 8.2: Register the test** + +```toml +[[test]] +name = "vault_registry_test" +required-features = ["test-helpers"] +``` + +- [ ] **Step 8.3: Run RED** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_registry_test +``` + +- [ ] **Step 8.4: Implement `vault/registry.rs`** + +Replace the stub with: + +```rust +//! Multi-vault registry persisted at +//! `/memry-{device}/vaults.json` (per-device, matches the +//! M2 DB path scheme). Holds the list of known vaults plus the +//! "current" vault path. `lib.rs::run` loads it at boot, `vault::state` +//! mutates it, and `vault_*` commands read it for the renderer. +//! +//! Persistence is best-effort sync JSON: the file is small (<2KB +//! typical) and only written on user actions (open/switch/remove). + +use crate::error::{AppError, AppResult}; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultInfo { + pub path: String, + pub name: String, + pub note_count: i64, + pub task_count: i64, + pub last_opened: String, + pub is_default: bool, +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultRegistry { + #[serde(default)] + pub vaults: Vec, + #[serde(default)] + pub current: Option, +} + +impl VaultRegistry { + pub fn load(path: &Path) -> AppResult { + if !path.exists() { + return Ok(Self::default()); + } + let raw = fs::read_to_string(path)?; + let parsed: Self = serde_json::from_str(&raw).unwrap_or_default(); + Ok(parsed) + } + + pub fn save(&self, path: &Path) -> AppResult<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let raw = serde_json::to_string_pretty(self)?; + fs::write(path, raw)?; + Ok(()) + } + + pub fn find(&self, vault_path: &str) -> Option<&VaultInfo> { + self.vaults.iter().find(|v| v.path == vault_path) + } + + pub fn upsert(&mut self, info: VaultInfo) { + if let Some(slot) = self.vaults.iter_mut().find(|v| v.path == info.path) { + *slot = info; + } else { + self.vaults.push(info); + } + } + + pub fn remove(&mut self, vault_path: &str) { + self.vaults.retain(|v| v.path != vault_path); + if self.current.as_deref() == Some(vault_path) { + self.current = None; + } + } + + pub fn set_current(&mut self, current: Option) { + self.current = current; + } + + pub fn touch(&mut self, vault_path: &str) { + let now = current_iso(); + if let Some(slot) = self.vaults.iter_mut().find(|v| v.path == vault_path) { + slot.last_opened = now; + } + } +} + +fn current_iso() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + crate::vault::frontmatter_iso(secs) +} + +pub fn registry_path() -> AppResult { + let device = std::env::var("MEMRY_DEVICE").unwrap_or_else(|_| "default".to_string()); + let project_dirs = directories::ProjectDirs::from("com", "memry", "memry") + .ok_or_else(|| AppError::Internal("could not determine OS project dirs".into()))?; + Ok(project_dirs + .data_dir() + .join(format!("memry-{device}")) + .join("vaults.json")) +} +``` + +- [ ] **Step 8.5: Add a tiny ISO helper alias in `frontmatter.rs`** + +Re-open `apps/desktop-tauri/src-tauri/src/vault/frontmatter.rs`. Make the existing `unix_secs_to_iso` function `pub(crate)` and add a re-export at the module path used by `registry.rs`: + +In `frontmatter.rs`, change: + +```rust +fn unix_secs_to_iso(secs: u64) -> String { +``` + +to: + +```rust +pub(crate) fn unix_secs_to_iso(secs: u64) -> String { +``` + +Then in `apps/desktop-tauri/src-tauri/src/vault/mod.rs` add: + +```rust +pub(crate) fn frontmatter_iso(secs: u64) -> String { + frontmatter::unix_secs_to_iso(secs) +} +``` + +- [ ] **Step 8.6: Run tests** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_registry_test +``` + +Expected: `6 passed`. + +- [ ] **Step 8.7: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/vault/registry.rs \ + apps/desktop-tauri/src-tauri/src/vault/mod.rs \ + apps/desktop-tauri/src-tauri/src/vault/frontmatter.rs \ + apps/desktop-tauri/src-tauri/tests/vault_registry_test.rs \ + apps/desktop-tauri/src-tauri/Cargo.toml +git commit -m "m3(vault): registry.rs multi-vault list at /memry-{device}/vaults.json" +``` + +--- + +## Task 9: Vault runtime state + AppState wiring (`vault/state.rs`) + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/src/vault/state.rs` +- Modify: `apps/desktop-tauri/src-tauri/src/vault/mod.rs` (uncomment re-exports now that types exist) +- Modify: `apps/desktop-tauri/src-tauri/src/app_state.rs` +- Modify: `apps/desktop-tauri/src-tauri/src/lib.rs` + +- [ ] **Step 9.1: Implement `vault/state.rs`** + +Replace the stub with: + +```rust +//! Runtime state for the vault layer. +//! +//! `VaultRuntime` owns: +//! - the current vault path (None when no vault is open) +//! - status flags (is_indexing, index_progress, error) +//! - the in-memory registry handle (loaded from disk at boot) +//! - the active `notify` watcher handle (started on open, stopped on +//! close) +//! +//! All fields are wrapped in `parking_lot::Mutex` (or `std::sync::Mutex` +//! — we use std for now, Tokio doesn't bind these). Tauri commands +//! lock briefly per call. The watcher itself runs on its own thread +//! managed by `notify`; the `Drop` impl on the watcher handle cleans +//! it up when the option is replaced. + +use crate::error::{AppError, AppResult}; +use crate::vault::registry::{VaultRegistry, registry_path}; +use std::path::PathBuf; +use std::sync::Mutex; + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultStatus { + pub is_open: bool, + pub path: Option, + pub is_indexing: bool, + pub index_progress: u8, + pub error: Option, +} + +pub struct VaultRuntime { + inner: Mutex, + pub watcher_slot: Mutex>, + registry_path: PathBuf, +} + +struct RuntimeInner { + current: Option, + is_indexing: bool, + index_progress: u8, + error: Option, + registry: VaultRegistry, +} + +impl VaultRuntime { + pub fn boot() -> AppResult { + let registry_path = registry_path()?; + let registry = VaultRegistry::load(®istry_path).unwrap_or_default(); + Ok(Self { + inner: Mutex::new(RuntimeInner { + current: registry.current.as_ref().map(PathBuf::from), + is_indexing: false, + index_progress: 0, + error: None, + registry, + }), + watcher_slot: Mutex::new(None), + registry_path, + }) + } + + pub fn status(&self) -> VaultStatus { + let g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + VaultStatus { + is_open: g.current.is_some(), + path: g + .current + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), + is_indexing: g.is_indexing, + index_progress: g.index_progress, + error: g.error.clone(), + } + } + + pub fn current_path(&self) -> Option { + let g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.current.clone() + } + + pub fn registry_snapshot(&self) -> VaultRegistry { + let g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.registry.clone() + } + + pub fn set_current(&self, path: Option) -> AppResult<()> { + { + let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.current = path.clone(); + g.error = None; + g.registry + .set_current(path.as_ref().map(|p| p.to_string_lossy().into_owned())); + g.registry.save(&self.registry_path)?; + } + Ok(()) + } + + pub fn upsert_registry(&self, info: crate::vault::registry::VaultInfo) -> AppResult<()> { + let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.registry.upsert(info); + g.registry.save(&self.registry_path) + } + + pub fn remove_from_registry(&self, vault_path: &str) -> AppResult<()> { + let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.registry.remove(vault_path); + if g.current.as_deref().map(|p| p.to_string_lossy().into_owned()) + == Some(vault_path.to_string()) + { + g.current = None; + } + g.registry.save(&self.registry_path) + } + + pub fn touch_registry(&self, vault_path: &str) -> AppResult<()> { + let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.registry.touch(vault_path); + g.registry.save(&self.registry_path) + } + + pub fn set_indexing(&self, indexing: bool, progress: u8) { + let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.is_indexing = indexing; + g.index_progress = progress; + } + + pub fn set_error(&self, error: Option) { + let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + g.error = error; + } + + pub fn require_current(&self) -> AppResult { + self.current_path() + .ok_or_else(|| AppError::Vault("no vault is open".into())) + } +} +``` + +- [ ] **Step 9.2: Re-enable re-exports in `vault/mod.rs`** + +Replace the commented-out block with: + +```rust +pub use frontmatter::{NoteFrontmatter, ParsedNote}; +pub use notes_io::{NoteOnDisk, ReadNoteResult}; +pub use preferences::{VaultConfig, VaultPreferences}; +pub use registry::{VaultInfo, VaultRegistry}; +pub use state::{VaultRuntime, VaultStatus}; +``` + +- [ ] **Step 9.3: Add `vault: Arc` to `AppState`** + +Open `apps/desktop-tauri/src-tauri/src/app_state.rs` and replace the contents with: + +```rust +//! Global runtime state shared across commands. + +use crate::db::Db; +use crate::vault::VaultRuntime; +use std::sync::Arc; + +pub struct AppState { + pub db: Db, + pub vault: Arc, +} + +impl AppState { + pub fn new(db: Db, vault: Arc) -> Self { + Self { db, vault } + } +} +``` + +- [ ] **Step 9.4: Wire `VaultRuntime::boot()` into `lib.rs::run`** + +Open `apps/desktop-tauri/src-tauri/src/lib.rs`. Replace the `init_app_state` function with: + +```rust +fn init_app_state() -> AppResult { + let db_path = resolve_db_path()?; + let db = Db::open(db_path)?; + let vault = std::sync::Arc::new(crate::vault::VaultRuntime::boot()?); + Ok(AppState::new(db, vault)) +} +``` + +- [ ] **Step 9.5: Verify cargo check + existing tests still pass** + +```bash +cd apps/desktop-tauri/src-tauri && cargo check && \ + cargo test --features test-helpers --tests +``` + +Expected: every previously-green test stays green. `vault_state` does not have its own integration test (state is exercised by Task 11's command tests + the runtime smoke). + +- [ ] **Step 9.6: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/vault/state.rs \ + apps/desktop-tauri/src-tauri/src/vault/mod.rs \ + apps/desktop-tauri/src-tauri/src/app_state.rs \ + apps/desktop-tauri/src-tauri/src/lib.rs +git commit -m "m3(vault): VaultRuntime + AppState extension + boot wiring" +``` + +--- + +## Task 10: File watcher with debounce (`vault/watcher.rs`) + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/src/vault/watcher.rs` +- Create: `apps/desktop-tauri/src-tauri/tests/vault_watcher_test.rs` +- Modify: `apps/desktop-tauri/src-tauri/Cargo.toml` (add `[[test]]` entry) + +- [ ] **Step 10.1: Write the failing test** + +Create `apps/desktop-tauri/src-tauri/tests/vault_watcher_test.rs`: + +```rust +use memry_desktop_tauri_lib::vault::watcher::{self, VaultEvent, VaultEventKind}; +use std::fs; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::timeout; + +fn make_vault() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("notes")).unwrap(); + dir +} + +#[tokio::test(flavor = "multi_thread")] +async fn detects_new_file_within_debounce_window() { + let vault = make_vault(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let handle = watcher::start(vault.path(), tx).unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + fs::write(vault.path().join("notes/new.md"), "hello").unwrap(); + + let event: VaultEvent = timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("timed out waiting for vault-changed") + .expect("channel closed"); + + assert!(event.relative_path.ends_with("new.md")); + assert!(matches!(event.kind, VaultEventKind::Created | VaultEventKind::Modified)); + + drop(handle); +} + +#[tokio::test(flavor = "multi_thread")] +async fn debounces_rapid_writes_to_same_file() { + let vault = make_vault(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let handle = watcher::start(vault.path(), tx).unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + let path = vault.path().join("notes/burst.md"); + for i in 0..5 { + fs::write(&path, format!("v{i}")).unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + } + + let mut count = 0; + let mut total = Duration::from_millis(0); + while total < Duration::from_secs(2) { + match timeout(Duration::from_millis(400), rx.recv()).await { + Ok(Some(_)) => count += 1, + _ => break, + } + total += Duration::from_millis(400); + } + assert!(count >= 1, "should fire at least once"); + assert!(count <= 3, "debounce should coalesce rapid writes (got {count})"); + + drop(handle); +} + +#[tokio::test(flavor = "multi_thread")] +async fn ignores_dot_memry_writes() { + let vault = make_vault(); + fs::create_dir_all(vault.path().join(".memry")).unwrap(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let handle = watcher::start(vault.path(), tx).unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + fs::write(vault.path().join(".memry/data.db"), "x").unwrap(); + + let event = timeout(Duration::from_millis(500), rx.recv()).await; + assert!(event.is_err(), "must not emit for .memry/ writes"); + + drop(handle); +} + +#[tokio::test(flavor = "multi_thread")] +async fn detects_deletion() { + let vault = make_vault(); + let path = vault.path().join("notes/del.md"); + fs::write(&path, "x").unwrap(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let handle = watcher::start(vault.path(), tx).unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + fs::remove_file(&path).unwrap(); + + let mut saw_delete = false; + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + match timeout(Duration::from_millis(300), rx.recv()).await { + Ok(Some(ev)) if matches!(ev.kind, VaultEventKind::Deleted) => { + saw_delete = true; + break; + } + Ok(Some(_)) => continue, + _ => break, + } + } + assert!(saw_delete, "expected a Deleted event"); + + drop(handle); +} +``` + +- [ ] **Step 10.2: Register the test** + +```toml +[[test]] +name = "vault_watcher_test" +required-features = ["test-helpers"] +``` + +- [ ] **Step 10.3: Implement `vault/watcher.rs`** + +Replace the stub with: + +```rust +//! `notify` filesystem watcher with path-keyed debounce. +//! +//! - `start(vault_root, sender)` registers a recursive recommended +//! watcher rooted at `vault_root` and returns an opaque handle that +//! stops the watcher when dropped. +//! - Raw `notify` events are filtered: hidden basenames (`.foo`), +//! anything inside `.memry/`, and unsupported extensions are +//! ignored before queuing. +//! - A path-keyed debounce coalesces rapid writes within 150ms per +//! file. Each file gets its own pending timer. +//! - The output channel emits `VaultEvent { relative_path, kind }` +//! where `kind` is `Created`, `Modified`, or `Deleted`. + +use crate::error::{AppError, AppResult}; +use crate::vault::paths; +use notify::{event::ModifyKind, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::mpsc::UnboundedSender; +use tokio::task::JoinHandle; + +const DEBOUNCE_MS: u64 = 150; + +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultEvent { + pub relative_path: String, + pub kind: VaultEventKind, +} + +#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type)] +#[serde(rename_all = "lowercase")] +pub enum VaultEventKind { + Created, + Modified, + Deleted, +} + +pub struct WatcherHandle { + _watcher: RecommendedWatcher, + _scheduler: JoinHandle<()>, + cancel: Arc, +} + +impl Drop for WatcherHandle { + fn drop(&mut self) { + self.cancel + .store(true, std::sync::atomic::Ordering::Relaxed); + } +} + +#[derive(Default)] +struct PendingMap { + pending: HashMap, +} + +struct PendingEntry { + last_event: VaultEventKind, + deadline: std::time::Instant, +} + +pub fn start( + vault_root: &Path, + out: UnboundedSender, +) -> AppResult { + let canonical_root = dunce::canonicalize(vault_root)?; + let pending = Arc::new(Mutex::new(PendingMap::default())); + let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (raw_tx, mut raw_rx) = + tokio::sync::mpsc::unbounded_channel::(); + + // notify callback runs on its own thread; bridge into the Tokio + // channel so the rest of the pipeline is async. + let cb_root = canonical_root.clone(); + let raw_tx_for_cb = raw_tx.clone(); + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + if let Ok(event) = res { + // Filter inside the callback so the bounded channel + // doesn't fill up with `.memry/` chatter on a busy DB. + if event.paths.iter().all(|p| should_ignore(&cb_root, p)) { + return; + } + let _ = raw_tx_for_cb.send(event); + } + })?; + drop(raw_tx); + + watcher.watch(&canonical_root, RecursiveMode::Recursive)?; + + // Drain raw events into the pending map, bumping the deadline + // every time we see another event for the same path. + let pending_drain = pending.clone(); + let cancel_drain = cancel.clone(); + let root_for_drain = canonical_root.clone(); + let _drain: JoinHandle<()> = tokio::spawn(async move { + while let Some(event) = raw_rx.recv().await { + if cancel_drain.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + let kind = classify(&event.kind); + for path in event.paths { + if should_ignore(&root_for_drain, &path) { + continue; + } + let entry = PendingEntry { + last_event: kind, + deadline: std::time::Instant::now() + + Duration::from_millis(DEBOUNCE_MS), + }; + pending_drain + .lock() + .unwrap_or_else(|p| p.into_inner()) + .pending + .insert(path, entry); + } + } + }); + + // Periodically scan the pending map for entries whose deadline + // has passed and emit them. + let pending_emit = pending.clone(); + let cancel_emit = cancel.clone(); + let root_for_emit = canonical_root.clone(); + let scheduler: JoinHandle<()> = tokio::spawn(async move { + let tick = Duration::from_millis(50); + loop { + if cancel_emit.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + tokio::time::sleep(tick).await; + let now = std::time::Instant::now(); + let ready: Vec<(PathBuf, VaultEventKind)> = { + let mut g = pending_emit + .lock() + .unwrap_or_else(|p| p.into_inner()); + let mut ready = Vec::new(); + g.pending.retain(|path, entry| { + if entry.deadline <= now { + ready.push((path.clone(), entry.last_event)); + false + } else { + true + } + }); + ready + }; + for (path, kind) in ready { + if let Some(rel) = + paths::to_relative_path(&root_for_emit, &path) + { + let _ = out.send(VaultEvent { + relative_path: rel, + kind, + }); + } else if matches!(kind, VaultEventKind::Deleted) { + // Deleted file canonicalize fails; fall back to + // string strip. + if let Ok(stripped) = path.strip_prefix(&root_for_emit) { + let rel = + stripped.to_string_lossy().replace('\\', "/"); + let _ = out.send(VaultEvent { + relative_path: rel, + kind, + }); + } + } + } + } + }); + + Ok(WatcherHandle { + _watcher: watcher, + _scheduler: scheduler, + cancel, + }) +} + +fn should_ignore(root: &Path, path: &Path) -> bool { + let rel = match path.strip_prefix(root) { + Ok(r) => r, + Err(_) => return true, + }; + let mut comps = rel.components(); + while let Some(c) = comps.next() { + if let std::path::Component::Normal(seg) = c { + let s = seg.to_string_lossy(); + if s.starts_with('.') { + return true; + } + } + } + if !path.is_dir() { + let lower = path + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + if lower.is_empty() { + return true; + } + let supported = matches!( + lower.as_str(), + "md" | "markdown" | "png" | "jpg" | "jpeg" | "gif" | "webp" | + "svg" | "pdf" | "mp3" | "wav" | "m4a" | "ogg" | "mp4" | "mov" | "webm" + ); + return !supported; + } + false +} + +fn classify(kind: &EventKind) -> VaultEventKind { + match kind { + EventKind::Create(_) => VaultEventKind::Created, + EventKind::Modify(ModifyKind::Name(_)) => VaultEventKind::Modified, + EventKind::Modify(_) => VaultEventKind::Modified, + EventKind::Remove(_) => VaultEventKind::Deleted, + _ => VaultEventKind::Modified, + } +} +``` + +- [ ] **Step 10.4: Verify cargo check before running watcher tests** + +```bash +cd apps/desktop-tauri/src-tauri && cargo check +``` + +Expected: pass. The watcher module is self-contained except for `paths`. + +- [ ] **Step 10.5: Run watcher tests (slow — ~10s)** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_watcher_test +``` + +Expected: `4 passed`. If `debounces_rapid_writes_to_same_file` is flaky on macOS FSEvents (coalescing latency varies under load), bump the upper bound from 3 to 5 events. Document any change in a code comment. + +- [ ] **Step 10.6: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/vault/watcher.rs \ + apps/desktop-tauri/src-tauri/tests/vault_watcher_test.rs \ + apps/desktop-tauri/src-tauri/Cargo.toml +git commit -m "m3(vault): watcher.rs notify+debounce emitting VaultEvent {path, kind}" +``` + +--- + +## Task 11: Vault Tauri commands (`commands/vault.rs`) + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/src/commands/vault.rs` +- Modify: `apps/desktop-tauri/src-tauri/src/commands/mod.rs` +- Modify: `apps/desktop-tauri/src-tauri/src/lib.rs` (register handlers + start/stop watcher hooks) + +- [ ] **Step 11.1: Implement `commands/vault.rs`** + +Create `apps/desktop-tauri/src-tauri/src/commands/vault.rs`: + +```rust +//! Vault command surface. Thin async wrappers over `vault::*` modules. +//! +//! Every command takes a single named-input struct. Path-bearing +//! commands always go through `paths::resolve_*` before any FS call — +//! the renderer can never poke a path outside the open vault. +//! +//! The commands fall into three groups: +//! 1. lifecycle: open, close, get_status, get_current +//! 2. registry: get_all, switch, remove +//! 3. note IO: list_notes, read_note, write_note, delete_note, +//! get_config, update_config, reveal, reindex (no-op until M7) + +use crate::app_state::AppState; +use crate::error::{AppError, AppResult}; +use crate::vault::{ + fs as vfs, frontmatter::NoteFrontmatter, notes_io, paths, preferences, + registry::VaultInfo, state::VaultStatus, watcher, +}; +use std::path::PathBuf; +use tauri::{AppHandle, Emitter, State}; + +#[derive(serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultOpenInput { + pub path: String, +} + +#[derive(serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultOpenOutput { + pub success: bool, + pub vault: Option, + pub error: Option, +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_open( + state: State<'_, AppState>, + app: AppHandle, + input: VaultOpenInput, +) -> AppResult { + let target = PathBuf::from(&input.path); + if !target.is_dir() { + return Ok(VaultOpenOutput { + success: false, + vault: None, + error: Some(format!("not a directory: {}", input.path)), + }); + } + if let Err(e) = std::fs::metadata(&target).and_then(|m| { + if m.permissions().readonly() { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "vault path is read-only", + )) + } else { + Ok(()) + } + }) { + return Ok(VaultOpenOutput { + success: false, + vault: None, + error: Some(e.to_string()), + }); + } + + preferences::init_vault(&target)?; + state.vault.set_indexing(true, 0); + + // Stop any prior watcher. + { + let mut slot = state + .vault + .watcher_slot + .lock() + .unwrap_or_else(|p| p.into_inner()); + slot.take(); + } + + state.vault.set_current(Some(target.clone()))?; + + // Compute counts cheaply for the registry blurb. + let cfg = preferences::read_config(&target)?; + let note_count = preferences::count_markdown_files(&target, &cfg.exclude_patterns); + + let info = VaultInfo { + path: target.to_string_lossy().into_owned(), + name: preferences::vault_name(&target), + note_count, + task_count: 0, + last_opened: now_iso(), + is_default: state + .vault + .registry_snapshot() + .vaults + .is_empty(), + }; + state.vault.upsert_registry(info.clone())?; + state.vault.touch_registry(&info.path)?; + + // Start watcher. + let app_handle = app.clone(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let handle = watcher::start(&target, tx)?; + { + let mut slot = state + .vault + .watcher_slot + .lock() + .unwrap_or_else(|p| p.into_inner()); + *slot = Some(handle); + } + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + let _ = app_handle.emit("vault-changed", &event); + } + }); + + state.vault.set_indexing(false, 100); + + let _ = app.emit("vault-status-changed", &state.vault.status()); + + Ok(VaultOpenOutput { + success: true, + vault: Some(info), + error: None, + }) +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_close(state: State<'_, AppState>, app: AppHandle) -> AppResult<()> { + { + let mut slot = state + .vault + .watcher_slot + .lock() + .unwrap_or_else(|p| p.into_inner()); + slot.take(); + } + state.vault.set_current(None)?; + state.vault.set_indexing(false, 0); + let _ = app.emit("vault-status-changed", &state.vault.status()); + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_get_status(state: State<'_, AppState>) -> AppResult { + Ok(state.vault.status()) +} + +#[derive(serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultCurrent { + pub path: Option, +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_get_current(state: State<'_, AppState>) -> AppResult { + Ok(VaultCurrent { + path: state + .vault + .current_path() + .map(|p| p.to_string_lossy().into_owned()), + }) +} + +#[derive(serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultGetAllOutput { + pub vaults: Vec, + pub current_vault: Option, +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_get_all(state: State<'_, AppState>) -> AppResult { + let snap = state.vault.registry_snapshot(); + Ok(VaultGetAllOutput { + vaults: snap.vaults, + current_vault: snap.current, + }) +} + +#[derive(serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultPathInput { + pub path: String, +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_switch( + state: State<'_, AppState>, + app: AppHandle, + input: VaultPathInput, +) -> AppResult { + vault_open(state, app, VaultOpenInput { path: input.path }).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_remove( + state: State<'_, AppState>, + app: AppHandle, + input: VaultPathInput, +) -> AppResult<()> { + let current = state.vault.current_path(); + if current + .as_ref() + .map(|p| p.to_string_lossy().into_owned()) + == Some(input.path.clone()) + { + let _ = vault_close(state.clone(), app.clone()).await; + } + state.vault.remove_from_registry(&input.path)?; + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_get_config(state: State<'_, AppState>) -> AppResult { + let path = state.vault.require_current()?; + preferences::read_config(&path) +} + +#[derive(serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultUpdateConfigInput { + pub exclude_patterns: Option>, + pub default_note_folder: Option, + pub journal_folder: Option, + pub attachments_folder: Option, +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_update_config( + state: State<'_, AppState>, + input: VaultUpdateConfigInput, +) -> AppResult { + let path = state.vault.require_current()?; + let mut cfg = preferences::read_config(&path)?; + if let Some(p) = input.exclude_patterns { cfg.exclude_patterns = p; } + if let Some(s) = input.default_note_folder { cfg.default_note_folder = s; } + if let Some(s) = input.journal_folder { cfg.journal_folder = s; } + if let Some(s) = input.attachments_folder { cfg.attachments_folder = s; } + preferences::update_config(&path, &cfg) +} + +#[derive(serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultListNotesOutput { + pub paths: Vec, +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_list_notes(state: State<'_, AppState>) -> AppResult { + let path = state.vault.require_current()?; + let entries = vfs::list_supported_files(&path).await?; + let only_md: Vec = entries + .into_iter() + .filter(|p| p.ends_with(".md") || p.ends_with(".markdown")) + .collect(); + Ok(VaultListNotesOutput { paths: only_md }) +} + +#[derive(serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultReadNoteInput { + pub relative_path: String, +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_read_note( + state: State<'_, AppState>, + input: VaultReadNoteInput, +) -> AppResult> { + let root = state.vault.require_current()?; + notes_io::read_note_from_disk(&root, &input.relative_path).await +} + +#[derive(serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultWriteNoteInput { + pub relative_path: String, + pub frontmatter: NoteFrontmatter, + pub content: String, +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_write_note( + state: State<'_, AppState>, + input: VaultWriteNoteInput, +) -> AppResult { + let root = state.vault.require_current()?; + notes_io::write_note_to_disk( + &root, + &input.relative_path, + &input.frontmatter, + &input.content, + ) + .await +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_delete_note( + state: State<'_, AppState>, + input: VaultReadNoteInput, +) -> AppResult<()> { + let root = state.vault.require_current()?; + notes_io::delete_note_from_disk(&root, &input.relative_path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_reveal(state: State<'_, AppState>) -> AppResult<()> { + let path = state.vault.require_current()?; + crate::commands::shell::reveal_in_finder_inner(&path) +} + +/// Deferred to M7 (rebuildable index.db). Returns a fixed payload so +/// the renderer's settings UI can render its "reindex" button without +/// breaking in production. +#[derive(serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct VaultReindexOutput { + pub success: bool, + pub files_indexed: i64, + pub duration: i64, + pub deferred_until: String, +} + +#[tauri::command] +#[specta::specta] +pub async fn vault_reindex(_state: State<'_, AppState>) -> AppResult { + Ok(VaultReindexOutput { + success: true, + files_indexed: 0, + duration: 0, + deferred_until: "M7".into(), + }) +} + +fn now_iso() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + crate::vault::frontmatter_iso(secs) +} +``` + +- [ ] **Step 11.2: Update `commands/mod.rs`** + +Replace the current contents with: + +```rust +//! IPC command surface exposed to the renderer. + +pub mod settings; +pub mod vault; +pub mod shell; +pub mod dialog; +``` + +- [ ] **Step 11.3: Register every vault command in `lib.rs::run`** + +Open `apps/desktop-tauri/src-tauri/src/lib.rs`. Replace the `.invoke_handler(tauri::generate_handler![...])` block with: + +```rust +.invoke_handler(tauri::generate_handler![ + commands::settings::settings_get, + commands::settings::settings_set, + commands::settings::settings_list, + commands::vault::vault_open, + commands::vault::vault_close, + commands::vault::vault_get_status, + commands::vault::vault_get_current, + commands::vault::vault_get_all, + commands::vault::vault_switch, + commands::vault::vault_remove, + commands::vault::vault_get_config, + commands::vault::vault_update_config, + commands::vault::vault_list_notes, + commands::vault::vault_read_note, + commands::vault::vault_write_note, + commands::vault::vault_delete_note, + commands::vault::vault_reveal, + commands::vault::vault_reindex, + commands::shell::shell_open_url, + commands::shell::shell_open_path, + commands::shell::shell_reveal_in_finder, + commands::dialog::dialog_choose_folder, + commands::dialog::dialog_choose_files, +]) +``` + +- [ ] **Step 11.4: Verify cargo check (commands not implemented yet — Step 11.5 ports those over)** + +The shell/dialog modules are stubbed in Task 12; for now expose empty modules so this compiles. Touch them as empty: + +```bash +touch apps/desktop-tauri/src-tauri/src/commands/shell.rs apps/desktop-tauri/src-tauri/src/commands/dialog.rs +``` + +Add minimal stubs in each so the macros find symbols. Open `commands/shell.rs`: + +```rust +//! Stubbed in Task 11; populated in Task 12. + +use crate::error::AppResult; + +#[tauri::command] +#[specta::specta] +pub async fn shell_open_url(_url: String) -> AppResult<()> { Ok(()) } + +#[tauri::command] +#[specta::specta] +pub async fn shell_open_path(_path: String) -> AppResult<()> { Ok(()) } + +#[tauri::command] +#[specta::specta] +pub async fn shell_reveal_in_finder(_path: String) -> AppResult<()> { Ok(()) } + +pub(crate) fn reveal_in_finder_inner(_path: &std::path::Path) -> AppResult<()> { Ok(()) } +``` + +Open `commands/dialog.rs`: + +```rust +//! Stubbed in Task 11; populated in Task 12. + +use crate::error::AppResult; + +#[tauri::command] +#[specta::specta] +pub async fn dialog_choose_folder(_title: Option) -> AppResult> { + Ok(None) +} + +#[tauri::command] +#[specta::specta] +pub async fn dialog_choose_files( + _title: Option, + _filters: Option>, +) -> AppResult> { + Ok(Vec::new()) +} +``` + +- [ ] **Step 11.5: Confirm cargo check + clippy pass** + +```bash +cd apps/desktop-tauri/src-tauri && cargo check && cargo clippy -- -D warnings +``` + +Expected: pass. + +- [ ] **Step 11.6: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/commands \ + apps/desktop-tauri/src-tauri/src/lib.rs +git commit -m "m3(commands): vault_* command surface (open/close/list/read/write/...) + shell/dialog stubs" +``` + +--- + +## Task 12: Native shell + dialog commands (`commands/shell.rs`, `commands/dialog.rs`) + +**Files:** +- Modify: `apps/desktop-tauri/src-tauri/src/commands/shell.rs` +- Modify: `apps/desktop-tauri/src-tauri/src/commands/dialog.rs` +- Modify: `apps/desktop-tauri/src-tauri/src/lib.rs` (register dialog plugin) +- Modify: `apps/desktop-tauri/src-tauri/capabilities/default.json` + +- [ ] **Step 12.1: Replace `commands/shell.rs` with the real impl** + +```rust +//! `shell.*` parity commands. +//! +//! - `shell_open_url(url)` opens an http(s) URL in the user's default +//! browser via `tauri-plugin-shell`. Rejects non-http schemes. +//! - `shell_open_path(path)` opens a local file/folder with the OS +//! default handler. The path must be absolute. +//! - `shell_reveal_in_finder(path)` selects the path in Finder +//! (`open -R` on macOS). + +use crate::error::{AppError, AppResult}; +use std::path::Path; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; + +#[tauri::command] +#[specta::specta] +pub async fn shell_open_url(app: AppHandle, url: String) -> AppResult<()> { + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err(AppError::Validation(format!("non-http url: {url}"))); + } + app.shell() + .open(url, None) + .map_err(|e| AppError::Vault(format!("shell open failed: {e}"))) +} + +#[tauri::command] +#[specta::specta] +pub async fn shell_open_path(app: AppHandle, path: String) -> AppResult<()> { + let p = Path::new(&path); + if !p.is_absolute() { + return Err(AppError::Validation(format!("path must be absolute: {path}"))); + } + if !p.exists() { + return Err(AppError::NotFound(path.clone())); + } + app.shell() + .open(path, None) + .map_err(|e| AppError::Vault(format!("shell open failed: {e}"))) +} + +#[tauri::command] +#[specta::specta] +pub async fn shell_reveal_in_finder(path: String) -> AppResult<()> { + let p = Path::new(&path); + if !p.is_absolute() { + return Err(AppError::Validation(format!("path must be absolute: {path}"))); + } + reveal_in_finder_inner(p) +} + +pub(crate) fn reveal_in_finder_inner(path: &Path) -> AppResult<()> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg("-R") + .arg(path) + .spawn() + .map(|_| ()) + .map_err(|e| AppError::Vault(format!("open -R failed: {e}"))) + } + #[cfg(not(target_os = "macos"))] + { + Err(AppError::Validation( + "reveal in finder is macOS-only in v1".into(), + )) + } +} +``` + +- [ ] **Step 12.2: Replace `commands/dialog.rs` with the real impl** + +```rust +//! Native folder + file picker commands using +//! `tauri-plugin-dialog`. Returns POSIX path strings (forward slashes +//! on macOS). + +use crate::error::{AppError, AppResult}; +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; + +#[tauri::command] +#[specta::specta] +pub async fn dialog_choose_folder( + app: AppHandle, + title: Option, +) -> AppResult> { + let mut builder = app.dialog().file(); + if let Some(t) = &title { + builder = builder.set_title(t); + } + let (tx, rx) = std::sync::mpsc::channel(); + builder.pick_folder(move |result| { + let _ = tx.send(result); + }); + let result = rx.recv().map_err(|e| AppError::Internal(e.to_string()))?; + Ok(result.map(|p| p.to_string())) +} + +#[tauri::command] +#[specta::specta] +pub async fn dialog_choose_files( + app: AppHandle, + title: Option, + filters: Option>, +) -> AppResult> { + let mut builder = app.dialog().file(); + if let Some(t) = &title { + builder = builder.set_title(t); + } + if let Some(exts) = filters.as_ref() { + if !exts.is_empty() { + let mut owned: Vec<&str> = exts.iter().map(String::as_str).collect(); + // Tauri's filter API expects (name, &[ext]). + let _name = "Allowed"; + builder = builder.add_filter("Allowed", &owned); + owned.clear(); + } + } + let (tx, rx) = std::sync::mpsc::channel(); + builder.pick_files(move |paths| { + let _ = tx.send(paths); + }); + let result = rx.recv().map_err(|e| AppError::Internal(e.to_string()))?; + Ok(result + .map(|paths| paths.into_iter().map(|p| p.to_string()).collect()) + .unwrap_or_default()) +} +``` + +- [ ] **Step 12.3: Register the dialog plugin in `lib.rs::run`** + +Find the existing `.plugin(tauri_plugin_shell::init())` line and add directly after it: + +```rust +.plugin(tauri_plugin_dialog::init()) +``` + +- [ ] **Step 12.4: Extend `capabilities/default.json`** + +Replace the file with: + +```json +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability grants for Memry desktop app", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-maximize", + "core:window:allow-unmaximize", + "core:window:allow-start-dragging", + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-print", + "dialog:default", + "dialog:allow-open", + "shell:default", + "shell:allow-open" + ] +} +``` + +`shell:allow-open` is the permission needed for `shell.open`. `dialog:allow-open` covers `pick_folder`/`pick_files`. The `core:event:*` grants are needed by the renderer to subscribe to `vault-changed` and `vault-status-changed` from background threads. + +- [ ] **Step 12.5: Verify capability:check passes** + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 && \ + pnpm --filter @memry/desktop-tauri capability:check +``` + +Expected: exits 0. The script cross-references `Cargo.toml` plugins against `capabilities/default.json` permissions; missing grants fail with a useful error. + +- [ ] **Step 12.6: Verify cargo + clippy clean** + +```bash +cd apps/desktop-tauri/src-tauri && cargo check && cargo clippy -- -D warnings +``` + +- [ ] **Step 12.7: Manual smoke (real Tauri window required)** + +```bash +pnpm --filter @memry/desktop-tauri dev +``` + +Open the dev console and run: + +```js +await window.__TAURI__.core.invoke('shell_open_url', { url: 'https://example.com' }) +await window.__TAURI__.core.invoke('shell_reveal_in_finder', { path: '/tmp' }) +const folder = await window.__TAURI__.core.invoke('dialog_choose_folder', {}) +console.log('chose', folder) +``` + +Expected: browser opens, Finder reveals `/tmp`, folder picker appears. Cancel returns `null`. Document the smoke completion in the PR body. + +- [ ] **Step 12.8: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/commands/shell.rs \ + apps/desktop-tauri/src-tauri/src/commands/dialog.rs \ + apps/desktop-tauri/src-tauri/src/lib.rs \ + apps/desktop-tauri/src-tauri/capabilities/default.json +git commit -m "m3(commands): native shell.open / dialog.pick + capability grants" +``` + +--- + +## Task 13: `memry-file://` custom URI scheme protocol handler + +**Files:** +- Modify: `apps/desktop-tauri/src-tauri/src/lib.rs` +- Modify: `apps/desktop-tauri/src-tauri/tauri.conf.json` +- Create: `apps/desktop-tauri/src/lib/memry-file.ts` + +- [ ] **Step 13.1: Register the URI scheme in `tauri.conf.json`** + +Open `apps/desktop-tauri/src-tauri/tauri.conf.json`. Inside the `app` object, after `windows`, add: + +```json + "withGlobalTauri": false, +``` + +Then add a top-level `app.security` `assetProtocol` entry — wait, in Tauri 2 custom URI scheme protocols are registered via `tauri::Builder::register_uri_scheme_protocol`. The conf only needs the CSP entry. Update `app.security.csp` to: + +```json +"csp": "default-src 'self' memry-file:; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: memry-file: https:; media-src 'self' blob: data: memry-file:; frame-src https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://player.twitch.tv; font-src 'self' data:; worker-src 'self' blob:; connect-src 'self' ipc: https://ipc.localhost http://localhost:1420 https://*.youtube.com" +``` + +Compared to M2 baseline this only adds `memry-file:` to `default-src`, `img-src`, and `media-src`. No third-party host opens up. + +- [ ] **Step 13.2: Implement the protocol handler in `lib.rs::run`** + +Open `apps/desktop-tauri/src-tauri/src/lib.rs`. Locate the `tauri::Builder::default()` chain. After the `.plugin(tauri_plugin_dialog::init())` line, insert: + +```rust + .register_uri_scheme_protocol("memry-file", |ctx, request| { + let app = ctx.app_handle().clone(); + let response = handle_memry_file(&app, &request); + response + }) +``` + +Then add the handler at the bottom of the file: + +```rust +fn handle_memry_file( + app: &tauri::AppHandle, + request: &tauri::http::Request>, +) -> tauri::http::Response> { + use std::fs; + use std::io::Read; + use tauri::http::{header, Response, StatusCode}; + + let url = request.uri().to_string(); + // Format: memry-file://local/ + let path = match url.strip_prefix("memry-file://local/") { + Some(p) => format!("/{}", urlencoding::decode(p).unwrap_or_default()), + None => { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(b"invalid memry-file url".to_vec()) + .unwrap(); + } + }; + let abs = match dunce::canonicalize(&path) { + Ok(a) => a, + Err(_) => return missing(&path), + }; + + // Allowlist: app data dir + current vault root. + let mut allowed: Vec = Vec::new(); + if let Some(state) = app.try_state::() { + if let Some(vault) = state.vault.current_path() { + if let Ok(c) = dunce::canonicalize(&vault) { + allowed.push(c); + } + } + } + if let Ok(dirs) = directories::ProjectDirs::from("com", "memry", "memry") + .ok_or(()) + .and_then(|d| Ok(d.data_dir().to_path_buf())) + { + if let Ok(c) = dunce::canonicalize(&dirs) { + allowed.push(c); + } + } + if !allowed.iter().any(|root| abs.starts_with(root)) { + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(b"path not in allowed roots".to_vec()) + .unwrap(); + } + + let bytes = match fs::read(&abs) { + Ok(b) => b, + Err(_) => return missing(&path), + }; + + let mime = mime_guess::from_path(&abs) + .first_or_octet_stream() + .essence_str() + .to_string(); + + if let Some(range) = request.headers().get(header::RANGE) { + if let Ok(range_str) = range.to_str() { + if let Some(captures) = parse_range(range_str, bytes.len() as u64) { + let (start, end) = captures; + let slice = &bytes[start as usize..=end as usize]; + let len = slice.len(); + return Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, mime) + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{}", bytes.len()), + ) + .header(header::CONTENT_LENGTH, len.to_string()) + .header(header::ACCEPT_RANGES, "bytes") + .body(slice.to_vec()) + .unwrap(); + } + } + } + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + .header(header::CONTENT_LENGTH, bytes.len().to_string()) + .header(header::ACCEPT_RANGES, "bytes") + .body(bytes) + .unwrap() +} + +fn missing(path: &str) -> tauri::http::Response> { + use tauri::http::{header, Response, StatusCode}; + let lower = path.to_lowercase(); + let is_image = lower.ends_with(".png") + || lower.ends_with(".jpg") + || lower.ends_with(".jpeg") + || lower.ends_with(".gif") + || lower.ends_with(".webp"); + if is_image { + // 1x1 transparent PNG + let bytes = base64_decode_static( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + ); + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "image/png") + .body(bytes) + .unwrap(); + } + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(b"not found".to_vec()) + .unwrap() +} + +fn parse_range(header_value: &str, total: u64) -> Option<(u64, u64)> { + let v = header_value.strip_prefix("bytes=")?; + let (start_str, end_str) = v.split_once('-')?; + let start: u64 = start_str.parse().ok().unwrap_or(0); + let end: u64 = if end_str.is_empty() { + total.saturating_sub(1) + } else { + end_str.parse().ok().unwrap_or(total.saturating_sub(1)) + }; + if start > end || end >= total { + return None; + } + Some((start, end)) +} + +fn base64_decode_static(input: &str) -> Vec { + use std::collections::HashMap; + let alphabet = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut lookup = HashMap::new(); + for (i, c) in alphabet.iter().enumerate() { + lookup.insert(*c as char, i as u8); + } + let mut buf = 0u32; + let mut bits = 0u32; + let mut out = Vec::with_capacity(input.len() * 3 / 4); + for c in input.chars() { + if c == '=' { + break; + } + if let Some(v) = lookup.get(&c) { + buf = (buf << 6) | *v as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1u32 << bits) - 1; + } + } + } + out +} +``` + +- [ ] **Step 13.3: Add `urlencoding` to `Cargo.toml`** + +```toml +urlencoding = "2.1" +``` + +- [ ] **Step 13.4: Add the renderer-side `toMemryFileUrl` helper** + +Create `apps/desktop-tauri/src/lib/memry-file.ts`: + +```ts +/** + * Build a memry-file:// URL from an absolute local path. + * + * Mirrors the Electron helper of the same name. The Tauri custom URI + * scheme handler in src-tauri/src/lib.rs decodes percent-encoded + * paths, so encodeURI is correct here. + */ +export function toMemryFileUrl(absolutePath: string): string { + const normalized = absolutePath.replace(/^\/+/, '') + return `memry-file://local/${encodeURI(normalized)}` +} + +export function fromMemryFileUrl(url: string): string { + const prefix = 'memry-file://local/' + if (!url.startsWith(prefix)) { + throw new Error(`Invalid memry-file URL: ${url}`) + } + const rest = decodeURIComponent(url.slice(prefix.length)) + return '/' + rest +} +``` + +- [ ] **Step 13.5: Manual smoke** + +Drop a small test image into your scratch vault: + +```bash +cp ~/Pictures/any.png ~/memry-test-vault-m3/attachments/images/test.png +``` + +Rebuild and open dev: + +```bash +pnpm --filter @memry/desktop-tauri dev +``` + +In dev console: + +```js +const url = `memry-file://local/${encodeURI(`${Object.values(window).find(v => v && v.__TAURI__)?.__TAURI__?.path?.appDataDir?.() || ''}`).replace(/^\/+/,'')}` +const test = 'memry-file://local/' + encodeURI(`${'/Users/' + 'YOU' + '/memry-test-vault-m3/attachments/images/test.png'}`.replace(/^\/+/,'')) +const img = new Image() +img.onload = () => console.log('image loaded', img.width, img.height) +img.onerror = (e) => console.error('image failed', e) +img.src = test +``` + +Expected: image renders. Repeat for a missing image — expect a 1×1 transparent PNG response (no console error, broken-image icon does not appear). Repeat for an outside-vault path (e.g. `/etc/hosts`) — expect a 403 response. + +- [ ] **Step 13.6: Document the smoke result in the PR body** (Task 16) + +- [ ] **Step 13.7: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/lib.rs \ + apps/desktop-tauri/src-tauri/Cargo.toml \ + apps/desktop-tauri/src-tauri/Cargo.lock \ + apps/desktop-tauri/src-tauri/tauri.conf.json \ + apps/desktop-tauri/src/lib/memry-file.ts +git commit -m "m3(protocol): memry-file:// URI scheme with byte-range + missing-image fallback" +``` + +--- + +## Task 14: Drag-drop path-resolution spike + +**Files:** +- Modify: `apps/desktop-tauri/src-tauri/src/lib.rs` (subscribe to drag-drop events) +- Modify: `apps/desktop-tauri/src-tauri/capabilities/default.json` (drop event grants) +- Create: `apps/desktop-tauri/scripts/drag-drop-smoke.md` (documented manual smoke) + +- [ ] **Step 14.1: Capability — already covered** + +`core:default` includes the file-drop event grants in Tauri 2.x. Confirm by inspecting the schema: + +```bash +grep -n 'drag-drop\|dragDropEvent\|drop' apps/desktop-tauri/src-tauri/gen/schemas/desktop-schema.json | head -5 +``` + +Expected: at least one `drop` reference in `core:default`. If absent, add `core:webview:allow-on-drag-drop-event` to `permissions`. + +- [ ] **Step 14.2: Subscribe to drag-drop events from the main window** + +Open `apps/desktop-tauri/src-tauri/src/lib.rs`. Inside the `.setup(|app| { ... })` closure, AFTER the `tracing_subscriber::fmt()...` block, add: + +```rust + let main_window = app + .get_webview_window("main") + .ok_or_else(|| anyhow::anyhow!("main window missing")) + .map_err(|e| Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) as Box)?; + let main_clone = main_window.clone(); + main_window.on_window_event(move |event| { + if let tauri::WindowEvent::DragDrop(drag) = event { + if let tauri::DragDropEvent::Drop { paths, .. } = drag { + let path_strs: Vec = + paths.iter().map(|p| p.to_string_lossy().into_owned()).collect(); + let _ = main_clone.app_handle().emit("vault-drag-drop", &path_strs); + } + } + }); +``` + +Tauri 2 emits absolute paths with the drop event — drop events on macOS WebKit work natively in M3, so the renderer never has to rely on the Electron-only `webUtils.getPathForFile`. + +> The `anyhow` import is just to keep the closure error-cast simple. +> Add `anyhow = "1"` to `[dependencies]` if not already present. + +- [ ] **Step 14.3: Renderer subscribes to `vault-drag-drop`** + +This task delivers the spike, not the import feature itself (file import lands in M8). Still, prove the wiring works end-to-end. Add a tiny logger in `apps/desktop-tauri/src/main.tsx`: + +Find the `createRoot` block. Right after `await ensureLibsodium()` (or the earliest top-level await), add: + +```ts +import { listen } from '@tauri-apps/api/event' + +void listen('vault-drag-drop', (event) => { + // Spike telemetry only. Replaced by real import handler in M8. + console.info('[drag-drop spike] paths:', event.payload) +}) +``` + +This is a temporary spike marker — Task 16's verification step deletes the import once the smoke is documented. + +- [ ] **Step 14.4: Run the manual smoke** + +```bash +pnpm --filter @memry/desktop-tauri dev +``` + +In a Finder window, select an image, a PDF, and a video. Drag them into the running Tauri window. In the dev console you should see: + +```text +[drag-drop spike] paths: ["/Users/.../foo.png", "/Users/.../doc.pdf", "/Users/.../clip.mp4"] +``` + +If paths are received, the spike is 🟢. If you see `webkit-fake-url://` strings (old WebKit fallback), the spike is 🔴 — fall back to a `dialog_choose_files` import flow (already implemented in Task 12) and document this in `scripts/drag-drop-smoke.md` as the canonical fallback. + +- [ ] **Step 14.5: Document the smoke** + +Create `apps/desktop-tauri/scripts/drag-drop-smoke.md`: + +```markdown +# M3 drag-drop path-resolution spike + +Run `pnpm --filter @memry/desktop-tauri dev`, drag files into the +window, watch dev console for `[drag-drop spike] paths:` log line. + +| Outcome | What it means | Action | +|---|---|---| +| Real `/Users/...` paths | macOS WebKit + Tauri 2 deliver real paths. | M8 file import can rely on drop events. | +| `webkit-fake-url://` | Native drop didn't work. | Fall back to `dialog_choose_files` for file imports. | + +Last verified: , macOS , Tauri 2.10, Memry desktop-tauri. +``` + +Fill in the date/version after the smoke. + +- [ ] **Step 14.6: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/lib.rs \ + apps/desktop-tauri/src-tauri/Cargo.toml \ + apps/desktop-tauri/src-tauri/capabilities/default.json \ + apps/desktop-tauri/src/main.tsx \ + apps/desktop-tauri/scripts/drag-drop-smoke.md +git commit -m "m3(spike): drag-drop path resolution + documented fallback to dialog picker" +``` + +--- + +## Task 15: Bindings regen + mock-swap + renderer integration + +**Files:** +- Modify: `apps/desktop-tauri/src-tauri/src/bin/generate_bindings.rs` +- Modify: `apps/desktop-tauri/src/lib/ipc/invoke.ts` +- Modify: `apps/desktop-tauri/src/lib/ipc/mocks/vault.ts` +- Create: `apps/desktop-tauri/e2e/specs/m3-vault-smoke.spec.ts` +- Modify: `apps/desktop-tauri/src/services/vault-service.ts` (small adapter shape changes) + +- [ ] **Step 15.1: Extend `generate_bindings.rs`** + +Open `apps/desktop-tauri/src-tauri/src/bin/generate_bindings.rs`. Replace the `collect_commands![...]` macro and `.typ::<...>()` chain with: + +```rust +.commands(collect_commands![ + commands::settings::settings_get, + commands::settings::settings_set, + commands::settings::settings_list, + commands::vault::vault_open, + commands::vault::vault_close, + commands::vault::vault_get_status, + commands::vault::vault_get_current, + commands::vault::vault_get_all, + commands::vault::vault_switch, + commands::vault::vault_remove, + commands::vault::vault_get_config, + commands::vault::vault_update_config, + commands::vault::vault_list_notes, + commands::vault::vault_read_note, + commands::vault::vault_write_note, + commands::vault::vault_delete_note, + commands::vault::vault_reveal, + commands::vault::vault_reindex, + commands::shell::shell_open_url, + commands::shell::shell_open_path, + commands::shell::shell_reveal_in_finder, + commands::dialog::dialog_choose_folder, + commands::dialog::dialog_choose_files, +]) +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::() +.typ::(); +``` + +Add `use memry_desktop_tauri_lib::vault;` at the top alongside the existing `commands` / `db` / `error` imports. + +- [ ] **Step 15.2: Regenerate bindings + verify** + +```bash +pnpm --filter @memry/desktop-tauri bindings:generate +pnpm --filter @memry/desktop-tauri bindings:check +``` + +Expected: clean. The generated `src/generated/bindings.ts` now contains every M3 type and command signature. + +- [ ] **Step 15.3: Add the swapped commands to `realCommands` in `invoke.ts`** + +Open `apps/desktop-tauri/src/lib/ipc/invoke.ts`. Replace the `realCommands` Set with: + +```ts +const realCommands = new Set([ + 'settings_get', + 'settings_set', + 'settings_list', + 'vault_open', + 'vault_close', + 'vault_get_status', + 'vault_get_current', + 'vault_get_all', + 'vault_switch', + 'vault_remove', + 'vault_get_config', + 'vault_update_config', + 'vault_list_notes', + 'vault_read_note', + 'vault_write_note', + 'vault_delete_note', + 'vault_reveal', + 'shell_open_url', + 'shell_open_path', + 'shell_reveal_in_finder', + 'dialog_choose_folder', + 'dialog_choose_files' +]) +``` + +Note `vault_reindex` stays on the mock path until M7 — the Rust stub returns `{ deferredUntil: 'M7' }` and the renderer does not need to know. + +- [ ] **Step 15.4: Trim the mock vault routes that are now real** + +Open `apps/desktop-tauri/src/lib/ipc/mocks/vault.ts`. Delete every route except `vault_reindex` and `vault_create` (the latter still has no Rust counterpart — the renderer's "create vault" flow uses `dialog_choose_folder` then `vault_open`, but the legacy `vault_create` mock stays for the existing onboarding component until M5 refactors it). + +The trimmed file: + +```ts +import type { MockRouteMap } from './types' + +const config = { + excludePatterns: ['.git', 'node_modules', '.DS_Store'], + defaultNoteFolder: 'notes', + journalFolder: 'journal', + attachmentsFolder: 'attachments' +} + +export const vaultRoutes: MockRouteMap = { + // M7 will replace this stub with a real index.db rebuild. + vault_reindex: async () => ({ success: true, filesIndexed: 0, duration: 0, deferredUntil: 'M7' }), + + // Legacy onboarding helper — replaced by dialog_choose_folder + vault_open in M5. + vault_create: async (args) => { + const { path, name } = args as { path: string; name: string } + return { + success: true, + vault: { + path, + name, + noteCount: 0, + taskCount: 0, + lastOpened: new Date().toISOString(), + isDefault: false + } + } + } +} +``` + +Update `mocks/index.ts` if it still imports the deleted route names — TypeScript will tell you on the next typecheck. + +- [ ] **Step 15.5: Adjust the vault service shape if needed** + +Open `apps/desktop-tauri/src/services/vault-service.ts`. The `createInvokeForwarder('vault')` call maps method names like `getStatus` to `vault_get_status`. Before M3 the renderer expected: + +- `vault_get_all` → `{ vaults, currentVault }` +- `vault_get_status` → `{ isOpen, path, isIndexing, indexProgress, error }` + +The Rust implementations match this shape. No service-layer change is required — but verify by running `pnpm --filter @memry/desktop-tauri test` and reading the failure output. Adjust the forwarder camelCase → snake_case mapping only if a test fails. + +- [ ] **Step 15.6: Add e2e smoke against the runtime lane** + +Create `apps/desktop-tauri/e2e/specs/m3-vault-smoke.spec.ts`: + +```ts +import { test, expect } from '@playwright/test' + +const TEST_VAULT = process.env.M3_TEST_VAULT_PATH ?? `${process.env.HOME}/memry-test-vault-m3` + +test.describe('M3 vault smoke', () => { + test('open vault, list notes, read+write a note', async ({ page }) => { + await page.goto('/') + + const result = await page.evaluate(async (path) => { + const { invoke } = (window as unknown as { + __TAURI__: { core: { invoke: (cmd: string, args?: Record) => Promise } } + }).__TAURI__.core + const open = await invoke('vault_open', { input: { path } }) + const status = await invoke('vault_get_status') + const list = await invoke('vault_list_notes') + return { open, status, list } + }, TEST_VAULT) + + expect(result.open).toMatchObject({ success: true }) + expect(result.status).toMatchObject({ isOpen: true }) + expect(Array.isArray((result.list as { paths: string[] }).paths)).toBe(true) + }) + + test('write then read roundtrip preserves Turkish chars', async ({ page }) => { + await page.goto('/') + + const out = await page.evaluate(async (path) => { + const { invoke } = (window as unknown as { + __TAURI__: { core: { invoke: (cmd: string, args?: Record) => Promise } } + }).__TAURI__.core + await invoke('vault_open', { input: { path } }) + const fm = { + id: 'm3-smoke-id', + title: 'Çalışma günü', + created: '2026-04-26T00:00:00Z', + modified: '2026-04-26T00:00:00Z', + tags: ['work'], + aliases: [], + emoji: null, + localOnly: null, + properties: null, + extra: {} + } + await invoke('vault_write_note', { + input: { + relativePath: 'notes/m3-smoke.md', + frontmatter: fm, + content: 'İçerik düzenlendi.' + } + }) + return await invoke('vault_read_note', { + input: { relativePath: 'notes/m3-smoke.md' } + }) + }, TEST_VAULT) + + expect(out).toBeTruthy() + expect((out as { parsed: { content: string } }).parsed.content).toBe( + 'İçerik düzenlendi.' + ) + }) +}) +``` + +This is a runtime-lane test — it requires the Tauri dev runtime, not the M1 mock-lane Vite WebKit. The mock-lane existing tests still cover visual parity. Adding a runtime lane is one of the spec's M5-prerequisite items; M3 starts the lane modestly with two smoke tests. + +If `playwright.config.ts` does not yet have a runtime-lane target, follow the comment in `e2e/playwright.config.ts` that documents the harness — for M3 it is acceptable to gate the suite behind `M3_TEST_VAULT_PATH` so CI does not run it without a vault checkout. + +- [ ] **Step 15.7: Verify all checks** + +```bash +pnpm --filter @memry/desktop-tauri lint +pnpm --filter @memry/desktop-tauri typecheck +pnpm --filter @memry/desktop-tauri test +pnpm --filter @memry/desktop-tauri bindings:check +pnpm --filter @memry/desktop-tauri capability:check +pnpm --filter @memry/desktop-tauri port:audit +pnpm --filter @memry/desktop-tauri command:parity +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +pnpm --filter @memry/desktop-tauri cargo:test +``` + +Expected: every command exits 0. The `command:parity` audit may flag `vault_reindex` and `vault_create` as still-mocked — both are documented deferrals and the audit needs an update in this commit. + +- [ ] **Step 15.8: Update `command:parity` ledger** + +Open `apps/desktop-tauri/scripts/command-parity-audit.ts`. Locate the deferral-classification list and add (or extend) the entries: + +```ts +{ command: 'vault_reindex', status: 'deferred', milestone: 'M7' }, +{ command: 'vault_create', status: 'mocked', milestone: 'M5' } +``` + +Re-run `pnpm --filter @memry/desktop-tauri command:parity` and confirm exit 0. + +- [ ] **Step 15.9: Commit** + +```bash +git add apps/desktop-tauri/src-tauri/src/bin/generate_bindings.rs \ + apps/desktop-tauri/src/generated/bindings.ts \ + apps/desktop-tauri/src/lib/ipc/invoke.ts \ + apps/desktop-tauri/src/lib/ipc/mocks/vault.ts \ + apps/desktop-tauri/src/lib/ipc/mocks/index.ts \ + apps/desktop-tauri/src/services/vault-service.ts \ + apps/desktop-tauri/scripts/command-parity-audit.ts \ + apps/desktop-tauri/e2e/specs/m3-vault-smoke.spec.ts +git commit -m "m3(renderer): swap vault_*/shell_*/dialog_* to real Rust + runtime e2e smoke" +``` + +--- + +## Task 16: Bench, acceptance gate, PR + +**Files:** +- Create: `apps/desktop-tauri/src-tauri/tests/vault_bench.rs` +- Modify: `apps/desktop-tauri/src-tauri/Cargo.toml` (add `[[test]]` entry) +- Read-only verification pass +- Modify: commit history / `git push` + +- [ ] **Step 16.1: Write the 100-note bench** + +Create `apps/desktop-tauri/src-tauri/tests/vault_bench.rs`: + +```rust +use memry_desktop_tauri_lib::vault::{fs as vfs, frontmatter, notes_io, preferences}; +use std::time::Instant; + +#[tokio::test(flavor = "multi_thread")] +async fn open_vault_with_100_notes_under_500ms() { + let vault = tempfile::tempdir().unwrap(); + preferences::init_vault(vault.path()).unwrap(); + for i in 0..100 { + let mut fm = frontmatter::create_frontmatter(&format!("Note {i}"), &["work".into()]); + fm.id = format!("seed-id-{i:03}"); + notes_io::write_note_to_disk( + vault.path(), + &format!("notes/note-{i:03}.md"), + &fm, + "body content goes here for the bench seed.", + ) + .await + .unwrap(); + } + + let start = Instant::now(); + let entries = vfs::list_supported_files(vault.path()).await.unwrap(); + let elapsed = start.elapsed(); + assert!(entries.len() >= 100); + assert!( + elapsed.as_millis() < 500, + "100-note vault scan took {:?}, exceeds 500ms acceptance gate", + elapsed + ); +} +``` + +Register the test in `Cargo.toml`: + +```toml +[[test]] +name = "vault_bench" +required-features = ["test-helpers"] +``` + +- [ ] **Step 16.2: Run the bench** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --release --features test-helpers --test vault_bench -- --nocapture +``` + +Expected: `100-note bench` passes well under 500ms. Local target on Apple silicon: <80ms. If it fails: +- Run release build (`--release` is critical — debug build is 5–10× slower). +- Check that `list_supported_files` is using `tokio::fs::read_dir` and not blocking IO on the executor thread. + +- [ ] **Step 16.3: Final acceptance gate verification** + +```bash +# Rust +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +pnpm --filter @memry/desktop-tauri cargo:test +cd apps/desktop-tauri/src-tauri && cargo test --release --features test-helpers --test vault_bench +cd - + +# TS / lint / type +pnpm --filter @memry/desktop-tauri typecheck +pnpm --filter @memry/desktop-tauri lint +pnpm --filter @memry/desktop-tauri test +pnpm --filter @memry/desktop-tauri bindings:check +pnpm --filter @memry/desktop-tauri capability:check +pnpm --filter @memry/desktop-tauri port:audit +pnpm --filter @memry/desktop-tauri command:parity + +# Cold-start smoke: open scratch vault, list notes, write a note, +# verify watcher fires. +pnpm --filter @memry/desktop-tauri db:reset +pnpm --filter @memry/desktop-tauri dev & +DEV_PID=$! +sleep 8 +# Manually exercise the renderer's vault selector against the scratch +# vault path. Verify in dev console: +# - vault_open returns success: true +# - vault_list_notes returns the seeded files +# - touching a file in Finder fires a vault-changed event +kill $DEV_PID +``` + +Expected: every command exits 0. Cold-start smoke completes without errors. + +- [ ] **Step 16.4: Count Rust tests for the acceptance gate** + +```bash +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | grep 'test result' +``` + +Spec doesn't quote a numeric Rust-test gate for M3, but the M2 bar of ≥20 should hold. Approximate count after this milestone: + +- `vault_paths_test`: 9 +- `vault_fs_test`: 9 +- `vault_frontmatter_test`: 9 +- `vault_notes_io_test`: 5 +- `vault_preferences_test`: 8 +- `vault_registry_test`: 6 +- `vault_watcher_test`: 4 +- `vault_bench`: 1 +- carry-over from M2 (settings + migrations): ~28 + +Total: ~79. Comfortably above the spec's "≥20 smoke tests" bar. + +- [ ] **Step 16.5: Remove the drag-drop spike telemetry import** + +Open `apps/desktop-tauri/src/main.tsx` and delete the `void listen('vault-drag-drop', ...)` block added in Task 14.3. The smoke is documented in `scripts/drag-drop-smoke.md`; the import was a one-shot verification. + +```bash +git add apps/desktop-tauri/src/main.tsx +git commit -m "m3(spike): remove drag-drop spike telemetry; smoke documented in scripts/drag-drop-smoke.md" +``` + +- [ ] **Step 16.6: Push branch and open PR** + +```bash +git push -u origin m3/vault-fs-and-watcher +``` + +Open the PR titled `m3: Vault FS + file watcher` with body: + +```markdown +## Summary + +- Vault FS module: `paths` (canonicalize + traversal/symlink/hidden-dir guard), `fs` (atomic_write + safe_read + list + sha256 hash), `frontmatter` (serde_yaml_ng parse/serialize with Turkish + multiline YAML round-trip), `notes_io` (high-level read/write with content-hash skip), `preferences` (vault-rooted `.memry/config.json`), `registry` (multi-vault list at `/memry-{device}/vaults.json`), `state` (VaultRuntime + status), `watcher` (notify v6 + 150ms debounce, emits `vault-changed`) +- 18 vault Tauri commands: open, close, get_status, get_current, get_all, switch, remove, get_config, update_config, list_notes, read_note, write_note, delete_note, reveal, reindex (M7-deferred stub) + 5 native commands: shell_open_url, shell_open_path, shell_reveal_in_finder, dialog_choose_folder, dialog_choose_files +- `memry-file://` custom URI scheme handler with vault allowlist, byte-range support, 1×1 transparent PNG fallback for missing images, 403 for outside-vault paths +- Drag-drop path-resolution spike: real macOS WebKit drop paths verified (or documented fallback to `dialog_choose_files`) +- 23 commands swapped from mock to real Rust; `vault_reindex` deferred to M7 with explicit ledger entry; `vault_create` flagged for M5 onboarding refactor +- Runtime e2e smoke (`m3-vault-smoke.spec.ts`) covering open/list/read/write + Turkish round-trip +- 100-note vault scan bench: <500ms (acceptance gate); local Apple-silicon dev target <80ms + +Parent spec: `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` §M3 +Plan: `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` + +## Acceptance gate + +- [x] `vault_open` scans 100-note test vault in <500ms (`vault_bench`) +- [x] External file edits emit watcher events to renderer (`vault_watcher_test::detects_new_file_within_debounce_window` + manual smoke) +- [x] Frontmatter Turkish/multiline YAML/date round-trip without loss (`vault_frontmatter_test`) +- [x] Atomic write survives mid-write crash simulation (`vault_fs_test::atomic_write_cleans_up_temp_on_failure`) +- [x] Path-traversal + symlink-escape tests fail closed (`vault_paths_test`) +- [x] Tauri drag-drop manual smoke documented (`scripts/drag-drop-smoke.md`) +- [x] Native choose/reveal/open commands fail closed for paths outside vault/app-data roots (`shell_open_path` validates absolute + exists; protocol returns 403) +- [x] Local file protocol spike: image, PDF, media range, missing-image fallback, outside-vault denial all verified +- [x] Renderer integration smoke: open/create/delete reflects in UI (m3-vault-smoke.spec.ts + manual) +- [x] `cargo test` passes (vault tests + carry-over) — `cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers` exits 0 + +## Carry-forward ledger + +1. `@memry/*` occurrences: (was 142 at M1, post-M2 baseline X — fill in actual) +2. Non-test Electron residue: `pnpm --filter @memry/desktop-tauri port:audit` exits 0 +3. M1 non-blocking warnings: still present (notes-tree setState, Radix dialog, ::highlight CSS, large chunk) — none touched by M3 domain +4. Runtime e2e lane: harness exists; 1 spec file (`m3-vault-smoke.spec.ts`) covers the M3 surface; lane gated behind `M3_TEST_VAULT_PATH` env var + +## Test plan + +- [ ] `pnpm --filter @memry/desktop-tauri cargo:test` green +- [ ] `pnpm --filter @memry/desktop-tauri cargo:test --release --test vault_bench` p95 < 500ms +- [ ] `pnpm --filter @memry/desktop-tauri bindings:check` clean +- [ ] `pnpm --filter @memry/desktop-tauri capability:check` exit 0 +- [ ] `pnpm --filter @memry/desktop-tauri command:parity` exit 0 with `vault_reindex`/`vault_create` classified +- [ ] `pnpm --filter @memry/desktop-tauri port:audit` exit 0 +- [ ] Manual: open scratch vault, list notes, write+read a note with Turkish chars, touch a file in Finder, see watcher event in dev console +- [ ] Manual: drag image/PDF/video into window, see real paths in console +- [ ] Manual: shell_open_url opens a browser, shell_reveal_in_finder reveals a path, dialog_choose_folder + dialog_choose_files show pickers +- [ ] Manual: memry-file:// URL renders an image, returns 403 for `/etc/hosts`, returns 1×1 PNG for missing image + +## Risk coverage + +- Risk: macOS FSEvents rename detection edge cases. Mitigation: notify v6 fallback + per-path debounce. The watcher classifies rename as `Modified`, which is the safe upper bound. +- Risk: serde_yaml deprecation. Mitigation: chose `serde_yaml_ng` (active fork) per ecosystem consensus. +- Risk: vault path canonicalization differences (`/private/var/...` vs `/var/...` on macOS). Mitigation: `dunce::canonicalize` everywhere. +- Risk: drag-drop path resolution might fall back to `webkit-fake-url://`. Mitigation: documented fallback uses `dialog_choose_files` already implemented. +``` + +- [ ] **Step 16.7: Land the PR** + +Use `/land-and-deploy` (gstack skill) or manual merge after CI green. Squash-merge per repo convention. + +--- + +## Self-review checklist (plan author) + +- [x] Vault FS module — `fs.rs` atomic_write + safe_read + list + content_hash (Task 4) +- [x] `frontmatter.rs` — serde_yaml_ng parse/serialize with property extraction (Task 5) +- [x] `notes_io.rs` — high-level `read_note_from_disk` / `write_note_to_disk` (Task 6) +- [x] `preferences.rs` — vault-rooted JSON config (Task 7) +- [x] `watcher.rs` — notify with 150ms debounce + `vault-changed` event (Task 10) +- [x] Path normalization + escape guard — `paths.rs` (Task 3) +- [x] Drag-drop path-resolution spike + documented fallback (Task 14) +- [x] Native open/reveal/dialog command set — `shell.rs` + `dialog.rs` (Task 12) +- [x] `memry-file://` protocol — Tauri custom URI scheme with allowlist + range + missing-image fallback (Task 13) +- [x] All commands from spec deliverable list registered: `vault_open`, `vault_close`, `vault_get_current`, `vault_list_notes`, `vault_read_note`, `vault_write_note` (Task 11) +- [x] All renderer-expected commands registered: `vault_get_status`, `vault_get_config`, `vault_update_config`, `vault_get_all`, `vault_switch`, `vault_remove`, `vault_reveal`, `vault_reindex` (Task 11) +- [x] `vault-changed` event with `{path, kind}` payload — `VaultEvent` (Tasks 10, 11) +- [x] 100-note vault scan <500ms bench (Task 16) +- [x] Watcher events external-edit smoke (Task 10 test + Task 16 manual) +- [x] Frontmatter Turkish/multiline YAML/date round-trip (Task 5) +- [x] Atomic write crash simulation (Task 4) +- [x] Path traversal + symlink escape tests (Task 3) +- [x] Renderer integration smoke (Tasks 15, 16) +- [x] `cargo test --package vault` equivalent — single-crate project means `cargo test` runs every vault test plus carry-over (Task 16.4) + +### Intentional deferrals + +| Item | Deferred to | Rationale | +|------|-------------|-----------| +| `vault_reindex` real impl | M7 | Index DB (FTS5 + sqlite-vec) does not exist until M7. M3 ships a stub that returns `{ deferredUntil: 'M7' }`. | +| `vault_create` real impl | M5 | The renderer onboarding flow uses `dialog_choose_folder` + `vault_open` already in M3. The legacy `vault_create` mock survives because the existing onboarding component still references it; M5 removes it during the notes-CRUD refactor. | +| Note-rename detection | M5 | The Electron watcher does delete-add-pair UUID matching for renames. M3 ships only Created/Modified/Deleted; rename detection lives with note CRUD in M5. | +| FTS / properties / tag definitions sync | M7 | Watcher emits the event; cache rebuild lives in M7. | +| PDF/thumbnail rendering | M8.13–M8.14 | Protocol carries the bytes; PDF/thumbnail UIs live with the broader media features. | + +### Open questions for Kaan + +1. The Rust DB stays at `/memry-{device}/data.db` (M2 decision). Electron stored DB at `/.memry/data.db`. Confirm the vault directory does NOT need a per-vault DB (matches M2 architecture). Default assumption: confirmed. +2. Watcher rename detection deferred to M5 (note CRUD owns it). Confirm. +3. `vault_create` mock kept for the onboarding component until M5 (because that's where the flow gets its proper Rust replacement). Confirm. + +--- + +## Post-M3 + +After M3 merges: + +1. Begin M4 (Crypto + Keychain + Auth) per spec §4 M4. Start with the 1-day security-framework subspike before opening the implementation plan. +2. M5 (Notes CRUD + BlockNote + CRDT) blocks on M3 + M4 both being merged. Plan that worktree off `main` after both PRs land. +3. The runtime e2e lane added in Task 15.6 becomes the default lane for M5 acceptance — extend it with note-rename + concurrent-edit specs at that time. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09a902a26..cdff9d62a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -973,6 +973,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/node': specifier: ^25.2.2 version: 25.2.2 @@ -994,6 +997,9 @@ importers: autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.8) + better-sqlite3: + specifier: ^12.6.2 + version: 12.6.2 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) diff --git a/prompts/m3/README.md b/prompts/m3/README.md new file mode 100644 index 000000000..fb47766b3 --- /dev/null +++ b/prompts/m3/README.md @@ -0,0 +1,125 @@ +# M3 — Vault FS + File Watcher / Phase Prompts + +Her phase için ayrı prompt. Temiz session'da tek tek çalıştır. Prior phase'ler tamamlanmadan sonrakine geçme. + +## Worktree + +M3 ayrı worktree'de yürütülür (user preference — feedback_worktree.md). M2 PR `main`'e merge edildikten sonra: + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m2 +git worktree add ../spike-tauri-m3 -b m3/vault-fs-and-watcher main +cd ../spike-tauri-m3 +mkdir -p ~/memry-test-vault-m3/notes ~/memry-test-vault-m3/journal ~/memry-test-vault-m3/attachments +``` + +Tüm phase'ler `../spike-tauri-m3` altında çalışır. Prompt'lar bu yolu default olarak kullanır. + +## Çalıştırma sırası + +| Phase | Prompt | Görev | Plan Task | Durum | +|-------|--------|-------|-----------|-------| +| A | `m3-phase-a-foundation.md` | Cargo deps + AppError vault variants + `vault/` module skeleton + `paths.rs` (TDD) | 1-3 | ⏳ | +| B | `m3-phase-b-vault-core-io.md` | `fs.rs` atomic write + safe read + list, `frontmatter.rs` parse/serialize, `notes_io.rs` round-trip (full TDD) | 4-6 | ⏳ | +| C | `m3-phase-c-preferences-registry.md` | `preferences.rs` per-vault JSON + `registry.rs` multi-vault list (TDD) | 7-8 | ⏳ | +| D | `m3-phase-d-watcher-runtime-state.md` | `watcher.rs` notify+debounce, `state.rs` VaultRuntime, `AppState` extension (TDD) | 10, 9 | ⏳ | +| E | `m3-phase-e-tauri-commands.md` | `commands/vault.rs` (13 commands) + `commands/shell.rs` + `commands/dialog.rs` + capabilities | 11-12 | ⏳ | +| F | `m3-phase-f-protocol-and-dragdrop.md` | `memry-file://` URI scheme handler + drag-drop path-resolution spike | 13-14 | ⏳ | +| G | `m3-phase-g-bindings-renderer-bench-pr.md` | Specta bindings regen, mock-swap, runtime e2e smoke, 100-note bench, acceptance gate, PR | 15-16 | ⏳ | + +## TDD metodoloji (zorunlu her phase için) + +**Her implementasyon adımı RED-GREEN-REFACTOR disiplinine bağlı:** + +1. **RED:** Önce failing test yaz (cargo test veya vitest — duruma göre). +2. Test koş → **FAIL** beklenir. Fail mesajı mantıklı mı doğrula. +3. **GREEN:** Minimum kod ile test'i geç. +4. Test koş → **PASS** doğrula. +5. **REFACTOR:** Duplication varsa temizle, test hâlâ PASS. +6. Commit. + +**TDD-appropriate phase'ler (zorunlu):** +- **Phase A** (Task 3 paths.rs) — RED-GREEN, 8 path-safety testleri +- **Phase B** (Tasks 4-6 fs/frontmatter/notes_io) — TAM TDD; 9+9+5 = 23 test +- **Phase C** (Tasks 7-8 preferences/registry) — TAM TDD; 8+6 = 14 test +- **Phase D** (Tasks 10 watcher) — TDD; 4 watcher test (multi_thread) +- **Phase G** (Task 16 bench) — bench-as-test, RED ile başla + +**TDD uygulanması mekanik olan phase'ler (komut/wiring scaffold):** +- Phase E (commands), Phase F (protocol + spike) +- Bu phase'lerde test yerine **verification-before-completion disiplini**: + - Her komut sonrası `cargo check` + `cargo clippy -- -D warnings` + - Her dosya değişikliğinde `cargo test --features test-helpers` (carry-over kırılmasın) + - Manuel smoke (e.g. `pnpm dev` + Finder drop) bu phase'lerde zorunlu + - Phase sonunda full check matrix (lint/typecheck/test/cargo:test/bindings:check) + +## Genel kurallar (her prompt bunu inherit eder) + +1. **Worktree kökü:** `/Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3` +2. **Branch:** `m3/vault-fs-and-watcher` +3. **Asla dokunma:** `apps/desktop/**`, `apps/sync-server/**`, `packages/**`, `docs/superpowers/specs/**`, `docs/superpowers/plans/**`, `docs/spikes/**` (okuma OK, yazma hayır). M2'de kalan mock'lar yerinde kalır — M3 sadece Phase G'de `vault_*`, `shell_*`, `dialog_*` komutlarını gerçek invoke'a çevirir; `vault_reindex` ve `vault_create` mock olarak kalır (M7/M5 deferred). +4. **Kod standardı (Rust):** `rustfmt` default, `clippy -- -D warnings` zorunlu. `unwrap()`/`expect()` command path'inde YASAK; `state.rs` mutex poison handling `unwrap_or_else(|p| p.into_inner())` pattern kullanır. Vault `fs::*` çağrıları HER ZAMAN `paths::resolve_*` ile başlar — bypass yok. +5. **Kod standardı (TS):** Prettier (single quotes, no semi, 100 char, no trailing comma), ESLint flat config, named exports, strict TS. `console.log` yasak — sadece e2e spec'lerinde `console.info` for spike telemetry tolere edilir (Phase F'de import edilir, Phase G'de kaldırılır). +6. **Rust error handling:** Her command `Result` döner. `AppError` Phase A'da Vault/PathEscape/Io variant'ları ile genişletilir. `From` → `AppError::Io` (M2'deki `Internal` mapping'i değiştirilir). `From` ve `From` Phase A'da eklenir. +7. **Specta hygiene:** Her vault struct'ta `#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]` + `#[serde(rename_all = "camelCase")]`. Phase G'deki bindings generator bu canonical isimleri bekler. +8. **Path discipline:** `tokio::fs` veya `std::fs` çağrıları SADECE `vault/fs.rs`, `vault/preferences.rs`, ve `vault/registry.rs` içinde. Diğer modüller bu modülleri çağırır — escape guard bypass'lanmaz. +9. **Commit message format:** `m3(): ` — scope plan'dan alınır (deps, vault, commands, protocol, spike, renderer, bench, devx). +10. **PR stratejisi:** Tüm phase'ler aynı branch'e commit. M3 bittiğinde Phase G sonunda tek PR açılır. +11. **Plan dosyası:** `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` — phase prompt'ları bu dosyaya referans verir, içerik yinelenmez. +12. **Spec dosyası:** `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` — mimari kararlar için otorite (M3 bölümü §4, cross-cutting §5). +13. **Predecessor:** M2 PR (`docs/superpowers/plans/2026-04-25-m2-db-schemas-migrations.md`) `main`'e merge edilmiş olmalı. Phase A pre-flight bunu doğrular. + +## Phase handoff + +Her phase sonunda şu komutları çalıştır: + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline -10 +git status --short +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -10 && cd - +pnpm --filter @memry/desktop-tauri typecheck +``` + +Ve kullanıcıya şunu rapor et: + +``` +Phase complete. +Tasks covered: +Commits: (..) +Rust tests: (delta vs prior phase: +N) +Next: Phase +Blockers: +``` + +## Emergency stop + +Phase içinde bir task patlak verirse: + +1. Durdur, sorunu özetle. +2. Root cause'u `superpowers:systematic-debugging` skill ile analiz et. +3. Plan'da yazan trip-wire'lardan biri tetiklendi mi kontrol et: + - `serde_yaml_ng` parse hatası → orijinal Electron `gray-matter` output'unu byte-byte karşılaştır. + - notify v6 platform fark → `macos_fsevents` feature aktif mi? + - Path canonical mismatch (`/private/var/...` vs `/var/...`) → her yerde `dunce::canonicalize` kullanıldı mı? + - Watcher debounce flake → test `multi_thread` flavor + 100ms warm-up sleep var mı? +4. Symlink loop / DOS test'inde hang → `list_supported_files` symlink skip ediyor mu? +5. Kullanıcıya rapor et ve onay bekle. + +Plan dışına çıkma. "Bu fikre düştüm, şunu da ekleyeyim" yapma. Scope creep = budget creep. Özellikle: + +- Yeni vault command ekleme (spec onayı olmadan; M3 surface = 13 vault + 3 shell + 2 dialog) +- M3'te rename detection eklemek (Plan defers to M5) +- M3'te FTS / embedding rebuild eklemek (Plan defers to M7 — `vault_reindex` stub returns `{ deferredUntil: 'M7' }`) +- M3'te `vault_create` real impl (Plan defers to M5 onboarding refactor) +- Yeni dep ekleme (Phase A'nın allow-listesi dışında: notify, serde_yaml_ng, mime_guess, sha2, dunce, tauri-plugin-dialog, nanoid, anyhow only) +- Pool / cache / async-watcher refactoru — M3 minimal Tokio mpsc + `notify::Watcher` + `parking_lot`/`std::sync::Mutex` + +## Kritik referanslar + +- **Plan self-review tablosu**: `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` sonunda "Intentional deferrals" tablosu var. `vault_reindex` (M7), `vault_create` (M5), note-rename detection (M5), FTS rebuild (M7), PDF/thumbnail UI (M8.13–14) M3'te **yazılmaz**. +- **MEMORY.md gotchas**: `~/.claude/projects/.../memory/MEMORY.md` — özellikle "Pre-existing type errors in test files" ve "Migrations are hand-written since 0020" notları M3 sırasında devreye girer. +- **Pre-production app**: No backward-compat needed. Phase F'de SHA-256 content_hash kullanır (Electron'un djb2 hash'i pre-production free swap). +- **Spec §5.7 Security**: `capabilities/default.json` düz grant — Phase E `tauri-plugin-dialog` ekler, `tauri-plugin-shell` zaten M2'den var. Phase F CSP'ye sadece `memry-file:` ekler — third-party host genişletilmez. diff --git a/prompts/m3/m3-phase-a-foundation.md b/prompts/m3/m3-phase-a-foundation.md new file mode 100644 index 000000000..6109b894f --- /dev/null +++ b/prompts/m3/m3-phase-a-foundation.md @@ -0,0 +1,186 @@ +# M3 Phase A — Foundation (Deps + AppError + Module Skeleton + Paths, TDD) + +Temiz session prompt. **Bu phase Task 3 için TDD gerektirir** — `paths.rs` baştan sona RED-GREEN. + +--- + +## PROMPT START + +You are implementing **Phase A of Milestone M3** for Memry's Electron→Tauri migration. This phase lands the vault FS foundation: new Cargo deps, extended `AppError` variants for filesystem context, the `vault/` module skeleton (8 stubbed submodules), and the first real implementation — `vault/paths.rs` with canonicalize + escape guard. + +### Context + +**Worktree:** `/Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3` +**Branch:** `m3/vault-fs-and-watcher` (must already exist — see README worktree setup) +**Parent spec:** `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` (M3 section §4, cross-cutting §5) +**Implementation plan:** `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` +**Prompts README:** `prompts/m3/README.md` + +Memry: desktop notes app, Electron→Tauri migration, pre-production, no backward compat. M1 landed the Tauri skeleton with mock IPC; M2 wired SQLite + the settings IPC slice. M3 lands the vault file tree — `.md` IO, YAML frontmatter, multi-vault registry, `notify` watcher, the full vault/shell/dialog command surface, and the `memry-file://` URI scheme. This phase opens the milestone with deps + `paths.rs`. + +### Prerequisite verification + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git rev-parse --abbrev-ref HEAD # expect: m3/vault-fs-and-watcher +git log --oneline main | head -10 # expect M2 commits ending with `m2(devx): add MEMRY_DEVICE=A/B dev scripts` +test -d apps/desktop-tauri/src-tauri/src +test -f apps/desktop-tauri/src-tauri/Cargo.toml +test -f apps/desktop-tauri/src-tauri/src/error.rs # M2 created this +test -f apps/desktop-tauri/src-tauri/src/app_state.rs # M2 created this +pnpm --filter @memry/desktop-tauri cargo:check # must exit 0 on M2 baseline +pnpm --filter @memry/desktop-tauri cargo:clippy # must exit 0 +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 && cd - +# expect: test result ok, all M2 carry-over tests passing (~28) +pnpm --filter @memry/desktop-tauri bindings:check # must exit 0 +test -d ~/memry-test-vault-m3 # scratch vault for later phases (created in README setup) +``` + +If any fails, STOP and report. Do not improvise the worktree, branch, or M2 baseline. + +### Your scope + +Execute **Tasks 1, 2, 3** from the plan: + +- **Task 1:** Append M3 deps to `[dependencies]` in `apps/desktop-tauri/src-tauri/Cargo.toml`: + - `notify = { version = "6.1", default-features = false, features = ["macos_fsevents"] }` + - `serde_yaml_ng = "0.10"` + - `mime_guess = "2.0"` + - `sha2 = "0.10"` + - `dunce = "1.0"` + - `tauri-plugin-dialog = "2"` + +- **Task 2:** Extend `AppError` enum + scaffold `vault/` module: + - Add `Vault(String)`, `PathEscape(String)`, `Io(String)` variants to `AppError` in `src/error.rs`. + - REPLACE existing `From` impl: it currently maps to `Internal`; remap to `AppError::Io` (filesystem context surfaces IO errors everywhere — vault layer needs them typed). + - Add `From` → `AppError::Validation(format!("yaml: {err}"))`. + - Add `From` → `AppError::Vault(format!("watcher: {err}"))`. + - Create `src/vault/mod.rs` with 8 `pub mod` declarations (paths, fs, frontmatter, notes_io, preferences, registry, state, watcher) + commented-out re-exports (re-enabled in Phase D Task 9). + - Stub each submodule with a one-line comment so the tree compiles between tasks. + - Wire `pub mod vault;` into `src/lib.rs` near the existing `pub mod` declarations. + +- **Task 3:** Implement `vault/paths.rs` (canonicalize + traversal/symlink/hidden-dir guard) full TDD: + - Write failing test file `tests/vault_paths_test.rs` with **9 tests** (rejects_dotdot_escape, rejects_absolute_outside_vault, allows_normal_relative_path, rejects_symlink_escape, rejects_hidden_dot_memry_directory, rejects_unsupported_extension, allows_supported_extensions, to_relative_path_normalizes_separators, to_relative_path_rejects_outside_vault). + - Register `[[test]] name = "vault_paths_test" required-features = ["test-helpers"]` in `Cargo.toml`. + - Run RED → unresolved imports. + - Implement `paths.rs` per plan Step 3.4 (uses `dunce::canonicalize`, supports `resolve_in_vault`, `resolve_supported`, `to_relative_path`, `is_markdown`, `normalize_relative`). + - Run GREEN → 9 passed. + +### Methodology — TDD mandatory for Task 3 + +1. **Invoke `superpowers:using-superpowers` and `superpowers:test-driven-development`** first. +2. **Task 1 (deps):** Not TDD — `cargo check` is the verification. Add deps + run check + commit. +3. **Task 2 (errors + skeleton):** Not TDD in the RED-GREEN sense. Verify with `cargo check && cargo clippy -- -D warnings` after each substep. The stubbed submodules must compile — `cargo check` will warn about unused modules, that is OK. +4. **Task 3 (paths.rs):** + - Plan Step 3.1 lists every test verbatim. Copy the test file as written; do NOT skip the symlink test (it uses `std::os::unix::fs::symlink` and must run on macOS). + - Step 3.2: register the `[[test]]` block. + - Step 3.3: RED → `cargo test --features test-helpers --test vault_paths_test` must fail with unresolved imports for `paths`, `resolve_in_vault`, `resolve_supported`, `to_relative_path`. Confirm before moving to Step 3.4. + - Step 3.4: implement `paths.rs` per plan verbatim. + - Step 3.5: re-run → 9 passed. + - Step 3.6: commit per plan verbatim message: `m3(vault): paths.rs canonicalize + traversal/symlink/hidden-dir guard`. + +### Critical gotchas + +1. **`From` REPLACE, not ADD:** M2's `error.rs` has an `impl From for AppError` returning `Internal`. Vault FS surfaces IO errors all over — Phase A maps them to `Io`. The plan uses the word "replace" deliberately. Adding a second impl will fail to compile (duplicate `From`). Edit, don't append. +2. **Submodule stubs are required:** `cargo check` after Step 2.4 expects 8 files at `src/vault/{paths,fs,frontmatter,notes_io,preferences,registry,state,watcher}.rs` each with a comment line. If any is missing, `cargo check` errors with `error[E0583]: file not found for module`. The bash `for name in ...; do echo "//! Stubbed in Task 2; populated in later tasks." > apps/desktop-tauri/src-tauri/src/vault/$name.rs; done` loop in Step 2.4 is the canonical setup. +3. **Comment out re-exports until Phase D:** `mod.rs` declares `pub mod fs; pub mod frontmatter; ...` but the `pub use frontmatter::{NoteFrontmatter, ParsedNote}; ...` block must be commented. Phase D (Task 9) re-enables them once the types exist. Leaving them uncommented in Phase A breaks `cargo check` because the symbols don't exist yet. +4. **`dunce::canonicalize` on macOS:** macOS resolves symlinks differently — `/var` vs `/private/var`. The plan uses `dunce::canonicalize` everywhere, including for the vault root + the joined candidate path, so `starts_with` comparisons are byte-stable. Don't substitute `std::fs::canonicalize` even if it "looks fine" locally — symlink test will flake on different macOS major versions. +5. **`resolve_in_vault` must handle non-existing leaf:** `atomic_write` (Phase B) calls `resolve_in_vault` for paths that don't exist yet. The plan handles this in Step 3.4: if `joined.exists()` then canonicalize the full path, else canonicalize the parent and re-attach the leaf. This is critical for the "write a new file" path-safety case. +6. **Hidden-dir check uses `.memry`, not `.git`:** The escape guard rejects `.memry/data.db` because that is the Electron-era reserved app folder. `.git` and other generic hidden dirs are skipped at *list* time (`fs.rs::list_supported_files`, Phase B), not rejected by `resolve_in_vault`. +7. **Symlink test is unix-specific:** `tests/vault_paths_test.rs` uses `std::os::unix::fs as unix_fs;` and `unix_fs::symlink(...)`. Plan assumes macOS dev. If cargo test runs on a non-unix host the test won't compile — guard with `#[cfg(unix)]` only if tests are run cross-platform; M3 dev is macOS-only so the unconditional import is fine. +8. **`SUPPORTED_EXT` allowlist:** `md, markdown, png, jpg, jpeg, gif, webp, svg, pdf, mp3, wav, m4a, ogg, mp4, mov, webm`. Match exactly. The plan's Step 3.4 lists these; the test `allows_supported_extensions` enumerates 11 of them — ensure both lists agree. + +### Constraints + +- **No scope creep:** Do not implement `fs.rs`, `frontmatter.rs`, or any other vault submodule in Phase A. Stubs only. Phase B-D handle them. +- **No additional crates beyond Phase A list:** notify, serde_yaml_ng, mime_guess, sha2, dunce, tauri-plugin-dialog. `nanoid` and `anyhow` come in Phases B and F respectively. Do NOT pre-add them. +- **No new error variants beyond the 3 listed:** Vault, PathEscape, Io. Do not invent `Permission`, `Locked`, `Encoding`, etc. — Phase B's `notes_io.rs` and Phase D's `state.rs` reuse existing variants (`NotFound`, `Validation`). +- **`AppError` ordering:** Insert new variants AFTER `Validation(String)` per plan Step 2.1. Don't re-sort the enum — that would break specta-generated TS bindings ordering and force a regen with no value. +- **Rust style:** `rustfmt` before commit. `cargo clippy -- -D warnings` must be clean at Task 1, 2, 3 boundaries. The unused-module warnings after Task 2.4 are acceptable (they go away after Task 3 implements `paths.rs`). + +### Acceptance criteria (Phase A done when all pass) + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 + +# Files exist +test -f apps/desktop-tauri/src-tauri/src/vault/mod.rs +for name in paths fs frontmatter notes_io preferences registry state watcher; do + test -f apps/desktop-tauri/src-tauri/src/vault/$name.rs || { echo "MISSING vault/$name.rs"; exit 1; } +done +test -f apps/desktop-tauri/src-tauri/tests/vault_paths_test.rs + +# Deps present +grep -q '^notify' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q '^serde_yaml_ng' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q '^mime_guess' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q '^sha2' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q '^dunce' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q '^tauri-plugin-dialog' apps/desktop-tauri/src-tauri/Cargo.toml + +# AppError variants +grep -q 'Vault(String)' apps/desktop-tauri/src-tauri/src/error.rs +grep -q 'PathEscape(String)' apps/desktop-tauri/src-tauri/src/error.rs +grep -q 'Io(String)' apps/desktop-tauri/src-tauri/src/error.rs +grep -q 'From' apps/desktop-tauri/src-tauri/src/error.rs +grep -q 'From' apps/desktop-tauri/src-tauri/src/error.rs + +# vault module wired +grep -q 'pub mod vault' apps/desktop-tauri/src-tauri/src/lib.rs + +# Re-exports still commented (Phase D enables them) +grep -E '^//.*pub use frontmatter' apps/desktop-tauri/src-tauri/src/vault/mod.rs + +# Test registered +grep -q 'name = "vault_paths_test"' apps/desktop-tauri/src-tauri/Cargo.toml + +# Rust compiles + clippy clean +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy + +# Tests pass — paths_test green + M2 carry-over still green +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers --test vault_paths_test 2>&1 | tail -3 +# Expect: test result: ok. 9 passed +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# Expect: total ~37 passed (9 new + ~28 carry-over). 0 failed. + +# Commits +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\((deps|vault)\)' # expect ≥ 3 (1 deps + 2 vault) + +# Electron / packages untouched +git diff --name-only main..HEAD -- apps/desktop/ apps/sync-server/ packages/ docs/superpowers/specs/ docs/superpowers/plans/ | wc -l +# expect 0 +``` + +### When done + +Report to user: + +``` +Phase A complete. +Tasks covered: 1, 2, 3 +Commits: (..) +Rust tests: 9 new (vault_paths_test) + ~28 M2 carry-over = ~37 passed +Verification: + - cargo check: clean + - cargo clippy -- -D warnings: clean + - cargo test --features test-helpers: ~37 passed, 0 failed + - Electron/packages/specs/plans untouched: 0 files + +Next: Phase B — prompts/m3/m3-phase-b-vault-core-io.md +Blockers: +``` + +If blocker: do not guess. Invoke `superpowers:systematic-debugging`. Check plan §"Risk coverage" + README §"Emergency stop" trip-wires. Report + wait for approval. + +### Ready + +1. Invoke `superpowers:using-superpowers` and `superpowers:test-driven-development`. +2. Read plan Tasks 1, 2, 3 fully (lines 110–597 of the plan file). +3. Run prerequisite verification. Report results. +4. Task 1: append deps → `cargo check` → commit `m3(deps): add notify, serde_yaml_ng, mime_guess, sha2, dunce, dialog plugin`. +5. Task 2: extend `AppError` + create `vault/mod.rs` + 8 stubs + wire into `lib.rs` → `cargo check` → commit `m3(vault): scaffold module + extend AppError with Vault/PathEscape/Io`. +6. Task 3: full RED-GREEN — failing test → impl `paths.rs` → 9 passed → commit `m3(vault): paths.rs canonicalize + traversal/symlink/hidden-dir guard`. + +## PROMPT END diff --git a/prompts/m3/m3-phase-b-vault-core-io.md b/prompts/m3/m3-phase-b-vault-core-io.md new file mode 100644 index 000000000..f4e2bcb61 --- /dev/null +++ b/prompts/m3/m3-phase-b-vault-core-io.md @@ -0,0 +1,207 @@ +# M3 Phase B — Vault Core IO (fs.rs + frontmatter.rs + notes_io.rs, full TDD) + +Temiz session prompt. **Bu phase TAM TDD** — her modül RED-GREEN-REFACTOR. 23 test toplam. + +--- + +## PROMPT START + +You are implementing **Phase B of Milestone M3** for Memry's Electron→Tauri migration. This phase fleshes out the three core IO modules of the vault layer: atomic filesystem ops + SHA-256 hash, YAML frontmatter parse/serialize with Turkish + multiline-YAML round-trip, and high-level note IO with content-hash skip. + +### Context + +**Worktree:** `/Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3` +**Branch:** `m3/vault-fs-and-watcher` +**Plan:** `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` +**Spec:** `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` (§4 M3, §5 cross-cutting) +**Prompts README:** `prompts/m3/README.md` + +Phase A landed deps + `AppError` extensions + `vault/` module skeleton + `paths.rs` (9 tests). Phase B implements the three modules that build on `paths.rs`: `fs.rs`, `frontmatter.rs`, `notes_io.rs`. + +### Prerequisite verification + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git rev-parse --abbrev-ref HEAD # expect: m3/vault-fs-and-watcher + +# Phase A complete +test -f apps/desktop-tauri/src-tauri/src/vault/paths.rs +test -f apps/desktop-tauri/src-tauri/tests/vault_paths_test.rs +grep -q 'Vault(String)' apps/desktop-tauri/src-tauri/src/error.rs +grep -q 'PathEscape(String)' apps/desktop-tauri/src-tauri/src/error.rs +grep -q 'Io(String)' apps/desktop-tauri/src-tauri/src/error.rs +grep -q '^notify' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q '^serde_yaml_ng' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q '^sha2' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q '^dunce' apps/desktop-tauri/src-tauri/Cargo.toml + +# All Phase A + M2 tests still green +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~37 passed, 0 failed (28 M2 carry-over + 9 paths_test) + +# Phase A commits present +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\((deps|vault)\)' +# expect: ≥ 3 (1 deps + 2 vault commits) +``` + +If any fails, STOP. Phase A must complete before Phase B starts. + +### Your scope + +Execute **Tasks 4, 5, 6** from the plan in this order. Each is a full RED-GREEN cycle. + +- **Task 4 — `vault/fs.rs`** (atomic write + safe read + list + content_hash): + - Add `nanoid = "0.4"` to `[dependencies]` (used by `atomic_write` for temp-file suffix). + - Write 9 failing tests in `tests/vault_fs_test.rs`: + - `atomic_write_creates_file`, `atomic_write_replaces_existing_file`, `atomic_write_creates_parent_dirs`, `atomic_write_cleans_up_temp_on_failure` (rename-fails-leaves-no-temp), `safe_read_returns_none_for_missing`, `safe_read_returns_content_for_existing`, `list_supported_files_skips_hidden_and_unsupported`, `content_hash_is_stable_for_same_content`, `content_hash_differs_for_different_content`. + - Register `[[test]] name = "vault_fs_test" required-features = ["test-helpers"]`. + - Implement `fs.rs` per plan Step 4.4: `atomic_write` (temp-then-rename + cleanup on fail), `safe_read` (`Ok(None)` for missing), `read_required`, `delete_file`, `list_supported_files` (DFS, skip dotfiles + `.memry/` + symlinks + unsupported extensions), `content_hash` (SHA-256 hex 64 chars), `is_supported_attachment`. + +- **Task 5 — `vault/frontmatter.rs`** (parse / serialize / property extraction): + - Write 9 failing tests in `tests/vault_frontmatter_test.rs`: + - `parses_minimal_frontmatter`, `auto_generates_missing_required_fields`, `extracts_title_from_filename_when_missing`, `turkish_chars_roundtrip_byte_identical`, `multiline_yaml_string_roundtrip`, `date_field_preserved_as_string`, `tags_normalized_to_vec_strings`, `preserves_non_reserved_properties_through_roundtrip`, `extract_properties_skips_reserved_keys`. + - Register `[[test]] name = "vault_frontmatter_test" required-features = ["test-helpers"]`. + - Implement `frontmatter.rs` per plan Step 5.4: `NoteFrontmatter` struct (id/title/created/modified/tags/aliases/emoji/local_only/properties + `#[serde(flatten)] extra`), `ParsedNote` struct, `parse_note(raw, file_path)`, `serialize_note(fm, content)`, `create_frontmatter(title, tags)`, `extract_properties` method, `RESERVED_KEYS` const = `["id","title","created","modified","tags","aliases","emoji","localOnly","properties"]`. + +- **Task 6 — `vault/notes_io.rs`** (high-level read/write): + - Write 5 failing tests in `tests/vault_notes_io_test.rs`: + - `write_then_read_roundtrip`, `read_returns_none_for_missing_path`, `read_auto_repairs_missing_frontmatter_and_writes_back`, `write_skips_no_op_when_hash_matches`, `read_rejects_path_traversal`. + - Register `[[test]] name = "vault_notes_io_test" required-features = ["test-helpers"]`. + - Implement `notes_io.rs` per plan Step 6.4: `NoteOnDisk` struct (relative_path/content_hash), `ReadNoteResult` struct (parsed/abs_path/content_hash), `read_note_from_disk(vault_root, rel)`, `write_note_to_disk(vault_root, rel, fm, content)`, `delete_note_from_disk(vault_root, rel)`. Auto-repair logic: if `parsed.was_modified`, write the canonical form back to disk. No-op skip: compute hash of new content, compare to disk hash; if equal, skip the rename. + +### Methodology — TDD mandatory for all 3 tasks + +1. **Invoke `superpowers:using-superpowers` and `superpowers:test-driven-development`** first. +2. **Three full RED-GREEN cycles. One commit per task.** +3. For each task: + - Step X.1: write the test file verbatim from the plan (lines listed below). + - Step X.2: register the `[[test]]` block in `Cargo.toml`. + - Step X.3: run `cargo test --features test-helpers --test ` → must FAIL with "unresolved import" / "no function named ...". Confirm RED. + - Step X.4: implement the module per plan verbatim. + - Step X.5 (Task 4 only): add the `nanoid` dep — do this BEFORE `cargo check` post-impl, otherwise the macro fails to resolve. + - Step X.6: re-run test → all PASS. + - Step X.7: `cargo test --features test-helpers` (no `--test` filter) → no regression in carry-over. + - Step X.8: commit per plan verbatim message. + +**Test source line ranges in plan (read them VERBATIM, do not paraphrase):** +- Task 4 test file: plan lines ~609–716 +- Task 4 impl file: plan lines ~738–900 +- Task 5 test file: plan lines ~941–1032 +- Task 5 impl file: plan lines ~1054–1300+ (continues into Task 5 detail block) +- Task 6 test file: plan lines ~1452–1539 +- Task 6 impl file: plan lines ~1550–1690 (read continues from line 1690 of plan) + +### Critical gotchas + +1. **`nanoid` dep order:** `atomic_write` uses `nanoid::nanoid!(12)` at compile time. Add `nanoid = "0.4"` to `Cargo.toml` BEFORE running `cargo check` on the new `fs.rs` — otherwise you'll see `error[E0433]: failed to resolve: use of undeclared crate`. Plan Step 4.5 covers this; don't skip. +2. **Atomic write cleanup branch is critical:** Plan Step 4.4's `atomic_write` uses an `async` block + `match` — if `Err`, unconditionally `let _ = fs::remove_file(&temp_path).await;` then return the error. The test `atomic_write_cleans_up_temp_on_failure` proves no `.tmp.` files leak when the rename fails (target is a directory). Do not factor this into a `?`-chain — the cleanup must run on the failure branch regardless of which step failed. +3. **`safe_read` distinguishes NotFound from other IO errors:** `Ok(None)` for `ErrorKind::NotFound`, else `Err(AppError::Io(...))` via `From` (Phase A remapped this to `Io`, not `Internal`). Plan Step 4.4 handles this in 3 branches; reproduce exactly. +4. **`list_supported_files` must skip symlinks:** Even though `paths::resolve_in_vault` rejects symlink targets, the *list walk* skips symlinks entirely so a symlink loop can't DOS the scan. Use `metadata.file_type().is_symlink()` BEFORE recursing or pushing to output. Plan Step 4.4 guards this; the tests don't exercise it directly but the watcher Phase D depends on the contract. +5. **`list_supported_files` extension check:** The plan calls `paths::resolve_supported(&canonical_root, &format!("dummy.{lower_ext}"))` to validate the extension. This is a hack — it builds a fake path just to reuse the allowlist. Do NOT replace with a separate const; the test relies on `resolve_supported`'s allowlist behavior matching `list_supported_files`. The fallback `paths::is_markdown(&path) || is_supported_attachment(&lower_ext)` covers edge cases. +6. **`serialize_note` bumps `modified`:** Plan Step 5.4 explicitly sets `modified: current_iso()` in `serialize_note`. The `turkish_chars_roundtrip_byte_identical` test compares parsed-after-write content but NOT byte-for-byte — title and body must match, but `modified` will differ from input. Do not skip this bump. +7. **`extra` field with `#[serde(flatten)]`:** This is what catches non-reserved frontmatter keys like `status: active` and `priority: 3`. The serializer iterates `out.extra` and writes any keys not already in the reserved set. Plan Step 5.4 emits these AFTER the reserved keys; preserve order. +8. **`RESERVED_KEYS` casing:** Use `"localOnly"` not `"local_only"`. The struct field is `local_only` with `#[serde(rename_all = "camelCase")]` so YAML serializes as `localOnly`. The `RESERVED_KEYS` array must match the serialized casing. +9. **`extract_title_from_path` titlecase:** `extract_title_from_path("notes/my-cool-thought.md")` returns `"My Cool Thought"` — split on `-`/`_`, capitalize first letter of each word. Test `extracts_title_from_filename_when_missing` verifies this. +10. **`create_frontmatter` test stability:** Tests fix `fm.id = "fixed-id-1"` after `create_frontmatter` returns. Don't make `id` non-public — keep it as `pub id: String`. The test sets it directly. +11. **`write_skips_no_op_when_hash_matches`:** Compute SHA-256 of the about-to-write serialized content. If it matches the on-disk hash (read-then-hash), return the existing `NoteOnDisk` without rewriting. The test asserts `first.content_hash == second.content_hash`. Plan Step 6.4 handles this; read carefully and replicate. +12. **`read_auto_repairs_missing_frontmatter_and_writes_back`:** When `parsed.was_modified`, `read_note_from_disk` writes the canonical form back so the next read is stable. The test reads a plain `"no fm here\n"` file and asserts the on-disk file now starts with `"---\n"`. The `parsed.was_modified` check after `parse_note` is the trigger. +13. **`read_rejects_path_traversal`:** Calls `read_note_from_disk(vault.path(), "../escape.md")`. `paths::resolve_in_vault` errors with `PathEscape`. The error must propagate verbatim — don't wrap it in `Vault` or `Validation`. +14. **`tokio::test` flavor:** Phase B's tests don't need `multi_thread`; default `tokio::test` is fine. (Phase D's watcher tests require `multi_thread`.) +15. **`Cargo.toml` `[[test]]` ordering:** Add new `[[test]]` blocks AT THE END of the file (not interleaved). Phase A added `vault_paths_test`; Phase B appends `vault_fs_test`, `vault_frontmatter_test`, `vault_notes_io_test` in that order. + +### Constraints + +- **No scope creep:** Do not implement `preferences.rs`, `registry.rs`, `state.rs`, `watcher.rs`, or any command in Phase B. Stubs untouched. Phase C-E handle them. +- **No additional crates beyond Phase A allow-list + `nanoid`:** No `chrono`, no `time`, no `regex`. The plan uses `chrono::Utc::now()` for `current_iso()` — `chrono` was already in M2 deps via `tauri-plugin-window-state` transitive or similar; verify with `cargo tree | grep chrono`. If not present, the plan's `current_iso()` helper falls back to `std::time::SystemTime` formatting. Do not add `chrono` as a direct dep just for this — use `std::time::SystemTime` + manual ISO format. +- **`extract_properties` returns owned `BTreeMap`:** Tests call `parsed.frontmatter.extract_properties()` and inspect keys. Make it `pub fn extract_properties(&self) -> BTreeMap`. Don't return `&BTreeMap` — the test mutates the result (`contains_key`). +- **No `unwrap()` in production paths:** All `parse_note`/`serialize_note`/`read_note_from_disk`/`write_note_to_disk` paths return `AppResult`. Test code may use `unwrap()` freely. +- **Rust style:** `cargo clippy -- -D warnings` clean at each task boundary. Format with `cargo fmt`. + +### Acceptance criteria (Phase B done when all pass) + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 + +# Files exist +test -f apps/desktop-tauri/src-tauri/src/vault/fs.rs +test -f apps/desktop-tauri/src-tauri/src/vault/frontmatter.rs +test -f apps/desktop-tauri/src-tauri/src/vault/notes_io.rs +test -f apps/desktop-tauri/src-tauri/tests/vault_fs_test.rs +test -f apps/desktop-tauri/src-tauri/tests/vault_frontmatter_test.rs +test -f apps/desktop-tauri/src-tauri/tests/vault_notes_io_test.rs + +# Files have content (not just stubs) +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/fs.rs)" -gt 100 ] +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/frontmatter.rs)" -gt 150 ] +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/notes_io.rs)" -gt 50 ] + +# `nanoid` dep present +grep -q '^nanoid' apps/desktop-tauri/src-tauri/Cargo.toml + +# Tests registered +grep -q 'name = "vault_fs_test"' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q 'name = "vault_frontmatter_test"' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q 'name = "vault_notes_io_test"' apps/desktop-tauri/src-tauri/Cargo.toml + +# Each test file passes +cd apps/desktop-tauri/src-tauri +cargo test --features test-helpers --test vault_fs_test 2>&1 | tail -3 # 9 passed +cargo test --features test-helpers --test vault_frontmatter_test 2>&1 | tail -3 # 9 passed +cargo test --features test-helpers --test vault_notes_io_test 2>&1 | tail -3 # 5 passed + +# Full carry-over +cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~60 passed (28 M2 + 9 paths + 9 fs + 9 frontmatter + 5 notes_io) + +# Rust hygiene +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy + +# Commits +git log --oneline | grep -cE 'm3\(vault\)' # expect ≥ 5 (Phase A 2 + Phase B 3) + +# Electron / packages / specs / plans untouched +git diff --name-only main..HEAD -- apps/desktop/ apps/sync-server/ packages/ docs/superpowers/specs/ docs/superpowers/plans/ | wc -l +# expect 0 +``` + +### When done + +Report to user: + +``` +Phase B complete. +Tasks covered: 4, 5, 6 +Commits: 3 (..) +Rust tests: 23 new (9 fs + 9 frontmatter + 5 notes_io) + 9 paths + 28 M2 = ~60 passed +Verification: + - cargo check: clean + - cargo clippy -- -D warnings: clean + - cargo test --features test-helpers: ~60 passed, 0 failed + - Electron/packages/specs/plans untouched: 0 files + +Next: Phase C — prompts/m3/m3-phase-c-preferences-registry.md +Blockers: +``` + +If blocker: +- Frontmatter Turkish round-trip fail → check the `serde_yaml_ng::to_string` writes UTF-8 bytes (it does by default). Inspect the serialized output with `println!`. Likely culprit: missing `Value::String` wrapping for non-ASCII titles. +- Atomic-write temp leak test fail → cleanup branch missed an error path. Re-read plan Step 4.4 — the `match write_then_rename.await` block must `let _ = fs::remove_file(&temp_path).await;` before returning the error. +- `notes_io::write_skips_no_op_when_hash_matches` fail → hash comparison happened post-write instead of pre-write. The skip must short-circuit BEFORE the rename, otherwise the test hash equality is trivially true. + +If still blocked: invoke `superpowers:systematic-debugging`. Report + wait for approval. + +### Ready + +1. Invoke `superpowers:using-superpowers` and `superpowers:test-driven-development`. +2. Read plan Tasks 4, 5, 6 fully (lines ~600–1697 of the plan file). +3. Run prerequisite verification. Report results. +4. Task 4 RED-GREEN: nanoid dep → failing fs_test → impl fs.rs → 9 passed → commit `m3(vault): fs.rs atomic_write + safe_read + list + sha256 content_hash`. +5. Task 5 RED-GREEN: failing frontmatter_test → impl frontmatter.rs → 9 passed → commit `m3(vault): frontmatter.rs serde_yaml_ng parse/serialize + Turkish/multiline/date round-trip`. +6. Task 6 RED-GREEN: failing notes_io_test → impl notes_io.rs → 5 passed → commit `m3(vault): notes_io.rs read/write/delete with content-hash skip + auto-repair`. + +## PROMPT END diff --git a/prompts/m3/m3-phase-c-preferences-registry.md b/prompts/m3/m3-phase-c-preferences-registry.md new file mode 100644 index 000000000..94ced839d --- /dev/null +++ b/prompts/m3/m3-phase-c-preferences-registry.md @@ -0,0 +1,193 @@ +# M3 Phase C — Preferences + Registry (vault/preferences.rs + vault/registry.rs, TDD) + +Temiz session prompt. **Bu phase TAM TDD** — `preferences.rs` (8 test) ve `registry.rs` (6 test) RED-GREEN. + +--- + +## PROMPT START + +You are implementing **Phase C of Milestone M3** for Memry's Electron→Tauri migration. This phase lands the two persistence modules: per-vault JSON config (`/.memry/config.json`) and the multi-vault registry (`/memry-{device}/vaults.json`). Both are sync (not async) JSON IO — small files, low frequency, single-writer. + +### Context + +**Worktree:** `/Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3` +**Branch:** `m3/vault-fs-and-watcher` +**Plan:** `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` +**Spec:** `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` +**Prompts README:** `prompts/m3/README.md` + +Phase A landed deps + paths. Phase B landed `fs.rs` + `frontmatter.rs` + `notes_io.rs` (23 tests). Phase C now adds `preferences.rs` + `registry.rs`. Phase C also makes `frontmatter::unix_secs_to_iso` `pub(crate)` and adds a `frontmatter_iso` helper in `mod.rs` so `registry.rs` can format ISO timestamps without re-implementing the conversion. + +### Prerequisite verification + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git rev-parse --abbrev-ref HEAD # expect: m3/vault-fs-and-watcher + +# Phase B complete +test -f apps/desktop-tauri/src-tauri/src/vault/fs.rs +test -f apps/desktop-tauri/src-tauri/src/vault/frontmatter.rs +test -f apps/desktop-tauri/src-tauri/src/vault/notes_io.rs +test -f apps/desktop-tauri/src-tauri/tests/vault_fs_test.rs +test -f apps/desktop-tauri/src-tauri/tests/vault_frontmatter_test.rs +test -f apps/desktop-tauri/src-tauri/tests/vault_notes_io_test.rs + +# Phase B tests still green +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~60 passed (28 M2 + 9 paths + 9 fs + 9 frontmatter + 5 notes_io) + +# Phase B commits +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\(vault\)' +# expect ≥ 5 + +# Stubs still in place for the modules we'll fill +test -f apps/desktop-tauri/src-tauri/src/vault/preferences.rs +test -f apps/desktop-tauri/src-tauri/src/vault/registry.rs +``` + +If any fails, STOP. Phase B must complete before Phase C starts. + +### Your scope + +Execute **Tasks 7, 8** from the plan in this order. Each is a full RED-GREEN cycle. + +- **Task 7 — `vault/preferences.rs`** (per-vault JSON config + UI prefs): + - Write 8 failing tests in `tests/vault_preferences_test.rs`: + - `init_creates_dot_memry_with_default_config`, `init_is_idempotent`, `read_config_returns_defaults_when_missing`, `write_config_round_trips`, `read_preferences_returns_defaults_when_missing`, `update_preferences_merges_partial`, `vault_name_falls_back_to_basename`, `count_markdown_files_excludes_patterns_and_hidden`. + - Register `[[test]] name = "vault_preferences_test" required-features = ["test-helpers"]`. + - Implement `preferences.rs` per plan Step 7.4: `VaultConfig` struct (excludePatterns/defaultNoteFolder/journalFolder/attachmentsFolder), `EditorPreferences` struct (width/spellCheck/autoSaveDelay/showWordCount/toolbarMode), `VaultPreferences` struct (theme/fontSize/fontFamily/accentColor/language/createInSelectedFolder/editor) — all `Default` impls + `#[serde(rename_all = "camelCase")]`. Public functions: `memry_dir`, `config_path`, `is_initialized`, `init_vault`, `read_config`, `update_config`, `read_preferences`, `update_preferences`, `vault_name`, `count_markdown_files`. Initial config blob writes the camelCase JSON shape Electron uses. Layout: `/.memry/`, `/notes/`, `/journal/`, `/attachments/{images,files}/`. + +- **Task 8 — `vault/registry.rs`** (multi-vault list at OS data dir): + - Write 6 failing tests in `tests/vault_registry_test.rs`: + - `empty_registry_when_file_missing`, `upsert_then_persist_then_reload`, `upsert_replaces_by_path_not_duplicates`, `remove_drops_vault_and_clears_current_if_match`, `touch_updates_last_opened`, `corrupt_file_falls_back_to_empty_registry`. + - Register `[[test]] name = "vault_registry_test" required-features = ["test-helpers"]`. + - Implement `registry.rs` per plan Step 8.4: `VaultInfo` struct (path/name/noteCount/taskCount/lastOpened/isDefault), `VaultRegistry` struct (`vaults: Vec`, `current: Option`), methods: `load(path) -> AppResult` (returns default on missing/corrupt), `save(path)`, `find`, `upsert`, `remove`, `set_current`, `touch`. Free function `registry_path()` returns `/memry-{device}/vaults.json` reading `MEMRY_DEVICE` env (matches M2 DB path scheme). + - Step 8.5: make `frontmatter::unix_secs_to_iso` `pub(crate)` and add `frontmatter_iso` re-export in `vault/mod.rs` so `registry.rs::current_iso()` can call `crate::vault::frontmatter_iso(secs)`. + +### Methodology — TDD mandatory for both tasks + +1. **Invoke `superpowers:using-superpowers` and `superpowers:test-driven-development`** first. +2. **Two full RED-GREEN cycles. One commit per task.** +3. For Task 7: + - Step 7.1: copy plan's test verbatim (lines ~1706–1810). 8 tests. + - Step 7.2: register `[[test]]` block. + - Step 7.3: RED → unresolved `init_vault`, `read_config`, `update_config`, `read_preferences`, `update_preferences`, `vault_name`, `count_markdown_files`. + - Step 7.4: implement per plan (lines ~1830–2150). + - Step 7.5: GREEN → 8 passed. + - Step 7.6: commit `m3(vault): preferences.rs vault config + UI prefs + count_markdown_files`. +4. For Task 8: + - Step 8.1: copy plan's test verbatim (lines ~2160–2255). 6 tests. + - Step 8.2: register `[[test]]` block. + - Step 8.3: RED → unresolved imports. + - Step 8.4: implement per plan (lines ~2275–2377). + - Step 8.5: make `frontmatter::unix_secs_to_iso` `pub(crate)` + add `frontmatter_iso` helper in `mod.rs`. `cargo check` to confirm cross-module path works. + - Step 8.6: GREEN → 6 passed. + - Step 8.7: commit `m3(vault): registry.rs multi-vault list at /memry-{device}/vaults.json`. + +### Critical gotchas + +1. **Sync IO, not async:** Both modules use `std::fs::*`, NOT `tokio::fs::*`. The files are tiny and writes are user-driven (open/switch/save), not high-frequency. Plan Step 7.4 + 8.4 use `std::fs` deliberately — don't "modernize" to tokio. +2. **`init_vault` is idempotent:** `fs::create_dir_all` is idempotent on existing dirs. The config write uses `if !cfg_path.exists()` to avoid clobbering user edits. Test `init_is_idempotent` calls `init_vault` twice and expects no error. The `VAULT_FOLDERS` const lists `notes`, `journal`, `attachments`, `attachments/images`, `attachments/files` — all five must exist after init. +3. **`read_config` returns defaults on missing OR corrupt:** Plan Step 7.4's `read_config_blob` returns the default JSON if the file doesn't exist. The test `corrupt_file_falls_back_to_empty_registry` verifies this for the registry; the same defensive pattern applies to config (`unwrap_or_default()` after `from_str`). NEVER error on a fresh vault — that would brick onboarding. +4. **`update_preferences_merges_partial`:** The function takes a `serde_json::Map` (NOT a typed struct) and merges into the existing `preferences` object. Test passes `{"theme": "dark"}` and asserts `theme == "dark"` AND `fontSize == "medium"` (default unchanged). The merge happens at the JSON `Value::Object` level, not the typed `VaultPreferences` level — round-trip through `serde_json::to_value` and back, then merge keys, then deserialize. +5. **`count_markdown_files` excludes hidden + excludePatterns:** It walks the vault tree, counts only `*.md` and `*.markdown` files, skips dotfiles + dirs in the `excludePatterns` list. The function is sync — Phase E's `vault_open` command calls it during the open flow. Use `walkdir = "2"` if needed; check `cargo tree` first — if not present, write a manual recursive walker (10 lines). +6. **`registry.rs` path scheme matches M2:** `MEMRY_DEVICE` env var defaults to `"default"`. Path: `/memry-{device}/vaults.json`. M2's DB lives at `/memry-{device}/data.db` — same parent directory. `registry_path()` MUST produce a sibling of M2's `data.db`. +7. **`registry::touch` requires unique `last_opened`:** Test `touch_updates_last_opened` sleeps 10ms then calls `touch`. The `last_opened` ISO string must change. If your `current_iso()` truncates to seconds, the 10ms sleep won't move the second-counter. Plan's `unix_secs_to_iso` formats with sub-second precision OR the helper falls back to nanos. Verify by reading the `frontmatter::unix_secs_to_iso` you wrote in Phase B — if seconds-only, bump `current_iso()` in `registry.rs` to use `as_millis()` or `as_nanos()` and format accordingly. (Plan's exact `current_iso` impl in `registry.rs` uses `as_secs()` then calls `frontmatter_iso` — the `unix_secs_to_iso` from Phase B may already include ms. If the test flakes, revisit Phase B impl.) +8. **`frontmatter::unix_secs_to_iso` visibility:** Phase B made this private. Phase C Step 8.5 elevates it to `pub(crate)`. Without this, `registry.rs::current_iso` won't compile. The `frontmatter_iso` re-export in `mod.rs` is a stable name for the rest of the crate (so `registry.rs` doesn't need to know `frontmatter` is the source). +9. **`VaultInfo` field order:** Match plan Step 8.4 exactly: `path`, `name`, `note_count: i64`, `task_count: i64`, `last_opened: String`, `is_default: bool`. Specta-generated TS preserves field order; Phase G's `bindings:check` will diff against renderer expectations. +10. **`VaultRegistry::load` is fault-tolerant:** Returns `Self::default()` (empty) on missing OR malformed JSON. The `unwrap_or_default()` after `serde_json::from_str` handles the corrupt case. Test `corrupt_file_falls_back_to_empty_registry` writes `"{not valid json}"` and expects `Ok(empty)` — never propagate the parse error. +11. **`registry::save` creates parent dir:** First call after a fresh device install: `/memry-{device}/` doesn't exist yet. `save` MUST `fs::create_dir_all(parent)` before writing. Plan Step 8.4 handles this; do not skip. +12. **`Cargo.toml` `[[test]]` ordering:** Append after Phase B's blocks. Order matters for predictable parallel test runs but is mostly cosmetic. + +### Constraints + +- **No scope creep:** Do not implement `state.rs`, `watcher.rs`, or any command in Phase C. Stubs untouched. Phase D handles state + watcher. +- **No new crates:** `walkdir` is the only candidate addition (for `count_markdown_files`). If you can avoid it with a manual `read_dir` recursion, do so. The plan's exact impl uses manual recursion to keep deps tight. +- **No Phase D wiring:** Do not touch `app_state.rs` or `lib.rs::run` to wire `VaultRuntime` — that is Phase D's job. The `mod.rs` re-exports are still commented at end of Phase C; Phase D enables them. +- **No `unwrap()` in production paths:** All `init_vault`, `read_config`, `update_config`, `update_preferences`, `VaultRegistry::load`, `VaultRegistry::save`, `registry_path` return `AppResult`. Test code may use `unwrap()` freely. +- **Rust style:** `cargo clippy -- -D warnings` clean at each task boundary. Format with `cargo fmt`. + +### Acceptance criteria (Phase C done when all pass) + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 + +# Files implemented +test -f apps/desktop-tauri/src-tauri/src/vault/preferences.rs +test -f apps/desktop-tauri/src-tauri/src/vault/registry.rs +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/preferences.rs)" -gt 100 ] +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/registry.rs)" -gt 80 ] + +# `frontmatter::unix_secs_to_iso` elevated to pub(crate) +grep -q 'pub(crate) fn unix_secs_to_iso' apps/desktop-tauri/src-tauri/src/vault/frontmatter.rs + +# `frontmatter_iso` helper in mod.rs +grep -q 'frontmatter_iso' apps/desktop-tauri/src-tauri/src/vault/mod.rs + +# Tests registered +grep -q 'name = "vault_preferences_test"' apps/desktop-tauri/src-tauri/Cargo.toml +grep -q 'name = "vault_registry_test"' apps/desktop-tauri/src-tauri/Cargo.toml + +# Each test file passes +cd apps/desktop-tauri/src-tauri +cargo test --features test-helpers --test vault_preferences_test 2>&1 | tail -3 # 8 passed +cargo test --features test-helpers --test vault_registry_test 2>&1 | tail -3 # 6 passed + +# Full carry-over +cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~74 passed (28 M2 + 9 paths + 9 fs + 9 frontmatter + 5 notes_io + 8 prefs + 6 registry) + +# Rust hygiene +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy + +# Commits +git log --oneline | grep -cE 'm3\(vault\)' # expect ≥ 7 + +# Electron / packages / specs / plans untouched +git diff --name-only main..HEAD -- apps/desktop/ apps/sync-server/ packages/ docs/superpowers/specs/ docs/superpowers/plans/ | wc -l +# expect 0 +``` + +### When done + +Report to user: + +``` +Phase C complete. +Tasks covered: 7, 8 +Commits: 2 (..) +Rust tests: 14 new (8 prefs + 6 registry) + 32 prior = ~74 passed +Verification: + - cargo check: clean + - cargo clippy -- -D warnings: clean + - cargo test --features test-helpers: ~74 passed, 0 failed + - frontmatter::unix_secs_to_iso elevated to pub(crate) + - mod.rs frontmatter_iso re-export added + - Electron/packages/specs/plans untouched: 0 files + +Next: Phase D — prompts/m3/m3-phase-d-watcher-runtime-state.md +Blockers: +``` + +If blocker: +- `update_preferences_merges_partial` fails → check the merge happens at `Value::Object` level (`Map`) not at `VaultPreferences` typed level. Pure JSON merge then deserialize. +- `touch_updates_last_opened` fails (`assert_ne!(before, after)`) → `current_iso()` returns same value across 10ms sleep. Bump precision to ms in `frontmatter::unix_secs_to_iso` or use `as_nanos()` in `registry::current_iso` directly. +- `corrupt_file_falls_back_to_empty_registry` fails → `unwrap_or_default()` after `serde_json::from_str` was replaced with `?`. Restore the swallow pattern; corrupt files MUST not panic the registry. +- `count_markdown_files` over-counts → didn't apply `excludePatterns` to dirs OR didn't skip dotfiles. Test the walker independently with `dbg!`. + +If still blocked: invoke `superpowers:systematic-debugging`. Report + wait for approval. + +### Ready + +1. Invoke `superpowers:using-superpowers` and `superpowers:test-driven-development`. +2. Read plan Tasks 7, 8 fully (lines ~1697–2422 of the plan file). +3. Run prerequisite verification. Report results. +4. Task 7 RED-GREEN: failing prefs_test → impl preferences.rs → 8 passed → commit `m3(vault): preferences.rs vault config + UI prefs + count_markdown_files`. +5. Task 8 RED-GREEN: failing registry_test → impl registry.rs + elevate `unix_secs_to_iso` + add `frontmatter_iso` helper → 6 passed → commit `m3(vault): registry.rs multi-vault list at /memry-{device}/vaults.json`. + +## PROMPT END diff --git a/prompts/m3/m3-phase-d-watcher-runtime-state.md b/prompts/m3/m3-phase-d-watcher-runtime-state.md new file mode 100644 index 000000000..fd1b52b91 --- /dev/null +++ b/prompts/m3/m3-phase-d-watcher-runtime-state.md @@ -0,0 +1,220 @@ +# M3 Phase D — Watcher + Runtime State (vault/watcher.rs + vault/state.rs + AppState wiring) + +Temiz session prompt. **Bu phase TDD** for watcher (4 multi_thread test). State wiring verification-driven. + +**Order matters:** Task 10 (watcher) first, then Task 9 (state). State imports `WatcherHandle` from watcher — without watcher implemented first, state won't compile. + +--- + +## PROMPT START + +You are implementing **Phase D of Milestone M3** for Memry's Electron→Tauri migration. This phase lands the live runtime: the `notify`-based file watcher with 150ms path-keyed debounce, and `VaultRuntime` — the thread-safe state owner that holds the current vault, registry handle, indexing status, and active watcher handle. It then wires `VaultRuntime` into `AppState` so commands (Phase E) can access it via `State<'_, AppState>`. + +### Context + +**Worktree:** `/Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3` +**Branch:** `m3/vault-fs-and-watcher` +**Plan:** `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` +**Spec:** `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` +**Prompts README:** `prompts/m3/README.md` + +Phase A-C landed deps + paths + fs/frontmatter/notes_io + preferences/registry (37 vault tests). Phase D unifies them under a runtime state object and adds the live watcher loop. + +### Prerequisite verification + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git rev-parse --abbrev-ref HEAD # expect: m3/vault-fs-and-watcher + +# Phase C complete +test -f apps/desktop-tauri/src-tauri/src/vault/preferences.rs +test -f apps/desktop-tauri/src-tauri/src/vault/registry.rs +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/preferences.rs)" -gt 100 ] +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/registry.rs)" -gt 80 ] +grep -q 'pub(crate) fn unix_secs_to_iso' apps/desktop-tauri/src-tauri/src/vault/frontmatter.rs +grep -q 'frontmatter_iso' apps/desktop-tauri/src-tauri/src/vault/mod.rs + +# Stubs still in place for state + watcher +test -f apps/desktop-tauri/src-tauri/src/vault/state.rs # stub +test -f apps/desktop-tauri/src-tauri/src/vault/watcher.rs # stub + +# All Phase A-C tests still green +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~74 passed (28 M2 + 9 paths + 9 fs + 9 frontmatter + 5 notes_io + 8 prefs + 6 registry) + +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\(vault\)' # expect ≥ 7 +``` + +If any fails, STOP. Phase C must complete before Phase D starts. + +### Your scope + +Execute **Tasks 10, 9** from the plan in this order. **Watcher first, state second.** + +- **Task 10 — `vault/watcher.rs`** (notify v6 + 150ms debounce + `vault-changed` events): + - Write 4 failing tests in `tests/vault_watcher_test.rs`: + - `detects_new_file_within_debounce_window`, `debounces_rapid_writes_to_same_file`, `ignores_dot_memry_writes`, `detects_deletion`. All `#[tokio::test(flavor = "multi_thread")]`. + - Register `[[test]] name = "vault_watcher_test" required-features = ["test-helpers"]`. + - Implement `watcher.rs` per plan Step 10.3: + - `VaultEvent { relative_path: String, kind: VaultEventKind }` (Specta Type, camelCase serialize). + - `VaultEventKind { Created, Modified, Deleted }` (Specta Type, lowercase serialize). + - `WatcherHandle` struct with `_watcher: RecommendedWatcher`, `_scheduler: JoinHandle<()>`, `cancel: Arc`. `Drop` impl sets `cancel = true`. + - `start(vault_root, out: UnboundedSender) -> AppResult`: notify recursive watcher, drain task that fills a path-keyed `PendingMap`, scheduler task that emits ready entries every 50ms. + - `should_ignore(root, path)` filters `.foo` segments anywhere AND unsupported extensions. + - `classify(kind: &EventKind)` maps notify kinds to `VaultEventKind`. + - 150ms debounce constant `DEBOUNCE_MS`. + +- **Task 9 — `vault/state.rs` + `AppState` wiring + `lib.rs::run` boot**: + - Implement `state.rs` per plan Step 9.1: + - `VaultStatus { is_open, path: Option, is_indexing, index_progress: u8, error: Option }` (Specta Type, camelCase, `Default`). + - `VaultRuntime { inner: Mutex, watcher_slot: Mutex>, registry_path: PathBuf }`. + - `RuntimeInner { current: Option, is_indexing, index_progress, error, registry: VaultRegistry }`. + - Methods: `boot()`, `status()`, `current_path()`, `registry_snapshot()`, `set_current()`, `upsert_registry()`, `remove_from_registry()`, `touch_registry()`, `set_indexing()`, `set_error()`, `require_current()`. All locks use `unwrap_or_else(|p| p.into_inner())` poison handling. + - Step 9.2: re-enable `pub use frontmatter::{NoteFrontmatter, ParsedNote}; pub use notes_io::{NoteOnDisk, ReadNoteResult}; pub use preferences::{VaultConfig, VaultPreferences}; pub use registry::{VaultInfo, VaultRegistry}; pub use state::{VaultRuntime, VaultStatus};` in `vault/mod.rs`. (Phase A commented these out; Phase D enables them now that the symbols exist.) + - Step 9.3: extend `AppState` to `{ db: Db, vault: Arc }` in `app_state.rs`. Add `AppState::new(db, vault)` constructor. + - Step 9.4: wire `VaultRuntime::boot()` into `lib.rs::run`'s `init_app_state` function. Boot order: `resolve_db_path` → `Db::open` → `VaultRuntime::boot()` → `AppState::new(db, vault)`. + +### Methodology — TDD for watcher, verification for state + +1. **Invoke `superpowers:using-superpowers` and `superpowers:test-driven-development`** first. +2. **Task 10 (watcher) — full RED-GREEN:** + - Step 10.1: write the 4-test file verbatim from plan lines ~2655–2759. Tests use `#[tokio::test(flavor = "multi_thread")]` and `tokio::sync::mpsc::unbounded_channel`. + - Step 10.2: register `[[test]]` block. + - Run RED → unresolved `watcher::start`, `VaultEvent`, `VaultEventKind`. Confirm. + - Step 10.3: implement `watcher.rs` per plan lines ~2773–2994. The implementation has THREE async tasks: notify callback (sync, on a notify thread, bridges to Tokio), drain task (consumes raw events into pending map), scheduler task (50ms tick, emits debounced events). + - Step 10.4: `cargo check` first — watcher imports are tricky; confirm before running tests. + - Step 10.5: run watcher tests. Allow 10–15s; FSEvents has ~50ms latency, debounce adds 150ms, scheduler ticks 50ms — total per test budget ~2s. + - Step 10.6: commit `m3(vault): watcher.rs notify+debounce emitting VaultEvent {path, kind}`. + +3. **Task 9 (state + wiring) — verification-driven:** + - Step 9.1: implement `state.rs` per plan lines ~2436–2572. **Note:** state.rs imports `crate::vault::watcher::WatcherHandle` which now exists from Task 10. + - Step 9.2: re-enable `pub use` block in `mod.rs`. `cargo check` should pass — every re-exported symbol now exists. + - Step 9.3: rewrite `app_state.rs` per plan Step 9.3 (replace contents). The struct now has TWO fields: `db: Db` (M2) + `vault: Arc` (M3). Old M2 sites that say `AppState { db }` won't compile — use the constructor. + - Step 9.4: edit `lib.rs::init_app_state` per plan Step 9.4. Three lines: open db, boot vault runtime, build AppState. Make sure the function signature still returns `AppResult`. + - Step 9.5: `cargo check && cargo test --features test-helpers --tests` — every prior test must stay green. `state.rs` has no integration test of its own (state is exercised by Task 11's command tests + the runtime smoke). + - Step 9.6: commit `m3(vault): VaultRuntime + AppState extension + boot wiring`. + +### Critical gotchas + +1. **Watcher test timing:** `#[tokio::test(flavor = "multi_thread")]` is mandatory. Default single-threaded flavor will deadlock the drain+scheduler+notify-thread arrangement. Plan's tests use multi_thread; do NOT downgrade. +2. **100ms warm-up sleep before writing:** Each watcher test has `tokio::time::sleep(Duration::from_millis(100)).await` after `start()` and before the first `fs::write`. This lets notify install the FSEvents subscription. Skip it and notify misses the first event. Test `detects_new_file_within_debounce_window` will flake without warm-up. +3. **`should_ignore` checks dotfiles AT EVERY component:** `notes/.git/HEAD` must be ignored even if `notes/` is fine. The plan's `should_ignore` walks `path.strip_prefix(root)?.components()` and returns true if ANY normal segment starts with `.`. Reproduce exactly. +4. **`should_ignore` for unsupported extensions only applies to files:** Directories (e.g. mid-walk `notes/foo/`) must NOT be filtered out by extension check (they have no extension). Plan's impl gates the extension check behind `if !path.is_dir()`. Don't lose this branch. +5. **`classify(EventKind)` rename → Modified:** macOS FSEvents emits rename as `EventKind::Modify(ModifyKind::Name(...))`. Plan classifies all `Modify` as `Modified`. The spec accepts this as the "safe upper bound" for rename detection (real rename detection is M5). +6. **`debounces_rapid_writes_to_same_file` upper bound:** Plan says `count <= 3`. macOS FSEvents under load can coalesce 5 writes into multiple events; if the test flakes, plan Step 10.5 says bump the upper bound to 5 and document in a code comment. **Default to 3 first** — the debounce should make 5 writes → 1–2 emitted events. Bump only if you see flakes after 3 runs. +7. **`detects_deletion` poll loop:** Test polls `rx.recv()` for up to 2 seconds looking for a `VaultEventKind::Deleted` event. macOS FSEvents may emit `Modified` first (file shrinks) then `Deleted`. The poll loop continues past `Modified` events. Don't change to `expect_one_event` semantics — the loop is correct. +8. **`ignores_dot_memry_writes` requires `.memry/` to be inside the canonicalized root:** The test `fs::create_dir_all(vault.path().join(".memry"))` creates the dir inside the temp vault, then writes a file. `should_ignore` strips the canonical root prefix and sees `.memry/data.db`; the dotfile check on the first segment fires → ignored. `timeout(Duration::from_millis(500), rx.recv()).await.is_err()` confirms no event was emitted. +9. **`WatcherHandle` Drop semantics:** Setting `cancel = true` makes the scheduler/drain tasks exit on their next tick (50ms). The notify watcher is dropped, which unsubscribes from FSEvents synchronously. Tests `drop(handle)` at the end to release file handles before the temp dir cleanup. +10. **`state.rs::watcher_slot` is a separate Mutex from `inner`:** Plan deliberately splits them. Reasons: (a) `watcher_slot.take()` runs during vault close — should not block on `inner` lock; (b) the watcher's drop drains the channel, which we don't want under the inner lock. Don't merge into one mutex. +11. **`VaultRuntime::boot()` is fault-tolerant on missing registry:** `VaultRegistry::load(®istry_path).unwrap_or_default()` (Phase C contract). Boot must succeed on a brand-new device install with no `vaults.json`. Plan Step 9.1 uses `unwrap_or_default` exactly here. +12. **`AppState::new` constructor required:** Phase G's command code uses `AppState::new(db, vault)` from the boot path. Don't make `AppState` public-ctor-only via field literal initialization — Specta-generated test fixtures and any future re-init path benefit from the named constructor. +13. **Re-exports in `mod.rs` must compile:** After Step 9.2, `pub use state::{VaultRuntime, VaultStatus}` etc. Every name in the re-export block must be a public item in its module. If you accidentally renamed `VaultStatus` → `VaultRuntimeStatus` in state.rs, the re-export breaks. Plan's exact names: `VaultRuntime`, `VaultStatus` from `state`; `VaultInfo`, `VaultRegistry` from `registry`; `VaultConfig`, `VaultPreferences` from `preferences`; `NoteOnDisk`, `ReadNoteResult` from `notes_io`; `NoteFrontmatter`, `ParsedNote` from `frontmatter`. +14. **`init_app_state` Arc-wrap the runtime:** `Arc::new(VaultRuntime::boot()?)`. Without `Arc`, Tauri's `State<'_, AppState>` would attempt to clone `VaultRuntime`, which holds `Mutex` + `JoinHandle` — not clonable. The Arc is the share boundary. + +### Constraints + +- **No scope creep:** Do not implement vault commands, shell/dialog commands, the URI protocol handler, or the drag-drop spike in Phase D. Phase E and Phase F handle them. +- **No `tokio::sync::Mutex` for state:** Plan deliberately uses `std::sync::Mutex` because Tauri commands hold the lock briefly per call and don't need async-aware locking. Tokio Mutex is allowed inside `watcher.rs`'s scheduler/drain tasks, but `state.rs` stays sync-Mutex. +- **No `parking_lot::Mutex`:** Plan's comment mentions it as an alternative but uses `std::sync::Mutex`. Don't add `parking_lot` as a new dep. +- **No new tests for `state.rs`:** State is exercised by Task 11's command tests + Phase G's runtime smoke. Don't write a `vault_state_test.rs` — that adds budget without coverage value (the unit tests would just exercise `Mutex` plumbing). +- **No watcher fanout to Tauri events yet:** Phase D only writes the watcher to a Tokio `UnboundedSender`. Bridging to Tauri's `app.emit("vault-changed", ...)` happens in Phase E's `vault_open` command — which spawns the consumer task that reads from the channel and forwards to the renderer. +- **Rust style:** `cargo clippy -- -D warnings` clean at each task boundary. Format with `cargo fmt`. + +### Acceptance criteria (Phase D done when all pass) + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 + +# Files implemented +test -f apps/desktop-tauri/src-tauri/src/vault/watcher.rs +test -f apps/desktop-tauri/src-tauri/src/vault/state.rs +test -f apps/desktop-tauri/src-tauri/tests/vault_watcher_test.rs +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/watcher.rs)" -gt 150 ] +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/state.rs)" -gt 100 ] + +# Re-exports enabled +grep -q '^pub use state::{VaultRuntime, VaultStatus}' apps/desktop-tauri/src-tauri/src/vault/mod.rs +grep -q '^pub use registry::{VaultInfo, VaultRegistry}' apps/desktop-tauri/src-tauri/src/vault/mod.rs +grep -q '^pub use frontmatter::{NoteFrontmatter, ParsedNote}' apps/desktop-tauri/src-tauri/src/vault/mod.rs +grep -q '^pub use notes_io::{NoteOnDisk, ReadNoteResult}' apps/desktop-tauri/src-tauri/src/vault/mod.rs +grep -q '^pub use preferences::{VaultConfig, VaultPreferences}' apps/desktop-tauri/src-tauri/src/vault/mod.rs + +# AppState extended +grep -q 'pub vault: Arc' apps/desktop-tauri/src-tauri/src/app_state.rs +grep -q 'pub fn new(db: Db, vault: Arc)' apps/desktop-tauri/src-tauri/src/app_state.rs + +# lib.rs boot wiring +grep -q 'VaultRuntime::boot' apps/desktop-tauri/src-tauri/src/lib.rs + +# Test registered +grep -q 'name = "vault_watcher_test"' apps/desktop-tauri/src-tauri/Cargo.toml + +# Watcher test passes +cd apps/desktop-tauri/src-tauri +cargo test --features test-helpers --test vault_watcher_test 2>&1 | tail -3 # 4 passed + +# Full carry-over green +cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~78 passed (74 prior + 4 watcher) + +# Rust hygiene +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy + +# Commits +git log --oneline | grep -cE 'm3\(vault\)' # expect ≥ 9 (Phase A-C 7 + Phase D 2) + +# Electron / packages / specs / plans untouched +git diff --name-only main..HEAD -- apps/desktop/ apps/sync-server/ packages/ docs/superpowers/specs/ docs/superpowers/plans/ | wc -l +# expect 0 +``` + +### When done + +Report to user: + +``` +Phase D complete. +Tasks covered: 10, 9 (in this execution order; plan listed as 9, 10) +Commits: 2 (..) +Rust tests: 4 new (watcher) + 74 prior = ~78 passed +Verification: + - cargo check: clean + - cargo clippy -- -D warnings: clean + - cargo test --features test-helpers: ~78 passed, 0 failed + - mod.rs re-exports enabled (5 pub use lines) + - AppState extended with vault: Arc + - lib.rs::init_app_state wires VaultRuntime::boot() + - Electron/packages/specs/plans untouched: 0 files + +Next: Phase E — prompts/m3/m3-phase-e-tauri-commands.md +Blockers: +``` + +If blocker: +- Watcher test flake on `debounces_rapid_writes_to_same_file` → run 3× to confirm. If consistent, bump upper bound to 5 + add code comment explaining macOS FSEvents coalescing. +- `state.rs` imports `crate::vault::watcher::WatcherHandle` but watcher.rs hasn't exported it → check Step 10.3 — `WatcherHandle` must be `pub struct`. Re-read plan line ~2814. +- `app_state.rs` change breaks M2 settings tests → the M2 settings code uses `state.db.with_conn(|c| ...)`. The new `AppState { db, vault }` keeps `db` as a public field; settings code still compiles. If a settings test fails with "expected AppState ...", you removed `pub` accidentally. +- `init_app_state` panics at boot → `VaultRuntime::boot()` calls `registry::registry_path()` which requires `directories::ProjectDirs::from(...)` to resolve. On dev machines this is fine; on CI sandboxes it might fail. Check the error path returns `AppResult` not panic. + +If still blocked: invoke `superpowers:systematic-debugging`. Report + wait for approval. + +### Ready + +1. Invoke `superpowers:using-superpowers` and `superpowers:test-driven-development`. +2. Read plan Tasks 9 and 10 fully (lines ~2424–3022 of the plan file). **Read Task 10 first** — Task 9 imports from it. +3. Run prerequisite verification. Report results. +4. Task 10 RED-GREEN: failing watcher_test → impl watcher.rs (notify + 3 async tasks + should_ignore + classify) → 4 passed → commit `m3(vault): watcher.rs notify+debounce emitting VaultEvent {path, kind}`. +5. Task 9 verification: + - Implement state.rs (VaultStatus + VaultRuntime + 11 methods). + - Re-enable mod.rs `pub use` block (5 re-exports). + - Extend app_state.rs (`vault: Arc` field + `new()` constructor). + - Wire `init_app_state` in lib.rs. + - `cargo check && cargo test --features test-helpers --tests` → all prior + new compile, all tests stay green. + - Commit `m3(vault): VaultRuntime + AppState extension + boot wiring`. + +## PROMPT END diff --git a/prompts/m3/m3-phase-e-tauri-commands.md b/prompts/m3/m3-phase-e-tauri-commands.md new file mode 100644 index 000000000..b1b83e3fa --- /dev/null +++ b/prompts/m3/m3-phase-e-tauri-commands.md @@ -0,0 +1,245 @@ +# M3 Phase E — Tauri Commands (vault.rs + shell.rs + dialog.rs + capabilities) + +Temiz session prompt. Bu phase verification-driven (TDD değil — komutlar ince wrapper, test'ler Phase G'deki runtime e2e ve manuel smoke ile karşılanır). + +--- + +## PROMPT START + +You are implementing **Phase E of Milestone M3** for Memry's Electron→Tauri migration. This phase lands the full command surface that the renderer will call: 13 `vault_*` commands (open / close / status / current / get_all / switch / remove / get_config / update_config / list_notes / read_note / write_note / delete_note / reveal / reindex), 3 `shell_*` commands (open_url / open_path / reveal_in_finder), and 2 `dialog_*` commands (choose_folder / choose_files). Capabilities and the `tauri-plugin-dialog` registration round it out. + +### Context + +**Worktree:** `/Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3` +**Branch:** `m3/vault-fs-and-watcher` +**Plan:** `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` +**Spec:** `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` +**Prompts README:** `prompts/m3/README.md` + +Phase A-D landed deps + paths + fs/frontmatter/notes_io + preferences/registry + watcher + state/AppState/lib boot wiring (78 vault tests). Phase E exposes that runtime through Tauri commands. The commands themselves are thin async wrappers — the heavy lifting lives in `vault::*` modules. Phase E's correctness is exercised by Phase G's runtime e2e + manual smoke (no separate Rust integration tests in this phase). + +### Prerequisite verification + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git rev-parse --abbrev-ref HEAD # expect: m3/vault-fs-and-watcher + +# Phase D complete +test -f apps/desktop-tauri/src-tauri/src/vault/state.rs +test -f apps/desktop-tauri/src-tauri/src/vault/watcher.rs +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/state.rs)" -gt 100 ] +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/vault/watcher.rs)" -gt 150 ] +grep -q 'pub vault: Arc' apps/desktop-tauri/src-tauri/src/app_state.rs +grep -q 'VaultRuntime::boot' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q '^pub use state::{VaultRuntime, VaultStatus}' apps/desktop-tauri/src-tauri/src/vault/mod.rs + +# All Phase A-D tests still green +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~78 passed (74 prior + 4 watcher) + +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\(vault\)' # expect ≥ 9 + +# Phase D's stubs (commands/shell.rs and commands/dialog.rs) — likely don't exist yet OR are M2 settings-only; check what's there +ls apps/desktop-tauri/src-tauri/src/commands/ +# expect: mod.rs, settings.rs (from M2). Phase E will add vault.rs, shell.rs, dialog.rs + +# Watcher_slot wiring spot-check — Task 11 vault_open uses it +grep -q 'watcher_slot' apps/desktop-tauri/src-tauri/src/vault/state.rs +``` + +If any fails, STOP. Phase D must complete before Phase E starts. + +### Your scope + +Execute **Tasks 11, 12** from the plan in this order. + +- **Task 11 — `commands/vault.rs`** (13 vault commands + lib.rs wiring): + - Create `commands/vault.rs` per plan Step 11.1 with the following Tauri commands. Each is `#[tauri::command]` + `#[specta::specta]` and returns `AppResult<...>`: + - `vault_open(state, app, input: VaultOpenInput { path }) -> VaultOpenOutput { success, vault, error }` — opens a vault: validate dir, init `.memry/`, set indexing, stop prior watcher, set current, count notes, upsert + touch registry, start watcher, spawn `vault-changed` event forwarder, emit `vault-status-changed`. + - `vault_close(state, app)` — drop watcher slot, set current `None`, set indexing false, emit status. + - `vault_get_status(state) -> VaultStatus`. + - `vault_get_current(state) -> VaultCurrent { path: Option }`. + - `vault_get_all(state) -> VaultGetAllOutput { vaults, currentVault }`. + - `vault_switch(state, app, input: VaultPathInput)` — alias to `vault_open`. + - `vault_remove(state, app, input: VaultPathInput)` — close-if-current, then `remove_from_registry`. + - `vault_get_config(state) -> VaultConfig`. + - `vault_update_config(state, input: VaultUpdateConfigInput)` — partial merge + `update_config`. + - `vault_list_notes(state) -> VaultListNotesOutput { paths }` — `list_supported_files`, filter to `.md`/`.markdown` only. + - `vault_read_note(state, input: VaultReadNoteInput { relativePath }) -> Option`. + - `vault_write_note(state, input: VaultWriteNoteInput { relativePath, frontmatter, content }) -> NoteOnDisk`. + - `vault_delete_note(state, input: VaultReadNoteInput)`. + - `vault_reveal(state)` — calls `commands::shell::reveal_in_finder_inner(¤t_path)`. + - `vault_reindex(_state) -> VaultReindexOutput { success: true, files_indexed: 0, duration: 0, deferred_until: "M7" }`. + - Update `commands/mod.rs` to declare `pub mod settings; pub mod vault; pub mod shell; pub mod dialog;`. + - Register every command in `lib.rs::run`'s `.invoke_handler(tauri::generate_handler![...])` block (per plan Step 11.3). + - Add minimal stubs to `commands/shell.rs` and `commands/dialog.rs` (signatures only, no real impl) so the macros find symbols and `cargo check` passes. Plan Step 11.4 supplies the exact stubs. + +- **Task 12 — `commands/shell.rs` + `commands/dialog.rs` + capabilities**: + - Replace `commands/shell.rs` stubs with real impl per plan Step 12.1: + - `shell_open_url(app, url)` — reject non-http(s) schemes via `AppError::Validation`, then `app.shell().open(url, None)`. + - `shell_open_path(app, path)` — reject non-absolute paths via `AppError::Validation`, reject non-existent via `AppError::NotFound`, then `app.shell().open(path, None)`. + - `shell_reveal_in_finder(path)` — reject non-absolute, then call `reveal_in_finder_inner(path)`. + - `pub(crate) fn reveal_in_finder_inner(path: &Path) -> AppResult<()>` — `#[cfg(target_os = "macos")]` runs `open -R `; non-mac branch returns `AppError::Validation("reveal in finder is macOS-only in v1")`. + - Replace `commands/dialog.rs` stubs with real impl per plan Step 12.2: + - `dialog_choose_folder(app, title) -> Option` — `app.dialog().file().pick_folder(...)` with `std::sync::mpsc` bridge. + - `dialog_choose_files(app, title, filters) -> Vec` — `pick_files(...)`. + - Register `tauri-plugin-dialog` in `lib.rs::run` per plan Step 12.3 (`.plugin(tauri_plugin_dialog::init())`). + - Update `capabilities/default.json` per plan Step 12.4 — add the dialog plugin's permission set (`dialog:default` or equivalent). + +### Methodology — verification-driven + +1. **Invoke `superpowers:using-superpowers`** first. TDD is not the methodology for this phase — the commands are thin wrappers and the underlying logic was tested in Phases A-D. Verification is via `cargo check`, `cargo clippy`, and Phase G's runtime smoke. +2. **Two commits — one per task.** +3. For Task 11: + - Step 11.1: paste plan's `commands/vault.rs` verbatim (lines ~3035–3395). It has 13 commands + 1 helper (`now_iso`) + 6 input/output structs. + - Step 11.2: rewrite `commands/mod.rs` to expose 4 modules. + - Step 11.3: register 23 commands in `lib.rs::run`'s `invoke_handler`. + - Step 11.4: stub `commands/shell.rs` and `commands/dialog.rs` per plan Step 11.4 (signatures + `Ok(())` bodies, plus `reveal_in_finder_inner` private helper). + - Step 11.5: `cargo check && cargo clippy -- -D warnings` → both pass. + - Step 11.6: commit `m3(commands): vault_* command surface (open/close/list/read/write/...) + shell/dialog stubs`. +4. For Task 12: + - Step 12.1: replace `commands/shell.rs` per plan (lines ~3522–3590). + - Step 12.2: replace `commands/dialog.rs` per plan (lines ~3593–3635). + - Step 12.3: add `.plugin(tauri_plugin_dialog::init())` to the Builder chain in `lib.rs::run`. + - Step 12.4: edit `capabilities/default.json` to grant dialog permissions. + - Step 12.5: `cargo check && cargo clippy -- -D warnings && cargo test --features test-helpers` → all pass; no new tests. + - Step 12.6: manual smoke — `pnpm dev`, click a button that triggers `dialog_choose_folder`, confirm picker opens. (Optional in Phase E; required in Phase G's manual smoke.) + - Step 12.7: commit `m3(commands): shell + dialog real impl + tauri-plugin-dialog wired`. + +### Critical gotchas + +1. **`vault_open` flow ordering matters:** + ``` + validate_dir → preferences::init_vault → set_indexing(true, 0) + → drop_existing_watcher → set_current → count_notes → upsert+touch_registry + → start_watcher → spawn_event_forwarder → set_indexing(false, 100) + → emit vault-status-changed → return success + ``` + Reordering breaks acceptance tests. Plan Step 11.1 lays this out exactly. Match step-by-step. +2. **Watcher slot lock scope:** Each `watcher_slot.lock()` runs in its own short block. **Never hold the lock across `await`** — Plan's impl uses `{ let mut slot = ...; *slot = Some(handle); }` then drops the guard before `tokio::spawn(...)`. Holding across the spawn would deadlock the next `vault_close` call. +3. **Event forwarder runs forever per vault open:** `tokio::spawn(async move { while let Some(event) = rx.recv().await { app_handle.emit(...); } })`. The receiver `rx` is held by the spawn task; when the watcher handle drops, `tx` drops, `rx.recv()` returns `None`, the loop exits. Don't add `if cancel { break }` — the channel close is the signal. +4. **`vault_switch` is just `vault_open`:** It exists for renderer ergonomics. Plan delegates it: `vault_switch(...) -> vault_open(state, app, VaultOpenInput { path: input.path }).await`. No additional logic. +5. **`vault_remove` close-if-current:** Compares `current.to_string_lossy()` to `input.path`. If match, `vault_close(state.clone(), app.clone()).await` first. **`State<'_, T>` is NOT clonable** — `state.clone()` would fail. The fix: pass the cloned `Arc` in via the dereference, OR re-fetch state in vault_close. Plan's impl uses `state.clone()` because `State<'_, T>` does have a `clone()` for the `tauri::State` smart-pointer — verify this compiles. If it doesn't, refactor to `vault_close_inner(state.vault.clone(), app.clone())` taking `Arc`. +6. **`vault_reindex` is a permanent stub for M3:** Returns `{ success: true, filesIndexed: 0, duration: 0, deferredUntil: "M7" }`. Phase G's `command:parity` audit must classify this as `deferred:M7`. Don't accidentally implement real reindex. +7. **`vault_reveal` reuses `shell::reveal_in_finder_inner`:** That helper is `pub(crate)` in `shell.rs` (Task 12). Phase E Task 11 stubs it returning `Ok(())` — Task 12 replaces it. The cross-module call is intentional; vault.rs depends on shell.rs. +8. **Stubs in Task 11 must match Task 12 signatures:** `shell_open_url(_url: String)` not `(_app, _url)`. `shell_open_path(_path: String)`. `shell_reveal_in_finder(_path: String)`. `dialog_choose_folder(_title: Option)`. `dialog_choose_files(_title: Option, _filters: Option>)`. Matching signatures keep the `generate_handler!` registration stable across the two commits. +9. **`reveal_in_finder_inner` cfg-gating:** `#[cfg(target_os = "macos")]` for the `open -R` impl. The `#[cfg(not(target_os = "macos"))]` branch returns `Err(AppError::Validation(...))`. Don't omit the non-mac branch — `cargo check --target x86_64-unknown-linux-gnu` would fail in CI. +10. **`tauri-plugin-dialog` registration order:** Plan Step 12.3 adds `.plugin(tauri_plugin_dialog::init())` to the Builder chain. `tauri-plugin-shell` was already registered in M2. Order in the chain is: `default()` → `.plugin(shell)` → `.plugin(dialog)` → `.invoke_handler(...)` → `.setup(...)`. Standard Tauri 2 chain ordering. +11. **Capabilities for dialog:** Tauri 2 plugins need explicit capability grants. Edit `capabilities/default.json` to add the dialog permission set. Schema: `"dialog:default"` typically grants `pick_folder` and `pick_files`. Run `pnpm --filter @memry/desktop-tauri capability:check` to verify. +12. **`shell_open_url` URL allowlist:** Plan's impl rejects non-http(s) via prefix check. Don't expand to `mailto:`, `tel:`, etc. — that bypasses the URL escape guard. If renderer needs other schemes, M8 lifecycle layer will add explicit support. +13. **`dialog_choose_*` blocking bridge:** The dialog API is callback-based; plan uses `std::sync::mpsc::channel()` to bridge to async. The `tx.send(...)` in the callback is sync; `rx.recv()` blocks the async runtime briefly. Acceptable because the user is interacting with the OS picker — the runtime will wait. **Don't replace with `tokio::sync::mpsc`** — the dialog callback runs on a non-Tokio thread. +14. **`commands/mod.rs` must come BEFORE `lib.rs` references:** Without `pub mod vault;` in `commands/mod.rs`, the `commands::vault::*` paths in `lib.rs` won't resolve. Step 11.2 is structural; don't skip. +15. **No new tests for vault commands in this phase:** Phase G adds `m3-vault-smoke.spec.ts` against the real Tauri runtime. Adding Rust integration tests for `vault_open` would require booting Tauri inside a test, which is non-trivial. Plan defers that to runtime e2e. + +### Constraints + +- **No scope creep:** Do not implement `memry-file://` protocol, drag-drop spike, bindings regen, mock-swap, or runtime e2e in Phase E. Phase F + Phase G handle them. +- **No modifications to vault::* modules:** `vault::fs`, `vault::frontmatter`, `vault::notes_io`, `vault::preferences`, `vault::registry`, `vault::state`, `vault::watcher` are FROZEN by Phase E. Adding a new public function there is scope creep — Phase E should only call existing surface. +- **No new commands beyond M3 deliverables list:** 13 vault + 3 shell + 2 dialog = 18 commands. Don't add `vault_create_folder`, `vault_rename_note`, etc. — those are M5. +- **No mock changes:** `apps/desktop-tauri/src/lib/ipc/mocks/vault.ts` stays frozen in Phase E. Phase G trims it after bindings regen. +- **`cargo clippy -- -D warnings`** clean at each task boundary. +- **No Specta type registration here:** Phase G's `bin/generate_bindings.rs` adds the `.typ::<...>()` calls. Phase E commands use Specta proc-macros (`#[specta::specta]`) but don't touch the binding generator. + +### Acceptance criteria (Phase E done when all pass) + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 + +# Files implemented +test -f apps/desktop-tauri/src-tauri/src/commands/vault.rs +test -f apps/desktop-tauri/src-tauri/src/commands/shell.rs +test -f apps/desktop-tauri/src-tauri/src/commands/dialog.rs +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/commands/vault.rs)" -gt 250 ] +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/commands/shell.rs)" -gt 30 ] +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/commands/dialog.rs)" -gt 30 ] + +# commands/mod.rs declares 4 modules +grep -q '^pub mod settings;' apps/desktop-tauri/src-tauri/src/commands/mod.rs +grep -q '^pub mod vault;' apps/desktop-tauri/src-tauri/src/commands/mod.rs +grep -q '^pub mod shell;' apps/desktop-tauri/src-tauri/src/commands/mod.rs +grep -q '^pub mod dialog;' apps/desktop-tauri/src-tauri/src/commands/mod.rs + +# 18 commands registered in lib.rs invoke_handler (settings 3 + vault 15 + shell 3 + dialog 2 = 23) +grep -c 'commands::vault::vault_' apps/desktop-tauri/src-tauri/src/lib.rs # expect ≥ 15 +grep -c 'commands::shell::shell_' apps/desktop-tauri/src-tauri/src/lib.rs # expect ≥ 3 +grep -c 'commands::dialog::dialog_' apps/desktop-tauri/src-tauri/src/lib.rs # expect ≥ 2 + +# tauri-plugin-dialog registered +grep -q 'tauri_plugin_dialog::init' apps/desktop-tauri/src-tauri/src/lib.rs + +# Capabilities updated +grep -q 'dialog' apps/desktop-tauri/src-tauri/capabilities/default.json + +# Rust hygiene +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +pnpm --filter @memry/desktop-tauri capability:check + +# All prior tests still green (no new tests in Phase E) +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~78 passed (no delta from Phase D) + +# Commits +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\(commands\)' # expect ≥ 2 + +# Electron / packages / specs / plans untouched +git diff --name-only main..HEAD -- apps/desktop/ apps/sync-server/ packages/ docs/superpowers/specs/ docs/superpowers/plans/ | wc -l +# expect 0 +``` + +### When done + +Report to user: + +``` +Phase E complete. +Tasks covered: 11, 12 +Commits: 2 (..) +Rust tests: 0 new (verification-driven phase) — total still ~78 passed +Verification: + - cargo check: clean + - cargo clippy -- -D warnings: clean + - cargo test --features test-helpers: 78 passed, 0 failed + - capability:check: clean + - 23 commands registered in lib.rs invoke_handler (15 vault + 3 shell + 2 dialog + 3 settings) + - tauri-plugin-dialog wired + - capabilities/default.json updated + - Electron/packages/specs/plans untouched: 0 files + +Next: Phase F — prompts/m3/m3-phase-f-protocol-and-dragdrop.md +Blockers: +``` + +If blocker: +- `cargo check` complains "no function `state.clone()`" → `tauri::State<'_, T>` does have `Clone`; if your version doesn't, refactor `vault_remove` to call `vault_close_inner(Arc, AppHandle)` instead of `vault_close(state, app)`. +- Specta complains about `frontmatter::NoteFrontmatter` → check that `vault/mod.rs` re-exports it (Phase D Step 9.2). Without the re-export, `commands::vault::vault.rs::use crate::vault::frontmatter::NoteFrontmatter;` works but `bindings.rs` Phase G `.typ::()` does too — both should work in parallel. +- `tauri::generate_handler!` complains about a missing command → check the macro list; you may have a typo. The list must EXACTLY match the function names in their respective modules. +- `capability:check` fails → the dialog permission identifier may have changed in the Tauri 2 plugin version. Check `apps/desktop-tauri/src-tauri/gen/schemas/desktop-schema.json` for the actual permission name. + +If still blocked: invoke `superpowers:systematic-debugging`. Report + wait for approval. + +### Ready + +1. Invoke `superpowers:using-superpowers`. +2. Read plan Tasks 11, 12 fully (lines ~3024–3735 of the plan file). +3. Run prerequisite verification. Report results. +4. Task 11: + - Paste `commands/vault.rs` verbatim from plan Step 11.1. + - Update `commands/mod.rs`. + - Register 23 commands in `lib.rs::run`. + - Stub `commands/shell.rs` and `commands/dialog.rs` per Step 11.4. + - `cargo check && cargo clippy -- -D warnings`. + - Commit `m3(commands): vault_* command surface (open/close/list/read/write/...) + shell/dialog stubs`. +5. Task 12: + - Replace `commands/shell.rs` with real impl. + - Replace `commands/dialog.rs` with real impl. + - Wire `tauri-plugin-dialog` in `lib.rs::run`. + - Update `capabilities/default.json`. + - `cargo check && cargo clippy -- -D warnings && cargo test --features test-helpers && capability:check`. + - Commit `m3(commands): shell + dialog real impl + tauri-plugin-dialog wired`. + +## PROMPT END diff --git a/prompts/m3/m3-phase-f-protocol-and-dragdrop.md b/prompts/m3/m3-phase-f-protocol-and-dragdrop.md new file mode 100644 index 000000000..14d6abc07 --- /dev/null +++ b/prompts/m3/m3-phase-f-protocol-and-dragdrop.md @@ -0,0 +1,247 @@ +# M3 Phase F — Protocol + Drag-Drop Spike (memry-file:// + drag-drop event) + +Temiz session prompt. Verification + manuel smoke driven phase. Bu phase Electron'un `memry-file://` ve `File.path` sürümlerinin Tauri replacement'ı. + +--- + +## PROMPT START + +You are implementing **Phase F of Milestone M3** for Memry's Electron→Tauri migration. This phase lands two desktop-app integrations that the spec calls out as M3 cross-cutting deliverables: (1) the `memry-file://` custom URI scheme protocol handler — a vault-allowlisted, byte-range-aware, missing-image-fallback file server that replaces Electron's `protocol.registerFileProtocol`; (2) a documented drag-drop path-resolution spike that proves Tauri 2 + macOS WebKit deliver real `/Users/...` paths on file drop, replacing Electron's `webUtils.getPathForFile`. + +### Context + +**Worktree:** `/Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3` +**Branch:** `m3/vault-fs-and-watcher` +**Plan:** `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` +**Spec:** `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` +**Prompts README:** `prompts/m3/README.md` + +Phase A-E landed deps + paths + fs/frontmatter/notes_io + preferences/registry + watcher + state/AppState + 23 commands (78 tests, 18 commands). Phase F adds the URI scheme protocol handler in `lib.rs::run` and subscribes to the main window's drag-drop event. The drag-drop spike emits a renderer console log so manual verification can prove paths come through. Phase G removes the spike telemetry once the smoke is documented in `scripts/drag-drop-smoke.md`. + +### Prerequisite verification + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git rev-parse --abbrev-ref HEAD # expect: m3/vault-fs-and-watcher + +# Phase E complete +test -f apps/desktop-tauri/src-tauri/src/commands/vault.rs +test -f apps/desktop-tauri/src-tauri/src/commands/shell.rs +test -f apps/desktop-tauri/src-tauri/src/commands/dialog.rs +[ "$(wc -l < apps/desktop-tauri/src-tauri/src/commands/vault.rs)" -gt 250 ] +grep -q 'tauri_plugin_dialog::init' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q 'commands::vault::vault_open' apps/desktop-tauri/src-tauri/src/lib.rs + +# Tests still green +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +pnpm --filter @memry/desktop-tauri capability:check +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~78 passed (no delta from Phase D; Phase E added no tests) + +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\(commands\)' # expect ≥ 2 + +# Scratch test vault has at least one image (we'll smoke it later) +ls ~/memry-test-vault-m3/attachments/images/ 2>/dev/null || mkdir -p ~/memry-test-vault-m3/attachments/images/ +``` + +If any fails, STOP. Phase E must complete before Phase F starts. + +### Your scope + +Execute **Tasks 13, 14** from the plan in this order. + +- **Task 13 — `memry-file://` URI scheme handler**: + - Step 13.1: update `tauri.conf.json` `app.security.csp` to add `memry-file:` to `default-src`, `img-src`, and `media-src`. Plan provides the exact CSP string. + - Step 13.2: add `urlencoding = "2.1"` to `Cargo.toml` `[dependencies]`. + - Step 13.3: implement the protocol handler in `lib.rs`: + - `.register_uri_scheme_protocol("memry-file", |ctx, request| { handle_memry_file(&ctx.app_handle().clone(), &request) })` — added to the Builder chain after `.plugin(tauri_plugin_dialog::init())`. + - `handle_memry_file(app, request)` function: parse `memry-file://local/` URL → percent-decode → canonicalize → check vault-root + app-data-dir allowlist → 403 if outside → read bytes → MIME-guess → handle `Range:` header for partial content (206) → return 200 with full bytes otherwise. + - `missing(path)` fallback: if the path looks like an image (.png/.jpg/.jpeg/.gif/.webp), return 200 + `image/png` + a 1×1 transparent PNG. Otherwise return 404. + - `parse_range(header_value, total)` helper. + - `base64_decode_static(input)` for the embedded 1×1 PNG. + - Step 13.4: create `apps/desktop-tauri/src/lib/memry-file.ts` with `toMemryFileUrl(absolutePath)` and `fromMemryFileUrl(url)` helpers. + - Step 13.5: manual smoke — drop a real image in the scratch vault, build dev mode, verify image loads, missing image returns 1×1 PNG, outside-vault path returns 403. + - Step 13.7: commit `m3(protocol): memry-file:// URI scheme with byte-range + missing-image fallback`. + +- **Task 14 — Drag-drop path-resolution spike**: + - Step 14.1: confirm `core:default` includes file-drop event grants. Inspect `gen/schemas/desktop-schema.json` for `dragDropEvent` / `drop` references. Add `core:webview:allow-on-drag-drop-event` to `capabilities/default.json` if absent. + - Step 14.2: subscribe to the main window's `WindowEvent::DragDrop` in `lib.rs::run`'s `.setup(...)` closure. On `DragDropEvent::Drop { paths, .. }`, emit `vault-drag-drop` event to the renderer with `Vec` (paths converted via `to_string_lossy().into_owned()`). + - Step 14.3: temporary spike telemetry in `apps/desktop-tauri/src/main.tsx` — `listen('vault-drag-drop', (event) => console.info('[drag-drop spike] paths:', event.payload))`. This block is REMOVED in Phase G Task 16.5; here we just add it. + - Step 14.4: manual smoke — `pnpm dev`, drag image/PDF/video into the window, verify dev console logs real `/Users/...` paths. If you see `webkit-fake-url://`, the smoke is 🔴 — document the fallback to `dialog_choose_files`. + - Step 14.5: create `apps/desktop-tauri/scripts/drag-drop-smoke.md` documenting the smoke, outcomes table, and last-verified date/version. + - Step 14.6: commit `m3(spike): drag-drop path resolution + documented fallback to dialog picker`. + +### Methodology — verification + manual smoke + +1. **Invoke `superpowers:using-superpowers`** first. No TDD this phase — protocol handlers and window events aren't unit-testable cleanly. +2. **Two commits — one per task.** +3. For Task 13: + - Step 13.1: edit `tauri.conf.json` CSP. + - Step 13.2: add `urlencoding` dep. + - Step 13.3: paste the protocol handler verbatim from plan (lines ~3762–3927). The handler is ~165 lines. Note that `register_uri_scheme_protocol`'s callback signature requires moving `app` into the closure carefully — the plan's exact pattern uses `let app = ctx.app_handle().clone();` then passes `&app` to `handle_memry_file`. + - Step 13.4: create `memry-file.ts` helper. + - Step 13.5: manual smoke with a real image drop test. + - Step 13.7: commit. +4. For Task 14: + - Step 14.1: capability inspection (read-only `grep`). + - Step 14.2: edit `lib.rs::run` to subscribe to drag-drop. Plan provides the exact closure pattern using `WindowEvent::DragDrop` + `DragDropEvent::Drop { paths, .. }`. + - Step 14.3: edit `main.tsx` — add the temporary `listen` call. + - Step 14.4: manual smoke. Capture the console output for the PR body. + - Step 14.5: write `scripts/drag-drop-smoke.md` per plan template. + - Step 14.6: commit. + +### Critical gotchas + +1. **CSP regression risk:** `tauri.conf.json::app.security.csp` already has the M2 baseline. Plan Step 13.1 modifies ONLY the `default-src`, `img-src`, and `media-src` directives — adding `memry-file:` to each. Other directives (`script-src`, `style-src`, `frame-src`, `font-src`, `worker-src`, `connect-src`) MUST be preserved verbatim. Re-read the plan's CSP string and diff against the current file before saving. +2. **`withGlobalTauri` doesn't matter for protocols:** Plan's draft mentions `"withGlobalTauri": false` but in Tauri 2 the URI scheme registration happens via `register_uri_scheme_protocol` in `lib.rs::run`, not in conf JSON. Plan corrects this in the same step ("wait, in Tauri 2 ..."). Don't add `withGlobalTauri` to conf. +3. **Path canonicalization is critical for the allowlist:** `dunce::canonicalize(&path)` BEFORE the `starts_with` allowlist check. Without canonicalization, `memry-file://local/Users/me/memry-test-vault-m3/../../../etc/passwd` would slip through the prefix check. The canonicalize resolves `..` and any symlinks first. +4. **Allowlist roots:** Vault root (from `state.vault.current_path()`) AND app-data dir (`directories::ProjectDirs::from(...).data_dir()`). The app-data inclusion is for future thumbnails / cached attachments. Don't drop it. +5. **`missing` fallback ONLY for images:** Plan's `missing` function checks the URL extension. `.png/.jpg/.jpeg/.gif/.webp` → 1×1 transparent PNG (so `` tags don't break-icon). Other types → 404. Don't expand to PDFs or video — those have proper UI fallbacks. +6. **`parse_range` saturating bounds:** Plan's impl uses `total.saturating_sub(1)` for the empty-end case (`Range: bytes=100-`). Don't use `total - 1` — would underflow if `total == 0`. +7. **`base64_decode_static` is hand-rolled:** Plan implements base64 inline because adding `base64` crate just for the 100-byte 1×1 PNG is overkill. Don't replace with `base64::decode(...)` — that's an unnecessary dep. +8. **`urlencoding` dep is needed:** Plan Step 13.3 uses `urlencoding::decode(p)` for percent-decoded paths. Without `urlencoding = "2.1"` in `Cargo.toml`, `cargo check` fails. Step 13.2 adds it. +9. **`memry-file.ts` `replace(/^\/+/, '')`:** The Tauri URI scheme handler reconstructs the absolute path as `/${decoded}`. So `toMemryFileUrl` strips leading slashes from the input first — `/Users/.../foo.png` → `Users/.../foo.png` → URL = `memry-file://local/Users/.../foo.png`. Don't double-slash. +10. **`encodeURI` not `encodeURIComponent`:** `toMemryFileUrl` uses `encodeURI` to preserve `/` in path segments. `encodeURIComponent` would percent-encode the slashes and break the path on the Rust side. +11. **`fromMemryFileUrl` decoder:** Mirrors `toMemryFileUrl` — strip the `memry-file://local/` prefix, `decodeURIComponent` the rest, prepend `/`. Used for renderer-side display logic if a route shows the original path. +12. **Drag-drop event signature:** Tauri 2 `WindowEvent::DragDrop` wraps `DragDropEvent` which has variants `Enter`, `Over`, `Drop`, `Leave`. The plan only handles `Drop { paths, .. }`. The `paths` field is `Vec`. The closure must `iter().map(|p| p.to_string_lossy().into_owned()).collect()` to produce `Vec` for the event payload — Tauri's `Emitter` serializes via Serde. +13. **`anyhow` dep already in Cargo.toml or not:** Plan Step 14.2's closure body uses `anyhow::anyhow!("main window missing")`. If `anyhow` isn't already a dep, add `anyhow = "1"`. Spike-only usage; remove if you can refactor without it (e.g., use `eyre` or just `Box::::from(...)` directly). Plan's note: "Add `anyhow = \"1\"` to `[dependencies]` if not already present." +14. **Drag-drop spike telemetry must be REMOVED in Phase G:** The `void listen('vault-drag-drop', ...)` block in `main.tsx` is a one-shot verification import. Phase G Task 16.5 deletes it. Phase F adds it; Phase G removes it. Don't preemptively delete it here. +15. **`scripts/drag-drop-smoke.md` is a **plain markdown** file:** Plan Step 14.5 supplies the exact template. Fill in `` and `` after running the smoke. Don't include the dev console image / video — markdown text only. +16. **Capability schema changes are surfaced in `desktop-schema.json`:** Plan Step 14.1 says inspect the schema for `dragDropEvent`. The Tauri 2 default capability set usually grants this; verify before adding. Adding redundant grants is fine but `capability:check` may flag duplicates. +17. **Manual smoke is REQUIRED, not optional:** Phase F's correctness depends on macOS-specific behaviors (FSEvents-rooted protocol handling, WebKit drop event delivery). Run the smokes; record output. Phase G's PR body includes the smoke results — don't skip. + +### Constraints + +- **No scope creep:** Do not regenerate Specta bindings, swap mock IPC, run the e2e suite, or open a PR in Phase F. Phase G handles all of that. +- **No vault module changes:** `vault::*` is FROZEN. Phase F only touches `lib.rs`, `tauri.conf.json`, `Cargo.toml`, `capabilities/default.json`, and renderer `main.tsx` + new `lib/memry-file.ts`. +- **No new commands:** The protocol handler is NOT a Tauri command — it's a URI scheme handler registered on the Builder. Don't `#[tauri::command]` it. +- **No behavior changes to existing commands:** `vault_open` may need to coexist with the protocol handler reading the current vault path; verify the lock semantics still hold (`state.vault.current_path()` is a brief lock, fine to call from the protocol thread). +- **No expanded CSP beyond `memry-file:`:** Don't add `https://*.cdn.com` or any third-party origin. The CSP narrowing is part of the security review. +- **No new tests:** Phase F adds no Rust integration tests. Manual smoke is the verification method; Phase G's runtime e2e covers `vault_*` paths but not the protocol handler. + +### Acceptance criteria (Phase F done when all pass) + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 + +# Files modified +grep -q 'memry-file:' apps/desktop-tauri/src-tauri/tauri.conf.json +grep -q 'register_uri_scheme_protocol' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q 'fn handle_memry_file' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q 'fn missing' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q 'fn parse_range' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q 'fn base64_decode_static' apps/desktop-tauri/src-tauri/src/lib.rs + +# urlencoding dep +grep -q '^urlencoding' apps/desktop-tauri/src-tauri/Cargo.toml + +# memry-file.ts helper +test -f apps/desktop-tauri/src/lib/memry-file.ts +grep -q 'toMemryFileUrl' apps/desktop-tauri/src/lib/memry-file.ts +grep -q 'fromMemryFileUrl' apps/desktop-tauri/src/lib/memry-file.ts + +# Drag-drop wiring +grep -q 'vault-drag-drop' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q 'DragDrop' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q "vault-drag-drop" apps/desktop-tauri/src/main.tsx +grep -q "drag-drop spike" apps/desktop-tauri/src/main.tsx + +# Spike documentation +test -f apps/desktop-tauri/scripts/drag-drop-smoke.md +[ "$(wc -l < apps/desktop-tauri/scripts/drag-drop-smoke.md)" -gt 5 ] + +# Rust hygiene +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +pnpm --filter @memry/desktop-tauri capability:check +pnpm --filter @memry/desktop-tauri typecheck + +# All prior tests still green +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~78 passed + +# Manual smoke evidence — capture in user report +# 1. Real image at `~/memry-test-vault-m3/attachments/images/test.png` loads via memry-file:// +# 2. Missing image at `notes/missing.png` returns 1×1 transparent PNG (no broken-image icon) +# 3. Outside-vault path `/etc/hosts` returns 403 +# 4. Drag image+pdf+mp4 from Finder into window — dev console logs real `/Users/...` paths + +# Commits +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\((protocol|spike)\)' # expect ≥ 2 + +# Electron / packages / specs / plans untouched +git diff --name-only main..HEAD -- apps/desktop/ apps/sync-server/ packages/ docs/superpowers/specs/ docs/superpowers/plans/ | wc -l +# expect 0 +``` + +### When done + +Report to user (include manual smoke results): + +``` +Phase F complete. +Tasks covered: 13, 14 +Commits: 2 (..) +Rust tests: 0 new — total still ~78 passed +Verification: + - cargo check: clean + - cargo clippy -- -D warnings: clean + - capability:check: clean + - typecheck: clean + - cargo test --features test-helpers: 78 passed, 0 failed + +Manual smoke (memry-file://): + - real image renders: 🟢 + - missing image → 1×1 PNG fallback: 🟢 + - outside-vault path → 403: 🟢 + +Manual smoke (drag-drop): + - drop image+pdf+mp4 from Finder → real paths: 🟢 (or 🔴 fallback documented) + +Documents: + - apps/desktop-tauri/scripts/drag-drop-smoke.md: ✓ filled with date and macOS + +Spike telemetry: + - main.tsx still has the temporary `listen('vault-drag-drop', ...)` block (Phase G Task 16.5 removes it) + +Electron/packages/specs/plans untouched: 0 files + +Next: Phase G — prompts/m3/m3-phase-g-bindings-renderer-bench-pr.md +Blockers: +``` + +If blocker: +- Image fails to load via `memry-file://` → check the CSP `img-src` includes `memry-file:`. Open DevTools > Network and verify the response status. 403 means allowlist mismatch — `state.vault.current_path()` returned `None` (vault not open) or the path is outside the canonicalized vault root. +- 1×1 PNG fallback fails → `base64_decode_static` returned wrong bytes. The hardcoded base64 string in plan Step 13.3's `missing` function decodes to a valid 67-byte transparent PNG. Verify by decoding manually: `echo "iVBORw0KG..." | base64 -d | file -` should report `PNG image data, 1 x 1`. +- Drop event delivers `webkit-fake-url://` instead of real paths → expected on older macOS WebKit; **don't fail Phase F**. Document the 🔴 outcome in `drag-drop-smoke.md` and Phase G's M8 file-import work uses `dialog_choose_files` as the canonical fallback (already implemented in Phase E). +- `register_uri_scheme_protocol` signature mismatch → Tauri 2's protocol handler closure signature changes between minor versions. The plan targets Tauri 2.10. Check `apps/desktop-tauri/src-tauri/Cargo.toml` for the Tauri version. If you're on a newer minor, the signature might be `|ctx, request, responder|` — in that case, call `responder.respond(response)` instead of returning the response. +- `capability:check` fails after drag-drop change → ensure `capabilities/default.json` keeps the JSON structure valid; missing comma is the usual culprit. + +If still blocked: invoke `superpowers:systematic-debugging`. Report + wait for approval. + +### Ready + +1. Invoke `superpowers:using-superpowers`. +2. Read plan Tasks 13, 14 fully (lines ~3735–4108 of the plan file). +3. Run prerequisite verification. Report results. +4. Task 13: + - Update `tauri.conf.json` CSP. + - Add `urlencoding` dep. + - Paste protocol handler in `lib.rs` (handle_memry_file + missing + parse_range + base64_decode_static). + - Add `register_uri_scheme_protocol("memry-file", ...)` to Builder chain. + - Create `apps/desktop-tauri/src/lib/memry-file.ts`. + - Manual smoke: real image renders, missing image returns 1×1 PNG, outside-vault returns 403. + - Commit `m3(protocol): memry-file:// URI scheme with byte-range + missing-image fallback`. +5. Task 14: + - Inspect/grant drag-drop capability. + - Subscribe to `WindowEvent::DragDrop` in `lib.rs::run::setup` and emit `vault-drag-drop` event. + - Add temporary `listen` block in `main.tsx`. + - Add `anyhow = "1"` if not present. + - Manual smoke: drop files into window, verify console output. + - Create `scripts/drag-drop-smoke.md` documenting outcomes table + verification date. + - Commit `m3(spike): drag-drop path resolution + documented fallback to dialog picker`. + +## PROMPT END diff --git a/prompts/m3/m3-phase-g-bindings-renderer-bench-pr.md b/prompts/m3/m3-phase-g-bindings-renderer-bench-pr.md new file mode 100644 index 000000000..3f39a2138 --- /dev/null +++ b/prompts/m3/m3-phase-g-bindings-renderer-bench-pr.md @@ -0,0 +1,286 @@ +# M3 Phase G — Bindings + Renderer Mock-Swap + Runtime e2e + Bench + PR + +Temiz session prompt. Bu phase M3'ün son halkası: Specta bindings regen, mock'tan gerçek invoke'a swap, runtime e2e smoke, 100-note bench, acceptance gate, PR open. M3 closure phase. + +--- + +## PROMPT START + +You are implementing **Phase G of Milestone M3** for Memry's Electron→Tauri migration. This is the closing phase: regenerate Specta TypeScript bindings to expose every M3 type and command, swap the renderer's mock IPC routes to real Rust for the 22 M3 commands (keeping `vault_reindex` and `vault_create` as documented deferrals), add a runtime e2e Playwright smoke that exercises `vault_open` / `vault_list_notes` / `vault_read_note` / `vault_write_note` end-to-end, write the 100-note `<500ms` performance bench, run the full acceptance gate, push the branch, and open the M3 PR. + +### Context + +**Worktree:** `/Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3` +**Branch:** `m3/vault-fs-and-watcher` +**Plan:** `docs/superpowers/plans/2026-04-26-m3-vault-fs-and-watcher.md` +**Spec:** `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md` +**Prompts README:** `prompts/m3/README.md` + +Phase A-F landed deps + paths + fs/frontmatter/notes_io + preferences/registry + watcher + state/AppState + 23 commands + memry-file:// + drag-drop spike (78 vault tests, 18 commands, 1 protocol handler). Phase G shows that work to the renderer, runs a runtime e2e smoke, proves the 100-note bench, removes the spike telemetry, and ships the PR. + +### Prerequisite verification + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git rev-parse --abbrev-ref HEAD # expect: m3/vault-fs-and-watcher + +# Phase F complete +grep -q 'register_uri_scheme_protocol' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q 'fn handle_memry_file' apps/desktop-tauri/src-tauri/src/lib.rs +grep -q 'vault-drag-drop' apps/desktop-tauri/src-tauri/src/lib.rs +test -f apps/desktop-tauri/src/lib/memry-file.ts +test -f apps/desktop-tauri/scripts/drag-drop-smoke.md +grep -q 'memry-file:' apps/desktop-tauri/src-tauri/tauri.conf.json +grep -q 'drag-drop spike' apps/desktop-tauri/src/main.tsx # spike telemetry still present + +# Tests still green +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +pnpm --filter @memry/desktop-tauri capability:check +pnpm --filter @memry/desktop-tauri typecheck +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~78 passed + +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\((deps|vault|commands|protocol|spike)\)' # expect ≥ 13 + +# Scratch test vault exists for runtime e2e (used by M3_TEST_VAULT_PATH) +test -d ~/memry-test-vault-m3 +``` + +If any fails, STOP. Phase F must complete before Phase G starts. + +### Your scope + +Execute **Tasks 15, 16** from the plan in this order. + +- **Task 15 — Bindings regen + mock-swap + renderer integration**: + - Step 15.1: extend `apps/desktop-tauri/src-tauri/src/bin/generate_bindings.rs` per plan. Add 23 commands to `collect_commands![...]` and 13+ vault `.typ::<...>()` calls. Plan's exact list lives at lines ~4126–4185. + - Step 15.2: regen + verify with `pnpm --filter @memry/desktop-tauri bindings:generate && pnpm --filter @memry/desktop-tauri bindings:check`. + - Step 15.3: extend `realCommands` Set in `apps/desktop-tauri/src/lib/ipc/invoke.ts` to include the 22 M3 commands (vault_open, vault_close, vault_get_status, vault_get_current, vault_get_all, vault_switch, vault_remove, vault_get_config, vault_update_config, vault_list_notes, vault_read_note, vault_write_note, vault_delete_note, vault_reveal, shell_open_url, shell_open_path, shell_reveal_in_finder, dialog_choose_folder, dialog_choose_files) **plus M2 carry-over** (settings_get/set/list). NOTE: `vault_reindex` stays mocked (M7 deferral). + - Step 15.4: trim `apps/desktop-tauri/src/lib/ipc/mocks/vault.ts` to keep ONLY `vault_reindex` (M7 stub) and `vault_create` (M5 onboarding deferral). Delete every other vault route. Update `mocks/index.ts` if it imports deleted route names. + - Step 15.5: verify `apps/desktop-tauri/src/services/vault-service.ts` shape — `createInvokeForwarder('vault')` maps `getStatus` → `vault_get_status`. Plan's note: Rust impls match the existing renderer expectations, so no service-layer change should be required. If `pnpm test` flags shape diff, adjust the forwarder mapping. + - Step 15.6: create `apps/desktop-tauri/e2e/specs/m3-vault-smoke.spec.ts` per plan — two runtime tests: open vault + list notes + read+write Turkish-character roundtrip. Use `M3_TEST_VAULT_PATH` env (defaults to `~/memry-test-vault-m3`). + - Step 15.7: run full check matrix (lint/typecheck/test/bindings:check/capability:check/port:audit/command:parity/cargo:check/cargo:clippy/cargo:test). + - Step 15.8: update `apps/desktop-tauri/scripts/command-parity-audit.ts` ledger to classify `vault_reindex` as `deferred:M7` and `vault_create` as `mocked:M5`. + - Step 15.9: commit `m3(renderer): swap vault_*/shell_*/dialog_* to real Rust + runtime e2e smoke`. + +- **Task 16 — Bench, acceptance gate, PR**: + - Step 16.1: write `apps/desktop-tauri/src-tauri/tests/vault_bench.rs` per plan — 100-note vault scan, `<500ms` assertion, release-build required. Register `[[test]] name = "vault_bench" required-features = ["test-helpers"]`. + - Step 16.2: run bench in release mode: `cargo test --release --features test-helpers --test vault_bench -- --nocapture`. Local Apple-silicon target `<80ms`; acceptance gate `<500ms`. + - Step 16.3: full final acceptance gate verification — every command from plan Step 16.3 exits 0. Runs lint, typecheck, test, bindings:check, capability:check, port:audit, command:parity, cargo:check, cargo:clippy, cargo:test, plus a manual cold-start smoke against the dev runtime. + - Step 16.4: count Rust tests — expect ~79 (28 M2 + 9 paths + 9 fs + 9 frontmatter + 5 notes_io + 8 prefs + 6 registry + 4 watcher + 1 bench). + - Step 16.5: REMOVE the drag-drop spike telemetry from `apps/desktop-tauri/src/main.tsx` (the `void listen('vault-drag-drop', ...)` block added in Phase F Task 14.3). Commit `m3(spike): remove drag-drop spike telemetry; smoke documented in scripts/drag-drop-smoke.md`. + - Step 16.6: push branch + open PR. Title: `m3: Vault FS + file watcher`. Body per plan template (lines ~4533–4585) — Summary, Acceptance gate (10 checkboxes), Carry-forward ledger, Test plan, Risk coverage. + - Step 16.7: do NOT merge. User owns the merge decision (typically via `/land-and-deploy` gstack skill). + +### Methodology — verification + atomic commits + +1. **Invoke `superpowers:using-superpowers`** first. For Step 16.1 (bench), invoke `superpowers:test-driven-development` because the bench IS the test (RED-GREEN — write failing assertion, measure, prove green). For Task 16.6 (PR open), optionally invoke `superpowers:finishing-a-development-branch`. +2. **Three commits expected:** Task 15 = 1 commit, Task 16 bench = 1 commit, Task 16 spike-removal = 1 commit. PR open = no new commit. +3. For Task 15: + - Step 15.1: edit `generate_bindings.rs` — add `use memry_desktop_tauri_lib::vault;` to imports, then extend `collect_commands![...]` macro with 23 entries and `.typ::<...>()` chain with 13+ vault types. Plan's exact listing is your source of truth. + - Step 15.2: regen → `pnpm bindings:generate`. Inspect `apps/desktop-tauri/src/generated/bindings.ts` — every M3 type and command should appear. + - Step 15.3-15.4: edit `invoke.ts` (real-commands Set) and `mocks/vault.ts` (trim). + - Step 15.5: run `pnpm test` to verify service layer. + - Step 15.6: write the e2e spec. + - Step 15.7: run the full check matrix. Fix any failure before commit. + - Step 15.8: update parity ledger. + - Step 15.9: ONE atomic commit covering bindings + invoke.ts + mocks + service + e2e + parity-audit. The commit message hints at scope: "swap ... to real Rust + runtime e2e smoke". +4. For Task 16: + - Step 16.1-16.2: bench RED-GREEN. Write the test → run → assertion either passes or you have a real perf problem. Plan provides the exact 100-note seed loop. + - Step 16.3: final gate — every command must exit 0. ANY failure = stop, fix, re-run. + - Step 16.4: spot-check test counts. + - Step 16.5: remove spike telemetry from `main.tsx`. Commit. + - Step 16.6: push + PR. Use `gh pr create` with HEREDOC body. Do NOT merge. + +### Critical gotchas + +1. **`generate_bindings.rs` Specta type ordering:** Order matters for diff stability. Plan lists types alphabetically by module path: `db::*` first (carry-over from M2), then `vault::*`. Don't reorder; bindings.ts diff would be unnecessarily noisy. +2. **`bindings:check` is the post-regen guard:** It re-runs the generator and diffs against the committed `bindings.ts`. If the generator output differs (e.g., a Specta proc-macro changed a field name), `bindings:check` fails. The fix is `pnpm bindings:generate` again, then commit the regenerated file. +3. **`realCommands` Set ordering:** TypeScript Sets preserve insertion order. Plan's order matches the bindings file ordering — keep it for diff stability. The `realCommands` Set is consulted at runtime to decide whether to invoke the real backend or the mock. +4. **`vault_reindex` is NOT in `realCommands`:** It stays mocked because the Rust impl is just a stub returning `{ deferredUntil: 'M7' }`. If you accidentally add it to `realCommands`, the renderer's settings UI will see the stubbed payload instead of the mock's no-op success — both are fine in M3 but the parity ledger expects mocked. +5. **`vault_create` legacy mock:** The Rust side has no `vault_create` command. The renderer's onboarding flow uses `dialog_choose_folder` + `vault_open` directly. The `vault_create` mock route stays alive ONLY because the legacy onboarding component still references it; M5 removes the component during the notes-CRUD refactor. Phase G must NOT add a Rust `vault_create` — the parity ledger flags it as `mocked:M5`. +6. **`mocks/index.ts` imports may break after trimming:** When you delete `vault_open` etc. from `mocks/vault.ts`, the named export from `mocks/index.ts` (if it re-exports) loses those names. Run `pnpm typecheck` after trim — TypeScript will surface every dangling import. Fix them by removing the re-exports or routing through the now-trimmed module. +7. **Service forwarder shape match:** `vault-service.ts` uses `createInvokeForwarder('vault')` which builds command names from the method names (`getStatus` → `vault_get_status`). The Rust commands match — but the forwarder might pass args under different keys. Plan Step 15.5: don't preemptively change anything; run `pnpm test` and only adjust if a failure surfaces. +8. **`m3-vault-smoke.spec.ts` is runtime-lane:** It requires the Tauri dev runtime, not the M1 mock-lane Vite WebKit harness. Plan Step 15.6 says "If `playwright.config.ts` does not yet have a runtime-lane target, follow the comment in `e2e/playwright.config.ts` that documents the harness — for M3 it is acceptable to gate the suite behind `M3_TEST_VAULT_PATH` so CI does not run it without a vault checkout." Read the existing config; if there's no runtime lane, the M3 spec runs as a gated dev-only check. +9. **`M3_TEST_VAULT_PATH` env:** Defaults to `~/memry-test-vault-m3`. The README created this directory in pre-flight. The e2e spec uses `process.env.M3_TEST_VAULT_PATH ?? \`${process.env.HOME}/memry-test-vault-m3\`` so dev runs without env work. +10. **Bench must run with `--release`:** Plan's bench fails with `<500ms` assertion in debug mode (5–10× slower). The exact command: `cargo test --release --features test-helpers --test vault_bench -- --nocapture`. Don't run without `--release` and report flakes — that's a false failure. +11. **Bench seeding bottleneck:** The 100-note seed loop is sequential because `notes_io::write_note_to_disk` holds the path lock. On dev hardware this takes ~2s; on CI maybe 5s. The MEASUREMENT (`list_supported_files`) is what's bench'd, not the seed. Don't `tokio::join_all!` the seed — sequential is intentional for bench reproducibility. +12. **`port:audit` must stay clean:** No new `electron-log` imports, no `from 'electron'`, no `@electron`. Phase G's renderer changes (invoke.ts edits, mock trim, service tweak) shouldn't introduce any. `pnpm port:audit` exits 0 means clean. +13. **`command:parity` must classify all 25+ commands:** 3 settings (M2 real) + 15 vault (M3 real except 2 deferred) + 3 shell (M3 real) + 2 dialog (M3 real) + ~10 mocked-only feature domains from M1. Plan Step 15.8 adds two ledger entries. `pnpm command:parity` exits 0 with no unclassified renderer calls. +14. **PR title convention:** `m3: Vault FS + file watcher` — matches conventional commit style (`: `). The PR body uses HEREDOC for proper markdown formatting. +15. **PR test plan checklist:** Per repo's CLAUDE.md ship workflow, every PR needs a Test plan section with bulleted markdown checklist. Plan's body template (lines ~4566–4577) provides the exact wording — use it verbatim via HEREDOC. +16. **No merge, no force-push, no branch delete:** User's global CLAUDE.md forbids unprompted destructive ops. Push + open PR = done. Wait for user. Use `/land-and-deploy` (gstack skill) is the user's preferred merge path. +17. **Spike telemetry removal commit is small:** Step 16.5's commit touches only `main.tsx`. Don't bundle it with bench or PR-prep edits — atomic per change. + +### Constraints + +- **No new vault commands:** The 18-command surface is locked from Phase E. Don't add `vault_init` or similar in Phase G. +- **No mock additions:** Mock surface SHRINKS in Phase G (trim `mocks/vault.ts`). Don't add new routes. +- **No new tests beyond `vault_bench.rs`:** The 78 vault tests + 1 bench = 79. Don't add unit tests for the bench code itself. +- **No service-layer refactor:** Keep `vault-service.ts` minimal — it's a thin forwarder. M5 will refactor the service layer when notes CRUD lands. +- **No PR merge:** Open the PR. Wait for user review. +- **No CI-config changes:** The runtime e2e lane is gated by env var; CI without the vault checkout is unaffected. +- **No CSP loosening:** The CSP from Phase F stays as-is. Don't add hosts to fix the e2e spec — the spec runs against the dev runtime, not a remote URL. + +### Acceptance criteria (Phase G done when all pass) + +```bash +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 + +# Bindings regenerated +grep -q 'vault_open' apps/desktop-tauri/src/generated/bindings.ts +grep -q 'NoteFrontmatter' apps/desktop-tauri/src/generated/bindings.ts +grep -q 'VaultStatus' apps/desktop-tauri/src/generated/bindings.ts +grep -q 'VaultEvent' apps/desktop-tauri/src/generated/bindings.ts + +# Real commands set updated +grep -q "'vault_open'" apps/desktop-tauri/src/lib/ipc/invoke.ts +grep -q "'vault_list_notes'" apps/desktop-tauri/src/lib/ipc/invoke.ts +grep -q "'shell_open_url'" apps/desktop-tauri/src/lib/ipc/invoke.ts +grep -q "'dialog_choose_folder'" apps/desktop-tauri/src/lib/ipc/invoke.ts + +# vault_reindex stays mocked +! grep -q "'vault_reindex'" apps/desktop-tauri/src/lib/ipc/invoke.ts +grep -q 'vault_reindex' apps/desktop-tauri/src/lib/ipc/mocks/vault.ts + +# vault_create stays mocked +! grep -q "'vault_create'" apps/desktop-tauri/src/lib/ipc/invoke.ts +grep -q 'vault_create' apps/desktop-tauri/src/lib/ipc/mocks/vault.ts + +# Mock vault.ts trimmed (M3 routes deleted) +! grep -q "vault_open: async" apps/desktop-tauri/src/lib/ipc/mocks/vault.ts +! grep -q "vault_list_notes: async" apps/desktop-tauri/src/lib/ipc/mocks/vault.ts + +# E2E spec exists +test -f apps/desktop-tauri/e2e/specs/m3-vault-smoke.spec.ts +grep -q 'M3_TEST_VAULT_PATH' apps/desktop-tauri/e2e/specs/m3-vault-smoke.spec.ts + +# Bench exists + passes in release +test -f apps/desktop-tauri/src-tauri/tests/vault_bench.rs +grep -q 'name = "vault_bench"' apps/desktop-tauri/src-tauri/Cargo.toml +cd apps/desktop-tauri/src-tauri && cargo test --release --features test-helpers --test vault_bench 2>&1 | tail -3 +# expect: passes (under 500ms) + +# Spike telemetry REMOVED +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +! grep -q 'drag-drop spike' apps/desktop-tauri/src/main.tsx +! grep -q "listen\\('vault-drag-drop'" apps/desktop-tauri/src/main.tsx + +# Parity audit ledger updated +grep -q "vault_reindex" apps/desktop-tauri/scripts/command-parity-audit.ts +grep -q "vault_create" apps/desktop-tauri/scripts/command-parity-audit.ts +grep -q "deferred" apps/desktop-tauri/scripts/command-parity-audit.ts +grep -q "M7" apps/desktop-tauri/scripts/command-parity-audit.ts +grep -q "M5" apps/desktop-tauri/scripts/command-parity-audit.ts + +# Full final gate +pnpm --filter @memry/desktop-tauri lint +pnpm --filter @memry/desktop-tauri typecheck +pnpm --filter @memry/desktop-tauri test +pnpm --filter @memry/desktop-tauri bindings:check +pnpm --filter @memry/desktop-tauri capability:check +pnpm --filter @memry/desktop-tauri port:audit +pnpm --filter @memry/desktop-tauri command:parity +pnpm --filter @memry/desktop-tauri cargo:check +pnpm --filter @memry/desktop-tauri cargo:clippy +cd apps/desktop-tauri/src-tauri && cargo test --features test-helpers 2>&1 | tail -3 +# expect: ~79 passed (78 prior + 1 bench) +cd apps/desktop-tauri/src-tauri && cargo test --release --features test-helpers --test vault_bench 2>&1 | tail -3 +# expect: 1 passed (bench under 500ms) + +# Commits in Phase G +cd /Users/h4yfans/sideproject/memry-worktrees/spike-tauri-m3 +git log --oneline | grep -cE 'm3\((renderer|bench|spike)\)' # expect ≥ 3 (renderer + bench + spike-removal) + +# Branch pushed +git ls-remote --heads origin m3/vault-fs-and-watcher | grep -q m3/vault-fs-and-watcher + +# PR open +gh pr view m3/vault-fs-and-watcher --json number,title,state 2>/dev/null +# Expect: state OPEN, title "m3: Vault FS + file watcher" + +# Electron / packages / specs / plans untouched +git diff --name-only main..HEAD -- apps/desktop/ apps/sync-server/ packages/ docs/superpowers/specs/ docs/superpowers/plans/ | wc -l +# expect 0 +``` + +### When done + +Report to user: + +``` +Phase G complete — M3 shipped. +Tasks covered: 15, 16 +Commits (Phase G): 3 (..) +Commits (M3 total): +Rust tests: ~79 passed (78 vault + 1 bench) +TS tests: passed +Bench: 100-note vault scan ms (acceptance gate <500ms; local Apple-silicon target <80ms) + +Renderer integration: + - 22 commands swapped from mock to real Rust + - vault_reindex deferred (M7) — mocked path returns { deferredUntil: 'M7' } + - vault_create deferred (M5) — legacy onboarding helper retained + - Bindings regenerated with 13+ M3 types + - Runtime e2e smoke (m3-vault-smoke.spec.ts) — open + list + Turkish-roundtrip + +Manual smoke history: + - memry-file:// real image: 🟢 (Phase F) + - memry-file:// 1×1 PNG fallback: 🟢 + - memry-file:// 403 outside vault: 🟢 + - drag-drop real paths: 🟢 (or 🔴 fallback documented) + +Cleanup: + - Drag-drop spike telemetry removed from main.tsx + - scripts/drag-drop-smoke.md documents the smoke history + +Branch: m3/vault-fs-and-watcher pushed to origin +PR: + +M3 milestone: ready for user review and merge. +Next: M4 plan authoring — invoke superpowers:writing-plans with spec §M4 (Crypto + Keychain + Auth). + +Blockers: +``` + +If acceptance fails at any check: + +1. Do not push a broken branch. +2. Diagnose: + - **`bindings:check` fails** → `pnpm bindings:generate` then commit the result. + - **`port:audit` fails** → grep for the violating import; remove or replace. + - **`command:parity` fails with "unclassified renderer call"** → either add the command to a parity ledger entry or remove the renderer call. + - **`cargo test --release --test vault_bench` exceeds 500ms** → check `--release` flag was set. Profile with `cargo flamegraph`. The most likely cause is the `dunce::canonicalize` re-run inside `list_supported_files` — Plan's impl canonicalizes once at entry. Don't optimize away the canonicalize without re-checking the path-safety contract. + - **e2e spec hangs** → Tauri dev runtime not up; check `playwright.config.ts` for the launch command. The runtime lane needs `tauri dev` running. +3. Report finding + fix plan, wait for approval before pushing PR. + +If still blocked: invoke `superpowers:systematic-debugging` and `superpowers:finishing-a-development-branch`. Report + wait for approval. + +### Ready + +1. Invoke `superpowers:using-superpowers`. Optionally `superpowers:test-driven-development` for the bench, `superpowers:finishing-a-development-branch` when reaching Step 16.6. +2. Read plan Tasks 15, 16 fully (lines ~4112–4630 of the plan file). +3. Run prerequisite verification. Report results. +4. Task 15: + - Extend `generate_bindings.rs` (commands + types). + - `pnpm bindings:generate && pnpm bindings:check`. + - Update `realCommands` in `invoke.ts`. + - Trim `mocks/vault.ts` (keep `vault_reindex` + `vault_create` only). + - Verify `vault-service.ts` shape (test if needed). + - Write `e2e/specs/m3-vault-smoke.spec.ts`. + - Update `scripts/command-parity-audit.ts` ledger. + - Run full check matrix (lint/typecheck/test/bindings:check/capability:check/port:audit/command:parity/cargo:check/cargo:clippy/cargo:test). + - Commit `m3(renderer): swap vault_*/shell_*/dialog_* to real Rust + runtime e2e smoke`. +5. Task 16: + - Write `tests/vault_bench.rs` (100-note scan with `<500ms` assertion). + - Run `cargo test --release --features test-helpers --test vault_bench` → green. + - Commit `m3(bench): 100-note vault scan <500ms acceptance test`. + - Run final acceptance gate matrix. Manual cold-start smoke. + - Remove spike telemetry from `main.tsx`. Commit `m3(spike): remove drag-drop spike telemetry; smoke documented in scripts/drag-drop-smoke.md`. + - `git push -u origin m3/vault-fs-and-watcher`. + - `gh pr create --title 'm3: Vault FS + file watcher'` with body per plan template. + - DO NOT MERGE. Report PR URL to user. + +## PROMPT END