From de8db4b7aaccd3337507aacfb93b1ec2f2ac42db Mon Sep 17 00:00:00 2001 From: BenSheridanEdwards Date: Thu, 28 May 2026 13:52:15 -0500 Subject: [PATCH] feat(api): add scoped multi-instance query client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds createScopedClient() — an openapi-fetch client bound to a specific Instance (rather than the active one in localStorage) — and the useScoped* TanStack Query hooks (workspaces, peers, peer representation, peer card) with per-instance query-key isolation so caches never collide across instances. Foundation for multi-instance features (seed kits, desktop auto-discover) that read from non-active instances. Co-authored-by: Ben Sheridan-Edwards --- packages/web/src/api/compareQueries.ts | 85 ++++++++++++++++++++++++++ packages/web/src/api/scopedClient.ts | 18 ++++++ 2 files changed, 103 insertions(+) create mode 100644 packages/web/src/api/compareQueries.ts create mode 100644 packages/web/src/api/scopedClient.ts diff --git a/packages/web/src/api/compareQueries.ts b/packages/web/src/api/compareQueries.ts new file mode 100644 index 0000000..d18407b --- /dev/null +++ b/packages/web/src/api/compareQueries.ts @@ -0,0 +1,85 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Instance } from "@/lib/config"; +import { createScopedClient } from "./scopedClient"; + +function err(e: unknown): never { + throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e)); +} + +// Query keys are scoped by instance.id so caches never collide across columns. +const CK = { + workspaces: (instId: string, page: number, size: number) => + ["compare", instId, "workspaces", page, size] as const, + peers: (instId: string, wsId: string, page: number, size: number) => + ["compare", instId, "peers", wsId, page, size] as const, + peerRepresentation: (instId: string, wsId: string, pId: string) => + ["compare", instId, "peer-representation", wsId, pId] as const, + peerCard: (instId: string, wsId: string, pId: string) => + ["compare", instId, "peer-card", wsId, pId] as const, +}; + +export function useScopedWorkspaces(instance: Instance, page = 1, pageSize = 20) { + return useQuery({ + queryKey: CK.workspaces(instance.id, page, pageSize), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.POST("/v3/workspaces/list", { + params: { query: { page, page_size: pageSize } }, + body: {}, + }); + return data ?? err(error); + }, + }); +} + +export function useScopedPeers(instance: Instance, workspaceId: string, page = 1, pageSize = 20) { + return useQuery({ + queryKey: CK.peers(instance.id, workspaceId, page, pageSize), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.POST("/v3/workspaces/{workspace_id}/peers/list", { + params: { path: { workspace_id: workspaceId }, query: { page, page_size: pageSize } }, + body: {}, + }); + return data ?? err(error); + }, + enabled: Boolean(workspaceId), + }); +} + +export function useScopedPeerRepresentation( + instance: Instance, + workspaceId: string, + peerId: string, +) { + return useQuery({ + queryKey: CK.peerRepresentation(instance.id, workspaceId, peerId), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.POST( + "/v3/workspaces/{workspace_id}/peers/{peer_id}/representation", + { + params: { path: { workspace_id: workspaceId, peer_id: peerId } }, + body: { max_conclusions: 20 }, + }, + ); + return data ?? err(error); + }, + enabled: Boolean(workspaceId) && Boolean(peerId), + }); +} + +export function useScopedPeerCard(instance: Instance, workspaceId: string, peerId: string) { + return useQuery({ + queryKey: CK.peerCard(instance.id, workspaceId, peerId), + queryFn: async () => { + const client = createScopedClient(instance); + const { data, error } = await client.GET( + "/v3/workspaces/{workspace_id}/peers/{peer_id}/card", + { params: { path: { workspace_id: workspaceId, peer_id: peerId } } }, + ); + return data ?? err(error); + }, + enabled: Boolean(workspaceId) && Boolean(peerId), + }); +} diff --git a/packages/web/src/api/scopedClient.ts b/packages/web/src/api/scopedClient.ts new file mode 100644 index 0000000..c37a9af --- /dev/null +++ b/packages/web/src/api/scopedClient.ts @@ -0,0 +1,18 @@ +import createClient from "openapi-fetch"; +import type { Instance } from "@/lib/config"; +import { httpFetch } from "@/lib/http"; +import type { paths } from "./schema.d.ts"; + +export type ScopedClient = ReturnType>; + +/** + * Create an openapi-fetch client bound to a specific instance. Use for views + * that need to query non-active instances (e.g. side-by-side comparison). + * For single-instance access, prefer `client.current` which tracks the active + * instance via localStorage. + */ +export function createScopedClient(instance: Instance): ScopedClient { + const headers: Record = { "Content-Type": "application/json" }; + if (instance.token) headers.Authorization = `Bearer ${instance.token}`; + return createClient({ baseUrl: instance.baseUrl, headers, fetch: httpFetch }); +}