- Create core/workspace/ with queries (members, agents, skills, list) and mutations - Create core/runtimes/ with queries - Migrate 11 consumer files from useWorkspaceStore.members/agents/skills to useQuery - Replace all WS refreshMap entries with qc.invalidateQueries - Simplify workspace store: delete members/agents/skills fields + refresh methods, hydrateWorkspace becomes synchronous (TQ auto-fetches on component mount) - Delete useRuntimeStore (no consumers left), runtimes-page uses local useState + TQ - Remove workspace→runtime cross-store dependency - Clean up dead test helper mocks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
205 lines
7 KiB
TypeScript
205 lines
7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { WSClient } from "@/shared/api";
|
|
import { toast } from "sonner";
|
|
import { useWorkspaceStore } from "@/features/workspace";
|
|
import { useAuthStore } from "@/features/auth";
|
|
import { createLogger } from "@/shared/logger";
|
|
import { issueKeys } from "@core/issues/queries";
|
|
import {
|
|
onIssueCreated,
|
|
onIssueUpdated,
|
|
onIssueDeleted,
|
|
} from "@core/issues/ws-updaters";
|
|
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "@core/inbox/ws-updaters";
|
|
import { inboxKeys } from "@core/inbox/queries";
|
|
import { workspaceKeys } from "@core/workspace/queries";
|
|
import type {
|
|
MemberAddedPayload,
|
|
WorkspaceDeletedPayload,
|
|
MemberRemovedPayload,
|
|
IssueUpdatedPayload,
|
|
IssueCreatedPayload,
|
|
IssueDeletedPayload,
|
|
InboxNewPayload,
|
|
} from "@/shared/types";
|
|
|
|
const logger = createLogger("realtime-sync");
|
|
|
|
/**
|
|
* Centralized WS → store sync. Called once from WSProvider.
|
|
*
|
|
* Uses the "WS as invalidation signal + refetch" pattern:
|
|
* - onAny handler extracts event prefix and calls the matching store refresh
|
|
* - Debounce per-prefix prevents rapid-fire refetches (e.g. bulk issue updates)
|
|
* - Precise handlers only for side effects (toast, navigation, self-check)
|
|
*
|
|
* Per-page events (comments, activity, subscribers, daemon) are still handled
|
|
* by individual components via useWSEvent — not here.
|
|
*/
|
|
export function useRealtimeSync(ws: WSClient | null) {
|
|
const qc = useQueryClient();
|
|
// Main sync: onAny → refreshMap with debounce
|
|
useEffect(() => {
|
|
if (!ws) return;
|
|
|
|
// Event types handled by specific handlers below — skip generic refresh
|
|
const specificEvents = new Set([
|
|
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
|
]);
|
|
|
|
const refreshMap: Record<string, () => void> = {
|
|
inbox: () => {
|
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
|
if (wsId) onInboxInvalidate(qc, wsId);
|
|
},
|
|
agent: () => {
|
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
|
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
|
},
|
|
member: () => {
|
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
|
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
|
},
|
|
workspace: () => {
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
|
},
|
|
skill: () => {
|
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
|
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
|
},
|
|
};
|
|
|
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
const debouncedRefresh = (prefix: string, fn: () => void) => {
|
|
const existing = timers.get(prefix);
|
|
if (existing) clearTimeout(existing);
|
|
timers.set(
|
|
prefix,
|
|
setTimeout(() => {
|
|
timers.delete(prefix);
|
|
fn();
|
|
}, 100),
|
|
);
|
|
};
|
|
|
|
const unsubAny = ws.onAny((msg) => {
|
|
const myUserId = useAuthStore.getState().user?.id;
|
|
if (msg.actor_id && msg.actor_id === myUserId) {
|
|
logger.debug("skipping self-event", msg.type);
|
|
return;
|
|
}
|
|
if (specificEvents.has(msg.type)) return;
|
|
const prefix = msg.type.split(":")[0] ?? "";
|
|
const refresh = refreshMap[prefix];
|
|
if (refresh) debouncedRefresh(prefix, refresh);
|
|
});
|
|
|
|
// --- Specific event handlers (granular updates, no full refetch) ---
|
|
|
|
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
|
const { issue } = p as IssueUpdatedPayload;
|
|
if (!issue?.id) return;
|
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
|
if (wsId) {
|
|
onIssueUpdated(qc, wsId, issue);
|
|
if (issue.status) {
|
|
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
|
|
}
|
|
}
|
|
});
|
|
|
|
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
|
const { issue } = p as IssueCreatedPayload;
|
|
if (!issue) return;
|
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
|
if (wsId) onIssueCreated(qc, wsId, issue);
|
|
});
|
|
|
|
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
|
const { issue_id } = p as IssueDeletedPayload;
|
|
if (!issue_id) return;
|
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
|
if (wsId) onIssueDeleted(qc, wsId, issue_id);
|
|
});
|
|
|
|
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
|
const { item } = p as InboxNewPayload;
|
|
if (!item) return;
|
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
|
if (wsId) onInboxNew(qc, wsId, item);
|
|
});
|
|
|
|
// --- Side-effect handlers (toast, navigation) ---
|
|
|
|
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
|
|
const { workspace_id } = p as WorkspaceDeletedPayload;
|
|
const currentWs = useWorkspaceStore.getState().workspace;
|
|
if (currentWs?.id === workspace_id) {
|
|
logger.warn("current workspace deleted, switching");
|
|
toast.info("This workspace was deleted");
|
|
useWorkspaceStore.getState().refreshWorkspaces();
|
|
}
|
|
});
|
|
|
|
const unsubMemberRemoved = ws.on("member:removed", (p) => {
|
|
const { user_id } = p as MemberRemovedPayload;
|
|
const myUserId = useAuthStore.getState().user?.id;
|
|
if (user_id === myUserId) {
|
|
logger.warn("removed from workspace, switching");
|
|
toast.info("You were removed from this workspace");
|
|
useWorkspaceStore.getState().refreshWorkspaces();
|
|
}
|
|
});
|
|
|
|
const unsubMemberAdded = ws.on("member:added", (p) => {
|
|
const { member, workspace_name } = p as MemberAddedPayload;
|
|
const myUserId = useAuthStore.getState().user?.id;
|
|
if (member.user_id === myUserId) {
|
|
useWorkspaceStore.getState().refreshWorkspaces();
|
|
toast.info(
|
|
`You were invited to ${workspace_name ?? "a workspace"}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
unsubAny();
|
|
unsubIssueUpdated();
|
|
unsubIssueCreated();
|
|
unsubIssueDeleted();
|
|
unsubInboxNew();
|
|
unsubWsDeleted();
|
|
unsubMemberRemoved();
|
|
unsubMemberAdded();
|
|
timers.forEach(clearTimeout);
|
|
timers.clear();
|
|
};
|
|
}, [ws, qc]);
|
|
|
|
// Reconnect → refetch all data to recover missed events
|
|
useEffect(() => {
|
|
if (!ws) return;
|
|
|
|
const unsub = ws.onReconnect(async () => {
|
|
logger.info("reconnected, refetching all data");
|
|
try {
|
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
|
if (wsId) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
|
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
|
}
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
|
} catch (e) {
|
|
logger.error("reconnect refetch failed", e);
|
|
}
|
|
});
|
|
|
|
return unsub;
|
|
}, [ws, qc]);
|
|
}
|