chat-dev / server /chatTrashStore.js
incognitolm
Migration to PostgreSQL
bff1056
import crypto from 'crypto';
import path from 'path';
import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
import { isPostgresStorageMode } from './dataPaths.js';
import {
decryptJsonPayload,
encryptJsonPayload,
makeOwnerLookup,
pgQuery,
} from './postgres.js';
const DATA_ROOT = '/data/deleted_chats';
const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const state = {
loaded: false,
index: {
deletedChats: {},
},
};
function nowIso() {
return new Date().toISOString();
}
function ensureOwner(owner) {
if (!owner?.type || !owner?.id) throw new Error('Invalid deleted chat owner');
return owner;
}
function deletedChatAad(id) {
return `deleted-chat:${id}`;
}
async function ensureLoaded() {
if (state.loaded || isPostgresStorageMode()) return;
const stored = await loadEncryptedJson(INDEX_FILE);
state.index = {
deletedChats: stored?.deletedChats || {},
};
state.loaded = true;
await thisStore.purgeExpired();
}
async function saveIndex() {
await saveEncryptedJson(INDEX_FILE, state.index);
}
function matchesOwner(record, owner) {
return record.ownerType === owner.type && record.ownerId === owner.id;
}
function sanitize(record) {
return {
id: record.id,
originalSessionId: record.originalSessionId,
name: record.name,
deletedAt: record.deletedAt,
purgeAt: record.purgeAt,
created: record.created,
};
}
async function listSqlRecords(owner) {
const { rows } = await pgQuery(
'SELECT id, payload FROM deleted_chats WHERE owner_lookup = $1 ORDER BY deleted_at DESC',
[makeOwnerLookup(owner)]
);
return rows
.map((row) => decryptJsonPayload(row.payload, deletedChatAad(row.id)))
.filter(Boolean);
}
async function upsertSqlRecord(record) {
await pgQuery(
`INSERT INTO deleted_chats (id, owner_lookup, purge_at, deleted_at, payload)
VALUES ($1, $2, $3, $4, $5::jsonb)
ON CONFLICT (id)
DO UPDATE SET purge_at = EXCLUDED.purge_at, deleted_at = EXCLUDED.deleted_at, payload = EXCLUDED.payload`,
[
record.id,
makeOwnerLookup({ type: record.ownerType, id: record.ownerId }),
record.purgeAt,
record.deletedAt,
JSON.stringify(encryptJsonPayload(record, deletedChatAad(record.id))),
]
);
}
const thisStore = {
async add(owner, sessionSnapshot) {
ensureOwner(owner);
const sessionId = sessionSnapshot?.id;
if (!sessionId) return null;
if (isPostgresStorageMode()) {
const existing = (await listSqlRecords(owner)).find((record) => record.originalSessionId === sessionId);
const deleted = existing || {
id: crypto.randomUUID(),
originalSessionId: sessionId,
ownerType: owner.type,
ownerId: owner.id,
sessionSnapshot,
created: sessionSnapshot.created || Date.now(),
};
deleted.sessionSnapshot = sessionSnapshot;
deleted.name = sessionSnapshot.name || 'Deleted Chat';
deleted.created = sessionSnapshot.created || deleted.created || Date.now();
deleted.deletedAt = nowIso();
deleted.purgeAt = new Date(Date.now() + RETENTION_MS).toISOString();
await upsertSqlRecord(deleted);
return sanitize(deleted);
}
await ensureLoaded();
for (const record of Object.values(state.index.deletedChats)) {
if (
record.originalSessionId === sessionId &&
record.ownerType === owner.type &&
record.ownerId === owner.id
) {
record.sessionSnapshot = sessionSnapshot;
record.name = sessionSnapshot.name || 'Deleted Chat';
record.created = sessionSnapshot.created || Date.now();
record.deletedAt = nowIso();
record.purgeAt = new Date(Date.now() + RETENTION_MS).toISOString();
await saveIndex();
return sanitize(record);
}
}
const deleted = {
id: crypto.randomUUID(),
originalSessionId: sessionId,
ownerType: owner.type,
ownerId: owner.id,
name: sessionSnapshot.name || 'Deleted Chat',
created: sessionSnapshot.created || Date.now(),
sessionSnapshot,
deletedAt: nowIso(),
purgeAt: new Date(Date.now() + RETENTION_MS).toISOString(),
};
state.index.deletedChats[deleted.id] = deleted;
await saveIndex();
return sanitize(deleted);
},
async list(owner) {
ensureOwner(owner);
if (isPostgresStorageMode()) {
return (await listSqlRecords(owner))
.sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime())
.map(sanitize);
}
await ensureLoaded();
return Object.values(state.index.deletedChats)
.filter((record) => matchesOwner(record, owner))
.sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime())
.map(sanitize);
},
async restore(owner, ids) {
ensureOwner(owner);
if (isPostgresStorageMode()) {
const restored = [];
for (const id of ids || []) {
const { rows } = await pgQuery(
'SELECT payload FROM deleted_chats WHERE id = $1 AND owner_lookup = $2',
[id, makeOwnerLookup(owner)]
);
const record = rows[0] ? decryptJsonPayload(rows[0].payload, deletedChatAad(id)) : null;
if (!record || !matchesOwner(record, owner)) continue;
restored.push(record.sessionSnapshot);
await pgQuery('DELETE FROM deleted_chats WHERE id = $1', [id]);
}
return restored;
}
await ensureLoaded();
const restored = [];
for (const id of ids || []) {
const record = state.index.deletedChats[id];
if (!record || !matchesOwner(record, owner)) continue;
restored.push(record.sessionSnapshot);
delete state.index.deletedChats[id];
}
if (restored.length) await saveIndex();
return restored;
},
async deleteForever(owner, ids) {
ensureOwner(owner);
if (isPostgresStorageMode()) {
const removed = [];
for (const id of ids || []) {
const result = await pgQuery(
'DELETE FROM deleted_chats WHERE id = $1 AND owner_lookup = $2',
[id, makeOwnerLookup(owner)]
);
if (result.rowCount > 0) removed.push(id);
}
return removed;
}
await ensureLoaded();
const removed = [];
for (const id of ids || []) {
const record = state.index.deletedChats[id];
if (!record || !matchesOwner(record, owner)) continue;
delete state.index.deletedChats[id];
removed.push(id);
}
if (removed.length) await saveIndex();
return removed;
},
async purgeExpired() {
if (isPostgresStorageMode()) {
await pgQuery('DELETE FROM deleted_chats WHERE purge_at IS NOT NULL AND purge_at <= $1', [new Date().toISOString()]);
return;
}
if (!state.loaded) return;
const now = Date.now();
let changed = false;
for (const [id, record] of Object.entries(state.index.deletedChats)) {
if (new Date(record.purgeAt).getTime() <= now) {
delete state.index.deletedChats[id];
changed = true;
}
}
if (changed) await saveIndex();
},
};
setInterval(() => {
thisStore.purgeExpired().catch((err) => console.error('chatTrashStore cleanup failed:', err));
}, 6 * 60 * 60 * 1000);
export const chatTrashStore = thisStore;