import { devLogger, userLogger } from "../utils/logger.js"; const DB_NAME = "bitvidDm"; const DB_VERSION = 2; const STORES = Object.freeze({ CONVERSATIONS: "conversations", MESSAGES: "messages", CONTACTS: "contacts", RELAYS: "relays", SEEN_CACHE: "seen_cache", }); const MIGRATIONS = [ { version: 1, migrate: (db) => { if (!db.objectStoreNames.contains(STORES.CONVERSATIONS)) { const store = db.createObjectStore(STORES.CONVERSATIONS, { keyPath: "conversation_id", }); store.createIndex("created_at", "created_at", { unique: false }); store.createIndex("unseen_count", "unseen_count", { unique: false }); } if (!db.objectStoreNames.contains(STORES.MESSAGES)) { const store = db.createObjectStore(STORES.MESSAGES, { keyPath: "id" }); store.createIndex("conversation_id", "conversation_id", { unique: false, }); store.createIndex("created_at", "created_at", { unique: false }); store.createIndex("sender_pubkey", "sender_pubkey", { unique: false, }); store.createIndex("conversation_created_at", [ "conversation_id", "created_at", ]); } if (!db.objectStoreNames.contains(STORES.CONTACTS)) { db.createObjectStore(STORES.CONTACTS, { keyPath: "pubkey" }); } if (!db.objectStoreNames.contains(STORES.RELAYS)) { db.createObjectStore(STORES.RELAYS, { keyPath: "url" }); } if (!db.objectStoreNames.contains(STORES.SEEN_CACHE)) { const store = db.createObjectStore(STORES.SEEN_CACHE, { keyPath: "id", }); store.createIndex("conversation_id", "conversation_id", { unique: false, }); store.createIndex("created_at", "created_at", { unique: false }); } }, }, { version: 2, migrate: (db, transaction) => { if (!db.objectStoreNames.contains(STORES.CONVERSATIONS)) { return; } // If transaction is provided (during upgrade), use it. // Otherwise create a new one (but this path is questionable during upgrade). const store = transaction ? transaction.objectStore(STORES.CONVERSATIONS) : db.createObjectStore(STORES.CONVERSATIONS, { keyPath: "conversation_id" }); // Fallback: If store doesn't exist and no transaction provided (unlikely in upgrade), create it? // Actually, if transaction is missing in migration, we are in trouble. // But for safe-guarding against "store not found": if (!store.indexNames.contains("opened_until")) { store.createIndex("opened_until", "opened_until", { unique: false }); } if (!store.indexNames.contains("downloaded_until")) { store.createIndex("downloaded_until", "downloaded_until", { unique: false, }); } }, }, ]; let dbInstance = null; let dbPromise = null; function isIndexedDbAvailable() { try { return typeof indexedDB !== "undefined"; } catch (error) { return false; } } function applyMigrations(db, oldVersion, newVersion, transaction) { for (const migration of MIGRATIONS) { if (migration.version > oldVersion && migration.version <= newVersion) { migration.migrate(db, transaction); } } } function openDmDb() { if (dbInstance) { return Promise.resolve(dbInstance); } if (dbPromise) { return dbPromise; } dbPromise = new Promise((resolve, reject) => { if (!isIndexedDbAvailable()) { resolve(null); return; } const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = (event) => { const db = request.result; const oldVersion = event.oldVersion ?? 0; const newVersion = event.newVersion ?? DB_VERSION; const upgradeTransaction = request.transaction || event.target?.transaction || null; applyMigrations(db, oldVersion, newVersion, upgradeTransaction); }; request.onsuccess = () => { const db = request.result; dbInstance = db; db.onversionchange = () => { db.close(); dbInstance = null; dbPromise = null; }; db.onclose = () => { dbInstance = null; dbPromise = null; }; resolve(db); }; request.onerror = () => { dbPromise = null; reject(request.error || new Error("Failed to open DM database.")); }; }); return dbPromise; } export async function closeDmDb() { if (dbInstance) { dbInstance.close(); dbInstance = null; } dbPromise = null; } function sanitizeTimestamp(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { return 0; } return Math.floor(numeric); } function normalizePubkey(pubkey) { if (typeof pubkey !== "string") { return ""; } return pubkey.trim().toLowerCase(); } function normalizeMessage(raw) { if (!raw || typeof raw !== "object") { return null; } const id = typeof raw.id === "string" ? raw.id.trim() : ""; const conversationId = typeof raw.conversation_id === "string" ? raw.conversation_id.trim() : typeof raw.conversationId === "string" ? raw.conversationId.trim() : ""; const senderPubkey = normalizePubkey( raw.sender_pubkey ?? raw.senderPubkey ?? raw.pubkey, ); if (!id || !conversationId || !senderPubkey) { return null; } const receiverPubkey = normalizePubkey( raw.receiver_pubkey ?? raw.receiverPubkey ?? raw.to_pubkey, ); const statusRaw = typeof raw.status === "string" ? raw.status.trim() : ""; const status = statusRaw === "pending" || statusRaw === "published" || statusRaw === "failed" ? statusRaw : ""; const seen = raw.seen === true; const encryptionScheme = typeof raw.encryption_scheme === "string" ? raw.encryption_scheme.trim() : typeof raw.encryptionScheme === "string" ? raw.encryptionScheme.trim() : typeof raw.scheme === "string" ? raw.scheme.trim() : ""; return { id, conversation_id: conversationId, sender_pubkey: senderPubkey, receiver_pubkey: receiverPubkey, created_at: sanitizeTimestamp(raw.created_at ?? raw.createdAt), kind: Number.isFinite(Number(raw.kind)) ? Number(raw.kind) : 0, content: typeof raw.content === "string" ? raw.content : "", tags: Array.isArray(raw.tags) ? raw.tags : [], relay: typeof raw.relay === "string" ? raw.relay : "", status, seen, encryption_scheme: encryptionScheme, }; } function normalizeConversation(raw) { if (!raw || typeof raw !== "object") { return null; } const conversationId = typeof raw.conversation_id === "string" ? raw.conversation_id.trim() : typeof raw.conversationId === "string" ? raw.conversationId.trim() : ""; if (!conversationId) { return null; } return { conversation_id: conversationId, created_at: sanitizeTimestamp(raw.created_at ?? raw.createdAt), last_message_at: sanitizeTimestamp( raw.last_message_at ?? raw.lastMessageAt, ), downloaded_until: sanitizeTimestamp( raw.downloaded_until ?? raw.downloadedUntil, ), opened_until: sanitizeTimestamp(raw.opened_until ?? raw.openedUntil), last_message_preview: typeof raw.last_message_preview === "string" ? raw.last_message_preview : typeof raw.lastMessagePreview === "string" ? raw.lastMessagePreview : "", unseen_count: Number.isFinite(Number(raw.unseen_count)) ? Number(raw.unseen_count) : Number.isFinite(Number(raw.unseenCount)) ? Number(raw.unseenCount) : 0, participants: Array.isArray(raw.participants) ? raw.participants : [], }; } function runRequest(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function finalizeTransaction(tx) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error || new Error("Transaction aborted")); }); } export async function writeConversationMetadata(conversation) { const normalized = normalizeConversation(conversation); if (!normalized) { return null; } try { const db = await openDmDb(); if (!db) { return null; } const tx = db.transaction(STORES.CONVERSATIONS, "readwrite"); tx.objectStore(STORES.CONVERSATIONS).put(normalized); await finalizeTransaction(tx); // db.close(); // Keep connection open return normalized; } catch (error) { userLogger.warn("[dmDb] Failed to write conversation metadata", error); return null; } } export async function writeMessages(messages) { const list = Array.isArray(messages) ? messages : [messages]; const normalized = list .map((message) => normalizeMessage(message)) .filter(Boolean); if (normalized.length === 0) { return []; } try { const db = await openDmDb(); if (!db) { return []; } const tx = db.transaction(STORES.MESSAGES, "readwrite"); const store = tx.objectStore(STORES.MESSAGES); for (const message of normalized) { store.put(message); } await finalizeTransaction(tx); // db.close(); // Keep connection open return normalized; } catch (error) { userLogger.warn("[dmDb] Failed to persist messages", error); return []; } } export async function getConversation(conversationId) { if (typeof conversationId !== "string" || !conversationId.trim()) { return null; } try { const db = await openDmDb(); if (!db) { return null; } const tx = db.transaction(STORES.CONVERSATIONS, "readonly"); const store = tx.objectStore(STORES.CONVERSATIONS); const result = await runRequest(store.get(conversationId.trim())); await finalizeTransaction(tx); // db.close(); // Keep connection open return result || null; } catch (error) { userLogger.warn("[dmDb] Failed to read conversation metadata", error); return null; } } export async function listMessagesByConversation(conversationId, options = {}) { if (typeof conversationId !== "string" || !conversationId.trim()) { return []; } const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0 ? Number(options.limit) : 50; const direction = options.direction === "asc" ? "next" : "prev"; try { const db = await openDmDb(); if (!db) { return []; } const tx = db.transaction(STORES.MESSAGES, "readonly"); const store = tx.objectStore(STORES.MESSAGES); const index = store.index("conversation_created_at"); const range = IDBKeyRange.bound( [conversationId.trim(), 0], [conversationId.trim(), Number.MAX_SAFE_INTEGER], ); const results = []; const request = index.openCursor(range, direction); return await new Promise((resolve) => { request.onsuccess = () => { const cursor = request.result; if (!cursor || results.length >= limit) { resolve(results); return; } results.push(cursor.value); cursor.continue(); }; request.onerror = () => { userLogger.warn("[dmDb] Failed to list messages", request.error); resolve(results); }; }); // removed finally db.close() } catch (error) { userLogger.warn("[dmDb] Failed to list messages", error); return []; } } export async function updateConversationFromMessage(message, options = {}) { const normalized = normalizeMessage(message); if (!normalized) { return null; } const preview = typeof options.preview === "string" && options.preview.trim() ? options.preview.trim() : normalized.content.slice(0, 160); const unseenDelta = Number.isFinite(Number(options.unseenDelta)) ? Number(options.unseenDelta) : 0; const downloadedUntil = sanitizeTimestamp( options.downloadedUntil ?? normalized.created_at, ); const openedUntil = sanitizeTimestamp(options.openedUntil); try { const db = await openDmDb(); if (!db) { return null; } const tx = db.transaction(STORES.CONVERSATIONS, "readwrite"); const store = tx.objectStore(STORES.CONVERSATIONS); const existing = (await runRequest( store.get(normalized.conversation_id), )) || { conversation_id: normalized.conversation_id, created_at: normalized.created_at, unseen_count: 0, }; const next = { ...existing, created_at: existing.created_at || normalized.created_at, last_message_at: normalized.created_at, downloaded_until: Math.max( existing.downloaded_until || 0, downloadedUntil, ), opened_until: Math.max(existing.opened_until || 0, openedUntil), last_message_preview: preview, unseen_count: Math.max( 0, Number(existing.unseen_count || 0) + unseenDelta, ), }; store.put(next); await finalizeTransaction(tx); // db.close(); // Keep connection open return next; } catch (error) { userLogger.warn("[dmDb] Failed to update conversation metadata", error); return null; } } export async function updateConversationOpenState( conversationId, { openedUntil, unseenCount } = {}, ) { if (typeof conversationId !== "string" || !conversationId.trim()) { return null; } const normalizedId = conversationId.trim(); const nextOpenedUntil = sanitizeTimestamp(openedUntil); const nextUnseenCount = Number.isFinite(Number(unseenCount)) ? Math.max(0, Number(unseenCount)) : null; try { const db = await openDmDb(); if (!db) { return null; } const tx = db.transaction(STORES.CONVERSATIONS, "readwrite"); const store = tx.objectStore(STORES.CONVERSATIONS); const existing = (await runRequest(store.get(normalizedId))) || normalizeConversation({ conversation_id: normalizedId }); if (!existing) { return null; } const next = { ...existing, opened_until: Math.max(existing.opened_until || 0, nextOpenedUntil), unseen_count: nextUnseenCount === null ? existing.unseen_count : nextUnseenCount, }; store.put(next); await finalizeTransaction(tx); // db.close(); // Keep connection open return next; } catch (error) { userLogger.warn("[dmDb] Failed to update conversation open state", error); return null; } } export async function listConversations() { try { const db = await openDmDb(); if (!db) { return []; } const tx = db.transaction(STORES.CONVERSATIONS, "readonly"); const store = tx.objectStore(STORES.CONVERSATIONS); const results = []; const request = store.openCursor(); return await new Promise((resolve) => { request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(results); return; } results.push(cursor.value); cursor.continue(); }; request.onerror = () => { userLogger.warn("[dmDb] Failed to list conversations", request.error); resolve(results); }; }); // removed finally db.close() } catch (error) { userLogger.warn("[dmDb] Failed to list conversations", error); return []; } } export async function clearDmDb() { try { const db = await openDmDb(); if (!db) { return false; } const tx = db.transaction( [ STORES.CONVERSATIONS, STORES.MESSAGES, STORES.CONTACTS, STORES.RELAYS, STORES.SEEN_CACHE, ], "readwrite", ); for (const storeName of tx.objectStoreNames) { tx.objectStore(storeName).clear(); } await finalizeTransaction(tx); // db.close(); // Keep connection open return true; } catch (error) { devLogger.warn("[dmDb] Failed to clear DM database", error); return false; } }