"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.convKey = convKey; exports.initDmStore = initDmStore; exports.isDmReady = isDmReady; exports.sendMessage = sendMessage; exports.deleteMessage = deleteMessage; exports.getConversation = getConversation; exports.listConversations = listConversations; exports.countUnread = countUnread; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const crypto_1 = __importDefault(require("crypto")); const secureStore_1 = require("./secureStore"); /** * Sharded encrypted DM store. * * Layout (local-db/): * dm.enc → shard 0 (conversations 0–N) * dm1.enc → shard 1 * dm2.enc → shard 2 … (infinite) * * Each shard holds up to DM_SHARD_SIZE conversation records. * Each conversation holds up to DM_MSG_LIMIT messages (ring-buffer — oldest * are evicted when the limit is reached, preventing unbounded growth). * * Conversation key is always the two usernames sorted and joined with ":", * e.g. "@alice:@bob" — canonical regardless of who sends first. */ const LOCAL_DIR = path_1.default.join(__dirname, "../../local-db"); const DM_SHARD_SIZE = 100; // max conversations per shard file const DM_MSG_LIMIT = 200; // max messages stored per conversation // ── in-memory cache ───────────────────────────────────────────────────────── const convCache = new Map(); let dmReady = false; let dmSecure = null; function ensureDir() { if (!fs_1.default.existsSync(LOCAL_DIR)) fs_1.default.mkdirSync(LOCAL_DIR, { recursive: true }); } function getSecure() { if (!dmSecure) { ensureDir(); dmSecure = new secureStore_1.SecureStore(LOCAL_DIR); } return dmSecure; } function shardPath(n) { return n === 0 ? path_1.default.join(LOCAL_DIR, "dm.enc") : path_1.default.join(LOCAL_DIR, `dm${n}.enc`); } function convKey(a, b) { return [a, b].sort().join(":"); } // ── persist ────────────────────────────────────────────────────────────────── function persistSync() { try { ensureDir(); const sec = getSecure(); const all = Array.from(convCache.entries()) .sort(([, a], [, b]) => b.updatedAt - a.updatedAt); // newest first const totalShards = Math.max(1, Math.ceil(all.length / DM_SHARD_SIZE)); for (let i = 0; i < totalShards; i++) { const chunk = all.slice(i * DM_SHARD_SIZE, (i + 1) * DM_SHARD_SIZE); const obj = {}; for (const [k, v] of chunk) obj[k] = v; const blob = sec.encrypt(JSON.stringify(obj)); fs_1.default.writeFileSync(shardPath(i), blob, { mode: 0o600 }); } // Remove stale shards if count shrank. for (let i = totalShards; i < 10_000; i++) { const stale = shardPath(i); if (fs_1.default.existsSync(stale)) fs_1.default.unlinkSync(stale); else break; } } catch (e) { console.error("[dm] persist failed:", e?.message); } } // ── load ───────────────────────────────────────────────────────────────────── function loadSync() { try { ensureDir(); const sec = getSecure(); convCache.clear(); for (let i = 0; i < 10_000; i++) { const f = shardPath(i); if (!fs_1.default.existsSync(f)) break; try { const blob = fs_1.default.readFileSync(f); const obj = JSON.parse(sec.decrypt(blob)); for (const [k, v] of Object.entries(obj)) { convCache.set(k, v); } } catch (e) { console.error(`[dm] shard ${i} load failed:`, e?.message); break; } } dmReady = true; const shardCount = Math.ceil(convCache.size / DM_SHARD_SIZE) || 1; console.log(`[dm] loaded ${convCache.size} conversations (${shardCount} shard(s))`); } catch (e) { console.error("[dm] load failed:", e?.message); dmReady = true; // still mark ready so the API doesn't block forever } } function initDmStore() { loadSync(); } function isDmReady() { return dmReady; } // ── public API ──────────────────────────────────────────────────────────────── /** Get or create a conversation between two users. */ function getOrCreate(a, b) { const key = convKey(a, b); if (!convCache.has(key)) { convCache.set(key, { key, participants: [a, b].sort(), messages: [], updatedAt: Date.now(), }); } return convCache.get(key); } /** Send a message from `from` to `to`. Returns the new message. */ function sendMessage(from, to, text) { const conv = getOrCreate(from, to); const msg = { id: crypto_1.default.randomUUID(), from, text: text.slice(0, 4000), // cap message length ts: Date.now(), }; conv.messages.push(msg); // Ring buffer — evict oldest messages beyond the limit. if (conv.messages.length > DM_MSG_LIMIT) { conv.messages.splice(0, conv.messages.length - DM_MSG_LIMIT); } conv.updatedAt = msg.ts; persistSync(); return msg; } /** Delete a message (soft-delete). Only the sender may delete. */ function deleteMessage(requester, partnerOrKey, msgId) { const key = partnerOrKey.includes(":") ? partnerOrKey : convKey(requester, partnerOrKey); const conv = convCache.get(key); if (!conv) return false; const msg = conv.messages.find(m => m.id === msgId); if (!msg || msg.from !== requester) return false; msg.deleted = true; msg.text = ""; conv.updatedAt = Date.now(); persistSync(); return true; } /** Return the conversation between two users (or null if none yet). */ function getConversation(a, b) { return convCache.get(convKey(a, b)) ?? null; } /** * Return all conversations where `username` is a participant, * sorted by most recent. Optionally limit the result count. */ function listConversations(username, limit = 100) { const results = []; for (const conv of convCache.values()) { if (conv.participants.includes(username)) results.push(conv); } return results .sort((a, b) => b.updatedAt - a.updatedAt) .slice(0, limit); } /** Count unread messages for a user (messages from others since `since` ms). */ function countUnread(username, since) { let count = 0; for (const conv of convCache.values()) { if (!conv.participants.includes(username)) continue; for (const m of conv.messages) { if (m.from !== username && m.ts > since && !m.deleted) count++; } } return count; }