Local-first sync: on-device store + offline mutation queue, idempotent server upserts
All checks were successful
Build & Release APK / build (push) Successful in 10m54s
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:
@@ -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})$/);
|
||||
|
||||
Reference in New Issue
Block a user