@@ -40,7 +40,8 @@ interface DebateContextState {
4040 lastUsage : Partial < Record < DebateContextFeature , DebateContextUsage > > ;
4141
4242 loadPins : ( debateId : string ) => Promise < void > ;
43- togglePin : ( debateId : string , ref : DebateOutputRef ) => Promise < void > ;
43+ /** Only pins on `resultKeys`, the debate's existing results, count toward the limit. */
44+ togglePin : ( debateId : string , ref : DebateOutputRef , resultKeys ?: ReadonlySet < string > ) => Promise < void > ;
4445 setSourceChoice : ( feature : DebateContextFeature , debateId : string , sourceId : string , include : boolean ) => void ;
4546 setOutputChoice : ( feature : DebateContextFeature , debateId : string , ref : DebateOutputRef , include : boolean ) => void ;
4647 resetOverride : ( feature : DebateContextFeature ) => void ;
@@ -52,9 +53,20 @@ function ensureOverride(current: DebateContextOverride | undefined, debateId: st
5253 return current && current . debateId === debateId ? current : { debateId, sources : { } , outputs : { } } ;
5354}
5455
55- // Counts pin changes per debate, so a load that started before a change
56- // cannot bring back what the student just pinned or unpinned.
57- const pinChanges : Record < string , number > = { } ;
56+ // Every local pin change, per debate and in order. A load replays the changes
57+ // it may have missed: ones made after it started, and ones that were still
58+ // saving at any point during the load, because its read may predate them.
59+ // `before` is the result's state before this change; if the change fails, the
60+ // result goes back to it.
61+ interface PinChange { seq : number ; ref : DebateOutputRef ; pinned : boolean ; before : boolean ; saving : boolean }
62+ const pinChanges : Record < string , PinChange [ ] > = { } ;
63+ let pinSeq = 0 ;
64+ // Each debate's pin writes run one at a time, so the database ends in the
65+ // same state as the screen even after a quick pin and unpin.
66+ const pinWrites : Record < string , Promise < unknown > > = { } ;
67+ // Bumped when local data is reset on sign-out. Pin work started before that
68+ // belongs to the previous account, so it must not write or change state.
69+ let pinGeneration = 0 ;
5870
5971function withPin ( pins : DebateOutputRef [ ] , ref : DebateOutputRef , pinned : boolean ) : DebateOutputRef [ ] {
6072 const without = pins . filter ( ( item ) => outputKey ( item ) !== outputKey ( ref ) ) ;
@@ -68,44 +80,73 @@ export const useDebateContextStore = create<DebateContextState>()((set, get) =>
6880 lastUsage : { } ,
6981
7082 loadPins : async ( debateId ) => {
71- const changesBefore = pinChanges [ debateId ] ?? 0 ;
83+ const startedAt = pinSeq ;
84+ const generation = pinGeneration ;
85+ const savingAtStart = new Set ( ( pinChanges [ debateId ] ?? [ ] ) . filter ( ( change ) => change . saving ) ) ;
7286 const { data, error } = await supabase
7387 . from ( 'debate_context_pins' )
7488 . select ( 'item_kind,item_id' )
7589 . eq ( 'debate_id' , debateId ) ;
90+ if ( generation !== pinGeneration ) return ;
7691 // Pins are an optional preference. Until the table exists everywhere, or
7792 // if it cannot be read, requests fall back to the automatic selection.
78- if ( error || ( pinChanges [ debateId ] ?? 0 ) !== changesBefore ) {
93+ if ( error ) {
7994 set ( ( state ) => ( { pinsLoaded : { ...state . pinsLoaded , [ debateId ] : true } } ) ) ;
8095 return ;
8196 }
82- const pins = ( ( data ?? [ ] ) as Array < { item_kind : unknown ; item_id : unknown } > ) . flatMap ( ( row ) =>
97+ let pins = ( ( data ?? [ ] ) as Array < { item_kind : unknown ; item_id : unknown } > ) . flatMap ( ( row ) =>
8398 isDebateContextOutputKind ( row . item_kind ) && typeof row . item_id === 'string'
8499 ? [ { kind : row . item_kind as DebateContextOutputKind , id : row . item_id } ]
85100 : [ ] ) ;
101+ for ( const change of pinChanges [ debateId ] ?? [ ] ) {
102+ if ( change . seq > startedAt || change . saving || savingAtStart . has ( change ) ) pins = withPin ( pins , change . ref , change . pinned ) ;
103+ }
86104 set ( ( state ) => ( {
87105 pins : { ...state . pins , [ debateId ] : pins } ,
88106 pinsLoaded : { ...state . pinsLoaded , [ debateId ] : true } ,
89107 } ) ) ;
90108 } ,
91109
92- togglePin : async ( debateId , ref ) => {
110+ togglePin : async ( debateId , ref , resultKeys ) => {
93111 const current = get ( ) . pins [ debateId ] ?? [ ] ;
94112 const pinned = current . some ( ( item ) => outputKey ( item ) === outputKey ( ref ) ) ;
95- if ( ! pinned && current . length >= MAX_PINNED_OUTPUTS ) throw new Error ( 'pin_limit' ) ;
96- pinChanges [ debateId ] = ( pinChanges [ debateId ] ?? 0 ) + 1 ;
97- set ( ( state ) => ( { pins : { ...state . pins , [ debateId ] : withPin ( current , ref , ! pinned ) } } ) ) ;
98- const { data } = await supabase . auth . getUser ( ) ;
99- const userId = data . user ?. id ;
100- const result = ! userId
101- ? { error : { message : 'Sign in to pin results' } }
102- : pinned
113+ const counted = resultKeys ? current . filter ( ( item ) => resultKeys . has ( outputKey ( item ) ) ) : current ;
114+ if ( ! pinned && counted . length >= MAX_PINNED_OUTPUTS ) throw new Error ( 'pin_limit' ) ;
115+ const generation = pinGeneration ;
116+ const change : PinChange = { seq : ++ pinSeq , ref, pinned : ! pinned , before : pinned , saving : true } ;
117+ ( pinChanges [ debateId ] ??= [ ] ) . push ( change ) ;
118+ set ( ( state ) => ( { pins : { ...state . pins , [ debateId ] : withPin ( state . pins [ debateId ] ?? [ ] , ref , ! pinned ) } } ) ) ;
119+ const save = async ( ) : Promise < { error : { message ?: string ; code ?: string } | null } > => {
120+ if ( generation !== pinGeneration ) return { error : null } ;
121+ const { data } = await supabase . auth . getUser ( ) ;
122+ const userId = data . user ?. id ;
123+ if ( generation !== pinGeneration ) return { error : null } ;
124+ if ( ! userId ) return { error : { message : 'Sign in to pin results' } } ;
125+ return pinned
103126 ? await supabase . from ( 'debate_context_pins' ) . delete ( )
104127 . eq ( 'user_id' , userId ) . eq ( 'debate_id' , debateId ) . eq ( 'item_kind' , ref . kind ) . eq ( 'item_id' , ref . id )
105128 : await supabase . from ( 'debate_context_pins' ) . insert ( { user_id : userId , debate_id : debateId , item_kind : ref . kind , item_id : ref . id } ) ;
129+ } ;
130+ const write = ( pinWrites [ debateId ] ?? Promise . resolve ( ) ) . then ( save ) ;
131+ pinWrites [ debateId ] = write . catch ( ( ) : void => undefined ) ;
132+ const result : Awaited < ReturnType < typeof save > > = await write
133+ . catch ( ( error : unknown ) => ( { error : { message : error instanceof Error ? error . message : undefined } } ) ) ;
134+ change . saving = false ;
135+ if ( generation !== pinGeneration ) return ;
136+ // The row already exists, so the result is pinned as asked.
137+ if ( change . pinned && result . error ?. code === '23505' ) return ;
106138 if ( result . error ) {
107- // Undo only this change; other pins may have changed in the meantime.
108- set ( ( state ) => ( { pins : { ...state . pins , [ debateId ] : withPin ( state . pins [ debateId ] ?? [ ] , ref , pinned ) } } ) ) ;
139+ // Undo only this change; other pins may have changed in the meantime. A
140+ // later change to the same result is queued behind this one and decides
141+ // its final state. It assumed this change was saved, so if it fails too
142+ // it must go back to the state before this one.
143+ const next = pinChanges [ debateId ] . find ( ( item ) => item . seq > change . seq && outputKey ( item . ref ) === outputKey ( ref ) ) ;
144+ pinChanges [ debateId ] = pinChanges [ debateId ] . filter ( ( item ) => item !== change ) ;
145+ if ( next ) {
146+ next . before = change . before ;
147+ return ;
148+ }
149+ set ( ( state ) => ( { pins : { ...state . pins , [ debateId ] : withPin ( state . pins [ debateId ] ?? [ ] , ref , change . before ) } } ) ) ;
109150 throw new Error ( result . error . message ?? 'Could not save the pin' ) ;
110151 }
111152 } ,
@@ -134,7 +175,12 @@ export const useDebateContextStore = create<DebateContextState>()((set, get) =>
134175 return { lastUsage } ;
135176 } ) ,
136177
137- resetLocalData : ( ) => set ( { pins : { } , pinsLoaded : { } , overrides : { } , lastUsage : { } } ) ,
178+ resetLocalData : ( ) => {
179+ pinGeneration += 1 ;
180+ for ( const debateId of Object . keys ( pinChanges ) ) delete pinChanges [ debateId ] ;
181+ for ( const debateId of Object . keys ( pinWrites ) ) delete pinWrites [ debateId ] ;
182+ set ( { pins : { } , pinsLoaded : { } , overrides : { } , lastUsage : { } } ) ;
183+ } ,
138184} ) ) ;
139185
140186/** Reads the server's `meta.debateContext`, ignoring anything malformed. */
0 commit comments