Files
ChatApp/apps/desktop/src/lib/messageCache.ts
T

277 lines
8.3 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.
//
// 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<string | null> | null = null;
async function getHandle(): Promise<string | null> {
if (loadPromise) return loadPromise;
if (!isTauriRuntime()) {
loadPromise = Promise.resolve(null);
return loadPromise;
}
loadPromise = (async (): Promise<string | null> => {
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);',
);
await execute(
handle,
`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
);`,
);
// 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<void> {
await window.electronAPI.sqlExecute({ handle, query, bindings: bindings ?? [] });
}
async function selectRows<T = Row>(
handle: string,
query: string,
bindings?: unknown[],
): Promise<T[]> {
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<DecryptedMessage[]> {
const handle = await getHandle();
if (!handle) return [];
try {
const rows = await selectRows<Row>(
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<void> {
const handle = await getHandle();
if (!handle || messages.length === 0) return;
try {
for (const m of messages) {
// Skip rows we couldn't decrypt this round (transient race or stuck
// legacy conv-key version). Persisting `plaintext = null` would
// permanently poison the cold-start hydrate path: next launch would
// load the null version from disk and render "Nachricht nicht lesbar"
// even though the next `refresh()` could still decrypt the row.
// Legitimately-deleted rows carry an empty string payload, not null —
// those still persist correctly.
if (m.plaintext === null) continue;
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<void> {
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<DecryptedMessage[]> {
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<Row>(
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<void> {
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);
}
}