fix: resolve sync data inconsistency between mobile and web (#55)
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) <noreply@anthropic.com>
This commit is contained in:
parent
818f66205b
commit
8f7204e4b1
5 changed files with 100 additions and 14 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>), updatedAt: now } as Record<string, unknown>;
|
||||
// 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)));
|
||||
|
|
|
|||
Loading…
Reference in a new issue