Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[mcp_servers.codemogger]
command = "npx"
args = ["-y", "codemogger", "mcp"]
enabled = true
[mcp_servers.memelord]
command = "memelord"
args = ["serve"]
env = { MEMELORD_DIR = "/Users/fulopkovacs/dev-projects/2025/trytanstackdb.com/fix-displaced-todo-items/.memelord" }
enabled = true
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ todos.json

# My local notes
notes.md
.memelord/
.codemogger
15 changes: 15 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"mcpServers": {
"codemogger": {
"command": "npx",
"args": ["-y", "codemogger", "mcp"]
},
"memelord": {
"command": "memelord",
"args": ["serve"],
"env": {
"MEMELORD_DIR": "/Users/fulopkovacs/dev-projects/2025/trytanstackdb.com/fix-displaced-todo-items/.memelord"
}
}
}
}
21 changes: 21 additions & 0 deletions config/mcporter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"mcpServers": {
"codemogger": {
"command": "npx",
"args": [
"-y",
"codemogger",
"mcp"
]
},
"memelord": {
"command": "memelord",
"args": [
"serve"
],
"env": {
"MEMELORD_DIR": "/Users/fulopkovacs/dev-projects/2025/trytanstackdb.com/fix-displaced-todo-items/.memelord"
}
}
}
}
18 changes: 18 additions & 0 deletions opencode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"codemogger": {
"type": "local",
"command": ["npx", "-y", "codemogger", "mcp"],
"enabled": true
},
"memelord": {
"type": "local",
"command": ["memelord", "serve"],
"environment": {
"MEMELORD_DIR": "/Users/fulopkovacs/dev-projects/2025/trytanstackdb.com/fix-displaced-todo-items/.memelord"
},
"enabled": true
}
}
}
288 changes: 237 additions & 51 deletions src/collections/todoItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,236 @@ import { toast } from "sonner";
import type { TodoItemRecord } from "@/db/schema";
import * as TanstackQuery from "@/integrations/tanstack-query/root-provider";
import type { TodoItemCreateDataType } from "@/local-api/api.todo-items";
import {
TODO_ITEMS_SYNC_STATE_ID,
todoItemsSyncCollection,
} from "./todoItemsSync";

type TodoItemSyncPayload = Pick<
TodoItemRecord,
"id" | "boardId" | "priority" | "title" | "description" | "position"
>;

const UNSYNCED_TOAST_ID = "todo-items-unsynced";

const desiredPayloadById = new Map<string, TodoItemSyncPayload>();
const inFlightItemIds = new Set<string>();
const lastSyncedSignatureById = new Map<string, string>();
const retryAttemptById = new Map<string, number>();
const retryTimeoutById = new Map<string, ReturnType<typeof setTimeout>>();
const unsyncedItemIds = new Set<string>();
const failedItemIds = new Set<string>();

function syncStateCollection() {
todoItemsSyncCollection.update(TODO_ITEMS_SYNC_STATE_ID, (draft) => {
draft.unsyncedItemIds = [...unsyncedItemIds].sort();
draft.inFlightItemIds = [...inFlightItemIds].sort();
draft.failedItemIds = [...failedItemIds].sort();
});
}

function updateUnsyncedToast() {
const failedCount = failedItemIds.size;

if (failedCount === 0) {
toast.dismiss(UNSYNCED_TOAST_ID);
return;
}

toast.error(
failedCount === 1
? "1 todo change failed to sync. Local state is preserved."
: `${failedCount} todo changes failed to sync. Local state is preserved.`,
{
id: UNSYNCED_TOAST_ID,
action: {
label: "Retry now",
onClick: () => {
void retryUnsyncedTodoItemsSync();
},
},
},
);
}

function syncUiIndicators() {
syncStateCollection();
updateUnsyncedToast();
}

function buildPayload(item: TodoItemRecord): TodoItemSyncPayload {
return {
id: item.id,
boardId: item.boardId,
priority: item.priority,
title: item.title,
description: item.description,
position: item.position,
};
}

function payloadSignature(payload: TodoItemSyncPayload): string {
return JSON.stringify(payload);
}

function clearRetryTimeout(itemId: string) {
const timeoutId = retryTimeoutById.get(itemId);
if (timeoutId) {
clearTimeout(timeoutId);
retryTimeoutById.delete(itemId);
}
}

function getRetryDelayMs(attempt: number): number {
return Math.min(30_000, 1_000 * 2 ** Math.max(0, attempt - 1));
}

function getTodoItem(itemId: string): TodoItemRecord | undefined {
return todoItemsCollection.get(itemId);
}

function syncTodoItemsQueryCache(modified: TodoItemRecord) {
const queryClient = TanstackQuery.getContext().queryClient;
queryClient.setQueriesData<TodoItemRecord[]>(
{ queryKey: todoItemsQueryKey },
(oldData) => {
if (!oldData) {
return oldData;
}

return oldData.map((item) => (item.id === modified.id ? modified : item));
},
);
}

function markUnsynced(itemId: string) {
unsyncedItemIds.add(itemId);
syncUiIndicators();
}

function markSynced(itemId: string) {
unsyncedItemIds.delete(itemId);
failedItemIds.delete(itemId);
syncUiIndicators();
}

