// 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. // // Under Electron all SQL goes through the main-process sql bridge // (better-sqlite3). Public API below is identical to the Tauri-era // version — callers don't change. import type { DecryptedMessage } from '@chat-app/shared/chat'; import { isTauriRuntime } from './globalShortcut'; const DB_NAME = 'chatapp-cache'; let loadPromise: Promise | null = null; async function getHandle(): Promise { if (loadPromise) return loadPromise; if (!isTauriRuntime()) { loadPromise = Promise.resolve(null); return loadPromise; } loadPromise = (async (): Promise => { try { const handle = await window.electronAPI.sqlLoad({ name: DB_NAME }); await execute( handle, `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 execute( handle, 'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);', ); // FTS5 — conditional since not every SQLite build has it. try { await execute( handle, `CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( message_id UNINDEXED, conversation_id UNINDEXED, plaintext, tokenize = 'unicode61 remove_diacritics 2' );`, ); await execute( handle, `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 execute( handle, `CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN DELETE FROM messages_fts WHERE message_id = old.id; END;`, ); await execute( handle, `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 handle; } catch (err: unknown) { console.warn('messageCache init failed — falling back to memory-only', err); return null; } })(); return loadPromise; } async function execute(handle: string, query: string, bindings?: unknown[]): Promise { await window.electronAPI.sqlExecute({ handle, query, bindings: bindings ?? [] }); } async function selectRows( handle: string, query: string, bindings?: unknown[], ): Promise { const rows = await window.electronAPI.sqlSelect({ handle, query, bindings: bindings ?? [], }); return rows as unknown as T[]; } // --------------------------------------------------------------------------- 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 handle = await getHandle(); if (!handle) return []; try { const rows = await selectRows( handle, '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 handle = await getHandle(); if (!handle || messages.length === 0) return; try { for (const m of messages) { await execute( handle, `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 handle = await getHandle(); if (!handle) return; try { await execute(handle, 'DELETE FROM messages WHERE id = $1', [id]); } catch (err: unknown) { console.warn('deleteCachedMessage failed', err); } } // Full-text search across cached messages. Empty result on cache-miss // or FTS5 unavailable — caller should fall back to in-memory regex. export async function searchCachedMessages( conversationId: string, query: string, limit = 200, ): Promise { const handle = await getHandle(); if (!handle) return []; const trimmed = query.trim(); if (trimmed.length === 0) return []; const tokens = trimmed .replace(/["'-]/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 selectRows( handle, `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 — bound the cache size. const KEEP_PER_CONV = 1000; export async function pruneCache(): Promise { const handle = await getHandle(); if (!handle) return; try { await execute( handle, `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); } }