Offline Sync
Works offline is a property of a resource, not of a client. One engine, one wire protocol, and three storage adapters, so the same resource behaves the same way in a browser, on a phone and in the desktop app.
The Go API has served /api/sync/pull and /api/sync/push since v3.60, and grit generate resource registers every model with the sync registry as it generates it. The server side has been ready for a while. What was missing until v3.148.0 was a client outside apps/desktop.
grit add offline
That writes packages/sync and adds it to whichever of apps/web, apps/admin and apps/expo your project has. By default it mirrors every model the API registered, read out of routes.go rather than from a list that can go stale. Narrow it with --models products,orders.
What it does
Three things, and it is worth being precise about each.
A mirror. Every row the client has pulled, kept locally. Reads come from the mirror, so a list renders at the same speed and through the same code whether or not there is a network.
An outbox. Every local change that has not reached the server. At most one entry per row: a second edit to the same record squashes into the entry already waiting, so the outbox stays proportional to the rows you touched rather than the edits you made. Creating a row and then deleting it cancels both ends, rather than sending the server a delete for something it has never seen.
A version check. Every push carries the version the client believes the server holds. If they disagree, the server answers VERSION_CONFLICT with its current row attached, so a merge UI has both sides without a second round trip. The conflicted change is parked rather than retried, because replaying it would overwrite exactly the state the user is being asked about.
Using it
useOfflineResource is the hook that makes the promise concrete. It returns rows from the mirror and writes through the outbox, and the calling screen does not branch on connectivity anywhere.
"use client";import { useOfflineResource, useSyncStatus } from "@myapp/sync/react";export default function ProductsPage() {const { data, loading, create, update, remove } = useOfflineResource<Product>("products");const { state, pending, syncNow } = useSyncStatus();return (<div><SyncBadge state={state} pending={pending} onSync={syncNow} />{loading ? <Spinner /> : <ProductTable rows={data} onDelete={remove} />}{/* Returns an id immediately, whether it reached the server or the outbox */}<NewProductForm onSubmit={(values) => create(values)} /></div>);}
useSyncStatus gives you the badge: synced, syncing, offline or conflict, with the pending count and the time of the last successful sync.
Conflicts
useSyncConflicts returns the changes waiting for a decision, each carrying both the local values and the server's. There are two ways to end one: resolve with the merged row, which replays it claiming the version the user actually saw, or revert, which discards the local change and puts the server's version back.
const { conflicts, resolve, revert } = useSyncConflicts();return conflicts.map((c) => (<ConflictRowkey={c.model + c.entityId}mine={c.data}theirs={c.serverData}message={c.conflictMessage}onKeepMine={() => resolve(c.model, c.entityId, c.data!, c.serverVersion)}onKeepTheirs={() => revert(c.model, c.entityId)}/>));
Declaring how a resource behaves offline
Every mirrored model has offline semantics whether or not anyone chose them. Left alone they are: mirror everything, ask a human about every conflict, no age limit, nothing kept off the wire. Those are reasonable defaults, and they are also exactly the kind of thing that should be stated rather than assumed, because offline products fail quietly and an assumption is invisible.
A sync: block in the resource definition states them:
name: Salefields:- name: referencetype: string- name: totaltype: float- name: payment_methodtype: string- name: draft_notetype: textsync:mode: offline_first # or online_only, to keep it off devices entirelyconflict: server_wins # manual (default) | server_wins | client_winsfields: [reference, total, payment_method]local_only: [draft_note] # never leaves the devicemax_offline_age: 72h
grit generate resource Sale --from sale.yaml
| Conflict | What happens on a version mismatch |
|---|---|
| manual | The change is parked with both versions attached and a human decides. The default, because silently discarding somebody's work should be opt-in. |
| server_wins | The client's change is discarded and the server row replaces it locally. Nobody is prompted. For records a back office owns: stock, prices. |
| client_wins | The client's change overwrites the server's. For records with a single author, where the version check protects nothing. |
The policy is enforced on the server. Clients read it from GET /api/sync/policy to render the right UI, but a rule an old build can ignore is not a rule, so the decision is made where a request cannot argue with it. A client that cannot reach the server falls back to the defaults rather than refusing to open, since being unreachable is the case this whole feature exists for.
local_only is stripped on both sides: the client does not send it and the server would drop it anyway, which is what makes it a promise rather than a convention. max_offline_age is advisory by necessity, because a client that has not synced is by definition not talking to the server. What it buys is a client that can say so, through its own stale badge state, instead of showing three-day-old stock levels in green.
grit sync doctor
Everything this checks fails silently. A field allowlist naming a column that does not exist errors nowhere: it excludes the real column, and every client mirrors rows with the value missing. A model with no Version field cannot detect a conflict at all, so it takes whichever write landed last and nobody is told.
grit sync doctorSync configuration: 5 model(s) registeredblogs, invoices, sales, uploads, userserror sales: sync fields allowlist names "totl", which the model does not haveFix the name. An allowlist entry that matches nothing silently dropsthe real column from every mirror.info sales: server_wins: an offline edit is discarded if the server moved onThe user is not asked. Make sure the screen says so before they type.
It also reports a policy that is declared but not enforced, which happens when a project's sync handler predates policies. That is the worst state to be in: routes.go says server_wins, the handler keeps prompting, and the declaration reads as though it took effect.
Diagnostics in the app
An outbox that stopped draining three days ago looks exactly like an outbox with nothing in it. useSyncHealth is the difference: pending count, conflict count, the age of the oldest queued change, time since the last successful sync, and per-model row counts.
const { health } = useSyncHealth();if (!health) return <Spinner />;return (<dl><Stat label="Queued changes" value={health.pending} /><Stat label="Awaiting a decision" value={health.conflicts} /><Stat label="Oldest queued" value={health.oldestPendingAgeSeconds} unit="s" /><Stat label="Last synced" value={health.lastSyncAgeSeconds ?? "never"} />{health.stale && <Warning>Past the declared offline age limit</Warning>}</dl>);
Encryption at rest
On mobile and desktop this is real, and it belongs to the caller: SQLiteAdapter takes an already-open database, so hand it an SQLCipher connection whose key came from the OS keystore and the mirror is encrypted with no change on this side.
In a browser it is not offered, because it could not be honest. There is no keystore, so any key the page holds sits in JavaScript beside the data it is meant to protect, and anything with script access to the origin has both. An “encrypted IndexedDB” option would defend against a threat nobody has while implying it defends against the one people picture. Keep genuinely sensitive fields off the device with local_only, or mark the resource online_only.
Where the mirror lives
The engine holds no storage-specific code. It talks to a StorageAdapter, and three ship:
| Adapter | For | Notes |
|---|---|---|
| IndexedDBAdapter | Web, PWA | Keys on [model, id]; no size ceiling worth worrying about |
| SQLiteAdapter | Expo | expo-sqlite, WAL mode, the same three tables the desktop engine keeps |
| MemoryAdapter | Tests, server rendering | Nothing survives a reload, which is the point |
The web setup picks between IndexedDB and memory at runtime rather than assuming a browser: a Next.js server render has no IndexedDB, and a component that reaches for it there throws during render instead of degrading.
The desktop engine
Grit Desktop keeps its Go engine. It is the same wire protocol, the same record and outbox shapes, and the same conflict semantics, running in-process against GORM rather than over a storage interface. A row means the same thing on a laptop as it does on a phone.
