@@ -4,6 +4,236 @@ import { toast } from "sonner";
44import type { TodoItemRecord } from "@/db/schema" ;
55import * as TanstackQuery from "@/integrations/tanstack-query/root-provider" ;
66import type { TodoItemCreateDataType } from "@/local-api/api.todo-items" ;
7+ import {
8+ TODO_ITEMS_SYNC_STATE_ID ,
9+ todoItemsSyncCollection ,
10+ } from "./todoItemsSync" ;
11+
12+ type TodoItemSyncPayload = Pick <
13+ TodoItemRecord ,
14+ "id" | "boardId" | "priority" | "title" | "description" | "position"
15+ > ;
16+
17+ const UNSYNCED_TOAST_ID = "todo-items-unsynced" ;
18+
19+ const desiredPayloadById = new Map < string , TodoItemSyncPayload > ( ) ;
20+ const inFlightItemIds = new Set < string > ( ) ;
21+ const lastSyncedSignatureById = new Map < string , string > ( ) ;
22+ const retryAttemptById = new Map < string , number > ( ) ;
23+ const retryTimeoutById = new Map < string , ReturnType < typeof setTimeout > > ( ) ;
24+ const unsyncedItemIds = new Set < string > ( ) ;
25+ const failedItemIds = new Set < string > ( ) ;
26+
27+ function syncStateCollection ( ) {
28+ todoItemsSyncCollection . update ( TODO_ITEMS_SYNC_STATE_ID , ( draft ) => {
29+ draft . unsyncedItemIds = [ ...unsyncedItemIds ] . sort ( ) ;
30+ draft . inFlightItemIds = [ ...inFlightItemIds ] . sort ( ) ;
31+ draft . failedItemIds = [ ...failedItemIds ] . sort ( ) ;
32+ } ) ;
33+ }
34+
35+ function updateUnsyncedToast ( ) {
36+ const failedCount = failedItemIds . size ;
37+
38+ if ( failedCount === 0 ) {
39+ toast . dismiss ( UNSYNCED_TOAST_ID ) ;
40+ return ;
41+ }
42+
43+ toast . error (
44+ failedCount === 1
45+ ? "1 todo change failed to sync. Local state is preserved."
46+ : `${ failedCount } todo changes failed to sync. Local state is preserved.` ,
47+ {
48+ id : UNSYNCED_TOAST_ID ,
49+ action : {
50+ label : "Retry now" ,
51+ onClick : ( ) => {
52+ void retryUnsyncedTodoItemsSync ( ) ;
53+ } ,
54+ } ,
55+ } ,
56+ ) ;
57+ }
58+
59+ function syncUiIndicators ( ) {
60+ syncStateCollection ( ) ;
61+ updateUnsyncedToast ( ) ;
62+ }
63+
64+ function buildPayload ( item : TodoItemRecord ) : TodoItemSyncPayload {
65+ return {
66+ id : item . id ,
67+ boardId : item . boardId ,
68+ priority : item . priority ,
69+ title : item . title ,
70+ description : item . description ,
71+ position : item . position ,
72+ } ;
73+ }
74+
75+ function payloadSignature ( payload : TodoItemSyncPayload ) : string {
76+ return JSON . stringify ( payload ) ;
77+ }
78+
79+ function clearRetryTimeout ( itemId : string ) {
80+ const timeoutId = retryTimeoutById . get ( itemId ) ;
81+ if ( timeoutId ) {
82+ clearTimeout ( timeoutId ) ;
83+ retryTimeoutById . delete ( itemId ) ;
84+ }
85+ }
86+
87+ function getRetryDelayMs ( attempt : number ) : number {
88+ return Math . min ( 30_000 , 1_000 * 2 ** Math . max ( 0 , attempt - 1 ) ) ;
89+ }
90+
91+ function getTodoItem ( itemId : string ) : TodoItemRecord | undefined {
92+ return todoItemsCollection . get ( itemId ) ;
93+ }
94+
95+ function syncTodoItemsQueryCache ( modified : TodoItemRecord ) {
96+ const queryClient = TanstackQuery . getContext ( ) . queryClient ;
97+ queryClient . setQueriesData < TodoItemRecord [ ] > (
98+ { queryKey : todoItemsQueryKey } ,
99+ ( oldData ) => {
100+ if ( ! oldData ) {
101+ return oldData ;
102+ }
103+
104+ return oldData . map ( ( item ) => ( item . id === modified . id ? modified : item ) ) ;
105+ } ,
106+ ) ;
107+ }
108+
109+ function markUnsynced ( itemId : string ) {
110+ unsyncedItemIds . add ( itemId ) ;
111+ syncUiIndicators ( ) ;
112+ }
113+
114+ function markSynced ( itemId : string ) {
115+ unsyncedItemIds . delete ( itemId ) ;
116+ failedItemIds . delete ( itemId ) ;
117+ syncUiIndicators ( ) ;
118+ }
119+
120+ function clearSyncStateForItem ( itemId : string ) {
121+ clearRetryTimeout ( itemId ) ;
122+ desiredPayloadById . delete ( itemId ) ;
123+ inFlightItemIds . delete ( itemId ) ;
124+ lastSyncedSignatureById . delete ( itemId ) ;
125+ retryAttemptById . delete ( itemId ) ;
126+ unsyncedItemIds . delete ( itemId ) ;
127+ failedItemIds . delete ( itemId ) ;
128+ syncUiIndicators ( ) ;
129+ }
130+
131+ async function flushItemSync ( itemId : string ) {
132+ if ( inFlightItemIds . has ( itemId ) ) {
133+ return ;
134+ }
135+
136+ const desiredPayload = desiredPayloadById . get ( itemId ) ;
137+
138+ if ( ! desiredPayload ) {
139+ clearSyncStateForItem ( itemId ) ;
140+ return ;
141+ }
142+
143+ const desiredSignature = payloadSignature ( desiredPayload ) ;
144+
145+ if ( lastSyncedSignatureById . get ( itemId ) === desiredSignature ) {
146+ markSynced ( itemId ) ;
147+ return ;
148+ }
149+
150+ inFlightItemIds . add ( itemId ) ;
151+ syncUiIndicators ( ) ;
152+
153+ try {
154+ await updateTodoItem ( { data : desiredPayload } ) ;
155+ lastSyncedSignatureById . set ( itemId , desiredSignature ) ;
156+ retryAttemptById . set ( itemId , 0 ) ;
157+ } catch ( error ) {
158+ const nextAttempt = ( retryAttemptById . get ( itemId ) ?? 0 ) + 1 ;
159+ retryAttemptById . set ( itemId , nextAttempt ) ;
160+ unsyncedItemIds . add ( itemId ) ;
161+ failedItemIds . add ( itemId ) ;
162+ syncUiIndicators ( ) ;
163+
164+ const delay = getRetryDelayMs ( nextAttempt ) ;
165+ const timeoutId = setTimeout ( ( ) => {
166+ retryTimeoutById . delete ( itemId ) ;
167+ void flushItemSync ( itemId ) ;
168+ } , delay ) ;
169+
170+ retryTimeoutById . set ( itemId , timeoutId ) ;
171+
172+ console . error ( `Failed to sync todo item ${ itemId } :` , error ) ;
173+ return ;
174+ } finally {
175+ inFlightItemIds . delete ( itemId ) ;
176+ syncUiIndicators ( ) ;
177+ }
178+
179+ const latestLocalItem = getTodoItem ( itemId ) ;
180+
181+ if ( ! latestLocalItem ) {
182+ clearSyncStateForItem ( itemId ) ;
183+ return ;
184+ }
185+
186+ const latestPayload = buildPayload ( latestLocalItem ) ;
187+ const latestSignature = payloadSignature ( latestPayload ) ;
188+
189+ desiredPayloadById . set ( itemId , latestPayload ) ;
190+
191+ if ( lastSyncedSignatureById . get ( itemId ) !== latestSignature ) {
192+ markUnsynced ( itemId ) ;
193+ void flushItemSync ( itemId ) ;
194+ return ;
195+ }
196+
197+ markSynced ( itemId ) ;
198+ }
199+
200+ function queueLatestSync ( itemId : string ) {
201+ const todoItem = getTodoItem ( itemId ) ;
202+
203+ if ( ! todoItem ) {
204+ clearSyncStateForItem ( itemId ) ;
205+ return ;
206+ }
207+
208+ clearRetryTimeout ( itemId ) ;
209+
210+ const nextPayload = buildPayload ( todoItem ) ;
211+ desiredPayloadById . set ( itemId , nextPayload ) ;
212+
213+ const nextSignature = payloadSignature ( nextPayload ) ;
214+ const lastSyncedSignature = lastSyncedSignatureById . get ( itemId ) ;
215+
216+ if ( lastSyncedSignature !== nextSignature ) {
217+ markUnsynced ( itemId ) ;
218+ }
219+
220+ void flushItemSync ( itemId ) ;
221+ }
222+
223+ export async function retryUnsyncedTodoItemsSync ( ) {
224+ const idsToRetry = [ ...failedItemIds ] ;
225+
226+ idsToRetry . forEach ( ( itemId ) => {
227+ failedItemIds . delete ( itemId ) ;
228+ clearRetryTimeout ( itemId ) ;
229+ retryAttemptById . set ( itemId , 0 ) ;
230+ void flushItemSync ( itemId ) ;
231+ } ) ;
232+
233+ syncUiIndicators ( ) ;
234+
235+ return idsToRetry . length ;
236+ }
7237
8238async function updateTodoItem ( {
9239 data,
@@ -94,60 +324,16 @@ export const todoItemsCollection = createCollection<TodoItemRecord>(
94324 }
95325 } ,
96326 onUpdate : async ( { transaction } ) => {
97- /**
98- NOTE: This is a temporary solution for updating todo items.
99- **Do not use this in production code!**
100-
101- Update strategy:
102- 1. Optimistically update the local cache when a todo item is moved/updated
103- 2. Update the server via API call
104- 3. If the API call fails, refetch the data from the server and revert the local cache
105-
106- The server state is only fetched from the server if the update fails.
107- Proper synchronization of moving/reordering items requires a sync engine
108- to handle client-server conflicts effectively, which is outside the scope
109- of this demo app.
110-
111- Check out the available built-in sync collections here:
112- https://tanstack.com/db/latest/docs/overview#built-in-collection-types
113- */
114-
115- const { original, changes } = transaction . mutations [ 0 ] ;
327+ const { modified } = transaction . mutations [ 0 ] ;
116328
117- try {
118- // Send the updates to the server
119- await updateTodoItem ( {
120- data : {
121- id : original . id ,
122- ...changes ,
123- } ,
124- } ) ;
329+ // Keep TanStack Query cache aligned with local optimistic state
330+ // so switching projects still shows the latest local edits.
331+ syncTodoItemsQueryCache ( modified ) ;
125332
126- // Update the TanStack Query cache so switching projects shows correct data
127- const queryClient = TanstackQuery . getContext ( ) . queryClient ;
128- queryClient . setQueriesData < TodoItemRecord [ ] > (
129- { queryKey : todoItemsQueryKey } ,
130- ( oldData ) => {
131- if ( ! oldData ) return oldData ;
132- return oldData . map ( ( item ) =>
133- item . id === original . id ? { ...item , ...changes } : item ,
134- ) ;
135- } ,
136- ) ;
137- } catch ( error ) {
138- toast . error ( `Failed to update todo item "${ original . title } "` ) ;
139-
140- // TODO: handle this one later properly
141- // with queryClient.invalidateQueries(todoItemsQueryKey);
142- // // Do not sync if the collection is already refetching
143- // if (todoItemsCollection.utils.isRefetching === false) {
144- // // Sync back the server's data
145- // todoItemsCollection.utils.refetch();
146- // }
147- throw error ;
148- }
333+ // Local-first, latest-wins sync queue per item.
334+ // We never throw here, so local optimistic state is never rolled back.
335+ queueLatestSync ( modified . id ) ;
149336
150- // Do not sync back the server's data by default
151337 return {
152338 refetch : false ,
153339 } ;
0 commit comments