Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SelectionTranslationProvider } from '@/components/selection-translation
import { ShadowReadingCompletion } from '@/components/shared/shadow-reading-completion';
import { ShadowReadingStatusBar } from '@/components/shared/shadow-reading-status-bar';
import { useShortcuts } from '@/hooks/use-shortcuts';
import { LOCAL_DATABASE_CHANGED_EVENT } from '@/lib/db';
import { I18nProvider } from '@/lib/i18n/provider';
import { hydrateIOSNativeQA } from '@/lib/ios-native-qa';
import { seedDatabase } from '@/lib/seed';
Expand Down Expand Up @@ -160,11 +161,31 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
if (nextValue !== currentValue) setter(nextValue);
};

useEffect(() => {
const refreshUserScopedData = () => {
if (!seeded) return;
void seedDatabase().then(() => {
void useContentStore.getState().loadContents(true);
void useFavoriteStore.getState().loadFavorites(true);
});
};

window.addEventListener(LOCAL_DATABASE_CHANGED_EVENT, refreshUserScopedData);
return () => window.removeEventListener(LOCAL_DATABASE_CHANGED_EVENT, refreshUserScopedData);
}, [seeded]);

useEffect(() => {
let cancelled = false;
let cancelWarmup = () => {};

void seedDatabase().then(async () => {
void (async () => {
try {
await useAuthStore.getState().initialize();
} catch {
// Continue in the anonymous local database when auth initialization is unavailable.
}

await seedDatabase();
await hydrateIOSNativeQA();
if (cancelled) return;

Expand All @@ -175,16 +196,14 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
cancelWarmup = scheduleBackgroundTask(() => {
void useFavoriteStore.getState().loadFavorites(true);
});
});
})();

void useProviderStore.getState().hydrate();
useAssessmentStore.getState().hydrate();
useDailyPlanStore.getState().hydrate();
usePracticeTranslationStore.getState().hydrate();
useShadowReadingStore.getState().hydrate();
useShortcutStore.getState().hydrate();
void useAuthStore.getState().initialize();

if (IS_TAURI) {
void useUpdaterStore.getState().checkForUpdate();
useUpdaterStore.getState().startPeriodicCheck();
Expand Down
26 changes: 23 additions & 3 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ class EchoTypeDB extends Dexie {
weakSpots!: Table<WeakSpot>;
journals!: Table<JournalEntry>;

constructor() {
super('echotype');
constructor(name: string) {
super(name);
this.version(1).stores({
contents: 'id, type, category, source, difficulty, createdAt',
records: 'id, contentId, module, lastPracticed, nextReview',
Expand Down Expand Up @@ -294,4 +294,24 @@ class EchoTypeDB extends Dexie {
}
}

export const db = new EchoTypeDB();
export const LOCAL_DATABASE_CHANGED_EVENT = 'echotype:local-database-changed';

export function getDatabaseNameForUser(userId: string | null): string {
return userId ? `echotype:user:${userId}` : 'echotype:anonymous';
}

let activeUserId: string | null = null;
export let db = new EchoTypeDB(getDatabaseNameForUser(activeUserId));

export async function switchDatabaseForUser(userId: string | null): Promise<void> {
if (activeUserId === userId) return;

db.close();
activeUserId = userId;
db = new EchoTypeDB(getDatabaseNameForUser(userId));
await db.open();

if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(LOCAL_DATABASE_CHANGED_EVENT, { detail: { userId } }));
}
}
33 changes: 33 additions & 0 deletions src/stores/auth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';

const createClientMock = vi.fn();
const isSupabaseConfiguredMock = vi.fn();
const switchDatabaseForUserMock = vi.fn();

vi.mock('@/lib/supabase/client', () => ({
createClient: createClientMock,
isSupabaseConfigured: isSupabaseConfiguredMock,
}));

vi.mock('@/lib/db', () => ({
switchDatabaseForUser: switchDatabaseForUserMock,
}));

vi.mock('@/lib/tauri', () => ({
IS_TAURI: false,
}));
Expand All @@ -17,6 +22,7 @@ describe('auth-store', () => {
vi.resetModules();
vi.clearAllMocks();
isSupabaseConfiguredMock.mockReturnValue(true);
switchDatabaseForUserMock.mockResolvedValue(undefined);
});

it('hydrates the authenticated user from getUser for cookie-backed sessions', async () => {
Expand Down Expand Up @@ -45,6 +51,7 @@ describe('auth-store', () => {
expect(getUser).toHaveBeenCalledTimes(1);
expect(getSession).not.toHaveBeenCalled();
expect(onAuthStateChange).toHaveBeenCalledTimes(1);
expect(switchDatabaseForUserMock).toHaveBeenCalledWith('user-1');
expect(useAuthStore.getState()).toMatchObject({
isAuthenticated: true,
isLoading: false,
Expand Down Expand Up @@ -83,4 +90,30 @@ describe('auth-store', () => {
user,
});
});

it('switches the local database before exposing a different signed-in user', async () => {
const userA = { id: 'user-a', email: 'a@example.com', user_metadata: {} };
const userB = { id: 'user-b', email: 'b@example.com', user_metadata: {} };
let onAuthStateChange: ((event: string, session: { user: typeof userB } | null) => void) | undefined;

createClientMock.mockReturnValue({
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: userA }, error: null }),
getSession: vi.fn(),
onAuthStateChange: vi.fn((callback) => {
onAuthStateChange = callback;
return { data: { subscription: { unsubscribe: vi.fn() } } };
}),
},
});

const { useAuthStore } = await import('./auth-store');
await useAuthStore.getState().initialize();
onAuthStateChange?.('SIGNED_IN', { user: userB });

await vi.waitFor(() => {
expect(switchDatabaseForUserMock).toHaveBeenLastCalledWith('user-b');
expect(useAuthStore.getState().user).toBe(userB);
});
});
});
13 changes: 9 additions & 4 deletions src/stores/auth-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AuthChangeEvent, Session, User } from '@supabase/supabase-js';
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
import { create } from 'zustand';
import { switchDatabaseForUser } from '@/lib/db';
import { createClient, isSupabaseConfigured } from '@/lib/supabase/client';
import { IOS_NATIVE_AUTH_CALLBACK_URL, IS_IOS_NATIVE_HOST, IS_NATIVE_HOST, IS_TAURI } from '@/lib/tauri';

Expand Down Expand Up @@ -171,16 +172,19 @@ export const useAuthStore = create<AuthState>((set) => ({
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event: AuthChangeEvent, session: Session | null) => {
set({
user: session?.user ?? null,
isAuthenticated: !!session?.user,
isLoading: false,
void switchDatabaseForUser(session?.user?.id ?? null).then(() => {
set({
user: session?.user ?? null,
isAuthenticated: !!session?.user,
isLoading: false,
});
});
});
authSubscription = subscription;
}

const user = await resolveInitialUser(supabase);
await switchDatabaseForUser(user?.id ?? null);

set({
user,
Expand Down Expand Up @@ -310,6 +314,7 @@ export const useAuthStore = create<AuthState>((set) => ({
const supabase = createClient();
if (!supabase) return;
await supabase.auth.signOut();
await switchDatabaseForUser(null);
set({ user: null, isAuthenticated: false });
},
}));
Loading