// Composer-draft persistence. Two-tier semantics: // * In-memory `Map` for instant synchronous reads on // mount (mirrors the `messageMemoryCache` pattern from Phase 7). // * SQLite (`composer_drafts` table, schema in `messageCache.ts`) for // cross-restart persistence. Writes are debounced and fire-and-forget // — losing the last 400ms of typing on a hard crash is acceptable; // blocking the keystroke handler is not. // // Attachments are intentionally NOT serialized: // * Files don't round-trip through SQLite cleanly (binary blobs blow // up the cache size). // * `replyToId` IS persisted; the consuming page looks up the actual // message by id at render time. import { isTauriRuntime } from './globalShortcut'; const DB_NAME = 'chatapp-cache'; const WRITE_DEBOUNCE_MS = 400; interface Draft { text: string; replyToId: string | null; } interface DraftRow { conversation_id: string; text: string; reply_to_id: string | null; updated_at: string; } const drafts = new Map(); const pendingWrites = new Map>(); let handlePromise: Promise | null = null; async function getHandle(): Promise { if (handlePromise) return handlePromise; if (!isTauriRuntime()) { handlePromise = Promise.resolve(null); return handlePromise; } handlePromise = (async () => { try { const handle = await window.electronAPI.sqlLoad({ name: DB_NAME }); // Self-contained DDL — the same statement also runs from // `messageCache.ts`'s init path, but we don't want to depend on // call order. SQLite's `CREATE TABLE IF NOT EXISTS` is idempotent // so the double-creation is safe. await window.electronAPI.sqlExecute({ handle, query: `CREATE TABLE IF NOT EXISTS composer_drafts ( conversation_id TEXT PRIMARY KEY, text TEXT NOT NULL, reply_to_id TEXT, updated_at TEXT NOT NULL )`, bindings: [], }); return handle; } catch (err: unknown) { console.warn('composerDraftStore: sqlLoad failed', err); return null; } })(); return handlePromise; } export function getDraftSync(conversationId: string): Draft | null { const stored = drafts.get(conversationId); if (!stored) return null; return { text: stored.text, replyToId: stored.replyToId }; } export function hasDraft(conversationId: string): boolean { return drafts.has(conversationId); } export function setDraft(conversationId: string, draft: Draft): void { if (draft.text.length === 0 && draft.replyToId === null) { if (drafts.has(conversationId)) { drafts.delete(conversationId); scheduleWrite(conversationId); } return; } drafts.set(conversationId, { text: draft.text, replyToId: draft.replyToId }); scheduleWrite(conversationId); } export function clearDraft(conversationId: string): void { if (!drafts.has(conversationId)) return; drafts.delete(conversationId); scheduleWrite(conversationId); } function scheduleWrite(conversationId: string): void { const existing = pendingWrites.get(conversationId); if (existing) clearTimeout(existing); const timer = setTimeout(() => { pendingWrites.delete(conversationId); void flushOne(conversationId); }, WRITE_DEBOUNCE_MS); pendingWrites.set(conversationId, timer); } async function flushOne(conversationId: string): Promise { const handle = await getHandle(); if (!handle) return; const draft = drafts.get(conversationId); try { if (draft) { await window.electronAPI.sqlExecute({ handle, query: `INSERT INTO composer_drafts (conversation_id, text, reply_to_id, updated_at) VALUES ($1, $2, $3, $4) ON CONFLICT(conversation_id) DO UPDATE SET text = excluded.text, reply_to_id = excluded.reply_to_id, updated_at = excluded.updated_at`, bindings: [conversationId, draft.text, draft.replyToId, new Date().toISOString()], }); } else { await window.electronAPI.sqlExecute({ handle, query: 'DELETE FROM composer_drafts WHERE conversation_id = $1', bindings: [conversationId], }); } } catch (err: unknown) { console.warn('composerDraftStore: flush failed', err); } } export async function hydrateDrafts(): Promise { const handle = await getHandle(); if (!handle) return; try { const rows = (await window.electronAPI.sqlSelect({ handle, query: 'SELECT conversation_id, text, reply_to_id, updated_at FROM composer_drafts', bindings: [], })) as unknown as DraftRow[]; for (const r of rows) { if (!r.conversation_id || typeof r.text !== 'string') continue; if (r.text.length === 0 && r.reply_to_id === null) continue; drafts.set(r.conversation_id, { text: r.text, replyToId: r.reply_to_id }); } } catch (err: unknown) { console.warn('composerDraftStore: hydrate failed', err); } } export function __resetForTests(): void { for (const t of pendingWrites.values()) clearTimeout(t); pendingWrites.clear(); drafts.clear(); handlePromise = null; }