# Convex sync (local-first → server)

**Status:** Implemented in **single-user / dogfood mode** (no auth). Verified end-to-end —
push + reactive pull — on the simulator against the dev deployment.
**Date:** 2026-06-24.
**Extends:** [local-first.md](local-first.md).
**Code:** server `convex/sync.ts` + `convex/schema.ts`; client `src/data/sync/` (engine, owner)
+ reconcile in `src/data/task-store.ts`; mounted as `<ConvexSync/>` in `src/app/_layout.tsx`.

## Context

The local-first foundation has shipped: the local MMKV store is the single source of
truth, Convex is out of the runtime path, and the app works with no account and no
network. We now want signed-in users to **sync their tasks across devices** (e.g. an
iPad and an Android phone).

Requirements (from the product owner):
- Edits made **offline** queue locally and flush to the server on reconnect.
- Writes are **idempotent** — a replayed/retried write never creates a duplicate.
- Multiple clients stream writes concurrently and must **converge**.
- **Convex is the source of truth** — on conflict, the server wins.
- Clients keep a **queue of optimistic writes** (UI stays responsive/offline-capable).

Hard constraint: this must **not** regress the no-account / offline default. The sync
engine is inert until a user signs in; signed-out behaviour is exactly today's.

## Decision

Adopt an **optimistic local cache + offline outbox over an authoritative server**.
Two axes, deliberately separated (they sound contradictory but aren't):

- **Data path is local-first:** the UI always reads/writes the local store and never
  blocks on the network — signed in or out.
- **Conflict authority is the server:** when local optimistic state and the server
  disagree, **the server wins**. Local writes are *provisional* until confirmed.

Concretely:

1. **No CRDT** (no Yjs). Rationale below.
2. **Identity = idempotency.** Tasks already carry a client-generated UUID. Convex rows
   key on `(ownerId, clientId)` with a unique index, so every write is an **upsert by
   clientId** — replaying it, from any client, any number of times, cannot duplicate.
3. **Outbox = a dirty-set, not an operation log.** Mark touched tasks dirty and push
   their *current state*. Ten offline edits to one task collapse into one upsert.
4. **Conflict resolution = whole-row last-write-wins by `updatedAt`.** Deletes ride
   along as the `deletedAt` tombstone (already in the schema).
5. **Convergence = Convex's reactive subscription.** All writes funnel through Convex,
   so the server is the single serialization point; clients reconcile server → local.
6. **`session`/`meta` stays device-local and is never synced** — it describes *this
   device's* current pass, deleting a whole category of multi-device conflict.
7. **Counters never need cross-device merge — single-writer by construction.**
   `resistance`/`exposureCount` are written only by `assess`, i.e. only during a Plan
   session, which is single-device — so concurrent increments don't arise. The UX "Ready"
   count is derived from task *status*; "Done" is the device-local session tally. No synced
   counter exists to merge.

### Why not a CRDT (Yjs)?

CRDTs earn their complexity when there is **no central authority** and peers must merge
**fine-grained concurrent edits to a shared mutable structure**. We have the opposite of
every premise:

| CRDTs are for…                         | Glide's situation                            |
| -------------------------------------- | -------------------------------------------- |
| No central server; peers merge         | **Convex is a central serialization point**  |
| Co-edited shared documents (text/rich) | **Independent task records, coarse ops**     |
| Character-level concurrent edits       | `create` / `assess` / `complete`, one op     |
| Multi-user real-time collaboration     | **Single user, multiple devices**            |

A central authoritative server is the thing CRDTs work hard to *avoid needing* — we
already have a good reactive one. Yjs would add a CRDT runtime, document encoding, and a
"peers converge on their own" model that fights "Convex decides," for zero benefit here.
The single-user / multi-device framing is what makes this the easy sync case. (If this
ever became a shared, real-time co-edited board, revisit.)

## Design

### Convex schema (additions)

Mirror `LocalTask` (drop the legacy `context`/`minutes`/`lane`) and add ownership + sync
keys. The Convex `_id` stays internal; **`clientId` is the cross-device identity.**

```ts
tasks: defineTable({
  ownerId: v.id('users'),        // scopes every row to an account
  clientId: v.string(),          // the client-generated UUID (TaskId)
  title: v.string(),
  resistance: v.number(),
  status: taskStatus,            // 'open' | 'ready' | 'done'
  exposureCount: v.number(),
  createdAt: v.number(),
  updatedAt: v.number(),         // the LWW key
  lastDecision: v.optional(decision),
  completedAt: v.optional(v.number()),
  deletedAt: v.optional(v.number()),   // tombstone
})
  .index('by_owner_client', ['ownerId', 'clientId'])   // unique upsert target
  .index('by_owner_updated', ['ownerId', 'updatedAt']), // incremental pulls
```

The existing per-operation mutations (`createTask`/`assess`/`complete`/…) become
obsolete under this model — sync is push/pull, not per-op RPC. Remove them when sync
lands (keep `convex/tasks.ts` as reference until then).

### Server functions

- **`pushTasks({ tasks: TaskRow[] })`** — for each row, upsert by `(ownerId, clientId)`:
  insert if absent, else patch **only if `incoming.updatedAt >= stored.updatedAt`**
  (LWW). Idempotent by construction. `ownerId` comes from the authenticated identity,
  never the client. Returns each row's applied `updatedAt` so the client can confirm.
- **`getTasks({ since?: number })`** — reactive query returning the caller's rows
  (optionally only those with `updatedAt > since`, via `by_owner_updated`) for the
  subscription. Tombstoned rows are included so deletes propagate; the client filters
  them (`useBoard` already drops `deletedAt`).

### Client (extends the local store)

`LocalTask` already has `id` / `updatedAt` / `deletedAt`. Add two fields:

- `ownerId?: string` — unset for local-only rows; stamped on first sign-in (adoption).
- `syncedAt?: number` — the `updatedAt` the server last confirmed. **A row is dirty iff
  `updatedAt > (syncedAt ?? 0)`** — no separate dirty-set needed, and it records exactly
  which version is pending.

Every mutator already bumps `updatedAt`; that alone now marks the row dirty.

A **sync engine** behind the [task-repository.ts](../../src/data/task-repository.ts) seam,
active only when **signed in + online**:

- **Push loop:** collect rows where `updatedAt > syncedAt` → `pushTasks(batch)`. On ack,
  set `syncedAt = updatedAt` **only for rows whose `updatedAt` didn't advance during the
  push** (an edit mid-flight keeps the row dirty for the next round).
- **Pull loop:** subscribe to `getTasks`. For each server row `R` (clientId `C`):
  - no local row → insert `R`.
  - local row **clean** (`updatedAt == syncedAt`) → overwrite with `R` (server wins).
  - local row **dirty** → keep local; our pending edit carries a newer `updatedAt`, so it
    wins when it pushes. (Globally: the write with the newest `updatedAt` wins everywhere.)
- **Reconnect** = re-run the push drain; the subscription auto-resumes. No special path.

### Adoption on first sign-in

Tasks created while signed-out have `ownerId == undefined`. On first sign-in, stamp every
local row with the new `ownerId` and bump `updatedAt` (→ dirty) so they flush up and
appear on the user's other devices. Nothing is lost when an offline user finally logs in.

### Auth (prerequisite)

Sync needs an `ownerId`, which needs auth. Recommend **Sign in with Apple** (iOS-first
feel) via Convex Auth, Android later. The engine stays inert until an identity exists.

## Consequences

**Pros**
- Small — a `pushTasks` mutation + `getTasks` query + a sync engine and two store fields;
  ~a few hundred lines. Reuses the foundation (client UUIDs, `updatedAt`, tombstones, seam).
- Offline-robust, multi-client convergent, idempotent, no CRDT runtime.
- Different devices editing **different** tasks never conflict — independent rows just
  both apply.

**Limits / risks**
- **`updatedAt` is a wall-clock LWW key → clock skew** across devices can let a stale edit
  win ("last to sync" vs "last edited"). **Accepted** for one user's roughly-synced devices
  (see §Decisions) — "the last edit I made wins" is the intended semantics. *Escape hatch if
  skew ever bites:* a hybrid logical clock (HLC).
- **Counters don't merge — and don't need to.** `resistance`/`exposureCount` change only
  during planning (sole writer: `assess`), which is single-device, so there's no concurrent
  increment to lose. Not a risk; called out only because it looks like one.
- Requires auth before any sync exists.

## Decisions & open questions

1. **`updatedAt` authority — RESOLVED: client wall-clock.** The LWW key is each device's
   wall-clock at edit time, so "the last edit I made wins" regardless of server arrival
   order. Validated against the prototype (`prototypes/convex-sync`, `client-clock` mode).
   *Accepted caveat:* relies on devices' clocks roughly agreeing — a badly-wrong device
   clock could let a not-actually-last edit win. A non-issue for NTP-synced consumer
   devices; revisit with a hybrid logical clock only if it ever bites.
2. **Counter exactness — RESOLVED: not needed.** `resistance`/`exposureCount` are written
   only during planning (`assess` is the sole writer), which is single-device — single
   writer by construction, so concurrent offline increments don't arise. The UX counts
   aren't synced counters anyway (Ready = derived from status; Done = device-local tally).
   There is no counter to merge. *If* a future feature ever incremented a counter from
   multiple devices, the fit would be an append-only **decision log** (immutable events
   keyed by a client event-id → idempotent by the same pattern as tasks; yields the count
   *and* history for free). **Rejected: `@convex-dev/sharded-counter`** — it targets write
   *contention* a single-user app doesn't have, its `add()` isn't idempotent under the
   at-least-once outbox (forcing a dedup table), and it splits the model.
3. **Auth provider:** Sign in with Apple via Convex Auth — confirm.
4. **Push mechanics:** batch size, backoff, partial-failure handling.

## Prototype plan (next)

A throwaway spike to validate the risky bits before committing:
1. Add the `ownerId`/`clientId` schema + `pushTasks`/`getTasks`; stub auth with a fixed
   `ownerId`.
2. Add `ownerId`/`syncedAt` to the store + a minimal sync engine behind the repository seam.
3. Drive **two simulators** (iPad + phone) and watch convergence via the reactive pull.
4. Kill the network mid-edit, queue several creates, reconnect → confirm the outbox flushes.
5. **Double-push** the same batch → confirm idempotency (no duplicates).
6. Bump the same task's `resistance` on both devices offline → observe the LWW outcome and
   decide whether counter exactness matters.
