feat(core): standardize storage + workspace isolation for persist stores

Unify all client-side persistence through StorageAdapter and add
workspace-scoped key namespacing (${key}:${wsId}).

- createPersistStorage: bridge for Zustand persist → StorageAdapter DI
- createWorkspaceAwareStorage: dynamic namespace by current workspace
- Migrate 6 persist stores (navigation, draft, view, scope, my-issues-view, chat)
- Rehydration registry: stores auto-rehydrate on workspace switch
- clearWorkspaceStorage: cleanup on workspace delete / member removal
- Chat store: namespace keys + rehydrate on workspace switch
- Factory view stores (createIssueViewStore): auto-register for rehydration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-10 15:59:53 +08:00
parent ee46fd6064
commit 85cff15427
14 changed files with 134 additions and 45 deletions

View File

@@ -1,5 +1,6 @@
import { create } from "zustand";
import type { StorageAdapter } from "../types";
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
@@ -39,12 +40,17 @@ export interface ChatStoreOptions {
export function createChatStore(options: ChatStoreOptions) {
const { storage } = options;
return create<ChatState>((set) => ({
const wsKey = (base: string) => {
const wsId = getCurrentWorkspaceId();
return wsId ? `${base}:${wsId}` : base;
};
const store = create<ChatState>((set) => ({
isOpen: false,
isFullscreen: false,
activeSessionId: storage.getItem(SESSION_STORAGE_KEY),
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
pendingTaskId: null,
selectedAgentId: storage.getItem(AGENT_STORAGE_KEY),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
timelineItems: [],
setOpen: (open) =>
@@ -57,15 +63,15 @@ export function createChatStore(options: ChatStoreOptions) {
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
setActiveSession: (id) => {
if (id) {
storage.setItem(SESSION_STORAGE_KEY, id);
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
} else {
storage.removeItem(SESSION_STORAGE_KEY);
storage.removeItem(wsKey(SESSION_STORAGE_KEY));
}
set({ activeSessionId: id });
},
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
setSelectedAgentId: (id) => {
storage.setItem(AGENT_STORAGE_KEY, id);
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
set({ selectedAgentId: id });
},
setShowHistory: (show) => set({ showHistory: show }),
@@ -80,4 +86,14 @@ export function createChatStore(options: ChatStoreOptions) {
}),
clearTimeline: () => set({ timelineItems: [] }),
}));
registerForWorkspaceRehydration(() => {
store.setState({
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
timelineItems: [],
});
});
return store;
}

View File

@@ -1,7 +1,7 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "../../types";
import { createWorkspaceAwareStorage } from "../../platform/workspace-storage";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
interface IssueDraft {
@@ -49,3 +49,5 @@ export const useIssueDraftStore = create<IssueDraftStore>()(
},
),
);
registerForWorkspaceRehydration(() => useIssueDraftStore.persist.rehydrate());

View File

@@ -2,7 +2,7 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage } from "../../platform/workspace-storage";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type IssuesScope = "all" | "members" | "agents";
@@ -24,3 +24,5 @@ export const useIssuesScopeStore = create<IssuesScopeState>()(
},
),
);
registerForWorkspaceRehydration(() => useIssuesScopeStore.persist.rehydrate());

View File

