From 925c995274721005a3fbf4126166440e0e388f63 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 15:25:10 -0600 Subject: [PATCH] refactor(config): centralize provider default resolution and simplify user preference inheritance Move provider normalization logic into a dedicated module to ensure consistent handling of provider defaults and user preference inheritance across client and server. Update layout components to mount ConfigProvider only at the shared layout level, preventing unnecessary remounts and hydration issues during navigation. Adjust app config defaults so user provider settings are empty by default, always inheriting the admin-configured provider unless explicitly set. Add tests for normalization and sync logic to ensure robust provider resolution. --- src/app/(app)/app/layout.tsx | 11 +- src/app/(app)/epub/[id]/layout.tsx | 11 +- src/app/(app)/html/[id]/layout.tsx | 7 +- src/app/(app)/layout.tsx | 15 +- src/app/(app)/pdf/[id]/layout.tsx | 7 +- src/app/api/user/state/preferences/route.ts | 147 +--------------- src/components/SettingsModal.tsx | 27 ++- src/contexts/ConfigContext.tsx | 128 +++++++------- src/lib/client/api/user-state.ts | 26 +++ src/lib/server/admin/settings.ts | 18 +- src/lib/server/user/preferences-normalize.ts | 161 ++++++++++++++++++ src/types/config.ts | 58 ++----- .../unit/tts-provider-catalog.vitest.spec.ts | 6 +- .../user-preferences-normalize.vitest.spec.ts | 99 +++++++++++ .../unit/user-preferences-sync.vitest.spec.ts | 81 +++++++++ 15 files changed, 523 insertions(+), 279 deletions(-) create mode 100644 src/lib/server/user/preferences-normalize.ts create mode 100644 tests/unit/user-preferences-normalize.vitest.spec.ts create mode 100644 tests/unit/user-preferences-sync.vitest.spec.ts diff --git a/src/app/(app)/app/layout.tsx b/src/app/(app)/app/layout.tsx index 947ab4f..3691ee2 100644 --- a/src/app/(app)/app/layout.tsx +++ b/src/app/(app)/app/layout.tsx @@ -2,16 +2,15 @@ import type { ReactNode } from 'react'; -import { ConfigProvider } from '@/contexts/ConfigContext'; import { DocumentProvider } from '@/contexts/DocumentContext'; import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext'; +// ConfigProvider is mounted once in the shared (app) layout so it survives +// library <-> reader navigation; do not re-wrap it here. export default function AppHomeLayout({ children }: { children: ReactNode }) { return ( - - - {children} - - + + {children} + ); } diff --git a/src/app/(app)/epub/[id]/layout.tsx b/src/app/(app)/epub/[id]/layout.tsx index 2f41494..f0b39fa 100644 --- a/src/app/(app)/epub/[id]/layout.tsx +++ b/src/app/(app)/epub/[id]/layout.tsx @@ -2,15 +2,14 @@ import type { ReactNode } from 'react'; -import { ConfigProvider } from '@/contexts/ConfigContext'; import { TTSProvider } from '@/contexts/TTSContext'; +// ConfigProvider is mounted once in the shared (app) layout so it survives +// library <-> reader navigation; do not re-wrap it here. export default function EpubReaderLayout({ children }: { children: ReactNode }) { return ( - - - {children} - - + + {children} + ); } diff --git a/src/app/(app)/html/[id]/layout.tsx b/src/app/(app)/html/[id]/layout.tsx index 3cadc04..9401b15 100644 --- a/src/app/(app)/html/[id]/layout.tsx +++ b/src/app/(app)/html/[id]/layout.tsx @@ -2,13 +2,12 @@ import type { ReactNode } from 'react'; -import { ConfigProvider } from '@/contexts/ConfigContext'; import { TTSProvider } from '@/contexts/TTSContext'; +// ConfigProvider is mounted once in the shared (app) layout so it survives +// library <-> reader navigation; do not re-wrap it here. export default function HtmlReaderLayout({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ); } diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index c048fbf..ebb1972 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; +import { ConfigProvider } from '@/contexts/ConfigContext'; import { AppMain, AppShell } from '@/components/layout'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; @@ -33,9 +34,17 @@ export default function AppLayout({ children }: { children: ReactNode }) { allowAnonymousAuthSessions={allowAnonymousAuthSessions} githubAuthEnabled={githubAuthEnabled} > - - {children} - + {/* ConfigProvider lives here, in the shared (app) layout, so it stays + mounted across library <-> reader navigation. Mounting it per-route + re-ran the Dexie/server hydration race on every navigation, causing + the reader to briefly use the admin-default provider until a full + page refresh. A single shared instance keeps the user's saved + provider hydrated the whole time. */} + + + {children} + + reader navigation; do not re-wrap it here. export default function PdfReaderLayout({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ); } diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 78f38ee..9eeae34 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -2,15 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { userPreferences } from '@/db/schema'; -import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; +import { type SyncedPreferencesPatch } from '@/types/user-state'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; -import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { listAdminProviders } from '@/lib/server/admin/providers'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; -import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; +import { + sanitizePreferencesPatch, + type PreferenceNormalizationContext, +} from '@/lib/server/user/preferences-normalize'; import { deserializeUserPreferencesPayload, extractUserPreferencesMeta, @@ -25,29 +27,14 @@ function serializePreferencesForDb(payload: Record): Record { - return typeof value === 'object' && value !== null; -} - -interface PreferenceNormalizationContext { - defaultProviderRef: string; - showAllProviderModels: boolean; - sharedProviders: Array<{ - slug: string; - providerType: TtsProviderId; - defaultModel: string | null; - defaultInstructions: string | null; - }>; -} - async function loadPreferenceNormalizationContext(): Promise { const [runtimeConfig, providers] = await Promise.all([ getResolvedRuntimeConfig(), listAdminProviders(), ]); return { - defaultProviderRef: runtimeConfig.defaultTtsProvider, showAllProviderModels: runtimeConfig.showAllProviderModels, + restrictUserApiKeys: runtimeConfig.restrictUserApiKeys, sharedProviders: providers .filter((entry) => entry.enabled) .map((entry) => ({ @@ -69,128 +56,6 @@ function parseStoredPreferences( return { ...sanitized, meta }; } -function sanitizeSavedVoices(value: unknown): Record { - if (!isRecord(value)) return {}; - const out: Record = {}; - for (const [key, val] of Object.entries(value)) { - if (typeof key !== 'string' || key.length === 0) continue; - if (typeof val !== 'string') continue; - out[key] = val; - } - return out; -} - -function sanitizePreferencesPatch( - input: unknown, - context: PreferenceNormalizationContext, - options: { fillMissingProvider: boolean }, -): { patch: SyncedPreferencesPatch; migrated: boolean } { - if (!isRecord(input)) return { patch: {}, migrated: false }; - - const rec = input as Record; - const out: SyncedPreferencesPatch = {}; - let migrated = false; - - const legacyProviderRef = typeof rec.ttsProvider === 'string' - ? rec.ttsProvider - : typeof rec.provider === 'string' - ? rec.provider - : ''; - const rawProviderRef = typeof rec.providerRef === 'string' ? rec.providerRef : legacyProviderRef; - const normalizedProviderRef = normalizeLegacyProviderRef(rawProviderRef, context.defaultProviderRef); - const providerDefaults = resolveProviderDefaults({ - providerRef: normalizedProviderRef || context.defaultProviderRef, - providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown', - sharedProviders: context.sharedProviders, - fallbackProviderRef: context.defaultProviderRef, - }); - const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string'; - if (hasLegacyProviderKey || rawProviderRef !== providerDefaults.providerRef) migrated = true; - - for (const key of SYNCED_PREFERENCE_KEYS) { - if (!(key in rec)) continue; - const value = rec[key]; - - switch (key) { - case 'viewType': - if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; - break; - case 'voice': - case 'ttsModel': - case 'ttsInstructions': - if (typeof value === 'string') out[key] = value; - break; - case 'providerRef': - out[key] = providerDefaults.providerRef; - break; - case 'providerType': - out[key] = providerDefaults.providerType; - break; - case 'voiceSpeed': - case 'audioPlayerSpeed': - case 'segmentPreloadDepthPages': - case 'segmentPreloadSentenceLookahead': - case 'ttsSegmentMaxBlockLength': - case 'headerMargin': - case 'footerMargin': - case 'leftMargin': - case 'rightMargin': - if (Number.isFinite(value)) out[key] = Number(value); - break; - case 'skipBlank': - case 'epubTheme': - case 'pdfHighlightEnabled': - case 'pdfWordHighlightEnabled': - case 'epubHighlightEnabled': - case 'epubWordHighlightEnabled': - case 'htmlHighlightEnabled': - case 'htmlWordHighlightEnabled': - if (typeof value === 'boolean') out[key] = value; - break; - case 'savedVoices': - out[key] = sanitizeSavedVoices(value); - break; - default: - break; - } - } - - if ('providerRef' in out && !('providerType' in out)) { - out.providerType = providerDefaults.providerType; - migrated = true; - } - - if (options.fillMissingProvider && !('providerRef' in out)) { - out.providerRef = providerDefaults.providerRef; - migrated = true; - } - if (options.fillMissingProvider && !('providerType' in out)) { - out.providerType = providerDefaults.providerType; - migrated = true; - } - - const rawModel = typeof rec.ttsModel === 'string' ? rec.ttsModel.trim() : ''; - const shouldNormalizeSharedDefaultModel = - !!providerDefaults.defaultModel - && context.sharedProviders.some((entry) => entry.slug === providerDefaults.providerRef) - && (rawModel.length === 0 || rawModel === 'kokoro') - && (hasLegacyProviderKey || rawProviderRef === 'default-openai'); - - if (options.fillMissingProvider && !('ttsModel' in out) && providerDefaults.defaultModel) { - out.ttsModel = providerDefaults.defaultModel; - migrated = true; - } else if (shouldNormalizeSharedDefaultModel && out.ttsModel !== providerDefaults.defaultModel) { - out.ttsModel = providerDefaults.defaultModel; - migrated = true; - } - if (!context.showAllProviderModels && providerDefaults.defaultModel && out.ttsModel !== providerDefaults.defaultModel) { - out.ttsModel = providerDefaults.defaultModel; - migrated = true; - } - - return { patch: out, migrated }; -} - function normalizeClientUpdatedAtMs(value: unknown): number { const normalized = coerceTimestampMs(value, nowTimestampMs()); if (normalized <= 0) return nowTimestampMs(); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index b9465ea..d730dc8 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -37,6 +37,8 @@ import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel'; import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; import { useSharedProviders } from '@/hooks/useSharedProviders'; +import { flushUserPreferencesSync } from '@/lib/client/api/user-state'; +import toast from 'react-hot-toast'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { SidebarNav, @@ -265,7 +267,7 @@ export function SettingsModal({ prefetch: prefetchLibraryDocuments, } = useLibraryDocumentsQuery(isSelectionModalOpen); - const { providers: sharedProviders } = useSharedProviders(); + const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders(); const { providers: ttsProviders, models: ttsModels, @@ -295,13 +297,18 @@ export function SettingsModal({ }, [changelogOpenSignal, setIsOpen]); useEffect(() => { + // Only mirror persisted config into the local form while the modal is closed. + // While it's open the user may be mid-edit, and a background refresh (e.g. the + // focus refetch in ConfigContext, or async shared-provider loading) must not + // stomp their in-progress selection. On close, resetToCurrent re-syncs. + if (isOpen) return; setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalProviderRef(providerRef); setLocalProviderType(providerType); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); - }, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); + }, [isOpen, apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { @@ -643,7 +650,9 @@ export function SettingsModal({
- {ttsProviders.length === 0 ? ( + {sharedProvidersLoading ? ( +

Loading providers…

+ ) : ttsProviders.length === 0 ? (

User API keys are restricted and no shared provider is configured. Ask an admin to add one.

@@ -815,7 +824,7 @@ export function SettingsModal({ type="button" variant="primary" size="md" - disabled={!canSubmit} + disabled={!canSubmit || sharedProvidersLoading} onClick={async () => { const defaults = resolveProviderDefaults({ providerRef: selectedProviderRef, @@ -833,6 +842,16 @@ export function SettingsModal({ : defaults.defaultModel; await updateConfigKey('ttsModel', finalModel); await updateConfigKey('ttsInstructions', localTTSInstructions); + // Push the change to the server immediately rather than waiting on + // the debounce, so a quick reload can't lose the save. Surface + // failures instead of silently dropping them. + try { + await flushUserPreferencesSync(); + } catch (error) { + console.error('Failed to save TTS provider settings:', error); + toast.error('Failed to save provider settings. Please try again.'); + return; + } setIsOpen(false); }} > diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index b6a6c74..cf8ce0a 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -5,11 +5,11 @@ import { useLiveQuery } from 'dexie-react-hooks'; import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog'; -import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; +import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { useAuthSession } from '@/hooks/useAuthSession'; -import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences'; import { applyConfigUpdate } from '@/lib/client/config/updates'; import { useSharedProviders } from '@/hooks/useSharedProviders'; @@ -69,16 +69,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const didRunStartupMigrations = useRef(false); const didAttemptInitialPreferenceSeedForSession = useRef(null); const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []); - const { providers: sharedProviders } = useSharedProviders(); + const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders(); const { data: sessionData, isPending: isSessionPending } = useAuthSession(); const sessionKey = sessionData?.user?.id ?? 'no-session'; - const providerResetDefaults = useMemo(() => { - return resolveProviderDefaults({ - providerRef: APP_CONFIG_DEFAULTS.providerRef, - providerType: APP_CONFIG_DEFAULTS.providerType, - sharedProviders, - }); - }, [sharedProviders]); + // The instance/admin default provider. An empty user providerRef "inherits" + // this, resolved (admin slug -> concrete provider) where the value is used. + const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider; const queueSyncedPreferencePatch = useCallback((patch: Partial) => { if (sessionKey === 'no-session') return; @@ -213,28 +209,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [appConfig]); useEffect(() => { - if (!ttsProvidersTabDisabled || !isDBReady || !appConfig) return; + if (!ttsProvidersTabDisabled || !isDBReady || !appConfig || sharedProvidersLoading) return; const resetPatch: Partial = {}; - if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) { - resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey; - } - if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) { - resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl; - } - if (appConfig.providerRef !== providerResetDefaults.providerRef) { - resetPatch.providerRef = providerResetDefaults.providerRef; - } - if (appConfig.providerType !== providerResetDefaults.providerType) { - resetPatch.providerType = providerResetDefaults.providerType; - } - if (appConfig.ttsModel !== providerResetDefaults.defaultModel) { - resetPatch.ttsModel = providerResetDefaults.defaultModel; - } - if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) { - resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions; - } + // When the provider tab is hidden, clear any user-set provider config back to + // "inherit the admin default" (empty) rather than baking in a concrete value. + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + if (appConfig.providerRef !== '') resetPatch.providerRef = ''; + if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown'; + if (appConfig.ttsModel !== '') resetPatch.ttsModel = ''; + if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = ''; // Keep voice selection state intact so player/Audiobook voice pickers still // work when the TTS providers tab is hidden. This reset is only for provider // configuration fields. @@ -245,32 +231,24 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to clear hidden TTS provider settings:', error); }); queueSyncedPreferencePatch(resetPatch); - }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]); + }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (!restrictUserApiKeys || !isDBReady || !appConfig) return; + if (!restrictUserApiKeys || !isDBReady || !appConfig || sharedProvidersLoading) return; const resetPatch: Partial = {}; - if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) { - resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey; - } - if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) { - resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl; - } + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + // Built-in providers aren't selectable in restricted mode. Clear any stale + // built-in selection (including the old 'custom-openai' default that used to + // be baked into every config) back to "inherit the admin default" so the + // user follows whatever shared provider the admin has configured. if (isBuiltInTtsProviderId(appConfig.providerRef)) { - if (appConfig.providerRef !== providerResetDefaults.providerRef) { - resetPatch.providerRef = providerResetDefaults.providerRef; - } - if (appConfig.providerType !== providerResetDefaults.providerType) { - resetPatch.providerType = providerResetDefaults.providerType; - } - if (appConfig.ttsModel !== providerResetDefaults.defaultModel) { - resetPatch.ttsModel = providerResetDefaults.defaultModel; - } - if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) { - resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions; - } + resetPatch.providerRef = ''; + resetPatch.providerType = 'unknown'; + resetPatch.ttsModel = ''; + resetPatch.ttsInstructions = ''; } if (Object.keys(resetPatch).length === 0) return; @@ -279,15 +257,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to enforce restricted user API key mode:', error); }); queueSyncedPreferencePatch(resetPatch); - }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]); + }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (showAllProviderModels || !isDBReady || !appConfig) return; + if (showAllProviderModels || !isDBReady || !appConfig || sharedProvidersLoading) return; + // Inheriting (empty providerRef): the effective model is resolved at read + // time, so there is nothing to persist/enforce here. + if (!appConfig.providerRef) return; const providerDefaults = resolveProviderDefaults({ providerRef: appConfig.providerRef, providerType: appConfig.providerType, sharedProviders, - fallbackProviderRef: providerResetDefaults.providerRef, + fallbackProviderRef: adminDefaultProviderRef, }); if (!providerDefaults.defaultModel) return; if (appConfig.ttsModel === providerDefaults.defaultModel) return; @@ -296,7 +277,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to enforce provider default model restriction:', error); }); queueSyncedPreferencePatch(patch); - }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]); + }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, adminDefaultProviderRef, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { if (!isDBReady || !appConfig || isSessionPending) return; @@ -359,33 +340,48 @@ export function ConfigProvider({ children }: { children: ReactNode }) { htmlHighlightEnabled, htmlWordHighlightEnabled, } = config || APP_CONFIG_DEFAULTS; - const providerType = useMemo( - () => resolveEffectiveProviderType({ + // Resolve the effective provider for consumers. An empty stored providerRef + // means "inherit the admin default", which we resolve here so the reader, + // voice pickers, and settings UI all see a concrete, usable provider without + // mutating the stored ("inherit") value. + const effectiveProvider = useMemo( + () => resolveProviderDefaults({ providerRef, providerType: _persistedProviderType, sharedProviders, + fallbackProviderRef: adminDefaultProviderRef, }), - [providerRef, _persistedProviderType, sharedProviders], + [providerRef, _persistedProviderType, sharedProviders, adminDefaultProviderRef], ); + const effectiveProviderRef = effectiveProvider.providerRef; + const providerType = effectiveProvider.providerType; + const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel; + const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions; useEffect(() => { - if (!isDBReady || !appConfig) return; + if (!isDBReady || !appConfig || sharedProvidersLoading) return; + // Only persist a resolved providerType for an explicitly chosen provider. + // While inheriting (empty providerRef) the type stays unset in storage. + if (!appConfig.providerRef) return; if (appConfig.providerType === providerType) return; const patch: Partial = { providerType }; updateAppConfig(patch).catch((error) => { console.warn('Failed to persist resolved providerType:', error); }); queueSyncedPreferencePatch(patch); - }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch]); + }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]); void _persistedProviderType; useEffect(() => { - if (!isDBReady || !appConfig) return; + if (!isDBReady || !appConfig || sharedProvidersLoading) return; + // Inheriting (empty providerRef): the effective model is resolved at read + // time; don't write a concrete model into the "inherit" state. + if (!appConfig.providerRef) return; const providerDefaults = resolveProviderDefaults({ providerRef: appConfig.providerRef, providerType: appConfig.providerType, sharedProviders, - fallbackProviderRef: providerResetDefaults.providerRef, + fallbackProviderRef: adminDefaultProviderRef, }); if (!providerDefaults.defaultModel) return; if (appConfig.ttsModel === providerDefaults.defaultModel) return; @@ -398,7 +394,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { console.warn('Failed to normalize shared-provider default model:', error); }); queueSyncedPreferencePatch(patch); - }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, providerResetDefaults.providerRef]); + }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]); /** * Updates multiple configuration values simultaneously @@ -436,9 +432,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) { try { setIsLoading(true); const { storagePatch, syncPatch } = applyConfigUpdate({ - providerRef, + providerRef: effectiveProviderRef, providerType, - ttsModel, + ttsModel: effectiveTtsModel, savedVoices, }, key, value); @@ -478,10 +474,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) { footerMargin, leftMargin, rightMargin, - providerRef, + providerRef: effectiveProviderRef, providerType, - ttsModel, - ttsInstructions, + ttsModel: effectiveTtsModel, + ttsInstructions: effectiveTtsInstructions, savedVoices, updateConfig, updateConfigKey, diff --git a/src/lib/client/api/user-state.ts b/src/lib/client/api/user-state.ts index a3d0948..d9e8cf1 100644 --- a/src/lib/client/api/user-state.ts +++ b/src/lib/client/api/user-state.ts @@ -148,6 +148,32 @@ export function scheduleUserPreferencesSync( }, debounceMs); } +/** + * Immediately send any pending debounced preference patch and await the result. + * Use this when the user explicitly saves: we want the server write to complete + * (and surface failures) instead of silently relying on the debounce timer, which + * can be lost if the page is reloaded inside the debounce window. + */ +export async function flushUserPreferencesSync(): Promise { + if (pendingPreferenceSync.timer) { + clearTimeout(pendingPreferenceSync.timer); + pendingPreferenceSync.timer = null; + } + + const payload = { ...pendingPreferenceSync.patch }; + pendingPreferenceSync.patch = {}; + if (Object.keys(payload).length === 0) return; + + if (activeSyncController) activeSyncController.abort(); + activeSyncController = new AbortController(); + + try { + await putUserPreferences(payload, { signal: activeSyncController.signal }); + } finally { + activeSyncController = null; + } +} + export async function getDocumentProgress( documentId: string, options?: { signal?: AbortSignal }, diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 5a87363..9f65e4a 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm'; +import { asc, desc, eq } from 'drizzle-orm'; import { db } from '@/db'; import { adminProviders, adminSettings } from '@/db/schema'; import { serverLogger } from '@/lib/server/logger'; @@ -116,9 +116,19 @@ async function resolveImplicitDefaultTtsProvider(): Promise const rows = await db .select({ slug: adminProviders.slug }) .from(adminProviders) - .where(and(eq(adminProviders.slug, 'default-openai'), eq(adminProviders.enabled, 1))) - .limit(1); - return rows[0]?.slug; + .where(eq(adminProviders.enabled, 1)) + .orderBy( + desc(adminProviders.updatedAt), + desc(adminProviders.createdAt), + asc(adminProviders.slug), + ); + const slugs = (rows as Array<{ slug: string }>).map((row) => row.slug); + // Prefer the conventional 'default-openai' slug when present, otherwise fall + // back to the first enabled shared provider so a fresh instance with any + // configured provider resolves to a real, usable provider rather than the + // built-in 'custom-openai' placeholder. + if (slugs.includes('default-openai')) return 'default-openai'; + return slugs[0]; } catch (error) { logDegraded(serverLogger, { event: 'admin.runtime_config.default_provider_lookup.failed', diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts new file mode 100644 index 0000000..4555330 --- /dev/null +++ b/src/lib/server/user/preferences-normalize.ts @@ -0,0 +1,161 @@ +import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; +import { isBuiltInTtsProviderId, isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; +import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; + +export interface PreferenceNormalizationContext { + showAllProviderModels: boolean; + restrictUserApiKeys: boolean; + sharedProviders: Array<{ + slug: string; + providerType: TtsProviderId; + defaultModel: string | null; + defaultInstructions: string | null; + }>; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function sanitizeSavedVoices(value: unknown): Record { + if (!isRecord(value)) return {}; + const out: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (typeof key !== 'string' || key.length === 0) continue; + if (typeof val !== 'string') continue; + out[key] = val; + } + return out; +} + +/** + * Normalize a stored/incoming preferences blob into a clean synced-preferences + * patch. + * + * Provider handling follows an "inherit the admin default" model: an empty + * `providerRef` means the user has made no explicit choice and should follow the + * instance default. We deliberately preserve empty rather than collapsing it to + * a concrete provider, so inheriting users track whatever the admin configures. + * Built-in provider ids under restricted mode (and the legacy `default-openai` + * sentinel that isn't a real shared provider) are mapped back to inherit. + */ +export function sanitizePreferencesPatch( + input: unknown, + context: PreferenceNormalizationContext, + options: { fillMissingProvider: boolean }, +): { patch: SyncedPreferencesPatch; migrated: boolean } { + if (!isRecord(input)) return { patch: {}, migrated: false }; + + const rec = input as Record; + const out: SyncedPreferencesPatch = {}; + let migrated = false; + + const legacyProviderRef = typeof rec.ttsProvider === 'string' + ? rec.ttsProvider + : typeof rec.provider === 'string' + ? rec.provider + : ''; + const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string'; + const hasProviderRefKey = typeof rec.providerRef === 'string'; + const rawProviderRef = hasProviderRefKey ? (rec.providerRef as string) : legacyProviderRef; + + const sharedSlugs = new Set(context.sharedProviders.map((entry) => entry.slug)); + let providerRefIntent = typeof rawProviderRef === 'string' ? rawProviderRef.trim() : ''; + // Legacy 'default-openai' sentinel: only honored when it's a real configured + // shared provider; otherwise it historically meant "use the default" → inherit. + if (providerRefIntent === 'default-openai' && !sharedSlugs.has('default-openai')) { + providerRefIntent = ''; + } + // Built-in providers aren't selectable under restricted mode → inherit. This + // also migrates the old baked-in 'custom-openai' default off existing rows. + if (context.restrictUserApiKeys && isBuiltInTtsProviderId(providerRefIntent)) { + providerRefIntent = ''; + } + const providerRefIsExplicit = providerRefIntent.length > 0; + const providerDefaults = providerRefIsExplicit + ? resolveProviderDefaults({ + providerRef: providerRefIntent, + providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown', + sharedProviders: context.sharedProviders, + }) + : null; + + if (hasLegacyProviderKey || (hasProviderRefKey && (rec.providerRef as string) !== providerRefIntent)) { + migrated = true; + } + + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in rec)) continue; + const value = rec[key]; + + switch (key) { + case 'viewType': + if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; + break; + case 'voice': + case 'ttsInstructions': + if (typeof value === 'string') out[key] = value; + break; + // providerRef / providerType / ttsModel are resolved together below. + case 'providerRef': + case 'providerType': + case 'ttsModel': + break; + case 'voiceSpeed': + case 'audioPlayerSpeed': + case 'segmentPreloadDepthPages': + case 'segmentPreloadSentenceLookahead': + case 'ttsSegmentMaxBlockLength': + case 'headerMargin': + case 'footerMargin': + case 'leftMargin': + case 'rightMargin': + if (Number.isFinite(value)) out[key] = Number(value); + break; + case 'skipBlank': + case 'epubTheme': + case 'pdfHighlightEnabled': + case 'pdfWordHighlightEnabled': + case 'epubHighlightEnabled': + case 'epubWordHighlightEnabled': + case 'htmlHighlightEnabled': + case 'htmlWordHighlightEnabled': + if (typeof value === 'boolean') out[key] = value; + break; + case 'savedVoices': + out[key] = sanitizeSavedVoices(value); + break; + default: + break; + } + } + + // Persist concrete provider/type/model only for an explicit selection. An + // inheriting user keeps these empty so resolution happens against the live + // admin default at read/generation time. + const shouldWriteProvider = hasProviderRefKey || hasLegacyProviderKey || options.fillMissingProvider; + if (shouldWriteProvider) { + out.providerRef = providerRefIntent; + out.providerType = providerRefIsExplicit ? providerDefaults!.providerType : 'unknown'; + } + + if (providerRefIsExplicit) { + const rawModel = typeof rec.ttsModel === 'string' ? rec.ttsModel.trim() : ''; + const lockedToDefault = !context.showAllProviderModels && !!providerDefaults!.defaultModel; + const model = lockedToDefault + ? providerDefaults!.defaultModel + : (rawModel || providerDefaults!.defaultModel); + if (model) { + out.ttsModel = model; + if (model !== rawModel) migrated = true; + } else if (typeof rec.ttsModel === 'string') { + out.ttsModel = rec.ttsModel; + } + } else if (shouldWriteProvider || 'ttsModel' in rec) { + // Inheriting: the model inherits too. + if (typeof rec.ttsModel === 'string' && rec.ttsModel !== '') migrated = true; + out.ttsModel = ''; + } + + return { patch: out, migrated }; +} diff --git a/src/types/config.ts b/src/types/config.ts index 53db98c..711b634 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,21 +1,5 @@ import type { DocumentListState } from '@/types/documents'; -import { isBuiltInTtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; -import { defaultModelForProviderType } from '@/lib/shared/tts-provider-policy'; - -// Runtime config (admin-controlled) is layered on top of the static defaults -// below. We resolve it lazily so this module stays importable from non-React -// contexts (Dexie, server routes). The actual values come from -// `window.__RUNTIME_CONFIG__` (SSR-injected) on the client, and -// from the built-in defaults during SSR. - -function readRuntimeString(key: string, defaultValue: string): string { - if (typeof window === 'undefined') return defaultValue; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__RUNTIME_CONFIG__; - if (!injected || typeof injected !== 'object') return defaultValue; - const value = injected[key]; - return typeof value === 'string' && value ? value : defaultValue; -} +import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog'; export type ViewType = 'single' | 'dual' | 'scroll'; @@ -78,20 +62,18 @@ export interface AppConfigValues { } /** - * Build defaults lazily so we can read SSR-injected admin overrides - * (`window.__RUNTIME_CONFIG__`). Modules that need the defaults - * statically should call `getAppConfigDefaults()` at use time. The exported - * `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so - * mutations to the runtime config (admin edits) are picked up by anything - * that reads through it. + * Build the static app-config defaults. These no longer read any SSR/admin + * runtime config: the user's TTS provider defaults to empty ("inherit the + * admin default"), which is resolved to a concrete provider where it is used + * (ConfigContext on the client, credential resolution on the server). */ export function getAppConfigDefaults(): AppConfigValues { - const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai'); - const defaultProviderRef = runtimeProviderRef.trim(); - const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown'; - const defaultModel = isBuiltInTtsProviderId(defaultProviderType) - ? defaultModelForProviderType(defaultProviderType) - : 'kokoro'; + // The user's TTS provider is intentionally left empty by default. An empty + // providerRef means "inherit the instance/admin default" and is resolved to a + // concrete provider at read time (see ConfigContext) and at generation time + // (server-side credential resolution). We no longer bake a placeholder + // provider id (the old 'custom-openai') into every user's config, since that + // value isn't actually selectable in shared-provider mode. return { apiKey: '', baseUrl: '', @@ -105,9 +87,9 @@ export function getAppConfigDefaults(): AppConfigValues { footerMargin: 0, leftMargin: 0, rightMargin: 0, - providerRef: defaultProviderRef, - providerType: defaultProviderType, - ttsModel: defaultModel, + providerRef: '', + providerType: 'unknown', + ttsModel: '', ttsInstructions: '', savedVoices: {}, segmentPreloadDepthPages: 1, @@ -134,17 +116,13 @@ export function getAppConfigDefaults(): AppConfigValues { } /** - * Static defaults snapshot resolved at first access. For callers that need - * fresh values after admin edits, prefer `getAppConfigDefaults()` directly. - * Most consumers just need a stable defaults object for spreads, so this is - * resolved once per process — admin overrides take effect on next page load - * (which is the SSR-injected behavior we want anyway). + * Static defaults snapshot, resolved once at first access. The values are + * constant (no SSR/admin overrides are read here anymore), so the lazy Proxy + * just avoids building the object until something first reads it. */ let cachedDefaults: AppConfigValues | null = null; export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => { - // Return a getter-backed object that resolves on first access. On the - // server, this resolves to built-in defaults; on the client, to the - // SSR-injected admin values. + // Getter-backed object that builds the defaults lazily on first access. const handler: ProxyHandler = { get(_target, prop) { if (!cachedDefaults) cachedDefaults = getAppConfigDefaults(); diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts index 8996617..acd5b94 100644 --- a/tests/unit/tts-provider-catalog.vitest.spec.ts +++ b/tests/unit/tts-provider-catalog.vitest.spec.ts @@ -476,9 +476,13 @@ describe('config helpers', () => { ttsModel: 'kokoro', }); + // The user's provider/model now default to empty ("inherit the admin + // default"), so empty values are treated as defaults and filtered out. expect(buildSyncedPreferencePatch({ voiceSpeed: 1, - ttsModel: 'kokoro', + providerRef: '', + providerType: 'unknown', + ttsModel: '', }, { nonDefaultOnly: true })).toEqual({}); }); }); diff --git a/tests/unit/user-preferences-normalize.vitest.spec.ts b/tests/unit/user-preferences-normalize.vitest.spec.ts new file mode 100644 index 0000000..16975ea --- /dev/null +++ b/tests/unit/user-preferences-normalize.vitest.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'vitest'; + +import { + sanitizePreferencesPatch, + type PreferenceNormalizationContext, +} from '../../src/lib/server/user/preferences-normalize'; + +function makeContext(overrides: Partial = {}): PreferenceNormalizationContext { + return { + showAllProviderModels: true, + restrictUserApiKeys: true, + sharedProviders: [ + { slug: 'shared-a', providerType: 'openai', defaultModel: 'gpt-4o-mini-tts', defaultInstructions: 'hi' }, + { slug: 'shared-b', providerType: 'custom-openai', defaultModel: 'kokoro', defaultInstructions: null }, + ], + ...overrides, + }; +} + +describe('sanitizePreferencesPatch — inherit-by-default provider model', () => { + test('preserves an empty providerRef as "inherit" rather than collapsing to a concrete provider', () => { + const { patch, migrated } = sanitizePreferencesPatch( + { providerRef: '', voiceSpeed: 1.5 }, + makeContext(), + { fillMissingProvider: true }, + ); + expect(patch.providerRef).toBe(''); + expect(patch.providerType).toBe('unknown'); + expect(patch.ttsModel).toBe(''); + expect(patch.voiceSpeed).toBe(1.5); + expect(migrated).toBe(false); + }); + + test('migrates a stale built-in (custom-openai) selection to inherit under restricted mode', () => { + const { patch, migrated } = sanitizePreferencesPatch( + { providerRef: 'custom-openai', providerType: 'custom-openai', ttsModel: 'kokoro' }, + makeContext({ restrictUserApiKeys: true }), + { fillMissingProvider: true }, + ); + expect(patch.providerRef).toBe(''); + expect(patch.providerType).toBe('unknown'); + expect(patch.ttsModel).toBe(''); + expect(migrated).toBe(true); + }); + + test('keeps an explicit built-in selection when API keys are NOT restricted', () => { + const { patch } = sanitizePreferencesPatch( + { providerRef: 'openai', providerType: 'openai', ttsModel: 'tts-1' }, + makeContext({ restrictUserApiKeys: false }), + { fillMissingProvider: false }, + ); + expect(patch.providerRef).toBe('openai'); + expect(patch.providerType).toBe('openai'); + expect(patch.ttsModel).toBe('tts-1'); + }); + + test('preserves an explicit shared-provider selection and resolves its type', () => { + const { patch } = sanitizePreferencesPatch( + { providerRef: 'shared-b', ttsModel: 'my-model' }, + makeContext(), + { fillMissingProvider: false }, + ); + expect(patch.providerRef).toBe('shared-b'); + expect(patch.providerType).toBe('custom-openai'); + expect(patch.ttsModel).toBe('my-model'); + }); + + test('locks the model to the shared provider default when showAllProviderModels is false', () => { + const { patch } = sanitizePreferencesPatch( + { providerRef: 'shared-a', ttsModel: 'something-else' }, + makeContext({ showAllProviderModels: false }), + { fillMissingProvider: false }, + ); + expect(patch.providerRef).toBe('shared-a'); + expect(patch.ttsModel).toBe('gpt-4o-mini-tts'); + }); + + test('treats the legacy "default-openai" sentinel as inherit when it is not a real shared provider', () => { + const { patch, migrated } = sanitizePreferencesPatch( + { providerRef: 'default-openai' }, + makeContext(), + { fillMissingProvider: true }, + ); + expect(patch.providerRef).toBe(''); + expect(migrated).toBe(true); + }); + + test('does not force provider fields on a partial PUT patch that omits them', () => { + const { patch } = sanitizePreferencesPatch( + { voice: 'af_sarah' }, + makeContext(), + { fillMissingProvider: false }, + ); + expect(patch.voice).toBe('af_sarah'); + expect('providerRef' in patch).toBe(false); + expect('providerType' in patch).toBe(false); + expect('ttsModel' in patch).toBe(false); + }); +}); diff --git a/tests/unit/user-preferences-sync.vitest.spec.ts b/tests/unit/user-preferences-sync.vitest.spec.ts new file mode 100644 index 0000000..aae3c76 --- /dev/null +++ b/tests/unit/user-preferences-sync.vitest.spec.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + cancelPendingPreferenceSync, + flushUserPreferencesSync, + scheduleUserPreferencesSync, +} from '../../src/lib/client/api/user-state'; + +type FetchMock = ReturnType & { calls: Array<{ url: string; init?: RequestInit }> }; + +function installFetchMock(responder: () => Response): FetchMock { + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + mock.calls.push({ url: String(input), init }); + return responder(); + }) as unknown as FetchMock; + mock.calls = []; + global.fetch = mock as unknown as typeof fetch; + return mock; +} + +function okResponse(): Response { + return new Response( + JSON.stringify({ preferences: {}, clientUpdatedAtMs: Date.now(), applied: true }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +} + +describe('flushUserPreferencesSync', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.useFakeTimers(); + cancelPendingPreferenceSync(); + }); + + afterEach(() => { + cancelPendingPreferenceSync(); + vi.useRealTimers(); + global.fetch = originalFetch; + }); + + test('sends a pending debounced patch immediately instead of waiting for the timer', async () => { + const fetchMock = installFetchMock(okResponse); + + scheduleUserPreferencesSync({ providerRef: 'shared-b', providerType: 'openai' }, 'user-1'); + + // Nothing should have fired yet — it's still debounced. + expect(fetchMock.calls).toHaveLength(0); + + await flushUserPreferencesSync(); + + expect(fetchMock.calls).toHaveLength(1); + const { url, init } = fetchMock.calls[0]; + expect(url).toContain('/api/user/state/preferences'); + expect(init?.method).toBe('PUT'); + const body = JSON.parse(String(init?.body)); + expect(body.patch.providerRef).toBe('shared-b'); + expect(body.patch.providerType).toBe('openai'); + + // The debounce timer must not also fire a duplicate request afterwards. + await vi.runAllTimersAsync(); + expect(fetchMock.calls).toHaveLength(1); + }); + + test('is a no-op when there is no pending patch', async () => { + const fetchMock = installFetchMock(okResponse); + await flushUserPreferencesSync(); + expect(fetchMock.calls).toHaveLength(0); + }); + + test('rejects when the server write fails so callers can surface the error', async () => { + installFetchMock(() => new Response(JSON.stringify({ error: 'boom' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + })); + + scheduleUserPreferencesSync({ providerRef: 'shared-b' }, 'user-1'); + + await expect(flushUserPreferencesSync()).rejects.toThrow(); + }); +});