mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 11:22:25 +00:00
Add IndexedDB storage for DM data
This commit is contained in:
1 parent
474071e84a
commit
f4672a0445
1 file changed
+419
@@ -0,0 +1,419 @@
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const DB_NAME = "bitvidDm";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function isIndexedDbAvailable() {
|
||||
try {
|
||||
return typeof indexedDB !== "undefined";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMigrations(db, oldVersion, newVersion) {
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (migration.version > oldVersion && migration.version <= newVersion) {
|
||||
migration.migrate(db);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openDmDb() {
|
||||
return 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;
|
||||
applyMigrations(db, oldVersion, newVersion);
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () =>
|
||||
reject(request.error || new Error("Failed to open DM database."));
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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 : "",
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
};
|
||||
});
|
||||
} 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;
|
||||
|
||||
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,
|
||||
last_message_preview: preview,
|
||||
unseen_count: Math.max(
|
||||
0,
|
||||
Number(existing.unseen_count || 0) + unseenDelta,
|
||||
),
|
||||
};
|
||||
|
||||
store.put(next);
|
||||
await finalizeTransaction(tx);
|
||||
return next;
|
||||
} catch (error) {
|
||||
userLogger.warn("[dmDb] Failed to update conversation metadata", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeDmDbSchema() {
|
||||
return {
|
||||
name: DB_NAME,
|
||||
version: DB_VERSION,
|
||||
stores: STORES,
|
||||
migrations: MIGRATIONS.map((migration) => migration.version),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
return true;
|
||||
} catch (error) {
|
||||
devLogger.warn("[dmDb] Failed to clear DM database", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const dmDbStores = STORES;
|
||||
export const dmDbVersion = DB_VERSION;
|
||||
Reference in new issue
Block a user