Spaces:
Runtime error
Runtime error
| 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; | |