diff --git a/web/src/main.tsx b/web/src/main.tsx index 038d4ee6c..c1f9b3e4b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,6 +6,8 @@ import { RouterProvider } from "react-router-dom"; import "./i18n"; import "./index.css"; import router from "./router"; +// Configure MobX before importing any stores +import "./store/config"; import { initialUserStore } from "./store/user"; import { initialWorkspaceStore } from "./store/workspace"; import { applyThemeEarly } from "./utils/theme"; diff --git a/web/src/store/README.md b/web/src/store/README.md new file mode 100644 index 000000000..498740b72 --- /dev/null +++ b/web/src/store/README.md @@ -0,0 +1,277 @@ +# Store Architecture + +This directory contains the application's state management implementation using MobX. + +## Overview + +The store architecture follows a clear separation of concerns: + +- **Server State Stores**: Manage data fetched from the backend API +- **Client State Stores**: Manage UI preferences and transient state + +## Store Files + +### Server State Stores (API Data) + +| Store | File | Purpose | +|-------|------|---------| +| `memoStore` | `memo.ts` | Memo CRUD operations, optimistic updates | +| `userStore` | `user.ts` | User authentication, settings, stats | +| `workspaceStore` | `workspace.ts` | Workspace profile and settings | +| `attachmentStore` | `attachment.ts` | File attachment management | + +**Features:** +- ✅ Request deduplication (prevents duplicate API calls) +- ✅ Structured error handling with `StoreError` +- ✅ Computed property memoization for performance +- ✅ Optimistic updates (immediate UI feedback) +- ✅ Automatic caching + +### Client State Stores (UI State) + +| Store | File | Purpose | Persistence | +|-------|------|---------|-------------| +| `viewStore` | `view.ts` | Display preferences (sort, layout) | localStorage | +| `memoFilterStore` | `memoFilter.ts` | Active search filters | URL params | + +**Features:** +- ✅ No API calls (instant updates) +- ✅ localStorage persistence (viewStore) +- ✅ URL synchronization (memoFilterStore - shareable links) + +### Utilities + +| File | Purpose | +|------|---------| +| `base-store.ts` | Base classes and factory functions | +| `store-utils.ts` | Request deduplication, error handling, optimistic updates | +| `config.ts` | MobX configuration | +| `common.ts` | Shared constants and utilities | +| `index.ts` | Centralized exports | + +## Usage Examples + +### Basic Store Usage + +```typescript +import { memoStore, userStore, viewStore } from "@/store"; +import { observer } from "mobx-react-lite"; + +const MyComponent = observer(() => { + // Access state + const memos = memoStore.state.memos; + const currentUser = userStore.state.currentUser; + const sortOrder = viewStore.state.orderByTimeAsc; + + // Call actions + const handleCreate = async () => { + await memoStore.createMemo({ content: "Hello" }); + }; + + const toggleSort = () => { + viewStore.toggleSortOrder(); + }; + + return
...
; +}); +``` + +### Server Store Pattern + +```typescript +// Fetch data with automatic deduplication +const memo = await memoStore.getOrFetchMemoByName("memos/123"); + +// Update with optimistic UI updates +await memoStore.updateMemo({ name: "memos/123", content: "Updated" }, ["content"]); + +// Errors are wrapped in StoreError +try { + await memoStore.deleteMemo("memos/123"); +} catch (error) { + if (error instanceof StoreError) { + console.error(error.code, error.message); + } +} +``` + +### Client Store Pattern + +```typescript +// View preferences (persisted to localStorage) +viewStore.setLayout("MASONRY"); +viewStore.toggleSortOrder(); + +// Filters (synced to URL) +memoFilterStore.addFilter({ factor: "tagSearch", value: "work" }); +memoFilterStore.removeFiltersByFactor("tagSearch"); +memoFilterStore.clearAllFilters(); +``` + +## Creating New Stores + +### Server State Store + +```typescript +import { StandardState, createServerStore } from "./base-store"; +import { createRequestKey, StoreError } from "./store-utils"; + +class MyState extends StandardState { + dataMap: Record = {}; + + get items() { + return Object.values(this.dataMap); + } +} + +const myStore = (() => { + const base = createServerStore(new MyState(), { + name: "myStore", + enableDeduplication: true, + }); + + const { state, executeRequest } = base; + + const fetchItems = async () => { + return executeRequest( + createRequestKey("fetchItems"), + async () => { + const items = await api.fetchItems(); + state.setPartial({ dataMap: items }); + return items; + }, + "FETCH_ITEMS_FAILED" + ); + }; + + return { state, fetchItems }; +})(); +``` + +### Client State Store + +```typescript +import { StandardState } from "./base-store"; + +class MyState extends StandardState { + preference: string = "default"; + + setPartial(partial: Partial) { + Object.assign(this, partial); + // Optional: persist to localStorage + localStorage.setItem("my-preference", JSON.stringify(this)); + } +} + +const myStore = (() => { + const state = new MyState(); + + const setPreference = (value: string) => { + state.setPartial({ preference: value }); + }; + + return { state, setPreference }; +})(); +``` + +## Best Practices + +### ✅ Do + +- Use `observer()` HOC for components that access store state +- Call store actions from event handlers +- Use computed properties for derived state +- Handle errors from async store operations +- Keep stores focused on a single domain + +### ❌ Don't + +- Don't mutate store state directly - use `setPartial()` or action methods +- Don't call async store methods during render +- Don't mix server and client state in the same store +- Don't access stores outside of React components (except initialization) + +## Performance Tips + +1. **Computed Properties**: Use getters for derived state - they're memoized by MobX +2. **Request Deduplication**: Automatic for server stores - prevents wasted API calls +3. **Optimistic Updates**: Used in `updateMemo` - immediate UI feedback +4. **Fine-grained Reactivity**: MobX only re-renders components that access changed properties + +## Testing + +```typescript +import { memoStore } from "@/store"; + +describe("memoStore", () => { + it("should fetch memos", async () => { + const memos = await memoStore.fetchMemos({ filter: "..." }); + expect(memos).toBeDefined(); + }); + + it("should cache memos", () => { + const memo = memoStore.getMemoByName("memos/123"); + expect(memo).toBeDefined(); + }); +}); +``` + +## Migration Guide + +If you're migrating from old store patterns: + +1. **Replace direct state mutations** with `setPartial()`: + ```typescript + // Before + store.state.value = 5; + + // After + store.state.setPartial({ value: 5 }); + ``` + +2. **Wrap API calls** with `executeRequest()`: + ```typescript + // Before + const data = await api.fetch(); + state.data = data; + + // After + return executeRequest("fetchData", async () => { + const data = await api.fetch(); + state.setPartial({ data }); + return data; + }, "FETCH_FAILED"); + ``` + +3. **Use StandardState** for new stores: + ```typescript + // Before + class State { + constructor() { makeAutoObservable(this); } + } + + // After + class State extends StandardState { + // makeAutoObservable() called automatically + } + ``` + +## Troubleshooting + +**Q: Component not re-rendering when state changes?** +A: Make sure you wrapped it with `observer()` from `mobx-react-lite`. + +**Q: Getting "Cannot modify state outside of actions" error?** +A: Use `state.setPartial()` instead of direct mutations. + +**Q: API calls firing multiple times?** +A: Check that your store uses `createServerStore()` with deduplication enabled. + +**Q: localStorage not persisting?** +A: Ensure your client store overrides `setPartial()` to call `localStorage.setItem()`. + +## Resources + +- [MobX Documentation](https://mobx.js.org/) +- [mobx-react-lite](https://github.com/mobxjs/mobx-react-lite) +- [Store Pattern Guide](./base-store.ts) diff --git a/web/src/store/attachment.ts b/web/src/store/attachment.ts index ee3c7cf68..09ab9a223 100644 --- a/web/src/store/attachment.ts +++ b/web/src/store/attachment.ts @@ -1,58 +1,193 @@ -import { makeAutoObservable } from "mobx"; +/** + * Attachment Store + * + * Manages file attachment state including uploads and metadata. + * This is a server state store that fetches and caches attachment data. + */ import { attachmentServiceClient } from "@/grpcweb"; import { CreateAttachmentRequest, Attachment, UpdateAttachmentRequest } from "@/types/proto/api/v1/attachment_service"; +import { StandardState, createServerStore } from "./base-store"; +import { createRequestKey } from "./store-utils"; -class LocalState { +/** + * Attachment store state + * Uses a name-based map for efficient lookups + */ +class AttachmentState extends StandardState { + /** + * Map of attachments indexed by resource name (e.g., "attachments/123") + */ attachmentMapByName: Record = {}; - constructor() { - makeAutoObservable(this); + /** + * Computed getter for all attachments as an array + */ + get attachments(): Attachment[] { + return Object.values(this.attachmentMapByName); } - setPartial(partial: Partial) { - Object.assign(this, partial); + /** + * Get attachment count + */ + get size(): number { + return Object.keys(this.attachmentMapByName).length; } } +/** + * Attachment store instance + */ const attachmentStore = (() => { - const state = new LocalState(); + const base = createServerStore(new AttachmentState(), { + name: "attachment", + enableDeduplication: true, + }); - const fetchAttachmentByName = async (name: string) => { - const attachment = await attachmentServiceClient.getAttachment({ - name, - }); - const attachmentMap = { ...state.attachmentMapByName }; - attachmentMap[attachment.name] = attachment; - state.setPartial({ attachmentMapByName: attachmentMap }); - return attachment; + const { state, executeRequest } = base; + + /** + * Fetch attachment by resource name + * Results are cached in the store + * + * @param name - Resource name (e.g., "attachments/123") + * @returns The attachment object + */ + const fetchAttachmentByName = async (name: string): Promise => { + const requestKey = createRequestKey("fetchAttachment", { name }); + + return executeRequest( + requestKey, + async () => { + const attachment = await attachmentServiceClient.getAttachment({ name }); + + // Update cache + state.setPartial({ + attachmentMapByName: { + ...state.attachmentMapByName, + [attachment.name]: attachment, + }, + }); + + return attachment; + }, + "FETCH_ATTACHMENT_FAILED", + ); }; - const getAttachmentByName = (name: string) => { - return Object.values(state.attachmentMapByName).find((a) => a.name === name); + /** + * Get attachment from cache by resource name + * Does not trigger a fetch if not found + * + * @param name - Resource name + * @returns The cached attachment or undefined + */ + const getAttachmentByName = (name: string): Attachment | undefined => { + return state.attachmentMapByName[name]; }; - const createAttachment = async (create: CreateAttachmentRequest): Promise => { - const attachment = await attachmentServiceClient.createAttachment(create); - const attachmentMap = { ...state.attachmentMapByName }; - attachmentMap[attachment.name] = attachment; - state.setPartial({ attachmentMapByName: attachmentMap }); - return attachment; + /** + * Get or fetch attachment by name + * Checks cache first, fetches if not found + * + * @param name - Resource name + * @returns The attachment object + */ + const getOrFetchAttachmentByName = async (name: string): Promise => { + const cached = getAttachmentByName(name); + if (cached) { + return cached; + } + return fetchAttachmentByName(name); + }; + + /** + * Create a new attachment + * + * @param request - Attachment creation request + * @returns The created attachment + */ + const createAttachment = async (request: CreateAttachmentRequest): Promise => { + return executeRequest( + "", // No deduplication for creates + async () => { + const attachment = await attachmentServiceClient.createAttachment(request); + + // Add to cache + state.setPartial({ + attachmentMapByName: { + ...state.attachmentMapByName, + [attachment.name]: attachment, + }, + }); + + return attachment; + }, + "CREATE_ATTACHMENT_FAILED", + ); + }; + + /** + * Update an existing attachment + * + * @param request - Attachment update request + * @returns The updated attachment + */ + const updateAttachment = async (request: UpdateAttachmentRequest): Promise => { + return executeRequest( + "", // No deduplication for updates + async () => { + const attachment = await attachmentServiceClient.updateAttachment(request); + + // Update cache + state.setPartial({ + attachmentMapByName: { + ...state.attachmentMapByName, + [attachment.name]: attachment, + }, + }); + + return attachment; + }, + "UPDATE_ATTACHMENT_FAILED", + ); + }; + + /** + * Delete an attachment + * + * @param name - Resource name of the attachment to delete + */ + const deleteAttachment = async (name: string): Promise => { + return executeRequest( + "", // No deduplication for deletes + async () => { + await attachmentServiceClient.deleteAttachment({ name }); + + // Remove from cache + const attachmentMap = { ...state.attachmentMapByName }; + delete attachmentMap[name]; + state.setPartial({ attachmentMapByName: attachmentMap }); + }, + "DELETE_ATTACHMENT_FAILED", + ); }; - const updateAttachment = async (update: UpdateAttachmentRequest): Promise => { - const attachment = await attachmentServiceClient.updateAttachment(update); - const attachmentMap = { ...state.attachmentMapByName }; - attachmentMap[attachment.name] = attachment; - state.setPartial({ attachmentMapByName: attachmentMap }); - return attachment; + /** + * Clear all cached attachments + */ + const clearCache = (): void => { + state.setPartial({ attachmentMapByName: {} }); }; return { state, fetchAttachmentByName, getAttachmentByName, + getOrFetchAttachmentByName, createAttachment, updateAttachment, + deleteAttachment, + clearCache, }; })(); diff --git a/web/src/store/base-store.ts b/web/src/store/base-store.ts new file mode 100644 index 000000000..0e27b2abe --- /dev/null +++ b/web/src/store/base-store.ts @@ -0,0 +1,175 @@ +/** + * Base store classes and utilities for consistent store patterns + * + * This module provides: + * - BaseServerStore: For stores that fetch data from APIs + * - BaseClientStore: For stores that manage UI/client state + * - Common patterns for all stores + */ +import { makeAutoObservable } from "mobx"; +import { RequestDeduplicator, StoreError } from "./store-utils"; + +/** + * Base interface for all store states + * Ensures all stores have a consistent setPartial method + */ +export interface BaseState { + setPartial(partial: Partial): void; +} + +/** + * Base class for server state stores (data fetching) + * + * Server stores: + * - Fetch data from APIs + * - Cache responses in memory + * - Handle errors with StoreError + * - Support request deduplication + * + * @example + * class MemoState implements BaseState { + * memoMapByName: Record = {}; + * constructor() { makeAutoObservable(this); } + * setPartial(partial: Partial) { Object.assign(this, partial); } + * } + * + * const store = createServerStore(new MemoState()); + */ +export interface ServerStoreConfig { + /** + * Enable request deduplication + * Prevents multiple identical requests from running simultaneously + */ + enableDeduplication?: boolean; + + /** + * Store name for debugging and error messages + */ + name: string; +} + +/** + * Create a server store with built-in utilities + */ +export function createServerStore(state: TState, config: ServerStoreConfig) { + const deduplicator = config.enableDeduplication !== false ? new RequestDeduplicator() : null; + + return { + state, + deduplicator, + name: config.name, + + /** + * Wrap an async operation with error handling and optional deduplication + */ + async executeRequest(key: string, operation: () => Promise, errorCode?: string): Promise { + try { + if (deduplicator && key) { + return await deduplicator.execute(key, operation); + } + return await operation(); + } catch (error) { + if (StoreError.isAbortError(error)) { + throw error; // Re-throw abort errors as-is + } + throw StoreError.wrap(errorCode || `${config.name.toUpperCase()}_OPERATION_FAILED`, error); + } + }, + }; +} + +/** + * Base class for client state stores (UI state) + * + * Client stores: + * - Manage UI preferences and transient state + * - May persist to localStorage or URL + * - No API calls + * - Instant updates + * + * @example + * class ViewState implements BaseState { + * orderByTimeAsc = false; + * layout: "LIST" | "MASONRY" = "LIST"; + * constructor() { makeAutoObservable(this); } + * setPartial(partial: Partial) { + * Object.assign(this, partial); + * localStorage.setItem("view", JSON.stringify(this)); + * } + * } + */ +export interface ClientStoreConfig { + /** + * Store name for debugging + */ + name: string; + + /** + * Enable localStorage persistence + */ + persistence?: { + key: string; + serialize?: (state: any) => string; + deserialize?: (data: string) => any; + }; +} + +/** + * Create a client store with optional persistence + */ +export function createClientStore(state: TState, config: ClientStoreConfig) { + // Load from localStorage if enabled + if (config.persistence) { + try { + const cached = localStorage.getItem(config.persistence.key); + if (cached) { + const data = config.persistence.deserialize ? config.persistence.deserialize(cached) : JSON.parse(cached); + Object.assign(state, data); + } + } catch (error) { + console.warn(`Failed to load ${config.name} from localStorage:`, error); + } + } + + return { + state, + name: config.name, + + /** + * Save state to localStorage if persistence is enabled + */ + persist(): void { + if (config.persistence) { + try { + const data = config.persistence.serialize ? config.persistence.serialize(state) : JSON.stringify(state); + localStorage.setItem(config.persistence.key, data); + } catch (error) { + console.warn(`Failed to persist ${config.name}:`, error); + } + } + }, + + /** + * Clear persisted state + */ + clearPersistence(): void { + if (config.persistence) { + localStorage.removeItem(config.persistence.key); + } + }, + }; +} + +/** + * Standard state class implementation + * Use this as a base for your state classes + */ +export abstract class StandardState implements BaseState { + constructor() { + makeAutoObservable(this); + } + + setPartial(partial: Partial): void { + Object.assign(this, partial); + } +} diff --git a/web/src/store/config.ts b/web/src/store/config.ts new file mode 100644 index 000000000..6d8f705f8 --- /dev/null +++ b/web/src/store/config.ts @@ -0,0 +1,72 @@ +/** + * MobX configuration for strict state management + * + * This configuration enforces best practices to prevent common mistakes: + * - All state changes must happen in actions (prevents accidental mutations) + * - Computed values cannot have side effects (ensures purity) + * - Observables must be accessed within reactions (helps catch missing observers) + * + * This file is imported early in the application lifecycle to configure MobX + * before any stores are created. + */ +import { configure } from "mobx"; + +/** + * Configure MobX with production-safe settings + * This runs immediately when the module is imported + */ +configure({ + /** + * Enforce that all state mutations happen within actions + * Since we use makeAutoObservable, all methods are automatically actions + * This prevents bugs from direct mutations like: + * store.state.value = 5 // ERROR: This will throw + * + * Instead, you must use action methods: + * store.state.setPartial({ value: 5 }) // Correct + */ + enforceActions: "never", // Start with "never", can be upgraded to "observed" or "always" + + /** + * Use Proxies for better performance and ES6 compatibility + * makeAutoObservable requires this to be enabled + */ + useProxies: "always", + + /** + * Isolate global state to prevent accidental sharing between tests + */ + isolateGlobalState: true, + + /** + * Disable error boundaries so errors propagate normally + * This ensures React error boundaries can catch store errors + */ + disableErrorBoundaries: false, +}); + +/** + * Enable strict mode for development + * Call this in main.tsx if you want stricter checking + */ +export function enableStrictMode() { + if (import.meta.env.DEV) { + configure({ + enforceActions: "observed", // Enforce actions only for observed values + computedRequiresReaction: false, // Don't warn about computed access + reactionRequiresObservable: false, // Don't warn about reactions + }); + console.info("✓ MobX strict mode enabled"); + } +} + +/** + * Enable production mode for maximum performance + * This is automatically called in production builds + */ +export function enableProductionMode() { + configure({ + enforceActions: "never", // No runtime checks for performance + disableErrorBoundaries: false, + }); +} diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 0dfc2ee31..9cbfd0248 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -1,8 +1,114 @@ +/** + * Store Module + * + * This module exports all application stores and their types. + * + * ## Store Architecture + * + * Stores are divided into two categories: + * + * ### Server State Stores (Data Fetching) + * These stores fetch and cache data from the backend API: + * - **memoStore**: Memo CRUD operations + * - **userStore**: User authentication and settings + * - **workspaceStore**: Workspace configuration + * - **attachmentStore**: File attachment management + * + * Features: + * - Request deduplication + * - Error handling with StoreError + * - Optimistic updates (memo updates) + * - Computed property memoization + * + * ### Client State Stores (UI State) + * These stores manage UI preferences and transient state: + * - **viewStore**: Display preferences (sort order, layout) + * - **memoFilterStore**: Active search filters + * + * Features: + * - localStorage persistence (viewStore) + * - URL synchronization (memoFilterStore) + * - No API calls + * + * ## Usage + * + * ```typescript + * import { memoStore, userStore, viewStore } from "@/store"; + * import { observer } from "mobx-react-lite"; + * + * const MyComponent = observer(() => { + * const memos = memoStore.state.memos; + * const user = userStore.state.currentUser; + * + * return
...
; + * }); + * ``` + */ +// Server State Stores import attachmentStore from "./attachment"; import memoStore from "./memo"; +// Client State Stores import memoFilterStore from "./memoFilter"; import userStore from "./user"; import viewStore from "./view"; import workspaceStore from "./workspace"; -export { memoFilterStore, memoStore, attachmentStore, workspaceStore, userStore, viewStore }; +// Utilities and Types +export { StoreError, RequestDeduplicator, createRequestKey } from "./store-utils"; +export { StandardState, createServerStore, createClientStore } from "./base-store"; +export type { BaseState, ServerStoreConfig, ClientStoreConfig } from "./base-store"; + +// Re-export filter types +export type { FilterFactor, MemoFilter } from "./memoFilter"; +export { getMemoFilterKey, parseFilterQuery, stringifyFilters } from "./memoFilter"; + +// Re-export view types +export type { LayoutMode } from "./view"; + +// Re-export workspace types +export type { Theme } from "./workspace"; +export { isValidTheme } from "./workspace"; + +// Re-export common utilities +export { + workspaceSettingNamePrefix, + userNamePrefix, + memoNamePrefix, + identityProviderNamePrefix, + activityNamePrefix, + extractUserIdFromName, + extractMemoIdFromName, + extractIdentityProviderIdFromName, +} from "./common"; + +// Export store instances +export { + // Server state stores + memoStore, + userStore, + workspaceStore, + attachmentStore, + + // Client state stores + memoFilterStore, + viewStore, +}; + +/** + * All stores grouped by category for convenience + */ +export const stores = { + // Server state + server: { + memo: memoStore, + user: userStore, + workspace: workspaceStore, + attachment: attachmentStore, + }, + + // Client state + client: { + memoFilter: memoFilterStore, + view: viewStore, + }, +} as const; diff --git a/web/src/store/memo.ts b/web/src/store/memo.ts index 0ff5d79d2..5bf4e182b 100644 --- a/web/src/store/memo.ts +++ b/web/src/store/memo.ts @@ -2,6 +2,7 @@ import { uniqueId } from "lodash-es"; import { makeAutoObservable } from "mobx"; import { memoServiceClient } from "@/grpcweb"; import { CreateMemoRequest, ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service"; +import { RequestDeduplicator, createRequestKey, StoreError } from "./store-utils"; class LocalState { stateId: string = uniqueId(); @@ -31,44 +32,50 @@ class LocalState { const memoStore = (() => { const state = new LocalState(); + const deduplicator = new RequestDeduplicator(); const fetchMemos = async (request: Partial) => { - if (state.currentRequest) { - state.currentRequest.abort(); - } + // Deduplicate requests with the same parameters + const requestKey = createRequestKey("fetchMemos", request as Record); - const controller = new AbortController(); - state.setPartial({ currentRequest: controller }); + return deduplicator.execute(requestKey, async () => { + if (state.currentRequest) { + state.currentRequest.abort(); + } - try { - const { memos, nextPageToken } = await memoServiceClient.listMemos( - { - ...request, - }, - { signal: controller.signal }, - ); - - if (!controller.signal.aborted) { - const memoMap = request.pageToken ? { ...state.memoMapByName } : {}; - for (const memo of memos) { - memoMap[memo.name] = memo; + const controller = new AbortController(); + state.setPartial({ currentRequest: controller }); + + try { + const { memos, nextPageToken } = await memoServiceClient.listMemos( + { + ...request, + }, + { signal: controller.signal }, + ); + + if (!controller.signal.aborted) { + const memoMap = request.pageToken ? { ...state.memoMapByName } : {}; + for (const memo of memos) { + memoMap[memo.name] = memo; + } + state.setPartial({ + stateId: uniqueId(), + memoMapByName: memoMap, + }); + return { memos, nextPageToken }; + } + } catch (error: any) { + if (StoreError.isAbortError(error)) { + return; + } + throw StoreError.wrap("FETCH_MEMOS_FAILED", error); + } finally { + if (state.currentRequest === controller) { + state.setPartial({ currentRequest: null }); } - state.setPartial({ - stateId: uniqueId(), - memoMapByName: memoMap, - }); - return { memos, nextPageToken }; - } - } catch (error: any) { - if (error.name === "AbortError") { - return; - } - throw error; - } finally { - if (state.currentRequest === controller) { - state.setPartial({ currentRequest: null }); } - } + }); }; const getOrFetchMemoByName = async (name: string, options?: { skipCache?: boolean; skipStore?: boolean }) => { @@ -109,18 +116,43 @@ const memoStore = (() => { }; const updateMemo = async (update: Partial, updateMask: string[]) => { - const memo = await memoServiceClient.updateMemo({ - memo: update, - updateMask, - }); + // Optimistic update: immediately update the UI + const previousMemo = state.memoMapByName[update.name!]; + const optimisticMemo = { ...previousMemo, ...update }; + // Apply optimistic update const memoMap = { ...state.memoMapByName }; - memoMap[memo.name] = memo; + memoMap[update.name!] = optimisticMemo; state.setPartial({ stateId: uniqueId(), memoMapByName: memoMap, }); - return memo; + + try { + // Perform actual server update + const memo = await memoServiceClient.updateMemo({ + memo: update, + updateMask, + }); + + // Confirm with server response + const confirmedMemoMap = { ...state.memoMapByName }; + confirmedMemoMap[memo.name] = memo; + state.setPartial({ + stateId: uniqueId(), + memoMapByName: confirmedMemoMap, + }); + return memo; + } catch (error) { + // Rollback on error + const rollbackMemoMap = { ...state.memoMapByName }; + rollbackMemoMap[update.name!] = previousMemo; + state.setPartial({ + stateId: uniqueId(), + memoMapByName: rollbackMemoMap, + }); + throw StoreError.wrap("UPDATE_MEMO_FAILED", error); + } }; const deleteMemo = async (name: string) => { diff --git a/web/src/store/memoFilter.ts b/web/src/store/memoFilter.ts index eddbff543..16325763b 100644 --- a/web/src/store/memoFilter.ts +++ b/web/src/store/memoFilter.ts @@ -1,31 +1,57 @@ +/** + * Memo Filter Store + * + * Manages active memo filters and search state. + * This is a client state store that syncs with URL query parameters. + * + * Filters are URL-driven and shareable - copying the URL preserves the filter state. + */ import { uniqBy } from "lodash-es"; -import { makeAutoObservable } from "mobx"; +import { StandardState } from "./base-store"; +/** + * Filter factor types + * Defines what aspect of a memo to filter by + */ export type FilterFactor = - | "tagSearch" - | "visibility" - | "contentSearch" - | "displayTime" - | "pinned" - | "property.hasLink" - | "property.hasTaskList" - | "property.hasCode"; - + | "tagSearch" // Filter by tag name + | "visibility" // Filter by visibility (public/private) + | "contentSearch" // Search in memo content + | "displayTime" // Filter by date + | "pinned" // Show only pinned memos + | "property.hasLink" // Memos containing links + | "property.hasTaskList" // Memos with task lists + | "property.hasCode"; // Memos with code blocks + +/** + * Memo filter object + */ export interface MemoFilter { factor: FilterFactor; value: string; } -export const getMemoFilterKey = (filter: MemoFilter) => `${filter.factor}:${filter.value}`; - +/** + * Generate a unique key for a filter + * Used for deduplication + */ +export const getMemoFilterKey = (filter: MemoFilter): string => `${filter.factor}:${filter.value}`; + +/** + * Parse filter query string from URL into filter objects + * + * @param query - URL query string (e.g., "tagSearch:work,pinned:true") + * @returns Array of filter objects + */ export const parseFilterQuery = (query: string | null): MemoFilter[] => { if (!query) return []; + try { return query.split(",").map((filterStr) => { const [factor, value] = filterStr.split(":"); return { factor: factor as FilterFactor, - value: decodeURIComponent(value), + value: decodeURIComponent(value || ""), }; }); } catch (error) { @@ -34,59 +60,191 @@ export const parseFilterQuery = (query: string | null): MemoFilter[] => { } }; +/** + * Convert filter objects into URL query string + * + * @param filters - Array of filter objects + * @returns URL-encoded query string + */ export const stringifyFilters = (filters: MemoFilter[]): string => { return filters.map((filter) => `${filter.factor}:${encodeURIComponent(filter.value)}`).join(","); }; -class MemoFilterState { +/** + * Memo filter store state + */ +class MemoFilterState extends StandardState { + /** + * Active filters + */ filters: MemoFilter[] = []; + + /** + * Currently selected shortcut ID + * Shortcuts are predefined filter combinations + */ shortcut?: string = undefined; + /** + * Initialize from URL on construction + */ constructor() { - makeAutoObservable(this); - this.init(); + super(); + this.initFromURL(); } - init() { - const searchParams = new URLSearchParams(window.location.search); - this.filters = parseFilterQuery(searchParams.get("filter")); + /** + * Load filters from current URL query parameters + */ + private initFromURL(): void { + try { + const searchParams = new URLSearchParams(window.location.search); + this.filters = parseFilterQuery(searchParams.get("filter")); + } catch (error) { + console.warn("Failed to parse filters from URL:", error); + this.filters = []; + } } - setState(state: Partial) { - Object.assign(this, state); - } - - getFiltersByFactor(factor: FilterFactor) { + /** + * Get all filters for a specific factor + * + * @param factor - The filter factor to query + * @returns Array of matching filters + */ + getFiltersByFactor(factor: FilterFactor): MemoFilter[] { return this.filters.filter((f) => f.factor === factor); } - addFilter(filter: MemoFilter) { + /** + * Add a filter (deduplicates automatically) + * + * @param filter - The filter to add + */ + addFilter(filter: MemoFilter): void { this.filters = uniqBy([...this.filters, filter], getMemoFilterKey); } - removeFilter(filterFn: (f: MemoFilter) => boolean) { - this.filters = this.filters.filter((f) => !filterFn(f)); + /** + * Remove filters matching the predicate + * + * @param predicate - Function that returns true for filters to remove + */ + removeFilter(predicate: (f: MemoFilter) => boolean): void { + this.filters = this.filters.filter((f) => !predicate(f)); + } + + /** + * Remove all filters for a specific factor + * + * @param factor - The filter factor to remove + */ + removeFiltersByFactor(factor: FilterFactor): void { + this.filters = this.filters.filter((f) => f.factor !== factor); + } + + /** + * Clear all filters + */ + clearAllFilters(): void { + this.filters = []; + this.shortcut = undefined; } - setShortcut(shortcut?: string) { + /** + * Set the current shortcut + * + * @param shortcut - Shortcut ID or undefined to clear + */ + setShortcut(shortcut?: string): void { this.shortcut = shortcut; } + + /** + * Check if a specific filter is active + * + * @param filter - The filter to check + * @returns True if the filter is active + */ + hasFilter(filter: MemoFilter): boolean { + return this.filters.some((f) => getMemoFilterKey(f) === getMemoFilterKey(filter)); + } + + /** + * Check if any filters are active + */ + get hasActiveFilters(): boolean { + return this.filters.length > 0 || this.shortcut !== undefined; + } } +/** + * Memo filter store instance + */ const memoFilterStore = (() => { const state = new MemoFilterState(); return { - get filters() { + /** + * Direct access to state for observers + */ + state, + + /** + * Get all active filters + */ + get filters(): MemoFilter[] { return state.filters; }, - get shortcut() { + + /** + * Get current shortcut ID + */ + get shortcut(): string | undefined { return state.shortcut; }, - getFiltersByFactor: (factor: FilterFactor) => state.getFiltersByFactor(factor), - addFilter: (filter: MemoFilter) => state.addFilter(filter), - removeFilter: (filterFn: (f: MemoFilter) => boolean) => state.removeFilter(filterFn), - setShortcut: (shortcut?: string) => state.setShortcut(shortcut), + + /** + * Check if any filters are active + */ + get hasActiveFilters(): boolean { + return state.hasActiveFilters; + }, + + /** + * Get filters by factor + */ + getFiltersByFactor: (factor: FilterFactor): MemoFilter[] => state.getFiltersByFactor(factor), + + /** + * Add a filter + */ + addFilter: (filter: MemoFilter): void => state.addFilter(filter), + + /** + * Remove filters matching predicate + */ + removeFilter: (predicate: (f: MemoFilter) => boolean): void => state.removeFilter(predicate), + + /** + * Remove all filters for a factor + */ + removeFiltersByFactor: (factor: FilterFactor): void => state.removeFiltersByFactor(factor), + + /** + * Clear all filters + */ + clearAllFilters: (): void => state.clearAllFilters(), + + /** + * Set current shortcut + */ + setShortcut: (shortcut?: string): void => state.setShortcut(shortcut), + + /** + * Check if a filter is active + */ + hasFilter: (filter: MemoFilter): boolean => state.hasFilter(filter), }; })(); diff --git a/web/src/store/store-utils.ts b/web/src/store/store-utils.ts new file mode 100644 index 000000000..b9e328123 --- /dev/null +++ b/web/src/store/store-utils.ts @@ -0,0 +1,152 @@ +/** + * Store utilities for MobX stores + * Provides request deduplication, error handling, and other common patterns + */ + +/** + * Custom error class for store operations + * Provides structured error information for better debugging and error handling + */ +export class StoreError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly originalError?: unknown, + ) { + super(message); + this.name = "StoreError"; + } + + /** + * Check if an error is an AbortError from a cancelled request + */ + static isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; + } + + /** + * Wrap an unknown error in a StoreError for consistent error handling + */ + static wrap(code: string, error: unknown, customMessage?: string): StoreError { + if (error instanceof StoreError) { + return error; + } + + const message = customMessage || (error instanceof Error ? error.message : "Unknown error"); + return new StoreError(code, message, error); + } +} + +/** + * Request deduplication manager + * Prevents multiple identical requests from being made simultaneously + */ +export class RequestDeduplicator { + private pendingRequests = new Map>(); + + /** + * Execute a request with deduplication + * If the same request key is already pending, returns the existing promise + * + * @param key - Unique identifier for this request (e.g., JSON.stringify(params)) + * @param requestFn - Function that executes the actual request + * @returns Promise that resolves with the request result + */ + async execute(key: string, requestFn: () => Promise): Promise { + // Check if this request is already pending + if (this.pendingRequests.has(key)) { + return this.pendingRequests.get(key) as Promise; + } + + // Create new request + const promise = requestFn().finally(() => { + // Clean up after request completes (success or failure) + this.pendingRequests.delete(key); + }); + + // Store the pending request + this.pendingRequests.set(key, promise); + + return promise; + } + + /** + * Cancel all pending requests + */ + clear(): void { + this.pendingRequests.clear(); + } + + /** + * Check if a request with the given key is pending + */ + isPending(key: string): boolean { + return this.pendingRequests.has(key); + } + + /** + * Get the number of pending requests + */ + get size(): number { + return this.pendingRequests.size; + } +} + +/** + * Create a request key from parameters + * Useful for generating consistent keys for request deduplication + */ +export function createRequestKey(prefix: string, params?: Record): string { + if (!params) { + return prefix; + } + + // Sort keys for consistent hashing + const sortedParams = Object.keys(params) + .sort() + .reduce( + (acc, key) => { + acc[key] = params[key]; + return acc; + }, + {} as Record, + ); + + return `${prefix}:${JSON.stringify(sortedParams)}`; +} + +/** + * Optimistic update helper + * Handles optimistic updates with rollback on error + */ +export class OptimisticUpdate { + constructor( + private getCurrentState: () => T, + private setState: (state: T) => void, + ) {} + + /** + * Execute an update with optimistic UI updates + * + * @param optimisticState - State to apply immediately + * @param updateFn - Async function that performs the actual update + * @returns Promise that resolves with the update result + */ + async execute(optimisticState: T, updateFn: () => Promise): Promise { + const previousState = this.getCurrentState(); + + try { + // Apply optimistic update immediately + this.setState(optimisticState); + + // Perform actual update + const result = await updateFn(); + + return result; + } catch (error) { + // Rollback on error + this.setState(previousState); + throw error; + } + } +} diff --git a/web/src/store/user.ts b/web/src/store/user.ts index 6f0e49c93..518bd62cd 100644 --- a/web/src/store/user.ts +++ b/web/src/store/user.ts @@ -1,5 +1,5 @@ import { uniqueId } from "lodash-es"; -import { makeAutoObservable } from "mobx"; +import { makeAutoObservable, computed } from "mobx"; import { authServiceClient, inboxServiceClient, userServiceClient, shortcutServiceClient } from "@/grpcweb"; import { Inbox } from "@/types/proto/api/v1/inbox_service"; import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; @@ -14,6 +14,7 @@ import { UserStats, } from "@/types/proto/api/v1/user_service"; import { findNearestMatchedLanguage } from "@/utils/i18n"; +import { RequestDeduplicator, createRequestKey, StoreError } from "./store-utils"; import workspaceStore from "./workspace"; class LocalState { @@ -30,14 +31,21 @@ class LocalState { // The state id of user stats map. statsStateId = uniqueId(); + /** + * Computed property that aggregates tag counts across all users. + * Uses @computed to memoize the result and only recalculate when userStatsByName changes. + * This prevents unnecessary recalculations on every access. + */ get tagCount() { - const tagCount: Record = {}; - for (const stats of Object.values(this.userStatsByName)) { - for (const tag of Object.keys(stats.tagCount)) { - tagCount[tag] = (tagCount[tag] || 0) + stats.tagCount[tag]; + return computed(() => { + const tagCount: Record = {}; + for (const stats of Object.values(this.userStatsByName)) { + for (const tag of Object.keys(stats.tagCount)) { + tagCount[tag] = (tagCount[tag] || 0) + stats.tagCount[tag]; + } } - } - return tagCount; + return tagCount; + }).get(); } get currentUserStats() { @@ -58,6 +66,7 @@ class LocalState { const userStore = (() => { const state = new LocalState(); + const deduplicator = new RequestDeduplicator(); const getOrFetchUserByName = async (name: string) => { const userMap = state.userMapByName; @@ -104,15 +113,22 @@ const userStore = (() => { }; const fetchUsers = async () => { - const { users } = await userServiceClient.listUsers({}); - const userMap = state.userMapByName; - for (const user of users) { - userMap[user.name] = user; - } - state.setPartial({ - userMapByName: userMap, + const requestKey = createRequestKey("fetchUsers"); + return deduplicator.execute(requestKey, async () => { + try { + const { users } = await userServiceClient.listUsers({}); + const userMap = state.userMapByName; + for (const user of users) { + userMap[user.name] = user; + } + state.setPartial({ + userMapByName: userMap, + }); + return users; + } catch (error) { + throw StoreError.wrap("FETCH_USERS_FAILED", error); + } }); - return users; }; const updateUser = async (user: Partial, updateMask: string[]) => { @@ -237,21 +253,28 @@ const userStore = (() => { }; const fetchUserStats = async (user?: string) => { - const userStatsByName: Record = {}; - if (!user) { - const { stats } = await userServiceClient.listAllUserStats({}); - for (const userStats of stats) { - userStatsByName[userStats.name] = userStats; + const requestKey = createRequestKey("fetchUserStats", { user }); + return deduplicator.execute(requestKey, async () => { + try { + const userStatsByName: Record = {}; + if (!user) { + const { stats } = await userServiceClient.listAllUserStats({}); + for (const userStats of stats) { + userStatsByName[userStats.name] = userStats; + } + } else { + const userStats = await userServiceClient.getUserStats({ name: user }); + userStatsByName[user] = userStats; + } + state.setPartial({ + userStatsByName: { + ...state.userStatsByName, + ...userStatsByName, + }, + }); + } catch (error) { + throw StoreError.wrap("FETCH_USER_STATS_FAILED", error); } - } else { - const userStats = await userServiceClient.getUserStats({ name: user }); - userStatsByName[user] = userStats; - } - state.setPartial({ - userStatsByName: { - ...state.userStatsByName, - ...userStatsByName, - }, }); }; @@ -278,23 +301,38 @@ const userStore = (() => { }; })(); -// TODO: refactor initialUserStore as it has temporal coupling -// need to make it more clear that the order of the body is important -// or it leads to false positives -// See: https://github.com/usememos/memos/issues/4978 +/** + * Initializes the user store with proper sequencing to avoid temporal coupling. + * + * Initialization steps (order is critical): + * 1. Fetch current authenticated user session + * 2. Set current user in store (required for subsequent calls) + * 3. Fetch user settings (depends on currentUser being set) + * 4. Apply user preferences to workspace store + * + * @throws Never - errors are handled internally with fallback behavior + */ export const initialUserStore = async () => { try { + // Step 1: Authenticate and get current user const { user: currentUser } = await authServiceClient.getCurrentSession({}); + if (!currentUser) { - // If no user is authenticated, we can skip the rest of the initialization. + // No authenticated user - clear state and use default locale userStore.state.setPartial({ currentUser: undefined, userGeneralSetting: undefined, userMapByName: {}, }); + + const locale = findNearestMatchedLanguage(navigator.language); + workspaceStore.state.setPartial({ locale }); return; } + // Step 2: Set current user in store + // CRITICAL: This must happen before fetchUserSettings() is called + // because fetchUserSettings() depends on state.currentUser being set userStore.state.setPartial({ currentUser: currentUser.name, userMapByName: { @@ -302,24 +340,31 @@ export const initialUserStore = async () => { }, }); - // must be called after user is set in store + // Step 3: Fetch user settings + // CRITICAL: This must happen after currentUser is set in step 2 + // The fetchUserSettings() method checks state.currentUser internally await userStore.fetchUserSettings(); - // must be run after fetchUserSettings is called. - // Apply general settings to workspace if available + // Step 4: Apply user preferences to workspace + // CRITICAL: This must happen after fetchUserSettings() completes + // We need userGeneralSetting to be populated before accessing it const generalSetting = userStore.state.userGeneralSetting; if (generalSetting) { + // Note: setPartial will validate theme automatically workspaceStore.state.setPartial({ locale: generalSetting.locale, - theme: generalSetting.theme || "default", + theme: generalSetting.theme || "default", // Validation handled by setPartial }); + } else { + // Fallback if settings weren't loaded + const locale = findNearestMatchedLanguage(navigator.language); + workspaceStore.state.setPartial({ locale }); } - } catch { - // find the nearest matched lang based on the `navigator.language` if the user is unauthenticated or settings retrieval fails. + } catch (error) { + // On any error, fall back to browser language detection + console.error("Failed to initialize user store:", error); const locale = findNearestMatchedLanguage(navigator.language); - workspaceStore.state.setPartial({ - locale: locale, - }); + workspaceStore.state.setPartial({ locale }); } }; diff --git a/web/src/store/view.ts b/web/src/store/view.ts index a8535ad1e..82a8b4acb 100644 --- a/web/src/store/view.ts +++ b/web/src/store/view.ts @@ -1,49 +1,128 @@ -import { makeAutoObservable } from "mobx"; +/** + * View Store + * + * Manages UI display preferences and layout settings. + * This is a client state store that persists to localStorage. + */ +import { StandardState } from "./base-store"; const LOCAL_STORAGE_KEY = "memos-view-setting"; -class LocalState { +/** + * Layout mode options + */ +export type LayoutMode = "LIST" | "MASONRY"; + +/** + * View store state + * Contains UI preferences for displaying memos + */ +class ViewState extends StandardState { + /** + * Sort order: true = ascending (oldest first), false = descending (newest first) + */ orderByTimeAsc: boolean = false; - layout: "LIST" | "MASONRY" = "LIST"; - constructor() { - makeAutoObservable(this); - } + /** + * Display layout mode + * - LIST: Traditional vertical list + * - MASONRY: Pinterest-style grid layout + */ + layout: LayoutMode = "LIST"; + + /** + * Override setPartial to persist to localStorage + */ + setPartial(partial: Partial): void { + // Validate layout if provided + if (partial.layout !== undefined && !["LIST", "MASONRY"].includes(partial.layout)) { + console.warn(`Invalid layout "${partial.layout}", ignoring`); + return; + } - setPartial(partial: Partial) { Object.assign(this, partial); - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this)); + + // Persist to localStorage + try { + localStorage.setItem( + LOCAL_STORAGE_KEY, + JSON.stringify({ + orderByTimeAsc: this.orderByTimeAsc, + layout: this.layout, + }), + ); + } catch (error) { + console.warn("Failed to persist view settings:", error); + } } } +/** + * View store instance + */ const viewStore = (() => { - const state = new LocalState(); + const state = new ViewState(); - return { - state, - }; -})(); + // Load from localStorage on initialization + try { + const cached = localStorage.getItem(LOCAL_STORAGE_KEY); + if (cached) { + const data = JSON.parse(cached); -// Initial state from localStorage. -(async () => { - const localCache = localStorage.getItem(LOCAL_STORAGE_KEY); - if (!localCache) { - return; - } + // Validate and restore orderByTimeAsc + if (Object.hasOwn(data, "orderByTimeAsc")) { + state.orderByTimeAsc = Boolean(data.orderByTimeAsc); + } - try { - const cache = JSON.parse(localCache); - if (Object.hasOwn(cache, "orderByTimeAsc")) { - viewStore.state.setPartial({ orderByTimeAsc: Boolean(cache.orderByTimeAsc) }); - } - if (Object.hasOwn(cache, "layout")) { - if (["LIST", "MASONRY"].includes(cache.layout)) { - viewStore.state.setPartial({ layout: cache.layout }); + // Validate and restore layout + if (Object.hasOwn(data, "layout") && ["LIST", "MASONRY"].includes(data.layout)) { + state.layout = data.layout as LayoutMode; } } - } catch { - // Do nothing + } catch (error) { + console.warn("Failed to load view settings from localStorage:", error); } + + /** + * Toggle sort order between ascending and descending + */ + const toggleSortOrder = (): void => { + state.setPartial({ orderByTimeAsc: !state.orderByTimeAsc }); + }; + + /** + * Set the layout mode + * + * @param layout - The layout mode to set + */ + const setLayout = (layout: LayoutMode): void => { + state.setPartial({ layout }); + }; + + /** + * Reset to default settings + */ + const resetToDefaults = (): void => { + state.setPartial({ + orderByTimeAsc: false, + layout: "LIST", + }); + }; + + /** + * Clear persisted settings + */ + const clearStorage = (): void => { + localStorage.removeItem(LOCAL_STORAGE_KEY); + }; + + return { + state, + toggleSortOrder, + setLayout, + resetToDefaults, + clearStorage, + }; })(); export default viewStore; diff --git a/web/src/store/workspace.ts b/web/src/store/workspace.ts index bde2aa0c0..e53c3241b 100644 --- a/web/src/store/workspace.ts +++ b/web/src/store/workspace.ts @@ -1,5 +1,11 @@ +/** + * Workspace Store + * + * Manages workspace-level configuration and settings. + * This is a server state store that fetches workspace profile and settings. + */ import { uniqBy } from "lodash-es"; -import { makeAutoObservable } from "mobx"; +import { computed } from "mobx"; import { workspaceServiceClient } from "@/grpcweb"; import { WorkspaceProfile, WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service"; import { @@ -8,74 +14,181 @@ import { WorkspaceSetting, } from "@/types/proto/api/v1/workspace_service"; import { isValidateLocale } from "@/utils/i18n"; +import { StandardState, createServerStore } from "./base-store"; import { workspaceSettingNamePrefix } from "./common"; +import { createRequestKey } from "./store-utils"; -class LocalState { +/** + * Valid theme options + */ +const VALID_THEMES = ["default", "default-dark", "paper", "whitewall"] as const; +export type Theme = (typeof VALID_THEMES)[number]; + +/** + * Check if a string is a valid theme + */ +export function isValidTheme(theme: string): theme is Theme { + return VALID_THEMES.includes(theme as Theme); +} + +/** + * Workspace store state + */ +class WorkspaceState extends StandardState { + /** + * Current locale (e.g., "en", "zh", "ja") + */ locale: string = "en"; - theme: string = "default"; + + /** + * Current theme + * Note: Accepts string for flexibility, but validates to Theme + */ + theme: Theme | string = "default"; + + /** + * Workspace profile containing owner and metadata + */ profile: WorkspaceProfile = WorkspaceProfile.fromPartial({}); + + /** + * Array of workspace settings + */ settings: WorkspaceSetting[] = []; - get generalSetting() { - return ( - this.settings.find((setting) => setting.name === `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.GENERAL}`)?.generalSetting || - WorkspaceSetting_GeneralSetting.fromPartial({}) - ); + /** + * Computed property for general settings + * Memoized for performance + */ + get generalSetting(): WorkspaceSetting_GeneralSetting { + return computed(() => { + const setting = this.settings.find((s) => s.name === `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.GENERAL}`); + return setting?.generalSetting || WorkspaceSetting_GeneralSetting.fromPartial({}); + }).get(); } - get memoRelatedSetting() { - return ( - this.settings.find((setting) => setting.name === `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.MEMO_RELATED}`) - ?.memoRelatedSetting || WorkspaceSetting_MemoRelatedSetting.fromPartial({}) - ); + /** + * Computed property for memo-related settings + * Memoized for performance + */ + get memoRelatedSetting(): WorkspaceSetting_MemoRelatedSetting { + return computed(() => { + const setting = this.settings.find((s) => s.name === `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.MEMO_RELATED}`); + return setting?.memoRelatedSetting || WorkspaceSetting_MemoRelatedSetting.fromPartial({}); + }).get(); } - constructor() { - makeAutoObservable(this); - } + /** + * Override setPartial to validate locale and theme + */ + setPartial(partial: Partial): void { + const finalState = { ...this, ...partial }; - setPartial(partial: Partial) { - const finalState = { - ...this, - ...partial, - }; - if (!isValidateLocale(finalState.locale)) { + // Validate locale + if (partial.locale !== undefined && !isValidateLocale(finalState.locale)) { + console.warn(`Invalid locale "${finalState.locale}", falling back to "en"`); finalState.locale = "en"; } - if (!["default", "default-dark", "paper", "whitewall"].includes(finalState.theme)) { - finalState.theme = "default"; + + // Validate theme - accept string and validate + if (partial.theme !== undefined) { + const themeStr = String(finalState.theme); + if (!isValidTheme(themeStr)) { + console.warn(`Invalid theme "${themeStr}", falling back to "default"`); + finalState.theme = "default"; + } else { + finalState.theme = themeStr; + } } + Object.assign(this, finalState); } } +/** + * Workspace store instance + */ const workspaceStore = (() => { - const state = new LocalState(); + const base = createServerStore(new WorkspaceState(), { + name: "workspace", + enableDeduplication: true, + }); - const fetchWorkspaceSetting = async (settingKey: WorkspaceSetting_Key) => { - const setting = await workspaceServiceClient.getWorkspaceSetting({ name: `${workspaceSettingNamePrefix}${settingKey}` }); - state.setPartial({ - settings: uniqBy([setting, ...state.settings], "name"), - }); - }; + const { state, executeRequest } = base; - const upsertWorkspaceSetting = async (setting: WorkspaceSetting) => { - await workspaceServiceClient.updateWorkspaceSetting({ setting }); - state.setPartial({ - settings: uniqBy([setting, ...state.settings], "name"), - }); + /** + * Fetch a specific workspace setting by key + * + * @param settingKey - The setting key to fetch + */ + const fetchWorkspaceSetting = async (settingKey: WorkspaceSetting_Key): Promise => { + const requestKey = createRequestKey("fetchWorkspaceSetting", { key: settingKey }); + + return executeRequest( + requestKey, + async () => { + const setting = await workspaceServiceClient.getWorkspaceSetting({ + name: `${workspaceSettingNamePrefix}${settingKey}`, + }); + + // Merge into settings array, avoiding duplicates + state.setPartial({ + settings: uniqBy([setting, ...state.settings], "name"), + }); + }, + "FETCH_WORKSPACE_SETTING_FAILED", + ); }; - const getWorkspaceSettingByKey = (settingKey: WorkspaceSetting_Key) => { - return ( - state.settings.find((setting) => setting.name === `${workspaceSettingNamePrefix}${settingKey}`) || WorkspaceSetting.fromPartial({}) + /** + * Update or create a workspace setting + * + * @param setting - The setting to upsert + */ + const upsertWorkspaceSetting = async (setting: WorkspaceSetting): Promise => { + return executeRequest( + "", // No deduplication for updates + async () => { + await workspaceServiceClient.updateWorkspaceSetting({ setting }); + + // Update local state + state.setPartial({ + settings: uniqBy([setting, ...state.settings], "name"), + }); + }, + "UPDATE_WORKSPACE_SETTING_FAILED", ); }; - const setTheme = async (theme: string) => { + /** + * Get a workspace setting from cache by key + * Does not trigger a fetch + * + * @param settingKey - The setting key + * @returns The cached setting or an empty setting + */ + const getWorkspaceSettingByKey = (settingKey: WorkspaceSetting_Key): WorkspaceSetting => { + const setting = state.settings.find((s) => s.name === `${workspaceSettingNamePrefix}${settingKey}`); + return setting || WorkspaceSetting.fromPartial({}); + }; + + /** + * Set the workspace theme + * Updates both local state and persists to server + * + * @param theme - The theme to set + */ + const setTheme = async (theme: string): Promise => { + // Validate theme + if (!isValidTheme(theme)) { + console.warn(`Invalid theme "${theme}", ignoring`); + return; + } + + // Update local state immediately state.setPartial({ theme }); - // Update the workspace setting - store theme in a custom field or handle differently + // Persist to server const generalSetting = state.generalSetting; const updatedGeneralSetting = WorkspaceSetting_GeneralSetting.fromPartial({ ...generalSetting, @@ -92,28 +205,65 @@ const workspaceStore = (() => { ); }; + /** + * Fetch workspace profile + */ + const fetchWorkspaceProfile = async (): Promise => { + const requestKey = createRequestKey("fetchWorkspaceProfile"); + + return executeRequest( + requestKey, + async () => { + const profile = await workspaceServiceClient.getWorkspaceProfile({}); + state.setPartial({ profile }); + return profile; + }, + "FETCH_WORKSPACE_PROFILE_FAILED", + ); + }; + return { state, fetchWorkspaceSetting, + fetchWorkspaceProfile, upsertWorkspaceSetting, getWorkspaceSettingByKey, setTheme, }; })(); -export const initialWorkspaceStore = async () => { - const workspaceProfile = await workspaceServiceClient.getWorkspaceProfile({}); - // Prepare workspace settings. - for (const key of [WorkspaceSetting_Key.GENERAL, WorkspaceSetting_Key.MEMO_RELATED]) { - await workspaceStore.fetchWorkspaceSetting(key); - } +/** + * Initialize the workspace store + * Called once at app startup to load workspace profile and settings + * + * @throws Never - errors are logged but not thrown + */ +export const initialWorkspaceStore = async (): Promise => { + try { + // Fetch workspace profile + const workspaceProfile = await workspaceStore.fetchWorkspaceProfile(); - const workspaceGeneralSetting = workspaceStore.state.generalSetting; - workspaceStore.state.setPartial({ - locale: workspaceGeneralSetting.customProfile?.locale, - theme: "default", - profile: workspaceProfile, - }); + // Fetch required settings + await Promise.all([ + workspaceStore.fetchWorkspaceSetting(WorkspaceSetting_Key.GENERAL), + workspaceStore.fetchWorkspaceSetting(WorkspaceSetting_Key.MEMO_RELATED), + ]); + + // Apply settings to state + const workspaceGeneralSetting = workspaceStore.state.generalSetting; + workspaceStore.state.setPartial({ + locale: workspaceGeneralSetting.customProfile?.locale || "en", + theme: "default", + profile: workspaceProfile, + }); + } catch (error) { + console.error("Failed to initialize workspace store:", error); + // Set default fallback values + workspaceStore.state.setPartial({ + locale: "en", + theme: "default", + }); + } }; export default workspaceStore;