refactor(native): scope MMKV storage per server instance and user

Isolate the query cache and recently-viewed history by instance ID +
user ID so that switching servers or accounts never bleeds data
between them.

- Split `mmkv.ts` into `globalStorage` (app-wide settings, analytics)
  and a scoped store keyed by `${instanceId}_${userId}`; add
  `setStorageScope`, `clearStorageScope`, `onStorageScopeChange`
- Add `QueryProvider` component — mounts `PersistQueryClientProvider`
  keyed by `${instanceId}_${userId}` when scoped storage is ready,
  falls back to plain `QueryClientProvider` otherwise; remounts on
  scope change to restore from the correct MMKV partition
- `registerServer` in `server-url.ts` persists the instance ID
  returned from the health check; `ensureInstanceId` fetches it from
  the server for upgrade paths and env-based URLs
- Auth client `storagePrefix` now includes the instance ID so
  SecureStore tokens are namespaced per server
- Export `rebuildAuthClient` for use when instance ID resolves after
  initial mount
- Remove explicit `clearRecentlyViewed` calls from sign-out — scoped
  storage naturally separates history per account
- Update `recently-viewed.ts` and `posthog.ts` to use the appropriate
  storage tier (`scopedStorage` / `globalStorage`)
This commit is contained in:
2026-03-14 09:24:51 -04:00
parent 9278ea6e73
commit 98c1143540
10 changed files with 261 additions and 69 deletions
@@ -22,6 +22,7 @@ import {
getServerUrl,
hasStoredServerUrl,
normalizeUrl,
registerServer,
setServerUrl,
type ValidationError,
validateServerUrl,
@@ -119,6 +120,7 @@ export default function ServerUrlScreen() {
if (result.status === "success") {
setConnection({ phase: "success" });
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
registerServer(fullUrl, result.instanceId);
setServerUrl(fullUrl);
successTimeout.current = setTimeout(() => {
replace("/(auth)/login");
@@ -46,7 +46,6 @@ import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { clearRecentlyViewed } from "@/lib/recently-viewed";
import { getServerUrl } from "@/lib/server-url";
import { toast } from "@/lib/toast";
@@ -194,7 +193,6 @@ export default function SettingsScreen() {
onPress: () => {
authClient.signOut();
queryClient.clear();
clearRecentlyViewed();
},
},
]);
+79 -6
View File
@@ -1,4 +1,5 @@
import "@/global.css";
import { QueryClientProvider } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
@@ -15,11 +16,22 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { authClient } from "@/lib/auth-client";
import { queryPersister } from "@/lib/mmkv";
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
import {
clearStorageScope,
hasScopedStorage,
onStorageScopeChange,
queryPersister,
setStorageScope,
} from "@/lib/mmkv";
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import { hasStoredServerUrl, onServerUrlChange } from "@/lib/server-url";
import {
ensureInstanceId,
getCurrentInstanceId,
hasStoredServerUrl,
onServerUrlChange,
} from "@/lib/server-url";
SplashScreen.preventAutoHideAsync();
@@ -39,6 +51,36 @@ function AppContent() {
const hasServerUrl =
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
// --- Ensure instance ID is available (handles upgrades and env-based URLs) ---
const [instanceId, setInstanceId] = useState(getCurrentInstanceId);
useEffect(() => {
if (!instanceId && hasServerUrl) {
ensureInstanceId().then((id) => {
if (id) {
setInstanceId(id);
rebuildAuthClient();
}
});
}
}, [instanceId, hasServerUrl]);
// Re-sync instanceId when server URL changes (registerServer sets it synchronously)
useEffect(() => {
return onServerUrlChange(() => setInstanceId(getCurrentInstanceId()));
}, []);
// --- Set storage scope when we have both instanceId and userId ---
const userId = session?.user?.id;
useEffect(() => {
if (instanceId && userId) {
setStorageScope(instanceId, userId);
} else if (!userId && hasScopedStorage()) {
clearStorageScope();
}
}, [instanceId, userId]);
// --- App Tracking Transparency (must resolve before screen tracking) ---
const [trackingReady, setTrackingReady] = useState(false);
@@ -135,18 +177,49 @@ function AppContent() {
);
}
export default function RootLayout() {
const inner = (
/**
* Wraps children with PersistQueryClientProvider when scoped storage is ready,
* otherwise uses plain QueryClientProvider. The key prop forces a remount when
* the scope changes, triggering a restore from the new MMKV instance.
*/
function QueryProvider({ children }: { children: React.ReactNode }) {
// Re-render when scope changes so we switch between providers
const [, setScopeVersion] = useState(0);
const { data: session } = authClient.useSession();
const instanceId = getCurrentInstanceId();
const scopeReady = hasScopedStorage();
// When scope changes (via setStorageScope), force re-render
useEffect(() => {
return onStorageScopeChange(() => setScopeVersion((n) => n + 1));
}, []);
if (scopeReady && instanceId && session?.user?.id) {
return (
<PersistQueryClientProvider
key={`${instanceId}_${session.user.id}`}
client={queryClient}
persistOptions={{ persister: queryPersister }}
>
{children}
</PersistQueryClientProvider>
);
}
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
export default function RootLayout() {
const inner = (
<QueryProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<AppContent />
</KeyboardProvider>
</GestureHandlerRootView>
</PersistQueryClientProvider>
</QueryProvider>
);
if (!posthog) return inner;
@@ -5,7 +5,6 @@ import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { authClient } from "@/lib/auth-client";
import { queryClient } from "@/lib/query-client";
import { clearRecentlyViewed } from "@/lib/recently-viewed";
import * as Haptics from "@/utils/haptics";
export function HeaderAvatar() {
@@ -64,7 +63,6 @@ export function HeaderAvatar() {
onPress: () => {
authClient.signOut();
queryClient.clear();
clearRecentlyViewed();
},
},
]);
+19 -13
View File
@@ -3,9 +3,20 @@ import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import * as SecureStore from "expo-secure-store";
import { QUERY_CACHE_KEY, storage } from "@/lib/mmkv";
import { queryClient } from "@/lib/query-client";
import { getServerUrl, onServerUrlChange } from "@/lib/server-url";
import {
getCurrentInstanceId,
getServerUrl,
onServerUrlChange,
} from "@/lib/server-url";
function getStoragePrefix(): string {
const instanceId = getCurrentInstanceId();
if (instanceId) {
return `sofa_${instanceId}`;
}
return "sofa";
}
function buildAuthClient() {
return createAuthClient({
@@ -15,7 +26,7 @@ function buildAuthClient() {
genericOAuthClient(),
expoClient({
scheme: "sofa",
storagePrefix: "sofa",
storagePrefix: getStoragePrefix(),
storage: SecureStore,
}),
],
@@ -24,17 +35,12 @@ function buildAuthClient() {
export let authClient = buildAuthClient();
/** Rebuild the auth client (e.g. when the instance ID becomes available). */
export function rebuildAuthClient() {
authClient = buildAuthClient();
}
onServerUrlChange(() => {
// Fire-and-forget SecureStore cleanup — must not block the
// synchronous rebuild so that subsequent listeners (e.g. the root
// layout re-render) see the new authClient immediately.
Promise.allSettled([
SecureStore.deleteItemAsync("sofa_cookie"),
SecureStore.deleteItemAsync("sofa_session_token"),
SecureStore.deleteItemAsync("sofa_session_data"),
]);
authClient = buildAuthClient();
storage.remove(QUERY_CACHE_KEY);
queryClient.clear();
});
+40 -6
View File
@@ -1,17 +1,51 @@
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { createMMKV } from "react-native-mmkv";
export const storage = createMMKV();
// Global storage — app-wide settings, server mappings, analytics
export const globalStorage = createMMKV();
const mmkvStorage = {
getItem: (key: string) => storage.getString(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => void storage.remove(key),
// Server+user scoped storage — switches when scope changes
let _scopedStore: ReturnType<typeof createMMKV> | null = null;
const scopeChangeListeners: Array<() => void> = [];
export function scopedStorage() {
if (!_scopedStore) throw new Error("Scoped storage not initialized");
return _scopedStore;
}
export function hasScopedStorage(): boolean {
return _scopedStore !== null;
}
export function setStorageScope(instanceId: string, userId: string) {
_scopedStore = createMMKV({ id: `${instanceId}_${userId}` });
for (const listener of scopeChangeListeners) listener();
}
export function clearStorageScope() {
_scopedStore = null;
for (const listener of scopeChangeListeners) listener();
}
export function onStorageScopeChange(callback: () => void): () => void {
scopeChangeListeners.push(callback);
return () => {
const idx = scopeChangeListeners.indexOf(callback);
if (idx !== -1) scopeChangeListeners.splice(idx, 1);
};
}
// Query persister — reads/writes through _scopedStore via closure
const scopedMmkvStorage = {
getItem: (key: string) => _scopedStore?.getString(key) ?? null,
setItem: (key: string, value: string) => _scopedStore?.set(key, value),
removeItem: (key: string) => void _scopedStore?.remove(key),
};
export const QUERY_CACHE_KEY = "REACT_QUERY_OFFLINE_CACHE";
export const queryPersister = createAsyncStoragePersister({
storage: mmkvStorage,
storage: scopedMmkvStorage,
key: QUERY_CACHE_KEY,
});
+12 -12
View File
@@ -1,7 +1,7 @@
import type { PostHogCustomStorage } from "posthog-react-native";
import { PostHog } from "posthog-react-native";
import { storage } from "@/lib/mmkv";
import { globalStorage } from "@/lib/mmkv";
const posthogApiKey = process.env.EXPO_PUBLIC_POSTHOG_KEY ?? "";
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
@@ -11,8 +11,8 @@ const ANALYTICS_EXPLICIT_KEY = "sofa_analytics_explicit";
const ATT_MIGRATED_KEY = "sofa_att_migrated";
const posthogStorage: PostHogCustomStorage = {
getItem: (key: string) => storage.getString(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
getItem: (key: string) => globalStorage.getString(key) ?? null,
setItem: (key: string, value: string) => globalStorage.set(key, value),
};
// PostHog throws if apiKey is empty, so only construct when configured.
@@ -35,18 +35,18 @@ export const posthog: PostHog | null = posthogApiKey
/** Whether the user has explicitly set a preference via the settings toggle. */
export function hasExplicitPreference(): boolean {
return storage.getBoolean(ANALYTICS_EXPLICIT_KEY) === true;
return globalStorage.getBoolean(ANALYTICS_EXPLICIT_KEY) === true;
}
/** Current analytics enabled state (explicit preference or default). */
export function isAnalyticsEnabled(): boolean {
return storage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true;
return globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) ?? true;
}
/** Called by the settings toggle — marks the preference as explicit. */
export function setAnalyticsEnabled(enabled: boolean): void {
storage.set(ANALYTICS_ENABLED_KEY, enabled);
storage.set(ANALYTICS_EXPLICIT_KEY, true);
globalStorage.set(ANALYTICS_ENABLED_KEY, enabled);
globalStorage.set(ANALYTICS_EXPLICIT_KEY, true);
syncPosthog(enabled);
}
@@ -79,13 +79,13 @@ export function applyTrackingTransparency(granted: boolean): boolean {
// it. This runs exactly once — subsequent launches skip it because
// ATT_MIGRATED_KEY is set, preventing applyTrackingTransparency's own
// writes to ANALYTICS_ENABLED_KEY from being misidentified as legacy.
if (!storage.getBoolean(ATT_MIGRATED_KEY)) {
storage.set(ATT_MIGRATED_KEY, true);
if (!globalStorage.getBoolean(ATT_MIGRATED_KEY)) {
globalStorage.set(ATT_MIGRATED_KEY, true);
if (
storage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined &&
globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined &&
!hasExplicitPreference()
) {
storage.set(ANALYTICS_EXPLICIT_KEY, true);
globalStorage.set(ANALYTICS_EXPLICIT_KEY, true);
}
}
@@ -97,7 +97,7 @@ export function applyTrackingTransparency(granted: boolean): boolean {
}
// No explicit preference yet — follow the ATT result.
storage.set(ANALYTICS_ENABLED_KEY, granted);
globalStorage.set(ANALYTICS_ENABLED_KEY, granted);
syncPosthog(granted);
return granted;
}
+32 -16
View File
@@ -1,6 +1,9 @@
import { useSyncExternalStore } from "react";
import { storage } from "@/lib/mmkv";
import { onServerUrlChange } from "@/lib/server-url";
import {
hasScopedStorage,
onStorageScopeChange,
scopedStorage,
} from "@/lib/mmkv";
const STORAGE_KEY = "recently_viewed";
const MAX_ITEMS = 50;
@@ -14,26 +17,45 @@ export interface RecentlyViewedItem {
viewedAt: number;
}
// --- In-memory cache synced with MMKV ---
// --- In-memory cache synced with scoped MMKV ---
const listeners = new Set<() => void>();
let items: RecentlyViewedItem[] = (() => {
const raw = storage.getString(STORAGE_KEY);
if (!raw) return [];
let items: RecentlyViewedItem[] = [];
function loadItems() {
if (!hasScopedStorage()) {
items = [];
return;
}
const raw = scopedStorage().getString(STORAGE_KEY);
if (!raw) {
items = [];
return;
}
try {
return JSON.parse(raw) as RecentlyViewedItem[];
items = JSON.parse(raw) as RecentlyViewedItem[];
} catch {
return [];
items = [];
}
}
})();
function persist(next: RecentlyViewedItem[]) {
if (!hasScopedStorage()) return;
items = next;
storage.set(STORAGE_KEY, JSON.stringify(next));
scopedStorage().set(STORAGE_KEY, JSON.stringify(next));
for (const listener of listeners) listener();
}
// Load persisted items if scoped storage is already initialized
loadItems();
// Reload items when the storage scope changes (server/user switch)
onStorageScopeChange(() => {
loadItems();
for (const listener of listeners) listener();
});
// --- Public API ---
export function addRecentlyViewed(item: Omit<RecentlyViewedItem, "viewedAt">) {
@@ -75,9 +97,3 @@ export function useRecentlyViewed() {
clearAll: clearRecentlyViewed,
};
}
// Clear recently-viewed when the user switches servers — stored IDs
// are server-specific and become dead links on a different backend.
onServerUrlChange(() => {
clearRecentlyViewed();
});
+71 -7
View File
@@ -1,6 +1,8 @@
import { storage } from "@/lib/mmkv";
import { globalStorage } from "@/lib/mmkv";
const SERVER_URL_KEY = "sofa_server_url";
const SERVERS_MAP_KEY = "sofa_servers";
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
const DEFAULT_URL =
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
@@ -9,7 +11,7 @@ const serverUrlListeners: Array<() => void> = [];
// --- Types ---
export type ValidationResult =
| { status: "success" }
| { status: "success"; instanceId: string }
| { status: "error"; error: ValidationError };
export type ValidationError =
@@ -46,12 +48,12 @@ export function resolveUrl(path: string | null): string | null {
// --- Server URL storage ---
export function getServerUrl(): string {
return storage.getString(SERVER_URL_KEY) ?? DEFAULT_URL;
return globalStorage.getString(SERVER_URL_KEY) ?? DEFAULT_URL;
}
export function setServerUrl(url: string): void {
const normalized = url.replace(/\/+$/, "");
storage.set(SERVER_URL_KEY, normalized);
globalStorage.set(SERVER_URL_KEY, normalized);
for (const listener of serverUrlListeners) listener();
}
@@ -64,7 +66,63 @@ export function onServerUrlChange(callback: () => void): () => void {
}
export function hasStoredServerUrl(): boolean {
return storage.contains(SERVER_URL_KEY);
return globalStorage.contains(SERVER_URL_KEY);
}
// --- Instance ID management ---
function getServersMap(): Record<string, string> {
const raw = globalStorage.getString(SERVERS_MAP_KEY);
if (!raw) return {};
try {
return JSON.parse(raw) as Record<string, string>;
} catch {
return {};
}
}
export function registerServer(url: string, instanceId: string): void {
const map = getServersMap();
map[url] = instanceId;
globalStorage.set(SERVERS_MAP_KEY, JSON.stringify(map));
globalStorage.set(CURRENT_INSTANCE_KEY, instanceId);
}
export function getCurrentInstanceId(): string | null {
return globalStorage.getString(CURRENT_INSTANCE_KEY) ?? null;
}
/**
* Ensures the current server has a cached instance ID. For existing installs
* that upgraded before instance IDs were introduced, or builds using
* EXPO_PUBLIC_SERVER_URL that never visit the server-url screen, this fetches
* the instance ID from /api/health on startup if it's missing.
*/
export async function ensureInstanceId(): Promise<string | null> {
const existing = getCurrentInstanceId();
if (existing) return existing;
const serverUrl = hasStoredServerUrl()
? getServerUrl()
: process.env.EXPO_PUBLIC_SERVER_URL;
if (!serverUrl) return null;
try {
const res = await fetch(`${serverUrl}/api/health`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const data = await res.json();
const instanceId =
typeof data.instanceId === "string" ? data.instanceId : null;
if (instanceId) {
registerServer(serverUrl, instanceId);
return instanceId;
}
} catch {
// Non-critical — will retry next launch
}
return null;
}
// --- Validation ---
@@ -104,11 +162,17 @@ export async function validateServerUrl(
if (!data || typeof data !== "object" || !("status" in data)) {
return { status: "error", error: "not_sofa_server" };
}
} catch {
const instanceId =
typeof data.instanceId === "string" ? data.instanceId : null;
if (!instanceId) {
return { status: "error", error: "not_sofa_server" };
}
return { status: "success" };
return { status: "success", instanceId };
} catch {
return { status: "error", error: "not_sofa_server" };
}
} catch (err) {
if (
err instanceof Error &&
+2 -1
View File
@@ -1,3 +1,4 @@
import { getInstanceId } from "@sofa/core/settings";
import { db } from "@sofa/db/client";
import { sql } from "@sofa/db/helpers";
import { createLogger } from "@sofa/logger";
@@ -10,7 +11,7 @@ const app = new Hono();
app.get("/", (c) => {
try {
db.run(sql`SELECT 1`);
return c.json({ status: "healthy" }, 200);
return c.json({ status: "healthy", instanceId: getInstanceId() }, 200);
} catch (err) {
log.error("Health check failed:", err);
return c.json({ status: "unhealthy" }, 503);