Files
bitvid/js/storage/dmDb.js
T

592 lines
16 KiB
JavaScript

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;
}
}