function clearSyncStateForItem(itemId: string) {
clearRetryTimeout(itemId);
desiredPayloadById.delete(itemId);
inFlightItemIds.delete(itemId);
lastSyncedSignatureById.delete(itemId);
retryAttemptById.delete(itemId);
unsyncedItemIds.delete(itemId);
failedItemIds.delete(itemId);
syncUiIndicators();
}

async function flushItemSync(itemId: string) {
if (inFlightItemIds.has(itemId)) {
return;
}

const desiredPayload = desiredPayloadById.get(itemId);

if (!desiredPayload) {
clearSyncStateForItem(itemId);
return;
}

const desiredSignature = payloadSignature(desiredPayload);

if (lastSyncedSignatureById.get(itemId) === desiredSignature) {
markSynced(itemId);
return;
}

inFlightItemIds.add(itemId);
syncUiIndicators();

try {
await updateTodoItem({ data: desiredPayload });
lastSyncedSignatureById.set(itemId, desiredSignature);
retryAttemptById.set(itemId, 0);
} catch (error) {
const nextAttempt = (retryAttemptById.get(itemId) ?? 0) + 1;
retryAttemptById.set(itemId, nextAttempt);
unsyncedItemIds.add(itemId);
failedItemIds.add(itemId);
syncUiIndicators();

const delay = getRetryDelayMs(nextAttempt);
const timeoutId = setTimeout(() => {
retryTimeoutById.delete(itemId);
void flushItemSync(itemId);
}, delay);

retryTimeoutById.set(itemId, timeoutId);

console.error(`Failed to sync todo item ${itemId}:`, error);
return;
} finally {
inFlightItemIds.delete(itemId);
syncUiIndicators();
}

const latestLocalItem = getTodoItem(itemId);

if (!latestLocalItem) {
clearSyncStateForItem(itemId);
return;
}

const latestPayload = buildPayload(latestLocalItem);
const latestSignature = payloadSignature(latestPayload);

desiredPayloadById.set(itemId, latestPayload);

if (lastSyncedSignatureById.get(itemId) !== latestSignature) {
markUnsynced(itemId);
void flushItemSync(itemId);
return;
}

markSynced(itemId);
}

function queueLatestSync(itemId: string) {
const todoItem = getTodoItem(itemId);

if (!todoItem) {
clearSyncStateForItem(itemId);
return;
}

clearRetryTimeout(itemId);

const nextPayload = buildPayload(todoItem);
desiredPayloadById.set(itemId, nextPayload);

const nextSignature = payloadSignature(nextPayload);
const lastSyncedSignature = lastSyncedSignatureById.get(itemId);

if (lastSyncedSignature !== nextSignature) {
markUnsynced(itemId);
}

void flushItemSync(itemId);
}

export async function retryUnsyncedTodoItemsSync() {
const idsToRetry = [...failedItemIds];

idsToRetry.forEach((itemId) => {
failedItemIds.delete(itemId);
clearRetryTimeout(itemId);
retryAttemptById.set(itemId, 0);
void flushItemSync(itemId);
});

syncUiIndicators();

return idsToRetry.length;
}

async function updateTodoItem({
data,
Expand Down Expand Up @@ -94,60 +324,16 @@ export const todoItemsCollection = createCollection<TodoItemRecord>(
}
},
onUpdate: async ({ transaction }) => {
/**
NOTE: This is a temporary solution for updating todo items.
**Do not use this in production code!**

Update strategy:
1. Optimistically update the local cache when a todo item is moved/updated
2. Update the server via API call
3. If the API call fails, refetch the data from the server and revert the local cache

The server state is only fetched from the server if the update fails.
Proper synchronization of moving/reordering items requires a sync engine
to handle client-server conflicts effectively, which is outside the scope
of this demo app.

Check out the available built-in sync collections here:
https://tanstack.com/db/latest/docs/overview#built-in-collection-types
*/

const { original, changes } = transaction.mutations[0];
const { modified } = transaction.mutations[0];

try {
// Send the updates to the server
await updateTodoItem({
data: {
id: original.id,
...changes,
},
});
// Keep TanStack Query cache aligned with local optimistic state
// so switching projects still shows the latest local edits.
syncTodoItemsQueryCache(modified);

// Update the TanStack Query cache so switching projects shows correct data
const queryClient = TanstackQuery.getContext().queryClient;
queryClient.setQueriesData<TodoItemRecord[]>(
{ queryKey: todoItemsQueryKey },
(oldData) => {
if (!oldData) return oldData;
return oldData.map((item) =>
item.id === original.id ? { ...item, ...changes } : item,
);
},
);
} catch (error) {
toast.error(`Failed to update todo item "${original.title}"`);

// TODO: handle this one later properly
// with queryClient.invalidateQueries(todoItemsQueryKey);
// // Do not sync if the collection is already refetching
// if (todoItemsCollection.utils.isRefetching === false) {
// // Sync back the server's data
// todoItemsCollection.utils.refetch();
// }
throw error;
}
// Local-first, latest-wins sync queue per item.
// We never throw here, so local optimistic state is never rolled back.
queueLatestSync(modified.id);

// Do not sync back the server's data by default
return {
refetch: false,
};
Expand Down
Loading
Loading