mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -04:00
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:
@@ -22,6 +22,7 @@ import {
|
|||||||
getServerUrl,
|
getServerUrl,
|
||||||
hasStoredServerUrl,
|
hasStoredServerUrl,
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
|
registerServer,
|
||||||
setServerUrl,
|
setServerUrl,
|
||||||
type ValidationError,
|
type ValidationError,
|
||||||
validateServerUrl,
|
validateServerUrl,
|
||||||
@@ -119,6 +120,7 @@ export default function ServerUrlScreen() {
|
|||||||
if (result.status === "success") {
|
if (result.status === "success") {
|
||||||
setConnection({ phase: "success" });
|
setConnection({ phase: "success" });
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
registerServer(fullUrl, result.instanceId);
|
||||||
setServerUrl(fullUrl);
|
setServerUrl(fullUrl);
|
||||||
successTimeout.current = setTimeout(() => {
|
successTimeout.current = setTimeout(() => {
|
||||||
replace("/(auth)/login");
|
replace("/(auth)/login");
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
import { orpc } from "@/lib/orpc";
|
import { orpc } from "@/lib/orpc";
|
||||||
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { clearRecentlyViewed } from "@/lib/recently-viewed";
|
|
||||||
import { getServerUrl } from "@/lib/server-url";
|
import { getServerUrl } from "@/lib/server-url";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
@@ -194,7 +193,6 @@ export default function SettingsScreen() {
|
|||||||
onPress: () => {
|
onPress: () => {
|
||||||
authClient.signOut();
|
authClient.signOut();
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
clearRecentlyViewed();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
@@ -15,11 +16,22 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
|
|||||||
import { Uniwind, useResolveClassNames } from "uniwind";
|
import { Uniwind, useResolveClassNames } from "uniwind";
|
||||||
|
|
||||||
import { OfflineBanner } from "@/components/ui/offline-banner";
|
import { OfflineBanner } from "@/components/ui/offline-banner";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
|
||||||
import { queryPersister } from "@/lib/mmkv";
|
import {
|
||||||
|
clearStorageScope,
|
||||||
|
hasScopedStorage,
|
||||||
|
onStorageScopeChange,
|
||||||
|
queryPersister,
|
||||||
|
setStorageScope,
|
||||||
|
} from "@/lib/mmkv";
|
||||||
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { hasStoredServerUrl, onServerUrlChange } from "@/lib/server-url";
|
import {
|
||||||
|
ensureInstanceId,
|
||||||
|
getCurrentInstanceId,
|
||||||
|
hasStoredServerUrl,
|
||||||
|
onServerUrlChange,
|
||||||
|
} from "@/lib/server-url";
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
@@ -39,6 +51,36 @@ function AppContent() {
|
|||||||
const hasServerUrl =
|
const hasServerUrl =
|
||||||
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
|
!!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) ---
|
// --- App Tracking Transparency (must resolve before screen tracking) ---
|
||||||
const [trackingReady, setTrackingReady] = useState(false);
|
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
|
<PersistQueryClientProvider
|
||||||
|
key={`${instanceId}_${session.user.id}`}
|
||||||
client={queryClient}
|
client={queryClient}
|
||||||
persistOptions={{ persister: queryPersister }}
|
persistOptions={{ persister: queryPersister }}
|
||||||
>
|
>
|
||||||
|
{children}
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const inner = (
|
||||||
|
<QueryProvider>
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<KeyboardProvider>
|
<KeyboardProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</PersistQueryClientProvider>
|
</QueryProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!posthog) return inner;
|
if (!posthog) return inner;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Image } from "@/components/ui/image";
|
|||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { clearRecentlyViewed } from "@/lib/recently-viewed";
|
|
||||||
import * as Haptics from "@/utils/haptics";
|
import * as Haptics from "@/utils/haptics";
|
||||||
|
|
||||||
export function HeaderAvatar() {
|
export function HeaderAvatar() {
|
||||||
@@ -64,7 +63,6 @@ export function HeaderAvatar() {
|
|||||||
onPress: () => {
|
onPress: () => {
|
||||||
authClient.signOut();
|
authClient.signOut();
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
clearRecentlyViewed();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,9 +3,20 @@ import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
import * as SecureStore from "expo-secure-store";
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
|
||||||
import { QUERY_CACHE_KEY, storage } from "@/lib/mmkv";
|
|
||||||
import { queryClient } from "@/lib/query-client";
|
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() {
|
function buildAuthClient() {
|
||||||
return createAuthClient({
|
return createAuthClient({
|
||||||
@@ -15,7 +26,7 @@ function buildAuthClient() {
|
|||||||
genericOAuthClient(),
|
genericOAuthClient(),
|
||||||
expoClient({
|
expoClient({
|
||||||
scheme: "sofa",
|
scheme: "sofa",
|
||||||
storagePrefix: "sofa",
|
storagePrefix: getStoragePrefix(),
|
||||||
storage: SecureStore,
|
storage: SecureStore,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -24,17 +35,12 @@ function buildAuthClient() {
|
|||||||
|
|
||||||
export let authClient = buildAuthClient();
|
export let authClient = buildAuthClient();
|
||||||
|
|
||||||
|
/** Rebuild the auth client (e.g. when the instance ID becomes available). */
|
||||||
|
export function rebuildAuthClient() {
|
||||||
|
authClient = buildAuthClient();
|
||||||
|
}
|
||||||
|
|
||||||
onServerUrlChange(() => {
|
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();
|
authClient = buildAuthClient();
|
||||||
storage.remove(QUERY_CACHE_KEY);
|
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,51 @@
|
|||||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||||
import { createMMKV } from "react-native-mmkv";
|
import { createMMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
export const storage = createMMKV();
|
// Global storage — app-wide settings, server mappings, analytics
|
||||||
|
export const globalStorage = createMMKV();
|
||||||
|
|
||||||
const mmkvStorage = {
|
// Server+user scoped storage — switches when scope changes
|
||||||
getItem: (key: string) => storage.getString(key) ?? null,
|
let _scopedStore: ReturnType<typeof createMMKV> | null = null;
|
||||||
setItem: (key: string, value: string) => storage.set(key, value),
|
|
||||||
removeItem: (key: string) => void storage.remove(key),
|
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 QUERY_CACHE_KEY = "REACT_QUERY_OFFLINE_CACHE";
|
||||||
|
|
||||||
export const queryPersister = createAsyncStoragePersister({
|
export const queryPersister = createAsyncStoragePersister({
|
||||||
storage: mmkvStorage,
|
storage: scopedMmkvStorage,
|
||||||
key: QUERY_CACHE_KEY,
|
key: QUERY_CACHE_KEY,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PostHogCustomStorage } from "posthog-react-native";
|
import type { PostHogCustomStorage } from "posthog-react-native";
|
||||||
import { PostHog } 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 posthogApiKey = process.env.EXPO_PUBLIC_POSTHOG_KEY ?? "";
|
||||||
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
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 ATT_MIGRATED_KEY = "sofa_att_migrated";
|
||||||
|
|
||||||
const posthogStorage: PostHogCustomStorage = {
|
const posthogStorage: PostHogCustomStorage = {
|
||||||
getItem: (key: string) => storage.getString(key) ?? null,
|
getItem: (key: string) => globalStorage.getString(key) ?? null,
|
||||||
setItem: (key: string, value: string) => storage.set(key, value),
|
setItem: (key: string, value: string) => globalStorage.set(key, value),
|
||||||
};
|
};
|
||||||
|
|
||||||
// PostHog throws if apiKey is empty, so only construct when configured.
|
// 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. */
|
/** Whether the user has explicitly set a preference via the settings toggle. */
|
||||||
export function hasExplicitPreference(): boolean {
|
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). */
|
/** Current analytics enabled state (explicit preference or default). */
|
||||||
export function isAnalyticsEnabled(): boolean {
|
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. */
|
/** Called by the settings toggle — marks the preference as explicit. */
|
||||||
export function setAnalyticsEnabled(enabled: boolean): void {
|
export function setAnalyticsEnabled(enabled: boolean): void {
|
||||||
storage.set(ANALYTICS_ENABLED_KEY, enabled);
|
globalStorage.set(ANALYTICS_ENABLED_KEY, enabled);
|
||||||
storage.set(ANALYTICS_EXPLICIT_KEY, true);
|
globalStorage.set(ANALYTICS_EXPLICIT_KEY, true);
|
||||||
syncPosthog(enabled);
|
syncPosthog(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,13 +79,13 @@ export function applyTrackingTransparency(granted: boolean): boolean {
|
|||||||
// it. This runs exactly once — subsequent launches skip it because
|
// it. This runs exactly once — subsequent launches skip it because
|
||||||
// ATT_MIGRATED_KEY is set, preventing applyTrackingTransparency's own
|
// ATT_MIGRATED_KEY is set, preventing applyTrackingTransparency's own
|
||||||
// writes to ANALYTICS_ENABLED_KEY from being misidentified as legacy.
|
// writes to ANALYTICS_ENABLED_KEY from being misidentified as legacy.
|
||||||
if (!storage.getBoolean(ATT_MIGRATED_KEY)) {
|
if (!globalStorage.getBoolean(ATT_MIGRATED_KEY)) {
|
||||||
storage.set(ATT_MIGRATED_KEY, true);
|
globalStorage.set(ATT_MIGRATED_KEY, true);
|
||||||
if (
|
if (
|
||||||
storage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined &&
|
globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined &&
|
||||||
!hasExplicitPreference()
|
!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.
|
// No explicit preference yet — follow the ATT result.
|
||||||
storage.set(ANALYTICS_ENABLED_KEY, granted);
|
globalStorage.set(ANALYTICS_ENABLED_KEY, granted);
|
||||||
syncPosthog(granted);
|
syncPosthog(granted);
|
||||||
return granted;
|
return granted;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { storage } from "@/lib/mmkv";
|
import {
|
||||||
import { onServerUrlChange } from "@/lib/server-url";
|
hasScopedStorage,
|
||||||
|
onStorageScopeChange,
|
||||||
|
scopedStorage,
|
||||||
|
} from "@/lib/mmkv";
|
||||||
|
|
||||||
const STORAGE_KEY = "recently_viewed";
|
const STORAGE_KEY = "recently_viewed";
|
||||||
const MAX_ITEMS = 50;
|
const MAX_ITEMS = 50;
|
||||||
@@ -14,26 +17,45 @@ export interface RecentlyViewedItem {
|
|||||||
viewedAt: number;
|
viewedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- In-memory cache synced with MMKV ---
|
// --- In-memory cache synced with scoped MMKV ---
|
||||||
|
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
let items: RecentlyViewedItem[] = (() => {
|
let items: RecentlyViewedItem[] = [];
|
||||||
const raw = storage.getString(STORAGE_KEY);
|
|
||||||
if (!raw) return [];
|
function loadItems() {
|
||||||
try {
|
if (!hasScopedStorage()) {
|
||||||
return JSON.parse(raw) as RecentlyViewedItem[];
|
items = [];
|
||||||
} catch {
|
return;
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
})();
|
const raw = scopedStorage().getString(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
items = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
items = JSON.parse(raw) as RecentlyViewedItem[];
|
||||||
|
} catch {
|
||||||
|
items = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function persist(next: RecentlyViewedItem[]) {
|
function persist(next: RecentlyViewedItem[]) {
|
||||||
|
if (!hasScopedStorage()) return;
|
||||||
items = next;
|
items = next;
|
||||||
storage.set(STORAGE_KEY, JSON.stringify(next));
|
scopedStorage().set(STORAGE_KEY, JSON.stringify(next));
|
||||||
for (const listener of listeners) listener();
|
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 ---
|
// --- Public API ---
|
||||||
|
|
||||||
export function addRecentlyViewed(item: Omit<RecentlyViewedItem, "viewedAt">) {
|
export function addRecentlyViewed(item: Omit<RecentlyViewedItem, "viewedAt">) {
|
||||||
@@ -75,9 +97,3 @@ export function useRecentlyViewed() {
|
|||||||
clearAll: clearRecentlyViewed,
|
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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { storage } from "@/lib/mmkv";
|
import { globalStorage } from "@/lib/mmkv";
|
||||||
|
|
||||||
const SERVER_URL_KEY = "sofa_server_url";
|
const SERVER_URL_KEY = "sofa_server_url";
|
||||||
|
const SERVERS_MAP_KEY = "sofa_servers";
|
||||||
|
const CURRENT_INSTANCE_KEY = "sofa_current_instance_id";
|
||||||
const DEFAULT_URL =
|
const DEFAULT_URL =
|
||||||
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
|
process.env.EXPO_PUBLIC_SERVER_URL ?? "https://sofa.example.com";
|
||||||
|
|
||||||
@@ -9,7 +11,7 @@ const serverUrlListeners: Array<() => void> = [];
|
|||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
export type ValidationResult =
|
export type ValidationResult =
|
||||||
| { status: "success" }
|
| { status: "success"; instanceId: string }
|
||||||
| { status: "error"; error: ValidationError };
|
| { status: "error"; error: ValidationError };
|
||||||
|
|
||||||
export type ValidationError =
|
export type ValidationError =
|
||||||
@@ -46,12 +48,12 @@ export function resolveUrl(path: string | null): string | null {
|
|||||||
// --- Server URL storage ---
|
// --- Server URL storage ---
|
||||||
|
|
||||||
export function getServerUrl(): string {
|
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 {
|
export function setServerUrl(url: string): void {
|
||||||
const normalized = url.replace(/\/+$/, "");
|
const normalized = url.replace(/\/+$/, "");
|
||||||
storage.set(SERVER_URL_KEY, normalized);
|
globalStorage.set(SERVER_URL_KEY, normalized);
|
||||||
for (const listener of serverUrlListeners) listener();
|
for (const listener of serverUrlListeners) listener();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +66,63 @@ export function onServerUrlChange(callback: () => void): () => void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasStoredServerUrl(): boolean {
|
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 ---
|
// --- Validation ---
|
||||||
@@ -104,11 +162,17 @@ export async function validateServerUrl(
|
|||||||
if (!data || typeof data !== "object" || !("status" in data)) {
|
if (!data || typeof data !== "object" || !("status" in data)) {
|
||||||
return { status: "error", error: "not_sofa_server" };
|
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: "error", error: "not_sofa_server" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: "success" };
|
return { status: "success", instanceId };
|
||||||
|
} catch {
|
||||||
|
return { status: "error", error: "not_sofa_server" };
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Error &&
|
err instanceof Error &&
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getInstanceId } from "@sofa/core/settings";
|
||||||
import { db } from "@sofa/db/client";
|
import { db } from "@sofa/db/client";
|
||||||
import { sql } from "@sofa/db/helpers";
|
import { sql } from "@sofa/db/helpers";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
@@ -10,7 +11,7 @@ const app = new Hono();
|
|||||||
app.get("/", (c) => {
|
app.get("/", (c) => {
|
||||||
try {
|
try {
|
||||||
db.run(sql`SELECT 1`);
|
db.run(sql`SELECT 1`);
|
||||||
return c.json({ status: "healthy" }, 200);
|
return c.json({ status: "healthy", instanceId: getInstanceId() }, 200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Health check failed:", err);
|
log.error("Health check failed:", err);
|
||||||
return c.json({ status: "unhealthy" }, 503);
|
return c.json({ status: "unhealthy" }, 503);
|
||||||
|
|||||||
Reference in New Issue
Block a user