Exocore-Backend / dist /services /dmStore.js
ChoruYt's picture
Upload 48 files
8b74df2 verified
Raw
History Blame Contribute Delete
7.54 kB
"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;
}