Scout — Full Product Context → feature documentation

sync-and-catalog-delivery

Scout's mobile app does not ship its catalog (categories, collections, campaigns, patches, patch types, patch↔collection memberships) inside the app bundle.

Summary

Scout's mobile app does not ship its catalog (categories, collections, campaigns, patches, patch types, patch↔collection memberships) inside the app bundle. It pulls the whole thing from one unauthenticated backend endpoint, GET /api/sync/content, on first launch, then keeps it warm with periodic deltas. The response is cached client-side in a TanStack Query cache (persisted to its own MMKV store), not in the app's Zustand store, which itself only persists a different MMKV key (scout-app-store) holding purely local/user data. The app is explicitly not offline-first: reading the already-synced catalog works offline, but the two things a "collect" actually needs — turning up new/changed content and confirming you're standing inside a polygon-based patch's boundary — both require a live network round trip. Publishing new content on the backend does not push to devices; a device discovers it by polling, gated by a 5-minute staleTime and refetch-on-foreground/refetch-on-reconnect, so a freshly published patch typically reaches an already-open app within 5 minutes and a backgrounded/relaunched app immediately (subject to that same staleness window).

Status

Live in production. This is the only mechanism the mobile app has for discovering catalog content — there is no bundled seed data and no CDN-fronted static catalog file.

Surfaces

How it works

Server side. SyncService.getContent(since?, isAdmin) (backend/src/sync/sync.service.ts:194) is a single method that serves both the full sync (no since) and the delta sync (since = an ISO timestamp) — the query shape barely changes between the two:

isAdmin is derived per-request from an optional bearer token or admin_token cookie via isAdminEmailFromToken (sync.controller.ts:19-21); admins get the unfiltered catalog (for in-app QA of unpublished/store-exclusive content), everyone else gets the filtered one. The filter is not gated on NODE_ENV — the prod droplet runs with NODE_ENV unset, and an env-based gate was the actual production bug this code fixed (sync.service.ts:196-201, regression-tested in backend/src/sync/__tests__/sync.service.admin-only.spec.ts).

Everything comes back as one JSON object, snake_case, built by a single $transaction of six findMany calls plus one raw-SQL pass computing two derived polygon metrics (bounding-box "reach" in meters and true polygon area) that get attached to each patch (sync.service.ts:232-271). The whole call is wrapped in withDbRetry for the transient "connection recycled by the pooler" case (sync.service.ts:228-251).

Client side. contentQuery (mobile/src/query/queries/content.ts:49) is a TanStack Query queryOptions object with staleTime: 5 min and gcTime: Infinity. Its queryFn deliberately does not just return what the server sent: it reads the previous cached value via queryClient.getQueryData, calls api.content.sync(prev?.syncedAt), transforms the response (snake_case → camelCase, per-entity transformX functions from mobile/src/domain/store.ts:150-444), and merges the incoming delta into the previous cache by id (mergeById, store.ts:445-452; mergePatchCollections, store.ts:453-464) before returning the merged whole as the new cache value. The delta cursor (syncedAt) is stored inside the cached value itself, not beside it, specifically so that losing the cache also loses the cursor and forces a correct full resync (comment at content.ts:28-36).

Refetching is driven by ordinary TanStack Query mechanics wired to React Native via mobile/src/query/bindings.ts:19-35: onlineManager bound to NetInfo, focusManager bound to AppState. That gives the query client's defaults — refetchOnWindowFocus: true, refetchOnReconnect: true (mobile/src/query/client.ts:25-33) — real triggers: content refetches when the app is foregrounded or the network reconnects, on top of the plain 5-minute staleness re-fetch on mount. SyncProvider no longer runs its own AppState/NetInfo listeners for this (it used to); a comment there enumerates exactly which responsibilities moved to contentQuery's staleTime and the bindings file (mobile/src/providers/SyncProvider.tsx:29-41). The one thing SyncProvider still adds is syncNow(force), which bypasses staleTime via queryClient.refetchQueries (used by the post-camera-roll-import screen to force a real content pull) — wrapped in refetchIfOnline so an offline caller doesn't hang forever waiting on a paused, never-resolving refetch (SyncProvider.tsx:100-122).

Persistence of the query cache (catalog data) is entirely separate from the Zustand "AppStore" persistence (user/local data): PersistQueryClientProvider (mounted in mobile/app/_layout.tsx:543) uses queryPersistOptions (mobile/src/query/persistOptions.ts:13) with maxAge: Infinity — explicitly chosen because the library's own default (24h) throws away the entire persisted query client, cursor included, past that age, which would force a ~6.4 MB full re-download (the number is from a comment measuring the real catalog, content.ts:44) for anyone who skips opening the app for a day.

