From 65543cb6ee72f1cd54dc8d00a9aa21b2fbde7efc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 09:35:33 +0000 Subject: [PATCH] fix: add PUT /tasks/mirror (array full-replace of desktop Idle backlog) The desktop pushes its full Idle backlog as a JSON array to /tasks/mirror, not per-task. Previously /tasks/mirror matched tasks/[id].put.ts (id=mirror) and rejected the array with 400. New static route validates per-element, accepts empty arrays, upserts each as consumed=true (desktop-owned), deletes consumed=true rows not in the array, and leaves web-created consumed=false rows untouched. Mirrors PUT /lists. --- README.md | 14 ++++---- server/api/tasks/mirror.put.ts | 43 +++++++++++++++++++++++ server/utils/repo.ts | 32 +++++++++++++++++ tests/repo.test.ts | 63 ++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 server/api/tasks/mirror.put.ts diff --git a/README.md b/README.md index b57c74d..e5c93b2 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,15 @@ belonging to the owner. Missing/invalid/expired → `401`. No anonymous access. | `GET /api/lists` | web | → 200 `[{id,name}]` | | `GET /api/lists/{id}/tasks` | web | → 200 tasks for the list; 404 if unknown | | `POST /api/tasks` | web | Body `{title, description?, listId}`. Server-generated GUID, `source=web`, `consumed=false`. 404 if listId unknown. → 201 with the created task | -| `PUT /api/tasks/{id}` | desktop | Body `{listId, title, description?}`. Idempotent upsert (`source=desktop` on insert). → 201 (new) / 200 (existing) | -| `DELETE /api/tasks/{id}` | desktop | Idempotent. → 204 (even if absent) | -| `GET /api/tasks?consumed=false` | desktop | → 200 `[{id, listId, title, description, createdAt}]` web tasks not yet imported | -| `POST /api/tasks/{id}/consume` | desktop | Sets `consumed=true`. Idempotent. → 200; 404 if unknown | +| `PUT /api/tasks/mirror` | desktop | Body `[{id, listId, title, description?}, ...]` = the FULL current Idle backlog (camelCase; `[]` is valid). Full-replace of the desktop-owned partition: upsert each (as `consumed=true`), delete any `consumed=true` task not in the array, leave web-created `consumed=false` tasks untouched. Mirrors `PUT /lists`. → 200 | +| `GET /api/tasks?consumed=false` | desktop | → 200 `[{id, listId, title, description, createdAt}]` web tasks not yet imported (awaiting pull) | +| `POST /api/tasks/{id}/consume` | desktop | Sets `consumed=true` (imports a pulled web task into the desktop partition). Idempotent. → 200; 404 if unknown | +| `PUT /api/tasks/{id}` · `DELETE /api/tasks/{id}` | desktop | Legacy per-task upsert/delete — **superseded by `PUT /tasks/mirror`**. Kept for compatibility. | -Task ids are a **shared GUID space**: web-created ids are reused verbatim by the desktop on -import; all task writes are idempotent upserts keyed on `id`. +The `consumed` flag is the desktop-owned partition marker: `consumed=false` = web-created +awaiting pull; `consumed=true` = imported/desktop-owned (managed by the mirror). Task ids are +a **shared GUID space**: web-created ids are reused verbatim by the desktop on import; all +task writes are idempotent upserts keyed on `id`. ## Zitadel configuration (for the desktop client) diff --git a/server/api/tasks/mirror.put.ts b/server/api/tasks/mirror.put.ts new file mode 100644 index 0000000..47750af --- /dev/null +++ b/server/api/tasks/mirror.put.ts @@ -0,0 +1,43 @@ +// PUT /api/tasks/mirror (desktop) — full-replace of the desktop's current Idle backlog. +// Body: [{ id, listId, title, description? }, ...] (camelCase). An empty array is valid and +// clears the desktop-owned partition. Mirrors PUT /lists. Web-created tasks awaiting pull +// (consumed=false) are never touched here. +export default defineEventHandler(async (event) => { + const body = await readBody(event); + if (!Array.isArray(body)) { + throw createError({ statusCode: 400, statusMessage: "expected an array of tasks" }); + } + + const items: { id: string; listId: string; title: string; description: string | null }[] = []; + for (let i = 0; i < body.length; i++) { + const t = body[i]; + if (typeof t?.id !== "string" || !t.id.trim()) { + throw createError({ statusCode: 400, statusMessage: `item ${i}: id is required` }); + } + if (typeof t?.listId !== "string" || !t.listId.trim()) { + throw createError({ statusCode: 400, statusMessage: `item ${i}: listId is required` }); + } + if (typeof t?.title !== "string" || !t.title.trim()) { + throw createError({ statusCode: 400, statusMessage: `item ${i}: title is required` }); + } + items.push({ + id: t.id, + listId: t.listId, + title: t.title, + description: typeof t.description === "string" ? t.description : null, + }); + } + + const sql = getSql(); + + // Every referenced list must exist (lists are full-replaced before tasks are mirrored). + const listIds = [...new Set(items.map((t) => t.listId))]; + for (const id of listIds) { + if (!(await listExists(sql, id))) { + throw createError({ statusCode: 400, statusMessage: `unknown listId: ${id}` }); + } + } + + await mirrorDesktopTasks(sql, items); + return { ok: true, count: items.length }; +}); diff --git a/server/utils/repo.ts b/server/utils/repo.ts index 65b0cf3..7e7f6b9 100644 --- a/server/utils/repo.ts +++ b/server/utils/repo.ts @@ -83,6 +83,38 @@ export async function upsertDesktopTask( return { created: row.created }; } +/** + * Full-replace of the desktop-owned partition (the desktop's current Idle backlog). + * Mirrors `replaceLists`: upsert every supplied task as desktop-owned (consumed=true), and + * delete any desktop-owned (consumed=true) task not in the payload. Web-created tasks still + * awaiting pull (consumed=false) are left untouched. An empty array clears the partition. + */ +export async function mirrorDesktopTasks( + sql: Sql, + items: { id: string; listId: string; title: string; description?: string | null }[], +): Promise { + await sql.begin(async (tx) => { + for (const t of items) { + await tx` + insert into tasks (id, list_id, title, description, source, consumed) + values (${t.id}, ${t.listId}, ${t.title}, ${t.description ?? null}, 'desktop', true) + on conflict (id) do update set + list_id = excluded.list_id, + title = excluded.title, + description = excluded.description, + source = 'desktop', + consumed = true, + updated_at = now()`; + } + const ids = items.map((t) => t.id); + if (ids.length) { + await tx`delete from tasks where consumed = true and id <> all(${ids})`; + } else { + await tx`delete from tasks where consumed = true`; + } + }); +} + /** Web-created tasks the desktop has not yet imported. */ export async function getUnconsumed(sql: Sql): Promise { return sql` diff --git a/tests/repo.test.ts b/tests/repo.test.ts index 6d9dfea..aa0f32f 100644 --- a/tests/repo.test.ts +++ b/tests/repo.test.ts @@ -93,3 +93,66 @@ describe("tasks", () => { expect(await repo.getTasksForList(sql, "L")).toEqual([]); }); }); + +describe("mirrorDesktopTasks (desktop Idle full-replace)", () => { + beforeEach(async () => { + await repo.replaceLists(sql, [ + { id: "L", name: "List" }, + { id: "L2", name: "List 2" }, + ]); + }); + + it("inserts items as desktop-owned (consumed=true, source=desktop)", async () => { + await repo.mirrorDesktopTasks(sql, [ + { id: "m1", listId: "L", title: "one", description: "d1" }, + { id: "m2", listId: "L2", title: "two" }, + ]); + const t = await repo.getTasksForList(sql, "L"); + expect(t).toHaveLength(1); + expect(t[0]).toMatchObject({ id: "m1", title: "one", description: "d1", source: "desktop", consumed: true }); + expect(await repo.getTasksForList(sql, "L2")).toHaveLength(1); + }); + + it("upserts existing items by id (title/listId/description)", async () => { + await repo.mirrorDesktopTasks(sql, [{ id: "m1", listId: "L", title: "one" }]); + await repo.mirrorDesktopTasks(sql, [{ id: "m1", listId: "L2", title: "renamed", description: "x" }]); + const onL = await repo.getTasksForList(sql, "L"); + const onL2 = await repo.getTasksForList(sql, "L2"); + expect(onL).toHaveLength(0); + expect(onL2).toHaveLength(1); + expect(onL2[0]).toMatchObject({ title: "renamed", description: "x" }); + }); + + it("deletes desktop tasks whose id is not in the array", async () => { + await repo.mirrorDesktopTasks(sql, [ + { id: "m1", listId: "L", title: "one" }, + { id: "m2", listId: "L", title: "two" }, + ]); + await repo.mirrorDesktopTasks(sql, [{ id: "m1", listId: "L", title: "one" }]); // m2 dropped + const t = await repo.getTasksForList(sql, "L"); + expect(t.map((x) => x.id)).toEqual(["m1"]); + }); + + it("empty array deletes all desktop tasks", async () => { + await repo.mirrorDesktopTasks(sql, [{ id: "m1", listId: "L", title: "one" }]); + await repo.mirrorDesktopTasks(sql, []); + expect(await repo.getTasksForList(sql, "L")).toEqual([]); + }); + + it("does NOT touch web-created tasks awaiting pull (consumed=false)", async () => { + const web = await repo.createWebTask(sql, { listId: "L", title: "web", description: null }); + await repo.mirrorDesktopTasks(sql, [{ id: "m1", listId: "L", title: "desk" }]); + // web task survives the mirror (and is still unconsumed) + const unconsumed = await repo.getUnconsumed(sql); + expect(unconsumed.map((x) => x.id)).toEqual([web.id]); + expect((await repo.getTasksForList(sql, "L")).map((x) => x.id).sort()).toEqual([web.id, "m1"].sort()); + }); + + it("claims a web task when its id is mirrored (becomes consumed=true)", async () => { + const web = await repo.createWebTask(sql, { listId: "L", title: "web", description: null }); + await repo.mirrorDesktopTasks(sql, [{ id: web.id, listId: "L", title: "web (imported)" }]); + expect(await repo.getUnconsumed(sql)).toEqual([]); // no longer awaiting pull + const t = await repo.getTasksForList(sql, "L"); + expect(t[0]).toMatchObject({ id: web.id, title: "web (imported)", consumed: true }); + }); +});