Files
ChatApp/apps/desktop/src/lib/messageCache.ts
T
byGalax 1303c8e26f feat: backup/restore, user profile popover, image compress, video blur, wake lock
- backup/restore dialog + user profile popover components
- image compression, video blur, wake lock utilities
- message cache + conversation messages hook refinements
- call context, active speakers, screen share dialog tweaks
- audio + screen share settings persistence
- refreshed app icons (smaller sizes) across all platforms
2026-04-21 12:11:09 +02:00

269 lines
8.8 KiB
TypeScript

// 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: <T>(sql: string, values?: unknown[]) => Promise<T[]>;
close: () => Promise<boolean>;
}
interface DatabaseStatic {
load: (path: string) => Promise<Database>;
}
const DB_NAME = 'chatapp-cache.db';
let dbPromise: Promise<Database | null> | null = null;
async function loadDb(): Promise<Database | null> {
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<Database | null> {
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<DecryptedMessage[]> {
const db = await getDb();
if (!db) return [];
try {
const rows = await db.select<Row>(
'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<void> {
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<void> {
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<DecryptedMessage[]> {
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<Row>(
`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<void> {
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);
}
}