From 6c36ebcce560592bb0d5e3f844652d8cd1614a81 Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 8 Apr 2026 20:54:55 -0400 Subject: [PATCH] fix: wrap inbox merge in transaction, revert seed to random UUID (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: 1. Wrap inbox deduplication (select, reassign tasks, soft-delete) in a db.transaction() for atomicity. 2. Revert seed.ts to use random UUID — a fixed ID shared across users would cause PK conflicts. The sync endpoint handles deduplication. 3. Subtasks share the same listId as their parent, so the reassign query already covers them (clarified in comment). Co-Authored-By: Claude Opus 4.6 (1M context) --- web/src/app/api/sync/route.ts | 60 +++++++++++++++++++++-------------- web/src/db/seed.ts | 6 ++-- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/web/src/app/api/sync/route.ts b/web/src/app/api/sync/route.ts index 95ff08c..b0b35f9 100644 --- a/web/src/app/api/sync/route.ts +++ b/web/src/app/api/sync/route.ts @@ -202,32 +202,44 @@ async function processOperation(op: SyncOperation, userId: string) { // If the incoming list is an inbox, check for an existing inbox and merge if (incomingIsInbox) { - const [existingInbox] = await db - .select() - .from(slLists) - .where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true), isNull(slLists.deletedAt))); + await db.transaction(async (tx) => { + const [existingInbox] = await tx + .select() + .from(slLists) + .where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true), isNull(slLists.deletedAt))); - if (existingInbox && existingInbox.id !== entityId) { - // Reassign all tasks from the old inbox to the new one - await db.update(slTasks) - .set({ listId: entityId, updatedAt: now }) - .where(and(eq(slTasks.listId, existingInbox.id), eq(slTasks.userId, userId))); - // Soft-delete the old inbox - await db.update(slLists) - .set({ deletedAt: now, updatedAt: now }) - .where(eq(slLists.id, existingInbox.id)); - } + if (existingInbox && existingInbox.id !== entityId) { + // Reassign all tasks (including subtasks) from the old inbox to the new one + await tx.update(slTasks) + .set({ listId: entityId, updatedAt: now }) + .where(and(eq(slTasks.listId, existingInbox.id), eq(slTasks.userId, userId))); + // Soft-delete the old inbox + await tx.update(slLists) + .set({ deletedAt: now, updatedAt: now }) + .where(eq(slLists.id, existingInbox.id)); + } + + await tx.insert(slLists).values({ + id: entityId, + userId, + name: d.name as string || 'Untitled', + color: d.color as string | undefined, + icon: d.icon as string | undefined, + position: d.position as number | undefined, + isInbox: incomingIsInbox, + }).onConflictDoNothing(); + }); + } else { + await db.insert(slLists).values({ + id: entityId, + userId, + name: d.name as string || 'Untitled', + color: d.color as string | undefined, + icon: d.icon as string | undefined, + position: d.position as number | undefined, + isInbox: incomingIsInbox, + }).onConflictDoNothing(); } - - await db.insert(slLists).values({ - id: entityId, - userId, - name: d.name as string || 'Untitled', - color: d.color as string | undefined, - icon: d.icon as string | undefined, - position: d.position as number | undefined, - isInbox: incomingIsInbox, - }).onConflictDoNothing(); } else if (action === 'update') { await verifyOwnership(slLists, entityId, userId); await db.update(slLists) diff --git a/web/src/db/seed.ts b/web/src/db/seed.ts index 4f48b0d..0632530 100644 --- a/web/src/db/seed.ts +++ b/web/src/db/seed.ts @@ -18,12 +18,10 @@ async function seed() { .from(slLists) .where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true))); - // Use the same fixed inbox ID as the mobile app to avoid duplicates during sync - const INBOX_ID = '00000000-0000-0000-0000-000000000001'; - if (existing.length === 0) { + // Let the DB generate a random UUID — the sync endpoint handles + // inbox deduplication when mobile pushes its fixed-ID inbox. await db.insert(slLists).values({ - id: INBOX_ID, userId, name: 'Inbox', isInbox: true,