metagame/services
PURPOSE — Services layer for the React metagame UI: owns the single Supabase client and RPC helper, manages the auth lifecycle (anonymous session, magic-link claim, session restore), bootstraps the player on cold start by calling the canonical RPC and hydrating every Zustand store, exposes profile mutations and role checks, hydrates the feature-flag cache, and runs the analytics event-batching pipeline with sync-flush on page teardown.
OWNS
- The single shared Supabase client instance for the app, created at module load with
persistSession+localStorage. - The
invokeRpctyped-RPC helper (offline-mode short-circuit, Sentry breadcrumb on failure, error-message wrapping). - The auth lifecycle: cold-start session detection, anonymous-user creation, PKCE / implicit magic-link redirect detection, redirect-token URL cleanup, magic-link session wait listener, anonymous-to-claimed identity upgrade, sign-out, session refresh, and a subscribe/unsubscribe wrapper around auth state changes.
- The auth-reachability and bootstrap-reachability fast-fail timeouts and the safety timeout on the magic-link wait listener.
- The player bootstrap orchestration: RPC call, save-migration pass on the returned save blob, then sequenced hydration of wallet, inventory (v4 ships map with v3 entity-row fallback), session, mod grid (separate RPC, defensive on migration absence), tier, challenge, and planet-progress stores.
- The legacy-id collapse helper that strips rarity suffixes from hull ids during inventory hydration.
- The
BootstrapPayloadshape contract — every server-shipped player-state field the metagame consumes. - Profile mutations (display-name write with length validation, analytics-opt-in toggle) and the matching local-store writeback.
- Read-only profile accessors (admin role check, account state, display name with fallback).
- The feature-flag in-memory cache, the hydration latch, the bulk-hydrate query, and the synchronous flag-lookup with default-on policy.
- The analytics event queue, flush timer, flush-on-batch-full / flush-on-interval / flush-on-visibility-hidden triggers, and the
sendBeacon-based synchronous flush path forbeforeunload. - The cached Supabase REST URL + anon key used by the sync-flush path (sendBeacon cannot carry custom headers, so the key rides as a query param).
- Exported v4 ship-rework event-name constants used by callers as the canonical event tags.
- The
visibilitychangelistener attached at module load to auto-flush queued events when the tab is hidden.
READS FROM
import.meta.envfor the Supabase URL + anon key (validated at import; throws on missing).localStorage(transitively, via the Supabase client’s session persistence).window.location(hash + query) for magic-link redirect detection and the post-redirect URL cleanup.window.__PLAYER_STORE_OFFLINE__global latch to short-circuit RPCs when the user has chosen offline mode.usePlayerStorefor the active profile (mutations gate on it) and for offline-mode access via the global latch.data/shipsHULL_CLASSESallow-list during inventory hydration.data/save-migrationsrunSaveMigrationsfor upgrading the cloud save blob in-place.data/save-schemaSaveBlobfor the bootstrap payload contract.- Supabase tables:
feature_flags(hydration),players(profile mutations),player_events(analytics insert). - Supabase RPCs:
bootstrap_player,get_mod_grid. - Supabase Auth:
getSession,getUser,signInAnonymously,signInWithOtp,signOut,refreshSession,onAuthStateChange.
PUSHES TO
- Supabase Auth for session lifecycle (anonymous sign-in, OTP magic link, sign-out, refresh).
- Supabase
playerstable for profile updates. - Supabase
player_eventstable for batched analytics inserts (via PostgREST insert) and unload-time sync inserts (vianavigator.sendBeaconwith a keepalive-fetchfallback). - Supabase RPCs
bootstrap_playerandget_mod_gridduring cold-start hydration. - Zustand stores during bootstrap:
useWalletStore.replaceFromSnapshot,useInventoryStore.loadInventory/.resetToStarter,useSessionStore.hydrateFromSave,useModGridStore.loadFromSnapshot/.resetToStarter,useTierStore.loadFromBootstrap,useChallengeStore.loadFromBootstrap,usePlanetProgressStore.loadFromBootstrap. usePlayerStore(setState) for in-place profile field updates after mutations confirm.- Sentry as breadcrumbs (not events) on RPC failure.
console.warn/console.errorfor service-internal diagnostics (offline RPC skips, feature-flag hydrate failures, analytics flush errors, missingget_mod_gridmigration).
DOES NOT
- Render any UI, mount React, or interact with the DOM beyond the redirect-URL cleanup and the
visibilitychangelistener. - Decide when to call
bootstrapPlayerorensureAnonymousSession— the splash / app shell owns boot sequencing; this layer only exposes the entry points and the fast-fail timeouts. - Resolve which stores to populate on a partial / fallback bootstrap — every consumer store is hydrated unconditionally with the field-present-or-empty-array pattern; missing fields are not surfaced as errors.
- Implement Row Level Security, server-authoritative mutation logic, or admin promotion — those live in Postgres RPCs (
SECURITY DEFINER) and SQL. The client-sideisAdmincheck is read-only and trusts the server payload. - Maintain a feature-flag subscription or push live flag updates — the cache is hydrated once at boot and is not refreshed; toggling a flag in the dashboard takes effect on next cold start.
- Own retry, backoff, queue persistence, or offline replay for analytics events — a failed flush logs and drops the in-memory batch on next overflow; nothing is persisted to disk.
- Encode the canonical event vocabulary beyond the v4 ship-rework constants — all other event names are passed in as string literals by callers.
- Manage Sentry initialization, DSN, or release tagging — only emits breadcrumbs to an already-initialized Sentry.
- Persist the offline-mode latch or own its UI — only reads the
window.__PLAYER_STORE_OFFLINE__flag set byplayerStore. - Schedule or perform background save writes — bootstrap is read-only on the save side after migration; gameplay save writes live elsewhere.
Signals fired / Signals watched — none. The module talks to Supabase, Sentry, the Zustand stores, and window directly; it does not emit or subscribe to engine signals.
Entry points
supabase— the shared Supabase client instance for the entire app.invokeRpc— typed RPC helper with offline-mode guard and Sentry breadcrumbs.ensureAnonymousSession— cold-start auth entry; restores session, processes magic-link redirects, or creates an anonymous user. Fast-fails withSUPABASE_TIMEOUT: auth unreachable.signInAnonymously— explicit anonymous sign-in.signOut— clear the active session.getUser— current authenticated user or null.getSession— current session (tokens + user).sendMagicLink— initiate the email-claim flow for an anonymous user.refreshSession— refresh tokens after magic-link redirect.onAuthChange— subscribe to auth state changes; returns an unsubscribe.bootstrapPlayer— cold-start hydration entry; callsbootstrap_player, runs save migrations, hydrates every metagame store. Fast-fails withSUPABASE_TIMEOUT: bootstrap unreachable.BootstrapPayload— exported type contract for the RPC return shape.updateDisplayName— write a new display name and update the local profile.setAnalyticsOptIn— toggle the analytics opt-in flag and update the local profile.isAdmin— synchronous role check off the local profile.getAccountState— synchronous guest / claimed accessor off the local profile.getDisplayName— synchronous display-name accessor with a default fallback.hydrateFeatureFlags— bulk-load the flag table into the in-memory cache.isFlagEnabled— synchronous flag lookup with default-on policy (un-hydrated and missing flags both return enabled).trackEvent— enqueue an analytics event; auto-flushes on batch fill, otherwise schedules a timer flush.flushAnalytics— async drain of the queue; for in-page lifecycle hooks where the tab isn’t unloading.flushAnalyticsSync— synchronous beacon-based drain forbeforeunload/pagehideonly.V4_MOD_TEMPLATE_UNLOCK/V4_MOD_BUY_PLACE/V4_MOD_DESTROY/V4_PULL_UNLOCK/V4_PULL_XP_GAIN— canonical v4 ship-rework event-name constants.
Pattern notes
- The Supabase client is a module-level singleton; env vars throw at import time so misconfigured builds fail loud rather than discover the problem at first RPC.
- All server-authoritative mutations route through
invokeRpc, which keeps the RLS-via-SECURITY DEFINERmodel consistent and gives one chokepoint for the offline-mode short-circuit and Sentry breadcrumbs. Directsupabase.from(...)access is the documented exception only for auth session management and the two profile-table writes. - Bootstrap is the single hydration funnel: one RPC populates seven stores in a fixed sequence, with each store-load arm defensively coercing absent fields to empty arrays so partially-migrated backends still boot cleanly. The mod grid is a one-off second RPC because the migration that returns it from
bootstrap_playerhasn’t landed; missing-RPC errors fall back to a starter mod grid rather than failing boot. - The v4 inventory hydration path branches on payload shape: a
shipsmap (v4) is preferred; absent that, the legacyinventoryentity-row array is filtered to ships, suffix-collapsed to bare hull ids, and duplicates are folded into XP. The same suffix-collapse helper is reused for the saved ship-selection id. - Both async entry points (
ensureAnonymousSession,bootstrapPlayer) wrap their real work inPromise.raceagainst a fixed-millisecond timeout that rejects with aSUPABASE_TIMEOUT:prefix the splash uses to offer offline mode. - Magic-link handling races a real auth-state listener against a longer safety timeout, then scrubs query / hash params via
history.replaceStateso a page refresh after redirect can’t accidentally re-process stale tokens or create a duplicate anonymous account. - Feature flags follow a default-on policy at two layers: the un-hydrated state returns true so kill switches start enabled during boot, and a missing cache entry also returns true so flags must be explicitly seeded-and-flipped-off to disable a feature.
- Analytics has two flush paths against the same queue: the async path uses the Supabase client’s REST insert, the sync path uses
navigator.sendBeaconwith the anon key threaded as a query param (since beacons can’t carry custom headers) and akeepalive: truefetchfallback when the beacon is rejected or unavailable. The asyncvisibilitychangelistener is registered at module load; the syncbeforeunloadpath must be wired by the app shell. - Offline-mode is communicated through a
windowglobal latch read byinvokeRpc, intentionally avoiding aplayerStore → supabase → playerStoreimport cycle.