Local-first sync: on-device store + offline mutation queue, idempotent server upserts
All checks were successful
Build & Release APK / build (push) Successful in 10m54s

Screens render synchronously from an AsyncStorage-backed store; every
mutation applies locally and drains to the API in the background
(last-write-wins, client-generated UUIDs). Sydney round-trips are off the
interaction path and the app works fully offline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 19:19:51 -07:00
parent 4d6031d007
commit ad16522461
13 changed files with 417 additions and 216 deletions

View File

@@ -12,6 +12,7 @@ const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_a
const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"];
const PROJECT_STATUSES = ["active", "someday", "completed", "dropped"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const CORS = {
"Access-Control-Allow-Origin": "*",
@@ -69,13 +70,18 @@ async function handle(req: Request): Promise<Response> {
const t = pick(body, TASK_FIELDS);
if (typeof t.title !== "string" || !t.title.trim()) return err("title required", 400);
if (t.status && !TASK_STATUSES.includes(t.status as string)) return err("bad status", 400);
const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null;
// Client-supplied id + on-conflict no-op make offline-queue retries idempotent.
const rows = await sql.unsafe(
`insert into tasks (title, notes, status, project_id, context, waiting_for, due_date, defer_date, sort_order)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, sort_order)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (id) do nothing
returning ${TASK_COLS}`,
[t.title, t.notes ?? "", t.status ?? "inbox", t.project_id ?? null, t.context ?? null,
[id, t.title, t.notes ?? "", t.status ?? "inbox", t.project_id ?? null, t.context ?? null,
t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.sort_order ?? 0]);
return json(rows[0], 201);
if (rows.length) return json(rows[0], 201);
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
return json(existing[0], 200);
}
let m = path.match(/^\/v1\/tasks\/([0-9a-f-]{36})$/);
@@ -115,11 +121,16 @@ async function handle(req: Request): Promise<Response> {
const p = pick(body, PROJECT_FIELDS);
if (typeof p.name !== "string" || !p.name.trim()) return err("name required", 400);
if (p.status && !PROJECT_STATUSES.includes(p.status as string)) return err("bad status", 400);
const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null;
const rows = await sql.unsafe(
`insert into projects (name, status, notes, sort_order) values ($1, $2, $3, $4)
`insert into projects (id, name, status, notes, sort_order)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5)
on conflict (id) do nothing
returning ${PROJECT_COLS}`,
[p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0]);
return json(rows[0], 201);
[id, p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0]);
if (rows.length) return json(rows[0], 201);
const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]);
return json(existing[0], 200);
}
m = path.match(/^\/v1\/projects\/([0-9a-f-]{36})$/);