@@ -568,6 +568,80 @@ describe('MessagingService — inbox read API (ADR-0030)', () => {
568568 expect ( receipts [ 0 ] ) . toMatchObject ( { id : 'r_concurrent' , state : 'read' } ) ;
569569 } ) ;
570570
571+ it ( 'markRead converges when the conflict arrives wrapped in a cause chain (#6542)' , async ( ) => {
572+ // Pool and query-builder layers re-throw with the driver error attached
573+ // as `cause`. The retired local isUniqueViolation() read only the
574+ // top-level code/message, judged this wrapper "not a conflict", and
575+ // rethrew — markRead logged the failure and reported readCount 0 with
576+ // the receipt stuck at `delivered`. The shared isUniqueViolationError
577+ // (@objectstack/types, #6250) follows the bounded cause chain, so the
578+ // race fallback now converges exactly as it does for a bare driver
579+ // error. This is the ONE behaviour change of the migration, in the
580+ // direction the call site wants: a wrapped conflict is still a conflict.
581+ const engine = inboxEngine ( {
582+ inbox : [ { id : 'm1' , user_id : 'u1' , notification_id : 'n1' , title : 'A' , created_at : '1' } ] ,
583+ } ) ;
584+ const realInsert = engine . insert . bind ( engine ) ;
585+ let raced = false ;
586+ engine . insert = async ( object : string , row : any ) => {
587+ if ( object === 'sys_notification_receipt' && ! raced ) {
588+ raced = true ;
589+ engine . store . sys_notification_receipt . push ( {
590+ id : 'r_concurrent' , notification_id : 'n1' , user_id : 'u1' , channel : 'inbox' , state : 'delivered' ,
591+ } ) ;
592+ // The wrapper's own text deliberately matches neither the codes
593+ // nor the message substrings the predicate knows — only the
594+ // attached driver error carries the conflict signal.
595+ const wrapper = new Error ( 'receipt insert failed (pool retry exhausted)' ) ;
596+ ( wrapper as Error & { cause ?: unknown } ) . cause = {
597+ code : '23505' ,
598+ message : 'duplicate key value violates unique constraint "sys_notification_receipt_notification_id_user_id_channel_key"' ,
599+ } ;
600+ throw wrapper ;
601+ }
602+ return realInsert ( object , row ) ;
603+ } ;
604+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
605+
606+ const res = await svc . markRead ( 'u1' , [ 'n1' ] ) ;
607+ expect ( res ) . toEqual ( { success : true , readCount : 1 } ) ;
608+ const receipts = engine . store . sys_notification_receipt ;
609+ expect ( receipts ) . toHaveLength ( 1 ) ; // converged, not duplicated
610+ expect ( receipts [ 0 ] ) . toMatchObject ( { id : 'r_concurrent' , state : 'read' } ) ;
611+ } ) ;
612+
613+ it . each ( [
614+ [ 'postgres' , { code : '23505' , message : 'duplicate key value violates unique constraint "sys_notification_receipt_uniq"' } ] ,
615+ [ 'mysql' , { code : 'ER_DUP_ENTRY' , errno : 1062 , message : "Duplicate entry 'n1-u1-inbox' for key 'sys_notification_receipt_uniq'" } ] ,
616+ [ 'sqlite' , { message : 'UNIQUE constraint failed: sys_notification_receipt.notification_id, sys_notification_receipt.user_id, sys_notification_receipt.channel' } ] ,
617+ ] ) ( 'markRead race fallback is unchanged for a plain (unwrapped) %s conflict (#6542)' , async ( _dialect , shape ) => {
618+ // Pin that the migration onto the shared predicate did not move any
619+ // verdict the old local copy already gave: a bare three-dialect driver
620+ // error still converges identically.
621+ const engine = inboxEngine ( {
622+ inbox : [ { id : 'm1' , user_id : 'u1' , notification_id : 'n1' , title : 'A' , created_at : '1' } ] ,
623+ } ) ;
624+ const realInsert = engine . insert . bind ( engine ) ;
625+ let raced = false ;
626+ engine . insert = async ( object : string , row : any ) => {
627+ if ( object === 'sys_notification_receipt' && ! raced ) {
628+ raced = true ;
629+ engine . store . sys_notification_receipt . push ( {
630+ id : 'r_concurrent' , notification_id : 'n1' , user_id : 'u1' , channel : 'inbox' , state : 'delivered' ,
631+ } ) ;
632+ throw Object . assign ( new Error ( shape . message ) , shape ) ;
633+ }
634+ return realInsert ( object , row ) ;
635+ } ;
636+ const svc = new MessagingService ( { logger, getData : ( ) => engine } ) ;
637+
638+ const res = await svc . markRead ( 'u1' , [ 'n1' ] ) ;
639+ expect ( res ) . toEqual ( { success : true , readCount : 1 } ) ;
640+ const receipts = engine . store . sys_notification_receipt ;
641+ expect ( receipts ) . toHaveLength ( 1 ) ;
642+ expect ( receipts [ 0 ] ) . toMatchObject ( { id : 'r_concurrent' , state : 'read' } ) ;
643+ } ) ;
644+
571645 it ( 'markAllRead flips every unread message and leaves already-read ones' , async ( ) => {
572646 const engine = inboxEngine ( {
573647 inbox : [
0 commit comments