@@ -74,6 +74,64 @@ async function seedSubmission(chatId: string, requestId: string, ageDays: number
7474 ) ;
7575}
7676
77+ /** One row in every chatId-keyed table, so a delete that misses one leaves a leak. */
78+ async function seedChatWithChildren ( db : DashboardAgentDb , id : string ) {
79+ await createChat ( db , { id, organizationId : ORG , userId : USER } ) ;
80+ await raw (
81+ `insert into trigger_dashboard_agent.chat_messages (chat_id, message_id, position, role, message)
82+ values ($1, $1 || '-m', 1, 'user', '{}'::jsonb)` ,
83+ [ id ]
84+ ) ;
85+ await raw (
86+ `insert into trigger_dashboard_agent.chat_sessions (chat_id, public_access_token) values ($1, 'pat')` ,
87+ [ id ]
88+ ) ;
89+ await raw (
90+ `insert into trigger_dashboard_agent.chat_turn_evals (chat_id, turn, organization_id, user_id)
91+ values ($1, 0, $2, $3)` ,
92+ [ id , ORG , USER ]
93+ ) ;
94+ await raw (
95+ `insert into trigger_dashboard_agent.investigations (id, chat_id, project_ref, environment_ref, state)
96+ values ($1 || '-inv', $1, 'proj', 'env', '{"outcome":"in_progress"}'::jsonb)` ,
97+ [ id ]
98+ ) ;
99+ await raw (
100+ `insert into trigger_dashboard_agent.watches
101+ (id, chat_id, identity, spec, organization_id, project_id, environment_id, user_id, expires_at)
102+ values ($1 || '-w', $1, 'ident', '{}'::jsonb, $2, 'proj', 'env', $3, now() + interval '1 day')` ,
103+ [ id , ORG , USER ]
104+ ) ;
105+ await raw (
106+ `insert into trigger_dashboard_agent.watch_submissions
107+ (chat_id, client_request_id, organization_id, user_id, project_id, environment_id, draft_hash, draft)
108+ values ($1, 'req', $2, $3, 'proj', 'env', 'hash', '{}'::jsonb)` ,
109+ [ id , ORG , USER ]
110+ ) ;
111+ }
112+
113+ const CHILD_TABLES = [
114+ "chat_messages" ,
115+ "chat_sessions" ,
116+ "chat_turn_evals" ,
117+ "investigations" ,
118+ "watches" ,
119+ "watch_submissions" ,
120+ ] ;
121+
122+ async function rowCounts ( id : string ) : Promise < Record < string , number > > {
123+ const counts : Record < string , number > = { } ;
124+ for ( const table of [ "chats" , ...CHILD_TABLES ] ) {
125+ const column = table === "chats" ? "id" : "chat_id" ;
126+ const rows = await raw (
127+ `select count(*)::int as n from trigger_dashboard_agent.${ table } where ${ column } = $1` ,
128+ [ id ]
129+ ) ;
130+ counts [ table ] = Number ( ( rows as unknown as { n : number } [ ] ) [ 0 ] ! . n ) ;
131+ }
132+ return counts ;
133+ }
134+
77135async function count ( table : string ) : Promise < number > {
78136 const rows = await raw ( `select count(*)::int as n from trigger_dashboard_agent.${ table } ` ) ;
79137 return Number ( ( rows as unknown as { n : number } [ ] ) [ 0 ] ! . n ) ;
@@ -136,6 +194,32 @@ describe("the dashboard agent retention pass", () => {
136194 60_000
137195 ) ;
138196
197+ postgresTest (
198+ "a purged chat takes every chatId-keyed child row with it" ,
199+ async ( { postgresContainer } ) => {
200+ const db = await boot ( postgresContainer . getConnectionUri ( ) ) ;
201+
202+ await seedChatWithChildren ( db , "chat_cascade_old" ) ;
203+ await seedChatWithChildren ( db , "chat_cascade_new" ) ;
204+ await raw (
205+ `update trigger_dashboard_agent.chats set deleted_at = now() - interval '40 days' where id = 'chat_cascade_old'`
206+ ) ;
207+ await raw (
208+ `update trigger_dashboard_agent.chats set deleted_at = now() - interval '1 day' where id = 'chat_cascade_new'`
209+ ) ;
210+
211+ expect ( await runDashboardAgentRetention ( db ) ) . toMatchObject ( { chats : 1 } ) ;
212+
213+ const purged = await rowCounts ( "chat_cascade_old" ) ;
214+ const kept = await rowCounts ( "chat_cascade_new" ) ;
215+ for ( const table of [ "chats" , ...CHILD_TABLES ] ) {
216+ expect ( purged [ table ] , `${ table } should be empty for the purged chat` ) . toBe ( 0 ) ;
217+ expect ( kept [ table ] , `${ table } kept for the in-window chat` ) . toBe ( 1 ) ;
218+ }
219+ } ,
220+ 60_000
221+ ) ;
222+
139223 postgresTest (
140224 "an active watch is never purged, however old it is" ,
141225 async ( { postgresContainer } ) => {
0 commit comments