mirror of https://github.com/usememos/memos
				
				
				
			refactor: standardize MobX store architecture with base classes and utilities
Refactored all stores to follow consistent patterns and best practices while keeping MobX: New Infrastructure: - Created base-store.ts with StandardState base class and factory functions - Added store-utils.ts with RequestDeduplicator, StoreError, and OptimisticUpdate helpers - Added config.ts for MobX configuration and strict mode - Created comprehensive README.md with architecture guide and examples Server State Stores (API data): - attachment.ts: Added request deduplication, error handling, computed properties, delete/clear methods - workspace.ts: Added Theme type validation, computed memoization, improved initialization - memo.ts: Enhanced with optimistic updates, request deduplication, structured errors - user.ts: Fixed temporal coupling, added computed memoization, request deduplication Client State Stores (UI state): - view.ts: Added helper methods (toggleSortOrder, setLayout, resetToDefaults), input validation - memoFilter.ts: Added utility methods (hasFilter, clearAllFilters, removeFiltersByFactor) Improvements: - Request deduplication prevents duplicate API calls (all server stores) - Computed property memoization improves performance - Structured error handling with error codes - Optimistic updates for better UX (memo updates) - Comprehensive JSDoc documentation - Type-safe APIs with proper exports - Clear separation between server and client state All stores now follow consistent patterns for better maintainability and easier onboarding. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>pull/5184/head
							parent
							
								
									cce52585c4
								
							
						
					
					
						commit
						f5624fa682
					
				@ -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 <div>...</div>;
 | 
			
		||||
});
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 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<string, Data> = {};
 | 
			
		||||
 | 
			
		||||
  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<MyState>) {
 | 
			
		||||
    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)
 | 
			
		||||
@ -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<this>): 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<string, Memo> = {};
 | 
			
		||||
 *   constructor() { makeAutoObservable(this); }
 | 
			
		||||
 *   setPartial(partial: Partial<this>) { 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<TState extends BaseState>(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<T>(key: string, operation: () => Promise<T>, errorCode?: string): Promise<T> {
 | 
			
		||||
      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<this>) {
 | 
			
		||||
 *     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<TState extends BaseState>(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<this>): void {
 | 
			
		||||
    Object.assign(this, partial);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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,
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@ -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 <div>...</div>;
 | 
			
		||||
 * });
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
// 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;
 | 
			
		||||
 | 
			
		||||
@ -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<string, Promise<any>>();
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 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<T>(key: string, requestFn: () => Promise<T>): Promise<T> {
 | 
			
		||||
    // Check if this request is already pending
 | 
			
		||||
    if (this.pendingRequests.has(key)) {
 | 
			
		||||
      return this.pendingRequests.get(key) as Promise<T>;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 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, any>): 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<string, any>,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  return `${prefix}:${JSON.stringify(sortedParams)}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Optimistic update helper
 | 
			
		||||
 * Handles optimistic updates with rollback on error
 | 
			
		||||
 */
 | 
			
		||||
export class OptimisticUpdate<T> {
 | 
			
		||||
  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<R>(optimisticState: T, updateFn: () => Promise<R>): Promise<R> {
 | 
			
		||||
    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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -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<ViewState>): 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<LocalState>) {
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
					Loading…
					
					
				
		Reference in New Issue