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:
Anso
2026-09-07 13:53:27 -04:00
parent 85d50f0ef8
commit edf584af3a
16 changed files with 2408 additions and 45 deletions
+37
View File
@@ -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>
);
+73
View File
@@ -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();
}
};
+32 -1
View File
@@ -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];
+23 -6
View File
@@ -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];
+36 -13
View File
@@ -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,
+23 -6
View File
@@ -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];
+19 -6
View File
@@ -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];
+19 -6
View File
@@ -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];
+49 -7
View File
@@ -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.
}
+587
View File
@@ -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;
}
}