ask the events table which key a transcript belongs to, instead of a live map

The id-space fix worked only for conversations younger than the process. The
caller resolves a session key from an in-memory map filled as sessions announce
themselves, so anything created before the last restart resolves to null and the
key list arrives empty — which is exactly what the first live test did. Everything
else cascaded; the events stayed, silently, for the one reason the code could not
report.

The answer was already in the rows. Every event carries `claudeSessionId` in its
payload: durable, restart-proof, and the one place the two id spaces are written
down beside each other. One event carrying that id resolves the key for all of
that session's rows — including the majority that carry no id of their own.

`sessionKeys` stays as a fast path for a live session and is now a hint rather
than the answer. A cleanup that only works for conversations younger than the
process is not a cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 23:16:42 +00:00
co-authored by Claude Opus 5
parent 2aef23c8ba
commit c04fa15eb5
@@ -1,4 +1,4 @@
import { inArray } from 'drizzle-orm';
import { inArray, sql } from 'drizzle-orm';
import { db } from './db';
import { chatSessionEvents } from './chat-events/schema';
import { chatNotificationPrefs } from './chat-notification-prefs/schema';
@@ -66,7 +66,27 @@ export async function forgetChatSessions({ transcriptIds, sessionKeys = [] }: Fo
await db.delete(chatSessionModels).where(inArray(chatSessionModels.sessionId, transcriptIds));
await db.delete(chatNotificationPrefs).where(inArray(chatNotificationPrefs.sessionId, transcriptIds));
}
if (sessionKeys.length) {
/*
Events, resolved from the table's OWN data rather than from the caller's key.
`sessionKeys` is a fast path and not a reliable one: the caller gets it from an in-memory map that is
empty for any session created before the last restart, so the first real test of this deleted everything
except the events — the key came back null and the list was empty. A cleanup that only works for
conversations younger than the process is not a cleanup.
So ask the rows. Every event carries `claudeSessionId` in its payload, which is durable, survives any
restart, and is the one place the two id spaces are written down together. One id inside the JSON is
enough to resolve the key for all of that session's rows, including the events that carry no id of their
own — which is most of them.
*/
if (transcriptIds.length) {
const keysFromEvents = await db
.selectDistinct({ sessionId: chatSessionEvents.sessionId })
.from(chatSessionEvents)
.where(inArray(sql`${chatSessionEvents.event}->>'claudeSessionId'`, transcriptIds));
const keys = [...new Set([...sessionKeys, ...keysFromEvents.map((r) => r.sessionId)])];
if (keys.length) await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, keys));
} else if (sessionKeys.length) {
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, sessionKeys));
}
}