From 8f7204e4b1c4e030c1eefa608c8a41a39dcd029e Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 8 Apr 2026 15:28:36 -0400 Subject: [PATCH] fix: resolve sync data inconsistency between mobile and web (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes fixed: 1. API GET /api/sync returned raw entities but mobile client expected {changes: [...], sync_token} format — pullChanges() iterated data.changes which was undefined, silently skipping all server data. Now transforms entities into the SyncPullChange format. 2. Mobile outbox writes used snake_case keys (due_date, list_id, etc.) but server processOperation spreads data directly into Drizzle which expects camelCase (dueDate, listId). Fixed all outbox writes to use camelCase. Also fixed task_tag → taskTag entity type. 3. Missing completedAt in task outbox payloads — completion state was lost during sync. Added completedAt to both create and update outbox entries, and added Date conversion in server update handler. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/repository/lists.ts | 2 +- src/db/repository/outbox.ts | 2 +- src/db/repository/tags.ts | 5 +- src/db/repository/tasks.ts | 15 +++--- web/src/app/api/sync/route.ts | 90 +++++++++++++++++++++++++++++++++-- 5 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/db/repository/lists.ts b/src/db/repository/lists.ts index 02346be..c6fc1b1 100644 --- a/src/db/repository/lists.ts +++ b/src/db/repository/lists.ts @@ -54,7 +54,7 @@ export async function createList(name: string, color?: string, icon?: string) { color: color ?? null, icon: icon ?? null, position: maxPosition + 1, - is_inbox: false, + isInbox: false, }).catch(() => {}); return id; diff --git a/src/db/repository/outbox.ts b/src/db/repository/outbox.ts index 5228731..4b41c94 100644 --- a/src/db/repository/outbox.ts +++ b/src/db/repository/outbox.ts @@ -3,7 +3,7 @@ import { syncOutbox } from '../schema'; import { randomUUID } from '@/src/lib/uuid'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; -type EntityType = 'task' | 'list' | 'tag' | 'task_tag'; +type EntityType = 'task' | 'list' | 'tag' | 'taskTag'; type Action = 'create' | 'update' | 'delete'; /** diff --git a/src/db/repository/tags.ts b/src/db/repository/tags.ts index cfe1667..00edcd6 100644 --- a/src/db/repository/tags.ts +++ b/src/db/repository/tags.ts @@ -52,7 +52,10 @@ export async function setTagsForTask(taskId: string, tagIds: string[]) { tagIds.map((tagId) => ({ taskId, tagId })) ); } - writeOutboxEntry('task_tag', taskId, 'update', { task_id: taskId, tag_ids: tagIds }).catch(() => {}); + // Send individual taskTag create operations (server expects entityId=taskId, data.tagId) + for (const tagId of tagIds) { + writeOutboxEntry('taskTag', taskId, 'create', { tagId }).catch(() => {}); + } } export async function addTagToTask(taskId: string, tagId: string) { diff --git a/src/db/repository/tasks.ts b/src/db/repository/tasks.ts index 83dc086..8d9ceec 100644 --- a/src/db/repository/tasks.ts +++ b/src/db/repository/tasks.ts @@ -176,10 +176,12 @@ export async function createTask(data: { id, title: data.title, notes: data.notes ?? null, + completed: false, + completedAt: null, priority: data.priority ?? 0, - due_date: data.dueDate?.toISOString() ?? null, - list_id: data.listId, - parent_id: data.parentId ?? null, + dueDate: data.dueDate?.toISOString() ?? null, + listId: data.listId, + parentId: data.parentId ?? null, recurrence: sanitizedRecurrence, }).catch(() => {}); @@ -255,10 +257,11 @@ export async function updateTask( title: task.title, notes: task.notes, completed: task.completed, + completedAt: task.completedAt ? new Date(task.completedAt).toISOString() : null, priority: task.priority, - due_date: task.dueDate ? new Date(task.dueDate).toISOString() : null, - list_id: task.listId, - parent_id: task.parentId, + dueDate: task.dueDate ? new Date(task.dueDate).toISOString() : null, + listId: task.listId, + parentId: task.parentId, recurrence: task.recurrence, }).catch(() => {}); } diff --git a/web/src/app/api/sync/route.ts b/web/src/app/api/sync/route.ts index 6f98e5b..aebdf44 100644 --- a/web/src/app/api/sync/route.ts +++ b/web/src/app/api/sync/route.ts @@ -64,12 +64,87 @@ export async function GET(request: NextRequest) { .where(inArray(slTaskTags.taskId, taskIds)); } + // Transform entities into the changes format expected by the mobile client + const changes: { + entity_type: string; + entity_id: string; + action: 'create' | 'update' | 'delete'; + payload: Record; + updated_at: string; + }[] = []; + + for (const l of lists) { + changes.push({ + entity_type: 'list', + entity_id: l.id, + action: l.deletedAt ? 'delete' : 'update', + payload: { + name: l.name, + color: l.color, + icon: l.icon, + position: l.position, + is_inbox: l.isInbox, + created_at: l.createdAt.toISOString(), + updated_at: l.updatedAt.toISOString(), + }, + updated_at: l.updatedAt.toISOString(), + }); + } + + for (const t of tasks) { + changes.push({ + entity_type: 'task', + entity_id: t.id, + action: t.deletedAt ? 'delete' : 'update', + payload: { + title: t.title, + notes: t.notes, + completed: t.completed, + completed_at: t.completedAt?.toISOString() ?? null, + priority: t.priority, + due_date: t.dueDate?.toISOString() ?? null, + list_id: t.listId, + parent_id: t.parentId, + position: t.position, + recurrence: t.recurrence, + created_at: t.createdAt.toISOString(), + updated_at: t.updatedAt.toISOString(), + }, + updated_at: t.updatedAt.toISOString(), + }); + } + + for (const tag of tags) { + changes.push({ + entity_type: 'tag', + entity_id: tag.id, + action: tag.deletedAt ? 'delete' : 'update', + payload: { + name: tag.name, + color: tag.color, + created_at: tag.createdAt.toISOString(), + updated_at: tag.createdAt.toISOString(), + }, + updated_at: tag.createdAt.toISOString(), + }); + } + + for (const tt of taskTags) { + changes.push({ + entity_type: 'task_tag', + entity_id: `${tt.taskId}:${tt.tagId}`, + action: 'update', + payload: { + task_id: tt.taskId, + tag_id: tt.tagId, + }, + updated_at: new Date().toISOString(), + }); + } + return NextResponse.json({ - lists, - tasks, - tags, - taskTags, - syncedAt: new Date().toISOString(), + changes, + sync_token: new Date().toISOString(), }); } @@ -162,9 +237,14 @@ async function processOperation(op: SyncOperation, userId: string) { } else if (action === 'update') { await verifyOwnership(slTasks, entityId, userId); const raw = { ...(data as Record), updatedAt: now } as Record; + // Remove id from payload to avoid overwriting primary key + delete raw.id; if (raw.dueDate !== undefined) { raw.dueDate = raw.dueDate ? new Date(raw.dueDate as string) : null; } + if (raw.completedAt !== undefined) { + raw.completedAt = raw.completedAt ? new Date(raw.completedAt as string) : null; + } await db.update(slTasks) .set(raw) .where(and(eq(slTasks.id, entityId), eq(slTasks.userId, userId))); -- 2.45.2