// Local SQLite message cache. Persists decrypted messages so startup + // conversation-switch hydrate from disk instantly instead of waiting on a // network round-trip + batch decrypt. // // The cache is per-device-local (app-local-data dir, same trust boundary as // the device private key). Plaintext is stored because the threat model // already assumes local-disk access means compromise — same as the existing // outbox + secret store. Ciphertext + nonce are kept alongside so a future // key-rotation migration can re-derive plaintext when a new bundle arrives. import type { DecryptedMessage } from '@chat-app/shared/chat'; import { isTauriRuntime } from './globalShortcut'; interface Database { execute: (sql: string, values?: unknown[]) => Promise<{ rowsAffected?: number }>; select: (sql: string, values?: unknown[]) => Promise; close: () => Promise; } interface DatabaseStatic { load: (path: string) => Promise; } const DB_NAME = 'chatapp-cache.db'; let dbPromise: Promise | null = null; async function loadDb(): Promise { if (!isTauriRuntime()) return null; try { // Dynamic import so browser-preview builds don't choke on the tauri // plugin module. Vite will statically analyse this + split it into a // chunk that only loads inside Tauri. const mod = (await import('@tauri-apps/plugin-sql')) as { default: DatabaseStatic; Database?: DatabaseStatic; }; const DatabaseCtor: DatabaseStatic = mod.default ?? (mod as { Database: DatabaseStatic }).Database; const db = await DatabaseCtor.load('sqlite:' + DB_NAME); await db.execute(` CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, sender_id TEXT NOT NULL, sender_device_id TEXT, reply_to_id TEXT, edited_at TEXT, deleted_at TEXT, created_at TEXT NOT NULL, plaintext TEXT ); `); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);', ); // FTS5 virtual table for instant full-text search across the cache. // Keeps only the searchable text columns (plaintext + sender), keyed // by the message id so we can join back to the main row. Triggers // mirror inserts/updates/deletes so the index never drifts. // // Falls back gracefully on FTS5-less builds: the IF NOT EXISTS keeps // the call idempotent, and the surrounding try/catch already handles // a CREATE failure by skipping the whole cache. try { await db.execute( `CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( message_id UNINDEXED, conversation_id UNINDEXED, plaintext, tokenize = 'unicode61 remove_diacritics 2' );`, ); await db.execute( `CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts (message_id, conversation_id, plaintext) VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, '')); END;`, ); await db.execute( `CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN DELETE FROM messages_fts WHERE message_id = old.id; END;`, ); await db.execute( `CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN DELETE FROM messages_fts WHERE message_id = old.id; INSERT INTO messages_fts (message_id, conversation_id, plaintext) VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, '')); END;`, ); } catch (err: unknown) { console.warn('FTS5 init failed — search falls back to in-memory scan', err); } return db; } catch (err: unknown) { console.warn('messageCache init failed — falling back to memory-only', err); return null; } } function getDb(): Promise { if (!dbPromise) dbPromise = loadDb(); return dbPromise; } // --------------------------------------------------------------------------- // Public API — all calls are no-ops when the cache isn't available (browser // preview, init error, etc.), so callers never need to guard. // --------------------------------------------------------------------------- interface Row { id: string; conversation_id: string; sender_id: string; sender_device_id: string | null; reply_to_id: string | null; edited_at: string | null; deleted_at: string | null; created_at: string; plaintext: string | null; } export async function loadCachedMessages( conversationId: string, limit = 500, ): Promise { const db = await getDb(); if (!db) return []; try { const rows = await db.select( 'SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2', [conversationId, limit], ); return rows .reverse() .map((r) => ({ id: r.id, conversationId: r.conversation_id, senderId: r.sender_id, senderDeviceId: r.sender_device_id, replyToId: r.reply_to_id, editedAt: r.edited_at, deletedAt: r.deleted_at, createdAt: r.created_at, plaintext: r.plaintext, })); } catch (err: unknown) { console.warn('loadCachedMessages failed', err); return []; } } export async function persistMessages( conversationId: string, messages: DecryptedMessage[], ): Promise { const db = await getDb(); if (!db || messages.length === 0) return; try { // Replace strategy: each message is keyed by id so upsert is "INSERT OR // REPLACE". Attachments + forward chains are part of `plaintext` JSON so // nothing lives outside this table. for (const m of messages) { await db.execute( `INSERT OR REPLACE INTO messages (id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, plaintext) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ m.id, conversationId, m.senderId, m.senderDeviceId, m.replyToId, m.editedAt, m.deletedAt, m.createdAt, m.plaintext, ], ); } } catch (err: unknown) { console.warn('persistMessages failed', err); } } export async function deleteCachedMessage(id: string): Promise { const db = await getDb(); if (!db) return; try { await db.execute('DELETE FROM messages WHERE id = $1', [id]); } catch (err: unknown) { console.warn('deleteCachedMessage failed', err); } } // Full-text search across cached messages. Returns rows in newest-first // order; caller maps to DecryptedMessage. Empty result on cache-miss or // FTS5 not available — caller should fall back to in-memory regex. export async function searchCachedMessages( conversationId: string, query: string, limit = 200, ): Promise { const db = await getDb(); if (!db) return []; const trimmed = query.trim(); if (trimmed.length === 0) return []; // Sanitize for FTS5 MATCH syntax. Strip quotes + control chars; wrap in // an OR over each token with a trailing wildcard so partial words match. // Hyphens + colons are FTS5 operators so we drop them. const tokens = trimmed .replace(/["'\u0000-\u001f]/g, ' ') .replace(/[-:^]/g, ' ') .split(/\s+/) .filter(Boolean); if (tokens.length === 0) return []; const matchExpr = tokens.map((t) => '"' + t.replace(/"/g, '') + '"*').join(' AND '); try { const rows = await db.select( `SELECT m.* FROM messages m JOIN messages_fts f ON f.message_id = m.id WHERE f.conversation_id = $1 AND messages_fts MATCH $2 ORDER BY m.created_at DESC LIMIT $3`, [conversationId, matchExpr, limit], ); return rows.map((r) => ({ id: r.id, conversationId: r.conversation_id, senderId: r.sender_id, senderDeviceId: r.sender_device_id, replyToId: r.reply_to_id, editedAt: r.edited_at, deletedAt: r.deleted_at, createdAt: r.created_at, plaintext: r.plaintext, })); } catch (err: unknown) { console.warn('searchCachedMessages failed', err); return []; } } // Housekeeping — run once per session to bound the cache size. Keeps the // latest KEEP_PER_CONV messages per conversation. const KEEP_PER_CONV = 1000; export async function pruneCache(): Promise { const db = await getDb(); if (!db) return; try { await db.execute( `DELETE FROM messages WHERE id IN ( SELECT id FROM messages m WHERE ( SELECT COUNT(*) FROM messages m2 WHERE m2.conversation_id = m.conversation_id AND m2.created_at > m.created_at ) >= $1 )`, [KEEP_PER_CONV], ); } catch (err: unknown) { console.warn('pruneCache failed', err); } }