diff --git a/README.md b/README.md
index 8760ea3bb14..38416ef2948 100644
--- a/README.md
+++ b/README.md
@@ -401,6 +401,22 @@ is opt-in: it fails fast when `frontend/.next` has no completed build.
Gateway owns `/api/langgraph/*` and translates those public LangGraph-compatible paths to its native `/api/*` routers behind nginx.
+With Gateway authentication enabled, the Web UI also persists an allowlisted
+set of user-level display and model defaults through the owner-scoped
+`/api/user-preferences` API. An existing server record is authoritative at
+sign-in; otherwise DeerFlow imports the browser's valid local base settings
+once. Local storage remains the offline fallback. Thread overrides, browser
+notification permission/system state, workspace data, and credentials stay
+local and are never accepted by this API. Auth-disabled deployments keep the
+original local-only settings behavior. Authenticated fallback caches are keyed
+by account, and cross-tab synchronization forwards only leaves changed by a
+local action so an older cache snapshot cannot clear newer server values. A
+schema-invalid stored record is exposed as absent and repaired by the next
+complete settings import. On browsers with cross-tab Web Locks, only one
+authenticated account can claim the old unscoped cache; without that lock,
+DeerFlow still reads existing server settings but skips ambiguous legacy import
+and preference writes.
+
#### LangGraph Studio (Optional)
The default `make dev` topology uses DeerFlow's Gateway-embedded runtime and
diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md
index 0fed15652a6..7ddd3f3e648 100644
--- a/backend/app/gateway/AGENTS.md
+++ b/backend/app/gateway/AGENTS.md
@@ -44,7 +44,8 @@ reads/searches.
| Router | Endpoints |
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
-| **Features** (`/api/features`) | `GET /` - UI capabilities: hot-reloaded agents, guarded browser, startup MCP tasks, and separate batch repository/worker states so history stays readable without a worker |
+| **Features** (`/api/features`) | Frontend runtime capability flags |
+| **Preferences** (`/api/user-preferences`) | Owner-scoped settings sync; invalid data reads absent, PUT repairs, and PATCH rejects |
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py
index 1ce4559a4ec..8192f4127da 100644
--- a/backend/app/gateway/app.py
+++ b/backend/app/gateway/app.py
@@ -40,6 +40,7 @@
thread_runs,
threads,
uploads,
+ user_preferences,
)
from app.gateway.trace_middleware import TraceMiddleware, resolve_trace_enabled
from deerflow.config import app_config as deerflow_app_config
@@ -841,6 +842,9 @@ def _resolve_extension_principal(request):
# Auth API is mounted at /api/v1/auth
app.include_router(auth.router)
+ # Authenticated user-level UI settings (server-backed, owner-scoped)
+ app.include_router(user_preferences.router)
+
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
app.include_router(feedback.router)
diff --git a/backend/app/gateway/auth/repositories/base.py b/backend/app/gateway/auth/repositories/base.py
index 3ec18f75cd4..8b3e9af0081 100644
--- a/backend/app/gateway/auth/repositories/base.py
+++ b/backend/app/gateway/auth/repositories/base.py
@@ -1,6 +1,7 @@
"""User repository interface for abstracting database operations."""
from abc import ABC, abstractmethod
+from collections.abc import Callable
from app.gateway.auth.models import User
@@ -15,6 +16,18 @@ class UserNotFoundError(LookupError):
"""
+class UserPreferencesNotInitializedError(LookupError):
+ """Raised when a partial preference update precedes initialization."""
+
+
+class UserPreferencesInvalidError(LookupError):
+ """Raised when a stored preference record fails the public schema."""
+
+
+class UserPreferencesWriteConflict(RuntimeError):
+ """Raised after bounded optimistic preference-update retries fail."""
+
+
class UserRepository(ABC):
"""Abstract interface for user data storage.
@@ -105,3 +118,30 @@ async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
User if found, None otherwise
"""
raise NotImplementedError
+
+ @abstractmethod
+ async def get_user_preferences(self, user_id: str) -> tuple[object | None, int]:
+ """Return the user's persisted UI preferences and revision."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def initialize_user_preferences(
+ self,
+ user_id: str,
+ settings: dict,
+ *,
+ existing_is_valid: Callable[[object], bool] | None = None,
+ ) -> tuple[dict, int]:
+ """Persist settings when absent, or repair a schema-invalid record."""
+ raise NotImplementedError
+
+ @abstractmethod
+ async def merge_user_preferences(
+ self,
+ user_id: str,
+ patch: dict,
+ *,
+ current_is_valid: Callable[[object], bool] | None = None,
+ ) -> tuple[dict, int]:
+ """Atomically deep-merge a validated partial preference update."""
+ raise NotImplementedError
diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py
index c9adeac8e69..76208745529 100644
--- a/backend/app/gateway/auth/repositories/sqlite.py
+++ b/backend/app/gateway/auth/repositories/sqlite.py
@@ -12,17 +12,34 @@
from __future__ import annotations
+import asyncio
+import random
+import sqlite3
+from collections.abc import Callable
+from copy import deepcopy
from datetime import UTC
from uuid import UUID
-from sqlalchemy import func, select
-from sqlalchemy.exc import IntegrityError
+from sqlalchemy import Text, cast, func, or_, select, update
+from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+from sqlalchemy.sql.elements import ColumnElement
from app.gateway.auth.models import User
-from app.gateway.auth.repositories.base import UserNotFoundError, UserRepository
+from app.gateway.auth.repositories.base import (
+ UserNotFoundError,
+ UserPreferencesInvalidError,
+ UserPreferencesNotInitializedError,
+ UserPreferencesWriteConflict,
+ UserRepository,
+)
from deerflow.persistence.user.model import UserRow
+_PREFERENCE_WRITE_MAX_ATTEMPTS = 5
+_PREFERENCE_WRITE_RETRY_BASE_SECONDS = 0.005
+_PREFERENCE_WRITE_RETRY_MAX_SECONDS = 0.05
+_SQL_NULL_PREFERENCES = object()
+
def _normalize_email(email: str) -> str:
"""Canonicalise an email address for storage and lookup.
@@ -43,6 +60,40 @@ def _normalize_email(email: str) -> str:
return email.lower()
+def _preferences_are_uninitialized() -> ColumnElement[bool]:
+ """Match both SQL NULL and the JSON literal ``null`` portably.
+
+ SQLAlchemy's generic JSON type serializes Python ``None`` as JSON ``null``
+ by default, and both values deserialize back to ``None``. PostgreSQL's
+ ``json`` type has no equality operator, so compare its text representation
+ instead of using ``UserRow.preferences == JSON.NULL``. The primary-key
+ predicate on the caller keeps this cast off any broad table scan.
+ """
+ return or_(
+ UserRow.preferences.is_(None),
+ cast(UserRow.preferences, Text) == "null",
+ )
+
+
+def _user_preferences_select(user_id: str):
+ """Select preferences with an explicit SQL-NULL marker.
+
+ PostgreSQL drivers decode both SQL NULL and JSON ``null`` to Python None.
+ Keeping the database predicate beside the value preserves that distinction
+ independently of DBAPI JSON decoding.
+ """
+ return select(
+ UserRow.preferences,
+ UserRow.preferences_revision,
+ UserRow.preferences.is_(None).label("preferences_is_sql_null"),
+ ).where(UserRow.id == user_id)
+
+
+def _unpack_user_preferences_row(row) -> tuple[object, int]:
+ preferences, revision, is_sql_null = row
+ return (_SQL_NULL_PREFERENCES if is_sql_null else preferences), int(revision)
+
+
class SQLiteUserRepository(UserRepository):
"""Async user repository backed by the shared SQLAlchemy engine."""
@@ -179,3 +230,184 @@ async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None
+
+ async def get_user_preferences(self, user_id: str) -> tuple[object | None, int]:
+ stmt = _user_preferences_select(user_id)
+ async with self._sf() as session:
+ row = (await session.execute(stmt)).one_or_none()
+ if row is None:
+ raise UserNotFoundError(f"User {user_id} no longer exists")
+ preferences, revision = _unpack_user_preferences_row(row)
+ if preferences is _SQL_NULL_PREFERENCES:
+ preferences = None
+ return deepcopy(preferences), revision
+
+ async def initialize_user_preferences(
+ self,
+ user_id: str,
+ settings: dict,
+ *,
+ existing_is_valid: Callable[[object], bool] | None = None,
+ ) -> tuple[dict, int]:
+ """Set the first preference record without overwriting another client.
+
+ The conditional update is the cross-process arbiter: two tabs or Gateway
+ workers can both observe NULL, but only one can change it. The loser
+ reads and returns the winner's server value.
+ """
+ candidate = deepcopy(settings)
+ for attempt in range(_PREFERENCE_WRITE_MAX_ATTEMPTS):
+ async with self._sf() as session:
+ dialect_name = session.get_bind().dialect.name
+ try:
+ result = await session.execute(
+ update(UserRow)
+ .where(UserRow.id == user_id, _preferences_are_uninitialized())
+ .values(
+ preferences=candidate,
+ preferences_revision=UserRow.preferences_revision + 1,
+ )
+ )
+ if result.rowcount == 0:
+ row = (await session.execute(_user_preferences_select(user_id))).one_or_none()
+ if row is None:
+ raise UserNotFoundError(f"User {user_id} no longer exists")
+ existing, revision = _unpack_user_preferences_row(row)
+ if existing is _SQL_NULL_PREFERENCES or existing is None:
+ # A concurrent transaction may have lost before committing;
+ # let a normal retry from the client resolve that rare race.
+ raise UserPreferencesWriteConflict(f"Preferences for user {user_id} are being initialized")
+ if existing_is_valid is not None and not existing_is_valid(existing):
+ repaired = await session.execute(
+ update(UserRow)
+ .where(
+ UserRow.id == user_id,
+ UserRow.preferences_revision == revision,
+ )
+ .values(
+ preferences=candidate,
+ preferences_revision=revision + 1,
+ )
+ )
+ if repaired.rowcount != 1:
+ await session.rollback()
+ winner_row = (await session.execute(_user_preferences_select(user_id))).one_or_none()
+ if winner_row is None:
+ raise UserNotFoundError(f"User {user_id} no longer exists")
+ winner, winner_revision = _unpack_user_preferences_row(winner_row)
+ if winner is _SQL_NULL_PREFERENCES or winner is None:
+ raise UserPreferencesWriteConflict(f"Preferences for user {user_id} are being initialized")
+ if existing_is_valid(winner):
+ return deepcopy(winner), winner_revision
+ raise UserPreferencesWriteConflict(f"Preferences for user {user_id} changed while being repaired")
+ await session.commit()
+ return candidate, revision + 1
+ return deepcopy(existing), revision
+
+ row = (await session.execute(_user_preferences_select(user_id))).one()
+ await session.commit()
+ stored, revision = _unpack_user_preferences_row(row)
+ return deepcopy(stored), revision
+ except OperationalError as exc:
+ # A stale SQLite WAL snapshot cannot be upgraded after a
+ # competing initializer/repair commits. Restart the whole
+ # transaction so this client can observe and return the
+ # first writer's value. Other database errors keep their
+ # existing semantics.
+ if dialect_name != "sqlite" or not _is_sqlite_busy_snapshot_error(exc):
+ raise
+ await session.rollback()
+
+ if attempt + 1 < _PREFERENCE_WRITE_MAX_ATTEMPTS:
+ await _sleep_before_preference_retry(attempt)
+
+ raise UserPreferencesWriteConflict(f"Concurrent preference initialization for user {user_id} did not settle")
+
+ async def merge_user_preferences(
+ self,
+ user_id: str,
+ patch: dict,
+ *,
+ current_is_valid: Callable[[object], bool] | None = None,
+ ) -> tuple[dict, int]:
+ """Merge with an optimistic revision CAS supported by SQLite/Postgres."""
+ for attempt in range(_PREFERENCE_WRITE_MAX_ATTEMPTS):
+ async with self._sf() as session:
+ dialect_name = session.get_bind().dialect.name
+ try:
+ row = (await session.execute(_user_preferences_select(user_id))).one_or_none()
+ if row is None:
+ raise UserNotFoundError(f"User {user_id} no longer exists")
+ current, revision = _unpack_user_preferences_row(row)
+ if current is _SQL_NULL_PREFERENCES or current is None:
+ raise UserPreferencesNotInitializedError(f"Preferences for user {user_id} have not been initialized")
+ if current_is_valid is not None and not current_is_valid(current):
+ raise UserPreferencesInvalidError(f"Preferences for user {user_id} do not match the current schema")
+
+ merged = _merge_preferences(current, patch)
+ result = await session.execute(
+ update(UserRow)
+ .where(
+ UserRow.id == user_id,
+ UserRow.preferences_revision == revision,
+ )
+ .values(
+ preferences=merged,
+ preferences_revision=revision + 1,
+ )
+ )
+ if result.rowcount == 1:
+ await session.commit()
+ return merged, revision + 1
+ await session.rollback()
+ except OperationalError as exc:
+ # In WAL mode, a transaction that SELECTed before another
+ # writer committed cannot upgrade its stale read snapshot.
+ # SQLite reports SQLITE_BUSY_SNAPSHOT immediately; the
+ # connection busy_timeout cannot make that snapshot valid.
+ # Roll back the snapshot and rerun the complete read/merge/
+ # CAS cycle so disjoint client patches are not lost. Other
+ # SQLite failures and every Postgres failure retain their
+ # existing error semantics.
+ if dialect_name != "sqlite" or not _is_sqlite_busy_snapshot_error(exc):
+ raise
+ await session.rollback()
+
+ if attempt + 1 < _PREFERENCE_WRITE_MAX_ATTEMPTS:
+ await _sleep_before_preference_retry(attempt)
+
+ raise UserPreferencesWriteConflict(f"Concurrent preference updates for user {user_id} did not settle")
+
+
+def _is_sqlite_busy_snapshot_error(exc: OperationalError) -> bool:
+ """Retry only an immediately returned stale WAL snapshot error.
+
+ Ordinary SQLITE_BUSY already waited for the configured SQLite busy timeout;
+ another application-level retry loop could turn one 30-second wait into
+ roughly 150 seconds. Adapters that omit the extended result code stay
+ fail-closed because their generic "database is locked" text cannot safely
+ distinguish BUSY_SNAPSHOT from a completed busy-timeout wait.
+ """
+ return getattr(exc.orig, "sqlite_errorcode", None) == sqlite3.SQLITE_BUSY_SNAPSHOT
+
+
+async def _sleep_before_preference_retry(attempt: int) -> None:
+ """Yield with bounded exponential jitter before retrying a preference CAS."""
+ ceiling = min(
+ _PREFERENCE_WRITE_RETRY_BASE_SECONDS * (2**attempt),
+ _PREFERENCE_WRITE_RETRY_MAX_SECONDS,
+ )
+ await asyncio.sleep(random.uniform(ceiling / 2, ceiling))
+
+
+def _merge_preferences(current: dict, patch: dict) -> dict:
+ """Deep-merge allowlisted sections; JSON null clears optional fields."""
+ merged = deepcopy(current)
+ for section, values in patch.items():
+ target = merged.setdefault(section, {})
+ for key, value in values.items():
+ if value is None:
+ target.pop(key, None)
+ else:
+ target[key] = deepcopy(value)
+ return merged
diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py
index aba322aea5a..25fcf73d751 100644
--- a/backend/app/gateway/deps.py
+++ b/backend/app/gateway/deps.py
@@ -752,6 +752,13 @@ def get_local_provider() -> LocalAuthProvider:
return _cached_local_provider
+def get_user_repository() -> SQLiteUserRepository:
+ """Return the shared SQL-backed user repository after engine startup."""
+ get_local_provider()
+ assert _cached_repo is not None
+ return _cached_repo
+
+
async def get_current_user_from_request(request: Request):
"""Get the current authenticated user from the request cookie.
diff --git a/backend/app/gateway/routers/user_preferences.py b/backend/app/gateway/routers/user_preferences.py
new file mode 100644
index 00000000000..fdc2fad5944
--- /dev/null
+++ b/backend/app/gateway/routers/user_preferences.py
@@ -0,0 +1,230 @@
+"""Authenticated, server-backed user-level UI preferences."""
+
+from __future__ import annotations
+
+import logging
+from typing import Annotated, Literal
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError, model_validator
+
+from app.gateway.auth.repositories.base import (
+ UserNotFoundError,
+ UserPreferencesInvalidError,
+ UserPreferencesNotInitializedError,
+ UserPreferencesWriteConflict,
+)
+from app.gateway.deps import get_current_user_from_request, get_user_repository
+
+router = APIRouter(prefix="/api/user-preferences", tags=["user-preferences"])
+logger = logging.getLogger(__name__)
+
+MAX_USER_PREFERENCES_BYTES = 2048
+EXPECTED_USER_ID_HEADER = "X-DeerFlow-Expected-User-Id"
+ModelName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=256)]
+
+
+def _is_none(value: object) -> bool:
+ return value is None
+
+
+class _StrictModel(BaseModel):
+ model_config = ConfigDict(extra="forbid", strict=True)
+
+
+class NotificationPreferences(_StrictModel):
+ enabled: bool
+
+
+class TokenUsagePreferences(_StrictModel):
+ headerTotal: bool
+ inlineMode: Literal["off", "per_turn", "step_debug"]
+
+
+class ContextPreferences(_StrictModel):
+ model_name: ModelName | None = Field(default=None, exclude_if=_is_none)
+ mode: Literal["flash", "thinking", "pro", "ultra"] | None = Field(default=None, exclude_if=_is_none)
+ reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = Field(
+ default=None,
+ exclude_if=_is_none,
+ )
+
+
+class UserPreferences(_StrictModel):
+ notification: NotificationPreferences
+ tokenUsage: TokenUsagePreferences
+ context: ContextPreferences
+
+ @model_validator(mode="after")
+ def enforce_size_limit(self) -> UserPreferences:
+ if len(self.model_dump_json(exclude_none=True).encode("utf-8")) > MAX_USER_PREFERENCES_BYTES:
+ raise ValueError("User preferences exceed the size limit")
+ return self
+
+ def to_storage_dict(self) -> dict:
+ return self.model_dump(exclude_none=True)
+
+
+class NotificationPreferencesPatch(_StrictModel):
+ enabled: bool | None = None
+
+ @model_validator(mode="after")
+ def require_field(self) -> NotificationPreferencesPatch:
+ if not self.model_fields_set:
+ raise ValueError("At least one notification preference is required")
+ if self.enabled is None:
+ raise ValueError("notification.enabled cannot be null")
+ return self
+
+
+class TokenUsagePreferencesPatch(_StrictModel):
+ headerTotal: bool | None = None
+ inlineMode: Literal["off", "per_turn", "step_debug"] | None = None
+
+ @model_validator(mode="after")
+ def require_field(self) -> TokenUsagePreferencesPatch:
+ if not self.model_fields_set:
+ raise ValueError("At least one token-usage preference is required")
+ if "headerTotal" in self.model_fields_set and self.headerTotal is None:
+ raise ValueError("tokenUsage.headerTotal cannot be null")
+ if "inlineMode" in self.model_fields_set and self.inlineMode is None:
+ raise ValueError("tokenUsage.inlineMode cannot be null")
+ return self
+
+
+class ContextPreferencesPatch(_StrictModel):
+ model_name: ModelName | None = None
+ mode: Literal["flash", "thinking", "pro", "ultra"] | None = None
+ reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
+
+ @model_validator(mode="after")
+ def require_field(self) -> ContextPreferencesPatch:
+ if not self.model_fields_set:
+ raise ValueError("At least one context preference is required")
+ return self
+
+
+class UserPreferencesPatchRequest(_StrictModel):
+ notification: NotificationPreferencesPatch | None = None
+ tokenUsage: TokenUsagePreferencesPatch | None = None
+ context: ContextPreferencesPatch | None = None
+
+ @model_validator(mode="after")
+ def require_patch_and_enforce_size(self) -> UserPreferencesPatchRequest:
+ if not self.model_fields_set:
+ raise ValueError("At least one preference section is required")
+ if any(getattr(self, field) is None for field in self.model_fields_set):
+ raise ValueError("Preference sections cannot be null")
+ if len(self.model_dump_json(exclude_unset=True).encode("utf-8")) > MAX_USER_PREFERENCES_BYTES:
+ raise ValueError("User preferences patch exceeds the size limit")
+ return self
+
+ def to_storage_patch(self) -> dict:
+ return self.model_dump(exclude_unset=True)
+
+
+class UserPreferencesInitializeRequest(_StrictModel):
+ settings: UserPreferences
+
+
+class UserPreferencesResponse(_StrictModel):
+ settings: UserPreferences | None
+ revision: int = Field(ge=0)
+
+
+def _stored_preferences_are_valid(settings: object) -> bool:
+ try:
+ UserPreferences.model_validate(settings)
+ except ValidationError:
+ return False
+ return True
+
+
+def _response(settings: object | None, revision: int) -> UserPreferencesResponse:
+ try:
+ validated = UserPreferences.model_validate(settings) if settings is not None else None
+ except ValidationError:
+ # A stale deployment or a manual database edit can leave JSON that no
+ # longer matches the public allowlist. Treat it like an absent record
+ # while preserving the revision; the normal PUT path then repairs it
+ # with a revision-CAS instead of trapping this session in a 500 loop.
+ logger.warning("Ignoring schema-invalid stored user preferences at revision %s", revision)
+ validated = None
+ return UserPreferencesResponse(settings=validated, revision=revision)
+
+
+def _translate_repository_error(exc: Exception) -> HTTPException:
+ if isinstance(exc, UserNotFoundError):
+ return HTTPException(status_code=404, detail="User not found")
+ if isinstance(exc, UserPreferencesNotInitializedError):
+ return HTTPException(status_code=409, detail="User preferences must be initialized before partial updates")
+ if isinstance(exc, UserPreferencesInvalidError):
+ return HTTPException(status_code=409, detail="Stored user preferences are invalid; initialize them before partial updates")
+ if isinstance(exc, UserPreferencesWriteConflict):
+ return HTTPException(status_code=409, detail="Concurrent user-preference update; retry the request")
+ raise exc
+
+
+async def _get_guarded_user(request: Request):
+ """Resolve the authenticated owner and reject a tab with a stale session.
+
+ Browser cookies are origin-wide. If another tab signs into a different
+ account, a still-mounted settings controller retains its old user id while
+ subsequent requests carry the new cookie. The expected id is only a guard;
+ repository ownership always comes from the authenticated request.
+ """
+ user = await get_current_user_from_request(request)
+ expected_user_id = request.headers.get(EXPECTED_USER_ID_HEADER)
+ if expected_user_id is not None and expected_user_id != str(user.id):
+ raise HTTPException(status_code=409, detail="Authenticated user changed; reload before synchronizing settings")
+ return user
+
+
+@router.get("", response_model=UserPreferencesResponse)
+async def get_user_preferences(request: Request) -> UserPreferencesResponse:
+ """Return preferences for the authenticated user only."""
+ user = await _get_guarded_user(request)
+ repository = get_user_repository()
+ try:
+ settings, revision = await repository.get_user_preferences(str(user.id))
+ return _response(settings, revision)
+ except Exception as exc:
+ raise _translate_repository_error(exc) from exc
+
+
+@router.put("", response_model=UserPreferencesResponse)
+async def initialize_user_preferences(
+ body: UserPreferencesInitializeRequest,
+ request: Request,
+) -> UserPreferencesResponse:
+ """First-writer-wins import of the legacy local base settings."""
+ user = await _get_guarded_user(request)
+ repository = get_user_repository()
+ try:
+ settings, revision = await repository.initialize_user_preferences(
+ str(user.id),
+ body.settings.to_storage_dict(),
+ existing_is_valid=_stored_preferences_are_valid,
+ )
+ return _response(settings, revision)
+ except Exception as exc:
+ raise _translate_repository_error(exc) from exc
+
+
+@router.patch("", response_model=UserPreferencesResponse)
+async def patch_user_preferences(
+ body: UserPreferencesPatchRequest,
+ request: Request,
+) -> UserPreferencesResponse:
+ """Deep-merge an allowlisted patch for the authenticated user."""
+ user = await _get_guarded_user(request)
+ repository = get_user_repository()
+ try:
+ settings, revision = await repository.merge_user_preferences(
+ str(user.id),
+ body.to_storage_patch(),
+ current_is_valid=_stored_preferences_are_valid,
+ )
+ return _response(settings, revision)
+ except Exception as exc:
+ raise _translate_repository_error(exc) from exc
diff --git a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
index 99dcbf1f2b0..45339ea1315 100644
--- a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
+++ b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md
@@ -86,6 +86,7 @@ on installs that never enabled it. The convention is:
- `migrations/versions/0014_managed_subagents.py` — creates the deployment-level managed Subagent catalog table
- `migrations/versions/0015_scheduled_task_enqueue.py` — interrupts legacy transient queued rows, adds durable scheduled-run launch leases and attempt counts, expands the one-active-occurrence index to `queued`/`launching`/`running`, and migrates the overlap policy from `skip` to `enqueue`; chains after `0014_managed_subagents`
- `migrations/versions/0016_subagent_batches.py` — creates durable native-subagent batch and item tables, including owner/submission idempotency, item identity, lease/recovery state, and result fields
+- `migrations/versions/0017_user_preferences.py` — extends `0016_subagent_batches` with nullable user-level preference JSON plus its optimistic concurrency revision; both columns use idempotent add/drop helpers
- `persistence/bootstrap.py` — `bootstrap_schema(engine, backend=...)`, the three-branch decision + locking
- `extensions/loader.py::load_extensions` — registers each spec's `table_prefix` with `register_extension_table_prefix()`
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter, including extension-owned tables), `tests/test_extension_loader.py::TestTablePrefixRegistration` (spec-to-filter wiring), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps)
diff --git a/backend/packages/harness/deerflow/persistence/migrations/versions/0017_user_preferences.py b/backend/packages/harness/deerflow/persistence/migrations/versions/0017_user_preferences.py
new file mode 100644
index 00000000000..e892cd01bac
--- /dev/null
+++ b/backend/packages/harness/deerflow/persistence/migrations/versions/0017_user_preferences.py
@@ -0,0 +1,39 @@
+"""Add durable user-level UI preferences.
+
+Revision ID: 0017_user_preferences
+Revises: 0016_subagent_batches
+Create Date: 2026-08-23
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+revision: str = "0017_user_preferences"
+down_revision: str | Sequence[str] | None = "0016_subagent_batches"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ from deerflow.persistence.migrations._helpers import safe_add_column
+
+ safe_add_column("users", sa.Column("preferences", sa.JSON(), nullable=True))
+ safe_add_column(
+ "users",
+ sa.Column(
+ "preferences_revision",
+ sa.Integer(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+
+def downgrade() -> None:
+ from deerflow.persistence.migrations._helpers import safe_drop_column
+
+ safe_drop_column("users", "preferences_revision")
+ safe_drop_column("users", "preferences")
diff --git a/backend/packages/harness/deerflow/persistence/user/model.py b/backend/packages/harness/deerflow/persistence/user/model.py
index 130d4bfcba3..157e171ce74 100644
--- a/backend/packages/harness/deerflow/persistence/user/model.py
+++ b/backend/packages/harness/deerflow/persistence/user/model.py
@@ -11,14 +11,58 @@
from __future__ import annotations
+from collections.abc import Callable
from datetime import UTC, datetime
+from typing import Any
-from sqlalchemy import Boolean, DateTime, Index, String, text
+from sqlalchemy import JSON, Boolean, DateTime, Index, Integer, String, text
+from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.types import TypeDecorator
from deerflow.persistence.base import Base
+class MalformedUserPreferences:
+ """Opaque marker returned when a legacy SQLite JSON value cannot decode."""
+
+ __slots__ = ()
+
+ def __deepcopy__(self, memo: dict[int, object]) -> MalformedUserPreferences:
+ return self
+
+
+MALFORMED_USER_PREFERENCES = MalformedUserPreferences()
+
+
+class LenientUserPreferencesJSON(TypeDecorator[object]):
+ """JSON storage that contains malformed preference rows at the read edge."""
+
+ impl = JSON
+ cache_ok = True
+
+ def result_processor(
+ self,
+ dialect: Dialect,
+ coltype: object,
+ ) -> Callable[[Any], Any] | None:
+ # TypeDecorator.process_result_value runs only *after* the inner JSON
+ # decoder, so it cannot catch malformed legacy SQLite text. Wrap that
+ # decoder directly for this one non-critical preference column; valid
+ # JSON and all bind/write behavior still use SQLAlchemy's JSON type.
+ impl_processor = self.impl_instance.result_processor(dialect, coltype)
+ if impl_processor is None:
+ return None
+
+ def process(value: Any) -> Any:
+ try:
+ return impl_processor(value)
+ except (TypeError, ValueError):
+ return MALFORMED_USER_PREFERENCES
+
+ return process
+
+
class UserRow(Base):
__tablename__ = "users"
@@ -48,6 +92,14 @@ class UserRow(Base):
needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
token_version: Mapped[int] = mapped_column(nullable=False, default=0)
+ # Browser-safe, user-level UI preferences. The API owns a strict allowlist;
+ # this JSON column must never receive credentials, browser permission state,
+ # or thread/workspace-scoped data. NULL distinguishes "never migrated" from
+ # a stored preference object so the frontend can perform a one-time import
+ # from its legacy localStorage value.
+ preferences: Mapped[object | None] = mapped_column(LenientUserPreferencesJSON(), nullable=True)
+ preferences_revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
+
__table_args__ = (
Index(
"idx_users_oauth_identity",
diff --git a/backend/tests/test_migration_0004_run_ownership_dedupe.py b/backend/tests/test_migration_0004_run_ownership_dedupe.py
index dbda04f7ff6..e7c622fc94f 100644
--- a/backend/tests/test_migration_0004_run_ownership_dedupe.py
+++ b/backend/tests/test_migration_0004_run_ownership_dedupe.py
@@ -157,7 +157,7 @@ async def test_migration_dedupes_duplicate_active_rows_before_unique_index(tmp_p
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
# Bootstrap upgrades through the later revisions after 0004.
- assert version_row[0] == "0016_subagent_batches"
+ assert version_row[0] == "0017_user_preferences"
# Sanity: the invariant the index enforces is now true — at most one
# active row per thread.
diff --git a/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py b/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
index aabd312e52e..791a079129e 100644
--- a/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
+++ b/backend/tests/test_migration_0007_scheduled_run_active_dedupe.py
@@ -173,7 +173,7 @@ async def test_migration_supersedes_duplicate_active_runs_before_unique_index(tm
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0016_subagent_batches"
+ assert version_row[0] == "0017_user_preferences"
# Sanity: the invariant the index enforces now holds — at most one
# active row per task_id.
diff --git a/backend/tests/test_migration_0015_scheduled_task_enqueue.py b/backend/tests/test_migration_0015_scheduled_task_enqueue.py
index 513ac776756..d12a82651eb 100644
--- a/backend/tests/test_migration_0015_scheduled_task_enqueue.py
+++ b/backend/tests/test_migration_0015_scheduled_task_enqueue.py
@@ -57,7 +57,7 @@ async def test_migration_interrupts_legacy_queue_and_adds_claim_fields(tmp_path:
# Bootstrap always advances to the repository head after exercising
# the 0015 migration behavior below.
- assert version == "0016_subagent_batches"
+ assert version == "0017_user_preferences"
assert {"lease_owner", "lease_expires_at", "attempt_count"} <= columns.keys()
assert columns["attempt_count"]["nullable"] is False
assert overlap_policy == "enqueue"
diff --git a/backend/tests/test_persistence_bootstrap.py b/backend/tests/test_persistence_bootstrap.py
index 23548e636a9..c181147609f 100644
--- a/backend/tests/test_persistence_bootstrap.py
+++ b/backend/tests/test_persistence_bootstrap.py
@@ -48,7 +48,7 @@
asyncio_test = pytest.mark.asyncio
-HEAD = "0016_subagent_batches"
+HEAD = "0017_user_preferences"
BASELINE = "0001_baseline"
diff --git a/backend/tests/test_persistence_bootstrap_concurrency.py b/backend/tests/test_persistence_bootstrap_concurrency.py
index 36f703fb5e6..2219f6ccb27 100644
--- a/backend/tests/test_persistence_bootstrap_concurrency.py
+++ b/backend/tests/test_persistence_bootstrap_concurrency.py
@@ -28,7 +28,7 @@
pytestmark = pytest.mark.asyncio
-HEAD = "0016_subagent_batches"
+HEAD = "0017_user_preferences"
def _url(tmp_path: Path) -> str:
diff --git a/backend/tests/test_persistence_bootstrap_regression.py b/backend/tests/test_persistence_bootstrap_regression.py
index 46aded16a56..a25d2cb6d41 100644
--- a/backend/tests/test_persistence_bootstrap_regression.py
+++ b/backend/tests/test_persistence_bootstrap_regression.py
@@ -76,7 +76,7 @@ async def test_legacy_database_recovers_token_usage_column(tmp_path: Path) -> No
cols = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()}
assert "token_usage_by_model" in cols
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0016_subagent_batches"
+ assert version_row[0] == "0017_user_preferences"
# And the read path that originally 500'd must now succeed.
sf = get_session_factory()
@@ -116,6 +116,6 @@ async def test_legacy_database_with_manual_alter_still_bootstraps(tmp_path: Path
# No duplicate column -- list, not set, to catch dupes.
assert cols.count("token_usage_by_model") == 1
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
- assert version_row[0] == "0016_subagent_batches"
+ assert version_row[0] == "0017_user_preferences"
finally:
await close_engine()
diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py
new file mode 100644
index 00000000000..13af5ad4519
--- /dev/null
+++ b/backend/tests/test_user_preferences.py
@@ -0,0 +1,1100 @@
+"""User-level settings persistence contract (issue #2595)."""
+
+from __future__ import annotations
+
+import asyncio
+import importlib.util
+import sqlite3
+from pathlib import Path
+from types import ModuleType, SimpleNamespace
+from unittest.mock import AsyncMock
+from uuid import uuid4
+
+import pytest
+import pytest_asyncio
+import sqlalchemy as sa
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from fastapi import FastAPI, HTTPException
+from fastapi.testclient import TestClient
+from pydantic import ValidationError
+from sqlalchemy.dialects import postgresql
+from sqlalchemy.dialects import sqlite as sqlite_dialect
+
+from app.gateway.auth.models import User
+from app.gateway.auth.repositories import sqlite as sqlite_repository
+from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
+from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
+from app.gateway.routers import user_preferences as user_preferences_router
+from app.gateway.routers.user_preferences import (
+ EXPECTED_USER_ID_HEADER,
+ UserPreferencesInitializeRequest,
+ UserPreferencesPatchRequest,
+ get_user_preferences,
+ initialize_user_preferences,
+ patch_user_preferences,
+)
+from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+from deerflow.persistence.user.model import MALFORMED_USER_PREFERENCES, UserRow
+
+
+def _full_preferences(*, model_name: str = "model-a") -> dict:
+ return {
+ "notification": {"enabled": True},
+ "tokenUsage": {"headerTotal": True, "inlineMode": "per_turn"},
+ "context": {
+ "model_name": model_name,
+ "mode": "thinking",
+ "reasoning_effort": "medium",
+ },
+ }
+
+
+@pytest_asyncio.fixture
+async def user_repository(tmp_path: Path):
+ db_path = tmp_path / "preferences.db"
+ await init_engine(
+ "sqlite",
+ url=f"sqlite+aiosqlite:///{db_path}",
+ sqlite_dir=str(tmp_path),
+ )
+ try:
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ yield SQLiteUserRepository(session_factory)
+ finally:
+ await close_engine()
+
+
+async def _create_user(repository: SQLiteUserRepository, email: str) -> User:
+ user = User(id=uuid4(), email=email, password_hash="hash")
+ await repository.create_user(user)
+ return user
+
+
+async def _store_raw_preferences(user_id: str, settings: object, revision: int) -> None:
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ async with session_factory() as session:
+ await session.execute(
+ sa.update(UserRow).where(UserRow.id == user_id).values(preferences=settings, preferences_revision=revision),
+ )
+ await session.commit()
+
+
+async def _store_malformed_preferences(user_id: str, raw_settings: str, revision: int) -> None:
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ async with session_factory() as session:
+ await session.execute(
+ sa.text(
+ "UPDATE users SET preferences = :preferences, preferences_revision = :revision WHERE id = :user_id",
+ ),
+ {
+ "preferences": raw_settings,
+ "revision": revision,
+ "user_id": user_id,
+ },
+ )
+ await session.commit()
+
+
+async def _read_raw_preferences(user_id: str) -> tuple[object, bool, int]:
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ async with session_factory() as session:
+ return (
+ await session.execute(
+ sa.text(
+ "SELECT preferences, preferences IS NULL, preferences_revision FROM users WHERE id = :user_id",
+ ),
+ {"user_id": user_id},
+ )
+ ).one()
+
+
+def _load_user_preferences_migration() -> ModuleType:
+ migration_path = Path(__file__).parents[1] / "packages" / "harness" / "deerflow" / "persistence" / "migrations" / "versions" / "0017_user_preferences.py"
+ spec = importlib.util.spec_from_file_location("migration_0017_user_preferences", migration_path)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.mark.asyncio
+async def test_preferences_are_isolated_by_authenticated_user(user_repository: SQLiteUserRepository) -> None:
+ alice = await _create_user(user_repository, "alice@example.com")
+ bob = await _create_user(user_repository, "bob@example.com")
+
+ await user_repository.initialize_user_preferences(str(alice.id), _full_preferences(model_name="alice-model"))
+ await user_repository.initialize_user_preferences(str(bob.id), _full_preferences(model_name="bob-model"))
+
+ alice_preferences, _alice_revision = await user_repository.get_user_preferences(str(alice.id))
+ bob_preferences, _bob_revision = await user_repository.get_user_preferences(str(bob.id))
+
+ assert alice_preferences is not None
+ assert bob_preferences is not None
+ assert alice_preferences["context"]["model_name"] == "alice-model"
+ assert bob_preferences["context"]["model_name"] == "bob-model"
+
+
+@pytest.mark.asyncio
+async def test_initialization_is_first_writer_wins(user_repository: SQLiteUserRepository) -> None:
+ user = await _create_user(user_repository, "migration@example.com")
+
+ (first, first_revision), (second, second_revision) = await asyncio.gather(
+ user_repository.initialize_user_preferences(
+ str(user.id),
+ _full_preferences(model_name="first"),
+ ),
+ user_repository.initialize_user_preferences(
+ str(user.id),
+ _full_preferences(model_name="second"),
+ ),
+ )
+
+ assert first["context"]["model_name"] in {"first", "second"}
+ assert second["context"]["model_name"] == first["context"]["model_name"]
+ assert first_revision == 1
+ assert second_revision == first_revision
+
+
+@pytest.mark.asyncio
+async def test_json_null_initialization_is_first_writer_wins(user_repository: SQLiteUserRepository) -> None:
+ user = await _create_user(user_repository, "json-null-race@example.com")
+ user_id = str(user.id)
+ await _store_raw_preferences(user_id, sa.JSON.NULL, revision=7)
+
+ (first, first_revision), (second, second_revision) = await asyncio.gather(
+ user_repository.initialize_user_preferences(
+ user_id,
+ _full_preferences(model_name="first-json-null"),
+ ),
+ user_repository.initialize_user_preferences(
+ user_id,
+ _full_preferences(model_name="second-json-null"),
+ ),
+ )
+
+ assert first["context"]["model_name"] in {"first-json-null", "second-json-null"}
+ assert second == first
+ assert first_revision == 8
+ assert second_revision == first_revision
+ stored, revision = await user_repository.get_user_preferences(user_id)
+ assert (stored, revision) == (first, 8)
+
+
+@pytest.mark.asyncio
+async def test_partial_update_merges_nested_sections(user_repository: SQLiteUserRepository) -> None:
+ user = await _create_user(user_repository, "merge@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+
+ merged, revision = await user_repository.merge_user_preferences(
+ str(user.id),
+ {
+ "tokenUsage": {"inlineMode": "step_debug"},
+ "context": {"reasoning_effort": "high"},
+ },
+ )
+
+ assert revision == 2
+ assert merged == {
+ "notification": {"enabled": True},
+ "tokenUsage": {"headerTotal": True, "inlineMode": "step_debug"},
+ "context": {
+ "model_name": "model-a",
+ "mode": "thinking",
+ "reasoning_effort": "high",
+ },
+ }
+
+
+@pytest.mark.asyncio
+async def test_patch_can_clear_optional_context_values(user_repository: SQLiteUserRepository) -> None:
+ user = await _create_user(user_repository, "clear@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+
+ merged, _revision = await user_repository.merge_user_preferences(
+ str(user.id),
+ {"context": {"model_name": None, "reasoning_effort": None}},
+ )
+
+ assert "model_name" not in merged["context"]
+ assert "reasoning_effort" not in merged["context"]
+ assert merged["context"]["mode"] == "thinking"
+
+
+@pytest.mark.asyncio
+async def test_concurrent_disjoint_updates_do_not_lose_fields(user_repository: SQLiteUserRepository) -> None:
+ user = await _create_user(user_repository, "concurrent@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+
+ await asyncio.gather(
+ user_repository.merge_user_preferences(
+ str(user.id),
+ {"notification": {"enabled": False}},
+ ),
+ user_repository.merge_user_preferences(
+ str(user.id),
+ {"tokenUsage": {"inlineMode": "off"}},
+ ),
+ )
+
+ stored, revision = await user_repository.get_user_preferences(str(user.id))
+ assert stored is not None
+ assert stored["notification"]["enabled"] is False
+ assert stored["tokenUsage"]["inlineMode"] == "off"
+ assert revision == 3
+
+
+class _PreferenceReadBarrier:
+ """Hold the first two preference readers on distinct SQLite snapshots."""
+
+ def __init__(self) -> None:
+ self._arrivals = 0
+ self._ready = asyncio.Event()
+ self._writer_committed = asyncio.Event()
+ self.connection_ids: set[int] = set()
+
+ async def wait(self, connection_id: int) -> bool:
+ self.connection_ids.add(connection_id)
+ self._arrivals += 1
+ position = self._arrivals
+ if self._arrivals >= 2:
+ self._ready.set()
+ await self._ready.wait()
+ if position == 2:
+ await self._writer_committed.wait()
+ return position == 1
+
+ def writer_committed(self) -> None:
+ self._writer_committed.set()
+
+ def has_writer_committed(self) -> bool:
+ return self._writer_committed.is_set()
+
+
+class _BarrierSession:
+ """AsyncSession proxy that pauses after its first preference SELECT."""
+
+ def __init__(self, session, barrier: _PreferenceReadBarrier) -> None:
+ self._session = session
+ self._barrier = barrier
+ self._first_execute = True
+ self._is_first_writer = False
+
+ async def __aenter__(self):
+ await self._session.__aenter__()
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ return await self._session.__aexit__(exc_type, exc_value, traceback)
+
+ def __getattr__(self, name: str):
+ return getattr(self._session, name)
+
+ async def commit(self) -> None:
+ await self._session.commit()
+ if self._is_first_writer:
+ self._barrier.writer_committed()
+
+ async def execute(self, statement, *args, **kwargs):
+ if not self._first_execute:
+ return await self._session.execute(statement, *args, **kwargs)
+
+ self._first_execute = False
+ # Python's sqlite3 legacy transaction mode does not always begin a
+ # database transaction for SELECT. An explicit BEGIN makes each SELECT
+ # retain a real WAL read snapshot, matching modern transaction mode and
+ # reproducing the read-to-write upgrade race deterministically.
+ await self._session.execute(sa.text("BEGIN"))
+ result = await self._session.execute(statement, *args, **kwargs)
+ connection = await self._session.connection()
+ dbapi_connection = connection.sync_connection.connection.dbapi_connection
+ self._is_first_writer = await self._barrier.wait(id(dbapi_connection))
+ return result
+
+
+class _RepairBarrierSession:
+ """Pause two invalid-record repairs after each captures a WAL snapshot."""
+
+ def __init__(self, session, barrier: _PreferenceReadBarrier) -> None:
+ self._session = session
+ self._barrier = barrier
+ self._execute_count = 0
+ self._is_first_writer = False
+
+ async def __aenter__(self):
+ await self._session.__aenter__()
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ return await self._session.__aexit__(exc_type, exc_value, traceback)
+
+ def __getattr__(self, name: str):
+ return getattr(self._session, name)
+
+ async def commit(self) -> None:
+ await self._session.commit()
+ if self._is_first_writer:
+ self._barrier.writer_committed()
+
+ async def execute(self, statement, *args, **kwargs):
+ self._execute_count += 1
+ if self._execute_count == 1:
+ # The initial first-writer-wins UPDATE cannot match an invalid,
+ # non-NULL record. End that no-op write transaction so both real
+ # SQLite connections can establish independent read snapshots.
+ result = await self._session.execute(statement, *args, **kwargs)
+ assert result.rowcount == 0
+ await self._session.rollback()
+ return result
+ if self._execute_count == 2 and not self._barrier.has_writer_committed():
+ await self._session.execute(sa.text("BEGIN"))
+ result = await self._session.execute(statement, *args, **kwargs)
+ connection = await self._session.connection()
+ dbapi_connection = connection.sync_connection.connection.dbapi_connection
+ self._is_first_writer = await self._barrier.wait(id(dbapi_connection))
+ return result
+ return await self._session.execute(statement, *args, **kwargs)
+
+
+class _CasMissResult:
+ def __init__(self, row=None, *, rowcount: int | None = None) -> None:
+ self._row = row
+ self.rowcount = rowcount
+
+ def one_or_none(self):
+ return self._row
+
+
+class _CasMissSession:
+ def __init__(self, attempts: list[int]) -> None:
+ self._attempts = attempts
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ return False
+
+ def get_bind(self):
+ return SimpleNamespace(dialect=SimpleNamespace(name="sqlite"))
+
+ async def execute(self, statement, *args, **kwargs):
+ if isinstance(statement, sa.sql.Select):
+ return _CasMissResult((_full_preferences(), 1, False))
+ self._attempts.append(1)
+ return _CasMissResult(rowcount=0)
+
+ async def rollback(self) -> None:
+ return None
+
+
+class _RepairRaceSession:
+ """Script an invalid-record repair losing its revision CAS."""
+
+ def __init__(self, responses: list[_CasMissResult]) -> None:
+ self._responses = responses
+ self.rollback_count = 0
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ return False
+
+ def get_bind(self):
+ return SimpleNamespace(dialect=SimpleNamespace(name="sqlite"))
+
+ async def execute(self, statement, *args, **kwargs):
+ assert self._responses, f"Unexpected statement after scripted repair race: {statement}"
+ return self._responses.pop(0)
+
+ async def rollback(self) -> None:
+ self.rollback_count += 1
+
+
+class _BusyInitializeSession:
+ def __init__(self, attempts: list[int], rollbacks: list[int], error_code: int) -> None:
+ self._attempts = attempts
+ self._rollbacks = rollbacks
+ self._error_code = error_code
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ return False
+
+ def get_bind(self):
+ return SimpleNamespace(dialect=SimpleNamespace(name="sqlite"))
+
+ async def execute(self, statement, *args, **kwargs):
+ self._attempts.append(1)
+ error = sqlite3.OperationalError("database is locked")
+ error.sqlite_errorcode = self._error_code
+ raise sa.exc.OperationalError(
+ str(statement),
+ {},
+ error,
+ )
+
+ async def rollback(self) -> None:
+ self._rollbacks.append(1)
+
+
+@pytest.mark.asyncio
+async def test_invalid_preference_repair_cas_loser_returns_valid_winner() -> None:
+ invalid = {"context": {}}
+ winner = _full_preferences(model_name="winner")
+ session = _RepairRaceSession(
+ [
+ _CasMissResult(rowcount=0),
+ _CasMissResult((invalid, 7, False)),
+ _CasMissResult(rowcount=0),
+ _CasMissResult((winner, 8, False)),
+ ]
+ )
+ repository = SQLiteUserRepository(lambda: session) # type: ignore[arg-type]
+
+ stored, revision = await repository.initialize_user_preferences(
+ "user-id",
+ _full_preferences(model_name="loser"),
+ existing_is_valid=lambda settings: settings == winner,
+ )
+
+ assert stored == winner
+ assert stored is not winner
+ assert revision == 8
+ assert session.rollback_count == 1
+ assert session._responses == []
+
+
+@pytest.mark.asyncio
+async def test_preference_initialization_busy_snapshot_retries_are_bounded(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ attempts: list[int] = []
+ rollbacks: list[int] = []
+ delays = AsyncMock()
+ monkeypatch.setattr(sqlite_repository, "_sleep_before_preference_retry", delays)
+ repository = SQLiteUserRepository(
+ lambda: _BusyInitializeSession(attempts, rollbacks, sqlite3.SQLITE_BUSY_SNAPSHOT), # type: ignore[arg-type]
+ )
+
+ with pytest.raises(sqlite_repository.UserPreferencesWriteConflict, match="initialization.*did not settle"):
+ await repository.initialize_user_preferences(
+ "user-id",
+ _full_preferences(),
+ existing_is_valid=user_preferences_router._stored_preferences_are_valid,
+ )
+
+ assert len(attempts) == sqlite_repository._PREFERENCE_WRITE_MAX_ATTEMPTS
+ assert len(rollbacks) == sqlite_repository._PREFERENCE_WRITE_MAX_ATTEMPTS
+ assert [call.args for call in delays.await_args_list] == [(0,), (1,), (2,), (3,)]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ["initialize", "merge"])
+async def test_preference_plain_busy_propagates_without_retry(
+ operation: str,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ attempts: list[int] = []
+ rollbacks: list[int] = []
+ delays = AsyncMock()
+ monkeypatch.setattr(sqlite_repository, "_sleep_before_preference_retry", delays)
+ repository = SQLiteUserRepository(
+ lambda: _BusyInitializeSession(attempts, rollbacks, sqlite3.SQLITE_BUSY), # type: ignore[arg-type]
+ )
+
+ with pytest.raises(sa.exc.OperationalError) as exc_info:
+ if operation == "initialize":
+ await repository.initialize_user_preferences(
+ "user-id",
+ _full_preferences(),
+ existing_is_valid=user_preferences_router._stored_preferences_are_valid,
+ )
+ else:
+ await repository.merge_user_preferences(
+ "user-id",
+ {"notification": {"enabled": False}},
+ current_is_valid=user_preferences_router._stored_preferences_are_valid,
+ )
+
+ assert getattr(exc_info.value.orig, "sqlite_errorcode", None) == sqlite3.SQLITE_BUSY
+ assert attempts == [1]
+ assert rollbacks == []
+ delays.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_concurrent_sqlite_invalid_preference_repairs_return_one_winner(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "concurrent-repair@example.com")
+ user_id = str(user.id)
+ await _store_raw_preferences(user_id, {"context": {}}, revision=7)
+
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ barrier = _PreferenceReadBarrier()
+ repository = SQLiteUserRepository(lambda: _RepairBarrierSession(session_factory(), barrier)) # type: ignore[arg-type]
+ busy_error_codes: list[int | None] = []
+ delays = AsyncMock()
+ original_is_busy = sqlite_repository._is_sqlite_busy_snapshot_error
+
+ def capture_busy_error(exc) -> bool:
+ busy_error_codes.append(getattr(exc.orig, "sqlite_errorcode", None))
+ return original_is_busy(exc)
+
+ monkeypatch.setattr(sqlite_repository, "_is_sqlite_busy_snapshot_error", capture_busy_error)
+ monkeypatch.setattr(sqlite_repository, "_sleep_before_preference_retry", delays)
+
+ first, second = await asyncio.gather(
+ repository.initialize_user_preferences(
+ user_id,
+ _full_preferences(model_name="first-repair"),
+ existing_is_valid=user_preferences_router._stored_preferences_are_valid,
+ ),
+ repository.initialize_user_preferences(
+ user_id,
+ _full_preferences(model_name="second-repair"),
+ existing_is_valid=user_preferences_router._stored_preferences_are_valid,
+ ),
+ )
+
+ assert len(barrier.connection_ids) == 2
+ assert busy_error_codes == [sqlite3.SQLITE_BUSY_SNAPSHOT]
+ delays.assert_awaited_once_with(0)
+ assert first == second
+ assert first[0]["context"]["model_name"] in {"first-repair", "second-repair"}
+ assert first[1] == 8
+ stored, revision = await user_repository.get_user_preferences(user_id)
+ assert (stored, revision) == first
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("winner_row", "error_type", "message"),
+ [
+ (None, sqlite_repository.UserNotFoundError, "no longer exists"),
+ ((None, 8, False), sqlite_repository.UserPreferencesWriteConflict, "being initialized"),
+ (({"context": {"still": "invalid"}}, 8, False), sqlite_repository.UserPreferencesWriteConflict, "changed while being repaired"),
+ ],
+)
+async def test_invalid_preference_repair_cas_loser_preserves_precise_errors(
+ winner_row,
+ error_type: type[Exception],
+ message: str,
+) -> None:
+ session = _RepairRaceSession(
+ [
+ _CasMissResult(rowcount=0),
+ _CasMissResult(({"context": {}}, 7, False)),
+ _CasMissResult(rowcount=0),
+ _CasMissResult(winner_row),
+ ]
+ )
+ repository = SQLiteUserRepository(lambda: session) # type: ignore[arg-type]
+
+ with pytest.raises(error_type, match=message):
+ await repository.initialize_user_preferences(
+ "user-id",
+ _full_preferences(model_name="loser"),
+ existing_is_valid=lambda settings: settings == _full_preferences(model_name="winner"),
+ )
+
+ assert session.rollback_count == 1
+ assert session._responses == []
+
+
+@pytest.mark.asyncio
+async def test_preference_cas_retries_back_off_before_exhaustion(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ attempts: list[int] = []
+ delays = AsyncMock()
+ monkeypatch.setattr(sqlite_repository, "_sleep_before_preference_retry", delays)
+ repository = SQLiteUserRepository(lambda: _CasMissSession(attempts)) # type: ignore[arg-type]
+
+ with pytest.raises(sqlite_repository.UserPreferencesWriteConflict):
+ await repository.merge_user_preferences(
+ "user-id",
+ {"notification": {"enabled": False}},
+ )
+
+ assert len(attempts) == sqlite_repository._PREFERENCE_WRITE_MAX_ATTEMPTS
+ assert [call.args for call in delays.await_args_list] == [(0,), (1,), (2,), (3,)]
+
+
+@pytest.mark.asyncio
+async def test_concurrent_sqlite_snapshot_busy_retries_without_losing_patch(user_repository: SQLiteUserRepository, monkeypatch: pytest.MonkeyPatch) -> None:
+ user = await _create_user(user_repository, "snapshot-busy@example.com")
+ await user_repository.initialize_user_preferences(str(user.id), _full_preferences())
+
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ barrier = _PreferenceReadBarrier()
+ repository = SQLiteUserRepository(lambda: _BarrierSession(session_factory(), barrier)) # type: ignore[arg-type]
+ busy_error_codes: list[int | None] = []
+ delays = AsyncMock()
+ original_is_busy = sqlite_repository._is_sqlite_busy_snapshot_error
+
+ def capture_busy_error(exc) -> bool:
+ busy_error_codes.append(getattr(exc.orig, "sqlite_errorcode", None))
+ return original_is_busy(exc)
+
+ monkeypatch.setattr(sqlite_repository, "_is_sqlite_busy_snapshot_error", capture_busy_error)
+ monkeypatch.setattr(sqlite_repository, "_sleep_before_preference_retry", delays)
+
+ await asyncio.gather(
+ repository.merge_user_preferences(
+ str(user.id),
+ {"notification": {"enabled": False}},
+ ),
+ repository.merge_user_preferences(
+ str(user.id),
+ {"tokenUsage": {"inlineMode": "off"}},
+ ),
+ )
+
+ stored, revision = await user_repository.get_user_preferences(str(user.id))
+ assert len(barrier.connection_ids) == 2
+ assert busy_error_codes == [sqlite3.SQLITE_BUSY_SNAPSHOT]
+ delays.assert_awaited_once_with(0)
+ assert stored is not None
+ assert stored["notification"]["enabled"] is False
+ assert stored["tokenUsage"]["inlineMode"] == "off"
+ assert revision == 3
+
+
+def test_preferences_schema_rejects_unknown_private_or_system_fields() -> None:
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"context": {"thread_id": "private-thread"}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"notification": {"permission": "granted"}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"token": "secret"},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"user_id": str(uuid4()), "notification": {"enabled": False}},
+ )
+
+
+def test_uninitialized_preferences_predicate_compiles_for_sqlite_and_postgres() -> None:
+ statement = sa.select(UserRow.id).where(sqlite_repository._preferences_are_uninitialized())
+
+ for dialect in (sqlite_dialect.dialect(), postgresql.dialect()):
+ compiled = str(statement.compile(dialect=dialect))
+ assert "users.preferences IS NULL" in compiled
+ assert "CAST(users.preferences AS TEXT)" in compiled
+
+
+def test_lenient_preferences_json_processor_preserves_dialect_native_values() -> None:
+ column_type = UserRow.__table__.c.preferences.type
+
+ sqlite = sqlite_dialect.dialect()
+ sqlite_processor = column_type.dialect_impl(sqlite).result_processor(sqlite, None)
+ assert sqlite_processor is not None
+ assert sqlite_processor('{"notification":{"enabled":true}}') == {"notification": {"enabled": True}}
+ assert sqlite_processor("{broken") is MALFORMED_USER_PREFERENCES
+ assert sqlite_processor("null") is None
+
+ postgres = postgresql.dialect()
+ postgres_processor = column_type.dialect_impl(postgres).result_processor(postgres, None)
+ native_value = {"notification": {"enabled": True}}
+ processed = native_value if postgres_processor is None else postgres_processor(native_value)
+ assert processed is native_value
+
+
+def test_preferences_schema_rejects_oversized_or_invalid_values() -> None:
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"context": {"model_name": "x" * 257}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"tokenUsage": {"inlineMode": "verbose"}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate(
+ {"notification": {"enabled": "yes"}},
+ )
+
+ with pytest.raises(ValidationError):
+ UserPreferencesPatchRequest.model_validate({"notification": None})
+
+
+def test_initialize_requires_complete_valid_base_settings() -> None:
+ request = UserPreferencesInitializeRequest.model_validate({"settings": _full_preferences()})
+ assert request.settings.context.model_name == "model-a"
+
+ with pytest.raises(ValidationError):
+ UserPreferencesInitializeRequest.model_validate(
+ {"settings": {"context": {"model_name": "model-a"}}},
+ )
+
+
+@pytest.mark.asyncio
+async def test_route_derives_owner_from_authenticated_request(monkeypatch: pytest.MonkeyPatch) -> None:
+ user = User(id=uuid4(), email="owner@example.com", password_hash="hash")
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ repository = SimpleNamespace(
+ get_user_preferences=AsyncMock(
+ return_value=(_full_preferences(model_name="owner-model"), 3),
+ ),
+ )
+ monkeypatch.setattr(
+ "app.gateway.routers.user_preferences.get_user_repository",
+ lambda: repository,
+ )
+
+ response = await get_user_preferences(request) # type: ignore[arg-type]
+
+ repository.get_user_preferences.assert_awaited_once_with(str(user.id))
+ assert response.settings is not None
+ assert response.settings.context.model_name == "owner-model"
+
+
+@pytest.mark.asyncio
+async def test_route_rejects_a_stale_tab_after_the_session_owner_changes(monkeypatch: pytest.MonkeyPatch) -> None:
+ current_user = User(id=uuid4(), email="current@example.com", password_hash="hash")
+ stale_user_id = str(uuid4())
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=current_user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={EXPECTED_USER_ID_HEADER: stale_user_id},
+ )
+ repository = SimpleNamespace(get_user_preferences=AsyncMock())
+ monkeypatch.setattr(
+ "app.gateway.routers.user_preferences.get_user_repository",
+ lambda: repository,
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_user_preferences(request) # type: ignore[arg-type]
+
+ assert exc_info.value.status_code == 409
+ repository.get_user_preferences.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_invalid_stored_preferences_degrade_on_get_and_self_heal_on_put(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "repair@example.com")
+ user_id = str(user.id)
+ await _store_raw_preferences(user_id, {"context": {}}, revision=7)
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: user_repository)
+
+ response = await get_user_preferences(request) # type: ignore[arg-type]
+
+ assert response.settings is None
+ assert response.revision == 7
+
+ repaired = await initialize_user_preferences(
+ UserPreferencesInitializeRequest.model_validate(
+ {"settings": _full_preferences(model_name="repaired-model")},
+ ),
+ request, # type: ignore[arg-type]
+ )
+
+ assert repaired.settings is not None
+ assert repaired.settings.context.model_name == "repaired-model"
+ assert repaired.revision == 8
+ stored, revision = await user_repository.get_user_preferences(user_id)
+ assert stored == _full_preferences(model_name="repaired-model")
+ assert revision == 8
+
+
+@pytest.mark.asyncio
+async def test_json_null_preferences_degrade_on_get_and_self_heal_on_put(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "json-null-repair@example.com")
+ user_id = str(user.id)
+ await _store_raw_preferences(user_id, sa.JSON.NULL, revision=7)
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ async with session_factory() as session:
+ raw_value = await session.scalar(
+ sa.select(sa.cast(UserRow.preferences, sa.String)).where(UserRow.id == user_id),
+ )
+ sql_null = await session.scalar(
+ sa.select(UserRow.preferences.is_(None)).where(UserRow.id == user_id),
+ )
+ assert raw_value == "null"
+ assert sql_null is False
+
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: user_repository)
+
+ response = await get_user_preferences(request) # type: ignore[arg-type]
+
+ assert response.settings is None
+ assert response.revision == 7
+
+ repaired = await initialize_user_preferences(
+ UserPreferencesInitializeRequest.model_validate(
+ {"settings": _full_preferences(model_name="json-null-repaired")},
+ ),
+ request, # type: ignore[arg-type]
+ )
+
+ assert repaired.settings is not None
+ assert repaired.settings.context.model_name == "json-null-repaired"
+ assert repaired.revision == 8
+ stored, revision = await user_repository.get_user_preferences(user_id)
+ assert stored == _full_preferences(model_name="json-null-repaired")
+ assert revision == 8
+
+
+@pytest.mark.asyncio
+async def test_malformed_json_preferences_do_not_break_auth_and_self_heal_on_put(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "malformed-json-repair@example.com")
+ user_id = str(user.id)
+ await _store_malformed_preferences(user_id, "{broken", revision=7)
+ assert await _read_raw_preferences(user_id) == ("{broken", False, 7)
+
+ loaded_user = await user_repository.get_user_by_id(user_id)
+ assert loaded_user is not None
+ assert loaded_user.id == user.id
+
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: user_repository)
+
+ response = await get_user_preferences(request) # type: ignore[arg-type]
+
+ assert response.settings is None
+ assert response.revision == 7
+
+ repaired = await initialize_user_preferences(
+ UserPreferencesInitializeRequest.model_validate(
+ {"settings": _full_preferences(model_name="malformed-json-repaired")},
+ ),
+ request, # type: ignore[arg-type]
+ )
+
+ assert repaired.settings is not None
+ assert repaired.settings.context.model_name == "malformed-json-repaired"
+ assert repaired.revision == 8
+ stored, revision = await user_repository.get_user_preferences(user_id)
+ assert stored == _full_preferences(model_name="malformed-json-repaired")
+ assert revision == 8
+
+
+@pytest.mark.asyncio
+async def test_patch_rejects_invalid_stored_preferences_without_mutating_them(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "invalid-patch@example.com")
+ user_id = str(user.id)
+ invalid = {"context": {}}
+ await _store_raw_preferences(user_id, invalid, revision=4)
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: user_repository)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await patch_user_preferences(
+ UserPreferencesPatchRequest.model_validate(
+ {"notification": {"enabled": False}},
+ ),
+ request, # type: ignore[arg-type]
+ )
+
+ assert exc_info.value.status_code == 409
+ stored, revision = await user_repository.get_user_preferences(user_id)
+ assert stored == invalid
+ assert revision == 4
+
+
+@pytest.mark.asyncio
+async def test_patch_rejects_json_null_preferences_without_mutating_them(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "json-null-patch@example.com")
+ user_id = str(user.id)
+ await _store_raw_preferences(user_id, sa.JSON.NULL, revision=4)
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: user_repository)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await patch_user_preferences(
+ UserPreferencesPatchRequest.model_validate(
+ {"notification": {"enabled": False}},
+ ),
+ request, # type: ignore[arg-type]
+ )
+
+ assert exc_info.value.status_code == 409
+ session_factory = get_session_factory()
+ assert session_factory is not None
+ async with session_factory() as session:
+ raw_value, sql_null, revision = (
+ await session.execute(
+ sa.select(
+ sa.cast(UserRow.preferences, sa.String),
+ UserRow.preferences.is_(None),
+ UserRow.preferences_revision,
+ ).where(UserRow.id == user_id),
+ )
+ ).one()
+ assert (raw_value, sql_null, revision) == ("null", False, 4)
+
+
+@pytest.mark.asyncio
+async def test_patch_rejects_malformed_json_preferences_without_mutating_them(
+ user_repository: SQLiteUserRepository,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = await _create_user(user_repository, "malformed-json-patch@example.com")
+ user_id = str(user.id)
+ await _store_malformed_preferences(user_id, "{broken", revision=4)
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=user, auth_source=AUTH_SOURCE_SESSION),
+ cookies={},
+ headers={},
+ )
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: user_repository)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await patch_user_preferences(
+ UserPreferencesPatchRequest.model_validate(
+ {"notification": {"enabled": False}},
+ ),
+ request, # type: ignore[arg-type]
+ )
+
+ assert exc_info.value.status_code == 409
+ assert await _read_raw_preferences(user_id) == ("{broken", False, 4)
+
+
+def test_http_response_keeps_absent_record_explicit_and_omits_unset_context_values(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user = User(id=uuid4(), email="wire@example.com", password_hash="hash")
+ repository = SimpleNamespace(get_user_preferences=AsyncMock(return_value=(None, 0)))
+ monkeypatch.setattr(user_preferences_router, "get_current_user_from_request", AsyncMock(return_value=user))
+ monkeypatch.setattr(user_preferences_router, "get_user_repository", lambda: repository)
+ app = FastAPI()
+ app.include_router(user_preferences_router.router)
+
+ response = TestClient(app).get("/api/user-preferences")
+
+ assert response.status_code == 200
+ assert response.json() == {"settings": None, "revision": 0}
+
+ repository.get_user_preferences = AsyncMock(
+ return_value=(
+ {
+ "notification": {"enabled": True},
+ "tokenUsage": {"headerTotal": True, "inlineMode": "per_turn"},
+ "context": {},
+ },
+ 1,
+ )
+ )
+
+ response = TestClient(app).get("/api/user-preferences")
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "settings": {
+ "notification": {"enabled": True},
+ "tokenUsage": {"headerTotal": True, "inlineMode": "per_turn"},
+ "context": {},
+ },
+ "revision": 1,
+ }
+
+
+def test_user_preferences_migration_is_idempotent_and_reversible(tmp_path: Path) -> None:
+ migration = _load_user_preferences_migration()
+
+ db_path = tmp_path / "migration.db"
+ engine = sa.create_engine(f"sqlite:///{db_path}")
+ with engine.begin() as connection:
+ connection.execute(sa.text("CREATE TABLE users (id VARCHAR(36) PRIMARY KEY, email VARCHAR(320) NOT NULL)"))
+ connection.execute(
+ sa.text("INSERT INTO users (id, email) VALUES (:id, :email)"),
+ {"id": "existing-user", "email": "existing@example.com"},
+ )
+ context = MigrationContext.configure(connection)
+ with Operations.context(context):
+ migration.upgrade()
+ migration.upgrade()
+
+ columns = {column["name"] for column in sa.inspect(connection).get_columns("users")}
+ assert {"preferences", "preferences_revision"} <= columns
+ assert connection.execute(
+ sa.text("SELECT preferences, preferences_revision FROM users WHERE id = 'existing-user'"),
+ ).one() == (None, 0)
+
+ with Operations.context(context):
+ migration.downgrade()
+ migration.downgrade()
+
+ columns = {column["name"] for column in sa.inspect(connection).get_columns("users")}
+ assert columns == {"id", "email"}
+
+ with Operations.context(context):
+ migration.upgrade()
+ migration.upgrade()
+
+ assert connection.execute(
+ sa.text("SELECT preferences, preferences_revision FROM users WHERE id = 'existing-user'"),
+ ).one() == (None, 0)
+
+ with Operations.context(context):
+ migration.downgrade()
+ migration.downgrade()
+
+ columns = {column["name"] for column in sa.inspect(connection).get_columns("users")}
+ assert columns == {"id", "email"}
diff --git a/frontend/README.md b/frontend/README.md
index e234cf87334..b4105f8736e 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -77,6 +77,19 @@ pnpm start
## Configuration
+### Settings Persistence
+
+When Gateway authentication is enabled, DeerFlow synchronizes a small,
+browser-safe allowlist of base UI settings across signed-in sessions. Existing
+server settings win on sign-in; accounts without a record import their valid
+local settings once. Local storage remains the non-blocking offline fallback,
+and per-thread model overrides, browser notification permission, workspace
+state, and credentials never enter this synchronization API. Auth-disabled
+deployments retain the original local-only behavior. Authenticated fallback
+caches and failed-write outboxes are account-scoped. A cross-tab Web Lock gives
+the old unscoped cache a single owner during upgrade; browsers without Web
+Locks skip that ambiguous import and start from defaults until server hydration.
+
### Environment Variables
Key environment variables (see `.env.example` for full list):
diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md
index cb642490549..e8a2d69e2a5 100644
--- a/frontend/src/AGENTS.md
+++ b/frontend/src/AGENTS.md
@@ -13,7 +13,60 @@
ownership and returns 206/416 through `FileResponse`.
3. `useThreadHistory` loads persisted conversation pages from `GET /api/threads/{id}/messages/page`, preserving the backend's thread-global event `seq`; rendering overlays checkpoint/live copies at their matching canonical identities (a summarized checkpoint may contain a protected early input plus a recent tail). Context-compaction rescue diffs every retained visible identity rather than slicing at the first anchor, and keeps a run-scoped ledger of committed visible messages so replacement updates and repeated rolling checkpoint windows cannot erase an already displayed step. The resolver suppresses checkpoint/transient prefixes whose canonical position is still behind an unloaded cursor page instead of collapsing that unknown gap before a recent anchor, then adds optimistic messages without timestamp re-sorting. History invalidation preserves already-loaded pages so their established ordering positions are not discarded. Dynamic context re-keys the submitted user message from `X` to `X__user`; UI identity matching normalizes that reserved suffix only for human messages so the submitted frame and checkpoint replacement remain one visible turn. A locally submitted turn also records its pre-submit identity baseline: if `messages-tuple` publishes new AI/tool steps before `values` publishes that turn's human message, render ordering moves only those non-baseline visible steps behind the new human while leaving history, hidden controls, and reconnected runs untouched. Keep that local order anchor through finish, stop, and stream error because the SDK's settled frame can retain transient event order; replace it on the next local submit and clear it on thread switch or replay-gap recovery.
4. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, thread-history, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits
-5. TanStack Query manages server state; localStorage stores user settings. The
+5. TanStack Query manages server state. `core/settings` keeps user settings in
+ localStorage as an offline fallback; in normal authenticated Gateway mode,
+ `UserSettingsSync` hydrates the browser-safe base-settings allowlist from
+ `GET /api/user-preferences`, or performs a first-writer-wins import of a
+ valid legacy local value when no server record exists. Later base mutations
+ are serialized as nested `PATCH` writes, and response values never overwrite
+ a newer local edit. Thread model override keys/ids remain local, as do browser
+ notification permission/system state and all workspace/credential data.
+ Every active-user mutation is captured at the store boundary and enters a
+ user-scoped, allowlisted local outbox before async activation or network
+ observers can run. If a setting changes between `UserSettingsSync` render
+ and activation, a six-leaf local-mutation journal seeds only those local
+ edits before hydrating the server response; storage events and server
+ hydration never enter that journal. Browser-storage failures additionally
+ retain the affected leaf in a user-scoped process-memory outbox across sync
+ controller remounts. A successful write acknowledges only the exact
+ volatile operation it sent, while a newer durable operation for that leaf
+ supersedes the older in-memory value. If only the full fallback-cache write
+ fails, its differing leaves remain in a separate per-user memory overlay
+ after the server outbox is acknowledged. Setters fold that overlay over the
+ latest shared cache, preserving the acknowledged local values without
+ hiding newer cross-tab sibling leaves; a successful cache write or newer
+ same-leaf durable operation clears the corresponding overlay.
+ Failed writes remain in that outbox; the next handshake folds them over the
+ server read and retries before clearing, so reconnect/reload cannot silently
+ erase an unsynchronized local selection. Local setters diff against the
+ latest user cache plus its durable and in-memory pending leaves and enqueue
+ only changed leaves. Immediately before their full fallback-cache write,
+ they re-read that cache and fold the authoritative local leaf plus all
+ pending leaves over it; the same merged snapshot becomes the in-process
+ base, so a sibling changed between outbox and cache writes is not rolled
+ back;
+ cross-tab cache events update local state without echoing a second write.
+ Each user/leaf has a fixed
+ mutation slot containing an opaque operation id and a separate acknowledgement
+ slot; successful writes advance acknowledgements without deleting mutations,
+ so a later tab write remains pending even when an older request completes.
+ Storage-write failures retain the leaf in memory with the mutation id they
+ observed, allowing a later durable mutation to supersede that fallback. The
+ bootstrap handshake plus every lock-time reread, PATCH, and acknowledgement
+ run under one per-user Web Lock. When Web Locks are unavailable, an existing
+ server record can still hydrate the local fallback, while initialization and
+ writes fail closed and pending work remains untouched. Keep
+ `UserSettingsSync` mounted
+ before interactive workspace content so its render-time version boundary is
+ established before settings controls render.
+ Authenticated fallback caches are also keyed by user. A Web Lock serializes
+ the one-time claim of the historical unscoped cache across tabs; without Web
+ Locks, the ambiguous legacy value is not imported. Storage events for a
+ different user's cache are ignored, and every API call sends an expected-user
+ guard that the Gateway compares with the authenticated cookie owner, so a tab
+ left open across an account switch cannot read or patch the new account.
+ Auth-disabled and static-website modes mount no settings synchronization and
+ retain the prior local-only behavior. The
Settings > Tools MCP switch calls the targeted `PATCH /api/mcp/config`
mutation, disables switches until that mutation's success refetch completes,
displays the backend error `detail` through a toast, and invalidates
diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx
index 2fa583bd9b9..57749ae9a58 100644
--- a/frontend/src/app/workspace/layout.tsx
+++ b/frontend/src/app/workspace/layout.tsx
@@ -4,11 +4,14 @@ import "streamdown/styles.css";
import { redirect } from "next/navigation";
import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback";
+import { isAuthDisabledMode } from "@/core/auth/auth-disabled-user";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import { assertNever } from "@/core/auth/types";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocaleServer } from "@/core/i18n/server";
+import { UserSettingsSync } from "@/core/settings/user-settings-sync";
+import { isStaticWebsiteOnly } from "@/core/static-mode";
import { WorkspaceContent } from "./workspace-content";
@@ -26,6 +29,10 @@ export default async function WorkspaceLayout({
case "authenticated":
content = (
+
{children}
);
diff --git a/frontend/src/core/settings/api.ts b/frontend/src/core/settings/api.ts
new file mode 100644
index 00000000000..a54608f1bdf
--- /dev/null
+++ b/frontend/src/core/settings/api.ts
@@ -0,0 +1,79 @@
+import { z } from "zod";
+
+import { throwGatewayApiError } from "@/core/api/errors";
+import { fetch } from "@/core/api/fetcher";
+import { getBackendBaseURL } from "@/core/config";
+
+import {
+ persistedUserSettingsSchema,
+ type PersistedUserSettings,
+ type PersistedUserSettingsPatch,
+} from "./persistence";
+
+const responseSchema = z
+ .object({
+ settings: persistedUserSettingsSchema.nullable(),
+ revision: z.number().int().nonnegative(),
+ })
+ .strict();
+
+export type UserSettingsResponse = z.infer;
+const EXPECTED_USER_ID_HEADER = "X-DeerFlow-Expected-User-Id";
+
+function url(): string {
+ return `${getBackendBaseURL()}/api/user-preferences`;
+}
+
+async function parseResponse(
+ response: Response,
+): Promise {
+ if (!response.ok) {
+ await throwGatewayApiError(
+ response,
+ `Failed to synchronize user settings: ${response.statusText}`,
+ );
+ }
+ return responseSchema.parse(await response.json());
+}
+
+export async function fetchUserSettings(
+ expectedUserId: string,
+): Promise {
+ return parseResponse(
+ await fetch(url(), {
+ headers: { [EXPECTED_USER_ID_HEADER]: expectedUserId },
+ }),
+ );
+}
+
+export async function initializeUserSettings(
+ expectedUserId: string,
+ settings: PersistedUserSettings,
+): Promise {
+ return parseResponse(
+ await fetch(url(), {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ [EXPECTED_USER_ID_HEADER]: expectedUserId,
+ },
+ body: JSON.stringify({ settings }),
+ }),
+ );
+}
+
+export async function patchUserSettings(
+ expectedUserId: string,
+ patch: PersistedUserSettingsPatch,
+): Promise {
+ return parseResponse(
+ await fetch(url(), {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ [EXPECTED_USER_ID_HEADER]: expectedUserId,
+ },
+ body: JSON.stringify(patch),
+ }),
+ );
+}
diff --git a/frontend/src/core/settings/persistence.ts b/frontend/src/core/settings/persistence.ts
new file mode 100644
index 00000000000..ce3b38ff38c
--- /dev/null
+++ b/frontend/src/core/settings/persistence.ts
@@ -0,0 +1,236 @@
+import { z } from "zod";
+
+import type { LocalSettings } from "./local";
+
+const modelNameSchema = z.string().trim().min(1).max(256);
+const modeSchema = z.enum(["flash", "thinking", "pro", "ultra"]);
+const reasoningEffortSchema = z.enum(["minimal", "low", "medium", "high"]);
+const inlineModeSchema = z.enum(["off", "per_turn", "step_debug"]);
+
+export const persistedUserSettingsSchema = z
+ .object({
+ notification: z.object({ enabled: z.boolean() }).strict(),
+ tokenUsage: z
+ .object({
+ headerTotal: z.boolean(),
+ inlineMode: inlineModeSchema,
+ })
+ .strict(),
+ context: z
+ .object({
+ model_name: modelNameSchema.optional(),
+ mode: modeSchema.optional(),
+ reasoning_effort: reasoningEffortSchema.optional(),
+ })
+ .strict(),
+ })
+ .strict();
+
+const contextPatchSchema = z
+ .object({
+ model_name: modelNameSchema.nullable().optional(),
+ mode: modeSchema.nullable().optional(),
+ reasoning_effort: reasoningEffortSchema.nullable().optional(),
+ })
+ .strict()
+ .refine((value) => Object.keys(value).length > 0);
+
+const tokenUsagePatchSchema = z
+ .object({
+ headerTotal: z.boolean().optional(),
+ inlineMode: inlineModeSchema.optional(),
+ })
+ .strict()
+ .refine((value) => Object.keys(value).length > 0);
+
+export const persistedUserSettingsPatchSchema = z
+ .object({
+ notification: z.object({ enabled: z.boolean() }).strict().optional(),
+ tokenUsage: tokenUsagePatchSchema.optional(),
+ context: contextPatchSchema.optional(),
+ })
+ .strict()
+ .refine((value) => Object.keys(value).length > 0);
+
+export type PersistedUserSettings = z.infer;
+export type PersistedUserSettingsPatch = z.infer<
+ typeof persistedUserSettingsPatchSchema
+>;
+
+const DEFAULT_PERSISTED_USER_SETTINGS: PersistedUserSettings = {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+};
+
+export function parsePersistedUserSettings(
+ value: unknown,
+): PersistedUserSettings | null {
+ const parsed = persistedUserSettingsSchema.safeParse(value);
+ return parsed.success ? parsed.data : null;
+}
+
+export function parsePersistedUserSettingsPatch(
+ value: unknown,
+): PersistedUserSettingsPatch | null {
+ const parsed = persistedUserSettingsPatchSchema.safeParse(value);
+ return parsed.success ? parsed.data : null;
+}
+
+/**
+ * Project browser state onto the server's deliberately small allowlist.
+ *
+ * Thread ids/overrides, agent/workspace metadata, browser Notification
+ * permission, and any accidental token-like fields are impossible to include
+ * because this function constructs every accepted property explicitly.
+ */
+export function toPersistedUserSettings(
+ settings: LocalSettings,
+): PersistedUserSettings {
+ const candidate = {
+ notification: { enabled: settings.notification.enabled },
+ tokenUsage: {
+ headerTotal: settings.tokenUsage.headerTotal,
+ inlineMode: settings.tokenUsage.inlineMode,
+ },
+ context: {
+ ...(settings.context.model_name === undefined
+ ? {}
+ : { model_name: settings.context.model_name }),
+ ...(settings.context.mode === undefined
+ ? {}
+ : { mode: settings.context.mode }),
+ ...(settings.context.reasoning_effort === undefined
+ ? {}
+ : { reasoning_effort: settings.context.reasoning_effort }),
+ },
+ };
+ return (
+ parsePersistedUserSettings(candidate) ??
+ structuredClone(DEFAULT_PERSISTED_USER_SETTINGS)
+ );
+}
+
+export function toFullUserSettingsPatch(
+ settings: PersistedUserSettings,
+): PersistedUserSettingsPatch {
+ return {
+ notification: { ...settings.notification },
+ tokenUsage: { ...settings.tokenUsage },
+ context: {
+ model_name: settings.context.model_name ?? null,
+ mode: settings.context.mode ?? null,
+ reasoning_effort: settings.context.reasoning_effort ?? null,
+ },
+ };
+}
+
+export function applyPersistedUserSettingsPatch(
+ settings: PersistedUserSettings,
+ patch: PersistedUserSettingsPatch,
+): PersistedUserSettings {
+ const context = { ...settings.context };
+ const modelName = patch.context?.model_name;
+ if (modelName === null) delete context.model_name;
+ else if (modelName !== undefined) context.model_name = modelName;
+ const mode = patch.context?.mode;
+ if (mode === null) delete context.mode;
+ else if (mode !== undefined) context.mode = mode;
+ const reasoningEffort = patch.context?.reasoning_effort;
+ if (reasoningEffort === null) delete context.reasoning_effort;
+ else if (reasoningEffort !== undefined)
+ context.reasoning_effort = reasoningEffort;
+ return persistedUserSettingsSchema.parse({
+ notification: {
+ ...settings.notification,
+ ...patch.notification,
+ },
+ tokenUsage: {
+ ...settings.tokenUsage,
+ ...patch.tokenUsage,
+ },
+ context,
+ });
+}
+
+export function mergePersistedUserSettingsPatches(
+ first: PersistedUserSettingsPatch | null,
+ second: PersistedUserSettingsPatch,
+): PersistedUserSettingsPatch {
+ return persistedUserSettingsPatchSchema.parse({
+ ...(first ?? {}),
+ ...second,
+ ...((first?.notification ?? second.notification) && {
+ notification: {
+ ...first?.notification,
+ ...second.notification,
+ },
+ }),
+ ...((first?.tokenUsage ?? second.tokenUsage) && {
+ tokenUsage: {
+ ...first?.tokenUsage,
+ ...second.tokenUsage,
+ },
+ }),
+ ...((first?.context ?? second.context) && {
+ context: {
+ ...first?.context,
+ ...second.context,
+ },
+ }),
+ });
+}
+
+/**
+ * Return only the allowlisted leaf values that changed between two snapshots.
+ *
+ * In particular, do not promote a one-leaf edit into a whole-section PATCH:
+ * another tab may have a newer value for a sibling leaf in that section.
+ */
+export function diffPersistedUserSettings(
+ previous: PersistedUserSettings,
+ next: PersistedUserSettings,
+): PersistedUserSettingsPatch | null {
+ const patch: {
+ notification?: PersistedUserSettingsPatch["notification"];
+ tokenUsage?: PersistedUserSettingsPatch["tokenUsage"];
+ context?: PersistedUserSettingsPatch["context"];
+ } = {};
+
+ if (previous.notification.enabled !== next.notification.enabled) {
+ patch.notification = { enabled: next.notification.enabled };
+ }
+
+ const tokenUsage: PersistedUserSettingsPatch["tokenUsage"] = {};
+ if (previous.tokenUsage.headerTotal !== next.tokenUsage.headerTotal) {
+ tokenUsage.headerTotal = next.tokenUsage.headerTotal;
+ }
+ if (previous.tokenUsage.inlineMode !== next.tokenUsage.inlineMode) {
+ tokenUsage.inlineMode = next.tokenUsage.inlineMode;
+ }
+ if (Object.keys(tokenUsage).length > 0) patch.tokenUsage = tokenUsage;
+
+ const context: PersistedUserSettingsPatch["context"] = {};
+ if (previous.context.model_name !== next.context.model_name) {
+ context.model_name = next.context.model_name ?? null;
+ }
+ if (previous.context.mode !== next.context.mode) {
+ context.mode = next.context.mode ?? null;
+ }
+ if (previous.context.reasoning_effort !== next.context.reasoning_effort) {
+ context.reasoning_effort = next.context.reasoning_effort ?? null;
+ }
+ if (Object.keys(context).length > 0) patch.context = context;
+
+ return parsePersistedUserSettingsPatch(patch);
+}
+
+export function fromPersistedUserSettings(
+ settings: PersistedUserSettings,
+): LocalSettings {
+ return {
+ notification: { ...settings.notification },
+ tokenUsage: { ...settings.tokenUsage },
+ context: { ...settings.context, mode: settings.context.mode },
+ };
+}
diff --git a/frontend/src/core/settings/store.ts b/frontend/src/core/settings/store.ts
index 86e85aa580c..52efcb07082 100644
--- a/frontend/src/core/settings/store.ts
+++ b/frontend/src/core/settings/store.ts
@@ -4,10 +4,28 @@ import {
THREAD_MODEL_KEY_PREFIX,
getLocalSettings,
getThreadModelName,
+ safeLocalStorage,
saveLocalSettings,
saveThreadModelName,
type LocalSettings,
} from "./local";
+import {
+ applyPersistedUserSettingsPatch,
+ diffPersistedUserSettings,
+ fromPersistedUserSettings,
+ mergePersistedUserSettingsPatches,
+ parsePersistedUserSettings,
+ parsePersistedUserSettingsPatch,
+ toFullUserSettingsPatch,
+ toPersistedUserSettings,
+ type PersistedUserSettings,
+ type PersistedUserSettingsPatch,
+} from "./persistence";
+import type {
+ UserSettingsMutationPersistence,
+ UserSettingsPatchLeaf,
+ VolatileUserSettingsPatchLeaf,
+} from "./sync";
type Listener = () => void;
@@ -17,11 +35,50 @@ export type LocalSettingsSetter = (
) => void;
const listeners = new Set();
+const mutationListeners = new Set<
+ (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void
+>();
const threadModelNames = new Map();
+const USER_SETTINGS_CACHE_KEY_PREFIX = "deerflow.user-settings-cache.";
+const LEGACY_SETTINGS_OWNER_KEY = "deerflow.local-settings-owner";
+const LEGACY_SETTINGS_LOCK_NAME = "deerflow.user-settings-legacy-migration";
+const USER_SETTINGS_WRITE_LOCK_PREFIX = "deerflow.user-settings-write.";
+const USER_SETTINGS_PENDING_KEY_PREFIX = "deerflow.user-settings-pending.";
let baseSettings: LocalSettings = DEFAULT_LOCAL_SETTINGS;
let baseSettingsLoaded = false;
let storageListenerRegistered = false;
+let baseSettingsMutationVersion = 0;
+let baseSettingsLocalMutationVersion = 0;
+let volatileMutationVersion = 0;
+let cacheWriteMutationVersion = 0;
+let activeBaseSettingsUserId: string | null = null;
+let baseSettingsActivationVersion = 0;
+const latestLocalMutationByLeaf = new Map<
+ UserSettingsPatchLeaf,
+ {
+ version: number;
+ userId: string | null;
+ patch: PersistedUserSettingsPatch;
+ }
+>();
+const outstandingVolatileMutationsByUser = new Map<
+ string,
+ Map
+>();
+interface OutstandingUserSettingsCacheWriteLeaf {
+ version: number;
+ leaf: UserSettingsPatchLeaf;
+ patch: PersistedUserSettingsPatch;
+ observedDurableOpId: string | null;
+}
+const outstandingUserSettingsCacheWritesByUser = new Map<
+ string,
+ Map
+>();
function emitChange() {
for (const listener of listeners) {
@@ -29,6 +86,31 @@ function emitChange() {
}
}
+function emitBaseSettingsMutation(
+ patch: PersistedUserSettingsPatch | null,
+ persist = true,
+): void {
+ if (patch === null) return;
+ baseSettingsMutationVersion += 1;
+ if (persist) {
+ baseSettingsLocalMutationVersion += 1;
+ for (const { leaf, patch: leafPatch } of splitPendingPatchLeaves(patch)) {
+ latestLocalMutationByLeaf.set(leaf, {
+ version: baseSettingsLocalMutationVersion,
+ userId: activeBaseSettingsUserId,
+ patch: leafPatch,
+ });
+ }
+ }
+ const persistence =
+ persist && activeBaseSettingsUserId !== null
+ ? appendPendingBaseSettingsPatch(activeBaseSettingsUserId, patch)
+ : { durableLeaves: [], volatileLeaves: [] };
+ for (const listener of mutationListeners) {
+ listener(patch, persistence);
+ }
+}
+
function ensureBaseSettingsLoaded() {
if (baseSettingsLoaded || typeof window === "undefined") {
return;
@@ -47,6 +129,275 @@ function ensureStorageListenerRegistered() {
storageListenerRegistered = true;
}
+function userSettingsCacheStorageKey(userId: string): string {
+ return `${USER_SETTINGS_CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`;
+}
+
+function readUserSettingsCache(userId: string): PersistedUserSettings | null {
+ const json = safeLocalStorage.getItem(userSettingsCacheStorageKey(userId));
+ if (!json) return null;
+ try {
+ return parsePersistedUserSettings(JSON.parse(json));
+ } catch {
+ return null;
+ }
+}
+
+function getOutstandingUserSettingsCacheWriteLeaves(
+ userId: string,
+): OutstandingUserSettingsCacheWriteLeaf[] {
+ const outstanding = outstandingUserSettingsCacheWritesByUser.get(userId);
+ if (outstanding === undefined) return [];
+ for (const [leaf, cacheWrite] of outstanding) {
+ if (
+ getPendingBaseSettingsLeafOpId(userId, leaf) !==
+ cacheWrite.observedDurableOpId
+ ) {
+ outstanding.delete(leaf);
+ }
+ }
+ if (outstanding.size === 0) {
+ outstandingUserSettingsCacheWritesByUser.delete(userId);
+ return [];
+ }
+ return [...outstanding.values()];
+}
+
+function acknowledgeUserSettingsCacheWriteLeaves(
+ userId: string,
+ leaves: readonly OutstandingUserSettingsCacheWriteLeaf[],
+): void {
+ const outstanding = outstandingUserSettingsCacheWritesByUser.get(userId);
+ if (outstanding === undefined) return;
+ for (const cacheWrite of leaves) {
+ if (outstanding.get(cacheWrite.leaf)?.version === cacheWrite.version) {
+ outstanding.delete(cacheWrite.leaf);
+ }
+ }
+ if (outstanding.size === 0) {
+ outstandingUserSettingsCacheWritesByUser.delete(userId);
+ }
+}
+
+function rememberFailedUserSettingsCacheWrite(
+ userId: string,
+ cached: PersistedUserSettings | null,
+ desired: PersistedUserSettings,
+ authoritativePatch?: PersistedUserSettingsPatch | null,
+): void {
+ const patch =
+ authoritativePatch !== undefined
+ ? authoritativePatch
+ : cached === null
+ ? toFullUserSettingsPatch(desired)
+ : diffPersistedUserSettings(cached, desired);
+ const outstanding =
+ authoritativePatch === undefined
+ ? new Map()
+ : new Map(outstandingUserSettingsCacheWritesByUser.get(userId));
+ if (patch !== null) {
+ for (const { leaf, patch: leafPatch } of splitPendingPatchLeaves(patch)) {
+ outstanding.set(leaf, {
+ version: ++cacheWriteMutationVersion,
+ leaf,
+ patch: leafPatch,
+ observedDurableOpId: getPendingBaseSettingsLeafOpId(userId, leaf),
+ });
+ }
+ }
+ if (outstanding.size === 0) {
+ outstandingUserSettingsCacheWritesByUser.delete(userId);
+ } else {
+ outstandingUserSettingsCacheWritesByUser.set(userId, outstanding);
+ }
+}
+
+function applyPendingUserSettings(
+ userId: string,
+ settings: PersistedUserSettings,
+): PersistedUserSettings {
+ let effective = settings;
+ for (const cacheWrite of getOutstandingUserSettingsCacheWriteLeaves(userId)) {
+ effective = applyPersistedUserSettingsPatch(effective, cacheWrite.patch);
+ }
+ const pending = getPendingBaseSettingsPatch(userId);
+ if (pending !== null) {
+ effective = applyPersistedUserSettingsPatch(effective, pending);
+ }
+ for (const volatile of getOutstandingBaseSettingsVolatileLeaves(userId)) {
+ effective = applyPersistedUserSettingsPatch(effective, volatile.patch);
+ }
+ return effective;
+}
+
+function readEffectiveUserSettingsCache(
+ userId: string,
+): PersistedUserSettings | null {
+ const cached = readUserSettingsCache(userId);
+ return cached === null ? null : applyPendingUserSettings(userId, cached);
+}
+
+function latestBaseSettingsForMutation(): LocalSettings {
+ if (activeBaseSettingsUserId === null) return baseSettings;
+ const persisted = readEffectiveUserSettingsCache(activeBaseSettingsUserId);
+ return persisted === null
+ ? baseSettings
+ : fromPersistedUserSettings(persisted);
+}
+
+function saveUserSettingsCache(
+ userId: string,
+ persisted: PersistedUserSettings,
+ authoritativePatch?: PersistedUserSettingsPatch | null,
+): PersistedUserSettings {
+ const previous = readUserSettingsCache(userId);
+ let desired = persisted;
+ if (authoritativePatch !== undefined) {
+ desired = previous ?? persisted;
+ if (authoritativePatch !== null) {
+ desired = applyPersistedUserSettingsPatch(desired, authoritativePatch);
+ }
+ desired = applyPendingUserSettings(userId, desired);
+ }
+ const outstanding = getOutstandingUserSettingsCacheWriteLeaves(userId);
+ if (
+ safeLocalStorage.setItem(
+ userSettingsCacheStorageKey(userId),
+ JSON.stringify(desired),
+ )
+ ) {
+ acknowledgeUserSettingsCacheWriteLeaves(userId, outstanding);
+ } else {
+ rememberFailedUserSettingsCacheWrite(
+ userId,
+ previous,
+ desired,
+ authoritativePatch,
+ );
+ }
+ return desired;
+}
+
+function saveBaseSettingsCache(
+ settings: LocalSettings,
+ authoritativePatch?: PersistedUserSettingsPatch | null,
+): LocalSettings {
+ if (activeBaseSettingsUserId === null) {
+ saveLocalSettings(settings);
+ return settings;
+ }
+ return fromPersistedUserSettings(
+ saveUserSettingsCache(
+ activeBaseSettingsUserId,
+ toPersistedUserSettings(settings),
+ authoritativePatch,
+ ),
+ );
+}
+
+async function claimLegacySettings(
+ userId: string,
+): Promise {
+ const existingOwner = safeLocalStorage.getItem(LEGACY_SETTINGS_OWNER_KEY);
+ if (existingOwner === userId) {
+ return toPersistedUserSettings(getLocalSettings());
+ }
+ if (existingOwner !== null || typeof navigator === "undefined") return null;
+
+ const lockManager = navigator.locks;
+ if (!lockManager) return null;
+ try {
+ return await lockManager.request(
+ LEGACY_SETTINGS_LOCK_NAME,
+ { mode: "exclusive" },
+ () => {
+ const owner = safeLocalStorage.getItem(LEGACY_SETTINGS_OWNER_KEY);
+ if (owner !== null && owner !== userId) return null;
+ if (
+ owner === null &&
+ !safeLocalStorage.setItem(LEGACY_SETTINGS_OWNER_KEY, userId)
+ ) {
+ return null;
+ }
+ return safeLocalStorage.getItem(LEGACY_SETTINGS_OWNER_KEY) === userId
+ ? toPersistedUserSettings(getLocalSettings())
+ : null;
+ },
+ );
+ } catch {
+ // Web Locks may be unavailable in hardened/embedded browsers. In that
+ // case, defaults are safer than assigning one unscoped value twice.
+ return null;
+ }
+}
+
+export async function withBaseSettingsWriteLock(
+ userId: string,
+ task: () => Promise,
+): Promise {
+ if (typeof navigator === "undefined" || !navigator.locks) return false;
+ const encodedUserId = encodeURIComponent(userId);
+ const lockName = `${USER_SETTINGS_WRITE_LOCK_PREFIX}${encodedUserId.length}.${encodedUserId}`;
+ try {
+ return await navigator.locks.request(
+ lockName,
+ { mode: "exclusive" },
+ async () => {
+ await task();
+ return true;
+ },
+ );
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Bind the local fallback to one authenticated account for this tab.
+ *
+ * The historical cache was unscoped. The first authenticated account claims
+ * that legacy value; later accounts start from their own cache (or defaults)
+ * until the server handshake completes. This keeps tabs signed into different
+ * accounts from forwarding each other's storage events to their own servers.
+ */
+export async function activateBaseSettingsPersistence(
+ userId: string,
+): Promise<() => void> {
+ ensureBaseSettingsLoaded();
+ ensureStorageListenerRegistered();
+ const activationVersion = ++baseSettingsActivationVersion;
+ const activationMutationVersion = baseSettingsMutationVersion;
+ activeBaseSettingsUserId = userId;
+
+ let persisted = readUserSettingsCache(userId);
+ if (persisted === null) {
+ const claimedLegacy = await claimLegacySettings(userId);
+ persisted =
+ readUserSettingsCache(userId) ??
+ claimedLegacy ??
+ toPersistedUserSettings(DEFAULT_LOCAL_SETTINGS);
+ }
+ persisted = applyPendingUserSettings(userId, persisted);
+ persisted = saveUserSettingsCache(userId, persisted);
+
+ if (
+ activeBaseSettingsUserId === userId &&
+ baseSettingsActivationVersion === activationVersion &&
+ baseSettingsMutationVersion === activationMutationVersion
+ ) {
+ baseSettings = fromPersistedUserSettings(persisted);
+ emitChange();
+ }
+ return () => {
+ if (
+ activeBaseSettingsUserId === userId &&
+ baseSettingsActivationVersion === activationVersion
+ ) {
+ activeBaseSettingsUserId = null;
+ }
+ };
+}
+
function mergeSettingsSection(
settings: LocalSettings,
key: K,
@@ -69,25 +420,58 @@ function handleStorage(event: StorageEvent) {
ensureBaseSettingsLoaded();
if (event.key === null) {
+ if (activeBaseSettingsUserId !== null) return;
+ const previous = toPersistedUserSettings(baseSettings);
baseSettings = getLocalSettings();
threadModelNames.clear();
+ emitBaseSettingsMutation(
+ diffPersistedUserSettings(
+ previous,
+ toPersistedUserSettings(baseSettings),
+ ),
+ false,
+ );
emitChange();
return;
}
- if (event.key === LOCAL_SETTINGS_KEY) {
- baseSettings = getLocalSettings();
+ if (event.key.startsWith(THREAD_MODEL_KEY_PREFIX)) {
+ const threadId = event.key.slice(THREAD_MODEL_KEY_PREFIX.length);
+ threadModelNames.set(threadId, getThreadModelName(threadId));
emitChange();
return;
}
- if (!event.key.startsWith(THREAD_MODEL_KEY_PREFIX)) {
+ if (activeBaseSettingsUserId !== null) {
+ if (event.key !== userSettingsCacheStorageKey(activeBaseSettingsUserId)) {
+ return;
+ }
+ const cached = readUserSettingsCache(activeBaseSettingsUserId);
+ if (cached === null) return;
+ let persisted = applyPendingUserSettings(activeBaseSettingsUserId, cached);
+ persisted = saveUserSettingsCache(activeBaseSettingsUserId, persisted);
+ const previous = toPersistedUserSettings(baseSettings);
+ baseSettings = fromPersistedUserSettings(persisted);
+ emitBaseSettingsMutation(
+ diffPersistedUserSettings(previous, persisted),
+ false,
+ );
+ emitChange();
return;
}
- const threadId = event.key.slice(THREAD_MODEL_KEY_PREFIX.length);
- threadModelNames.set(threadId, getThreadModelName(threadId));
- emitChange();
+ if (event.key === LOCAL_SETTINGS_KEY) {
+ const previous = toPersistedUserSettings(baseSettings);
+ baseSettings = getLocalSettings();
+ emitBaseSettingsMutation(
+ diffPersistedUserSettings(
+ previous,
+ toPersistedUserSettings(baseSettings),
+ ),
+ false,
+ );
+ emitChange();
+ }
}
export function subscribe(listener: Listener): () => void {
@@ -105,6 +489,401 @@ export function getBaseSettingsSnapshot(): LocalSettings {
return baseSettings;
}
+export function getPersistedBaseSettingsSnapshot(): PersistedUserSettings {
+ ensureBaseSettingsLoaded();
+ return toPersistedUserSettings(baseSettings);
+}
+
+export function getBaseSettingsMutationVersion(): number {
+ return baseSettingsMutationVersion;
+}
+
+export function getBaseSettingsMutationBoundary(userId: string): {
+ version: number;
+ userId: string | null;
+ durableLeafOpIds: Record;
+} {
+ return {
+ version: baseSettingsLocalMutationVersion,
+ userId: activeBaseSettingsUserId,
+ durableLeafOpIds: Object.fromEntries(
+ PENDING_PATCH_LEAVES.map((leaf) => [
+ leaf,
+ getPendingBaseSettingsLeafOpId(userId, leaf),
+ ]),
+ ) as Record,
+ };
+}
+
+export function hydrateBaseSettingsFromServer(
+ settings: PersistedUserSettings,
+ expectedVersion: number,
+): boolean {
+ ensureBaseSettingsLoaded();
+ if (expectedVersion !== baseSettingsMutationVersion) return false;
+ const effective =
+ activeBaseSettingsUserId === null
+ ? settings
+ : applyPendingUserSettings(activeBaseSettingsUserId, settings);
+ baseSettings = saveBaseSettingsCache(fromPersistedUserSettings(effective));
+ emitChange();
+ return true;
+}
+
+export function subscribeBaseSettingsMutations(
+ listener: (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void,
+): () => void {
+ ensureBaseSettingsLoaded();
+ ensureStorageListenerRegistered();
+ mutationListeners.add(listener);
+ return () => mutationListeners.delete(listener);
+}
+
+function pendingPatchStorageKey(userId: string): string {
+ return `${USER_SETTINGS_PENDING_KEY_PREFIX}${encodeURIComponent(userId)}`;
+}
+
+function pendingPatchLeafPrefix(userId: string): string {
+ const encodedUserId = encodeURIComponent(userId);
+ return `${USER_SETTINGS_PENDING_KEY_PREFIX}leaf.${encodedUserId.length}.${encodedUserId}.`;
+}
+
+const PENDING_PATCH_LEAVES = [
+ "notification.enabled",
+ "tokenUsage.headerTotal",
+ "tokenUsage.inlineMode",
+ "context.model_name",
+ "context.mode",
+ "context.reasoning_effort",
+] as const satisfies readonly UserSettingsPatchLeaf[];
+
+interface PendingPatchEntry {
+ ackKey: string;
+ ackValue: string;
+ patch: PersistedUserSettingsPatch;
+}
+
+export interface PendingBaseSettingsPatchBatch {
+ patch: PersistedUserSettingsPatch;
+ acknowledge: () => boolean;
+}
+
+function pendingPatchLeafStorageKey(
+ userId: string,
+ leaf: UserSettingsPatchLeaf,
+): string {
+ return `${pendingPatchLeafPrefix(userId)}${leaf}`;
+}
+
+function pendingPatchLeafAckStorageKey(
+ userId: string,
+ leaf: UserSettingsPatchLeaf,
+): string {
+ return `${pendingPatchLeafStorageKey(userId, leaf)}.ack`;
+}
+
+function pendingPatchLegacyAckStorageKey(userId: string): string {
+ const encodedUserId = encodeURIComponent(userId);
+ return `${USER_SETTINGS_PENDING_KEY_PREFIX}legacy-ack.${encodedUserId.length}.${encodedUserId}`;
+}
+
+function splitPendingPatchLeaves(
+ patch: PersistedUserSettingsPatch,
+): Array<{ leaf: UserSettingsPatchLeaf; patch: PersistedUserSettingsPatch }> {
+ const leaves: Array<{
+ leaf: UserSettingsPatchLeaf;
+ patch: PersistedUserSettingsPatch;
+ }> = [];
+ if (patch.notification?.enabled !== undefined) {
+ leaves.push({
+ leaf: "notification.enabled",
+ patch: { notification: { enabled: patch.notification.enabled } },
+ });
+ }
+ if (patch.tokenUsage?.headerTotal !== undefined) {
+ leaves.push({
+ leaf: "tokenUsage.headerTotal",
+ patch: { tokenUsage: { headerTotal: patch.tokenUsage.headerTotal } },
+ });
+ }
+ if (patch.tokenUsage?.inlineMode !== undefined) {
+ leaves.push({
+ leaf: "tokenUsage.inlineMode",
+ patch: { tokenUsage: { inlineMode: patch.tokenUsage.inlineMode } },
+ });
+ }
+ if (patch.context?.model_name !== undefined) {
+ leaves.push({
+ leaf: "context.model_name",
+ patch: { context: { model_name: patch.context.model_name } },
+ });
+ }
+ if (patch.context?.mode !== undefined) {
+ leaves.push({
+ leaf: "context.mode",
+ patch: { context: { mode: patch.context.mode } },
+ });
+ }
+ if (patch.context?.reasoning_effort !== undefined) {
+ leaves.push({
+ leaf: "context.reasoning_effort",
+ patch: { context: { reasoning_effort: patch.context.reasoning_effort } },
+ });
+ }
+ return leaves;
+}
+
+function parsePendingLeafEnvelope(
+ serialized: string,
+ expectedLeaf: UserSettingsPatchLeaf,
+): { opId: string; patch: PersistedUserSettingsPatch } | null {
+ try {
+ const value = JSON.parse(serialized) as unknown;
+ if (
+ typeof value !== "object" ||
+ value === null ||
+ Array.isArray(value) ||
+ Object.keys(value).length !== 2 ||
+ !("opId" in value) ||
+ !("patch" in value) ||
+ typeof value.opId !== "string" ||
+ value.opId.length === 0 ||
+ value.opId.length > 128
+ ) {
+ return null;
+ }
+ const patch = parsePersistedUserSettingsPatch(value.patch);
+ if (patch === null) return null;
+ const split = splitPendingPatchLeaves(patch);
+ return split.length === 1 && split[0]?.leaf === expectedLeaf
+ ? { opId: value.opId, patch }
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function readPendingLeafEnvelope(
+ userId: string,
+ leaf: UserSettingsPatchLeaf,
+): { opId: string; patch: PersistedUserSettingsPatch } | null {
+ const serialized = safeLocalStorage.getItem(
+ pendingPatchLeafStorageKey(userId, leaf),
+ );
+ return serialized === null
+ ? null
+ : parsePendingLeafEnvelope(serialized, leaf);
+}
+
+function readPendingPatchEntries(userId: string): PendingPatchEntry[] {
+ const entries: PendingPatchEntry[] = [];
+ const legacyKey = pendingPatchStorageKey(userId);
+ const keys: Array<{ key: string; leaf: UserSettingsPatchLeaf | null }> = [
+ { key: legacyKey, leaf: null },
+ ...PENDING_PATCH_LEAVES.map((leaf) => ({
+ key: pendingPatchLeafStorageKey(userId, leaf),
+ leaf,
+ })),
+ ];
+ for (const { key, leaf } of keys) {
+ const serialized = safeLocalStorage.getItem(key);
+ if (serialized === null) continue;
+ if (leaf !== null) {
+ const envelope = parsePendingLeafEnvelope(serialized, leaf);
+ const ackKey = pendingPatchLeafAckStorageKey(userId, leaf);
+ if (
+ envelope !== null &&
+ safeLocalStorage.getItem(ackKey) !== envelope.opId
+ ) {
+ entries.push({
+ ackKey,
+ ackValue: envelope.opId,
+ ...envelope,
+ });
+ }
+ continue;
+ }
+ try {
+ const patch = parsePersistedUserSettingsPatch(JSON.parse(serialized));
+ const ackKey = pendingPatchLegacyAckStorageKey(userId);
+ if (patch !== null && safeLocalStorage.getItem(ackKey) !== serialized) {
+ entries.push({
+ ackKey,
+ ackValue: serialized,
+ patch,
+ });
+ }
+ } catch {}
+ }
+ return entries;
+}
+
+export function getPendingBaseSettingsPatchBatch(
+ userId: string,
+): PendingBaseSettingsPatchBatch | null {
+ const entries = readPendingPatchEntries(userId);
+ if (entries.length === 0) return null;
+ const patch = entries.reduce(
+ (merged, entry) => mergePersistedUserSettingsPatches(merged, entry.patch),
+ null,
+ );
+ if (patch === null) return null;
+ return {
+ patch,
+ acknowledge: () => {
+ let acknowledged = true;
+ for (const entry of entries) {
+ if (!safeLocalStorage.setItem(entry.ackKey, entry.ackValue)) {
+ acknowledged = false;
+ }
+ }
+ return acknowledged;
+ },
+ };
+}
+
+export function getPendingBaseSettingsPatch(
+ userId: string,
+): PersistedUserSettingsPatch | null {
+ return getPendingBaseSettingsPatchBatch(userId)?.patch ?? null;
+}
+
+export function getPendingBaseSettingsLeafOpId(
+ userId: string,
+ leaf: UserSettingsPatchLeaf,
+): string | null {
+ return readPendingLeafEnvelope(userId, leaf)?.opId ?? null;
+}
+
+export function appendPendingBaseSettingsPatch(
+ userId: string,
+ patch: PersistedUserSettingsPatch,
+): UserSettingsMutationPersistence {
+ const validated = parsePersistedUserSettingsPatch(patch);
+ const persistence: UserSettingsMutationPersistence = {
+ durableLeaves: [],
+ volatileLeaves: [],
+ };
+ if (validated === null) return persistence;
+ for (const { leaf, patch: leafPatch } of splitPendingPatchLeaves(validated)) {
+ const observedDurableOpId =
+ readPendingLeafEnvelope(userId, leaf)?.opId ?? null;
+ const opId =
+ globalThis.crypto?.randomUUID?.() ??
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
+ if (
+ safeLocalStorage.setItem(
+ pendingPatchLeafStorageKey(userId, leaf),
+ JSON.stringify({ opId, patch: leafPatch }),
+ )
+ ) {
+ outstandingVolatileMutationsByUser.get(userId)?.delete(leaf);
+ persistence.durableLeaves.push(leaf);
+ } else {
+ const volatile = {
+ version: ++volatileMutationVersion,
+ leaf,
+ patch: leafPatch,
+ observedDurableOpId,
+ };
+ let outstanding = outstandingVolatileMutationsByUser.get(userId);
+ if (outstanding === undefined) {
+ outstanding = new Map();
+ outstandingVolatileMutationsByUser.set(userId, outstanding);
+ }
+ outstanding.set(leaf, volatile);
+ persistence.volatileLeaves.push(volatile);
+ }
+ }
+ return persistence;
+}
+
+export function getOutstandingBaseSettingsVolatileLeaves(
+ userId: string,
+): VolatileUserSettingsPatchLeaf[] {
+ const outstanding = outstandingVolatileMutationsByUser.get(userId);
+ if (outstanding === undefined) return [];
+ for (const [leaf, volatile] of outstanding) {
+ if (
+ getPendingBaseSettingsLeafOpId(userId, leaf) !==
+ volatile.observedDurableOpId
+ ) {
+ outstanding.delete(leaf);
+ }
+ }
+ if (outstanding.size === 0) {
+ outstandingVolatileMutationsByUser.delete(userId);
+ return [];
+ }
+ return [...outstanding.values()];
+}
+
+export function acknowledgeBaseSettingsVolatileLeaves(
+ userId: string,
+ leaves: readonly VolatileUserSettingsPatchLeaf[],
+): void {
+ const outstanding = outstandingVolatileMutationsByUser.get(userId);
+ if (outstanding === undefined) return;
+ for (const volatile of leaves) {
+ if (outstanding.get(volatile.leaf)?.version === volatile.version) {
+ outstanding.delete(volatile.leaf);
+ }
+ }
+ if (outstanding.size === 0) {
+ outstandingVolatileMutationsByUser.delete(userId);
+ }
+}
+
+export function savePendingBaseSettingsPatch(
+ userId: string,
+ patch: PersistedUserSettingsPatch | null,
+): void {
+ if (patch === null) {
+ for (const key of [
+ pendingPatchStorageKey(userId),
+ pendingPatchLegacyAckStorageKey(userId),
+ ...PENDING_PATCH_LEAVES.flatMap((leaf) => [
+ pendingPatchLeafStorageKey(userId, leaf),
+ pendingPatchLeafAckStorageKey(userId, leaf),
+ ]),
+ ]) {
+ safeLocalStorage.removeItem(key);
+ }
+ return;
+ }
+ appendPendingBaseSettingsPatch(userId, patch);
+}
+
+export function seedPendingBaseSettingsMutationsSince(
+ userId: string,
+ boundaryVersion: number,
+ boundaryLeafOpIds: Record,
+): VolatileUserSettingsPatchLeaf[] {
+ let eligiblePatch: PersistedUserSettingsPatch | null = null;
+ for (const [leaf, mutation] of latestLocalMutationByLeaf) {
+ if (
+ mutation.version <= boundaryVersion ||
+ (mutation.userId !== null && mutation.userId !== userId)
+ ) {
+ continue;
+ }
+ if (
+ getPendingBaseSettingsLeafOpId(userId, leaf) === boundaryLeafOpIds[leaf]
+ ) {
+ eligiblePatch = mergePersistedUserSettingsPatches(
+ eligiblePatch,
+ mutation.patch,
+ );
+ }
+ }
+ return eligiblePatch === null
+ ? []
+ : appendPendingBaseSettingsPatch(userId, eligiblePatch).volatileLeaves;
+}
+
export function getThreadModelSnapshot(threadId: string): string | undefined {
ensureBaseSettingsLoaded();
@@ -119,8 +898,16 @@ export const updateLocalSettings: LocalSettingsSetter = (key, value) => {
ensureBaseSettingsLoaded();
ensureStorageListenerRegistered();
- baseSettings = mergeSettingsSection(baseSettings, key, value);
- saveLocalSettings(baseSettings);
+ const mutationBaseline = latestBaseSettingsForMutation();
+ const previous = toPersistedUserSettings(mutationBaseline);
+ const locallyMerged = mergeSettingsSection(mutationBaseline, key, value);
+ const patch = diffPersistedUserSettings(
+ previous,
+ toPersistedUserSettings(locallyMerged),
+ );
+ baseSettings = locallyMerged;
+ emitBaseSettingsMutation(patch);
+ baseSettings = saveBaseSettingsCache(baseSettings, patch);
emitChange();
};
@@ -132,9 +919,16 @@ export function updateThreadSettings(
ensureBaseSettingsLoaded();
ensureStorageListenerRegistered();
- const nextBaseSettings = mergeSettingsSection(baseSettings, key, value);
- baseSettings = nextBaseSettings;
- saveLocalSettings(baseSettings);
+ const mutationBaseline = latestBaseSettingsForMutation();
+ const previous = toPersistedUserSettings(mutationBaseline);
+ const locallyMerged = mergeSettingsSection(mutationBaseline, key, value);
+ const patch = diffPersistedUserSettings(
+ previous,
+ toPersistedUserSettings(locallyMerged),
+ );
+ baseSettings = locallyMerged;
+ emitBaseSettingsMutation(patch);
+ baseSettings = saveBaseSettingsCache(baseSettings, patch);
if (
key === "context" &&
diff --git a/frontend/src/core/settings/sync.ts b/frontend/src/core/settings/sync.ts
new file mode 100644
index 00000000000..3124e0e8412
--- /dev/null
+++ b/frontend/src/core/settings/sync.ts
@@ -0,0 +1,256 @@
+import type { UserSettingsResponse } from "./api";
+import {
+ applyPersistedUserSettingsPatch,
+ mergePersistedUserSettingsPatches,
+ type PersistedUserSettings,
+ type PersistedUserSettingsPatch,
+} from "./persistence";
+
+export interface UserSettingsTransport {
+ get: () => Promise;
+ initialize: (
+ settings: PersistedUserSettings,
+ ) => Promise;
+ patch: (patch: PersistedUserSettingsPatch) => Promise;
+}
+
+export type UserSettingsPatchLeaf =
+ | "notification.enabled"
+ | "tokenUsage.headerTotal"
+ | "tokenUsage.inlineMode"
+ | "context.model_name"
+ | "context.mode"
+ | "context.reasoning_effort";
+
+export interface VolatileUserSettingsPatchLeaf {
+ version: number;
+ leaf: UserSettingsPatchLeaf;
+ patch: PersistedUserSettingsPatch;
+ observedDurableOpId: string | null;
+}
+
+export interface UserSettingsMutationPersistence {
+ durableLeaves: UserSettingsPatchLeaf[];
+ volatileLeaves: VolatileUserSettingsPatchLeaf[];
+}
+
+export interface UserSettingsSyncStore {
+ getSettings: () => PersistedUserSettings;
+ getMutationVersion: () => number;
+ getPendingPatchBatch: () => {
+ patch: PersistedUserSettingsPatch;
+ acknowledge: () => boolean;
+ } | null;
+ getDurableLeafOpId: (leaf: UserSettingsPatchLeaf) => string | null;
+ acknowledgeVolatileLeaves: (
+ leaves: readonly VolatileUserSettingsPatchLeaf[],
+ ) => void;
+ withWriteLock: (task: () => Promise) => Promise;
+ hydrate: (
+ settings: PersistedUserSettings,
+ expectedVersion: number,
+ ) => boolean;
+ subscribeMutations: (
+ listener: (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void,
+ ) => () => void;
+}
+
+/**
+ * Coordinates one authenticated user's local fallback with server state.
+ *
+ * Initial reads are authoritative, except for local edits made after the read
+ * starts. Those edits are folded over the server snapshot and written through
+ * a serialized queue. PATCH responses never mutate local state, so an older
+ * async response cannot roll back a newer click. Failed writes remain in a
+ * user-scoped local outbox; the next handshake folds that patch over its GET
+ * result before hydration, so reconnect/reload cannot erase the unsynced edit.
+ */
+export class UserSettingsSyncController {
+ private stopped = false;
+ private started = false;
+ private bootstrapped = false;
+ private writeFailed = false;
+ private readonly volatileLeaves = new Map<
+ UserSettingsPatchLeaf,
+ VolatileUserSettingsPatchLeaf
+ >();
+ private writeTask: Promise | null = null;
+ private unsubscribe: (() => void) | null = null;
+
+ constructor(
+ private readonly store: UserSettingsSyncStore,
+ private readonly transport: UserSettingsTransport,
+ initialVolatileLeaves: VolatileUserSettingsPatchLeaf[] = [],
+ ) {
+ for (const leaf of initialVolatileLeaves) {
+ this.volatileLeaves.set(leaf.leaf, leaf);
+ }
+ }
+
+ async start(): Promise {
+ if (this.started) return;
+ this.started = true;
+ this.unsubscribe = this.store.subscribeMutations((_patch, persistence) => {
+ for (const leaf of persistence.durableLeaves) {
+ this.volatileLeaves.delete(leaf);
+ }
+ for (const leaf of persistence.volatileLeaves) {
+ this.volatileLeaves.set(leaf.leaf, leaf);
+ }
+ this.writeFailed = false;
+ if (this.bootstrapped) this.scheduleWrites();
+ });
+ const hydrationVersion = this.store.getMutationVersion();
+
+ try {
+ const acquired = await this.store.withWriteLock(async () => {
+ const response = await this.transport.get();
+ if (this.stopped) return;
+
+ const baselineResponse =
+ response.settings === null
+ ? await this.transport.initialize(this.store.getSettings())
+ : response;
+ this.hydrateResponse(baselineResponse, hydrationVersion);
+ });
+ if (acquired) {
+ this.scheduleWrites();
+ return;
+ }
+
+ // Web Locks are optional browser functionality. Without them, keep the
+ // cross-device read path available but leave PUT/PATCH fail-closed so
+ // two tabs cannot race an outbox acknowledgement.
+ const response = await this.transport.get();
+ this.hydrateResponse(response, hydrationVersion);
+ } catch {
+ // Offline/auth-refresh/validation failures are intentionally non-fatal.
+ // The existing localStorage-backed behavior remains available, and the
+ // next authenticated page load tries the handshake again.
+ }
+ }
+
+ stop(): void {
+ this.stopped = true;
+ this.unsubscribe?.();
+ this.unsubscribe = null;
+ }
+
+ async whenIdle(): Promise {
+ while (this.writeTask) await this.writeTask;
+ }
+
+ private hydrateResponse(
+ response: UserSettingsResponse,
+ hydrationVersion: number,
+ ): void {
+ if (this.stopped || response.settings === null) return;
+ const desiredPatch = this.composePendingPatch(
+ this.store.getPendingPatchBatch(),
+ ).patch;
+ const desired = desiredPatch
+ ? applyPersistedUserSettingsPatch(response.settings, desiredPatch)
+ : response.settings;
+ this.store.hydrate(desired, hydrationVersion);
+ this.bootstrapped = true;
+ }
+
+ private composePendingPatch(
+ durableBatch: ReturnType,
+ ): {
+ patch: PersistedUserSettingsPatch | null;
+ volatileLeaves: VolatileUserSettingsPatchLeaf[];
+ } {
+ let patch = durableBatch?.patch ?? null;
+ const volatileLeaves: VolatileUserSettingsPatchLeaf[] = [];
+ const supersededLeaves: VolatileUserSettingsPatchLeaf[] = [];
+ for (const [leaf, volatile] of this.volatileLeaves) {
+ const currentOpId = this.store.getDurableLeafOpId(leaf);
+ if (currentOpId !== volatile.observedDurableOpId) {
+ this.volatileLeaves.delete(leaf);
+ supersededLeaves.push(volatile);
+ continue;
+ }
+ patch = mergePersistedUserSettingsPatches(patch, volatile.patch);
+ volatileLeaves.push(volatile);
+ }
+ this.store.acknowledgeVolatileLeaves(supersededLeaves);
+ return { patch, volatileLeaves };
+ }
+
+ private scheduleWrites(): void {
+ if (
+ this.stopped ||
+ !this.bootstrapped ||
+ this.writeFailed ||
+ this.writeTask
+ )
+ return;
+ this.writeTask = this.drainWrites().finally(() => {
+ this.writeTask = null;
+ if (
+ !this.writeFailed &&
+ (this.volatileLeaves.size > 0 ||
+ this.store.getPendingPatchBatch() !== null)
+ ) {
+ this.scheduleWrites();
+ }
+ });
+ }
+
+ private async drainWrites(): Promise {
+ while (!this.stopped) {
+ let attempted = false;
+ let requestFailed = false;
+ let acknowledgeFailed = false;
+ const acquired = await this.store.withWriteLock(async () => {
+ if (this.stopped) return;
+ const durableBatch = this.store.getPendingPatchBatch();
+ const { patch, volatileLeaves } =
+ this.composePendingPatch(durableBatch);
+ if (patch === null) return;
+ attempted = true;
+ try {
+ const response = await this.transport.patch(patch);
+ if (response.settings === null) {
+ const recovered = await this.transport.initialize(
+ this.store.getSettings(),
+ );
+ if (recovered.settings === null) {
+ throw new Error(
+ "User settings recovery did not initialize a record",
+ );
+ }
+ const reapplied = await this.transport.patch(patch);
+ if (reapplied.settings === null) {
+ throw new Error(
+ "User settings recovery did not retain the pending patch",
+ );
+ }
+ }
+ } catch {
+ requestFailed = true;
+ return;
+ }
+ if (durableBatch !== null && !durableBatch.acknowledge()) {
+ acknowledgeFailed = true;
+ return;
+ }
+ this.store.acknowledgeVolatileLeaves(volatileLeaves);
+ for (const volatile of volatileLeaves) {
+ if (this.volatileLeaves.get(volatile.leaf) === volatile) {
+ this.volatileLeaves.delete(volatile.leaf);
+ }
+ }
+ });
+ if (!acquired || requestFailed || acknowledgeFailed) {
+ this.writeFailed = true;
+ return;
+ }
+ if (!attempted) return;
+ }
+ }
+}
diff --git a/frontend/src/core/settings/user-settings-sync.tsx b/frontend/src/core/settings/user-settings-sync.tsx
new file mode 100644
index 00000000000..d93aaf5266d
--- /dev/null
+++ b/frontend/src/core/settings/user-settings-sync.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import {
+ fetchUserSettings,
+ initializeUserSettings,
+ patchUserSettings,
+} from "./api";
+import {
+ acknowledgeBaseSettingsVolatileLeaves,
+ activateBaseSettingsPersistence,
+ getBaseSettingsMutationBoundary,
+ getBaseSettingsMutationVersion,
+ getOutstandingBaseSettingsVolatileLeaves,
+ getPendingBaseSettingsLeafOpId,
+ getPendingBaseSettingsPatchBatch,
+ getPersistedBaseSettingsSnapshot,
+ hydrateBaseSettingsFromServer,
+ seedPendingBaseSettingsMutationsSince,
+ subscribeBaseSettingsMutations,
+ withBaseSettingsWriteLock,
+} from "./store";
+import { UserSettingsSyncController } from "./sync";
+
+function transportForUser(userId: string) {
+ return {
+ get: () => fetchUserSettings(userId),
+ initialize: (settings: Parameters[1]) =>
+ initializeUserSettings(userId, settings),
+ patch: (patch: Parameters[1]) =>
+ patchUserSettings(userId, patch),
+ };
+}
+
+export function UserSettingsSync({
+ enabled,
+ userId,
+}: {
+ enabled: boolean;
+ userId: string;
+}) {
+ return (
+
+ );
+}
+
+function UserSettingsSyncLifecycle({
+ enabled,
+ userId,
+}: {
+ enabled: boolean;
+ userId: string;
+}) {
+ const [activationBoundary] = useState(() =>
+ getBaseSettingsMutationBoundary(userId),
+ );
+
+ useEffect(() => {
+ if (!enabled || !userId) return;
+ let cancelled = false;
+ let controller: UserSettingsSyncController | null = null;
+ let deactivatePersistence: (() => void) | null = null;
+ void activateBaseSettingsPersistence(userId).then((deactivate) => {
+ if (cancelled) {
+ deactivate();
+ return;
+ }
+ if (
+ activationBoundary.userId === null ||
+ activationBoundary.userId === userId
+ ) {
+ seedPendingBaseSettingsMutationsSince(
+ userId,
+ activationBoundary.version,
+ activationBoundary.durableLeafOpIds,
+ );
+ }
+ deactivatePersistence = deactivate;
+ const store = {
+ getSettings: getPersistedBaseSettingsSnapshot,
+ getMutationVersion: getBaseSettingsMutationVersion,
+ getPendingPatchBatch: () => getPendingBaseSettingsPatchBatch(userId),
+ getDurableLeafOpId: (
+ leaf: Parameters[1],
+ ) => getPendingBaseSettingsLeafOpId(userId, leaf),
+ acknowledgeVolatileLeaves: (
+ leaves: Parameters[1],
+ ) => acknowledgeBaseSettingsVolatileLeaves(userId, leaves),
+ withWriteLock: (task: () => Promise) =>
+ withBaseSettingsWriteLock(userId, task),
+ hydrate: hydrateBaseSettingsFromServer,
+ subscribeMutations: subscribeBaseSettingsMutations,
+ };
+ controller = new UserSettingsSyncController(
+ store,
+ transportForUser(userId),
+ getOutstandingBaseSettingsVolatileLeaves(userId),
+ );
+ void controller.start();
+ });
+ return () => {
+ cancelled = true;
+ controller?.stop();
+ deactivatePersistence?.();
+ };
+ }, [activationBoundary, enabled, userId]);
+
+ return null;
+}
diff --git a/frontend/tests/unit/app/layout-boundaries.test.ts b/frontend/tests/unit/app/layout-boundaries.test.ts
index 25659b60d52..7112f631ae0 100644
--- a/frontend/tests/unit/app/layout-boundaries.test.ts
+++ b/frontend/tests/unit/app/layout-boundaries.test.ts
@@ -46,4 +46,12 @@ describe("layout performance boundaries", () => {
expect(layout).not.toContain("getI18n");
}
});
+
+ it("mounts no user-settings requests in auth-disabled or static mode", () => {
+ const workspaceLayout = source("src/app/workspace/layout.tsx");
+
+ expect(workspaceLayout).toContain(
+ "enabled={!isAuthDisabledMode() && !isStaticWebsiteOnly()}",
+ );
+ });
});
diff --git a/frontend/tests/unit/core/settings/api.test.ts b/frontend/tests/unit/core/settings/api.test.ts
new file mode 100644
index 00000000000..9008b3a6768
--- /dev/null
+++ b/frontend/tests/unit/core/settings/api.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it, rs } from "@rstest/core";
+
+rs.mock("@/core/api/fetcher", () => ({
+ fetch: rs.fn(),
+}));
+
+rs.mock("@/core/config", () => ({
+ getBackendBaseURL: () => "",
+}));
+
+import { fetch } from "@/core/api/fetcher";
+import {
+ fetchUserSettings,
+ initializeUserSettings,
+ patchUserSettings,
+} from "@/core/settings/api";
+
+const mockedFetch = rs.mocked(fetch);
+const settings = {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" as const },
+ context: {},
+};
+
+function response(): Response {
+ return new Response(JSON.stringify({ settings, revision: 1 }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+beforeEach(() => {
+ mockedFetch.mockReset();
+ mockedFetch.mockImplementation(async () => response());
+});
+
+describe("user settings API", () => {
+ it("binds reads to the user that mounted the sync controller", async () => {
+ await fetchUserSettings("user-a");
+
+ expect(mockedFetch).toHaveBeenCalledWith("/api/user-preferences", {
+ headers: { "X-DeerFlow-Expected-User-Id": "user-a" },
+ });
+ });
+
+ it("binds initialization and patches to the same expected owner", async () => {
+ await initializeUserSettings("user-a", settings);
+ await patchUserSettings("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+
+ expect(mockedFetch).toHaveBeenNthCalledWith(1, "/api/user-preferences", {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "X-DeerFlow-Expected-User-Id": "user-a",
+ },
+ body: JSON.stringify({ settings }),
+ });
+ expect(mockedFetch).toHaveBeenNthCalledWith(2, "/api/user-preferences", {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "X-DeerFlow-Expected-User-Id": "user-a",
+ },
+ body: JSON.stringify({ tokenUsage: { inlineMode: "off" } }),
+ });
+ });
+});
diff --git a/frontend/tests/unit/core/settings/persistence.test.ts b/frontend/tests/unit/core/settings/persistence.test.ts
new file mode 100644
index 00000000000..0ae3e026a74
--- /dev/null
+++ b/frontend/tests/unit/core/settings/persistence.test.ts
@@ -0,0 +1,77 @@
+import { expect, test } from "@rstest/core";
+
+import {
+ diffPersistedUserSettings,
+ parsePersistedUserSettings,
+ parsePersistedUserSettingsPatch,
+ toPersistedUserSettings,
+} from "@/core/settings/persistence";
+
+test("the server projection allowlists base settings and excludes thread/private state", () => {
+ const projected = toPersistedUserSettings({
+ notification: { enabled: true, permission: "granted" },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: {
+ model_name: "model-a",
+ mode: "pro",
+ reasoning_effort: "high",
+ thread_id: "private-thread",
+ agent_name: "private-agent",
+ token: "secret",
+ },
+ } as never);
+
+ expect(projected).toEqual({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: {
+ model_name: "model-a",
+ mode: "pro",
+ reasoning_effort: "high",
+ },
+ });
+});
+
+test("rejects malformed or oversized server/local settings", () => {
+ expect(
+ parsePersistedUserSettings({
+ notification: { enabled: "yes" },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ }),
+ ).toBeNull();
+
+ expect(
+ parsePersistedUserSettings({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: { model_name: "x".repeat(257) },
+ }),
+ ).toBeNull();
+});
+
+test("rejects empty patches at the same boundary as the Gateway schema", () => {
+ expect(parsePersistedUserSettingsPatch({})).toBeNull();
+ expect(parsePersistedUserSettingsPatch({ context: {} })).toBeNull();
+ expect(parsePersistedUserSettingsPatch({ tokenUsage: {} })).toBeNull();
+});
+
+test("snapshot diffs contain only changed leaves and encode removals as null", () => {
+ expect(
+ diffPersistedUserSettings(
+ {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: { model_name: "old-model", mode: "thinking" },
+ },
+ {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ context: { mode: "thinking" },
+ },
+ ),
+ ).toEqual({
+ tokenUsage: { inlineMode: "off" },
+ context: { model_name: null },
+ });
+});
diff --git a/frontend/tests/unit/core/settings/sync.test.ts b/frontend/tests/unit/core/settings/sync.test.ts
new file mode 100644
index 00000000000..d2965f83c6e
--- /dev/null
+++ b/frontend/tests/unit/core/settings/sync.test.ts
@@ -0,0 +1,713 @@
+import { expect, rs, test } from "@rstest/core";
+
+import type {
+ PersistedUserSettings,
+ PersistedUserSettingsPatch,
+} from "@/core/settings/persistence";
+import { mergePersistedUserSettingsPatches } from "@/core/settings/persistence";
+import {
+ UserSettingsSyncController,
+ type UserSettingsMutationPersistence,
+ type UserSettingsPatchLeaf,
+ type UserSettingsSyncStore,
+ type UserSettingsTransport,
+} from "@/core/settings/sync";
+
+function settings(modelName = "local-model"): PersistedUserSettings {
+ return {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {
+ model_name: modelName,
+ mode: "thinking",
+ reasoning_effort: "medium",
+ },
+ };
+}
+
+class FakeStore implements UserSettingsSyncStore {
+ current: PersistedUserSettings;
+ version = 0;
+ lockAvailable = true;
+ hydrateCalls: PersistedUserSettings[] = [];
+ private nextOperationId = 0;
+ private operations = new Map();
+ private listeners = new Set<
+ (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void
+ >();
+
+ constructor(initial: PersistedUserSettings) {
+ this.current = structuredClone(initial);
+ }
+
+ getSettings = () => structuredClone(this.current);
+ getMutationVersion = () => this.version;
+ getPendingPatchBatch = () => {
+ const entries = [...this.operations.entries()];
+ if (entries.length === 0) return null;
+ const patch = entries.reduce(
+ (merged, [, operation]) =>
+ mergePersistedUserSettingsPatches(merged, operation),
+ null,
+ )!;
+ return {
+ patch: structuredClone(patch),
+ acknowledge: () => {
+ for (const [id] of entries) this.operations.delete(id);
+ return true;
+ },
+ };
+ };
+ getDurableLeafOpId: (leaf: UserSettingsPatchLeaf) => string | null = (
+ _leaf,
+ ) => null;
+ acknowledgeVolatileLeaves = () => undefined;
+ withWriteLock = async (task: () => Promise) => {
+ if (!this.lockAvailable) return false;
+ await task();
+ return true;
+ };
+
+ get pendingPatch(): PersistedUserSettingsPatch | null {
+ return this.getPendingPatchBatch()?.patch ?? null;
+ }
+
+ set pendingPatch(patch: PersistedUserSettingsPatch | null) {
+ this.operations.clear();
+ if (patch !== null) this.appendPendingPatch(patch);
+ }
+
+ hydrate = (next: PersistedUserSettings, expectedVersion: number) => {
+ if (expectedVersion !== this.version) return false;
+ this.current = structuredClone(next);
+ this.hydrateCalls.push(structuredClone(next));
+ return true;
+ };
+
+ subscribeMutations = (
+ listener: (
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) => void,
+ ) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ mutate(patch: PersistedUserSettingsPatch) {
+ this.appendPendingPatch(patch);
+ const durableLeaves: UserSettingsPatchLeaf[] = [];
+ if (patch.context?.model_name !== undefined) {
+ durableLeaves.push("context.model_name");
+ }
+ if (patch.tokenUsage?.inlineMode !== undefined) {
+ durableLeaves.push("tokenUsage.inlineMode");
+ }
+ this.notifyMutation(patch, { durableLeaves, volatileLeaves: [] });
+ }
+
+ notifyMutation(
+ patch: PersistedUserSettingsPatch,
+ persistence: UserSettingsMutationPersistence,
+ ) {
+ this.version += 1;
+ if (patch.context?.model_name !== undefined) {
+ this.current.context.model_name = patch.context.model_name ?? undefined;
+ }
+ if (patch.tokenUsage?.inlineMode !== undefined) {
+ this.current.tokenUsage.inlineMode = patch.tokenUsage.inlineMode;
+ }
+ for (const listener of this.listeners) {
+ listener(patch, persistence);
+ }
+ }
+
+ appendPendingPatch(patch: PersistedUserSettingsPatch) {
+ this.operations.set(++this.nextOperationId, structuredClone(patch));
+ }
+}
+
+function transportWithServer(
+ initial: PersistedUserSettings | null,
+): UserSettingsTransport & {
+ initialize: ReturnType;
+ patch: ReturnType;
+} {
+ let server = initial === null ? null : structuredClone(initial);
+ return {
+ get: rs.fn(async () => ({ settings: server, revision: server ? 1 : 0 })),
+ initialize: rs.fn(async (local: PersistedUserSettings) => {
+ server ??= structuredClone(local);
+ return { settings: structuredClone(server), revision: 1 };
+ }),
+ patch: rs.fn(async (patch: PersistedUserSettingsPatch) => {
+ if (!server) throw new Error("server was not initialized");
+ if (patch.context?.model_name !== undefined) {
+ server.context.model_name = patch.context.model_name ?? undefined;
+ }
+ if (patch.tokenUsage?.inlineMode !== undefined) {
+ server.tokenUsage.inlineMode = patch.tokenUsage.inlineMode;
+ }
+ return { settings: structuredClone(server), revision: 2 };
+ }),
+ };
+}
+
+test("hydrates an authenticated user's existing server settings", async () => {
+ const store = new FakeStore(settings("stale-local"));
+ const transport = transportWithServer(settings("server-model"));
+ const controller = new UserSettingsSyncController(store, transport);
+
+ await controller.start();
+
+ expect(store.current.context.model_name).toBe("server-model");
+ expect(transport.initialize).not.toHaveBeenCalled();
+ expect(transport.patch).not.toHaveBeenCalled();
+ controller.stop();
+});
+
+test("migrates valid local base settings only when the server record is absent", async () => {
+ const local = settings("local-only");
+ const store = new FakeStore(local);
+ const transport = transportWithServer(null);
+ const controller = new UserSettingsSyncController(store, transport);
+
+ await controller.start();
+
+ expect(transport.initialize).toHaveBeenCalledTimes(1);
+ expect(transport.initialize).toHaveBeenCalledWith(local);
+ expect(transport.patch).not.toHaveBeenCalled();
+ controller.stop();
+});
+
+test("keeps local fallback when hydration fails or the gateway is offline", async () => {
+ const local = settings("offline-local");
+ const store = new FakeStore(local);
+ const transport: UserSettingsTransport = {
+ get: rs.fn(async () => {
+ throw new Error("offline");
+ }),
+ initialize: rs.fn(),
+ patch: rs.fn(),
+ };
+ const controller = new UserSettingsSyncController(store, transport);
+
+ await expect(controller.start()).resolves.toBeUndefined();
+
+ expect(store.current).toEqual(local);
+ expect(store.hydrateCalls).toHaveLength(0);
+ controller.stop();
+});
+
+test("replays a newer local mutation instead of applying a stale hydrate response", async () => {
+ let resolveGet!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const getPromise = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveGet = resolve;
+ });
+ const store = new FakeStore(settings("local-before-load"));
+ const transport = transportWithServer(settings("server-before-load"));
+ transport.get = rs.fn(() => getPromise);
+ const controller = new UserSettingsSyncController(store, transport);
+
+ const starting = controller.start();
+ store.mutate({ context: { model_name: "new-local-model" } });
+ resolveGet({ settings: settings("server-before-load"), revision: 1 });
+ await starting;
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("new-local-model");
+ expect(transport.patch).toHaveBeenCalledWith({
+ context: { model_name: "new-local-model" },
+ });
+ controller.stop();
+});
+
+test("rereads a later durable leaf after GET even without a storage event", async () => {
+ let resolveGet!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const getPromise = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveGet = resolve;
+ });
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ transport.get = rs.fn(() => getPromise);
+ const controller = new UserSettingsSyncController(store, transport);
+ const starting = controller.start();
+
+ store.mutate({ context: { model_name: "durable-p" } });
+ store.appendPendingPatch({ context: { model_name: "durable-q" } });
+ store.current.context.model_name = "durable-q";
+ resolveGet({ settings: settings("server"), revision: 1 });
+ await starting;
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("durable-q");
+ expect(transport.patch).toHaveBeenCalledWith({
+ context: { model_name: "durable-q" },
+ });
+ controller.stop();
+});
+
+test("rejects a stale GET after a newer mutation was already acknowledged", async () => {
+ let resolveGet!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const getPromise = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveGet = resolve;
+ });
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("old-server"));
+ transport.get = rs.fn(() => getPromise);
+ const controller = new UserSettingsSyncController(store, transport);
+ const starting = controller.start();
+
+ store.mutate({ context: { model_name: "newer-local" } });
+ expect(store.getPendingPatchBatch()?.acknowledge()).toBe(true);
+ resolveGet({ settings: settings("old-server"), revision: 1 });
+ await starting;
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("newer-local");
+ expect(store.hydrateCalls).toHaveLength(0);
+ expect(transport.patch).not.toHaveBeenCalled();
+ controller.stop();
+});
+
+test("holds the write lock while bootstrap folds a preexisting outbox over GET", async () => {
+ const store = new FakeStore(settings("pending"));
+ store.pendingPatch = { context: { model_name: "pending" } };
+ let lockTail = Promise.resolve();
+ const withSharedLock = async (task: () => Promise) => {
+ const previous = lockTail;
+ let release!: () => void;
+ lockTail = new Promise((resolve) => {
+ release = resolve;
+ });
+ await previous;
+ try {
+ await task();
+ return true;
+ } finally {
+ release();
+ }
+ };
+ store.withWriteLock = withSharedLock;
+ let resolveGet!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const getPromise = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveGet = resolve;
+ });
+ let markGetStarted!: () => void;
+ const getStarted = new Promise((resolve) => {
+ markGetStarted = resolve;
+ });
+ const transport = transportWithServer(settings("old-server"));
+ transport.get = rs.fn(() => {
+ markGetStarted();
+ return getPromise;
+ });
+ const controller = new UserSettingsSyncController(store, transport);
+ const starting = controller.start();
+ await getStarted;
+
+ let otherTabAcknowledged = false;
+ const otherTabWrite = withSharedLock(async () => {
+ otherTabAcknowledged = true;
+ expect(store.getPendingPatchBatch()?.acknowledge()).toBe(true);
+ });
+ await Promise.resolve();
+ expect(otherTabAcknowledged).toBe(false);
+
+ resolveGet({ settings: settings("old-server"), revision: 1 });
+ await Promise.all([starting, otherTabWrite]);
+ await controller.whenIdle();
+
+ expect(otherTabAcknowledged).toBe(true);
+ expect(store.hydrateCalls).toEqual([settings("pending")]);
+ expect(store.current.context.model_name).toBe("pending");
+ expect(transport.patch).not.toHaveBeenCalled();
+ controller.stop();
+});
+
+test("does not let an older PATCH response roll back a newer local edit", async () => {
+ let resolveFirstPatch!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const firstPatch = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveFirstPatch = resolve;
+ });
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ let patchCount = 0;
+ transport.patch = rs.fn(async () => {
+ patchCount += 1;
+ if (patchCount === 1) return firstPatch;
+ return { settings: settings("newest"), revision: 3 };
+ });
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+ await controller.whenIdle();
+
+ store.mutate({ context: { model_name: "older-edit" } });
+ store.mutate({ context: { model_name: "newest" } });
+ resolveFirstPatch({ settings: settings("older-edit"), revision: 2 });
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("newest");
+ expect(transport.patch).toHaveBeenCalledTimes(2);
+ expect(transport.patch).toHaveBeenNthCalledWith(2, {
+ context: { model_name: "newest" },
+ });
+ controller.stop();
+});
+
+test("keeps the local edit when a background PATCH fails", async () => {
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ transport.patch = rs.fn(async () => {
+ throw new Error("offline during write");
+ });
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+
+ store.mutate({ context: { model_name: "offline-edit" } });
+ await controller.whenIdle();
+
+ expect(store.current.context.model_name).toBe("offline-edit");
+ expect(transport.patch).toHaveBeenCalledTimes(1);
+ expect(store.pendingPatch).toEqual({
+ context: { model_name: "offline-edit" },
+ });
+ controller.stop();
+});
+
+test("reapplies a pending patch after corrupt-record recovery before acknowledging it", async () => {
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ let patchAttempt = 0;
+ transport.patch = rs.fn(async () => {
+ patchAttempt += 1;
+ return patchAttempt === 1
+ ? { settings: null, revision: 2 }
+ : { settings: settings("recovered-edit"), revision: 4 };
+ });
+ transport.initialize = rs.fn(async () => ({
+ // Another device won the first-writer-wins initialization race without
+ // this tab's pending mutation.
+ settings: settings("other-device"),
+ revision: 3,
+ }));
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+
+ store.mutate({ context: { model_name: "recovered-edit" } });
+ await controller.whenIdle();
+
+ const pendingPatch = {
+ context: { model_name: "recovered-edit" },
+ };
+ expect(transport.patch).toHaveBeenCalledTimes(2);
+ expect(transport.patch).toHaveBeenNthCalledWith(1, pendingPatch);
+ expect(transport.patch).toHaveBeenNthCalledWith(2, pendingPatch);
+ expect(transport.initialize).toHaveBeenCalledWith(
+ expect.objectContaining({
+ context: expect.objectContaining({ model_name: "recovered-edit" }),
+ }),
+ );
+ expect(store.pendingPatch).toBeNull();
+ controller.stop();
+});
+
+test("fails closed and retains pending work when the write lock is unavailable", async () => {
+ const store = new FakeStore(settings("initial"));
+ store.pendingPatch = { context: { model_name: "pending" } };
+ store.lockAvailable = false;
+ const transport = transportWithServer(settings("server"));
+ const controller = new UserSettingsSyncController(store, transport);
+
+ await controller.start();
+ await controller.whenIdle();
+
+ expect(transport.patch).not.toHaveBeenCalled();
+ expect(store.pendingPatch).toEqual({
+ context: { model_name: "pending" },
+ });
+ controller.stop();
+});
+
+test("hydrates existing server settings when the write lock is unavailable", async () => {
+ const store = new FakeStore(settings("local"));
+ store.lockAvailable = false;
+ const transport = transportWithServer(settings("server"));
+ const controller = new UserSettingsSyncController(store, transport);
+
+ await controller.start();
+ await controller.whenIdle();
+
+ expect(transport.get).toHaveBeenCalledTimes(1);
+ expect(transport.initialize).not.toHaveBeenCalled();
+ expect(transport.patch).not.toHaveBeenCalled();
+ expect(store.current.context.model_name).toBe("server");
+ controller.stop();
+});
+
+test("serializes an older durable write before a later volatile write", async () => {
+ const tabA = new FakeStore(settings("initial"));
+ const tabB = new FakeStore(settings("initial"));
+ let durableSlot: {
+ opId: string;
+ patch: PersistedUserSettingsPatch;
+ } | null = null;
+ let acknowledgedOpId: string | null = null;
+ let lockTail = Promise.resolve();
+ const withSharedLock = async (task: () => Promise) => {
+ const previous = lockTail;
+ let release!: () => void;
+ lockTail = new Promise((resolve) => {
+ release = resolve;
+ });
+ await previous;
+ try {
+ await task();
+ return true;
+ } finally {
+ release();
+ }
+ };
+ for (const store of [tabA, tabB]) {
+ store.getPendingPatchBatch = () => {
+ const captured = durableSlot;
+ if (captured === null || captured.opId === acknowledgedOpId) return null;
+ return {
+ patch: structuredClone(captured.patch),
+ acknowledge: () => {
+ acknowledgedOpId = captured.opId;
+ return true;
+ },
+ };
+ };
+ store.getDurableLeafOpId = (leaf) =>
+ leaf === "context.model_name" ? (durableSlot?.opId ?? null) : null;
+ store.withWriteLock = withSharedLock;
+ }
+
+ let releaseOlderWrite!: () => void;
+ const olderWriteBlocked = new Promise((resolve) => {
+ releaseOlderWrite = resolve;
+ });
+ let markOlderWriteStarted!: () => void;
+ const olderWriteStarted = new Promise((resolve) => {
+ markOlderWriteStarted = resolve;
+ });
+ const server = settings("server");
+ const patchCalls: PersistedUserSettingsPatch[] = [];
+ let inFlight = 0;
+ let maximumInFlight = 0;
+ const patch = async (nextPatch: PersistedUserSettingsPatch) => {
+ patchCalls.push(structuredClone(nextPatch));
+ inFlight += 1;
+ maximumInFlight = Math.max(maximumInFlight, inFlight);
+ try {
+ if (nextPatch.context?.model_name === "durable-q") {
+ markOlderWriteStarted();
+ await olderWriteBlocked;
+ }
+ if (nextPatch.context?.model_name !== undefined) {
+ server.context.model_name = nextPatch.context.model_name ?? undefined;
+ }
+ return {
+ settings: structuredClone(server),
+ revision: patchCalls.length + 1,
+ };
+ } finally {
+ inFlight -= 1;
+ }
+ };
+ const transport = (): UserSettingsTransport => ({
+ get: async () => ({ settings: structuredClone(server), revision: 1 }),
+ initialize: async (local) => ({ settings: local, revision: 1 }),
+ patch,
+ });
+ const controllerA = new UserSettingsSyncController(tabA, transport());
+ const controllerB = new UserSettingsSyncController(tabB, transport());
+ await Promise.all([controllerA.start(), controllerB.start()]);
+ await Promise.all([controllerA.whenIdle(), controllerB.whenIdle()]);
+
+ durableSlot = {
+ opId: "q",
+ patch: { context: { model_name: "durable-q" } },
+ };
+ tabA.notifyMutation(durableSlot.patch, {
+ durableLeaves: ["context.model_name"],
+ volatileLeaves: [],
+ });
+ await olderWriteStarted;
+ tabB.notifyMutation(
+ { context: { model_name: "volatile-p" } },
+ {
+ durableLeaves: [],
+ volatileLeaves: [
+ {
+ version: 1,
+ leaf: "context.model_name",
+ patch: { context: { model_name: "volatile-p" } },
+ observedDurableOpId: "q",
+ },
+ ],
+ },
+ );
+ await Promise.resolve();
+
+ expect(patchCalls).toEqual([{ context: { model_name: "durable-q" } }]);
+ expect(maximumInFlight).toBe(1);
+
+ releaseOlderWrite();
+ await Promise.all([controllerA.whenIdle(), controllerB.whenIdle()]);
+
+ expect(patchCalls).toEqual([
+ { context: { model_name: "durable-q" } },
+ { context: { model_name: "volatile-p" } },
+ ]);
+ expect(maximumInFlight).toBe(1);
+ expect(server.context.model_name).toBe("volatile-p");
+ controllerA.stop();
+ controllerB.stop();
+});
+
+test("persists an in-flight write before a reload can interrupt it", async () => {
+ const store = new FakeStore(settings("initial"));
+ let resolvePatch!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const pendingRequest = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolvePatch = resolve;
+ });
+ const transport = transportWithServer(settings("server"));
+ transport.patch = rs.fn(() => pendingRequest);
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+
+ store.mutate({ context: { model_name: "survives-reload" } });
+ await Promise.resolve();
+
+ expect(store.pendingPatch).toEqual({
+ context: { model_name: "survives-reload" },
+ });
+ controller.stop();
+ resolvePatch({ settings: settings("survives-reload"), revision: 2 });
+ await controller.whenIdle();
+});
+
+test("acknowledging an in-flight batch preserves a patch appended by another tab", async () => {
+ let resolveFirstPatch!: (value: {
+ settings: PersistedUserSettings;
+ revision: number;
+ }) => void;
+ const firstRequest = new Promise<{
+ settings: PersistedUserSettings;
+ revision: number;
+ }>((resolve) => {
+ resolveFirstPatch = resolve;
+ });
+ const store = new FakeStore(settings("initial"));
+ const transport = transportWithServer(settings("server"));
+ transport.patch = rs
+ .fn()
+ .mockImplementationOnce(() => firstRequest)
+ .mockResolvedValue({ settings: settings("updated"), revision: 3 });
+ const controller = new UserSettingsSyncController(store, transport);
+ await controller.start();
+ await controller.whenIdle();
+
+ store.mutate({ context: { model_name: "tab-a" } });
+ await Promise.resolve();
+ store.appendPendingPatch({ context: { model_name: "tab-b-newer" } });
+ resolveFirstPatch({ settings: settings("tab-a"), revision: 2 });
+ await controller.whenIdle();
+
+ expect(transport.patch).toHaveBeenNthCalledWith(1, {
+ context: { model_name: "tab-a" },
+ });
+ expect(transport.patch).toHaveBeenNthCalledWith(2, {
+ context: { model_name: "tab-b-newer" },
+ });
+ expect(store.pendingPatch).toBeNull();
+ controller.stop();
+});
+
+test("replays a failed write before a later GET can overwrite the local choice", async () => {
+ const firstStore = new FakeStore(settings("initial"));
+ const failingTransport = transportWithServer(settings("server-old"));
+ failingTransport.patch = rs.fn(async () => {
+ throw new Error("offline during write");
+ });
+ const first = new UserSettingsSyncController(firstStore, failingTransport);
+ await first.start();
+ firstStore.mutate({ context: { model_name: "unsynced-local" } });
+ await first.whenIdle();
+ first.stop();
+
+ const reloadedStore = new FakeStore(settings("unsynced-local"));
+ reloadedStore.pendingPatch = structuredClone(firstStore.pendingPatch);
+ const recoveredTransport = transportWithServer(settings("server-old"));
+ const reloaded = new UserSettingsSyncController(
+ reloadedStore,
+ recoveredTransport,
+ );
+ await reloaded.start();
+ await reloaded.whenIdle();
+
+ expect(reloadedStore.current.context.model_name).toBe("unsynced-local");
+ expect(recoveredTransport.patch).toHaveBeenCalledWith({
+ context: { model_name: "unsynced-local" },
+ });
+ expect(reloadedStore.pendingPatch).toBeNull();
+ reloaded.stop();
+});
+
+test("a reload hydrates the value migrated by the previous session", async () => {
+ const transport = transportWithServer(null);
+ const firstStore = new FakeStore(settings("migrated-model"));
+ const first = new UserSettingsSyncController(firstStore, transport);
+ await first.start();
+ first.stop();
+
+ const reloadedStore = new FakeStore(settings("different-local"));
+ const reloaded = new UserSettingsSyncController(reloadedStore, transport);
+ await reloaded.start();
+
+ expect(reloadedStore.current.context.model_name).toBe("migrated-model");
+ expect(transport.initialize).toHaveBeenCalledTimes(1);
+ reloaded.stop();
+});
diff --git a/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx b/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx
new file mode 100644
index 00000000000..3ff1ee22f58
--- /dev/null
+++ b/frontend/tests/unit/core/settings/user-settings-sync.dom.test.tsx
@@ -0,0 +1,1025 @@
+import { afterEach, expect, rs, test } from "@rstest/core";
+import { cleanup, render, waitFor } from "@testing-library/react";
+
+rs.mock("@/core/settings/api", () => ({
+ fetchUserSettings: rs.fn(),
+ initializeUserSettings: rs.fn(),
+ patchUserSettings: rs.fn(),
+}));
+
+import {
+ fetchUserSettings,
+ initializeUserSettings,
+ patchUserSettings,
+} from "@/core/settings/api";
+import {
+ activateBaseSettingsPersistence,
+ getOutstandingBaseSettingsVolatileLeaves,
+ getPersistedBaseSettingsSnapshot,
+ getPendingBaseSettingsPatch,
+ getPendingBaseSettingsPatchBatch,
+ savePendingBaseSettingsPatch,
+ subscribeBaseSettingsMutations,
+ updateLocalSettings,
+} from "@/core/settings/store";
+import { UserSettingsSync } from "@/core/settings/user-settings-sync";
+
+const mockedFetchUserSettings = rs.mocked(fetchUserSettings);
+const mockedInitializeUserSettings = rs.mocked(initializeUserSettings);
+const mockedPatchUserSettings = rs.mocked(patchUserSettings);
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ mockedFetchUserSettings.mockReset();
+ mockedInitializeUserSettings.mockReset();
+ mockedPatchUserSettings.mockReset();
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: undefined,
+ });
+ rs.restoreAllMocks();
+});
+
+function installSerialWebLocks() {
+ let tail: Promise = Promise.resolve();
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (
+ _name: string,
+ optionsOrCallback: object | (() => unknown),
+ maybeCallback?: () => unknown,
+ ) => {
+ const callback: () => unknown =
+ typeof optionsOrCallback === "function"
+ ? (optionsOrCallback as () => unknown)
+ : maybeCallback!;
+ const result = tail.then(callback);
+ tail = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ return result;
+ },
+ },
+ });
+}
+
+test("auth-disabled mode leaves the existing local-only behavior untouched", async () => {
+ render();
+ await Promise.resolve();
+
+ expect(mockedFetchUserSettings).not.toHaveBeenCalled();
+});
+
+test("an edit made while legacy activation waits is outboxed before server hydration", async () => {
+ let releaseLock: (() => void) | undefined;
+ let lockRequested = false;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ if (_name !== "deerflow.user-settings-legacy-migration") {
+ return Promise.resolve(callback());
+ }
+ lockRequested = true;
+ return new Promise((resolve) => {
+ releaseLock = () => resolve(callback());
+ });
+ },
+ },
+ });
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "per_turn" },
+ context: { model_name: "server-model" },
+ },
+ revision: 1,
+ });
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "server-model" },
+ },
+ revision: 2,
+ });
+
+ render();
+ await waitFor(() => expect(lockRequested).toBe(true));
+
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { inlineMode: "off" },
+ });
+ releaseLock?.();
+
+ await waitFor(() =>
+ expect(mockedPatchUserSettings).toHaveBeenCalledWith("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ }),
+ );
+ expect(mockedInitializeUserSettings).not.toHaveBeenCalled();
+ expect(getPersistedBaseSettingsSnapshot()).toEqual({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "server-model" },
+ });
+});
+
+test("an activation-gap cache hydration is not replayed as a local mutation", async () => {
+ const before = getPersistedBaseSettingsSnapshot();
+ let releaseLock: (() => void) | undefined;
+ let lockRequested = false;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ if (_name !== "deerflow.user-settings-legacy-migration") {
+ return Promise.resolve(callback());
+ }
+ lockRequested = true;
+ return new Promise((resolve) => {
+ releaseLock = () => resolve(callback());
+ });
+ },
+ },
+ });
+ const hydratedCache = {
+ ...before,
+ context: { ...before.context, model_name: "other-tab-hydration" },
+ };
+ const newerServer = {
+ ...before,
+ context: { ...before.context, model_name: "newer-server" },
+ };
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: newerServer,
+ revision: 4,
+ });
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: newerServer,
+ revision: 5,
+ });
+
+ render();
+ await waitFor(() => expect(lockRequested).toBe(true));
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify(hydratedCache),
+ );
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "deerflow.user-settings-cache.user-a",
+ storageArea: localStorage,
+ }),
+ );
+ releaseLock?.();
+
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith("user-a"),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(mockedPatchUserSettings).not.toHaveBeenCalled();
+ expect(getPersistedBaseSettingsSnapshot().context.model_name).toBe(
+ "newer-server",
+ );
+});
+
+test("an activation-gap edit survives when browser storage rejects outbox writes", async () => {
+ updateLocalSettings("tokenUsage", { inlineMode: "per_turn" });
+ rs.spyOn(localStorage, "setItem").mockImplementation(() => {
+ throw new DOMException("Blocked", "SecurityError");
+ });
+ let releaseLock: (() => void) | undefined;
+ let lockRequested = false;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ if (_name !== "deerflow.user-settings-legacy-migration") {
+ return Promise.resolve(callback());
+ }
+ lockRequested = true;
+ return new Promise((resolve) => {
+ releaseLock = () => resolve(callback());
+ });
+ },
+ },
+ });
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ },
+ revision: 1,
+ });
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ context: {},
+ },
+ revision: 2,
+ });
+
+ render();
+ await waitFor(() => expect(lockRequested).toBe(true));
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ expect(getPendingBaseSettingsPatch("user-a")).toBeNull();
+ releaseLock?.();
+
+ await waitFor(() =>
+ expect(mockedPatchUserSettings).toHaveBeenCalledWith("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ }),
+ );
+ expect(getPersistedBaseSettingsSnapshot().tokenUsage.inlineMode).toBe("off");
+});
+
+test("a volatile pre-bootstrap edit survives an account switch and remount", async () => {
+ for (const userId of ["user-a", "user-b"]) {
+ localStorage.setItem(
+ `deerflow.user-settings-cache.${userId}`,
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ }),
+ );
+ }
+ const originalSetItem = localStorage.setItem.bind(localStorage);
+ rs.spyOn(localStorage, "setItem").mockImplementation((key, value) => {
+ if (key.startsWith("deerflow.user-settings-pending.leaf.")) {
+ throw new DOMException("Blocked", "SecurityError");
+ }
+ originalSetItem(key, value);
+ });
+ let releaseBootstrapLock: (() => void) | undefined;
+ let bootstrapLockRequested = false;
+ let shouldBlockAlice = true;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ if (shouldBlockAlice && _name.endsWith(".user-a")) {
+ shouldBlockAlice = false;
+ bootstrapLockRequested = true;
+ return new Promise((resolve) => {
+ releaseBootstrapLock = () => resolve(callback());
+ });
+ }
+ return Promise.resolve(callback());
+ },
+ },
+ });
+ mockedFetchUserSettings.mockImplementation(async (userId) => ({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: userId === "user-a" ? {} : { model_name: "bob-model" },
+ },
+ revision: 1,
+ }));
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: { model_name: "alice-volatile" },
+ },
+ revision: 2,
+ });
+
+ const view = render();
+ await waitFor(() => expect(bootstrapLockRequested).toBe(true));
+ updateLocalSettings("context", { model_name: "alice-volatile" });
+ expect(getPendingBaseSettingsPatch("user-a")).toBeNull();
+ expect(getOutstandingBaseSettingsVolatileLeaves("user-a")).toHaveLength(1);
+
+ view.rerender();
+ releaseBootstrapLock?.();
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith("user-b"),
+ );
+ expect(getOutstandingBaseSettingsVolatileLeaves("user-a")).toHaveLength(1);
+ view.rerender();
+
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith("user-a"),
+ );
+ await waitFor(() =>
+ expect(mockedPatchUserSettings).toHaveBeenCalledWith("user-a", {
+ context: { model_name: "alice-volatile" },
+ }),
+ );
+});
+
+test("a failed volatile write is retried after the sync controller remounts", async () => {
+ installSerialWebLocks();
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ }),
+ );
+ const originalSetItem = localStorage.setItem.bind(localStorage);
+ rs.spyOn(localStorage, "setItem").mockImplementation((key, value) => {
+ if (key.startsWith("deerflow.user-settings-pending.leaf.")) {
+ throw new DOMException("Blocked", "SecurityError");
+ }
+ originalSetItem(key, value);
+ });
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ },
+ revision: 1,
+ });
+ mockedPatchUserSettings
+ .mockRejectedValueOnce(new Error("offline"))
+ .mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: { model_name: "retry-after-remount" },
+ },
+ revision: 2,
+ });
+
+ const firstView = render();
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith("user-a"),
+ );
+ updateLocalSettings("context", { model_name: "retry-after-remount" });
+ await waitFor(() => expect(mockedPatchUserSettings).toHaveBeenCalledTimes(1));
+ firstView.unmount();
+
+ const secondView = render();
+
+ await waitFor(() => expect(mockedPatchUserSettings).toHaveBeenCalledTimes(2));
+ expect(mockedPatchUserSettings).toHaveBeenLastCalledWith("user-a", {
+ context: { model_name: "retry-after-remount" },
+ });
+
+ secondView.unmount();
+ render();
+ await waitFor(() => expect(mockedFetchUserSettings).toHaveBeenCalledTimes(3));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(mockedPatchUserSettings).toHaveBeenCalledTimes(2);
+});
+
+test("activation seeding cannot overwrite a later durable leaf hidden behind a storage event", async () => {
+ updateLocalSettings("tokenUsage", { inlineMode: "per_turn" });
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "per_turn" },
+ });
+ let releaseLock: (() => void) | undefined;
+ let lockRequested = false;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ if (_name !== "deerflow.user-settings-legacy-migration") {
+ return Promise.resolve(callback());
+ }
+ lockRequested = true;
+ return new Promise((resolve) => {
+ releaseLock = () => resolve(callback());
+ });
+ },
+ },
+ });
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ },
+ revision: 1,
+ });
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "step_debug" },
+ context: {},
+ },
+ revision: 2,
+ });
+
+ render();
+ await waitFor(() => expect(lockRequested).toBe(true));
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ context: {},
+ }),
+ );
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "deerflow.user-settings-cache.user-a",
+ storageArea: localStorage,
+ }),
+ );
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "step_debug" },
+ });
+ releaseLock?.();
+
+ await waitFor(() =>
+ expect(mockedPatchUserSettings).toHaveBeenCalledWith("user-a", {
+ tokenUsage: { inlineMode: "step_debug" },
+ }),
+ );
+ expect(mockedPatchUserSettings).not.toHaveBeenCalledWith("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+});
+
+test("a cancelled activation cannot seed another account's snapshot", async () => {
+ updateLocalSettings("tokenUsage", { inlineMode: "per_turn" });
+ let releaseLock: (() => void) | undefined;
+ let lockRequested = false;
+ Object.defineProperty(navigator, "locks", {
+ configurable: true,
+ value: {
+ request: (_name: string, _options: object, callback: () => unknown) => {
+ if (_name !== "deerflow.user-settings-legacy-migration") {
+ return Promise.resolve(callback());
+ }
+ lockRequested = true;
+ return new Promise((resolve) => {
+ releaseLock = () => resolve(callback());
+ });
+ },
+ },
+ });
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-b",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "step_debug" },
+ context: { model_name: "bob-model" },
+ }),
+ );
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "step_debug" },
+ context: { model_name: "bob-model" },
+ },
+ revision: 1,
+ });
+
+ const view = render();
+ await waitFor(() => expect(lockRequested).toBe(true));
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ view.rerender();
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith("user-b"),
+ );
+
+ releaseLock?.();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { inlineMode: "off" },
+ });
+});
+
+test("a prior account mutation before activation does not dirty the next account", async () => {
+ installSerialWebLocks();
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ }),
+ );
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-b",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "step_debug" },
+ context: { model_name: "bob-model" },
+ }),
+ );
+ const deactivateAlice = await activateBaseSettingsPersistence("user-a");
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: {
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "step_debug" },
+ context: { model_name: "bob-model" },
+ },
+ revision: 1,
+ });
+
+ function AliceMutationDuringRender() {
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ return null;
+ }
+
+ render(
+ <>
+
+
+ >,
+ );
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith("user-b"),
+ );
+
+ expect(mockedPatchUserSettings).not.toHaveBeenCalled();
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { inlineMode: "off" },
+ });
+ expect(getPendingBaseSettingsPatch("user-b")).toBeNull();
+ deactivateAlice();
+});
+
+test("failed-write outboxes are isolated by authenticated user", () => {
+ savePendingBaseSettingsPatch("user-a", {
+ context: { model_name: "unsynced-model" },
+ });
+
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ context: { model_name: "unsynced-model" },
+ });
+ expect(getPendingBaseSettingsPatch("user-b")).toBeNull();
+ expect(getPendingBaseSettingsPatch("user-a.ack")).toBeNull();
+
+ savePendingBaseSettingsPatch("user-a", null);
+ expect(getPendingBaseSettingsPatch("user-a")).toBeNull();
+});
+
+test("legacy monolithic outboxes remain readable and clearable", () => {
+ const legacyKey = "deerflow.user-settings-pending.user-a";
+ localStorage.setItem(
+ legacyKey,
+ JSON.stringify({ context: { model_name: "legacy-pending" } }),
+ );
+
+ const batch = getPendingBaseSettingsPatchBatch("user-a");
+ expect(batch?.patch).toEqual({
+ context: { model_name: "legacy-pending" },
+ });
+ expect(batch?.acknowledge()).toBe(true);
+ expect(getPendingBaseSettingsPatch("user-a")).toBeNull();
+
+ savePendingBaseSettingsPatch("user-a", null);
+ expect(localStorage.getItem(legacyKey)).toBeNull();
+});
+
+test("a legacy unscoped cache is claimed by only one authenticated user", async () => {
+ installSerialWebLocks();
+ localStorage.setItem(
+ "deerflow.local-settings",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "alice-model", mode: "thinking" },
+ }),
+ );
+
+ const deactivateAlice = await activateBaseSettingsPersistence("user-a");
+ expect(getPersistedBaseSettingsSnapshot().context.model_name).toBe(
+ "alice-model",
+ );
+ deactivateAlice();
+
+ const deactivateBob = await activateBaseSettingsPersistence("user-b");
+ expect(getPersistedBaseSettingsSnapshot()).toEqual({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ });
+ deactivateBob();
+});
+
+test("concurrent account activation imports the legacy cache at most once", async () => {
+ installSerialWebLocks();
+ localStorage.setItem(
+ "deerflow.local-settings",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "legacy-model" },
+ }),
+ );
+
+ const [deactivateAlice, deactivateBob] = await Promise.all([
+ activateBaseSettingsPersistence("user-a"),
+ activateBaseSettingsPersistence("user-b"),
+ ]);
+ const alice = JSON.parse(
+ localStorage.getItem("deerflow.user-settings-cache.user-a") ?? "null",
+ ) as { context?: { model_name?: string } } | null;
+ const bob = JSON.parse(
+ localStorage.getItem("deerflow.user-settings-cache.user-b") ?? "null",
+ ) as { context?: { model_name?: string } } | null;
+
+ expect(
+ [alice, bob].filter(
+ (settings) => settings?.context?.model_name === "legacy-model",
+ ),
+ ).toHaveLength(1);
+ expect(localStorage.getItem("deerflow.local-settings-owner")).toBe("user-a");
+ deactivateAlice();
+ deactivateBob();
+});
+
+test("without Web Locks an unowned legacy cache is not imported", async () => {
+ localStorage.setItem(
+ "deerflow.local-settings",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "ambiguous-owner" },
+ }),
+ );
+
+ const deactivate = await activateBaseSettingsPersistence("user-a");
+
+ expect(getPersistedBaseSettingsSnapshot().context.model_name).toBeUndefined();
+ expect(localStorage.getItem("deerflow.local-settings-owner")).toBeNull();
+ deactivate();
+});
+
+test("another account's tab cache cannot replace the active user's fallback", async () => {
+ const deactivate = await activateBaseSettingsPersistence("user-b");
+ const before = getPersistedBaseSettingsSnapshot();
+
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "deerflow.user-settings-cache.user-a",
+ newValue: JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "alice-model" },
+ }),
+ storageArea: localStorage,
+ }),
+ );
+
+ expect(getPersistedBaseSettingsSnapshot()).toEqual(before);
+ deactivate();
+});
+
+test("the same account's tab cache still produces a synchronized mutation", async () => {
+ const deactivate = await activateBaseSettingsPersistence("user-a");
+ const listener = rs.fn();
+ const unsubscribe = subscribeBaseSettingsMutations(listener);
+
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "step_debug" },
+ context: {},
+ }),
+ );
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "deerflow.user-settings-cache.user-a",
+ storageArea: localStorage,
+ }),
+ );
+
+ expect(getPersistedBaseSettingsSnapshot().tokenUsage.inlineMode).toBe(
+ "step_debug",
+ );
+ expect(listener).toHaveBeenCalledWith(
+ {
+ tokenUsage: { inlineMode: "step_debug" },
+ },
+ {
+ durableLeaves: [],
+ volatileLeaves: [],
+ },
+ );
+ unsubscribe();
+ deactivate();
+});
+
+test("a local leaf edit preserves a newer sibling leaf from another tab", async () => {
+ const deactivate = await activateBaseSettingsPersistence("user-a");
+ const listener = rs.fn();
+ const unsubscribe = subscribeBaseSettingsMutations(listener);
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: false, inlineMode: "per_turn" },
+ context: {},
+ }),
+ );
+
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+
+ expect(
+ JSON.parse(
+ localStorage.getItem("deerflow.user-settings-cache.user-a") ?? "null",
+ ),
+ ).toEqual({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: {},
+ });
+ expect(listener).toHaveBeenCalledWith(
+ {
+ tokenUsage: { inlineMode: "off" },
+ },
+ {
+ durableLeaves: ["tokenUsage.inlineMode"],
+ volatileLeaves: [],
+ },
+ );
+ unsubscribe();
+ deactivate();
+});
+
+test("a stale same-value edit is diffed against the latest shared cache", async () => {
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ }),
+ );
+ const deactivate = await activateBaseSettingsPersistence("user-a");
+ const listener = rs.fn();
+ const unsubscribe = subscribeBaseSettingsMutations(listener);
+ localStorage.setItem(
+ "deerflow.user-settings-cache.user-a",
+ JSON.stringify({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "newer-sibling" },
+ }),
+ );
+
+ updateLocalSettings("tokenUsage", { inlineMode: "per_turn" });
+
+ expect(
+ JSON.parse(
+ localStorage.getItem("deerflow.user-settings-cache.user-a") ?? "null",
+ ),
+ ).toEqual({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "per_turn" },
+ context: { model_name: "newer-sibling" },
+ });
+ expect(listener).toHaveBeenCalledWith(
+ { tokenUsage: { inlineMode: "per_turn" } },
+ {
+ durableLeaves: ["tokenUsage.inlineMode"],
+ volatileLeaves: [],
+ },
+ );
+ unsubscribe();
+ deactivate();
+});
+
+test("consecutive volatile leaf edits preserve earlier in-memory values", async () => {
+ localStorage.setItem(
+ "deerflow.user-settings-cache.storage-blocked-user",
+ JSON.stringify({
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {},
+ }),
+ );
+ const deactivate = await activateBaseSettingsPersistence(
+ "storage-blocked-user",
+ );
+ rs.spyOn(localStorage, "setItem").mockImplementation(() => {
+ throw new DOMException("Blocked", "SecurityError");
+ });
+
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ updateLocalSettings("notification", { enabled: false });
+
+ expect(getPersistedBaseSettingsSnapshot()).toEqual({
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: true, inlineMode: "off" },
+ context: {},
+ });
+ deactivate();
+});
+
+test("an acknowledged edit remains in the baseline when only its cache write fails", async () => {
+ installSerialWebLocks();
+ const cacheKey = "deerflow.user-settings-cache.cache-failure-user";
+ const initialSettings = {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" as const },
+ context: {},
+ };
+ localStorage.setItem(cacheKey, JSON.stringify(initialSettings));
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: initialSettings,
+ revision: 1,
+ });
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: initialSettings,
+ revision: 2,
+ });
+
+ render();
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith("cache-failure-user"),
+ );
+ await waitFor(() =>
+ expect(getPersistedBaseSettingsSnapshot()).toEqual(initialSettings),
+ );
+
+ const originalSetItem = Storage.prototype.setItem.bind(localStorage);
+ const newerCrossTabSettings = {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: false, inlineMode: "per_turn" },
+ context: { model_name: "newer-cross-tab-sibling" },
+ };
+ let crossTabWriteInjected = false;
+ let cacheWriteFailures = 1;
+ rs.spyOn(localStorage, "setItem").mockImplementation((key, value) => {
+ if (
+ key.startsWith("deerflow.user-settings-pending.leaf.") &&
+ !crossTabWriteInjected
+ ) {
+ crossTabWriteInjected = true;
+ originalSetItem(cacheKey, JSON.stringify(newerCrossTabSettings));
+ }
+ if (key === cacheKey && cacheWriteFailures > 0) {
+ cacheWriteFailures -= 1;
+ throw new DOMException("Blocked", "QuotaExceededError");
+ }
+ originalSetItem(key, value);
+ });
+
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+ await waitFor(() =>
+ expect(mockedPatchUserSettings).toHaveBeenCalledWith("cache-failure-user", {
+ tokenUsage: { inlineMode: "off" },
+ }),
+ );
+ await waitFor(() =>
+ expect(getPendingBaseSettingsPatch("cache-failure-user")).toBeNull(),
+ );
+ expect(JSON.parse(localStorage.getItem(cacheKey) ?? "null")).toEqual(
+ newerCrossTabSettings,
+ );
+
+ updateLocalSettings("notification", { enabled: false });
+
+ await waitFor(() => expect(mockedPatchUserSettings).toHaveBeenCalledTimes(2));
+ expect(mockedPatchUserSettings).toHaveBeenLastCalledWith(
+ "cache-failure-user",
+ { notification: { enabled: false } },
+ );
+ const expectedSettings = {
+ notification: { enabled: false },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "newer-cross-tab-sibling" },
+ };
+ expect(getPersistedBaseSettingsSnapshot()).toEqual(expectedSettings);
+ expect(JSON.parse(localStorage.getItem(cacheKey) ?? "null")).toEqual(
+ expectedSettings,
+ );
+});
+
+test("a successful cache write rebases its leaf over a newer cross-tab sibling", async () => {
+ installSerialWebLocks();
+ const userId = "cache-rebase-user";
+ const cacheKey = `deerflow.user-settings-cache.${userId}`;
+ const initialSettings = {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" as const },
+ context: {},
+ };
+ const newerCrossTabSettings = {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: false, inlineMode: "per_turn" as const },
+ context: { model_name: "newer-cross-tab-sibling" },
+ };
+ localStorage.setItem(cacheKey, JSON.stringify(initialSettings));
+ mockedFetchUserSettings.mockResolvedValue({
+ settings: initialSettings,
+ revision: 1,
+ });
+ mockedPatchUserSettings.mockResolvedValue({
+ settings: initialSettings,
+ revision: 2,
+ });
+
+ render();
+ await waitFor(() =>
+ expect(mockedFetchUserSettings).toHaveBeenCalledWith(userId),
+ );
+ await waitFor(() =>
+ expect(getPersistedBaseSettingsSnapshot()).toEqual(initialSettings),
+ );
+
+ const originalSetItem = Storage.prototype.setItem.bind(localStorage);
+ let crossTabWriteInjected = false;
+ rs.spyOn(localStorage, "setItem").mockImplementation((key, value) => {
+ if (
+ key.startsWith("deerflow.user-settings-pending.leaf.") &&
+ !crossTabWriteInjected
+ ) {
+ crossTabWriteInjected = true;
+ originalSetItem(cacheKey, JSON.stringify(newerCrossTabSettings));
+ }
+ originalSetItem(key, value);
+ });
+
+ updateLocalSettings("tokenUsage", { inlineMode: "off" });
+
+ const firstExpectedSettings = {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: false, inlineMode: "off" },
+ context: { model_name: "newer-cross-tab-sibling" },
+ };
+ expect(getPersistedBaseSettingsSnapshot()).toEqual(firstExpectedSettings);
+ expect(JSON.parse(localStorage.getItem(cacheKey) ?? "null")).toEqual(
+ firstExpectedSettings,
+ );
+ await waitFor(() =>
+ expect(mockedPatchUserSettings).toHaveBeenCalledWith(userId, {
+ tokenUsage: { inlineMode: "off" },
+ }),
+ );
+ await waitFor(() => expect(getPendingBaseSettingsPatch(userId)).toBeNull());
+
+ updateLocalSettings("notification", { enabled: false });
+
+ await waitFor(() => expect(mockedPatchUserSettings).toHaveBeenCalledTimes(2));
+ expect(mockedPatchUserSettings).toHaveBeenLastCalledWith(userId, {
+ notification: { enabled: false },
+ });
+ const finalExpectedSettings = {
+ ...firstExpectedSettings,
+ notification: { enabled: false },
+ };
+ expect(getPersistedBaseSettingsSnapshot()).toEqual(finalExpectedSettings);
+ expect(JSON.parse(localStorage.getItem(cacheKey) ?? "null")).toEqual(
+ finalExpectedSettings,
+ );
+});
+
+test("acknowledging one durable batch cannot clear a later tab operation", () => {
+ savePendingBaseSettingsPatch("user-a", {
+ context: { model_name: "tab-a" },
+ });
+ const firstBatch = getPendingBaseSettingsPatchBatch("user-a");
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+
+ expect(firstBatch?.acknowledge()).toBe(true);
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { inlineMode: "off" },
+ });
+});
+
+test("a reload recovers every patch retained by two failed tabs", () => {
+ savePendingBaseSettingsPatch("user-a", {
+ context: { model_name: "offline-tab-a" },
+ });
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+
+ expect(getPendingBaseSettingsPatchBatch("user-a")?.patch).toEqual({
+ context: { model_name: "offline-tab-a" },
+ tokenUsage: { inlineMode: "off" },
+ });
+});
+
+test("a same-leaf overwrite survives acknowledgement of the older batch", () => {
+ rs.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "off" },
+ });
+ const olderBatch = getPendingBaseSettingsPatchBatch("user-a");
+ savePendingBaseSettingsPatch("user-a", {
+ tokenUsage: { inlineMode: "step_debug" },
+ });
+
+ expect(olderBatch?.acknowledge()).toBe(true);
+ expect(getPendingBaseSettingsPatch("user-a")).toEqual({
+ tokenUsage: { inlineMode: "step_debug" },
+ });
+});