import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], cursor: null }); function normalizeState(value) { if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE); const sessions = {}; if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) { for (const [key, sessionId] of Object.entries(value.sessions)) { if (typeof key === 'string' && key && typeof sessionId === 'string' && sessionId) { sessions[key] = sessionId; } } } return { version: 1, sessions, seenMessageIds: Array.isArray(value.seenMessageIds) ? value.seenMessageIds.filter((id) => typeof id === 'string' && id).slice(-1_000) : [], cursor: Number.isSafeInteger(value.cursor) && value.cursor >= 0 ? value.cursor : null, }; } export class ConversationStateStore { #path; #state = structuredClone(EMPTY_STATE); #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8'))); } catch (error) { if (error?.code !== 'ENOENT') throw error; this.#state = structuredClone(EMPTY_STATE); await this.#persist(); } return this; } sessionFor(key) { return this.#state.sessions[key] ?? null; } async setSession(key, sessionId) { this.#state.sessions[key] = sessionId; await this.#persist(); } async clearSession(key) { delete this.#state.sessions[key]; await this.#persist(); } async clearSessions() { this.#state.sessions = {}; await this.#persist(); } hasSeen(messageId) { return this.#state.seenMessageIds.includes(messageId); } async markSeen(messageId) { if (this.hasSeen(messageId)) return; this.#state.seenMessageIds.push(messageId); if (this.#state.seenMessageIds.length > 1_000) { this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000); } await this.#persist(); } cursor() { return this.#state.cursor; } async setCursor(cursor) { if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError('Invalid update cursor'); this.#state.cursor = cursor; await this.#persist(); } snapshot() { return structuredClone(this.#state); } async remove() { try { await unlink(this.#path); } catch (error) { if (error?.code !== 'ENOENT') throw error; } this.#state = structuredClone(EMPTY_STATE); } async #persist() { const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`; const operation = this.#writeQueue.then(async () => { await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 }); const temporary = `${this.#path}.tmp`; await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 }); await rename(temporary, this.#path); }); this.#writeQueue = operation.then(() => undefined, () => undefined); await operation; } }