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.
Context
Section titled “Context”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.
Decisions
Section titled “Decisions”- 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 reachinglocalReadReady. 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 theuserId→storeIdregistry, so the cached identity selects the right store with no network.) - The
syncWiredread 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: ifbootReport.syncStartMsshows the per-group wiring dominating warm paint, choose then — with numbers — between an upstreamlocalReadReadyread seam for eager relations and consistency- group batching. - 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. - Registry tiers. Entries classify into: paint-required eager (a ruthlessly small set whose
per-group
groupReadygates 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. - The snapshot layer is removed entirely — no interim optimisation.
readSyncSnapshotand all snapshot state, thewatchReadmechanism, andqueryLiveOnceare deleted (with most of thelive-rowsbridge wrapper). Reads become@pgxsinkit/reacthooks (SyncClientProvider,useLiveDrizzleRows,useLiveQueryRaw) and directly awaited builders against the attached client —AttachedSyncClientis aSyncClient, 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. - 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.
- 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
closeclearingengineReadyeagerly; a best-effortengine-teardownhandshake on pagehide;createSyncAccessHandle({ mode: "readwrite-unsafe" })(the Web Locks election already guarantees a single writer, making OPFS-level exclusivity redundant).
Alternatives rejected
Section titled “Alternatives rejected”- 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
localReadReadynow: 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.
Consequences
Section titled “Consequences”- 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-dataloseswatch-read.tsandqueryLiveOnce(and most oflive-rows.ts); emergent gains a@pgxsinkit/reactdependency. 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.