ADR 0001 — Offline strategy via snapshot sync
Status: accepted · Date: 2026-06-24 · Implemented baseline: 2026-07-27 · Supersedes: none
Context
Section titled “Context”Tradespeople work on building sites with poor or no signal. The app must let them:
- Browse existing quotes/clients on-site (read).
- Create and edit drafts on-site (write to own data, no state transition).
- Send a quote (state transition
draft → sent, generatespublic_token, customer link goes live). - See accepts/payments come in (server-originated state changes).
Current architecture (per architecture-schema-cost-model.md): supabase-js as thin HTTP client, every write goes through a Postgres definer RPC, RLS enforces ownership, one writer per state transition. supabase-js is online-only; loss of network = app is dead weight.
Three offline shapes were considered:
| Option | Build cost | Offline browse | Offline create | Multi-device merge |
|---|---|---|---|---|
| A. Optimistic UI + mutation queue (TanStack Query + persister) | ~1 week | Last-viewed only | Yes (drafts in cache) | No |
| B. Snapshot sync + local SQLite mirror | ~3-5 days on top of A | Full history | Yes | Read-only mirror, no merge needed |
| C. Full local-first with bi-dir sync (PowerSync / Electric) | ~3-4 weeks or $35/mo + integration | Full history | Yes | Built-in CRDT-ish |
Decision
Section titled “Decision”Adopt option B (snapshot sync) as the MVP offline strategy, layered on top of option A.
- Drafts: local-first in expo-sqlite. Create/edit/delete fully offline. TanStack Query cache backed by SQLite persister.
- State transitions (
send_quote,accept_quote_by_token,void_quote): server-authoritative. Mutation queue retries on reconnect. UI gates share-sheet on confirmed send. - Browse history: periodic pull of owner-scoped snapshot into SQLite via single definer RPC
get_my_snapshot(p_since timestamptz). Read-only mirror — never written to by user actions directly. - Teams / approval queues: stay online-first for the initial offline pass. The owner mirror contains the signed-in user’s own sole-trader data only, not rows visible through the manager overlay.
Defer option C (PowerSync) until a user complains about a real limit not covered here (e.g. true live sync across phone + tablet, or browse-everything always-fresh).
Why snapshot sync wins for this stack
Section titled “Why snapshot sync wins for this stack”- Solo tradesperson, mostly one device → no multi-writer merge problem → CRDT machinery is overkill.
- Server is already authoritative for state transitions (one-writer rule, advisory locks for quote numbering, RLS, audit fields like
accepted_ip). Local-first would force re-implementing these client-side or sync-handler-side. - Customer-facing accept lands on the server (web
/q/:token). That write is server-first by definition; SQLite is downstream regardless. - Snapshot sync = boring, easy to reason about, no third-party sync runtime. The RPC is one function we own.
Schema impact
Section titled “Schema impact”Add now (cheap, future-proofs):
updated_at timestamptz not null default now()onclientsandquote_items(already onquotes). Backfill in migration.deleted_at timestamptzonclients,quotes,quote_items. Soft-delete only — hard deletes leave stale rows in SQLite mirrors with no signal to remove them.last_sync_atper device, stored client-side in expo-secure-store. Not a schema concern.
New RPC:
get_my_snapshot(p_since timestamptz default null) returns jsonb- Returns owner-scoped
{ profile, clients, quotes, quote_items, invoices, invoice_quotes, payments, rebates, reminders, deleted: { ... } }filtered byauth.uid()andupdated_at > p_since(or all rows if null). security definer, granted toauthenticated, must explicitly filter byauth.uid()(no RLS bypass leak — same risk class as existingget_quote_detail).- Single round-trip on app open; subsequent calls incremental.
- Returns owner-scoped
Client shape
Section titled “Client shape”- Initial baseline uses TanStack Query persisted through
expo-sqlite/kv-store, so previously loaded core screens render after a cold start without signal. - Entity-mirror follow-up: expo-sqlite tables mirror entity shape. Store each entity as a
jsonbblob keyed by id to avoid migration burden on column adds:quotes(id text primary key, json text, updated_at integer)clients(id text primary key, json text, updated_at integer)quote_items(id text primary key, quote_id text, json text, updated_at integer)- JSON1 extension lets us query
json_extract(json, '$.profile_id')when needed.
- TanStack Query rehydrates from SQLite on cold start, then triggers a background
get_my_snapshot(last_sync_at)to refresh. - Mutation queue paused via
onlineManager.setOnline(NetInfo.isConnected); drains on reconnect. - Drafts created offline get a client-generated UUID; server accepts the UUID as
quotes.idon first sync (already supported bygen_random_uuid()default — pass explicit id from client instead).
Gotchas
Section titled “Gotchas”- Initial sync on slow signal could be megabytes. Mitigate by paginating snapshot newest-first and rendering UI from whatever lands first.
public_tokengeneration currently server-side insend_quote. For offline-send (deferred), token would move client-side. Defer that decision until offline-send is requested — current model requires network to send anyway, which is acceptable behaviour (you can’t deliver a link offline).- Quote numbering (
next_quote_number) needs server. Drafts created offline carry a local placeholder (DRAFT-<short-uuid>) and are renumbered toQ-NNNNon first sync. UI shows placeholder until sync completes. - Photos not covered. Stay in Supabase Storage; cache strategy is a separate decision.
- Schema drift: storing as jsonb avoids local migrations on add-column. Removing/renaming a column still needs a local cache bust on app upgrade (bump a
SCHEMA_VERSIONconstant, wipe SQLite if mismatched).
Consequences
Section titled “Consequences”- All new tables in MVP scope must include
updated_atanddeleted_atfrom the start — add to the schema-conventions section ofarchitecture-schema-cost-model.md. - All writers (definer RPCs) must touch
updated_aton every state change (existingsend_quote,void_quote,accept_quote_by_tokenalready do; verify on review). - Soft-delete becomes the only legal delete shape. No
delete from ...in RPCs — replace withupdate ... set deleted_at = now(). get_my_snapshotjoins the security-critical RPC list. Add to/supabase-security-auditchecklist.
Implementation status
Section titled “Implementation status”Baseline implemented 2026-07-27:
- App cache dependencies installed: TanStack Query, TanStack persist client, NetInfo, expo-sqlite.
- Root app wrapped in an offline provider: SQLite-backed query persistence, NetInfo-backed online state, foreground focus refresh, paused-mutation resume hook.
- Core read surfaces moved to persisted queries: home summary/activity/ready-to-invoice, quote/invoice lists, voided lists, new-quote clients/templates, quote detail, invoice detail/reminders.
- Migration
20260727000000_offline_snapshot_sync.sql: adds missingupdated_at/deleted_atmarkers, update triggers, and authenticated owner-scopedget_my_snapshot(p_since).
Draft outbox slice implemented 2026-07-27:
- Migration
20260727000001_quote_draft_client_id.sql: adds idempotentcreate_quote_draft_with_id(...)so offline-created quote UUIDs survive replay. - App local quote-draft outbox in
lib/quotes/local-drafts.ts, persisted throughexpo-sqlite/kv-store. - New quote flow saves locally when the device is offline or a likely network failure happens mid-save.
- Local drafts appear in the quote list/detail immediately as
DRAFT-<short-id>and sync automatically on reconnect. - Send/share remains server-confirmed: local drafts show a saved-offline callout and do not expose send/discount actions until replay creates the server draft.
Offline edit slice implemented 2026-07-27:
- Local drafts can be reopened before sync and edited on-device.
- Edits preserve the local quote UUID and pending replay payload.
- Editing a draft resets failed/syncing local state back to pending so reconnect replay uses the latest saved version.
Offline hardening slice implemented 2026-07-27:
- App-wide connectivity state drives a persistent offline banner.
- Reconnect triggers local draft replay without requiring a manual refresh.
- Quote and invoice detail screens disable or guard online-only actions while offline.
- Failed local draft syncs expose the last error plus retry, edit, and discard paths.
Snapshot mirror ingest slice implemented 2026-07-27:
- App owns a separate
sawdust-offline-snapshot-v1.dbSQLite mirror. - Snapshot rows are stored in normalised entity tables with JSON payloads and local sync metadata.
get_my_snapshot(p_since)is pulled on app start/reconnect and applied incrementally usingoffline_meta.last_sync_at.- Tombstones from the RPC remove stale local rows for soft-deleted entities.
Quote mirror read slice implemented 2026-07-27:
- Quote list falls back to the SQLite mirror when the live Supabase query fails.
- Quote detail falls back to mirrored quote/client/profile/items data when
get_quote_detailis unavailable. - Voided quotes, clients, and quote templates also fall back to mirrored rows.
- Mirror fallback requires a local Supabase session, so signed-out devices do not render stale mirrored business data.
Remaining mirror read slice implemented 2026-07-27:
- Invoice list, voided invoice list, invoice detail, invoice summary, live-invoice lookup, invoiceable quotes, and ready-to-invoice fall back to the SQLite mirror.
- Home recent activity falls back to mirrored quotes/invoices and reuses the same merge/label path as live data.
- Mirror-derived totals continue to account for payment rows when calculating owed/overdue values.
Queued action retry slice implemented 2026-07-27:
- Server-authoritative quote/invoice state RPCs are persisted when they fail with likely network errors.
- Reconnect drains the queued actions before local draft replay and snapshot refresh, then invalidates affected quote, invoice, home, and activity surfaces.
- Validation, permission, and other non-network replay failures are not retried indefinitely; they are captured and discarded from the local queue.
Explicit offline action queue UX implemented 2026-07-27:
- Quote, invoice, and approval screens let users queue server-authoritative actions while already offline instead of blocking the tap.
- The app banner shows pending queued-action count so users can see work waiting to sync.
- External sharing paths remain online-only because they need a real public token/link handoff to another app.
Still pending:
- Manual offline matrix on a physical device: airplane mode mid-edit, low-connectivity mid-send, app kill during sync, reopen offline, reconnect, two-quote race.
Potential implementation steps
Section titled “Potential implementation steps”Not committed — sequenced sketch for when this ADR moves to accepted. Each step shippable on its own.
-
Schema prep migration (
projects/db/supabase/migrations/)- New migration file (init is frozen post-push). Add
updated_at timestamptz not null default now()anddeleted_at timestamptztoclientsandquote_items. Backfillupdated_atfromcreated_atfor existing rows. - Add
updated_attrigger function + per-table triggers so any update bumps it (avoid relying on every writer to remember). - Replace any
delete fromin RPCs withupdate ... set deleted_at = now(). Auditclientsdirect CRUD policy — owner delete becomes soft-delete via RLS-allowed update.
- New migration file (init is frozen post-push). Add
-
Docs update
architecture-schema-cost-model.mdschema-conventions section: add “every table carriesupdated_at+deleted_at; hard deletes are illegal”.CLAUDE.mdmigration rules: add soft-delete +updated_attrigger to the new-table checklist.
-
get_my_snapshotRPC (new migration)- Signature:
get_my_snapshot(p_since timestamptz default null) returns jsonb. security definer,set search_path = public, explicitauth.uid()filter on every joined table.- Returns
{ profiles, clients, quotes, quote_items, deleted: { ... } }.deletedarrays = ids withdeleted_at > p_since. - Revoke from
public, anon, authenticated; grant toauthenticatedonly. - Add to
/supabase-security-auditchecklist.
- Signature:
-
Client cache layer in
/app(no SQLite yet — start with in-memory persistence to derisk)- Install
@tanstack/react-query+@tanstack/query-async-storage-persister+@react-native-community/netinfo. - Wrap root in
QueryClientProvider, wireonlineManagerto NetInfo. - Convert existing supabase-js calls in
/apptouseQuery/useMutation. RPC names stay identical; mutationonMutatedoes optimistic cache update. - Persist cache to expo-secure-store first (fastest path). Validates the shape before SQLite work.
- Install
-
SQLite mirror (replace secure-store persister)
expo-sqliteschema: jsonb-blob tables as in ADR client shape.- Custom persister adapter for TanStack Query that writes to SQLite tables instead of one blob.
- Bootstrap on app open: read SQLite → hydrate cache → render → background-fire
get_my_snapshot(last_sync_at)→ diff in → save back. SCHEMA_VERSIONconstant; mismatch on app upgrade wipes SQLite and re-syncs from scratch.
-
Offline draft creation
create_quote_draftalready takes items payload. Modify RPC (or addcreate_quote_draft_with_id(p_id uuid, ...)) to accept client-generated UUID so offline-created drafts keep stable identity through sync.- UI generates UUID +
DRAFT-<short>placeholder number locally; renders identically to a sent one. - Send is gated behind connectivity (TanStack mutation queue + share-sheet disabled until success).
-
Verification
- Manual test matrix on physical device: airplane mode mid-edit, airplane mode mid-send, kill app mid-sync, reopen offline, reconnect, two-quote race.
- No automated offline tests for MVP — manual is enough at solo-trader scale.
Revisit when
Section titled “Revisit when”- A user reports needing live updates across two devices (phone + tablet) without manual refresh → re-evaluate PowerSync.
- Snapshot payload regularly exceeds ~5MB for a single user → switch to entity-typed normalised SQLite tables and per-entity incremental sync.
- Offline-send is requested (tradesperson wants to tap “Send” on roof and have it fire later) → move token generation client-side, add
pending_sendlocal state.