@@ -264,7 +264,7 @@ describeIfDb("0025_workbench_threads_unique_key dedupe", () => {
264264 }
265265 } , 20000 ) ;
266266
267- test ( "collapses two seeded root threads, repointing thread membership at the kept row" , async ( ) => {
267+ test ( "collapses duplicate root and reply threads, repointing thread_id and parent_thread_id references at the kept row" , async ( ) => {
268268 // Replay every migration through 0023 by hand, recording each in
269269 // the same ledger table `applyChatMigrations` reads, so the two
270270 // duplicate roots below can be seeded *before* 0025 exists to
@@ -291,16 +291,40 @@ describeIfDb("0025_workbench_threads_unique_key dedupe", () => {
291291
292292 await seed . unsafe ( `
293293 INSERT INTO "chat"."workbench_threads"
294- (id, tenant_id, workbench_id, kind, created_at)
294+ (id, tenant_id, workbench_id, kind, parent_message_id, created_at)
295295 VALUES
296- ('thr_root_old', 'tnt_dedupe', 'wb_dedupe', 'root', now() - interval '1 hour'),
297- ('thr_root_new', 'tnt_dedupe', 'wb_dedupe', 'root', now())
296+ ('thr_root_old', 'tnt_dedupe', 'wb_dedupe', 'root', NULL, now() - interval '1 hour'),
297+ ('thr_root_new', 'tnt_dedupe', 'wb_dedupe', 'root', NULL, now()),
298+ ('thr_reply_old', 'tnt_dedupe', 'wb_dedupe', 'reply', 'msg_parent', now() - interval '1 hour'),
299+ ('thr_reply_new', 'tnt_dedupe', 'wb_dedupe', 'reply', 'msg_parent', now())
298300 ` ) ;
299301 await seed . unsafe ( `
300302 INSERT INTO "chat"."workbench_thread_messages"
301303 (tenant_id, workbench_id, thread_id, message_id)
302304 VALUES ('tnt_dedupe', 'wb_dedupe', 'thr_root_new', 'msg_dedupe')
303305 ` ) ;
306+ // A message parked in the dropped reply duplicate — proves
307+ // `workbench_messages.thread_id` is repointed, not just
308+ // `workbench_thread_messages.thread_id`.
309+ await seed . unsafe ( `
310+ INSERT INTO "chat"."workbench_messages"
311+ (id, tenant_id, workbench_id, sender_address, thread_id, parts)
312+ VALUES (
313+ 'msg_in_dropped_reply', 'tnt_dedupe', 'wb_dedupe', 'addr_1',
314+ 'thr_reply_new', '[]'::jsonb
315+ )
316+ ` ) ;
317+ // A depth-2 thread anchored off the dropped root duplicate —
318+ // proves `workbench_threads.parent_thread_id` is repointed too,
319+ // not just message-facing references.
320+ await seed . unsafe ( `
321+ INSERT INTO "chat"."workbench_threads"
322+ (id, tenant_id, workbench_id, kind, parent_message_id, parent_thread_id, created_at)
323+ VALUES (
324+ 'thr_child_of_dropped_root', 'tnt_dedupe', 'wb_dedupe', 'reply',
325+ 'msg_child', 'thr_root_new', now()
326+ )
327+ ` ) ;
304328 } finally {
305329 await seed . end ( ) ;
306330 }
@@ -320,8 +344,44 @@ describeIfDb("0025_workbench_threads_unique_key dedupe", () => {
320344 `SELECT thread_id FROM "chat"."workbench_thread_messages" WHERE message_id = 'msg_dedupe'` ,
321345 ) ;
322346 expect ( String ( membership [ 0 ] ?. [ "thread_id" ] ) ) . toBe ( "thr_root_old" ) ;
347+
348+ const replies = await verify . unsafe (
349+ `SELECT id FROM "chat"."workbench_threads" ` +
350+ `WHERE tenant_id = 'tnt_dedupe' AND workbench_id = 'wb_dedupe' ` +
351+ `AND kind = 'reply' AND parent_message_id = 'msg_parent'` ,
352+ ) ;
353+ expect ( replies . map ( ( row ) => String ( row [ "id" ] ) ) ) . toEqual ( [
354+ "thr_reply_old" ,
355+ ] ) ;
356+
357+ const messageThreadId = await verify . unsafe (
358+ `SELECT thread_id FROM "chat"."workbench_messages" WHERE id = 'msg_in_dropped_reply'` ,
359+ ) ;
360+ expect ( String ( messageThreadId [ 0 ] ?. [ "thread_id" ] ) ) . toBe ( "thr_reply_old" ) ;
361+
362+ const childParentThreadId = await verify . unsafe (
363+ `SELECT parent_thread_id FROM "chat"."workbench_threads" WHERE id = 'thr_child_of_dropped_root'` ,
364+ ) ;
365+ expect ( String ( childParentThreadId [ 0 ] ?. [ "parent_thread_id" ] ) ) . toBe (
366+ "thr_root_old" ,
367+ ) ;
368+
369+ // The reply unique index holds: a second reply row for the same
370+ // (tenant, workbench, parent_message_id) key is now rejected
371+ // rather than silently accepted as a second duplicate.
372+ // postgres.js queries are lazy thenables, not Promises; wrap so
373+ // `expect(...).rejects` actually runs the statement.
374+ await expect (
375+ ( async ( ) => {
376+ await verify . unsafe (
377+ `INSERT INTO "chat"."workbench_threads" ` +
378+ `(id, tenant_id, workbench_id, kind, parent_message_id) ` +
379+ `VALUES ('thr_reply_conflict', 'tnt_dedupe', 'wb_dedupe', 'reply', 'msg_parent')` ,
380+ ) ;
381+ } ) ( ) ,
382+ ) . rejects . toThrow ( ) ;
323383 } finally {
324384 await verify . end ( ) ;
325385 }
326- } ) ;
386+ } , 120000 ) ;
327387} ) ;
0 commit comments