mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-10 01:15:55 +00:00
feat(preferences): frontend preference domains, sync bus, and identity guards
Introduce the client half of server-backed per-user preferences: the existing localStorage hooks remain the owners of state and DOM application; a thin sync layer carries their documents to and from the per-user preference API. - preferenceEvents: the leaf module with write notifications (dirty field attribution), the identity-generation counter, the eligibility-readiness store with ownership-captured publications, and the unsaved-episode failure surface - preferencesDocuments: build and per-field sanitize of appearance and navigation documents, tombstone-aware hydration, and the owner marker that wipes cached values when another account claims the browser - syncBus: the single write choke point. Per-domain serialization, same-kind coalescing, reset-cancels-prior-puts chronology, debounced trailing writes, conditional PUTs with 409 reconciliation (reset conflicts re-queue the DELETE once on the adopted revision), keepalive flush on page hide, and pump-level identity guards that drop operations captured by a superseded account or generation - useUserPreferencesSync: the always-mounted sync owner. Hydrates on identity resolution, reconciles late GETs against dirty fields (server wins untouched fields), adopts remote resets by observable revision, repairs corrupt rows with a conditional PUT, and surfaces failures as a single unsaved episode per domain - AuthContext: sequenced identity transitions and a cross-tab storage signal so a login or logout in one tab re-checks auth in the others - the localStorage hooks gain apply/current exports for the hydration path; user-facing setters notify the bus, hydration and derived seeding paths deliberately do not
This commit is contained in:
@@ -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() {
|
||||
<DeployFeedbackProvider>
|
||||
<AppContent />
|
||||
</DeployFeedbackProvider>
|
||||
<UserPreferencesSync />
|
||||
<ToastContainer />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
@@ -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<AuthContextType | undefined>(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<AppStatus>('loading');
|
||||
const [user, setUser] = useState<UserInfo | null>(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<number | null>(null);
|
||||
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
|
||||
const [permissionsStatus, setPermissionsStatus] = useState<PermissionsStatus>('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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Density>(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];
|
||||
|
||||
@@ -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<LogChipColorMode>(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];
|
||||
|
||||
@@ -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<ThemeState>) {
|
||||
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<ThemeState>): 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,
|
||||
|
||||
@@ -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<TopNavAlign>(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];
|
||||
|
||||
@@ -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<boolean>(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];
|
||||
|
||||
@@ -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<TopNavMode>(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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<Record<PreferenceDomain, Set<string>>>({
|
||||
appearance: new Set(), navigation: new Set(),
|
||||
});
|
||||
const conflictCountRef = useRef<Record<PreferenceDomain, number>>({
|
||||
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<void> {
|
||||
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<string, unknown> };
|
||||
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<void> {
|
||||
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<string, unknown> = { ...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<string, unknown>, b: Record<string, unknown>): 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<string, unknown>,
|
||||
localDoc: Record<string, unknown>,
|
||||
dirtySnapshot: Set<string> | null,
|
||||
): Record<string, unknown> {
|
||||
if (dirtySnapshot === null) return { ...serverDoc };
|
||||
const merged: Record<string, unknown> = { ...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<string, unknown>,
|
||||
expectedRevision: number,
|
||||
userId: number,
|
||||
generation: number,
|
||||
dirtySnapshot: Set<string> | null = null,
|
||||
): Promise<void> {
|
||||
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<string> | 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<string> | 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<void> {
|
||||
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<string, unknown>): 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<number | null>(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<string, unknown> };
|
||||
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]);
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
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<string, unknown> | 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<MockResponse>((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();
|
||||
});
|
||||
});
|
||||
@@ -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<PreferenceDomain, readonly PreferenceField[]> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<AppearanceDocument, 'density' | 'logChipColorMode'>;
|
||||
|
||||
/** 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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<PreferenceDomain, DomainSyncState> = {
|
||||
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<PreferenceDomain> = 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<PreferenceDomain>): 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<typeof setTimeout> | 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<Response> {
|
||||
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<string, unknown> | 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<Envelope | null> {
|
||||
const response = await authedFetch({ method: 'GET', path: '/user-preferences' }, userId);
|
||||
if (!response.ok) return null;
|
||||
const payload = (await response.json()) as { preferences?: Record<string, unknown> };
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user