|
| 1 | +import { |
| 2 | + createChat, |
| 3 | + createDashboardAgentDb, |
| 4 | + type DashboardAgentDb, |
| 5 | + type DashboardAgentDbClient, |
| 6 | +} from "@internal/dashboard-agent-db"; |
| 7 | +import { postgresTest } from "@internal/testcontainers"; |
| 8 | +import type { PrismaClient } from "@trigger.dev/database"; |
| 9 | +import { readdirSync, readFileSync } from "node:fs"; |
| 10 | +import path from "node:path"; |
| 11 | +import { afterEach, describe, expect, vi } from "vitest"; |
| 12 | + |
| 13 | +const ctx = vi.hoisted(() => ({ |
| 14 | + agentDb: undefined as unknown as DashboardAgentDb, |
| 15 | +})); |
| 16 | + |
| 17 | +vi.mock("~/services/dashboardAgentDb.server", () => ({ |
| 18 | + get dashboardAgentDb() { |
| 19 | + return ctx.agentDb; |
| 20 | + }, |
| 21 | +})); |
| 22 | + |
| 23 | +const { sweepDashboardAgentSoftDeletedChats, purgeDashboardAgentChatsForOrganization } = |
| 24 | + await import("~/services/dashboardAgentChatRetention.server"); |
| 25 | + |
| 26 | +/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */ |
| 27 | +async function applyAgentSchema(prisma: PrismaClient) { |
| 28 | + const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); |
| 29 | + const migrations = readdirSync(folder) |
| 30 | + .filter((file) => file.endsWith(".sql")) |
| 31 | + .sort(); |
| 32 | + for (const name of migrations) { |
| 33 | + const sql = readFileSync(path.join(folder, name), "utf8"); |
| 34 | + for (const statement of sql.split("--> statement-breakpoint")) { |
| 35 | + const trimmed = statement.trim(); |
| 36 | + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); |
| 37 | + } |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +let agentDbClient: DashboardAgentDbClient | undefined; |
| 42 | +let prismaForRaw: PrismaClient | undefined; |
| 43 | + |
| 44 | +async function boot(prisma: PrismaClient, connectionUri: string) { |
| 45 | + await applyAgentSchema(prisma); |
| 46 | + agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 }); |
| 47 | + ctx.agentDb = agentDbClient.db; |
| 48 | + prismaForRaw = prisma; |
| 49 | +} |
| 50 | + |
| 51 | +afterEach(async () => { |
| 52 | + await agentDbClient?.close(); |
| 53 | + agentDbClient = undefined; |
| 54 | +}); |
| 55 | + |
| 56 | +const ORG = "org_ret"; |
| 57 | +const USER = "user_ret"; |
| 58 | + |
| 59 | +/** One row in every chatId-keyed table, so a delete that misses one leaves a leak. */ |
| 60 | +async function seedChatWithChildren(id: string) { |
| 61 | + await createChat(ctx.agentDb, { id, organizationId: ORG, userId: USER }); |
| 62 | + const raw = prismaForRaw!; |
| 63 | + await raw.$executeRawUnsafe( |
| 64 | + `insert into trigger_dashboard_agent.chat_messages (chat_id, message_id, position, role, message) |
| 65 | + values ($1, $1 || '-m', 1, 'user', '{}'::jsonb)`, |
| 66 | + id |
| 67 | + ); |
| 68 | + await raw.$executeRawUnsafe( |
| 69 | + `insert into trigger_dashboard_agent.chat_sessions (chat_id, public_access_token) values ($1, 'pat')`, |
| 70 | + id |
| 71 | + ); |
| 72 | + await raw.$executeRawUnsafe( |
| 73 | + `insert into trigger_dashboard_agent.chat_turn_evals (chat_id, turn, organization_id, user_id) |
| 74 | + values ($1, 0, $2, $3)`, |
| 75 | + id, |
| 76 | + ORG, |
| 77 | + USER |
| 78 | + ); |
| 79 | + await raw.$executeRawUnsafe( |
| 80 | + `insert into trigger_dashboard_agent.investigations (id, chat_id, project_ref, environment_ref, state) |
| 81 | + values ($1 || '-inv', $1, 'proj', 'env', '{"outcome":"in_progress"}'::jsonb)`, |
| 82 | + id |
| 83 | + ); |
| 84 | + await raw.$executeRawUnsafe( |
| 85 | + `insert into trigger_dashboard_agent.watches |
| 86 | + (id, chat_id, identity, spec, organization_id, project_id, environment_id, user_id, expires_at) |
| 87 | + values ($1 || '-w', $1, 'ident', '{}'::jsonb, $2, 'proj', 'env', $3, now() + interval '1 day')`, |
| 88 | + id, |
| 89 | + ORG, |
| 90 | + USER |
| 91 | + ); |
| 92 | + await raw.$executeRawUnsafe( |
| 93 | + `insert into trigger_dashboard_agent.watch_submissions |
| 94 | + (chat_id, client_request_id, organization_id, user_id, project_id, environment_id, draft_hash, draft) |
| 95 | + values ($1, 'req', $2, $3, 'proj', 'env', 'hash', '{}'::jsonb)`, |
| 96 | + id, |
| 97 | + ORG, |
| 98 | + USER |
| 99 | + ); |
| 100 | +} |
| 101 | + |
| 102 | +async function setDeletedAtDaysAgo(id: string, days: number) { |
| 103 | + await prismaForRaw!.$executeRawUnsafe( |
| 104 | + `update trigger_dashboard_agent.chats set deleted_at = now() - ($2 || ' days')::interval where id = $1`, |
| 105 | + id, |
| 106 | + String(days) |
| 107 | + ); |
| 108 | +} |
| 109 | + |
| 110 | +const CHILD_TABLES = [ |
| 111 | + "chat_messages", |
| 112 | + "chat_sessions", |
| 113 | + "chat_turn_evals", |
| 114 | + "investigations", |
| 115 | + "watches", |
| 116 | + "watch_submissions", |
| 117 | +]; |
| 118 | + |
| 119 | +async function rowCounts(id: string): Promise<Record<string, number>> { |
| 120 | + const counts: Record<string, number> = {}; |
| 121 | + const chat = await prismaForRaw!.$queryRawUnsafe<{ n: bigint }[]>( |
| 122 | + `select count(*)::int as n from trigger_dashboard_agent.chats where id = $1`, |
| 123 | + id |
| 124 | + ); |
| 125 | + counts.chats = Number(chat[0]!.n); |
| 126 | + for (const table of CHILD_TABLES) { |
| 127 | + const rows = await prismaForRaw!.$queryRawUnsafe<{ n: number }[]>( |
| 128 | + `select count(*)::int as n from trigger_dashboard_agent.${table} where chat_id = $1`, |
| 129 | + id |
| 130 | + ); |
| 131 | + counts[table] = Number(rows[0]!.n); |
| 132 | + } |
| 133 | + return counts; |
| 134 | +} |
| 135 | + |
| 136 | +describe("the dashboard agent chat retention sweep", () => { |
| 137 | + postgresTest( |
| 138 | + "hard-deletes only chats soft-deleted past the window, with every child row", |
| 139 | + async ({ prisma, postgresContainer }) => { |
| 140 | + await boot(prisma, postgresContainer.getConnectionUri()); |
| 141 | + |
| 142 | + await seedChatWithChildren("chat_old"); |
| 143 | + await setDeletedAtDaysAgo("chat_old", 40); // past the 30d window |
| 144 | + |
| 145 | + await seedChatWithChildren("chat_recent"); |
| 146 | + await setDeletedAtDaysAgo("chat_recent", 1); // inside the window |
| 147 | + |
| 148 | + await seedChatWithChildren("chat_live"); // never deleted |
| 149 | + |
| 150 | + const result = await sweepDashboardAgentSoftDeletedChats(); |
| 151 | + expect(result).toEqual({ purged: 1, failed: 0 }); |
| 152 | + |
| 153 | + const old = await rowCounts("chat_old"); |
| 154 | + for (const table of ["chats", ...CHILD_TABLES]) { |
| 155 | + expect(old[table], `${table} should be empty for the purged chat`).toBe(0); |
| 156 | + } |
| 157 | + |
| 158 | + const recent = await rowCounts("chat_recent"); |
| 159 | + const live = await rowCounts("chat_live"); |
| 160 | + for (const table of ["chats", ...CHILD_TABLES]) { |
| 161 | + expect(recent[table], `${table} kept for the in-window chat`).toBe(1); |
| 162 | + expect(live[table], `${table} kept for the live chat`).toBe(1); |
| 163 | + } |
| 164 | + }, |
| 165 | + 30_000 |
| 166 | + ); |
| 167 | + |
| 168 | + postgresTest( |
| 169 | + "org purge soft-deletes the org's chats and leaves other orgs alone", |
| 170 | + async ({ prisma, postgresContainer }) => { |
| 171 | + await boot(prisma, postgresContainer.getConnectionUri()); |
| 172 | + |
| 173 | + await createChat(ctx.agentDb, { id: "chat_a", organizationId: ORG, userId: USER }); |
| 174 | + await createChat(ctx.agentDb, { id: "chat_b", organizationId: ORG, userId: USER }); |
| 175 | + await createChat(ctx.agentDb, { |
| 176 | + id: "chat_other", |
| 177 | + organizationId: "org_other", |
| 178 | + userId: USER, |
| 179 | + }); |
| 180 | + |
| 181 | + const soft = await purgeDashboardAgentChatsForOrganization({ organizationId: ORG }); |
| 182 | + expect(soft).toBe(2); |
| 183 | + |
| 184 | + const deleted = await prisma.$queryRawUnsafe<{ id: string; deleted: boolean }[]>( |
| 185 | + `select id, (deleted_at is not null) as deleted from trigger_dashboard_agent.chats order by id` |
| 186 | + ); |
| 187 | + const byId = Object.fromEntries(deleted.map((row) => [row.id, row.deleted])); |
| 188 | + expect(byId).toEqual({ chat_a: true, chat_b: true, chat_other: false }); |
| 189 | + |
| 190 | + // Idempotent: a second call touches nothing already soft-deleted. |
| 191 | + expect(await purgeDashboardAgentChatsForOrganization({ organizationId: ORG })).toBe(0); |
| 192 | + } |
| 193 | + ); |
| 194 | +}); |
0 commit comments