@@ -7,6 +7,7 @@ import {
viewStoreSlice,
viewStorePersistOptions,
} from "./view-store";
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
export type MyIssuesScope = "assigned" | "created" | "agents";
@@ -17,7 +18,7 @@ export interface MyIssuesViewState extends IssueViewState {
const basePersist = viewStorePersistOptions("multica_my_issues_view");
export const myIssuesViewStore: StoreApi<MyIssuesViewState> = createStore<MyIssuesViewState>()(
const _myIssuesViewStore = createStore<MyIssuesViewState>()(
persist(
(set) => ({
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
@@ -34,3 +35,7 @@ export const myIssuesViewStore: StoreApi<MyIssuesViewState> = createStore<MyIssu
},
),
);
export const myIssuesViewStore: StoreApi<MyIssuesViewState> = _myIssuesViewStore;
registerForWorkspaceRehydration(() => _myIssuesViewStore.persist.rehydrate());

View File

@@ -5,7 +5,7 @@ import { createStore, type StoreApi } from "zustand/vanilla";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus, IssuePriority } from "../../types";
import { ALL_STATUSES } from "../config";
import { createWorkspaceAwareStorage } from "../../platform/workspace-storage";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list";
@@ -183,9 +183,11 @@ export const viewStorePersistOptions = (name: string) => ({
/** Factory: creates a vanilla StoreApi for use with React Context. */
export function createIssueViewStore(persistKey: string): StoreApi<IssueViewState> {
return createStore<IssueViewState>()(
const store = createStore<IssueViewState>()(
persist(viewStoreSlice, viewStorePersistOptions(persistKey))
);
registerForWorkspaceRehydration(() => store.persist.rehydrate());
return store;
}
/** Global singleton for the /issues page. */
@@ -193,6 +195,8 @@ export const useIssueViewStore = create<IssueViewState>()(
persist(viewStoreSlice, viewStorePersistOptions("multica_issues_view"))
);
registerForWorkspaceRehydration(() => useIssueViewStore.persist.rehydrate());
// Clear filters on all registered view stores when workspace switches.
const _syncedStores = new Set<StoreApi<IssueViewState>>();
let _workspaceSyncInitialized = false;

View File

@@ -1,7 +1,9 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createJSONStorage, persist } from "zustand/middleware";
import { createPersistStorage } from "../platform/persist-storage";
import { defaultStorage } from "../platform/storage";
const EXCLUDED_PREFIXES = ["/login", "/pair/"];
@@ -23,6 +25,7 @@ export const useNavigationStore = create<NavigationState>()(
}),
{
name: "multica_navigation",
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
partialize: (state) => ({ lastPath: state.lastPath }),
}
)

View File

@@ -3,4 +3,5 @@ export type { CoreProviderProps } from "./types";
export { AuthInitializer } from "./auth-initializer";
export { defaultStorage } from "./storage";
export { createPersistStorage } from "./persist-storage";
export { createWorkspaceAwareStorage, setCurrentWorkspaceId, getCurrentWorkspaceId } from "./workspace-storage";
export { createWorkspaceAwareStorage, setCurrentWorkspaceId, getCurrentWorkspaceId, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
export { clearWorkspaceStorage } from "./storage-cleanup";

View File

@@ -12,7 +12,7 @@ function mockAdapter(): StorageAdapter {
}
describe("createPersistStorage", () => {
it("delegates to StorageAdapter without namespace", () => {
it("delegates to StorageAdapter", () => {
const adapter = mockAdapter();
const storage = createPersistStorage(adapter);
@@ -27,28 +27,6 @@ describe("createPersistStorage", () => {
expect(result).toEqual(JSON.stringify("value"));
});
it("namespaces keys when wsId is provided", () => {
const adapter = mockAdapter();
const storage = createPersistStorage(adapter, "ws_123");
storage.setItem("draft", JSON.stringify({ title: "test" }));
expect(adapter.setItem).toHaveBeenCalledWith(
"draft:ws_123",
JSON.stringify({ title: "test" }),
);
storage.getItem("draft");
expect(adapter.getItem).toHaveBeenCalledWith("draft:ws_123");
});
it("removeItem namespaces correctly", () => {
const adapter = mockAdapter();
const storage = createPersistStorage(adapter, "ws_abc");
storage.removeItem("draft");
expect(adapter.removeItem).toHaveBeenCalledWith("draft:ws_abc");
});
it("returns null for missing keys", () => {
const adapter = mockAdapter();
const storage = createPersistStorage(adapter);
@@ -56,4 +34,12 @@ describe("createPersistStorage", () => {
const result = storage.getItem("nonexistent");
expect(result).toBeNull();
});
it("removeItem delegates correctly", () => {
const adapter = mockAdapter();
const storage = createPersistStorage(adapter);
storage.removeItem("key");
expect(adapter.removeItem).toHaveBeenCalledWith("key");
});
});

View File

@@ -1,14 +1,14 @@
import type { StateStorage } from "zustand/middleware";
import type { StorageAdapter } from "../types/storage";
export function createPersistStorage(
adapter: StorageAdapter,
wsId?: string,
): StateStorage {
const resolve = (key: string) => (wsId ? `${key}:${wsId}` : key);
/**
* Bridge between Zustand persist middleware and our StorageAdapter DI system.
* For workspace-scoped stores, use createWorkspaceAwareStorage instead.
*/
export function createPersistStorage(adapter: StorageAdapter): StateStorage {
return {
getItem: (key) => adapter.getItem(resolve(key)),
setItem: (key, value) => adapter.setItem(resolve(key), value),
removeItem: (key) => adapter.removeItem(resolve(key)),
getItem: (key) => adapter.getItem(key),
setItem: (key, value) => adapter.setItem(key, value),
removeItem: (key) => adapter.removeItem(key),
};
}

View File

@@ -0,0 +1,22 @@
import { describe, it, expect, vi } from "vitest";
import { clearWorkspaceStorage } from "./storage-cleanup";
describe("clearWorkspaceStorage", () => {
it("removes all workspace-scoped keys for given wsId", () => {
const adapter = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
};
clearWorkspaceStorage(adapter, "ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica_issue_draft:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica_issues_view:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica_issues_scope:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica_my_issues_view:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:selectedAgentId:ws_123");
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
expect(adapter.removeItem).toHaveBeenCalledTimes(6);
});
});

View File

@@ -0,0 +1,27 @@
import type { StorageAdapter } from "../types/storage";
/**
* Keys that are namespaced per workspace (stored as `${key}:${wsId}`).
*
* IMPORTANT: When adding a new workspace-scoped persist store or storage key,
* add its key here so that workspace deletion and logout properly clean it up.
* Also ensure the store uses `createWorkspaceAwareStorage` for its persist config.
*/
const WORKSPACE_SCOPED_KEYS = [
"multica_issue_draft",
"multica_issues_view",
"multica_issues_scope",
"multica_my_issues_view",
"multica:chat:selectedAgentId",
"multica:chat:activeSessionId",
];
/** Remove all workspace-scoped storage entries for the given workspace. */
export function clearWorkspaceStorage(
adapter: StorageAdapter,
wsId: string,
) {
for (const key of WORKSPACE_SCOPED_KEYS) {
adapter.removeItem(`${key}:${wsId}`);
}
}

View File

@@ -2,11 +2,24 @@ import type { StateStorage } from "zustand/middleware";
import type { StorageAdapter } from "../types/storage";
let _currentWsId: string | null = null;
const _rehydrateFns: Array<() => void> = [];
export function setCurrentWorkspaceId(wsId: string | null) {
_currentWsId = wsId;
}
/** Register a persist store's rehydrate function to be called on workspace switch. */
export function registerForWorkspaceRehydration(fn: () => void) {
_rehydrateFns.push(fn);
}
/** Rehydrate all registered workspace-scoped persist stores from the new namespace. */
export function rehydrateAllWorkspaceStores() {
for (const fn of _rehydrateFns) {
fn();
}
}
export function getCurrentWorkspaceId(): string | null {
return _currentWsId;
}

View File

@@ -7,6 +7,8 @@ import type { StoreApi, UseBoundStore } from "zustand";
import type { AuthState } from "../auth/store";
import type { WorkspaceStore } from "../workspace/store";
import { createLogger } from "../logger";
import { clearWorkspaceStorage } from "../platform/storage-cleanup";
import { defaultStorage } from "../platform/storage";
import { issueKeys } from "../issues/queries";
import { projectKeys } from "../projects/queries";
import { runtimeKeys } from "../runtimes/queries";
@@ -238,6 +240,7 @@ export function useRealtimeSync(
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
clearWorkspaceStorage(defaultStorage, workspace_id);
const currentWs = workspaceStore.getState().workspace;
if (currentWs?.id === workspace_id) {
logger.warn("current workspace deleted, switching");
@@ -250,6 +253,8 @@ export function useRealtimeSync(
const { user_id } = p as MemberRemovedPayload;
const myUserId = authStore.getState().user?.id;
if (user_id === myUserId) {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) clearWorkspaceStorage(defaultStorage, wsId);
logger.warn("removed from workspace, switching");
onToast?.("You were removed from this workspace", "info");
workspaceStore.getState().refreshWorkspaces();

View File

@@ -2,7 +2,7 @@ import { create } from "zustand";
import type { Workspace, StorageAdapter } from "../types";
import type { ApiClient } from "../api/client";
import { createLogger } from "../logger";
import { setCurrentWorkspaceId } from "../platform/workspace-storage";
import { setCurrentWorkspaceId, rehydrateAllWorkspaceStores } from "../platform/workspace-storage";
const logger = createLogger("workspace-store");
@@ -59,6 +59,7 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
if (!nextWorkspace) {
api.setWorkspaceId(null);
setCurrentWorkspaceId(null);
rehydrateAllWorkspaceStores();
storage?.removeItem("multica_workspace_id");
set({ workspace: null });
return null;
@@ -66,6 +67,7 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
api.setWorkspaceId(nextWorkspace.id);
setCurrentWorkspaceId(nextWorkspace.id);
rehydrateAllWorkspaceStores();
storage?.setItem("multica_workspace_id", nextWorkspace.id);
set({ workspace: nextWorkspace });
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
@@ -142,6 +144,7 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
clearWorkspace: () => {
api.setWorkspaceId(null);
setCurrentWorkspaceId(null);
rehydrateAllWorkspaceStores();
set({ workspace: null, workspaces: [] });
},
}));