mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
22
packages/core/platform/storage-cleanup.test.ts
Normal file
22
packages/core/platform/storage-cleanup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
packages/core/platform/storage-cleanup.ts
Normal file
27
packages/core/platform/storage-cleanup.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user