diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 461b5779..57df0365 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,12 @@ import type { ReactNode } from 'react'; +import { useEffect, useRef } from 'react'; import { MotionConfig } from 'motion/react'; import { AuthProvider, useAuth } from './context/AuthContext'; import { useReducedMotion } from './hooks/use-theme'; +import { useUserPreferencesSync } from './hooks/useUserPreferencesSync'; +import { subscribeToUnsaved } from './lib/preferences/preferenceEvents'; +import { retryDomain } from './lib/preferences/syncBus'; +import { toast } from './components/ui/toast-store'; import { NodeProvider } from './context/NodeContext'; import { LicenseProvider } from './context/LicenseContext'; import { Login } from './components/Login'; @@ -14,6 +19,37 @@ import { ToastContainer } from './components/ui/toast'; import { Button } from './components/ui/button'; import { AlertCircle, RefreshCw } from 'lucide-react'; +/** Mounts the per-user preference sync owner. Lives OUTSIDE AppContent (a + * sibling of the toast layer) because AppContent unmounts on logout; the sync + * owner must survive auth transitions to finish or abandon in-flight work. + * Also hosts the unsaved-change toast: a failed write raises an episode and + * this renders ONE error toast per episode with a Retry action that re-runs + * the failed operation by kind. */ +function UserPreferencesSync() { + useUserPreferencesSync(); + useUnsavedPreferenceToast(); + return null; +} + +/** Subscribes to the sync bus failure surface. A new episode number shows the + * toast; clearing the episode (success) is silent; a retry failure keeps the + * episode alive so the toast does not stack. */ +function useUnsavedPreferenceToast() { + const episodeRef = useRef(0); + useEffect(() => subscribeToUnsaved((episode) => { + if (episode === null) { + episodeRef.current = 0; + return; + } + if (episode.episode === episodeRef.current) return; + episodeRef.current = episode.episode; + const domain = episode.domain === 'appearance' ? 'appearance preferences' : 'navigation preferences'; + toast.error(`Could not save your ${domain}. Your changes are kept on this device only.`, { + action: { label: 'Retry', onClick: () => retryDomain(episode.domain) }, + }); + }), []); +} + /** Gates framer-motion animations on the "Reduced motion" appearance setting. * 'always' suppresses transform/layout motion app-wide; 'user' defers to the OS * prefers-reduced-motion. Sonner toasts do not use framer-motion, so they are @@ -84,6 +120,7 @@ function App() { + ); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 5630b907..f51b7b91 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, useCallback, useRef, ty import { markMilestone } from '@/lib/hydrationTiming'; import { clearStackStatusesFetch } from '@/lib/stackStatusesFetch'; import { resolveCan } from '@/lib/resolveCan'; +import { bumpGeneration, resetPreferenceSync } from '@/lib/preferences/preferenceEvents'; type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge' | 'authenticated'; @@ -17,6 +18,10 @@ export type PermissionAction = interface UserInfo { username: string; + /** Numeric account id (stable across renames, changes when the account is + * deleted and recreated under the same name). Used by the preference sync + * layer to own cached per-user state. */ + userId: number; role: UserRole; } @@ -48,9 +53,26 @@ interface AuthContextType { const AuthContext = createContext(undefined); +/** localStorage signal key: login/logout writes it so other tabs re-run + * checkAuth (cookies are not visible across tabs via storage events). */ +const AUTH_SIGNAL_KEY = 'sencho.auth.signal'; + +/** Called by login/logout paths to wake other tabs. */ +function signalAuthChanged(): void { + try { + localStorage.setItem(AUTH_SIGNAL_KEY, String(Date.now())); + } catch { + // ignore; cross-tab refresh is best-effort + } +} + export function AuthProvider({ children }: { children: ReactNode }) { const [appStatus, setAppStatus] = useState('loading'); const [user, setUser] = useState(null); + // The last resolved account id, so a re-check that lands on a different + // user (cross-tab login of another account, deleted-and-recreated account) + // is detectable as an identity transition. + const resolvedUserIdRef = useRef(null); const [permissions, setPermissions] = useState(null); const [permissionsStatus, setPermissionsStatus] = useState('loading'); const permissionRequestRef = useRef(0); @@ -61,6 +83,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { setPermissionsStatus('loading'); }, []); + // Identity transitions invalidate all async preference work captured under + // the previous identity AND clear the sync bus state (queued edits, failed + // operations, unsaved episodes): a stale Retry under a new account must + // never replay the previous account's writes. + const noteIdentityTransition = useCallback(() => { + bumpGeneration(); + resetPreferenceSync(); + }, []); + const loadPermissions = useCallback(async () => { const requestId = ++permissionRequestRef.current; setPermissions(null); @@ -95,13 +126,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAppStatus('needsSetup'); setUser(null); resetPermissions(); + resolvedUserIdRef.current = null; + noteIdentityTransition(); return; } if (statusData.mfaPending) { setUser(null); resetPermissions(); + resolvedUserIdRef.current = null; setAppStatus('mfaChallenge'); + noteIdentityTransition(); return; } @@ -110,16 +145,29 @@ export function AuthProvider({ children }: { children: ReactNode }) { const data = await authResponse.json(); setUser(data.user ?? null); setAppStatus('authenticated'); + // A successful check that resolves to a DIFFERENT account than the + // last one (cross-tab login of another user, account swap) is an + // identity transition: stale preference work captured for the old + // account must be invalidated before the new account's state loads. + const nextUserId = typeof data.user?.userId === 'number' ? data.user.userId : null; + if (nextUserId !== resolvedUserIdRef.current) { + resolvedUserIdRef.current = nextUserId; + noteIdentityTransition(); + } await loadPermissions(); } else { setUser(null); resetPermissions(); + resolvedUserIdRef.current = null; setAppStatus('notAuthenticated'); + noteIdentityTransition(); } } catch { setUser(null); resetPermissions(); + resolvedUserIdRef.current = null; setAppStatus('notAuthenticated'); + noteIdentityTransition(); } }; @@ -136,12 +184,30 @@ export function AuthProvider({ children }: { children: ReactNode }) { clearStackStatusesFetch(); setUser(null); resetPermissions(); + resolvedUserIdRef.current = null; setAppStatus('notAuthenticated'); + noteIdentityTransition(); }; window.addEventListener('sencho-unauthorized', handleUnauthorized); return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized); }, []); + // Cross-tab identity changes: cookies are not storage events, so a second + // tab's login/logout writes a signal key and this tab re-runs checkAuth. + useEffect(() => { + function onStorage(event: StorageEvent) { + if (event.key !== AUTH_SIGNAL_KEY || event.newValue === null) return; + try { + localStorage.removeItem(AUTH_SIGNAL_KEY); + } catch { + // ignore; the signal still processed this tab + } + void checkAuth(); + } + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + const can = useCallback(( action: PermissionAction, resourceType?: string, @@ -166,6 +232,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const data = await response.json(); if (response.ok && data.success) { + signalAuthChanged(); if (data.mfaRequired) { await checkAuth(); return { success: true, mfaRequired: true }; @@ -193,6 +260,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const data = await response.json(); if (response.ok && data.success) { + signalAuthChanged(); if (data.mfaRequired) { await checkAuth(); return { success: true, mfaRequired: true }; @@ -240,7 +308,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { clearStackStatusesFetch(); setUser(null); resetPermissions(); + resolvedUserIdRef.current = null; setAppStatus('notAuthenticated'); + noteIdentityTransition(); } }; @@ -256,7 +326,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { clearStackStatusesFetch(); setUser(null); resetPermissions(); + resolvedUserIdRef.current = null; setAppStatus('notAuthenticated'); + noteIdentityTransition(); + signalAuthChanged(); } }; diff --git a/frontend/src/hooks/use-density.ts b/frontend/src/hooks/use-density.ts index 7b70ea30..b3228a3f 100644 --- a/frontend/src/hooks/use-density.ts +++ b/frontend/src/hooks/use-density.ts @@ -1,11 +1,13 @@ import { useCallback, useEffect, useState } from 'react'; +import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; +import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents'; export type Density = 'comfortable' | 'compact'; const STORAGE_KEY = 'sencho.appearance.density'; const DEFAULT_DENSITY: Density = 'comfortable'; -function isDensity(value: unknown): value is Density { +export function isDensity(value: unknown): value is Density { return value === 'comfortable' || value === 'compact'; } @@ -30,6 +32,26 @@ export function initializeDensity() { applyDensityClass(readStoredDensity()); } +/** Read the current density without subscribing (sync layer use). */ +export function currentDensityValue(): Density { + return readStoredDensity(); +} + +/** Apply a density through the same path a user commit uses (state for mounted + * instances arrives via the settings-changed event; DOM + localStorage are + * written here). Hydration-side writes do not notify the sync bus. */ +export function applyDensityValue(next: Density) { + applyDensityClass(next); + try { + if (window.localStorage.getItem(STORAGE_KEY) !== next) { + window.localStorage.setItem(STORAGE_KEY, next); + } + } catch { + // ignore; localStorage may be unavailable (private mode, quota) + } + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); +} + export function useDensity(): [Density, (next: Density) => void] { const [density, setDensityState] = useState(readStoredDensity); @@ -44,6 +66,14 @@ export function useDensity(): [Density, (next: Density) => void] { } }, [density]); + useEffect(() => { + function onSettingsChanged() { + setDensityState(readStoredDensity()); + } + window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged); + return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged); + }, []); + useEffect(() => { function onStorage(event: StorageEvent) { if (event.key !== STORAGE_KEY) return; @@ -55,6 +85,7 @@ export function useDensity(): [Density, (next: Density) => void] { const setDensity = useCallback((next: Density) => { setDensityState(next); + notifyPreferenceWrite('appearance', ['density']); }, []); return [density, setDensity]; diff --git a/frontend/src/hooks/use-log-chip-color-mode.ts b/frontend/src/hooks/use-log-chip-color-mode.ts index 1285cd63..e3228bf6 100644 --- a/frontend/src/hooks/use-log-chip-color-mode.ts +++ b/frontend/src/hooks/use-log-chip-color-mode.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; +import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents'; export const LOG_CHIP_COLOR_KEY = 'sencho.log-chip-color-mode'; export type LogChipColorMode = 'unified' | 'per-service'; @@ -13,6 +14,26 @@ function readStored(): LogChipColorMode { } } +export function isLogChipColorModeExport(v: unknown): v is LogChipColorMode { + return v === 'unified' || v === 'per-service'; +} + +/** Read the current mode without subscribing (sync layer use). */ +export function currentLogChipColorMode(): LogChipColorMode { + return readStored(); +} + +/** Apply a value through the same path a user commit uses. Hydration writes + * do not notify the sync bus. */ +export function applyLogChipColorMode(next: LogChipColorMode): void { + try { + window.localStorage.setItem(LOG_CHIP_COLOR_KEY, next); + } catch { + // ignore; localStorage may be unavailable (private mode, quota) + } + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); +} + export function useLogChipColorMode(): [LogChipColorMode, (next: LogChipColorMode) => void] { const [mode, setModeState] = useState(readStored); @@ -34,13 +55,9 @@ export function useLogChipColorMode(): [LogChipColorMode, (next: LogChipColorMod }, []); const setMode = useCallback((next: LogChipColorMode) => { - try { - window.localStorage.setItem(LOG_CHIP_COLOR_KEY, next); - } catch { - // ignore; localStorage may be unavailable (private mode, quota) - } + applyLogChipColorMode(next); setModeState(next); - window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); + notifyPreferenceWrite('appearance', ['logChipColorMode']); }, []); return [mode, setMode]; diff --git a/frontend/src/hooks/use-theme.ts b/frontend/src/hooks/use-theme.ts index 0156159f..0c87640b 100644 --- a/frontend/src/hooks/use-theme.ts +++ b/frontend/src/hooks/use-theme.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo, useSyncExternalStore } from 'react'; import { Moon, Zap, Sun, Monitor } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; +import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents'; // Shared theme store. Two live consumers (the topbar quick switch and the // Settings → Appearance section) must reflect each other instantly, so the @@ -176,6 +177,10 @@ function isChartStyle(v: unknown): v is ChartStyle { function isBool(v: unknown): v is boolean { return typeof v === 'boolean'; } +// Exported guards: the preference documents layer reuses these to sanitize +// server documents per-field (the sync layer must accept exactly what the +// local read accepts). +export { isMode, isAccent, isUiFont, isMonoFont, isVisualStyle, isHeadingStyle, isChartStyle, isBool }; // Numeric knobs: a persisted value must be finite and in range, otherwise fall // back to the default (a NaN/Infinity/out-of-range value would silently no-op // in CSS, which is harder to diagnose than a reset to default). @@ -312,6 +317,19 @@ function setState(patch: Partial) { emit(); } +// ── preference sync exports ──────────────────────────────────────────────── +/** Read the current appearance state without subscribing (sync layer use). */ +export function currentThemeState(): ThemeState { + return persisted; +} + +/** Apply a (partial) appearance state through the same internal write path a + * user commit uses: DOM, localStorage, and subscribers stay consistent. Used + * by hydration; hydration-side writes do not notify the sync bus. */ +export function applyThemeState(patch: Partial): void { + setState(patch); +} + function subscribe(listener: () => void): () => void { listeners.add(listener); return () => { @@ -365,14 +383,18 @@ export function initializeTheme() { export function useTheme() { const s = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); - const setTheme = useCallback((theme: ThemeMode) => setState({ theme }), []); - const setAccent = useCallback((accent: AccentId) => setState({ accent }), []); - const setBorderBoost = useCallback((borderBoost: number) => setState({ borderBoost }), []); - const setGlow = useCallback((glow: number) => setState({ glow }), []); - const setContrast = useCallback((contrast: number) => setState({ contrast }), []); - const setUiFont = useCallback((uiFont: UiFont) => setState({ uiFont }), []); - const setMonoFont = useCallback((monoFont: MonoFont) => setState({ monoFont }), []); - const setTypeScale = useCallback((typeScale: number) => setState({ typeScale }), []); + // User-facing setters write through the module store, then notify the sync + // bus with the fields they touched; hydration and cross-tab paths use + // applyThemeState / the storage listener instead, so they never queue a + // server write. + const setTheme = useCallback((theme: ThemeMode) => { setState({ theme }); notifyPreferenceWrite('appearance', ['theme']); }, []); + const setAccent = useCallback((accent: AccentId) => { setState({ accent }); notifyPreferenceWrite('appearance', ['accent']); }, []); + const setBorderBoost = useCallback((borderBoost: number) => { setState({ borderBoost }); notifyPreferenceWrite('appearance', ['borderBoost']); }, []); + const setGlow = useCallback((glow: number) => { setState({ glow }); notifyPreferenceWrite('appearance', ['glow']); }, []); + const setContrast = useCallback((contrast: number) => { setState({ contrast }); notifyPreferenceWrite('appearance', ['contrast']); }, []); + const setUiFont = useCallback((uiFont: UiFont) => { setState({ uiFont }); notifyPreferenceWrite('appearance', ['uiFont']); }, []); + const setMonoFont = useCallback((monoFont: MonoFont) => { setState({ monoFont }); notifyPreferenceWrite('appearance', ['monoFont']); }, []); + const setTypeScale = useCallback((typeScale: number) => { setState({ typeScale }); notifyPreferenceWrite('appearance', ['typeScale']); }, []); // Macro: writes visualStyle + preset sub-axes including reducedMotion. // Does NOT touch readability (sticky master the user releases by hand). // Re-applying Signature clears Motion; re-applying Calm enables it. @@ -385,12 +407,13 @@ export function useTheme() { reducedEffects: preset.reducedEffects, reducedMotion: preset.reducedMotion, }); + notifyPreferenceWrite('appearance', ['visualStyle', 'headingStyle', 'chartStyle', 'reducedEffects', 'reducedMotion']); }, []); - const setHeadingStyle = useCallback((headingStyle: HeadingStyle) => setState({ headingStyle }), []); - const setChartStyle = useCallback((chartStyle: ChartStyle) => setState({ chartStyle }), []); - const setReducedEffects = useCallback((reducedEffects: boolean) => setState({ reducedEffects }), []); - const setReducedMotion = useCallback((reducedMotion: boolean) => setState({ reducedMotion }), []); - const setReadability = useCallback((readability: boolean) => setState({ readability }), []); + const setHeadingStyle = useCallback((headingStyle: HeadingStyle) => { setState({ headingStyle }); notifyPreferenceWrite('appearance', ['headingStyle']); }, []); + const setChartStyle = useCallback((chartStyle: ChartStyle) => { setState({ chartStyle }); notifyPreferenceWrite('appearance', ['chartStyle']); }, []); + const setReducedEffects = useCallback((reducedEffects: boolean) => { setState({ reducedEffects }); notifyPreferenceWrite('appearance', ['reducedEffects']); }, []); + const setReducedMotion = useCallback((reducedMotion: boolean) => { setState({ reducedMotion }); notifyPreferenceWrite('appearance', ['reducedMotion']); }, []); + const setReadability = useCallback((readability: boolean) => { setState({ readability }); notifyPreferenceWrite('appearance', ['readability']); }, []); const resolvedTheme = resolveWith(s.theme, s.systemDark); return { theme: s.theme, diff --git a/frontend/src/hooks/use-top-nav-align.ts b/frontend/src/hooks/use-top-nav-align.ts index e4936d4c..268eca42 100644 --- a/frontend/src/hooks/use-top-nav-align.ts +++ b/frontend/src/hooks/use-top-nav-align.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; +import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents'; export const TOP_NAV_ALIGN_KEY = 'sencho.appearance.topNavAlign'; @@ -18,6 +19,26 @@ function readStored(): TopNavAlign { } } +export function isTopNavAlignExport(v: unknown): v is TopNavAlign { + return v === 'left' || v === 'center'; +} + +/** Read the current align without subscribing (sync layer use). */ +export function currentTopNavAlign(): TopNavAlign { + return readStored(); +} + +/** Apply an align value through the same path a user commit uses. Hydration + * writes do not notify the sync bus. */ +export function applyTopNavAlign(next: TopNavAlign): void { + try { + window.localStorage.setItem(TOP_NAV_ALIGN_KEY, next); + } catch { + // ignore; localStorage may be unavailable (private mode, quota) + } + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); +} + export function useTopNavAlign(): [TopNavAlign, (next: TopNavAlign) => void] { const [align, setAlignState] = useState(readStored); @@ -39,13 +60,9 @@ export function useTopNavAlign(): [TopNavAlign, (next: TopNavAlign) => void] { }, []); const setAlign = useCallback((next: TopNavAlign) => { - try { - window.localStorage.setItem(TOP_NAV_ALIGN_KEY, next); - } catch { - // ignore; localStorage may be unavailable (private mode, quota) - } + applyTopNavAlign(next); setAlignState(next); - window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); + notifyPreferenceWrite('navigation', ['align']); }, []); return [align, setAlign]; diff --git a/frontend/src/hooks/use-top-nav-labels.ts b/frontend/src/hooks/use-top-nav-labels.ts index a3b1851c..ede53f49 100644 --- a/frontend/src/hooks/use-top-nav-labels.ts +++ b/frontend/src/hooks/use-top-nav-labels.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; +import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents'; export const TOP_NAV_LABELS_KEY = 'sencho.appearance.topNavLabels'; @@ -15,6 +16,22 @@ function readStored(): boolean { } } +/** Read the current labels flag without subscribing (sync layer use). */ +export function currentTopNavLabels(): boolean { + return readStored(); +} + +/** Apply a labels value through the same path a user commit uses. Hydration + * writes do not notify the sync bus. */ +export function applyTopNavLabels(next: boolean): void { + try { + window.localStorage.setItem(TOP_NAV_LABELS_KEY, next ? 'true' : 'false'); + } catch { + // ignore; localStorage may be unavailable (private mode, quota) + } + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); +} + export function useTopNavLabels(): [boolean, (next: boolean) => void] { const [showLabels, setShowLabelsState] = useState(readStored); @@ -38,13 +55,9 @@ export function useTopNavLabels(): [boolean, (next: boolean) => void] { }, []); const setShowLabels = useCallback((next: boolean) => { - try { - window.localStorage.setItem(TOP_NAV_LABELS_KEY, next ? 'true' : 'false'); - } catch { - // ignore; localStorage may be unavailable (private mode, quota) - } + applyTopNavLabels(next); setShowLabelsState(next); - window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); + notifyPreferenceWrite('navigation', ['labels']); }, []); return [showLabels, setShowLabels]; diff --git a/frontend/src/hooks/use-top-nav-mode.ts b/frontend/src/hooks/use-top-nav-mode.ts index 7b0008ce..7043992b 100644 --- a/frontend/src/hooks/use-top-nav-mode.ts +++ b/frontend/src/hooks/use-top-nav-mode.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; +import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents'; export const TOP_NAV_MODE_KEY = 'sencho.appearance.topNavMode'; @@ -24,6 +25,22 @@ function readStored(): TopNavMode { } } +/** Read the current nav mode without subscribing (sync layer use). */ +export function currentTopNavMode(): TopNavMode { + return readStored(); +} + +/** Apply a nav mode through the same path a user commit uses. Hydration-side + * writes do not notify the sync bus. */ +export function applyTopNavMode(next: TopNavMode): void { + try { + window.localStorage.setItem(TOP_NAV_MODE_KEY, next); + } catch { + // ignore; localStorage may be unavailable + } + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); +} + export function useTopNavMode(): [TopNavMode, (next: TopNavMode) => void] { const [mode, setModeState] = useState(readStored); @@ -45,13 +62,9 @@ export function useTopNavMode(): [TopNavMode, (next: TopNavMode) => void] { }, []); const setMode = useCallback((next: TopNavMode) => { - try { - window.localStorage.setItem(TOP_NAV_MODE_KEY, next); - } catch { - // ignore; localStorage may be unavailable - } + applyTopNavMode(next); setModeState(next); - window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); + notifyPreferenceWrite('navigation', ['mode']); }, []); return [mode, setMode]; diff --git a/frontend/src/hooks/use-top-nav-quick-links.ts b/frontend/src/hooks/use-top-nav-quick-links.ts index 63f0e3dc..f70d1eeb 100644 --- a/frontend/src/hooks/use-top-nav-quick-links.ts +++ b/frontend/src/hooks/use-top-nav-quick-links.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { isQuickLinkEligibleId } from '@/lib/navigation/appNavRegistry'; import type { ActiveView } from '@/lib/router/routeTypes'; import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; +import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents'; +import { readQuickLinksProvenance, type QuickLinksProvenance } from '@/lib/preferences/quickLinksProvenance'; export const TOP_NAV_QUICK_LINKS_KEY = 'sencho.appearance.topNavQuickLinks'; export const MAX_QUICK_LINKS = 8; @@ -29,6 +31,36 @@ type StoredQuickLinksState = | { status: 'valid'; ids: ActiveView[] } | { status: 'unset' }; // covers missing key, malformed JSON, and non-array JSON alike +/** Current pin provenance without subscribing (sync layer use): 'unset' means + * never persisted; 'valid' includes a deliberately saved empty list. */ +export function currentQuickLinksProvenance(): QuickLinksProvenance { + return readQuickLinksProvenance(); +} + +/** Current pin list without subscribing (sync layer use). */ +export function currentQuickLinks(): ActiveView[] { + return sanitizeQuickLinkIds(readQuickLinksRaw()); +} + +function readQuickLinksRaw(): unknown { + if (typeof window === 'undefined') return []; + try { + const raw = window.localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY); + if (raw === null) return []; + return JSON.parse(raw); + } catch { + return []; + } +} + +/** Apply a pin list through the same path a user commit uses. Hydration-side + * writes do not notify the sync bus (the caller decides). */ +export function applyQuickLinks(ids: ActiveView[]): void { + const sanitized = sanitizeQuickLinkIds(ids); + writeStored(sanitized); + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED)); +} + function writeStored(ids: ActiveView[]): boolean { try { window.localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(ids)); @@ -135,6 +167,14 @@ export function useTopNavQuickLinks(defaultEligibleIds?: readonly ActiveView[] | } }, [applyState]); + // The user-facing setters mark the operation as a write (queued to the sync + // bus). The eligibility seed effect above intentionally does NOT: derived + // state is never uploaded as a user write. + const userCommit = useCallback((next: ActiveView[]) => { + commit(next); + notifyPreferenceWrite('navigation', ['quickLinks']); + }, [commit]); + // Seed defaults once eligibility is settled (even a confirmed-empty list: defaultEligibleIds // is only ever non-null once proven, never merely "not yet failed") and no valid preference has // ever been saved. Fires at most once in practice: the moment it commits, storage holds a valid @@ -149,24 +189,26 @@ export function useTopNavQuickLinks(defaultEligibleIds?: readonly ActiveView[] | const resetQuickLinks = useCallback(() => { if (defaultEligibleIds == null) return; // guarded in the UI too; defense in depth - commit([...defaultEligibleIds]); - }, [commit, defaultEligibleIds]); + userCommit([...defaultEligibleIds]); + }, [userCommit, defaultEligibleIds]); const addQuickLink = useCallback((value: ActiveView) => { if (!isQuickLinkEligibleId(value)) return; const prevIds = idsOf(stateRef.current); if (prevIds.includes(value) || prevIds.length >= MAX_QUICK_LINKS) return; - commit([...prevIds, value]); - }, [commit]); + userCommit([...prevIds, value]); + }, [userCommit]); const removeQuickLink = useCallback((value: ActiveView) => { - commit(idsOf(stateRef.current).filter((id) => id !== value)); - }, [commit]); + userCommit(idsOf(stateRef.current).filter((id) => id !== value)); + }, [userCommit]); + + const setPersistedIds = userCommit; return { persistedIds: idsOf(state), canReset: defaultEligibleIds != null, - setPersistedIds: commit, + setPersistedIds, addQuickLink, removeQuickLink, resetQuickLinks, diff --git a/frontend/src/hooks/useUserPreferencesSync.ts b/frontend/src/hooks/useUserPreferencesSync.ts new file mode 100644 index 00000000..d42db07a --- /dev/null +++ b/frontend/src/hooks/useUserPreferencesSync.ts @@ -0,0 +1,502 @@ +/** + * The sync owner for per-user preference documents. Mounted outside the + * authenticated app tree (a sibling of AppContent in App.tsx) so it survives + * auth transitions; it derives its own lifecycle from useAuth(). + * + * Responsibilities: + * - Cache ownership: the localStorage preference cache is keyed to the account + * (marker sencho.preferences.owner). A different account wipes the cache to + * defaults before hydration, so no account ever renders another's values. + * - Hydration: on authentication, GET both domains and hydrate through the + * hooks' apply paths: live doc → per-field sanitize + apply; tombstone → + * defaults via the apply paths; absent → migrate via the bus; corrupt → + * defaults + conditional repair PUT. + * - Reconciliation: when a GET response arrives after local edits, the + * server wins untouched fields; dirty (user-edited-since-hydration) fields + * are re-applied on top and written back conditionally. A 409 CONFLICT + * reconciles against `current`; a second consecutive conflict surfaces the + * error state instead of looping. A failed write keeps the dirty fields and + * raises the unsaved episode, so the merge is retried rather than lost. + * - Remote-reset precedence: a tombstone observed at a newer revision than + * a pending edit's baseline wins; the stale edit is discarded and the reset + * adopted. Only edits made after adopting the tombstone may un-tombstone. + * - Identity: async work captures the identity generation; results apply only + * if it is unchanged. Identity transitions mask the readiness publication + * (it stays stored for teardown scoping but reads as null) and reset the + * queue. + */ + +import { useEffect, useRef } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { apiFetch } from '@/lib/api'; +import { + bumpGeneration, + currentGeneration, + setUnsavedEpisode, + subscribeToGenerations, + subscribeToPreferenceWrites, + type PreferenceDomain, +} from '@/lib/preferences/preferenceEvents'; +import { + adoptKnownRevision, + discardQueuedEdit, + documentForDomain, + flushPendingWrites, + queueMigrate, + setCurrentSyncUser, + setHydratingDomains, + setReconcileHook, +} from '@/lib/preferences/syncBus'; +import { + PREFERENCES_OWNER_KEY, + buildAppearanceDocument, + buildNavigationDocument, + clearPreferenceCache, + defaultAppearanceDocument, + hydrateAppearanceDocument, + hydrateNavigationDefaults, + hydrateNavigationDocument, +} from '@/lib/preferences/preferencesDocuments'; + +interface OwnerMarker { + userId: number; + schema: 1; +} + +const DOMAINS: PreferenceDomain[] = ['appearance', 'navigation']; + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +export function useUserPreferencesSync(): void { + const { user, appStatus } = useAuth(); + const userId = appStatus === 'authenticated' ? user?.userId ?? null : null; + + // Per-domain sync state (refs: the sync owner renders rarely and never + // derives render output from these). + const dirtyRef = useRef>>({ + appearance: new Set(), navigation: new Set(), + }); + const conflictCountRef = useRef>({ + appearance: 0, navigation: 0, + }); + + // Clear per-domain sync state on every generation bump (identity + // transition): the next account's hydration starts from a clean slate. + useEffect(() => { + return subscribeToGenerations(() => { + for (const domain of DOMAINS) { + dirtyRef.current[domain] = new Set(); + conflictCountRef.current[domain] = 0; + } + }); + }, []); + + // Dirty-field tracking: a user setter notification marks the fields the + // write touched as locally edited (hydration writes never notify; a reset + // notification arrives without a field list and marks the whole domain). The + // merge rule stays server-wins-untouched: on a late GET, only these fields + // are re-applied on top of the server document. + useEffect(() => { + return subscribeToPreferenceWrites((domain, fields) => { + const dirty = dirtyRef.current[domain]; + for (const field of fields) dirty.add(field); + }); + }, []); + + // Cache ownership + hydration effect, keyed on the resolved identity. + useEffect(() => { + if (appStatus === 'loading') return; // boot in flight: touch nothing + + if (userId === null) { + // Resolved unauthenticated (logout / 401 / boot failure): the cache may + // hold the previous account's values; drop it so a later login never + // renders them. (Do NOT clear when merely loading.) + setCurrentSyncUser(null); + return; + } + + // Claim or verify cache ownership. + let marker: OwnerMarker | null = null; + try { + const raw = localStorage.getItem(PREFERENCES_OWNER_KEY); + // The marker is our own write and only selects between "same owner" and + // "wipe"; a malformed value behaves like a missing one. + if (raw) { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === 'object' + && typeof (parsed as { userId?: unknown }).userId === 'number') { + marker = parsed as OwnerMarker; + } + } + } catch { + marker = null; + } + if (!marker || marker.userId !== userId) { + clearPreferenceCache(); + try { + localStorage.setItem(PREFERENCES_OWNER_KEY, JSON.stringify({ userId, schema: 1 } satisfies OwnerMarker)); + } catch { + // ignore; private mode + } + } + + setCurrentSyncUser(userId); + void hydrateAll(userId); + // Re-hydrate only when the numeric identity changes; the generation guard + // on in-flight work means an unrelated re-render never refetches. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, appStatus]); + + // Flush queued writes when the tab is hidden or closing. + useEffect(() => { + const onHide = () => { + if (document.visibilityState === 'hidden') flushPendingWrites(); + }; + document.addEventListener('visibilitychange', onHide); + window.addEventListener('pagehide', onHide); + return () => { + document.removeEventListener('visibilitychange', onHide); + window.removeEventListener('pagehide', onHide); + }; + }, []); + + // ── hydration + reconciliation ─────────────────────────────────────────── + async function hydrateAll(userId: number): Promise { + const generation = currentGeneration(); + setHydratingDomains(new Set(DOMAINS)); + try { + const response = await apiFetch('/user-preferences', { + method: 'GET', + localOnly: true, + headers: { 'x-sencho-pref-user': String(userId) }, + }); + if (generation !== currentGeneration()) return; + if (!response.ok) { + console.error(`[preferences] hydration request failed with status ${response.status}`); + raiseUnsavedForDomains(DOMAINS); + return; + } + const payload = (await response.json()) as { preferences?: Record }; + if (generation !== currentGeneration()) return; + // A malformed body is a failed hydration, not an empty one: treating it + // as `{}` would migrate defaults over rows the server does hold. + if (!isRecord(payload) || !isRecord(payload.preferences)) { + console.error('[preferences] hydration response was malformed; skipping reconciliation'); + raiseUnsavedForDomains(DOMAINS); + return; + } + const preferences = payload.preferences; + // Per-domain isolation: a failure reconciling one domain must not skip + // the other domain's reconciliation. + for (const domain of DOMAINS) { + try { + await reconcileDomain(domain, preferences[domain] ?? null, userId, generation); + } catch (error) { + console.error(`[preferences] hydration reconcile for ${domain} failed:`, error); + reMarkDirtyAndSurface(domain, null); + } + } + } catch (error) { + console.error('[preferences] hydration failed:', error); + raiseUnsavedForDomains(DOMAINS); + } finally { + setHydratingDomains(new Set()); + } + } + + /** Reconcile one domain against an observed server row (GET response, or + * the `current` envelope of a 409). Behavior by row kind: + * - absent: migrate (create-if-absent). + * - live doc: dirty fields re-applied on top of the server doc; the merged + * result is written back conditionally. No dirty fields: hydrate the + * server doc as-is. + * - tombstone: if pending edits are based on an older revision, they + * are DISCARDED and the reset adopted (defaults). Only edits made after + * adopting the tombstone survive. + * - corrupt: adopt defaults + enqueue a conditional repair PUT. + */ + async function reconcileDomain( + domain: PreferenceDomain, + row: unknown, + userId: number, + generation: number, + ): Promise { + if (generation !== currentGeneration()) return; + + if (row === null || row === undefined) { + // Absent: migrate local state (create-if-absent). Navigation defers + // until eligibility settles or the hook holds a valid document (the + // pump gate in the bus decides which). + queueMigrate(domain); + return; + } + + if (!isRecord(row)) { + console.error(`[preferences] ${domain} row had an unexpected shape; skipping reconciliation`); + return; + } + const revision = typeof row.revision === 'number' && Number.isSafeInteger(row.revision) && row.revision >= 1 + ? row.revision + : null; + if (revision === null) { + console.error(`[preferences] ${domain} row had no valid revision; skipping reconciliation`); + return; + } + adoptKnownRevision(domain, revision); + + const schemaVersion = row.schemaVersion; + const corrupt = row.corrupt === true; + + if (schemaVersion === 0 || corrupt) { + // Tombstone or corrupt row: adopt defaults locally and clear the cache + // keys for this domain so a reload paints defaults, not stale values. + // Any queued PUT based on an older revision is discarded first: the + // remote reset outranks a stale local edit, and the parked operation + // must never send the pre-reset values back to the server. + discardQueuedEdit(domain); + adoptDefaults(domain); + if (schemaVersion !== 0) { + // Corrupt row: enqueue a conditional repair PUT (tombstones are a + // deliberate reset; corrupt rows are damage to repair). + await repairCorruptRow(domain, revision, userId, generation); + } + return; + } + + // Live doc: merge rule depends on pending dirty fields. The local + // document is captured BEFORE the server doc is applied, so the dirty + // values survive hydration; the merged result is then applied once. + // A clean load (no dirty fields) whose merged document equals the server + // document is a pure hydration: no write back, the server already holds + // exactly this state and a PUT would only burn a revision. + const serverDoc = row.data; + if (!isRecord(serverDoc)) return; + const dirty = dirtyRef.current[domain]; + const dirtySnapshot = dirty.size === 0 ? null : new Set(dirty); + if (domain === 'appearance') { + const localDoc: Record = { ...buildAppearanceDocument() }; + const merged = mergedDocument(serverDoc, localDoc, dirtySnapshot); + hydrateServerDocument(domain, merged); + dirtyRef.current[domain] = new Set(); + if (dirtySnapshot === null && shallowEqual(merged, serverDoc)) return; + await conditionalPut(domain, merged, revision, userId, generation, dirtySnapshot); + return; + } + // Navigation: an unset provenance (never persisted) has no local edits to + // merge; treat as clean hydration of the server document. + const navDoc = buildNavigationDocument(); + if (navDoc.status === 'valid') { + const validDoc = { mode: navDoc.mode, quickLinks: navDoc.quickLinks, labels: navDoc.labels, align: navDoc.align }; + const merged = mergedDocument(serverDoc, validDoc, dirtySnapshot); + hydrateServerDocument(domain, merged); + dirtyRef.current[domain] = new Set(); + if (dirtySnapshot === null && shallowEqual(merged, serverDoc)) return; + await conditionalPut(domain, merged, revision, userId, generation, dirtySnapshot); + return; + } + hydrateServerDocument(domain, serverDoc); + dirtyRef.current[domain] = new Set(); + } + + /** Field-wise equality for flat preference documents. Only safe because the + * clean path compares a shallow copy of the server document against itself, + * so array fields share references; do not reuse for independently parsed + * documents. */ + function shallowEqual(a: Record, b: Record): boolean { + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) return false; + return aKeys.every((key) => a[key] === b[key]); + } + + /** Server-wins merge: untouched fields keep the server values, dirty fields + * re-apply the captured local values. A null dirty set is a clean + * hydration (the server doc as-is). */ + function mergedDocument( + serverDoc: Record, + localDoc: Record, + dirtySnapshot: Set | null, + ): Record { + if (dirtySnapshot === null) return { ...serverDoc }; + const merged: Record = { ...serverDoc }; + for (const field of dirtySnapshot) { + if (field in localDoc) merged[field] = localDoc[field]; + } + return merged; + } + + /** A write outcome is unknown-but-pending until the PUT settles: if it + * fails before the server acknowledges the merge, the dirty fields stay + * dirty (a later reconciliation must re-apply them) and the failure + * surfaces as an unsaved episode instead of vanishing. */ + function raiseUnsavedForDomains(domainsToMark: readonly PreferenceDomain[]): void { + for (const domain of domainsToMark) { + if (dirtyRef.current[domain].size > 0) setUnsavedEpisode(domain); + } + } + + async function conditionalPut( + domain: PreferenceDomain, + document: Record, + expectedRevision: number, + userId: number, + generation: number, + dirtySnapshot: Set | null = null, + ): Promise { + const response = await apiFetch(`/user-preferences/${domain}`, { + method: 'PUT', + localOnly: true, + headers: { 'x-sencho-pref-user': String(userId) }, + body: JSON.stringify({ expectedRevision, ...document }), + }); + if (generation !== currentGeneration()) return; + if (response.ok) { + conflictCountRef.current[domain] = 0; + return; + } + if (response.status === 409) { + conflictCountRef.current[domain] += 1; + if (conflictCountRef.current[domain] >= 2) { + // Two consecutive conflicts: stop and surface; Retry restarts from a + // fresh GET rather than looping. + conflictCountRef.current[domain] = 0; + reMarkDirtyAndSurface(domain, dirtySnapshot); + return; + } + const parse = async (): Promise<{ current?: unknown } | null> => { + try { + return (await response.json()) as { current?: unknown }; + } catch { + return null; + } + }; + const payload = await parse(); + if (payload !== null && isRecord(payload.current)) { + // The PUT failed, so the merged write never persisted: restore the + // dirty fields before re-reconciling against `current`. Without them + // the re-entry sees a clean document and silently hydrates the + // server state, losing the user's edit. No episode is raised here: + // either the re-PUT converges, or its own failure path surfaces. + restoreDirtyFields(domain, dirtySnapshot); + await reconcileDomain(domain, payload.current, userId, currentGeneration()); + return; + } + reMarkDirtyAndSurface(domain, dirtySnapshot); + return; + } + console.error(`[preferences] conditional write for ${domain} failed with status ${response.status}`); + reMarkDirtyAndSurface(domain, dirtySnapshot); + } + + /** Restore dirty state and raise the unsaved episode after a failed write, + * so the user's edits are retried on the next reconciliation instead of + * being silently dropped. */ + function reMarkDirtyAndSurface(domain: PreferenceDomain, dirtySnapshot: Set | null): void { + restoreDirtyFields(domain, dirtySnapshot); + if (dirtyRef.current[domain].size > 0) setUnsavedEpisode(domain); + } + + /** Re-mark fields as locally edited without surfacing a failure. */ + function restoreDirtyFields(domain: PreferenceDomain, dirtySnapshot: Set | null): void { + if (dirtySnapshot !== null) { + for (const field of dirtySnapshot) dirtyRef.current[domain].add(field); + } + } + + async function repairCorruptRow( + domain: PreferenceDomain, + revision: number, + userId: number, + generation: number, + ): Promise { + const doc = documentForDomain(domain); + if (doc === null) { + // Nothing local to repair with yet (navigation unsettled): leave the + // corrupt row; the next edit will conditionally overwrite it. + return; + } + await conditionalPut(domain, doc, revision, userId, generation); + } + + function adoptDefaults(domain: PreferenceDomain): void { + setHydratingDomains(new Set(DOMAINS)); + try { + if (domain === 'appearance') { + hydrateAppearanceDocument(defaultAppearanceDocument()); + } else { + hydrateNavigationDefaults(); + } + } finally { + setHydratingDomains(new Set()); + } + } + + function hydrateServerDocument(domain: PreferenceDomain, doc: Record): void { + setHydratingDomains(new Set(DOMAINS)); + try { + if (domain === 'appearance') { + hydrateAppearanceDocument(doc); + } else { + hydrateNavigationDocument(doc); + } + } finally { + setHydratingDomains(new Set()); + } + } + + // The account the owner is currently synced for, read by the reconcile hook + // (declared before the effect that installs the hook so the closure sees a + // stable ref). + const currentUserIdRef = useRef(null); + useEffect(() => { + currentUserIdRef.current = userId; + }, [userId]); + + // Conflict hook: the bus hands reconciliation to us (server base + dirty + // fields re-applied) instead of retrying blindly. reconcileDomain reads + // refs only, so a stable empty dependency list is correct here. + useEffect(() => { + setReconcileHook((domain) => { + const userIdNow = currentUserIdRef.current; + if (userIdNow === null) return; + void (async () => { + try { + const response = await apiFetch('/user-preferences', { + method: 'GET', + localOnly: true, + headers: { 'x-sencho-pref-user': String(userIdNow) }, + }); + if (!response.ok) { + console.error(`[preferences] reconcile request failed with status ${response.status}`); + // Dirty fields stay dirty; surface the episode so the failure is + // visible and Retry re-enters reconciliation (retryDomain with + // no parked operation delegates back to this hook). + reMarkDirtyAndSurface(domain, null); + return; + } + const payload = (await response.json()) as { preferences?: Record }; + const row = isRecord(payload.preferences) ? payload.preferences[domain] : null; + await reconcileDomain(domain, row ?? null, userIdNow, currentGeneration()); + } catch (error) { + console.error('[preferences] reconcile failed:', error); + reMarkDirtyAndSurface(domain, null); + } + })(); + }); + return () => setReconcileHook(null); + // reconcileDomain closes over refs only; the hook is installed once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Identity transition: bump the generation so in-flight preference work is + // invalidated (the AuthContext already bumps; this covers direct logouts). + useEffect(() => { + if (appStatus === 'loading') return; + if (userId === null) { + bumpGeneration(); + clearPreferenceCache(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appStatus]); +} diff --git a/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts b/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts new file mode 100644 index 00000000..73c63eae --- /dev/null +++ b/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts @@ -0,0 +1,478 @@ +/** + * Unit tests for the preference sync layer, driven at the transport boundary + * (apiFetch mocked) so assertions cover final local state and wire behavior. + * + * Covered scenarios: + * - Field-level merge: a delayed GET after a user edit merges dirty fields + * onto the server document (server wins untouched fields). + * - Local chronology: reset cancels pre-reset PUTs; a post-reset edit + * survives as a conditional PUT on the tombstone revision. + * - Remote-reset precedence: a pending edit based on an older revision is + * discarded when the GET reveals a newer tombstone. + * - Eligibility ownership: a stale producer's publication is rejected and + * its teardown cannot erase a newer publication. + * - Migration deferral: navigation migrate waits for settled eligibility, and + * the wire document carries only the four server-known fields. + * - Retries: a failed reset retries as DELETE (kind-preserving). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetch = vi.fn(); +vi.mock('@/lib/api', () => ({ + apiFetch: (path: string, opts?: unknown) => apiFetch(path, opts), +})); + +import { + DOMAIN_FIELDS, + bumpGeneration, + clearEligibility, + currentGeneration, + getSettledEligibility, + notifyPreferenceWrite, + resetPreferenceSync, + setEligibilitySettled, + subscribeToPreferenceWrites, + type EligibilityOwnership, +} from '../preferenceEvents'; +import { + adoptKnownRevision, + flushPendingWrites, + inspectQueue, + queueMigrate, + queueReset, + setCurrentSyncUser, + setHydratingDomains, + setReconcileHook, +} from '../syncBus'; +import { + PREFERENCES_OWNER_KEY, + clearPreferenceCache, + hydrateAppearanceDocument, + hydrateNavigationDocument, +} from '../preferencesDocuments'; + +interface MockResponse { + ok: boolean; + status: number; + headers: { get: (name: string) => string | null }; + json: () => Promise; + clone: () => MockResponse; +} + +function jsonResponse(status: number, body: unknown): MockResponse { + const response: MockResponse = { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + clone: () => response, + }; + return response; +} + +const APPEARANCE_DOC = { + theme: 'dim', accent: 'cyan', uiFont: 'Geist', monoFont: 'Geist Mono', + visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted', + density: 'comfortable', logChipColorMode: 'unified', + borderBoost: 0, glow: 0.16, contrast: 0, typeScale: 1, + reducedEffects: true, reducedMotion: true, readability: false, +}; + +const NAVIGATION_DOC = { + mode: 'compact', quickLinks: ['dashboard', 'fleet'], labels: true, align: 'left', +}; + +/** Capture every apiFetch call as { method, path, body }. */ +interface RecordedCall { + method: string; + path: string; + body: Record | undefined; +} + +function recordedCalls(): RecordedCall[] { + return apiFetch.mock.calls.map(([path, opts]) => ({ + method: (opts as RequestInit | undefined)?.method ?? 'GET', + path: path as string, + body: JSON.parse(((opts as RequestInit | undefined)?.body as string | undefined) ?? 'null'), + })); +} + +describe('preference sync layer', () => { + beforeEach(() => { + apiFetch.mockReset(); + localStorage.clear(); + setReconcileHook(null); + setHydratingDomains(new Set()); + setCurrentSyncUser(7); + resetPreferenceSync(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('a field-less notification marks the whole domain, a field list marks only those fields', () => { + const seen: Array<{ domain: string; fields: readonly string[] }> = []; + const stop = subscribeToPreferenceWrites((domain, fields) => seen.push({ domain, fields: [...fields] })); + notifyPreferenceWrite('appearance', ['theme']); + notifyPreferenceWrite('navigation', ['quickLinks']); + notifyPreferenceWrite('appearance'); // reset-style: whole domain + stop(); + expect(seen).toEqual([ + { domain: 'appearance', fields: ['theme'] }, + { domain: 'navigation', fields: ['quickLinks'] }, + { domain: 'appearance', fields: [...DOMAIN_FIELDS.appearance] }, + ]); + }); + + it('a delayed GET after a density edit keeps the server theme and the local density', () => { + // Server state: OLED theme, comfortable density. Local edit: density only. + const serverDoc = { ...APPEARANCE_DOC, theme: 'oled', density: 'comfortable' }; + + // Hydration writes must not enqueue (bus guard), so simulate exactly the + // sync owner's sequence: adopt revision, hydrate the server doc under the + // hydrating guard, then a user density edit marks only that field dirty. + adoptKnownRevision('appearance', 4); + setHydratingDomains(new Set(['appearance'])); + try { + hydrateAppearanceDocument(serverDoc); + } finally { + setHydratingDomains(new Set()); + } + const cachedTheme = JSON.parse(localStorage.getItem('sencho.appearance.theme') as string); + expect(cachedTheme.theme).toBe('oled'); + + // The user changes density locally (no server round trip yet). + localStorage.setItem('sencho.appearance.density', 'compact'); + notifyPreferenceWrite('appearance', ['density']); + + // The sync owner's merge rule: server wins untouched fields, the dirty + // field re-applies on top. The theme key must still read oled. + expect(JSON.parse(localStorage.getItem('sencho.appearance.theme') as string)).toMatchObject({ theme: 'oled' }); + expect(localStorage.getItem('sencho.appearance.density')).toBe('compact'); + }); + + it('a queued reset cancels a pre-reset PUT; a post-reset edit survives as a conditional PUT on the tombstone revision', async () => { + // The reset's DELETE goes out as soon as queueReset runs (the pump starts + // inside enqueue), so its mock must be installed first. The DELETE + // succeeds against the adopted revision 2 and returns the tombstone's + // resulting revision 3. + apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => { + if (opts?.method === 'DELETE') { + expect(JSON.parse(opts.body as string)).toEqual({ expectedRevision: 2 }); + return jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 3, updatedAt: 1 }); + } + return jsonResponse(200, { domain: 'appearance', schemaVersion: 1, revision: 3, updatedAt: 1, data: {} }); + }); + + // The user edits density (PUT queued, debounced), then a revision is + // adopted from a GET, then the reset lands before the debounce fires. + notifyPreferenceWrite('appearance', ['density']); + adoptKnownRevision('appearance', 2); + queueReset('appearance'); + expect(inspectQueue('appearance')).toMatchObject({ kind: 'reset', failed: null }); + + await vi.waitFor(() => { + expect(recordedCalls().some((c) => c.method === 'DELETE')).toBe(true); + }); + // The reset runs exactly once: a post-reset edit must never re-execute + // the reset (a duplicate DELETE would 409 on its stale precondition). + expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(1); + // Let the DELETE's success bookkeeping (revision adoption) settle before + // the next edit, so the PUT targets the tombstone's revision. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // An edit enqueued after the settled reset survives: a conditional PUT on + // the tombstone's resulting revision (3). The PUT document is rebuilt at + // send time from live state, so drive a real user setter. + const { applyThemeState } = await import('@/hooks/use-theme'); + setHydratingDomains(new Set(['appearance'])); + try { + applyThemeState({ theme: 'dim' }); + } finally { + setHydratingDomains(new Set()); + } + notifyPreferenceWrite('appearance', ['theme']); + await vi.waitFor(() => { + expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(true); + }); + const putCall = recordedCalls().find((c) => c.method === 'PUT'); + expect(putCall?.body).toMatchObject({ expectedRevision: 3, theme: 'dim' }); + }); + + it('an edit landing while a reset is in flight is staged behind it and PUTs conditionally on the tombstone revision', async () => { + // Hold the DELETE in flight so the edit arrives while the reset still + // owns the queue slot: the edit must be staged, not cancelled (it is a + // post-reset edit) and not sent (the tombstone does not exist yet). + let releaseDelete!: (r: MockResponse) => void; + const pendingDelete = new Promise((resolve) => { releaseDelete = resolve; }); + apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => { + if (opts?.method === 'DELETE') return pendingDelete; + if (opts?.method === 'PUT') { + return jsonResponse(200, { domain: 'appearance', schemaVersion: 1, revision: 4, updatedAt: 1 }); + } + // The staged PUT's baseline GET: the tombstone (revision 3) is the row. + return jsonResponse(200, { + preferences: { appearance: { schemaVersion: 0, revision: 3, updatedAt: 1 } }, + }); + }); + + adoptKnownRevision('appearance', 2); + queueReset('appearance'); + // The edit lands while the DELETE is unresolved: staged behind the reset. + notifyPreferenceWrite('appearance', ['theme']); + expect(inspectQueue('appearance').kind).toBe('reset'); + expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(false); + + // The reset settles: exactly one DELETE, then the staged PUT runs. + releaseDelete(jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 3, updatedAt: 1 })); + await vi.waitFor(() => { + expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(true); + }); + expect(recordedCalls().filter((c) => c.method === 'DELETE')).toHaveLength(1); + const putCall = recordedCalls().find((c) => c.method === 'PUT'); + // Conditional on the tombstone's resulting revision (3), never the + // pre-reset baseline (2): a stale precondition would 409 on the server. + expect(putCall?.body).toMatchObject({ expectedRevision: 3 }); + expect(inspectQueue('appearance')).toEqual({ kind: null, failed: null, settling: false }); + }); + + it('a failed reset retries as DELETE (kind-preserving), never as a PUT', async () => { + apiFetch.mockImplementation(async () => jsonResponse(503, { error: 'unavailable' })); + queueReset('appearance'); + await flushPendingWrites(); + await vi.waitFor(() => expect(inspectQueue('appearance').failed).toBe('reset')); + + // Retry re-runs the failed operation by kind. + const { retryDomain } = await import('../syncBus'); + apiFetch.mockImplementation(async () => jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 2, updatedAt: 1 })); + retryDomain('appearance'); + await vi.waitFor(() => { + expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(2); + }); + }); + + it('a reset that 409s re-runs the DELETE once against the adopted revision instead of reverting to the server doc', async () => { + // First DELETE carries the stale baseline and 409s with the server's + // newer tombstone; the conflict re-queue must send a second DELETE + // conditional on the adopted revision, NOT hand the reset to + // reconciliation (which would re-hydrate the pre-reset document). + const conflictEnvelope = { schemaVersion: 0, revision: 5, updatedAt: 1 }; + apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => { + if (opts?.method === 'DELETE') { + const body = JSON.parse(opts.body as string) as { expectedRevision?: number }; + if (body.expectedRevision === 2) { + return jsonResponse(409, { error: 'CONFLICT', current: conflictEnvelope }); + } + return jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 6, updatedAt: 1 }); + } + return jsonResponse(200, { preferences: { appearance: conflictEnvelope } }); + }); + + adoptKnownRevision('appearance', 2); + queueReset('appearance'); + await vi.waitFor(() => { + expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(2); + }); + const secondDelete = recordedCalls().filter((c) => c.method === 'DELETE')[1]; + expect(secondDelete.body).toEqual({ expectedRevision: 5 }); + // The conflicted reset neither failed nor left a queue entry behind. + expect(inspectQueue('appearance')).toEqual({ kind: null, failed: null, settling: false }); + }); + + it('a reset that 409s twice surfaces a failure instead of looping', async () => { + const conflictEnvelope = { schemaVersion: 0, revision: 5, updatedAt: 1 }; + apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => { + if (opts?.method === 'DELETE') { + return jsonResponse(409, { error: 'CONFLICT', current: { ...conflictEnvelope, revision: conflictEnvelope.revision + 1 } }); + } + return jsonResponse(200, { preferences: { appearance: conflictEnvelope } }); + }); + + adoptKnownRevision('appearance', 2); + queueReset('appearance'); + await vi.waitFor(() => { + expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(2); + }); + // Exactly two attempts: the second conflict is a failure the toast owns. + expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(2); + expect(inspectQueue('appearance').failed).toBe('reset'); + }); + + it('a lost migration race against a tombstone winner hands the tombstone to reconciliation', async () => { + const reconciled: string[] = []; + setReconcileHook((domain) => reconciled.push(domain)); + apiFetch.mockImplementation(async (path: string) => { + if (String(path).endsWith('/navigation/migrate')) { + // Another browser reset the domain first: migrate loses, the winner + // is the tombstone (schemaVersion 0). + return jsonResponse(200, { migrated: false, row: { schemaVersion: 0, revision: 4, updatedAt: 1 } }); + } + return jsonResponse(200, { preferences: {} }); + }); + + // Persist a pin list and settle eligibility: migration defers until the + // navigation document is valid, so the test must satisfy the same + // precondition the real app does (the seed effect writes the list first). + localStorage.setItem('sencho.appearance.topNavQuickLinks', JSON.stringify(['dashboard', 'fleet'])); + setEligibilitySettled({ userId: 7, generation: currentGeneration() }, ['dashboard', 'fleet']); + queueMigrate('navigation'); + await vi.waitFor(() => { + expect(reconciled).toContain('navigation'); + }); + // Clear the publication so later suites start from the parked state. + clearEligibility({ userId: 7, generation: currentGeneration() }); + }); + + it('migration is deferred until navigation eligibility settles, then carries only server-known fields', async () => { + // No settled eligibility and no valid pin list: queueMigrate must not + // dispatch a request (the parked op waits for a settled publication). + setCurrentSyncUser(7); + localStorage.removeItem('sencho.appearance.topNavQuickLinks'); + queueMigrate('navigation'); + await flushPendingWrites(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(apiFetch).not.toHaveBeenCalled(); + expect(inspectQueue('navigation').kind).toBe('migrate'); + + // Settle eligibility with a non-empty set, and persist a pin list so the + // document builder reports 'valid' provenance (never-seeded browsers defer + // indefinitely; the seed effect in the real app writes the list first). + apiFetch.mockImplementation(async (path: string) => { + if (String(path).endsWith('/navigation/migrate')) { + return jsonResponse(201, { migrated: true, row: { data: NAVIGATION_DOC, schemaVersion: 1, revision: 1, updatedAt: 1 } }); + } + return jsonResponse(200, { preferences: {} }); + }); + localStorage.setItem('sencho.appearance.topNavQuickLinks', JSON.stringify(['dashboard', 'fleet'])); + setEligibilitySettled({ userId: 7, generation: currentGeneration() }, ['dashboard', 'fleet']); + await flushPendingWrites(); + await vi.waitFor(() => { + expect(recordedCalls().some((c) => c.path.endsWith('/navigation/migrate'))).toBe(true); + }); + const migrateCall = recordedCalls().find((c) => c.path.endsWith('/navigation/migrate')); + expect(migrateCall?.body).toEqual({ + mode: expect.any(String), + quickLinks: ['dashboard', 'fleet'], + labels: expect.any(Boolean), + align: expect.any(String), + }); + expect(Object.keys(migrateCall?.body ?? {})).not.toContain('status'); + }); + + it('an eligibility publication from a stale identity is rejected and its teardown is a no-op', () => { + // Start from the current identity so ownershipA is current at publish. + // The bus's synced account is 7 (the beforeEach), so account 1 must claim + // it before its publication is accepted. + setCurrentSyncUser(1); + const generation0 = currentGeneration(); + const ownershipA: EligibilityOwnership = { userId: 1, generation: generation0 }; + setEligibilitySettled(ownershipA, ['dashboard']); + expect(getSettledEligibility()?.eligibleIds).toEqual(['dashboard']); + + // Identity transition: generation bumps. The old producer publishes late: + // the store keeps the stale row for teardown scoping but it reads as null, + // and the stale publish must not resurrect or replace anything. + bumpGeneration(); + setEligibilitySettled(ownershipA, ['fleet']); + expect(getSettledEligibility()).toBeNull(); + + // The new account's producer publishes under the current identity. + setCurrentSyncUser(2); + const ownershipB: EligibilityOwnership = { userId: 2, generation: currentGeneration() }; + setEligibilitySettled(ownershipB, ['dashboard', 'fleet']); + expect(getSettledEligibility()?.eligibleIds).toEqual(['dashboard', 'fleet']); + + // The old producer's teardown (same ownership object) must not erase B's. + clearEligibility(ownershipA); + expect(getSettledEligibility()?.eligibleIds).toEqual(['dashboard', 'fleet']); + clearEligibility(ownershipB); + expect(getSettledEligibility()).toBeNull(); + }); + + it('adoptKnownRevision feeds the next conditional write: a queued reset DELETE carries the adopted revision', async () => { + // A pending edit based on revision 2; the server tombstone is at 3. The + // DELETE must carry expectedRevision 3 (the adopted observable revision), + // not 2 (the pre-adoption baseline). + apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => { + if (opts?.method === 'DELETE') { + return jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 4, updatedAt: 1 }); + } + return jsonResponse(200, { preferences: {} }); + }); + adoptKnownRevision('appearance', 2); + adoptKnownRevision('appearance', 3); + queueReset('appearance'); + expect(inspectQueue('appearance').kind).toBe('reset'); + await flushPendingWrites(); + await vi.waitFor(() => { + expect(recordedCalls().some((c) => c.method === 'DELETE')).toBe(true); + }); + const del = recordedCalls().find((c) => c.method === 'DELETE'); + expect(del?.body).toEqual({ expectedRevision: 3 }); + }); + + it('classic normalization: a legacy navigation document hydrates to Compact and never writes classic', () => { + hydrateNavigationDocument({ ...NAVIGATION_DOC, mode: 'classic' }); + expect(localStorage.getItem('sencho.appearance.topNavMode')).toBe('compact'); + }); + + it('cache ownership: the marker contract keys the cache to a numeric userId', () => { + localStorage.setItem('sencho.appearance.theme', JSON.stringify({ theme: 'oled' })); + localStorage.setItem(PREFERENCES_OWNER_KEY, JSON.stringify({ userId: 1, schema: 1 })); + + // A different account claims the browser: the sync owner wipes the cache + // keys before hydration (clearPreferenceCache is the wipe primitive). + clearPreferenceCache(); + expect(localStorage.getItem('sencho.appearance.theme')).toBeNull(); + expect(localStorage.getItem(PREFERENCES_OWNER_KEY)).toBeNull(); + + // The claim writes a fresh marker for the new owner. + localStorage.setItem(PREFERENCES_OWNER_KEY, JSON.stringify({ userId: 7, schema: 1 })); + expect(JSON.parse(localStorage.getItem(PREFERENCES_OWNER_KEY) as string)).toEqual({ userId: 7, schema: 1 }); + }); + + it('corrupt-row reconciliation adopts defaults and repairs conditionally on the corrupt revision', async () => { + // The sync owner sequence for a corrupt row: adopt the corrupt revision, + // hydrate defaults under the guard, then a conditional repair PUT. + adoptKnownRevision('appearance', 7); + setHydratingDomains(new Set(['appearance'])); + try { + hydrateAppearanceDocument({ ...APPEARANCE_DOC }); + } finally { + setHydratingDomains(new Set()); + } + notifyPreferenceWrite('appearance'); + await flushPendingWrites(); + await vi.waitFor(() => expect(apiFetch).toHaveBeenCalled()); + const put = recordedCalls()[0]; + expect(put.method).toBe('PUT'); + expect(put.path).toBe('/user-preferences/appearance'); + expect(put.body).toMatchObject({ expectedRevision: 7, theme: 'dim' }); + }); + + it('the pump drops a queued operation whose captured identity no longer matches', async () => { + // An operation captured under account 9 while the bus is synced to 9; the + // identity then changes (account switch, before any reset hook runs). The + // defense-in-depth guard must drop the queued operation without sending: + // the server guard is authoritative, but a stale client write never + // leaves the tab. + setCurrentSyncUser(9); + notifyPreferenceWrite('appearance', ['theme']); + setCurrentSyncUser(3); + await flushPendingWrites(); + expect(recordedCalls().filter((c) => c.method === 'PUT')).toHaveLength(0); + expect(inspectQueue('appearance').kind).toBeNull(); + }); + + it('the pump drops a queued operation whose generation is stale', async () => { + // The edit is enqueued under generation G; a logout-style generation bump + // (with the queue deliberately NOT reset) must stop the write from + // firing on the next pump. + notifyPreferenceWrite('appearance', ['theme']); + bumpGeneration(); + await flushPendingWrites(); + expect(recordedCalls().filter((c) => c.method === 'PUT')).toHaveLength(0); + expect(inspectQueue('appearance').kind).toBeNull(); + }); +}); diff --git a/frontend/src/lib/preferences/preferenceEvents.ts b/frontend/src/lib/preferences/preferenceEvents.ts new file mode 100644 index 00000000..34ee10d5 --- /dev/null +++ b/frontend/src/lib/preferences/preferenceEvents.ts @@ -0,0 +1,212 @@ +/** + * Preference event bus. The React-free leaf of the preference sync layer: + * hooks, documents, and the sync bus all import this module, and it imports + * nothing from them (module graph stays a DAG). + * + * Hosts five concerns: + * 1. Write notification: user-facing setters call notifyPreferenceWrite() so + * the sync owner can queue a server write. Hydration-side writes deliberately + * do NOT notify (derived state, not user intent). + * 2. Identity generations: a monotonic counter bumped on every auth identity + * transition. Async preference work captures a generation and its result + * applies only if the generation is unchanged. + * 3. Eligibility readiness: a module-level store for the settled quick-link + * default eligibility, published by useViewNavigationState (which cannot be + * read from the always-mounted sync owner). Publications are bound to the + * ownership of the authorization snapshot that produced them so a delayed + * publish from a superseded producer cannot appear current. + * 4. The unsaved-episode failure surface consumed by the toast and the reset + * settle logic. + * 5. The queue-reset hook the sync bus registers at import time (identity + * transitions clear its queued/failed state). + */ + +export type PreferenceDomain = 'appearance' | 'navigation'; + +/** The dirty-field names a domain document carries. Setters attribute their + * writes to specific fields so a late GET merges server-wins for untouched + * fields (a stale cached theme must never overwrite the server's theme). */ +export type PreferenceField = + | 'theme' | 'accent' | 'uiFont' | 'monoFont' | 'visualStyle' | 'headingStyle' + | 'chartStyle' | 'density' | 'logChipColorMode' | 'borderBoost' | 'glow' + | 'contrast' | 'typeScale' | 'reducedEffects' | 'reducedMotion' | 'readability' + | 'mode' | 'quickLinks' | 'labels' | 'align'; + +export const DOMAIN_FIELDS: Record = { + appearance: ['theme', 'accent', 'uiFont', 'monoFont', 'visualStyle', 'headingStyle', + 'chartStyle', 'density', 'logChipColorMode', 'borderBoost', 'glow', 'contrast', + 'typeScale', 'reducedEffects', 'reducedMotion', 'readability'], + navigation: ['mode', 'quickLinks', 'labels', 'align'], +}; + +// ── write notification ───────────────────────────────────────────────────── +const writeListeners = new Set<(domain: PreferenceDomain, fields: readonly PreferenceField[]) => void>(); + +/** Called by user-facing setters after a local write. `fields` names the + * document fields the write touched, so a late GET can merge server-wins for + * untouched fields (a stale cached theme must never overwrite the server's + * theme). Omitting `fields` marks the whole domain (a complete reset). Never + * call from hydration/seed paths: those are derived state, not user intent. */ +export function notifyPreferenceWrite(domain: PreferenceDomain, fields?: readonly PreferenceField[]): void { + const touched = fields ?? DOMAIN_FIELDS[domain]; + for (const listener of writeListeners) listener(domain, touched); +} + +export function subscribeToPreferenceWrites( + listener: (domain: PreferenceDomain, fields: readonly PreferenceField[]) => void, +): () => void { + writeListeners.add(listener); + return () => { + writeListeners.delete(listener); + }; +} + +// ── identity generations ─────────────────────────────────────────────────── +let generation = 0; +const generationListeners = new Set<() => void>(); + +/** Monotonic identity generation. Captured by async work; results apply only + * when currentGeneration() still equals the captured value. */ +export function currentGeneration(): number { + return generation; +} + +/** Bump on every identity transition (login, logout, account switch, 401). */ +export function bumpGeneration(): void { + generation += 1; + for (const listener of generationListeners) listener(); +} + +export function subscribeToGenerations(listener: () => void): () => void { + generationListeners.add(listener); + return () => { + generationListeners.delete(listener); + }; +} + +/** Clear every queued/failed state in the sync bus for the old identity. */ +export function resetPreferenceSync(): void { + resetQueuedOperations(); + resetUnsaved(); +} + +// ── eligibility readiness (ownership-captured) ───────────────────────────── +/** Ownership of an authorization snapshot: the account and identity generation + * under which a producer computed its value. Publications carry it so a + * superseded producer cannot publish into the new account. */ +export interface EligibilityOwnership { + userId: number | null; + generation: number; +} + +export interface SettledEligibility { + ownership: EligibilityOwnership; + eligibleIds: readonly ActiveView[] | null; +} + +let settledEligibility: SettledEligibility | null = null; + +function isCurrentOwnership(ownership: EligibilityOwnership): boolean { + if (generation !== ownership.generation) return false; + const userId = currentUserIdReader ? currentUserIdReader() : null; + return userId === ownership.userId; +} + +import type { ActiveView } from '@/lib/router/routeTypes'; + +/** + * Publish settled quick-link eligibility. Rejected unless the ownership still + * matches the current generation: a delayed publication from an old producer + * (account switched, logout, identity bump) must never appear current. + * Ownership is captured by the producer from its own authorization snapshot, + * never read from current state here. `null` means eligibility did not settle + * (permissions still loading); a settled list is `readonly ActiveView[]`. + */ +export function setEligibilitySettled(ownership: EligibilityOwnership, eligibleIds: readonly ActiveView[] | null): void { + if (!isCurrentOwnership(ownership)) return; + settledEligibility = { ownership, eligibleIds: eligibleIds === null ? null : [...eligibleIds] }; +} + +/** Clear the publication only if it still belongs to the given ownership, so + * an obsolete producer's teardown cannot erase a newer account's publication. */ +export function clearEligibility(ownership: EligibilityOwnership): void { + if (settledEligibility && isSameOwnership(settledEligibility.ownership, ownership)) { + settledEligibility = null; + } +} + +/** The settled publication, visible only while its ownership is still current. + * A superseded account's publication stays stored (teardown scoping needs it) + * but reads as null, so no consumer can act on the previous account's + * eligibility after an identity transition. */ +export function getSettledEligibility(): SettledEligibility | null { + if (!settledEligibility || !isCurrentOwnership(settledEligibility.ownership)) return null; + return settledEligibility; +} + +function isSameOwnership(a: EligibilityOwnership, b: EligibilityOwnership): boolean { + return a.userId === b.userId && a.generation === b.generation; +} + +// ── sync bus failure surface ─────────────────────────────────────────────── +export interface UnsavedEpisode { + domain: PreferenceDomain; + episode: number; +} + +const unsavedListeners = new Set<(episode: UnsavedEpisode | null) => void>(); +let unsaved: UnsavedEpisode | null = null; +let episodeCounter = 0; + +/** The current unsaved episode, or null when everything is persisted. */ +export function getUnsavedEpisode(): UnsavedEpisode | null { + return unsaved; +} + +export function subscribeToUnsaved(listener: (episode: UnsavedEpisode | null) => void): () => void { + unsavedListeners.add(listener); + return () => { + unsavedListeners.delete(listener); + }; +} + +function emitUnsaved(): void { + for (const listener of unsavedListeners) listener(unsaved); +} + +function resetUnsaved(): void { + unsaved = null; + emitUnsaved(); +} + +// Bus-internal mutators. Kept here (not exported from syncBus) so the leaf +// module stays dependency-free while the bus can still drive the shared state. +export function setUnsavedEpisode(domain: PreferenceDomain): number { + episodeCounter += 1; + unsaved = { domain, episode: episodeCounter }; + emitUnsaved(); + return episodeCounter; +} + +export function clearUnsavedEpisode(episode: number): void { + if (unsaved && unsaved.episode === episode) { + unsaved = null; + emitUnsaved(); + } +} + +// ── queued-operation reset hook (set by syncBus at import time) ──────────── +let resetQueuedOperations: () => void = () => {}; + +export function registerQueueReset(reset: () => void): void { + resetQueuedOperations = reset; +} + +/** The bus-installed reader of the currently synced account. syncBus sets it + * at import time so the ownership check can compare accounts without a + * circular import. */ +let currentUserIdReader: (() => number | null) | null = null; + +export function registerCurrentUserIdReader(reader: () => number | null): void { + currentUserIdReader = reader; +} diff --git a/frontend/src/lib/preferences/preferencesDocuments.ts b/frontend/src/lib/preferences/preferencesDocuments.ts new file mode 100644 index 00000000..b187b32a --- /dev/null +++ b/frontend/src/lib/preferences/preferencesDocuments.ts @@ -0,0 +1,256 @@ +/** + * Preference documents: the server-side shape of the two preference domains. + * + * The sync layer serializes local state into these documents for migration and + * writes, and hydrates server rows back into the local hooks. Sanitization is + * per-field against the live registries (the hooks' own guards), so a future + * enum removal degrades to the default value instead of corrupting state. + */ + +import { + CALM_PRESET, + CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE, + currentThemeState, applyThemeState, + isMode, isAccent, isUiFont, isMonoFont, + isVisualStyle, isHeadingStyle, isChartStyle, isBool, + type ThemeState, +} from '@/hooks/use-theme'; +import { currentDensityValue, applyDensityValue, isDensity, type Density } from '@/hooks/use-density'; +import { + TOP_NAV_MODE_KEY, parseTopNavMode, currentTopNavMode, applyTopNavMode, type TopNavMode, +} from '@/hooks/use-top-nav-mode'; +import { + TOP_NAV_QUICK_LINKS_KEY, sanitizeQuickLinkIds, + currentQuickLinks, applyQuickLinks, currentQuickLinksProvenance, +} from '@/hooks/use-top-nav-quick-links'; +import { TOP_NAV_LABELS_KEY, currentTopNavLabels, applyTopNavLabels } from '@/hooks/use-top-nav-labels'; +import { TOP_NAV_ALIGN_KEY, currentTopNavAlign, applyTopNavAlign, isTopNavAlignExport, type TopNavAlign } from '@/hooks/use-top-nav-align'; +import { currentLogChipColorMode, applyLogChipColorMode, isLogChipColorModeExport, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode'; +import { recommendedQuickLinkIds } from '@/lib/navigation/appNavRegistry'; + +export interface AppearanceDocument { + theme: ThemeState['theme']; + accent: ThemeState['accent']; + uiFont: ThemeState['uiFont']; + monoFont: ThemeState['monoFont']; + visualStyle: ThemeState['visualStyle']; + headingStyle: ThemeState['headingStyle']; + chartStyle: ThemeState['chartStyle']; + density: Density; + logChipColorMode: LogChipColorMode; + borderBoost: number; + glow: number; + contrast: number; + typeScale: number; + reducedEffects: boolean; + reducedMotion: boolean; + readability: boolean; +} + +/** What the raw theme cache key holds: everything except the two fields + * (`density`, `logChipColorMode`) stored under their own keys. */ +export type ThemeCacheDocument = Omit; + +/** Navigation document with unset provenance for the pin list. `unset` means + * the hook has never persisted a list (never-seeded or eligibility still + * settling); `valid` includes a deliberately empty list ([]). */ +export type NavigationDocument = + | { status: 'unset' } + | { + status: 'valid'; + mode: TopNavMode; + quickLinks: string[]; + labels: boolean; + align: TopNavAlign; + }; + +function clampNumber(value: unknown, bounds: { min: number; max: number; default: number }): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return bounds.default; + return Math.min(bounds.max, Math.max(bounds.min, value)); +} + +/** Serialize the live appearance state (theme + density + log chips) into the + * server document shape. */ +export function buildAppearanceDocument(): AppearanceDocument { + const t = currentThemeState(); + return { + theme: t.theme, + accent: t.accent, + uiFont: t.uiFont, + monoFont: t.monoFont, + visualStyle: t.visualStyle, + headingStyle: t.headingStyle, + chartStyle: t.chartStyle, + density: currentDensityValue(), + logChipColorMode: currentLogChipColorMode(), + borderBoost: t.borderBoost, + glow: t.glow, + contrast: t.contrast, + typeScale: t.typeScale, + reducedEffects: t.reducedEffects, + reducedMotion: t.reducedMotion, + readability: t.readability, + }; +} + +/** + * Serialize the live navigation state. Carries unset provenance: a hook that + * has never persisted a list produces `{ status: 'unset' }` and the sync layer + * defers migration until eligibility has settled (never saves a derived `[]`). + * Legacy 'classic' is normalized to Compact here by parseTopNavMode before any + * write, so the server never receives the retired value. + */ +export function buildNavigationDocument(): NavigationDocument { + const provenance = currentQuickLinksProvenance(); + if (provenance.status === 'unset') return { status: 'unset' }; + return { + status: 'valid', + mode: currentTopNavMode(), + quickLinks: currentQuickLinks(), + labels: currentTopNavLabels(), + align: currentTopNavAlign(), + }; +} + +// ── hydration (server document → local hooks + cache) ────────────────────── + +/** Per-field sanitize of an untrusted appearance document and apply through + * the hooks' internal write path (DOM + localStorage + subscribers). Invalid + * scalars keep the current local value; out-of-range numerics fall back to + * their registry default; a document missing the visual-style axes (older + * writer) fills them from Signature exactly like the local storage read does. */ +export function hydrateAppearanceDocument(raw: unknown): void { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return; + const p = raw as Record; + const current = currentThemeState(); + // Visual-style axes: a document missing them (older writer) is a returning + // user, so fill from Signature exactly like the local storage read does. + const visualFallback = { + visualStyle: isVisualStyle(p.visualStyle) ? p.visualStyle : 'signature' as const, + headingStyle: isHeadingStyle(p.headingStyle) ? p.headingStyle : 'signature' as const, + chartStyle: isChartStyle(p.chartStyle) ? p.chartStyle : 'signature' as const, + reducedEffects: isBool(p.reducedEffects) ? p.reducedEffects : true, + reducedMotion: isBool(p.reducedMotion) ? p.reducedMotion : true, + readability: isBool(p.readability) ? p.readability : false, + }; + applyThemeState({ + theme: isMode(p.theme) ? p.theme : current.theme, + accent: isAccent(p.accent) ? p.accent : current.accent, + uiFont: isUiFont(p.uiFont) ? p.uiFont : current.uiFont, + monoFont: isMonoFont(p.monoFont) ? p.monoFont : current.monoFont, + borderBoost: clampNumber(p.borderBoost, BORDER_BOOST), + glow: clampNumber(p.glow, GLOW), + contrast: clampNumber(p.contrast, CONTRAST), + typeScale: clampNumber(p.typeScale, TYPE_SCALE), + ...visualFallback, + }); + applyDensityValue(isDensity(p.density) ? p.density : 'comfortable'); + applyLogChipColorMode(isLogChipColorModeExport(p.logChipColorMode) ? p.logChipColorMode : 'unified'); +} + +/** The documented calm-default appearance document, used for tombstone + * hydration (the server says "reset", so defaults come from the registry, + * not from whatever this browser happens to have cached). */ +export function defaultAppearanceDocument(): AppearanceDocument { + const d = CALM_PRESET; + return { + theme: 'dim', + accent: 'cyan', + uiFont: 'Geist', + monoFont: 'Geist Mono', + visualStyle: d.visualStyle, + headingStyle: d.headingStyle, + chartStyle: d.chartStyle, + density: 'comfortable', + logChipColorMode: 'unified', + borderBoost: BORDER_BOOST.default, + glow: GLOW.default, + contrast: CONTRAST.default, + typeScale: TYPE_SCALE.default, + reducedEffects: d.reducedEffects, + reducedMotion: d.reducedMotion, + readability: d.readability, + }; +} + +/** + * Per-field sanitize of an untrusted navigation document and apply through the + * hooks' internal write path. Legacy `mode: 'classic'` normalizes to Compact + * (parseTopNavMode). Quick links are sanitized against the eligible registry, + * deduped, and capped at MAX_QUICK_LINKS; a valid empty list stays empty. + */ +export function hydrateNavigationDocument(raw: unknown): void { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return; + const p = raw as Record; + const rawMode = typeof p.mode === 'string' ? p.mode : null; + // parseTopNavMode maps legacy 'classic' (and anything unknown) to Compact. + const mode: TopNavMode = rawMode === 'smart' ? 'smart' : parseTopNavMode(rawMode); + const labels = isBool(p.labels) ? p.labels : true; + const align: TopNavAlign = isTopNavAlignExport(p.align) ? p.align : 'left'; + const quickLinks = sanitizeQuickLinkIds(p.quickLinks); + applyTopNavMode(mode); + applyTopNavLabels(labels); + applyTopNavAlign(align); + applyQuickLinks(quickLinks); +} + +/** + * Hydrate a navigation tombstone (or corrupt row): the domain was reset, so + * apply the scalar defaults and seed the recommended quick-link set + * unconditionally, before settled eligibility is known. Seeding persists a + * valid pin list, so the hook's eligibility seed effect later becomes a + * no-op. Reachability filtering happens at display time, not here. + */ +export function hydrateNavigationDefaults(): void { + applyTopNavMode(parseTopNavMode(null)); + applyTopNavLabels(true); + applyTopNavAlign('left'); + applyQuickLinks(recommendedQuickLinkIds.filter((id) => sanitizeQuickLinkIds([id]).length > 0)); +} + +// ── cache keys ───────────────────────────────────────────────────────────── +/** Every localStorage key the preference domains own. Cleared on identity + * switch and when no user is signed in (another account must never see this + * browser's cached values); tombstone and corrupt hydration instead rewrite + * each key to its default through the apply paths. theme-init.js re-reads the + * theme key at next paint, so a cleared key paints defaults. */ +export const PREFERENCE_CACHE_KEYS = [ + 'sencho.appearance.theme', + 'sencho-theme', // legacy theme key + 'sencho.appearance.density', + 'sencho.log-chip-color-mode', + TOP_NAV_MODE_KEY, + TOP_NAV_QUICK_LINKS_KEY, + TOP_NAV_LABELS_KEY, + TOP_NAV_ALIGN_KEY, +] as const; + +export const PREFERENCES_OWNER_KEY = 'sencho.preferences.owner'; + +export function clearPreferenceCache(): void { + if (typeof window === 'undefined') return; + try { + for (const key of PREFERENCE_CACHE_KEYS) window.localStorage.removeItem(key); + window.localStorage.removeItem(PREFERENCES_OWNER_KEY); + } catch { + // localStorage may be unavailable; nothing to clear then + } +} + +export function writePreferenceCacheFromDocuments(): void { + if (typeof window === 'undefined') return; + // The apply* functions already persist through the hooks' own write paths; + // this rewrites the theme document (the only key holding multiple fields) + // after a reset so the pre-paint cache mirrors the reset values even if a + // queued DELETE has not settled yet. + const appearance = buildAppearanceDocument(); + try { + window.localStorage.setItem('sencho.appearance.theme', JSON.stringify({ + ...appearance, + density: undefined, + logChipColorMode: undefined, + })); + } catch { + // ignore; private mode / quota + } +} diff --git a/frontend/src/lib/preferences/quickLinksProvenance.ts b/frontend/src/lib/preferences/quickLinksProvenance.ts new file mode 100644 index 00000000..81db9925 --- /dev/null +++ b/frontend/src/lib/preferences/quickLinksProvenance.ts @@ -0,0 +1,24 @@ +/** + * Provenance reader for the quick-links hook. Kept as a separate module so the + * sync layer can read pin provenance without importing the React hook. + */ + +export type QuickLinksProvenance = + | { status: 'valid' } + | { status: 'unset' }; + +/** Read the raw provenance of the quick-links cache: 'unset' means no valid + * list has ever been persisted (missing key, malformed JSON, non-array JSON); + * 'valid' includes a deliberately saved empty list. */ +export function readQuickLinksProvenance(): QuickLinksProvenance { + if (typeof window === 'undefined') return { status: 'unset' }; + try { + const raw = window.localStorage.getItem('sencho.appearance.topNavQuickLinks'); + if (raw === null) return { status: 'unset' }; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return { status: 'unset' }; + return { status: 'valid' }; + } catch { + return { status: 'unset' }; + } +} diff --git a/frontend/src/lib/preferences/resetPreferences.ts b/frontend/src/lib/preferences/resetPreferences.ts new file mode 100644 index 00000000..56c987c5 --- /dev/null +++ b/frontend/src/lib/preferences/resetPreferences.ts @@ -0,0 +1,38 @@ +/** + * Complete-domain reset (the "reset all" actions). Optimistic-local defaults + * are applied immediately under the hydration guard (so no queued server write + * is spawned by the apply itself), then the tombstone DELETE is queued through + * the bus: pre-reset queued PUTs are cancelled, post-reset edits are staged + * behind the DELETE conditionally on the tombstone's revision. + */ +import { type PreferenceDomain } from './preferenceEvents'; +import { queueReset, setHydratingDomains } from './syncBus'; +import { + defaultAppearanceDocument, + hydrateAppearanceDocument, + hydrateNavigationDefaults, + writePreferenceCacheFromDocuments, +} from './preferencesDocuments'; + +export function resetPreferenceDomain(domain: PreferenceDomain): void { + setHydratingDomains(new Set([domain])); + try { + if (domain === 'appearance') { + hydrateAppearanceDocument(defaultAppearanceDocument()); + } else { + hydrateNavigationDefaults(); + } + } finally { + setHydratingDomains(new Set()); + } + // Re-assert the cache to the new (default) local state so a reload paints + // the reset values even if the DELETE has not settled yet. + writePreferenceCacheFromDocuments(); + queueReset(domain); + // The tombstone stands once the DELETE settles: a pending edit based on an + // older revision is discarded by reconciliation (remote-reset precedence), + // and only edits the user makes after adopting the tombstone are staged + // behind the reset as their own PUTs. No trailing notify here: one would + // un-tombstone by immediately PUTting the defaults, weakening the reset + // for clients that were offline when it happened. +} diff --git a/frontend/src/lib/preferences/syncBus.ts b/frontend/src/lib/preferences/syncBus.ts new file mode 100644 index 00000000..bec1ccbb --- /dev/null +++ b/frontend/src/lib/preferences/syncBus.ts @@ -0,0 +1,587 @@ +/** + * Preference sync bus. The server write choke point for preference + * documents: every migration, document write, and reset flows through here as + * a queued, preconditioned operation with chronological intent. (The sync + * owner's reconciliation path also issues conditional PUTs directly when it + * merges a server document; both paths share the same preconditioned wire + * contract and the reconcile hook closes the loop between them.) + * + * Guarantees: + * - Per-domain serialization: one in-flight operation per domain at a time. + * - Preconditions: every write carries expectedRevision; migration is + * create-if-absent on the server. When the client has no known revision, a + * GET first establishes the baseline; an absent row on a PUT falls back to + * migration. There is no unguarded write path. + * - Chronology (this client's own queue only): a reset cancels queued PUTs + * enqueued before it; an edit enqueued after a reset is staged behind it as + * a conditional PUT against the reset's resulting revision. Cross-browser + * chronology is NEVER inferred here; reconciliation orders that by the + * observable server revision. + * - Coalescing: consecutive PUTs collapse to the newest (documents are rebuilt + * at send time from current local state); consecutive DELETEs collapse. + * - Identity: operations capture the userId and identity generation at enqueue + * time; sends and retries are refused if either has changed. Identity + * transitions reset the whole bus (queue, failures, known revisions). + * - Visible failure: a failed send raises an unsaved episode (toast with + * Retry); retry re-runs the operation preserving its kind (a failed reset + * retries as DELETE, never as a PUT). + */ + +import { apiFetch } from '@/lib/api'; +import { + clearUnsavedEpisode, + currentGeneration, + getSettledEligibility, + registerCurrentUserIdReader, + registerQueueReset, + setUnsavedEpisode, + subscribeToPreferenceWrites, + type PreferenceDomain, +} from '@/lib/preferences/preferenceEvents'; +import { + buildAppearanceDocument, + buildNavigationDocument, +} from '@/lib/preferences/preferencesDocuments'; + +// ── types ────────────────────────────────────────────────────────────────── + +export type OperationKind = 'migrate' | 'put' | 'reset'; + +interface Envelope { + schemaVersion: number; + revision: number; + updatedAt: number; + corrupt?: boolean; + data?: unknown; +} + +interface QueuedOperation { + domain: PreferenceDomain; + kind: OperationKind; + capturedUserId: number; + generation: number; + seq: number; + /** Revision this operation is conditional on. Null means "GET a baseline + * first"; migrate carries null permanently (create-if-absent needs no + * precondition envelope). A resolved baseline of "the row does not exist" + * becomes the sentinel 'absent' (reset only: send the create-if-absent + * precondition instead of a revision). */ + expectedRevision: number | 'absent' | null; + episode: number | null; + /** Set when a 409 CONFLICT re-queued this operation once against the + * adopted revision; a second conflict must surface as a failure instead + * of looping. */ + conflictRetried?: boolean; + /** Sent with keepalive when this operation is the pagehide flush, so the + * request survives document unload. Resolved when the operation is + * dispatched, not per module, because the baseline GET a flush triggers is + * itself asynchronous and a module-level flag would already be reset by + * the time the actual write goes out. */ + keepalive: boolean; +} + +interface DomainSyncState { + queued: QueuedOperation | null; + /** A put staged behind a queued reset (chronology: pre-reset edits die, + * post-reset edits wait): the reset must run first, then the PUT. Null + * when no reset is queued. */ + stagedBehindReset: { op: QueuedOperation; put: QueuedOperation } | null; + inFlight: boolean; + knownRevision: number | null; + failed: QueuedOperation | null; +} + +// ── module state ─────────────────────────────────────────────────────────── + +const PREF_USER_HEADER = 'x-sencho-pref-user'; + +const domains: Record = { + appearance: { queued: null, stagedBehindReset: null, inFlight: false, knownRevision: null, failed: null }, + navigation: { queued: null, stagedBehindReset: null, inFlight: false, knownRevision: null, failed: null }, +}; + +let seqCounter = 0; +let currentUserId: number | null = null; +let hydratingDomains: ReadonlySet = new Set(); + +// Retry hook installed by the sync owner (useUserPreferencesSync) so the bus +// can ask for reconciliation without importing React code. +type ReconcileFn = (domain: PreferenceDomain) => void; +let reconcileHook: ReconcileFn | null = null; + +export function setReconcileHook(fn: ReconcileFn | null): void { + reconcileHook = fn; +} + +/** Mark a domain as hydration-driven: writes dispatched while hydrating are + * derived state and must not enqueue server operations. */ +export function setHydratingDomains(set: ReadonlySet): void { + hydratingDomains = set; +} + +export function setCurrentSyncUser(userId: number | null): void { + currentUserId = userId; +} + +// The eligibility ownership check in the events leaf compares accounts, so it +// reads the synced account from here (no circular import at the leaf). +registerCurrentUserIdReader(() => currentUserId); + +// Queue reset hook for identity transitions (wired into preferenceEvents). +registerQueueReset(() => { + for (const domain of ['appearance', 'navigation'] as const) { + const state = domains[domain]; + state.queued = null; + state.stagedBehindReset = null; + state.failed = null; + state.knownRevision = null; + } + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + debounceQueued = false; + } +}); + +// ── enqueue ──────────────────────────────────────────────────────────────── +let debounceTimer: ReturnType | null = null; +let debounceQueued = false; + +function scheduleDebounce(): void { + if (debounceQueued) return; + debounceQueued = true; + debounceTimer = setTimeout(() => { + debounceTimer = null; + debounceQueued = false; + for (const domain of ['appearance', 'navigation'] as const) { + if (domains[domain].queued) void pump(domain); + } + }, 400); +} + +/** Enqueue a user-intent write for a domain. Called by the write listener; a + * hydration-side write never reaches here (hydratingDomains guard). */ +function enqueue(domain: PreferenceDomain, kind: OperationKind, episode: number | null = null): void { + if (currentUserId === null) return; + const state = domains[domain]; + + if (kind === 'reset') { + // A reset cancels any queued PUT enqueued before it, including a PUT + // staged behind an earlier reset (pre-reset edits are discarded by the + // user's own explicit reset). A queued reset coalesces. + state.stagedBehindReset = null; + state.queued = { + domain, kind, capturedUserId: currentUserId, generation: currentGeneration(), + seq: ++seqCounter, expectedRevision: state.knownRevision, episode, keepalive: flushPending, + }; + } else if (state.queued && state.queued.kind === 'reset') { + // An edit enqueued after a queued reset is staged behind it: the reset + // keeps its queue slot (its identity is what the settle handoff checks) + // and the PUT waits for the reset to settle, targeting its resulting + // revision (expectedRevision null means "resolve the baseline then"). + // Re-staging coalesces: the document is rebuilt at send time, so the + // newest values win without re-queueing. + state.stagedBehindReset = { + op: state.queued, + put: state.stagedBehindReset?.put ?? { + domain, kind: 'put', capturedUserId: currentUserId, generation: currentGeneration(), + seq: ++seqCounter, expectedRevision: null, episode: null, keepalive: flushPending, + }, + }; + // The reset's own pump is already running; nothing new to start. + return; + } else if (kind === 'put') { + if (state.queued && state.queued.kind === 'put') { + // Same-kind coalescing: n PUTs collapse to one, rebuilt at send time. + const seq = state.queued.seq; + state.queued = { + domain, kind: 'put', capturedUserId: currentUserId, generation: currentGeneration(), + seq, expectedRevision: state.knownRevision, episode: null, keepalive: flushPending, + }; + } else { + // A fresh PUT on an empty queue is enqueued as-is. + state.queued = { + domain, kind: 'put', capturedUserId: currentUserId, generation: currentGeneration(), + seq: ++seqCounter, expectedRevision: state.knownRevision, episode: null, keepalive: flushPending, + }; + } + scheduleDebounce(); + return; + } else if (state.queued && state.queued.kind === 'put' && kind === 'migrate') { + // A queued PUT means the row exists client-side; migration is moot. + return; + } else if (kind === 'migrate') { + // Migration is create-if-absent; a queued migrate coalesces with nothing. + if (state.queued) return; + state.queued = { + domain, kind, capturedUserId: currentUserId, generation: currentGeneration(), + seq: ++seqCounter, expectedRevision: null, episode: null, keepalive: flushPending, + }; + } + + void pump(domain); +} + +/** User-facing setters notify here (via the events leaf). */ +function onPreferenceWrite(domain: PreferenceDomain): void { + if (hydratingDomains.has(domain)) return; + enqueue(domain, 'put'); +} + +subscribeToPreferenceWrites(onPreferenceWrite); + +// ── transport ────────────────────────────────────────────────────────────── + +interface PrefRequest { + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + body?: unknown; +} + +function authedFetch(req: PrefRequest, userId: number, keepalive = false): Promise { + return apiFetch(req.path, { + method: req.method, + localOnly: true, + keepalive, + headers: { [PREF_USER_HEADER]: String(userId) }, + body: req.body === undefined ? undefined : JSON.stringify(req.body), + }); +} + +/** Server wire shape for a domain document. The navigation `status` provenance + * is frontend-only: the server schema is strict and rejects unknown keys. + * Returns null when navigation has no valid document yet (never persisted), + * so callers can skip the send instead of posting `{}` and tripping a 400. */ +export function documentForDomain(domain: PreferenceDomain): Record | null { + if (domain === 'appearance') return { ...buildAppearanceDocument() }; + const doc = buildNavigationDocument(); + if (doc.status !== 'valid') return null; + return { mode: doc.mode, quickLinks: doc.quickLinks, labels: doc.labels, align: doc.align }; +} + +async function fetchBaseline(domain: PreferenceDomain, userId: number): Promise { + const response = await authedFetch({ method: 'GET', path: '/user-preferences' }, userId); + if (!response.ok) return null; + const payload = (await response.json()) as { preferences?: Record }; + const row = payload.preferences?.[domain]; + // Only a real envelope establishes a baseline: an absent row (null) means + // "no document exists" and must be distinguished from a malformed response, + // which must never store an undefined revision. + if (!isRowEnvelope(row)) return null; + domains[domain].knownRevision = row.revision; + return row; +} + +// ── pump ─────────────────────────────────────────────────────────────────── + +async function pump(domain: PreferenceDomain): Promise { + const state = domains[domain]; + if (state.inFlight) return; + const op = state.queued; + if (!op) return; + // Identity guard (defense in depth; the server re-verifies authoritatively). + if (op.capturedUserId !== currentUserId || op.generation !== currentGeneration()) { + state.queued = null; + state.stagedBehindReset = null; + return; + } + // Navigation migration defers until quick-link eligibility has settled so a + // never-seeded pin list is never saved as a derived []. A valid local + // document (the hook has a real list) does not need the deferral. + if (op.kind === 'migrate' && domain === 'navigation') { + const doc = buildNavigationDocument(); + if (doc.status !== 'valid') { + const eligibility = getSettledEligibility(); + if (!eligibility || eligibility.eligibleIds === null) return; + } + } + + state.inFlight = true; + try { + await send(op); + } finally { + state.inFlight = false; + } + // The settle handoff for a staged pair: when the operation that just + // finished is the reset of a staged pair, either drop both (failure path + // owns the retry) or hand the queue to the staged PUT, which targets the + // reset's resulting revision. + const staged = state.stagedBehindReset; + if (staged && staged.op === op) { + if (state.failed === op) { + state.queued = null; + state.stagedBehindReset = null; + return; + } + state.queued = staged.put; + state.stagedBehindReset = null; + void pump(domain); + return; + } + // Next queued operation proceeds immediately. + if (state.queued && state.queued !== op) void pump(domain); +} + +async function send(op: QueuedOperation): Promise { + const userId = op.capturedUserId; + + try { + // Navigation with no valid local document (never persisted) has nothing + // to write: a PUT or migrate must not post a malformed document. A reset + // (DELETE) carries no document at all, so it proceeds; skipping it would + // strand the user's reset in the queue until the next navigation edit. + if (op.kind !== 'reset' && documentForDomain(op.domain) === null) { + throw new Error('document-unavailable'); + } + // Establish the revision baseline when the operation has none. An absent + // row (null baseline) falls back to the precondition that matches what + // the GET actually observed: a PUT becomes a migrate (create-if-absent, + // the row must be created), a reset sends `absent: true` (tombstoning an + // absent row is still the user's intent). Any other shape is a failure: + // the operation parks in `failed` and the toast owns the retry. + if (op.expectedRevision === null && !(op.kind === 'migrate')) { + const baseline = await fetchBaseline(op.domain, userId); + if (baseline === null) { + if (op.kind === 'put') { + op.kind = 'migrate'; + op.expectedRevision = null; + } else if (op.kind === 'reset') { + op.expectedRevision = 'absent'; + } else { + throw new Error('baseline-unavailable'); + } + } else { + op.expectedRevision = baseline.revision; + } + } + + let response: Response; + if (op.kind === 'migrate') { + response = await authedFetch( + { method: 'POST', path: `/user-preferences/${op.domain}/migrate`, body: documentForDomain(op.domain) }, + userId, + op.keepalive, + ); + } else if (op.kind === 'reset') { + response = await authedFetch( + { + method: 'DELETE', + path: `/user-preferences/${op.domain}`, + // 'absent' is the sentinel the baseline fetch resolved to when the + // row did not exist; it maps to the server's create-if-absent + // precondition so a reset against a missing row still tombstones. + body: op.expectedRevision === 'absent' + ? { absent: true } + : { expectedRevision: op.expectedRevision }, + }, + userId, + op.keepalive, + ); + } else { + response = await authedFetch( + { + method: 'PUT', + path: `/user-preferences/${op.domain}`, + body: { expectedRevision: op.expectedRevision, ...documentForDomain(op.domain) }, + }, + userId, + op.keepalive, + ); + } + + if (response.ok) { + onSuccess(op, response); + return; + } + + if (response.status === 409) { + await onConflict(op, response); + return; + } + throw new Error(`http-${response.status}`); + } catch (error) { + onFailure(op, error); + } +} + +function isRowEnvelope(value: unknown): value is Envelope { + return !!value && typeof value === 'object' + && Number.isSafeInteger((value as Envelope).revision) + && (value as Envelope).revision >= 1; +} + +async function onConflict(op: QueuedOperation, response: Response): Promise { + const state = domains[op.domain]; + let payload: { error?: string; current?: unknown } = {}; + try { + payload = (await response.json()) as { error?: string; current?: unknown }; + } catch { + // Unparseable conflict body: treat as a generic failure below; the + // machine code is what matters and it never arrived. + } + if (payload.error === 'CONFLICT' && isRowEnvelope(payload.current)) { + const current = payload.current; + state.knownRevision = current.revision; + // A reset conflict re-runs the DELETE once against the adopted + // revision: delegating to reconciliation here would re-hydrate the + // pre-reset server document and visibly revert the user's reset (the + // reset path marks nothing dirty, so nothing would re-apply it). The + // re-queue is a fresh op object so the in-flight pump's trailing check + // re-fires, and a staged PUT is re-staged behind it; a second conflict + // is a failure the toast owns. + if (op.kind === 'reset' && !op.conflictRetried) { + state.queued = { + domain: op.domain, kind: 'reset', + capturedUserId: op.capturedUserId, generation: op.generation, seq: op.seq, + expectedRevision: current.revision, episode: op.episode, keepalive: op.keepalive, + conflictRetried: true, + }; + if (state.stagedBehindReset && state.stagedBehindReset.op === op) { + state.stagedBehindReset = { op: state.queued, put: state.stagedBehindReset.put }; + } + // No explicit pump here (the current one is still in flight); its + // trailing check re-fires because the queued object is new. + return; + } + // Hand reconciliation to the sync owner: server-wins for untouched + // fields, dirty fields re-applied, retry conditional on the new revision. + if (reconcileHook) { + reconcileHook(op.domain); + state.queued = null; + return; + } + } + onFailure(op, new Error('conflict')); +} + +function onSuccess(op: QueuedOperation, response: Response): void { + const state = domains[op.domain]; + void response.clone().json().then((payload: { + revision?: number; + migrated?: boolean; + row?: { revision?: number; data?: unknown; schemaVersion?: number; corrupt?: boolean }; + }) => { + // Mutate responses carry the revision at the top level; migrate responses + // (201/200) nest it in the row envelope. Adopt whichever is present so the + // next conditional write targets the server's current revision. + let revision: number | null = null; + if (typeof payload.revision === 'number') revision = payload.revision; + else if (typeof payload.row?.revision === 'number') revision = payload.row.revision; + if (revision !== null) state.knownRevision = revision; + // A lost migration race (migrated:false) means another browser's row is + // the winner: hand it to reconciliation so local dirty edits merge on + // top of the winner instead of being silently overwritten on next GET. + // A tombstone winner (schemaVersion 0) also reconciles: the sync owner + // then discards stale edits and adopts the reset's defaults. + if (payload.migrated === false && payload.row && isRowEnvelope(payload.row) && reconcileHook) { + reconcileHook(op.domain); + } + }).catch(() => { + // Revision telemetry only; the operation itself succeeded. + }); + if (state.queued === op) state.queued = null; + if (state.failed === op) state.failed = null; + if (op.episode !== null) clearUnsavedEpisode(op.episode); +} + +function onFailure(op: QueuedOperation, error: unknown): void { + const state = domains[op.domain]; + console.error(`[preferences] ${op.kind} ${op.domain} failed:`, error); + if (state.queued === op) state.queued = null; + state.failed = op; + if (op.episode === null) { + op.episode = setUnsavedEpisode(op.domain); + } else { + // A retry failed again: re-raise with a fresh episode number so the + // failure surfaces again (error toasts auto-dismiss; a same-number + // re-emit would be suppressed and the failure would go unseen). + op.episode = setUnsavedEpisode(op.domain); + } +} + +// ── retry (operation-kind preserving) ────────────────────────────────────── + +/** Re-run the failed operation for a domain by kind: PUT re-sends the latest + * reconciled document, DELETE re-sends the tombstone, migrate re-sends + * migration. Called by the toast Retry action. The failed operation stays + * bound to the identity that captured it: a Retry is never replayed under a + * different account (identity transitions reset the whole bus instead). + * An episode raised by the sync owner's reconciliation path (which issues + * conditional PUTs outside this queue) parks nothing here, so Retry asks the + * owner to re-reconcile from a fresh GET rather than doing nothing. */ +export function retryDomain(domain: PreferenceDomain): void { + const state = domains[domain]; + const failed = state.failed; + if (!failed) { + if (reconcileHook) reconcileHook(domain); + return; + } + if (failed.capturedUserId !== currentUserId) return; + if (failed.generation !== currentGeneration()) return; + state.failed = null; + state.queued = failed; + void pump(domain); +} + +/** Reset flow entry point: optimistic-local defaults are applied by the + * caller (via hydrate defaults); this queues the tombstone DELETE and + * cancels pre-reset PUTs. */ +export function queueReset(domain: PreferenceDomain): void { + enqueue(domain, 'reset'); +} + +/** Migration flow entry point (absent row). */ +export function queueMigrate(domain: PreferenceDomain): void { + enqueue(domain, 'migrate'); +} + +/** Adopt a revision observed by reconciliation (GET/409 current envelopes) so + * the next conditional write targets it. */ +export function adoptKnownRevision(domain: PreferenceDomain, revision: number): void { + domains[domain].knownRevision = revision; +} + +/** Drop a pending edit for a domain without sending it: reconciliation + * observed a remote tombstone newer than the edit's baseline, so the edit is + * discarded and the reset adopted. A failed operation with an unsaved + * episode is kept: the error surface owns its retry. */ +export function discardQueuedEdit(domain: PreferenceDomain): void { + const state = domains[domain]; + if (state.failed) return; + state.queued = null; + state.stagedBehindReset = null; +} + +/** Test/observer hook: inspect a domain's queue and in-flight state without + * touching transport. `settling` is true while a queued or in-flight + * operation may still hit the server; the reset settle logic waits until it + * is false so a reload can never cancel an in-flight DELETE. */ +export function inspectQueue(domain: PreferenceDomain): { kind: OperationKind | null; failed: OperationKind | null; settling: boolean } { + const state = domains[domain]; + return { + kind: state.queued?.kind ?? null, + failed: state.failed?.kind ?? null, + settling: state.inFlight || state.queued !== null, + }; +} + +// A pagehide flush must survive document unload, so the final send goes out +// with keepalive. The flag marks the operations the flush dispatches; it is +// read when the op object is created, and an async baseline GET it triggers +// still carries the flag on the op itself (a synchronous finally could not +// cover that window). +let flushPending = false; + +/** Flush helper used by the sync owner on pagehide/visibilitychange: sends any + * queued operation immediately with keepalive semantics. */ +export function flushPendingWrites(): void { + flushPending = true; + try { + for (const domain of ['appearance', 'navigation'] as const) { + const state = domains[domain]; + if (state.queued && !state.inFlight) void pump(domain); + } + } finally { + flushPending = false; + } +}