121 lines
3.9 KiB
TypeScript
121 lines
3.9 KiB
TypeScript
/*
|
|
* Veskslop daemon — per-channel message cache in a tmp folder with a size limit.
|
|
*
|
|
* The cache backs conversation context. It keeps the newest messages per
|
|
* channel and evicts the least recently written channel files when the folder
|
|
* exceeds the size limit.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { compareSnowflakes, type CachedMessage } from "./communication.js";
|
|
|
|
export const CACHE_DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
const MAX_MESSAGES_PER_CHANNEL = 200;
|
|
|
|
interface ChannelCacheFile {
|
|
messages: CachedMessage[];
|
|
}
|
|
|
|
export class MessageCache {
|
|
private readonly dir: string;
|
|
private readonly maxBytes: number;
|
|
|
|
constructor(
|
|
dir = process.env.VESKSLOP_CACHE_DIR ?? join(tmpdir(), "veskslop-cache"),
|
|
maxBytes = Number(process.env.VESKSLOP_CACHE_MAX_BYTES ?? CACHE_DEFAULT_MAX_BYTES)
|
|
) {
|
|
this.dir = dir;
|
|
this.maxBytes = maxBytes;
|
|
mkdirSync(this.dir, { recursive: true });
|
|
}
|
|
|
|
get(channelId: string): CachedMessage[] {
|
|
const file = this.file(channelId);
|
|
if (!existsSync(file)) return [];
|
|
|
|
try {
|
|
return (JSON.parse(readFileSync(file, "utf8")) as ChannelCacheFile).messages;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
newestId(channelId: string): string | undefined {
|
|
return this.get(channelId).at(-1)?.id;
|
|
}
|
|
|
|
/** Merge messages by ID, keep the newest, write, then evict old files. */
|
|
merge(channelId: string, incoming: readonly CachedMessage[]): CachedMessage[] {
|
|
const byId = new Map<string, CachedMessage>();
|
|
for (const message of [...this.get(channelId), ...incoming]) byId.set(message.id, message);
|
|
|
|
const merged = [...byId.values()]
|
|
.sort((a, b) => compareSnowflakes(a.id, b.id))
|
|
.slice(-MAX_MESSAGES_PER_CHANNEL);
|
|
|
|
this.write(channelId, merged);
|
|
this.evict();
|
|
return merged;
|
|
}
|
|
|
|
/** Update cached content after an edit. */
|
|
edit(channelId: string, messageId: string, content: string): boolean {
|
|
const messages = this.get(channelId);
|
|
const message = messages.find(item => item.id === messageId);
|
|
if (!message) return false;
|
|
|
|
message.content = content;
|
|
this.write(channelId, messages);
|
|
return true;
|
|
}
|
|
|
|
/** Drop a deleted message from the cache. */
|
|
remove(channelId: string, messageId: string): boolean {
|
|
const messages = this.get(channelId);
|
|
const next = messages.filter(item => item.id !== messageId);
|
|
if (next.length === messages.length) return false;
|
|
|
|
this.write(channelId, next);
|
|
return true;
|
|
}
|
|
|
|
private file(channelId: string): string {
|
|
return join(this.dir, `${channelId}.json`);
|
|
}
|
|
|
|
private write(channelId: string, messages: CachedMessage[]) {
|
|
try {
|
|
writeFileSync(this.file(channelId), JSON.stringify({ messages } satisfies ChannelCacheFile));
|
|
} catch (error) {
|
|
console.error("[cache] write failed:", error);
|
|
}
|
|
}
|
|
|
|
private evict() {
|
|
try {
|
|
const files = readdirSync(this.dir)
|
|
.filter(name => name.endsWith(".json"))
|
|
.map(name => {
|
|
const path = join(this.dir, name);
|
|
const stats = statSync(path);
|
|
return { path, size: stats.size, mtime: stats.mtimeMs };
|
|
});
|
|
|
|
let total = files.reduce((sum, file) => sum + file.size, 0);
|
|
if (total <= this.maxBytes) return;
|
|
|
|
files.sort((a, b) => a.mtime - b.mtime);
|
|
for (const file of files) {
|
|
if (total <= this.maxBytes) break;
|
|
rmSync(file.path, { force: true });
|
|
total -= file.size;
|
|
}
|
|
} catch (error) {
|
|
console.error("[cache] eviction failed:", error);
|
|
}
|
|
}
|
|
}
|