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 invokeRpc typed-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 BootstrapPayload shape 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 for beforeunload.
  • 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 visibilitychange listener attached at module load to auto-flush queued events when the tab is hidden.

READS FROM

  • import.meta.env for 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.
  • usePlayerStore for the active profile (mutations gate on it) and for offline-mode access via the global latch.
  • data/ships HULL_CLASSES allow-list during inventory hydration.
  • data/save-migrations runSaveMigrations for upgrading the cloud save blob in-place.
  • data/save-schema SaveBlob for 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 players table for profile updates.
  • Supabase player_events table for batched analytics inserts (via PostgREST insert) and unload-time sync inserts (via navigator.sendBeacon with a keepalive-fetch fallback).
  • Supabase RPCs bootstrap_player and get_mod_grid during 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.error for service-internal diagnostics (offline RPC skips, feature-flag hydrate failures, analytics flush errors, missing get_mod_grid migration).

DOES NOT

  • Render any UI, mount React, or interact with the DOM beyond the redirect-URL cleanup and the visibilitychange listener.
  • Decide when to call bootstrapPlayer or ensureAnonymousSession — 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-side isAdmin check 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 by playerStore.
  • 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 with SUPABASE_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; calls bootstrap_player, runs save migrations, hydrates every metagame store. Fast-fails with SUPABASE_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 for beforeunload / pagehide only.
  • 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 DEFINER model consistent and gives one chokepoint for the offline-mode short-circuit and Sentry breadcrumbs. Direct supabase.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_player hasn’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 ships map (v4) is preferred; absent that, the legacy inventory entity-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 in Promise.race against a fixed-millisecond timeout that rejects with a SUPABASE_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.replaceState so 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.sendBeacon with the anon key threaded as a query param (since beacons can’t carry custom headers) and a keepalive: true fetch fallback when the beacon is rejected or unavailable. The async visibilitychange listener is registered at module load; the sync beforeunload path must be wired by the app shell.
  • Offline-mode is communicated through a window global latch read by invokeRpc, intentionally avoiding a playerStore → supabase → playerStore import cycle.