Content-sync visibility in the UI

The user essentially never sees an explicit "syncing catalog" indicator. Two screens mount useContentSync() and gate on it differently:

The other sync/ surfaces (context, not catalog delivery)

backend/src/sync/ also hosts the JWT-guarded user-data endpoints — getUserData, push, merge (guest→account migration), reconcileDevice, resetProgress (sync.service.ts:329-875). These move the opposite direction (device → server: collected patches, trips, purchases) and are a separate concern from catalog delivery, but they share the controller, share SyncPushDto's caps (MAX_TRIPS_PER_PUSH = 250, MAX_STOPS_PER_TRIP = 500, MAX_TOTAL_STOPS_PER_PUSH = 5000; backend/src/sync/dto/sync.dto.ts:77-160), and share the flushPendingPatches client-side flush that SyncProvider also owns (SyncProvider.tsx:70-83). push/migrateGuest are deliberately tolerant of stale/unknown patch ids on the device (apply the valid subset, name the rest as rejected, never fail the whole request) — see backend/src/sync/__tests__/sync.service.unknown-patches.spec.ts's header comment for the incident this fixed.

How long until a freshly published patch reaches an installed app

There is no push path, so the answer is entirely a function of when the client's next contentQuery fetch fires and whether it lands within the 5-minute staleTime window:

So the practical answer is "within 5 minutes for an app in active use, and on the next foreground/cold-start otherwise" — never instantaneous, and never guaranteed at all without connectivity at one of those trigger points.

Data model

Relevant backend/prisma/schema.prisma models (all @@schema("public")):

Visibility predicate: VISIBLE_PATCH_WHERE/ORPHANED_PATCH_WHERE/visiblePatchSql in backend/src/common/patch-visibility.ts:32-63 — "visible" means "belongs to at least one collection with admin_only = false". This is the app's only definition of a shippable patch; the doc comment there notes it is also enforced outside sync (camera-roll import, unlock) so a hidden patch can't be earned from a photo either.

API surface

Endpoint Auth Notes
GET /api/sync/content?since=<ISO> None. Optional bearer/cookie elevates to admin (unfiltered) if it resolves to an admin email. Full catalog when since omitted; delta (but see note below) when given. sync.controller.ts:17-23
GET /api/sync/patch/:id None Single patch, unfiltered by visibility, 404 if the id doesn't exist. sync.controller.ts:28-31
GET /api/sync/user-data JWT (JwtAuthGuard) Not catalog — the user's collected patches, trips, purchases. sync.controller.ts:33-43
POST /api/sync/push JWT Pushes locally-queued collected patches / trips / deletes. sync.controller.ts:45-50
POST /api/sync/merge JWT Guest→account migration of local data. sync.controller.ts:52-56
POST /api/sync/reconcile-device JWT sync.controller.ts:58-63
POST /api/sync/reset-progress JWT sync.controller.ts:65-70

No application-level rate limiting or throttling was found anywhere in backend/src (no @nestjs/throttler, no express-rate-limit) — /api/sync/content and /api/location/check-polygons (the unlock endpoint, also unauthenticated: backend/src/location/location.controller.ts:28-33) are both open to unlimited unauthenticated calls at the application layer. Whatever protection exists (if any) would have to live at the proxy/nginx layer, which is outside this repo.

Request body/response sizes are uncapped by any compression: backend/src/configure-app.ts sets up JSON body parsing (10 MB default limit, configure-app.ts:76-78) and CORS (configure-app.ts:80-83) but no compression() middleware anywhere in the backend — responses (including this one) are served uncompressed JSON.

Payload shape. Every patch alone serializes ~60 fields (mapSyncPatch, sync.service.ts:84-161) — identity/media, coordinates, two derived polygon metrics, historical-context fields, and ~38 nullable *Data JSON blobs (only one of which is ever non-null per patch, since it's keyed by patch type, but all ~38 keys are present on every object). Multiplied across a catalog on the order of ~2,000 patches (2,171 rows in the repo's own PATCH_COLLECTION_INVENTORY.md, flagged elsewhere in this repo as a source that can go stale) plus every collection/campaign/category/patchType/patchCollection row, this is what the content.ts:44 comment's ~6.37 MB figure describes for a full sync. A delta sync is only smaller on the categories/collections/patches/patchTypes/campaigns tables (each scoped by updated_at >= since); the patchCollections table, as noted above, is sent in full on every call regardless of since, so it puts a floor under how small any delta response can be.

Key files (annotated path:line list)

Configuration and flags

Edge cases and known limits

What this does NOT do

Tests that cover it

Open questions