# Local-first data layer

Glide is **local-first**. Every feature works with no account, no network, and no
link to Convex at all: the single source of truth is an on-device store, and the UI
only ever talks to it. Convex is never in the data path — it is an *optional,
account-scoped sync layer* (now implemented in single-user mode; see "The sync seam"
below and [convex-sync.md](convex-sync.md)), and the basis for the paid multi-device
tier ([subscriptions.md](subscriptions.md)).

If you came here from the widget or a screen wondering "where does the data come
from now?" — it's the local store described below, not `api.tasks.*`.

## Layers

```
UI (screens + domain hooks)
   │  reads  →  useBoard()                 (reactive selector)
   │  writes →  tasks.* (task-repository)  (imperative seam)
   ▼
Zustand store  (src/data/task-store.ts — the source of truth)
   ▼  persist
MMKV           (src/data/storage.ts)
```

- **`src/data/task-store.ts`** — the Zustand store. Holds `tasks: LocalTask[]` and a
  device-local `session`. Its mutators (`createTask`, `assess`, `complete`,
  `startPlanSession`, `ensureStarterTasks`) mirror the old `convex/tasks.ts`
  functions one-for-one, so the swipe loop's emergent behaviour is unchanged.
- **`src/data/use-board.ts`** — `useBoard(limit)`, a reactive selector returning the
  same `{ open, ready, done, doneThisSession }` shape the old `getBoard` query did.
  Buckets by status, drops tombstoned rows, sorts open/ready ascending by `sortAt`
  and done descending. Memoised, so an unrelated store change won't churn the
  reference (the widget bridge relies on this).
- **`src/data/task-repository.ts`** — `tasks`, the imperative write surface and the
  **seam** a future sync engine decorates. The UI imports this for writes and
  `useBoard` for reads; it never imports the store directly.
- **`src/data/storage.ts`** — the MMKV instance + the Zustand `persist` adapter.
- **`src/data/ids.ts`** — `newTaskId()`, a client-generated UUID (`expo-crypto`).

## Why a repository seam

It's the deep-module boundary from `TASTE.md`: the UI depends on a small, stable
interface (`useBoard` + `tasks.*`), and everything about *how* data is stored and
(later) synced lives behind it. Swapping MMKV, or adding the sync engine, changes
nothing above this line.

## The `LocalTask` shape is sync-ready by design

Even though sync is deferred, the persisted record already carries what a future
merge needs, so we never have to migrate stored data to add it:

- **`id` is a client-owned UUID**, not a server key. A task is identifiable the
  instant it's created offline — the precondition for reconciling without a
  round-trip. This replaced Convex's `Id<'tasks'>`; the UI always treated identity
  as opaque, so the change was contained to the type.
- **`updatedAt`** — last-write timestamp, the basis for last-write-wins merges.
- **`deletedAt`** — a soft-delete **tombstone**. No UI deletes tasks today, but the
  field exists (and `useBoard` filters on it) so deletes can propagate later.
- **`resistance` / `exposureCount` are counters** — flagged as the *merge-sensitive*
  fields. Naive last-write-wins loses concurrent increments; handle them
  deliberately when sync lands.

## Session state is a local boundary

`session.planSessionAt` describes *this device's current pass boundary*, not shared task
data. It is deliberately **not** part of the synced collection. The visible
`doneThisSession` value is derived from synced task rows whose `completedAt` is newer than
that local boundary, so a task completed on another signed-in device during the same active
pass still moves the local Done pill.

## The sync seam → Convex (implemented, single-user)

> Full design + decisions in [convex-sync.md](convex-sync.md).

The **data path stays local-first** (the UI always reads/writes the local store, so offline
works even when synced), while **Convex is the conflict authority** (server wins; local
writes are optimistic until confirmed). The sync engine lives in
[`src/data/sync`](../../src/data/sync) behind the `task-repository` seam — an imperative
`ConvexReactClient`, **no `ConvexProvider`**, mounted as `<ConvexSync/>` in `_layout.tsx`. It:
- pushes dirty rows (`updatedAt > syncedAt`) to `pushTasks` as idempotent upserts keyed by
  the client UUID, and
- subscribes to `getTasks`, reconciling server rows back into the store (last-write-wins by
  `updatedAt`). Local rows are *adopted* into the account via `adoptOwner()`.

It runs in **single-user / dogfood mode**: the account is a fixed constant resolved by
`currentOwnerId()` (client, `src/data/sync/owner.ts`) and `getOwnerId(ctx)` (server,
`convex/sync.ts`) — the two swap points for auth.

`EXPO_PUBLIC_CONVEX_URL` stays **optional** — with no URL the engine is a no-op and the app
runs fully local. `convex/` (schema + `sync.ts`) is the sync backend.

## Gotchas

- **MMKV is a native module.** Adding/upgrading `react-native-mmkv` (v4, Nitro-based —
  pulls `react-native-nitro-modules`) requires a **dev-client rebuild** (`expo
  run:ios`), not just a Metro reload.
- **Hydration is synchronous.** MMKV reads are sync, so the store is populated before
  the first render — there's no loading flash. The `'loading'` members on
  `EngineStatus`/`ActionListStatus` are therefore never emitted (kept for shape only).
- **Don't reintroduce a hard Convex dependency at launch.** The old provider threw if
  `EXPO_PUBLIC_CONVEX_URL` was unset. Anything wired into app start must tolerate its
  absence, or the local-first guarantee breaks.
