dsh-im-ops/src/channels/shared/conversation-state-store.mjs
2026-08-17 23:56:27 +08:00

112 lines
3.1 KiB
JavaScript

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;
}
}