Skip to content

Offline-first boot contract and demolition of the provider snapshot layer

Status: accepted

Date: 2026-07-25

Relates to: ADR-0025/0026 (per-app sync providers and person-stamped projections), ADR-0032 (SharedWorker sync; reads over the worker bridge), ADR-0051 (docs sites; this ADR publishes through the engineering site), and upstream pgxsinkit ADR-0040 (live-query manager: fingerprint dedup, keepAliveMs), ADR-0041 (localReadReady attach contract), ADR-0049 (capability-driven engine placement), docs/runbooks/consuming-conform-ed-and-pgxsinkit.md.

Emergent is an offline-first platform, but the app layer did not honour it. With a fully synced local store, closing the tab and navigating back to /offerings took ~5 seconds to paint five rows — and with no connectivity it never painted at all, because the paint gate was client.ready: network catch-up of all 45 eager shape groups. Everything the toolkit provides for local-first reads was unused: attach already resolves at localReadReady (zero network), syncWired is local wiring only (shape streams are kicked off, never awaited — it resolves offline), catch-up is awaitable per group (groupReady), and worker-side live queries dedupe by fingerprint and stay warm across subscriber turnover (keepAliveMs).

The structural cause is a provider snapshot layer, present in all three apps. In learner-web: one React context holds ~25 assembled domain fields; 30 persistent table watches re-run a monolithic 31-read snapshot on any change to any watched table, re-deriving every field (survey delivery, CAT session state, wallet, contents…) regardless of what changed, and re-rendering every consumer (21 files). Each snapshot read goes through queryLiveOnce — a full live-query registration in the worker followed by immediate teardown, per read, per snapshot run. Judged on its present-day merits, the layer is a second store duplicated on top of the local database it reads from: coarser reactivity than the database’s own change tracking, no snapshot isolation across its 31 reads (its apparent consistency is illusory), a monolithic paint gate coupling every route to every feature’s reads, and a large hand-maintained assembly surface. The router also blocks on auth.getSession() before mounting, which performs a network token refresh for any returning user whose token expired while away — serialising every route’s first paint behind GoTrue.

  1. Warm paint gate — nothing network. On a returning load, paint gates on exactly: a cached session present in local storage (read without getSession() and without awaiting any refresh) plus the user’s mapped store reaching localReadReady. Token refresh, sync wiring, and Electric catch-up all run behind the paint. If the background refresh fails, the app transitions to the login screen; the local store stays intact on disk for the next sign-in. (The store is bound per-user via the userId→storeId registry, so the cached identity selects the right store with no network.)
  2. The syncWired read park stands; no upstream read-seam change. Guarded reads issued in the pre-wire window wait for the local sync-wiring tail (verified network-free). Reopen trigger: if bootReport.syncStartMs shows the per-group wiring dominating warm paint, choose then — with numbers — between an upstream localReadReady read seam for eager relations and consistency- group batching.
  3. Catch-up never gates paint. caughtUp (client.ready) drives one subtle shell-level affordance only. Rows update silently in place as catch-up lands. A warm store that reads empty shows the ordinary empty state (local truth: last known nothing); a fresh store never paints before its required groups catch up, so no empty state is ever shown for data that merely has not arrived yet.
  4. Registry tiers. Entries classify into: paint-required eager (a ruthlessly small set whose per-group groupReady gates first-load paint), background eager (the offline promise — what a learner needs on-device without having visited it; deliberately generous, since after decisions 1–3 eager breadth no longer costs warm paint), and lazy (genuinely on-demand). Consistency- group batching of the 45 singleton groups is deferred: multi-shape groups are experimental upstream and Electric Cloud support has caching issues; tier 1 works unchanged with singleton groups.
  5. The snapshot layer is removed entirely — no interim optimisation. readSyncSnapshot and all snapshot state, the watchRead mechanism, and queryLiveOnce are deleted (with most of the live-rows bridge wrapper). Reads become @pgxsinkit/react hooks (SyncClientProvider, useLiveDrizzleRows, useLiveQueryRaw) and directly awaited builders against the attached client — AttachedSyncClient is a SyncClient, so the upstream hooks run against it as-is. Each route/feature owns its queries and derivations; each paints on its own first read. The providers slim to what they are actually for: attach lifecycle, auth token wiring, personId, the mutations facade, and status. Any gap found in the upstream hooks is fixed in pgxsinkit, never wrapped locally.
  6. One campaign, all three apps: learner-web first (establishes the per-feature hook conventions), then teacher-portal, then admin-console. Route-by-route migration within an app; a snapshot field is deleted when its last consumer moves; no monolith survives the campaign.
  7. Upstream stays untouched until measured. This includes the router engine-liveness gap (the engine is not associated with its host tab entry; a late-joining tab can be piped to a dead engine until the Web Locks election recovers) and the OPFS open retry ladder (worst-case budget ~4.5 s; the designed-for case is a worker terminated mid-synchronous-op — an idle close likely costs 0–300 ms). After the demolition lands, the boot rail measures real warm boots (emergent sets the tab-side debug flag in dev so the rail prints). The option list waiting on numbers: host-tab association / control-port close clearing engineReady eagerly; a best-effort engine-teardown handshake on pagehide; createSyncAccessHandle({ mode: "readwrite-unsafe" }) (the Web Locks election already guarantees a single writer, making OPFS-level exclusivity redundant).
  • Optimising the monolith before removing it (one-shot conversion of queryLiveOnce, parallel watch registration, domain-sliced snapshots): rejected — the layer duplicates the database and is condemned in full; deleted code needs no tuning.
  • An upstream read seam serving eager relations at localReadReady now: unnecessary for correctness (the park is network-free); revisit only on the decision-2 trigger.
  • Consistency-group batching now: deferred per decision 4.
  • A harder auth failure disposition (immediately wiping or locking the painted UI when the background refresh fails): the data is already on the device; withholding its display pending a server round trip adds no real security. Transition to login and leave the store intact.
  • Upstream engine-lifetime work on suspicion: measure first; the ladder’s worst case is a budget, not the typical cost.
  • Warm loads paint from local truth at localReadReady + wiring; first loads paint when the small paint-required tier catches up; offline reloads paint. The infinite offline skeleton is structurally impossible rather than merely unlikely.
  • packages/offline-data loses watch-read.ts and queryLiveOnce (and most of live-rows.ts); emergent gains a @pgxsinkit/react dependency. Feature code gains explicit, feature-local queries — reactivity scoped to the tables actually referenced.
  • The provider context stops being a data API; 21 learner-web consumer files (and their teacher-portal / admin-console counterparts) migrate to feature hooks during the campaign.
  • Measurement follows demolition: the boot rail decomposes real warm boots before any upstream lane opens. Further refinement is expected after all three apps are clean.